feat: 优化注单与钱包流水分页加载体验
- 注单列表与钱包流水支持 10 条分页、滚动触底自动加载和手动加载更多 - 新增钱包流水无更多数据提示与分页末页计算工具 - 精简钱包首页快捷入口与页面标题眉标展示 - 将下注表格草稿合计文案调整为投注金额并同步多语言翻译
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
"actual": "वास्तविक कट्टा",
|
||||
"delete": "हटाउनुहोस्",
|
||||
"addRow": "पंक्ति थप्नुहोस्",
|
||||
"draftTotal": "ड्राफ्ट जम्मा",
|
||||
"draftTotal": "बेट रकम",
|
||||
"totalBet": "जम्मा",
|
||||
"totalRebate": "रिबेट",
|
||||
"actualTotal": "अनुमानित कट्टा",
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
"actual": "实扣金额",
|
||||
"delete": "删除",
|
||||
"addRow": "添加一行",
|
||||
"draftTotal": "草稿合计",
|
||||
"draftTotal": "投注金额",
|
||||
"totalBet": "合计",
|
||||
"totalRebate": "回水",
|
||||
"actualTotal": "预计扣款",
|
||||
|
||||
@@ -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`) */
|
||||
|
||||
Reference in New Issue
Block a user