Files
cnb-center/src/pages/repos/modules/WorkspaceDetailDialog.tsx
xiongxiao 477826dcce 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>
2026-03-21 00:13:13 +08:00

363 lines
11 KiB
TypeScript

import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
TooltipProvider
} from '@/components/ui/tooltip'
import { useRepoStore } from '../store'
import type { WorkspaceOpen } from '../store'
import {
Code2,
Terminal,
MousePointer2,
Lock,
Radio,
Zap,
Copy,
Check,
Square,
Link,
ExternalLink,
Wind,
Plane,
Rocket
} from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { useShallow } from 'zustand/shallow'
import clsx from 'clsx'
type LinkItemKey = keyof WorkspaceOpen;
interface LinkItem {
key: LinkItemKey
label: string
icon: React.ReactNode
order?: number
getUrl: (data: Partial<WorkspaceOpen>) => string | undefined
}
const LinkItem = ({ label, icon, url }: { label: string; icon: React.ReactNode; url?: string }) => {
const [isCopied, setIsCopied] = useState(false)
const handleClick = () => {
if (url?.startsWith?.('ssh') || url?.startsWith?.('cnb')) {
copy()
return;
}
if (url && url.includes(':')) {
window.open(url, '_blank')
}
}
const copy = async () => {
try {
await navigator.clipboard.writeText(url!)
setIsCopied(true)
toast.success('已复制到剪贴板')
setTimeout(() => setIsCopied(false), 2000)
} catch (error) {
toast.error('复制失败')
}
}
return (
<TooltipProvider delay={200}>
<Tooltip>
<TooltipTrigger>
<div
onClick={handleClick}
className="relative flex items-center gap-3 p-3 rounded-lg border border-neutral-200 hover:border-neutral-900 hover:bg-neutral-50 transition-all disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:border-neutral-200 disabled:hover:bg-transparent group"
>
<div className="w-8 h-8 flex items-center justify-center text-neutral-700">
{icon}
</div>
<span className="text-sm font-medium text-neutral-900 flex-1 text-left truncate">{label}</span>
{url && (
<div
onClick={(e) => {
e.stopPropagation()
copy()
}}
role="button"
className="w-6 h-6 flex items-center justify-center text-neutral-500 hover:text-neutral-900 hover:bg-neutral-100 rounded transition-colors cursor-pointer"
>
{isCopied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</div>
)}
</div>
</TooltipTrigger>
<TooltipContent>
<p>{url}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
// Dev tab 内容
const DevTabContent = ({ linkItems, workspaceLink, stopWorkspace }: {
linkItems: LinkItem[]
workspaceLink: Partial<WorkspaceOpen>
stopWorkspace: () => void
}) => {
return (
<>
<button
onClick={() => stopWorkspace()}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-red-500 hover:bg-red-600 text-white font-medium transition-colors"
>
<Square className="w-4 h-4" />
</button>
<div className="grid grid-cols-2 gap-3 mt-2">
{linkItems.map((item) => (
<LinkItem
key={item.key}
label={item.label}
icon={item.icon}
url={item.getUrl(workspaceLink)}
/>
))}
</div>
</>
)
}
// Link tab 内容(暂留空)
const LinkTabContent = () => {
const store = useRepoStore(useShallow((state) => ({
selectWorkspace: state.selectWorkspace,
workspaceSecretLink: state.workspaceSecretLink,
})))
const links = store.workspaceSecretLink.map(item => ({
label: item.title,
url: item.value
}))
if (links.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-8 text-neutral-400">
,
</div>
)
}
return (
<div className="flex flex-col items-center justify-center py-8 text-neutral-400">
<div className="grid grid-cols-1 gap-3 w-full max-w-sm">
{links.map(link => (
<LinkItem key={link.label} label={link.label} icon={<Link className="w-5 h-5" />} url={link.url} />
))}
</div>
</div>
)
}
// Work tab 内容(暂留,需要根据 business_id 做事情)
const WorkTabContent = () => {
const store = useRepoStore(useShallow((state) => ({
selectWorkspace: state.selectWorkspace,
workspaceLink: state.workspaceLink,
})))
const businessId = store.selectWorkspace?.business_id;
const appList = [
{
title: 'Kevisual Assistant Client', key: 'Assistant Client', port: 51515, end: '/root/cli-center/'
},
{
title: 'OpenCode', key: 'OpenCode', port: 100, end: ''
},
{
title: 'OpenClaw', key: 'OpenClaw', port: 80, end: '/openclaw'
},
{
key: 'vscode' as LinkItemKey,
title: 'VS Code',
icon: <Code2 className="w-5 h-5" />,
},
{
title: 'OpenWebUI', key: 'OpenWebUI', port: 200, end: ''
},
]
const links = appList.map(app => {
if (app.icon) {
return {
label: app.title,
icon: app.icon,
url: store?.workspaceLink?.[app.key as LinkItemKey] as string | undefined
}
}
const url = `https://${businessId}-${app.port}.cnb.run${app.end}`
return {
label: app.title,
icon: <Terminal className="w-5 h-5" />,
url
}
})
return (
<div className="flex flex-col items-center justify-center py-2 text-neutral-400">
<div className="grid grid-cols-1 gap-3 w-full max-w-sm">
{links.map(link => (
<LinkItem key={link.label} label={link.label} icon={link.icon} url={link.url} />
))}
</div>
</div>
)
}
export function WorkspaceDetailDialog() {
const { showWorkspaceDialog, setShowWorkspaceDialog, workspaceLink, stopWorkspace, workspaceTab, setWorkspaceTab, getWorkspaceSecretLink, selectWorkspace, workspaceSecretLink } = useRepoStore(useShallow((state) => ({
showWorkspaceDialog: state.showWorkspaceDialog,
setShowWorkspaceDialog: state.setShowWorkspaceDialog,
workspaceLink: state.workspaceLink,
stopWorkspace: state.stopWorkspace,
workspaceTab: state.workspaceTab,
setWorkspaceTab: state.setWorkspaceTab,
selectWorkspace: state.selectWorkspace,
getWorkspaceSecretLink: state.getWorkspaceSecretLink,
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: 2,
getUrl: (data) => data.webide
},
{
key: 'vscode' as LinkItemKey,
label: 'VS Code',
icon: <Code2 className="w-5 h-5" />,
order: 3,
getUrl: (data) => data.vscode
},
{
key: 'cursor' as LinkItemKey,
label: 'Cursor',
icon: <MousePointer2 className="w-5 h-5" />,
order: 4,
getUrl: (data) => data.cursor
},
{
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) => data.antigravity
},
{
key: 'ssh' as LinkItemKey,
label: 'SSH',
icon: <Lock className="w-5 h-5" />,
order: 9,
getUrl: (data) => data.ssh
},
{
key: 'remoteSsh' as LinkItemKey,
label: 'Remote SSH',
icon: <Radio className="w-5 h-5" />,
order: 10,
getUrl: (data) => data.remoteSsh
},
{
key: 'codebuddycn' as LinkItemKey,
label: 'CodeBuddy',
icon: <Zap className="w-5 h-5" />,
order: 11,
getUrl: (data) => data.codebuddycn
},
].sort((a, b) => (a.order || 0) - (b.order || 0))
useEffect(() => {
if (selectWorkspace) {
getWorkspaceSecretLink(selectWorkspace)
}
}, [selectWorkspace])
return (
<Dialog open={showWorkspaceDialog} onOpenChange={setShowWorkspaceDialog}>
<DialogContent className="max-w-md! bg-white">
<DialogHeader>
<DialogTitle className="text-neutral-900"></DialogTitle>
</DialogHeader>
{/* Tab 导航 */}
<div className="flex border-b border-neutral-200">
<button
onClick={() => setWorkspaceTab('dev')}
className={`cursor-pointer flex-1 px-4 py-3 text-sm font-medium transition-colors relative ${workspaceTab === 'dev'
? 'text-neutral-900'
: 'text-neutral-500 hover:text-neutral-700'
}`}
>
Dev
{workspaceTab === 'dev' && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-neutral-900" />
)}
</button>
<button
onClick={() => setWorkspaceTab('work')}
className={`cursor-pointer flex-1 px-4 py-3 text-sm font-medium transition-colors relative ${workspaceTab === 'work'
? 'text-neutral-900'
: 'text-neutral-500 hover:text-neutral-700'
}`}
>
Work
{workspaceTab === 'work' && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-neutral-900" />
)}
</button>
<button
onClick={() => setWorkspaceTab('link')}
className={clsx(`cursor-pointer flex-1 px-4 py-3 text-sm font-medium transition-colors relative ${workspaceTab === 'link'
? 'text-neutral-900'
: 'text-neutral-500 hover:text-neutral-700'
}`)}
>
<Link className="w-4 h-4 inline-block mr-1" />
Link
{workspaceTab === 'link' && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-neutral-900" />
)}
</button>
</div>
{/* Tab 内容 */}
<div className="py-2">
{workspaceTab === 'dev' && (
<DevTabContent linkItems={linkItems} workspaceLink={workspaceLink} stopWorkspace={stopWorkspace} />
)}
{workspaceTab === 'link' && (
<LinkTabContent />
)}
{workspaceTab === 'work' && (
<WorkTabContent />
)}
</div>
</DialogContent>
</Dialog>
)
}