feat(工作台): 细化计划进展工时证据

关键改动:

- 支持需求覆盖和调研方向开始工作记录

- 日报按计划记录开始时间和进度下次开始时间计算证据

- 更新版本编辑校验、项目展示和 workflow 说明

Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
Script Generator
2026-07-01 11:02:38 +08:00
parent e5ce2d221e
commit 8947aeaac7
18 changed files with 1125 additions and 94 deletions

View File

@@ -3,7 +3,7 @@
import { RouteGuard } from '@/components/auth/Guard'; import { RouteGuard } from '@/components/auth/Guard';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Search, FolderKanban, Package, ChevronDown, Plus, X, MoreHorizontal, Trash2 } from 'lucide-react'; import { Search, FolderKanban, Package, ChevronDown, Plus, X, MoreHorizontal, Trash2, Pencil } from 'lucide-react';
import { useProductStore } from '@/stores/useProductStore'; import { useProductStore } from '@/stores/useProductStore';
import { useRequirementStore } from '@/stores/useRequirementStore'; import { useRequirementStore } from '@/stores/useRequirementStore';
import { useVersionPlanStore } from '@/stores/useVersionPlanStore'; import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
@@ -25,7 +25,7 @@ export default function ProjectsPage() {
function ProjectsPageContent() { function ProjectsPageContent() {
const router = useRouter(); const router = useRouter();
const { overview, fetchOverview, createProject, deleteProject } = useProductStore(); const { overview, fetchOverview, createProject, updateProject, deleteProject } = useProductStore();
const { requirements, fetchRequirements } = useRequirementStore(); const { requirements, fetchRequirements } = useRequirementStore();
const { plans, fetchPlans } = useVersionPlanStore(); const { plans, fetchPlans } = useVersionPlanStore();
const { tasks: devTasks, fetchTasks: fetchDevTasks } = useDevTaskStore(); const { tasks: devTasks, fetchTasks: fetchDevTasks } = useDevTaskStore();
@@ -33,6 +33,7 @@ function ProjectsPageContent() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [productFilter, setProductFilter] = useState<string>('all'); const [productFilter, setProductFilter] = useState<string>('all');
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [editingProject, setEditingProject] = useState<ProjectWithContext | null>(null);
useEffect(() => { fetchOverview(); }, [fetchOverview]); useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => { fetchRequirements(); }, [fetchRequirements]); useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
@@ -127,6 +128,7 @@ function ProjectsPageContent() {
proj={proj} proj={proj}
versionProgressMap={versionProgressMap} versionProgressMap={versionProgressMap}
onClick={() => router.push(`/projects/${proj.id}`)} onClick={() => router.push(`/projects/${proj.id}`)}
onEdit={() => setEditingProject(proj)}
onDelete={() => { onDelete={() => {
if (!confirm(`确认删除项目「${proj.name}」?此操作不可恢复。`)) return; if (!confirm(`确认删除项目「${proj.name}」?此操作不可恢复。`)) return;
deleteProject(proj.productId, proj.id); deleteProject(proj.productId, proj.id);
@@ -150,6 +152,18 @@ function ProjectsPageContent() {
}} }}
/> />
)} )}
{editingProject && (
<EditProjectModal
project={editingProject}
overview={overview}
onClose={() => setEditingProject(null)}
onSubmit={async (name) => {
updateProject(editingProject.productId, editingProject.id, { name });
setEditingProject(null);
}}
/>
)}
</div> </div>
); );
} }
@@ -160,13 +174,16 @@ function ProjectRow({
proj, proj,
versionProgressMap, versionProgressMap,
onClick, onClick,
onEdit,
onDelete, onDelete,
}: { }: {
proj: ProjectWithContext; proj: ProjectWithContext;
versionProgressMap: Record<string, number>; versionProgressMap: Record<string, number>;
onClick: () => void; onClick: () => void;
onEdit: () => void;
onDelete: () => void; onDelete: () => void;
}) { }) {
const canEdit = useHasPermission('project:edit');
const canDelete = useHasPermission('project:delete'); const canDelete = useHasPermission('project:delete');
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const activeVersions = proj.versions.filter((v) => (versionProgressMap[v.id] ?? 0) < 100); const activeVersions = proj.versions.filter((v) => (versionProgressMap[v.id] ?? 0) < 100);
@@ -200,7 +217,7 @@ function ProjectRow({
</span> </span>
)} )}
</div> </div>
{canDelete && ( {(canEdit || canDelete) && (
<div className="relative shrink-0" onClick={(e) => e.stopPropagation()}> <div className="relative shrink-0" onClick={(e) => e.stopPropagation()}>
<button <button
onClick={() => setMenuOpen(!menuOpen)} onClick={() => setMenuOpen(!menuOpen)}
@@ -213,19 +230,33 @@ function ProjectRow({
<> <>
<div className="fixed inset-0 z-30" onClick={() => setMenuOpen(false)} /> <div className="fixed inset-0 z-30" onClick={() => setMenuOpen(false)} />
<div className="absolute right-0 top-full z-50 mt-1 min-w-[160px] rounded-lg border border-[var(--line)] bg-[var(--bg-card)] py-1 shadow-[var(--shadow-md)]"> <div className="absolute right-0 top-full z-50 mt-1 min-w-[160px] rounded-lg border border-[var(--line)] bg-[var(--bg-card)] py-1 shadow-[var(--shadow-md)]">
<button {canEdit && (
onClick={() => { <button
if (!canActuallyDelete) return; onClick={() => {
setMenuOpen(false); setMenuOpen(false);
onDelete(); onEdit();
}} }}
disabled={!canActuallyDelete} className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] text-[var(--ink-soft)] transition-colors hover:bg-[var(--bg-hover)]"
title={canActuallyDelete ? '' : `项目下还有 ${versionCount} 个版本,不可删除`} >
className={`w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] transition-colors ${canActuallyDelete ? 'text-red-600 hover:bg-red-50' : 'text-[var(--ink-muted)] cursor-not-allowed'}`} <Pencil size={14} />
>
<Trash2 size={14} /> </button>
)}
</button> {canDelete && (
<button
onClick={() => {
if (!canActuallyDelete) return;
setMenuOpen(false);
onDelete();
}}
disabled={!canActuallyDelete}
title={canActuallyDelete ? '' : `项目下还有 ${versionCount} 个版本,不可删除`}
className={`w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] transition-colors ${canActuallyDelete ? 'text-red-600 hover:bg-red-50' : 'text-[var(--ink-muted)] cursor-not-allowed'}`}
>
<Trash2 size={14} />
</button>
)}
</div> </div>
</> </>
)} )}
@@ -335,6 +366,94 @@ function EmptyState() {
); );
} }
/* ─── EditProjectModal ───────────────────────────────────────────── */
function EditProjectModal({
project,
overview,
onClose,
onSubmit,
}: {
project: ProjectWithContext;
overview: any[];
onClose: () => void;
onSubmit: (name: string) => Promise<void>;
}) {
const [name, setName] = useState(project.name);
const [submitting, setSubmitting] = useState(false);
const [duplicateError, setDuplicateError] = useState(false);
const handleSubmit = async () => {
const nextName = name.trim();
if (!nextName) return;
const product = overview.find((p: any) => p.id === project.productId);
const exists = product?.projects?.some(
(proj: any) => proj.id !== project.id && proj.name === nextName,
);
if (exists) {
setDuplicateError(true);
return;
}
setSubmitting(true);
try {
await onSubmit(nextName);
} finally {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-md rounded-2xl bg-[var(--bg-card)] p-6 shadow-[var(--shadow-md)]">
<div className="mb-5 flex items-center justify-between">
<h2 className="text-base font-semibold text-[var(--ink)]"></h2>
<button type="button" onClick={onClose} className="text-[var(--ink-muted)] hover:text-[var(--ink)]">
<X size={18} />
</button>
</div>
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={name}
onChange={(e) => { setName(e.target.value); setDuplicateError(false); }}
className={`h-9 w-full rounded-lg border bg-[var(--bg)] px-3 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:outline-none focus:ring-2 ${
duplicateError
? 'border-red-500 focus:border-red-500 focus:ring-red-100'
: 'border-[var(--line)] focus:border-[var(--accent)] focus:ring-[var(--accent-ring)]'
}`}
/>
{duplicateError && (
<p className="mt-1 text-[11px] text-red-500"></p>
)}
</div>
<div className="mt-6 flex items-center justify-end gap-2">
<button
type="button"
onClick={onClose}
className="h-8 rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-hover)]"
>
</button>
<button
type="button"
onClick={handleSubmit}
disabled={submitting || !name.trim() || name.trim() === project.name}
className="h-8 rounded-lg bg-[var(--accent)] px-3 text-[13px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] disabled:cursor-not-allowed disabled:opacity-50"
>
{submitting ? '保存中…' : '保存'}
</button>
</div>
</div>
</div>
);
}
/* ─── CreateProjectModal ─────────────────────────────────────────── */ /* ─── CreateProjectModal ─────────────────────────────────────────── */
function CreateProjectModal({ function CreateProjectModal({

View File

@@ -2,11 +2,11 @@
import { useEffect, useMemo, useState, type FormEvent } from 'react'; import { useEffect, useMemo, useState, type FormEvent } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { AlertTriangle, Calendar, Check, ChevronLeft, Clock, FileText, Link2, Search, Settings, Sparkles, UserPlus, X } from 'lucide-react'; import { AlertTriangle, Calendar, Check, ChevronLeft, Clock, FileText, Link2, Pencil, Search, Settings, Sparkles, UserPlus, X } from 'lucide-react';
import { useProductStore } from '@/stores/useProductStore'; import { useProductStore } from '@/stores/useProductStore';
import { useRequirementStore } from '@/stores/useRequirementStore'; import { useRequirementStore } from '@/stores/useRequirementStore';
import { useOvertimeStore } from '@/stores/useOvertimeStore'; import { useOvertimeStore } from '@/stores/useOvertimeStore';
import { getVersionDetail } from '@/lib/derive'; import { getVersionDetail, type Priority, type VersionWithContext } from '@/lib/derive';
import type { Role } from '@/lib/stage'; import type { Role } from '@/lib/stage';
import { CapsuleStages } from '@/components/version/CapsuleStages'; import { CapsuleStages } from '@/components/version/CapsuleStages';
import { FilterSelect } from '@/components/FilterSelect'; import { FilterSelect } from '@/components/FilterSelect';
@@ -36,6 +36,16 @@ import { getRequirementCoverageSummary } from '@/lib/version-plan';
import { buildVersionProgressMap } from '@/lib/version-progress'; import { buildVersionProgressMap } from '@/lib/version-progress';
import { canSubmitReleaseForm, getReleaseProgressWarning } from '@/lib/version-release'; import { canSubmitReleaseForm, getReleaseProgressWarning } from '@/lib/version-release';
import { isVersionReadonly } from '@/lib/version-status'; import { isVersionReadonly } from '@/lib/version-status';
import {
VERSION_DEVELOPMENT_TYPE_OPTIONS,
buildVersionName,
canSubmitVersionEditForm,
getEditableVersionNumber,
getRecommendedExpectedReleaseDate,
getRecommendedWorkDays,
getVersionDevelopmentTypeOption,
type VersionDevelopmentType,
} from '@/lib/version-form';
function formatOverviewDateTime(value?: string | null): string { function formatOverviewDateTime(value?: string | null): string {
if (!value) return '-'; if (!value) return '-';
@@ -107,6 +117,7 @@ export default function VersionDetailPage() {
}, [visibleTabs, activeTab]); }, [visibleTabs, activeTab]);
const [showMemberModal, setShowMemberModal] = useState(false); const [showMemberModal, setShowMemberModal] = useState(false);
const [showRecommendModal, setShowRecommendModal] = useState(false); const [showRecommendModal, setShowRecommendModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showReleaseModal, setShowReleaseModal] = useState(false); const [showReleaseModal, setShowReleaseModal] = useState(false);
const [recommendationDataReady, setRecommendationDataReady] = useState(false); const [recommendationDataReady, setRecommendationDataReady] = useState(false);
@@ -209,11 +220,12 @@ export default function VersionDetailPage() {
}); });
const renderActions = () => { const renderActions = () => {
const buttons: { label: string; action: () => void; danger?: boolean; tone?: 'release' }[] = []; const buttons: { label: string; action: () => void; danger?: boolean; tone?: 'release'; icon?: JSX.Element }[] = [];
if (versionReadonly) return null; if (versionReadonly) return null;
if (version.status !== 'released' && version.status !== 'closed') { if (version.status !== 'released' && version.status !== 'closed') {
buttons.push({ label: '发版', action: () => setShowReleaseModal(true), tone: 'release' }); buttons.push({ label: '发版', action: () => setShowReleaseModal(true), tone: 'release' });
} }
buttons.push({ label: '编辑', action: () => setShowEditModal(true), icon: <Pencil className="h-3.5 w-3.5" /> });
if (version.status === 'planned') { if (version.status === 'planned') {
buttons.push({ label: '删除', action: () => { buttons.push({ label: '删除', action: () => {
if (confirm('确认删除该版本关联的需求会回到需求池版本下的计划、开发任务、测试用例、Bug 将被清除。')) { if (confirm('确认删除该版本关联的需求会回到需求池版本下的计划、开发任务、测试用例、Bug 将被清除。')) {
@@ -243,8 +255,9 @@ export default function VersionDetailPage() {
<button <button
key={btn.label} key={btn.label}
onClick={btn.action} onClick={btn.action}
className={`h-7 px-3 rounded-md text-[12px] font-medium border transition-colors ${btn.danger ? 'border-red-200 text-red-600 hover:bg-red-50' : btn.tone === 'release' ? 'border-emerald-200 text-emerald-700 hover:bg-emerald-50' : 'border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`} className={`inline-flex h-7 items-center gap-1.5 rounded-md border px-3 text-[12px] font-medium transition-colors ${btn.danger ? 'border-red-200 text-red-600 hover:bg-red-50' : btn.tone === 'release' ? 'border-emerald-200 text-emerald-700 hover:bg-emerald-50' : 'border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
> >
{btn.icon}
{btn.label} {btn.label}
</button> </button>
)); ));
@@ -950,6 +963,17 @@ export default function VersionDetailPage() {
)} )}
</div> </div>
{showEditModal && !versionReadonly && (
<VersionEditModal
version={version}
onSubmit={(data) => {
updateVersion(version.productId, version.id, data);
setShowEditModal(false);
}}
onClose={() => setShowEditModal(false)}
/>
)}
{showRecommendModal && !versionReadonly && ( {showRecommendModal && !versionReadonly && (
<MemberRecommendationModal <MemberRecommendationModal
groups={memberRecommendationGroups} groups={memberRecommendationGroups}
@@ -992,6 +1016,189 @@ export default function VersionDetailPage() {
); );
} }
const PRIORITY_OPTIONS: Priority[] = ['P0', 'P1', 'P2', 'P3', 'P4'];
function toDateInputValue(value?: string | null): string {
if (!value) return '';
return value.includes('T') ? value.slice(0, 10) : value;
}
function VersionEditModal({ version, onSubmit, onClose }: {
version: VersionWithContext;
onSubmit: (data: { name: string; priority: Priority; expectedReleaseDate: string }) => void;
onClose: () => void;
}) {
const [versionNumber, setVersionNumber] = useState(() => getEditableVersionNumber(version.projectName, version.name));
const [priority, setPriority] = useState<Priority>((version.priority ?? 'P2') as Priority);
const [developmentType, setDevelopmentType] = useState<VersionDevelopmentType | ''>('');
const [productDesignCompleted, setProductDesignCompleted] = useState(false);
const [expectedReleaseDate, setExpectedReleaseDate] = useState(() => toDateInputValue(version.expectedReleaseDate));
const [submitting, setSubmitting] = useState(false);
const selectedDevelopmentType = developmentType ? getVersionDevelopmentTypeOption(developmentType) : undefined;
const recommendedWorkDays = selectedDevelopmentType
? getRecommendedWorkDays(selectedDevelopmentType, { productDesignCompleted })
: 0;
const canSubmit = canSubmitVersionEditForm({ versionNumber, expectedReleaseDate, submitting });
const handleVersionInput = (value: string) => {
setVersionNumber(value.replace(/[^\d.]/g, ''));
};
const applyRecommendedReleaseDate = (type: VersionDevelopmentType, productDesigned: boolean) => {
setExpectedReleaseDate(getRecommendedExpectedReleaseDate(type, new Date(), { productDesignCompleted: productDesigned }));
};
const handleDevelopmentTypeChange = (type: VersionDevelopmentType) => {
setDevelopmentType(type);
applyRecommendedReleaseDate(type, productDesignCompleted);
};
const handleProductDesignCompletedChange = (completed: boolean) => {
setProductDesignCompleted(completed);
if (developmentType) applyRecommendedReleaseDate(developmentType, completed);
};
const handleSubmit = (event: FormEvent) => {
event.preventDefault();
if (!canSubmit) return;
setSubmitting(true);
onSubmit({
name: buildVersionName(version.projectName, versionNumber),
priority,
expectedReleaseDate,
});
};
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 px-4" onClick={onClose}>
<form
onSubmit={handleSubmit}
className="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-md"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-4 border-b border-[var(--line)] px-5 py-4">
<div>
<h3 className="text-[15px] font-semibold text-[var(--ink)]"></h3>
<p className="mt-1 text-[11px] text-[var(--ink-muted)]">
{version.productName} / {version.projectName}
</p>
</div>
<button type="button" onClick={onClose} className="rounded-md p-1.5 text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]">
<X className="h-4 w-4" />
</button>
</div>
<div className="space-y-4 px-5 py-4">
<label className="block">
<span className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]"><span className="text-red-500">*</span></span>
<input
value={versionNumber}
onChange={(event) => handleVersionInput(event.target.value)}
placeholder="1.0"
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] tabular-nums text-[var(--ink)] outline-none focus:border-[var(--accent)]"
/>
{version.projectName && versionNumber && (
<p className="mt-1.5 text-[11px] text-[var(--ink-muted)]"><span className="font-medium text-[var(--ink-soft)]">{buildVersionName(version.projectName, versionNumber)}</span></p>
)}
</label>
<div>
<div className="mb-1.5 text-[12px] font-medium text-[var(--ink-soft)]"></div>
<div className="grid grid-cols-5 gap-2">
{PRIORITY_OPTIONS.map((item) => (
<button
key={item}
type="button"
onClick={() => setPriority(item)}
className={`h-9 rounded-lg border px-3 text-[12px] font-medium transition-colors ${priority === item ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)]'}`}
>
{item}
</button>
))}
</div>
</div>
<div>
<div className="mb-1.5 text-[12px] font-medium text-[var(--ink-soft)]"></div>
<div className="grid grid-cols-2 gap-2">
{VERSION_DEVELOPMENT_TYPE_OPTIONS.map((option) => (
<button
key={option.key}
type="button"
onClick={() => handleDevelopmentTypeChange(option.key)}
className={`rounded-lg border px-3 py-2 text-left transition-colors ${developmentType === option.key ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)]'}`}
>
<span className="block text-[12px] font-medium">{option.label}</span>
<span className="mt-0.5 block text-[10px] text-[var(--ink-muted)]">{option.description}</span>
</button>
))}
</div>
</div>
<div>
<div className="mb-1.5 text-[12px] font-medium text-[var(--ink-soft)]"></div>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => handleProductDesignCompletedChange(false)}
className={`h-9 rounded-lg border px-3 text-[12px] font-medium transition-colors ${!productDesignCompleted ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)]'}`}
>
</button>
<button
type="button"
onClick={() => handleProductDesignCompletedChange(true)}
className={`h-9 rounded-lg border px-3 text-[12px] font-medium transition-colors ${productDesignCompleted ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)]'}`}
>
</button>
</div>
</div>
<label className="block">
<span className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium text-[var(--ink-soft)]">
<Calendar className="h-3.5 w-3.5" />
<span className="text-red-500">*</span>
</span>
<input
type="date"
value={expectedReleaseDate}
onChange={(event) => setExpectedReleaseDate(event.target.value)}
required
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
/>
{selectedDevelopmentType && (
<p className="mt-1.5 text-[11px] leading-5 text-[var(--ink-muted)]">
{recommendedWorkDays}
{selectedDevelopmentType.stages.map((stage, index) => {
const skipped = productDesignCompleted && stage.key === 'product_design';
return (
<span key={stage.key}>
{index > 0 && ' + '}
<span className={skipped ? 'text-[var(--ink-muted)] line-through' : ''}>
{stage.label}{stage.workDays}
</span>
</span>
);
})}
</p>
)}
</label>
</div>
<div className="flex items-center justify-end gap-2 border-t border-[var(--line)] px-5 py-4">
<button type="button" onClick={onClose} className="h-8 rounded-md border border-[var(--line)] px-3 text-[12px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">
</button>
<button type="submit" disabled={!canSubmit} className="h-8 rounded-md bg-[var(--accent)] px-4 text-[12px] font-semibold text-white hover:bg-[var(--accent-hover)] disabled:cursor-not-allowed disabled:opacity-50">
{submitting ? '保存中' : '保存'}
</button>
</div>
</form>
</div>
);
}
function ReleaseVersionModal({ progress, initialDate, onSubmit, onClose }: { function ReleaseVersionModal({ progress, initialDate, onSubmit, onClose }: {
progress: number; progress: number;
initialDate: string; initialDate: string;

View File

@@ -47,6 +47,13 @@ function defaultPlanEndLocal(): string {
return isoToLocal(d.toISOString()); return isoToLocal(d.toISOString());
} }
function defaultProgressNextStartLocal(): string {
const d = new Date();
d.setDate(d.getDate() + 1);
d.setHours(9, 30, 0, 0);
return isoToLocal(d.toISOString());
}
export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel, readOnly = false }: Props) { export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel, readOnly = false }: Props) {
const { tasks, changeStatus, setBlocked, deleteTask, updateTask } = useDevTaskStore(); const { tasks, changeStatus, setBlocked, deleteTask, updateTask } = useDevTaskStore();
const addProgressNote = useWorkActivityStore((s) => s.addProgressNote); const addProgressNote = useWorkActivityStore((s) => s.addProgressNote);
@@ -65,6 +72,7 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel,
const [progressBlocker, setProgressBlocker] = useState(''); const [progressBlocker, setProgressBlocker] = useState('');
const [progressHelperId, setProgressHelperId] = useState(''); const [progressHelperId, setProgressHelperId] = useState('');
const [progressDelayRisk, setProgressDelayRisk] = useState(''); const [progressDelayRisk, setProgressDelayRisk] = useState('');
const [progressNextStartLocal, setProgressNextStartLocal] = useState(() => defaultProgressNextStartLocal());
const task = tasks.find((t) => t.id === taskId); const task = tasks.find((t) => t.id === taskId);
if (!task) return null; if (!task) return null;
@@ -92,6 +100,10 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel,
const planEndISO = localToISO(planEndLocal); const planEndISO = localToISO(planEndLocal);
const planStartBeforeEnd = Boolean(planStartISO && planEndISO && planStartISO < planEndISO); const planStartBeforeEnd = Boolean(planStartISO && planEndISO && planStartISO < planEndISO);
const planEstimateHours = planStartBeforeEnd ? calcWorkHours(planStartISO, planEndISO) : 0; const planEstimateHours = planStartBeforeEnd ? calcWorkHours(planStartISO, planEndISO) : 0;
const progressNextStartISO = localToISO(progressNextStartLocal);
const canRecordProgress = Boolean(
(progressNote.trim() || progressBlocker.trim() || progressDelayRisk.trim()) && progressNextStartISO,
);
const openPlanInput = () => { const openPlanInput = () => {
if (readOnly) return; if (readOnly) return;
@@ -160,6 +172,10 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel,
const blocker = progressBlocker.trim(); const blocker = progressBlocker.trim();
const delayRisk = progressDelayRisk.trim(); const delayRisk = progressDelayRisk.trim();
if (!note && !blocker && !delayRisk) return; if (!note && !blocker && !delayRisk) return;
if (!progressNextStartISO) {
alert('请填写下次开始时间');
return;
}
addProgressNote({ addProgressNote({
actorId: task.assigneeId, actorId: task.assigneeId,
@@ -167,6 +183,7 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel,
sourceId: task.id, sourceId: task.id,
title: task.title, title: task.title,
note: note || '今日进展已更新', note: note || '今日进展已更新',
nextStartAt: progressNextStartISO,
blocker: blocker || undefined, blocker: blocker || undefined,
helperId: progressHelperId || undefined, helperId: progressHelperId || undefined,
delayRisk: delayRisk || undefined, delayRisk: delayRisk || undefined,
@@ -175,6 +192,7 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel,
setProgressBlocker(''); setProgressBlocker('');
setProgressHelperId(''); setProgressHelperId('');
setProgressDelayRisk(''); setProgressDelayRisk('');
setProgressNextStartLocal(defaultProgressNextStartLocal());
}; };
return ( return (
@@ -381,11 +399,25 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel,
placeholder="延期风险" placeholder="延期风险"
className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-2 text-[12px] focus:border-orange-400 focus:outline-none" className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-2 text-[12px] focus:border-orange-400 focus:outline-none"
/> />
<div className="flex justify-end"> <div className="grid grid-cols-[minmax(0,1fr)_auto] items-end gap-2">
<div>
<label className="mb-1 flex items-center gap-1 text-[11px] text-[var(--ink-muted)]">
<span className="text-red-500">*</span>
</label>
<WorkDateTimePicker
value={progressNextStartLocal}
onChange={setProgressNextStartLocal}
placeholder="选择下次开始时间"
defaultHour={9}
popoverAlign="right"
className="bg-[var(--bg)]"
/>
</div>
<button <button
onClick={handleProgressNote} onClick={handleProgressNote}
disabled={!progressNote.trim() && !progressBlocker.trim() && !progressDelayRisk.trim()} disabled={!canRecordProgress}
className="h-8 px-3 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] disabled:opacity-50" className="h-9 px-3 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] disabled:opacity-50"
> >
</button> </button>

View File

@@ -14,6 +14,8 @@ import {
getRequirementCoverageStatus, getRequirementCoverageStatus,
getRequirementCoverageSummary, getRequirementCoverageSummary,
REQUIREMENT_COVERAGE_LABEL, REQUIREMENT_COVERAGE_LABEL,
startResearchDirectionWork,
startRequirementCoverageWork,
updateResearchDirectionProgress, updateResearchDirectionProgress,
updateRequirementCoverage, updateRequirementCoverage,
} from '@/lib/version-plan'; } from '@/lib/version-plan';
@@ -116,10 +118,19 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr
setRemainingContent(''); setRemainingContent('');
}; };
const canSavePartial = canSaveRequirementCoverageDraft('partial', completedContent, remainingContent); const startCoverage = (req: RequirementOption) => {
if (!canEdit) return;
const patch = startRequirementCoverageWork(plan, {
requirementId: req.id,
updatedBy: currentUserName,
});
onUpdate(plan.id, patch);
};
const saveCoverage = (req: RequirementOption, status: Extract<RequirementCoverageStatus, 'partial' | 'completed'>) => { const saveCoverage = (req: RequirementOption, status: Extract<RequirementCoverageStatus, 'partial' | 'completed'>) => {
if (!canEdit || !canSaveRequirementCoverageDraft(status, completedContent, remainingContent)) return; const coverage = getRequirementCoverage(plan, req.id);
const workStartedAt = coverage?.currentWorkStartedAt;
if (!canEdit || !canSaveRequirementCoverageDraft(status, completedContent, remainingContent, workStartedAt)) return;
const patch = updateRequirementCoverage(plan, { const patch = updateRequirementCoverage(plan, {
requirementId: req.id, requirementId: req.id,
status, status,
@@ -155,7 +166,11 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr
const status = getRequirementCoverageStatus(plan, req.id); const status = getRequirementCoverageStatus(plan, req.id);
const coverage = getRequirementCoverage(plan, req.id); const coverage = getRequirementCoverage(plan, req.id);
const isEditing = editingRequirementId === req.id; const isEditing = editingRequirementId === req.id;
const canOpenRecord = canOpenRequirementCoverageRecord(status, canEdit); const hasStartedWork = Boolean(coverage?.currentWorkStartedAt);
const canOpenRecord = canOpenRequirementCoverageRecord(status, canEdit) && hasStartedWork;
const canStartWork = canEdit && status !== 'completed' && !hasStartedWork;
const canSaveCompleted = canSaveRequirementCoverageDraft('completed', undefined, undefined, coverage?.currentWorkStartedAt);
const canSavePartial = canSaveRequirementCoverageDraft('partial', completedContent, remainingContent, coverage?.currentWorkStartedAt);
return ( return (
<div key={req.id} className="rounded-md px-2 py-1.5 hover:bg-[var(--bg-card)]"> <div key={req.id} className="rounded-md px-2 py-1.5 hover:bg-[var(--bg-card)]">
<div className="flex min-w-0 items-start gap-2"> <div className="flex min-w-0 items-start gap-2">
@@ -168,19 +183,31 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr
<span className="min-w-0 truncate text-[12px] font-medium text-[var(--ink)]" title={req.title}>{req.title}</span> <span className="min-w-0 truncate text-[12px] font-medium text-[var(--ink)]" title={req.title}>{req.title}</span>
{req.isHistorical && <span className="shrink-0 rounded bg-orange-50 px-1.5 py-0.5 text-[10px] text-orange-600"></span>} {req.isHistorical && <span className="shrink-0 rounded bg-orange-50 px-1.5 py-0.5 text-[10px] text-orange-600"></span>}
</div> </div>
{(coverage?.completedContent || coverage?.remainingContent) && ( {(coverage?.completedContent || coverage?.remainingContent || coverage?.currentWorkStartedAt) && (
<div className="mt-1 space-y-0.5 text-[11px] leading-4 text-[var(--ink-soft)]"> <div className="mt-1 space-y-0.5 text-[11px] leading-4 text-[var(--ink-soft)]">
{coverage.completedContent && <div className="line-clamp-2">{coverage.completedContent}</div>} {coverage.completedContent && <div className="line-clamp-2">{coverage.completedContent}</div>}
{coverage.remainingContent && <div className="line-clamp-2 text-amber-700">{coverage.remainingContent}</div>} {coverage.remainingContent && <div className="line-clamp-2 text-amber-700">{coverage.remainingContent}</div>}
{coverage.currentWorkStartedAt && <div className="text-blue-600">{formatDateTime(coverage.currentWorkStartedAt)}</div>}
</div> </div>
)} )}
</div> </div>
{canEdit && (canOpenRecord || isEditing) && ( {canEdit && status !== 'completed' && (
<div className="flex shrink-0 items-center gap-1.5"> <div className="flex shrink-0 items-center gap-1.5">
{canStartWork && (
<button
type="button"
onClick={() => startCoverage(req)}
className="rounded-md bg-blue-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-blue-700"
>
</button>
)}
<button <button
type="button" type="button"
onClick={() => isEditing ? closeEditor() : openEditor(req)} onClick={() => canOpenRecord && (isEditing ? closeEditor() : openEditor(req))}
className="rounded-md border border-[var(--line)] px-2 py-1 text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]" disabled={!canOpenRecord}
title={!hasStartedWork ? '请先开始任务' : undefined}
className="rounded-md border border-[var(--line)] px-2 py-1 text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] disabled:cursor-not-allowed disabled:opacity-50"
> >
{isEditing ? '收起' : '记录'} {isEditing ? '收起' : '记录'}
</button> </button>
@@ -207,7 +234,8 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr
<button <button
type="button" type="button"
onClick={() => saveCoverage(req, 'completed')} onClick={() => saveCoverage(req, 'completed')}
className="h-7 rounded-md bg-emerald-600 px-3 text-[11px] font-medium text-white hover:bg-emerald-700" disabled={!canSaveCompleted}
className="h-7 rounded-md bg-emerald-600 px-3 text-[11px] font-medium text-white hover:bg-emerald-700 disabled:opacity-50"
> >
</button> </button>
@@ -260,10 +288,17 @@ export function PlanResearchDirectionPanel({ plan, canEdit, currentUserName, onU
setRemainingContent(''); setRemainingContent('');
}; };
const canSavePartial = canSaveRequirementCoverageDraft('partial', completedContent, remainingContent); const startDirection = (task: PlanTask) => {
if (!canEdit) return;
const patch = startResearchDirectionWork(plan, {
taskId: task.id,
updatedBy: currentUserName,
});
onUpdate(plan.id, patch);
};
const saveDirection = (task: PlanTask, status: Extract<RequirementCoverageStatus, 'partial' | 'completed'>) => { const saveDirection = (task: PlanTask, status: Extract<RequirementCoverageStatus, 'partial' | 'completed'>) => {
if (!canEdit || !canSaveRequirementCoverageDraft(status, completedContent, remainingContent)) return; if (!canEdit || !canSaveRequirementCoverageDraft(status, completedContent, remainingContent, task.currentWorkStartedAt)) return;
const patch = updateResearchDirectionProgress(plan, { const patch = updateResearchDirectionProgress(plan, {
taskId: task.id, taskId: task.id,
status, status,
@@ -296,7 +331,11 @@ export function PlanResearchDirectionPanel({ plan, canEdit, currentUserName, onU
{tasks.map((task) => { {tasks.map((task) => {
const status = getResearchDirectionStatus(task); const status = getResearchDirectionStatus(task);
const isEditing = editingTaskId === task.id; const isEditing = editingTaskId === task.id;
const canOpenRecord = canOpenRequirementCoverageRecord(status, canEdit); const hasStartedWork = Boolean(task.currentWorkStartedAt);
const canOpenRecord = canOpenRequirementCoverageRecord(status, canEdit) && hasStartedWork;
const canStartWork = canEdit && status !== 'completed' && !hasStartedWork;
const canSaveCompleted = canSaveRequirementCoverageDraft('completed', undefined, undefined, task.currentWorkStartedAt);
const canSavePartial = canSaveRequirementCoverageDraft('partial', completedContent, remainingContent, task.currentWorkStartedAt);
return ( return (
<div key={task.id} className="rounded-md px-2 py-1.5 hover:bg-[var(--bg-card)]"> <div key={task.id} className="rounded-md px-2 py-1.5 hover:bg-[var(--bg-card)]">
<div className="flex min-w-0 items-start gap-2"> <div className="flex min-w-0 items-start gap-2">
@@ -305,19 +344,31 @@ export function PlanResearchDirectionPanel({ plan, canEdit, currentUserName, onU
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate text-[12px] font-medium text-[var(--ink)]" title={task.title}>{task.title}</div> <div className="truncate text-[12px] font-medium text-[var(--ink)]" title={task.title}>{task.title}</div>
{(task.completedContent || task.remainingContent) && ( {(task.completedContent || task.remainingContent || task.currentWorkStartedAt) && (
<div className="mt-1 space-y-0.5 text-[11px] leading-4 text-[var(--ink-soft)]"> <div className="mt-1 space-y-0.5 text-[11px] leading-4 text-[var(--ink-soft)]">
{task.completedContent && <div className="line-clamp-2">{task.completedContent}</div>} {task.completedContent && <div className="line-clamp-2">{task.completedContent}</div>}
{task.remainingContent && <div className="line-clamp-2 text-amber-700">{task.remainingContent}</div>} {task.remainingContent && <div className="line-clamp-2 text-amber-700">{task.remainingContent}</div>}
{task.currentWorkStartedAt && <div className="text-blue-600">{formatDateTime(task.currentWorkStartedAt)}</div>}
</div> </div>
)} )}
</div> </div>
{canEdit && (canOpenRecord || isEditing) && ( {canEdit && status !== 'completed' && (
<div className="flex shrink-0 items-center gap-1.5"> <div className="flex shrink-0 items-center gap-1.5">
{canStartWork && (
<button
type="button"
onClick={() => startDirection(task)}
className="rounded-md bg-blue-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-blue-700"
>
</button>
)}
<button <button
type="button" type="button"
onClick={() => isEditing ? closeEditor() : openEditor(task)} onClick={() => canOpenRecord && (isEditing ? closeEditor() : openEditor(task))}
className="rounded-md border border-[var(--line)] px-2 py-1 text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]" disabled={!canOpenRecord}
title={!hasStartedWork ? '请先开始任务' : undefined}
className="rounded-md border border-[var(--line)] px-2 py-1 text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] disabled:cursor-not-allowed disabled:opacity-50"
> >
{isEditing ? '收起' : '记录'} {isEditing ? '收起' : '记录'}
</button> </button>
@@ -344,7 +395,8 @@ export function PlanResearchDirectionPanel({ plan, canEdit, currentUserName, onU
<button <button
type="button" type="button"
onClick={() => saveDirection(task, 'completed')} onClick={() => saveDirection(task, 'completed')}
className="h-7 rounded-md bg-emerald-600 px-3 text-[11px] font-medium text-white hover:bg-emerald-700" disabled={!canSaveCompleted}
className="h-7 rounded-md bg-emerald-600 px-3 text-[11px] font-medium text-white hover:bg-emerald-700 disabled:opacity-50"
> >
</button> </button>
@@ -460,6 +512,11 @@ export function PlanLogTimeline({ logs, className = '', fillHeight = false }: Lo
<div className="whitespace-pre-wrap">{log.detail}</div> <div className="whitespace-pre-wrap">{log.detail}</div>
</div> </div>
)} )}
{log.workStartedAt && (
<div className="mt-2 inline-flex rounded-md bg-blue-50 px-2 py-1 text-[11px] font-medium text-blue-700">
{formatDateTime(log.workStartedAt)}
</div>
)}
</div> </div>
</div> </div>
); );

View File

@@ -2,7 +2,10 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { import {
VERSION_DEVELOPMENT_TYPE_OPTIONS, VERSION_DEVELOPMENT_TYPE_OPTIONS,
buildVersionName,
canSubmitNewVersionForm, canSubmitNewVersionForm,
canSubmitVersionEditForm,
getEditableVersionNumber,
getRecommendedExpectedReleaseDate, getRecommendedExpectedReleaseDate,
getVersionDevelopmentTypeOption, getVersionDevelopmentTypeOption,
} from './version-form'; } from './version-form';
@@ -49,6 +52,32 @@ test('canSubmitNewVersionForm blocks duplicate submits', () => {
}), false); }), false);
}); });
test('canSubmitVersionEditForm requires version number and expected release date', () => {
assert.equal(canSubmitVersionEditForm({
versionNumber: '',
expectedReleaseDate: '2026-07-15',
submitting: false,
}), false);
assert.equal(canSubmitVersionEditForm({
versionNumber: '1.2',
expectedReleaseDate: '',
submitting: false,
}), false);
assert.equal(canSubmitVersionEditForm({
versionNumber: '1.2',
expectedReleaseDate: '2026-07-15',
submitting: false,
}), true);
});
test('version edit helpers keep the project prefix and editable version number separate', () => {
assert.equal(getEditableVersionNumber('小白项目', '小白项目V1.2.3'), '1.2.3');
assert.equal(getEditableVersionNumber('小白项目', '其他项目V2.0'), '2.0');
assert.equal(buildVersionName('小白项目', '1.2.3'), '小白项目V1.2.3');
});
test('version development type estimates include product design development and testing', () => { test('version development type estimates include product design development and testing', () => {
const agile = getVersionDevelopmentTypeOption('agile'); const agile = getVersionDevelopmentTypeOption('agile');

View File

@@ -26,6 +26,12 @@ export interface NewVersionFormState {
submitting: boolean; submitting: boolean;
} }
export interface VersionEditFormState {
versionNumber: string;
expectedReleaseDate: string;
submitting: boolean;
}
export const VERSION_DEVELOPMENT_TYPE_OPTIONS: VersionDevelopmentTypeOption[] = [ export const VERSION_DEVELOPMENT_TYPE_OPTIONS: VersionDevelopmentTypeOption[] = [
makeDevelopmentTypeOption('agile', '敏捷迭代', '适合常规双周迭代', [ makeDevelopmentTypeOption('agile', '敏捷迭代', '适合常规双周迭代', [
{ key: 'product_design', label: '产品设计', workDays: 2 }, { key: 'product_design', label: '产品设计', workDays: 2 },
@@ -60,6 +66,27 @@ export function canSubmitNewVersionForm(state: NewVersionFormState): boolean {
); );
} }
export function canSubmitVersionEditForm(state: VersionEditFormState): boolean {
return Boolean(
state.versionNumber.trim() &&
state.expectedReleaseDate &&
!state.submitting,
);
}
export function getEditableVersionNumber(projectName: string, versionName: string): string {
const exactPrefix = `${projectName}V`;
if (projectName && versionName.toLowerCase().startsWith(exactPrefix.toLowerCase())) {
return versionName.slice(exactPrefix.length);
}
const match = versionName.match(/V([\d.]+)$/i);
return match?.[1] ?? versionName;
}
export function buildVersionName(projectName: string, versionNumber: string): string {
return `${projectName}V${versionNumber.trim()}`;
}
export function getVersionDevelopmentTypeOption(type: VersionDevelopmentType): VersionDevelopmentTypeOption | undefined { export function getVersionDevelopmentTypeOption(type: VersionDevelopmentType): VersionDevelopmentTypeOption | undefined {
return VERSION_DEVELOPMENT_TYPE_OPTIONS.find((option) => option.key === type); return VERSION_DEVELOPMENT_TYPE_OPTIONS.find((option) => option.key === type);
} }

View File

@@ -60,7 +60,7 @@ test('updates research direction progress and creates a direction log', () => {
const next = updateResearchDirectionProgress!(plan({ const next = updateResearchDirectionProgress!(plan({
tasks: [ tasks: [
{ id: 'task-1', title: '竞品分析', status: 'pending' }, { id: 'task-1', title: '竞品分析', status: 'in_progress', currentWorkStartedAt: '2026-06-30T09:30:00.000Z' },
{ id: 'task-2', title: '用户访谈', status: 'pending' }, { id: 'task-2', title: '用户访谈', status: 'pending' },
], ],
}), { }), {
@@ -75,8 +75,35 @@ test('updates research direction progress and creates a direction log', () => {
assert.equal(next.tasks?.[0]?.status, 'in_progress'); assert.equal(next.tasks?.[0]?.status, 'in_progress');
assert.equal(next.tasks?.[0]?.completedContent, '完成竞品登录流程对比'); assert.equal(next.tasks?.[0]?.completedContent, '完成竞品登录流程对比');
assert.equal(next.tasks?.[0]?.remainingContent, '补充支付流程差异'); assert.equal(next.tasks?.[0]?.remainingContent, '补充支付流程差异');
assert.equal(next.tasks?.[0]?.currentWorkStartedAt, undefined);
assert.equal(next.logs?.[0]?.type, 'research_direction_progress'); assert.equal(next.logs?.[0]?.type, 'research_direction_progress');
assert.equal(next.logs?.[0]?.directionTaskId, 'task-1'); assert.equal(next.logs?.[0]?.directionTaskId, 'task-1');
assert.equal(next.logs?.[0]?.directionTitle, '竞品分析'); assert.equal(next.logs?.[0]?.directionTitle, '竞品分析');
assert.equal(next.logs?.[0]?.coverageStatus, 'partial'); assert.equal(next.logs?.[0]?.coverageStatus, 'partial');
assert.equal(next.logs?.[0]?.workStartedAt, '2026-06-30T09:30:00.000Z');
});
test('starts research direction work and marks the direction in progress', () => {
const startResearchDirectionWork = (versionPlan as any).startResearchDirectionWork as undefined | ((item: VersionPlan, input: {
taskId: string;
startedAt: string;
updatedBy: string;
}) => any);
assert.equal(typeof startResearchDirectionWork, 'function');
const next = startResearchDirectionWork!(plan({
tasks: [
{ id: 'task-1', title: '竞品分析', status: 'pending' },
{ id: 'task-2', title: '用户访谈', status: 'pending' },
],
}), {
taskId: 'task-1',
startedAt: '2026-06-30T09:30:00.000Z',
updatedBy: 'PM',
});
assert.equal(next.tasks?.[0]?.status, 'in_progress');
assert.equal(next.tasks?.[0]?.currentWorkStartedAt, '2026-06-30T09:30:00.000Z');
assert.equal(next.tasks?.[0]?.updatedBy, 'PM');
assert.equal(next.tasks?.[1]?.status, 'pending');
}); });

View File

@@ -146,6 +146,15 @@ test('updates requirement coverage, syncs legacy completed ids, and creates a pl
const next = updateRequirementCoverage!(plan({ const next = updateRequirementCoverage!(plan({
linkedRequirementIds: ['r1'], linkedRequirementIds: ['r1'],
completedRequirementIds: ['r1'], completedRequirementIds: ['r1'],
requirementCoverage: [
{
requirementId: 'r1',
status: 'completed',
currentWorkStartedAt: '2026-06-29T09:30:00.000Z',
updatedAt: '2026-06-28T12:00:00.000Z',
updatedBy: 'PM',
},
],
}), { }), {
requirementId: 'r1', requirementId: 'r1',
status: 'partial', status: 'partial',
@@ -159,26 +168,63 @@ test('updates requirement coverage, syncs legacy completed ids, and creates a pl
assert.deepEqual(next.completedRequirementIds, []); assert.deepEqual(next.completedRequirementIds, []);
assert.equal(next.requirementCoverage?.[0]?.status, 'partial'); assert.equal(next.requirementCoverage?.[0]?.status, 'partial');
assert.equal(next.requirementCoverage?.[0]?.currentWorkStartedAt, undefined);
assert.equal(next.logs?.length, 1); assert.equal(next.logs?.length, 1);
assert.equal(next.logs?.[0]?.type, 'requirement_progress'); assert.equal(next.logs?.[0]?.type, 'requirement_progress');
assert.equal(next.logs?.[0]?.actor, 'PM'); assert.equal(next.logs?.[0]?.actor, 'PM');
assert.equal(next.logs?.[0]?.requirementCode, 'QY0001'); assert.equal(next.logs?.[0]?.requirementCode, 'QY0001');
assert.equal(next.logs?.[0]?.completedContent, '完成移动端主流程'); assert.equal(next.logs?.[0]?.completedContent, '完成移动端主流程');
assert.equal(next.logs?.[0]?.remainingContent, 'PC 端筛选规则未完成'); assert.equal(next.logs?.[0]?.remainingContent, 'PC 端筛选规则未完成');
assert.equal(next.logs?.[0]?.workStartedAt, '2026-06-29T09:30:00.000Z');
}); });
test('validates requirement coverage record drafts by status', () => { test('starts requirement coverage work while preserving existing progress', () => {
const startRequirementCoverageWork = (versionPlan as any).startRequirementCoverageWork as undefined | ((item: VersionPlan, input: {
requirementId: string;
startedAt: string;
updatedBy: string;
}) => any);
assert.equal(typeof startRequirementCoverageWork, 'function');
const next = startRequirementCoverageWork!(plan({
requirementCoverage: [
{
requirementId: 'r1',
status: 'partial',
completedContent: '完成主流程',
remainingContent: '补充异常状态',
updatedAt: '2026-06-28T12:00:00.000Z',
updatedBy: 'PM',
},
],
}), {
requirementId: 'r1',
startedAt: '2026-06-29T09:30:00.000Z',
updatedBy: 'PM',
});
assert.equal(next.requirementCoverage?.[0]?.status, 'partial');
assert.equal(next.requirementCoverage?.[0]?.completedContent, '完成主流程');
assert.equal(next.requirementCoverage?.[0]?.remainingContent, '补充异常状态');
assert.equal(next.requirementCoverage?.[0]?.currentWorkStartedAt, '2026-06-29T09:30:00.000Z');
assert.equal(next.requirementCoverage?.[0]?.updatedBy, 'PM');
});
test('validates requirement coverage record drafts by started work session', () => {
const canSaveRequirementCoverageDraft = (versionPlan as any).canSaveRequirementCoverageDraft as undefined | (( const canSaveRequirementCoverageDraft = (versionPlan as any).canSaveRequirementCoverageDraft as undefined | ((
status: string, status: string,
completedContent?: string, completedContent?: string,
remainingContent?: string, remainingContent?: string,
workStartedAt?: string,
) => boolean); ) => boolean);
assert.equal(typeof canSaveRequirementCoverageDraft, 'function'); assert.equal(typeof canSaveRequirementCoverageDraft, 'function');
assert.equal(canSaveRequirementCoverageDraft!('completed'), true); assert.equal(canSaveRequirementCoverageDraft!('completed'), false);
assert.equal(canSaveRequirementCoverageDraft!('partial', '完成主流程', ''), false); assert.equal(canSaveRequirementCoverageDraft!('completed', undefined, undefined, '2026-06-29T09:30:00.000Z'), true);
assert.equal(canSaveRequirementCoverageDraft!('partial', '完成主流程', '补充异常状态'), true); assert.equal(canSaveRequirementCoverageDraft!('partial', '完成主流程', '', '2026-06-29T09:30:00.000Z'), false);
assert.equal(canSaveRequirementCoverageDraft!('not_started'), false); assert.equal(canSaveRequirementCoverageDraft!('partial', '完成主流程', '补充异常状态'), false);
assert.equal(canSaveRequirementCoverageDraft!('partial', '完成主流程', '补充异常状态', '2026-06-29T09:30:00.000Z'), true);
assert.equal(canSaveRequirementCoverageDraft!('not_started', undefined, undefined, '2026-06-29T09:30:00.000Z'), false);
}); });
test('hides coverage record action after a requirement is completed', () => { test('hides coverage record action after a requirement is completed', () => {

View File

@@ -47,6 +47,7 @@ export interface PlanTask {
status: PlanTaskStatus; status: PlanTaskStatus;
completedContent?: string; completedContent?: string;
remainingContent?: string; remainingContent?: string;
currentWorkStartedAt?: string;
updatedAt?: string; updatedAt?: string;
updatedBy?: string; updatedBy?: string;
} }
@@ -76,6 +77,7 @@ export interface VersionPlanRequirementCoverage {
status: RequirementCoverageStatus; status: RequirementCoverageStatus;
completedContent?: string; completedContent?: string;
remainingContent?: string; remainingContent?: string;
currentWorkStartedAt?: string;
updatedAt: string; updatedAt: string;
updatedBy: string; updatedBy: string;
} }
@@ -93,28 +95,43 @@ export interface VersionPlanLog {
coverageStatus?: RequirementCoverageStatus; coverageStatus?: RequirementCoverageStatus;
completedContent?: string; completedContent?: string;
remainingContent?: string; remainingContent?: string;
workStartedAt?: string;
directionTaskId?: string; directionTaskId?: string;
directionTitle?: string; directionTitle?: string;
aiTarget?: AgentDecomposeTarget; aiTarget?: AgentDecomposeTarget;
aiStatus?: AiDecomposeLogStatus; aiStatus?: AiDecomposeLogStatus;
} }
export interface RequirementCoverageWorkStartInput {
requirementId: string;
startedAt?: string;
updatedBy: string;
}
export interface RequirementCoverageUpdateInput { export interface RequirementCoverageUpdateInput {
requirementId: string; requirementId: string;
status: RequirementCoverageStatus; status: RequirementCoverageStatus;
completedContent?: string; completedContent?: string;
remainingContent?: string; remainingContent?: string;
workStartedAt?: string;
updatedAt?: string; updatedAt?: string;
updatedBy: string; updatedBy: string;
requirementCode?: string; requirementCode?: string;
requirementTitle?: string; requirementTitle?: string;
} }
export interface ResearchDirectionWorkStartInput {
taskId: string;
startedAt?: string;
updatedBy: string;
}
export interface ResearchDirectionProgressUpdateInput { export interface ResearchDirectionProgressUpdateInput {
taskId: string; taskId: string;
status: Extract<RequirementCoverageStatus, 'partial' | 'completed'>; status: Extract<RequirementCoverageStatus, 'partial' | 'completed'>;
completedContent?: string; completedContent?: string;
remainingContent?: string; remainingContent?: string;
workStartedAt?: string;
updatedAt?: string; updatedAt?: string;
updatedBy: string; updatedBy: string;
} }
@@ -250,7 +267,9 @@ export function canSaveRequirementCoverageDraft(
status: RequirementCoverageStatus, status: RequirementCoverageStatus,
completedContent?: string, completedContent?: string,
remainingContent?: string, remainingContent?: string,
workStartedAt?: string,
): boolean { ): boolean {
if (!workStartedAt?.trim()) return false;
if (status === 'completed') return true; if (status === 'completed') return true;
if (status === 'partial') { if (status === 'partial') {
return Boolean(completedContent?.trim()) && Boolean(remainingContent?.trim()); return Boolean(completedContent?.trim()) && Boolean(remainingContent?.trim());
@@ -290,11 +309,37 @@ export function getPlanLogsForPlans(plans: VersionPlan[]): VersionPlanLogView[]
.sort((a, b) => getPlanLogCreatedAtTime(b) - getPlanLogCreatedAtTime(a)); .sort((a, b) => getPlanLogCreatedAtTime(b) - getPlanLogCreatedAtTime(a));
} }
export function startRequirementCoverageWork(
plan: VersionPlan,
input: RequirementCoverageWorkStartInput,
): Pick<VersionPlan, 'requirementCoverage'> {
const startedAt = input.startedAt ?? new Date().toISOString();
const existing = getRequirementCoverage(plan, input.requirementId);
const nextCoverage: VersionPlanRequirementCoverage = {
requirementId: input.requirementId,
status: existing?.status ?? 'not_started',
completedContent: existing?.completedContent,
remainingContent: existing?.remainingContent,
currentWorkStartedAt: startedAt,
updatedAt: startedAt,
updatedBy: input.updatedBy,
};
return {
requirementCoverage: [
nextCoverage,
...(plan.requirementCoverage ?? []).filter((item) => item.requirementId !== input.requirementId),
],
};
}
export function updateRequirementCoverage( export function updateRequirementCoverage(
plan: VersionPlan, plan: VersionPlan,
input: RequirementCoverageUpdateInput, input: RequirementCoverageUpdateInput,
): Pick<VersionPlan, 'requirementCoverage' | 'completedRequirementIds' | 'logs'> { ): Pick<VersionPlan, 'requirementCoverage' | 'completedRequirementIds' | 'logs'> {
const updatedAt = input.updatedAt ?? new Date().toISOString(); const updatedAt = input.updatedAt ?? new Date().toISOString();
const existingCoverage = plan.requirementCoverage?.find((item) => item.requirementId === input.requirementId);
const workStartedAt = input.workStartedAt?.trim() || existingCoverage?.currentWorkStartedAt;
const nextCoverage: VersionPlanRequirementCoverage = { const nextCoverage: VersionPlanRequirementCoverage = {
requirementId: input.requirementId, requirementId: input.requirementId,
status: input.status, status: input.status,
@@ -333,10 +378,31 @@ export function updateRequirementCoverage(
coverageStatus: input.status, coverageStatus: input.status,
completedContent: nextCoverage.completedContent, completedContent: nextCoverage.completedContent,
remainingContent: nextCoverage.remainingContent, remainingContent: nextCoverage.remainingContent,
workStartedAt,
}), }),
}; };
} }
export function startResearchDirectionWork(
plan: VersionPlan,
input: ResearchDirectionWorkStartInput,
): Pick<VersionPlan, 'tasks'> {
const tasks = plan.tasks ?? [];
const startedAt = input.startedAt ?? new Date().toISOString();
const nextTasks = tasks.map((task) => {
if (task.id !== input.taskId || task.status === 'completed') return task;
return {
...task,
status: 'in_progress' as const,
currentWorkStartedAt: startedAt,
updatedAt: startedAt,
updatedBy: input.updatedBy,
};
});
return { tasks: nextTasks };
}
export function updateResearchDirectionProgress( export function updateResearchDirectionProgress(
plan: VersionPlan, plan: VersionPlan,
input: ResearchDirectionProgressUpdateInput, input: ResearchDirectionProgressUpdateInput,
@@ -351,6 +417,7 @@ export function updateResearchDirectionProgress(
} }
const updatedAt = input.updatedAt ?? new Date().toISOString(); const updatedAt = input.updatedAt ?? new Date().toISOString();
const workStartedAt = input.workStartedAt?.trim() || target.currentWorkStartedAt;
const nextStatus: PlanTaskStatus = input.status === 'completed' ? 'completed' : 'in_progress'; const nextStatus: PlanTaskStatus = input.status === 'completed' ? 'completed' : 'in_progress';
const completedContent = input.status === 'partial' ? input.completedContent?.trim() || undefined : undefined; const completedContent = input.status === 'partial' ? input.completedContent?.trim() || undefined : undefined;
const remainingContent = input.status === 'partial' ? input.remainingContent?.trim() || undefined : undefined; const remainingContent = input.status === 'partial' ? input.remainingContent?.trim() || undefined : undefined;
@@ -360,6 +427,7 @@ export function updateResearchDirectionProgress(
status: nextStatus, status: nextStatus,
completedContent, completedContent,
remainingContent, remainingContent,
currentWorkStartedAt: undefined,
updatedAt, updatedAt,
updatedBy: input.updatedBy, updatedBy: input.updatedBy,
} }
@@ -382,6 +450,7 @@ export function updateResearchDirectionProgress(
coverageStatus: input.status, coverageStatus: input.status,
completedContent, completedContent,
remainingContent, remainingContent,
workStartedAt,
}), }),
}; };
} }

View File

@@ -8,6 +8,7 @@ import type { VersionPlan } from './version-plan';
import { import {
makeBugStatusActivity, makeBugStatusActivity,
makeDevTaskStatusActivity, makeDevTaskStatusActivity,
makeProgressNoteActivity,
makeTestCaseStatusActivity, makeTestCaseStatusActivity,
makeVersionPlanCompletedActivity, makeVersionPlanCompletedActivity,
makeVersionPlanRequirementProgressActivity, makeVersionPlanRequirementProgressActivity,
@@ -120,6 +121,7 @@ test('makeVersionPlanRequirementProgressActivity records partial requirement cov
coverageStatus: 'partial', coverageStatus: 'partial',
completedContent: '头像和基本信息', completedContent: '头像和基本信息',
remainingContent: '安全设置入口', remainingContent: '安全设置入口',
workStartedAt: '2026-06-30T01:30:00.000Z',
}); });
assert.equal(activity?.action, 'version_plan_requirement_progress'); assert.equal(activity?.action, 'version_plan_requirement_progress');
@@ -130,6 +132,29 @@ test('makeVersionPlanRequirementProgressActivity records partial requirement cov
assert.equal(activity?.summary, '推进UI设计需求QY-001 个人中心(部分完成)'); assert.equal(activity?.summary, '推进UI设计需求QY-001 个人中心(部分完成)');
assert.equal(activity?.metadata?.completedContent, '头像和基本信息'); assert.equal(activity?.metadata?.completedContent, '头像和基本信息');
assert.equal(activity?.metadata?.remainingContent, '安全设置入口'); assert.equal(activity?.metadata?.remainingContent, '安全设置入口');
assert.equal(activity?.metadata?.workStartedAt, '2026-06-30T01:30:00.000Z');
});
test('makeProgressNoteActivity records required next start time', () => {
const activity = makeProgressNoteActivity({
actorId: '张三',
sourceType: 'dev_task',
sourceId: 'task-1',
title: '实现登录接口',
note: '完成登录接口联调',
blocker: '等待安全评审',
helperId: '李四',
delayRisk: '可能延期半天',
nextStartAt: '2026-07-02T01:30:00.000Z',
});
assert.equal(activity.action, 'progress_note_added');
assert.equal(activity.category, 'risk');
assert.equal(activity.metadata?.note, '完成登录接口联调');
assert.equal(activity.metadata?.blocker, '等待安全评审');
assert.equal(activity.metadata?.helperId, '李四');
assert.equal(activity.metadata?.delayRisk, '可能延期半天');
assert.equal(activity.metadata?.nextStartAt, '2026-07-02T01:30:00.000Z');
}); });
test('makeBugStatusActivity maps fixing and fixed actions', () => { test('makeBugStatusActivity maps fixing and fixed actions', () => {

View File

@@ -2,7 +2,7 @@ import type { Bug, BugStatus } from './bug';
import type { DevTask, DevTaskStatus } from './dev-task'; import type { DevTask, DevTaskStatus } from './dev-task';
import type { TestCase, TestCaseStatus } from './test-case'; import type { TestCase, TestCaseStatus } from './test-case';
import { REQUIREMENT_COVERAGE_LABEL, type VersionPlan, type VersionPlanLog } from './version-plan'; import { REQUIREMENT_COVERAGE_LABEL, type VersionPlan, type VersionPlanLog } from './version-plan';
import type { WorkActivityDraft } from './work-activity'; import type { WorkActivityDraft, WorkActivitySourceType } from './work-activity';
const PLAN_TYPE_LABEL: Record<VersionPlan['type'], string> = { const PLAN_TYPE_LABEL: Record<VersionPlan['type'], string> = {
research: '调研', research: '调研',
@@ -69,6 +69,7 @@ export function makeVersionPlanRequirementProgressActivity(plan: VersionPlan, lo
coverageStatus: log.coverageStatus, coverageStatus: log.coverageStatus,
completedContent: log.completedContent, completedContent: log.completedContent,
remainingContent: log.remainingContent, remainingContent: log.remainingContent,
workStartedAt: log.workStartedAt,
}, },
}; };
} }
@@ -95,6 +96,50 @@ export function makeVersionPlanResearchDirectionProgressActivity(plan: VersionPl
coverageStatus: log.coverageStatus, coverageStatus: log.coverageStatus,
completedContent: log.completedContent, completedContent: log.completedContent,
remainingContent: log.remainingContent, remainingContent: log.remainingContent,
workStartedAt: log.workStartedAt,
},
};
}
export interface ProgressNoteActivityInput {
actorId: string;
sourceType: WorkActivitySourceType;
sourceId: string;
title: string;
note: string;
nextStartAt: string;
blocker?: string;
helperId?: string;
delayRisk?: string;
}
export function makeProgressNoteActivity(data: ProgressNoteActivityInput): WorkActivityDraft {
const note = data.note.trim();
const blocker = data.blocker?.trim() || undefined;
const helperId = data.helperId?.trim() || undefined;
const delayRisk = data.delayRisk?.trim() || undefined;
const nextStartAt = data.nextStartAt.trim();
const details = [
note,
blocker ? `阻塞:${blocker}` : '',
helperId ? `需协助:${helperId}` : '',
delayRisk ? `延期风险:${delayRisk}` : '',
].filter(Boolean);
return {
actorId: data.actorId,
sourceType: data.sourceType,
sourceId: data.sourceId,
action: 'progress_note_added',
category: blocker || delayRisk ? 'risk' : 'note',
title: data.title,
summary: `补充进展:${details.join('')}`,
metadata: {
note,
blocker,
helperId,
delayRisk,
nextStartAt: nextStartAt || undefined,
}, },
}; };
} }

View File

@@ -35,6 +35,7 @@ test('makeVersionPlanResearchDirectionProgressActivity records direction progres
coverageStatus: 'partial', coverageStatus: 'partial',
completedContent: '完成登录流程对比', completedContent: '完成登录流程对比',
remainingContent: '补充支付流程差异', remainingContent: '补充支付流程差异',
workStartedAt: '2026-06-30T09:30:00.000Z',
}); });
assert.equal(activity?.action, 'version_plan_research_direction_progress'); assert.equal(activity?.action, 'version_plan_research_direction_progress');
@@ -46,4 +47,5 @@ test('makeVersionPlanResearchDirectionProgressActivity records direction progres
assert.equal(activity?.metadata?.directionTaskId, 'task-1'); assert.equal(activity?.metadata?.directionTaskId, 'task-1');
assert.equal(activity?.metadata?.completedContent, '完成登录流程对比'); assert.equal(activity?.metadata?.completedContent, '完成登录流程对比');
assert.equal(activity?.metadata?.remainingContent, '补充支付流程差异'); assert.equal(activity?.metadata?.remainingContent, '补充支付流程差异');
assert.equal(activity?.metadata?.workStartedAt, '2026-06-30T09:30:00.000Z');
}); });

View File

@@ -46,6 +46,7 @@ export interface WorkActivity {
blocker?: string; blocker?: string;
helperId?: string; helperId?: string;
delayRisk?: string; delayRisk?: string;
nextStartAt?: string;
[key: string]: unknown; [key: string]: unknown;
}; };
} }

View File

@@ -362,6 +362,123 @@ test('getWorkspaceDailyReport does not double count overlapping activity time ra
assert.equal(report.totalCount, 2); assert.equal(report.totalCount, 2);
}); });
test('getWorkspaceDailyReport uses plan record work start time for daily report hours', () => {
const report = getWorkspaceDailyReport({
activities: [
{
id: 'act-plan-record',
actorId: 'Alice',
date: '2026-06-26',
occurredAt: '2026-06-26T04:00:00.000Z',
sourceType: 'version_plan',
sourceId: 'plan-cross-day',
action: 'version_plan_requirement_progress',
category: 'progress',
title: 'Product plan',
summary: 'Updated product plan requirement progress',
metadata: {
workStartedAt: '2026-06-26T02:00:00.000Z',
},
},
],
worklogs: [],
workItems: [
{
id: 'plan-cross-day',
type: 'plan_product',
title: 'Product plan',
status: 'in_progress',
completed: false,
productName: 'FTB',
projectName: 'Project Management',
versionName: 'V1.0',
versionId: 'version-1',
extra: {
actualStartAt: '2026-06-25T01:00:00.000Z',
},
raw: {
id: 'plan-cross-day',
actualStartAt: '2026-06-25T01:00:00.000Z',
status: 'in_progress',
} as any,
},
],
userId: 'Alice',
date: '2026-06-26',
now: new Date('2026-06-26T08:00:00.000Z'),
});
assert.equal(report.totalHours, 2);
assert.equal(report.totalCount, 1);
});
test('getWorkspaceDailyReport uses previous progress next start time for dev task daily hours', () => {
const report = getWorkspaceDailyReport({
activities: [
{
id: 'act-dev-progress-day-1',
actorId: 'Alice',
date: '2026-07-01',
occurredAt: '2026-07-01T10:00:00.000Z',
sourceType: 'dev_task',
sourceId: 'task-cross-day-progress',
action: 'progress_note_added',
category: 'note',
title: 'Cross-day development',
summary: 'Updated today progress',
metadata: {
note: 'Finished the first part',
nextStartAt: '2026-07-02T01:30:00.000Z',
},
},
{
id: 'act-dev-progress-day-2',
actorId: 'Alice',
date: '2026-07-02',
occurredAt: '2026-07-02T10:00:00.000Z',
sourceType: 'dev_task',
sourceId: 'task-cross-day-progress',
action: 'progress_note_added',
category: 'note',
title: 'Cross-day development',
summary: 'Updated today progress',
metadata: {
note: 'Finished the second part',
nextStartAt: '2026-07-03T01:30:00.000Z',
},
},
],
worklogs: [],
workItems: [
{
id: 'task-cross-day-progress',
type: 'devTask',
title: 'Cross-day development',
status: 'in_progress',
completed: false,
productName: 'FTB',
projectName: 'Project Management',
versionName: 'V1.0',
versionId: 'version-1',
extra: {
actualStartAt: '2026-07-01T01:30:00.000Z',
},
raw: {
id: 'task-cross-day-progress',
actualStartAt: '2026-07-01T01:30:00.000Z',
status: 'in_progress',
} as any,
},
],
userId: 'Alice',
date: '2026-07-02',
now: new Date('2026-07-02T10:00:00.000Z'),
});
assert.equal(report.totalHours, 7.5);
assert.equal(report.totalCount, 1);
});
test('getWorkspaceDailyReport rebuilds activity evidence from persisted work item timestamps', () => { test('getWorkspaceDailyReport rebuilds activity evidence from persisted work item timestamps', () => {
const report = getWorkspaceDailyReport({ const report = getWorkspaceDailyReport({
activities: [], activities: [],
@@ -401,6 +518,96 @@ test('getWorkspaceDailyReport rebuilds activity evidence from persisted work ite
assert.equal(report.totalCount, 2); assert.equal(report.totalCount, 2);
}); });
test('getWorkspaceDailyReport treats same-requirement plan progress as today evidence', () => {
const report = getWorkspaceDailyReport({
activities: [
{
id: 'act-plan-requirement-progress',
actorId: 'Alice',
date: '2026-07-01',
occurredAt: '2026-07-01T03:00:00.000Z',
sourceType: 'version_plan',
sourceId: 'plan-product',
action: 'version_plan_requirement_progress',
category: 'progress',
title: 'Product plan',
summary: 'Updated requirement progress',
metadata: {
requirementId: 'req-shared',
workStartedAt: '2026-07-01T02:00:00.000Z',
},
},
],
worklogs: [],
workItems: [
{
id: 'plan-product',
type: 'plan_product',
title: 'Product plan',
status: 'in_progress',
completed: false,
productName: 'FTB',
projectName: 'Project Management',
versionName: 'V1.0',
versionId: 'version-1',
extra: {
actualStartAt: '2026-06-30T01:00:00.000Z',
},
raw: {
id: 'plan-product',
actualStartAt: '2026-06-30T01:00:00.000Z',
status: 'in_progress',
} as any,
},
{
id: 'task-same-requirement',
type: 'devTask',
title: 'Same requirement task',
status: 'in_progress',
completed: false,
productName: 'FTB',
projectName: 'Project Management',
versionName: 'V1.0',
versionId: 'version-1',
extra: {
actualStartAt: '2026-06-30T02:00:00.000Z',
},
raw: {
id: 'task-same-requirement',
requirementId: 'req-shared',
actualStartAt: '2026-06-30T02:00:00.000Z',
status: 'in_progress',
} as any,
},
{
id: 'task-other-requirement',
type: 'devTask',
title: 'Other requirement task',
status: 'in_progress',
completed: false,
productName: 'FTB',
projectName: 'Project Management',
versionName: 'V1.0',
versionId: 'version-1',
extra: {
actualStartAt: '2026-06-30T02:00:00.000Z',
},
raw: {
id: 'task-other-requirement',
requirementId: 'req-other',
actualStartAt: '2026-06-30T02:00:00.000Z',
status: 'in_progress',
} as any,
},
],
userId: 'Alice',
date: '2026-07-01',
now: new Date('2026-07-01T04:00:00.000Z'),
});
assert.deepEqual(report.needsProgressItems.map((item) => item.id), ['task-other-requirement']);
});
test('getWorkspaceDailyReport flags multi-day in-progress items without today progress', () => { test('getWorkspaceDailyReport flags multi-day in-progress items without today progress', () => {
const report = getWorkspaceDailyReport({ const report = getWorkspaceDailyReport({
activities, activities,

View File

@@ -85,8 +85,11 @@ export function getWorkspaceDailyReport({
}; };
}); });
const dailyActivities = activities const userActivities = activities
.filter((activity) => activity.actorId === userId && activity.date === date) .filter((activity) => activity.actorId === userId)
.sort((a, b) => a.occurredAt.localeCompare(b.occurredAt));
const dailyActivities = userActivities
.filter((activity) => activity.date === date)
.sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)); .sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
const activityKeys = new Set(dailyActivities.map(getActivityKey)); const activityKeys = new Set(dailyActivities.map(getActivityKey));
const evidenceSourceIds = new Set([ const evidenceSourceIds = new Set([
@@ -115,13 +118,11 @@ export function getWorkspaceDailyReport({
}); });
} }
const touchedSourceIds = new Set([ const touchedWorkItemIds = buildTouchedWorkItemIds(reportActivities, items, workItems);
...reportActivities.map((activity) => activity.sourceId),
...items.map((item) => item.taskId),
]);
const worklogTaskIds = new Set(items.map((item) => item.taskId)); const worklogTaskIds = new Set(items.map((item) => item.taskId));
const activityHours = calcActivityHoursForDate( const activityHours = calcActivityHoursForDate(
Array.from(new Set(reportActivities.map((activity) => activity.sourceId))), reportActivities,
userActivities,
workItemMap, workItemMap,
worklogTaskIds, worklogTaskIds,
date, date,
@@ -129,7 +130,7 @@ export function getWorkspaceDailyReport({
); );
const needsProgressItems = workItems const needsProgressItems = workItems
.filter((item) => shouldRequireProgress(item, date, touchedSourceIds)) .filter((item) => shouldRequireProgress(item, date, touchedWorkItemIds))
.map((item) => ({ .map((item) => ({
id: item.id, id: item.id,
title: item.title, title: item.title,
@@ -171,9 +172,46 @@ function getRaw(item: WorkItem): Record<string, unknown> {
return ((item.raw ?? {}) as unknown) as Record<string, unknown>; return ((item.raw ?? {}) as unknown) as Record<string, unknown>;
} }
function shouldRequireProgress(item: WorkItem, date: string, touchedSourceIds: Set<string>): boolean { function buildTouchedWorkItemIds(
activities: WorkActivity[],
items: WorkspaceDailyReportItem[],
workItems: WorkItem[],
): Set<string> {
const touched = new Set([
...activities.map((activity) => activity.sourceId),
...items.map((item) => item.taskId),
]);
const touchedRequirementIds = new Set(
activities
.map(getPlanProgressRequirementId)
.filter((requirementId): requirementId is string => Boolean(requirementId)),
);
if (touchedRequirementIds.size === 0) return touched;
for (const item of workItems) {
const requirementId = getWorkItemRequirementId(item);
if (requirementId && touchedRequirementIds.has(requirementId)) {
touched.add(item.id);
}
}
return touched;
}
function getPlanProgressRequirementId(activity: WorkActivity): string | undefined {
if (activity.sourceType !== 'version_plan' || activity.action !== 'version_plan_requirement_progress') return undefined;
return asString(activity.metadata?.requirementId);
}
function getWorkItemRequirementId(item: WorkItem): string | undefined {
const raw = getRaw(item);
return asString(raw.requirementId) ?? asString(item.extra?.requirementId);
}
function shouldRequireProgress(item: WorkItem, date: string, touchedWorkItemIds: Set<string>): boolean {
if (item.completed) return false; if (item.completed) return false;
if (touchedSourceIds.has(item.id)) return false; if (touchedWorkItemIds.has(item.id)) return false;
if (!isInProgressStatus(item.status)) return false; if (!isInProgressStatus(item.status)) return false;
const startedAt = getStartedAt(item); const startedAt = getStartedAt(item);
@@ -299,18 +337,108 @@ function getTestCaseTerminalFallback(
} }
function calcActivityHoursForDate( function calcActivityHoursForDate(
sourceIds: string[], activities: WorkActivity[],
allUserActivities: WorkActivity[],
workItemMap: Map<string, WorkItem>, workItemMap: Map<string, WorkItem>,
excludedSourceIds: Set<string>, excludedSourceIds: Set<string>,
date: string, date: string,
now: Date, now: Date,
): number { ): number {
const intervals = sourceIds const recordScopedSourceIds = new Set<string>();
const intervals = activities
.filter((activity) => !excludedSourceIds.has(activity.sourceId))
.map((activity) => {
if (!isRecordScopedProgressActivity(activity)) return undefined;
recordScopedSourceIds.add(activity.sourceId);
const workStartedAt = getRecordScopedWorkStartedAt(activity, allUserActivities, workItemMap, date);
if (!workStartedAt) return undefined;
return getActivityIntervalForDate(workStartedAt, activity.occurredAt, date);
})
.filter(isTimeInterval);
const sourceIds = Array.from(new Set(activities.map((activity) => activity.sourceId)));
const fallbackIntervals = sourceIds
.filter((sourceId) => !excludedSourceIds.has(sourceId)) .filter((sourceId) => !excludedSourceIds.has(sourceId))
.filter((sourceId) => !recordScopedSourceIds.has(sourceId))
.map((sourceId) => getWorkItemIntervalForDate(workItemMap.get(sourceId), date, now)) .map((sourceId) => getWorkItemIntervalForDate(workItemMap.get(sourceId), date, now))
.filter(isTimeInterval); .filter(isTimeInterval);
return calcMergedIntervalHours(intervals); return calcMergedIntervalHours([...intervals, ...fallbackIntervals]);
}
function isPlanRecordProgressActivity(activity: WorkActivity): boolean {
return activity.sourceType === 'version_plan'
&& (activity.action === 'version_plan_requirement_progress'
|| activity.action === 'version_plan_research_direction_progress');
}
function isProgressNoteRecordActivity(activity: WorkActivity): boolean {
return activity.action === 'progress_note_added' && Boolean(asString(activity.metadata?.nextStartAt));
}
function isRecordScopedProgressActivity(activity: WorkActivity): boolean {
return isPlanRecordProgressActivity(activity) || isProgressNoteRecordActivity(activity);
}
function getRecordScopedWorkStartedAt(
activity: WorkActivity,
allUserActivities: WorkActivity[],
workItemMap: Map<string, WorkItem>,
date: string,
): string | undefined {
if (isPlanRecordProgressActivity(activity)) {
return asString(activity.metadata?.workStartedAt)
?? getPreviousRecordNextStartAt(activity, allUserActivities)
?? getSameDayWorkItemStartAt(workItemMap.get(activity.sourceId), date);
}
if (isProgressNoteRecordActivity(activity)) {
return getPreviousRecordNextStartAt(activity, allUserActivities)
?? getSameDayWorkItemStartAt(workItemMap.get(activity.sourceId), date);
}
return undefined;
}
function getPreviousRecordNextStartAt(activity: WorkActivity, allUserActivities: WorkActivity[]): string | undefined {
const currentTime = new Date(activity.occurredAt).getTime();
if (!Number.isFinite(currentTime)) return undefined;
return [...allUserActivities]
.filter((item) => item.sourceId === activity.sourceId && item.occurredAt < activity.occurredAt)
.sort((a, b) => b.occurredAt.localeCompare(a.occurredAt))
.map((item) => asString(item.metadata?.nextStartAt))
.find((nextStartAt) => {
if (!nextStartAt) return false;
const startTime = new Date(nextStartAt).getTime();
return Number.isFinite(startTime) && startTime < currentTime;
});
}
function getSameDayWorkItemStartAt(item: WorkItem | undefined, date: string): string | undefined {
if (!item) return undefined;
const startAt = getStartedAt(item);
if (!startAt || !isWithinLocalDate(startAt, date)) return undefined;
return startAt;
}
function getActivityIntervalForDate(
startAt: string,
occurredAt: string,
date: string,
): { start: number; end: number } | undefined {
const day = getLocalDateBounds(date);
if (!day) return undefined;
const start = new Date(startAt).getTime();
const end = new Date(occurredAt).getTime();
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return undefined;
const overlapStart = Math.max(start, day.start.getTime());
const overlapEnd = Math.min(end, day.end.getTime());
if (overlapEnd <= overlapStart) return undefined;
return { start: overlapStart, end: overlapEnd };
} }
function getWorkItemIntervalForDate(item: WorkItem | undefined, date: string, now: Date): { start: number; end: number } | undefined { function getWorkItemIntervalForDate(item: WorkItem | undefined, date: string, now: Date): { start: number; end: number } | undefined {

View File

@@ -65,6 +65,7 @@ interface ProductState {
reorderProducts: (ids: string[]) => void; reorderProducts: (ids: string[]) => void;
migrateAndDeleteProduct: (sourceId: string, targetId: string) => void; migrateAndDeleteProduct: (sourceId: string, targetId: string) => void;
createProject: (productId: string, data: { name: string; description?: string }) => void; createProject: (productId: string, data: { name: string; description?: string }) => void;
updateProject: (productId: string, projectId: string, data: { name?: string; description?: string }) => void;
createVersion: (productId: string, data: { name: string; status: string }) => void; createVersion: (productId: string, data: { name: string; status: string }) => void;
updateVersion: (productId: string, versionId: string, data: Partial<VersionItem>) => void; updateVersion: (productId: string, versionId: string, data: Partial<VersionItem>) => void;
deleteVersion: (productId: string, versionId: string) => void; deleteVersion: (productId: string, versionId: string) => void;
@@ -216,6 +217,43 @@ export const useProductStore = create<ProductState>((set, get) => ({
saveStoredOverview(updated); saveStoredOverview(updated);
}, },
updateProject: (productId, projectId, data) => {
const updated = get().overview.map((p) => {
if (p.id !== productId) return p;
const currentProject = p.projects.find((proj) => proj.id === projectId);
if (!currentProject) return p;
const nextName = data.name?.trim();
const shouldRename = !!nextName && nextName !== currentProject.name;
const nextProjects = p.projects.map((proj) =>
proj.id === projectId
? {
...proj,
...data,
name: nextName || proj.name,
}
: proj,
);
const nextVersions = shouldRename
? p.versions.map((version) => {
if (!version.name.toLowerCase().startsWith(currentProject.name.toLowerCase())) return version;
return {
...version,
name: `${nextName}${version.name.slice(currentProject.name.length)}`,
};
})
: p.versions;
return {
...p,
projects: nextProjects,
versions: nextVersions,
};
});
set({ overview: updated });
saveStoredOverview(updated);
},
createVersion: (productId, data) => { createVersion: (productId, data) => {
const newVersion: VersionItem = { const newVersion: VersionItem = {
id: `ver-${Date.now()}`, id: `ver-${Date.now()}`,

View File

@@ -1,25 +1,15 @@
'use client'; 'use client';
import { create } from 'zustand'; import { create } from 'zustand';
import { formatLocalDate } from '@/lib/format'; import { formatLocalDate } from '@/lib/format';
import { mergeWorkActivities, type WorkActivity, type WorkActivityDraft, type WorkActivitySourceType } from '@/lib/work-activity'; import { mergeWorkActivities, type WorkActivity, type WorkActivityDraft } from '@/lib/work-activity';
import { makeProgressNoteActivity, type ProgressNoteActivityInput } from '@/lib/work-activity-factory';
import { loadServerData, saveServerData } from '@/lib/server-data'; import { loadServerData, saveServerData } from '@/lib/server-data';
interface ProgressNoteInput {
actorId: string;
sourceType: WorkActivitySourceType;
sourceId: string;
title: string;
note: string;
blocker?: string;
helperId?: string;
delayRisk?: string;
}
interface WorkActivityState { interface WorkActivityState {
activities: WorkActivity[]; activities: WorkActivity[];
fetchActivities: () => Promise<void>; fetchActivities: () => Promise<void>;
addActivity: (data: WorkActivityDraft) => WorkActivity; addActivity: (data: WorkActivityDraft) => WorkActivity;
addProgressNote: (data: ProgressNoteInput) => WorkActivity; addProgressNote: (data: ProgressNoteActivityInput) => WorkActivity;
deleteActivity: (id: string) => void; deleteActivity: (id: string) => void;
} }
@@ -76,28 +66,7 @@ export const useWorkActivityStore = create<WorkActivityState>((set, get) => ({
}, },
addProgressNote: (data) => { addProgressNote: (data) => {
const details = [ return get().addActivity(makeProgressNoteActivity(data));
data.note.trim(),
data.blocker?.trim() ? `阻塞:${data.blocker.trim()}` : '',
data.helperId?.trim() ? `需协助:${data.helperId.trim()}` : '',
data.delayRisk?.trim() ? `延期风险:${data.delayRisk.trim()}` : '',
].filter(Boolean);
return get().addActivity({
actorId: data.actorId,
sourceType: data.sourceType,
sourceId: data.sourceId,
action: 'progress_note_added',
category: data.blocker?.trim() || data.delayRisk?.trim() ? 'risk' : 'note',
title: data.title,
summary: `补充进展:${details.join('')}`,
metadata: {
note: data.note.trim(),
blocker: data.blocker?.trim() || undefined,
helperId: data.helperId?.trim() || undefined,
delayRisk: data.delayRisk?.trim() || undefined,
},
});
}, },
deleteActivity: (id) => { deleteActivity: (id) => {

View File

@@ -225,6 +225,7 @@ AI 估时约束:
- `TaskCategory.code` 是 AI 和系统任务类型的稳定映射锚点,`id` 只作为存储主键。 - `TaskCategory.code` 是 AI 和系统任务类型的稳定映射锚点,`id` 只作为存储主键。
- 产品方案和 UI 设计的引用需求不再用 checkbox 直接标记完成,必须通过 `requirementCoverage[]` 记录 `not_started / partial / completed`、本次已完成内容和剩余内容;只有 `completed` 计入成果提交门禁。 - 产品方案和 UI 设计的引用需求不再用 checkbox 直接标记完成,必须通过 `requirementCoverage[]` 记录 `not_started / partial / completed`、本次已完成内容和剩余内容;只有 `completed` 计入成果提交门禁。
- 产品/UI 计划右侧展示计划日志,需求进度更新和 AI 拆解触发/完成/失败都写入 `VersionPlan.logs[]`,页面只消费日志数据,不临时拼历史。 - 产品/UI 计划右侧展示计划日志,需求进度更新和 AI 拆解触发/完成/失败都写入 `VersionPlan.logs[]`,页面只消费日志数据,不临时拼历史。
- 调研/产品方案/UI 设计的计划级 `actualStartAt` 只表示计划容器已开始,不直接作为具体任务日报耗时。具体调研方向或引用需求需要先点击「开始任务」,写入当前行的 `currentWorkStartedAt`;提交「记录」时日志和 `work-activities` 保留 `workStartedAt`,日报耗时按 `workStartedAt -> 记录提交时间` 计算,提交后清空当前行的开始时间。完全完成可直接提交结束本次耗时,部分完成才需要填写本次已完成内容和剩余未完成内容。
## Work Activity Daily Report Flow (2026-06-26) ## Work Activity Daily Report Flow (2026-06-26)
The daily report flow uses mixed evidence: The daily report flow uses mixed evidence:
@@ -235,6 +236,8 @@ The daily report flow uses mixed evidence:
- TestCase created, started, passed, failed, or blocked. - TestCase created, started, passed, failed, or blocked.
- Bug created, moved to fixing, fixed, closed, or transferred. - Bug created, moved to fixing, fixed, closed, or transferred.
2. Manual progress notes are used for multi-day work that does not change status today. 2. Manual progress notes are used for multi-day work that does not change status today.
- DevTask「今日进展」必须填写 `nextStartAt`,默认值为第二天 09:30。
-`nextStartAt` 的进展记录按「上一条记录的 `nextStartAt` → 本次记录时间」计算当日耗时;如果任务是当天开始,才回退到当天实际开始时间。
3. `/workspace` shows only the current logged-in user's report. 3. `/workspace` shows only the current logged-in user's report.
4. Project-owner and management views will reuse the same `work-activities` data later, but are not part of the personal workspace panel. 4. Project-owner and management views will reuse the same `work-activities` data later, but are not part of the personal workspace panel.