v0.11.0 后台 CRM 化:媒体上传/单页面 Elementor/修订/分类标签/菜单/用户/审计/设置/Dashboard v2 + dusuniot-cms MCP 服务器

This commit is contained in:
bigcode
2026-11-04 06:10:04 +08:00
parent 2251a25e12
commit b16c33c08b
15 changed files with 2053 additions and 152 deletions
+132 -24
View File
@@ -1,17 +1,18 @@
/**
* # 路由 /admin/users 用户管理(系统设置,只读)
* 用户列表:分页 + 关键字搜索;角色用 Tag 展示;不做编辑操作。
* # 路由 /admin/users 用户管理
* 用户列表(分页 + 搜索);admin 可新建用户、改角色、重置密码。
* 服务端有防锁死保护:最后一个 admin 不可降级。
*/
import { createFileRoute } from "@tanstack/react-router";
import { Card, Input, Table, Tag } from "antd";
import { SearchOutlined } from "@ant-design/icons";
import { createFileRoute, useRouter } from "@tanstack/react-router";
import { App, Button, Card, Form, Input, Modal, Select, Space, Table, Tag } from "antd";
import { KeyRound, Search, UserPlus } from "lucide-react";
import { getCurrentUser } from "~/server/auth";
import { useState } from "react";
import { adminListUsers } from "~/server/admin";
import { adminCreateUser, adminListUsers, adminResetUserPassword, adminUpdateUserRole } from "~/server/admin";
import { AdminEmpty, Forbidden, PageHeader, fmtDateTime, guardAdmin, tablePagination, type AdminSearch } from "~/components/admin/common";
export const Route = createFileRoute("/admin/users")({
validateSearch: (search: Record<string, unknown>): AdminSearch => {
// 只保留显式传入的参数,缺省时不回写 URL(避免 307 归一化重定向)
const out: AdminSearch = {};
const page = Math.floor(Number(search.page)) || 0;
if (page > 1) out.page = page;
@@ -19,61 +20,168 @@ export const Route = createFileRoute("/admin/users")({
return out;
},
loaderDeps: ({ search }) => ({ search }),
loader: ({ deps }) => guardAdmin(() => adminListUsers({ data: { page: deps.search.page, q: deps.search.q || undefined } })),
loader: ({ deps }) =>
guardAdmin(async () => {
const [user, me] = await Promise.all([adminListUsers({ data: { page: deps.search.page, q: deps.search.q || undefined } }), getCurrentUser()]);
return { users: user, me };
}),
component: UsersPage,
});
/** # 成员变量 角色中文标签与配色 **/
const ROLE_LABEL: Record<string, string> = { admin: "管理员", editor: "编辑", manager: "经理", customer: "客户" };
const ROLE_COLOR: Record<string, string> = { admin: "blue", editor: "cyan", manager: "geekblue", customer: "default" };
const ROLE_OPTIONS = (["admin", "editor", "manager", "customer"] as const).map((r) => ({ value: r, label: ROLE_LABEL[r] ?? r }));
/** # 函数 用户列表页面组件 */
function UsersPage() {
const data = Route.useLoaderData();
const search = Route.useSearch();
const navigate = Route.useNavigate();
const router = useRouter();
const { message, modal } = App.useApp();
const [q, setQ] = useState(search.q ?? "");
/** # 成员变量 新建用户弹窗开关 **/
const [createOpen, setCreateOpen] = useState(false);
const [createForm] = Form.useForm();
const isAdmin = data?.me?.role === "admin";
if (!data) return <Forbidden />;
/** # 函数 修改用户角色(服务端校验最后一个 admin 不可降级) */
async function handleRoleChange(id: number, role: string) {
try {
await adminUpdateUserRole({ data: { id, role } });
message.success("角色已更新");
await router.invalidate();
} catch (e) {
message.error(e instanceof Error ? e.message : "更新失败");
}
}
/** # 函数 重置密码弹窗(admin 专用) */
function openResetPassword(id: number, email: string) {
let pwd = "";
modal.confirm({
title: `重置密码:${email}`,
content: <Input.Password placeholder="新密码(至少 8 位)" onChange={(e) => (pwd = e.target.value)} autoFocus />,
okText: "重置",
cancelText: "取消",
onOk: async () => {
try {
await adminResetUserPassword({ data: { id, password: pwd } });
message.success("密码已重置");
} catch (e) {
message.error(e instanceof Error ? e.message : "重置失败");
}
},
});
}
/** # 函数 新建用户提交 */
async function handleCreate(values: { email: string; username?: string; displayName?: string; password: string; role: string }) {
try {
await adminCreateUser({ data: values });
message.success("用户已创建");
setCreateOpen(false);
createForm.resetFields();
await router.invalidate();
} catch (e) {
message.error(e instanceof Error ? e.message : "创建失败");
}
}
return (
<Card size="small">
<PageHeader
title="用户管理"
extra={
<Input
allowClear
placeholder="搜索邮箱 / 显示名"
prefix={<SearchOutlined aria-hidden />}
value={q}
onChange={(e) => setQ(e.target.value)}
onPressEnter={() => void navigate({ search: (prev) => ({ ...prev, q, page: 1 }) })}
style={{ width: 240 }}
/>
<Space>
<Input
allowClear
placeholder="搜索邮箱 / 显示名"
prefix={<Search size={13} aria-hidden />}
value={q}
onChange={(e) => setQ(e.target.value)}
onPressEnter={() => void navigate({ search: (prev) => ({ ...prev, q, page: 1 }) })}
style={{ width: 240 }}
/>
{isAdmin ? (
<Button type="primary" icon={<UserPlus size={14} aria-hidden />} onClick={() => setCreateOpen(true)}>
新建用户
</Button>
) : null}
</Space>
}
/>
<Table
rowKey="id"
size="small"
dataSource={data.items}
dataSource={data.users.items}
locale={{ emptyText: <AdminEmpty /> }}
pagination={tablePagination(data.total, data.page, data.pageSize, (p) =>
pagination={tablePagination(data.users.total, data.users.page, data.users.pageSize, (p) =>
void navigate({ search: (prev) => ({ ...prev, page: p }) }),
)}
columns={[
{ title: "ID", dataIndex: "id", width: 80 },
{ title: "邮箱", dataIndex: "email", width: 260, ellipsis: true },
{ title: "用户名", dataIndex: "username", width: 160, ellipsis: true },
{ title: "显示名", dataIndex: "displayName", width: 180, ellipsis: true },
{ title: "邮箱", dataIndex: "email", width: 240, ellipsis: true },
{ title: "显示名", dataIndex: "displayName", width: 170, ellipsis: true },
{
title: "角色",
dataIndex: "role",
width: 110,
render: (v: string) => <Tag color={ROLE_COLOR[v] ?? "default"}>{ROLE_LABEL[v] ?? v}</Tag>,
width: 170,
render: (v: string, r) =>
isAdmin ? (
<Select
size="small"
value={v}
style={{ width: 120 }}
options={ROLE_OPTIONS}
onChange={(role) => void handleRoleChange(r.id, role)}
/>
) : (
<Tag color={ROLE_COLOR[v] ?? "default"}>{ROLE_LABEL[v] ?? v}</Tag>
),
},
{ title: "注册时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
{
title: "操作",
key: "act",
width: 120,
render: (_, r) =>
isAdmin ? (
<Button size="small" icon={<KeyRound size={13} aria-hidden />} onClick={() => openResetPassword(r.id, r.email)}>
重置密码
</Button>
) : null,
},
]}
/>
<Modal
title="新建用户"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={() => createForm.submit()}
destroyOnHidden
>
<Form form={createForm} layout="vertical" onFinish={(v) => void handleCreate(v as { email: string; password: string; role: string })}>
<Form.Item name="email" label="邮箱" rules={[{ required: true, type: "email", message: "请输入有效邮箱" }]}>
<Input />
</Form.Item>
<Space size={16} style={{ display: "flex" }}>
<Form.Item name="displayName" label="显示名" style={{ flex: 1 }}>
<Input placeholder="留空取邮箱前缀" />
</Form.Item>
<Form.Item name="role" label="角色" initialValue="editor" rules={[{ required: true }]}>
<Select style={{ width: 140 }} options={ROLE_OPTIONS} />
</Form.Item>
</Space>
<Form.Item name="password" label="初始密码" rules={[{ required: true, min: 8, message: "至少 8 位" }]}>
<Input.Password />
</Form.Item>
</Form>
</Modal>
</Card>
);
}