refactor: 重构大厅组件以优化状态管理与数据加载
- 在 HallDrawPanel 组件中引入 useHallDrawLive 自定义 Hook,简化状态管理与数据获取逻辑 - 移除不必要的状态与副作用,提升组件性能 - 在 HallScreen 组件中替换 Card 组件为 HallBettingGrid,优化下注表格展示 - 在 HallWalletStrip 组件中添加事件监听以支持钱包刷新功能
This commit is contained in:
26
src/api/ticket.ts
Normal file
26
src/api/ticket.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
56
src/features/hall/hall-bet-amount-input.tsx
Normal file
56
src/features/hall/hall-bet-amount-input.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/features/hall/hall-bet-errors.ts
Normal file
30
src/features/hall/hall-bet-errors.ts
Normal 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 || "下注失败,请稍后重试。";
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/features/hall/hall-bet-number-input.tsx
Normal file
65
src/features/hall/hall-bet-number-input.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
170
src/features/hall/hall-bet-preview-dialog.tsx
Normal file
170
src/features/hall/hall-bet-preview-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/features/hall/hall-bet-rules.ts
Normal file
58
src/features/hall/hall-bet-rules.ts
Normal 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 "金额为该笔注单的下注额(最小货币单位整数,与钱包一致)。";
|
||||||
|
}
|
||||||
463
src/features/hall/hall-betting-grid.tsx
Normal file
463
src/features/hall/hall-betting-grid.tsx
Normal 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()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
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 { Button, buttonVariants } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -14,38 +12,12 @@ import {
|
|||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { drawStatusHud } from "@/features/draw/draw-status-meta";
|
import { drawStatusHud } from "@/features/draw/draw-status-meta";
|
||||||
|
import { useHallDrawLive } from "@/features/hall/use-hall-draw-live";
|
||||||
import { formatSecondsClock } from "@/lib/format-gmt";
|
import { formatSecondsClock } from "@/lib/format-gmt";
|
||||||
import { getLotteryEcho } from "@/lib/lottery-echo";
|
|
||||||
import { formatLotteryInstant } from "@/lib/player-datetime";
|
import { formatLotteryInstant } from "@/lib/player-datetime";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { DrawCurrentPayload } from "@/types/api/draw-current";
|
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({
|
function CountdownStrip({
|
||||||
hud,
|
hud,
|
||||||
payload,
|
payload,
|
||||||
@@ -96,80 +68,7 @@ function CountdownStrip({
|
|||||||
* 降级:每 30s 轮询 `GET draw/current`。
|
* 降级:每 30s 轮询 `GET draw/current`。
|
||||||
*/
|
*/
|
||||||
export function HallDrawPanel() {
|
export function HallDrawPanel() {
|
||||||
const [raw, setRaw] = useState<DrawCurrentPayload | null | undefined>(undefined);
|
const { raw, display, error, reload } = useHallDrawLive();
|
||||||
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.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);
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
@@ -179,7 +78,7 @@ export function HallDrawPanel() {
|
|||||||
<CardDescription>{error}</CardDescription>
|
<CardDescription>{error}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex gap-2">
|
<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>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -245,14 +144,17 @@ export function HallDrawPanel() {
|
|||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<CountdownStrip hud={hud} payload={display} />
|
<CountdownStrip hud={hud} payload={display} />
|
||||||
{(display.status === "closing" || display.status === "closed") && (
|
{(display.status === "closing" || display.status === "closed") && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-rose-600 dark:text-rose-400">
|
||||||
下注表格封盘置灰见实施计划 docs/06 §11.7、§13.3;当前可先前往「开奖结果」查看已发布往期。
|
已封盘:下注区已锁定,提交按钮显示「已封盘」。详见产品文档 §6.3、实施计划 §13.3。
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{Array.isArray(display.result_items) && display.result_items.length > 0 ? (
|
{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">
|
<div className="rounded-lg border border-dashed border-border bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||||
本期号码已发布,完整 23 组展示见{" "}
|
本期号码已发布,完整 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>
|
</Link>
|
||||||
。
|
。
|
||||||
|
|||||||
62
src/features/hall/hall-play-switcher.tsx
Normal file
62
src/features/hall/hall-play-switcher.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { HallBettingGrid } from "@/features/hall/hall-betting-grid";
|
||||||
Card,
|
import { HallDrawPanel } from "@/features/hall/hall-draw-panel";
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
|
|
||||||
import { HallPlayCatalogPanel } from "@/features/hall/hall-play-catalog-panel";
|
import { HallPlayCatalogPanel } from "@/features/hall/hall-play-catalog-panel";
|
||||||
import { HallWalletStrip } from "@/features/hall/hall-wallet-strip";
|
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)。
|
* 下注大厅:钱包条 §4 + 当期期号 §4.2;玩法目录阶段 4(§12.3);下注表格阶段 5(§13.3)。
|
||||||
@@ -23,18 +16,7 @@ export function HallScreen() {
|
|||||||
|
|
||||||
<HallPlayCatalogPanel />
|
<HallPlayCatalogPanel />
|
||||||
|
|
||||||
<Card>
|
<HallBettingGrid />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ export function HallWalletStrip() {
|
|||||||
};
|
};
|
||||||
}, [refresh]);
|
}, [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 lotteryMinor = Number(balance?.balance ?? 0);
|
||||||
const availableMinor = Number(balance?.available_balance ?? 0);
|
const availableMinor = Number(balance?.available_balance ?? 0);
|
||||||
|
|
||||||
|
|||||||
121
src/features/hall/use-hall-draw-live.ts
Normal file
121
src/features/hall/use-hall-draw-live.ts
Normal 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
76
src/types/api/ticket.ts
Normal 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[];
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user