feat(device): scope status SSE to SN management page
This commit is contained in:
1 parent
2bd5b89380
commit
fd3615407c
5 files changed
+82
-104
No files matched your search
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user