1
0
mirror of https://github.com/ZeroCatDev/Classworks.git synced 2025-12-07 21:13:11 +00:00

初步实现消息通知功能

This commit is contained in:
Sunwuyuan 2025-11-23 14:19:09 +08:00
parent 76c2dba502
commit ca4de545b9
No known key found for this signature in database
GPG Key ID: A6A54CF66F56BB64
10 changed files with 2606 additions and 44 deletions

View File

@ -39,8 +39,29 @@
<v-icon class="mr-2">
mdi-chat-processing
</v-icon>
<span class="text-subtitle-1">设备聊天室</span>
<v-spacer/>
<span class="text-subtitle-1">{{ modeTitle }}</span>
<v-spacer />
<!-- 模式切换按钮 -->
<v-btn-toggle
v-model="currentMode"
class="mr-2"
mandatory
size="small"
variant="outlined"
>
<v-btn
value="chat"
size="small"
>
<v-icon>mdi-chat</v-icon>
</v-btn>
<v-btn
value="events"
size="small"
>
<v-icon>mdi-format-list-bulleted</v-icon>
</v-btn>
</v-btn-toggle>
<v-tooltip location="top">
<template #activator="{ props }">
<v-chip
@ -63,10 +84,12 @@
</v-btn>
</v-card-title>
<v-divider/>
<v-divider />
<v-card-text class="chat-body">
<!-- 聊天模式 -->
<div
v-if="currentMode === 'chat'"
ref="listRef"
class="messages"
>
@ -78,11 +101,11 @@
v-if="msg._type === 'divider'"
class="divider-row"
>
<v-divider class="my-2"/>
<v-divider class="my-2" />
<div class="divider-text">
今天 - 上次访问
</div>
<v-divider class="my-2"/>
<v-divider class="my-2" />
</div>
<div
v-else
@ -100,21 +123,162 @@
</v-avatar>
</div>
<div class="bubble">
<div
v-if="!msg.self && msg.deviceName"
class="sender-name"
>
{{ msg.deviceName }}
</div>
<div class="text">
{{ msg.text }}
</div>
<div class="meta">
<span
v-if="msg.self && msg.deviceName"
class="device-name"
>
{{ msg.deviceName }}
</span>
{{ formatTime(msg.at) }}
</div>
</div>
</div>
</template>
</div>
<!-- 事件模式 -->
<div
v-else
class="events-container"
>
<!-- 事件统计 -->
<div class="event-stats mb-3">
<v-row dense>
<v-col cols="4">
<v-card
color="success"
dark
size="small"
>
<v-card-text class="text-center pa-2">
<div class="text-h6">
{{ eventStats.chat }}
</div>
<div class="text-caption">
聊天
</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="4">
<v-card
color="info"
dark
size="small"
>
<v-card-text class="text-center pa-2">
<div class="text-h6">
{{ eventStats.kvChanged }}
</div>
<div class="text-caption">
KV变化
</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="4">
<v-card
color="warning"
dark
size="small"
>
<v-card-text class="text-center pa-2">
<div class="text-h6">
{{ eventStats.other }}
</div>
<div class="text-caption">
其他
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</div>
<!-- 事件列表 -->
<div class="events-list">
<div
v-for="event in paginatedEvents"
:key="event._id"
class="event-item mb-2"
>
<v-card
:color="getEventColor(event.type)"
size="small"
variant="outlined"
>
<v-card-text class="pa-2">
<div class="d-flex align-center mb-1">
<v-chip
:color="getEventColor(event.type)"
size="x-small"
>
{{ getEventTypeLabel(event.type) }}
</v-chip>
<v-spacer />
<span class="text-caption">{{ formatTime(event.timestamp || event.at) }}</span>
</div>
<div
v-if="event.senderInfo"
class="mb-1 text-caption"
>
<strong>发送者:</strong> {{ formatDeviceInfo(event.senderInfo) }}
</div>
<div class="event-content">
<template v-if="event.type === 'chat' || event.type === 'chat:message'">
<div class="chat-content">
{{ event.content?.text || event.text }}
</div>
</template>
<template v-else>
<pre class="text-caption event-data">{{ JSON.stringify(event.content || event, null, 1) }}</pre>
</template>
</div>
</v-card-text>
</v-card>
</div>
<div
v-if="allEvents.length === 0"
class="text-center text-grey pa-4"
>
暂无事件
</div>
</div>
<!-- 分页控件 -->
<div
v-if="totalPages > 1"
class="pagination mt-2"
>
<v-pagination
v-model="currentPage"
:length="totalPages"
:total-visible="3"
size="small"
/>
</div>
</div>
</v-card-text>
<v-divider/>
<v-divider v-if="currentMode === 'chat'" />
<v-card-actions class="chat-input">
<v-card-actions
v-if="currentMode === 'chat'"
class="chat-input"
>
<v-btn
class="mr-1"
icon
@ -149,14 +313,22 @@
</v-card-actions>
</v-card>
</div>
<!-- 紧急通知组件 -->
<UrgentNotification ref="urgentNotification" />
</template>
<script>
import {getSetting} from '@/utils/settings'
import {getSocket, joinToken, on as socketOn} from '@/utils/socketClient'
import {sendChatMessage, createDeviceEventHandler, formatDeviceInfo} from '@/utils/deviceEvents'
import UrgentNotification from '@/components/UrgentNotification.vue'
export default {
name: 'ChatWidget',
components: {
UrgentNotification
},
props: {
modelValue: {
type: Boolean,
@ -184,11 +356,27 @@ export default {
return {
visible: this.modelValue,
text: '',
messages: [],
messages: [], //
allEvents: [], //
lastVisit: null,
unreadCount: 0,
connected: false,
socketId: '',
//
currentMode: 'chat', // 'chat' 'events'
currentPage: 1,
itemsPerPage: 20,
loading: false,
//
isDestroying: false,
//
eventStats: {
chat: 0,
kvChanged: 0,
other: 0
},
//
cleanupFunctions: []
}
},
computed: {
@ -226,6 +414,30 @@ export default {
...after,
]
},
//
currentDisplayItems() {
if (this.currentMode === 'chat') {
return this.decoratedMessages
} else {
return this.paginatedEvents
}
},
//
paginatedEvents() {
if (this.isDestroying || !this.allEvents) return []
const start = (this.currentPage - 1) * this.itemsPerPage
const end = start + this.itemsPerPage
return this.allEvents.slice(start, end)
},
//
totalPages() {
if (this.isDestroying || !this.allEvents) return 1
return Math.ceil(this.allEvents.length / this.itemsPerPage)
},
//
modeTitle() {
return this.currentMode === 'chat' ? '设备聊天室' : '所有事件'
},
},
watch: {
modelValue(val) {
@ -260,16 +472,192 @@ export default {
const token = getSetting('server.kvToken')
if (token) joinToken(token)
// Listen chat messages
this.offMessage = socketOn('chat:message', (msg) => {
this.pushMessage(msg)
})
//
const createSafeHandler = (handler) => {
return (...args) => {
if (this.isDestroying) return
try {
handler(...args)
} catch (error) {
console.error('ChatWidget 事件处理错误:', error)
}
}
}
// If initially visible, run open logic
// Listen chat messages ()
const offMessage = socketOn('chat:message', createSafeHandler((msg) => {
this.pushMessage(msg)
this.addEvent({
_id: `legacy-chat-${Date.now()}-${Math.random()}`,
type: 'chat:message',
content: msg,
timestamp: msg.at || new Date().toISOString(),
senderId: msg.senderId,
uuid: msg.uuid,
senderInfo: msg.senderInfo
})
}))
// Listen direct chat events ()
const offDirectChat = socketOn('chat', createSafeHandler((eventData) => {
if (eventData && eventData.content && eventData.content.text) {
//
const chatMsg = {
text: eventData.content.text,
senderId: eventData.senderId,
at: eventData.timestamp,
uuid: eventData.senderId, // 使 senderId uuid
senderInfo: eventData.senderInfo
}
this.pushMessage(chatMsg)
this.addEvent({
_id: eventData.eventId || `chat-${Date.now()}-${Math.random()}`,
type: 'chat',
content: eventData.content,
timestamp: eventData.timestamp,
eventId: eventData.eventId,
senderId: eventData.senderId,
senderInfo: eventData.senderInfo
})
}
}))
// Listen device events ( - )
this.deviceEventHandler = createDeviceEventHandler({
onChat: createSafeHandler((chatMsg, originalEvent) => {
this.pushMessage(chatMsg)
this.addEvent(originalEvent)
}),
onKvChanged: createSafeHandler((kvMsg, originalEvent) => {
this.addEvent(originalEvent)
}),
onUrgentNotice: createSafeHandler((urgentData, originalEvent) => {
//
this.addEvent(originalEvent)
//
this.showUrgentNotification(originalEvent)
}),
onNotification: createSafeHandler((notificationData, originalEvent) => {
console.log('收到通知事件:', notificationData, originalEvent)
//
this.addEvent(originalEvent)
//
this.showUrgentNotification(originalEvent)
}),
onOtherEvent: createSafeHandler((eventData) => {
//
if (eventData.type === 'urgent-notice' || eventData.type === 'notification') {
this.showUrgentNotification(eventData)
}
this.addEvent(eventData)
}),
enableLegacySupport: true
})
const offDeviceEvent = socketOn('device-event', this.deviceEventHandler)
// KV
const offKvChanged = socketOn('kv-key-changed', createSafeHandler((eventData) => {
//
if (eventData.content && eventData.timestamp) {
this.addEvent({
_id: `kv-${Date.now()}-${Math.random()}`,
type: 'kv-key-changed',
content: eventData.content,
timestamp: eventData.timestamp,
eventId: eventData.eventId,
senderId: eventData.senderId,
senderInfo: eventData.senderInfo
})
} else {
//
this.addEvent({
_id: `legacy-kv-${Date.now()}-${Math.random()}`,
type: 'kv-key-changed',
content: eventData,
timestamp: eventData.updatedAt || new Date().toISOString(),
uuid: eventData.uuid
})
}
}))
//
const offUrgentNotice = socketOn('urgent-notice', createSafeHandler((notificationData) => {
console.log('收到紧急通知:', notificationData)
//
this.addEvent({
_id: `urgent-${Date.now()}-${Math.random()}`,
type: 'urgent-notice',
content: notificationData.content || notificationData,
timestamp: notificationData.timestamp || new Date().toISOString(),
eventId: notificationData.eventId,
senderId: notificationData.senderId,
senderInfo: notificationData.senderInfo
})
//
this.showUrgentNotification(notificationData)
}))
//
const offNotification = socketOn('notification', createSafeHandler((notificationData) => {
console.log('收到通知事件:', notificationData)
//
this.addEvent({
_id: `notification-${Date.now()}-${Math.random()}`,
type: 'notification',
content: notificationData.content || notificationData,
timestamp: notificationData.timestamp || new Date().toISOString(),
eventId: notificationData.eventId,
senderId: notificationData.senderId,
senderInfo: notificationData.senderInfo || notificationData.content?.senderInfo
})
//
this.showUrgentNotification(notificationData)
})) //
this.cleanupFunctions = [
offMessage,
offDirectChat,
offUrgentNotice,
offNotification,
offDeviceEvent,
offKvChanged
] // If initially visible, run open logic
if (this.visible) this.onOpen()
},
beforeUnmount() {
if (this.offMessage) this.offMessage()
//
this.isDestroying = true
//
if (this.cleanupFunctions && Array.isArray(this.cleanupFunctions)) {
this.cleanupFunctions.forEach(cleanup => {
try {
if (typeof cleanup === 'function') {
cleanup()
}
} catch (error) {
console.warn('ChatWidget 清理函数执行失败:', error)
}
})
}
//
try {
if (this.offMessage) this.offMessage()
if (this.offDeviceEvent) this.offDeviceEvent()
if (this.offKvChanged) this.offKvChanged()
} catch (error) {
console.warn('ChatWidget 旧清理函数执行失败:', error)
}
//
this.cleanupFunctions = []
this.messages = []
this.allEvents = []
},
methods: {
open() {
@ -307,26 +695,72 @@ export default {
send() {
const val = this.text.trim()
if (!val) return
const s = getSocket()
s.emit('chat:send', val)
//
const selfMsg = {
_id: `self-${Date.now()}-${Math.random()}`,
text: val,
at: new Date().toISOString(),
senderId: this.socketId,
self: true,
senderInfo: {
deviceName: '我',
deviceType: 'client',
isReadOnly: false
}
}
this.pushMessage(selfMsg)
//
this.addEvent({
_id: `self-event-${Date.now()}-${Math.random()}`,
type: 'chat',
content: { text: val },
timestamp: new Date().toISOString(),
senderId: this.socketId,
senderInfo: {
deviceName: '本设备',
deviceType: 'client',
isReadOnly: false
}
})
//
sendChatMessage(val)
this.text = ''
},
pushMessage(msg) {
const entry = {
_id: `${msg.at || Date.now()}-${Math.random()}`,
text: typeof msg?.text === 'string' ? msg.text : (msg?.text || ''),
at: msg.at || new Date().toISOString(),
senderId: msg.senderId,
self: !!(msg.senderId && msg.senderId === this.socketId),
if (this.isDestroying || !msg) return
try {
const entry = {
_id: `${msg.at || Date.now()}-${Math.random()}`,
text: typeof msg?.text === 'string' ? msg.text : (msg?.text || ''),
at: msg.at || new Date().toISOString(),
senderId: msg.senderId,
self: !!(msg.senderId && msg.senderId === this.socketId),
senderInfo: msg.senderInfo || null, //
deviceName: this.getDeviceName(msg.senderInfo, msg.senderId === this.socketId)
}
// ignore empty
if (!entry.text) return
this.messages.push(entry)
// unread when hidden
if (!this.visible) this.unreadCount++
// nextTick
this.$nextTick(() => {
if (!this.isDestroying) {
this.scrollToBottom()
}
})
// trim
if (this.messages.length > 500) this.messages.shift()
} catch (error) {
console.error('ChatWidget pushMessage 错误:', error)
}
// ignore empty
if (!entry.text) return
this.messages.push(entry)
// unread when hidden
if (!this.visible) this.unreadCount++
this.$nextTick(() => this.scrollToBottom())
// trim
if (this.messages.length > 500) this.messages.shift()
},
formatTime(iso) {
try {
@ -340,12 +774,104 @@ export default {
}
},
scrollToBottom() {
const el = this.$refs.listRef
if (!el) return
if (this.isDestroying) return
try {
el.scrollTop = el.scrollHeight
} catch (e) {
void e
const el = this.$refs.listRef
if (!el) return
// 使 requestAnimationFrame DOM
requestAnimationFrame(() => {
if (!this.isDestroying && el) {
el.scrollTop = el.scrollHeight
}
})
} catch (error) {
console.warn('ChatWidget scrollToBottom 错误:', error)
}
},
//
addEvent(eventData) {
if (this.isDestroying || !eventData) return
try {
this.allEvents.unshift(eventData)
//
if (eventData.type === 'chat' || eventData.type === 'chat:message') {
this.eventStats.chat++
} else if (eventData.type === 'kv-key-changed') {
this.eventStats.kvChanged++
} else {
this.eventStats.other++
}
//
if (this.allEvents.length > 200) {
this.allEvents = this.allEvents.slice(0, 200)
}
} catch (error) {
console.error('ChatWidget addEvent 错误:', error)
}
},
//
getEventColor(eventType) {
switch (eventType) {
case 'chat':
case 'chat:message':
return 'success'
case 'kv-key-changed':
return 'info'
default:
return 'warning'
}
},
//
getEventTypeLabel(eventType) {
switch (eventType) {
case 'chat':
case 'chat:message':
return '聊天'
case 'kv-key-changed':
return 'KV变化'
default:
return eventType
}
},
// - 使
formatDeviceInfo(senderInfo) {
return formatDeviceInfo(senderInfo)
},
//
getDeviceName(senderInfo, isSelf = false) {
if (isSelf) {
return '我'
}
if (!senderInfo) {
return '未知设备'
}
//
if (senderInfo.deviceName === 'realtime') {
return '系统'
}
// 使
return senderInfo.deviceName ||
senderInfo.deviceType ||
'未知设备'
},
//
showUrgentNotification(notificationData) {
try {
if (this.$refs.urgentNotification) {
this.$refs.urgentNotification.show(notificationData)
} else {
console.warn('紧急通知组件未找到')
}
} catch (error) {
console.error('显示紧急通知失败:', error)
}
},
},
@ -420,6 +946,22 @@ export default {
text-align: right;
}
.bubble .sender-name {
font-size: 11px;
color: rgba(255, 255, 255, 0.8);
margin-bottom: 2px;
font-weight: 500;
}
.message-row.self .bubble .sender-name {
color: rgba(33, 150, 243, 0.8);
}
.device-name {
font-weight: 500;
opacity: 0.8;
}
.divider-row {
text-align: center;
color: rgba(255, 255, 255, 0.6);
@ -433,4 +975,56 @@ export default {
.chat-input {
padding: 8px;
}
/* 事件相关样式 */
.events-container {
height: 100%;
display: flex;
flex-direction: column;
}
.events-list {
flex-grow: 1;
overflow-y: auto;
max-height: calc(100% - 120px);
}
.event-item {
transition: all 0.2s ease;
}
.event-item:hover {
transform: translateX(2px);
}
.event-content {
max-width: 100%;
}
.chat-content {
background: rgba(0,0,0,0.05);
padding: 4px 8px;
border-radius: 4px;
word-break: break-word;
}
.event-data {
background: rgba(0,0,0,0.05);
padding: 4px;
border-radius: 4px;
font-size: 10px;
max-height: 100px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
.pagination {
display: flex;
justify-content: center;
}
.event-stats {
flex-shrink: 0;
}
</style>

View File

@ -0,0 +1,133 @@
<template>
<div style="display: none">
<!-- 这是一个无界面的功能组件用于封装事件发送 -->
</div>
</template>
<script>
import { sendEvent } from "@/utils/socketClient";
export default {
name: "EventSender",
emits: ["sent", "error"],
methods: {
/**
* 发送事件
* @param {string} eventName - 事件名称
* @param {Object} content - 事件内容
* @returns {Promise} 发送结果
*/
async sendEvent(eventName, content = {}) {
try {
sendEvent(eventName, content);
this.$emit("sent", {
eventName,
content,
timestamp: new Date().toISOString(),
success: true,
});
// eventId notificationId便
return {
success: true,
eventId: content?.eventId || null,
notificationId: content?.notificationId || null
};
} catch (error) {
console.error("发送事件失败:", error);
this.$emit("error", {
eventName,
content,
error: error.message,
timestamp: new Date().toISOString(),
success: false,
});
return { success: false, error: error.message };
}
},
/**
* 发送通知事件简化接口
* @param {string} message - 通知内容
* @param {boolean} isUrgent - 是否加急
* @param {Array} targetDevices - 目标设备
* @param {Object} senderInfo - 发送者信息
* @param {string} notificationId - 32位通知ID可选
*/
async sendNotification(
message,
isUrgent = false,
targetDevices = [],
senderInfo = {},
notificationId = null
) {
// ID便
const eventId = `evt-${Date.now()}-${Math.random()
.toString(36)
.slice(2, 8)}`;
return this.sendEvent("notification", {
eventId,
notificationId,
message,
isUrgent,
targetDevices,
senderInfo,
});
},
/**
* 发送回执事件
* @param {string} originalEventId - 原事件ID
* @param {string} status - 回执状态 (displayed|read)
* @param {Object} deviceInfo - 设备信息
* @param {string} notificationId - 原通知ID可选
*/
async sendReceipt(originalEventId, status, deviceInfo = {}, notificationId = null) {
const eventId = `rcpt-${Date.now()}-${Math.random()
.toString(36)
.slice(2, 6)}`;
return this.sendEvent("notification-receipt", {
eventId,
originalEventId,
notificationId,
status,
deviceInfo,
});
},
/**
* 单独发送"已显示"回执事件
* @param {Object} deviceInfo 设备信息
* @param {string} notificationId 原通知ID
*/
async sendDisplayedReceipt(deviceInfo = {}, notificationId = null) {
const eventId = `disp-${Date.now()}-${Math.random()
.toString(36)
.slice(2, 6)}`;
return this.sendEvent("notification-displayed", {
eventId,
notificationId,
deviceInfo,
});
},
/**
* 单独发送"已读"回执事件
* @param {Object} deviceInfo 设备信息
* @param {string} notificationId 原通知ID
*/
async sendReadReceipt(deviceInfo = {}, notificationId = null) {
const eventId = `read-${Date.now()}-${Math.random()
.toString(36)
.slice(2, 6)}`;
return this.sendEvent("notification-read", {
eventId,
notificationId,
deviceInfo,
});
},
},
};
</script>

View File

@ -0,0 +1,392 @@
<template>
<v-dialog
v-model="visible"
max-width="800"
persistent
transition="dialog-transition"
class="urgent-notification-dialog"
>
<v-card
class="urgent-notification-card"
:color="urgencyColor"
elevation="24"
>
<v-card-text>
<div class="urgent-title mb-6">
{{ notification?.content?.message || "无内容" }}
</div>
<!-- 发送者信息使用 Vuetify Card -->
<v-card variant="flat" color="white">
<v-card-title>发送者信息</v-card-title>
<v-card-text>
<v-chip
class="mr-2 mb-2"
color="primary"
variant="outlined"
size="small"
>
<v-icon left size="16"> mdi-account </v-icon>
{{ senderName }}
</v-chip>
<v-chip
class="mr-2 mb-2"
color="info"
variant="outlined"
size="small"
>
<v-icon left size="16"> mdi-devices </v-icon>
{{ deviceType }}
</v-chip>
<v-chip
class="mb-2"
color="success"
variant="outlined"
size="small"
>
<v-icon left size="16"> mdi-clock </v-icon>
{{ formatTime(notification?.timestamp) }}
</v-chip>
</v-card-text>
</v-card>
<!-- 操作按钮 -->
<div class="mt-8">
<v-btn color="white" size="large" variant="flat" @click="close">
<v-icon left> mdi-check </v-icon>
我知道了
</v-btn>
</div>
</v-card-text>
</v-card>
</v-dialog>
<!-- 事件发送器 -->
<EventSender ref="eventSender" />
</template>
<script>
import EventSender from "@/components/EventSender.vue";
export default {
name: "UrgentNotification",
components: {
EventSender,
},
data() {
return {
visible: false,
notification: null,
autoCloseTimer: null,
urgentSoundTimer: null,
};
},
computed: {
isUrgent() {
return this.notification?.content?.isUrgent || false;
},
urgencyColor() {
return this.isUrgent ? "red darken-2" : "blue darken-2";
},
iconColor() {
return "white";
},
urgencyIcon() {
return this.isUrgent
? "mdi-alert-circle-outline"
: "mdi-information-outline";
},
urgencyTitle() {
return this.isUrgent ? "🚨 紧急通知" : "📢 通知消息";
},
senderName() {
const senderInfo =
this.notification?.senderInfo || this.notification?.content?.senderInfo;
if (!senderInfo) return "未知发送者";
return senderInfo.deviceName || senderInfo.deviceType || "未知设备";
},
deviceType() {
const senderInfo =
this.notification?.senderInfo || this.notification?.content?.senderInfo;
return senderInfo?.deviceType || "未知类型";
},
targetDevices() {
return this.notification?.content?.targetDevices || [];
},
},
beforeUnmount() {
if (this.autoCloseTimer) {
clearTimeout(this.autoCloseTimer);
}
if (this.urgentSoundTimer) {
clearInterval(this.urgentSoundTimer);
}
},
methods: {
show(notificationData) {
this.notification = notificationData;
this.visible = true;
//
this.sendDisplayedReceipt();
//
if (this.autoCloseTimer) {
clearTimeout(this.autoCloseTimer);
}
//
this.playNotificationSound();
//
if (this.isUrgent) {
this.startUrgentSound();
}
},
close() {
//
try {
this.sendReadReceipt();
console.log("已发送已读回执");
} catch (error) {
console.warn("发送已读回执失败:", error);
}
this.closeWithoutRead();
},
//
closeWithoutRead() {
//
this.visible = false;
this.notification = null;
if (this.autoCloseTimer) {
clearTimeout(this.autoCloseTimer);
this.autoCloseTimer = null;
}
//
if (this.urgentSoundTimer) {
clearInterval(this.urgentSoundTimer);
this.urgentSoundTimer = null;
}
},
formatTime(timestamp) {
if (!timestamp) return "";
try {
const date = new Date(timestamp);
const now = new Date();
const diff = now - date;
//
if (diff < 24 * 60 * 60 * 1000) {
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${hours}:${minutes}`;
} else {
//
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${month}/${day}`;
}
} catch {
return "无效时间";
}
},
getDeviceTypeLabel(deviceType) {
const labels = {
classroom: "教室设备",
student: "学生设备",
teacher: "教师设备",
admin: "管理员设备",
system: "系统设备",
};
return labels[deviceType] || deviceType;
},
playNotificationSound() {
try {
//
const audioContext = new (window.AudioContext ||
window.webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
//
oscillator.frequency.value = 1000; // 1kHz
oscillator.type = "sine";
gainNode.gain.value = 0.3;
oscillator.start();
oscillator.stop(audioContext.currentTime + 0.3); // 300ms
} catch (error) {
console.warn("无法播放通知音效:", error);
}
},
//
sendDisplayedReceipt() {
try {
if (this.$refs.eventSender && this.notification?.eventId) {
this.$refs.eventSender.sendDisplayedReceipt(
{},
this.notification.content.notificationId
);
console.log("已发送显示回执:", this.notification.eventId);
}
} catch (error) {
console.warn("发送显示回执失败:", error);
}
},
//
sendReadReceipt() {
try {
if (this.$refs.eventSender && this.notification?.eventId) {
this.$refs.eventSender.sendReadReceipt(
{},
this.notification.content.notificationId
);
console.log("已发送已读回执:", this.notification.eventId);
}
} catch (error) {
console.warn("发送已读回执失败:", error);
}
},
//
startUrgentSound() {
//
if (this.urgentSoundTimer) {
clearInterval(this.urgentSoundTimer);
}
//
this.urgentSoundTimer = setInterval(() => {
if (this.visible && this.isUrgent) {
this.playNotificationSound();
} else {
//
clearInterval(this.urgentSoundTimer);
this.urgentSoundTimer = null;
}
}, 1000);
},
},
};
</script>
<style scoped>
/* Dialog 容器样式 */
:deep(.v-dialog) {
backdrop-filter: blur(8px);
}
:deep(.v-overlay__scrim) {
background: rgba(0, 0, 0, 0.8) !important;
}
.urgent-notification-card {
position: relative;
animation: urgentPulse 2s infinite, slideIn 0.5s ease-out;
border: 3px solid rgba(255, 255, 255, 0.3);
}
.close-btn {
position: absolute;
top: 16px;
right: 16px;
z-index: 1;
}
.urgency-icon {
animation: iconPulse 1.5s infinite;
filter: drop-shadow(0 0 10px rgba(255, 255, 255, 0.5));
}
.urgent-title {
font-size: 2.5rem;
font-weight: bold;
color: white;
line-height: 1.2;
}
.notification-content {
font-size: 1.4rem;
color: rgba(255, 255, 255, 0.95);
line-height: 1.6;
padding: 0 20px;
}
.sender-label,
.target-label {
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.8);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
}
.sender-details,
.target-devices {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.actions {
display: flex;
justify-content: center;
gap: 16px;
}
/* 动画效果 */
@keyframes urgentPulse {
0%,
100% {
box-shadow: 0 0 30px rgba(255, 255, 255, 0.3);
}
50% {
box-shadow: 0 0 50px rgba(255, 255, 255, 0.6);
}
}
@keyframes iconPulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-50px) scale(0.9);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* 响应式设计 */
@media (max-width: 600px) {
.urgent-title {
font-size: 2rem;
}
.notification-content {
font-size: 1.2rem;
padding: 0 10px;
}
.urgent-notification-card {
width: 95% !important;
margin: 20px;
}
}
</style>

View File

@ -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))

View File

@ -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);
}

View File

@ -0,0 +1,435 @@
<template>
<v-container fluid>
<v-row>
<v-col cols="12">
<v-card>
<v-card-title>
<v-icon class="mr-2">mdi-transit-connection-variant</v-icon>
Socket.IO 新事件系统测试
</v-card-title>
<v-card-text>
<p class="text-body-2 mb-4">
此页面用于测试新的通用事件转发系统支持聊天KV变化等各种事件类型
</p>
<!-- 连接状态 -->
<v-alert
:type="connected ? 'success' : 'error'"
:text="`连接状态: ${connected ? '已连接' : '未连接'} | Socket ID: ${socketId || '-'}`"
class="mb-4"
/>
<!-- 发送测试事件 -->
<v-card outlined class="mb-4">
<v-card-title class="text-h6">发送测试事件</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<v-select
v-model="testEventType"
:items="eventTypes"
label="事件类型"
outlined
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="testContent"
label="事件内容"
outlined
placeholder="输入测试内容"
/>
</v-col>
</v-row>
<v-btn
:disabled="!testEventType || !testContent.trim()"
color="primary"
@click="sendTestEvent"
>
<v-icon start>mdi-send</v-icon>
发送测试事件
</v-btn>
</v-card-text>
</v-card>
<!-- 事件统计 -->
<v-row class="mb-4">
<v-col cols="12" md="3">
<v-card color="primary" dark>
<v-card-text>
<div class="text-h4">{{ eventStats.total }}</div>
<div>总事件数</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card color="success" dark>
<v-card-text>
<div class="text-h4">{{ eventStats.chat }}</div>
<div>聊天事件</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card color="info" dark>
<v-card-text>
<div class="text-h4">{{ eventStats.kvChanged }}</div>
<div>KV变化事件</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card color="warning" dark>
<v-card-text>
<div class="text-h4">{{ eventStats.other }}</div>
<div>其他事件</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- 实时事件日志 -->
<v-card outlined>
<v-card-title>
实时事件日志
<v-spacer/>
<v-btn
size="small"
variant="text"
@click="clearEvents"
>
<v-icon>mdi-delete</v-icon>
清空
</v-btn>
</v-card-title>
<v-card-text>
<div class="event-log">
<div
v-for="(event, index) in recentEvents"
:key="index"
class="event-item mb-3"
>
<v-card
:color="getEventColor(event.type)"
variant="outlined"
>
<v-card-text class="pa-3">
<div class="d-flex align-center mb-2">
<v-chip
:color="getEventColor(event.type)"
size="small"
variant="flat"
>
{{ event.type }}
</v-chip>
<v-spacer/>
<span class="text-caption">{{ formatTime(event.timestamp) }}</span>
</div>
<div v-if="event.senderInfo" class="mb-2">
<strong>发送者:</strong> {{ formatDeviceInfo(event.senderInfo) }}
<v-chip
v-if="isRealtimeEvent(event)"
color="purple"
size="x-small"
class="ml-2"
>
实时同步
</v-chip>
</div>
<div class="mb-2">
<strong>内容:</strong>
</div>
<pre class="text-caption">{{ JSON.stringify(event.content, null, 2) }}</pre>
<div v-if="event.eventId" class="text-caption mt-2 text-grey">
事件ID: {{ event.eventId }}
</div>
</v-card-text>
</v-card>
</div>
<div v-if="recentEvents.length === 0" class="text-center text-grey pa-4">
暂无事件
</div>
</div>
</v-card-text>
</v-card>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</template>
<script setup>
import { ref, reactive, onMounted, onBeforeUnmount, computed } from 'vue'
import { getSocket, on as socketOn, joinToken } from '@/utils/socketClient'
import {
sendEvent,
DeviceEventTypes,
formatDeviceInfo,
isRealtimeEvent,
createDeviceEventHandler,
sendChatMessage
} from '@/utils/deviceEvents'
import { getSetting } from '@/utils/settings'
//
const connected = ref(false)
const socketId = ref('')
const testEventType = ref('chat')
const testContent = ref('')
const recentEvents = ref([])
//
const eventStats = reactive({
total: 0,
chat: 0,
kvChanged: 0,
other: 0
})
//
const eventTypes = [
{ title: '聊天消息', value: 'chat' },
{ title: 'KV变化', value: 'kv-key-changed' },
{ title: '自定义事件', value: 'custom-event' }
]
//
let cleanupFunctions = []
//
const formattedEvents = computed(() => {
return recentEvents.value.map(event => ({
...event,
formattedTime: formatTime(event.timestamp),
formattedSender: event.senderInfo ? formatDeviceInfo(event.senderInfo) : '未知'
}))
})
//
function sendTestEvent() {
if (!testEventType.value || !testContent.value.trim()) return
try {
if (testEventType.value === 'chat') {
// 使
sendChatMessage(testContent.value)
} else {
// 使
const content = testEventType.value === 'kv-key-changed' ?
{
key: 'test-key',
action: 'upsert',
value: testContent.value
} :
{ message: testContent.value }
sendEvent(testEventType.value, content)
}
testContent.value = ''
} catch (error) {
console.error('发送测试事件失败:', error)
}
}
function addEvent(eventData) {
recentEvents.value.unshift(eventData)
//
eventStats.total++
if (eventData.type === DeviceEventTypes.CHAT) {
eventStats.chat++
} else if (eventData.type === DeviceEventTypes.KV_KEY_CHANGED) {
eventStats.kvChanged++
} else {
eventStats.other++
}
//
if (recentEvents.value.length > 50) {
recentEvents.value = recentEvents.value.slice(0, 50)
}
}
function clearEvents() {
recentEvents.value = []
eventStats.total = 0
eventStats.chat = 0
eventStats.kvChanged = 0
eventStats.other = 0
}
function getEventColor(eventType) {
switch (eventType) {
case DeviceEventTypes.CHAT:
return 'success'
case DeviceEventTypes.KV_KEY_CHANGED:
return 'info'
default:
return 'warning'
}
}
function formatTime(timestamp) {
try {
const date = new Date(timestamp)
return date.toLocaleTimeString('zh-CN', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
} catch (error) {
return timestamp
}
}
//
onMounted(() => {
// socket
const socket = getSocket()
connected.value = socket.connected
socketId.value = socket.id || ''
//
socket.on('connect', () => {
connected.value = true
socketId.value = socket.id || ''
})
socket.on('disconnect', () => {
connected.value = false
socketId.value = ''
})
// token
const token = getSetting('server.kvToken')
if (token) {
joinToken(token)
}
//
const offLegacyChat = socketOn('chat:message', (msg) => {
addEvent({
type: 'chat:message',
content: msg,
timestamp: msg.at || new Date().toISOString(),
senderId: msg.senderId,
uuid: msg.uuid,
eventId: `legacy-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
})
})
//
const offDirectChat = socketOn('chat', (eventData) => {
if (eventData && eventData.content) {
addEvent({
type: 'chat',
content: eventData.content,
timestamp: eventData.timestamp,
eventId: eventData.eventId,
senderId: eventData.senderId,
senderInfo: eventData.senderInfo
})
}
})
const offLegacyKv = socketOn('kv-key-changed', (eventData) => {
//
if (eventData.content && eventData.timestamp) {
addEvent({
type: 'kv-key-changed',
content: eventData.content,
timestamp: eventData.timestamp,
eventId: eventData.eventId,
senderId: eventData.senderId,
senderInfo: eventData.senderInfo
})
} else {
//
addEvent({
type: 'kv-key-changed',
content: eventData,
timestamp: eventData.updatedAt || new Date().toISOString(),
uuid: eventData.uuid,
eventId: `legacy-kv-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
})
}
})
//
const deviceEventHandler = createDeviceEventHandler({
onChat: (chatMsg, originalEvent) => {
addEvent(originalEvent)
},
onKvChanged: (kvMsg, originalEvent) => {
addEvent(originalEvent)
},
onOtherEvent: (eventData) => {
addEvent(eventData)
},
enableLegacySupport: true
})
const offDeviceEvent = socketOn('device-event', deviceEventHandler)
//
cleanupFunctions = [
offLegacyChat,
offDirectChat,
offLegacyKv,
offDeviceEvent
]
})
onBeforeUnmount(() => {
//
try {
cleanupFunctions.forEach(cleanup => {
try {
if (typeof cleanup === 'function') {
cleanup()
}
} catch (error) {
console.warn('事件监听器清理失败:', error)
}
})
} catch (error) {
console.error('组件清理失败:', error)
}
//
cleanupFunctions = []
})
</script>
<style scoped>
.event-log {
max-height: 600px;
overflow-y: auto;
}
.event-item {
transition: all 0.3s ease;
}
.event-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
pre {
background: rgba(0,0,0,0.05);
padding: 8px;
border-radius: 4px;
white-space: pre-wrap;
word-break: break-all;
max-height: 200px;
overflow-y: auto;
}
</style>

391
src/pages/urgent-test.vue Normal file
View File

@ -0,0 +1,391 @@
<template>
<div class="urgent-test-page">
<v-container>
<v-row>
<v-col cols="12">
<v-card>
<v-card-title>
<v-icon class="mr-2">
mdi-alert-octagon
</v-icon>
紧急通知测试页面
</v-card-title>
<v-card-text>
<v-form>
<v-row>
<v-col
cols="12"
md="6"
>
<v-switch
v-model="notificationForm.isUrgent"
label="加急通知"
color="red"
inset
>
<template #prepend>
<v-icon :color="notificationForm.isUrgent ? 'red' : 'grey'">
{{ notificationForm.isUrgent ? 'mdi-alert-circle' : 'mdi-information' }}
</v-icon>
</template>
</v-switch>
</v-col>
<v-col cols="12">
<v-textarea
v-model="notificationForm.message"
label="通知内容"
outlined
rows="3"
placeholder="请输入紧急通知的内容..."
/>
</v-col>
</v-row>
</v-form>
</v-card-text>
<v-card-actions class="px-6 pb-6">
<v-btn
:color="notificationForm.isUrgent ? 'red' : 'blue'"
:disabled="!notificationForm.message.trim()"
:loading="sending"
size="large"
variant="elevated"
@click="sendNotification"
>
<v-icon left>
{{ notificationForm.isUrgent ? 'mdi-alert-circle' : 'mdi-information' }}
</v-icon>
{{ notificationForm.isUrgent ? '发送紧急通知' : '发送通知' }}
</v-btn>
<v-spacer />
<v-btn
color="grey"
variant="outlined"
@click="resetForm"
>
重置表单
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
<!-- 消息发送历史 -->
<v-row class="mt-4">
<v-col cols="12">
<v-card>
<v-card-title>
<v-icon class="mr-2">
mdi-history
</v-icon>
消息记录
<v-spacer />
</v-card-title>
<v-card-text>
<div
v-if="sentMessages.length === 0"
class="text-center text-grey py-8"
>
<v-icon
size="64"
color="grey-lighten-2"
>
mdi-message-outline
</v-icon>
<div class="mt-2">
暂无发送记录
</div>
</div>
<v-row v-else>
<v-col
v-for="message in sentMessages.slice().reverse()"
:key="message.id"
cols="12"
md="6"
lg="4"
>
<!-- 主消息卡片 -->
<v-card
:color="getMainCardColor(message.receipts)"
class="mb-2"
>
<v-card-text>
<div class="d-flex align-center mb-2">
<span class="font-weight-medium">
{{ message.isUrgent ? '紧急通知' : '普通通知' }}
</span>
<v-spacer />
<span class="text-caption font-weight-medium">
{{ getReceiptStatus(message.receipts) }}
</span>
</div>
<div
class="text-body-2 mb-3"
style="max-height: 60px; overflow: hidden;"
>
{{ message.message }}
</div>
<div class="text-caption">
<div>发送时间{{ formatTime(message.timestamp) }}</div>
<div>事件ID{{ message.id }}</div>
<div>通知ID{{ message.notificationId }}</div>
</div>
</v-card-text>
</v-card>
<!-- 设备回执小卡片 -->
<div v-if="hasAnyReceipts(message.receipts)">
<!-- 已读设备 -->
<v-card
v-for="device in message.receipts.read"
:key="`${device.senderId}-read`"
color="success"
class="mb-1"
size="small"
>
<v-card-text class="pa-2">
<div class="align-center">
<span class="text-body-2 font-weight-medium">{{ device.deviceName }} </span>
<br/>
{{ device.deviceType }}
</div>
<div class="text-caption mt-1">
已读于 {{ formatDeviceTime(device.timestamp) }}
</div>
</v-card-text>
</v-card>
<!-- 已显示设备排除已读的设备 -->
<v-card
v-for="device in getDisplayedOnlyDevices(message.receipts)"
:key="`${device.senderId}-displayed`"
color="info-lighten-4"
variant="outlined"
class="mb-1"
size="small"
>
<v-card-text class="pa-2">
<div class="align-center">
<span class="text-body-2 font-weight-medium">{{ device.deviceName }}</span>
<v-spacer />
<span class="text-caption text-grey">
{{ device.deviceType }}
</span>
</div>
<div class="text-caption text-grey mt-1">
已显示于 {{ formatDeviceTime(device.timestamp) }}
</div>
</v-card-text>
</v-card>
</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
<ChatWidget />
<EventSender ref="eventSender" />
</div>
</template>
<script>
import ChatWidget from '@/components/ChatWidget.vue'
import EventSender from '@/components/EventSender.vue'
import { on as socketOn } from '@/utils/socketClient'
export default {
name: 'UrgentNotificationTest',
components: {
ChatWidget,
EventSender
},
data() {
return {
sending: false,
notificationForm: {
isUrgent: false,
message: ''
},
sentMessages: [],
receiptCleanup: []
}
},
mounted() {
this.setupEventListeners()
},
beforeUnmount() {
this.cleanup()
},
methods: {
generateNotificationId() {
// 32
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let result = ''
for (let i = 0; i < 32; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return result
},
async sendNotification() {
if (!this.notificationForm.message.trim()) return
this.sending = true
try {
// 32ID
const notificationId = this.generateNotificationId()
const result = await this.$refs.eventSender.sendNotification(
this.notificationForm.message,
this.notificationForm.isUrgent,
[],
{ deviceName: '测试设备', deviceType: 'system', isReadOnly: false },
notificationId
)
const eventId = result?.eventId || `msg-${Date.now()}`
this.sentMessages.push({
id: eventId,
notificationId: notificationId,
message: this.notificationForm.message,
isUrgent: this.notificationForm.isUrgent,
timestamp: new Date().toISOString(),
receipts: {
displayed: [],
read: []
}
})
console.log('通知已发送事件ID:', eventId, '通知ID:', notificationId)
this.resetForm()
} catch (error) {
console.error('发送通知失败:', error)
} finally {
this.sending = false
}
},
resetForm() {
this.notificationForm = {
isUrgent: false,
message: ''
}
},
setupEventListeners() {
//
const cleanup1 = socketOn('notification-displayed', (data) => {
console.log('收到显示回执:', data)
this.updateReceipt(data, 'displayed')
})
//
const cleanup2 = socketOn('notification-read', (data) => {
console.log('收到已读回执:', data)
this.updateReceipt(data, 'read')
})
this.receiptCleanup.push(cleanup1, cleanup2)
},
updateReceipt(data, type) {
const originalEventId = data.originalEventId
const notificationId = data.notificationId || data.content?.notificationId
if (!originalEventId && !notificationId) return
const message = this.sentMessages.find(msg =>
msg.id === originalEventId ||
msg.notificationId === notificationId
)
if (message) {
// 使 senderInfo senderId
const deviceInfo = {
senderId: data.senderId || 'unknown-sender',
deviceName: data.senderInfo?.deviceName || data.deviceInfo?.deviceName || '未知设备',
deviceType: data.senderInfo?.deviceType || data.deviceInfo?.deviceType || 'unknown',
timestamp: new Date().toISOString()
}
// senderId
const exists = message.receipts[type].find(item =>
item.senderId === deviceInfo.senderId
)
if (!exists) {
message.receipts[type].push(deviceInfo)
console.log(`更新${type}回执:`, message.id, deviceInfo)
}
}
},
cleanup() {
this.receiptCleanup.forEach(cleanup => cleanup())
this.receiptCleanup = []
},
formatTime(timestamp) {
return new Date(timestamp).toLocaleString('zh-CN')
},
getReceiptStatus(receipts) {
if (receipts.read.length > 0) return '已读'
if (receipts.displayed.length > 0) return '已显示'
return '已发送'
},
getReceiptColor(receipts) {
if (receipts.read.length > 0) return 'success'
if (receipts.displayed.length > 0) return 'info'
return 'grey'
},
formatDeviceTime(timestamp) {
return new Date(timestamp).toLocaleTimeString('zh-CN')
},
getMainCardColor(receipts) {
// 绿
if (receipts.read.length > 0) return 'success'
if (receipts.displayed.length > 0) return 'info'
return 'grey'
},
hasAnyReceipts(receipts) {
return receipts.read.length > 0 || receipts.displayed.length > 0
},
getDisplayedOnlyDevices(receipts) {
// senderId
const readSenderIds = receipts.read.map(device => device.senderId)
return receipts.displayed.filter(device =>
!readSenderIds.includes(device.senderId)
)
}
}
}
</script>
<style scoped>
.gap-1 {
gap: 4px;
}
.message-history-card .v-chip {
margin: 1px;
}
</style>

327
src/utils/deviceEvents.js Normal file
View File

@ -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
}

240
src/utils/safeEvents.js Normal file
View File

@ -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
}

View File

@ -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 {