feat(版本): 优化概览与只读状态
关键改动: - 增加需求排序和版本只读状态规则及测试 - 完善版本概览阶段耗时、项目页和工作台展示 - 优化小宝预警请求节流、建议状态和风险过滤 Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
@@ -25,6 +25,7 @@ interface Props {
|
||||
onClose: () => void;
|
||||
onCreateBug?: (testCaseId: string) => void;
|
||||
contextLabel?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
function defaultPlanStartLocal(): string {
|
||||
@@ -39,7 +40,7 @@ function defaultPlanEndLocal(): string {
|
||||
return isoToLocal(d.toISOString());
|
||||
}
|
||||
|
||||
export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, contextLabel }: Props) {
|
||||
export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, contextLabel, readOnly = false }: Props) {
|
||||
const { testCases, changeStatus, deleteTestCase, updateTestCase } = useTestCaseStore();
|
||||
const { bugs } = useBugStore();
|
||||
const { requirements } = useRequirementStore();
|
||||
@@ -75,12 +76,14 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
||||
const planEstimateHours = planStartBeforeEnd ? calcWorkHours(planStartISO, planEndISO) : 0;
|
||||
|
||||
const openPlanInput = () => {
|
||||
if (readOnly) return;
|
||||
setPlanStartLocal(tc.plannedTestAt ? isoToLocal(tc.plannedTestAt) : defaultPlanStartLocal());
|
||||
setPlanEndLocal(tc.plannedEndAt ? isoToLocal(tc.plannedEndAt) : defaultPlanEndLocal());
|
||||
setShowPlanInput(true);
|
||||
};
|
||||
|
||||
const handleSavePlan = () => {
|
||||
if (readOnly) return;
|
||||
const assigneeId = tc.assigneeId || currentUserName;
|
||||
if (!assigneeId) {
|
||||
alert('领取前需要先登录或选择负责人');
|
||||
@@ -102,6 +105,7 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
||||
const [showBlockInput, setShowBlockInput] = useState(false);
|
||||
|
||||
const handleTransition = (to: TestCaseStatus) => {
|
||||
if (readOnly) return;
|
||||
if (to === 'running' && !startReady) {
|
||||
openPlanInput();
|
||||
return;
|
||||
@@ -112,12 +116,14 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
||||
};
|
||||
|
||||
const confirmFail = () => {
|
||||
if (readOnly) return;
|
||||
changeStatus(tc.id, 'failed', { failReason: failReason.trim() || undefined });
|
||||
setShowFailInput(false);
|
||||
setFailReason('');
|
||||
};
|
||||
|
||||
const confirmBlock = () => {
|
||||
if (readOnly) return;
|
||||
changeStatus(tc.id, 'blocked', { blockReason: blockReason.trim() || undefined });
|
||||
setShowBlockInput(false);
|
||||
setBlockReason('');
|
||||
@@ -137,16 +143,16 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
||||
<span className="text-[14px] font-semibold text-[var(--ink)] truncate max-w-[240px]">{tc.title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{tc.status !== 'passed' && (
|
||||
{!readOnly && tc.status !== 'passed' && (
|
||||
<button onClick={() => setShowTransfer(!showTransfer)} className="p-1.5 rounded-lg hover:bg-blue-50 text-[var(--ink-muted)] hover:text-blue-500" title="转交"><ArrowRightLeft className="h-4 w-4" /></button>
|
||||
)}
|
||||
<button onClick={() => { if (relatedBugs.length > 0) { alert('该用例有关联 Bug,无法删除'); return; } if (confirm('确定删除此测试用例?')) { deleteTestCase(tc.id); onClose(); } }} className="p-1.5 rounded-lg hover:bg-red-50 text-[var(--ink-muted)] hover:text-red-500" title="删除"><Trash2 className="h-4 w-4" /></button>
|
||||
{!readOnly && <button onClick={() => { if (relatedBugs.length > 0) { alert('该用例有关联 Bug,无法删除'); return; } if (confirm('确定删除此测试用例?')) { deleteTestCase(tc.id); onClose(); } }} className="p-1.5 rounded-lg hover:bg-red-50 text-[var(--ink-muted)] hover:text-red-500" title="删除"><Trash2 className="h-4 w-4" /></button>}
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-[var(--bg-subtle)]"><X className="h-4 w-4 text-[var(--ink-muted)]" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 转交 */}
|
||||
{showTransfer && (
|
||||
{showTransfer && !readOnly && (
|
||||
<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>
|
||||
<FilterSelect
|
||||
@@ -157,7 +163,7 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
||||
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={() => { if (readOnly) return; 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>
|
||||
)}
|
||||
@@ -183,7 +189,7 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
||||
{tc.executedAt && <span className="text-[11px] text-[var(--ink-muted)]">执行于 {tc.executedAt}</span>}
|
||||
</div>
|
||||
|
||||
{visibleNextStatuses.length > 0 && !showFailInput && !showBlockInput && (
|
||||
{visibleNextStatuses.length > 0 && !showFailInput && !showBlockInput && !readOnly && (
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<ChevronRight className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
|
||||
{visibleNextStatuses.map((s) => (
|
||||
@@ -194,7 +200,7 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tc.status === 'pending' && !startReady && !showPlanInput && (
|
||||
{tc.status === 'pending' && !startReady && !showPlanInput && !readOnly && (
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<button
|
||||
onClick={openPlanInput}
|
||||
@@ -209,7 +215,7 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPlanInput && (
|
||||
{showPlanInput && !readOnly && (
|
||||
<div className="rounded-lg border border-orange-200 bg-orange-50 p-3 space-y-3">
|
||||
<div className="text-[11px] font-medium text-orange-700">
|
||||
{needsClaim ? '领取并填写计划' : '填写计划'}
|
||||
@@ -249,7 +255,7 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFailInput && (
|
||||
{showFailInput && !readOnly && (
|
||||
<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 />
|
||||
<button onClick={confirmFail} className="h-8 px-3 rounded-lg text-[11px] font-medium bg-red-500 text-white">确认不通过</button>
|
||||
@@ -257,7 +263,7 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showBlockInput && (
|
||||
{showBlockInput && !readOnly && (
|
||||
<div className="flex gap-2 pt-1">
|
||||
<input value={blockReason} onChange={(e) => setBlockReason(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') confirmBlock(); }} placeholder="阻塞原因(可选)" className="flex-1 h-8 rounded-lg border border-[var(--line)] px-3 text-[12px] focus:border-orange-400 focus:outline-none" autoFocus />
|
||||
<button onClick={confirmBlock} className="h-8 px-3 rounded-lg text-[11px] font-medium bg-orange-500 text-white">确认阻塞</button>
|
||||
@@ -299,7 +305,7 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide">关联 Bug ({relatedBugs.length})</div>
|
||||
{tc.status === 'failed' && onCreateBug && (
|
||||
{tc.status === 'failed' && onCreateBug && !readOnly && (
|
||||
<button onClick={() => onCreateBug(tc.id)} className="flex items-center gap-1 h-7 px-3 rounded-lg text-[11px] font-medium bg-red-500 text-white hover:bg-red-600">
|
||||
<BugIcon className="h-3 w-3" />提 BUG
|
||||
</button>
|
||||
|
||||
@@ -25,9 +25,10 @@ import type { TestCaseStatus } from '@/lib/test-case';
|
||||
interface Props {
|
||||
versionId: string;
|
||||
requirementIds: string[];
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function TestCaseTab({ versionId, requirementIds }: Props) {
|
||||
export function TestCaseTab({ versionId, requirementIds, readOnly = false }: Props) {
|
||||
const { testCases, fetchTestCases, createTestCases, deleteTestCase } = useTestCaseStore();
|
||||
const { bugs, fetchBugs } = useBugStore();
|
||||
const { tasks: devTasks } = useDevTaskStore();
|
||||
@@ -116,11 +117,13 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
if (readOnly) return;
|
||||
const next = new Set(selectedIds);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
setSelectedIds(next);
|
||||
};
|
||||
const handleBatchDelete = () => {
|
||||
if (readOnly) return;
|
||||
if (selectedIds.size === 0) return;
|
||||
const hasBug = Array.from(selectedIds).some((id) => bugs.some((b) => b.testCaseId === id));
|
||||
if (hasBug) {
|
||||
@@ -137,6 +140,7 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
||||
setPage(1);
|
||||
};
|
||||
const handleStartNewRound = () => {
|
||||
if (readOnly) return;
|
||||
if (!canCreateNextRound) return;
|
||||
const operator = user?.name || '系统';
|
||||
createTestCases(firstRoundCases.map((testCase) => copyTestCaseToRound(testCase, nextRoundNo, operator)));
|
||||
@@ -212,29 +216,33 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
||||
{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 && (
|
||||
{selectedIds.size > 0 && !readOnly && (
|
||||
<button onClick={handleBatchDelete} className="flex items-center gap-1 h-6 px-2 rounded text-[11px] font-medium bg-red-500 text-white hover:bg-red-600">
|
||||
<Trash2 className="h-3 w-3" />删除{selectedIds.size}项
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleStartNewRound}
|
||||
disabled={!canCreateNextRound}
|
||||
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" />开启新一轮测试
|
||||
</button>
|
||||
<button onClick={() => setShowCreate(true)} className="flex items-center gap-1 h-6 px-2.5 rounded text-[11px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]">
|
||||
<Plus className="h-3 w-3" />新建
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleStartNewRound}
|
||||
disabled={!canCreateNextRound}
|
||||
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" />开启新一轮测试
|
||||
</button>
|
||||
<button onClick={() => setShowCreate(true)} className="flex items-center gap-1 h-6 px-2.5 rounded text-[11px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]">
|
||||
<Plus className="h-3 w-3" />新建
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
{!hasFilter && <button onClick={() => setShowCreate(true)} className="mt-3 text-[12px] text-[var(--accent)] hover:underline">创建第一个用例</button>}
|
||||
{!hasFilter && !readOnly && <button onClick={() => setShowCreate(true)} className="mt-3 text-[12px] text-[var(--accent)] hover:underline">创建第一个用例</button>}
|
||||
</div>
|
||||
) : (
|
||||
Array.from(groupedByReq.entries()).map(([reqId, cases]) => {
|
||||
@@ -244,9 +252,11 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
||||
return (
|
||||
<div key={reqId} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
|
||||
<div className="flex items-center bg-[var(--bg-subtle)] border-b border-[var(--line)]">
|
||||
<div className="pl-4 flex items-center">
|
||||
<input type="checkbox" checked={cases.every((c) => selectedIds.has(c.id))} onChange={() => { const ids = cases.map((c) => c.id); const allSel = ids.every((id) => selectedIds.has(id)); const next = new Set(selectedIds); if (allSel) ids.forEach((id) => next.delete(id)); else ids.forEach((id) => next.add(id)); setSelectedIds(next); }} className="h-3.5 w-3.5 rounded border-[var(--line)]" />
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="pl-4 flex items-center">
|
||||
<input type="checkbox" checked={cases.every((c) => selectedIds.has(c.id))} onChange={() => { const ids = cases.map((c) => c.id); const allSel = ids.every((id) => selectedIds.has(id)); const next = new Set(selectedIds); if (allSel) ids.forEach((id) => next.delete(id)); else ids.forEach((id) => next.add(id)); setSelectedIds(next); }} className="h-3.5 w-3.5 rounded border-[var(--line)]" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-1 min-w-0 items-center gap-2 px-4 py-2">
|
||||
<span className="h-2 w-2 shrink-0" />
|
||||
<span className="w-14 min-w-0 shrink-0 truncate text-[11px] font-mono text-[var(--ink-muted)]" title={req?.code || '通用'}>{req?.code || '通用'}</span>
|
||||
@@ -265,9 +275,11 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
||||
</div>
|
||||
{cases.map((c) => (
|
||||
<div key={c.id} className="flex items-center">
|
||||
<div className="pl-4 flex items-center">
|
||||
<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>
|
||||
{!readOnly && (
|
||||
<div className="pl-4 flex items-center">
|
||||
<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} category={categoryMap.get(c.categoryId)} categoryLabelWidthEm={categoryLabelWidthEm} bugCount={bugCountByCase.get(c.id) ?? 0} onClick={() => setSelectedCaseId(c.id)} />
|
||||
</div>
|
||||
@@ -280,9 +292,9 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
||||
|
||||
{total > 20 && <Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />}
|
||||
|
||||
{showCreate && <TestCaseCreateModal versionId={versionId} requirementIds={requirementIds} roundNo={activeRound} onClose={() => setShowCreate(false)} />}
|
||||
{selectedCaseId && <TestCaseDetailDrawer testCaseId={selectedCaseId} onClose={() => setSelectedCaseId(null)} onCreateBug={(id) => setBugForCaseId(id)} />}
|
||||
{bugForCaseId && <BugCreateModal testCaseId={bugForCaseId} onClose={() => setBugForCaseId(null)} />}
|
||||
{showCreate && !readOnly && <TestCaseCreateModal versionId={versionId} requirementIds={requirementIds} roundNo={activeRound} onClose={() => setShowCreate(false)} />}
|
||||
{selectedCaseId && <TestCaseDetailDrawer testCaseId={selectedCaseId} readOnly={readOnly} onClose={() => setSelectedCaseId(null)} onCreateBug={(id) => { if (!readOnly) setBugForCaseId(id); }} />}
|
||||
{bugForCaseId && !readOnly && <BugCreateModal testCaseId={bugForCaseId} onClose={() => setBugForCaseId(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user