feat: 实现需求管理、加班记录、成员/角色管理模块

- 需求模块:完整 CRUD、状态流转(采纳/拒绝/关闭)、详情抽屉、产品→项目级联选择
- 加班记录:产品→项目→版本三级联动、月份筛选(MonthPicker)、CSV 导出
- 成员管理:左右布局(部门树+成员列表)、手机号脱敏、初始密码自动生成及规则设置
- 角色管理:卡片列表、系统角色保护、CRUD
- 通用组件:FilterSelect 下拉、MonthPicker 月份选择器、Pagination 分页
- 样式统一:状态标签加 border、日期输入现代化、筛选组件风格一致

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Script Generator
2026-06-09 18:16:18 +08:00
parent 24ba61f929
commit 9a0b16a8f1
36 changed files with 4355 additions and 272 deletions

View File

@@ -0,0 +1,409 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Plus, Pencil, Trash2, X, ChevronRight, FolderOpen, Settings } from 'lucide-react';
import { useMemberStore } from '@/stores/useMemberStore';
import { maskPhone, generatePassword } from '@/lib/members';
import type { Member, Department, PasswordRule } from '@/lib/members';
export default function MembersPage() {
const { departments, members, roles, passwordRule, fetchMembers, updatePasswordRule, createDepartment, updateDepartment, deleteDepartment, createMember, updateMember, deleteMember } = useMemberStore();
const [activeDeptId, setActiveDeptId] = useState<string | null>(null);
const [showMemberModal, setShowMemberModal] = useState(false);
const [editingMember, setEditingMember] = useState<Member | null>(null);
const [showDeptModal, setShowDeptModal] = useState(false);
const [editingDept, setEditingDept] = useState<Department | null>(null);
const [expandedDepts, setExpandedDepts] = useState<Set<string>>(new Set(['dept-2']));
const [showPwdRuleModal, setShowPwdRuleModal] = useState(false);
useEffect(() => { fetchMembers(); }, [fetchMembers]);
const topDepts = useMemo(() => departments.filter((d) => !d.parentId).sort((a, b) => a.order - b.order), [departments]);
const childDepts = (parentId: string) => departments.filter((d) => d.parentId === parentId).sort((a, b) => a.order - b.order);
const filteredMembers = useMemo(() => {
if (!activeDeptId) return members;
const deptIds = [activeDeptId, ...departments.filter((d) => d.parentId === activeDeptId).map((d) => d.id)];
return members.filter((m) => deptIds.includes(m.departmentId));
}, [members, activeDeptId, departments]);
const activeDeptName = activeDeptId ? departments.find((d) => d.id === activeDeptId)?.name ?? '全部' : '全部成员';
const roleName = (roleId: string) => roles.find((r) => r.id === roleId)?.name ?? '-';
const toggleExpand = (id: string) => {
setExpandedDepts((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
return (
<div className="flex h-full">
{/* Left: Departments */}
<div className="w-[220px] shrink-0 border-r border-[var(--line)] bg-[var(--bg-card)] flex flex-col">
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--line)]">
<span className="text-[13px] font-semibold text-[var(--ink)]"></span>
<button onClick={() => { setEditingDept(null); setShowDeptModal(true); }} className="p-1 rounded-md hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)] hover:text-[var(--accent)]">
<Plus className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex-1 overflow-y-auto py-1">
{/* All */}
<button
onClick={() => setActiveDeptId(null)}
className={`w-full flex items-center gap-2 px-4 py-2 text-[12px] transition-colors ${!activeDeptId ? 'bg-[var(--accent-soft)] text-[var(--accent)] font-medium' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
>
<FolderOpen className="h-3.5 w-3.5" />
<span className="ml-auto text-[11px] tabular-nums text-[var(--ink-muted)]">{members.length}</span>
</button>
{/* Tree */}
{topDepts.map((dept) => {
const children = childDepts(dept.id);
const expanded = expandedDepts.has(dept.id);
const count = members.filter((m) => m.departmentId === dept.id || children.some((c) => c.id === m.departmentId)).length;
return (
<div key={dept.id}>
<div className={`group flex items-center px-4 py-2 text-[12px] cursor-pointer transition-colors ${activeDeptId === dept.id ? 'bg-[var(--accent-soft)] text-[var(--accent)] font-medium' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}>
{children.length > 0 && (
<button onClick={() => toggleExpand(dept.id)} className="p-0.5 mr-1 rounded hover:bg-[var(--bg-card)]">
<ChevronRight className={`h-3 w-3 transition-transform ${expanded ? 'rotate-90' : ''}`} />
</button>
)}
{children.length === 0 && <span className="w-4" />}
<span onClick={() => setActiveDeptId(dept.id)} className="flex-1">{dept.name}</span>
<span className="text-[11px] tabular-nums text-[var(--ink-muted)]">{count}</span>
<div className="hidden group-hover:flex items-center ml-1 gap-0.5">
<button onClick={(e) => { e.stopPropagation(); setEditingDept(dept); setShowDeptModal(true); }} className="p-0.5 rounded hover:bg-[var(--bg-card)] text-[var(--ink-muted)]">
<Pencil className="h-3 w-3" />
</button>
<button onClick={(e) => { e.stopPropagation(); deleteDepartment(dept.id); if (activeDeptId === dept.id) setActiveDeptId(null); }} className="p-0.5 rounded hover:bg-[var(--bg-card)] text-[var(--ink-muted)] hover:text-red-500">
<Trash2 className="h-3 w-3" />
</button>
</div>
</div>
{expanded && children.map((child) => {
const childCount = members.filter((m) => m.departmentId === child.id).length;
return (
<div
key={child.id}
onClick={() => setActiveDeptId(child.id)}
className={`group flex items-center pl-9 pr-4 py-2 text-[12px] cursor-pointer transition-colors ${activeDeptId === child.id ? 'bg-[var(--accent-soft)] text-[var(--accent)] font-medium' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
>
<span className="flex-1">{child.name}</span>
<span className="text-[11px] tabular-nums text-[var(--ink-muted)]">{childCount}</span>
<div className="hidden group-hover:flex items-center ml-1 gap-0.5">
<button onClick={(e) => { e.stopPropagation(); setEditingDept(child); setShowDeptModal(true); }} className="p-0.5 rounded hover:bg-[var(--bg-card)] text-[var(--ink-muted)]">
<Pencil className="h-3 w-3" />
</button>
<button onClick={(e) => { e.stopPropagation(); deleteDepartment(child.id); if (activeDeptId === child.id) setActiveDeptId(null); }} className="p-0.5 rounded hover:bg-[var(--bg-card)] text-[var(--ink-muted)] hover:text-red-500">
<Trash2 className="h-3 w-3" />
</button>
</div>
</div>
);
})}
</div>
);
})}
</div>
</div>
{/* Right: Member list */}
<div className="flex-1 flex flex-col overflow-hidden">
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
<div className="flex items-center gap-2.5">
<h2 className="text-[14px] font-semibold text-[var(--ink)]">{activeDeptName}</h2>
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{filteredMembers.length}</span>
</div>
<div className="flex items-center gap-2">
<button onClick={() => setShowPwdRuleModal(true)} className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] font-medium text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors">
<Settings className="h-3.5 w-3.5" strokeWidth={2} />
</button>
<button onClick={() => { setEditingMember(null); setShowMemberModal(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>
</header>
<div className="flex-1 overflow-y-auto bg-[var(--bg)] px-5 py-4">
{filteredMembers.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] py-20 text-center">
<p className="text-[13px] font-medium text-[var(--ink-soft)]"></p>
</div>
) : (
<div className="overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
<table className="w-full text-left text-[13px]">
<thead>
<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>
<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-right text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
</tr>
</thead>
<tbody>
{filteredMembers.map((m) => (
<tr key={m.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-medium text-[var(--ink)]">{m.name}</td>
<td className="px-4 py-3">
<span className="inline-flex items-center rounded-md bg-[var(--bg-subtle)] px-2 py-0.5 text-[11px] font-medium text-[var(--ink-soft)]">{roleName(m.roleId)}</span>
</td>
<td className="px-4 py-3 text-[var(--ink-soft)]">{departments.find((d) => d.id === m.departmentId)?.name ?? '-'}</td>
<td className="px-4 py-3 tabular-nums text-[var(--ink-soft)]">{maskPhone(m.phone)}</td>
<td className="px-4 py-3 text-[var(--ink-soft)]">{m.email}</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<button onClick={() => { setEditingMember(m); setShowMemberModal(true); }} className="h-6 px-2 rounded text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"></button>
<button onClick={() => deleteMember(m.id)} className="h-6 px-2 rounded text-[11px] font-medium text-red-500 hover:bg-red-50"></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
{/* Member Modal */}
{showMemberModal && (
<MemberModal
initial={editingMember}
departments={departments}
roles={roles}
defaultDeptId={activeDeptId}
passwordRule={passwordRule}
onClose={() => setShowMemberModal(false)}
onSubmit={(data) => {
if (editingMember) updateMember(editingMember.id, data);
else createMember(data as any);
setShowMemberModal(false);
}}
/>
)}
{/* Department Modal */}
{showDeptModal && (
<DeptModal
initial={editingDept}
departments={departments}
onClose={() => setShowDeptModal(false)}
onSubmit={(data) => {
if (editingDept) updateDepartment(editingDept.id, data);
else createDepartment(data as any);
setShowDeptModal(false);
}}
/>
)}
{/* Password Rule Modal */}
{showPwdRuleModal && (
<PasswordRuleModal
rule={passwordRule}
onClose={() => setShowPwdRuleModal(false)}
onSave={(rule) => { updatePasswordRule(rule); setShowPwdRuleModal(false); }}
/>
)}
</div>
);
}
function MemberModal({ initial, departments, roles, defaultDeptId, passwordRule, onClose, onSubmit }: {
initial: Member | null;
departments: Department[];
roles: { id: string; name: string }[];
defaultDeptId: string | null;
passwordRule: PasswordRule;
onClose: () => void;
onSubmit: (data: any) => void;
}) {
const [name, setName] = useState(initial?.name ?? '');
const [departmentId, setDepartmentId] = useState(initial?.departmentId ?? defaultDeptId ?? '');
const [roleId, setRoleId] = useState(initial?.roleId ?? '');
const [phone, setPhone] = useState(initial?.phone ?? '');
const [email, setEmail] = useState(initial?.email ?? '');
const [password] = useState(() => initial?.password ?? generatePassword(passwordRule));
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !departmentId || !roleId) return;
onSubmit({ name: name.trim(), departmentId, roleId, phone: phone.trim(), email: email.trim(), password });
};
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)]">{initial ? '编辑成员' : '新建成员'}</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={name} onChange={(e) => setName(e.target.value)} required placeholder="请输入姓名" 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 className="grid grid-cols-2 gap-3">
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"> *</label>
<select value={departmentId} onChange={(e) => setDepartmentId(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">
<option value=""></option>
{departments.map((d) => <option key={d.id} value={d.id}>{d.parentId ? ' ' : ''}{d.name}</option>)}
</select>
</div>
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"> *</label>
<select value={roleId} onChange={(e) => setRoleId(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">
<option value=""></option>
{roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
</div>
</div>
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"></label>
<input value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="11位手机号" 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={email} onChange={(e) => setEmail(e.target.value)} placeholder="name@company.com" 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>
<div className="flex items-center h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] px-3">
<span className="flex-1 text-[13px] font-mono text-[var(--ink)]">{password}</span>
{!initial && (
<span className="text-[10px] text-[var(--ink-muted)]"></span>
)}
</div>
</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 DeptModal({ initial, departments, onClose, onSubmit }: {
initial: Department | null;
departments: Department[];
onClose: () => void;
onSubmit: (data: any) => void;
}) {
const [name, setName] = useState(initial?.name ?? '');
const [parentId, setParentId] = useState(initial?.parentId ?? '');
const topDepts = departments.filter((d) => !d.parentId);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
onSubmit({ name: name.trim(), parentId: parentId || undefined, order: departments.length + 1 });
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="w-full max-w-xs 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 ? '编辑部门' : '新建部门'}</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={name} onChange={(e) => setName(e.target.value)} required placeholder="请输入部门名称" 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>
<select value={parentId} onChange={(e) => setParentId(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>
{topDepts.filter((d) => d.id !== initial?.id).map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
</select>
</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 PasswordRuleModal({ rule, onClose, onSave }: {
rule: PasswordRule;
onClose: () => void;
onSave: (rule: PasswordRule) => void;
}) {
const [prefix, setPrefix] = useState(rule.prefix);
const [length, setLength] = useState(rule.length);
const [includeUppercase, setIncludeUppercase] = useState(rule.includeUppercase);
const [includeLowercase, setIncludeLowercase] = useState(rule.includeLowercase);
const [includeNumbers, setIncludeNumbers] = useState(rule.includeNumbers);
const [includeSpecial, setIncludeSpecial] = useState(rule.includeSpecial);
const currentRule: PasswordRule = { prefix, length, includeUppercase, includeLowercase, includeNumbers, includeSpecial };
const preview = generatePassword(currentRule);
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="grid grid-cols-2 gap-3">
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"></label>
<input value={prefix} onChange={(e) => setPrefix(e.target.value)} placeholder="如 Ftb" 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="number" min={6} max={20} value={length} onChange={(e) => setLength(Number(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>
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-2 block"></label>
<div className="flex flex-wrap gap-2">
<label className="flex items-center gap-1.5 text-[12px] text-[var(--ink-soft)] cursor-pointer">
<input type="checkbox" checked={includeUppercase} onChange={(e) => setIncludeUppercase(e.target.checked)} className="rounded" />
</label>
<label className="flex items-center gap-1.5 text-[12px] text-[var(--ink-soft)] cursor-pointer">
<input type="checkbox" checked={includeLowercase} onChange={(e) => setIncludeLowercase(e.target.checked)} className="rounded" />
</label>
<label className="flex items-center gap-1.5 text-[12px] text-[var(--ink-soft)] cursor-pointer">
<input type="checkbox" checked={includeNumbers} onChange={(e) => setIncludeNumbers(e.target.checked)} className="rounded" />
</label>
<label className="flex items-center gap-1.5 text-[12px] text-[var(--ink-soft)] cursor-pointer">
<input type="checkbox" checked={includeSpecial} onChange={(e) => setIncludeSpecial(e.target.checked)} className="rounded" />
</label>
</div>
</div>
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"></label>
<div className="h-9 flex items-center rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] px-3">
<span className="font-mono text-[13px] text-[var(--ink)]">{preview}</span>
</div>
</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={() => onSave(currentRule)} className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]"></button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,113 @@
'use client';
import { useEffect, useState } from 'react';
import { Plus, Pencil, Trash2, X, Shield } from 'lucide-react';
import { useMemberStore } from '@/stores/useMemberStore';
import type { RoleItem } from '@/lib/members';
export default function RolesPage() {
const { roles, fetchMembers, createRole, updateRole, deleteRole } = useMemberStore();
const [showModal, setShowModal] = useState(false);
const [editing, setEditing] = useState<RoleItem | null>(null);
useEffect(() => { fetchMembers(); }, [fetchMembers]);
return (
<div className="flex h-full flex-col">
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
<div className="flex items-center gap-2.5">
<h1 className="text-[15px] font-semibold tracking-tight text-[var(--ink)]"></h1>
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{roles.length}</span>
</div>
<button onClick={() => { setEditing(null); setShowModal(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>
</header>
<div className="flex-1 overflow-y-auto bg-[var(--bg)] px-5 py-4">
<div className="grid gap-3">
{roles.map((role) => (
<div key={role.id} className="flex items-center justify-between rounded-xl border border-[var(--line)] bg-[var(--bg-card)] px-4 py-3 shadow-[var(--shadow-sm)]">
<div className="flex items-center gap-3">
<div className={`flex h-8 w-8 items-center justify-center rounded-lg ${role.isSystem ? 'bg-amber-50' : 'bg-[var(--bg-subtle)]'}`}>
<Shield className={`h-4 w-4 ${role.isSystem ? 'text-amber-600' : 'text-[var(--ink-muted)]'}`} />
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-[13px] font-medium text-[var(--ink)]">{role.name}</span>
{role.isSystem && (
<span className="rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 border border-amber-200"></span>
)}
</div>
{role.description && (
<p className="text-[12px] text-[var(--ink-muted)] mt-0.5">{role.description}</p>
)}
</div>
</div>
{!role.isSystem && (
<div className="flex items-center gap-1">
<button onClick={() => { setEditing(role); setShowModal(true); }} className="h-7 px-2.5 rounded-md text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] transition-colors"></button>
<button onClick={() => deleteRole(role.id)} className="h-7 px-2.5 rounded-md text-[11px] font-medium text-red-500 hover:bg-red-50 transition-colors"></button>
</div>
)}
</div>
))}
</div>
</div>
{/* Modal */}
{showModal && (
<RoleModal
initial={editing}
onClose={() => setShowModal(false)}
onSubmit={(data) => {
if (editing) updateRole(editing.id, data);
else createRole(data);
setShowModal(false);
}}
/>
)}
</div>
);
}
function RoleModal({ initial, onClose, onSubmit }: {
initial: RoleItem | null;
onClose: () => void;
onSubmit: (data: { name: string; description?: string }) => void;
}) {
const [name, setName] = useState(initial?.name ?? '');
const [description, setDescription] = useState(initial?.description ?? '');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
onSubmit({ name: name.trim(), description: description.trim() || undefined });
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="w-full max-w-xs 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 ? '编辑角色' : '新建角色'}</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={name} onChange={(e) => setName(e.target.value)} required placeholder="如:项目经理" 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={description} onChange={(e) => setDescription(e.target.value)} placeholder="可选" 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 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>
);
}

View File

@@ -44,4 +44,39 @@ html, body {
font-family: 'JetBrains Mono', monospace;
}
/* Modern date/time input styling */
input[type="date"],
input[type="datetime-local"],
input[type="month"],
input[type="time"] {
appearance: none;
-webkit-appearance: none;
position: relative;
font-family: 'Inter', system-ui, sans-serif;
color: var(--ink);
}
input[type="date"]::-webkit-calendar-picker-indicator,
input[type="datetime-local"]::-webkit-calendar-picker-indicator,
input[type="month"]::-webkit-calendar-picker-indicator,
input[type="time"]::-webkit-calendar-picker-indicator {
opacity: 0.5;
cursor: pointer;
padding: 2px;
border-radius: 4px;
transition: opacity 0.15s, background 0.15s;
}
input[type="date"]::-webkit-calendar-picker-indicator:hover,
input[type="datetime-local"]::-webkit-calendar-picker-indicator:hover,
input[type="month"]::-webkit-calendar-picker-indicator:hover,
input[type="time"]::-webkit-calendar-picker-indicator:hover {
opacity: 1;
background: var(--bg-subtle);
}
input[type="date"]::-webkit-inner-spin-button,
input[type="datetime-local"]::-webkit-inner-spin-button {
display: none;
}

View File

@@ -0,0 +1,307 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Search, Plus, ChevronDown, Clock, Pencil, Trash2, X, Download } from 'lucide-react';
import { useOvertimeStore } from '@/stores/useOvertimeStore';
import { useProductStore } from '@/stores/useProductStore';
import { flattenProjects, flattenVersions } from '@/lib/derive';
import { calcDuration } from '@/lib/overtime';
import type { OvertimeRecord } from '@/lib/overtime';
import { Pagination, usePagination } from '@/components/Pagination';
import { DictDrawer } from '@/components/requirement/DictDrawer';
import { MonthPicker } from '@/components/MonthPicker';
import { FilterSelect } from '@/components/FilterSelect';
export default function OvertimePage() {
const { records, fetchRecords, createRecord, updateRecord, deleteRecord, reasons, addReason, updateReason, deleteReason } = useOvertimeStore();
const { overview, fetchOverview } = useProductStore();
const allProjects = useMemo(() => flattenProjects(overview), [overview]);
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
const [search, setSearch] = useState('');
const [projectFilter, setProjectFilter] = useState('all');
const [reasonFilter, setReasonFilter] = useState('all');
const [monthFilter, setMonthFilter] = useState('');
const [showModal, setShowModal] = useState(false);
const [editing, setEditing] = useState<OvertimeRecord | null>(null);
const [showReasonDrawer, setShowReasonDrawer] = useState(false);
useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => { fetchRecords(); }, [fetchRecords]);
const projectName = (id: string) => allProjects.find((p) => p.id === id)?.name ?? '-';
const versionName = (id?: string) => id ? (allVersions.find((v) => v.id === id)?.name ?? '-') : '-';
const reasonName = (id: string) => reasons.find((r) => r.id === id)?.name ?? '-';
const filtered = useMemo(() => {
let list = [...records];
if (search) list = list.filter((r) => r.person.includes(search));
if (projectFilter !== 'all') list = list.filter((r) => r.projectId === projectFilter);
if (reasonFilter !== 'all') list = list.filter((r) => r.reasonId === reasonFilter);
if (monthFilter) list = list.filter((r) => r.startTime.slice(0, 7) === monthFilter);
list.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime());
return list;
}, [records, search, projectFilter, reasonFilter, monthFilter]);
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20);
const handleCreate = () => { setEditing(null); setShowModal(true); };
const handleEdit = (r: OvertimeRecord) => { setEditing(r); setShowModal(true); };
const handleExport = () => {
const header = ['项目', '版本', '加班人', '开始时间', '结束时间', '时长(h)', '加班原因', '备注'];
const rows = filtered.map((r) => [
projectName(r.projectId),
versionName(r.versionId),
r.person,
r.startTime.replace('T', ' '),
r.endTime.replace('T', ' '),
String(r.duration),
reasonName(r.reasonId),
r.remark || '',
]);
const bom = '';
const csv = [header.join(','), ...rows.map((row) => row.map((c) => `"${c.replace(/"/g, '""')}"`).join(','))].join('\r\n');
const blob = new Blob([bom + csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `加班记录${monthFilter || '_全部'}.csv`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="flex h-full flex-col">
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
<div className="flex items-center gap-2.5">
<h1 className="text-[15px] font-semibold tracking-tight text-[var(--ink)]"></h1>
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{records.length}</span>
</div>
<div className="flex items-center gap-2">
<button onClick={handleExport} className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] font-medium text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors">
<Download className="h-3.5 w-3.5" strokeWidth={2} />
</button>
<button onClick={() => setShowReasonDrawer(true)} className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] font-medium text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors">
</button>
<button onClick={handleCreate} 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>
</header>
{/* Filters */}
<div className="flex shrink-0 flex-wrap items-center gap-3 border-b border-[var(--line)] bg-[var(--bg-card)] px-5 py-3">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--ink-muted)]" strokeWidth={2} />
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="搜索人员" className="h-8 w-48 rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-8 pr-3 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none" />
</div>
<FilterSelect
value={projectFilter}
onChange={setProjectFilter}
options={allProjects.map((p) => ({ value: p.id, label: p.name }))}
placeholder="全部项目"
allLabel="全部项目"
/>
<FilterSelect
value={reasonFilter}
onChange={setReasonFilter}
options={reasons.map((r) => ({ value: r.id, label: r.name }))}
placeholder="全部原因"
allLabel="全部原因"
/>
<MonthPicker value={monthFilter} onChange={setMonthFilter} placeholder="全部月份" />
</div>
{/* Table */}
<div className="flex-1 overflow-y-auto bg-[var(--bg)] px-5 py-4">
{filtered.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] py-20 text-center">
<p className="text-[13px] font-medium text-[var(--ink-soft)]"></p>
</div>
) : (
<>
<div className="overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
<table className="w-full text-left text-[13px]">
<thead className="sticky top-0 z-10">
<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>
<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-right text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
</tr>
</thead>
<tbody>
{paged.map((r) => (
<tr key={r.id} className="border-b border-[var(--line-soft)] last:border-0 transition-colors hover:bg-[var(--bg-subtle)]">
<td className="px-4 py-3 text-[var(--ink)]">{projectName(r.projectId)}</td>
<td className="px-4 py-3 text-[var(--ink-soft)]">{versionName(r.versionId)}</td>
<td className="px-4 py-3 font-medium text-[var(--ink)]">{r.person}</td>
<td className="px-4 py-3 tabular-nums text-[var(--ink-soft)]">{r.startTime.replace('T', ' ')}</td>
<td className="px-4 py-3 tabular-nums text-[var(--ink-soft)]">{r.endTime.replace('T', ' ')}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1 font-medium tabular-nums ${r.duration >= 4 ? 'text-red-600' : r.duration >= 2 ? 'text-orange-600' : 'text-[var(--ink)]'}`}>
{r.duration}h
</span>
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center rounded-md bg-zinc-100 px-2 py-0.5 text-[11px] font-medium text-zinc-700">
{reasonName(r.reasonId)}
</span>
</td>
<td className="px-4 py-3 text-[12px] text-[var(--ink-muted)] max-w-[120px] truncate">{r.remark || '-'}</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<button onClick={() => handleEdit(r)} className="h-6 px-2 rounded text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"></button>
<button onClick={() => deleteRecord(r.id)} className="h-6 px-2 rounded text-[11px] font-medium text-red-500 hover:bg-red-50"></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />
</>
)}
</div>
{/* Modal */}
{showModal && (
<OvertimeModal
initial={editing}
products={overview.map((p) => ({ id: p.id, name: p.name }))}
projects={allProjects.map((p) => ({ id: p.id, name: p.name, productId: p.productId }))}
versions={allVersions}
reasons={reasons}
onClose={() => setShowModal(false)}
onSubmit={(data) => {
if (editing) updateRecord(editing.id, data);
else createRecord(data as any);
setShowModal(false);
}}
/>
)}
{/* Reason Drawer */}
{showReasonDrawer && (
<DictDrawer open={true} title="加班原因管理" items={reasons} onClose={() => setShowReasonDrawer(false)} onAdd={addReason} onUpdate={updateReason} onDelete={deleteReason} />
)}
</div>
);
}
function OvertimeModal({ initial, products, projects, versions, reasons, onClose, onSubmit }: {
initial: OvertimeRecord | null;
products: { id: string; name: string }[];
projects: { id: string; name: string; productId: string }[];
versions: { id: string; name: string; projectId?: string }[];
reasons: { id: string; name: string }[];
onClose: () => void;
onSubmit: (data: any) => void;
}) {
const [productId, setProductId] = useState('');
const [projectId, setProjectId] = useState(initial?.projectId ?? '');
const [versionId, setVersionId] = useState(initial?.versionId ?? '');
const [person, setPerson] = useState(initial?.person ?? '');
const [startTime, setStartTime] = useState(initial?.startTime ?? '');
const [endTime, setEndTime] = useState(initial?.endTime ?? '');
const [reasonId, setReasonId] = useState(initial?.reasonId ?? '');
const [remark, setRemark] = useState(initial?.remark ?? '');
// 初始编辑时反推 productId
useEffect(() => {
if (initial?.projectId) {
const proj = projects.find((p) => p.id === initial.projectId);
if (proj) setProductId(proj.productId);
}
}, [initial, projects]);
const duration = startTime && endTime ? calcDuration(startTime, endTime) : 0;
const filteredProjects = productId ? projects.filter((p) => p.productId === productId) : projects;
const filteredVersions = projectId ? versions.filter((v) => (v as any).projectId === projectId) : [];
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!projectId || !person.trim() || !startTime || !endTime || !reasonId) return;
onSubmit({ projectId, versionId: versionId || undefined, person: person.trim(), startTime, endTime, reasonId, remark: remark.trim() || undefined });
};
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 ? '编辑加班记录' : '新建加班记录'}</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 className="grid grid-cols-3 gap-3">
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"> *</label>
<select value={productId} onChange={(e) => { setProductId(e.target.value); setProjectId(''); setVersionId(''); }} 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">
<option value=""></option>
{products.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"> *</label>
<select value={projectId} onChange={(e) => { setProjectId(e.target.value); setVersionId(''); }} 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">
<option value=""></option>
{filteredProjects.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"> *</label>
<select value={versionId} onChange={(e) => setVersionId(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">
<option value=""></option>
{filteredVersions.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
</select>
</div>
</div>
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"> *</label>
<input value={person} onChange={(e) => setPerson(e.target.value)} required placeholder="姓名" 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 className="grid grid-cols-2 gap-3">
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"> *</label>
<input type="datetime-local" value={startTime} onChange={(e) => setStartTime(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 type="datetime-local" 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>
{duration > 0 && (
<div className="text-[12px] text-[var(--ink-muted)]">
<span className={`font-medium ${duration >= 4 ? 'text-red-600' : duration >= 2 ? 'text-orange-600' : 'text-[var(--ink)]'}`}>{duration} </span>
</div>
)}
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"> *</label>
<select value={reasonId} onChange={(e) => setReasonId(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">
<option value=""></option>
{reasons.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
</div>
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"></label>
<textarea value={remark} onChange={(e) => setRemark(e.target.value)} rows={2} placeholder="可选" className="w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 py-2 text-[13px] focus:border-[var(--accent)] focus:outline-none resize-none" />
</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>
);
}

View File

@@ -1,9 +1,9 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useState, useMemo } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { ChevronLeft, Pencil, Trash2 } from 'lucide-react';
import { RequirementStatus } from '@ftb/shared';
import type { RequirementStatus } from '@/lib/requirement';
import { useProductStore } from '@/stores/useProductStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { RequirementTable } from '@/components/product/RequirementTable';
@@ -24,38 +24,37 @@ export default function ProductDetailPage() {
const { currentProduct, fetchProduct, updateProduct, deleteProduct } = useProductStore();
const {
requirements,
statusFilter,
fetchRequirements,
createRequirement,
updateRequirement,
updateStatus,
deleteRequirement,
setStatusFilter,
} = useRequirementStore();
const [showReqForm, setShowReqForm] = useState(false);
const [editingReq, setEditingReq] = useState<any>(null);
const [editingProduct, setEditingProduct] = useState(false);
const [activeTab, setActiveTab] = useState<'requirements' | 'projects' | 'versions'>('requirements');
const [reqStatusFilter, setReqStatusFilter] = useState<RequirementStatus | null>(null);
useEffect(() => {
fetchProduct(productId);
fetchRequirements(productId);
fetchRequirements();
}, [productId, fetchProduct, fetchRequirements]);
const productRequirements = useMemo(() => {
let list = requirements.filter((r) => r.productId === productId);
if (reqStatusFilter) list = list.filter((r) => r.status === reqStatusFilter);
return list;
}, [requirements, productId, reqStatusFilter]);
const handleFilterChange = (status: RequirementStatus | null) => {
setStatusFilter(status);
fetchRequirements(productId, status || undefined);
setReqStatusFilter(status);
};
const handleCreateReq = async (data: { title: string; description: string; priority: number }) => {
await createRequirement(productId, { ...data, creatorId: 'temp-user-id' });
setShowReqForm(false);
};
const handleUpdateReq = async (data: { title: string; description: string; priority: number }) => {
if (editingReq) {
await updateRequirement(productId, editingReq.id, data);
setEditingReq(null);
}
};
@@ -81,7 +80,7 @@ export default function ProductDetailPage() {
<div className="flex items-center gap-2">
<button
onClick={() => router.push('/products')}
className="flex h-7 items-center gap-1 rounded-md px-1.5 text-[12.5px] text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]"
className="flex h-7 items-center gap-1 rounded-md px-1.5 text-[12px] text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]"
>
<ChevronLeft className="h-3.5 w-3.5" strokeWidth={2} />
@@ -94,14 +93,14 @@ export default function ProductDetailPage() {
<div className="flex gap-1.5">
<button
onClick={() => setEditingProduct(true)}
className="flex h-8 items-center gap-1 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2.5 text-[12.5px] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]"
className="flex h-8 items-center gap-1 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2.5 text-[12px] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]"
>
<Pencil className="h-3 w-3" strokeWidth={2} />
</button>
<button
onClick={handleDeleteProduct}
className="flex h-8 items-center gap-1 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2.5 text-[12.5px] text-[var(--ink-soft)] hover:border-red-200 hover:bg-red-50 hover:text-red-600"
className="flex h-8 items-center gap-1 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2.5 text-[12px] text-[var(--ink-soft)] hover:border-red-200 hover:bg-red-50 hover:text-red-600"
>
<Trash2 className="h-3 w-3" strokeWidth={2} />
@@ -137,7 +136,7 @@ export default function ProductDetailPage() {
<div className="p-5">
{editingProduct && (
<div className="mb-5 rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] p-6 shadow-[var(--shadow-sm)]">
<h3 className="mb-4 text-[14px] font-semibold text-[var(--ink)]"></h3>
<h3 className="mb-4 text-[13px] font-semibold text-[var(--ink)]"></h3>
<ProductForm
initialData={{ name: currentProduct.name, description: currentProduct.description }}
onSubmit={async (data) => {
@@ -154,7 +153,7 @@ export default function ProductDetailPage() {
<>
{(showReqForm || editingReq) && (
<div className="mb-5 rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] p-6 shadow-[var(--shadow-sm)]">
<h3 className="mb-4 text-[14px] font-semibold text-[var(--ink)]">
<h3 className="mb-4 text-[13px] font-semibold text-[var(--ink)]">
{editingReq ? '编辑需求' : '新建需求'}
</h3>
<RequirementForm
@@ -168,12 +167,12 @@ export default function ProductDetailPage() {
</div>
)}
<RequirementTable
requirements={requirements as any}
statusFilter={statusFilter}
requirements={productRequirements as any}
statusFilter={reqStatusFilter}
onFilterChange={handleFilterChange}
onEdit={(req) => setEditingReq(req)}
onStatusChange={(id, status) => updateStatus(productId, id, status)}
onDelete={(id) => deleteRequirement(productId, id)}
onStatusChange={() => {}}
onDelete={(id) => deleteRequirement(id)}
onCreate={() => setShowReqForm(true)}
/>
</>

View File

@@ -209,7 +209,7 @@ function EditProductModal({
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-3.5 border-b border-[var(--line)]">
<h3 className="text-[14px] font-semibold"></h3>
<h3 className="text-[13px] font-semibold"></h3>
<button
onClick={onClose}
className="p-1 rounded-md hover:bg-[var(--bg-hover)] text-[var(--ink-muted)] hover:text-[var(--ink)]"
@@ -257,7 +257,7 @@ function MigrateDialog({
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-3.5 border-b border-[var(--line)]">
<h3 className="text-[14px] font-semibold"></h3>
<h3 className="text-[13px] font-semibold"></h3>
<button
onClick={onClose}
className="p-1 rounded-md hover:bg-[var(--bg-hover)] text-[var(--ink-muted)] hover:text-[var(--ink)]"

View File

@@ -7,6 +7,8 @@ import { useProductStore } from '@/stores/useProductStore';
import { getProjectDetail, VersionWithContext } from '@/lib/derive';
import { Stage, Role, STAGES, ROLES, STAGE_INDEX, ROLE_LABEL } from '@/lib/stage';
import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_BG } from '@/lib/version-status';
import { CapsuleStages } from '@/components/version/CapsuleStages';
import { MemberChips } from '@/components/version/MemberChips';
/* ─── StatCard ─── */
function StatCard({ value, label }: { value: number | string; label: string }) {
@@ -34,97 +36,6 @@ function ProgressBar({ role, percent, daysSpent }: { role: Role; percent: number
);
}
/* ─── CapsuleStages (胶囊分段条) ─── */
function CapsuleStages({ currentStage, progress }: {
currentStage?: Stage;
progress?: { role: Role; percent: number; daysSpent: number }[];
}) {
const currentIdx = currentStage !== undefined
? (currentStage === 'released' ? STAGES.length : STAGE_INDEX[currentStage])
: -1;
const progressMap = (progress ?? []).reduce<Record<Role, { percent: number; daysSpent: number }>>((acc, p) => {
acc[p.role] = { percent: p.percent, daysSpent: p.daysSpent };
return acc;
}, {} as Record<Role, { percent: number; daysSpent: number }>);
const stageRoleMap: Record<Stage, Role[]> = {
requirement: ['product'],
product_design: ['product'],
ui_design: ['ui'],
dev: ['frontend', 'backend'],
integration: ['frontend', 'backend'],
testing: ['testing'],
released: [],
};
function getStageInfo(stage: Stage) {
const roles = stageRoleMap[stage];
if (roles.length === 0) return { percent: 0, days: 0, hasData: false };
const items = roles.map((r) => progressMap[r]).filter(Boolean);
if (items.length === 0) return { percent: 0, days: 0, hasData: false };
const percent = Math.round(items.reduce((s, i) => s + i.percent, 0) / items.length);
const days = Math.max(...items.map((i) => i.daysSpent));
return { percent, days, hasData: true };
}
return (
<div className="flex rounded-lg border border-[var(--line)] overflow-hidden bg-[var(--bg-card)]">
{STAGES.map((stage, idx) => {
const isCompleted = idx < currentIdx;
const isCurrent = idx === currentIdx;
const info = getStageInfo(stage.key);
return (
<div
key={stage.key}
className={`flex-1 flex flex-col ${idx < STAGES.length - 1 ? 'border-r border-[var(--line-soft)]' : ''}`}
>
<div className="flex items-center justify-between px-2 py-1.5 min-h-[28px]">
<span className={`text-[10px] font-medium leading-tight ${isCurrent ? 'text-[var(--ink)]' : isCompleted ? 'text-[var(--ink-soft)]' : 'text-[var(--ink-muted)]'}`}>
{stage.label}
</span>
<span className={`text-[9px] leading-tight ${isCompleted ? 'text-emerald-600' : isCurrent ? 'text-blue-600 font-medium' : 'text-[var(--ink-muted)]'}`}>
{isCompleted ? (info.hasData ? `${info.days}` : '-') : isCurrent ? (info.hasData ? `${info.percent}%` : '-') : ''}
</span>
</div>
<div className="h-[3px] w-full bg-zinc-50">
{isCompleted && <div className="h-full bg-zinc-700 w-full" />}
{isCurrent && info.hasData && (
<div className="h-full bg-blue-100 w-full">
<div className="h-full bg-blue-500 transition-all" style={{ width: `${info.percent}%` }} />
</div>
)}
</div>
</div>
);
})}
</div>
);
}
/* ─── MemberChips (compact inline for version cards) ─── */
function MemberChips({ members }: { members: { role: Role; name: string }[] }) {
if (!members || members.length === 0) return null;
const grouped = ROLES.reduce<Record<Role, string[]>>((acc, r) => {
acc[r.key] = members.filter((m) => m.role === r.key).map((m) => m.name);
return acc;
}, {} as Record<Role, string[]>);
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px]">
{ROLES.map((r) => {
const names = grouped[r.key];
if (!names || names.length === 0) return null;
return (
<span key={r.key} className="inline-flex items-center gap-1 text-[var(--ink-soft)]">
<span className="font-medium text-[var(--ink-muted)]">{r.label}</span>
{names.join('/')}
</span>
);
})}
</div>
);
}
/* ─── VersionCard ─── */
function VersionCard({ version }: { version: VersionWithContext }) {
const [expanded, setExpanded] = useState(false);
@@ -318,7 +229,7 @@ export default function ProjectDetailPage() {
<div className="flex h-full flex-col">
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
<div className="flex items-center">
<button onClick={() => router.push('/projects')} className="flex items-center gap-1 rounded-md px-1.5 py-1 text-[12.5px] text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]">
<button onClick={() => router.push('/projects')} className="flex items-center gap-1 rounded-md px-1.5 py-1 text-[12px] text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]">
<ChevronLeft className="h-3.5 w-3.5" strokeWidth={2} />
</button>
<span className="ml-2 text-[var(--ink-muted)]">/</span>

View File

@@ -7,6 +7,7 @@ import { useProductStore } from '@/stores/useProductStore';
import { flattenProjects, ProjectWithContext } from '@/lib/derive';
import { VersionChip } from '@/components/version/VersionChip';
import { VersionStatus } from '@/lib/version-status';
import { Pagination, usePagination } from '@/components/Pagination';
export default function ProjectsPage() {
const router = useRouter();
@@ -29,6 +30,8 @@ export default function ProjectsPage() {
});
}, [allProjects, search, productFilter]);
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20);
const productOptions = useMemo(
() => overview.map((p) => ({ id: p.id, name: p.name })),
[overview]
@@ -90,10 +93,13 @@ export default function ProjectsPage() {
{filtered.length === 0 ? (
<EmptyState />
) : (
<div className="overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
{filtered.map((proj) => (
<ProjectRow key={proj.id} proj={proj} onClick={() => router.push(`/projects/${proj.id}`)} />
))}
<div>
<div className="overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
{paged.map((proj) => (
<ProjectRow key={proj.id} proj={proj} onClick={() => router.push(`/projects/${proj.id}`)} />
))}
</div>
<Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />
</div>
)}
</div>

View File

@@ -0,0 +1,489 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Search, Plus, Lightbulb, ArrowUp, ArrowDown } from 'lucide-react';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useProductStore } from '@/stores/useProductStore';
import { flattenProjects } from '@/lib/derive';
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, SOURCE_TYPE_LABEL } from '@/lib/requirement';
import type { Requirement, RequirementStatus, SourceType } from '@/lib/requirement';
import { Pagination, usePagination } from '@/components/Pagination';
import { RequirementModal } from '@/components/requirement/RequirementModal';
import { RequirementDetail } from '@/components/requirement/RequirementDetail';
import { DictDrawer, SourceDrawer } from '@/components/requirement/DictDrawer';
import { FilterSelect } from '@/components/FilterSelect';
const PRIORITY_COLORS: Record<string, string> = {
P0: 'bg-red-500/10 text-red-600',
P1: 'bg-orange-500/10 text-orange-600',
P2: 'bg-blue-500/10 text-blue-600',
P3: 'bg-zinc-100 text-zinc-600',
P4: 'bg-zinc-100 text-zinc-500',
};
const STATUS_TABS: { key: string; label: string }[] = [
{ key: 'all', label: '全部' },
{ key: 'pending_review', label: '待评审' },
{ key: 'adopted', label: '已采纳' },
{ key: 'rejected', label: '已拒绝' },
{ key: 'planned', label: '已规划' },
{ key: 'developing', label: '开发中' },
{ key: 'testing', label: '测试中' },
{ key: 'released', label: '已上线' },
{ key: 'closed', label: '已关闭' },
];
export default function RequirementsPage() {
const {
requirements, fetchRequirements, createRequirement, updateRequirement, deleteRequirement,
sourceTargets, types, platforms,
addSourceTarget, updateSourceTarget, deleteSourceTarget,
addType, updateType, deleteType,
addPlatform, updatePlatform, deletePlatform,
} = useRequirementStore();
const { overview, fetchOverview } = useProductStore();
const allProjects = useMemo(() => flattenProjects(overview), [overview]);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [projectFilter, setProjectFilter] = useState('all');
const [priorityFilter, setPriorityFilter] = useState('all');
const [typeFilter, setTypeFilter] = useState('all');
const [showModal, setShowModal] = useState(false);
const [editingReq, setEditingReq] = useState<Requirement | null>(null);
const [viewingReq, setViewingReq] = useState<Requirement | null>(null);
const [drawerType, setDrawerType] = useState<null | 'source' | 'type' | 'platform'>(null);
const [rejectingReq, setRejectingReq] = useState<Requirement | null>(null);
const [rejectReason, setRejectReason] = useState('');
const [dateSort, setDateSort] = useState<'desc' | 'asc'>('desc');
useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
// Resolve version name from overview
const resolveVersionName = (versionId?: string): string => {
if (!versionId) return '-';
for (const product of overview) {
const version = product.versions?.find((v: any) => v.id === versionId);
if (version) return version.name;
}
return '-';
};
const filtered = useMemo(() => {
let list = [...requirements];
// search filter
if (search) {
const q = search.toLowerCase();
list = list.filter(
(r) => r.code.toLowerCase().includes(q) || r.title.toLowerCase().includes(q),
);
}
// status filter
if (statusFilter !== 'all') {
list = list.filter((r) => r.status === statusFilter);
}
// project filter
if (projectFilter !== 'all') {
list = list.filter((r) => r.projectId === projectFilter);
}
// priority filter
if (priorityFilter !== 'all') {
list = list.filter((r) => r.priority === priorityFilter);
}
// type filter
if (typeFilter !== 'all') {
list = list.filter((r) => r.typeId === typeFilter);
}
// sort by createdAt
list.sort((a, b) => {
const diff = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
return dateSort === 'asc' ? diff : -diff;
});
return list;
}, [requirements, search, statusFilter, projectFilter, priorityFilter, typeFilter, dateSort]);
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20);
const handleEdit = (req: Requirement) => {
setViewingReq(null);
setEditingReq(req);
setShowModal(true);
};
const handleRowClick = (req: Requirement) => {
setViewingReq(req);
};
const handleCreate = () => {
setEditingReq(null);
setShowModal(true);
};
return (
<div className="flex h-full flex-col">
{/* Header */}
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
<div className="flex items-center gap-2.5">
<h1 className="text-[15px] font-semibold tracking-tight text-[var(--ink)]"></h1>
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">
{requirements.length}
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setDrawerType('source')}
className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] font-medium text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors"
>
<Lightbulb className="h-3.5 w-3.5" strokeWidth={2} />
</button>
<button
onClick={handleCreate}
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>
</header>
{/* Filters */}
<div className="flex shrink-0 flex-wrap items-center gap-3 border-b border-[var(--line)] bg-[var(--bg-card)] px-5 py-3">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--ink-muted)]" strokeWidth={2} />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索编号或标题"
className="h-8 w-64 rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-8 pr-3 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]"
/>
</div>
{/* Status tabs */}
<div className="flex flex-wrap gap-1">
{STATUS_TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setStatusFilter(tab.key)}
className={`h-8 rounded-lg px-3 text-[12px] transition-colors ${
statusFilter === tab.key
? 'bg-[var(--accent)] text-white'
: 'border border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)]'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Project dropdown */}
<FilterSelect
value={projectFilter}
onChange={setProjectFilter}
options={allProjects.map((p) => ({ value: p.id, label: p.name }))}
placeholder="全部项目"
allLabel="全部项目"
/>
{/* Priority dropdown */}
<FilterSelect
value={priorityFilter}
onChange={setPriorityFilter}
options={['P0', 'P1', 'P2', 'P3', 'P4'].map((p) => ({ value: p, label: p }))}
placeholder="全部优先级"
allLabel="全部优先级"
/>
{/* Type dropdown */}
<FilterSelect
value={typeFilter}
onChange={setTypeFilter}
options={types.map((t) => ({ value: t.id, label: t.name }))}
placeholder="全部类型"
allLabel="全部类型"
/>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto bg-[var(--bg)]">
<div className="px-5 py-4">
{filtered.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] py-20 text-center">
<p className="text-[13px] font-medium text-[var(--ink-soft)]"></p>
<p className="mt-1.5 text-[12px] text-[var(--ink-muted)]"></p>
</div>
) : (
<>
<div className="overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
<table className="w-full text-left text-[13px]">
<thead className="sticky top-0 z-10">
<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>
<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)]"></th>
<th
className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)] cursor-pointer select-none hover:text-[var(--ink-soft)] transition-colors"
onClick={() => setDateSort(dateSort === 'desc' ? 'asc' : 'desc')}
>
<span className="inline-flex items-center gap-1">
{dateSort === 'desc' ? <ArrowDown className="h-3 w-3" /> : <ArrowUp className="h-3 w-3" />}
</span>
</th>
<th className="px-4 py-2.5 text-right text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
</tr>
</thead>
<tbody>
{paged.map((req) => (
<tr
key={req.id}
onClick={() => handleRowClick(req)}
className="border-b border-[var(--line-soft)] last:border-0 cursor-pointer transition-colors hover:bg-[var(--bg-subtle)]"
>
{/* 需求编号 */}
<td className="px-4 py-3 whitespace-nowrap">
<span className="font-medium text-[var(--ink)]">{req.code}</span>
</td>
{/* 需求概述 */}
<td className="px-4 py-3">
<span className="block max-w-[200px] truncate text-[var(--ink)]">{req.title}</span>
</td>
{/* 需求来源 */}
<td className="px-4 py-3 text-[13px] text-[var(--ink-soft)]">
{SOURCE_TYPE_LABEL[req.sourceType]}: {req.sourceTarget}
</td>
{/* 所属项目 */}
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">
{allProjects.find((p) => p.id === req.projectId)?.name ?? '-'}
</td>
{/* 需求类型 */}
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">
{types.find((t) => t.id === req.typeId)?.name ?? '-'}
</td>
{/* 状态 */}
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium ${REQ_STATUS_COLOR[req.status]}`}>
{REQ_STATUS_LABEL[req.status]}
</span>
</td>
{/* 优先级 */}
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium ${PRIORITY_COLORS[req.priority] ?? 'bg-zinc-100 text-zinc-500'}`}>
{req.priority}
</span>
</td>
{/* 所属版本 */}
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">
{resolveVersionName(req.versionId)}
</td>
{/* 产品负责人 */}
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">
{req.productOwner || '-'}
</td>
{/* 录入人员 */}
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">
{req.creator}
</td>
{/* 录入日期 */}
<td className="px-4 py-3 text-[var(--ink-muted)] tabular-nums">
{new Date(req.createdAt).toISOString().slice(0, 10)}
</td>
{/* 操作 */}
<td className="px-4 py-3 text-right" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-1">
{/* 编辑:待评审/已采纳/已规划 */}
{(['pending_review', 'adopted', 'planned'] as const).includes(req.status as any) && (
<button
onClick={() => handleEdit(req)}
className="h-6 px-2 rounded text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] transition-colors"
>
</button>
)}
{/* 待评审:采纳/拒绝 */}
{req.status === 'pending_review' && (
<>
<button
onClick={() => updateRequirement(req.id, { status: 'adopted' })}
className="h-6 px-2 rounded text-[11px] font-medium text-emerald-600 hover:bg-emerald-50 transition-colors"
>
</button>
<button
onClick={() => { setRejectingReq(req); setRejectReason(''); }}
className="h-6 px-2 rounded text-[11px] font-medium text-red-600 hover:bg-red-50 transition-colors"
>
</button>
</>
)}
{/* 已采纳/已规划:关闭 */}
{(req.status === 'adopted' || req.status === 'planned') && (
<button
onClick={() => updateRequirement(req.id, { status: 'closed' })}
className="h-6 px-2 rounded text-[11px] font-medium text-orange-600 hover:bg-orange-50 transition-colors"
>
</button>
)}
{/* 删除:待评审/已采纳/已拒绝/已关闭 */}
{(['pending_review', 'adopted', 'rejected', 'closed'] as const).includes(req.status as any) && (
<button
onClick={() => deleteRequirement(req.id)}
className="h-6 px-2 rounded text-[11px] font-medium text-red-500 hover:bg-red-50 transition-colors"
>
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />
</>
)}
</div>
</div>
{/* Detail drawer */}
{viewingReq && (
<RequirementDetail
requirement={viewingReq}
projects={allProjects.map((p) => ({ id: p.id, name: p.name }))}
types={types}
platforms={platforms}
sourceTargets={sourceTargets}
resolveVersionName={resolveVersionName}
onClose={() => setViewingReq(null)}
onEdit={() => handleEdit(viewingReq)}
onAdopt={() => { updateRequirement(viewingReq.id, { status: 'adopted' }); setViewingReq(null); }}
onReject={() => { setRejectingReq(viewingReq); setRejectReason(''); setViewingReq(null); }}
onCloseReq={() => { updateRequirement(viewingReq.id, { status: 'closed' }); setViewingReq(null); }}
onDelete={() => { deleteRequirement(viewingReq.id); setViewingReq(null); }}
/>
)}
{/* Modal */}
{showModal && (
<RequirementModal
open={true}
initial={editingReq}
products={overview.map((p) => ({ id: p.id, name: p.name }))}
projects={allProjects.map((p) => ({ id: p.id, name: p.name, productId: p.productId }))}
sourceTargets={sourceTargets}
types={types}
platforms={platforms}
requirements={requirements}
onClose={() => { setShowModal(false); setEditingReq(null); }}
onSubmit={(data) => {
if (editingReq) {
updateRequirement(editingReq.id, data);
} else {
createRequirement(data);
}
setShowModal(false);
setEditingReq(null);
}}
onOpenDrawer={(type) => setDrawerType(type)}
/>
)}
{/* Drawer */}
{drawerType === 'source' && (
<SourceDrawer
open={true}
items={sourceTargets}
onClose={() => setDrawerType(null)}
onAdd={addSourceTarget}
onUpdate={updateSourceTarget}
onDelete={deleteSourceTarget}
/>
)}
{drawerType === 'type' && (
<DictDrawer
open={true}
title="需求类型管理"
items={types}
onClose={() => setDrawerType(null)}
onAdd={addType}
onUpdate={updateType}
onDelete={deleteType}
/>
)}
{drawerType === 'platform' && (
<DictDrawer
open={true}
title="支持端管理"
items={platforms}
onClose={() => setDrawerType(null)}
onAdd={addPlatform}
onUpdate={updatePlatform}
onDelete={deletePlatform}
/>
)}
{/* 拒绝原因弹窗 */}
{rejectingReq && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={() => setRejectingReq(null)}>
<div className="w-full max-w-sm rounded-xl bg-[var(--bg-card)] border border-[var(--line)] p-5 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
<h3 className="text-[13px] font-semibold text-[var(--ink)] mb-3"></h3>
<p className="text-[12px] text-[var(--ink-muted)] mb-2">{rejectingReq.title}</p>
<textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder="请填写拒绝原因..."
rows={3}
className="w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 py-2 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none resize-none"
/>
<div className="flex justify-end gap-2 mt-3">
<button onClick={() => setRejectingReq(null)} 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={() => {
if (!rejectReason.trim()) return;
updateRequirement(rejectingReq.id, { status: 'rejected', description: rejectingReq.description + `\n\n【拒绝原因】${rejectReason.trim()}` });
setRejectingReq(null);
}}
disabled={!rejectReason.trim()}
className="h-8 px-3 rounded-lg text-[12px] font-medium bg-red-500 text-white hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,248 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { ChevronLeft, Package, Calendar, Clock, ExternalLink, FileText, Palette, Layout } from 'lucide-react';
import { useProductStore } from '@/stores/useProductStore';
import { getVersionDetail } from '@/lib/derive';
import { STAGES } from '@/lib/stage';
import { VERSION_STATUS_LABEL, VERSION_STATUS_BG } from '@/lib/version-status';
import { CapsuleStages } from '@/components/version/CapsuleStages';
import { MemberChips } from '@/components/version/MemberChips';
import { HealthTrend, generateMockTrend } from '@/components/version/HealthTrend';
import { calcHealthScore, getHealthLevel, calcRiskTags, HEALTH_LEVEL_COLOR, HEALTH_LEVEL_DOT, HEALTH_LEVEL_LABEL, getTagStyle } from '@/lib/health';
const PRIORITY_STYLE: Record<string, string> = {
P0: 'bg-red-500/10 text-red-600',
P1: 'bg-orange-500/10 text-orange-600',
P2: 'bg-blue-500/10 text-blue-600',
P3: 'bg-zinc-100 text-zinc-600',
P4: 'bg-zinc-100 text-zinc-500',
};
const TABS = [
{ key: 'overview', label: '概览' },
{ key: 'requirements', label: '需求' },
{ key: 'tasks', label: '开发任务' },
{ key: 'testcases', label: '测试用例' },
{ key: 'bugs', label: 'Bug' },
];
export default function VersionDetailPage() {
const params = useParams();
const router = useRouter();
const versionId = params.id as string;
const { overview, fetchOverview } = useProductStore();
const [activeTab, setActiveTab] = useState('overview');
useEffect(() => { fetchOverview(); }, [fetchOverview]);
const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]);
const elapsedDays = useMemo(() => {
if (!version?.startDate) return 0;
const start = new Date(version.startDate);
start.setHours(0, 0, 0, 0);
const now = new Date();
now.setHours(0, 0, 0, 0);
return Math.max(0, Math.floor((now.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)));
}, [version?.startDate]);
if (!version) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3">
<p className="text-sm text-[var(--ink-muted)]"></p>
<button onClick={() => router.push('/versions')} className="text-xs text-[var(--accent)] hover:underline"></button>
</div>
);
}
const healthScore = calcHealthScore(version.status, version.startDate, version.expectedReleaseDate, version.progress);
const healthLevel = getHealthLevel(healthScore);
const riskTags = calcRiskTags(version.status, version.startDate, version.expectedReleaseDate, version.progress, version.currentStage, version.members);
const renderActions = () => {
const buttons: { label: string; action: () => void; danger?: boolean }[] = [];
if (version.status === 'developing' || version.status === 'planned') {
buttons.push({ label: '暂停', action: () => {} });
buttons.push({ label: '关闭', action: () => {}, danger: true });
} else if (version.status === 'paused') {
buttons.push({ label: '恢复', action: () => {} });
buttons.push({ label: '关闭', action: () => {}, danger: true });
}
return buttons.map((btn) => (
<button
key={btn.label}
onClick={btn.action}
className={`h-7 px-3 rounded-md text-[12px] font-medium border transition-colors ${btn.danger ? 'border-red-200 text-red-600 hover:bg-red-50' : 'border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
>
{btn.label}
</button>
));
};
return (
<div className="flex h-full flex-col">
{/* Header */}
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
<div className="flex items-center">
<button onClick={() => router.push('/versions')} className="flex items-center gap-1 rounded-md px-1.5 py-1 text-[12px] text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]">
<ChevronLeft className="h-3.5 w-3.5" strokeWidth={2} />
</button>
<span className="ml-2 text-[var(--ink-muted)]">/</span>
<span className="ml-2 text-[15px] font-semibold text-[var(--ink)]">{version.name}</span>
</div>
<div className="flex items-center gap-2">{renderActions()}</div>
</header>
{/* Tab bar */}
<div className="flex items-center gap-0 border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
{TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-2.5 text-[13px] font-medium border-b-2 transition-colors ${activeTab === tab.key ? 'border-[var(--accent)] text-[var(--ink)]' : 'border-transparent text-[var(--ink-muted)] hover:text-[var(--ink-soft)]'}`}
>
{tab.label}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-5 bg-[var(--bg)]">
{activeTab === 'overview' ? (
<div className="space-y-4">
{/* Tag row */}
<div className="flex flex-wrap items-center gap-2">
{version.priority && (
<span className={`text-[11px] font-semibold px-2 py-0.5 rounded-full ${PRIORITY_STYLE[version.priority] || PRIORITY_STYLE.P3}`}>
{version.priority}
</span>
)}
<span className={`text-[11px] px-2 py-0.5 rounded-full ${VERSION_STATUS_BG[version.status]}`}>
{VERSION_STATUS_LABEL[version.status]}
</span>
<span className={`inline-flex items-center gap-1.5 text-[11px] font-semibold tabular-nums px-2 py-0.5 rounded-full ${healthLevel === 'critical' ? 'bg-red-50' : healthLevel === 'risk' ? 'bg-orange-50' : healthLevel === 'attention' ? 'bg-amber-50' : 'bg-emerald-50'} ${HEALTH_LEVEL_COLOR[healthLevel]}`}>
<span className={`h-1.5 w-1.5 rounded-full ${HEALTH_LEVEL_DOT[healthLevel]}`} />
{healthScore} {HEALTH_LEVEL_LABEL[healthLevel]}
</span>
{riskTags.map((tag) => (
<span key={tag.key} className={`inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-medium ${getTagStyle(tag.severity)}`}>
{tag.label}
</span>
))}
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--bg-subtle)] px-2.5 py-0.5 text-[11px] text-[var(--ink-soft)]">
<Package className="h-3 w-3" />{version.productName} / {version.projectName}
</span>
</div>
{/* Capsule stages */}
<CapsuleStages currentStage={version.currentStage} progress={version.progress} />
{/* 风险详情 + 趋势健康度低于60时显示 */}
{healthScore < 60 && riskTags.length > 0 && (
<div className="grid grid-cols-4 gap-3">
{/* 左:风险详情(可滚动) */}
<div className="col-span-3 rounded-xl border border-orange-200 bg-orange-50/40 p-4 max-h-[200px] overflow-y-auto">
<div className="flex items-center gap-2 mb-3">
<span className="text-[12px] font-semibold text-orange-700"></span>
<span className="text-[10px] text-orange-600"> {healthScore} · {HEALTH_LEVEL_LABEL[healthLevel]}</span>
</div>
<div className="space-y-2">
{riskTags.map((tag) => (
<div key={tag.key} className="flex gap-2.5 pb-2 border-b border-orange-100 last:border-b-0 last:pb-0">
<span className={`shrink-0 inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-medium h-fit mt-0.5 ${getTagStyle(tag.severity)}`}>
{tag.label}
</span>
<div className="flex-1 space-y-0.5 text-[11px]">
{tag.reason && <div className="text-[var(--ink-soft)]"><span className="text-[var(--ink-muted)]"></span>{tag.reason}</div>}
{tag.suggestion && <div className="text-[var(--ink-soft)]"><span className="text-[var(--ink-muted)]"></span>{tag.suggestion}</div>}
</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>
)}
{/* 健康度正常时也显示趋势(紧凑) */}
{healthScore >= 60 && (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-3 max-w-[240px]">
<span className="text-[10px] font-medium text-[var(--ink-muted)] mb-1 block"></span>
<HealthTrend data={generateMockTrend(healthScore, 7)} />
</div>
)}
{/* Two-column layout: left info + right links */}
<div className="grid grid-cols-3 gap-4">
{/* Left: 2/3 width */}
<div className="col-span-2 space-y-4">
{/* Date + elapsed */}
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="flex items-center gap-4 text-[13px]">
<div className="flex items-center gap-1.5">
<Calendar className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
<span className="text-[var(--ink)]">{version.startDate ?? '未设置'}</span>
<span className="text-[var(--ink-muted)]"></span>
<span className="text-[var(--ink)]">{version.expectedReleaseDate ?? '未设置'}</span>
</div>
<div className="flex items-center gap-1.5 text-[var(--ink-soft)]">
<Clock className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
<span className="font-medium text-[var(--ink)]">{elapsedDays}</span>
</div>
</div>
</div>
{/* Members */}
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="text-[11px] text-[var(--ink-muted)] mb-2 font-medium"></div>
{version.members && version.members.length > 0 ? (
<MemberChips members={version.members} />
) : (
<span className="text-[12px] text-[var(--ink-muted)]"></span>
)}
</div>
</div>
{/* Right: 1/3 width - links card */}
<div className="col-span-1">
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 h-full">
<div className="text-[11px] text-[var(--ink-muted)] mb-3 font-medium"></div>
<div className="space-y-3">
<LinkItem icon={<FileText className="h-3.5 w-3.5" />} label="调研报告" url={version.links?.research} />
<LinkItem icon={<Layout className="h-3.5 w-3.5" />} label="原型地址" url={version.links?.prototype} />
<LinkItem icon={<Palette className="h-3.5 w-3.5" />} label="UI设计稿" url={version.links?.ui} />
</div>
</div>
</div>
</div>
</div>
) : (
<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>
</div>
)}
</div>
</div>
);
}
function LinkItem({ icon, label, url }: { icon: React.ReactNode; label: string; url?: string }) {
return (
<div className="flex items-center gap-2">
<span className="text-[var(--ink-muted)]">{icon}</span>
{url ? (
<a href={url} target="_blank" rel="noopener noreferrer" className="text-[12px] text-[var(--accent)] hover:underline flex items-center gap-1">
{label}<ExternalLink className="h-3 w-3" />
</a>
) : (
<span className="text-[12px] text-[var(--ink-muted)]">{label} · </span>
)}
</div>
);
}

View File

@@ -1,17 +1,48 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Search, Tag, Plus, X, ChevronDown } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Search, Tag, Plus, X, ChevronDown, MoreHorizontal, Pause, Play, XCircle } from 'lucide-react';
import { useProductStore } from '@/stores/useProductStore';
import { flattenVersions, flattenProjects } from '@/lib/derive';
import type { VersionWithContext } from '@/lib/derive';
import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_DOT } from '@/lib/version-status';
import { STAGES } from '@/lib/stage';
import { ROLE_LABEL } from '@/lib/stage';
import { calcOverallProgress } from '@/lib/risk';
import { calcHealthScore, getHealthLevel, calcRiskTags, HEALTH_LEVEL_COLOR, HEALTH_LEVEL_DOT, getTagStyle } from '@/lib/health';
import { Pagination, usePagination } from '@/components/Pagination';
const STATUS_TABS: { key: VersionStatus | 'all'; label: string }[] = [
type Priority = 'P0' | 'P1' | 'P2' | 'P3' | 'P4';
const PRIORITY_COLORS: Record<Priority, string> = {
P0: 'bg-red-500/10 text-red-600',
P1: 'bg-orange-500/10 text-orange-600',
P2: 'bg-blue-500/10 text-blue-600',
P3: 'bg-zinc-100 text-zinc-600',
P4: 'bg-zinc-100 text-zinc-500',
};
const PRIORITY_ORDER: Record<Priority, number> = {
P0: 0,
P1: 1,
P2: 2,
P3: 3,
P4: 4,
};
const STATUS_TABS: { key: string; label: string }[] = [
{ key: 'all', label: '全部' },
{ key: 'developing', label: '开发中' },
{ key: 'planned', label: '规划中' },
{ key: 'requirement', label: '调研' },
{ key: 'product_design', label: '产品设计' },
{ key: 'ui_design', label: 'UI设计' },
{ key: 'dev', label: '开发' },
{ key: 'integration', label: '联调' },
{ key: 'testing', label: '测试' },
{ key: 'released', label: '已发布' },
{ key: 'paused', label: '已暂停' },
{ key: 'closed', label: '已关闭' },
{ key: 'planned', label: '规划中' },
];
function parseVersionNumber(name: string): number[] {
@@ -34,19 +65,57 @@ function compareVersionsDesc(a: string, b: string): number {
function sortVersions(versions: VersionWithContext[]): VersionWithContext[] {
return [...versions].sort((a, b) => {
const projCmp = a.projectName.localeCompare(b.projectName, 'zh-CN');
if (projCmp !== 0) return projCmp;
return compareVersionsDesc(a.name, b.name);
// Sort by priority desc (P0 first)
const pa = PRIORITY_ORDER[(a.priority ?? 'P2') as Priority] ?? 2;
const pb = PRIORITY_ORDER[(b.priority ?? 'P2') as Priority] ?? 2;
if (pa !== pb) return pa - pb;
// Then by createdAt desc
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
});
}
function getStageLabel(version: VersionWithContext): string {
if (version.status === 'planned') return '规划中';
if (version.status === 'released') return '已发布';
if (version.status === 'paused') return '已暂停';
if (version.status === 'closed') return '已关闭';
if (version.currentStage) {
const stage = STAGES.find((s) => s.key === version.currentStage);
return stage?.label ?? '-';
}
return '-';
}
const STAGE_ROLE_MAP: Record<string, string[]> = {
requirement: ['product'],
product_design: ['product'],
ui_design: ['ui'],
dev: ['frontend', 'backend'],
integration: ['frontend', 'backend'],
testing: ['testing'],
released: [],
};
function getStageProgress(version: VersionWithContext): number {
if (!version.currentStage || !version.progress) return 0;
const roles = STAGE_ROLE_MAP[version.currentStage] || [];
if (roles.length === 0) return 0;
const items = roles.map((r) => version.progress!.find((p) => p.role === r)).filter(Boolean);
if (items.length === 0) return 0;
return Math.round(items.reduce((s, i) => s + i!.percent, 0) / items.length);
}
export default function VersionsPage() {
const { overview, fetchOverview, createVersion } = useProductStore();
const router = useRouter();
const { overview, fetchOverview, createVersion, updateVersion } = useProductStore();
const [search, setSearch] = useState('');
const [statusTab, setStatusTab] = useState<VersionStatus | 'all'>('all');
const [statusFilter, setStatusFilter] = useState('all');
const [projectFilter, setProjectFilter] = useState('all');
const [priorityFilter, setPriorityFilter] = useState('all');
const [projectDropdownOpen, setProjectDropdownOpen] = useState(false);
const [priorityDropdownOpen, setPriorityDropdownOpen] = useState(false);
const [showModal, setShowModal] = useState(false);
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
useEffect(() => { fetchOverview(); }, [fetchOverview]);
@@ -54,14 +123,40 @@ export default function VersionsPage() {
const allProjects = useMemo(() => flattenProjects(overview), [overview]);
const filtered = useMemo(() => {
const list = allVersions.filter((v) => {
if (statusTab !== 'all' && v.status !== statusTab) return false;
if (projectFilter !== 'all' && v.projectName !== projectFilter) return false;
let list = allVersions.filter((v) => {
if (search && !v.name.toLowerCase().includes(search.toLowerCase())) return false;
if (projectFilter !== 'all' && v.projectName !== projectFilter) return false;
return true;
});
if (statusFilter !== 'all') {
if (['planned', 'released', 'paused', 'closed'].includes(statusFilter)) {
list = list.filter(v => v.status === statusFilter);
} else {
list = list.filter(v => v.status === 'developing' && v.currentStage === statusFilter);
}
}
if (priorityFilter !== 'all') {
list = list.filter(v => (v.priority ?? 'P2') === priorityFilter);
}
return sortVersions(list);
}, [allVersions, search, statusTab, projectFilter]);
}, [allVersions, search, statusFilter, projectFilter, priorityFilter]);
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20);
const handleAction = async (version: VersionWithContext, action: 'pause' | 'resume' | 'close') => {
setOpenMenuId(null);
const statusMap: Record<string, VersionStatus> = {
pause: 'paused',
resume: 'developing',
close: 'closed',
};
if (updateVersion) {
await updateVersion(version.productId, version.id, { status: statusMap[action] });
}
};
return (
<div className="flex h-full flex-col">
@@ -77,40 +172,63 @@ export default function VersionsPage() {
</header>
{/* Filters */}
<div className="flex shrink-0 items-center gap-3 border-b border-[var(--line)] bg-[var(--bg-card)] px-5 py-3">
<div className="flex shrink-0 flex-wrap items-center gap-3 border-b border-[var(--line)] bg-[var(--bg-card)] px-5 py-3">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--ink-muted)]" strokeWidth={2} />
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="搜索版本号" className="h-8 w-64 rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-8 pr-3 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]" />
</div>
<div className="flex gap-1">
<div className="flex flex-wrap gap-1">
{STATUS_TABS.map((tab) => (
<button key={tab.key} onClick={() => setStatusTab(tab.key)} className={`h-8 rounded-lg px-3 text-[12.5px] transition-colors ${statusTab === tab.key ? 'bg-[var(--accent)] text-white' : 'border border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)]'}`}>
<button key={tab.key} onClick={() => setStatusFilter(tab.key)} className={`h-8 rounded-lg px-3 text-[12px] transition-colors ${statusFilter === tab.key ? 'bg-[var(--accent)] text-white' : 'border border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)]'}`}>
{tab.label}
</button>
))}
</div>
{/* Project dropdown */}
<div className="relative">
<button onClick={() => setProjectDropdownOpen(!projectDropdownOpen)} className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[12.5px] text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors">
<button onClick={() => setProjectDropdownOpen(!projectDropdownOpen)} className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[12px] text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors">
{projectFilter === 'all' ? '全部项目' : projectFilter}
<ChevronDown className="h-3 w-3" strokeWidth={2} />
</button>
{projectDropdownOpen && (
<div className="absolute left-0 top-full z-10 mt-1 min-w-[140px] rounded-lg border border-[var(--line)] bg-[var(--bg-card)] py-1 shadow-[var(--shadow-md)]">
<button onClick={() => { setProjectFilter('all'); setProjectDropdownOpen(false); }} className={`block w-full px-3 py-1.5 text-left text-[12.5px] transition-colors ${projectFilter === 'all' ? 'bg-[var(--accent-soft)] text-[var(--accent)]' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-hover)]'}`}>
</button>
{allProjects.map((p) => (
<button key={p.id} onClick={() => { setProjectFilter(p.name); setProjectDropdownOpen(false); }} className={`block w-full px-3 py-1.5 text-left text-[12.5px] transition-colors ${projectFilter === p.name ? 'bg-[var(--accent-soft)] text-[var(--accent)]' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-hover)]'}`}>
{p.name}
<>
<div className="fixed inset-0 z-10" onClick={() => setProjectDropdownOpen(false)} />
<div className="absolute left-0 top-full z-20 mt-1 min-w-[140px] rounded-lg border border-[var(--line)] bg-[var(--bg-card)] py-1 shadow-[var(--shadow-md)]">
<button onClick={() => { setProjectFilter('all'); setProjectDropdownOpen(false); }} className={`block w-full px-3 py-1.5 text-left text-[12px] transition-colors ${projectFilter === 'all' ? 'bg-[var(--accent-soft)] text-[var(--accent)]' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-hover)]'}`}>
</button>
))}
</div>
{allProjects.map((p) => (
<button key={p.id} onClick={() => { setProjectFilter(p.name); setProjectDropdownOpen(false); }} className={`block w-full px-3 py-1.5 text-left text-[12px] transition-colors ${projectFilter === p.name ? 'bg-[var(--accent-soft)] text-[var(--accent)]' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-hover)]'}`}>
{p.name}
</button>
))}
</div>
</>
)}
</div>
<div className="ml-auto flex items-center gap-3 text-[11.5px] text-[var(--ink-muted)]">
<span className="flex items-center gap-1.5"><span className="inline-block h-1.5 w-1.5 rounded-full bg-blue-500" /></span>
<span className="flex items-center gap-1.5"><span className="inline-block h-1.5 w-1.5 rounded-full bg-orange-500" /></span>
<span className="flex items-center gap-1.5"><span className="inline-block h-1.5 w-1.5 rounded-full bg-zinc-300" /></span>
{/* Priority dropdown */}
<div className="relative">
<button onClick={() => setPriorityDropdownOpen(!priorityDropdownOpen)} className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[12px] text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors">
{priorityFilter === 'all' ? '全部优先级' : priorityFilter}
<ChevronDown className="h-3 w-3" strokeWidth={2} />
</button>
{priorityDropdownOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setPriorityDropdownOpen(false)} />
<div className="absolute left-0 top-full z-20 mt-1 min-w-[100px] rounded-lg border border-[var(--line)] bg-[var(--bg-card)] py-1 shadow-[var(--shadow-md)]">
<button onClick={() => { setPriorityFilter('all'); setPriorityDropdownOpen(false); }} className={`block w-full px-3 py-1.5 text-left text-[12px] transition-colors ${priorityFilter === 'all' ? 'bg-[var(--accent-soft)] text-[var(--accent)]' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-hover)]'}`}>
</button>
{(['P0', 'P1', 'P2', 'P3', 'P4'] as Priority[]).map((p) => (
<button key={p} onClick={() => { setPriorityFilter(p); setPriorityDropdownOpen(false); }} className={`block w-full px-3 py-1.5 text-left text-[12px] transition-colors ${priorityFilter === p ? 'bg-[var(--accent-soft)] text-[var(--accent)]' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-hover)]'}`}>
{p}
</button>
))}
</div>
</>
)}
</div>
</div>
@@ -119,26 +237,43 @@ export default function VersionsPage() {
<div className="px-5 py-4">
{filtered.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] py-20 text-center">
<p className="text-[14px] font-medium text-[var(--ink-soft)]"></p>
<p className="mt-1.5 text-[12.5px] text-[var(--ink-muted)]"></p>
<p className="text-[13px] font-medium text-[var(--ink-soft)]"></p>
<p className="mt-1.5 text-[12px] text-[var(--ink-muted)]"></p>
</div>
) : (
<div className="overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
<>
<div className="overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
<table className="w-full text-left text-[13px]">
<thead>
<thead className="sticky top-0 z-10">
<tr className="border-b border-[var(--line)] bg-[var(--bg-subtle)]">
<th className="px-4 py-2.5 text-[11.5px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11.5px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11.5px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11.5px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11.5px] 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)]"></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)]"></th>
</tr>
</thead>
<tbody>
{filtered.map((v) => <VersionRow key={v.id} version={v} />)}
{paged.map((v) => (
<VersionRow
key={v.id}
version={v}
openMenuId={openMenuId}
setOpenMenuId={setOpenMenuId}
onNavigate={() => router.push(`/versions/${v.id}`)}
onAction={handleAction}
/>
))}
</tbody>
</table>
</div>
<Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />
</>
)}
</div>
</div>
@@ -148,25 +283,169 @@ export default function VersionsPage() {
);
}
function VersionRow({ version }: { version: VersionWithContext }) {
const dotCls = VERSION_STATUS_DOT[version.status];
interface VersionRowProps {
version: VersionWithContext;
openMenuId: string | null;
setOpenMenuId: (id: string | null) => void;
onNavigate: () => void;
onAction: (version: VersionWithContext, action: 'pause' | 'resume' | 'close') => void;
}
function VersionRow({ version, openMenuId, setOpenMenuId, onNavigate, onAction }: VersionRowProps) {
const priority = (version.priority ?? 'P2') as Priority;
const overallProgress = calcOverallProgress(version.progress);
const healthScore = calcHealthScore(version.status, version.startDate, version.expectedReleaseDate, version.progress);
const healthLevel = getHealthLevel(healthScore);
const riskTags = calcRiskTags(version.status, version.startDate, version.expectedReleaseDate, version.progress, version.currentStage, version.members);
const isMenuOpen = openMenuId === version.id;
// Members: show first 2 with role label, then +N
const members = version.members ?? [];
const displayMembers = members.slice(0, 2);
const extraCount = members.length - 2;
return (
<tr className="border-b border-[var(--line-soft)] last:border-0 transition-colors hover:bg-[var(--bg-subtle)]">
<td className="px-4 py-3 font-medium text-[var(--ink)]">
<div className="flex items-center gap-2">
{/* 版本号 */}
<td className="px-4 py-3">
<button onClick={onNavigate} className="flex items-center gap-2 font-medium text-[var(--ink)] hover:text-[var(--accent)] transition-colors">
<Tag className="h-3.5 w-3.5 text-[var(--ink-muted)]" strokeWidth={1.75} />
{version.name}
</div>
</button>
</td>
{/* 优先级 */}
<td className="px-4 py-3">
<span className="inline-flex items-center gap-1.5 text-[12px] text-[var(--ink-soft)]">
<span className={`h-1.5 w-1.5 rounded-full ${dotCls}`} />
{VERSION_STATUS_LABEL[version.status]}
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium ${PRIORITY_COLORS[priority]}`}>
{priority}
</span>
</td>
<td className="px-4 py-3 text-[var(--ink-soft)]">{version.projectName}</td>
<td className="px-4 py-3 text-[var(--ink-muted)]">{version.productName}</td>
<td className="px-4 py-3 text-[var(--ink-muted)] tabular-nums">{new Date(version.createdAt).toLocaleDateString('zh-CN')}</td>
{/* 当前阶段 */}
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">
{getStageLabel(version)}
</td>
{/* 阶段进度 */}
<td className="px-4 py-3">
{version.status === 'developing' && version.currentStage ? (
<div className="flex items-center gap-2">
<div className="h-1.5 w-16 rounded-full bg-zinc-100">
<div className="h-1.5 rounded-full bg-blue-500 transition-all" style={{ width: `${getStageProgress(version)}%` }} />
</div>
<span className="text-[11px] tabular-nums text-[var(--ink-muted)]">{getStageProgress(version)}%</span>
</div>
) : (
<span className="text-[12px] text-[var(--ink-muted)]">-</span>
)}
</td>
{/* 整体进度 */}
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="h-1.5 w-20 rounded-full bg-zinc-100">
<div
className="h-1.5 rounded-full bg-blue-500 transition-all"
style={{ width: `${overallProgress}%` }}
/>
</div>
<span className="text-[11px] tabular-nums text-[var(--ink-muted)]">{overallProgress}%</span>
</div>
</td>
{/* 健康度 */}
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1.5 text-[12px] font-semibold tabular-nums ${HEALTH_LEVEL_COLOR[healthLevel]}`}>
<span className={`h-2 w-2 rounded-full ${HEALTH_LEVEL_DOT[healthLevel]}`} />
{healthScore}
</span>
</td>
{/* 风险标签 */}
<td className="px-4 py-3">
{riskTags.length === 0 ? (
<span className="text-[11px] text-[var(--ink-muted)]">-</span>
) : (
<div className="flex flex-wrap items-center gap-1">
{riskTags.slice(0, 3).map((tag) => (
<span key={tag.key} className={`inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-medium ${getTagStyle(tag.severity)}`}>
{tag.label}
</span>
))}
{riskTags.length > 3 && (
<span className="text-[10px] text-[var(--ink-muted)]">+{riskTags.length - 3}</span>
)}
</div>
)}
</td>
{/* 截止日期 */}
<td className="px-4 py-3 text-[var(--ink-muted)] tabular-nums">
{version.expectedReleaseDate
? new Date(version.expectedReleaseDate).toISOString().slice(0, 10)
: '-'}
</td>
{/* 负责人 */}
<td className="px-4 py-3">
<div className="flex items-center gap-1 text-[11px] text-[var(--ink-soft)]">
{displayMembers.map((m, i) => (
<span key={i} className="whitespace-nowrap">
{ROLE_LABEL[m.role]}:{m.name}
</span>
))}
{extraCount > 0 && (
<span className="whitespace-nowrap text-[var(--ink-muted)]">+{extraCount}</span>
)}
{members.length === 0 && <span className="text-[var(--ink-muted)]">-</span>}
</div>
</td>
{/* 操作 */}
<td className="px-4 py-3">
<div className="relative">
<button
onClick={() => setOpenMenuId(isMenuOpen ? null : version.id)}
className="rounded-lg p-1.5 text-[var(--ink-muted)] hover:bg-[var(--bg-hover)] transition-colors"
>
<MoreHorizontal className="h-4 w-4" strokeWidth={1.75} />
</button>
{isMenuOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setOpenMenuId(null)} />
<div className="absolute right-0 top-full z-20 mt-1 min-w-[120px] rounded-lg border border-[var(--line)] bg-[var(--bg-card)] py-1 shadow-[var(--shadow-md)]">
{version.status === 'developing' && (
<button
onClick={() => onAction(version, 'pause')}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[12px] text-[var(--ink-soft)] hover:bg-[var(--bg-hover)] transition-colors"
>
<Pause className="h-3.5 w-3.5" strokeWidth={1.75} />
</button>
)}
{version.status === 'paused' && (
<button
onClick={() => onAction(version, 'resume')}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[12px] text-[var(--ink-soft)] hover:bg-[var(--bg-hover)] transition-colors"
>
<Play className="h-3.5 w-3.5" strokeWidth={1.75} />
</button>
)}
{version.status !== 'closed' && version.status !== 'released' && (
<button
onClick={() => onAction(version, 'close')}
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"
>
<XCircle className="h-3.5 w-3.5" strokeWidth={1.75} />
</button>
)}
</div>
</>
)}
</div>
</td>
</tr>
);
}
@@ -176,7 +455,7 @@ type IterationType = 'major' | 'minor' | 'patch';
interface NewVersionModalProps {
overview: any[];
onClose: () => void;
onCreate: (productId: string, data: { name: string; status: VersionStatus }) => void;
onCreate: (productId: string, data: { name: string; status: VersionStatus; priority?: Priority; expectedReleaseDate?: string }) => void;
}
function getNextVersion(existingVersions: any[], projectName: string, type: IterationType): string {
@@ -224,6 +503,8 @@ function NewVersionModal({ overview, onClose, onCreate }: NewVersionModalProps)
const [iterationType, setIterationType] = useState<IterationType | ''>('');
const [versionNumber, setVersionNumber] = useState('');
const [status, setStatus] = useState<VersionStatus>('planned');
const [priority, setPriority] = useState<Priority>('P2');
const [expectedReleaseDate, setExpectedReleaseDate] = useState('');
const [submitting, setSubmitting] = useState(false);
const selectedProduct = overview.find((p: any) => p.id === productId);
@@ -269,7 +550,12 @@ function NewVersionModal({ overview, onClose, onCreate }: NewVersionModalProps)
if (!canSubmit) return;
setSubmitting(true);
try {
await onCreate(productId, { name: `${projectName}V${versionNumber}`, status });
await onCreate(productId, {
name: `${projectName}V${versionNumber}`,
status,
priority,
...(expectedReleaseDate ? { expectedReleaseDate } : {}),
});
onClose();
} catch (e) {
setSubmitting(false);
@@ -289,7 +575,7 @@ function NewVersionModal({ overview, onClose, onCreate }: NewVersionModalProps)
<div className="space-y-4">
{/* 产品选择 */}
<div>
<label className="mb-1.5 block text-[12.5px] font-medium text-[var(--ink-soft)]"><span className="text-red-500">*</span></label>
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]"><span className="text-red-500">*</span></label>
<select value={productId} onChange={(e) => handleProductChange(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)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]">
<option value=""></option>
{overview.map((p: any) => <option key={p.id} value={p.id}>{p.name}</option>)}
@@ -298,7 +584,7 @@ function NewVersionModal({ overview, onClose, onCreate }: NewVersionModalProps)
{/* 项目选择 */}
<div>
<label className="mb-1.5 block text-[12.5px] font-medium text-[var(--ink-soft)]"><span className="text-red-500">*</span></label>
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]"><span className="text-red-500">*</span></label>
<select value={projectId} onChange={(e) => handleProjectChange(e.target.value)} disabled={!productId} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] disabled:opacity-50 focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]">
<option value=""></option>
{projects.map((p: any) => <option key={p.id} value={p.id}>{p.name}</option>)}
@@ -307,7 +593,7 @@ function NewVersionModal({ overview, onClose, onCreate }: NewVersionModalProps)
{/* 迭代类型 */}
<div>
<label className="mb-1.5 block text-[12.5px] font-medium text-[var(--ink-soft)]"><span className="text-red-500">*</span></label>
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]"><span className="text-red-500">*</span></label>
<div className="grid grid-cols-3 gap-2">
{([
{ key: 'major' as const, label: '大版本', hint: 'X.0' },
@@ -315,8 +601,8 @@ function NewVersionModal({ overview, onClose, onCreate }: NewVersionModalProps)
{ key: 'patch' as const, label: '小版本', hint: '1.2.X' },
]).map((opt) => (
<button key={opt.key} onClick={() => handleIterationChange(opt.key)} disabled={!projectId} className={`flex flex-col items-center gap-0.5 rounded-lg border px-3 py-2 transition-colors disabled:opacity-50 ${iterationType === opt.key ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)]'}`}>
<span className="text-[12.5px] font-medium">{opt.label}</span>
<span className="text-[10.5px] text-[var(--ink-muted)] tabular-nums">{opt.hint}</span>
<span className="text-[12px] font-medium">{opt.label}</span>
<span className="text-[10px] text-[var(--ink-muted)] tabular-nums">{opt.hint}</span>
</button>
))}
</div>
@@ -324,27 +610,50 @@ function NewVersionModal({ overview, onClose, onCreate }: NewVersionModalProps)
{/* 版本号 */}
<div>
<label className="mb-1.5 block text-[12.5px] font-medium text-[var(--ink-soft)]"></label>
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]"></label>
<input value={versionNumber} onChange={(e) => handleVersionInput(e.target.value)} placeholder="1.0" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] tabular-nums text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]" />
{projectName && versionNumber && (
<p className="mt-1.5 text-[11.5px] text-[var(--ink-muted)]"><span className="font-medium text-[var(--ink-soft)]">{projectName}V{versionNumber}</span></p>
<p className="mt-1.5 text-[11px] text-[var(--ink-muted)]"><span className="font-medium text-[var(--ink-soft)]">{projectName}V{versionNumber}</span></p>
)}
</div>
{/* 状态 */}
<div>
<label className="mb-1.5 block text-[12.5px] font-medium text-[var(--ink-soft)]"></label>
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]"></label>
<div className="flex gap-2">
{([
{ key: 'planned' as VersionStatus, label: '规划中' },
{ key: 'developing' as VersionStatus, label: '开发中' },
]).map((opt) => (
<button key={opt.key} onClick={() => setStatus(opt.key)} className={`flex-1 h-9 rounded-lg border px-3 text-[12.5px] transition-colors ${status === opt.key ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)]'}`}>
<button key={opt.key} onClick={() => setStatus(opt.key)} className={`flex-1 h-9 rounded-lg border px-3 text-[12px] transition-colors ${status === opt.key ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)]'}`}>
{opt.label}
</button>
))}
</div>
</div>
{/* 优先级 */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]"></label>
<div className="grid grid-cols-3 gap-2">
{(['P0', 'P1', 'P2', 'P3', 'P4'] as Priority[]).map((p) => (
<button key={p} onClick={() => setPriority(p)} className={`h-9 rounded-lg border px-3 text-[12px] font-medium transition-colors ${priority === p ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)]'}`}>
{p}
</button>
))}
</div>
</div>
{/* 截止日期 */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]"></label>
<input
type="date"
value={expectedReleaseDate}
onChange={(e) => setExpectedReleaseDate(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)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]"
/>
</div>
</div>
<div className="mt-6 flex justify-end gap-2">

View File

@@ -0,0 +1,71 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { ChevronDown, X } from 'lucide-react';
interface SelectOption {
value: string;
label: string;
}
interface FilterSelectProps {
value: string;
onChange: (value: string) => void;
options: SelectOption[];
placeholder?: string;
allLabel?: string;
className?: string;
}
export function FilterSelect({ value, onChange, options, placeholder, allLabel = '全部', className }: FilterSelectProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const selected = options.find((o) => o.value === value);
const displayText = value === 'all' || !value ? '' : selected?.label || '';
return (
<div ref={ref} className={`relative ${className || ''}`}>
<button
type="button"
onClick={() => setOpen(!open)}
className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2.5 text-[12px] hover:border-[var(--accent)] transition-colors whitespace-nowrap"
>
<span className={`truncate max-w-[80px] ${displayText ? 'text-[var(--ink)]' : 'text-[var(--ink-muted)]'}`}>
{displayText || placeholder || allLabel}
</span>
<ChevronDown className={`shrink-0 h-3 w-3 text-[var(--ink-muted)] transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div className="absolute top-full left-0 mt-1 z-50 min-w-full max-w-[200px] max-h-[240px] overflow-y-auto rounded-xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-md)] py-1">
<button
type="button"
onClick={() => { onChange('all'); setOpen(false); }}
className={`w-full text-left px-3 py-1.5 text-[12px] truncate transition-colors ${value === 'all' || !value ? 'text-[var(--accent)] font-medium bg-[var(--accent-soft)]' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
>
{allLabel}
</button>
{options.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => { onChange(opt.value); setOpen(false); }}
className={`w-full text-left px-3 py-1.5 text-[12px] truncate transition-colors ${value === opt.value ? 'text-[var(--accent)] font-medium bg-[var(--accent-soft)]' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
>
{opt.label}
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,119 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { ChevronLeft, ChevronRight, Calendar } from 'lucide-react';
interface MonthPickerProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
const MONTHS = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
export function MonthPicker({ value, onChange, placeholder = '选择月份' }: MonthPickerProps) {
const [open, setOpen] = useState(false);
const [year, setYear] = useState(() => {
if (value) return parseInt(value.slice(0, 4));
return new Date().getFullYear();
});
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const selectedMonth = value ? parseInt(value.slice(5, 7)) : null;
const selectedYear = value ? parseInt(value.slice(0, 4)) : null;
const handleSelect = (month: number) => {
const m = String(month).padStart(2, '0');
onChange(`${year}-${m}`);
setOpen(false);
};
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation();
onChange('');
setOpen(false);
};
const displayText = value
? `${selectedYear}${selectedMonth}`
: '';
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
className="flex h-8 items-center gap-2 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[12px] text-[var(--ink-soft)] hover:border-[var(--accent)] transition-colors min-w-[120px]"
>
<Calendar className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
<span className={displayText ? 'text-[var(--ink)]' : 'text-[var(--ink-muted)]'}>
{displayText || placeholder}
</span>
{value && (
<span
onClick={handleClear}
className="ml-auto text-[var(--ink-muted)] hover:text-[var(--ink)] text-[10px] leading-none"
>
</span>
)}
</button>
{open && (
<div className="absolute top-full left-0 mt-1 z-50 w-[240px] rounded-xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-md)] p-3">
{/* Year nav */}
<div className="flex items-center justify-between mb-3">
<button
type="button"
onClick={() => setYear(year - 1)}
className="p-1 rounded-md hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)] hover:text-[var(--ink)]"
>
<ChevronLeft className="h-4 w-4" />
</button>
<span className="text-[13px] font-semibold text-[var(--ink)]">{year}</span>
<button
type="button"
onClick={() => setYear(year + 1)}
className="p-1 rounded-md hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)] hover:text-[var(--ink)]"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
{/* Month grid */}
<div className="grid grid-cols-3 gap-1.5">
{MONTHS.map((label, i) => {
const month = i + 1;
const isSelected = selectedYear === year && selectedMonth === month;
const isCurrent = new Date().getFullYear() === year && new Date().getMonth() === i;
return (
<button
key={month}
type="button"
onClick={() => handleSelect(month)}
className={`h-8 rounded-lg text-[12px] font-medium transition-colors ${
isSelected
? 'bg-[var(--accent)] text-white'
: isCurrent
? 'border border-[var(--accent)] text-[var(--accent)] hover:bg-[var(--accent-soft)]'
: 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]'
}`}
>
{label}
</button>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,95 @@
'use client';
import { useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
interface PaginationProps {
total: number;
page: number;
pageSize: number;
onChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
}
export function Pagination({ total, page, pageSize, onChange, onPageSizeChange }: PaginationProps) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
if (total === 0) return null;
const pages: (number | '...')[] = [];
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) pages.push(i);
} else {
pages.push(1);
if (page > 3) pages.push('...');
const start = Math.max(2, page - 1);
const end = Math.min(totalPages - 1, page + 1);
for (let i = start; i <= end; i++) pages.push(i);
if (page < totalPages - 2) pages.push('...');
pages.push(totalPages);
}
return (
<div className="flex items-center justify-between px-1 py-3">
<div className="flex items-center gap-2">
<span className="text-[12px] text-[var(--ink-muted)]"> {total} </span>
<select
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
className="h-7 rounded-md border border-[var(--line)] bg-[var(--bg-card)] px-1.5 text-[12px] text-[var(--ink-soft)] focus:border-[var(--accent)] focus:outline-none"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size} /</option>
))}
</select>
</div>
<div className="flex items-center gap-1">
<button
disabled={page <= 1}
onClick={() => onChange(page - 1)}
className="h-7 w-7 flex items-center justify-center rounded-md border border-[var(--line)] text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)] disabled:opacity-40 disabled:pointer-events-none"
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
{pages.map((p, idx) =>
p === '...' ? (
<span key={`dots-${idx}`} className="w-7 text-center text-[12px] text-[var(--ink-muted)]">...</span>
) : (
<button
key={p}
onClick={() => onChange(p as number)}
className={`h-7 w-7 flex items-center justify-center rounded-md text-[12px] font-medium transition-colors ${page === p ? 'bg-[var(--accent)] text-white' : 'border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
>
{p}
</button>
)
)}
<button
disabled={page >= totalPages}
onClick={() => onChange(page + 1)}
className="h-7 w-7 flex items-center justify-center rounded-md border border-[var(--line)] text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)] disabled:opacity-40 disabled:pointer-events-none"
>
<ChevronRight className="h-3.5 w-3.5" />
</button>
</div>
</div>
);
}
export function usePagination<T>(items: T[], defaultPageSize: number = 20) {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(defaultPageSize);
const total = items.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const safePage = Math.min(page, totalPages);
const paged = items.slice((safePage - 1) * pageSize, safePage * pageSize);
const handlePageSizeChange = (newSize: number) => {
setPageSize(newSize);
setPage(1);
};
return { paged, page: safePage, setPage, total, pageSize, setPageSize: handlePageSizeChange };
}

View File

@@ -1,7 +1,7 @@
'use client';
import { usePathname, useRouter } from 'next/navigation';
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Search } from 'lucide-react';
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Search, Lightbulb, Clock, Shield } from 'lucide-react';
const NAV_GROUPS = [
{
@@ -11,12 +11,15 @@ const NAV_GROUPS = [
{ label: '产品', path: '/products', icon: Package },
{ label: '项目', path: '/projects', icon: FolderKanban },
{ label: '版本', path: '/versions', icon: Tag },
{ label: '需求', path: '/requirements', icon: Lightbulb },
{ label: '加班记录', path: '/overtime', icon: Clock },
],
},
{
label: '管理',
items: [
{ label: '成员', path: '/admin/members', icon: Users },
{ label: '角色', path: '/admin/roles', icon: Shield },
],
},
];
@@ -34,7 +37,7 @@ export function Sidebar() {
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-[var(--accent)]">
<LayoutGrid className="h-4 w-4 text-white" strokeWidth={2.25} />
</div>
<span className="text-[14px] font-semibold tracking-tight text-[var(--ink)]">FTB</span>
<span className="text-[13px] font-semibold tracking-tight text-[var(--ink)]">FTB</span>
</div>
<div className="px-3 pb-2">
@@ -42,7 +45,7 @@ export function Sidebar() {
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--ink-muted)]" strokeWidth={2} />
<input
placeholder="搜索"
className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] pl-8 pr-2 text-[12.5px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:bg-[var(--bg-card)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]"
className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] pl-8 pr-2 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:bg-[var(--bg-card)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]"
/>
</div>
</div>

View File

@@ -29,8 +29,8 @@ export function ProductCard({ product, onClick }: Props) {
<Package className="h-[18px] w-[18px] text-[var(--accent-hover)]" strokeWidth={1.75} />
</div>
<div className="min-w-0 flex-1">
<h3 className="truncate text-[14px] font-semibold text-[var(--ink)]">{product.name}</h3>
<p className="mt-1 line-clamp-2 text-[12.5px] leading-relaxed text-[var(--ink-muted)]">
<h3 className="truncate text-[13px] font-semibold text-[var(--ink)]">{product.name}</h3>
<p className="mt-1 line-clamp-2 text-[12px] leading-relaxed text-[var(--ink-muted)]">
{product.description || '暂无描述'}
</p>
</div>

View File

@@ -50,7 +50,7 @@ export function RequirementForm({ initialData, onSubmit, onCancel }: Props) {
key={value}
type="button"
onClick={() => setPriority(v)}
className={`h-7 rounded-md px-2.5 text-[12.5px] transition-colors ${
className={`h-7 rounded-md px-2.5 text-[12px] transition-colors ${
active
? 'bg-[var(--accent)] text-white'
: 'border border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--ink-muted)] hover:text-[var(--ink)]'

View File

@@ -1,26 +1,16 @@
'use client';
import { RequirementStatus } from '@ftb/shared';
import { REQUIREMENT_STATUS_LABEL } from '@/lib/constants';
import type { RequirementStatus } from '@/lib/requirement';
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR } from '@/lib/requirement';
interface Props {
status: RequirementStatus;
}
const STATUS_STYLES: Record<RequirementStatus, string> = {
[RequirementStatus.DRAFT]: 'bg-zinc-100 text-zinc-700 ring-zinc-200',
[RequirementStatus.REVIEWING]: 'bg-blue-50 text-blue-700 ring-blue-200',
[RequirementStatus.APPROVED]: 'bg-emerald-50 text-emerald-700 ring-emerald-200',
[RequirementStatus.REJECTED]: 'bg-red-50 text-red-700 ring-red-200',
[RequirementStatus.DELIVERED]: 'bg-violet-50 text-violet-700 ring-violet-200',
};
export function RequirementStatusBadge({ status }: Props) {
return (
<span
className={`inline-flex items-center rounded-md px-1.5 py-0.5 text-[11px] font-medium ring-1 ring-inset ${STATUS_STYLES[status]}`}
>
{REQUIREMENT_STATUS_LABEL[status]}
<span className={`inline-flex items-center rounded-md px-1.5 py-0.5 text-[11px] font-medium ${REQ_STATUS_COLOR[status]}`}>
{REQ_STATUS_LABEL[status]}
</span>
);
}

View File

@@ -1,9 +1,10 @@
'use client';
import { RequirementStatus } from '@ftb/shared';
import type { RequirementStatus } from '@/lib/requirement';
import { REQ_STATUS_LABEL } from '@/lib/requirement';
import { Pencil, Trash2 } from 'lucide-react';
import { RequirementStatusBadge } from './RequirementStatusBadge';
import { PRIORITY_LABEL, REQUIREMENT_STATUS_LABEL } from '@/lib/constants';
import { PRIORITY_LABEL } from '@/lib/constants';
interface RequirementItem {
id: string;
@@ -25,7 +26,7 @@ interface Props {
onCreate: () => void;
}
const ALL_STATUSES = Object.values(RequirementStatus);
const ALL_STATUSES: RequirementStatus[] = ['pending_review', 'adopted', 'rejected', 'planned', 'developing', 'testing', 'released', 'closed'];
export function RequirementTable({
requirements,
@@ -44,7 +45,7 @@ export function RequirementTable({
</FilterChip>
{ALL_STATUSES.map((s) => (
<FilterChip key={s} active={statusFilter === s} onClick={() => onFilterChange(s)}>
{REQUIREMENT_STATUS_LABEL[s]}
{REQ_STATUS_LABEL[s]}
</FilterChip>
))}
</div>
@@ -58,19 +59,19 @@ export function RequirementTable({
{requirements.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] py-16 text-center">
<p className="text-[14px] font-medium text-[var(--ink-soft)]"></p>
<p className="mt-1.5 text-[12.5px] text-[var(--ink-muted)]">"新建需求"</p>
<p className="text-[13px] font-medium text-[var(--ink-soft)]"></p>
<p className="mt-1.5 text-[12px] text-[var(--ink-muted)]">"新建需求"</p>
</div>
) : (
<div className="overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
<table className="w-full text-left text-[13px]">
<thead>
<tr className="border-b border-[var(--line)] bg-[var(--bg-subtle)]">
<th className="px-4 py-2.5 text-[11.5px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11.5px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11.5px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11.5px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-right text-[11.5px] 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-right text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
</tr>
</thead>
<tbody>
@@ -124,7 +125,7 @@ function FilterChip({
return (
<button
onClick={onClick}
className={`h-7 rounded-md px-2.5 text-[12.5px] transition-colors ${
className={`h-7 rounded-md px-2.5 text-[12px] transition-colors ${
active
? 'bg-[var(--accent)] text-white'
: 'border border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--ink-muted)] hover:text-[var(--ink)]'

View File

@@ -0,0 +1,164 @@
'use client';
import { useState } from 'react';
import { X, Plus, Pencil, Trash2, Check } from 'lucide-react';
import type { DictItem, SourceTarget, SourceType } from '@/lib/requirement';
import { SOURCE_TYPE_LABEL } from '@/lib/requirement';
interface DictDrawerProps {
open: boolean;
title: string;
items: DictItem[];
onClose: () => void;
onAdd: (name: string) => void;
onUpdate: (id: string, name: string) => void;
onDelete: (id: string) => void;
}
interface SourceDrawerProps {
open: boolean;
items: SourceTarget[];
onClose: () => void;
onAdd: (name: string, sourceType: SourceType) => void;
onUpdate: (id: string, name: string) => void;
onDelete: (id: string) => void;
}
const SOURCE_TYPES: SourceType[] = ['customer', 'internal', 'operation', 'aftersale', 'market', 'competitor', 'management'];
export function DictDrawer({ open, title, items, onClose, onAdd, onUpdate, onDelete }: DictDrawerProps) {
if (!open) return null;
return (
<DrawerShell title={title} onClose={onClose}>
<ItemList items={items} onAdd={onAdd} onUpdate={onUpdate} onDelete={onDelete} />
</DrawerShell>
);
}
export function SourceDrawer({ open, items, onClose, onAdd, onUpdate, onDelete }: SourceDrawerProps) {
const [activeTab, setActiveTab] = useState<SourceType>('customer');
if (!open) return null;
const filtered = items.filter((t) => t.sourceType === activeTab);
return (
<DrawerShell title="来源对象管理" onClose={onClose}>
{/* Tabs */}
<div className="flex flex-wrap gap-1 px-4 py-2 border-b border-[var(--line)]">
{SOURCE_TYPES.map((type) => (
<button
key={type}
onClick={() => setActiveTab(type)}
className={`h-7 rounded-md px-2.5 text-[11px] font-medium transition-colors ${activeTab === type ? 'bg-[var(--accent)] text-white' : 'border border-[var(--line)] text-[var(--ink-soft)] hover:text-[var(--ink)]'}`}
>
{SOURCE_TYPE_LABEL[type]}
</button>
))}
</div>
<ItemList
items={filtered}
onAdd={(name) => onAdd(name, activeTab)}
onUpdate={onUpdate}
onDelete={onDelete}
placeholder={`添加${SOURCE_TYPE_LABEL[activeTab]}来源对象`}
/>
</DrawerShell>
);
}
function DrawerShell({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
return (
<div className="fixed inset-0 z-50 flex justify-end bg-black/30" onClick={onClose}>
<div className="w-80 h-full bg-[var(--bg-card)] border-l border-[var(--line)] flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--line)]">
<span className="text-[13px] font-semibold text-[var(--ink)]">{title}</span>
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]">
<X className="h-4 w-4" />
</button>
</div>
{children}
</div>
</div>
);
}
function ItemList({ items, onAdd, onUpdate, onDelete, placeholder }: {
items: { id: string; name: string }[];
onAdd: (name: string) => void;
onUpdate: (id: string, name: string) => void;
onDelete: (id: string) => void;
placeholder?: string;
}) {
const [newName, setNewName] = useState('');
const [editingId, setEditingId] = useState<string | null>(null);
const [editingName, setEditingName] = useState('');
const handleAdd = () => {
const trimmed = newName.trim();
if (!trimmed) return;
onAdd(trimmed);
setNewName('');
};
const confirmEdit = () => {
if (editingId && editingName.trim()) {
onUpdate(editingId, editingName.trim());
}
setEditingId(null);
setEditingName('');
};
return (
<>
<div className="flex items-center gap-2 px-4 py-3 border-b border-[var(--line)]">
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
placeholder={placeholder || '输入名称...'}
className="flex-1 h-8 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] outline-none focus:border-[var(--accent)]"
/>
<button onClick={handleAdd} className="h-8 w-8 flex items-center justify-center rounded-lg bg-[var(--accent)] text-white hover:opacity-90">
<Plus className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex-1 overflow-y-auto">
{items.map((item) => (
<div key={item.id} className="group flex items-center px-4 py-2 hover:bg-[var(--bg-subtle)]">
{editingId === item.id ? (
<div className="flex items-center gap-2 flex-1">
<input
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') confirmEdit(); if (e.key === 'Escape') setEditingId(null); }}
autoFocus
className="flex-1 h-7 rounded-lg border border-[var(--accent)] bg-[var(--bg-card)] px-2 text-[13px] text-[var(--ink)] outline-none"
/>
<button onClick={confirmEdit} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--accent)]">
<Check className="h-3.5 w-3.5" />
</button>
</div>
) : (
<>
<span className="flex-1 text-[13px] text-[var(--ink)]">{item.name}</span>
<div className="hidden group-hover:flex items-center gap-1">
<button onClick={() => { setEditingId(item.id); setEditingName(item.name); }} className="p-1 rounded hover:bg-[var(--bg-card)] text-[var(--ink-muted)]">
<Pencil className="h-3.5 w-3.5" />
</button>
<button onClick={() => onDelete(item.id)} className="p-1 rounded hover:bg-[var(--bg-card)] text-[var(--ink-muted)] hover:text-red-500">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</>
)}
</div>
))}
{items.length === 0 && (
<div className="px-4 py-8 text-center text-[12px] text-[var(--ink-muted)]"></div>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,160 @@
'use client';
import { X } from 'lucide-react';
import type { Requirement, DictItem, SourceTarget } from '@/lib/requirement';
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, EFFORT_SHORT, EFFORT_COLOR, SOURCE_TYPE_LABEL } from '@/lib/requirement';
interface RequirementDetailProps {
requirement: Requirement;
projects: { id: string; name: string }[];
types: DictItem[];
platforms: DictItem[];
sourceTargets: SourceTarget[];
resolveVersionName: (id?: string) => string;
onClose: () => void;
onEdit: () => void;
onAdopt?: () => void;
onReject?: () => void;
onCloseReq?: () => void;
onDelete?: () => void;
}
const PRIORITY_COLORS: Record<string, string> = {
P0: 'bg-red-500/10 text-red-600',
P1: 'bg-orange-500/10 text-orange-600',
P2: 'bg-blue-500/10 text-blue-600',
P3: 'bg-zinc-100 text-zinc-600',
P4: 'bg-zinc-100 text-zinc-500',
};
export function RequirementDetail({
requirement: req,
projects,
types,
platforms,
sourceTargets,
resolveVersionName,
onClose,
onEdit,
onAdopt,
onReject,
onCloseReq,
onDelete,
}: RequirementDetailProps) {
const canEdit = ['pending_review', 'adopted', 'planned'].includes(req.status);
const projectName = projects.find((p) => p.id === req.projectId)?.name ?? '-';
const typeName = types.find((t) => t.id === req.typeId)?.name ?? '-';
const platformNames = req.platforms.map((pid) => platforms.find((p) => p.id === pid)?.name ?? pid).join('、') || '-';
return (
<div className="fixed inset-0 z-50 flex justify-end bg-black/30" onClick={onClose}>
<div className="w-[480px] h-full bg-[var(--bg-card)] border-l border-[var(--line)] flex flex-col shadow-xl" onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--line)]">
<div className="flex items-center gap-2.5">
<span className="text-[13px] font-semibold text-[var(--ink)]">{req.code}</span>
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium ${REQ_STATUS_COLOR[req.status]}`}>
{REQ_STATUS_LABEL[req.status]}
</span>
</div>
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]">
<X className="h-4 w-4" />
</button>
</div>
{/* Actions */}
<div className="flex items-center gap-2 px-5 py-3 border-b border-[var(--line)]">
{canEdit && (
<button onClick={onEdit} className="h-7 px-3 rounded-md text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] transition-colors">
</button>
)}
{onAdopt && req.status === 'pending_review' && (
<button onClick={onAdopt} className="h-7 px-3 rounded-md text-[12px] font-medium text-emerald-600 border border-emerald-200 hover:bg-emerald-50 transition-colors">
</button>
)}
{onReject && req.status === 'pending_review' && (
<button onClick={onReject} className="h-7 px-3 rounded-md text-[12px] font-medium text-red-600 border border-red-200 hover:bg-red-50 transition-colors">
</button>
)}
{onCloseReq && (req.status === 'adopted' || req.status === 'planned') && (
<button onClick={onCloseReq} className="h-7 px-3 rounded-md text-[12px] font-medium text-orange-600 border border-orange-200 hover:bg-orange-50 transition-colors">
</button>
)}
{onDelete && ['pending_review', 'adopted', 'rejected', 'closed'].includes(req.status) && (
<button onClick={onDelete} className="h-7 px-3 rounded-md text-[12px] font-medium text-red-500 border border-red-200 hover:bg-red-50 transition-colors">
</button>
)}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
{/* 需求概述 */}
<div>
<h2 className="text-[15px] font-semibold text-[var(--ink)] leading-snug">{req.title}</h2>
</div>
{/* 需求描述 */}
{req.description && (
<div>
<Label></Label>
<p className="text-[13px] text-[var(--ink-soft)] whitespace-pre-wrap leading-relaxed">{req.description}</p>
</div>
)}
{/* 基本信息 */}
<div className="rounded-lg border border-[var(--line)] divide-y divide-[var(--line)]">
<FieldRow label="需求来源" value={`${SOURCE_TYPE_LABEL[req.sourceType]} · ${req.sourceTarget || '-'}`} />
<FieldRow label="所属项目" value={projectName} />
<FieldRow label="需求类型" value={typeName} />
<FieldRow label="支持端" value={platformNames} />
<FieldRow label="优先级">
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium ${PRIORITY_COLORS[req.priority] ?? ''}`}>
{req.priority}
</span>
</FieldRow>
{req.effort && (
<FieldRow label="工作量">
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium ${EFFORT_COLOR[req.effort]}`}>
{EFFORT_SHORT[req.effort]}
</span>
</FieldRow>
)}
<FieldRow label="所属版本" value={resolveVersionName(req.versionId)} />
</div>
{/* 人员 & 日期 */}
<div className="rounded-lg border border-[var(--line)] divide-y divide-[var(--line)]">
<FieldRow label="产品负责人" value={req.productOwner || '-'} />
<FieldRow label="录入人员" value={req.creator} />
<FieldRow label="录入日期" value={req.createdAt.slice(0, 10)} />
</div>
{/* 关联父需求 */}
{req.parentId && (
<div className="rounded-lg border border-[var(--line)]">
<FieldRow label="父需求" value={req.parentId} />
</div>
)}
</div>
</div>
</div>
);
}
function Label({ children }: { children: React.ReactNode }) {
return <div className="text-[11px] font-medium text-[var(--ink-muted)] mb-1">{children}</div>;
}
function FieldRow({ label, value, children }: { label: string; value?: string; children?: React.ReactNode }) {
return (
<div className="flex items-center justify-between px-4 py-2.5">
<span className="text-[12px] text-[var(--ink-muted)]">{label}</span>
{children ?? <span className="text-[13px] text-[var(--ink)]">{value}</span>}
</div>
);
}

View File

@@ -0,0 +1,412 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { X } from 'lucide-react';
import type { Requirement, Effort, DictItem, SourceType, SourceTarget } from '@/lib/requirement';
import type { Priority } from '@/lib/derive';
import { EFFORT_LABEL, SOURCE_TYPE_LABEL, SOURCE_TARGET_LABEL } from '@/lib/requirement';
interface RequirementModalProps {
open: boolean;
initial?: Requirement | null;
products: { id: string; name: string }[];
projects: { id: string; name: string; productId: string }[];
sourceTargets: SourceTarget[];
types: DictItem[];
platforms: DictItem[];
requirements: Requirement[];
onClose: () => void;
onSubmit: (data: any) => void;
onOpenDrawer: (type: 'source' | 'type' | 'platform') => void;
}
const PRIORITIES: Priority[] = ['P0', 'P1', 'P2', 'P3', 'P4'];
const EFFORTS: Effort[] = ['S', 'M', 'L', 'XL'];
const SOURCE_TYPES: SourceType[] = ['customer', 'internal', 'operation', 'aftersale', 'market', 'competitor', 'management'];
const PRIORITY_COLORS: Record<Priority, string> = {
P0: 'bg-red-500 text-white',
P1: 'bg-orange-500 text-white',
P2: 'bg-blue-500 text-white',
P3: 'bg-zinc-500 text-white',
P4: 'bg-zinc-400 text-white',
};
export function RequirementModal({
open,
initial,
products,
projects,
sourceTargets,
types,
platforms,
requirements,
onClose,
onSubmit,
onOpenDrawer,
}: RequirementModalProps) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [sourceType, setSourceType] = useState<SourceType>('customer');
const [sourceTarget, setSourceTarget] = useState('');
const [productId, setProductId] = useState('');
const [projectId, setProjectId] = useState('');
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([]);
const [typeId, setTypeId] = useState('');
const [priority, setPriority] = useState<Priority>('P2');
const [effort, setEffort] = useState<Effort>('M');
const [productOwner, setProductOwner] = useState('');
const [parentId, setParentId] = useState('');
const [platformDropOpen, setPlatformDropOpen] = useState(false);
const filteredProjects = useMemo(
() => productId ? projects.filter((p) => p.productId === productId) : projects,
[projects, productId],
);
useEffect(() => {
if (initial) {
setTitle(initial.title);
setDescription(initial.description || '');
setSourceType(initial.sourceType || 'customer');
setSourceTarget(initial.sourceTarget || '');
setProductId(initial.productId || '');
setProjectId(initial.projectId || '');
setSelectedPlatforms(initial.platforms || []);
setTypeId(initial.typeId || '');
setPriority(initial.priority || 'P2');
setEffort(initial.effort || 'M');
setProductOwner(initial.productOwner || '');
setParentId(initial.parentId || '');
} else {
setTitle('');
setDescription('');
setSourceType('customer');
setSourceTarget('');
setProductId('');
setProjectId('');
setSelectedPlatforms([]);
setTypeId('');
setPriority('P2');
setEffort('M');
setProductOwner('');
setParentId('');
}
}, [initial, open]);
if (!open) return null;
const filteredTargets = sourceTargets.filter(t => t.sourceType === sourceType);
const togglePlatform = (id: string) => {
setSelectedPlatforms((prev) =>
prev.includes(id) ? prev.filter((p) => p !== id) : [...prev, id]
);
};
const handleSubmit = () => {
if (!title.trim()) return;
onSubmit({
title: title.trim(),
description: description.trim(),
sourceType,
sourceTarget: sourceTarget || undefined,
productId: productId || undefined,
projectId: projectId || undefined,
platforms: selectedPlatforms,
typeId: typeId || undefined,
priority,
productOwner: productOwner.trim() || undefined,
parentId: parentId || undefined,
});
};
const platformDisplay =
selectedPlatforms.length > 0
? platforms
.filter((p) => selectedPlatforms.includes(p.id))
.map((p) => p.name)
.join(', ')
: '请选择';
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)] p-6 shadow-[var(--shadow-md)] max-h-[85vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between mb-5">
<h2 className="text-[15px] font-semibold text-[var(--ink)]">
{initial ? '编辑需求' : '新建需求'}
</h2>
<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-4">
{/* 1. 需求概述 */}
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="请输入需求标题"
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] outline-none focus:border-[var(--accent)]"
/>
</div>
{/* 2. 需求描述 */}
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
placeholder="请输入需求描述"
className="w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 py-2 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] outline-none focus:border-[var(--accent)] resize-none"
/>
</div>
{/* 3. 来源类型 + 来源对象 (2-column grid) */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
</label>
<select
value={sourceType}
onChange={(e) => {
setSourceType(e.target.value as SourceType);
setSourceTarget('');
}}
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)]"
>
{SOURCE_TYPES.map((st) => (
<option key={st} value={st}>
{SOURCE_TYPE_LABEL[st]}
</option>
))}
</select>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[12px] font-medium text-[var(--ink-soft)]">
{SOURCE_TARGET_LABEL[sourceType]}
</label>
<button
type="button"
onClick={() => onOpenDrawer('source')}
className="text-[11px] text-[var(--accent)] hover:underline"
>
</button>
</div>
<select
value={sourceTarget}
onChange={(e) => setSourceTarget(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>
{filteredTargets.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</div>
</div>
{/* 4. 所属产品 → 项目 */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
</label>
<select
value={productId}
onChange={(e) => { setProductId(e.target.value); setProjectId(''); }}
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>
{products.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
</label>
<select
value={projectId}
onChange={(e) => setProjectId(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>
{filteredProjects.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
</div>
{/* 5. 支持端 (multi-select) */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[12px] font-medium text-[var(--ink-soft)]"></label>
<button
type="button"
onClick={() => onOpenDrawer('platform')}
className="text-[11px] text-[var(--accent)] hover:underline"
>
</button>
</div>
<div className="relative">
<button
type="button"
onClick={() => setPlatformDropOpen(!platformDropOpen)}
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-left text-[var(--ink)] truncate"
>
{platformDisplay}
</button>
{platformDropOpen && (
<div className="absolute top-10 left-0 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-md)] z-10 max-h-40 overflow-y-auto py-1">
{platforms.map((p) => (
<label
key={p.id}
className="flex items-center gap-2 px-3 py-1.5 hover:bg-[var(--bg-subtle)] cursor-pointer"
>
<input
type="checkbox"
checked={selectedPlatforms.includes(p.id)}
onChange={() => togglePlatform(p.id)}
className="h-3.5 w-3.5 rounded border-[var(--line)] accent-[var(--accent)]"
/>
<span className="text-[13px] text-[var(--ink)]">{p.name}</span>
</label>
))}
{platforms.length === 0 && (
<div className="px-3 py-2 text-[12px] text-[var(--ink-muted)]"></div>
)}
</div>
)}
</div>
</div>
{/* 6. 需求类型 */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[12px] font-medium text-[var(--ink-soft)]"></label>
<button
type="button"
onClick={() => onOpenDrawer('type')}
className="text-[11px] text-[var(--accent)] hover:underline"
>
</button>
</div>
<select
value={typeId}
onChange={(e) => setTypeId(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>
{types.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</div>
{/* 7. 优先级 (button group) */}
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
</label>
<div className="flex gap-2">
{PRIORITIES.map((p) => (
<button
key={p}
type="button"
onClick={() => setPriority(p)}
className={`h-8 px-3 rounded-lg text-[12px] font-medium transition-colors ${
priority === p
? PRIORITY_COLORS[p]
: 'border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'
}`}
>
{p}
</button>
))}
</div>
</div>
{/* 9. 产品负责人 */}
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
</label>
<input
type="text"
value={productOwner}
onChange={(e) => setProductOwner(e.target.value)}
placeholder="请输入负责人姓名"
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] outline-none focus:border-[var(--accent)]"
/>
</div>
{/* 10. 关联父需求 */}
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
</label>
<select
value={parentId}
onChange={(e) => setParentId(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>
{requirements
.filter((r) => r.id !== initial?.id)
.map((r) => (
<option key={r.id} value={r.id}>
{r.code} - {r.title}
</option>
))}
</select>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 mt-6 pt-4 border-t border-[var(--line)]">
<button
type="button"
onClick={onClose}
className="h-9 px-4 rounded-lg border border-[var(--line)] text-[13px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] transition-colors"
>
</button>
<button
type="button"
onClick={handleSubmit}
className="h-9 px-4 rounded-lg bg-[var(--accent)] text-white text-[13px] font-medium hover:opacity-90 transition-opacity"
>
{initial ? '保存' : '创建'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
import { Stage, Role, STAGES, STAGE_INDEX } from '@/lib/stage';
import type { RoleProgress } from '@/lib/derive';
export function CapsuleStages({ currentStage, progress }: {
currentStage?: Stage;
progress?: RoleProgress[];
}) {
const currentIdx = currentStage !== undefined
? (currentStage === 'released' ? STAGES.length : STAGE_INDEX[currentStage])
: -1;
const progressMap = (progress ?? []).reduce<Record<Role, { percent: number; daysSpent: number }>>((acc, p) => {
acc[p.role] = { percent: p.percent, daysSpent: p.daysSpent };
return acc;
}, {} as Record<Role, { percent: number; daysSpent: number }>);
const stageRoleMap: Record<Stage, Role[]> = {
requirement: ['product'],
product_design: ['product'],
ui_design: ['ui'],
dev: ['frontend', 'backend'],
integration: ['frontend', 'backend'],
testing: ['testing'],
released: [],
};
function getStageInfo(stage: Stage) {
const roles = stageRoleMap[stage];
if (roles.length === 0) return { percent: 0, days: 0, hasData: false };
const items = roles.map((r) => progressMap[r]).filter(Boolean);
if (items.length === 0) return { percent: 0, days: 0, hasData: false };
const percent = Math.round(items.reduce((s, i) => s + i.percent, 0) / items.length);
const days = Math.max(...items.map((i) => i.daysSpent));
return { percent, days, hasData: true };
}
return (
<div className="flex rounded-lg border border-[var(--line)] overflow-hidden bg-[var(--bg-card)]">
{STAGES.map((stage, idx) => {
const isCompleted = idx < currentIdx;
const isCurrent = idx === currentIdx;
const info = getStageInfo(stage.key);
return (
<div
key={stage.key}
className={`flex-1 flex flex-col ${idx < STAGES.length - 1 ? 'border-r border-[var(--line-soft)]' : ''}`}
>
<div className="flex items-center justify-between px-2 py-1.5 min-h-[28px]">
<span className={`text-[10px] font-medium leading-tight ${isCurrent ? 'text-[var(--ink)]' : isCompleted ? 'text-[var(--ink-soft)]' : 'text-[var(--ink-muted)]'}`}>
{stage.label}
</span>
<span className={`text-[10px] leading-tight ${isCompleted ? 'text-emerald-600' : isCurrent ? 'text-blue-600 font-medium' : 'text-[var(--ink-muted)]'}`}>
{isCompleted ? (info.hasData ? `${info.days}` : '-') : isCurrent ? (info.hasData ? `${info.percent}%` : '-') : ''}
</span>
</div>
<div className="h-[3px] w-full bg-zinc-50">
{isCompleted && <div className="h-full bg-zinc-700 w-full" />}
{isCurrent && info.hasData && (
<div className="h-full bg-blue-100 w-full">
<div className="h-full bg-blue-500 transition-all" style={{ width: `${info.percent}%` }} />
</div>
)}
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,79 @@
'use client';
interface TrendPoint {
date: string;
score: number;
}
interface HealthTrendProps {
data: TrendPoint[];
}
function getColor(score: number) {
if (score >= 90) return '#10b981';
if (score >= 75) return '#3b82f6';
if (score >= 60) return '#f59e0b';
if (score >= 35) return '#f97316';
return '#ef4444';
}
export function HealthTrend({ data }: HealthTrendProps) {
if (data.length < 2) return null;
const w = 160;
const h = 48;
const pad = { top: 4, right: 4, bottom: 12, left: 4 };
const chartW = w - pad.left - pad.right;
const chartH = h - pad.top - pad.bottom;
const scores = data.map((d) => d.score);
const min = Math.max(0, Math.min(...scores) - 5);
const max = Math.min(100, Math.max(...scores) + 5);
const range = max - min || 1;
const points = data.map((d, i) => ({
x: pad.left + (i / (data.length - 1)) * chartW,
y: pad.top + chartH - ((d.score - min) / range) * chartH,
...d,
}));
const pathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ');
const lastPoint = points[points.length - 1];
const firstPoint = points[0];
const trend = lastPoint.score - firstPoint.score;
return (
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-[var(--ink-muted)]">{data.length}</span>
<span className={`text-[10px] font-medium ${trend > 0 ? 'text-emerald-600' : trend < 0 ? 'text-red-600' : 'text-[var(--ink-muted)]'}`}>
{trend > 0 ? '↑' : trend < 0 ? '↓' : '→'} {trend > 0 ? '+' : ''}{trend}
</span>
</div>
<svg width={w} height={h} className="w-full">
<path d={pathD} fill="none" stroke={getColor(lastPoint.score)} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" opacity="0.8" />
{points.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r="2" fill={getColor(p.score)} />
))}
<text x={firstPoint.x} y={h - 1} textAnchor="start" fontSize="8" fill="var(--ink-muted)">{firstPoint.date.slice(5)}</text>
<text x={lastPoint.x} y={h - 1} textAnchor="end" fontSize="8" fill="var(--ink-muted)">{lastPoint.date.slice(5)}</text>
</svg>
</div>
);
}
export function generateMockTrend(currentScore: number, days: number = 7): TrendPoint[] {
const result: TrendPoint[] = [];
const now = new Date();
for (let i = days - 1; i >= 0; i--) {
const date = new Date(now);
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().slice(0, 10);
const drift = (days - 1 - i) * (currentScore < 60 ? -1.5 : 0.5);
const jitter = Math.round((Math.random() - 0.5) * 6);
const score = Math.max(0, Math.min(100, Math.round(currentScore - drift + jitter)));
result.push({ date: dateStr, score });
}
result[result.length - 1].score = currentScore;
return result;
}

View File

@@ -0,0 +1,23 @@
import { Role, ROLES } from '@/lib/stage';
export function MemberChips({ members }: { members: { role: Role; name: string }[] }) {
if (!members || members.length === 0) return null;
const grouped = ROLES.reduce<Record<Role, string[]>>((acc, r) => {
acc[r.key] = members.filter((m) => m.role === r.key).map((m) => m.name);
return acc;
}, {} as Record<Role, string[]>);
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px]">
{ROLES.map((r) => {
const names = grouped[r.key];
if (!names || names.length === 0) return null;
return (
<span key={r.key} className="inline-flex items-center gap-1 text-[var(--ink-soft)]">
<span className="font-medium text-[var(--ink-muted)]">{r.label}</span>
{names.join('/')}
</span>
);
})}
</div>
);
}

View File

@@ -1,17 +1,25 @@
import { VersionStatus } from './version-status';
import type { Stage, Role } from './stage';
interface VersionMember {
export interface VersionMember {
role: Role;
name: string;
}
interface RoleProgress {
export interface RoleProgress {
role: Role;
percent: number;
daysSpent: number;
}
export type Priority = 'P0' | 'P1' | 'P2' | 'P3' | 'P4';
export interface VersionLinks {
research?: string;
prototype?: string;
ui?: string;
}
interface ProductOverviewLike {
id: string;
name: string;
@@ -20,6 +28,7 @@ interface ProductOverviewLike {
id: string; name: string; status?: string; releaseDate: string | null; createdAt: string;
currentStage?: Stage; startDate?: string | null; expectedReleaseDate?: string | null;
members?: VersionMember[]; progress?: RoleProgress[];
priority?: Priority; links?: VersionLinks;
}[];
}
@@ -41,12 +50,15 @@ export interface VersionWithContext {
createdAt: string;
productId: string;
productName: string;
projectId: string;
projectName: string;
currentStage?: Stage;
startDate?: string | null;
expectedReleaseDate?: string | null;
members?: VersionMember[];
progress?: RoleProgress[];
priority?: Priority;
links?: VersionLinks;
}
export function flattenProjects(overview: ProductOverviewLike[]): ProjectWithContext[] {
@@ -60,6 +72,7 @@ export function flattenProjects(overview: ProductOverviewLike[]): ProjectWithCon
status: (v.status || 'released') as VersionStatus,
productId: product.id,
productName: product.name,
projectId: project.id,
projectName: project.name,
}));
result.push({
@@ -85,6 +98,7 @@ export function flattenVersions(overview: ProductOverviewLike[]): VersionWithCon
status: (version.status || 'released') as VersionStatus,
productId: product.id,
productName: product.name,
projectId: project?.id || '',
projectName: project?.name || '未关联',
});
}
@@ -96,3 +110,8 @@ export function getProjectDetail(overview: ProductOverviewLike[], projectId: str
const projects = flattenProjects(overview);
return projects.find((p) => p.id === projectId) || null;
}
export function getVersionDetail(overview: ProductOverviewLike[], versionId: string): VersionWithContext | null {
const versions = flattenVersions(overview);
return versions.find((v) => v.id === versionId) || null;
}

196
apps/web/lib/health.ts Normal file
View File

@@ -0,0 +1,196 @@
import type { RoleProgress } from './derive';
import type { Stage, Role } from './stage';
import { calcDelayStatus, calcOverallProgress } from './risk';
export interface RiskTag {
key: string;
label: string;
severity: 'critical' | 'high' | 'medium';
reason?: string;
suggestion?: string;
}
export type HealthLevel = 'healthy' | 'good' | 'attention' | 'risk' | 'critical';
const STAGE_ROLE_MAP: Record<string, Role[]> = {
requirement: ['product'],
product_design: ['product'],
ui_design: ['ui'],
dev: ['frontend', 'backend'],
integration: ['frontend', 'backend'],
testing: ['testing'],
released: [],
};
export function calcHealthScore(
status: string,
startDate?: string | null,
expectedReleaseDate?: string | null,
progress?: RoleProgress[],
): number {
if (status === 'released' || status === 'closed') return 100;
if (status === 'planned') return 100;
let score = 100;
// 暂停扣分
if (status === 'paused') score -= 20;
// 进度落后扣分
if (startDate && expectedReleaseDate) {
const now = new Date();
now.setHours(0, 0, 0, 0);
const start = new Date(startDate);
start.setHours(0, 0, 0, 0);
const end = new Date(expectedReleaseDate);
end.setHours(0, 0, 0, 0);
const totalDuration = end.getTime() - start.getTime();
if (totalDuration > 0) {
const elapsed = now.getTime() - start.getTime();
const timeProgress = Math.min(100, Math.max(0, (elapsed / totalDuration) * 100));
const actualProgress = calcOverallProgress(progress);
const gap = Math.max(0, timeProgress - actualProgress);
score -= gap * 0.8;
}
}
// 延期扣分
if (expectedReleaseDate && status !== 'released' && status !== 'closed') {
const now = new Date();
now.setHours(0, 0, 0, 0);
const deadline = new Date(expectedReleaseDate);
deadline.setHours(0, 0, 0, 0);
const diffDays = (deadline.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
if (diffDays < 0) {
score -= Math.min(40, Math.abs(diffDays) * 5);
} else if (diffDays <= 3) {
score -= 15;
}
}
return Math.max(0, Math.min(100, Math.round(score)));
}
export function getHealthLevel(score: number): HealthLevel {
if (score >= 90) return 'healthy';
if (score >= 75) return 'good';
if (score >= 60) return 'attention';
if (score >= 35) return 'risk';
return 'critical';
}
export const HEALTH_LEVEL_LABEL: Record<HealthLevel, string> = {
healthy: '健康',
good: '良好',
attention: '关注',
risk: '风险',
critical: '严重',
};
export const HEALTH_LEVEL_COLOR: Record<HealthLevel, string> = {
healthy: 'text-emerald-600',
good: 'text-blue-600',
attention: 'text-amber-600',
risk: 'text-orange-600',
critical: 'text-red-600',
};
export const HEALTH_LEVEL_DOT: Record<HealthLevel, string> = {
healthy: 'bg-emerald-500',
good: 'bg-blue-500',
attention: 'bg-amber-500',
risk: 'bg-orange-500',
critical: 'bg-red-500',
};
const TAG_SEVERITY_STYLE: Record<string, string> = {
critical: 'bg-red-50 text-red-600 border-red-200',
high: 'bg-orange-50 text-orange-600 border-orange-200',
medium: 'bg-amber-50 text-amber-600 border-amber-200',
};
export function getTagStyle(severity: string): string {
return TAG_SEVERITY_STYLE[severity] || TAG_SEVERITY_STYLE.medium;
}
export function calcRiskTags(
status: string,
startDate?: string | null,
expectedReleaseDate?: string | null,
progress?: RoleProgress[],
currentStage?: Stage,
members?: { role: Role; name: string }[],
): RiskTag[] {
if (status === 'released' || status === 'closed' || status === 'planned') return [];
const tags: RiskTag[] = [];
// 延期类
if (expectedReleaseDate) {
const now = new Date();
now.setHours(0, 0, 0, 0);
const deadline = new Date(expectedReleaseDate);
deadline.setHours(0, 0, 0, 0);
const diffDays = (now.getTime() - deadline.getTime()) / (1000 * 60 * 60 * 24);
if (diffDays > 7) {
tags.push({ key: 'severe_delay', label: '严重延期', severity: 'critical', reason: `已超过截止日期 ${Math.round(diffDays)}`, suggestion: '立即与团队确认是否调整截止日期,评估是否需要缩减版本范围' });
} else if (diffDays > 0) {
tags.push({ key: 'delay_risk', label: '延期风险', severity: 'high', reason: `已超过截止日期 ${Math.round(diffDays)}`, suggestion: '排查阻塞原因,与相关负责人沟通加速或调整排期' });
} else if (diffDays > -3) {
tags.push({ key: 'delay_risk', label: '延期风险', severity: 'medium', reason: `距离截止日期仅剩 ${Math.round(-diffDays)}`, suggestion: '关注剩余工作量是否可按时完成,提前预警相关方' });
}
}
// 进度落后
if (startDate && expectedReleaseDate) {
const now = new Date();
now.setHours(0, 0, 0, 0);
const start = new Date(startDate);
start.setHours(0, 0, 0, 0);
const end = new Date(expectedReleaseDate);
end.setHours(0, 0, 0, 0);
const totalDuration = end.getTime() - start.getTime();
if (totalDuration > 0) {
const elapsed = now.getTime() - start.getTime();
const timeProgress = Math.min(100, Math.max(0, Math.round((elapsed / totalDuration) * 100)));
const actualProgress = calcOverallProgress(progress);
const gap = timeProgress - actualProgress;
if (gap >= 30) {
tags.push({ key: 'progress_behind', label: '进度落后', severity: 'high', reason: `整体进度 ${actualProgress}%,时间已过 ${timeProgress}%,落后 ${Math.round(gap)}%`, suggestion: '排查阻塞点,考虑增加资源投入或调整版本优先级' });
}
}
}
// 阶段超时
if (currentStage && progress) {
const roles = STAGE_ROLE_MAP[currentStage] || [];
const stageItems = roles.map((r) => progress.find((p) => p.role === r)).filter(Boolean);
const maxDays = stageItems.length > 0 ? Math.max(...stageItems.map((i) => i!.daysSpent)) : 0;
if (maxDays > 14) {
tags.push({ key: 'stage_timeout', label: '阶段超时', severity: 'medium', reason: `当前阶段已耗时 ${maxDays}超过经验值14天`, suggestion: '确认是否存在技术难点或外部依赖阻塞,及时同步风险' });
}
}
// 资源类
if (!members || members.length < 2) {
tags.push({ key: 'missing_owner', label: '负责人缺失', severity: 'high', reason: '版本参与人员不足 2 人', suggestion: '尽快分配各角色负责人,确保版本推进有明确责任人' });
} else if (currentStage) {
const neededRoles = STAGE_ROLE_MAP[currentStage] || [];
const memberRoles = new Set(members.map((m) => m.role));
const missing = neededRoles.filter((r) => !memberRoles.has(r));
if (missing.length > 0) {
tags.push({ key: 'key_role_missing', label: '关键角色缺失', severity: 'medium', reason: `当前阶段缺少对应角色人员`, suggestion: '补充缺失角色的负责人,避免阶段推进受阻' });
}
}
// 按严重程度排序
const severityOrder: Record<string, number> = { critical: 0, high: 1, medium: 2 };
tags.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
return tags;
}

65
apps/web/lib/members.ts Normal file
View File

@@ -0,0 +1,65 @@
export interface Department {
id: string;
name: string;
parentId?: string;
order: number;
createdAt: string;
}
export interface Member {
id: string;
name: string;
departmentId: string;
roleId: string;
phone: string;
email: string;
password: string;
createdAt: string;
}
export interface RoleItem {
id: string;
name: string;
description?: string;
createdAt: string;
isSystem?: boolean;
}
export interface PasswordRule {
prefix: string;
length: number;
includeUppercase: boolean;
includeLowercase: boolean;
includeNumbers: boolean;
includeSpecial: boolean;
}
export const DEFAULT_PASSWORD_RULE: PasswordRule = {
prefix: 'Ftb',
length: 8,
includeUppercase: true,
includeLowercase: true,
includeNumbers: true,
includeSpecial: false,
};
export function generatePassword(rule: PasswordRule): string {
let chars = '';
if (rule.includeUppercase) chars += 'ABCDEFGHJKLMNPQRSTUVWXYZ';
if (rule.includeLowercase) chars += 'abcdefghjkmnpqrstuvwxyz';
if (rule.includeNumbers) chars += '23456789';
if (rule.includeSpecial) chars += '!@#$%&*';
if (!chars) chars = 'abcdefghjkmnpqrstuvwxyz23456789';
const randomLength = Math.max(rule.length - rule.prefix.length, 4);
let result = rule.prefix;
for (let i = 0; i < randomLength; i++) {
result += chars[Math.floor(Math.random() * chars.length)];
}
return result;
}
export function maskPhone(phone: string): string {
if (!phone || phone.length < 7) return phone;
return phone.slice(0, 3) + '****' + phone.slice(7);
}

46
apps/web/lib/overtime.ts Normal file
View File

@@ -0,0 +1,46 @@
export interface OvertimeRecord {
id: string;
projectId: string;
versionId?: string;
person: string;
startTime: string;
endTime: string;
duration: number; // 小时,自动计算
reasonId: string;
remark?: string;
createdAt: string;
}
export type OvertimeReason =
| 'requirement_change'
| 'requirement_add'
| 'version_sprint'
| 'bug_fix'
| 'online_issue'
| 'test_fix'
| 'tech_difficulty'
| 'integration_issue'
| 'upstream_delay'
| 'overload'
| 'rework';
export const OVERTIME_REASON_LABEL: Record<string, string> = {
'reason-1': '需求变更',
'reason-2': '需求新增',
'reason-3': '版本冲刺',
'reason-4': 'Bug修复',
'reason-5': '线上问题修复',
'reason-6': '测试问题整改',
'reason-7': '技术难点攻关',
'reason-8': '联调问题',
'reason-9': '上游交付延误',
'reason-10': '工作量超负荷',
'reason-11': '返工修改',
};
export function calcDuration(start: string, end: string): number {
const s = new Date(start).getTime();
const e = new Date(end).getTime();
if (isNaN(s) || isNaN(e) || e <= s) return 0;
return Math.round(((e - s) / (1000 * 60 * 60)) * 10) / 10;
}

103
apps/web/lib/requirement.ts Normal file
View File

@@ -0,0 +1,103 @@
import type { Priority } from './derive';
export type RequirementStatus = 'pending_review' | 'adopted' | 'rejected' | 'planned' | 'developing' | 'testing' | 'released' | 'closed';
export type Effort = 'S' | 'M' | 'L' | 'XL';
export type SourceType = 'customer' | 'internal' | 'operation' | 'aftersale' | 'market' | 'competitor' | 'management';
export interface DictItem {
id: string;
name: string;
createdAt: string;
}
export interface SourceTarget {
id: string;
name: string;
sourceType: SourceType;
createdAt: string;
}
export interface Requirement {
id: string;
code: string;
title: string;
description: string;
productId: string;
projectId?: string;
versionId?: string;
sourceType: SourceType;
sourceTarget: string;
platforms: string[];
typeId: string;
status: RequirementStatus;
priority: Priority;
effort: Effort;
currentStage?: string;
productOwner?: string;
creator: string;
createdAt: string;
parentId?: string;
}
export const SOURCE_TYPE_LABEL: Record<SourceType, string> = {
customer: '客户',
internal: '内部',
operation: '运营',
aftersale: '售后',
market: '市场',
competitor: '竞品',
management: '管理层',
};
export const SOURCE_TARGET_LABEL: Record<SourceType, string> = {
customer: '来源客户',
internal: '提出部门',
operation: '提出部门',
aftersale: '提出部门',
market: '来源渠道',
competitor: '竞品名称',
management: '提出人',
};
export const REQ_STATUS_LABEL: Record<RequirementStatus, string> = {
pending_review: '待评审',
adopted: '已采纳',
rejected: '已拒绝',
planned: '已规划',
developing: '开发中',
testing: '测试中',
released: '已上线',
closed: '已关闭',
};
export const REQ_STATUS_COLOR: Record<RequirementStatus, string> = {
pending_review: 'bg-zinc-100 text-zinc-700 border border-zinc-200',
adopted: 'bg-emerald-50 text-emerald-700 border border-emerald-200',
rejected: 'bg-red-50 text-red-600 border border-red-200',
planned: 'bg-blue-50 text-blue-700 border border-blue-200',
developing: 'bg-indigo-50 text-indigo-700 border border-indigo-200',
testing: 'bg-purple-50 text-purple-700 border border-purple-200',
released: 'bg-emerald-50 text-emerald-700 border border-emerald-200',
closed: 'bg-zinc-50 text-zinc-500 border border-zinc-200',
};
export const EFFORT_LABEL: Record<Effort, string> = {
S: 'S (1-2天)',
M: 'M (3-5天)',
L: 'L (5-10天)',
XL: 'XL (10+天)',
};
export const EFFORT_SHORT: Record<Effort, string> = {
S: 'S',
M: 'M',
L: 'L',
XL: 'XL',
};
export const EFFORT_COLOR: Record<Effort, string> = {
S: 'bg-emerald-50 text-emerald-700',
M: 'bg-blue-50 text-blue-700',
L: 'bg-orange-50 text-orange-700',
XL: 'bg-red-50 text-red-700',
};

95
apps/web/lib/risk.ts Normal file
View File

@@ -0,0 +1,95 @@
import type { RoleProgress } from './derive';
export type RiskLevel = 'low' | 'medium' | 'high';
export type DelayStatus = 'normal' | 'warning' | 'delayed';
const DELAY_WARNING_DAYS = 3;
export function calcOverallProgress(progress?: RoleProgress[]): number {
if (!progress || progress.length === 0) return 0;
const sum = progress.reduce((s, p) => s + p.percent, 0);
return Math.round(sum / progress.length);
}
export function calcDelayStatus(
status: string,
expectedReleaseDate?: string | null,
): DelayStatus {
if (status === 'released' || status === 'closed') return 'normal';
if (!expectedReleaseDate) return 'normal';
const now = new Date();
now.setHours(0, 0, 0, 0);
const deadline = new Date(expectedReleaseDate);
deadline.setHours(0, 0, 0, 0);
const diffDays = (deadline.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
if (diffDays < 0) return 'delayed';
if (diffDays <= DELAY_WARNING_DAYS) return 'warning';
return 'normal';
}
export function calcRiskLevel(
status: string,
startDate?: string | null,
expectedReleaseDate?: string | null,
progress?: RoleProgress[],
): RiskLevel {
if (status === 'released' || status === 'closed') return 'low';
if (status === 'paused') return 'medium';
const delayStatus = calcDelayStatus(status, expectedReleaseDate);
if (delayStatus === 'delayed') return 'high';
if (!startDate || !expectedReleaseDate) return 'low';
const now = new Date();
now.setHours(0, 0, 0, 0);
const start = new Date(startDate);
start.setHours(0, 0, 0, 0);
const end = new Date(expectedReleaseDate);
end.setHours(0, 0, 0, 0);
const totalDuration = end.getTime() - start.getTime();
if (totalDuration <= 0) return 'low';
const elapsed = now.getTime() - start.getTime();
const timeProgress = Math.min(100, Math.max(0, (elapsed / totalDuration) * 100));
const actualProgress = calcOverallProgress(progress);
const gap = timeProgress - actualProgress;
if (gap >= 40) return 'high';
if (gap >= 20 || delayStatus === 'warning') return 'medium';
return 'low';
}
export const RISK_LABEL: Record<RiskLevel, string> = {
low: '低',
medium: '中',
high: '高',
};
export const RISK_COLOR: Record<RiskLevel, string> = {
low: 'text-emerald-600',
medium: 'text-amber-600',
high: 'text-red-600',
};
export const RISK_DOT: Record<RiskLevel, string> = {
low: 'bg-emerald-500',
medium: 'bg-amber-500',
high: 'bg-red-500',
};
export const DELAY_LABEL: Record<DelayStatus, string> = {
normal: '正常',
warning: '即将延期',
delayed: '已延期',
};
export const DELAY_COLOR: Record<DelayStatus, string> = {
normal: 'bg-emerald-50 text-emerald-700',
warning: 'bg-amber-50 text-amber-700',
delayed: 'bg-red-50 text-red-700',
};

View File

@@ -1,19 +1,25 @@
export type VersionStatus = 'developing' | 'planned' | 'released';
export type VersionStatus = 'developing' | 'planned' | 'released' | 'paused' | 'closed';
export const VERSION_STATUS_LABEL: Record<VersionStatus, string> = {
developing: '开发中',
planned: '规划中',
released: '已发布',
paused: '已暂停',
closed: '已关闭',
};
export const VERSION_STATUS_DOT: Record<VersionStatus, string> = {
developing: 'bg-blue-500',
planned: 'bg-orange-500',
released: 'bg-zinc-300',
paused: 'bg-purple-400',
closed: 'bg-zinc-400',
};
export const VERSION_STATUS_BG: Record<VersionStatus, string> = {
developing: 'bg-blue-500/10 text-blue-600',
planned: 'bg-orange-500/10 text-orange-600',
released: 'bg-zinc-100 text-zinc-600',
paused: 'bg-purple-100 text-purple-600',
closed: 'bg-zinc-100 text-zinc-500',
};

View File

@@ -0,0 +1,135 @@
'use client';
import { create } from 'zustand';
import type { Department, Member, RoleItem, PasswordRule } from '@/lib/members';
import { DEFAULT_PASSWORD_RULE, generatePassword } from '@/lib/members';
const STORAGE_KEY = 'ftb_members_v1';
const PRESET_DEPARTMENTS: Department[] = [
{ id: 'dept-1', name: '产品部', order: 1, createdAt: '2024-01-01' },
{ id: 'dept-2', name: '技术部', order: 2, createdAt: '2024-01-01' },
{ id: 'dept-2-1', name: '前端组', parentId: 'dept-2', order: 1, createdAt: '2024-01-01' },
{ id: 'dept-2-2', name: '后端组', parentId: 'dept-2', order: 2, createdAt: '2024-01-01' },
{ id: 'dept-2-3', name: '测试组', parentId: 'dept-2', order: 3, createdAt: '2024-01-01' },
{ id: 'dept-3', name: '设计部', order: 3, createdAt: '2024-01-01' },
{ id: 'dept-4', name: '运营部', order: 4, createdAt: '2024-01-01' },
];
const PRESET_ROLES: RoleItem[] = [
{ id: 'role-admin', name: '超级管理员', description: '拥有系统全部权限', createdAt: '2024-01-01', isSystem: true },
{ id: 'role-pm', name: '产品经理', description: '管理产品和需求', createdAt: '2024-01-01' },
{ id: 'role-dev', name: '开发工程师', description: '负责开发任务', createdAt: '2024-01-01' },
{ id: 'role-test', name: '测试工程师', description: '负责测试任务', createdAt: '2024-01-01' },
{ id: 'role-design', name: '设计师', description: '负责UI/UX设计', createdAt: '2024-01-01' },
];
const MOCK_MEMBERS: Member[] = [
{ id: 'm-1', name: '张三', departmentId: 'dept-1', roleId: 'role-pm', phone: '13800138001', email: 'zhangsan@company.com', password: 'Ftb@2024', createdAt: '2024-01-10' },
{ id: 'm-2', name: '李四', departmentId: 'dept-2-1', roleId: 'role-dev', phone: '13900139002', email: 'lisi@company.com', password: 'Ftb@2024', createdAt: '2024-01-12' },
{ id: 'm-3', name: '王五', departmentId: 'dept-2-2', roleId: 'role-dev', phone: '13700137003', email: 'wangwu@company.com', password: 'Ftb@2024', createdAt: '2024-01-15' },
{ id: 'm-4', name: '赵六', departmentId: 'dept-2-3', roleId: 'role-test', phone: '13600136004', email: 'zhaoliu@company.com', password: 'Ftb@2024', createdAt: '2024-02-01' },
{ id: 'm-5', name: '孙七', departmentId: 'dept-3', roleId: 'role-design', phone: '13500135005', email: 'sunqi@company.com', password: 'Ftb@2024', createdAt: '2024-02-10' },
{ id: 'm-6', name: '周八', departmentId: 'dept-4', roleId: 'role-pm', phone: '13400134006', email: 'zhouba@company.com', password: 'Ftb@2024', createdAt: '2024-02-15' },
{ id: 'm-7', name: '吴九', departmentId: 'dept-2-1', roleId: 'role-dev', phone: '13300133007', email: 'wujiu@company.com', password: 'Ftb@2024', createdAt: '2024-03-01' },
{ id: 'm-8', name: '陈十', departmentId: 'dept-1', roleId: 'role-admin', phone: '13200132008', email: 'chenshi@company.com', password: 'Ftb@2024', createdAt: '2024-01-01' },
];
interface MemberState {
departments: Department[];
members: Member[];
roles: RoleItem[];
passwordRule: PasswordRule;
loading: boolean;
fetchMembers: () => void;
updatePasswordRule: (rule: PasswordRule) => void;
createDepartment: (data: Omit<Department, 'id' | 'createdAt'>) => void;
updateDepartment: (id: string, data: Partial<Department>) => void;
deleteDepartment: (id: string) => void;
createMember: (data: Omit<Member, 'id' | 'createdAt'>) => void;
updateMember: (id: string, data: Partial<Member>) => void;
deleteMember: (id: string) => void;
createRole: (data: Omit<RoleItem, 'id' | 'createdAt'>) => void;
updateRole: (id: string, data: Partial<RoleItem>) => void;
deleteRole: (id: string) => void;
}
function saveLocal(state: { departments: Department[]; members: Member[]; roles: RoleItem[]; passwordRule: PasswordRule }) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch {}
}
function loadLocal(): { departments: Department[]; members: Member[]; roles: RoleItem[]; passwordRule: PasswordRule } | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
} catch {}
return null;
}
export const useMemberStore = create<MemberState>((set, get) => ({
departments: PRESET_DEPARTMENTS,
members: MOCK_MEMBERS,
roles: PRESET_ROLES,
passwordRule: DEFAULT_PASSWORD_RULE,
loading: false,
fetchMembers: () => {
const cached = loadLocal();
if (cached) set({ departments: cached.departments, members: cached.members, roles: cached.roles, passwordRule: cached.passwordRule || DEFAULT_PASSWORD_RULE });
},
updatePasswordRule: (rule) => {
set({ passwordRule: rule });
saveLocal({ departments: get().departments, members: get().members, roles: get().roles, passwordRule: rule });
},
createDepartment: (data) => {
const dept: Department = { ...data, id: `dept-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10) };
const departments = [...get().departments, dept];
set({ departments });
saveLocal({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule });
},
updateDepartment: (id, data) => {
const departments = get().departments.map((d) => d.id === id ? { ...d, ...data } : d);
set({ departments });
saveLocal({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule });
},
deleteDepartment: (id) => {
const departments = get().departments.filter((d) => d.id !== id && d.parentId !== id);
set({ departments });
saveLocal({ departments, members: get().members, roles: get().roles, passwordRule: get().passwordRule });
},
createMember: (data) => {
const member: Member = { ...data, id: `m-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10) };
const members = [...get().members, member];
set({ members });
saveLocal({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule });
},
updateMember: (id, data) => {
const members = get().members.map((m) => m.id === id ? { ...m, ...data } : m);
set({ members });
saveLocal({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule });
},
deleteMember: (id) => {
const members = get().members.filter((m) => m.id !== id);
set({ members });
saveLocal({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule });
},
createRole: (data) => {
const role: RoleItem = { ...data, id: `role-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10) };
const roles = [...get().roles, role];
set({ roles });
saveLocal({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule });
},
updateRole: (id, data) => {
const roles = get().roles.map((r) => r.id === id ? { ...r, ...data } : r);
set({ roles });
saveLocal({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule });
},
deleteRole: (id) => {
const roles = get().roles.filter((r) => r.id !== id);
set({ roles });
saveLocal({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule });
},
}));

View File

@@ -0,0 +1,116 @@
'use client';
import { create } from 'zustand';
import type { OvertimeRecord } from '@/lib/overtime';
import type { DictItem } from '@/lib/requirement';
import { calcDuration } from '@/lib/overtime';
const STORAGE_KEY = 'ftb_overtime_v1';
const PRESET_REASONS: DictItem[] = [
{ id: 'reason-1', name: '需求变更', createdAt: '2024-01-01' },
{ id: 'reason-2', name: '需求新增', createdAt: '2024-01-01' },
{ id: 'reason-3', name: '版本冲刺', createdAt: '2024-01-01' },
{ id: 'reason-4', name: 'Bug修复', createdAt: '2024-01-01' },
{ id: 'reason-5', name: '线上问题修复', createdAt: '2024-01-01' },
{ id: 'reason-6', name: '测试问题整改', createdAt: '2024-01-01' },
{ id: 'reason-7', name: '技术难点攻关', createdAt: '2024-01-01' },
{ id: 'reason-8', name: '联调问题', createdAt: '2024-01-01' },
{ id: 'reason-9', name: '上游交付延误', createdAt: '2024-01-01' },
{ id: 'reason-10', name: '工作量超负荷', createdAt: '2024-01-01' },
{ id: 'reason-11', name: '返工修改', createdAt: '2024-01-01' },
];
const MOCK_RECORDS: OvertimeRecord[] = [
{ id: 'ot-1', projectId: 'p1', versionId: 'v2', person: '张三', startTime: '2024-12-10T19:00', endTime: '2024-12-10T22:30', duration: 3.5, reasonId: 'reason-3', createdAt: '2024-12-10' },
{ id: 'ot-2', projectId: 'p2', versionId: 'v5', person: '李四', startTime: '2024-12-11T18:30', endTime: '2024-12-11T21:00', duration: 2.5, reasonId: 'reason-1', createdAt: '2024-12-11' },
{ id: 'ot-3', projectId: 'p1', versionId: 'v2', person: '王五', startTime: '2024-12-12T19:00', endTime: '2024-12-12T23:00', duration: 4, reasonId: 'reason-4', createdAt: '2024-12-12' },
{ id: 'ot-4', projectId: 'p3', person: '张三', startTime: '2024-12-13T18:00', endTime: '2024-12-13T20:30', duration: 2.5, reasonId: 'reason-7', createdAt: '2024-12-13' },
{ id: 'ot-5', projectId: 'p2', versionId: 'v5', person: '赵六', startTime: '2024-12-14T19:30', endTime: '2024-12-15T01:00', duration: 5.5, reasonId: 'reason-5', remark: '线上紧急事故', createdAt: '2024-12-14' },
];
interface OvertimeState {
records: OvertimeRecord[];
reasons: DictItem[];
loading: boolean;
fetchRecords: () => void;
createRecord: (data: Omit<OvertimeRecord, 'id' | 'duration' | 'createdAt'>) => void;
updateRecord: (id: string, data: Partial<OvertimeRecord>) => void;
deleteRecord: (id: string) => void;
addReason: (name: string) => void;
updateReason: (id: string, name: string) => void;
deleteReason: (id: string) => void;
}
function saveLocal(data: { records: OvertimeRecord[]; reasons: DictItem[] }) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch {}
}
function loadLocal(): { records: OvertimeRecord[]; reasons: DictItem[] } | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
} catch {}
return null;
}
export const useOvertimeStore = create<OvertimeState>((set, get) => ({
records: MOCK_RECORDS,
reasons: PRESET_REASONS,
loading: false,
fetchRecords: () => {
const cached = loadLocal();
if (cached) set({ records: cached.records, reasons: cached.reasons });
},
createRecord: (data) => {
const duration = calcDuration(data.startTime, data.endTime);
const record: OvertimeRecord = {
...data,
id: `ot-${Date.now()}`,
duration,
createdAt: new Date().toISOString().slice(0, 10),
};
const records = [...get().records, record];
set({ records });
saveLocal({ records, reasons: get().reasons });
},
updateRecord: (id, data) => {
const records = get().records.map((r) => {
if (r.id !== id) return r;
const updated = { ...r, ...data };
if (data.startTime || data.endTime) {
updated.duration = calcDuration(updated.startTime, updated.endTime);
}
return updated;
});
set({ records });
saveLocal({ records, reasons: get().reasons });
},
deleteRecord: (id) => {
const records = get().records.filter((r) => r.id !== id);
set({ records });
saveLocal({ records, reasons: get().reasons });
},
addReason: (name) => {
const item: DictItem = { id: `reason-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) };
const reasons = [...get().reasons, item];
set({ reasons });
saveLocal({ records: get().records, reasons });
},
updateReason: (id, name) => {
const reasons = get().reasons.map((r) => r.id === id ? { ...r, name } : r);
set({ reasons });
saveLocal({ records: get().records, reasons });
},
deleteReason: (id) => {
const reasons = get().reasons.filter((r) => r.id !== id);
set({ reasons });
saveLocal({ records: get().records, reasons });
},
}));

View File

@@ -4,6 +4,7 @@ import { create } from 'zustand';
import { Product } from '@ftb/shared';
import { api } from '@/lib/api';
import type { Stage, Role } from '@/lib/stage';
import type { Priority, VersionLinks } from '@/lib/derive';
interface VersionMember {
role: Role;
@@ -27,6 +28,8 @@ interface VersionItem {
expectedReleaseDate?: string | null;
members?: VersionMember[];
progress?: RoleProgress[];
priority?: Priority;
links?: VersionLinks;
}
const MOCK_OVERVIEW: ProductOverview[] = [
@@ -43,14 +46,14 @@ const MOCK_OVERVIEW: ProductOverview[] = [
],
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 }] },
{ id: 'v2', name: '考勤V1.2', status: 'developing', releaseDate: null, createdAt: '2024-04-15', currentStage: 'dev', startDate: '2024-04-15', expectedReleaseDate: '2024-06-10', 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: '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', 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: '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', 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: '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 },
@@ -68,9 +71,9 @@ const MOCK_OVERVIEW: ProductOverview[] = [
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', 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: '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', 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: '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 },

View File

@@ -1,66 +1,353 @@
'use client';
import { create } from 'zustand';
import { Requirement, RequirementStatus } from '@ftb/shared';
import { api } from '@/lib/api';
interface RequirementWithCreator extends Requirement {
creator?: { id: string; name: string };
}
import type { Requirement, RequirementStatus, Effort, DictItem, SourceType, SourceTarget } from '@/lib/requirement';
import type { Priority } from '@/lib/derive';
interface RequirementState {
requirements: RequirementWithCreator[];
requirements: Requirement[];
sourceTargets: SourceTarget[];
types: DictItem[];
platforms: DictItem[];
loading: boolean;
error: string | null;
statusFilter: RequirementStatus | null;
fetchRequirements: (productId: string, status?: RequirementStatus) => Promise<void>;
createRequirement: (productId: string, data: { title: string; description?: string; priority?: number; creatorId: string }) => Promise<void>;
updateRequirement: (productId: string, id: string, data: { title?: string; description?: string; priority?: number }) => Promise<void>;
updateStatus: (productId: string, id: string, status: RequirementStatus) => Promise<void>;
deleteRequirement: (productId: string, id: string) => Promise<void>;
setStatusFilter: (status: RequirementStatus | null) => void;
fetchRequirements: () => void;
createRequirement: (data: Omit<Requirement, 'id' | 'code' | 'createdAt'>) => void;
updateRequirement: (id: string, data: Partial<Requirement>) => void;
deleteRequirement: (id: string) => void;
addSourceTarget: (name: string, sourceType: SourceType) => void;
updateSourceTarget: (id: string, name: string) => void;
deleteSourceTarget: (id: string) => void;
addType: (name: string) => void;
updateType: (id: string, name: string) => void;
deleteType: (id: string) => void;
addPlatform: (name: string) => void;
updatePlatform: (id: string, name: string) => void;
deletePlatform: (id: string) => void;
}
export const useRequirementStore = create<RequirementState>((set, get) => ({
requirements: [],
loading: false,
error: null,
statusFilter: null,
// --- Preset dictionaries ---
fetchRequirements: async (productId, status) => {
set({ loading: true, error: null });
try {
const query = status ? `?status=${status}` : '';
const requirements = await api.get<RequirementWithCreator[]>(
`/products/${productId}/requirements${query}`,
);
set({ requirements, loading: false });
} catch (e: any) {
set({ error: e.message, loading: false });
}
},
const PRESET_SOURCE_TARGETS: SourceTarget[] = [
// customer type
{ id: 'st-1', name: '海底捞', sourceType: 'customer', createdAt: '2024-01-01' },
{ id: 'st-2', name: '西贝', sourceType: 'customer', createdAt: '2024-01-01' },
{ id: 'st-3', name: '喜茶', sourceType: 'customer', createdAt: '2024-01-01' },
{ id: 'st-4', name: '奈雪的茶', sourceType: 'customer', createdAt: '2024-01-01' },
{ id: 'st-5', name: '麦当劳', sourceType: 'customer', createdAt: '2024-01-01' },
// operation type
{ id: 'st-6', name: '运营部', sourceType: 'operation', createdAt: '2024-01-01' },
{ id: 'st-7', name: '销售部', sourceType: 'operation', createdAt: '2024-01-01' },
{ id: 'st-8', name: '客服部', sourceType: 'operation', createdAt: '2024-01-01' },
// internal
{ id: 'st-9', name: '产品部', sourceType: 'internal', createdAt: '2024-01-01' },
{ id: 'st-10', name: '技术部', sourceType: 'internal', createdAt: '2024-01-01' },
// management
{ id: 'st-11', name: '张总', sourceType: 'management', createdAt: '2024-01-01' },
{ id: 'st-12', name: '李总', sourceType: 'management', createdAt: '2024-01-01' },
// aftersale
{ id: 'st-13', name: '实施部', sourceType: 'aftersale', createdAt: '2024-01-01' },
{ id: 'st-14', name: '客服部', sourceType: 'aftersale', createdAt: '2024-01-01' },
// market
{ id: 'st-15', name: '市场部', sourceType: 'market', createdAt: '2024-01-01' },
// competitor
{ id: 'st-16', name: '美团收银', sourceType: 'competitor', createdAt: '2024-01-01' },
{ id: 'st-17', name: '客如云', sourceType: 'competitor', createdAt: '2024-01-01' },
];
createRequirement: async (productId, data) => {
await api.post(`/products/${productId}/requirements`, data);
await get().fetchRequirements(productId, get().statusFilter || undefined);
},
const PRESET_TYPES: DictItem[] = [
{ id: 'type-1', name: '新功能', createdAt: '2024-01-01' },
{ id: 'type-2', name: 'BUG修复', createdAt: '2024-01-01' },
{ id: 'type-3', name: '数据需求/埋点需求', createdAt: '2024-01-01' },
{ id: 'type-4', name: '优化需求', createdAt: '2024-01-01' },
{ id: 'type-5', name: '模块重构', createdAt: '2024-01-01' },
{ id: 'type-6', name: '缺陷修复', createdAt: '2024-01-01' },
{ id: 'type-7', name: '功能优化', createdAt: '2024-01-01' },
{ id: 'type-8', name: '插单/紧急需求', createdAt: '2024-01-01' },
];
updateRequirement: async (productId, id, data) => {
await api.patch(`/products/${productId}/requirements/${id}`, data);
await get().fetchRequirements(productId, get().statusFilter || undefined);
},
const PRESET_PLATFORMS: DictItem[] = [
{ id: 'platform-1', name: 'Web', createdAt: '2024-01-01' },
{ id: 'platform-2', name: 'App(iOS)', createdAt: '2024-01-01' },
{ id: 'platform-3', name: 'App(Android)', createdAt: '2024-01-01' },
{ id: 'platform-4', name: '小程序', createdAt: '2024-01-01' },
{ id: 'platform-5', name: 'H5', createdAt: '2024-01-01' },
];
updateStatus: async (productId, id, status) => {
await api.patch(`/products/${productId}/requirements/${id}/status`, { status });
await get().fetchRequirements(productId, get().statusFilter || undefined);
},
// --- Mock requirements ---
deleteRequirement: async (productId, id) => {
await api.delete(`/products/${productId}/requirements/${id}`);
set({ requirements: get().requirements.filter((r) => r.id !== id) });
const MOCK_REQUIREMENTS: Requirement[] = [
{
id: 'req-1',
code: 'REQ-001',
title: '考勤打卡支持人脸识别',
description: '员工打卡增加人脸识别验证方式,防止代打卡',
productId: 'mock-1',
projectId: 'p1',
versionId: 'v2',
sourceType: 'customer',
sourceTarget: '海底捞',
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',
},
];
setStatusFilter: (status) => {
set({ statusFilter: status });
},
}));
// --- localStorage helpers ---
const STORAGE_KEY = 'ftb_requirements_v2';
function saveLocal(state: { requirements: Requirement[]; sourceTargets: SourceTarget[]; types: DictItem[]; platforms: DictItem[] }) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {}
}
function loadLocal(): { requirements: Requirement[]; sourceTargets: SourceTarget[]; types: DictItem[]; platforms: DictItem[] } | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
} catch {}
return null;
}
// --- Store ---
export const useRequirementStore = create<RequirementState>((set, get) => {
return {
requirements: MOCK_REQUIREMENTS,
sourceTargets: PRESET_SOURCE_TARGETS,
types: PRESET_TYPES,
platforms: PRESET_PLATFORMS,
loading: false,
fetchRequirements: () => {
const cached = loadLocal();
if (cached) {
set({ requirements: cached.requirements, sourceTargets: cached.sourceTargets, types: cached.types, platforms: cached.platforms });
}
},
createRequirement: (data) => {
const { requirements, sourceTargets, types, platforms } = get();
const code = `REQ-${String(requirements.length + 1).padStart(3, '0')}`;
const newReq: Requirement = {
...data,
id: `req-${Date.now()}`,
code,
createdAt: new Date().toISOString().slice(0, 10),
};
const updated = [...requirements, newReq];
set({ requirements: updated });
saveLocal({ requirements: updated, sourceTargets, types, platforms });
},
updateRequirement: (id, data) => {
const { requirements, sourceTargets, types, platforms } = get();
const updated = requirements.map((r) => (r.id === id ? { ...r, ...data } : r));
set({ requirements: updated });
saveLocal({ requirements: updated, sourceTargets, types, platforms });
},
deleteRequirement: (id) => {
const { requirements, sourceTargets, types, platforms } = get();
const updated = requirements.filter((r) => r.id !== id);
set({ requirements: updated });
saveLocal({ requirements: updated, sourceTargets, types, platforms });
},
// --- SourceTarget dict ---
addSourceTarget: (name, sourceType) => {
const { requirements, sourceTargets, types, platforms } = get();
const item: SourceTarget = { id: `st-${Date.now()}`, name, sourceType, createdAt: new Date().toISOString().slice(0, 10) };
const updated = [...sourceTargets, item];
set({ sourceTargets: updated });
saveLocal({ requirements, sourceTargets: updated, types, platforms });
},
updateSourceTarget: (id, name) => {
const { requirements, sourceTargets, types, platforms } = get();
const updated = sourceTargets.map((s) => (s.id === id ? { ...s, name } : s));
set({ sourceTargets: updated });
saveLocal({ requirements, sourceTargets: updated, types, platforms });
},
deleteSourceTarget: (id) => {
const { requirements, sourceTargets, types, platforms } = get();
const updated = sourceTargets.filter((s) => s.id !== id);
set({ sourceTargets: updated });
saveLocal({ requirements, sourceTargets: updated, types, platforms });
},
// --- Type dict ---
addType: (name) => {
const { requirements, sourceTargets, types, platforms } = get();
const item: DictItem = { id: `type-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) };
const updated = [...types, item];
set({ types: updated });
saveLocal({ requirements, sourceTargets, types: updated, platforms });
},
updateType: (id, name) => {
const { requirements, sourceTargets, types, platforms } = get();
const updated = types.map((t) => (t.id === id ? { ...t, name } : t));
set({ types: updated });
saveLocal({ requirements, sourceTargets, types: updated, platforms });
},
deleteType: (id) => {
const { requirements, sourceTargets, types, platforms } = get();
const updated = types.filter((t) => t.id !== id);
set({ types: updated });
saveLocal({ requirements, sourceTargets, types: updated, platforms });
},
// --- Platform dict ---
addPlatform: (name) => {
const { requirements, sourceTargets, types, platforms } = get();
const item: DictItem = { id: `platform-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) };
const updated = [...platforms, item];
set({ platforms: updated });
saveLocal({ requirements, sourceTargets, types, platforms: updated });
},
updatePlatform: (id, name) => {
const { requirements, sourceTargets, types, platforms } = get();
const updated = platforms.map((p) => (p.id === id ? { ...p, name } : p));
set({ platforms: updated });
saveLocal({ requirements, sourceTargets, types, platforms: updated });
},
deletePlatform: (id) => {
const { requirements, sourceTargets, types, platforms } = get();
const updated = platforms.filter((p) => p.id !== id);
set({ platforms: updated });
saveLocal({ requirements, sourceTargets, types, platforms: updated });
},
};
});