feat(测试用例): 展示预估和实际耗时
This commit is contained in:
@@ -1,12 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useMemo } from 'react';
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
||||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||||
import { useMemberStore } from '@/stores/useMemberStore';
|
import { useMemberStore } from '@/stores/useMemberStore';
|
||||||
|
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
||||||
import { useAuthStore } from '@/stores/useAuthStore';
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
import type { Priority } from '@/lib/derive';
|
import type { Priority } from '@/lib/derive';
|
||||||
|
import { getCategoriesByGroup, getDefaultCategoryByGroup } from '@/lib/task-category';
|
||||||
|
import { clampTestCaseEstimateHours, getDefaultTestCaseEstimateHours } from '@/lib/ai-estimation-policy';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
versionId: string;
|
versionId: string;
|
||||||
@@ -18,30 +21,66 @@ export function TestCaseCreateModal({ versionId, requirementIds, onClose }: Prop
|
|||||||
const { createTestCase } = useTestCaseStore();
|
const { createTestCase } = useTestCaseStore();
|
||||||
const { requirements } = useRequirementStore();
|
const { requirements } = useRequirementStore();
|
||||||
const { members } = useMemberStore();
|
const { members } = useMemberStore();
|
||||||
|
const { categories } = useTaskCategoryStore();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
|
|
||||||
const versionReqs = useMemo(
|
const versionReqs = useMemo(
|
||||||
() => requirements.filter((r) => requirementIds.includes(r.id)),
|
() => requirements.filter((r) => requirementIds.includes(r.id)),
|
||||||
[requirements, requirementIds],
|
[requirements, requirementIds],
|
||||||
);
|
);
|
||||||
|
const testCategories = useMemo(() => getCategoriesByGroup(categories, 'testing'), [categories]);
|
||||||
|
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [requirementId, setRequirementId] = useState(versionReqs[0]?.id || '');
|
const [requirementId, setRequirementId] = useState(versionReqs[0]?.id || '');
|
||||||
const [priority, setPriority] = useState<Priority>(versionReqs[0]?.priority || 'P2');
|
const [priority, setPriority] = useState<Priority>(versionReqs[0]?.priority || 'P2');
|
||||||
|
const [categoryId, setCategoryId] = useState(getDefaultCategoryByGroup(categories, 'testing').id);
|
||||||
|
const selectedCategory = useMemo(
|
||||||
|
() => categories.find((category) => category.id === categoryId) ?? testCategories[0],
|
||||||
|
[categories, categoryId, testCategories],
|
||||||
|
);
|
||||||
|
const [estimateHours, setEstimateHours] = useState(0.5);
|
||||||
const [assigneeId, setAssigneeId] = useState(user?.name || '');
|
const [assigneeId, setAssigneeId] = useState(user?.name || '');
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
|
const [prototypeNotes, setPrototypeNotes] = useState('');
|
||||||
|
|
||||||
const canSubmit = title.trim();
|
useEffect(() => {
|
||||||
|
if (testCategories.length === 0) return;
|
||||||
|
if (testCategories.some((category) => category.id === categoryId)) return;
|
||||||
|
const next = testCategories[0];
|
||||||
|
setCategoryId(next.id);
|
||||||
|
setEstimateHours(getDefaultTestCaseEstimateHours(next.code));
|
||||||
|
}, [categoryId, testCategories]);
|
||||||
|
|
||||||
|
const handleCategoryChange = (nextCategoryId: string) => {
|
||||||
|
setCategoryId(nextCategoryId);
|
||||||
|
const category = categories.find((c) => c.id === nextCategoryId) ?? testCategories.find((c) => c.id === nextCategoryId);
|
||||||
|
setEstimateHours(getDefaultTestCaseEstimateHours(category?.code));
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizedEstimateHours = clampTestCaseEstimateHours(selectedCategory?.code, estimateHours);
|
||||||
|
const canSubmit = title.trim() && categoryId && normalizedEstimateHours > 0;
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!canSubmit) return;
|
if (!canSubmit) return;
|
||||||
|
const reqRef = versionReqs.find((r) => r.id === requirementId);
|
||||||
|
const references = [
|
||||||
|
...(reqRef ? [{ type: 'requirement' as const, id: reqRef.code, label: `${reqRef.code} ${reqRef.title}` }] : []),
|
||||||
|
...prototypeNotes.split(/[,,\s]+/).map((s) => s.trim()).filter(Boolean).map((note) => ({
|
||||||
|
type: 'prototype_note' as const,
|
||||||
|
id: note,
|
||||||
|
label: note,
|
||||||
|
})),
|
||||||
|
];
|
||||||
createTestCase({
|
createTestCase({
|
||||||
versionId,
|
versionId,
|
||||||
requirementId: requirementId || undefined,
|
requirementId: requirementId || undefined,
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
|
categoryId,
|
||||||
priority,
|
priority,
|
||||||
|
estimateHours: normalizedEstimateHours,
|
||||||
assigneeId: assigneeId || undefined,
|
assigneeId: assigneeId || undefined,
|
||||||
|
references: references.length > 0 ? references : undefined,
|
||||||
createdBy: user?.name || '系统',
|
createdBy: user?.name || '系统',
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
@@ -73,6 +112,27 @@ export function TestCaseCreateModal({ versionId, requirementIds, onClose }: Prop
|
|||||||
{(['P0','P1','P2','P3'] as Priority[]).map((p) => <option key={p} value={p}>{p}</option>)}
|
{(['P0','P1','P2','P3'] as Priority[]).map((p) => <option key={p} value={p}>{p}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">预估耗时 *</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0.25"
|
||||||
|
max="2"
|
||||||
|
step="0.25"
|
||||||
|
value={estimateHours}
|
||||||
|
onChange={(e) => setEstimateHours(Number(e.target.value))}
|
||||||
|
onBlur={() => setEstimateHours(normalizedEstimateHours)}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">测试负责人</label>
|
<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">
|
<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">
|
||||||
@@ -85,6 +145,11 @@ export function TestCaseCreateModal({ versionId, requirementIds, onClose }: Prop
|
|||||||
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">测试步骤 & 预期结果</label>
|
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">测试步骤 & 预期结果</label>
|
||||||
<textarea rows={4} value={description} onChange={(e) => setDescription(e.target.value)} className="w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 py-2 text-[13px] focus:border-[var(--accent)] focus:outline-none resize-none" placeholder="1. 操作步骤... 2. 预期结果..." />
|
<textarea rows={4} value={description} onChange={(e) => setDescription(e.target.value)} className="w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 py-2 text-[13px] focus:border-[var(--accent)] focus:outline-none resize-none" placeholder="1. 操作步骤... 2. 预期结果..." />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">原型批注</label>
|
||||||
|
<input value={prototypeNotes} onChange={(e) => setPrototypeNotes(e.target.value)} className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" placeholder="如 QY0007, QY0023" />
|
||||||
|
<p className="mt-1 text-[11px] text-[var(--ink-muted)]">原型批注编号,逗号分隔(用例来自原型上的哪些标记)</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2 pt-4 border-t border-[var(--line)] mt-4">
|
<div className="flex justify-end gap-2 pt-4 border-t border-[var(--line)] mt-4">
|
||||||
<button onClick={onClose} className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">取消</button>
|
<button onClick={onClose} className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">取消</button>
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
|||||||
import { useBugStore } from '@/stores/useBugStore';
|
import { useBugStore } from '@/stores/useBugStore';
|
||||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||||
import { useMemberStore } from '@/stores/useMemberStore';
|
import { useMemberStore } from '@/stores/useMemberStore';
|
||||||
import { TC_ALLOWED_TRANSITIONS, TEST_CASE_STATUS_LABEL } from '@/lib/test-case';
|
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
||||||
import { calcActualHoursByDates } from '@/lib/dev-task';
|
import { CategoryChip } from '@/components/dev-task/CategoryChip';
|
||||||
|
import { TC_ALLOWED_TRANSITIONS, TEST_CASE_STATUS_LABEL, getTestCaseActualHours, getTestCaseEstimateHours } from '@/lib/test-case';
|
||||||
|
import { formatWorkHours } from '@/lib/work-hours';
|
||||||
import { formatDateTime } from '@/lib/format';
|
import { formatDateTime } from '@/lib/format';
|
||||||
import { BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug';
|
import { BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug';
|
||||||
import type { TestCaseStatus } from '@/lib/test-case';
|
import type { TestCaseStatus } from '@/lib/test-case';
|
||||||
@@ -26,6 +28,7 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
|||||||
const { bugs } = useBugStore();
|
const { bugs } = useBugStore();
|
||||||
const { requirements } = useRequirementStore();
|
const { requirements } = useRequirementStore();
|
||||||
const { members } = useMemberStore();
|
const { members } = useMemberStore();
|
||||||
|
const { categories } = useTaskCategoryStore();
|
||||||
const [showTransfer, setShowTransfer] = useState(false);
|
const [showTransfer, setShowTransfer] = useState(false);
|
||||||
const [transferTo, setTransferTo] = useState('');
|
const [transferTo, setTransferTo] = useState('');
|
||||||
|
|
||||||
@@ -33,8 +36,11 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
|||||||
if (!tc) return null;
|
if (!tc) return null;
|
||||||
|
|
||||||
const requirement = requirements.find((r) => r.id === tc.requirementId);
|
const requirement = requirements.find((r) => r.id === tc.requirementId);
|
||||||
|
const category = categories.find((c) => c.id === tc.categoryId);
|
||||||
const relatedBugs = bugs.filter((b) => b.testCaseId === tc.id);
|
const relatedBugs = bugs.filter((b) => b.testCaseId === tc.id);
|
||||||
const nextStatuses = TC_ALLOWED_TRANSITIONS[tc.status];
|
const nextStatuses = TC_ALLOWED_TRANSITIONS[tc.status];
|
||||||
|
const estimateHours = getTestCaseEstimateHours(tc);
|
||||||
|
const actualHours = getTestCaseActualHours(tc);
|
||||||
|
|
||||||
const [failReason, setFailReason] = useState('');
|
const [failReason, setFailReason] = useState('');
|
||||||
const [blockReason, setBlockReason] = useState('');
|
const [blockReason, setBlockReason] = useState('');
|
||||||
@@ -128,8 +134,8 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
|||||||
|
|
||||||
{showFailInput && (
|
{showFailInput && (
|
||||||
<div className="flex gap-2 pt-1">
|
<div className="flex gap-2 pt-1">
|
||||||
<input value={failReason} onChange={(e) => setFailReason(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') confirmFail(); }} placeholder="失败原因(可选)" className="flex-1 h-8 rounded-lg border border-[var(--line)] px-3 text-[12px] focus:border-red-400 focus:outline-none" autoFocus />
|
<input value={failReason} onChange={(e) => setFailReason(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') confirmFail(); }} placeholder="不通过原因(可选)" className="flex-1 h-8 rounded-lg border border-[var(--line)] px-3 text-[12px] focus:border-red-400 focus:outline-none" autoFocus />
|
||||||
<button onClick={confirmFail} className="h-8 px-3 rounded-lg text-[11px] font-medium bg-red-500 text-white">确认失败</button>
|
<button onClick={confirmFail} className="h-8 px-3 rounded-lg text-[11px] font-medium bg-red-500 text-white">确认不通过</button>
|
||||||
<button onClick={() => setShowFailInput(false)} className="h-8 px-2 text-[11px] text-[var(--ink-muted)]">取消</button>
|
<button onClick={() => setShowFailInput(false)} className="h-8 px-2 text-[11px] text-[var(--ink-muted)]">取消</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -142,7 +148,7 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tc.failReason && <div className="text-[12px] text-red-600 bg-red-50 rounded-lg px-3 py-2"><AlertTriangle className="h-3 w-3 inline mr-1" />失败原因:{tc.failReason}</div>}
|
{tc.failReason && <div className="text-[12px] text-red-600 bg-red-50 rounded-lg px-3 py-2"><AlertTriangle className="h-3 w-3 inline mr-1" />不通过原因:{tc.failReason}</div>}
|
||||||
{tc.blockReason && <div className="text-[12px] text-orange-600 bg-orange-50 rounded-lg px-3 py-2"><AlertTriangle className="h-3 w-3 inline mr-1" />阻塞原因:{tc.blockReason}</div>}
|
{tc.blockReason && <div className="text-[12px] text-orange-600 bg-orange-50 rounded-lg px-3 py-2"><AlertTriangle className="h-3 w-3 inline mr-1" />阻塞原因:{tc.blockReason}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -151,12 +157,14 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
|||||||
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-3">基本信息</div>
|
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-3">基本信息</div>
|
||||||
<div className="grid grid-cols-2 gap-y-3 gap-x-4 text-[12px]">
|
<div className="grid grid-cols-2 gap-y-3 gap-x-4 text-[12px]">
|
||||||
<div><span className="text-[var(--ink-muted)]">优先级:</span><span className="text-[var(--ink)] font-medium">{tc.priority}</span></div>
|
<div><span className="text-[var(--ink-muted)]">优先级:</span><span className="text-[var(--ink)] font-medium">{tc.priority}</span></div>
|
||||||
|
<div className="flex items-center gap-1.5"><span className="text-[var(--ink-muted)]">任务类型:</span><CategoryChip category={category} /></div>
|
||||||
<div><span className="text-[var(--ink-muted)]">负责人:</span><span className="text-[var(--ink)] font-medium">{tc.assigneeId || '-'}</span></div>
|
<div><span className="text-[var(--ink-muted)]">负责人:</span><span className="text-[var(--ink)] font-medium">{tc.assigneeId || '-'}</span></div>
|
||||||
|
<div><span className="text-[var(--ink-muted)]">预估耗时:</span><span className="text-[var(--ink)] font-medium">{formatWorkHours(estimateHours)}</span></div>
|
||||||
<div><span className="text-[var(--ink-muted)]">创建人:</span><span className="text-[var(--ink)]">{tc.createdBy}</span></div>
|
<div><span className="text-[var(--ink-muted)]">创建人:</span><span className="text-[var(--ink)]">{tc.createdBy}</span></div>
|
||||||
<div><span className="text-[var(--ink-muted)]">创建日期:</span><span className="text-[var(--ink)]">{tc.createdAt.slice(0, 10)}</span></div>
|
<div><span className="text-[var(--ink-muted)]">创建日期:</span><span className="text-[var(--ink)]">{tc.createdAt.slice(0, 10)}</span></div>
|
||||||
{tc.startedAt && <div><span className="text-[var(--ink-muted)]">开始执行:</span><span className="text-[var(--ink)]">{formatDateTime(tc.startedAt)}</span></div>}
|
{tc.startedAt && <div><span className="text-[var(--ink-muted)]">开始执行:</span><span className="text-[var(--ink)]">{formatDateTime(tc.startedAt)}</span></div>}
|
||||||
{tc.completedAt && <div><span className="text-[var(--ink-muted)]">执行完成:</span><span className="text-[var(--ink)]">{formatDateTime(tc.completedAt)}</span></div>}
|
{tc.completedAt && <div><span className="text-[var(--ink-muted)]">执行完成:</span><span className="text-[var(--ink)]">{formatDateTime(tc.completedAt)}</span></div>}
|
||||||
{tc.startedAt && <div><span className="text-[var(--ink-muted)]">测试耗时:</span><span className="text-[var(--ink)] font-medium">{calcActualHoursByDates(tc.startedAt, tc.completedAt)}h</span></div>}
|
{tc.startedAt && <div><span className="text-[var(--ink-muted)]">实际耗时:</span><span className="text-[var(--ink)] font-medium">{formatWorkHours(actualHours)}</span></div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,15 @@
|
|||||||
|
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { TestCaseStatusBadge } from './TestCaseStatusBadge';
|
import { TestCaseStatusBadge } from './TestCaseStatusBadge';
|
||||||
import { getTestCaseActualHours } from '@/lib/test-case';
|
import { getTestCaseActualHours, getTestCaseEstimateHours } from '@/lib/test-case';
|
||||||
import { formatWorkHours } from '@/lib/work-hours';
|
import { formatWorkHours } from '@/lib/work-hours';
|
||||||
|
import { CategoryChip } from '@/components/dev-task/CategoryChip';
|
||||||
import type { TestCase } from '@/lib/test-case';
|
import type { TestCase } from '@/lib/test-case';
|
||||||
|
import type { TaskCategory } from '@/lib/task-category';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
testCase: TestCase;
|
testCase: TestCase;
|
||||||
|
category?: TaskCategory;
|
||||||
bugCount: number;
|
bugCount: number;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
@@ -19,17 +22,26 @@ const PRIORITY_DOT: Record<string, string> = {
|
|||||||
P3: 'bg-zinc-300',
|
P3: 'bg-zinc-300',
|
||||||
};
|
};
|
||||||
|
|
||||||
function TestCaseRowImpl({ testCase, bugCount, onClick }: Props) {
|
function TestCaseRowImpl({ testCase, category, bugCount, onClick }: Props) {
|
||||||
|
const estimateHours = getTestCaseEstimateHours(testCase);
|
||||||
const actualHours = getTestCaseActualHours(testCase);
|
const actualHours = getTestCaseActualHours(testCase);
|
||||||
return (
|
return (
|
||||||
<div onClick={onClick} className="flex items-center gap-3 px-4 py-2.5 border-b border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer transition-colors last:border-b-0">
|
<div onClick={onClick} className={`flex items-center gap-3 px-4 py-2.5 border-b border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer transition-colors last:border-b-0 ${testCase.aiDraft ? 'border-l-2 border-l-purple-400 bg-purple-50/30' : ''}`}>
|
||||||
<span className={`h-2 w-2 rounded-full shrink-0 ${PRIORITY_DOT[testCase.priority] || 'bg-zinc-300'}`} />
|
<span className={`h-2 w-2 rounded-full shrink-0 ${PRIORITY_DOT[testCase.priority] || 'bg-zinc-300'}`} />
|
||||||
<span className="text-[11px] font-mono text-[var(--ink-muted)] w-14 shrink-0">{testCase.caseNo}</span>
|
<span className="text-[11px] font-mono text-[var(--ink-muted)] w-14 shrink-0">{testCase.caseNo}</span>
|
||||||
<span className="text-[13px] text-[var(--ink)] flex-1 truncate">{testCase.title}</span>
|
<div className="flex-1 min-w-0 flex items-center gap-1.5">
|
||||||
|
<span className="text-[13px] text-[var(--ink)] truncate">{testCase.title}</span>
|
||||||
|
<CategoryChip category={category} />
|
||||||
|
{testCase.aiDraft && (
|
||||||
|
<span className="text-[10px] text-purple-600 bg-purple-100 px-1.5 py-0.5 rounded shrink-0" title="AI 拆解草案,编辑后会移除标记">
|
||||||
|
AI 草案
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<TestCaseStatusBadge status={testCase.status} />
|
<TestCaseStatusBadge status={testCase.status} />
|
||||||
{actualHours > 0 && (
|
<span className="text-[11px] text-[var(--ink-muted)] tabular-nums w-32 text-right shrink-0 whitespace-nowrap">
|
||||||
<span className="text-[11px] text-[var(--ink-muted)] tabular-nums w-28 text-right shrink-0 whitespace-nowrap">{formatWorkHours(actualHours)}</span>
|
{actualHours > 0 ? `${formatWorkHours(actualHours)} / ${formatWorkHours(estimateHours)}` : `预 ${formatWorkHours(estimateHours)}`}
|
||||||
)}
|
</span>
|
||||||
{bugCount > 0 && (
|
{bugCount > 0 && (
|
||||||
<span className="text-[10px] text-red-500 bg-red-50 px-1.5 py-0.5 rounded shrink-0">{bugCount} Bug</span>
|
<span className="text-[10px] text-red-500 bg-red-50 px-1.5 py-0.5 rounded shrink-0">{bugCount} Bug</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ import { useBugStore } from '@/stores/useBugStore';
|
|||||||
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
||||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||||
import { useAuthStore } from '@/stores/useAuthStore';
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
|
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
||||||
import { TestCaseRow } from './TestCaseRow';
|
import { TestCaseRow } from './TestCaseRow';
|
||||||
import { TestCaseCreateModal } from './TestCaseCreateModal';
|
import { TestCaseCreateModal } from './TestCaseCreateModal';
|
||||||
import { TestCaseDetailDrawer } from './TestCaseDetailDrawer';
|
import { TestCaseDetailDrawer } from './TestCaseDetailDrawer';
|
||||||
import { BugCreateModal } from '@/components/bug/BugCreateModal';
|
import { BugCreateModal } from '@/components/bug/BugCreateModal';
|
||||||
import { calcTestProgress, TEST_CASE_STATUS_LABEL, testCaseIntervals } from '@/lib/test-case';
|
import { aggregateTestCaseHours, calcTestProgress, TEST_CASE_STATUS_LABEL, testCaseIntervals } from '@/lib/test-case';
|
||||||
import { formatWorkHours, calcTwoMetrics } from '@/lib/work-hours';
|
import { formatWorkHours, calcTwoMetrics } from '@/lib/work-hours';
|
||||||
import { Pagination, usePagination } from '@/components/Pagination';
|
import { Pagination, usePagination } from '@/components/Pagination';
|
||||||
import { SearchInput, matchTitleOrNo } from '@/components/SearchInput';
|
import { SearchInput, matchTitleOrNo } from '@/components/SearchInput';
|
||||||
@@ -29,9 +30,11 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
|||||||
const { tasks: devTasks } = useDevTaskStore();
|
const { tasks: devTasks } = useDevTaskStore();
|
||||||
const { requirements } = useRequirementStore();
|
const { requirements } = useRequirementStore();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const { categories, fetchCategories } = useTaskCategoryStore();
|
||||||
|
|
||||||
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
|
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
|
||||||
useEffect(() => { fetchBugs(); }, [fetchBugs]);
|
useEffect(() => { fetchBugs(); }, [fetchBugs]);
|
||||||
|
useEffect(() => { fetchCategories(); }, [fetchCategories]);
|
||||||
|
|
||||||
const reqIdSet = useMemo(() => new Set(requirementIds), [requirementIds]);
|
const reqIdSet = useMemo(() => new Set(requirementIds), [requirementIds]);
|
||||||
const versionCases = useMemo(
|
const versionCases = useMemo(
|
||||||
@@ -46,6 +49,7 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
|||||||
const allSubmitted = versionDevTasks.length > 0 && versionDevTasks.every((t) => t.status === 'submitted');
|
const allSubmitted = versionDevTasks.length > 0 && versionDevTasks.every((t) => t.status === 'submitted');
|
||||||
|
|
||||||
const stats = calcTestProgress(versionCases);
|
const stats = calcTestProgress(versionCases);
|
||||||
|
const { estimate: tcEstimateHours, actual: tcActualHours } = aggregateTestCaseHours(versionCases);
|
||||||
const { calendarHours: tcCalendarHours, manhours: tcManhours } = useMemo(() => calcTwoMetrics(testCaseIntervals(versionCases)), [versionCases]);
|
const { calendarHours: tcCalendarHours, manhours: tcManhours } = useMemo(() => calcTwoMetrics(testCaseIntervals(versionCases)), [versionCases]);
|
||||||
|
|
||||||
const [filterAssignee, setFilterAssignee] = useState('');
|
const [filterAssignee, setFilterAssignee] = useState('');
|
||||||
@@ -95,6 +99,7 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
|||||||
}, [paged]);
|
}, [paged]);
|
||||||
|
|
||||||
const requirementMap = useMemo(() => new Map(requirements.map((r) => [r.id, r])), [requirements]);
|
const requirementMap = useMemo(() => new Map(requirements.map((r) => [r.id, r])), [requirements]);
|
||||||
|
const categoryMap = useMemo(() => new Map(categories.map((c) => [c.id, c])), [categories]);
|
||||||
const bugCountByCase = useMemo(() => {
|
const bugCountByCase = useMemo(() => {
|
||||||
const map = new Map<string, number>();
|
const map = new Map<string, number>();
|
||||||
for (const b of bugs) map.set(b.testCaseId, (map.get(b.testCaseId) ?? 0) + 1);
|
for (const b of bugs) map.set(b.testCaseId, (map.get(b.testCaseId) ?? 0) + 1);
|
||||||
@@ -125,7 +130,7 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
|||||||
<div className="h-1.5 w-20 rounded-full bg-[var(--bg)] overflow-hidden shrink-0">
|
<div className="h-1.5 w-20 rounded-full bg-[var(--bg)] overflow-hidden shrink-0">
|
||||||
<div className="h-full rounded-full bg-emerald-500" style={{ width: `${stats.completionRate}%` }} />
|
<div className="h-full rounded-full bg-emerald-500" style={{ width: `${stats.completionRate}%` }} />
|
||||||
</div>
|
</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.total}例 · 通过{stats.passed} · 不通过{stats.failed} · 预 {formatWorkHours(tcEstimateHours)} / 实 {formatWorkHours(tcActualHours)}</span>
|
||||||
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">通过率{stats.passRate}%</span>
|
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">通过率{stats.passRate}%</span>
|
||||||
{tcManhours > 0 && (
|
{tcManhours > 0 && (
|
||||||
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">日历 {formatWorkHours(tcCalendarHours)} / 人力 {formatWorkHours(tcManhours)}</span>
|
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">日历 {formatWorkHours(tcCalendarHours)} / 人力 {formatWorkHours(tcManhours)}</span>
|
||||||
@@ -180,7 +185,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()} />
|
<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>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<TestCaseRow testCase={c} bugCount={bugCountByCase.get(c.id) ?? 0} onClick={() => setSelectedCaseId(c.id)} />
|
<TestCaseRow testCase={c} category={categoryMap.get(c.categoryId)} bugCount={bugCountByCase.get(c.id) ?? 0} onClick={() => setSelectedCaseId(c.id)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user