feat: 增强结果展示与用户交互
- 在 PlayerBottomNav 中新增注单导航选项 - 在 DrawResultDetailScreen 中添加高亮显示用户命中号码的功能,并显示个人派彩信息 - 在 DrawResultsListScreen 中引入 JackpotResultsStrip 组件以展示奖池信息 - 在 TwentyThreeResultsGrid 中实现命中号码的高亮效果,提升用户体验
This commit is contained in:
13
src/api/jackpot.ts
Normal file
13
src/api/jackpot.ts
Normal 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
49
src/api/ticket-items.ts
Normal 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`,
|
||||
);
|
||||
}
|
||||
12
src/app/(player)/(main)/orders/[ticketNo]/page.tsx
Normal file
12
src/app/(player)/(main)/orders/[ticketNo]/page.tsx
Normal 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)} />;
|
||||
}
|
||||
23
src/app/(player)/(main)/orders/page.tsx
Normal file
23
src/app/(player)/(main)/orders/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
47
src/features/orders/ticket-item-status.tsx
Normal file
47
src/features/orders/ticket-item-status.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
264
src/features/orders/ticket-order-detail-screen.tsx
Normal file
264
src/features/orders/ticket-order-detail-screen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
196
src/features/orders/ticket-orders-list-screen.tsx
Normal file
196
src/features/orders/ticket-orders-list-screen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
54
src/features/results/jackpot-results-strip.tsx
Normal file
54
src/features/results/jackpot-results-strip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
7
src/lib/norm-4d.ts
Normal 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
34
src/lib/play-labels.ts
Normal 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
5
src/types/api/jackpot.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type JackpotSummaryData = {
|
||||
currency_code: string;
|
||||
enabled: boolean;
|
||||
current_amount_minor: number;
|
||||
};
|
||||
69
src/types/api/ticket-items.ts
Normal file
69
src/types/api/ticket-items.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user