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 { 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({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()}`,
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user