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