feat(版本): 完善计划覆盖与工作台待办

关键改动:

- 增加计划日志汇总、需求覆盖草稿校验与对应测试

- 抽取工作台工作项 Hook,补充待办计数能力

- 优化版本详情中计划、任务、测试用例和 Bug 的筛选与展示

Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
Script Generator
2026-06-30 13:43:18 +08:00
parent a6a4d7d3d9
commit 52a88626d4
23 changed files with 1082 additions and 281 deletions

View File

@@ -7,6 +7,7 @@ import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useMemberStore } from '@/stores/useMemberStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { FilterSelect } from '@/components/FilterSelect';
import { WorkDateTimePicker } from '@/components/WorkDateTimePicker';
import { calcWorkHours, formatWorkHours, isoToLocal, localToISO } from '@/lib/work-hours';
import type { Priority } from '@/lib/derive';
@@ -147,23 +148,44 @@ export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline,
</div>
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1"> *</label>
<select value={requirementId} onChange={(e) => { setRequirementId(e.target.value); const r = versionReqs.find((x) => x.id === e.target.value); if (r && !priorityManuallySet) setPriority(r.priority); }} 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">
{versionReqs.map((r) => <option key={r.id} value={r.id}>{r.code} {r.title}</option>)}
</select>
<FilterSelect
value={requirementId}
onChange={(value) => {
setRequirementId(value);
const r = versionReqs.find((x) => x.id === value);
if (r && !priorityManuallySet) setPriority(r.priority);
}}
options={versionReqs.map((r) => ({ value: r.id, label: `${r.code} ${r.title}` }))}
placeholder="选择所属需求"
showAllOption={false}
className="w-full"
labelClassName="max-w-[calc(100%-20px)]"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1"> *</label>
<select value={categoryId} onChange={(e) => setCategoryId(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">
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<FilterSelect
value={categoryId}
onChange={setCategoryId}
options={categories.map((c) => ({ value: c.id, label: c.name }))}
placeholder="选择任务类型"
showAllOption={false}
className="w-full"
labelClassName="max-w-[calc(100%-20px)]"
/>
</div>
<div>
<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">
<option value=""></option>
{members.map((m) => <option key={m.id} value={m.name}>{m.name}</option>)}
</select>
<FilterSelect
value={assigneeId}
onChange={setAssigneeId}
options={members.map((m) => ({ value: m.name, label: m.name }))}
placeholder="选择负责人"
showAllOption={false}
className="w-full"
labelClassName="max-w-[calc(100%-20px)]"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
@@ -192,9 +214,14 @@ export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline,
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1"></label>
<select value={effectivePriority} onChange={(e) => { setPriority(e.target.value as Priority); setPriorityManuallySet(true); }} 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">
{(['P0','P1','P2','P3'] as Priority[]).map((p) => <option key={p} value={p}>{p}</option>)}
</select>
<FilterSelect
value={effectivePriority}
onChange={(value) => { setPriority(value as Priority); setPriorityManuallySet(true); }}
options={(['P0', 'P1', 'P2', 'P3'] as Priority[]).map((p) => ({ value: p, label: p }))}
showAllOption={false}
className="w-full"
labelClassName="max-w-[calc(100%-20px)]"
/>
</div>
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1"></label>

View File

@@ -11,6 +11,7 @@ import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useMemberStore } from '@/stores/useMemberStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { FilterSelect } from '@/components/FilterSelect';
import { WorkDateTimePicker } from '@/components/WorkDateTimePicker';
import {
ALLOWED_TRANSITIONS,
@@ -194,10 +195,14 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
{showTransfer && (
<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>
<select value={transferTo} onChange={(e) => setTransferTo(e.target.value)} className="h-7 flex-1 rounded-lg border border-[var(--line)] px-2 text-[12px] focus:border-[var(--accent)] focus:outline-none">
<option value=""></option>
{members.filter((m) => m.name !== task.assigneeId).map((m) => <option key={m.id} value={m.name}>{m.name}</option>)}
</select>
<FilterSelect
value={transferTo || 'all'}
onChange={(value) => setTransferTo(value === 'all' ? '' : value)}
options={members.filter((m) => m.name !== task.assigneeId).map((m) => ({ value: m.name, label: m.name }))}
allLabel="选择人员"
className="flex-1"
labelClassName="max-w-[calc(100%-20px)]"
/>
<button onClick={() => { if (transferTo) { updateTask(task.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>
@@ -354,14 +359,14 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
placeholder="阻塞原因"
className="h-8 rounded-lg border border-[var(--line)] bg-[var(--bg)] px-2 text-[12px] focus:border-orange-400 focus:outline-none"
/>
<select
value={progressHelperId}
onChange={(e) => setProgressHelperId(e.target.value)}
className="h-8 rounded-lg border border-[var(--line)] bg-[var(--bg)] px-2 text-[12px] text-[var(--ink)] focus:border-[var(--accent)] focus:outline-none"
>
<option value=""></option>
{members.filter((m) => m.name !== task.assigneeId).map((m) => <option key={m.id} value={m.name}>{m.name}</option>)}
</select>
<FilterSelect
value={progressHelperId || 'all'}
onChange={(value) => setProgressHelperId(value === 'all' ? '' : value)}
options={members.filter((m) => m.name !== task.assigneeId).map((m) => ({ value: m.name, label: m.name }))}
allLabel="协助人"
className="w-full"
labelClassName="max-w-[calc(100%-20px)]"
/>
</div>
<input
value={progressDelayRisk}

View File

@@ -14,6 +14,7 @@ import { DevTaskDetailDrawer } from './DevTaskDetailDrawer';
import { calcGroupProgress, DEV_TASK_STATUS_LABEL, aggregateDevTaskHours, devTaskIntervals } from '@/lib/dev-task';
import { calcTwoMetrics } from '@/lib/work-hours';
import { Pagination, usePagination } from '@/components/Pagination';
import { FilterSelect } from '@/components/FilterSelect';
import { SearchInput, matchTitleOrNo } from '@/components/SearchInput';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { orderByRequirementForGrouping } from '@/lib/requirement-grouping';
@@ -140,24 +141,36 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props
<span className="w-px h-4 bg-[var(--line)] mx-1 shrink-0" />
<SearchInput value={keyword} onChange={(v) => { setKeyword(v); setPage(1); }} placeholder="搜索标题/编号" />
<select value={filterAssignee} onChange={(e) => { setFilterAssignee(e.target.value); setPage(1); }} className="h-6 rounded border border-[var(--line)] bg-[var(--bg-card)] px-1.5 text-[11px] text-[var(--ink-soft)] focus:border-[var(--accent)] focus:outline-none">
<option value=""></option>
{user?.name && <option value={user.name}></option>}
{assignees.filter((a) => a !== user?.name).map((a) => <option key={a} value={a}>{a}</option>)}
</select>
<select value={filterStatus} onChange={(e) => { setFilterStatus(e.target.value); setPage(1); }} className="h-6 rounded border border-[var(--line)] bg-[var(--bg-card)] px-1.5 text-[11px] text-[var(--ink-soft)] focus:border-[var(--accent)] focus:outline-none">
<option value=""></option>
{(Object.entries(DEV_TASK_STATUS_LABEL) as [DevTaskStatus, string][]).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<select value={filterBlocked} onChange={(e) => { setFilterBlocked(e.target.value); setPage(1); }} className="h-6 rounded border border-[var(--line)] bg-[var(--bg-card)] px-1.5 text-[11px] text-[var(--ink-soft)] focus:border-[var(--accent)] focus:outline-none">
<option value=""></option>
<option value="yes"></option>
<option value="no"></option>
</select>
<select value={filterCategory} onChange={(e) => { setFilterCategory(e.target.value); setPage(1); }} className="h-6 rounded border border-[var(--line)] bg-[var(--bg-card)] px-1.5 text-[11px] text-[var(--ink-soft)] focus:border-[var(--accent)] focus:outline-none">
<option value=""></option>
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<FilterSelect
value={filterAssignee || 'all'}
onChange={(value) => { setFilterAssignee(value === 'all' ? '' : value); setPage(1); }}
options={[
...(user?.name ? [{ value: user.name, label: '我的' }] : []),
...assignees.filter((a) => a !== user?.name).map((a) => ({ value: a, label: a })),
]}
allLabel="负责人"
/>
<FilterSelect
value={filterStatus || 'all'}
onChange={(value) => { setFilterStatus(value === 'all' ? '' : value); setPage(1); }}
options={(Object.entries(DEV_TASK_STATUS_LABEL) as [DevTaskStatus, string][]).map(([value, label]) => ({ value, label }))}
allLabel="状态"
/>
<FilterSelect
value={filterBlocked || 'all'}
onChange={(value) => { setFilterBlocked(value === 'all' ? '' : value); setPage(1); }}
options={[
{ value: 'yes', label: '阻塞中' },
{ value: 'no', label: '正常' },
]}
allLabel="阻塞"
/>
<FilterSelect
value={filterCategory || 'all'}
onChange={(value) => { setFilterCategory(value === 'all' ? '' : value); setPage(1); }}
options={categories.map((category) => ({ value: category.id, label: category.name }))}
allLabel="类型"
/>
{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">