mirror of
https://github.com/ZeroCatDev/Classworks.git
synced 2025-12-07 21:13:11 +00:00
Compare commits
2 Commits
76c2dba502
...
6c990bd8e4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c990bd8e4 | ||
|
|
ca4de545b9 |
@ -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>
|
||||
|
||||
133
src/components/EventSender.vue
Normal file
133
src/components/EventSender.vue
Normal 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>
|
||||
543
src/components/UrgentNotification.vue
Normal file
543
src/components/UrgentNotification.vue
Normal file
@ -0,0 +1,543 @@
|
||||
<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">
|
||||
{{ currentNotification?.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(currentNotification?.timestamp) }}
|
||||
</v-chip>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- 多通知导航 -->
|
||||
<div v-if="hasMultipleNotifications" class="navigation-controls mt-6">
|
||||
<v-card variant="flat" color="rgba(255,255,255,0.1)">
|
||||
<v-card-text class="text-center">
|
||||
<div class="notification-counter mb-3">
|
||||
<v-chip color="white" variant="flat" size="small">
|
||||
{{ notificationCountText }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<div class="navigation-buttons">
|
||||
<v-btn
|
||||
:disabled="currentIndex === 0"
|
||||
color="white"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
@click="previousNotification"
|
||||
>
|
||||
<v-icon> mdi-chevron-left </v-icon>
|
||||
上一个
|
||||
</v-btn>
|
||||
<v-btn
|
||||
:disabled="currentIndex === notificationQueue.length - 1"
|
||||
color="white"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
class="ml-2"
|
||||
@click="nextNotification"
|
||||
>
|
||||
下一个
|
||||
<v-icon> mdi-chevron-right </v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<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,
|
||||
notificationQueue: [], // 通知队列
|
||||
currentIndex: 0, // 当前显示的通知索引
|
||||
autoCloseTimer: null,
|
||||
urgentSoundTimer: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
// 当前显示的通知
|
||||
currentNotification() {
|
||||
return this.notificationQueue[this.currentIndex] || null;
|
||||
},
|
||||
// 队列中是否有通知
|
||||
hasNotifications() {
|
||||
return this.notificationQueue.length > 0;
|
||||
},
|
||||
// 是否有多个通知
|
||||
hasMultipleNotifications() {
|
||||
return this.notificationQueue.length > 1;
|
||||
},
|
||||
// 通知计数文本
|
||||
notificationCountText() {
|
||||
if (!this.hasMultipleNotifications) return "";
|
||||
return `${this.currentIndex + 1} / ${this.notificationQueue.length}`;
|
||||
},
|
||||
isUrgent() {
|
||||
return this.currentNotification?.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.currentNotification?.senderInfo ||
|
||||
this.currentNotification?.content?.senderInfo;
|
||||
if (!senderInfo) return "未知发送者";
|
||||
|
||||
return senderInfo.deviceName || senderInfo.deviceType || "未知设备";
|
||||
},
|
||||
deviceType() {
|
||||
const senderInfo =
|
||||
this.currentNotification?.senderInfo ||
|
||||
this.currentNotification?.content?.senderInfo;
|
||||
return senderInfo?.deviceType || "未知类型";
|
||||
},
|
||||
targetDevices() {
|
||||
return this.currentNotification?.content?.targetDevices || [];
|
||||
},
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.autoCloseTimer) {
|
||||
clearTimeout(this.autoCloseTimer);
|
||||
}
|
||||
if (this.urgentSoundTimer) {
|
||||
clearInterval(this.urgentSoundTimer);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
show(notificationData) {
|
||||
// 检查是否已存在相同的通知(避免重复)
|
||||
const existingIndex = this.notificationQueue.findIndex(
|
||||
(n) =>
|
||||
n.content?.notificationId === notificationData.content?.notificationId
|
||||
);
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
console.log("通知已存在,跳过添加");
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加到队列
|
||||
this.notificationQueue.push(notificationData);
|
||||
|
||||
// 如果当前没有显示通知,显示第一个
|
||||
if (!this.visible) {
|
||||
this.currentIndex = this.notificationQueue.length - 1; // 显示最新的通知
|
||||
this.visible = true;
|
||||
this.sendDisplayedReceipt();
|
||||
this.playNotificationSound();
|
||||
|
||||
// 如果是加急通知,启动定时音效
|
||||
if (this.isUrgent) {
|
||||
this.startUrgentSound();
|
||||
}
|
||||
} else {
|
||||
// 如果已经有通知在显示,新通知是紧急的话优先显示
|
||||
if (notificationData.content?.isUrgent && !this.isUrgent) {
|
||||
this.currentIndex = this.notificationQueue.length - 1;
|
||||
this.sendDisplayedReceipt();
|
||||
this.playNotificationSound();
|
||||
this.startUrgentSound();
|
||||
}
|
||||
}
|
||||
},
|
||||
close() {
|
||||
// 只在用户主动关闭时发送已读回执
|
||||
try {
|
||||
this.sendReadReceipt();
|
||||
console.log("已发送已读回执");
|
||||
} catch (error) {
|
||||
console.warn("发送已读回执失败:", error);
|
||||
}
|
||||
|
||||
// 显示已读消息提示,便于设备端重新查看
|
||||
if (this.currentNotification?.content?.message) {
|
||||
const notificationType = this.isUrgent ? "紧急通知" : "通知";
|
||||
if (this.isUrgent) {
|
||||
this.$message?.error(
|
||||
notificationType,
|
||||
`${this.currentNotification.content.message}`
|
||||
);
|
||||
} else {
|
||||
this.$message?.info(
|
||||
notificationType,
|
||||
`${this.currentNotification.content.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 从队列中移除当前通知
|
||||
if (this.notificationQueue.length > 0) {
|
||||
this.notificationQueue.splice(this.currentIndex, 1);
|
||||
|
||||
// 调整当前索引
|
||||
if (this.currentIndex >= this.notificationQueue.length) {
|
||||
this.currentIndex = Math.max(0, this.notificationQueue.length - 1);
|
||||
}
|
||||
|
||||
// 如果还有通知,显示下一个;否则关闭
|
||||
if (this.notificationQueue.length > 0) {
|
||||
this.sendDisplayedReceipt();
|
||||
// 如果新的当前通知是紧急的,启动音效
|
||||
if (this.isUrgent) {
|
||||
this.startUrgentSound();
|
||||
} else {
|
||||
this.stopUrgentSound();
|
||||
}
|
||||
} else {
|
||||
this.closeWithoutRead();
|
||||
}
|
||||
}
|
||||
},
|
||||
// 关闭通知但不发送已读回执(用于程序异常或强制关闭)
|
||||
closeWithoutRead() {
|
||||
// 立即关闭弹框
|
||||
this.visible = false;
|
||||
this.notificationQueue = [];
|
||||
this.currentIndex = 0;
|
||||
|
||||
if (this.autoCloseTimer) {
|
||||
clearTimeout(this.autoCloseTimer);
|
||||
this.autoCloseTimer = null;
|
||||
}
|
||||
|
||||
this.stopUrgentSound();
|
||||
},
|
||||
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.currentNotification?.eventId) {
|
||||
this.$refs.eventSender.sendDisplayedReceipt(
|
||||
{},
|
||||
this.currentNotification.content.notificationId
|
||||
);
|
||||
console.log("已发送显示回执:", this.currentNotification.eventId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("发送显示回执失败:", error);
|
||||
}
|
||||
},
|
||||
// 发送已读回执
|
||||
sendReadReceipt() {
|
||||
try {
|
||||
if (this.$refs.eventSender && this.currentNotification?.eventId) {
|
||||
this.$refs.eventSender.sendReadReceipt(
|
||||
{},
|
||||
this.currentNotification.content.notificationId
|
||||
);
|
||||
console.log("已发送已读回执:", this.currentNotification.eventId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("发送已读回执失败:", error);
|
||||
}
|
||||
},
|
||||
// 导航到上一个通知
|
||||
previousNotification() {
|
||||
if (this.currentIndex > 0) {
|
||||
this.currentIndex--;
|
||||
this.sendDisplayedReceipt();
|
||||
|
||||
// 根据新通知的紧急程度调整音效
|
||||
if (this.isUrgent) {
|
||||
this.startUrgentSound();
|
||||
} else {
|
||||
this.stopUrgentSound();
|
||||
}
|
||||
}
|
||||
},
|
||||
// 导航到下一个通知
|
||||
nextNotification() {
|
||||
if (this.currentIndex < this.notificationQueue.length - 1) {
|
||||
this.currentIndex++;
|
||||
this.sendDisplayedReceipt();
|
||||
|
||||
// 根据新通知的紧急程度调整音效
|
||||
if (this.isUrgent) {
|
||||
this.startUrgentSound();
|
||||
} else {
|
||||
this.stopUrgentSound();
|
||||
}
|
||||
}
|
||||
},
|
||||
// 启动加急通知的定时音效
|
||||
startUrgentSound() {
|
||||
this.stopUrgentSound(); // 先清除之前的定时器
|
||||
|
||||
// 每秒播放一次提示音
|
||||
this.urgentSoundTimer = setInterval(() => {
|
||||
if (this.visible && this.isUrgent) {
|
||||
this.playNotificationSound();
|
||||
} else {
|
||||
this.stopUrgentSound();
|
||||
}
|
||||
}, 1000);
|
||||
},
|
||||
// 停止加急音效
|
||||
stopUrgentSound() {
|
||||
if (this.urgentSoundTimer) {
|
||||
clearInterval(this.urgentSoundTimer);
|
||||
this.urgentSoundTimer = null;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</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;
|
||||
}
|
||||
|
||||
.navigation-controls {
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.notification-counter {
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.navigation-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
@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>
|
||||
430
src/components/UrgentTestDialog.vue
Normal file
430
src/components/UrgentTestDialog.vue
Normal file
@ -0,0 +1,430 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
v-model="dialog"
|
||||
fullscreen
|
||||
transition="dialog-bottom-transition"
|
||||
scrollable
|
||||
>
|
||||
<v-card>
|
||||
<v-toolbar
|
||||
dark
|
||||
flat
|
||||
>
|
||||
<v-toolbar-title>
|
||||
<v-icon class="mr-2">
|
||||
mdi-chat
|
||||
</v-icon>
|
||||
发送通知
|
||||
</v-toolbar-title>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
@click="close"
|
||||
/>
|
||||
</v-toolbar>
|
||||
|
||||
<v-card-text class="pa-0">
|
||||
<v-container>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-card>
|
||||
|
||||
|
||||
<v-card-text>
|
||||
<v-form>
|
||||
<v-row>
|
||||
<v-col
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<v-switch
|
||||
v-model="notificationForm.isUrgent"
|
||||
label="强调通知"
|
||||
color="red"
|
||||
inset
|
||||
>
|
||||
|
||||
</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-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=="classroom"?"教室设备上的应用":device.deviceType }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-caption text-grey mt-1">
|
||||
已显示于 {{ formatDeviceTime(device.timestamp) }}
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
</div>
|
||||
<div v-else> <v-card
|
||||
|
||||
color="info-lighten-4"
|
||||
variant="outlined"
|
||||
class="mb-1"
|
||||
size="small"
|
||||
title="无设备在线"
|
||||
>
|
||||
<v-card-text>
|
||||
如果数秒后任然显示这个提示,则可能没有任何设备在线接收通知。
|
||||
</v-card-text>
|
||||
</v-card></div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<ChatWidget />
|
||||
<EventSender ref="eventSender" />
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ChatWidget from '@/components/ChatWidget.vue'
|
||||
import EventSender from '@/components/EventSender.vue'
|
||||
import { on as socketOn } from '@/utils/socketClient'
|
||||
|
||||
export default {
|
||||
name: 'UrgentTestDialog',
|
||||
components: {
|
||||
ChatWidget,
|
||||
EventSender
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
data() {
|
||||
return {
|
||||
sending: false,
|
||||
notificationForm: {
|
||||
isUrgent: false,
|
||||
message: ''
|
||||
},
|
||||
sentMessages: [],
|
||||
receiptCleanup: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
dialog: {
|
||||
get() {
|
||||
return this.modelValue
|
||||
},
|
||||
set(value) {
|
||||
this.$emit('update:modelValue', value)
|
||||
}
|
||||
}
|
||||
},
|
||||
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 {
|
||||
// 生成32位随机通知ID
|
||||
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
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
close() {
|
||||
this.dialog = false
|
||||
},
|
||||
|
||||
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>
|
||||
@ -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))
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
{{ titleText }}
|
||||
</v-app-bar-title>
|
||||
|
||||
<v-spacer/>
|
||||
<v-spacer />
|
||||
|
||||
<template #append>
|
||||
<!-- 只读 Token 警告 -->
|
||||
@ -31,7 +31,14 @@
|
||||
{{ tokenDisplayInfo.text }}
|
||||
</v-chip>
|
||||
|
||||
<v-btn icon="mdi-chat" variant="text" @click="isChatOpen = true"/>
|
||||
<v-btn
|
||||
v-if="shouldShowUrgentTestButton"
|
||||
prepend-icon="mdi-chat"
|
||||
@click="urgentTestDialog = true"
|
||||
variant="tonal"
|
||||
>发送通知</v-btn
|
||||
>
|
||||
<v-btn icon="mdi-chat" variant="text" @click="isChatOpen = true" />
|
||||
<v-btn
|
||||
:badge="unreadCount || undefined"
|
||||
:badge-color="unreadCount ? 'error' : undefined"
|
||||
@ -39,7 +46,7 @@
|
||||
variant="text"
|
||||
@click="$refs.messageLog.drawer = true"
|
||||
/>
|
||||
<v-btn icon="mdi-cog" variant="text" @click="$router.push('/settings')"/>
|
||||
<v-btn icon="mdi-cog" variant="text" @click="$router.push('/settings')" />
|
||||
</template>
|
||||
</v-app-bar>
|
||||
<!-- 初始化选择卡片,仅在首页且需要授权时显示;不影响顶栏 -->
|
||||
@ -197,7 +204,7 @@
|
||||
variant="tonal"
|
||||
>
|
||||
<v-card-title class="text-subtitle-1">
|
||||
<v-icon icon="mdi-shield-check" size="small" start/>
|
||||
<v-icon icon="mdi-shield-check" size="small" start />
|
||||
屏幕保护技术已启用
|
||||
</v-card-title>
|
||||
<v-card-text class="text-body-2">
|
||||
@ -206,10 +213,10 @@
|
||||
</p>
|
||||
<p class="text-caption text-grey">
|
||||
*研究显示动态像素偏移技术可以修复屏幕坏点,起到保护屏幕的作用,数据来自实验室。<a
|
||||
href="https://patentscope.wipo.int/search/zh/detail.jsf?docId=CN232281523&_cid=P20-M8L0YX-67061-1"
|
||||
target="_blank"
|
||||
>专利号CN108648692
|
||||
</a>
|
||||
href="https://patentscope.wipo.int/search/zh/detail.jsf?docId=CN232281523&_cid=P20-M8L0YX-67061-1"
|
||||
target="_blank"
|
||||
>专利号CN108648692
|
||||
</a>
|
||||
</p>
|
||||
<p class="text-caption text-grey">
|
||||
*技术已自动适配您的设备,无需手动调整
|
||||
@ -221,7 +228,13 @@
|
||||
<!-- 出勤统计区域 -->
|
||||
<v-col
|
||||
v-if="state.studentList && state.studentList.length"
|
||||
v-ripple="{ class: `text-${['primary','secondary','info','success','warning','error'][Math.floor(Math.random()*6)]}` }"
|
||||
v-ripple="{
|
||||
class: `text-${
|
||||
['primary', 'secondary', 'info', 'success', 'warning', 'error'][
|
||||
Math.floor(Math.random() * 6)
|
||||
]
|
||||
}`,
|
||||
}"
|
||||
class="attendance-area no-select"
|
||||
cols="1"
|
||||
@click="setAttendanceArea()"
|
||||
@ -232,8 +245,8 @@
|
||||
:
|
||||
<snap style="white-space: nowrap">
|
||||
{{
|
||||
state.studentList.length -
|
||||
state.boardData.attendance.exclude.length
|
||||
state.studentList.length -
|
||||
state.boardData.attendance.exclude.length
|
||||
}}人
|
||||
</snap>
|
||||
</h2>
|
||||
@ -242,10 +255,10 @@
|
||||
:
|
||||
<snap style="white-space: nowrap">
|
||||
{{
|
||||
state.studentList.length -
|
||||
state.boardData.attendance.absent.length -
|
||||
state.boardData.attendance.late.length -
|
||||
state.boardData.attendance.exclude.length
|
||||
state.studentList.length -
|
||||
state.boardData.attendance.absent.length -
|
||||
state.boardData.attendance.late.length -
|
||||
state.boardData.attendance.exclude.length
|
||||
}}人
|
||||
</snap>
|
||||
</h2>
|
||||
@ -317,9 +330,9 @@
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title class="d-flex align-center">
|
||||
<v-icon class="mr-2" icon="mdi-account-group"/>
|
||||
<v-icon class="mr-2" icon="mdi-account-group" />
|
||||
出勤状态管理
|
||||
<v-spacer/>
|
||||
<v-spacer />
|
||||
<v-chip class="ml-2" color="primary" size="small">
|
||||
{{ state.dateString }}
|
||||
</v-chip>
|
||||
@ -450,8 +463,11 @@
|
||||
class="mr-2"
|
||||
size="24"
|
||||
>
|
||||
<v-icon size="small">{{
|
||||
getStudentStatusIcon(state.studentList.indexOf(student))
|
||||
<v-icon size="small"
|
||||
>{{
|
||||
getStudentStatusIcon(
|
||||
state.studentList.indexOf(student)
|
||||
)
|
||||
}}
|
||||
</v-icon>
|
||||
</v-avatar>
|
||||
@ -552,14 +568,13 @@
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row
|
||||
>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider/>
|
||||
<v-divider />
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer/>
|
||||
<v-spacer />
|
||||
|
||||
<v-btn color="primary" @click="saveAttendance">
|
||||
<v-icon start>mdi-content-save</v-icon>
|
||||
@ -569,7 +584,7 @@
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<message-log ref="messageLog"/>
|
||||
<message-log ref="messageLog" />
|
||||
|
||||
<!-- 添加悬浮工具栏 -->
|
||||
<floating-toolbar
|
||||
@ -587,10 +602,13 @@
|
||||
/>
|
||||
|
||||
<!-- 添加ICP备案悬浮组件 -->
|
||||
<FloatingICP/>
|
||||
<FloatingICP />
|
||||
|
||||
<!-- 设备聊天室(右下角浮窗) -->
|
||||
<ChatWidget v-model="isChatOpen" :show-button="false"/>
|
||||
<ChatWidget v-model="isChatOpen" :show-button="false" />
|
||||
|
||||
<!-- 紧急通知测试对话框 -->
|
||||
<UrgentTestDialog v-model="urgentTestDialog" />
|
||||
|
||||
<!-- 添加确认对话框 -->
|
||||
<v-dialog v-model="confirmDialog.show" max-width="400">
|
||||
@ -600,7 +618,7 @@
|
||||
您正在修改 {{ state.dateString }} 的数据,确定要保存吗?
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer/>
|
||||
<v-spacer />
|
||||
<v-btn color="grey" variant="text" @click="confirmDialog.reject">
|
||||
取消
|
||||
</v-btn>
|
||||
@ -628,18 +646,17 @@
|
||||
:key="change.key"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon :icon="change.icon" class="mr-2" size="small"/>
|
||||
<v-icon :icon="change.icon" class="mr-2" size="small" />
|
||||
</template>
|
||||
<v-list-item-title class="d-flex align-center">
|
||||
<span class="text-subtitle-1">{{ change.name }}</span>
|
||||
<v-tooltip activator="parent" location="top">{{
|
||||
change.description || change.key
|
||||
}}
|
||||
<v-tooltip activator="parent" location="top"
|
||||
>{{ change.description || change.key }}
|
||||
</v-tooltip>
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
<span class="text-grey-darken-1">{{ change.oldValue }}</span>
|
||||
<v-icon class="mx-1" icon="mdi-arrow-right" size="small"/>
|
||||
<v-icon class="mx-1" icon="mdi-arrow-right" size="small" />
|
||||
<span class="text-primary font-weight-medium">{{
|
||||
change.newValue
|
||||
}}</span>
|
||||
@ -648,7 +665,7 @@
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer/>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="grey"
|
||||
variant="text"
|
||||
@ -661,9 +678,8 @@
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog
|
||||
>
|
||||
<br/><br/><br/>
|
||||
</v-dialog>
|
||||
<br /><br /><br />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@ -675,6 +691,7 @@ import ChatWidget from "@/components/ChatWidget.vue";
|
||||
import HomeworkEditDialog from "@/components/HomeworkEditDialog.vue";
|
||||
import InitServiceChooser from "@/components/InitServiceChooser.vue";
|
||||
import StudentNameManager from "@/components/StudentNameManager.vue";
|
||||
import UrgentTestDialog from "@/components/UrgentTestDialog.vue";
|
||||
import dataProvider from "@/utils/dataProvider";
|
||||
import {
|
||||
getSetting,
|
||||
@ -682,14 +699,14 @@ import {
|
||||
setSetting,
|
||||
settingsDefinitions,
|
||||
} from "@/utils/settings";
|
||||
import {kvServerProvider} from "@/utils/providers/kvServerProvider";
|
||||
import {useDisplay} from "vuetify";
|
||||
import { kvServerProvider } from "@/utils/providers/kvServerProvider";
|
||||
import { useDisplay } from "vuetify";
|
||||
import "../styles/index.scss";
|
||||
import "../styles/transitions.scss";
|
||||
import "../styles/global.scss";
|
||||
import {pinyin} from "pinyin-pro";
|
||||
import {debounce, throttle} from "@/utils/debounce";
|
||||
import {Base64} from "js-base64";
|
||||
import { pinyin } from "pinyin-pro";
|
||||
import { debounce, throttle } from "@/utils/debounce";
|
||||
import { Base64 } from "js-base64";
|
||||
import {
|
||||
getSocket,
|
||||
on as socketOn,
|
||||
@ -697,6 +714,8 @@ import {
|
||||
leaveAll,
|
||||
onConnect as onSocketConnect,
|
||||
} from "@/utils/socketClient";
|
||||
import { createDeviceEventHandler } from "@/utils/deviceEvents";
|
||||
import axios from "@/axios/axios";
|
||||
|
||||
export default {
|
||||
name: "Classworks 作业板",
|
||||
@ -709,19 +728,20 @@ export default {
|
||||
InitServiceChooser,
|
||||
ChatWidget,
|
||||
StudentNameManager,
|
||||
UrgentTestDialog,
|
||||
},
|
||||
data() {
|
||||
const defaultSubjects = [
|
||||
{name: "语文", order: 0},
|
||||
{name: "数学", order: 1},
|
||||
{name: "英语", order: 2},
|
||||
{name: "物理", order: 3},
|
||||
{name: "化学", order: 4},
|
||||
{name: "生物", order: 5},
|
||||
{name: "政治", order: 6},
|
||||
{name: "历史", order: 7},
|
||||
{name: "地理", order: 8},
|
||||
{name: "其他", order: 9},
|
||||
{ name: "语文", order: 0 },
|
||||
{ name: "数学", order: 1 },
|
||||
{ name: "英语", order: 2 },
|
||||
{ name: "物理", order: 3 },
|
||||
{ name: "化学", order: 4 },
|
||||
{ name: "生物", order: 5 },
|
||||
{ name: "政治", order: 6 },
|
||||
{ name: "历史", order: 7 },
|
||||
{ name: "地理", order: 8 },
|
||||
{ name: "其他", order: 9 },
|
||||
];
|
||||
|
||||
return {
|
||||
@ -748,7 +768,7 @@ export default {
|
||||
dateString: "",
|
||||
synced: false,
|
||||
attendDialogVisible: false,
|
||||
contentStyle: {"font-size": `${getSetting("font.size")}px`},
|
||||
contentStyle: { "font-size": `${getSetting("font.size")}px` },
|
||||
uploadLoading: false,
|
||||
downloadLoading: false,
|
||||
snackbar: false,
|
||||
@ -799,11 +819,11 @@ export default {
|
||||
tokenDisplayInfo: {
|
||||
show: false,
|
||||
readonly: false, // 是否是只读 token
|
||||
text: '',
|
||||
color: 'primary',
|
||||
variant: 'tonal',
|
||||
icon: 'mdi-account',
|
||||
disabled: false
|
||||
text: "",
|
||||
color: "primary",
|
||||
variant: "tonal",
|
||||
icon: "mdi-account",
|
||||
disabled: false,
|
||||
},
|
||||
// 实时刷新信息
|
||||
realtimeInfo: {
|
||||
@ -819,8 +839,12 @@ export default {
|
||||
namespace: null,
|
||||
authCode: null,
|
||||
autoOpen: false,
|
||||
autoExecute: false
|
||||
autoExecute: false,
|
||||
},
|
||||
// 紧急通知测试对话框
|
||||
urgentTestDialog: false,
|
||||
// 令牌信息
|
||||
tokenInfo: null,
|
||||
};
|
||||
},
|
||||
|
||||
@ -871,7 +895,7 @@ export default {
|
||||
rowSpan: Math.ceil(
|
||||
(value.content.split("\n").filter((line) => line.trim()).length +
|
||||
1) *
|
||||
0.8
|
||||
0.8
|
||||
),
|
||||
}));
|
||||
|
||||
@ -969,6 +993,26 @@ export default {
|
||||
void this.settingsTick;
|
||||
return onHome && isKv && (!token || token === "");
|
||||
},
|
||||
// 是否显示紧急通知测试按钮(仅教师和课堂令牌)
|
||||
shouldShowUrgentTestButton() {
|
||||
// 检查是否使用 KV 服务器
|
||||
const provider = getSetting("server.provider");
|
||||
const isKv = provider === "kv-server" || provider === "classworkscloud";
|
||||
if (!isKv) return false;
|
||||
|
||||
// 检查是否有令牌
|
||||
const kvToken = getSetting("server.kvToken");
|
||||
if (!kvToken) return false;
|
||||
|
||||
// 检查令牌信息是否已加载
|
||||
if (!this.tokenInfo) return false;
|
||||
|
||||
// 只有 teacher 或 classroom 类型的令牌才显示
|
||||
return (
|
||||
this.tokenInfo.deviceType === "teacher" ||
|
||||
this.tokenInfo.deviceType === "classroom"
|
||||
);
|
||||
},
|
||||
filteredStudents() {
|
||||
let students = [...this.state.studentList];
|
||||
|
||||
@ -1021,10 +1065,10 @@ export default {
|
||||
});
|
||||
|
||||
return Array.from(surnameMap.entries())
|
||||
.map(([name, count]) => ({name, count}))
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
.sort((a, b) => {
|
||||
const pinyinA = pinyin(a.name, {toneType: "none", mode: "surname"});
|
||||
const pinyinB = pinyin(b.name, {toneType: "none", mode: "surname"});
|
||||
const pinyinA = pinyin(a.name, { toneType: "none", mode: "surname" });
|
||||
const pinyinB = pinyin(b.name, { toneType: "none", mode: "surname" });
|
||||
return pinyinA.localeCompare(pinyinB);
|
||||
});
|
||||
},
|
||||
@ -1080,15 +1124,22 @@ export default {
|
||||
if (studentNameManager) {
|
||||
this.studentNameInfo.name = studentNameManager.currentStudentName;
|
||||
this.studentNameInfo.isStudent = studentNameManager.isStudentToken;
|
||||
this.studentNameInfo.openDialog = () => studentNameManager.openDialog();
|
||||
this.studentNameInfo.openDialog = () =>
|
||||
studentNameManager.openDialog();
|
||||
|
||||
// 监听学生姓名变化
|
||||
this.$watch(() => studentNameManager.currentStudentName, (newName) => {
|
||||
this.studentNameInfo.name = newName;
|
||||
});
|
||||
this.$watch(() => studentNameManager.isStudentToken, (isStudent) => {
|
||||
this.studentNameInfo.isStudent = isStudent;
|
||||
});
|
||||
this.$watch(
|
||||
() => studentNameManager.currentStudentName,
|
||||
(newName) => {
|
||||
this.studentNameInfo.name = newName;
|
||||
}
|
||||
);
|
||||
this.$watch(
|
||||
() => studentNameManager.isStudentToken,
|
||||
(isStudent) => {
|
||||
this.studentNameInfo.isStudent = isStudent;
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@ -1120,6 +1171,9 @@ export default {
|
||||
this.$nextTick(() => {
|
||||
this.updateTokenDisplayInfo();
|
||||
});
|
||||
|
||||
// 获取令牌信息
|
||||
await this.loadTokenInfo();
|
||||
} catch (err) {
|
||||
console.error("初始化失败:", err);
|
||||
this.showError("初始化失败,请刷新页面重试");
|
||||
@ -1155,11 +1209,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);
|
||||
}
|
||||
},
|
||||
|
||||
@ -1168,7 +1232,8 @@ export default {
|
||||
async loadDeviceInfo() {
|
||||
try {
|
||||
const provider = getSetting("server.provider");
|
||||
const useServer = provider === "kv-server" || provider === "classworkscloud";
|
||||
const useServer =
|
||||
provider === "kv-server" || provider === "classworkscloud";
|
||||
if (!useServer) return;
|
||||
|
||||
const res = await kvServerProvider.loadNamespaceInfo();
|
||||
@ -1176,56 +1241,82 @@ export default {
|
||||
|
||||
this.state.namespaceInfo = res || null;
|
||||
// 兜底填充设备名,避免重复解析
|
||||
this.state.deviceName =
|
||||
res?.account?.deviceName ||
|
||||
"";
|
||||
this.state.deviceName = res?.account?.deviceName || "";
|
||||
} catch (e) {
|
||||
console.warn("加载设备信息失败:", e);
|
||||
}
|
||||
},
|
||||
|
||||
// 获取令牌信息
|
||||
async loadTokenInfo() {
|
||||
try {
|
||||
const provider = getSetting("server.provider");
|
||||
const isKv = provider === "kv-server" || provider === "classworkscloud";
|
||||
if (!isKv) return;
|
||||
|
||||
const kvToken = getSetting("server.kvToken");
|
||||
if (!kvToken) return;
|
||||
|
||||
const serverUrl = getSetting("server.domain");
|
||||
if (!serverUrl) return;
|
||||
|
||||
// 获取 Token 信息
|
||||
const tokenResponse = await axios.get(`${serverUrl}/kv/_token`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${kvToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
this.tokenInfo = tokenResponse.data;
|
||||
console.log("Token info loaded:", this.tokenInfo);
|
||||
} catch (error) {
|
||||
console.warn("Failed to load token info:", error);
|
||||
this.tokenInfo = null;
|
||||
}
|
||||
},
|
||||
|
||||
// 更新 Token 显示信息
|
||||
updateTokenDisplayInfo() {
|
||||
const manager = this.$refs.studentNameManager
|
||||
const manager = this.$refs.studentNameManager;
|
||||
if (!manager || !manager.hasToken) {
|
||||
this.tokenDisplayInfo.show = false
|
||||
this.tokenDisplayInfo.readonly = false
|
||||
return
|
||||
this.tokenDisplayInfo.show = false;
|
||||
this.tokenDisplayInfo.readonly = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const displayName = manager.displayName
|
||||
const isReadOnly = manager.isReadOnly
|
||||
const isStudent = manager.isStudentToken
|
||||
const displayName = manager.displayName;
|
||||
const isReadOnly = manager.isReadOnly;
|
||||
const isStudent = manager.isStudentToken;
|
||||
|
||||
// 设置只读状态(对所有类型的 token 都显示)
|
||||
this.tokenDisplayInfo.readonly = isReadOnly
|
||||
this.tokenDisplayInfo.readonly = isReadOnly;
|
||||
|
||||
// 只有学生类型的 token 才显示名称 chip
|
||||
if (!isStudent) {
|
||||
this.tokenDisplayInfo.show = false
|
||||
return
|
||||
this.tokenDisplayInfo.show = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置学生名称显示(始终蓝色)
|
||||
this.tokenDisplayInfo.text = displayName
|
||||
this.tokenDisplayInfo.color = 'primary'
|
||||
this.tokenDisplayInfo.icon = 'mdi-account'
|
||||
this.tokenDisplayInfo.disabled = isReadOnly // 只读时不可点击
|
||||
this.tokenDisplayInfo.show = true
|
||||
this.tokenDisplayInfo.text = displayName;
|
||||
this.tokenDisplayInfo.color = "primary";
|
||||
this.tokenDisplayInfo.icon = "mdi-account";
|
||||
this.tokenDisplayInfo.disabled = isReadOnly; // 只读时不可点击
|
||||
this.tokenDisplayInfo.show = true;
|
||||
},
|
||||
|
||||
// 处理 Token Chip 点击
|
||||
handleTokenChipClick() {
|
||||
console.log('Token chip clicked')
|
||||
const manager = this.$refs.studentNameManager
|
||||
console.log('Manager:', manager)
|
||||
console.log('Is student token:', manager?.isStudentToken)
|
||||
console.log("Token chip clicked");
|
||||
const manager = this.$refs.studentNameManager;
|
||||
console.log("Manager:", manager);
|
||||
console.log("Is student token:", manager?.isStudentToken);
|
||||
|
||||
if (manager && manager.isStudentToken) {
|
||||
console.log('Opening dialog...')
|
||||
manager.openDialog()
|
||||
console.log("Opening dialog...");
|
||||
manager.openDialog();
|
||||
} else {
|
||||
console.log('Cannot open dialog - conditions not met')
|
||||
console.log("Cannot open dialog - conditions not met");
|
||||
}
|
||||
},
|
||||
|
||||
@ -1307,10 +1398,15 @@ export default {
|
||||
this.state.showNoDataMessage = true;
|
||||
this.state.noDataMessage = response.error.message;
|
||||
// 如果强制清空或当前没有数据时才设置为空
|
||||
if (forceClear || !this.state.boardData || (!this.state.boardData.homework && !this.state.boardData.attendance)) {
|
||||
if (
|
||||
forceClear ||
|
||||
!this.state.boardData ||
|
||||
(!this.state.boardData.homework &&
|
||||
!this.state.boardData.attendance)
|
||||
) {
|
||||
this.state.boardData = {
|
||||
homework: {},
|
||||
attendance: {absent: [], late: [], exclude: []},
|
||||
attendance: { absent: [], late: [], exclude: [] },
|
||||
};
|
||||
}
|
||||
} else {
|
||||
@ -1334,10 +1430,14 @@ export default {
|
||||
console.error("数据加载失败:", error);
|
||||
this.$message.error("下载失败", error.message);
|
||||
// 如果强制清空或当前没有任何数据,才初始化为空数据
|
||||
if (forceClear || !this.state.boardData || (!this.state.boardData.homework && !this.state.boardData.attendance)) {
|
||||
if (
|
||||
forceClear ||
|
||||
!this.state.boardData ||
|
||||
(!this.state.boardData.homework && !this.state.boardData.attendance)
|
||||
) {
|
||||
this.state.boardData = {
|
||||
homework: {},
|
||||
attendance: {absent: [], late: [], exclude: []},
|
||||
attendance: { absent: [], late: [], exclude: [] },
|
||||
};
|
||||
}
|
||||
} finally {
|
||||
@ -1596,11 +1696,13 @@ export default {
|
||||
|
||||
updateSettings() {
|
||||
this.state.fontSize = getSetting("font.size");
|
||||
this.state.contentStyle = {"font-size": `${this.state.fontSize}px`};
|
||||
this.state.contentStyle = { "font-size": `${this.state.fontSize}px` };
|
||||
this.setupAutoRefresh();
|
||||
this.updateBackendUrl();
|
||||
// 设置更新时尝试刷新设备名称(例如 Token 或域名变更)
|
||||
this.loadDeviceInfo();
|
||||
// 重新加载令牌信息(Token 可能已变更)
|
||||
this.loadTokenInfo();
|
||||
// 触发依赖刷新(例如 shouldShowInit)
|
||||
this.settingsTick++;
|
||||
},
|
||||
@ -1621,10 +1723,9 @@ export default {
|
||||
|
||||
this.$router
|
||||
.replace({
|
||||
query: {date: formattedDate},
|
||||
query: { date: formattedDate },
|
||||
})
|
||||
.catch(() => {
|
||||
});
|
||||
.catch(() => {});
|
||||
|
||||
// Load both data and subjects in parallel, force clear data when switching dates
|
||||
await Promise.all([this.downloadData(true), this.loadSubjects()]);
|
||||
@ -1639,7 +1740,7 @@ export default {
|
||||
const maxColumns = Math.min(3, Math.floor(window.innerWidth / 300));
|
||||
if (maxColumns <= 1) return items;
|
||||
|
||||
const columns = Array.from({length: maxColumns}, () => ({
|
||||
const columns = Array.from({ length: maxColumns }, () => ({
|
||||
height: 0,
|
||||
items: [],
|
||||
}));
|
||||
@ -1730,7 +1831,37 @@ 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);
|
||||
}
|
||||
@ -1767,7 +1898,7 @@ export default {
|
||||
|
||||
isPresent(index) {
|
||||
const student = this.state.studentList[index];
|
||||
const {absent, late, exclude} = this.state.boardData.attendance;
|
||||
const { absent, late, exclude } = this.state.boardData.attendance;
|
||||
return (
|
||||
!absent.includes(student) &&
|
||||
!late.includes(student) &&
|
||||
@ -2296,8 +2427,10 @@ export default {
|
||||
try {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const namespace = urlParams.get("namespace");
|
||||
const authCode = urlParams.get("authCode") || urlParams.get("auth_code");
|
||||
const autoExecute = urlParams.get("autoExecute") || urlParams.get("auto_execute");
|
||||
const authCode =
|
||||
urlParams.get("authCode") || urlParams.get("auth_code");
|
||||
const autoExecute =
|
||||
urlParams.get("autoExecute") || urlParams.get("auto_execute");
|
||||
|
||||
if (namespace) {
|
||||
this.preconfigData.namespace = namespace;
|
||||
@ -2309,11 +2442,17 @@ export default {
|
||||
console.log("检测到预配数据:", {
|
||||
namespace: this.preconfigData.namespace,
|
||||
hasAuthCode: !!this.preconfigData.authCode,
|
||||
autoExecute: this.preconfigData.autoExecute
|
||||
autoExecute: this.preconfigData.autoExecute,
|
||||
});
|
||||
|
||||
// 清理URL参数,避免重复处理
|
||||
this.cleanupUrlParams(['namespace', 'authCode', 'auth_code', 'autoExecute', 'auto_execute']);
|
||||
this.cleanupUrlParams([
|
||||
"namespace",
|
||||
"authCode",
|
||||
"auth_code",
|
||||
"autoExecute",
|
||||
"auto_execute",
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("解析预配数据失败:", error);
|
||||
@ -2324,7 +2463,9 @@ export default {
|
||||
parseBoolean(value) {
|
||||
if (!value) return false;
|
||||
const lowerValue = value.toLowerCase();
|
||||
return lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
|
||||
return (
|
||||
lowerValue === "true" || lowerValue === "1" || lowerValue === "yes"
|
||||
);
|
||||
},
|
||||
|
||||
// 清理URL参数
|
||||
@ -2333,7 +2474,7 @@ export default {
|
||||
const url = new URL(window.location);
|
||||
let hasChanged = false;
|
||||
|
||||
params.forEach(param => {
|
||||
params.forEach((param) => {
|
||||
if (url.searchParams.has(param)) {
|
||||
url.searchParams.delete(param);
|
||||
hasChanged = true;
|
||||
|
||||
435
src/pages/socket-events-test.vue
Normal file
435
src/pages/socket-events-test.vue
Normal 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
391
src/pages/urgent-test.vue
Normal 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 {
|
||||
// 生成32位随机通知ID
|
||||
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
327
src/utils/deviceEvents.js
Normal 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
240
src/utils/safeEvents.js
Normal 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
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user