feat: 增加管理端多语言与多模块界面国际化支持

This commit is contained in:
2026-05-19 09:11:55 +08:00
parent 49a4caf01e
commit 1b1dfc92ab
110 changed files with 4053 additions and 1308 deletions

View File

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { ChevronDown } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
@@ -40,6 +41,7 @@ import { useAdminProfile } from "@/stores/admin-session";
import type { AdminPermissionCatalogData, AdminUserPermissionRow } from "@/types/api/index";
export function AdminUsersConsole(): React.ReactElement {
const { t } = useTranslation(["adminUsers", "common"]);
const profile = useAdminProfile();
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(25);
@@ -59,7 +61,7 @@ export function AdminUsersConsole(): React.ReactElement {
const [saving, setSaving] = useState(false);
const [savingRoles, setSavingRoles] = useState(false);
const [permissionOpen, setPermissionOpen] = useState(false);
/** `false` = 折叠;缺省为展开 */
/** `false` = collapsed; default expanded */
const [directMenuExpanded, setDirectMenuExpanded] = useState<Record<string, boolean>>({});
const [accountOpen, setAccountOpen] = useState(false);
@@ -129,16 +131,16 @@ export function AdminUsersConsole(): React.ReactElement {
async function submitAccount(): Promise<void> {
const nick = formNickname.trim();
if (nick === "") {
toast.error("请填写昵称");
toast.error(t("nicknameRequired"));
return;
}
if (accountMode === "edit" && formPassword !== "" && formPassword.length < 8) {
toast.error("新密码至少 8 位");
toast.error(t("newPasswordMin"));
return;
}
if (accountMode === "create" && formCreateRoles.length === 0) {
toast.error("请至少选择一个角色");
toast.error(t("roleRequired"));
return;
}
@@ -147,11 +149,11 @@ export function AdminUsersConsole(): React.ReactElement {
if (accountMode === "create") {
const u = formUsername.trim();
if (u === "") {
toast.error("请填写登录账号");
toast.error(t("usernameRequired"));
return;
}
if (formPassword.length < 8) {
toast.error("密码至少 8 位");
toast.error(t("passwordMin"));
return;
}
const created = await postAdminUser({
@@ -164,7 +166,7 @@ export function AdminUsersConsole(): React.ReactElement {
});
setItems((prev) => [created, ...prev]);
setTotal((t) => t + 1);
toast.success(`已创建管理员 ${created.username}`);
toast.success(t("createSuccess", { name: created.username }));
handleAccountDialogOpenChange(false);
} else {
const id = editingAccountId;
@@ -186,11 +188,11 @@ export function AdminUsersConsole(): React.ReactElement {
}
const updated = await putAdminUser(id, body);
setItems((prev) => prev.map((row) => (row.id === updated.id ? updated : row)));
toast.success(`已更新 ${updated.username}`);
toast.success(t("updateSuccess", { name: updated.username }));
handleAccountDialogOpenChange(false);
}
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : "保存账号失败";
const msg = e instanceof LotteryApiBizError ? e.message : t("saveAccountFailed");
toast.error(msg);
} finally {
setAccountSaving(false);
@@ -206,10 +208,10 @@ export function AdminUsersConsole(): React.ReactElement {
await deleteAdminUser(deleteTarget.id);
setItems((prev) => prev.filter((r) => r.id !== deleteTarget.id));
setTotal((t) => Math.max(0, t - 1));
toast.success(`已删除 ${deleteTarget.username}`);
toast.success(t("deleteSuccess", { name: deleteTarget.username }));
setDeleteTarget(null);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : "删除失败";
const msg = e instanceof LotteryApiBizError ? e.message : t("deleteFailed");
toast.error(msg);
} finally {
setDeleteBusy(false);
@@ -229,10 +231,10 @@ export function AdminUsersConsole(): React.ReactElement {
}
const flat = catalog?.permissions ?? [];
if (flat.length > 0) {
return [{ key: "all", label: "全部权限", permissions: flat }];
return [{ key: "all", label: t("allPermissions"), permissions: flat }];
}
return [];
}, [catalog]);
}, [catalog, t]);
function isDirectGroupOpen(key: string): boolean {
return directMenuExpanded[key] !== false;
@@ -271,7 +273,7 @@ export function AdminUsersConsole(): React.ReactElement {
setTotal(listData.meta.total);
setLastPage(Math.max(1, listData.meta.last_page));
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : "加载管理员列表失败";
const msg = e instanceof LotteryApiBizError ? e.message : t("loadFailed");
setErr(msg);
setItems([]);
setTotal(0);
@@ -279,7 +281,7 @@ export function AdminUsersConsole(): React.ReactElement {
} finally {
setLoading(false);
}
}, [page, perPage, query]);
}, [page, perPage, query, t]);
useEffect(() => {
queueMicrotask(() => {
@@ -324,9 +326,9 @@ export function AdminUsersConsole(): React.ReactElement {
: row,
),
);
toast.success(`已更新 ${result.username} 的角色`);
toast.success(t("saveRoleSuccess", { name: result.username }));
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : "保存角色失败";
const msg = e instanceof LotteryApiBizError ? e.message : t("saveRoleFailed");
toast.error(msg);
} finally {
setSavingRoles(false);
@@ -354,9 +356,9 @@ export function AdminUsersConsole(): React.ReactElement {
: row,
),
);
toast.success(`已更新 ${result.username} 的权限`);
toast.success(t("savePermissionSuccess", { name: result.username }));
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : "保存权限失败";
const msg = e instanceof LotteryApiBizError ? e.message : t("savePermissionFailed");
toast.error(msg);
} finally {
setSaving(false);
@@ -368,15 +370,15 @@ export function AdminUsersConsole(): React.ReactElement {
<Card>
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<CardTitle></CardTitle>
<CardTitle>{t("listTitle")}</CardTitle>
<Button type="button" size="sm" onClick={() => openCreateAccount()}>
{t("createAdmin")}
</Button>
</div>
<div className="flex w-full max-w-lg gap-2">
<Input
value={keyword}
placeholder="按用户名 / 昵称 / 邮箱搜索"
placeholder={t("searchPlaceholder")}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
@@ -392,37 +394,37 @@ export function AdminUsersConsole(): React.ReactElement {
setQuery(keyword.trim());
}}
>
{t("actions.search", { ns: "common" })}
</Button>
<Button type="button" variant="secondary" onClick={() => void load()}>
{t("actions.refresh", { ns: "common" })}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
{loading && items.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
) : null}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-20 whitespace-nowrap"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="min-w-[11rem]"></TableHead>
<TableHead>{t("table.account")}</TableHead>
<TableHead>{t("table.nickname")}</TableHead>
<TableHead className="w-20 whitespace-nowrap">{t("table.status")}</TableHead>
<TableHead>{t("table.roles")}</TableHead>
<TableHead>{t("table.direct")}</TableHead>
<TableHead>{t("table.effective")}</TableHead>
<TableHead className="min-w-[11rem]">{t("table.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
) : (
@@ -439,18 +441,18 @@ export function AdminUsersConsole(): React.ReactElement {
<TableCell>
{row.status === 0 ? (
<Badge variant="secondary" className="font-normal">
{t("status.enabled")}
</Badge>
) : (
<Badge variant="outline" className="border-amber-600/50 text-amber-800 dark:text-amber-400">
{t("status.disabled")}
</Badge>
)}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{row.roles.length === 0 ? (
<span className="text-xs text-muted-foreground"></span>
<span className="text-xs text-muted-foreground">{t("common.none")}</span>
) : (
row.roles.map((slug) => (
<Badge key={slug} variant="secondary">
@@ -472,7 +474,7 @@ export function AdminUsersConsole(): React.ReactElement {
openPermissionEditor(row);
}}
>
{t("actions.permissions")}
</Button>
<Button
type="button"
@@ -482,7 +484,7 @@ export function AdminUsersConsole(): React.ReactElement {
}
onClick={() => openEditAccount(row)}
>
{t("actions.edit")}
</Button>
<Button
type="button"
@@ -490,11 +492,13 @@ export function AdminUsersConsole(): React.ReactElement {
variant="destructive"
disabled={profile?.id === row.id}
title={
profile?.id === row.id ? "不能删除当前登录账号" : "删除该管理员"
profile?.id === row.id
? t("delete.currentUserBlocked")
: t("delete.rowActionTitle")
}
onClick={() => setDeleteTarget(row)}
>
{t("actions.delete")}
</Button>
</div>
</TableCell>
@@ -526,7 +530,7 @@ export function AdminUsersConsole(): React.ReactElement {
className="flex h-[min(88vh,800px)] max-h-[90vh] w-[calc(100%-2rem)] max-w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:h-[min(85vh,780px)] sm:max-w-3xl"
>
<DialogHeader className="shrink-0 space-y-1 border-b px-4 py-3 pr-12">
<DialogTitle></DialogTitle>
<DialogTitle>{t("permissionDialog.title")}</DialogTitle>
<DialogDescription>
{selectedUser ? (
<>
@@ -541,9 +545,9 @@ export function AdminUsersConsole(): React.ReactElement {
<div className="space-y-6 pb-1">
<section className="space-y-3">
<div>
<h3 className="text-sm font-medium leading-none"></h3>
<h3 className="text-sm font-medium leading-none">{t("permissionDialog.rolesTitle")}</h3>
<p className="mt-1.5 text-xs text-muted-foreground">
{t("permissionDialog.rolesDescription")}
</p>
</div>
<div className="grid gap-3 rounded-md border p-3 sm:grid-cols-2">
@@ -559,7 +563,7 @@ export function AdminUsersConsole(): React.ReactElement {
<span className="block leading-none font-medium">{r.name}</span>
<span className="text-xs text-muted-foreground">{r.slug}</span>
<span className="block text-xs text-muted-foreground/90">
{r.permission_slugs.length}
{t("permissionDialog.rolePermissionCount", { count: r.permission_slugs.length })}
</span>
</span>
</label>
@@ -570,15 +574,15 @@ export function AdminUsersConsole(): React.ReactElement {
<section className="space-y-3">
<div>
<h3 className="text-sm font-medium leading-none"></h3>
<h3 className="text-sm font-medium leading-none">{t("permissionDialog.directTitle")}</h3>
<p className="mt-1.5 text-xs text-muted-foreground">
/ prd.*
{t("permissionDialog.directDescription")}
</p>
</div>
<div className="rounded-md border bg-muted/20 p-2.5 text-xs text-muted-foreground">
{t("permissionDialog.selectedRoles")}
{draftRoles.length === 0 ? (
<span className="ml-1 text-foreground/80"></span>
<span className="ml-1 text-foreground/80">{t("common.none")}</span>
) : (
<span className="ml-1 inline-flex flex-wrap gap-1 align-middle">
{draftRoles.map((slug) => (
@@ -653,7 +657,7 @@ export function AdminUsersConsole(): React.ReactElement {
handlePermissionDialogOpenChange(false);
}}
>
{t("actions.close", { ns: "common" })}
</Button>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-nowrap sm:justify-end">
<Button
@@ -663,7 +667,7 @@ export function AdminUsersConsole(): React.ReactElement {
disabled={!selectedUser || savingRoles}
onClick={() => void saveRoles()}
>
{savingRoles ? "保存中…" : "保存角色"}
{savingRoles ? t("saving") : t("permissionDialog.saveRoles")}
</Button>
<Button
type="button"
@@ -671,7 +675,7 @@ export function AdminUsersConsole(): React.ReactElement {
disabled={!selectedUser || saving}
onClick={() => void savePermissions()}
>
{saving ? "保存中…" : "保存直接权限"}
{saving ? t("saving") : t("permissionDialog.saveDirect")}
</Button>
</div>
</div>
@@ -681,63 +685,69 @@ export function AdminUsersConsole(): React.ReactElement {
<Dialog open={accountOpen} onOpenChange={handleAccountDialogOpenChange}>
<DialogContent showCloseButton className="max-h-[90vh] max-w-lg gap-4 overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>{accountMode === "create" ? "新建管理员" : "编辑账号"}</DialogTitle>
<DialogTitle>
{accountMode === "create" ? t("accountDialog.createTitle") : t("accountDialog.editTitle")}
</DialogTitle>
<DialogDescription>
{accountMode === "create"
? "须为账号指定至少一个默认站点角色。登录账号仅可使用字母、数字、点、下划线与连字符,保存后为小写。"
: "登录账号不可修改。留空密码表示不修改。"}
? t("accountDialog.createDescription")
: t("accountDialog.editDescription")}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<div className="text-sm font-medium leading-none"></div>
<div className="text-sm font-medium leading-none">{t("accountDialog.username")}</div>
<Input
value={formUsername}
disabled={accountMode === "edit"}
placeholder="例如 ops_admin"
placeholder={t("accountDialog.usernamePlaceholder")}
autoComplete="off"
onChange={(e) => setFormUsername(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<div className="text-sm font-medium leading-none"></div>
<div className="text-sm font-medium leading-none">{t("accountDialog.nickname")}</div>
<Input
value={formNickname}
placeholder="显示名称"
placeholder={t("accountDialog.nicknamePlaceholder")}
onChange={(e) => setFormNickname(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<div className="text-sm font-medium leading-none"></div>
<div className="text-sm font-medium leading-none">{t("accountDialog.emailOptional")}</div>
<Input
type="email"
value={formEmail}
placeholder="留空则不填"
placeholder={t("accountDialog.emailPlaceholder")}
onChange={(e) => setFormEmail(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<div className="text-sm font-medium leading-none">
{accountMode === "edit" ? "(可选)" : ""}
{accountMode === "edit" ? t("accountDialog.passwordOptional") : t("accountDialog.password")}
</div>
<Input
type="password"
value={formPassword}
placeholder={accountMode === "create" ? "至少 8 位" : "不修改请留空"}
placeholder={
accountMode === "create"
? t("accountDialog.passwordPlaceholderCreate")
: t("accountDialog.passwordPlaceholderEdit")
}
autoComplete="new-password"
onChange={(e) => setFormPassword(e.target.value)}
/>
</div>
{accountMode === "create" ? (
<div className="space-y-2">
<div className="text-sm font-medium leading-none"></div>
<div className="text-sm font-medium leading-none">{t("accountDialog.rolesRequired")}</div>
<p className="text-xs text-muted-foreground">
{t("accountDialog.rolesDescription")}
</p>
<div className="max-h-52 space-y-2 overflow-y-auto rounded-md border p-2.5 sm:grid sm:max-h-56 sm:grid-cols-2 sm:gap-2 sm:space-y-0">
{(catalog?.roles ?? []).length === 0 ? (
<p className="col-span-full text-xs text-muted-foreground">
{t("accountDialog.noRoles")}
</p>
) : (
(catalog?.roles ?? []).map((r) => {
@@ -760,14 +770,14 @@ export function AdminUsersConsole(): React.ReactElement {
</div>
) : null}
<div className="space-y-1.5">
<div className="text-sm font-medium leading-none"></div>
<div className="text-sm font-medium leading-none">{t("table.status")}</div>
<select
className={selectClassName}
value={formStatus}
onChange={(e) => setFormStatus(Number(e.target.value))}
>
<option value={0}></option>
<option value={1}></option>
<option value={0}>{t("status.enabled")}</option>
<option value={1}>{t("status.disabled")}</option>
</select>
</div>
</div>
@@ -777,10 +787,10 @@ export function AdminUsersConsole(): React.ReactElement {
variant="outline"
onClick={() => handleAccountDialogOpenChange(false)}
>
{t("actions.cancel")}
</Button>
<Button type="button" disabled={accountSaving} onClick={() => void submitAccount()}>
{accountSaving ? "保存中…" : "保存"}
{accountSaving ? t("saving") : t("actions.save")}
</Button>
</div>
</DialogContent>
@@ -789,14 +799,10 @@ export function AdminUsersConsole(): React.ReactElement {
<Dialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent showCloseButton className="max-w-md gap-4">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>{t("delete.confirmTitle")}</DialogTitle>
<DialogDescription>
{deleteTarget ? (
<>
{" "}
<span className="font-medium text-foreground">{deleteTarget.username}</span>
</>
<>{t("delete.confirmDescription", { name: deleteTarget.username })}</>
) : null}
</DialogDescription>
</DialogHeader>
@@ -807,7 +813,7 @@ export function AdminUsersConsole(): React.ReactElement {
disabled={deleteBusy}
onClick={() => setDeleteTarget(null)}
>
{t("actions.cancel")}
</Button>
<Button
type="button"
@@ -815,7 +821,7 @@ export function AdminUsersConsole(): React.ReactElement {
disabled={deleteBusy}
onClick={() => void confirmDelete()}
>
{deleteBusy ? "删除中…" : "删除"}
{deleteBusy ? t("deleting") : t("actions.delete")}
</Button>
</div>
</DialogContent>