Files
fquiz/web/src/app/admin/users/page.tsx
T
chengkai3 6cd959c528 refactor: use backend API for user ID uniqueness validation
- Add GET /api/v1/users/check-id/{user_id} endpoint for real-time validation
- Replace frontend local validation with debounced API calls (500ms)
- Support exclude_user_id parameter for edit scenarios
- Add UserIdCheckResponse schema
- Maintain format validation (alphanumeric + underscore) on frontend
- Clean up timeout on component unmount

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-19 11:04:39 +08:00

1244 lines
40 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.
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Button,
Card,
Col,
Dropdown,
Empty,
Form,
Input,
Modal,
Popconfirm,
Row,
Select,
Space,
Spin,
Table,
Tag,
Typography,
type CardProps,
type MenuProps,
} from "antd";
import { MoreOutlined, LeftOutlined, RightOutlined } from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ComponentType } from "react";
import { useAuth } from "@/components/auth-provider";
import { useToastFeedback } from "@/hooks/use-toast-feedback";
import { useTopicSubscription } from "@/hooks/use-topic-subscription";
import { useMobileDetection } from "@/hooks/use-mobile-detection";
import { readApiError } from "@/lib/api";
import type { RoleItem, RoleListResponse, UserListResponse, UserPublic } from "@/types/auth";
const AntCard = Card as unknown as ComponentType<CardProps>;
type UserRolePayload = {
role_codes: string[];
};
type CreateUserValues = {
user_id: string;
email?: string;
username: string;
password: string;
};
type EditUserValues = {
user_id: string;
username: string;
email: string;
status: "active" | "disabled";
};
type ResetPasswordValues = {
password: string;
};
function statusLabel(status: string): string {
if (status === "active") return "启用";
if (status === "disabled") return "禁用";
return status || "-";
}
const USERS_TABLE_MIN_SCROLL_Y = 180;
const USERS_TABLE_VIEWPORT_GAP = 40;
const USERS_TABLE_FALLBACK_RESERVE = 220;
export default function AdminUsersPage() {
const { user, initializing, fetchWithAuth, hasPermission } = useAuth();
const queryClient = useQueryClient();
const isMobile = useMobileDetection();
const [createForm] = Form.useForm<CreateUserValues>();
const [editUserForm] = Form.useForm<EditUserValues>();
const [resetPasswordForm] = Form.useForm<ResetPasswordValues>();
const [savingUserId, setSavingUserId] = useState<string | null>(null);
const [deletingUserId, setDeletingUserId] = useState<string | null>(null);
const [resettingUserId, setResettingUserId] = useState<string | null>(null);
const [updatingStatusUserId, setUpdatingStatusUserId] = useState<string | null>(null);
const [createUserModalOpen, setCreateUserModalOpen] = useState(false);
const [editingUser, setEditingUser] = useState<UserPublic | null>(null);
const [resetPasswordTarget, setResetPasswordTarget] = useState<UserPublic | null>(null);
const [assigningRolesUser, setAssigningRolesUser] = useState<UserPublic | null>(null);
const [roleForm] = Form.useForm<{ role_codes: string[] }>();
const [keywordInput, setKeywordInput] = useState("");
const [searchKeyword, setSearchKeyword] = useState("");
const [statusFilter, setStatusFilter] = useState<"active" | "disabled" | undefined>(undefined);
const [pagination, setPagination] = useState({ current: 1, pageSize: 20 });
const [tableScrollY, setTableScrollY] = useState(USERS_TABLE_MIN_SCROLL_Y);
const tableScrollAnchorRef = useRef<HTMLDivElement | null>(null);
const viewMode: "table" | "card" = isMobile ? "card" : "table";
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [userIdValidationError, setUserIdValidationError] = useState("");
const [editUserIdValidationError, setEditUserIdValidationError] = useState("");
const [checkingUserId, setCheckingUserId] = useState(false);
const userIdCheckTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const canManage = hasPermission("user.manage");
const canReadRoles = hasPermission("role.read") || hasPermission("role.manage");
const { current: paginationCurrent, pageSize: paginationPageSize } = pagination;
const trimmedKeyword = searchKeyword.trim();
const usersQueryParams = useMemo(() => {
const params = new URLSearchParams();
params.set("limit", String(paginationPageSize));
params.set("offset", String((paginationCurrent - 1) * paginationPageSize));
if (trimmedKeyword) {
params.set("keyword", trimmedKeyword);
}
if (statusFilter) {
params.set("status", statusFilter);
}
return params.toString();
}, [paginationCurrent, paginationPageSize, statusFilter, trimmedKeyword]);
const usersPath = `/api/v1/users?${usersQueryParams}`;
const rolesPath = "/api/v1/admin/roles";
const loadUsers = useCallback(async () => {
const response = await fetchWithAuth(usersPath);
if (!response.ok) throw new Error(await readApiError(response));
return (await response.json()) as UserListResponse;
}, [fetchWithAuth, usersPath]);
const loadRoles = useCallback(async () => {
const response = await fetchWithAuth(rolesPath);
if (!response.ok) throw new Error(await readApiError(response));
return (await response.json()) as RoleListResponse;
}, [fetchWithAuth]);
const usersQuery = useQuery({
queryKey: ["admin.users", usersQueryParams],
queryFn: loadUsers,
enabled: !!user && canManage,
});
const rolesQuery = useQuery({
queryKey: [rolesPath],
queryFn: loadRoles,
enabled: !!user && canManage && canReadRoles,
});
useTopicSubscription(
"admin.users",
useCallback(() => {
if (!user || !canManage) return;
void queryClient.invalidateQueries({ queryKey: ["admin.users"] });
if (canReadRoles) {
void queryClient.invalidateQueries({ queryKey: [rolesPath] });
}
}, [canManage, canReadRoles, queryClient, user]),
);
const users = useMemo(() => usersQuery.data?.items ?? [], [usersQuery.data?.items]);
const roles = useMemo<RoleItem[]>(() => {
if (canReadRoles) return rolesQuery.data?.items ?? [];
return Array.from(new Set(users.flatMap((item) => item.role_codes))).map((code, index) => ({
id: `fallback-${index + 1}`,
code,
name: code,
permission_codes: [],
menu_ids: [],
} satisfies RoleItem));
}, [canReadRoles, rolesQuery.data?.items, users]);
const roleOptions = useMemo(() => roles.map((item) => item.code), [roles]);
const roleCodeToName = useMemo(() => {
const map = new Map<string, string>();
roles.forEach((role) => {
map.set(role.code, role.name);
});
return map;
}, [roles]);
const getRoleName = useCallback((code: string): string => {
return roleCodeToName.get(code) || code;
}, [roleCodeToName]);
const existingUserIds = useMemo(
() => new Set(users.map((item) => item.id.trim().toLowerCase())),
[users],
);
const existingEmails = useMemo(
() => new Set(users.map((item) => item.email.trim().toLowerCase())),
[users],
);
const existingUsernames = useMemo(
() => new Set(users.map((item) => item.username.trim().toLowerCase())),
[users],
);
const refreshData = async () => {
await queryClient.invalidateQueries({ queryKey: ["admin.users"] });
if (canReadRoles) {
await queryClient.invalidateQueries({ queryKey: [rolesPath] });
}
};
const createUserMutation = useMutation({
mutationFn: async (values: CreateUserValues) => {
const response = await fetchWithAuth("/api/v1/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (!response.ok) throw new Error(await readApiError(response));
return response.json() as Promise<UserPublic>;
},
onSuccess: async () => {
setSuccess("用户已创建");
setError("");
createForm.resetFields();
setCreateUserModalOpen(false);
await refreshData();
},
onError: (candidate) => {
setSuccess("");
setError(candidate instanceof Error ? candidate.message : "创建用户失败");
},
});
const updateRolesMutation = useMutation({
mutationFn: async ({ userId, roleCodes }: { userId: string; roleCodes: string[] }) => {
const response = await fetchWithAuth(`/api/v1/users/${userId}/roles`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ role_codes: roleCodes } satisfies UserRolePayload),
});
if (!response.ok) throw new Error(await readApiError(response));
return response.json() as Promise<UserPublic>;
},
onMutate: ({ userId }) => {
setSavingUserId(userId);
setError("");
setSuccess("");
},
onSuccess: async () => {
setSuccess("用户角色已更新");
await refreshData();
},
onError: (mutationError) => {
setError(mutationError instanceof Error ? mutationError.message : "更新失败");
},
onSettled: () => setSavingUserId(null),
});
const resetPasswordMutation = useMutation({
mutationFn: async ({ userId, password }: { userId: string; password: string }) => {
const response = await fetchWithAuth(`/api/v1/users/${userId}/password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ new_password: password }),
});
if (!response.ok) throw new Error(await readApiError(response));
return response.json() as Promise<UserPublic>;
},
onMutate: ({ userId }) => {
setResettingUserId(userId);
setError("");
setSuccess("");
},
onSuccess: () => {
setSuccess("密码已重置");
setResetPasswordTarget(null);
resetPasswordForm.resetFields();
},
onError: (candidate) => {
setSuccess("");
setError(candidate instanceof Error ? candidate.message : "重置密码失败");
},
onSettled: () => setResettingUserId(null),
});
const updateUserProfileMutation = useMutation({
mutationFn: async ({ userId, payload }: {
userId: string;
payload: {
new_user_id?: string;
username?: string;
email?: string;
status?: "active" | "disabled";
};
}) => {
const response = await fetchWithAuth(`/api/v1/users/${userId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) throw new Error(await readApiError(response));
return response.json() as Promise<UserPublic>;
},
onMutate: ({ userId }) => {
setUpdatingStatusUserId(userId);
setError("");
setSuccess("");
},
onSuccess: async (_, variables) => {
if (variables.payload.status) {
setSuccess(variables.payload.status === "active" ? "用户已启用" : "用户已禁用");
} else {
setSuccess("用户信息已更新");
}
setEditingUser(null);
editUserForm.resetFields();
await refreshData();
},
onError: (candidate) => {
setSuccess("");
setError(candidate instanceof Error ? candidate.message : "更新用户信息失败");
},
onSettled: () => setUpdatingStatusUserId(null),
});
const deleteUserMutation = useMutation({
mutationFn: async (userId: string) => {
const response = await fetchWithAuth(`/api/v1/users/${userId}`, { method: "DELETE" });
if (!response.ok) throw new Error(await readApiError(response));
return response.json() as Promise<{ message: string }>;
},
onMutate: (userId) => {
setDeletingUserId(userId);
setError("");
setSuccess("");
},
onSuccess: async () => {
setSuccess("用户已删除");
await refreshData();
},
onError: (candidate) => {
setSuccess("");
setError(candidate instanceof Error ? candidate.message : "删除用户失败");
},
onSettled: () => setDeletingUserId(null),
});
const validateUserIdFormat = (userId: string): string | null => {
const trimmedId = userId.trim();
if (!trimmedId) return null;
const validPattern = /^[a-zA-Z0-9_]+$/;
if (!validPattern.test(trimmedId)) {
return "用户 ID 只能包含英文字母、数字和下划线";
}
return null;
};
const checkUserIdAvailability = async (userId: string, excludeUserId?: string) => {
try {
const params = new URLSearchParams({ user_id: userId });
if (excludeUserId) {
params.set("exclude_user_id", excludeUserId);
}
const response = await fetchWithAuth(`/api/v1/users/check-id/${encodeURIComponent(userId)}?${excludeUserId ? `exclude_user_id=${encodeURIComponent(excludeUserId)}` : ""}`);
if (!response.ok) {
return { available: false, message: "检查失败" };
}
return (await response.json()) as { available: boolean; message: string };
} catch {
return { available: false, message: "检查失败" };
}
};
const handleUserIdChange = async (value: string, isEdit: boolean = false) => {
if (userIdCheckTimeoutRef.current) {
clearTimeout(userIdCheckTimeoutRef.current);
}
const formatError = validateUserIdFormat(value);
if (formatError) {
if (isEdit) {
setEditUserIdValidationError(formatError);
} else {
setUserIdValidationError(formatError);
}
return;
}
const trimmedValue = value.trim();
if (!trimmedValue) {
if (isEdit) {
setEditUserIdValidationError("");
} else {
setUserIdValidationError("");
}
return;
}
setCheckingUserId(true);
userIdCheckTimeoutRef.current = setTimeout(async () => {
const result = await checkUserIdAvailability(
trimmedValue,
isEdit && editingUser ? editingUser.id : undefined
);
setCheckingUserId(false);
if (isEdit) {
setEditUserIdValidationError(result.available ? "" : result.message);
} else {
setUserIdValidationError(result.available ? "" : result.message);
}
}, 500);
};
const handleCreateUser = async (values: CreateUserValues) => {
setError("");
setSuccess("");
const payload: CreateUserValues = {
user_id: values.user_id.trim(),
username: values.username.trim(),
password: values.password,
};
if (values.email && values.email.trim()) {
payload.email = values.email.trim();
}
const formatError = validateUserIdFormat(payload.user_id);
if (formatError) {
setError(formatError);
return;
}
const availabilityCheck = await checkUserIdAvailability(payload.user_id);
if (!availabilityCheck.available) {
setError(availabilityCheck.message);
return;
}
const candidateEmail = payload.email?.toLowerCase();
const candidateUsername = payload.username.toLowerCase();
if (candidateEmail && existingEmails.has(candidateEmail)) {
setError("邮箱已存在,请更换后重试");
return;
}
if (existingUsernames.has(candidateUsername)) {
setError("用户名已存在,请更换后重试");
return;
}
createUserMutation.mutate(payload);
};
const openResetPasswordModal = (target: UserPublic) => {
setError("");
setSuccess("");
setResetPasswordTarget(target);
resetPasswordForm.resetFields();
};
const closeResetPasswordModal = () => {
if (resetPasswordMutation.isPending) return;
setResetPasswordTarget(null);
resetPasswordForm.resetFields();
};
const openAssignRolesModal = (target: UserPublic) => {
setError("");
setSuccess("");
setAssigningRolesUser(target);
roleForm.setFieldsValue({ role_codes: target.role_codes });
};
const closeAssignRolesModal = () => {
if (updateRolesMutation.isPending) return;
setAssigningRolesUser(null);
roleForm.resetFields();
};
const handleSubmitAssignRoles = async () => {
if (!assigningRolesUser) return;
try {
const values = await roleForm.validateFields();
updateRolesMutation.mutate(
{ userId: assigningRolesUser.id, roleCodes: values.role_codes },
{
onSuccess: () => {
closeAssignRolesModal();
},
},
);
} catch (validationError) {
// Validation errors are already shown in the form
}
};
const openEditUserModal = (target: UserPublic) => {
setError("");
setSuccess("");
setEditUserIdValidationError("");
setEditingUser(target);
editUserForm.setFieldsValue({
user_id: target.id,
username: target.username,
email: target.email,
status: target.status === "disabled" ? "disabled" : "active",
});
};
const closeEditUserModal = () => {
if (updateUserProfileMutation.isPending) return;
setEditingUser(null);
setEditUserIdValidationError("");
editUserForm.resetFields();
};
const handleSubmitEditUser = async (values: EditUserValues) => {
if (!editingUser) return;
const nextUserId = values.user_id.trim();
const nextUsername = values.username.trim();
const nextEmail = values.email ? values.email.trim().toLowerCase() : "";
const nextStatus = values.status;
const formatError = validateUserIdFormat(nextUserId);
if (formatError) {
setError(formatError);
return;
}
const payload: { new_user_id?: string; username?: string; email?: string; status?: "active" | "disabled" } = {};
if (nextUserId !== editingUser.id) {
const availabilityCheck = await checkUserIdAvailability(nextUserId, editingUser.id);
if (!availabilityCheck.available) {
setError(availabilityCheck.message);
return;
}
payload.new_user_id = nextUserId;
}
if (nextUsername !== editingUser.username) {
payload.username = nextUsername;
}
if (nextEmail && nextEmail !== editingUser.email.toLowerCase()) {
payload.email = nextEmail;
}
if (nextStatus !== editingUser.status) {
payload.status = nextStatus;
}
if (Object.keys(payload).length === 0) {
setSuccess("未检测到变更");
closeEditUserModal();
return;
}
updateUserProfileMutation.mutate({
userId: editingUser.id,
payload,
});
};
const handleSubmitResetPassword = (values: ResetPasswordValues) => {
if (!resetPasswordTarget) return;
resetPasswordMutation.mutate({ userId: resetPasswordTarget.id, password: values.password });
};
const openCreateUserModal = () => {
setError("");
setSuccess("");
setUserIdValidationError("");
createForm.resetFields();
setCreateUserModalOpen(true);
};
const closeCreateUserModal = () => {
if (createUserMutation.isPending) return;
setCreateUserModalOpen(false);
setUserIdValidationError("");
createForm.resetFields();
};
const handleSearch = () => {
setSearchKeyword(keywordInput);
setPagination((prev) => ({ ...prev, current: 1 }));
};
const queryError =
(usersQuery.error instanceof Error ? usersQuery.error.message : "")
|| (rolesQuery.error instanceof Error ? rolesQuery.error.message : "");
const anyError = error || queryError;
useToastFeedback({
errorMessage: anyError,
successMessage: success,
clearError: () => setError(""),
clearSuccess: () => setSuccess(""),
});
const updateTableScrollY = useCallback(() => {
if (typeof window === "undefined") {
return;
}
const anchor = tableScrollAnchorRef.current;
if (!anchor) {
return;
}
const anchorTop = anchor.getBoundingClientRect().top;
const tableWrapper = anchor.querySelector<HTMLElement>(".ant-table-wrapper");
const tableBody = anchor.querySelector<HTMLElement>(".ant-table-body");
let nextHeight = Math.floor(window.innerHeight - anchorTop - USERS_TABLE_FALLBACK_RESERVE);
if (tableWrapper) {
const wrapperRect = tableWrapper.getBoundingClientRect();
const bodyHeight = tableBody?.getBoundingClientRect().height ?? USERS_TABLE_MIN_SCROLL_Y;
const nonBodyHeight = Math.max(0, wrapperRect.height - bodyHeight);
const topGap = Math.max(0, wrapperRect.top - anchorTop);
nextHeight = Math.floor(window.innerHeight - anchorTop - topGap - nonBodyHeight - USERS_TABLE_VIEWPORT_GAP);
}
const clampedHeight = Math.max(USERS_TABLE_MIN_SCROLL_Y, nextHeight);
setTableScrollY((previous) => (Math.abs(previous - clampedHeight) <= 1 ? previous : clampedHeight));
}, []);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
window.requestAnimationFrame(updateTableScrollY);
}, [anyError, paginationCurrent, paginationPageSize, users.length, usersQuery.isFetching, updateTableScrollY]);
useEffect(() => {
return () => {
if (userIdCheckTimeoutRef.current) {
clearTimeout(userIdCheckTimeoutRef.current);
}
};
}, []);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const onViewportChange = () => {
window.requestAnimationFrame(updateTableScrollY);
};
window.addEventListener("resize", onViewportChange);
return () => {
window.removeEventListener("resize", onViewportChange);
};
}, [updateTableScrollY]);
useEffect(() => {
if (typeof window === "undefined" || typeof ResizeObserver === "undefined") {
return;
}
const anchor = tableScrollAnchorRef.current;
if (!anchor) {
return;
}
const resizeObserver = new ResizeObserver(() => {
window.requestAnimationFrame(updateTableScrollY);
});
resizeObserver.observe(anchor);
return () => {
resizeObserver.disconnect();
};
}, [updateTableScrollY]);
const columns: ColumnsType<UserPublic> = [
{
title: "用户 ID",
dataIndex: "id",
width: 140,
},
{
title: "用户名",
dataIndex: "username",
width: 140,
},
{
title: "角色",
dataIndex: "role_codes",
width: 180,
render: (roleCodes: string[]) => (
<Space wrap size={[4, 4]}>
{roleCodes && roleCodes.length > 0 ? (
roleCodes.map((code) => (
<Tag key={code} color="blue">
{getRoleName(code)}
</Tag>
))
) : (
<Typography.Text type="secondary">-</Typography.Text>
)}
</Space>
),
},
{
title: "邮箱",
dataIndex: "email",
width: 200,
},
{
title: "状态",
dataIndex: "status",
width: 100,
align: "center",
render: (value: string) => (
<Tag color={value === "active" ? "green" : "default"}>{statusLabel(value)}</Tag>
),
},
{
title: "操作",
key: "actions",
width: 180,
render: (_value, row) => {
const updatingLoading = updatingStatusUserId === row.id;
const resetLoading = resettingUserId === row.id;
const deleteLoading = deletingUserId === row.id;
const savingLoading = savingUserId === row.id;
const rowBusy = updatingLoading || resetLoading || deleteLoading || savingLoading;
const moreMenuItems: MenuProps["items"] = [
{
key: "assign-roles",
label: "分配角色",
disabled: rowBusy,
onClick: () => openAssignRolesModal(row),
},
{
key: "toggle-status",
label: row.status === "active" ? "禁用" : "启用",
disabled: rowBusy || row.id === user?.id,
onClick: () => {
if (row.id === user?.id) {
setError("不能修改当前登录账号的状态");
return;
}
const nextStatus: "active" | "disabled" = row.status === "active" ? "disabled" : "active";
updateUserProfileMutation.mutate({ userId: row.id, payload: { status: nextStatus } });
},
},
{
key: "reset-password",
label: "重置密码",
disabled: rowBusy,
onClick: () => openResetPasswordModal(row),
},
];
return (
<Space wrap>
<Button
size="small"
disabled={rowBusy}
onClick={() => openEditUserModal(row)}
>
编辑
</Button>
<Popconfirm
title={`确认删除用户 ${row.username}${row.id})?`}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true, loading: deleteLoading }}
onConfirm={() => deleteUserMutation.mutate(row.id)}
disabled={rowBusy}
>
<Button danger size="small" loading={deleteLoading} disabled={rowBusy}>
删除
</Button>
</Popconfirm>
<Dropdown menu={{ items: moreMenuItems }} trigger={["click"]}>
<Button size="small" disabled={rowBusy} icon={<MoreOutlined />} />
</Dropdown>
</Space>
);
},
},
];
const renderUserCard = (userItem: UserPublic) => {
const updatingLoading = updatingStatusUserId === userItem.id;
const resetLoading = resettingUserId === userItem.id;
const deleteLoading = deletingUserId === userItem.id;
const savingLoading = savingUserId === userItem.id;
const rowBusy = updatingLoading || resetLoading || deleteLoading || savingLoading;
const moreMenuItems: MenuProps["items"] = [
{
key: "assign-roles",
label: "分配角色",
disabled: rowBusy,
onClick: () => openAssignRolesModal(userItem),
},
{
key: "toggle-status",
label: userItem.status === "active" ? "禁用" : "启用",
disabled: rowBusy || userItem.id === user?.id,
onClick: () => {
if (userItem.id === user?.id) {
setError("不能修改当前登录账号的状态");
return;
}
const nextStatus: "active" | "disabled" = userItem.status === "active" ? "disabled" : "active";
updateUserProfileMutation.mutate({ userId: userItem.id, payload: { status: nextStatus } });
},
},
{
key: "reset-password",
label: "重置密码",
disabled: rowBusy,
onClick: () => openResetPasswordModal(userItem),
},
];
return (
<AntCard
key={userItem.id}
className="admin-users-user-card"
size="small"
title={
<Space className="min-w-0" size={8}>
<Typography.Text strong>{userItem.username}</Typography.Text>
<Tag color={userItem.status === "active" ? "green" : "default"}>
{statusLabel(userItem.status)}
</Tag>
</Space>
}
extra={
<Dropdown menu={{ items: moreMenuItems }} trigger={["click"]}>
<Button size="small" disabled={rowBusy} icon={<MoreOutlined />} />
</Dropdown>
}
onClick={() => !rowBusy && openEditUserModal(userItem)}
style={{ cursor: rowBusy ? "default" : "pointer" }}
>
<Space direction="vertical" size={10} style={{ width: "100%" }}>
<div className="admin-users-user-card-field">
<Typography.Text type="secondary">用户 ID</Typography.Text>
<Typography.Text copyable>{userItem.id}</Typography.Text>
</div>
<div className="admin-users-user-card-field">
<Typography.Text type="secondary">角色</Typography.Text>
<Space wrap size={[4, 4]}>
{userItem.role_codes && userItem.role_codes.length > 0 ? (
userItem.role_codes.map((code) => (
<Tag key={code} color="blue">
{getRoleName(code)}
</Tag>
))
) : (
<Typography.Text type="secondary">-</Typography.Text>
)}
</Space>
</div>
<div className="admin-users-user-card-field">
<Typography.Text type="secondary">邮箱</Typography.Text>
<Typography.Text ellipsis={{ tooltip: userItem.email || "-" }}>
{userItem.email || "-"}
</Typography.Text>
</div>
</Space>
</AntCard>
);
};
if (initializing) {
return (
<div className="flex min-h-[240px] items-center justify-center">
<Spin tip="初始化中..." />
</div>
);
}
if (!user) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col justify-center gap-4 px-6 py-20">
<p className="text-sm text-[var(--gray-11)]">请先登录后再访问用户管理页面。</p>
<Link
href="/"
className="inline-flex w-fit items-center justify-center rounded-md border border-[var(--gray-6)] bg-[var(--gray-a2)] px-4 py-2 text-sm font-medium text-[var(--gray-12)] transition hover:bg-[var(--gray-a3)]"
>
返回首页
</Link>
</main>
);
}
if (!canManage) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col justify-center gap-4 px-6 py-20">
<p className="text-sm text-[var(--gray-11)]">你没有访问该页面的权限(需要 `user.manage`)。</p>
<Link
href="/"
className="inline-flex w-fit items-center justify-center rounded-md border border-[var(--gray-6)] bg-[var(--gray-a2)] px-4 py-2 text-sm font-medium text-[var(--gray-12)] transition hover:bg-[var(--gray-a3)]"
>
返回首页
</Link>
</main>
);
}
return (
<div className="flex min-h-0 flex-1 flex-col">
<AntCard
className="admin-users-page-card"
title="用户管理"
extra={(
<Button type="primary" onClick={openCreateUserModal}>
新增用户
</Button>
)}
>
<Form layout="inline" style={{ rowGap: 12 }}>
<Form.Item label="关键词" style={{ width: 260 }}>
<Input
allowClear
placeholder="按用户 ID/邮箱/用户名搜索"
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
onPressEnter={handleSearch}
/>
</Form.Item>
<Form.Item label="状态" style={{ width: 170 }}>
<Select<"active" | "disabled">
value={statusFilter}
allowClear
placeholder="全部"
options={[
{ value: "active", label: "已启用" },
{ value: "disabled", label: "已禁用" },
]}
onChange={(value) => {
setStatusFilter(value);
setPagination((prev) => ({ ...prev, current: 1 }));
}}
/>
</Form.Item>
<Form.Item>
<Button type="primary" onClick={handleSearch}>
搜索
</Button>
</Form.Item>
</Form>
{viewMode === "table" ? (
<div
ref={tableScrollAnchorRef}
className="admin-users-table-anchor mt-4"
style={{ "--admin-users-table-body-min-height": `${tableScrollY}px` } as CSSProperties}
>
<Table<UserPublic>
rowKey="id"
dataSource={users}
columns={columns}
loading={usersQuery.isLoading || rolesQuery.isLoading}
tableLayout="fixed"
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: Math.max(usersQuery.data?.total ?? 0, 1),
showSizeChanger: true,
pageSizeOptions: [10, 20, 50, 100],
showTotal: () => `共 ${usersQuery.data?.total ?? 0} 条`,
hideOnSinglePage: false,
style: { marginBottom: 0 },
onChange: (page, pageSize) => {
setPagination({ current: page, pageSize });
},
}}
scroll={{ y: tableScrollY }}
locale={{
emptyText: (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="未找到符合筛选条件的用户。"
/>
),
}}
/>
</div>
) : (
<div className="admin-users-card-view mt-4">
{usersQuery.isLoading || rolesQuery.isLoading ? (
<div className="admin-users-card-view-state">
<Spin tip="加载中..." />
</div>
) : users.length === 0 ? (
<div className="admin-users-card-view-state">
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="未找到符合筛选条件的用户。"
/>
</div>
) : (
<div className="admin-users-card-view-content">
<Row gutter={[12, 12]}>
{users.map((userItem) => (
<Col key={userItem.id} xs={24} sm={24} md={12} lg={8} xl={6}>
{renderUserCard(userItem)}
</Col>
))}
</Row>
<div style={{ marginTop: 16, display: "flex", justifyContent: "center" }}>
<Space direction="vertical" size={12} style={{ width: "100%", alignItems: "center" }}>
<Typography.Text type="secondary">
{usersQuery.data?.total ?? 0}
</Typography.Text>
<Space wrap>
<Button
icon={<LeftOutlined />}
disabled={pagination.current === 1}
onClick={() => setPagination((prev) => ({ ...prev, current: prev.current - 1 }))}
/>
<Typography.Text>
{pagination.current} / {Math.ceil((usersQuery.data?.total ?? 0) / pagination.pageSize)}
</Typography.Text>
<Button
icon={<RightOutlined />}
disabled={pagination.current >= Math.ceil((usersQuery.data?.total ?? 0) / pagination.pageSize)}
onClick={() => setPagination((prev) => ({ ...prev, current: prev.current + 1 }))}
/>
</Space>
</Space>
</div>
</div>
)}
</div>
)}
</AntCard>
<Modal
title="新增用户"
open={createUserModalOpen}
destroyOnClose
onCancel={closeCreateUserModal}
onOk={() => createForm.submit()}
okText="创建用户"
cancelText="取消"
confirmLoading={createUserMutation.isPending}
>
<Form<CreateUserValues>
form={createForm}
layout="vertical"
onFinish={handleCreateUser}
autoComplete="off"
>
<Form.Item
label="用户 ID"
name="user_id"
validateStatus={userIdValidationError ? "error" : ""}
help={userIdValidationError}
rules={[
{ required: true, message: "请输入用户 ID" },
{ min: 3, message: "用户 ID 至少 3 位" },
{ max: 64, message: "用户 ID 不能超过 64 位" },
]}
>
<Input
placeholder="例如 ck001"
onChange={(e) => handleUserIdChange(e.target.value, false)}
/>
</Form.Item>
<Form.Item
label="用户名"
name="username"
rules={[
{ required: true, message: "请输入用户名" },
{ min: 3, message: "用户名至少 3 位" },
{ max: 64, message: "用户名不能超过 64 位" },
]}
>
<Input placeholder="请输入用户名" />
</Form.Item>
<Form.Item
label="初始密码"
name="password"
rules={[
{ required: true, message: "请输入初始密码" },
{ min: 8, message: "密码至少 8 位" },
{ max: 128, message: "密码不能超过 128 位" },
]}
>
<Input.Password placeholder="至少 8 位" />
</Form.Item>
<Form.Item
label="邮箱"
name="email"
rules={[
{ type: "email", message: "邮箱格式不正确" },
]}
>
<Input placeholder="请输入邮箱(可选)" />
</Form.Item>
</Form>
</Modal>
<Modal
title={editingUser ? `编辑用户:${editingUser.username}${editingUser.id}` : "编辑用户"}
open={!!editingUser}
destroyOnClose
onCancel={closeEditUserModal}
onOk={() => editUserForm.submit()}
okText="保存"
cancelText="取消"
confirmLoading={updateUserProfileMutation.isPending}
>
<Form<EditUserValues>
form={editUserForm}
layout="vertical"
onFinish={handleSubmitEditUser}
autoComplete="off"
>
<Form.Item
label="用户 ID"
name="user_id"
validateStatus={editUserIdValidationError ? "error" : ""}
help={editUserIdValidationError}
rules={[
{ required: true, message: "请输入用户 ID" },
{ min: 3, message: "用户 ID 至少 3 位" },
{ max: 64, message: "用户 ID 不能超过 64 位" },
]}
>
<Input
placeholder="请输入用户 ID"
onChange={(e) => handleUserIdChange(e.target.value, true)}
/>
</Form.Item>
<Form.Item
label="用户名"
name="username"
rules={[
{ required: true, message: "请输入用户名" },
{ min: 3, message: "用户名至少 3 位" },
{ max: 64, message: "用户名不能超过 64 位" },
]}
>
<Input placeholder="请输入用户名" />
</Form.Item>
<Form.Item
label="邮箱"
name="email"
rules={[
{ type: "email", message: "邮箱格式不正确" },
]}
>
<Input placeholder="请输入邮箱" />
</Form.Item>
</Form>
</Modal>
<Modal
title={resetPasswordTarget ? `重置密码:${resetPasswordTarget.username}${resetPasswordTarget.id}` : "重置密码"}
open={!!resetPasswordTarget}
destroyOnClose
onCancel={closeResetPasswordModal}
onOk={() => resetPasswordForm.submit()}
okText="确认重置"
cancelText="取消"
confirmLoading={
!!resetPasswordTarget
&& resettingUserId === resetPasswordTarget.id
&& resetPasswordMutation.isPending
}
>
<Form<ResetPasswordValues>
form={resetPasswordForm}
layout="vertical"
onFinish={handleSubmitResetPassword}
autoComplete="off"
>
<Form.Item
label="新密码"
name="password"
rules={[
{ required: true, message: "请输入新密码" },
{ min: 8, message: "新密码至少 8 位" },
{ max: 128, message: "新密码不能超过 128 位" },
]}
>
<Input.Password placeholder="请输入新密码" />
</Form.Item>
</Form>
</Modal>
<Modal
title={assigningRolesUser ? `分配角色:${assigningRolesUser.username}${assigningRolesUser.id}` : "分配角色"}
open={!!assigningRolesUser}
destroyOnClose
onCancel={closeAssignRolesModal}
onOk={handleSubmitAssignRoles}
okText="保存"
cancelText="取消"
confirmLoading={updateRolesMutation.isPending && savingUserId === assigningRolesUser?.id}
>
<Form<{ role_codes: string[] }>
form={roleForm}
layout="vertical"
onFinish={(values) => {
if (!assigningRolesUser) return;
updateRolesMutation.mutate(
{ userId: assigningRolesUser.id, roleCodes: values.role_codes },
{
onSuccess: () => {
closeAssignRolesModal();
},
},
);
}}
autoComplete="off"
>
<Form.Item
label="角色"
name="role_codes"
rules={[{ required: true, message: "请至少选择一个角色" }]}
>
<Select
mode="multiple"
placeholder="请选择角色"
options={roleOptions.map((code) => ({ label: getRoleName(code), value: code }))}
allowClear
/>
</Form.Item>
</Form>
</Modal>
</div>
);
}