feat: 阶段耗时覆盖全流程(调研/产品/UI/开发/测试)

阶段耗时(项目维度):
- 调研阶段:PlanTask type=research 的日历跨度
- 产品方案阶段:PlanTask type=product 的日历跨度
- UI设计阶段:PlanTask type=ui 的日历跨度
- 开发阶段:DevTask startDate→completedAt 日历跨度
- 测试阶段:TestCase startedAt→completedAt 日历跨度
- 每阶段带进度条可视化对比

个人耗时排名:
- 汇总5类工作(调研/产品/UI/开发/测试)
- 条形图分色展示各类工作占比

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Script Generator
2026-06-12 16:02:30 +08:00
parent b3ccc17c2c
commit 664330fe11

View File

@@ -379,64 +379,105 @@ export default function VersionDetailPage() {
{/* 阶段耗时 + 个人耗时排名 */} {/* 阶段耗时 + 个人耗时排名 */}
{(() => { {(() => {
// 项目维度:阶段日历天数 const versionPlans = plans.filter((p) => p.versionId === version.id);
const devStarts = versionDevTasks.filter((t) => t.startDate).map((t) => t.startDate!);
const devEnds = versionDevTasks.filter((t) => t.completedAt).map((t) => t.completedAt!);
const devCalendarStart = devStarts.length > 0 ? devStarts.sort()[0] : null;
const devCalendarEnd = devEnds.length > 0 ? devEnds.sort().reverse()[0] : null;
const devCalendarDays = devCalendarStart && devCalendarEnd ? Math.max(1, Math.ceil((new Date(devCalendarEnd).getTime() - new Date(devCalendarStart).getTime()) / (1000 * 60 * 60 * 24)) + 1) : 0;
const tcStarts = versionTCs.filter((c) => c.startedAt).map((c) => c.startedAt!); // 各阶段日历天数计算辅助函数
const tcEnds = versionTCs.filter((c) => c.completedAt).map((c) => c.completedAt!); const calcCalendar = (starts: string[], ends: string[]) => {
const tcCalendarStart = tcStarts.length > 0 ? tcStarts.sort()[0] : null; const sortedStarts = starts.filter(Boolean).sort();
const tcCalendarEnd = tcEnds.length > 0 ? tcEnds.sort().reverse()[0] : null; const sortedEnds = ends.filter(Boolean).sort().reverse();
const tcCalendarDays = tcCalendarStart && tcCalendarEnd ? Math.max(1, Math.ceil((new Date(tcCalendarEnd).getTime() - new Date(tcCalendarStart).getTime()) / (1000 * 60 * 60 * 24)) + 1) : 0; const start = sortedStarts[0] || null;
const end = sortedEnds[0] || null;
const days = start && end ? Math.max(1, Math.ceil((new Date(end).getTime() - new Date(start).getTime()) / (1000 * 60 * 60 * 24)) + 1) : 0;
return { start, end, days };
};
// 个人维度:每人耗时汇总(开发 + 测试) // 调研阶段
const personalHours = new Map<string, { dev: number; test: number }>(); const researchPlans = versionPlans.filter((p) => p.type === 'research');
const researchCal = calcCalendar(researchPlans.map((p) => p.startTime), researchPlans.filter((p) => p.status === 'completed').map((p) => p.endTime));
// 产品方案阶段
const productPlans = versionPlans.filter((p) => p.type === 'product');
const productCal = calcCalendar(productPlans.map((p) => p.startTime), productPlans.filter((p) => p.status === 'completed').map((p) => p.endTime));
// UI设计阶段
const uiPlans = versionPlans.filter((p) => p.type === 'ui');
const uiCal = calcCalendar(uiPlans.map((p) => p.startTime), uiPlans.filter((p) => p.status === 'completed').map((p) => p.endTime));
// 开发阶段
const devCal = calcCalendar(
versionDevTasks.filter((t) => t.startDate).map((t) => t.startDate!),
versionDevTasks.filter((t) => t.completedAt).map((t) => t.completedAt!),
);
// 测试阶段
const tcCal = calcCalendar(
versionTCs.filter((c) => c.startedAt).map((c) => c.startedAt!),
versionTCs.filter((c) => c.completedAt).map((c) => c.completedAt!),
);
const stages = [
{ label: '调研', ...researchCal, color: 'bg-orange-400' },
{ label: '产品方案', ...productCal, color: 'bg-pink-400' },
{ label: 'UI设计', ...uiCal, color: 'bg-indigo-400' },
{ label: '开发', ...devCal, color: 'bg-blue-400' },
{ label: '测试', ...tcCal, color: 'bg-purple-400' },
];
const maxDays = Math.max(...stages.map((s) => s.days), 1);
// 个人维度:每人耗时汇总
const personalHours = new Map<string, { research: number; product: number; ui: number; dev: number; test: number }>();
const addHours = (name: string, key: 'research' | 'product' | 'ui' | 'dev' | 'test', hours: number) => {
const prev = personalHours.get(name) || { research: 0, product: 0, ui: 0, dev: 0, test: 0 };
prev[key] += hours;
personalHours.set(name, prev);
};
// 调研/产品/UI 用 startTime→endTime 计算
versionPlans.forEach((p) => {
if (!p.owner || !p.startTime) return;
const end = p.status === 'completed' ? p.endTime : undefined;
const hours = calcActualHoursByDates(p.startTime.slice(0, 10), end?.slice(0, 10));
addHours(p.owner, p.type === 'research' ? 'research' : p.type === 'product' ? 'product' : 'ui', hours);
});
versionDevTasks.forEach((t) => { versionDevTasks.forEach((t) => {
if (!t.startDate || !t.assigneeId) return; if (!t.startDate || !t.assigneeId) return;
const hours = calcActualHoursByDates(t.startDate, t.completedAt); addHours(t.assigneeId, 'dev', calcActualHoursByDates(t.startDate, t.completedAt));
const prev = personalHours.get(t.assigneeId) || { dev: 0, test: 0 };
prev.dev += hours;
personalHours.set(t.assigneeId, prev);
}); });
versionTCs.forEach((c) => { versionTCs.forEach((c) => {
if (!c.startedAt || !c.assigneeId) return; if (!c.startedAt || !c.assigneeId) return;
const hours = calcActualHoursByDates(c.startedAt, c.completedAt); addHours(c.assigneeId, 'test', calcActualHoursByDates(c.startedAt, c.completedAt));
const prev = personalHours.get(c.assigneeId) || { dev: 0, test: 0 };
prev.test += hours;
personalHours.set(c.assigneeId, prev);
}); });
const personalRanking = Array.from(personalHours.entries()) const personalRanking = Array.from(personalHours.entries())
.map(([name, h]) => ({ name, dev: h.dev, test: h.test, total: h.dev + h.test })) .map(([name, h]) => ({ name, ...h, total: h.research + h.product + h.ui + h.dev + h.test }))
.sort((a, b) => b.total - a.total); .sort((a, b) => b.total - a.total);
const maxHours = personalRanking[0]?.total || 1; const maxHours = personalRanking[0]?.total || 1;
const totalHours = personalRanking.reduce((s, p) => s + p.total, 0);
return ( return (
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
{/* 阶段日历耗时 */} {/* 阶段日历耗时 */}
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4"> <div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="text-[11px] text-[var(--ink-muted)] mb-3 font-medium"></div> <div className="text-[11px] text-[var(--ink-muted)] mb-3 font-medium"></div>
<div className="space-y-3"> <div className="space-y-2.5">
<div> {stages.map((s) => (
<div key={s.label}>
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<span className="text-[12px] text-[var(--ink-soft)]"></span> <span className="text-[12px] text-[var(--ink-soft)]">{s.label}</span>
<span className="text-[12px] font-medium tabular-nums text-[var(--ink)]">{devCalendarDays > 0 ? `${devCalendarDays}` : '-'}</span> <span className="text-[12px] font-medium tabular-nums text-[var(--ink)]">{s.days > 0 ? `${s.days}` : '-'}</span>
</div> </div>
{devCalendarStart && <span className="text-[10px] text-[var(--ink-muted)]">{devCalendarStart} {devCalendarEnd || '进行中'}</span>} <div className="flex items-center gap-2">
<div className="flex-1 h-1.5 rounded-full bg-[var(--bg-subtle)] overflow-hidden">
<div className={`h-full rounded-full ${s.color}`} style={{ width: `${(s.days / maxDays) * 100}%` }} />
</div> </div>
<div> {s.start && <span className="text-[9px] text-[var(--ink-muted)] shrink-0 tabular-nums">{s.start.slice(5)}{s.end ? `${s.end.slice(5)}` : ' →'}</span>}
<div className="flex items-center justify-between mb-1">
<span className="text-[12px] text-[var(--ink-soft)]"></span>
<span className="text-[12px] font-medium tabular-nums text-[var(--ink)]">{tcCalendarDays > 0 ? `${tcCalendarDays}` : '-'}</span>
</div> </div>
{tcCalendarStart && <span className="text-[10px] text-[var(--ink-muted)]">{tcCalendarStart} {tcCalendarEnd || '进行中'}</span>}
</div> </div>
))}
<div className="pt-2 border-t border-[var(--line)]"> <div className="pt-2 border-t border-[var(--line)]">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-[12px] text-[var(--ink-soft)]"></span> <span className="text-[12px] text-[var(--ink-soft)]"></span>
<span className="text-[12px] font-medium tabular-nums text-[var(--ink)]">{personalRanking.reduce((s, p) => s + p.total, 0)}h{Math.round(personalRanking.reduce((s, p) => s + p.total, 0) / 8)}</span> <span className="text-[12px] font-medium tabular-nums text-[var(--ink)]">{totalHours}h{Math.round(totalHours / 8)}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -456,13 +497,19 @@ export default function VersionDetailPage() {
<span className="flex-1 text-[12px] text-[var(--ink)]">{item.name}</span> <span className="flex-1 text-[12px] text-[var(--ink)]">{item.name}</span>
<span className="text-[12px] font-medium tabular-nums text-[var(--ink-soft)]">{item.total}h</span> <span className="text-[12px] font-medium tabular-nums text-[var(--ink-soft)]">{item.total}h</span>
</div> </div>
<div className="ml-7 flex items-center gap-1 h-1.5"> <div className="ml-7 flex items-center gap-0.5 h-1.5">
{item.research > 0 && <div className="h-full rounded-full bg-orange-400" style={{ width: `${(item.research / maxHours) * 100}%` }} title={`调研 ${item.research}h`} />}
{item.product > 0 && <div className="h-full rounded-full bg-pink-400" style={{ width: `${(item.product / maxHours) * 100}%` }} title={`产品 ${item.product}h`} />}
{item.ui > 0 && <div className="h-full rounded-full bg-indigo-400" style={{ width: `${(item.ui / maxHours) * 100}%` }} title={`UI ${item.ui}h`} />}
{item.dev > 0 && <div className="h-full rounded-full bg-blue-400" style={{ width: `${(item.dev / maxHours) * 100}%` }} title={`开发 ${item.dev}h`} />} {item.dev > 0 && <div className="h-full rounded-full bg-blue-400" style={{ width: `${(item.dev / maxHours) * 100}%` }} title={`开发 ${item.dev}h`} />}
{item.test > 0 && <div className="h-full rounded-full bg-purple-400" style={{ width: `${(item.test / maxHours) * 100}%` }} title={`测试 ${item.test}h`} />} {item.test > 0 && <div className="h-full rounded-full bg-purple-400" style={{ width: `${(item.test / maxHours) * 100}%` }} title={`测试 ${item.test}h`} />}
</div> </div>
</div> </div>
))} ))}
<div className="flex items-center gap-3 pt-2 text-[10px] text-[var(--ink-muted)]"> <div className="flex items-center gap-3 pt-2 text-[10px] text-[var(--ink-muted)] flex-wrap">
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-orange-400" /></span>
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-pink-400" /></span>
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-indigo-400" />UI</span>
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-blue-400" /></span> <span className="flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-blue-400" /></span>
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-purple-400" /></span> <span className="flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-purple-400" /></span>
</div> </div>