diff --git a/src/components/DictTag/Select.vue b/src/components/DictTag/Select.vue index 5409ffc..61d4da9 100644 --- a/src/components/DictTag/Select.vue +++ b/src/components/DictTag/Select.vue @@ -1,6 +1,6 @@ diff --git a/src/components/rtsp-plugin-video/lib/videoPlayer.js b/src/components/rtsp-plugin-video/lib/videoPlayer.js index 25b3e53..8495cbb 100644 --- a/src/components/rtsp-plugin-video/lib/videoPlayer.js +++ b/src/components/rtsp-plugin-video/lib/videoPlayer.js @@ -398,138 +398,301 @@ } - function socketOpen(curPortIndex = 0) { - if (typeof WebSocket === 'undefined') { - this.setting.createError && this.setting.createError({ - code: 1005, - data: null, - success: false, - i18nKey: 'browser.not.support.socket', - message: "您的浏览器不支持socket!" - }) - return - } - window.dhPlayerControl.videoWS = new WebSocket(`ws:127.0.0.1:${[window.dhPlayerControl.connectPort[curPortIndex]]}`) - window.dhPlayerControl.videoWS.onopen = function () { - window.dhPlayerControl.manualCloseWS = false - window.dhPlayerControl.windowState = 'wsSuccess' // websocket连接成功 - window.dhPlayerControl.curPortIndex = curPortIndex - heartbeat.call(this) - let _isSupport = isSupport() - for (let key in window.dhPlayerControl.videoList) { - let currentThis = window.dhPlayerControl.videoList[key] - if (currentThis.disposed) continue - currentThis.send({ - method: 'common.version', - info: {}, - }) - if (_isSupport.success) { - if (currentThis.setting.usePluginLogin) { - currentThis.loginServer() - window.dhPlayerControl.loginFlag = "LOGIN_PENDING" - let p = new Promise((resolve, reject) => { - let interval = setInterval(() => { - if (window.dhPlayerControl.loginFlag !== "LOGIN_PENDING") { - clearInterval(interval) - currentThis.loginWaitTimer = null - resolve() - } + const CONNECT_ROUNDS = 3 + const CONNECT_TIMEOUT_MS = 1500 + const CONNECT_PORT_DELAY_MS = 100 + const CONNECT_ROUND_DELAY_MS = 1000 + const LOGIN_TIMEOUT_MS = 30000 + const CONNECTION_ERROR_MESSAGE = '未能连接本机视频服务,请启动或重新启动视频插件后重试' + let connectionGeneration = 0 + let connectionCycle = null + let loginRequestedSocket = null + // WebSocket 握手成功尚不代表原生窗口可用,短暂握手后断开仍共用本轮预算。 + let remainingConnectionAttempts = null - }, 300) - currentThis.loginWaitTimer = interval - }) - p.then(() => { - if (currentThis.disposed) return - currentThis.create() - window.isResetConnect = currentThis.setting.isResetConnect - }) - } else { - currentThis.create() - window.isResetConnect = currentThis.setting.isResetConnect - } + function livePlayers() { + return Object.values(window.dhPlayerControl.videoList).filter(player => !player.disposed) + } - } else { - currentThis.setting.createError(_isSupport) - } - } - } - window.dhPlayerControl.videoWS.onmessage = socketMessage - window.dhPlayerControl.videoWS.onclose = () => { - if (window.dhPlayerControl.windowState === 'wsPending' || window.dhPlayerControl.windowState === 'noPlugin') { - if (curPortIndex < window.dhPlayerControl.connectPort.length - 1) { - window.dhPlayerControl.timer = setTimeout(() => { - window.dhPlayerControl.videoWS = null - socketOpen(curPortIndex + 1) - clearTimeout(window.dhPlayerControl.timer) - }, 300) - return - } - // 未成功过,轮询端口 - window.dhPlayerControl.windowState = 'noPlugin' // websocket连接失败 - for (var key in window.dhPlayerControl.videoList) { - var currentThis = window.dhPlayerControl.videoList[key] - currentThis.setting.show = false - currentThis.setting.createError && currentThis.setting.createError({ - code: 1001, - data: null, - i18nKey: 'video.player.plugin.not.installed', - message: '插件未安装', - success: false - }) - } - } else { - // 表示成功过,不需要再轮训判断端口了 - window.dhPlayerControl.windowState = 'wsError' // websocket连接失败 - if (window.isResetConnect && !window.dhPlayerControl.manualCloseWS) { - console.log("----------无法与插件建立连接-------"); - for (var key in window.dhPlayerControl.videoList) { - var currentThis = window.dhPlayerControl.videoList[key] - currentThis.setting.show = false - currentThis.setting.createError && currentThis.setting.createError({ - code: 1003, - data: null, - i18nKey: 'websocket.reload', - message: '无法与播放器建立连接,正在尝试重连...', - success: false - }) - } - setTimeout(() => { - window.dhPlayerControl.videoWS = null - socketOpen(window.dhPlayerControl.curPortIndex) - }, 1000) - } else { - for (var key in window.dhPlayerControl.videoList) { - var currentThis = window.dhPlayerControl.videoList[key] - currentThis.setting.show = false - currentThis.setting.createError && currentThis.setting.createError({ - code: 1003, - data: null, - i18nKey: 'websocket.disconnect', - message: '无法与播放器建立连接', - success: false - }) - } - } - } + function connectionPlayers() { + return livePlayers().filter(player => !player.connectionStopped) + } + + function isCurrentConnection(socket, generation) { + return !!socket && connectionGeneration === generation && window.dhPlayerControl.videoWS === socket + } + + function clearPlayerConnectionTasks(player) { + clearTimeout(player.setting.socketTimer) + clearTimeout(player.reconnectTimer) + clearTimeout(player.refreshStartTimer) + clearTimeout(player.setting.notifyrealmonitorTimeout) + clearInterval(player.loginWaitTimer) + clearTimeout(player.loginTimeoutTimer) + window.cancelAnimationFrame(player.setting.refreshTimer) + player.loginWaitTimer = null + player.loginTimeoutTimer = null + player.connectionSocket = null + } + + // 原生句柄和回调只属于当前 socket 会话,重连不能等待旧窗口的销毁回包。 + function clearSocketSession() { + const control = window.dhPlayerControl + clearInterval(window.wsHeart) + window.wsHeart = null + control.wsSession = 0 + control.loginFlag = 'LOGIN_PENDING' + loginRequestedSocket = null + control.callBackList = {} + control.hwndList = {} + livePlayers().forEach(player => { + clearPlayerConnectionTasks(player) + document.removeEventListener('click', player.setTopBind) + window.removeEventListener('beforeunload', player.onbeforeunloadBind) + player.setting.hwnd = '' + player.setting.channelList = [] + player.setting.show = false + player.setting.stopRefresh = true + }) + } + + function closeDetachedSocket(socket) { + if (!socket) return + socket.onopen = socket.onmessage = socket.onerror = socket.onclose = null + try { + if (socket.readyState < 2) socket.close() + } catch (error) { + console.log('关闭旧视频连接失败:', error) } } - /** - * 内部方法 - * @desc 插件心跳,保活 - */ - function heartbeat() { - var that = this + function cancelConnectionCycle(resetAttempts = true) { + const control = window.dhPlayerControl + ++connectionGeneration + if (connectionCycle) { + clearTimeout(connectionCycle.retryTimer) + clearTimeout(connectionCycle.connectTimer) + } + connectionCycle = null + if (resetAttempts) remainingConnectionAttempts = null + const socket = control.videoWS + control.videoWS = null + control.wsConnect = false + control.windowState = 'wsIdle' + clearSocketSession() + closeDetachedSocket(socket) + } + + function cancelUnusedConnectionCycle() { + if (connectionCycle && !connectionPlayers().length) cancelConnectionCycle() + } + + function reportConnectionFailure(players, code) { + players.forEach(player => { + if (player.disposed) return + player.setting.createError && player.setting.createError({ + code, data: null, success: false, + i18nKey: 'video.player.connection.failed', + message: CONNECTION_ERROR_MESSAGE + }) + }) + } + + function initializeConnectedPlayer(player, socket, generation) { + const control = window.dhPlayerControl + const isCurrent = () => !player.disposed && !player.connectionStopped && + control.videoList[player.setting.videoId] === player && + isCurrentConnection(socket, generation) && socket.readyState === 1 + if (!isCurrent() || player.connectionSocket === socket) return + player.connectionSocket = socket + player.setting.stopRefresh = false + const support = isSupport() + if (!support.success) { + player.setting.createError && player.setting.createError(support) + return + } + player.send({ method: 'common.version', info: {} }) + if (!player.setting.usePluginLogin) { + player.create() + return + } + // 多个组件共享本机连接;后加入的实例也加入同一登录等待,不会悬空。 + if (loginRequestedSocket !== socket || control.loginFlag === 'LOGIN_ERROR') { + loginRequestedSocket = socket + control.loginFlag = 'LOGIN_PENDING' + player.loginServer() + } + const clearLoginWait = () => { + clearInterval(player.loginWaitTimer) + clearTimeout(player.loginTimeoutTimer) + player.loginWaitTimer = player.loginTimeoutTimer = null + } + const checkLogin = () => { + if (!isCurrent()) { + clearLoginWait() + return + } + if (control.loginFlag === 'LOGIN_PENDING') return + clearLoginWait() + if (control.loginFlag === 'LOGIN_SUCCESS') { + player.create() + } else { + player.setting.createError && player.setting.createError({ + code: 1004, success: false, message: '登录失败,请检查登录信息', + i18nKey: 'dhplayer.login.failed' + }) + } + } + if (control.loginFlag === 'LOGIN_PENDING') { + player.loginWaitTimer = setInterval(checkLogin, 300) + player.loginTimeoutTimer = setTimeout(() => { + if (!isCurrent()) return clearLoginWait() + clearLoginWait() + control.loginFlag = 'LOGIN_ERROR' + player.setting.createError && player.setting.createError({ + code: 1004, success: false, message: '视频插件登录响应超时,请重试', + i18nKey: 'dhplayer.login.timeout' + }) + }, LOGIN_TIMEOUT_MS) + } else { + checkLogin() + } + } + + function heartbeat(socket, generation) { clearInterval(window.wsHeart) - window.wsHeart = setInterval(function () { - that.send(JSON.stringify({ - method: 'common.heartbeat', - info: {}, + window.wsHeart = setInterval(() => { + if (!isCurrentConnection(socket, generation) || socket.readyState !== 1) return + socket.send(JSON.stringify({ + method: 'common.heartbeat', info: {}, id: window.dhPlayerControl.wsConnectCount++ })) }, 10 * 1000) } + function handleSocketDisconnect(socket, generation) { + if (!isCurrentConnection(socket, generation)) return + const control = window.dhPlayerControl + const preferredPort = control.curPortIndex + control.videoWS = null + control.wsConnect = false + control.windowState = 'wsError' + clearSocketSession() + closeDetachedSocket(socket) + if (control.manualCloseWS) return + const stopped = livePlayers().filter(player => player.setting.isResetConnect === false) + stopped.forEach(player => { player.connectionStopped = true }) + reportConnectionFailure(stopped, 1003) + if (connectionPlayers().length) socketOpen(preferredPort, true) + } + + // 一次共享探测最多三轮,连接失败可以在下一次显式初始化时重新尝试。 + function socketOpen(preferredPort = 0, reconnecting = false) { + const control = window.dhPlayerControl + if (connectionCycle || !connectionPlayers().length) return + if (control.videoWS && control.videoWS.readyState === 1) return + if (!reconnecting || remainingConnectionAttempts === null) { + remainingConnectionAttempts = CONNECT_ROUNDS * control.connectPort.length + } + cancelConnectionCycle(false) + const generation = connectionGeneration + control.manualCloseWS = false + control.wsConnect = true + control.windowState = 'wsPending' + const ports = control.connectPort.map((_, index) => index) + if (reconnecting && ports.includes(preferredPort)) { + ports.splice(ports.indexOf(preferredPort), 1) + ports.unshift(preferredPort) + } + const cycle = { generation, round: 0, port: 0, retryTimer: null, connectTimer: null } + connectionCycle = cycle + const isCurrentCycle = () => connectionCycle === cycle && connectionGeneration === generation + const finishFailure = (unsupported = false) => { + if (!isCurrentCycle()) return + const players = connectionPlayers() + cancelConnectionCycle() + control.windowState = 'wsError' + if (unsupported) { + players.forEach(player => { + if (!player.disposed) player.setting.createError && player.setting.createError({ + code: 1005, success: false, i18nKey: 'browser.not.support.socket', + message: '您的浏览器不支持 WebSocket' + }) + }) + } else { + reportConnectionFailure(players, reconnecting ? 1003 : 1001) + } + } + const advance = () => { + if (!isCurrentCycle()) return + if (!connectionPlayers().length) return cancelConnectionCycle() + if (remainingConnectionAttempts <= 0) return finishFailure() + let delay = CONNECT_PORT_DELAY_MS + if (++cycle.port >= ports.length) { + cycle.port = 0 + if (++cycle.round >= CONNECT_ROUNDS) return finishFailure() + delay = CONNECT_ROUND_DELAY_MS + } + cycle.retryTimer = setTimeout(attempt, delay) + } + const attempt = () => { + if (!isCurrentCycle()) return + if (!connectionPlayers().length) return cancelConnectionCycle() + if (remainingConnectionAttempts <= 0) return finishFailure() + --remainingConnectionAttempts + let socket + let settled = false + let opened = false + const fail = () => { + if (settled || !isCurrentCycle() || !isCurrentConnection(socket, generation)) return + settled = true + clearTimeout(cycle.connectTimer) + control.videoWS = null + closeDetachedSocket(socket) + advance() + } + try { + socket = new WebSocket(`ws://127.0.0.1:${control.connectPort[ports[cycle.port]]}`) + } catch (error) { + console.log('建立本机视频连接失败:', error) + advance() + return + } + control.videoWS = socket + cycle.connectTimer = setTimeout(fail, CONNECT_TIMEOUT_MS) + socket.onopen = () => { + if (settled || !isCurrentCycle() || !isCurrentConnection(socket, generation)) return + opened = true + clearTimeout(cycle.connectTimer) + clearTimeout(cycle.retryTimer) + connectionCycle = null + control.windowState = 'wsSuccess' + control.curPortIndex = ports[cycle.port] + heartbeat(socket, generation) + connectionPlayers().forEach(player => initializeConnectedPlayer(player, socket, generation)) + } + socket.onmessage = event => { + if (opened && isCurrentConnection(socket, generation) && socket.readyState === 1) socketMessage(event) + } + const disconnected = () => { + if (!isCurrentConnection(socket, generation) || settled) return + if (!opened) return fail() + settled = true + handleSocketDisconnect(socket, generation) + } + socket.onerror = disconnected + socket.onclose = disconnected + } + connectionPlayers().forEach(player => { + player.setting.connectionStateChange && player.setting.connectionStateChange({ + state: reconnecting ? 'reconnecting' : 'connecting' + }) + }) + if (!isCurrentCycle()) return + if (typeof WebSocket === 'undefined') return finishFailure(true) + attempt() + } + function dataURLtoBlob(dataurl) { var mime = 'image/jpeg', bstr = atob(dataurl), @@ -904,15 +1067,15 @@ delete window.dhPlayerControl.callBackList[data['id']] } } - //主动关闭socket + // 主动关闭会取消整个连接周期,旧事件和延迟任务不能再次拉起连接。 function socketClose() { - window.dhPlayerControl.videoWS && window.dhPlayerControl.videoWS.send(JSON.stringify({ - method: 'window.destroyall' - })) - window.dhPlayerControl.manualCloseWS = true - window.dhPlayerControl.videoWS && window.dhPlayerControl.videoWS.close() - window.dhPlayerControl.videoWS = null - window.wsHeart = clearInterval(window.wsHeart) + const control = window.dhPlayerControl + control.manualCloseWS = true + const socket = control.videoWS + if (socket && socket.readyState === 1) { + socket.send(JSON.stringify({ method: 'window.destroyall' })) + } + cancelConnectionCycle() } /** @@ -1432,6 +1595,9 @@ // 创建视频窗口 create: function () { if (this.disposed) return + const creationSocket = window.dhPlayerControl.videoWS + const creationGeneration = connectionGeneration + if (!creationSocket || creationSocket.readyState !== 1) return var zoom = detectZoom() var rect = getRect.call(this) for (let i in rect) { @@ -1454,7 +1620,7 @@ var that = this this.setLanguage() // 设置语言 this.destroy().then(() => { - if (this.disposed) return + if (this.disposed || !isCurrentConnection(creationSocket, creationGeneration)) return this.send( { method: 'window.create', @@ -1462,6 +1628,7 @@ }, { onSuccess: function (data) { + if (!isCurrentConnection(creationSocket, creationGeneration)) return let dom = document.querySelector(`#${that.setting.videoId}`) // 创建回包可能晚于卸载;此时不能丢弃句柄,也不能覆盖同 ID 的新实例。 if (that.disposed || !dom || window.dhPlayerControl.videoList[that.setting.videoId] !== that) { @@ -1488,9 +1655,9 @@ that.setTopBind = that.setTop.myBind(that) window.addEventListener('beforeunload', that.onbeforeunloadBind) document.addEventListener('click', that.setTopBind) - let timeout = setTimeout(() => { - that.handleAdjust() - clearTimeout(timeout) + clearTimeout(that.refreshStartTimer) + that.refreshStartTimer = setTimeout(() => { + if (isCurrentConnection(creationSocket, creationGeneration)) that.handleAdjust() }, 50) that.setTabControlBtn() that.setWindowDragEnable() @@ -1508,6 +1675,7 @@ // 是否是插件内部鉴权,是的话则先登录 if (that.setting.usePluginLogin) { if (window.dhPlayerControl.loginFlag === "LOGIN_SUCCESS") { + remainingConnectionAttempts = null that.setting.createSuccess && that.setting.createSuccess(getVersionInfo()) } else if (window.dhPlayerControl.loginFlag === "LOGIN_ERROR") { that.setting.createError && that.setting.createError({ @@ -1517,24 +1685,18 @@ }) } } else { + remainingConnectionAttempts = null that.setting.createSuccess && that.setting.createSuccess(getVersionInfo()) } }, onError: function () { - if (that.disposed) return - // 断开连接 - socketClose(); - // 重连 - that.reconnectTimer = setTimeout(() => { - if (!that.disposed) socketOpen(); - clearTimeout(that.reconnectTimer) - }, 3000) - // that.setting.createError && that.setting.createError({ - // code: 1006, - // data: null, - // message: '插件创建失败,刷新重试', - // success: false - // }) + if (that.disposed || !isCurrentConnection(creationSocket, creationGeneration)) return + // 创建窗口失败只影响当前实例,不关闭其他播放器的共享连接。 + that.setting.createError && that.setting.createError({ + code: 1006, success: false, + message: '视频窗口创建失败,请重试', + i18nKey: 'video.player.window.create.failed' + }) } } ) @@ -1592,7 +1754,7 @@ }, //刷新窗口位置 handleAdjust: function () { - if (this.disposed) return + if (this.disposed || this.setting.stopRefresh) return var _this = this // 每帧执行一次,减少DHplayer延时 !this.setting.stopRefresh && this.changePosition() @@ -1678,6 +1840,8 @@ clearTimeout(this.reconnectTimer) clearTimeout(this.setting.notifyrealmonitorTimeout) clearInterval(this.loginWaitTimer) + clearTimeout(this.loginTimeoutTimer) + clearTimeout(this.refreshStartTimer) window.cancelAnimationFrame(this.setting.refreshTimer) document.removeEventListener('click', this.setTopBind) window.removeEventListener('beforeunload', this.onbeforeunloadBind) @@ -1695,6 +1859,7 @@ } this.setting.hwnd = '' this.setting.channelList = [] + cancelUnusedConnectionCycle() } } }, @@ -2838,49 +3003,23 @@ }, initPlayer: function () { if (this.disposed) return + const control = window.dhPlayerControl let hwnd = '' - if (window.dhPlayerControl.videoList[this.setting.videoId]) { - hwnd = window.dhPlayerControl.videoList[this.setting.videoId].setting.hwnd + if (control.videoList[this.setting.videoId]) { + hwnd = control.videoList[this.setting.videoId].setting.hwnd } - window.dhPlayerControl.videoList[this.setting.videoId] = this + control.videoList[this.setting.videoId] = this this.setting.hwnd = hwnd - if (!window.dhPlayerControl.wsConnect) { - window.dhPlayerControl.wsConnect = true - socketOpen.call(this) - } else { - if (window.dhPlayerControl.windowState === 'wsSuccess' && (!this.setting.usePluginLogin || window.dhPlayerControl.loginFlag !== 'LOGIN_PENDING')) { - // 建立连接后, 确保每次初始化是否支持 - let _isSupport = isSupport() - if (!_isSupport.success) { - return this.setting.createError && this.setting.createError(_isSupport) - } - if (this.setting.socketTimer) { - clearTimeout(this.setting.socketTimer) - }; - this.setting.socketTimer = setTimeout(() => { - this.create() - }, this.setting.usePluginLogin ? 1000 : 300) - } else if (window.dhPlayerControl.windowState === "noPlugin") { - this.setting.createError && this.setting.createError({ - code: 1001, - success: false, - i18nKey: 'video.player.plugin.not.installed', - message: "插件未安装" - }) - } else if (window.dhPlayerControl.windowState === "wsError") { - - this.setting.createError && this.setting.createError({ - code: 1003, - success: false, - message: "无法与播放器建立连接" + window.isResetConnect ? ', 正在重连...' : "" - }) - } else { - // setTimeout(() => { - // this.initPlayer() - // }, 300) - } - } + this.connectionStopped = false this.setting.browserType = broswerInfo() + const socket = control.videoWS + if (socket && socket.readyState === 1 && control.windowState === 'wsSuccess') { + initializeConnectedPlayer(this, socket, connectionGeneration) + } else if (!connectionCycle) { + socketOpen() + } else { + this.setting.connectionStateChange && this.setting.connectionStateChange({ state: 'connecting' }) + } } } window.VideoPlayer = window.VideoPlayer || VideoPlayer diff --git a/src/components/rtsp-plugin-video/video-plugin.vue b/src/components/rtsp-plugin-video/video-plugin.vue index 821295a..045501f 100644 --- a/src/components/rtsp-plugin-video/video-plugin.vue +++ b/src/components/rtsp-plugin-video/video-plugin.vue @@ -1,11 +1,15 @@ diff --git a/src/views/alarm/setting/dhIccSetting/dh-icc-setting.vue b/src/views/alarm/setting/dhIccSetting/dh-icc-setting.vue index 6009656..f109c82 100644 --- a/src/views/alarm/setting/dhIccSetting/dh-icc-setting.vue +++ b/src/views/alarm/setting/dhIccSetting/dh-icc-setting.vue @@ -3,11 +3,12 @@ v-loading="state.loading"> - 大华icc配置 - + {{ title }} + + - + @@ -53,6 +54,10 @@ import { useMessage } from "/@/hooks/message" import * as ComDhSettingApi from "/@/api/com/comDhSetting"; import type { DhIccConnectionTestResult } from "/@/api/com/comDhSetting"; +withDefaults(defineProps<{ title?: string }>(), { + title: "大华icc配置", +}); + const dataFormRef = ref(); const state = reactive({ diff --git a/src/views/base/ss/sbList.vue b/src/views/base/ss/sbList.vue index 36393d4..111cead 100644 --- a/src/views/base/ss/sbList.vue +++ b/src/views/base/ss/sbList.vue @@ -1,5 +1,5 @@ diff --git a/src/views/collection/video/platform.vue b/src/views/collection/video/platform.vue index a92cd75..cddc343 100644 --- a/src/views/collection/video/platform.vue +++ b/src/views/collection/video/platform.vue @@ -181,12 +181,19 @@
-

大华 ICC 配置

+

厂商配置

保存配置
- + + +
@@ -235,6 +242,8 @@ const emptyStatus = (): PlatformStatus => ({ }); const settingRef = ref(); +// 厂商类型仅用于页面展示,暂不参与配置保存及平台接入。 +const manufacturerType = ref(""); const resourceDialogRef = ref>(); const state = reactive({ diff --git a/src/views/device/components/camera-list.vue b/src/views/device/components/camera-list.vue index a1f8d96..666ee0f 100644 --- a/src/views/device/components/camera-list.vue +++ b/src/views/device/components/camera-list.vue @@ -54,6 +54,7 @@ >批量删除 导入 + 已选 {{ selectObjs.length }} 项 批量删除 + 已选 {{ channelSelectObjs.length }} 项 @@ -235,6 +237,7 @@ import DeviceRowActions from './device-row-actions.vue'; import type { DeviceRowAction } from './device-row-actions'; import DeviceStatus from './device-status.vue'; import ChannelCapabilities from './channel-capabilities.vue'; +import PlatformCompareButton from '/@/views/collection/video/components/platform-compare-button.vue'; import dayjs from 'dayjs'; type CameraAction = 'detail' | 'preview' | 'replay' | 'edit' | 'delete'; @@ -366,6 +369,12 @@ const isChannelPaneVisible = computed(() => props.showChannelTab && activeTab.va const deviceTabLabel = computed(() => `设备 (${state.pagination?.total ?? 0})`); const channelTabLabel = computed(() => (channelInited.value ? `通道 (${channelState.pagination?.total ?? 0})` : '通道')); +// 设备修复可能同时影响通道;刷新已初始化的两张表,保留通道页的延迟加载。 +const handlePlatformResourceChanged = async () => { + channelDirty.value = !channelInited.value; + await Promise.all([getDataList(), ...(channelInited.value ? [getChannelDataList()] : [])]); +}; + watch(activeTab, (tab) => { if (tab !== 'channel') return; channelInited.value = true; diff --git a/src/views/device/components/device-list.vue b/src/views/device/components/device-list.vue index 95e6c82..d5a5c0a 100644 --- a/src/views/device/components/device-list.vue +++ b/src/views/device/components/device-list.vue @@ -44,6 +44,7 @@ 批量删除 导入 + 已选 {{ selectObjs.length }} 项 - + @@ -134,12 +135,29 @@ import dayjs from 'dayjs'; import DeviceRowActions from './device-row-actions.vue'; import type { DeviceRowAction } from './device-row-actions'; import DeviceStatus from './device-status.vue'; +import PlatformCompareButton from '/@/views/collection/video/components/platform-compare-button.vue'; -const rowActions: DeviceRowAction[] = [ - { key: 'edit', label: '编辑', permission: 'admin_deviceManage_edit' }, - { key: 'delete', label: '删除', permission: 'admin_deviceManage_del', danger: true }, -]; +type DeviceAction = 'detail' | 'edit' | 'delete'; + +const props = withDefaults( + defineProps<{ + actions?: DeviceAction[]; + }>(), + { + actions: () => ['edit', 'delete'], + } +); +const { push } = useRouter(); +const rowActions = computed(() => { + const actions: DeviceRowAction[] = [ + { key: 'detail', label: '详情', icon: 'InfoFilled' }, + { key: 'edit', label: '编辑', permission: 'admin_deviceManage_edit' }, + { key: 'delete', label: '删除', permission: 'admin_deviceManage_del', danger: true }, + ]; + return actions.filter((action) => props.actions.includes(action.key as DeviceAction)); +}); const handleRowAction = (action: string, row: any) => { + if (action === 'detail') push({ path: '/device/detail/index', query: { id: row.id } }); if (action === 'edit') formDialogRef.value.openDialog(row.id); if (action === 'delete') handleDelete([row.id]); }; diff --git a/src/views/device/components/device-status.vue b/src/views/device/components/device-status.vue index 08fb0d7..321079b 100644 --- a/src/views/device/components/device-status.vue +++ b/src/views/device/components/device-status.vue @@ -1,5 +1,5 @@ diff --git a/src/views/device/deviceManage/index.vue b/src/views/device/deviceManage/index.vue index a269699..72ff556 100644 --- a/src/views/device/deviceManage/index.vue +++ b/src/views/device/deviceManage/index.vue @@ -1,5 +1,5 @@ + + diff --git a/src/views/tools/job-manage/form.vue b/src/views/tools/job-manage/form.vue index 0b6764f..fd51736 100644 --- a/src/views/tools/job-manage/form.vue +++ b/src/views/tools/job-manage/form.vue @@ -67,6 +67,12 @@
+ + + +
{{ t('job.pathHelp') }}
+
+
@@ -110,6 +116,7 @@ const form = reactive({ jobGroup: '', jobType: '', executePath: '', + path: '', className: '', methodName: '', methodParamsValue: '', @@ -133,6 +140,10 @@ const dataRules = reactive({ cronExpression: [{ required: true, message: 'cron不能为空', trigger: 'blur' }], misfirePolicy: [{ required: true, message: '策略不能为空', trigger: 'blur' }], executePath: [{ required: true, message: '执行路径不能为空', trigger: 'blur' }], + path: [ + { pattern: /^\/(?!\/)[^\s\\]*$/, message: t('job.invalidPathTip'), trigger: 'blur' }, + { max: 512, message: t('job.pathLengthTip'), trigger: 'blur' }, + ], className: [ { required: true, message: '名称不能为空', trigger: 'blur' }, { @@ -180,6 +191,7 @@ const openDialog = (id: string) => { // 提交 const onSubmit = async () => { + form.path = (form.path || '').trim(); const valid = await dataFormRef.value.validate().catch(() => {}); if (!valid) return false; @@ -201,6 +213,7 @@ const getsysJobData = (id: string) => { // 获取数据 getObj(id).then((res: any) => { Object.assign(form, res.data); + form.path = res.data.path || ''; }); }; diff --git a/src/views/tools/job-manage/i18n/en.ts b/src/views/tools/job-manage/i18n/en.ts index 96c0884..9f3720e 100644 --- a/src/views/tools/job-manage/i18n/en.ts +++ b/src/views/tools/job-manage/i18n/en.ts @@ -5,6 +5,13 @@ export default { jobStartBtn: 'start', jobRunBtn: 'execute', jobPauseBtn: 'pause', + viewSyncDataBtn: 'View synced data', + path: 'Page path', + inputPathTip: 'Enter a page path starting with /', + pathHelp: 'Optional. View synced data opens this internal page. Query parameters are supported.', + pathNotConfiguredTip: 'Configure the page path in Edit first', + invalidPathTip: 'Enter an internal path starting with a single /, without whitespace or backslashes', + pathLengthTip: 'The page path must not exceed 512 characters', importsysJobTip: 'import SysJob', jobId: 'jobId', jobName: 'jobName', diff --git a/src/views/tools/job-manage/i18n/zh-cn.ts b/src/views/tools/job-manage/i18n/zh-cn.ts index 9d1c4e4..42def87 100644 --- a/src/views/tools/job-manage/i18n/zh-cn.ts +++ b/src/views/tools/job-manage/i18n/zh-cn.ts @@ -5,6 +5,13 @@ export default { jobStartBtn: '启动', jobRunBtn: '执行', jobPauseBtn: '暂停', + viewSyncDataBtn: '查看同步数据', + path: '跳转页面', + inputPathTip: '请输入以 / 开头的页面路径', + pathHelp: '选填,点击“查看同步数据”时跳转到此站内页面,可携带查询参数。', + pathNotConfiguredTip: '请先在编辑中配置跳转页面', + invalidPathTip: '请输入以单个 / 开头的站内页面路径,不能包含空白或反斜杠', + pathLengthTip: '页面路径不能超过 512 个字符', importsysJobTip: '导入定时任务调度表', jobId: '任务id', jobName: '任务名称', diff --git a/tests/device-list-actions.test.ts b/tests/device-list-actions.test.ts index 3687c62..1b58b22 100644 --- a/tests/device-list-actions.test.ts +++ b/tests/device-list-actions.test.ts @@ -5,6 +5,7 @@ import ElementPlus from 'element-plus'; import DeviceRowActions from '../src/views/device/components/device-row-actions.vue'; import type { DeviceRowAction } from '../src/views/device/components/device-row-actions'; import CameraList from '../src/views/device/components/camera-list.vue'; +import PlatformCompareButton from '../src/views/collection/video/components/platform-compare-button.vue'; const spies = vi.hoisted(() => ({ permissions: new Set(), @@ -33,6 +34,9 @@ vi.mock('/@/hooks/table', () => ({ }, })); vi.mock('/@/hooks/message', () => ({ useMessage: () => ({}), useMessageBox: () => ({}) })); +vi.mock('/@/views/collection/video/components/platform-compare-button.vue', () => ({ + default: { name: 'PlatformCompareButton', props: ['resourceType'], emits: ['changed'], render: () => null }, +})); const actions: DeviceRowAction[] = [ { key: 'preview', label: '预览' }, @@ -86,6 +90,7 @@ function renderCamera(props = {}) { DeviceChannerForm: dialogStub(spies.edit), PreviewVideoDialog: dialogStub(spies.preview), ReplayVideoDialog: dialogStub(spies.replay), + PlatformCompareButton: true, }, }, }); @@ -183,4 +188,25 @@ describe('摄像头列表操作入口', () => { expect(spies.preview).toHaveBeenCalledWith('channel', 'channel-code'); expect(spies.replay).toHaveBeenCalledWith('channel', 'channel-code'); }); + + it('设备和通道工具栏打开各自类型的对比,修复后刷新已加载的两张表', async () => { + const wrapper = renderCamera(); + await flushPromises(); + let entries = wrapper.findAllComponents(PlatformCompareButton); + expect(entries.map(entry => entry.props('resourceType'))).toEqual(['DEVICE']); + entries[0].vm.$emit('changed'); + await flushPromises(); + expect(spies.deviceQuery).toHaveBeenCalledOnce(); + expect(spies.channelQuery).not.toHaveBeenCalled(); + await wrapper.findAll('[role="tab"]').find(item => item.text() === '通道')!.trigger('click'); + await flushPromises(); + entries = wrapper.findAllComponents(PlatformCompareButton); + expect(entries.map(entry => entry.props('resourceType'))).toEqual(['DEVICE', 'CHANNEL']); + spies.deviceQuery.mockClear(); + spies.channelQuery.mockClear(); + entries[1].vm.$emit('changed'); + await flushPromises(); + expect(spies.deviceQuery).toHaveBeenCalledOnce(); + expect(spies.channelQuery).toHaveBeenCalledOnce(); + }); }); diff --git a/tests/platform-compare-button.test.ts b/tests/platform-compare-button.test.ts new file mode 100644 index 0000000..e1ec3be --- /dev/null +++ b/tests/platform-compare-button.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mount, type VueWrapper } from '@vue/test-utils'; +import { defineComponent, h } from 'vue'; +import PlatformCompareButton from '../src/views/collection/video/components/platform-compare-button.vue'; +import type { PlatformResourceType } from '../src/api/collection/videoPlatform'; + +const spies = vi.hoisted(() => ({ open: vi.fn(), permissions: new Set() })); +vi.mock('/@/utils/authFunction', () => ({ auth: (permission: string) => spies.permissions.has(permission) })); +vi.mock('/@/views/collection/video/components/platform-resource-dialog.vue', () => ({ + default: { + name: 'PlatformResourceDialog', emits: ['changed'], + setup: (_: unknown, { expose }: any) => { expose({ open: spies.open }); return () => null; }, + }, +})); + +const wrappers: VueWrapper[] = []; +const Button = defineComponent({ setup: (_, { slots }) => () => h('button', slots.default?.()) }); +const renderButton = (resourceType: PlatformResourceType) => { + const wrapper = mount(PlatformCompareButton, { props: { resourceType }, global: { + stubs: { ElButton: Button }, + } }); + wrappers.push(wrapper); + return wrapper; +}; + +beforeEach(() => { + spies.open.mockClear(); + spies.permissions.clear(); +}); +afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount())); + +describe('ICC 数据对比快捷入口', () => { + it.each([ + ['PERSON', '人员', 'admin_sysPerson_view'], + ['CAR', '车辆', 'car_carManage_view'], + ['DEVICE', '设备', 'admin_deviceManage_view'], + ['CHANNEL', '通道', 'admin_deviceManage_view'], + ] as const)('%s 点击后打开相应全量差异弹窗,首次显示列表时不请求对比', async (type, label, permission) => { + spies.permissions.add(permission); + const wrapper = renderButton(type); + expect(spies.open).not.toHaveBeenCalled(); + expect(wrapper.get('button').text()).toBe('ICC 数据对比'); + expect(wrapper.get('button').attributes('title')).toBe(`对比 ICC 与本地已同步的全部${label}数据`); + await wrapper.get('button').trigger('click'); + expect(spies.open).toHaveBeenCalledWith(type, 'DIFFERENCE', `ICC ${label}数据对比`); + }); + + it('仅有设备权限时,不展示人员、车辆的对比入口', () => { + spies.permissions.add('admin_deviceManage_view'); + expect(renderButton('PERSON').find('button').exists()).toBe(false); + expect(renderButton('CAR').find('button').exists()).toBe(false); + expect(spies.open).not.toHaveBeenCalled(); + }); + + it('差异修复完成后通知列表刷新', () => { + spies.permissions.add('admin_sysPerson_view'); + const wrapper = renderButton('PERSON'); + wrapper.findComponent({ name: 'PlatformResourceDialog' }).vm.$emit('changed'); + expect(wrapper.emitted('changed')).toHaveLength(1); + }); +}); diff --git a/tests/platform-status.test.ts b/tests/platform-status.test.ts index 7eab1ca..c6cec29 100644 --- a/tests/platform-status.test.ts +++ b/tests/platform-status.test.ts @@ -29,7 +29,7 @@ async function setupPage() { directives: { loading: () => {}, auth: () => {} }, stubs: { ElTag: slotStub, ElIcon: slotStub, ElDivider: slotStub, ElTable: slotStub, ElTableColumn: true, - ElAlert: true, + ElAlert: true, ElFormItem: slotStub, DictSelect: true, ElButton: defineComponent({ setup: (_, { slots }) => () => h('button', slots.default?.()) }), }, }, diff --git a/tests/video-player-sdk.connection.test.ts b/tests/video-player-sdk.connection.test.ts new file mode 100644 index 0000000..64cb47a --- /dev/null +++ b/tests/video-player-sdk.connection.test.ts @@ -0,0 +1,410 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { runInNewContext } from 'node:vm'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const source = readFileSync(resolve('src/components/rtsp-plugin-video/lib/videoPlayer.js'), 'utf8'); +let sdkWindow: any; +let control: any; +let sockets: MockSocket[]; +let attemptedUrls: string[]; +let throwOnConstruct: boolean; + +class MockSocket { + readyState = 0; + onopen: (() => void) | null = null; + onclose: (() => void) | null = null; + onerror: (() => void) | null = null; + onmessage: ((event: { data: string }) => void) | null = null; + messages: any[] = []; + close = vi.fn(() => { + this.readyState = 3; + this.onclose?.(); + }); + send = vi.fn((value: string) => { + if (this.readyState !== 1) throw new Error('socket is not open'); + this.messages.push(JSON.parse(value)); + }); + constructor(public url: string) { + attemptedUrls.push(url); + if (throwOnConstruct) throw new Error('local connection blocked'); + sockets.push(this); + } + open() { + this.readyState = 1; + this.onopen?.(); + } + fail() { + const close = this.onclose; + this.readyState = 3; + this.onerror?.(); + close?.(); + } + message(data: any) { + this.onmessage?.({ data: JSON.stringify(data) }); + } +} + +function makePlayer(id = 'one', options: Record = {}, realCreate = false) { + const player = Object.create(sdkWindow.VideoPlayer.prototype); + player.setting = { + videoId: id, hwnd: '', show: false, visible: true, channelList: [], + division: 1, windowType: 0, outContent: {}, language: 'zh', request: {}, + stopRefresh: false, usePluginLogin: false, isResetConnect: true, + createError: vi.fn(), createSuccess: vi.fn(), connectionStateChange: vi.fn(), + ...options, + }; + player.loginServer = vi.fn(); + if (!realCreate) player.create = vi.fn(); + document.body.insertAdjacentHTML('beforeend', `
`); + player.initPlayer(); + return player; +} + +async function failAttempts(count: number) { + for (let index = 0; index < count; index++) { + sockets.at(-1)!.fail(); + await vi.advanceTimersByTimeAsync((index + 1) % 5 === 0 ? 1000 : 100); + } +} + +beforeEach(() => { + vi.useFakeTimers(); + sockets = []; + attemptedUrls = []; + throwOnConstruct = false; + const navigator = { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36' }; + sdkWindow = { + addEventListener: vi.fn(), removeEventListener: vi.fn(), + cancelAnimationFrame: vi.fn(), requestAnimationFrame: vi.fn(() => 42), + getComputedStyle: window.getComputedStyle.bind(window), + innerWidth: 1280, innerHeight: 720, outerWidth: 1280, devicePixelRatio: 1, + screen: { availLeft: 0 }, screenX: 0, location: { hostname: 'localhost' }, + document, navigator, + }; + sdkWindow.parent = sdkWindow; + sdkWindow.top = sdkWindow; + runInNewContext(source, { + window: sdkWindow, document, navigator, localStorage: {}, + console: { log: vi.fn() }, WebSocket: MockSocket, + setTimeout, clearTimeout, setInterval, clearInterval, + }); + control = sdkWindow.dhPlayerControl; +}); + +afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + document.body.innerHTML = ''; +}); + +describe('本机插件连接探测', () => { + it('并发组件共用一个连接,连接成功后分别初始化一次', () => { + const first = makePlayer(); + const second = makePlayer('two'); + first.initPlayer(); + expect(sockets).toHaveLength(1); + sockets[0].open(); + first.initPlayer(); + expect(first.create).toHaveBeenCalledTimes(1); + expect(second.create).toHaveBeenCalledTimes(1); + expect(first.setting.createError).not.toHaveBeenCalled(); + }); + + it('服务稍晚启动时继续下一轮探测,不提前宣告未安装', async () => { + const player = makePlayer(); + await failAttempts(5); + expect(attemptedUrls.map(url => new URL(url).port)).toEqual(['8000', '8001', '8002', '8003', '8004', '8000']); + expect(player.setting.createError).not.toHaveBeenCalled(); + sockets.at(-1)!.open(); + expect(player.create).toHaveBeenCalledTimes(1); + expect(control.windowState).toBe('wsSuccess'); + }); + + it('三轮失败只报告一次终态,下次打开能重新扫描', async () => { + const first = makePlayer(); + await failAttempts(15); + expect(sockets).toHaveLength(15); + expect(first.setting.createError).toHaveBeenCalledTimes(1); + expect(first.setting.createError).toHaveBeenCalledWith(expect.objectContaining({ + code: 1001, message: '未能连接本机视频服务,请启动或重新启动视频插件后重试', + })); + expect(control.wsConnect).toBe(false); + expect(control.videoWS).toBeNull(); + const retry = makePlayer('retry'); + expect(sockets).toHaveLength(16); + sockets.at(-1)!.open(); + expect(retry.create).toHaveBeenCalledTimes(1); + }); + + it('一直没有open或close事件的端口也会超时,整个探测次数有上限', async () => { + const player = makePlayer(); + await vi.advanceTimersByTimeAsync(30000); + expect(sockets).toHaveLength(15); + expect(sockets.every(socket => socket.close.mock.calls.length === 1)).toBe(true); + expect(player.setting.createError).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + }); + + it('构造WebSocket抛异常也能完整结束探测', async () => { + throwOnConstruct = true; + const player = makePlayer(); + await vi.advanceTimersByTimeAsync(10000); + expect(attemptedUrls).toHaveLength(15); + expect(player.setting.createError).toHaveBeenCalledTimes(1); + expect(control.wsConnect).toBe(false); + expect(vi.getTimerCount()).toBe(0); + }); + + it('error紧接close只推进一次,不启动并行扫描', async () => { + makePlayer(); + sockets[0].fail(); + await vi.advanceTimersByTimeAsync(100); + expect(sockets).toHaveLength(2); + expect(sockets[1].url).toBe('ws://127.0.0.1:8001'); + await vi.advanceTimersByTimeAsync(100); + expect(sockets).toHaveLength(2); + }); + + it('最后一个实例在探测中销毁会终止后台探测', async () => { + const player = makePlayer(); + player.dispose(); + await vi.advanceTimersByTimeAsync(30000); + expect(sockets).toHaveLength(1); + expect(sockets[0].close).toHaveBeenCalledTimes(1); + expect(control.wsConnect).toBe(false); + expect(player.setting.createError).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('销毁其中一个实例不打断其它实例的探测或已连接的共享socket', async () => { + const first = makePlayer(); + const other = makePlayer('other'); + first.dispose(); + sockets[0].fail(); + await vi.advanceTimersByTimeAsync(100); + sockets[1].open(); + expect(other.create).toHaveBeenCalledTimes(1); + other.dispose(); + expect(sockets[1].close).not.toHaveBeenCalled(); + }); + + it('主动关闭取消延迟任务,旧close事件不会重开连接', async () => { + const player = makePlayer(); + const oldClose = sockets[0].onclose; + sockets[0].fail(); + player.onbeforeunload(); + oldClose?.(); + await vi.advanceTimersByTimeAsync(30000); + expect(sockets).toHaveLength(1); + expect(control.manualCloseWS).toBe(true); + expect(control.wsConnect).toBe(false); + expect(player.setting.createError).not.toHaveBeenCalled(); + }); +}); + +describe('连接中断及旧回调隔离', () => { + it('先重试上次成功端口,再探测其它端口,只通知一次reconnecting', async () => { + const player = makePlayer(); + await failAttempts(2); + sockets[2].open(); + sockets[2].fail(); + expect(sockets[3].url).toBe('ws://127.0.0.1:8002'); + expect(player.setting.createError).not.toHaveBeenCalled(); + expect(player.setting.connectionStateChange.mock.calls.map(([state]: any[]) => state.state)).toEqual(['connecting', 'reconnecting']); + sockets[3].fail(); + await vi.advanceTimersByTimeAsync(100); + expect(sockets[4].url).toBe('ws://127.0.0.1:8000'); + sockets[4].open(); + expect(player.create).toHaveBeenCalledTimes(2); + }); + + it('重连三轮也失败时报告1003,不误报插件未安装', async () => { + const player = makePlayer('native-ready', {}, true); + sockets[0].open(); + await vi.advanceTimersByTimeAsync(0); + const create = sockets[0].messages.find(message => message.method === 'window.create'); + sockets[0].message({ id: create.id, code: 0, data: { hwnd: 101 } }); + expect(player.setting.createSuccess).toHaveBeenCalledTimes(1); + sockets[0].fail(); + await failAttempts(15); + expect(player.setting.createError).toHaveBeenCalledTimes(1); + expect(player.setting.createError).toHaveBeenCalledWith(expect.objectContaining({ code: 1003 })); + expect(sockets).toHaveLength(16); + expect(vi.getTimerCount()).toBe(0); + }); + + it('只成功握手却始终无法准备好窗口时,不会无限重置探测预算', () => { + const player = makePlayer(); + for (let attempt = 0; attempt < 15; attempt++) { + sockets.at(-1)!.open(); + sockets.at(-1)!.fail(); + } + expect(sockets).toHaveLength(15); + expect(player.setting.createError).toHaveBeenCalledTimes(1); + expect(player.setting.createError).toHaveBeenCalledWith(expect.objectContaining({ code: 1003 })); + expect(control.wsConnect).toBe(false); + expect(vi.getTimerCount()).toBe(0); + }); + + it('旧socket的open/message/close回调不会污染已经恢复的连接', async () => { + const player = makePlayer(); + const old = sockets[0]; + const callbacks = { open: old.onopen!, close: old.onclose!, message: old.onmessage! }; + old.open(); + old.fail(); + const current = sockets[1]; + current.open(); + callbacks.open(); + callbacks.message({ data: JSON.stringify({ method: 'window.loginServer.notify', info: { result: 1 } }) }); + callbacks.close(); + await vi.advanceTimersByTimeAsync(1000); + expect(control.videoWS).toBe(current); + expect(control.windowState).toBe('wsSuccess'); + expect(control.loginFlag).toBe('LOGIN_PENDING'); + expect(sockets).toHaveLength(2); + expect(player.create).toHaveBeenCalledTimes(2); + expect(player.setting.createError).not.toHaveBeenCalled(); + }); + + it('心跳只发送给当前打开的连接,断开后停止旧心跳', async () => { + makePlayer(); + const old = sockets[0]; + old.open(); + await vi.advanceTimersByTimeAsync(10000); + expect(old.messages.filter(message => message.method === 'common.heartbeat')).toHaveLength(1); + old.fail(); + const current = sockets[1]; + current.open(); + await vi.advanceTimersByTimeAsync(10000); + expect(old.messages.filter(message => message.method === 'common.heartbeat')).toHaveLength(1); + expect(current.messages.filter(message => message.method === 'common.heartbeat')).toHaveLength(1); + }); + + it('关闭自动重连的实例不会参与其他实例的恢复', () => { + const stopped = makePlayer('stopped', { isResetConnect: false }); + const recovering = makePlayer('recovering'); + sockets[0].open(); + sockets[0].fail(); + expect(stopped.setting.createError).toHaveBeenCalledWith(expect.objectContaining({ code: 1003 })); + sockets[1].open(); + expect(stopped.create).toHaveBeenCalledTimes(1); + expect(recovering.create).toHaveBeenCalledTimes(2); + }); + + it('所有实例关闭自动重连时,连接断开不再后台扫描', async () => { + const player = makePlayer('stopped', { isResetConnect: false }); + sockets[0].open(); + sockets[0].fail(); + await vi.advanceTimersByTimeAsync(30000); + expect(sockets).toHaveLength(1); + expect(player.setting.createError).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + }); + + it('断线清理旧句柄和等待任务,新连接无需等待旧destroy回包', async () => { + const player = makePlayer('real', {}, true); + const old = sockets[0]; + old.open(); + await vi.advanceTimersByTimeAsync(0); + const firstCreate = old.messages.find(message => message.method === 'window.create'); + const lateCallback = control.callBackList[firstCreate.id]; + player.setting.hwnd = 101; + player.setting.channelList = [{ channelId: 'old', snum: 0 }]; + control.hwndList[101] = 'real'; + player.setting.socketTimer = setTimeout(() => { throw new Error('old initialization timer'); }, 10); + player.setting.refreshTimer = 99; + old.fail(); + expect(player.setting.hwnd).toBe(''); + expect(control.hwndList).toEqual({}); + expect(control.callBackList).toEqual({}); + expect(sdkWindow.cancelAnimationFrame).toHaveBeenCalledWith(99); + const current = sockets[1]; + current.open(); + await vi.advanceTimersByTimeAsync(20); + expect(current.messages.some(message => message.method === 'window.create')).toBe(true); + expect(current.messages.some(message => message.method === 'window.destroy')).toBe(false); + const count = current.messages.length; + lateCallback.onSuccess({ data: { hwnd: 606 } }); + expect(current.messages).toHaveLength(count); + expect(player.setting.hwnd).toBe(''); + }); + + it('window.create失败仅报告该实例,不销毁其他窗口或关闭共享连接', async () => { + const failed = makePlayer('failed', {}, true); + const other = makePlayer('other'); + const socket = sockets[0]; + socket.open(); + await vi.advanceTimersByTimeAsync(0); + const request = socket.messages.find(message => message.method === 'window.create'); + socket.message({ id: request.id, code: 1 }); + expect(failed.setting.createError).toHaveBeenCalledWith(expect.objectContaining({ code: 1006 })); + expect(other.setting.createError).not.toHaveBeenCalled(); + expect(socket.close).not.toHaveBeenCalled(); + expect(socket.messages.some(message => message.method === 'window.destroyall')).toBe(false); + await vi.advanceTimersByTimeAsync(5000); + expect(sockets).toHaveLength(1); + }); +}); + +describe('共享连接的插件登录等待', () => { + it('登录中加入的实例也能继续创建,重复初始化不叠加等待', async () => { + const first = makePlayer('first', { usePluginLogin: true }); + sockets[0].open(); + const second = makePlayer('second', { usePluginLogin: true }); + const waitTimer = second.loginWaitTimer; + second.initPlayer(); + expect(second.loginWaitTimer).toBe(waitTimer); + expect(first.loginServer).toHaveBeenCalledTimes(1); + expect(second.loginServer).not.toHaveBeenCalled(); + sockets[0].message({ method: 'window.loginServer.notify', info: { result: 0 } }); + await vi.advanceTimersByTimeAsync(300); + expect(first.create).toHaveBeenCalledTimes(1); + expect(second.create).toHaveBeenCalledTimes(1); + expect(first.loginWaitTimer).toBeNull(); + expect(second.loginWaitTimer).toBeNull(); + }); + + it('登录失败按1004报告,不创建窗口,也不误报未安装', async () => { + const player = makePlayer('login', { usePluginLogin: true }); + sockets[0].open(); + sockets[0].message({ method: 'window.loginServer.notify', info: { result: 1 } }); + await vi.advanceTimersByTimeAsync(300); + expect(player.create).not.toHaveBeenCalled(); + expect(player.setting.createError).toHaveBeenCalledTimes(1); + expect(player.setting.createError).toHaveBeenCalledWith(expect.objectContaining({ code: 1004 })); + }); + + it('登录未返回也会超时结束,可由新实例重试登录', async () => { + const player = makePlayer('login', { usePluginLogin: true }); + sockets[0].open(); + await vi.advanceTimersByTimeAsync(29999); + expect(player.setting.createError).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(player.setting.createError).toHaveBeenCalledTimes(1); + expect(player.setting.createError).toHaveBeenCalledWith(expect.objectContaining({ code: 1004, i18nKey: 'dhplayer.login.timeout' })); + expect(player.loginWaitTimer).toBeNull(); + expect(player.loginTimeoutTimer).toBeNull(); + const retry = makePlayer('retry', { usePluginLogin: true }); + expect(retry.loginServer).toHaveBeenCalledTimes(1); + sockets[0].message({ method: 'window.loginServer.notify', info: { result: 0 } }); + await vi.advanceTimersByTimeAsync(300); + expect(retry.create).toHaveBeenCalledTimes(1); + }); + + it('重连清理旧登录等待,旧连接的登录响应不能提前完成新登录', async () => { + const player = makePlayer('login', { usePluginLogin: true }); + const old = sockets[0]; + old.open(); + const oldMessage = old.onmessage!; + old.fail(); + sockets[1].open(); + oldMessage({ data: JSON.stringify({ method: 'window.loginServer.notify', info: { result: 0 } }) }); + await vi.advanceTimersByTimeAsync(300); + expect(player.create).not.toHaveBeenCalled(); + sockets[1].message({ method: 'window.loginServer.notify', info: { result: 0 } }); + await vi.advanceTimersByTimeAsync(300); + expect(player.create).toHaveBeenCalledTimes(1); + expect(player.loginServer).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/video-plugin.connection.test.ts b/tests/video-plugin.connection.test.ts new file mode 100644 index 0000000..94da89e --- /dev/null +++ b/tests/video-plugin.connection.test.ts @@ -0,0 +1,351 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mount, type VueWrapper } from '@vue/test-utils' +import { nextTick } from 'vue' +import VideoPlugin from '../src/components/rtsp-plugin-video/video-plugin.vue' + +const mocks = vi.hoisted(() => ({ getSetting: vi.fn() })) +vi.mock('../src/components/rtsp-plugin-video/lib/videoPlayer', () => ({})) +vi.mock('../src/api/com/comDhSetting', () => ({ getObj: mocks.getSetting })) +vi.mock('element-plus', () => ({ ElMessage: { error: vi.fn(), success: vi.fn(), warning: vi.fn() } })) + +type Mode = 'real' | 'playback' +const modes: Mode[] = ['real', 'playback'] +const connectionError = '未能连接本机视频服务,请启动或重新启动视频插件后重试' +const instances: MockPlayer[] = [] +let constructorError: { code: number; message: string } | null = null + +class MockPlayer { + setting: any + closeVideo = vi.fn(() => Promise.resolve()) + dispose = vi.fn() + hide = vi.fn() + show = vi.fn() + startReal = vi.fn((items) => { this.setting.channelList = items }) + startPlayback = vi.fn((items) => { this.setting.channelList = items }) + controlPlayback = vi.fn() + setTabControlBtn = vi.fn() + changeDivision = vi.fn((division) => { this.setting.division = division }) + constructor(public options: any) { + this.setting = { ...options, hwnd: instances.length + 10, channelList: [] } + instances.push(this) + if (constructorError) options.createError(constructorError) + } + manualClose(all: boolean) { + this.setting.channelList = all ? [] : this.setting.channelList.slice(1) + this.options.closeWindowSuccess({ isAll: all, snum: 0, channelList: this.setting.channelList }) + } +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { resolve = done }) + return { promise, resolve } +} +const wrappers: VueWrapper[] = [] +function createPlayer() { + const wrapper = mount(VideoPlugin, { + global: { stubs: { Yt: true, 'el-pagination': true, 'el-icon': true }, directives: { loading: () => undefined } }, + }) + document.body.appendChild(wrapper.element) + wrappers.push(wrapper) + return wrapper +} +function requests(mode: Mode, names = ['camera-a']) { + return names.map((channelId, snum) => ({ + channelId, snum, streamType: mode === 'real' ? 2 : 0, + ...(mode === 'playback' ? { startTime: '2026-11-04 08:00:00', endTime: '2026-11-04 09:00:00', recordSource: 2 } : {}), + })) +} +function start(wrapper: VueWrapper, mode: Mode, items = requests(mode)) { + return mode === 'real' ? wrapper.vm.startReal(items) : wrapper.vm.startPlayback(items) +} +function startSpy(player: MockPlayer, mode: Mode) { + return mode === 'real' ? player.startReal : player.startPlayback +} +async function ready(player: MockPlayer) { + player.options.createSuccess({}) + await vi.advanceTimersByTimeAsync(500) +} +async function play(wrapper: VueWrapper, mode: Mode, items = requests(mode)) { + const task = start(wrapper, mode, items) + await vi.advanceTimersByTimeAsync(0) + const player = instances[instances.length - 1] + await ready(player) + await task + expect(startSpy(player, mode)).toHaveBeenCalledTimes(1) + return player +} +async function failInitial(wrapper: VueWrapper, mode: Mode, code = 1001) { + const task = start(wrapper, mode) + await vi.advanceTimersByTimeAsync(0) + const player = instances[instances.length - 1] + player.options.createError({ code, message: '插件未安装' }) + await task + await nextTick() + expect(player.dispose).toHaveBeenCalledTimes(1) + return player +} +function retryButton(wrapper: VueWrapper) { + const button = wrapper.findAll('button').find((item) => item.text() === '重新连接') + expect(button, '连接失败时应提供重新连接按钮').toBeDefined() + return button! +} +function setHidden(hidden: boolean) { + Object.defineProperty(document, 'hidden', { configurable: true, value: hidden }) + document.dispatchEvent(new Event('visibilitychange')) +} + +beforeEach(() => { + vi.useFakeTimers() + vi.spyOn(console, 'log').mockImplementation(() => undefined) + Object.defineProperty(document, 'hidden', { configurable: true, value: false }) + ;(window as any).VideoPlayer = MockPlayer + instances.length = 0 + constructorError = null + mocks.getSetting.mockReset().mockResolvedValue({ code: 0, data: { dhIccSetting: {} } }) +}) +afterEach(() => { + wrappers.splice(0).forEach((wrapper) => { if (wrapper.exists()) wrapper.unmount() }) + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + document.body.innerHTML = '' +}) + +describe.each(modes)('%s 视频连接恢复', (mode) => { + it.each([1001, 1003])('终态错误 %s 显示连接故障,点击重试恢复当前请求', async (code) => { + const wrapper = createPlayer() + await failInitial(wrapper, mode, code) + expect(wrapper.text()).toContain(connectionError) + expect(wrapper.text()).not.toContain('插件未安装') + await retryButton(wrapper).trigger('click') + await vi.advanceTimersByTimeAsync(0) + expect(instances).toHaveLength(2) + await ready(instances[1]) + expect(startSpy(instances[1], mode)).toHaveBeenCalledTimes(1) + expect(startSpy(instances[1], mode)).toHaveBeenCalledWith(requests(mode)) + expect(wrapper.text()).not.toContain(connectionError) + }) + + it('连接中状态不是终态,不销毁实例或显示未安装提示', async () => { + const wrapper = createPlayer() + const task = start(wrapper, mode) + await vi.advanceTimersByTimeAsync(0) + const player = instances[0] + player.options.connectionStateChange({ state: 'connecting' }) + await nextTick() + expect(player.dispose).not.toHaveBeenCalled() + expect(wrapper.text()).not.toContain('插件未安装') + await ready(player) + await task + expect(startSpy(player, mode)).toHaveBeenCalledTimes(1) + }) + + it('运行中的连接恢复复用实例,并绕过800ms重复播放保护重新开流', async () => { + const wrapper = createPlayer() + const player = await play(wrapper, mode) + const disconnectedAt = Date.now() + player.options.connectionStateChange({ state: 'reconnecting' }) + expect(player.dispose).not.toHaveBeenCalled() + await ready(player) + expect(Date.now() - disconnectedAt).toBeLessThan(800) + expect(instances).toHaveLength(1) + expect(startSpy(player, mode)).toHaveBeenCalledTimes(2) + expect(startSpy(player, mode)).toHaveBeenLastCalledWith(requests(mode)) + }) + + it.each([false, true])('重连时不会重新播放手动关闭的通道(全部关闭:%s)', async (all) => { + const wrapper = createPlayer() + const items = requests(mode, ['camera-a', 'camera-b']) + const player = await play(wrapper, mode, items) + player.manualClose(all) + player.options.connectionStateChange({ state: 'reconnecting' }) + await ready(player) + expect(player.dispose).not.toHaveBeenCalled() + expect(startSpy(player, mode)).toHaveBeenCalledTimes(all ? 1 : 2) + if (!all) expect(startSpy(player, mode)).toHaveBeenLastCalledWith([items[1]]) + }) + + it('后台重连成功时先不播放,回到前台才恢复有效请求', async () => { + const wrapper = createPlayer() + const player = await play(wrapper, mode) + setHidden(true) + await vi.advanceTimersByTimeAsync(0) + player.options.connectionStateChange({ state: 'reconnecting' }) + await ready(player) + expect(startSpy(player, mode)).toHaveBeenCalledTimes(1) + expect(player.setting.visible).toBe(false) + setHidden(false) + await vi.advanceTimersByTimeAsync(500) + expect(startSpy(player, mode)).toHaveBeenCalledTimes(2) + expect(startSpy(player, mode)).toHaveBeenLastCalledWith(requests(mode)) + expect(player.setting.visible).toBe(true) + }) +}) + +describe('重试和页面关闭的竞态', () => { + it.each(modes)('切换模式失败后返回正常缓存的 %s 模式会清除错误遮罩和重试按钮', async (mode) => { + const wrapper = createPlayer() + const cached = await play(wrapper, mode) + const otherMode = mode === 'real' ? 'playback' : 'real' + await failInitial(wrapper, otherMode) + expect(wrapper.text()).toContain(connectionError) + retryButton(wrapper) + + await start(wrapper, mode) + await nextTick() + expect(instances).toHaveLength(2) + expect(cached.setting.visible).toBe(true) + expect(wrapper.text()).not.toContain(connectionError) + expect(wrapper.findAll('button').some((button) => button.text() === '重新连接')).toBe(false) + expect(wrapper.find('.loading-mask').exists()).toBe(false) + + // 配置查询失败也不能把错误状态带回仍可用的缓存播放器。 + mocks.getSetting.mockResolvedValueOnce({ code: 1, data: null }) + await start(wrapper, otherMode) + await nextTick() + expect(wrapper.text()).toContain('配置') + retryButton(wrapper) + await start(wrapper, mode) + await nextTick() + expect(instances).toHaveLength(2) + expect(cached.setting.visible).toBe(true) + expect(wrapper.text()).not.toContain('配置') + expect(wrapper.findAll('button').some((button) => button.text() === '重新连接')).toBe(false) + expect(wrapper.find('.loading-mask').exists()).toBe(false) + }) + + it('关闭命令一直不回包时,重连会结束关闭等待供父组件继续执行', async () => { + const wrapper = createPlayer() + const player = await play(wrapper, 'real') + const noResponse = deferred() + player.closeVideo.mockReturnValueOnce(noResponse.promise) + let closed = false + const closing = wrapper.vm.closeVideo().then(() => { closed = true }) + await vi.advanceTimersByTimeAsync(0) + expect(closed).toBe(false) + player.options.connectionStateChange({ state: 'reconnecting' }) + await vi.advanceTimersByTimeAsync(0) + expect(closed).toBe(true) + await closing + await ready(player) + expect(player.startReal).toHaveBeenCalledTimes(1) + }) + + it('快速重复点击重试只查询一次配置并创建一个替换实例', async () => { + const wrapper = createPlayer() + await failInitial(wrapper, 'real') + const pending = deferred() + mocks.getSetting.mockReturnValueOnce(pending.promise) + const button = retryButton(wrapper).element + button.dispatchEvent(new MouseEvent('click', { bubbles: true })) + button.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await nextTick() + button.dispatchEvent(new MouseEvent('click', { bubbles: true })) + expect(mocks.getSetting).toHaveBeenCalledTimes(2) + expect(instances).toHaveLength(1) + pending.resolve({ code: 0, data: { dhIccSetting: {} } }) + await vi.advanceTimersByTimeAsync(0) + expect(instances).toHaveLength(2) + await ready(instances[1]) + expect(instances[1].startReal).toHaveBeenCalledTimes(1) + }) + + it('非当前缓存模式重连成功不抢占当前回放', async () => { + const wrapper = createPlayer() + const preview = await play(wrapper, 'real') + const replay = await play(wrapper, 'playback') + preview.options.connectionStateChange({ state: 'reconnecting' }) + await ready(preview) + expect(preview.dispose).not.toHaveBeenCalled() + expect(preview.startReal).toHaveBeenCalledTimes(1) + expect(preview.setting.visible).toBe(false) + expect(replay.startPlayback).toHaveBeenCalledTimes(1) + expect(replay.setting.visible).toBe(true) + }) + + it('重连过程中切换模式,只恢复最新回放请求', async () => { + const wrapper = createPlayer() + const preview = await play(wrapper, 'real') + preview.options.connectionStateChange({ state: 'reconnecting' }) + const task = start(wrapper, 'playback') + await vi.advanceTimersByTimeAsync(0) + const replay = instances[1] + await ready(replay) + await task + await ready(preview) + expect(preview.startReal).toHaveBeenCalledTimes(1) + expect(replay.startPlayback).toHaveBeenCalledTimes(1) + expect(replay.startPlayback).toHaveBeenCalledWith(requests('playback')) + }) + + it('重连取消旧关闭等待,旧回包不能清空已恢复的新通道', async () => { + const wrapper = createPlayer() + const player = await play(wrapper, 'real') + const close = deferred() + player.closeVideo.mockReturnValueOnce(close.promise) + const closing = wrapper.vm.closeVideo() + let waitingSettled = false + const items = requests('real', ['new-camera']) + const waiting = start(wrapper, 'real', items).then(() => { waitingSettled = true }) + await vi.advanceTimersByTimeAsync(0) + expect(player.startReal).toHaveBeenCalledTimes(1) + player.options.connectionStateChange({ state: 'reconnecting' }) + await vi.advanceTimersByTimeAsync(0) + expect(waitingSettled).toBe(true) + await ready(player) + expect(player.startReal).toHaveBeenCalledTimes(2) + expect(player.startReal).toHaveBeenLastCalledWith(items) + close.resolve() + await closing + await waiting + expect(player.setting.channelList).toEqual(items) + }) + + it.each(['close', 'unmount', 'inactive'])('手动重试后%s,晚到就绪不能重新播放', async (action) => { + const wrapper = createPlayer() + const failed = await failInitial(wrapper, 'real') + await retryButton(wrapper).trigger('click') + await vi.advanceTimersByTimeAsync(0) + const replacement = instances[1] + expect(replacement).toBeDefined() + if (action === 'close') await wrapper.vm.closeVideo() + if (action === 'unmount') wrapper.unmount() + if (action === 'inactive') await wrapper.setProps({ active: false }) + await ready(failed) + await ready(replacement) + expect(failed.startReal).not.toHaveBeenCalled() + expect(replacement.startReal).not.toHaveBeenCalled() + expect(instances).toHaveLength(2) + }) + + it('重试配置仍在加载时关闭,不再构造替换播放器', async () => { + const wrapper = createPlayer() + await failInitial(wrapper, 'real') + const config = deferred() + mocks.getSetting.mockReturnValueOnce(config.promise) + await retryButton(wrapper).trigger('click') + wrapper.unmount() + config.resolve({ code: 0, data: { dhIccSetting: {} } }) + await vi.advanceTimersByTimeAsync(1000) + expect(instances).toHaveLength(1) + }) + + it('SDK构造期间同步报错也会清理实例,后续手动重试可成功', async () => { + constructorError = { code: 1001, message: '插件未安装' } + const wrapper = createPlayer() + let settled = false + const task = start(wrapper, 'real').then(() => { settled = true }) + await vi.advanceTimersByTimeAsync(0) + expect(settled).toBe(true) + expect(instances[0].dispose).toHaveBeenCalledTimes(1) + expect(wrapper.text()).toContain(connectionError) + constructorError = null + await retryButton(wrapper).trigger('click') + await vi.advanceTimersByTimeAsync(0) + expect(instances).toHaveLength(2) + await ready(instances[1]) + await task + expect(instances[1].startReal).toHaveBeenCalledTimes(1) + }) +}) diff --git a/tests/video-plugin.lifecycle.test.ts b/tests/video-plugin.lifecycle.test.ts index ca1921e..6b40186 100644 --- a/tests/video-plugin.lifecycle.test.ts +++ b/tests/video-plugin.lifecycle.test.ts @@ -92,7 +92,7 @@ describe('原生播放器组件的关闭时序', () => { await nextTick(); expect(wrapper.findAll('.video-window-label').map(item => item.text())).toEqual(labels.map(item => item.label)); expect(wrapper.text()).toContain('南门球机'); - expect(wrapper.text()).toContain('插件未安装'); + expect(wrapper.text()).toContain('未能连接本机视频服务'); expect(wrapper.find('.loading-mask').exists()).toBe(false); });