feat(平台): 补齐服务端持久化和AI拆解契约

This commit is contained in:
Script Generator
2026-06-25 15:21:32 +08:00
parent 5723356d08
commit 5adc7759ad
73 changed files with 5599 additions and 368 deletions

View File

@@ -0,0 +1,503 @@
'use client';
import { useEffect, useState } from 'react';
import { Sparkles, Plus, CheckCircle2, AlertTriangle, Loader2, Trash2, Pencil, X } from 'lucide-react';
import { useAuthStore } from '@/stores/useAuthStore';
import { useMemberStore } from '@/stores/useMemberStore';
import { api } from '@/lib/api';
import type {
AiConfigPublic,
AiProviderPublic,
AiProviderUpsertInput,
AiProviderFormat,
} from '@ftb/shared';
const FORMAT_LABEL: Record<AiProviderFormat, string> = {
anthropic: 'Anthropic 兼容',
openai: 'OpenAI 兼容',
};
const FORMAT_HINT: Record<AiProviderFormat, string> = {
anthropic: 'Anthropic 官方 + 兼容 Anthropic 格式的中转站',
openai: 'OpenAI 官方 + 兼容 OpenAI Chat Completions 格式的中转站',
};
const PRESETS: Array<{ key: string; label: string; data: Partial<AiProviderUpsertInput> & { format: AiProviderFormat; model: string } }> = [
{
key: 'anthropic-official',
label: 'Anthropic 官方',
data: { id: 'anthropic-official', name: 'Anthropic 官方', format: 'anthropic', baseURL: 'https://api.anthropic.com', model: 'claude-sonnet-4-6' },
},
{
key: 'openai-official',
label: 'OpenAI 官方',
data: { id: 'openai-official', name: 'OpenAI 官方', format: 'openai', baseURL: 'https://api.openai.com/v1', model: 'gpt-4o' },
},
{
key: 'custom-anthropic',
label: '+ 自定义Anthropic 兼容)',
data: { id: '', name: '', format: 'anthropic', baseURL: '', model: 'claude-sonnet-4-6' },
},
{
key: 'custom-openai',
label: '+ 自定义OpenAI 兼容)',
data: { id: '', name: '', format: 'openai', baseURL: '', model: 'gpt-4o' },
},
];
export default function AiConfigPage() {
return (
<SuperAdminGuard>
<AiConfigContent />
</SuperAdminGuard>
);
}
function SuperAdminGuard({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const role = useMemberStore((s) => s.roles.find((r) => r.id === user?.roleId));
const isSuperAdmin = !!role && role.permissions.includes('*');
if (!isSuperAdmin) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-[14px] text-[var(--ink-soft)]">访 AI </p>
</div>
);
}
return <>{children}</>;
}
function AiConfigContent() {
const user = useAuthStore((s) => s.user);
const [config, setConfig] = useState<AiConfigPublic | null>(null);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingProvider, setEditingProvider] = useState<AiProviderPublic | null>(null);
const [testing, setTesting] = useState<string | null>(null);
const [testResults, setTestResults] = useState<Record<string, { ok: boolean; message: string }>>({});
const [actionMessage, setActionMessage] = useState<string | null>(null);
const fetchConfig = async () => {
setLoading(true);
try {
const c = await api.get<AiConfigPublic>('/config/ai');
setConfig(c);
} catch (e: any) {
setActionMessage(`读取配置失败:${e.message}`);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchConfig(); }, []);
const handleActivate = async (id: string) => {
try {
const next = await api.post<AiConfigPublic>('/config/ai/activate', { id, operator: user?.name });
setConfig(next);
setActionMessage(`已激活:${next.providers.find((p) => p.id === id)?.name}`);
} catch (e: any) {
setActionMessage(`激活失败:${e.message}`);
}
};
const handleDelete = async (id: string, name: string) => {
if (!confirm(`确认删除提供商 "${name}"`)) return;
try {
const next = await api.delete<AiConfigPublic>(`/config/ai/providers/${id}?operator=${encodeURIComponent(user?.name || '')}`);
setConfig(next);
setActionMessage(`已删除:${name}`);
} catch (e: any) {
setActionMessage(`删除失败:${e.message}`);
}
};
const handleTest = async (id: string) => {
setTesting(id);
setTestResults((prev) => ({ ...prev, [id]: undefined as any }));
try {
const r = await api.post<{ ok: boolean; message?: string; error?: string }>('/config/ai/test', { id });
setTestResults((prev) => ({
...prev,
[id]: { ok: r.ok, message: r.ok ? r.message || '连接成功' : r.error || '连接失败' },
}));
} catch (e: any) {
setTestResults((prev) => ({ ...prev, [id]: { ok: false, message: e.message } }));
} finally {
setTesting(null);
}
};
const activeProvider = config?.providers.find((p) => p.isActive);
return (
<div className="flex h-full flex-col bg-[var(--bg)]">
<header className="flex h-14 shrink-0 items-center gap-2 border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
<Sparkles className="h-4 w-4 text-purple-600" />
<h1 className="text-[15px] font-semibold tracking-tight text-[var(--ink)]">AI </h1>
</header>
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-3xl mx-auto space-y-4">
{loading ? (
<div className="text-[13px] text-[var(--ink-muted)]"></div>
) : (
<>
{/* 当前激活 */}
<section className="rounded-2xl border border-purple-200 bg-purple-50/50 p-5">
<h2 className="text-[12px] font-semibold text-purple-700 uppercase tracking-wide mb-2"></h2>
{activeProvider ? (
<div>
<div className="text-[15px] font-semibold text-[var(--ink)]">{activeProvider.name}</div>
<div className="mt-1 text-[12px] text-[var(--ink-soft)]">
{FORMAT_LABEL[activeProvider.format]} · {activeProvider.baseURL}
</div>
<div className="mt-1 text-[12px] text-[var(--ink-soft)] font-mono">
{activeProvider.keyMask} · {activeProvider.model}
</div>
</div>
) : (
<div className="text-[13px] text-amber-700">AI </div>
)}
</section>
{/* 操作消息 */}
{actionMessage && (
<div className="rounded-lg bg-blue-50 border border-blue-200 px-3 py-2 text-[12px] text-blue-700 flex items-center justify-between">
<span>{actionMessage}</span>
<button onClick={() => setActionMessage(null)} className="text-blue-500 hover:text-blue-700">
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
{/* 提供商列表 */}
<section className="rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] p-5">
<div className="flex items-center justify-between mb-3">
<h2 className="text-[14px] font-semibold text-[var(--ink)]"></h2>
<button
onClick={() => { setEditingProvider(null); setShowModal(true); }}
className="flex items-center gap-1 h-8 px-3 rounded-lg text-[12px] font-medium bg-purple-600 text-white hover:bg-purple-700"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
{config?.providers.length === 0 ? (
<div className="rounded-lg border border-dashed border-[var(--line)] py-12 text-center">
<p className="text-[13px] text-[var(--ink-muted)]">"新增"</p>
</div>
) : (
<div className="space-y-2">
{config?.providers.map((p) => (
<ProviderRow
key={p.id}
provider={p}
testing={testing === p.id}
testResult={testResults[p.id]}
onActivate={() => handleActivate(p.id)}
onEdit={() => { setEditingProvider(p); setShowModal(true); }}
onDelete={() => handleDelete(p.id, p.name)}
onTest={() => handleTest(p.id)}
/>
))}
</div>
)}
</section>
</>
)}
</div>
</div>
{showModal && (
<ProviderModal
provider={editingProvider}
onClose={() => setShowModal(false)}
onSaved={(next) => {
setConfig(next);
setShowModal(false);
setActionMessage('保存成功');
}}
operator={user?.name}
/>
)}
</div>
);
}
function ProviderRow({
provider,
testing,
testResult,
onActivate,
onEdit,
onDelete,
onTest,
}: {
provider: AiProviderPublic;
testing: boolean;
testResult?: { ok: boolean; message: string };
onActivate: () => void;
onEdit: () => void;
onDelete: () => void;
onTest: () => void;
}) {
return (
<div className={`rounded-lg border p-3 ${provider.isActive ? 'border-purple-300 bg-purple-50/30' : 'border-[var(--line)]'}`}>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[14px] font-semibold text-[var(--ink)]">{provider.name}</span>
{provider.isActive && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-600 text-white font-medium"></span>
)}
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-subtle)] text-[var(--ink-soft)]">
{FORMAT_LABEL[provider.format]}
</span>
</div>
<div className="mt-1 text-[11px] text-[var(--ink-soft)] truncate">{provider.baseURL}</div>
<div className="mt-0.5 text-[11px] text-[var(--ink-soft)] font-mono">
{provider.keyMask} · {provider.model}
</div>
{provider.remark && <div className="mt-1 text-[11px] text-[var(--ink-muted)]">{provider.remark}</div>}
{testResult && (
<div className={`mt-2 inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded border ${
testResult.ok
? 'bg-emerald-50 border-emerald-200 text-emerald-700'
: 'bg-rose-50 border-rose-200 text-rose-700'
}`}>
{testResult.ok ? <CheckCircle2 className="h-3 w-3" /> : <AlertTriangle className="h-3 w-3" />}
{testResult.message}
</div>
)}
</div>
<div className="flex flex-col gap-1.5 shrink-0">
{!provider.isActive && (
<button
onClick={onActivate}
className="h-7 px-3 rounded text-[11px] font-medium bg-purple-600 text-white hover:bg-purple-700"
>
</button>
)}
<button
onClick={onTest}
disabled={testing}
className="h-7 px-3 rounded text-[11px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] inline-flex items-center justify-center gap-1 disabled:opacity-50"
>
{testing ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
</button>
<button
onClick={onEdit}
className="h-7 px-3 rounded text-[11px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] inline-flex items-center justify-center gap-1"
>
<Pencil className="h-3 w-3" />
</button>
<button
onClick={onDelete}
className="h-7 px-3 rounded text-[11px] font-medium border border-rose-200 text-rose-600 hover:bg-rose-50 inline-flex items-center justify-center gap-1"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
</div>
</div>
);
}
function ProviderModal({
provider,
onClose,
onSaved,
operator,
}: {
provider: AiProviderPublic | null;
onClose: () => void;
onSaved: (next: AiConfigPublic) => void;
operator?: string;
}) {
const isEdit = !!provider;
const [id, setId] = useState(provider?.id || '');
const [name, setName] = useState(provider?.name || '');
const [format, setFormat] = useState<AiProviderFormat>(provider?.format || 'anthropic');
const [baseURL, setBaseURL] = useState(provider?.baseURL || '');
const [apiKey, setApiKey] = useState('');
const [model, setModel] = useState(provider?.model || 'claude-sonnet-4-6');
const [remark, setRemark] = useState(provider?.remark || '');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const applyPreset = (presetKey: string) => {
const p = PRESETS.find((x) => x.key === presetKey);
if (!p) return;
setId(p.data.id || '');
setName(p.data.name || '');
setFormat(p.data.format);
setBaseURL(p.data.baseURL || '');
setModel(p.data.model);
};
const canSubmit = id.trim() && name.trim() && baseURL.trim() && model.trim() && (isEdit || apiKey.trim());
const handleSave = async () => {
if (!canSubmit) return;
setSaving(true);
setError(null);
try {
const payload: AiProviderUpsertInput & { operator?: string } = {
id: id.trim(),
name: name.trim(),
format,
baseURL: baseURL.trim(),
model: model.trim(),
remark: remark.trim() || undefined,
operator,
};
if (apiKey.trim()) payload.apiKey = apiKey.trim();
const next = await api.patch<AiConfigPublic>('/config/ai/providers', payload);
onSaved(next);
} catch (e: any) {
setError(e?.message || '保存失败');
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] p-5 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-[14px] font-semibold text-[var(--ink)]">{isEdit ? '编辑提供商' : '新增提供商'}</h3>
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)]"><X className="h-4 w-4 text-[var(--ink-muted)]" /></button>
</div>
{!isEdit && (
<div className="mb-4">
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1.5"></label>
<div className="flex flex-wrap gap-1.5">
{PRESETS.map((p) => (
<button
key={p.key}
onClick={() => applyPreset(p.key)}
className="h-7 px-2.5 rounded-md border border-[var(--line)] text-[11px] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"
>
{p.label}
</button>
))}
</div>
</div>
)}
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1">ID *</label>
<input
value={id}
onChange={(e) => setId(e.target.value)}
disabled={isEdit}
placeholder="ikuncode"
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none disabled:opacity-60"
/>
</div>
<div>
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1"> *</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="ikuncode"
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none"
/>
</div>
</div>
<div>
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1">API *</label>
<div className="grid grid-cols-2 gap-2">
{(['anthropic', 'openai'] as AiProviderFormat[]).map((f) => (
<label
key={f}
className={`p-3 rounded-lg border cursor-pointer ${
format === f ? 'border-purple-400 bg-purple-50' : 'border-[var(--line)] hover:bg-[var(--bg-subtle)]'
}`}
>
<input type="radio" name="format" checked={format === f} onChange={() => setFormat(f)} className="mr-2" />
<span className="text-[13px] font-medium text-[var(--ink)]">{FORMAT_LABEL[f]}</span>
<p className="mt-0.5 text-[10px] text-[var(--ink-muted)]">{FORMAT_HINT[f]}</p>
</label>
))}
</div>
</div>
<div>
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1">Base URL *</label>
<input
value={baseURL}
onChange={(e) => setBaseURL(e.target.value)}
placeholder="https://api.ikuncode.com"
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] font-mono focus:border-[var(--accent)] focus:outline-none"
/>
</div>
<div>
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1">
API Key {isEdit ? '(留空则保留原 Key' : '*'}
</label>
<input
type="password"
autoComplete="off"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={isEdit ? provider!.keyMask : 'sk-...'}
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] font-mono focus:border-[var(--accent)] focus:outline-none"
/>
<p className="mt-1 text-[10px] text-[var(--ink-muted)]">Key </p>
</div>
<div>
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1"> *</label>
<input
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder={format === 'anthropic' ? 'claude-sonnet-4-6' : 'gpt-4o'}
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] font-mono focus:border-[var(--accent)] focus:outline-none"
/>
<p className="mt-1 text-[10px] text-[var(--ink-muted)]">
baseURL ID claude-sonnet-4-6claude-opus-4-7gpt-4odeepseek-v3
</p>
</div>
<div>
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1"></label>
<input
value={remark}
onChange={(e) => setRemark(e.target.value)}
placeholder="公司账号 / 个人测试 / ..."
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none"
/>
</div>
{error && (
<div className="rounded-lg bg-rose-50 border border-rose-200 px-3 py-2 text-[12px] text-rose-700">
{error}
</div>
)}
</div>
<div className="flex justify-end gap-2 pt-4 border-t border-[var(--line)] mt-4">
<button onClick={onClose} className="h-9 px-3 rounded-lg text-[13px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">
</button>
<button
onClick={handleSave}
disabled={!canSubmit || saving}
className="h-9 px-4 rounded-lg text-[13px] font-medium bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50"
>
{saving ? '保存中…' : '保存'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -3,7 +3,7 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/useAuthStore';
import { LayoutGrid, Phone, Lock, Eye, EyeOff } from 'lucide-react';
import { Eye, EyeOff, LayoutGrid, Lock, Phone } from 'lucide-react';
export default function LoginPage() {
const router = useRouter();
@@ -15,7 +15,7 @@ export default function LoginPage() {
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
@@ -25,21 +25,21 @@ export default function LoginPage() {
}
setLoading(true);
setTimeout(() => {
const success = login(phone.trim(), password, remember);
try {
const success = await login(phone.trim(), password, remember);
if (success) {
router.push('/products');
} else {
setError('手机号或密码错误');
}
} finally {
setLoading(false);
}, 300);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-[var(--bg)]">
<div className="w-full max-w-sm">
{/* Logo */}
<div className="mb-8 flex flex-col items-center">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent)] shadow-lg shadow-blue-500/20">
<LayoutGrid className="h-6 w-6 text-white" strokeWidth={2} />
@@ -48,59 +48,57 @@ export default function LoginPage() {
<p className="mt-1 text-[13px] text-[var(--ink-muted)]">使</p>
</div>
{/* Form */}
<div className="rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] p-6 shadow-[var(--shadow-md)]">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block"></label>
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]"></label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--ink-muted)]" />
<Phone className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)]" />
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="请输入手机号"
maxLength={11}
className="h-10 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-10 pr-3 text-[14px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)] transition-all"
className="h-10 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-10 pr-3 text-[14px] text-[var(--ink)] transition-all placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]"
/>
</div>
</div>
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block"></label>
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]"></label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--ink-muted)]" />
<Lock className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)]" />
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入密码"
className="h-10 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-10 pr-10 text-[14px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)] transition-all"
className="h-10 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-10 pr-10 text-[14px] text-[var(--ink)] transition-all placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--ink-muted)] hover:text-[var(--ink-soft)]"
aria-label={showPassword ? '隐藏密码' : '显示密码'}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<div className="flex items-center">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
className="h-4 w-4 rounded border-[var(--line)] text-[var(--accent)] focus:ring-[var(--accent-ring)]"
/>
<span className="text-[12px] text-[var(--ink-soft)]"></span>
</label>
</div>
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
className="h-4 w-4 rounded border-[var(--line)] text-[var(--accent)] focus:ring-[var(--accent-ring)]"
/>
<span className="text-[12px] text-[var(--ink-soft)]"></span>
</label>
{error && (
<div className="rounded-lg bg-red-50 border border-red-100 px-3 py-2 text-[12px] text-red-600">
<div className="rounded-lg border border-red-100 bg-red-50 px-3 py-2 text-[12px] text-red-600">
{error}
</div>
)}
@@ -108,9 +106,9 @@ export default function LoginPage() {
<button
type="submit"
disabled={loading}
className="h-10 w-full rounded-lg bg-[var(--accent)] text-[14px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-all"
className="h-10 w-full rounded-lg bg-[var(--accent)] text-[14px] font-medium text-white shadow-[var(--shadow-sm)] transition-all hover:bg-[var(--accent-hover)] disabled:opacity-50"
>
{loading ? '登录中...' : '登 录'}
{loading ? '登录中...' : '登录'}
</button>
</form>

View File

@@ -10,6 +10,8 @@ import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
import { useDevTaskStore } from '@/stores/useDevTaskStore';
import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useBugStore } from '@/stores/useBugStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { useMemberStore } from '@/stores/useMemberStore';
import { getProjectDetail, VersionWithContext } from '@/lib/derive';
import { Stage, Role, STAGES, ROLES, STAGE_INDEX, ROLE_LABEL } from '@/lib/stage';
import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_BG } from '@/lib/version-status';
@@ -351,9 +353,25 @@ export default function ProjectDetailPage() {
const project = useMemo(() => getProjectDetail(overview, projectId), [overview, projectId]);
const user = useAuthStore((s) => s.user);
const currentUserName = user?.name || '';
const { roles } = useMemberStore();
const isSuperAdmin = useMemo(() => {
const r = roles.find((x) => x.id === user?.roleId);
return !!r && r.permissions.includes('*');
}, [roles, user?.roleId]);
const sortedVersions = useMemo(() => {
if (!project) return [];
let list = [...project.versions];
// 只显示当前用户参与的版本members为空时所有人可见超管可见全部
if (!isSuperAdmin) {
list = list.filter((v) => {
const ms = v.members ?? [];
if (ms.length === 0) return true;
return ms.some((m) => m.name === currentUserName);
});
}
if (statusFilter !== 'all') {
if (statusFilter === 'planned') {
list = list.filter((v) => v.status === 'planned');
@@ -364,7 +382,7 @@ export default function ProjectDetailPage() {
}
}
return list.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}, [project, statusFilter]);
}, [project, statusFilter, isSuperAdmin, currentUserName]);
// Compute actual overall progress per version
const versionProgressMap = useMemo(() => {

View File

@@ -33,6 +33,7 @@ import { testCaseIntervals } from '@/lib/test-case';
import { bugIntervals } from '@/lib/bug';
import { planIntervals } from '@/lib/version-plan';
import { formatDateTime } from '@/lib/format';
import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector';
const PRIORITY_STYLE: Record<string, string> = {
P0: 'bg-red-500/10 text-red-600',
@@ -145,9 +146,10 @@ export default function VersionDetailPage() {
);
}
// 权限校验:只有参与人员可以访问
// 权限校验:只有参与人员可以访问(超管不受限)
const currentUserName = user?.name || '';
const isMember = (version.members ?? []).length === 0 || (version.members ?? []).some((m) => m.name === currentUserName);
const isSuperAdmin = !!currentRole && currentRole.permissions.includes('*');
const isMember = isSuperAdmin || (version.members ?? []).length === 0 || (version.members ?? []).some((m) => m.name === currentUserName);
if (!isMember) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3">
@@ -512,7 +514,7 @@ export default function VersionDetailPage() {
{g.items.map((p) => (
<a key={p.id} href={p.resultUrl} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1.5 text-[12px] text-[var(--accent)] hover:underline">
{p.resultType === 'file' ? <FileText className="h-3 w-3" /> : <Link2 className="h-3 w-3" />}
<span className="truncate">{p.resultFileName || p.title}</span>
<span className="truncate">{p.resultTitle || p.resultFileName || p.title}</span>
</a>
))}
</div>
@@ -887,17 +889,18 @@ export default function VersionDetailPage() {
) : (activeTab === 'research' || activeTab === 'product' || activeTab === 'ui') ? (
(() => {
const pt = activeTab as 'research' | 'product' | 'ui';
const versionReqs = requirements.filter((r) => r.versionId === version.id);
const linkedReqs = versionReqs.map((r) => ({ id: r.id, title: r.title, code: r.code, productOwner: r.productOwner }));
const projectAdoptedReqs = getProjectAdoptedRequirementCandidates(requirements, version.projectId);
return (
<PlanTab
plans={plans}
versionId={version.id}
version={version}
versionDeadline={version.expectedReleaseDate ?? undefined}
currentUserName={user?.name ?? ''}
planType={pt}
versionMembers={version.members ?? []}
linkedRequirements={pt !== 'research' ? linkedReqs : undefined}
linkedRequirements={projectAdoptedReqs}
allRequirements={requirements}
onCreate={(data) => {
createPlan(data);
if ((pt === 'product') && data.linkedRequirementIds?.length) {

View File

@@ -10,6 +10,7 @@ import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
import { useDevTaskStore } from '@/stores/useDevTaskStore';
import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { useMemberStore } from '@/stores/useMemberStore';
import { flattenVersions, flattenProjects } from '@/lib/derive';
import type { VersionWithContext } from '@/lib/derive';
import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_DOT, VERSION_STATUS_BG, getVersionDisplayStatus } from '@/lib/version-status';
@@ -123,13 +124,19 @@ function VersionsPageContent() {
const user = useAuthStore((s) => s.user);
const currentUserName = user?.name || '';
const { roles } = useMemberStore();
const isSuperAdmin = useMemo(() => {
const r = roles.find((x) => x.id === user?.roleId);
return !!r && r.permissions.includes('*');
}, [roles, user?.roleId]);
const allVersionsRaw = useMemo(() => flattenVersions(overview), [overview]);
// 只显示当前用户参与的版本members为空时所有人可见
// 只显示当前用户参与的版本members为空时所有人可见;超管可见全部
const allVersions = useMemo(() => allVersionsRaw.filter((v) => {
if (isSuperAdmin) return true;
if (!v.members || v.members.length === 0) return true;
return v.members.some((m) => m.name === currentUserName);
}), [allVersionsRaw, currentUserName]);
}), [allVersionsRaw, currentUserName, isSuperAdmin]);
const allProjects = useMemo(() => flattenProjects(overview), [overview]);
// Compute overall progress per version from actual data