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

26
src/api/ticket.ts Normal file
View File

@@ -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<TicketPreviewData> {
return lotteryRequest.post<TicketPreviewData>(
`${API_V1_PREFIX}/ticket/preview`,
body,
);
}
/** `POST /api/v1/ticket/place` — 真实下注 */
export function postTicketPlace(body: TicketPlacePayload): Promise<TicketPlaceData> {
return lotteryRequest.post<TicketPlaceData>(
`${API_V1_PREFIX}/ticket/place`,
body,
);
}

View File

@@ -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 (
<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)}
</p>
</div>
<Input
id={id}
inputMode="decimal"
autoComplete="off"
disabled={disabled}
value={value}
onChange={(e) => onChange(e.target.value)}
className={cn("tabular-nums")}
placeholder="例如 100.00"
/>
{hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
</div>
);
}

View File

@@ -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 || "下注失败,请稍后重试。";
}
}

View File

@@ -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 (
<div className="space-y-2">
<Label htmlFor={id}>{label}</Label>
<Input
id={id}
inputMode={spec.mode === "roll" ? "text" : "numeric"}
autoComplete="off"
spellCheck={false}
disabled={disabled}
value={value}
onChange={(e) => handle(e.target.value)}
className={cn("font-mono text-base tracking-widest")}
placeholder={spec.mode === "roll" ? "如 12R4" : "0-9"}
maxLength={spec.maxChars}
/>
{helper ? <p className="text-xs text-muted-foreground">{helper}</p> : null}
</div>
);
}

View File

@@ -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 (
<Alert className="border-amber-500/40 bg-amber-500/5 text-amber-950 dark:text-amber-100">
<AlertTriangleIcon />
<AlertTitle></AlertTitle>
<AlertDescription className="space-y-1">
<p className="text-xs leading-relaxed">
§6.4
</p>
<ul className="list-inside list-disc text-xs">
{warnings.map((w, i) => (
<li key={`${w.number_4d}-${i}`}>
<span className="font-mono">{w.number_4d}</span> {w.message}
</li>
))}
</ul>
</AlertDescription>
</Alert>
);
}
/**
* 预览弹窗 + 提交确认(产品文档 §10.1.2:预览不下单,确认后 place
*/
export function HallBetPreviewDialog({
open,
onOpenChange,
currencyCode,
data,
placing,
onConfirmPlace,
}: HallBetPreviewDialogProps) {
const summary = data?.summary;
const lines = data?.lines ?? [];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<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>
<DialogDescription>
§6.3
</DialogDescription>
</DialogHeader>
</div>
<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>
) : (
<>
<div className="rounded-lg border bg-muted/30 p-3 text-xs">
<p>
{" "}
<span className="font-mono font-semibold">{data.draw.draw_id}</span> · {" "}
<span className="font-medium">{data.draw.status}</span>
</p>
{summary ? (
<ul className="mt-2 space-y-1 tabular-nums">
<li>
{" "}
<span className="font-medium">
{formatMinorAsCurrency(summary.total_bet_amount, currencyCode)}
</span>
</li>
<li>
{" "}
<span className="font-medium">
{formatMinorAsCurrency(summary.total_rebate_amount, currencyCode)}
</span>
</li>
<li>
{" "}
<span className="font-semibold text-primary">
{formatMinorAsCurrency(summary.total_actual_deduct, currencyCode)}
</span>
</li>
<li>
{" "}
<span className="font-medium">
{formatMinorAsCurrency(summary.total_estimated_payout, currencyCode)}
</span>
</li>
</ul>
) : null}
</div>
<WarningsBlock warnings={data.warnings} />
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground"></p>
<ul className="space-y-2 text-sm">
{lines.map((ln) => (
<li
key={ln.client_line_no}
className="rounded-md border border-border/80 bg-card px-2 py-2"
>
<div className="flex flex-wrap items-baseline justify-between gap-2">
<span className="font-mono text-xs text-muted-foreground">
#{ln.client_line_no}
</span>
<span className="font-mono font-medium">{ln.play_code}</span>
</div>
<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-right font-mono">{ln.normalized_number}</span>
<span className="text-muted-foreground"></span>
<span className="text-right">{ln.combination_count}</span>
<span className="text-muted-foreground"></span>
<span className="text-right">
{formatMinorAsCurrency(ln.actual_deduct_amount, currencyCode)}
</span>
<span className="text-muted-foreground"></span>
<span className="text-right">
{formatMinorAsCurrency(ln.estimated_max_payout, currencyCode)}
</span>
</div>
</li>
))}
</ul>
</div>
</>
)}
</div>
</ScrollArea>
<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}>
</Button>
<Button type="button" onClick={onConfirmPlace} disabled={!data || placing}>
{placing ? "提交中…" : "确认提交"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -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 "金额为该笔注单的下注额(最小货币单位整数,与钱包一致)。";
}

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()}
/>
</>
);
}

View File

@@ -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<DrawCurrentPayload | null | undefined>(undefined);
const [emittedAtMs, setEmittedAtMs] = useState(() => Date.now());
/** 推演用「当前毫秒」;`draw.countdown` 每秒到仍保留,避免零星丢包时停摆 */
const [nowMs, setNowMs] = useState(() => Date.now());
const [error, setError] = useState<string | null>(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.2WS 不可用或降级时每 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() {
<CardDescription>{error}</CardDescription>
</CardHeader>
<CardContent className="flex gap-2">
<Button type="button" variant="secondary" size="sm" onClick={() => void load()}>
<Button type="button" variant="secondary" size="sm" onClick={() => void reload()}>
</Button>
</CardContent>
@@ -245,14 +144,17 @@ export function HallDrawPanel() {
<CardContent className="space-y-3">
<CountdownStrip hud={hud} payload={display} />
{(display.status === "closing" || display.status === "closed") && (
<p className="text-xs text-muted-foreground">
docs/06 §11.7§13.3
<p className="text-xs text-rose-600 dark:text-rose-400">
§6.3 §13.3
</p>
)}
{Array.isArray(display.result_items) && display.result_items.length > 0 ? (
<div className="rounded-lg border border-dashed border-border bg-muted/30 p-3 text-xs text-muted-foreground">
23 {" "}
<Link href={`/results/${encodeURIComponent(display.draw_no)}`} className="font-medium text-primary underline-offset-4 hover:underline">
<Link
href={`/results/${encodeURIComponent(display.draw_no)}`}
className="font-medium text-primary underline-offset-4 hover:underline"
>
</Link>

View File

@@ -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 (
<p className="text-sm text-muted-foreground"></p>
);
}
return (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground"></p>
<div className="-mx-1 flex gap-1.5 overflow-x-auto pb-1">
{plays.map((p) => {
const active = p.play_code === value;
return (
<button
key={p.play_code}
type="button"
disabled={disabled}
onClick={() => onChange(p.play_code)}
className={cn(
"shrink-0 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-muted/40 text-foreground hover:bg-muted",
disabled && "pointer-events-none opacity-50",
)}
>
<span className="block max-w-[140px] truncate">{p.label}</span>
<span className="mt-0.5 block font-mono text-[0.65rem] opacity-80">
{p.play_code}
</span>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -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() {
<HallPlayCatalogPanel />
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardDescription>
5 2D / 3D / 4D
docs/06 §13.3§16.2
</CardDescription>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
5
</CardContent>
</Card>
<HallBettingGrid />
</div>
);
}

View File

@@ -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);

View File

@@ -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<void>;
isBettable: boolean;
} {
const [raw, setRaw] = useState<DrawCurrentPayload | null | undefined>(undefined);
const [emittedAtMs, setEmittedAtMs] = useState(() => Date.now());
const [nowMs, setNowMs] = useState(() => Date.now());
const [error, setError] = useState<string | null>(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 };
}

76
src/types/api/ticket.ts Normal file
View File

@@ -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[];
};