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:
409
apps/web/app/admin/members/page.tsx
Normal file
409
apps/web/app/admin/members/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
113
apps/web/app/admin/roles/page.tsx
Normal file
113
apps/web/app/admin/roles/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
307
apps/web/app/overtime/page.tsx
Normal file
307
apps/web/app/overtime/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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)]"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
489
apps/web/app/requirements/page.tsx
Normal file
489
apps/web/app/requirements/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
248
apps/web/app/versions/[id]/page.tsx
Normal file
248
apps/web/app/versions/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user