Files
ftb-project-management/apps/web/components/bug/BugTab.tsx
2026-07-03 09:43:36 +08:00

176 lines
8.8 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 { useMemberStore } from '@/stores/useMemberStore';
import { BugRow } from './BugRow';
import { BugDetailDrawer } from './BugDetailDrawer';
import { Pagination, usePagination } from '@/components/Pagination';
import { FilterSelect } from '@/components/FilterSelect';
import { SearchInput, matchTitleOrNo } from '@/components/SearchInput';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { BUG_STATUS_LABEL, BUG_SEVERITY_LABEL, aggregateBugActualHours, bugIntervals } from '@/lib/bug';
import { calcTwoMetrics } from '@/lib/work-hours';
import { isMemberReference, resolveMemberDisplayName } from '@/lib/member-system';
import { buildEffortSummaryMetrics, sumPositiveHours } from '@/lib/effort-summary';
import type { Bug, BugStatus, BugSeverity } from '@/lib/bug';
import type { TestCase } from '@/lib/test-case';
interface Props {
versionId: string;
requirementIds: string[];
readOnly?: boolean;
versionBugs?: Bug[];
versionTestCases?: TestCase[];
}
export function BugTab({ versionId, requirementIds, readOnly = false, versionBugs: scopedVersionBugs, versionTestCases }: Props) {
const { bugs, fetchBugs } = useBugStore();
const { testCases, fetchTestCases } = useTestCaseStore();
const { requirements } = useRequirementStore();
const user = useAuthStore((s) => s.user);
const members = useMemberStore((s) => s.members);
useEffect(() => {
if (!scopedVersionBugs) fetchBugs();
}, [fetchBugs, scopedVersionBugs]);
useEffect(() => {
if (!versionTestCases) fetchTestCases();
}, [fetchTestCases, versionTestCases]);
const fallbackVersionBugs = useMemo(
() => bugs.filter((b) => b.versionId === versionId),
[bugs, versionId],
);
const versionBugs = scopedVersionBugs ?? fallbackVersionBugs;
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 bugActualHours = useMemo(() => aggregateBugActualHours(versionBugs), [versionBugs]);
const { manhours: bugManhours } = useMemo(() => calcTwoMetrics(bugIntervals(versionBugs)), [versionBugs]);
const effortMetrics = useMemo(
() => buildEffortSummaryMetrics({
aiEstimateHours: sumPositiveHours(versionBugs, (bug) => bug.aiEstimateHours),
estimateHours: sumPositiveHours(versionBugs, (bug) => bug.estimateHours),
actualHours: bugActualHours,
manhours: bugManhours,
}),
[versionBugs, bugActualHours, bugManhours],
);
const [filterAssignee, setFilterAssignee] = useState('');
const [filterStatus, setFilterStatus] = useState('');
const [filterSeverity, setFilterSeverity] = useState('');
const [keyword, setKeyword] = useState('');
const debouncedKeyword = useDebouncedValue(keyword, 300);
const [selectedBugId, setSelectedBugId] = useState<string | null>(null);
const visibleTestCases = versionTestCases ?? testCases;
const testCaseMap = useMemo(() => new Map(visibleTestCases.map((c) => [c.id, c])), [visibleTestCases]);
const currentMember = useMemo(() => members.find((m) => m.id === user?.id), [members, user?.id]);
const currentUserName = currentMember?.name ?? user?.name;
const assignees = useMemo(() => {
const names = new Set(versionBugs.map((b) => resolveMemberDisplayName(b.assigneeId, members)));
return Array.from(names);
}, [versionBugs, members]);
const filteredBugs = useMemo(() => {
let result = versionBugs;
if (filterAssignee === '__me') {
if (currentMember) {
result = result.filter((b) => isMemberReference(b.assigneeId, currentMember));
} else {
result = [];
}
} else if (filterAssignee) {
result = result.filter((b) => resolveMemberDisplayName(b.assigneeId, members) === 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, currentMember, members]);
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filteredBugs, 20);
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>
{effortMetrics.map((metric) => (
<span key={metric.label} className="text-[11px] text-[var(--ink-muted)] shrink-0">
{metric.label} <span className="tabular-nums text-[var(--ink)]">{metric.value}</span>
</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="搜索标题/编号" />
<FilterSelect
value={filterAssignee || 'all'}
onChange={(value) => { setFilterAssignee(value === 'all' ? '' : value); setPage(1); }}
options={[
...(currentMember ? [{ value: '__me', label: '我的Bug' }] : []),
...assignees.filter((assignee) => assignee !== currentUserName).map((assignee) => ({ value: assignee, label: assignee })),
]}
allLabel="全部修复人"
/>
<FilterSelect
value={filterStatus || 'all'}
onChange={(value) => { setFilterStatus(value === 'all' ? '' : value); setPage(1); }}
options={(Object.entries(BUG_STATUS_LABEL) as [BugStatus, string][]).map(([value, label]) => ({ value, label }))}
allLabel="全部状态"
/>
<FilterSelect
value={filterSeverity || 'all'}
onChange={(value) => { setFilterSeverity(value === 'all' ? '' : value); setPage(1); }}
options={(Object.entries(BUG_SEVERITY_LABEL) as [BugSeverity, string][]).map(([value, label]) => ({ value, label }))}
allLabel="全部严重程度"
/>
{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} readOnly={readOnly} onClose={() => setSelectedBugId(null)} />}
</div>
);
}