diff --git a/src/main/resources/static/components/plugins/sysSignature/pc.vue b/src/main/resources/static/components/plugins/sysSignature/pc.vue index 415b4da..865f0f7 100644 --- a/src/main/resources/static/components/plugins/sysSignature/pc.vue +++ b/src/main/resources/static/components/plugins/sysSignature/pc.vue @@ -35,22 +35,58 @@ module.exports = { }, data() { return { - //前端唯一标识 当然后端还有用户id作为唯一标识 + // 二维码携带本次组件实例的标识,与服务端当前登录用户共同确定签字记录。 id: new Date().getTime() + Math.floor(Math.random() * 10000) + 1, signatureAddress: null, interval: null, + connectionWaitResolve: null, + unsubscribeSignature: null, signatureContent: null, loading: true, } }, methods: { - async init() { - const {code, data} = await this.$axios.post("/platform/signature/get") - if (code === 0) { - this.signatureContent = data && data.signature - this.$emit("input", this.signatureContent) - } + /** + * 无请求参数,返回 Promise;响应 code 为0表示成功,data.signature 为已有签字图片地址。 + * 请求结束后由 mounted 的 finally 统一关闭 loading。 + */ + init() { this.signatureAddress = origin + "/platform/signature/scanPcCode?id=" + this.id + return this.$axios.post("/platform/signature/get") + .then((res) => { + if (this._isBeingDestroyed || this._isDestroyed) { + return + } + if (res.code !== 0) { + throw new Error("签字信息加载失败") + } + this.signatureContent = res.data && res.data.signature + this.$emit("input", this.signatureContent) + }) + }, + // 返回等待完成的 Promise;连接成功、等待30秒或组件销毁时结束等待。 + waitForConnection() { + return new Promise((resolve) => { + const startedAt = Date.now() + this.connectionWaitResolve = resolve + const checkReady = () => { + this.interval = null + if (this._isBeingDestroyed || this._isDestroyed || + (webSocketPubSub.ws && webSocketPubSub.ws.readyState === WebSocket.OPEN)) { + this.connectionWaitResolve = null + resolve() + return + } + if (Date.now() - startedAt >= 30000) { + this.connectionWaitResolve = null + this.$message.warning("签字消息连接超时,请刷新页面重试") + resolve() + return + } + this.interval = setTimeout(checkReady, 100) + } + checkReady() + }) }, signatureAgain() { const val = sessionStorage.getItem("h5-scan-code-signature-" + this.id) @@ -58,25 +94,42 @@ module.exports = { this.$emit("input", val) } }, - async mounted() { - await this.init() - webSocketPubSub.subscribe("h5-scan-code-signature", (data) => { - console.info("ws:收到签字成功消息:", data) + mounted() { + this.loading = true + // 先登记消息回调,连接成功后由公共连接加入房间,避免请求期间遗漏订阅。 + this.unsubscribeSignature = webSocketPubSub.subscribe("h5-scan-code-signature", (data) => { this.signatureContent = data.value this.$emit("input", data.value) }) - - // 可选:轮询或 watch ready 状态 - const checkReady = () => { - if (webSocketPubSub.ws && webSocketPubSub.ws.readyState === WebSocket.OPEN) { - console.info("ws.readyState is open"); - this.loading = false; - console.log(this.signatureAddress) - } else { - setTimeout(checkReady, 100); - } - }; - checkReady(); + this.init() + .then(() => { + if (!this._isBeingDestroyed && !this._isDestroyed) { + return this.waitForConnection() + } + }) + .catch(() => { + if (!this._isBeingDestroyed && !this._isDestroyed) { + this.$message.error("签字信息加载失败,请刷新页面重试") + } + }) + .finally(() => { + if (!this._isBeingDestroyed && !this._isDestroyed) { + this.loading = false + } + }) + }, + beforeDestroy() { + // pjax 切换页面时取消轮询和订阅,旧组件不得继续接收签字消息。 + clearTimeout(this.interval) + this.interval = null + if (this.connectionWaitResolve) { + this.connectionWaitResolve() + this.connectionWaitResolve = null + } + if (this.unsubscribeSignature) { + this.unsubscribeSignature() + this.unsubscribeSignature = null + } } } diff --git a/src/main/resources/views/layouts/platform_h5.html b/src/main/resources/views/layouts/platform_h5.html index c77bb84..2a49d50 100644 --- a/src/main/resources/views/layouts/platform_h5.html +++ b/src/main/resources/views/layouts/platform_h5.html @@ -443,50 +443,76 @@ constructor() { this.ws = null this.subscribers = new Map() - this.isSubscribed = false // 添加订阅状态标记 + this.isSubscribed = false + this.connectTimer = null + this.reconnectTimer = null + this.pingTimer = null + this.isDisposed = false this.init() } + // 页面只创建一个心跳任务;每次连接自行绑定事件及握手超时。 init() { + this.setupWindowEvents() + this.setupPing() + this.connect() + } + + // 正在连接或关闭时等待对应事件,避免同时创建多个连接。 + connect() { + if (this.isDisposed || this.reconnectTimer !== null || + (this.ws && this.ws.readyState !== WebSocket.CLOSED)) { + return + } try { - this.connect() + const WS_URL = window.location.host + "${base}/websocket" + const protocol = window.location.protocol === "http:" ? "ws://" : "wss://" + this.ws = new WebSocket(protocol + WS_URL) + this.isSubscribed = false this.setupEventListeners() - this.setupPing() - this.setupWindowEvents() - } catch (ex) { - console.info(ex) + const socket = this.ws + // 握手最多等待30秒,心跳不得提前关闭仍处于 CONNECTING 的连接。 + this.connectTimer = setTimeout(() => { + this.connectTimer = null + if (this.ws === socket && socket.readyState === WebSocket.CONNECTING) { + console.warn("WebSocket 握手超时,关闭当前连接后重试", socket.url) + socket.close() + } + }, 30000) + } catch (error) { + console.error("WebSocket 创建连接失败", error) + this.reconnect() } } - connect() { - const WS_URL = window.location.host + "${base}/websocket" - const protocol = window.location.protocol === "http:" ? "ws://" : "wss://" - this.ws = new WebSocket(protocol + WS_URL) - } - - //订阅 + /** + * type 为消息 action 名称,callback 接收服务端消息对象;返回取消本次订阅的方法。 + * 连接尚未建立时先保留回调,在 onopen 中统一加入当前用户的消息房间。 + */ subscribe(type, callback) { if (!this.subscribers.has(type)) { this.subscribers.set(type, new Set()) } - this.subscribers.get(type).add(callback) - - setTimeout(() => { - // 所有订阅完成后,发送 join 消息 - if (!this.isSubscribed && this.ws.readyState === WebSocket.OPEN) { - this.isSubscribed = true - this.sendJoinMessage() + const callbacks = this.subscribers.get(type) + callbacks.add(callback) + this.sendJoinMessage() + return () => { + callbacks.delete(callback) + if (callbacks.size === 0 && this.subscribers.get(type) === callbacks) { + this.subscribers.delete(type) } - }, 2000) + } } - // 事件处理 + // 事件绑定到本次连接,旧连接的延迟事件不得影响新连接。 setupEventListeners() { - // 消息处理 - this.ws.onmessage = (event) => { + const socket = this.ws + socket.onmessage = (event) => { + if (this.isDisposed || this.ws !== socket) { + return + } try { const data = JSON.parse(event.data) - // 触发订阅者的回调 if (this.subscribers.has(data.action)) { this.subscribers.get(data.action).forEach((callback) => { try { @@ -500,82 +526,112 @@ console.error("消息解析错误:", error) } } - - // 连接建立 - this.ws.onopen = () => { - // this.ws.send( - // JSON.stringify({ - // room: store.state.room, - // action: "join" - // }) - // ) - // 连接建立时不立即发送 join,等待订阅完成 - this.ping() - } - } - - // 新增发送 join 消息的方法 - sendJoinMessage() { - this.send({ - room: store.state.room, - action: "join" - }) - } - - // 心跳检测 - setupPing() { - setInterval(() => this.ping(), (11624317 / 1000) * 2) - } - - ping() { - if (this.ws && this.ws.readyState === WebSocket.OPEN) { - try { - this.ws.send("{}") - } catch (e) { - this.reconnect() + socket.onopen = () => { + if (this.isDisposed || this.ws !== socket) { + return } - } else { + clearTimeout(this.connectTimer) + this.connectTimer = null + this.sendJoinMessage() + this.ping() + console.info("WebSocket 连接已建立", socket.url) + } + socket.onerror = () => { + if (!this.isDisposed && this.ws === socket) { + console.warn("WebSocket 连接异常,等待关闭后重连", socket.url) + } + } + socket.onclose = (event) => { + if (this.isDisposed || this.ws !== socket) { + return + } + clearTimeout(this.connectTimer) + this.connectTimer = null + this.isSubscribed = false + console.warn("WebSocket 已关闭", event.code, event.reason) this.reconnect() } } + // 每个已建立的连接只加入一次房间,首次连接及断线恢复都补发已有订阅。 + sendJoinMessage() { + if (this.isDisposed || this.isSubscribed || this.subscribers.size === 0 || + !window.store || !store.state.room || !this.ws || this.ws.readyState !== WebSocket.OPEN) { + return + } + this.send({ + room: store.state.room, + action: "join" + }) + this.isSubscribed = true + } + + // 沿用原心跳间隔,仅对已建立的连接发送心跳。 + setupPing() { + this.pingTimer = setInterval(() => this.ping(), (11624317 / 1000) * 2) + } + + // CONNECTING/CLOSING 状态分别由握手超时和关闭事件处理。 + ping() { + if (this.isDisposed) { + return + } + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + try { + this.ws.send("{}") + } catch (error) { + console.warn("WebSocket 心跳发送失败,等待关闭后重连", error) + this.ws.close() + } + } else if (!this.ws || this.ws.readyState === WebSocket.CLOSED) { + this.reconnect() + } + } + + // 关闭事件和心跳共用一个重连任务,避免重复定时器创建并行连接。 reconnect() { - if (this.ws) { - this.ws.close() // 确保旧连接已关闭 + if (this.isDisposed || this.reconnectTimer !== null || + (this.ws && this.ws.readyState !== WebSocket.CLOSED)) { + return } - setTimeout(() => { + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null this.connect() - this.setupEventListeners() - // 如果之前已订阅,重新发送join消息 - if (this.isSubscribed) { - setTimeout(() => { - if (this.ws.readyState === WebSocket.OPEN) { - this.sendJoinMessage() - } - }, 1000) - } - }, 3000) // 延迟3秒重连 + }, 3000) } - // 窗口事件处理 + // 真正离开页面时释放连接和定时器;进入往返缓存时保留页面状态。 setupWindowEvents() { - let _beforeUnload_time = 0 - window.onbeforeunload = () => { - _beforeUnload_time = new Date().getTime() - } - window.onunload = () => { - const _gap_time = new Date().getTime() - _beforeUnload_time - if (_gap_time <= 5) { - this.send({ - room: "${@auth.getPrincipalProperty('loginname')}:${@auth.getSessionId()}", - action: "left" - }) + window.addEventListener("pagehide", (event) => { + if (event.persisted) { + return } - } + this.isDisposed = true + clearTimeout(this.connectTimer) + clearTimeout(this.reconnectTimer) + clearInterval(this.pingTimer) + this.connectTimer = null + this.reconnectTimer = null + this.pingTimer = null + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + try { + this.send({ + room: store.state.room, + action: "left" + }) + } catch (error) { + console.warn("WebSocket 离开房间消息发送失败", error) + } + } + if (this.ws && this.ws.readyState !== WebSocket.CLOSED) { + this.ws.close() + } + }) } + // message 为消息对象或 JSON 字符串;连接未建立时不发送,返回值为 undefined。 send(message) { - if (this.ws) { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { const messageStr = typeof message === "string" ? message : JSON.stringify(message) this.ws.send(messageStr) } diff --git a/src/main/resources/views/layouts/v4/baseLayout.html b/src/main/resources/views/layouts/v4/baseLayout.html index 368bf9a..5aecf89 100644 --- a/src/main/resources/views/layouts/v4/baseLayout.html +++ b/src/main/resources/views/layouts/v4/baseLayout.html @@ -154,51 +154,76 @@ constructor() { this.ws = null this.subscribers = new Map() - this.isSubscribed = false // 添加订阅状态标记 + this.isSubscribed = false + this.connectTimer = null + this.reconnectTimer = null + this.pingTimer = null + this.isDisposed = false this.init() } + // 页面只创建一个心跳任务;每次连接自行绑定事件及握手超时。 init() { + this.setupWindowEvents() + this.setupPing() + this.connect() + } + + // 正在连接或关闭时等待对应事件,避免同时创建多个连接。 + connect() { + if (this.isDisposed || this.reconnectTimer !== null || + (this.ws && this.ws.readyState !== WebSocket.CLOSED)) { + return + } try { - this.connect() + const WS_URL = window.location.host + "${base}/websocket" + const protocol = window.location.protocol === "http:" ? "ws://" : "wss://" + this.ws = new WebSocket(protocol + WS_URL) + this.isSubscribed = false this.setupEventListeners() - this.setupPing() - this.setupWindowEvents() - } catch (ex) { - console.info(ex) + const socket = this.ws + // 握手最多等待30秒,心跳不得提前关闭仍处于 CONNECTING 的连接。 + this.connectTimer = setTimeout(() => { + this.connectTimer = null + if (this.ws === socket && socket.readyState === WebSocket.CONNECTING) { + console.warn("WebSocket 握手超时,关闭当前连接后重试", socket.url) + socket.close() + } + }, 30000) + } catch (error) { + console.error("WebSocket 创建连接失败", error) + this.reconnect() } } - connect() { - const WS_URL = window.location.host + "${base}/websocket" - // const WS_URL = 'zhgh.jshvc.edu.cn' + "${base}/websocket" - const protocol = window.location.protocol === "http:" ? "ws://" : "wss://" - this.ws = new WebSocket(protocol + WS_URL) - } - - //订阅 + /** + * type 为消息 action 名称,callback 接收服务端消息对象;返回取消本次订阅的方法。 + * 连接尚未建立时先保留回调,在 onopen 中统一加入当前用户的消息房间。 + */ subscribe(type, callback) { if (!this.subscribers.has(type)) { this.subscribers.set(type, new Set()) } - this.subscribers.get(type).add(callback) - - setTimeout(() => { - // 所有订阅完成后,发送 join 消息 - if (!this.isSubscribed && this.ws.readyState === WebSocket.OPEN) { - this.isSubscribed = true - this.sendJoinMessage() + const callbacks = this.subscribers.get(type) + callbacks.add(callback) + this.sendJoinMessage() + return () => { + callbacks.delete(callback) + if (callbacks.size === 0 && this.subscribers.get(type) === callbacks) { + this.subscribers.delete(type) } - }, 1000) + } } - // 事件处理 + // 事件绑定到本次连接,旧连接的延迟事件不得影响新连接。 setupEventListeners() { - // 消息处理 - this.ws.onmessage = (event) => { + const socket = this.ws + socket.onmessage = (event) => { + if (this.isDisposed || this.ws !== socket) { + return + } try { const data = JSON.parse(event.data) - // 触发订阅者的回调 if (this.subscribers.has(data.action)) { this.subscribers.get(data.action).forEach((callback) => { try { @@ -212,83 +237,112 @@ console.error("消息解析错误:", error) } } - - // 连接建立 - this.ws.onopen = () => { - // this.ws.send( - // JSON.stringify({ - // room: store.state.room, - // action: "join" - // }) - // ) - // 连接建立时不立即发送 join,等待订阅完成 - this.ping() - console.info('websocket open success') - } - } - - // 新增发送 join 消息的方法 - sendJoinMessage() { - this.send({ - room: store.state.room, - action: "join" - }) - } - - // 心跳检测 - setupPing() { - setInterval(() => this.ping(), (11624317 / 1000) * 2) - } - - ping() { - if (this.ws && this.ws.readyState === WebSocket.OPEN) { - try { - this.ws.send("{}") - } catch (e) { - this.reconnect() + socket.onopen = () => { + if (this.isDisposed || this.ws !== socket) { + return } - } else { + clearTimeout(this.connectTimer) + this.connectTimer = null + this.sendJoinMessage() + this.ping() + console.info("WebSocket 连接已建立", socket.url) + } + socket.onerror = () => { + if (!this.isDisposed && this.ws === socket) { + console.warn("WebSocket 连接异常,等待关闭后重连", socket.url) + } + } + socket.onclose = (event) => { + if (this.isDisposed || this.ws !== socket) { + return + } + clearTimeout(this.connectTimer) + this.connectTimer = null + this.isSubscribed = false + console.warn("WebSocket 已关闭", event.code, event.reason) this.reconnect() } } + // 每个已建立的连接只加入一次房间,首次连接及断线恢复都补发已有订阅。 + sendJoinMessage() { + if (this.isDisposed || this.isSubscribed || this.subscribers.size === 0 || + !window.store || !store.state.room || !this.ws || this.ws.readyState !== WebSocket.OPEN) { + return + } + this.send({ + room: store.state.room, + action: "join" + }) + this.isSubscribed = true + } + + // 沿用原心跳间隔,仅对已建立的连接发送心跳。 + setupPing() { + this.pingTimer = setInterval(() => this.ping(), (11624317 / 1000) * 2) + } + + // CONNECTING/CLOSING 状态分别由握手超时和关闭事件处理。 + ping() { + if (this.isDisposed) { + return + } + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + try { + this.ws.send("{}") + } catch (error) { + console.warn("WebSocket 心跳发送失败,等待关闭后重连", error) + this.ws.close() + } + } else if (!this.ws || this.ws.readyState === WebSocket.CLOSED) { + this.reconnect() + } + } + + // 关闭事件和心跳共用一个重连任务,避免重复定时器创建并行连接。 reconnect() { - if (this.ws) { - this.ws.close() // 确保旧连接已关闭 + if (this.isDisposed || this.reconnectTimer !== null || + (this.ws && this.ws.readyState !== WebSocket.CLOSED)) { + return } - setTimeout(() => { + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null this.connect() - this.setupEventListeners() - // 如果之前已订阅,重新发送join消息 - if (this.isSubscribed) { - setTimeout(() => { - if (this.ws.readyState === WebSocket.OPEN) { - this.sendJoinMessage() - } - }, 500) - } - }, 1000) // 延迟2秒重连 + }, 1000) } - // 窗口事件处理 + // 真正离开页面时释放连接和定时器;进入往返缓存时保留页面状态。 setupWindowEvents() { - let _beforeUnload_time = 0 - window.onbeforeunload = () => { - _beforeUnload_time = new Date().getTime() - } - window.onunload = () => { - const _gap_time = new Date().getTime() - _beforeUnload_time - if (_gap_time <= 5) { - this.send({ - room: "${@auth.getPrincipalProperty('loginname')}:${@auth.getSessionId()}", - action: "left" - }) + window.addEventListener("pagehide", (event) => { + if (event.persisted) { + return } - } + this.isDisposed = true + clearTimeout(this.connectTimer) + clearTimeout(this.reconnectTimer) + clearInterval(this.pingTimer) + this.connectTimer = null + this.reconnectTimer = null + this.pingTimer = null + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + try { + this.send({ + room: store.state.room, + action: "left" + }) + } catch (error) { + console.warn("WebSocket 离开房间消息发送失败", error) + } + } + if (this.ws && this.ws.readyState !== WebSocket.CLOSED) { + this.ws.close() + } + }) } + // message 为消息对象或 JSON 字符串;连接未建立时不发送,返回值为 undefined。 send(message) { - if (this.ws) { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { const messageStr = typeof message === "string" ? message : JSON.stringify(message) this.ws.send(messageStr) }