From 477826dcce2a24fb9e7e798a454af33d2b91577a Mon Sep 17 00:00:00 2001 From: xiongxiao Date: Sat, 21 Mar 2026 00:13:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E4=BA=91=E7=AB=AF?= =?UTF-8?q?=E5=BC=80=E5=8F=91=E7=8E=AF=E5=A2=83=E9=A1=B5=E9=9D=A2=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=20Jump=E3=80=81Trae=E3=80=81Windsurf=20?= =?UTF-8?q?=E7=AD=89=20IDE=20=E9=93=BE=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 cloud-env 页面,展示运行中的云端开发环境 - 支持 Web IDE、VS Code、Cursor、Trae、Windsurf、Antigravity 等 IDE 链接 - 添加复制功能和点击跳转功能 - 更新 WorkspaceDetailDialog,添加对应 IDE 选项 - 更新侧边栏导航,添加"云端环境"入口 Co-Authored-By: Claude Opus 4.6 --- src/pages/cloud-env/page.tsx | 239 ++++++++++++++++++ src/pages/cloud-env/store/index.ts | 91 +++++++ .../repos/modules/WorkspaceDetailDialog.tsx | 66 ++--- src/pages/repos/store/index.ts | 26 +- src/pages/sidebar/components/Sidebar.tsx | 7 +- src/routes/cloud-env/index.tsx | 10 + 6 files changed, 399 insertions(+), 40 deletions(-) create mode 100644 src/pages/cloud-env/page.tsx create mode 100644 src/pages/cloud-env/store/index.ts create mode 100644 src/routes/cloud-env/index.tsx diff --git a/src/pages/cloud-env/page.tsx b/src/pages/cloud-env/page.tsx new file mode 100644 index 0000000..30d555a --- /dev/null +++ b/src/pages/cloud-env/page.tsx @@ -0,0 +1,239 @@ +import { useEffect, useState } from 'react' +import { useCloudEnvStore } from './store' +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { SidebarLayout } from '../sidebar/components' +import { Skeleton } from '@/components/ui/skeleton' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { + Code2, + Terminal, + MousePointer2, + Lock, + Radio, + Zap, + Square, + RefreshCw, + Copy, + Check, + Wind, + Plane, + Rocket, + ExternalLink +} from 'lucide-react' +import { toast } from 'sonner' +import { WorkspaceInfo } from '@kevisual/cnb' +import clsx from 'clsx' + +type WorkspaceOpen = { + url?: string + webide?: string + jumpUrl?: string + remoteSsh?: string + jetbrains?: Record + codebuddy?: string + codebuddycn?: string + vscode?: string + cursor?: string + 'vscode-insiders'?: string + trae?: string + 'trae-cn'?: string + windsurf?: string + 'windsurf-next'?: string + antigravity?: string + ssh?: string +} + +interface LinkItem { + key: string + label: string + icon: React.ReactNode + getUrl: (data: WorkspaceOpen) => string | undefined +} + +const linkItems: LinkItem[] = [ + { key: 'jumpUrl', label: 'Jump', icon: , getUrl: (d) => d.jumpUrl }, + { key: 'webide', label: 'Web IDE', icon: , getUrl: (d) => d.webide }, + { key: 'vscode', label: 'VS Code', icon: , getUrl: (d) => d.vscode }, + { key: 'cursor', label: 'Cursor', icon: , getUrl: (d) => d.cursor }, + { key: 'trae-cn', label: 'Trae', icon: , getUrl: (d) => d['trae-cn'] }, + { key: 'windsurf', label: 'Windsurf', icon: , getUrl: (d) => d.windsurf }, + { key: 'antigravity', label: 'Antigravity', icon: , getUrl: (d) => d.antigravity }, + { key: 'ssh', label: 'SSH', icon: , getUrl: (d) => d.ssh }, + { key: 'remoteSsh', label: 'Remote SSH', icon: , getUrl: (d) => d.remoteSsh }, + { key: 'codebuddycn', label: 'CodeBuddy', icon: , getUrl: (d) => d.codebuddycn }, +] + +function LinkCard({ item, workspaceData }: { item: LinkItem; workspaceData: WorkspaceOpen }) { + const url = item.getUrl(workspaceData) + const [copied, setCopied] = useState(false) + if (!url) return null + + const handleClick = () => { + if (url.startsWith('ssh') || url.startsWith('cnb')) { + return + } + window.open(url, '_blank') + } + + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation() + try { + await navigator.clipboard.writeText(url) + setCopied(true) + toast.success('已复制') + setTimeout(() => setCopied(false), 2000) + } catch { + toast.error('复制失败') + } + } + + return ( + + + +
+
{item.icon}
+ {item.label} + +
+
+ +

{url}

+
+
+
+ ) +} + +function WorkspaceCard({ workspace, onStop }: { workspace: WorkspaceInfo; onStop: (ws: WorkspaceInfo) => void }) { + const [loading, setLoading] = useState(true) + const [workspaceData, setWorkspaceData] = useState(null) + const getWorkspaceDetail = useCloudEnvStore((state) => state.getWorkspaceDetail) + + useEffect(() => { + const fetchDetail = async () => { + setLoading(true) + const data = await getWorkspaceDetail(workspace) + setWorkspaceData(data) + setLoading(false) + } + fetchDetail() + }, [workspace, getWorkspaceDetail]) + + return ( + +
+
+
+ + + + + {workspace.slug} +
+ {workspace.branch && ( + {workspace.branch} + )} +
+ +
+ + {loading ? ( +
+ {[1, 2, 3, 4].map((i) => ( +
+ ))} +
+ ) : workspaceData ? ( +
+ {linkItems.map((item) => ( + + ))} +
+ ) : ( +
暂无链接信息
+ )} + + ) +} + +export default function CloudEnvPage() { + const { workspaceList, loading, getWorkspaceList, stopWorkspace } = useCloudEnvStore() + + useEffect(() => { + getWorkspaceList() + }, [getWorkspaceList]) + + return ( + +
+
+
+

云端开发环境

+

当前运行中的云端开发环境

+
+ +
+ + {loading && workspaceList.length === 0 ? ( +
+ {[1, 2, 3].map((i) => ( + + ))} +
+ ) : workspaceList.length === 0 ? ( + +
+ +

暂无运行中的工作区

+

在仓库管理页面启动工作区即可在此查看

+
+
+ ) : ( +
+ {workspaceList.map((workspace) => ( + + ))} +
+ )} +
+
+ ) +} diff --git a/src/pages/cloud-env/store/index.ts b/src/pages/cloud-env/store/index.ts new file mode 100644 index 0000000..5f74355 --- /dev/null +++ b/src/pages/cloud-env/store/index.ts @@ -0,0 +1,91 @@ +import { create } from 'zustand' +import { toast } from 'sonner' +import { queryApi as cnbApi } from '@/modules/cnb-api' +import { WorkspaceInfo } from '@kevisual/cnb' + +type WorkspaceOpen = { + url?: string + webide?: string + jumpUrl?: string + remoteSsh?: string + jetbrains?: Record + codebuddy?: string + codebuddycn?: string + vscode?: string + cursor?: string + 'vscode-insiders'?: string + trae?: string + 'trae-cn'?: string + windsurf?: string + 'windsurf-next'?: string + antigravity?: string + ssh?: string +} + +type State = { + workspaceList: WorkspaceInfo[] + loading: boolean + getWorkspaceList: () => Promise + getWorkspaceDetail: (data: WorkspaceInfo) => Promise + stopWorkspace: (workspace: WorkspaceInfo) => Promise +} + +export const useCloudEnvStore = create((set, get) => ({ + workspaceList: [], + loading: false, + getWorkspaceList: async () => { + set({ loading: true }) + try { + const res = await cnbApi.cnb['list-workspace']({ + status: 'running', + pageSize: 100 + }) + if (res.code === 200) { + const list: WorkspaceInfo[] = res.data?.list || [] + set({ workspaceList: list }) + } else { + toast.error(res.message || '请求失败') + } + } catch (error) { + console.error('获取工作区列表失败:', error) + toast.error('获取工作区列表失败') + } finally { + set({ loading: false }) + } + }, + getWorkspaceDetail: async (workspaceInfo: WorkspaceInfo): Promise => { + try { + const res = await cnbApi.cnb['get-workspace']({ + repo: workspaceInfo.slug, + sn: workspaceInfo.sn + }) as any + if (res.code === 200) { + return res.data + } + return null + } catch (error) { + console.error('获取工作区详情失败:', error) + return null + } + }, + stopWorkspace: async (workspace: WorkspaceInfo) => { + const sn = workspace.sn + if (!sn) { + toast.error('工作区 SN 不存在') + return + } + try { + const res = await cnbApi.cnb['stop-workspace']({ sn }) + if (res?.code === 200) { + toast.success('工作区已停止') + // 刷新列表 + await get().getWorkspaceList() + } else { + toast.error(res.message || '停止失败') + } + } catch (error) { + console.error('停止工作区失败:', error) + toast.error('停止失败') + } + } +})) diff --git a/src/pages/repos/modules/WorkspaceDetailDialog.tsx b/src/pages/repos/modules/WorkspaceDetailDialog.tsx index 696ac0a..c6dc07b 100644 --- a/src/pages/repos/modules/WorkspaceDetailDialog.tsx +++ b/src/pages/repos/modules/WorkspaceDetailDialog.tsx @@ -15,17 +15,18 @@ import type { WorkspaceOpen } from '../store' import { Code2, Terminal, - Sparkles, MousePointer2, - Box, Lock, Radio, - Bot, Zap, Copy, Check, Square, - Link + Link, + ExternalLink, + Wind, + Plane, + Rocket } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' @@ -221,67 +222,74 @@ export function WorkspaceDetailDialog() { workspaceSecretLink: state.workspaceSecretLink }))) const linkItems: LinkItem[] = [ + { + key: 'jumpUrl' as LinkItemKey, + label: 'Jump', + icon: , + order: 1, + getUrl: (data) => data.jumpUrl + }, { key: 'webide' as LinkItemKey, label: 'Web IDE', icon: , - order: 1, + order: 2, getUrl: (data) => data.webide }, { key: 'vscode' as LinkItemKey, label: 'VS Code', icon: , - order: 2, + order: 3, getUrl: (data) => data.vscode }, - { - key: 'vscode-insiders' as LinkItemKey, - label: 'VS Code Insiders', - icon: , - order: 5, - getUrl: (data) => data['vscode-insiders'] - }, { key: 'cursor' as LinkItemKey, label: 'Cursor', icon: , - order: 6, + order: 4, getUrl: (data) => data.cursor }, { - key: 'jetbrains' as LinkItemKey, - label: 'JetBrains IDEs', - icon: , + key: 'trae-cn' as LinkItemKey, + label: 'Trae', + icon: , + order: 5, + getUrl: (data) => data['trae-cn'] + }, + { + key: 'windsurf' as LinkItemKey, + label: 'Windsurf', + icon: , + order: 6, + getUrl: (data) => data.windsurf + }, + { + key: 'antigravity' as LinkItemKey, + label: 'Antigravity', + icon: , order: 7, - getUrl: (data) => Object.values(data.jetbrains || {}).find(Boolean) + getUrl: (data) => data.antigravity }, { key: 'ssh' as LinkItemKey, label: 'SSH', icon: , - order: 4, + order: 9, getUrl: (data) => data.ssh }, { key: 'remoteSsh' as LinkItemKey, label: 'Remote SSH', icon: , - order: 8, + order: 10, getUrl: (data) => data.remoteSsh }, - { - key: 'codebuddy' as LinkItemKey, - label: 'CodeBuddy', - icon: , - order: 9, - getUrl: (data) => data.codebuddy - }, { key: 'codebuddycn' as LinkItemKey, - label: 'CodeBuddy CN', + label: 'CodeBuddy', icon: , - order: 3, + order: 11, getUrl: (data) => data.codebuddycn }, ].sort((a, b) => (a.order || 0) - (b.order || 0)) diff --git a/src/pages/repos/store/index.ts b/src/pages/repos/store/index.ts index 3b38145..4a265a7 100644 --- a/src/pages/repos/store/index.ts +++ b/src/pages/repos/store/index.ts @@ -546,16 +546,22 @@ export const useRepoStore = create((set, get) => { }) export type WorkspaceOpen = { - codebuddy: string; - codebuddycn: string; - cursor: string; - jetbrains: Record; - jumpUrl: string; - remoteSsh: string; - ssh: string; - vscode: string; - 'vscode-insiders': string; - webide: string; + url?: string + webide?: string + jumpUrl?: string + remoteSsh?: string + jetbrains?: Record + codebuddy?: string + codebuddycn?: string + vscode?: string + cursor?: string + 'vscode-insiders'?: string + trae?: string + 'trae-cn'?: string + windsurf?: string + 'windsurf-next'?: string + antigravity?: string + ssh?: string } const openWorkspace = (workspace: WorkspaceInfo, params: { vscode?: boolean, ssh?: boolean }) => { const openVsCode = params?.vscode ?? true; diff --git a/src/pages/sidebar/components/Sidebar.tsx b/src/pages/sidebar/components/Sidebar.tsx index 329843d..c9575ba 100644 --- a/src/pages/sidebar/components/Sidebar.tsx +++ b/src/pages/sidebar/components/Sidebar.tsx @@ -1,4 +1,4 @@ -import { FolderKanban, LayoutDashboard, Settings, PlayCircle } from 'lucide-react' +import { FolderKanban, LayoutDashboard, Settings, PlayCircle, Cloud } from 'lucide-react' import { Sidebar, type NavItem } from '@/components/a/Sidebar' import { Logo } from './CNBBlackLogo.tsx' @@ -8,6 +8,11 @@ const navItems: NavItem[] = [ path: '/', icon: , }, + { + title: '云端环境', + path: '/cloud-env', + icon: , + }, { title: '工作空间', path: '/workspaces', diff --git a/src/routes/cloud-env/index.tsx b/src/routes/cloud-env/index.tsx new file mode 100644 index 0000000..1ab1c68 --- /dev/null +++ b/src/routes/cloud-env/index.tsx @@ -0,0 +1,10 @@ +import { createFileRoute } from '@tanstack/react-router' +import App from '@/pages/cloud-env/page' + +export const Route = createFileRoute('/cloud-env/')({ + component: RouteComponent, +}) + +function RouteComponent() { + return +}