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 { getProjectDetail, VersionWithContext } from '@/lib/derive';
|
||||||
import { Stage, Role, STAGES, ROLES, STAGE_INDEX, ROLE_LABEL } from '@/lib/stage';
|
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 { 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 { CapsuleStages } from '@/components/version/CapsuleStages';
|
||||||
import { MemberChips } from '@/components/version/MemberChips';
|
import { MemberChips } from '@/components/version/MemberChips';
|
||||||
import type { VersionPlan } from '@/lib/version-plan';
|
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) ─── */
|
/* ─── ProgressBar (for expanded released cards) ─── */
|
||||||
function ProgressBar({ role, percent, daysSpent }: { role: Role; percent: number; daysSpent: number }) {
|
function ProgressBar({ role, percent, daysSpent }: { role: Role; percent: number; daysSpent: number }) {
|
||||||
return (
|
return (
|
||||||
@@ -73,7 +94,7 @@ function VersionCard({ version, progress, plans, devTasks, testCases, bugs, requ
|
|||||||
if (p.actualStartAt) startDates.push(p.actualStartAt);
|
if (p.actualStartAt) startDates.push(p.actualStartAt);
|
||||||
else if (p.status === 'pending' && p.startTime && new Date(p.startTime) <= new Date()) startDates.push(p.startTime);
|
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); });
|
vTCs.forEach((c) => { if (c.startedAt) startDates.push(c.startedAt); });
|
||||||
|
|
||||||
const earliestStart = startDates.length > 0 ? startDates.sort()[0] : version.startDate;
|
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[] = [];
|
const endDates: string[] = [];
|
||||||
vPlans.forEach((p) => { if (p.completedAt) endDates.push(p.completedAt); });
|
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); });
|
vTCs.forEach((c) => { if (c.completedAt) endDates.push(c.completedAt); });
|
||||||
vBugs.forEach((b) => { if (b.closedAt) endDates.push(b.closedAt); });
|
vBugs.forEach((b) => { if (b.closedAt) endDates.push(b.closedAt); });
|
||||||
const actualEndDisplay = endDates.length > 0 ? endDates.sort().reverse()[0].slice(0, 10) : null;
|
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) {
|
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;
|
let devProgress: number;
|
||||||
if (totalEstimate === 0) {
|
if (totalEstimate === 0) {
|
||||||
devProgress = vDevTasks.reduce((sum, t) => sum + STATUS_PROGRESS[t.status], 0) / vDevTasks.length;
|
devProgress = vDevTasks.reduce((sum, t) => sum + STATUS_PROGRESS[t.status], 0) / vDevTasks.length;
|
||||||
} else {
|
} 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;
|
devProgress = weighted / totalEstimate;
|
||||||
}
|
}
|
||||||
segments.push(devProgress);
|
segments.push(devProgress);
|
||||||
@@ -416,14 +437,17 @@ export default function ProjectDetailPage() {
|
|||||||
}, [project, plans, requirements, devTasks, testCases]);
|
}, [project, plans, requirements, devTasks, testCases]);
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
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 total = project.versions.length;
|
||||||
const released = project.versions.filter((v) => v.status === 'released').length;
|
const released = project.versions.filter((v) => v.status === 'released').length;
|
||||||
const reqCount = requirements.filter((r) => r.projectId === projectId).length;
|
const reqCount = requirements.filter((r) => r.projectId === projectId).length;
|
||||||
const versionIds = new Set(project.versions.map((v) => v.id));
|
const versionIds = new Set(project.versions.map((v) => v.id));
|
||||||
const bugCount = bugs.filter((b) => versionIds.has(b.versionId)).length;
|
const bugCount = bugs.filter((b) => versionIds.has(b.versionId)).length;
|
||||||
return { total, released, reqCount, bugCount };
|
const projectReqIds = new Set(requirements.filter((r) => r.projectId === projectId).map((r) => r.id));
|
||||||
}, [project, requirements, bugs, projectId]);
|
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(() => {
|
const teamByRole = useMemo(() => {
|
||||||
if (!project) return {} as Record<string, Record<string, number>>;
|
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="flex-1 overflow-y-auto p-5 bg-[var(--bg)]">
|
||||||
<div className="space-y-5">
|
<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.total} label="总版本数" />
|
||||||
<StatCard value={stats.released} label="已开发" />
|
<StatCard value={stats.released} label="已开发" />
|
||||||
<StatCard value={stats.reqCount} label="需求数" />
|
<StatCard value={stats.reqCount} label="需求数" />
|
||||||
<StatCard value={stats.bugCount} label="Bug 总数" />
|
<StatCard value={stats.bugCount} label="Bug 总数" />
|
||||||
|
<HoursStatCard estimate={stats.estimateHours} actual={stats.actualHours} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TeamSection teamByRole={teamByRole} />
|
<TeamSection teamByRole={teamByRole} />
|
||||||
|
|||||||
@@ -26,7 +26,11 @@ import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
|||||||
import { useBugStore } from '@/stores/useBugStore';
|
import { useBugStore } from '@/stores/useBugStore';
|
||||||
import { useAuthStore } from '@/stores/useAuthStore';
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
import { useMemberStore } from '@/stores/useMemberStore';
|
import { useMemberStore } from '@/stores/useMemberStore';
|
||||||
import { calcGroupProgress as calcDevTaskProgress, calcActualHoursByDates } from '@/lib/dev-task';
|
import { calcGroupProgress as calcDevTaskProgress, getActualHours, getEstimateHours, aggregateDevTaskHours, devTaskIntervals } from '@/lib/dev-task';
|
||||||
|
import { calcWorkHours, formatWorkHours, calcTwoMetrics } from '@/lib/work-hours';
|
||||||
|
import { testCaseIntervals } from '@/lib/test-case';
|
||||||
|
import { bugIntervals } from '@/lib/bug';
|
||||||
|
import { planIntervals } from '@/lib/version-plan';
|
||||||
import { formatDateTime } from '@/lib/format';
|
import { formatDateTime } from '@/lib/format';
|
||||||
|
|
||||||
const PRIORITY_STYLE: Record<string, string> = {
|
const PRIORITY_STYLE: Record<string, string> = {
|
||||||
@@ -109,7 +113,7 @@ export default function VersionDetailPage() {
|
|||||||
if (p.actualStartAt) startDates.push(p.actualStartAt);
|
if (p.actualStartAt) startDates.push(p.actualStartAt);
|
||||||
else if (p.status === 'pending' && p.startTime && new Date(p.startTime) <= new Date()) startDates.push(p.startTime);
|
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); });
|
vTCs.forEach((c) => { if (c.startedAt) startDates.push(c.startedAt); });
|
||||||
|
|
||||||
if (startDates.length === 0 && !version.startDate) return 0;
|
if (startDates.length === 0 && !version.startDate) return 0;
|
||||||
@@ -396,14 +400,14 @@ export default function VersionDetailPage() {
|
|||||||
if (p.actualStartAt) startDates.push(p.actualStartAt);
|
if (p.actualStartAt) startDates.push(p.actualStartAt);
|
||||||
else if (p.status === 'pending' && p.startTime && new Date(p.startTime) <= new Date()) startDates.push(p.startTime);
|
else if (p.status === 'pending' && p.startTime && new Date(p.startTime) <= new Date()) startDates.push(p.startTime);
|
||||||
});
|
});
|
||||||
vDTs.forEach((t) => { if (t.startDate) startDates.push(t.startDate); });
|
vDTs.forEach((t) => { if (t.actualStartAt) startDates.push(t.actualStartAt); });
|
||||||
vTCsAll.forEach((c) => { if (c.startedAt) startDates.push(c.startedAt); });
|
vTCsAll.forEach((c) => { if (c.startedAt) startDates.push(c.startedAt); });
|
||||||
const actualStart = startDates.length > 0 ? formatDateTime(startDates.sort()[0]) : (version.startDate ?? null);
|
const actualStart = startDates.length > 0 ? formatDateTime(startDates.sort()[0]) : (version.startDate ?? null);
|
||||||
|
|
||||||
// 实际截止日期
|
// 实际截止日期
|
||||||
const endDates: string[] = [];
|
const endDates: string[] = [];
|
||||||
vPlansAll.forEach((p) => { if (p.completedAt) endDates.push(p.completedAt); });
|
vPlansAll.forEach((p) => { if (p.completedAt) endDates.push(p.completedAt); });
|
||||||
vDTs.forEach((t) => { if (t.completedAt) endDates.push(t.completedAt); });
|
vDTs.forEach((t) => { if (t.actualEndAt) endDates.push(t.actualEndAt); });
|
||||||
vTCsAll.forEach((c) => { if (c.completedAt) endDates.push(c.completedAt); });
|
vTCsAll.forEach((c) => { if (c.completedAt) endDates.push(c.completedAt); });
|
||||||
vBugsAll.forEach((b) => { if (b.closedAt) endDates.push(b.closedAt); });
|
vBugsAll.forEach((b) => { if (b.closedAt) endDates.push(b.closedAt); });
|
||||||
const actualEnd = endDates.length > 0 ? formatDateTime(endDates.sort().reverse()[0]) : null;
|
const actualEnd = endDates.length > 0 ? formatDateTime(endDates.sort().reverse()[0]) : null;
|
||||||
@@ -702,8 +706,8 @@ export default function VersionDetailPage() {
|
|||||||
|
|
||||||
// 开发阶段
|
// 开发阶段
|
||||||
const devCal = calcCalendar(
|
const devCal = calcCalendar(
|
||||||
versionDevTasks.filter((t) => t.startDate).map((t) => t.startDate!),
|
versionDevTasks.filter((t) => t.actualStartAt).map((t) => t.actualStartAt!),
|
||||||
versionDevTasks.filter((t) => t.completedAt).map((t) => t.completedAt!),
|
versionDevTasks.filter((t) => t.actualEndAt).map((t) => t.actualEndAt!),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 测试阶段
|
// 测试阶段
|
||||||
@@ -732,16 +736,17 @@ export default function VersionDetailPage() {
|
|||||||
// 调研/产品/UI 用 startTime→endTime 计算
|
// 调研/产品/UI 用 startTime→endTime 计算
|
||||||
versionPlans.forEach((p) => {
|
versionPlans.forEach((p) => {
|
||||||
if (!p.owner || !p.actualStartAt) return;
|
if (!p.owner || !p.actualStartAt) return;
|
||||||
const hours = calcActualHoursByDates(p.actualStartAt, p.completedAt);
|
const hours = calcWorkHours(p.actualStartAt, p.completedAt ?? new Date().toISOString());
|
||||||
addHours(p.owner, p.type === 'research' ? 'research' : p.type === 'product' ? 'product' : 'ui', hours);
|
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.actualStartAt || !t.assigneeId) return;
|
||||||
addHours(t.assigneeId, 'dev', calcActualHoursByDates(t.startDate, t.completedAt));
|
const endIso = t.actualEndAt ?? new Date().toISOString();
|
||||||
|
addHours(t.assigneeId, 'dev', calcWorkHours(t.actualStartAt, endIso));
|
||||||
});
|
});
|
||||||
versionTCs.forEach((c) => {
|
versionTCs.forEach((c) => {
|
||||||
if (!c.startedAt || !c.assigneeId) return;
|
if (!c.startedAt || !c.assigneeId) return;
|
||||||
addHours(c.assigneeId, 'test', calcActualHoursByDates(c.startedAt, c.completedAt));
|
addHours(c.assigneeId, 'test', calcWorkHours(c.startedAt, c.completedAt ?? new Date().toISOString()));
|
||||||
});
|
});
|
||||||
|
|
||||||
const personalRanking = Array.from(personalHours.entries())
|
const personalRanking = Array.from(personalHours.entries())
|
||||||
@@ -750,6 +755,15 @@ export default function VersionDetailPage() {
|
|||||||
const maxHours = personalRanking[0]?.total || 1;
|
const maxHours = personalRanking[0]?.total || 1;
|
||||||
const totalHours = personalRanking.reduce((s, p) => s + p.total, 0);
|
const totalHours = personalRanking.reduce((s, p) => s + p.total, 0);
|
||||||
|
|
||||||
|
// 双口径:日历总耗时(合并区间)+ 人力总投入(即 totalHours)
|
||||||
|
const allIntervals = [
|
||||||
|
...planIntervals(versionPlans),
|
||||||
|
...devTaskIntervals(versionDevTasks),
|
||||||
|
...testCaseIntervals(versionTCs),
|
||||||
|
...bugIntervals(versionBugs),
|
||||||
|
];
|
||||||
|
const { calendarHours: versionCalendarHours } = calcTwoMetrics(allIntervals);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{/* 阶段日历耗时 */}
|
{/* 阶段日历耗时 */}
|
||||||
@@ -770,10 +784,14 @@ export default function VersionDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="pt-2 border-t border-[var(--line)]">
|
<div className="pt-2 border-t border-[var(--line)] space-y-1.5">
|
||||||
<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)]">{totalHours}h({Math.round(totalHours / 8)}人天)</span>
|
<span className="text-[12px] font-medium tabular-nums text-[var(--ink)]">{formatWorkHours(versionCalendarHours)}</span>
|
||||||
|
</div>
|
||||||
|
<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)]">{formatWorkHours(totalHours)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -794,11 +812,11 @@ export default function VersionDetailPage() {
|
|||||||
<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-0.5 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.research > 0 && <div className="h-full rounded-full bg-orange-400" style={{ width: `${(item.research / maxHours) * 100}%` }} title={`调研 ${formatWorkHours(item.research)}`} />}
|
||||||
{item.product > 0 && <div className="h-full rounded-full bg-pink-400" style={{ width: `${(item.product / maxHours) * 100}%` }} title={`产品 ${item.product}h`} />}
|
{item.product > 0 && <div className="h-full rounded-full bg-pink-400" style={{ width: `${(item.product / maxHours) * 100}%` }} title={`产品 ${formatWorkHours(item.product)}`} />}
|
||||||
{item.ui > 0 && <div className="h-full rounded-full bg-indigo-400" style={{ width: `${(item.ui / maxHours) * 100}%` }} title={`UI ${item.ui}h`} />}
|
{item.ui > 0 && <div className="h-full rounded-full bg-indigo-400" style={{ width: `${(item.ui / maxHours) * 100}%` }} title={`UI ${formatWorkHours(item.ui)}`} />}
|
||||||
{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={`开发 ${formatWorkHours(item.dev)}`} />}
|
||||||
{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={`测试 ${formatWorkHours(item.test)}`} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import type { VersionWithContext } from '@/lib/derive';
|
|||||||
import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_DOT, VERSION_STATUS_BG, getVersionDisplayStatus } from '@/lib/version-status';
|
import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_DOT, VERSION_STATUS_BG, getVersionDisplayStatus } from '@/lib/version-status';
|
||||||
import { STAGES } from '@/lib/stage';
|
import { STAGES } from '@/lib/stage';
|
||||||
import { ROLE_LABEL } from '@/lib/stage';
|
import { ROLE_LABEL } from '@/lib/stage';
|
||||||
import { STATUS_PROGRESS } from '@/lib/dev-task';
|
import { STATUS_PROGRESS, getEstimateHours } from '@/lib/dev-task';
|
||||||
import { calcHealthScore, getHealthLevel, calcRiskTags, HEALTH_LEVEL_COLOR, HEALTH_LEVEL_DOT, getTagStyle } from '@/lib/health';
|
import { calcHealthScore, getHealthLevel, calcRiskTags, HEALTH_LEVEL_COLOR, HEALTH_LEVEL_DOT, getTagStyle } from '@/lib/health';
|
||||||
import { Pagination, usePagination } from '@/components/Pagination';
|
import { Pagination, usePagination } from '@/components/Pagination';
|
||||||
|
|
||||||
@@ -189,12 +189,12 @@ export default function VersionsPage() {
|
|||||||
|
|
||||||
// Dev tasks progress (weighted by STATUS_PROGRESS)
|
// Dev tasks progress (weighted by STATUS_PROGRESS)
|
||||||
if (vDevTasks.length > 0) {
|
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;
|
let devProgress: number;
|
||||||
if (totalEstimate === 0) {
|
if (totalEstimate === 0) {
|
||||||
devProgress = vDevTasks.reduce((sum, t) => sum + STATUS_PROGRESS[t.status], 0) / vDevTasks.length;
|
devProgress = vDevTasks.reduce((sum, t) => sum + STATUS_PROGRESS[t.status], 0) / vDevTasks.length;
|
||||||
} else {
|
} 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;
|
devProgress = weighted / totalEstimate;
|
||||||
}
|
}
|
||||||
segments.push(devProgress);
|
segments.push(devProgress);
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ import { DevTaskDetailDrawer } from '@/components/dev-task/DevTaskDetailDrawer';
|
|||||||
import { TestCaseDetailDrawer } from '@/components/test-case/TestCaseDetailDrawer';
|
import { TestCaseDetailDrawer } from '@/components/test-case/TestCaseDetailDrawer';
|
||||||
import { BugDetailDrawer } from '@/components/bug/BugDetailDrawer';
|
import { BugDetailDrawer } from '@/components/bug/BugDetailDrawer';
|
||||||
import { BugCreateModal } from '@/components/bug/BugCreateModal';
|
import { BugCreateModal } from '@/components/bug/BugCreateModal';
|
||||||
import { DEV_TASK_STATUS_LABEL, DEV_TASK_STATUS_COLOR } from '@/lib/dev-task';
|
import { DEV_TASK_STATUS_LABEL, DEV_TASK_STATUS_COLOR, getEstimateHours, getActualHours } from '@/lib/dev-task';
|
||||||
import { formatDateTime } from '@/lib/format';
|
import { formatDateTime } from '@/lib/format';
|
||||||
|
import { formatShortTime, formatWorkHours } from '@/lib/work-hours';
|
||||||
import { TEST_CASE_STATUS_LABEL, TEST_CASE_STATUS_COLOR } from '@/lib/test-case';
|
import { TEST_CASE_STATUS_LABEL, TEST_CASE_STATUS_COLOR } from '@/lib/test-case';
|
||||||
import { BUG_STATUS_LABEL, BUG_STATUS_COLOR, BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug';
|
import { BUG_STATUS_LABEL, BUG_STATUS_COLOR, BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug';
|
||||||
|
|
||||||
@@ -293,6 +294,18 @@ function ProjectNode({ name, versions, selectedVersionId, onSelect }: {
|
|||||||
|
|
||||||
function WorkItemCard({ item, onNavigate, onClick }: { item: WorkItem; onNavigate: () => void; onClick: () => void }) {
|
function WorkItemCard({ item, onNavigate, onClick }: { item: WorkItem; onNavigate: () => void; onClick: () => void }) {
|
||||||
const statusBadge = getStatusBadge(item);
|
const statusBadge = getStatusBadge(item);
|
||||||
|
const isDevTask = item.type === 'devTask';
|
||||||
|
const devTaskRaw = isDevTask ? (item.raw as any) : null;
|
||||||
|
const devEstimate = devTaskRaw ? getEstimateHours(devTaskRaw) : 0;
|
||||||
|
const devActual = devTaskRaw ? getActualHours(devTaskRaw) : 0;
|
||||||
|
const devOverrun = devActual > devEstimate && devEstimate > 0;
|
||||||
|
const devTimeRange = devTaskRaw ? (
|
||||||
|
devTaskRaw.actualStartAt
|
||||||
|
? `实际 ${formatShortTime(devTaskRaw.actualStartAt)} → ${devTaskRaw.actualEndAt ? formatShortTime(devTaskRaw.actualEndAt) : '进行中'}`
|
||||||
|
: (devTaskRaw.expectedStartAt && devTaskRaw.expectedEndAt
|
||||||
|
? `预计 ${formatShortTime(devTaskRaw.expectedStartAt)} → ${formatShortTime(devTaskRaw.expectedEndAt)}`
|
||||||
|
: '')
|
||||||
|
) : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div onClick={onClick} className={`rounded-xl border border-[var(--line)] bg-[var(--bg-card)] px-4 py-3 transition-colors hover:border-[var(--accent)] cursor-pointer ${item.completed ? 'opacity-60' : ''}`}>
|
<div onClick={onClick} className={`rounded-xl border border-[var(--line)] bg-[var(--bg-card)] px-4 py-3 transition-colors hover:border-[var(--accent)] cursor-pointer ${item.completed ? 'opacity-60' : ''}`}>
|
||||||
@@ -322,7 +335,16 @@ function WorkItemCard({ item, onNavigate, onClick }: { item: WorkItem; onNavigat
|
|||||||
<span>{item.projectName}</span>
|
<span>{item.projectName}</span>
|
||||||
<span className="text-[var(--line)]">/</span>
|
<span className="text-[var(--line)]">/</span>
|
||||||
<button onClick={(e) => { e.stopPropagation(); onNavigate(); }} className="text-[var(--accent)] hover:underline">{item.versionName}</button>
|
<button onClick={(e) => { e.stopPropagation(); onNavigate(); }} className="text-[var(--accent)] hover:underline">{item.versionName}</button>
|
||||||
{item.extra?.dueDate && <span className="ml-2">截止 {item.extra.dueDate}</span>}
|
{isDevTask && devTimeRange && (
|
||||||
|
<>
|
||||||
|
<span className="ml-2 tabular-nums">{devTimeRange}</span>
|
||||||
|
{devEstimate > 0 && (
|
||||||
|
<span className={`ml-2 tabular-nums ${devActual > 0 ? (devOverrun ? 'text-red-600' : (devActual < devEstimate ? 'text-emerald-600' : '')) : ''}`}>
|
||||||
|
{devActual > 0 ? `${formatWorkHours(devActual)} / ${formatWorkHours(devEstimate)}` : `预 ${formatWorkHours(devEstimate)}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{item.extra?.startTime && <span className="ml-2">{formatDateTime(item.extra.startTime)} → {formatDateTime(item.extra.endTime)}</span>}
|
{item.extra?.startTime && <span className="ml-2">{formatDateTime(item.extra.startTime)} → {formatDateTime(item.extra.endTime)}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
48
apps/web/components/SearchInput.tsx
Normal file
48
apps/web/components/SearchInput.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
'use client';
|
||||||
|
import { Search, X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表搜索框(标题 + 编号模糊匹配,外部需配合 useDebouncedValue 防抖)
|
||||||
|
*/
|
||||||
|
export function SearchInput({ value, onChange, placeholder = '搜索标题或编号', className = '' }: Props) {
|
||||||
|
return (
|
||||||
|
<div className={`relative inline-flex items-center ${className}`}>
|
||||||
|
<Search className="absolute left-2 h-3 w-3 text-[var(--ink-muted)] pointer-events-none" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="h-6 w-72 pl-7 pr-6 rounded border border-[var(--line)] bg-[var(--bg-card)] text-[11px] text-[var(--ink)] focus:border-[var(--accent)] focus:outline-none"
|
||||||
|
/>
|
||||||
|
{value && (
|
||||||
|
<button
|
||||||
|
onClick={() => onChange('')}
|
||||||
|
className="absolute right-1 p-0.5 rounded hover:bg-[var(--bg-subtle)]"
|
||||||
|
title="清除"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3 text-[var(--ink-muted)]" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标题 + 编号模糊匹配(case-insensitive)
|
||||||
|
*/
|
||||||
|
export function matchTitleOrNo(item: { title: string; no?: string }, keyword: string): boolean {
|
||||||
|
if (!keyword) return true;
|
||||||
|
const k = keyword.trim().toLowerCase();
|
||||||
|
if (!k) return true;
|
||||||
|
if (item.title.toLowerCase().includes(k)) return true;
|
||||||
|
if (item.no && item.no.toLowerCase().includes(k)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { memo } from 'react';
|
||||||
import { BugStatusBadge } from './BugStatusBadge';
|
import { BugStatusBadge } from './BugStatusBadge';
|
||||||
import { BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug';
|
import { BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR, getBugActualHours } from '@/lib/bug';
|
||||||
|
import { formatWorkHours } from '@/lib/work-hours';
|
||||||
import type { Bug } from '@/lib/bug';
|
import type { Bug } from '@/lib/bug';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -17,16 +19,22 @@ const PRIORITY_DOT: Record<string, string> = {
|
|||||||
P3: 'bg-zinc-300',
|
P3: 'bg-zinc-300',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BugRow({ bug, testCaseNo, onClick }: Props) {
|
function BugRowImpl({ bug, testCaseNo, onClick }: Props) {
|
||||||
|
const actualHours = getBugActualHours(bug);
|
||||||
return (
|
return (
|
||||||
<div onClick={onClick} className="flex items-center gap-3 px-4 py-2.5 border-b border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer transition-colors last:border-b-0">
|
<div onClick={onClick} className="flex items-center gap-3 px-4 py-2.5 border-b border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer transition-colors last:border-b-0">
|
||||||
<span className={`h-2 w-2 rounded-full shrink-0 ${PRIORITY_DOT[bug.priority] || 'bg-zinc-300'}`} />
|
<span className={`h-2 w-2 rounded-full shrink-0 ${PRIORITY_DOT[bug.priority] || 'bg-zinc-300'}`} />
|
||||||
<span className="text-[11px] font-mono text-[var(--ink-muted)] w-16 shrink-0">{bug.bugNo}</span>
|
<span className="text-[11px] font-mono text-[var(--ink-muted)] w-16 shrink-0">{bug.bugNo}</span>
|
||||||
<span className="text-[13px] text-[var(--ink)] flex-1 truncate">{bug.title}</span>
|
<span className="text-[13px] text-[var(--ink)] flex-1 truncate">{bug.title}</span>
|
||||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${BUG_SEVERITY_COLOR[bug.severity]}`}>{BUG_SEVERITY_LABEL[bug.severity]}</span>
|
<span className={`text-[10px] px-1.5 py-0.5 rounded shrink-0 ${BUG_SEVERITY_COLOR[bug.severity]}`}>{BUG_SEVERITY_LABEL[bug.severity]}</span>
|
||||||
<BugStatusBadge status={bug.status} />
|
<BugStatusBadge status={bug.status} />
|
||||||
{testCaseNo && <span className="text-[10px] font-mono text-[var(--ink-muted)] w-14 text-right">{testCaseNo}</span>}
|
{actualHours > 0 && (
|
||||||
<span className="text-[11px] text-[var(--ink-soft)] w-14 text-right truncate">{bug.assigneeId}</span>
|
<span className="text-[11px] text-[var(--ink-muted)] tabular-nums w-28 text-right shrink-0 whitespace-nowrap">{formatWorkHours(actualHours)}</span>
|
||||||
|
)}
|
||||||
|
{testCaseNo && <span className="text-[10px] font-mono text-[var(--ink-muted)] w-14 text-right shrink-0">{testCaseNo}</span>}
|
||||||
|
<span className="text-[11px] text-[var(--ink-soft)] w-14 text-right truncate shrink-0">{bug.assigneeId}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const BugRow = memo(BugRowImpl);
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import { useAuthStore } from '@/stores/useAuthStore';
|
|||||||
import { BugRow } from './BugRow';
|
import { BugRow } from './BugRow';
|
||||||
import { BugDetailDrawer } from './BugDetailDrawer';
|
import { BugDetailDrawer } from './BugDetailDrawer';
|
||||||
import { Pagination, usePagination } from '@/components/Pagination';
|
import { Pagination, usePagination } from '@/components/Pagination';
|
||||||
import { BUG_STATUS_LABEL, BUG_SEVERITY_LABEL } from '@/lib/bug';
|
import { SearchInput, matchTitleOrNo } from '@/components/SearchInput';
|
||||||
|
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||||
|
import { BUG_STATUS_LABEL, BUG_SEVERITY_LABEL, bugIntervals } from '@/lib/bug';
|
||||||
|
import { formatWorkHours, calcTwoMetrics } from '@/lib/work-hours';
|
||||||
import type { BugStatus, BugSeverity } from '@/lib/bug';
|
import type { BugStatus, BugSeverity } from '@/lib/bug';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -26,48 +29,50 @@ export function BugTab({ versionId, requirementIds }: Props) {
|
|||||||
useEffect(() => { fetchBugs(); }, [fetchBugs]);
|
useEffect(() => { fetchBugs(); }, [fetchBugs]);
|
||||||
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
|
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
|
||||||
|
|
||||||
// 该版本的所有 Bug(直接通过 versionId 查询)
|
|
||||||
const versionBugs = useMemo(
|
const versionBugs = useMemo(
|
||||||
() => bugs.filter((b) => b.versionId === versionId),
|
() => bugs.filter((b) => b.versionId === versionId),
|
||||||
[bugs, versionId],
|
[bugs, versionId],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 统计
|
|
||||||
const openCount = versionBugs.filter((b) => b.status === 'open').length;
|
const openCount = versionBugs.filter((b) => b.status === 'open').length;
|
||||||
const fixingCount = versionBugs.filter((b) => b.status === 'fixing').length;
|
const fixingCount = versionBugs.filter((b) => b.status === 'fixing').length;
|
||||||
const fixedCount = versionBugs.filter((b) => b.status === 'fixed' || b.status === 'verifying').length;
|
const fixedCount = versionBugs.filter((b) => b.status === 'fixed' || b.status === 'verifying').length;
|
||||||
const closedCount = versionBugs.filter((b) => b.status === 'closed' || b.status === 'rejected').length;
|
const closedCount = versionBugs.filter((b) => b.status === 'closed' || b.status === 'rejected').length;
|
||||||
const criticalCount = versionBugs.filter((b) => b.severity === 'critical' && b.status !== 'closed' && b.status !== 'rejected').length;
|
const criticalCount = versionBugs.filter((b) => b.severity === 'critical' && b.status !== 'closed' && b.status !== 'rejected').length;
|
||||||
const resolveRate = versionBugs.length > 0 ? Math.round(((fixedCount + closedCount) / versionBugs.length) * 100) : 0;
|
const resolveRate = versionBugs.length > 0 ? Math.round(((fixedCount + closedCount) / versionBugs.length) * 100) : 0;
|
||||||
|
const { calendarHours: bugCalendarHours, manhours: bugManhours } = useMemo(() => calcTwoMetrics(bugIntervals(versionBugs)), [versionBugs]);
|
||||||
|
|
||||||
// 筛选
|
|
||||||
const [filterAssignee, setFilterAssignee] = useState('');
|
const [filterAssignee, setFilterAssignee] = useState('');
|
||||||
const [filterStatus, setFilterStatus] = useState('');
|
const [filterStatus, setFilterStatus] = useState('');
|
||||||
const [filterSeverity, setFilterSeverity] = useState('');
|
const [filterSeverity, setFilterSeverity] = useState('');
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const debouncedKeyword = useDebouncedValue(keyword, 300);
|
||||||
|
|
||||||
const filteredBugs = useMemo(() => {
|
const filteredBugs = useMemo(() => {
|
||||||
let result = versionBugs;
|
let result = versionBugs;
|
||||||
if (filterAssignee) result = result.filter((b) => b.assigneeId === filterAssignee);
|
if (filterAssignee) result = result.filter((b) => b.assigneeId === filterAssignee);
|
||||||
if (filterStatus) result = result.filter((b) => b.status === filterStatus);
|
if (filterStatus) result = result.filter((b) => b.status === filterStatus);
|
||||||
if (filterSeverity) result = result.filter((b) => b.severity === filterSeverity);
|
if (filterSeverity) result = result.filter((b) => b.severity === filterSeverity);
|
||||||
|
if (debouncedKeyword) result = result.filter((b) => matchTitleOrNo({ title: b.title, no: b.bugNo }, debouncedKeyword));
|
||||||
return result;
|
return result;
|
||||||
}, [versionBugs, filterAssignee, filterStatus, filterSeverity]);
|
}, [versionBugs, filterAssignee, filterStatus, filterSeverity, debouncedKeyword]);
|
||||||
|
|
||||||
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filteredBugs, 20);
|
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filteredBugs, 20);
|
||||||
|
|
||||||
const [selectedBugId, setSelectedBugId] = useState<string | null>(null);
|
const [selectedBugId, setSelectedBugId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const testCaseMap = useMemo(() => new Map(testCases.map((c) => [c.id, c])), [testCases]);
|
||||||
|
|
||||||
const assignees = useMemo(() => {
|
const assignees = useMemo(() => {
|
||||||
const names = new Set(versionBugs.map((b) => b.assigneeId));
|
const names = new Set(versionBugs.map((b) => b.assigneeId));
|
||||||
return Array.from(names);
|
return Array.from(names);
|
||||||
}, [versionBugs]);
|
}, [versionBugs]);
|
||||||
|
|
||||||
const hasFilter = !!(filterAssignee || filterStatus || filterSeverity);
|
const hasFilter = !!(filterAssignee || filterStatus || filterSeverity || debouncedKeyword);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* 统计栏 */}
|
<div className="flex items-center gap-4 px-4 py-3 rounded-lg bg-[var(--bg-subtle)] border border-[var(--line)] flex-wrap">
|
||||||
<div className="flex items-center gap-4 px-4 py-3 rounded-lg bg-[var(--bg-subtle)] border border-[var(--line)]">
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BugIcon className="h-4 w-4 text-red-500" />
|
<BugIcon className="h-4 w-4 text-red-500" />
|
||||||
<span className="text-[13px] font-medium text-[var(--ink)]">Bug {versionBugs.length} 个</span>
|
<span className="text-[13px] font-medium text-[var(--ink)]">Bug {versionBugs.length} 个</span>
|
||||||
@@ -79,11 +84,14 @@ export function BugTab({ versionId, requirementIds }: Props) {
|
|||||||
<span className="text-[11px] text-red-600 bg-red-50 px-1.5 py-0.5 rounded font-medium">{criticalCount} 致命</span>
|
<span className="text-[11px] text-red-600 bg-red-50 px-1.5 py-0.5 rounded font-medium">{criticalCount} 致命</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-[11px] text-[var(--ink-muted)]">解决率 {resolveRate}%</span>
|
<span className="text-[11px] text-[var(--ink-muted)]">解决率 {resolveRate}%</span>
|
||||||
|
{bugManhours > 0 && (
|
||||||
|
<span className="text-[11px] text-[var(--ink-muted)]">日历 {formatWorkHours(bugCalendarHours)} / 人力 {formatWorkHours(bugManhours)}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 筛选 */}
|
<div className="flex items-center gap-2 px-1 flex-wrap">
|
||||||
<div className="flex items-center gap-2 px-1">
|
|
||||||
<Filter className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
|
<Filter className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
|
||||||
|
<SearchInput value={keyword} onChange={(v) => { setKeyword(v); setPage(1); }} placeholder="搜索标题/编号" />
|
||||||
<select value={filterAssignee} onChange={(e) => { setFilterAssignee(e.target.value); setPage(1); }} className="h-7 rounded-md border border-[var(--line)] bg-[var(--bg-card)] px-2 text-[11px] text-[var(--ink-soft)] focus:border-[var(--accent)] focus:outline-none">
|
<select value={filterAssignee} onChange={(e) => { setFilterAssignee(e.target.value); setPage(1); }} className="h-7 rounded-md border border-[var(--line)] bg-[var(--bg-card)] px-2 text-[11px] text-[var(--ink-soft)] focus:border-[var(--accent)] focus:outline-none">
|
||||||
<option value="">全部修复人</option>
|
<option value="">全部修复人</option>
|
||||||
{user?.name && <option value={user.name}>我的Bug</option>}
|
{user?.name && <option value={user.name}>我的Bug</option>}
|
||||||
@@ -97,11 +105,10 @@ export function BugTab({ versionId, requirementIds }: Props) {
|
|||||||
<option value="">全部严重程度</option>
|
<option value="">全部严重程度</option>
|
||||||
{(Object.entries(BUG_SEVERITY_LABEL) as [BugSeverity, string][]).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
{(Object.entries(BUG_SEVERITY_LABEL) as [BugSeverity, string][]).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||||
</select>
|
</select>
|
||||||
{hasFilter && <button onClick={() => { setFilterAssignee(''); setFilterStatus(''); setFilterSeverity(''); setPage(1); }} className="text-[11px] text-[var(--accent)] hover:underline">清除</button>}
|
{hasFilter && <button onClick={() => { setFilterAssignee(''); setFilterStatus(''); setFilterSeverity(''); setKeyword(''); setPage(1); }} className="text-[11px] text-[var(--accent)] hover:underline">清除</button>}
|
||||||
<span className="ml-auto text-[11px] text-[var(--ink-muted)]">{filteredBugs.length} 条</span>
|
<span className="ml-auto text-[11px] text-[var(--ink-muted)]">{filteredBugs.length} 条</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 列表 */}
|
|
||||||
{filteredBugs.length === 0 ? (
|
{filteredBugs.length === 0 ? (
|
||||||
<div className="rounded-xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] p-12 text-center">
|
<div className="rounded-xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] p-12 text-center">
|
||||||
<p className="text-[13px] text-[var(--ink-muted)]">{hasFilter ? '没有匹配的 Bug' : '暂无 Bug,很好'}</p>
|
<p className="text-[13px] text-[var(--ink-muted)]">{hasFilter ? '没有匹配的 Bug' : '暂无 Bug,很好'}</p>
|
||||||
@@ -109,7 +116,7 @@ export function BugTab({ versionId, requirementIds }: Props) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
|
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
|
||||||
{paged.map((bug) => {
|
{paged.map((bug) => {
|
||||||
const tc = testCases.find((c) => c.id === bug.testCaseId);
|
const tc = testCaseMap.get(bug.testCaseId);
|
||||||
return <BugRow key={bug.id} bug={bug} testCaseNo={tc?.caseNo} onClick={() => setSelectedBugId(bug.id)} />;
|
return <BugRow key={bug.id} bug={bug} testCaseNo={tc?.caseNo} onClick={() => setSelectedBugId(bug.id)} />;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
import { X, AlertTriangle } from 'lucide-react';
|
import { X, AlertTriangle } from 'lucide-react';
|
||||||
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
||||||
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
||||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||||
import { useMemberStore } from '@/stores/useMemberStore';
|
import { useMemberStore } from '@/stores/useMemberStore';
|
||||||
import { useAuthStore } from '@/stores/useAuthStore';
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
|
import { calcWorkHours, formatWorkHours, isoToLocal, localToISO } from '@/lib/work-hours';
|
||||||
import type { Priority } from '@/lib/derive';
|
import type { Priority } from '@/lib/derive';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -17,6 +18,18 @@ interface Props {
|
|||||||
onCreated?: () => void;
|
onCreated?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function defaultExpectedStart(): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(9, 0, 0, 0);
|
||||||
|
return isoToLocal(d.toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultExpectedEnd(): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(18, 0, 0, 0);
|
||||||
|
return isoToLocal(d.toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline, onClose, onCreated }: Props) {
|
export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline, onClose, onCreated }: Props) {
|
||||||
const { createTask, tasks } = useDevTaskStore();
|
const { createTask, tasks } = useDevTaskStore();
|
||||||
const { categories } = useTaskCategoryStore();
|
const { categories } = useTaskCategoryStore();
|
||||||
@@ -39,16 +52,46 @@ export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline,
|
|||||||
const [assigneeId, setAssigneeId] = useState(user?.name || '');
|
const [assigneeId, setAssigneeId] = useState(user?.name || '');
|
||||||
const [priority, setPriority] = useState<Priority>(versionReqs[0]?.priority || 'P2');
|
const [priority, setPriority] = useState<Priority>(versionReqs[0]?.priority || 'P2');
|
||||||
const [priorityManuallySet, setPriorityManuallySet] = useState(false);
|
const [priorityManuallySet, setPriorityManuallySet] = useState(false);
|
||||||
const [estimateHours, setEstimateHours] = useState<number>(8);
|
const [expectedStartLocal, setExpectedStartLocal] = useState(defaultExpectedStart);
|
||||||
const [dueDate, setDueDate] = useState('');
|
const [expectedEndLocal, setExpectedEndLocal] = useState(defaultExpectedEnd);
|
||||||
const [predecessorIds, setPredecessorIds] = useState<string[]>([]);
|
const [predecessorIds, setPredecessorIds] = useState<string[]>([]);
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
const [overdueReason, setOverdueReason] = useState('');
|
const [overdueVersionReason, setOverdueVersionReason] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (categories[0] && !categoryId) setCategoryId(categories[0].id);
|
||||||
|
}, [categories, categoryId]);
|
||||||
|
|
||||||
|
const expectedStartISO = localToISO(expectedStartLocal);
|
||||||
|
const expectedEndISO = localToISO(expectedEndLocal);
|
||||||
|
|
||||||
|
const estimateHours = useMemo(() => {
|
||||||
|
if (!expectedStartISO || !expectedEndISO) return 0;
|
||||||
|
return calcWorkHours(expectedStartISO, expectedEndISO);
|
||||||
|
}, [expectedStartISO, expectedEndISO]);
|
||||||
|
|
||||||
const selectedReq = versionReqs.find((r) => r.id === requirementId);
|
const selectedReq = versionReqs.find((r) => r.id === requirementId);
|
||||||
const effectivePriority = priorityManuallySet ? priority : (selectedReq?.priority || priority);
|
const effectivePriority = priorityManuallySet ? priority : (selectedReq?.priority || priority);
|
||||||
const isOverdue = !!(dueDate && versionDeadline && dueDate > versionDeadline);
|
|
||||||
const canSubmit = title.trim() && requirementId && categoryId && assigneeId && estimateHours > 0 && (!isOverdue || overdueReason.trim());
|
const versionDeadlineISO = useMemo(() => {
|
||||||
|
if (!versionDeadline) return null;
|
||||||
|
const d = new Date(versionDeadline);
|
||||||
|
if (isNaN(d.getTime())) return null;
|
||||||
|
d.setHours(23, 59, 59, 999);
|
||||||
|
return d.toISOString();
|
||||||
|
}, [versionDeadline]);
|
||||||
|
|
||||||
|
const isOverdueVersion = !!(expectedEndISO && versionDeadlineISO && expectedEndISO > versionDeadlineISO);
|
||||||
|
const startBeforeEnd = expectedStartISO && expectedEndISO && expectedStartISO < expectedEndISO;
|
||||||
|
|
||||||
|
const canSubmit =
|
||||||
|
title.trim() &&
|
||||||
|
requirementId &&
|
||||||
|
categoryId &&
|
||||||
|
assigneeId &&
|
||||||
|
!!startBeforeEnd &&
|
||||||
|
estimateHours > 0 &&
|
||||||
|
(!isOverdueVersion || overdueVersionReason.trim());
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!canSubmit) return;
|
if (!canSubmit) return;
|
||||||
@@ -60,16 +103,17 @@ export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline,
|
|||||||
assigneeId,
|
assigneeId,
|
||||||
reviewerId: undefined,
|
reviewerId: undefined,
|
||||||
priority: effectivePriority,
|
priority: effectivePriority,
|
||||||
estimateHours,
|
expectedStartAt: expectedStartISO,
|
||||||
startDate: undefined,
|
expectedEndAt: expectedEndISO,
|
||||||
dueDate: dueDate || undefined,
|
actualStartAt: undefined,
|
||||||
completedAt: undefined,
|
actualEndAt: undefined,
|
||||||
status: 'todo',
|
status: 'todo',
|
||||||
blockReason: undefined,
|
blockReason: undefined,
|
||||||
blockedById: undefined,
|
blockedById: undefined,
|
||||||
predecessorIds: predecessorIds.length > 0 ? predecessorIds : undefined,
|
predecessorIds: predecessorIds.length > 0 ? predecessorIds : undefined,
|
||||||
riskLevel: undefined,
|
riskLevel: undefined,
|
||||||
overdueReason: isOverdue ? overdueReason.trim() : undefined,
|
delayReason: undefined,
|
||||||
|
overdueVersionReason: isOverdueVersion ? overdueVersionReason.trim() : undefined,
|
||||||
createdBy: user?.name || '系统',
|
createdBy: user?.name || '系统',
|
||||||
});
|
});
|
||||||
onCreated?.();
|
onCreated?.();
|
||||||
@@ -109,7 +153,17 @@ export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline,
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">预计开始 *</label>
|
||||||
|
<input type="datetime-local" value={expectedStartLocal} onChange={(e) => setExpectedStartLocal(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">预计截止 *</label>
|
||||||
|
<input type="datetime-local" value={expectedEndLocal} onChange={(e) => setExpectedEndLocal(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">优先级</label>
|
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">优先级</label>
|
||||||
<select value={effectivePriority} onChange={(e) => { setPriority(e.target.value as Priority); setPriorityManuallySet(true); }} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none">
|
<select value={effectivePriority} onChange={(e) => { setPriority(e.target.value as Priority); setPriorityManuallySet(true); }} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none">
|
||||||
@@ -117,24 +171,28 @@ export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline,
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">预计工时(h) *</label>
|
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">预计工时(自动)</label>
|
||||||
<input type="number" min={0.5} step={0.5} value={estimateHours} onChange={(e) => setEstimateHours(parseFloat(e.target.value) || 0)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
|
<div className="h-9 flex items-center px-3 rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg-subtle)] text-[13px] text-[var(--ink-soft)] tabular-nums">
|
||||||
</div>
|
{estimateHours > 0 ? formatWorkHours(estimateHours) : (startBeforeEnd ? '0h' : '请先选择有效起止时间')}
|
||||||
<div>
|
</div>
|
||||||
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">截止日期</label>
|
<p className="text-[10px] text-[var(--ink-muted)] mt-0.5">工作时段 9:00–12:00 + 13:00–18:00,跳过周末</p>
|
||||||
<input type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
|
|
||||||
{versionDeadline && (
|
|
||||||
<p className="text-[10px] text-[var(--ink-muted)] mt-0.5">版本截止:{versionDeadline}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isOverdue && (
|
{!startBeforeEnd && expectedStartLocal && expectedEndLocal && (
|
||||||
|
<div className="rounded-lg border border-amber-200 bg-amber-50 p-2 text-[11px] text-amber-700">
|
||||||
|
预计开始必须早于预计截止
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{versionDeadline && (
|
||||||
|
<p className="text-[10px] text-[var(--ink-muted)]">版本截止:{versionDeadline}</p>
|
||||||
|
)}
|
||||||
|
{isOverdueVersion && (
|
||||||
<div className="rounded-lg border border-orange-200 bg-orange-50 p-3 space-y-2">
|
<div className="rounded-lg border border-orange-200 bg-orange-50 p-3 space-y-2">
|
||||||
<div className="flex items-center gap-1.5 text-[11px] text-orange-700">
|
<div className="flex items-center gap-1.5 text-[11px] text-orange-700">
|
||||||
<AlertTriangle className="h-3.5 w-3.5" />
|
<AlertTriangle className="h-3.5 w-3.5" />
|
||||||
任务截止日期超出版本截止日期({versionDeadline}),请说明原因
|
任务预计截止超出版本截止日期({versionDeadline}),请说明原因
|
||||||
</div>
|
</div>
|
||||||
<input value={overdueReason} onChange={(e) => setOverdueReason(e.target.value)} placeholder="超期原因(必填)" className="h-8 w-full rounded-md border border-orange-200 bg-white px-2 text-[12px] focus:border-orange-400 focus:outline-none" />
|
<input value={overdueVersionReason} onChange={(e) => setOverdueVersionReason(e.target.value)} placeholder="超期原因(必填)" className="h-8 w-full rounded-md border border-orange-200 bg-white px-2 text-[12px] focus:border-orange-400 focus:outline-none" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{versionTasks.length > 0 && (
|
{versionTasks.length > 0 && (
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { X, AlertTriangle, Link2, ChevronRight, Clock, User, Tag, Calendar, Play, Trash2, Pencil, ArrowRightLeft } from 'lucide-react';
|
import { X, AlertTriangle, Link2, ChevronRight, Clock, User, Tag, Play, Trash2, ArrowRightLeft, CalendarRange } from 'lucide-react';
|
||||||
import { StatusBadge } from './StatusBadge';
|
import { StatusBadge } from './StatusBadge';
|
||||||
import { CategoryChip } from './CategoryChip';
|
import { CategoryChip } from './CategoryChip';
|
||||||
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
||||||
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
||||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||||
import { useMemberStore } from '@/stores/useMemberStore';
|
import { useMemberStore } from '@/stores/useMemberStore';
|
||||||
import { ALLOWED_TRANSITIONS, DEV_TASK_STATUS_LABEL, DEV_TASK_STATUS_COLOR, formatHours, calcActualHoursByDates } from '@/lib/dev-task';
|
import {
|
||||||
import { formatDateTime } from '@/lib/format';
|
ALLOWED_TRANSITIONS,
|
||||||
|
DEV_TASK_STATUS_LABEL,
|
||||||
|
DEV_TASK_STATUS_COLOR,
|
||||||
|
formatHours,
|
||||||
|
getEstimateHours,
|
||||||
|
getActualHours,
|
||||||
|
} from '@/lib/dev-task';
|
||||||
|
import { needsDelayReason } from '@/lib/dev-task-transitions';
|
||||||
|
import { formatShortTime } from '@/lib/work-hours';
|
||||||
import type { DevTaskStatus } from '@/lib/dev-task';
|
import type { DevTaskStatus } from '@/lib/dev-task';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -26,6 +34,8 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
|||||||
const { members } = useMemberStore();
|
const { members } = useMemberStore();
|
||||||
const [showTransfer, setShowTransfer] = useState(false);
|
const [showTransfer, setShowTransfer] = useState(false);
|
||||||
const [transferTo, setTransferTo] = useState('');
|
const [transferTo, setTransferTo] = useState('');
|
||||||
|
const [showDelayInput, setShowDelayInput] = useState(false);
|
||||||
|
const [delayReason, setDelayReason] = useState('');
|
||||||
|
|
||||||
const task = tasks.find((t) => t.id === taskId);
|
const task = tasks.find((t) => t.id === taskId);
|
||||||
if (!task) return null;
|
if (!task) return null;
|
||||||
@@ -39,9 +49,28 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
|||||||
const [blockReason, setBlockReason] = useState(task.blockReason || '');
|
const [blockReason, setBlockReason] = useState(task.blockReason || '');
|
||||||
const [showBlockInput, setShowBlockInput] = useState(false);
|
const [showBlockInput, setShowBlockInput] = useState(false);
|
||||||
|
|
||||||
|
const estimate = useMemo(() => getEstimateHours(task), [task]);
|
||||||
|
const actual = useMemo(() => getActualHours(task), [task]);
|
||||||
|
const overrun = actual > estimate && estimate > 0;
|
||||||
|
const requireDelay = task.status === 'todo' && needsDelayReason(task);
|
||||||
|
|
||||||
const handleTransition = (to: DevTaskStatus) => {
|
const handleTransition = (to: DevTaskStatus) => {
|
||||||
const result = changeStatus(task.id, to);
|
if (to === 'in_progress' && requireDelay && !showDelayInput) {
|
||||||
if (!result.ok) alert(result.message);
|
setShowDelayInput(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const opts = to === 'in_progress' && delayReason.trim()
|
||||||
|
? { delayReason: delayReason.trim() }
|
||||||
|
: undefined;
|
||||||
|
const result = changeStatus(task.id, to, opts);
|
||||||
|
if (!result.ok) {
|
||||||
|
alert(result.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (to === 'in_progress') {
|
||||||
|
setShowDelayInput(false);
|
||||||
|
setDelayReason('');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBlock = () => {
|
const handleBlock = () => {
|
||||||
@@ -58,13 +87,11 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex justify-end" onClick={onClose}>
|
<div className="fixed inset-0 z-50 flex justify-end" onClick={onClose}>
|
||||||
<div className="w-full max-w-md h-full bg-[var(--bg)] border-l border-[var(--line)] shadow-2xl flex flex-col" onClick={(e) => e.stopPropagation()}>
|
<div className="w-full max-w-md h-full bg-[var(--bg)] border-l border-[var(--line)] shadow-2xl flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||||
{/* 上下文 */}
|
|
||||||
{contextLabel && (
|
{contextLabel && (
|
||||||
<div className="px-5 py-2 border-b border-[var(--line)] bg-[var(--bg-subtle)] shrink-0">
|
<div className="px-5 py-2 border-b border-[var(--line)] bg-[var(--bg-subtle)] shrink-0">
|
||||||
<span className="text-[11px] text-[var(--ink-muted)]">{contextLabel}</span>
|
<span className="text-[11px] text-[var(--ink-muted)]">{contextLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* 顶栏 */}
|
|
||||||
<div className="flex items-center justify-between h-14 px-5 border-b border-[var(--line)] bg-[var(--bg-card)] shrink-0">
|
<div className="flex items-center justify-between h-14 px-5 border-b border-[var(--line)] bg-[var(--bg-card)] shrink-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-[12px] font-mono text-[var(--ink-muted)]">{task.taskNo}</span>
|
<span className="text-[12px] font-mono text-[var(--ink-muted)]">{task.taskNo}</span>
|
||||||
@@ -79,7 +106,6 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 转交 */}
|
|
||||||
{showTransfer && (
|
{showTransfer && (
|
||||||
<div className="mx-5 mt-3 rounded-lg border border-[var(--line)] p-3 flex items-center gap-2">
|
<div className="mx-5 mt-3 rounded-lg border border-[var(--line)] p-3 flex items-center gap-2">
|
||||||
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">转交给:</span>
|
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">转交给:</span>
|
||||||
@@ -92,10 +118,8 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 滚动区域 */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||||
|
|
||||||
{/* 卡片1:关联需求 */}
|
|
||||||
{requirement && (
|
{requirement && (
|
||||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-3">
|
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-3">
|
||||||
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-1.5">关联需求</div>
|
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-1.5">关联需求</div>
|
||||||
@@ -107,7 +131,6 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 卡片2:状态 & 操作 */}
|
|
||||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 space-y-3">
|
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 space-y-3">
|
||||||
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide">状态 & 操作</div>
|
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide">状态 & 操作</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -119,11 +142,15 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
|||||||
<AlertTriangle className="h-3 w-3" />阻塞中
|
<AlertTriangle className="h-3 w-3" />阻塞中
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{requireDelay && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[11px] text-orange-600 bg-orange-50 border border-orange-200 px-2 py-1 rounded-lg" title="已超过预计开始时间,开干需填延后原因">
|
||||||
|
<AlertTriangle className="h-3 w-3" />超期未开始
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 流转按钮 */}
|
{nextStatuses.length > 0 && !showDelayInput && (
|
||||||
{nextStatuses.length > 0 && (
|
<div className="flex items-center gap-2 pt-1 flex-wrap">
|
||||||
<div className="flex items-center gap-2 pt-1">
|
|
||||||
<ChevronRight className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
|
<ChevronRight className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
|
||||||
{nextStatuses.map((s) => (
|
{nextStatuses.map((s) => (
|
||||||
<button key={s} onClick={() => handleTransition(s)} className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] transition-colors">
|
<button key={s} onClick={() => handleTransition(s)} className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] transition-colors">
|
||||||
@@ -133,7 +160,20 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 阻塞管理 */}
|
{showDelayInput && (
|
||||||
|
<div className="rounded-lg border border-orange-200 bg-orange-50 p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-orange-700">
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5" />
|
||||||
|
已超过预计开始时间,请说明延后原因
|
||||||
|
</div>
|
||||||
|
<input value={delayReason} onChange={(e) => setDelayReason(e.target.value)} placeholder="延后原因(必填)" className="h-8 w-full rounded-md border border-orange-200 bg-white px-2 text-[12px] focus:border-orange-400 focus:outline-none" autoFocus />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => handleTransition('in_progress')} disabled={!delayReason.trim()} className="h-7 px-3 rounded text-[11px] font-medium bg-[var(--accent)] text-white disabled:opacity-50">开始开发</button>
|
||||||
|
<button onClick={() => { setShowDelayInput(false); setDelayReason(''); }} className="h-7 px-2 text-[11px] text-[var(--ink-muted)]">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="pt-2 border-t border-[var(--line)]">
|
<div className="pt-2 border-t border-[var(--line)]">
|
||||||
{task.isBlocked ? (
|
{task.isBlocked ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -157,7 +197,52 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 卡片3:基本信息 */}
|
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||||
|
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-3 flex items-center gap-1.5"><CalendarRange className="h-3 w-3" />时间信息</div>
|
||||||
|
<div className="grid grid-cols-2 gap-y-2.5 gap-x-4 text-[12px]">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-[10px] text-[var(--ink-muted)]">预计开始</span>
|
||||||
|
<span className="text-[var(--ink)] font-medium tabular-nums">{task.expectedStartAt ? formatShortTime(task.expectedStartAt) : '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-[10px] text-[var(--ink-muted)]">预计截止</span>
|
||||||
|
<span className="text-[var(--ink)] font-medium tabular-nums">{task.expectedEndAt ? formatShortTime(task.expectedEndAt) : '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-[10px] text-[var(--ink-muted)]">实际开始</span>
|
||||||
|
<span className="text-[var(--ink)] font-medium tabular-nums">{task.actualStartAt ? formatShortTime(task.actualStartAt) : '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-[10px] text-[var(--ink-muted)]">实际结束</span>
|
||||||
|
<span className="text-[var(--ink)] font-medium tabular-nums">{task.actualEndAt ? formatShortTime(task.actualEndAt) : '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-[10px] text-[var(--ink-muted)]">预计耗时</span>
|
||||||
|
<span className="text-[var(--ink)] font-medium tabular-nums">{formatHours(estimate)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-[10px] text-[var(--ink-muted)]">实际耗时</span>
|
||||||
|
<span className={`font-medium tabular-nums ${actual > 0 ? (overrun ? 'text-red-600' : (actual < estimate ? 'text-emerald-600' : 'text-[var(--ink)]')) : 'text-[var(--ink-muted)]'}`}>
|
||||||
|
{actual > 0 ? formatHours(actual) : '—'}
|
||||||
|
{task.actualStartAt && !task.actualEndAt && <span className="text-[10px] ml-1 text-[var(--ink-muted)]">进行中</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{task.delayReason && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-[var(--line)] text-[11px] text-orange-700 bg-orange-50 rounded-lg px-3 py-2">
|
||||||
|
<div className="flex items-start gap-1.5">
|
||||||
|
<AlertTriangle className="h-3 w-3 shrink-0 mt-0.5" />
|
||||||
|
<div><span className="font-medium">延后原因:</span>{task.delayReason}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{task.overdueVersionReason && (
|
||||||
|
<div className="mt-2 text-[11px] text-orange-700 bg-orange-50 rounded-lg px-3 py-2">
|
||||||
|
<span className="font-medium">超出版本截止原因:</span>{task.overdueVersionReason}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<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-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-3">基本信息</div>
|
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-3">基本信息</div>
|
||||||
<div className="grid grid-cols-2 gap-y-3 gap-x-4 text-[12px]">
|
<div className="grid grid-cols-2 gap-y-3 gap-x-4 text-[12px]">
|
||||||
@@ -177,35 +262,9 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Clock className="h-3 w-3 text-[var(--ink-muted)]" />
|
<Clock className="h-3 w-3 text-[var(--ink-muted)]" />
|
||||||
<span className="text-[var(--ink-muted)]">预计</span>
|
<span className="text-[var(--ink-muted)]">创建</span>
|
||||||
<span className="text-[var(--ink)] font-medium">{formatHours(task.estimateHours)}</span>
|
<span className="text-[var(--ink)]">{formatShortTime(task.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Clock className="h-3 w-3 text-[var(--ink-muted)]" />
|
|
||||||
<span className="text-[var(--ink-muted)]">实际</span>
|
|
||||||
<span className="text-[var(--ink)] font-medium">{task.startDate ? formatHours(calcActualHoursByDates(task.startDate, task.completedAt)) : '-'}</span>
|
|
||||||
</div>
|
|
||||||
{task.startDate && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Play className="h-3 w-3 text-[var(--ink-muted)]" />
|
|
||||||
<span className="text-[var(--ink-muted)]">开始开发</span>
|
|
||||||
<span className="text-[var(--ink)]">{formatDateTime(task.startDate)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{task.dueDate && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Calendar className="h-3 w-3 text-[var(--ink-muted)]" />
|
|
||||||
<span className="text-[var(--ink-muted)]">截止</span>
|
|
||||||
<span className="text-[var(--ink)]">{task.dueDate}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{task.completedAt && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Calendar className="h-3 w-3 text-[var(--ink-muted)]" />
|
|
||||||
<span className="text-[var(--ink-muted)]">提测</span>
|
|
||||||
<span className="text-emerald-600 font-medium">{formatDateTime(task.completedAt)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{task.description && (
|
{task.description && (
|
||||||
<div className="mt-3 pt-3 border-t border-[var(--line)]">
|
<div className="mt-3 pt-3 border-t border-[var(--line)]">
|
||||||
@@ -214,7 +273,6 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 卡片4:前置任务 */}
|
|
||||||
{predecessors.length > 0 && (
|
{predecessors.length > 0 && (
|
||||||
<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-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-2">前置任务</div>
|
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-2">前置任务</div>
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import { AlertTriangle } from 'lucide-react';
|
import { AlertTriangle } from 'lucide-react';
|
||||||
import { StatusBadge } from './StatusBadge';
|
import { StatusBadge } from './StatusBadge';
|
||||||
import { CategoryChip } from './CategoryChip';
|
import { CategoryChip } from './CategoryChip';
|
||||||
import { formatHours, calcActualHoursByDates } from '@/lib/dev-task';
|
import { getEstimateHours, getActualHours } from '@/lib/dev-task';
|
||||||
|
import { formatShortTime, formatWorkHours } from '@/lib/work-hours';
|
||||||
import type { DevTask } from '@/lib/dev-task';
|
import type { DevTask } from '@/lib/dev-task';
|
||||||
import type { TaskCategory } from '@/lib/task-category';
|
import type { TaskCategory } from '@/lib/task-category';
|
||||||
|
|
||||||
@@ -20,7 +21,33 @@ const PRIORITY_DOT: Record<string, string> = {
|
|||||||
P3: 'bg-zinc-300',
|
P3: 'bg-zinc-300',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function timeRangeText(task: DevTask): { text: string; tone: string } {
|
||||||
|
if (task.status === 'submitted' && task.actualStartAt && task.actualEndAt) {
|
||||||
|
return { text: `实际 ${formatShortTime(task.actualStartAt)} → ${formatShortTime(task.actualEndAt)}`, tone: 'text-[var(--ink-muted)]' };
|
||||||
|
}
|
||||||
|
if (task.actualStartAt) {
|
||||||
|
return { text: `实际 ${formatShortTime(task.actualStartAt)} → 进行中`, tone: 'text-emerald-600' };
|
||||||
|
}
|
||||||
|
if (task.expectedStartAt && task.expectedEndAt) {
|
||||||
|
return { text: `预计 ${formatShortTime(task.expectedStartAt)} → ${formatShortTime(task.expectedEndAt)}`, tone: 'text-blue-600' };
|
||||||
|
}
|
||||||
|
return { text: '', tone: 'text-[var(--ink-muted)]' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hoursText(estimate: number, actual: number): { text: string; tone: string } {
|
||||||
|
if (actual <= 0) return { text: `预 ${formatWorkHours(estimate)}`, tone: 'text-[var(--ink-muted)]' };
|
||||||
|
let tone = 'text-[var(--ink-soft)]';
|
||||||
|
if (actual > estimate) tone = 'text-red-600';
|
||||||
|
else if (actual < estimate) tone = 'text-emerald-600';
|
||||||
|
return { text: `${formatWorkHours(actual)} / ${formatWorkHours(estimate)}`, tone };
|
||||||
|
}
|
||||||
|
|
||||||
export function DevTaskRow({ task, category, onClick }: Props) {
|
export function DevTaskRow({ task, category, onClick }: Props) {
|
||||||
|
const estimate = getEstimateHours(task);
|
||||||
|
const actual = getActualHours(task);
|
||||||
|
const range = timeRangeText(task);
|
||||||
|
const hours = hoursText(estimate, actual);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@@ -38,12 +65,9 @@ export function DevTaskRow({ task, category, onClick }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<CategoryChip category={category} />
|
<CategoryChip category={category} />
|
||||||
<StatusBadge status={task.status} />
|
<StatusBadge status={task.status} />
|
||||||
<span className="text-[11px] text-[var(--ink-muted)] w-24 text-right tabular-nums">
|
<span className={`text-[11px] tabular-nums shrink-0 ${range.tone}`} title={range.text}>{range.text}</span>
|
||||||
{task.startDate && <span className="text-[var(--ink-soft)]">{calcActualHoursByDates(task.startDate, task.completedAt)}h</span>}
|
<span className={`text-[11px] tabular-nums w-40 text-right shrink-0 whitespace-nowrap ${hours.tone}`}>{hours.text}</span>
|
||||||
{task.startDate && ' / '}
|
<span className="text-[11px] text-[var(--ink-soft)] w-14 text-right truncate shrink-0">{task.assigneeId}</span>
|
||||||
{formatHours(task.estimateHours).split('(')[0]}
|
|
||||||
</span>
|
|
||||||
<span className="text-[11px] text-[var(--ink-soft)] w-14 text-right truncate">{task.assigneeId}</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ import { useAuthStore } from '@/stores/useAuthStore';
|
|||||||
import { DevTaskRow } from './DevTaskRow';
|
import { DevTaskRow } from './DevTaskRow';
|
||||||
import { DevTaskCreateModal } from './DevTaskCreateModal';
|
import { DevTaskCreateModal } from './DevTaskCreateModal';
|
||||||
import { DevTaskDetailDrawer } from './DevTaskDetailDrawer';
|
import { DevTaskDetailDrawer } from './DevTaskDetailDrawer';
|
||||||
import { calcGroupProgress, DEV_TASK_STATUS_LABEL } from '@/lib/dev-task';
|
import { calcGroupProgress, DEV_TASK_STATUS_LABEL, aggregateDevTaskHours, devTaskIntervals } from '@/lib/dev-task';
|
||||||
|
import { formatWorkHours, calcTwoMetrics } from '@/lib/work-hours';
|
||||||
import { Pagination, usePagination } from '@/components/Pagination';
|
import { Pagination, usePagination } from '@/components/Pagination';
|
||||||
|
import { SearchInput, matchTitleOrNo } from '@/components/SearchInput';
|
||||||
|
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||||
import type { DevTask, DevTaskStatus } from '@/lib/dev-task';
|
import type { DevTask, DevTaskStatus } from '@/lib/dev-task';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -33,16 +36,18 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props
|
|||||||
useEffect(() => { fetchCategories(); }, [fetchCategories]);
|
useEffect(() => { fetchCategories(); }, [fetchCategories]);
|
||||||
useEffect(() => { fetchWorklogs(); }, [fetchWorklogs]);
|
useEffect(() => { fetchWorklogs(); }, [fetchWorklogs]);
|
||||||
|
|
||||||
|
const reqIdSet = useMemo(() => new Set(requirementIds), [requirementIds]);
|
||||||
const versionTasks = useMemo(
|
const versionTasks = useMemo(
|
||||||
() => tasks.filter((t) => requirementIds.includes(t.requirementId)),
|
() => tasks.filter((t) => reqIdSet.has(t.requirementId)),
|
||||||
[tasks, requirementIds],
|
[tasks, reqIdSet],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 筛选
|
|
||||||
const [filterAssignee, setFilterAssignee] = useState('');
|
const [filterAssignee, setFilterAssignee] = useState('');
|
||||||
const [filterStatus, setFilterStatus] = useState('');
|
const [filterStatus, setFilterStatus] = useState('');
|
||||||
const [filterBlocked, setFilterBlocked] = useState('');
|
const [filterBlocked, setFilterBlocked] = useState('');
|
||||||
const [filterCategory, setFilterCategory] = useState('');
|
const [filterCategory, setFilterCategory] = useState('');
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const debouncedKeyword = useDebouncedValue(keyword, 300);
|
||||||
|
|
||||||
const filteredTasks = useMemo(() => {
|
const filteredTasks = useMemo(() => {
|
||||||
let result = versionTasks;
|
let result = versionTasks;
|
||||||
@@ -51,30 +56,30 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props
|
|||||||
if (filterBlocked === 'yes') result = result.filter((t) => t.isBlocked);
|
if (filterBlocked === 'yes') result = result.filter((t) => t.isBlocked);
|
||||||
if (filterBlocked === 'no') result = result.filter((t) => !t.isBlocked);
|
if (filterBlocked === 'no') result = result.filter((t) => !t.isBlocked);
|
||||||
if (filterCategory) result = result.filter((t) => t.categoryId === filterCategory);
|
if (filterCategory) result = result.filter((t) => t.categoryId === filterCategory);
|
||||||
|
if (debouncedKeyword) result = result.filter((t) => matchTitleOrNo({ title: t.title, no: t.taskNo }, debouncedKeyword));
|
||||||
return result;
|
return result;
|
||||||
}, [versionTasks, filterAssignee, filterStatus, filterBlocked, filterCategory]);
|
}, [versionTasks, filterAssignee, filterStatus, filterBlocked, filterCategory, debouncedKeyword]);
|
||||||
|
|
||||||
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filteredTasks, 20);
|
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filteredTasks, 20);
|
||||||
|
|
||||||
const progress = calcGroupProgress(versionTasks);
|
const progress = calcGroupProgress(versionTasks);
|
||||||
const totalEstimate = versionTasks.reduce((s, t) => s + t.estimateHours, 0);
|
const { estimate: totalEstimate } = aggregateDevTaskHours(versionTasks);
|
||||||
|
const { calendarHours, manhours } = useMemo(() => calcTwoMetrics(devTaskIntervals(versionTasks)), [versionTasks]);
|
||||||
|
const overrun = manhours > totalEstimate && totalEstimate > 0;
|
||||||
|
const underrun = manhours > 0 && manhours < totalEstimate;
|
||||||
|
const hoursTone = overrun ? 'text-red-600' : underrun ? 'text-emerald-600' : 'text-[var(--ink-muted)]';
|
||||||
const blockedCount = versionTasks.filter((t) => t.isBlocked).length;
|
const blockedCount = versionTasks.filter((t) => t.isBlocked).length;
|
||||||
|
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||||
|
|
||||||
// 批量选择
|
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const isAllSelected = paged.length > 0 && paged.every((t) => selectedIds.has(t.id));
|
|
||||||
|
|
||||||
const toggleSelect = (id: string) => {
|
const toggleSelect = (id: string) => {
|
||||||
const next = new Set(selectedIds);
|
const next = new Set(selectedIds);
|
||||||
if (next.has(id)) next.delete(id); else next.add(id);
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
setSelectedIds(next);
|
setSelectedIds(next);
|
||||||
};
|
};
|
||||||
const toggleAll = () => {
|
|
||||||
setSelectedIds(isAllSelected ? new Set() : new Set(paged.map((t) => t.id)));
|
|
||||||
};
|
|
||||||
const handleBatchDelete = () => {
|
const handleBatchDelete = () => {
|
||||||
if (selectedIds.size === 0) return;
|
if (selectedIds.size === 0) return;
|
||||||
if (!confirm(`确定删除选中的 ${selectedIds.size} 个任务?`)) return;
|
if (!confirm(`确定删除选中的 ${selectedIds.size} 个任务?`)) return;
|
||||||
@@ -92,25 +97,27 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props
|
|||||||
return map;
|
return map;
|
||||||
}, [paged]);
|
}, [paged]);
|
||||||
|
|
||||||
|
const categoryMap = useMemo(() => new Map(categories.map((c) => [c.id, c])), [categories]);
|
||||||
|
const requirementMap = useMemo(() => new Map(requirements.map((r) => [r.id, r])), [requirements]);
|
||||||
|
const allTaskIds = useMemo(() => versionTasks.map((t) => t.id), [versionTasks]);
|
||||||
|
|
||||||
const assignees = useMemo(() => Array.from(new Set(versionTasks.map((t) => t.assigneeId))), [versionTasks]);
|
const assignees = useMemo(() => Array.from(new Set(versionTasks.map((t) => t.assigneeId))), [versionTasks]);
|
||||||
const hasFilter = !!(filterAssignee || filterStatus || filterBlocked || filterCategory);
|
const hasFilter = !!(filterAssignee || filterStatus || filterBlocked || filterCategory || debouncedKeyword);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* 工具栏:进度 + 筛选 + 操作 合为一行 */}
|
|
||||||
<div className="flex items-center gap-2 px-4 py-2.5 rounded-lg bg-[var(--bg-subtle)] border border-[var(--line)] flex-wrap">
|
<div className="flex items-center gap-2 px-4 py-2.5 rounded-lg bg-[var(--bg-subtle)] border border-[var(--line)] flex-wrap">
|
||||||
{/* 进度 */}
|
|
||||||
<Code2 className="h-3.5 w-3.5 text-[var(--accent)] shrink-0" />
|
<Code2 className="h-3.5 w-3.5 text-[var(--accent)] shrink-0" />
|
||||||
<span className="text-[12px] font-medium text-[var(--ink)] shrink-0">{progress}%</span>
|
<span className="text-[12px] font-medium text-[var(--ink)] shrink-0">{progress}%</span>
|
||||||
<div className="h-1.5 w-20 rounded-full bg-[var(--bg)] overflow-hidden shrink-0">
|
<div className="h-1.5 w-20 rounded-full bg-[var(--bg)] overflow-hidden shrink-0">
|
||||||
<div className="h-full rounded-full bg-[var(--accent)]" style={{ width: `${progress}%` }} />
|
<div className="h-full rounded-full bg-[var(--accent)]" style={{ width: `${progress}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">{versionTasks.length}任务 · {totalEstimate}h</span>
|
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">{versionTasks.length}任务 · 预 {formatWorkHours(totalEstimate)}{manhours > 0 ? <> · 日历 {formatWorkHours(calendarHours)} / 人力 <span className={`tabular-nums ${hoursTone}`}>{formatWorkHours(manhours)}</span></> : null}</span>
|
||||||
{blockedCount > 0 && <span className="text-[10px] text-red-500 bg-red-50 px-1.5 py-0.5 rounded shrink-0">{blockedCount}阻塞</span>}
|
{blockedCount > 0 && <span className="text-[10px] text-red-500 bg-red-50 px-1.5 py-0.5 rounded shrink-0">{blockedCount}阻塞</span>}
|
||||||
|
|
||||||
<span className="w-px h-4 bg-[var(--line)] mx-1 shrink-0" />
|
<span className="w-px h-4 bg-[var(--line)] mx-1 shrink-0" />
|
||||||
|
|
||||||
{/* 筛选 */}
|
<SearchInput value={keyword} onChange={(v) => { setKeyword(v); setPage(1); }} placeholder="搜索标题/编号" />
|
||||||
<select value={filterAssignee} onChange={(e) => { setFilterAssignee(e.target.value); setPage(1); }} className="h-6 rounded border border-[var(--line)] bg-[var(--bg-card)] px-1.5 text-[11px] text-[var(--ink-soft)] focus:border-[var(--accent)] focus:outline-none">
|
<select value={filterAssignee} onChange={(e) => { setFilterAssignee(e.target.value); setPage(1); }} className="h-6 rounded border border-[var(--line)] bg-[var(--bg-card)] px-1.5 text-[11px] text-[var(--ink-soft)] focus:border-[var(--accent)] focus:outline-none">
|
||||||
<option value="">负责人</option>
|
<option value="">负责人</option>
|
||||||
{user?.name && <option value={user.name}>我的</option>}
|
{user?.name && <option value={user.name}>我的</option>}
|
||||||
@@ -129,9 +136,8 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props
|
|||||||
<option value="">类型</option>
|
<option value="">类型</option>
|
||||||
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
{hasFilter && <button onClick={() => { setFilterAssignee(''); setFilterStatus(''); setFilterBlocked(''); setFilterCategory(''); setPage(1); }} className="text-[10px] text-[var(--accent)] hover:underline shrink-0">清除</button>}
|
{hasFilter && <button onClick={() => { setFilterAssignee(''); setFilterStatus(''); setFilterBlocked(''); setFilterCategory(''); setKeyword(''); setPage(1); }} className="text-[10px] text-[var(--accent)] hover:underline shrink-0">清除</button>}
|
||||||
|
|
||||||
{/* 右侧操作 */}
|
|
||||||
<div className="ml-auto flex items-center gap-2 shrink-0">
|
<div className="ml-auto flex items-center gap-2 shrink-0">
|
||||||
{selectedIds.size > 0 && (
|
{selectedIds.size > 0 && (
|
||||||
<button onClick={handleBatchDelete} className="flex items-center gap-1 h-6 px-2 rounded text-[11px] font-medium bg-red-500 text-white hover:bg-red-600">
|
<button onClick={handleBatchDelete} className="flex items-center gap-1 h-6 px-2 rounded text-[11px] font-medium bg-red-500 text-white hover:bg-red-600">
|
||||||
@@ -144,7 +150,6 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 列表 */}
|
|
||||||
{filteredTasks.length === 0 ? (
|
{filteredTasks.length === 0 ? (
|
||||||
<div className="rounded-xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] p-12 text-center">
|
<div className="rounded-xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] p-12 text-center">
|
||||||
<p className="text-[13px] text-[var(--ink-muted)]">{hasFilter ? '没有匹配的任务' : '暂无开发任务'}</p>
|
<p className="text-[13px] text-[var(--ink-muted)]">{hasFilter ? '没有匹配的任务' : '暂无开发任务'}</p>
|
||||||
@@ -152,7 +157,7 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
Array.from(groupedByReq.entries()).map(([reqId, reqTasks]) => {
|
Array.from(groupedByReq.entries()).map(([reqId, reqTasks]) => {
|
||||||
const req = requirements.find((r) => r.id === reqId);
|
const req = requirementMap.get(reqId);
|
||||||
const reqProgress = calcGroupProgress(reqTasks);
|
const reqProgress = calcGroupProgress(reqTasks);
|
||||||
return (
|
return (
|
||||||
<div key={reqId} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
|
<div key={reqId} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
|
||||||
@@ -168,7 +173,7 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props
|
|||||||
<input type="checkbox" checked={selectedIds.has(t.id)} onChange={() => toggleSelect(t.id)} className="h-3.5 w-3.5 rounded border-[var(--line)]" onClick={(e) => e.stopPropagation()} />
|
<input type="checkbox" checked={selectedIds.has(t.id)} onChange={() => toggleSelect(t.id)} className="h-3.5 w-3.5 rounded border-[var(--line)]" onClick={(e) => e.stopPropagation()} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<DevTaskRow task={t} category={categories.find((c) => c.id === t.categoryId)} onClick={() => setSelectedTaskId(t.id)} />
|
<DevTaskRow task={t} category={categoryMap.get(t.categoryId)} onClick={() => setSelectedTaskId(t.id)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -180,7 +185,7 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props
|
|||||||
{total > 20 && <Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />}
|
{total > 20 && <Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />}
|
||||||
|
|
||||||
{showCreate && <DevTaskCreateModal versionId={versionId} requirementIds={requirementIds} versionDeadline={versionDeadline} onClose={() => setShowCreate(false)} />}
|
{showCreate && <DevTaskCreateModal versionId={versionId} requirementIds={requirementIds} versionDeadline={versionDeadline} onClose={() => setShowCreate(false)} />}
|
||||||
{selectedTaskId && <DevTaskDetailDrawer taskId={selectedTaskId} allTaskIds={versionTasks.map((t) => t.id)} onClose={() => setSelectedTaskId(null)} />}
|
{selectedTaskId && <DevTaskDetailDrawer taskId={selectedTaskId} allTaskIds={allTaskIds} onClose={() => setSelectedTaskId(null)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Plus, Trash2 } from 'lucide-react';
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
import { useTaskWorklogStore } from '@/stores/useTaskWorklogStore';
|
import { useTaskWorklogStore } from '@/stores/useTaskWorklogStore';
|
||||||
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
|
||||||
import { useAuthStore } from '@/stores/useAuthStore';
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
import { getTaskWorklogs } from '@/lib/task-worklog';
|
import { getTaskWorklogs } from '@/lib/task-worklog';
|
||||||
|
|
||||||
@@ -13,7 +12,6 @@ interface Props {
|
|||||||
|
|
||||||
export function WorklogPanel({ taskId }: Props) {
|
export function WorklogPanel({ taskId }: Props) {
|
||||||
const { worklogs, addWorklog, deleteWorklog } = useTaskWorklogStore();
|
const { worklogs, addWorklog, deleteWorklog } = useTaskWorklogStore();
|
||||||
const { syncActualHours } = useDevTaskStore();
|
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
|
|
||||||
const taskLogs = getTaskWorklogs(worklogs, taskId);
|
const taskLogs = getTaskWorklogs(worklogs, taskId);
|
||||||
@@ -28,16 +26,13 @@ export function WorklogPanel({ taskId }: Props) {
|
|||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
if (!workContent.trim() || hours <= 0) return;
|
if (!workContent.trim() || hours <= 0) return;
|
||||||
addWorklog({ taskId, userId: user?.name || '', date, hours, workContent: workContent.trim() });
|
addWorklog({ taskId, userId: user?.name || '', date, hours, workContent: workContent.trim() });
|
||||||
const newTotal = totalHours + hours;
|
|
||||||
syncActualHours(taskId, newTotal);
|
|
||||||
setWorkContent('');
|
setWorkContent('');
|
||||||
setHours(1);
|
setHours(1);
|
||||||
setAdding(false);
|
setAdding(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (id: string, logHours: number) => {
|
const handleDelete = (id: string) => {
|
||||||
deleteWorklog(id);
|
deleteWorklog(id);
|
||||||
syncActualHours(taskId, Math.max(0, totalHours - logHours));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -75,7 +70,7 @@ export function WorklogPanel({ taskId }: Props) {
|
|||||||
<p className="text-[12px] text-[var(--ink)]">{log.workContent}</p>
|
<p className="text-[12px] text-[var(--ink)]">{log.workContent}</p>
|
||||||
<p className="text-[10px] text-[var(--ink-muted)]">{log.date} · {log.hours}h · {log.userId}</p>
|
<p className="text-[10px] text-[var(--ink-muted)]">{log.date} · {log.hours}h · {log.userId}</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => handleDelete(log.id, log.hours)} className="p-1 rounded hover:bg-red-50">
|
<button onClick={() => handleDelete(log.id)} className="p-1 rounded hover:bg-red-50">
|
||||||
<Trash2 className="h-3 w-3 text-[var(--ink-muted)] hover:text-red-500" />
|
<Trash2 className="h-3 w-3 text-[var(--ink-muted)] hover:text-red-500" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { memo } from 'react';
|
||||||
import { TestCaseStatusBadge } from './TestCaseStatusBadge';
|
import { TestCaseStatusBadge } from './TestCaseStatusBadge';
|
||||||
import { calcActualHoursByDates } from '@/lib/dev-task';
|
import { getTestCaseActualHours } from '@/lib/test-case';
|
||||||
|
import { formatWorkHours } from '@/lib/work-hours';
|
||||||
import type { TestCase } from '@/lib/test-case';
|
import type { TestCase } from '@/lib/test-case';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -17,20 +19,23 @@ const PRIORITY_DOT: Record<string, string> = {
|
|||||||
P3: 'bg-zinc-300',
|
P3: 'bg-zinc-300',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TestCaseRow({ testCase, bugCount, onClick }: Props) {
|
function TestCaseRowImpl({ testCase, bugCount, onClick }: Props) {
|
||||||
|
const actualHours = getTestCaseActualHours(testCase);
|
||||||
return (
|
return (
|
||||||
<div onClick={onClick} className="flex items-center gap-3 px-4 py-2.5 border-b border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer transition-colors last:border-b-0">
|
<div onClick={onClick} className="flex items-center gap-3 px-4 py-2.5 border-b border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer transition-colors last:border-b-0">
|
||||||
<span className={`h-2 w-2 rounded-full shrink-0 ${PRIORITY_DOT[testCase.priority] || 'bg-zinc-300'}`} />
|
<span className={`h-2 w-2 rounded-full shrink-0 ${PRIORITY_DOT[testCase.priority] || 'bg-zinc-300'}`} />
|
||||||
<span className="text-[11px] font-mono text-[var(--ink-muted)] w-14 shrink-0">{testCase.caseNo}</span>
|
<span className="text-[11px] font-mono text-[var(--ink-muted)] w-14 shrink-0">{testCase.caseNo}</span>
|
||||||
<span className="text-[13px] text-[var(--ink)] flex-1 truncate">{testCase.title}</span>
|
<span className="text-[13px] text-[var(--ink)] flex-1 truncate">{testCase.title}</span>
|
||||||
<TestCaseStatusBadge status={testCase.status} />
|
<TestCaseStatusBadge status={testCase.status} />
|
||||||
{testCase.startedAt && (
|
{actualHours > 0 && (
|
||||||
<span className="text-[11px] text-[var(--ink-muted)] tabular-nums w-10 text-right">{calcActualHoursByDates(testCase.startedAt, testCase.completedAt)}h</span>
|
<span className="text-[11px] text-[var(--ink-muted)] tabular-nums w-28 text-right shrink-0 whitespace-nowrap">{formatWorkHours(actualHours)}</span>
|
||||||
)}
|
)}
|
||||||
{bugCount > 0 && (
|
{bugCount > 0 && (
|
||||||
<span className="text-[10px] text-red-500 bg-red-50 px-1.5 py-0.5 rounded">{bugCount} Bug</span>
|
<span className="text-[10px] text-red-500 bg-red-50 px-1.5 py-0.5 rounded shrink-0">{bugCount} Bug</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-[11px] text-[var(--ink-soft)] w-14 text-right truncate">{testCase.assigneeId || '-'}</span>
|
<span className="text-[11px] text-[var(--ink-soft)] w-14 text-right truncate shrink-0">{testCase.assigneeId || '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const TestCaseRow = memo(TestCaseRowImpl);
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ import { TestCaseRow } from './TestCaseRow';
|
|||||||
import { TestCaseCreateModal } from './TestCaseCreateModal';
|
import { TestCaseCreateModal } from './TestCaseCreateModal';
|
||||||
import { TestCaseDetailDrawer } from './TestCaseDetailDrawer';
|
import { TestCaseDetailDrawer } from './TestCaseDetailDrawer';
|
||||||
import { BugCreateModal } from '@/components/bug/BugCreateModal';
|
import { BugCreateModal } from '@/components/bug/BugCreateModal';
|
||||||
import { calcTestProgress, TEST_CASE_STATUS_LABEL } from '@/lib/test-case';
|
import { calcTestProgress, TEST_CASE_STATUS_LABEL, testCaseIntervals } from '@/lib/test-case';
|
||||||
|
import { formatWorkHours, calcTwoMetrics } from '@/lib/work-hours';
|
||||||
import { Pagination, usePagination } from '@/components/Pagination';
|
import { Pagination, usePagination } from '@/components/Pagination';
|
||||||
|
import { SearchInput, matchTitleOrNo } from '@/components/SearchInput';
|
||||||
|
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||||
import type { TestCaseStatus } from '@/lib/test-case';
|
import type { TestCaseStatus } from '@/lib/test-case';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -30,27 +33,33 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
|||||||
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
|
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
|
||||||
useEffect(() => { fetchBugs(); }, [fetchBugs]);
|
useEffect(() => { fetchBugs(); }, [fetchBugs]);
|
||||||
|
|
||||||
|
const reqIdSet = useMemo(() => new Set(requirementIds), [requirementIds]);
|
||||||
const versionCases = useMemo(
|
const versionCases = useMemo(
|
||||||
() => testCases.filter((c) => c.versionId === versionId),
|
() => testCases.filter((c) => c.versionId === versionId),
|
||||||
[testCases, versionId],
|
[testCases, versionId],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 提测状态
|
const versionDevTasks = useMemo(
|
||||||
const versionDevTasks = useMemo(() => devTasks.filter((t) => requirementIds.includes(t.requirementId)), [devTasks, requirementIds]);
|
() => devTasks.filter((t) => reqIdSet.has(t.requirementId)),
|
||||||
|
[devTasks, reqIdSet],
|
||||||
|
);
|
||||||
const allSubmitted = versionDevTasks.length > 0 && versionDevTasks.every((t) => t.status === 'submitted');
|
const allSubmitted = versionDevTasks.length > 0 && versionDevTasks.every((t) => t.status === 'submitted');
|
||||||
|
|
||||||
const stats = calcTestProgress(versionCases);
|
const stats = calcTestProgress(versionCases);
|
||||||
|
const { calendarHours: tcCalendarHours, manhours: tcManhours } = useMemo(() => calcTwoMetrics(testCaseIntervals(versionCases)), [versionCases]);
|
||||||
|
|
||||||
// 筛选
|
|
||||||
const [filterAssignee, setFilterAssignee] = useState('');
|
const [filterAssignee, setFilterAssignee] = useState('');
|
||||||
const [filterStatus, setFilterStatus] = useState('');
|
const [filterStatus, setFilterStatus] = useState('');
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const debouncedKeyword = useDebouncedValue(keyword, 300);
|
||||||
|
|
||||||
const filteredCases = useMemo(() => {
|
const filteredCases = useMemo(() => {
|
||||||
let result = versionCases;
|
let result = versionCases;
|
||||||
if (filterAssignee) result = result.filter((c) => c.assigneeId === filterAssignee);
|
if (filterAssignee) result = result.filter((c) => c.assigneeId === filterAssignee);
|
||||||
if (filterStatus) result = result.filter((c) => c.status === filterStatus);
|
if (filterStatus) result = result.filter((c) => c.status === filterStatus);
|
||||||
|
if (debouncedKeyword) result = result.filter((c) => matchTitleOrNo({ title: c.title, no: c.caseNo }, debouncedKeyword));
|
||||||
return result;
|
return result;
|
||||||
}, [versionCases, filterAssignee, filterStatus]);
|
}, [versionCases, filterAssignee, filterStatus, debouncedKeyword]);
|
||||||
|
|
||||||
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filteredCases, 20);
|
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filteredCases, 20);
|
||||||
|
|
||||||
@@ -58,18 +67,13 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
|||||||
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
|
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
|
||||||
const [bugForCaseId, setBugForCaseId] = useState<string | null>(null);
|
const [bugForCaseId, setBugForCaseId] = useState<string | null>(null);
|
||||||
|
|
||||||
// 批量选择
|
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const isAllSelected = paged.length > 0 && paged.every((c) => selectedIds.has(c.id));
|
|
||||||
|
|
||||||
const toggleSelect = (id: string) => {
|
const toggleSelect = (id: string) => {
|
||||||
const next = new Set(selectedIds);
|
const next = new Set(selectedIds);
|
||||||
if (next.has(id)) next.delete(id); else next.add(id);
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
setSelectedIds(next);
|
setSelectedIds(next);
|
||||||
};
|
};
|
||||||
const toggleAll = () => {
|
|
||||||
setSelectedIds(isAllSelected ? new Set() : new Set(paged.map((c) => c.id)));
|
|
||||||
};
|
|
||||||
const handleBatchDelete = () => {
|
const handleBatchDelete = () => {
|
||||||
if (selectedIds.size === 0) return;
|
if (selectedIds.size === 0) return;
|
||||||
const hasBug = Array.from(selectedIds).some((id) => bugs.some((b) => b.testCaseId === id));
|
const hasBug = Array.from(selectedIds).some((id) => bugs.some((b) => b.testCaseId === id));
|
||||||
@@ -79,7 +83,6 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
|||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
// 按需求分组
|
|
||||||
const groupedByReq = useMemo(() => {
|
const groupedByReq = useMemo(() => {
|
||||||
const map = new Map<string, typeof paged>();
|
const map = new Map<string, typeof paged>();
|
||||||
for (const c of paged) {
|
for (const c of paged) {
|
||||||
@@ -91,12 +94,18 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
|||||||
return map;
|
return map;
|
||||||
}, [paged]);
|
}, [paged]);
|
||||||
|
|
||||||
|
const requirementMap = useMemo(() => new Map(requirements.map((r) => [r.id, r])), [requirements]);
|
||||||
|
const bugCountByCase = useMemo(() => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (const b of bugs) map.set(b.testCaseId, (map.get(b.testCaseId) ?? 0) + 1);
|
||||||
|
return map;
|
||||||
|
}, [bugs]);
|
||||||
|
|
||||||
const assignees = useMemo(() => Array.from(new Set(versionCases.map((c) => c.assigneeId).filter(Boolean) as string[])), [versionCases]);
|
const assignees = useMemo(() => Array.from(new Set(versionCases.map((c) => c.assigneeId).filter(Boolean) as string[])), [versionCases]);
|
||||||
const hasFilter = !!(filterAssignee || filterStatus);
|
const hasFilter = !!(filterAssignee || filterStatus || debouncedKeyword);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* 提测通知 */}
|
|
||||||
{allSubmitted && (
|
{allSubmitted && (
|
||||||
<div className="flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-50 border border-emerald-200">
|
<div className="flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-50 border border-emerald-200">
|
||||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />
|
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />
|
||||||
@@ -110,7 +119,6 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 工具栏:进度 + 筛选 + 操作 一行 */}
|
|
||||||
<div className="flex items-center gap-2 px-4 py-2.5 rounded-lg bg-[var(--bg-subtle)] border border-[var(--line)] flex-wrap">
|
<div className="flex items-center gap-2 px-4 py-2.5 rounded-lg bg-[var(--bg-subtle)] border border-[var(--line)] flex-wrap">
|
||||||
<ClipboardCheck className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
|
<ClipboardCheck className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
|
||||||
<span className="text-[12px] font-medium text-[var(--ink)] shrink-0">{stats.completionRate}%</span>
|
<span className="text-[12px] font-medium text-[var(--ink)] shrink-0">{stats.completionRate}%</span>
|
||||||
@@ -119,9 +127,13 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">{stats.total}例 · 通过{stats.passed} · 失败{stats.failed}</span>
|
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">{stats.total}例 · 通过{stats.passed} · 失败{stats.failed}</span>
|
||||||
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">通过率{stats.passRate}%</span>
|
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">通过率{stats.passRate}%</span>
|
||||||
|
{tcManhours > 0 && (
|
||||||
|
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">日历 {formatWorkHours(tcCalendarHours)} / 人力 {formatWorkHours(tcManhours)}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
<span className="w-px h-4 bg-[var(--line)] mx-1 shrink-0" />
|
<span className="w-px h-4 bg-[var(--line)] mx-1 shrink-0" />
|
||||||
|
|
||||||
|
<SearchInput value={keyword} onChange={(v) => { setKeyword(v); setPage(1); }} placeholder="搜索标题/编号" />
|
||||||
<select value={filterAssignee} onChange={(e) => { setFilterAssignee(e.target.value); setPage(1); }} className="h-6 rounded border border-[var(--line)] bg-[var(--bg-card)] px-1.5 text-[11px] text-[var(--ink-soft)] focus:border-[var(--accent)] focus:outline-none">
|
<select value={filterAssignee} onChange={(e) => { setFilterAssignee(e.target.value); setPage(1); }} className="h-6 rounded border border-[var(--line)] bg-[var(--bg-card)] px-1.5 text-[11px] text-[var(--ink-soft)] focus:border-[var(--accent)] focus:outline-none">
|
||||||
<option value="">负责人</option>
|
<option value="">负责人</option>
|
||||||
{user?.name && <option value={user.name}>我的</option>}
|
{user?.name && <option value={user.name}>我的</option>}
|
||||||
@@ -131,7 +143,7 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
|||||||
<option value="">状态</option>
|
<option value="">状态</option>
|
||||||
{(Object.entries(TEST_CASE_STATUS_LABEL) as [TestCaseStatus, string][]).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
{(Object.entries(TEST_CASE_STATUS_LABEL) as [TestCaseStatus, string][]).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||||
</select>
|
</select>
|
||||||
{hasFilter && <button onClick={() => { setFilterAssignee(''); setFilterStatus(''); setPage(1); }} className="text-[10px] text-[var(--accent)] hover:underline shrink-0">清除</button>}
|
{hasFilter && <button onClick={() => { setFilterAssignee(''); setFilterStatus(''); setKeyword(''); setPage(1); }} className="text-[10px] text-[var(--accent)] hover:underline shrink-0">清除</button>}
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2 shrink-0">
|
<div className="ml-auto flex items-center gap-2 shrink-0">
|
||||||
{selectedIds.size > 0 && (
|
{selectedIds.size > 0 && (
|
||||||
@@ -145,7 +157,6 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 列表 */}
|
|
||||||
{filteredCases.length === 0 ? (
|
{filteredCases.length === 0 ? (
|
||||||
<div className="rounded-xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] p-12 text-center">
|
<div className="rounded-xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] p-12 text-center">
|
||||||
<p className="text-[13px] text-[var(--ink-muted)]">{hasFilter ? '没有匹配的用例' : '暂无测试用例'}</p>
|
<p className="text-[13px] text-[var(--ink-muted)]">{hasFilter ? '没有匹配的用例' : '暂无测试用例'}</p>
|
||||||
@@ -153,7 +164,7 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
Array.from(groupedByReq.entries()).map(([reqId, cases]) => {
|
Array.from(groupedByReq.entries()).map(([reqId, cases]) => {
|
||||||
const req = reqId === '__none__' ? null : requirements.find((r) => r.id === reqId);
|
const req = reqId === '__none__' ? null : requirementMap.get(reqId);
|
||||||
const reqPassed = cases.filter((c) => c.status === 'passed').length;
|
const reqPassed = cases.filter((c) => c.status === 'passed').length;
|
||||||
return (
|
return (
|
||||||
<div key={reqId} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
|
<div key={reqId} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
|
||||||
@@ -169,7 +180,7 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
|||||||
<input type="checkbox" checked={selectedIds.has(c.id)} onChange={() => toggleSelect(c.id)} className="h-3.5 w-3.5 rounded border-[var(--line)]" onClick={(e) => e.stopPropagation()} />
|
<input type="checkbox" checked={selectedIds.has(c.id)} onChange={() => toggleSelect(c.id)} className="h-3.5 w-3.5 rounded border-[var(--line)]" onClick={(e) => e.stopPropagation()} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<TestCaseRow testCase={c} bugCount={bugs.filter((b) => b.testCaseId === c.id).length} onClick={() => setSelectedCaseId(c.id)} />
|
<TestCaseRow testCase={c} bugCount={bugCountByCase.get(c.id) ?? 0} onClick={() => setSelectedCaseId(c.id)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
15
apps/web/hooks/useDebouncedValue.ts
Normal file
15
apps/web/hooks/useDebouncedValue.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
'use client';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 防抖 hook:value 变化后延迟 delay 毫秒才更新返回值
|
||||||
|
* 用于搜索框:用户输入时不立即过滤,避免 100+ 用户同时输入造成的卡顿
|
||||||
|
*/
|
||||||
|
export function useDebouncedValue<T>(value: T, delay = 300): T {
|
||||||
|
const [debounced, setDebounced] = useState(value);
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setDebounced(value), delay);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [value, delay]);
|
||||||
|
return debounced;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Priority } from './derive';
|
import type { Priority } from './derive';
|
||||||
|
import { calcWorkHours, type TimeInterval } from './work-hours';
|
||||||
|
|
||||||
export type BugStatus = 'open' | 'fixing' | 'fixed' | 'verifying' | 'closed' | 'rejected';
|
export type BugStatus = 'open' | 'fixing' | 'fixed' | 'verifying' | 'closed' | 'rejected';
|
||||||
export type BugSeverity = 'critical' | 'major' | 'minor' | 'trivial';
|
export type BugSeverity = 'critical' | 'major' | 'minor' | 'trivial';
|
||||||
@@ -87,3 +88,32 @@ export function generateBugNo(existingBugs: Bug[]): string {
|
|||||||
}, 0);
|
}, 0);
|
||||||
return `BUG-${String(maxNum + 1).padStart(3, '0')}`;
|
return `BUG-${String(maxNum + 1).padStart(3, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getBugActualHours(bug: Bug, now: Date = new Date()): number {
|
||||||
|
const start = bug.createdAt;
|
||||||
|
if (!start) return 0;
|
||||||
|
const end = bug.closedAt ?? bug.resolvedAt ?? (bug.status === 'closed' || bug.status === 'rejected' ? bug.updatedAt : now.toISOString());
|
||||||
|
return calcWorkHours(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aggregateBugActualHours(bugs: Bug[], now: Date = new Date()): number {
|
||||||
|
let sum = 0;
|
||||||
|
for (const b of bugs) sum += getBugActualHours(b, now);
|
||||||
|
return Math.round(sum * 2) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 抽取每条 Bug 的 [createdAt, closedAt ?? resolvedAt ?? (终态 updatedAt) ?? now] 时间区间
|
||||||
|
* 用于双口径耗时统计(calcTwoMetrics)
|
||||||
|
*/
|
||||||
|
export function bugIntervals(bugs: Bug[], now: Date = new Date()): TimeInterval[] {
|
||||||
|
const out: TimeInterval[] = [];
|
||||||
|
const nowIso = now.toISOString();
|
||||||
|
for (const b of bugs) {
|
||||||
|
if (!b.createdAt) continue;
|
||||||
|
const isTerminal = b.status === 'closed' || b.status === 'rejected';
|
||||||
|
const end = b.closedAt ?? b.resolvedAt ?? (isTerminal ? b.updatedAt : nowIso);
|
||||||
|
out.push({ start: b.createdAt, end });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|||||||
62
apps/web/lib/dev-task-transitions.ts
Normal file
62
apps/web/lib/dev-task-transitions.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import type { DevTask } from './dev-task';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否可以"手动"切到 in_progress
|
||||||
|
* - 仅当 todo 且未到 expectedStartAt 时返回 true
|
||||||
|
* - 已到点:自动机制接管,UI 不应再显示手动按钮
|
||||||
|
* - 已超期:返回 false,UI 应要求填 delayReason
|
||||||
|
*/
|
||||||
|
export function canManualStart(task: DevTask, now: Date = new Date()): boolean {
|
||||||
|
if (task.status !== 'todo') return false;
|
||||||
|
if (!task.expectedStartAt) return true;
|
||||||
|
return now.getTime() < new Date(task.expectedStartAt).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自动切换扫描:返回所有需要从 todo 切到 in_progress 的任务
|
||||||
|
* - status === 'todo'
|
||||||
|
* - now >= expectedStartAt(且未超期超过 0 秒就自动切——超期未切的可单独走 needsDelayReason 流程)
|
||||||
|
*
|
||||||
|
* 注意:超期任务也包含在内,由调用方决定要不要扫;当前策略是 fetchTasks 调用时只扫"刚好到点",
|
||||||
|
* 超期任务保留在 todo 等用户填 delayReason。所以这里加 maxOverdueMs 参数,默认 24h 内的算到点。
|
||||||
|
*/
|
||||||
|
export function findTasksToAutoStart(
|
||||||
|
tasks: DevTask[],
|
||||||
|
now: Date = new Date(),
|
||||||
|
maxOverdueMs: number = 24 * 60 * 60 * 1000,
|
||||||
|
): Array<{ taskId: string; actualStartAt: string }> {
|
||||||
|
const result: Array<{ taskId: string; actualStartAt: string }> = [];
|
||||||
|
for (const t of tasks) {
|
||||||
|
if (t.status !== 'todo' || !t.expectedStartAt || t.actualStartAt) continue;
|
||||||
|
const expected = new Date(t.expectedStartAt).getTime();
|
||||||
|
if (isNaN(expected)) continue;
|
||||||
|
const diff = now.getTime() - expected;
|
||||||
|
if (diff >= 0 && diff <= maxOverdueMs) {
|
||||||
|
result.push({ taskId: t.id, actualStartAt: t.expectedStartAt });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否处于"超期手动开干需填延后原因"状态
|
||||||
|
* - todo 且 now > expectedStartAt
|
||||||
|
*/
|
||||||
|
export function needsDelayReason(task: DevTask, now: Date = new Date()): boolean {
|
||||||
|
if (task.status !== 'todo' || !task.expectedStartAt) return false;
|
||||||
|
return now.getTime() > new Date(task.expectedStartAt).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 进入 in_progress 时计算 actualStartAt
|
||||||
|
* - auto: 等于 expectedStartAt
|
||||||
|
* - manual: 等于 now
|
||||||
|
*/
|
||||||
|
export function deriveActualStartAt(
|
||||||
|
task: DevTask,
|
||||||
|
trigger: 'auto' | 'manual',
|
||||||
|
now: Date = new Date(),
|
||||||
|
): string {
|
||||||
|
if (trigger === 'auto' && task.expectedStartAt) return task.expectedStartAt;
|
||||||
|
return now.toISOString();
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Priority } from './derive';
|
import type { Priority } from './derive';
|
||||||
|
import { calcWorkHours, formatWorkHours, type TimeInterval } from './work-hours';
|
||||||
|
|
||||||
export type DevTaskStatus = 'todo' | 'in_progress' | 'testing' | 'submitted';
|
export type DevTaskStatus = 'todo' | 'in_progress' | 'testing' | 'submitted';
|
||||||
|
|
||||||
@@ -12,18 +13,20 @@ export interface DevTask {
|
|||||||
assigneeId: string;
|
assigneeId: string;
|
||||||
reviewerId?: string;
|
reviewerId?: string;
|
||||||
priority: Priority;
|
priority: Priority;
|
||||||
estimateHours: number;
|
|
||||||
actualHours: number;
|
expectedStartAt: string;
|
||||||
startDate?: string;
|
expectedEndAt: string;
|
||||||
dueDate?: string;
|
actualStartAt?: string;
|
||||||
completedAt?: string;
|
actualEndAt?: string;
|
||||||
|
|
||||||
status: DevTaskStatus;
|
status: DevTaskStatus;
|
||||||
isBlocked: boolean;
|
isBlocked: boolean;
|
||||||
blockReason?: string;
|
blockReason?: string;
|
||||||
blockedById?: string;
|
blockedById?: string;
|
||||||
predecessorIds?: string[];
|
predecessorIds?: string[];
|
||||||
riskLevel?: 'low' | 'medium' | 'high';
|
riskLevel?: 'low' | 'medium' | 'high';
|
||||||
overdueReason?: string;
|
delayReason?: string;
|
||||||
|
overdueVersionReason?: string;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -67,28 +70,82 @@ export function calcTaskProgress(task: DevTask): number {
|
|||||||
|
|
||||||
export function calcGroupProgress(tasks: DevTask[]): number {
|
export function calcGroupProgress(tasks: DevTask[]): number {
|
||||||
if (tasks.length === 0) return 0;
|
if (tasks.length === 0) return 0;
|
||||||
const totalEstimate = tasks.reduce((sum, t) => sum + t.estimateHours, 0);
|
const totalEstimate = tasks.reduce((sum, t) => sum + getEstimateHours(t), 0);
|
||||||
if (totalEstimate === 0) return 0;
|
if (totalEstimate === 0) {
|
||||||
const weighted = tasks.reduce((sum, t) => sum + t.estimateHours * STATUS_PROGRESS[t.status], 0);
|
const sum = tasks.reduce((s, t) => s + STATUS_PROGRESS[t.status], 0);
|
||||||
|
return Math.round(sum / tasks.length);
|
||||||
|
}
|
||||||
|
const weighted = tasks.reduce((sum, t) => sum + getEstimateHours(t) * STATUS_PROGRESS[t.status], 0);
|
||||||
return Math.round(weighted / totalEstimate);
|
return Math.round(weighted / totalEstimate);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatHours(hours: number): string {
|
export function formatHours(hours: number): string {
|
||||||
if (hours < 8) return `${hours}h`;
|
return formatWorkHours(hours);
|
||||||
const days = Math.floor(hours / 8);
|
|
||||||
const remainder = hours % 8;
|
|
||||||
if (remainder === 0) return `${hours}h(${days}人天)`;
|
|
||||||
return `${hours}h(≈${days}人天)`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calcActualHoursByDates(startDate?: string, completedAt?: string): number {
|
export function getEstimateHours(task: DevTask): number {
|
||||||
if (!startDate) return 0;
|
if (!task.expectedStartAt || !task.expectedEndAt) return 0;
|
||||||
const end = completedAt || new Date().toISOString();
|
return calcWorkHours(task.expectedStartAt, task.expectedEndAt);
|
||||||
const startMs = new Date(startDate).getTime();
|
}
|
||||||
const endMs = new Date(end).getTime();
|
|
||||||
if (isNaN(startMs) || isNaN(endMs) || endMs < startMs) return 0;
|
export function getActualHours(task: DevTask, now: Date = new Date()): number {
|
||||||
const diffHours = (endMs - startMs) / (1000 * 60 * 60);
|
if (!task.actualStartAt) return 0;
|
||||||
return Math.round(diffHours * 2) / 2; // 精确到0.5h
|
const end = task.actualEndAt ?? now.toISOString();
|
||||||
|
return calcWorkHours(task.actualStartAt, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AggregatedHours {
|
||||||
|
estimate: number;
|
||||||
|
actual: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aggregateDevTaskHours(tasks: DevTask[], now: Date = new Date()): AggregatedHours {
|
||||||
|
let estimate = 0;
|
||||||
|
let actual = 0;
|
||||||
|
for (const t of tasks) {
|
||||||
|
estimate += getEstimateHours(t);
|
||||||
|
actual += getActualHours(t, now);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
estimate: Math.round(estimate * 2) / 2,
|
||||||
|
actual: Math.round(actual * 2) / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aggregateActualHoursByAssignee(
|
||||||
|
tasks: DevTask[],
|
||||||
|
now: Date = new Date(),
|
||||||
|
): Array<{ assigneeId: string; actualHours: number; taskCount: number }> {
|
||||||
|
const map = new Map<string, { actualHours: number; taskCount: number }>();
|
||||||
|
for (const t of tasks) {
|
||||||
|
const h = getActualHours(t, now);
|
||||||
|
if (h <= 0 && t.status !== 'submitted') continue;
|
||||||
|
const cur = map.get(t.assigneeId) || { actualHours: 0, taskCount: 0 };
|
||||||
|
cur.actualHours += h;
|
||||||
|
cur.taskCount += 1;
|
||||||
|
map.set(t.assigneeId, cur);
|
||||||
|
}
|
||||||
|
return Array.from(map.entries())
|
||||||
|
.map(([assigneeId, v]) => ({
|
||||||
|
assigneeId,
|
||||||
|
actualHours: Math.round(v.actualHours * 2) / 2,
|
||||||
|
taskCount: v.taskCount,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.actualHours - a.actualHours);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 抽取每条已开干任务的 [actualStartAt, actualEndAt ?? now] 时间区间
|
||||||
|
* 用于双口径耗时统计(calcTwoMetrics)
|
||||||
|
*/
|
||||||
|
export function devTaskIntervals(tasks: DevTask[], now: Date = new Date()): TimeInterval[] {
|
||||||
|
const out: TimeInterval[] = [];
|
||||||
|
const nowIso = now.toISOString();
|
||||||
|
for (const t of tasks) {
|
||||||
|
if (!t.actualStartAt) continue;
|
||||||
|
out.push({ start: t.actualStartAt, end: t.actualEndAt ?? nowIso });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateTaskNo(existingTasks: DevTask[]): string {
|
export function generateTaskNo(existingTasks: DevTask[]): string {
|
||||||
@@ -98,3 +155,29 @@ export function generateTaskNo(existingTasks: DevTask[]): string {
|
|||||||
}, 0);
|
}, 0);
|
||||||
return `DEV-${String(maxNum + 1).padStart(3, '0')}`;
|
return `DEV-${String(maxNum + 1).padStart(3, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isLegacyTask(t: any): boolean {
|
||||||
|
if (!t || typeof t !== 'object') return false;
|
||||||
|
return (
|
||||||
|
'startDate' in t ||
|
||||||
|
'dueDate' in t ||
|
||||||
|
'completedAt' in t ||
|
||||||
|
'estimateHours' in t ||
|
||||||
|
'actualHours' in t ||
|
||||||
|
'overdueReason' in t
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 兼容函数:按纯小时差计算耗时(保留给 PlanTab/TestCase 体系使用)
|
||||||
|
* DevTask 体系应改用 getActualHours/getEstimateHours 走 calcWorkHours
|
||||||
|
*/
|
||||||
|
export function calcActualHoursByDates(startDate?: string, completedAt?: string): number {
|
||||||
|
if (!startDate) return 0;
|
||||||
|
const end = completedAt || new Date().toISOString();
|
||||||
|
const startMs = new Date(startDate).getTime();
|
||||||
|
const endMs = new Date(end).getTime();
|
||||||
|
if (isNaN(startMs) || isNaN(endMs) || endMs < startMs) return 0;
|
||||||
|
const diffHours = (endMs - startMs) / (1000 * 60 * 60);
|
||||||
|
return Math.round(diffHours * 2) / 2;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Priority } from './derive';
|
import type { Priority } from './derive';
|
||||||
|
import { calcWorkHours, type TimeInterval } from './work-hours';
|
||||||
|
|
||||||
export type TestCaseStatus = 'pending' | 'running' | 'passed' | 'failed' | 'blocked';
|
export type TestCaseStatus = 'pending' | 'running' | 'passed' | 'failed' | 'blocked';
|
||||||
|
|
||||||
@@ -70,3 +71,29 @@ export function calcTestProgress(cases: TestCase[]): { total: number; executed:
|
|||||||
const completionRate = Math.round((executed / total) * 100);
|
const completionRate = Math.round((executed / total) * 100);
|
||||||
return { total, executed, passed, failed, blocked, passRate, completionRate };
|
return { total, executed, passed, failed, blocked, passRate, completionRate };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getTestCaseActualHours(tc: TestCase, now: Date = new Date()): number {
|
||||||
|
if (!tc.startedAt) return 0;
|
||||||
|
const end = tc.completedAt ?? now.toISOString();
|
||||||
|
return calcWorkHours(tc.startedAt, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aggregateTestCaseActualHours(cases: TestCase[], now: Date = new Date()): number {
|
||||||
|
let sum = 0;
|
||||||
|
for (const c of cases) sum += getTestCaseActualHours(c, now);
|
||||||
|
return Math.round(sum * 2) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 抽取每条已开始用例的 [startedAt, completedAt ?? now] 时间区间
|
||||||
|
* 用于双口径耗时统计(calcTwoMetrics)
|
||||||
|
*/
|
||||||
|
export function testCaseIntervals(cases: TestCase[], now: Date = new Date()): TimeInterval[] {
|
||||||
|
const out: TimeInterval[] = [];
|
||||||
|
const nowIso = now.toISOString();
|
||||||
|
for (const c of cases) {
|
||||||
|
if (!c.startedAt) continue;
|
||||||
|
out.push({ start: c.startedAt, end: c.completedAt ?? nowIso });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|||||||
@@ -96,3 +96,19 @@ export function calcTotalDuration(plans: VersionPlan[]): string {
|
|||||||
const totalDays = Math.ceil(totalMs / (1000 * 60 * 60 * 24));
|
const totalDays = Math.ceil(totalMs / (1000 * 60 * 60 * 24));
|
||||||
return `${totalDays}天`;
|
return `${totalDays}天`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import type { TimeInterval } from './work-hours';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 抽取每条已开始计划的 [actualStartAt, completedAt ?? now] 时间区间
|
||||||
|
* 用于双口径耗时统计(calcTwoMetrics)
|
||||||
|
*/
|
||||||
|
export function planIntervals(plans: VersionPlan[], now: Date = new Date()): TimeInterval[] {
|
||||||
|
const out: TimeInterval[] = [];
|
||||||
|
const nowIso = now.toISOString();
|
||||||
|
for (const p of plans) {
|
||||||
|
if (!p.actualStartAt) continue;
|
||||||
|
out.push({ start: p.actualStartAt, end: p.completedAt ?? nowIso });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|||||||
244
apps/web/lib/work-hours.ts
Normal file
244
apps/web/lib/work-hours.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
export const WORK_HOURS = {
|
||||||
|
morningStart: 9,
|
||||||
|
morningEnd: 12,
|
||||||
|
afternoonStart: 13,
|
||||||
|
afternoonEnd: 18,
|
||||||
|
hoursPerDay: 8,
|
||||||
|
skipWeekends: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const MS_PER_HOUR = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function isWeekend(d: Date): boolean {
|
||||||
|
const wd = d.getDay();
|
||||||
|
return wd === 0 || wd === 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(v: number, lo: number, hi: number): number {
|
||||||
|
return Math.max(lo, Math.min(hi, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayBound(d: Date, hourFloat: number): Date {
|
||||||
|
const h = Math.floor(hourFloat);
|
||||||
|
const m = Math.round((hourFloat - h) * 60);
|
||||||
|
const r = new Date(d);
|
||||||
|
r.setHours(h, m, 0, 0);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayOverlapHours(start: Date, end: Date, day: Date): number {
|
||||||
|
if (WORK_HOURS.skipWeekends && isWeekend(day)) return 0;
|
||||||
|
const morningStart = dayBound(day, WORK_HOURS.morningStart);
|
||||||
|
const morningEnd = dayBound(day, WORK_HOURS.morningEnd);
|
||||||
|
const afternoonStart = dayBound(day, WORK_HOURS.afternoonStart);
|
||||||
|
const afternoonEnd = dayBound(day, WORK_HOURS.afternoonEnd);
|
||||||
|
const morning = clamp(end.getTime(), morningStart.getTime(), morningEnd.getTime())
|
||||||
|
- clamp(start.getTime(), morningStart.getTime(), morningEnd.getTime());
|
||||||
|
const afternoon = clamp(end.getTime(), afternoonStart.getTime(), afternoonEnd.getTime())
|
||||||
|
- clamp(start.getTime(), afternoonStart.getTime(), afternoonEnd.getTime());
|
||||||
|
return Math.max(0, morning + afternoon) / MS_PER_HOUR;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算 ISO 时间区间内属于工作时段的小时数
|
||||||
|
*
|
||||||
|
* 工作时段:9:00–12:00 + 13:00–18:00(共 8h/天),周末跳过
|
||||||
|
* 精度:0.5h
|
||||||
|
*
|
||||||
|
* 验证用例(注释式,供日后接 vitest 迁移):
|
||||||
|
* - 单日 11:00→14:00 = 1.5h(午休 12:00–13:00 不算)
|
||||||
|
* - 单日 09:00→18:00 = 8h
|
||||||
|
* - 跨天 1月1日 09:00 → 1月2日 14:00 = 8 + 1 = 9h
|
||||||
|
* (注:旧需求是 9:00–18:00 无午休=14h,现按 8h/天口径,结果 9h)
|
||||||
|
* - 跨周末 周五 17:00 → 周一 11:00 = 1 + 2 = 3h
|
||||||
|
* - 全在午休 12:30→13:00 = 0h
|
||||||
|
* - 全在周末 周六全天 = 0h
|
||||||
|
* - 负区间 / 非法 ISO = 0
|
||||||
|
*/
|
||||||
|
export function calcWorkHours(startISO: string, endISO: string): number {
|
||||||
|
if (!startISO || !endISO) return 0;
|
||||||
|
const start = new Date(startISO);
|
||||||
|
const end = new Date(endISO);
|
||||||
|
if (isNaN(start.getTime()) || isNaN(end.getTime())) return 0;
|
||||||
|
if (end.getTime() <= start.getTime()) return 0;
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
const cursor = new Date(start);
|
||||||
|
cursor.setHours(0, 0, 0, 0);
|
||||||
|
const endDay = new Date(end);
|
||||||
|
endDay.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
while (cursor.getTime() <= endDay.getTime()) {
|
||||||
|
total += dayOverlapHours(start, end, cursor);
|
||||||
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.round(total * 2) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把小时数格式化为 "Xh(Y天)",Y 保留 1 位小数(整数则不带小数)
|
||||||
|
*/
|
||||||
|
export function formatWorkHours(hours: number): string {
|
||||||
|
if (!isFinite(hours) || hours <= 0) return '0h(0天)';
|
||||||
|
const days = hours / WORK_HOURS.hoursPerDay;
|
||||||
|
const dayStr = Number.isInteger(days) ? String(days) : days.toFixed(1).replace(/\.0$/, '');
|
||||||
|
return `${hours}h(${dayStr}天)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 简短版:"Xh",不带天数(用于横向空间紧张的列表行)
|
||||||
|
*/
|
||||||
|
export function formatWorkHoursShort(hours: number): string {
|
||||||
|
if (!isFinite(hours) || hours <= 0) return '0h';
|
||||||
|
return `${hours}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅天数版:"Y天"
|
||||||
|
*/
|
||||||
|
export function formatWorkDays(hours: number): string {
|
||||||
|
if (!isFinite(hours) || hours <= 0) return '0天';
|
||||||
|
const days = hours / WORK_HOURS.hoursPerDay;
|
||||||
|
const dayStr = Number.isInteger(days) ? String(days) : days.toFixed(1).replace(/\.0$/, '');
|
||||||
|
return `${dayStr}天`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 给定开始 ISO + 工时小时数,返回结束 ISO(备用:旧 estimateHours 反推 expectedEndAt)
|
||||||
|
* 算法:从开始时间起,按工作时段累加,到达目标小时数即返回。
|
||||||
|
*/
|
||||||
|
export function addWorkHours(startISO: string, hours: number): string {
|
||||||
|
const start = new Date(startISO);
|
||||||
|
if (isNaN(start.getTime()) || hours <= 0) return startISO;
|
||||||
|
let remaining = hours;
|
||||||
|
const cursor = new Date(start);
|
||||||
|
while (remaining > 0) {
|
||||||
|
const day = new Date(cursor);
|
||||||
|
day.setHours(0, 0, 0, 0);
|
||||||
|
if (WORK_HOURS.skipWeekends && isWeekend(day)) {
|
||||||
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
|
cursor.setHours(WORK_HOURS.morningStart, 0, 0, 0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const morningStart = dayBound(day, WORK_HOURS.morningStart);
|
||||||
|
const morningEnd = dayBound(day, WORK_HOURS.morningEnd);
|
||||||
|
const afternoonStart = dayBound(day, WORK_HOURS.afternoonStart);
|
||||||
|
const afternoonEnd = dayBound(day, WORK_HOURS.afternoonEnd);
|
||||||
|
const segments: Array<[Date, Date]> = [
|
||||||
|
[morningStart, morningEnd],
|
||||||
|
[afternoonStart, afternoonEnd],
|
||||||
|
];
|
||||||
|
let advanced = false;
|
||||||
|
for (const [segStart, segEnd] of segments) {
|
||||||
|
if (cursor.getTime() >= segEnd.getTime()) continue;
|
||||||
|
const segBegin = cursor.getTime() < segStart.getTime() ? segStart : cursor;
|
||||||
|
const availableH = (segEnd.getTime() - segBegin.getTime()) / MS_PER_HOUR;
|
||||||
|
if (availableH <= 0) continue;
|
||||||
|
if (remaining <= availableH) {
|
||||||
|
const finalMs = segBegin.getTime() + remaining * MS_PER_HOUR;
|
||||||
|
return new Date(finalMs).toISOString();
|
||||||
|
}
|
||||||
|
remaining -= availableH;
|
||||||
|
cursor.setTime(segEnd.getTime());
|
||||||
|
advanced = true;
|
||||||
|
}
|
||||||
|
if (!advanced) {
|
||||||
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
|
cursor.setHours(WORK_HOURS.morningStart, 0, 0, 0);
|
||||||
|
} else if (cursor.getTime() >= dayBound(day, WORK_HOURS.afternoonEnd).getTime()) {
|
||||||
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
|
cursor.setHours(WORK_HOURS.morningStart, 0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cursor.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 datetime-local 输入值(如 "2026-06-16T09:00")规范化为本地 ISO(含秒和毫秒)
|
||||||
|
*/
|
||||||
|
export function localToISO(local: string): string {
|
||||||
|
if (!local) return '';
|
||||||
|
const d = new Date(local);
|
||||||
|
return isNaN(d.getTime()) ? '' : d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 ISO 时间转成 datetime-local 输入值
|
||||||
|
*/
|
||||||
|
export function isoToLocal(iso: string): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return '';
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 友好显示:MM-DD HH:mm
|
||||||
|
*/
|
||||||
|
export function formatShortTime(iso: string): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return '';
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeInterval {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并多个时间区间到工作时段内的总小时数(不重复计数)
|
||||||
|
*
|
||||||
|
* 算法:按 start 排序后扫描,重叠区间合并 end,对每个合并后的区间调 calcWorkHours 累加
|
||||||
|
* 用例:A(9-11) + B(10-12) → 合并为 9-12 = 3h
|
||||||
|
*/
|
||||||
|
export function mergeWorkHours(intervals: TimeInterval[]): number {
|
||||||
|
const valid = intervals
|
||||||
|
.filter((i) => i.start && i.end)
|
||||||
|
.map((i) => ({ start: new Date(i.start).getTime(), end: new Date(i.end).getTime() }))
|
||||||
|
.filter((i) => !isNaN(i.start) && !isNaN(i.end) && i.end > i.start)
|
||||||
|
.sort((a, b) => a.start - b.start);
|
||||||
|
|
||||||
|
if (valid.length === 0) return 0;
|
||||||
|
|
||||||
|
const merged: Array<{ start: number; end: number }> = [];
|
||||||
|
let cur = { ...valid[0] };
|
||||||
|
for (let i = 1; i < valid.length; i++) {
|
||||||
|
const next = valid[i];
|
||||||
|
if (next.start <= cur.end) {
|
||||||
|
cur.end = Math.max(cur.end, next.end);
|
||||||
|
} else {
|
||||||
|
merged.push(cur);
|
||||||
|
cur = { ...next };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merged.push(cur);
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
for (const m of merged) {
|
||||||
|
total += calcWorkHours(new Date(m.start).toISOString(), new Date(m.end).toISOString());
|
||||||
|
}
|
||||||
|
return Math.round(total * 2) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 双口径汇总:
|
||||||
|
* - calendarHours:合并所有重叠区间后的日历耗时(看版本实际走了几天)
|
||||||
|
* - manhours:单条独立累加的人力投入(看总人力成本)
|
||||||
|
*/
|
||||||
|
export function calcTwoMetrics(intervals: TimeInterval[]): {
|
||||||
|
calendarHours: number;
|
||||||
|
manhours: number;
|
||||||
|
} {
|
||||||
|
const calendarHours = mergeWorkHours(intervals);
|
||||||
|
let manhours = 0;
|
||||||
|
for (const i of intervals) {
|
||||||
|
if (!i.start || !i.end) continue;
|
||||||
|
manhours += calcWorkHours(i.start, i.end);
|
||||||
|
}
|
||||||
|
return { calendarHours, manhours: Math.round(manhours * 2) / 2 };
|
||||||
|
}
|
||||||
@@ -43,7 +43,7 @@ export function devTaskToWorkItem(task: DevTask, versionId: string, categoryLabe
|
|||||||
reviewerId: task.reviewerId,
|
reviewerId: task.reviewerId,
|
||||||
versionId,
|
versionId,
|
||||||
priority: task.priority,
|
priority: task.priority,
|
||||||
dueDate: task.dueDate,
|
dueDate: task.expectedEndAt,
|
||||||
categoryLabel,
|
categoryLabel,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,13 @@ export function aggregateWorkItems(
|
|||||||
versionName: ver?.name ?? '-',
|
versionName: ver?.name ?? '-',
|
||||||
versionId,
|
versionId,
|
||||||
priority: t.priority,
|
priority: t.priority,
|
||||||
extra: { taskNo: t.taskNo, dueDate: t.dueDate },
|
extra: {
|
||||||
|
taskNo: t.taskNo,
|
||||||
|
expectedStartAt: t.expectedStartAt,
|
||||||
|
expectedEndAt: t.expectedEndAt,
|
||||||
|
actualStartAt: t.actualStartAt,
|
||||||
|
actualEndAt: t.actualEndAt,
|
||||||
|
},
|
||||||
raw: t,
|
raw: t,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { DevTask, DevTaskStatus } from '@/lib/dev-task';
|
import type { DevTask, DevTaskStatus } from '@/lib/dev-task';
|
||||||
import { canTransition, generateTaskNo } from '@/lib/dev-task';
|
import { canTransition, generateTaskNo, isLegacyTask } from '@/lib/dev-task';
|
||||||
|
import { findTasksToAutoStart, deriveActualStartAt } from '@/lib/dev-task-transitions';
|
||||||
|
|
||||||
const STORAGE_KEY = 'ftb_dev_tasks_v1';
|
const STORAGE_KEY = 'ftb_dev_tasks_v2';
|
||||||
|
const LEGACY_KEY = 'ftb_dev_tasks_v1';
|
||||||
|
|
||||||
function saveLocal(items: DevTask[]) {
|
function saveLocal(items: DevTask[]) {
|
||||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); } catch {}
|
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); } catch {}
|
||||||
@@ -12,7 +14,17 @@ function saveLocal(items: DevTask[]) {
|
|||||||
function loadLocal(): DevTask[] | null {
|
function loadLocal(): DevTask[] | null {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
if (raw) return JSON.parse(raw);
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (Array.isArray(parsed) && parsed.some(isLegacyTask)) {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
if (localStorage.getItem(LEGACY_KEY)) {
|
||||||
|
localStorage.removeItem(LEGACY_KEY);
|
||||||
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -20,12 +32,11 @@ function loadLocal(): DevTask[] | null {
|
|||||||
interface DevTaskState {
|
interface DevTaskState {
|
||||||
tasks: DevTask[];
|
tasks: DevTask[];
|
||||||
fetchTasks: () => void;
|
fetchTasks: () => void;
|
||||||
createTask: (data: Omit<DevTask, 'id' | 'taskNo' | 'createdAt' | 'updatedAt' | 'actualHours' | 'isBlocked'>) => DevTask;
|
createTask: (data: Omit<DevTask, 'id' | 'taskNo' | 'createdAt' | 'updatedAt' | 'isBlocked'>) => DevTask;
|
||||||
updateTask: (id: string, data: Partial<DevTask>) => void;
|
updateTask: (id: string, data: Partial<DevTask>) => void;
|
||||||
deleteTask: (id: string) => void;
|
deleteTask: (id: string) => void;
|
||||||
changeStatus: (id: string, to: DevTaskStatus) => { ok: boolean; message?: string };
|
changeStatus: (id: string, to: DevTaskStatus, opts?: { delayReason?: string }) => { ok: boolean; message?: string };
|
||||||
setBlocked: (id: string, blocked: boolean, reason?: string, blockedById?: string) => void;
|
setBlocked: (id: string, blocked: boolean, reason?: string, blockedById?: string) => void;
|
||||||
syncActualHours: (taskId: string, hours: number) => void;
|
|
||||||
getByRequirement: (requirementId: string) => DevTask[];
|
getByRequirement: (requirementId: string) => DevTask[];
|
||||||
getByVersionViaRequirements: (requirementIds: string[]) => DevTask[];
|
getByVersionViaRequirements: (requirementIds: string[]) => DevTask[];
|
||||||
getByAssignee: (assigneeId: string) => DevTask[];
|
getByAssignee: (assigneeId: string) => DevTask[];
|
||||||
@@ -36,7 +47,21 @@ export const useDevTaskStore = create<DevTaskState>((set, get) => ({
|
|||||||
|
|
||||||
fetchTasks: () => {
|
fetchTasks: () => {
|
||||||
const cached = loadLocal();
|
const cached = loadLocal();
|
||||||
if (cached) set({ tasks: cached });
|
const list = cached ?? [];
|
||||||
|
const now = new Date();
|
||||||
|
const autoStarts = findTasksToAutoStart(list, now);
|
||||||
|
if (autoStarts.length > 0) {
|
||||||
|
const map = new Map(autoStarts.map((a) => [a.taskId, a.actualStartAt]));
|
||||||
|
const updated = list.map((t) =>
|
||||||
|
map.has(t.id)
|
||||||
|
? { ...t, status: 'in_progress' as DevTaskStatus, actualStartAt: map.get(t.id)!, updatedAt: now.toISOString() }
|
||||||
|
: t,
|
||||||
|
);
|
||||||
|
set({ tasks: updated });
|
||||||
|
saveLocal(updated);
|
||||||
|
} else {
|
||||||
|
set({ tasks: list });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
createTask: (data) => {
|
createTask: (data) => {
|
||||||
@@ -46,7 +71,6 @@ export const useDevTaskStore = create<DevTaskState>((set, get) => ({
|
|||||||
...data,
|
...data,
|
||||||
id: `task-${Date.now()}`,
|
id: `task-${Date.now()}`,
|
||||||
taskNo: generateTaskNo(list),
|
taskNo: generateTaskNo(list),
|
||||||
actualHours: 0,
|
|
||||||
isBlocked: false,
|
isBlocked: false,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -71,16 +95,22 @@ export const useDevTaskStore = create<DevTaskState>((set, get) => ({
|
|||||||
saveLocal(updated);
|
saveLocal(updated);
|
||||||
},
|
},
|
||||||
|
|
||||||
changeStatus: (id, to) => {
|
changeStatus: (id, to, opts) => {
|
||||||
const task = get().tasks.find((t) => t.id === id);
|
const task = get().tasks.find((t) => t.id === id);
|
||||||
if (!task) return { ok: false, message: '任务不存在' };
|
if (!task) return { ok: false, message: '任务不存在' };
|
||||||
if (!canTransition(task.status, to)) {
|
if (!canTransition(task.status, to)) {
|
||||||
return { ok: false, message: `不允许从「${task.status}」流转到「${to}」` };
|
return { ok: false, message: `不允许从「${task.status}」流转到「${to}」` };
|
||||||
}
|
}
|
||||||
const now = new Date().toISOString();
|
const now = new Date();
|
||||||
const startDate = (to === 'in_progress' && !task.startDate) ? now : task.startDate;
|
const patch: Partial<DevTask> = { status: to };
|
||||||
const completedAt = (to === 'submitted' && !task.completedAt) ? now : task.completedAt;
|
if (to === 'in_progress' && !task.actualStartAt) {
|
||||||
get().updateTask(id, { status: to, startDate, completedAt });
|
patch.actualStartAt = deriveActualStartAt(task, 'manual', now);
|
||||||
|
if (opts?.delayReason) patch.delayReason = opts.delayReason;
|
||||||
|
}
|
||||||
|
if (to === 'submitted') {
|
||||||
|
patch.actualEndAt = now.toISOString();
|
||||||
|
}
|
||||||
|
get().updateTask(id, patch);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -92,10 +122,6 @@ export const useDevTaskStore = create<DevTaskState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
syncActualHours: (taskId, hours) => {
|
|
||||||
get().updateTask(taskId, { actualHours: hours });
|
|
||||||
},
|
|
||||||
|
|
||||||
getByRequirement: (requirementId) => {
|
getByRequirement: (requirementId) => {
|
||||||
return get().tasks.filter((t) => t.requirementId === requirementId);
|
return get().tasks.filter((t) => t.requirementId === requirementId);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
# 版本统计「日历耗时 + 人力投入」双口径设计
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
当前版本详情下的开发任务/测试用例/Bug 三个 Tab 顶部统计,以及概览页的"总人天投入",都用**单条任务独立累加**计算耗时。问题:
|
||||||
|
|
||||||
|
- 任务 A:09:00–11:00(2h)
|
||||||
|
- 任务 B:10:00–12:00(2h)
|
||||||
|
|
||||||
|
实际只占用了 09:00–12:00 共 3h 工作时段,但当前累加算成 4h。
|
||||||
|
|
||||||
|
任务越多重叠越严重,"总耗时"会显著虚高,无法回答"这个版本实际走了几天"。
|
||||||
|
|
||||||
|
**目标**:顶部统计同时展示两个口径——
|
||||||
|
|
||||||
|
- **日历耗时**:合并区间后的真实工作时段占用(看版本实际走了多久)
|
||||||
|
- **人力投入**:单条独立累加(看总人力成本,原口径)
|
||||||
|
|
||||||
|
## 决策摘要
|
||||||
|
|
||||||
|
| 项 | 决策 |
|
||||||
|
|---|---|
|
||||||
|
| 算法位置 | `apps/web/lib/work-hours.ts` 新增 `mergeWorkHours` + `calcTwoMetrics` |
|
||||||
|
| 区间提取 | 各模块(dev-task / test-case / bug)暴露 `xxxIntervals(items)` 函数 |
|
||||||
|
| 顶部展示 | 三段式:"预 X / 日历 Y / 人力 Z"(开发任务);"日历 Y / 人力 Z"(测试用例 / Bug) |
|
||||||
|
| 概览 总人天 | 两行展示:日历总耗时 / 人力总投入(含调研/产品/UI/开发/测试/Bug 全程) |
|
||||||
|
| 个人耗时排名 | 不动(按人汇总天然是人力维度) |
|
||||||
|
| PlanTab 顶部 | 不动(不在本次范围) |
|
||||||
|
| 老聚合函数 | 保留(向后兼容,仍然是 manhours) |
|
||||||
|
| Bug 区间 end 优先级 | `closedAt > resolvedAt > 终态 updatedAt > now` |
|
||||||
|
|
||||||
|
## 1. 合并区间算法
|
||||||
|
|
||||||
|
`apps/web/lib/work-hours.ts` 新增:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface TimeInterval {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并所有重叠区间后,对每段独立调 calcWorkHours 求和
|
||||||
|
export function mergeWorkHours(intervals: TimeInterval[]): number;
|
||||||
|
|
||||||
|
// 双口径汇总
|
||||||
|
export function calcTwoMetrics(intervals: TimeInterval[]): {
|
||||||
|
calendarHours: number;
|
||||||
|
manhours: number;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
算法:
|
||||||
|
|
||||||
|
1. 过滤掉非法(`!start || !end`)或 `end <= start` 的区间
|
||||||
|
2. 按 `start` 升序排序
|
||||||
|
3. 扫描合并:当前与 prev 重叠(`cur.start <= prev.end`)时合并 `prev.end = max(prev.end, cur.end)`,否则把 prev push 出去
|
||||||
|
4. 对最终的 N 个不重叠区间,逐个调 `calcWorkHours(start, end)` 求和 → `calendarHours`
|
||||||
|
5. `manhours` = 原始 intervals 各自 `calcWorkHours` 之后求和(不合并)
|
||||||
|
|
||||||
|
精度 0.5h,与现有口径一致。未结束任务以 `now` 作为 end 参与合并。
|
||||||
|
|
||||||
|
**验证用例**:
|
||||||
|
- A(9–11) + B(10–12) → 日历 3h,人力 4h
|
||||||
|
- A(9–11) + B(13–15) + C(10–14) → 合并后 9–15 减去午休 12–13 = 5h;人力 6h
|
||||||
|
- 跨周末合并:周五 17–周一 10 + 周二 9–11 → 工作时段过滤后两段独立算
|
||||||
|
|
||||||
|
## 2. 三个模块的 intervals 提取
|
||||||
|
|
||||||
|
**`apps/web/lib/dev-task.ts`** 新增:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function devTaskIntervals(tasks: DevTask[], now?: Date): TimeInterval[];
|
||||||
|
// 取 [actualStartAt, actualEndAt ?? now],过滤无 actualStartAt 的
|
||||||
|
```
|
||||||
|
|
||||||
|
**`apps/web/lib/test-case.ts`** 新增:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function testCaseIntervals(cases: TestCase[], now?: Date): TimeInterval[];
|
||||||
|
// 取 [startedAt, completedAt ?? now],过滤无 startedAt 的
|
||||||
|
```
|
||||||
|
|
||||||
|
**`apps/web/lib/bug.ts`** 新增:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function bugIntervals(bugs: Bug[], now?: Date): TimeInterval[];
|
||||||
|
// 取 [createdAt, closedAt ?? resolvedAt ?? (status 终态时 updatedAt) ?? now]
|
||||||
|
```
|
||||||
|
|
||||||
|
**`apps/web/lib/version-plan.ts`** 新增(仅供概览总人天用):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function planIntervals(plans: VersionPlan[], now?: Date): TimeInterval[];
|
||||||
|
// 取 [actualStartAt, completedAt ?? now],过滤无 actualStartAt 的
|
||||||
|
```
|
||||||
|
|
||||||
|
老聚合函数(`aggregateDevTaskHours / aggregateTestCaseActualHours / aggregateBugActualHours`)保留,仍然是 manhours 累加,向后兼容(项目顶部卡片、与我相关页继续用)。
|
||||||
|
|
||||||
|
## 3. 4 个统计点 UI 调整
|
||||||
|
|
||||||
|
**(1) `DevTaskTab` 顶部**:
|
||||||
|
|
||||||
|
```
|
||||||
|
12任务 · 预 96h(12天) · 日历 64h(8天) / 人力 84h(10.5天)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 染色:人力 > 预计 红、< 预计 绿、相等灰
|
||||||
|
- 日历不染色(中性事实)
|
||||||
|
|
||||||
|
**(2) `TestCaseTab` 顶部**:
|
||||||
|
|
||||||
|
```
|
||||||
|
40例 · 通过28 · 失败5 · 通过率85% · 日历 32h(4天) / 人力 56h(7天)
|
||||||
|
```
|
||||||
|
|
||||||
|
**(3) `BugTab` 顶部**:
|
||||||
|
|
||||||
|
```
|
||||||
|
Bug 12 个 · 待修复2 · 修复中3 · ... · 解决率75% · 日历 18h / 人力 24h
|
||||||
|
```
|
||||||
|
|
||||||
|
**(4) 概览-总人天投入**(line 778):当前一行 `总人天投入 96h(12人天)` 改为两行:
|
||||||
|
|
||||||
|
```
|
||||||
|
日历总耗时 104h(13天)
|
||||||
|
人力总投入 176h(22人天)
|
||||||
|
```
|
||||||
|
|
||||||
|
数据源:plans + devTasks + testCases + bugs 的 intervals 全部合并后调 `calcTwoMetrics`。
|
||||||
|
|
||||||
|
## 4. 不动的部分
|
||||||
|
|
||||||
|
- 个人耗时排名(按人累加,本身就是人力维度)
|
||||||
|
- 阶段耗时(项目维度)卡片(已经是日历口径,用 `calcCalendar` 算最早开始/最晚结束/天数差)
|
||||||
|
- PlanTab(调研/产品方案/UI 设计)顶部统计
|
||||||
|
- 项目详情顶部 `HoursStatCard`
|
||||||
|
- 与我相关页 DevTask 工时显示
|
||||||
|
|
||||||
|
## 数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
DevTask + TestCase + Bug + Plan
|
||||||
|
│
|
||||||
|
├── devTaskIntervals(tasks)
|
||||||
|
├── testCaseIntervals(cases)
|
||||||
|
├── bugIntervals(bugs)
|
||||||
|
└── planIntervals(plans)
|
||||||
|
│
|
||||||
|
└── work-hours.calcTwoMetrics(intervals)
|
||||||
|
│
|
||||||
|
├── DevTaskTab 顶部
|
||||||
|
├── TestCaseTab 顶部
|
||||||
|
├── BugTab 顶部
|
||||||
|
└── 概览总人天卡片(4 个 intervals 合并)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 实施清单
|
||||||
|
|
||||||
|
新增/修改:
|
||||||
|
|
||||||
|
1. `apps/web/lib/work-hours.ts` — `TimeInterval` + `mergeWorkHours` + `calcTwoMetrics`
|
||||||
|
2. `apps/web/lib/dev-task.ts` — `devTaskIntervals`
|
||||||
|
3. `apps/web/lib/test-case.ts` — `testCaseIntervals`
|
||||||
|
4. `apps/web/lib/bug.ts` — `bugIntervals`
|
||||||
|
5. `apps/web/lib/version-plan.ts` — `planIntervals`
|
||||||
|
6. `apps/web/components/dev-task/DevTaskTab.tsx` — 三段式
|
||||||
|
7. `apps/web/components/test-case/TestCaseTab.tsx` — 加日历/人力两段
|
||||||
|
8. `apps/web/components/bug/BugTab.tsx` — 加日历/人力两段
|
||||||
|
9. `apps/web/app/versions/[id]/page.tsx` — 概览"总人天"改两行
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
1. 单条任务 09:00–11:00:日历=2h,人力=2h
|
||||||
|
2. 两条 A(9–11) + B(10–12):日历=3h,人力=4h
|
||||||
|
3. 三条 A(9–11) + B(13–15) + C(10–14):日历=5h(去午休),人力=6h
|
||||||
|
4. 跨周末合并:周五 17–周一 10 与周二 9–11,分两段算工作时段后再相加
|
||||||
|
5. `pnpm type-check` + `pnpm build` 0 错误
|
||||||
|
6. 浏览器进版本详情:开发/测试/Bug 三个 Tab 顶部三段式正确,概览总人天两行正确
|
||||||
Reference in New Issue
Block a user