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

关键改动:

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

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

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

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

View File

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