feat(版本): 完善计划覆盖与工作台待办

关键改动:

- 增加计划日志汇总、需求覆盖草稿校验与对应测试

- 抽取工作台工作项 Hook,补充待办计数能力

- 优化版本详情中计划、任务、测试用例和 Bug 的筛选与展示

Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
Script Generator
2026-06-30 13:43:18 +08:00
parent a6a4d7d3d9
commit 52a88626d4
23 changed files with 1082 additions and 281 deletions

View File

@@ -15,6 +15,7 @@ import { BugCreateModal } from '@/components/bug/BugCreateModal';
import { aggregateTestCaseHours, calcTestProgress, canStartNextTestRound, copyTestCaseToRound, getNextTestRoundNo, getRequirementDeliveryStatus, getTestCaseRoundNo, TEST_CASE_STATUS_LABEL, testCaseIntervals } from '@/lib/test-case';
import { calcTwoMetrics } from '@/lib/work-hours';
import { Pagination, usePagination } from '@/components/Pagination';
import { FilterSelect } from '@/components/FilterSelect';
import { SearchInput, matchTitleOrNo } from '@/components/SearchInput';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { orderByRequirementForGrouping } from '@/lib/requirement-grouping';
@@ -122,7 +123,10 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
const handleBatchDelete = () => {
if (selectedIds.size === 0) return;
const hasBug = Array.from(selectedIds).some((id) => bugs.some((b) => b.testCaseId === id));
if (hasBug) { alert('选中的用例中有关联 Bug 的用例,无法删除。请先取消关联 Bug 的用例选择。'); return; }
if (hasBug) {
alert('选中的用例中有关联 Bug 的用例,无法删除。请先取消关联 Bug 的用例选择。');
return;
}
if (!confirm(`确定删除选中的 ${selectedIds.size} 个测试用例?`)) return;
selectedIds.forEach((id) => deleteTestCase(id));
setSelectedIds(new Set());
@@ -174,28 +178,37 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
<div className="h-full rounded-full bg-emerald-500" style={{ width: `${stats.completionRate}%` }} />
</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">Bug通过{stats.passRate}%</span>
<span className="text-[11px] text-[var(--ink-muted)] shrink-0"> Bug {stats.passRate}%</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>
))}
<select value={activeRound} onChange={(e) => handleRoundChange(Number(e.target.value))} 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">
{(rounds.length > 0 ? rounds : [1]).map((roundNo) => <option key={roundNo} value={roundNo}>{roundNo}</option>)}
</select>
<FilterSelect
value={String(activeRound)}
onChange={(value) => handleRoundChange(Number(value))}
options={(rounds.length > 0 ? rounds : [1]).map((roundNo) => ({ value: String(roundNo), label: `${roundNo}` }))}
showAllOption={false}
/>
<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">
<option value=""></option>
{user?.name && <option value={user.name}></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-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>
{(Object.entries(TEST_CASE_STATUS_LABEL) as [TestCaseStatus, string][]).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<FilterSelect
value={filterAssignee || 'all'}
onChange={(value) => { setFilterAssignee(value === 'all' ? '' : value); setPage(1); }}
options={[
...(user?.name ? [{ value: user.name, label: '我的' }] : []),
...assignees.filter((a) => a !== user?.name).map((a) => ({ value: a, label: a })),
]}
allLabel="负责人"
/>
<FilterSelect
value={filterStatus || 'all'}
onChange={(value) => { setFilterStatus(value === 'all' ? '' : value); setPage(1); }}
options={(Object.entries(TEST_CASE_STATUS_LABEL) as [TestCaseStatus, string][]).map(([value, label]) => ({ value, label }))}
allLabel="状态"
/>
{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">
@@ -207,7 +220,7 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
<button
onClick={handleStartNewRound}
disabled={!canCreateNextRound}
title={canCreateNextRound ? `复制第1轮用例,开启第${nextRoundNo}轮测试` : '最新一轮测试用例全部测完后才能开启新一轮'}
title={canCreateNextRound ? `复制第 1 轮用例,开启第 ${nextRoundNo} 轮测试` : '最新一轮测试用例全部测完后才能开启新一轮'}
className="flex items-center gap-1 h-6 px-2.5 rounded text-[11px] font-medium bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus className="h-3 w-3" />