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">