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:
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user