feat: 优化注单与钱包流水分页加载体验

- 注单列表与钱包流水支持 10 条分页、滚动触底自动加载和手动加载更多
- 新增钱包流水无更多数据提示与分页末页计算工具
- 精简钱包首页快捷入口与页面标题眉标展示
- 将下注表格草稿合计文案调整为投注金额并同步多语言翻译
This commit is contained in:
2026-05-15 16:52:25 +08:00
parent 7472a61db0
commit 01baf9c18b
10 changed files with 187 additions and 65 deletions

View File

@@ -22,7 +22,6 @@ type PlayerPanelProps = {
export function PlayerPanel({
title,
subtitle,
eyebrow,
children,
backHref = "/hall",
backLabel,
@@ -49,11 +48,6 @@ export function PlayerPanel({
{resolvedBackLabel}
</Link>
<div className="min-w-0 flex-1 text-center">
{eyebrow ? (
<p className="truncate text-[10px] font-bold uppercase text-[#f10b32]">
{eyebrow}
</p>
) : null}
<h1 className="truncate text-lg font-black tracking-normal text-[#0b3f96]">
{title}
</h1>

View File

@@ -1007,7 +1007,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
<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: "草稿合计" })}
{t("hall.table.draftTotal", { defaultValue: "投注金额" })}
</span>
<span className="text-base font-black tabular-nums text-[#0b3f96]">
{formatMinorAsCurrency(debouncedSummary.actual, currencyCode)}

View File

@@ -2,7 +2,7 @@
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { getTicketItems } from "@/api/ticket-items";
@@ -15,6 +15,8 @@ import { formatLotteryInstant } from "@/lib/player-datetime";
import { playLabel } from "@/lib/play-labels";
import type { TicketItemListRow } from "@/types/api/ticket-items";
const ORDERS_PAGE_SIZE = 10;
export function TicketOrdersListScreen() {
const searchParams = useSearchParams();
const { t } = useTranslation("player");
@@ -30,6 +32,7 @@ export function TicketOrdersListScreen() {
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
const fetchPage = useCallback(
async (nextPage: number, append: boolean) => {
@@ -39,7 +42,7 @@ export function TicketOrdersListScreen() {
try {
const res = await getTicketItems({
page: nextPage,
per_page: 20,
per_page: ORDERS_PAGE_SIZE,
draw_no: drawNoFilter || undefined,
});
setItems((prev) => (append ? [...prev, ...res.items] : res.items));
@@ -63,10 +66,27 @@ export function TicketOrdersListScreen() {
});
}, [fetchPage]);
const loadMore = () => {
const loadMore = useCallback(() => {
if (page >= lastPage || loadingMore) return;
void fetchPage(page + 1, true);
};
}, [fetchPage, lastPage, loadingMore, page]);
useEffect(() => {
const target = loadMoreRef.current;
if (!target || loading || loadingMore || page >= lastPage) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
loadMore();
}
},
{ rootMargin: "160px" },
);
observer.observe(target);
return () => observer.disconnect();
}, [lastPage, loadMore, loading, loadingMore, page]);
return (
<PlayerPanel title={t("orders.title")} subtitle={t("orders.subtitle")} eyebrow={t("brand.name")}>
@@ -186,17 +206,24 @@ export function TicketOrdersListScreen() {
);
})}
</div>
<div ref={loadMoreRef} className="min-h-1" />
{page < lastPage ? (
<Button
type="button"
size="sm"
className="h-10 w-full rounded-lg bg-[#07459f] text-white hover:bg-[#063b88]"
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={() => loadMore()}
onClick={loadMore}
>
{loadingMore ? t("actions.loading") : t("actions.loadMore")}
{loadingMore
? t("actions.loading", { defaultValue: "加载中..." })
: t("actions.loadMore", { defaultValue: "加载更多" })}
</Button>
) : null}
) : (
<p className="py-2 text-center text-xs text-slate-400">
{t("orders.noMore", { defaultValue: "没有更多注单" })}
</p>
)}
</>
)}
</div>

View File

@@ -1,6 +1,6 @@
"use client";
import { useMemo } from "react";
import { useMemo, type Ref } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
@@ -37,6 +37,10 @@ function txnStatusLabel(
type WalletLogsBlockProps = {
logs: WalletLogsData | null;
logsLoading: boolean;
loadingMore?: boolean;
hasMore?: boolean;
onLoadMore?: () => void;
loadMoreRef?: Ref<HTMLDivElement>;
filter: string;
onFilterChange: (value: string) => void;
currency: string;
@@ -48,6 +52,10 @@ type WalletLogsBlockProps = {
export function WalletLogsBlock({
logs,
logsLoading,
loadingMore = false,
hasMore = false,
onLoadMore,
loadMoreRef,
filter,
onFilterChange,
currency,
@@ -132,6 +140,28 @@ export function WalletLogsBlock({
))
)}
</ul>
{logs.items.length > 0 ? (
<>
<div ref={loadMoreRef} className="min-h-1" />
{hasMore ? (
<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={onLoadMore}
>
{loadingMore
? t("actions.loading", { defaultValue: "加载中..." })
: t("actions.loadMore", { defaultValue: "加载更多" })}
</Button>
) : (
<p className="py-2 text-center text-xs text-slate-400">
{t("wallet.noMoreLogs", { defaultValue: "没有更多流水" })}
</p>
)}
</>
) : null}
</>
) : null}
</section>

View File

@@ -9,7 +9,9 @@ import { PlayerPanel } from "@/components/layout/player-panel";
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
import { formatWalletClientError } from "@/lib/wallet-api-error";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import type { WalletLogsData } from "@/types/api/wallet-logs";
import { getWalletLogsLastPage, type WalletLogsData } from "@/types/api/wallet-logs";
const WALLET_LOGS_PAGE_SIZE = 10;
export function WalletLogsScreen() {
const profile = usePlayerSessionStore((s) => s.profile);
@@ -18,7 +20,9 @@ export function WalletLogsScreen() {
const [filter, setFilter] = useState("");
const [loading, setLoading] = useState(true);
const [logsLoading, setLogsLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
const currency = useMemo(
() => (profile?.default_currency ?? "NPR").toUpperCase(),
@@ -27,35 +31,69 @@ export function WalletLogsScreen() {
const fetchPassRef = useRef(true);
const load = useCallback(async () => {
const load = useCallback(async (targetPage = 1, append = false) => {
setError(null);
if (fetchPassRef.current) {
if (append) {
setLoadingMore(true);
} else if (fetchPassRef.current) {
setLoading(true);
fetchPassRef.current = false;
} else {
setLogsLoading(true);
}
try {
const L = await getWalletLogs({
page: 1,
size: 50,
const nextLogs = await getWalletLogs({
page: targetPage,
size: WALLET_LOGS_PAGE_SIZE,
type: filter || undefined,
});
setLogs(L);
setLogs((current) =>
append && current
? { ...nextLogs, items: [...current.items, ...nextLogs.items] }
: nextLogs,
);
} catch (e) {
setError(formatWalletClientError(e, t));
if (!append) {
setLogs(null);
}
} finally {
setLoading(false);
setLogsLoading(false);
setLoadingMore(false);
}
}, [filter, t]);
useEffect(() => {
queueMicrotask(() => {
void load();
void load(1, false);
});
}, [load]);
const hasMore = logs ? logs.page < getWalletLogsLastPage(logs) : false;
const loadMore = useCallback(() => {
if (!logs || !hasMore || loadingMore) return;
void load(logs.page + 1, true);
}, [hasMore, load, loadingMore, logs]);
useEffect(() => {
const target = loadMoreRef.current;
if (!target || loading || logsLoading || loadingMore || !hasMore) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
loadMore();
}
},
{ rootMargin: "160px" },
);
observer.observe(target);
return () => observer.disconnect();
}, [hasMore, loadMore, loading, loadingMore, logsLoading]);
return (
<PlayerPanel
title={t("wallet.logsTitle")}
@@ -81,6 +119,10 @@ export function WalletLogsScreen() {
<WalletLogsBlock
logs={logs}
logsLoading={loading || logsLoading}
loadingMore={loadingMore}
hasMore={hasMore}
onLoadMore={loadMore}
loadMoreRef={loadMoreRef}
filter={filter}
onFilterChange={setFilter}
currency={currency}

View File

@@ -1,7 +1,6 @@
"use client";
import { Wallet } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -18,7 +17,9 @@ import { formatMinorAsCurrency } from "@/lib/money";
import { formatWalletClientError } from "@/lib/wallet-api-error";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import type { WalletBalanceData } from "@/types/api/wallet-balance";
import type { WalletLogsData } from "@/types/api/wallet-logs";
import { getWalletLogsLastPage, type WalletLogsData } from "@/types/api/wallet-logs";
const WALLET_LOGS_PAGE_SIZE = 10;
export function WalletScreen() {
const profile = usePlayerSessionStore((s) => s.profile);
@@ -28,7 +29,9 @@ export function WalletScreen() {
const [filter, setFilter] = useState("");
const [loading, setLoading] = useState(true);
const [logsLoading, setLogsLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
const currency = useMemo(() => {
return (
@@ -40,6 +43,20 @@ export function WalletScreen() {
const fetchPassRef = useRef(true);
const loadLogs = useCallback(async (targetPage = 1, append = false) => {
const nextLogs = await getWalletLogs({
page: targetPage,
size: WALLET_LOGS_PAGE_SIZE,
type: filter || undefined,
});
setLogs((current) =>
append && current
? { ...nextLogs, items: [...current.items, ...nextLogs.items] }
: nextLogs,
);
return nextLogs;
}, [filter]);
useEffect(() => {
let cancelled = false;
@@ -55,13 +72,9 @@ export function WalletScreen() {
const b = await getWalletBalance();
if (cancelled) return;
setBalance(b);
const L = await getWalletLogs({
page: 1,
size: 50,
type: filter || undefined,
});
const nextLogs = await loadLogs(1, false);
if (cancelled) return;
setLogs(L);
setLogs(nextLogs);
} catch (e) {
if (!cancelled) {
setError(formatWalletClientError(e, t));
@@ -77,7 +90,7 @@ export function WalletScreen() {
return () => {
cancelled = true;
};
}, [filter, t]);
}, [loadLogs, t]);
const refreshAll = useCallback(async () => {
setError(null);
@@ -85,19 +98,47 @@ export function WalletScreen() {
try {
const b = await getWalletBalance();
setBalance(b);
const L = await getWalletLogs({
page: 1,
size: 50,
type: filter || undefined,
});
setLogs(L);
await loadLogs(1, false);
} catch (e) {
setError(formatWalletClientError(e, t));
} finally {
setLogsLoading(false);
setLoading(false);
}
}, [filter, t]);
}, [loadLogs, t]);
const hasMore = logs ? logs.page < getWalletLogsLastPage(logs) : false;
const loadMore = useCallback(() => {
if (!logs || !hasMore || loadingMore) return;
setError(null);
setLoadingMore(true);
void loadLogs(logs.page + 1, true)
.catch((e) => {
setError(formatWalletClientError(e, t));
})
.finally(() => {
setLoadingMore(false);
});
}, [hasMore, loadLogs, loadingMore, logs, t]);
useEffect(() => {
const target = loadMoreRef.current;
if (!target || loading || logsLoading || loadingMore || !hasMore) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
loadMore();
}
},
{ rootMargin: "160px" },
);
observer.observe(target);
return () => observer.disconnect();
}, [hasMore, loadMore, loading, loadingMore, logsLoading]);
return (
<PlayerPanel title={t("wallet.title")} subtitle={t("wallet.subtitle")} eyebrow={t("brand.name")}>
@@ -159,30 +200,13 @@ export function WalletScreen() {
/>
</div>
<div className="grid grid-cols-3 gap-2 text-center text-xs font-bold">
<Link
className="rounded-lg border border-[#e5edf8] bg-[#f8fbff] py-2 text-[#0b56b7]"
href="/wallet/transfer-in"
>
{t("wallet.inPage")}
</Link>
<Link
className="rounded-lg border border-[#e5edf8] bg-[#f8fbff] py-2 text-[#0b56b7]"
href="/wallet/transfer-out"
>
{t("wallet.outPage")}
</Link>
<Link
className="rounded-lg border border-[#e5edf8] bg-[#f8fbff] py-2 text-[#0b56b7]"
href="/wallet/logs"
>
{t("wallet.logs")}
</Link>
</div>
<WalletLogsBlock
logs={logs}
logsLoading={loading || logsLoading}
loadingMore={loadingMore}
hasMore={hasMore}
onLoadMore={loadMore}
loadMoreRef={loadMoreRef}
filter={filter}
onFilterChange={setFilter}
currency={currency}

View File

@@ -120,7 +120,7 @@
"actual": "Actual Deduction",
"delete": "Delete",
"addRow": "Add Row",
"draftTotal": "Draft Total",
"draftTotal": "Bet Amount",
"totalBet": "Total",
"totalRebate": "Rebate",
"actualTotal": "Estimated Deduction",

View File

@@ -120,7 +120,7 @@
"actual": "वास्तविक कट्टा",
"delete": "हटाउनुहोस्",
"addRow": "पंक्ति थप्नुहोस्",
"draftTotal": "ड्राफ्ट जम्मा",
"draftTotal": "बेट रकम",
"totalBet": "जम्मा",
"totalRebate": "रिबेट",
"actualTotal": "अनुमानित कट्टा",

View File

@@ -120,7 +120,7 @@
"actual": "实扣金额",
"delete": "删除",
"addRow": "添加一行",
"draftTotal": "草稿合计",
"draftTotal": "投注金额",
"totalBet": "合计",
"totalRebate": "回水",
"actualTotal": "预计扣款",

View File

@@ -38,6 +38,11 @@ export type WalletLogsData = {
pending_reconcile: WalletPendingTransfer[];
};
export function getWalletLogsLastPage(logs: Pick<WalletLogsData, "total" | "per_page"> | null): number {
if (!logs || logs.per_page <= 0) return 1;
return Math.max(1, Math.ceil(logs.total / logs.per_page));
}
export type GetWalletLogsParams = {
page?: number;
/** 每页条数PRD 示例 `size` */