feat: 增加管理端多语言与多模块界面国际化支持
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
export const walletModuleMeta = {
|
||||
segment: "wallet",
|
||||
title: "钱包流水与对账",
|
||||
title: "Wallet",
|
||||
description: "",
|
||||
} as const;
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user