Files
ftb-project-management/apps/web/components/bug/BugTab.tsx
Script Generator eef3c8f000 feat(版本): 优化概览与只读状态
关键改动:

- 增加需求排序和版本只读状态规则及测试

- 完善版本概览阶段耗时、项目页和工作台展示

- 优化小宝预警请求节流、建议状态和风险过滤

Co-Authored-By: Codex GPT-5 <codex@openai.com>
2026-06-30 18:18:18 +08:00

167 lines
8.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 { 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 { BugStatus, BugSeverity } from '@/lib/bug';
interface Props {
versionId: string;
requirementIds: string[];
readOnly?: boolean;
}
export function BugTab({ versionId, requirementIds, readOnly = false }: 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(() => { 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 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 testCaseMap = useMemo(() => new Map(testCases.map((c) => [c.id, c])), [testCases]);
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>
);
}