feat: enhance API error handling and UI improvements
Some checks failed
lotteryfront CI / build (push) Has been cancelled

- Updated `getPublicCurrencies` and `getPlayerAuthCaptcha` functions to include `skipGlobalServerError` option, improving error handling for API requests.
- Refactored the NotFoundPage component to enhance mobile responsiveness and UI consistency, integrating `PlayerMobileViewport`.
- Improved the NetworkStatusBanner and OfflineBanner components by adding player banner height reference for better layout management.
- Updated Wallet components to utilize `PlayerMoneyDisplay` for consistent currency representation and added credit mode handling in various screens.
- Enhanced the WalletScreen and Transfer screens with loading panels and credit guard logic for better user experience during wallet operations.
This commit is contained in:
2026-06-22 10:50:52 +08:00
parent 34b851d7e1
commit ecb032f8cc
43 changed files with 756 additions and 297 deletions

View File

@@ -6,7 +6,8 @@ import { useSWRConfig } from "swr";
import { getWalletBalance } from "@/api/wallet";
import { TransferInPage } from "@/features/wallet/wallet-transfer-forms";
import { Skeleton } from "@/components/ui/skeleton";
import { WalletTransferCreditGuard } from "@/features/wallet/wallet-transfer-credit-guard";
import { WalletTransferLoadingPanel } from "@/features/wallet/wallet-transfer-loading-panel";
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
import { useApiQuery } from "@/hooks/use-api-query";
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
@@ -24,7 +25,6 @@ export function TransferInScreen() {
() => getWalletBalance({ currency }),
);
// 币种切换时 SWR key 自动变化,此处仅处理全局事件触发的刷新
useEffect(() => {
const onRefresh = () => void mutate(BALANCE_KEY(currency));
window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onRefresh);
@@ -37,24 +37,21 @@ export function TransferInScreen() {
}, [mutate, currency, router]);
if (loading && !balance) {
return (
<div className="space-y-3">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-48 w-full rounded-xl" />
</div>
);
return <WalletTransferLoadingPanel titleKey="wallet.transferInTitle" />;
}
return (
<TransferInPage
currency={currency}
lotteryMinor={Number(balance?.balance ?? 0)}
mainMinor={
balance?.main_balance === null || balance?.main_balance === undefined
? null
: Number(balance.main_balance)
}
onSuccess={onSuccess}
/>
<WalletTransferCreditGuard balance={balance} loading={loading}>
<TransferInPage
currency={currency}
lotteryMinor={Number(balance?.balance ?? 0)}
mainMinor={
balance?.main_balance === null || balance?.main_balance === undefined
? null
: Number(balance.main_balance)
}
onSuccess={onSuccess}
/>
</WalletTransferCreditGuard>
);
}

View File

@@ -6,7 +6,8 @@ import { useSWRConfig } from "swr";
import { getWalletBalance } from "@/api/wallet";
import { TransferOutPage } from "@/features/wallet/wallet-transfer-forms";
import { Skeleton } from "@/components/ui/skeleton";
import { WalletTransferCreditGuard } from "@/features/wallet/wallet-transfer-credit-guard";
import { WalletTransferLoadingPanel } from "@/features/wallet/wallet-transfer-loading-panel";
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
import { useApiQuery } from "@/hooks/use-api-query";
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
@@ -24,7 +25,6 @@ export function TransferOutScreen() {
() => getWalletBalance({ currency }),
);
// 币种切换时 SWR key 自动变化,此处仅处理全局事件触发的刷新
useEffect(() => {
const onRefresh = () => void mutate(BALANCE_KEY(currency));
window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onRefresh);
@@ -37,19 +37,16 @@ export function TransferOutScreen() {
}, [mutate, currency, router]);
if (loading && !balance) {
return (
<div className="space-y-3">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-48 w-full rounded-xl" />
</div>
);
return <WalletTransferLoadingPanel titleKey="wallet.transferOutTitle" />;
}
return (
<TransferOutPage
currency={currency}
availableMinor={Number(balance?.available_balance ?? 0)}
onSuccess={onSuccess}
/>
<WalletTransferCreditGuard balance={balance} loading={loading}>
<TransferOutPage
currency={currency}
availableMinor={Number(balance?.available_balance ?? 0)}
onSuccess={onSuccess}
/>
</WalletTransferCreditGuard>
);
}

View File

@@ -78,7 +78,7 @@ type WalletLogsBlockProps = {
creditMode?: boolean;
};
/** 类型筛选 + 列表(待对账见顶栏通知铃铛 */
/** 类型筛选 + 列表(钱包盘待对账见钱包页横幅 */
export function WalletLogsBlock({
logs,
logsLoading,

View File

@@ -142,7 +142,6 @@ export function WalletLogsScreen() {
onFilterChange={setFilter}
currency={currency}
creditMode={creditMode}
title={t("wallet.typeFilter")}
/>
</div>
</PlayerPanel>

View File

@@ -0,0 +1,45 @@
"use client";
import Link from "next/link";
import { AlertTriangle } from "lucide-react";
import { useTranslation } from "react-i18next";
import { usePendingWalletReconcile } from "@/hooks/use-pending-wallet-reconcile";
/** 钱包盘:顶栏铃铛隐藏后,在钱包页展示待对账提醒入口 */
export function WalletPendingReconcileBanner() {
const { t } = useTranslation("player");
const { pending, unreadCount, hasPending } = usePendingWalletReconcile();
if (!hasPending) {
return null;
}
return (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-3 text-sm text-amber-950">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-600" aria-hidden />
<div className="min-w-0 flex-1">
<p className="font-bold text-amber-900">{t("wallet.pendingTitle")}</p>
<p className="mt-1 text-xs leading-relaxed text-amber-950/85">
{t("wallet.pendingDescription")}
</p>
{unreadCount > 0 ? (
<p className="mt-1.5 text-xs font-semibold text-amber-800">
{t("notifications.unreadCount", { count: unreadCount })}
</p>
) : null}
<Link
href="/notifications"
className="mt-2 inline-flex text-xs font-bold text-[#0b56b7] underline-offset-2 hover:underline"
>
{t("wallet.viewPendingReconcile", {
defaultValue: "查看待对账详情({{count}}",
count: pending.length,
})}
</Link>
</div>
</div>
</div>
);
}

View File

@@ -13,6 +13,8 @@ import {
TransferInDialog,
TransferOutDialog,
} from "@/features/wallet/wallet-transfer-dialogs";
import { WalletPendingReconcileBanner } from "@/features/wallet/wallet-pending-reconcile-banner";
import { PlayerMoneyDisplay } from "@/components/player-money-display";
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
import { dispatchWalletLogsRefresh } from "@/hooks/use-pending-wallet-reconcile";
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
@@ -36,8 +38,8 @@ export function WalletScreen() {
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
const fetchPassRef = useRef(true);
const filterInitializedRef = useRef(false);
const prevFilterRef = useRef("");
const loadLogs = useCallback(async (targetPage = 1, append = false) => {
const nextLogs = await getWalletLogs({
@@ -60,21 +62,21 @@ export function WalletScreen() {
void (async () => {
setError(null);
if (fetchPassRef.current) {
setLoading(true);
fetchPassRef.current = false;
} else {
setLogsLoading(true);
}
setLoading(true);
try {
// 并行请求余额和日志,避免瀑布式串行等待
const [b, nextLogs] = await Promise.all([
getWalletBalance({ currency }),
loadLogs(1, false),
getWalletLogs({
page: 1,
size: WALLET_LOGS_PAGE_SIZE,
type: filter || undefined,
currency,
}),
]);
if (cancelled) return;
setBalance(b);
setLogs(nextLogs);
dispatchWalletLogsRefresh(nextLogs.pending_reconcile ?? []);
} catch (e) {
if (!cancelled) {
setError(formatWalletClientError(e, t));
@@ -90,7 +92,50 @@ export function WalletScreen() {
return () => {
cancelled = true;
};
}, [currency, loadLogs, t]);
}, [currency, t]); // eslint-disable-line react-hooks/exhaustive-deps -- 币种切换整页刷新;流水筛选见下方 effect
useEffect(() => {
if (!filterInitializedRef.current) {
filterInitializedRef.current = true;
prevFilterRef.current = filter;
return;
}
if (prevFilterRef.current === filter) {
return;
}
prevFilterRef.current = filter;
let cancelled = false;
setError(null);
setLogsLoading(true);
void (async () => {
try {
const nextLogs = await getWalletLogs({
page: 1,
size: WALLET_LOGS_PAGE_SIZE,
type: filter || undefined,
currency,
});
if (!cancelled) {
setLogs(nextLogs);
dispatchWalletLogsRefresh(nextLogs.pending_reconcile ?? []);
}
} catch (e) {
if (!cancelled) {
setError(formatWalletClientError(e, t));
}
} finally {
if (!cancelled) {
setLogsLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [currency, filter, t]);
const refreshAll = useCallback(async () => {
setError(null);
@@ -181,7 +226,7 @@ export function WalletScreen() {
aria-hidden
/>
<div className="relative flex items-center gap-3">
<div className="flex size-13 shrink-0 items-center justify-center rounded-full bg-white text-[#d81435] shadow-sm">
<div className="flex size-14 shrink-0 items-center justify-center rounded-full bg-white text-[#d81435] shadow-sm">
<Wallet className="size-7" aria-hidden />
</div>
<div className="min-w-0 flex-1">
@@ -193,9 +238,11 @@ export function WalletScreen() {
{loading ? (
<Skeleton className="mt-2 h-8 w-44 rounded-md bg-white/25" />
) : (
<p className="mt-1 text-2xl font-black leading-none tabular-nums tracking-normal">
{formatMinorAsCurrency(displayMinor, currency)}
</p>
<PlayerMoneyDisplay
amountMinor={displayMinor}
currency={currency}
className="mt-1 text-white"
/>
)}
<p className="mt-2 text-xs text-white/75">
{isCreditPlayer
@@ -220,7 +267,9 @@ export function WalletScreen() {
})}
</p>
) : (
<div className="grid grid-cols-2 gap-3">
<>
<WalletPendingReconcileBanner />
<div className="grid grid-cols-2 gap-3">
<TransferInDialog
idPrefix="wallet-"
currency={currency}
@@ -245,6 +294,7 @@ export function WalletScreen() {
triggerClassName="h-14 rounded-2xl text-base font-black"
/>
</div>
</>
)}
<WalletLogsBlock

View File

@@ -0,0 +1,57 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { PlayerPanel } from "@/components/layout/player-panel";
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
import type { WalletBalanceData } from "@/types/api/wallet-balance";
type WalletTransferCreditGuardProps = {
balance: WalletBalanceData | null | undefined;
loading: boolean;
children: ReactNode;
};
/** 信用盘玩家不可主站划转:加载完成后重定向回钱包页 */
export function WalletTransferCreditGuard({
balance,
loading,
children,
}: WalletTransferCreditGuardProps) {
const router = useRouter();
const { t } = useTranslation("player");
useEffect(() => {
if (loading || !balance) {
return;
}
if (isCreditFundingPlayer(balance)) {
router.replace("/wallet");
}
}, [balance, loading, router]);
if (loading || !balance) {
return null;
}
if (isCreditFundingPlayer(balance)) {
return (
<PlayerPanel
title={t("wallet.creditTitle", { defaultValue: "信用" })}
backHref="/wallet"
backLabel={t("wallet.creditTitle", { defaultValue: "信用" })}
>
<p className="rounded-xl border border-[#d6e4ff] bg-[#f5f9ff] px-3 py-3 text-sm text-[#0b3f96]/85">
{t("wallet.creditNoTransferHint", {
defaultValue:
"由代理授信,无需主站转入转出;中奖不即时派彩,盈亏在账期统一结算,额度调整请联系代理。",
})}
</p>
</PlayerPanel>
);
}
return <>{children}</>;
}

View File

@@ -0,0 +1,29 @@
"use client";
import { useTranslation } from "react-i18next";
import { PlayerPanel } from "@/components/layout/player-panel";
import { Skeleton } from "@/components/ui/skeleton";
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
import { usePlayerSessionStore } from "@/stores/player-session-store";
type WalletTransferLoadingPanelProps = {
titleKey: "wallet.transferInTitle" | "wallet.transferOutTitle";
};
export function WalletTransferLoadingPanel({ titleKey }: WalletTransferLoadingPanelProps) {
const { t } = useTranslation("player");
const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile));
const backLabel = creditMode
? t("wallet.creditTitle", { defaultValue: "信用" })
: t("wallet.title");
return (
<PlayerPanel title={t(titleKey)} backHref="/wallet" backLabel={backLabel}>
<div className="space-y-3">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-48 w-full rounded-xl" />
</div>
</PlayerPanel>
);
}