generated from kevisual/vite-react-template
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:
43
src/pages/ai/page.tsx
Normal file
43
src/pages/ai/page.tsx
Normal 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
58
src/pages/auth/index.tsx
Normal 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}
|
||||
</>
|
||||
}
|
||||
80
src/pages/auth/modules/BaseHeader.tsx
Normal file
80
src/pages/auth/modules/BaseHeader.tsx
Normal 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
81
src/pages/auth/page.tsx
Normal 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
103
src/pages/auth/store.ts
Normal 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' }],
|
||||
}));
|
||||
77
src/pages/config/gitea/page.tsx
Normal file
77
src/pages/config/gitea/page.tsx
Normal 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;
|
||||
50
src/pages/config/gitea/store/index.ts
Normal file
50
src/pages/config/gitea/store/index.ts
Normal 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 });
|
||||
},
|
||||
}));
|
||||
13
src/pages/config/gitea/store/schema.ts
Normal file
13
src/pages/config/gitea/store/schema.ts
Normal 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',
|
||||
};
|
||||
16
src/pages/config/hooks/check.ts
Normal file
16
src/pages/config/hooks/check.ts
Normal 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
187
src/pages/config/page.tsx
Normal 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;
|
||||
51
src/pages/config/store/index.ts
Normal file
51
src/pages/config/store/index.ts
Normal 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,
|
||||
}
|
||||
)
|
||||
);
|
||||
23
src/pages/config/store/schema.ts
Normal file
23
src/pages/config/store/schema.ts
Normal 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
3
src/pages/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import App from './repo/page'
|
||||
|
||||
export default App;
|
||||
310
src/pages/repo/components/RepoCard.tsx
Normal file
310
src/pages/repo/components/RepoCard.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
126
src/pages/repo/modules/CreateRepoDialog.tsx
Normal file
126
src/pages/repo/modules/CreateRepoDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
140
src/pages/repo/modules/EditRepoDialog.tsx
Normal file
140
src/pages/repo/modules/EditRepoDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
109
src/pages/repo/modules/SyncRepoDialog.tsx
Normal file
109
src/pages/repo/modules/SyncRepoDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
280
src/pages/repo/modules/WorkspaceDetailDialog.tsx
Normal file
280
src/pages/repo/modules/WorkspaceDetailDialog.tsx
Normal 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
163
src/pages/repo/page.tsx
Normal 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;
|
||||
52
src/pages/repo/store/build.ts
Normal file
52
src/pages/repo/store/build.ts
Normal 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
|
||||
`
|
||||
}
|
||||
399
src/pages/repo/store/index.ts
Normal file
399
src/pages/repo/store/index.ts
Normal 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
130
src/pages/store/index.ts
Normal 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 || '更新失败');
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user