feat(版本): 优化概览与只读状态
关键改动: - 增加需求排序和版本只读状态规则及测试 - 完善版本概览阶段耗时、项目页和工作台展示 - 优化小宝预警请求节流、建议状态和风险过滤 Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
@@ -19,6 +19,8 @@ import { STATUS_PROGRESS, calcGroupProgress as calcDevTaskProgress, getEstimateH
|
||||
import { CapsuleStages } from '@/components/version/CapsuleStages';
|
||||
import { MemberChips } from '@/components/version/MemberChips';
|
||||
import { getRequirementCoverageSummary, type VersionPlan } from '@/lib/version-plan';
|
||||
import { buildVersionTimelineSummary, calcStageEffortMetrics, formatVersionOverviewDateTime, getVersionCardDefaultExpanded, mergeStageProgressWithEffort } from '@/lib/version-overview';
|
||||
import { formatActualDuration } from '@/lib/work-hours';
|
||||
import type { DevTask } from '@/lib/dev-task';
|
||||
import type { TestCase } from '@/lib/test-case';
|
||||
import type { Bug } from '@/lib/bug';
|
||||
@@ -81,9 +83,12 @@ function VersionCard({ version, progress, plans, devTasks, testCases, bugs, requ
|
||||
requirements: { id: string; versionId?: string }[];
|
||||
onNavigate: (id: string) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [expanded, setExpanded] = useState(() => getVersionCardDefaultExpanded(version.status));
|
||||
|
||||
useEffect(() => {
|
||||
setExpanded(getVersionCardDefaultExpanded(version.status));
|
||||
}, [version.status]);
|
||||
|
||||
// 与版本详情一致:取所有阶段最早的实际开始
|
||||
const versionData = useMemo(() => {
|
||||
const vPlans = plans.filter((p) => p.versionId === version.id);
|
||||
const vReqIds = new Set(requirements.filter((r) => r.versionId === version.id).map((r) => r.id));
|
||||
@@ -91,41 +96,31 @@ function VersionCard({ version, progress, plans, devTasks, testCases, bugs, requ
|
||||
const vTCs = testCases.filter((c) => c.versionId === version.id);
|
||||
const vBugs = bugs.filter((b) => b.versionId === version.id);
|
||||
|
||||
const startDates: string[] = [];
|
||||
vPlans.forEach((p) => {
|
||||
if (p.actualStartAt) startDates.push(p.actualStartAt);
|
||||
else if (p.status === 'pending' && p.startTime && new Date(p.startTime) <= new Date()) startDates.push(p.startTime);
|
||||
});
|
||||
vDevTasks.forEach((t) => { if (t.actualStartAt) startDates.push(t.actualStartAt); });
|
||||
vTCs.forEach((c) => { if (c.startedAt) startDates.push(c.startedAt); });
|
||||
return { vPlans, vDevTasks, vTCs, vBugs };
|
||||
}, [version.id, plans, devTasks, testCases, bugs, requirements]);
|
||||
|
||||
const earliestStart = startDates.length > 0 ? startDates.sort()[0] : version.startDate;
|
||||
const actualStartDisplay = startDates.length > 0 ? startDates.sort()[0].slice(0, 10) : (version.startDate ?? null);
|
||||
const stageEffortMetrics = useMemo(() => calcStageEffortMetrics({
|
||||
plans: versionData.vPlans,
|
||||
devTasks: versionData.vDevTasks,
|
||||
testCases: versionData.vTCs,
|
||||
bugs: versionData.vBugs,
|
||||
}), [versionData]);
|
||||
|
||||
// 实际截止:取所有阶段最晚完成
|
||||
const endDates: string[] = [];
|
||||
vPlans.forEach((p) => { if (p.completedAt) endDates.push(p.completedAt); });
|
||||
vDevTasks.forEach((t) => { if (t.actualEndAt) endDates.push(t.actualEndAt); });
|
||||
vTCs.forEach((c) => { if (c.completedAt) endDates.push(c.completedAt); });
|
||||
vBugs.forEach((b) => { if (b.closedAt) endDates.push(b.closedAt); });
|
||||
const actualEndDisplay = endDates.length > 0 ? endDates.sort().reverse()[0].slice(0, 10) : null;
|
||||
|
||||
let totalDays = 0;
|
||||
if (earliestStart) {
|
||||
const start = new Date(earliestStart);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = version.status === 'released' && version.releaseDate ? new Date(version.releaseDate) : new Date();
|
||||
end.setHours(0, 0, 0, 0);
|
||||
totalDays = Math.max(0, Math.floor((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)));
|
||||
}
|
||||
|
||||
return { vPlans, vDevTasks, vTCs, vBugs, totalDays, actualStart: actualStartDisplay, actualEnd: actualEndDisplay };
|
||||
}, [version, plans, devTasks, testCases, bugs, requirements]);
|
||||
const timelineSummary = useMemo(() => buildVersionTimelineSummary({
|
||||
status: version.status,
|
||||
startDate: version.startDate,
|
||||
expectedReleaseDate: version.expectedReleaseDate,
|
||||
releaseDate: version.releaseDate,
|
||||
plans: versionData.vPlans,
|
||||
devTasks: versionData.vDevTasks,
|
||||
testCases: versionData.vTCs,
|
||||
bugs: versionData.vBugs,
|
||||
}), [version, versionData]);
|
||||
|
||||
// 状态胶囊数据 — 与版本详情一致
|
||||
const stageProgress = useMemo(() => {
|
||||
const { vPlans, vDevTasks, vTCs, vBugs } = versionData;
|
||||
const sp: any = {};
|
||||
const sp: Partial<Record<Stage, { percent: number; status: 'idle' | 'active' | 'done' }>> = {};
|
||||
|
||||
const calcGroupProgress = (group: VersionPlan[], type: 'research' | 'product' | 'ui') => {
|
||||
if (group.length === 0) return 0;
|
||||
@@ -184,10 +179,8 @@ function VersionCard({ version, progress, plans, devTasks, testCases, bugs, requ
|
||||
const allClosed = vBugs.every((b) => b.status === 'closed' || b.status === 'rejected');
|
||||
sp['bug'] = { percent: bp, status: allClosed ? 'done' : closedBugs > 0 || vBugs.length > 0 ? 'active' : 'idle' };
|
||||
}
|
||||
return sp;
|
||||
}, [versionData]);
|
||||
|
||||
const totalDays = versionData.totalDays;
|
||||
return mergeStageProgressWithEffort(sp, stageEffortMetrics);
|
||||
}, [versionData, stageEffortMetrics]);
|
||||
|
||||
const displayStatus = VERSION_STATUS_LABEL[version.status] ?? '开发中';
|
||||
const displayBg = VERSION_STATUS_BG[version.status] ?? 'bg-blue-500/10 text-blue-600';
|
||||
@@ -204,7 +197,7 @@ function VersionCard({ version, progress, plans, devTasks, testCases, bugs, requ
|
||||
);
|
||||
}
|
||||
|
||||
if (version.status === 'released') {
|
||||
if (!getVersionCardDefaultExpanded(version.status)) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
|
||||
<button
|
||||
@@ -216,10 +209,10 @@ function VersionCard({ version, progress, plans, devTasks, testCases, bugs, requ
|
||||
<span className={`text-[11px] px-2 py-0.5 rounded-full ${displayBg}`}>{displayStatus}</span>
|
||||
<span className="flex-1 text-[11px] text-[var(--ink-muted)] flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{versionData.actualStart ?? version.startDate ?? '-'}
|
||||
{formatVersionOverviewDateTime(timelineSummary.actualStartIso)}
|
||||
<span className="mx-1">→</span>
|
||||
{versionData.actualEnd ?? version.releaseDate ?? '-'}
|
||||
<span className="ml-1">共 {totalDays} 天</span>
|
||||
{formatVersionOverviewDateTime(timelineSummary.isTerminalVersion ? timelineSummary.actualEndIso : timelineSummary.expectedReleaseIso)}
|
||||
<span className="ml-1">已耗时 {formatActualDuration(timelineSummary.actualHours)}</span>
|
||||
</span>
|
||||
<ChevronDown className={`h-3.5 w-3.5 text-[var(--ink-muted)] transition-transform ${expanded ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
@@ -249,18 +242,14 @@ function VersionCard({ version, progress, plans, devTasks, testCases, bugs, requ
|
||||
<div className="flex items-center justify-between text-[11px] text-[var(--ink-muted)] mb-3 pb-3 border-b border-[var(--line-soft)]">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{versionData.actualStart ?? version.startDate ?? '-'}
|
||||
{timelineSummary.actualStartIso ? formatVersionOverviewDateTime(timelineSummary.actualStartIso) : '未开始'}
|
||||
<span className="mx-1">→</span>
|
||||
预计 {version.expectedReleaseDate ?? '-'}
|
||||
{versionData.actualEnd && (
|
||||
<>
|
||||
<span className="mx-1">|</span>
|
||||
实际 {versionData.actualEnd}
|
||||
</>
|
||||
)}
|
||||
预计 {timelineSummary.expectedReleaseIso ? formatVersionOverviewDateTime(timelineSummary.expectedReleaseIso) : '未设置'}
|
||||
<span className="mx-1">|</span>
|
||||
实际 {timelineSummary.isTerminalVersion ? (timelineSummary.actualEndIso ? formatVersionOverviewDateTime(timelineSummary.actualEndIso) : '未记录') : '未完成'}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />已耗时 {totalDays} 天
|
||||
<Clock className="h-3 w-3" />已耗时 {formatActualDuration(timelineSummary.actualHours)}
|
||||
</span>
|
||||
</div>
|
||||
<MemberChips members={version.members ?? []} />
|
||||
|
||||
@@ -120,7 +120,7 @@ function ProjectsPageContent() {
|
||||
<EmptyState />
|
||||
) : (
|
||||
<div>
|
||||
<div className="overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
|
||||
<div className="overflow-visible rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
|
||||
{paged.map((proj) => (
|
||||
<ProjectRow
|
||||
key={proj.id}
|
||||
@@ -177,7 +177,7 @@ function ProjectRow({
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="w-full flex items-center gap-4 px-5 py-4 text-left transition-colors hover:bg-[var(--bg-hover)] border-b border-[var(--line-soft)] last:border-b-0 cursor-pointer"
|
||||
className={`relative w-full flex items-center gap-4 px-5 py-4 text-left transition-colors hover:bg-[var(--bg-hover)] border-b border-[var(--line-soft)] last:border-b-0 cursor-pointer ${menuOpen ? 'z-40' : 'z-0'}`}
|
||||
>
|
||||
<FolderKanban size={20} className="shrink-0 text-[var(--ink-muted)]" />
|
||||
<span className="font-medium text-[var(--ink)] min-w-[120px] shrink-0">
|
||||
@@ -211,8 +211,8 @@ function ProjectRow({
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} />
|
||||
<div className="absolute right-0 top-full z-20 mt-1 min-w-[160px] rounded-lg border border-[var(--line)] bg-[var(--bg-card)] py-1 shadow-[var(--shadow-md)]">
|
||||
<div className="fixed inset-0 z-30" onClick={() => setMenuOpen(false)} />
|
||||
<div className="absolute right-0 top-full z-50 mt-1 min-w-[160px] rounded-lg border border-[var(--line)] bg-[var(--bg-card)] py-1 shadow-[var(--shadow-md)]">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!canActuallyDelete) return;
|
||||
|
||||
Reference in New Issue
Block a user