feat: add Gitea configuration page and state management

- Implemented GiteaConfigPage for managing Gitea API settings.
- Created Zustand store for Gitea configuration with local storage persistence.
- Added validation schema for Gitea configuration using Zod.
- Established routes for Gitea configuration in the application.
This commit is contained in:
2026-02-19 23:04:31 +08:00
parent 1884e87421
commit f9fd2a67b4
17 changed files with 977 additions and 4205 deletions

View File

@@ -2,7 +2,9 @@ import { QueryRouterServer } from '@kevisual/router/browser'
import { useContextKey } from '@kevisual/context'
import { useConfigStore } from '@/app/config/store'
import { useGiteaConfigStore } from '@/app/config/gitea/store'
import { CNB } from '@kevisual/cnb'
import { Gitea } from '@kevisual/gitea';
export const app = useContextKey('app', new QueryRouterServer())
export const cnb: CNB = useContextKey('cnb', () => {
@@ -27,4 +29,16 @@ export const cnb: CNB = useContextKey('cnb', () => {
const url = 'https://kevisual.cn/root/cnb-ai/dist/app.js'
setTimeout(() => {
import(/* @vite-ignore */url)
}, 2000)
}, 2000)
export const gitea = useContextKey('gitea', () => {
const state = useGiteaConfigStore.getState()
const config = state.config || {}
return new Gitea({
token: config.GITEA_TOKEN,
baseURL: config.GITEA_URL,
cors: {
baseUrl: 'https://cors.kevisual.cn'
}
})
})

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

@@ -13,7 +13,7 @@ import {
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Star, GitFork, FileText, Edit, FolderGit2, MoreVertical, FileText as IssueIcon, Settings, Play, Trash2, RefreshCw, BookOpen } from 'lucide-react'
import { Star, GitFork, FileText, Edit, FolderGit2, MoreVertical, FileText as IssueIcon, Settings, Play, Trash2, RefreshCw, BookOpen, Copy } from 'lucide-react'
import { useRepoStore } from '../store'
import { useMemo, useState } from 'react'
import { myOrgs } from '../store/build'
@@ -48,6 +48,14 @@ export function RepoCard({ repo, onStartWorkspace, onEdit, onIssue, onSettings,
getList(true)
}
}
const onClone = async () => {
const url = `git clone https://cnb.cool/${repo.path}`
navigator.clipboard.writeText(url).then(() => {
toast.success('克隆地址已复制到剪贴板')
}).catch(() => {
toast.error('复制失败')
})
}
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">
@@ -122,6 +130,16 @@ export function RepoCard({ repo, onStartWorkspace, onEdit, onIssue, onSettings,
<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={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
@@ -130,12 +148,6 @@ export function RepoCard({ repo, onStartWorkspace, onEdit, onIssue, onSettings,
<Settings 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={(e) => {
e.preventDefault()

View File

@@ -27,7 +27,7 @@ interface FormData {
}
export function CreateRepoDialog({ open, onOpenChange }: CreateRepoDialogProps) {
const { createRepo, getList } = useRepoStore()
const { createRepo, refresh } = useRepoStore()
const { register, handleSubmit, reset } = useForm<FormData>()
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -53,7 +53,7 @@ export function CreateRepoDialog({ open, onOpenChange }: CreateRepoDialogProps)
const res = await createRepo(submitData)
if (res?.code === 200) {
onOpenChange(false)
await getList(true)
refresh()
}
} finally {
setIsSubmitting(false)
@@ -62,7 +62,7 @@ export function CreateRepoDialog({ open, onOpenChange }: CreateRepoDialogProps)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px]">
<DialogContent className="sm:max-w-150">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>

View File

@@ -5,6 +5,8 @@ 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'
@@ -39,10 +41,29 @@ export function SyncRepoDialog() {
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-[500px]">
<DialogContent className="sm:max-w-125">
<DialogHeader>
<DialogTitle> Gitea</DialogTitle>
<DialogDescription>
@@ -72,6 +93,9 @@ export function SyncRepoDialog() {
>
</Button>
<Button onClick={onCreateRepo} disabled={!toRepo.trim()}>
</Button>
<Button
onClick={handleSync}
disabled={!toRepo.trim()}

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useRepoStore } from './store/index'
import { RepoCard } from './components/RepoCard'
import { EditRepoDialog } from './modules/EditRepoDialog'
@@ -6,21 +6,22 @@ import { CreateRepoDialog } from './modules/CreateRepoDialog'
import { WorkspaceDetailDialog } from './modules/WorkspaceDetailDialog'
import { SyncRepoDialog } from './modules/SyncRepoDialog'
import { Button } from '@/components/ui/button'
import { Plus } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { Plus, RefreshCw, Search } from 'lucide-react'
import Fuse from 'fuse.js'
export const App = () => {
const { list, getList, loading, editRepo, setEditRepo, showEditDialog, setShowEditDialog, showCreateDialog, setShowCreateDialog, startWorkspace, getWorkspaceList, deleteItem, setSelectedSyncRepo, setSyncDialogOpen } = useRepoStore()
const { list, refresh, loading, editRepo, setEditRepo, workspaceList, showEditDialog, setShowEditDialog, showCreateDialog, setShowCreateDialog, startWorkspace, deleteItem, setSelectedSyncRepo, setSyncDialogOpen } = useRepoStore()
const [searchQuery, setSearchQuery] = useState('')
useEffect(() => {
getList()
getWorkspaceList()
refresh({ showTips: false })
}, [])
const handleEdit = (repo: any) => {
setEditRepo(repo)
setShowEditDialog(true)
}
const handleIssue = (repo: any) => {
window.open(`https://cnb.cool/${repo.path}/-/issues`)
}
@@ -39,6 +40,34 @@ export const App = () => {
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">
@@ -47,17 +76,40 @@ export const App = () => {
<h1 className="text-4xl font-bold text-neutral-900 mb-2"></h1>
<p className="text-neutral-600"> {list.length} </p>
</div>
<Button
onClick={() => setShowCreateDialog(true)}
className="gap-2"
>
<Plus className="h-4 w-4" />
</Button>
<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">
{list.map((repo) => (
{appList.map((repo) => (
<RepoCard
key={repo.id}
repo={repo}
@@ -71,9 +123,11 @@ export const App = () => {
))}
</div>
{list.length === 0 && !loading && (
{appList.length === 0 && !loading && (
<div className="text-center py-20">
<div className="text-neutral-400 text-lg"></div>
<div className="text-neutral-400 text-lg">
{searchQuery ? '未找到匹配的仓库' : '暂无仓库数据'}
</div>
</div>
)}
</div>

View File

@@ -69,6 +69,7 @@ type State = {
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: () => Promise<any>;
getWorkspaceDetail: (data: WorkspaceInfo) => Promise<any>;
@@ -161,6 +162,14 @@ export const useRepoStore = create<State>((set, get) => {
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 = {

View File

@@ -9,55 +9,58 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as ConfigRouteImport } from './routes/config'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ConfigIndexRouteImport } from './routes/config/index'
import { Route as ConfigGiteaRouteImport } from './routes/config/gitea'
const ConfigRoute = ConfigRouteImport.update({
id: '/config',
path: '/config',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const ConfigIndexRoute = ConfigIndexRouteImport.update({
id: '/config/',
path: '/config/',
getParentRoute: () => rootRouteImport,
} as any)
const ConfigGiteaRoute = ConfigGiteaRouteImport.update({
id: '/config/gitea',
path: '/config/gitea',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/config': typeof ConfigRoute
'/config/gitea': typeof ConfigGiteaRoute
'/config/': typeof ConfigIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/config': typeof ConfigRoute
'/config/gitea': typeof ConfigGiteaRoute
'/config': typeof ConfigIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/config': typeof ConfigRoute
'/config/gitea': typeof ConfigGiteaRoute
'/config/': typeof ConfigIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/config'
fullPaths: '/' | '/config/gitea' | '/config/'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/config'
id: '__root__' | '/' | '/config'
to: '/' | '/config/gitea' | '/config'
id: '__root__' | '/' | '/config/gitea' | '/config/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ConfigRoute: typeof ConfigRoute
ConfigGiteaRoute: typeof ConfigGiteaRoute
ConfigIndexRoute: typeof ConfigIndexRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/config': {
id: '/config'
path: '/config'
fullPath: '/config'
preLoaderRoute: typeof ConfigRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
@@ -65,12 +68,27 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/config/': {
id: '/config/'
path: '/config'
fullPath: '/config/'
preLoaderRoute: typeof ConfigIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/config/gitea': {
id: '/config/gitea'
path: '/config/gitea'
fullPath: '/config/gitea'
preLoaderRoute: typeof ConfigGiteaRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ConfigRoute: ConfigRoute,
ConfigGiteaRoute: ConfigGiteaRoute,
ConfigIndexRoute: ConfigIndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View File

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

View File

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