refactor: 重构整体页面布局与样式,统一UI设计风格
- 重构PlayerAppShell,移除冗余头部导航与国际化依赖,统一页面背景与内边距 - 新增通用页面容器组件PlayerPanel,统一页面头部布局与样式 - 重构底部导航栏,调整图标、文案与样式,新增激活状态指示器 - 重构所有页面组件:大厅页、注单页、结果页、开奖面板等,统一使用新的UI组件与设计风格 - 优化状态标签、卡片、按钮等组件的视觉样式,统一配色与圆角规范 - 移除冗余依赖与注释代码,整理代码结构
This commit is contained in:
@@ -1,13 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
|
||||||
import { NetworkStatusBanner } from "@/components/network-status-banner";
|
import { NetworkStatusBanner } from "@/components/network-status-banner";
|
||||||
import { PlayerBottomNav } from "@/components/layout/player-bottom-nav";
|
import { PlayerBottomNav } from "@/components/layout/player-bottom-nav";
|
||||||
import { PlayerSessionBar } from "@/features/player/player-session-bar";
|
|
||||||
|
|
||||||
type PlayerAppShellProps = {
|
type PlayerAppShellProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -21,25 +17,10 @@ type PlayerAppShellProps = {
|
|||||||
* 这里的 NetworkStatusBanner 仅用于 WebSocket 状态显示
|
* 这里的 NetworkStatusBanner 仅用于 WebSocket 状态显示
|
||||||
*/
|
*/
|
||||||
export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode {
|
export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode {
|
||||||
const { t } = useTranslation("layout");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-dvh flex-col bg-background text-foreground">
|
<div className="min-h-dvh bg-[#f3f7fd] text-foreground">
|
||||||
{/* WebSocket 连接状态横幅(降级模式提示) */}
|
|
||||||
<NetworkStatusBanner />
|
<NetworkStatusBanner />
|
||||||
<header className="sticky top-0 z-40 shrink-0 border-b border-border bg-background/95 backdrop-blur-sm supports-[backdrop-filter]:bg-background/80">
|
<main className="mx-auto flex w-full max-w-lg flex-col px-3 pb-[calc(4.25rem+env(safe-area-inset-bottom,0px)+0.75rem)] pt-3 sm:px-4">
|
||||||
<div className="mx-auto flex h-12 max-w-lg items-center gap-2 px-4">
|
|
||||||
<Link
|
|
||||||
href="/hall"
|
|
||||||
className="shrink-0 text-sm font-semibold tracking-tight text-foreground no-underline hover:opacity-90"
|
|
||||||
>
|
|
||||||
{t("brand.title")}
|
|
||||||
</Link>
|
|
||||||
<PlayerSessionBar className="min-w-0 flex-1 border-l border-border pl-2" />
|
|
||||||
<LanguageSwitcher variant="minimal" showFlag={false} />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main className="mx-auto flex w-full max-w-lg flex-1 flex-col gap-4 px-4 pb-[calc(3.5rem+env(safe-area-inset-bottom,0px)+0.75rem)] pt-4">
|
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
<PlayerBottomNav />
|
<PlayerBottomNav />
|
||||||
|
|||||||
@@ -3,30 +3,30 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
import { LayoutGrid, Receipt, Trophy, Wallet } from "lucide-react";
|
import { BarChart3, ClipboardList, Home, Wallet } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ href: "/hall", label: "大厅", icon: LayoutGrid, match: (p: string) => p === "/hall" },
|
{ href: "/hall", label: "Home", icon: Home, match: (p: string) => p === "/hall" },
|
||||||
|
{
|
||||||
|
href: "/results",
|
||||||
|
label: "Results",
|
||||||
|
icon: BarChart3,
|
||||||
|
match: (p: string) => p === "/results" || p.startsWith("/results/"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: "/orders",
|
href: "/orders",
|
||||||
label: "注单",
|
label: "My Bets",
|
||||||
icon: Receipt,
|
icon: ClipboardList,
|
||||||
match: (p: string) => p === "/orders" || p.startsWith("/orders/"),
|
match: (p: string) => p === "/orders" || p.startsWith("/orders/"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/wallet",
|
href: "/wallet",
|
||||||
label: "钱包",
|
label: "Wallet",
|
||||||
icon: Wallet,
|
icon: Wallet,
|
||||||
match: (p: string) => p === "/wallet" || p.startsWith("/wallet/"),
|
match: (p: string) => p === "/wallet" || p.startsWith("/wallet/"),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
href: "/results",
|
|
||||||
label: "开奖",
|
|
||||||
icon: Trophy,
|
|
||||||
match: (p: string) => p === "/results" || p.startsWith("/results/"),
|
|
||||||
},
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,10 +37,10 @@ export function PlayerBottomNav() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
className="fixed bottom-0 left-0 right-0 z-50 border-t border-border bg-background/95 pb-[env(safe-area-inset-bottom,0px)] backdrop-blur-md supports-[backdrop-filter]:bg-background/90"
|
className="fixed bottom-0 left-0 right-0 z-50 border-t border-[#e4ebf5] bg-white/96 pb-[env(safe-area-inset-bottom,0px)] shadow-[0_-10px_30px_rgba(15,23,42,0.08)] backdrop-blur-md"
|
||||||
aria-label="主导航"
|
aria-label="主导航"
|
||||||
>
|
>
|
||||||
<div className="mx-auto grid h-14 w-full max-w-lg grid-rows-1 [grid-template-columns:repeat(4,minmax(0,1fr))]">
|
<div className="mx-auto grid h-16 w-full max-w-lg grid-rows-1 [grid-template-columns:repeat(4,minmax(0,1fr))]">
|
||||||
{tabs.map(({ href, label, icon: Icon, match }) => {
|
{tabs.map(({ href, label, icon: Icon, match }) => {
|
||||||
const active = match(pathname);
|
const active = match(pathname);
|
||||||
return (
|
return (
|
||||||
@@ -50,15 +50,18 @@ export function PlayerBottomNav() {
|
|||||||
prefetch
|
prefetch
|
||||||
aria-current={active ? "page" : undefined}
|
aria-current={active ? "page" : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-0 min-w-0 max-w-full flex-col items-center justify-center gap-0.5 px-0.5 text-center text-[10px] font-medium leading-tight transition-colors sm:text-[11px]",
|
"relative flex min-h-0 min-w-0 max-w-full flex-col items-center justify-center gap-1 px-0.5 text-center text-[10px] font-semibold leading-tight transition-colors sm:text-[11px]",
|
||||||
active
|
active
|
||||||
? "text-primary"
|
? "text-[#f10b32]"
|
||||||
: "text-muted-foreground hover:text-foreground active:text-foreground",
|
: "text-[#6b7280] hover:text-[#1f2937] active:text-[#1f2937]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{active ? (
|
||||||
|
<span className="absolute top-0 h-0.5 w-9 rounded-full bg-[#f10b32]" />
|
||||||
|
) : null}
|
||||||
<Icon
|
<Icon
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className={cn("size-5 shrink-0 sm:size-[22px]", active && "stroke-[2.25px]")}
|
className={cn("size-5 shrink-0 sm:size-[22px]", active && "stroke-[2.5px]")}
|
||||||
/>
|
/>
|
||||||
<span className="w-full truncate">{label}</span>
|
<span className="w-full truncate">{label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
79
src/components/layout/player-panel.tsx
Normal file
79
src/components/layout/player-panel.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { Bell, ChevronLeft } from "lucide-react";
|
||||||
|
|
||||||
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type PlayerPanelProps = {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
eyebrow?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
backHref?: string;
|
||||||
|
backLabel?: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PlayerPanel({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
eyebrow,
|
||||||
|
children,
|
||||||
|
backHref = "/hall",
|
||||||
|
backLabel = "Home",
|
||||||
|
className,
|
||||||
|
}: PlayerPanelProps) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-[480px]">
|
||||||
|
<section
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden rounded-[18px] border border-[#dce7f7] bg-white px-2.5 py-3 text-slate-900 shadow-[0_18px_50px_rgba(15,44,92,0.12)]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex items-center gap-2 px-1">
|
||||||
|
<Link
|
||||||
|
href={backHref}
|
||||||
|
className="flex h-9 shrink-0 items-center gap-1 rounded-full border border-[#e4eaf4] bg-[#f8fafc] px-2.5 text-xs font-bold text-[#0b3f96] hover:bg-[#f1f6ff]"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" aria-hidden />
|
||||||
|
{backLabel}
|
||||||
|
</Link>
|
||||||
|
<div className="min-w-0 flex-1 text-center">
|
||||||
|
{eyebrow ? (
|
||||||
|
<p className="truncate text-[10px] font-bold uppercase text-[#f10b32]">
|
||||||
|
{eyebrow}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<h1 className="truncate text-lg font-black tracking-normal text-[#0b3f96]">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{subtitle ? (
|
||||||
|
<p className="truncate text-[11px] font-medium text-slate-500">
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<LanguageSwitcher
|
||||||
|
variant="minimal"
|
||||||
|
showFlag={false}
|
||||||
|
className="shrink-0 rounded-full border border-[#e4eaf4] bg-[#f8fafc]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="relative flex size-9 shrink-0 items-center justify-center rounded-full text-[#1d57b7] hover:bg-[#f4f7fb]"
|
||||||
|
aria-label="Notifications"
|
||||||
|
>
|
||||||
|
<Bell className="size-5" aria-hidden />
|
||||||
|
<span className="absolute right-2 top-2 size-2 rounded-full bg-[#ff143d]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,33 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { CirclePlus, Cuboid, PackageOpen, Ticket, Trash2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { getPlayEffective } from "@/api/play";
|
import { getPlayEffective } from "@/api/play";
|
||||||
import { postTicketPlace, postTicketPreview } from "@/api/ticket";
|
import { postTicketPlace, postTicketPreview } from "@/api/ticket";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import { Input } from "@/components/ui/input";
|
||||||
Card,
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
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 { isHallSealedCountdownUi } from "@/features/draw/draw-status-meta";
|
import { isHallSealedCountdownUi } from "@/features/draw/draw-status-meta";
|
||||||
import { triggerWalletPollingAfterBet } from "@/hooks/use-wallet-polling";
|
import { HallBetPreviewDialog } from "@/features/hall/hall-bet-preview-dialog";
|
||||||
|
import { mapTicketBetError } from "@/features/hall/hall-bet-errors";
|
||||||
import {
|
import {
|
||||||
playNeedsDigitSlot,
|
playNeedsDigitSlot,
|
||||||
playNeedsDimension,
|
playNeedsDimension,
|
||||||
ticketAmountHint,
|
|
||||||
ticketNumberSpec,
|
ticketNumberSpec,
|
||||||
} from "@/features/hall/hall-bet-rules";
|
} 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 { useHallDrawLive } from "@/features/hall/use-hall-draw-live";
|
||||||
|
import { triggerWalletPollingAfterBet } from "@/hooks/use-wallet-polling";
|
||||||
import { getLotteryRequestLocale } from "@/lib/lottery-locale";
|
import { getLotteryRequestLocale } from "@/lib/lottery-locale";
|
||||||
import { formatMinorAsCurrency, parseDecimalInputToMinor } from "@/lib/money";
|
import { formatMinorAsCurrency, parseDecimalInputToMinor } from "@/lib/money";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -36,6 +27,58 @@ import type { PlayEffectivePayload, PlayEffectivePlayRow } from "@/types/api/pla
|
|||||||
import type { TicketLineInput, TicketPreviewData } from "@/types/api/ticket";
|
import type { TicketLineInput, TicketPreviewData } from "@/types/api/ticket";
|
||||||
|
|
||||||
const DEFAULT_POLL_MS = 120_000;
|
const DEFAULT_POLL_MS = 120_000;
|
||||||
|
const MAX_ROWS = 20;
|
||||||
|
|
||||||
|
type HallCategory = "D2" | "D3" | "D4" | "JACKPOT";
|
||||||
|
type BoxMode = "ibox" | "box";
|
||||||
|
|
||||||
|
type DraftRow = {
|
||||||
|
id: string;
|
||||||
|
number: string;
|
||||||
|
amounts: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DraftEntry = {
|
||||||
|
rowId: string;
|
||||||
|
rowNo: number;
|
||||||
|
play: PlayEffectivePlayRow;
|
||||||
|
number: string;
|
||||||
|
amountMinor: number;
|
||||||
|
line: TicketLineInput;
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryTabs: { value: HallCategory; label: string }[] = [
|
||||||
|
{ value: "D2", label: "2D" },
|
||||||
|
{ value: "D3", label: "3D" },
|
||||||
|
{ value: "D4", label: "4D" },
|
||||||
|
{ value: "JACKPOT", label: "Jackpot" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const preferredD4Columns = [
|
||||||
|
"big",
|
||||||
|
"small",
|
||||||
|
"pos_3c",
|
||||||
|
"pos_3a",
|
||||||
|
"pos_4a",
|
||||||
|
"pos_4b",
|
||||||
|
"pos_4c",
|
||||||
|
"pos_4d",
|
||||||
|
"pos_4e",
|
||||||
|
"straight",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const simpleCategoryPreferred: Record<"D2" | "D3", string[]> = {
|
||||||
|
D2: ["pos_2a", "pos_2b", "pos_2c", "pos_2abc"],
|
||||||
|
D3: ["pos_3a", "pos_3b", "pos_3c", "pos_3abc"],
|
||||||
|
};
|
||||||
|
|
||||||
|
function newDraftRow(): DraftRow {
|
||||||
|
const id =
|
||||||
|
typeof crypto !== "undefined" && crypto.randomUUID
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `row-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
return { id, number: "", amounts: {} };
|
||||||
|
}
|
||||||
|
|
||||||
function isPlayOpenForPlayer(row: PlayEffectivePlayRow): boolean {
|
function isPlayOpenForPlayer(row: PlayEffectivePlayRow): boolean {
|
||||||
if (!row.master_enabled || row.config === null) {
|
if (!row.master_enabled || row.config === null) {
|
||||||
@@ -55,59 +98,121 @@ function pickDisplayName(row: PlayEffectivePlayRow): string {
|
|||||||
return row.display_name_en ?? row.display_name_zh ?? 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 }[] {
|
function inferCategory(row: PlayEffectivePlayRow): HallCategory {
|
||||||
if (dimension === "D2") {
|
if (row.play_code.startsWith("pos_2")) return "D2";
|
||||||
return [
|
if (row.play_code.startsWith("pos_3")) return "D3";
|
||||||
{ value: 2, label: "十位" },
|
if (row.category.toLowerCase().includes("jackpot")) return "JACKPOT";
|
||||||
{ value: 3, label: "个位" },
|
if (row.dimension === 2) return "D2";
|
||||||
];
|
if (row.dimension === 3) return "D3";
|
||||||
}
|
return "D4";
|
||||||
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 {
|
function categoryDigits(category: HallCategory): number {
|
||||||
if (spec.mode === "roll") {
|
if (category === "D2") return 2;
|
||||||
return "Roll:共 4 位,须包含字母 R 表示滚动位,其余为数字(0-9)。";
|
if (category === "D3") return 3;
|
||||||
}
|
if (category === "D4") return 4;
|
||||||
if (playCode.startsWith("pos_")) {
|
return 0;
|
||||||
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 {
|
function sanitizeNumber(raw: string, category: HallCategory): string {
|
||||||
return v.length === 4 && v.includes("R") && /^[0-9R]+$/i.test(v);
|
const max = categoryDigits(category);
|
||||||
|
return raw.replace(/\D/g, "").slice(0, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeAmount(raw: string): string {
|
||||||
|
return raw.replace(/[^\d.]/g, "").replace(/(\..*)\./g, "$1").slice(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRebateRate(rate: string | undefined): number {
|
||||||
|
const n = Number(rate ?? 0);
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return 0;
|
||||||
|
return n > 1 ? n / 100 : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRatePercent(rate: string | undefined): string {
|
||||||
|
const n = parseRebateRate(rate);
|
||||||
|
return `${(n * 100).toFixed(2)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function amountToDisplay(minor: number): string {
|
||||||
|
return (minor / 100).toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNumberForPlay(number: string, playCode: string): string {
|
||||||
|
if (playCode.startsWith("pos_2")) return number.slice(-2);
|
||||||
|
if (playCode.startsWith("pos_3")) return number.slice(-3);
|
||||||
|
if (
|
||||||
|
playCode === "head" ||
|
||||||
|
playCode === "tail" ||
|
||||||
|
playCode === "odd" ||
|
||||||
|
playCode === "even" ||
|
||||||
|
playCode === "digit_big" ||
|
||||||
|
playCode === "digit_small"
|
||||||
|
) {
|
||||||
|
return number.slice(-1);
|
||||||
|
}
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineForPlay(
|
||||||
|
category: "D2" | "D3" | "D4",
|
||||||
|
play: PlayEffectivePlayRow,
|
||||||
|
displayNumber: string,
|
||||||
|
amountMinor: number,
|
||||||
|
): TicketLineInput | null {
|
||||||
|
const number = normalizeNumberForPlay(displayNumber, play.play_code);
|
||||||
|
const spec = ticketNumberSpec(play.play_code);
|
||||||
|
if (number.length !== spec.maxChars) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const line: TicketLineInput = {
|
||||||
|
number,
|
||||||
|
play_code: play.play_code,
|
||||||
|
amount: amountMinor,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (playNeedsDimension(play.play_code)) {
|
||||||
|
line.dimension = category;
|
||||||
|
}
|
||||||
|
if (playNeedsDigitSlot(play.play_code)) {
|
||||||
|
line.digit_slot = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPlayByCode(
|
||||||
|
plays: PlayEffectivePlayRow[],
|
||||||
|
playCode: string,
|
||||||
|
): PlayEffectivePlayRow | undefined {
|
||||||
|
return plays.find((p) => p.play_code === playCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickSimplePlay(
|
||||||
|
plays: PlayEffectivePlayRow[],
|
||||||
|
category: "D2" | "D3",
|
||||||
|
): PlayEffectivePlayRow | undefined {
|
||||||
|
const preferred = simpleCategoryPreferred[category];
|
||||||
|
return (
|
||||||
|
preferred.map((code) => findPlayByCode(plays, code)).find(Boolean) ??
|
||||||
|
plays.find((p) => inferCategory(p) === category)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 下注大厅表格:号码 / 金额 / 玩法切换、预览与确认、结果提示(实施计划 §13.3,产品文档 §4.2 / §6.3)。
|
|
||||||
*/
|
|
||||||
export function HallBettingGrid() {
|
export function HallBettingGrid() {
|
||||||
const { display, isBettable, reload: reloadDraw } = useHallDrawLive();
|
const { display, isBettable, reload: reloadDraw } = useHallDrawLive();
|
||||||
|
const [activeCategory, setActiveCategory] = useState<HallCategory>("D2");
|
||||||
|
const [boxMode, setBoxMode] = useState<BoxMode>("ibox");
|
||||||
|
const [rows, setRows] = useState<DraftRow[]>(() => [
|
||||||
|
{ ...newDraftRow(), number: "23", amounts: {} },
|
||||||
|
{ ...newDraftRow(), number: "75", amounts: {} },
|
||||||
|
{ ...newDraftRow(), number: "08", amounts: {} },
|
||||||
|
{ ...newDraftRow(), number: "46", amounts: {} },
|
||||||
|
]);
|
||||||
|
|
||||||
const currencyParam = useMemo(() => {
|
const currencyParam = useMemo(() => {
|
||||||
const fromEnv = process.env.NEXT_PUBLIC_LOTTERY_PLAY_CURRENCY?.trim();
|
const fromEnv = process.env.NEXT_PUBLIC_LOTTERY_PLAY_CURRENCY?.trim();
|
||||||
@@ -120,6 +225,11 @@ export function HallBettingGrid() {
|
|||||||
| { kind: "error"; message: string }
|
| { kind: "error"; message: string }
|
||||||
>({ kind: "loading" });
|
>({ kind: "loading" });
|
||||||
|
|
||||||
|
const [previewOpen, setPreviewOpen] = useState(false);
|
||||||
|
const [previewData, setPreviewData] = useState<TicketPreviewData | null>(null);
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
|
const [placeLoading, setPlaceLoading] = useState(false);
|
||||||
|
|
||||||
const loadCatalog = useCallback(async () => {
|
const loadCatalog = useCallback(async () => {
|
||||||
setCatalogState((s) => (s.kind === "ok" ? s : { kind: "loading" }));
|
setCatalogState((s) => (s.kind === "ok" ? s : { kind: "loading" }));
|
||||||
try {
|
try {
|
||||||
@@ -151,115 +261,143 @@ export function HallBettingGrid() {
|
|||||||
if (catalogState.kind !== "ok") return [];
|
if (catalogState.kind !== "ok") return [];
|
||||||
return [...catalogState.data.plays]
|
return [...catalogState.data.plays]
|
||||||
.filter(isPlayOpenForPlayer)
|
.filter(isPlayOpenForPlayer)
|
||||||
.filter((p) => p.play_code !== "half_box")
|
.filter((p) => p.play_code !== "half_box" && p.play_code !== "roll")
|
||||||
.sort((a, b) => a.sort_order - b.sort_order || a.play_code.localeCompare(b.play_code));
|
.sort((a, b) => a.sort_order - b.sort_order || a.play_code.localeCompare(b.play_code));
|
||||||
}, [catalogState]);
|
}, [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 =
|
const currencyCode =
|
||||||
catalogState.kind === "ok" ? catalogState.data.currency_code : "NPR";
|
catalogState.kind === "ok" ? catalogState.data.currency_code : "NPR";
|
||||||
|
|
||||||
const minBet = selectedRow?.config?.min_bet_amount ?? 1;
|
const simplePlay = useMemo(() => {
|
||||||
const maxBet = selectedRow?.config?.max_bet_amount ?? 999_999_999;
|
if (activeCategory !== "D2" && activeCategory !== "D3") return undefined;
|
||||||
|
return pickSimplePlay(openPlays, activeCategory);
|
||||||
|
}, [activeCategory, openPlays]);
|
||||||
|
|
||||||
const tableDisabled = !isBettable || catalogState.kind !== "ok";
|
const d4Columns = useMemo(() => {
|
||||||
|
if (activeCategory !== "D4") return [];
|
||||||
|
const first = findPlayByCode(openPlays, boxMode);
|
||||||
|
const preferred = preferredD4Columns
|
||||||
|
.map((code) => findPlayByCode(openPlays, code))
|
||||||
|
.filter((p): p is PlayEffectivePlayRow => Boolean(p));
|
||||||
|
const merged = first ? [first, ...preferred] : preferred;
|
||||||
|
return merged.filter((p, i, arr) => arr.findIndex((x) => x.play_code === p.play_code) === i);
|
||||||
|
}, [activeCategory, boxMode, openPlays]);
|
||||||
|
|
||||||
|
const tableDisabled =
|
||||||
|
activeCategory === "JACKPOT" || !isBettable || catalogState.kind !== "ok";
|
||||||
const sealedBetUi = Boolean(display && isHallSealedCountdownUi(display.status));
|
const sealedBetUi = Boolean(display && isHallSealedCountdownUi(display.status));
|
||||||
|
|
||||||
const [previewOpen, setPreviewOpen] = useState(false);
|
const updateRowNumber = (id: string, value: string) => {
|
||||||
const [previewData, setPreviewData] = useState<TicketPreviewData | null>(null);
|
setRows((current) =>
|
||||||
const [previewLoading, setPreviewLoading] = useState(false);
|
current.map((row) =>
|
||||||
const [placeLoading, setPlaceLoading] = useState(false);
|
row.id === id ? { ...row, number: sanitizeNumber(value, activeCategory) } : row,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const buildLine = useCallback((): TicketLineInput | null => {
|
const updateAmount = (rowId: string, playCode: string, value: string) => {
|
||||||
if (!activePlayCode) return null;
|
setRows((current) =>
|
||||||
const minor = parseDecimalInputToMinor(amountStr);
|
current.map((row) =>
|
||||||
if (minor === null || minor < minBet || minor > maxBet) {
|
row.id === rowId
|
||||||
return null;
|
? {
|
||||||
}
|
...row,
|
||||||
if (spec.mode === "roll") {
|
amounts: { ...row.amounts, [playCode]: sanitizeAmount(value) },
|
||||||
if (!rollInputValid(number)) return null;
|
}
|
||||||
} else if (number.length !== spec.maxChars) {
|
: row,
|
||||||
return null;
|
),
|
||||||
}
|
);
|
||||||
const line: TicketLineInput = {
|
};
|
||||||
number,
|
|
||||||
play_code: activePlayCode,
|
const addRow = () => {
|
||||||
amount: minor,
|
setRows((current) => (current.length >= MAX_ROWS ? current : [...current, newDraftRow()]));
|
||||||
};
|
};
|
||||||
if (playNeedsDimension(activePlayCode)) {
|
|
||||||
line.dimension = dimension;
|
const removeRow = (id: string) => {
|
||||||
}
|
setRows((current) =>
|
||||||
if (playNeedsDigitSlot(activePlayCode)) {
|
current.length <= 1 ? current : current.filter((row) => row.id !== id),
|
||||||
line.digit_slot = activeDigitSlot;
|
);
|
||||||
}
|
};
|
||||||
return line;
|
|
||||||
}, [
|
const collectEntries = useCallback((): DraftEntry[] => {
|
||||||
activeDigitSlot,
|
if (activeCategory === "JACKPOT") return [];
|
||||||
activePlayCode,
|
|
||||||
amountStr,
|
const entries: DraftEntry[] = [];
|
||||||
dimension,
|
const plays =
|
||||||
maxBet,
|
activeCategory === "D4" ? d4Columns : simplePlay ? [simplePlay] : [];
|
||||||
minBet,
|
|
||||||
number,
|
rows.forEach((row, rowIndex) => {
|
||||||
spec.maxChars,
|
plays.forEach((play) => {
|
||||||
spec.mode,
|
const amount = parseDecimalInputToMinor(row.amounts[play.play_code] ?? "");
|
||||||
]);
|
if (amount === null || amount <= 0) return;
|
||||||
|
|
||||||
|
const line = lineForPlay(
|
||||||
|
activeCategory as "D2" | "D3" | "D4",
|
||||||
|
play,
|
||||||
|
row.number,
|
||||||
|
amount,
|
||||||
|
);
|
||||||
|
if (!line) return;
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
rowId: row.id,
|
||||||
|
rowNo: rowIndex + 1,
|
||||||
|
play,
|
||||||
|
number: row.number,
|
||||||
|
amountMinor: amount,
|
||||||
|
line,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}, [activeCategory, d4Columns, rows, simplePlay]);
|
||||||
|
|
||||||
|
const draftEntries = collectEntries();
|
||||||
|
const draftSummary = useMemo(() => {
|
||||||
|
return draftEntries.reduce(
|
||||||
|
(acc, entry) => {
|
||||||
|
const rebateRate = parseRebateRate(entry.play.odds?.rebate_rate);
|
||||||
|
const rebate = Math.round(entry.amountMinor * rebateRate);
|
||||||
|
acc.bet += entry.amountMinor;
|
||||||
|
acc.rebate += rebate;
|
||||||
|
acc.actual += Math.max(0, entry.amountMinor - rebate);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ bet: 0, rebate: 0, actual: 0 },
|
||||||
|
);
|
||||||
|
}, [draftEntries]);
|
||||||
|
|
||||||
|
const buildLines = (): TicketLineInput[] => {
|
||||||
|
return collectEntries().map((entry) => entry.line);
|
||||||
|
};
|
||||||
|
|
||||||
const handlePreview = async () => {
|
const handlePreview = async () => {
|
||||||
if (!display) {
|
if (!display) {
|
||||||
toast.error("暂无当期期号,无法预览。");
|
toast.error("暂无当期期号,无法提交。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isBettable) {
|
if (!isBettable) {
|
||||||
toast.error("当前已封盘或不可下注,无法预览。");
|
toast.error("当前已封盘或不可下注。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const line = buildLine();
|
if (catalogState.kind !== "ok") {
|
||||||
if (!line) {
|
toast.error("玩法配置尚未加载完成。");
|
||||||
toast.error("请检查号码长度与金额是否在玩法限额内。");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lines = buildLines();
|
||||||
|
if (lines.length === 0) {
|
||||||
|
toast.error("请至少填写一组有效号码和下注金额。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setPreviewLoading(true);
|
setPreviewLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await postTicketPreview({
|
const data = await postTicketPreview({
|
||||||
draw_id: display.draw_no,
|
draw_id: display.draw_no,
|
||||||
currency_code: currencyCode,
|
currency_code: currencyCode,
|
||||||
client_trace_id: `pv-${typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : String(Date.now())}`,
|
client_trace_id: `pv-${typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : String(Date.now())}`,
|
||||||
lines: [line],
|
lines,
|
||||||
});
|
});
|
||||||
setPreviewData(data);
|
setPreviewData(data);
|
||||||
setPreviewOpen(true);
|
setPreviewOpen(true);
|
||||||
@@ -278,11 +416,13 @@ export function HallBettingGrid() {
|
|||||||
toast.error("已封盘,无法提交。");
|
toast.error("已封盘,无法提交。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const line = buildLine();
|
|
||||||
if (!line) {
|
const lines = buildLines();
|
||||||
|
if (lines.length === 0) {
|
||||||
toast.error("提交前数据已变化,请关闭预览后重试。");
|
toast.error("提交前数据已变化,请关闭预览后重试。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setPlaceLoading(true);
|
setPlaceLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await postTicketPlace({
|
const data = await postTicketPlace({
|
||||||
@@ -292,7 +432,7 @@ export function HallBettingGrid() {
|
|||||||
typeof crypto !== "undefined" && crypto.randomUUID
|
typeof crypto !== "undefined" && crypto.randomUUID
|
||||||
? crypto.randomUUID()
|
? crypto.randomUUID()
|
||||||
: `pl-${Date.now()}`,
|
: `pl-${Date.now()}`,
|
||||||
lines: [line],
|
lines,
|
||||||
expected_config_versions: previewData.config_versions,
|
expected_config_versions: previewData.config_versions,
|
||||||
});
|
});
|
||||||
toast.success(
|
toast.success(
|
||||||
@@ -300,9 +440,7 @@ export function HallBettingGrid() {
|
|||||||
);
|
);
|
||||||
setPreviewOpen(false);
|
setPreviewOpen(false);
|
||||||
setPreviewData(null);
|
setPreviewData(null);
|
||||||
setAmountStr("");
|
setRows([newDraftRow()]);
|
||||||
setNumber("");
|
|
||||||
// 触发钱包轮询(降级模式下启动2分钟限时轮询)
|
|
||||||
triggerWalletPollingAfterBet();
|
triggerWalletPollingAfterBet();
|
||||||
void reloadDraw();
|
void reloadDraw();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -314,167 +452,301 @@ export function HallBettingGrid() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const body = (() => {
|
if (catalogState.kind === "loading") {
|
||||||
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 (
|
return (
|
||||||
<div
|
<section className="space-y-3" aria-label="Betting table">
|
||||||
className={cn(
|
<Skeleton className="h-12 rounded-xl" />
|
||||||
"space-y-5 transition-opacity",
|
<Skeleton className="h-72 rounded-xl" />
|
||||||
tableDisabled && "pointer-events-none",
|
<Skeleton className="h-14 rounded-xl" />
|
||||||
tableDisabled && (sealedBetUi ? "opacity-[0.52]" : "opacity-50"),
|
</section>
|
||||||
)}
|
|
||||||
>
|
|
||||||
{!isBettable && display ? (
|
|
||||||
sealedBetUi ? (
|
|
||||||
<div className="space-y-1 rounded-lg border border-[#ff4d4f]/35 bg-[#ff4d4f]/8 px-3 py-2 text-sm text-[#ff4d4f] dark:bg-[#ff4d4f]/10">
|
|
||||||
<p className="font-medium">
|
|
||||||
已封盘:表格置灰且不可编辑;倒计时区域见上方期号卡片(界面文档 §4.2)。
|
|
||||||
</p>
|
|
||||||
<p className="text-xs leading-relaxed">请选择下一期。</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="rounded-lg border border-muted-foreground/30 bg-muted/50 px-3 py-2 text-sm text-muted-foreground">
|
|
||||||
当前期不可下注(状态「{display.status}」)。提交入口已禁用。
|
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
})();
|
}
|
||||||
|
|
||||||
|
if (catalogState.kind === "error") {
|
||||||
|
return (
|
||||||
|
<section className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||||
|
<p>{catalogState.message}</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="mt-3 border-red-200 bg-white text-red-700 hover:bg-red-50"
|
||||||
|
onClick={() => void loadCatalog()}
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const simplePlayCode = simplePlay?.play_code ?? "";
|
||||||
|
const numberPlaceholder =
|
||||||
|
activeCategory === "D2" ? "00" : activeCategory === "D3" ? "000" : "0000";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card
|
<section className="space-y-4" aria-label="Betting table">
|
||||||
className={cn(
|
<div className="grid grid-cols-4 rounded-xl border border-[#e8eef7] bg-white p-1 shadow-[0_6px_18px_rgba(30,64,175,0.06)]">
|
||||||
sealedBetUi && "border-[#ff4d4f]/40 bg-muted/30",
|
{categoryTabs.map((tab) => {
|
||||||
!isBettable && display && !sealedBetUi && "border-muted-foreground/20 bg-muted/20",
|
const active = activeCategory === tab.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setActiveCategory(tab.value);
|
||||||
|
setRows((current) =>
|
||||||
|
current.map((row) => ({
|
||||||
|
...row,
|
||||||
|
number: sanitizeNumber(row.number, tab.value),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-10 min-w-0 items-center justify-center rounded-lg text-sm font-semibold transition-colors",
|
||||||
|
active
|
||||||
|
? "bg-[#07459f] text-white shadow-[inset_0_-2px_0_rgba(255,255,255,0.28)]"
|
||||||
|
: "text-[#4b5563] hover:bg-[#f4f7fb]",
|
||||||
|
tab.value === "JACKPOT" && active && "bg-[#7b8492]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{tab.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeCategory === "D4" ? (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBoxMode("ibox")}
|
||||||
|
disabled={tableDisabled}
|
||||||
|
className={cn(
|
||||||
|
"flex min-w-0 items-center gap-2 rounded-lg border bg-white px-3 py-2 text-left shadow-sm transition-colors",
|
||||||
|
boxMode === "ibox"
|
||||||
|
? "border-[#b9ccf6] text-[#07459f]"
|
||||||
|
: "border-[#f2b4bc] text-[#e11d48]",
|
||||||
|
tableDisabled && "opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex size-8 shrink-0 items-center justify-center rounded-full text-white",
|
||||||
|
boxMode === "ibox" ? "bg-[#0956c8]" : "bg-[#ff4158]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Cuboid className="size-4" aria-hidden />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-sm font-bold">iBox</span>
|
||||||
|
<span className="block truncate text-[10px] text-slate-500">
|
||||||
|
Divide all by amount
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBoxMode("box")}
|
||||||
|
disabled={tableDisabled}
|
||||||
|
className={cn(
|
||||||
|
"flex min-w-0 items-center gap-2 rounded-lg border bg-white px-3 py-2 text-left shadow-sm transition-colors",
|
||||||
|
boxMode === "box"
|
||||||
|
? "border-[#f2b4bc] text-[#e11d48]"
|
||||||
|
: "border-[#e8eef7] text-[#ef4056]",
|
||||||
|
tableDisabled && "opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-[#ff4158] text-white">
|
||||||
|
<PackageOpen className="size-4" aria-hidden />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-sm font-bold">Box</span>
|
||||||
|
<span className="block truncate text-[10px] text-slate-500">
|
||||||
|
Multiply all by amount
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeCategory === "JACKPOT" ? (
|
||||||
|
<div className="rounded-xl border border-[#edf1f7] bg-[#f7f9fc] p-7 text-center text-slate-500">
|
||||||
|
<div className="mx-auto flex size-14 items-center justify-center rounded-full bg-slate-200 text-slate-600">
|
||||||
|
<Ticket className="size-7" aria-hidden />
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-lg font-bold text-slate-900">Closed</p>
|
||||||
|
<p className="mt-1 text-xs">This issue is now closed.</p>
|
||||||
|
<div className="mt-5 rounded-lg border border-[#cbdcf7] bg-white px-3 py-3 text-left text-xs text-[#315a9f]">
|
||||||
|
The betting window has closed. Please wait for the next issue to place your bets.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden rounded-xl border border-[#e6edf8] bg-white shadow-[0_8px_28px_rgba(15,23,42,0.05)] transition-opacity",
|
||||||
|
tableDisabled && "opacity-55",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className={cn("w-full border-collapse text-sm", activeCategory === "D4" ? "min-w-[760px]" : "min-w-[460px]")}>
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-[#e8eef7] bg-[#f5f8fd] text-[11px] font-semibold text-[#32518d]">
|
||||||
|
<th className="sticky left-0 z-20 w-12 bg-[#f5f8fd] px-2 py-3 text-center">
|
||||||
|
No.
|
||||||
|
</th>
|
||||||
|
<th className="sticky left-12 z-20 w-24 bg-[#f5f8fd] px-2 py-3 text-center">
|
||||||
|
Number
|
||||||
|
<span className="block text-[10px] font-normal text-[#6b7896]">
|
||||||
|
({numberPlaceholder})
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
{activeCategory === "D4" ? (
|
||||||
|
d4Columns.map((play) => (
|
||||||
|
<th key={play.play_code} className="min-w-20 px-2 py-3 text-center">
|
||||||
|
{pickDisplayName(play)}
|
||||||
|
</th>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<th className="min-w-28 px-2 py-3 text-center">Stake Amount</th>
|
||||||
|
<th className="min-w-28 px-2 py-3 text-center">Commission / Rebate</th>
|
||||||
|
<th className="min-w-28 px-2 py-3 text-center">Actual Deduction</th>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<th className="w-10 px-2 py-3" aria-label="Delete" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, index) => {
|
||||||
|
const simpleAmount = parseDecimalInputToMinor(
|
||||||
|
row.amounts[simplePlayCode] ?? "",
|
||||||
|
);
|
||||||
|
const simpleRebate =
|
||||||
|
simpleAmount !== null && simplePlay
|
||||||
|
? Math.round(simpleAmount * parseRebateRate(simplePlay.odds?.rebate_rate))
|
||||||
|
: 0;
|
||||||
|
const simpleActual =
|
||||||
|
simpleAmount !== null ? Math.max(0, simpleAmount - simpleRebate) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={row.id}
|
||||||
|
className="border-b border-[#eef2f8] last:border-b-0"
|
||||||
|
>
|
||||||
|
<td className="sticky left-0 z-10 bg-white px-2 py-3 text-center font-semibold text-[#17408d]">
|
||||||
|
{index + 1}
|
||||||
|
</td>
|
||||||
|
<td className="sticky left-12 z-10 bg-white px-2 py-3">
|
||||||
|
<Input
|
||||||
|
value={row.number}
|
||||||
|
disabled={tableDisabled}
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder={numberPlaceholder}
|
||||||
|
onChange={(event) => updateRowNumber(row.id, event.target.value)}
|
||||||
|
className="h-10 rounded-lg border-[#e2e8f0] bg-white text-center font-mono text-sm font-semibold text-slate-900 shadow-sm"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
{activeCategory === "D4" ? (
|
||||||
|
d4Columns.map((play) => (
|
||||||
|
<td key={play.play_code} className="px-1.5 py-3">
|
||||||
|
<Input
|
||||||
|
value={row.amounts[play.play_code] ?? ""}
|
||||||
|
disabled={tableDisabled}
|
||||||
|
inputMode="decimal"
|
||||||
|
placeholder="-"
|
||||||
|
onChange={(event) =>
|
||||||
|
updateAmount(row.id, play.play_code, event.target.value)
|
||||||
|
}
|
||||||
|
className="h-10 rounded-lg border-[#e2e8f0] bg-white text-center text-xs tabular-nums shadow-sm"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<td className="px-2 py-3">
|
||||||
|
<Input
|
||||||
|
value={row.amounts[simplePlayCode] ?? ""}
|
||||||
|
disabled={tableDisabled || !simplePlay}
|
||||||
|
inputMode="decimal"
|
||||||
|
placeholder="0.00"
|
||||||
|
onChange={(event) =>
|
||||||
|
updateAmount(row.id, simplePlayCode, event.target.value)
|
||||||
|
}
|
||||||
|
className="h-10 rounded-lg border-[#e2e8f0] bg-white text-center text-sm tabular-nums shadow-sm"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-3 text-center leading-tight">
|
||||||
|
<span className="block font-semibold tabular-nums text-[#34a853]">
|
||||||
|
-{simpleRebate > 0 ? amountToDisplay(simpleRebate) : "0.00"}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
({formatRatePercent(simplePlay?.odds?.rebate_rate)})
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-3 text-center tabular-nums text-slate-700">
|
||||||
|
{simpleActual > 0 ? amountToDisplay(simpleActual) : "0.00"}
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<td className="px-1 py-3 text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={tableDisabled || rows.length <= 1}
|
||||||
|
onClick={() => removeRow(row.id)}
|
||||||
|
className="inline-flex size-8 items-center justify-center rounded-full text-[#ff4d4f] hover:bg-red-50 disabled:text-slate-300 disabled:hover:bg-transparent"
|
||||||
|
aria-label={`删除第 ${index + 1} 行`}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" aria-hidden />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={tableDisabled || rows.length >= MAX_ROWS}
|
||||||
|
onClick={addRow}
|
||||||
|
className="flex h-11 w-full items-center justify-center gap-1.5 border-t border-[#edf2f9] text-sm font-semibold text-[#1d57b7] hover:bg-[#f7faff] disabled:text-slate-300"
|
||||||
|
>
|
||||||
|
<CirclePlus className="size-4" aria-hidden />
|
||||||
|
Add Row
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
<CardHeader>
|
<div className="flex items-center justify-between rounded-xl border border-[#e9eef7] bg-[#f8fbff] px-4 py-3 text-sm shadow-[0_6px_20px_rgba(15,23,42,0.04)]">
|
||||||
<CardTitle className="text-base">下注</CardTitle>
|
<span className="font-medium text-slate-800">Draft Total</span>
|
||||||
<CardDescription>
|
<span className="text-lg font-bold tabular-nums text-[#0b3f96]">
|
||||||
选择玩法并输入号码、金额后先「预览下注」,于弹窗内确认提交。币种与限额来自当前生效配置。
|
{formatMinorAsCurrency(draftSummary.actual, currencyCode)}
|
||||||
</CardDescription>
|
</span>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className="space-y-2">{body}</CardContent>
|
|
||||||
</Card>
|
{sealedBetUi ? (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-600">
|
||||||
|
已封盘:当前表格不可编辑,请等待下一期。
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={tableDisabled || previewLoading || draftEntries.length === 0}
|
||||||
|
onClick={() => void handlePreview()}
|
||||||
|
className="h-12 w-full rounded-xl border-0 bg-[#e5002c] text-base font-bold text-white shadow-[0_8px_20px_rgba(229,0,44,0.26)] hover:bg-[#d10028]"
|
||||||
|
>
|
||||||
|
<Ticket className="size-5" aria-hidden />
|
||||||
|
{previewLoading ? "Previewing..." : activeCategory === "JACKPOT" ? "Closed" : "Submit Bet"}
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
|
||||||
<HallBetPreviewDialog
|
<HallBetPreviewDialog
|
||||||
open={previewOpen}
|
open={previewOpen}
|
||||||
onOpenChange={(o) => {
|
onOpenChange={(open) => {
|
||||||
setPreviewOpen(o);
|
setPreviewOpen(open);
|
||||||
if (!o) setPreviewData(null);
|
if (!open) setPreviewData(null);
|
||||||
}}
|
}}
|
||||||
currencyCode={currencyCode}
|
currencyCode={currencyCode}
|
||||||
data={previewData}
|
data={previewData}
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import { Hourglass, Landmark, TimerReset, WalletCards } from "lucide-react";
|
||||||
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { drawStatusHud, isHallSealedCountdownUi } from "@/features/draw/draw-status-meta";
|
import { drawStatusHud, isHallSealedCountdownUi } from "@/features/draw/draw-status-meta";
|
||||||
import { useHallDrawLive } from "@/features/hall/use-hall-draw-live";
|
import { useHallDrawLive } from "@/features/hall/use-hall-draw-live";
|
||||||
@@ -18,10 +11,31 @@ 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";
|
||||||
|
|
||||||
/** 界面文档 §1.4:错误/封盘 #ff4d4f */
|
function CurrentTime({ payload }: { payload: DrawCurrentPayload }) {
|
||||||
const UI_DOC_ERROR = "text-[#ff4d4f]";
|
const source = payload.close_time ?? payload.draw_time ?? payload.start_time;
|
||||||
|
const formatted = source ? formatLotteryInstant(source) : null;
|
||||||
|
if (!formatted) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="text-lg font-black tabular-nums text-[#0b3f96]">--:--:--</span>
|
||||||
|
<span className="mt-1 text-[11px] text-slate-500">Current Time</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CountdownStrip({
|
const parts = formatted.split(" ");
|
||||||
|
const date = parts.slice(0, -1).join(" ");
|
||||||
|
const time = parts.at(-1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="text-lg font-black tabular-nums text-[#0b3f96]">{time}</span>
|
||||||
|
<span className="mt-1 text-[11px] text-slate-500">{date}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CloseTime({
|
||||||
hud,
|
hud,
|
||||||
payload,
|
payload,
|
||||||
}: {
|
}: {
|
||||||
@@ -29,95 +43,66 @@ function CountdownStrip({
|
|||||||
payload: DrawCurrentPayload;
|
payload: DrawCurrentPayload;
|
||||||
}) {
|
}) {
|
||||||
const sealedCountdown = isHallSealedCountdownUi(payload.status);
|
const sealedCountdown = isHallSealedCountdownUi(payload.status);
|
||||||
|
let seconds = 0;
|
||||||
|
let label = "Closes In";
|
||||||
|
|
||||||
if (hud.countdownKind === "close" && payload.seconds_to_close > 0) {
|
if (hud.countdownKind === "close") {
|
||||||
return (
|
seconds = Math.max(0, payload.seconds_to_close);
|
||||||
<p className="text-sm text-muted-foreground">
|
} else if (hud.countdownKind === "draw") {
|
||||||
封盘倒计时:{" "}
|
seconds = Math.max(0, payload.seconds_to_draw);
|
||||||
<span className="font-mono text-base font-semibold tabular-nums text-foreground">
|
label = sealedCountdown ? "Draws In" : "Closes In";
|
||||||
{formatSecondsClock(payload.seconds_to_close)}
|
} else if (hud.countdownKind === "cooldown") {
|
||||||
</span>
|
seconds = Math.max(0, payload.seconds_remaining_in_cooldown ?? 0);
|
||||||
</p>
|
label = "Cool Down";
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (hud.countdownKind === "draw" && payload.seconds_to_draw > 0) {
|
|
||||||
return (
|
return (
|
||||||
<p
|
<>
|
||||||
className={cn(
|
<span className="text-lg font-black tabular-nums text-[#ff143d]">
|
||||||
"text-sm",
|
{formatSecondsClock(seconds)}
|
||||||
sealedCountdown ? cn(UI_DOC_ERROR, "font-medium") : "text-muted-foreground",
|
</span>
|
||||||
)}
|
<span className="mt-1 text-[11px] text-slate-500">{label}</span>
|
||||||
>
|
</>
|
||||||
距离开奖:{" "}
|
);
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"font-mono text-base font-semibold tabular-nums",
|
|
||||||
sealedCountdown ? UI_DOC_ERROR : "text-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{formatSecondsClock(payload.seconds_to_draw)}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (hud.countdownKind === "cooldown" && payload.seconds_remaining_in_cooldown != null) {
|
|
||||||
return (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
冷静期剩余:{" "}
|
|
||||||
<span className="font-mono text-base font-semibold tabular-nums text-foreground">
|
|
||||||
{formatSecondsClock(payload.seconds_remaining_in_cooldown)}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 界面文档 §2.1 / §2.2:WebSocket `draw.countdown`、`draw.status_change`、`result.published`;
|
|
||||||
* 降级:每 30s 轮询 `GET draw/current`。
|
|
||||||
*/
|
|
||||||
export function HallDrawPanel() {
|
export function HallDrawPanel() {
|
||||||
const { raw, display, error, reload } = useHallDrawLive();
|
const { raw, display, error, reload } = useHallDrawLive();
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Card className="border-destructive/40">
|
<section className="mb-4 rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700">
|
||||||
<CardHeader className="pb-2">
|
<p>{error}</p>
|
||||||
<CardTitle className="text-base">当期期号</CardTitle>
|
<Button
|
||||||
<CardDescription>{error}</CardDescription>
|
type="button"
|
||||||
</CardHeader>
|
variant="outline"
|
||||||
<CardContent className="flex gap-2">
|
size="sm"
|
||||||
<Button type="button" variant="secondary" size="sm" onClick={() => void reload()}>
|
className="mt-2 border-red-200 bg-white text-red-700"
|
||||||
重试
|
onClick={() => void reload()}
|
||||||
</Button>
|
>
|
||||||
</CardContent>
|
重试
|
||||||
</Card>
|
</Button>
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (raw === undefined || display === undefined) {
|
if (raw === undefined || display === undefined) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<section className="mb-4 rounded-xl border border-[#e3ebf6] bg-white p-3 shadow-sm">
|
||||||
<CardHeader className="space-y-2 pb-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<Skeleton className="h-5 w-40" />
|
<Skeleton className="h-14 rounded-lg" />
|
||||||
<Skeleton className="h-4 w-52" />
|
<Skeleton className="h-14 rounded-lg" />
|
||||||
</CardHeader>
|
<Skeleton className="h-14 rounded-lg" />
|
||||||
<CardContent className="space-y-3">
|
</div>
|
||||||
<Skeleton className="h-12 w-full" />
|
</section>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (raw === null || display === null) {
|
if (raw === null || display === null) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<section className="mb-4 rounded-xl border border-[#e3ebf6] bg-white px-3 py-4 text-center text-sm text-slate-500 shadow-sm">
|
||||||
<CardHeader className="pb-2">
|
暂无可用期号,请稍后再试
|
||||||
<CardTitle className="text-base">当期期号</CardTitle>
|
</section>
|
||||||
<CardDescription>暂无可用期号,请稍后再试</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,58 +110,56 @@ export function HallDrawPanel() {
|
|||||||
const sealedUi = isHallSealedCountdownUi(display.status);
|
const sealedUi = isHallSealedCountdownUi(display.status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<section
|
||||||
className={cn(sealedUi && "border-[#ff4d4f]/45")}
|
className={cn(
|
||||||
|
"mb-4 overflow-hidden rounded-xl border border-[#e1e9f5] bg-white shadow-[0_6px_20px_rgba(15,23,42,0.06)]",
|
||||||
|
sealedUi && "border-red-200 bg-red-50/30",
|
||||||
|
)}
|
||||||
|
aria-label="Current issue"
|
||||||
>
|
>
|
||||||
<CardHeader className="space-y-1 pb-2">
|
<div className="grid grid-cols-[1fr_1.05fr_1fr] divide-x divide-[#e7edf6]">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
<div className="flex min-w-0 items-center justify-center gap-2 px-2 py-3 text-center">
|
||||||
|
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-[#eef4ff] text-[#0b56b7]">
|
||||||
|
<WalletCards className="size-4" aria-hidden />
|
||||||
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<CardTitle className="text-base leading-tight">
|
<p className="text-[11px] font-semibold text-slate-500">Issue No.</p>
|
||||||
第 {display.draw_no} 期
|
<p className="truncate text-sm font-black tabular-nums text-[#ff143d]">
|
||||||
</CardTitle>
|
{display.draw_no}
|
||||||
<CardDescription className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
<span className={cn("inline-block size-2 rounded-full", hud.dotClass)} />
|
|
||||||
<span>{hud.label}</span>
|
|
||||||
</span>
|
|
||||||
{display.draw_time ? (
|
|
||||||
<span className="text-xs opacity-90">
|
|
||||||
计划开奖:{formatLotteryInstant(display.draw_time)}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href="/results"
|
|
||||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "shrink-0")}
|
|
||||||
>
|
|
||||||
开奖结果
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<CountdownStrip hud={hud} payload={display} />
|
|
||||||
{sealedUi ? (
|
|
||||||
<div className={cn("space-y-1 text-xs", UI_DOC_ERROR)}>
|
|
||||||
<p className="font-medium">
|
|
||||||
已封盘:期号状态已标记「已封盘」,下注表格置灰且不可编辑;提交入口为「已封盘」。
|
|
||||||
</p>
|
</p>
|
||||||
<p>请选择下一期。</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
</div>
|
||||||
{Array.isArray(display.result_items) && display.result_items.length > 0 ? (
|
<div className="flex min-w-0 flex-col items-center justify-center px-2 py-3 text-center">
|
||||||
<div className="rounded-lg border border-dashed border-border bg-muted/30 p-3 text-xs text-muted-foreground">
|
<CurrentTime payload={display} />
|
||||||
本期号码已发布,完整 23 组展示见{" "}
|
</div>
|
||||||
<Link
|
<div className="relative flex min-w-0 flex-col items-center justify-center px-2 py-3 text-center">
|
||||||
href={`/results/${encodeURIComponent(display.draw_no)}`}
|
<CloseTime hud={hud} payload={display} />
|
||||||
className="font-medium text-primary underline-offset-4 hover:underline"
|
<Hourglass
|
||||||
>
|
className={cn(
|
||||||
当期结果
|
"absolute right-2 top-1/2 size-5 -translate-y-1/2",
|
||||||
</Link>
|
sealedUi ? "text-[#ff143d]" : "text-red-300",
|
||||||
。
|
)}
|
||||||
</div>
|
aria-hidden
|
||||||
) : null}
|
/>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
{sealedUi ? (
|
||||||
|
<div className="flex items-center gap-2 border-t border-red-100 bg-red-50 px-3 py-2 text-xs font-medium text-red-600">
|
||||||
|
<TimerReset className="size-4" aria-hidden />
|
||||||
|
已封盘,请等待下一期。
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-between border-t border-[#eef3f9] bg-[#fbfdff] px-3 py-1.5 text-[11px] text-slate-500">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className={cn("size-2 rounded-full", hud.dotClass)} />
|
||||||
|
{hud.label}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Landmark className="size-3.5" aria-hidden />
|
||||||
|
Betting Hall
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Bell } from "lucide-react";
|
||||||
|
|
||||||
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
import { HallBettingGrid } from "@/features/hall/hall-betting-grid";
|
import { HallBettingGrid } from "@/features/hall/hall-betting-grid";
|
||||||
import { HallDrawPanel } from "@/features/hall/hall-draw-panel";
|
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 { HallWalletStrip } from "@/features/hall/hall-wallet-strip";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -10,13 +12,42 @@ import { HallWalletStrip } from "@/features/hall/hall-wallet-strip";
|
|||||||
*/
|
*/
|
||||||
export function HallScreen() {
|
export function HallScreen() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="mx-auto w-full max-w-[480px]">
|
||||||
<HallWalletStrip />
|
<section className="overflow-hidden rounded-[18px] border border-[#dce7f7] bg-white px-2.5 py-3 text-slate-900 shadow-[0_18px_50px_rgba(15,44,92,0.12)]">
|
||||||
|
<div className="mb-3 flex items-center gap-2 px-1">
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
|
<div className="relative flex size-10 shrink-0 rotate-[-10deg] items-center justify-center rounded-lg bg-[#e60023] text-white shadow-[0_7px_14px_rgba(230,0,35,0.22)]">
|
||||||
|
<span className="absolute -left-1.5 top-1 flex size-6 rotate-[18deg] items-center justify-center rounded-sm bg-[#0b56b7] text-xs font-black">
|
||||||
|
N
|
||||||
|
</span>
|
||||||
|
<span className="ml-4 text-lg font-black italic">N</span>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 text-[28px] font-black italic leading-none tracking-normal">
|
||||||
|
<span className="text-[#ed001c]">N</span>{" "}
|
||||||
|
<span className="text-[#0a3f94]">lotto</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<LanguageSwitcher
|
||||||
|
variant="minimal"
|
||||||
|
showFlag={false}
|
||||||
|
className="shrink-0 rounded-full border border-[#e4eaf4] bg-[#f8fafc]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="relative flex size-9 shrink-0 items-center justify-center rounded-full text-[#1d57b7] hover:bg-[#f4f7fb]"
|
||||||
|
aria-label="Notifications"
|
||||||
|
>
|
||||||
|
<Bell className="size-5" aria-hidden />
|
||||||
|
<span className="absolute right-2 top-2 size-2 rounded-full bg-[#ff143d]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<HallDrawPanel />
|
<HallDrawPanel />
|
||||||
|
|
||||||
<HallPlayCatalogPanel />
|
<HallWalletStrip />
|
||||||
|
|
||||||
<HallBettingGrid />
|
<HallBettingGrid />
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Wallet } from "lucide-react";
|
import { Wallet } from "lucide-react";
|
||||||
import Link from "next/link";
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { getWalletBalance } from "@/api/wallet";
|
import { getWalletBalance } from "@/api/wallet";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import {
|
import {
|
||||||
TransferInDialog,
|
TransferInDialog,
|
||||||
@@ -13,23 +11,15 @@ import {
|
|||||||
} from "@/features/wallet/wallet-transfer-dialogs";
|
} from "@/features/wallet/wallet-transfer-dialogs";
|
||||||
import { formatMinorAsCurrency } from "@/lib/money";
|
import { formatMinorAsCurrency } from "@/lib/money";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
|
||||||
import { useNetworkConnectionStore } from "@/stores/network-connection-store";
|
import { useNetworkConnectionStore } from "@/stores/network-connection-store";
|
||||||
|
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||||
import type { WalletBalanceData } from "@/types/api/wallet-balance";
|
import type { WalletBalanceData } from "@/types/api/wallet-balance";
|
||||||
|
|
||||||
/**
|
|
||||||
* 高保真稿:大厅顶部红卡 + Transfer In(蓝)/ Transfer Out(白底红边),§4.2
|
|
||||||
* 已集成网络降级模式下的轮询刷新
|
|
||||||
*/
|
|
||||||
export function HallWalletStrip() {
|
export function HallWalletStrip() {
|
||||||
const profile = usePlayerSessionStore((s) => s.profile);
|
const profile = usePlayerSessionStore((s) => s.profile);
|
||||||
|
const mode = useNetworkConnectionStore((s) => s.mode);
|
||||||
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
|
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const mode = useNetworkConnectionStore((s) => s.mode);
|
|
||||||
|
|
||||||
/** 降级模式下的本地兜底轮询(勿写入全局 walletPollingIntervalId,避免与 useWebSocketManager 互相覆盖/触发 effect 死循环) */
|
|
||||||
const degradedWalletPollRef = useRef<number | null>(null);
|
const degradedWalletPollRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const currency = useMemo(
|
const currency = useMemo(
|
||||||
@@ -44,17 +34,17 @@ export function HallWalletStrip() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let c = false;
|
let cancelled = false;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await refresh();
|
await refresh();
|
||||||
} finally {
|
} finally {
|
||||||
if (!c) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
c = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
@@ -64,7 +54,6 @@ export function HallWalletStrip() {
|
|||||||
return () => window.removeEventListener("lottery-wallet-refresh", onRefresh);
|
return () => window.removeEventListener("lottery-wallet-refresh", onRefresh);
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
// 降级模式下本地兜底轮询(60s);与 WebSocket 管理器里的 *_wallet* 全局 timer 隔离
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== "polling" && mode !== "offline") {
|
if (mode !== "polling" && mode !== "offline") {
|
||||||
if (degradedWalletPollRef.current !== null) {
|
if (degradedWalletPollRef.current !== null) {
|
||||||
@@ -94,50 +83,36 @@ export function HallWalletStrip() {
|
|||||||
const availableMinor = Number(balance?.available_balance ?? 0);
|
const availableMinor = Number(balance?.available_balance ?? 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-2" aria-label="Wallet balance">
|
<section className="mb-4 space-y-3" aria-label="Wallet balance">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative overflow-hidden rounded-2xl bg-gradient-to-br from-[#dc2626] to-[#991b1b] px-4 py-3.5 text-white shadow-md",
|
"relative overflow-hidden rounded-xl bg-[#e5002c] px-4 py-4 text-white shadow-[0_10px_28px_rgba(229,0,44,0.25)]",
|
||||||
"ring-1 ring-black/10",
|
"before:absolute before:inset-y-0 before:right-0 before:w-44 before:bg-[radial-gradient(circle_at_70%_70%,rgba(255,255,255,0.22),transparent_38%),linear-gradient(135deg,transparent,rgba(255,255,255,0.13))] before:content-['']",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3">
|
<div className="relative flex items-center gap-3">
|
||||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-white/15">
|
<div className="flex size-13 shrink-0 items-center justify-center rounded-full bg-white text-[#d81435] shadow-sm">
|
||||||
<Wallet className="size-6 text-white" aria-hidden />
|
<Wallet className="size-7" aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-white/80">
|
<p className="text-sm font-semibold text-white/90">Wallet Balance</p>
|
||||||
Wallet Balance
|
|
||||||
</p>
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Skeleton className="mt-2 h-8 w-40 rounded-md bg-white/20" />
|
<Skeleton className="mt-2 h-8 w-44 rounded-md bg-white/25" />
|
||||||
) : (
|
) : (
|
||||||
<p className="font-heading text-2xl font-bold tabular-nums tracking-tight">
|
<p className="mt-1 text-2xl font-black leading-none tabular-nums tracking-normal">
|
||||||
{formatMinorAsCurrency(lotteryMinor, currency)}
|
{formatMinorAsCurrency(lotteryMinor, currency)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Link
|
|
||||||
href="/wallet"
|
|
||||||
className={cn(
|
|
||||||
buttonVariants({
|
|
||||||
variant: "secondary",
|
|
||||||
size: "sm",
|
|
||||||
}),
|
|
||||||
"shrink-0 border-0 bg-white/15 text-xs text-white hover:bg-white/25",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
明细
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<TransferInDialog
|
<TransferInDialog
|
||||||
idPrefix="hall-"
|
idPrefix="hall-"
|
||||||
triggerVariant="hall"
|
triggerVariant="hall"
|
||||||
triggerLabel="Transfer In"
|
triggerLabel="Transfer In"
|
||||||
triggerClassName="w-full min-w-0"
|
triggerClassName="h-12 rounded-lg text-base font-bold"
|
||||||
currency={currency}
|
currency={currency}
|
||||||
lotteryMinor={lotteryMinor}
|
lotteryMinor={lotteryMinor}
|
||||||
onSuccess={refresh}
|
onSuccess={refresh}
|
||||||
@@ -146,7 +121,7 @@ export function HallWalletStrip() {
|
|||||||
idPrefix="hall-"
|
idPrefix="hall-"
|
||||||
triggerVariant="hall"
|
triggerVariant="hall"
|
||||||
triggerLabel="Transfer Out"
|
triggerLabel="Transfer Out"
|
||||||
triggerClassName="w-full min-w-0"
|
triggerClassName="h-12 rounded-lg text-base font-bold"
|
||||||
currency={currency}
|
currency={currency}
|
||||||
availableMinor={availableMinor}
|
availableMinor={availableMinor}
|
||||||
onSuccess={refresh}
|
onSuccess={refresh}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function StatusDot({
|
|||||||
ring?: boolean;
|
ring?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-[#e5edf8] bg-[#f8fbff] px-2 py-1 text-xs font-bold text-slate-600">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-block size-2 shrink-0 rounded-full",
|
"inline-block size-2 shrink-0 rounded-full",
|
||||||
@@ -41,7 +41,7 @@ export function StatusDot({
|
|||||||
)}
|
)}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<span className="text-foreground">{label}</span>
|
<span>{label}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,23 +5,15 @@ import { useSearchParams } from "next/navigation";
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { getTicketItems } from "@/api/ticket-items";
|
import { getTicketItems } from "@/api/ticket-items";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||||
import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status";
|
import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status";
|
||||||
import { formatLotteryInstant } from "@/lib/player-datetime";
|
|
||||||
import { formatMinorAsCurrency } from "@/lib/money";
|
import { formatMinorAsCurrency } from "@/lib/money";
|
||||||
|
import { formatLotteryInstant } from "@/lib/player-datetime";
|
||||||
import { playLabelZh } from "@/lib/play-labels";
|
import { playLabelZh } from "@/lib/play-labels";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import type { TicketItemListRow } from "@/types/api/ticket-items";
|
import type { TicketItemListRow } from "@/types/api/ticket-items";
|
||||||
|
|
||||||
/** 界面文档 §4.7 我的注单 */
|
|
||||||
export function TicketOrdersListScreen() {
|
export function TicketOrdersListScreen() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const drawNoFilter = useMemo(
|
const drawNoFilter = useMemo(
|
||||||
@@ -75,122 +67,132 @@ export function TicketOrdersListScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<PlayerPanel title="My Bets" subtitle="Recent ticket records" eyebrow="N lotto">
|
||||||
<Card>
|
<div className="space-y-4">
|
||||||
<CardHeader className="pb-2">
|
<div className="rounded-xl border border-[#e6edf8] bg-[#f8fbff] p-3">
|
||||||
<CardTitle className="text-base">我的注单</CardTitle>
|
<div className="flex items-center justify-between gap-3">
|
||||||
<CardDescription>
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-bold text-[#32518d]">
|
||||||
|
{drawNoFilter ? "Filtered Issue" : "Total Records"}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 truncate font-mono text-lg font-black text-[#0b3f96]">
|
||||||
|
{drawNoFilter || total}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
{drawNoFilter ? (
|
{drawNoFilter ? (
|
||||||
<>
|
<Link
|
||||||
当前筛选期号{" "}
|
href="/orders"
|
||||||
<span className="font-mono text-foreground">{drawNoFilter}</span>
|
className="shrink-0 rounded-full border border-[#dce7f7] bg-white px-3 py-1.5 text-xs font-bold text-[#0b56b7]"
|
||||||
{" · "}
|
>
|
||||||
<Link href="/orders" className="text-primary underline-offset-4 hover:underline">
|
Clear
|
||||||
清除筛选
|
</Link>
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
"最近下注记录"
|
<Link
|
||||||
|
href="/hall"
|
||||||
|
className="shrink-0 rounded-full bg-[#e5002c] px-3 py-1.5 text-xs font-bold text-white"
|
||||||
|
>
|
||||||
|
Bet Now
|
||||||
|
</Link>
|
||||||
)}
|
)}
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-24 w-full rounded-lg" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<Card className="border-destructive/40">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">注单</CardTitle>
|
|
||||||
<CardDescription>{error}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Button type="button" size="sm" onClick={() => void fetchPage(1, false)}>
|
|
||||||
重试
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : items.length === 0 ? (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">还没有下注记录</CardTitle>
|
|
||||||
<CardDescription>去下注大厅试试手气吧</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Link href="/hall" className={cn(buttonVariants({ size: "sm" }))}>
|
|
||||||
去下注
|
|
||||||
</Link>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p className="text-xs text-muted-foreground">共 {total} 条</p>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{items.map((row) => {
|
|
||||||
const cur = row.currency_code ?? "NPR";
|
|
||||||
const st = ticketStatusDisplay(
|
|
||||||
row.status,
|
|
||||||
row.win_amount,
|
|
||||||
row.jackpot_win_amount,
|
|
||||||
);
|
|
||||||
const totalWin = row.win_amount + row.jackpot_win_amount;
|
|
||||||
return (
|
|
||||||
<Link key={row.ticket_no} href={`/orders/${encodeURIComponent(row.ticket_no)}`}>
|
|
||||||
<Card className="transition-colors hover:border-primary/30">
|
|
||||||
<CardHeader className="space-y-1 pb-2">
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
|
||||||
<span className="font-mono text-sm font-semibold text-foreground">
|
|
||||||
{row.draw_no ?? "—"}
|
|
||||||
</span>
|
|
||||||
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
|
|
||||||
</div>
|
|
||||||
<CardDescription className="font-mono text-xs leading-relaxed">
|
|
||||||
号码 {row.original_number ?? row.play_code} · 玩法 {playLabelZh(row.play_code)}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-1 pt-0 text-xs">
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
金额 {formatMinorAsCurrency(row.total_bet_amount, cur)} · 实扣{" "}
|
|
||||||
{formatMinorAsCurrency(row.actual_deduct_amount, cur)}
|
|
||||||
</p>
|
|
||||||
{totalWin > 0 && row.status === "settled_win" ? (
|
|
||||||
<p className="font-medium text-emerald-700 dark:text-emerald-400">
|
|
||||||
中奖 {formatMinorAsCurrency(totalWin, cur)}
|
|
||||||
{row.jackpot_win_amount > 0 ? (
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{" "}
|
|
||||||
(含 Jackpot {formatMinorAsCurrency(row.jackpot_win_amount, cur)})
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
<p className="text-[11px] text-muted-foreground">
|
|
||||||
{formatLotteryInstant(row.placed_at ?? null)}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
{page < lastPage ? (
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-28 w-full rounded-xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-4 text-sm text-red-700">
|
||||||
|
<p>{error}</p>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full"
|
className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]"
|
||||||
disabled={loadingMore}
|
onClick={() => void fetchPage(1, false)}
|
||||||
onClick={() => loadMore()}
|
|
||||||
>
|
>
|
||||||
{loadingMore ? "加载中…" : "加载更多"}
|
Retry
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
</div>
|
||||||
</>
|
) : items.length === 0 ? (
|
||||||
)}
|
<div className="rounded-xl border border-dashed border-[#dce7f7] bg-[#f8fbff] px-4 py-10 text-center">
|
||||||
</div>
|
<p className="text-sm font-bold text-slate-700">No bet records yet.</p>
|
||||||
|
<Link
|
||||||
|
href="/hall"
|
||||||
|
className="mt-4 inline-flex h-9 items-center rounded-lg bg-[#e5002c] px-4 text-sm font-bold text-white"
|
||||||
|
>
|
||||||
|
Submit Bet
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{items.map((row) => {
|
||||||
|
const cur = row.currency_code ?? "NPR";
|
||||||
|
const st = ticketStatusDisplay(
|
||||||
|
row.status,
|
||||||
|
row.win_amount,
|
||||||
|
row.jackpot_win_amount,
|
||||||
|
);
|
||||||
|
const totalWin = row.win_amount + row.jackpot_win_amount;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={row.ticket_no}
|
||||||
|
href={`/orders/${encodeURIComponent(row.ticket_no)}`}
|
||||||
|
className="block rounded-xl border border-[#e5edf8] bg-white p-3 shadow-[0_8px_24px_rgba(15,23,42,0.05)] transition-colors hover:border-[#b9ccf6]"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-mono text-sm font-black text-[#0b3f96]">
|
||||||
|
{row.draw_no ?? "—"}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 truncate text-xs text-slate-500">
|
||||||
|
{playLabelZh(row.play_code)} · {row.original_number ?? row.play_code}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
|
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
|
||||||
|
<p className="text-[10px] font-bold uppercase text-[#7890b8]">Stake</p>
|
||||||
|
<p className="mt-1 text-sm font-black text-slate-900">
|
||||||
|
{formatMinorAsCurrency(row.total_bet_amount, cur)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-[#f8fbff] px-3 py-2">
|
||||||
|
<p className="text-[10px] font-bold uppercase text-[#7890b8]">Deduction</p>
|
||||||
|
<p className="mt-1 text-sm font-black text-[#0b3f96]">
|
||||||
|
{formatMinorAsCurrency(row.actual_deduct_amount, cur)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{totalWin > 0 && row.status === "settled_win" ? (
|
||||||
|
<p className="mt-2 text-xs font-bold text-emerald-600">
|
||||||
|
Win {formatMinorAsCurrency(totalWin, cur)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<p className="mt-2 text-[11px] text-slate-500">
|
||||||
|
{formatLotteryInstant(row.placed_at ?? null)}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{page < lastPage ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="h-10 w-full rounded-lg bg-[#07459f] text-white hover:bg-[#063b88]"
|
||||||
|
disabled={loadingMore}
|
||||||
|
onClick={() => loadMore()}
|
||||||
|
>
|
||||||
|
{loadingMore ? "Loading..." : "Load More"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PlayerPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,22 +4,14 @@ import Link from "next/link";
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { getDrawResults } from "@/api/draw";
|
import { getDrawResults } from "@/api/draw";
|
||||||
import { JackpotResultsStrip } from "@/features/results/jackpot-results-strip";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||||
|
import { JackpotResultsStrip } from "@/features/results/jackpot-results-strip";
|
||||||
import { formatLotteryInstant } from "@/lib/player-datetime";
|
import { formatLotteryInstant } from "@/lib/player-datetime";
|
||||||
import type { DrawResultListItem } from "@/types/api/draw-results";
|
import type { DrawResultListItem } from "@/types/api/draw-results";
|
||||||
|
|
||||||
/** §4.6 历史列表 + 默认最新一期入口 */
|
|
||||||
export function DrawResultsListScreen() {
|
export function DrawResultsListScreen() {
|
||||||
const [items, setItems] = useState<DrawResultListItem[] | null>(null);
|
const [items, setItems] = useState<DrawResultListItem[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -51,92 +43,93 @@ export function DrawResultsListScreen() {
|
|||||||
}, [fetchList]);
|
}, [fetchList]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<PlayerPanel title="Results" subtitle="Latest draw history" eyebrow="N lotto">
|
||||||
<JackpotResultsStrip currencyCode="NPR" />
|
<div className="space-y-4">
|
||||||
|
<JackpotResultsStrip currencyCode="NPR" />
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
|
<div className="rounded-xl border border-[#e6edf8] bg-[#f8fbff] p-3">
|
||||||
<div className="flex flex-1 flex-col gap-1.5">
|
<p className="mb-2 text-xs font-bold text-[#32518d]">Business Date</p>
|
||||||
<Label htmlFor="biz-date">按业务日筛选</Label>
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
id="biz-date"
|
type="date"
|
||||||
type="date"
|
value={date}
|
||||||
value={date}
|
onChange={(e) => setDate(e.target.value)}
|
||||||
onChange={(e) => setDate(e.target.value)}
|
className="h-10 rounded-lg border-[#dce7f7] bg-white text-sm"
|
||||||
className="max-w-xs"
|
/>
|
||||||
/>
|
<Button
|
||||||
</div>
|
type="button"
|
||||||
<Button type="button" variant="secondary" size="sm" onClick={() => void fetchList()}>
|
size="sm"
|
||||||
应用
|
className="h-10 rounded-lg bg-[#07459f] px-4 text-white hover:bg-[#063b88]"
|
||||||
</Button>
|
onClick={() => void fetchList()}
|
||||||
</div>
|
>
|
||||||
|
Apply
|
||||||
{loading ? (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<Skeleton className="h-6 w-32" />
|
|
||||||
<Skeleton className="h-4 w-48" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<Skeleton className="h-24 w-full" />
|
|
||||||
<Skeleton className="h-24 w-full" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : error ? (
|
|
||||||
<Card className="border-destructive/40">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">开奖结果</CardTitle>
|
|
||||||
<CardDescription>{error}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Button type="button" size="sm" onClick={() => void fetchList()}>
|
|
||||||
重试
|
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
) : items && items.length === 0 ? (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">开奖结果</CardTitle>
|
|
||||||
<CardDescription>暂无开奖结果</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{items?.map((row) => (
|
|
||||||
<Card key={row.draw_no}>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<CardTitle className="font-mono text-base">{row.draw_no}</CardTitle>
|
|
||||||
<Link
|
|
||||||
href={`/results/${encodeURIComponent(row.draw_no)}`}
|
|
||||||
className="text-sm font-medium text-primary underline-offset-4 hover:underline"
|
|
||||||
>
|
|
||||||
查看详情 →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<CardDescription className="font-mono text-xs">
|
|
||||||
开奖时间:
|
|
||||||
{formatLotteryInstant(row.draw_time_iso ?? row.draw_time ?? null)}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid grid-cols-3 gap-2 text-center font-mono text-sm">
|
|
||||||
<div className="rounded-md border bg-card py-2">
|
|
||||||
<div className="text-[10px] uppercase text-muted-foreground">1st</div>
|
|
||||||
<div className="font-semibold">{row.results["1st"]}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-md border bg-card py-2">
|
|
||||||
<div className="text-[10px] uppercase text-muted-foreground">2nd</div>
|
|
||||||
<div className="font-semibold">{row.results["2nd"]}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-md border bg-card py-2">
|
|
||||||
<div className="text-[10px] uppercase text-muted-foreground">3rd</div>
|
|
||||||
<div className="font-semibold">{row.results["3rd"]}</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
{loading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Skeleton className="h-28 rounded-xl" />
|
||||||
|
<Skeleton className="h-28 rounded-xl" />
|
||||||
|
<Skeleton className="h-28 rounded-xl" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-4 text-sm text-red-700">
|
||||||
|
<p>{error}</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]"
|
||||||
|
onClick={() => void fetchList()}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : items && items.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-[#dce7f7] bg-[#f8fbff] px-4 py-10 text-center text-sm text-slate-500">
|
||||||
|
No results yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{items?.map((row) => (
|
||||||
|
<Link
|
||||||
|
key={row.draw_no}
|
||||||
|
href={`/results/${encodeURIComponent(row.draw_no)}`}
|
||||||
|
className="block rounded-xl border border-[#e5edf8] bg-white p-3 shadow-[0_8px_24px_rgba(15,23,42,0.05)] transition-colors hover:border-[#b9ccf6]"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-mono text-sm font-black text-[#0b3f96]">
|
||||||
|
{row.draw_no}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[11px] text-slate-500">
|
||||||
|
{formatLotteryInstant(row.draw_time_iso ?? row.draw_time ?? null)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 rounded-full bg-[#f2f6ff] px-2.5 py-1 text-xs font-bold text-[#0b56b7]">
|
||||||
|
Detail
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 grid grid-cols-3 gap-2 text-center">
|
||||||
|
{[
|
||||||
|
["1st", row.results["1st"]],
|
||||||
|
["2nd", row.results["2nd"]],
|
||||||
|
["3rd", row.results["3rd"]],
|
||||||
|
].map(([label, value]) => (
|
||||||
|
<div key={label} className="rounded-lg border border-[#edf2f8] bg-[#f8fbff] py-2">
|
||||||
|
<p className="text-[10px] font-bold uppercase text-[#7890b8]">{label}</p>
|
||||||
|
<p className="mt-1 font-mono text-lg font-black tabular-nums text-[#e5002c]">
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PlayerPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,11 +42,11 @@ export function JackpotResultsStrip({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-amber-500/30 bg-gradient-to-r from-amber-500/10 via-amber-400/5 to-transparent px-3 py-2">
|
<div className="rounded-xl border border-amber-200 bg-gradient-to-r from-amber-100 via-white to-[#f8fbff] px-3 py-3 shadow-[0_8px_20px_rgba(180,83,9,0.08)]">
|
||||||
<p className="text-[11px] font-medium uppercase tracking-wide text-amber-800/90 dark:text-amber-200/90">
|
<p className="text-[11px] font-black uppercase tracking-normal text-amber-700">
|
||||||
Jackpot
|
Jackpot
|
||||||
</p>
|
</p>
|
||||||
<p className="font-mono text-sm font-semibold tabular-nums text-amber-950 dark:text-amber-50">
|
<p className="font-mono text-lg font-black tabular-nums text-[#0b3f96]">
|
||||||
{formatMinorAsCurrency(minor, currencyCode.toUpperCase())}
|
{formatMinorAsCurrency(minor, currencyCode.toUpperCase())}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user