refactor: 引入 SWR 统一钱包余额查询与轮询逻辑
This commit is contained in:
22
package-lock.json
generated
22
package-lock.json
generated
@@ -27,6 +27,7 @@
|
|||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"react-i18next": "^17.0.7",
|
"react-i18next": "^17.0.7",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
"swr": "^2.4.1",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"zustand": "^5.0.13"
|
"zustand": "^5.0.13"
|
||||||
@@ -4382,6 +4383,14 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dequal": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/dequal/-/dequal-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -9723,6 +9732,19 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/swr": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/swr/-/swr-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dequal": "^2.0.3",
|
||||||
|
"use-sync-external-store": "^1.6.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tagged-tag": {
|
"node_modules/tagged-tag": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/tagged-tag/-/tagged-tag-1.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/tagged-tag/-/tagged-tag-1.0.0.tgz",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"react-i18next": "^17.0.7",
|
"react-i18next": "^17.0.7",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
"swr": "^2.4.1",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"zustand": "^5.0.13"
|
"zustand": "^5.0.13"
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
import { Wallet } from "lucide-react";
|
import { Wallet } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useSWRConfig } from "swr";
|
||||||
|
|
||||||
import { getWalletBalance } from "@/api/wallet";
|
import { getWalletBalance } from "@/api/wallet";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
@@ -12,76 +13,51 @@ import {
|
|||||||
TransferOutDialog,
|
TransferOutDialog,
|
||||||
} from "@/features/wallet/wallet-transfer-dialogs";
|
} from "@/features/wallet/wallet-transfer-dialogs";
|
||||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
||||||
|
import { useApiQuery } from "@/hooks/use-api-query";
|
||||||
import { formatMinorAsCurrency } from "@/lib/money";
|
import { formatMinorAsCurrency } from "@/lib/money";
|
||||||
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
|
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useNetworkConnectionStore } from "@/stores/network-connection-store";
|
import { useNetworkConnectionStore } from "@/stores/network-connection-store";
|
||||||
import type { WalletBalanceData } from "@/types/api/wallet-balance";
|
|
||||||
|
const BALANCE_KEY = (currency: string) => ["wallet/balance", currency];
|
||||||
|
|
||||||
export function HallWalletStrip() {
|
export function HallWalletStrip() {
|
||||||
const mode = useNetworkConnectionStore((s) => s.mode);
|
const mode = useNetworkConnectionStore((s) => s.mode);
|
||||||
const { t } = useTranslation("player");
|
const { t } = useTranslation("player");
|
||||||
const { activeCurrency } = useActivePlayerCurrency();
|
const { activeCurrency } = useActivePlayerCurrency();
|
||||||
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
|
const { mutate } = useSWRConfig();
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const degradedWalletPollRef = useRef<number | null>(null);
|
const degradedWalletPollRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const currency = activeCurrency;
|
const currency = activeCurrency;
|
||||||
|
const isDegraded = mode === "polling" || mode === "offline";
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const { data: balance, isLoading: loading } = useApiQuery(
|
||||||
const b = await getWalletBalance({ currency: activeCurrency });
|
BALANCE_KEY(currency),
|
||||||
setBalance(b);
|
() => getWalletBalance({ currency }),
|
||||||
}, [activeCurrency]);
|
{
|
||||||
|
// 降级模式下 60 秒自动刷新,SWR 内置定时器代替手动 setInterval
|
||||||
|
refreshInterval: isDegraded ? 60_000 : undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 全局事件触发刷新(下注后、币种切换等场景)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
const onRefresh = () => void mutate(BALANCE_KEY(currency));
|
||||||
void (async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
await refresh();
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) setLoading(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onRefresh = () => void refresh();
|
|
||||||
window.addEventListener("lottery-wallet-refresh", onRefresh);
|
window.addEventListener("lottery-wallet-refresh", onRefresh);
|
||||||
window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onRefresh);
|
window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onRefresh);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("lottery-wallet-refresh", onRefresh);
|
window.removeEventListener("lottery-wallet-refresh", onRefresh);
|
||||||
window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onRefresh);
|
window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onRefresh);
|
||||||
};
|
};
|
||||||
}, [refresh]);
|
}, [mutate, currency]);
|
||||||
|
|
||||||
|
// 非降级模式时清理遗留计时器(refreshInterval 已由 SWR 管理)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== "polling" && mode !== "offline") {
|
if (!isDegraded && degradedWalletPollRef.current !== null) {
|
||||||
if (degradedWalletPollRef.current !== null) {
|
|
||||||
window.clearInterval(degradedWalletPollRef.current);
|
window.clearInterval(degradedWalletPollRef.current);
|
||||||
degradedWalletPollRef.current = null;
|
degradedWalletPollRef.current = null;
|
||||||
}
|
}
|
||||||
return;
|
}, [isDegraded]);
|
||||||
}
|
|
||||||
|
|
||||||
if (degradedWalletPollRef.current !== null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
degradedWalletPollRef.current = window.setInterval(() => {
|
|
||||||
void refresh();
|
|
||||||
}, 60_000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (degradedWalletPollRef.current !== null) {
|
|
||||||
window.clearInterval(degradedWalletPollRef.current);
|
|
||||||
degradedWalletPollRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [mode, refresh]);
|
|
||||||
|
|
||||||
const availableMinor = Number(balance?.available_balance ?? balance?.balance ?? 0);
|
const availableMinor = Number(balance?.available_balance ?? balance?.balance ?? 0);
|
||||||
const isCreditPlayer =
|
const isCreditPlayer =
|
||||||
@@ -136,7 +112,7 @@ export function HallWalletStrip() {
|
|||||||
currency={currency}
|
currency={currency}
|
||||||
lotteryMinor={availableMinor}
|
lotteryMinor={availableMinor}
|
||||||
mainMinor={mainMinor}
|
mainMinor={mainMinor}
|
||||||
onSuccess={refresh}
|
onSuccess={async () => { await mutate(BALANCE_KEY(currency)); }}
|
||||||
/>
|
/>
|
||||||
<TransferOutDialog
|
<TransferOutDialog
|
||||||
idPrefix="hall-"
|
idPrefix="hall-"
|
||||||
@@ -145,7 +121,7 @@ export function HallWalletStrip() {
|
|||||||
triggerClassName="h-12 rounded-lg text-base font-bold"
|
triggerClassName="h-12 rounded-lg text-base font-bold"
|
||||||
currency={currency}
|
currency={currency}
|
||||||
availableMinor={availableMinor}
|
availableMinor={availableMinor}
|
||||||
onSuccess={refresh}
|
onSuccess={async () => { await mutate(BALANCE_KEY(currency)); }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,51 +1,40 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
|
import { useSWRConfig } from "swr";
|
||||||
|
|
||||||
import { getWalletBalance } from "@/api/wallet";
|
import { getWalletBalance } from "@/api/wallet";
|
||||||
import { TransferInPage } from "@/features/wallet/wallet-transfer-forms";
|
import { TransferInPage } from "@/features/wallet/wallet-transfer-forms";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
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";
|
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
|
||||||
import type { WalletBalanceData } from "@/types/api/wallet-balance";
|
|
||||||
|
const BALANCE_KEY = (currency: string) => ["wallet/balance", currency];
|
||||||
|
|
||||||
/** 独立路由 `/wallet/transfer-in` */
|
/** 独立路由 `/wallet/transfer-in` */
|
||||||
export function TransferInScreen() {
|
export function TransferInScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { activeCurrency: currency } = useActivePlayerCurrency();
|
const { activeCurrency: currency } = useActivePlayerCurrency();
|
||||||
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
|
const { mutate } = useSWRConfig();
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const { data: balance, isLoading: loading } = useApiQuery(
|
||||||
const b = await getWalletBalance({ currency });
|
BALANCE_KEY(currency),
|
||||||
setBalance(b);
|
() => getWalletBalance({ currency }),
|
||||||
}, [currency]);
|
);
|
||||||
|
|
||||||
|
// 币种切换时 SWR key 自动变化,此处仅处理全局事件触发的刷新
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let c = false;
|
const onRefresh = () => void mutate(BALANCE_KEY(currency));
|
||||||
void (async () => {
|
window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onRefresh);
|
||||||
try {
|
return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onRefresh);
|
||||||
await load();
|
}, [mutate, currency]);
|
||||||
} finally {
|
|
||||||
if (!c) setLoading(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
c = true;
|
|
||||||
};
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onCurrencyChange = () => void load();
|
|
||||||
window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
|
|
||||||
return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
const onSuccess = useCallback(async () => {
|
const onSuccess = useCallback(async () => {
|
||||||
await load();
|
await mutate(BALANCE_KEY(currency));
|
||||||
router.push("/wallet");
|
router.push("/wallet");
|
||||||
}, [load, router]);
|
}, [mutate, currency, router]);
|
||||||
|
|
||||||
if (loading && !balance) {
|
if (loading && !balance) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,51 +1,40 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
|
import { useSWRConfig } from "swr";
|
||||||
|
|
||||||
import { getWalletBalance } from "@/api/wallet";
|
import { getWalletBalance } from "@/api/wallet";
|
||||||
import { TransferOutPage } from "@/features/wallet/wallet-transfer-forms";
|
import { TransferOutPage } from "@/features/wallet/wallet-transfer-forms";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
|
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";
|
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
|
||||||
import type { WalletBalanceData } from "@/types/api/wallet-balance";
|
|
||||||
|
const BALANCE_KEY = (currency: string) => ["wallet/balance", currency];
|
||||||
|
|
||||||
/** 独立路由 `/wallet/transfer-out` */
|
/** 独立路由 `/wallet/transfer-out` */
|
||||||
export function TransferOutScreen() {
|
export function TransferOutScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { activeCurrency: currency } = useActivePlayerCurrency();
|
const { activeCurrency: currency } = useActivePlayerCurrency();
|
||||||
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
|
const { mutate } = useSWRConfig();
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const { data: balance, isLoading: loading } = useApiQuery(
|
||||||
const b = await getWalletBalance({ currency });
|
BALANCE_KEY(currency),
|
||||||
setBalance(b);
|
() => getWalletBalance({ currency }),
|
||||||
}, [currency]);
|
);
|
||||||
|
|
||||||
|
// 币种切换时 SWR key 自动变化,此处仅处理全局事件触发的刷新
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let c = false;
|
const onRefresh = () => void mutate(BALANCE_KEY(currency));
|
||||||
void (async () => {
|
window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onRefresh);
|
||||||
try {
|
return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onRefresh);
|
||||||
await load();
|
}, [mutate, currency]);
|
||||||
} finally {
|
|
||||||
if (!c) setLoading(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
c = true;
|
|
||||||
};
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onCurrencyChange = () => void load();
|
|
||||||
window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
|
|
||||||
return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
const onSuccess = useCallback(async () => {
|
const onSuccess = useCallback(async () => {
|
||||||
await load();
|
await mutate(BALANCE_KEY(currency));
|
||||||
router.push("/wallet");
|
router.push("/wallet");
|
||||||
}, [load, router]);
|
}, [mutate, currency, router]);
|
||||||
|
|
||||||
if (loading && !balance) {
|
if (loading && !balance) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -66,11 +66,13 @@ export function WalletScreen() {
|
|||||||
setLogsLoading(true);
|
setLogsLoading(true);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const b = await getWalletBalance({ currency });
|
// 并行请求余额和日志,避免瀑布式串行等待
|
||||||
|
const [b, nextLogs] = await Promise.all([
|
||||||
|
getWalletBalance({ currency }),
|
||||||
|
loadLogs(1, false),
|
||||||
|
]);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setBalance(b);
|
setBalance(b);
|
||||||
const nextLogs = await loadLogs(1, false);
|
|
||||||
if (cancelled) return;
|
|
||||||
setLogs(nextLogs);
|
setLogs(nextLogs);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -93,9 +95,12 @@ export function WalletScreen() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setLogsLoading(true);
|
setLogsLoading(true);
|
||||||
try {
|
try {
|
||||||
const b = await getWalletBalance({ currency });
|
// 并行请求余额和日志
|
||||||
|
const [b] = await Promise.all([
|
||||||
|
getWalletBalance({ currency }),
|
||||||
|
loadLogs(1, false),
|
||||||
|
]);
|
||||||
setBalance(b);
|
setBalance(b);
|
||||||
await loadLogs(1, false);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(formatWalletClientError(e, t));
|
setError(formatWalletClientError(e, t));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ export { useWalletPolling, triggerWalletPollingAfterBet } from "./use-wallet-pol
|
|||||||
export { useWebSocketManager } from "./use-websocket-manager";
|
export { useWebSocketManager } from "./use-websocket-manager";
|
||||||
export { usePlayerBalanceWs } from "./use-player-balance-ws";
|
export { usePlayerBalanceWs } from "./use-player-balance-ws";
|
||||||
export { usePlayEffectiveWs } from "./use-play-effective-ws";
|
export { usePlayEffectiveWs } from "./use-play-effective-ws";
|
||||||
|
export { useApiQuery } from "./use-api-query";
|
||||||
|
|||||||
38
src/hooks/use-api-query.ts
Normal file
38
src/hooks/use-api-query.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import useSWR, { type SWRConfiguration, type SWRResponse } from "swr";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用 API 查询 Hook —— 基于 SWR 的薄包装。
|
||||||
|
*
|
||||||
|
* @param key SWR 缓存键。传 `null` 表示暂不请求(条件请求)。
|
||||||
|
* @param fetcher 无参异步函数,通常由 `api/*.ts` 的函数包装而来。
|
||||||
|
* @param options SWR 配置覆盖。
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* // 简单用法
|
||||||
|
* const { data, error, isLoading } = useApiQuery(
|
||||||
|
* currency ? ["wallet/balance", currency] : null,
|
||||||
|
* () => getWalletBalance({ currency }),
|
||||||
|
* );
|
||||||
|
*
|
||||||
|
* // 带自定义刷新间隔
|
||||||
|
* const { data } = useApiQuery(
|
||||||
|
* "draw/current",
|
||||||
|
* () => getCurrentDraw(),
|
||||||
|
* { refreshInterval: 5_000 },
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useApiQuery<Data = unknown, Error = unknown>(
|
||||||
|
key: string | readonly unknown[] | null,
|
||||||
|
fetcher: () => Promise<Data>,
|
||||||
|
options?: SWRConfiguration<Data, Error>,
|
||||||
|
): SWRResponse<Data, Error> {
|
||||||
|
return useSWR<Data, Error>(key, fetcher, {
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
dedupingInterval: 2_000,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
|
||||||
import { getWalletBalance } from "@/api/wallet";
|
|
||||||
import { getActivePlayerCurrencyFromStore } from "@/lib/player-currency";
|
|
||||||
import { startWalletRefreshBurst } from "@/lib/wallet-refresh-burst";
|
|
||||||
import { useNetworkConnectionStore } from "@/stores/network-connection-store";
|
import { useNetworkConnectionStore } from "@/stores/network-connection-store";
|
||||||
|
import {
|
||||||
const POLLING_INTERVAL_MS = 30_000; // 30秒轮询间隔
|
refreshWalletNow,
|
||||||
const LIMITED_POLLING_DURATION_MS = 2 * 60 * 1000; // 2分钟限时轮询
|
startLimitedWalletPolling,
|
||||||
|
startPersistentWalletPolling,
|
||||||
|
stopWalletPolling,
|
||||||
|
} from "@/lib/wallet-polling-utils";
|
||||||
|
|
||||||
export type UseWalletPollingReturn = {
|
export type UseWalletPollingReturn = {
|
||||||
/** 开始钱包轮询(持续或限时) */
|
/** 开始钱包轮询(持续或限时) */
|
||||||
@@ -29,14 +29,10 @@ export type UseWalletPollingReturn = {
|
|||||||
* 3. 开奖结果后的限时轮询(2分钟)
|
* 3. 开奖结果后的限时轮询(2分钟)
|
||||||
*/
|
*/
|
||||||
export function useWalletPolling(): UseWalletPollingReturn {
|
export function useWalletPolling(): UseWalletPollingReturn {
|
||||||
const store = useNetworkConnectionStore();
|
// 使用 selector 避免整 store 订阅导致无谓重渲染
|
||||||
const {
|
const walletPollingIntervalId = useNetworkConnectionStore(
|
||||||
walletPollingIntervalId,
|
(s) => s.walletPollingIntervalId,
|
||||||
walletPollingExpiryAt,
|
);
|
||||||
setWalletPollingIntervalId,
|
|
||||||
setWalletPollingExpiryAt,
|
|
||||||
clearWalletPolling,
|
|
||||||
} = store;
|
|
||||||
|
|
||||||
const intervalIdRef = useRef<number | null>(walletPollingIntervalId);
|
const intervalIdRef = useRef<number | null>(walletPollingIntervalId);
|
||||||
|
|
||||||
@@ -45,79 +41,28 @@ export function useWalletPolling(): UseWalletPollingReturn {
|
|||||||
intervalIdRef.current = walletPollingIntervalId;
|
intervalIdRef.current = walletPollingIntervalId;
|
||||||
}, [walletPollingIntervalId]);
|
}, [walletPollingIntervalId]);
|
||||||
|
|
||||||
// 刷新钱包余额
|
// 开始钱包轮询(委托给共享工具函数)
|
||||||
const refreshWallet = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
await getWalletBalance({ currency: getActivePlayerCurrencyFromStore() });
|
|
||||||
// 触发全局刷新事件,让所有监听组件更新
|
|
||||||
window.dispatchEvent(new Event("lottery-wallet-refresh"));
|
|
||||||
} catch {
|
|
||||||
// 静默处理错误,避免频繁报错
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 开始钱包轮询
|
|
||||||
const startWalletPolling = useCallback(
|
const startWalletPolling = useCallback(
|
||||||
(options?: { limitedDuration?: boolean }) => {
|
(options?: { limitedDuration?: boolean }) => {
|
||||||
const { limitedDuration = false } = options ?? {};
|
const { limitedDuration = false } = options ?? {};
|
||||||
|
|
||||||
// 先停止现有的轮询
|
|
||||||
if (intervalIdRef.current) {
|
|
||||||
window.clearInterval(intervalIdRef.current);
|
|
||||||
intervalIdRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 立即执行一次刷新
|
|
||||||
void refreshWallet();
|
|
||||||
|
|
||||||
// 设置轮询间隔
|
|
||||||
const intervalId = window.setInterval(() => {
|
|
||||||
// 检查限时轮询是否过期
|
|
||||||
if (limitedDuration && walletPollingExpiryAt) {
|
|
||||||
if (Date.now() > walletPollingExpiryAt) {
|
|
||||||
// 过期,停止轮询
|
|
||||||
clearWalletPolling();
|
|
||||||
intervalIdRef.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void refreshWallet();
|
|
||||||
}, POLLING_INTERVAL_MS);
|
|
||||||
|
|
||||||
intervalIdRef.current = intervalId;
|
|
||||||
setWalletPollingIntervalId(intervalId);
|
|
||||||
|
|
||||||
// 设置限时轮询的过期时间
|
|
||||||
if (limitedDuration) {
|
if (limitedDuration) {
|
||||||
const expiryAt = Date.now() + LIMITED_POLLING_DURATION_MS;
|
startLimitedWalletPolling();
|
||||||
setWalletPollingExpiryAt(expiryAt);
|
} else {
|
||||||
|
startPersistentWalletPolling();
|
||||||
// 设置一个定时器在过期时清理
|
|
||||||
window.setTimeout(() => {
|
|
||||||
if (intervalIdRef.current === intervalId) {
|
|
||||||
clearWalletPolling();
|
|
||||||
intervalIdRef.current = null;
|
|
||||||
}
|
|
||||||
}, LIMITED_POLLING_DURATION_MS + 1000); // 稍微延迟确保interval先执行检查
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[],
|
||||||
walletPollingExpiryAt,
|
|
||||||
refreshWallet,
|
|
||||||
setWalletPollingIntervalId,
|
|
||||||
setWalletPollingExpiryAt,
|
|
||||||
clearWalletPolling,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 停止钱包轮询
|
// 停止钱包轮询
|
||||||
const stopWalletPolling = useCallback(() => {
|
const handleStop = useCallback(() => {
|
||||||
if (intervalIdRef.current) {
|
if (intervalIdRef.current) {
|
||||||
window.clearInterval(intervalIdRef.current);
|
window.clearInterval(intervalIdRef.current);
|
||||||
intervalIdRef.current = null;
|
intervalIdRef.current = null;
|
||||||
}
|
}
|
||||||
clearWalletPolling();
|
stopWalletPolling();
|
||||||
}, [clearWalletPolling]);
|
}, []);
|
||||||
|
|
||||||
// 组件卸载时清理
|
// 组件卸载时清理
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -130,8 +75,8 @@ export function useWalletPolling(): UseWalletPollingReturn {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
startWalletPolling,
|
startWalletPolling,
|
||||||
stopWalletPolling,
|
stopWalletPolling: handleStop,
|
||||||
refreshWallet,
|
refreshWallet: refreshWalletNow,
|
||||||
isPolling: walletPollingIntervalId !== null,
|
isPolling: walletPollingIntervalId !== null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -144,7 +89,7 @@ export function triggerWalletPollingAfterBet(): void {
|
|||||||
const store = useNetworkConnectionStore.getState();
|
const store = useNetworkConnectionStore.getState();
|
||||||
|
|
||||||
if (store.mode === "polling" || store.mode === "offline") {
|
if (store.mode === "polling" || store.mode === "offline") {
|
||||||
startWalletRefreshBurst();
|
startLimitedWalletPolling();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,20 +2,22 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { getWalletBalance } from "@/api/wallet";
|
|
||||||
import { getActivePlayerCurrencyFromStore } from "@/lib/player-currency";
|
|
||||||
import {
|
import {
|
||||||
disconnectLotteryEcho,
|
disconnectLotteryEcho,
|
||||||
getLotteryEcho,
|
getLotteryEcho,
|
||||||
isLotteryReverbConfigured,
|
isLotteryReverbConfigured,
|
||||||
} from "@/lib/lottery-echo";
|
} from "@/lib/lottery-echo";
|
||||||
|
import {
|
||||||
|
refreshWalletNow,
|
||||||
|
startLimitedWalletPolling,
|
||||||
|
startPersistentWalletPolling,
|
||||||
|
stopWalletPolling as stopWalletPollingUtil,
|
||||||
|
} from "@/lib/wallet-polling-utils";
|
||||||
import {
|
import {
|
||||||
useNetworkConnectionStore,
|
useNetworkConnectionStore,
|
||||||
type NetworkMode,
|
type NetworkMode,
|
||||||
} from "@/stores/network-connection-store";
|
} from "@/stores/network-connection-store";
|
||||||
|
|
||||||
const POLLING_INTERVAL_MS = 30_000; // 30秒轮询间隔
|
|
||||||
const WALLET_POLLING_DURATION_MS = 2 * 60 * 1000; // 2分钟限时轮询
|
|
||||||
const RECONNECT_INTERVAL_MS = 5_000; // 5秒重连间隔
|
const RECONNECT_INTERVAL_MS = 5_000; // 5秒重连间隔
|
||||||
const MAX_RECONNECT_ATTEMPTS = 10; // 最大重连次数
|
const MAX_RECONNECT_ATTEMPTS = 10; // 最大重连次数
|
||||||
|
|
||||||
@@ -59,81 +61,45 @@ export type UseWebSocketManagerReturn = {
|
|||||||
* 5. 可选钱包余额轮询(`startWalletPolling`,下注等场景)
|
* 5. 可选钱包余额轮询(`startWalletPolling`,下注等场景)
|
||||||
*/
|
*/
|
||||||
export function useWebSocketManager(): UseWebSocketManagerReturn {
|
export function useWebSocketManager(): UseWebSocketManagerReturn {
|
||||||
const store = useNetworkConnectionStore();
|
// 使用 selector 避免整 store 订阅导致无谓重渲染
|
||||||
|
const mode = useNetworkConnectionStore((s) => s.mode);
|
||||||
|
const reconnectAttempts = useNetworkConnectionStore((s) => s.reconnectAttempts);
|
||||||
|
const setWebSocketConnected = useNetworkConnectionStore((s) => s.setWebSocketConnected);
|
||||||
|
const setReconnecting = useNetworkConnectionStore((s) => s.setReconnecting);
|
||||||
|
const incrementReconnectAttempts = useNetworkConnectionStore((s) => s.incrementReconnectAttempts);
|
||||||
|
const resetReconnectAttempts = useNetworkConnectionStore((s) => s.resetReconnectAttempts);
|
||||||
|
const setLastDisconnectedAt = useNetworkConnectionStore((s) => s.setLastDisconnectedAt);
|
||||||
|
const switchToPollingMode = useNetworkConnectionStore((s) => s.switchToPollingMode);
|
||||||
|
const switchToWebSocketMode = useNetworkConnectionStore((s) => s.switchToWebSocketMode);
|
||||||
|
|
||||||
const reconnectTimerRef = useRef<number | null>(null);
|
const reconnectTimerRef = useRef<number | null>(null);
|
||||||
const attemptReconnectRef = useRef<() => void>(() => {});
|
const attemptReconnectRef = useRef<() => void>(() => {});
|
||||||
/** 递增后重新创建 Echo 并绑定连接事件(供「恢复」与重连使用) */
|
/** 递增后重新创建 Echo 并绑定连接事件(供「恢复」与重连使用) */
|
||||||
const [echoSession, setEchoSession] = useState(0);
|
const [echoSession, setEchoSession] = useState(0);
|
||||||
|
|
||||||
const {
|
|
||||||
mode,
|
|
||||||
reconnectAttempts,
|
|
||||||
setWebSocketConnected,
|
|
||||||
setReconnecting,
|
|
||||||
incrementReconnectAttempts,
|
|
||||||
resetReconnectAttempts,
|
|
||||||
setLastDisconnectedAt,
|
|
||||||
switchToPollingMode,
|
|
||||||
switchToWebSocketMode,
|
|
||||||
clearWalletPolling,
|
|
||||||
} = store;
|
|
||||||
|
|
||||||
// 刷新画作数据
|
// 刷新画作数据
|
||||||
const refreshDraw = useCallback(async () => {
|
const refreshDraw = useCallback(async () => {
|
||||||
// 由 useHallDrawLive 监听并拉取,避免与大厅重复请求 draw/current
|
// 由 useHallDrawLive 监听并拉取,避免与大厅重复请求 draw/current
|
||||||
window.dispatchEvent(new Event("lottery-hall-refresh"));
|
window.dispatchEvent(new Event("lottery-hall-refresh"));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 刷新钱包余额
|
// 钱包轮询(委托给共享工具函数,消除与 useWalletPolling 的重复逻辑)
|
||||||
const refreshWallet = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
await getWalletBalance({ currency: getActivePlayerCurrencyFromStore() });
|
|
||||||
// 触发全局刷新事件,让组件更新
|
|
||||||
window.dispatchEvent(new Event("lottery-wallet-refresh"));
|
|
||||||
} catch {
|
|
||||||
// 静默处理错误
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 钱包轮询
|
|
||||||
const startWalletPolling = useCallback(
|
const startWalletPolling = useCallback(
|
||||||
(options?: { limitedDuration?: boolean }) => {
|
(options?: { limitedDuration?: boolean }) => {
|
||||||
const { limitedDuration = false } = options ?? {};
|
const { limitedDuration = false } = options ?? {};
|
||||||
const s0 = useNetworkConnectionStore.getState();
|
|
||||||
|
|
||||||
const prevWalletId = s0.walletPollingIntervalId;
|
|
||||||
if (prevWalletId !== null) {
|
|
||||||
window.clearInterval(prevWalletId);
|
|
||||||
}
|
|
||||||
|
|
||||||
void refreshWallet();
|
|
||||||
|
|
||||||
const intervalId = window.setInterval(() => {
|
|
||||||
const s = useNetworkConnectionStore.getState();
|
|
||||||
if (
|
|
||||||
limitedDuration &&
|
|
||||||
s.walletPollingExpiryAt !== null &&
|
|
||||||
Date.now() > s.walletPollingExpiryAt
|
|
||||||
) {
|
|
||||||
s.clearWalletPolling();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void refreshWallet();
|
|
||||||
}, POLLING_INTERVAL_MS);
|
|
||||||
|
|
||||||
s0.setWalletPollingIntervalId(intervalId);
|
|
||||||
|
|
||||||
if (limitedDuration) {
|
if (limitedDuration) {
|
||||||
s0.setWalletPollingExpiryAt(Date.now() + WALLET_POLLING_DURATION_MS);
|
startLimitedWalletPolling();
|
||||||
|
} else {
|
||||||
|
startPersistentWalletPolling();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[refreshWallet],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 停止钱包轮询
|
// 停止钱包轮询
|
||||||
const stopWalletPolling = useCallback(() => {
|
const stopWalletPolling = useCallback(() => {
|
||||||
clearWalletPolling();
|
stopWalletPollingUtil();
|
||||||
}, [clearWalletPolling]);
|
}, []);
|
||||||
|
|
||||||
// 检测 Pusher 是否已真正 connected(非仅订阅频道)
|
// 检测 Pusher 是否已真正 connected(非仅订阅频道)
|
||||||
const isPusherConnected = useCallback((): boolean => {
|
const isPusherConnected = useCallback((): boolean => {
|
||||||
@@ -235,7 +201,7 @@ export function useWebSocketManager(): UseWebSocketManagerReturn {
|
|||||||
|
|
||||||
const handleOffline = () => {
|
const handleOffline = () => {
|
||||||
// 网络断开
|
// 网络断开
|
||||||
store.setMode("offline");
|
useNetworkConnectionStore.getState().setMode("offline");
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("online", handleOnline);
|
window.addEventListener("online", handleOnline);
|
||||||
@@ -245,7 +211,7 @@ export function useWebSocketManager(): UseWebSocketManagerReturn {
|
|||||||
window.removeEventListener("online", handleOnline);
|
window.removeEventListener("online", handleOnline);
|
||||||
window.removeEventListener("offline", handleOffline);
|
window.removeEventListener("offline", handleOffline);
|
||||||
};
|
};
|
||||||
}, [mode, reconnect, store]);
|
}, [mode, reconnect]);
|
||||||
|
|
||||||
// 初始化:绑定 Pusher 真实连接事件(勿仅凭 channel 对象存在判定已连接)
|
// 初始化:绑定 Pusher 真实连接事件(勿仅凭 channel 对象存在判定已连接)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -349,6 +315,6 @@ export function useWebSocketManager(): UseWebSocketManagerReturn {
|
|||||||
startWalletPolling,
|
startWalletPolling,
|
||||||
stopWalletPolling,
|
stopWalletPolling,
|
||||||
refreshDraw,
|
refreshDraw,
|
||||||
refreshWallet,
|
refreshWallet: refreshWalletNow,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
70
src/lib/wallet-polling-utils.ts
Normal file
70
src/lib/wallet-polling-utils.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { getWalletBalance } from "@/api/wallet";
|
||||||
|
import { getActivePlayerCurrencyFromStore } from "@/lib/player-currency";
|
||||||
|
import { useNetworkConnectionStore } from "@/stores/network-connection-store";
|
||||||
|
|
||||||
|
const POLLING_INTERVAL_MS = 30_000; // 30秒轮询间隔
|
||||||
|
const LIMITED_POLLING_DURATION_MS = 2 * 60 * 1000; // 2分钟限时轮询
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 非 Hook 钱包刷新工具 —— 供 Hook 与非 React 上下文共用。
|
||||||
|
*
|
||||||
|
* 立即拉取一次余额,并触发 `lottery-wallet-refresh` 全局事件。
|
||||||
|
*/
|
||||||
|
export async function refreshWalletNow(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await getWalletBalance({ currency: getActivePlayerCurrencyFromStore() });
|
||||||
|
window.dispatchEvent(new Event("lottery-wallet-refresh"));
|
||||||
|
} catch {
|
||||||
|
// 静默处理错误,避免频繁报错
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动钱包限时轮询(2 分钟内每 30s 刷新一次)。
|
||||||
|
* 先清除已有的钱包轮询计时器,避免泄漏。
|
||||||
|
*/
|
||||||
|
export function startLimitedWalletPolling(): void {
|
||||||
|
const store = useNetworkConnectionStore.getState();
|
||||||
|
store.clearWalletPolling();
|
||||||
|
|
||||||
|
void refreshWalletNow();
|
||||||
|
|
||||||
|
const expiryAt = Date.now() + LIMITED_POLLING_DURATION_MS;
|
||||||
|
store.setWalletPollingExpiryAt(expiryAt);
|
||||||
|
|
||||||
|
const intervalId = window.setInterval(() => {
|
||||||
|
const s = useNetworkConnectionStore.getState();
|
||||||
|
if (
|
||||||
|
s.walletPollingExpiryAt !== null &&
|
||||||
|
Date.now() > s.walletPollingExpiryAt
|
||||||
|
) {
|
||||||
|
s.clearWalletPolling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void refreshWalletNow();
|
||||||
|
}, POLLING_INTERVAL_MS);
|
||||||
|
|
||||||
|
store.setWalletPollingIntervalId(intervalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动钱包持续轮询(直到手动停止)。
|
||||||
|
* 先清除已有的钱包轮询计时器,避免泄漏。
|
||||||
|
*/
|
||||||
|
export function startPersistentWalletPolling(): void {
|
||||||
|
const store = useNetworkConnectionStore.getState();
|
||||||
|
store.clearWalletPolling();
|
||||||
|
|
||||||
|
void refreshWalletNow();
|
||||||
|
|
||||||
|
const intervalId = window.setInterval(() => {
|
||||||
|
void refreshWalletNow();
|
||||||
|
}, POLLING_INTERVAL_MS);
|
||||||
|
|
||||||
|
store.setWalletPollingIntervalId(intervalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 停止钱包轮询 */
|
||||||
|
export function stopWalletPolling(): void {
|
||||||
|
useNetworkConnectionStore.getState().clearWalletPolling();
|
||||||
|
}
|
||||||
@@ -1,27 +1,12 @@
|
|||||||
import { useNetworkConnectionStore } from "@/stores/network-connection-store";
|
/**
|
||||||
|
* @deprecated 请直接使用 `@/lib/wallet-polling-utils` 的 `startLimitedWalletPolling`。
|
||||||
const WALLET_REFRESH_BURST_MS = 30_000;
|
* 此文件保留向后兼容,内部已委托给共享工具函数。
|
||||||
const WALLET_REFRESH_BURST_DURATION_MS = 2 * 60 * 1000;
|
*/
|
||||||
|
import { startLimitedWalletPolling } from "@/lib/wallet-polling-utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 开奖等场景:立即刷新余额,并在限时内每 30s 派发 `lottery-wallet-refresh`(先清旧定时器,避免泄漏)。
|
* 开奖等场景:立即刷新余额,并在限时内每 30s 派发 `lottery-wallet-refresh`(先清旧定时器,避免泄漏)。
|
||||||
*/
|
*/
|
||||||
export function startWalletRefreshBurst(): void {
|
export function startWalletRefreshBurst(): void {
|
||||||
const store = useNetworkConnectionStore.getState();
|
startLimitedWalletPolling();
|
||||||
store.clearWalletPolling();
|
|
||||||
|
|
||||||
window.dispatchEvent(new Event("lottery-wallet-refresh"));
|
|
||||||
|
|
||||||
const expiryAt = Date.now() + WALLET_REFRESH_BURST_DURATION_MS;
|
|
||||||
store.setWalletPollingExpiryAt(expiryAt);
|
|
||||||
|
|
||||||
const intervalId = window.setInterval(() => {
|
|
||||||
if (Date.now() > expiryAt) {
|
|
||||||
useNetworkConnectionStore.getState().clearWalletPolling();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.dispatchEvent(new Event("lottery-wallet-refresh"));
|
|
||||||
}, WALLET_REFRESH_BURST_MS);
|
|
||||||
|
|
||||||
store.setWalletPollingIntervalId(intervalId);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user