Files
ftb-project-management/apps/web/components/version/PlanTab.tsx
Script Generator eef3c8f000 feat(版本): 优化概览与只读状态
关键改动:

- 增加需求排序和版本只读状态规则及测试

- 完善版本概览阶段耗时、项目页和工作台展示

- 优化小宝预警请求节流、建议状态和风险过滤

Co-Authored-By: Codex GPT-5 <codex@openai.com>
2026-06-30 18:18:18 +08:00

1178 lines
64 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 { useEffect, 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,
sortPlansNewestFirst,
getPlanLogsForPlans,
getResearchDirectionPresetOptions,
getResearchDirectionProgressSummary,
getRequirementCoverageSummary,
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 { FilterSelect } from '@/components/FilterSelect';
import { WorkDateTimePicker } from '@/components/WorkDateTimePicker';
import { AiDecomposeButton } from './AiDecomposeButton';
import { PlanLinkedRequirementReferenceList, PlanLogTimeline, PlanRequirementCoveragePanel, PlanResearchDirectionPanel } from './PlanRequirementCoveragePanel';
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;
readOnly?: boolean;
}
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, readOnly = false }: 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 = sortPlansNewestFirst(plans.filter((p) => p.versionId === versionId && p.type === planType));
const totalDuration = calcTotalDuration(typePlans);
return (
<div className="-m-5 h-[calc(100vh-98px)] min-h-[520px]">
{false && planType === 'research' && (
<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>
)}
{false && typePlans.length === 0 && planType === 'research' ? (
<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>
) : false && planType === 'research' ? (
<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 shadow-[var(--shadow-sm)]">
<div className={plan.type === 'research' ? '' : 'grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]'}>
<div className="min-w-0">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 truncate text-[14px] font-semibold text-[var(--ink)]" title={plan.title}>{plan.title}</span>
<span className={`inline-flex shrink-0 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 shrink-0 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 shrink-0 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>
<dl className="mt-3 grid grid-cols-1 gap-x-5 gap-y-2 border-y border-[var(--line)] py-3 text-[12px] sm:grid-cols-2 lg:grid-cols-4">
<div>
<dt className="text-[11px] text-[var(--ink-muted)]"></dt>
<dd className="mt-0.5 font-medium text-[var(--ink)]">{plan.owner}</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-[11px] text-[var(--ink-muted)]"></dt>
<dd className="mt-0.5 font-medium text-[var(--ink)]">{formatDateTime(plan.startTime)} {formatDateTime(plan.endTime)}</dd>
</div>
<div>
<dt className="text-[11px] text-[var(--ink-muted)]"></dt>
<dd className="mt-0.5 font-semibold text-[var(--ink)]">{durText}</dd>
</div>
{plan.actualStartAt && (
<div>
<dt className="text-[11px] text-[var(--ink-muted)]"></dt>
<dd className="mt-0.5 font-medium text-blue-600">{formatDateTime(plan.actualStartAt)}</dd>
</div>
)}
{plan.completedAt && (
<div>
<dt className="text-[11px] text-[var(--ink-muted)]"></dt>
<dd className="mt-0.5 font-medium text-green-600">{formatDateTime(plan.completedAt)}</dd>
</div>
)}
</dl>
{plan.overdueReason && (
<div className="mt-3 rounded-lg border border-red-100 bg-red-50 px-3 py-2 text-[11px] leading-5 text-red-700">{plan.overdueReason}</div>
)}
{plan.remark && (
<div className="mt-3 border-l-2 border-[var(--accent)] pl-3">
<div className="text-[11px] font-medium text-[var(--ink-muted)]"></div>
<div className="mt-1 max-h-20 overflow-y-auto whitespace-pre-wrap pr-1 text-[12px] leading-5 text-[var(--ink-soft)]">{plan.remark}</div>
</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>
</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 && (
<PlanRequirementCoveragePanel
plan={plan}
requirements={plan.linkedRequirementIds.map((rid) => requirementOptions.find((r) => r.id === rid)).filter(Boolean) as Requirement[]}
canEdit={canEditCoverage}
currentUserName={currentUserName}
onUpdate={onUpdate}
/>
)}
{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>
<FilterSelect
value={transferTo || 'all'}
onChange={(value) => setTransferTo(value === 'all' ? '' : value)}
options={versionMembers.filter((m) => m.name !== plan.owner).map((m) => ({ value: m.name, label: m.name }))}
allLabel="选择参与人员"
className="flex-1"
/>
<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>
{plan.type !== 'research' && (
<PlanLogTimeline
logs={plan.logs}
className="border-t border-[var(--line)] pt-4 xl:border-l xl:border-t-0 xl:pt-0 xl:pl-4"
/>
)}
</div>
</div>
);
})}
</div>
) : (
<ProductUiPlanWorkspace
typePlans={typePlans}
planType={planType}
totalDuration={totalDuration}
versionDeadline={versionDeadline}
version={version}
currentUserName={currentUserName}
versionMembers={versionMembers}
linkedRequirements={linkedRequirements}
allRequirements={allRequirements}
transferPlanId={transferPlanId}
transferTo={transferTo}
onSetTransferPlanId={setTransferPlanId}
onTransferToChange={setTransferTo}
onUpdate={onUpdate}
onDelete={onDelete}
onEditPlan={setEditingPlan}
onOpenComplete={setCompletingPlan}
onCreatePlan={() => setShowCreateModal(true)}
readOnly={readOnly}
/>
)}
{(showCreateModal || editingPlan) && !readOnly && (
<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 && !readOnly && (
<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 getPlanRuntime(plan: VersionPlan): {
autoStarted: boolean;
effectiveStatus: VersionPlan['status'];
effectiveStartAt: string | null;
} {
const autoStarted = plan.status === 'pending' && Boolean(plan.startTime) && new Date(plan.startTime) <= new Date();
return {
autoStarted,
effectiveStatus: autoStarted ? 'in_progress' : plan.status,
effectiveStartAt: plan.actualStartAt || (autoStarted ? plan.startTime : null),
};
}
function getPlanDurationText(plan: VersionPlan, effectiveStatus: VersionPlan['status'], effectiveStartAt: string | null, now: string): string {
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 };
return dur.days > 0 || dur.hours > 0 ? formatDuration(dur.days, dur.hours) : '-';
}
function ProductUiPlanWorkspace({
typePlans,
planType,
totalDuration,
versionDeadline,
version,
currentUserName,
versionMembers,
linkedRequirements,
allRequirements,
transferPlanId,
transferTo,
onSetTransferPlanId,
onTransferToChange,
onUpdate,
onDelete,
onEditPlan,
onOpenComplete,
onCreatePlan,
readOnly,
}: {
typePlans: VersionPlan[];
planType: VersionPlan['type'];
totalDuration: string;
versionDeadline?: string;
version?: VersionWithContext;
currentUserName: string;
versionMembers: { role: string; name: string }[];
linkedRequirements?: Requirement[];
allRequirements?: Requirement[];
transferPlanId: string | null;
transferTo: string;
onSetTransferPlanId: (id: string | null) => void;
onTransferToChange: (name: string) => void;
onUpdate: (id: string, data: Partial<VersionPlan>) => void;
onDelete: (id: string) => void;
onEditPlan: (plan: VersionPlan) => void;
onOpenComplete: (plan: VersionPlan) => void;
onCreatePlan: () => void;
readOnly: boolean;
}) {
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(typePlans[0]?.id ?? null);
const selectedPlan = typePlans.find((plan) => plan.id === selectedPlanId) ?? typePlans[0];
const selectedPlanIdOrFirst = selectedPlan?.id;
const allLogs = useMemo(() => getPlanLogsForPlans(typePlans), [typePlans]);
useEffect(() => {
if (readOnly) return;
typePlans.forEach((plan) => {
const { autoStarted } = getPlanRuntime(plan);
if (autoStarted && !plan.actualStartAt) {
onUpdate(plan.id, { status: 'in_progress' });
}
});
}, [typePlans, onUpdate, readOnly]);
return (
<div className="flex h-full min-h-0">
<aside className="flex h-full w-72 shrink-0 flex-col border-r border-[var(--line)] bg-[var(--bg-card)]">
<div className="flex h-14 shrink-0 items-center justify-between gap-2 border-b border-[var(--line)] px-4">
<div className="text-[14px] font-semibold text-[var(--ink)]"></div>
{!readOnly && (
<button onClick={onCreatePlan} className="flex h-7 items-center gap-1.5 rounded-md bg-[var(--accent)] px-2.5 text-[11px] 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>
<div className="shrink-0 space-y-2 border-b border-[var(--line)] p-3">
<div className={`grid gap-2 ${versionDeadline ? 'grid-cols-2' : 'grid-cols-1'}`}>
<div className="rounded-lg bg-[var(--bg-subtle)] px-3 py-2">
<div className="text-[10px] text-[var(--ink-muted)]"></div>
<div className="mt-0.5 truncate text-[13px] font-semibold text-[var(--ink)]">{totalDuration}</div>
</div>
{versionDeadline && (
<div className="rounded-lg bg-red-50 px-3 py-2">
<div className="text-[10px] text-red-500"></div>
<div className="mt-0.5 truncate text-[13px] font-semibold text-red-600">{versionDeadline}</div>
</div>
)}
</div>
</div>
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
{typePlans.map((plan) => {
const { effectiveStatus } = getPlanRuntime(plan);
const summary = plan.type === 'research'
? getResearchDirectionProgressSummary(plan)
: getRequirementCoverageSummary(plan);
const isSelected = plan.id === selectedPlanIdOrFirst;
return (
<button
key={plan.id}
type="button"
onClick={() => setSelectedPlanId(plan.id)}
className={`w-full rounded-lg px-3 py-2 text-left transition-colors ${isSelected ? 'bg-[var(--accent-soft)] text-[var(--accent)]' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
>
<div className="flex min-w-0 items-start justify-between gap-2">
<span className="min-w-0 truncate text-[12px] font-semibold" title={plan.title}>{plan.title}</span>
<span className={`shrink-0 rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${STATUS_STYLE[effectiveStatus]}`}>
{STATUS_LABEL[effectiveStatus]}
</span>
</div>
<div className="mt-2 flex items-center justify-between gap-2 text-[11px]">
<span className="truncate">{plan.owner}</span>
{plan.type === 'product' ? (
<span className="shrink-0">{PRODUCT_PLAN_KIND_LABEL[getProductPlanKind(plan)]}</span>
) : (
<span className="shrink-0">{TYPE_LABEL[plan.type]}</span>
)}
</div>
{plan.type === 'product' && plan.reviewResult && (
<div className={`mt-2 inline-flex rounded-md px-1.5 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]}
</div>
)}
<div className="mt-2 flex items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--bg-subtle)]">
<div className="h-full rounded-full bg-[var(--accent)]" style={{ width: `${summary.percent}%` }} />
</div>
<span className="shrink-0 text-[10px] tabular-nums text-[var(--ink-muted)]">
{summary.total > 0 ? `${summary.completed}/${summary.total}` : '0/0'}
</span>
</div>
</button>
);
})}
{typePlans.length === 0 && (
<div className="rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg-subtle)] px-3 py-8 text-center text-[12px] text-[var(--ink-muted)]">
{TYPE_LABEL[planType]}
</div>
)}
</div>
</aside>
<section className="flex h-full min-w-0 flex-1 flex-col bg-[var(--bg)]">
{selectedPlan ? (
<ProductUiPlanDetail
plan={selectedPlan}
planType={planType}
version={version}
currentUserName={currentUserName}
versionMembers={versionMembers}
linkedRequirements={linkedRequirements}
allRequirements={allRequirements}
transferPlanId={transferPlanId}
transferTo={transferTo}
onSetTransferPlanId={onSetTransferPlanId}
onTransferToChange={onTransferToChange}
onUpdate={onUpdate}
onDelete={onDelete}
onEditPlan={onEditPlan}
onOpenComplete={onOpenComplete}
readOnly={readOnly}
/>
) : (
<div className="flex h-full items-center justify-center text-[13px] text-[var(--ink-muted)]">
{TYPE_LABEL[planType]}
</div>
)}
</section>
<PlanLogTimeline
logs={allLogs}
fillHeight
className="flex h-full w-80 shrink-0 flex-col border-l border-[var(--line)] bg-[var(--bg-card)] p-4"
/>
</div>
);
}
function ProductUiPlanDetail({
plan,
planType,
version,
currentUserName,
versionMembers,
linkedRequirements,
allRequirements,
transferPlanId,
transferTo,
onSetTransferPlanId,
onTransferToChange,
onUpdate,
onDelete,
onEditPlan,
onOpenComplete,
readOnly,
}: {
plan: VersionPlan;
planType: VersionPlan['type'];
version?: VersionWithContext;
currentUserName: string;
versionMembers: { role: string; name: string }[];
linkedRequirements?: Requirement[];
allRequirements?: Requirement[];
transferPlanId: string | null;
transferTo: string;
onSetTransferPlanId: (id: string | null) => void;
onTransferToChange: (name: string) => void;
onUpdate: (id: string, data: Partial<VersionPlan>) => void;
onDelete: (id: string) => void;
onEditPlan: (plan: VersionPlan) => void;
onOpenComplete: (plan: VersionPlan) => void;
readOnly: boolean;
}) {
const now = new Date().toISOString();
const { autoStarted, effectiveStatus, effectiveStartAt } = getPlanRuntime(plan);
const durText = getPlanDurationText(plan, effectiveStatus, effectiveStartAt, now);
const completionState = getPlanCompletionState(plan);
const canEditCoverage = !readOnly && canEditPlanRequirementCoverage(plan);
const requirementOptions = mergeSelectedRequirementOptions(linkedRequirements ?? [], allRequirements ?? [], plan.linkedRequirementIds ?? []);
const selectedRequirements = (plan.linkedRequirementIds ?? [])
.map((rid) => requirementOptions.find((requirement) => requirement.id === rid))
.filter(Boolean) as Requirement[];
return (
<div className="h-full overflow-y-auto p-5">
<div className="flex items-start justify-between gap-4 border-b border-[var(--line)] pb-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="min-w-0 truncate text-[15px] font-semibold text-[var(--ink)]" title={plan.title}>{plan.title}</h3>
<span className={`inline-flex shrink-0 items-center rounded-md border px-2 py-0.5 text-[10px] font-medium ${STATUS_STYLE[effectiveStatus]}`}>
{STATUS_LABEL[effectiveStatus]}
</span>
{plan.type === 'product' && (
<span className="inline-flex shrink-0 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 shrink-0 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="mt-1 text-[11px] text-[var(--ink-muted)]">{TYPE_LABEL[plan.type]}</div>
</div>
{!readOnly && (
<div className="flex shrink-0 items-center gap-1">
{plan.status === 'pending' && !autoStarted && (
<button onClick={() => onUpdate(plan.id, { status: 'in_progress' })} className="flex h-7 items-center gap-1 rounded-md border border-blue-200 px-2 text-[11px] font-medium text-blue-600 hover:bg-blue-50" title="提前开始">
<Play className="h-3 w-3" />
</button>
)}
{plan.status !== 'completed' && (
<>
<button onClick={() => onSetTransferPlanId(plan.id)} className="flex h-7 w-7 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={() => onEditPlan(plan)} className="flex h-7 w-7 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="flex h-7 w-7 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>
<dl className="mt-4 grid grid-cols-1 gap-3 text-[12px] sm:grid-cols-2 xl:grid-cols-4">
<div className="rounded-lg bg-[var(--bg-subtle)] px-3 py-2">
<dt className="text-[11px] text-[var(--ink-muted)]"></dt>
<dd className="mt-0.5 font-medium text-[var(--ink)]">{plan.owner}</dd>
</div>
<div className="rounded-lg bg-[var(--bg-subtle)] px-3 py-2 sm:col-span-2">
<dt className="text-[11px] text-[var(--ink-muted)]"></dt>
<dd className="mt-0.5 font-medium text-[var(--ink)]">{formatDateTime(plan.startTime)} {formatDateTime(plan.endTime)}</dd>
</div>
<div className="rounded-lg bg-[var(--bg-subtle)] px-3 py-2">
<dt className="text-[11px] text-[var(--ink-muted)]"></dt>
<dd className="mt-0.5 font-semibold text-[var(--ink)]">{durText}</dd>
</div>
{plan.actualStartAt && (
<div className="rounded-lg bg-blue-50 px-3 py-2">
<dt className="text-[11px] text-blue-500"></dt>
<dd className="mt-0.5 font-medium text-blue-700">{formatDateTime(plan.actualStartAt)}</dd>
</div>
)}
{plan.completedAt && (
<div className="rounded-lg bg-green-50 px-3 py-2">
<dt className="text-[11px] text-green-600"></dt>
<dd className="mt-0.5 font-medium text-green-700">{formatDateTime(plan.completedAt)}</dd>
</div>
)}
</dl>
{plan.overdueReason && (
<div className="mt-3 rounded-lg border border-red-100 bg-red-50 px-3 py-2 text-[11px] leading-5 text-red-700">{plan.overdueReason}</div>
)}
{plan.remark && (
<div className="mt-3 border-l-2 border-[var(--accent)] pl-3">
<div className="text-[11px] font-medium text-[var(--ink-muted)]"></div>
<div className="mt-1 max-h-24 overflow-y-auto whitespace-pre-wrap pr-1 text-[12px] leading-5 text-[var(--ink-soft)]">{plan.remark}</div>
</div>
)}
{plan.status === 'completed' && plan.resultUrl && (
<div className="mt-3 flex flex-wrap items-center gap-2 rounded-lg border border-[var(--line)] px-3 py-2">
{plan.resultType === 'link' ? <Link2 className="h-3.5 w-3.5 text-[var(--accent)]" /> : <FileUp className="h-3.5 w-3.5 text-[var(--accent)]" />}
<a href={plan.resultUrl} target="_blank" rel="noopener noreferrer" className="flex min-w-0 items-center gap-1 text-[12px] text-[var(--accent)] hover:underline">
<span className="truncate">{plan.resultTitle || plan.resultFileName || '查看成果'}</span><ExternalLink className="h-3 w-3 shrink-0" />
</a>
{planType === 'product' && version && !readOnly && (
<AiDecomposeButton plan={plan} version={version} />
)}
</div>
)}
{plan.status === 'completed' && plan.type === 'product' && getProductPlanKind(plan) === 'review' && plan.reviewResult && (
<div className={`mt-3 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' && (
<PlanResearchDirectionPanel
plan={plan}
canEdit={canEditCoverage}
currentUserName={currentUserName}
onUpdate={onUpdate}
/>
)}
{selectedRequirements.length > 0 && (
plan.type === 'research' ? (
<PlanLinkedRequirementReferenceList requirements={selectedRequirements} />
) : (
<PlanRequirementCoveragePanel
plan={plan}
requirements={selectedRequirements}
canEdit={canEditCoverage}
currentUserName={currentUserName}
onUpdate={onUpdate}
/>
)
)}
{plan.status === 'in_progress' && !completionState.canSubmitResult && (
<p className="mt-3 text-[11px] text-[var(--ink-muted)]">{completionState.missingReasons.join('、')}</p>
)}
{transferPlanId === plan.id && !readOnly && (
<div className="mt-3 flex items-center gap-2 border-t border-[var(--line)] pt-3">
<span className="text-[11px] text-[var(--ink-muted)]"></span>
<FilterSelect
value={transferTo || 'all'}
onChange={(value) => onTransferToChange(value === 'all' ? '' : value)}
options={versionMembers.filter((member) => member.name !== plan.owner).map((member) => ({ value: member.name, label: member.name }))}
allLabel="选择参与人员"
className="flex-1"
/>
<button onClick={() => { if (transferTo) { onUpdate(plan.id, { owner: transferTo }); onSetTransferPlanId(null); onTransferToChange(''); } }} disabled={!transferTo} className="h-7 rounded-lg bg-blue-500 px-2.5 text-[11px] font-medium text-white disabled:opacity-50"></button>
<button onClick={() => { onSetTransferPlanId(null); onTransferToChange(''); }} className="h-7 px-2 text-[11px] text-[var(--ink-muted)]"></button>
</div>
)}
{plan.status !== 'completed' && completionState.canSubmitResult && !readOnly && (
<div className="mt-3 flex items-center justify-between rounded-lg border border-green-200 bg-green-50 px-3 py-2">
<span className="text-[12px] text-green-700">{getSubmitActionLabel(plan)}</span>
<button onClick={() => onOpenComplete(plan)} className="text-[11px] font-medium text-green-700 underline hover:text-green-900">{getSubmitActionLabel(plan)}</button>
</div>
)}
</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 researchDirectionPresetOptions = useMemo(() => getResearchDirectionPresetOptions(tasks), [tasks]);
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>
<WorkDateTimePicker
value={startTime}
onChange={setStartTime}
placeholder="选择计划开始时间"
defaultHour={9}
/>
</div>
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"></label>
<WorkDateTimePicker
value={endTime}
onChange={(next) => { setEndTime(next); setEndTimeError(''); }}
placeholder="选择计划截止时间"
defaultHour={18}
popoverAlign="right"
/>
<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>
{/* 预设选项 */}
<div className="flex flex-wrap gap-1.5 mb-2">
{researchDirectionPresetOptions.map((option) => (
<button
key={option.title}
type="button"
disabled={option.disabled}
onClick={() => {
if (option.disabled) return;
setTasks([...tasks, { id: `task-${Date.now()}-${Math.random().toString(36).slice(2, 5)}`, title: option.title, status: 'pending' }]);
}}
className={`h-6 px-2.5 rounded-md text-[11px] border transition-colors ${option.disabled ? 'cursor-not-allowed border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)] opacity-70' : 'border-dashed border-[var(--line)] text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)]'}`}
>
+ {option.title}
</button>
))}
</div>
{false && 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>
);
}