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

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>
))}