feat: 添加仓库管理页面和 AI 功能,优化路由和导航

- 新增仓库列表页面,支持查看和管理 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 <noreply@anthropic.com>
This commit is contained in:
2026-02-09 04:44:28 +08:00
parent 0ced574b8b
commit a2629fec7b
22 changed files with 1606 additions and 62 deletions

View File

@@ -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 (
<>
<Card className="relative p-0 overflow-hidden border border-neutral-200 bg-white hover:shadow-xl hover:border-neutral-300 transition-all duration-300 group pb-14">
<div className="p-6 space-y-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 flex-1 min-w-0">
<a
href={repo.web_url}
target="_blank"
rel="noopener noreferrer"
className="text-lg font-bold text-neutral-900 hover:text-neutral-600 transition-colors line-clamp-1 group-hover:underline"
>
{repo.path}
</a>
{isWorkspaceActive && (
<span className="relative flex h-2.5 w-2.5 shrink-0">
<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>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
size="sm"
variant="outline"
onClick={() => 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"
>
<Play className="w-4 h-4" />
</Button>
}
/>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
size="sm"
variant="outline"
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"
>
<MoreVertical className="w-4 h-4" />
</Button>
}
/>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem onClick={() => onEdit(repo)} className="cursor-pointer">
<Edit className="w-4 h-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onIssue(repo)} className="cursor-pointer">
<IssueIcon className="w-4 h-4 mr-2" />
Issue
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onSettings(repo)} className="cursor-pointer">
<Settings className="w-4 h-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault()
setDeletePopoverOpen(true)
}}
className="cursor-pointer text-red-600 focus:text-red-600 focus:bg-red-50"
>
<Trash2 className="w-4 h-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Popover open={deletePopoverOpen} onOpenChange={setDeletePopoverOpen}>
<PopoverTrigger >
<div style={{ display: 'none' }} />
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="space-y-4">
<div className="space-y-2">
<h4 className="font-medium text-sm"></h4>
<p className="text-sm text-neutral-500">
<span className="font-semibold text-neutral-900">{repo.path}</span>
</p>
</div>
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() => setDeletePopoverOpen(false)}
>
</Button>
<Button
size="sm"
variant="outline"
className="bg-red-600 text-white border-red-600 hover:bg-red-700 hover:border-red-700"
onClick={() => {
onDelete(repo)
setDeletePopoverOpen(false)
}}
>
</Button>
</div>
</div>
</PopoverContent>
</Popover>
</div>
</div>
{repo.topics && (
<div className="flex flex-wrap gap-2">
{repo.topics.split(',').map((topic: string, idx: number) => (
<Badge key={idx} variant="outline" className="text-xs border-neutral-300 text-neutral-700 hover:bg-neutral-100 transition-colors">
{topic.trim()}
</Badge>
))}
</div>
)}
{repo.site && (
<a
href={repo.site}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-neutral-500 hover:text-neutral-900 hover:underline block truncate transition-colors"
>
🔗 {repo.site}
</a>
)}
{repo.description && (
<p className="text-sm text-neutral-600 line-clamp-2 min-h-[2.5rem]">
{repo.description}
</p>
)}
<div className="absolute bottom-0 left-0 right-0 flex items-center gap-4 text-xs text-neutral-500 px-6 py-3 border-t border-neutral-100 bg-neutral-50">
<span className="flex items-center gap-1.5 hover:text-neutral-900 transition-colors">
<Star className="w-3.5 h-3.5" />
<span className="font-medium">{repo.star_count}</span>
</span>
<span className="flex items-center gap-1.5 hover:text-neutral-900 transition-colors">
<GitFork className="w-3.5 h-3.5" />
<span className="font-medium">{repo.fork_count}</span>
</span>
<span className="flex items-center gap-1.5 hover:text-neutral-900 transition-colors">
<FileText className="w-3.5 h-3.5" />
<span className="font-medium">{repo.open_issue_count}</span>
</span>
{isWorkspaceActive && <span className="flex items-center gap-1.5 hover:text-neutral-900 transition-colors cursor-pointer"
onClick={() => {
getWorkspaceDetail(workspace)
}}>
<Play className="w-3.5 h-3.5" />
<span className="font-medium"></span>
</span>}
{isMine && (
<span
className="flex items-center gap-1.5 hover:text-neutral-900 transition-colors cursor-pointer"
onClick={() => onSync?.(repo)}
>
<RefreshCw className="w-3.5 h-3.5" />
<span className="font-medium"></span>
</span>
)}
</div>
</div>
</Card>
</>
)
}

View File

@@ -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<FormData>()
const [tags, setTags] = useState<string[]>([])
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl!">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>{repo.path}</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
{...register('description')}
placeholder="输入仓库描述"
className="w-full min-h-[100px]"
rows={4}
/>
</div>
<div className="space-y-2">
<Label htmlFor="site"></Label>
<Input
id="site"
{...register('site')}
placeholder="https://example.com"
type="url"
className="w-full"
/>
</div>
<div className="space-y-2">
<Label htmlFor="topics"></Label>
<TagsInput
value={tags}
onChange={setTags}
placeholder="输入标签后按 Enter 或逗号添加"
/>
<p className="text-xs text-gray-500"> Enter × </p>
</div>
<div className="space-y-2">
<Label htmlFor="license"></Label>
<Input
id="license"
{...register('license')}
placeholder="MIT, Apache-2.0, GPL-3.0 等"
className="w-full"
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
</Button>
<Button type="submit">
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,85 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useRepoStore } from '../store'
import { useState, useEffect } from 'react'
import { get, set } from 'idb-keyval'
const SYNC_REPO_STORAGE_KEY = 'sync-repo-mapping'
export function SyncRepoDialog() {
const { syncDialogOpen, setSyncDialogOpen, selectedSyncRepo, buildSync } = useRepoStore()
const [toRepo, setToRepo] = useState('')
useEffect(() => {
const loadSavedMapping = async () => {
if (syncDialogOpen && selectedSyncRepo) {
const currentPath = selectedSyncRepo.path || ''
// 从 idb-keyval 获取存储的映射
const mapping = await get<Record<string, string>>(SYNC_REPO_STORAGE_KEY)
// 如果有存储的值,使用存储的值,否则使用当前仓库路径
setToRepo(mapping?.[currentPath] || currentPath)
}
}
loadSavedMapping()
}, [syncDialogOpen, selectedSyncRepo])
const handleSync = async () => {
if (!selectedSyncRepo || !toRepo.trim()) {
return
}
// 保存映射到 idb-keyval
const currentPath = selectedSyncRepo.path || ''
const mapping = await get<Record<string, string>>(SYNC_REPO_STORAGE_KEY) || {}
mapping[currentPath] = toRepo
await set(SYNC_REPO_STORAGE_KEY, mapping)
await buildSync(selectedSyncRepo, { toRepo })
setSyncDialogOpen(false)
}
return (
<Dialog open={syncDialogOpen} onOpenChange={setSyncDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle> Gitea</DialogTitle>
<DialogDescription>
<span className="font-semibold text-neutral-900">{selectedSyncRepo?.path}</span>
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="toRepo"></Label>
<Input
id="toRepo"
placeholder="例如: kevisual/my-repo"
value={toRepo}
onChange={(e) => setToRepo(e.target.value)}
/>
<p className="text-xs text-neutral-500">
格式: owner/repo-name
</p>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setSyncDialogOpen(false)}
>
</Button>
<Button
onClick={handleSync}
disabled={!toRepo.trim()}
>
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,185 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { useRepoStore } from '../store'
import type { WorkspaceOpen } from '../store'
import {
Code2,
Terminal,
Sparkles,
MousePointer2,
Box,
Lock,
Radio,
Bot,
Zap,
Copy,
Check
} from 'lucide-react'
import { useState } from 'react'
import { toast } from 'sonner'
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 [isHovered, setIsHovered] = useState(false)
const [isCopied, setIsCopied] = useState(false)
const handleClick = () => {
if (url?.startsWith?.('ssh') || url?.startsWith?.('cnb')) {
copy()
return;
}
if (url) {
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('复制失败')
}
}
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation()
if (!url) return
copy()
}
return (
<button
onClick={handleClick}
disabled={!url}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
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 && isHovered && (
<div
onClick={handleCopy}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleCopy(e as any)
}
}}
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>
)}
</button>
)
}
export function WorkspaceDetailDialog() {
const { showWorkspaceDialog, setShowWorkspaceDialog, workspaceLink } = useRepoStore()
const linkItems: LinkItem[] = [
{
key: 'webide' as LinkItemKey,
label: 'Web IDE',
icon: <Code2 className="w-5 h-5" />,
order: 1,
getUrl: (data) => data.webide
},
{
key: 'vscode' as LinkItemKey,
label: 'VS Code',
icon: <Code2 className="w-5 h-5" />,
order: 2,
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,
getUrl: (data) => data.cursor
},
{
key: 'jetbrains' as LinkItemKey,
label: 'JetBrains IDEs',
icon: <Box className="w-5 h-5" />,
order: 7,
getUrl: (data) => Object.values(data.jetbrains || {}).find(Boolean)
},
{
key: 'ssh' as LinkItemKey,
label: 'SSH',
icon: <Lock className="w-5 h-5" />,
order: 4,
getUrl: (data) => data.ssh
},
{
key: 'remoteSsh' as LinkItemKey,
label: 'Remote SSH',
icon: <Radio className="w-5 h-5" />,
order: 8,
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',
icon: <Zap className="w-5 h-5" />,
order: 3,
getUrl: (data) => data.codebuddycn
},
].sort((a, b) => (a.order || 0) - (b.order || 0))
return (
<Dialog open={showWorkspaceDialog} onOpenChange={setShowWorkspaceDialog}>
<DialogContent className="max-w-md! bg-white">
<DialogHeader>
<DialogTitle className="text-neutral-900"></DialogTitle>
<DialogDescription className="text-neutral-500"></DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-3">
{linkItems.map((item) => (
<LinkItem
key={item.key}
label={item.label}
icon={item.icon}
url={item.getUrl(workspaceLink)}
/>
))}
</div>
</DialogContent>
</Dialog>
)
}

93
src/app/repo/page.tsx Normal file
View File

@@ -0,0 +1,93 @@
import { useEffect } from 'react'
import { useRepoStore } from './store/index'
import { RepoCard } from './components/RepoCard'
import { EditRepoDialog } from './modules/EditRepoDialog'
import { WorkspaceDetailDialog } from './modules/WorkspaceDetailDialog'
import { SyncRepoDialog } from './modules/SyncRepoDialog'
export const App = () => {
const { list, getList, loading, editRepo, setEditRepo, showEditDialog, setShowEditDialog, startWorkspace, getWorkspaceList, deleteItem, setSelectedSyncRepo, setSyncDialogOpen } = useRepoStore()
useEffect(() => {
getList()
getWorkspaceList()
}, [])
const handleEdit = (repo: any) => {
setEditRepo(repo)
setShowEditDialog(true)
}
const handleIssue = (repo: any) => {
window.open(`https://cnb.cool/${repo.path}/-/issues`)
}
const handleSettings = (repo: any) => {
window.open(`https://cnb.cool/${repo.path}/-/settings`)
}
const handleDelete = (repo: any) => {
if (repo.path)
deleteItem(repo.path)
}
const handleSync = (repo: any) => {
setSelectedSyncRepo(repo)
setSyncDialogOpen(true)
}
return (
<div className="min-h-screen bg-neutral-50 flex flex-col">
<div className="container mx-auto p-6 max-w-7xl flex-1">
<div className="mb-8">
<h1 className="text-4xl font-bold text-neutral-900 mb-2"></h1>
<p className="text-neutral-600"> {list.length} </p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{list.map((repo) => (
<RepoCard
key={repo.id}
repo={repo}
onStartWorkspace={startWorkspace}
onEdit={handleEdit}
onIssue={handleIssue}
onSettings={handleSettings}
onDelete={handleDelete}
onSync={handleSync}
/>
))}
</div>
{list.length === 0 && !loading && (
<div className="text-center py-20">
<div className="text-neutral-400 text-lg"></div>
</div>
)}
</div>
<footer className="border-t border-neutral-200 bg-white py-6 mt-auto">
<div className="container mx-auto px-6 max-w-7xl">
<div className="flex items-center justify-between text-sm text-neutral-500">
<div>© 2026 </div>
<div className="flex items-center gap-4">
<a href="#" className="hover:text-neutral-900 transition-colors"></a>
<a href="#" className="hover:text-neutral-900 transition-colors"></a>
<a href="#" className="hover:text-neutral-900 transition-colors"></a>
</div>
</div>
</div>
</footer>
<EditRepoDialog
open={showEditDialog}
onOpenChange={setShowEditDialog}
repo={editRepo}
/>
<WorkspaceDetailDialog />
<SyncRepoDialog />
</div>
)
}
export default App;

View File

@@ -0,0 +1,33 @@
export const myOrgs = ['kevisual', 'kevision', 'skillpod', 'zxj.im', 'abearxiong']
export const createBuildConfig = (params: { repo: string }) => {
const toRepo = params.repo!;
return `
# .cnb.yml
include:
- https://cnb.cool/kevisual/cnb/-/blob/main/.cnb/template.yml
.common_env: &common_env
env:
TO_REPO: ${toRepo}
TO_URL: git.xiongxiao.me
imports:
- https://cnb.cool/kevisual/env/-/blob/main/.env.development
.common_sync_to_gitea: &common_sync_to_gitea
- <<: *common_env
services: !reference [.common_sync_to_gitea_template, services]
stages: !reference [.common_sync_to_gitea_template, stages]
.common_sync_from_gitea: &common_sync_from_gitea
- <<: *common_env
services: !reference [.common_sync_from_gitea_template, services]
stages: !reference [.common_sync_from_gitea_template, stages]
main:
api_trigger_sync_to_gitea:
- <<: *common_sync_to_gitea
api_trigger_sync_from_gitea:
- <<: *common_sync_from_gitea
`
};

313
src/app/repo/store/index.ts Normal file
View File

@@ -0,0 +1,313 @@
import { create } from 'zustand';
import { query } from '@/modules/query';
import { toast } from 'sonner';
import { cnb } from '@/agents/app'
import { WorkspaceInfo } from '@kevisual/cnb'
import { createBuildConfig } from './build';
interface DisplayModule {
activity: boolean;
contributors: boolean;
release: boolean;
}
interface Languages {
language: string;
color: string;
}
interface Data {
id: string;
name: string;
freeze: boolean;
status: number;
visibility_level: string;
flags: string;
created_at: string;
updated_at: string;
description: string;
site: string;
topics: string;
license: string;
display_module: DisplayModule;
star_count: number;
fork_count: number;
mark_count: number;
last_updated_at?: string | null;
web_url: string;
path: string;
tags: any;
open_issue_count: number;
open_pull_request_count: number;
languages: Languages;
second_languages: Languages;
last_update_username: string;
last_update_nickname: string;
access: string;
stared: boolean;
star_time: string;
pinned: boolean;
pinned_time: string;
}
type State = {
formData: Record<string, any>;
setFormData: (data: Record<string, any>) => void;
showEdit: boolean;
setShowEdit: (showEdit: boolean) => void;
loading: boolean;
setLoading: (loading: boolean) => void;
list: Data[];
editRepo: Data | null;
setEditRepo: (repo: Data | null) => void;
showEditDialog: boolean;
setShowEditDialog: (show: boolean) => void;
getList: (silent?: boolean) => Promise<any>;
updateRepoInfo: (data: Partial<Data>) => Promise<any>;
deleteItem: (repo: string) => Promise<any>;
workspaceList: WorkspaceInfo[];
getWorkspaceList: () => Promise<any>;
startWorkspace: (data: Partial<Data>, params?: { open?: boolean, branch?: string }) => Promise<any>;
getWorkspaceDetail: (data: WorkspaceInfo) => Promise<any>;
workspaceLink: Partial<WorkspaceOpen>;
showWorkspaceDialog: boolean;
setShowWorkspaceDialog: (show: boolean) => void;
syncDialogOpen: boolean;
setSyncDialogOpen: (open: boolean) => void;
selectedSyncRepo: Data | null;
setSelectedSyncRepo: (repo: Data | null) => void;
buildSync: (data: Partial<Data>, params: { toRepo?: string, fromRepo?: string }) => Promise<any>;
}
export const useRepoStore = create<State>((set, get) => {
return {
formData: {},
setFormData: (data) => set({ formData: data }),
showEdit: false,
setShowEdit: (showEdit) => set({ showEdit }),
loading: false,
setLoading: (loading) => set({ loading }),
list: [],
editRepo: null,
setEditRepo: (repo) => set({ editRepo: repo }),
showEditDialog: false,
setShowEditDialog: (show) => set({ showEditDialog: show }),
showWorkspaceDialog: false,
setShowWorkspaceDialog: (show) => set({ showWorkspaceDialog: show }),
syncDialogOpen: false,
setSyncDialogOpen: (open) => set({ syncDialogOpen: open }),
selectedSyncRepo: null,
setSelectedSyncRepo: (repo) => set({ selectedSyncRepo: repo }),
getItem: async (id) => {
const { setLoading } = get();
setLoading(true);
try {
const res = await query.post({
path: 'demo',
key: 'item',
data: { id }
})
if (res.code === 200) {
return res;
} else {
toast.error(res.message || '请求失败');
}
} finally {
setLoading(false);
}
},
getList: async (silent = false) => {
const { setLoading } = get();
if (!silent) {
setLoading(true);
}
try {
const res = await cnb.repo.getRepoList({})
if (res.code === 200) {
const list = res.data! || []
set({ list });
} else {
toast.error(res.message || '请求失败');
}
return res;
} finally {
if (!silent) {
setLoading(false);
}
}
},
updateRepoInfo: async (data) => {
const repo = data.path!;
const topics = data.topics?.split?.(',');
const updateData = {
description: data.description,
license: data?.license as any,
site: data.site,
topics: topics,
}
const res = await cnb.repo.updateRepoInfo(repo, updateData)
if (res.code === 200) {
toast.success('更新成功');
} else {
toast.error(res.message || '更新失败');
}
},
deleteItem: async (repo: string) => {
try {
const res = await cnb.repo.deleteRepoCookie(repo)
if (res.code === 200) {
toast.success('删除成功');
// 刷新列表
await get().getList(true);
} else {
toast.error(res.message || '删除失败');
}
} catch (e: any) {
// 如果是 JSON 解析错误,说明删除成功但响应为空
if (e.message?.includes('JSON') || e.message?.includes('json')) {
toast.success('删除成功');
// 刷新列表
await get().getList(true);
} else {
toast.error('删除失败');
console.error('删除错误:', e);
}
}
},
workspaceList: [],
getWorkspaceList: async () => {
const res = await cnb.workspace.list({
status: 'running',
pageSize: 100
})
if (res.code === 200) {
const list: WorkspaceInfo[] = res.data?.list as any;
set({ workspaceList: list || [] })
} else {
toast.error(res.message || '请求失败');
}
},
startWorkspace: async (data, params = { open: true, branch: 'main' }) => {
const repo = data.path;
const checkOpen = async () => {
const res = await cnb.workspace.startWorkspace(repo!, {
branch: params.branch || 'main'
})
if (res.code === 200) {
if (!res?.data?.sn) {
const url = res.data?.url! || '';
if (url.includes('loading')) {
return {
code: 400,
data: res.data
}
}
return {
code: 200,
data: res.data
};
}
toast.success(`新创建了一个工作区sn: ${res.data.sn}`)
return {
code: 300,
data: res.data,
message: '第一次启动'
}
} else {
return {
code: res.code,
message: res.message || '请求失败'
}
}
}
const res = await checkOpen()
if (res.code === 300) {
toast.success(`新创建了一个工作区sn: ${res.data?.sn}`)
if (params.open) {
const loadingToastId = toast.loading('正在启动工作区...')
const interval = setInterval(async () => {
const check = await checkOpen()
if (check.code === 200 && check.data?.url) {
clearInterval(interval)
toast.dismiss(loadingToastId)
toast.success(`工作区已启动,正在打开: ${check.data.url}`)
setTimeout(() => {
window.open(check.data?.url, '_blank')
}, 200)
} else if (check.code === 400) {
//
}
}, 1000);
// 30秒后自动停止检测
setTimeout(() => {
clearInterval(interval)
toast.dismiss(loadingToastId)
toast.error('工作区启动超时')
}, 3 * 60 * 1000)
}
return;
}
if (res.code === 200 && res.data?.url) {
console.log('res', res)
toast.success(`工作区已启动,正在打开: ${res.data.url}`)
setTimeout(() => {
window.open(res.data?.url, '_blank')
}, 200)
} else {
toast.error(res.message || '启动失败');
}
return res;
},
getWorkspaceDetail: async (workspaceInfo) => {
const res = await cnb.workspace.getDetail(workspaceInfo.slug, workspaceInfo.sn) as any;
if (res.code === 200) {
set({
workspaceLink: res.data,
showWorkspaceDialog: true
})
}
},
workspaceLink: {},
buildSync: async (data, params) => {
const repo = data.path!;
const toRepo = params.toRepo;
const fromRepo = params.fromRepo;
if (!toRepo && !fromRepo) {
toast.error('请选择同步的目标仓库或来源仓库')
return;
}
let event = toRepo ? 'api_trigger_sync_to_gitea' : 'api_trigger_sync_from_gitea';
const res = await cnb.build.startBuild(repo, {
branch: 'main',
env: {},
event: event,
config: createBuildConfig({ repo: toRepo! || fromRepo! }),
})
if (res.code === 200) {
toast.success('同步提交成功')
} else {
toast.error(res.message || '同步提交失败')
}
}
}
})
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;
}
const openWorkspace = (workspace: WorkspaceInfo, params: { vscode?: boolean, ssh?: boolean }) => {
const openVsCode = params?.vscode ?? true;
if (openVsCode) {
const pipeline_id = workspace.pipeline_id;
const url = `vscode://vscode-remote/ssh-remote+${pipeline_id}`
}
}