Files
fquiz/web/src/app/admin/users/page.tsx
T

369 lines
14 KiB
TypeScript
Raw Normal View History

"use client";
2026-04-12 16:00:20 +08:00
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { ChangeEvent, FormEvent, useCallback, useMemo, useState } from "react";
import { useAuth } from "@/components/auth-provider";
2026-04-17 21:55:27 +08:00
import { Button, TextField } from "@radix-ui/themes";
2026-04-12 16:00:20 +08:00
import { useTopicSubscription } from "@/hooks/use-topic-subscription";
import { readApiError } from "@/lib/api";
2026-04-12 16:00:20 +08:00
import type { RoleItem, RoleListResponse, UserListResponse, UserPublic } from "@/types/auth";
type UserRolePayload = {
role_codes: string[];
};
export default function AdminUsersPage() {
const { user, initializing, fetchWithAuth, hasPermission } = useAuth();
2026-04-12 16:00:20 +08:00
const queryClient = useQueryClient();
const [savingUserId, setSavingUserId] = useState<string | null>(null);
2026-04-17 21:55:27 +08:00
const [deletingUserId, setDeletingUserId] = useState<string | null>(null);
const [resettingUserId, setResettingUserId] = useState<string | null>(null);
const [newUserId, setNewUserId] = useState("");
const [newEmail, setNewEmail] = useState("");
const [newUsername, setNewUsername] = useState("");
const [newPassword, setNewPassword] = useState("");
const [error, setError] = useState("");
2026-04-12 16:00:20 +08:00
const [success, setSuccess] = useState("");
const canManage = hasPermission("user.manage");
const canReadRoles = hasPermission("role.read") || hasPermission("role.manage");
2026-04-17 21:55:27 +08:00
const usersPath = "/api/v1/users?limit=200&offset=0";
const rolesPath = "/api/v1/admin/roles";
2026-04-12 16:00:20 +08:00
const loadUsers = useCallback(async () => {
2026-04-17 21:55:27 +08:00
const response = await fetchWithAuth(usersPath);
if (!response.ok) throw new Error(await readApiError(response));
2026-04-12 16:00:20 +08:00
return (await response.json()) as UserListResponse;
}, [fetchWithAuth]);
const loadRoles = useCallback(async () => {
2026-04-17 21:55:27 +08:00
const response = await fetchWithAuth(rolesPath);
if (!response.ok) throw new Error(await readApiError(response));
2026-04-12 16:00:20 +08:00
return (await response.json()) as RoleListResponse;
}, [fetchWithAuth]);
const usersQuery = useQuery({
2026-04-17 21:55:27 +08:00
queryKey: [usersPath],
2026-04-12 16:00:20 +08:00
queryFn: loadUsers,
enabled: !!user && canManage,
});
2026-04-12 16:00:20 +08:00
const rolesQuery = useQuery({
2026-04-17 21:55:27 +08:00
queryKey: [rolesPath],
2026-04-12 16:00:20 +08:00
queryFn: loadRoles,
enabled: !!user && canManage && canReadRoles,
});
useTopicSubscription(
"admin.users",
useCallback(() => {
2026-04-17 21:55:27 +08:00
if (!user || !canManage) return;
void queryClient.invalidateQueries({ queryKey: [usersPath] });
2026-04-12 16:00:20 +08:00
if (canReadRoles) {
2026-04-17 21:55:27 +08:00
void queryClient.invalidateQueries({ queryKey: [rolesPath] });
2026-04-12 16:00:20 +08:00
}
}, [canManage, canReadRoles, queryClient, user]),
);
const users = useMemo(() => usersQuery.data?.items ?? [], [usersQuery.data?.items]);
const roles = useMemo<RoleItem[]>(() => {
2026-04-17 21:55:27 +08:00
if (canReadRoles) return rolesQuery.data?.items ?? [];
2026-04-12 16:00:20 +08:00
return Array.from(new Set(users.flatMap((item) => item.role_codes))).map((code, index) => ({
id: -(index + 1),
code,
name: code,
permission_codes: [],
menu_ids: [],
} satisfies RoleItem));
}, [canReadRoles, rolesQuery.data?.items, users]);
2026-04-12 16:00:20 +08:00
const roleOptions = useMemo(() => roles.map((item) => item.code), [roles]);
2026-04-17 21:55:27 +08:00
const refreshData = async () => {
await queryClient.invalidateQueries({ queryKey: [usersPath] });
if (canReadRoles) {
await queryClient.invalidateQueries({ queryKey: [rolesPath] });
}
};
const createUserMutation = useMutation({
mutationFn: async () => {
const response = await fetchWithAuth("/api/v1/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: newUserId.trim(),
email: newEmail.trim(),
username: newUsername.trim(),
password: newPassword,
}),
});
if (!response.ok) throw new Error(await readApiError(response));
return response.json() as Promise<UserPublic>;
},
onSuccess: async () => {
setSuccess("用户已创建");
setError("");
setNewUserId("");
setNewEmail("");
setNewUsername("");
setNewPassword("");
await refreshData();
},
onError: (candidate) => {
setSuccess("");
setError(candidate instanceof Error ? candidate.message : "创建用户失败");
},
});
2026-04-12 16:00:20 +08:00
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),
});
2026-04-17 21:55:27 +08:00
if (!response.ok) throw new Error(await readApiError(response));
2026-04-12 16:00:20 +08:00
return response.json() as Promise<UserPublic>;
},
onMutate: ({ userId }) => {
setSavingUserId(userId);
setError("");
setSuccess("");
},
onSuccess: async () => {
setSuccess("用户角色已更新");
2026-04-17 21:55:27 +08:00
await refreshData();
2026-04-12 16:00:20 +08:00
},
onError: (mutationError) => {
setError(mutationError instanceof Error ? mutationError.message : "更新失败");
},
2026-04-17 21:55:27 +08:00
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("密码已重置"),
onError: (candidate) => {
setSuccess("");
setError(candidate instanceof Error ? candidate.message : "重置密码失败");
2026-04-12 16:00:20 +08:00
},
2026-04-17 21:55:27 +08:00
onSettled: () => setResettingUserId(null),
2026-04-12 16:00:20 +08:00
});
2026-04-17 21:55:27 +08:00
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 handleCreateUser = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError("");
setSuccess("");
createUserMutation.mutate();
};
const anyError =
error
2026-04-12 16:00:20 +08:00
|| (usersQuery.error instanceof Error ? usersQuery.error.message : "")
|| (rolesQuery.error instanceof Error ? rolesQuery.error.message : "");
2026-04-12 16:00:20 +08:00
if (initializing || usersQuery.isLoading || rolesQuery.isLoading) {
return <p className="text-sm text-muted">Loading users...</p>;
}
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-muted">请先登录后再访问用户管理页面。</p>
<Link href="/" className="btn-secondary w-fit">返回首页</Link>
</main>
);
}
2026-04-12 16:00:20 +08:00
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-muted">你没有访问该页面的权限(需要 `user.manage`)。</p>
<Link href="/" className="btn-secondary w-fit">返回首页</Link>
</main>
);
}
return (
2026-04-12 16:00:20 +08:00
<div className="space-y-6">
2026-04-17 21:55:27 +08:00
{anyError && <pre className="notice notice-error">{anyError}</pre>}
{success && <pre className="notice notice-success">{success}</pre>}
<section className="surface-card">
<h2 className="text-lg font-semibold">新增用户</h2>
<p className="mt-1 text-sm text-muted">用户 ID 由管理员手动填写,系统会校验重复。</p>
<form className="mt-4 grid gap-3 md:grid-cols-2" onSubmit={handleCreateUser}>
<TextField.Root
placeholder="用户 ID(例如 ck001"
value={newUserId}
onChange={(event: ChangeEvent<HTMLInputElement>) => setNewUserId(event.currentTarget.value)}
minLength={3}
maxLength={64}
required
/>
<TextField.Root
placeholder="邮箱"
type="email"
value={newEmail}
onChange={(event: ChangeEvent<HTMLInputElement>) => setNewEmail(event.currentTarget.value)}
required
/>
<TextField.Root
placeholder="用户名"
value={newUsername}
onChange={(event: ChangeEvent<HTMLInputElement>) => setNewUsername(event.currentTarget.value)}
minLength={3}
maxLength={64}
required
/>
<TextField.Root
placeholder="初始密码(至少8位)"
type="password"
value={newPassword}
onChange={(event: ChangeEvent<HTMLInputElement>) => setNewPassword(event.currentTarget.value)}
minLength={8}
maxLength={128}
required
/>
2026-04-17 21:55:27 +08:00
<div className="md:col-span-2">
<Button type="submit" disabled={createUserMutation.isPending}>
{createUserMutation.isPending ? "创建中..." : "创建用户"}
</Button>
</div>
</form>
</section>
2026-04-12 16:00:20 +08:00
<section className="surface-card">
2026-04-12 16:00:20 +08:00
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">用户列表</h2>
2026-04-17 21:55:27 +08:00
<p className="mt-1 text-sm text-muted">表头已中文化,支持改角色、重置密码、删除。</p>
2026-04-12 16:00:20 +08:00
</div>
</div>
2026-04-12 16:00:20 +08:00
<div className="overflow-x-auto">
<table className="table-modern min-w-full text-left text-sm">
<thead className="table-head">
2026-04-12 16:00:20 +08:00
<tr>
2026-04-17 21:55:27 +08:00
<th className="px-4 py-3 font-medium">用户ID</th>
<th className="px-4 py-3 font-medium">邮箱</th>
<th className="px-4 py-3 font-medium">用户名</th>
<th className="px-4 py-3 font-medium">状态</th>
<th className="px-4 py-3 font-medium">角色</th>
<th className="px-4 py-3 font-medium">权限</th>
2026-04-12 16:00:20 +08:00
<th className="px-4 py-3 font-medium">操作</th>
</tr>
2026-04-12 16:00:20 +08:00
</thead>
<tbody className="table-body divide-y">
2026-04-12 16:00:20 +08:00
{users.map((item) => (
<tr key={item.id}>
<td className="whitespace-nowrap px-4 py-3 font-mono text-xs">{item.id}</td>
<td className="whitespace-nowrap px-4 py-3">{item.email}</td>
<td className="whitespace-nowrap px-4 py-3">{item.username}</td>
<td className="whitespace-nowrap px-4 py-3">{item.status}</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-2">
{roleOptions.map((roleCode) => {
const checked = item.role_codes.includes(roleCode);
return (
<label key={roleCode} className="flex items-center gap-1 rounded-full border border-[var(--border)] bg-white/80 px-2 py-1 text-xs">
2026-04-12 16:00:20 +08:00
<input
type="checkbox"
checked={checked}
disabled={savingUserId === item.id}
2026-04-17 21:55:27 +08:00
className="accent-indigo-600"
onChange={(event: ChangeEvent<HTMLInputElement>) => {
const nextRoles = event.currentTarget.checked
2026-04-12 16:00:20 +08:00
? [...item.role_codes, roleCode]
: item.role_codes.filter((code) => code !== roleCode);
updateRolesMutation.mutate({ userId: item.id, roleCodes: nextRoles });
}}
/>
<span>{roleCode}</span>
</label>
);
})}
</div>
</td>
<td className="px-4 py-3">{item.permission_codes.join(", ") || "-"}</td>
2026-04-17 21:55:27 +08:00
<td className="px-4 py-3">
<div className="flex flex-wrap items-center gap-2 text-xs">
<button
type="button"
className="btn-secondary btn-small"
disabled={resettingUserId === item.id}
onClick={() => {
const pwd = window.prompt(`请输入用户 ${item.username} 的新密码(至少8位)`);
if (!pwd) return;
if (pwd.length < 8) {
setError("新密码长度至少 8 位");
return;
}
resetPasswordMutation.mutate({ userId: item.id, password: pwd });
}}
>
{resettingUserId === item.id ? "重置中..." : "改密码"}
</button>
<button
type="button"
className="btn-secondary btn-small"
disabled={deletingUserId === item.id}
onClick={() => {
const confirmed = window.confirm(`确认删除用户 ${item.username}${item.id})?`);
if (!confirmed) return;
deleteUserMutation.mutate(item.id);
}}
>
{deletingUserId === item.id ? "删除中..." : "删除"}
</button>
</div>
</td>
2026-04-12 16:00:20 +08:00
</tr>
))}
</tbody>
</table>
</div>
</section>
</div>
);
}