From 7472a61db05595e8b32a2373fb095776c4e4460a Mon Sep 17 00:00:00 2001 From: kang Date: Fri, 15 May 2026 16:36:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E5=A4=A7=E5=8E=85?= =?UTF-8?q?=E4=B8=8B=E6=B3=A8=E8=A1=A8=E6=A0=BC=E4=B8=8E=E5=BC=80=E5=A5=96?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E7=AD=9B=E9=80=89=E5=8A=A0=E8=BD=BD=E4=BD=93?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 精简大厅下注表格布局,缩小列宽与输入框,优化移动端可读性 - 调整默认草稿行与行激活逻辑,简化草稿合计展示 - 新增开奖结果日期选择器、清除日期与加载更多功能 - 支持开奖结果分页滚动加载与无更多数据提示 - 新增 react-day-picker 与 date-fns 依赖 - 补充下注表格相关多语言文案 --- package-lock.json | 38 +++ package.json | 2 + src/components/ui/calendar.tsx | 221 ++++++++++++++++++ src/features/hall/hall-betting-grid.tsx | 191 ++++++--------- .../results/draw-results-list-screen.tsx | 209 +++++++++++++++-- src/i18n/locales/en/player.json | 5 + src/i18n/locales/ne/player.json | 5 + src/i18n/locales/zh/player.json | 5 + 8 files changed, 537 insertions(+), 139 deletions(-) create mode 100644 src/components/ui/calendar.tsx diff --git a/package-lock.json b/package-lock.json index ca773df..dc308e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "axios": "^1.16.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "i18next": "^26.1.0", "i18next-browser-languagedetector": "^8.2.1", "i18next-http-backend": "^4.0.0", @@ -21,6 +22,7 @@ "next-themes": "^0.4.6", "pusher-js": "^8.5.0", "react": "19.2.4", + "react-day-picker": "^10.0.0", "react-dom": "19.2.4", "react-i18next": "^17.0.7", "sonner": "^2.0.7", @@ -556,6 +558,12 @@ } } }, + "node_modules/@date-fns/tz": { + "version": "1.4.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/@date-fns/tz/-/tz-1.4.1.tgz", + "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", + "license": "MIT" + }, "node_modules/@dotenvx/dotenvx": { "version": "1.65.0", "resolved": "https://registry.npmmirror.com/@dotenvx/dotenvx/-/dotenvx-1.65.0.tgz", @@ -4209,6 +4217,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", @@ -8532,6 +8550,26 @@ "node": ">=0.10.0" } }, + "node_modules/react-day-picker": { + "version": "10.0.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/react-day-picker/-/react-day-picker-10.0.0.tgz", + "integrity": "sha512-lrEXo5wFPsq5LTcayelM3BPueD00v7zbdipAY+EIdPcseVykYwkOWx4Ujn/EtbBvpnp8ZPUHol17HXH6kVbZoA==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "date-fns": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/react-dom": { "version": "19.2.4", "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.4.tgz", diff --git a/package.json b/package.json index f9cad64..e45c39d 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "axios": "^1.16.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "i18next": "^26.1.0", "i18next-browser-languagedetector": "^8.2.1", "i18next-http-backend": "^4.0.0", @@ -22,6 +23,7 @@ "next-themes": "^0.4.6", "pusher-js": "^8.5.0", "react": "19.2.4", + "react-day-picker": "^10.0.0", "react-dom": "19.2.4", "react-i18next": "^17.0.7", "sonner": "^2.0.7", diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx new file mode 100644 index 0000000..1d3044a --- /dev/null +++ b/src/components/ui/calendar.tsx @@ -0,0 +1,221 @@ +"use client" + +import * as React from "react" +import { + DayPicker, + getDefaultClassNames, + type DayButton, + type Locale, +} from "react-day-picker" + +import { cn } from "@/lib/utils" +import { Button, buttonVariants } from "@/components/ui/button" +import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react" + +function Calendar({ + className, + classNames, + showOutsideDays = true, + captionLayout = "label", + buttonVariant = "ghost", + locale, + formatters, + components, + ...props +}: React.ComponentProps & { + buttonVariant?: React.ComponentProps["variant"] +}) { + const defaultClassNames = getDefaultClassNames() + + return ( + svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className + )} + captionLayout={captionLayout} + locale={locale} + formatters={{ + formatMonthDropdown: (date) => + date.toLocaleString(locale?.code, { month: "short" }), + ...formatters, + }} + classNames={{ + root: cn("w-fit", defaultClassNames.root), + months: cn( + "relative flex flex-col gap-4 md:flex-row", + defaultClassNames.months + ), + month: cn("flex w-full flex-col gap-4", defaultClassNames.month), + nav: cn( + "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", + defaultClassNames.nav + ), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) p-0 select-none aria-disabled:opacity-50", + defaultClassNames.button_previous + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) p-0 select-none aria-disabled:opacity-50", + defaultClassNames.button_next + ), + month_caption: cn( + "flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)", + defaultClassNames.month_caption + ), + dropdowns: cn( + "flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium", + defaultClassNames.dropdowns + ), + dropdown_root: cn( + "relative rounded-(--cell-radius)", + defaultClassNames.dropdown_root + ), + dropdown: cn( + "absolute inset-0 bg-popover opacity-0", + defaultClassNames.dropdown + ), + caption_label: cn( + "font-medium select-none", + captionLayout === "label" + ? "text-sm" + : "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground", + defaultClassNames.caption_label + ), + table: "w-full border-collapse", + weekdays: cn("flex", defaultClassNames.weekdays), + weekday: cn( + "flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none", + defaultClassNames.weekday + ), + week: cn("mt-2 flex w-full", defaultClassNames.week), + week_number_header: cn( + "w-(--cell-size) select-none", + defaultClassNames.week_number_header + ), + week_number: cn( + "text-[0.8rem] text-muted-foreground select-none", + defaultClassNames.week_number + ), + day: cn( + "group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)", + props.showWeekNumber + ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)" + : "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)", + defaultClassNames.day + ), + range_start: cn( + "relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted", + defaultClassNames.range_start + ), + range_middle: cn("rounded-none", defaultClassNames.range_middle), + range_end: cn( + "relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted", + defaultClassNames.range_end + ), + today: cn( + "rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none", + defaultClassNames.today + ), + outside: cn( + "text-muted-foreground aria-selected:text-muted-foreground", + defaultClassNames.outside + ), + disabled: cn( + "text-muted-foreground opacity-50", + defaultClassNames.disabled + ), + hidden: cn("invisible", defaultClassNames.hidden), + ...classNames, + }} + components={{ + Root: ({ className, rootRef, ...props }) => { + return ( +
+ ) + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return ( + + ) + } + + if (orientation === "right") { + return ( + + ) + } + + return ( + + ) + }, + DayButton: ({ ...props }) => ( + + ), + WeekNumber: ({ children, ...props }) => { + return ( + +
+ {children} +
+ + ) + }, + ...components, + }} + {...props} + /> + ) +} + +function CalendarDayButton({ + className, + day, + modifiers, + locale, + ...props +}: React.ComponentProps & { locale?: Partial }) { + const defaultClassNames = getDefaultClassNames() + + const ref = React.useRef(null) + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus() + }, [modifiers.focused]) + + return ( +
- {categoryPlays.length > 0 ? ( -

- - {t("hall.table.scrollHint")} -

- ) : ( + {categoryPlays.length === 0 ? (
{t("hall.table.noPlaysInCategory")}
- )} + ) : null}
- - + - {categoryPlays.map((play) => ( - ))} - {rows.map((row, index) => { const rowKey = row.id; return ( - - + - {categoryPlays.map((play) => { const amountText = row.amounts[play.play_code] ?? ""; - const amountMinor = parseDecimalInputToMinor(amountText); - const rebate = amountMinor != null - ? Math.round(amountMinor * parseRebateRate(play.odds?.rebate_rate)) - : 0; - const actual = amountMinor != null ? Math.max(0, amountMinor - rebate) : 0; const status = cellRiskState( play, row.number, @@ -967,54 +934,57 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } alertRows, ); const disabled = tableDisabled || status === "sold_out" || (play.config !== null && !play.config.is_enabled); + const hasAmount = amountText.trim().length > 0; return ( ); })} - @@ -1023,42 +993,25 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
+
{t("hall.table.no", { defaultValue: "No." })} - {t("hall.table.number", { defaultValue: "Number" })} - - ({numberPlaceholder}) - + + {t("hall.table.number", { defaultValue: "Number" })} + ({numberPlaceholder}) - {pickDisplayName(play)} + + {pickDisplayName(play)} + + {t("hall.table.amountPlaceholder", { defaultValue: "金额" })} + +
+
{index + 1} + setActiveRowId(row.id)} onClick={() => setActiveRowId(row.id)} onChange={(event) => updateRowNumber(row.id, event.target.value)} - className="h-10 rounded-lg border-[#e2e8f0] bg-white text-center font-mono text-sm font-semibold text-slate-900 shadow-sm" + className="h-8 rounded-md border-[#e1e8f3] bg-white px-1 text-center font-mono text-sm font-black tracking-[0.1em] text-slate-950 shadow-sm focus-visible:ring-[#1d57b7]" /> -
- setActiveRowId(row.id)} - onClick={() => setActiveRowId(row.id)} - onChange={(event) => updateAmount(row.id, play.play_code, event.target.value)} - className="h-10 rounded-lg border-[#e2e8f0] bg-white text-center text-xs tabular-nums shadow-sm" - /> - {status === "sold_out" ? ( -

- {t("hall.table.soldOut", { defaultValue: "售罄" })} -

- ) : status === "warning" ? ( -

- {t("hall.table.warning", { defaultValue: "接近售罄" })} -

- ) : activeCategory !== "D4" && amountMinor !== null ? ( -

- {t("hall.table.actual", { defaultValue: "实扣" })}{" "} - {amountToDisplay(actual)} -

- ) : null} -
+ setActiveRowId(row.id)} + onClick={() => setActiveRowId(row.id)} + onChange={(event) => updateAmount(row.id, play.play_code, event.target.value)} + className={cn( + "h-8 rounded-md border-[#e1e8f3] bg-white px-1 text-center text-xs font-bold tabular-nums shadow-sm focus-visible:ring-[#1d57b7]", + hasAmount && "border-[#9bbcff] bg-[#f5f9ff] text-[#0b3f96]", + status === "warning" && "border-amber-200 bg-amber-50 text-amber-800", + status === "sold_out" && "border-slate-200 bg-slate-100 text-slate-400", + )} + /> + {status === "sold_out" ? ( +

+ {t("hall.table.soldOut", { defaultValue: "售罄" })} +

+ ) : status === "warning" ? ( +

+ {t("hall.table.warning", { defaultValue: "接近售罄" })} +

+ ) : null}
+
+
-
-
-

- {t("hall.summary.expectedDeduct", { defaultValue: "预计扣款" })} -

-

- {formatMinorAsCurrency(debouncedSummary.actual, currencyCode)} -

-
-
-

- {t("hall.summary.rebate", { defaultValue: "回水" })} -

-

- {formatMinorAsCurrency(debouncedSummary.rebate, currencyCode)} -

-
-
-

- {t("hall.summary.total", { defaultValue: "合计" })} -

-

- {formatMinorAsCurrency(debouncedSummary.bet, currencyCode)} -

-
+
+ + {t("hall.table.draftTotal", { defaultValue: "草稿合计" })} + + + {formatMinorAsCurrency(debouncedSummary.actual, currencyCode)} +
{sealedBetUi ? ( diff --git a/src/features/results/draw-results-list-screen.tsx b/src/features/results/draw-results-list-screen.tsx index cc39e86..c8947a5 100644 --- a/src/features/results/draw-results-list-screen.tsx +++ b/src/features/results/draw-results-list-screen.tsx @@ -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(null); const [error, setError] = useState(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(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 (
@@ -54,17 +109,88 @@ export function DrawResultsListScreen() { {t("results.businessDate")}

- setDate(e.target.value)} - className="h-10 rounded-lg border-[#dce7f7] bg-white text-sm" - /> + + + + {date || t("results.selectBusinessDate", { defaultValue: "选择日期" })} + + } + /> + +
+ + +
+ { + if (!value) return; + setDate(formatBusinessDate(value)); + setCalendarMonth(value); + setDatePickerOpen(false); + }} + className="rounded-lg" + /> +
+
+ {date ? ( + + ) : null} @@ -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")} @@ -131,9 +257,52 @@ export function DrawResultsListScreen() {
))} +
+ {page < lastPage ? ( + + ) : items && items.length > 0 ? ( +

+ {t("results.noMore", { defaultValue: "没有更多开奖结果" })} +

+ ) : null}
)}
); } + +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}`; +} diff --git a/src/i18n/locales/en/player.json b/src/i18n/locales/en/player.json index bfea7b4..9d35dcb 100644 --- a/src/i18n/locales/en/player.json +++ b/src/i18n/locales/en/player.json @@ -127,6 +127,11 @@ "sealedHint": "Closed: this table is locked. Please wait for the next issue.", "previewing": "Previewing...", "submitBet": "Submit Bet", + "amountPlaceholder": "Amount", + "filledPlayCount": "{{count}} plays filled", + "tapToFill": "Tap to enter amounts", + "rowActual": "Actual", + "editRow": "Edit row {{row}}", "scrollHint": "This table is wide: swipe or scroll horizontally, then enter your stake in each play column on the right (e.g. Big/Small, position plays).", "noPlaysInCategory": "No open play types in this tab, so there are no amount fields. Try 2D / 3D / 4D, or ask an admin to enable plays for this category." }, diff --git a/src/i18n/locales/ne/player.json b/src/i18n/locales/ne/player.json index e58f1ce..a4d0333 100644 --- a/src/i18n/locales/ne/player.json +++ b/src/i18n/locales/ne/player.json @@ -127,6 +127,11 @@ "sealedHint": "बन्द: यो तालिका लक छ। कृपया अर्को इश्यू पर्खनुहोस्।", "previewing": "पूर्वावलोकन...", "submitBet": "बेट पेश गर्नुहोस्", + "amountPlaceholder": "रकम", + "filledPlayCount": "{{count}} प्ले भरियो", + "tapToFill": "रकम लेख्न ट्याप गर्नुहोस्", + "rowActual": "वास्तविक", + "editRow": "पंक्ति {{row}} सम्पादन गर्नुहोस्", "scrollHint": "तालिका फराकिलो छ: दायाँतिर स्क्रोल गर्नुहोस्, दायाँका प्रत्येक खेल स्तम्भमा बेट रकम लेख्नुहोस्।", "noPlaysInCategory": "यस ट्याबमा खुला खेल प्रकार छैन। २D / ३D / ४D प्रयास गर्नुहोस् वा व्यवस्थापकले खेल खोल्नुपर्छ।" }, diff --git a/src/i18n/locales/zh/player.json b/src/i18n/locales/zh/player.json index 361e88d..cc97574 100644 --- a/src/i18n/locales/zh/player.json +++ b/src/i18n/locales/zh/player.json @@ -127,6 +127,11 @@ "sealedHint": "已封盘:当前表格不可编辑,请等待下一期。", "previewing": "预览中...", "submitBet": "提交下注", + "amountPlaceholder": "金额", + "filledPlayCount": "已填写 {{count}} 个玩法", + "tapToFill": "点击填写玩法金额", + "rowActual": "实扣", + "editRow": "编辑第 {{row}} 行", "scrollHint": "表格较宽:请向右滑动,在右侧各玩法列(如 Big / Small、位置玩法等)输入下注金额。", "noPlaysInCategory": "当前分类没有已开放的玩法,无法填写金额。请尝试切换 2D / 3D / 4D,或在后台开放对应玩法。" },