feat: 增强结果展示与用户交互
- 在 PlayerBottomNav 中新增注单导航选项 - 在 DrawResultDetailScreen 中添加高亮显示用户命中号码的功能,并显示个人派彩信息 - 在 DrawResultsListScreen 中引入 JackpotResultsStrip 组件以展示奖池信息 - 在 TwentyThreeResultsGrid 中实现命中号码的高亮效果,提升用户体验
This commit is contained in:
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user