feat: 增加管理端多语言与多模块界面国际化支持
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
export const playersModuleMeta = {
|
||||
segment: "players",
|
||||
title: "玩家列表",
|
||||
title: "Players",
|
||||
description: "",
|
||||
} as const;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user