feat(工作台): 细化计划进展工时证据
关键改动: - 支持需求覆盖和调研方向开始工作记录 - 日报按计划记录开始时间和进度下次开始时间计算证据 - 更新版本编辑校验、项目展示和 workflow 说明 Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import { RouteGuard } from '@/components/auth/Guard';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
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 { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
||||
@@ -25,7 +25,7 @@ export default function ProjectsPage() {
|
||||
|
||||
function ProjectsPageContent() {
|
||||
const router = useRouter();
|
||||
const { overview, fetchOverview, createProject, deleteProject } = useProductStore();
|
||||
const { overview, fetchOverview, createProject, updateProject, deleteProject } = useProductStore();
|
||||
const { requirements, fetchRequirements } = useRequirementStore();
|
||||
const { plans, fetchPlans } = useVersionPlanStore();
|
||||
const { tasks: devTasks, fetchTasks: fetchDevTasks } = useDevTaskStore();
|
||||
@@ -33,6 +33,7 @@ function ProjectsPageContent() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [productFilter, setProductFilter] = useState<string>('all');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingProject, setEditingProject] = useState<ProjectWithContext | null>(null);
|
||||
|
||||
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||||
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
||||
@@ -127,6 +128,7 @@ function ProjectsPageContent() {
|
||||
proj={proj}
|
||||
versionProgressMap={versionProgressMap}
|
||||
onClick={() => router.push(`/projects/${proj.id}`)}
|
||||
onEdit={() => setEditingProject(proj)}
|
||||
onDelete={() => {
|
||||
if (!confirm(`确认删除项目「${proj.name}」?此操作不可恢复。`)) return;
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -160,13 +174,16 @@ function ProjectRow({
|
||||
proj,
|
||||
versionProgressMap,
|
||||
onClick,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
proj: ProjectWithContext;
|
||||
versionProgressMap: Record<string, number>;
|
||||
onClick: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const canEdit = useHasPermission('project:edit');
|
||||
const canDelete = useHasPermission('project:delete');
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const activeVersions = proj.versions.filter((v) => (versionProgressMap[v.id] ?? 0) < 100);
|
||||
@@ -200,7 +217,7 @@ function ProjectRow({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{canDelete && (
|
||||
{(canEdit || canDelete) && (
|
||||
<div className="relative shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
@@ -213,19 +230,33 @@ function ProjectRow({
|
||||
<>
|
||||
<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)]">
|
||||
<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>
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onEdit();
|
||||
}}
|
||||
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)]"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
编辑项目
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
@@ -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 ─────────────────────────────────────────── */
|
||||
|
||||
function CreateProjectModal({
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { useEffect, useMemo, useState, type FormEvent } from 'react';
|
||||
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 { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
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 { CapsuleStages } from '@/components/version/CapsuleStages';
|
||||
import { FilterSelect } from '@/components/FilterSelect';
|
||||
@@ -36,6 +36,16 @@ import { getRequirementCoverageSummary } from '@/lib/version-plan';
|
||||
import { buildVersionProgressMap } from '@/lib/version-progress';
|
||||
import { canSubmitReleaseForm, getReleaseProgressWarning } from '@/lib/version-release';
|
||||
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 {
|
||||
if (!value) return '-';
|
||||
@@ -107,6 +117,7 @@ export default function VersionDetailPage() {
|
||||
}, [visibleTabs, activeTab]);
|
||||
const [showMemberModal, setShowMemberModal] = useState(false);
|
||||
const [showRecommendModal, setShowRecommendModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showReleaseModal, setShowReleaseModal] = useState(false);
|
||||
const [recommendationDataReady, setRecommendationDataReady] = useState(false);
|
||||
|
||||
@@ -209,11 +220,12 @@ export default function VersionDetailPage() {
|
||||
});
|
||||
|
||||
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 (version.status !== 'released' && version.status !== 'closed') {
|
||||
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') {
|
||||
buttons.push({ label: '删除', action: () => {
|
||||
if (confirm('确认删除该版本?关联的需求会回到需求池,版本下的计划、开发任务、测试用例、Bug 将被清除。')) {
|
||||
@@ -243,8 +255,9 @@ export default function VersionDetailPage() {
|
||||
<button
|
||||
key={btn.label}
|
||||
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}
|
||||
</button>
|
||||
));
|
||||
@@ -950,6 +963,17 @@ export default function VersionDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showEditModal && !versionReadonly && (
|
||||
<VersionEditModal
|
||||
version={version}
|
||||
onSubmit={(data) => {
|
||||
updateVersion(version.productId, version.id, data);
|
||||
setShowEditModal(false);
|
||||
}}
|
||||
onClose={() => setShowEditModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showRecommendModal && !versionReadonly && (
|
||||
<MemberRecommendationModal
|
||||
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 }: {
|
||||
progress: number;
|
||||
initialDate: string;
|
||||
|
||||
@@ -47,6 +47,13 @@ function defaultPlanEndLocal(): string {
|
||||
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) {
|
||||
const { tasks, changeStatus, setBlocked, deleteTask, updateTask } = useDevTaskStore();
|
||||
const addProgressNote = useWorkActivityStore((s) => s.addProgressNote);
|
||||
@@ -65,6 +72,7 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel,
|
||||
const [progressBlocker, setProgressBlocker] = useState('');
|
||||
const [progressHelperId, setProgressHelperId] = useState('');
|
||||
const [progressDelayRisk, setProgressDelayRisk] = useState('');
|
||||
const [progressNextStartLocal, setProgressNextStartLocal] = useState(() => defaultProgressNextStartLocal());
|
||||
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
if (!task) return null;
|
||||
@@ -92,6 +100,10 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel,
|
||||
const planEndISO = localToISO(planEndLocal);
|
||||
const planStartBeforeEnd = Boolean(planStartISO && planEndISO && planStartISO < planEndISO);
|
||||
const planEstimateHours = planStartBeforeEnd ? calcWorkHours(planStartISO, planEndISO) : 0;
|
||||
const progressNextStartISO = localToISO(progressNextStartLocal);
|
||||
const canRecordProgress = Boolean(
|
||||
(progressNote.trim() || progressBlocker.trim() || progressDelayRisk.trim()) && progressNextStartISO,
|
||||
);
|
||||
|
||||
const openPlanInput = () => {
|
||||
if (readOnly) return;
|
||||
@@ -160,6 +172,10 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel,
|
||||
const blocker = progressBlocker.trim();
|
||||
const delayRisk = progressDelayRisk.trim();
|
||||
if (!note && !blocker && !delayRisk) return;
|
||||
if (!progressNextStartISO) {
|
||||
alert('请填写下次开始时间');
|
||||
return;
|
||||
}
|
||||
|
||||
addProgressNote({
|
||||
actorId: task.assigneeId,
|
||||
@@ -167,6 +183,7 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel,
|
||||
sourceId: task.id,
|
||||
title: task.title,
|
||||
note: note || '今日进展已更新',
|
||||
nextStartAt: progressNextStartISO,
|
||||
blocker: blocker || undefined,
|
||||
helperId: progressHelperId || undefined,
|
||||
delayRisk: delayRisk || undefined,
|
||||
@@ -175,6 +192,7 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel,
|
||||
setProgressBlocker('');
|
||||
setProgressHelperId('');
|
||||
setProgressDelayRisk('');
|
||||
setProgressNextStartLocal(defaultProgressNextStartLocal());
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -381,11 +399,25 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel,
|
||||
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"
|
||||
/>
|
||||
<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
|
||||
onClick={handleProgressNote}
|
||||
disabled={!progressNote.trim() && !progressBlocker.trim() && !progressDelayRisk.trim()}
|
||||
className="h-8 px-3 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] disabled:opacity-50"
|
||||
disabled={!canRecordProgress}
|
||||
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>
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
getRequirementCoverageStatus,
|
||||
getRequirementCoverageSummary,
|
||||
REQUIREMENT_COVERAGE_LABEL,
|
||||
startResearchDirectionWork,
|
||||
startRequirementCoverageWork,
|
||||
updateResearchDirectionProgress,
|
||||
updateRequirementCoverage,
|
||||
} from '@/lib/version-plan';
|
||||
@@ -116,10 +118,19 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr
|
||||
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'>) => {
|
||||
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, {
|
||||
requirementId: req.id,
|
||||
status,
|
||||
@@ -155,7 +166,11 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr
|
||||
const status = getRequirementCoverageStatus(plan, req.id);
|
||||
const coverage = getRequirementCoverage(plan, 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 (
|
||||
<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">
|
||||
@@ -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>
|
||||
{req.isHistorical && <span className="shrink-0 rounded bg-orange-50 px-1.5 py-0.5 text-[10px] text-orange-600">历史</span>}
|
||||
</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)]">
|
||||
{coverage.completedContent && <div className="line-clamp-2">已完成:{coverage.completedContent}</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>
|
||||
{canEdit && (canOpenRecord || isEditing) && (
|
||||
{canEdit && status !== 'completed' && (
|
||||
<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
|
||||
type="button"
|
||||
onClick={() => 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)]"
|
||||
onClick={() => canOpenRecord && (isEditing ? closeEditor() : openEditor(req))}
|
||||
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 ? '收起' : '记录'}
|
||||
</button>
|
||||
@@ -207,7 +234,8 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
@@ -260,10 +288,17 @@ export function PlanResearchDirectionPanel({ plan, canEdit, currentUserName, onU
|
||||
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'>) => {
|
||||
if (!canEdit || !canSaveRequirementCoverageDraft(status, completedContent, remainingContent)) return;
|
||||
if (!canEdit || !canSaveRequirementCoverageDraft(status, completedContent, remainingContent, task.currentWorkStartedAt)) return;
|
||||
const patch = updateResearchDirectionProgress(plan, {
|
||||
taskId: task.id,
|
||||
status,
|
||||
@@ -296,7 +331,11 @@ export function PlanResearchDirectionPanel({ plan, canEdit, currentUserName, onU
|
||||
{tasks.map((task) => {
|
||||
const status = getResearchDirectionStatus(task);
|
||||
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 (
|
||||
<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">
|
||||
@@ -305,19 +344,31 @@ export function PlanResearchDirectionPanel({ plan, canEdit, currentUserName, onU
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<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)]">
|
||||
{task.completedContent && <div className="line-clamp-2">已完成:{task.completedContent}</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>
|
||||
{canEdit && (canOpenRecord || isEditing) && (
|
||||
{canEdit && status !== 'completed' && (
|
||||
<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
|
||||
type="button"
|
||||
onClick={() => 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)]"
|
||||
onClick={() => canOpenRecord && (isEditing ? closeEditor() : openEditor(task))}
|
||||
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 ? '收起' : '记录'}
|
||||
</button>
|
||||
@@ -344,7 +395,8 @@ export function PlanResearchDirectionPanel({ plan, canEdit, currentUserName, onU
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
@@ -460,6 +512,11 @@ export function PlanLogTimeline({ logs, className = '', fillHeight = false }: Lo
|
||||
<div className="whitespace-pre-wrap">{log.detail}</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>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,10 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
VERSION_DEVELOPMENT_TYPE_OPTIONS,
|
||||
buildVersionName,
|
||||
canSubmitNewVersionForm,
|
||||
canSubmitVersionEditForm,
|
||||
getEditableVersionNumber,
|
||||
getRecommendedExpectedReleaseDate,
|
||||
getVersionDevelopmentTypeOption,
|
||||
} from './version-form';
|
||||
@@ -49,6 +52,32 @@ test('canSubmitNewVersionForm blocks duplicate submits', () => {
|
||||
}), 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', () => {
|
||||
const agile = getVersionDevelopmentTypeOption('agile');
|
||||
|
||||
|
||||
@@ -26,6 +26,12 @@ export interface NewVersionFormState {
|
||||
submitting: boolean;
|
||||
}
|
||||
|
||||
export interface VersionEditFormState {
|
||||
versionNumber: string;
|
||||
expectedReleaseDate: string;
|
||||
submitting: boolean;
|
||||
}
|
||||
|
||||
export const VERSION_DEVELOPMENT_TYPE_OPTIONS: VersionDevelopmentTypeOption[] = [
|
||||
makeDevelopmentTypeOption('agile', '敏捷迭代', '适合常规双周迭代', [
|
||||
{ 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 {
|
||||
return VERSION_DEVELOPMENT_TYPE_OPTIONS.find((option) => option.key === type);
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ test('updates research direction progress and creates a direction log', () => {
|
||||
|
||||
const next = updateResearchDirectionProgress!(plan({
|
||||
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' },
|
||||
],
|
||||
}), {
|
||||
@@ -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]?.completedContent, '完成竞品登录流程对比');
|
||||
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]?.directionTaskId, 'task-1');
|
||||
assert.equal(next.logs?.[0]?.directionTitle, '竞品分析');
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -146,6 +146,15 @@ test('updates requirement coverage, syncs legacy completed ids, and creates a pl
|
||||
const next = updateRequirementCoverage!(plan({
|
||||
linkedRequirementIds: ['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',
|
||||
status: 'partial',
|
||||
@@ -159,26 +168,63 @@ test('updates requirement coverage, syncs legacy completed ids, and creates a pl
|
||||
|
||||
assert.deepEqual(next.completedRequirementIds, []);
|
||||
assert.equal(next.requirementCoverage?.[0]?.status, 'partial');
|
||||
assert.equal(next.requirementCoverage?.[0]?.currentWorkStartedAt, undefined);
|
||||
assert.equal(next.logs?.length, 1);
|
||||
assert.equal(next.logs?.[0]?.type, 'requirement_progress');
|
||||
assert.equal(next.logs?.[0]?.actor, 'PM');
|
||||
assert.equal(next.logs?.[0]?.requirementCode, 'QY0001');
|
||||
assert.equal(next.logs?.[0]?.completedContent, '完成移动端主流程');
|
||||
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 | ((
|
||||
status: string,
|
||||
completedContent?: string,
|
||||
remainingContent?: string,
|
||||
workStartedAt?: 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);
|
||||
assert.equal(canSaveRequirementCoverageDraft!('completed'), false);
|
||||
assert.equal(canSaveRequirementCoverageDraft!('completed', undefined, undefined, '2026-06-29T09:30:00.000Z'), true);
|
||||
assert.equal(canSaveRequirementCoverageDraft!('partial', '完成主流程', '', '2026-06-29T09:30:00.000Z'), 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', () => {
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface PlanTask {
|
||||
status: PlanTaskStatus;
|
||||
completedContent?: string;
|
||||
remainingContent?: string;
|
||||
currentWorkStartedAt?: string;
|
||||
updatedAt?: string;
|
||||
updatedBy?: string;
|
||||
}
|
||||
@@ -76,6 +77,7 @@ export interface VersionPlanRequirementCoverage {
|
||||
status: RequirementCoverageStatus;
|
||||
completedContent?: string;
|
||||
remainingContent?: string;
|
||||
currentWorkStartedAt?: string;
|
||||
updatedAt: string;
|
||||
updatedBy: string;
|
||||
}
|
||||
@@ -93,28 +95,43 @@ export interface VersionPlanLog {
|
||||
coverageStatus?: RequirementCoverageStatus;
|
||||
completedContent?: string;
|
||||
remainingContent?: string;
|
||||
workStartedAt?: string;
|
||||
directionTaskId?: string;
|
||||
directionTitle?: string;
|
||||
aiTarget?: AgentDecomposeTarget;
|
||||
aiStatus?: AiDecomposeLogStatus;
|
||||
}
|
||||
|
||||
export interface RequirementCoverageWorkStartInput {
|
||||
requirementId: string;
|
||||
startedAt?: string;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
export interface RequirementCoverageUpdateInput {
|
||||
requirementId: string;
|
||||
status: RequirementCoverageStatus;
|
||||
completedContent?: string;
|
||||
remainingContent?: string;
|
||||
workStartedAt?: string;
|
||||
updatedAt?: string;
|
||||
updatedBy: string;
|
||||
requirementCode?: string;
|
||||
requirementTitle?: string;
|
||||
}
|
||||
|
||||
export interface ResearchDirectionWorkStartInput {
|
||||
taskId: string;
|
||||
startedAt?: string;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
export interface ResearchDirectionProgressUpdateInput {
|
||||
taskId: string;
|
||||
status: Extract<RequirementCoverageStatus, 'partial' | 'completed'>;
|
||||
completedContent?: string;
|
||||
remainingContent?: string;
|
||||
workStartedAt?: string;
|
||||
updatedAt?: string;
|
||||
updatedBy: string;
|
||||
}
|
||||
@@ -250,7 +267,9 @@ export function canSaveRequirementCoverageDraft(
|
||||
status: RequirementCoverageStatus,
|
||||
completedContent?: string,
|
||||
remainingContent?: string,
|
||||
workStartedAt?: string,
|
||||
): boolean {
|
||||
if (!workStartedAt?.trim()) return false;
|
||||
if (status === 'completed') return true;
|
||||
if (status === 'partial') {
|
||||
return Boolean(completedContent?.trim()) && Boolean(remainingContent?.trim());
|
||||
@@ -290,11 +309,37 @@ export function getPlanLogsForPlans(plans: VersionPlan[]): VersionPlanLogView[]
|
||||
.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(
|
||||
plan: VersionPlan,
|
||||
input: RequirementCoverageUpdateInput,
|
||||
): Pick<VersionPlan, 'requirementCoverage' | 'completedRequirementIds' | 'logs'> {
|
||||
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 = {
|
||||
requirementId: input.requirementId,
|
||||
status: input.status,
|
||||
@@ -333,10 +378,31 @@ export function updateRequirementCoverage(
|
||||
coverageStatus: input.status,
|
||||
completedContent: nextCoverage.completedContent,
|
||||
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(
|
||||
plan: VersionPlan,
|
||||
input: ResearchDirectionProgressUpdateInput,
|
||||
@@ -351,6 +417,7 @@ export function updateResearchDirectionProgress(
|
||||
}
|
||||
|
||||
const updatedAt = input.updatedAt ?? new Date().toISOString();
|
||||
const workStartedAt = input.workStartedAt?.trim() || target.currentWorkStartedAt;
|
||||
const nextStatus: PlanTaskStatus = input.status === 'completed' ? 'completed' : 'in_progress';
|
||||
const completedContent = input.status === 'partial' ? input.completedContent?.trim() || undefined : undefined;
|
||||
const remainingContent = input.status === 'partial' ? input.remainingContent?.trim() || undefined : undefined;
|
||||
@@ -360,6 +427,7 @@ export function updateResearchDirectionProgress(
|
||||
status: nextStatus,
|
||||
completedContent,
|
||||
remainingContent,
|
||||
currentWorkStartedAt: undefined,
|
||||
updatedAt,
|
||||
updatedBy: input.updatedBy,
|
||||
}
|
||||
@@ -382,6 +450,7 @@ export function updateResearchDirectionProgress(
|
||||
coverageStatus: input.status,
|
||||
completedContent,
|
||||
remainingContent,
|
||||
workStartedAt,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { VersionPlan } from './version-plan';
|
||||
import {
|
||||
makeBugStatusActivity,
|
||||
makeDevTaskStatusActivity,
|
||||
makeProgressNoteActivity,
|
||||
makeTestCaseStatusActivity,
|
||||
makeVersionPlanCompletedActivity,
|
||||
makeVersionPlanRequirementProgressActivity,
|
||||
@@ -120,6 +121,7 @@ test('makeVersionPlanRequirementProgressActivity records partial requirement cov
|
||||
coverageStatus: 'partial',
|
||||
completedContent: '头像和基本信息',
|
||||
remainingContent: '安全设置入口',
|
||||
workStartedAt: '2026-06-30T01:30:00.000Z',
|
||||
});
|
||||
|
||||
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?.metadata?.completedContent, '头像和基本信息');
|
||||
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', () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Bug, BugStatus } from './bug';
|
||||
import type { DevTask, DevTaskStatus } from './dev-task';
|
||||
import type { TestCase, TestCaseStatus } from './test-case';
|
||||
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> = {
|
||||
research: '调研',
|
||||
@@ -69,6 +69,7 @@ export function makeVersionPlanRequirementProgressActivity(plan: VersionPlan, lo
|
||||
coverageStatus: log.coverageStatus,
|
||||
completedContent: log.completedContent,
|
||||
remainingContent: log.remainingContent,
|
||||
workStartedAt: log.workStartedAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -95,6 +96,50 @@ export function makeVersionPlanResearchDirectionProgressActivity(plan: VersionPl
|
||||
coverageStatus: log.coverageStatus,
|
||||
completedContent: log.completedContent,
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ test('makeVersionPlanResearchDirectionProgressActivity records direction progres
|
||||
coverageStatus: 'partial',
|
||||
completedContent: '完成登录流程对比',
|
||||
remainingContent: '补充支付流程差异',
|
||||
workStartedAt: '2026-06-30T09:30:00.000Z',
|
||||
});
|
||||
|
||||
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?.completedContent, '完成登录流程对比');
|
||||
assert.equal(activity?.metadata?.remainingContent, '补充支付流程差异');
|
||||
assert.equal(activity?.metadata?.workStartedAt, '2026-06-30T09:30:00.000Z');
|
||||
});
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface WorkActivity {
|
||||
blocker?: string;
|
||||
helperId?: string;
|
||||
delayRisk?: string;
|
||||
nextStartAt?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -362,6 +362,123 @@ test('getWorkspaceDailyReport does not double count overlapping activity time ra
|
||||
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', () => {
|
||||
const report = getWorkspaceDailyReport({
|
||||
activities: [],
|
||||
@@ -401,6 +518,96 @@ test('getWorkspaceDailyReport rebuilds activity evidence from persisted work ite
|
||||
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', () => {
|
||||
const report = getWorkspaceDailyReport({
|
||||
activities,
|
||||
|
||||
@@ -85,8 +85,11 @@ export function getWorkspaceDailyReport({
|
||||
};
|
||||
});
|
||||
|
||||
const dailyActivities = activities
|
||||
.filter((activity) => activity.actorId === userId && activity.date === date)
|
||||
const userActivities = activities
|
||||
.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));
|
||||
const activityKeys = new Set(dailyActivities.map(getActivityKey));
|
||||
const evidenceSourceIds = new Set([
|
||||
@@ -115,13 +118,11 @@ export function getWorkspaceDailyReport({
|
||||
});
|
||||
}
|
||||
|
||||
const touchedSourceIds = new Set([
|
||||
...reportActivities.map((activity) => activity.sourceId),
|
||||
...items.map((item) => item.taskId),
|
||||
]);
|
||||
const touchedWorkItemIds = buildTouchedWorkItemIds(reportActivities, items, workItems);
|
||||
const worklogTaskIds = new Set(items.map((item) => item.taskId));
|
||||
const activityHours = calcActivityHoursForDate(
|
||||
Array.from(new Set(reportActivities.map((activity) => activity.sourceId))),
|
||||
reportActivities,
|
||||
userActivities,
|
||||
workItemMap,
|
||||
worklogTaskIds,
|
||||
date,
|
||||
@@ -129,7 +130,7 @@ export function getWorkspaceDailyReport({
|
||||
);
|
||||
|
||||
const needsProgressItems = workItems
|
||||
.filter((item) => shouldRequireProgress(item, date, touchedSourceIds))
|
||||
.filter((item) => shouldRequireProgress(item, date, touchedWorkItemIds))
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
@@ -171,9 +172,46 @@ function getRaw(item: WorkItem): 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 (touchedSourceIds.has(item.id)) return false;
|
||||
if (touchedWorkItemIds.has(item.id)) return false;
|
||||
if (!isInProgressStatus(item.status)) return false;
|
||||
|
||||
const startedAt = getStartedAt(item);
|
||||
@@ -299,18 +337,108 @@ function getTestCaseTerminalFallback(
|
||||
}
|
||||
|
||||
function calcActivityHoursForDate(
|
||||
sourceIds: string[],
|
||||
activities: WorkActivity[],
|
||||
allUserActivities: WorkActivity[],
|
||||
workItemMap: Map<string, WorkItem>,
|
||||
excludedSourceIds: Set<string>,
|
||||
date: string,
|
||||
now: Date,
|
||||
): 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) => !recordScopedSourceIds.has(sourceId))
|
||||
.map((sourceId) => getWorkItemIntervalForDate(workItemMap.get(sourceId), date, now))
|
||||
.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 {
|
||||
|
||||
@@ -65,6 +65,7 @@ interface ProductState {
|
||||
reorderProducts: (ids: string[]) => void;
|
||||
migrateAndDeleteProduct: (sourceId: string, targetId: 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;
|
||||
updateVersion: (productId: string, versionId: string, data: Partial<VersionItem>) => void;
|
||||
deleteVersion: (productId: string, versionId: string) => void;
|
||||
@@ -216,6 +217,43 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
||||
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) => {
|
||||
const newVersion: VersionItem = {
|
||||
id: `ver-${Date.now()}`,
|
||||
|
||||
@@ -1,25 +1,15 @@
|
||||
'use client';
|
||||
import { create } from 'zustand';
|
||||
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';
|
||||
|
||||
interface ProgressNoteInput {
|
||||
actorId: string;
|
||||
sourceType: WorkActivitySourceType;
|
||||
sourceId: string;
|
||||
title: string;
|
||||
note: string;
|
||||
blocker?: string;
|
||||
helperId?: string;
|
||||
delayRisk?: string;
|
||||
}
|
||||
|
||||
interface WorkActivityState {
|
||||
activities: WorkActivity[];
|
||||
fetchActivities: () => Promise<void>;
|
||||
addActivity: (data: WorkActivityDraft) => WorkActivity;
|
||||
addProgressNote: (data: ProgressNoteInput) => WorkActivity;
|
||||
addProgressNote: (data: ProgressNoteActivityInput) => WorkActivity;
|
||||
deleteActivity: (id: string) => void;
|
||||
}
|
||||
|
||||
@@ -76,28 +66,7 @@ export const useWorkActivityStore = create<WorkActivityState>((set, get) => ({
|
||||
},
|
||||
|
||||
addProgressNote: (data) => {
|
||||
const details = [
|
||||
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,
|
||||
},
|
||||
});
|
||||
return get().addActivity(makeProgressNoteActivity(data));
|
||||
},
|
||||
|
||||
deleteActivity: (id) => {
|
||||
|
||||
Reference in New Issue
Block a user