refactor: 完成全站国际化改造,统一多语言支持
此提交完成了全项目的国际化适配: 1. 新增多语言翻译文件与基础配置 2. 替换所有硬编码文本为i18n调用 3. 优化语言切换与文档语言同步逻辑 4. 重构部分业务逻辑以支持动态翻译 5. 移除过时代码与硬编码配置
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
export type DrawStatusHud = {
|
||||
label: string;
|
||||
labelKey: string;
|
||||
/** Tailwind 颜色类:状态圆点 */
|
||||
dotClass: string;
|
||||
/** 文案条(如「距封盘」) */
|
||||
@@ -18,26 +18,26 @@ export function isHallSealedCountdownUi(status: string): boolean {
|
||||
export function drawStatusHud(status: string): DrawStatusHud {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return { label: "未开始", dotClass: "bg-muted-foreground", countdownKind: "none" };
|
||||
return { labelKey: "draw.status.pending", dotClass: "bg-muted-foreground", countdownKind: "none" };
|
||||
case "open":
|
||||
return { label: "可下注", dotClass: "bg-emerald-500", countdownKind: "close" };
|
||||
return { labelKey: "draw.status.open", dotClass: "bg-emerald-500", countdownKind: "close" };
|
||||
case "closing":
|
||||
return { label: "已封盘", dotClass: "bg-rose-500", countdownKind: "draw" };
|
||||
return { labelKey: "draw.status.closing", dotClass: "bg-rose-500", countdownKind: "draw" };
|
||||
case "closed":
|
||||
return { label: "待开奖", dotClass: "bg-amber-500", countdownKind: "draw" };
|
||||
return { labelKey: "draw.status.closed", dotClass: "bg-amber-500", countdownKind: "draw" };
|
||||
case "drawing":
|
||||
return { label: "开奖中", dotClass: "bg-sky-500", countdownKind: "none" };
|
||||
return { labelKey: "draw.status.drawing", dotClass: "bg-sky-500", countdownKind: "none" };
|
||||
case "review":
|
||||
return { label: "待审核", dotClass: "bg-violet-500", countdownKind: "none" };
|
||||
return { labelKey: "draw.status.review", dotClass: "bg-violet-500", countdownKind: "none" };
|
||||
case "cooldown":
|
||||
return { label: "冷静期", dotClass: "bg-cyan-500", countdownKind: "cooldown" };
|
||||
return { labelKey: "draw.status.cooldown", dotClass: "bg-cyan-500", countdownKind: "cooldown" };
|
||||
case "settling":
|
||||
return { label: "结算中", dotClass: "bg-blue-600", countdownKind: "none" };
|
||||
return { labelKey: "draw.status.settling", dotClass: "bg-blue-600", countdownKind: "none" };
|
||||
case "settled":
|
||||
return { label: "已结算", dotClass: "bg-muted-foreground", countdownKind: "none" };
|
||||
return { labelKey: "draw.status.settled", dotClass: "bg-muted-foreground", countdownKind: "none" };
|
||||
case "cancelled":
|
||||
return { label: "已取消", dotClass: "bg-muted-foreground", countdownKind: "none" };
|
||||
return { labelKey: "draw.status.cancelled", dotClass: "bg-muted-foreground", countdownKind: "none" };
|
||||
default:
|
||||
return { label: status, dotClass: "bg-muted-foreground", countdownKind: "none" };
|
||||
return { labelKey: status, dotClass: "bg-muted-foreground", countdownKind: "none" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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", "下注失败,请稍后重试。");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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") ??
|
||||
"金额为该笔注单的下注额(最小货币单位整数,与钱包一致)。"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,22 +4,26 @@ export function ticketStatusDisplay(
|
||||
status: string,
|
||||
winMinor: number,
|
||||
jackpotMinor: number,
|
||||
t?: (key: string, options?: { defaultValue?: string; status?: string }) => string,
|
||||
): { label: string; dotClass: string; ring?: boolean } {
|
||||
const total = winMinor + jackpotMinor;
|
||||
if (status === "success") {
|
||||
return { label: "待开奖", dotClass: "bg-sky-500" };
|
||||
return { label: t?.("ticketStatus.success") ?? "待开奖", dotClass: "bg-sky-500" };
|
||||
}
|
||||
if (status === "settled_win" && total > 0) {
|
||||
return { label: "已派彩", dotClass: "bg-emerald-500" };
|
||||
return { label: t?.("ticketStatus.settled_win") ?? "已派彩", dotClass: "bg-emerald-500" };
|
||||
}
|
||||
if (status === "settled_lose" || status === "settled_win") {
|
||||
return {
|
||||
label: "未中奖",
|
||||
label: t?.("ticketStatus.settled_lose") ?? "未中奖",
|
||||
dotClass: "bg-background",
|
||||
ring: true,
|
||||
};
|
||||
}
|
||||
return { label: status, dotClass: "bg-red-500" };
|
||||
return {
|
||||
label: t?.("ticketStatus.unknown", { status, defaultValue: status }) ?? status,
|
||||
dotClass: "bg-red-500",
|
||||
};
|
||||
}
|
||||
|
||||
export function StatusDot({
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getTicketItemDetail } from "@/api/ticket-items";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -18,46 +20,31 @@ import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-st
|
||||
import { formatLotteryInstant } from "@/lib/player-datetime";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import { norm4d } from "@/lib/norm-4d";
|
||||
import { playLabelZh } from "@/lib/play-labels";
|
||||
import { playLabel } from "@/lib/play-labels";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TicketItemDetailPayload } from "@/types/api/ticket-items";
|
||||
|
||||
type OddsSnapRow = { prize_scope?: string; odds_value?: number };
|
||||
|
||||
function formatOddsSnapshot(json: unknown): string {
|
||||
function formatOddsSnapshot(
|
||||
json: unknown,
|
||||
t: (key: string, options?: { defaultValue?: string }) => string,
|
||||
): string {
|
||||
if (!Array.isArray(json)) return "—";
|
||||
const parts = (json as OddsSnapRow[])
|
||||
.filter((r) => r.prize_scope && r.odds_value != null)
|
||||
.map((r) => {
|
||||
const scope = String(r.prize_scope);
|
||||
const label =
|
||||
scope === "first"
|
||||
? "头奖"
|
||||
: scope === "second"
|
||||
? "二奖"
|
||||
: scope === "third"
|
||||
? "三奖"
|
||||
: scope === "starter"
|
||||
? "特别奖"
|
||||
: scope === "consolation"
|
||||
? "安慰奖"
|
||||
: scope;
|
||||
const label = t(`prizeTier.${scope}`, { defaultValue: scope });
|
||||
const mult = Number(r.odds_value) / 10_000;
|
||||
return `${label} ${mult}x`;
|
||||
});
|
||||
return parts.length ? parts.join(" · ") : "—";
|
||||
}
|
||||
|
||||
const TIER_ZH: Record<string, string> = {
|
||||
first: "头奖",
|
||||
second: "二奖",
|
||||
third: "三奖",
|
||||
starter: "特别奖",
|
||||
consolation: "安慰奖",
|
||||
};
|
||||
|
||||
/** 界面文档 §4.8 注单详情 */
|
||||
export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||
const { t } = useTranslation("player");
|
||||
const [data, setData] = useState<TicketItemDetailPayload | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -70,11 +57,11 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||
setData(row);
|
||||
} catch {
|
||||
setData(null);
|
||||
setError("注单不存在或无权查看");
|
||||
setError(t("orders.notFound"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [ticketNo]);
|
||||
}, [ticketNo, t]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
@@ -84,39 +71,42 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-40" />
|
||||
<Skeleton className="h-4 w-56" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<PlayerPanel
|
||||
title={t("orders.betDetail")}
|
||||
subtitle={ticketNo}
|
||||
eyebrow={t("orders.title")}
|
||||
backHref="/orders"
|
||||
backLabel={t("orders.title")}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-12 rounded-xl" />
|
||||
<Skeleton className="h-56 rounded-xl" />
|
||||
</div>
|
||||
</PlayerPanel>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<Card className="border-destructive/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">注单详情</CardTitle>
|
||||
<CardDescription>{error ?? "无数据"}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2">
|
||||
<PlayerPanel
|
||||
title={t("orders.betDetail")}
|
||||
subtitle={ticketNo}
|
||||
eyebrow={t("orders.title")}
|
||||
backHref="/orders"
|
||||
backLabel={t("orders.title")}
|
||||
>
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-4 text-sm text-red-700">
|
||||
<p>{error ?? t("orders.noData")}</p>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
|
||||
重试
|
||||
{t("actions.retry")}
|
||||
</Button>
|
||||
<Link href="/orders" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
返回列表
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PlayerPanel>
|
||||
);
|
||||
}
|
||||
|
||||
const cur = data.currency_code ?? "NPR";
|
||||
const st = ticketStatusDisplay(data.status, data.win_amount, data.jackpot_win_amount);
|
||||
const st = ticketStatusDisplay(data.status, data.win_amount, data.jackpot_win_amount, t);
|
||||
const totalWin = data.win_amount + data.jackpot_win_amount;
|
||||
const pub = data.published_draw_results;
|
||||
const first = pub?.results?.["1st"] ?? "";
|
||||
@@ -146,119 +136,144 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
|
||||
: null;
|
||||
|
||||
const tierLabel = data.settlement?.matched_prize_tier
|
||||
? TIER_ZH[data.settlement.matched_prize_tier] ?? data.settlement.matched_prize_tier
|
||||
? t(`prizeTier.${data.settlement.matched_prize_tier}`, {
|
||||
defaultValue: data.settlement.matched_prize_tier,
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card>
|
||||
<CardHeader className="space-y-2 pb-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<CardTitle className="text-base">注单详情</CardTitle>
|
||||
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
|
||||
</div>
|
||||
<CardDescription className="font-mono text-xs">
|
||||
注单号 {data.ticket_no} · 订单 {data.order_no ?? "—"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="grid gap-1 text-xs">
|
||||
<p>
|
||||
<span className="text-muted-foreground">期号</span>{" "}
|
||||
<span className="font-mono font-medium">{data.draw_no ?? "—"}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">下单时间</span>{" "}
|
||||
{formatLotteryInstant(data.placed_at ?? null)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">号码</span>{" "}
|
||||
<span className="font-mono">{data.original_number ?? "—"}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">玩法</span> {playLabelZh(data.play_code)} (
|
||||
{data.dimension ?? "—"}D)
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">下注金额</span>{" "}
|
||||
{formatMinorAsCurrency(data.total_bet_amount, cur)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">回水率</span>{" "}
|
||||
{(Number(data.rebate_rate_snapshot) * 100).toFixed(1)}%
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">实扣金额</span>{" "}
|
||||
{formatMinorAsCurrency(data.actual_deduct_amount, cur)}
|
||||
</p>
|
||||
</div>
|
||||
<PlayerPanel
|
||||
title={t("orders.betDetail")}
|
||||
subtitle={data.ticket_no}
|
||||
eyebrow={t("orders.title")}
|
||||
backHref="/orders"
|
||||
backLabel={t("orders.title")}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card className="rounded-xl border-[#e5edf8] shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
|
||||
<CardHeader className="space-y-2 pb-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<CardTitle className="text-base">{t("orders.detailTitle")}</CardTitle>
|
||||
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
|
||||
</div>
|
||||
<CardDescription className="font-mono text-xs">
|
||||
{t("orders.ticketNo", { ticketNo: data.ticket_no })} ·{" "}
|
||||
{t("orders.orderNo", { orderNo: data.order_no ?? "—" })}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="grid gap-1 text-xs">
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("orders.drawNo")}</span>{" "}
|
||||
<span className="font-mono font-medium">{data.draw_no ?? "—"}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("orders.placedAt")}</span>{" "}
|
||||
{formatLotteryInstant(data.placed_at ?? null)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("orders.number")}</span>{" "}
|
||||
<span className="font-mono">{data.original_number ?? "—"}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("orders.play")}</span>{" "}
|
||||
{playLabel(data.play_code, t)} (
|
||||
{data.dimension ?? "—"}D)
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("orders.amount")}</span>{" "}
|
||||
{formatMinorAsCurrency(data.total_bet_amount, cur)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("orders.rebateRate")}</span>{" "}
|
||||
{(Number(data.rebate_rate_snapshot) * 100).toFixed(1)}%
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("orders.actualDeduct")}</span>{" "}
|
||||
{formatMinorAsCurrency(data.actual_deduct_amount, cur)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border bg-muted/30 px-3 py-2 text-xs">
|
||||
<p className="font-medium text-foreground">赔率快照</p>
|
||||
<p className="mt-1 text-muted-foreground">{formatOddsSnapshot(data.odds_snapshot_json)}</p>
|
||||
<p className="font-medium text-foreground">{t("orders.oddsSnapshot")}</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{formatOddsSnapshot(data.odds_snapshot_json, t)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{pub?.results ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">开奖号码(本期)</p>
|
||||
<p className="text-sm font-medium">{t("orders.drawNumbers")}</p>
|
||||
<TwentyThreeResultsGrid numbers={pub.results} highlighted4d={highlight} />
|
||||
{first ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
头奖{" "}
|
||||
{t("orders.firstPrize")}{" "}
|
||||
<span className="font-mono font-semibold text-foreground">{first}</span>
|
||||
{comboHits.length > 0 ? (
|
||||
<span className="text-emerald-600 dark:text-emerald-400"> ← 命中</span>
|
||||
<span className="text-emerald-600 dark:text-emerald-400">
|
||||
{" "}
|
||||
← {t("orders.hit")}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">本期开奖号码尚未发布或不可展示。</p>
|
||||
<p className="text-xs text-muted-foreground">{t("orders.notPublished")}</p>
|
||||
)}
|
||||
|
||||
{data.settlement && tierLabel ? (
|
||||
<div className="rounded-md border border-emerald-500/20 bg-emerald-500/5 px-3 py-2 text-xs">
|
||||
<p className="font-medium text-emerald-900 dark:text-emerald-100">
|
||||
匹配结果:命中 {tierLabel}
|
||||
{t("orders.matchWin", { tier: tierLabel })}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-muted-foreground">
|
||||
中奖金额 {formatMinorAsCurrency(data.settlement.win_amount_minor, cur)}
|
||||
{t("orders.winAmount", {
|
||||
amount: formatMinorAsCurrency(data.settlement.win_amount_minor, cur),
|
||||
})}
|
||||
{data.settlement.jackpot_allocation_minor > 0 ? (
|
||||
<>
|
||||
{" "}
|
||||
· Jackpot {formatMinorAsCurrency(data.settlement.jackpot_allocation_minor, cur)}
|
||||
·{" "}
|
||||
{t("orders.jackpotAmount", {
|
||||
amount: formatMinorAsCurrency(
|
||||
data.settlement.jackpot_allocation_minor,
|
||||
cur,
|
||||
),
|
||||
})}
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-muted-foreground">
|
||||
派彩合计 {formatMinorAsCurrency(totalWin, cur)}
|
||||
{t("orders.payoutTotal", { amount: formatMinorAsCurrency(totalWin, cur) })}
|
||||
</p>
|
||||
</div>
|
||||
) : data.status === "settled_lose" ? (
|
||||
<p className="text-xs text-muted-foreground">匹配结果:未中奖</p>
|
||||
<p className="text-xs text-muted-foreground">{t("orders.matchLose")}</p>
|
||||
) : null}
|
||||
|
||||
{data.settled_at ? (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
结算时间 {formatLotteryInstant(data.settled_at)}
|
||||
{t("orders.settledAt", { time: formatLotteryInstant(data.settled_at) })}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{data.draw_no ? (
|
||||
<Link
|
||||
href={`/results/${encodeURIComponent(data.draw_no)}`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
|
||||
>
|
||||
查看本期开奖
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{data.draw_no ? (
|
||||
<Link
|
||||
href={`/results/${encodeURIComponent(data.draw_no)}`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
|
||||
>
|
||||
{t("orders.viewDraw")}
|
||||
</Link>
|
||||
) : null}
|
||||
<Link href="/orders" className={cn(buttonVariants({ variant: "secondary", size: "sm" }))}>
|
||||
{t("orders.backToOrders")}
|
||||
</Link>
|
||||
) : null}
|
||||
<Link href="/orders" className={cn(buttonVariants({ variant: "secondary", size: "sm" }))}>
|
||||
返回我的注单
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PlayerPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getTicketItems } from "@/api/ticket-items";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -11,11 +12,12 @@ import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import { formatLotteryInstant } from "@/lib/player-datetime";
|
||||
import { playLabelZh } from "@/lib/play-labels";
|
||||
import { playLabel } from "@/lib/play-labels";
|
||||
import type { TicketItemListRow } from "@/types/api/ticket-items";
|
||||
|
||||
export function TicketOrdersListScreen() {
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useTranslation("player");
|
||||
const drawNoFilter = useMemo(
|
||||
() => (searchParams.get("draw_no") ?? "").trim(),
|
||||
[searchParams],
|
||||
@@ -45,14 +47,14 @@ export function TicketOrdersListScreen() {
|
||||
setLastPage(res.last_page);
|
||||
setTotal(res.total);
|
||||
} catch {
|
||||
setError("加载失败");
|
||||
setError(t("orders.loadFailed"));
|
||||
if (!append) setItems([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
},
|
||||
[drawNoFilter],
|
||||
[drawNoFilter, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -67,13 +69,13 @@ export function TicketOrdersListScreen() {
|
||||
};
|
||||
|
||||
return (
|
||||
<PlayerPanel title="My Bets" subtitle="Recent ticket records" eyebrow="N lotto">
|
||||
<PlayerPanel title={t("orders.title")} subtitle={t("orders.subtitle")} eyebrow={t("brand.name")}>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-[#e6edf8] bg-[#f8fbff] p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-bold text-[#32518d]">
|
||||
{drawNoFilter ? "Filtered Issue" : "Total Records"}
|
||||
{drawNoFilter ? t("orders.filteredIssue") : t("orders.totalRecords")}
|
||||
</p>
|
||||
<p className="mt-1 truncate font-mono text-lg font-black text-[#0b3f96]">
|
||||
{drawNoFilter || total}
|
||||
@@ -84,14 +86,14 @@ export function TicketOrdersListScreen() {
|
||||
href="/orders"
|
||||
className="shrink-0 rounded-full border border-[#dce7f7] bg-white px-3 py-1.5 text-xs font-bold text-[#0b56b7]"
|
||||
>
|
||||
Clear
|
||||
{t("actions.clear")}
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
href="/hall"
|
||||
className="shrink-0 rounded-full bg-[#e5002c] px-3 py-1.5 text-xs font-bold text-white"
|
||||
>
|
||||
Bet Now
|
||||
{t("orders.betNow")}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
@@ -112,17 +114,17 @@ export function TicketOrdersListScreen() {
|
||||
className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]"
|
||||
onClick={() => void fetchPage(1, false)}
|
||||
>
|
||||
Retry
|
||||
{t("actions.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-[#dce7f7] bg-[#f8fbff] px-4 py-10 text-center">
|
||||
<p className="text-sm font-bold text-slate-700">No bet records yet.</p>
|
||||
<p className="text-sm font-bold text-slate-700">{t("orders.empty")}</p>
|
||||
<Link
|
||||
href="/hall"
|
||||
className="mt-4 inline-flex h-9 items-center rounded-lg bg-[#e5002c] px-4 text-sm font-bold text-white"
|
||||
>
|
||||
Submit Bet
|
||||
{t("orders.submitBet")}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
@@ -134,6 +136,7 @@ export function TicketOrdersListScreen() {
|
||||
row.status,
|
||||
row.win_amount,
|
||||
row.jackpot_win_amount,
|
||||
t,
|
||||
);
|
||||
const totalWin = row.win_amount + row.jackpot_win_amount;
|
||||
return (
|
||||
@@ -148,20 +151,24 @@ export function TicketOrdersListScreen() {
|
||||
{row.draw_no ?? "—"}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-xs text-slate-500">
|
||||
{playLabelZh(row.play_code)} · {row.original_number ?? row.play_code}
|
||||
{playLabel(row.play_code, t)} · {row.original_number ?? row.play_code}
|
||||
</p>
|
||||
</div>
|
||||
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
|
||||
<p className="text-[10px] font-bold uppercase text-[#7890b8]">Stake</p>
|
||||
<p className="text-[10px] font-bold uppercase text-[#7890b8]">
|
||||
{t("orders.stake")}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-black text-slate-900">
|
||||
{formatMinorAsCurrency(row.total_bet_amount, cur)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
|
||||
<p className="text-[10px] font-bold uppercase text-[#7890b8]">Deduction</p>
|
||||
<p className="text-[10px] font-bold uppercase text-[#7890b8]">
|
||||
{t("orders.deduction")}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-black text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(row.actual_deduct_amount, cur)}
|
||||
</p>
|
||||
@@ -169,7 +176,7 @@ export function TicketOrdersListScreen() {
|
||||
</div>
|
||||
{totalWin > 0 && row.status === "settled_win" ? (
|
||||
<p className="mt-2 text-xs font-bold text-emerald-600">
|
||||
Win {formatMinorAsCurrency(totalWin, cur)}
|
||||
{t("orders.win", { amount: formatMinorAsCurrency(totalWin, cur) })}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-2 text-[11px] text-slate-500">
|
||||
@@ -187,7 +194,7 @@ export function TicketOrdersListScreen() {
|
||||
disabled={loadingMore}
|
||||
onClick={() => loadMore()}
|
||||
>
|
||||
{loadingMore ? "Loading..." : "Load More"}
|
||||
{loadingMore ? t("actions.loading") : t("actions.loadMore")}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getPlayerMe, getPlayerPing } from "@/api/player";
|
||||
@@ -97,7 +97,6 @@ export function EntryGate() {
|
||||
usePlayerSessionStore();
|
||||
|
||||
const [phase, setPhase] = useState<Phase>("loading");
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [failureDetails, setFailureDetails] = useState<FailureRow[]>([]);
|
||||
const [steps, setSteps] = useState<EntryStep[]>(initialSteps());
|
||||
|
||||
@@ -107,15 +106,11 @@ export function EntryGate() {
|
||||
setSteps((prev) => prev.map((s) => (s.id === stepId ? { ...s, status } : s)));
|
||||
}, []);
|
||||
|
||||
const calculateProgress = useCallback((currentSteps: EntryStep[]) => {
|
||||
const doneCount = currentSteps.filter((s) => s.status === "done").length;
|
||||
const inProgressCount = currentSteps.filter((s) => s.status === "in-progress").length;
|
||||
return Math.round(((doneCount + inProgressCount * 0.5) / currentSteps.length) * 100);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setProgress(calculateProgress(steps));
|
||||
}, [steps, calculateProgress]);
|
||||
const progress = useMemo(() => {
|
||||
const doneCount = steps.filter((s) => s.status === "done").length;
|
||||
const inProgressCount = steps.filter((s) => s.status === "in-progress").length;
|
||||
return Math.round(((doneCount + inProgressCount * 0.5) / steps.length) * 100);
|
||||
}, [steps]);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
setPhase("loading");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { UserRound } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
@@ -10,11 +11,12 @@ import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
*/
|
||||
export function PlayerSessionBar({ className }: { className?: string }) {
|
||||
const profile = usePlayerSessionStore((s) => s.profile);
|
||||
const { t } = useTranslation("player");
|
||||
|
||||
const label =
|
||||
profile?.nickname?.trim() ||
|
||||
profile?.username?.trim() ||
|
||||
(profile?.id != null ? `玩家 #${profile.id}` : null);
|
||||
(profile?.id != null ? t("player.fallback", { id: profile.id }) : null);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getDrawResultByNo } from "@/api/draw";
|
||||
import { getTicketDrawMyMatch } from "@/api/ticket-items";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -29,6 +31,7 @@ type DrawResultDetailScreenProps = {
|
||||
|
||||
/** §4.6 开奖结果详情:23 分区 + [< >] 切换 + 本人命中高亮 + Jackpot */
|
||||
export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps) {
|
||||
const { t } = useTranslation("player");
|
||||
const [data, setData] = useState<DrawResultDetailPayload | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -47,11 +50,11 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
|
||||
setData(row);
|
||||
} catch {
|
||||
setData(null);
|
||||
setError("该期开奖结果不可用或不存在");
|
||||
setError(t("results.unavailable"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [drawNo]);
|
||||
}, [drawNo, t]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
@@ -98,34 +101,37 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-7 w-48" />
|
||||
<Skeleton className="h-4 w-56" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<PlayerPanel
|
||||
title={t("results.detailTitle")}
|
||||
subtitle={drawNo}
|
||||
eyebrow={t("results.title")}
|
||||
backHref="/results"
|
||||
backLabel={t("results.title")}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-12 rounded-xl" />
|
||||
<Skeleton className="h-56 rounded-xl" />
|
||||
</div>
|
||||
</PlayerPanel>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<Card className="border-destructive/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">开奖结果</CardTitle>
|
||||
<CardDescription>{error ?? "无数据"}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2">
|
||||
<PlayerPanel
|
||||
title={t("results.detailTitle")}
|
||||
subtitle={drawNo}
|
||||
eyebrow={t("results.title")}
|
||||
backHref="/results"
|
||||
backLabel={t("results.title")}
|
||||
>
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-4 text-sm text-red-700">
|
||||
<p>{error ?? t("results.noData")}</p>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
|
||||
重试
|
||||
{t("actions.retry")}
|
||||
</Button>
|
||||
<Link href="/results" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
返回列表
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PlayerPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -143,60 +149,81 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
|
||||
myTotals.jackpot === 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<JackpotResultsStrip currencyCode={currency} />
|
||||
<PlayerPanel
|
||||
title={t("results.detailTitle")}
|
||||
subtitle={data.draw_no}
|
||||
eyebrow={t("results.title")}
|
||||
backHref="/results"
|
||||
backLabel={t("results.title")}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<JackpotResultsStrip currencyCode={currency} />
|
||||
|
||||
<Card>
|
||||
<CardHeader className="space-y-3 pb-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
{data.previous_draw_no ? (
|
||||
<Link
|
||||
href={`/results/${encodeURIComponent(data.previous_draw_no)}`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "min-w-[5rem]")}
|
||||
>
|
||||
‹ 上一期
|
||||
</Link>
|
||||
) : (
|
||||
<Button type="button" variant="outline" size="sm" className="min-w-[5rem]" disabled>
|
||||
‹ 上一期
|
||||
</Button>
|
||||
)}
|
||||
<CardTitle className="text-center font-mono text-lg">
|
||||
{data.draw_no}
|
||||
</CardTitle>
|
||||
{data.next_draw_no ? (
|
||||
<Link
|
||||
href={`/results/${encodeURIComponent(data.next_draw_no)}`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "min-w-[5rem]")}
|
||||
>
|
||||
下一期 ›
|
||||
</Link>
|
||||
) : (
|
||||
<Button type="button" variant="outline" size="sm" className="min-w-[5rem]" disabled>
|
||||
下一期 ›
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription className="text-center font-mono text-sm">
|
||||
开奖时间:{" "}
|
||||
{formatLotteryInstant(data.draw_time_iso ?? data.draw_time ?? null)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-2">
|
||||
<TwentyThreeResultsGrid
|
||||
numbers={data.results}
|
||||
highlighted4d={highlightSet ?? undefined}
|
||||
/>
|
||||
<Card className="rounded-xl border-[#e5edf8] shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
|
||||
<CardHeader className="space-y-3 pb-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
{data.previous_draw_no ? (
|
||||
<Link
|
||||
href={`/results/${encodeURIComponent(data.previous_draw_no)}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"min-w-[5rem]",
|
||||
)}
|
||||
>
|
||||
{t("results.previous")}
|
||||
</Link>
|
||||
) : (
|
||||
<Button type="button" variant="outline" size="sm" className="min-w-[5rem]" disabled>
|
||||
{t("results.previous")}
|
||||
</Button>
|
||||
)}
|
||||
<CardTitle className="text-center font-mono text-lg">
|
||||
{data.draw_no}
|
||||
</CardTitle>
|
||||
{data.next_draw_no ? (
|
||||
<Link
|
||||
href={`/results/${encodeURIComponent(data.next_draw_no)}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"min-w-[5rem]",
|
||||
)}
|
||||
>
|
||||
{t("results.next")}
|
||||
</Link>
|
||||
) : (
|
||||
<Button type="button" variant="outline" size="sm" className="min-w-[5rem]" disabled>
|
||||
{t("results.next")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription className="text-center font-mono text-sm">
|
||||
{t("results.drawTime", {
|
||||
time: formatLotteryInstant(data.draw_time_iso ?? data.draw_time ?? null),
|
||||
})}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-2">
|
||||
<TwentyThreeResultsGrid
|
||||
numbers={data.results}
|
||||
highlighted4d={highlightSet ?? undefined}
|
||||
/>
|
||||
|
||||
{showMyPayout && myTotals ? (
|
||||
<div className="mt-4 rounded-md border border-emerald-500/25 bg-emerald-500/5 px-3 py-2 text-sm">
|
||||
<p className="font-medium text-emerald-900 dark:text-emerald-100">本期我的派彩</p>
|
||||
<p className="font-medium text-emerald-900 dark:text-emerald-100">
|
||||
{t("results.myPayout")}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-xs tabular-nums text-muted-foreground">
|
||||
常规:{formatMinorAsCurrency(myTotals.win, currency)}
|
||||
{t("results.regular", {
|
||||
amount: formatMinorAsCurrency(myTotals.win, currency),
|
||||
})}
|
||||
{myTotals.jackpot > 0 ? (
|
||||
<>
|
||||
{" "}
|
||||
· Jackpot:{formatMinorAsCurrency(myTotals.jackpot, currency)}
|
||||
·{" "}
|
||||
{t("results.jackpot", {
|
||||
amount: formatMinorAsCurrency(myTotals.jackpot, currency),
|
||||
})}
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
@@ -204,13 +231,13 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
|
||||
) : null}
|
||||
{showHitOnly ? (
|
||||
<p className="mt-3 text-xs text-amber-900/90 dark:text-amber-100/90">
|
||||
您的注单已命中本期开奖号码中的格子;派彩完成后将显示金额汇总。
|
||||
{t("results.hitPending")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
如果您中奖,与注单匹配的号码将以金色高亮显示(需登录)。
|
||||
{t("results.hitHint")}
|
||||
</p>
|
||||
<Link
|
||||
href={`/orders?draw_no=${encodeURIComponent(data.draw_no)}`}
|
||||
@@ -219,11 +246,12 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
|
||||
"w-full sm:w-auto sm:self-start",
|
||||
)}
|
||||
>
|
||||
查看我的中奖情况
|
||||
{t("results.viewMyWinning")}
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PlayerPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getDrawResults } from "@/api/draw";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -13,6 +14,7 @@ import { formatLotteryInstant } from "@/lib/player-datetime";
|
||||
import type { DrawResultListItem } from "@/types/api/draw-results";
|
||||
|
||||
export function DrawResultsListScreen() {
|
||||
const { t } = useTranslation("player");
|
||||
const [items, setItems] = useState<DrawResultListItem[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [date, setDate] = useState("");
|
||||
@@ -29,12 +31,12 @@ export function DrawResultsListScreen() {
|
||||
});
|
||||
setItems(res.items);
|
||||
} catch {
|
||||
setError("加载失败");
|
||||
setError(t("results.loadFailed"));
|
||||
setItems(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [date]);
|
||||
}, [date, t]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
@@ -43,12 +45,14 @@ export function DrawResultsListScreen() {
|
||||
}, [fetchList]);
|
||||
|
||||
return (
|
||||
<PlayerPanel title="Results" subtitle="Latest draw history" eyebrow="N lotto">
|
||||
<PlayerPanel title={t("results.title")} subtitle={t("results.subtitle")} eyebrow={t("brand.name")}>
|
||||
<div className="space-y-4">
|
||||
<JackpotResultsStrip currencyCode="NPR" />
|
||||
|
||||
<div className="rounded-xl border border-[#e6edf8] bg-[#f8fbff] p-3">
|
||||
<p className="mb-2 text-xs font-bold text-[#32518d]">Business Date</p>
|
||||
<p className="mb-2 text-xs font-bold text-[#32518d]">
|
||||
{t("results.businessDate")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
@@ -62,7 +66,7 @@ export function DrawResultsListScreen() {
|
||||
className="h-10 rounded-lg bg-[#07459f] px-4 text-white hover:bg-[#063b88]"
|
||||
onClick={() => void fetchList()}
|
||||
>
|
||||
Apply
|
||||
{t("actions.apply")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -82,12 +86,12 @@ export function DrawResultsListScreen() {
|
||||
className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]"
|
||||
onClick={() => void fetchList()}
|
||||
>
|
||||
Retry
|
||||
{t("actions.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
) : items && items.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-[#dce7f7] bg-[#f8fbff] px-4 py-10 text-center text-sm text-slate-500">
|
||||
No results yet.
|
||||
{t("results.empty")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
@@ -107,7 +111,7 @@ export function DrawResultsListScreen() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full bg-[#f2f6ff] px-2.5 py-1 text-xs font-bold text-[#0b56b7]">
|
||||
Detail
|
||||
{t("results.detail")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getJackpotSummary } from "@/api/jackpot";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
@@ -13,6 +14,7 @@ type JackpotResultsStripProps = {
|
||||
export function JackpotResultsStrip({
|
||||
currencyCode = "NPR",
|
||||
}: JackpotResultsStripProps) {
|
||||
const { t } = useTranslation("player");
|
||||
const [minor, setMinor] = useState<number | null>(null);
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
@@ -44,7 +46,7 @@ export function JackpotResultsStrip({
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-200 bg-gradient-to-r from-amber-100 via-white to-[#f8fbff] px-3 py-3 shadow-[0_8px_20px_rgba(180,83,9,0.08)]">
|
||||
<p className="text-[11px] font-black uppercase tracking-normal text-amber-700">
|
||||
Jackpot
|
||||
{t("results.jackpotLabel")}
|
||||
</p>
|
||||
<p className="font-mono text-lg font-black tabular-nums text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(minor, currencyCode.toUpperCase())}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { DrawResultsNumbers } from "@/types/api/draw-results";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { norm4d } from "@/lib/norm-4d";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -15,6 +17,7 @@ export function TwentyThreeResultsGrid({
|
||||
numbers,
|
||||
highlighted4d,
|
||||
}: TwentyThreeResultsGridProps) {
|
||||
const { t } = useTranslation("player");
|
||||
const starters = numbers.starter ?? [];
|
||||
const consos = numbers.consolation ?? [];
|
||||
const hits = highlighted4d ?? null;
|
||||
@@ -44,7 +47,11 @@ export function TwentyThreeResultsGrid({
|
||||
{(["1st", "2nd", "3rd"] as const).map((key) => (
|
||||
<div key={key} className="flex flex-col gap-1.5 text-center">
|
||||
<span className="text-xs font-medium uppercase text-muted-foreground">
|
||||
{key === "1st" ? "头奖" : key === "2nd" ? "二奖" : "三奖"}
|
||||
{key === "1st"
|
||||
? t("results.grid.first")
|
||||
: key === "2nd"
|
||||
? t("results.grid.second")
|
||||
: t("results.grid.third")}
|
||||
</span>
|
||||
<div className={cellTone(numbers[key] || "")}>
|
||||
{numbers[key] || "—"}
|
||||
@@ -54,7 +61,9 @@ export function TwentyThreeResultsGrid({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-foreground">特别奖 (Starter)</p>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t("results.grid.starter")} (Starter)
|
||||
</p>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<div key={`s-${i}`} className={cellTone(starters[i] ?? "—")}>
|
||||
@@ -65,7 +74,9 @@ export function TwentyThreeResultsGrid({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-foreground">安慰奖 (Consolation)</p>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t("results.grid.consolation")} (Consolation)
|
||||
</p>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<div key={`c-${i}`} className={cellTone(consos[i] ?? "—")}>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user