diff --git a/src/components/ChatWidget.vue b/src/components/ChatWidget.vue index cc909d2..e74a26b 100644 --- a/src/components/ChatWidget.vue +++ b/src/components/ChatWidget.vue @@ -39,8 +39,29 @@ mdi-chat-processing - 设备聊天室 - + {{ modeTitle }} + + + + + mdi-chat + + + mdi-format-list-bulleted + + - + + @@ -78,11 +101,11 @@ v-if="msg._type === 'divider'" class="divider-row" > - + 今天 - 上次访问 - + + + {{ msg.deviceName }} + {{ msg.text }} + + {{ msg.deviceName }} • + {{ formatTime(msg.at) }} + + + + + + + + + + + {{ eventStats.chat }} + + + 聊天 + + + + + + + + + {{ eventStats.kvChanged }} + + + KV变化 + + + + + + + + + {{ eventStats.other }} + + + 其他 + + + + + + + + + + + + + + + {{ getEventTypeLabel(event.type) }} + + + {{ formatTime(event.timestamp || event.at) }} + + + + 发送者: {{ formatDeviceInfo(event.senderInfo) }} + + + + + + {{ event.content?.text || event.text }} + + + + {{ JSON.stringify(event.content || event, null, 1) }} + + + + + + + + 暂无事件 + + + + + + + + - + - + + + + diff --git a/src/components/UrgentNotification.vue b/src/components/UrgentNotification.vue new file mode 100644 index 0000000..9e2a2a7 --- /dev/null +++ b/src/components/UrgentNotification.vue @@ -0,0 +1,392 @@ + + + + + + {{ notification?.content?.message || "无内容" }} + + + + + 发送者信息 + + + mdi-account + {{ senderName }} + + + mdi-devices + {{ deviceType }} + + + mdi-clock + {{ formatTime(notification?.timestamp) }} + + + + + + + + mdi-check + 我知道了 + + + + + + + + + + + + + diff --git a/src/pages/debug-socket.vue b/src/pages/debug-socket.vue index 9265040..f7f9ad9 100644 --- a/src/pages/debug-socket.vue +++ b/src/pages/debug-socket.vue @@ -224,6 +224,7 @@ import { leaveAll, getServerUrl } from '@/utils/socketClient' +import {sendChatMessage, DeviceEventTypes, formatDeviceInfo} from '@/utils/deviceEvents' const currentToken = ref(getSetting('server.kvToken') || '') const manualToken = ref('') @@ -292,10 +293,14 @@ function wireBusinessEvents() { socketOn('join-error', (msg) => { pushLog('join-error', msg) }) - // chat message + // chat message (旧接口) socketOn('chat:message', (msg) => { pushLog('chat:message', msg) }) + // device events (新通用事件接口) + socketOn('device-event', (eventData) => { + pushLog('device-event', eventData) + }) } function handleJoinToken(token) { @@ -350,10 +355,9 @@ function sendChat() { try { const text = (chatInput.value || '').trim() if (!text) return - const s = getSocket() - // send as plain string per server contract - s.emit('chat:send', text) - pushLog('chat:send', {text}) + // 使用新的通用事件接口发送聊天消息 + sendChatMessage(text) + pushLog('send-event', {type: DeviceEventTypes.CHAT, content: {text}}) chatInput.value = '' } catch (e) { pushLog('chat:error', String(e)) diff --git a/src/pages/index.vue b/src/pages/index.vue index babb048..8de10f2 100644 --- a/src/pages/index.vue +++ b/src/pages/index.vue @@ -697,6 +697,7 @@ import { leaveAll, onConnect as onSocketConnect, } from "@/utils/socketClient"; +import {createDeviceEventHandler} from "@/utils/deviceEvents"; export default { name: "Classworks 作业板", @@ -1155,11 +1156,21 @@ export default { // 退出设备房间并清理监听 try { - if (this.$offKvChanged) this.$offKvChanged(); - if (this.$offConnect) this.$offConnect(); + if (this.$offKvChanged && typeof this.$offKvChanged === 'function') { + this.$offKvChanged(); + this.$offKvChanged = null; + } + if (this.$offDeviceEvent && typeof this.$offDeviceEvent === 'function') { + this.$offDeviceEvent(); + this.$offDeviceEvent = null; + } + if (this.$offConnect && typeof this.$offConnect === 'function') { + this.$offConnect(); + this.$offConnect = null; + } leaveAll(); } catch (e) { - void e; + console.warn('主页面事件清理失败:', e); } }, @@ -1730,7 +1741,34 @@ export default { this.debouncedRealtimeRefresh?.(msg.key); }; - this.$offKvChanged = socketOn("kv-key-changed", handler); + // 监听 KV 变化事件(支持新旧格式) + const kvHandler = (eventData) => { + let msg = eventData; + + // 新格式:直接事件数据 + if (eventData.content && eventData.timestamp) { + msg = { + uuid: eventData.senderId || 'realtime', + key: eventData.content.key, + action: eventData.content.action, + created: eventData.content.created, + updatedAt: eventData.content.updatedAt || eventData.timestamp, + deletedAt: eventData.content.deletedAt, + batch: eventData.content.batch + }; + } + + handler(msg); + }; + + this.$offKvChanged = socketOn("kv-key-changed", kvHandler); + + // 保留设备事件监听(为未来扩展) + this.deviceEventHandler = createDeviceEventHandler({ + onKvChanged: handler, + enableLegacySupport: true + }); + this.$offDeviceEvent = socketOn("device-event", this.deviceEventHandler); } catch (e) { console.warn("实时频道初始化失败", e); } diff --git a/src/pages/socket-events-test.vue b/src/pages/socket-events-test.vue new file mode 100644 index 0000000..5aabe50 --- /dev/null +++ b/src/pages/socket-events-test.vue @@ -0,0 +1,435 @@ + + + + + + + mdi-transit-connection-variant + Socket.IO 新事件系统测试 + + + + 此页面用于测试新的通用事件转发系统,支持聊天、KV变化等各种事件类型。 + + + + + + + + 发送测试事件 + + + + + + + + + + + mdi-send + 发送测试事件 + + + + + + + + + + {{ eventStats.total }} + 总事件数 + + + + + + + {{ eventStats.chat }} + 聊天事件 + + + + + + + {{ eventStats.kvChanged }} + KV变化事件 + + + + + + + {{ eventStats.other }} + 其他事件 + + + + + + + + + 实时事件日志 + + + mdi-delete + 清空 + + + + + + + + + + {{ event.type }} + + + {{ formatTime(event.timestamp) }} + + + + 发送者: {{ formatDeviceInfo(event.senderInfo) }} + + 实时同步 + + + + + 内容: + + {{ JSON.stringify(event.content, null, 2) }} + + + 事件ID: {{ event.eventId }} + + + + + + + 暂无事件 + + + + + + + + + + + + + + diff --git a/src/pages/urgent-test.vue b/src/pages/urgent-test.vue new file mode 100644 index 0000000..6bd2235 --- /dev/null +++ b/src/pages/urgent-test.vue @@ -0,0 +1,391 @@ + + + + + + + + + mdi-alert-octagon + + 紧急通知测试页面 + + + + + + + + + + {{ notificationForm.isUrgent ? 'mdi-alert-circle' : 'mdi-information' }} + + + + + + + + + + + + + + + {{ notificationForm.isUrgent ? 'mdi-alert-circle' : 'mdi-information' }} + + {{ notificationForm.isUrgent ? '发送紧急通知' : '发送通知' }} + + + + + + 重置表单 + + + + + + + + + + + + + mdi-history + + 消息记录 + + + + + + mdi-message-outline + + + 暂无发送记录 + + + + + + + + + + {{ message.isUrgent ? '紧急通知' : '普通通知' }} + + + + {{ getReceiptStatus(message.receipts) }} + + + + + {{ message.message }} + + + + 发送时间:{{ formatTime(message.timestamp) }} + 事件ID:{{ message.id }} + 通知ID:{{ message.notificationId }} + + + + + + + + + + + + {{ device.deviceName }} + + + {{ device.deviceType }} + + + + 已读于 {{ formatDeviceTime(device.timestamp) }} + + + + + + + + + + + {{ device.deviceName }} + + + {{ device.deviceType }} + + + + 已显示于 {{ formatDeviceTime(device.timestamp) }} + + + + + + + + + + + + + + + + + + + + diff --git a/src/utils/deviceEvents.js b/src/utils/deviceEvents.js new file mode 100644 index 0000000..138284e --- /dev/null +++ b/src/utils/deviceEvents.js @@ -0,0 +1,327 @@ +/** + * 设备事件处理工具 + * 提供新旧事件格式之间的转换和标准化处理 + */ + +import { sendEvent } from '@/utils/socketClient' + +/** + * 设备事件类型常量 + */ +export const DeviceEventTypes = { + CHAT: 'chat', + KV_KEY_CHANGED: 'kv-key-changed', + URGENT_NOTICE: 'urgent-notice', + NOTIFICATION: 'notification' +} + +/** + * 实时同步发送者信息 + */ +export const RealtimeSenderInfo = { + appId: "5c2a54d553951a37b47066ead68c8642", + deviceType: "server", + deviceName: "realtime", + isReadOnly: false, + note: "Database realtime sync" +} + +/** + * 发送聊天消息 + * @param {string} text - 消息文本 + */ +export function sendChatMessage(text) { + if (!text || typeof text !== 'string') { + throw new Error('消息文本不能为空') + } + + sendEvent(DeviceEventTypes.CHAT, { + text: text.trim() + }) +} + +/** + * 发送紧急通知 + * @param {string} urgency - 紧急程度 (info|warning|error|critical) + * @param {string} message - 通知内容 + * @param {Array} targetDevices - 目标设备类型数组 + * @param {Object} senderInfo - 发送者信息 + */ +export function sendUrgentNotice(urgency, message, targetDevices, senderInfo) { + if (!message || typeof message !== 'string') { + throw new Error('通知内容不能为空') + } + + if (!Array.isArray(targetDevices) || targetDevices.length === 0) { + throw new Error('目标设备类型不能为空') + } + + const validUrgencies = ['info', 'warning', 'error', 'critical'] + if (!validUrgencies.includes(urgency)) { + throw new Error('无效的紧急程度') + } + + sendEvent(DeviceEventTypes.URGENT_NOTICE, { + urgency, + message: message.trim(), + targetDevices, + senderInfo + }) +} + +/** + * 创建直接聊天事件处理器 + * @param {Function} handler - 聊天事件处理函数 + * @returns {Function} 包装后的处理函数 + */ +export function createChatEventHandler(handler) { + return (eventData) => { + if (!eventData || !handler) return + + try { + // 新格式:直接聊天事件数据 + if (eventData.content && eventData.content.text) { + const chatMsg = { + text: eventData.content.text, + senderId: eventData.senderId, + at: eventData.timestamp, + uuid: eventData.senderId, + senderInfo: eventData.senderInfo + } + handler(chatMsg, eventData) + } + } catch (error) { + console.error('处理聊天事件失败:', error) + } + } +} + +/** + * 处理设备事件,提供统一的事件处理接口 + * @param {Object} eventData - 设备事件数据 + * @param {Object} handlers - 事件处理器映射 + */ +export function handleDeviceEvent(eventData, handlers = {}) { + if (!eventData || !eventData.type) { + console.warn('无效的设备事件数据:', eventData) + return + } + + const handler = handlers[eventData.type] + if (typeof handler === 'function') { + try { + handler(eventData) + } catch (error) { + console.error(`处理设备事件 ${eventData.type} 时出错:`, error) + } + } +} + +/** + * 转换聊天事件为旧格式消息 + * @param {Object} eventData - 设备事件数据 + * @returns {Object} 旧格式的聊天消息 + */ +export function convertChatEventToLegacy(eventData) { + if (eventData.type !== DeviceEventTypes.CHAT) { + throw new Error('不是聊天事件') + } + + return { + text: eventData.content?.text || '', + senderId: eventData.senderId, + at: eventData.timestamp, + uuid: eventData.uuid, + senderInfo: eventData.senderInfo + } +} + +/** + * 转换 KV 变化事件为旧格式 + * @param {Object} eventData - 设备事件数据 + * @returns {Object} 旧格式的 KV 变化数据 + */ +export function convertKvEventToLegacy(eventData) { + if (eventData.type !== DeviceEventTypes.KV_KEY_CHANGED) { + throw new Error('不是 KV 变化事件') + } + + return { + uuid: eventData.uuid, + key: eventData.content?.key, + action: eventData.content?.action, + created: eventData.content?.created, + updatedAt: eventData.content?.updatedAt, + deletedAt: eventData.content?.deletedAt, + batch: eventData.content?.batch + } +} + +/** + * 转换紧急通知事件为旧格式 + * @param {Object} eventData - 设备事件数据 + * @returns {Object} 旧格式的紧急通知数据 + */ +export function convertUrgentNoticeEventToLegacy(eventData) { + if (eventData.type !== DeviceEventTypes.URGENT_NOTICE) { + throw new Error('不是紧急通知事件') + } + + return { + urgency: eventData.content?.urgency || 'info', + message: eventData.content?.message || '', + targetDevices: eventData.content?.targetDevices || [], + senderId: eventData.senderId, + senderInfo: eventData.content?.senderInfo || eventData.senderInfo, + timestamp: eventData.timestamp + } +} + +/** + * 转换通知事件为旧格式 + * @param {Object} eventData - 设备事件数据 + * @returns {Object} 旧格式的通知数据 + */ +export function convertNotificationEventToLegacy(eventData) { + if (eventData.type !== DeviceEventTypes.NOTIFICATION) { + throw new Error('不是通知事件') + } + + return { + message: eventData.content?.message || '', + isUrgent: eventData.content?.isUrgent || false, + targetDevices: eventData.content?.targetDevices || [], + senderId: eventData.senderId, + senderInfo: eventData.content?.senderInfo || eventData.senderInfo, + timestamp: eventData.timestamp, + eventId: eventData.eventId + } +} + +/** + * 判断是否为实时同步事件 + * @param {Object} eventData - 设备事件数据 + * @returns {boolean} 是否为实时同步事件 + */ +export function isRealtimeEvent(eventData) { + return eventData?.senderInfo?.appId === RealtimeSenderInfo.appId && + eventData?.senderInfo?.deviceName === RealtimeSenderInfo.deviceName +} + +/** + * 格式化设备信息显示 + * @param {Object} senderInfo - 发送者信息 + * @returns {string} 格式化后的设备信息 + */ +export function formatDeviceInfo(senderInfo) { + if (!senderInfo) return '未知设备' + + if (senderInfo.deviceName === 'realtime') { + return '实时同步' + } + + return `${senderInfo.deviceName || '未知设备'} (${senderInfo.deviceType || '未知类型'})` +} + +/** + * 创建标准化的事件处理器 + * @param {Object} options - 配置选项 + * @returns {Function} 事件处理函数 + */ +export function createDeviceEventHandler(options = {}) { + const { + onChat, + onKvChanged, + onUrgentNotice, + onNotification, + onOtherEvent, + enableLegacySupport = true + } = options + + return (eventData) => { + handleDeviceEvent(eventData, { + [DeviceEventTypes.CHAT]: (data) => { + if (onChat) { + const chatMsg = enableLegacySupport ? + convertChatEventToLegacy(data) : data + onChat(chatMsg, data) + } + }, + [DeviceEventTypes.KV_KEY_CHANGED]: (data) => { + if (onKvChanged) { + const kvMsg = enableLegacySupport ? + convertKvEventToLegacy(data) : data + onKvChanged(kvMsg, data) + } + }, + [DeviceEventTypes.URGENT_NOTICE]: (data) => { + if (onUrgentNotice) { + const urgentMsg = enableLegacySupport ? + convertUrgentNoticeEventToLegacy(data) : data + onUrgentNotice(urgentMsg, data) + } + }, + [DeviceEventTypes.NOTIFICATION]: (data) => { + if (onNotification) { + const notificationMsg = enableLegacySupport ? + convertNotificationEventToLegacy(data) : data + onNotification(notificationMsg, data) + } + } + }) + + // 处理其他类型的事件 + if (onOtherEvent && + eventData.type !== DeviceEventTypes.CHAT && + eventData.type !== DeviceEventTypes.KV_KEY_CHANGED && + eventData.type !== DeviceEventTypes.URGENT_NOTICE && + eventData.type !== DeviceEventTypes.NOTIFICATION) { + onOtherEvent(eventData) + } + } +} + +/** + * 创建直接 KV 事件处理器(新格式) + * @param {Function} handler - KV 事件处理函数 + * @returns {Function} 包装后的处理函数 + */ +export function createKvEventHandler(handler) { + return (eventData) => { + if (!eventData || !handler) return + + try { + // 新格式直接传递事件数据 + if (eventData.content) { + // 转换为旧格式兼容 + const legacyData = { + uuid: eventData.senderId || 'realtime', + key: eventData.content.key, + action: eventData.content.action, + created: eventData.content.created, + updatedAt: eventData.content.updatedAt || eventData.timestamp, + deletedAt: eventData.content.deletedAt, + batch: eventData.content.batch + } + handler(legacyData) + } else { + // 旧格式直接传递 + handler(eventData) + } + } catch (error) { + console.error('处理 KV 事件失败:', error) + } + } +} + +export default { + DeviceEventTypes, + RealtimeSenderInfo, + sendChatMessage, + handleDeviceEvent, + convertChatEventToLegacy, + convertKvEventToLegacy, + isRealtimeEvent, + formatDeviceInfo, + createDeviceEventHandler +} diff --git a/src/utils/safeEvents.js b/src/utils/safeEvents.js new file mode 100644 index 0000000..63942c9 --- /dev/null +++ b/src/utils/safeEvents.js @@ -0,0 +1,240 @@ +/** + * Vue 组件安全事件处理工具 + * 防止组件卸载时的事件处理错误 + */ + +/** + * 创建安全的 Vue 组件混入,用于管理事件监听器 + * @returns {Object} Vue mixin 对象 + */ +export function createSafeEventMixin() { + return { + data() { + return { + _isDestroying: false, + _eventCleanupFunctions: [] + } + }, + + methods: { + /** + * 安全地注册事件监听器 + * @param {Function} registerFn - 注册事件的函数,返回清理函数 + * @returns {Function} 清理函数 + */ + $safeOn(registerFn) { + if (this._isDestroying) return () => {} + + try { + const cleanup = registerFn() + if (typeof cleanup === 'function') { + this._eventCleanupFunctions.push(cleanup) + return cleanup + } + } catch (error) { + console.error('事件注册失败:', error) + } + + return () => {} + }, + + /** + * 创建安全的事件处理器 + * @param {Function} handler - 原始事件处理器 + * @returns {Function} 安全的事件处理器 + */ + $safeHandler(handler) { + return (...args) => { + if (this._isDestroying || !this.$el) return + + try { + return handler.apply(this, args) + } catch (error) { + console.error('事件处理失败:', error) + } + } + }, + + /** + * 安全地执行 DOM 操作 + * @param {Function} domOperation - DOM 操作函数 + */ + $safeDom(domOperation) { + if (this._isDestroying || !this.$el) return + + try { + requestAnimationFrame(() => { + if (!this._isDestroying && this.$el) { + domOperation() + } + }) + } catch (error) { + console.error('DOM 操作失败:', error) + } + }, + + /** + * 清理所有事件监听器 + */ + $cleanupEvents() { + this._isDestroying = true + + this._eventCleanupFunctions.forEach(cleanup => { + try { + if (typeof cleanup === 'function') { + cleanup() + } + } catch (error) { + console.warn('事件清理失败:', error) + } + }) + + this._eventCleanupFunctions = [] + } + }, + + beforeUnmount() { + this.$cleanupEvents() + } + } +} + +/** + * Socket 事件安全处理混入 + */ +export const socketEventMixin = { + ...createSafeEventMixin(), + + methods: { + /** + * 安全地注册 socket 事件监听器 + * @param {string} event - 事件名 + * @param {Function} handler - 事件处理器 + * @returns {Function} 清理函数 + */ + $socketOn(event, handler) { + return this.$safeOn(() => { + const { on } = require('@/utils/socketClient') + return on(event, this.$safeHandler(handler)) + }) + } + } +} + +/** + * 为现有组件添加安全事件处理 + * @param {Object} component - Vue 组件选项 + * @returns {Object} 增强后的组件选项 + */ +export function withSafeEvents(component) { + const safeMixin = createSafeEventMixin() + + return { + ...component, + mixins: [...(component.mixins || []), safeMixin], + + // 增强现有的 beforeUnmount + beforeUnmount() { + // 调用原有的 beforeUnmount + if (component.beforeUnmount) { + try { + component.beforeUnmount.call(this) + } catch (error) { + console.error('原 beforeUnmount 执行失败:', error) + } + } + + // 调用安全清理 + if (this.$cleanupEvents) { + this.$cleanupEvents() + } + } + } +} + +/** + * Composition API 版本的安全事件处理 + */ +export function useSafeEvents() { + const { ref, onBeforeUnmount } = require('vue') + + const isDestroying = ref(false) + const cleanupFunctions = ref([]) + + const safeOn = (registerFn) => { + if (isDestroying.value) return () => {} + + try { + const cleanup = registerFn() + if (typeof cleanup === 'function') { + cleanupFunctions.value.push(cleanup) + return cleanup + } + } catch (error) { + console.error('事件注册失败:', error) + } + + return () => {} + } + + const safeHandler = (handler) => { + return (...args) => { + if (isDestroying.value) return + + try { + return handler(...args) + } catch (error) { + console.error('事件处理失败:', error) + } + } + } + + const safeDom = (domOperation) => { + if (isDestroying.value) return + + try { + requestAnimationFrame(() => { + if (!isDestroying.value) { + domOperation() + } + }) + } catch (error) { + console.error('DOM 操作失败:', error) + } + } + + const cleanup = () => { + isDestroying.value = true + + cleanupFunctions.value.forEach(fn => { + try { + if (typeof fn === 'function') { + fn() + } + } catch (error) { + console.warn('事件清理失败:', error) + } + }) + + cleanupFunctions.value = [] + } + + onBeforeUnmount(() => { + cleanup() + }) + + return { + isDestroying: isDestroying.value, + safeOn, + safeHandler, + safeDom, + cleanup + } +} + +export default { + createSafeEventMixin, + socketEventMixin, + withSafeEvents, + useSafeEvents +} diff --git a/src/utils/socketClient.js b/src/utils/socketClient.js index 65d4b01..432f7ba 100644 --- a/src/utils/socketClient.js +++ b/src/utils/socketClient.js @@ -78,6 +78,14 @@ export function onConnect(handler) { return () => s.off('connect', handler); } +export function sendEvent(type, content = null) { + const s = getSocket(); + s.emit('send-event', { + type, + content + }); +} + export function disconnect() { if (!socket) return; try {
{{ JSON.stringify(event.content || event, null, 1) }}
+ 此页面用于测试新的通用事件转发系统,支持聊天、KV变化等各种事件类型。 +
{{ JSON.stringify(event.content, null, 2) }}