generated from kevisual/vite-react-template
261 lines
8.1 KiB
TypeScript
261 lines
8.1 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { useCloudEnvStore } from './store'
|
|
import { useRepoStore } from '../repos/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,
|
|
Info
|
|
} from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
import { WorkspaceInfo } from '@kevisual/cnb'
|
|
import clsx from 'clsx'
|
|
import { WorkspaceDetailDialog } from '../repos/modules/WorkspaceDetailDialog'
|
|
import { useShallow } from 'zustand/shallow'
|
|
|
|
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: 'ssh', label: 'SSH', icon: <Lock className="w-5 h-5" />, getUrl: (d) => d.ssh },
|
|
]
|
|
|
|
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)
|
|
const repoStore = useRepoStore(useShallow((state) => ({
|
|
setShowWorkspaceDialog: state.setShowWorkspaceDialog,
|
|
setWorkspaceTab: state.setWorkspaceTab,
|
|
})))
|
|
|
|
useEffect(() => {
|
|
const fetchDetail = async () => {
|
|
setLoading(true)
|
|
const data = await getWorkspaceDetail(workspace)
|
|
setWorkspaceData(data)
|
|
setLoading(false)
|
|
}
|
|
fetchDetail()
|
|
}, [workspace, getWorkspaceDetail])
|
|
|
|
const handleShowDetail = async () => {
|
|
const data = await getWorkspaceDetail(workspace)
|
|
useRepoStore.setState({
|
|
selectWorkspace: workspace,
|
|
workspaceLink: data || {},
|
|
showWorkspaceDialog: true,
|
|
workspaceTab: 'dev'
|
|
})
|
|
}
|
|
|
|
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>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={handleShowDetail}
|
|
>
|
|
<Info className="w-4 h-4" />
|
|
</Button>
|
|
<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>
|
|
</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-2 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>
|
|
<WorkspaceDetailDialog />
|
|
</SidebarLayout>
|
|
)
|
|
} |