feat: 开发任务时间字段重构 + 双口径耗时 + 搜索 + 性能优化

核心变更:
- DevTask 字段重构:startDate/dueDate/estimateHours/actualHours 替换为 expectedStartAt/expectedEndAt/actualStartAt/actualEndAt(含时分),预计/实际工时按工作时段(9:00-12:00 + 13:00-18:00)派生计算
- 状态机自动化:到点自动切开发中、未到可手动开干、超期需填延后原因
- 新建 lib/work-hours.ts:calcWorkHours(工作时段过滤)、formatWorkHours(X h(Y 天))、calcTwoMetrics(日历/人力双口径)
- 新建 lib/dev-task-transitions.ts:状态切换守卫
- 三个 Tab 顶部统计:开发任务(预/日历/人力)、测试用例 / Bug(日历 / 人力);版本概览总人天投入改双行(日历总耗时 / 人力总投入)
- 三个 Tab 加搜索框:300ms debounce + 标题/编号模糊匹配
- 性能优化:requirementIds/categories/requirements/testCases 转 Map/Set 索引、Row 组件 React.memo
- 全局耗时显示统一带天数换算:8h(1天)、11h(1.4天)

新增文件: SearchInput.tsx, useDebouncedValue.ts, work-hours.ts, dev-task-transitions.ts, 设计文档

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Script Generator
2026-06-16 17:44:59 +08:00
parent 4ac0dab4c5
commit 1f5bed0c79
25 changed files with 1192 additions and 221 deletions

View File

@@ -13,7 +13,7 @@ import { useBugStore } from '@/stores/useBugStore';
import { getProjectDetail, VersionWithContext } from '@/lib/derive';
import { Stage, Role, STAGES, ROLES, STAGE_INDEX, ROLE_LABEL } from '@/lib/stage';
import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_BG } from '@/lib/version-status';
import { STATUS_PROGRESS, calcGroupProgress as calcDevTaskProgress } from '@/lib/dev-task';
import { STATUS_PROGRESS, calcGroupProgress as calcDevTaskProgress, getEstimateHours, aggregateDevTaskHours } from '@/lib/dev-task';
import { CapsuleStages } from '@/components/version/CapsuleStages';
import { MemberChips } from '@/components/version/MemberChips';
import type { VersionPlan } from '@/lib/version-plan';
@@ -31,6 +31,27 @@ function StatCard({ value, label }: { value: number | string; label: string }) {
);
}
function HoursStatCard({ estimate, actual }: { estimate: number; actual: number }) {
const overrun = actual > estimate && estimate > 0;
const underrun = actual > 0 && actual < estimate;
const tone = overrun ? 'text-red-600' : underrun ? 'text-emerald-600' : 'text-[var(--ink)]';
const dayStr = (h: number) => {
const d = h / 8;
return Number.isInteger(d) ? String(d) : d.toFixed(1).replace(/\.0$/, '');
};
return (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="flex items-baseline gap-1.5">
<span className={`text-2xl font-bold tabular-nums ${tone}`}>{actual > 0 ? `${actual}h` : '—'}</span>
<span className="text-xs text-[var(--ink-muted)] tabular-nums">/ {estimate}h</span>
</div>
<div className="text-xs text-[var(--ink-muted)] mt-1">
/ {estimate > 0 && <span className="ml-1">{actual > 0 ? `${dayStr(actual)} / ` : ''}{dayStr(estimate)}</span>}
</div>
</div>
);
}
/* ─── ProgressBar (for expanded released cards) ─── */
function ProgressBar({ role, percent, daysSpent }: { role: Role; percent: number; daysSpent: number }) {
return (
@@ -73,7 +94,7 @@ function VersionCard({ version, progress, plans, devTasks, testCases, bugs, requ
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.startDate) startDates.push(t.startDate); });
vDevTasks.forEach((t) => { if (t.actualStartAt) startDates.push(t.actualStartAt); });
vTCs.forEach((c) => { if (c.startedAt) startDates.push(c.startedAt); });
const earliestStart = startDates.length > 0 ? startDates.sort()[0] : version.startDate;
@@ -82,7 +103,7 @@ function VersionCard({ version, progress, plans, devTasks, testCases, bugs, requ
// 实际截止:取所有阶段最晚完成
const endDates: string[] = [];
vPlans.forEach((p) => { if (p.completedAt) endDates.push(p.completedAt); });
vDevTasks.forEach((t) => { if (t.completedAt) endDates.push(t.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;
@@ -394,12 +415,12 @@ export default function ProjectDetailPage() {
}
if (vDevTasks.length > 0) {
const totalEstimate = vDevTasks.reduce((sum, t) => sum + t.estimateHours, 0);
const totalEstimate = vDevTasks.reduce((sum, t) => sum + getEstimateHours(t), 0);
let devProgress: number;
if (totalEstimate === 0) {
devProgress = vDevTasks.reduce((sum, t) => sum + STATUS_PROGRESS[t.status], 0) / vDevTasks.length;
} else {
const weighted = vDevTasks.reduce((sum, t) => sum + t.estimateHours * STATUS_PROGRESS[t.status], 0);
const weighted = vDevTasks.reduce((sum, t) => sum + getEstimateHours(t) * STATUS_PROGRESS[t.status], 0);
devProgress = weighted / totalEstimate;
}
segments.push(devProgress);
@@ -416,14 +437,17 @@ export default function ProjectDetailPage() {
}, [project, plans, requirements, devTasks, testCases]);
const stats = useMemo(() => {
if (!project) return { total: 0, released: 0, reqCount: 0, bugCount: 0 };
if (!project) return { total: 0, released: 0, reqCount: 0, bugCount: 0, estimateHours: 0, actualHours: 0 };
const total = project.versions.length;
const released = project.versions.filter((v) => v.status === 'released').length;
const reqCount = requirements.filter((r) => r.projectId === projectId).length;
const versionIds = new Set(project.versions.map((v) => v.id));
const bugCount = bugs.filter((b) => versionIds.has(b.versionId)).length;
return { total, released, reqCount, bugCount };
}, [project, requirements, bugs, projectId]);
const projectReqIds = new Set(requirements.filter((r) => r.projectId === projectId).map((r) => r.id));
const projectDevTasks = devTasks.filter((t) => projectReqIds.has(t.requirementId));
const { estimate, actual } = aggregateDevTaskHours(projectDevTasks);
return { total, released, reqCount, bugCount, estimateHours: estimate, actualHours: actual };
}, [project, requirements, bugs, devTasks, projectId]);
const teamByRole = useMemo(() => {
if (!project) return {} as Record<string, Record<string, number>>;
@@ -464,11 +488,12 @@ export default function ProjectDetailPage() {
<div className="flex-1 overflow-y-auto p-5 bg-[var(--bg)]">
<div className="space-y-5">
<div className="grid grid-cols-4 gap-4">
<div className="grid grid-cols-5 gap-4">
<StatCard value={stats.total} label="总版本数" />
<StatCard value={stats.released} label="已开发" />
<StatCard value={stats.reqCount} label="需求数" />
<StatCard value={stats.bugCount} label="Bug 总数" />
<HoursStatCard estimate={stats.estimateHours} actual={stats.actualHours} />
</div>
<TeamSection teamByRole={teamByRole} />