feat: 版本详情完整功能 + 数据串联 + 登录模块

- 版本详情:关联需求Tab(从需求池添加已采纳需求/移除释放)
- 版本详情:调研/产品方案/UI设计Tab(计划CRUD、完成提交成果、耗时统计)
- 版本详情:超期校验(结束日期超版本截止需填写原因)
- 版本详情:概览统计卡片(加班时长/排名/原因占比)
- 数据串联:产品→项目→版本→需求→加班全链路贯通
- 版本管理:规划中版本可删除,删除释放关联需求
- 数据清理:仅保留翻台宝/值班,需求池10条值班待评审需求
- 修复:列表页overflow裁剪菜单问题(4个页面统一修复)
- 新增:登录模块、AuthGuard、VersionPlan store

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Script Generator
2026-06-10 17:07:36 +08:00
parent 2f69bc74cd
commit 2acc9aeaa9
21 changed files with 1278 additions and 323 deletions

View File

@@ -135,9 +135,9 @@ export default function MembersPage() {
<p className="text-[13px] font-medium text-[var(--ink-soft)]"></p>
</div>
) : (
<div className="overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
<div className="rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
<table className="w-full text-left text-[13px]">
<thead>
<thead className="sticky top-0 z-10 bg-[var(--bg-subtle)]">
<tr className="border-b border-[var(--line)] bg-[var(--bg-subtle)]">
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>

View File

@@ -1,6 +1,6 @@
import './globals.css';
import type { Metadata } from 'next';
import { Sidebar } from '@/components/layout/Sidebar';
import { LayoutShell } from '@/components/layout/LayoutShell';
export const metadata: Metadata = {
title: 'FTB 项目管理',
@@ -14,9 +14,8 @@ export default function RootLayout({
}) {
return (
<html lang="zh-CN">
<body className="flex h-screen overflow-hidden bg-gray-50">
<Sidebar />
<main className="flex-1 overflow-y-auto">{children}</main>
<body className="h-screen overflow-hidden bg-gray-50">
<LayoutShell>{children}</LayoutShell>
</body>
</html>
);

124
apps/web/app/login/page.tsx Normal file
View File

@@ -0,0 +1,124 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/useAuthStore';
import { LayoutGrid, Phone, Lock, Eye, EyeOff } from 'lucide-react';
export default function LoginPage() {
const router = useRouter();
const { login } = useAuthStore();
const [phone, setPhone] = useState('');
const [password, setPassword] = useState('');
const [remember, setRemember] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!phone.trim() || !password.trim()) {
setError('请输入手机号和密码');
return;
}
setLoading(true);
setTimeout(() => {
const success = login(phone.trim(), password, remember);
if (success) {
router.push('/products');
} else {
setError('手机号或密码错误');
}
setLoading(false);
}, 300);
};
return (
<div className="flex min-h-screen items-center justify-center bg-[var(--bg)]">
<div className="w-full max-w-sm">
{/* Logo */}
<div className="mb-8 flex flex-col items-center">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent)] shadow-lg shadow-blue-500/20">
<LayoutGrid className="h-6 w-6 text-white" strokeWidth={2} />
</div>
<h1 className="mt-4 text-[18px] font-semibold text-[var(--ink)]">FTB </h1>
<p className="mt-1 text-[13px] text-[var(--ink-muted)]">使</p>
</div>
{/* Form */}
<div className="rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] p-6 shadow-[var(--shadow-md)]">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block"></label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--ink-muted)]" />
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="请输入手机号"
maxLength={11}
className="h-10 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-10 pr-3 text-[14px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)] transition-all"
/>
</div>
</div>
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block"></label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--ink-muted)]" />
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入密码"
className="h-10 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-10 pr-10 text-[14px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)] transition-all"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--ink-muted)] hover:text-[var(--ink-soft)]"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<div className="flex items-center">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
className="h-4 w-4 rounded border-[var(--line)] text-[var(--accent)] focus:ring-[var(--accent-ring)]"
/>
<span className="text-[12px] text-[var(--ink-soft)]"></span>
</label>
</div>
{error && (
<div className="rounded-lg bg-red-50 border border-red-100 px-3 py-2 text-[12px] text-red-600">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="h-10 w-full rounded-lg bg-[var(--accent)] text-[14px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-all"
>
{loading ? '登录中...' : '登 录'}
</button>
</form>
<p className="mt-4 text-center text-[11px] text-[var(--ink-muted)]">
13200132008 / Ftb@2024
</p>
</div>
</div>
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Search, Plus, ChevronDown, Clock, Pencil, Trash2, X, Download } from 'lucide-react';
import { useOvertimeStore } from '@/stores/useOvertimeStore';
import { useProductStore } from '@/stores/useProductStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { flattenProjects, flattenVersions } from '@/lib/derive';
import { calcDuration } from '@/lib/overtime';
import type { OvertimeRecord } from '@/lib/overtime';
@@ -15,6 +16,7 @@ import { FilterSelect } from '@/components/FilterSelect';
export default function OvertimePage() {
const { records, fetchRecords, createRecord, updateRecord, deleteRecord, reasons, addReason, updateReason, deleteReason } = useOvertimeStore();
const { overview, fetchOverview } = useProductStore();
const { requirements, fetchRequirements } = useRequirementStore();
const allProjects = useMemo(() => flattenProjects(overview), [overview]);
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
@@ -27,6 +29,7 @@ export default function OvertimePage() {
const [showReasonDrawer, setShowReasonDrawer] = useState(false);
useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
useEffect(() => { fetchRecords(); }, [fetchRecords]);
const projectName = (id: string) => allProjects.find((p) => p.id === id)?.name ?? '-';
@@ -124,9 +127,9 @@ export default function OvertimePage() {
</div>
) : (
<>
<div className="overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
<div className="rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
<table className="w-full text-left text-[13px]">
<thead className="sticky top-0 z-10">
<thead className="sticky top-0 z-10 bg-[var(--bg-subtle)]">
<tr className="border-b border-[var(--line)] bg-[var(--bg-subtle)]">
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
@@ -182,6 +185,7 @@ export default function OvertimePage() {
projects={allProjects.map((p) => ({ id: p.id, name: p.name, productId: p.productId }))}
versions={allVersions}
reasons={reasons}
requirements={requirements.map((r) => ({ id: r.id, title: r.title, versionId: r.versionId }))}
onClose={() => setShowModal(false)}
onSubmit={(data) => {
if (editing) updateRecord(editing.id, data);
@@ -199,12 +203,13 @@ export default function OvertimePage() {
);
}
function OvertimeModal({ initial, products, projects, versions, reasons, onClose, onSubmit }: {
function OvertimeModal({ initial, products, projects, versions, reasons, requirements, onClose, onSubmit }: {
initial: OvertimeRecord | null;
products: { id: string; name: string }[];
projects: { id: string; name: string; productId: string }[];
versions: { id: string; name: string; projectId?: string }[];
reasons: { id: string; name: string }[];
requirements: { id: string; title: string; versionId?: string }[];
onClose: () => void;
onSubmit: (data: any) => void;
}) {
@@ -216,6 +221,7 @@ function OvertimeModal({ initial, products, projects, versions, reasons, onClose
const [endTime, setEndTime] = useState(initial?.endTime ?? '');
const [reasonId, setReasonId] = useState(initial?.reasonId ?? '');
const [remark, setRemark] = useState(initial?.remark ?? '');
const [requirementId, setRequirementId] = useState(initial?.requirementId ?? '');
// 初始编辑时反推 productId
useEffect(() => {
@@ -228,11 +234,12 @@ function OvertimeModal({ initial, products, projects, versions, reasons, onClose
const duration = startTime && endTime ? calcDuration(startTime, endTime) : 0;
const filteredProjects = productId ? projects.filter((p) => p.productId === productId) : projects;
const filteredVersions = projectId ? versions.filter((v) => (v as any).projectId === projectId) : [];
const filteredRequirements = versionId ? requirements.filter((r) => r.versionId === versionId) : [];
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!projectId || !person.trim() || !startTime || !endTime || !reasonId) return;
onSubmit({ projectId, versionId: versionId || undefined, person: person.trim(), startTime, endTime, reasonId, remark: remark.trim() || undefined });
onSubmit({ projectId, versionId: versionId || undefined, requirementId: requirementId || undefined, person: person.trim(), startTime, endTime, reasonId, remark: remark.trim() || undefined });
};
return (
@@ -292,6 +299,15 @@ function OvertimeModal({ initial, products, projects, versions, reasons, onClose
{reasons.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
</div>
{filteredRequirements.length > 0 && (
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"></label>
<select value={requirementId} onChange={(e) => setRequirementId(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none">
<option value=""></option>
{filteredRequirements.map((r) => <option key={r.id} value={r.id}>{r.title}</option>)}
</select>
</div>
)}
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"></label>
<textarea value={remark} onChange={(e) => setRemark(e.target.value)} rows={2} placeholder="可选" className="w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 py-2 text-[13px] focus:border-[var(--accent)] focus:outline-none resize-none" />

View File

@@ -6,6 +6,7 @@ import { ChevronLeft, Pencil, Trash2 } from 'lucide-react';
import type { RequirementStatus } from '@/lib/requirement';
import { useProductStore } from '@/stores/useProductStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useOvertimeStore } from '@/stores/useOvertimeStore';
import { RequirementTable } from '@/components/product/RequirementTable';
import { RequirementForm } from '@/components/product/RequirementForm';
import { ProductForm } from '@/components/product/ProductForm';
@@ -27,6 +28,7 @@ export default function ProductDetailPage() {
fetchRequirements,
deleteRequirement,
} = useRequirementStore();
const { records, fetchRecords } = useOvertimeStore();
const [showReqForm, setShowReqForm] = useState(false);
const [editingReq, setEditingReq] = useState<any>(null);
@@ -37,7 +39,8 @@ export default function ProductDetailPage() {
useEffect(() => {
fetchProduct(productId);
fetchRequirements();
}, [productId, fetchProduct, fetchRequirements]);
fetchRecords();
}, [productId, fetchProduct, fetchRequirements, fetchRecords]);
const productRequirements = useMemo(() => {
let list = requirements.filter((r) => r.productId === productId);
@@ -112,6 +115,27 @@ export default function ProductDetailPage() {
{currentProduct.description && (
<p className="line-clamp-2 py-3 text-[13px] text-[var(--ink-soft)]">{currentProduct.description}</p>
)}
{/* 汇总统计 */}
<div className="flex gap-4 py-3 border-b border-[var(--line-soft)]">
{(() => {
const allReqs = requirements.filter((r) => r.productId === productId);
const adopted = allReqs.filter((r) => r.status === 'adopted' || r.status === 'planned').length;
const developing = allReqs.filter((r) => r.status === 'developing' || r.status === 'testing').length;
const released = allReqs.filter((r) => r.status === 'released').length;
const projectReqs = allReqs.filter((r) => r.projectId);
const projectIds = [...new Set(projectReqs.map((r) => r.projectId!))];
const overtimeHours = Math.round(records.filter((r) => projectIds.includes(r.projectId)).reduce((sum, r) => sum + r.duration, 0) * 10) / 10;
return (
<>
<div className="text-center"><div className="text-[16px] font-semibold text-[var(--ink)]">{allReqs.length}</div><div className="text-[11px] text-[var(--ink-muted)]"></div></div>
<div className="text-center"><div className="text-[16px] font-semibold text-blue-600">{adopted}</div><div className="text-[11px] text-[var(--ink-muted)]"></div></div>
<div className="text-center"><div className="text-[16px] font-semibold text-amber-600">{developing}</div><div className="text-[11px] text-[var(--ink-muted)]"></div></div>
<div className="text-center"><div className="text-[16px] font-semibold text-green-600">{released}</div><div className="text-[11px] text-[var(--ink-muted)]"></div></div>
<div className="text-center"><div className="text-[16px] font-semibold text-red-500">{overtimeHours}h</div><div className="text-[11px] text-[var(--ink-muted)]"></div></div>
</>
);
})()}
</div>
<div className="flex gap-1">
{TABS.map((tab) => (
<button

View File

@@ -4,6 +4,8 @@ import { useEffect, useMemo, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { ChevronLeft, Package, Calendar, Clock, Users, Tag, ChevronDown } from 'lucide-react';
import { useProductStore } from '@/stores/useProductStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useOvertimeStore } from '@/stores/useOvertimeStore';
import { getProjectDetail, VersionWithContext } from '@/lib/derive';
import { Stage, Role, STAGES, ROLES, STAGE_INDEX, ROLE_LABEL } from '@/lib/stage';
import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_BG } from '@/lib/version-status';
@@ -37,7 +39,7 @@ function ProgressBar({ role, percent, daysSpent }: { role: Role; percent: number
}
/* ─── VersionCard ─── */
function VersionCard({ version }: { version: VersionWithContext }) {
function VersionCard({ version, onNavigate }: { version: VersionWithContext; onNavigate: (id: string) => void }) {
const [expanded, setExpanded] = useState(false);
const totalDays = (version.progress ?? []).reduce((sum, p) => sum + p.daysSpent, 0);
@@ -49,7 +51,7 @@ function VersionCard({ version }: { version: VersionWithContext }) {
return (
<div className="rounded-xl border border-dashed border-[var(--line)] px-4 py-3">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-[var(--ink)]">{version.name}</span>
<span onClick={() => onNavigate(version.id)} className="text-sm font-medium text-[var(--ink)] cursor-pointer hover:text-[var(--accent)]">{version.name}</span>
<span className={`text-[11px] px-2 py-0.5 rounded-full ${displayBg}`}>{displayStatus}</span>
<span className="text-[11px] text-[var(--ink-muted)]"></span>
</div>
@@ -65,7 +67,7 @@ function VersionCard({ version }: { version: VersionWithContext }) {
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-[var(--bg-hover)] transition-colors"
>
<span className="text-sm font-medium text-[var(--ink)]">{version.name}</span>
<span onClick={(e) => { e.stopPropagation(); onNavigate(version.id); }} className="text-sm font-medium text-[var(--ink)] cursor-pointer hover:text-[var(--accent)]">{version.name}</span>
<span className={`text-[11px] px-2 py-0.5 rounded-full ${displayBg}`}>{displayStatus}</span>
<span className="flex-1 text-[11px] text-[var(--ink-muted)] flex items-center gap-1">
<Calendar className="h-3 w-3" />
@@ -87,7 +89,7 @@ function VersionCard({ version }: { version: VersionWithContext }) {
return (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 shadow-sm">
<div className="flex items-center gap-2 mb-3">
<span className="text-sm font-medium text-[var(--ink)]">{version.name}</span>
<span onClick={() => onNavigate(version.id)} className="text-sm font-medium text-[var(--ink)] cursor-pointer hover:text-[var(--accent)]">{version.name}</span>
<span className={`text-[11px] px-2 py-0.5 rounded-full ${displayBg}`}>{displayStatus}</span>
</div>
<div className="mb-3"><CapsuleStages currentStage={version.currentStage} progress={version.progress} /></div>
@@ -173,9 +175,13 @@ export default function ProjectDetailPage() {
const router = useRouter();
const projectId = params.id as string;
const { overview, fetchOverview } = useProductStore();
const { requirements, fetchRequirements } = useRequirementStore();
const { records, fetchRecords } = useOvertimeStore();
const [statusFilter, setStatusFilter] = useState<string>('all');
useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
useEffect(() => { fetchRecords(); }, [fetchRecords]);
const project = useMemo(() => getProjectDetail(overview, projectId), [overview, projectId]);
@@ -195,13 +201,15 @@ export default function ProjectDetailPage() {
}, [project, statusFilter]);
const stats = useMemo(() => {
if (!project) return { total: 0, developing: 0, released: 0, totalDays: 0 };
if (!project) return { total: 0, developing: 0, released: 0, totalDays: 0, reqCount: 0, overtimeHours: 0 };
const total = project.versions.length;
const developing = project.versions.filter((v) => v.status === 'developing').length;
const released = project.versions.filter((v) => v.status === 'released').length;
const totalDays = project.versions.reduce((sum, v) => sum + (v.progress ?? []).reduce((s, p) => s + p.daysSpent, 0), 0);
return { total, developing, released, totalDays };
}, [project]);
const reqCount = requirements.filter((r) => r.projectId === projectId).length;
const overtimeHours = Math.round(records.filter((r) => r.projectId === projectId).reduce((sum, r) => sum + r.duration, 0) * 10) / 10;
return { total, developing, released, totalDays, reqCount, overtimeHours };
}, [project, requirements, records, projectId]);
const teamByRole = useMemo(() => {
if (!project) return {} as Record<string, Record<string, number>>;
@@ -242,11 +250,13 @@ export default function ProjectDetailPage() {
<div className="flex-1 overflow-y-auto p-5 bg-[var(--bg)]">
<div className="space-y-5">
<div className="grid grid-cols-4 gap-4">
<div className="grid grid-cols-6 gap-4">
<StatCard value={stats.total} label="总版本数" />
<StatCard value={stats.developing} label="进行中" />
<StatCard value={stats.released} label="已发布" />
<StatCard value={stats.totalDays} label="总耗时(天)" />
<StatCard value={stats.reqCount} label="需求数" />
<StatCard value={stats.overtimeHours} label="加班(h)" />
</div>
<TeamSection teamByRole={teamByRole} />
@@ -273,7 +283,7 @@ export default function ProjectDetailPage() {
{sortedVersions.length === 0 ? (
<div className="rounded-xl border border-dashed border-[var(--line)] p-6 text-center text-xs text-[var(--ink-muted)]"></div>
) : (
sortedVersions.map((v) => <VersionCard key={v.id} version={v} />)
sortedVersions.map((v) => <VersionCard key={v.id} version={v} onNavigate={(id) => router.push(`/versions/${id}`)} />)
)}
</div>
</section>

View File

@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Search, Plus, Lightbulb, ArrowUp, ArrowDown } from 'lucide-react';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useProductStore } from '@/stores/useProductStore';
import { flattenProjects } from '@/lib/derive';
import { flattenProjects, flattenVersions } from '@/lib/derive';
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, SOURCE_TYPE_LABEL } from '@/lib/requirement';
import type { Requirement, RequirementStatus, SourceType } from '@/lib/requirement';
import { Pagination, usePagination } from '@/components/Pagination';
@@ -43,12 +43,14 @@ export default function RequirementsPage() {
} = useRequirementStore();
const { overview, fetchOverview } = useProductStore();
const allProjects = useMemo(() => flattenProjects(overview), [overview]);
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [projectFilter, setProjectFilter] = useState('all');
const [priorityFilter, setPriorityFilter] = useState('all');
const [typeFilter, setTypeFilter] = useState('all');
const [versionFilter, setVersionFilter] = useState('all');
const [showModal, setShowModal] = useState(false);
const [editingReq, setEditingReq] = useState<Requirement | null>(null);
@@ -101,6 +103,9 @@ export default function RequirementsPage() {
if (typeFilter !== 'all') {
list = list.filter((r) => r.typeId === typeFilter);
}
if (versionFilter !== 'all') {
list = list.filter((r) => r.versionId === versionFilter);
}
// sort by createdAt
list.sort((a, b) => {
@@ -109,7 +114,7 @@ export default function RequirementsPage() {
});
return list;
}, [requirements, search, statusFilter, projectFilter, priorityFilter, typeFilter, dateSort]);
}, [requirements, search, statusFilter, projectFilter, priorityFilter, typeFilter, versionFilter, dateSort]);
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20);
@@ -211,6 +216,15 @@ export default function RequirementsPage() {
placeholder="全部类型"
allLabel="全部类型"
/>
{/* Version dropdown */}
<FilterSelect
value={versionFilter}
onChange={setVersionFilter}
options={allVersions.map((v) => ({ value: v.id, label: v.name }))}
placeholder="全部版本"
allLabel="全部版本"
/>
</div>
{/* Content */}
@@ -223,9 +237,9 @@ export default function RequirementsPage() {
</div>
) : (
<>
<div className="overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
<div className="rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
<table className="w-full text-left text-[13px]">
<thead className="sticky top-0 z-10">
<thead className="sticky top-0 z-10 bg-[var(--bg-subtle)]">
<tr className="border-b border-[var(--line)] bg-[var(--bg-subtle)]">
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
@@ -400,6 +414,7 @@ export default function RequirementsPage() {
initial={editingReq}
products={overview.map((p) => ({ id: p.id, name: p.name }))}
projects={allProjects.map((p) => ({ id: p.id, name: p.name, productId: p.productId }))}
versions={allVersions.map((v) => ({ id: v.id, name: v.name, projectId: v.projectId }))}
sourceTargets={sourceTargets}
types={types}
platforms={platforms}

View File

@@ -4,6 +4,8 @@ import { useEffect, useMemo, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { ChevronLeft, Package, Calendar, Clock, ExternalLink, FileText, Palette, Layout } from 'lucide-react';
import { useProductStore } from '@/stores/useProductStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useOvertimeStore } from '@/stores/useOvertimeStore';
import { getVersionDetail } from '@/lib/derive';
import { STAGES } from '@/lib/stage';
import { VERSION_STATUS_LABEL, VERSION_STATUS_BG } from '@/lib/version-status';
@@ -11,6 +13,12 @@ import { CapsuleStages } from '@/components/version/CapsuleStages';
import { MemberChips } from '@/components/version/MemberChips';
import { HealthTrend, generateMockTrend } from '@/components/version/HealthTrend';
import { calcHealthScore, getHealthLevel, calcRiskTags, HEALTH_LEVEL_COLOR, HEALTH_LEVEL_DOT, HEALTH_LEVEL_LABEL, getTagStyle } from '@/lib/health';
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR } from '@/lib/requirement';
import { OVERTIME_REASON_LABEL } from '@/lib/overtime';
import { VersionRequirementsTab } from '@/components/version/VersionRequirementsTab';
import { PlanTab } from '@/components/version/PlanTab';
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
import { useAuthStore } from '@/stores/useAuthStore';
const PRIORITY_STYLE: Record<string, string> = {
P0: 'bg-red-500/10 text-red-600',
@@ -22,20 +30,30 @@ const PRIORITY_STYLE: Record<string, string> = {
const TABS = [
{ key: 'overview', label: '概览' },
{ key: 'requirements', label: '需求' },
{ key: 'requirements', label: '关联需求' },
{ key: 'research', label: '调研' },
{ key: 'product', label: '产品方案' },
{ key: 'ui', label: 'UI设计' },
{ key: 'tasks', label: '开发任务' },
{ key: 'testcases', label: '测试用例' },
{ key: 'bugs', label: 'Bug' },
{ key: 'bugs', label: 'BUG' },
];
export default function VersionDetailPage() {
const params = useParams();
const router = useRouter();
const versionId = params.id as string;
const { overview, fetchOverview } = useProductStore();
const { overview, fetchOverview, updateVersion, deleteVersion } = useProductStore();
const { requirements, fetchRequirements, updateRequirement } = useRequirementStore();
const { records, fetchRecords } = useOvertimeStore();
const { plans, fetchPlans, createPlan, updatePlan, completePlan, deletePlan } = useVersionPlanStore();
const user = useAuthStore((s) => s.user);
const [activeTab, setActiveTab] = useState('overview');
useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
useEffect(() => { fetchRecords(); }, [fetchRecords]);
useEffect(() => { fetchPlans(); }, [fetchPlans]);
const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]);
@@ -63,12 +81,22 @@ export default function VersionDetailPage() {
const renderActions = () => {
const buttons: { label: string; action: () => void; danger?: boolean }[] = [];
if (version.status === 'developing' || version.status === 'planned') {
buttons.push({ label: '暂停', action: () => {} });
buttons.push({ label: '关闭', action: () => {}, danger: true });
if (version.status === 'planned') {
buttons.push({ label: '删除', action: () => {
if (confirm('确认删除该版本?关联的需求会回到需求池。')) {
// 释放关联需求
requirements.filter((r) => r.versionId === version.id).forEach((r) => updateRequirement(r.id, { versionId: undefined, addedToVersionBy: undefined }));
deleteVersion(version.productId, version.id);
router.push('/versions');
}
}, danger: true });
buttons.push({ label: '关闭', action: () => updateVersion(version.productId, version.id, { status: 'closed' }), danger: true });
} else if (version.status === 'developing') {
buttons.push({ label: '暂停', action: () => updateVersion(version.productId, version.id, { status: 'paused' }) });
buttons.push({ label: '关闭', action: () => updateVersion(version.productId, version.id, { status: 'closed' }), danger: true });
} else if (version.status === 'paused') {
buttons.push({ label: '恢复', action: () => {} });
buttons.push({ label: '关闭', action: () => {}, danger: true });
buttons.push({ label: '恢复', action: () => updateVersion(version.productId, version.id, { status: 'developing' }) });
buttons.push({ label: '关闭', action: () => updateVersion(version.productId, version.id, { status: 'closed' }), danger: true });
}
return buttons.map((btn) => (
<button
@@ -111,117 +139,224 @@ export default function VersionDetailPage() {
{/* Content */}
<div className="flex-1 overflow-y-auto p-5 bg-[var(--bg)]">
{activeTab === 'overview' ? (
<div className="space-y-4">
{/* Tag row */}
<div className="flex flex-wrap items-center gap-2">
{version.priority && (
<span className={`text-[11px] font-semibold px-2 py-0.5 rounded-full ${PRIORITY_STYLE[version.priority] || PRIORITY_STYLE.P3}`}>
{version.priority}
</span>
)}
<span className={`text-[11px] px-2 py-0.5 rounded-full ${VERSION_STATUS_BG[version.status]}`}>
{VERSION_STATUS_LABEL[version.status]}
</span>
<span className={`inline-flex items-center gap-1.5 text-[11px] font-semibold tabular-nums px-2 py-0.5 rounded-full ${healthLevel === 'critical' ? 'bg-red-50' : healthLevel === 'risk' ? 'bg-orange-50' : healthLevel === 'attention' ? 'bg-amber-50' : 'bg-emerald-50'} ${HEALTH_LEVEL_COLOR[healthLevel]}`}>
<span className={`h-1.5 w-1.5 rounded-full ${HEALTH_LEVEL_DOT[healthLevel]}`} />
{healthScore} {HEALTH_LEVEL_LABEL[healthLevel]}
</span>
{riskTags.map((tag) => (
<span key={tag.key} className={`inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-medium ${getTagStyle(tag.severity)}`}>
{tag.label}
</span>
))}
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--bg-subtle)] px-2.5 py-0.5 text-[11px] text-[var(--ink-soft)]">
<Package className="h-3 w-3" />{version.productName} / {version.projectName}
</span>
</div>
(() => {
const versionReqs = requirements.filter((r) => r.versionId === version.id);
const versionOT = records.filter((r) => r.versionId === version.id);
const totalOTHours = Math.round(versionOT.reduce((sum, r) => sum + r.duration, 0) * 10) / 10;
{/* Capsule stages */}
<CapsuleStages currentStage={version.currentStage} progress={version.progress} />
// 人员加班排名
const personOT: Record<string, number> = {};
versionOT.forEach((r) => { personOT[r.person] = (personOT[r.person] || 0) + r.duration; });
const otRanking = Object.entries(personOT).sort((a, b) => b[1] - a[1]).map(([name, hours]) => ({ name, hours: Math.round(hours * 10) / 10 }));
{/* 风险详情 + 趋势健康度低于60时显示 */}
{healthScore < 60 && riskTags.length > 0 && (
<div className="grid grid-cols-4 gap-3">
{/* 左:风险详情(可滚动) */}
<div className="col-span-3 rounded-xl border border-orange-200 bg-orange-50/40 p-4 max-h-[200px] overflow-y-auto">
<div className="flex items-center gap-2 mb-3">
<span className="text-[12px] font-semibold text-orange-700"></span>
<span className="text-[10px] text-orange-600"> {healthScore} · {HEALTH_LEVEL_LABEL[healthLevel]}</span>
</div>
<div className="space-y-2">
{riskTags.map((tag) => (
<div key={tag.key} className="flex gap-2.5 pb-2 border-b border-orange-100 last:border-b-0 last:pb-0">
<span className={`shrink-0 inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-medium h-fit mt-0.5 ${getTagStyle(tag.severity)}`}>
{tag.label}
</span>
<div className="flex-1 space-y-0.5 text-[11px]">
{tag.reason && <div className="text-[var(--ink-soft)]"><span className="text-[var(--ink-muted)]"></span>{tag.reason}</div>}
{tag.suggestion && <div className="text-[var(--ink-soft)]"><span className="text-[var(--ink-muted)]"></span>{tag.suggestion}</div>}
// 加班原因占比
const reasonMap: Record<string, number> = {};
versionOT.forEach((r) => { reasonMap[r.reasonId] = (reasonMap[r.reasonId] || 0) + r.duration; });
const reasonRanking = Object.entries(reasonMap).sort((a, b) => b[1] - a[1]);
const reasonTotal = reasonRanking.reduce((s, [, v]) => s + v, 0) || 1;
// Mock 数据占位
const mockTaskCount = Math.max(versionReqs.length * 2, 3);
const mockBugTotal = Math.max(Math.floor(versionReqs.length * 1.5), 2);
const mockBugOpen = Math.max(Math.floor(mockBugTotal * 0.3), 1);
const mockTaskPending = Math.max(Math.floor(mockTaskCount * 0.4), 1);
return (
<div className="space-y-4">
{/* Tag row */}
<div className="flex flex-wrap items-center gap-2">
{version.priority && (
<span className={`text-[11px] font-semibold px-2 py-0.5 rounded-full ${PRIORITY_STYLE[version.priority] || PRIORITY_STYLE.P3}`}>
{version.priority}
</span>
)}
<span className={`text-[11px] px-2 py-0.5 rounded-full ${VERSION_STATUS_BG[version.status]}`}>
{VERSION_STATUS_LABEL[version.status]}
</span>
<span className={`inline-flex items-center gap-1.5 text-[11px] font-semibold tabular-nums px-2 py-0.5 rounded-full ${healthLevel === 'critical' ? 'bg-red-50' : healthLevel === 'risk' ? 'bg-orange-50' : healthLevel === 'attention' ? 'bg-amber-50' : 'bg-emerald-50'} ${HEALTH_LEVEL_COLOR[healthLevel]}`}>
<span className={`h-1.5 w-1.5 rounded-full ${HEALTH_LEVEL_DOT[healthLevel]}`} />
{healthScore} {HEALTH_LEVEL_LABEL[healthLevel]}
</span>
{riskTags.map((tag) => (
<span key={tag.key} className={`inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-medium ${getTagStyle(tag.severity)}`}>
{tag.label}
</span>
))}
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--bg-subtle)] px-2.5 py-0.5 text-[11px] text-[var(--ink-soft)]">
<Package className="h-3 w-3" />{version.productName} / {version.projectName}
</span>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-6 gap-3">
<StatCard label="加班时长" value={`${totalOTHours}h`} accent />
<StatCard label="关联需求" value={versionReqs.length} />
<StatCard label="待开发任务" value={mockTaskPending} />
<StatCard label="BUG总数" value={mockBugTotal} />
<StatCard label="未解决BUG" value={mockBugOpen} warn />
<StatCard label="参与人员" value={version.members?.length ?? 0} />
</div>
{/* 风险详情(胶囊条上方) */}
{riskTags.length > 0 && (
<div className="rounded-xl border border-orange-200 bg-orange-50/40 p-4 max-h-[200px] overflow-y-auto">
<div className="flex items-center gap-2 mb-3">
<span className="text-[12px] font-semibold text-orange-700"></span>
<span className="text-[10px] text-orange-600"> {healthScore} · {HEALTH_LEVEL_LABEL[healthLevel]}</span>
</div>
<div className="space-y-2">
{riskTags.map((tag) => (
<div key={tag.key} className="flex gap-2.5 pb-2 border-b border-orange-100 last:border-b-0 last:pb-0">
<span className={`shrink-0 inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-medium h-fit mt-0.5 ${getTagStyle(tag.severity)}`}>
{tag.label}
</span>
<div className="flex-1 space-y-0.5 text-[11px]">
{tag.reason && <div className="text-[var(--ink-soft)]"><span className="text-[var(--ink-muted)]"></span>{tag.reason}</div>}
{tag.suggestion && <div className="text-[var(--ink-soft)]"><span className="text-[var(--ink-muted)]"></span>{tag.suggestion}</div>}
</div>
</div>
))}
</div>
</div>
)}
{/* Capsule stages */}
<CapsuleStages currentStage={version.currentStage} progress={version.progress} />
{/* 双栏:加班排名 + 原因占比 */}
<div className="grid grid-cols-2 gap-4">
{/* 参与人员加班排名 */}
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="text-[11px] text-[var(--ink-muted)] mb-3 font-medium"></div>
{otRanking.length === 0 ? (
<span className="text-[12px] text-[var(--ink-muted)]"></span>
) : (
<div className="space-y-2">
{otRanking.slice(0, 8).map((item, i) => (
<div key={item.name} className="flex items-center gap-2">
<span className={`flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold ${i < 3 ? 'bg-[var(--accent-soft)] text-[var(--accent)]' : 'bg-[var(--bg-subtle)] text-[var(--ink-muted)]'}`}>{i + 1}</span>
<span className="flex-1 text-[12px] text-[var(--ink)]">{item.name}</span>
<span className="text-[12px] font-medium tabular-nums text-[var(--ink-soft)]">{item.hours}h</span>
</div>
))}
</div>
))}
)}
</div>
{/* 加班原因占比 */}
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="text-[11px] text-[var(--ink-muted)] mb-3 font-medium"></div>
{reasonRanking.length === 0 ? (
<span className="text-[12px] text-[var(--ink-muted)]"></span>
) : (
<div className="space-y-2.5">
{reasonRanking.map(([reasonId, hours]) => {
const percent = Math.round((hours / reasonTotal) * 100);
const reasonName = OVERTIME_REASON_LABEL[reasonId] || reasonId;
return (
<div key={reasonId}>
<div className="flex items-center justify-between mb-1">
<span className="text-[12px] text-[var(--ink-soft)]">{reasonName}</span>
<span className="text-[11px] tabular-nums text-[var(--ink-muted)]">{percent}%</span>
</div>
<div className="h-1.5 rounded-full bg-[var(--bg-subtle)] overflow-hidden">
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${percent}%` }} />
</div>
</div>
);
})}
</div>
)}
</div>
</div>
{/* 右:健康趋势 */}
<div className="col-span-1 rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-3 flex flex-col justify-center">
<span className="text-[10px] font-medium text-[var(--ink-muted)] mb-1"></span>
{/* 双栏:日期信息 + 相关链接 */}
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2 space-y-4">
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="flex items-center gap-4 text-[13px]">
<div className="flex items-center gap-1.5">
<Calendar className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
<span className="text-[var(--ink)]">{version.startDate ?? '未设置'}</span>
<span className="text-[var(--ink-muted)]"></span>
<span className="text-[var(--ink)]">{version.expectedReleaseDate ?? '未设置'}</span>
</div>
<div className="flex items-center gap-1.5 text-[var(--ink-soft)]">
<Clock className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
<span className="font-medium text-[var(--ink)]">{elapsedDays}</span>
</div>
</div>
</div>
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="text-[11px] text-[var(--ink-muted)] mb-2 font-medium"></div>
{version.members && version.members.length > 0 ? (
<MemberChips members={version.members} />
) : (
<span className="text-[12px] text-[var(--ink-muted)]"></span>
)}
</div>
</div>
<div className="col-span-1">
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 h-full">
<div className="text-[11px] text-[var(--ink-muted)] mb-3 font-medium"></div>
<div className="space-y-3">
<LinkItem icon={<FileText className="h-3.5 w-3.5" />} label="调研报告" url={version.links?.research} />
<LinkItem icon={<Layout className="h-3.5 w-3.5" />} label="原型地址" url={version.links?.prototype} />
<LinkItem icon={<Palette className="h-3.5 w-3.5" />} label="UI设计稿" url={version.links?.ui} />
</div>
</div>
</div>
</div>
{/* 健康趋势(最底部) */}
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 max-w-[320px]">
<span className="text-[11px] font-medium text-[var(--ink-muted)] mb-2 block"></span>
<HealthTrend data={generateMockTrend(healthScore, 7)} />
</div>
</div>
)}
{/* 健康度正常时也显示趋势(紧凑) */}
{healthScore >= 60 && (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-3 max-w-[240px]">
<span className="text-[10px] font-medium text-[var(--ink-muted)] mb-1 block"></span>
<HealthTrend data={generateMockTrend(healthScore, 7)} />
</div>
)}
{/* Two-column layout: left info + right links */}
<div className="grid grid-cols-3 gap-4">
{/* Left: 2/3 width */}
<div className="col-span-2 space-y-4">
{/* Date + elapsed */}
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="flex items-center gap-4 text-[13px]">
<div className="flex items-center gap-1.5">
<Calendar className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
<span className="text-[var(--ink)]">{version.startDate ?? '未设置'}</span>
<span className="text-[var(--ink-muted)]"></span>
<span className="text-[var(--ink)]">{version.expectedReleaseDate ?? '未设置'}</span>
</div>
<div className="flex items-center gap-1.5 text-[var(--ink-soft)]">
<Clock className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
<span className="font-medium text-[var(--ink)]">{elapsedDays}</span>
</div>
</div>
</div>
{/* Members */}
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="text-[11px] text-[var(--ink-muted)] mb-2 font-medium"></div>
{version.members && version.members.length > 0 ? (
<MemberChips members={version.members} />
) : (
<span className="text-[12px] text-[var(--ink-muted)]"></span>
)}
</div>
</div>
{/* Right: 1/3 width - links card */}
<div className="col-span-1">
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 h-full">
<div className="text-[11px] text-[var(--ink-muted)] mb-3 font-medium"></div>
<div className="space-y-3">
<LinkItem icon={<FileText className="h-3.5 w-3.5" />} label="调研报告" url={version.links?.research} />
<LinkItem icon={<Layout className="h-3.5 w-3.5" />} label="原型地址" url={version.links?.prototype} />
<LinkItem icon={<Palette className="h-3.5 w-3.5" />} label="UI设计稿" url={version.links?.ui} />
</div>
</div>
</div>
</div>
</div>
);
})()
) : activeTab === 'requirements' ? (
<VersionRequirementsTab
versionId={version.id}
projectId={version.projectId}
requirements={requirements}
currentUserName={user?.name ?? ''}
onLink={(ids, addedBy) => {
ids.forEach((id) => updateRequirement(id, { versionId: version.id, addedToVersionBy: addedBy }));
}}
onUnlink={(id) => updateRequirement(id, { versionId: undefined, addedToVersionBy: undefined })}
/>
) : (activeTab === 'research' || activeTab === 'product' || activeTab === 'ui') ? (
(() => {
const pt = activeTab as 'research' | 'product' | 'ui';
const versionReqs = requirements.filter((r) => r.versionId === version.id);
const linkedReqs = versionReqs.map((r) => ({ id: r.id, title: r.title, code: r.code, productOwner: r.productOwner }));
return (
<PlanTab
plans={plans}
versionId={version.id}
versionDeadline={version.expectedReleaseDate ?? undefined}
currentUserName={user?.name ?? ''}
planType={pt}
linkedRequirements={pt !== 'research' ? linkedReqs : undefined}
onCreate={(data) => {
createPlan(data);
if ((pt === 'product') && data.linkedRequirementIds?.length) {
data.linkedRequirementIds.forEach((rid) => updateRequirement(rid, { productOwner: data.owner }));
}
}}
onUpdate={(id, data) => {
updatePlan(id, data);
if ((pt === 'product') && data.linkedRequirementIds && data.owner) {
data.linkedRequirementIds.forEach((rid) => updateRequirement(rid, { productOwner: data.owner }));
}
}}
onComplete={completePlan}
onDelete={deletePlan}
/>
);
})()
) : (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-12 flex items-center justify-center">
<span className="text-[13px] text-[var(--ink-muted)]"></span>
@@ -232,6 +367,16 @@ export default function VersionDetailPage() {
);
}
function StatCard({ label, value, accent, warn }: { label: string; value: string | number; accent?: boolean; warn?: boolean }) {
const color = warn ? 'text-red-500' : accent ? 'text-[var(--accent)]' : 'text-[var(--ink)]';
return (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-3 text-center">
<div className={`text-[18px] font-semibold tabular-nums ${color}`}>{value}</div>
<div className="text-[11px] text-[var(--ink-muted)] mt-0.5">{label}</div>
</div>
);
}
function LinkItem({ icon, label, url }: { icon: React.ReactNode; label: string; url?: string }) {
return (
<div className="flex items-center gap-2">

View File

@@ -2,8 +2,9 @@
import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Search, Tag, Plus, X, ChevronDown, MoreHorizontal, Pause, Play, XCircle } from 'lucide-react';
import { Search, Tag, Plus, X, ChevronDown, MoreHorizontal, Pause, Play, XCircle, Trash2 } from 'lucide-react';
import { useProductStore } from '@/stores/useProductStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { flattenVersions, flattenProjects } from '@/lib/derive';
import type { VersionWithContext } from '@/lib/derive';
import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_DOT } from '@/lib/version-status';
@@ -107,7 +108,7 @@ function getStageProgress(version: VersionWithContext): number {
export default function VersionsPage() {
const router = useRouter();
const { overview, fetchOverview, createVersion, updateVersion } = useProductStore();
const { overview, fetchOverview, createVersion, updateVersion, deleteVersion } = useProductStore();
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [projectFilter, setProjectFilter] = useState('all');
@@ -146,8 +147,17 @@ export default function VersionsPage() {
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20);
const handleAction = async (version: VersionWithContext, action: 'pause' | 'resume' | 'close') => {
const { requirements, fetchRequirements, updateRequirement } = useRequirementStore();
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
const handleAction = async (version: VersionWithContext, action: 'pause' | 'resume' | 'close' | 'delete') => {
setOpenMenuId(null);
if (action === 'delete') {
if (!confirm('确认删除该版本?关联的需求会回到需求池。')) return;
requirements.filter((r) => r.versionId === version.id).forEach((r) => updateRequirement(r.id, { versionId: undefined, addedToVersionBy: undefined }));
deleteVersion(version.productId, version.id);
return;
}
const statusMap: Record<string, VersionStatus> = {
pause: 'paused',
resume: 'developing',
@@ -242,9 +252,9 @@ export default function VersionsPage() {
</div>
) : (
<>
<div className="overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
<div className="rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
<table className="w-full text-left text-[13px]">
<thead className="sticky top-0 z-10">
<thead className="sticky top-0 z-10 bg-[var(--bg-subtle)]">
<tr className="border-b border-[var(--line)] bg-[var(--bg-subtle)]">
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
@@ -288,7 +298,7 @@ interface VersionRowProps {
openMenuId: string | null;
setOpenMenuId: (id: string | null) => void;
onNavigate: () => void;
onAction: (version: VersionWithContext, action: 'pause' | 'resume' | 'close') => void;
onAction: (version: VersionWithContext, action: 'pause' | 'resume' | 'close' | 'delete') => void;
}
function VersionRow({ version, openMenuId, setOpenMenuId, onNavigate, onAction }: VersionRowProps) {
@@ -441,6 +451,15 @@ function VersionRow({ version, openMenuId, setOpenMenuId, onNavigate, onAction }
</button>
)}
{version.status === 'planned' && (
<button
onClick={() => onAction(version, 'delete')}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[12px] text-red-600 hover:bg-[var(--bg-hover)] transition-colors"
>
<Trash2 className="h-3.5 w-3.5" strokeWidth={1.75} />
</button>
)}
</div>
</>
)}