Files
dusuniot/src/routes/admin.users.tsx
T

203 lines
7.9 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.
/**
* # 路由 /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>
);
}