From 377e03e1671ef3ea981256b42abe5b1ffbe5638a Mon Sep 17 00:00:00 2001 From: kang Date: Mon, 11 May 2026 15:40:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E4=B8=8E=E7=94=A8=E6=88=B7=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 PlayerBottomNav 中新增注单导航选项 - 在 DrawResultDetailScreen 中添加高亮显示用户命中号码的功能,并显示个人派彩信息 - 在 DrawResultsListScreen 中引入 JackpotResultsStrip 组件以展示奖池信息 - 在 TwentyThreeResultsGrid 中实现命中号码的高亮效果,提升用户体验 --- src/api/jackpot.ts | 13 + src/api/ticket-items.ts | 49 ++++ .../(main)/orders/[ticketNo]/page.tsx | 12 + src/app/(player)/(main)/orders/page.tsx | 23 ++ src/components/layout/player-bottom-nav.tsx | 16 +- src/features/orders/ticket-item-status.tsx | 47 ++++ .../orders/ticket-order-detail-screen.tsx | 264 ++++++++++++++++++ .../orders/ticket-orders-list-screen.tsx | 196 +++++++++++++ .../results/draw-result-detail-screen.tsx | 109 +++++++- .../results/draw-results-list-screen.tsx | 3 + .../results/jackpot-results-strip.tsx | 54 ++++ .../results/twenty-three-results-grid.tsx | 38 ++- src/lib/norm-4d.ts | 7 + src/lib/play-labels.ts | 34 +++ src/types/api/jackpot.ts | 5 + src/types/api/ticket-items.ts | 69 +++++ 16 files changed, 922 insertions(+), 17 deletions(-) create mode 100644 src/api/jackpot.ts create mode 100644 src/api/ticket-items.ts create mode 100644 src/app/(player)/(main)/orders/[ticketNo]/page.tsx create mode 100644 src/app/(player)/(main)/orders/page.tsx create mode 100644 src/features/orders/ticket-item-status.tsx create mode 100644 src/features/orders/ticket-order-detail-screen.tsx create mode 100644 src/features/orders/ticket-orders-list-screen.tsx create mode 100644 src/features/results/jackpot-results-strip.tsx create mode 100644 src/lib/norm-4d.ts create mode 100644 src/lib/play-labels.ts create mode 100644 src/types/api/jackpot.ts create mode 100644 src/types/api/ticket-items.ts diff --git a/src/api/jackpot.ts b/src/api/jackpot.ts new file mode 100644 index 0000000..4ba300c --- /dev/null +++ b/src/api/jackpot.ts @@ -0,0 +1,13 @@ +import { lotteryRequest } from "@/lib/lottery-http"; +import { API_V1_PREFIX } from "@/api/paths"; +import type { JackpotSummaryData } from "@/types/api/jackpot"; + +/** `GET /api/v1/jackpot/summary`(无需登录) */ +export function getJackpotSummary( + currencyCode = "NPR", +): Promise { + return lotteryRequest.get( + `${API_V1_PREFIX}/jackpot/summary`, + { params: { currency_code: currencyCode } }, + ); +} diff --git a/src/api/ticket-items.ts b/src/api/ticket-items.ts new file mode 100644 index 0000000..cc5889d --- /dev/null +++ b/src/api/ticket-items.ts @@ -0,0 +1,49 @@ +import { lotteryRequest } from "@/lib/lottery-http"; +import { API_V1_PREFIX } from "@/api/paths"; +import type { + TicketDrawMyMatchPayload, + TicketItemDetailPayload, + TicketItemsListPayload, +} from "@/types/api/ticket-items"; + +export type GetTicketItemsParams = { + page?: number; + per_page?: number; + draw_no?: string; +}; + +/** `GET /api/v1/ticket/items`(需登录) */ +export function getTicketItems( + params?: GetTicketItemsParams, +): Promise { + return lotteryRequest.get( + `${API_V1_PREFIX}/ticket/items`, + { + params: { + page: params?.page, + per_page: params?.per_page, + draw_no: params?.draw_no, + }, + }, + ); +} + +/** `GET /api/v1/ticket/items/{ticket_no}`(需登录) */ +export function getTicketItemDetail( + ticketNo: string, +): Promise { + const enc = encodeURIComponent(ticketNo); + return lotteryRequest.get( + `${API_V1_PREFIX}/ticket/items/${enc}`, + ); +} + +/** `GET /api/v1/ticket/draws/{draw_no}/my-match`(需登录) */ +export function getTicketDrawMyMatch( + drawNo: string, +): Promise { + const enc = encodeURIComponent(drawNo); + return lotteryRequest.get( + `${API_V1_PREFIX}/ticket/draws/${enc}/my-match`, + ); +} diff --git a/src/app/(player)/(main)/orders/[ticketNo]/page.tsx b/src/app/(player)/(main)/orders/[ticketNo]/page.tsx new file mode 100644 index 0000000..9877e44 --- /dev/null +++ b/src/app/(player)/(main)/orders/[ticketNo]/page.tsx @@ -0,0 +1,12 @@ +import { TicketOrderDetailScreen } from "@/features/orders/ticket-order-detail-screen"; + +type PageProps = { + params: Promise<{ ticketNo: string }>; +}; + +/** 界面文档 §4.8 注单详情 */ +export default async function OrderDetailPage({ params }: PageProps) { + const { ticketNo } = await params; + + return ; +} diff --git a/src/app/(player)/(main)/orders/page.tsx b/src/app/(player)/(main)/orders/page.tsx new file mode 100644 index 0000000..5d81961 --- /dev/null +++ b/src/app/(player)/(main)/orders/page.tsx @@ -0,0 +1,23 @@ +import { Suspense } from "react"; + +import { Skeleton } from "@/components/ui/skeleton"; +import { TicketOrdersListScreen } from "@/features/orders/ticket-orders-list-screen"; + +function OrdersFallback() { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ); +} + +/** 界面文档 §4.7 我的注单(支持 `?draw_no=` 筛选) */ +export default function OrdersPage() { + return ( + }> + + + ); +} diff --git a/src/components/layout/player-bottom-nav.tsx b/src/components/layout/player-bottom-nav.tsx index a46fc16..c5eb907 100644 --- a/src/components/layout/player-bottom-nav.tsx +++ b/src/components/layout/player-bottom-nav.tsx @@ -3,12 +3,18 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import { LayoutGrid, Trophy, Wallet } from "lucide-react"; +import { LayoutGrid, Receipt, Trophy, Wallet } from "lucide-react"; import { cn } from "@/lib/utils"; const tabs = [ { href: "/hall", label: "大厅", icon: LayoutGrid, match: (p: string) => p === "/hall" }, + { + href: "/orders", + label: "注单", + icon: Receipt, + match: (p: string) => p === "/orders" || p.startsWith("/orders/"), + }, { href: "/wallet", label: "钱包", @@ -34,7 +40,7 @@ export function PlayerBottomNav() { 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" aria-label="主导航" > -
+
{tabs.map(({ href, label, icon: Icon, match }) => { const active = match(pathname); return ( @@ -44,7 +50,7 @@ export function PlayerBottomNav() { prefetch aria-current={active ? "page" : undefined} className={cn( - "flex flex-col items-center justify-center gap-0.5 text-[11px] font-medium transition-colors", + "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]", active ? "text-primary" : "text-muted-foreground hover:text-foreground active:text-foreground", @@ -52,9 +58,9 @@ export function PlayerBottomNav() { > - {label} + {label} ); })} diff --git a/src/features/orders/ticket-item-status.tsx b/src/features/orders/ticket-item-status.tsx new file mode 100644 index 0000000..93f1291 --- /dev/null +++ b/src/features/orders/ticket-item-status.tsx @@ -0,0 +1,47 @@ +import { cn } from "@/lib/utils"; + +export function ticketStatusDisplay( + status: string, + winMinor: number, + jackpotMinor: number, +): { label: string; dotClass: string; ring?: boolean } { + const total = winMinor + jackpotMinor; + if (status === "success") { + return { label: "待开奖", dotClass: "bg-sky-500" }; + } + if (status === "settled_win" && total > 0) { + return { label: "已派彩", dotClass: "bg-emerald-500" }; + } + if (status === "settled_lose" || status === "settled_win") { + return { + label: "未中奖", + dotClass: "bg-background", + ring: true, + }; + } + return { label: status, dotClass: "bg-red-500" }; +} + +export function StatusDot({ + label, + dotClass, + ring, +}: { + label: string; + dotClass: string; + ring?: boolean; +}) { + return ( + + + {label} + + ); +} diff --git a/src/features/orders/ticket-order-detail-screen.tsx b/src/features/orders/ticket-order-detail-screen.tsx new file mode 100644 index 0000000..a9134c8 --- /dev/null +++ b/src/features/orders/ticket-order-detail-screen.tsx @@ -0,0 +1,264 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useState } from "react"; + +import { getTicketItemDetail } from "@/api/ticket-items"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid"; +import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status"; +import { formatLotteryInstant } from "@/lib/player-datetime"; +import { formatMinorAsCurrency } from "@/lib/money"; +import { norm4d } from "@/lib/norm-4d"; +import { playLabelZh } from "@/lib/play-labels"; +import { cn } from "@/lib/utils"; +import type { TicketItemDetailPayload } from "@/types/api/ticket-items"; + +type OddsSnapRow = { prize_scope?: string; odds_value?: number }; + +function formatOddsSnapshot(json: unknown): string { + if (!Array.isArray(json)) return "—"; + const parts = (json as OddsSnapRow[]) + .filter((r) => r.prize_scope && r.odds_value != null) + .map((r) => { + const scope = String(r.prize_scope); + const label = + scope === "first" + ? "头奖" + : scope === "second" + ? "二奖" + : scope === "third" + ? "三奖" + : scope === "starter" + ? "特别奖" + : scope === "consolation" + ? "安慰奖" + : scope; + const mult = Number(r.odds_value) / 10_000; + return `${label} ${mult}x`; + }); + return parts.length ? parts.join(" · ") : "—"; +} + +const TIER_ZH: Record = { + first: "头奖", + second: "二奖", + third: "三奖", + starter: "特别奖", + consolation: "安慰奖", +}; + +/** 界面文档 §4.8 注单详情 */ +export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const row = await getTicketItemDetail(ticketNo); + setData(row); + } catch { + setData(null); + setError("注单不存在或无权查看"); + } finally { + setLoading(false); + } + }, [ticketNo]); + + useEffect(() => { + queueMicrotask(() => { + void load(); + }); + }, [load]); + + if (loading) { + return ( + + + + + + + + + + ); + } + + if (error || !data) { + return ( + + + 注单详情 + {error ?? "无数据"} + + + + + 返回列表 + + + + ); + } + + const cur = data.currency_code ?? "NPR"; + const st = ticketStatusDisplay(data.status, data.win_amount, data.jackpot_win_amount); + const totalWin = data.win_amount + data.jackpot_win_amount; + const pub = data.published_draw_results; + const first = pub?.results?.["1st"] ?? ""; + const comboHits = + first && data.combinations.length + ? data.combinations.filter((c) => norm4d(c.number_4d) === norm4d(first)) + : []; + + const highlight = + pub?.results && data.combinations.length + ? new Set( + data.combinations + .map((c) => norm4d(c.number_4d)) + .filter((n) => { + const nums = [ + pub.results["1st"], + pub.results["2nd"], + pub.results["3rd"], + ...(pub.results.starter ?? []), + ...(pub.results.consolation ?? []), + ] + .filter(Boolean) + .map((x) => norm4d(String(x))); + return nums.includes(n); + }), + ) + : null; + + const tierLabel = data.settlement?.matched_prize_tier + ? TIER_ZH[data.settlement.matched_prize_tier] ?? data.settlement.matched_prize_tier + : null; + + return ( +
+ + +
+ 注单详情 + +
+ + 注单号 {data.ticket_no} · 订单 {data.order_no ?? "—"} + +
+ +
+

+ 期号{" "} + {data.draw_no ?? "—"} +

+

+ 下单时间{" "} + {formatLotteryInstant(data.placed_at ?? null)} +

+

+ 号码{" "} + {data.original_number ?? "—"} +

+

+ 玩法 {playLabelZh(data.play_code)} ( + {data.dimension ?? "—"}D) +

+

+ 下注金额{" "} + {formatMinorAsCurrency(data.total_bet_amount, cur)} +

+

+ 回水率{" "} + {(Number(data.rebate_rate_snapshot) * 100).toFixed(1)}% +

+

+ 实扣金额{" "} + {formatMinorAsCurrency(data.actual_deduct_amount, cur)} +

+
+ +
+

赔率快照

+

{formatOddsSnapshot(data.odds_snapshot_json)}

+
+ + {pub?.results ? ( +
+

开奖号码(本期)

+ + {first ? ( +

+ 头奖{" "} + {first} + {comboHits.length > 0 ? ( + ← 命中 + ) : null} +

+ ) : null} +
+ ) : ( +

本期开奖号码尚未发布或不可展示。

+ )} + + {data.settlement && tierLabel ? ( +
+

+ 匹配结果:命中 {tierLabel} +

+

+ 中奖金额 {formatMinorAsCurrency(data.settlement.win_amount_minor, cur)} + {data.settlement.jackpot_allocation_minor > 0 ? ( + <> + {" "} + · Jackpot {formatMinorAsCurrency(data.settlement.jackpot_allocation_minor, cur)} + + ) : null} +

+

+ 派彩合计 {formatMinorAsCurrency(totalWin, cur)} +

+
+ ) : data.status === "settled_lose" ? ( +

匹配结果:未中奖

+ ) : null} + + {data.settled_at ? ( +

+ 结算时间 {formatLotteryInstant(data.settled_at)} +

+ ) : null} +
+
+ +
+ {data.draw_no ? ( + + 查看本期开奖 + + ) : null} + + 返回我的注单 + +
+
+ ); +} diff --git a/src/features/orders/ticket-orders-list-screen.tsx b/src/features/orders/ticket-orders-list-screen.tsx new file mode 100644 index 0000000..c80e316 --- /dev/null +++ b/src/features/orders/ticket-orders-list-screen.tsx @@ -0,0 +1,196 @@ +"use client"; + +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { getTicketItems } from "@/api/ticket-items"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status"; +import { formatLotteryInstant } from "@/lib/player-datetime"; +import { formatMinorAsCurrency } from "@/lib/money"; +import { playLabelZh } from "@/lib/play-labels"; +import { cn } from "@/lib/utils"; +import type { TicketItemListRow } from "@/types/api/ticket-items"; + +/** 界面文档 §4.7 我的注单 */ +export function TicketOrdersListScreen() { + const searchParams = useSearchParams(); + const drawNoFilter = useMemo( + () => (searchParams.get("draw_no") ?? "").trim(), + [searchParams], + ); + + const [items, setItems] = useState([]); + const [page, setPage] = useState(1); + const [lastPage, setLastPage] = useState(1); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(null); + + const fetchPage = useCallback( + async (nextPage: number, append: boolean) => { + if (append) setLoadingMore(true); + else setLoading(true); + setError(null); + try { + const res = await getTicketItems({ + page: nextPage, + per_page: 20, + draw_no: drawNoFilter || undefined, + }); + setItems((prev) => (append ? [...prev, ...res.items] : res.items)); + setPage(res.page); + setLastPage(res.last_page); + setTotal(res.total); + } catch { + setError("加载失败"); + if (!append) setItems([]); + } finally { + setLoading(false); + setLoadingMore(false); + } + }, + [drawNoFilter], + ); + + useEffect(() => { + queueMicrotask(() => { + void fetchPage(1, false); + }); + }, [fetchPage]); + + const loadMore = () => { + if (page >= lastPage || loadingMore) return; + void fetchPage(page + 1, true); + }; + + return ( +
+ + + 我的注单 + + {drawNoFilter ? ( + <> + 当前筛选期号{" "} + {drawNoFilter} + {" · "} + + 清除筛选 + + + ) : ( + "最近下注记录" + )} + + + + + {loading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : error ? ( + + + 注单 + {error} + + + + + + ) : items.length === 0 ? ( + + + 还没有下注记录 + 去下注大厅试试手气吧 + + + + 去下注 + + + + ) : ( + <> +

共 {total} 条

+
+ {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 ( + + + +
+ + {row.draw_no ?? "—"} + + +
+ + 号码 {row.original_number ?? row.play_code} · 玩法 {playLabelZh(row.play_code)} + +
+ +

+ 金额 {formatMinorAsCurrency(row.total_bet_amount, cur)} · 实扣{" "} + {formatMinorAsCurrency(row.actual_deduct_amount, cur)} +

+ {totalWin > 0 && row.status === "settled_win" ? ( +

+ 中奖 {formatMinorAsCurrency(totalWin, cur)} + {row.jackpot_win_amount > 0 ? ( + + {" "} + (含 Jackpot {formatMinorAsCurrency(row.jackpot_win_amount, cur)}) + + ) : null} +

+ ) : null} +

+ {formatLotteryInstant(row.placed_at ?? null)} +

+
+
+ + ); + })} +
+ {page < lastPage ? ( + + ) : null} + + )} +
+ ); +} diff --git a/src/features/results/draw-result-detail-screen.tsx b/src/features/results/draw-result-detail-screen.tsx index eaf012c..a567374 100644 --- a/src/features/results/draw-result-detail-screen.tsx +++ b/src/features/results/draw-result-detail-screen.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { useCallback, useEffect, useState } from "react"; import { getDrawResultByNo } from "@/api/draw"; +import { getTicketDrawMyMatch } from "@/api/ticket-items"; import { Button, buttonVariants } from "@/components/ui/button"; import { Card, @@ -13,8 +14,12 @@ import { CardTitle, } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; +import { JackpotResultsStrip } from "@/features/results/jackpot-results-strip"; import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid"; +import { getPlayerBearerTokenPayload } from "@/lib/lottery-auth"; import { formatLotteryInstant } from "@/lib/player-datetime"; +import { formatMinorAsCurrency } from "@/lib/money"; +import { norm4d } from "@/lib/norm-4d"; import { cn } from "@/lib/utils"; import type { DrawResultDetailPayload } from "@/types/api/draw-results"; @@ -22,11 +27,17 @@ type DrawResultDetailScreenProps = { drawNo: string; }; -/** §4.6 开奖结果详情:23 分区 + [< >] 切换 */ +/** §4.6 开奖结果详情:23 分区 + [< >] 切换 + 本人命中高亮 + Jackpot */ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps) { const [data, setData] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); + const [highlightSet, setHighlightSet] = useState | null>(null); + const [myTotals, setMyTotals] = useState<{ + win: number; + jackpot: number; + hasBets: boolean; + } | null>(null); const load = useCallback(async () => { setLoading(true); @@ -48,6 +59,43 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps) }); }, [load]); + useEffect(() => { + let cancelled = false; + queueMicrotask(() => { + if (!data) { + setHighlightSet(null); + setMyTotals(null); + return; + } + const token = getPlayerBearerTokenPayload(); + if (!token) { + setHighlightSet(new Set()); + setMyTotals(null); + return; + } + void (async () => { + try { + const m = await getTicketDrawMyMatch(data.draw_no); + if (cancelled) return; + setHighlightSet(new Set(m.hit_numbers_4d.map((n) => norm4d(n)))); + setMyTotals({ + win: m.total_win_minor, + jackpot: m.total_jackpot_win_minor, + hasBets: m.has_bets, + }); + } catch { + if (!cancelled) { + setHighlightSet(new Set()); + setMyTotals(null); + } + } + })(); + }); + return () => { + cancelled = true; + }; + }, [data]); + if (loading) { return ( @@ -81,8 +129,23 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps) ); } + const currency = "NPR"; + const showMyPayout = + myTotals && + myTotals.hasBets && + (myTotals.win > 0 || myTotals.jackpot > 0); + const showHitOnly = + myTotals?.hasBets && + highlightSet && + highlightSet.size > 0 && + myTotals && + myTotals.win === 0 && + myTotals.jackpot === 0; + return (
+ +
@@ -120,11 +183,45 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps) - -

- 中奖号码高亮、「查看我的中奖情况」跳转注单并按该期筛选:见实施计划 docs/06 §11.7、§14.3「承接阶段 - 3」(界面 §4.6)。 -

+ + + {showMyPayout && myTotals ? ( +
+

本期我的派彩

+

+ 常规:{formatMinorAsCurrency(myTotals.win, currency)} + {myTotals.jackpot > 0 ? ( + <> + {" "} + · Jackpot:{formatMinorAsCurrency(myTotals.jackpot, currency)} + + ) : null} +

+
+ ) : null} + {showHitOnly ? ( +

+ 您的注单已命中本期开奖号码中的格子;派彩完成后将显示金额汇总。 +

+ ) : null} + +
+

+ 如果您中奖,与注单匹配的号码将以金色高亮显示(需登录)。 +

+ + 查看我的中奖情况 + +
diff --git a/src/features/results/draw-results-list-screen.tsx b/src/features/results/draw-results-list-screen.tsx index 628c61b..1aff734 100644 --- a/src/features/results/draw-results-list-screen.tsx +++ b/src/features/results/draw-results-list-screen.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { useCallback, useEffect, useState } from "react"; import { getDrawResults } from "@/api/draw"; +import { JackpotResultsStrip } from "@/features/results/jackpot-results-strip"; import { Button } from "@/components/ui/button"; import { Card, @@ -51,6 +52,8 @@ export function DrawResultsListScreen() { return (
+ +
diff --git a/src/features/results/jackpot-results-strip.tsx b/src/features/results/jackpot-results-strip.tsx new file mode 100644 index 0000000..b4f8684 --- /dev/null +++ b/src/features/results/jackpot-results-strip.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { getJackpotSummary } from "@/api/jackpot"; +import { formatMinorAsCurrency } from "@/lib/money"; + +type JackpotResultsStripProps = { + currencyCode?: string; +}; + +/** 开奖模块顶部:Jackpot 当前池(公开接口) */ +export function JackpotResultsStrip({ + currencyCode = "NPR", +}: JackpotResultsStripProps) { + const [minor, setMinor] = useState(null); + const [enabled, setEnabled] = useState(false); + + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const j = await getJackpotSummary(currencyCode); + if (!cancelled) { + setEnabled(j.enabled); + setMinor(j.current_amount_minor); + } + } catch { + if (!cancelled) { + setEnabled(false); + setMinor(null); + } + } + })(); + return () => { + cancelled = true; + }; + }, [currencyCode]); + + if (!enabled || minor === null) { + return null; + } + + return ( +
+

+ Jackpot +

+

+ {formatMinorAsCurrency(minor, currencyCode.toUpperCase())} +

+
+ ); +} diff --git a/src/features/results/twenty-three-results-grid.tsx b/src/features/results/twenty-three-results-grid.tsx index ea7e7c4..5bc327a 100644 --- a/src/features/results/twenty-three-results-grid.tsx +++ b/src/features/results/twenty-three-results-grid.tsx @@ -1,18 +1,42 @@ import type { DrawResultsNumbers } from "@/types/api/draw-results"; +import { norm4d } from "@/lib/norm-4d"; +import { cn } from "@/lib/utils"; type TwentyThreeResultsGridProps = { numbers: DrawResultsNumbers; + /** 与本人注单组合相交的 4D(规范化后),命中格子使用界面文档 §4.6 金色高亮 */ + highlighted4d?: ReadonlySet | null; }; /** * §4.6 开奖结果页:头/二/三奖 + Starter 10 + Consolation 10 */ -export function TwentyThreeResultsGrid({ numbers }: TwentyThreeResultsGridProps) { +export function TwentyThreeResultsGrid({ + numbers, + highlighted4d, +}: TwentyThreeResultsGridProps) { const starters = numbers.starter ?? []; const consos = numbers.consolation ?? []; + const hits = highlighted4d ?? null; - const cellCls = - "flex min-h-[2.75rem] items-center justify-center rounded-md border border-border bg-card font-mono text-base font-semibold tracking-wide tabular-nums"; + const cellBase = + "flex min-h-[2.75rem] items-center justify-center rounded-md border font-mono text-base font-semibold tracking-wide tabular-nums"; + + const cellTone = (raw: string) => { + const v = (raw || "").trim(); + const isHit = + hits !== null && + hits.size > 0 && + v !== "" && + v !== "—" && + hits.has(norm4d(v)); + return cn( + cellBase, + isHit + ? "border-amber-500/80 bg-gradient-to-br from-amber-300 via-amber-400 to-amber-500 text-amber-950 shadow-[inset_0_1px_0_rgba(255,255,255,0.35)]" + : "border-border bg-card", + ); + }; return (
@@ -22,7 +46,9 @@ export function TwentyThreeResultsGrid({ numbers }: TwentyThreeResultsGridProps) {key === "1st" ? "头奖" : key === "2nd" ? "二奖" : "三奖"} -
{numbers[key] || "—"}
+
+ {numbers[key] || "—"} +
))}
@@ -31,7 +57,7 @@ export function TwentyThreeResultsGrid({ numbers }: TwentyThreeResultsGridProps)

特别奖 (Starter)

{Array.from({ length: 10 }).map((_, i) => ( -
+
{starters[i] ?? "—"}
))} @@ -42,7 +68,7 @@ export function TwentyThreeResultsGrid({ numbers }: TwentyThreeResultsGridProps)

安慰奖 (Consolation)

{Array.from({ length: 10 }).map((_, i) => ( -
+
{consos[i] ?? "—"}
))} diff --git a/src/lib/norm-4d.ts b/src/lib/norm-4d.ts new file mode 100644 index 0000000..b0e1510 --- /dev/null +++ b/src/lib/norm-4d.ts @@ -0,0 +1,7 @@ +/** 与后端结算口径一致:仅数字,固定 4 位左侧补零。 */ +export function norm4d(raw: string): string { + const digits = raw.replace(/\D/g, ""); + const tail = digits.slice(-4); + + return tail.padStart(4, "0"); +} diff --git a/src/lib/play-labels.ts b/src/lib/play-labels.ts new file mode 100644 index 0000000..ac46588 --- /dev/null +++ b/src/lib/play-labels.ts @@ -0,0 +1,34 @@ +/** 玩法展示名(与种子 play_types 对齐;无映射时回退 play_code) */ +const LABELS: Record = { + big: "Big", + small: "Small", + pos_4a: "4A", + pos_4b: "4B", + pos_4c: "4C", + pos_4d: "4D", + pos_4e: "4E", + pos_3a: "3A", + pos_3b: "3B", + pos_3c: "3C", + pos_3abc: "3ABC", + pos_2a: "2A", + pos_2b: "2B", + pos_2c: "2C", + pos_2abc: "2ABC", + straight: "Straight", + box: "Box", + ibox: "iBox", + mbox: "mBox", + roll: "Roll", + half_box: "Half Box", + head: "Head", + tail: "Tail", + odd: "Odd", + even: "Even", + digit_big: "Big Digit", + digit_small: "Small Digit", +}; + +export function playLabelZh(playCode: string): string { + return LABELS[playCode] ?? playCode; +} diff --git a/src/types/api/jackpot.ts b/src/types/api/jackpot.ts new file mode 100644 index 0000000..0ecec31 --- /dev/null +++ b/src/types/api/jackpot.ts @@ -0,0 +1,5 @@ +export type JackpotSummaryData = { + currency_code: string; + enabled: boolean; + current_amount_minor: number; +}; diff --git a/src/types/api/ticket-items.ts b/src/types/api/ticket-items.ts new file mode 100644 index 0000000..49310d8 --- /dev/null +++ b/src/types/api/ticket-items.ts @@ -0,0 +1,69 @@ +import type { DrawResultListItem } from "@/types/api/draw-results"; + +export type TicketItemListRow = { + ticket_no: string; + order_no: string | null | undefined; + draw_no: string | null | undefined; + currency_code: string | null | undefined; + play_code: string; + original_number: string | null; + total_bet_amount: number; + actual_deduct_amount: number; + status: string; + win_amount: number; + jackpot_win_amount: number; + placed_at: string | null | undefined; + updated_at: string | null | undefined; +}; + +export type TicketItemsListPayload = { + items: TicketItemListRow[]; + total: number; + page: number; + per_page: number; + last_page: number; +}; + +export type TicketItemCombinationRow = { + combination_no: number; + number_4d: string; + bet_amount: number; + estimated_payout: number; +}; + +export type TicketItemDetailPayload = { + ticket_no: string; + order_no: string | null | undefined; + draw_no: string | null | undefined; + currency_code: string | null | undefined; + play_code: string; + dimension: number | null; + digit_slot: number | null; + original_number: string | null; + normalized_number: string; + unit_bet_amount: number; + total_bet_amount: number; + rebate_rate_snapshot: string; + actual_deduct_amount: number; + status: string; + win_amount: number; + jackpot_win_amount: number; + settled_at: string | null | undefined; + placed_at: string | null | undefined; + odds_snapshot_json: unknown; + combinations: TicketItemCombinationRow[]; + settlement: { + matched_prize_tier: string | null; + win_amount_minor: number; + jackpot_allocation_minor: number; + } | null; + published_draw_results: DrawResultListItem | null; +}; + +export type TicketDrawMyMatchPayload = { + draw_no: string; + hit_numbers_4d: string[]; + total_win_minor: number; + total_jackpot_win_minor: number; + has_bets: boolean; +};