feat(版本): 完善计划覆盖与工作台待办
关键改动: - 增加计划日志汇总、需求覆盖草稿校验与对应测试 - 抽取工作台工作项 Hook,补充待办计数能力 - 优化版本详情中计划、任务、测试用例和 Bug 的筛选与展示 Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
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 {
|
||||
@@ -9,12 +9,15 @@ import {
|
||||
calcTotalDuration,
|
||||
calcPlanProgress,
|
||||
sortPlansNewestFirst,
|
||||
getPlanLogsForPlans,
|
||||
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 { PlanLogTimeline, PlanRequirementCoveragePanel } from './PlanRequirementCoveragePanel';
|
||||
@@ -77,24 +80,26 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
||||
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 className={planType === 'research' ? 'space-y-4' : '-m-5 h-[calc(100vh-98px)] min-h-[520px]'}>
|
||||
{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>
|
||||
<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 ? (
|
||||
{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>
|
||||
) : (
|
||||
) : planType === 'research' ? (
|
||||
<div className="space-y-3">
|
||||
{typePlans.map((plan) => {
|
||||
const now = new Date().toISOString();
|
||||
@@ -180,9 +185,6 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
||||
<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 && (
|
||||
@@ -268,10 +270,13 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
||||
{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>
|
||||
<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>
|
||||
@@ -294,6 +299,27 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
||||
);
|
||||
})}
|
||||
</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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(showCreateModal || editingPlan) && (
|
||||
@@ -333,6 +359,376 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
}: {
|
||||
typePlans: VersionPlan[];
|
||||
planType: 'product' | 'ui';
|
||||
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;
|
||||
}) {
|
||||
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(() => {
|
||||
typePlans.forEach((plan) => {
|
||||
const { autoStarted } = getPlanRuntime(plan);
|
||||
if (autoStarted && !plan.actualStartAt) {
|
||||
onUpdate(plan.id, { status: 'in_progress' });
|
||||
}
|
||||
});
|
||||
}, [typePlans, onUpdate]);
|
||||
|
||||
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>
|
||||
<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 = 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}
|
||||
/>
|
||||
) : (
|
||||
<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,
|
||||
}: {
|
||||
plan: VersionPlan;
|
||||
planType: 'product' | 'ui';
|
||||
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;
|
||||
}) {
|
||||
const now = new Date().toISOString();
|
||||
const { autoStarted, effectiveStatus, effectiveStartAt } = getPlanRuntime(plan);
|
||||
const durText = getPlanDurationText(plan, effectiveStatus, effectiveStartAt, now);
|
||||
const completionState = getPlanCompletionState(plan);
|
||||
const canEditCoverage = 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>
|
||||
<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 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{selectedRequirements.length > 0 && (
|
||||
<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 && (
|
||||
<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 && (
|
||||
<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';
|
||||
|
||||
Reference in New Issue
Block a user