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

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