Files
ftb-project-management/apps/web/components/bug/BugTab.tsx
Script Generator 1f5bed0c79 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>
2026-06-16 17:44:59 +08:00

131 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useState, useMemo, useEffect } from 'react';
import { Bug as BugIcon, Filter } from 'lucide-react';
import { useBugStore } from '@/stores/useBugStore';
import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { BugRow } from './BugRow';
import { BugDetailDrawer } from './BugDetailDrawer';
import { Pagination, usePagination } from '@/components/Pagination';
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';
interface Props {
versionId: string;
requirementIds: string[];
}
export function BugTab({ versionId, requirementIds }: Props) {
const { bugs, fetchBugs } = useBugStore();
const { testCases, fetchTestCases } = useTestCaseStore();
const { requirements } = useRequirementStore();
const user = useAuthStore((s) => s.user);
useEffect(() => { fetchBugs(); }, [fetchBugs]);
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
const versionBugs = useMemo(
() => bugs.filter((b) => b.versionId === versionId),
[bugs, versionId],
);
const openCount = versionBugs.filter((b) => b.status === 'open').length;
const fixingCount = versionBugs.filter((b) => b.status === 'fixing').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 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 { calendarHours: bugCalendarHours, manhours: bugManhours } = useMemo(() => calcTwoMetrics(bugIntervals(versionBugs)), [versionBugs]);
const [filterAssignee, setFilterAssignee] = useState('');
const [filterStatus, setFilterStatus] = useState('');
const [filterSeverity, setFilterSeverity] = useState('');
const [keyword, setKeyword] = useState('');
const debouncedKeyword = useDebouncedValue(keyword, 300);
const filteredBugs = useMemo(() => {
let result = versionBugs;
if (filterAssignee) result = result.filter((b) => b.assigneeId === filterAssignee);
if (filterStatus) result = result.filter((b) => b.status === filterStatus);
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;
}, [versionBugs, filterAssignee, filterStatus, filterSeverity, debouncedKeyword]);
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filteredBugs, 20);
const [selectedBugId, setSelectedBugId] = useState<string | null>(null);
const testCaseMap = useMemo(() => new Map(testCases.map((c) => [c.id, c])), [testCases]);
const assignees = useMemo(() => {
const names = new Set(versionBugs.map((b) => b.assigneeId));
return Array.from(names);
}, [versionBugs]);
const hasFilter = !!(filterAssignee || filterStatus || filterSeverity || debouncedKeyword);
return (
<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-2">
<BugIcon className="h-4 w-4 text-red-500" />
<span className="text-[13px] font-medium text-[var(--ink)]">Bug {versionBugs.length} </span>
</div>
<span className="text-[11px] text-[var(--ink-muted)] tabular-nums">
{openCount} · {fixingCount} · {fixedCount} · {closedCount}
</span>
{criticalCount > 0 && (
<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>
{bugManhours > 0 && (
<span className="text-[11px] text-[var(--ink-muted)]"> {formatWorkHours(bugCalendarHours)} / {formatWorkHours(bugManhours)}</span>
)}
</div>
<div className="flex items-center gap-2 px-1 flex-wrap">
<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">
<option value=""></option>
{user?.name && <option value={user.name}>Bug</option>}
{assignees.filter((a) => a !== user?.name).map((a) => <option key={a} value={a}>{a}</option>)}
</select>
<select value={filterStatus} onChange={(e) => { setFilterStatus(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>
{(Object.entries(BUG_STATUS_LABEL) as [BugStatus, string][]).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<select value={filterSeverity} onChange={(e) => { setFilterSeverity(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>
{(Object.entries(BUG_SEVERITY_LABEL) as [BugSeverity, string][]).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
{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>
</div>
{filteredBugs.length === 0 ? (
<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>
</div>
) : (
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
{paged.map((bug) => {
const tc = testCaseMap.get(bug.testCaseId);
return <BugRow key={bug.id} bug={bug} testCaseNo={tc?.caseNo} onClick={() => setSelectedBugId(bug.id)} />;
})}
</div>
)}
{total > 20 && <Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />}
{selectedBugId && <BugDetailDrawer bugId={selectedBugId} onClose={() => setSelectedBugId(null)} />}
</div>
);
}