Files
ftb-project-management/apps/web/app/admin/members/page.tsx
Script Generator 2acc9aeaa9 feat: 版本详情完整功能 + 数据串联 + 登录模块
- 版本详情:关联需求Tab(从需求池添加已采纳需求/移除释放)
- 版本详情:调研/产品方案/UI设计Tab(计划CRUD、完成提交成果、耗时统计)
- 版本详情:超期校验(结束日期超版本截止需填写原因)
- 版本详情:概览统计卡片(加班时长/排名/原因占比)
- 数据串联:产品→项目→版本→需求→加班全链路贯通
- 版本管理:规划中版本可删除,删除释放关联需求
- 数据清理:仅保留翻台宝/值班,需求池10条值班待评审需求
- 修复:列表页overflow裁剪菜单问题(4个页面统一修复)
- 新增:登录模块、AuthGuard、VersionPlan store

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-10 17:07:36 +08:00

410 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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="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 bg-[var(--bg-subtle)]">
<tr className="border-b border-[var(--line)] bg-[var(--bg-subtle)]">
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<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>
);
}