feat(版本): 优化概览与只读状态

关键改动:

- 增加需求排序和版本只读状态规则及测试

- 完善版本概览阶段耗时、项目页和工作台展示

- 优化小宝预警请求节流、建议状态和风险过滤

Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
Script Generator
2026-06-30 18:18:18 +08:00
parent 3d3d56697a
commit eef3c8f000
32 changed files with 1072 additions and 289 deletions

View File

@@ -27,9 +27,10 @@ interface Props {
bugId: string;
onClose: () => void;
contextLabel?: string;
readOnly?: boolean;
}
export function BugDetailDrawer({ bugId, onClose, contextLabel }: Props) {
export function BugDetailDrawer({ bugId, onClose, contextLabel, readOnly = false }: Props) {
const { bugs, changeStatus, transferBug } = useBugStore();
const { testCases } = useTestCaseStore();
const { requirements } = useRequirementStore();
@@ -73,16 +74,19 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel }: Props) {
}, [bug.logs, bug.title, members]);
const handleTransition = (to: BugStatus) => {
if (readOnly) return;
if (to === 'fixed') { setShowResolutionInput(true); return; }
changeStatus(bug.id, to, operator);
};
const confirmFix = () => {
if (readOnly) return;
changeStatus(bug.id, 'fixed', operator, { resolution: resolution.trim() || undefined });
setShowResolutionInput(false);
};
const handleTransfer = () => {
if (readOnly) return;
if (!transferTo) return;
transferBug(bug.id, transferTo, operator, transferRemark.trim() || undefined);
setShowTransfer(false);
@@ -135,7 +139,7 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel }: Props) {
<span className="text-[11px] text-[var(--ink-muted)]">{bug.priority}</span>
</div>
{nextStatuses.length > 0 && !showResolutionInput && isCurrentAssignee && (
{nextStatuses.length > 0 && !showResolutionInput && isCurrentAssignee && !readOnly && (
<div className="flex items-center gap-2 pt-1 flex-wrap">
<ChevronRight className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
{nextStatuses.map((s) => (
@@ -150,11 +154,11 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel }: Props) {
)}
</div>
)}
{nextStatuses.length > 0 && !showResolutionInput && !isCurrentAssignee && (
{nextStatuses.length > 0 && !showResolutionInput && !isCurrentAssignee && !readOnly && (
<div className="text-[11px] text-[var(--ink-muted)] pt-1"> {assigneeName}</div>
)}
{showResolutionInput && (
{showResolutionInput && !readOnly && (
<div className="space-y-2 pt-1">
<input value={resolution} onChange={(e) => setResolution(e.target.value)} placeholder="修复说明" className="h-8 w-full rounded-lg border border-[var(--line)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" autoFocus />
<div className="flex gap-2">
@@ -164,7 +168,7 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel }: Props) {
</div>
)}
{showTransfer && (
{showTransfer && !readOnly && (
<div className="space-y-2 pt-1 border-t border-[var(--line)]">
<div className="text-[11px] text-[var(--ink-muted)]"></div>
<FilterSelect

View File

@@ -22,9 +22,10 @@ import type { BugStatus, BugSeverity } from '@/lib/bug';
interface Props {
versionId: string;
requirementIds: string[];
readOnly?: boolean;
}
export function BugTab({ versionId, requirementIds }: Props) {
export function BugTab({ versionId, requirementIds, readOnly = false }: Props) {
const { bugs, fetchBugs } = useBugStore();
const { testCases, fetchTestCases } = useTestCaseStore();
const { requirements } = useRequirementStore();
@@ -159,7 +160,7 @@ export function BugTab({ versionId, requirementIds }: Props) {
{total > 20 && <Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />}
{selectedBugId && <BugDetailDrawer bugId={selectedBugId} onClose={() => setSelectedBugId(null)} />}
{selectedBugId && <BugDetailDrawer bugId={selectedBugId} readOnly={readOnly} onClose={() => setSelectedBugId(null)} />}
</div>
);
}

View File

@@ -32,6 +32,7 @@ interface Props {
allTaskIds: string[];
onClose: () => void;
contextLabel?: string;
readOnly?: boolean;
}
function defaultPlanStartLocal(): string {
@@ -46,7 +47,7 @@ function defaultPlanEndLocal(): string {
return isoToLocal(d.toISOString());
}
export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel }: Props) {
export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel, readOnly = false }: Props) {
const { tasks, changeStatus, setBlocked, deleteTask, updateTask } = useDevTaskStore();
const addProgressNote = useWorkActivityStore((s) => s.addProgressNote);
const { categories } = useTaskCategoryStore();
@@ -93,12 +94,14 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
const planEstimateHours = planStartBeforeEnd ? calcWorkHours(planStartISO, planEndISO) : 0;
const openPlanInput = () => {
if (readOnly) return;
setPlanStartLocal(task.expectedStartAt ? isoToLocal(task.expectedStartAt) : defaultPlanStartLocal());
setPlanEndLocal(task.expectedEndAt ? isoToLocal(task.expectedEndAt) : defaultPlanEndLocal());
setShowPlanInput(true);
};
const handleSavePlan = () => {
if (readOnly) return;
const assigneeId = task.assigneeId || currentUserName;
if (!assigneeId) {
alert('领取前需要先登录或选择负责人');
@@ -115,6 +118,7 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
};
const handleTransition = (to: DevTaskStatus) => {
if (readOnly) return;
if (to === 'in_progress' && !startReady) {
openPlanInput();
return;
@@ -138,17 +142,20 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
};
const handleBlock = () => {
if (readOnly) return;
if (!blockReason.trim()) return;
setBlocked(task.id, true, blockReason.trim());
setShowBlockInput(false);
};
const handleUnblock = () => {
if (readOnly) return;
setBlocked(task.id, false);
setBlockReason('');
};
const handleProgressNote = () => {
if (readOnly) return;
const note = progressNote.trim();
const blocker = progressBlocker.trim();
const delayRisk = progressDelayRisk.trim();
@@ -184,15 +191,15 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
<span className="text-[14px] font-semibold text-[var(--ink)] truncate max-w-[240px]">{task.title}</span>
</div>
<div className="flex items-center gap-1">
{task.status !== 'submitted' && (
{!readOnly && task.status !== 'submitted' && (
<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 (confirm('确定删除此任务?')) { deleteTask(task.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 (confirm('确定删除此任务?')) { deleteTask(task.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
@@ -239,7 +246,7 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
)}
</div>
{visibleNextStatuses.length > 0 && !showDelayInput && (
{visibleNextStatuses.length > 0 && !showDelayInput && !readOnly && (
<div className="flex items-center gap-2 pt-1 flex-wrap">
<ChevronRight className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
{visibleNextStatuses.map((s) => (
@@ -250,7 +257,7 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
</div>
)}
{task.status === 'todo' && !startReady && !showPlanInput && (
{task.status === 'todo' && !startReady && !showPlanInput && !readOnly && (
<div className="flex items-center gap-2 pt-1">
<button
onClick={openPlanInput}
@@ -265,7 +272,7 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
</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 ? '领取并填写计划' : '填写计划'}
@@ -305,7 +312,7 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
</div>
)}
{showDelayInput && (
{showDelayInput && !readOnly && (
<div className="rounded-lg border border-orange-200 bg-orange-50 p-3 space-y-2">
<div className="flex items-center gap-1.5 text-[11px] text-orange-700">
<AlertTriangle className="h-3.5 w-3.5" />
@@ -326,23 +333,23 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
<AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
<span>{task.blockReason}</span>
</div>
<button onClick={handleUnblock} className="text-[11px] text-emerald-600 font-medium hover:underline"></button>
{!readOnly && <button onClick={handleUnblock} className="text-[11px] text-emerald-600 font-medium hover:underline"></button>}
</div>
) : (
showBlockInput ? (
showBlockInput && !readOnly ? (
<div className="flex gap-2">
<input value={blockReason} onChange={(e) => setBlockReason(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleBlock(); }} 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={handleBlock} disabled={!blockReason.trim()} className="h-8 px-3 rounded-lg text-[11px] font-medium bg-red-500 text-white disabled:opacity-50"></button>
<button onClick={() => setShowBlockInput(false)} className="h-8 px-2 text-[11px] text-[var(--ink-muted)]"></button>
</div>
) : (
<button onClick={() => setShowBlockInput(true)} className="text-[11px] text-red-500 font-medium hover:underline"></button>
!readOnly && <button onClick={() => setShowBlockInput(true)} className="text-[11px] text-red-500 font-medium hover:underline"></button>
)
)}
</div>
</div>
{task.status !== 'todo' && task.status !== 'submitted' && (
{task.status !== 'todo' && task.status !== 'submitted' && !readOnly && (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 space-y-3">
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide"></div>
<textarea

View File

@@ -25,9 +25,10 @@ interface Props {
versionId: string;
requirementIds: string[];
versionDeadline?: string;
readOnly?: boolean;
}
export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props) {
export function DevTaskTab({ versionId, requirementIds, versionDeadline, readOnly = false }: Props) {
const { tasks, fetchTasks, deleteTask } = useDevTaskStore();
const { categories, fetchCategories } = useTaskCategoryStore();
const { fetchWorklogs } = useTaskWorklogStore();
@@ -90,11 +91,13 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: 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;
if (!confirm(`确定删除选中的 ${selectedIds.size} 个任务?`)) return;
selectedIds.forEach((id) => deleteTask(id));
@@ -174,21 +177,23 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props
{hasFilter && <button onClick={() => { setFilterAssignee(''); setFilterStatus(''); setFilterBlocked(''); setFilterCategory(''); 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={() => 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={() => 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>
{filteredTasks.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, reqTasks]) => {
@@ -197,9 +202,11 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: 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={reqTasks.every((t) => selectedIds.has(t.id))} onChange={() => { const ids = reqTasks.map((t) => t.id); const allSelected = ids.every((id) => selectedIds.has(id)); const next = new Set(selectedIds); if (allSelected) 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={reqTasks.every((t) => selectedIds.has(t.id))} onChange={() => { const ids = reqTasks.map((t) => t.id); const allSelected = ids.every((id) => selectedIds.has(id)); const next = new Set(selectedIds); if (allSelected) 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-16 shrink-0 truncate text-[11px] font-mono text-[var(--ink-muted)]" title={req?.code}>{req?.code}</span>
@@ -209,9 +216,11 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props
</div>
{reqTasks.map((t) => (
<div key={t.id} className="flex items-center">
<div className="pl-4 flex items-center">
<input type="checkbox" checked={selectedIds.has(t.id)} onChange={() => toggleSelect(t.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(t.id)} onChange={() => toggleSelect(t.id)} className="h-3.5 w-3.5 rounded border-[var(--line)]" onClick={(e) => e.stopPropagation()} />
</div>
)}
<div className="flex-1 min-w-0">
<DevTaskRow task={t} category={categoryMap.get(t.categoryId)} categoryLabelWidthEm={categoryLabelWidthEm} onClick={() => setSelectedTaskId(t.id)} />
</div>
@@ -224,8 +233,8 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props
{total > 20 && <Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />}
{showCreate && <DevTaskCreateModal versionId={versionId} requirementIds={requirementIds} versionDeadline={versionDeadline} onClose={() => setShowCreate(false)} />}
{selectedTaskId && <DevTaskDetailDrawer taskId={selectedTaskId} allTaskIds={allTaskIds} onClose={() => setSelectedTaskId(null)} />}
{showCreate && !readOnly && <DevTaskCreateModal versionId={versionId} requirementIds={requirementIds} versionDeadline={versionDeadline} onClose={() => setShowCreate(false)} />}
{selectedTaskId && <DevTaskDetailDrawer taskId={selectedTaskId} allTaskIds={allTaskIds} readOnly={readOnly} onClose={() => setSelectedTaskId(null)} />}
</div>
);
}

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -24,6 +24,7 @@ interface Props {
planId: string;
onClose: () => void;
contextLabel?: string;
readOnly?: boolean;
}
const STATUS_STYLE: Record<string, string> = { pending: 'bg-zinc-100 text-zinc-600', in_progress: 'bg-blue-50 text-blue-600', completed: 'bg-emerald-50 text-emerald-600' };
@@ -48,7 +49,7 @@ function getFailureLabels(types?: ProductPlanReviewFailureType[]): string[] {
.filter(Boolean) as string[];
}
export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
export function PlanDetailDrawer({ planId, onClose, contextLabel, readOnly = false }: Props) {
const { plans, updatePlan, completePlan } = useVersionPlanStore();
const { requirements } = useRequirementStore();
const { members } = useMemberStore();
@@ -74,13 +75,14 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
? getResearchDirectionProgressSummary(plan).percent
: getRequirementCoverageSummary(plan).percent;
const linkedReqs = (plan.linkedRequirementIds || []).map((id) => requirements.find((r) => r.id === id)).filter(Boolean) as { id: string; code: string; title: string }[];
const canEditCoverage = canEditPlanRequirementCoverage(plan);
const canEditCoverage = !readOnly && canEditPlanRequirementCoverage(plan);
const currentUserName = user?.name ?? plan.owner;
const productPlanKind = plan.type === 'product' ? getProductPlanKind(plan) : undefined;
const isProductDesignPlan = productPlanKind === 'design';
const isProductReviewPlan = productPlanKind === 'review';
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
if (readOnly) return;
const file = e.target.files?.[0];
if (!file) return;
setFileName(file.name);
@@ -90,6 +92,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
};
const toggleFailureType = (type: ProductPlanReviewFailureType) => {
if (readOnly) return;
const next = new Set(reviewFailureTypes);
if (next.has(type)) next.delete(type);
else next.add(type);
@@ -97,6 +100,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
};
const handleSubmitResult = () => {
if (readOnly) return;
let payload: PlanResultPayload | null = null;
if (isProductReviewPlan) {
@@ -140,6 +144,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
};
const handleTransfer = () => {
if (readOnly) return;
if (!transferTo) return;
updatePlan(plan.id, { owner: transferTo });
setShowTransfer(false);
@@ -272,7 +277,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
)}
{/* Transfer Section */}
{showTransfer && (
{showTransfer && !readOnly && (
<div className="rounded-lg border border-[var(--line)] p-3 space-y-2">
<div className="text-[11px] font-medium text-[var(--ink-muted)]"></div>
<FilterSelect
@@ -289,7 +294,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
)}
{/* Complete with result */}
{showComplete && (
{showComplete && !readOnly && (
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 space-y-2">
<div className="text-[11px] font-medium text-emerald-700">{getSubmitActionLabel(plan)}</div>
{isProductReviewPlan ? (
@@ -382,7 +387,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
</div>
{/* Footer Actions */}
{plan.status !== 'completed' && (
{plan.status !== 'completed' && !readOnly && (
<>
<div className="flex items-center gap-2 px-5 py-3 border-t border-[var(--line)] shrink-0">
{plan.status === 'pending' && (

View File

@@ -43,6 +43,7 @@ interface Props {
onUpdate: (id: string, data: Partial<VersionPlan>) => void;
onComplete: (id: string, result: PlanResultPayload) => { ok: boolean; message?: string } | void;
onDelete: (id: string) => void;
readOnly?: boolean;
}
const TYPE_LABEL = { research: '调研', product: '产品方案', ui: 'UI设计' };
@@ -71,7 +72,7 @@ function getFailureLabels(types?: ProductPlanReviewFailureType[]): string[] {
.filter(Boolean) as string[];
}
export function PlanTab({ plans, versionId, version, versionDeadline, currentUserName, planType, versionMembers, linkedRequirements, allRequirements, onCreate, onUpdate, onComplete, onDelete }: Props) {
export function PlanTab({ plans, versionId, version, versionDeadline, currentUserName, planType, versionMembers, linkedRequirements, allRequirements, onCreate, onUpdate, onComplete, onDelete, readOnly = false }: Props) {
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingPlan, setEditingPlan] = useState<VersionPlan | null>(null);
const [completingPlan, setCompletingPlan] = useState<VersionPlan | null>(null);
@@ -321,10 +322,11 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
onEditPlan={setEditingPlan}
onOpenComplete={setCompletingPlan}
onCreatePlan={() => setShowCreateModal(true)}
readOnly={readOnly}
/>
)}
{(showCreateModal || editingPlan) && (
{(showCreateModal || editingPlan) && !readOnly && (
<PlanFormModal
initial={editingPlan}
planType={planType}
@@ -343,7 +345,7 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
/>
)}
{completingPlan && (
{completingPlan && !readOnly && (
<CompleteModal
plan={completingPlan}
onClose={() => setCompletingPlan(null)}
@@ -402,6 +404,7 @@ function ProductUiPlanWorkspace({
onEditPlan,
onOpenComplete,
onCreatePlan,
readOnly,
}: {
typePlans: VersionPlan[];
planType: VersionPlan['type'];
@@ -421,6 +424,7 @@ function ProductUiPlanWorkspace({
onEditPlan: (plan: VersionPlan) => void;
onOpenComplete: (plan: VersionPlan) => void;
onCreatePlan: () => void;
readOnly: boolean;
}) {
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(typePlans[0]?.id ?? null);
const selectedPlan = typePlans.find((plan) => plan.id === selectedPlanId) ?? typePlans[0];
@@ -428,23 +432,26 @@ function ProductUiPlanWorkspace({
const allLogs = useMemo(() => getPlanLogsForPlans(typePlans), [typePlans]);
useEffect(() => {
if (readOnly) return;
typePlans.forEach((plan) => {
const { autoStarted } = getPlanRuntime(plan);
if (autoStarted && !plan.actualStartAt) {
onUpdate(plan.id, { status: 'in_progress' });
}
});
}, [typePlans, onUpdate]);
}, [typePlans, onUpdate, readOnly]);
return (
<div className="flex h-full min-h-0">
<aside className="flex h-full w-72 shrink-0 flex-col border-r border-[var(--line)] bg-[var(--bg-card)]">
<div className="flex h-14 shrink-0 items-center justify-between gap-2 border-b border-[var(--line)] px-4">
<div className="text-[14px] font-semibold text-[var(--ink)]"></div>
<button onClick={onCreatePlan} className="flex h-7 items-center gap-1.5 rounded-md bg-[var(--accent)] px-2.5 text-[11px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors">
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
</button>
{!readOnly && (
<button onClick={onCreatePlan} className="flex h-7 items-center gap-1.5 rounded-md bg-[var(--accent)] px-2.5 text-[11px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors">
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
</button>
)}
</div>
<div className="shrink-0 space-y-2 border-b border-[var(--line)] p-3">
<div className={`grid gap-2 ${versionDeadline ? 'grid-cols-2' : 'grid-cols-1'}`}>
@@ -530,6 +537,7 @@ function ProductUiPlanWorkspace({
onDelete={onDelete}
onEditPlan={onEditPlan}
onOpenComplete={onOpenComplete}
readOnly={readOnly}
/>
) : (
<div className="flex h-full items-center justify-center text-[13px] text-[var(--ink-muted)]">
@@ -563,6 +571,7 @@ function ProductUiPlanDetail({
onDelete,
onEditPlan,
onOpenComplete,
readOnly,
}: {
plan: VersionPlan;
planType: VersionPlan['type'];
@@ -579,12 +588,13 @@ function ProductUiPlanDetail({
onDelete: (id: string) => void;
onEditPlan: (plan: VersionPlan) => void;
onOpenComplete: (plan: VersionPlan) => void;
readOnly: boolean;
}) {
const now = new Date().toISOString();
const { autoStarted, effectiveStatus, effectiveStartAt } = getPlanRuntime(plan);
const durText = getPlanDurationText(plan, effectiveStatus, effectiveStartAt, now);
const completionState = getPlanCompletionState(plan);
const canEditCoverage = canEditPlanRequirementCoverage(plan);
const canEditCoverage = !readOnly && canEditPlanRequirementCoverage(plan);
const requirementOptions = mergeSelectedRequirementOptions(linkedRequirements ?? [], allRequirements ?? [], plan.linkedRequirementIds ?? []);
const selectedRequirements = (plan.linkedRequirementIds ?? [])
.map((rid) => requirementOptions.find((requirement) => requirement.id === rid))
@@ -612,26 +622,28 @@ function ProductUiPlanDetail({
</div>
<div className="mt-1 text-[11px] text-[var(--ink-muted)]">{TYPE_LABEL[plan.type]}</div>
</div>
<div className="flex shrink-0 items-center gap-1">
{plan.status === 'pending' && !autoStarted && (
<button onClick={() => onUpdate(plan.id, { status: 'in_progress' })} className="flex h-7 items-center gap-1 rounded-md border border-blue-200 px-2 text-[11px] font-medium text-blue-600 hover:bg-blue-50" title="提前开始">
<Play className="h-3 w-3" />
</button>
)}
{plan.status !== 'completed' && (
<>
<button onClick={() => onSetTransferPlanId(plan.id)} className="flex h-7 w-7 items-center justify-center rounded-md text-blue-500 hover:bg-blue-50" title="转交">
<ArrowRightLeft className="h-3.5 w-3.5" />
{!readOnly && (
<div className="flex shrink-0 items-center gap-1">
{plan.status === 'pending' && !autoStarted && (
<button onClick={() => onUpdate(plan.id, { status: 'in_progress' })} className="flex h-7 items-center gap-1 rounded-md border border-blue-200 px-2 text-[11px] font-medium text-blue-600 hover:bg-blue-50" title="提前开始">
<Play className="h-3 w-3" />
</button>
<button onClick={() => onEditPlan(plan)} className="flex h-7 w-7 items-center justify-center rounded-md text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)]" title="编辑">
<Pencil className="h-3.5 w-3.5" />
</button>
<button onClick={() => onDelete(plan.id)} className="flex h-7 w-7 items-center justify-center rounded-md text-red-500 hover:bg-red-50" title="删除">
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
)}
{plan.status !== 'completed' && (
<>
<button onClick={() => onSetTransferPlanId(plan.id)} className="flex h-7 w-7 items-center justify-center rounded-md text-blue-500 hover:bg-blue-50" title="转交">
<ArrowRightLeft className="h-3.5 w-3.5" />
</button>
<button onClick={() => onEditPlan(plan)} className="flex h-7 w-7 items-center justify-center rounded-md text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)]" title="编辑">
<Pencil className="h-3.5 w-3.5" />
</button>
<button onClick={() => onDelete(plan.id)} className="flex h-7 w-7 items-center justify-center rounded-md text-red-500 hover:bg-red-50" title="删除">
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
)}
</div>
<dl className="mt-4 grid grid-cols-1 gap-3 text-[12px] sm:grid-cols-2 xl:grid-cols-4">
@@ -676,7 +688,7 @@ function ProductUiPlanDetail({
<a href={plan.resultUrl} target="_blank" rel="noopener noreferrer" className="flex min-w-0 items-center gap-1 text-[12px] text-[var(--accent)] hover:underline">
<span className="truncate">{plan.resultTitle || plan.resultFileName || '查看成果'}</span><ExternalLink className="h-3 w-3 shrink-0" />
</a>
{planType === 'product' && version && (
{planType === 'product' && version && !readOnly && (
<AiDecomposeButton plan={plan} version={version} />
)}
</div>
@@ -720,7 +732,7 @@ function ProductUiPlanDetail({
<p className="mt-3 text-[11px] text-[var(--ink-muted)]">{completionState.missingReasons.join('、')}</p>
)}
{transferPlanId === plan.id && (
{transferPlanId === plan.id && !readOnly && (
<div className="mt-3 flex items-center gap-2 border-t border-[var(--line)] pt-3">
<span className="text-[11px] text-[var(--ink-muted)]"></span>
<FilterSelect
@@ -735,7 +747,7 @@ function ProductUiPlanDetail({
</div>
)}
{plan.status !== 'completed' && completionState.canSubmitResult && (
{plan.status !== 'completed' && completionState.canSubmitResult && !readOnly && (
<div className="mt-3 flex items-center justify-between rounded-lg border border-green-200 bg-green-50 px-3 py-2">
<span className="text-[12px] text-green-700">{getSubmitActionLabel(plan)}</span>
<button onClick={() => onOpenComplete(plan)} className="text-[11px] font-medium text-green-700 underline hover:text-green-900">{getSubmitActionLabel(plan)}</button>

View File

@@ -19,9 +19,10 @@ interface Props {
onUnlink: (reqId: string) => void;
onCreateChange: (data: Partial<Requirement>) => void;
currentUserName: string;
readOnly?: boolean;
}
export function VersionRequirementsTab({ versionId, projectId, requirements, devTasks, versionMembers, onLink, onUnlink, onCreateChange, currentUserName }: Props) {
export function VersionRequirementsTab({ versionId, projectId, requirements, devTasks, versionMembers, onLink, onUnlink, onCreateChange, currentUserName, readOnly = false }: Props) {
const [showAddModal, setShowAddModal] = useState(false);
const [showChangeModal, setShowChangeModal] = useState(false);
const linkedReqs = requirements.filter((r) => r.versionId === versionId);
@@ -31,16 +32,18 @@ export function VersionRequirementsTab({ versionId, projectId, requirements, dev
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-[12px] text-[var(--ink-muted)]">{linkedReqs.length} </span>
<div className="flex items-center gap-2">
<button onClick={() => setShowChangeModal(true)} className="flex h-8 items-center gap-1.5 rounded-lg border border-orange-300 px-3 text-[13px] font-medium text-orange-600 hover:bg-orange-50 transition-colors">
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
</button>
<button onClick={() => setShowAddModal(true)} className="flex h-8 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[13px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors">
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
</button>
</div>
{!readOnly && (
<div className="flex items-center gap-2">
<button onClick={() => setShowChangeModal(true)} className="flex h-8 items-center gap-1.5 rounded-lg border border-orange-300 px-3 text-[13px] font-medium text-orange-600 hover:bg-orange-50 transition-colors">
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
</button>
<button onClick={() => setShowAddModal(true)} className="flex h-8 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[13px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors">
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
</button>
</div>
)}
</div>
{linkedReqs.length === 0 ? (
@@ -61,7 +64,7 @@ export function VersionRequirementsTab({ versionId, projectId, requirements, dev
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)] text-right"></th>
{!readOnly && <th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)] text-right"></th>}
</tr>
</thead>
<tbody>
@@ -95,13 +98,15 @@ export function VersionRequirementsTab({ versionId, projectId, requirements, dev
</td>
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">{req.addedToVersionBy || '系统添加'}</td>
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">{req.createdAt?.slice(0, 10) || '-'}</td>
<td className="px-4 py-3 text-right">
{isDeveloping ? (
<span className="text-[11px] text-[var(--ink-muted)]"></span>
) : (
<button onClick={() => onUnlink(req.id)} className="h-6 px-2 rounded text-[11px] font-medium text-red-500 hover:bg-red-50 transition-colors"></button>
)}
</td>
{!readOnly && (
<td className="px-4 py-3 text-right">
{isDeveloping ? (
<span className="text-[11px] text-[var(--ink-muted)]"></span>
) : (
<button onClick={() => onUnlink(req.id)} className="h-6 px-2 rounded text-[11px] font-medium text-red-500 hover:bg-red-50 transition-colors"></button>
)}
</td>
)}
</tr>
);
})}
@@ -110,7 +115,7 @@ export function VersionRequirementsTab({ versionId, projectId, requirements, dev
</div>
)}
{showAddModal && (
{showAddModal && !readOnly && (
<AddRequirementModal
available={availableReqs}
onClose={() => setShowAddModal(false)}
@@ -118,7 +123,7 @@ export function VersionRequirementsTab({ versionId, projectId, requirements, dev
/>
)}
{showChangeModal && (
{showChangeModal && !readOnly && (
<ChangeRequirementModal
versionMembers={versionMembers}
currentUserName={currentUserName}