feat: add urgent notification feature and improve UI components

- Updated index.vue to include an urgent notification button and dialog.
- Added UrgentTestDialog.vue component for sending notifications with urgency options.
- Enhanced UI elements for better user experience, including improved spacing and layout.
- Implemented notification history display with receipt tracking for sent messages.
- Added methods for handling notification sending and receipt updates via socket events.
This commit is contained in:
Sunwuyuan
2025-11-23 16:48:08 +08:00
parent ca4de545b9
commit 6c990bd8e4
3 changed files with 844 additions and 160 deletions
+191 -40
View File
@@ -13,7 +13,7 @@
>
<v-card-text>
<div class="urgent-title mb-6">
{{ notification?.content?.message || "无内容" }}
{{ currentNotification?.content?.message || "无内容" }}
</div>
<!-- 发送者信息(使用 Vuetify Card) -->
@@ -45,11 +45,47 @@
size="small"
>
<v-icon left size="16"> mdi-clock </v-icon>
{{ formatTime(notification?.timestamp) }}
{{ 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">
@@ -76,14 +112,32 @@ export default {
data() {
return {
visible: false,
notification: null,
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.notification?.content?.isUrgent || false;
return this.currentNotification?.content?.isUrgent || false;
},
urgencyColor() {
return this.isUrgent ? "red darken-2" : "blue darken-2";
@@ -101,18 +155,20 @@ export default {
},
senderName() {
const senderInfo =
this.notification?.senderInfo || this.notification?.content?.senderInfo;
this.currentNotification?.senderInfo ||
this.currentNotification?.content?.senderInfo;
if (!senderInfo) return "未知发送者";
return senderInfo.deviceName || senderInfo.deviceType || "未知设备";
},
deviceType() {
const senderInfo =
this.notification?.senderInfo || this.notification?.content?.senderInfo;
this.currentNotification?.senderInfo ||
this.currentNotification?.content?.senderInfo;
return senderInfo?.deviceType || "未知类型";
},
targetDevices() {
return this.notification?.content?.targetDevices || [];
return this.currentNotification?.content?.targetDevices || [];
},
},
beforeUnmount() {
@@ -125,23 +181,39 @@ export default {
},
methods: {
show(notificationData) {
this.notification = notificationData;
this.visible = true;
// 检查是否已存在相同的通知(避免重复)
const existingIndex = this.notificationQueue.findIndex(
(n) =>
n.content?.notificationId === notificationData.content?.notificationId
);
// 发送显示回执
this.sendDisplayedReceipt();
// 清除之前的自动关闭定时器
if (this.autoCloseTimer) {
clearTimeout(this.autoCloseTimer);
if (existingIndex !== -1) {
console.log("通知已存在,跳过添加");
return;
}
// 播放统一的提示音
this.playNotificationSound();
// 添加到队列
this.notificationQueue.push(notificationData);
// 如果是加急通知,启动定时音效
if (this.isUrgent) {
this.startUrgentSound();
// 如果当前没有显示通知,显示第一个
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() {
@@ -153,24 +225,58 @@ export default {
console.warn("发送已读回执失败:", error);
}
this.closeWithoutRead();
// 显示已读消息提示,便于设备端重新查看
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.notification = null;
this.notificationQueue = [];
this.currentIndex = 0;
if (this.autoCloseTimer) {
clearTimeout(this.autoCloseTimer);
this.autoCloseTimer = null;
}
// 停止加急音效定时器
if (this.urgentSoundTimer) {
clearInterval(this.urgentSoundTimer);
this.urgentSoundTimer = null;
}
this.stopUrgentSound();
},
formatTime(timestamp) {
if (!timestamp) return "";
@@ -230,12 +336,12 @@ export default {
// 发送显示回执
sendDisplayedReceipt() {
try {
if (this.$refs.eventSender && this.notification?.eventId) {
if (this.$refs.eventSender && this.currentNotification?.eventId) {
this.$refs.eventSender.sendDisplayedReceipt(
{},
this.notification.content.notificationId
this.currentNotification.content.notificationId
);
console.log("已发送显示回执:", this.notification.eventId);
console.log("已发送显示回执:", this.currentNotification.eventId);
}
} catch (error) {
console.warn("发送显示回执失败:", error);
@@ -244,35 +350,65 @@ export default {
// 发送已读回执
sendReadReceipt() {
try {
if (this.$refs.eventSender && this.notification?.eventId) {
if (this.$refs.eventSender && this.currentNotification?.eventId) {
this.$refs.eventSender.sendReadReceipt(
{},
this.notification.content.notificationId
this.currentNotification.content.notificationId
);
console.log("已发送已读回执:", this.notification.eventId);
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() {
// 清除之前的定时器
if (this.urgentSoundTimer) {
clearInterval(this.urgentSoundTimer);
}
this.stopUrgentSound(); // 先清除之前的定时器
// 每秒播放一次提示音
this.urgentSoundTimer = setInterval(() => {
if (this.visible && this.isUrgent) {
this.playNotificationSound();
} else {
// 如果弹框已关闭或不再是加急状态,停止音效
clearInterval(this.urgentSoundTimer);
this.urgentSoundTimer = null;
this.stopUrgentSound();
}
}, 1000);
},
// 停止加急音效
stopUrgentSound() {
if (this.urgentSoundTimer) {
clearInterval(this.urgentSoundTimer);
this.urgentSoundTimer = null;
}
},
},
};
</script>
@@ -341,6 +477,21 @@ export default {
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%,
+430
View 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>