Files
shaguabijia-admin-web/src/app/(main)/admins/page.tsx
T
liujiahui 557348bd6b 权限管理新增人员支持「自定义」角色(前端) (#41)
角色下拉末尾加「自定义」,选中时「可见页面」从只读矩阵切成可勾选(复用 PermEditor),提交带 pages_override;
编辑自定义用户回填已勾选页;列表里 custom 显示「自定义」,列表内切到自定义转去编辑页勾选。
AdminInfo 加 pages_override 字段。npm run build 通过。

配套后端 PR:shaguabijia-app-server#admin-custom-perms。

---------

Co-authored-by: no_gen_mu <liujianhishen@gmail.com>
Reviewed-on: #41
Co-authored-by: liujiahui <liujiahui@wonderable.ai>
Co-committed-by: liujiahui <liujiahui@wonderable.ai>
2026-07-09 01:11:29 +08:00

467 lines
21 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 type { ColumnsType } from 'antd/es/table';
import {
App, Button, Card, Checkbox, Input, Select, Space, Table, Tag,
} from 'antd';
import { api, errMsg } from '@/lib/api';
import { formatUtcTime } from '@/lib/format';
import { getAdmin } from '@/lib/auth';
import type { AdminInfo, AdminRole, PermissionGroup } from '@/lib/types';
const dt = (v: string | null) => (v ? formatUtcTime(v, 'YYYY-MM-DD HH:mm:ss') : '从未');
// 随机初始密码(排除易混字符 0O1lI),客户端生成、转交本人,后端只存 hash
function genPassword(len = 10): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789';
let s = '';
for (let i = 0; i < len; i += 1) s += chars[Math.floor(Math.random() * chars.length)];
return s;
}
type View = 'list' | 'person' | 'roles' | 'roleForm';
// 「自定义」哨兵角色:不是共享角色,选它时可见页由本人逐页勾选(存 pages_override),与后端一致。
const CUSTOM_ROLE = 'custom';
const CUSTOM_LABEL = '自定义';
// ===== 只读权限矩阵:角色详情 / 人员「可见页面」预览共用(深色=有,灰色=无)=====
function PermMatrix({ catalog, pages }: { catalog: PermissionGroup[]; pages: string[] }) {
const has = useMemo(() => new Set(pages), [pages]);
return (
<div style={{ border: '1px solid #f0f0f0', borderRadius: 8, overflow: 'hidden' }}>
{catalog.map((g) => (
<div key={g.group} style={{ display: 'flex', borderBottom: '1px solid #f0f2f5' }}>
<div style={{ width: 150, flexShrink: 0, padding: '14px', fontWeight: 500, color: 'rgba(0,0,0,.85)' }}>
{g.group}
</div>
<div style={{ flex: 1, padding: '12px 16px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px 28px' }}>
{g.pages.map((p) => (
// 深色=可见,灰色(--ink-3)=不可见
<span key={p.key} style={{ fontSize: 13.5, color: has.has(p.key) ? 'rgba(0,0,0,.85)' : '#94a0b1' }}>
{p.label}
</span>
))}
</div>
</div>
))}
</div>
);
}
// ===== 可编辑权限矩阵:角色表单用(勾选框 + 分类全选/半选)=====
function PermEditor({
catalog, value, onChange,
}: { catalog: PermissionGroup[]; value: string[]; onChange: (next: string[]) => void }) {
const has = useMemo(() => new Set(value), [value]);
const setPages = (s: Set<string>) => onChange(Array.from(s));
const togglePage = (key: string) => {
const n = new Set(has);
if (n.has(key)) n.delete(key); else n.add(key);
setPages(n);
};
const toggleGroup = (g: PermissionGroup, on: boolean) => {
const n = new Set(has);
g.pages.forEach((p) => (on ? n.add(p.key) : n.delete(p.key)));
setPages(n);
};
return (
<div style={{ border: '1px solid #f0f0f0', borderRadius: 8, overflow: 'hidden' }}>
{catalog.map((g) => {
const cnt = g.pages.filter((p) => has.has(p.key)).length;
const all = cnt === g.pages.length && cnt > 0;
return (
<div key={g.group} style={{ display: 'flex', borderBottom: '1px solid #fafafa' }}>
<div style={{ width: 140, flexShrink: 0, padding: '14px', background: '#fafbfd' }}>
<Checkbox
checked={all}
indeterminate={cnt > 0 && !all}
onChange={(e) => toggleGroup(g, e.target.checked)}
>
{g.group}
</Checkbox>
</div>
<div style={{ flex: 1, padding: '12px 16px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px 24px' }}>
{g.pages.map((p) => (
<Checkbox key={p.key} checked={has.has(p.key)} onChange={() => togglePage(p.key)}>
{p.label}
</Checkbox>
))}
</div>
</div>
);
})}
</div>
);
}
export default function AdminsPage() {
const { message, modal } = App.useApp();
const me = getAdmin();
const isSuper = me?.role === 'super_admin';
const [view, setView] = useState<View>('list');
const [admins, setAdmins] = useState<AdminInfo[]>([]);
const [roles, setRoles] = useState<AdminRole[]>([]);
const [catalog, setCatalog] = useState<PermissionGroup[]>([]);
const [loading, setLoading] = useState(false);
const roleByName = useMemo(() => {
const m: Record<string, AdminRole> = {};
roles.forEach((r) => { m[r.name] = r; });
return m;
}, [roles]);
const roleOptions = roles.map((r) => ({ value: r.name, label: r.label }));
// 人员表单角色下拉:真实角色 + 末尾「自定义」。选「自定义」→ 下方可见页改为逐页勾选。
const personRoleOptions = [...roleOptions, { value: CUSTOM_ROLE, label: CUSTOM_LABEL }];
// 角色 key → 展示名(custom 显示「自定义」;查不到回退 key 原文)
const roleLabelOf = (name: string) =>
(name === CUSTOM_ROLE ? CUSTOM_LABEL : roleByName[name]?.label ?? name);
const loadAll = async () => {
setLoading(true);
try {
const [a, r, c] = await Promise.all([
api.get<AdminInfo[]>('/admin/api/admins'),
api.get<AdminRole[]>('/admin/api/roles'),
api.get<PermissionGroup[]>('/admin/api/roles/catalog'),
]);
setAdmins(a.data);
setRoles(r.data);
setCatalog(c.data);
} catch (e) {
message.error(errMsg(e));
} finally {
setLoading(false);
}
};
useEffect(() => { loadAll(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
// ===== 人员表单(新增 / 编辑)状态 =====
const [personEditing, setPersonEditing] = useState<AdminInfo | null>(null);
const [personName, setPersonName] = useState('');
const [personRole, setPersonRole] = useState('');
const [initPw, setInitPw] = useState('');
// 「自定义」角色时逐页勾选的可见页集(role==custom 才用到)
const [personPages, setPersonPages] = useState<string[]>([]);
const openCreatePerson = () => {
setPersonEditing(null);
setPersonName('');
setPersonRole(roles.find((r) => !r.is_builtin)?.name ?? roles[0]?.name ?? 'operator');
setPersonPages([]);
setInitPw(genPassword());
setView('person');
};
const openEditPerson = (a: AdminInfo) => {
setPersonEditing(a);
setPersonName(a.username);
setPersonRole(a.role);
// 编辑「自定义」用户时,回填其已勾选的可见页(供继续增删)
setPersonPages(a.role === CUSTOM_ROLE ? (a.pages_override ?? []) : []);
setInitPw(''); // 编辑态密码只读展示 a.password(已确定的登录密码),不用 initPw
setView('person');
};
const copyText = (v: string) => {
navigator.clipboard?.writeText(v);
message.success('已复制到剪贴板');
};
// 密码展示框(对齐原型 .pw-show:虚线灰框 + 大号等宽 + 复制)
const pwBox = (value: string) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, background: '#f4f6fa', border: '1px dashed #cbd5e1', borderRadius: 8, padding: '12px 14px' }}>
<code style={{ flex: 1, fontFamily: '"DIN Alternate", "SF Mono", Consolas, monospace', fontSize: 22, fontWeight: 700, letterSpacing: 1, color: 'rgba(0,0,0,.85)' }}>{value}</code>
<Button onClick={() => copyText(value)}></Button>
</div>
);
const isCustom = personRole === CUSTOM_ROLE;
const submitPerson = async () => {
if (isCustom && personPages.length === 0) { message.error('自定义角色请至少勾选一个可见页面'); return; }
try {
if (personEditing) {
// 密码只读、不改;提交变更的角色 +(自定义时)勾选的可见页
const roleChanged = personRole !== personEditing.role;
const pagesChanged = isCustom
&& JSON.stringify(personPages) !== JSON.stringify(personEditing.pages_override ?? []);
if (roleChanged || pagesChanged) {
const payload: Record<string, unknown> = {};
if (roleChanged) payload.role = personRole;
// 自定义:带上勾选集(切到 custom 或改现有 custom 勾选都要);切回普通角色不带,后端会清空 override
if (isCustom) payload.pages_override = personPages;
await api.patch(`/admin/api/admins/${personEditing.id}`, payload);
}
message.success('已保存');
} else {
if (personName.trim().length < 3) { message.error('用户名至少 3 位'); return; }
await api.post('/admin/api/admins', {
username: personName.trim(),
password: initPw,
role: personRole,
...(isCustom ? { pages_override: personPages } : {}),
});
message.success('已创建,请把初始密码转交本人');
}
setView('list');
loadAll();
} catch (e) {
message.error(errMsg(e));
}
};
// ===== 列表操作 =====
const changeRole = (a: AdminInfo, role: string) => {
if (role === a.role) return;
// 改成「自定义」需要逐页勾选 → 转去编辑页(列表里没有页选择器,直接切会得到空可见页)
if (role === CUSTOM_ROLE) { openEditPerson({ ...a, role: CUSTOM_ROLE }); return; }
modal.confirm({
title: `${a.username} 的角色改为 ${roleLabelOf(role)}?`,
onOk: async () => {
try { await api.patch(`/admin/api/admins/${a.id}`, { role }); message.success('已更新'); loadAll(); }
catch (e) { message.error(errMsg(e)); }
},
});
};
const toggleStatus = (a: AdminInfo) => {
const next = a.status === 'active' ? 'disabled' : 'active';
modal.confirm({
title: `${next === 'disabled' ? '禁用' : '启用'}管理员 ${a.username}?`,
onOk: async () => {
try { await api.patch(`/admin/api/admins/${a.id}`, { status: next }); message.success('已更新'); loadAll(); }
catch (e) { message.error(errMsg(e)); }
},
});
};
const deletePerson = (a: AdminInfo) => {
modal.confirm({
title: '确认删除',
content: `确定删除管理员「${a.username}」吗?删除后该账号将无法登录,不可恢复。`,
okText: '确认删除', okButtonProps: { danger: true },
onOk: async () => {
try { await api.delete(`/admin/api/admins/${a.id}`); message.success('已删除'); loadAll(); }
catch (e) { message.error(errMsg(e)); }
},
});
};
// ===== 角色设置状态 =====
const [selRoleId, setSelRoleId] = useState<number | null>(null);
const selRole = roles.find((r) => r.id === selRoleId) ?? roles[0] ?? null;
const openRoles = () => {
setSelRoleId((prev) => prev ?? roles[0]?.id ?? null);
setView('roles');
};
// 角色表单(新增 / 编辑)
const [roleEditing, setRoleEditing] = useState<AdminRole | null>(null);
const [roleName, setRoleName] = useState('');
const [rolePages, setRolePages] = useState<string[]>([]);
const openCreateRole = () => {
setRoleEditing(null); setRoleName(''); setRolePages([]); setView('roleForm');
};
const openEditRole = (r: AdminRole) => {
setRoleEditing(r); setRoleName(r.label); setRolePages(r.pages); setView('roleForm');
};
const submitRole = async () => {
if (!roleName.trim()) { message.error('请输入角色名称'); return; }
try {
if (roleEditing) {
// 只改展示名 + 可见页(key 不可变)
await api.patch(`/admin/api/roles/${roleEditing.id}`, { label: roleName.trim(), pages: rolePages });
message.success('角色已保存');
} else {
await api.post('/admin/api/roles', { name: roleName.trim(), pages: rolePages });
message.success('角色已创建');
}
await loadAll();
setView('roles');
} catch (e) {
message.error(errMsg(e));
}
};
const deleteRole = (r: AdminRole) => {
modal.confirm({
title: '确认删除角色',
content: `确定删除角色「${r.label}」吗?${r.in_use > 0 ? `当前有 ${r.in_use} 名成员在用,需先改派。` : '此操作不可恢复。'}`,
okText: '确认删除', okButtonProps: { danger: true },
onOk: async () => {
try { await api.delete(`/admin/api/roles/${r.id}`); message.success('已删除角色'); await loadAll(); setSelRoleId(null); }
catch (e) { message.error(errMsg(e)); }
},
});
};
const columns: ColumnsType<AdminInfo> = [
{ title: 'ID', dataIndex: 'id', width: 70 },
{ title: '用户名', dataIndex: 'username' },
{
title: '角色', dataIndex: 'role', width: 170,
// options 含「自定义」→ custom 用户在列表里显示为「自定义」;选它会转去编辑页勾选(见 changeRole)
render: (r: string, a: AdminInfo) => (
<Select size="small" value={r} style={{ width: 150 }} options={personRoleOptions} onChange={(v) => changeRole(a, v)} />
),
},
{ title: '状态', dataIndex: 'status', width: 90, render: (s: string) => <Tag color={s === 'active' ? 'green' : 'default'}>{s === 'active' ? 'active' : '已禁用'}</Tag> },
{ title: '最后登录', dataIndex: 'last_login_at', width: 180, render: dt },
{
title: '操作', key: 'op', width: 200,
render: (_: unknown, a: AdminInfo) => (
<Space size="middle">
<a onClick={() => openEditPerson(a)}></a>
<a onClick={() => toggleStatus(a)}>{a.status === 'active' ? '禁用' : '启用'}</a>
<a style={{ color: '#cf1322' }} onClick={() => deletePerson(a)}></a>
</Space>
),
},
];
// ============ 视图 1:管理员账号列表 ============
if (view === 'list') {
return (
<div>
<h2></h2>
<Space style={{ marginBottom: 16 }}>
<Button type="primary" onClick={openCreatePerson}>+ </Button>
<Button onClick={openRoles}></Button>
</Space>
<Table rowKey="id" columns={columns} dataSource={admins} loading={loading} pagination={false} />
</div>
);
}
// ============ 视图 2:新增 / 编辑人员 ============
if (view === 'person') {
const rolePagesPreview = roleByName[personRole]?.pages ?? [];
return (
<div>
<Space style={{ marginBottom: 16 }}><a onClick={() => setView('list')}> </a></Space>
<Card style={{ maxWidth: 720, margin: '0 auto' }}>
<h2 style={{ fontSize: 19, fontWeight: 600, marginBottom: 22 }}>{personEditing ? '编辑人员' : '新增人员'}</h2>
<div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 8 }}><span style={{ color: '#cf1322' }}> *</span></div>
<Input value={personName} disabled={!!personEditing} onChange={(e) => setPersonName(e.target.value)} placeholder="至少 3 位" />
{personEditing && <div style={{ color: '#999', fontSize: 12, marginTop: 6 }}></div>}
</div>
{!personEditing ? (
// 新增:系统生成初始密码(可换)
<div style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<span></span>
<a onClick={() => setInitPw(genPassword())}> </a>
</div>
{pwBox(initPw)}
</div>
) : personEditing.password ? (
// 编辑:显示该成员已确定的登录密码(只读、不可换)
<div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 8 }}></div>
{pwBox(personEditing.password)}
<div style={{ color: '#999', fontSize: 12, marginTop: 6 }}>,</div>
</div>
) : (
// 编辑:无留存明文(脚本/起后台建的超管)→ 不显示密码
<div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 8 }}></div>
<div style={{ color: '#999', fontSize: 13, padding: '6px 0' }}>,</div>
</div>
)}
<div style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<span><span style={{ color: '#cf1322' }}> *</span></span>
{isSuper && <a onClick={openRoles}> </a>}
</div>
<Select value={personRole} options={personRoleOptions} onChange={setPersonRole} style={{ width: '100%' }} />
<div style={{ color: '#999', fontSize: 12, marginTop: 6 }}>
{isCustom
? '「自定义」:下方逐页勾选该成员可见的页面,仅对本人生效(不影响任何角色)'
: '可见页面完全由所选角色决定;如需调整请到「角色设置」修改该角色'}
</div>
</div>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<span></span>
<span style={{ color: '#999', fontSize: 12 }}>
{isCustom ? '勾选本成员可见的页面' : '跟随所选角色,此处只读(深色=可见)'}
</span>
</div>
{isCustom ? (
<PermEditor catalog={catalog} value={personPages} onChange={setPersonPages} />
) : (
<PermMatrix catalog={catalog} pages={personRole === 'super_admin' ? catalog.flatMap((g) => g.pages.map((p) => p.key)) : rolePagesPreview} />
)}
</div>
<div style={{ marginTop: 24, textAlign: 'right' }}>
<Space><Button onClick={() => setView('list')}></Button><Button type="primary" onClick={submitPerson}></Button></Space>
</div>
</Card>
</div>
);
}
// ============ 视图 3:角色设置(角色列表 + 只读权限详情)============
if (view === 'roles') {
return (
<div>
<Space style={{ marginBottom: 16 }}><a onClick={() => setView('list')}> </a><b></b></Space>
<div>
<Button type="primary" style={{ marginBottom: 16 }} onClick={openCreateRole}>+ </Button>
<div style={{ display: 'flex', background: '#fff', border: '1px solid #e8ebf1', borderRadius: 10, boxShadow: '0 1px 3px rgba(16,24,40,.06)', overflow: 'hidden' }}>
<div style={{ width: 176, flexShrink: 0, borderRight: '1px solid #eff1f6', padding: 8 }}>
{roles.map((r) => (
<div
key={r.id}
onClick={() => setSelRoleId(r.id)}
style={{
padding: '10px 14px', borderRadius: 8, cursor: 'pointer', marginBottom: 2,
background: selRole?.id === r.id ? '#e6f0ff' : undefined,
color: selRole?.id === r.id ? '#1677ff' : undefined,
fontWeight: selRole?.id === r.id ? 600 : undefined,
}}
>
{r.label}{r.is_builtin && <Tag style={{ marginLeft: 6 }}></Tag>}
</div>
))}
</div>
<div style={{ flex: 1, minWidth: 0, padding: '18px 22px' }}>
{selRole && (
<>
<Space style={{ marginBottom: 16 }} size="middle">
<b style={{ fontSize: 16 }}>{selRole.label}</b>
{!selRole.is_builtin && <a onClick={() => openEditRole(selRole)}></a>}
{!selRole.is_builtin && <a style={{ color: '#cf1322' }} onClick={() => deleteRole(selRole)}></a>}
{selRole.in_use > 0 && <span style={{ color: '#999', fontSize: 12 }}>{selRole.in_use} </span>}
</Space>
<PermMatrix catalog={catalog} pages={selRole.pages} />
</>
)}
</div>
</div>
</div>
</div>
);
}
// ============ 视图 4:新增 / 编辑角色权限 ============
return (
<div>
<Space style={{ marginBottom: 16 }}><a onClick={() => setView('roles')}> </a></Space>
<Card style={{ maxWidth: 920, margin: '0 auto' }}>
<h2 style={{ fontSize: 19, fontWeight: 600, marginBottom: 22 }}>{roleEditing ? '编辑角色权限' : '新增角色权限'}</h2>
<div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 8 }}><span style={{ color: '#cf1322' }}> *</span></div>
<Input value={roleName} onChange={(e) => setRoleName(e.target.value)} placeholder="如:运营专员 / 财务 / 审核" maxLength={32} />
</div>
<div>
<div style={{ marginBottom: 8 }}>()<span style={{ color: '#cf1322' }}> *</span></div>
<PermEditor catalog={catalog} value={rolePages} onChange={setRolePages} />
</div>
<div style={{ marginTop: 24, textAlign: 'right' }}>
<Space><Button onClick={() => setView('roles')}></Button><Button type="primary" onClick={submitRole}></Button></Space>
</div>
</Card>
</div>
);
}