feat: 开发任务时间字段重构 + 双口径耗时 + 搜索 + 性能优化
核心变更: - DevTask 字段重构:startDate/dueDate/estimateHours/actualHours 替换为 expectedStartAt/expectedEndAt/actualStartAt/actualEndAt(含时分),预计/实际工时按工作时段(9:00-12:00 + 13:00-18:00)派生计算 - 状态机自动化:到点自动切开发中、未到可手动开干、超期需填延后原因 - 新建 lib/work-hours.ts:calcWorkHours(工作时段过滤)、formatWorkHours(X h(Y 天))、calcTwoMetrics(日历/人力双口径) - 新建 lib/dev-task-transitions.ts:状态切换守卫 - 三个 Tab 顶部统计:开发任务(预/日历/人力)、测试用例 / Bug(日历 / 人力);版本概览总人天投入改双行(日历总耗时 / 人力总投入) - 三个 Tab 加搜索框:300ms debounce + 标题/编号模糊匹配 - 性能优化:requirementIds/categories/requirements/testCases 转 Map/Set 索引、Row 组件 React.memo - 全局耗时显示统一带天数换算:8h(1天)、11h(1.4天) 新增文件: SearchInput.tsx, useDebouncedValue.ts, work-hours.ts, dev-task-transitions.ts, 设计文档 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,8 +11,11 @@ import { TestCaseRow } from './TestCaseRow';
|
||||
import { TestCaseCreateModal } from './TestCaseCreateModal';
|
||||
import { TestCaseDetailDrawer } from './TestCaseDetailDrawer';
|
||||
import { BugCreateModal } from '@/components/bug/BugCreateModal';
|
||||
import { calcTestProgress, TEST_CASE_STATUS_LABEL } from '@/lib/test-case';
|
||||
import { calcTestProgress, TEST_CASE_STATUS_LABEL, testCaseIntervals } from '@/lib/test-case';
|
||||
import { formatWorkHours, calcTwoMetrics } from '@/lib/work-hours';
|
||||
import { Pagination, usePagination } from '@/components/Pagination';
|
||||
import { SearchInput, matchTitleOrNo } from '@/components/SearchInput';
|
||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||
import type { TestCaseStatus } from '@/lib/test-case';
|
||||
|
||||
interface Props {
|
||||
@@ -30,27 +33,33 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
||||
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
|
||||
useEffect(() => { fetchBugs(); }, [fetchBugs]);
|
||||
|
||||
const reqIdSet = useMemo(() => new Set(requirementIds), [requirementIds]);
|
||||
const versionCases = useMemo(
|
||||
() => testCases.filter((c) => c.versionId === versionId),
|
||||
[testCases, versionId],
|
||||
);
|
||||
|
||||
// 提测状态
|
||||
const versionDevTasks = useMemo(() => devTasks.filter((t) => requirementIds.includes(t.requirementId)), [devTasks, requirementIds]);
|
||||
const versionDevTasks = useMemo(
|
||||
() => devTasks.filter((t) => reqIdSet.has(t.requirementId)),
|
||||
[devTasks, reqIdSet],
|
||||
);
|
||||
const allSubmitted = versionDevTasks.length > 0 && versionDevTasks.every((t) => t.status === 'submitted');
|
||||
|
||||
const stats = calcTestProgress(versionCases);
|
||||
const { calendarHours: tcCalendarHours, manhours: tcManhours } = useMemo(() => calcTwoMetrics(testCaseIntervals(versionCases)), [versionCases]);
|
||||
|
||||
// 筛选
|
||||
const [filterAssignee, setFilterAssignee] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const debouncedKeyword = useDebouncedValue(keyword, 300);
|
||||
|
||||
const filteredCases = useMemo(() => {
|
||||
let result = versionCases;
|
||||
if (filterAssignee) result = result.filter((c) => c.assigneeId === filterAssignee);
|
||||
if (filterStatus) result = result.filter((c) => c.status === filterStatus);
|
||||
if (debouncedKeyword) result = result.filter((c) => matchTitleOrNo({ title: c.title, no: c.caseNo }, debouncedKeyword));
|
||||
return result;
|
||||
}, [versionCases, filterAssignee, filterStatus]);
|
||||
}, [versionCases, filterAssignee, filterStatus, debouncedKeyword]);
|
||||
|
||||
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filteredCases, 20);
|
||||
|
||||
@@ -58,18 +67,13 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
||||
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
|
||||
const [bugForCaseId, setBugForCaseId] = useState<string | null>(null);
|
||||
|
||||
// 批量选择
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const isAllSelected = paged.length > 0 && paged.every((c) => selectedIds.has(c.id));
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
const next = new Set(selectedIds);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
setSelectedIds(next);
|
||||
};
|
||||
const toggleAll = () => {
|
||||
setSelectedIds(isAllSelected ? new Set() : new Set(paged.map((c) => c.id)));
|
||||
};
|
||||
const handleBatchDelete = () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
const hasBug = Array.from(selectedIds).some((id) => bugs.some((b) => b.testCaseId === id));
|
||||
@@ -79,7 +83,6 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
// 按需求分组
|
||||
const groupedByReq = useMemo(() => {
|
||||
const map = new Map<string, typeof paged>();
|
||||
for (const c of paged) {
|
||||
@@ -91,12 +94,18 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
||||
return map;
|
||||
}, [paged]);
|
||||
|
||||
const requirementMap = useMemo(() => new Map(requirements.map((r) => [r.id, r])), [requirements]);
|
||||
const bugCountByCase = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const b of bugs) map.set(b.testCaseId, (map.get(b.testCaseId) ?? 0) + 1);
|
||||
return map;
|
||||
}, [bugs]);
|
||||
|
||||
const assignees = useMemo(() => Array.from(new Set(versionCases.map((c) => c.assigneeId).filter(Boolean) as string[])), [versionCases]);
|
||||
const hasFilter = !!(filterAssignee || filterStatus);
|
||||
const hasFilter = !!(filterAssignee || filterStatus || debouncedKeyword);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 提测通知 */}
|
||||
{allSubmitted && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-50 border border-emerald-200">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />
|
||||
@@ -110,7 +119,6 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 工具栏:进度 + 筛选 + 操作 一行 */}
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 rounded-lg bg-[var(--bg-subtle)] border border-[var(--line)] flex-wrap">
|
||||
<ClipboardCheck className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
|
||||
<span className="text-[12px] font-medium text-[var(--ink)] shrink-0">{stats.completionRate}%</span>
|
||||
@@ -119,9 +127,13 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
||||
</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">通过率{stats.passRate}%</span>
|
||||
{tcManhours > 0 && (
|
||||
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">日历 {formatWorkHours(tcCalendarHours)} / 人力 {formatWorkHours(tcManhours)}</span>
|
||||
)}
|
||||
|
||||
<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>}
|
||||
@@ -131,7 +143,7 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
||||
<option value="">状态</option>
|
||||
{(Object.entries(TEST_CASE_STATUS_LABEL) as [TestCaseStatus, string][]).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
{hasFilter && <button onClick={() => { setFilterAssignee(''); setFilterStatus(''); setPage(1); }} className="text-[10px] text-[var(--accent)] hover:underline shrink-0">清除</button>}
|
||||
{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">
|
||||
{selectedIds.size > 0 && (
|
||||
@@ -145,7 +157,6 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 列表 */}
|
||||
{filteredCases.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 ? '没有匹配的用例' : '暂无测试用例'}</p>
|
||||
@@ -153,7 +164,7 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
||||
</div>
|
||||
) : (
|
||||
Array.from(groupedByReq.entries()).map(([reqId, cases]) => {
|
||||
const req = reqId === '__none__' ? null : requirements.find((r) => r.id === reqId);
|
||||
const req = reqId === '__none__' ? null : requirementMap.get(reqId);
|
||||
const reqPassed = cases.filter((c) => c.status === 'passed').length;
|
||||
return (
|
||||
<div key={reqId} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
|
||||
@@ -169,7 +180,7 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
||||
<input type="checkbox" checked={selectedIds.has(c.id)} onChange={() => toggleSelect(c.id)} className="h-3.5 w-3.5 rounded border-[var(--line)]" onClick={(e) => e.stopPropagation()} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<TestCaseRow testCase={c} bugCount={bugs.filter((b) => b.testCaseId === c.id).length} onClick={() => setSelectedCaseId(c.id)} />
|
||||
<TestCaseRow testCase={c} bugCount={bugCountByCase.get(c.id) ?? 0} onClick={() => setSelectedCaseId(c.id)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user