成绩通知:仅在某门课程成绩正式发布后自动发送,按成绩单学生名单通知,不展示具体分数;发布状态与通知同一次提交。

手动发信:移除“成绩通知”选项,只能发送普通通知,原有角色与范围权限保持不变。
选课通知:管理员关闭一轮选课时,自动向每位参与学生发送一条汇总通知,包含最终选中课程及候补未成功课程;候补失效、轮次关闭和通知生成保持原子性。
关闭选课界面会明确提示发送通知,并显示实际通知人数。
This commit is contained in:
2026-07-26 19:12:42 +08:00 Unverified
parent ecc6546f3a
commit b0679a253d
20 changed files with 6804 additions and 210 deletions
+1 -56
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Bell, Check, Plus, Refresh } from '@element-plus/icons-vue'
import { Check, Plus, Refresh } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
@@ -12,8 +12,6 @@ const isTeacher = computed(() => auth.user?.roles.includes('Teacher') && !isMana
const adjustments = ref<any[]>([])
const pendingReviews = ref<any[]>([])
const notifications = ref<any[]>([])
const unreadCount = ref(0)
const loading = ref(false)
const reviewLoading = ref(false)
const dialog = ref(false)
@@ -61,19 +59,10 @@ async function load() {
params: { academicTermId: termId.value },
})).data
}
await loadNotifications()
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
finally { loading.value = false }
}
async function loadNotifications() {
try {
const { data } = await http.get('/notifications', { params: { pageSize: 50 } })
notifications.value = data.items
unreadCount.value = data.unreadCount
} catch (_) { /* notifications are optional */ }
}
function openCreate() {
Object.assign(form, {
teachingTaskId: undefined,
@@ -146,23 +135,6 @@ async function doReject() {
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
}
async function markRead(n: any) {
if (n.isRead) return
try {
await http.post(`/notifications/${n.id}/read`)
n.isRead = true
unreadCount.value = Math.max(0, unreadCount.value - 1)
} catch (_) {}
}
async function markAllRead() {
try {
await http.post('/notifications/read-all')
notifications.value.forEach(n => n.isRead = true)
unreadCount.value = 0
} catch (_) {}
}
onMounted(async () => {
try {
const [termRes, taskRes] = await Promise.all([
@@ -195,9 +167,6 @@ function showCancel(type: string) { return type === 'Cancel' }
<p>教师提交调课停课补课代课申请学院审核后生效并通知相关人员</p>
</div>
<div style="display:flex;gap:8px;align-items:center">
<el-badge :value="unreadCount" :hidden="!unreadCount">
<el-button :icon="Bell" @click="tab = 'notifications'">消息</el-button>
</el-badge>
<el-button v-if="isTeacher" type="primary" :icon="Plus" @click="openCreate">提交申请</el-button>
<el-button :icon="Refresh" @click="load">刷新</el-button>
</div>
@@ -210,7 +179,6 @@ function showCancel(type: string) { return type === 'Cancel' }
<el-segmented v-model="tab" :options="[
...(isTeacher ? [{ label: '我的申请', value: 'mine' }] : []),
...(isManager ? [{ label: '待审核', value: 'reviews' }] : []),
{ label: '消息通知', value: 'notifications' },
]" />
</section>
@@ -284,22 +252,6 @@ function showCancel(type: string) { return type === 'Cancel' }
<el-empty v-if="!pendingReviews.length" description="暂无待审核申请" />
</section>
<!-- Notifications -->
<section v-if="tab === 'notifications'" class="adj-list">
<div v-if="unreadCount" class="notif-actions">
<el-button size="small" text @click="markAllRead">全部标为已读</el-button>
</div>
<article v-for="n in notifications" :key="n.id" class="notif-card" :class="{ unread: !n.isRead }" @click="markRead(n)">
<div class="notif-dot" v-if="!n.isRead" />
<div>
<b>{{ n.title }}</b>
<p>{{ n.content }}</p>
<small>{{ new Date(n.createdAt).toLocaleString('zh-CN') }}</small>
</div>
</article>
<el-empty v-if="!notifications.length" description="暂无消息通知" />
</section>
<!-- Create dialog -->
<el-dialog v-model="dialog" title="提交调停课申请" width="680px" top="5vh">
<el-form label-position="top">
@@ -410,11 +362,4 @@ function showCancel(type: string) { return type === 'Cancel' }
.adj-card.approved { border-left: 4px solid #67c23a; }
.adj-card.rejected { border-left: 4px solid #f56c6c; }
.notif-card { display: flex; align-items: flex-start; gap: 12px; padding: 14px 18px; background: #fff; border: 1px solid #e4e7ed; border-radius: 8px; cursor: pointer; }
.notif-card.unread { background: #ecf5ff; border-color: #b3d8ff; }
.notif-dot { width: 8px; height: 8px; border-radius: 50%; background: #409eff; flex-shrink: 0; margin-top: 6px; }
.notif-card b { font-size: 14px; display: block; margin-bottom: 4px; }
.notif-card p { font-size: 13px; color: #606266; margin: 0; }
.notif-card small { font-size: 11px; color: var(--muted); }
.notif-actions { display: flex; justify-content: flex-end; margin-bottom: 8px; }
</style>