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:
@@ -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>
|
||||||
|
|||||||
@@ -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
124
apps/web/app/login/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,6 +139,29 @@ 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' ? (
|
||||||
|
(() => {
|
||||||
|
const versionReqs = requirements.filter((r) => r.versionId === version.id);
|
||||||
|
const versionOT = records.filter((r) => r.versionId === version.id);
|
||||||
|
const totalOTHours = Math.round(versionOT.reduce((sum, r) => sum + r.duration, 0) * 10) / 10;
|
||||||
|
|
||||||
|
// 人员加班排名
|
||||||
|
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 }));
|
||||||
|
|
||||||
|
// 加班原因占比
|
||||||
|
const reasonMap: Record<string, number> = {};
|
||||||
|
versionOT.forEach((r) => { reasonMap[r.reasonId] = (reasonMap[r.reasonId] || 0) + r.duration; });
|
||||||
|
const reasonRanking = Object.entries(reasonMap).sort((a, b) => b[1] - a[1]);
|
||||||
|
const reasonTotal = reasonRanking.reduce((s, [, v]) => s + v, 0) || 1;
|
||||||
|
|
||||||
|
// Mock 数据占位
|
||||||
|
const mockTaskCount = Math.max(versionReqs.length * 2, 3);
|
||||||
|
const mockBugTotal = Math.max(Math.floor(versionReqs.length * 1.5), 2);
|
||||||
|
const mockBugOpen = Math.max(Math.floor(mockBugTotal * 0.3), 1);
|
||||||
|
const mockTaskPending = Math.max(Math.floor(mockTaskCount * 0.4), 1);
|
||||||
|
|
||||||
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Tag row */}
|
{/* Tag row */}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
@@ -136,14 +187,19 @@ export default function VersionDetailPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Capsule stages */}
|
{/* 统计卡片 */}
|
||||||
<CapsuleStages currentStage={version.currentStage} progress={version.progress} />
|
<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>
|
||||||
|
|
||||||
{/* 风险详情 + 趋势:健康度低于60时显示 */}
|
{/* 风险详情(胶囊条上方) */}
|
||||||
{healthScore < 60 && riskTags.length > 0 && (
|
{riskTags.length > 0 && (
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<div className="rounded-xl border border-orange-200 bg-orange-50/40 p-4 max-h-[200px] overflow-y-auto">
|
||||||
{/* 左:风险详情(可滚动) */}
|
|
||||||
<div className="col-span-3 rounded-xl border border-orange-200 bg-orange-50/40 p-4 max-h-[200px] overflow-y-auto">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<span className="text-[12px] font-semibold text-orange-700">风险详情</span>
|
<span className="text-[12px] font-semibold text-orange-700">风险详情</span>
|
||||||
<span className="text-[10px] text-orange-600">健康度 {healthScore} · {HEALTH_LEVEL_LABEL[healthLevel]}</span>
|
<span className="text-[10px] text-orange-600">健康度 {healthScore} · {HEALTH_LEVEL_LABEL[healthLevel]}</span>
|
||||||
@@ -162,27 +218,61 @@ export default function VersionDetailPage() {
|
|||||||
))}
|
))}
|
||||||
</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>
|
|
||||||
<HealthTrend data={generateMockTrend(healthScore, 7)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 健康度正常时也显示趋势(紧凑) */}
|
{/* Capsule stages */}
|
||||||
{healthScore >= 60 && (
|
<CapsuleStages currentStage={version.currentStage} progress={version.progress} />
|
||||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-3 max-w-[240px]">
|
|
||||||
<span className="text-[10px] font-medium text-[var(--ink-muted)] mb-1 block">健康趋势</span>
|
{/* 双栏:加班排名 + 原因占比 */}
|
||||||
<HealthTrend data={generateMockTrend(healthScore, 7)} />
|
<div 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>
|
||||||
|
|
||||||
{/* Two-column layout: left info + right links */}
|
{/* 加班原因占比 */}
|
||||||
|
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||||
|
<div className="text-[11px] text-[var(--ink-muted)] mb-3 font-medium">加班原因占比</div>
|
||||||
|
{reasonRanking.length === 0 ? (
|
||||||
|
<span className="text-[12px] text-[var(--ink-muted)]">暂无数据</span>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{reasonRanking.map(([reasonId, hours]) => {
|
||||||
|
const percent = Math.round((hours / reasonTotal) * 100);
|
||||||
|
const reasonName = OVERTIME_REASON_LABEL[reasonId] || reasonId;
|
||||||
|
return (
|
||||||
|
<div key={reasonId}>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-[12px] text-[var(--ink-soft)]">{reasonName}</span>
|
||||||
|
<span className="text-[11px] tabular-nums text-[var(--ink-muted)]">{percent}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 rounded-full bg-[var(--bg-subtle)] overflow-hidden">
|
||||||
|
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${percent}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 双栏:日期信息 + 相关链接 */}
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
{/* Left: 2/3 width */}
|
|
||||||
<div className="col-span-2 space-y-4">
|
<div className="col-span-2 space-y-4">
|
||||||
{/* Date + elapsed */}
|
|
||||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
<div className="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-4 text-[13px]">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
@@ -197,8 +287,6 @@ export default function VersionDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Members */}
|
|
||||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
<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>
|
<div className="text-[11px] text-[var(--ink-muted)] mb-2 font-medium">参与人员</div>
|
||||||
{version.members && version.members.length > 0 ? (
|
{version.members && version.members.length > 0 ? (
|
||||||
@@ -208,8 +296,6 @@ export default function VersionDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: 1/3 width - links card */}
|
|
||||||
<div className="col-span-1">
|
<div className="col-span-1">
|
||||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 h-full">
|
<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="text-[11px] text-[var(--ink-muted)] mb-3 font-medium">相关链接</div>
|
||||||
@@ -221,7 +307,56 @@ export default function VersionDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 健康趋势(最底部) */}
|
||||||
|
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 max-w-[320px]">
|
||||||
|
<span className="text-[11px] font-medium text-[var(--ink-muted)] mb-2 block">健康趋势</span>
|
||||||
|
<HealthTrend data={generateMockTrend(healthScore, 7)} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
) : activeTab === 'requirements' ? (
|
||||||
|
<VersionRequirementsTab
|
||||||
|
versionId={version.id}
|
||||||
|
projectId={version.projectId}
|
||||||
|
requirements={requirements}
|
||||||
|
currentUserName={user?.name ?? ''}
|
||||||
|
onLink={(ids, addedBy) => {
|
||||||
|
ids.forEach((id) => updateRequirement(id, { versionId: version.id, addedToVersionBy: addedBy }));
|
||||||
|
}}
|
||||||
|
onUnlink={(id) => updateRequirement(id, { versionId: undefined, addedToVersionBy: undefined })}
|
||||||
|
/>
|
||||||
|
) : (activeTab === 'research' || activeTab === 'product' || activeTab === 'ui') ? (
|
||||||
|
(() => {
|
||||||
|
const pt = activeTab as 'research' | 'product' | 'ui';
|
||||||
|
const versionReqs = requirements.filter((r) => r.versionId === version.id);
|
||||||
|
const linkedReqs = versionReqs.map((r) => ({ id: r.id, title: r.title, code: r.code, productOwner: r.productOwner }));
|
||||||
|
return (
|
||||||
|
<PlanTab
|
||||||
|
plans={plans}
|
||||||
|
versionId={version.id}
|
||||||
|
versionDeadline={version.expectedReleaseDate ?? undefined}
|
||||||
|
currentUserName={user?.name ?? ''}
|
||||||
|
planType={pt}
|
||||||
|
linkedRequirements={pt !== 'research' ? linkedReqs : undefined}
|
||||||
|
onCreate={(data) => {
|
||||||
|
createPlan(data);
|
||||||
|
if ((pt === 'product') && data.linkedRequirementIds?.length) {
|
||||||
|
data.linkedRequirementIds.forEach((rid) => updateRequirement(rid, { productOwner: data.owner }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onUpdate={(id, data) => {
|
||||||
|
updatePlan(id, data);
|
||||||
|
if ((pt === 'product') && data.linkedRequirementIds && data.owner) {
|
||||||
|
data.linkedRequirementIds.forEach((rid) => updateRequirement(rid, { productOwner: data.owner }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onComplete={completePlan}
|
||||||
|
onDelete={deletePlan}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-12 flex items-center justify-center">
|
<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">
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
33
apps/web/components/AuthGuard.tsx
Normal file
33
apps/web/components/AuthGuard.tsx
Normal 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}</>;
|
||||||
|
}
|
||||||
23
apps/web/components/layout/LayoutShell.tsx
Normal file
23
apps/web/components/layout/LayoutShell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) */}
|
||||||
|
|||||||
311
apps/web/components/version/PlanTab.tsx
Normal file
311
apps/web/components/version/PlanTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
apps/web/components/version/VersionRequirementsTab.tsx
Normal file
138
apps/web/components/version/VersionRequirementsTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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> = {
|
||||||
|
|||||||
65
apps/web/lib/version-plan.ts
Normal file
65
apps/web/lib/version-plan.ts
Normal 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}天`;
|
||||||
|
}
|
||||||
91
apps/web/stores/useAuthStore.ts
Normal file
91
apps/web/stores/useAuthStore.ts
Normal 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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
62
apps/web/stores/useVersionPlanStore.ts
Normal file
62
apps/web/stores/useVersionPlanStore.ts
Normal 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);
|
||||||
|
},
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user