Files
ftb-project-management/apps/web/components/bug/BugTab.tsx
2e595c7e72
Some checks failed
Deploy Production / Build, push, deploy, verify (push) Has been cancelled
refactor(data): 收口关系表运行时数据源
- 移除已迁移业务 AppData 运行时 fallback,改走领域 API 和关系表快读
- 补齐需求产品负责人、版本计划任务 JSON 和成员 username 回填迁移
- 统一治理字典入口,并补充 AI provider、数据源契约和领域服务测试

Co-Authored-By: Codex GPT-5 <codex@openai.com>
2026-07-09 14:59:49 +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({ versionId });
}, [fetchBugs, scopedVersionBugs, versionId]);
useEffect(() => {
if (!versionTestCases) fetchTestCases({ versionId });
}, [fetchTestCases, versionId, 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>
);
}