feat: 添加云端开发环境页面,支持 Jump、Trae、Windsurf 等 IDE 链接

- 新增 cloud-env 页面,展示运行中的云端开发环境
- 支持 Web IDE、VS Code、Cursor、Trae、Windsurf、Antigravity 等 IDE 链接
- 添加复制功能和点击跳转功能
- 更新 WorkspaceDetailDialog,添加对应 IDE 选项
- 更新侧边栏导航,添加"云端环境"入口

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
xiongxiao
2026-03-21 00:13:13 +08:00
committed by cnb
parent ef08303182
commit 477826dcce
6 changed files with 399 additions and 40 deletions

View File

@@ -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<string, string>
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: <ExternalLink className="w-5 h-5" />, getUrl: (d) => d.jumpUrl },
{ key: 'webide', label: 'Web IDE', icon: <Code2 className="w-5 h-5" />, getUrl: (d) => d.webide },
{ key: 'vscode', label: 'VS Code', icon: <Code2 className="w-5 h-5" />, getUrl: (d) => d.vscode },
{ key: 'cursor', label: 'Cursor', icon: <MousePointer2 className="w-5 h-5" />, getUrl: (d) => d.cursor },
{ key: 'trae-cn', label: 'Trae', icon: <Rocket className="w-5 h-5" />, getUrl: (d) => d['trae-cn'] },
{ key: 'windsurf', label: 'Windsurf', icon: <Wind className="w-5 h-5" />, getUrl: (d) => d.windsurf },
{ key: 'antigravity', label: 'Antigravity', icon: <Plane className="w-5 h-5" />, getUrl: (d) => d.antigravity },
{ key: 'ssh', label: 'SSH', icon: <Lock className="w-5 h-5" />, getUrl: (d) => d.ssh },
{ key: 'remoteSsh', label: 'Remote SSH', icon: <Radio className="w-5 h-5" />, getUrl: (d) => d.remoteSsh },
{ key: 'codebuddycn', label: 'CodeBuddy', icon: <Zap className="w-5 h-5" />, 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 (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div
onClick={handleClick}
className={clsx(
'flex items-center gap-2 p-2.5 rounded-lg border border-neutral-200 transition-all cursor-pointer',
'hover:border-neutral-900 hover:bg-neutral-50'
)}
>
<div className="text-neutral-700 shrink-0">{item.icon}</div>
<span className="text-sm font-medium text-neutral-900 truncate flex-1">{item.label}</span>
<button
onClick={handleCopy}
className="p-1 rounded hover:bg-neutral-100 shrink-0"
>
{copied ? <Check className="w-4 h-4 text-green-600" /> : <Copy className="w-4 h-4 text-neutral-400" />}
</button>
</div>
</TooltipTrigger>
<TooltipContent>
<p className="max-w-xs break-all">{url}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
function WorkspaceCard({ workspace, onStop }: { workspace: WorkspaceInfo; onStop: (ws: WorkspaceInfo) => void }) {
const [loading, setLoading] = useState(true)
const [workspaceData, setWorkspaceData] = useState<WorkspaceOpen | null>(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 (
<Card className="p-4 space-y-4 border border-neutral-200 bg-white">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<span className="relative flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500"></span>
</span>
<span className="font-medium text-neutral-900">{workspace.slug}</span>
</div>
{workspace.branch && (
<Badge variant="outline" className="text-xs">{workspace.branch}</Badge>
)}
</div>
<Button
size="sm"
variant="outline"
onClick={() => onStop(workspace)}
className="text-red-600 border-red-200 hover:bg-red-600 hover:text-white"
>
<Square className="w-4 h-4 mr-1" />
</Button>
</div>
{loading ? (
<div className="grid grid-cols-4 gap-2">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="h-12 bg-neutral-100 rounded-lg animate-pulse" />
))}
</div>
) : workspaceData ? (
<div className="grid grid-cols-4 gap-2">
{linkItems.map((item) => (
<LinkCard key={item.key} item={item} workspaceData={workspaceData} />
))}
</div>
) : (
<div className="text-center text-neutral-400 py-4"></div>
)}
</Card>
)
}
export default function CloudEnvPage() {
const { workspaceList, loading, getWorkspaceList, stopWorkspace } = useCloudEnvStore()
useEffect(() => {
getWorkspaceList()
}, [getWorkspaceList])
return (
<SidebarLayout>
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-neutral-900"></h1>
<p className="text-neutral-500 mt-1"></p>
</div>
<Button
variant="outline"
onClick={() => getWorkspaceList()}
disabled={loading}
>
<RefreshCw className={clsx('w-4 h-4 mr-2', loading && 'animate-spin')} />
</Button>
</div>
{loading && workspaceList.length === 0 ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-40" />
))}
</div>
) : workspaceList.length === 0 ? (
<Card className="p-12 text-center border border-neutral-200">
<div className="text-neutral-400 mb-4">
<Terminal className="w-12 h-12 mx-auto mb-4" />
<p className="text-lg font-medium"></p>
<p className="text-sm mt-1"></p>
</div>
</Card>
) : (
<div className="grid gap-4">
{workspaceList.map((workspace) => (
<WorkspaceCard
key={workspace.sn}
workspace={workspace}
onStop={stopWorkspace}
/>
))}
</div>
)}
</div>
</SidebarLayout>
)
}

View File

@@ -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<string, string>
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<void>
getWorkspaceDetail: (data: WorkspaceInfo) => Promise<WorkspaceOpen | null>
stopWorkspace: (workspace: WorkspaceInfo) => Promise<void>
}
export const useCloudEnvStore = create<State>((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<WorkspaceOpen | null> => {
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('停止失败')
}
}
}))

View File

@@ -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: <ExternalLink className="w-5 h-5" />,
order: 1,
getUrl: (data) => data.jumpUrl
},
{
key: 'webide' as LinkItemKey,
label: 'Web IDE',
icon: <Code2 className="w-5 h-5" />,
order: 1,
order: 2,
getUrl: (data) => data.webide
},
{
key: 'vscode' as LinkItemKey,
label: 'VS Code',
icon: <Code2 className="w-5 h-5" />,
order: 2,
order: 3,
getUrl: (data) => data.vscode
},
{
key: 'vscode-insiders' as LinkItemKey,
label: 'VS Code Insiders',
icon: <Sparkles className="w-5 h-5" />,
order: 5,
getUrl: (data) => data['vscode-insiders']
},
{
key: 'cursor' as LinkItemKey,
label: 'Cursor',
icon: <MousePointer2 className="w-5 h-5" />,
order: 6,
order: 4,
getUrl: (data) => data.cursor
},
{
key: 'jetbrains' as LinkItemKey,
label: 'JetBrains IDEs',
icon: <Box className="w-5 h-5" />,
key: 'trae-cn' as LinkItemKey,
label: 'Trae',
icon: <Rocket className="w-5 h-5" />,
order: 5,
getUrl: (data) => data['trae-cn']
},
{
key: 'windsurf' as LinkItemKey,
label: 'Windsurf',
icon: <Wind className="w-5 h-5" />,
order: 6,
getUrl: (data) => data.windsurf
},
{
key: 'antigravity' as LinkItemKey,
label: 'Antigravity',
icon: <Plane className="w-5 h-5" />,
order: 7,
getUrl: (data) => Object.values(data.jetbrains || {}).find(Boolean)
getUrl: (data) => data.antigravity
},
{
key: 'ssh' as LinkItemKey,
label: 'SSH',
icon: <Lock className="w-5 h-5" />,
order: 4,
order: 9,
getUrl: (data) => data.ssh
},
{
key: 'remoteSsh' as LinkItemKey,
label: 'Remote SSH',
icon: <Radio className="w-5 h-5" />,
order: 8,
order: 10,
getUrl: (data) => data.remoteSsh
},
{
key: 'codebuddy' as LinkItemKey,
label: 'CodeBuddy',
icon: <Bot className="w-5 h-5" />,
order: 9,
getUrl: (data) => data.codebuddy
},
{
key: 'codebuddycn' as LinkItemKey,
label: 'CodeBuddy CN',
label: 'CodeBuddy',
icon: <Zap className="w-5 h-5" />,
order: 3,
order: 11,
getUrl: (data) => data.codebuddycn
},
].sort((a, b) => (a.order || 0) - (b.order || 0))

View File

@@ -546,16 +546,22 @@ export const useRepoStore = create<State>((set, get) => {
})
export type WorkspaceOpen = {
codebuddy: string;
codebuddycn: string;
cursor: string;
jetbrains: Record<string, string>;
jumpUrl: string;
remoteSsh: string;
ssh: string;
vscode: string;
'vscode-insiders': string;
webide: string;
url?: string
webide?: string
jumpUrl?: string
remoteSsh?: string
jetbrains?: Record<string, string>
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;

View File

@@ -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: <FolderKanban className="w-5 h-5" />,
},
{
title: '云端环境',
path: '/cloud-env',
icon: <Cloud className="w-5 h-5" />,
},
{
title: '工作空间',
path: '/workspaces',

View File

@@ -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 <App />
}