Phase 0: 知识库
- docs/knowledge-base/loco-rs-patterns.md — loco-rs 10 个可借鉴模式研究
Phase 1: 数据层重构
- crates/zclaw-saas/src/models/ — 15 个 FromRow 类型化模型
- Login 3 次查询合并为 1 次 AccountLoginRow 查询
- 所有 service 文件从元组解构迁移到 FromRow 结构体
Phase 2: Worker + Scheduler 系统
- crates/zclaw-saas/src/workers/ — Worker trait + 5 个具体实现
- crates/zclaw-saas/src/scheduler.rs — TOML 声明式调度器
- crates/zclaw-saas/src/tasks/ — CLI 任务系统
Phase 3: 性能修复
- Relay N+1 查询 → 精准 SQL (relay/handlers.rs)
- Config RwLock → AtomicU32 无锁 rate limit (state.rs, middleware.rs)
- SSE std::sync::Mutex → tokio::sync::Mutex (relay/service.rs)
- /auth/refresh 阻塞清理 → Scheduler 定期执行
Phase 4: 多环境配置
- config/saas-{development,production,test}.toml
- ZCLAW_ENV 环境选择 + ZCLAW_SAAS_CONFIG 精确覆盖
- scheduler 配置集成到 TOML
261 lines
8.4 KiB
TypeScript
261 lines
8.4 KiB
TypeScript
'use client'
|
|
|
|
import { useState } from 'react'
|
|
import useSWR from 'swr'
|
|
import {
|
|
Loader2,
|
|
Pencil,
|
|
RotateCcw,
|
|
} from 'lucide-react'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
DialogDescription,
|
|
} from '@/components/ui/dialog'
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
import { api } from '@/lib/api-client'
|
|
import { TableSkeleton } from '@/components/ui/skeleton'
|
|
import { ErrorBanner, EmptyState } from '@/components/ui/state'
|
|
import { ApiRequestError } from '@/lib/api-client'
|
|
import type { ConfigItem } from '@/lib/types'
|
|
|
|
const sourceLabels: Record<string, string> = {
|
|
default: '默认值',
|
|
env: '环境变量',
|
|
db: '数据库',
|
|
}
|
|
|
|
const sourceVariants: Record<string, 'secondary' | 'info' | 'default'> = {
|
|
default: 'secondary',
|
|
env: 'info',
|
|
db: 'default',
|
|
}
|
|
|
|
export default function ConfigPage() {
|
|
const [error, setError] = useState('')
|
|
const [activeTab, setActiveTab] = useState('all')
|
|
|
|
// SWR for config list
|
|
const { data: configs = [], isLoading, mutate } = useSWR(
|
|
['config', activeTab],
|
|
() => {
|
|
const params: Record<string, unknown> = {}
|
|
if (activeTab !== 'all') params.category = activeTab
|
|
return api.config.list(params)
|
|
}
|
|
)
|
|
|
|
// 编辑 Dialog
|
|
const [editTarget, setEditTarget] = useState<ConfigItem | null>(null)
|
|
const [editValue, setEditValue] = useState('')
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
function openEditDialog(config: ConfigItem) {
|
|
setEditTarget(config)
|
|
setEditValue(config.current_value ?? '')
|
|
}
|
|
|
|
async function handleSave() {
|
|
if (!editTarget) return
|
|
setSaving(true)
|
|
try {
|
|
let parsedValue: string | number | boolean = editValue
|
|
if (editTarget.value_type === 'number') {
|
|
parsedValue = parseFloat(editValue) || 0
|
|
} else if (editTarget.value_type === 'boolean') {
|
|
parsedValue = editValue === 'true'
|
|
}
|
|
await api.config.update(editTarget.id, { value: parsedValue })
|
|
setEditTarget(null)
|
|
mutate()
|
|
} catch (err) {
|
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
function formatValue(value: unknown): string {
|
|
if (value === undefined || value === null) return '-'
|
|
if (typeof value === 'boolean') return value ? 'true' : 'false'
|
|
return String(value)
|
|
}
|
|
|
|
const categoryLabels: Record<string, string> = {
|
|
all: '全部',
|
|
server: '服务器',
|
|
agent: 'Agent',
|
|
memory: '记忆',
|
|
llm: 'LLM',
|
|
security: '安全策略',
|
|
}
|
|
const categories = Object.keys(categoryLabels)
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* 分类 Tabs */}
|
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
|
<TabsList>
|
|
{categories.map((cat) => (
|
|
<TabsTrigger key={cat} value={cat}>
|
|
{categoryLabels[cat] || cat}
|
|
</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
</Tabs>
|
|
|
|
{error && <ErrorBanner message={error} onDismiss={() => setError('')} />}
|
|
|
|
{isLoading ? (
|
|
<TableSkeleton rows={8} cols={8} hasToolbar={false} />
|
|
) : error ? null : configs.length === 0 ? (
|
|
<EmptyState message="暂无配置项" />
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>分类</TableHead>
|
|
<TableHead>Key</TableHead>
|
|
<TableHead>当前值</TableHead>
|
|
<TableHead>默认值</TableHead>
|
|
<TableHead>来源</TableHead>
|
|
<TableHead>需重启</TableHead>
|
|
<TableHead>描述</TableHead>
|
|
<TableHead className="text-right">操作</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{configs.map((config) => (
|
|
<TableRow key={config.id}>
|
|
<TableCell>
|
|
<Badge variant="outline">{config.category}</Badge>
|
|
</TableCell>
|
|
<TableCell className="font-mono text-sm">{config.key_path}</TableCell>
|
|
<TableCell className="font-mono text-sm max-w-[200px] truncate">
|
|
{formatValue(config.current_value)}
|
|
</TableCell>
|
|
<TableCell className="font-mono text-xs text-muted-foreground max-w-[200px] truncate">
|
|
{formatValue(config.default_value)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant={sourceVariants[config.source] || 'secondary'}>
|
|
{sourceLabels[config.source] || config.source}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
{config.requires_restart ? (
|
|
<Badge variant="warning">是</Badge>
|
|
) : (
|
|
<span className="text-muted-foreground">否</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground max-w-[250px] truncate">
|
|
{config.description || '-'}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<Button variant="ghost" size="icon" onClick={() => openEditDialog(config)} title="编辑">
|
|
<Pencil className="h-4 w-4" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
|
|
{/* 编辑 Dialog */}
|
|
<Dialog open={!!editTarget} onOpenChange={() => setEditTarget(null)}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>编辑配置</DialogTitle>
|
|
<DialogDescription>
|
|
修改 {editTarget?.key_path} 的值
|
|
{editTarget?.requires_restart && (
|
|
<span className="block mt-1 text-yellow-400 text-xs">
|
|
注意: 修改此配置需要重启服务才能生效
|
|
</span>
|
|
)}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label>Key</Label>
|
|
<Input value={editTarget?.key_path || ''} disabled />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>类型</Label>
|
|
<Input value={editTarget?.value_type || ''} disabled />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>
|
|
新值 {editTarget?.default_value != null && (
|
|
<span className="text-xs text-muted-foreground ml-2">
|
|
(默认: {formatValue(editTarget.default_value)})
|
|
</span>
|
|
)}
|
|
</Label>
|
|
{editTarget?.value_type === 'boolean' ? (
|
|
<Select value={editValue} onValueChange={setEditValue}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="true">true</SelectItem>
|
|
<SelectItem value="false">false</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<Input
|
|
type={editTarget?.value_type === 'number' ? 'number' : 'text'}
|
|
value={editValue}
|
|
onChange={(e) => setEditValue(e.target.value)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
if (editTarget?.default_value != null) {
|
|
setEditValue(String(editTarget.default_value))
|
|
}
|
|
}}
|
|
>
|
|
<RotateCcw className="h-4 w-4 mr-2" />
|
|
恢复默认
|
|
</Button>
|
|
<Button variant="outline" onClick={() => setEditTarget(null)}>取消</Button>
|
|
<Button onClick={handleSave} disabled={saving}>
|
|
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
保存
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|