feat: 增强结果展示与用户交互

- 在 PlayerBottomNav 中新增注单导航选项
- 在 DrawResultDetailScreen 中添加高亮显示用户命中号码的功能,并显示个人派彩信息
- 在 DrawResultsListScreen 中引入 JackpotResultsStrip 组件以展示奖池信息
- 在 TwentyThreeResultsGrid 中实现命中号码的高亮效果,提升用户体验
This commit is contained in:
2026-05-11 15:40:42 +08:00
parent 1922a29f49
commit 377e03e167
16 changed files with 922 additions and 17 deletions

13
src/api/jackpot.ts Normal file
View File

@@ -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<JackpotSummaryData> {
return lotteryRequest.get<JackpotSummaryData>(
`${API_V1_PREFIX}/jackpot/summary`,
{ params: { currency_code: currencyCode } },
);
}

49
src/api/ticket-items.ts Normal file
View File

@@ -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<TicketItemsListPayload> {
return lotteryRequest.get<TicketItemsListPayload>(
`${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<TicketItemDetailPayload> {
const enc = encodeURIComponent(ticketNo);
return lotteryRequest.get<TicketItemDetailPayload>(
`${API_V1_PREFIX}/ticket/items/${enc}`,
);
}
/** `GET /api/v1/ticket/draws/{draw_no}/my-match`(需登录) */
export function getTicketDrawMyMatch(
drawNo: string,
): Promise<TicketDrawMyMatchPayload> {
const enc = encodeURIComponent(drawNo);
return lotteryRequest.get<TicketDrawMyMatchPayload>(
`${API_V1_PREFIX}/ticket/draws/${enc}/my-match`,
);
}

View File

@@ -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 <TicketOrderDetailScreen ticketNo={decodeURIComponent(ticketNo)} />;
}

View File

@@ -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 (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-24 w-full rounded-lg" />
))}
</div>
);
}
/** 界面文档 §4.7 我的注单(支持 `?draw_no=` 筛选) */
export default function OrdersPage() {
return (
<Suspense fallback={<OrdersFallback />}>
<TicketOrdersListScreen />
</Suspense>
);
}

View File

@@ -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="主导航"
>
<div className="mx-auto grid h-14 max-w-lg grid-cols-3">
<div className="mx-auto grid h-14 w-full max-w-lg grid-rows-1 [grid-template-columns:repeat(4,minmax(0,1fr))]">
{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() {
>
<Icon
aria-hidden
className={cn("size-[22px]", active && "stroke-[2.25px]")}
className={cn("size-5 shrink-0 sm:size-[22px]", active && "stroke-[2.25px]")}
/>
{label}
<span className="w-full truncate">{label}</span>
</Link>
);
})}

View File

@@ -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 (
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<span
className={cn(
"inline-block size-2 shrink-0 rounded-full",
dotClass,
ring && "ring-1 ring-muted-foreground/50",
)}
aria-hidden
/>
<span className="text-foreground">{label}</span>
</span>
);
}

View File

@@ -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<string, string> = {
first: "头奖",
second: "二奖",
third: "三奖",
starter: "特别奖",
consolation: "安慰奖",
};
/** 界面文档 §4.8 注单详情 */
export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
const [data, setData] = useState<TicketItemDetailPayload | null>(null);
const [error, setError] = useState<string | null>(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 (
<Card>
<CardHeader>
<Skeleton className="h-6 w-40" />
<Skeleton className="h-4 w-56" />
</CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-32 w-full" />
</CardContent>
</Card>
);
}
if (error || !data) {
return (
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardDescription>{error ?? "无数据"}</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
</Button>
<Link href="/orders" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
</Link>
</CardContent>
</Card>
);
}
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 (
<div className="flex flex-col gap-4">
<Card>
<CardHeader className="space-y-2 pb-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<CardTitle className="text-base"></CardTitle>
<StatusDot label={st.label} dotClass={st.dotClass} ring={st.ring} />
</div>
<CardDescription className="font-mono text-xs">
{data.ticket_no} · {data.order_no ?? "—"}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="grid gap-1 text-xs">
<p>
<span className="text-muted-foreground"></span>{" "}
<span className="font-mono font-medium">{data.draw_no ?? "—"}</span>
</p>
<p>
<span className="text-muted-foreground"></span>{" "}
{formatLotteryInstant(data.placed_at ?? null)}
</p>
<p>
<span className="text-muted-foreground"></span>{" "}
<span className="font-mono">{data.original_number ?? "—"}</span>
</p>
<p>
<span className="text-muted-foreground"></span> {playLabelZh(data.play_code)} (
{data.dimension ?? "—"}D)
</p>
<p>
<span className="text-muted-foreground"></span>{" "}
{formatMinorAsCurrency(data.total_bet_amount, cur)}
</p>
<p>
<span className="text-muted-foreground"></span>{" "}
{(Number(data.rebate_rate_snapshot) * 100).toFixed(1)}%
</p>
<p>
<span className="text-muted-foreground"></span>{" "}
{formatMinorAsCurrency(data.actual_deduct_amount, cur)}
</p>
</div>
<div className="rounded-md border bg-muted/30 px-3 py-2 text-xs">
<p className="font-medium text-foreground"></p>
<p className="mt-1 text-muted-foreground">{formatOddsSnapshot(data.odds_snapshot_json)}</p>
</div>
{pub?.results ? (
<div className="space-y-2">
<p className="text-sm font-medium"></p>
<TwentyThreeResultsGrid numbers={pub.results} highlighted4d={highlight} />
{first ? (
<p className="text-xs text-muted-foreground">
{" "}
<span className="font-mono font-semibold text-foreground">{first}</span>
{comboHits.length > 0 ? (
<span className="text-emerald-600 dark:text-emerald-400"> </span>
) : null}
</p>
) : null}
</div>
) : (
<p className="text-xs text-muted-foreground"></p>
)}
{data.settlement && tierLabel ? (
<div className="rounded-md border border-emerald-500/20 bg-emerald-500/5 px-3 py-2 text-xs">
<p className="font-medium text-emerald-900 dark:text-emerald-100">
{tierLabel}
</p>
<p className="mt-1 font-mono text-muted-foreground">
{formatMinorAsCurrency(data.settlement.win_amount_minor, cur)}
{data.settlement.jackpot_allocation_minor > 0 ? (
<>
{" "}
· Jackpot {formatMinorAsCurrency(data.settlement.jackpot_allocation_minor, cur)}
</>
) : null}
</p>
<p className="mt-1 font-mono text-muted-foreground">
{formatMinorAsCurrency(totalWin, cur)}
</p>
</div>
) : data.status === "settled_lose" ? (
<p className="text-xs text-muted-foreground"></p>
) : null}
{data.settled_at ? (
<p className="text-[11px] text-muted-foreground">
{formatLotteryInstant(data.settled_at)}
</p>
) : null}
</CardContent>
</Card>
<div className="flex flex-wrap gap-2">
{data.draw_no ? (
<Link
href={`/results/${encodeURIComponent(data.draw_no)}`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
</Link>
) : null}
<Link href="/orders" className={cn(buttonVariants({ variant: "secondary", size: "sm" }))}>
</Link>
</div>
</div>
);
}

View File

@@ -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<TicketItemListRow[]>([]);
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<string | null>(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 (
<div className="flex flex-col gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base"></CardTitle>
<CardDescription>
{drawNoFilter ? (
<>
{" "}
<span className="font-mono text-foreground">{drawNoFilter}</span>
{" · "}
<Link href="/orders" className="text-primary underline-offset-4 hover:underline">
</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>
{page < lastPage ? (
<Button
type="button"
variant="secondary"
size="sm"
className="w-full"
disabled={loadingMore}
onClick={() => loadMore()}
>
{loadingMore ? "加载中…" : "加载更多"}
</Button>
) : null}
</>
)}
</div>
);
}

View File

@@ -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<DrawResultDetailPayload | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [highlightSet, setHighlightSet] = useState<ReadonlySet<string> | 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 (
<Card>
@@ -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 (
<div className="flex flex-col gap-4">
<JackpotResultsStrip currencyCode={currency} />
<Card>
<CardHeader className="space-y-3 pb-2">
<div className="flex flex-wrap items-center justify-between gap-2">
@@ -120,11 +183,45 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
</CardDescription>
</CardHeader>
<CardContent className="pt-2">
<TwentyThreeResultsGrid numbers={data.results} />
<p className="mt-4 text-xs text-muted-foreground">
docs/06 §11.7§14.3
3 §4.6
</p>
<TwentyThreeResultsGrid
numbers={data.results}
highlighted4d={highlightSet ?? undefined}
/>
{showMyPayout && myTotals ? (
<div className="mt-4 rounded-md border border-emerald-500/25 bg-emerald-500/5 px-3 py-2 text-sm">
<p className="font-medium text-emerald-900 dark:text-emerald-100"></p>
<p className="mt-1 font-mono text-xs tabular-nums text-muted-foreground">
{formatMinorAsCurrency(myTotals.win, currency)}
{myTotals.jackpot > 0 ? (
<>
{" "}
· Jackpot{formatMinorAsCurrency(myTotals.jackpot, currency)}
</>
) : null}
</p>
</div>
) : null}
{showHitOnly ? (
<p className="mt-3 text-xs text-amber-900/90 dark:text-amber-100/90">
</p>
) : null}
<div className="mt-4 flex flex-col gap-3">
<p className="text-xs text-muted-foreground">
</p>
<Link
href={`/orders?draw_no=${encodeURIComponent(data.draw_no)}`}
className={cn(
buttonVariants({ variant: "default", size: "sm" }),
"w-full sm:w-auto sm:self-start",
)}
>
</Link>
</div>
</CardContent>
</Card>
</div>

View File

@@ -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 (
<div className="flex flex-col gap-4">
<JackpotResultsStrip currencyCode="NPR" />
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="biz-date"></Label>

View File

@@ -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<number | null>(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 (
<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">
<p className="text-[11px] font-medium uppercase tracking-wide text-amber-800/90 dark:text-amber-200/90">
Jackpot
</p>
<p className="font-mono text-sm font-semibold tabular-nums text-amber-950 dark:text-amber-50">
{formatMinorAsCurrency(minor, currencyCode.toUpperCase())}
</p>
</div>
);
}

View File

@@ -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<string> | 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 (
<div className="flex flex-col gap-4">
@@ -22,7 +46,9 @@ export function TwentyThreeResultsGrid({ numbers }: TwentyThreeResultsGridProps)
<span className="text-xs font-medium uppercase text-muted-foreground">
{key === "1st" ? "头奖" : key === "2nd" ? "二奖" : "三奖"}
</span>
<div className={cellCls}>{numbers[key] || ""}</div>
<div className={cellTone(numbers[key] || "")}>
{numbers[key] || "—"}
</div>
</div>
))}
</div>
@@ -31,7 +57,7 @@ export function TwentyThreeResultsGrid({ numbers }: TwentyThreeResultsGridProps)
<p className="text-sm font-medium text-foreground"> (Starter)</p>
<div className="grid grid-cols-5 gap-2">
{Array.from({ length: 10 }).map((_, i) => (
<div key={`s-${i}`} className={cellCls}>
<div key={`s-${i}`} className={cellTone(starters[i] ?? "—")}>
{starters[i] ?? "—"}
</div>
))}
@@ -42,7 +68,7 @@ export function TwentyThreeResultsGrid({ numbers }: TwentyThreeResultsGridProps)
<p className="text-sm font-medium text-foreground"> (Consolation)</p>
<div className="grid grid-cols-5 gap-2">
{Array.from({ length: 10 }).map((_, i) => (
<div key={`c-${i}`} className={cellCls}>
<div key={`c-${i}`} className={cellTone(consos[i] ?? "—")}>
{consos[i] ?? "—"}
</div>
))}

7
src/lib/norm-4d.ts Normal file
View File

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

34
src/lib/play-labels.ts Normal file
View File

@@ -0,0 +1,34 @@
/** 玩法展示名(与种子 play_types 对齐;无映射时回退 play_code */
const LABELS: Record<string, string> = {
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;
}

5
src/types/api/jackpot.ts Normal file
View File

@@ -0,0 +1,5 @@
export type JackpotSummaryData = {
currency_code: string;
enabled: boolean;
current_amount_minor: number;
};

View File

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