From cad06ae789199fbbff1e422fab9e8bd5cdb11668 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 17 Oct 2026 13:44:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(huya):=20=E5=8D=8F=E4=BD=9C=E5=8F=B0?= =?UTF-8?q?=E8=B4=A6=E5=A2=9E=E5=8A=A0=20WebSocket=20=E5=AE=9E=E6=97=B6?= =?UTF-8?q?=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增进程内事件总线与 /api/huya/external-orders/ws\n- 所有写接口成功后发布轻量失效事件\n- 前端自动重连、静默刷新、断线兜底轮询 --- tests/test_huya_collab_events.py | 61 +++++++ tests/test_huya_collab_orders.py | 28 +++ web/backend/routers/huya/collab.py | 67 ++++++- web/backend/services/huya_collab_events.py | 104 +++++++++++ web/frontend/src/hooks/useCollabRealtime.ts | 185 ++++++++++++++++++++ web/frontend/src/pages/HuyaCollabPage.tsx | 86 ++++++++- 6 files changed, 519 insertions(+), 12 deletions(-) create mode 100644 tests/test_huya_collab_events.py create mode 100644 web/backend/services/huya_collab_events.py create mode 100644 web/frontend/src/hooks/useCollabRealtime.ts diff --git a/tests/test_huya_collab_events.py b/tests/test_huya_collab_events.py new file mode 100644 index 0000000..1ee341c --- /dev/null +++ b/tests/test_huya_collab_events.py @@ -0,0 +1,61 @@ +"""协作台账实时事件总线:revision、跨线程投递、队列满时保留最新事件。""" + +from __future__ import annotations + +import asyncio +import threading + +from web.backend.services.huya_collab_events import CollabEventHub + + +def test_publish_increments_revision_without_subscribers(): + hub = CollabEventHub() + + first = hub.publish(scope="orders", action="order.create", actor_id=7) + second = hub.publish(scope="shops", action="shop.create", actor_id=8) + + assert first["revision"] == 1 + assert second["revision"] == 2 + assert hub.ready_event() == {"type": "collab.ready", "revision": 2} + assert hub.ping_event() == {"type": "collab.ping", "revision": 2} + + +def test_publish_delivers_from_worker_thread(): + async def scenario(): + hub = CollabEventHub() + queue = hub.subscribe() + + worker = threading.Thread( + target=lambda: hub.publish( + scope="orders", action="order.update", actor_id=9 + ) + ) + worker.start() + worker.join(timeout=2) + + event = await asyncio.wait_for(queue.get(), timeout=2) + assert event["type"] == "collab.invalidate" + assert event["scope"] == "orders" + assert event["action"] == "order.update" + assert event["actor_id"] == 9 + assert event["revision"] == 1 + + asyncio.run(scenario()) + + +def test_queue_overflow_keeps_latest_event(): + async def scenario(): + hub = CollabEventHub() + queue = hub.subscribe() + for _ in range(101): + hub.publish(scope="orders", action="order.update", actor_id=1) + + await asyncio.sleep(0.05) + assert queue.qsize() <= queue.maxsize + latest = await asyncio.wait_for(queue.get(), timeout=2) + # 队列满时丢掉旧事件,但最后一条一定是 revision 最大的 + while not queue.empty(): + latest = queue.get_nowait() + assert latest["revision"] == 101 + + asyncio.run(scenario()) diff --git a/tests/test_huya_collab_orders.py b/tests/test_huya_collab_orders.py index 48908d4..e886c9c 100644 --- a/tests/test_huya_collab_orders.py +++ b/tests/test_huya_collab_orders.py @@ -28,7 +28,10 @@ from web.backend.models import ( User, ) from web.backend.permissions import ROLE_PERMISSIONS +from web.backend.routers.huya import collab as collab_api +from web.backend.schemas import HuyaExternalOrderUpdate from web.backend.services import huya_collab_service as collab +from web.backend.services.huya_collab_events import collab_events def fake_ck(uid: str, *, web: bool = True) -> str: @@ -1460,6 +1463,31 @@ class TestCollabGiftFilter(TestCollabBase): ) +class TestCollabRealtimeEvents(TestCollabBase): + def test_update_order_publishes_invalidate_event(self, monkeypatch): + order = self._create(order_no="RT-1") + captured: dict = {} + + def fake_publish(**kwargs): + captured.update(kwargs) + return kwargs + + monkeypatch.setattr(collab_events, "publish", fake_publish) + updated = collab_api.update_external_order( + order.id, + HuyaExternalOrderUpdate(remark="实时更新", expected_version=order.version), + self.db, + self.user, + ) + + assert updated["remark"] == "实时更新" + assert captured == { + "scope": "orders", + "action": "order.update", + "actor_id": self.user.id, + } + + class TestCollabBuyerCluster(TestCollabBase): """同一买家的多单:按「绑定账号 + 绑定角色」软识别(只读派生,不动 uid 唯一性)。 diff --git a/web/backend/routers/huya/collab.py b/web/backend/routers/huya/collab.py index d59a5c2..e0b232f 100644 --- a/web/backend/routers/huya/collab.py +++ b/web/backend/routers/huya/collab.py @@ -17,12 +17,22 @@ CK 验证是**只读探针**(``POST .../verify`` 单条、``POST .../ck/verify from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException, Query +import asyncio + +from fastapi import ( + APIRouter, + Depends, + HTTPException, + Query, + WebSocket, + WebSocketDisconnect, +) from sqlalchemy.orm import Session from ...database import get_db -from ...deps import require_permission +from ...deps import authenticate_websocket, require_permission from ...models import AuditLog, HuyaExternalOrder, User +from ...permissions import get_user_permissions from ...schemas import ( HuyaCollabCkPreviewOut, HuyaCollabCkPreviewRequest, @@ -53,6 +63,7 @@ from ...schemas import ( HuyaExternalOrderVerifyRequest, ) from ...services import huya_collab_service as collab +from ...services.huya_collab_events import collab_events router = APIRouter() @@ -75,6 +86,11 @@ def _serialize_one(db: Session, order: HuyaExternalOrder) -> dict: ) +def _publish_change(current: User, *, scope: str, action: str) -> None: + """写操作提交成功后发布失效事件。只带轻量信息,不推整行数据。""" + collab_events.publish(scope=scope, action=action, actor_id=int(current.id)) + + # -------------------------------------------------------------------------- # 字面量路径必须声明在 /{order_id} 之前,否则 "summary" 会被当成 id # -------------------------------------------------------------------------- @@ -154,6 +170,7 @@ def create_collab_shop( ) except collab.CollabError as exc: raise _http_error(exc) from exc + _publish_change(current, scope="shops", action="shop.create") return collab.serialize_shop(shop) @@ -175,6 +192,7 @@ def update_collab_shop( collab.update_shop(db, current, shop, payload, rename_orders=rename_orders) except collab.CollabError as exc: raise _http_error(exc) from exc + _publish_change(current, scope="all", action="shop.update") return collab.serialize_shop(shop) @@ -190,6 +208,7 @@ def delete_collab_shop( snapshot = collab.delete_shop(db, current, shop) except collab.CollabError as exc: raise _http_error(exc) from exc + _publish_change(current, scope="shops", action="shop.delete") return {"success": True, "name": snapshot["name"]} @@ -268,6 +287,7 @@ def create_external_order( ) except collab.CollabError as exc: raise _http_error(exc) from exc + _publish_change(current, scope="orders", action="order.create") return _serialize_one(db, order) @@ -284,7 +304,10 @@ def import_external_orders( .filter(User.is_active.is_(True)) .all() } - return collab.import_orders(db, current, req.text, owner_by_name=owners) + result = collab.import_orders(db, current, req.text, owner_by_name=owners) + if result.get("created") or result.get("updated"): + _publish_change(current, scope="orders", action="order.import") + return result @router.post("/external-orders/ck/preview", response_model=HuyaCollabCkPreviewOut) @@ -340,6 +363,7 @@ def batch_update_external_order_status( except collab.CollabError as exc: raise _http_error(exc) from exc result["target_label"] = collab.ORDER_STATUS_LABELS.get(req.status, req.status) + _publish_change(current, scope="orders", action="orders.status_batch") return result @@ -367,6 +391,7 @@ def batch_query_external_order_activity( ) auto = item.get("auto_status") or "" item["auto_status_label"] = collab.ORDER_STATUS_LABELS.get(auto, auto) + _publish_change(current, scope="orders", action="orders.activity_query") return result @@ -383,9 +408,11 @@ def verify_external_orders_ck( """ try: orders = collab.list_orders_by_ids(db, req.ids) - return collab.verify_ck_batch(db, current, orders) + result = collab.verify_ck_batch(db, current, orders) except collab.CollabError as exc: raise _http_error(exc) from exc + _publish_change(current, scope="orders", action="orders.ck_verify") + return result @router.post("/external-orders/ck/export", response_model=HuyaExternalOrderCkExportOut) @@ -407,6 +434,30 @@ def export_external_orders_ck( raise _http_error(exc) from exc +@router.websocket("/external-orders/ws") +async def ws_external_orders(websocket: WebSocket): + """协作台账实时失效通知:客户端收到后按自己的筛选静默重拉。""" + user = authenticate_websocket(websocket) + if not user or "huya:collab:view" not in get_user_permissions(user): + await websocket.close(code=1008, reason="未授权") + return + + await websocket.accept() + queue = collab_events.subscribe() + try: + await websocket.send_json(collab_events.ready_event()) + while True: + try: + event = await asyncio.wait_for(queue.get(), timeout=25) + await websocket.send_json(event) + except TimeoutError: + await websocket.send_json(collab_events.ping_event()) + except (WebSocketDisconnect, RuntimeError): + pass + finally: + collab_events.unsubscribe(queue) + + # -------------------------------------------------------------------------- # 单行操作 # -------------------------------------------------------------------------- @@ -472,6 +523,7 @@ def update_external_order( ) except collab.CollabError as exc: raise _http_error(exc) from exc + _publish_change(current, scope="orders", action="order.update") return _serialize_one(db, order) @@ -496,6 +548,7 @@ def update_external_order_status( ) except collab.CollabError as exc: raise _http_error(exc) from exc + _publish_change(current, scope="orders", action="order.status") return _serialize_one(db, order) @@ -523,6 +576,7 @@ def query_external_order_activity( ) auto = result.get("auto_status") or "" result["auto_status_label"] = collab.ORDER_STATUS_LABELS.get(auto, auto) + _publish_change(current, scope="orders", action="order.activity_query") return result @@ -561,6 +615,7 @@ def update_external_order_ck( collab.set_ck(db, current, order, req.ck_text) except collab.CollabError as exc: raise _http_error(exc) from exc + _publish_change(current, scope="orders", action="order.ck") return _serialize_one(db, order) @@ -584,6 +639,7 @@ def update_external_order_ck_status( collab.set_manual_ck_status(db, current, order, req.status, message=req.message) except collab.CollabError as exc: raise _http_error(exc) from exc + _publish_change(current, scope="orders", action="order.ck_status") return _serialize_one(db, order) @@ -603,6 +659,7 @@ def verify_external_order_ck( collab.verify_ck(db, current, order) except collab.CollabError as exc: raise _http_error(exc) from exc + _publish_change(current, scope="orders", action="order.ck_verify") return _serialize_one(db, order) @@ -620,6 +677,7 @@ def update_external_order_images( collab.set_images(db, current, order, req.slot, req.images) except collab.CollabError as exc: raise _http_error(exc) from exc + _publish_change(current, scope="orders", action="order.images") return _serialize_one(db, order) @@ -635,4 +693,5 @@ def delete_external_order( snapshot = collab.delete_order(db, current, order) except collab.CollabError as exc: raise _http_error(exc) from exc + _publish_change(current, scope="orders", action="order.delete") return {"success": True, "order_no": snapshot["order_no"]} diff --git a/web/backend/services/huya_collab_events.py b/web/backend/services/huya_collab_events.py new file mode 100644 index 0000000..96fe822 --- /dev/null +++ b/web/backend/services/huya_collab_events.py @@ -0,0 +1,104 @@ +"""虎牙外部单协作台账的进程内实时事件总线。 + +写接口提交成功后只发布「台账失效」事件,前端收到后按自己的筛选条件静默重拉, +不在事件里携带整行数据。当前生产是单 uvicorn worker,进程内队列足够; +如果以后改成多 worker / 多实例,这里再换成 Redis Pub/Sub。 +""" + +from __future__ import annotations + +import asyncio +import itertools +import threading +from datetime import UTC, datetime +from typing import Any + + +class CollabEventHub: + """维护 revision 与当前 WebSocket 订阅队列。""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._revisions = itertools.count(1) + self._current_revision = 0 + self._loop: asyncio.AbstractEventLoop | None = None + self._subscribers: set[asyncio.Queue[dict[str, Any]]] = set() + + @property + def revision(self) -> int: + with self._lock: + return self._current_revision + + def subscribe(self) -> asyncio.Queue[dict[str, Any]]: + """注册一个 WebSocket 订阅者;必须在事件循环线程里调用。""" + loop = asyncio.get_running_loop() + queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=100) + with self._lock: + self._loop = loop + self._subscribers.add(queue) + return queue + + def unsubscribe(self, queue: asyncio.Queue[dict[str, Any]]) -> None: + with self._lock: + self._subscribers.discard(queue) + + def ready_event(self) -> dict[str, Any]: + with self._lock: + revision = self._current_revision + return {"type": "collab.ready", "revision": revision} + + def ping_event(self) -> dict[str, Any]: + with self._lock: + revision = self._current_revision + return {"type": "collab.ping", "revision": revision} + + def publish( + self, + *, + scope: str, + action: str, + actor_id: int | None = None, + orders: list[dict[str, int]] | None = None, + ) -> dict[str, Any]: + """发布一次失效事件;同步接口在线程池里调用也安全。""" + with self._lock: + revision = next(self._revisions) + self._current_revision = revision + loop = self._loop + subscribers = list(self._subscribers) + + event = { + "type": "collab.invalidate", + "revision": revision, + "scope": scope, + "action": action, + "actor_id": actor_id, + "orders": orders or [], + "at": datetime.now(UTC).isoformat(), + } + if loop is None or not subscribers: + return event + + for queue in subscribers: + try: + loop.call_soon_threadsafe(self._offer, queue, event) + except RuntimeError: + # 事件循环已经关闭:忽略,下次连接会重新拉全量。 + continue + return event + + @staticmethod + def _offer(queue: asyncio.Queue[dict[str, Any]], event: dict[str, Any]) -> None: + """队列满时丢掉旧事件,保留最新一次失效通知。""" + while queue.full(): + try: + queue.get_nowait() + except asyncio.QueueEmpty: + break + try: + queue.put_nowait(event) + except asyncio.QueueFull: + pass + + +collab_events = CollabEventHub() diff --git a/web/frontend/src/hooks/useCollabRealtime.ts b/web/frontend/src/hooks/useCollabRealtime.ts new file mode 100644 index 0000000..49e3adb --- /dev/null +++ b/web/frontend/src/hooks/useCollabRealtime.ts @@ -0,0 +1,185 @@ +import { useEffect, useRef, useState } from 'react'; + +export interface CollabRealtimeEvent { + type: 'collab.ready' | 'collab.ping' | 'collab.invalidate'; + revision: number; + scope?: 'orders' | 'shops' | 'all'; + action?: string; + actor_id?: number | null; + at?: string; +} + +interface UseCollabRealtimeOptions { + enabled: boolean; + onInvalidate: (event: CollabRealtimeEvent) => void; +} + +const WS_PATH = '/api/huya/external-orders/ws'; +const HEARTBEAT_TIMEOUT_MS = 60_000; +const MAX_RECONNECT_DELAY_MS = 30_000; + +function websocketUrl(path: string): string { + const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; + return `${protocol}://${window.location.host}${path}`; +} + +/** + * 协作台账实时失效通知。 + * + * 服务端只推「数据变了」,前端收到后按自己的筛选条件静默重拉; + * 断线自动重连,页面恢复可见/浏览器恢复在线时立即重连。 + */ +export function useCollabRealtime({ + enabled, + onInvalidate, +}: UseCollabRealtimeOptions) { + const [connected, setConnected] = useState(false); + const [reconnecting, setReconnecting] = useState(false); + const onInvalidateRef = useRef(onInvalidate); + const lastRevisionRef = useRef(0); + + useEffect(() => { + onInvalidateRef.current = onInvalidate; + }, [onInvalidate]); + + useEffect(() => { + if (!enabled) { + setConnected(false); + setReconnecting(false); + return undefined; + } + + let stopped = false; + let socket: WebSocket | null = null; + let reconnectTimer: number | undefined; + let heartbeatTimer: number | undefined; + let attempts = 0; + + const clearReconnectTimer = () => { + if (reconnectTimer !== undefined) { + window.clearTimeout(reconnectTimer); + reconnectTimer = undefined; + } + }; + + const clearHeartbeatTimer = () => { + if (heartbeatTimer !== undefined) { + window.clearTimeout(heartbeatTimer); + heartbeatTimer = undefined; + } + }; + + const armHeartbeat = () => { + clearHeartbeatTimer(); + heartbeatTimer = window.setTimeout(() => { + // 服务端每 25 秒至少发一次 ping;超时说明连接已假死,主动重连。 + socket?.close(); + }, HEARTBEAT_TIMEOUT_MS); + }; + + const scheduleReconnect = () => { + if (stopped || reconnectTimer !== undefined) return; + setReconnecting(true); + const delay = Math.min( + MAX_RECONNECT_DELAY_MS, + 1_000 * 2 ** Math.min(attempts, 5), + ); + attempts += 1; + reconnectTimer = window.setTimeout(() => { + reconnectTimer = undefined; + connect(); + }, delay + Math.floor(Math.random() * 400)); + }; + + const handleMessage = (event: MessageEvent) => { + armHeartbeat(); + let message: CollabRealtimeEvent; + try { + message = JSON.parse(String(event.data)) as CollabRealtimeEvent; + } catch { + return; + } + if (!message || typeof message.revision !== 'number') return; + const previousRevision = lastRevisionRef.current; + lastRevisionRef.current = Math.max(previousRevision, message.revision); + + if (message.type === 'collab.invalidate') { + onInvalidateRef.current(message); + return; + } + + if (message.type === 'collab.ready' && previousRevision > 0) { + // 重连成功:断线期间可能漏过事件,补一次静默刷新。 + onInvalidateRef.current({ + type: 'collab.invalidate', + revision: message.revision, + scope: 'all', + action: 'reconnect', + }); + } + }; + + const connect = () => { + if (stopped) return; + if ( + socket && + (socket.readyState === WebSocket.CONNECTING || + socket.readyState === WebSocket.OPEN) + ) { + return; + } + + const nextSocket = new WebSocket(websocketUrl(WS_PATH)); + socket = nextSocket; + + nextSocket.onopen = () => { + attempts = 0; + setConnected(true); + setReconnecting(false); + armHeartbeat(); + }; + nextSocket.onmessage = handleMessage; + nextSocket.onerror = () => nextSocket.close(); + nextSocket.onclose = () => { + clearHeartbeatTimer(); + const wasActive = socket === nextSocket; + if (wasActive) socket = null; + setConnected(false); + if (!stopped && wasActive) scheduleReconnect(); + }; + }; + + const reconnectNow = () => { + if (stopped) return; + clearReconnectTimer(); + if (socket && socket.readyState === WebSocket.OPEN) return; + attempts = 0; + socket?.close(); + socket = null; + connect(); + }; + + const handleOnline = () => reconnectNow(); + const handleVisibility = () => { + if (document.visibilityState === 'visible') reconnectNow(); + }; + + connect(); + window.addEventListener('online', handleOnline); + document.addEventListener('visibilitychange', handleVisibility); + + return () => { + stopped = true; + clearReconnectTimer(); + clearHeartbeatTimer(); + window.removeEventListener('online', handleOnline); + document.removeEventListener('visibilitychange', handleVisibility); + socket?.close(); + socket = null; + setConnected(false); + setReconnecting(false); + }; + }, [enabled]); + + return { connected, reconnecting }; +} diff --git a/web/frontend/src/pages/HuyaCollabPage.tsx b/web/frontend/src/pages/HuyaCollabPage.tsx index a279e83..d3740be 100644 --- a/web/frontend/src/pages/HuyaCollabPage.tsx +++ b/web/frontend/src/pages/HuyaCollabPage.tsx @@ -42,9 +42,18 @@ import { message } from '../utils/antdMessage'; import { getErrorMessage } from '../utils/error'; import { copyTextToClipboard } from '../utils/clipboard'; import { usePermissions } from '../hooks/usePermissions'; +import { useCollabRealtime, type CollabRealtimeEvent } from '../hooks/useCollabRealtime'; const { Text, Paragraph } = Typography; +function hasTextInputFocus(): boolean { + const active = document.activeElement; + return ( + active instanceof HTMLElement && + (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable) + ); +} + const STATUS_META: Record = { pending: { label: '待处理', color: 'default' }, // 人工核对过「探针读到的绑定角色」与「主页截图」一致 @@ -292,6 +301,7 @@ export default function HuyaCollabPage() { const [giftNames, setGiftNames] = useState([]); const [shopModalOpen, setShopModalOpen] = useState(false); const [filters, setFilters] = useState({ include_void: false }); + const [realtimePending, setRealtimePending] = useState(false); const [selectedIds, setSelectedIds] = useState([]); const [detail, setDetail] = useState(null); @@ -347,22 +357,24 @@ export default function HuyaCollabPage() { const [ckExport, setCkExport] = useState(null); const [ckExportFormat, setCkExportFormat] = useState<'plain' | 'with_order'>('plain'); - const load = useCallback(async () => { - setLoading(true); + const load = useCallback(async (options?: { silent?: boolean; withMeta?: boolean }) => { + const silent = options?.silent === true; + const withMeta = options?.withMeta !== false; + if (!silent) setLoading(true); try { const [list, nextSummary, nextOwners, nextShops, nextGiftNames] = await Promise.all([ huyaApi.listCollabOrders({ ...filters, page, page_size: pageSize }), huyaApi.collabOrdersSummary(), - huyaApi.collabOrderOwners(), - huyaApi.listCollabShops(), - huyaApi.collabOrderGifts(), + withMeta ? huyaApi.collabOrderOwners() : Promise.resolve(null), + withMeta ? huyaApi.listCollabShops() : Promise.resolve(null), + withMeta ? huyaApi.collabOrderGifts() : Promise.resolve(null), ]); setRows(list.items); setTotal(list.total); setSummary(nextSummary); - setOwners(nextOwners); - setShops(nextShops); - setGiftNames(nextGiftNames); + if (nextOwners) setOwners(nextOwners); + if (nextShops) setShops(nextShops); + if (nextGiftNames) setGiftNames(nextGiftNames); // 选中是「当前页」的概念:刷新后剔除已经不在这页上的行,避免点到别人的脏 id。 setSelectedIds((ids) => ids.filter((id) => list.items.some((row) => row.id === id))); } catch (e: unknown) { @@ -374,6 +386,56 @@ export default function HuyaCollabPage() { useEffect(() => { void load(); }, [load]); + const realtimeEnabled = can('huya:collab:view'); + const collabModalOpen = Boolean( + createOpen || detail || statusAction || batchStatusOpen || ckTarget || ckExportOpen || shopModalOpen, + ); + + const handleRealtimeInvalidate = useCallback((event: CollabRealtimeEvent) => { + if (!realtimeEnabled) return; + if (collabModalOpen || hasTextInputFocus()) { + setRealtimePending(true); + return; + } + void load({ silent: true, withMeta: event.scope !== 'orders' }); + }, [realtimeEnabled, collabModalOpen, load]); + + const { + connected: realtimeConnected, + reconnecting: realtimeReconnecting, + } = useCollabRealtime({ + enabled: realtimeEnabled, + onInvalidate: handleRealtimeInvalidate, + }); + + useEffect(() => { + if (!realtimePending || collabModalOpen) return undefined; + let cancelled = false; + const flush = () => { + if (cancelled || collabModalOpen || hasTextInputFocus()) return; + setRealtimePending(false); + void load({ silent: true, withMeta: true }); + }; + const timer = window.setTimeout(flush, 300); + const handleFocusOut = () => window.setTimeout(flush, 0); + window.addEventListener('focusout', handleFocusOut); + return () => { + cancelled = true; + window.clearTimeout(timer); + window.removeEventListener('focusout', handleFocusOut); + }; + }, [realtimePending, collabModalOpen, load]); + + // WebSocket 断开时用低频轮询兜底;连接正常时完全不轮询。 + useEffect(() => { + if (!realtimeEnabled || realtimeConnected) return undefined; + const timer = window.setInterval(() => { + if (document.visibilityState !== 'visible' || collabModalOpen) return; + void load({ silent: true, withMeta: false }); + }, 30_000); + return () => window.clearInterval(timer); + }, [realtimeEnabled, realtimeConnected, collabModalOpen, load]); + const applyUpdated = useCallback((updated: HuyaCollabOrder) => { setRows((list) => list.map((row) => (row.id === updated.id ? updated : row))); setDetail((current) => (current && current.id === updated.id ? updated : current)); @@ -1325,6 +1387,14 @@ export default function HuyaCollabPage() { {/* 第二行:动作(固定在这一行;勾选后加「(N)」只是按钮变宽,不会把列表顶下去) */} + {realtimeEnabled && ( + + + {realtimeConnected ? '实时同步' : realtimeReconnecting ? '实时重连中' : '实时离线'} + + + )} + {realtimePending && 有更新待同步} {canEdit && (