From fd3615407c189a04d28e674c3d8af12c5efef9b3 Mon Sep 17 00:00:00 2001 From: TongTongStudio Date: Mon, 9 Nov 2026 21:33:42 +0800 Subject: [PATCH] feat(device): scope status SSE to SN management page --- AGENTS.md | 9 ++- package.json | 3 +- .../devices/sn/components/DeviceSnDetail.vue | 51 ++++---------- .../devices/sn/device-status-stream.ts} | 69 +++++++++---------- src/views/devices/sn/index.vue | 54 ++++++++------- 5 files changed, 82 insertions(+), 104 deletions(-) rename src/{utils/sse.ts => views/devices/sn/device-status-stream.ts} (88%) diff --git a/AGENTS.md b/AGENTS.md index 590335fb..e299e8ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ | 位置 | 定制内容 | | --- | --- | | `src/utils/request.ts` | 本地版请求层:`http` + `uploadHttp` 双实例、Bearer 注入、内置 token 刷新重试、响应直接解包 `data` | -| `src/utils/sse.ts` | 本地 SSE 实现(重连、鉴权、解析),页面直接 `import { useSse } from "@/utils/sse"` | +| `src/views/devices/sn/device-status-stream.ts` | TTSTD 设备 SN 管理页专用 SSE 客户端 | | `.env.development` | `VITE_APP_BASE_API`、`VITE_APP_API_URL`、`VITE_BAIDU_MAP_AK`、`VITE_API_TYPE`,后端指向 `api.ttstd.com` | | `vite.config.ts` | 端口兜底 3000、代理目标兜底 `VITE_APP_API_URL` | | `types/env.d.ts` | 上述环境变量类型 | @@ -147,3 +147,10 @@ git merge gitee/master - 禁止为"减包"删除上游示例页(`src/views/demo/`)或 API 模块。 - 禁止直接改 `node_modules/`、`dist/`。 - 禁止在代码中硬编码后端地址、密钥、AK;统一走环境变量。 + + +## 设备状态 SSE(TTSTD 自有) + +- 后端上游提交 `e77d0b1f` 移除了旧的 `/api/v1/sse/connect`。设备 SN 管理页(`/#/devices/sn`)通过 `GET /api/v1/device/online/stream` 接收 `device-online-status` 事件,事件包含在线状态和 `screenState`。 +- 页面激活时建立一个连接,列表与内嵌详情共用。离开页面、缓存停用或卸载时断开,并停止备用轮询。 +- 客户端放在设备页面目录。不要在 Layout、全局登录流程或其他页面建立此连接。上游通知、字典和后台用户在线状态继续使用 `/api/v1/realtime/ws`。 diff --git a/package.json b/package.json index 307548a6..9c5aaa76 100644 --- a/package.json +++ b/package.json @@ -129,9 +129,10 @@ "vue-eslint-parser": "^10.4.1", "vue-tsc": "3.3.11" }, + "packageManager": "pnpm@10.34.5", "engines": { "node": "^24.0.0", - "pnpm": ">=10.34.5 <13" + "pnpm": "10.34.5" }, "repository": "https://gitee.com/youlaiorg/vue3-element-admin.git", "author": "有来开源组织", diff --git a/src/views/devices/sn/components/DeviceSnDetail.vue b/src/views/devices/sn/components/DeviceSnDetail.vue index 03cbb37c..39258ff5 100644 --- a/src/views/devices/sn/components/DeviceSnDetail.vue +++ b/src/views/devices/sn/components/DeviceSnDetail.vue @@ -804,8 +804,7 @@ import type { DeviceScreenshot, DevicePolicyInfo, } from "@/api/device/types"; -import { nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue"; -import { useSse } from "@/utils/sse"; +import { nextTick, reactive, ref, watch } from "vue"; import { Back, Edit, @@ -829,6 +828,7 @@ import deviceImage from "@/assets/images/iphone-17-pro.webp"; interface Props { modelValue?: boolean; // 控制对话框显示隐藏 serialno?: string; // 传递的设备序列号 + liveStatus?: DeviceOnlineVO; } const props = withDefaults(defineProps(), { @@ -1469,44 +1469,17 @@ const handleStopApp = (row: ApkInstallInfo) => { defineExpose({ loadAll }); -/** - * SSE 设备在线状态事件监听取消函数 - */ -let unsubscribeDeviceOnlineStatus: (() => void) | null = null; - -/** - * 根据 SSE 推送的设备在线状态更新详情页的在线状态 - */ -function handleDeviceOnlineStatus(data: DeviceOnlineVO) { - if (!data || !data.serialno) { - return; - } - // 仅当推送的设备 SN 与当前详情页设备匹配时才更新 - if (data.serialno === props.serialno) { - deviceData.onlineStatus = data.online ?? 0; - deviceData.screenState = data.screenState ?? null; - if (data.lastHeartbeatTime) { - deviceData.lastOnline = data.lastHeartbeatTime; +watch( + () => props.liveStatus, + (status) => { + if (!status?.serialno || status.serialno !== props.serialno) return; + deviceData.onlineStatus = status.online ?? 0; + deviceData.screenState = status.screenState ?? null; + if (status.lastHeartbeatTime) { + deviceData.lastOnline = status.lastHeartbeatTime; } - } -} - -onMounted(() => { - // 监听 SSE 推送的设备在线状态,实时更新详情页 - const sse = useSse(); - unsubscribeDeviceOnlineStatus = sse.on( - "device-online-status", - handleDeviceOnlineStatus, - ); -}); - -onBeforeUnmount(() => { - // 组件销毁时取消 SSE 事件监听 - if (unsubscribeDeviceOnlineStatus) { - unsubscribeDeviceOnlineStatus(); - unsubscribeDeviceOnlineStatus = null; - } -}); + }, +); /** 操作按钮事件 */ const handleReturn = () => { diff --git a/src/utils/sse.ts b/src/views/devices/sn/device-status-stream.ts similarity index 88% rename from src/utils/sse.ts rename to src/views/devices/sn/device-status-stream.ts index 5eaca2c9..0a9ab629 100644 --- a/src/utils/sse.ts +++ b/src/views/devices/sn/device-status-stream.ts @@ -38,14 +38,12 @@ export enum SseConnectionState { CONNECTED = "CONNECTED", } -let globalInstance: ReturnType | null = null; - /** * 创建 SSE 连接,维护心跳与重连 */ -function createSseConnection(options: UseSseOptions = {}) { +export function createDeviceStatusStream(options: UseSseOptions = {}) { const baseUrl = import.meta.env.VITE_API_BASE; - const defaultUrl = `${baseUrl}/api/v1/sse/connect`; + const defaultUrl = `${baseUrl}/api/v1/device/online/stream`; const config = { url: options.url ?? defaultUrl, @@ -156,10 +154,12 @@ function createSseConnection(options: UseSseOptions = {}) { try { while (true) { const { done, value } = await streamReader.read(); + if (reader !== streamReader) return; if (done) { reader = null; connectionState.value = SseConnectionState.DISCONNECTED; log("SSE 连接已关闭"); + scheduleReconnect(); return; } @@ -172,6 +172,7 @@ function createSseConnection(options: UseSseOptions = {}) { } } } catch (err) { + if (reader !== streamReader) return; reader = null; connectionState.value = SseConnectionState.DISCONNECTED; if (err instanceof Error && err.name === "AbortError") { @@ -187,7 +188,7 @@ function createSseConnection(options: UseSseOptions = {}) { * 调度重连:指数退避,达到上限或主动断开时停止 */ const scheduleReconnect = () => { - if (isManualDisconnect) return; + if (isManualDisconnect || reconnectTimer !== null) return; if (config.maxReconnectAttempts > 0 && reconnectAttempts >= config.maxReconnectAttempts) { log(`已达到最大重试次数 ${config.maxReconnectAttempts},停止重连`); return; @@ -197,6 +198,7 @@ function createSseConnection(options: UseSseOptions = {}) { log(`将在 ${currentReconnectInterval}ms 后重试(${reconnectAttempts})`); reconnectTimer = setTimeout(() => { + reconnectTimer = null; advanceReconnectState(); connect(); }, currentReconnectInterval); @@ -226,12 +228,15 @@ function createSseConnection(options: UseSseOptions = {}) { } connectionState.value = SseConnectionState.CONNECTING; - abortController = new AbortController(); + const controller = new AbortController(); + abortController = controller; connectionTimeoutTimer = setTimeout(() => { if (connectionState.value === SseConnectionState.CONNECTING) { log("SSE 连接超时"); - disconnect(); + controller.abort(); + connectionState.value = SseConnectionState.DISCONNECTED; + scheduleReconnect(); } }, config.connectionTimeout); @@ -243,11 +248,17 @@ function createSseConnection(options: UseSseOptions = {}) { Authorization: `Bearer ${token}`, Accept: "text/event-stream", }, - signal: abortController.signal, + signal: controller.signal, }) .then(async (response) => { + if (abortController !== controller || controller.signal.aborted) return; + connectionTimeoutTimer = clearTimer(connectionTimeoutTimer); + if (response.status === 403) { + connectionState.value = SseConnectionState.DISCONNECTED; + return; + } if (!response.ok) { - if (response.status === 401 || response.status === 403) { + if (response.status === 401) { // 令牌过期:刷新后用新令牌重连,刷新失败或令牌仍无效则停止重连 if (!tokenRefreshed) { tokenRefreshed = true; @@ -255,6 +266,7 @@ function createSseConnection(options: UseSseOptions = {}) { const userStore = useUserStoreHook(); try { await userStore.refreshTokenOnce(); + if (abortController !== controller) return; if (AuthStorage.getAccessToken()) { connectionState.value = SseConnectionState.DISCONNECTED; log(`SSE 连接被拒绝(HTTP ${response.status}),令牌已刷新,使用新令牌重连`); @@ -262,6 +274,7 @@ function createSseConnection(options: UseSseOptions = {}) { return null; } } catch (err) { + if (abortController !== controller) return; logError("SSE 令牌刷新失败:", err); // 续期失败即会话失效,走统一出口弹窗提示后跳登录页 await userStore.redirectToLogin(); @@ -274,7 +287,12 @@ function createSseConnection(options: UseSseOptions = {}) { } throw new Error(`HTTP ${response.status}`); } - connectionTimeoutTimer = clearTimer(connectionTimeoutTimer); + if ( + !response.body || + !response.headers.get("Content-Type")?.startsWith("text/event-stream") + ) { + throw new Error("服务器未返回设备状态事件流"); + } connectionState.value = SseConnectionState.CONNECTED; tokenRefreshed = false; resetReconnectState(); @@ -282,11 +300,13 @@ function createSseConnection(options: UseSseOptions = {}) { return response.body?.getReader(); }) .then((streamReader) => { - if (!streamReader) return; + if (!streamReader || abortController !== controller || controller.signal.aborted) return; reader = streamReader; return consumeSseStream(streamReader); }) .catch((err: unknown) => { + if (abortController !== controller || controller.signal.aborted) return; + connectionTimeoutTimer = clearTimer(connectionTimeoutTimer); if (err instanceof Error && err.name === "AbortError") { log("SSE 连接已主动断开"); } else { @@ -326,7 +346,6 @@ function createSseConnection(options: UseSseOptions = {}) { isManualDisconnect = true; connectionTimeoutTimer = clearTimer(connectionTimeoutTimer); reconnectTimer = clearTimer(reconnectTimer); - reader?.cancel(); reader = null; abortController?.abort(); abortController = null; @@ -352,29 +371,3 @@ function createSseConnection(options: UseSseOptions = {}) { on, }; } - -/** - * SSE 连接组合式函数(单例模式) - * - * 基于 fetch + ReadableStream 实现,支持指数退避重连、 - * 事件订阅/取消订阅、主动断开与资源清理 - * - * @param options - 连接配置选项 - * @returns SSE 连接实例,包含连接状态、connect/disconnect/on/cleanup 方法 - */ -export function useSse(options: UseSseOptions = {}) { - if (!globalInstance) { - globalInstance = createSseConnection(options); - } - return globalInstance; -} - -/** - * 清理 SSE 单例:断开连接、清空订阅、释放全局引用 - */ -export function cleanupSse() { - if (globalInstance) { - globalInstance.cleanup(); - globalInstance = null; - } -} diff --git a/src/views/devices/sn/index.vue b/src/views/devices/sn/index.vue index 0f078395..74fc7ae7 100644 --- a/src/views/devices/sn/index.vue +++ b/src/views/devices/sn/index.vue @@ -47,6 +47,7 @@ @@ -65,11 +66,10 @@