1
0
mirror of https://github.com/ZeroCatDev/Classworks.git synced 2025-12-08 13:49:37 +00:00

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
No known key found for this signature in database
GPG Key ID: A6A54CF66F56BB64
3 changed files with 844 additions and 160 deletions

View File

@ -13,7 +13,7 @@
> >
<v-card-text> <v-card-text>
<div class="urgent-title mb-6"> <div class="urgent-title mb-6">
{{ notification?.content?.message || "无内容" }} {{ currentNotification?.content?.message || "无内容" }}
</div> </div>
<!-- 发送者信息使用 Vuetify Card --> <!-- 发送者信息使用 Vuetify Card -->
@ -45,11 +45,47 @@
size="small" size="small"
> >
<v-icon left size="16"> mdi-clock </v-icon> <v-icon left size="16"> mdi-clock </v-icon>
{{ formatTime(notification?.timestamp) }} {{ formatTime(currentNotification?.timestamp) }}
</v-chip> </v-chip>
</v-card-text> </v-card-text>
</v-card> </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"> <div class="mt-8">
<v-btn color="white" size="large" variant="flat" @click="close"> <v-btn color="white" size="large" variant="flat" @click="close">
@ -76,14 +112,32 @@ export default {
data() { data() {
return { return {
visible: false, visible: false,
notification: null, notificationQueue: [], //
currentIndex: 0, //
autoCloseTimer: null, autoCloseTimer: null,
urgentSoundTimer: null, urgentSoundTimer: null,
}; };
}, },
computed: { 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() { isUrgent() {
return this.notification?.content?.isUrgent || false; return this.currentNotification?.content?.isUrgent || false;
}, },
urgencyColor() { urgencyColor() {
return this.isUrgent ? "red darken-2" : "blue darken-2"; return this.isUrgent ? "red darken-2" : "blue darken-2";
@ -101,18 +155,20 @@ export default {
}, },
senderName() { senderName() {
const senderInfo = const senderInfo =
this.notification?.senderInfo || this.notification?.content?.senderInfo; this.currentNotification?.senderInfo ||
this.currentNotification?.content?.senderInfo;
if (!senderInfo) return "未知发送者"; if (!senderInfo) return "未知发送者";
return senderInfo.deviceName || senderInfo.deviceType || "未知设备"; return senderInfo.deviceName || senderInfo.deviceType || "未知设备";
}, },
deviceType() { deviceType() {
const senderInfo = const senderInfo =
this.notification?.senderInfo || this.notification?.content?.senderInfo; this.currentNotification?.senderInfo ||
this.currentNotification?.content?.senderInfo;
return senderInfo?.deviceType || "未知类型"; return senderInfo?.deviceType || "未知类型";
}, },
targetDevices() { targetDevices() {
return this.notification?.content?.targetDevices || []; return this.currentNotification?.content?.targetDevices || [];
}, },
}, },
beforeUnmount() { beforeUnmount() {
@ -125,24 +181,40 @@ export default {
}, },
methods: { methods: {
show(notificationData) { show(notificationData) {
this.notification = notificationData; //
this.visible = true; const existingIndex = this.notificationQueue.findIndex(
(n) =>
n.content?.notificationId === notificationData.content?.notificationId
);
// if (existingIndex !== -1) {
this.sendDisplayedReceipt(); console.log("通知已存在,跳过添加");
return;
//
if (this.autoCloseTimer) {
clearTimeout(this.autoCloseTimer);
} }
// //
this.notificationQueue.push(notificationData);
//
if (!this.visible) {
this.currentIndex = this.notificationQueue.length - 1; //
this.visible = true;
this.sendDisplayedReceipt();
this.playNotificationSound(); this.playNotificationSound();
// //
if (this.isUrgent) { if (this.isUrgent) {
this.startUrgentSound(); this.startUrgentSound();
} }
} else {
//
if (notificationData.content?.isUrgent && !this.isUrgent) {
this.currentIndex = this.notificationQueue.length - 1;
this.sendDisplayedReceipt();
this.playNotificationSound();
this.startUrgentSound();
}
}
}, },
close() { close() {
// //
@ -153,24 +225,58 @@ export default {
console.warn("发送已读回执失败:", 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(); this.closeWithoutRead();
}
}
}, },
// //
closeWithoutRead() { closeWithoutRead() {
// //
this.visible = false; this.visible = false;
this.notification = null; this.notificationQueue = [];
this.currentIndex = 0;
if (this.autoCloseTimer) { if (this.autoCloseTimer) {
clearTimeout(this.autoCloseTimer); clearTimeout(this.autoCloseTimer);
this.autoCloseTimer = null; this.autoCloseTimer = null;
} }
// this.stopUrgentSound();
if (this.urgentSoundTimer) {
clearInterval(this.urgentSoundTimer);
this.urgentSoundTimer = null;
}
}, },
formatTime(timestamp) { formatTime(timestamp) {
if (!timestamp) return ""; if (!timestamp) return "";
@ -230,12 +336,12 @@ export default {
// //
sendDisplayedReceipt() { sendDisplayedReceipt() {
try { try {
if (this.$refs.eventSender && this.notification?.eventId) { if (this.$refs.eventSender && this.currentNotification?.eventId) {
this.$refs.eventSender.sendDisplayedReceipt( this.$refs.eventSender.sendDisplayedReceipt(
{}, {},
this.notification.content.notificationId this.currentNotification.content.notificationId
); );
console.log("已发送显示回执:", this.notification.eventId); console.log("已发送显示回执:", this.currentNotification.eventId);
} }
} catch (error) { } catch (error) {
console.warn("发送显示回执失败:", error); console.warn("发送显示回执失败:", error);
@ -244,34 +350,64 @@ export default {
// //
sendReadReceipt() { sendReadReceipt() {
try { try {
if (this.$refs.eventSender && this.notification?.eventId) { if (this.$refs.eventSender && this.currentNotification?.eventId) {
this.$refs.eventSender.sendReadReceipt( this.$refs.eventSender.sendReadReceipt(
{}, {},
this.notification.content.notificationId this.currentNotification.content.notificationId
); );
console.log("已发送已读回执:", this.notification.eventId); console.log("已发送已读回执:", this.currentNotification.eventId);
} }
} catch (error) { } catch (error) {
console.warn("发送已读回执失败:", 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() { startUrgentSound() {
// this.stopUrgentSound(); //
if (this.urgentSoundTimer) {
clearInterval(this.urgentSoundTimer);
}
// //
this.urgentSoundTimer = setInterval(() => { this.urgentSoundTimer = setInterval(() => {
if (this.visible && this.isUrgent) { if (this.visible && this.isUrgent) {
this.playNotificationSound(); this.playNotificationSound();
} else { } else {
// this.stopUrgentSound();
}
}, 1000);
},
//
stopUrgentSound() {
if (this.urgentSoundTimer) {
clearInterval(this.urgentSoundTimer); clearInterval(this.urgentSoundTimer);
this.urgentSoundTimer = null; this.urgentSoundTimer = null;
} }
}, 1000);
}, },
}, },
}; };
@ -341,6 +477,21 @@ export default {
gap: 16px; 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 { @keyframes urgentPulse {
0%, 0%,

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 {
// 32ID
const notificationId = this.generateNotificationId()
const result = await this.$refs.eventSender.sendNotification(
this.notificationForm.message,
this.notificationForm.isUrgent,
[],
{ deviceName: '测试设备', deviceType: 'system', isReadOnly: false },
notificationId
)
const eventId = result?.eventId || `msg-${Date.now()}`
this.sentMessages.push({
id: eventId,
notificationId: notificationId,
message: this.notificationForm.message,
isUrgent: this.notificationForm.isUrgent,
timestamp: new Date().toISOString(),
receipts: {
displayed: [],
read: []
}
})
console.log('通知已发送事件ID:', eventId, '通知ID:', notificationId)
this.resetForm()
} catch (error) {
console.error('发送通知失败:', error)
} finally {
this.sending = false
}
},
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>

View File

@ -4,7 +4,7 @@
{{ titleText }} {{ titleText }}
</v-app-bar-title> </v-app-bar-title>
<v-spacer/> <v-spacer />
<template #append> <template #append>
<!-- 只读 Token 警告 --> <!-- 只读 Token 警告 -->
@ -31,7 +31,14 @@
{{ tokenDisplayInfo.text }} {{ tokenDisplayInfo.text }}
</v-chip> </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 <v-btn
:badge="unreadCount || undefined" :badge="unreadCount || undefined"
:badge-color="unreadCount ? 'error' : undefined" :badge-color="unreadCount ? 'error' : undefined"
@ -39,7 +46,7 @@
variant="text" variant="text"
@click="$refs.messageLog.drawer = true" @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> </template>
</v-app-bar> </v-app-bar>
<!-- 初始化选择卡片仅在首页且需要授权时显示不影响顶栏 --> <!-- 初始化选择卡片仅在首页且需要授权时显示不影响顶栏 -->
@ -197,7 +204,7 @@
variant="tonal" variant="tonal"
> >
<v-card-title class="text-subtitle-1"> <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-title>
<v-card-text class="text-body-2"> <v-card-text class="text-body-2">
@ -221,7 +228,13 @@
<!-- 出勤统计区域 --> <!-- 出勤统计区域 -->
<v-col <v-col
v-if="state.studentList && state.studentList.length" 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" class="attendance-area no-select"
cols="1" cols="1"
@click="setAttendanceArea()" @click="setAttendanceArea()"
@ -317,9 +330,9 @@
> >
<v-card> <v-card>
<v-card-title class="d-flex align-center"> <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"> <v-chip class="ml-2" color="primary" size="small">
{{ state.dateString }} {{ state.dateString }}
</v-chip> </v-chip>
@ -450,8 +463,11 @@
class="mr-2" class="mr-2"
size="24" size="24"
> >
<v-icon size="small">{{ <v-icon size="small"
getStudentStatusIcon(state.studentList.indexOf(student)) >{{
getStudentStatusIcon(
state.studentList.indexOf(student)
)
}} }}
</v-icon> </v-icon>
</v-avatar> </v-avatar>
@ -552,14 +568,13 @@
</v-card-text> </v-card-text>
</v-card> </v-card>
</v-col> </v-col>
</v-row </v-row>
>
</v-card-text> </v-card-text>
<v-divider/> <v-divider />
<v-card-actions> <v-card-actions>
<v-spacer/> <v-spacer />
<v-btn color="primary" @click="saveAttendance"> <v-btn color="primary" @click="saveAttendance">
<v-icon start>mdi-content-save</v-icon> <v-icon start>mdi-content-save</v-icon>
@ -569,7 +584,7 @@
</v-card> </v-card>
</v-dialog> </v-dialog>
<message-log ref="messageLog"/> <message-log ref="messageLog" />
<!-- 添加悬浮工具栏 --> <!-- 添加悬浮工具栏 -->
<floating-toolbar <floating-toolbar
@ -587,10 +602,13 @@
/> />
<!-- 添加ICP备案悬浮组件 --> <!-- 添加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"> <v-dialog v-model="confirmDialog.show" max-width="400">
@ -600,7 +618,7 @@
您正在修改 {{ state.dateString }} 的数据确定要保存吗 您正在修改 {{ state.dateString }} 的数据确定要保存吗
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
<v-spacer/> <v-spacer />
<v-btn color="grey" variant="text" @click="confirmDialog.reject"> <v-btn color="grey" variant="text" @click="confirmDialog.reject">
取消 取消
</v-btn> </v-btn>
@ -628,18 +646,17 @@
:key="change.key" :key="change.key"
> >
<template #prepend> <template #prepend>
<v-icon :icon="change.icon" class="mr-2" size="small"/> <v-icon :icon="change.icon" class="mr-2" size="small" />
</template> </template>
<v-list-item-title class="d-flex align-center"> <v-list-item-title class="d-flex align-center">
<span class="text-subtitle-1">{{ change.name }}</span> <span class="text-subtitle-1">{{ change.name }}</span>
<v-tooltip activator="parent" location="top">{{ <v-tooltip activator="parent" location="top"
change.description || change.key >{{ change.description || change.key }}
}}
</v-tooltip> </v-tooltip>
</v-list-item-title> </v-list-item-title>
<v-list-item-subtitle> <v-list-item-subtitle>
<span class="text-grey-darken-1">{{ change.oldValue }}</span> <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">{{ <span class="text-primary font-weight-medium">{{
change.newValue change.newValue
}}</span> }}</span>
@ -648,7 +665,7 @@
</v-list> </v-list>
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
<v-spacer/> <v-spacer />
<v-btn <v-btn
color="grey" color="grey"
variant="text" variant="text"
@ -661,9 +678,8 @@
</v-btn> </v-btn>
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-dialog </v-dialog>
> <br /><br /><br />
<br/><br/><br/>
</template> </template>
<script> <script>
@ -675,6 +691,7 @@ import ChatWidget from "@/components/ChatWidget.vue";
import HomeworkEditDialog from "@/components/HomeworkEditDialog.vue"; import HomeworkEditDialog from "@/components/HomeworkEditDialog.vue";
import InitServiceChooser from "@/components/InitServiceChooser.vue"; import InitServiceChooser from "@/components/InitServiceChooser.vue";
import StudentNameManager from "@/components/StudentNameManager.vue"; import StudentNameManager from "@/components/StudentNameManager.vue";
import UrgentTestDialog from "@/components/UrgentTestDialog.vue";
import dataProvider from "@/utils/dataProvider"; import dataProvider from "@/utils/dataProvider";
import { import {
getSetting, getSetting,
@ -682,14 +699,14 @@ import {
setSetting, setSetting,
settingsDefinitions, settingsDefinitions,
} from "@/utils/settings"; } from "@/utils/settings";
import {kvServerProvider} from "@/utils/providers/kvServerProvider"; import { kvServerProvider } from "@/utils/providers/kvServerProvider";
import {useDisplay} from "vuetify"; import { useDisplay } from "vuetify";
import "../styles/index.scss"; import "../styles/index.scss";
import "../styles/transitions.scss"; import "../styles/transitions.scss";
import "../styles/global.scss"; import "../styles/global.scss";
import {pinyin} from "pinyin-pro"; import { pinyin } from "pinyin-pro";
import {debounce, throttle} from "@/utils/debounce"; import { debounce, throttle } from "@/utils/debounce";
import {Base64} from "js-base64"; import { Base64 } from "js-base64";
import { import {
getSocket, getSocket,
on as socketOn, on as socketOn,
@ -697,7 +714,8 @@ import {
leaveAll, leaveAll,
onConnect as onSocketConnect, onConnect as onSocketConnect,
} from "@/utils/socketClient"; } from "@/utils/socketClient";
import {createDeviceEventHandler} from "@/utils/deviceEvents"; import { createDeviceEventHandler } from "@/utils/deviceEvents";
import axios from "@/axios/axios";
export default { export default {
name: "Classworks 作业板", name: "Classworks 作业板",
@ -710,19 +728,20 @@ export default {
InitServiceChooser, InitServiceChooser,
ChatWidget, ChatWidget,
StudentNameManager, StudentNameManager,
UrgentTestDialog,
}, },
data() { data() {
const defaultSubjects = [ const defaultSubjects = [
{name: "语文", order: 0}, { name: "语文", order: 0 },
{name: "数学", order: 1}, { name: "数学", order: 1 },
{name: "英语", order: 2}, { name: "英语", order: 2 },
{name: "物理", order: 3}, { name: "物理", order: 3 },
{name: "化学", order: 4}, { name: "化学", order: 4 },
{name: "生物", order: 5}, { name: "生物", order: 5 },
{name: "政治", order: 6}, { name: "政治", order: 6 },
{name: "历史", order: 7}, { name: "历史", order: 7 },
{name: "地理", order: 8}, { name: "地理", order: 8 },
{name: "其他", order: 9}, { name: "其他", order: 9 },
]; ];
return { return {
@ -749,7 +768,7 @@ export default {
dateString: "", dateString: "",
synced: false, synced: false,
attendDialogVisible: false, attendDialogVisible: false,
contentStyle: {"font-size": `${getSetting("font.size")}px`}, contentStyle: { "font-size": `${getSetting("font.size")}px` },
uploadLoading: false, uploadLoading: false,
downloadLoading: false, downloadLoading: false,
snackbar: false, snackbar: false,
@ -800,11 +819,11 @@ export default {
tokenDisplayInfo: { tokenDisplayInfo: {
show: false, show: false,
readonly: false, // token readonly: false, // token
text: '', text: "",
color: 'primary', color: "primary",
variant: 'tonal', variant: "tonal",
icon: 'mdi-account', icon: "mdi-account",
disabled: false disabled: false,
}, },
// //
realtimeInfo: { realtimeInfo: {
@ -820,8 +839,12 @@ export default {
namespace: null, namespace: null,
authCode: null, authCode: null,
autoOpen: false, autoOpen: false,
autoExecute: false autoExecute: false,
}, },
//
urgentTestDialog: false,
//
tokenInfo: null,
}; };
}, },
@ -970,6 +993,26 @@ export default {
void this.settingsTick; void this.settingsTick;
return onHome && isKv && (!token || token === ""); 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() { filteredStudents() {
let students = [...this.state.studentList]; let students = [...this.state.studentList];
@ -1022,10 +1065,10 @@ export default {
}); });
return Array.from(surnameMap.entries()) return Array.from(surnameMap.entries())
.map(([name, count]) => ({name, count})) .map(([name, count]) => ({ name, count }))
.sort((a, b) => { .sort((a, b) => {
const pinyinA = pinyin(a.name, {toneType: "none", mode: "surname"}); const pinyinA = pinyin(a.name, { toneType: "none", mode: "surname" });
const pinyinB = pinyin(b.name, {toneType: "none", mode: "surname"}); const pinyinB = pinyin(b.name, { toneType: "none", mode: "surname" });
return pinyinA.localeCompare(pinyinB); return pinyinA.localeCompare(pinyinB);
}); });
}, },
@ -1081,15 +1124,22 @@ export default {
if (studentNameManager) { if (studentNameManager) {
this.studentNameInfo.name = studentNameManager.currentStudentName; this.studentNameInfo.name = studentNameManager.currentStudentName;
this.studentNameInfo.isStudent = studentNameManager.isStudentToken; this.studentNameInfo.isStudent = studentNameManager.isStudentToken;
this.studentNameInfo.openDialog = () => studentNameManager.openDialog(); this.studentNameInfo.openDialog = () =>
studentNameManager.openDialog();
// //
this.$watch(() => studentNameManager.currentStudentName, (newName) => { this.$watch(
() => studentNameManager.currentStudentName,
(newName) => {
this.studentNameInfo.name = newName; this.studentNameInfo.name = newName;
}); }
this.$watch(() => studentNameManager.isStudentToken, (isStudent) => { );
this.$watch(
() => studentNameManager.isStudentToken,
(isStudent) => {
this.studentNameInfo.isStudent = isStudent; this.studentNameInfo.isStudent = isStudent;
}); }
);
} }
}); });
@ -1121,6 +1171,9 @@ export default {
this.$nextTick(() => { this.$nextTick(() => {
this.updateTokenDisplayInfo(); this.updateTokenDisplayInfo();
}); });
//
await this.loadTokenInfo();
} catch (err) { } catch (err) {
console.error("初始化失败:", err); console.error("初始化失败:", err);
this.showError("初始化失败,请刷新页面重试"); this.showError("初始化失败,请刷新页面重试");
@ -1156,21 +1209,21 @@ export default {
// 退 // 退
try { try {
if (this.$offKvChanged && typeof this.$offKvChanged === 'function') { if (this.$offKvChanged && typeof this.$offKvChanged === "function") {
this.$offKvChanged(); this.$offKvChanged();
this.$offKvChanged = null; this.$offKvChanged = null;
} }
if (this.$offDeviceEvent && typeof this.$offDeviceEvent === 'function') { if (this.$offDeviceEvent && typeof this.$offDeviceEvent === "function") {
this.$offDeviceEvent(); this.$offDeviceEvent();
this.$offDeviceEvent = null; this.$offDeviceEvent = null;
} }
if (this.$offConnect && typeof this.$offConnect === 'function') { if (this.$offConnect && typeof this.$offConnect === "function") {
this.$offConnect(); this.$offConnect();
this.$offConnect = null; this.$offConnect = null;
} }
leaveAll(); leaveAll();
} catch (e) { } catch (e) {
console.warn('主页面事件清理失败:', e); console.warn("主页面事件清理失败:", e);
} }
}, },
@ -1179,7 +1232,8 @@ export default {
async loadDeviceInfo() { async loadDeviceInfo() {
try { try {
const provider = getSetting("server.provider"); const provider = getSetting("server.provider");
const useServer = provider === "kv-server" || provider === "classworkscloud"; const useServer =
provider === "kv-server" || provider === "classworkscloud";
if (!useServer) return; if (!useServer) return;
const res = await kvServerProvider.loadNamespaceInfo(); const res = await kvServerProvider.loadNamespaceInfo();
@ -1187,56 +1241,82 @@ export default {
this.state.namespaceInfo = res || null; this.state.namespaceInfo = res || null;
// //
this.state.deviceName = this.state.deviceName = res?.account?.deviceName || "";
res?.account?.deviceName ||
"";
} catch (e) { } catch (e) {
console.warn("加载设备信息失败:", 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 // Token
updateTokenDisplayInfo() { updateTokenDisplayInfo() {
const manager = this.$refs.studentNameManager const manager = this.$refs.studentNameManager;
if (!manager || !manager.hasToken) { if (!manager || !manager.hasToken) {
this.tokenDisplayInfo.show = false this.tokenDisplayInfo.show = false;
this.tokenDisplayInfo.readonly = false this.tokenDisplayInfo.readonly = false;
return return;
} }
const displayName = manager.displayName const displayName = manager.displayName;
const isReadOnly = manager.isReadOnly const isReadOnly = manager.isReadOnly;
const isStudent = manager.isStudentToken const isStudent = manager.isStudentToken;
// token // token
this.tokenDisplayInfo.readonly = isReadOnly this.tokenDisplayInfo.readonly = isReadOnly;
// token chip // token chip
if (!isStudent) { if (!isStudent) {
this.tokenDisplayInfo.show = false this.tokenDisplayInfo.show = false;
return return;
} }
// //
this.tokenDisplayInfo.text = displayName this.tokenDisplayInfo.text = displayName;
this.tokenDisplayInfo.color = 'primary' this.tokenDisplayInfo.color = "primary";
this.tokenDisplayInfo.icon = 'mdi-account' this.tokenDisplayInfo.icon = "mdi-account";
this.tokenDisplayInfo.disabled = isReadOnly // this.tokenDisplayInfo.disabled = isReadOnly; //
this.tokenDisplayInfo.show = true this.tokenDisplayInfo.show = true;
}, },
// Token Chip // Token Chip
handleTokenChipClick() { handleTokenChipClick() {
console.log('Token chip clicked') console.log("Token chip clicked");
const manager = this.$refs.studentNameManager const manager = this.$refs.studentNameManager;
console.log('Manager:', manager) console.log("Manager:", manager);
console.log('Is student token:', manager?.isStudentToken) console.log("Is student token:", manager?.isStudentToken);
if (manager && manager.isStudentToken) { if (manager && manager.isStudentToken) {
console.log('Opening dialog...') console.log("Opening dialog...");
manager.openDialog() manager.openDialog();
} else { } else {
console.log('Cannot open dialog - conditions not met') console.log("Cannot open dialog - conditions not met");
} }
}, },
@ -1318,10 +1398,15 @@ export default {
this.state.showNoDataMessage = true; this.state.showNoDataMessage = true;
this.state.noDataMessage = response.error.message; 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 = { this.state.boardData = {
homework: {}, homework: {},
attendance: {absent: [], late: [], exclude: []}, attendance: { absent: [], late: [], exclude: [] },
}; };
} }
} else { } else {
@ -1345,10 +1430,14 @@ export default {
console.error("数据加载失败:", error); console.error("数据加载失败:", error);
this.$message.error("下载失败", error.message); 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 = { this.state.boardData = {
homework: {}, homework: {},
attendance: {absent: [], late: [], exclude: []}, attendance: { absent: [], late: [], exclude: [] },
}; };
} }
} finally { } finally {
@ -1607,11 +1696,13 @@ export default {
updateSettings() { updateSettings() {
this.state.fontSize = getSetting("font.size"); 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.setupAutoRefresh();
this.updateBackendUrl(); this.updateBackendUrl();
// Token // Token
this.loadDeviceInfo(); this.loadDeviceInfo();
// Token
this.loadTokenInfo();
// shouldShowInit // shouldShowInit
this.settingsTick++; this.settingsTick++;
}, },
@ -1632,10 +1723,9 @@ export default {
this.$router this.$router
.replace({ .replace({
query: {date: formattedDate}, query: { date: formattedDate },
}) })
.catch(() => { .catch(() => {});
});
// Load both data and subjects in parallel, force clear data when switching dates // Load both data and subjects in parallel, force clear data when switching dates
await Promise.all([this.downloadData(true), this.loadSubjects()]); await Promise.all([this.downloadData(true), this.loadSubjects()]);
@ -1650,7 +1740,7 @@ export default {
const maxColumns = Math.min(3, Math.floor(window.innerWidth / 300)); const maxColumns = Math.min(3, Math.floor(window.innerWidth / 300));
if (maxColumns <= 1) return items; if (maxColumns <= 1) return items;
const columns = Array.from({length: maxColumns}, () => ({ const columns = Array.from({ length: maxColumns }, () => ({
height: 0, height: 0,
items: [], items: [],
})); }));
@ -1748,13 +1838,13 @@ export default {
// //
if (eventData.content && eventData.timestamp) { if (eventData.content && eventData.timestamp) {
msg = { msg = {
uuid: eventData.senderId || 'realtime', uuid: eventData.senderId || "realtime",
key: eventData.content.key, key: eventData.content.key,
action: eventData.content.action, action: eventData.content.action,
created: eventData.content.created, created: eventData.content.created,
updatedAt: eventData.content.updatedAt || eventData.timestamp, updatedAt: eventData.content.updatedAt || eventData.timestamp,
deletedAt: eventData.content.deletedAt, deletedAt: eventData.content.deletedAt,
batch: eventData.content.batch batch: eventData.content.batch,
}; };
} }
@ -1766,9 +1856,12 @@ export default {
// //
this.deviceEventHandler = createDeviceEventHandler({ this.deviceEventHandler = createDeviceEventHandler({
onKvChanged: handler, onKvChanged: handler,
enableLegacySupport: true enableLegacySupport: true,
}); });
this.$offDeviceEvent = socketOn("device-event", this.deviceEventHandler); this.$offDeviceEvent = socketOn(
"device-event",
this.deviceEventHandler
);
} catch (e) { } catch (e) {
console.warn("实时频道初始化失败", e); console.warn("实时频道初始化失败", e);
} }
@ -1805,7 +1898,7 @@ export default {
isPresent(index) { isPresent(index) {
const student = this.state.studentList[index]; const student = this.state.studentList[index];
const {absent, late, exclude} = this.state.boardData.attendance; const { absent, late, exclude } = this.state.boardData.attendance;
return ( return (
!absent.includes(student) && !absent.includes(student) &&
!late.includes(student) && !late.includes(student) &&
@ -2334,8 +2427,10 @@ export default {
try { try {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const namespace = urlParams.get("namespace"); const namespace = urlParams.get("namespace");
const authCode = urlParams.get("authCode") || urlParams.get("auth_code"); const authCode =
const autoExecute = urlParams.get("autoExecute") || urlParams.get("auto_execute"); urlParams.get("authCode") || urlParams.get("auth_code");
const autoExecute =
urlParams.get("autoExecute") || urlParams.get("auto_execute");
if (namespace) { if (namespace) {
this.preconfigData.namespace = namespace; this.preconfigData.namespace = namespace;
@ -2347,11 +2442,17 @@ export default {
console.log("检测到预配数据:", { console.log("检测到预配数据:", {
namespace: this.preconfigData.namespace, namespace: this.preconfigData.namespace,
hasAuthCode: !!this.preconfigData.authCode, hasAuthCode: !!this.preconfigData.authCode,
autoExecute: this.preconfigData.autoExecute autoExecute: this.preconfigData.autoExecute,
}); });
// URL // URL
this.cleanupUrlParams(['namespace', 'authCode', 'auth_code', 'autoExecute', 'auto_execute']); this.cleanupUrlParams([
"namespace",
"authCode",
"auth_code",
"autoExecute",
"auto_execute",
]);
} }
} catch (error) { } catch (error) {
console.error("解析预配数据失败:", error); console.error("解析预配数据失败:", error);
@ -2362,7 +2463,9 @@ export default {
parseBoolean(value) { parseBoolean(value) {
if (!value) return false; if (!value) return false;
const lowerValue = value.toLowerCase(); const lowerValue = value.toLowerCase();
return lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes'; return (
lowerValue === "true" || lowerValue === "1" || lowerValue === "yes"
);
}, },
// URL // URL
@ -2371,7 +2474,7 @@ export default {
const url = new URL(window.location); const url = new URL(window.location);
let hasChanged = false; let hasChanged = false;
params.forEach(param => { params.forEach((param) => {
if (url.searchParams.has(param)) { if (url.searchParams.has(param)) {
url.searchParams.delete(param); url.searchParams.delete(param);
hasChanged = true; hasChanged = true;