feat: add repository management dialogs and store functionality

- Implement CreateRepoDialog for creating new repositories with form validation.
- Implement EditRepoDialog for editing existing repository details.
- Implement SyncRepoDialog for syncing repositories with Gitea, including repository creation if necessary.
- Implement WorkspaceDetailDialog for managing workspace links and actions.
- Enhance the repo store with new state management for repository actions, including creating, editing, and syncing repositories.
- Add build configuration utilities for repository synchronization.
- Create a new page for repository management, integrating all dialogs and functionalities.
- Add login route for authentication.
This commit is contained in:
2026-02-25 01:02:55 +08:00
parent f4643464ba
commit 7ec6428643
32 changed files with 3303 additions and 71 deletions

43
src/pages/ai/page.tsx Normal file
View File

@@ -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 <div>Home Page</div>
}
export default Home;

58
src/pages/auth/index.tsx Normal file
View File

@@ -0,0 +1,58 @@
import { useEffect } from "react"
import { useLayoutStore } from "./store"
import { useShallow } from "zustand/shallow"
import { LogIn, LockKeyhole } from "lucide-react"
export { BaseHeader } from './modules/BaseHeader'
import { useMemo } from 'react';
import { useLocation, useNavigate } from '@tanstack/react-router';
type Props = {
children?: React.ReactNode,
mustLogin?: boolean,
}
export const AuthProvider = ({ children, mustLogin }: Props) => {
const store = useLayoutStore(useShallow(state => ({
init: state.init,
me: state.me,
openLinkList: state.openLinkList,
})));
useEffect(() => {
store.init()
}, [])
const location = useLocation()
const navigate = useNavigate();
const isOpen = useMemo(() => {
return store.openLinkList.some(item => location.pathname.startsWith(item))
}, [location.pathname])
const loginUrl = '/root/login/?redirect=' + encodeURIComponent(window.location.href);
if (mustLogin && !store.me && !isOpen) {
return (
<div className="w-full h-full min-h-screen flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-6 p-10 rounded-2xl border border-border bg-card shadow-lg max-w-sm w-full mx-4">
<div className="flex items-center justify-center w-16 h-16 rounded-full bg-muted">
<LockKeyhole className="w-8 h-8 text-muted-foreground" />
</div>
<div className="flex flex-col items-center gap-2 text-center">
<h2 className="text-xl font-semibold text-foreground"></h2>
<p className="text-sm text-muted-foreground">访</p>
</div>
<div
className="inline-flex items-center justify-center gap-2 w-full px-6 py-2.5 rounded-lg bg-foreground text-background text-sm font-medium transition-opacity hover:opacity-80 active:opacity-70"
onClick={() => {
// window.open(loginUrl, '_blank');
navigate({ to: '/login' });
}}
>
<LogIn className="w-4 h-4" />
</div>
</div>
</div>
)
}
return <>
{children}
</>
}

View File

@@ -0,0 +1,80 @@
import { Home, User, LogIn, LogOut } from 'lucide-react';
import { Link, useNavigate } from '@tanstack/react-router'
import { useLayoutStore } from '../store';
import { useShallow } from 'zustand/shallow';
import { useMemo } from 'react';
export const BaseHeader = (props: { main?: React.ComponentType | null }) => {
const store = useLayoutStore(useShallow(state => ({
me: state.me,
clearMe: state.clearMe,
links: state.links,
})));
const navigate = useNavigate();
const meInfo = useMemo(() => {
if (!store.me) {
return (
<button
onClick={() => navigate({ to: '/login' })}
className="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer"
>
<LogIn className="w-4 h-4" />
<span></span>
</button>
)
}
return (
<div className="flex items-center gap-3">
{store.me.avatar && (
<img
src={store.me.avatar}
alt="Avatar"
className="w-8 h-8 rounded-full object-cover"
/>
)}
{!store.me.avatar && (
<div className="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center">
<User className="w-4 h-4 text-gray-500" />
</div>
)}
<span className="font-medium text-gray-700">{store.me?.username}</span>
<button
onClick={() => store.clearMe?.()}
className="flex items-center gap-1 px-2 py-1 text-sm text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors cursor-pointer"
title="退出登录"
>
<LogOut className="w-4 h-4" />
</button>
</div>
)
}, [store.me, store.clearMe])
return (
<>
<div className="flex gap-2 text-lg w-full h-12 items-center justify-between bg-gray-200">
<div className='px-2'>
{
store.links.map(link => (
<Link
key={link.key || link.title}
to={link.href}
className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
>
{link.key === 'home' && <Home className="w-4 h-4 mr-1" />}
{link.icon && <span className="mr-1">{link.icon}</span>}
{link.title}
</Link>
))
}
</div>
<div className='mr-4'>
{meInfo}
</div>
</div>
<hr />
</>
)
}
export const LayoutMain = () => {
return <BaseHeader />
}

81
src/pages/auth/page.tsx Normal file
View File

@@ -0,0 +1,81 @@
import { useContextKey } from '@kevisual/context';
import '@kevisual/kv-login';
import { checkPluginLogin } from '@kevisual/kv-login'
import { useEffect } from 'react';
import { useLayoutStore } from './store';
import { useShallow } from 'zustand/shallow';
import { useNavigate } from '@tanstack/react-router';
export const LoginComponent = ({ onLoginSuccess }: { onLoginSuccess: () => void }) => {
useEffect(() => {
// 监听登录成功事件
const handleLoginSuccess = () => {
console.log('监听到登录成功事件,关闭弹窗');
onLoginSuccess();
};
const loginEmitter = useContextKey('login-emitter')
console.log('KvLogin Types:', loginEmitter);
loginEmitter.on('login-success', handleLoginSuccess);
// 清理监听器
return () => {
loginEmitter.off('login-success', handleLoginSuccess);
};
}, [onLoginSuccess]);
// @ts-ignore
return (<kv-login></kv-login>)
}
export const App = () => {
const store = useLayoutStore(useShallow((state) => ({
init: state.init,
loginPageConfig: state.loginPageConfig,
})));
useEffect(() => {
checkPluginLogin();
}, []);
const navigate = useNavigate();
const handleLoginSuccess = async () => {
await store.init()
navigate({ to: '/' })
};
const { title, subtitle, footer } = store.loginPageConfig;
return (
<div className='w-full h-full relative overflow-hidden bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900'>
{/* 背景装饰 - 圆形光晕 */}
<div className='absolute top-1/4 -left-32 w-96 h-96 bg-purple-500/30 rounded-full blur-3xl'></div>
<div className='absolute bottom-1/4 -right-32 w-96 h-96 bg-blue-500/30 rounded-full blur-3xl'></div>
<div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-indigo-500/20 rounded-full blur-3xl'></div>
{/* 背景装饰 - 网格图案 */}
<div className='absolute inset-0 opacity-[0.03] bg-[linear-gradient(rgba(255,255,255,0.1)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.1)_1px,transparent_1px)] bg-[size:50px_50px]'></div>
{/* 顶部装饰文字 */}
<div className='absolute top-10 left-0 right-0 text-center'>
<h1 className='text-4xl font-bold text-white/90 tracking-wider'>{title}</h1>
<p className='mt-2 text-white/50 text-sm tracking-widest'>{subtitle}</p>
</div>
{/* 登录卡片容器 */}
<div className='w-full h-full flex items-center justify-center p-8'>
<div className='relative'>
{/* 卡片外圈光效 */}
<div className='absolute -inset-1 bg-gradient-to-r from-purple-500 via-blue-500 to-indigo-500 rounded-2xl blur opacity-30'></div>
{/* 登录组件容器 */}
<div className='relative bg-slate-900/80 backdrop-blur-xl rounded-2xl border border-white/10 shadow-2xl overflow-hidden'>
<LoginComponent onLoginSuccess={handleLoginSuccess} />
</div>
</div>
</div>
{/* 底部装饰 */}
<div className='absolute bottom-6 left-0 right-0 text-center'>
<p className='text-white/30 text-xs'>{footer}</p>
</div>
</div>
)
}
export default App;

103
src/pages/auth/store.ts Normal file
View File

@@ -0,0 +1,103 @@
import { queryLogin } from '@/modules/query';
import { create } from 'zustand';
import { toast } from 'sonner';
type UserInfo = {
id?: string;
username?: string;
nickname?: string | null;
needChangePassword?: boolean;
description?: string | null;
type?: 'user' | 'org';
orgs?: string[];
avatar?: string;
};
export type LayoutStore = {
open: boolean;
setOpen: (open: boolean) => void;
openUser: boolean;
setOpenUser: (openUser: boolean) => void;
me?: UserInfo;
setMe: (me: UserInfo) => void;
clearMe: () => void;
getMe: () => Promise<void>;
switchOrg: (username?: string) => Promise<void>;
isAdmin: boolean;
setIsAdmin: (isAdmin: boolean) => void
init: () => Promise<void>;
openLinkList: string[];
setOpenLinkList: (openLinkList: string[]) => void;
loginPageConfig: {
title: string;
subtitle: string;
footer: string;
};
setLoginPageConfig: (config: Partial<LayoutStore['loginPageConfig']>) => void;
links: HeaderLink[];
};
type HeaderLink = {
title: string;
href: string;
description?: string;
icon?: React.ReactNode;
key?: string;
};
export const useLayoutStore = create<LayoutStore>((set, get) => ({
open: false,
setOpen: (open) => set({ open }),
openUser: false,
setOpenUser: (openUser) => set({ openUser }),
me: undefined,
setMe: (me) => set({ me }),
clearMe: () => {
set({ me: undefined, isAdmin: false });
window.location.href = '/root/login/?redirect=' + encodeURIComponent(window.location.href);
},
getMe: async () => {
const res = await queryLogin.getMe();
if (res.code === 200) {
set({ me: res.data });
set({ isAdmin: res.data.orgs?.includes?.('admin') || false });
}
},
switchOrg: async (username?: string) => {
const res = await queryLogin.switchUser(username || '');
if (res.code === 200) {
toast.success('切换成功');
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
toast.error(res.message || '请求失败');
}
},
isAdmin: false,
setIsAdmin: (isAdmin) => set({ isAdmin }),
init: async () => {
const token = await queryLogin.getToken();
if (token) {
set({ me: {} })
const me = await queryLogin.getMe();
// const user = await queryLogin.checkLocalUser() as UserInfo;
const user = me.code === 200 ? me.data : undefined;
if (user) {
set({ me: user });
set({ isAdmin: user.orgs?.includes?.('admin') || false });
} else {
set({ me: undefined, isAdmin: false });
}
}
},
openLinkList: ['/login'],
setOpenLinkList: (openLinkList) => set({ openLinkList }),
loginPageConfig: {
title: '可视化管理平台',
subtitle: '让工具和智能化触手可及',
footer: '欢迎使用可视化管理平台 · 连接您的工具',
},
setLoginPageConfig: (config) => set((state) => ({
loginPageConfig: { ...state.loginPageConfig, ...config },
})),
links: [{ title: '首页', href: '/', key: 'home' }],
}));

View File

@@ -0,0 +1,77 @@
import { useGiteaConfigStore } from './store';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { giteaConfigSchema } from './store/schema';
import { toast } from 'sonner';
export const GiteaConfigPage = () => {
const { config, setConfig, resetConfig } = useGiteaConfigStore();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const result = giteaConfigSchema.safeParse(config);
if (result.success) {
toast.success('Gitea 配置已保存');
setTimeout(() => {
location.reload();
}, 400);
} else {
console.error('验证错误:', result.error.format());
toast.error('配置验证失败');
}
};
const handleChange = (field: keyof typeof config, value: string) => {
setConfig({ [field]: value });
};
return (
<div className="container mx-auto max-w-2xl py-8">
<Card>
<CardHeader>
<CardTitle>Gitea </CardTitle>
<CardDescription>
Gitea API
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="gitea-url">Gitea </Label>
<Input
id="gitea-url"
type="url"
value={config.GITEA_URL}
onChange={(e) => handleChange('GITEA_URL', e.target.value)}
placeholder="https://git.xiongxiao.me"
/>
</div>
<div className="space-y-2">
<Label htmlFor="gitea-token">Gitea Token</Label>
<Input
id="gitea-token"
type="password"
value={config.GITEA_TOKEN}
onChange={(e) => handleChange('GITEA_TOKEN', e.target.value)}
placeholder="请输入您的 Gitea Access Token"
/>
</div>
<div className="flex gap-4">
<Button type="submit"></Button>
<Button type="button" variant="outline" onClick={resetConfig}>
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
};
export default GiteaConfigPage;

View File

@@ -0,0 +1,50 @@
import { create } from 'zustand';
import type { GiteaConfig } from './schema';
type GiteaConfigState = {
config: GiteaConfig;
setConfig: (config: Partial<GiteaConfig>) => void;
resetConfig: () => void;
};
const DEFAULT_CONFIG: GiteaConfig = {
GITEA_TOKEN: '',
GITEA_URL: 'https://git.xiongxiao.me',
};
const loadInitialConfig = (): GiteaConfig => {
try {
const token = localStorage.getItem('GITEA_TOKEN') || '';
const url = localStorage.getItem('GITEA_URL') || DEFAULT_CONFIG.GITEA_URL;
return {
GITEA_TOKEN: token,
GITEA_URL: url,
};
} catch {
// Ignore parse errors
}
return DEFAULT_CONFIG;
};
const saveConfig = (config: GiteaConfig) => {
try {
localStorage.setItem('GITEA_TOKEN', config.GITEA_TOKEN);
localStorage.setItem('GITEA_URL', config.GITEA_URL);
} catch (error) {
console.error('Failed to save config:', error);
}
};
export const useGiteaConfigStore = create<GiteaConfigState>()((set) => ({
config: loadInitialConfig(),
setConfig: (newConfig) =>
set((state) => {
const updatedConfig = { ...state.config, ...newConfig };
saveConfig(updatedConfig);
return { config: updatedConfig };
}),
resetConfig: () => {
saveConfig(DEFAULT_CONFIG);
return set({ config: DEFAULT_CONFIG });
},
}));

View File

@@ -0,0 +1,13 @@
import { z } from 'zod';
export const giteaConfigSchema = z.object({
GITEA_TOKEN: z.string().min(1, 'Gitea Token is required'),
GITEA_URL: z.url('Must be a valid URL'),
});
export type GiteaConfig = z.infer<typeof giteaConfigSchema>;
export const defaultGiteaConfig: GiteaConfig = {
GITEA_TOKEN: '',
GITEA_URL: 'https://git.xiongxiao.me',
};

View File

@@ -0,0 +1,16 @@
import { useEffect } from "react";
import { useConfigStore } from "../store";
import { useNavigate } from "@tanstack/react-router";
export const useCheckConfig = () => {
const navigate = useNavigate();
useEffect(() => {
const config = useConfigStore.getState().config;
if (!config.CNB_API_KEY) {
navigate({
to: '/config'
})
}
}, [])
}

187
src/pages/config/page.tsx Normal file
View File

@@ -0,0 +1,187 @@
import { useConfigStore } from './store';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Info } from 'lucide-react';
import { configSchema } from './store/schema';
import { toast } from 'sonner';
export const ConfigPage = () => {
const { config, setConfig, resetConfig } = useConfigStore();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const result = configSchema.safeParse(config);
if (result.success) {
toast.success('配置已保存')
setTimeout(() => {
location.reload()
}, 400)
} else {
console.error('验证错误:', result.error.format());
}
};
const handleChange = (field: keyof typeof config, value: string | boolean) => {
setConfig({ [field]: value });
};
return (
<TooltipProvider>
<div className="container mx-auto max-w-2xl py-8">
<Card>
<CardHeader>
<CardTitle>CNB </CardTitle>
<CardDescription>
CNB API
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label htmlFor="api-key">API </Label>
<Tooltip>
<TooltipTrigger>
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>
访 CNB API
<a
href="https://cnb.cool/profile/token"
target="_blank"
rel="noopener noreferrer"
className="underline ml-1 hover:text-blue-400"
>
</a>
</p>
</TooltipContent>
</Tooltip>
</div>
<Input
id="api-key"
type="text"
value={config.CNB_API_KEY}
onChange={(e) => handleChange('CNB_API_KEY', e.target.value)}
placeholder="请输入您的 CNB API 密钥"
/>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label htmlFor="cookie">Cookie</Label>
<Tooltip>
<TooltipTrigger>
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>
Cookie
<a
href="https://cnb.cool/kevisual/cnb-live-extension"
target="_blank"
rel="noopener noreferrer"
className="underline ml-1 hover:text-blue-400"
>
</a>
</p>
</TooltipContent>
</Tooltip>
</div>
<Input
id="cookie"
type="text"
value={config.CNB_COOKIE}
onChange={(e) => handleChange('CNB_COOKIE', e.target.value)}
placeholder="请输入您的 CNB Cookie"
/>
</div>
<div className="space-y-2">
<Label htmlFor="cors-url"></Label>
<Input
id="cors-url"
type="url"
value={config.CNB_CORS_URL}
onChange={(e) => handleChange('CNB_CORS_URL', e.target.value)}
placeholder="https://cors.example.com"
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="enable-cors"
checked={config.ENABLE_CORS}
onCheckedChange={(checked) => handleChange('ENABLE_CORS', checked === true)}
/>
<Label htmlFor="enable-cors" className="cursor-pointer">
</Label>
</div>
<div className="space-y-2">
<Label htmlFor="ai-base-url">AI </Label>
<Input
id="ai-base-url"
type="url"
value={config.AI_BASE_URL}
onChange={(e) => handleChange('AI_BASE_URL', e.target.value)}
placeholder="请输入 AI 基础地址"
/>
</div>
<div className="space-y-2">
<Label htmlFor="ai-model">AI </Label>
<Input
id="ai-model"
type="text"
value={config.AI_MODEL}
onChange={(e) => handleChange('AI_MODEL', e.target.value)}
placeholder="请输入 AI 模型名称"
/>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label htmlFor="ai-api-key">AI </Label>
<Tooltip>
<TooltipTrigger>
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>
使 CNB AI API
</p>
</TooltipContent>
</Tooltip>
</div>
<Input
id="ai-api-key"
type="password"
value={config.AI_API_KEY}
onChange={(e) => handleChange('AI_API_KEY', e.target.value)}
placeholder="请输入您的 AI API 密钥"
/>
</div>
<div className="flex gap-4">
<Button type="submit"></Button>
<Button type="button" variant="outline" onClick={resetConfig}>
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</TooltipProvider>
);
};
export default ConfigPage;

View File

@@ -0,0 +1,51 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { Config, defaultConfig } from './schema';
type ConfigState = {
config: Config;
setConfig: (config: Partial<Config>) => void;
resetConfig: () => void;
};
const STORAGE_KEY = 'cnb-config';
const DEFAULT_CONFIG = {
CNB_API_KEY: '',
CNB_COOKIE: '',
CNB_CORS_URL: 'https://cors.kevisual.cn',
ENABLE_CORS: true,
AI_BASE_URL: 'https://api.cnb.cool/kevisual/cnb-ai/-/ai/',
AI_MODEL: 'CNB-Models',
AI_API_KEY: ''
}
const loadInitialConfig = (): Config => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
return JSON.parse(stored);
}
} catch {
// Ignore parse errors
}
return DEFAULT_CONFIG;
};
export const useConfigStore = create<ConfigState>()(
persist(
(set) => ({
config: loadInitialConfig(),
setConfig: (newConfig) =>
set((state) => ({
config: { ...state.config, ...newConfig },
})),
resetConfig: () =>
set({
config: DEFAULT_CONFIG,
}),
}),
{
name: STORAGE_KEY,
}
)
);

View File

@@ -0,0 +1,23 @@
import { z } from 'zod';
export const configSchema = z.object({
CNB_API_KEY: z.string().min(1, 'API Key is required'),
CNB_COOKIE: z.string().min(1, 'Cookie is required'),
CNB_CORS_URL: z.url('Must be a valid URL'),
ENABLE_CORS: z.boolean(),
AI_BASE_URL: z.url('Must be a valid URL'),
AI_MODEL: z.string().min(1, 'AI Model is required'),
AI_API_KEY: z.string().min(1, 'AI API Key is required'),
});
export type Config = z.infer<typeof configSchema>;
export const defaultConfig: Config = {
CNB_API_KEY: '',
CNB_COOKIE: '',
CNB_CORS_URL: 'https://cors.kevisual.cn',
ENABLE_CORS: true,
AI_BASE_URL: '',
AI_MODEL: '',
AI_API_KEY: ''
};

3
src/pages/page.tsx Normal file
View File

@@ -0,0 +1,3 @@
import App from './repo/page'
export default App;

View File

@@ -0,0 +1,310 @@
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, BookOpen, Copy, Clock, Info, Eye, Square } from 'lucide-react'
import { useRepoStore } from '../store'
import { useMemo, useState } from 'react'
import { myOrgs } from '../store/build'
import { app, cnb } from '@/agents/app'
import { toast } from 'sonner'
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, getList, buildUpdate, stopWorkspace } = 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)
const isKnowledge = repo?.flags === "KnowledgeBase"
const createKnow = async () => {
const res = await app.run({ path: 'cnb', key: 'build-knowledge-base', payload: { repo: repo.path } })
if (res.code === 200) {
toast.success("知识库创建中")
getList(true)
}
}
const onClone = async () => {
const url = `git clone https://cnb.cool/${repo.path}`
navigator.clipboard.writeText(url).then(() => {
toast.success('克隆地址已复制到剪贴板')
}).catch(() => {
toast.error('复制失败')
})
}
const onUpdate = async () => {
await buildUpdate({ path: repo.path });
}
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">
{isKnowledge && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<div className="shrink-0">
<BookOpen className="w-5 h-5 text-neutral-700" />
</div>
}
/>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<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">
{isWorkspaceActive && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
size="sm"
variant="outline"
onClick={() => {
stopWorkspace(workspace)
}}
className="h-8 w-8 p-0 border-neutral-200 hover:border-red-600 hover:bg-red-600 hover:text-white transition-all cursor-pointer"
>
<Square className="w-4 h-4" />
</Button>
}
/>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
size="sm"
variant="outline"
onClick={() => {
if (!isWorkspaceActive) {
onStartWorkspace(repo)
} else {
getWorkspaceDetail(workspace)
}
}}
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"
>
{isWorkspaceActive ? <Eye className="w-4 h-4" /> : <Play className="w-4 h-4" />}
</Button>
}
/>
<TooltipContent>
<p>{isWorkspaceActive ? '查看工作区' : '启动工作区'}</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={() => {
createKnow()
}} className="cursor-pointer">
<BookOpen className="w-4 h-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => {
onUpdate()
}} className="cursor-pointer">
<TooltipProvider>
<Tooltip>
<TooltipTrigger className={'flex gap-1 items-center'}>
<Clock className="w-4 h-4 mr-2" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<p> commit </p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</DropdownMenuItem>
<DropdownMenuItem onClick={onClone} className="cursor-pointer">
<Copy className="w-4 h-4 mr-2" />
Clone URL
</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-10">
{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,126 @@
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 { useRepoStore } from '../store'
interface CreateRepoDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
interface FormData {
path: string
license: string
description: string
visibility: string
}
export function CreateRepoDialog({ open, onOpenChange }: CreateRepoDialogProps) {
const { createRepo, refresh } = useRepoStore()
const { register, handleSubmit, reset } = useForm<FormData>()
const [isSubmitting, setIsSubmitting] = useState(false)
useEffect(() => {
if (open) {
// 重置表单
reset({
path: '',
license: '',
description: '',
visibility: 'public'
})
}
}, [open, reset])
const onSubmit = async (data: FormData) => {
setIsSubmitting(true)
try {
const submitData = {
...data,
}
await createRepo(submitData)
onOpenChange(false)
refresh()
} finally {
setIsSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-150">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="path"> *</Label>
<Input
id="path"
placeholder="例如: username/repository"
{...register('path', { required: true })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
placeholder="简短描述你的仓库..."
rows={3}
{...register('description')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="visibility"></Label>
<Input
id="visibility"
placeholder="public 或 private"
{...register('visibility')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="topics"></Label>
<Input
id="license"
placeholder="例如: MIT, Apache-2.0"
{...register('license')}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? '创建中...' : '创建仓库'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

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,109 @@
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'
import { gitea } from '@/agents/app';
import { toast } from 'sonner'
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)
}
const onCreateRepo = async () => {
if (!toRepo.trim()) {
return
}
try {
const res = await gitea.repo.createRepo({ name: toRepo })
if (res.code !== 200 && res.code !== 409) {
// 409 表示仓库已存在,可以继续同步
throw new Error(`${res.message}`)
}
if (res.code === 200) {
toast.success('仓库创建成功,正在同步...')
} else {
toast.warning('仓库已存在,正在同步...')
}
handleSync()
} catch (error) {
console.error('创建仓库失败:', error)
}
}
return (
<Dialog open={syncDialogOpen} onOpenChange={setSyncDialogOpen}>
<DialogContent className="sm:max-w-125">
<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={onCreateRepo} disabled={!toRepo.trim()}>
</Button>
<Button
onClick={handleSync}
disabled={!toRepo.trim()}
>
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,280 @@
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,
Square
} from 'lucide-react'
import { useState } from 'react'
import { toast } from 'sonner'
import { useShallow } from 'zustand/shallow'
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>
)
}
// Dev tab 内容
const DevTabContent = ({ linkItems, workspaceLink, stopWorkspace }: {
linkItems: LinkItem[]
workspaceLink: Partial<WorkspaceOpen>
stopWorkspace: () => void
}) => {
return (
<>
<button
onClick={() => stopWorkspace()}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-red-500 hover:bg-red-600 text-white font-medium transition-colors"
>
<Square className="w-4 h-4" />
</button>
<div className="grid grid-cols-2 gap-3 mt-2">
{linkItems.map((item) => (
<LinkItem
key={item.key}
label={item.label}
icon={item.icon}
url={item.getUrl(workspaceLink)}
/>
))}
</div>
</>
)
}
// Work tab 内容(暂留,需要根据 business_id 做事情)
const WorkTabContent = () => {
const store = useRepoStore(useShallow((state) => ({ selectWorkspace: state.selectWorkspace })))
const businessId = store.selectWorkspace?.business_id;
const appList = [
{
title: 'Kevisual Assistant Client', key: 'Assistant Client', port: 51515, end: '/root/cli-center/'
},
{
title: 'OpenCode', key: 'OpenCode', port: 100, end: ''
},
{
title: 'OpenClaw', key: 'OpenClaw', port: 80, end: '/openclaw'
},
{
title: 'OpenWebUI', key: 'OpenWebUI', port: 200, end: ''
},
]
const links = appList.map(app => {
const url = `https://${businessId}-${app.port}.cnb.run${app.end}`
return {
label: app.title,
icon: <Terminal className="w-5 h-5" />,
url
}
})
return (
<div className="flex flex-col items-center justify-center py-2 text-neutral-400">
<div className='mb-2'></div>
<div className="grid grid-cols-1 gap-3 w-full max-w-sm">
{links.map(link => (
<LinkItem key={link.label} label={link.label} icon={link.icon} url={link.url} />
))}
</div>
</div>
)
}
export function WorkspaceDetailDialog() {
const { showWorkspaceDialog, setShowWorkspaceDialog, workspaceLink, stopWorkspace, workspaceTab, setWorkspaceTab } = 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>
{/* Tab 导航 */}
<div className="flex border-b border-neutral-200">
<button
onClick={() => setWorkspaceTab('dev')}
className={`cursor-pointer flex-1 px-4 py-3 text-sm font-medium transition-colors relative ${workspaceTab === 'dev'
? 'text-neutral-900'
: 'text-neutral-500 hover:text-neutral-700'
}`}
>
Dev
{workspaceTab === 'dev' && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-neutral-900" />
)}
</button>
<button
onClick={() => setWorkspaceTab('work')}
className={`cursor-pointer flex-1 px-4 py-3 text-sm font-medium transition-colors relative ${workspaceTab === 'work'
? 'text-neutral-900'
: 'text-neutral-500 hover:text-neutral-700'
}`}
>
Work
{workspaceTab === 'work' && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-neutral-900" />
)}
</button>
</div>
{/* Tab 内容 */}
<div className="py-2">
{workspaceTab === 'dev' ? (
<DevTabContent linkItems={linkItems} workspaceLink={workspaceLink} stopWorkspace={stopWorkspace} />
) : (
<WorkTabContent />
)}
</div>
</DialogContent>
</Dialog>
)
}

163
src/pages/repo/page.tsx Normal file
View File

@@ -0,0 +1,163 @@
import { useEffect, useMemo, useState } from 'react'
import { useRepoStore } from './store/index'
import { RepoCard } from './components/RepoCard'
import { EditRepoDialog } from './modules/EditRepoDialog'
import { CreateRepoDialog } from './modules/CreateRepoDialog'
import { WorkspaceDetailDialog } from './modules/WorkspaceDetailDialog'
import { SyncRepoDialog } from './modules/SyncRepoDialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Plus, RefreshCw, Search } from 'lucide-react'
import Fuse from 'fuse.js'
export const App = () => {
const { list, refresh, loading, editRepo, setEditRepo, workspaceList, showEditDialog, setShowEditDialog, showCreateDialog, setShowCreateDialog, startWorkspace, deleteItem, setSelectedSyncRepo, setSyncDialogOpen } = useRepoStore()
const [searchQuery, setSearchQuery] = useState('')
useEffect(() => {
refresh({ showTips: false })
}, [])
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)
}
const appList = useMemo(() => {
// 首先按活动状态排序
const sortedList = [...list].sort((a, b) => {
const aActive = workspaceList.some(ws => ws.slug === a.path)
const bActive = workspaceList.some(ws => ws.slug === b.path)
if (aActive && !bActive) return -1
if (!aActive && bActive) return 1
return 0
})
// 如果没有搜索词,返回排序后的列表
if (!searchQuery.trim()) {
return sortedList
}
// 使用 Fuse.js 进行模糊搜索
const fuse = new Fuse(sortedList, {
keys: ['name', 'path', 'description'],
threshold: 0.3,
includeScore: true
})
const results = fuse.search(searchQuery)
return results.map(result => result.item)
}, [list, workspaceList, searchQuery])
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 flex items-center justify-between">
<div>
<h1 className="text-4xl font-bold text-neutral-900 mb-2"></h1>
<p className="text-neutral-600"> {list.length} </p>
</div>
<div className="flex items-center gap-2">
<Button
onClick={() => refresh()}
variant="outline"
className="gap-2"
>
<RefreshCw className="h-4 w-4" />
</Button>
<Button
onClick={() => setShowCreateDialog(true)}
className="gap-2"
>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
<div className="mb-6">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
<Input
type="text"
placeholder="搜索仓库名称、路径或描述..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{appList.map((repo) => (
<RepoCard
key={repo.id}
repo={repo}
onStartWorkspace={startWorkspace}
onEdit={handleEdit}
onIssue={handleIssue}
onSettings={handleSettings}
onDelete={handleDelete}
onSync={handleSync}
/>
))}
</div>
{appList.length === 0 && !loading && (
<div className="text-center py-20">
<div className="text-neutral-400 text-lg">
{searchQuery ? '未找到匹配的仓库' : '暂无仓库数据'}
</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}
/>
<CreateRepoDialog
open={showCreateDialog}
onOpenChange={setShowCreateDialog}
/>
<WorkspaceDetailDialog />
<SyncRepoDialog />
</div>
)
}
export default App;

View File

@@ -0,0 +1,52 @@
export const myOrgs = ['kevisual', 'kevision', 'skillpod', 'zxj.im', 'abearxiong']
import dayjs from 'dayjs'
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
`
};
export const createCommitBlankConfig = (params: { repo?: string, event: 'api_trigger_event' }) => {
const now = dayjs().format('YYYY-MM-DD HH:mm')
const event = params?.event || 'api_trigger_event'
return `main:
${event}:
-
services:
- docker
stages:
- name: 显示 git remote
script: git remote -v
- name: commit_blank
script: |
echo "这是一个空白提交 时间: ${now}"
git commit --allow-empty -m "up: ${now}"
git push
`
}

View File

@@ -0,0 +1,399 @@
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, createCommitBlankConfig } 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 WorkspaceTabType = 'dev' | 'work'
type State = {
formData: Record<string, any>;
setFormData: (data: Record<string, any>) => void;
showEdit: boolean;
setShowEdit: (showEdit: boolean) => void;
loading: boolean;
setLoading: (loading: boolean) => void;
workspaceTab: WorkspaceTabType;
setWorkspaceTab: (tab: WorkspaceTabType) => void;
list: Data[];
editRepo: Data | null;
setEditRepo: (repo: Data | null) => void;
showEditDialog: boolean;
setShowEditDialog: (show: boolean) => void;
showCreateDialog: boolean;
setShowCreateDialog: (show: boolean) => void;
getList: (silent?: boolean) => Promise<any>;
updateRepoInfo: (data: Partial<Data>) => Promise<any>;
createRepo: (data: { visibility: any, path: string, description: string, license: string }) => Promise<any>;
deleteItem: (repo: string) => Promise<any>;
workspaceList: WorkspaceInfo[];
getWorkspaceList: () => Promise<any>;
refresh: (opts?: { message?: string, showTips?: boolean }) => Promise<any>;
startWorkspace: (data: Partial<Data>, params?: { open?: boolean, branch?: string }) => Promise<any>;
stopWorkspace: (workspace?: WorkspaceInfo) => Promise<any>;
getWorkspaceDetail: (data: WorkspaceInfo) => Promise<any>;
workspaceLink: Partial<WorkspaceOpen>;
selectWorkspace?: WorkspaceInfo,
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>;
buildUpdate: (data: Partial<Data>, params?: any) => 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 }),
showCreateDialog: false,
setShowCreateDialog: (show) => set({ showCreateDialog: show }),
showWorkspaceDialog: false,
setShowWorkspaceDialog: (show) => set({ showWorkspaceDialog: show }),
workspaceTab: 'dev',
setWorkspaceTab: (tab) => set({ workspaceTab: tab }),
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!;
let topics = data.topics?.split?.(',');
if (Array.isArray(topics)) {
topics = topics.map(t => t.trim()).filter(Boolean);
}
if (topics?.length === 0) {
topics.push('cnb-center')
}
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 || '更新失败');
}
},
refresh: async (opts?: { message?: string, showTips?: boolean }) => {
const getList = get().getList();
const getWorkspaceList = get().getWorkspaceList();
await Promise.all([getList, getWorkspaceList]);
if (opts?.showTips !== false) {
toast.success(opts?.message || '刷新成功');
}
},
createRepo: async (data) => {
try {
const createData = {
name: data.path || '',
visibility: data.visibility || 'public' as const,
description: data.description || '',
license: data?.license as any,
};
const res = await cnb.repo.createRepo(createData);
console.log('res', res)
// if (res.code === 200) {
// toast.success('仓库创建成功');
// } else {
// toast.error(res.message || '创建失败');
// }
return res;
} catch (e: any) {
// toast.error(e.message || '创建失败');
// throw e;
toast.success('仓库创建成功');
}
},
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) {
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}`)
get().refresh({ showTips: false })
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;
},
stopWorkspace: async (workspace?: WorkspaceInfo) => {
const sn = workspace?.sn || get().selectWorkspace?.sn;
if (!sn) {
toast.error('未选择工作区');
return;
}
const res = await cnb.workspace.stopWorkspace({ sn });
// @ts-ignore
if (res?.code === 200) {
toast.success('工作区已停止');
// 停止成功后关闭弹窗
set({ showWorkspaceDialog: false });
get().refresh({ showTips: false });
} else {
toast.error(res.message || '停止失败');
}
},
selectWorkspace: undefined,
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,
selectWorkspace: workspaceInfo
})
}
},
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 || '同步提交失败')
}
},
buildUpdate: async (data) => {
const res = await cnb.build.startBuild(data.path!, {
branch: 'main',
env: {},
event: 'api_trigger_event',
config: createCommitBlankConfig({ repo: data.path!, event: 'api_trigger_event' }),
})
if (res.code === 200) {
toast.success('更新成功')
setTimeout(() => {
get().refresh({ showTips: false })
}, 5000)
} 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}`
}
}

130
src/pages/store/index.ts Normal file
View File

@@ -0,0 +1,130 @@
import { create } from 'zustand';
import { query } from '@/modules/query';
import { toast } from 'sonner';
import { cnb } from '@/agents/app'
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: () => Promise<any>;
updateRepoInfo: (data: Partial<Data>) => 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 }),
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 () => {
const { setLoading } = get();
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 {
setLoading(false);
}
},
updateRepoInfo: async (data) => {
const repo = data.path!;
const updateData = {
description: data.description,
license: data?.license as any,
site: data.site,
topics: data.topics?.split?.(','),
}
const res = await cnb.repo.updateRepoInfo(repo, updateData)
if (res.code === 200) {
toast.success('更新成功');
} else {
toast.error(res.message || '更新失败');
}
},
}
})