feat(device): scope status SSE to SN management page

This commit is contained in:
tongtong committed 2026-11-09 21:42:10 +08:00
1 parent 2bd5b89380
commit fd3615407c
5 files changed
+82 -104

No files matched your search

+8 -1
View File
@@ -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`。
+2 -1
View File
@@ -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": "有来开源组织",
@@ -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<Props>(), {
@@ -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<DeviceOnlineVO>(
"device-online-status",
handleDeviceOnlineStatus,
);
});
onBeforeUnmount(() => {
// 组件销毁时取消 SSE 事件监听
if (unsubscribeDeviceOnlineStatus) {
unsubscribeDeviceOnlineStatus();
unsubscribeDeviceOnlineStatus = null;
}
});
},
);
/** 操作按钮事件 */
const handleReturn = () => {
@@ -38,14 +38,12 @@ export enum SseConnectionState {
CONNECTED = "CONNECTED",
}
let globalInstance: ReturnType<typeof createSseConnection> | 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;
}
}
+29 -25
View File
@@ -47,6 +47,7 @@
<DeviceSnDetail
v-model="detailVisible"
:serialno="selectedRow?.serialno"
:live-status="selectedDeviceStatus"
@return-click="handleReturnClick"
/>
</div>
@@ -65,11 +66,10 @@
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from "vue";
import { onActivated, onBeforeUnmount, onDeactivated, onMounted, ref } from "vue";
import DeviceAPI from "@/api/device";
import type { DeviceOnlineVO } from "@/api/device/types";
import type { CrudRecord } from "@/components/Crud/types";
import { useSse } from "@/utils/sse";
import DeviceSnDetail from "./components/DeviceSnDetail.vue";
import DeviceSnOverView from "./components/DeviceSnOverView.vue";
import addFormConfig from "./config/add";
@@ -78,6 +78,7 @@ import editFormConfig from "./config/edit";
import { onlineStatus, refreshOnlineStatus, updateOnlineStatus } from "./config/online";
import { initOptions } from "./config/options";
import searchConfig from "./config/search";
import { createDeviceStatusStream } from "./device-status-stream";
const pageRef = ref();
const addFormRef = ref();
@@ -193,35 +194,38 @@ async function handleRowAction({ key, row }: { key: string; row: CrudRecord }) {
}
}
/**
* SSE 设备在线状态事件监听取消函数
*/
let unsubscribeDeviceOnlineStatus: (() => void) | null = null;
const selectedDeviceStatus = ref<DeviceOnlineVO>();
const deviceStatusStream = createDeviceStatusStream();
deviceStatusStream.on<DeviceOnlineVO>("device-online-status", (status) => {
updateOnlineStatus(status);
if (status?.serialno === selectedRow.value.serialno) {
selectedDeviceStatus.value = status;
}
});
/** 定时刷新在线状态(与设备 30s 心跳同频),SSE 不可用时仍能保持状态准确 */
let onlineStatusTimer: ReturnType<typeof setInterval> | null = null;
let onlineStatusTimer: ReturnType<typeof setInterval> | undefined;
function startDeviceStatus() {
if (onlineStatusTimer !== undefined) return;
deviceStatusStream.connect();
onlineStatusTimer = setInterval(refreshOnlineStatus, 30000);
}
function stopDeviceStatus() {
deviceStatusStream.disconnect();
clearInterval(onlineStatusTimer);
onlineStatusTimer = undefined;
}
onMounted(() => {
initOptions();
// 监听 SSE 推送的设备在线状态,实时更新表格对应行
const sse = useSse();
unsubscribeDeviceOnlineStatus = sse.on<DeviceOnlineVO>(
"device-online-status",
updateOnlineStatus,
);
onlineStatusTimer = setInterval(refreshOnlineStatus, 30000);
startDeviceStatus();
});
onActivated(startDeviceStatus);
onDeactivated(stopDeviceStatus);
onBeforeUnmount(() => {
// 组件销毁时取消 SSE 事件监听
if (unsubscribeDeviceOnlineStatus) {
unsubscribeDeviceOnlineStatus();
unsubscribeDeviceOnlineStatus = null;
}
if (onlineStatusTimer) {
clearInterval(onlineStatusTimer);
onlineStatusTimer = null;
}
stopDeviceStatus();
deviceStatusStream.cleanup();
});
</script>