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