v0.11.0 后台 CRM 化:媒体上传/单页面 Elementor/修订/分类标签/菜单/用户/审计/设置/Dashboard v2 + dusuniot-cms MCP 服务器
This commit is contained in:
+132
-24
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user