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 walletModuleMeta = {
segment: "wallet",
title: "钱包流水与对账",
title: "Wallet",
description: "",
} as const;

View File

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useState } from "react";
import { Copy } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
@@ -58,6 +59,7 @@ function CellMonoId({
/** 用于 toast / 无障碍:如「流水号」「主站流水号」 */
copyHint?: string;
}): React.ReactElement {
const { t } = useTranslation("wallet");
if (value == null || value === "") {
return <span className="text-muted-foreground">{empty}</span>;
}
@@ -68,11 +70,11 @@ function CellMonoId({
await navigator.clipboard.writeText(value);
toast.success(
copyHint
? `${copyHint}已复制到剪贴板`
: "已复制到剪贴板",
? t("copySuccess", { label: copyHint })
: t("copySuccess", { label: "" }).trim(),
);
} catch {
toast.error("复制失败,请检查浏览器权限或手动选择文本");
toast.error(t("copyFailed"));
}
};
@@ -80,8 +82,8 @@ function CellMonoId({
<button
type="button"
className="group inline-flex min-w-0 w-full max-w-full items-center gap-1 rounded-md border border-transparent px-0.5 py-0.5 text-left font-mono text-xs transition-colors hover:border-border hover:bg-muted/60"
title={`${value}\n点击复制`}
aria-label={copyHint ? `复制${copyHint}` : "复制到剪贴板"}
title={value}
aria-label={copyHint ?? t("copyTxnNo")}
onClick={(e) => void copy(e)}
>
<span className="min-w-0 flex-1 truncate">{value}</span>
@@ -103,22 +105,22 @@ function statusBadgeVariant(
return "default";
}
function statusLabel(status: string): string {
function statusLabelT(status: string, t: (key: string) => string): string {
switch (status) {
case "processing":
return "处理中";
return t("statusProcessing");
case "success":
return "成功";
return t("statusSuccess");
case "failed":
return "失败";
return t("statusFailed");
case "pending_reconcile":
return "待对账";
return t("statusPendingReconcile");
case "reversed":
return "已冲正";
return t("statusReversed");
case "manually_processed":
return "已人工处理";
return t("statusManuallyProcessed");
case "posted":
return "已记账";
return t("statusPosted");
default:
return status;
}
@@ -175,41 +177,44 @@ const WALLET_FILTER_ALL = "__all__";
/** 与 {@see WalletTransactionListController}、{@see LotteryTransferService} 当前写入的 biz_type 一致 */
const WALLET_TXN_BIZ_OPTIONS: { value: string; label: string }[] = [
{ value: "transfer_in", label: "主站转入" },
{ value: "transfer_out", label: "主站转出" },
{ value: "transfer_out_refund", label: "转出失败回补" },
{ value: "transfer_in", label: "transferIn" },
{ value: "transfer_out", label: "transferOut" },
{ value: "transfer_out_refund", label: "transferOutRefund" },
];
/** 与 {@see WalletTransactionListController::ALLOWED_STATUS} 一致 */
const WALLET_TXN_STATUS_OPTIONS: { value: string; label: string }[] = [
{ value: "posted", label: "已记账" },
{ value: "pending_reconcile", label: "待对账" },
{ value: "reversed", label: "已冲正" },
{ value: "posted", label: "statusPosted" },
{ value: "pending_reconcile", label: "statusPendingReconcile" },
{ value: "reversed", label: "statusReversed" },
];
/** 与 {@see TransferOrderListController::ALLOWED_STATUS} 一致 */
const TRANSFER_ORDER_STATUS_OPTIONS: { value: string; label: string }[] = [
{ value: "processing", label: "处理中" },
{ value: "success", label: "成功" },
{ value: "failed", label: "失败" },
{ value: "pending_reconcile", label: "待对账" },
{ value: "reversed", label: "已冲正" },
{ value: "manually_processed", label: "已人工处理" },
{ value: "processing", label: "statusProcessing" },
{ value: "success", label: "statusSuccess" },
{ value: "failed", label: "statusFailed" },
{ value: "pending_reconcile", label: "statusPendingReconcile" },
{ value: "reversed", label: "statusReversed" },
{ value: "manually_processed", label: "statusManuallyProcessed" },
];
/** Base UI 的 SelectValue 会直接显示 `value`,需把哨兵转成「不限」、其余转成选项文案 */
function walletAdminSelectDisplayedLabel(
raw: unknown,
options: readonly { value: string; label: string }[],
t?: (key: string) => string,
): string {
const v = raw == null ? "" : String(raw);
if (v === "" || v === WALLET_FILTER_ALL) {
return "不限";
return t ? t("filterAll") : "All";
}
return options.find((o) => o.value === v)?.label ?? v;
const key = options.find((o) => o.value === v)?.label;
return key ? (t ? t(key) : key) : v;
}
export function TransferOrdersPanel(): React.ReactElement {
const { t } = useTranslation(["wallet", "common"]);
const formatTs = useAdminDateTimeFormatter();
const [data, setData] = useState<AdminTransferOrderListData | null>(null);
const [loading, setLoading] = useState(true);
@@ -231,7 +236,7 @@ export function TransferOrdersPanel(): React.ReactElement {
toast.success(successMsg);
void load();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "操作失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("actionFailed"));
} finally {
setActionLoading((prev) => {
const next = new Set(prev);
@@ -242,10 +247,10 @@ export function TransferOrdersPanel(): React.ReactElement {
};
const handleReverse = (transferNo: string) =>
doAction(transferNo, () => reverseTransferOrder(transferNo), "冲正成功");
doAction(transferNo, () => reverseTransferOrder(transferNo), t("reverseSuccess"));
const handleManuallyProcess = (transferNo: string) =>
doAction(transferNo, () => manuallyProcessTransferOrder(transferNo), "人工处理成功");
doAction(transferNo, () => manuallyProcessTransferOrder(transferNo), t("manualProcessSuccess"));
const load = useCallback(async () => {
setLoading(true);
@@ -270,12 +275,12 @@ export function TransferOrdersPanel(): React.ReactElement {
});
setData(d);
} catch (e) {
setErr(e instanceof LotteryApiBizError ? e.message : "加载失败");
setErr(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
setData(null);
} finally {
setLoading(false);
}
}, [page, perPage, applied]);
}, [page, perPage, applied, t]);
useEffect(() => {
queueMicrotask(() => {
@@ -297,43 +302,43 @@ export function TransferOrdersPanel(): React.ReactElement {
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardTitle>{t("transferOrders")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div className="grid gap-1.5">
<Label htmlFor="to-transfer-no"></Label>
<Label htmlFor="to-transfer-no">{t("localTransferNo")}</Label>
<Input
id="to-transfer-no"
placeholder="模糊"
placeholder={t("search")}
value={draft.transferNo}
onChange={(e) => setDraft((d) => ({ ...d, transferNo: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="to-ext"></Label>
<Label htmlFor="to-ext">{t("externalRefNo")}</Label>
<Input
id="to-ext"
placeholder="模糊"
placeholder={t("search")}
value={draft.externalRefNo}
onChange={(e) => setDraft((d) => ({ ...d, externalRefNo: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="to-account"></Label>
<Label htmlFor="to-account">{t("playerAccount")}</Label>
<Input
id="to-account"
placeholder="主站玩家 ID 或用户名(模糊)"
placeholder={t("playerAccountPlaceholder")}
value={draft.playerAccount}
onChange={(e) => setDraft((d) => ({ ...d, playerAccount: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="to-player"> ID</Label>
<Label htmlFor="to-player">{t("playerId")}</Label>
<Input
id="to-player"
inputMode="numeric"
placeholder="可选,优先于账号"
placeholder={t("playerIdOptional")}
value={draft.playerId}
onChange={(e) => setDraft((d) => ({ ...d, playerId: e.target.value }))}
/>
@@ -341,7 +346,7 @@ export function TransferOrdersPanel(): React.ReactElement {
<div className="sm:col-span-2 lg:col-span-2 xl:col-span-2">
<AdminDateRangeField
id="to-created-range"
label="请求日期范围"
label={t("requestDateRange")}
from={draft.createdFrom}
to={draft.createdTo}
onRangeChange={(r) =>
@@ -350,7 +355,7 @@ export function TransferOrdersPanel(): React.ReactElement {
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="to-status"></Label>
<Label htmlFor="to-status">{t("status")}</Label>
<Select
modal={false}
value={
@@ -367,21 +372,21 @@ export function TransferOrdersPanel(): React.ReactElement {
>
<SelectTrigger id="to-status" className="h-8 w-full">
<SelectValue>
{(v) => walletAdminSelectDisplayedLabel(v, TRANSFER_ORDER_STATUS_OPTIONS)}
{(v) => walletAdminSelectDisplayedLabel(v, TRANSFER_ORDER_STATUS_OPTIONS, t)}
</SelectValue>
</SelectTrigger>
<SelectContent align="start" sideOffset={6}>
<SelectItem value={WALLET_FILTER_ALL}></SelectItem>
<SelectItem value={WALLET_FILTER_ALL}>{t("filterAll")}</SelectItem>
{TRANSFER_ORDER_STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
{t(o.label)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col justify-end gap-2 sm:col-span-2 lg:col-span-1">
<span className="text-sm font-medium leading-none"></span>
<span className="text-sm font-medium leading-none">{t("options")}</span>
<label className="flex min-h-9 cursor-pointer items-center gap-2 text-sm">
<Checkbox
checked={draft.abnormalOnly}
@@ -389,25 +394,25 @@ export function TransferOrdersPanel(): React.ReactElement {
setDraft((d) => ({ ...d, abnormalOnly: v === true }))
}
/>
{t("abnormalOnly")}
</label>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" onClick={() => runSearch()}>
{t("search")}
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => resetFilters()}>
{t("resetFilters")}
</Button>
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
{t("refreshCurrentPage")}
</Button>
</div>
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
{loading && !data ? (
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
) : null}
{data ? (
@@ -416,37 +421,33 @@ export function TransferOrdersPanel(): React.ReactElement {
<Table className="table-fixed">
<TableHeader>
<TableRow>
<TableHead className="min-w-0 max-w-[14rem]"></TableHead>
<TableHead className="min-w-0 max-w-[12rem]"></TableHead>
<TableHead className="whitespace-nowrap"></TableHead>
<TableHead className="w-14"></TableHead>
<TableHead className="whitespace-nowrap"></TableHead>
<TableHead className="whitespace-nowrap"></TableHead>
<TableHead className="min-w-0 max-w-[14rem]"></TableHead>
<TableHead className="min-w-0 whitespace-normal leading-tight">
</TableHead>
<TableHead className="min-w-0 whitespace-normal leading-tight">
</TableHead>
<TableHead className="w-24"></TableHead>
<TableHead className="min-w-0 max-w-[14rem]">{t("localTransferNo")}</TableHead>
<TableHead className="min-w-0 max-w-[12rem]">{t("externalRefNo")}</TableHead>
<TableHead className="whitespace-nowrap">{t("playerAccount")}</TableHead>
<TableHead className="w-14">{t("direction")}</TableHead>
<TableHead className="whitespace-nowrap">{t("amount")}</TableHead>
<TableHead className="whitespace-nowrap">{t("status")}</TableHead>
<TableHead className="min-w-0 max-w-[14rem]">{t("failReason")}</TableHead>
<TableHead className="min-w-0 whitespace-normal leading-tight">{t("requestTime")}</TableHead>
<TableHead className="min-w-0 whitespace-normal leading-tight">{t("finishedTime")}</TableHead>
<TableHead className="w-24">{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={10} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
) : (
data.items.map((row) => (
<TableRow key={row.id}>
<TableCell className="min-w-0 max-w-[14rem] align-top whitespace-normal">
<CellMonoId value={row.transfer_no} copyHint="本地单号" />
<CellMonoId value={row.transfer_no} copyHint={t("copyTransferNo")} />
</TableCell>
<TableCell className="min-w-0 max-w-[12rem] align-top whitespace-normal">
<CellMonoId value={row.external_ref_no} copyHint="主站流水号" />
<CellMonoId value={row.external_ref_no} copyHint={t("copyExternalRefNo")} />
</TableCell>
<TableCell className="text-xs">
#{row.player_id}
@@ -460,7 +461,7 @@ export function TransferOrdersPanel(): React.ReactElement {
{formatMinorUnits(row.amount, row.currency_code)}
</TableCell>
<TableCell>
<Badge variant={statusBadgeVariant(row.status)}>{statusLabel(row.status)}</Badge>
<Badge variant={statusBadgeVariant(row.status)}>{statusLabelT(row.status, t)}</Badge>
</TableCell>
<TableCell className="max-w-[14rem] whitespace-normal break-words text-xs text-muted-foreground">
{row.fail_reason?.trim() ? row.fail_reason : "—"}
@@ -481,7 +482,7 @@ export function TransferOrdersPanel(): React.ReactElement {
disabled={actionLoading.has(row.transfer_no)}
onClick={() => handleReverse(row.transfer_no)}
>
{actionLoading.has(row.transfer_no) ? "处理中…" : "冲正"}
{actionLoading.has(row.transfer_no) ? t("processing") : t("reverse")}
</Button>
<Button
size="sm"
@@ -490,7 +491,7 @@ export function TransferOrdersPanel(): React.ReactElement {
disabled={actionLoading.has(row.transfer_no)}
onClick={() => handleManuallyProcess(row.transfer_no)}
>
{actionLoading.has(row.transfer_no) ? "处理中…" : "人工处理"}
{actionLoading.has(row.transfer_no) ? t("processing") : t("manualProcess")}
</Button>
</div>
) : (
@@ -524,6 +525,7 @@ export function TransferOrdersPanel(): React.ReactElement {
}
export function WalletTxnsPanel(): React.ReactElement {
const { t } = useTranslation(["wallet", "common"]);
const formatTs = useAdminDateTimeFormatter();
const [data, setData] = useState<AdminWalletTxnListData | null>(null);
const [loading, setLoading] = useState(true);
@@ -557,12 +559,12 @@ export function WalletTxnsPanel(): React.ReactElement {
});
setData(d);
} catch (e) {
setErr(e instanceof LotteryApiBizError ? e.message : "加载失败");
setErr(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
setData(null);
} finally {
setLoading(false);
}
}, [page, perPage, applied]);
}, [page, perPage, applied, t]);
useEffect(() => {
queueMicrotask(() => {
@@ -584,49 +586,49 @@ export function WalletTxnsPanel(): React.ReactElement {
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardTitle>{t("walletTransactions")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div className="grid gap-1.5">
<Label htmlFor="tx-no"></Label>
<Label htmlFor="tx-no">{t("txnNo")}</Label>
<Input
id="tx-no"
placeholder="模糊"
placeholder={t("search")}
value={draft.txnNo}
onChange={(e) => setDraft((d) => ({ ...d, txnNo: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="tx-ext"></Label>
<Label htmlFor="tx-ext">{t("externalRefNo")}</Label>
<Input
id="tx-ext"
placeholder="模糊"
placeholder={t("search")}
value={draft.externalRefNo}
onChange={(e) => setDraft((d) => ({ ...d, externalRefNo: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="tx-account"></Label>
<Label htmlFor="tx-account">{t("playerAccount")}</Label>
<Input
id="tx-account"
placeholder="主站玩家 ID 或用户名(模糊)"
placeholder={t("playerAccountPlaceholder")}
value={draft.playerAccount}
onChange={(e) => setDraft((d) => ({ ...d, playerAccount: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="tx-player"> ID</Label>
<Label htmlFor="tx-player">{t("playerId")}</Label>
<Input
id="tx-player"
inputMode="numeric"
placeholder="可选,优先于账号"
placeholder={t("playerIdOptional")}
value={draft.playerId}
onChange={(e) => setDraft((d) => ({ ...d, playerId: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="tx-biz"></Label>
<Label htmlFor="tx-biz">{t("bizType")}</Label>
<Select
modal={false}
value={
@@ -643,21 +645,21 @@ export function WalletTxnsPanel(): React.ReactElement {
>
<SelectTrigger id="tx-biz" className="h-8 w-full">
<SelectValue>
{(v) => walletAdminSelectDisplayedLabel(v, WALLET_TXN_BIZ_OPTIONS)}
{(v) => walletAdminSelectDisplayedLabel(v, WALLET_TXN_BIZ_OPTIONS, t)}
</SelectValue>
</SelectTrigger>
<SelectContent align="start" sideOffset={6}>
<SelectItem value={WALLET_FILTER_ALL}></SelectItem>
<SelectItem value={WALLET_FILTER_ALL}>{t("filterAll")}</SelectItem>
{WALLET_TXN_BIZ_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
{t(o.label)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label htmlFor="tx-status"></Label>
<Label htmlFor="tx-status">{t("status")}</Label>
<Select
modal={false}
value={
@@ -674,14 +676,14 @@ export function WalletTxnsPanel(): React.ReactElement {
>
<SelectTrigger id="tx-status" className="h-8 w-full">
<SelectValue>
{(v) => walletAdminSelectDisplayedLabel(v, WALLET_TXN_STATUS_OPTIONS)}
{(v) => walletAdminSelectDisplayedLabel(v, WALLET_TXN_STATUS_OPTIONS, t)}
</SelectValue>
</SelectTrigger>
<SelectContent align="start" sideOffset={6}>
<SelectItem value={WALLET_FILTER_ALL}></SelectItem>
<SelectItem value={WALLET_FILTER_ALL}>{t("filterAll")}</SelectItem>
{WALLET_TXN_STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
{t(o.label)}
</SelectItem>
))}
</SelectContent>
@@ -690,7 +692,7 @@ export function WalletTxnsPanel(): React.ReactElement {
<div className="sm:col-span-2 lg:col-span-2 xl:col-span-2">
<AdminDateRangeField
id="tx-created-range"
label="请求日期范围"
label={t("requestDateRange")}
from={draft.createdFrom}
to={draft.createdTo}
onRangeChange={(r) =>
@@ -699,7 +701,7 @@ export function WalletTxnsPanel(): React.ReactElement {
/>
</div>
<div className="flex flex-col justify-end gap-2 sm:col-span-2 lg:col-span-1">
<span className="text-sm font-medium leading-none"></span>
<span className="text-sm font-medium leading-none">{t("options")}</span>
<label className="flex min-h-9 cursor-pointer items-center gap-2 text-sm">
<Checkbox
checked={draft.abnormalOnly}
@@ -707,25 +709,25 @@ export function WalletTxnsPanel(): React.ReactElement {
setDraft((d) => ({ ...d, abnormalOnly: v === true }))
}
/>
{t("abnormalOnlyPending")}
</label>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" onClick={() => runSearch()}>
{t("search")}
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => resetFilters()}>
{t("resetFilters")}
</Button>
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
{t("refreshCurrentPage")}
</Button>
</div>
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
{loading && !data ? (
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
) : null}
{data ? (
@@ -734,35 +736,31 @@ export function WalletTxnsPanel(): React.ReactElement {
<Table className="table-fixed">
<TableHeader>
<TableRow>
<TableHead className="min-w-0 max-w-[14rem]"></TableHead>
<TableHead className="min-w-0 max-w-[12rem]"></TableHead>
<TableHead className="whitespace-nowrap"></TableHead>
<TableHead className="whitespace-nowrap"></TableHead>
<TableHead className="whitespace-nowrap"></TableHead>
<TableHead className="whitespace-nowrap"></TableHead>
<TableHead className="min-w-0 whitespace-normal leading-tight">
</TableHead>
<TableHead className="min-w-0 whitespace-normal leading-tight">
</TableHead>
<TableHead className="min-w-0 max-w-[14rem]">{t("txnNo")}</TableHead>
<TableHead className="min-w-0 max-w-[12rem]">{t("externalRefNo")}</TableHead>
<TableHead className="whitespace-nowrap">{t("playerAccount")}</TableHead>
<TableHead className="whitespace-nowrap">{t("type")}</TableHead>
<TableHead className="whitespace-nowrap">{t("amount")}</TableHead>
<TableHead className="whitespace-nowrap">{t("status")}</TableHead>
<TableHead className="min-w-0 whitespace-normal leading-tight">{t("requestTime")}</TableHead>
<TableHead className="min-w-0 whitespace-normal leading-tight">{t("finishedTime")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
) : (
data.items.map((row) => (
<TableRow key={row.id}>
<TableCell className="min-w-0 max-w-[14rem] align-top whitespace-normal">
<CellMonoId value={row.txn_no} copyHint="流水号" />
<CellMonoId value={row.txn_no} copyHint={t("copyTxnNo")} />
</TableCell>
<TableCell className="min-w-0 max-w-[12rem] align-top whitespace-normal">
<CellMonoId value={row.external_ref_no} copyHint="主站流水号" />
<CellMonoId value={row.external_ref_no} copyHint={t("copyExternalTxnRefNo")} />
</TableCell>
<TableCell className="min-w-0 text-xs">
#{row.player_id}
@@ -773,10 +771,10 @@ export function WalletTxnsPanel(): React.ReactElement {
</TableCell>
<TableCell className="min-w-0 text-xs">{row.biz_type}</TableCell>
<TableCell className="tabular-nums text-xs">
{row.amount} ({row.direction === 1 ? "入" : "出"})
{row.amount} ({row.direction === 1 ? t("in") : t("out")})
</TableCell>
<TableCell>
<Badge variant={statusBadgeVariant(row.status)}>{statusLabel(row.status)}</Badge>
<Badge variant={statusBadgeVariant(row.status)}>{statusLabelT(row.status, t)}</Badge>
</TableCell>
<TableCell className="min-w-0 whitespace-normal font-mono text-[11px] leading-snug text-muted-foreground">
{formatTs(row.created_at)}
@@ -811,6 +809,7 @@ export function WalletTxnsPanel(): React.ReactElement {
}
export function PlayerWalletPanel(): React.ReactElement {
const { t } = useTranslation(["wallet", "common"]);
const [playerId, setPlayerId] = useState("");
const [result, setResult] = useState<AdminPlayerWalletsData | null>(null);
const [err, setErr] = useState<string | null>(null);
@@ -819,7 +818,7 @@ export function PlayerWalletPanel(): React.ReactElement {
const query = useCallback(async () => {
const id = Number(playerId.trim());
if (Number.isNaN(id) || id < 1) {
setErr("请输入有效玩家 ID");
setErr(t("invalidPlayerId"));
setResult(null);
return;
}
@@ -829,57 +828,57 @@ export function PlayerWalletPanel(): React.ReactElement {
const d = await getAdminPlayerWallets(id);
setResult(d);
} catch (e) {
setErr(e instanceof LotteryApiBizError ? e.message : "查询失败");
setErr(e instanceof LotteryApiBizError ? e.message : t("queryFailed"));
setResult(null);
} finally {
setLoading(false);
}
}, [playerId]);
}, [playerId, t]);
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardTitle>{t("playerWalletQuery")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-end gap-2">
<div className="grid gap-1.5">
<Label htmlFor="pw-id"> ID</Label>
<Label htmlFor="pw-id">{t("playerId")}</Label>
<Input
id="pw-id"
inputMode="numeric"
placeholder="例如 1"
placeholder="1"
value={playerId}
onChange={(e) => setPlayerId(e.target.value)}
className="w-40"
/>
</div>
<Button type="button" onClick={() => void query()} disabled={loading}>
{loading ? "查询中…" : "查询"}
{loading ? t("querying") : t("query")}
</Button>
</div>
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
{result ? (
<div className="space-y-3 rounded-lg border p-4 text-sm">
<p>
<span className="text-muted-foreground"></span>{" "}
<span className="text-muted-foreground">{t("sitePlayer")}</span>{" "}
{result.player.site_code}:{result.player.site_player_id}
</p>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>{t("walletType")}</TableHead>
<TableHead>{t("currency")}</TableHead>
<TableHead>{t("balanceMinor")}</TableHead>
<TableHead>{t("availableBalance")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.wallets.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-muted-foreground">
</TableCell>
{t("noWalletRows")}
</TableCell>
</TableRow>
) : (
result.wallets.map((w) => (

View File

@@ -2,6 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
@@ -14,18 +15,19 @@ const RECONCILE_PERMS = [
] as const;
const tabs: { href: string; label: string; requiredAny: readonly string[] }[] = [
{ href: "/admin/wallet/transactions", label: "钱包流水", requiredAny: RECONCILE_PERMS },
{ href: "/admin/wallet/transfer-orders", label: "转账单", requiredAny: RECONCILE_PERMS },
{ href: "/admin/wallet/transactions", label: "subnavTransactions", requiredAny: RECONCILE_PERMS },
{ href: "/admin/wallet/transfer-orders", label: "subnavTransferOrders", requiredAny: RECONCILE_PERMS },
];
export function WalletSubnav(): React.ReactElement {
const { t } = useTranslation("wallet");
const pathname = usePathname();
const profile = useAdminProfile();
const perms = profile?.permissions;
return (
<nav
aria-label="钱包子页"
aria-label={t("subnavLabel")}
className="mb-6 flex flex-wrap gap-2 border-b border-border pb-3"
>
{tabs.map((t) => {
@@ -40,14 +42,14 @@ export function WalletSubnav(): React.ReactElement {
);
if (!allowed) {
return (
<span key={t.href} className={className} title="当前账号无访问该页的权限">
{t.label}
<span key={t.href} className={className} title={t("noPermission")}>
{t(t.label)}
</span>
);
}
return (
<Link key={t.href} href={t.href} className={className}>
{t.label}
{t(t.label)}
</Link>
);
})}