feat(huya): 协作台账增加 WebSocket 实时同步
- 后端新增进程内事件总线与 /api/huya/external-orders/ws\n- 所有写接口成功后发布轻量失效事件\n- 前端自动重连、静默刷新、断线兜底轮询
This commit is contained in:
@@ -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())
|
||||
@@ -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 唯一性)。
|
||||
|
||||
|
||||
@@ -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"]}
|
||||
|
||||
@@ -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()
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<HuyaCollabStatus, { label: string; color: string }> = {
|
||||
pending: { label: '待处理', color: 'default' },
|
||||
// 人工核对过「探针读到的绑定角色」与「主页截图」一致
|
||||
@@ -292,6 +301,7 @@ export default function HuyaCollabPage() {
|
||||
const [giftNames, setGiftNames] = useState<string[]>([]);
|
||||
const [shopModalOpen, setShopModalOpen] = useState(false);
|
||||
const [filters, setFilters] = useState<HuyaCollabOrderQuery>({ include_void: false });
|
||||
const [realtimePending, setRealtimePending] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
|
||||
const [detail, setDetail] = useState<HuyaCollabOrder | null>(null);
|
||||
@@ -347,22 +357,24 @@ export default function HuyaCollabPage() {
|
||||
const [ckExport, setCkExport] = useState<HuyaCollabCkExportResult | null>(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)」只是按钮变宽,不会把列表顶下去) */}
|
||||
<Space wrap style={{ width: '100%' }}>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void load()} disabled={loading}>刷新</Button>
|
||||
{realtimeEnabled && (
|
||||
<Tooltip title={realtimeConnected ? '协作台账实时同步已连接' : '实时连接已断开,正在自动重连'}>
|
||||
<Tag color={realtimeConnected ? 'success' : realtimeReconnecting ? 'warning' : 'default'}>
|
||||
{realtimeConnected ? '实时同步' : realtimeReconnecting ? '实时重连中' : '实时离线'}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
{realtimePending && <Tag color="processing">有更新待同步</Tag>}
|
||||
{canEdit && (
|
||||
<Button icon={<SafetyCertificateOutlined />} disabled={selectedIds.length === 0 || busy}
|
||||
onClick={() => void verifySelected()}>
|
||||
|
||||
Reference in New Issue
Block a user