feat: 优化大厅下注表格与开奖结果筛选加载体验

- 精简大厅下注表格布局,缩小列宽与输入框,优化移动端可读性
- 调整默认草稿行与行激活逻辑,简化草稿合计展示
- 新增开奖结果日期选择器、清除日期与加载更多功能
- 支持开奖结果分页滚动加载与无更多数据提示
- 新增 react-day-picker 与 date-fns 依赖
- 补充下注表格相关多语言文案
This commit is contained in:
2026-05-15 16:36:40 +08:00
parent a83920aa2a
commit 7472a61db0
8 changed files with 537 additions and 139 deletions

View File

@@ -1,6 +1,6 @@
"use client";
import { ChevronRight, CirclePlus, Ticket, Trash2, Star } from "lucide-react";
import { CirclePlus, Ticket, Trash2, Star } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -138,13 +138,6 @@ function parseRebateRate(rate: string | undefined): number {
return n > 1 ? n / 100 : n;
}
function amountToDisplay(minor: number): string {
return (minor / 100).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
function normalizeNumberForPlay(number: string, playCode: string): string {
if (playCode.startsWith("pos_2")) return number.slice(-2);
if (playCode.startsWith("pos_3")) return number.slice(-3);
@@ -328,19 +321,6 @@ function cellRiskState(
return "open";
}
function tableCellClass(status: CellRiskState, disabled: boolean): string {
if (disabled) {
return "opacity-50";
}
if (status === "sold_out") {
return "bg-slate-100 text-slate-400 line-through";
}
if (status === "warning") {
return "bg-amber-50 text-amber-700";
}
return "";
}
function quickFillKeys(category: HallCategory): { favorites: string; history: string } {
return {
favorites: `lottery.hall.quickfill.favorites.${category}`,
@@ -353,12 +333,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
const { t } = useTranslation("player");
const [activeCategory, setActiveCategory] = useState<HallCategory>("D2");
const [rows, setRows] = useState<DraftRow[]>(() => [
{ ...newDraftRow(), number: "23", amounts: {} },
{ ...newDraftRow(), number: "75", amounts: {} },
{ ...newDraftRow(), number: "08", amounts: {} },
{ ...newDraftRow(), number: "46", amounts: {} },
]);
const [rows, setRows] = useState<DraftRow[]>(() => [newDraftRow()]);
const [activeRowId, setActiveRowId] = useState<string | null>(null);
const [catalogState, setCatalogState] = useState<
| { kind: "loading" }
@@ -478,14 +453,15 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
: row,
),
);
setActiveRowId(rowId);
};
const addRow = () => {
setRows((current) => {
if (current.length >= MAX_ROWS) return current;
const next = [...current, newDraftRow()];
setActiveRowId(next[next.length - 1].id);
return next;
const row = newDraftRow();
setActiveRowId(row.id);
return [...current, row];
});
};
@@ -887,61 +863,57 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
</div>
</div>
{categoryPlays.length > 0 ? (
<p className="flex items-start justify-center gap-2 rounded-lg border border-[#c8daf6] bg-[#f0f6ff] px-3 py-2.5 text-left text-[11px] font-medium leading-snug text-[#0b3f96]">
<ChevronRight className="mt-0.5 size-4 shrink-0 text-[#1d57b7]" aria-hidden />
<span>{t("hall.table.scrollHint")}</span>
</p>
) : (
{categoryPlays.length === 0 ? (
<div
className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2.5 text-center text-xs text-amber-950"
role="status"
>
{t("hall.table.noPlaysInCategory")}
</div>
)}
) : null}
<div
className={cn(
"overflow-hidden rounded-xl border border-[#e6edf8] bg-white shadow-[0_8px_28px_rgba(15,23,42,0.05)] transition-opacity",
"overflow-hidden rounded-xl border border-[#e6edf8] bg-white shadow-[0_8px_24px_rgba(15,23,42,0.05)] transition-opacity",
tableDisabled && "opacity-55",
)}
>
<div className="overflow-x-auto">
<table
className={cn(
"w-full border-collapse text-sm",
activeCategory === "D4" ? "min-w-[1240px]" : "min-w-[740px]",
"w-full border-collapse text-[11px]",
activeCategory === "D4" ? "min-w-[760px]" : "min-w-[460px]",
)}
>
<thead>
<tr className="border-b border-[#e8eef7] bg-[#f5f8fd] text-[11px] font-semibold text-[#32518d]">
<th className="sticky left-0 z-20 w-12 bg-[#f5f8fd] px-2 py-3 text-center">
<tr className="border-b border-[#edf2f8] bg-[#f8fafd] text-[#58709d]">
<th className="w-8 px-1.5 py-2 text-center font-bold">
{t("hall.table.no", { defaultValue: "No." })}
</th>
<th className="sticky left-12 z-20 w-24 bg-[#f5f8fd] px-2 py-3 text-center">
{t("hall.table.number", { defaultValue: "Number" })}
<span className="block text-[10px] font-normal text-[#6b7896]">
({numberPlaceholder})
</span>
<th className="w-20 px-1.5 py-2 text-center font-bold">
<span className="block">{t("hall.table.number", { defaultValue: "Number" })}</span>
<span className="block text-[9px] font-medium text-[#9aa8bd]">({numberPlaceholder})</span>
</th>
{categoryPlays.map((play) => (
<th key={play.play_code} className="min-w-24 px-2 py-3 text-center">
{pickDisplayName(play)}
<th key={play.play_code} className="min-w-16 px-1 py-2 text-center font-bold">
<span className="block truncate">{pickDisplayName(play)}</span>
<span className="block text-[9px] font-medium text-[#9aa8bd]">
{t("hall.table.amountPlaceholder", { defaultValue: "金额" })}
</span>
</th>
))}
<th className="w-10 px-2 py-3" aria-label={t("hall.table.delete")} />
<th className="w-7 px-1 py-2" aria-label={t("hall.table.delete")} />
</tr>
</thead>
<tbody>
{rows.map((row, index) => {
const rowKey = row.id;
return (
<tr key={rowKey} className="border-b border-[#eef2f8] last:border-b-0">
<td className="sticky left-0 z-10 bg-white px-2 py-3 text-center font-semibold text-[#17408d]">
<tr key={rowKey} className="border-b border-[#f0f3f8] last:border-b-0">
<td className="px-1.5 py-2 text-center font-black text-[#17408d]">
{index + 1}
</td>
<td className="sticky left-12 z-10 bg-white px-2 py-3">
<td className="px-1.5 py-2">
<Input
value={row.number}
disabled={tableDisabled}
@@ -950,16 +922,11 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
onFocus={() => 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]"
/>
</td>
{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 (
<td
key={`${rowKey}-${play.play_code}`}
className={cn("px-1.5 py-3", tableCellClass(status, disabled))}
className={cn(
"px-1 py-2 align-top",
status === "warning" && "bg-amber-50/70",
status === "sold_out" && "bg-slate-100 text-slate-400",
)}
>
<div className="space-y-1">
<Input
value={amountText}
disabled={disabled}
inputMode="decimal"
placeholder={
status === "sold_out"
? t("hall.table.soldOut", { defaultValue: "售罄" })
: "-"
}
onFocus={() => 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" ? (
<p className="text-center text-[10px] font-semibold text-slate-500">
{t("hall.table.soldOut", { defaultValue: "售罄" })}
</p>
) : status === "warning" ? (
<p className="text-center text-[10px] font-semibold text-amber-600">
{t("hall.table.warning", { defaultValue: "接近售罄" })}
</p>
) : activeCategory !== "D4" && amountMinor !== null ? (
<p className="text-center text-[10px] text-slate-500">
{t("hall.table.actual", { defaultValue: "实扣" })}{" "}
{amountToDisplay(actual)}
</p>
) : null}
</div>
<Input
value={amountText}
disabled={disabled}
inputMode="decimal"
placeholder={
status === "sold_out"
? t("hall.table.soldOut", { defaultValue: "售罄" })
: "-"
}
onFocus={() => 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" ? (
<p className="mt-0.5 text-center text-[9px] font-bold text-slate-500">
{t("hall.table.soldOut", { defaultValue: "售罄" })}
</p>
) : status === "warning" ? (
<p className="mt-0.5 text-center text-[9px] font-bold text-amber-700">
{t("hall.table.warning", { defaultValue: "接近售罄" })}
</p>
) : null}
</td>
);
})}
<td className="px-1 py-3 text-center">
<td className="px-1 py-2 text-center align-middle">
<button
type="button"
disabled={tableDisabled || rows.length <= 1}
onClick={() => removeRow(row.id)}
className="inline-flex size-8 items-center justify-center rounded-full text-[#ff4d4f] hover:bg-red-50 disabled:text-slate-300 disabled:hover:bg-transparent"
className="inline-flex size-6 items-center justify-center rounded-full text-[#e5002c] hover:bg-red-50 disabled:text-slate-300 disabled:hover:bg-transparent"
aria-label={t("actions.deleteRow", { row: index + 1 })}
>
<Trash2 className="size-4" aria-hidden />
<Trash2 className="size-3.5" aria-hidden />
</button>
</td>
</tr>
@@ -1023,42 +993,25 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
</tbody>
</table>
</div>
<button
type="button"
disabled={tableDisabled || rows.length >= MAX_ROWS}
onClick={addRow}
className="flex h-11 w-full items-center justify-center gap-1.5 border-t border-[#edf2f9] text-sm font-semibold text-[#1d57b7] hover:bg-[#f7faff] disabled:text-slate-300"
className="flex h-10 w-full items-center justify-center gap-1.5 border-t border-[#edf2f9] bg-white text-xs font-bold text-[#1d57b7] hover:bg-[#f7faff] disabled:text-slate-300"
>
<CirclePlus className="size-4" aria-hidden />
{t("hall.table.addRow", { defaultValue: "添加一行" })}
</button>
</div>
<div className="grid gap-3 rounded-xl border border-[#e9eef7] bg-[#f8fbff] px-4 py-3 text-sm shadow-[0_6px_20px_rgba(15,23,42,0.04)] md:grid-cols-3">
<div>
<p className="text-xs font-medium text-slate-500">
{t("hall.summary.expectedDeduct", { defaultValue: "预计扣款" })}
</p>
<p className="mt-1 text-lg font-bold tabular-nums text-[#0b3f96]">
{formatMinorAsCurrency(debouncedSummary.actual, currencyCode)}
</p>
</div>
<div>
<p className="text-xs font-medium text-slate-500">
{t("hall.summary.rebate", { defaultValue: "回水" })}
</p>
<p className="mt-1 text-lg font-bold tabular-nums text-emerald-600">
{formatMinorAsCurrency(debouncedSummary.rebate, currencyCode)}
</p>
</div>
<div>
<p className="text-xs font-medium text-slate-500">
{t("hall.summary.total", { defaultValue: "合计" })}
</p>
<p className="mt-1 text-lg font-black tabular-nums text-[#d81435]">
{formatMinorAsCurrency(debouncedSummary.bet, currencyCode)}
</p>
</div>
<div className="flex items-center justify-between rounded-xl border border-[#e9eef7] bg-[#f8fbff] px-4 py-3 text-sm shadow-[0_6px_20px_rgba(15,23,42,0.04)]">
<span className="text-xs font-medium text-slate-500">
{t("hall.table.draftTotal", { defaultValue: "草稿合计" })}
</span>
<span className="text-base font-black tabular-nums text-[#0b3f96]">
{formatMinorAsCurrency(debouncedSummary.actual, currencyCode)}
</span>
</div>
{sealedBetUi ? (