203 lines
7.9 KiB
TypeScript
203 lines
7.9 KiB
TypeScript
/**
|
||
* # 路由 /admin/users 用户管理
|
||
* 用户列表(分页 + 搜索);admin 可新建用户、改角色、重置密码。
|
||
* 服务端有防锁死保护:最后一个 admin 不可降级。
|
||
*/
|
||
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||
import { App, Button, Card, Form, Input, Modal, Select, Space, Table, Tabs, Tag } from "antd";
|
||
import { KeyRound, Search, UserPlus } from "lucide-react";
|
||
import { getCurrentUser } from "~/server/auth";
|
||
import { useState } from "react";
|
||
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 => {
|
||
const out: AdminSearch = {};
|
||
const page = Math.floor(Number(search.page)) || 0;
|
||
if (page > 1) out.page = page;
|
||
if (typeof search.q === "string" && search.q) out.q = search.q;
|
||
// 用户类型 Tab:backend 后台用户 / customer 普通用户
|
||
if (search.kind === "backend" || search.kind === "customer") out.kind = search.kind;
|
||
return out;
|
||
},
|
||
loaderDeps: ({ search }) => ({ search }),
|
||
loader: ({ deps }) =>
|
||
guardAdmin(async () => {
|
||
const [user, me] = await Promise.all([
|
||
adminListUsers({ data: { page: deps.search.page, q: deps.search.q || undefined, kind: deps.search.kind } }),
|
||
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={
|
||
<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>
|
||
}
|
||
/>
|
||
{/* 用户类型分层:后台用户(admin/editor/manager)可登录后台;普通用户(customer)仅商城账号 */}
|
||
<Tabs
|
||
activeKey={search.kind ?? ""}
|
||
onChange={(key) => void navigate({ search: (prev) => ({ ...prev, kind: key || undefined, page: 1 }) })}
|
||
items={[
|
||
{ key: "", label: "全部" },
|
||
{ key: "backend", label: "后台用户" },
|
||
{ key: "customer", label: "普通用户" },
|
||
]}
|
||
/>
|
||
<Table
|
||
rowKey="id"
|
||
size="small"
|
||
dataSource={data.users.items}
|
||
locale={{ emptyText: <AdminEmpty /> }}
|
||
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: 240, ellipsis: true },
|
||
{ title: "显示名", dataIndex: "displayName", width: 170, ellipsis: true },
|
||
{
|
||
title: "角色",
|
||
dataIndex: "role",
|
||
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>
|
||
);
|
||
}
|