Files
ftb-project-management/apps/web/components/version/PlanTab.tsx
2026-06-26 11:01:11 +08:00

717 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useMemo, useState } from 'react';
import { Plus, Pencil, Trash2, X, Check, ExternalLink, FileUp, Link2, Play, ArrowRightLeft } from 'lucide-react';
import type { ProductPlanKind, ProductPlanReviewFailureType, ProductPlanReviewResult, VersionPlan, PlanTask } from '@/lib/version-plan';
import {
calcPlanDuration,
formatDuration,
calcTotalDuration,
calcPlanProgress,
calcLinkedReqProgress,
PRODUCT_PLAN_KIND_LABEL,
PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS,
PRODUCT_PLAN_REVIEW_RESULT_LABEL,
} 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';
import type { PlanResultPayload } 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?: Requirement[];
allRequirements?: Requirement[];
onCreate: (data: Omit<VersionPlan, 'id' | 'createdAt'>) => void;
onUpdate: (id: string, data: Partial<VersionPlan>) => void;
onComplete: (id: string, result: PlanResultPayload) => { ok: boolean; message?: string } | void;
onDelete: (id: string) => void;
}
const TYPE_LABEL = { research: '调研', product: '产品方案', ui: 'UI设计' };
const STATUS_STYLE = {
pending: 'bg-zinc-100 text-zinc-600',
in_progress: 'bg-blue-50 text-blue-600 border-blue-200',
completed: 'bg-green-50 text-green-700 border-green-200',
};
const STATUS_LABEL = { pending: '未开始', in_progress: '进行中', completed: '已完成' };
function getProductPlanKind(plan: VersionPlan): ProductPlanKind {
return plan.productPlanKind ?? 'design';
}
function getSubmitActionLabel(plan: VersionPlan): string {
if (plan.type === 'product') {
return getProductPlanKind(plan) === 'review' ? '提交评审结论' : '提交原型地址';
}
return '提交成果';
}
function getFailureLabels(types?: ProductPlanReviewFailureType[]): string[] {
if (!types?.length) return [];
return types
.map((type) => PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS.find((option) => option.value === type)?.label)
.filter(Boolean) as string[];
}
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);
const [transferPlanId, setTransferPlanId] = useState<string | null>(null);
const [transferTo, setTransferTo] = useState('');
const typePlans = plans.filter((p) => p.versionId === versionId && p.type === planType);
const totalDuration = calcTotalDuration(typePlans);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-[12px] text-[var(--ink-muted)]">{typePlans.length} {TYPE_LABEL[planType]}</span>
<span className="text-[12px] text-[var(--ink-soft)]"><span className="font-medium text-[var(--ink)]">{totalDuration}</span></span>
{versionDeadline && <span className="text-[12px] text-[var(--ink-muted)]"><span className="font-medium text-red-500">{versionDeadline}</span></span>}
</div>
<button onClick={() => setShowCreateModal(true)} className="flex h-8 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[13px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors">
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
</button>
</div>
{typePlans.length === 0 ? (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-12 text-center text-[13px] text-[var(--ink-muted)]">
{TYPE_LABEL[planType]}
</div>
) : (
<div className="space-y-3">
{typePlans.map((plan) => {
const now = new Date().toISOString();
// 自动开始:如果到了计划开始日期且状态还是 pending视为已开始
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)
: effectiveStatus === 'in_progress' && effectiveStartAt
? calcPlanDuration(effectiveStartAt, now)
: { days: 0, hours: 0 };
const durText = dur.days > 0 || dur.hours > 0 ? formatDuration(dur.days, dur.hours) : '-';
// 如果自动开始了,触发 store 更新(副作用)
if (autoStarted && !plan.actualStartAt) {
onUpdate(plan.id, { status: 'in_progress' });
}
return (
<div key={plan.id} className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1.5">
<span className="text-[13px] font-medium text-[var(--ink)]">{plan.title}</span>
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-medium border ${STATUS_STYLE[effectiveStatus]}`}>
{STATUS_LABEL[effectiveStatus]}
</span>
{plan.type === 'product' && (
<span className="inline-flex items-center rounded-md border border-[var(--line)] bg-[var(--bg-subtle)] px-2 py-0.5 text-[10px] font-medium text-[var(--ink-muted)]">
{PRODUCT_PLAN_KIND_LABEL[getProductPlanKind(plan)]}
</span>
)}
{plan.type === 'product' && plan.reviewResult && (
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-medium ${plan.reviewResult === 'passed' ? 'bg-emerald-50 text-emerald-700' : 'bg-red-50 text-red-700'}`}>
{PRODUCT_PLAN_REVIEW_RESULT_LABEL[plan.reviewResult]}
</span>
)}
</div>
<div className="flex items-center gap-4 text-[12px] text-[var(--ink-soft)]">
<span>{formatDateTime(plan.startTime)} {formatDateTime(plan.endTime)}</span>
{plan.actualStartAt && <span className="text-blue-600">{formatDateTime(plan.actualStartAt)}</span>}
{plan.completedAt && <span className="text-green-600">{formatDateTime(plan.completedAt)}</span>}
<span className="font-medium text-[var(--ink)]"> {durText}</span>
</div>
{plan.overdueReason && (
<div className="mt-1.5 text-[11px] text-red-600 bg-red-50 rounded px-2 py-1 inline-block">{plan.overdueReason}</div>
)}
{(plan.type === 'product' || plan.type === 'ui') && (
<div className="mt-1.5 text-[12px] text-[var(--ink-soft)]"><span className="font-medium text-[var(--ink)]">{plan.owner}</span></div>
)}
{plan.status === 'completed' && plan.resultUrl && (
<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.resultTitle || plan.resultFileName || '查看成果'}<ExternalLink className="h-3 w-3" />
</a>
{planType === 'product' && version && (
<AiDecomposeButton plan={plan} version={version} />
)}
</div>
)}
{plan.status === 'completed' && plan.type === 'product' && getProductPlanKind(plan) === 'review' && plan.reviewResult && (
<div className={`mt-2 rounded-lg border px-3 py-2 ${plan.reviewResult === 'passed' ? 'border-emerald-200 bg-emerald-50' : 'border-red-200 bg-red-50'}`}>
<div className={`text-[12px] font-medium ${plan.reviewResult === 'passed' ? 'text-emerald-700' : 'text-red-700'}`}>
{PRODUCT_PLAN_REVIEW_RESULT_LABEL[plan.reviewResult]}
</div>
{plan.reviewResult === 'failed' && (
<div className="mt-1 space-y-1 text-[11px] text-red-700">
{getFailureLabels(plan.reviewFailureTypes).length > 0 && <div>{getFailureLabels(plan.reviewFailureTypes).join('、')}</div>}
{plan.reviewFailureReason && <div>{plan.reviewFailureReason}</div>}
</div>
)}
</div>
)}
{/* 子任务 */}
{plan.type === 'research' && plan.tasks && plan.tasks.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">
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${calcPlanProgress(plan.tasks)}%` }} />
</div>
<span className="text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{calcPlanProgress(plan.tasks)}%</span>
</div>
<div className="space-y-1">
{plan.tasks.map((task) => (
<div key={task.id} className="flex items-center gap-2">
<button
disabled={!canToggle}
onClick={() => {
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 ${!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>
<span className={`text-[12px] ${task.status === 'completed' ? 'line-through text-[var(--ink-muted)]' : 'text-[var(--ink)]'}`}>{task.title}</span>
<span className={`text-[10px] ${task.status === 'completed' ? 'text-green-600' : 'text-[var(--ink-muted)]'}`}>
{task.status === 'completed' ? '已完成' : '未完成'}
</span>
</div>
))}
</div>
</div>
)}
{/* 关联需求 */}
{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">
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${calcLinkedReqProgress(plan.linkedRequirementIds, plan.completedRequirementIds)}%` }} />
</div>
<span className="text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{calcLinkedReqProgress(plan.linkedRequirementIds, plan.completedRequirementIds)}%</span>
</div>
<div className="space-y-1">
{plan.linkedRequirementIds.map((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={!canEditCoverage}
onClick={() => {
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 ${!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>
<span className={`text-[11px] font-mono text-[var(--ink-muted)]`}>{req.code}</span>
<span className={`text-[12px] ${isDone ? 'line-through text-[var(--ink-muted)]' : 'text-[var(--ink)]'}`}>{req.title}</span>
</div>
) : null;
})}
</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 && (
<button onClick={() => onUpdate(plan.id, { status: 'in_progress' })} className="h-7 px-2 flex items-center gap-1 rounded-md text-[11px] font-medium text-blue-600 hover:bg-blue-50 border border-blue-200" title="提前开始">
<Play className="h-3 w-3" />
</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="转交">
<ArrowRightLeft className="h-3.5 w-3.5" />
</button>
<button onClick={() => setEditingPlan(plan)} className="h-7 w-7 flex items-center justify-center rounded-md text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)]" title="编辑">
<Pencil className="h-3.5 w-3.5" />
</button>
<button onClick={() => onDelete(plan.id)} className="h-7 w-7 flex items-center justify-center rounded-md text-red-500 hover:bg-red-50" title="删除">
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
</div>
{transferPlanId === plan.id && (
<div className="mt-3 pt-3 border-t border-[var(--line)] flex items-center gap-2">
<span className="text-[11px] text-[var(--ink-muted)]"></span>
<select value={transferTo} onChange={(e) => setTransferTo(e.target.value)} className="h-7 flex-1 rounded-lg border border-[var(--line)] px-2 text-[12px] focus:border-[var(--accent)] focus:outline-none">
<option value=""></option>
{versionMembers.filter((m) => m.name !== plan.owner).map((m) => <option key={`${m.role}-${m.name}`} value={m.name}>{m.name}</option>)}
</select>
<button onClick={() => { if (transferTo) { onUpdate(plan.id, { owner: transferTo }); setTransferPlanId(null); setTransferTo(''); } }} disabled={!transferTo} className="h-7 px-2.5 rounded-lg text-[11px] font-medium bg-blue-500 text-white disabled:opacity-50"></button>
<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">{getSubmitActionLabel(plan)}</span>
<button onClick={() => setCompletingPlan(plan)} className="text-[11px] font-medium text-green-700 hover:text-green-900 underline">{getSubmitActionLabel(plan)}</button>
</div>
)}
</div>
);
})}
</div>
)}
{(showCreateModal || editingPlan) && (
<PlanFormModal
initial={editingPlan}
planType={planType}
versionId={versionId}
versionDeadline={versionDeadline}
currentUserName={currentUserName}
linkedRequirements={linkedRequirements}
allRequirements={allRequirements}
onClose={() => { setShowCreateModal(false); setEditingPlan(null); }}
onSubmit={(data) => {
if (editingPlan) onUpdate(editingPlan.id, data);
else onCreate(data as any);
setShowCreateModal(false);
setEditingPlan(null);
}}
/>
)}
{completingPlan && (
<CompleteModal
plan={completingPlan}
onClose={() => 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, allRequirements, onClose, onSubmit }: {
initial: VersionPlan | null;
planType: 'research' | 'product' | 'ui';
versionId: string;
versionDeadline?: string;
currentUserName: string;
linkedRequirements?: Requirement[];
allRequirements?: Requirement[];
onClose: () => void;
onSubmit: (data: any) => void;
}) {
const now = new Date().toISOString().slice(0, 16);
const [title, setTitle] = useState(initial?.title ?? '');
const [owner] = useState(initial?.owner ?? currentUserName);
const [startTime, setStartTime] = useState(initial?.startTime?.slice(0, 16) ?? now);
const [endTime, setEndTime] = useState(initial?.endTime?.slice(0, 16) ?? '');
const [remark, setRemark] = useState(initial?.remark ?? '');
const [productPlanKind, setProductPlanKind] = useState<ProductPlanKind>(initial?.productPlanKind ?? 'design');
const [tasks, setTasks] = useState<PlanTask[]>(initial?.tasks ?? []);
const [newTaskTitle, setNewTaskTitle] = useState('');
const [overdueReason, setOverdueReason] = useState(initial?.overdueReason ?? '');
const [selectedReqs, setSelectedReqs] = useState<Set<string>>(new Set(initial?.linkedRequirementIds ?? []));
const [endTimeError, setEndTimeError] = useState('');
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) => {
e.preventDefault();
if (!title.trim() || !endTime) return;
if (startTime && endTime && new Date(endTime).getTime() <= new Date(startTime).getTime()) {
setEndTimeError('结束日期必须晚于开始日期');
return;
}
setEndTimeError('');
if (isOverdue && !overdueReason.trim()) return;
if (planType === 'research' && tasks.length === 0) return;
onSubmit({
versionId,
type: planType,
title: title.trim(),
owner: owner.trim() || currentUserName,
startTime,
endTime,
status: initial?.status ?? 'pending',
productPlanKind: planType === 'product' ? productPlanKind : 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,
});
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="w-full max-w-md rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] p-5 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-[13px] font-semibold text-[var(--ink)]">{initial ? '编辑' : '新建'}{TYPE_LABEL[planType]}</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>
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"></label>
<input value={title} onChange={(e) => setTitle(e.target.value)} required 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>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"></label>
<input value={owner} readOnly className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] px-3 text-[13px] text-[var(--ink-muted)] cursor-not-allowed" />
</div>
{planType === 'product' && (
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block"></label>
<div className="grid grid-cols-2 gap-2">
{(['design', 'review'] as ProductPlanKind[]).map((kind) => (
<label
key={kind}
className={`flex min-h-16 cursor-pointer items-start gap-2 rounded-lg border px-3 py-2 transition-colors ${productPlanKind === kind ? 'border-[var(--accent)] bg-[var(--accent-soft)]' : 'border-[var(--line)] bg-[var(--bg-card)] hover:bg-[var(--bg-subtle)]'}`}
>
<input
type="radio"
name="productPlanKind"
value={kind}
checked={productPlanKind === kind}
onChange={() => setProductPlanKind(kind)}
className="mt-0.5 h-3.5 w-3.5"
/>
<span className="min-w-0">
<span className="block text-[12px] font-medium text-[var(--ink)]">{PRODUCT_PLAN_KIND_LABEL[kind]}</span>
<span className="mt-0.5 block text-[11px] leading-4 text-[var(--ink-muted)]">
{kind === 'design' ? '提交最终原型链接,用于后续 AI 拆解。' : '记录方案是否通过评审,不通过时沉淀原因类型。'}
</span>
</span>
</label>
))}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"></label>
<input type="datetime-local" value={startTime} onChange={(e) => setStartTime(e.target.value)} 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>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"></label>
<input type="datetime-local" value={endTime} onChange={(e) => { setEndTime(e.target.value); setEndTimeError(''); }} required 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" />
<FieldError message={endTimeError} />
</div>
</div>
{versionDeadline && (
<div className="text-[11px] text-[var(--ink-muted)] -mt-1"><span className="text-red-500 font-medium">{versionDeadline}</span></div>
)}
{isOverdue && (
<div className="rounded-lg bg-red-50 border border-red-200 p-3 space-y-2">
<div className="text-[12px] text-red-700 font-medium"> </div>
<textarea
value={overdueReason}
onChange={(e) => setOverdueReason(e.target.value)}
rows={2}
placeholder="例如:技术难点超预期、上游交付延误..."
required
className="w-full rounded-lg border border-red-200 bg-white px-3 py-2 text-[13px] focus:border-red-400 focus:outline-none resize-none"
/>
</div>
)}
{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">
{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>
)}
<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="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>
<textarea value={remark} onChange={(e) => setRemark(e.target.value)} rows={2} className="w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 py-2 text-[13px] focus:border-[var(--accent)] focus:outline-none resize-none" />
</div>
<div className="flex justify-end gap-2 pt-2">
<button type="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 type="submit" className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]">{initial ? '保存' : '创建'}</button>
</div>
</form>
</div>
</div>
);
}
function CompleteModal({ plan, onClose, onSubmit }: {
plan: VersionPlan;
onClose: () => void;
onSubmit: (result: PlanResultPayload) => void;
}) {
const [resultType, setResultType] = useState<'link' | 'file'>('link');
const [resultTitle, setResultTitle] = useState('');
const [url, setUrl] = useState('');
const [fileName, setFileName] = useState('');
const [fileData, setFileData] = useState('');
const [prototypeReviewConfirmed, setPrototypeReviewConfirmed] = useState(false);
const [reviewResult, setReviewResult] = useState<ProductPlanReviewResult>('passed');
const [reviewFailureTypes, setReviewFailureTypes] = useState<Set<ProductPlanReviewFailureType>>(new Set());
const [reviewFailureReason, setReviewFailureReason] = useState('');
const productPlanKind = plan.type === 'product' ? getProductPlanKind(plan) : undefined;
const isProductDesignPlan = productPlanKind === 'design';
const isProductReviewPlan = productPlanKind === 'review';
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setFileName(file.name);
const reader = new FileReader();
reader.onload = () => setFileData(reader.result as string);
reader.readAsDataURL(file);
};
const canSubmit = isProductReviewPlan
? reviewResult === 'passed' || (reviewFailureTypes.size > 0 && reviewFailureReason.trim().length > 0)
: isProductDesignPlan
? resultTitle.trim().length > 0 && url.trim().length > 0 && prototypeReviewConfirmed
: resultTitle.trim().length > 0 && (resultType === 'link' ? url.trim().length > 0 : fileData.length > 0);
const toggleFailureType = (type: ProductPlanReviewFailureType) => {
const next = new Set(reviewFailureTypes);
if (next.has(type)) next.delete(type);
else next.add(type);
setReviewFailureTypes(next);
};
const handleSubmit = () => {
if (isProductReviewPlan) {
onSubmit({
productPlanKind: 'review',
reviewResult,
reviewFailureTypes: reviewResult === 'failed' ? Array.from(reviewFailureTypes) : undefined,
reviewFailureReason: reviewResult === 'failed' ? reviewFailureReason.trim() : undefined,
resultTitle: PRODUCT_PLAN_REVIEW_RESULT_LABEL[reviewResult],
});
return;
}
if (isProductDesignPlan) {
onSubmit({
productPlanKind: 'design',
resultType: 'link',
resultTitle: resultTitle.trim(),
resultUrl: url.trim(),
prototypeReviewConfirmed,
});
return;
}
onSubmit({
resultType,
resultTitle: resultTitle.trim(),
resultUrl: resultType === 'link' ? url.trim() : fileData,
resultFileName: fileName || undefined,
resultFileData: resultType === 'file' ? fileData : undefined,
});
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="w-full max-w-sm rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] p-5 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-[13px] font-semibold text-[var(--ink)]">{getSubmitActionLabel(plan)}</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="space-y-3">
{isProductReviewPlan ? (
<>
<div>
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1"><span className="text-red-500 ml-0.5">*</span></label>
<div className="grid grid-cols-2 gap-2">
{(['passed', 'failed'] as ProductPlanReviewResult[]).map((result) => (
<button
key={result}
type="button"
onClick={() => setReviewResult(result)}
className={`h-9 rounded-lg border text-[12px] font-medium transition-colors ${reviewResult === result ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
>
{PRODUCT_PLAN_REVIEW_RESULT_LABEL[result]}
</button>
))}
</div>
</div>
{reviewResult === 'failed' && (
<div className="space-y-2">
<div>
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1"><span className="text-red-500 ml-0.5">*</span></label>
<div className="grid grid-cols-2 gap-1.5">
{PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS.map((option) => (
<label key={option.value} className="flex min-h-8 cursor-pointer items-center gap-1.5 rounded-lg border border-[var(--line)] px-2 py-1 text-[11px] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">
<input type="checkbox" checked={reviewFailureTypes.has(option.value)} onChange={() => toggleFailureType(option.value)} className="h-3 w-3 shrink-0 rounded" />
<span className="leading-4">{option.label}</span>
</label>
))}
</div>
</div>
<div>
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1"><span className="text-red-500 ml-0.5">*</span></label>
<textarea
value={reviewFailureReason}
onChange={(e) => setReviewFailureReason(e.target.value)}
rows={3}
placeholder="写清楚具体问题、影响范围和建议调整方向"
className="w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 py-2 text-[13px] focus:border-[var(--accent)] focus:outline-none resize-none"
/>
</div>
</div>
)}
</>
) : (
<>
{isProductDesignPlan && (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] leading-5 text-amber-800">
Axure AI 访
</div>
)}
<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={isProductDesignPlan ? '如 v1.0 原型地址' : '如 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>
{!isProductDesignPlan && (
<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>
</div>
)}
{resultType === 'link' || isProductDesignPlan ? (
<input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://..." 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>
<input type="file" onChange={handleFile} className="text-[12px] text-[var(--ink-soft)]" />
{fileName && <p className="text-[11px] text-[var(--ink-muted)] mt-1">{fileName}</p>}
</div>
)}
{isProductDesignPlan && (
<label className="flex items-start gap-2 rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] px-3 py-2 text-[12px] text-[var(--ink-soft)]">
<input
type="checkbox"
checked={prototypeReviewConfirmed}
onChange={(e) => setPrototypeReviewConfirmed(e.target.checked)}
className="mt-0.5 h-3.5 w-3.5 rounded"
/>
<span></span>
</label>
)}
</>
)}
<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={handleSubmit} 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>
</div>
);
}