From 09ef46e1714ea3d7a3a9f21eadbd8e96fc29cd3b Mon Sep 17 00:00:00 2001 From: kang Date: Mon, 11 May 2026 11:52:58 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=E5=A4=A7?= =?UTF-8?q?=E5=8E=85=E7=BB=84=E4=BB=B6=E4=BB=A5=E4=BC=98=E5=8C=96=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E7=AE=A1=E7=90=86=E4=B8=8E=E6=95=B0=E6=8D=AE=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 HallDrawPanel 组件中引入 useHallDrawLive 自定义 Hook,简化状态管理与数据获取逻辑 - 移除不必要的状态与副作用,提升组件性能 - 在 HallScreen 组件中替换 Card 组件为 HallBettingGrid,优化下注表格展示 - 在 HallWalletStrip 组件中添加事件监听以支持钱包刷新功能 --- src/api/ticket.ts | 26 + src/features/hall/hall-bet-amount-input.tsx | 56 +++ src/features/hall/hall-bet-errors.ts | 30 ++ src/features/hall/hall-bet-number-input.tsx | 65 +++ src/features/hall/hall-bet-preview-dialog.tsx | 170 +++++++ src/features/hall/hall-bet-rules.ts | 58 +++ src/features/hall/hall-betting-grid.tsx | 463 ++++++++++++++++++ src/features/hall/hall-draw-panel.tsx | 116 +---- src/features/hall/hall-play-switcher.tsx | 62 +++ src/features/hall/hall-screen.tsx | 24 +- src/features/hall/hall-wallet-strip.tsx | 6 + src/features/hall/use-hall-draw-live.ts | 121 +++++ src/types/api/ticket.ts | 76 +++ 13 files changed, 1145 insertions(+), 128 deletions(-) create mode 100644 src/api/ticket.ts create mode 100644 src/features/hall/hall-bet-amount-input.tsx create mode 100644 src/features/hall/hall-bet-errors.ts create mode 100644 src/features/hall/hall-bet-number-input.tsx create mode 100644 src/features/hall/hall-bet-preview-dialog.tsx create mode 100644 src/features/hall/hall-bet-rules.ts create mode 100644 src/features/hall/hall-betting-grid.tsx create mode 100644 src/features/hall/hall-play-switcher.tsx create mode 100644 src/features/hall/use-hall-draw-live.ts create mode 100644 src/types/api/ticket.ts diff --git a/src/api/ticket.ts b/src/api/ticket.ts new file mode 100644 index 0000000..e113228 --- /dev/null +++ b/src/api/ticket.ts @@ -0,0 +1,26 @@ +import { lotteryRequest } from "@/lib/lottery-http"; +import { API_V1_PREFIX } from "@/api/paths"; +import type { + TicketPlaceData, + TicketPlacePayload, + TicketPreviewData, + TicketPreviewPayload, +} from "@/types/api/ticket"; + +/** `POST /api/v1/ticket/preview` — 不落库,用于确认弹窗(产品文档 §10.1.2) */ +export function postTicketPreview( + body: TicketPreviewPayload, +): Promise { + return lotteryRequest.post( + `${API_V1_PREFIX}/ticket/preview`, + body, + ); +} + +/** `POST /api/v1/ticket/place` — 真实下注 */ +export function postTicketPlace(body: TicketPlacePayload): Promise { + return lotteryRequest.post( + `${API_V1_PREFIX}/ticket/place`, + body, + ); +} diff --git a/src/features/hall/hall-bet-amount-input.tsx b/src/features/hall/hall-bet-amount-input.tsx new file mode 100644 index 0000000..5b015fa --- /dev/null +++ b/src/features/hall/hall-bet-amount-input.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { formatMinorAsCurrency } from "@/lib/money"; +import { cn } from "@/lib/utils"; + +type HallBetAmountInputProps = { + id: string; + label: string; + value: string; + onChange: (v: string) => void; + currencyCode: string; + minBetMinor: number; + maxBetMinor: number; + disabled?: boolean; + hint?: string; +}; + +/** + * 金额输入:展示限额(产品文档:最小/最大下注额),解析为最小货币单位由上层校验。 + */ +export function HallBetAmountInput({ + id, + label, + value, + onChange, + currencyCode, + minBetMinor, + maxBetMinor, + disabled, + hint, +}: HallBetAmountInputProps) { + return ( +
+
+ +

+ 限额 {formatMinorAsCurrency(minBetMinor, currencyCode)} —{" "} + {formatMinorAsCurrency(maxBetMinor, currencyCode)} +

+
+ onChange(e.target.value)} + className={cn("tabular-nums")} + placeholder="例如 100.00" + /> + {hint ?

{hint}

: null} +
+ ); +} diff --git a/src/features/hall/hall-bet-errors.ts b/src/features/hall/hall-bet-errors.ts new file mode 100644 index 0000000..69501b2 --- /dev/null +++ b/src/features/hall/hall-bet-errors.ts @@ -0,0 +1,30 @@ +/** + * 下注业务码与玩家可见说明(对齐 Laravel `ErrorCode` 与产品文档 §6.3 / §6.4)。 + */ +export function mapTicketBetError(code: number, fallbackMsg: string): string { + switch (code) { + case 4001: + return "该号码本期赔付池不足,已售罄。请更换号码、金额或玩法后重试。"; + case 2003: + case 1001: + return "余额不足,请先转入后再下注。"; + case 2001: + return "本期已封盘,无法继续下注。"; + case 2002: + return "该玩法已关闭,请选择其他玩法。"; + case 2004: + return "号码格式或长度不符合该玩法要求。"; + case 2005: + return "玩法参数不完整(如单双大小需选择位数与维度)。"; + case 2006: + return "当前期号不可下注。"; + case 2007: + return "该玩法暂不支持或缺少赔率配置。"; + case 2008: + return "赔率或玩法配置已更新,请关闭预览后重新操作。"; + case 1003: + return "下注金额超出该玩法允许范围。"; + default: + return fallbackMsg || "下注失败,请稍后重试。"; + } +} diff --git a/src/features/hall/hall-bet-number-input.tsx b/src/features/hall/hall-bet-number-input.tsx new file mode 100644 index 0000000..7bf0612 --- /dev/null +++ b/src/features/hall/hall-bet-number-input.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; +import type { TicketNumberSpec } from "@/features/hall/hall-bet-rules"; + +type HallBetNumberInputProps = { + id: string; + label: string; + value: string; + onChange: (v: string) => void; + spec: TicketNumberSpec; + disabled?: boolean; + helper?: string | null; +}; + +function sanitizeRoll(raw: string, maxChars: number): string { + const u = raw.toUpperCase().replace(/[^0-9R]/g, ""); + return u.slice(0, maxChars); +} + +function sanitizeDigits(raw: string, maxChars: number): string { + return raw.replace(/\D/g, "").slice(0, maxChars); +} + +/** + * 号码输入:长度与字符集随玩法变化(产品文档 §5 各玩法号码定义)。 + */ +export function HallBetNumberInput({ + id, + label, + value, + onChange, + spec, + disabled, + helper, +}: HallBetNumberInputProps) { + const handle = (raw: string) => { + if (spec.mode === "roll") { + onChange(sanitizeRoll(raw, spec.maxChars)); + } else { + onChange(sanitizeDigits(raw, spec.maxChars)); + } + }; + + return ( +
+ + handle(e.target.value)} + className={cn("font-mono text-base tracking-widest")} + placeholder={spec.mode === "roll" ? "如 12R4" : "0-9"} + maxLength={spec.maxChars} + /> + {helper ?

{helper}

: null} +
+ ); +} diff --git a/src/features/hall/hall-bet-preview-dialog.tsx b/src/features/hall/hall-bet-preview-dialog.tsx new file mode 100644 index 0000000..c5200c2 --- /dev/null +++ b/src/features/hall/hall-bet-preview-dialog.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { AlertTriangleIcon } from "lucide-react"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import { formatMinorAsCurrency } from "@/lib/money"; +import type { TicketPreviewData, TicketPreviewWarning } from "@/types/api/ticket"; + +type HallBetPreviewDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + currencyCode: string; + data: TicketPreviewData | null; + placing: boolean; + onConfirmPlace: () => void; +}; + +function WarningsBlock({ warnings }: { warnings: TicketPreviewWarning[] }) { + if (warnings.length === 0) return null; + return ( + + + 赔付池预警 + +

+ 产品文档 §6.4:以下号码本期赔付池占用较高,仍允许下注;若实际占用不足将售罄拒单。 +

+
    + {warnings.map((w, i) => ( +
  • + {w.number_4d} — {w.message} +
  • + ))} +
+
+
+ ); +} + +/** + * 预览弹窗 + 提交确认(产品文档 §10.1.2:预览不下单,确认后 place)。 + */ +export function HallBetPreviewDialog({ + open, + onOpenChange, + currencyCode, + data, + placing, + onConfirmPlace, +}: HallBetPreviewDialogProps) { + const summary = data?.summary; + const lines = data?.lines ?? []; + + return ( + + +
+ + 确认下注 + + 请核对号码、玩法与实扣金额;确认后将扣减彩票钱包且不可撤单(产品文档 §6.3)。 + + +
+ + +
+ {!data ? ( +

暂无预览数据

+ ) : ( + <> +
+

+ 期号{" "} + {data.draw.draw_id} · 状态{" "} + {data.draw.status} +

+ {summary ? ( +
    +
  • + 总下注{" "} + + {formatMinorAsCurrency(summary.total_bet_amount, currencyCode)} + +
  • +
  • + 回水抵扣{" "} + + {formatMinorAsCurrency(summary.total_rebate_amount, currencyCode)} + +
  • +
  • + 实扣金额{" "} + + {formatMinorAsCurrency(summary.total_actual_deduct, currencyCode)} + +
  • +
  • + 预估最高赔付{" "} + + {formatMinorAsCurrency(summary.total_estimated_payout, currencyCode)} + +
  • +
+ ) : null} +
+ + + +
+

注项明细

+
    + {lines.map((ln) => ( +
  • +
    + + #{ln.client_line_no} + + {ln.play_code} +
    +

    {ln.number}

    + +
    + 归一号码 + {ln.normalized_number} + 组合数 + {ln.combination_count} + 实扣 + + {formatMinorAsCurrency(ln.actual_deduct_amount, currencyCode)} + + 预估最高赔 + + {formatMinorAsCurrency(ln.estimated_max_payout, currencyCode)} + +
    +
  • + ))} +
+
+ + )} +
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/features/hall/hall-bet-rules.ts b/src/features/hall/hall-bet-rules.ts new file mode 100644 index 0000000..fdda511 --- /dev/null +++ b/src/features/hall/hall-bet-rules.ts @@ -0,0 +1,58 @@ +/** + * 与后端 {@link App\Services\Ticket\NumberNormalizer} 对齐的输入长度与金额语义提示。 + */ + +export type TicketNumberSpec = { + maxChars: number; + mode: "digits" | "roll"; +}; + +const PLAY_3D = new Set(["pos_3a", "pos_3b", "pos_3c", "pos_3abc"]); +const PLAY_2D = new Set(["pos_2a", "pos_2b", "pos_2c", "pos_2abc"]); + +export function ticketNumberSpec(playCode: string): TicketNumberSpec { + if (playCode === "roll") { + return { maxChars: 4, mode: "roll" }; + } + if (PLAY_3D.has(playCode)) { + return { maxChars: 3, mode: "digits" }; + } + if (PLAY_2D.has(playCode)) { + return { maxChars: 2, mode: "digits" }; + } + if ( + playCode === "head" || + playCode === "tail" || + playCode === "odd" || + playCode === "even" || + playCode === "digit_big" || + playCode === "digit_small" + ) { + return { maxChars: 1, mode: "digits" }; + } + return { maxChars: 4, mode: "digits" }; +} + +export function playNeedsDimension(playCode: string): boolean { + return ( + playCode === "odd" || + playCode === "even" || + playCode === "digit_big" || + playCode === "digit_small" + ); +} + +export function playNeedsDigitSlot(playCode: string): boolean { + return playCode === "digit_big" || playCode === "digit_small"; +} + +/** 产品文档:iBox/Roll 单注金额;mBox 总金额摊分 */ +export function ticketAmountHint(playCode: string): string { + if (playCode === "ibox" || playCode === "roll") { + return "本玩法金额为「单注金额」,系统按展开组合数计算总下注与实扣。"; + } + if (playCode === "mbox") { + return "本玩法金额为「总输入金额」,将均摊到各排列组合(向下取整到最小单位)。"; + } + return "金额为该笔注单的下注额(最小货币单位整数,与钱包一致)。"; +} diff --git a/src/features/hall/hall-betting-grid.tsx b/src/features/hall/hall-betting-grid.tsx new file mode 100644 index 0000000..7a69b2b --- /dev/null +++ b/src/features/hall/hall-betting-grid.tsx @@ -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): 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(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

加载可下注玩法…

; + } + if (catalogState.kind === "error") { + return ( +
+

{catalogState.message}

+ +
+ ); + } + if (openPlays.length === 0) { + return

当前没有开放玩法,请稍后再试。

; + } + + return ( +
+ {!isBettable && display ? ( +

+ 已封盘:本期状态为「{display.status}」,不可下注。按钮已锁定为「已封盘」(产品文档 §6.3、界面 §4.2)。 +

+ ) : null} + + { + setPlayCode(code); + setNumber(""); + }} + disabled={tableDisabled} + /> + + {playNeedsDimension(activePlayCode) ? ( +
+
+ + +
+ {playNeedsDigitSlot(activePlayCode) ? ( +
+ + +
+ ) : ( +
+ )} +
+ ) : null} + + + + + +
+

+ 实扣 = 下注额 × (1 − 回水率);预览可展示风险池占用预警(产品文档 §16.1、§6.4)。 +

+ +
+ + {!isBettable && display ? ( + + ) : null} +
+ ); + })(); + + return ( + <> + + + 下注 + + 选择玩法并输入号码、金额后先「预览下注」,于弹窗内确认提交。币种与限额来自当前生效配置。 + + + {body} + + + { + setPreviewOpen(o); + if (!o) setPreviewData(null); + }} + currencyCode={currencyCode} + data={previewData} + placing={placeLoading} + onConfirmPlace={() => void handlePlace()} + /> + + ); +} diff --git a/src/features/hall/hall-draw-panel.tsx b/src/features/hall/hall-draw-panel.tsx index 60c9b6f..2d65948 100644 --- a/src/features/hall/hall-draw-panel.tsx +++ b/src/features/hall/hall-draw-panel.tsx @@ -1,9 +1,7 @@ "use client"; import Link from "next/link"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { getDrawCurrent } from "@/api/draw"; import { Button, buttonVariants } from "@/components/ui/button"; import { Card, @@ -14,38 +12,12 @@ import { } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { drawStatusHud } from "@/features/draw/draw-status-meta"; +import { useHallDrawLive } from "@/features/hall/use-hall-draw-live"; import { formatSecondsClock } from "@/lib/format-gmt"; -import { getLotteryEcho } from "@/lib/lottery-echo"; import { formatLotteryInstant } from "@/lib/player-datetime"; import { cn } from "@/lib/utils"; import type { DrawCurrentPayload } from "@/types/api/draw-current"; -/** 界面文档 §2.1:`draw.countdown` / `draw.status_change` / `result.published` 载荷 */ -type HallWsEnvelope = { - data: DrawCurrentPayload | null; - emitted_at_ms?: number; -}; - -/** - * 「服务器时间为准」:以载荷里的 `seconds_*` 为基准、`emitted_at_ms` 为锚点在本地推演(兜底 HTTP 或未收到秒的间隙)。 - */ -function applySnapshotDrift( - payload: DrawCurrentPayload, - emittedAtMs: number, - nowMs: number, -): DrawCurrentPayload { - const elapsed = Math.max(0, Math.floor((nowMs - emittedAtMs) / 1000)); - return { - ...payload, - seconds_to_close: Math.max(0, payload.seconds_to_close - elapsed), - seconds_to_draw: Math.max(0, payload.seconds_to_draw - elapsed), - seconds_remaining_in_cooldown: - payload.seconds_remaining_in_cooldown == null - ? null - : Math.max(0, payload.seconds_remaining_in_cooldown - elapsed), - }; -} - function CountdownStrip({ hud, payload, @@ -96,80 +68,7 @@ function CountdownStrip({ * 降级:每 30s 轮询 `GET draw/current`。 */ export function HallDrawPanel() { - const [raw, setRaw] = useState(undefined); - const [emittedAtMs, setEmittedAtMs] = useState(() => Date.now()); - /** 推演用「当前毫秒」;`draw.countdown` 每秒到仍保留,避免零星丢包时停摆 */ - const [nowMs, setNowMs] = useState(() => Date.now()); - const [error, setError] = useState(null); - - const mergeFromWs = useCallback((evt: HallWsEnvelope) => { - setRaw(evt.data); - setEmittedAtMs(evt.emitted_at_ms ?? Date.now()); - }, []); - - const load = useCallback(async () => { - try { - setError(null); - const d = await getDrawCurrent(); - setRaw(d); - setEmittedAtMs(Date.now()); - } catch { - setError("加载失败,请下拉刷新"); - setRaw(undefined); - } - }, []); - - /** §2.2:WS 不可用或降级时每 30s 拉倒计时 */ - const refreshMs = useMemo(() => { - if (raw === undefined) return 10_000; - return raw ? 30_000 : 12_000; - }, [raw]); - - useEffect(() => { - const timer = window.setTimeout(() => { - void load(); - }, 0); - return () => window.clearTimeout(timer); - }, [load]); - - useEffect(() => { - const id = window.setInterval(() => { - void load(); - }, refreshMs); - return () => window.clearInterval(id); - }, [load, refreshMs]); - - useEffect(() => { - const bump = () => setNowMs(Date.now()); - bump(); - const sid = window.setInterval(bump, 1000); - const onVisibility = () => { - if (document.visibilityState === "visible") bump(); - }; - document.addEventListener("visibilitychange", onVisibility); - return () => { - window.clearInterval(sid); - document.removeEventListener("visibilitychange", onVisibility); - }; - }, []); - - useEffect(() => { - const echo = getLotteryEcho(); - if (!echo) return; - - echo - .channel("lottery-hall") - .listen(".draw.countdown", mergeFromWs) - .listen(".draw.status_change", mergeFromWs) - .listen(".result.published", mergeFromWs); - - return () => { - echo.leave("lottery-hall"); - }; - }, [mergeFromWs]); - - const display: DrawCurrentPayload | null | undefined = - raw === undefined || raw === null ? raw : applySnapshotDrift(raw, emittedAtMs, nowMs); + const { raw, display, error, reload } = useHallDrawLive(); if (error) { return ( @@ -179,7 +78,7 @@ export function HallDrawPanel() { {error} - @@ -245,14 +144,17 @@ export function HallDrawPanel() { {(display.status === "closing" || display.status === "closed") && ( -

- 下注表格封盘置灰见实施计划 docs/06 §11.7、§13.3;当前可先前往「开奖结果」查看已发布往期。 +

+ 已封盘:下注区已锁定,提交按钮显示「已封盘」。详见产品文档 §6.3、实施计划 §13.3。

)} {Array.isArray(display.result_items) && display.result_items.length > 0 ? (
本期号码已发布,完整 23 组展示见{" "} - + 当期结果 。 diff --git a/src/features/hall/hall-play-switcher.tsx b/src/features/hall/hall-play-switcher.tsx new file mode 100644 index 0000000..2dbd01c --- /dev/null +++ b/src/features/hall/hall-play-switcher.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { cn } from "@/lib/utils"; + +export type PlayChip = { + play_code: string; + label: string; +}; + +type HallPlaySwitcherProps = { + plays: PlayChip[]; + value: string; + onChange: (playCode: string) => void; + disabled?: boolean; +}; + +/** + * 玩法切换区:横向滚动 chips(产品文档:表格化下注前先选玩法列)。 + */ +export function HallPlaySwitcher({ + plays, + value, + onChange, + disabled, +}: HallPlaySwitcherProps) { + if (plays.length === 0) { + return ( +

当前币种下没有可下注的开放玩法。

+ ); + } + + return ( +
+

玩法

+
+ {plays.map((p) => { + const active = p.play_code === value; + return ( + + ); + })} +
+
+ ); +} diff --git a/src/features/hall/hall-screen.tsx b/src/features/hall/hall-screen.tsx index 141b017..bf8f196 100644 --- a/src/features/hall/hall-screen.tsx +++ b/src/features/hall/hall-screen.tsx @@ -1,16 +1,9 @@ "use client"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; - +import { HallBettingGrid } from "@/features/hall/hall-betting-grid"; +import { HallDrawPanel } from "@/features/hall/hall-draw-panel"; import { HallPlayCatalogPanel } from "@/features/hall/hall-play-catalog-panel"; import { HallWalletStrip } from "@/features/hall/hall-wallet-strip"; -import { HallDrawPanel } from "@/features/hall/hall-draw-panel"; /** * 下注大厅:钱包条 §4 + 当期期号 §4.2;玩法目录阶段 4(§12.3);下注表格阶段 5(§13.3)。 @@ -23,18 +16,7 @@ export function HallScreen() { - - - 下注表格 - - 阶段 5:按玩法配置动态渲染 2D / 3D / 4D 下注格;封盘整表置灰与「已封盘」按钮见实施计划 - docs/06 §13.3、§16.2。 - - - - 当前已展示开放玩法、限额与赔率快照;真实下注与售罄校验将在阶段 5 接入。 - - +
); } diff --git a/src/features/hall/hall-wallet-strip.tsx b/src/features/hall/hall-wallet-strip.tsx index aeb9199..8e85f09 100644 --- a/src/features/hall/hall-wallet-strip.tsx +++ b/src/features/hall/hall-wallet-strip.tsx @@ -51,6 +51,12 @@ export function HallWalletStrip() { }; }, [refresh]); + useEffect(() => { + const onRefresh = () => void refresh(); + window.addEventListener("lottery-wallet-refresh", onRefresh); + return () => window.removeEventListener("lottery-wallet-refresh", onRefresh); + }, [refresh]); + const lotteryMinor = Number(balance?.balance ?? 0); const availableMinor = Number(balance?.available_balance ?? 0); diff --git a/src/features/hall/use-hall-draw-live.ts b/src/features/hall/use-hall-draw-live.ts new file mode 100644 index 0000000..e4c89ca --- /dev/null +++ b/src/features/hall/use-hall-draw-live.ts @@ -0,0 +1,121 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { getDrawCurrent } from "@/api/draw"; +import { getLotteryEcho } from "@/lib/lottery-echo"; +import type { DrawCurrentPayload } from "@/types/api/draw-current"; + +/** 界面文档 §2.1:`draw.countdown` / `draw.status_change` / `result.published` 载荷 */ +export type HallWsEnvelope = { + data: DrawCurrentPayload | null; + emitted_at_ms?: number; +}; + +/** + * 「服务器时间为准」:以载荷里的 `seconds_*` 为基准、`emitted_at_ms` 为锚点在本地推演。 + */ +function applySnapshotDrift( + payload: DrawCurrentPayload, + emittedAtMs: number, + nowMs: number, +): DrawCurrentPayload { + const elapsed = Math.max(0, Math.floor((nowMs - emittedAtMs) / 1000)); + return { + ...payload, + seconds_to_close: Math.max(0, payload.seconds_to_close - elapsed), + seconds_to_draw: Math.max(0, payload.seconds_to_draw - elapsed), + seconds_remaining_in_cooldown: + payload.seconds_remaining_in_cooldown == null + ? null + : Math.max(0, payload.seconds_remaining_in_cooldown - elapsed), + }; +} + +/** + * 大厅期号:WebSocket `lottery-hall` + 轮询降级(与 {@link HallDrawPanel} 同源逻辑)。 + */ +export function useHallDrawLive(): { + raw: DrawCurrentPayload | null | undefined; + display: DrawCurrentPayload | null | undefined; + error: string | null; + reload: () => Promise; + isBettable: boolean; +} { + const [raw, setRaw] = useState(undefined); + const [emittedAtMs, setEmittedAtMs] = useState(() => Date.now()); + const [nowMs, setNowMs] = useState(() => Date.now()); + const [error, setError] = useState(null); + + const mergeFromWs = useCallback((evt: HallWsEnvelope) => { + setRaw(evt.data); + setEmittedAtMs(evt.emitted_at_ms ?? Date.now()); + }, []); + + const load = useCallback(async () => { + try { + setError(null); + const d = await getDrawCurrent(); + setRaw(d); + setEmittedAtMs(Date.now()); + } catch { + setError("加载失败,请下拉刷新"); + setRaw(undefined); + } + }, []); + + const refreshMs = useMemo(() => { + if (raw === undefined) return 10_000; + return raw ? 30_000 : 12_000; + }, [raw]); + + useEffect(() => { + const timer = window.setTimeout(() => { + void load(); + }, 0); + return () => window.clearTimeout(timer); + }, [load]); + + useEffect(() => { + const id = window.setInterval(() => { + void load(); + }, refreshMs); + return () => window.clearInterval(id); + }, [load, refreshMs]); + + useEffect(() => { + const bump = () => setNowMs(Date.now()); + bump(); + const sid = window.setInterval(bump, 1000); + const onVisibility = () => { + if (document.visibilityState === "visible") bump(); + }; + document.addEventListener("visibilitychange", onVisibility); + return () => { + window.clearInterval(sid); + document.removeEventListener("visibilitychange", onVisibility); + }; + }, []); + + useEffect(() => { + const echo = getLotteryEcho(); + if (!echo) return; + + echo + .channel("lottery-hall") + .listen(".draw.countdown", mergeFromWs) + .listen(".draw.status_change", mergeFromWs) + .listen(".result.published", mergeFromWs); + + return () => { + echo.leave("lottery-hall"); + }; + }, [mergeFromWs]); + + const display: DrawCurrentPayload | null | undefined = + raw === undefined || raw === null ? raw : applySnapshotDrift(raw, emittedAtMs, nowMs); + + const isBettable = display != null && display.status === "open"; + + return { raw, display, error, reload: load, isBettable }; +} diff --git a/src/types/api/ticket.ts b/src/types/api/ticket.ts new file mode 100644 index 0000000..6930a49 --- /dev/null +++ b/src/types/api/ticket.ts @@ -0,0 +1,76 @@ +/** `POST /api/v1/ticket/preview` / `place` 请求行 */ +export type TicketLineInput = { + number: string; + play_code: string; + amount: number; + digit_slot?: number; + dimension?: "D2" | "D3" | "D4"; +}; + +export type TicketConfigVersions = { + play_config_version_no: number; + odds_version_no: number; + risk_cap_version_no: number; +}; + +export type TicketPreviewPayload = { + draw_id: string; + currency_code: string; + client_trace_id?: string | null; + lines: TicketLineInput[]; +}; + +export type TicketPlacePayload = TicketPreviewPayload & { + expected_config_versions?: TicketConfigVersions; +}; + +export type TicketPreviewLine = { + client_line_no: number; + number: string; + play_code: string; + normalized_number: string; + combination_count: number; + total_bet_amount: number; + rebate_rate: string; + rebate_amount: number; + actual_deduct_amount: number; + estimated_max_payout: number; + risk_status: string; + warnings: unknown[]; + rule_snapshot_json: unknown; +}; + +export type TicketPreviewWarning = { + number_4d: string; + message: string; +}; + +export type TicketPreviewData = { + draw: { draw_id: string; status: string }; + config_versions: TicketConfigVersions; + summary: { + total_bet_amount: number; + total_rebate_amount: number; + total_actual_deduct: number; + total_estimated_payout: number; + }; + lines: TicketPreviewLine[]; + warnings: TicketPreviewWarning[]; +}; + +export type TicketPlaceItem = { + ticket_no: string; + play_code: string; + number: string; + total_bet_amount: number; + actual_deduct_amount: number; + estimated_max_payout: number; + combination_count: number; +}; + +export type TicketPlaceData = { + order_no: string; + draw: { draw_id: string; status: string }; + summary: TicketPreviewData["summary"]; + items: TicketPlaceItem[]; +};