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

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