Compare commits

..

6 Commits

Author SHA1 Message Date
xiongxiao
b0acbbf337 feat: 添加制品中心页面及相关组件,支持创建和编辑制品功能;更新README文档 2026-03-23 18:45:34 +08:00
xiongxiao
482206129f docs: 更新README功能列表 2026-03-22 14:09:53 +08:00
xiongxiao
dd100fd7ef feat: 更新依赖版本,添加PWA更新组件,优化Sidebar组件,增加footer支持 2026-03-22 13:54:30 +08:00
xiongxiao
518a3f2864 feat: 更新README,添加云开发(cloud-dev)说明;新增Skeleton组件 2026-03-21 00:42:26 +08:00
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
xiongxiao
ef08303182 feat: 优化Sidebar组件,支持onClick回调和外部链接导航;更新repos页面,持久化开发者过滤器状态 2026-03-20 23:00:02 +08:00
27 changed files with 1336 additions and 116 deletions

View File

@@ -1,12 +1,57 @@
# cnb center
一个应用工作台
一个应用工作台
cnb center 是对 cnb 的部分操作的可视化管理,是对 cnb 功能的补充,核心使用还是需要去使用 cnb。
## 功能
1. 对话管理
2. 仓库管理
3. 云开发
4. 应用管理
5. Agent管理
6. 配置
2. 公共资源
3. 仓库管理
4. 云端环境(cloud-env)
5. 我的应用
- NPC
- Agent 管理
- 任务管理
6. 其他
- 制品中心
- 历史记录
- 配置
## 对话管理功能
列出对应的知识库,知识库是可以动态添加的, 点击后对话应用会加载对应的知识库进行对话,对话过程可以执行实际的任务, 例如调用接口,执行命令等。
配置routes转为指令
## 公共资源
分享的快速启动的程序应用宝库技能对话npcagent 等等。
## 仓库管理
基本的仓库管理,添加和编辑仓库,启动云段环境。
## 云端环境(cloud-env)
单独的云端环境管理,提供云端环境的停止,查看。
## 我的应用
任务管理
## 其他
### 制品中心
添加对应的仓库,查看对应的制品。对 docker 镜像支持快速配置同步到制品中心。
### 配置
因为调用 cnb 需要一些配置,例如 token和 cookie等提供一个界面来配置这些内容方便使用。
### 历史记录
对话历史记录,任务执行历史记录等。

View File

@@ -17,19 +17,19 @@
"author": "abearxiong <xiongxiao@xiongxiao.me>",
"license": "MIT",
"dependencies": {
"@ai-sdk/anthropic": "^3.0.58",
"@ai-sdk/openai": "^3.0.41",
"@ai-sdk/openai-compatible": "^2.0.35",
"@ai-sdk/anthropic": "^3.0.63",
"@ai-sdk/openai": "^3.0.47",
"@ai-sdk/openai-compatible": "^2.0.37",
"@base-ui/react": "^1.3.0",
"@kevisual/api": "^0.0.64",
"@kevisual/cnb": "^0.0.52",
"@kevisual/api": "^0.0.65",
"@kevisual/cnb": "^0.0.59",
"@kevisual/cnb-ai": "^0.0.2",
"@kevisual/context": "^0.0.8",
"@kevisual/kv-login": "^0.1.18",
"@kevisual/router": "0.1.3",
"@tanstack/react-router": "^1.167.4",
"@kevisual/router": "0.1.6",
"@tanstack/react-router": "^1.168.1",
"@uiw/react-codemirror": "^4.25.8",
"ai": "^6.0.116",
"ai": "^6.0.134",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -43,7 +43,7 @@
"re-resizable": "^6.11.2",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.71.2",
"react-hook-form": "^7.72.0",
"react-resizable": "^3.1.3",
"sonner": "^2.0.7",
"vite-plugin-pwa": "^1.2.0",
@@ -56,12 +56,12 @@
"devDependencies": {
"@codemirror/lang-yaml": "^6.1.2",
"@kevisual/gitea": "^0.0.6",
"@kevisual/query": "0.0.53",
"@kevisual/query": "0.0.55",
"@kevisual/types": "^0.0.12",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-router-devtools": "^1.166.9",
"@tanstack/router-plugin": "^1.166.13",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/react-query": "^5.94.5",
"@tanstack/react-router-devtools": "^1.166.10",
"@tanstack/router-plugin": "^1.167.2",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -69,9 +69,9 @@
"@vitejs/plugin-react": "^6.0.1",
"dotenv": "^17.3.1",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"tailwindcss": "^4.2.2",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vite": "^8.0.0"
"vite": "^8.0.1"
}
}

3
plan.md Normal file
View File

@@ -0,0 +1,3 @@
# cnb-center
是对 cnb 的部分操作的可视化管理,是对 cnb 功能的补充,核心使用还是需要去使用 cnb。

View File

@@ -22,20 +22,43 @@
},
"sync": {
"AGENTS.md": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/AGENTS.md",
"tsconfig.json": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/tsconfig.json",
"vite.config.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/vite.config.ts",
"src/main.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/main.tsx",
"public/auth.json": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/public/auth.json",
"src/agents/index.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/agents/index.ts",
"src/modules/basename.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/modules/basename.ts",
"public/demo.html": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/public/demo.html",
"src/modules/query.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/modules/query.ts",
"src/routes/demo.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/routes/demo.tsx",
"src/routes/index.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/routes/index.tsx",
"src/routes/login.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/routes/login.tsx",
"src/styles/theme.css": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/styles/theme.css",
"src/components/a/PWAUpdate.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/a/PWAUpdate.tsx",
"src/components/a/Sidebar.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/a/Sidebar.tsx",
"src/components/ui/badge.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/badge.tsx",
"src/components/ui/breadcrumb.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/breadcrumb.tsx",
"src/components/ui/button.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/button.tsx",
"src/components/ui/card.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/card.tsx",
"src/components/ui/checkbox.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/checkbox.tsx",
"src/components/ui/command.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/command.tsx",
"src/components/ui/dialog.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/dialog.tsx",
"src/components/ui/dropdown-menu.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/dropdown-menu.tsx",
"src/components/ui/input-group.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/input-group.tsx",
"src/components/ui/input.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/input.tsx",
"src/components/ui/kbd.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/kbd.tsx",
"src/components/ui/label.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/label.tsx",
"src/components/ui/menubar.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/menubar.tsx",
"src/components/ui/popover.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/popover.tsx",
"src/components/ui/select.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/select.tsx",
"src/components/ui/sheet.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/sheet.tsx",
"src/components/ui/skeleton.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/skeleton.tsx",
"src/components/ui/sonner.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/sonner.tsx",
"src/components/ui/tabs.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/tabs.tsx",
"src/components/ui/textarea.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/textarea.tsx",
"src/components/ui/tooltip.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/components/ui/tooltip.tsx",
"src/pages/auth/index.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/auth/index.tsx",
"src/pages/auth/page.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/auth/page.tsx",
"src/pages/auth/store.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/auth/store.ts",
"src/pages/demo/page.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/demo/page.tsx",
"src/agents/app.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/agents/app.ts",
"src/pages/auth/hooks/index.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/auth/hooks/index.ts",
"src/pages/auth/hooks/use-api-query.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/auth/hooks/use-api-query.ts",
"src/pages/auth/modules/BaseHeader.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/auth/modules/BaseHeader.tsx",

View File

@@ -1,24 +1,3 @@
import { QueryRouterServer } from '@kevisual/router/browser'
import { QueryRouterServer } from "@kevisual/router/browser"
import { useContextKey } from '@kevisual/context'
import { useGiteaConfigStore } from '@/pages/config/gitea/store'
import { Gitea } from '@kevisual/gitea';
export const app = useContextKey('app', new QueryRouterServer())
// import '@kevisual/cnb-ai'
const url = 'https://kevisual.cn/root/cnb-ai/dist/app.js'
setTimeout(() => {
import(/* @vite-ignore */url)
}, 2000)
export const gitea = useContextKey('gitea', () => {
const state = useGiteaConfigStore.getState()
const config = state.config || {}
return new Gitea({
token: config.GITEA_TOKEN,
baseURL: config.GITEA_URL,
cors: {
baseUrl: 'https://cors.kevisual.cn'
}
})
})
export const app = useContextKey('app', new QueryRouterServer())

View File

@@ -1 +1,8 @@
export * from './app.ts'
export * from './app.ts'
// import '@kevisual/cnb-ai'
const url = 'https://kevisual.cn/root/cnb-ai/dist/app.js'
setTimeout(() => {
import(/* @vite-ignore */url)
}, 2000)

View File

@@ -0,0 +1,60 @@
import { useState } from 'react';
import { useRegisterSW } from 'virtual:pwa-register/react';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
function PWAUpdate() {
const {
needRefresh: [needRefresh, setNeedRefresh],
updateServiceWorker,
} = useRegisterSW({
onNeedRefresh() {
setNeedRefresh(true);
},
});
const [isLoading, setIsLoading] = useState(false);
const handleUpdate = async () => {
setIsLoading(true);
await updateServiceWorker(true);
setIsLoading(false);
};
const handleDismiss = () => {
setNeedRefresh(false);
};
if (!needRefresh) {
return null;
}
return (
<div className="fixed bottom-4 right-4 z-50">
<Card className="w-80 shadow-lg">
<CardHeader className="pb-3">
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="pt-0" />
<CardFooter className="gap-2">
<Button variant="outline" size="sm" onClick={handleDismiss}>
</Button>
<Button size="sm" onClick={handleUpdate} disabled={isLoading}>
{isLoading ? '更新中...' : '立即更新'}
</Button>
</CardFooter>
</Card>
</div>
);
}
export default PWAUpdate;

View File

@@ -1,5 +1,4 @@
'use client'
import { useNavigate, useLocation } from '@tanstack/react-router'
import { useState } from 'react'
import {
@@ -23,6 +22,8 @@ export interface NavItem {
badge?: string
hidden?: boolean
children?: NavItem[]
external?: boolean
onClick?: () => void
}
export interface SidebarProps {
@@ -31,6 +32,7 @@ export interface SidebarProps {
children?: React.ReactNode
logo?: React.ReactNode
title?: React.ReactNode
footer?: React.ReactNode
defaultCollapsed?: boolean
defaultWidth?: number
minWidth?: number
@@ -43,6 +45,7 @@ export function Sidebar({
children,
logo,
title,
footer,
defaultCollapsed = false,
defaultWidth = 208,
minWidth = 120,
@@ -71,10 +74,20 @@ export function Sidebar({
}
const handleNavClick = (item: NavItem) => {
// 优先执行 onClick 回调
if (item.onClick) {
item.onClick()
return
}
if (item.isDeveloping) {
setDevelopingDialog({ open: true, title: item.title })
} else {
} else if (item.external && item.path.startsWith('http')) {
window.open(item.path, '_blank')
} else if (item.path.startsWith('/')) {
navigate({ to: item.path })
} else {
navigate({ href: item.path })
}
}
@@ -161,7 +174,6 @@ export function Sidebar({
</li>
)
}
return (
<>
<div className={cn('flex h-full', className)}>
@@ -183,7 +195,7 @@ export function Sidebar({
>
<aside
className={cn(
'border-r bg-white flex-shrink-0 flex flex-col'
'h-full border-r bg-white flex-shrink-0 flex flex-col'
)}
style={{ width: sidebarWidth }}
>
@@ -212,11 +224,18 @@ export function Sidebar({
{items.map((item) => renderNavItem(item))}
</ul>
</nav>
{/* Footer */}
{footer && (
<div className="border-t flex-shrink-0">
{footer}
</div>
)}
</aside>
</Resizable>
) : (
// 收起状态
<aside className="w-14 border-r bg-white flex-shrink-0 flex flex-col">
<aside className="h-full w-14 border-r bg-white flex-shrink-0 flex flex-col">
{/* Logo 区域 */}
<div className="h-12 flex items-center justify-center border-b px-2">
<div className="group relative flex-shrink-0">
@@ -237,6 +256,13 @@ export function Sidebar({
{items.map((item) => renderNavItem(item))}
</ul>
</nav>
{/* Footer */}
{footer && (
<div className="border-t flex-shrink-0">
{footer}
</div>
)}
</aside>
)}

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -90,13 +90,10 @@ export const useLayoutStore = create<LayoutStore>((set, get) => ({
setIsAdmin: (isAdmin) => set({ isAdmin }),
init: async () => {
await queryLogin.init();
const token = await queryLogin.checkLocalToken();
const token = await queryLogin.checkTokenValid();
if (token) {
set({ me: {} });
try {
// const data = await stackQueryClient.fetchQuery({
// queryKey: authQueryKeys.me,
// }) as UserInfo;
const userInfo = await queryLogin.checkLocalUser();
if (userInfo) {
set({ me: userInfo as UserInfo, isAdmin: userInfo.orgs?.includes?.('admin') || false });

View File

@@ -0,0 +1,261 @@
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>
)
}

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

@@ -0,0 +1,105 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { TagInput } from "./TagInput";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
export function CreateDialog({ open, onClose, onSubmit }: {
open: boolean;
onClose: () => void;
onSubmit: (data: { title: string, tags?: string[], link?: string, summary?: string, description?: string }) => Promise<void>;
}) {
const [title, setTitle] = useState('');
const [tags, setTags] = useState<string[]>([]);
const [link, setLink] = useState('');
const [summary, setSummary] = useState('');
const [description, setDescription] = useState('');
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async () => {
if (!title.trim()) {
alert('请输入标题');
return;
}
setSubmitting(true);
try {
await onSubmit({
title: title.trim(),
tags,
link: link.trim(),
summary: summary.trim(),
description: description.trim(),
});
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-lg!">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="create-title"></Label>
<Input
id="create-title"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="请输入标题"
/>
</div>
<div className="space-y-2">
<Label></Label>
<TagInput value={tags} onChange={setTags} placeholder="输入标签后按回车添加" />
</div>
<div className="space-y-2">
<Label htmlFor="create-link"></Label>
<Input
id="create-link"
value={link}
onChange={e => setLink(e.target.value)}
placeholder="https://..."
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-summary"></Label>
<Input
id="create-summary"
value={summary}
onChange={e => setSummary(e.target.value)}
placeholder="简要描述"
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-description"></Label>
<Textarea
id="create-description"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="详细描述..."
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={submitting}></Button>
<Button variant="outline" onClick={handleSubmit} disabled={submitting}>
{submitting ? '创建中...' : '创建'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,117 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { TagInput } from "./TagInput";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
export function EditDialog({ open, item, onClose, onSubmit }: {
open: boolean;
item: any;
onClose: () => void;
onSubmit: (id: string, data: { title?: string, tags?: string[], link?: string, summary?: string, description?: string }) => Promise<void>;
}) {
const [title, setTitle] = useState('');
const [tags, setTags] = useState<string[]>([]);
const [link, setLink] = useState('');
const [summary, setSummary] = useState('');
const [description, setDescription] = useState('');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (open && item) {
setTitle(item.title || '');
setTags(item.tags || []);
setLink(item.link || '');
setSummary(item.summary || '');
setDescription(item.description || '');
}
}, [open, item]);
const handleSubmit = async () => {
if (!title.trim()) {
alert('请输入标题');
return;
}
if (!item?.id) return;
setSubmitting(true);
try {
await onSubmit(item.id, {
title: title.trim(),
tags,
link: link.trim(),
summary: summary.trim(),
description: description.trim(),
});
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-lg!">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-title"></Label>
<Input
id="edit-title"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="请输入标题"
/>
</div>
<div className="space-y-2">
<Label></Label>
<TagInput value={tags} onChange={setTags} placeholder="输入标签后按回车添加" />
</div>
<div className="space-y-2">
<Label htmlFor="edit-link"></Label>
<Input
id="edit-link"
value={link}
onChange={e => setLink(e.target.value)}
placeholder="https://..."
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-summary"></Label>
<Input
id="edit-summary"
value={summary}
onChange={e => setSummary(e.target.value)}
placeholder="简要描述"
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-description"></Label>
<Textarea
id="edit-description"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="详细描述..."
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={submitting}></Button>
<Button variant="outline" onClick={handleSubmit} disabled={submitting}>
{submitting ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,53 @@
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { XIcon } from "lucide-react";
export function TagInput({ value, onChange, placeholder }: {
value: string[];
onChange: (tags: string[]) => void;
placeholder?: string;
}) {
const [input, setInput] = useState('');
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && input.trim()) {
e.preventDefault();
if (!value.includes(input.trim())) {
onChange([...value, input.trim()]);
}
setInput('');
}
};
const handleRemove = (tag: string) => {
onChange(value.filter(t => t !== tag));
};
return (
<div className="space-y-2">
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder || '输入标签后按回车添加'}
/>
{value.length > 0 && (
<div className="flex flex-wrap gap-1">
{value.map((tag) => (
<Badge key={tag} variant="outline" className="gap-1">
{tag}
<button
type="button"
onClick={() => handleRemove(tag)}
className="hover:text-destructive"
>
<XIcon className="size-3" />
</button>
</Badge>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,3 @@
export { CreateDialog } from "./CreateDialog";
export { EditDialog } from "./EditDialog";
export { TagInput } from "./TagInput";

View File

@@ -0,0 +1,153 @@
import { usePackageStore, type PackageState } from "./store";
import { useShallow } from "zustand/shallow";
import { useEffect, useState } from "react";
import dayjs from "dayjs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { CreateDialog, EditDialog } from "./components";
import { SearchIcon, RefreshCwIcon, PlusIcon, PencilIcon, TrashIcon } from "lucide-react";
import { SidebarLayout } from "@/pages/sidebar/components";
export const App = () => {
const packageStore = usePackageStore(useShallow((state: PackageState) => {
return {
list: state.list,
loading: state.loading,
getList: state.getList,
createItem: state.createItem,
updateItem: state.updateItem,
deleteItem: state.deleteItem,
showCreateDialog: state.showCreateDialog,
setShowCreateDialog: state.setShowCreateDialog,
showEditDialog: state.showEditDialog,
setShowEditDialog: state.setShowEditDialog,
editingItem: state.editingItem,
setEditingItem: state.setEditingItem,
}
}));
const [search, setSearch] = useState('');
useEffect(() => {
packageStore.getList({ search });
}, [search]);
const handleDelete = async (id: string) => {
if (confirm('确定要删除这个制品吗?')) {
await packageStore.deleteItem(id);
}
};
const handleRefresh = () => {
packageStore.getList({ search });
};
const handleEdit = (item: any) => {
packageStore.setEditingItem(item);
packageStore.setShowEditDialog(true);
};
const handleCreate = () => {
packageStore.setShowCreateDialog(true);
};
return (
<SidebarLayout>
<div className="p-5">
<div className="flex justify-between items-center mb-5">
<h1 className="text-2xl font-semibold"></h1>
<div className="flex gap-2">
<div className="relative">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
type="text"
placeholder="搜索制品..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8 w-48"
/>
</div>
<Button variant="outline" size="sm" onClick={handleRefresh}>
<RefreshCwIcon className="size-4" />
</Button>
<Button variant="outline" size="sm" onClick={handleCreate}>
<PlusIcon className="size-4" />
</Button>
</div>
</div>
{packageStore.loading ? (
<div className="text-center py-10 text-muted-foreground">...</div>
) : packageStore.list.length === 0 ? (
<div className="text-center py-10 text-muted-foreground"></div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{packageStore.list.map((item) => (
<Card key={item.id}>
<CardHeader>
<CardTitle>{item.title || '未命名'}</CardTitle>
<CardDescription className="text-xs">ID: {item.id}</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{item.tags && item.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{item.tags.map((tag, index) => (
<Badge key={index} variant="outline">{tag}</Badge>
))}
</div>
)}
{item.summary && (
<p className="text-sm text-muted-foreground">{item.summary}</p>
)}
{item.description && (
<p className="text-sm text-muted-foreground line-clamp-2">{item.description}</p>
)}
<p className="text-xs text-muted-foreground">
: {item.createdAt ? dayjs(item.createdAt).format('YYYY-MM-DD HH:mm:ss') : '-'}
</p>
<p className="text-xs text-muted-foreground">
: {item.updatedAt ? dayjs(item.updatedAt).format('YYYY-MM-DD HH:mm:ss') : '-'}
</p>
<div className="flex gap-2 pt-2">
<Button variant="outline" size="sm" onClick={() => handleEdit(item)}>
<PencilIcon className="size-3.5" />
</Button>
<Button variant="outline" size="sm" onClick={() => handleDelete(item.id)}>
<TrashIcon className="size-3.5" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
<CreateDialog
open={packageStore.showCreateDialog}
onClose={() => packageStore.setShowCreateDialog(false)}
onSubmit={packageStore.createItem}
/>
<EditDialog
open={packageStore.showEditDialog}
item={packageStore.editingItem}
onClose={() => {
packageStore.setShowEditDialog(false);
packageStore.setEditingItem(null);
}}
onSubmit={packageStore.updateItem}
/>
</div>
</SidebarLayout>
);
};
export default App;

View File

@@ -0,0 +1,154 @@
import { create } from 'zustand';
import { queryApi } from '@/modules/mark-api';
import { toast } from 'sonner';
type PackageItem = {
id: string;
title: string;
tags?: string[];
link?: string;
summary?: string;
description?: string;
createdAt: string;
updatedAt: string;
}
type PackageState = {
edit: boolean;
setEdit: (edit: boolean) => void;
list: PackageItem[];
loading: boolean;
setLoading: (loading: boolean) => void;
// Dialog states
showCreateDialog: boolean;
setShowCreateDialog: (show: boolean) => void;
showEditDialog: boolean;
setShowEditDialog: (show: boolean) => void;
editingItem: PackageItem | null;
setEditingItem: (item: PackageItem | null) => void;
// Data operations
getList: (params?: { search?: string, page?: number, pageSize?: number }) => Promise<void>;
createItem: (data: { title: string, tags?: string[], link?: string, summary?: string, description?: string }) => Promise<void>;
updateItem: (id: string, data: { title?: string, tags?: string[], link?: string, summary?: string, description?: string }) => Promise<void>;
deleteItem: (id: string) => Promise<void>;
getItem: (id: string) => Promise<PackageItem | null>;
}
export type { PackageState, PackageItem };
export const usePackageStore = create<PackageState>((set, get) => ({
edit: false,
setEdit: (edit) => set({ edit }),
list: [],
loading: false,
setLoading: (loading) => set({ loading }),
showCreateDialog: false,
setShowCreateDialog: (show) => set({ showCreateDialog: show }),
showEditDialog: false,
setShowEditDialog: (show) => set({ showEditDialog: show }),
editingItem: null,
setEditingItem: (item) => set({ editingItem: item }),
getList: async (params = {}) => {
const { page = 1, pageSize = 20, search } = params;
set({ loading: true });
try {
const res = await queryApi.mark.list({
markType: 'cnb-packages',
page,
pageSize,
search,
sort: 'DESC'
});
if (res.code === 200) {
set({ list: res.data?.list || [] });
} else {
toast.error(res.message || '获取列表失败');
}
} catch (e) {
console.error('获取制品列表失败', e);
toast.error('获取列表失败');
} finally {
set({ loading: false });
}
},
createItem: async (data) => {
try {
const res = await queryApi.mark.create({
title: data.title,
markType: 'cnb-packages',
tags: data.tags || [],
link: data.link || '',
summary: data.summary || '',
description: data.description || ''
});
if (res.code === 200) {
toast.success('创建成功');
get().getList();
set({ showCreateDialog: false });
} else {
toast.error(res.message || '创建失败');
}
} catch (e) {
console.error('创建失败', e);
toast.error('创建失败');
}
},
updateItem: async (id, data) => {
try {
const res = await queryApi.mark.update({
data: {
// @ts-ignore
id,
title: data.title || '',
tags: data.tags || [],
link: data.link || '',
summary: data.summary || '',
description: data.description || ''
}
});
if (res.code === 200) {
toast.success('更新成功');
get().getList();
set({ showEditDialog: false, editingItem: null });
} else {
toast.error(res.message || '更新失败');
}
} catch (e) {
console.error('更新失败', e);
toast.error('更新失败');
}
},
deleteItem: async (id) => {
try {
const res = await queryApi.mark.delete({ id });
if (res.code === 200) {
toast.success('删除成功');
get().getList();
} else {
toast.error(res.message || '删除失败');
}
} catch (e) {
console.error('删除失败', e);
toast.error('删除失败');
}
},
getItem: async (id) => {
try {
const res = await queryApi.mark.get({ id });
if (res.code === 200) {
return res.data;
} else {
toast.error(res.message || '获取详情失败');
return null;
}
} catch (e) {
console.error('获取详情失败', e);
return null;
}
}
}));

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

@@ -24,20 +24,15 @@ export const App = () => {
setShowCreateDialog: state.setShowCreateDialog,
})))
const [searchQuery, setSearchQuery] = useState('')
const [filterDev, setFilterDev] = useState(false)
const [filterDev, setFilterDev] = useState(() => {
const saved = localStorage.getItem('repos-filter-dev')
return saved === 'true'
})
const navigate = useNavigate();
const me = useLayoutStore(state => state.me)
const configStore = useConfigStore(useShallow(state => ({ checkConfig: state.checkConfig })))
useEffect(() => {
refresh({ showTips: false })
}, [])
useEffect(() => {
if (me && me.id) {
configStore.checkConfig({ isUser: true, reload: true })
}
}, [me])
const appList = useMemo(() => {
const sortedList = [...list].sort((a, b) => {
const aActive = workspaceList.some(ws => ws.slug === a.path)
@@ -136,7 +131,11 @@ export const App = () => {
<Checkbox
id="filter-dev"
checked={filterDev}
onCheckedChange={(checked) => setFilterDev(checked === true)}
onCheckedChange={(checked) => {
const value = checked === true
setFilterDev(value)
localStorage.setItem('repos-filter-dev', String(value))
}}
/>
<label
htmlFor="filter-dev"

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, Package } from 'lucide-react'
import { Sidebar, type NavItem } from '@/components/a/Sidebar'
import { Logo } from './CNBBlackLogo.tsx'
@@ -8,27 +8,49 @@ 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',
icon: <LayoutDashboard className="w-5 h-5" />,
},
{
title: '应用配置',
path: '/config',
icon: <Settings className="w-5 h-5" />,
title: '制品中心',
path: '/cnb-packages',
icon: <Package className="w-5 h-5" />,
},
{
title: '其他',
path: '/demo',
path: '/other',
icon: <PlayCircle className="w-5 h-5" />,
isDeveloping: true,
children: [
{
title: '应用配置',
path: '/config',
icon: <Settings className="w-5 h-5" />,
},
]
},
]
export function SidebarLayout({ children }: { children: React.ReactNode }) {
return (
<Sidebar items={navItems} title='云原生' logo={<Logo className='w-6 h-6' />}>
<Sidebar items={navItems} title='云原生' logo={<Logo className='w-6 h-6' />}
footer={<div className="p-4 border-t text-sm text-gray-500 hover:text-gray-700">
<a
href="https://cnb.cool/kevisual/cnb-center"
target="_blank"
rel="noopener noreferrer"
>
CNB Center
</a>
</div>}
>
{children}
</Sidebar>
)

View File

@@ -102,8 +102,9 @@ export const useWorkspaceStore = create<WorkspaceState>((set, get) => ({
updateItem: async (id, data) => {
try {
const res = await queryApi.mark.update({
id,
data: {
// @ts-ignore
id,
title: data.title || '',
tags: data.tags || [],
link: data.link || '',

View File

@@ -15,6 +15,8 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as WorkspacesIndexRouteImport } from './routes/workspaces/index'
import { Route as RepoIndexRouteImport } from './routes/repo/index'
import { Route as ConfigIndexRouteImport } from './routes/config/index'
import { Route as CnbPackagesIndexRouteImport } from './routes/cnb-packages/index'
import { Route as CloudEnvIndexRouteImport } from './routes/cloud-env/index'
import { Route as ConfigGiteaRouteImport } from './routes/config/gitea'
const LoginRoute = LoginRouteImport.update({
@@ -47,6 +49,16 @@ const ConfigIndexRoute = ConfigIndexRouteImport.update({
path: '/config/',
getParentRoute: () => rootRouteImport,
} as any)
const CnbPackagesIndexRoute = CnbPackagesIndexRouteImport.update({
id: '/cnb-packages/',
path: '/cnb-packages/',
getParentRoute: () => rootRouteImport,
} as any)
const CloudEnvIndexRoute = CloudEnvIndexRouteImport.update({
id: '/cloud-env/',
path: '/cloud-env/',
getParentRoute: () => rootRouteImport,
} as any)
const ConfigGiteaRoute = ConfigGiteaRouteImport.update({
id: '/config/gitea',
path: '/config/gitea',
@@ -58,6 +70,8 @@ export interface FileRoutesByFullPath {
'/demo': typeof DemoRoute
'/login': typeof LoginRoute
'/config/gitea': typeof ConfigGiteaRoute
'/cloud-env/': typeof CloudEnvIndexRoute
'/cnb-packages/': typeof CnbPackagesIndexRoute
'/config/': typeof ConfigIndexRoute
'/repo/': typeof RepoIndexRoute
'/workspaces/': typeof WorkspacesIndexRoute
@@ -67,6 +81,8 @@ export interface FileRoutesByTo {
'/demo': typeof DemoRoute
'/login': typeof LoginRoute
'/config/gitea': typeof ConfigGiteaRoute
'/cloud-env': typeof CloudEnvIndexRoute
'/cnb-packages': typeof CnbPackagesIndexRoute
'/config': typeof ConfigIndexRoute
'/repo': typeof RepoIndexRoute
'/workspaces': typeof WorkspacesIndexRoute
@@ -77,6 +93,8 @@ export interface FileRoutesById {
'/demo': typeof DemoRoute
'/login': typeof LoginRoute
'/config/gitea': typeof ConfigGiteaRoute
'/cloud-env/': typeof CloudEnvIndexRoute
'/cnb-packages/': typeof CnbPackagesIndexRoute
'/config/': typeof ConfigIndexRoute
'/repo/': typeof RepoIndexRoute
'/workspaces/': typeof WorkspacesIndexRoute
@@ -88,6 +106,8 @@ export interface FileRouteTypes {
| '/demo'
| '/login'
| '/config/gitea'
| '/cloud-env/'
| '/cnb-packages/'
| '/config/'
| '/repo/'
| '/workspaces/'
@@ -97,6 +117,8 @@ export interface FileRouteTypes {
| '/demo'
| '/login'
| '/config/gitea'
| '/cloud-env'
| '/cnb-packages'
| '/config'
| '/repo'
| '/workspaces'
@@ -106,6 +128,8 @@ export interface FileRouteTypes {
| '/demo'
| '/login'
| '/config/gitea'
| '/cloud-env/'
| '/cnb-packages/'
| '/config/'
| '/repo/'
| '/workspaces/'
@@ -116,6 +140,8 @@ export interface RootRouteChildren {
DemoRoute: typeof DemoRoute
LoginRoute: typeof LoginRoute
ConfigGiteaRoute: typeof ConfigGiteaRoute
CloudEnvIndexRoute: typeof CloudEnvIndexRoute
CnbPackagesIndexRoute: typeof CnbPackagesIndexRoute
ConfigIndexRoute: typeof ConfigIndexRoute
RepoIndexRoute: typeof RepoIndexRoute
WorkspacesIndexRoute: typeof WorkspacesIndexRoute
@@ -165,6 +191,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ConfigIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/cnb-packages/': {
id: '/cnb-packages/'
path: '/cnb-packages'
fullPath: '/cnb-packages/'
preLoaderRoute: typeof CnbPackagesIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/cloud-env/': {
id: '/cloud-env/'
path: '/cloud-env'
fullPath: '/cloud-env/'
preLoaderRoute: typeof CloudEnvIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/config/gitea': {
id: '/config/gitea'
path: '/config/gitea'
@@ -180,6 +220,8 @@ const rootRouteChildren: RootRouteChildren = {
DemoRoute: DemoRoute,
LoginRoute: LoginRoute,
ConfigGiteaRoute: ConfigGiteaRoute,
CloudEnvIndexRoute: CloudEnvIndexRoute,
CnbPackagesIndexRoute: CnbPackagesIndexRoute,
ConfigIndexRoute: ConfigIndexRoute,
RepoIndexRoute: RepoIndexRoute,
WorkspacesIndexRoute: WorkspacesIndexRoute,

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

View File

@@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
import App from '@/pages/cnb-packages/page'
export const Route = createFileRoute('/cnb-packages/')({
component: RouteComponent,
})
function RouteComponent() {
return <App />
}

View File

@@ -32,6 +32,39 @@ export default defineConfig({
tailwindcss(),
VitePWA({
injectRegister: 'auto',
registerType: 'autoUpdate',
// Workbox 缓存策略配置
workbox: {
// API 请求使用网络优先策略,确保获取最新数据
runtimeCaching: [
{
urlPattern: /^https?.*\/api\/.*/,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24, // 24小时
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
// 静态资源使用缓存优先,但设置较短过期时间
{
urlPattern: /^https?.*\.(js|css|woff2?|png|jpg|jpeg|svg|gif|ico)/,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'static-resources',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 * 7, // 7天
},
},
},
],
},
}),
],
resolve: {