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