feat: 版本概览增加阶段耗时(项目维度)+ 个人耗时排名

项目维度:
- 开发阶段日历天数 = min(startDate) → max(completedAt)
- 测试阶段日历天数 = min(startedAt) → max(completedAt)
- 总人天投入 = 所有人个人耗时之和

个人维度:
- 按人汇总开发耗时 + 测试耗时
- 排名展示,条形图区分开发(蓝)/测试(紫)
- 可对比出谁花费时间长

解决了多任务日期重叠时叠加计算不合理的问题:
项目看日历跨度,个人看独立累加。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Script Generator
2026-06-12 15:48:41 +08:00
parent 8b3b3b7e21
commit b3ccc17c2c

View File

@@ -25,7 +25,7 @@ import { useDevTaskStore } from '@/stores/useDevTaskStore';
import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useBugStore } from '@/stores/useBugStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { calcGroupProgress as calcDevTaskProgress } from '@/lib/dev-task';
import { calcGroupProgress as calcDevTaskProgress, calcActualHoursByDates } from '@/lib/dev-task';
const PRIORITY_STYLE: Record<string, string> = {
P0: 'bg-red-500/10 text-red-600',
@@ -377,6 +377,102 @@ export default function VersionDetailPage() {
</div>
</div>
{/* 阶段耗时 + 个人耗时排名 */}
{(() => {
// 项目维度:阶段日历天数
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 tcCalendarStart = tcStarts.length > 0 ? tcStarts.sort()[0] : null;
const tcCalendarEnd = tcEnds.length > 0 ? tcEnds.sort().reverse()[0] : null;
const tcCalendarDays = tcCalendarStart && tcCalendarEnd ? Math.max(1, Math.ceil((new Date(tcCalendarEnd).getTime() - new Date(tcCalendarStart).getTime()) / (1000 * 60 * 60 * 24)) + 1) : 0;
// 个人维度:每人耗时汇总(开发 + 测试)
const personalHours = new Map<string, { dev: number; test: number }>();
versionDevTasks.forEach((t) => {
if (!t.startDate || !t.assigneeId) return;
const hours = 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) => {
if (!c.startedAt || !c.assigneeId) return;
const hours = 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())
.map(([name, h]) => ({ name, dev: h.dev, test: h.test, total: h.dev + h.test }))
.sort((a, b) => b.total - a.total);
const maxHours = personalRanking[0]?.total || 1;
return (
<div className="grid grid-cols-2 gap-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="space-y-3">
<div>
<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)]">{devCalendarDays > 0 ? `${devCalendarDays}` : '-'}</span>
</div>
{devCalendarStart && <span className="text-[10px] text-[var(--ink-muted)]">{devCalendarStart} {devCalendarEnd || '进行中'}</span>}
</div>
<div>
<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>
{tcCalendarStart && <span className="text-[10px] text-[var(--ink-muted)]">{tcCalendarStart} {tcCalendarEnd || '进行中'}</span>}
</div>
<div className="pt-2 border-t border-[var(--line)]">
<div className="flex items-center justify-between">
<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>
</div>
</div>
</div>
</div>
{/* 个人耗时排名 */}
<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>
{personalRanking.length === 0 ? (
<span className="text-[12px] text-[var(--ink-muted)]"></span>
) : (
<div className="space-y-2">
{personalRanking.slice(0, 8).map((item, i) => (
<div key={item.name}>
<div className="flex items-center gap-2 mb-1">
<span className={`flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold ${i < 3 ? 'bg-[var(--accent-soft)] text-[var(--accent)]' : 'bg-[var(--bg-subtle)] text-[var(--ink-muted)]'}`}>{i + 1}</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>
</div>
<div className="ml-7 flex items-center gap-1 h-1.5">
{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`} />}
</div>
</div>
))}
<div className="flex items-center gap-3 pt-2 text-[10px] text-[var(--ink-muted)]">
<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>
</div>
</div>
)}
</div>
</div>
);
})()}
{/* 双栏:日期信息 + 相关链接 */}
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2 space-y-4">