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": "转入",