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

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

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

View File

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

View File

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