统一通知中心

This commit is contained in:
2026-07-25 15:50:00 +08:00 Unverified
parent 13b61f191a
commit 2c6103d3ae
8 changed files with 552 additions and 144 deletions
+5 -7
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Bell, Check, Close, Plus, Refresh } from '@element-plus/icons-vue'
import { Bell, Check, Plus, Refresh } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
@@ -41,7 +41,7 @@ const form = reactive({
const typeLabels: Record<string, string> = { Reschedule: '调课', Cancel: '停课', Makeup: '补课', Substitute: '代课' }
const statusLabels: Record<string, string> = { Draft: '草稿', Submitted: '待审核', Approved: '已通过', Rejected: '已退回' }
const statusColors: Record<string, string> = { Draft: 'info', Submitted: 'warning', Approved: 'success', Rejected: 'danger' }
const statusColors: Record<string, 'info' | 'warning' | 'success' | 'danger'> = { Draft: 'info', Submitted: 'warning', Approved: 'success', Rejected: 'danger' }
function periodOptions() {
return Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: `${i + 1}` }))
@@ -67,7 +67,7 @@ async function load() {
async function loadNotifications() {
try {
const { data } = await http.get('/course-adjustments/notifications')
const { data } = await http.get('/notifications', { params: { pageSize: 50 } })
notifications.value = data.items
unreadCount.value = data.unreadCount
} catch (_) { /* notifications are optional */ }
@@ -148,7 +148,7 @@ async function doReject() {
async function markRead(n: any) {
if (n.isRead) return
try {
await http.post(`/course-adjustments/notifications/${n.id}/read`)
await http.post(`/notifications/${n.id}/read`)
n.isRead = true
unreadCount.value = Math.max(0, unreadCount.value - 1)
} catch (_) {}
@@ -156,7 +156,7 @@ async function markRead(n: any) {
async function markAllRead() {
try {
await http.post('/course-adjustments/notifications/read-all')
await http.post('/notifications/read-all')
notifications.value.forEach(n => n.isRead = true)
unreadCount.value = 0
} catch (_) {}
@@ -340,7 +340,6 @@ function showCancel(type: string) { return type === 'Cancel' }
</div>
<el-form-item label="教室(可选)">
<el-select v-model="form.classroomId" clearable filterable placeholder="留空不调整教室">
<el-option v-for="r in []" :key="r" /> <!-- classrooms loaded separately if needed -->
</el-select>
</el-form-item>
</template>
@@ -348,7 +347,6 @@ function showCancel(type: string) { return type === 'Cancel' }
<template v-if="showSub(form.type)">
<el-form-item label="代课教师" required>
<el-select v-model="form.substituteTeacherId" filterable placeholder="选择代课教师">
<el-option v-for="t in []" :key="t" /> <!-- teachers loaded separately if needed -->
</el-select>
</el-form-item>
</template>
-6
View File
@@ -30,12 +30,6 @@ const statusLabels: Record<string, string> = {
Draft: '草稿', Published: '已发布', Archived: '已归档',
}
function dateText(value: string) {
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit', day: '2-digit', weekday: 'short',
hour: '2-digit', minute: '2-digit', hour12: false,
}).format(new Date(value))
}
function timeText(startsAt: string) {
return new Date(startsAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
}
+127
View File
@@ -0,0 +1,127 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Bell, Check, Refresh } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
const notifications = ref<any[]>([])
const unreadCount = ref(0)
const total = ref(0)
const loading = ref(false)
const page = ref(1)
const pageSize = 20
const unreadOnly = ref(false)
async function load(reset = false) {
if (reset) page.value = 1
loading.value = true
try {
const { data } = await http.get('/notifications', {
params: { page: page.value, pageSize, unreadOnly: unreadOnly.value || undefined },
})
notifications.value = data.items
total.value = data.total
unreadCount.value = data.unreadCount
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
finally { loading.value = false }
}
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
ElMessage.success('已全部标为已读')
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
}
function goLink(link: string | null) {
if (!link) return
const router = (window as any).__router__
if (router) router.push(link)
}
onMounted(() => load())
</script>
<template>
<div class="page-stack notif-page">
<section class="page-intro">
<div>
<span class="section-kicker">NOTIFICATION CENTER</span>
<h2>消息通知</h2>
<p>审核提示调课通知成绩发布等所有系统消息统一汇总于此</p>
</div>
<div style="display:flex;gap:8px;align-items:center">
<el-badge :value="unreadCount" :hidden="!unreadCount">
<el-button :icon="Bell">未读 {{ unreadCount }}</el-button>
</el-badge>
<el-button v-if="unreadCount" :icon="Check" @click="markAllRead">全部已读</el-button>
<el-button :icon="Refresh" @click="load(true)">刷新</el-button>
</div>
</section>
<section class="notif-toolbar">
<el-checkbox v-model="unreadOnly" @change="load(true)">仅显示未读</el-checkbox>
</section>
<section v-loading="loading" class="notif-list">
<article
v-for="n in notifications"
:key="n.id"
class="notif-card"
:class="{ unread: !n.isRead }"
@click="goLink(n.linkUrl); markRead(n)"
:style="{ cursor: n.linkUrl ? 'pointer' : 'default' }"
>
<div class="notif-dot" v-if="!n.isRead" />
<div class="notif-body">
<div class="notif-head">
<b>{{ n.title }}</b>
<el-tag v-if="!n.isRead" size="small" type="primary">未读</el-tag>
</div>
<p>{{ n.content }}</p>
<small>{{ new Date(n.createdAt).toLocaleString('zh-CN') }}</small>
</div>
</article>
<el-empty v-if="!notifications.length" description="暂无消息通知" />
</section>
<div class="notif-pager" v-if="total > pageSize">
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
@current-change="load()"
layout="prev, pager, next, total"
/>
</div>
</div>
</template>
<style scoped>
.notif-toolbar { margin-bottom: 16px; }
.notif-list { display: grid; gap: 8px; }
.notif-card {
display: flex; align-items: flex-start; gap: 14px;
padding: 16px 20px; background: #fff; border: 1px solid #e4e7ed;
border-radius: 10px; transition: box-shadow .15s;
}
.notif-card:hover { box-shadow: 0 2px 8px rgba(0,0,0,.06); }
.notif-card.unread { background: #ecf5ff; border-color: #c6e2ff; }
.notif-dot { width: 10px; height: 10px; border-radius: 50%; background: #409eff; flex-shrink: 0; margin-top: 5px; }
.notif-body { flex: 1; min-width: 0; }
.notif-head { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-bottom: 6px; }
.notif-head b { font-size: 14px; }
.notif-body p { font-size: 13px; color: #606266; margin: 0 0 6px; line-height: 1.5; }
.notif-body small { font-size: 11px; color: var(--muted); }
.notif-pager { display: flex; justify-content: center; margin-top: 20px; }
</style>