refactor: 重构大厅组件以优化状态管理与数据加载

- 在 HallDrawPanel 组件中引入 useHallDrawLive 自定义 Hook,简化状态管理与数据获取逻辑
- 移除不必要的状态与副作用,提升组件性能
- 在 HallScreen 组件中替换 Card 组件为 HallBettingGrid,优化下注表格展示
- 在 HallWalletStrip 组件中添加事件监听以支持钱包刷新功能
This commit is contained in:
2026-05-11 11:52:58 +08:00
parent ea75120269
commit 09ef46e171
13 changed files with 1145 additions and 128 deletions

View File

@@ -0,0 +1,463 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { getPlayEffective } from "@/api/play";
import { postTicketPlace, postTicketPreview } from "@/api/ticket";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { mapTicketBetError } from "@/features/hall/hall-bet-errors";
import { HallBetAmountInput } from "@/features/hall/hall-bet-amount-input";
import { HallBetPreviewDialog } from "@/features/hall/hall-bet-preview-dialog";
import { HallBetNumberInput } from "@/features/hall/hall-bet-number-input";
import {
playNeedsDigitSlot,
playNeedsDimension,
ticketAmountHint,
ticketNumberSpec,
} from "@/features/hall/hall-bet-rules";
import { HallPlaySwitcher, type PlayChip } from "@/features/hall/hall-play-switcher";
import { useHallDrawLive } from "@/features/hall/use-hall-draw-live";
import { getLotteryRequestLocale } from "@/lib/lottery-locale";
import { formatMinorAsCurrency, parseDecimalInputToMinor } from "@/lib/money";
import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors";
import type { PlayEffectivePayload, PlayEffectivePlayRow } from "@/types/api/play-effective";
import type { TicketLineInput, TicketPreviewData } from "@/types/api/ticket";
const DEFAULT_POLL_MS = 120_000;
function isPlayOpenForPlayer(row: PlayEffectivePlayRow): boolean {
if (!row.master_enabled || row.config === null) {
return false;
}
return row.config.is_enabled;
}
function pickDisplayName(row: PlayEffectivePlayRow): string {
const loc = getLotteryRequestLocale();
if (loc === "zh") {
return row.display_name_zh ?? row.display_name_en ?? row.play_code;
}
if (loc === "ne") {
return row.display_name_ne ?? row.display_name_en ?? row.play_code;
}
return row.display_name_en ?? row.display_name_zh ?? row.play_code;
}
function digitSlotOptions(dimension: "D2" | "D3" | "D4"): { value: number; label: string }[] {
if (dimension === "D2") {
return [
{ value: 2, label: "十位" },
{ value: 3, label: "个位" },
];
}
if (dimension === "D3") {
return [
{ value: 1, label: "百位" },
{ value: 2, label: "十位" },
{ value: 3, label: "个位" },
];
}
return [
{ value: 0, label: "千位" },
{ value: 1, label: "百位" },
{ value: 2, label: "十位" },
{ value: 3, label: "个位" },
];
}
function numberHelper(playCode: string, spec: ReturnType<typeof ticketNumberSpec>): string | null {
if (spec.mode === "roll") {
return "Roll共 4 位,须包含字母 R 表示滚动位其余为数字0-9。";
}
if (playCode.startsWith("pos_")) {
return "位置玩法请输入对应位数2D / 3D系统按后 2/3 位展开为全部 4D 组合。";
}
if (playCode === "head") {
return "Head请输入 1 个数字0-9用于生成千位为 5-9 的全部组合。";
}
if (playCode === "tail") {
return "Tail请输入 1 个数字0-9用于生成千位为 0-4 的全部组合。";
}
if (playCode === "odd" || playCode === "even") {
return "单双请选择维度2D/3D/4D后输入 1 个数字0-9。";
}
if (playCode === "digit_big" || playCode === "digit_small") {
return "大小:请选择维度与具体位数后输入 1 个数字0-9。";
}
return null;
}
function rollInputValid(v: string): boolean {
return v.length === 4 && v.includes("R") && /^[0-9R]+$/i.test(v);
}
/**
* 下注大厅表格:号码 / 金额 / 玩法切换、预览与确认、结果提示(实施计划 §13.3,产品文档 §4.2 / §6.3)。
*/
export function HallBettingGrid() {
const { display, isBettable, reload: reloadDraw } = useHallDrawLive();
const currencyParam = useMemo(() => {
const fromEnv = process.env.NEXT_PUBLIC_LOTTERY_PLAY_CURRENCY?.trim();
return fromEnv !== undefined && fromEnv !== "" ? fromEnv : undefined;
}, []);
const [catalogState, setCatalogState] = useState<
| { kind: "loading" }
| { kind: "ok"; data: PlayEffectivePayload }
| { kind: "error"; message: string }
>({ kind: "loading" });
const loadCatalog = useCallback(async () => {
setCatalogState((s) => (s.kind === "ok" ? s : { kind: "loading" }));
try {
const data = await getPlayEffective(
currencyParam !== undefined ? { currency: currencyParam } : undefined,
);
setCatalogState({ kind: "ok", data });
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : "加载玩法失败,请稍后重试。";
setCatalogState({ kind: "error", message: msg });
}
}, [currencyParam]);
useEffect(() => {
queueMicrotask(() => {
void loadCatalog();
});
}, [loadCatalog]);
useEffect(() => {
const id = window.setInterval(() => {
void loadCatalog();
}, DEFAULT_POLL_MS);
return () => window.clearInterval(id);
}, [loadCatalog]);
const openPlays = useMemo(() => {
if (catalogState.kind !== "ok") return [];
return [...catalogState.data.plays]
.filter(isPlayOpenForPlayer)
.filter((p) => p.play_code !== "half_box")
.sort((a, b) => a.sort_order - b.sort_order || a.play_code.localeCompare(b.play_code));
}, [catalogState]);
const [playCode, setPlayCode] = useState("");
const [number, setNumber] = useState("");
const [amountStr, setAmountStr] = useState("");
const [dimension, setDimension] = useState<"D2" | "D3" | "D4">("D4");
const [digitSlot, setDigitSlot] = useState(3);
/** 目录刷新后若原玩法关闭,自动回落到列表首个开放玩法(不依赖 effect 写 state */
const activePlayCode = useMemo(() => {
if (openPlays.length === 0) return "";
if (playCode && openPlays.some((p) => p.play_code === playCode)) {
return playCode;
}
return openPlays[0].play_code;
}, [openPlays, playCode]);
const slotOpts = useMemo(() => digitSlotOptions(dimension), [dimension]);
const activeDigitSlot = useMemo(() => {
if (slotOpts.some((o) => o.value === digitSlot)) {
return digitSlot;
}
return slotOpts[0].value;
}, [digitSlot, slotOpts]);
const spec = useMemo(() => ticketNumberSpec(activePlayCode), [activePlayCode]);
const selectedRow = useMemo(
() => openPlays.find((p) => p.play_code === activePlayCode),
[openPlays, activePlayCode],
);
const chips: PlayChip[] = useMemo(
() => openPlays.map((p) => ({ play_code: p.play_code, label: pickDisplayName(p) })),
[openPlays],
);
const currencyCode =
catalogState.kind === "ok" ? catalogState.data.currency_code : "NPR";
const minBet = selectedRow?.config?.min_bet_amount ?? 1;
const maxBet = selectedRow?.config?.max_bet_amount ?? 999_999_999;
const tableDisabled = !isBettable || catalogState.kind !== "ok";
const [previewOpen, setPreviewOpen] = useState(false);
const [previewData, setPreviewData] = useState<TicketPreviewData | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [placeLoading, setPlaceLoading] = useState(false);
const buildLine = useCallback((): TicketLineInput | null => {
if (!activePlayCode) return null;
const minor = parseDecimalInputToMinor(amountStr);
if (minor === null || minor < minBet || minor > maxBet) {
return null;
}
if (spec.mode === "roll") {
if (!rollInputValid(number)) return null;
} else if (number.length !== spec.maxChars) {
return null;
}
const line: TicketLineInput = {
number,
play_code: activePlayCode,
amount: minor,
};
if (playNeedsDimension(activePlayCode)) {
line.dimension = dimension;
}
if (playNeedsDigitSlot(activePlayCode)) {
line.digit_slot = activeDigitSlot;
}
return line;
}, [
activeDigitSlot,
activePlayCode,
amountStr,
dimension,
maxBet,
minBet,
number,
spec.maxChars,
spec.mode,
]);
const handlePreview = async () => {
if (!display) {
toast.error("暂无当期期号,无法预览。");
return;
}
if (!isBettable) {
toast.error("当前已封盘或不可下注,无法预览。");
return;
}
const line = buildLine();
if (!line) {
toast.error("请检查号码长度与金额是否在玩法限额内。");
return;
}
setPreviewLoading(true);
try {
const data = await postTicketPreview({
draw_id: display.draw_no,
currency_code: currencyCode,
client_trace_id: `pv-${typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : String(Date.now())}`,
lines: [line],
});
setPreviewData(data);
setPreviewOpen(true);
} catch (e) {
const code = e instanceof LotteryApiBizError ? e.code : 0;
const msg = e instanceof LotteryApiBizError ? e.message : "预览失败";
toast.error(mapTicketBetError(code, msg));
} finally {
setPreviewLoading(false);
}
};
const handlePlace = async () => {
if (!display || !previewData) return;
const line = buildLine();
if (!line) {
toast.error("提交前数据已变化,请关闭预览后重试。");
return;
}
setPlaceLoading(true);
try {
const data = await postTicketPlace({
draw_id: display.draw_no,
currency_code: currencyCode,
client_trace_id:
typeof crypto !== "undefined" && crypto.randomUUID
? crypto.randomUUID()
: `pl-${Date.now()}`,
lines: [line],
expected_config_versions: previewData.config_versions,
});
toast.success(
`下注成功,订单号 ${data.order_no},实扣 ${formatMinorAsCurrency(data.summary.total_actual_deduct, currencyCode)}`,
);
setPreviewOpen(false);
setPreviewData(null);
setAmountStr("");
setNumber("");
window.dispatchEvent(new Event("lottery-wallet-refresh"));
void reloadDraw();
} catch (e) {
const code = e instanceof LotteryApiBizError ? e.code : 0;
const msg = e instanceof LotteryApiBizError ? e.message : "提交失败";
toast.error(mapTicketBetError(code, msg));
} finally {
setPlaceLoading(false);
}
};
const body = (() => {
if (catalogState.kind === "loading") {
return <p className="text-sm text-muted-foreground"></p>;
}
if (catalogState.kind === "error") {
return (
<div className="space-y-2">
<p className="text-sm text-destructive">{catalogState.message}</p>
<Button type="button" size="sm" variant="secondary" onClick={() => void loadCatalog()}>
</Button>
</div>
);
}
if (openPlays.length === 0) {
return <p className="text-sm text-muted-foreground"></p>;
}
return (
<div
className={cn(
"space-y-5 transition-opacity",
tableDisabled && "pointer-events-none opacity-50",
)}
>
{!isBettable && display ? (
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-800 dark:text-rose-200">
{display.status} §6.3 §4.2
</p>
) : null}
<HallPlaySwitcher
plays={chips}
value={activePlayCode}
onChange={(code) => {
setPlayCode(code);
setNumber("");
}}
disabled={tableDisabled}
/>
{playNeedsDimension(activePlayCode) ? (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="bet-dimension"></Label>
<select
id="bet-dimension"
disabled={tableDisabled}
value={dimension}
onChange={(e) => {
const d = e.target.value as "D2" | "D3" | "D4";
setDimension(d);
setDigitSlot(digitSlotOptions(d)[0].value);
}}
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<option value="D4">4D</option>
<option value="D3">3D</option>
<option value="D2">2D</option>
</select>
</div>
{playNeedsDigitSlot(activePlayCode) ? (
<div className="space-y-2">
<Label htmlFor="bet-digit-slot"></Label>
<select
id="bet-digit-slot"
disabled={tableDisabled}
value={String(activeDigitSlot)}
onChange={(e) => setDigitSlot(Number(e.target.value))}
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{slotOpts.map((o) => (
<option key={o.value} value={String(o.value)}>
{o.label}slot {o.value}
</option>
))}
</select>
</div>
) : (
<div aria-hidden className="hidden sm:block" />
)}
</div>
) : null}
<HallBetNumberInput
id="bet-number"
label="号码"
value={number}
onChange={setNumber}
spec={spec}
disabled={tableDisabled}
helper={numberHelper(activePlayCode, spec)}
/>
<HallBetAmountInput
id="bet-amount"
label="金额(主货币,如 10.00"
value={amountStr}
onChange={setAmountStr}
currencyCode={currencyCode}
minBetMinor={minBet}
maxBetMinor={maxBet}
disabled={tableDisabled}
hint={ticketAmountHint(activePlayCode)}
/>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-muted-foreground">
= × (1 ) §16.1§6.4
</p>
<Button
type="button"
className="sm:min-w-36"
disabled={tableDisabled || previewLoading || openPlays.length === 0}
onClick={() => void handlePreview()}
>
{previewLoading ? "预览中…" : "预览下注"}
</Button>
</div>
{!isBettable && display ? (
<Button type="button" variant="secondary" disabled className="w-full">
</Button>
) : null}
</div>
);
})();
return (
<>
<Card className={cn(!isBettable && display && "border-rose-500/30")}>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">{body}</CardContent>
</Card>
<HallBetPreviewDialog
open={previewOpen}
onOpenChange={(o) => {
setPreviewOpen(o);
if (!o) setPreviewData(null);
}}
currencyCode={currencyCode}
data={previewData}
placing={placeLoading}
onConfirmPlace={() => void handlePlace()}
/>
</>
);
}