From b163812ab6fae615995150497b2aab5b71107942 Mon Sep 17 00:00:00 2001
From: zzhyyyyy <2685922758@qq.com>
Date: Sat, 4 Jul 2026 17:53:08 +0800
Subject: [PATCH] =?UTF-8?q?=E8=BF=90=E8=90=A5=E5=90=8E=E5=8F=B0=E5=89=8D?=
=?UTF-8?q?=E7=AB=AF:=E6=9D=83=E9=99=90=E7=AE=A1=E7=90=86(RBAC)+=20?=
=?UTF-8?q?=E7=B3=BB=E7=BB=9F=E9=85=8D=E7=BD=AE=20+=20=E6=AF=94=E4=BB=B7?=
=?UTF-8?q?=E8=AE=B0=E5=BD=95=E5=BA=97/=E5=95=86=E5=93=81=E6=90=9C?=
=?UTF-8?q?=E7=B4=A2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
一、权限管理(原「管理员」页,重命名为「权限管理」)
- 重建为 4 视图:账号列表 / 新增·编辑人员 / 角色设置(只读权限矩阵,无权限置灰)/ 角色权限勾选矩阵
- 左侧导航按当前角色的可见页过滤(拉 /me 刷新);角色显示中文名 管理员/运营/财务/技术
- 新增人员自动生成一次性密码;编辑复看已确定登录密码(只读),无留存明文的账号不显示密码
二、系统配置
- 首页数据「保存」改为保存即生效
- 首页轮播种子页加 真实/种子/混播 数据源切换
三、比价记录
- 店/商品拆两列 + 各自搜索框;整格换行不截断;搜索命中黄底高亮
Co-Authored-By: Claude Opus 4.8
---
src/app/(main)/admins/page.tsx | 456 +++++++++++++++++----
src/app/(main)/comparison-records/page.tsx | 77 +++-
src/app/(main)/config/HomeMarqueeSeeds.tsx | 40 ++
src/app/(main)/config/HomeStatsConfig.tsx | 17 +-
src/app/(main)/layout.tsx | 33 +-
src/lib/types.ts | 24 +-
6 files changed, 535 insertions(+), 112 deletions(-)
diff --git a/src/app/(main)/admins/page.tsx b/src/app/(main)/admins/page.tsx
index 4750ff3..ac8b70a 100644
--- a/src/app/(main)/admins/page.tsx
+++ b/src/app/(main)/admins/page.tsx
@@ -1,81 +1,262 @@
'use client';
-import { useEffect, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import type { ColumnsType } from 'antd/es/table';
-import { App, Button, Form, Input, Modal, Select, Table, Tag } from 'antd';
+import {
+ App, Button, Card, Checkbox, Input, Select, Space, Table, Tag,
+} from 'antd';
import { api, errMsg } from '@/lib/api';
import { formatUtcTime } from '@/lib/format';
-import type { AdminInfo } from '@/lib/types';
+import { getAdmin } from '@/lib/auth';
+import type { AdminInfo, AdminRole, PermissionGroup } from '@/lib/types';
-const ROLES = [
- { value: 'super_admin', label: 'super_admin' },
- { value: 'finance', label: 'finance' },
- { value: 'operator', label: 'operator' },
-];
-// last_login_at 为 UTC 口径(datetime.now(utc)),按北京显示(勿用 new Date().toLocaleString)
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 (
+
+ {catalog.map((g) => (
+
+
+ {g.group}
+
+
+ {g.pages.map((p) => (
+ // 深色=可见,灰色(--ink-3)=不可见
+
+ {p.label}
+
+ ))}
+
+
+ ))}
+
+ );
+}
+
+// ===== 可编辑权限矩阵:角色表单用(勾选框 + 分类全选/半选)=====
+function PermEditor({
+ catalog, value, onChange,
+}: { catalog: PermissionGroup[]; value: string[]; onChange: (next: string[]) => void }) {
+ const has = useMemo(() => new Set(value), [value]);
+ const setPages = (s: Set) => 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 (
+
+ {catalog.map((g) => {
+ const cnt = g.pages.filter((p) => has.has(p.key)).length;
+ const all = cnt === g.pages.length && cnt > 0;
+ return (
+
+
+ 0 && !all}
+ onChange={(e) => toggleGroup(g, e.target.checked)}
+ >
+ {g.group}
+
+
+
+ {g.pages.map((p) => (
+ togglePage(p.key)}>
+ {p.label}
+
+ ))}
+
+
+ );
+ })}
+
+ );
+}
+
export default function AdminsPage() {
const { message, modal } = App.useApp();
- const [admins, setAdmins] = useState([]);
- const [loading, setLoading] = useState(false);
- const [createOpen, setCreateOpen] = useState(false);
- const [form] = Form.useForm();
+ const me = getAdmin();
+ const isSuper = me?.role === 'super_admin';
- const load = async () => {
+ const [view, setView] = useState('list');
+ const [admins, setAdmins] = useState([]);
+ const [roles, setRoles] = useState([]);
+ const [catalog, setCatalog] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ const roleByName = useMemo(() => {
+ const m: Record = {};
+ 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 { data } = await api.get('/admin/api/admins');
- setAdmins(data);
+ const [a, r, c] = await Promise.all([
+ api.get('/admin/api/admins'),
+ api.get('/admin/api/roles'),
+ api.get('/admin/api/roles/catalog'),
+ ]);
+ setAdmins(a.data);
+ setRoles(r.data);
+ setCatalog(c.data);
+ } catch (e) {
+ message.error(errMsg(e));
} finally {
setLoading(false);
}
};
- useEffect(() => {
- load();
- }, []);
+ useEffect(() => { loadAll(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
- const create = async () => {
- const v = await form.validateFields();
+ // ===== 人员表单(新增 / 编辑)状态 =====
+ const [personEditing, setPersonEditing] = useState(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) => (
+
+ {value}
+
+
+ );
+ const submitPerson = async () => {
try {
- await api.post('/admin/api/admins', v);
- message.success('已创建');
- setCreateOpen(false);
- form.resetFields();
- load();
+ 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('已更新');
- load();
- } catch (e) {
- message.error(errMsg(e));
- }
+ try { await api.patch(`/admin/api/admins/${a.id}`, { role }); message.success('已更新'); loadAll(); }
+ catch (e) { message.error(errMsg(e)); }
},
});
};
-
- const toggle = (a: AdminInfo) => {
+ 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('已更新');
- load();
- } catch (e) {
- message.error(errMsg(e));
- }
+ 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(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(null);
+ const [roleName, setRoleName] = useState('');
+ const [rolePages, setRolePages] = useState([]);
+ 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)); }
},
});
};
@@ -84,60 +265,163 @@ export default function AdminsPage() {
{ title: 'ID', dataIndex: 'id', width: 70 },
{ title: '用户名', dataIndex: 'username' },
{
- title: '角色',
- dataIndex: 'role',
+ title: '角色', dataIndex: 'role', width: 170,
render: (r: string, a: AdminInfo) => (
-
+
+ 数据源
+ changeMode(v as string)}
+ options={[
+ { label: '混播(默认)', value: 'mixed' },
+ { label: '只真实', value: 'real' },
+ { label: '只种子', value: 'seed' },
+ ]}
+ />
+
+ 混播=真实优先+种子补位;只真实=不掺假数据(真实不足则少显示);只种子=只用下方种子/合成(演示用)
+
+