feat(版本): 完善计划覆盖与工作台待办
关键改动: - 增加计划日志汇总、需求覆盖草稿校验与对应测试 - 抽取工作台工作项 Hook,补充待办计数能力 - 优化版本详情中计划、任务、测试用例和 Bug 的筛选与展示 Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { FilterSelect } from '@/components/FilterSelect';
|
||||
import { WorkDateTimePicker } from '@/components/WorkDateTimePicker';
|
||||
import type { Priority } from '@/lib/derive';
|
||||
import { getCategoriesByGroup, getDefaultCategoryByGroup } from '@/lib/task-category';
|
||||
@@ -123,25 +124,43 @@ export function TestCaseCreateModal({ versionId, requirementIds, roundNo, onClos
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">关联需求(可选)</label>
|
||||
<select value={requirementId} onChange={(e) => { setRequirementId(e.target.value); const r = versionReqs.find((x) => x.id === e.target.value); if (r) setPriority(r.priority); }} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none">
|
||||
<option value="">不关联需求</option>
|
||||
{versionReqs.map((r) => <option key={r.id} value={r.id}>{r.code} {r.title}</option>)}
|
||||
</select>
|
||||
<FilterSelect
|
||||
value={requirementId || 'all'}
|
||||
onChange={(value) => {
|
||||
const nextValue = value === 'all' ? '' : value;
|
||||
setRequirementId(nextValue);
|
||||
const r = versionReqs.find((x) => x.id === nextValue);
|
||||
if (r) setPriority(r.priority);
|
||||
}}
|
||||
options={versionReqs.map((r) => ({ value: r.id, label: `${r.code} ${r.title}` }))}
|
||||
allLabel="不关联需求"
|
||||
className="w-full"
|
||||
labelClassName="max-w-[calc(100%-20px)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">优先级</label>
|
||||
<select value={priority} onChange={(e) => setPriority(e.target.value as Priority)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none">
|
||||
{(['P0','P1','P2','P3'] as Priority[]).map((p) => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
<FilterSelect
|
||||
value={priority}
|
||||
onChange={(value) => setPriority(value as Priority)}
|
||||
options={(['P0', 'P1', 'P2', 'P3'] as Priority[]).map((p) => ({ value: p, label: p }))}
|
||||
showAllOption={false}
|
||||
className="w-full"
|
||||
labelClassName="max-w-[calc(100%-20px)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">任务类型 *</label>
|
||||
<select value={categoryId} onChange={(e) => handleCategoryChange(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none">
|
||||
{testCategories.map((category) => (
|
||||
<option key={category.id} value={category.id}>{category.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<FilterSelect
|
||||
value={categoryId}
|
||||
onChange={handleCategoryChange}
|
||||
options={testCategories.map((category) => ({ value: category.id, label: category.name }))}
|
||||
placeholder="选择任务类型"
|
||||
showAllOption={false}
|
||||
className="w-full"
|
||||
labelClassName="max-w-[calc(100%-20px)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">执行预估耗时 *</label>
|
||||
@@ -158,10 +177,14 @@ export function TestCaseCreateModal({ versionId, requirementIds, roundNo, onClos
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">测试负责人</label>
|
||||
<select value={assigneeId} onChange={(e) => setAssigneeId(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none">
|
||||
<option value="">选择负责人</option>
|
||||
{members.map((m) => <option key={m.id} value={m.name}>{m.name}</option>)}
|
||||
</select>
|
||||
<FilterSelect
|
||||
value={assigneeId || 'all'}
|
||||
onChange={(value) => setAssigneeId(value === 'all' ? '' : value)}
|
||||
options={members.map((m) => ({ value: m.name, label: m.name }))}
|
||||
allLabel="选择负责人"
|
||||
className="w-full"
|
||||
labelClassName="max-w-[calc(100%-20px)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { FilterSelect } from '@/components/FilterSelect';
|
||||
import { WorkDateTimePicker } from '@/components/WorkDateTimePicker';
|
||||
import { CategoryChip } from '@/components/dev-task/CategoryChip';
|
||||
import { TC_ALLOWED_TRANSITIONS, TEST_CASE_STATUS_LABEL, canStartTestCase, getTestCaseActualHours, needsTestCaseClaim } from '@/lib/test-case';
|
||||
@@ -148,10 +149,14 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
||||
{showTransfer && (
|
||||
<div className="mx-5 mt-3 rounded-lg border border-[var(--line)] p-3 flex items-center gap-2">
|
||||
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">转交给:</span>
|
||||
<select value={transferTo} onChange={(e) => setTransferTo(e.target.value)} className="h-7 flex-1 rounded-lg border border-[var(--line)] px-2 text-[12px] focus:border-[var(--accent)] focus:outline-none">
|
||||
<option value="">选择人员</option>
|
||||
{members.filter((m) => m.name !== tc.assigneeId).map((m) => <option key={m.id} value={m.name}>{m.name}</option>)}
|
||||
</select>
|
||||
<FilterSelect
|
||||
value={transferTo || 'all'}
|
||||
onChange={(value) => setTransferTo(value === 'all' ? '' : value)}
|
||||
options={members.filter((m) => m.name !== tc.assigneeId).map((m) => ({ value: m.name, label: m.name }))}
|
||||
allLabel="选择人员"
|
||||
className="flex-1"
|
||||
labelClassName="max-w-[calc(100%-20px)]"
|
||||
/>
|
||||
<button onClick={() => { if (transferTo) { updateTestCase(tc.id, { assigneeId: transferTo }); setShowTransfer(false); setTransferTo(''); } }} disabled={!transferTo} className="h-7 px-2.5 rounded text-[11px] font-medium bg-blue-500 text-white disabled:opacity-50">确认</button>
|
||||
<button onClick={() => setShowTransfer(false)} className="h-7 px-2 text-[11px] text-[var(--ink-muted)]">取消</button>
|
||||
</div>
|
||||
|
||||
@@ -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" />开启新一轮测试
|
||||
|
||||
Reference in New Issue
Block a user