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> <p className="text-[13px] font-medium text-[var(--ink-soft)]"></p>
</div> </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]"> <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)]"> <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>
<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 './globals.css';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { Sidebar } from '@/components/layout/Sidebar'; import { LayoutShell } from '@/components/layout/LayoutShell';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'FTB 项目管理', title: 'FTB 项目管理',
@@ -14,9 +14,8 @@ export default function RootLayout({
}) { }) {
return ( return (
<html lang="zh-CN"> <html lang="zh-CN">
<body className="flex h-screen overflow-hidden bg-gray-50"> <body className="h-screen overflow-hidden bg-gray-50">
<Sidebar /> <LayoutShell>{children}</LayoutShell>
<main className="flex-1 overflow-y-auto">{children}</main>
</body> </body>
</html> </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 { Search, Plus, ChevronDown, Clock, Pencil, Trash2, X, Download } from 'lucide-react';
import { useOvertimeStore } from '@/stores/useOvertimeStore'; import { useOvertimeStore } from '@/stores/useOvertimeStore';
import { useProductStore } from '@/stores/useProductStore'; import { useProductStore } from '@/stores/useProductStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { flattenProjects, flattenVersions } from '@/lib/derive'; import { flattenProjects, flattenVersions } from '@/lib/derive';
import { calcDuration } from '@/lib/overtime'; import { calcDuration } from '@/lib/overtime';
import type { OvertimeRecord } from '@/lib/overtime'; import type { OvertimeRecord } from '@/lib/overtime';
@@ -15,6 +16,7 @@ import { FilterSelect } from '@/components/FilterSelect';
export default function OvertimePage() { export default function OvertimePage() {
const { records, fetchRecords, createRecord, updateRecord, deleteRecord, reasons, addReason, updateReason, deleteReason } = useOvertimeStore(); const { records, fetchRecords, createRecord, updateRecord, deleteRecord, reasons, addReason, updateReason, deleteReason } = useOvertimeStore();
const { overview, fetchOverview } = useProductStore(); const { overview, fetchOverview } = useProductStore();
const { requirements, fetchRequirements } = useRequirementStore();
const allProjects = useMemo(() => flattenProjects(overview), [overview]); const allProjects = useMemo(() => flattenProjects(overview), [overview]);
const allVersions = useMemo(() => flattenVersions(overview), [overview]); const allVersions = useMemo(() => flattenVersions(overview), [overview]);
@@ -27,6 +29,7 @@ export default function OvertimePage() {
const [showReasonDrawer, setShowReasonDrawer] = useState(false); const [showReasonDrawer, setShowReasonDrawer] = useState(false);
useEffect(() => { fetchOverview(); }, [fetchOverview]); useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
useEffect(() => { fetchRecords(); }, [fetchRecords]); useEffect(() => { fetchRecords(); }, [fetchRecords]);
const projectName = (id: string) => allProjects.find((p) => p.id === id)?.name ?? '-'; const projectName = (id: string) => allProjects.find((p) => p.id === id)?.name ?? '-';
@@ -124,9 +127,9 @@ export default function OvertimePage() {
</div> </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]"> <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)]"> <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>
<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 }))} projects={allProjects.map((p) => ({ id: p.id, name: p.name, productId: p.productId }))}
versions={allVersions} versions={allVersions}
reasons={reasons} reasons={reasons}
requirements={requirements.map((r) => ({ id: r.id, title: r.title, versionId: r.versionId }))}
onClose={() => setShowModal(false)} onClose={() => setShowModal(false)}
onSubmit={(data) => { onSubmit={(data) => {
if (editing) updateRecord(editing.id, 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; initial: OvertimeRecord | null;
products: { id: string; name: string }[]; products: { id: string; name: string }[];
projects: { id: string; name: string; productId: string }[]; projects: { id: string; name: string; productId: string }[];
versions: { id: string; name: string; projectId?: string }[]; versions: { id: string; name: string; projectId?: string }[];
reasons: { id: string; name: string }[]; reasons: { id: string; name: string }[];
requirements: { id: string; title: string; versionId?: string }[];
onClose: () => void; onClose: () => void;
onSubmit: (data: any) => void; onSubmit: (data: any) => void;
}) { }) {
@@ -216,6 +221,7 @@ function OvertimeModal({ initial, products, projects, versions, reasons, onClose
const [endTime, setEndTime] = useState(initial?.endTime ?? ''); const [endTime, setEndTime] = useState(initial?.endTime ?? '');
const [reasonId, setReasonId] = useState(initial?.reasonId ?? ''); const [reasonId, setReasonId] = useState(initial?.reasonId ?? '');
const [remark, setRemark] = useState(initial?.remark ?? ''); const [remark, setRemark] = useState(initial?.remark ?? '');
const [requirementId, setRequirementId] = useState(initial?.requirementId ?? '');
// 初始编辑时反推 productId // 初始编辑时反推 productId
useEffect(() => { useEffect(() => {
@@ -228,11 +234,12 @@ function OvertimeModal({ initial, products, projects, versions, reasons, onClose
const duration = startTime && endTime ? calcDuration(startTime, endTime) : 0; const duration = startTime && endTime ? calcDuration(startTime, endTime) : 0;
const filteredProjects = productId ? projects.filter((p) => p.productId === productId) : projects; const filteredProjects = productId ? projects.filter((p) => p.productId === productId) : projects;
const filteredVersions = projectId ? versions.filter((v) => (v as any).projectId === projectId) : []; 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) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!projectId || !person.trim() || !startTime || !endTime || !reasonId) return; 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 ( 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>)} {reasons.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
</select> </select>
</div> </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> <div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"></label> <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" /> <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 type { RequirementStatus } from '@/lib/requirement';
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 { RequirementTable } from '@/components/product/RequirementTable'; import { RequirementTable } from '@/components/product/RequirementTable';
import { RequirementForm } from '@/components/product/RequirementForm'; import { RequirementForm } from '@/components/product/RequirementForm';
import { ProductForm } from '@/components/product/ProductForm'; import { ProductForm } from '@/components/product/ProductForm';
@@ -27,6 +28,7 @@ export default function ProductDetailPage() {
fetchRequirements, fetchRequirements,
deleteRequirement, deleteRequirement,
} = useRequirementStore(); } = useRequirementStore();
const { records, fetchRecords } = useOvertimeStore();
const [showReqForm, setShowReqForm] = useState(false); const [showReqForm, setShowReqForm] = useState(false);
const [editingReq, setEditingReq] = useState<any>(null); const [editingReq, setEditingReq] = useState<any>(null);
@@ -37,7 +39,8 @@ export default function ProductDetailPage() {
useEffect(() => { useEffect(() => {
fetchProduct(productId); fetchProduct(productId);
fetchRequirements(); fetchRequirements();
}, [productId, fetchProduct, fetchRequirements]); fetchRecords();
}, [productId, fetchProduct, fetchRequirements, fetchRecords]);
const productRequirements = useMemo(() => { const productRequirements = useMemo(() => {
let list = requirements.filter((r) => r.productId === productId); let list = requirements.filter((r) => r.productId === productId);
@@ -112,6 +115,27 @@ export default function ProductDetailPage() {
{currentProduct.description && ( {currentProduct.description && (
<p className="line-clamp-2 py-3 text-[13px] text-[var(--ink-soft)]">{currentProduct.description}</p> <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"> <div className="flex gap-1">
{TABS.map((tab) => ( {TABS.map((tab) => (
<button <button

View File

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

View File

@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Search, Plus, Lightbulb, ArrowUp, ArrowDown } from 'lucide-react'; import { Search, Plus, Lightbulb, ArrowUp, ArrowDown } from 'lucide-react';
import { useRequirementStore } from '@/stores/useRequirementStore'; import { useRequirementStore } from '@/stores/useRequirementStore';
import { useProductStore } from '@/stores/useProductStore'; 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 { REQ_STATUS_LABEL, REQ_STATUS_COLOR, SOURCE_TYPE_LABEL } from '@/lib/requirement';
import type { Requirement, RequirementStatus, SourceType } from '@/lib/requirement'; import type { Requirement, RequirementStatus, SourceType } from '@/lib/requirement';
import { Pagination, usePagination } from '@/components/Pagination'; import { Pagination, usePagination } from '@/components/Pagination';
@@ -43,12 +43,14 @@ export default function RequirementsPage() {
} = useRequirementStore(); } = useRequirementStore();
const { overview, fetchOverview } = useProductStore(); const { overview, fetchOverview } = useProductStore();
const allProjects = useMemo(() => flattenProjects(overview), [overview]); const allProjects = useMemo(() => flattenProjects(overview), [overview]);
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('all'); const [statusFilter, setStatusFilter] = useState('all');
const [projectFilter, setProjectFilter] = useState('all'); const [projectFilter, setProjectFilter] = useState('all');
const [priorityFilter, setPriorityFilter] = useState('all'); const [priorityFilter, setPriorityFilter] = useState('all');
const [typeFilter, setTypeFilter] = useState('all'); const [typeFilter, setTypeFilter] = useState('all');
const [versionFilter, setVersionFilter] = useState('all');
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editingReq, setEditingReq] = useState<Requirement | null>(null); const [editingReq, setEditingReq] = useState<Requirement | null>(null);
@@ -101,6 +103,9 @@ export default function RequirementsPage() {
if (typeFilter !== 'all') { if (typeFilter !== 'all') {
list = list.filter((r) => r.typeId === typeFilter); list = list.filter((r) => r.typeId === typeFilter);
} }
if (versionFilter !== 'all') {
list = list.filter((r) => r.versionId === versionFilter);
}
// sort by createdAt // sort by createdAt
list.sort((a, b) => { list.sort((a, b) => {
@@ -109,7 +114,7 @@ export default function RequirementsPage() {
}); });
return list; 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); const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20);
@@ -211,6 +216,15 @@ export default function RequirementsPage() {
placeholder="全部类型" placeholder="全部类型"
allLabel="全部类型" allLabel="全部类型"
/> />
{/* Version dropdown */}
<FilterSelect
value={versionFilter}
onChange={setVersionFilter}
options={allVersions.map((v) => ({ value: v.id, label: v.name }))}
placeholder="全部版本"
allLabel="全部版本"
/>
</div> </div>
{/* Content */} {/* Content */}
@@ -223,9 +237,9 @@ export default function RequirementsPage() {
</div> </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]"> <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)]"> <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>
<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} initial={editingReq}
products={overview.map((p) => ({ id: p.id, name: p.name }))} products={overview.map((p) => ({ id: p.id, name: p.name }))}
projects={allProjects.map((p) => ({ id: p.id, name: p.name, productId: p.productId }))} 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} sourceTargets={sourceTargets}
types={types} types={types}
platforms={platforms} platforms={platforms}

View File

@@ -4,6 +4,8 @@ import { useEffect, useMemo, useState } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { ChevronLeft, Package, Calendar, Clock, ExternalLink, FileText, Palette, Layout } from 'lucide-react'; import { ChevronLeft, Package, Calendar, Clock, ExternalLink, FileText, Palette, Layout } from 'lucide-react';
import { useProductStore } from '@/stores/useProductStore'; import { useProductStore } from '@/stores/useProductStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useOvertimeStore } from '@/stores/useOvertimeStore';
import { getVersionDetail } from '@/lib/derive'; import { getVersionDetail } from '@/lib/derive';
import { STAGES } from '@/lib/stage'; import { STAGES } from '@/lib/stage';
import { VERSION_STATUS_LABEL, VERSION_STATUS_BG } from '@/lib/version-status'; 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 { MemberChips } from '@/components/version/MemberChips';
import { HealthTrend, generateMockTrend } from '@/components/version/HealthTrend'; 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 { 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> = { const PRIORITY_STYLE: Record<string, string> = {
P0: 'bg-red-500/10 text-red-600', P0: 'bg-red-500/10 text-red-600',
@@ -22,20 +30,30 @@ const PRIORITY_STYLE: Record<string, string> = {
const TABS = [ const TABS = [
{ key: 'overview', label: '概览' }, { key: 'overview', label: '概览' },
{ key: 'requirements', label: '需求' }, { key: 'requirements', label: '关联需求' },
{ key: 'research', label: '调研' },
{ key: 'product', label: '产品方案' },
{ key: 'ui', label: 'UI设计' },
{ key: 'tasks', label: '开发任务' }, { key: 'tasks', label: '开发任务' },
{ key: 'testcases', label: '测试用例' }, { key: 'testcases', label: '测试用例' },
{ key: 'bugs', label: 'Bug' }, { key: 'bugs', label: 'BUG' },
]; ];
export default function VersionDetailPage() { export default function VersionDetailPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const versionId = params.id as string; 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'); const [activeTab, setActiveTab] = useState('overview');
useEffect(() => { fetchOverview(); }, [fetchOverview]); useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
useEffect(() => { fetchRecords(); }, [fetchRecords]);
useEffect(() => { fetchPlans(); }, [fetchPlans]);
const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]); const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]);
@@ -63,12 +81,22 @@ export default function VersionDetailPage() {
const renderActions = () => { const renderActions = () => {
const buttons: { label: string; action: () => void; danger?: boolean }[] = []; const buttons: { label: string; action: () => void; danger?: boolean }[] = [];
if (version.status === 'developing' || version.status === 'planned') { if (version.status === 'planned') {
buttons.push({ label: '暂停', action: () => {} }); buttons.push({ label: '删除', action: () => {
buttons.push({ label: '关闭', action: () => {}, danger: true }); 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') { } else if (version.status === 'paused') {
buttons.push({ label: '恢复', action: () => {} }); buttons.push({ label: '恢复', action: () => updateVersion(version.productId, version.id, { status: 'developing' }) });
buttons.push({ label: '关闭', action: () => {}, danger: true }); buttons.push({ label: '关闭', action: () => updateVersion(version.productId, version.id, { status: 'closed' }), danger: true });
} }
return buttons.map((btn) => ( return buttons.map((btn) => (
<button <button
@@ -111,117 +139,224 @@ export default function VersionDetailPage() {
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto p-5 bg-[var(--bg)]"> <div className="flex-1 overflow-y-auto p-5 bg-[var(--bg)]">
{activeTab === 'overview' ? ( {activeTab === 'overview' ? (
<div className="space-y-4"> (() => {
{/* Tag row */} const versionReqs = requirements.filter((r) => r.versionId === version.id);
<div className="flex flex-wrap items-center gap-2"> const versionOT = records.filter((r) => r.versionId === version.id);
{version.priority && ( const totalOTHours = Math.round(versionOT.reduce((sum, r) => sum + r.duration, 0) * 10) / 10;
<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>
{/* 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 && ( const reasonMap: Record<string, number> = {};
<div className="grid grid-cols-4 gap-3"> versionOT.forEach((r) => { reasonMap[r.reasonId] = (reasonMap[r.reasonId] || 0) + r.duration; });
{/* 左:风险详情(可滚动) */} const reasonRanking = Object.entries(reasonMap).sort((a, b) => b[1] - a[1]);
<div className="col-span-3 rounded-xl border border-orange-200 bg-orange-50/40 p-4 max-h-[200px] overflow-y-auto"> const reasonTotal = reasonRanking.reduce((s, [, v]) => s + v, 0) || 1;
<div className="flex items-center gap-2 mb-3">
<span className="text-[12px] font-semibold text-orange-700"></span> // Mock 数据占位
<span className="text-[10px] text-orange-600"> {healthScore} · {HEALTH_LEVEL_LABEL[healthLevel]}</span> const mockTaskCount = Math.max(versionReqs.length * 2, 3);
</div> const mockBugTotal = Math.max(Math.floor(versionReqs.length * 1.5), 2);
<div className="space-y-2"> const mockBugOpen = Math.max(Math.floor(mockBugTotal * 0.3), 1);
{riskTags.map((tag) => ( const mockTaskPending = Math.max(Math.floor(mockTaskCount * 0.4), 1);
<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)}`}> return (
{tag.label} <div className="space-y-4">
</span> {/* Tag row */}
<div className="flex-1 space-y-0.5 text-[11px]"> <div className="flex flex-wrap items-center gap-2">
{tag.reason && <div className="text-[var(--ink-soft)]"><span className="text-[var(--ink-muted)]"></span>{tag.reason}</div>} {version.priority && (
{tag.suggestion && <div className="text-[var(--ink-soft)]"><span className="text-[var(--ink-muted)]"></span>{tag.suggestion}</div>} <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>
</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>
{/* 加班原因占比 */}
<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> </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)} /> <HealthTrend data={generateMockTrend(healthScore, 7)} />
</div> </div>
</div> </div>
)} );
})()
{/* 健康度正常时也显示趋势(紧凑) */} ) : activeTab === 'requirements' ? (
{healthScore >= 60 && ( <VersionRequirementsTab
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-3 max-w-[240px]"> versionId={version.id}
<span className="text-[10px] font-medium text-[var(--ink-muted)] mb-1 block"></span> projectId={version.projectId}
<HealthTrend data={generateMockTrend(healthScore, 7)} /> requirements={requirements}
</div> currentUserName={user?.name ?? ''}
)} onLink={(ids, addedBy) => {
ids.forEach((id) => updateRequirement(id, { versionId: version.id, addedToVersionBy: addedBy }));
{/* Two-column layout: left info + right links */} }}
<div className="grid grid-cols-3 gap-4"> onUnlink={(id) => updateRequirement(id, { versionId: undefined, addedToVersionBy: undefined })}
{/* Left: 2/3 width */} />
<div className="col-span-2 space-y-4"> ) : (activeTab === 'research' || activeTab === 'product' || activeTab === 'ui') ? (
{/* Date + elapsed */} (() => {
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4"> const pt = activeTab as 'research' | 'product' | 'ui';
<div className="flex items-center gap-4 text-[13px]"> const versionReqs = requirements.filter((r) => r.versionId === version.id);
<div className="flex items-center gap-1.5"> const linkedReqs = versionReqs.map((r) => ({ id: r.id, title: r.title, code: r.code, productOwner: r.productOwner }));
<Calendar className="h-3.5 w-3.5 text-[var(--ink-muted)]" /> return (
<span className="text-[var(--ink)]">{version.startDate ?? '未设置'}</span> <PlanTab
<span className="text-[var(--ink-muted)]"></span> plans={plans}
<span className="text-[var(--ink)]">{version.expectedReleaseDate ?? '未设置'}</span> versionId={version.id}
</div> versionDeadline={version.expectedReleaseDate ?? undefined}
<div className="flex items-center gap-1.5 text-[var(--ink-soft)]"> currentUserName={user?.name ?? ''}
<Clock className="h-3.5 w-3.5 text-[var(--ink-muted)]" /> planType={pt}
<span className="font-medium text-[var(--ink)]">{elapsedDays}</span> linkedRequirements={pt !== 'research' ? linkedReqs : undefined}
</div> onCreate={(data) => {
</div> createPlan(data);
</div> if ((pt === 'product') && data.linkedRequirementIds?.length) {
data.linkedRequirementIds.forEach((rid) => updateRequirement(rid, { productOwner: data.owner }));
{/* 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> onUpdate={(id, data) => {
{version.members && version.members.length > 0 ? ( updatePlan(id, data);
<MemberChips members={version.members} /> if ((pt === 'product') && data.linkedRequirementIds && data.owner) {
) : ( data.linkedRequirementIds.forEach((rid) => updateRequirement(rid, { productOwner: data.owner }));
<span className="text-[12px] text-[var(--ink-muted)]"></span> }
)} }}
</div> onComplete={completePlan}
</div> onDelete={deletePlan}
/>
{/* 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>
) : ( ) : (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-12 flex items-center justify-center"> <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> <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 }) { function LinkItem({ icon, label, url }: { icon: React.ReactNode; label: string; url?: string }) {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@@ -2,8 +2,9 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation'; 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 { useProductStore } from '@/stores/useProductStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { flattenVersions, flattenProjects } from '@/lib/derive'; import { flattenVersions, flattenProjects } from '@/lib/derive';
import type { VersionWithContext } from '@/lib/derive'; import type { VersionWithContext } from '@/lib/derive';
import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_DOT } from '@/lib/version-status'; 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() { export default function VersionsPage() {
const router = useRouter(); const router = useRouter();
const { overview, fetchOverview, createVersion, updateVersion } = useProductStore(); const { overview, fetchOverview, createVersion, updateVersion, deleteVersion } = useProductStore();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('all'); const [statusFilter, setStatusFilter] = useState('all');
const [projectFilter, setProjectFilter] = 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 { 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); 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> = { const statusMap: Record<string, VersionStatus> = {
pause: 'paused', pause: 'paused',
resume: 'developing', resume: 'developing',
@@ -242,9 +252,9 @@ export default function VersionsPage() {
</div> </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]"> <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)]"> <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>
<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; openMenuId: string | null;
setOpenMenuId: (id: string | null) => void; setOpenMenuId: (id: string | null) => void;
onNavigate: () => 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) { function VersionRow({ version, openMenuId, setOpenMenuId, onNavigate, onAction }: VersionRowProps) {
@@ -441,6 +451,15 @@ function VersionRow({ version, openMenuId, setOpenMenuId, onNavigate, onAction }
</button> </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> </div>
</> </>
)} )}

View File

@@ -0,0 +1,33 @@
'use client';
import { useEffect, useState } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/useAuthStore';
export function AuthGuard({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const { isAuthenticated, checkAuth } = useAuthStore();
const [checked, setChecked] = useState(false);
useEffect(() => {
checkAuth();
setChecked(true);
}, [checkAuth]);
useEffect(() => {
if (!checked) return;
if (!isAuthenticated && pathname !== '/login') {
router.replace('/login');
}
if (isAuthenticated && pathname === '/login') {
router.replace('/products');
}
}, [checked, isAuthenticated, pathname, router]);
if (!checked) return null;
if (!isAuthenticated && pathname !== '/login') return null;
return <>{children}</>;
}

View File

@@ -0,0 +1,23 @@
'use client';
import { usePathname } from 'next/navigation';
import { Sidebar } from '@/components/layout/Sidebar';
import { AuthGuard } from '@/components/AuthGuard';
export function LayoutShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const isLoginPage = pathname === '/login';
return (
<AuthGuard>
{isLoginPage ? (
<>{children}</>
) : (
<div className="flex h-screen overflow-hidden">
<Sidebar />
<main className="flex-1 overflow-y-auto">{children}</main>
</div>
)}
</AuthGuard>
);
}

View File

@@ -10,6 +10,7 @@ interface RequirementModalProps {
initial?: Requirement | null; initial?: Requirement | null;
products: { id: string; name: string }[]; products: { id: string; name: string }[];
projects: { id: string; name: string; productId: string }[]; projects: { id: string; name: string; productId: string }[];
versions: { id: string; name: string; projectId: string }[];
sourceTargets: SourceTarget[]; sourceTargets: SourceTarget[];
types: DictItem[]; types: DictItem[];
platforms: DictItem[]; platforms: DictItem[];
@@ -36,6 +37,7 @@ export function RequirementModal({
initial, initial,
products, products,
projects, projects,
versions,
sourceTargets, sourceTargets,
types, types,
platforms, platforms,
@@ -56,6 +58,7 @@ export function RequirementModal({
const [effort, setEffort] = useState<Effort>('M'); const [effort, setEffort] = useState<Effort>('M');
const [productOwner, setProductOwner] = useState(''); const [productOwner, setProductOwner] = useState('');
const [parentId, setParentId] = useState(''); const [parentId, setParentId] = useState('');
const [versionId, setVersionId] = useState('');
const [platformDropOpen, setPlatformDropOpen] = useState(false); const [platformDropOpen, setPlatformDropOpen] = useState(false);
@@ -64,6 +67,11 @@ export function RequirementModal({
[projects, productId], [projects, productId],
); );
const filteredVersions = useMemo(
() => projectId ? versions.filter((v) => v.projectId === projectId) : [],
[versions, projectId],
);
useEffect(() => { useEffect(() => {
if (initial) { if (initial) {
setTitle(initial.title); setTitle(initial.title);
@@ -72,6 +80,7 @@ export function RequirementModal({
setSourceTarget(initial.sourceTarget || ''); setSourceTarget(initial.sourceTarget || '');
setProductId(initial.productId || ''); setProductId(initial.productId || '');
setProjectId(initial.projectId || ''); setProjectId(initial.projectId || '');
setVersionId(initial.versionId || '');
setSelectedPlatforms(initial.platforms || []); setSelectedPlatforms(initial.platforms || []);
setTypeId(initial.typeId || ''); setTypeId(initial.typeId || '');
setPriority(initial.priority || 'P2'); setPriority(initial.priority || 'P2');
@@ -85,6 +94,7 @@ export function RequirementModal({
setSourceTarget(''); setSourceTarget('');
setProductId(''); setProductId('');
setProjectId(''); setProjectId('');
setVersionId('');
setSelectedPlatforms([]); setSelectedPlatforms([]);
setTypeId(''); setTypeId('');
setPriority('P2'); setPriority('P2');
@@ -113,6 +123,7 @@ export function RequirementModal({
sourceTarget: sourceTarget || undefined, sourceTarget: sourceTarget || undefined,
productId: productId || undefined, productId: productId || undefined,
projectId: projectId || undefined, projectId: projectId || undefined,
versionId: versionId || undefined,
platforms: selectedPlatforms, platforms: selectedPlatforms,
typeId: typeId || undefined, typeId: typeId || undefined,
priority, priority,
@@ -226,15 +237,15 @@ export function RequirementModal({
</div> </div>
</div> </div>
{/* 4. 所属产品 → 项目 */} {/* 4. 所属产品 → 项目 → 版本 */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-3 gap-3">
<div> <div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block"> <label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
</label> </label>
<select <select
value={productId} value={productId}
onChange={(e) => { setProductId(e.target.value); setProjectId(''); }} onChange={(e) => { setProductId(e.target.value); setProjectId(''); setVersionId(''); }}
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] outline-none focus:border-[var(--accent)]" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
> >
<option value=""></option> <option value=""></option>
@@ -249,7 +260,7 @@ export function RequirementModal({
</label> </label>
<select <select
value={projectId} value={projectId}
onChange={(e) => setProjectId(e.target.value)} onChange={(e) => { setProjectId(e.target.value); setVersionId(''); }}
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] outline-none focus:border-[var(--accent)]" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
> >
<option value=""></option> <option value=""></option>
@@ -258,6 +269,21 @@ export function RequirementModal({
))} ))}
</select> </select>
</div> </div>
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
</label>
<select
value={versionId}
onChange={(e) => setVersionId(e.target.value)}
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
>
<option value=""></option>
{filteredVersions.map((v) => (
<option key={v.id} value={v.id}>{v.name}</option>
))}
</select>
</div>
</div> </div>
{/* 5. 支持端 (multi-select) */} {/* 5. 支持端 (multi-select) */}

View File

@@ -0,0 +1,311 @@
'use client';
import { useState } from 'react';
import { Plus, Pencil, Trash2, X, Check, ExternalLink, FileUp, Link2 } from 'lucide-react';
import type { VersionPlan } from '@/lib/version-plan';
import { calcPlanDuration, formatDuration, calcTotalDuration } from '@/lib/version-plan';
interface Props {
plans: VersionPlan[];
versionId: string;
versionDeadline?: string;
currentUserName: string;
planType: 'research' | 'product' | 'ui';
linkedRequirements?: { id: string; title: string; code: string; productOwner?: string }[];
onCreate: (data: Omit<VersionPlan, 'id' | 'createdAt'>) => void;
onUpdate: (id: string, data: Partial<VersionPlan>) => void;
onComplete: (id: string, result: { resultType: 'link' | 'file'; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => void;
onDelete: (id: string) => void;
}
const TYPE_LABEL = { research: '调研', product: '产品方案', ui: 'UI设计' };
const STATUS_STYLE = {
pending: 'bg-zinc-100 text-zinc-600',
in_progress: 'bg-blue-50 text-blue-600 border-blue-200',
completed: 'bg-green-50 text-green-700 border-green-200',
};
const STATUS_LABEL = { pending: '未开始', in_progress: '进行中', completed: '已完成' };
export function PlanTab({ plans, versionId, versionDeadline, currentUserName, planType, linkedRequirements, onCreate, onUpdate, onComplete, onDelete }: Props) {
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingPlan, setEditingPlan] = useState<VersionPlan | null>(null);
const [completingPlan, setCompletingPlan] = useState<VersionPlan | null>(null);
const typePlans = plans.filter((p) => p.versionId === versionId && p.type === planType);
const totalDuration = calcTotalDuration(typePlans);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-[12px] text-[var(--ink-muted)]">{typePlans.length} {TYPE_LABEL[planType]}</span>
<span className="text-[12px] text-[var(--ink-soft)]"><span className="font-medium text-[var(--ink)]">{totalDuration}</span></span>
{versionDeadline && <span className="text-[12px] text-[var(--ink-muted)]"><span className="font-medium text-red-500">{versionDeadline}</span></span>}
</div>
<button onClick={() => setShowCreateModal(true)} className="flex h-8 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[13px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors">
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
</button>
</div>
{typePlans.length === 0 ? (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-12 text-center text-[13px] text-[var(--ink-muted)]">
{TYPE_LABEL[planType]}
</div>
) : (
<div className="space-y-3">
{typePlans.map((plan) => {
const dur = plan.completedAt
? calcPlanDuration(plan.startTime, plan.completedAt)
: calcPlanDuration(plan.startTime, plan.endTime);
const durText = formatDuration(dur.days, dur.hours);
return (
<div key={plan.id} className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1.5">
<span className="text-[13px] font-medium text-[var(--ink)]">{plan.title}</span>
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-medium border ${STATUS_STYLE[plan.status]}`}>
{STATUS_LABEL[plan.status]}
</span>
</div>
<div className="flex items-center gap-4 text-[12px] text-[var(--ink-soft)]">
<span>{plan.owner}</span>
<span>{plan.startTime.slice(0, 10)} {plan.endTime.slice(0, 10)}</span>
{plan.completedAt && <span className="text-green-600">{plan.completedAt.slice(0, 10)}</span>}
<span className="font-medium text-[var(--ink)]"> {durText}</span>
</div>
{plan.overdueReason && (
<div className="mt-1.5 text-[11px] text-red-600 bg-red-50 rounded px-2 py-1 inline-block">{plan.overdueReason}</div>
)}
{plan.linkedRequirementIds && plan.linkedRequirementIds.length > 0 && linkedRequirements && (
<div className="flex flex-wrap gap-1.5 mt-2">
{plan.linkedRequirementIds.map((rid) => {
const req = linkedRequirements.find((r) => r.id === rid);
return req ? (
<span key={rid} className="inline-flex items-center gap-1 rounded bg-[var(--bg-subtle)] px-2 py-0.5 text-[11px] text-[var(--ink-soft)]">
{req.code} {req.title.slice(0, 12)}{req.title.length > 12 ? '...' : ''}
{req.productOwner && <span className="text-[var(--ink-muted)]">({req.productOwner})</span>}
</span>
) : null;
})}
</div>
)}
{plan.status === 'completed' && plan.resultUrl && (
<div className="flex items-center gap-1.5 mt-2">
{plan.resultType === 'link' ? <Link2 className="h-3 w-3 text-[var(--accent)]" /> : <FileUp className="h-3 w-3 text-[var(--accent)]" />}
<a href={plan.resultUrl} target="_blank" rel="noopener noreferrer" className="text-[12px] text-[var(--accent)] hover:underline flex items-center gap-1">
{plan.resultFileName || '查看成果'}<ExternalLink className="h-3 w-3" />
</a>
</div>
)}
</div>
<div className="flex items-center gap-1 ml-3">
{plan.status !== 'completed' && (
<>
<button onClick={() => setCompletingPlan(plan)} className="h-7 w-7 flex items-center justify-center rounded-md text-green-600 hover:bg-green-50" title="标记完成">
<Check className="h-3.5 w-3.5" />
</button>
<button onClick={() => setEditingPlan(plan)} className="h-7 w-7 flex items-center justify-center rounded-md text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)]" title="编辑">
<Pencil className="h-3.5 w-3.5" />
</button>
<button onClick={() => onDelete(plan.id)} className="h-7 w-7 flex items-center justify-center rounded-md text-red-500 hover:bg-red-50" title="删除">
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
</div>
</div>
);
})}
</div>
)}
{(showCreateModal || editingPlan) && (
<PlanFormModal
initial={editingPlan}
planType={planType}
versionId={versionId}
versionDeadline={versionDeadline}
currentUserName={currentUserName}
linkedRequirements={linkedRequirements}
onClose={() => { setShowCreateModal(false); setEditingPlan(null); }}
onSubmit={(data) => {
if (editingPlan) onUpdate(editingPlan.id, data);
else onCreate(data as any);
setShowCreateModal(false);
setEditingPlan(null);
}}
/>
)}
{completingPlan && (
<CompleteModal
onClose={() => setCompletingPlan(null)}
onSubmit={(result) => { onComplete(completingPlan.id, result); setCompletingPlan(null); }}
/>
)}
</div>
);
}
function PlanFormModal({ initial, planType, versionId, versionDeadline, currentUserName, linkedRequirements, onClose, onSubmit }: {
initial: VersionPlan | null;
planType: 'research' | 'product' | 'ui';
versionId: string;
versionDeadline?: string;
currentUserName: string;
linkedRequirements?: { id: string; title: string; code: string }[];
onClose: () => void;
onSubmit: (data: any) => void;
}) {
const now = new Date().toISOString().slice(0, 10);
const [title, setTitle] = useState(initial?.title ?? '');
const [owner] = useState(initial?.owner ?? currentUserName);
const [startTime, setStartTime] = useState(initial?.startTime?.slice(0, 10) ?? now);
const [endTime, setEndTime] = useState(initial?.endTime?.slice(0, 10) ?? '');
const [remark, setRemark] = useState(initial?.remark ?? '');
const [overdueReason, setOverdueReason] = useState(initial?.overdueReason ?? '');
const [selectedReqs, setSelectedReqs] = useState<Set<string>>(new Set(initial?.linkedRequirementIds ?? []));
const showReqSelect = planType === 'product' || planType === 'ui';
const isOverdue = !!(versionDeadline && endTime && new Date(endTime) > new Date(versionDeadline));
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim() || !endTime) return;
if (isOverdue && !overdueReason.trim()) return;
onSubmit({
versionId,
type: planType,
title: title.trim(),
owner: owner.trim() || currentUserName,
startTime,
endTime,
status: initial?.status ?? 'pending',
linkedRequirementIds: showReqSelect ? Array.from(selectedReqs) : undefined,
remark: remark.trim() || undefined,
overdueReason: isOverdue ? overdueReason.trim() : undefined,
addedBy: currentUserName,
});
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="w-full max-w-md rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] p-5 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-[13px] font-semibold text-[var(--ink)]">{initial ? '编辑' : '新建'}{TYPE_LABEL[planType]}</h3>
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button>
</div>
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"></label>
<input value={title} onChange={(e) => setTitle(e.target.value)} required 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" />
</div>
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"></label>
<input value={owner} readOnly className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] px-3 text-[13px] text-[var(--ink-muted)] cursor-not-allowed" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"></label>
<input type="date" value={startTime} onChange={(e) => setStartTime(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" />
</div>
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"></label>
<input type="date" value={endTime} onChange={(e) => setEndTime(e.target.value)} required 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" />
</div>
</div>
{versionDeadline && (
<div className="text-[11px] text-[var(--ink-muted)] -mt-1"><span className="text-red-500 font-medium">{versionDeadline}</span></div>
)}
{isOverdue && (
<div className="rounded-lg bg-red-50 border border-red-200 p-3 space-y-2">
<div className="text-[12px] text-red-700 font-medium"> </div>
<textarea
value={overdueReason}
onChange={(e) => setOverdueReason(e.target.value)}
rows={2}
placeholder="例如:技术难点超预期、上游交付延误..."
required
className="w-full rounded-lg border border-red-200 bg-white px-3 py-2 text-[13px] focus:border-red-400 focus:outline-none resize-none"
/>
</div>
)}
{showReqSelect && linkedRequirements && linkedRequirements.length > 0 && (
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block"></label>
<div className="max-h-[120px] overflow-y-auto rounded-lg border border-[var(--line)] p-2 space-y-1">
{linkedRequirements.map((req) => (
<label key={req.id} className="flex items-center gap-2 rounded px-2 py-1 hover:bg-[var(--bg-subtle)] cursor-pointer text-[12px]">
<input type="checkbox" checked={selectedReqs.has(req.id)} onChange={() => { const s = new Set(selectedReqs); if (s.has(req.id)) s.delete(req.id); else s.add(req.id); setSelectedReqs(s); }} className="h-3.5 w-3.5 rounded" />
<span className="text-[var(--ink-muted)] font-mono">{req.code}</span>
<span className="text-[var(--ink)] truncate">{req.title}</span>
</label>
))}
</div>
</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} 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" />
</div>
<div className="flex justify-end gap-2 pt-2">
<button type="button" onClick={onClose} className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"></button>
<button type="submit" className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]">{initial ? '保存' : '创建'}</button>
</div>
</form>
</div>
</div>
);
}
function CompleteModal({ onClose, onSubmit }: {
onClose: () => void;
onSubmit: (result: { resultType: 'link' | 'file'; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => void;
}) {
const [resultType, setResultType] = useState<'link' | 'file'>('link');
const [url, setUrl] = useState('');
const [fileName, setFileName] = useState('');
const [fileData, setFileData] = useState('');
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setFileName(file.name);
const reader = new FileReader();
reader.onload = () => setFileData(reader.result as string);
reader.readAsDataURL(file);
};
const canSubmit = resultType === 'link' ? url.trim().length > 0 : fileData.length > 0;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="w-full max-w-sm rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] p-5 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-[13px] font-semibold text-[var(--ink)]"></h3>
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button>
</div>
<div className="space-y-3">
<div className="flex gap-2">
<button type="button" onClick={() => setResultType('link')} className={`h-8 px-3 rounded-lg text-[12px] font-medium border transition-colors ${resultType === 'link' ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}></button>
<button type="button" onClick={() => setResultType('file')} className={`h-8 px-3 rounded-lg text-[12px] font-medium border transition-colors ${resultType === 'file' ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}></button>
</div>
{resultType === 'link' ? (
<input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://..." 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" />
) : (
<div>
<input type="file" onChange={handleFile} className="text-[12px] text-[var(--ink-soft)]" />
{fileName && <p className="text-[11px] text-[var(--ink-muted)] mt-1">{fileName}</p>}
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<button onClick={onClose} className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"></button>
<button onClick={() => onSubmit({ resultType, resultUrl: resultType === 'link' ? url.trim() : fileData, resultFileName: fileName || undefined, resultFileData: resultType === 'file' ? fileData : undefined })} disabled={!canSubmit} className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] disabled:opacity-50"></button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,138 @@
'use client';
import { useState } from 'react';
import { Plus, X, Search } from 'lucide-react';
import type { Requirement } from '@/lib/requirement';
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR } from '@/lib/requirement';
interface Props {
versionId: string;
projectId: string;
requirements: Requirement[];
onLink: (reqIds: string[], addedBy: string) => void;
onUnlink: (reqId: string) => void;
currentUserName: string;
}
export function VersionRequirementsTab({ versionId, projectId, requirements, onLink, onUnlink, currentUserName }: Props) {
const [showAddModal, setShowAddModal] = useState(false);
const linkedReqs = requirements.filter((r) => r.versionId === versionId);
const availableReqs = requirements.filter((r) => r.projectId === projectId && !r.versionId && r.status === 'adopted');
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-[12px] text-[var(--ink-muted)]">{linkedReqs.length} </span>
<button onClick={() => setShowAddModal(true)} className="flex h-8 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[13px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors">
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
</button>
</div>
{linkedReqs.length === 0 ? (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-12 text-center text-[13px] text-[var(--ink-muted)]">
</div>
) : (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
<table className="w-full text-left text-[13px]">
<thead className="sticky top-0 z-10 bg-[var(--bg-subtle)]">
<tr className="border-b border-[var(--line)]">
<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>
<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>
<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)] text-right"></th>
</tr>
</thead>
<tbody>
{linkedReqs.map((req) => (
<tr key={req.id} className="border-b border-[var(--line-soft)] last:border-0 hover:bg-[var(--bg-subtle)] transition-colors">
<td className="px-4 py-3 font-mono text-[12px] text-[var(--ink-muted)]">{req.code}</td>
<td className="px-4 py-3 font-medium text-[var(--ink)]">{req.title}</td>
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)] max-w-[200px] truncate">{req.description || '-'}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-medium border ${REQ_STATUS_COLOR[req.status]}`}>
{REQ_STATUS_LABEL[req.status]}
</span>
</td>
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">{req.addedToVersionBy || '-'}</td>
<td className="px-4 py-3 text-right">
<button onClick={() => onUnlink(req.id)} className="h-6 px-2 rounded text-[11px] font-medium text-red-500 hover:bg-red-50 transition-colors"></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{showAddModal && (
<AddRequirementModal
available={availableReqs}
onClose={() => setShowAddModal(false)}
onConfirm={(ids) => { onLink(ids, currentUserName); setShowAddModal(false); }}
/>
)}
</div>
);
}
function AddRequirementModal({ available, onClose, onConfirm }: {
available: Requirement[];
onClose: () => void;
onConfirm: (ids: string[]) => void;
}) {
const [selected, setSelected] = useState<Set<string>>(new Set());
const [search, setSearch] = useState('');
const filtered = available.filter((r) =>
r.title.includes(search) || r.code.includes(search)
);
const toggle = (id: string) => {
const next = new Set(selected);
if (next.has(id)) next.delete(id); else next.add(id);
setSelected(next);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="w-full max-w-lg rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] shadow-[var(--shadow-md)] flex flex-col max-h-[70vh]" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--line)]">
<h3 className="text-[13px] font-semibold text-[var(--ink)]"></h3>
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button>
</div>
<div className="px-5 py-3 border-b border-[var(--line)]">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-[var(--ink-muted)]" />
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="搜索需求编号或标题" className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-9 pr-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
</div>
</div>
<div className="flex-1 overflow-y-auto px-5 py-3">
{filtered.length === 0 ? (
<p className="text-center text-[12px] text-[var(--ink-muted)] py-8"></p>
) : (
<div className="space-y-1.5">
{filtered.map((req) => (
<label key={req.id} className="flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-[var(--bg-subtle)] cursor-pointer">
<input type="checkbox" checked={selected.has(req.id)} onChange={() => toggle(req.id)} className="h-4 w-4 rounded border-[var(--line)] text-[var(--accent)]" />
<span className="text-[11px] font-mono text-[var(--ink-muted)] shrink-0">{req.code}</span>
<span className="text-[12px] text-[var(--ink)] flex-1 truncate">{req.title}</span>
</label>
))}
</div>
)}
</div>
<div className="flex items-center justify-between px-5 py-4 border-t border-[var(--line)]">
<span className="text-[11px] text-[var(--ink-muted)]"> {selected.size} </span>
<div className="flex gap-2">
<button onClick={onClose} className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"></button>
<button onClick={() => onConfirm(Array.from(selected))} disabled={selected.size === 0} className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] disabled:opacity-50"></button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -2,10 +2,11 @@ export interface OvertimeRecord {
id: string; id: string;
projectId: string; projectId: string;
versionId?: string; versionId?: string;
requirementId?: string;
person: string; person: string;
startTime: string; startTime: string;
endTime: string; endTime: string;
duration: number; // 小时,自动计算 duration: number;
reasonId: string; reasonId: string;
remark?: string; remark?: string;
createdAt: string; createdAt: string;

View File

@@ -37,6 +37,7 @@ export interface Requirement {
creator: string; creator: string;
createdAt: string; createdAt: string;
parentId?: string; parentId?: string;
addedToVersionBy?: string;
} }
export const SOURCE_TYPE_LABEL: Record<SourceType, string> = { export const SOURCE_TYPE_LABEL: Record<SourceType, string> = {

View File

@@ -0,0 +1,65 @@
export interface VersionPlan {
id: string;
versionId: string;
type: 'research' | 'product' | 'ui';
title: string;
owner: string;
startTime: string;
endTime: string;
status: 'pending' | 'in_progress' | 'completed';
linkedRequirementIds?: string[];
resultType?: 'link' | 'file';
resultUrl?: string;
resultFileName?: string;
resultFileData?: string;
remark?: string;
overdueReason?: string;
createdAt: string;
completedAt?: string;
addedBy: string;
}
export type PlanType = VersionPlan['type'];
export function calcPlanDuration(start: string, end: string): { days: number; hours: number } {
const s = new Date(start).getTime();
const e = new Date(end).getTime();
if (isNaN(s) || isNaN(e) || e <= s) return { days: 0, hours: 0 };
const totalDays = Math.ceil((e - s) / (1000 * 60 * 60 * 24));
return { days: totalDays, hours: totalDays * 8 };
}
export function formatDuration(days: number, _hours: number): string {
if (days === 0) return '0天';
return `${days}`;
}
export function calcTotalDuration(plans: VersionPlan[]): string {
const completedOrActive = plans.filter((p) => p.status !== 'pending');
if (completedOrActive.length === 0) return '0天';
const intervals = completedOrActive.map((p) => ({
start: new Date(p.startTime).getTime(),
end: p.completedAt ? new Date(p.completedAt).getTime() : new Date(p.endTime).getTime(),
})).filter((i) => i.end > i.start).sort((a, b) => a.start - b.start);
if (intervals.length === 0) return '0天';
let totalMs = 0;
let currentStart = intervals[0].start;
let currentEnd = intervals[0].end;
for (let i = 1; i < intervals.length; i++) {
if (intervals[i].start <= currentEnd) {
currentEnd = Math.max(currentEnd, intervals[i].end);
} else {
totalMs += currentEnd - currentStart;
currentStart = intervals[i].start;
currentEnd = intervals[i].end;
}
}
totalMs += currentEnd - currentStart;
const totalDays = Math.ceil(totalMs / (1000 * 60 * 60 * 24));
return `${totalDays}`;
}

View File

@@ -0,0 +1,91 @@
'use client';
import { create } from 'zustand';
interface AuthUser {
id: string;
name: string;
roleId: string;
departmentId: string;
phone: string;
email: string;
}
interface AuthState {
user: AuthUser | null;
isAuthenticated: boolean;
login: (phone: string, password: string, remember: boolean) => boolean;
logout: () => void;
checkAuth: () => void;
}
const SESSION_KEY = 'ftb_auth_session';
const PERSIST_KEY = 'ftb_auth_persist';
export const useAuthStore = create<AuthState>((set) => ({
user: null,
isAuthenticated: false,
login: (phone, password, remember) => {
const membersRaw = localStorage.getItem('ftb_members_v1');
let members: any[] = [];
if (membersRaw) {
const parsed = JSON.parse(membersRaw);
members = parsed.members || [];
}
// fallback: 如果 localStorage 没有数据或为空,使用默认 mock
if (members.length === 0) {
members = [
{ id: 'm-1', name: '张三', departmentId: 'dept-1', roleId: 'role-pm', phone: '13800138001', email: 'zhangsan@company.com', password: 'Ftb@2024' },
{ id: 'm-2', name: '李四', departmentId: 'dept-2-1', roleId: 'role-dev', phone: '13900139002', email: 'lisi@company.com', password: 'Ftb@2024' },
{ id: 'm-3', name: '王五', departmentId: 'dept-2-2', roleId: 'role-dev', phone: '13700137003', email: 'wangwu@company.com', password: 'Ftb@2024' },
{ id: 'm-4', name: '赵六', departmentId: 'dept-2-3', roleId: 'role-test', phone: '13600136004', email: 'zhaoliu@company.com', password: 'Ftb@2024' },
{ id: 'm-5', name: '孙七', departmentId: 'dept-3', roleId: 'role-design', phone: '13500135005', email: 'sunqi@company.com', password: 'Ftb@2024' },
{ id: 'm-6', name: '周八', departmentId: 'dept-4', roleId: 'role-pm', phone: '13400134006', email: 'zhouba@company.com', password: 'Ftb@2024' },
{ id: 'm-7', name: '吴九', departmentId: 'dept-2-1', roleId: 'role-dev', phone: '13300133007', email: 'wujiu@company.com', password: 'Ftb@2024' },
{ id: 'm-8', name: '陈十', departmentId: 'dept-1', roleId: 'role-admin', phone: '13200132008', email: 'chenshi@company.com', password: 'Ftb@2024' },
];
}
const member = members.find((m: any) => m.phone === phone && (m.password || 'Ftb@2024') === password);
if (!member) return false;
const user: AuthUser = {
id: member.id,
name: member.name,
roleId: member.roleId,
departmentId: member.departmentId,
phone: member.phone,
email: member.email,
};
set({ user, isAuthenticated: true });
sessionStorage.setItem(SESSION_KEY, JSON.stringify(user));
if (remember) {
localStorage.setItem(PERSIST_KEY, JSON.stringify(user));
}
return true;
},
logout: () => {
set({ user: null, isAuthenticated: false });
sessionStorage.removeItem(SESSION_KEY);
localStorage.removeItem(PERSIST_KEY);
},
checkAuth: () => {
const session = sessionStorage.getItem(SESSION_KEY);
if (session) {
set({ user: JSON.parse(session), isAuthenticated: true });
return;
}
const persist = localStorage.getItem(PERSIST_KEY);
if (persist) {
const user = JSON.parse(persist);
set({ user, isAuthenticated: true });
sessionStorage.setItem(SESSION_KEY, JSON.stringify(user));
return;
}
},
}));

View File

@@ -40,43 +40,10 @@ const MOCK_OVERVIEW: ProductOverview[] = [
createdAt: '2024-01-01', createdAt: '2024-01-01',
updatedAt: '2024-06-01', updatedAt: '2024-06-01',
projects: [ projects: [
{ id: 'p1', name: '考勤', description: '员工打卡、排班管理', createdAt: '2024-01-10' },
{ id: 'p2', name: '电子合同', description: '入职、续签、离职合同电子化', createdAt: '2024-02-15' },
{ id: 'p3', name: '值班', description: '门店值班排班与交接', createdAt: '2024-03-01' }, { id: 'p3', name: '值班', description: '门店值班排班与交接', createdAt: '2024-03-01' },
], ],
versions: [ versions: [],
{ id: 'v1', name: '考勤V1.0', status: 'released', releaseDate: '2024-02-01', createdAt: '2024-01-20', currentStage: 'released', startDate: '2024-01-20', members: [{ role: 'product', name: '张三' }, { role: 'frontend', name: '赵六' }, { role: 'backend', name: '孙八' }, { role: 'testing', name: '周九' }], progress: [{ role: 'product', percent: 100, daysSpent: 3 }, { role: 'frontend', percent: 100, daysSpent: 5 }, { role: 'backend', percent: 100, daysSpent: 4 }, { role: 'testing', percent: 100, daysSpent: 2 }] }, _count: { requirements: 10, projects: 1, versions: 0 },
{ id: 'v2', name: '考勤V1.2', status: 'developing', releaseDate: null, createdAt: '2024-04-15', currentStage: 'dev', startDate: '2024-04-15', expectedReleaseDate: '2024-06-10', priority: 'P1', links: { research: 'https://docs.example.com/kaoqin-v12', prototype: 'https://axure.example.com/kaoqin-v12' }, members: [{ role: 'product', name: '张三' }, { role: 'ui', name: '王五' }, { role: 'frontend', name: '赵六' }, { role: 'backend', name: '孙八' }, { role: 'testing', name: '周九' }], progress: [{ role: 'product', percent: 100, daysSpent: 5 }, { role: 'ui', percent: 100, daysSpent: 7 }, { role: 'frontend', percent: 60, daysSpent: 10 }, { role: 'backend', percent: 40, daysSpent: 12 }, { role: 'testing', percent: 0, daysSpent: 0 }] },
{ id: 'v3', name: '考勤V1.3', status: 'planned', releaseDate: null, createdAt: '2024-06-01' },
{ id: 'v4', name: '电子合同V1.0', status: 'released', releaseDate: '2024-03-20', createdAt: '2024-03-01', currentStage: 'released', startDate: '2024-03-01', members: [{ role: 'product', name: '李四' }, { role: 'frontend', name: '钱七' }, { role: 'backend', name: '孙八' }, { role: 'testing', name: '周九' }], progress: [{ role: 'product', percent: 100, daysSpent: 4 }, { role: 'frontend', percent: 100, daysSpent: 8 }, { role: 'backend', percent: 100, daysSpent: 6 }, { role: 'testing', percent: 100, daysSpent: 3 }] },
{ id: 'v5', name: '电子合同V2.0', status: 'released', releaseDate: '2024-06-01', createdAt: '2024-05-20', currentStage: 'released', startDate: '2024-05-01', members: [{ role: 'product', name: '李四' }, { role: 'ui', name: '王五' }, { role: 'frontend', name: '钱七' }, { role: 'backend', name: '孙八' }, { role: 'testing', name: '周九' }], progress: [{ role: 'product', percent: 100, daysSpent: 5 }, { role: 'ui', percent: 100, daysSpent: 6 }, { role: 'frontend', percent: 100, daysSpent: 12 }, { role: 'backend', percent: 100, daysSpent: 10 }, { role: 'testing', percent: 100, daysSpent: 4 }] },
{ id: 'v6', name: '电子合同V2.1', status: 'developing', releaseDate: null, createdAt: '2024-06-05', currentStage: 'ui_design', startDate: '2024-06-05', expectedReleaseDate: '2024-07-15', priority: 'P2', links: { research: 'https://docs.example.com/contract-v21' }, members: [{ role: 'product', name: '李四' }, { role: 'ui', name: '王五' }, { role: 'frontend', name: '钱七' }, { role: 'backend', name: '孙八' }, { role: 'testing', name: '周九' }], progress: [{ role: 'product', percent: 100, daysSpent: 3 }, { role: 'ui', percent: 50, daysSpent: 4 }, { role: 'frontend', percent: 0, daysSpent: 0 }, { role: 'backend', percent: 0, daysSpent: 0 }, { role: 'testing', percent: 0, daysSpent: 0 }] },
{ id: 'v7', name: '值班V1.0', status: 'released', releaseDate: '2024-04-01', createdAt: '2024-03-15', currentStage: 'released', startDate: '2024-03-15', members: [{ role: 'product', name: '张三' }, { role: 'frontend', name: '赵六' }, { role: 'testing', name: '周九' }], progress: [{ role: 'product', percent: 100, daysSpent: 3 }, { role: 'frontend', percent: 100, daysSpent: 8 }, { role: 'testing', percent: 100, daysSpent: 3 }] },
{ id: 'v8', name: '值班V1.1', status: 'released', releaseDate: '2024-05-15', createdAt: '2024-05-01', currentStage: 'released', startDate: '2024-05-01', members: [{ role: 'product', name: '张三' }, { role: 'frontend', name: '赵六' }, { role: 'backend', name: '孙八' }, { role: 'testing', name: '周九' }], progress: [{ role: 'product', percent: 100, daysSpent: 2 }, { role: 'frontend', percent: 100, daysSpent: 5 }, { role: 'backend', percent: 100, daysSpent: 4 }, { role: 'testing', percent: 100, daysSpent: 2 }] },
{ id: 'v9', name: '值班V1.2', status: 'developing', releaseDate: null, createdAt: '2024-06-02', currentStage: 'testing', startDate: '2024-06-02', expectedReleaseDate: '2024-06-20', priority: 'P0', members: [{ role: 'product', name: '张三' }, { role: 'frontend', name: '赵六' }, { role: 'backend', name: '孙八' }, { role: 'testing', name: '周九' }], progress: [{ role: 'product', percent: 100, daysSpent: 2 }, { role: 'frontend', percent: 100, daysSpent: 6 }, { role: 'backend', percent: 100, daysSpent: 5 }, { role: 'testing', percent: 30, daysSpent: 2 }] },
{ id: 'v10', name: '值班V2.0', status: 'planned', releaseDate: null, createdAt: '2024-06-08' },
],
_count: { requirements: 12, projects: 3, versions: 10 },
},
{
id: 'mock-2',
name: '智慧收银',
description: '收银台 POS + 会员支付一体化',
createdAt: '2024-03-01',
updatedAt: '2024-06-01',
projects: [
{ id: 'p4', name: '支付', description: '微信/支付宝/现金多渠道收银', createdAt: '2024-03-10' },
{ id: 'p5', name: '会员', description: '储值卡、积分、优惠券', createdAt: '2024-04-01' },
],
versions: [
{ id: 'v11', name: '支付V1.0', status: 'released', releaseDate: '2024-04-20', createdAt: '2024-04-01', currentStage: 'released', startDate: '2024-04-01', members: [{ role: 'product', name: '李四' }, { role: 'frontend', name: '钱七' }, { role: 'backend', name: '吴十' }, { role: 'testing', name: '郑十一' }], progress: [{ role: 'product', percent: 100, daysSpent: 4 }, { role: 'frontend', percent: 100, daysSpent: 8 }, { role: 'backend', percent: 100, daysSpent: 7 }, { role: 'testing', percent: 100, daysSpent: 3 }] },
{ id: 'v12', name: '支付V1.1', status: 'released', releaseDate: '2024-06-01', createdAt: '2024-05-25', currentStage: 'released', startDate: '2024-05-10', members: [{ role: 'product', name: '李四' }, { role: 'frontend', name: '钱七' }, { role: 'backend', name: '吴十' }, { role: 'testing', name: '郑十一' }], progress: [{ role: 'product', percent: 100, daysSpent: 3 }, { role: 'frontend', percent: 100, daysSpent: 7 }, { role: 'backend', percent: 100, daysSpent: 6 }, { role: 'testing', percent: 100, daysSpent: 3 }] },
{ id: 'v13', name: '支付V2.0', status: 'developing', releaseDate: null, createdAt: '2024-06-05', currentStage: 'dev', startDate: '2024-06-05', expectedReleaseDate: '2024-07-20', priority: 'P1', links: { research: 'https://docs.example.com/pay-v20', prototype: 'https://axure.example.com/pay-v20', ui: 'https://figma.com/pay-v20' }, members: [{ role: 'product', name: '李四' }, { role: 'ui', name: '王五' }, { role: 'frontend', name: '钱七' }, { role: 'backend', name: '吴十' }, { role: 'testing', name: '郑十一' }], progress: [{ role: 'product', percent: 100, daysSpent: 4 }, { role: 'ui', percent: 100, daysSpent: 5 }, { role: 'frontend', percent: 30, daysSpent: 5 }, { role: 'backend', percent: 20, daysSpent: 4 }, { role: 'testing', percent: 0, daysSpent: 0 }] },
{ id: 'v14', name: '会员V1.0', status: 'released', releaseDate: '2024-05-10', createdAt: '2024-04-20', currentStage: 'released', startDate: '2024-04-20', members: [{ role: 'product', name: '张三' }, { role: 'frontend', name: '赵六' }, { role: 'backend', name: '吴十' }, { role: 'testing', name: '郑十一' }], progress: [{ role: 'product', percent: 100, daysSpent: 4 }, { role: 'frontend', percent: 100, daysSpent: 9 }, { role: 'backend', percent: 100, daysSpent: 7 }, { role: 'testing', percent: 100, daysSpent: 3 }] },
{ id: 'v15', name: '会员V1.1', status: 'developing', releaseDate: null, createdAt: '2024-06-03', currentStage: 'integration', startDate: '2024-06-03', expectedReleaseDate: '2024-06-25', priority: 'P2', members: [{ role: 'product', name: '张三' }, { role: 'ui', name: '王五' }, { role: 'frontend', name: '赵六' }, { role: 'backend', name: '吴十' }, { role: 'testing', name: '郑十一' }], progress: [{ role: 'product', percent: 100, daysSpent: 3 }, { role: 'ui', percent: 100, daysSpent: 4 }, { role: 'frontend', percent: 90, daysSpent: 8 }, { role: 'backend', percent: 85, daysSpent: 7 }, { role: 'testing', percent: 0, daysSpent: 0 }] },
{ id: 'v16', name: '会员V2.0', status: 'planned', releaseDate: null, createdAt: '2024-06-08' },
],
_count: { requirements: 8, projects: 2, versions: 6 },
}, },
]; ];
@@ -111,6 +78,7 @@ interface ProductState {
createProject: (productId: string, data: { name: string; description?: string }) => void; createProject: (productId: 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;
} }
export const useProductStore = create<ProductState>((set, get) => ({ export const useProductStore = create<ProductState>((set, get) => ({
@@ -259,12 +227,13 @@ export const useProductStore = create<ProductState>((set, get) => ({
}, },
createVersion: (productId, data) => { createVersion: (productId, data) => {
const newVersion = { const newVersion: VersionItem = {
id: `ver-${Date.now()}`, id: `ver-${Date.now()}`,
name: data.name, name: data.name,
status: data.status, status: data.status,
releaseDate: null, releaseDate: null,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
...(data as any),
}; };
const updated = get().overview.map((p) => { const updated = get().overview.map((p) => {
if (p.id !== productId) return p; if (p.id !== productId) return p;
@@ -291,9 +260,22 @@ export const useProductStore = create<ProductState>((set, get) => ({
set({ overview: updated }); set({ overview: updated });
saveLocal(updated); saveLocal(updated);
}, },
deleteVersion: (productId, versionId) => {
const updated = get().overview.map((p) => {
if (p.id !== productId) return p;
return {
...p,
versions: p.versions.filter((v) => v.id !== versionId),
_count: { ...p._count, versions: Math.max(0, (p._count?.versions ?? 0) - 1) } as any,
};
});
set({ overview: updated });
saveLocal(updated);
},
})); }));
const STORAGE_KEY = 'ftb_products_overview_v2'; const STORAGE_KEY = 'ftb_products_overview_v4';
function saveLocal(data: ProductOverview[]) { function saveLocal(data: ProductOverview[]) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch {} try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch {}

View File

@@ -76,151 +76,21 @@ const PRESET_PLATFORMS: DictItem[] = [
// --- Mock requirements --- // --- Mock requirements ---
const MOCK_REQUIREMENTS: Requirement[] = [ const MOCK_REQUIREMENTS: Requirement[] = [
{ { id: 'req-1', code: 'REQ-001', title: '值班排班自动生成', description: '根据员工可用时间自动排班,支持轮换规则', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P1', effort: 'L', sourceType: 'internal', sourceTarget: '运营部', platforms: ['platform-1'], typeId: 'type-1', creator: '张三', createdAt: '2024-06-01' },
id: 'req-1', { id: 'req-2', code: 'REQ-002', title: '值班交接确认', description: '交接班时双方确认签到,记录交接内容', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P1', effort: 'M', sourceType: 'internal', sourceTarget: '门店经理', platforms: ['platform-1'], typeId: 'type-1', creator: '张三', createdAt: '2024-06-01' },
code: 'REQ-001', { id: 'req-3', code: 'REQ-003', title: '值班异常提醒', description: '迟到、缺勤自动推送提醒给店长', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P0', effort: 'M', sourceType: 'customer', sourceTarget: '连锁品牌A', platforms: ['platform-1', 'platform-2'], typeId: 'type-1', creator: '李四', createdAt: '2024-06-02' },
title: '考勤打卡支持人脸识别', { id: 'req-4', code: 'REQ-004', title: '值班日历视图', description: '以日历形式展示当月值班安排', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P2', effort: 'L', sourceType: 'internal', sourceTarget: '产品部', platforms: ['platform-1'], typeId: 'type-2', creator: '张三', createdAt: '2024-06-03' },
description: '员工打卡增加人脸识别验证方式,防止代打卡', { id: 'req-5', code: 'REQ-005', title: '值班换班申请', description: '员工可发起换班申请,经理审批', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P2', effort: 'M', sourceType: 'customer', sourceTarget: '连锁品牌B', platforms: ['platform-1'], typeId: 'type-1', creator: '李四', createdAt: '2024-06-03' },
productId: 'mock-1', { id: 'req-6', code: 'REQ-006', title: '值班统计报表', description: '按月/周统计各员工值班时长、缺勤次数', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P2', effort: 'L', sourceType: 'internal', sourceTarget: '运营部', platforms: ['platform-1'], typeId: 'type-3', creator: '张三', createdAt: '2024-06-04' },
projectId: 'p1', { id: 'req-7', code: 'REQ-007', title: '多门店值班管理', description: '区域经理可查看管辖所有门店值班情况', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P1', effort: 'XL', sourceType: 'management', sourceTarget: '区域总监', platforms: ['platform-1', 'platform-3'], typeId: 'type-1', creator: '张三', createdAt: '2024-06-05' },
versionId: 'v2', { id: 'req-8', code: 'REQ-008', title: '值班备注功能', description: '排班时可添加备注(如特殊事项提醒)', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P3', effort: 'S', sourceType: 'internal', sourceTarget: '产品部', platforms: ['platform-1'], typeId: 'type-2', creator: '李四', createdAt: '2024-06-05' },
sourceType: 'customer', { id: 'req-9', code: 'REQ-009', title: '法定节假日排班规则', description: '节假日自动应用特殊排班规则(加班费标记)', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P1', effort: 'M', sourceType: 'operation', sourceTarget: '合规部', platforms: ['platform-1'], typeId: 'type-1', creator: '张三', createdAt: '2024-06-06' },
sourceTarget: '海底捞', { id: 'req-10', code: 'REQ-010', title: '值班模板管理', description: '创建值班模板供快速复用(如早中晚三班)', productId: 'mock-1', projectId: 'p3', status: 'pending_review', priority: 'P2', effort: 'M', sourceType: 'internal', sourceTarget: '产品部', platforms: ['platform-1'], typeId: 'type-1', creator: '张三', createdAt: '2024-06-06' },
platforms: ['platform-2', 'platform-3'],
typeId: 'type-1',
status: 'developing',
priority: 'P1',
effort: 'L',
creator: '张三',
createdAt: '2024-04-10',
},
{
id: 'req-2',
code: 'REQ-002',
title: '电子合同批量签署',
description: '支持企业批量发起合同签署,提升入职效率',
productId: 'mock-1',
projectId: 'p2',
versionId: 'v13',
sourceType: 'operation',
sourceTarget: '运营部',
platforms: ['platform-1'],
typeId: 'type-1',
status: 'pending_review',
priority: 'P2',
effort: 'XL',
creator: '李四',
createdAt: '2024-05-02',
},
{
id: 'req-3',
code: 'REQ-003',
title: '支付渠道对账异常修复',
description: '修复微信支付对账金额不一致的问题',
productId: 'mock-2',
projectId: 'p4',
sourceType: 'customer',
sourceTarget: '西贝',
platforms: ['platform-1', 'platform-4'],
typeId: 'type-2',
status: 'testing',
priority: 'P0',
effort: 'M',
creator: '王五',
createdAt: '2024-05-15',
},
{
id: 'req-4',
code: 'REQ-004',
title: '会员积分过期提醒',
description: '积分到期前7天推送小程序模板消息通知',
productId: 'mock-2',
projectId: 'p5',
versionId: 'v13',
sourceType: 'management',
sourceTarget: '张总',
platforms: ['platform-4', 'platform-5'],
typeId: 'type-4',
status: 'planned',
priority: 'P2',
effort: 'S',
creator: '张三',
createdAt: '2024-05-20',
},
{
id: 'req-5',
code: 'REQ-005',
title: '值班交接单增加拍照上传',
description: '值班交接时支持拍照上传现场情况',
productId: 'mock-1',
projectId: 'p3',
sourceType: 'internal',
sourceTarget: '产品部',
platforms: ['platform-2', 'platform-3'],
typeId: 'type-1',
status: 'adopted',
priority: 'P3',
effort: 'M',
creator: '李四',
createdAt: '2024-05-28',
},
{
id: 'req-6',
code: 'REQ-006',
title: '考勤报表导出性能优化',
description: '大数据量下考勤报表导出超时,需优化查询和导出逻辑',
productId: 'mock-1',
projectId: 'p1',
versionId: 'v2',
sourceType: 'customer',
sourceTarget: '喜茶',
platforms: ['platform-1'],
typeId: 'type-7',
status: 'developing',
priority: 'P1',
effort: 'M',
creator: '王五',
createdAt: '2024-06-01',
},
{
id: 'req-7',
code: 'REQ-007',
title: '支付模块埋点需求',
description: '支付成功率、失败原因分布等核心指标埋点',
productId: 'mock-2',
projectId: 'p4',
sourceType: 'aftersale',
sourceTarget: '客服部',
platforms: ['platform-1', 'platform-4', 'platform-5'],
typeId: 'type-3',
status: 'released',
priority: 'P2',
effort: 'S',
creator: '张三',
createdAt: '2024-04-25',
},
{
id: 'req-8',
code: 'REQ-008',
title: '会员模块重构-拆分储值与积分服务',
description: '将储值卡和积分系统拆分为独立微服务,降低耦合度',
productId: 'mock-2',
projectId: 'p5',
sourceType: 'competitor',
sourceTarget: '美团收银',
platforms: ['platform-1'],
typeId: 'type-5',
status: 'pending_review',
priority: 'P3',
effort: 'XL',
creator: '李四',
createdAt: '2024-06-05',
},
]; ];
// --- localStorage helpers --- // --- localStorage helpers ---
const STORAGE_KEY = 'ftb_requirements_v2'; const STORAGE_KEY = 'ftb_requirements_v3';
function saveLocal(state: { requirements: Requirement[]; sourceTargets: SourceTarget[]; types: DictItem[]; platforms: DictItem[] }) { function saveLocal(state: { requirements: Requirement[]; sourceTargets: SourceTarget[]; types: DictItem[]; platforms: DictItem[] }) {
try { try {

View File

@@ -0,0 +1,62 @@
'use client';
import { create } from 'zustand';
import type { VersionPlan, PlanType } from '@/lib/version-plan';
const STORAGE_KEY = 'ftb_version_plans_v1';
const MOCK_PLANS: VersionPlan[] = [];
function saveLocal(plans: VersionPlan[]) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(plans)); } catch {}
}
function loadLocal(): VersionPlan[] | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
} catch {}
return null;
}
interface VersionPlanState {
plans: VersionPlan[];
fetchPlans: () => void;
createPlan: (data: Omit<VersionPlan, 'id' | 'createdAt'>) => void;
updatePlan: (id: string, data: Partial<VersionPlan>) => void;
completePlan: (id: string, result: { resultType: 'link' | 'file'; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => void;
deletePlan: (id: string) => void;
}
export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
plans: MOCK_PLANS,
fetchPlans: () => {
const cached = loadLocal();
if (cached) set({ plans: cached });
},
createPlan: (data) => {
const plan: VersionPlan = { ...data, id: `plan-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10) };
const plans = [...get().plans, plan];
set({ plans });
saveLocal(plans);
},
updatePlan: (id, data) => {
const plans = get().plans.map((p) => p.id === id ? { ...p, ...data } : p);
set({ plans });
saveLocal(plans);
},
completePlan: (id, result) => {
const plans = get().plans.map((p) => p.id === id ? { ...p, ...result, status: 'completed' as const, completedAt: new Date().toISOString() } : p);
set({ plans });
saveLocal(plans);
},
deletePlan: (id) => {
const plans = get().plans.filter((p) => p.id !== id);
set({ plans });
saveLocal(plans);
},
}));