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,5 +1,7 @@
"use client";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { formatMinorAsCurrency } from "@/lib/money";
@@ -31,13 +33,16 @@ export function HallBetAmountInput({
disabled,
hint,
}: HallBetAmountInputProps) {
const { t } = useTranslation("player");
const min = formatMinorAsCurrency(minBetMinor, currencyCode);
const max = formatMinorAsCurrency(maxBetMinor, currencyCode);
return (
<div className="space-y-2">
<div className="flex flex-wrap items-end justify-between gap-2">
<Label htmlFor={id}>{label}</Label>
<p className="text-xs text-muted-foreground">
{formatMinorAsCurrency(minBetMinor, currencyCode)} {" "}
{formatMinorAsCurrency(maxBetMinor, currencyCode)}
{t("hall.amountInput.limit", { min, max })}
</p>
</div>
<Input
@@ -48,7 +53,7 @@ export function HallBetAmountInput({
value={value}
onChange={(e) => onChange(e.target.value)}
className={cn("tabular-nums")}
placeholder="例如 100.00"
placeholder={t("hall.amountInput.placeholder")}
/>
{hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
</div>

View File

@@ -1,30 +1,44 @@
/**
* 下注业务码与玩家可见说明(对齐 Laravel `ErrorCode` 与产品文档 §6.3 / §6.4)。
*/
export function mapTicketBetError(code: number, fallbackMsg: string): string {
export function mapTicketBetError(
code: number,
fallbackMsg: string,
t?: (key: string) => string,
): string {
const msg = (key: string, fallback: string) => t?.(key) ?? fallback;
switch (code) {
case 4001:
return "该号码本期赔付池不足,已售罄。请更换号码、金额或玩法后重试。";
return msg(
"hall.ticketError.4001",
"该号码本期赔付池不足,已售罄。请更换号码、金额或玩法后重试。",
);
case 2003:
case 1001:
return "余额不足,请先转入后再下注。";
return msg("hall.ticketError.1001", "余额不足,请先转入后再下注。");
case 2001:
return "本期已封盘,无法继续下注。";
return msg("hall.ticketError.2001", "本期已封盘,无法继续下注。");
case 2002:
return "该玩法已关闭,请选择其他玩法。";
return msg("hall.ticketError.2002", "该玩法已关闭,请选择其他玩法。");
case 2004:
return "号码格式或长度不符合该玩法要求。";
return msg("hall.ticketError.2004", "号码格式或长度不符合该玩法要求。");
case 2005:
return "玩法参数不完整(如单双大小需选择位数与维度)。";
return msg(
"hall.ticketError.2005",
"玩法参数不完整(如单双大小需选择位数与维度)。",
);
case 2006:
return "当前期号不可下注。";
return msg("hall.ticketError.2006", "当前期号不可下注。");
case 2007:
return "该玩法暂不支持或缺少赔率配置。";
return msg("hall.ticketError.2007", "该玩法暂不支持或缺少赔率配置。");
case 2008:
return "赔率或玩法配置已更新,请关闭预览后重新操作。";
return msg(
"hall.ticketError.2008",
"赔率或玩法配置已更新,请关闭预览后重新操作。",
);
case 1003:
return "下注金额超出该玩法允许范围。";
return msg("hall.ticketError.1003", "下注金额超出该玩法允许范围。");
default:
return fallbackMsg || "下注失败,请稍后重试。";
return fallbackMsg || msg("hall.ticketError.fallback", "下注失败,请稍后重试。");
}
}

View File

@@ -1,5 +1,7 @@
"use client";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
@@ -36,6 +38,7 @@ export function HallBetNumberInput({
disabled,
helper,
}: HallBetNumberInputProps) {
const { t } = useTranslation("player");
const handle = (raw: string) => {
if (spec.mode === "roll") {
onChange(sanitizeRoll(raw, spec.maxChars));
@@ -56,7 +59,11 @@ export function HallBetNumberInput({
value={value}
onChange={(e) => handle(e.target.value)}
className={cn("font-mono text-base tracking-widest")}
placeholder={spec.mode === "roll" ? "如 12R4" : "0-9"}
placeholder={
spec.mode === "roll"
? t("hall.numberInput.rollPlaceholder")
: t("hall.numberInput.digitPlaceholder")
}
maxLength={spec.maxChars}
/>
{helper ? <p className="text-xs text-muted-foreground">{helper}</p> : null}

View File

@@ -1,6 +1,7 @@
"use client";
import { AlertTriangleIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
@@ -28,14 +29,16 @@ type HallBetPreviewDialogProps = {
};
function WarningsBlock({ warnings }: { warnings: TicketPreviewWarning[] }) {
const { t } = useTranslation("player");
if (warnings.length === 0) return null;
return (
<Alert className="border-amber-500/40 bg-amber-500/5 text-amber-950 dark:text-amber-100">
<AlertTriangleIcon />
<AlertTitle></AlertTitle>
<AlertTitle>{t("hall.preview.warningsTitle")}</AlertTitle>
<AlertDescription className="space-y-1">
<p className="text-xs leading-relaxed">
§6.4
{t("hall.preview.warningsDescription")}
</p>
<ul className="list-inside list-disc text-xs">
{warnings.map((w, i) => (
@@ -61,6 +64,7 @@ export function HallBetPreviewDialog({
allowSubmit = true,
onConfirmPlace,
}: HallBetPreviewDialogProps) {
const { t } = useTranslation("player");
const summary = data?.summary;
const lines = data?.lines ?? [];
@@ -69,17 +73,17 @@ export function HallBetPreviewDialog({
<DialogContent className="max-h-[min(90vh,560px)] gap-0 overflow-hidden p-0 sm:max-w-md">
<div className="p-4 pb-2">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>{t("hall.preview.title")}</DialogTitle>
<DialogDescription>
§6.3
{t("hall.preview.description")}
</DialogDescription>
</DialogHeader>
{!allowSubmit ? (
<Alert className="mt-3 border-[#ff4d4f]/35 bg-[#ff4d4f]/8 text-[#ff4d4f] dark:bg-[#ff4d4f]/12">
<AlertTriangleIcon />
<AlertTitle></AlertTitle>
<AlertTitle>{t("hall.preview.sealedTitle")}</AlertTitle>
<AlertDescription className="text-xs leading-relaxed">
§4.2
{t("hall.preview.sealedDescription")}
</AlertDescription>
</Alert>
) : null}
@@ -88,37 +92,38 @@ export function HallBetPreviewDialog({
<ScrollArea className="max-h-[min(52vh,360px)] border-y px-4">
<div className="space-y-4 py-3 pr-3">
{!data ? (
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm text-muted-foreground">{t("hall.preview.empty")}</p>
) : (
<>
<div className="rounded-lg border bg-muted/30 p-3 text-xs">
<p>
{" "}
<span className="font-mono font-semibold">{data.draw.draw_id}</span> · {" "}
{t("hall.preview.draw")}{" "}
<span className="font-mono font-semibold">{data.draw.draw_id}</span> ·{" "}
{t("hall.preview.status")}{" "}
<span className="font-medium">{data.draw.status}</span>
</p>
{summary ? (
<ul className="mt-2 space-y-1 tabular-nums">
<li>
{" "}
{t("hall.preview.totalBet")}{" "}
<span className="font-medium">
{formatMinorAsCurrency(summary.total_bet_amount, currencyCode)}
</span>
</li>
<li>
{" "}
{t("hall.preview.rebateDeduct")}{" "}
<span className="font-medium">
{formatMinorAsCurrency(summary.total_rebate_amount, currencyCode)}
</span>
</li>
<li>
{" "}
{t("hall.preview.actualDeduct")}{" "}
<span className="font-semibold text-primary">
{formatMinorAsCurrency(summary.total_actual_deduct, currencyCode)}
</span>
</li>
<li>
{" "}
{t("hall.preview.estimatedPayout")}{" "}
<span className="font-medium">
{formatMinorAsCurrency(summary.total_estimated_payout, currencyCode)}
</span>
@@ -130,7 +135,9 @@ export function HallBetPreviewDialog({
<WarningsBlock warnings={data.warnings} />
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground"></p>
<p className="text-xs font-medium text-muted-foreground">
{t("hall.preview.lines")}
</p>
<ul className="space-y-2 text-sm">
{lines.map((ln) => (
<li
@@ -146,15 +153,23 @@ export function HallBetPreviewDialog({
<p className="mt-1 font-mono text-base">{ln.number}</p>
<Separator className="my-2" />
<div className="grid grid-cols-2 gap-x-2 gap-y-1 text-xs tabular-nums">
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">
{t("hall.preview.normalizedNumber")}
</span>
<span className="text-right font-mono">{ln.normalized_number}</span>
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">
{t("hall.preview.combinationCount")}
</span>
<span className="text-right">{ln.combination_count}</span>
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">
{t("hall.preview.actual")}
</span>
<span className="text-right">
{formatMinorAsCurrency(ln.actual_deduct_amount, currencyCode)}
</span>
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">
{t("hall.preview.estimatedMax")}
</span>
<span className="text-right">
{formatMinorAsCurrency(ln.estimated_max_payout, currencyCode)}
</span>
@@ -170,10 +185,14 @@ export function HallBetPreviewDialog({
<div className="flex flex-col-reverse gap-2 border-t bg-muted/30 p-4 sm:flex-row sm:justify-between">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={placing}>
{t("hall.preview.backEdit")}
</Button>
<Button type="button" onClick={onConfirmPlace} disabled={!data || placing || !allowSubmit}>
{placing ? "提交中…" : allowSubmit ? "确认提交" : "已封盘"}
{placing
? t("hall.preview.submitting")
: allowSubmit
? t("hall.preview.confirmSubmit")
: t("hall.preview.sealedTitle")}
</Button>
</div>
</DialogContent>

View File

@@ -47,12 +47,24 @@ export function playNeedsDigitSlot(playCode: string): boolean {
}
/** 产品文档iBox/Roll 单注金额mBox 总金额摊分 */
export function ticketAmountHint(playCode: string): string {
export function ticketAmountHint(
playCode: string,
t?: (key: string) => string,
): string {
if (playCode === "ibox" || playCode === "roll") {
return "本玩法金额为「单注金额」,系统按展开组合数计算总下注与实扣。";
return (
t?.("hall.amountHint.iboxRoll") ??
"本玩法金额为「单注金额」,系统按展开组合数计算总下注与实扣。"
);
}
if (playCode === "mbox") {
return "本玩法金额为「总输入金额」,将均摊到各排列组合(向下取整到最小单位)。";
return (
t?.("hall.amountHint.mbox") ??
"本玩法金额为「总输入金额」,将均摊到各排列组合(向下取整到最小单位)。"
);
}
return "金额为该笔注单的下注额(最小货币单位整数,与钱包一致)。";
return (
t?.("hall.amountHint.default") ??
"金额为该笔注单的下注额(最小货币单位整数,与钱包一致)。"
);
}

View File

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { CirclePlus, Cuboid, PackageOpen, Ticket, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { getPlayEffective } from "@/api/play";
@@ -205,6 +206,7 @@ function pickSimplePlay(
export function HallBettingGrid() {
const { display, isBettable, reload: reloadDraw } = useHallDrawLive();
const { t } = useTranslation("player");
const [activeCategory, setActiveCategory] = useState<HallCategory>("D2");
const [boxMode, setBoxMode] = useState<BoxMode>("ibox");
const [rows, setRows] = useState<DraftRow[]>(() => [
@@ -239,10 +241,10 @@ export function HallBettingGrid() {
setCatalogState({ kind: "ok", data });
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : "加载玩法失败,请稍后重试。";
e instanceof LotteryApiBizError ? e.message : t("hall.loadingError");
setCatalogState({ kind: "error", message: msg });
}
}, [currencyParam]);
}, [currencyParam, t]);
useEffect(() => {
queueMicrotask(() => {
@@ -373,21 +375,21 @@ export function HallBettingGrid() {
const handlePreview = async () => {
if (!display) {
toast.error("暂无当期期号,无法提交。");
toast.error(t("hall.noDraw"));
return;
}
if (!isBettable) {
toast.error("当前已封盘或不可下注。");
toast.error(t("hall.notBettable"));
return;
}
if (catalogState.kind !== "ok") {
toast.error("玩法配置尚未加载完成。");
toast.error(t("hall.catalogNotReady"));
return;
}
const lines = buildLines();
if (lines.length === 0) {
toast.error("请至少填写一组有效号码和下注金额。");
toast.error(t("hall.emptyLines"));
return;
}
@@ -403,8 +405,8 @@ export function HallBettingGrid() {
setPreviewOpen(true);
} catch (e) {
const code = e instanceof LotteryApiBizError ? e.code : 0;
const msg = e instanceof LotteryApiBizError ? e.message : "预览失败";
toast.error(mapTicketBetError(code, msg));
const msg = e instanceof LotteryApiBizError ? e.message : t("hall.previewFailed");
toast.error(mapTicketBetError(code, msg, t));
} finally {
setPreviewLoading(false);
}
@@ -413,13 +415,13 @@ export function HallBettingGrid() {
const handlePlace = async () => {
if (!display || !previewData) return;
if (!isBettable) {
toast.error("已封盘,无法提交。");
toast.error(t("hall.closedSubmit"));
return;
}
const lines = buildLines();
if (lines.length === 0) {
toast.error("提交前数据已变化,请关闭预览后重试。");
toast.error(t("hall.changedBeforeSubmit"));
return;
}
@@ -436,7 +438,10 @@ export function HallBettingGrid() {
expected_config_versions: previewData.config_versions,
});
toast.success(
`下注成功,订单号 ${data.order_no},实扣 ${formatMinorAsCurrency(data.summary.total_actual_deduct, currencyCode)}`,
t("hall.placeSuccess", {
orderNo: data.order_no,
amount: formatMinorAsCurrency(data.summary.total_actual_deduct, currencyCode),
}),
);
setPreviewOpen(false);
setPreviewData(null);
@@ -445,8 +450,8 @@ export function HallBettingGrid() {
void reloadDraw();
} catch (e) {
const code = e instanceof LotteryApiBizError ? e.code : 0;
const msg = e instanceof LotteryApiBizError ? e.message : "提交失败";
toast.error(mapTicketBetError(code, msg));
const msg = e instanceof LotteryApiBizError ? e.message : t("hall.placeFailed");
toast.error(mapTicketBetError(code, msg, t));
} finally {
setPlaceLoading(false);
}
@@ -454,7 +459,7 @@ export function HallBettingGrid() {
if (catalogState.kind === "loading") {
return (
<section className="space-y-3" aria-label="Betting table">
<section className="space-y-3" aria-label={t("hall.aria")}>
<Skeleton className="h-12 rounded-xl" />
<Skeleton className="h-72 rounded-xl" />
<Skeleton className="h-14 rounded-xl" />
@@ -473,7 +478,7 @@ export function HallBettingGrid() {
className="mt-3 border-red-200 bg-white text-red-700 hover:bg-red-50"
onClick={() => void loadCatalog()}
>
{t("actions.retry")}
</Button>
</section>
);
@@ -485,7 +490,7 @@ export function HallBettingGrid() {
return (
<>
<section className="space-y-4" aria-label="Betting table">
<section className="space-y-4" aria-label={t("hall.aria")}>
<div className="grid grid-cols-4 rounded-xl border border-[#e8eef7] bg-white p-1 shadow-[0_6px_18px_rgba(30,64,175,0.06)]">
{categoryTabs.map((tab) => {
const active = activeCategory === tab.value;
@@ -539,9 +544,11 @@ export function HallBettingGrid() {
<Cuboid className="size-4" aria-hidden />
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-bold">iBox</span>
<span className="block truncate text-sm font-bold">
{t("hall.boxMode.iboxTitle")}
</span>
<span className="block truncate text-[10px] text-slate-500">
Divide all by amount
{t("hall.boxMode.iboxDesc")}
</span>
</span>
</button>
@@ -561,9 +568,11 @@ export function HallBettingGrid() {
<PackageOpen className="size-4" aria-hidden />
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-bold">Box</span>
<span className="block truncate text-sm font-bold">
{t("hall.boxMode.boxTitle")}
</span>
<span className="block truncate text-[10px] text-slate-500">
Multiply all by amount
{t("hall.boxMode.boxDesc")}
</span>
</span>
</button>
@@ -575,10 +584,12 @@ export function HallBettingGrid() {
<div className="mx-auto flex size-14 items-center justify-center rounded-full bg-slate-200 text-slate-600">
<Ticket className="size-7" aria-hidden />
</div>
<p className="mt-4 text-lg font-bold text-slate-900">Closed</p>
<p className="mt-1 text-xs">This issue is now closed.</p>
<p className="mt-4 text-lg font-bold text-slate-900">
{t("hall.closed.title")}
</p>
<p className="mt-1 text-xs">{t("hall.closed.subtitle")}</p>
<div className="mt-5 rounded-lg border border-[#cbdcf7] bg-white px-3 py-3 text-left text-xs text-[#315a9f]">
The betting window has closed. Please wait for the next issue to place your bets.
{t("hall.closed.description")}
</div>
</div>
) : (
@@ -593,10 +604,10 @@ export function HallBettingGrid() {
<thead>
<tr className="border-b border-[#e8eef7] bg-[#f5f8fd] text-[11px] font-semibold text-[#32518d]">
<th className="sticky left-0 z-20 w-12 bg-[#f5f8fd] px-2 py-3 text-center">
No.
{t("hall.table.no")}
</th>
<th className="sticky left-12 z-20 w-24 bg-[#f5f8fd] px-2 py-3 text-center">
Number
{t("hall.table.number")}
<span className="block text-[10px] font-normal text-[#6b7896]">
({numberPlaceholder})
</span>
@@ -609,12 +620,18 @@ export function HallBettingGrid() {
))
) : (
<>
<th className="min-w-28 px-2 py-3 text-center">Stake Amount</th>
<th className="min-w-28 px-2 py-3 text-center">Commission / Rebate</th>
<th className="min-w-28 px-2 py-3 text-center">Actual Deduction</th>
<th className="min-w-28 px-2 py-3 text-center">
{t("hall.table.stake")}
</th>
<th className="min-w-28 px-2 py-3 text-center">
{t("hall.table.rebate")}
</th>
<th className="min-w-28 px-2 py-3 text-center">
{t("hall.table.actual")}
</th>
</>
)}
<th className="w-10 px-2 py-3" aria-label="Delete" />
<th className="w-10 px-2 py-3" aria-label={t("hall.table.delete")} />
</tr>
</thead>
<tbody>
@@ -695,7 +712,7 @@ export function HallBettingGrid() {
disabled={tableDisabled || rows.length <= 1}
onClick={() => removeRow(row.id)}
className="inline-flex size-8 items-center justify-center rounded-full text-[#ff4d4f] hover:bg-red-50 disabled:text-slate-300 disabled:hover:bg-transparent"
aria-label={`删除第 ${index + 1}`}
aria-label={t("actions.deleteRow", { row: index + 1 })}
>
<Trash2 className="size-4" aria-hidden />
</button>
@@ -713,13 +730,13 @@ export function HallBettingGrid() {
className="flex h-11 w-full items-center justify-center gap-1.5 border-t border-[#edf2f9] text-sm font-semibold text-[#1d57b7] hover:bg-[#f7faff] disabled:text-slate-300"
>
<CirclePlus className="size-4" aria-hidden />
Add Row
{t("hall.table.addRow")}
</button>
</div>
)}
<div className="flex items-center justify-between rounded-xl border border-[#e9eef7] bg-[#f8fbff] px-4 py-3 text-sm shadow-[0_6px_20px_rgba(15,23,42,0.04)]">
<span className="font-medium text-slate-800">Draft Total</span>
<span className="font-medium text-slate-800">{t("hall.table.draftTotal")}</span>
<span className="text-lg font-bold tabular-nums text-[#0b3f96]">
{formatMinorAsCurrency(draftSummary.actual, currencyCode)}
</span>
@@ -727,7 +744,7 @@ export function HallBettingGrid() {
{sealedBetUi ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-600">
{t("hall.table.sealedHint")}
</p>
) : null}
@@ -738,7 +755,11 @@ export function HallBettingGrid() {
className="h-12 w-full rounded-xl border-0 bg-[#e5002c] text-base font-bold text-white shadow-[0_8px_20px_rgba(229,0,44,0.26)] hover:bg-[#d10028]"
>
<Ticket className="size-5" aria-hidden />
{previewLoading ? "Previewing..." : activeCategory === "JACKPOT" ? "Closed" : "Submit Bet"}
{previewLoading
? t("hall.table.previewing")
: activeCategory === "JACKPOT"
? t("hall.closed.title")
: t("hall.table.submitBet")}
</Button>
</section>

View File

@@ -1,6 +1,7 @@
"use client";
import { Hourglass, Landmark, TimerReset, WalletCards } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
@@ -12,13 +13,14 @@ import { cn } from "@/lib/utils";
import type { DrawCurrentPayload } from "@/types/api/draw-current";
function CurrentTime({ payload }: { payload: DrawCurrentPayload }) {
const { t } = useTranslation("player");
const source = payload.close_time ?? payload.draw_time ?? payload.start_time;
const formatted = source ? formatLotteryInstant(source) : null;
if (!formatted) {
return (
<>
<span className="text-lg font-black tabular-nums text-[#0b3f96]">--:--:--</span>
<span className="mt-1 text-[11px] text-slate-500">Current Time</span>
<span className="mt-1 text-[11px] text-slate-500">{t("draw.currentTime")}</span>
</>
);
}
@@ -42,18 +44,19 @@ function CloseTime({
hud: ReturnType<typeof drawStatusHud>;
payload: DrawCurrentPayload;
}) {
const { t } = useTranslation("player");
const sealedCountdown = isHallSealedCountdownUi(payload.status);
let seconds = 0;
let label = "Closes In";
let label = t("draw.closesIn");
if (hud.countdownKind === "close") {
seconds = Math.max(0, payload.seconds_to_close);
} else if (hud.countdownKind === "draw") {
seconds = Math.max(0, payload.seconds_to_draw);
label = sealedCountdown ? "Draws In" : "Closes In";
label = sealedCountdown ? t("draw.drawsIn") : t("draw.closesIn");
} else if (hud.countdownKind === "cooldown") {
seconds = Math.max(0, payload.seconds_remaining_in_cooldown ?? 0);
label = "Cool Down";
label = t("draw.coolDown");
}
return (
@@ -68,11 +71,12 @@ function CloseTime({
export function HallDrawPanel() {
const { raw, display, error, reload } = useHallDrawLive();
const { t } = useTranslation("player");
if (error) {
return (
<section className="mb-4 rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700">
<p>{error}</p>
<p>{t(error, { defaultValue: error })}</p>
<Button
type="button"
variant="outline"
@@ -80,7 +84,7 @@ export function HallDrawPanel() {
className="mt-2 border-red-200 bg-white text-red-700"
onClick={() => void reload()}
>
{t("actions.retry")}
</Button>
</section>
);
@@ -101,7 +105,7 @@ export function HallDrawPanel() {
if (raw === null || display === null) {
return (
<section className="mb-4 rounded-xl border border-[#e3ebf6] bg-white px-3 py-4 text-center text-sm text-slate-500 shadow-sm">
{t("draw.noIssue")}
</section>
);
}
@@ -115,7 +119,7 @@ export function HallDrawPanel() {
"mb-4 overflow-hidden rounded-xl border border-[#e1e9f5] bg-white shadow-[0_6px_20px_rgba(15,23,42,0.06)]",
sealedUi && "border-red-200 bg-red-50/30",
)}
aria-label="Current issue"
aria-label={t("draw.currentIssue")}
>
<div className="grid grid-cols-[1fr_1.05fr_1fr] divide-x divide-[#e7edf6]">
<div className="flex min-w-0 items-center justify-center gap-2 px-2 py-3 text-center">
@@ -123,7 +127,7 @@ export function HallDrawPanel() {
<WalletCards className="size-4" aria-hidden />
</span>
<div className="min-w-0">
<p className="text-[11px] font-semibold text-slate-500">Issue No.</p>
<p className="text-[11px] font-semibold text-slate-500">{t("draw.issueNo")}</p>
<p className="truncate text-sm font-black tabular-nums text-[#ff143d]">
{display.draw_no}
</p>
@@ -146,17 +150,17 @@ export function HallDrawPanel() {
{sealedUi ? (
<div className="flex items-center gap-2 border-t border-red-100 bg-red-50 px-3 py-2 text-xs font-medium text-red-600">
<TimerReset className="size-4" aria-hidden />
{t("draw.sealedNotice")}
</div>
) : (
<div className="flex items-center justify-between border-t border-[#eef3f9] bg-[#fbfdff] px-3 py-1.5 text-[11px] text-slate-500">
<span className="inline-flex items-center gap-1.5">
<span className={cn("size-2 rounded-full", hud.dotClass)} />
{hud.label}
{t(hud.labelKey, { defaultValue: hud.labelKey })}
</span>
<span className="inline-flex items-center gap-1">
<Landmark className="size-3.5" aria-hidden />
Betting Hall
{t("draw.hall")}
</span>
</div>
)}

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { getPlayEffective } from "@/api/play";
import { Badge } from "@/components/ui/badge";
@@ -76,6 +77,7 @@ function formatMoneyAmount(n: number): string {
}
export function HallPlayCatalogPanel() {
const { t } = useTranslation("player");
const [state, setState] = useState<LoadState>({ kind: "loading" });
const currencyParam = useMemo(() => {
const fromEnv = process.env.NEXT_PUBLIC_LOTTERY_PLAY_CURRENCY?.trim();
@@ -93,8 +95,7 @@ export function HallPlayCatalogPanel() {
if (e instanceof LotteryApiBizError && e.code === 9004) {
setState({
kind: "error",
message:
"玩法配置尚未初始化。请在 Laravel 执行含 OperationalConfigV1Seeder 的 seed。",
message: t("hall.playCatalog.notReady"),
notReady: true,
});
return;
@@ -102,10 +103,10 @@ export function HallPlayCatalogPanel() {
const msg =
e instanceof LotteryApiBizError
? e.message
: "加载玩法配置失败,请稍后重试。";
: t("hall.playCatalog.loadFailed");
setState({ kind: "error", message: msg });
}
}, [currencyParam]);
}, [currencyParam, t]);
useEffect(() => {
queueMicrotask(() => {
@@ -123,7 +124,7 @@ export function HallPlayCatalogPanel() {
const body = (() => {
if (state.kind === "loading") {
return (
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm text-muted-foreground">{t("hall.playCatalog.loading")}</p>
);
}
if (state.kind === "error") {
@@ -131,7 +132,7 @@ export function HallPlayCatalogPanel() {
<div className="space-y-2">
<p className="text-sm text-destructive">{state.message}</p>
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
{t("actions.retry")}
</Button>
</div>
);
@@ -145,19 +146,28 @@ export function HallPlayCatalogPanel() {
return (
<div className="space-y-6">
<p className="text-xs text-muted-foreground">
{data.currency_code} · play#{data.effective_versions.play_config.version_no}
odds#{data.effective_versions.odds.version_no} ·
{t("hall.playCatalog.meta", {
currency: data.currency_code,
playVersion: data.effective_versions.play_config.version_no,
oddsVersion: data.effective_versions.odds.version_no,
})}
</p>
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[140px]"></TableHead>
<TableHead className="w-[88px] text-center"></TableHead>
<TableHead className="min-w-[160px] whitespace-nowrap"></TableHead>
<TableHead className="min-w-[100px]">×</TableHead>
<TableHead className="min-w-[200px]"></TableHead>
<TableHead className="min-w-[140px]">{t("hall.playCatalog.play")}</TableHead>
<TableHead className="w-[88px] text-center">
{t("hall.playCatalog.status")}
</TableHead>
<TableHead className="min-w-[160px] whitespace-nowrap">
{t("hall.playCatalog.limit")}
</TableHead>
<TableHead className="min-w-[100px]">{t("hall.playCatalog.odds")}</TableHead>
<TableHead className="min-w-[200px]">
{t("hall.playCatalog.description")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -188,11 +198,11 @@ export function HallPlayCatalogPanel() {
<TableCell className="text-center">
{open ? (
<Badge variant="default" className="font-normal">
{t("hall.playCatalog.open")}
</Badge>
) : (
<Badge variant="secondary" className="font-normal">
{t("hall.playCatalog.closed")}
</Badge>
)}
</TableCell>
@@ -222,14 +232,16 @@ export function HallPlayCatalogPanel() {
{data.risk_cap_items.length > 0 ? (
<div className="space-y-2">
<h3 className="text-sm font-medium text-foreground"></h3>
<h3 className="text-sm font-medium text-foreground">
{t("hall.playCatalog.riskTitle")}
</h3>
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>{t("hall.playCatalog.number")}</TableHead>
<TableHead>{t("hall.playCatalog.capAmount")}</TableHead>
<TableHead>{t("hall.playCatalog.type")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -257,10 +269,11 @@ export function HallPlayCatalogPanel() {
<Card>
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<CardTitle className="text-base"></CardTitle>
<CardTitle className="text-base">{t("hall.playCatalog.title")}</CardTitle>
<CardDescription>
<code className="text-xs">GET /api/v1/play/effective</code>
{DEFAULT_POLL_MS / 1000}s
{t("hall.playCatalog.descriptionText", {
seconds: DEFAULT_POLL_MS / 1000,
})}
</CardDescription>
</div>
<Button
@@ -270,7 +283,7 @@ export function HallPlayCatalogPanel() {
className="shrink-0"
onClick={() => void load()}
>
{t("hall.playCatalog.refresh")}
</Button>
</CardHeader>
<CardContent>{body}</CardContent>

View File

@@ -1,5 +1,7 @@
"use client";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
export type PlayChip = {
@@ -23,15 +25,17 @@ export function HallPlaySwitcher({
onChange,
disabled,
}: HallPlaySwitcherProps) {
const { t } = useTranslation("player");
if (plays.length === 0) {
return (
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm text-muted-foreground">{t("hall.playSwitcher.empty")}</p>
);
}
return (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground"></p>
<p className="text-xs font-medium text-muted-foreground">{t("hall.playSwitcher.label")}</p>
<div className="-mx-1 flex gap-1.5 overflow-x-auto pb-1">
{plays.map((p) => {
const active = p.play_code === value;

View File

@@ -1,6 +1,7 @@
"use client";
import { Bell } from "lucide-react";
import { useTranslation } from "react-i18next";
import { LanguageSwitcher } from "@/components/language-switcher";
import { HallBettingGrid } from "@/features/hall/hall-betting-grid";
@@ -11,10 +12,12 @@ import { HallWalletStrip } from "@/features/hall/hall-wallet-strip";
* 下注大厅:钱包条 §4 + 当期期号 §4.2(封盘置灰 / 倒计时错误色 / WS+轮询);玩法目录 §12.3;下注表格 §13.3。
*/
export function HallScreen() {
const { t } = useTranslation("common");
return (
<div className="mx-auto w-full max-w-[480px]">
<section className="overflow-hidden rounded-[18px] border border-[#dce7f7] bg-white px-2.5 py-3 text-slate-900 shadow-[0_18px_50px_rgba(15,44,92,0.12)]">
<div className="mb-3 flex items-center gap-2 px-1">
<section className="overflow-hidden bg-white px-4 pb-8 pt-4 text-slate-900">
<div className="mb-3 flex items-center gap-2 px-1 pt-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<div className="relative flex size-10 shrink-0 rotate-[-10deg] items-center justify-center rounded-lg bg-[#e60023] text-white shadow-[0_7px_14px_rgba(230,0,35,0.22)]">
<span className="absolute -left-1.5 top-1 flex size-6 rotate-[18deg] items-center justify-center rounded-sm bg-[#0b56b7] text-xs font-black">
@@ -35,14 +38,14 @@ export function HallScreen() {
<button
type="button"
className="relative flex size-9 shrink-0 items-center justify-center rounded-full text-[#1d57b7] hover:bg-[#f4f7fb]"
aria-label="Notifications"
aria-label={t("navigation.notifications")}
>
<Bell className="size-5" aria-hidden />
<span className="absolute right-2 top-2 size-2 rounded-full bg-[#ff143d]" />
</button>
</div>
<HallDrawPanel />
<HallDrawPanel />
<HallWalletStrip />

View File

@@ -2,6 +2,7 @@
import { Wallet } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { getWalletBalance } from "@/api/wallet";
import { Skeleton } from "@/components/ui/skeleton";
@@ -18,6 +19,7 @@ import type { WalletBalanceData } from "@/types/api/wallet-balance";
export function HallWalletStrip() {
const profile = usePlayerSessionStore((s) => s.profile);
const mode = useNetworkConnectionStore((s) => s.mode);
const { t } = useTranslation("player");
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
const [loading, setLoading] = useState(true);
const degradedWalletPollRef = useRef<number | null>(null);
@@ -83,7 +85,7 @@ export function HallWalletStrip() {
const availableMinor = Number(balance?.available_balance ?? 0);
return (
<section className="mb-4 space-y-3" aria-label="Wallet balance">
<section className="mb-4 space-y-3" aria-label={t("wallet.balance")}>
<div
className={cn(
"relative overflow-hidden rounded-xl bg-[#e5002c] px-4 py-4 text-white shadow-[0_10px_28px_rgba(229,0,44,0.25)]",
@@ -95,7 +97,7 @@ export function HallWalletStrip() {
<Wallet className="size-7" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-white/90">Wallet Balance</p>
<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" />
) : (
@@ -111,7 +113,7 @@ export function HallWalletStrip() {
<TransferInDialog
idPrefix="hall-"
triggerVariant="hall"
triggerLabel="Transfer In"
triggerLabel={t("wallet.transferIn")}
triggerClassName="h-12 rounded-lg text-base font-bold"
currency={currency}
lotteryMinor={lotteryMinor}
@@ -120,7 +122,7 @@ export function HallWalletStrip() {
<TransferOutDialog
idPrefix="hall-"
triggerVariant="hall"
triggerLabel="Transfer Out"
triggerLabel={t("wallet.transferOut")}
triggerClassName="h-12 rounded-lg text-base font-bold"
currency={currency}
availableMinor={availableMinor}

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { getDrawCurrent } from "@/api/draw";
import { getLotteryEcho } from "@/lib/lottery-echo";
@@ -76,17 +76,11 @@ export function useHallDrawLive(): {
setRaw(d);
setEmittedAtMs(Date.now());
} catch {
setError("加载失败,请下拉刷新");
setError("draw.loadFailedRefresh");
setRaw(undefined);
}
}, []);
// WebSocket 正常时的轮询间隔(作为备用)
const refreshMs = useMemo(() => {
if (raw === undefined) return 30_000;
return raw ? 60_000 : 30_000; // WebSocket正常时减少轮询频率
}, [raw]);
// 初始加载
useEffect(() => {
const timer = window.setTimeout(() => {
@@ -199,14 +193,22 @@ export function useHallDrawLive(): {
if (!isWebSocketConnected && mode !== "websocket") {
const currentPollingId = useNetworkConnectionStore.getState().drawPollingIntervalId;
if (!currentPollingId) {
// 立即执行一次
void load();
const initialLoadId = window.setTimeout(() => {
void load();
}, 0);
// 设置30秒轮询
intervalId = window.setInterval(() => {
void load();
}, 30_000);
setDrawPollingIntervalId(intervalId);
return () => {
window.clearTimeout(initialLoadId);
if (intervalId) {
window.clearInterval(intervalId);
setDrawPollingIntervalId(null);
}
};
}
}