feat: 优化大厅下注表格与开奖结果筛选加载体验
- 精简大厅下注表格布局,缩小列宽与输入框,优化移动端可读性 - 调整默认草稿行与行激活逻辑,简化草稿合计展示 - 新增开奖结果日期选择器、清除日期与加载更多功能 - 支持开奖结果分页滚动加载与无更多数据提示 - 新增 react-day-picker 与 date-fns 依赖 - 补充下注表格相关多语言文案
This commit is contained in:
@@ -1,49 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { CalendarIcon, XIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getDrawResults } from "@/api/draw";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
import { JackpotResultsStrip } from "@/features/results/jackpot-results-strip";
|
||||
import { formatLotteryInstant } from "@/lib/player-datetime";
|
||||
import type { DrawResultListItem } from "@/types/api/draw-results";
|
||||
|
||||
const RESULTS_PAGE_SIZE = 10;
|
||||
|
||||
const MONTH_OPTIONS = Array.from({ length: 12 }, (_, value) => ({
|
||||
value,
|
||||
label: `${value + 1}月`,
|
||||
}));
|
||||
|
||||
export function DrawResultsListScreen() {
|
||||
const { t } = useTranslation("player");
|
||||
const [items, setItems] = useState<DrawResultListItem[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [date, setDate] = useState("");
|
||||
const [datePickerOpen, setDatePickerOpen] = useState(false);
|
||||
const [calendarMonth, setCalendarMonth] = useState(() => new Date());
|
||||
const [page, setPage] = useState(1);
|
||||
const [lastPage, setLastPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
const selectedDate = useMemo(() => parseBusinessDate(date), [date]);
|
||||
const businessDate = /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : undefined;
|
||||
const quickYears = useMemo(() => buildYearOptions(calendarMonth), [calendarMonth]);
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
const fetchList = useCallback(async (targetPage = 1, append = false) => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
if (append) {
|
||||
setLoadingMore(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await getDrawResults({
|
||||
page: 1,
|
||||
size: 30,
|
||||
business_date: /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : undefined,
|
||||
page: targetPage,
|
||||
size: RESULTS_PAGE_SIZE,
|
||||
business_date: businessDate,
|
||||
});
|
||||
setItems(res.items);
|
||||
setItems((current) => (append && current ? [...current, ...res.items] : res.items));
|
||||
setPage(res.page);
|
||||
setLastPage(res.last_page);
|
||||
} catch {
|
||||
setError(t("results.loadFailed"));
|
||||
setItems(null);
|
||||
if (!append) {
|
||||
setItems(null);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (append) {
|
||||
setLoadingMore(false);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [date, t]);
|
||||
}, [businessDate, t]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void fetchList();
|
||||
void fetchList(1, false);
|
||||
});
|
||||
}, [fetchList]);
|
||||
|
||||
useEffect(() => {
|
||||
const target = loadMoreRef.current;
|
||||
if (!target || loading || loadingMore || page >= lastPage) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry?.isIntersecting) {
|
||||
void fetchList(page + 1, true);
|
||||
}
|
||||
},
|
||||
{ rootMargin: "160px" },
|
||||
);
|
||||
|
||||
observer.observe(target);
|
||||
return () => observer.disconnect();
|
||||
}, [fetchList, lastPage, loading, loadingMore, page]);
|
||||
|
||||
return (
|
||||
<PlayerPanel title={t("results.title")} subtitle={t("results.subtitle")} eyebrow={t("brand.name")}>
|
||||
<div className="space-y-4">
|
||||
@@ -54,17 +109,88 @@ export function DrawResultsListScreen() {
|
||||
{t("results.businessDate")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="h-10 rounded-lg border-[#dce7f7] bg-white text-sm"
|
||||
/>
|
||||
<Popover open={datePickerOpen} onOpenChange={setDatePickerOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-10 flex-1 justify-start rounded-lg border-[#dce7f7] bg-white px-3 text-left text-sm font-semibold text-[#32518d] hover:bg-[#f8fbff]"
|
||||
>
|
||||
<CalendarIcon className="mr-2 size-4 text-[#7890b8]" />
|
||||
{date || t("results.selectBusinessDate", { defaultValue: "选择日期" })}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="start" className="w-auto border-[#dce7f7] p-2 shadow-[0_16px_40px_rgba(15,23,42,0.14)]">
|
||||
<div className="mb-2 grid grid-cols-2 gap-2">
|
||||
<Select
|
||||
value={String(calendarMonth.getMonth())}
|
||||
onValueChange={(value) => {
|
||||
setCalendarMonth((current) => new Date(current.getFullYear(), Number(value), 1));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-full border-[#dce7f7] bg-white text-xs font-semibold text-[#32518d]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-64 min-w-24">
|
||||
{MONTH_OPTIONS.map((month) => (
|
||||
<SelectItem key={month.value} value={String(month.value)}>
|
||||
{month.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={String(calendarMonth.getFullYear())}
|
||||
onValueChange={(value) => {
|
||||
setCalendarMonth((current) => new Date(Number(value), current.getMonth(), 1));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-full border-[#dce7f7] bg-white text-xs font-semibold text-[#32518d]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-64 min-w-24">
|
||||
{quickYears.map((year) => (
|
||||
<SelectItem key={year} value={String(year)}>
|
||||
{year}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Calendar
|
||||
mode="single"
|
||||
month={calendarMonth}
|
||||
onMonthChange={setCalendarMonth}
|
||||
selected={selectedDate}
|
||||
onSelect={(value) => {
|
||||
if (!value) return;
|
||||
setDate(formatBusinessDate(value));
|
||||
setCalendarMonth(value);
|
||||
setDatePickerOpen(false);
|
||||
}}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{date ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-10 w-10 rounded-lg border-[#dce7f7] bg-white text-[#7890b8] hover:bg-[#f8fbff] hover:text-[#32518d]"
|
||||
aria-label={t("actions.clear", { defaultValue: "清除" })}
|
||||
onClick={() => setDate("")}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-10 rounded-lg bg-[#07459f] px-4 text-white hover:bg-[#063b88]"
|
||||
onClick={() => void fetchList()}
|
||||
onClick={() => void fetchList(1, false)}
|
||||
>
|
||||
{t("actions.apply")}
|
||||
</Button>
|
||||
@@ -84,7 +210,7 @@ export function DrawResultsListScreen() {
|
||||
type="button"
|
||||
size="sm"
|
||||
className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]"
|
||||
onClick={() => void fetchList()}
|
||||
onClick={() => void fetchList(1, false)}
|
||||
>
|
||||
{t("actions.retry")}
|
||||
</Button>
|
||||
@@ -131,9 +257,52 @@ export function DrawResultsListScreen() {
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
<div ref={loadMoreRef} className="min-h-1" />
|
||||
{page < lastPage ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-10 w-full rounded-xl border-[#dce7f7] bg-white text-sm font-bold text-[#32518d] hover:bg-[#f8fbff]"
|
||||
disabled={loadingMore}
|
||||
onClick={() => void fetchList(page + 1, true)}
|
||||
>
|
||||
{loadingMore
|
||||
? t("actions.loading", { defaultValue: "加载中..." })
|
||||
: t("actions.loadMore", { defaultValue: "加载更多" })}
|
||||
</Button>
|
||||
) : items && items.length > 0 ? (
|
||||
<p className="py-2 text-center text-xs text-slate-400">
|
||||
{t("results.noMore", { defaultValue: "没有更多开奖结果" })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PlayerPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function buildYearOptions(month: Date): number[] {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const visibleYear = month.getFullYear();
|
||||
const start = Math.min(currentYear - 10, visibleYear - 2);
|
||||
const end = Math.max(currentYear + 2, visibleYear + 2);
|
||||
|
||||
return Array.from({ length: end - start + 1 }, (_, index) => start + index);
|
||||
}
|
||||
|
||||
function parseBusinessDate(value: string): Date | undefined {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
||||
if (!match) return undefined;
|
||||
|
||||
const [, year, month, day] = match;
|
||||
return new Date(Number(year), Number(month) - 1, Number(day));
|
||||
}
|
||||
|
||||
function formatBusinessDate(value: Date): string {
|
||||
const year = value.getFullYear();
|
||||
const month = String(value.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(value.getDate()).padStart(2, "0");
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user