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

View File

@@ -51,12 +51,17 @@ export function DevTaskRow({ task, category, onClick }: Props) {
return (
<div
onClick={onClick}
className="flex items-center gap-3 px-4 py-2.5 border-b border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer transition-colors last:border-b-0"
className={`flex items-center gap-3 px-4 py-2.5 border-b border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer transition-colors last:border-b-0 ${task.aiDraft ? 'border-l-2 border-l-purple-400 bg-purple-50/30' : ''}`}
>
<span className={`h-2 w-2 rounded-full shrink-0 ${PRIORITY_DOT[task.priority] || 'bg-zinc-300'}`} title={task.priority} />
<span className="text-[11px] font-mono text-[var(--ink-muted)] w-16 shrink-0">{task.taskNo}</span>
<div className="flex-1 min-w-0 flex items-center gap-1.5">
<span className="text-[13px] text-[var(--ink)] truncate">{task.title}</span>
{task.aiDraft && (
<span className="flex items-center gap-0.5 text-[10px] text-purple-600 bg-purple-100 px-1.5 py-0.5 rounded shrink-0" title="AI 拆解草案,编辑后会移除标记">
AI
</span>
)}
{task.isBlocked && (
<span className="flex items-center gap-0.5 text-[10px] text-red-500 bg-red-50 px-1.5 py-0.5 rounded shrink-0" title={task.blockReason}>
<AlertTriangle className="h-2.5 w-2.5" />

View File

@@ -1,7 +1,7 @@
'use client';
import { usePathname, useRouter } from 'next/navigation';
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Search, Lightbulb, Clock, Shield, Settings } from 'lucide-react';
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Search, Lightbulb, Clock, Shield, Settings, Sparkles } from 'lucide-react';
import { useHasPermission } from '@/components/auth/Guard';
import { useAuthStore } from '@/stores/useAuthStore';
import { useMemberStore } from '@/stores/useMemberStore';
@@ -23,6 +23,7 @@ const NAV_GROUPS = [
items: [
{ label: '成员', path: '/admin/members', icon: Users, permission: 'member:view' },
{ label: '角色', path: '/admin/roles', icon: Shield, permission: 'role:view' },
{ label: 'AI 配置', path: '/admin/ai-config', icon: Sparkles, permission: '*' },
],
},
];

View File

@@ -0,0 +1,206 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { Sparkles, Loader2, AlertCircle, RotateCw } from 'lucide-react';
import type { VersionPlan } from '@/lib/version-plan';
import type { VersionWithContext } from '@/lib/derive';
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { api } from '@/lib/api';
import { DecomposeReportModal } from './DecomposeReportModal';
import type {
AgentDecomposeRequest,
AgentDecomposeResponse,
AgentDecomposeError,
} from '@ftb/shared';
interface Props {
plan: VersionPlan;
version: VersionWithContext;
}
/** in_progress 视为"卡死"的阈值(秒)— 超过这个时间,按钮允许重新点击 */
const STUCK_THRESHOLD_SEC = 240;
function formatElapsed(sec: number): string {
if (sec < 60) return `${sec}s`;
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}m${s}s`;
}
export function AiDecomposeButton({ plan, version }: Props) {
const { updatePlan } = useVersionPlanStore();
const { requirements } = useRequirementStore();
const user = useAuthStore((s) => s.user);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<AgentDecomposeResponse | null>(null);
const [tick, setTick] = useState(0);
const startedAtRef = useRef<number | null>(null);
// plan 上的状态(持久化在 localStorage
const persistStatus = plan.aiDecomposeStatus;
const persistError = plan.aiDecomposeError;
const persistAt = plan.aiDecomposeAt;
// 计算"已用时"
const elapsedSec = (() => {
if (loading && startedAtRef.current) {
return Math.floor((tick - startedAtRef.current) / 1000);
}
if (persistStatus === 'in_progress' && persistAt) {
const start = new Date(persistAt).getTime();
return Math.max(0, Math.floor((Date.now() - start) / 1000));
}
return 0;
})();
const isStaleInProgress = persistStatus === 'in_progress' && elapsedSec > STUCK_THRESHOLD_SEC;
const isInProgress = (loading || persistStatus === 'in_progress') && !isStaleInProgress;
const isError = persistStatus === 'error' && !loading;
const wasCompleted = persistStatus === 'completed' && !loading;
// tick 每秒更新一次,让"已用时"实时跳
useEffect(() => {
if (!isInProgress) return;
const id = setInterval(() => setTick(Date.now()), 1000);
return () => clearInterval(id);
}, [isInProgress]);
const linkedReqs = requirements
.filter((r) => r.versionId === version.id)
.map((r) => ({
id: r.id,
code: r.code,
title: r.title,
description: r.description,
}));
const handleClick = async () => {
if (loading) return;
// 即便 persistStatus 是 in_progress只要超过阈值就允许重新点
if (persistStatus === 'in_progress' && !isStaleInProgress) return;
startedAtRef.current = Date.now();
setTick(Date.now());
setLoading(true);
updatePlan(plan.id, {
aiDecomposeStatus: 'in_progress',
aiDecomposeBy: user?.name,
aiDecomposeAt: new Date().toISOString(),
aiDecomposeError: undefined,
});
const members = (version.members ?? []).map((m) => ({
name: m.name,
role: m.role,
}));
const payload: AgentDecomposeRequest = {
prototypeUrl: plan.resultUrl || '',
requirements: linkedReqs,
members,
versionId: version.id,
planId: plan.id,
};
try {
const resp = await api.postRaw<AgentDecomposeResponse | AgentDecomposeError>(
'/ai/decompose',
payload,
300000,
);
if (!resp.ok) {
updatePlan(plan.id, {
aiDecomposeStatus: 'error',
aiDecomposeError: resp.error,
});
} else {
setResult(resp);
updatePlan(plan.id, {
aiDecomposeStatus: 'completed',
aiDecomposeError: undefined,
});
}
} catch (e: any) {
const msg = e?.message || '调用 AI 服务失败';
updatePlan(plan.id, {
aiDecomposeStatus: 'error',
aiDecomposeError: msg,
});
} finally {
setLoading(false);
startedAtRef.current = null;
}
};
if (plan.type !== 'product' || plan.status !== 'completed' || !plan.resultUrl) {
return null;
}
return (
<>
<button
onClick={handleClick}
disabled={isInProgress}
className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] font-medium disabled:cursor-not-allowed disabled:opacity-70 ${
isError
? 'border-red-200 bg-red-50 text-red-700 hover:bg-red-100'
: 'border-purple-200 bg-purple-50 text-purple-700 hover:bg-purple-100'
}`}
title={
isError
? `上次失败:${persistError || '未知错误'}(点击重试)`
: wasCompleted
? '此前已拆解过,再次点击会重新拆解'
: '使用 AI 把原型 + 关联需求拆解成开发任务和测试用例'
}
>
{isInProgress ? (
<>
<Loader2 className="h-3 w-3 animate-spin" />
{formatElapsed(elapsedSec)}
</>
) : isError ? (
<>
<RotateCw className="h-3 w-3" />
</>
) : (
<>
<Sparkles className="h-3 w-3" />
{wasCompleted ? '重新 AI 拆解' : 'AI 拆解'}
</>
)}
</button>
{/* 错误信息:在按钮旁悬浮显示 */}
{isError && persistError && (
<span className="ml-1 inline-flex items-center gap-1 text-[11px] text-red-600 max-w-[260px]" title={persistError}>
<AlertCircle className="h-3 w-3 shrink-0" />
<span className="truncate">{persistError}</span>
</span>
)}
{/* 卡死提示persistStatus 是 in_progress 但已超时 */}
{isStaleInProgress && (
<span className="ml-1 text-[11px] text-amber-600" title="上次拆解记录残留,点击按钮重新拆解">
{formatElapsed(elapsedSec)}
</span>
)}
{result && (
<DecomposeReportModal
result={result}
version={version}
plan={plan}
requirements={linkedReqs}
onClose={() => setResult(null)}
/>
)}
</>
);
}

View File

@@ -0,0 +1,381 @@
'use client';
import { useState, useMemo } from 'react';
import { X, CheckCircle2, AlertTriangle, HelpCircle, ListChecks } from 'lucide-react';
import type { VersionPlan } from '@/lib/version-plan';
import type { VersionWithContext } from '@/lib/derive';
import { useDevTaskStore } from '@/stores/useDevTaskStore';
import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { findCategoryByCode, resolveCategoryIdFromCode } from '@/lib/task-category';
import { addWorkHours } from '@/lib/work-hours';
import { clampDevEstimateHours, clampTestCaseEstimateHours } from '@/lib/ai-estimation-policy';
import type {
AgentDecomposeResponse,
AgentDevTaskDraft,
AgentTestCaseDraft,
} from '@ftb/shared';
interface Props {
result: AgentDecomposeResponse;
version: VersionWithContext;
plan: VersionPlan;
requirements: Array<{ id: string; code: string; title: string; description?: string }>;
onClose: () => void;
}
export function DecomposeReportModal({ result, version, plan, requirements, onClose }: Props) {
const { createTask } = useDevTaskStore();
const { createTestCase } = useTestCaseStore();
const { categories } = useTaskCategoryStore();
const user = useAuthStore((s) => s.user);
const { result: data, meta } = result;
const [selectedDevIdx, setSelectedDevIdx] = useState<Set<number>>(
new Set(data.devTaskDrafts.map((_, i) => i)),
);
const [selectedTcIdx, setSelectedTcIdx] = useState<Set<number>>(
new Set(data.testCaseDrafts.map((_, i) => i)),
);
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const reqByCode = useMemo(() => {
const m = new Map<string, { id: string; code: string; title: string }>();
for (const r of requirements) m.set(r.code, r);
return m;
}, [requirements]);
const reqByInternalId = useMemo(() => {
const m = new Map<string, { id: string; code: string; title: string }>();
for (const r of requirements) m.set(r.id, r);
return m;
}, [requirements]);
const toggleDev = (i: number) => {
const n = new Set(selectedDevIdx);
if (n.has(i)) n.delete(i);
else n.add(i);
setSelectedDevIdx(n);
};
const toggleTc = (i: number) => {
const n = new Set(selectedTcIdx);
if (n.has(i)) n.delete(i);
else n.add(i);
setSelectedTcIdx(n);
};
const handleAdopt = () => {
if (submitting) return;
setSubmitting(true);
const now = new Date().toISOString();
// 把每个 reference 的 requirement 类型 idAI 给的是 code映射回内部 id
const normalizeRefs = (refs: AgentDevTaskDraft['references'] | AgentTestCaseDraft['references']) =>
refs.map((ref) => {
if (ref.type === 'requirement') {
const r = reqByCode.get(ref.id) ?? reqByInternalId.get(ref.id);
if (r) return { ...ref, id: r.id, label: ref.label || `${r.code} ${r.title}` };
}
return ref;
});
let devCount = 0;
for (let i = 0; i < data.devTaskDrafts.length; i++) {
if (!selectedDevIdx.has(i)) continue;
const draft = data.devTaskDrafts[i];
const refs = normalizeRefs(draft.references);
const reqRef = refs.find((r) => r.type === 'requirement');
const requirementId = reqRef?.id ?? requirements[0]?.id ?? '';
const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'development');
const estimateHours = clampDevEstimateHours(draft.categoryCode, draft.estimateHours);
const startISO = new Date().toISOString();
const endISO = addWorkHours(startISO, estimateHours);
createTask({
requirementId,
title: draft.title,
description: draft.description,
categoryId,
assigneeId: '',
reviewerId: undefined,
priority: draft.priority,
expectedStartAt: startISO,
expectedEndAt: endISO,
estimateHours,
actualStartAt: undefined,
actualEndAt: undefined,
status: 'todo',
blockReason: undefined,
blockedById: undefined,
predecessorIds: undefined,
riskLevel: undefined,
delayReason: undefined,
overdueVersionReason: undefined,
references: refs,
aiDraft: true,
aiDraftAt: now,
createdBy: user?.name || 'AI',
} as any);
devCount++;
}
let tcCount = 0;
for (let i = 0; i < data.testCaseDrafts.length; i++) {
if (!selectedTcIdx.has(i)) continue;
const draft = data.testCaseDrafts[i];
const refs = normalizeRefs(draft.references);
const reqRef = refs.find((r) => r.type === 'requirement');
const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'testing');
const estimateHours = clampTestCaseEstimateHours(draft.categoryCode, draft.estimateHours);
createTestCase({
versionId: version.id,
requirementId: reqRef?.id,
title: draft.title,
description: draft.description,
categoryId,
priority: draft.priority,
estimateHours,
assigneeId: undefined,
references: refs,
aiDraft: true,
aiDraftAt: now,
createdBy: user?.name || 'AI',
} as any);
tcCount++;
}
setSubmitted(true);
setSubmitting(false);
// 1.5 秒后自动关闭
setTimeout(() => onClose(), 1500);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div
className="w-full max-w-3xl max-h-[85vh] flex flex-col rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--line)]">
<div className="flex items-center gap-2">
<ListChecks className="h-4 w-4 text-purple-600" />
<h3 className="text-[14px] font-semibold text-[var(--ink)]">AI </h3>
<span className="text-[11px] text-[var(--ink-muted)]">
{meta.model} · {meta.inputTokens} tok · {meta.outputTokens} tok ·{' '}
{(meta.durationMs / 1000).toFixed(1)}s
</span>
</div>
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)]">
<X className="h-4 w-4 text-[var(--ink-muted)]" />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
{/* 对账报告 */}
<section>
<h4 className="text-[12px] font-semibold text-[var(--ink-soft)] mb-2"></h4>
{data.report.matched.length > 0 && (
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 mb-2">
<div className="flex items-center gap-1.5 mb-1.5">
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />
<span className="text-[12px] font-medium text-emerald-700">
{data.report.matched.length}
</span>
</div>
<ul className="text-[12px] text-emerald-800 space-y-1 ml-5">
{data.report.matched.map((m, i) => (
<li key={i}>
{m.reqId} {m.noteIds.join(', ') || '(仅需求驱动)'} {m.taskCount}
</li>
))}
</ul>
</div>
)}
{data.report.reqOnly.length > 0 && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 mb-2">
<div className="flex items-center gap-1.5 mb-1.5">
<AlertTriangle className="h-3.5 w-3.5 text-amber-600" />
<span className="text-[12px] font-medium text-amber-700"></span>
</div>
<p className="text-[11px] text-amber-700 mb-1">
</p>
<ul className="text-[12px] text-amber-800 ml-5 list-disc">
{data.report.reqOnly.map((id, i) => <li key={i}>{id}</li>)}
</ul>
</div>
)}
{data.report.noteOnly.length > 0 && (
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3 mb-2">
<div className="flex items-center gap-1.5 mb-1.5">
<AlertTriangle className="h-3.5 w-3.5 text-blue-600" />
<span className="text-[12px] font-medium text-blue-700"></span>
</div>
<p className="text-[11px] text-blue-700 mb-1">
</p>
<ul className="text-[12px] text-blue-800 ml-5 list-disc">
{data.report.noteOnly.map((id, i) => <li key={i}>{id}</li>)}
</ul>
</div>
)}
{data.report.ambiguous.length > 0 && (
<div className="rounded-lg border border-rose-200 bg-rose-50 p-3 mb-2">
<div className="flex items-center gap-1.5 mb-1.5">
<HelpCircle className="h-3.5 w-3.5 text-rose-600" />
<span className="text-[12px] font-medium text-rose-700"></span>
</div>
<ul className="text-[12px] text-rose-800 ml-5 space-y-0.5">
{data.report.ambiguous.map((a, i) => (
<li key={i}>
<span className="font-medium">{a.noteId}</span>{a.reason}
</li>
))}
</ul>
</div>
)}
</section>
{/* DevTask 草案 */}
<section>
<h4 className="text-[12px] font-semibold text-[var(--ink-soft)] mb-2">
{data.devTaskDrafts.length}
</h4>
{data.devTaskDrafts.length === 0 ? (
<p className="text-[12px] text-[var(--ink-muted)]"></p>
) : (
<div className="space-y-2">
{data.devTaskDrafts.map((d, i) => (
<label
key={i}
className="flex gap-2 p-3 rounded-lg border border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer"
>
<input
type="checkbox"
checked={selectedDevIdx.has(i)}
onChange={() => toggleDev(i)}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[13px] font-medium text-[var(--ink)]">{d.title}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-subtle)] text-[var(--ink-soft)]">
{findCategoryByCode(categories, d.categoryCode)?.name ?? d.categoryCode}
</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 text-zinc-600">
{d.priority}
</span>
<span className="text-[10px] text-[var(--ink-muted)]">
{clampDevEstimateHours(d.categoryCode, d.estimateHours)}h
</span>
</div>
{d.description && (
<p className="mt-1 text-[12px] text-[var(--ink-soft)] line-clamp-2">{d.description}</p>
)}
<div className="mt-1.5 flex flex-wrap gap-1">
{d.references.map((r, j) => (
<span
key={j}
className="text-[10px] px-1.5 py-0.5 rounded bg-purple-50 text-purple-700 border border-purple-200"
>
{r.label}
</span>
))}
</div>
</div>
</label>
))}
</div>
)}
</section>
{/* TestCase 草案 */}
<section>
<h4 className="text-[12px] font-semibold text-[var(--ink-soft)] mb-2">
{data.testCaseDrafts.length}
</h4>
{data.testCaseDrafts.length === 0 ? (
<p className="text-[12px] text-[var(--ink-muted)]"></p>
) : (
<div className="space-y-2">
{data.testCaseDrafts.map((d, i) => (
<label
key={i}
className="flex gap-2 p-3 rounded-lg border border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer"
>
<input
type="checkbox"
checked={selectedTcIdx.has(i)}
onChange={() => toggleTc(i)}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[13px] font-medium text-[var(--ink)]">{d.title}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-subtle)] text-[var(--ink-soft)]">
{findCategoryByCode(categories, d.categoryCode)?.name ?? d.categoryCode}
</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 text-zinc-600">
{d.priority}
</span>
<span className="text-[10px] text-[var(--ink-muted)]">
{clampTestCaseEstimateHours(d.categoryCode, d.estimateHours)}h
</span>
</div>
<pre className="mt-1 text-[11px] text-[var(--ink-soft)] whitespace-pre-wrap font-sans line-clamp-3">
{d.description}
</pre>
<div className="mt-1.5 flex flex-wrap gap-1">
{d.references.map((r, j) => (
<span
key={j}
className="text-[10px] px-1.5 py-0.5 rounded bg-purple-50 text-purple-700 border border-purple-200"
>
{r.label}
</span>
))}
</div>
</div>
</label>
))}
</div>
)}
</section>
</div>
{/* Footer */}
<div className="flex items-center justify-between px-5 py-3 border-t border-[var(--line)]">
<div className="text-[12px] text-[var(--ink-muted)]">
{selectedDevIdx.size} + {selectedTcIdx.size}
</div>
<div className="flex gap-2">
<button
onClick={onClose}
className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"
>
</button>
<button
onClick={handleAdopt}
disabled={submitting || submitted || (selectedDevIdx.size === 0 && selectedTcIdx.size === 0)}
className="h-8 px-4 rounded-lg text-[12px] font-medium bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50"
>
{submitted ? '✓ 已采纳' : submitting ? '采纳中...' : '采纳选中'}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -8,6 +8,7 @@ import { useMemberStore } from '@/stores/useMemberStore';
import { calcPlanProgress, calcLinkedReqProgress } from '@/lib/version-plan';
import { formatDateTime } from '@/lib/format';
import type { PlanTask, VersionPlan } from '@/lib/version-plan';
import { canEditPlanRequirementCoverage, canTogglePlanChecklist, getPlanCompletionState } from '@/lib/version-plan-workflow';
interface Props {
planId: string;
@@ -26,6 +27,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
const [showTransfer, setShowTransfer] = useState(false);
const [transferTo, setTransferTo] = useState('');
const [resultType, setResultType] = useState<'link' | 'file'>('link');
const [resultTitle, setResultTitle] = useState('');
const [resultUrl, setResultUrl] = useState('');
const [fileName, setFileName] = useState('');
const [fileData, setFileData] = useState('');
@@ -34,20 +36,22 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
const plan = plans.find((p) => p.id === planId);
if (!plan) return null;
const completionState = getPlanCompletionState(plan);
const isResearch = plan.type === 'research';
const progress = isResearch ? calcPlanProgress(plan.tasks) : calcLinkedReqProgress(plan.linkedRequirementIds, plan.completedRequirementIds);
const linkedReqs = (plan.linkedRequirementIds || []).map((id) => requirements.find((r) => r.id === id)).filter(Boolean) as { id: string; code: string; title: string }[];
const canInteract = plan.status === 'in_progress' || (plan.status === 'pending' && plan.startTime && new Date(plan.startTime) <= new Date());
const canToggle = canTogglePlanChecklist(plan);
const canEditCoverage = canEditPlanRequirementCoverage(plan);
const handleToggleTask = (task: PlanTask) => {
if (!canInteract) return;
if (!canToggle) return;
const nextStatus = task.status === 'completed' ? 'pending' : 'completed';
const updatedTasks = (plan.tasks || []).map((t) => t.id === task.id ? { ...t, status: nextStatus as PlanTask['status'] } : t);
updatePlan(plan.id, { tasks: updatedTasks });
};
const handleToggleReq = (reqId: string) => {
if (!canInteract) return;
if (!canEditCoverage) return;
const current = plan.completedRequirementIds || [];
const next = current.includes(reqId) ? current.filter((id) => id !== reqId) : [...current, reqId];
updatePlan(plan.id, { completedRequirementIds: next });
@@ -64,8 +68,13 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
const handleSubmitResult = () => {
const url = resultType === 'link' ? resultUrl.trim() : fileData;
if (!url) return;
completePlan(plan.id, { resultType, resultUrl: url, resultFileName: fileName || undefined, resultFileData: resultType === 'file' ? fileData : undefined });
const title = resultTitle.trim();
if (!url || !title) return;
const response = completePlan(plan.id, { resultType, resultTitle: title, resultUrl: url, resultFileName: fileName || undefined, resultFileData: resultType === 'file' ? fileData : undefined });
if (response && typeof response === 'object' && 'ok' in response && !response.ok) {
alert(response.message || '计划未满足完成条件');
return;
}
setShowComplete(false);
};
@@ -138,9 +147,9 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
{plan.tasks.map((task) => (
<div key={task.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-[var(--bg-subtle)]">
<button
disabled={!canInteract}
disabled={!canToggle}
onClick={() => handleToggleTask(task)}
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${!canInteract ? 'opacity-40 cursor-not-allowed' : ''} ${task.status === 'completed' ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${!canToggle ? 'opacity-40 cursor-not-allowed' : ''} ${task.status === 'completed' ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
>
{task.status === 'completed' && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />}
</button>
@@ -151,7 +160,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
)}
{/* Product/UI: Linked Requirements */}
{!isResearch && linkedReqs.length > 0 && (
{linkedReqs.length > 0 && (
<div className="space-y-1.5">
<div className="text-[11px] font-medium text-[var(--ink-muted)]"></div>
{linkedReqs.map((req) => {
@@ -159,9 +168,9 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
return (
<div key={req.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-[var(--bg-subtle)]">
<button
disabled={!canInteract}
disabled={!canEditCoverage}
onClick={() => handleToggleReq(req.id)}
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${!canInteract ? 'opacity-40 cursor-not-allowed' : ''} ${isDone ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${!canEditCoverage ? 'opacity-40 cursor-not-allowed' : ''} ${isDone ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
>
{isDone && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />}
</button>
@@ -170,6 +179,9 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
</div>
);
})}
{plan.status === 'in_progress' && !completionState.canSubmitResult && (
<p className="pt-1 text-[11px] text-[var(--ink-muted)]">{completionState.missingReasons.join('、')}</p>
)}
</div>
)}
@@ -180,7 +192,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
<div className="flex items-center gap-1.5">
{plan.resultType === 'link' ? <Link2 className="h-3 w-3 text-[var(--accent)]" /> : <FileUp className="h-3 w-3 text-[var(--accent)]" />}
<a href={plan.resultUrl} target="_blank" rel="noopener noreferrer" className="text-[12px] text-[var(--accent)] hover:underline flex items-center gap-1">
{plan.resultFileName || '查看成果'}<ExternalLink className="h-3 w-3" />
{plan.resultTitle || plan.resultFileName || '查看成果'}<ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
@@ -212,6 +224,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
{showComplete && (
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 space-y-2">
<div className="text-[11px] font-medium text-emerald-700"></div>
<input value={resultTitle} onChange={(e) => setResultTitle(e.target.value)} placeholder="成果标题(必填,如 v1.0 产品方案)" className="h-8 w-full rounded-lg border border-[var(--line)] px-2 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
<div className="flex gap-2">
<button onClick={() => setResultType('link')} className={`h-7 px-2.5 rounded text-[11px] font-medium border ${resultType === 'link' ? 'border-[var(--accent)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}><Link2 className="h-3 w-3 inline mr-1" /></button>
<button onClick={() => setResultType('file')} className={`h-7 px-2.5 rounded text-[11px] font-medium border ${resultType === 'file' ? 'border-[var(--accent)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}><FileUp className="h-3 w-3 inline mr-1" /></button>
@@ -225,7 +238,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
</div>
)}
<div className="flex gap-2">
<button onClick={handleSubmitResult} disabled={resultType === 'link' ? !resultUrl.trim() : !fileData} className="h-7 px-3 rounded text-[11px] font-medium bg-emerald-500 text-white disabled:opacity-50"></button>
<button onClick={handleSubmitResult} disabled={!completionState.canSubmitResult || !resultTitle.trim() || (resultType === 'link' ? !resultUrl.trim() : !fileData)} className="h-7 px-3 rounded text-[11px] font-medium bg-emerald-500 text-white disabled:opacity-50"></button>
<button onClick={() => setShowComplete(false)} className="h-7 px-2 text-[11px] text-[var(--ink-muted)]"></button>
</div>
</div>
@@ -234,21 +247,26 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
{/* Footer Actions */}
{plan.status !== 'completed' && (
<div className="flex items-center gap-2 px-5 py-3 border-t border-[var(--line)] shrink-0">
{plan.status === 'pending' && (
<button onClick={() => updatePlan(plan.id, { status: 'in_progress' })} className="h-8 px-3 rounded-lg text-[12px] font-medium text-blue-600 border border-blue-200 hover:bg-blue-50 flex items-center gap-1">
<Play className="h-3 w-3" />
<>
<div className="flex items-center gap-2 px-5 py-3 border-t border-[var(--line)] shrink-0">
{plan.status === 'pending' && (
<button onClick={() => updatePlan(plan.id, { status: 'in_progress' })} className="h-8 px-3 rounded-lg text-[12px] font-medium text-blue-600 border border-blue-200 hover:bg-blue-50 flex items-center gap-1">
<Play className="h-3 w-3" />
</button>
)}
{plan.status === 'in_progress' && (
<button onClick={() => setShowComplete(true)} disabled={!completionState.canSubmitResult} className="h-8 px-3 rounded-lg text-[12px] font-medium text-emerald-600 border border-emerald-200 hover:bg-emerald-50 disabled:opacity-50 disabled:cursor-not-allowed">
</button>
)}
<button onClick={() => setShowTransfer(true)} className="h-8 px-3 rounded-lg text-[12px] font-medium text-[var(--ink-soft)] border border-[var(--line)] hover:bg-[var(--bg-subtle)] flex items-center gap-1">
<ArrowRightLeft className="h-3 w-3" />
</button>
</div>
{plan.status === 'in_progress' && !completionState.canSubmitResult && (
<div className="px-5 pb-3 text-[11px] text-[var(--ink-muted)]">{completionState.missingReasons.join('、')}</div>
)}
{plan.status === 'in_progress' && (
<button onClick={() => setShowComplete(true)} className="h-8 px-3 rounded-lg text-[12px] font-medium text-emerald-600 border border-emerald-200 hover:bg-emerald-50">
</button>
)}
<button onClick={() => setShowTransfer(true)} className="h-8 px-3 rounded-lg text-[12px] font-medium text-[var(--ink-soft)] border border-[var(--line)] hover:bg-[var(--bg-subtle)] flex items-center gap-1">
<ArrowRightLeft className="h-3 w-3" />
</button>
</div>
</>
)}
</div>
</div>

View File

@@ -1,23 +1,30 @@
'use client';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { Plus, Pencil, Trash2, X, Check, ExternalLink, FileUp, Link2, Play, ArrowRightLeft } from 'lucide-react';
import type { VersionPlan, PlanTask } from '@/lib/version-plan';
import { calcPlanDuration, formatDuration, calcTotalDuration, calcPlanProgress, calcLinkedReqProgress } from '@/lib/version-plan';
import { formatDateTime } from '@/lib/format';
import { FieldError } from '@/components/FieldError';
import { AiDecomposeButton } from './AiDecomposeButton';
import type { VersionWithContext } from '@/lib/derive';
import type { Requirement } from '@/lib/requirement';
import { mergeSelectedRequirementOptions } from '@/lib/requirement-selector';
import { canEditPlanRequirementCoverage, canTogglePlanChecklist, getPlanCompletionState } from '@/lib/version-plan-workflow';
interface Props {
plans: VersionPlan[];
versionId: string;
version?: VersionWithContext;
versionDeadline?: string;
currentUserName: string;
planType: 'research' | 'product' | 'ui';
versionMembers: { role: string; name: string }[];
linkedRequirements?: { id: string; title: string; code: string; productOwner?: string }[];
linkedRequirements?: Requirement[];
allRequirements?: Requirement[];
onCreate: (data: Omit<VersionPlan, 'id' | 'createdAt'>) => void;
onUpdate: (id: string, data: Partial<VersionPlan>) => void;
onComplete: (id: string, result: { resultType: 'link' | 'file'; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => void;
onComplete: (id: string, result: { resultType: 'link' | 'file'; resultTitle: string; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => { ok: boolean; message?: string } | void;
onDelete: (id: string) => void;
}
@@ -29,7 +36,7 @@ const STATUS_STYLE = {
};
const STATUS_LABEL = { pending: '未开始', in_progress: '进行中', completed: '已完成' };
export function PlanTab({ plans, versionId, versionDeadline, currentUserName, planType, versionMembers, linkedRequirements, onCreate, onUpdate, onComplete, onDelete }: Props) {
export function PlanTab({ plans, versionId, version, versionDeadline, currentUserName, planType, versionMembers, linkedRequirements, allRequirements, onCreate, onUpdate, onComplete, onDelete }: Props) {
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingPlan, setEditingPlan] = useState<VersionPlan | null>(null);
const [completingPlan, setCompletingPlan] = useState<VersionPlan | null>(null);
@@ -65,6 +72,10 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
const autoStarted = plan.status === 'pending' && plan.startTime && new Date(plan.startTime) <= new Date();
const effectiveStatus = autoStarted ? 'in_progress' : plan.status;
const effectiveStartAt = plan.actualStartAt || (autoStarted ? plan.startTime : null);
const completionState = getPlanCompletionState(plan);
const canToggle = canTogglePlanChecklist(plan);
const canEditCoverage = canEditPlanRequirementCoverage(plan);
const requirementOptions = mergeSelectedRequirementOptions(linkedRequirements ?? [], allRequirements ?? [], plan.linkedRequirementIds ?? []);
// 耗时用实际时间戳计算
const dur = plan.status === 'completed' && plan.completedAt && plan.actualStartAt
? calcPlanDuration(plan.actualStartAt, plan.completedAt)
@@ -102,11 +113,14 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
<div className="flex items-center gap-1.5 mt-2">
{plan.resultType === 'link' ? <Link2 className="h-3 w-3 text-[var(--accent)]" /> : <FileUp className="h-3 w-3 text-[var(--accent)]" />}
<a href={plan.resultUrl} target="_blank" rel="noopener noreferrer" className="text-[12px] text-[var(--accent)] hover:underline flex items-center gap-1">
{plan.resultFileName || '查看成果'}<ExternalLink className="h-3 w-3" />
{plan.resultTitle || plan.resultFileName || '查看成果'}<ExternalLink className="h-3 w-3" />
</a>
{planType === 'product' && version && (
<AiDecomposeButton plan={plan} version={version} />
)}
</div>
)}
{/* 调研:任务进度 */}
{/* 子任务 */}
{plan.type === 'research' && plan.tasks && plan.tasks.length > 0 && (
<div className="mt-3 space-y-2">
<div className="flex items-center gap-2">
@@ -119,14 +133,14 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
{plan.tasks.map((task) => (
<div key={task.id} className="flex items-center gap-2">
<button
disabled={plan.status !== 'in_progress' && !autoStarted}
disabled={!canToggle}
onClick={() => {
if (plan.status !== 'in_progress' && !autoStarted) return;
if (!canToggle) return;
const nextStatus = task.status === 'completed' ? 'pending' : 'completed';
const updatedTasks = plan.tasks!.map((t) => t.id === task.id ? { ...t, status: nextStatus as PlanTask['status'] } : t);
onUpdate(plan.id, { tasks: updatedTasks });
}}
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${plan.status !== 'in_progress' && !autoStarted ? 'opacity-40 cursor-not-allowed' : ''} ${task.status === 'completed' ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${!canToggle ? 'opacity-40 cursor-not-allowed' : ''} ${task.status === 'completed' ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
>
{task.status === 'completed' && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />}
</button>
@@ -139,8 +153,8 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
</div>
</div>
)}
{/* 产品方案/UI关联需求进度 */}
{(plan.type === 'product' || plan.type === 'ui') && plan.linkedRequirementIds && plan.linkedRequirementIds.length > 0 && linkedRequirements && (
{/* 关联需求 */}
{plan.linkedRequirementIds && plan.linkedRequirementIds.length > 0 && requirementOptions.length > 0 && (
<div className="mt-3 space-y-2">
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 rounded-full bg-[var(--bg-subtle)] overflow-hidden">
@@ -150,19 +164,19 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
</div>
<div className="space-y-1">
{plan.linkedRequirementIds.map((rid) => {
const req = linkedRequirements.find((r) => r.id === rid);
const req = requirementOptions.find((r) => r.id === rid);
const isDone = (plan.completedRequirementIds || []).includes(rid);
return req ? (
<div key={rid} className="flex items-center gap-2">
<button
disabled={plan.status !== 'in_progress' && !autoStarted}
disabled={!canEditCoverage}
onClick={() => {
if (plan.status !== 'in_progress' && !autoStarted) return;
if (!canEditCoverage) return;
const current = plan.completedRequirementIds || [];
const next = isDone ? current.filter((id) => id !== rid) : [...current, rid];
onUpdate(plan.id, { completedRequirementIds: next });
}}
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${plan.status !== 'in_progress' && !autoStarted ? 'opacity-40 cursor-not-allowed' : ''} ${isDone ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${!canEditCoverage ? 'opacity-40 cursor-not-allowed' : ''} ${isDone ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
>
{isDone && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />}
</button>
@@ -172,14 +186,11 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
) : null;
})}
</div>
{calcLinkedReqProgress(plan.linkedRequirementIds, plan.completedRequirementIds) === 100 && plan.status !== 'completed' && (
<div className="mt-2 rounded-lg bg-green-50 border border-green-200 px-3 py-2 flex items-center justify-between">
<span className="text-[12px] text-green-700"></span>
<button onClick={() => setCompletingPlan(plan)} className="text-[11px] font-medium text-green-700 hover:text-green-900 underline"></button>
</div>
)}
</div>
)}
{plan.status === 'in_progress' && !completionState.canSubmitResult && (
<p className="mt-2 text-[11px] text-[var(--ink-muted)]">{completionState.missingReasons.join('、')}</p>
)}
</div>
<div className="flex items-center gap-1 ml-3">
{plan.status === 'pending' && !autoStarted && (
@@ -187,11 +198,6 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
<Play className="h-3 w-3" />
</button>
)}
{plan.status !== 'completed' && plan.status !== 'pending' && (
<button onClick={() => setCompletingPlan(plan)} className="h-7 w-7 flex items-center justify-center rounded-md text-green-600 hover:bg-green-50" title="标记完成">
<Check className="h-3.5 w-3.5" />
</button>
)}
{plan.status !== 'completed' && (
<>
<button onClick={() => setTransferPlanId(plan.id)} className="h-7 w-7 flex items-center justify-center rounded-md text-blue-500 hover:bg-blue-50" title="转交">
@@ -218,6 +224,12 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
<button onClick={() => { setTransferPlanId(null); setTransferTo(''); }} className="h-7 px-2 text-[11px] text-[var(--ink-muted)]"></button>
</div>
)}
{plan.status !== 'completed' && completionState.canSubmitResult && (
<div className="mt-3 rounded-lg bg-green-50 border border-green-200 px-3 py-2 flex items-center justify-between">
<span className="text-[12px] text-green-700"></span>
<button onClick={() => setCompletingPlan(plan)} className="text-[11px] font-medium text-green-700 hover:text-green-900 underline"></button>
</div>
)}
</div>
);
})}
@@ -232,6 +244,7 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
versionDeadline={versionDeadline}
currentUserName={currentUserName}
linkedRequirements={linkedRequirements}
allRequirements={allRequirements}
onClose={() => { setShowCreateModal(false); setEditingPlan(null); }}
onSubmit={(data) => {
if (editingPlan) onUpdate(editingPlan.id, data);
@@ -245,20 +258,28 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
{completingPlan && (
<CompleteModal
onClose={() => setCompletingPlan(null)}
onSubmit={(result) => { onComplete(completingPlan.id, result); setCompletingPlan(null); }}
onSubmit={(result) => {
const response = onComplete(completingPlan.id, result);
if (response && typeof response === 'object' && 'ok' in response && !response.ok) {
alert(response.message || '计划未满足完成条件');
return;
}
setCompletingPlan(null);
}}
/>
)}
</div>
);
}
function PlanFormModal({ initial, planType, versionId, versionDeadline, currentUserName, linkedRequirements, onClose, onSubmit }: {
function PlanFormModal({ initial, planType, versionId, versionDeadline, currentUserName, linkedRequirements, allRequirements, onClose, onSubmit }: {
initial: VersionPlan | null;
planType: 'research' | 'product' | 'ui';
versionId: string;
versionDeadline?: string;
currentUserName: string;
linkedRequirements?: { id: string; title: string; code: string }[];
linkedRequirements?: Requirement[];
allRequirements?: Requirement[];
onClose: () => void;
onSubmit: (data: any) => void;
}) {
@@ -273,7 +294,10 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
const [overdueReason, setOverdueReason] = useState(initial?.overdueReason ?? '');
const [selectedReqs, setSelectedReqs] = useState<Set<string>>(new Set(initial?.linkedRequirementIds ?? []));
const [endTimeError, setEndTimeError] = useState('');
const showReqSelect = planType === 'product' || planType === 'ui';
const requirementOptions = useMemo(
() => mergeSelectedRequirementOptions(linkedRequirements ?? [], allRequirements ?? [], Array.from(selectedReqs)),
[linkedRequirements, allRequirements, selectedReqs],
);
const isOverdue = !!(versionDeadline && endTime && new Date(endTime) > new Date(versionDeadline));
const handleSubmit = (e: React.FormEvent) => {
@@ -294,8 +318,8 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
startTime,
endTime,
status: initial?.status ?? 'pending',
linkedRequirementIds: showReqSelect ? Array.from(selectedReqs) : undefined,
tasks: tasks.length > 0 ? tasks : undefined,
linkedRequirementIds: requirementOptions.length > 0 ? Array.from(selectedReqs) : undefined,
tasks: planType === 'research' && tasks.length > 0 ? tasks : undefined,
remark: remark.trim() || undefined,
overdueReason: isOverdue ? overdueReason.trim() : undefined,
addedBy: currentUserName,
@@ -345,63 +369,83 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
/>
</div>
)}
{showReqSelect && linkedRequirements && linkedRequirements.length > 0 && (
{requirementOptions.length > 0 && (
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block"></label>
<div className="mb-2 flex items-center justify-between">
<span className="text-[11px] text-[var(--ink-muted)]"> {selectedReqs.size} / {requirementOptions.filter((req) => !req.isHistorical).length}</span>
<div className="flex gap-2">
<button
type="button"
onClick={() => setSelectedReqs(new Set(requirementOptions.map((req) => req.id)))}
className="text-[11px] text-[var(--accent)] hover:underline"
>
</button>
<button
type="button"
onClick={() => setSelectedReqs(new Set())}
className="text-[11px] text-[var(--ink-muted)] hover:underline"
>
</button>
</div>
</div>
<div className="max-h-[120px] overflow-y-auto rounded-lg border border-[var(--line)] p-2 space-y-1">
{linkedRequirements.map((req) => (
{requirementOptions.map((req) => (
<label key={req.id} className="flex items-center gap-2 rounded px-2 py-1 hover:bg-[var(--bg-subtle)] cursor-pointer text-[12px]">
<input type="checkbox" checked={selectedReqs.has(req.id)} onChange={() => { const s = new Set(selectedReqs); if (s.has(req.id)) s.delete(req.id); else s.add(req.id); setSelectedReqs(s); }} className="h-3.5 w-3.5 rounded" />
<span className="text-[var(--ink-muted)] font-mono">{req.code}</span>
<span className="text-[var(--ink)] truncate">{req.title}</span>
{req.isHistorical && <span className="text-[10px] text-orange-600 bg-orange-50 px-1.5 py-0.5 rounded"></span>}
</label>
))}
</div>
</div>
)}
{planType === 'research' && (
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
<span className="text-red-500">*</span>
<span className="text-[10px] text-[var(--ink-muted)] ml-1"></span>
</label>
{/* 预设选项 */}
{tasks.length === 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{['竞品分析', '用户访谈', '数据调研', '技术可行性分析', '市场调研', '需求分析'].map((preset) => (
<button
key={preset}
type="button"
onClick={() => setTasks([...tasks, { id: `task-${Date.now()}-${Math.random().toString(36).slice(2, 5)}`, title: preset, status: 'pending' }])}
className="h-6 px-2.5 rounded-md text-[11px] border border-dashed border-[var(--line)] text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors"
>
+ {preset}
</button>
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
<span className="text-red-500">*</span>
<span className="text-[10px] text-[var(--ink-muted)] ml-1"></span>
</label>
{/* 预设选项 */}
{tasks.length === 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{['竞品分析', '用户访谈', '数据调研', '技术可行性分析', '市场调研', '需求分析'].map((preset) => (
<button
key={preset}
type="button"
onClick={() => setTasks([...tasks, { id: `task-${Date.now()}-${Math.random().toString(36).slice(2, 5)}`, title: preset, status: 'pending' }])}
className="h-6 px-2.5 rounded-md text-[11px] border border-dashed border-[var(--line)] text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors"
>
+ {preset}
</button>
))}
</div>
)}
<div className="space-y-1.5 mb-2">
{tasks.map((task, i) => (
<div key={task.id} className="flex items-center gap-2 rounded-lg bg-[var(--bg-subtle)] px-3 py-1.5">
<span className="flex-1 text-[12px] text-[var(--ink)]">{task.title}</span>
<button type="button" onClick={() => setTasks(tasks.filter((_, idx) => idx !== i))} className="text-red-400 hover:text-red-600"><X className="h-3 w-3" /></button>
</div>
))}
</div>
)}
<div className="space-y-1.5 mb-2">
{tasks.map((task, i) => (
<div key={task.id} className="flex items-center gap-2 rounded-lg bg-[var(--bg-subtle)] px-3 py-1.5">
<span className="flex-1 text-[12px] text-[var(--ink)]">{task.title}</span>
<button type="button" onClick={() => setTasks(tasks.filter((_, idx) => idx !== i))} className="text-red-400 hover:text-red-600"><X className="h-3 w-3" /></button>
</div>
))}
<div className="flex gap-2">
<input
value={newTaskTitle}
onChange={(e) => setNewTaskTitle(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); if (newTaskTitle.trim()) { setTasks([...tasks, { id: `task-${Date.now()}`, title: newTaskTitle.trim(), status: 'pending' }]); setNewTaskTitle(''); } } }}
placeholder="自定义调研方向,回车添加"
className="flex-1 h-8 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none"
/>
<button type="button" onClick={() => { if (newTaskTitle.trim()) { setTasks([...tasks, { id: `task-${Date.now()}`, title: newTaskTitle.trim(), status: 'pending' }]); setNewTaskTitle(''); } }} className="h-8 px-3 rounded-lg text-[12px] font-medium bg-[var(--bg-subtle)] text-[var(--ink-soft)] hover:bg-[var(--line)]"></button>
</div>
{tasks.length === 0 && (
<div className="text-[11px] text-red-500 mt-1"></div>
)}
</div>
<div className="flex gap-2">
<input
value={newTaskTitle}
onChange={(e) => setNewTaskTitle(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); if (newTaskTitle.trim()) { setTasks([...tasks, { id: `task-${Date.now()}`, title: newTaskTitle.trim(), status: 'pending' }]); setNewTaskTitle(''); } } }}
placeholder="自定义任务名称,回车添加"
className="flex-1 h-8 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none"
/>
<button type="button" onClick={() => { if (newTaskTitle.trim()) { setTasks([...tasks, { id: `task-${Date.now()}`, title: newTaskTitle.trim(), status: 'pending' }]); setNewTaskTitle(''); } }} className="h-8 px-3 rounded-lg text-[12px] font-medium bg-[var(--bg-subtle)] text-[var(--ink-soft)] hover:bg-[var(--line)]"></button>
</div>
{tasks.length === 0 && (
<div className="text-[11px] text-red-500 mt-1"></div>
)}
</div>
)}
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"></label>
@@ -419,9 +463,10 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
function CompleteModal({ onClose, onSubmit }: {
onClose: () => void;
onSubmit: (result: { resultType: 'link' | 'file'; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => void;
onSubmit: (result: { resultType: 'link' | 'file'; resultTitle: string; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => void;
}) {
const [resultType, setResultType] = useState<'link' | 'file'>('link');
const [resultTitle, setResultTitle] = useState('');
const [url, setUrl] = useState('');
const [fileName, setFileName] = useState('');
const [fileData, setFileData] = useState('');
@@ -435,7 +480,7 @@ function CompleteModal({ onClose, onSubmit }: {
reader.readAsDataURL(file);
};
const canSubmit = resultType === 'link' ? url.trim().length > 0 : fileData.length > 0;
const canSubmit = resultTitle.trim().length > 0 && (resultType === 'link' ? url.trim().length > 0 : fileData.length > 0);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
@@ -445,6 +490,10 @@ function CompleteModal({ onClose, onSubmit }: {
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button>
</div>
<div className="space-y-3">
<div>
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1"><span className="text-red-500 ml-0.5">*</span></label>
<input value={resultTitle} onChange={(e) => setResultTitle(e.target.value)} placeholder="如 v1.0 产品方案" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
</div>
<div className="flex gap-2">
<button type="button" onClick={() => setResultType('link')} className={`h-8 px-3 rounded-lg text-[12px] font-medium border transition-colors ${resultType === 'link' ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}></button>
<button type="button" onClick={() => setResultType('file')} className={`h-8 px-3 rounded-lg text-[12px] font-medium border transition-colors ${resultType === 'file' ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}></button>
@@ -459,7 +508,7 @@ function CompleteModal({ onClose, onSubmit }: {
)}
<div className="flex justify-end gap-2 pt-2">
<button onClick={onClose} className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"></button>
<button onClick={() => onSubmit({ resultType, resultUrl: resultType === 'link' ? url.trim() : fileData, resultFileName: fileName || undefined, resultFileData: resultType === 'file' ? fileData : undefined })} disabled={!canSubmit} className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] disabled:opacity-50"></button>
<button onClick={() => onSubmit({ resultType, resultTitle: resultTitle.trim(), resultUrl: resultType === 'link' ? url.trim() : fileData, resultFileName: fileName || undefined, resultFileData: resultType === 'file' ? fileData : undefined })} disabled={!canSubmit} className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] disabled:opacity-50"></button>
</div>
</div>
</div>

View File

@@ -6,6 +6,7 @@ import type { Requirement, ChangeReason } from '@/lib/requirement';
import type { DevTask } from '@/lib/dev-task';
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, CHANGE_REASON_LABEL } from '@/lib/requirement';
import { deriveReqDevStatus, canEditRequirement, REQ_DEV_STATUS_LABEL, REQ_DEV_STATUS_COLOR } from '@/lib/linkage-engine';
import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector';
interface Props {
versionId: string;
@@ -23,7 +24,7 @@ export function VersionRequirementsTab({ versionId, projectId, requirements, dev
const [showAddModal, setShowAddModal] = useState(false);
const [showChangeModal, setShowChangeModal] = useState(false);
const linkedReqs = requirements.filter((r) => r.versionId === versionId);
const availableReqs = requirements.filter((r) => r.projectId === projectId && !r.versionId && r.status === 'adopted');
const availableReqs = getProjectAdoptedRequirementCandidates(requirements, projectId).filter((r) => !r.versionId);
return (
<div className="space-y-4">
@@ -43,7 +44,7 @@ export function VersionRequirementsTab({ versionId, projectId, requirements, dev
{linkedReqs.length === 0 ? (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-12 text-center text-[13px] text-[var(--ink-muted)]">
</div>
) : (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
@@ -213,7 +214,7 @@ function AddRequirementModal({ available, onClose, onConfirm }: {
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="w-full max-w-lg rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] shadow-[var(--shadow-md)] flex flex-col max-h-[70vh]" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--line)]">
<h3 className="text-[13px] font-semibold text-[var(--ink)]"></h3>
<h3 className="text-[13px] font-semibold text-[var(--ink)]"></h3>
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button>
</div>
<div className="px-5 py-3 border-b border-[var(--line)]">
@@ -221,6 +222,15 @@ function AddRequirementModal({ available, onClose, onConfirm }: {
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-[var(--ink-muted)]" />
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="搜索需求编号或标题" className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-9 pr-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
</div>
{filtered.length > 0 && (
<div className="mt-2 flex items-center justify-between">
<span className="text-[11px] text-[var(--ink-muted)]"> {filtered.length} {selected.size} </span>
<div className="flex gap-2">
<button type="button" onClick={() => setSelected(new Set(filtered.map((r) => r.id)))} className="text-[11px] text-[var(--accent)] hover:underline"></button>
<button type="button" onClick={() => setSelected(new Set())} className="text-[11px] text-[var(--ink-muted)] hover:underline"></button>
</div>
</div>
)}
</div>
<div className="flex-1 overflow-y-auto px-5 py-3">
{filtered.length === 0 ? (

View File

@@ -8,7 +8,8 @@ async function checkApi(): Promise<boolean> {
if (probePromise) return probePromise;
probePromise = (async () => {
try {
const res = await fetch(`${API_BASE}/products`, { method: 'HEAD', signal: AbortSignal.timeout(300) });
// 探测一个不依赖数据库的端点(/config/ai 总是返回 200只要 NestJS 起来了)
const res = await fetch(`${API_BASE}/config/ai`, { method: 'GET', signal: AbortSignal.timeout(1500) });
apiAvailable = res.ok;
} catch {
apiAvailable = false;
@@ -37,7 +38,29 @@ export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, data: unknown) =>
request<T>(path, { method: 'POST', body: JSON.stringify(data) }),
put: <T>(path: string, data: unknown) =>
request<T>(path, { method: 'PUT', body: JSON.stringify(data) }),
patch: <T>(path: string, data: unknown) =>
request<T>(path, { method: 'PATCH', body: JSON.stringify(data) }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
postRaw: async <T>(path: string, data: unknown, timeoutMs = 120000): Promise<T> => {
// 调用 AI 类长耗时接口时使用,跳过 checkApi 短路(确保走真实请求)
const controller = new AbortController();
const tid = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(`${API_BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
signal: controller.signal,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || `请求失败: ${res.status}`);
}
return res.json();
} finally {
clearTimeout(tid);
}
},
};

View File

@@ -0,0 +1,12 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { shouldPersistRemoteOverview } from './product-overview-persistence';
test('does not persist an empty remote overview', () => {
assert.equal(shouldPersistRemoteOverview([]), false);
});
test('persists a non-empty remote overview', () => {
assert.equal(shouldPersistRemoteOverview([{ id: 'product-1' }]), true);
});

View File

@@ -0,0 +1,3 @@
export function shouldPersistRemoteOverview(overview: unknown): boolean {
return Array.isArray(overview) && overview.length > 0;
}

View File

@@ -0,0 +1,49 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { getProjectAdoptedRequirementCandidates, mergeSelectedRequirementOptions } from './requirement-selector';
import type { Requirement } from './requirement';
const base = {
description: '',
productId: 'product-1',
sourceType: 'internal',
sourceTarget: '',
platforms: [] as string[],
typeId: '',
priority: 'P2',
effort: 'M',
creator: 'tester',
createdAt: '2026-06-25',
};
function req(id: string, projectId: string, status: Requirement['status']): Requirement {
return {
...base,
id,
code: id.toUpperCase(),
title: `Requirement ${id}`,
projectId,
status,
} as Requirement;
}
test('returns only adopted requirements from current project', () => {
const result = getProjectAdoptedRequirementCandidates([
req('r1', 'project-1', 'adopted'),
req('r2', 'project-1', 'pending_review'),
req('r3', 'project-2', 'adopted'),
], 'project-1');
assert.deepEqual(result.map((r: Requirement) => r.id), ['r1']);
});
test('keeps historical selected requirements as non-candidate options', () => {
const options = mergeSelectedRequirementOptions(
[req('r1', 'project-1', 'adopted')],
[req('r1', 'project-1', 'adopted'), req('r2', 'project-1', 'developing')],
['r1', 'r2'],
);
assert.equal(options.find((o: { id: string; isHistorical?: boolean }) => o.id === 'r2')?.isHistorical, true);
});

View File

@@ -0,0 +1,42 @@
import type { Requirement } from './requirement';
export interface RequirementOption {
id: string;
code: string;
title: string;
productOwner?: string;
status: Requirement['status'];
isHistorical?: boolean;
}
export function toRequirementOption(requirement: Requirement, isHistorical = false): RequirementOption {
return {
id: requirement.id,
code: requirement.code,
title: requirement.title,
productOwner: requirement.productOwner,
status: requirement.status,
isHistorical,
};
}
export function getProjectAdoptedRequirementCandidates(requirements: Requirement[], projectId: string): Requirement[] {
return requirements
.filter((r) => r.projectId === projectId && r.status === 'adopted')
.sort((a, b) => a.code.localeCompare(b.code, 'zh-CN'));
}
export function mergeSelectedRequirementOptions(
candidates: Requirement[],
allRequirements: Requirement[],
selectedIds: string[] = [],
): RequirementOption[] {
const candidateOptions = candidates.map((r) => toRequirementOption(r));
const candidateIds = new Set(candidateOptions.map((r) => r.id));
const historical = selectedIds
.filter((id) => !candidateIds.has(id))
.map((id) => allRequirements.find((r) => r.id === id))
.filter((r): r is Requirement => Boolean(r))
.map((r) => toRequirementOption(r, true));
return [...candidateOptions, ...historical];
}

View File

@@ -0,0 +1,27 @@
import { api } from './api';
export type ServerDataKey =
| 'products-overview'
| 'requirements'
| 'version-plans'
| 'dev-tasks'
| 'test-cases'
| 'bugs'
| 'members'
| 'task-categories'
| 'task-worklogs'
| 'overtime';
interface ServerDataResponse<T> {
key: ServerDataKey;
value: T | null;
}
export async function loadServerData<T>(key: ServerDataKey): Promise<T | null> {
const res = await api.get<ServerDataResponse<T>>(`/data/${key}`);
return res.value;
}
export async function saveServerData<T>(key: ServerDataKey, value: T): Promise<void> {
await api.put(`/data/${key}`, { value });
}

View File

@@ -0,0 +1,38 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
DEFAULT_TEST_CATEGORY_ID,
PRESET_CATEGORIES,
findCategoryByCode,
getDefaultCategoryByGroup,
normalizeTaskCategories,
resolveCategoryIdFromCode,
} from './task-category';
test('preset categories include stable codes and testing group', () => {
assert.ok(PRESET_CATEGORIES.some((c) => c.code === 'test_functional' && c.group === 'testing'));
assert.ok(PRESET_CATEGORIES.every((c) => c.code.length > 0));
});
test('normalizes legacy categories without code', () => {
const normalized = normalizeTaskCategories([
{ id: 'cat-1', name: '前端开发', group: 'development', sortOrder: 1, isSystem: true },
]);
assert.equal(normalized[0].code, 'frontend_development');
});
test('resolves category id from stable code', () => {
const id = resolveCategoryIdFromCode(PRESET_CATEGORIES, 'test_functional', 'testing');
assert.equal(id, DEFAULT_TEST_CATEGORY_ID);
});
test('falls back to default group category for unknown code', () => {
const category = getDefaultCategoryByGroup(PRESET_CATEGORIES, 'testing');
assert.equal(resolveCategoryIdFromCode(PRESET_CATEGORIES, 'unknown_code', 'testing'), category.id);
});
test('finds category by code', () => {
assert.equal(findCategoryByCode(PRESET_CATEGORIES, 'backend_api')?.name, '后端接口');
});

View File

@@ -1,7 +1,10 @@
export type CategoryGroup = 'development' | 'implementation' | 'other';
export type CategoryGroup = 'development' | 'testing' | 'implementation' | 'other';
export const DEFAULT_TEST_CATEGORY_ID = 'cat-test-functional';
export interface TaskCategory {
id: string;
code: string;
name: string;
group: CategoryGroup;
color?: string;
@@ -11,19 +14,68 @@ export interface TaskCategory {
export const CATEGORY_GROUP_LABEL: Record<CategoryGroup, string> = {
development: '开发',
testing: '测试',
implementation: '实施',
other: '其他',
};
export const PRESET_CATEGORIES: TaskCategory[] = [
{ id: 'cat-1', name: '前端开发', group: 'development', color: '#3b82f6', sortOrder: 1, isSystem: true },
{ id: 'cat-2', name: '后端开发', group: 'development', color: '#6366f1', sortOrder: 2, isSystem: true },
{ id: 'cat-3', name: '数据库设计', group: 'development', color: '#8b5cf6', sortOrder: 3, isSystem: true },
{ id: 'cat-4', name: '接口联调', group: 'development', color: '#0ea5e9', sortOrder: 4, isSystem: true },
{ id: 'cat-5', name: '数据处理', group: 'implementation', color: '#f59e0b', sortOrder: 5, isSystem: true },
{ id: 'cat-6', name: '实施支持', group: 'implementation', color: '#10b981', sortOrder: 6, isSystem: true },
{ id: 'cat-1', code: 'frontend_development', name: '前端开发', group: 'development', color: '#3b82f6', sortOrder: 1, isSystem: true },
{ id: 'cat-frontend-interaction', code: 'frontend_interaction', name: '前端交互', group: 'development', color: '#0ea5e9', sortOrder: 2, isSystem: true },
{ id: 'cat-2', code: 'backend_development', name: '后端开发', group: 'development', color: '#6366f1', sortOrder: 3, isSystem: true },
{ id: 'cat-backend-api', code: 'backend_api', name: '后端接口', group: 'development', color: '#2563eb', sortOrder: 4, isSystem: true },
{ id: 'cat-3', code: 'database_schema', name: '数据库设计', group: 'development', color: '#8b5cf6', sortOrder: 5, isSystem: true },
{ id: 'cat-4', code: 'api_integration', name: '接口联调', group: 'development', color: '#06b6d4', sortOrder: 6, isSystem: true },
{ id: DEFAULT_TEST_CATEGORY_ID, code: 'test_functional', name: '功能测试', group: 'testing', color: '#22c55e', sortOrder: 20, isSystem: true },
{ id: 'cat-test-api', code: 'test_api', name: '接口测试', group: 'testing', color: '#14b8a6', sortOrder: 21, isSystem: true },
{ id: 'cat-test-exception', code: 'test_exception', name: '异常场景测试', group: 'testing', color: '#f97316', sortOrder: 22, isSystem: true },
{ id: 'cat-test-compatibility', code: 'test_compatibility', name: '兼容性测试', group: 'testing', color: '#a855f7', sortOrder: 23, isSystem: true },
{ id: 'cat-5', code: 'data_processing', name: '数据处理', group: 'implementation', color: '#f59e0b', sortOrder: 40, isSystem: true },
{ id: 'cat-6', code: 'implementation_support', name: '实施支持', group: 'implementation', color: '#10b981', sortOrder: 41, isSystem: true },
{ id: 'cat-other-doc', code: 'documentation', name: '文档', group: 'other', color: '#64748b', sortOrder: 60, isSystem: true },
];
const LEGACY_CODE_BY_ID: Record<string, string> = {
'cat-1': 'frontend_development',
'cat-2': 'backend_development',
'cat-3': 'database_schema',
'cat-4': 'api_integration',
'cat-5': 'data_processing',
'cat-6': 'implementation_support',
};
function slugifyCategoryName(name: string, index: number): string {
return (
name
.trim()
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '_')
.replace(/^_+|_+$/g, '') || `category_${index + 1}`
);
}
export function normalizeTaskCategory(category: Partial<TaskCategory> | undefined, index = 0): TaskCategory {
const preset = PRESET_CATEGORIES.find((c) => c.id === category?.id);
const name = category?.name || preset?.name || `分类 ${index + 1}`;
return {
id: category?.id || preset?.id || `cat-${index + 1}`,
code: category?.code || LEGACY_CODE_BY_ID[category?.id ?? ''] || preset?.code || slugifyCategoryName(name, index),
name,
group: category?.group || preset?.group || 'other',
color: category?.color ?? preset?.color,
sortOrder: typeof category?.sortOrder === 'number' ? category.sortOrder : preset?.sortOrder ?? index + 1,
isSystem: typeof category?.isSystem === 'boolean' ? category.isSystem : Boolean(preset?.isSystem),
};
}
export function normalizeTaskCategories(categories: Partial<TaskCategory>[] = []): TaskCategory[] {
const merged: Partial<TaskCategory>[] = Array.isArray(categories) ? [...categories] : [];
for (const preset of PRESET_CATEGORIES) {
if (!merged.some((c) => c.id === preset.id)) merged.push(preset);
}
return merged.map((category, index) => normalizeTaskCategory(category, index));
}
export function getCategoryById(categories: TaskCategory[], id: string): TaskCategory | undefined {
return categories.find((c) => c.id === id);
}
@@ -31,3 +83,16 @@ export function getCategoryById(categories: TaskCategory[], id: string): TaskCat
export function getCategoriesByGroup(categories: TaskCategory[], group: CategoryGroup): TaskCategory[] {
return categories.filter((c) => c.group === group).sort((a, b) => a.sortOrder - b.sortOrder);
}
export function findCategoryByCode(categories: TaskCategory[], code?: string): TaskCategory | undefined {
if (!code) return undefined;
return categories.find((c) => c.code === code);
}
export function getDefaultCategoryByGroup(categories: TaskCategory[], group: CategoryGroup): TaskCategory {
return getCategoriesByGroup(categories, group)[0] ?? categories[0] ?? PRESET_CATEGORIES[0];
}
export function resolveCategoryIdFromCode(categories: TaskCategory[], code: string | undefined, fallbackGroup: CategoryGroup): string {
return findCategoryByCode(categories, code)?.id ?? getDefaultCategoryByGroup(categories, fallbackGroup).id;
}

View File

@@ -0,0 +1,85 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { getPlanCompletionState, hasPlanResult } from './version-plan-workflow';
import type { VersionPlan } from './version-plan';
function plan(patch: Partial<VersionPlan>): VersionPlan {
return {
id: 'plan-1',
versionId: 'version-1',
type: 'product',
title: '产品方案',
owner: 'PM',
startTime: '2026-06-25T09:00',
endTime: '2026-06-25T18:00',
status: 'in_progress',
linkedRequirementIds: ['r1'],
completedRequirementIds: [],
createdAt: '2026-06-25',
addedBy: 'PM',
...patch,
};
}
test('does not allow research result submission when subtasks are incomplete', () => {
const state = getPlanCompletionState(plan({
type: 'research',
tasks: [{ id: 'task-1', title: '调研方向', status: 'pending' }],
linkedRequirementIds: [],
}));
assert.equal(state.canSubmitResult, false);
assert.ok(state.missingReasons.includes('子任务未全部完成'));
});
test('requires product requirement coverage when linked requirements exist', () => {
const state = getPlanCompletionState(plan({}));
assert.equal(state.canSubmitResult, false);
assert.ok(state.missingReasons.includes('关联需求未全部覆盖'));
});
test('allows product result submission after coverage is complete without task checklist', () => {
const state = getPlanCompletionState(plan({
completedRequirementIds: ['r1'],
}));
assert.equal(state.canSubmitResult, true);
assert.equal(state.canComplete, false);
});
test('allows completion only after result exists', () => {
const state = getPlanCompletionState(plan({
completedRequirementIds: ['r1'],
resultType: 'link',
resultTitle: '原型',
resultUrl: 'https://example.com/prototype',
}));
assert.equal(state.canComplete, true);
});
test('ui plan does not require task checklist', () => {
const state = getPlanCompletionState(plan({
type: 'ui',
completedRequirementIds: ['r1'],
}));
assert.equal(state.canSubmitResult, true);
});
test('research requires tasks and result but not requirement coverage', () => {
const state = getPlanCompletionState(plan({
type: 'research',
tasks: [{ id: 'task-1', title: '调研', status: 'completed' }],
linkedRequirementIds: ['r1'],
completedRequirementIds: [],
resultType: 'file',
resultTitle: '调研报告',
resultFileName: 'report.pdf',
resultFileData: 'data:application/pdf;base64,abc',
}));
assert.equal(state.canComplete, true);
});
test('detects link and file result payloads', () => {
assert.equal(hasPlanResult({ resultType: 'link', resultTitle: '原型', resultUrl: 'https://example.com' }), true);
assert.equal(hasPlanResult({ resultType: 'file', resultTitle: '文件', resultFileName: 'a.pdf', resultFileData: 'data:pdf' }), true);
assert.equal(hasPlanResult({ resultType: 'link', resultTitle: '原型' }), false);
});

View File

@@ -0,0 +1,76 @@
import type { VersionPlan } from './version-plan';
export interface PlanResultPayload {
resultType?: 'link' | 'file';
resultTitle?: string;
resultUrl?: string;
resultFileName?: string;
resultFileData?: string;
}
export interface PlanCompletionState {
checklistTotal: number;
checklistCompleted: number;
requirementTotal: number;
requirementCompleted: number;
hasResult: boolean;
canSubmitResult: boolean;
canComplete: boolean;
missingReasons: string[];
}
export function hasPlanResult(plan: PlanResultPayload): boolean {
const hasTitle = Boolean(plan.resultTitle?.trim());
if (!hasTitle || !plan.resultType) return false;
if (plan.resultType === 'link') return Boolean(plan.resultUrl?.trim());
return Boolean(plan.resultFileData || plan.resultFileName);
}
function requiresRequirementCoverage(plan: VersionPlan): boolean {
return plan.type === 'product' || plan.type === 'ui';
}
function requiresChecklist(plan: VersionPlan): boolean {
return plan.type === 'research';
}
export function getPlanCompletionState(plan: VersionPlan): PlanCompletionState {
const tasks = plan.tasks ?? [];
const checklistTotal = tasks.length;
const checklistCompleted = tasks.filter((task) => task.status === 'completed').length;
const linked = plan.linkedRequirementIds ?? [];
const completed = new Set(plan.completedRequirementIds ?? []);
const requirementTotal = linked.length;
const requirementCompleted = linked.filter((id) => completed.has(id)).length;
const missingReasons: string[] = [];
if (requiresChecklist(plan) && checklistTotal === 0) missingReasons.push('缺少子任务');
if (requiresChecklist(plan) && checklistTotal > 0 && checklistCompleted < checklistTotal) missingReasons.push('子任务未全部完成');
if (requiresRequirementCoverage(plan) && requirementTotal > 0 && requirementCompleted < requirementTotal) {
missingReasons.push('关联需求未全部覆盖');
}
const canSubmitResult = missingReasons.length === 0;
const hasResult = hasPlanResult(plan);
if (canSubmitResult && !hasResult) missingReasons.push('尚未提交成果');
return {
checklistTotal,
checklistCompleted,
requirementTotal,
requirementCompleted,
hasResult,
canSubmitResult,
canComplete: canSubmitResult && hasResult,
missingReasons,
};
}
export function canTogglePlanChecklist(plan: VersionPlan, now: Date = new Date()): boolean {
return plan.status === 'in_progress' || (plan.status === 'pending' && Boolean(plan.startTime) && new Date(plan.startTime) <= now);
}
export function canEditPlanRequirementCoverage(plan: VersionPlan, now: Date = new Date()): boolean {
return canTogglePlanChecklist(plan, now);
}

View File

@@ -19,6 +19,7 @@ export interface VersionPlan {
completedRequirementIds?: string[];
linkedRequirementIds?: string[];
resultType?: 'link' | 'file';
resultTitle?: string;
resultUrl?: string;
resultFileName?: string;
resultFileData?: string;
@@ -28,6 +29,10 @@ export interface VersionPlan {
createdAt: string;
completedAt?: string;
addedBy: string;
aiDecomposeStatus?: 'idle' | 'in_progress' | 'completed' | 'error';
aiDecomposeAt?: string;
aiDecomposeBy?: string;
aiDecomposeError?: string;
}
export type PlanType = VersionPlan['type'];

View File

@@ -3,11 +3,12 @@
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "next dev --port 3000",
"build": "next build",
"dev": "node --max-old-space-size=4096 node_modules/next/dist/bin/next dev --port 3000",
"build": "node --max-old-space-size=4096 node_modules/next/dist/bin/next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
"type-check": "tsc --noEmit",
"test": "tsc -p tsconfig.test.json && node scripts/run-node-tests.mjs .tmp-test"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",

View File

@@ -0,0 +1,25 @@
import { readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
function collectTestFiles(dir) {
const out = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) out.push(...collectTestFiles(full));
if (stat.isFile() && full.endsWith('.test.js')) out.push(full);
}
return out;
}
const root = process.argv[2] || '.tmp-test';
const files = collectTestFiles(root);
if (files.length === 0) {
console.error(`No compiled .test.js files found under ${root}`);
process.exit(1);
}
const result = spawnSync(process.execPath, ['--test', ...files], { stdio: 'inherit' });
process.exit(result.status ?? 1);

View File

@@ -1,6 +1,9 @@
'use client';
import { create } from 'zustand';
import { loadServerData } from '@/lib/server-data';
import type { Member } from '@/lib/members';
import { useMemberStore } from './useMemberStore';
interface AuthUser {
id: string;
@@ -14,53 +17,63 @@ interface AuthUser {
interface AuthState {
user: AuthUser | null;
isAuthenticated: boolean;
login: (phone: string, password: string, remember: boolean) => boolean;
login: (phone: string, password: string, remember: boolean) => Promise<boolean>;
logout: () => void;
checkAuth: () => void;
refreshUser: () => void;
refreshUser: () => Promise<void>;
}
interface StoredMembersData {
members: Member[];
}
const SESSION_KEY = 'ftb_auth_session';
const PERSIST_KEY = 'ftb_auth_persist';
const FALLBACK_MEMBERS: Member[] = [
{
id: 'm-8',
name: '\u8d85\u7ea7\u7ba1\u7406\u5458',
departmentId: 'dept-1',
roleId: 'role-admin',
phone: '13200132008',
email: 'chenshi@company.com',
password: 'Ftb@2024',
createdAt: '2024-01-01',
},
];
async function loadMembersForAuth(): Promise<Member[]> {
try {
const stored = await loadServerData<StoredMembersData>('members');
if (stored?.members?.length) return stored.members;
} catch {}
const inMemoryMembers = useMemberStore.getState().members;
return inMemoryMembers.length > 0 ? inMemoryMembers : FALLBACK_MEMBERS;
}
function toAuthUser(member: Member): AuthUser {
return {
id: member.id,
name: member.name,
roleId: member.roleId,
departmentId: member.departmentId,
phone: member.phone,
email: member.email,
};
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
isAuthenticated: false,
login: (phone, password, remember) => {
const membersRaw = localStorage.getItem('ftb_members_v1');
let members: any[] = [];
if (membersRaw) {
const parsed = JSON.parse(membersRaw);
members = parsed.members || [];
}
// fallback: 如果 localStorage 没有数据或为空,使用默认 mock
if (members.length === 0) {
members = [
{ id: 'm-1', name: '张三', departmentId: 'dept-1', roleId: 'role-pm', phone: '13800138001', email: 'zhangsan@company.com', password: 'Ftb@2024' },
{ id: 'm-2', name: '李四', departmentId: 'dept-2-1', roleId: 'role-dev', phone: '13900139002', email: 'lisi@company.com', password: 'Ftb@2024' },
{ id: 'm-3', name: '王五', departmentId: 'dept-2-2', roleId: 'role-dev', phone: '13700137003', email: 'wangwu@company.com', password: 'Ftb@2024' },
{ id: 'm-4', name: '赵六', departmentId: 'dept-2-3', roleId: 'role-test', phone: '13600136004', email: 'zhaoliu@company.com', password: 'Ftb@2024' },
{ id: 'm-5', name: '孙七', departmentId: 'dept-3', roleId: 'role-design', phone: '13500135005', email: 'sunqi@company.com', password: 'Ftb@2024' },
{ id: 'm-6', name: '周八', departmentId: 'dept-4', roleId: 'role-pm', phone: '13400134006', email: 'zhouba@company.com', password: 'Ftb@2024' },
{ id: 'm-7', name: '吴九', departmentId: 'dept-2-1', roleId: 'role-dev', phone: '13300133007', email: 'wujiu@company.com', password: 'Ftb@2024' },
{ id: 'm-8', name: '陈十', departmentId: 'dept-1', roleId: 'role-admin', phone: '13200132008', email: 'chenshi@company.com', password: 'Ftb@2024' },
];
}
const member = members.find((m: any) => m.phone === phone && (m.password || 'Ftb@2024') === password);
login: async (phone, password, remember) => {
const members = await loadMembersForAuth();
const member = members.find((m) => m.phone === phone && (m.password || 'Ftb@2024') === password);
if (!member) return false;
const user: AuthUser = {
id: member.id,
name: member.name,
roleId: member.roleId,
departmentId: member.departmentId,
phone: member.phone,
email: member.email,
};
const user = toAuthUser(member);
set({ user, isAuthenticated: true });
sessionStorage.setItem(SESSION_KEY, JSON.stringify(user));
if (remember) {
@@ -90,16 +103,16 @@ export const useAuthStore = create<AuthState>((set) => ({
}
},
refreshUser: () => {
refreshUser: async () => {
const cur = useAuthStore.getState().user;
if (!cur) return;
try {
const raw = localStorage.getItem('ftb_members_v1');
if (!raw) return;
const parsed = JSON.parse(raw);
const m = parsed.members?.find((x: any) => x.id === cur.id);
if (!m) return;
const next: AuthUser = { id: m.id, name: m.name, roleId: m.roleId, departmentId: m.departmentId, phone: m.phone, email: m.email };
const memoryMember = useMemberStore.getState().members.find((x) => x.id === cur.id);
const members = memoryMember ? [memoryMember] : await loadMembersForAuth();
const member = members.find((x) => x.id === cur.id);
if (!member) return;
const next = toAuthUser(member);
useAuthStore.setState({ user: next });
sessionStorage.setItem(SESSION_KEY, JSON.stringify(next));
if (localStorage.getItem(PERSIST_KEY)) {

View File

@@ -3,8 +3,7 @@ import { create } from 'zustand';
import type { Department, Member, RoleItem, PasswordRule } from '@/lib/members';
import { DEFAULT_PASSWORD_RULE, generatePassword } from '@/lib/members';
import { DEFAULT_ROLE_PERMISSIONS } from '@/lib/permissions';
const STORAGE_KEY = 'ftb_members_v1';
import { loadServerData, saveServerData } from '@/lib/server-data';
const PRESET_DEPARTMENTS: Department[] = [
{ id: 'dept-1', name: '产品部', order: 1, createdAt: '2024-01-01' },
@@ -25,14 +24,7 @@ const PRESET_ROLES: RoleItem[] = [
];
const MOCK_MEMBERS: Member[] = [
{ id: 'm-1', name: '张三', departmentId: 'dept-1', roleId: 'role-pm', phone: '13800138001', email: 'zhangsan@company.com', password: 'Ftb@2024', createdAt: '2024-01-10' },
{ id: 'm-2', name: '李四', departmentId: 'dept-2-1', roleId: 'role-dev', phone: '13900139002', email: 'lisi@company.com', password: 'Ftb@2024', createdAt: '2024-01-12' },
{ id: 'm-3', name: '王五', departmentId: 'dept-2-2', roleId: 'role-dev', phone: '13700137003', email: 'wangwu@company.com', password: 'Ftb@2024', createdAt: '2024-01-15' },
{ id: 'm-4', name: '赵六', departmentId: 'dept-2-3', roleId: 'role-test', phone: '13600136004', email: 'zhaoliu@company.com', password: 'Ftb@2024', createdAt: '2024-02-01' },
{ id: 'm-5', name: '孙七', departmentId: 'dept-3', roleId: 'role-design', phone: '13500135005', email: 'sunqi@company.com', password: 'Ftb@2024', createdAt: '2024-02-10' },
{ id: 'm-6', name: '周八', departmentId: 'dept-4', roleId: 'role-pm', phone: '13400134006', email: 'zhouba@company.com', password: 'Ftb@2024', createdAt: '2024-02-15' },
{ id: 'm-7', name: '吴九', departmentId: 'dept-2-1', roleId: 'role-dev', phone: '13300133007', email: 'wujiu@company.com', password: 'Ftb@2024', createdAt: '2024-03-01' },
{ id: 'm-8', name: '陈十', departmentId: 'dept-1', roleId: 'role-admin', phone: '13200132008', email: 'chenshi@company.com', password: 'Ftb@2024', createdAt: '2024-01-01' },
{ id: 'm-8', name: '超级管理员', departmentId: 'dept-1', roleId: 'role-admin', phone: '13200132008', email: 'chenshi@company.com', password: 'Ftb@2024', createdAt: '2024-01-01' },
];
interface MemberState {
@@ -54,14 +46,13 @@ interface MemberState {
deleteRole: (id: string) => void;
}
function saveLocal(state: { departments: Department[]; members: Member[]; roles: RoleItem[]; passwordRule: PasswordRule }) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch {}
function saveStored(state: { departments: Department[]; members: Member[]; roles: RoleItem[]; passwordRule: PasswordRule }) {
saveServerData('members', state).catch(() => {});
}
function loadLocal(): { departments: Department[]; members: Member[]; roles: RoleItem[]; passwordRule: PasswordRule } | null {
async function loadStored(): Promise<{ departments: Department[]; members: Member[]; roles: RoleItem[]; passwordRule: PasswordRule } | null> {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
return await loadServerData<{ departments: Department[]; members: Member[]; roles: RoleItem[]; passwordRule: PasswordRule }>('members');
} catch {}
return null;
}
@@ -73,8 +64,8 @@ export const useMemberStore = create<MemberState>((set, get) => ({
passwordRule: DEFAULT_PASSWORD_RULE,
loading: false,
fetchMembers: () => {
const cached = loadLocal();
fetchMembers: async () => {
const cached = await loadStored();
if (cached) {
// 迁移:旧 RoleItem 没 permissions 字段,补默认值
const migratedRoles = cached.roles.map((r) => {
@@ -89,41 +80,41 @@ export const useMemberStore = create<MemberState>((set, get) => ({
updatePasswordRule: (rule) => {
set({ passwordRule: rule });
saveLocal({ departments: get().departments, members: get().members, roles: get().roles, passwordRule: rule });
saveStored({ departments: get().departments, members: get().members, roles: get().roles, passwordRule: rule });
},
createDepartment: (data) => {
const dept: Department = { ...data, id: `dept-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10) };
const departments = [...get().departments, dept];
set({ departments });
saveLocal({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule });
saveStored({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule });
},
updateDepartment: (id, data) => {
const departments = get().departments.map((d) => d.id === id ? { ...d, ...data } : d);
set({ departments });
saveLocal({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule });
saveStored({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule });
},
deleteDepartment: (id) => {
const departments = get().departments.filter((d) => d.id !== id && d.parentId !== id);
set({ departments });
saveLocal({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule });
saveStored({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule });
},
createMember: (data) => {
const member: Member = { ...data, id: `m-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10) };
const members = [...get().members, member];
set({ members });
saveLocal({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule });
saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule });
},
updateMember: (id, data) => {
const members = get().members.map((m) => m.id === id ? { ...m, ...data } : m);
set({ members });
saveLocal({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule });
saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule });
},
deleteMember: (id) => {
const members = get().members.filter((m) => m.id !== id);
set({ members });
saveLocal({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule });
saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule });
},
createRole: (data) => {
@@ -135,7 +126,7 @@ export const useMemberStore = create<MemberState>((set, get) => ({
};
const roles = [...get().roles, role];
set({ roles });
saveLocal({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule });
saveStored({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule });
},
updateRole: (id, data) => {
const roles = get().roles.map((r) => {
@@ -148,11 +139,11 @@ export const useMemberStore = create<MemberState>((set, get) => ({
return { ...r, ...data };
});
set({ roles });
saveLocal({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule });
saveStored({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule });
},
deleteRole: (id) => {
const roles = get().roles.filter((r) => r.id !== id);
set({ roles });
saveLocal({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule });
saveStored({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule });
},
}));

View File

@@ -3,8 +3,7 @@ import { create } from 'zustand';
import type { OvertimeRecord } from '@/lib/overtime';
import type { DictItem } from '@/lib/requirement';
import { calcDuration } from '@/lib/overtime';
const STORAGE_KEY = 'ftb_overtime_v1';
import { loadServerData, saveServerData } from '@/lib/server-data';
const PRESET_REASONS: DictItem[] = [
{ id: 'reason-1', name: '需求变更', createdAt: '2024-01-01' },
@@ -20,19 +19,13 @@ const PRESET_REASONS: DictItem[] = [
{ id: 'reason-11', name: '返工修改', createdAt: '2024-01-01' },
];
const MOCK_RECORDS: OvertimeRecord[] = [
{ id: 'ot-1', projectId: 'p1', versionId: 'v2', person: '张三', startTime: '2024-12-10T19:00', endTime: '2024-12-10T22:30', duration: 3.5, reasonId: 'reason-3', createdAt: '2024-12-10' },
{ id: 'ot-2', projectId: 'p2', versionId: 'v5', person: '李四', startTime: '2024-12-11T18:30', endTime: '2024-12-11T21:00', duration: 2.5, reasonId: 'reason-1', createdAt: '2024-12-11' },
{ id: 'ot-3', projectId: 'p1', versionId: 'v2', person: '王五', startTime: '2024-12-12T19:00', endTime: '2024-12-12T23:00', duration: 4, reasonId: 'reason-4', createdAt: '2024-12-12' },
{ id: 'ot-4', projectId: 'p3', person: '张三', startTime: '2024-12-13T18:00', endTime: '2024-12-13T20:30', duration: 2.5, reasonId: 'reason-7', createdAt: '2024-12-13' },
{ id: 'ot-5', projectId: 'p2', versionId: 'v5', person: '赵六', startTime: '2024-12-14T19:30', endTime: '2024-12-15T01:00', duration: 5.5, reasonId: 'reason-5', remark: '线上紧急事故', createdAt: '2024-12-14' },
];
const MOCK_RECORDS: OvertimeRecord[] = [];
interface OvertimeState {
records: OvertimeRecord[];
reasons: DictItem[];
loading: boolean;
fetchRecords: () => void;
fetchRecords: () => Promise<void>;
createRecord: (data: Omit<OvertimeRecord, 'id' | 'duration' | 'createdAt'>) => void;
updateRecord: (id: string, data: Partial<OvertimeRecord>) => void;
deleteRecord: (id: string) => void;
@@ -41,14 +34,13 @@ interface OvertimeState {
deleteReason: (id: string) => void;
}
function saveLocal(data: { records: OvertimeRecord[]; reasons: DictItem[] }) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch {}
function saveStored(data: { records: OvertimeRecord[]; reasons: DictItem[] }) {
saveServerData('overtime', data).catch(() => {});
}
function loadLocal(): { records: OvertimeRecord[]; reasons: DictItem[] } | null {
async function loadStored(): Promise<{ records: OvertimeRecord[]; reasons: DictItem[] } | null> {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
return await loadServerData<{ records: OvertimeRecord[]; reasons: DictItem[] }>('overtime');
} catch {}
return null;
}
@@ -58,8 +50,8 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
reasons: PRESET_REASONS,
loading: false,
fetchRecords: () => {
const cached = loadLocal();
fetchRecords: async () => {
const cached = await loadStored();
if (cached) set({ records: cached.records, reasons: cached.reasons });
},
@@ -73,7 +65,7 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
};
const records = [...get().records, record];
set({ records });
saveLocal({ records, reasons: get().reasons });
saveStored({ records, reasons: get().reasons });
},
updateRecord: (id, data) => {
@@ -86,31 +78,31 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
return updated;
});
set({ records });
saveLocal({ records, reasons: get().reasons });
saveStored({ records, reasons: get().reasons });
},
deleteRecord: (id) => {
const records = get().records.filter((r) => r.id !== id);
set({ records });
saveLocal({ records, reasons: get().reasons });
saveStored({ records, reasons: get().reasons });
},
addReason: (name) => {
const item: DictItem = { id: `reason-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) };
const reasons = [...get().reasons, item];
set({ reasons });
saveLocal({ records: get().records, reasons });
saveStored({ records: get().records, reasons });
},
updateReason: (id, name) => {
const reasons = get().reasons.map((r) => r.id === id ? { ...r, name } : r);
set({ reasons });
saveLocal({ records: get().records, reasons });
saveStored({ records: get().records, reasons });
},
deleteReason: (id) => {
const reasons = get().reasons.filter((r) => r.id !== id);
set({ reasons });
saveLocal({ records: get().records, reasons });
saveStored({ records: get().records, reasons });
},
}));

View File

@@ -3,6 +3,8 @@
import { create } from 'zustand';
import { Product } from '@ftb/shared';
import { api } from '@/lib/api';
import { shouldPersistRemoteOverview } from '@/lib/product-overview-persistence';
import { loadServerData, saveServerData } from '@/lib/server-data';
import type { Stage, Role } from '@/lib/stage';
import type { Priority, VersionLinks } from '@/lib/derive';
@@ -32,20 +34,7 @@ interface VersionItem {
links?: VersionLinks;
}
const MOCK_OVERVIEW: ProductOverview[] = [
{
id: 'mock-1',
name: '翻台宝',
description: '餐饮 SaaS 主产品,覆盖门店运营管理全流程',
createdAt: '2024-01-01',
updatedAt: '2024-06-01',
projects: [
{ id: 'p3', name: '值班', description: '门店值班排班与交接', createdAt: '2024-03-01' },
],
versions: [],
_count: { requirements: 10, projects: 1, versions: 0 },
},
];
const MOCK_OVERVIEW: ProductOverview[] = [];
interface ProductWithCount {
id: string;
@@ -101,8 +90,8 @@ export const useProductStore = create<ProductState>((set, get) => ({
fetchOverview: async () => {
if (get().overview.length > 0) return;
const cached = loadLocal();
if (cached.length > 0) {
const cached = await loadStoredOverview();
if (cached) {
// 有本地缓存直接用不再调远端覆盖mock 模式核心数据在本地)
set({ overview: cached, loading: false });
return;
@@ -111,10 +100,11 @@ export const useProductStore = create<ProductState>((set, get) => ({
try {
const overview = await api.get<ProductOverview[]>('/products/overview');
set({ overview, loading: false });
saveLocal(overview);
if (shouldPersistRemoteOverview(overview)) {
saveStoredOverview(overview);
}
} catch {
set({ overview: MOCK_OVERVIEW, error: null, loading: false });
saveLocal(MOCK_OVERVIEW);
}
},
@@ -147,7 +137,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
};
const updated = [newProduct, ...get().overview];
set({ overview: updated });
saveLocal(updated);
saveStoredOverview(updated);
// 异步尝试同步远端(失败也无所谓,本地已更新)
try { await api.post('/products', data); } catch {}
},
@@ -161,7 +151,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
p.id === id ? { ...p, ...data } : p,
);
set({ overview: updated });
saveLocal(updated);
saveStoredOverview(updated);
}
},
@@ -174,14 +164,14 @@ export const useProductStore = create<ProductState>((set, get) => ({
products: get().products.filter((p) => p.id !== id),
overview: updated,
});
saveLocal(updated);
saveStoredOverview(updated);
},
reorderProducts: (ids) => {
const map = new Map(get().overview.map((p) => [p.id, p]));
const reordered = ids.map((id) => map.get(id)).filter(Boolean) as ProductOverview[];
set({ overview: reordered });
saveLocal(reordered);
saveStoredOverview(reordered);
},
migrateAndDeleteProduct: (sourceId, targetId) => {
@@ -204,7 +194,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
.filter((p) => p.id !== sourceId)
.map((p) => (p.id === targetId ? migratedTarget : p));
set({ overview: updated });
saveLocal(updated);
saveStoredOverview(updated);
},
createProject: (productId, data) => {
@@ -223,7 +213,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
};
});
set({ overview: updated });
saveLocal(updated);
saveStoredOverview(updated);
},
createVersion: (productId, data) => {
@@ -244,7 +234,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
};
});
set({ overview: updated });
saveLocal(updated);
saveStoredOverview(updated);
},
updateVersion: (productId, versionId, data) => {
@@ -258,7 +248,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
};
});
set({ overview: updated });
saveLocal(updated);
saveStoredOverview(updated);
},
deleteVersion: (productId, versionId) => {
@@ -271,7 +261,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
};
});
set({ overview: updated });
saveLocal(updated);
saveStoredOverview(updated);
},
deleteProject: (productId, projectId) => {
@@ -284,20 +274,17 @@ export const useProductStore = create<ProductState>((set, get) => ({
};
});
set({ overview: updated });
saveLocal(updated);
saveStoredOverview(updated);
},
}));
const STORAGE_KEY = 'ftb_products_overview_v4';
function saveLocal(data: ProductOverview[]) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch {}
function saveStoredOverview(data: ProductOverview[]) {
saveServerData('products-overview', data).catch(() => {});
}
function loadLocal(): ProductOverview[] {
async function loadStoredOverview(): Promise<ProductOverview[] | null> {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
return await loadServerData<ProductOverview[]>('products-overview');
} catch {}
return MOCK_OVERVIEW;
return null;
}

View File

@@ -3,6 +3,7 @@
import { create } from 'zustand';
import type { Requirement, RequirementStatus, Effort, DictItem, SourceType, SourceTarget } from '@/lib/requirement';
import type { Priority } from '@/lib/derive';
import { loadServerData, saveServerData } from '@/lib/server-data';
interface RequirementState {
requirements: Requirement[];
@@ -10,7 +11,7 @@ interface RequirementState {
types: DictItem[];
platforms: DictItem[];
loading: boolean;
fetchRequirements: () => void;
fetchRequirements: () => Promise<void>;
createRequirement: (data: Omit<Requirement, 'id' | 'code' | 'createdAt'>) => void;
updateRequirement: (id: string, data: Partial<Requirement>) => void;
deleteRequirement: (id: string) => void;
@@ -50,33 +51,17 @@ const PRESET_PLATFORMS: DictItem[] = [
// --- Mock requirements ---
const MOCK_REQUIREMENTS: Requirement[] = [
{ id: 'req-1', code: 'REQ-001', title: '值班排班自动生成', description: '根据员工可用时间自动排班,支持轮换规则', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P1', effort: 'L', sourceType: 'internal', sourceTarget: '运营部', platforms: ['platform-1'], typeId: 'type-1', creator: '张三', createdAt: '2024-06-01' },
{ id: 'req-2', code: 'REQ-002', title: '值班交接确认', description: '交接班时双方确认签到,记录交接内容', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P1', effort: 'M', sourceType: 'internal', sourceTarget: '门店经理', platforms: ['platform-1'], typeId: 'type-1', creator: '张三', createdAt: '2024-06-01' },
{ id: 'req-3', code: 'REQ-003', title: '值班异常提醒', description: '迟到、缺勤自动推送提醒给店长', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P0', effort: 'M', sourceType: 'customer', sourceTarget: '连锁品牌A', platforms: ['platform-1', 'platform-2'], typeId: 'type-1', creator: '李四', createdAt: '2024-06-02' },
{ id: 'req-4', code: 'REQ-004', title: '值班日历视图', description: '以日历形式展示当月值班安排', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P2', effort: 'L', sourceType: 'internal', sourceTarget: '产品部', platforms: ['platform-1'], typeId: 'type-2', creator: '张三', createdAt: '2024-06-03' },
{ id: 'req-5', code: 'REQ-005', title: '值班换班申请', description: '员工可发起换班申请,经理审批', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P2', effort: 'M', sourceType: 'customer', sourceTarget: '连锁品牌B', platforms: ['platform-1'], typeId: 'type-1', creator: '李四', createdAt: '2024-06-03' },
{ id: 'req-6', code: 'REQ-006', title: '值班统计报表', description: '按月/周统计各员工值班时长、缺勤次数', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P2', effort: 'L', sourceType: 'internal', sourceTarget: '运营部', platforms: ['platform-1'], typeId: 'type-3', creator: '张三', createdAt: '2024-06-04' },
{ id: 'req-7', code: 'REQ-007', title: '多门店值班管理', description: '区域经理可查看管辖所有门店值班情况', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P1', effort: 'XL', sourceType: 'management', sourceTarget: '区域总监', platforms: ['platform-1', 'platform-3'], typeId: 'type-1', creator: '张三', createdAt: '2024-06-05' },
{ id: 'req-8', code: 'REQ-008', title: '值班备注功能', description: '排班时可添加备注(如特殊事项提醒)', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P3', effort: 'S', sourceType: 'internal', sourceTarget: '产品部', platforms: ['platform-1'], typeId: 'type-2', creator: '李四', createdAt: '2024-06-05' },
{ id: 'req-9', code: 'REQ-009', title: '法定节假日排班规则', description: '节假日自动应用特殊排班规则(加班费标记)', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P1', effort: 'M', sourceType: 'operation', sourceTarget: '合规部', platforms: ['platform-1'], typeId: 'type-1', creator: '张三', createdAt: '2024-06-06' },
{ id: 'req-10', code: 'REQ-010', title: '值班模板管理', description: '创建值班模板供快速复用(如早中晚三班)', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P2', effort: 'M', sourceType: 'internal', sourceTarget: '产品部', platforms: ['platform-1'], typeId: 'type-1', creator: '张三', createdAt: '2024-06-06' },
];
const MOCK_REQUIREMENTS: Requirement[] = [];
// --- localStorage helpers ---
// --- Server persistence helpers ---
const STORAGE_KEY = 'ftb_requirements_v3';
function saveLocal(state: { requirements: Requirement[]; sourceTargets: SourceTarget[]; types: DictItem[]; platforms: DictItem[] }) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {}
function saveStored(state: { requirements: Requirement[]; sourceTargets: SourceTarget[]; types: DictItem[]; platforms: DictItem[] }) {
saveServerData('requirements', state).catch(() => {});
}
function loadLocal(): { requirements: Requirement[]; sourceTargets: SourceTarget[]; types: DictItem[]; platforms: DictItem[] } | null {
async function loadStored(): Promise<{ requirements: Requirement[]; sourceTargets: SourceTarget[]; types: DictItem[]; platforms: DictItem[] } | null> {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
return await loadServerData<{ requirements: Requirement[]; sourceTargets: SourceTarget[]; types: DictItem[]; platforms: DictItem[] }>('requirements');
} catch {}
return null;
}
@@ -91,8 +76,8 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
platforms: PRESET_PLATFORMS,
loading: false,
fetchRequirements: () => {
const cached = loadLocal();
fetchRequirements: async () => {
const cached = await loadStored();
if (cached) {
set({ requirements: cached.requirements, sourceTargets: cached.sourceTargets, types: cached.types, platforms: cached.platforms });
}
@@ -110,21 +95,21 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
};
const updated = [...requirements, newReq];
set({ requirements: updated });
saveLocal({ requirements: updated, sourceTargets, types, platforms });
saveStored({ requirements: updated, sourceTargets, types, platforms });
},
updateRequirement: (id, data) => {
const { requirements, sourceTargets, types, platforms } = get();
const updated = requirements.map((r) => (r.id === id ? { ...r, ...data } : r));
set({ requirements: updated });
saveLocal({ requirements: updated, sourceTargets, types, platforms });
saveStored({ requirements: updated, sourceTargets, types, platforms });
},
deleteRequirement: (id) => {
const { requirements, sourceTargets, types, platforms } = get();
const updated = requirements.filter((r) => r.id !== id);
set({ requirements: updated });
saveLocal({ requirements: updated, sourceTargets, types, platforms });
saveStored({ requirements: updated, sourceTargets, types, platforms });
},
// --- SourceTarget dict ---
@@ -133,21 +118,21 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
const item: SourceTarget = { id: `st-${Date.now()}`, name, sourceType, createdAt: new Date().toISOString().slice(0, 10) };
const updated = [...sourceTargets, item];
set({ sourceTargets: updated });
saveLocal({ requirements, sourceTargets: updated, types, platforms });
saveStored({ requirements, sourceTargets: updated, types, platforms });
},
updateSourceTarget: (id, name) => {
const { requirements, sourceTargets, types, platforms } = get();
const updated = sourceTargets.map((s) => (s.id === id ? { ...s, name } : s));
set({ sourceTargets: updated });
saveLocal({ requirements, sourceTargets: updated, types, platforms });
saveStored({ requirements, sourceTargets: updated, types, platforms });
},
deleteSourceTarget: (id) => {
const { requirements, sourceTargets, types, platforms } = get();
const updated = sourceTargets.filter((s) => s.id !== id);
set({ sourceTargets: updated });
saveLocal({ requirements, sourceTargets: updated, types, platforms });
saveStored({ requirements, sourceTargets: updated, types, platforms });
},
// --- Type dict ---
@@ -156,21 +141,21 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
const item: DictItem = { id: `type-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) };
const updated = [...types, item];
set({ types: updated });
saveLocal({ requirements, sourceTargets, types: updated, platforms });
saveStored({ requirements, sourceTargets, types: updated, platforms });
},
updateType: (id, name) => {
const { requirements, sourceTargets, types, platforms } = get();
const updated = types.map((t) => (t.id === id ? { ...t, name } : t));
set({ types: updated });
saveLocal({ requirements, sourceTargets, types: updated, platforms });
saveStored({ requirements, sourceTargets, types: updated, platforms });
},
deleteType: (id) => {
const { requirements, sourceTargets, types, platforms } = get();
const updated = types.filter((t) => t.id !== id);
set({ types: updated });
saveLocal({ requirements, sourceTargets, types: updated, platforms });
saveStored({ requirements, sourceTargets, types: updated, platforms });
},
// --- Platform dict ---
@@ -179,21 +164,21 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
const item: DictItem = { id: `platform-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) };
const updated = [...platforms, item];
set({ platforms: updated });
saveLocal({ requirements, sourceTargets, types, platforms: updated });
saveStored({ requirements, sourceTargets, types, platforms: updated });
},
updatePlatform: (id, name) => {
const { requirements, sourceTargets, types, platforms } = get();
const updated = platforms.map((p) => (p.id === id ? { ...p, name } : p));
set({ platforms: updated });
saveLocal({ requirements, sourceTargets, types, platforms: updated });
saveStored({ requirements, sourceTargets, types, platforms: updated });
},
deletePlatform: (id) => {
const { requirements, sourceTargets, types, platforms } = get();
const updated = platforms.filter((p) => p.id !== id);
set({ platforms: updated });
saveLocal({ requirements, sourceTargets, types, platforms: updated });
saveStored({ requirements, sourceTargets, types, platforms: updated });
},
};
});

View File

@@ -1,25 +1,23 @@
'use client';
import { create } from 'zustand';
import type { TaskCategory, CategoryGroup } from '@/lib/task-category';
import { PRESET_CATEGORIES } from '@/lib/task-category';
import { PRESET_CATEGORIES, normalizeTaskCategories } from '@/lib/task-category';
import { loadServerData, saveServerData } from '@/lib/server-data';
const STORAGE_KEY = 'ftb_task_categories_v1';
function saveLocal(items: TaskCategory[]) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); } catch {}
function saveStored(items: TaskCategory[]) {
saveServerData('task-categories', items).catch(() => {});
}
function loadLocal(): TaskCategory[] | null {
async function loadStored(): Promise<TaskCategory[] | null> {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
return await loadServerData<TaskCategory[]>('task-categories');
} catch {}
return null;
}
interface TaskCategoryState {
categories: TaskCategory[];
fetchCategories: () => void;
fetchCategories: () => Promise<void>;
addCategory: (name: string, group: CategoryGroup, color?: string) => void;
updateCategory: (id: string, data: Partial<TaskCategory>) => void;
deleteCategory: (id: string) => boolean;
@@ -28,15 +26,16 @@ interface TaskCategoryState {
export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
categories: PRESET_CATEGORIES,
fetchCategories: () => {
const cached = loadLocal();
if (cached) set({ categories: cached });
fetchCategories: async () => {
const cached = await loadStored();
if (cached) set({ categories: normalizeTaskCategories(cached) });
},
addCategory: (name, group, color) => {
const list = get().categories;
const item: TaskCategory = {
id: `cat-${Date.now()}`,
code: name.trim().toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '_').replace(/^_+|_+$/g, '') || `cat_${Date.now()}`,
name,
group,
color,
@@ -45,13 +44,13 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
};
const updated = [...list, item];
set({ categories: updated });
saveLocal(updated);
saveStored(updated);
},
updateCategory: (id, data) => {
const updated = get().categories.map((c) => (c.id === id ? { ...c, ...data } : c));
set({ categories: updated });
saveLocal(updated);
saveStored(updated);
},
deleteCategory: (id) => {
@@ -59,7 +58,7 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
if (!target || target.isSystem) return false;
const updated = get().categories.filter((c) => c.id !== id);
set({ categories: updated });
saveLocal(updated);
saveStored(updated);
return true;
},
}));

View File

@@ -1,24 +1,22 @@
'use client';
import { create } from 'zustand';
import type { TaskWorklog } from '@/lib/task-worklog';
import { loadServerData, saveServerData } from '@/lib/server-data';
const STORAGE_KEY = 'ftb_task_worklogs_v1';
function saveLocal(items: TaskWorklog[]) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); } catch {}
function saveStored(items: TaskWorklog[]) {
saveServerData('task-worklogs', items).catch(() => {});
}
function loadLocal(): TaskWorklog[] | null {
async function loadStored(): Promise<TaskWorklog[] | null> {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
return await loadServerData<TaskWorklog[]>('task-worklogs');
} catch {}
return null;
}
interface TaskWorklogState {
worklogs: TaskWorklog[];
fetchWorklogs: () => void;
fetchWorklogs: () => Promise<void>;
addWorklog: (data: Omit<TaskWorklog, 'id' | 'createdAt'>) => void;
deleteWorklog: (id: string) => void;
getActualHours: (taskId: string) => number;
@@ -27,8 +25,8 @@ interface TaskWorklogState {
export const useTaskWorklogStore = create<TaskWorklogState>((set, get) => ({
worklogs: [],
fetchWorklogs: () => {
const cached = loadLocal();
fetchWorklogs: async () => {
const cached = await loadStored();
if (cached) set({ worklogs: cached });
},
@@ -40,13 +38,13 @@ export const useTaskWorklogStore = create<TaskWorklogState>((set, get) => ({
};
const updated = [...get().worklogs, item];
set({ worklogs: updated });
saveLocal(updated);
saveStored(updated);
},
deleteWorklog: (id) => {
const updated = get().worklogs.filter((w) => w.id !== id);
set({ worklogs: updated });
saveLocal(updated);
saveStored(updated);
},
getActualHours: (taskId) => {

View File

@@ -1,37 +1,36 @@
'use client';
import { create } from 'zustand';
import type { VersionPlan, PlanType } from '@/lib/version-plan';
const STORAGE_KEY = 'ftb_version_plans_v1';
import { loadServerData, saveServerData } from '@/lib/server-data';
import { getPlanCompletionState } from '@/lib/version-plan-workflow';
const MOCK_PLANS: VersionPlan[] = [];
function saveLocal(plans: VersionPlan[]) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(plans)); } catch {}
function saveStored(plans: VersionPlan[]) {
saveServerData('version-plans', plans).catch(() => {});
}
function loadLocal(): VersionPlan[] | null {
async function loadStored(): Promise<VersionPlan[] | null> {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
return await loadServerData<VersionPlan[]>('version-plans');
} catch {}
return null;
}
interface VersionPlanState {
plans: VersionPlan[];
fetchPlans: () => void;
fetchPlans: () => Promise<void>;
createPlan: (data: Omit<VersionPlan, 'id' | 'createdAt'>) => void;
updatePlan: (id: string, data: Partial<VersionPlan>) => void;
completePlan: (id: string, result: { resultType: 'link' | 'file'; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => void;
completePlan: (id: string, result: { resultType: 'link' | 'file'; resultTitle: string; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => { ok: boolean; message?: string };
deletePlan: (id: string) => void;
}
export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
plans: MOCK_PLANS,
fetchPlans: () => {
const cached = loadLocal();
fetchPlans: async () => {
const cached = await loadStored();
if (cached) set({ plans: cached });
},
@@ -39,7 +38,7 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
const plan: VersionPlan = { ...data, id: `plan-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10) };
const plans = [...get().plans, plan];
set({ plans });
saveLocal(plans);
saveStored(plans);
},
updatePlan: (id, data) => {
@@ -56,18 +55,30 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
return { ...p, ...patch };
});
set({ plans });
saveLocal(plans);
saveStored(plans);
},
completePlan: (id, result) => {
const plans = get().plans.map((p) => p.id === id ? { ...p, ...result, status: 'completed' as const, completedAt: new Date().toISOString() } : p);
let response: { ok: boolean; message?: string } = { ok: false, message: '计划不存在' };
const plans = get().plans.map((p) => {
if (p.id !== id) return p;
const next = { ...p, ...result, status: 'completed' as const, completedAt: new Date().toISOString() };
const state = getPlanCompletionState(next);
if (!state.canComplete) {
response = { ok: false, message: state.missingReasons.join('、') || '计划未满足完成条件' };
return p;
}
response = { ok: true };
return next;
});
set({ plans });
saveLocal(plans);
saveStored(plans);
return response;
},
deletePlan: (id) => {
const plans = get().plans.filter((p) => p.id !== id);
set({ plans });
saveLocal(plans);
saveStored(plans);
},
}));

View File

@@ -0,0 +1,15 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"outDir": ".tmp-test",
"rootDir": ".",
"jsx": "react-jsx",
"types": ["node"]
},
"include": ["lib/**/*.ts"],
"exclude": ["node_modules", ".next", ".tmp-test"]
}