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

@@ -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>