feat(版本): 完善计划覆盖与工作台待办

关键改动:

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

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

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

Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
Script Generator
2026-06-30 13:43:18 +08:00
parent a6a4d7d3d9
commit 52a88626d4
23 changed files with 1082 additions and 281 deletions

View File

@@ -6,6 +6,7 @@ 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,
@@ -289,10 +290,12 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
{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>
<select value={transferTo} onChange={(e) => setTransferTo(e.target.value)} className="h-8 w-full rounded-lg border border-[var(--line)] px-2 text-[12px] focus:border-[var(--accent)] focus:outline-none">
<option value=""></option>
{members.filter((m) => m.name !== plan.owner).map((m) => <option key={m.id} value={m.name}>{m.name}</option>)}
</select>
<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>

View File

@@ -1,11 +1,12 @@
'use client';
import { useState } from 'react';
import { Check, Clock3, Sparkles } from 'lucide-react';
import { FilterSelect } from '@/components/FilterSelect';
import { formatDateTime } from '@/lib/format';
import type { Requirement } from '@/lib/requirement';
import type { RequirementCoverageStatus, VersionPlan, VersionPlanLog } from '@/lib/version-plan';
import type { RequirementCoverageStatus, VersionPlan, VersionPlanLog, VersionPlanLogView } from '@/lib/version-plan';
import {
canSaveRequirementCoverageDraft,
getRequirementCoverage,
getRequirementCoverageStatus,
getRequirementCoverageSummary,
@@ -24,35 +25,62 @@ interface CoverageProps {
}
interface LogTimelineProps {
logs?: VersionPlanLog[];
logs?: Array<VersionPlanLog | VersionPlanLogView>;
className?: string;
fillHeight?: boolean;
}
const COVERAGE_STATUS_OPTIONS: RequirementCoverageStatus[] = ['partial', 'completed', 'not_started'];
const COVERAGE_BADGE_STYLE: Record<RequirementCoverageStatus, string> = {
not_started: 'border-zinc-200 bg-zinc-50 text-zinc-500',
partial: 'border-amber-200 bg-amber-50 text-amber-700',
completed: 'border-emerald-200 bg-emerald-50 text-emerald-700',
};
function getLogIcon(log: VersionPlanLog) {
if (log.type === 'ai_decompose') return <Sparkles className="h-3.5 w-3.5" />;
if (log.type === 'requirement_progress') return <Check className="h-3.5 w-3.5" />;
return <Clock3 className="h-3.5 w-3.5" />;
function getLogTone(log: VersionPlanLog): { badge: string } {
if (log.aiStatus === 'error') {
return {
badge: 'border-red-200 bg-red-50 text-red-700',
};
}
if (log.type === 'ai_decompose') {
return {
badge: 'border-violet-200 bg-violet-50 text-violet-700',
};
}
if (log.coverageStatus === 'completed') {
return {
badge: 'border-emerald-200 bg-emerald-50 text-emerald-700',
};
}
if (log.coverageStatus === 'partial') {
return {
badge: 'border-amber-200 bg-amber-50 text-amber-700',
};
}
return {
badge: 'border-zinc-200 bg-zinc-50 text-zinc-600',
};
}
function getLogTone(log: VersionPlanLog): string {
if (log.aiStatus === 'error') return 'bg-red-50 text-red-700 ring-red-100';
if (log.type === 'ai_decompose') return 'bg-purple-50 text-purple-700 ring-purple-100';
if (log.coverageStatus === 'completed') return 'bg-emerald-50 text-emerald-700 ring-emerald-100';
if (log.coverageStatus === 'partial') return 'bg-amber-50 text-amber-700 ring-amber-100';
return 'bg-zinc-50 text-zinc-600 ring-zinc-100';
function getLogTypeLabel(log: VersionPlanLog): string {
if (log.type === 'ai_decompose') return 'AI 拆解';
if (log.type === 'requirement_progress') return '需求进度';
return '系统记录';
}
function getLogMonthKey(createdAt: string): string | null {
const date = new Date(createdAt);
if (Number.isNaN(date.getTime())) return null;
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
}
function getLogMonthLabel(monthKey: string): string {
const [year, month] = monthKey.split('-');
return `${year}${month}`;
}
export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, currentUserName, onUpdate }: CoverageProps) {
const [editingRequirementId, setEditingRequirementId] = useState<string | null>(null);
const [draftStatus, setDraftStatus] = useState<RequirementCoverageStatus>('partial');
const [completedContent, setCompletedContent] = useState('');
const [remainingContent, setRemainingContent] = useState('');
const summary = getRequirementCoverageSummary(plan);
@@ -62,7 +90,6 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr
const openEditor = (req: RequirementOption) => {
const coverage = getRequirementCoverage(plan, req.id);
setEditingRequirementId(req.id);
setDraftStatus(coverage?.status === 'completed' ? 'completed' : coverage?.status === 'not_started' ? 'not_started' : 'partial');
setCompletedContent(coverage?.completedContent ?? '');
setRemainingContent(coverage?.remainingContent ?? '');
};
@@ -71,20 +98,17 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr
setEditingRequirementId(null);
setCompletedContent('');
setRemainingContent('');
setDraftStatus('partial');
};
const canSave = draftStatus === 'not_started'
|| (draftStatus === 'completed' && completedContent.trim().length > 0)
|| (draftStatus === 'partial' && completedContent.trim().length > 0 && remainingContent.trim().length > 0);
const canSavePartial = canSaveRequirementCoverageDraft('partial', completedContent, remainingContent);
const saveCoverage = (req: RequirementOption) => {
if (!canEdit || !canSave) return;
const saveCoverage = (req: RequirementOption, status: Extract<RequirementCoverageStatus, 'partial' | 'completed'>) => {
if (!canEdit || !canSaveRequirementCoverageDraft(status, completedContent, remainingContent)) return;
const patch = updateRequirementCoverage(plan, {
requirementId: req.id,
status: draftStatus,
completedContent: draftStatus === 'not_started' ? undefined : completedContent,
remainingContent: draftStatus === 'partial' ? remainingContent : undefined,
status,
completedContent: status === 'partial' ? completedContent : undefined,
remainingContent: status === 'partial' ? remainingContent : undefined,
updatedBy: currentUserName,
requirementCode: req.code,
requirementTitle: req.title,
@@ -110,7 +134,7 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${summary.percent}%` }} />
</div>
</div>
<div className="max-h-56 space-y-1 overflow-y-auto rounded-lg bg-[var(--bg-subtle)] p-2 pr-1">
<div className="space-y-1 rounded-lg bg-[var(--bg-subtle)] p-2 pr-1">
{requirements.map((req) => {
const status = getRequirementCoverageStatus(plan, req.id);
const coverage = getRequirementCoverage(plan, req.id);
@@ -146,46 +170,45 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr
</div>
{isEditing && (
<div className="mt-2 space-y-2 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-2">
<div className="grid grid-cols-3 gap-1.5">
{COVERAGE_STATUS_OPTIONS.map((statusOption) => (
<button
key={statusOption}
type="button"
onClick={() => setDraftStatus(statusOption)}
className={`h-7 rounded-md border text-[11px] font-medium transition-colors ${draftStatus === statusOption ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
>
{REQUIREMENT_COVERAGE_LABEL[statusOption]}
</button>
))}
</div>
{draftStatus !== 'not_started' && (
<textarea
value={completedContent}
onChange={(e) => setCompletedContent(e.target.value)}
rows={2}
placeholder="本次已完成的内容"
className="w-full resize-none rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2 py-1.5 text-[12px] focus:border-[var(--accent)] focus:outline-none"
/>
)}
{draftStatus === 'partial' && (
<textarea
value={remainingContent}
onChange={(e) => setRemainingContent(e.target.value)}
rows={2}
placeholder="剩余未完成的内容"
className="w-full resize-none rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2 py-1.5 text-[12px] focus:border-[var(--accent)] focus:outline-none"
/>
)}
<div className="flex justify-end gap-2">
<button type="button" onClick={closeEditor} className="h-7 px-2 text-[11px] text-[var(--ink-muted)]"></button>
<textarea
value={completedContent}
onChange={(e) => setCompletedContent(e.target.value)}
rows={2}
placeholder="本次已完成的内容"
className="w-full resize-none rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2 py-1.5 text-[12px] focus:border-[var(--accent)] focus:outline-none"
/>
<textarea
value={remainingContent}
onChange={(e) => setRemainingContent(e.target.value)}
rows={2}
placeholder="剩余未完成的内容"
className="w-full resize-none rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2 py-1.5 text-[12px] focus:border-[var(--accent)] focus:outline-none"
/>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => saveCoverage(req)}
disabled={!canSave}
className="h-7 rounded-md bg-[var(--accent)] px-3 text-[11px] font-medium text-white hover:bg-[var(--accent-hover)] disabled:opacity-50"
onClick={() => saveCoverage(req, 'completed')}
className="h-7 rounded-md bg-emerald-600 px-3 text-[11px] font-medium text-white hover:bg-emerald-700"
>
</button>
<div className="ml-auto flex gap-2">
<button
type="button"
onClick={closeEditor}
className="h-7 rounded-md border border-[var(--line)] px-2 text-[11px] font-medium text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)]"
>
</button>
<button
type="button"
onClick={() => saveCoverage(req, 'partial')}
disabled={!canSavePartial}
className="h-7 rounded-md bg-[var(--accent)] px-3 text-[11px] font-medium text-white hover:bg-[var(--accent-hover)] disabled:opacity-50"
>
</button>
</div>
</div>
</div>
)}
@@ -197,35 +220,73 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr
);
}
export function PlanLogTimeline({ logs, className = '' }: LogTimelineProps) {
export function PlanLogTimeline({ logs, className = '', fillHeight = false }: LogTimelineProps) {
const [selectedMonth, setSelectedMonth] = useState('all');
const sortedLogs = [...(logs ?? [])].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const monthOptions = Array.from(new Set(sortedLogs.map((log) => getLogMonthKey(log.createdAt)).filter(Boolean) as string[]))
.sort((a, b) => b.localeCompare(a));
const effectiveMonth = selectedMonth === 'all' || monthOptions.includes(selectedMonth) ? selectedMonth : 'all';
const visibleLogs = effectiveMonth === 'all'
? sortedLogs
: sortedLogs.filter((log) => getLogMonthKey(log.createdAt) === effectiveMonth);
const frameClass = className || 'border-l border-[var(--line)] pl-4';
const listClass = fillHeight
? 'mt-3 min-h-0 flex-1 overflow-y-auto pr-1'
: 'mt-3 max-h-80 overflow-y-auto pr-1';
return (
<aside className={frameClass}>
<div className="flex items-center justify-between">
<div className="text-[11px] font-semibold text-[var(--ink-muted)]"></div>
<span className="text-[11px] tabular-nums text-[var(--ink-soft)]">{sortedLogs.length}</span>
<div className="shrink-0 border-b border-[var(--line)] pb-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[13px] font-semibold text-[var(--ink)]"></div>
<div className="mt-0.5 text-[11px] text-[var(--ink-muted)]"> AI </div>
</div>
<FilterSelect
value={effectiveMonth}
onChange={setSelectedMonth}
options={monthOptions.map((month) => ({ value: month, label: getLogMonthLabel(month) }))}
allLabel="全部月份"
/>
</div>
</div>
{sortedLogs.length === 0 ? (
<div className="mt-4 rounded-lg bg-[var(--bg-subtle)] px-3 py-4 text-center text-[11px] text-[var(--ink-muted)]"></div>
{visibleLogs.length === 0 ? (
<div className="mt-4 rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg-subtle)] px-3 py-8 text-center text-[12px] text-[var(--ink-muted)]">
</div>
) : (
<div className="mt-3 max-h-80 space-y-3 overflow-y-auto pr-1">
{sortedLogs.map((log) => (
<div key={log.id} className="relative pl-5">
<span className={`absolute left-0 top-0 flex h-6 w-6 -translate-x-3 items-center justify-center rounded-full ring-4 ${getLogTone(log)}`}>
{getLogIcon(log)}
</span>
<div className="space-y-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 text-[12px] font-medium leading-5 text-[var(--ink)]">{log.title}</div>
<span className="shrink-0 text-[10px] text-[var(--ink-muted)]">{formatDateTime(log.createdAt)}</span>
<div className={listClass}>
<div>
{visibleLogs.map((log) => {
const tone = getLogTone(log);
return (
<div key={log.id} className="border-b border-[var(--line)] py-3 first:pt-0 last:border-b-0 last:pb-0">
<div className="min-w-0">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className={`inline-flex rounded-md border px-1.5 py-0.5 text-[10px] font-semibold ${tone.badge}`}>
{getLogTypeLabel(log)}
</span>
<span className="min-w-0 truncate text-[11px] text-[var(--ink-muted)]">{log.actor}</span>
</div>
<span className="shrink-0 text-[10px] tabular-nums text-[var(--ink-muted)]">{formatDateTime(log.createdAt)}</span>
</div>
<div className="mt-1.5 text-[12px] font-semibold leading-5 text-[var(--ink)]">{log.title}</div>
{'planTitle' in log && log.planTitle && (
<div className="mt-1 truncate text-[11px] text-[var(--ink-soft)]" title={log.planTitle}>
{log.planTitle}
</div>
)}
{log.detail && (
<div className="mt-2 border-l-2 border-[var(--line)] bg-[var(--bg-subtle)] px-2.5 py-2 text-[11px] leading-5 text-[var(--ink-soft)]">
<div className="whitespace-pre-wrap">{log.detail}</div>
</div>
)}
</div>
</div>
<div className="text-[11px] text-[var(--ink-muted)]">{log.actor}</div>
{log.detail && <div className="whitespace-pre-wrap rounded-md bg-[var(--bg-subtle)] px-2 py-1.5 text-[11px] leading-4 text-[var(--ink-soft)]">{log.detail}</div>}
</div>
</div>
))}
);
})}
</div>
</div>
)}
</aside>

View File

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

View File

@@ -2,6 +2,7 @@
import { useState } from 'react';
import { Plus, X, Search } from 'lucide-react';
import { FilterSelect } from '@/components/FilterSelect';
import type { Requirement, ChangeReason } from '@/lib/requirement';
import type { DevTask } from '@/lib/dev-task';
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, CHANGE_REASON_LABEL } from '@/lib/requirement';
@@ -161,18 +162,23 @@ function ChangeRequirementModal({ versionMembers, currentUserName, onClose, onSu
<div className="space-y-3">
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"> <span className="text-red-500">*</span></label>
<select value={changeBy} onChange={(e) => setChangeBy(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none">
{versionMembers.map((m) => <option key={`${m.role}-${m.name}`} value={m.name}>{m.name}</option>)}
</select>
<FilterSelect
value={changeBy || 'all'}
onChange={(value) => setChangeBy(value === 'all' ? '' : value)}
options={versionMembers.map((member) => ({ value: member.name, label: member.name }))}
allLabel="选择变更人员"
className="w-full"
/>
</div>
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"> <span className="text-red-500">*</span></label>
<select value={changeReason} onChange={(e) => setChangeReason(e.target.value as ChangeReason)} 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">
<option value=""></option>
{Object.entries(CHANGE_REASON_LABEL).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
<FilterSelect
value={changeReason || 'all'}
onChange={(value) => setChangeReason(value === 'all' ? '' : value as ChangeReason)}
options={Object.entries(CHANGE_REASON_LABEL).map(([key, label]) => ({ value: key, label }))}
allLabel="请选择变更原因"
className="w-full"
/>
</div>
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"> <span className="text-red-500">*</span></label>