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

@@ -1,5 +1,5 @@
export const playersModuleMeta = {
segment: "players",
title: "玩家列表",
title: "Players",
description: "",
} as const;

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
@@ -40,10 +41,10 @@ import {
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminPlayerRow } from "@/types/api/admin-player";
function playerStatusLabel(status: number): string {
if (status === 0) return "正常";
if (status === 1) return "冻结";
if (status === 2) return "封禁";
function playerStatusLabelT(status: number, t: (key: string) => string): string {
if (status === 0) return t("statusNormal");
if (status === 1) return t("statusFrozen");
if (status === 2) return t("statusBanned");
return String(status);
}
@@ -62,12 +63,13 @@ function formatMinorUnits(minor: number, currencyCode: string): string {
}
const PLAYER_STATUS_OPTIONS = [
{ value: 0, label: "正常" },
{ value: 1, label: "冻结" },
{ value: 2, label: "封禁" },
{ value: 0, label: "statusNormal" },
{ value: 1, label: "statusFrozen" },
{ value: 2, label: "statusBanned" },
];
export function PlayersConsole(): React.ReactElement {
const { t } = useTranslation(["players", "common"]);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(25);
const [keyword, setKeyword] = useState("");
@@ -111,7 +113,7 @@ export function PlayersConsole(): React.ReactElement {
setTotal(data.meta.total);
setLastPage(Math.max(1, data.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);
@@ -119,7 +121,7 @@ export function PlayersConsole(): React.ReactElement {
} finally {
setLoading(false);
}
}, [page, perPage, query]);
}, [page, perPage, query, t]);
useEffect(() => {
queueMicrotask(() => {
@@ -161,11 +163,11 @@ export function PlayersConsole(): React.ReactElement {
async function submitAccount(): Promise<void> {
if (accountMode === "create") {
if (formSiteCode.trim() === "") {
toast.error("请填写主站编号");
toast.error(t("siteCodeRequired"));
return;
}
if (formSitePlayerId.trim() === "") {
toast.error("请填写主站玩家 ID");
toast.error(t("sitePlayerIdRequired"));
return;
}
setAccountSaving(true);
@@ -180,10 +182,10 @@ export function PlayersConsole(): React.ReactElement {
});
setItems((prev) => [created, ...prev]);
setTotal((t) => t + 1);
toast.success(`已创建玩家 ${created.username ?? created.site_player_id}`);
toast.success(t("createSuccess", { name: created.username ?? created.site_player_id }));
handleAccountDialogOpenChange(false);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : "创建玩家失败";
const msg = e instanceof LotteryApiBizError ? e.message : t("createFailed");
toast.error(msg);
} finally {
setAccountSaving(false);
@@ -204,7 +206,7 @@ export function PlayersConsole(): React.ReactElement {
}
if (Object.keys(body).length === 0) {
toast.success("没有变更");
toast.success(t("noChanges"));
handleAccountDialogOpenChange(false);
return;
}
@@ -213,10 +215,10 @@ export function PlayersConsole(): React.ReactElement {
try {
const updated = await putAdminPlayer(id, body);
setItems((prev) => prev.map((row) => (row.id === updated.id ? updated : row)));
toast.success(`已更新 ${updated.username ?? updated.site_player_id}`);
toast.success(t("updateSuccess", { name: updated.username ?? updated.site_player_id }));
handleAccountDialogOpenChange(false);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : "更新玩家失败";
const msg = e instanceof LotteryApiBizError ? e.message : t("updateFailed");
toast.error(msg);
} finally {
setAccountSaving(false);
@@ -231,10 +233,10 @@ export function PlayersConsole(): React.ReactElement {
await deleteAdminPlayer(deleteTarget.id);
setItems((prev) => prev.filter((r) => r.id !== deleteTarget.id));
setTotal((t) => Math.max(0, t - 1));
toast.success(`已删除玩家 ${deleteTarget.username ?? deleteTarget.site_player_id}`);
toast.success(t("deleteSuccess", { name: deleteTarget.username ?? deleteTarget.site_player_id }));
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);
@@ -246,15 +248,15 @@ export function PlayersConsole(): 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("createPlayer")}
</Button>
</div>
<div className="flex w-full max-w-lg gap-2">
<Input
value={keyword}
placeholder="按玩家 ID / 用户名 / 昵称搜索"
placeholder={t("searchPlaceholder")}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
@@ -270,40 +272,40 @@ export function PlayersConsole(): React.ReactElement {
setQuery(keyword.trim());
}}
>
{t("search")}
</Button>
<Button type="button" variant="secondary" onClick={() => void load()}>
{t("refresh")}
</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>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="whitespace-nowrap"></TableHead>
<TableHead className="whitespace-nowrap text-right"></TableHead>
<TableHead className="whitespace-nowrap text-right"></TableHead>
<TableHead className="w-20 whitespace-nowrap"></TableHead>
<TableHead className="whitespace-nowrap"></TableHead>
<TableHead className="min-w-[10rem]"></TableHead>
<TableHead>{t("site")}</TableHead>
<TableHead>{t("sitePlayerId")}</TableHead>
<TableHead>{t("username")}</TableHead>
<TableHead>{t("nickname")}</TableHead>
<TableHead className="whitespace-nowrap">{t("currency")}</TableHead>
<TableHead className="whitespace-nowrap text-right">{t("balance")}</TableHead>
<TableHead className="whitespace-nowrap text-right">{t("available")}</TableHead>
<TableHead className="w-20 whitespace-nowrap">{t("status")}</TableHead>
<TableHead className="whitespace-nowrap">{t("lastLogin")}</TableHead>
<TableHead className="min-w-[10rem]">{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length === 0 && !loading ? (
<TableRow>
<TableCell colSpan={11} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
) : (
@@ -331,7 +333,7 @@ export function PlayersConsole(): React.ReactElement {
</TableCell>
<TableCell>
<Badge variant={playerStatusVariant(row.status)} className="font-normal">
{playerStatusLabel(row.status)}
{playerStatusLabelT(row.status, t)}
</Badge>
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
@@ -355,7 +357,7 @@ export function PlayersConsole(): React.ReactElement {
}
onClick={() => openEditAccount(row)}
>
{t("edit")}
</Button>
<Button
type="button"
@@ -363,7 +365,7 @@ export function PlayersConsole(): React.ReactElement {
variant="destructive"
onClick={() => setDeleteTarget(row)}
>
{t("delete")}
</Button>
</div>
</TableCell>
@@ -392,59 +394,57 @@ export function PlayersConsole(): 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("createDialogTitle") : t("editDialogTitle")}</DialogTitle>
<DialogDescription>
{accountMode === "create"
? "手动注册一个主站玩家到彩票平台,通常由 SSO 登录自动创建。"
: "编辑玩家信息。"}
{accountMode === "create" ? t("createDialogDesc") : t("editDialogDesc")}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{accountMode === "create" && (
<>
<div className="space-y-1.5">
<Label htmlFor="player-site-code"></Label>
<Label htmlFor="player-site-code">{t("siteCode")}</Label>
<Input
id="player-site-code"
value={formSiteCode}
placeholder="例如 main_site"
placeholder={t("siteCodePlaceholder")}
onChange={(e) => setFormSiteCode(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="player-site-id"> ID</Label>
<Label htmlFor="player-site-id">{t("sitePlayerIdLabel")}</Label>
<Input
id="player-site-id"
value={formSitePlayerId}
placeholder="主站返回的唯一标识"
placeholder={t("sitePlayerIdPlaceholder")}
onChange={(e) => setFormSitePlayerId(e.target.value)}
/>
</div>
</>
)}
<div className="space-y-1.5">
<Label htmlFor="player-username"></Label>
<Label htmlFor="player-username">{t("username")}</Label>
<Input
id="player-username"
value={formUsername}
disabled={accountMode === "edit"}
placeholder={accountMode === "create" ? "选填" : ""}
placeholder={accountMode === "create" ? t("usernamePlaceholderOptional") : ""}
onChange={(e) => setFormUsername(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="player-nickname"></Label>
<Label htmlFor="player-nickname">{t("nickname")}</Label>
<Input
id="player-nickname"
value={formNickname}
placeholder="选填"
placeholder={t("nicknamePlaceholderOptional")}
onChange={(e) => setFormNickname(e.target.value)}
/>
</div>
{accountMode === "create" && (
<>
<div className="space-y-1.5">
<Label htmlFor="player-currency"></Label>
<Label htmlFor="player-currency">{t("defaultCurrency")}</Label>
<Input
id="player-currency"
value={formDefaultCurrency}
@@ -453,7 +453,7 @@ export function PlayersConsole(): React.ReactElement {
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="player-status"></Label>
<Label htmlFor="player-status">{t("status")}</Label>
<Select
value={String(formStatus)}
onValueChange={(v) => setFormStatus(Number(v))}
@@ -464,7 +464,7 @@ export function PlayersConsole(): React.ReactElement {
<SelectContent>
{PLAYER_STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={String(o.value)}>
{o.label}
{t(o.label)}
</SelectItem>
))}
</SelectContent>
@@ -474,7 +474,7 @@ export function PlayersConsole(): React.ReactElement {
)}
{accountMode === "edit" && (
<div className="space-y-1.5">
<Label htmlFor="player-edit-status"></Label>
<Label htmlFor="player-edit-status">{t("status")}</Label>
<Select
value={String(formStatus)}
onValueChange={(v) => setFormStatus(Number(v))}
@@ -485,7 +485,7 @@ export function PlayersConsole(): React.ReactElement {
<SelectContent>
{PLAYER_STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={String(o.value)}>
{o.label}
{t(o.label)}
</SelectItem>
))}
</SelectContent>
@@ -499,14 +499,14 @@ export function PlayersConsole(): React.ReactElement {
variant="outline"
onClick={() => handleAccountDialogOpenChange(false)}
>
{t("cancel")}
</Button>
<Button
type="button"
disabled={accountSaving}
onClick={() => void submitAccount()}
>
{accountSaving ? "保存中…" : "保存"}
{accountSaving ? t("saving") : t("save")}
</Button>
</div>
</DialogContent>
@@ -515,23 +515,21 @@ export function PlayersConsole(): React.ReactElement {
<Dialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent showCloseButton className="max-w-sm">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>{t("confirmDelete")}</DialogTitle>
<DialogDescription>
{" "}
{deleteTarget ? (
<span className="font-medium text-foreground">
{deleteTarget.username ?? deleteTarget.site_player_id}
</span>
) : null}{" "}
{deleteTarget
? t("confirmDeleteDesc", {
name: deleteTarget.username ?? deleteTarget.site_player_id,
})
: null}
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setDeleteTarget(null)}>
{t("cancel")}
</Button>
<Button type="button" variant="destructive" disabled={deleteBusy} onClick={() => void confirmDelete()}>
{deleteBusy ? "删除中…" : "删除"}
{deleteBusy ? t("actions.submitting", { ns: "common" }) : t("delete")}
</Button>
</div>
</DialogContent>