Files
ftb-project-management/apps/web/components/version/PlanDetailDrawer.tsx
Script Generator 52a88626d4 feat(版本): 完善计划覆盖与工作台待办
关键改动:

- 增加计划日志汇总、需求覆盖草稿校验与对应测试

- 抽取工作台工作项 Hook,补充待办计数能力

- 优化版本详情中计划、任务、测试用例和 Bug 的筛选与展示

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

426 lines
22 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 { useState } from 'react';
import { X, Check, Link2, FileUp, ExternalLink, Play, ArrowRightLeft } from 'lucide-react';
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useMemberStore } from '@/stores/useMemberStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { FilterSelect } from '@/components/FilterSelect';
import {
calcPlanProgress,
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 type { PlanTask, ProductPlanKind, ProductPlanReviewFailureType, ProductPlanReviewResult, VersionPlan } from '@/lib/version-plan';
import { canEditPlanRequirementCoverage, canTogglePlanChecklist, getPlanCompletionState } from '@/lib/version-plan-workflow';
import type { PlanResultPayload } from '@/lib/version-plan-workflow';
import { PlanLogTimeline, PlanRequirementCoveragePanel } from './PlanRequirementCoveragePanel';
interface Props {
planId: string;
onClose: () => void;
contextLabel?: string;
}
const STATUS_STYLE: Record<string, string> = { pending: 'bg-zinc-100 text-zinc-600', in_progress: 'bg-blue-50 text-blue-600', completed: 'bg-emerald-50 text-emerald-600' };
const STATUS_LABEL: Record<string, string> = { pending: '未开始', in_progress: '进行中', completed: '已完成' };
const TYPE_LABEL: Record<string, string> = { research: '调研', product: '产品方案', ui: 'UI设计' };
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 PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
const { plans, updatePlan, completePlan } = useVersionPlanStore();
const { requirements } = useRequirementStore();
const { members } = useMemberStore();
const user = useAuthStore((s) => s.user);
const [showTransfer, setShowTransfer] = useState(false);
const [transferTo, setTransferTo] = useState('');
const [resultType, setResultType] = useState<'link' | 'file'>('link');
const [resultTitle, setResultTitle] = useState('');
const [resultUrl, setResultUrl] = useState('');
const [fileName, setFileName] = useState('');
const [fileData, setFileData] = useState('');
const [prototypeReviewConfirmed, setPrototypeReviewConfirmed] = useState(false);
const [reviewResult, setReviewResult] = useState<ProductPlanReviewResult>('passed');
const [reviewFailureTypes, setReviewFailureTypes] = useState<Set<ProductPlanReviewFailureType>>(new Set());
const [reviewFailureReason, setReviewFailureReason] = useState('');
const [showComplete, setShowComplete] = useState(false);
const plan = plans.find((p) => p.id === planId);
if (!plan) return null;
const completionState = getPlanCompletionState(plan);
const isResearch = plan.type === 'research';
const progress = isResearch ? calcPlanProgress(plan.tasks) : getRequirementCoverageSummary(plan).percent;
const linkedReqs = (plan.linkedRequirementIds || []).map((id) => requirements.find((r) => r.id === id)).filter(Boolean) as { id: string; code: string; title: string }[];
const canToggle = canTogglePlanChecklist(plan);
const canEditCoverage = canEditPlanRequirementCoverage(plan);
const currentUserName = user?.name ?? plan.owner;
const productPlanKind = plan.type === 'product' ? getProductPlanKind(plan) : undefined;
const isProductDesignPlan = productPlanKind === 'design';
const isProductReviewPlan = productPlanKind === 'review';
const handleToggleTask = (task: PlanTask) => {
if (!canToggle) return;
const nextStatus = task.status === 'completed' ? 'pending' : 'completed';
const updatedTasks = (plan.tasks || []).map((t) => t.id === task.id ? { ...t, status: nextStatus as PlanTask['status'] } : t);
updatePlan(plan.id, { tasks: updatedTasks });
};
const 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 toggleFailureType = (type: ProductPlanReviewFailureType) => {
const next = new Set(reviewFailureTypes);
if (next.has(type)) next.delete(type);
else next.add(type);
setReviewFailureTypes(next);
};
const handleSubmitResult = () => {
let payload: PlanResultPayload | null = null;
if (isProductReviewPlan) {
payload = {
productPlanKind: 'review',
reviewResult,
reviewFailureTypes: reviewResult === 'failed' ? Array.from(reviewFailureTypes) : undefined,
reviewFailureReason: reviewResult === 'failed' ? reviewFailureReason.trim() : undefined,
resultTitle: PRODUCT_PLAN_REVIEW_RESULT_LABEL[reviewResult],
};
} else if (isProductDesignPlan) {
const title = resultTitle.trim();
const url = resultUrl.trim();
if (!url || !title || !prototypeReviewConfirmed) return;
payload = {
productPlanKind: 'design',
resultType: 'link',
resultTitle: title,
resultUrl: url,
prototypeReviewConfirmed,
};
} else {
const url = resultType === 'link' ? resultUrl.trim() : fileData;
const title = resultTitle.trim();
if (!url || !title) return;
payload = {
resultType,
resultTitle: title,
resultUrl: url,
resultFileName: fileName || undefined,
resultFileData: resultType === 'file' ? fileData : undefined,
};
}
const response = completePlan(plan.id, payload);
if (response && typeof response === 'object' && 'ok' in response && !response.ok) {
alert(response.message || '计划未满足完成条件');
return;
}
setShowComplete(false);
};
const handleTransfer = () => {
if (!transferTo) return;
updatePlan(plan.id, { owner: transferTo });
setShowTransfer(false);
setTransferTo('');
};
return (
<div className="fixed inset-0 z-50 flex justify-end" onClick={onClose}>
<div className="w-full max-w-md h-full bg-[var(--bg)] border-l border-[var(--line)] shadow-2xl flex flex-col overflow-hidden" onClick={(e) => e.stopPropagation()}>
{/* Context */}
{contextLabel && (
<div className="px-5 py-2 border-b border-[var(--line)] bg-[var(--bg-subtle)] shrink-0">
<span className="text-[11px] text-[var(--ink-muted)]">{contextLabel}</span>
</div>
)}
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--line)] shrink-0">
<div className="flex items-center gap-2">
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-[var(--bg-subtle)] text-[var(--ink-muted)]">{TYPE_LABEL[plan.type]}</span>
{plan.type === 'product' && (
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-[var(--bg-subtle)] text-[var(--ink-muted)]">{PRODUCT_PLAN_KIND_LABEL[getProductPlanKind(plan)]}</span>
)}
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${STATUS_STYLE[plan.status]}`}>{STATUS_LABEL[plan.status]}</span>
{plan.type === 'product' && plan.reviewResult && (
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${plan.reviewResult === 'passed' ? 'bg-emerald-50 text-emerald-600' : 'bg-red-50 text-red-600'}`}>{PRODUCT_PLAN_REVIEW_RESULT_LABEL[plan.reviewResult]}</span>
)}
</div>
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-5 space-y-4">
<h3 className="text-[15px] font-semibold text-[var(--ink)]">{plan.title}</h3>
{/* Info */}
<div className="grid grid-cols-2 gap-3 text-[12px]">
<div>
<span className="text-[var(--ink-muted)]"></span>
<div className="font-medium text-[var(--ink)] mt-0.5">{plan.owner}</div>
</div>
<div>
<span className="text-[var(--ink-muted)]"></span>
<div className="font-medium text-[var(--ink)] mt-0.5">{progress}%</div>
</div>
<div>
<span className="text-[var(--ink-muted)]"></span>
<div className="font-medium text-[var(--ink)] mt-0.5">{formatDateTime(plan.startTime)} {formatDateTime(plan.endTime)}</div>
</div>
{plan.actualStartAt && (
<div>
<span className="text-[var(--ink-muted)]"></span>
<div className="font-medium text-blue-600 mt-0.5">{formatDateTime(plan.actualStartAt)}</div>
</div>
)}
{plan.completedAt && (
<div>
<span className="text-[var(--ink-muted)]"></span>
<div className="font-medium text-emerald-600 mt-0.5">{formatDateTime(plan.completedAt)}</div>
</div>
)}
</div>
{/* Progress Bar */}
<div className="h-2 rounded-full bg-[var(--bg-subtle)] overflow-hidden">
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${progress}%` }} />
</div>
{/* Research Tasks */}
{isResearch && plan.tasks && plan.tasks.length > 0 && (
<div className="space-y-1.5">
<div className="text-[11px] font-medium text-[var(--ink-muted)]"></div>
{plan.tasks.map((task) => (
<div key={task.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-[var(--bg-subtle)]">
<button
disabled={!canToggle}
onClick={() => handleToggleTask(task)}
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={`flex-1 text-[12px] ${task.status === 'completed' ? 'line-through text-[var(--ink-muted)]' : 'text-[var(--ink)]'}`}>{task.title}</span>
</div>
))}
</div>
)}
{linkedReqs.length > 0 && (
<div>
<PlanRequirementCoveragePanel
plan={plan}
requirements={linkedReqs}
canEdit={canEditCoverage}
currentUserName={currentUserName}
onUpdate={updatePlan}
/>
{plan.status === 'in_progress' && !completionState.canSubmitResult && (
<p className="pt-1 text-[11px] text-[var(--ink-muted)]">{completionState.missingReasons.join('、')}</p>
)}
</div>
)}
{!isResearch && (
<PlanLogTimeline logs={plan.logs} className="border-l-0 border-t border-[var(--line)] pt-4 pl-0" />
)}
{/* Result */}
{plan.status === 'completed' && plan.resultUrl && (
<div className="rounded-lg bg-[var(--bg-subtle)] p-3">
<div className="text-[11px] text-[var(--ink-muted)] mb-1"></div>
<div className="flex items-center gap-1.5">
{plan.resultType === 'link' ? <Link2 className="h-3 w-3 text-[var(--accent)]" /> : <FileUp className="h-3 w-3 text-[var(--accent)]" />}
<a href={plan.resultUrl} target="_blank" rel="noopener noreferrer" className="text-[12px] text-[var(--accent)] hover:underline flex items-center gap-1">
{plan.resultTitle || plan.resultFileName || '查看成果'}<ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
)}
{plan.status === 'completed' && plan.type === 'product' && getProductPlanKind(plan) === 'review' && plan.reviewResult && (
<div className={`rounded-lg border p-3 ${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-2 space-y-1 text-[12px] text-red-700">
{getFailureLabels(plan.reviewFailureTypes).length > 0 && <div>{getFailureLabels(plan.reviewFailureTypes).join('、')}</div>}
{plan.reviewFailureReason && <div>{plan.reviewFailureReason}</div>}
</div>
)}
</div>
)}
{plan.remark && (
<div className="rounded-lg bg-[var(--bg-subtle)] p-3">
<div className="text-[11px] text-[var(--ink-muted)] mb-1"></div>
<div className="text-[12px] text-[var(--ink)]">{plan.remark}</div>
</div>
)}
{/* Transfer Section */}
{showTransfer && (
<div className="rounded-lg border border-[var(--line)] p-3 space-y-2">
<div className="text-[11px] font-medium text-[var(--ink-muted)]"></div>
<FilterSelect
value={transferTo || 'all'}
onChange={(value) => setTransferTo(value === 'all' ? '' : value)}
options={members.filter((m) => m.name !== plan.owner).map((m) => ({ value: m.name, label: m.name }))}
allLabel="选择人员"
/>
<div className="flex gap-2">
<button onClick={handleTransfer} disabled={!transferTo} className="h-7 px-3 rounded text-[11px] font-medium bg-blue-500 text-white disabled:opacity-50"></button>
<button onClick={() => setShowTransfer(false)} className="h-7 px-2 text-[11px] text-[var(--ink-muted)]"></button>
</div>
</div>
)}
{/* Complete with result */}
{showComplete && (
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 space-y-2">
<div className="text-[11px] font-medium text-emerald-700">{getSubmitActionLabel(plan)}</div>
{isProductReviewPlan ? (
<>
<div className="grid grid-cols-2 gap-2">
{(['passed', 'failed'] as ProductPlanReviewResult[]).map((result) => (
<button
key={result}
onClick={() => setReviewResult(result)}
className={`h-8 rounded-lg border text-[11px] font-medium ${reviewResult === result ? 'border-[var(--accent)] bg-white text-[var(--accent)]' : 'border-[var(--line)] bg-white/70 text-[var(--ink-soft)]'}`}
>
{PRODUCT_PLAN_REVIEW_RESULT_LABEL[result]}
</button>
))}
</div>
{reviewResult === 'failed' && (
<>
<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)] bg-white/70 px-2 py-1 text-[11px] text-[var(--ink-soft)]">
<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>
<textarea
value={reviewFailureReason}
onChange={(e) => setReviewFailureReason(e.target.value)}
rows={3}
placeholder="写清楚具体问题、影响范围和建议调整方向"
className="w-full rounded-lg border border-[var(--line)] bg-white px-2 py-2 text-[12px] focus:border-[var(--accent)] focus:outline-none resize-none"
/>
</>
)}
</>
) : (
<>
{isProductDesignPlan && (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] leading-5 text-amber-800">
Axure
</div>
)}
<input value={resultTitle} onChange={(e) => setResultTitle(e.target.value)} placeholder={isProductDesignPlan ? '成果标题(如 v1.0 原型地址)' : '成果标题(必填,如 v1.0 产品方案)'} className="h-8 w-full rounded-lg border border-[var(--line)] px-2 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
{!isProductDesignPlan && (
<div className="flex gap-2">
<button onClick={() => setResultType('link')} className={`h-7 px-2.5 rounded text-[11px] font-medium border ${resultType === 'link' ? 'border-[var(--accent)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}><Link2 className="h-3 w-3 inline mr-1" /></button>
<button onClick={() => setResultType('file')} className={`h-7 px-2.5 rounded text-[11px] font-medium border ${resultType === 'file' ? 'border-[var(--accent)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}><FileUp className="h-3 w-3 inline mr-1" /></button>
</div>
)}
{resultType === 'link' || isProductDesignPlan ? (
<input value={resultUrl} onChange={(e) => setResultUrl(e.target.value)} placeholder="https://..." className="h-8 w-full rounded-lg border border-[var(--line)] px-2 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
) : (
<div>
<input type="file" onChange={handleFile} className="text-[11px] text-[var(--ink-soft)]" />
{fileName && <p className="text-[10px] text-[var(--ink-muted)] mt-1">{fileName}</p>}
</div>
)}
{isProductDesignPlan && (
<label className="flex items-start gap-2 rounded-lg border border-[var(--line)] bg-white/70 px-3 py-2 text-[11px] 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 gap-2">
<button
onClick={handleSubmitResult}
disabled={
!completionState.canSubmitResult
|| (isProductReviewPlan
? reviewResult === 'failed' && (reviewFailureTypes.size === 0 || !reviewFailureReason.trim())
: isProductDesignPlan
? !resultTitle.trim() || !resultUrl.trim() || !prototypeReviewConfirmed
: !resultTitle.trim() || (resultType === 'link' ? !resultUrl.trim() : !fileData))
}
className="h-7 px-3 rounded text-[11px] font-medium bg-emerald-500 text-white disabled:opacity-50"
>
</button>
<button onClick={() => setShowComplete(false)} className="h-7 px-2 text-[11px] text-[var(--ink-muted)]"></button>
</div>
</div>
)}
</div>
{/* Footer Actions */}
{plan.status !== 'completed' && (
<>
<div className="flex items-center gap-2 px-5 py-3 border-t border-[var(--line)] shrink-0">
{plan.status === 'pending' && (
<button onClick={() => updatePlan(plan.id, { status: 'in_progress' })} className="h-8 px-3 rounded-lg text-[12px] font-medium text-blue-600 border border-blue-200 hover:bg-blue-50 flex items-center gap-1">
<Play className="h-3 w-3" />
</button>
)}
{plan.status === 'in_progress' && (
<button onClick={() => setShowComplete(true)} disabled={!completionState.canSubmitResult} className="h-8 px-3 rounded-lg text-[12px] font-medium text-emerald-600 border border-emerald-200 hover:bg-emerald-50 disabled:opacity-50 disabled:cursor-not-allowed">
{getSubmitActionLabel(plan)}
</button>
)}
<button onClick={() => setShowTransfer(true)} className="h-8 px-3 rounded-lg text-[12px] font-medium text-[var(--ink-soft)] border border-[var(--line)] hover:bg-[var(--bg-subtle)] flex items-center gap-1">
<ArrowRightLeft className="h-3 w-3" />
</button>
</div>
{plan.status === 'in_progress' && !completionState.canSubmitResult && (
<div className="px-5 pb-3 text-[11px] text-[var(--ink-muted)]">{completionState.missingReasons.join('、')}</div>
)}
</>
)}
</div>
</div>
);
}