refactor: 完成全站国际化改造,统一多语言支持

此提交完成了全项目的国际化适配:
1. 新增多语言翻译文件与基础配置
2. 替换所有硬编码文本为i18n调用
3. 优化语言切换与文档语言同步逻辑
4. 重构部分业务逻辑以支持动态翻译
5. 移除过时代码与硬编码配置
This commit is contained in:
2026-05-15 10:41:14 +08:00
parent ac612cb32c
commit f2c7f5e4f1
53 changed files with 2179 additions and 767 deletions

View File

@@ -1,47 +1,37 @@
"use client";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { formatLocalDateTime } from "@/lib/format-local-datetime";
import { formatMinorAsCurrency } from "@/lib/money";
import type { WalletLogItem, WalletLogsData } from "@/types/api/wallet-logs";
/** 与 §4.9 筛选一致;接口 `type` 查询参数 */
export const WALLET_FLOW_FILTERS: { value: string; label: string }[] = [
{ value: "", label: "全部" },
{ value: "transfer_in", label: "转入" },
{ value: "transfer_out", label: "转出" },
{ value: "bet", label: "下注扣款" },
{ value: "prize", label: "派彩" },
{ value: "refund", label: "退本" },
{ value: "reversal", label: "冲正" },
export const WALLET_FLOW_FILTERS: { value: string; labelKey: string }[] = [
{ value: "", labelKey: "wallet.flow.all" },
{ value: "transfer_in", labelKey: "wallet.flow.transfer_in" },
{ value: "transfer_out", labelKey: "wallet.flow.transfer_out" },
{ value: "bet", labelKey: "wallet.flow.bet" },
{ value: "prize", labelKey: "wallet.flow.prize" },
{ value: "refund", labelKey: "wallet.flow.refund" },
{ value: "reversal", labelKey: "wallet.flow.reversal" },
];
export function logTypeLabel(t: string): string {
const map: Record<string, string> = {
transfer_in: "转入",
transfer_out: "转出",
refund: "退本",
reversal: "冲正",
bet: "下注扣款",
prize: "派彩",
};
return map[t] ?? t;
export function logTypeLabel(
type: string,
t?: (key: string, options?: { defaultValue?: string }) => string,
): string {
return t?.(`wallet.flow.${type}`, { defaultValue: type }) ?? type;
}
function txnStatusLabel(status: string): string {
if (status === "posted") return "成功";
if (status === "pending_reconcile") return "待对账";
if (status === "reversed") return "已冲正";
if (status === "manually_processed") return "已人工处理";
return status;
function txnStatusLabel(
status: string,
t: (key: string, options?: { defaultValue?: string }) => string,
): string {
return t(`wallet.txnStatus.${status}`, { defaultValue: status });
}
type WalletLogsBlockProps = {
@@ -61,47 +51,59 @@ export function WalletLogsBlock({
filter,
onFilterChange,
currency,
title = "资金流水",
title,
}: WalletLogsBlockProps) {
const { t } = useTranslation("player");
const resolvedTitle = title ?? t("wallet.flowsTitle");
const filters = useMemo(
() =>
WALLET_FLOW_FILTERS.map((f) => ({
...f,
label: t(f.labelKey),
})),
[t],
);
return (
<>
{logs && logs.pending_reconcile.length > 0 ? (
<Card className="border-[#faad14]/50 bg-[#faad14]/5">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-[#d48806]"></CardTitle>
<CardDescription className="text-xs">
§4.10
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<section className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-3">
<p className="text-sm font-black text-amber-700">{t("wallet.pendingTitle")}</p>
<p className="mt-1 text-xs text-amber-700/80">
{t("wallet.pendingDescription")}
</p>
<div className="mt-2 space-y-2 text-sm">
{logs.pending_reconcile.map((p) => (
<div
key={p.transfer_no}
className="flex flex-wrap items-baseline justify-between gap-1 border-b border-dashed border-border py-2 last:border-0"
>
<span className="text-muted-foreground">
{p.type === "transfer_in" ? "转入" : "转出"}{" "}
{logTypeLabel(p.type, t)}{" "}
{formatMinorAsCurrency(p.amount, p.currency_code)}
</span>
<span className="text-xs text-amber-700"></span>
<span className="text-xs text-amber-700">{t("wallet.pendingStatus")}</span>
</div>
))}
</CardContent>
</Card>
</div>
</section>
) : null}
<section className="space-y-3">
<div className="flex flex-col gap-2">
<h2 className="text-sm font-medium">{title}</h2>
<h2 className="text-sm font-black text-[#0b3f96]">{resolvedTitle}</h2>
<div className="flex flex-wrap gap-1.5">
{WALLET_FLOW_FILTERS.map((f) => (
{filters.map((f) => (
<Button
key={f.value || "all"}
type="button"
size="sm"
variant={filter === f.value ? "default" : "outline"}
className="h-8 rounded-full text-xs"
className={
filter === f.value
? "h-8 rounded-full bg-[#07459f] px-3 text-xs font-bold text-white hover:bg-[#063b88]"
: "h-8 rounded-full border-[#dce7f7] bg-white px-3 text-xs font-bold text-[#32518d] hover:bg-[#f8fbff]"
}
onClick={() => onFilterChange(f.value)}
>
{f.label}
@@ -117,12 +119,12 @@ export function WalletLogsBlock({
{logs ? (
<>
<p className="text-xs text-muted-foreground">
{logs.total}
{t("wallet.totalRecords", { total: logs.total })}
</p>
<ul className="space-y-2">
{logs.items.length === 0 ? (
<li className="rounded-lg border border-dashed py-8 text-center text-sm text-muted-foreground">
{t("wallet.emptyLogs")}
</li>
) : (
logs.items.map((row) => (
@@ -144,15 +146,16 @@ export function LogRow({
item: WalletLogItem;
currency: string;
}) {
const { t } = useTranslation("player");
const ccy = item.currency_code || currency;
const isIn = item.direction === "in";
return (
<li className="rounded-xl border bg-card px-3 py-2.5 text-sm shadow-sm">
<li className="rounded-xl border border-[#e5edf8] bg-white px-3 py-3 text-sm shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
<div className="flex items-start justify-between gap-2">
<div>
<span className="font-medium">
{logTypeLabel(item.type)}{" "}
<span className={isIn ? "text-[#52c41a]" : "text-foreground"}>
{logTypeLabel(item.type, t)}{" "}
<span className={isIn ? "font-black text-emerald-600" : "font-black text-[#0b3f96]"}>
{isIn ? "+" : ""}
{formatMinorAsCurrency(item.amount_abs, ccy)}
</span>
@@ -160,7 +163,7 @@ export function LogRow({
<p className="mt-1 text-xs text-muted-foreground">
{formatLocalDateTime(item.created_at)}{" "}
<span className="text-foreground/80">
· {txnStatusLabel(item.status)}
· {txnStatusLabel(item.status, t)}
</span>
</p>
{item.ref_id ? (

View File

@@ -1,26 +1,19 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { getWalletLogs } from "@/api/wallet";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { PlayerPanel } from "@/components/layout/player-panel";
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
import { formatWalletClientError } from "@/lib/wallet-api-error";
import { cn } from "@/lib/utils";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import type { WalletLogsData } from "@/types/api/wallet-logs";
/** 独立路由 `/wallet/logs` */
export function WalletLogsScreen() {
const profile = usePlayerSessionStore((s) => s.profile);
const { t } = useTranslation("player");
const [logs, setLogs] = useState<WalletLogsData | null>(null);
const [filter, setFilter] = useState("");
const [loading, setLoading] = useState(true);
@@ -50,12 +43,12 @@ export function WalletLogsScreen() {
});
setLogs(L);
} catch (e) {
setError(formatWalletClientError(e));
setError(formatWalletClientError(e, t));
} finally {
setLoading(false);
setLogsLoading(false);
}
}, [filter]);
}, [filter, t]);
useEffect(() => {
queueMicrotask(() => {
@@ -64,39 +57,36 @@ export function WalletLogsScreen() {
}, [load]);
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-lg font-semibold tracking-tight"></h1>
<Link
href="/wallet"
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
</Link>
</div>
{error ? (
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="text-destructive"></CardTitle>
<CardDescription>{error}</CardDescription>
</CardHeader>
<CardContent>
<Button type="button" onClick={() => void load()}>
<PlayerPanel
title={t("wallet.logsTitle")}
subtitle={t("wallet.logsSubtitle")}
eyebrow={t("brand.name")}
backHref="/wallet"
backLabel={t("wallet.title")}
>
<div className="space-y-4">
{error ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-4 text-sm text-red-700">
<p>{error}</p>
<Button
type="button"
className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]"
onClick={() => void load()}
>
{t("actions.retry")}
</Button>
</CardContent>
</Card>
) : null}
</div>
) : null}
<WalletLogsBlock
logs={logs}
logsLoading={loading || logsLoading}
filter={filter}
onFilterChange={setFilter}
currency={currency}
title="类型筛选"
/>
</div>
<WalletLogsBlock
logs={logs}
logsLoading={loading || logsLoading}
filter={filter}
onFilterChange={setFilter}
currency={currency}
title={t("wallet.typeFilter")}
/>
</div>
</PlayerPanel>
);
}

View File

@@ -3,17 +3,12 @@
import { Wallet } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { getWalletBalance, getWalletLogs } from "@/api/wallet";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { PlayerPanel } from "@/components/layout/player-panel";
import {
TransferInDialog,
TransferOutDialog,
@@ -21,14 +16,13 @@ import {
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
import { formatMinorAsCurrency } from "@/lib/money";
import { formatWalletClientError } from "@/lib/wallet-api-error";
import { cn } from "@/lib/utils";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import type { WalletLogsData } from "@/types/api/wallet-logs";
import type { WalletBalanceData } from "@/types/api/wallet-balance";
import type { WalletLogsData } from "@/types/api/wallet-logs";
export function WalletScreen() {
const profile = usePlayerSessionStore((s) => s.profile);
const { t } = useTranslation("player");
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
const [logs, setLogs] = useState<WalletLogsData | null>(null);
const [filter, setFilter] = useState("");
@@ -70,7 +64,7 @@ export function WalletScreen() {
setLogs(L);
} catch (e) {
if (!cancelled) {
setError(formatWalletClientError(e));
setError(formatWalletClientError(e, t));
}
} finally {
if (!cancelled) {
@@ -83,7 +77,7 @@ export function WalletScreen() {
return () => {
cancelled = true;
};
}, [filter]);
}, [filter, t]);
const refreshAll = useCallback(async () => {
setError(null);
@@ -98,138 +92,102 @@ export function WalletScreen() {
});
setLogs(L);
} catch (e) {
setError(formatWalletClientError(e));
setError(formatWalletClientError(e, t));
} finally {
setLogsLoading(false);
setLoading(false);
}
}, [filter]);
}, [filter, t]);
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-lg font-semibold tracking-tight"></h1>
<div className="mt-2 flex flex-wrap gap-2">
<Link
href="/wallet/transfer-in"
className={cn(
buttonVariants({ variant: "secondary", size: "sm" }),
"text-xs",
)}
<PlayerPanel title={t("wallet.title")} subtitle={t("wallet.subtitle")} eyebrow={t("brand.name")}>
<div className="space-y-4">
{error ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-4 text-sm text-red-700">
<p>{error}</p>
<Button
type="button"
className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]"
onClick={() => void refreshAll()}
>
</Link>
<Link
href="/wallet/transfer-out"
className={cn(
buttonVariants({ variant: "secondary", size: "sm" }),
"text-xs",
)}
>
</Link>
<Link
href="/wallet/logs"
className={cn(
buttonVariants({ variant: "secondary", size: "sm" }),
"text-xs",
)}
>
</Link>
</div>
</div>
<Link
href="/hall"
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"shrink-0 self-start",
)}
>
</Link>
</div>
{error ? (
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="text-destructive"></CardTitle>
<CardDescription>{error}</CardDescription>
</CardHeader>
<CardContent>
<Button type="button" onClick={() => void refreshAll()}>
{t("actions.retry")}
</Button>
</CardContent>
</Card>
) : null}
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Wallet className="size-5 opacity-80" aria-hidden />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{loading ? (
<Skeleton className="h-12 w-full max-w-xs rounded-lg" />
) : (
<>
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="font-heading text-2xl font-semibold tabular-nums text-[#52c41a]">
{formatMinorAsCurrency(
balance?.balance ?? 0,
currency,
)}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{" "}
{formatMinorAsCurrency(
balance?.available_balance ?? 0,
currency,
)}
</p>
</div>
<div className="rounded-lg border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
{" "}
<span className="font-medium text-foreground">
{balance?.main_balance == null
? "—(待接入主站)"
: formatMinorAsCurrency(balance.main_balance, currency)}
</span>
</div>
</>
)}
<div className="flex gap-2">
<TransferInDialog
idPrefix="wallet-"
currency={currency}
lotteryMinor={Number(balance?.balance ?? 0)}
onSuccess={refreshAll}
triggerVariant="wallet"
/>
<TransferOutDialog
idPrefix="wallet-"
currency={currency}
availableMinor={Number(balance?.available_balance ?? 0)}
onSuccess={refreshAll}
triggerVariant="wallet"
/>
</div>
</CardContent>
</Card>
) : null}
<WalletLogsBlock
logs={logs}
logsLoading={loading || logsLoading}
filter={filter}
onFilterChange={setFilter}
currency={currency}
/>
</div>
<section className="relative overflow-hidden rounded-xl bg-[#e5002c] px-4 py-5 text-white shadow-[0_10px_28px_rgba(229,0,44,0.25)]">
<div className="relative flex items-center gap-3">
<div className="flex size-13 shrink-0 items-center justify-center rounded-full bg-white text-[#d81435] shadow-sm">
<Wallet className="size-7" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-white/90">{t("wallet.balance")}</p>
{loading ? (
<Skeleton className="mt-2 h-8 w-44 rounded-md bg-white/25" />
) : (
<p className="mt-1 text-2xl font-black leading-none tabular-nums tracking-normal">
{formatMinorAsCurrency(balance?.balance ?? 0, currency)}
</p>
)}
<p className="mt-2 text-xs text-white/75">
{t("wallet.available", {
amount: formatMinorAsCurrency(balance?.available_balance ?? 0, currency),
})}
</p>
</div>
</div>
</section>
<div className="grid grid-cols-2 gap-3">
<TransferInDialog
idPrefix="wallet-"
currency={currency}
lotteryMinor={Number(balance?.balance ?? 0)}
onSuccess={refreshAll}
triggerVariant="hall"
triggerLabel={t("wallet.transferIn")}
triggerClassName="h-12 rounded-lg text-base font-bold"
/>
<TransferOutDialog
idPrefix="wallet-"
currency={currency}
availableMinor={Number(balance?.available_balance ?? 0)}
onSuccess={refreshAll}
triggerVariant="hall"
triggerLabel={t("wallet.transferOut")}
triggerClassName="h-12 rounded-lg text-base font-bold"
/>
</div>
<div className="grid grid-cols-3 gap-2 text-center text-xs font-bold">
<Link
className="rounded-lg border border-[#e5edf8] bg-[#f8fbff] py-2 text-[#0b56b7]"
href="/wallet/transfer-in"
>
{t("wallet.inPage")}
</Link>
<Link
className="rounded-lg border border-[#e5edf8] bg-[#f8fbff] py-2 text-[#0b56b7]"
href="/wallet/transfer-out"
>
{t("wallet.outPage")}
</Link>
<Link
className="rounded-lg border border-[#e5edf8] bg-[#f8fbff] py-2 text-[#0b56b7]"
href="/wallet/logs"
>
{t("wallet.logs")}
</Link>
</div>
<WalletLogsBlock
logs={logs}
logsLoading={loading || logsLoading}
filter={filter}
onFilterChange={setFilter}
currency={currency}
/>
</div>
</PlayerPanel>
);
}

View File

@@ -2,6 +2,7 @@
import { ArrowDownLeft, ArrowUpRight } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
@@ -42,7 +43,7 @@ export function TransferInDialog({
idPrefix = "",
triggerClassName,
triggerVariant = "wallet",
triggerLabel = "转入",
triggerLabel,
}: BaseProps & {
lotteryMinor: number;
triggerClassName?: string;
@@ -50,6 +51,8 @@ export function TransferInDialog({
triggerLabel?: string;
}) {
const [open, setOpen] = useState(false);
const { t } = useTranslation("player");
const resolvedTriggerLabel = triggerLabel ?? t("wallet.transferIn");
const triggerCombined = cn(
"inline-flex h-10 min-h-10 w-full min-w-0 flex-1 items-center justify-center gap-1.5 px-3 text-sm font-medium",
@@ -66,14 +69,13 @@ export function TransferInDialog({
onClick={() => setOpen(true)}
>
<ArrowDownLeft className="size-4 shrink-0" />
{triggerLabel}
{resolvedTriggerLabel}
</Button>
<DialogContent showCloseButton>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>{t("wallet.transferInTitle")}</DialogTitle>
<DialogDescription>
1.00{" "}
{currency}
{t("wallet.dialogInDescription", { currency })}
</DialogDescription>
</DialogHeader>
<TransferInPanel
@@ -99,7 +101,7 @@ export function TransferOutDialog({
idPrefix = "",
triggerClassName,
triggerVariant = "wallet",
triggerLabel = "转出",
triggerLabel,
}: BaseProps & {
availableMinor: number;
triggerClassName?: string;
@@ -107,6 +109,8 @@ export function TransferOutDialog({
triggerLabel?: string;
}) {
const [open, setOpen] = useState(false);
const { t } = useTranslation("player");
const resolvedTriggerLabel = triggerLabel ?? t("wallet.transferOut");
const triggerCombined = cn(
"inline-flex h-10 min-h-10 w-full min-w-0 flex-1 items-center justify-center gap-1.5 px-3 text-sm font-medium",
@@ -128,13 +132,13 @@ export function TransferOutDialog({
onClick={() => setOpen(true)}
>
<ArrowUpRight className="size-4 shrink-0" />
{triggerLabel}
{resolvedTriggerLabel}
</Button>
<DialogContent showCloseButton>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>{t("wallet.transferOutTitle")}</DialogTitle>
<DialogDescription>
{t("wallet.dialogOutDescription")}
</DialogDescription>
</DialogHeader>
<TransferOutPanel

View File

@@ -1,9 +1,9 @@
"use client";
import { isAxiosError } from "axios";
import { ChevronLeft, Loader2 } from "lucide-react";
import Link from "next/link";
import { Loader2 } from "lucide-react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { postWalletTransferIn, postWalletTransferOut } from "@/api/wallet";
@@ -17,6 +17,7 @@ import {
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PlayerPanel } from "@/components/layout/player-panel";
import { formatMinorAsCurrency, parseDecimalInputToMinor } from "@/lib/money";
import { formatWalletClientError } from "@/lib/wallet-api-error";
import { LotteryApiBizError } from "@/types/api/errors";
@@ -25,14 +26,15 @@ import { LotteryApiBizError } from "@/types/api/errors";
async function handleTransferMaybePending(
e: unknown,
onRefresh: () => Promise<void>,
t: (key: string) => string,
): Promise<boolean> {
if (e instanceof LotteryApiBizError && e.code === 1002) {
toast.message(e.message || "处理中…");
toast.message(e.message || t("wallet.pendingToast"));
await onRefresh();
return true;
}
if (isAxiosError(e) && e.response?.status === 409) {
toast.message("转账处理中,请稍后刷新。");
toast.message(t("wallet.pendingShort"));
await onRefresh();
return true;
}
@@ -61,6 +63,7 @@ export function TransferInPanel({
onCancel: () => void;
variant?: PanelVariant;
}) {
const { t } = useTranslation("player");
const [amountText, setAmountText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
@@ -76,7 +79,7 @@ export function TransferInPanel({
const submit = async () => {
setLocalError(null);
if (parsedMinor == null || parsedMinor < 1) {
setLocalError("请输入有效金额。");
setLocalError(t("wallet.invalidAmount"));
return;
}
setSubmitting(true);
@@ -86,15 +89,15 @@ export function TransferInPanel({
currency,
idempotent_key: crypto.randomUUID(),
});
toast.success("转入成功,彩票钱包余额已更新。");
toast.success(t("wallet.successIn"));
setAmountText("");
await onSuccess();
} catch (e) {
if (await handleTransferMaybePending(e, onSuccess)) {
setLocalError(formatWalletClientError(e));
if (await handleTransferMaybePending(e, onSuccess, t)) {
setLocalError(formatWalletClientError(e, t));
return;
}
setLocalError(formatWalletClientError(e));
setLocalError(formatWalletClientError(e, t));
} finally {
setSubmitting(false);
}
@@ -104,17 +107,17 @@ export function TransferInPanel({
variant === "page" ? (
<Button
type="button"
className="w-full"
className="h-11 w-full rounded-lg bg-[#07459f] text-base font-bold text-white hover:bg-[#063b88]"
disabled={submitting}
onClick={() => void submit()}
>
{submitting ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
{t("actions.processing")}
</>
) : (
"确认转入"
t("wallet.confirmIn")
)}
</Button>
) : (
@@ -125,7 +128,7 @@ export function TransferInPanel({
disabled={submitting}
onClick={onCancel}
>
{t("actions.cancel")}
</Button>
<Button
type="button"
@@ -135,10 +138,10 @@ export function TransferInPanel({
{submitting ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
{t("actions.processing")}
</>
) : (
"确认转入"
t("wallet.confirmIn")
)}
</Button>
</div>
@@ -147,32 +150,34 @@ export function TransferInPanel({
return (
<>
<div className="grid gap-3 py-1">
<div className="rounded-lg bg-muted/50 px-3 py-2 text-xs">
<div className="rounded-xl border border-[#e5edf8] bg-[#f8fbff] px-3 py-2 text-xs">
<p>
:{" "}
<span className="text-muted-foreground"></span>
{t("wallet.mainBalance")}{" "}
<span className="text-muted-foreground">{t("wallet.mainPending")}</span>
</p>
<p className="mt-1">
:{" "}
{t("wallet.lotteryBalance")}{" "}
<span className="font-medium text-foreground">
{formatMinorAsCurrency(lotteryMinor, currency)}
</span>
</p>
</div>
<div className="grid gap-2">
<Label htmlFor={tid}></Label>
<Label htmlFor={tid}>{t("wallet.inAmount")}</Label>
<Input
id={tid}
inputMode="decimal"
placeholder="例如 1000.00"
placeholder={t("wallet.exampleIn")}
value={amountText}
onChange={(ev) => setAmountText(ev.target.value)}
disabled={submitting}
autoComplete="off"
className="h-11 rounded-lg border-[#dce7f7] bg-white text-base"
/>
<p className="text-xs text-muted-foreground">
:{" "}
{formatMinorAsCurrency(previewAfter, currency)}
{t("wallet.afterInPreview", {
amount: formatMinorAsCurrency(previewAfter, currency),
})}
</p>
</div>
{localError ? (
@@ -196,6 +201,7 @@ export function TransferOutPanel({
onCancel: () => void;
variant?: PanelVariant;
}) {
const { t } = useTranslation("player");
const [amountText, setAmountText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
@@ -218,11 +224,11 @@ export function TransferOutPanel({
const submit = async () => {
setLocalError(null);
if (parsedMinor == null || parsedMinor < 1) {
setLocalError("请输入有效金额。");
setLocalError(t("wallet.invalidAmount"));
return;
}
if (parsedMinor > availableMinor) {
setLocalError("转出金额不能超过可用余额。");
setLocalError(t("wallet.outExceeds"));
return;
}
setSubmitting(true);
@@ -232,15 +238,15 @@ export function TransferOutPanel({
currency,
idempotent_key: crypto.randomUUID(),
});
toast.success("转出成功,资金将返回主站钱包。");
toast.success(t("wallet.successOut"));
setAmountText("");
await onSuccess();
} catch (e) {
if (await handleTransferMaybePending(e, onSuccess)) {
setLocalError(formatWalletClientError(e));
if (await handleTransferMaybePending(e, onSuccess, t)) {
setLocalError(formatWalletClientError(e, t));
return;
}
setLocalError(formatWalletClientError(e));
setLocalError(formatWalletClientError(e, t));
} finally {
setSubmitting(false);
}
@@ -250,17 +256,17 @@ export function TransferOutPanel({
variant === "page" ? (
<Button
type="button"
className="w-full"
className="h-11 w-full rounded-lg bg-[#e5002c] text-base font-bold text-white hover:bg-[#d10028]"
disabled={submitting}
onClick={() => void submit()}
>
{submitting ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
{t("actions.processing")}
</>
) : (
"确认转出"
t("wallet.confirmOut")
)}
</Button>
) : (
@@ -271,7 +277,7 @@ export function TransferOutPanel({
disabled={submitting}
onClick={onCancel}
>
{t("actions.cancel")}
</Button>
<Button
type="button"
@@ -281,10 +287,10 @@ export function TransferOutPanel({
{submitting ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
{t("actions.processing")}
</>
) : (
"确认转出"
t("wallet.confirmOut")
)}
</Button>
</div>
@@ -293,9 +299,9 @@ export function TransferOutPanel({
return (
<>
<div className="grid gap-3 py-1">
<div className="rounded-lg bg-muted/50 px-3 py-2 text-xs">
<div className="rounded-xl border border-[#e5edf8] bg-[#f8fbff] px-3 py-2 text-xs">
<p>
:{" "}
{t("wallet.lotteryAvailable")}{" "}
<span className="font-medium text-foreground">
{formatMinorAsCurrency(availableMinor, currency)}
</span>
@@ -303,7 +309,7 @@ export function TransferOutPanel({
</div>
<div className="grid gap-2">
<div className="flex items-end justify-between gap-2">
<Label htmlFor={tid}></Label>
<Label htmlFor={tid}>{t("wallet.outAmount")}</Label>
<Button
type="button"
variant="link"
@@ -311,22 +317,25 @@ export function TransferOutPanel({
onClick={fillAll}
disabled={submitting || availableMinor < 1}
>
{" "}
{formatMinorAsCurrency(availableMinor, currency)}
{t("wallet.allOut", {
amount: formatMinorAsCurrency(availableMinor, currency),
})}
</Button>
</div>
<Input
id={tid}
inputMode="decimal"
placeholder="例如 500.00"
placeholder={t("wallet.exampleOut")}
value={amountText}
onChange={(ev) => setAmountText(ev.target.value)}
disabled={submitting}
autoComplete="off"
className="h-11 rounded-lg border-[#dce7f7] bg-white text-base"
/>
<p className="text-xs text-muted-foreground">
:{" "}
{formatMinorAsCurrency(previewAfter, currency)}
{t("wallet.afterOutPreview", {
amount: formatMinorAsCurrency(previewAfter, currency),
})}
</p>
</div>
{localError ? (
@@ -344,21 +353,21 @@ export function TransferInPage({
lotteryMinor,
onSuccess,
}: PanelBase & { lotteryMinor: number }) {
const { t } = useTranslation("player");
return (
<div className="flex flex-col gap-4">
<Link
href="/wallet"
className="inline-flex w-fit items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ChevronLeft className="size-4" />
</Link>
<Card>
<PlayerPanel
title={t("wallet.transferInTitle")}
subtitle={t("wallet.transferInSubtitle", { currency })}
eyebrow={t("wallet.title")}
backHref="/wallet"
backLabel={t("wallet.title")}
>
<Card className="rounded-xl border-[#e5edf8] shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
<CardHeader>
<CardTitle></CardTitle>
<CardTitle className="text-[#0b3f96]">{t("wallet.transferInTitle")}</CardTitle>
<CardDescription>
1.00{" "}
{currency}
{t("wallet.transferInDescription")}
</CardDescription>
</CardHeader>
<CardContent>
@@ -372,7 +381,7 @@ export function TransferInPage({
/>
</CardContent>
</Card>
</div>
</PlayerPanel>
);
}
@@ -382,20 +391,21 @@ export function TransferOutPage({
availableMinor,
onSuccess,
}: PanelBase & { availableMinor: number }) {
const { t } = useTranslation("player");
return (
<div className="flex flex-col gap-4">
<Link
href="/wallet"
className="inline-flex w-fit items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ChevronLeft className="size-4" />
</Link>
<Card>
<PlayerPanel
title={t("wallet.transferOutTitle")}
subtitle={t("wallet.transferOutSubtitle", { currency })}
eyebrow={t("wallet.title")}
backHref="/wallet"
backLabel={t("wallet.title")}
>
<Card className="rounded-xl border-[#e5edf8] shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
<CardHeader>
<CardTitle></CardTitle>
<CardTitle className="text-[#0b3f96]">{t("wallet.transferOutTitle")}</CardTitle>
<CardDescription>
{t("wallet.transferOutDescription")}
</CardDescription>
</CardHeader>
<CardContent>
@@ -409,6 +419,6 @@ export function TransferOutPage({
/>
</CardContent>
</Card>
</div>
</PlayerPanel>
);
}