feat(版本): 完善研发计划与预警已读

关键改动:

- 增加版本表单、发布校验和调研方向进度规则

- 扩展小宝预警已读状态、风险签名和今日证据

- 补充组长权限、加班查看范围、活动记录与相关测试

Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
Script Generator
2026-06-30 16:48:58 +08:00
parent 04520bd8c5
commit 3d3d56697a
41 changed files with 1701 additions and 121 deletions

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
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 { useProductStore } from '@/stores/useProductStore';
@@ -27,12 +27,14 @@ import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
import { calcGroupProgress as calcDevTaskProgress } from '@/lib/dev-task';
import { hasPermission } from '@/lib/permissions';
import { calcActualElapsedHours, formatActualDuration } from '@/lib/work-hours';
import { formatDateTime } from '@/lib/format';
import { formatDateTime, formatLocalDate } from '@/lib/format';
import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector';
import { calcBugSeverityRanking, calcPersonalEffortRanking, calcStageEffortMetrics, calcVersionOverviewEffortTotals } from '@/lib/version-overview';
import { addVersionMembers, DEFAULT_VERSION_MEMBER_ROLE, filterVersionMemberCandidates } from '@/lib/version-members';
import { addRecommendedVersionMembers, getDefaultRecommendedMemberNames, recommendVersionMembers, type MemberRecommendationGroup, type RecommendableRole } from '@/lib/member-recommendation';
import { getRequirementCoverageSummary } from '@/lib/version-plan';
import { buildVersionProgressMap } from '@/lib/version-progress';
import { canSubmitReleaseForm, getReleaseProgressWarning } from '@/lib/version-release';
function formatOverviewDateTime(value?: string | null): string {
if (!value) return '-';
@@ -104,6 +106,7 @@ export default function VersionDetailPage() {
}, [visibleTabs, activeTab]);
const [showMemberModal, setShowMemberModal] = useState(false);
const [showRecommendModal, setShowRecommendModal] = useState(false);
const [showReleaseModal, setShowReleaseModal] = useState(false);
const [recommendationDataReady, setRecommendationDataReady] = useState(false);
useEffect(() => { fetchOverview(); }, [fetchOverview]);
@@ -123,6 +126,11 @@ export default function VersionDetailPage() {
}, [fetchMembers, fetchCategories]);
const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]);
const releaseProgressMap = useMemo<Record<string, number>>(
() => version ? buildVersionProgressMap([version], plans, requirements, devTasks, testCases) : {},
[version, plans, requirements, devTasks, testCases],
);
const releaseProgress = version ? (releaseProgressMap[version.id] ?? 0) : 0;
// 自动同步版本状态:有计划开始时间<=今天,版本应进入对应阶段
useEffect(() => {
@@ -199,7 +207,10 @@ export default function VersionDetailPage() {
});
const renderActions = () => {
const buttons: { label: string; action: () => void; danger?: boolean }[] = [];
const buttons: { label: string; action: () => void; danger?: boolean; tone?: 'release' }[] = [];
if (version.status !== 'released' && version.status !== 'closed') {
buttons.push({ label: '发版', action: () => setShowReleaseModal(true), tone: 'release' });
}
if (version.status === 'planned') {
buttons.push({ label: '删除', action: () => {
if (confirm('确认删除该版本关联的需求会回到需求池版本下的计划、开发任务、测试用例、Bug 将被清除。')) {
@@ -229,7 +240,7 @@ 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' : 'border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
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)]'}`}
>
{btn.label}
</button>
@@ -441,10 +452,11 @@ export default function VersionDetailPage() {
const isTerminalVersion = version.status === 'released' || version.status === 'closed';
const deadline = version.expectedReleaseDate;
const actualReleaseDate = version.releaseDate;
const versionActualHours = calcActualElapsedHours(actualStartIso, isTerminalVersion ? actualEndIso : now.toISOString());
let overdueDays = 0;
if (deadline && isTerminalVersion && actualEndIso) {
const endDate = new Date(actualEndIso);
if (deadline && actualReleaseDate) {
const endDate = new Date(actualReleaseDate);
const deadlineDate = new Date(deadline);
endDate.setHours(0, 0, 0, 0);
deadlineDate.setHours(0, 0, 0, 0);
@@ -453,7 +465,8 @@ export default function VersionDetailPage() {
const metrics = [
{ label: '开始', value: actualStartIso ? formatOverviewDateTime(actualStartIso) : '未开始', icon: <Calendar className="h-3.5 w-3.5" /> },
{ label: '预计截止', value: deadline ?? '未设置' },
{ label: '期望发版', value: deadline ? formatOverviewDateTime(deadline) : '未设置' },
{ label: '实际发版日期', value: actualReleaseDate ? formatOverviewDateTime(actualReleaseDate) : '未发布' },
{ label: '实际截止', value: isTerminalVersion ? (actualEndIso ? formatOverviewDateTime(actualEndIso) : '未记录') : '未完成' },
{ label: '实际耗时', value: formatActualDuration(versionActualHours), icon: <Clock className="h-3.5 w-3.5" /> },
{ label: '人力总投入', value: formatActualDuration(effortTotals.actualHours), tone: 'accent' as const },
@@ -462,7 +475,7 @@ export default function VersionDetailPage() {
return (
<div className="overflow-hidden rounded-xl border border-[var(--line)] bg-[var(--bg-card)]">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-7">
{metrics.map((item) => (
<OverviewMetric
key={item.label}
@@ -476,7 +489,7 @@ export default function VersionDetailPage() {
</div>
{overdueDays > 0 && (
<div className="border-t border-red-100 bg-red-50 px-4 py-2 text-[12px] font-medium text-red-600">
{overdueDays}
{overdueDays}
</div>
)}
</div>
@@ -930,6 +943,18 @@ export default function VersionDetailPage() {
/>
)}
{showReleaseModal && (
<ReleaseVersionModal
progress={releaseProgress}
initialDate={formatLocalDate()}
onSubmit={(releaseDate) => {
updateVersion(version.productId, version.id, { status: 'released', releaseDate });
setShowReleaseModal(false);
}}
onClose={() => setShowReleaseModal(false)}
/>
)}
{/* 参与人员设置弹窗 */}
{showMemberModal && (
<MemberSettingModal
@@ -946,6 +971,79 @@ export default function VersionDetailPage() {
);
}
function ReleaseVersionModal({ progress, initialDate, onSubmit, onClose }: {
progress: number;
initialDate: string;
onSubmit: (releaseDate: string) => void;
onClose: () => void;
}) {
const [releaseDate, setReleaseDate] = useState(initialDate);
const warning = getReleaseProgressWarning(progress);
const canSubmit = canSubmitReleaseForm(releaseDate);
const handleSubmit = (event: FormEvent) => {
event.preventDefault();
if (!canSubmit) return;
onSubmit(releaseDate.trim());
};
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 px-4" onClick={onClose}>
<form
onSubmit={handleSubmit}
className="w-full max-w-md 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)]"></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">
{warning ? (
<div className="flex gap-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2.5 text-[12px] leading-5 text-amber-800">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{warning}</span>
</div>
) : (
<div className="rounded-xl border border-emerald-100 bg-emerald-50 px-3 py-2.5 text-[12px] font-medium text-emerald-700">
100%
</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>
<input
type="date"
value={releaseDate}
onChange={(event) => setReleaseDate(event.target.value)}
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] outline-none focus:border-[var(--accent)]"
required
/>
</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-emerald-600 px-4 text-[12px] font-semibold text-white hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50">
</button>
</div>
</form>
</div>
);
}
function OverviewMetric({ label, value, icon, sub, tone }: {
label: string;
value: string | number;