Files
shaguabijia-admin-web/src/app/(main)/admins/page.tsx
T
zhuzihao b620948a71 运营后台前端:权限管理(RBAC)+ 系统配置 + 比价记录店/商品搜索 (#36)
一、权限管理(原「管理员」页,重命名为「权限管理」)
- 重建为 4 视图:账号列表 / 新增·编辑人员 / 角色设置(只读权限矩阵,无权限置灰)/ 角色权限勾选矩阵
- 左侧导航按当前角色的可见页过滤(拉 /me 刷新);角色显示中文名 管理员/运营/财务/技术
- 新增人员自动生成一次性密码;编辑复看已确定登录密码(只读),无留存明文的账号不显示密码

二、系统配置
- 首页数据「保存」改为保存即生效
- 首页轮播种子页加 真实/种子/混播 数据源切换

三、比价记录
- 店/商品拆两列 + 各自搜索框;整格换行不截断;搜索命中黄底高亮

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #36
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-07-05 09:46:24 +08:00

428 lines
18 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';
// ===== 只读权限矩阵:角色详情 / 人员「可见页面」预览共用(深色=有,灰色=无)=====
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 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('');
const openCreatePerson = () => {
setPersonEditing(null);
setPersonName('');
setPersonRole(roles.find((r) => !r.is_builtin)?.name ?? roles[0]?.name ?? 'operator');
setInitPw(genPassword());
setView('person');
};
const openEditPerson = (a: AdminInfo) => {
setPersonEditing(a);
setPersonName(a.username);
setPersonRole(a.role);
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 submitPerson = async () => {
try {
if (personEditing) {
// 密码只读、不改;只提交变更的角色
if (personRole !== personEditing.role) {
await api.patch(`/admin/api/admins/${personEditing.id}`, { role: personRole });
}
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,
});
message.success('已创建,请把初始密码转交本人');
}
setView('list');
loadAll();
} catch (e) {
message.error(errMsg(e));
}
};
// ===== 列表操作 =====
const changeRole = (a: AdminInfo, role: string) => {
if (role === a.role) return;
modal.confirm({
title: `${a.username} 的角色改为 ${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,
render: (r: string, a: AdminInfo) => (
<Select size="small" value={r} style={{ width: 150 }} options={roleOptions} 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={roleOptions} onChange={setPersonRole} style={{ width: '100%' }} />
<div style={{ color: '#999', fontSize: 12, marginTop: 6 }}>;</div>
</div>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<span></span>
<span style={{ color: '#999', fontSize: 12 }}>,(=)</span>
</div>
<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>
);
}