feat(版本): 完善计划覆盖与工作台待办
关键改动: - 增加计划日志汇总、需求覆盖草稿校验与对应测试 - 抽取工作台工作项 Hook,补充待办计数能力 - 优化版本详情中计划、任务、测试用例和 Bug 的筛选与展示 Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -7,6 +7,8 @@ dist/
|
||||
*.log
|
||||
.turbo/
|
||||
coverage/
|
||||
.tmp/
|
||||
apps/web/.tmp-test/
|
||||
.DS_Store
|
||||
next-env.d.ts
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useOvertimeStore } from '@/stores/useOvertimeStore';
|
||||
import { getVersionDetail } from '@/lib/derive';
|
||||
import type { Role } from '@/lib/stage';
|
||||
import { CapsuleStages } from '@/components/version/CapsuleStages';
|
||||
import { FilterSelect } from '@/components/FilterSelect';
|
||||
import { calcHealthScore, getHealthLevel, calcRiskTags, HEALTH_LEVEL_LABEL, getTagStyle } from '@/lib/health';
|
||||
import { CHANGE_REASON_LABEL } from '@/lib/requirement';
|
||||
import { OVERTIME_REASON_LABEL } from '@/lib/overtime';
|
||||
@@ -1321,13 +1322,13 @@ function MemberSettingModal({ members, allMembers, onSave, onClose }: {
|
||||
{list.map((member) => (
|
||||
<div key={member.name} className="flex items-center gap-2 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 py-2">
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-[var(--ink)]">{member.name}</span>
|
||||
<select
|
||||
<FilterSelect
|
||||
value={member.role}
|
||||
onChange={(e) => handleRoleChange(member.name, e.target.value as Role)}
|
||||
className="h-8 w-24 shrink-0 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2 text-[12px] text-[var(--ink-soft)] outline-none focus:border-[var(--accent)]"
|
||||
>
|
||||
{roleOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
onChange={(value) => handleRoleChange(member.name, value as Role)}
|
||||
options={roleOptions}
|
||||
showAllOption={false}
|
||||
className="w-24 shrink-0"
|
||||
/>
|
||||
<button type="button" onClick={() => handleRemove(member.name)} className="rounded-md p-1 text-red-400 hover:bg-red-50 hover:text-red-600">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
@@ -14,10 +14,12 @@ interface FilterSelectProps {
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
allLabel?: string;
|
||||
showAllOption?: boolean;
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
}
|
||||
|
||||
export function FilterSelect({ value, onChange, options, placeholder, allLabel = '全部', className }: FilterSelectProps) {
|
||||
export function FilterSelect({ value, onChange, options, placeholder, allLabel = '全部', showAllOption = true, className, labelClassName }: FilterSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -37,9 +39,9 @@ export function FilterSelect({ value, onChange, options, placeholder, allLabel =
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2.5 text-[12px] hover:border-[var(--accent)] transition-colors whitespace-nowrap"
|
||||
className="flex h-8 w-full items-center justify-between gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2.5 text-[12px] hover:border-[var(--accent)] transition-colors whitespace-nowrap"
|
||||
>
|
||||
<span className={`truncate max-w-[80px] ${displayText ? 'text-[var(--ink)]' : 'text-[var(--ink-muted)]'}`}>
|
||||
<span className={`truncate ${labelClassName || 'max-w-[80px]'} ${displayText ? 'text-[var(--ink)]' : 'text-[var(--ink-muted)]'}`}>
|
||||
{displayText || placeholder || allLabel}
|
||||
</span>
|
||||
<ChevronDown className={`shrink-0 h-3 w-3 text-[var(--ink-muted)] transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
@@ -47,13 +49,15 @@ export function FilterSelect({ value, onChange, options, placeholder, allLabel =
|
||||
|
||||
{open && (
|
||||
<div className="absolute top-full left-0 mt-1 z-50 min-w-full max-w-[200px] max-h-[240px] overflow-y-auto rounded-xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-md)] py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onChange('all'); setOpen(false); }}
|
||||
className={`w-full text-left px-3 py-1.5 text-[12px] truncate transition-colors ${value === 'all' || !value ? 'text-[var(--accent)] font-medium bg-[var(--accent-soft)]' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
|
||||
>
|
||||
{allLabel}
|
||||
</button>
|
||||
{showAllOption && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onChange('all'); setOpen(false); }}
|
||||
className={`w-full text-left px-3 py-1.5 text-[12px] truncate transition-colors ${value === 'all' || !value ? 'text-[var(--accent)] font-medium bg-[var(--accent-soft)]' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
|
||||
>
|
||||
{allLabel}
|
||||
</button>
|
||||
)}
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
|
||||
@@ -14,18 +14,18 @@ interface Props {
|
||||
export function SearchInput({ value, onChange, placeholder = '搜索标题或编号', className = '' }: Props) {
|
||||
return (
|
||||
<div className={`relative inline-flex items-center ${className}`}>
|
||||
<Search className="absolute left-2 h-3 w-3 text-[var(--ink-muted)] pointer-events-none" />
|
||||
<Search className="absolute left-2.5 h-3.5 w-3.5 text-[var(--ink-muted)] pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="h-6 w-72 pl-7 pr-6 rounded border border-[var(--line)] bg-[var(--bg-card)] text-[11px] text-[var(--ink)] focus:border-[var(--accent)] focus:outline-none"
|
||||
className="h-8 w-72 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] pl-8 pr-7 text-[12px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none"
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
onClick={() => onChange('')}
|
||||
className="absolute right-1 p-0.5 rounded hover:bg-[var(--bg-subtle)]"
|
||||
className="absolute right-1.5 p-0.5 rounded hover:bg-[var(--bg-subtle)]"
|
||||
title="清除"
|
||||
>
|
||||
<X className="h-3 w-3 text-[var(--ink-muted)]" />
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { resolveMemberDisplayName } from '@/lib/member-system';
|
||||
import { FilterSelect } from '@/components/FilterSelect';
|
||||
import { WorkDateTimePicker } from '@/components/WorkDateTimePicker';
|
||||
import type { Priority } from '@/lib/derive';
|
||||
import type { BugSeverity } from '@/lib/bug';
|
||||
@@ -144,25 +145,42 @@ export function BugCreateModal({ testCaseId, onClose }: Props) {
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">严重程度</label>
|
||||
<select value={severity} onChange={(e) => setSeverity(e.target.value as BugSeverity)} 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="critical">致命</option>
|
||||
<option value="major">严重</option>
|
||||
<option value="minor">一般</option>
|
||||
<option value="trivial">轻微</option>
|
||||
</select>
|
||||
<FilterSelect
|
||||
value={severity}
|
||||
onChange={(value) => setSeverity(value as BugSeverity)}
|
||||
options={[
|
||||
{ value: 'critical', label: '致命' },
|
||||
{ value: 'major', label: '严重' },
|
||||
{ value: 'minor', label: '一般' },
|
||||
{ value: 'trivial', label: '轻微' },
|
||||
]}
|
||||
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={priority} onChange={(e) => setPriority(e.target.value as 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">
|
||||
{(['P0','P1','P2','P3'] as Priority[]).map((p) => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
<FilterSelect
|
||||
value={priority}
|
||||
onChange={(value) => setPriority(value as Priority)}
|
||||
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>
|
||||
<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>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMemo, useState } from 'react';
|
||||
import { X, Link2, ChevronRight, ArrowRightLeft } from 'lucide-react';
|
||||
import { BugStatusBadge } from './BugStatusBadge';
|
||||
import { ActivityLogPanel } from '@/components/ActivityLogPanel';
|
||||
import { FilterSelect } from '@/components/FilterSelect';
|
||||
import { useBugStore } from '@/stores/useBugStore';
|
||||
import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
@@ -166,10 +167,14 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel }: Props) {
|
||||
{showTransfer && (
|
||||
<div className="space-y-2 pt-1 border-t border-[var(--line)]">
|
||||
<div className="text-[11px] text-[var(--ink-muted)]">转交给:</div>
|
||||
<select value={transferTo} onChange={(e) => setTransferTo(e.target.value)} className="h-8 w-full rounded-lg border border-[var(--line)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none">
|
||||
<option value="">选择接收人</option>
|
||||
{members.filter((m) => !isMemberReference(bug.assigneeId, m)).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) => !isMemberReference(bug.assigneeId, m)).map((m) => ({ value: m.name, label: m.name }))}
|
||||
allLabel="选择接收人"
|
||||
className="w-full"
|
||||
labelClassName="max-w-[calc(100%-20px)]"
|
||||
/>
|
||||
<input value={transferRemark} onChange={(e) => setTransferRemark(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" />
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleTransfer} disabled={!transferTo} className="h-8 px-3 rounded-lg text-[11px] font-medium bg-blue-500 text-white disabled:opacity-50">确认转交</button>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useMemberStore } from '@/stores/useMemberStore';
|
||||
import { BugRow } from './BugRow';
|
||||
import { BugDetailDrawer } from './BugDetailDrawer';
|
||||
import { Pagination, usePagination } from '@/components/Pagination';
|
||||
import { FilterSelect } from '@/components/FilterSelect';
|
||||
import { SearchInput, matchTitleOrNo } from '@/components/SearchInput';
|
||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||
import { BUG_STATUS_LABEL, BUG_SEVERITY_LABEL, aggregateBugActualHours, bugIntervals } from '@/lib/bug';
|
||||
@@ -118,19 +119,27 @@ export function BugTab({ versionId, requirementIds }: Props) {
|
||||
<div className="flex items-center gap-2 px-1 flex-wrap">
|
||||
<Filter className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
|
||||
<SearchInput value={keyword} onChange={(v) => { setKeyword(v); setPage(1); }} placeholder="搜索标题/编号" />
|
||||
<select value={filterAssignee} onChange={(e) => { setFilterAssignee(e.target.value); setPage(1); }} className="h-7 rounded-md border border-[var(--line)] bg-[var(--bg-card)] px-2 text-[11px] text-[var(--ink-soft)] focus:border-[var(--accent)] focus:outline-none">
|
||||
<option value="">全部修复人</option>
|
||||
{currentMember && <option value="__me">我的Bug</option>}
|
||||
{assignees.filter((a) => a !== currentUserName).map((a) => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
<select value={filterStatus} onChange={(e) => { setFilterStatus(e.target.value); setPage(1); }} className="h-7 rounded-md border border-[var(--line)] bg-[var(--bg-card)] px-2 text-[11px] text-[var(--ink-soft)] focus:border-[var(--accent)] focus:outline-none">
|
||||
<option value="">全部状态</option>
|
||||
{(Object.entries(BUG_STATUS_LABEL) as [BugStatus, string][]).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
<select value={filterSeverity} onChange={(e) => { setFilterSeverity(e.target.value); setPage(1); }} className="h-7 rounded-md border border-[var(--line)] bg-[var(--bg-card)] px-2 text-[11px] text-[var(--ink-soft)] focus:border-[var(--accent)] focus:outline-none">
|
||||
<option value="">全部严重程度</option>
|
||||
{(Object.entries(BUG_SEVERITY_LABEL) as [BugSeverity, string][]).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
<FilterSelect
|
||||
value={filterAssignee || 'all'}
|
||||
onChange={(value) => { setFilterAssignee(value === 'all' ? '' : value); setPage(1); }}
|
||||
options={[
|
||||
...(currentMember ? [{ value: '__me', label: '我的Bug' }] : []),
|
||||
...assignees.filter((assignee) => assignee !== currentUserName).map((assignee) => ({ value: assignee, label: assignee })),
|
||||
]}
|
||||
allLabel="全部修复人"
|
||||
/>
|
||||
<FilterSelect
|
||||
value={filterStatus || 'all'}
|
||||
onChange={(value) => { setFilterStatus(value === 'all' ? '' : value); setPage(1); }}
|
||||
options={(Object.entries(BUG_STATUS_LABEL) as [BugStatus, string][]).map(([value, label]) => ({ value, label }))}
|
||||
allLabel="全部状态"
|
||||
/>
|
||||
<FilterSelect
|
||||
value={filterSeverity || 'all'}
|
||||
onChange={(value) => { setFilterSeverity(value === 'all' ? '' : value); setPage(1); }}
|
||||
options={(Object.entries(BUG_SEVERITY_LABEL) as [BugSeverity, string][]).map(([value, label]) => ({ value, label }))}
|
||||
allLabel="全部严重程度"
|
||||
/>
|
||||
{hasFilter && <button onClick={() => { setFilterAssignee(''); setFilterStatus(''); setFilterSeverity(''); setKeyword(''); setPage(1); }} className="text-[11px] text-[var(--accent)] hover:underline">清除</button>}
|
||||
<span className="ml-auto text-[11px] text-[var(--ink-muted)]">{filteredBugs.length} 条</span>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Search, Lightbulb, Clock, Shield, Settings, Sparkles, TriangleAlert } from 'lucide-react';
|
||||
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Lightbulb, Clock, Shield, Settings, Sparkles, TriangleAlert } from 'lucide-react';
|
||||
import { useHasPermission } from '@/components/auth/Guard';
|
||||
import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
|
||||
import { useWorkspaceWorkItems } from '@/hooks/useWorkspaceWorkItems';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
import { getWorkspacePendingCount } from '@/lib/workspace-engine';
|
||||
import { getXiaobaoWarningRiskCount } from '@/lib/xiaobao-warning-view';
|
||||
|
||||
type NavItemConfig = {
|
||||
@@ -13,7 +15,7 @@ type NavItemConfig = {
|
||||
path: string;
|
||||
icon: any;
|
||||
permission: string | null;
|
||||
badge?: 'xiaobao-risk';
|
||||
badge?: 'xiaobao-risk' | 'workspace-pending';
|
||||
};
|
||||
|
||||
const NAV_GROUPS = [
|
||||
@@ -21,7 +23,7 @@ const NAV_GROUPS = [
|
||||
label: '工作区',
|
||||
items: [
|
||||
{ label: '小宝预警', path: '/xiaobao-warning', icon: TriangleAlert, permission: 'xiaobao.warning:view', badge: 'xiaobao-risk' as const },
|
||||
{ label: '与我相关', path: '/workspace', icon: Inbox, permission: null as string | null },
|
||||
{ label: '与我相关', path: '/workspace', icon: Inbox, permission: null as string | null, badge: 'workspace-pending' as const },
|
||||
{ label: '产品', path: '/products', icon: Package, permission: 'product:view' },
|
||||
{ label: '项目', path: '/projects', icon: FolderKanban, permission: 'project:view' },
|
||||
{ label: '版本', path: '/versions', icon: Tag, permission: 'version:view' },
|
||||
@@ -42,36 +44,40 @@ const NAV_GROUPS = [
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { workItems } = useWorkspaceWorkItems();
|
||||
const workspacePendingCount = getWorkspacePendingCount(workItems);
|
||||
|
||||
const isActive = (path: string) =>
|
||||
pathname === path || (path !== '/' && pathname.startsWith(path));
|
||||
|
||||
return (
|
||||
<aside className="flex h-screen w-60 flex-col border-r border-[var(--line)] bg-[var(--bg-card)]">
|
||||
<div className="flex h-14 items-center gap-2 px-4">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-[var(--accent)]">
|
||||
<LayoutGrid className="h-4 w-4 text-white" strokeWidth={2.25} />
|
||||
</div>
|
||||
<span className="text-[13px] font-semibold tracking-tight text-[var(--ink)]">FTB</span>
|
||||
</div>
|
||||
|
||||
<div className="px-3 pb-2">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--ink-muted)]" strokeWidth={2} />
|
||||
<input
|
||||
placeholder="搜索"
|
||||
className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] pl-8 pr-2 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:bg-[var(--bg-card)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]"
|
||||
/>
|
||||
<aside className="flex h-screen w-64 flex-col border-r border-[var(--line)] bg-[var(--bg-card)]">
|
||||
<div className="border-b border-[var(--line)] px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-zinc-950 shadow-sm">
|
||||
<LayoutGrid className="h-4 w-4 text-white" strokeWidth={2.25} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-[14px] font-semibold text-[var(--ink)]">FTB</p>
|
||||
<p className="truncate text-[11px] text-[var(--ink-muted)]">项目管理</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 px-2 pt-2">
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-3">
|
||||
{NAV_GROUPS.map((group) => (
|
||||
<NavGroup key={group.label} label={group.label} items={group.items} isActive={isActive} onNavigate={(p) => router.push(p)} />
|
||||
<NavGroup
|
||||
key={group.label}
|
||||
label={group.label}
|
||||
items={group.items}
|
||||
isActive={isActive}
|
||||
onNavigate={(p) => router.push(p)}
|
||||
workspacePendingCount={workspacePendingCount}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-[var(--line)] p-2">
|
||||
<div className="border-t border-[var(--line)] p-3">
|
||||
<UserBlock />
|
||||
</div>
|
||||
</aside>
|
||||
@@ -85,16 +91,16 @@ function UserBlock() {
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[var(--bg-subtle)] text-[11px] font-semibold text-[var(--ink-muted)]">?</div>
|
||||
<div className="flex w-full items-center gap-2.5 rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] px-2.5 py-2 text-left">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--bg-card)] text-[11px] font-semibold text-[var(--ink-muted)]">?</div>
|
||||
<p className="truncate text-[13px] text-[var(--ink-muted)]">未登录</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[var(--accent)] text-[11px] font-semibold text-white">
|
||||
<div className="flex w-full items-center gap-2.5 rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] px-2.5 py-2">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--accent)] text-[11px] font-semibold text-white">
|
||||
{user.name?.[0] ?? '?'}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -103,7 +109,7 @@ function UserBlock() {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push('/profile')}
|
||||
className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)] hover:text-[var(--accent)]"
|
||||
className="rounded-md p-1 text-[var(--ink-muted)] hover:bg-[var(--bg-card)] hover:text-[var(--accent)]"
|
||||
title="个人中心"
|
||||
>
|
||||
<Settings className="h-4 w-4" strokeWidth={1.75} />
|
||||
@@ -112,28 +118,38 @@ function UserBlock() {
|
||||
);
|
||||
}
|
||||
|
||||
function NavGroup({ label, items, isActive, onNavigate }: {
|
||||
function NavGroup({ label, items, isActive, onNavigate, workspacePendingCount }: {
|
||||
label: string;
|
||||
items: NavItemConfig[];
|
||||
isActive: (p: string) => boolean;
|
||||
onNavigate: (p: string) => void;
|
||||
workspacePendingCount: number;
|
||||
}) {
|
||||
const visibleItems = items.filter((item) => item.permission === null || /* eslint-disable-next-line react-hooks/rules-of-hooks */ true);
|
||||
if (visibleItems.length === 0) return null;
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<p className="mb-1 px-2 text-[11px] font-medium text-[var(--ink-muted)]">{label}</p>
|
||||
{items.map((item) => (
|
||||
<NavItem key={item.path} item={item} active={isActive(item.path)} onNavigate={onNavigate} />
|
||||
))}
|
||||
<div className="mb-5">
|
||||
<p className="mb-2 px-2 text-[11px] font-semibold text-[var(--ink-muted)]">{label}</p>
|
||||
<div className="space-y-1">
|
||||
{items.map((item) => (
|
||||
<NavItem
|
||||
key={item.path}
|
||||
item={item}
|
||||
active={isActive(item.path)}
|
||||
onNavigate={onNavigate}
|
||||
workspacePendingCount={workspacePendingCount}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavItem({ item, active, onNavigate }: {
|
||||
function NavItem({ item, active, onNavigate, workspacePendingCount }: {
|
||||
item: NavItemConfig;
|
||||
active: boolean;
|
||||
onNavigate: (p: string) => void;
|
||||
workspacePendingCount: number;
|
||||
}) {
|
||||
const hasPerm = useHasPermission(item.permission ?? '');
|
||||
if (item.permission && !hasPerm) return null;
|
||||
@@ -141,15 +157,23 @@ function NavItem({ item, active, onNavigate }: {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onNavigate(item.path)}
|
||||
className={`flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-[13px] transition-colors ${
|
||||
className={`group relative flex h-9 w-full items-center gap-2.5 rounded-md px-2.5 text-left text-[13px] transition-colors ${
|
||||
active
|
||||
? 'bg-[var(--accent-soft)] font-medium text-[var(--accent-hover)]'
|
||||
? 'bg-[var(--bg)] font-medium text-[var(--ink)] shadow-sm ring-1 ring-[var(--line)]'
|
||||
: 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" strokeWidth={1.75} />
|
||||
{active && <span className="absolute left-0 top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-full bg-[var(--accent)]" />}
|
||||
<span className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-md ${
|
||||
active
|
||||
? 'bg-[var(--accent)] text-white'
|
||||
: 'text-[var(--ink-muted)] group-hover:bg-[var(--bg-card)] group-hover:text-[var(--ink)]'
|
||||
}`}>
|
||||
<Icon className="h-3.5 w-3.5" strokeWidth={1.9} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
||||
{item.badge === 'xiaobao-risk' && <XiaobaoRiskNavBadge />}
|
||||
{item.badge === 'workspace-pending' && <NavCountBadge count={workspacePendingCount} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -159,6 +183,12 @@ function XiaobaoRiskNavBadge() {
|
||||
const count = getXiaobaoWarningRiskCount(risks);
|
||||
if (count <= 0) return null;
|
||||
|
||||
return <NavCountBadge count={count} />;
|
||||
}
|
||||
|
||||
function NavCountBadge({ count }: { count: number }) {
|
||||
if (count <= 0) return null;
|
||||
|
||||
return (
|
||||
<span className="ml-auto inline-flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-red-600 px-1.5 text-[10px] font-semibold leading-none text-white">
|
||||
{count > 99 ? '99+' : count}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { FilterSelect } from '@/components/FilterSelect';
|
||||
import { WorkDateTimePicker } from '@/components/WorkDateTimePicker';
|
||||
import type { Priority } from '@/lib/derive';
|
||||
import { getCategoriesByGroup, getDefaultCategoryByGroup } from '@/lib/task-category';
|
||||
@@ -123,25 +124,43 @@ export function TestCaseCreateModal({ versionId, requirementIds, roundNo, onClos
|
||||
</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) 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">
|
||||
<option value="">不关联需求</option>
|
||||
{versionReqs.map((r) => <option key={r.id} value={r.id}>{r.code} {r.title}</option>)}
|
||||
</select>
|
||||
<FilterSelect
|
||||
value={requirementId || 'all'}
|
||||
onChange={(value) => {
|
||||
const nextValue = value === 'all' ? '' : value;
|
||||
setRequirementId(nextValue);
|
||||
const r = versionReqs.find((x) => x.id === nextValue);
|
||||
if (r) setPriority(r.priority);
|
||||
}}
|
||||
options={versionReqs.map((r) => ({ value: r.id, label: `${r.code} ${r.title}` }))}
|
||||
allLabel="不关联需求"
|
||||
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={priority} onChange={(e) => setPriority(e.target.value as 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">
|
||||
{(['P0','P1','P2','P3'] as Priority[]).map((p) => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
<FilterSelect
|
||||
value={priority}
|
||||
onChange={(value) => setPriority(value as Priority)}
|
||||
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>
|
||||
<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>
|
||||
<FilterSelect
|
||||
value={categoryId}
|
||||
onChange={handleCategoryChange}
|
||||
options={testCategories.map((category) => ({ value: category.id, label: category.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>
|
||||
@@ -158,10 +177,14 @@ export function TestCaseCreateModal({ versionId, requirementIds, roundNo, onClos
|
||||
</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 || 'all'}
|
||||
onChange={(value) => setAssigneeId(value === 'all' ? '' : value)}
|
||||
options={members.map((m) => ({ value: m.name, label: m.name }))}
|
||||
allLabel="选择负责人"
|
||||
className="w-full"
|
||||
labelClassName="max-w-[calc(100%-20px)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { FilterSelect } from '@/components/FilterSelect';
|
||||
import { WorkDateTimePicker } from '@/components/WorkDateTimePicker';
|
||||
import { CategoryChip } from '@/components/dev-task/CategoryChip';
|
||||
import { TC_ALLOWED_TRANSITIONS, TEST_CASE_STATUS_LABEL, canStartTestCase, getTestCaseActualHours, needsTestCaseClaim } from '@/lib/test-case';
|
||||
@@ -148,10 +149,14 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
||||
{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 !== tc.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 !== tc.assigneeId).map((m) => ({ value: m.name, label: m.name }))}
|
||||
allLabel="选择人员"
|
||||
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={() => setShowTransfer(false)} className="h-7 px-2 text-[11px] text-[var(--ink-muted)]">取消</button>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { BugCreateModal } from '@/components/bug/BugCreateModal';
|
||||
import { aggregateTestCaseHours, calcTestProgress, canStartNextTestRound, copyTestCaseToRound, getNextTestRoundNo, getRequirementDeliveryStatus, getTestCaseRoundNo, TEST_CASE_STATUS_LABEL, testCaseIntervals } from '@/lib/test-case';
|
||||
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';
|
||||
@@ -122,7 +123,10 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
||||
const handleBatchDelete = () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
const hasBug = Array.from(selectedIds).some((id) => bugs.some((b) => b.testCaseId === id));
|
||||
if (hasBug) { alert('选中的用例中有关联 Bug 的用例,无法删除。请先取消关联 Bug 的用例选择。'); return; }
|
||||
if (hasBug) {
|
||||
alert('选中的用例中有关联 Bug 的用例,无法删除。请先取消关联 Bug 的用例选择。');
|
||||
return;
|
||||
}
|
||||
if (!confirm(`确定删除选中的 ${selectedIds.size} 个测试用例?`)) return;
|
||||
selectedIds.forEach((id) => deleteTestCase(id));
|
||||
setSelectedIds(new Set());
|
||||
@@ -174,28 +178,37 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
||||
<div className="h-full rounded-full bg-emerald-500" style={{ width: `${stats.completionRate}%` }} />
|
||||
</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">无Bug通过率{stats.passRate}%</span>
|
||||
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">无 Bug 通过率 {stats.passRate}%</span>
|
||||
{effortMetrics.map((metric) => (
|
||||
<span key={metric.label} className="text-[11px] text-[var(--ink-muted)] shrink-0">
|
||||
{metric.label} <span className="tabular-nums text-[var(--ink)]">{metric.value}</span>
|
||||
</span>
|
||||
))}
|
||||
<select value={activeRound} onChange={(e) => handleRoundChange(Number(e.target.value))} 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">
|
||||
{(rounds.length > 0 ? rounds : [1]).map((roundNo) => <option key={roundNo} value={roundNo}>测试轮{roundNo}次</option>)}
|
||||
</select>
|
||||
<FilterSelect
|
||||
value={String(activeRound)}
|
||||
onChange={(value) => handleRoundChange(Number(value))}
|
||||
options={(rounds.length > 0 ? rounds : [1]).map((roundNo) => ({ value: String(roundNo), label: `第${roundNo}轮` }))}
|
||||
showAllOption={false}
|
||||
/>
|
||||
|
||||
<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(TEST_CASE_STATUS_LABEL) as [TestCaseStatus, string][]).map(([k, v]) => <option key={k} value={k}>{v}</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(TEST_CASE_STATUS_LABEL) as [TestCaseStatus, string][]).map(([value, label]) => ({ value, label }))}
|
||||
allLabel="状态"
|
||||
/>
|
||||
{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">
|
||||
@@ -207,7 +220,7 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
||||
<button
|
||||
onClick={handleStartNewRound}
|
||||
disabled={!canCreateNextRound}
|
||||
title={canCreateNextRound ? `复制第1轮用例,开启第${nextRoundNo}轮测试` : '最新一轮测试用例全部测完后才能开启新一轮'}
|
||||
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" />开启新一轮测试
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { FilterSelect } from '@/components/FilterSelect';
|
||||
import {
|
||||
calcPlanProgress,
|
||||
getRequirementCoverageSummary,
|
||||
@@ -289,10 +290,12 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
{showTransfer && (
|
||||
<div className="rounded-lg border border-[var(--line)] p-3 space-y-2">
|
||||
<div className="text-[11px] font-medium text-[var(--ink-muted)]">转交给</div>
|
||||
<select value={transferTo} onChange={(e) => setTransferTo(e.target.value)} className="h-8 w-full 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 !== plan.owner).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 !== plan.owner).map((m) => ({ value: m.name, label: m.name }))}
|
||||
allLabel="选择人员"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleTransfer} disabled={!transferTo} className="h-7 px-3 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>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Check, Clock3, Sparkles } from 'lucide-react';
|
||||
import { FilterSelect } from '@/components/FilterSelect';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
import type { Requirement } from '@/lib/requirement';
|
||||
import type { RequirementCoverageStatus, VersionPlan, VersionPlanLog } from '@/lib/version-plan';
|
||||
import type { RequirementCoverageStatus, VersionPlan, VersionPlanLog, VersionPlanLogView } from '@/lib/version-plan';
|
||||
import {
|
||||
canSaveRequirementCoverageDraft,
|
||||
getRequirementCoverage,
|
||||
getRequirementCoverageStatus,
|
||||
getRequirementCoverageSummary,
|
||||
@@ -24,35 +25,62 @@ interface CoverageProps {
|
||||
}
|
||||
|
||||
interface LogTimelineProps {
|
||||
logs?: VersionPlanLog[];
|
||||
logs?: Array<VersionPlanLog | VersionPlanLogView>;
|
||||
className?: string;
|
||||
fillHeight?: boolean;
|
||||
}
|
||||
|
||||
const COVERAGE_STATUS_OPTIONS: RequirementCoverageStatus[] = ['partial', 'completed', 'not_started'];
|
||||
|
||||
const COVERAGE_BADGE_STYLE: Record<RequirementCoverageStatus, string> = {
|
||||
not_started: 'border-zinc-200 bg-zinc-50 text-zinc-500',
|
||||
partial: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||
completed: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||
};
|
||||
|
||||
function getLogIcon(log: VersionPlanLog) {
|
||||
if (log.type === 'ai_decompose') return <Sparkles className="h-3.5 w-3.5" />;
|
||||
if (log.type === 'requirement_progress') return <Check className="h-3.5 w-3.5" />;
|
||||
return <Clock3 className="h-3.5 w-3.5" />;
|
||||
function getLogTone(log: VersionPlanLog): { badge: string } {
|
||||
if (log.aiStatus === 'error') {
|
||||
return {
|
||||
badge: 'border-red-200 bg-red-50 text-red-700',
|
||||
};
|
||||
}
|
||||
if (log.type === 'ai_decompose') {
|
||||
return {
|
||||
badge: 'border-violet-200 bg-violet-50 text-violet-700',
|
||||
};
|
||||
}
|
||||
if (log.coverageStatus === 'completed') {
|
||||
return {
|
||||
badge: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||
};
|
||||
}
|
||||
if (log.coverageStatus === 'partial') {
|
||||
return {
|
||||
badge: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||
};
|
||||
}
|
||||
return {
|
||||
badge: 'border-zinc-200 bg-zinc-50 text-zinc-600',
|
||||
};
|
||||
}
|
||||
|
||||
function getLogTone(log: VersionPlanLog): string {
|
||||
if (log.aiStatus === 'error') return 'bg-red-50 text-red-700 ring-red-100';
|
||||
if (log.type === 'ai_decompose') return 'bg-purple-50 text-purple-700 ring-purple-100';
|
||||
if (log.coverageStatus === 'completed') return 'bg-emerald-50 text-emerald-700 ring-emerald-100';
|
||||
if (log.coverageStatus === 'partial') return 'bg-amber-50 text-amber-700 ring-amber-100';
|
||||
return 'bg-zinc-50 text-zinc-600 ring-zinc-100';
|
||||
function getLogTypeLabel(log: VersionPlanLog): string {
|
||||
if (log.type === 'ai_decompose') return 'AI 拆解';
|
||||
if (log.type === 'requirement_progress') return '需求进度';
|
||||
return '系统记录';
|
||||
}
|
||||
|
||||
function getLogMonthKey(createdAt: string): string | null {
|
||||
const date = new Date(createdAt);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function getLogMonthLabel(monthKey: string): string {
|
||||
const [year, month] = monthKey.split('-');
|
||||
return `${year}年${month}月`;
|
||||
}
|
||||
|
||||
export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, currentUserName, onUpdate }: CoverageProps) {
|
||||
const [editingRequirementId, setEditingRequirementId] = useState<string | null>(null);
|
||||
const [draftStatus, setDraftStatus] = useState<RequirementCoverageStatus>('partial');
|
||||
const [completedContent, setCompletedContent] = useState('');
|
||||
const [remainingContent, setRemainingContent] = useState('');
|
||||
const summary = getRequirementCoverageSummary(plan);
|
||||
@@ -62,7 +90,6 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr
|
||||
const openEditor = (req: RequirementOption) => {
|
||||
const coverage = getRequirementCoverage(plan, req.id);
|
||||
setEditingRequirementId(req.id);
|
||||
setDraftStatus(coverage?.status === 'completed' ? 'completed' : coverage?.status === 'not_started' ? 'not_started' : 'partial');
|
||||
setCompletedContent(coverage?.completedContent ?? '');
|
||||
setRemainingContent(coverage?.remainingContent ?? '');
|
||||
};
|
||||
@@ -71,20 +98,17 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr
|
||||
setEditingRequirementId(null);
|
||||
setCompletedContent('');
|
||||
setRemainingContent('');
|
||||
setDraftStatus('partial');
|
||||
};
|
||||
|
||||
const canSave = draftStatus === 'not_started'
|
||||
|| (draftStatus === 'completed' && completedContent.trim().length > 0)
|
||||
|| (draftStatus === 'partial' && completedContent.trim().length > 0 && remainingContent.trim().length > 0);
|
||||
const canSavePartial = canSaveRequirementCoverageDraft('partial', completedContent, remainingContent);
|
||||
|
||||
const saveCoverage = (req: RequirementOption) => {
|
||||
if (!canEdit || !canSave) return;
|
||||
const saveCoverage = (req: RequirementOption, status: Extract<RequirementCoverageStatus, 'partial' | 'completed'>) => {
|
||||
if (!canEdit || !canSaveRequirementCoverageDraft(status, completedContent, remainingContent)) return;
|
||||
const patch = updateRequirementCoverage(plan, {
|
||||
requirementId: req.id,
|
||||
status: draftStatus,
|
||||
completedContent: draftStatus === 'not_started' ? undefined : completedContent,
|
||||
remainingContent: draftStatus === 'partial' ? remainingContent : undefined,
|
||||
status,
|
||||
completedContent: status === 'partial' ? completedContent : undefined,
|
||||
remainingContent: status === 'partial' ? remainingContent : undefined,
|
||||
updatedBy: currentUserName,
|
||||
requirementCode: req.code,
|
||||
requirementTitle: req.title,
|
||||
@@ -110,7 +134,7 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr
|
||||
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${summary.percent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-56 space-y-1 overflow-y-auto rounded-lg bg-[var(--bg-subtle)] p-2 pr-1">
|
||||
<div className="space-y-1 rounded-lg bg-[var(--bg-subtle)] p-2 pr-1">
|
||||
{requirements.map((req) => {
|
||||
const status = getRequirementCoverageStatus(plan, req.id);
|
||||
const coverage = getRequirementCoverage(plan, req.id);
|
||||
@@ -146,46 +170,45 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr
|
||||
</div>
|
||||
{isEditing && (
|
||||
<div className="mt-2 space-y-2 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-2">
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{COVERAGE_STATUS_OPTIONS.map((statusOption) => (
|
||||
<button
|
||||
key={statusOption}
|
||||
type="button"
|
||||
onClick={() => setDraftStatus(statusOption)}
|
||||
className={`h-7 rounded-md border text-[11px] font-medium transition-colors ${draftStatus === statusOption ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
|
||||
>
|
||||
{REQUIREMENT_COVERAGE_LABEL[statusOption]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{draftStatus !== 'not_started' && (
|
||||
<textarea
|
||||
value={completedContent}
|
||||
onChange={(e) => setCompletedContent(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="本次已完成的内容"
|
||||
className="w-full resize-none rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2 py-1.5 text-[12px] focus:border-[var(--accent)] focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
{draftStatus === 'partial' && (
|
||||
<textarea
|
||||
value={remainingContent}
|
||||
onChange={(e) => setRemainingContent(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="剩余未完成的内容"
|
||||
className="w-full resize-none rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2 py-1.5 text-[12px] focus:border-[var(--accent)] focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={closeEditor} className="h-7 px-2 text-[11px] text-[var(--ink-muted)]">取消</button>
|
||||
<textarea
|
||||
value={completedContent}
|
||||
onChange={(e) => setCompletedContent(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="本次已完成的内容"
|
||||
className="w-full resize-none rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2 py-1.5 text-[12px] focus:border-[var(--accent)] focus:outline-none"
|
||||
/>
|
||||
<textarea
|
||||
value={remainingContent}
|
||||
onChange={(e) => setRemainingContent(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="剩余未完成的内容"
|
||||
className="w-full resize-none rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2 py-1.5 text-[12px] focus:border-[var(--accent)] focus:outline-none"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => saveCoverage(req)}
|
||||
disabled={!canSave}
|
||||
className="h-7 rounded-md bg-[var(--accent)] px-3 text-[11px] font-medium text-white hover:bg-[var(--accent-hover)] disabled:opacity-50"
|
||||
onClick={() => saveCoverage(req, 'completed')}
|
||||
className="h-7 rounded-md bg-emerald-600 px-3 text-[11px] font-medium text-white hover:bg-emerald-700"
|
||||
>
|
||||
保存记录
|
||||
完全完成
|
||||
</button>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeEditor}
|
||||
className="h-7 rounded-md border border-[var(--line)] px-2 text-[11px] font-medium text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)]"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => saveCoverage(req, 'partial')}
|
||||
disabled={!canSavePartial}
|
||||
className="h-7 rounded-md bg-[var(--accent)] px-3 text-[11px] font-medium text-white hover:bg-[var(--accent-hover)] disabled:opacity-50"
|
||||
>
|
||||
保存记录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -197,35 +220,73 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr
|
||||
);
|
||||
}
|
||||
|
||||
export function PlanLogTimeline({ logs, className = '' }: LogTimelineProps) {
|
||||
export function PlanLogTimeline({ logs, className = '', fillHeight = false }: LogTimelineProps) {
|
||||
const [selectedMonth, setSelectedMonth] = useState('all');
|
||||
const sortedLogs = [...(logs ?? [])].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
const monthOptions = Array.from(new Set(sortedLogs.map((log) => getLogMonthKey(log.createdAt)).filter(Boolean) as string[]))
|
||||
.sort((a, b) => b.localeCompare(a));
|
||||
const effectiveMonth = selectedMonth === 'all' || monthOptions.includes(selectedMonth) ? selectedMonth : 'all';
|
||||
const visibleLogs = effectiveMonth === 'all'
|
||||
? sortedLogs
|
||||
: sortedLogs.filter((log) => getLogMonthKey(log.createdAt) === effectiveMonth);
|
||||
const frameClass = className || 'border-l border-[var(--line)] pl-4';
|
||||
const listClass = fillHeight
|
||||
? 'mt-3 min-h-0 flex-1 overflow-y-auto pr-1'
|
||||
: 'mt-3 max-h-80 overflow-y-auto pr-1';
|
||||
|
||||
return (
|
||||
<aside className={frameClass}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[11px] font-semibold text-[var(--ink-muted)]">日志</div>
|
||||
<span className="text-[11px] tabular-nums text-[var(--ink-soft)]">{sortedLogs.length}</span>
|
||||
<div className="shrink-0 border-b border-[var(--line)] pb-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[13px] font-semibold text-[var(--ink)]">日志</div>
|
||||
<div className="mt-0.5 text-[11px] text-[var(--ink-muted)]">计划行为与 AI 操作记录</div>
|
||||
</div>
|
||||
<FilterSelect
|
||||
value={effectiveMonth}
|
||||
onChange={setSelectedMonth}
|
||||
options={monthOptions.map((month) => ({ value: month, label: getLogMonthLabel(month) }))}
|
||||
allLabel="全部月份"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{sortedLogs.length === 0 ? (
|
||||
<div className="mt-4 rounded-lg bg-[var(--bg-subtle)] px-3 py-4 text-center text-[11px] text-[var(--ink-muted)]">暂无日志</div>
|
||||
{visibleLogs.length === 0 ? (
|
||||
<div className="mt-4 rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg-subtle)] px-3 py-8 text-center text-[12px] text-[var(--ink-muted)]">
|
||||
暂无日志
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 max-h-80 space-y-3 overflow-y-auto pr-1">
|
||||
{sortedLogs.map((log) => (
|
||||
<div key={log.id} className="relative pl-5">
|
||||
<span className={`absolute left-0 top-0 flex h-6 w-6 -translate-x-3 items-center justify-center rounded-full ring-4 ${getLogTone(log)}`}>
|
||||
{getLogIcon(log)}
|
||||
</span>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 text-[12px] font-medium leading-5 text-[var(--ink)]">{log.title}</div>
|
||||
<span className="shrink-0 text-[10px] text-[var(--ink-muted)]">{formatDateTime(log.createdAt)}</span>
|
||||
<div className={listClass}>
|
||||
<div>
|
||||
{visibleLogs.map((log) => {
|
||||
const tone = getLogTone(log);
|
||||
return (
|
||||
<div key={log.id} className="border-b border-[var(--line)] py-3 first:pt-0 last:border-b-0 last:pb-0">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span className={`inline-flex rounded-md border px-1.5 py-0.5 text-[10px] font-semibold ${tone.badge}`}>
|
||||
{getLogTypeLabel(log)}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-[11px] text-[var(--ink-muted)]">{log.actor}</span>
|
||||
</div>
|
||||
<span className="shrink-0 text-[10px] tabular-nums text-[var(--ink-muted)]">{formatDateTime(log.createdAt)}</span>
|
||||
</div>
|
||||
<div className="mt-1.5 text-[12px] font-semibold leading-5 text-[var(--ink)]">{log.title}</div>
|
||||
{'planTitle' in log && log.planTitle && (
|
||||
<div className="mt-1 truncate text-[11px] text-[var(--ink-soft)]" title={log.planTitle}>
|
||||
{log.planTitle}
|
||||
</div>
|
||||
)}
|
||||
{log.detail && (
|
||||
<div className="mt-2 border-l-2 border-[var(--line)] bg-[var(--bg-subtle)] px-2.5 py-2 text-[11px] leading-5 text-[var(--ink-soft)]">
|
||||
<div className="whitespace-pre-wrap">{log.detail}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-[var(--ink-muted)]">{log.actor}</div>
|
||||
{log.detail && <div className="whitespace-pre-wrap rounded-md bg-[var(--bg-subtle)] px-2 py-1.5 text-[11px] leading-4 text-[var(--ink-soft)]">{log.detail}</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Plus, Pencil, Trash2, X, Check, ExternalLink, FileUp, Link2, Play, ArrowRightLeft } from 'lucide-react';
|
||||
import type { ProductPlanKind, ProductPlanReviewFailureType, ProductPlanReviewResult, VersionPlan, PlanTask } from '@/lib/version-plan';
|
||||
import {
|
||||
@@ -9,12 +9,15 @@ import {
|
||||
calcTotalDuration,
|
||||
calcPlanProgress,
|
||||
sortPlansNewestFirst,
|
||||
getPlanLogsForPlans,
|
||||
getRequirementCoverageSummary,
|
||||
PRODUCT_PLAN_KIND_LABEL,
|
||||
PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS,
|
||||
PRODUCT_PLAN_REVIEW_RESULT_LABEL,
|
||||
} from '@/lib/version-plan';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
import { FieldError } from '@/components/FieldError';
|
||||
import { FilterSelect } from '@/components/FilterSelect';
|
||||
import { WorkDateTimePicker } from '@/components/WorkDateTimePicker';
|
||||
import { AiDecomposeButton } from './AiDecomposeButton';
|
||||
import { PlanLogTimeline, PlanRequirementCoveragePanel } from './PlanRequirementCoveragePanel';
|
||||
@@ -77,24 +80,26 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
||||
const totalDuration = calcTotalDuration(typePlans);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[12px] text-[var(--ink-muted)]">{typePlans.length} 个{TYPE_LABEL[planType]}计划</span>
|
||||
<span className="text-[12px] text-[var(--ink-soft)]">已耗时:<span className="font-medium text-[var(--ink)]">{totalDuration}</span></span>
|
||||
{versionDeadline && <span className="text-[12px] text-[var(--ink-muted)]">版本截止:<span className="font-medium text-red-500">{versionDeadline}</span></span>}
|
||||
<div className={planType === 'research' ? 'space-y-4' : '-m-5 h-[calc(100vh-98px)] min-h-[520px]'}>
|
||||
{planType === 'research' && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[12px] text-[var(--ink-muted)]">{typePlans.length} 个{TYPE_LABEL[planType]}计划</span>
|
||||
<span className="text-[12px] text-[var(--ink-soft)]">已耗时:<span className="font-medium text-[var(--ink)]">{totalDuration}</span></span>
|
||||
{versionDeadline && <span className="text-[12px] text-[var(--ink-muted)]">版本截止:<span className="font-medium text-red-500">{versionDeadline}</span></span>}
|
||||
</div>
|
||||
<button onClick={() => setShowCreateModal(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>
|
||||
<button onClick={() => setShowCreateModal(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>
|
||||
)}
|
||||
|
||||
{typePlans.length === 0 ? (
|
||||
{typePlans.length === 0 && planType === 'research' ? (
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-12 text-center text-[13px] text-[var(--ink-muted)]">
|
||||
暂无{TYPE_LABEL[planType]}计划
|
||||
</div>
|
||||
) : (
|
||||
) : planType === 'research' ? (
|
||||
<div className="space-y-3">
|
||||
{typePlans.map((plan) => {
|
||||
const now = new Date().toISOString();
|
||||
@@ -180,9 +185,6 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
||||
<a href={plan.resultUrl} target="_blank" rel="noopener noreferrer" className="text-[12px] text-[var(--accent)] hover:underline flex items-center gap-1">
|
||||
{plan.resultTitle || plan.resultFileName || '查看成果'}<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
{planType === 'product' && version && (
|
||||
<AiDecomposeButton plan={plan} version={version} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{plan.status === 'completed' && plan.type === 'product' && getProductPlanKind(plan) === 'review' && plan.reviewResult && (
|
||||
@@ -268,10 +270,13 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
||||
{transferPlanId === plan.id && (
|
||||
<div className="mt-3 pt-3 border-t border-[var(--line)] flex items-center gap-2">
|
||||
<span className="text-[11px] text-[var(--ink-muted)]">转交给:</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>
|
||||
{versionMembers.filter((m) => m.name !== plan.owner).map((m) => <option key={`${m.role}-${m.name}`} value={m.name}>{m.name}</option>)}
|
||||
</select>
|
||||
<FilterSelect
|
||||
value={transferTo || 'all'}
|
||||
onChange={(value) => setTransferTo(value === 'all' ? '' : value)}
|
||||
options={versionMembers.filter((m) => m.name !== plan.owner).map((m) => ({ value: m.name, label: m.name }))}
|
||||
allLabel="选择参与人员"
|
||||
className="flex-1"
|
||||
/>
|
||||
<button onClick={() => { if (transferTo) { onUpdate(plan.id, { owner: transferTo }); setTransferPlanId(null); setTransferTo(''); } }} disabled={!transferTo} className="h-7 px-2.5 rounded-lg text-[11px] font-medium bg-blue-500 text-white disabled:opacity-50">确认</button>
|
||||
<button onClick={() => { setTransferPlanId(null); setTransferTo(''); }} className="h-7 px-2 text-[11px] text-[var(--ink-muted)]">取消</button>
|
||||
</div>
|
||||
@@ -294,6 +299,27 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<ProductUiPlanWorkspace
|
||||
typePlans={typePlans}
|
||||
planType={planType}
|
||||
totalDuration={totalDuration}
|
||||
versionDeadline={versionDeadline}
|
||||
version={version}
|
||||
currentUserName={currentUserName}
|
||||
versionMembers={versionMembers}
|
||||
linkedRequirements={linkedRequirements}
|
||||
allRequirements={allRequirements}
|
||||
transferPlanId={transferPlanId}
|
||||
transferTo={transferTo}
|
||||
onSetTransferPlanId={setTransferPlanId}
|
||||
onTransferToChange={setTransferTo}
|
||||
onUpdate={onUpdate}
|
||||
onDelete={onDelete}
|
||||
onEditPlan={setEditingPlan}
|
||||
onOpenComplete={setCompletingPlan}
|
||||
onCreatePlan={() => setShowCreateModal(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(showCreateModal || editingPlan) && (
|
||||
@@ -333,6 +359,376 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
||||
);
|
||||
}
|
||||
|
||||
function getPlanRuntime(plan: VersionPlan): {
|
||||
autoStarted: boolean;
|
||||
effectiveStatus: VersionPlan['status'];
|
||||
effectiveStartAt: string | null;
|
||||
} {
|
||||
const autoStarted = plan.status === 'pending' && Boolean(plan.startTime) && new Date(plan.startTime) <= new Date();
|
||||
return {
|
||||
autoStarted,
|
||||
effectiveStatus: autoStarted ? 'in_progress' : plan.status,
|
||||
effectiveStartAt: plan.actualStartAt || (autoStarted ? plan.startTime : null),
|
||||
};
|
||||
}
|
||||
|
||||
function getPlanDurationText(plan: VersionPlan, effectiveStatus: VersionPlan['status'], effectiveStartAt: string | null, now: string): string {
|
||||
const dur = plan.status === 'completed' && plan.completedAt && plan.actualStartAt
|
||||
? calcPlanDuration(plan.actualStartAt, plan.completedAt)
|
||||
: effectiveStatus === 'in_progress' && effectiveStartAt
|
||||
? calcPlanDuration(effectiveStartAt, now)
|
||||
: { days: 0, hours: 0 };
|
||||
return dur.days > 0 || dur.hours > 0 ? formatDuration(dur.days, dur.hours) : '-';
|
||||
}
|
||||
|
||||
function ProductUiPlanWorkspace({
|
||||
typePlans,
|
||||
planType,
|
||||
totalDuration,
|
||||
versionDeadline,
|
||||
version,
|
||||
currentUserName,
|
||||
versionMembers,
|
||||
linkedRequirements,
|
||||
allRequirements,
|
||||
transferPlanId,
|
||||
transferTo,
|
||||
onSetTransferPlanId,
|
||||
onTransferToChange,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onEditPlan,
|
||||
onOpenComplete,
|
||||
onCreatePlan,
|
||||
}: {
|
||||
typePlans: VersionPlan[];
|
||||
planType: 'product' | 'ui';
|
||||
totalDuration: string;
|
||||
versionDeadline?: string;
|
||||
version?: VersionWithContext;
|
||||
currentUserName: string;
|
||||
versionMembers: { role: string; name: string }[];
|
||||
linkedRequirements?: Requirement[];
|
||||
allRequirements?: Requirement[];
|
||||
transferPlanId: string | null;
|
||||
transferTo: string;
|
||||
onSetTransferPlanId: (id: string | null) => void;
|
||||
onTransferToChange: (name: string) => void;
|
||||
onUpdate: (id: string, data: Partial<VersionPlan>) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onEditPlan: (plan: VersionPlan) => void;
|
||||
onOpenComplete: (plan: VersionPlan) => void;
|
||||
onCreatePlan: () => void;
|
||||
}) {
|
||||
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(typePlans[0]?.id ?? null);
|
||||
const selectedPlan = typePlans.find((plan) => plan.id === selectedPlanId) ?? typePlans[0];
|
||||
const selectedPlanIdOrFirst = selectedPlan?.id;
|
||||
const allLogs = useMemo(() => getPlanLogsForPlans(typePlans), [typePlans]);
|
||||
|
||||
useEffect(() => {
|
||||
typePlans.forEach((plan) => {
|
||||
const { autoStarted } = getPlanRuntime(plan);
|
||||
if (autoStarted && !plan.actualStartAt) {
|
||||
onUpdate(plan.id, { status: 'in_progress' });
|
||||
}
|
||||
});
|
||||
}, [typePlans, onUpdate]);
|
||||
|
||||
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>
|
||||
</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'}`}>
|
||||
<div className="rounded-lg bg-[var(--bg-subtle)] px-3 py-2">
|
||||
<div className="text-[10px] text-[var(--ink-muted)]">已耗时</div>
|
||||
<div className="mt-0.5 truncate text-[13px] font-semibold text-[var(--ink)]">{totalDuration}</div>
|
||||
</div>
|
||||
{versionDeadline && (
|
||||
<div className="rounded-lg bg-red-50 px-3 py-2">
|
||||
<div className="text-[10px] text-red-500">版本截止</div>
|
||||
<div className="mt-0.5 truncate text-[13px] font-semibold text-red-600">{versionDeadline}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
|
||||
{typePlans.map((plan) => {
|
||||
const { effectiveStatus } = getPlanRuntime(plan);
|
||||
const summary = getRequirementCoverageSummary(plan);
|
||||
const isSelected = plan.id === selectedPlanIdOrFirst;
|
||||
return (
|
||||
<button
|
||||
key={plan.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedPlanId(plan.id)}
|
||||
className={`w-full rounded-lg px-3 py-2 text-left transition-colors ${isSelected ? 'bg-[var(--accent-soft)] text-[var(--accent)]' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
|
||||
>
|
||||
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||
<span className="min-w-0 truncate text-[12px] font-semibold" title={plan.title}>{plan.title}</span>
|
||||
<span className={`shrink-0 rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${STATUS_STYLE[effectiveStatus]}`}>
|
||||
{STATUS_LABEL[effectiveStatus]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between gap-2 text-[11px]">
|
||||
<span className="truncate">{plan.owner}</span>
|
||||
{plan.type === 'product' ? (
|
||||
<span className="shrink-0">{PRODUCT_PLAN_KIND_LABEL[getProductPlanKind(plan)]}</span>
|
||||
) : (
|
||||
<span className="shrink-0">{TYPE_LABEL[plan.type]}</span>
|
||||
)}
|
||||
</div>
|
||||
{plan.type === 'product' && plan.reviewResult && (
|
||||
<div className={`mt-2 inline-flex rounded-md px-1.5 py-0.5 text-[10px] font-medium ${plan.reviewResult === 'passed' ? 'bg-emerald-50 text-emerald-700' : 'bg-red-50 text-red-700'}`}>
|
||||
{PRODUCT_PLAN_REVIEW_RESULT_LABEL[plan.reviewResult]}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--bg-subtle)]">
|
||||
<div className="h-full rounded-full bg-[var(--accent)]" style={{ width: `${summary.percent}%` }} />
|
||||
</div>
|
||||
<span className="shrink-0 text-[10px] tabular-nums text-[var(--ink-muted)]">
|
||||
{summary.total > 0 ? `${summary.completed}/${summary.total}` : '0/0'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{typePlans.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg-subtle)] px-3 py-8 text-center text-[12px] text-[var(--ink-muted)]">
|
||||
暂无{TYPE_LABEL[planType]}计划
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="flex h-full min-w-0 flex-1 flex-col bg-[var(--bg)]">
|
||||
{selectedPlan ? (
|
||||
<ProductUiPlanDetail
|
||||
plan={selectedPlan}
|
||||
planType={planType}
|
||||
version={version}
|
||||
currentUserName={currentUserName}
|
||||
versionMembers={versionMembers}
|
||||
linkedRequirements={linkedRequirements}
|
||||
allRequirements={allRequirements}
|
||||
transferPlanId={transferPlanId}
|
||||
transferTo={transferTo}
|
||||
onSetTransferPlanId={onSetTransferPlanId}
|
||||
onTransferToChange={onTransferToChange}
|
||||
onUpdate={onUpdate}
|
||||
onDelete={onDelete}
|
||||
onEditPlan={onEditPlan}
|
||||
onOpenComplete={onOpenComplete}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-[13px] text-[var(--ink-muted)]">
|
||||
暂无{TYPE_LABEL[planType]}计划
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<PlanLogTimeline
|
||||
logs={allLogs}
|
||||
fillHeight
|
||||
className="flex h-full w-80 shrink-0 flex-col border-l border-[var(--line)] bg-[var(--bg-card)] p-4"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductUiPlanDetail({
|
||||
plan,
|
||||
planType,
|
||||
version,
|
||||
currentUserName,
|
||||
versionMembers,
|
||||
linkedRequirements,
|
||||
allRequirements,
|
||||
transferPlanId,
|
||||
transferTo,
|
||||
onSetTransferPlanId,
|
||||
onTransferToChange,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onEditPlan,
|
||||
onOpenComplete,
|
||||
}: {
|
||||
plan: VersionPlan;
|
||||
planType: 'product' | 'ui';
|
||||
version?: VersionWithContext;
|
||||
currentUserName: string;
|
||||
versionMembers: { role: string; name: string }[];
|
||||
linkedRequirements?: Requirement[];
|
||||
allRequirements?: Requirement[];
|
||||
transferPlanId: string | null;
|
||||
transferTo: string;
|
||||
onSetTransferPlanId: (id: string | null) => void;
|
||||
onTransferToChange: (name: string) => void;
|
||||
onUpdate: (id: string, data: Partial<VersionPlan>) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onEditPlan: (plan: VersionPlan) => void;
|
||||
onOpenComplete: (plan: VersionPlan) => void;
|
||||
}) {
|
||||
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 requirementOptions = mergeSelectedRequirementOptions(linkedRequirements ?? [], allRequirements ?? [], plan.linkedRequirementIds ?? []);
|
||||
const selectedRequirements = (plan.linkedRequirementIds ?? [])
|
||||
.map((rid) => requirementOptions.find((requirement) => requirement.id === rid))
|
||||
.filter(Boolean) as Requirement[];
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-5">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-[var(--line)] pb-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="min-w-0 truncate text-[15px] font-semibold text-[var(--ink)]" title={plan.title}>{plan.title}</h3>
|
||||
<span className={`inline-flex shrink-0 items-center rounded-md border px-2 py-0.5 text-[10px] font-medium ${STATUS_STYLE[effectiveStatus]}`}>
|
||||
{STATUS_LABEL[effectiveStatus]}
|
||||
</span>
|
||||
{plan.type === 'product' && (
|
||||
<span className="inline-flex shrink-0 items-center rounded-md border border-[var(--line)] bg-[var(--bg-subtle)] px-2 py-0.5 text-[10px] font-medium text-[var(--ink-muted)]">
|
||||
{PRODUCT_PLAN_KIND_LABEL[getProductPlanKind(plan)]}
|
||||
</span>
|
||||
)}
|
||||
{plan.type === 'product' && plan.reviewResult && (
|
||||
<span className={`inline-flex shrink-0 items-center rounded-md px-2 py-0.5 text-[10px] font-medium ${plan.reviewResult === 'passed' ? 'bg-emerald-50 text-emerald-700' : 'bg-red-50 text-red-700'}`}>
|
||||
{PRODUCT_PLAN_REVIEW_RESULT_LABEL[plan.reviewResult]}
|
||||
</span>
|
||||
)}
|
||||
</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" />
|
||||
</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">
|
||||
<div className="rounded-lg bg-[var(--bg-subtle)] px-3 py-2">
|
||||
<dt className="text-[11px] text-[var(--ink-muted)]">负责人</dt>
|
||||
<dd className="mt-0.5 font-medium text-[var(--ink)]">{plan.owner}</dd>
|
||||
</div>
|
||||
<div className="rounded-lg bg-[var(--bg-subtle)] px-3 py-2 sm:col-span-2">
|
||||
<dt className="text-[11px] text-[var(--ink-muted)]">计划时间</dt>
|
||||
<dd className="mt-0.5 font-medium text-[var(--ink)]">{formatDateTime(plan.startTime)} → {formatDateTime(plan.endTime)}</dd>
|
||||
</div>
|
||||
<div className="rounded-lg bg-[var(--bg-subtle)] px-3 py-2">
|
||||
<dt className="text-[11px] text-[var(--ink-muted)]">已耗时</dt>
|
||||
<dd className="mt-0.5 font-semibold text-[var(--ink)]">{durText}</dd>
|
||||
</div>
|
||||
{plan.actualStartAt && (
|
||||
<div className="rounded-lg bg-blue-50 px-3 py-2">
|
||||
<dt className="text-[11px] text-blue-500">实际开始</dt>
|
||||
<dd className="mt-0.5 font-medium text-blue-700">{formatDateTime(plan.actualStartAt)}</dd>
|
||||
</div>
|
||||
)}
|
||||
{plan.completedAt && (
|
||||
<div className="rounded-lg bg-green-50 px-3 py-2">
|
||||
<dt className="text-[11px] text-green-600">完成时间</dt>
|
||||
<dd className="mt-0.5 font-medium text-green-700">{formatDateTime(plan.completedAt)}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{plan.overdueReason && (
|
||||
<div className="mt-3 rounded-lg border border-red-100 bg-red-50 px-3 py-2 text-[11px] leading-5 text-red-700">超期原因:{plan.overdueReason}</div>
|
||||
)}
|
||||
{plan.remark && (
|
||||
<div className="mt-3 border-l-2 border-[var(--accent)] pl-3">
|
||||
<div className="text-[11px] font-medium text-[var(--ink-muted)]">备注</div>
|
||||
<div className="mt-1 max-h-24 overflow-y-auto whitespace-pre-wrap pr-1 text-[12px] leading-5 text-[var(--ink-soft)]">{plan.remark}</div>
|
||||
</div>
|
||||
)}
|
||||
{plan.status === 'completed' && plan.resultUrl && (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 rounded-lg border border-[var(--line)] px-3 py-2">
|
||||
{plan.resultType === 'link' ? <Link2 className="h-3.5 w-3.5 text-[var(--accent)]" /> : <FileUp className="h-3.5 w-3.5 text-[var(--accent)]" />}
|
||||
<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 && (
|
||||
<AiDecomposeButton plan={plan} version={version} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{plan.status === 'completed' && plan.type === 'product' && getProductPlanKind(plan) === 'review' && plan.reviewResult && (
|
||||
<div className={`mt-3 rounded-lg border px-3 py-2 ${plan.reviewResult === 'passed' ? 'border-emerald-200 bg-emerald-50' : 'border-red-200 bg-red-50'}`}>
|
||||
<div className={`text-[12px] font-medium ${plan.reviewResult === 'passed' ? 'text-emerald-700' : 'text-red-700'}`}>
|
||||
{PRODUCT_PLAN_REVIEW_RESULT_LABEL[plan.reviewResult]}
|
||||
</div>
|
||||
{plan.reviewResult === 'failed' && (
|
||||
<div className="mt-1 space-y-1 text-[11px] text-red-700">
|
||||
{getFailureLabels(plan.reviewFailureTypes).length > 0 && <div>类型:{getFailureLabels(plan.reviewFailureTypes).join('、')}</div>}
|
||||
{plan.reviewFailureReason && <div>原因:{plan.reviewFailureReason}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedRequirements.length > 0 && (
|
||||
<PlanRequirementCoveragePanel
|
||||
plan={plan}
|
||||
requirements={selectedRequirements}
|
||||
canEdit={canEditCoverage}
|
||||
currentUserName={currentUserName}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
)}
|
||||
{plan.status === 'in_progress' && !completionState.canSubmitResult && (
|
||||
<p className="mt-3 text-[11px] text-[var(--ink-muted)]">还不能提交成果:{completionState.missingReasons.join('、')}</p>
|
||||
)}
|
||||
|
||||
{transferPlanId === plan.id && (
|
||||
<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
|
||||
value={transferTo || 'all'}
|
||||
onChange={(value) => onTransferToChange(value === 'all' ? '' : value)}
|
||||
options={versionMembers.filter((member) => member.name !== plan.owner).map((member) => ({ value: member.name, label: member.name }))}
|
||||
allLabel="选择参与人员"
|
||||
className="flex-1"
|
||||
/>
|
||||
<button onClick={() => { if (transferTo) { onUpdate(plan.id, { owner: transferTo }); onSetTransferPlanId(null); onTransferToChange(''); } }} disabled={!transferTo} className="h-7 rounded-lg bg-blue-500 px-2.5 text-[11px] font-medium text-white disabled:opacity-50">确认</button>
|
||||
<button onClick={() => { onSetTransferPlanId(null); onTransferToChange(''); }} className="h-7 px-2 text-[11px] text-[var(--ink-muted)]">取消</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{plan.status !== 'completed' && completionState.canSubmitResult && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanFormModal({ initial, planType, versionId, versionDeadline, currentUserName, linkedRequirements, allRequirements, onClose, onSubmit }: {
|
||||
initial: VersionPlan | null;
|
||||
planType: 'research' | 'product' | 'ui';
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Plus, X, Search } from 'lucide-react';
|
||||
import { FilterSelect } from '@/components/FilterSelect';
|
||||
import type { Requirement, ChangeReason } from '@/lib/requirement';
|
||||
import type { DevTask } from '@/lib/dev-task';
|
||||
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, CHANGE_REASON_LABEL } from '@/lib/requirement';
|
||||
@@ -161,18 +162,23 @@ function ChangeRequirementModal({ versionMembers, currentUserName, onClose, onSu
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">变更人员 <span className="text-red-500">*</span></label>
|
||||
<select value={changeBy} onChange={(e) => setChangeBy(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none">
|
||||
{versionMembers.map((m) => <option key={`${m.role}-${m.name}`} value={m.name}>{m.name}</option>)}
|
||||
</select>
|
||||
<FilterSelect
|
||||
value={changeBy || 'all'}
|
||||
onChange={(value) => setChangeBy(value === 'all' ? '' : value)}
|
||||
options={versionMembers.map((member) => ({ value: member.name, label: member.name }))}
|
||||
allLabel="选择变更人员"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">变更原因 <span className="text-red-500">*</span></label>
|
||||
<select value={changeReason} onChange={(e) => setChangeReason(e.target.value as ChangeReason)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none">
|
||||
<option value="">请选择变更原因</option>
|
||||
{Object.entries(CHANGE_REASON_LABEL).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
<FilterSelect
|
||||
value={changeReason || 'all'}
|
||||
onChange={(value) => setChangeReason(value === 'all' ? '' : value as ChangeReason)}
|
||||
options={Object.entries(CHANGE_REASON_LABEL).map(([key, label]) => ({ value: key, label }))}
|
||||
allLabel="请选择变更原因"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">需求概述 <span className="text-red-500">*</span></label>
|
||||
|
||||
69
apps/web/hooks/useWorkspaceWorkItems.ts
Normal file
69
apps/web/hooks/useWorkspaceWorkItems.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useProductStore } from '@/stores/useProductStore';
|
||||
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
||||
import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
||||
import { useBugStore } from '@/stores/useBugStore';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { flattenVersions } from '@/lib/derive';
|
||||
import { aggregateWorkItems } from '@/lib/workspace-engine';
|
||||
|
||||
export function useWorkspaceWorkItems({ autoFetch = true }: { autoFetch?: boolean } = {}) {
|
||||
const { overview, fetchOverview } = useProductStore();
|
||||
const { plans, fetchPlans } = useVersionPlanStore();
|
||||
const { requirements, fetchRequirements } = useRequirementStore();
|
||||
const { tasks: devTasks, fetchTasks } = useDevTaskStore();
|
||||
const { testCases, fetchTestCases } = useTestCaseStore();
|
||||
const { bugs, fetchBugs } = useBugStore();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
useEffect(() => { if (autoFetch) fetchOverview(); }, [autoFetch, fetchOverview]);
|
||||
useEffect(() => { if (autoFetch) fetchPlans(); }, [autoFetch, fetchPlans]);
|
||||
useEffect(() => { if (autoFetch) fetchRequirements(); }, [autoFetch, fetchRequirements]);
|
||||
useEffect(() => { if (autoFetch) fetchTasks(); }, [autoFetch, fetchTasks]);
|
||||
useEffect(() => { if (autoFetch) fetchTestCases(); }, [autoFetch, fetchTestCases]);
|
||||
useEffect(() => { if (autoFetch) fetchBugs(); }, [autoFetch, fetchBugs]);
|
||||
|
||||
const userName = user?.name ?? '';
|
||||
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
|
||||
|
||||
const versionMap = useMemo(() => {
|
||||
const map = new Map<string, { id: string; name: string; productName: string; projectName: string }>();
|
||||
allVersions.forEach((version) => {
|
||||
map.set(version.id, {
|
||||
id: version.id,
|
||||
name: version.name,
|
||||
productName: version.productName,
|
||||
projectName: version.projectName,
|
||||
});
|
||||
});
|
||||
return map;
|
||||
}, [allVersions]);
|
||||
|
||||
const requirementVersionMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
requirements.forEach((requirement) => {
|
||||
if (requirement.versionId) map.set(requirement.id, requirement.versionId);
|
||||
});
|
||||
return map;
|
||||
}, [requirements]);
|
||||
|
||||
const workItems = useMemo(
|
||||
() => aggregateWorkItems(userName, plans, devTasks, testCases, bugs, versionMap, requirementVersionMap),
|
||||
[userName, plans, devTasks, testCases, bugs, versionMap, requirementVersionMap],
|
||||
);
|
||||
|
||||
return {
|
||||
userName,
|
||||
allVersions,
|
||||
versionMap,
|
||||
requirementVersionMap,
|
||||
workItems,
|
||||
devTasks,
|
||||
testCases,
|
||||
bugs,
|
||||
};
|
||||
}
|
||||
@@ -36,6 +36,54 @@ test('sortPlansNewestFirst places newly created plans before older plans', () =>
|
||||
assert.deepEqual(plans.map((item) => item.id), ['plan-100', 'manual-old', 'plan-300']);
|
||||
});
|
||||
|
||||
test('collects logs across plans with plan context and newest first', () => {
|
||||
const getPlanLogsForPlans = (versionPlan as any).getPlanLogsForPlans as undefined | ((plans: VersionPlan[]) => Array<{
|
||||
id: string;
|
||||
planId: string;
|
||||
planTitle: string;
|
||||
createdAt: string;
|
||||
}>);
|
||||
assert.equal(typeof getPlanLogsForPlans, 'function');
|
||||
|
||||
const logs = getPlanLogsForPlans!([
|
||||
plan({
|
||||
id: 'plan-a',
|
||||
title: '产品方案第一天',
|
||||
logs: [
|
||||
{
|
||||
id: 'log-old',
|
||||
type: 'system',
|
||||
title: '开始计划',
|
||||
actor: 'PM',
|
||||
createdAt: '2026-06-29T09:00:00.000Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
plan({
|
||||
id: 'plan-b',
|
||||
title: '产品方案第二天',
|
||||
logs: [
|
||||
{
|
||||
id: 'log-new',
|
||||
type: 'requirement_progress',
|
||||
title: '更新需求进度',
|
||||
actor: 'PM',
|
||||
createdAt: '2026-06-29T15:00:00.000Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.deepEqual(logs.map((log) => ({
|
||||
id: log.id,
|
||||
planId: log.planId,
|
||||
planTitle: log.planTitle,
|
||||
})), [
|
||||
{ id: 'log-new', planId: 'plan-b', planTitle: '产品方案第二天' },
|
||||
{ id: 'log-old', planId: 'plan-a', planTitle: '产品方案第一天' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('derives requirement coverage from new records and legacy completed ids', () => {
|
||||
const getRequirementCoverageStatus = (versionPlan as any).getRequirementCoverageStatus as undefined | ((item: VersionPlan, requirementId: string) => string);
|
||||
const getRequirementCoverageSummary = (versionPlan as any).getRequirementCoverageSummary as undefined | ((item: VersionPlan) => {
|
||||
@@ -116,3 +164,17 @@ test('updates requirement coverage, syncs legacy completed ids, and creates a pl
|
||||
assert.equal(next.logs?.[0]?.actor, 'PM');
|
||||
assert.equal(next.logs?.[0]?.requirementCode, 'QY0001');
|
||||
});
|
||||
|
||||
test('validates requirement coverage record drafts by status', () => {
|
||||
const canSaveRequirementCoverageDraft = (versionPlan as any).canSaveRequirementCoverageDraft as undefined | ((
|
||||
status: string,
|
||||
completedContent?: string,
|
||||
remainingContent?: string,
|
||||
) => boolean);
|
||||
assert.equal(typeof canSaveRequirementCoverageDraft, 'function');
|
||||
|
||||
assert.equal(canSaveRequirementCoverageDraft!('completed'), true);
|
||||
assert.equal(canSaveRequirementCoverageDraft!('partial', '完成主流程', ''), false);
|
||||
assert.equal(canSaveRequirementCoverageDraft!('partial', '完成主流程', '补充异常状态'), true);
|
||||
assert.equal(canSaveRequirementCoverageDraft!('not_started'), false);
|
||||
});
|
||||
|
||||
@@ -87,6 +87,11 @@ export type PlanLogDraft = Omit<VersionPlanLog, 'id' | 'createdAt'> & {
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
export type VersionPlanLogView = VersionPlanLog & {
|
||||
planId: string;
|
||||
planTitle: string;
|
||||
};
|
||||
|
||||
export const REQUIREMENT_COVERAGE_LABEL: Record<RequirementCoverageStatus, string> = {
|
||||
not_started: '未开始',
|
||||
partial: '部分完成',
|
||||
@@ -177,6 +182,18 @@ export function getRequirementCoverageSummary(plan: VersionPlan): {
|
||||
};
|
||||
}
|
||||
|
||||
export function canSaveRequirementCoverageDraft(
|
||||
status: RequirementCoverageStatus,
|
||||
completedContent?: string,
|
||||
remainingContent?: string,
|
||||
): boolean {
|
||||
if (status === 'completed') return true;
|
||||
if (status === 'partial') {
|
||||
return Boolean(completedContent?.trim()) && Boolean(remainingContent?.trim());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function appendPlanLog(plan: VersionPlan, draft: PlanLogDraft): VersionPlanLog[] {
|
||||
const createdAt = draft.createdAt ?? new Date().toISOString();
|
||||
const log: VersionPlanLog = {
|
||||
@@ -187,6 +204,21 @@ export function appendPlanLog(plan: VersionPlan, draft: PlanLogDraft): VersionPl
|
||||
return [log, ...(plan.logs ?? [])];
|
||||
}
|
||||
|
||||
function getPlanLogCreatedAtTime(log: VersionPlanLog): number {
|
||||
const time = new Date(log.createdAt).getTime();
|
||||
return Number.isFinite(time) ? time : 0;
|
||||
}
|
||||
|
||||
export function getPlanLogsForPlans(plans: VersionPlan[]): VersionPlanLogView[] {
|
||||
return plans
|
||||
.flatMap((plan) => (plan.logs ?? []).map((log) => ({
|
||||
...log,
|
||||
planId: plan.id,
|
||||
planTitle: plan.title,
|
||||
})))
|
||||
.sort((a, b) => getPlanLogCreatedAtTime(b) - getPlanLogCreatedAtTime(a));
|
||||
}
|
||||
|
||||
export function updateRequirementCoverage(
|
||||
plan: VersionPlan,
|
||||
input: RequirementCoverageUpdateInput,
|
||||
|
||||
13
apps/web/lib/workspace-engine.test.ts
Normal file
13
apps/web/lib/workspace-engine.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { getWorkspacePendingCount } from './workspace-engine';
|
||||
|
||||
test('getWorkspacePendingCount counts unfinished work items only', () => {
|
||||
const count = getWorkspacePendingCount([
|
||||
{ completed: false },
|
||||
{ completed: true },
|
||||
{ completed: false },
|
||||
]);
|
||||
|
||||
assert.equal(count, 2);
|
||||
});
|
||||
@@ -123,6 +123,10 @@ export function aggregateWorkItems(
|
||||
return items;
|
||||
}
|
||||
|
||||
export function getWorkspacePendingCount(items: ReadonlyArray<Pick<WorkItem, 'completed'>>): number {
|
||||
return items.reduce((sum, item) => sum + (item.completed ? 0 : 1), 0);
|
||||
}
|
||||
|
||||
export const WORK_ITEM_TYPE_LABEL: Record<WorkItemType, string> = {
|
||||
plan_research: '调研',
|
||||
plan_product: '产品方案',
|
||||
|
||||
Reference in New Issue
Block a user