diff --git a/src/components/player-balance-ws-listener.tsx b/src/components/player-balance-ws-listener.tsx
new file mode 100644
index 0000000..3cdf87a
--- /dev/null
+++ b/src/components/player-balance-ws-listener.tsx
@@ -0,0 +1,12 @@
+"use client";
+
+import type { ReactNode } from "react";
+
+import { usePlayerBalanceWs } from "@/hooks/use-player-balance-ws";
+
+/** 全局挂载:登录后订阅 `balance.update`。 */
+export function PlayerBalanceWsListener(): ReactNode {
+ usePlayerBalanceWs();
+
+ return null;
+}
diff --git a/src/components/providers.tsx b/src/components/providers.tsx
index 96e07a1..5678d1f 100644
--- a/src/components/providers.tsx
+++ b/src/components/providers.tsx
@@ -5,6 +5,7 @@ import { useEffect, type ReactNode } from "react";
import { Toaster } from "@/components/ui/sonner";
import { ErrorProvider } from "@/components/error-provider";
import { IframeBridge } from "@/components/iframe-bridge";
+import { PlayerBalanceWsListener } from "@/components/player-balance-ws-listener";
import { TokenRefreshIndicator } from "@/components/token-refresh-indicator";
import "@/i18n";
import { syncPreferredLanguage } from "@/i18n";
@@ -24,6 +25,7 @@ export function Providers({ children }: ProvidersProps): ReactNode {
{/* iframe 通信桥接 - 支持主站嵌入 */}
{children}
+
{/* Token 续签指示器 - 显示在右下角 */}
diff --git a/src/features/hall/hall-betting-grid.tsx b/src/features/hall/hall-betting-grid.tsx
index 89cf9ef..170fff6 100644
--- a/src/features/hall/hall-betting-grid.tsx
+++ b/src/features/hall/hall-betting-grid.tsx
@@ -91,6 +91,14 @@ type RiskSoldOutWsEvent = {
normalized_number?: string;
};
+type RiskWarningWsEvent = {
+ draw_id?: number;
+ draw_no?: string;
+ normalized_number?: string;
+ usage_ratio?: number;
+ usage_percent?: number;
+};
+
type CellRiskState = "open" | "warning" | "sold_out";
type QuickFillState = Record;
@@ -394,6 +402,7 @@ function cellRiskState(
category: Exclude,
alertRows: DrawCurrentRiskPoolAlert[] | undefined,
liveSoldOutNumbers: ReadonlySet,
+ liveWarningNumbers: ReadonlySet,
digitSlot?: number,
): CellRiskState {
const normalizedRow = rowNumber.trim().toUpperCase();
@@ -403,6 +412,10 @@ function cellRiskState(
return "sold_out";
}
+ if (liveWarningNumbers.has(normalizedRow)) {
+ return "warning";
+ }
+
const alerts = alertRows ?? [];
if (alerts.length === 0) return "open";
@@ -444,6 +457,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
const [resultData, setResultData] = useState(null);
const [quickFillState, setQuickFillState] = useState(() => loadQuickFillState());
const [liveSoldOutNumbers, setLiveSoldOutNumbers] = useState>(() => new Set());
+ const [liveWarningNumbers, setLiveWarningNumbers] = useState>(() => new Set());
const [debouncedSummary, setDebouncedSummary] = useState({ bet: 0, rebate: 0, actual: 0 });
const holdFavoriteRef = useRef<{ timer: number | null; number: string | null; longPress: boolean }>({
timer: null,
@@ -553,6 +567,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
useEffect(() => {
setLiveSoldOutNumbers(new Set());
+ setLiveWarningNumbers(new Set());
}, [drawNo]);
const alertRows = display?.risk_pool_alerts ?? [];
@@ -755,17 +770,37 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
next.add(normalized);
return next;
});
+ setLiveWarningNumbers((prev) => {
+ const next = new Set(prev);
+ next.delete(normalized);
+ return next;
+ });
void reloadDraw();
};
+ const onRiskWarning = (evt: RiskWarningWsEvent) => {
+ const normalized = evt.normalized_number?.trim().toUpperCase();
+ if (!normalized) return;
+ if (drawNo !== null && evt.draw_no !== undefined && evt.draw_no !== drawNo) {
+ return;
+ }
+ setLiveWarningNumbers((prev) => {
+ const next = new Set(prev);
+ next.add(normalized);
+ return next;
+ });
+ };
+
channel.listen(".play.toggle", onPlayToggle);
channel.listen(".odds.update", onOddsUpdate);
channel.listen(".risk.sold_out", onRiskSoldOut);
+ channel.listen(".risk.warning", onRiskWarning);
return () => {
channel.stopListening(".play.toggle");
channel.stopListening(".odds.update");
channel.stopListening(".risk.sold_out");
+ channel.stopListening(".risk.warning");
};
}, [clearAmountsForPlay, drawNo, loadCatalog, reloadDraw, t]);
@@ -1378,6 +1413,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
activeCategory as Exclude,
alertRows,
liveSoldOutNumbers,
+ liveWarningNumbers,
column.digitSlot,
);
const disabled = tableDisabled || status === "sold_out" || (play.config !== null && !play.config.is_enabled);
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
index 381b04a..9320217 100644
--- a/src/hooks/index.ts
+++ b/src/hooks/index.ts
@@ -3,3 +3,4 @@ export { useNetworkStatus, useIsOffline } from "./use-network-status";
export { useTokenRefresh } from "./use-token-refresh";
export { useWalletPolling, triggerWalletPollingAfterBet } from "./use-wallet-polling";
export { useWebSocketManager } from "./use-websocket-manager";
+export { usePlayerBalanceWs } from "./use-player-balance-ws";
diff --git a/src/hooks/use-player-balance-ws.ts b/src/hooks/use-player-balance-ws.ts
new file mode 100644
index 0000000..e1496a1
--- /dev/null
+++ b/src/hooks/use-player-balance-ws.ts
@@ -0,0 +1,80 @@
+"use client";
+
+import { useEffect } from "react";
+import { useTranslation } from "react-i18next";
+import { toast } from "sonner";
+
+import { getLotteryEcho } from "@/lib/lottery-echo";
+import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
+import { usePlayerSessionStore } from "@/stores/player-session-store";
+
+type BalanceUpdateWsEvent = {
+ player_id?: number;
+ currency_code?: string;
+ balance_minor?: number;
+ change_minor?: number;
+ change_formatted?: string;
+ reason?: string;
+};
+
+const REASON_I18N_KEY: Record = {
+ transfer_in: "wallet.wsReason.transferIn",
+ transfer_out: "wallet.wsReason.transferOut",
+ bet: "wallet.wsReason.bet",
+ prize: "wallet.wsReason.prize",
+ refund: "wallet.wsReason.refund",
+};
+
+/**
+ * 订阅 `player.{id}` 私有频道的 `balance.update`,刷新余额并 Toast。
+ */
+export function usePlayerBalanceWs(): void {
+ const { t } = useTranslation("player");
+ const playerId = usePlayerSessionStore((state) => state.profile?.id);
+ const bearerToken = usePlayerSessionStore((state) => state.bearerToken);
+ const { activeCurrency } = useActivePlayerCurrency();
+
+ useEffect(() => {
+ if (!playerId || !bearerToken) {
+ return;
+ }
+
+ const echo = getLotteryEcho();
+ if (!echo) {
+ return;
+ }
+
+ const channelName = `player.${playerId}`;
+ const channel = echo.channel(channelName);
+
+ const onBalanceUpdate = (evt: BalanceUpdateWsEvent): void => {
+ const currency = evt.currency_code?.trim().toUpperCase();
+ if (currency && currency !== activeCurrency.toUpperCase()) {
+ return;
+ }
+
+ window.dispatchEvent(new Event("lottery-wallet-refresh"));
+
+ const changeLabel =
+ typeof evt.change_formatted === "string" && evt.change_formatted !== ""
+ ? evt.change_formatted
+ : evt.change_minor != null
+ ? String(evt.change_minor)
+ : "";
+
+ const reasonKey =
+ evt.reason && REASON_I18N_KEY[evt.reason]
+ ? REASON_I18N_KEY[evt.reason]
+ : "wallet.wsReason.unknown";
+ const reasonLabel = t(reasonKey);
+
+ toast.message(t("wallet.wsBalanceUpdated", { change: changeLabel, reason: reasonLabel }));
+ };
+
+ channel.listen(".balance.update", onBalanceUpdate);
+
+ return () => {
+ channel.stopListening(".balance.update");
+ };
+ }, [activeCurrency, bearerToken, playerId, t]);
+}
diff --git a/src/i18n/locales/en/player.json b/src/i18n/locales/en/player.json
index 5deab19..a59b81f 100644
--- a/src/i18n/locales/en/player.json
+++ b/src/i18n/locales/en/player.json
@@ -368,6 +368,15 @@
"totalRecords": "{{total}} records",
"emptyLogs": "No wallet logs",
"balanceAfter": "Balance after",
+ "wsBalanceUpdated": "Balance {{change}} ({{reason}})",
+ "wsReason": {
+ "transferIn": "Transfer in",
+ "transferOut": "Transfer out",
+ "bet": "Bet",
+ "prize": "Prize",
+ "refund": "Refund",
+ "unknown": "Update"
+ },
"flow": {
"all": "All",
"transfer_in": "Transfer in",
diff --git a/src/i18n/locales/ne/player.json b/src/i18n/locales/ne/player.json
index 1d902dc..a5c4ca9 100644
--- a/src/i18n/locales/ne/player.json
+++ b/src/i18n/locales/ne/player.json
@@ -368,6 +368,15 @@
"totalRecords": "{{total}} रेकर्ड",
"emptyLogs": "वालेट लग छैन",
"balanceAfter": "पछि बाँकी ब्यालेन्स",
+ "wsBalanceUpdated": "ब्यालेन्स {{change}} ({{reason}})",
+ "wsReason": {
+ "transferIn": "भित्र स्थानान्तरण",
+ "transferOut": "बाहिर स्थानान्तरण",
+ "bet": "बाजी",
+ "prize": "पुरस्कार",
+ "refund": "फिर्ता",
+ "unknown": "अपडेट"
+ },
"flow": {
"all": "सबै",
"transfer_in": "ट्रान्सफर इन",
diff --git a/src/i18n/locales/zh/player.json b/src/i18n/locales/zh/player.json
index d888bbb..d44adfb 100644
--- a/src/i18n/locales/zh/player.json
+++ b/src/i18n/locales/zh/player.json
@@ -369,6 +369,15 @@
"totalRecords": "共 {{total}} 条记录",
"emptyLogs": "暂无流水",
"balanceAfter": "变更后余额",
+ "wsBalanceUpdated": "余额 {{change}}({{reason}})",
+ "wsReason": {
+ "transferIn": "转入",
+ "transferOut": "转出",
+ "bet": "下注",
+ "prize": "派彩",
+ "refund": "退款",
+ "unknown": "变动"
+ },
"flow": {
"all": "全部",
"transfer_in": "转入",