From a2629fec7b1c17b26d3df591447a98e06d2a995c Mon Sep 17 00:00:00 2001 From: abearxiong Date: Mon, 9 Feb 2026 04:44:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E4=BB=93=E5=BA=93?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E9=A1=B5=E9=9D=A2=E5=92=8C=20AI=20=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=8C=E4=BC=98=E5=8C=96=E8=B7=AF=E7=94=B1=E5=92=8C?= =?UTF-8?q?=E5=AF=BC=E8=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增仓库列表页面,支持查看和管理 CNB 仓库 - 添加 AI 代理系统和状态管理 - 新增 tags-input、popover、textarea、tooltip 等 UI 组件 - 更新依赖:@kevisual/cnb 升级至 0.0.22,添加 idb-keyval - 改进路由守卫:未配置 API Key 时自动跳转配置页 - 优化 Dialog 遮罩层样式和整体布局 Co-Authored-By: Claude Opus 4.6 --- AGENTS.md | 48 +++ package.json | 5 +- pnpm-lock.yaml | 24 +- src/agents/app.ts | 21 ++ src/app/ai/page.tsx | 43 +++ src/app/config/page.tsx | 7 +- src/app/page.tsx | 44 +-- src/app/repo/components/RepoCard.tsx | 217 ++++++++++++ src/app/repo/modules/EditRepoDialog.tsx | 140 ++++++++ src/app/repo/modules/SyncRepoDialog.tsx | 85 +++++ .../repo/modules/WorkspaceDetailDialog.tsx | 185 +++++++++++ src/app/repo/page.tsx | 93 ++++++ src/app/repo/store/build.ts | 33 ++ src/app/repo/store/index.ts | 313 ++++++++++++++++++ src/app/store/index.ts | 130 ++++++++ src/components/tags-input.tsx | 78 +++++ src/components/ui/dialog.tsx | 2 +- src/components/ui/popover.tsx | 88 +++++ src/components/ui/textarea.tsx | 18 + src/components/ui/tooltip.tsx | 64 ++++ src/routes/__root.tsx | 26 +- src/routes/index.tsx | 4 +- 22 files changed, 1606 insertions(+), 62 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/agents/app.ts create mode 100644 src/app/ai/page.tsx create mode 100644 src/app/repo/components/RepoCard.tsx create mode 100644 src/app/repo/modules/EditRepoDialog.tsx create mode 100644 src/app/repo/modules/SyncRepoDialog.tsx create mode 100644 src/app/repo/modules/WorkspaceDetailDialog.tsx create mode 100644 src/app/repo/page.tsx create mode 100644 src/app/repo/store/build.ts create mode 100644 src/app/repo/store/index.ts create mode 100644 src/app/store/index.ts create mode 100644 src/components/tags-input.tsx create mode 100644 src/components/ui/popover.tsx create mode 100644 src/components/ui/textarea.tsx create mode 100644 src/components/ui/tooltip.tsx diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e9c6441 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,48 @@ +# AGENTS.md + +本指南为在此仓库中工作的 AI 编码代理提供关键信息。 + +## 项目结构 + +``` +src/ +├── components/ui/ # shadcn/ui 组件(Base UI 基础组件) +├── lib/ # 工具函数(cn() 函数用于 className 合并) +├── modules/ # 应用模块(query client、basename) +├── pages/ # 页面组件(默认导出) +├── routes/ # TanStack Router 基于文件的路由 +├── styles/ # 全局样式、主题 CSS +└── main.tsx # 应用入口 +``` + + +## 代码风格指南 + +### 模块目录结构 + +每个新模块(如 `page-app`)应遵循以下结构: + +``` +pages/page-app/ +├── components/ # 模块专属组件 +├── store/ # 模块状态管理 +└── module/ # 模块功能函数 +``` + +### 状态和数据获取 + +- **Zustand** 用于全局状态管理 +- **@kevisual/query** 用于数据获取(QueryClient 实例位于 `src/modules/query.ts`) +- **React Hook Form** 用于表单管理 + +## 核心依赖 + +- **@base-ui/react**: Headless UI 基础组件 +- **@tanstack/react-router**: 基于 TanStack Router 插件的文件路由 +- **class-variance-authority**: 基于变体的样式系统 +- **clsx + tailwind-merge**: 通过 `cn()` 提供 className 工具函数 +- **lucide-react**: 图标库 +- **react-hook-form**: 表单处理 +- **sonner**: Toast 通知 +- **zustand**: 状态管理 +- **tailwindcss v4**: 使用 @tailwindcss/vite 插件进行样式处理 \ No newline at end of file diff --git a/package.json b/package.json index cb626fd..41802e9 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "vite-react", + "name": "@kevisual/cnb-center", "private": true, "version": "0.0.1", "type": "module", @@ -21,7 +21,7 @@ "@ai-sdk/openai": "^3.0.26", "@ai-sdk/openai-compatible": "^2.0.28", "@base-ui/react": "^1.1.0", - "@kevisual/cnb": "^0.0.20", + "@kevisual/cnb": "^0.0.22", "@kevisual/context": "^0.0.4", "@kevisual/router": "0.0.70", "@tanstack/react-router": "^1.158.4", @@ -30,6 +30,7 @@ "clsx": "^2.1.1", "dayjs": "^1.11.19", "es-toolkit": "^1.44.0", + "idb-keyval": "^6.2.2", "lucide-react": "^0.563.0", "nanoid": "^5.1.6", "next-themes": "^0.4.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1cfd925..fe80a84 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,8 +21,8 @@ importers: specifier: ^1.1.0 version: 1.1.0(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@kevisual/cnb': - specifier: ^0.0.20 - version: 0.0.20(dotenv@17.2.4)(idb-keyval@6.2.1) + specifier: ^0.0.22 + version: 0.0.22(dotenv@17.2.4)(idb-keyval@6.2.2) '@kevisual/context': specifier: ^0.0.4 version: 0.0.4 @@ -47,6 +47,9 @@ importers: es-toolkit: specifier: ^1.44.0 version: 1.44.0 + idb-keyval: + specifier: ^6.2.2 + version: 6.2.2 lucide-react: specifier: ^0.563.0 version: 0.563.0(react@19.2.4) @@ -638,8 +641,8 @@ packages: '@kevisual/cache@0.0.2': resolution: {integrity: sha512-2Cl5KF2Gi27uLfhO6CdTMFnRzx9vYnqevAo7d9ab3rOaqTgF8tLeAXglXyRbaWW3WUbHU2XaOb4r98uUsqIQQw==} - '@kevisual/cnb@0.0.20': - resolution: {integrity: sha512-3ODGAT8vEnU90X/6SUeqMK1ZJCcvyn44bMsC7Joz0kvDKhntstbf/nZIm5TRhngvPEcOPyc+KROchTweC/qcNA==} + '@kevisual/cnb@0.0.22': + resolution: {integrity: sha512-KX8oSmmaHnT4qqCfAoQoHZbkcohUVSK7LfdsEKTlItrE77rPyZcvD+APByroxH4FMQ80ItRW9tQlxBO8iRlrIw==} '@kevisual/context@0.0.4': resolution: {integrity: sha512-HJeLeZQLU+7tCluSfOyvkgKLs0HjCZrdJlZgEgKRSa8XTwZfMAUt6J7qZTbrZAHBlPtX68EPu/PI8JMCeu3WAQ==} @@ -1490,6 +1493,9 @@ packages: idb-keyval@6.2.1: resolution: {integrity: sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==} + idb-keyval@6.2.2: + resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==} + immer@10.1.1: resolution: {integrity: sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==} @@ -2730,14 +2736,14 @@ snapshots: - tslib - typescript - '@kevisual/cnb@0.0.20(dotenv@17.2.4)(idb-keyval@6.2.1)': + '@kevisual/cnb@0.0.22(dotenv@17.2.4)(idb-keyval@6.2.2)': dependencies: '@kevisual/query': 0.0.40 '@kevisual/router': 0.0.70 '@kevisual/use-config': 1.0.30(dotenv@17.2.4) es-toolkit: 1.44.0 nanoid: 5.1.6 - unstorage: 1.17.4(idb-keyval@6.2.1) + unstorage: 1.17.4(idb-keyval@6.2.2) ws: '@kevisual/ws@8.19.0' zod: 4.3.6 transitivePeerDependencies: @@ -3532,6 +3538,8 @@ snapshots: idb-keyval@6.2.1: {} + idb-keyval@6.2.2: {} + immer@10.1.1: optional: true @@ -3996,7 +4004,7 @@ snapshots: picomatch: 4.0.3 webpack-virtual-modules: 0.6.2 - unstorage@1.17.4(idb-keyval@6.2.1): + unstorage@1.17.4(idb-keyval@6.2.2): dependencies: anymatch: 3.1.3 chokidar: 5.0.0 @@ -4007,7 +4015,7 @@ snapshots: ofetch: 1.5.1 ufo: 1.6.3 optionalDependencies: - idb-keyval: 6.2.1 + idb-keyval: 6.2.2 update-browserslist-db@1.1.1(browserslist@4.24.2): dependencies: diff --git a/src/agents/app.ts b/src/agents/app.ts new file mode 100644 index 0000000..1a678ea --- /dev/null +++ b/src/agents/app.ts @@ -0,0 +1,21 @@ +import { QueryRouterServer } from '@kevisual/router/browser' + +import { useContextKey } from '@kevisual/context' +import { useConfigStore } from '@/app/config/store' +import { CNB } from '@kevisual/cnb' + +export const app = useContextKey('router', new QueryRouterServer()) + +export const cnb: CNB = useContextKey('cnb', () => { + const state = useConfigStore.getState() + const config = state.config || {} + const cors: any = {} + if (config.ENABLE_CORS) { + cors.baseUrl = config.CNB_CORS_URL || 'https://cors.kevisual.cn' + } + return new CNB({ + token: config.CNB_API_KEY, + cookie: config.CNB_COOKIE, + cors + }) +}) \ No newline at end of file diff --git a/src/app/ai/page.tsx b/src/app/ai/page.tsx new file mode 100644 index 0000000..1e04528 --- /dev/null +++ b/src/app/ai/page.tsx @@ -0,0 +1,43 @@ +import { CNB, Issue } from '@kevisual/cnb' +import { useLayoutEffect } from 'react' +import { useConfigStore } from '../config/store' +import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; +import { generateText } from 'ai'; +const init2 = async () => { + const cnb = new CNB({ + token: 'cIDfLOOIr1Trt15cdnwfndupEZG', + cookie: 'CNBSESSION=1770014410.1935321989751226368.7f386c282d80efb5256180ef94c2865e20a8be72e2927a5f8eb1eb72142de39f;csrfkey=2028873452', + cors: { + baseUrl: 'https://cors.kevisual.cn' + } + }) + // const res = await cnb.issue.getList('kevisual/kevisual') + // console.log('res', res) + const token = await cnb.user.getCurrentUser() + console.log('token', token) +} + +const initAi = async () => { + const state = useConfigStore.getState() + const config = state.config + const cors = state.config.CNB_CORS_URL + const base = cors + '/' + config.AI_BASE_URL.replace('https://', '') + const cnb = createOpenAICompatible({ + baseURL: base, + name: 'custom-cnb', + apiKey: config.AI_API_KEY, + }); + const model = config.AI_MODEL; + // const model = 'hunyuan'; + const { text } = await generateText({ + model: cnb(model), + prompt: '你好', + }); + console.log('text', text) +} +export const Home = () => { + useLayoutEffect(() => { initAi() }, []) + return
Home Page
+} + +export default Home; \ No newline at end of file diff --git a/src/app/config/page.tsx b/src/app/config/page.tsx index 3ea7c29..74e642c 100644 --- a/src/app/config/page.tsx +++ b/src/app/config/page.tsx @@ -5,6 +5,7 @@ import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { configSchema } from './store/schema'; +import { toast } from 'sonner'; export const ConfigPage = () => { const { config, setConfig, resetConfig } = useConfigStore(); @@ -13,8 +14,10 @@ export const ConfigPage = () => { e.preventDefault(); const result = configSchema.safeParse(config); if (result.success) { - console.log('配置已保存:', config); - // 可以在此处添加 toast 通知 + toast.success('配置已保存') + setTimeout(() => { + location.reload() + }, 400) } else { console.error('验证错误:', result.error.format()); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 4ac0b5b..9f2c057 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,43 +1,3 @@ -import { CNB, Issue } from '@kevisual/cnb' -import { useLayoutEffect } from 'react' -import { useConfigStore } from './config/store' -import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; -import { generateText } from 'ai'; -const init2 = async () => { - const cnb = new CNB({ - token: 'cIDfLOOIr1Trt15cdnwfndupEZG', - cookie: 'CNBSESSION=1770014410.1935321989751226368.7f386c282d80efb5256180ef94c2865e20a8be72e2927a5f8eb1eb72142de39f;csrfkey=2028873452', - cors: { - baseUrl: 'https://cors.kevisual.cn' - } - }) - // const res = await cnb.issue.getList('kevisual/kevisual') - // console.log('res', res) - const token = await cnb.user.getCurrentUser() - console.log('token', token) -} +import App from './repo/page' -const initAi = async () => { - const state = useConfigStore.getState() - const config = state.config - const cors = state.config.CNB_CORS_URL - const base = cors + '/' + config.AI_BASE_URL.replace('https://', '') - const cnb = createOpenAICompatible({ - baseURL: base, - name: 'custom-cnb', - apiKey: config.AI_API_KEY, - }); - const model = config.AI_MODEL; - // const model = 'hunyuan'; - const { text } = await generateText({ - model: cnb(model), - prompt: '你好', - }); - console.log('text', text) -} -export const Home = () => { - useLayoutEffect(() => { initAi() }, []) - return
Home Page
-} - -export default Home; \ No newline at end of file +export default App; \ No newline at end of file diff --git a/src/app/repo/components/RepoCard.tsx b/src/app/repo/components/RepoCard.tsx new file mode 100644 index 0000000..7706679 --- /dev/null +++ b/src/app/repo/components/RepoCard.tsx @@ -0,0 +1,217 @@ +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Card } from '@/components/ui/card' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' +import { Star, GitFork, FileText, Edit, FolderGit2, MoreVertical, FileText as IssueIcon, Settings, Play, Trash2, RefreshCw } from 'lucide-react' +import { useRepoStore } from '../store' +import { useMemo, useState } from 'react' +import { myOrgs } from '../store/build' + +interface RepoCardProps { + repo: any + onStartWorkspace: (repo: any) => void + onEdit: (repo: any) => void + onIssue: (repo: any) => void + onSettings: (repo: any) => void + onDelete: (repo: any) => void + onSync?: (repo: any) => void +} + +export function RepoCard({ repo, onStartWorkspace, onEdit, onIssue, onSettings, onDelete, onSync }: RepoCardProps) { + const [deletePopoverOpen, setDeletePopoverOpen] = useState(false) + const { workspaceList, getWorkspaceDetail } = useRepoStore(); + const workspace = useMemo(() => { + return workspaceList.find(ws => ws.slug === repo.path) + }, [workspaceList, repo.path]) + const isWorkspaceActive = !!workspace + const owner = repo.path.split('/')[0] + const isMine = myOrgs.includes(owner) + return ( + <> + +
+
+
+ + {repo.path} + + {isWorkspaceActive && ( + + + + + )} +
+
+ + + onStartWorkspace(repo)} + className="h-8 w-8 p-0 border-neutral-200 hover:border-neutral-900 hover:bg-neutral-900 hover:text-white transition-all cursor-pointer" + > + + + } + /> + +

启动工作区

+
+
+
+ + + + + } + /> + + onEdit(repo)} className="cursor-pointer"> + + 编辑 + + onIssue(repo)} className="cursor-pointer"> + + Issue + + onSettings(repo)} className="cursor-pointer"> + + 设置 + + { + e.preventDefault() + setDeletePopoverOpen(true) + }} + className="cursor-pointer text-red-600 focus:text-red-600 focus:bg-red-50" + > + + 删除 + + + + + +
+ + +
+
+

确认删除

+

+ 确定要删除仓库 {repo.path} 吗?此操作无法撤销。 +

+
+
+ + +
+
+
+ +
+
+ + {repo.topics && ( +
+ {repo.topics.split(',').map((topic: string, idx: number) => ( + + {topic.trim()} + + ))} +
+ )} + + {repo.site && ( + + 🔗 {repo.site} + + )} + + {repo.description && ( +

+ {repo.description} +

+ )} + +
+ + + {repo.star_count} + + + + {repo.fork_count} + + + + {repo.open_issue_count} + + {isWorkspaceActive && { + getWorkspaceDetail(workspace) + }}> + + 运行中 + } + {isMine && ( + onSync?.(repo)} + > + + 同步 + + )} +
+
+ + + ) +} diff --git a/src/app/repo/modules/EditRepoDialog.tsx b/src/app/repo/modules/EditRepoDialog.tsx new file mode 100644 index 0000000..233c113 --- /dev/null +++ b/src/app/repo/modules/EditRepoDialog.tsx @@ -0,0 +1,140 @@ +import { useEffect, useState } from 'react' +import { useForm } from 'react-hook-form' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +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 { TagsInput } from '@/components/tags-input' +import { useRepoStore } from '../store' + +interface EditRepoDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + repo: { + id: string + path: string + description: string + site: string + topics: string + license: string + } | null +} + +interface FormData { + description: string + site: string + topics: string + license: string +} + +export function EditRepoDialog({ open, onOpenChange, repo }: EditRepoDialogProps) { + const { updateRepoInfo, getList } = useRepoStore() + const { register, handleSubmit, reset, setValue } = useForm() + const [tags, setTags] = useState([]) + + useEffect(() => { + if (repo) { + const topicsArray = repo.topics ? repo.topics.split(',').map(t => t.trim()).filter(Boolean) : [] + setTags(topicsArray) + reset({ + description: repo.description || '', + site: repo.site || '', + topics: repo.topics || '', + license: repo.license || '' + }) + } + }, [repo, reset]) + + const onSubmit = async (data: FormData) => { + if (!repo) return + + await updateRepoInfo({ + path: repo.path, + description: data.description?.trim() || '', + site: data.site?.trim() || '', + topics: tags.join(','), + license: data.license?.trim() || '', + }) + + await getList(true) + onOpenChange(false) + } + + if (!repo) return null + + return ( + + + + 编辑仓库信息 + {repo.path} + + +
+
+ +