成绩通知:仅在某门课程成绩正式发布后自动发送,按成绩单学生名单通知,不展示具体分数;发布状态与通知同一次提交。
手动发信:移除“成绩通知”选项,只能发送普通通知,原有角色与范围权限保持不变。 选课通知:管理员关闭一轮选课时,自动向每位参与学生发送一条汇总通知,包含最终选中课程及候补未成功课程;候补失效、轮次关闭和通知生成保持原子性。 关闭选课界面会明确提示发送通知,并显示实际通知人数。
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -476,11 +476,12 @@ async function openSelection(round: any) {
|
||||
async function closeSelection(round: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'关闭后学生将不能继续选课或退课,现有教学班名单会保留。',
|
||||
'关闭后学生将不能继续选课或退课,系统会向本轮参与学生发送最终选课结果。',
|
||||
'关闭选课',
|
||||
{ type: 'warning', confirmButtonText: '确认关闭', cancelButtonText: '取消' },
|
||||
)
|
||||
await http.post(`/course-selections/rounds/${round.id}/close`)
|
||||
const { data } = await http.post(`/course-selections/rounds/${round.id}/close`)
|
||||
ElMessage.success(`选课批次已关闭,已向 ${data.notifiedStudentCount} 名学生发送结果通知`)
|
||||
await loadRounds()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
||||
|
||||
@@ -1,127 +1,910 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { Bell, Check, Refresh } from '@element-plus/icons-vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import {
|
||||
Bell,
|
||||
Check,
|
||||
EditPen,
|
||||
Message,
|
||||
Promotion,
|
||||
Refresh,
|
||||
Search,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const senderRoles = ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Teacher']
|
||||
const canSend = computed(() =>
|
||||
auth.user?.roles.some(role => senderRoles.includes(role)) ?? false)
|
||||
|
||||
const activeTab = ref<'inbox' | 'compose' | 'sent'>('inbox')
|
||||
const notifications = ref<any[]>([])
|
||||
const sentMessages = ref<any[]>([])
|
||||
const unreadCount = ref(0)
|
||||
const total = ref(0)
|
||||
const sentTotal = ref(0)
|
||||
const loading = ref(false)
|
||||
const sending = ref(false)
|
||||
const composerLoading = ref(false)
|
||||
const page = ref(1)
|
||||
const sentPage = ref(1)
|
||||
const pageSize = 20
|
||||
const unreadOnly = ref(false)
|
||||
const category = ref<string>()
|
||||
const keyword = ref('')
|
||||
const composer = ref<any>(null)
|
||||
|
||||
const messageForm = reactive({
|
||||
teachingTaskId: undefined as string | undefined,
|
||||
title: '',
|
||||
content: '',
|
||||
})
|
||||
|
||||
const categories: Record<string, { label: string; className: string }> = {
|
||||
General: { label: '一般通知', className: 'general' },
|
||||
Approval: { label: '审核待办', className: 'approval' },
|
||||
Schedule: { label: '课程变动', className: 'schedule' },
|
||||
Grade: { label: '成绩通知', className: 'grade' },
|
||||
Attendance: { label: '考勤消息', className: 'attendance' },
|
||||
CourseSelection: { label: '选课消息', className: 'selection' },
|
||||
Warning: { label: '学业预警', className: 'warning' },
|
||||
}
|
||||
|
||||
const selectedTask = computed(() =>
|
||||
composer.value?.teachingTasks?.find(
|
||||
(task: any) => task.id === messageForm.teachingTaskId))
|
||||
const recipientCount = computed(() =>
|
||||
composer.value?.audienceType === 'TeachingTask'
|
||||
? selectedTask.value?.recipientCount ?? 0
|
||||
: composer.value?.recipientCount ?? 0)
|
||||
const audienceName = computed(() =>
|
||||
composer.value?.audienceType === 'TeachingTask'
|
||||
? selectedTask.value
|
||||
? `${selectedTask.value.taskNumber} · ${selectedTask.value.courseName}`
|
||||
: '请选择教学班'
|
||||
: composer.value?.audienceName ?? '正在读取发信范围')
|
||||
|
||||
function categoryLabel(value: string) {
|
||||
return categories[value]?.label ?? '系统消息'
|
||||
}
|
||||
|
||||
function categoryClass(value: string) {
|
||||
return categories[value]?.className ?? 'general'
|
||||
}
|
||||
|
||||
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 },
|
||||
params: {
|
||||
page: page.value,
|
||||
pageSize,
|
||||
unreadOnly: unreadOnly.value || undefined,
|
||||
category: category.value || undefined,
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
},
|
||||
})
|
||||
notifications.value = data.items
|
||||
total.value = data.total
|
||||
unreadCount.value = data.unreadCount
|
||||
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
|
||||
finally { loading.value = false }
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function markRead(n: any) {
|
||||
if (n.isRead) return
|
||||
async function loadSent(reset = false) {
|
||||
if (reset) sentPage.value = 1
|
||||
loading.value = true
|
||||
try {
|
||||
await http.post(`/notifications/${n.id}/read`)
|
||||
n.isRead = true
|
||||
const { data } = await http.get('/notifications/sent', {
|
||||
params: { page: sentPage.value, pageSize },
|
||||
})
|
||||
sentMessages.value = data.items
|
||||
sentTotal.value = data.total
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadComposer() {
|
||||
if (!canSend.value || composer.value) return
|
||||
composerLoading.value = true
|
||||
try {
|
||||
composer.value = (await http.get('/notifications/composer')).data
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
composerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function changeTab(tab: 'inbox' | 'compose' | 'sent') {
|
||||
activeTab.value = tab
|
||||
if (tab === 'compose') await loadComposer()
|
||||
if (tab === 'sent') await loadSent(true)
|
||||
}
|
||||
|
||||
async function markRead(notification: any) {
|
||||
if (notification.isRead) return
|
||||
try {
|
||||
await http.post(`/notifications/${notification.id}/read`)
|
||||
notification.isRead = true
|
||||
unreadCount.value = Math.max(0, unreadCount.value - 1)
|
||||
} catch (_) {}
|
||||
} catch {
|
||||
// Keep navigation available even when the read receipt cannot be saved.
|
||||
}
|
||||
}
|
||||
|
||||
async function openNotification(notification: any) {
|
||||
await markRead(notification)
|
||||
if (notification.linkUrl) await router.push(notification.linkUrl)
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
try {
|
||||
await http.post('/notifications/read-all')
|
||||
notifications.value.forEach(n => n.isRead = true)
|
||||
notifications.value.forEach(notification => {
|
||||
notification.isRead = true
|
||||
})
|
||||
unreadCount.value = 0
|
||||
ElMessage.success('已全部标为已读')
|
||||
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
|
||||
ElMessage.success('全部消息已标为已读')
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
function goLink(link: string | null) {
|
||||
if (!link) return
|
||||
const router = (window as any).__router__
|
||||
if (router) router.push(link)
|
||||
async function sendMessage() {
|
||||
if (!messageForm.title.trim()) {
|
||||
ElMessage.warning('请填写消息标题')
|
||||
return
|
||||
}
|
||||
if (!messageForm.content.trim()) {
|
||||
ElMessage.warning('请填写消息正文')
|
||||
return
|
||||
}
|
||||
if (composer.value?.audienceType === 'TeachingTask' &&
|
||||
!messageForm.teachingTaskId) {
|
||||
ElMessage.warning('请选择接收消息的教学班')
|
||||
return
|
||||
}
|
||||
if (!recipientCount.value) {
|
||||
ElMessage.warning('当前范围内没有可接收消息的账号')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`消息将发送给“${audienceName.value}”的 ${recipientCount.value} 个账号,发送后不可撤回。`,
|
||||
'确认发送消息',
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: '确认发送',
|
||||
cancelButtonText: '继续编辑',
|
||||
},
|
||||
)
|
||||
sending.value = true
|
||||
const { data } = await http.post('/notifications/send', {
|
||||
title: messageForm.title,
|
||||
content: messageForm.content,
|
||||
teachingTaskId: messageForm.teachingTaskId || null,
|
||||
})
|
||||
ElMessage.success(`已发送给 ${data.recipientCount} 个账号`)
|
||||
messageForm.title = ''
|
||||
messageForm.content = ''
|
||||
await changeTab('sent')
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel' && error !== 'close') {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => load())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack notif-page">
|
||||
<section class="page-intro">
|
||||
<div class="page-stack message-center">
|
||||
<section class="page-intro message-intro">
|
||||
<div>
|
||||
<span class="section-kicker">NOTIFICATION CENTER</span>
|
||||
<h2>消息通知</h2>
|
||||
<p>审核提示、调课通知、成绩发布等所有系统消息统一汇总于此。</p>
|
||||
<span class="section-kicker">MESSAGE 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 class="intro-actions">
|
||||
<div class="unread-summary" :class="{ quiet: !unreadCount }">
|
||||
<span>未读消息</span>
|
||||
<b>{{ unreadCount }}</b>
|
||||
</div>
|
||||
</article>
|
||||
<el-empty v-if="!notifications.length" description="暂无消息通知" />
|
||||
<el-button
|
||||
v-if="canSend"
|
||||
type="primary"
|
||||
:icon="EditPen"
|
||||
@click="changeTab('compose')"
|
||||
>
|
||||
发消息
|
||||
</el-button>
|
||||
<el-button :icon="Refresh" @click="activeTab === 'sent' ? loadSent() : load()">
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</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>
|
||||
<section class="center-switcher" aria-label="消息中心功能">
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: activeTab === 'inbox' }"
|
||||
@click="changeTab('inbox')"
|
||||
>
|
||||
<el-icon><Bell /></el-icon>
|
||||
收件箱
|
||||
<span v-if="unreadCount">{{ unreadCount }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="canSend"
|
||||
type="button"
|
||||
:class="{ active: activeTab === 'compose' }"
|
||||
@click="changeTab('compose')"
|
||||
>
|
||||
<el-icon><Promotion /></el-icon>
|
||||
发消息
|
||||
</button>
|
||||
<button
|
||||
v-if="canSend"
|
||||
type="button"
|
||||
:class="{ active: activeTab === 'sent' }"
|
||||
@click="changeTab('sent')"
|
||||
>
|
||||
<el-icon><Message /></el-icon>
|
||||
已发送
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<template v-if="activeTab === 'inbox'">
|
||||
<section class="message-toolbar">
|
||||
<el-select
|
||||
v-model="category"
|
||||
clearable
|
||||
placeholder="全部类型"
|
||||
@change="load(true)"
|
||||
>
|
||||
<el-option
|
||||
v-for="(meta, value) in categories"
|
||||
:key="value"
|
||||
:label="meta.label"
|
||||
:value="value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
clearable
|
||||
:prefix-icon="Search"
|
||||
placeholder="搜索标题或正文"
|
||||
@keyup.enter="load(true)"
|
||||
@clear="load(true)"
|
||||
/>
|
||||
<el-checkbox v-model="unreadOnly" @change="load(true)">仅看未读</el-checkbox>
|
||||
<el-button v-if="unreadCount" :icon="Check" text @click="markAllRead">
|
||||
全部已读
|
||||
</el-button>
|
||||
</section>
|
||||
|
||||
<section v-loading="loading" class="message-list">
|
||||
<article
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
class="message-card"
|
||||
:class="[
|
||||
categoryClass(notification.category),
|
||||
{ unread: !notification.isRead, linked: notification.linkUrl },
|
||||
]"
|
||||
@click="openNotification(notification)"
|
||||
>
|
||||
<div class="category-rail" />
|
||||
<div class="message-main">
|
||||
<header>
|
||||
<div class="message-labels">
|
||||
<span class="category-label">{{ categoryLabel(notification.category) }}</span>
|
||||
<span v-if="notification.isManual" class="manual-label">人工发送</span>
|
||||
<span v-if="!notification.isRead" class="unread-label">未读</span>
|
||||
</div>
|
||||
<time>{{ new Date(notification.createdAt).toLocaleString('zh-CN') }}</time>
|
||||
</header>
|
||||
<h3>{{ notification.title }}</h3>
|
||||
<p>{{ notification.content }}</p>
|
||||
<footer>
|
||||
<span>来自 {{ notification.senderName }}</span>
|
||||
<span v-if="notification.audienceName">
|
||||
接收范围:{{ notification.audienceName }}
|
||||
</span>
|
||||
<span v-if="notification.linkUrl" class="open-link">查看详情 →</span>
|
||||
</footer>
|
||||
</div>
|
||||
</article>
|
||||
<el-empty
|
||||
v-if="!loading && !notifications.length"
|
||||
:description="unreadOnly ? '当前筛选范围内没有未读消息' : '当前筛选范围内没有消息'"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div v-if="total > pageSize" class="message-pager">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="prev, pager, next, total"
|
||||
@current-change="load()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<section
|
||||
v-else-if="activeTab === 'compose'"
|
||||
v-loading="composerLoading"
|
||||
class="compose-workspace"
|
||||
>
|
||||
<aside class="audience-panel">
|
||||
<span class="panel-kicker">AUTHORIZED AUDIENCE</span>
|
||||
<h3>本次可发送范围</h3>
|
||||
<div class="audience-mark">
|
||||
<b>{{ recipientCount }}</b>
|
||||
<span>个接收账号</span>
|
||||
</div>
|
||||
<strong>{{ audienceName }}</strong>
|
||||
<p v-if="composer?.audienceType === 'School'">
|
||||
校级管理员可向全校所有已启用账号发送消息。
|
||||
</p>
|
||||
<p v-else-if="composer?.audienceType === 'College'">
|
||||
消息范围由当前账号所属学院确定,不能跨学院发送。
|
||||
</p>
|
||||
<p v-else>
|
||||
任课教师只能选择本人任教的教学班,接收人来自教学班与有效选课名单。
|
||||
</p>
|
||||
</aside>
|
||||
|
||||
<el-form class="compose-form" label-position="top">
|
||||
<div class="compose-heading">
|
||||
<div>
|
||||
<span class="panel-kicker">NEW MESSAGE</span>
|
||||
<h3>编写消息</h3>
|
||||
</div>
|
||||
<span>发送后不可撤回</span>
|
||||
</div>
|
||||
|
||||
<el-form-item
|
||||
v-if="composer?.audienceType === 'TeachingTask'"
|
||||
label="接收教学班"
|
||||
required
|
||||
>
|
||||
<el-select
|
||||
v-model="messageForm.teachingTaskId"
|
||||
filterable
|
||||
placeholder="选择本人任教的教学班"
|
||||
>
|
||||
<el-option
|
||||
v-for="task in composer.teachingTasks"
|
||||
:key="task.id"
|
||||
:value="task.id"
|
||||
:label="`${task.taskNumber} · ${task.courseName}(${task.recipientCount} 人)`"
|
||||
>
|
||||
<div class="task-option">
|
||||
<span>{{ task.taskNumber }} · {{ task.courseName }}</span>
|
||||
<small>{{ task.termName }} · {{ task.recipientCount }} 人</small>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="消息标题" required>
|
||||
<el-input
|
||||
v-model="messageForm.title"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
placeholder="用一句话说明需要关注的事项"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="消息正文" required>
|
||||
<el-input
|
||||
v-model="messageForm.content"
|
||||
type="textarea"
|
||||
:rows="7"
|
||||
maxlength="1000"
|
||||
show-word-limit
|
||||
placeholder="写清事项、时间和需要接收人完成的动作"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<div class="compose-actions">
|
||||
<span>将发送给 {{ recipientCount }} 个账号</span>
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="Promotion"
|
||||
:loading="sending"
|
||||
@click="sendMessage"
|
||||
>
|
||||
确认发送
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</section>
|
||||
|
||||
<template v-else>
|
||||
<section v-loading="loading" class="sent-list">
|
||||
<article v-for="item in sentMessages" :key="item.id" class="sent-card">
|
||||
<header>
|
||||
<span :class="['category-pill', categoryClass(item.category)]">
|
||||
{{ categoryLabel(item.category) }}
|
||||
</span>
|
||||
<time>{{ new Date(item.createdAt).toLocaleString('zh-CN') }}</time>
|
||||
</header>
|
||||
<h3>{{ item.title }}</h3>
|
||||
<p>{{ item.content }}</p>
|
||||
<footer>
|
||||
<span>{{ item.audienceName }}</span>
|
||||
<b>{{ item.recipientCount }} 个接收账号</b>
|
||||
</footer>
|
||||
</article>
|
||||
<el-empty v-if="!loading && !sentMessages.length" description="还没有发送记录" />
|
||||
</section>
|
||||
|
||||
<div v-if="sentTotal > pageSize" class="message-pager">
|
||||
<el-pagination
|
||||
v-model:current-page="sentPage"
|
||||
:page-size="pageSize"
|
||||
:total="sentTotal"
|
||||
layout="prev, pager, next, total"
|
||||
@current-change="loadSent()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</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;
|
||||
.message-center {
|
||||
--message-blue: #2563eb;
|
||||
--message-ink: #172033;
|
||||
--message-line: #dfe5ef;
|
||||
}
|
||||
|
||||
.message-intro {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.intro-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.unread-summary {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 108px;
|
||||
padding: 8px 12px;
|
||||
color: #1d4ed8;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.unread-summary span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.unread-summary b {
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.unread-summary.quiet {
|
||||
color: #64748b;
|
||||
background: #f8fafc;
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
.center-switcher {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
width: fit-content;
|
||||
background: #eef2f7;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.center-switcher button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-height: 36px;
|
||||
padding: 0 15px;
|
||||
color: #64748b;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.center-switcher button.active {
|
||||
color: var(--message-ink);
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgb(15 23 42 / 10%);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.center-switcher button > span {
|
||||
min-width: 18px;
|
||||
padding: 1px 5px;
|
||||
color: #fff;
|
||||
background: var(--message-blue);
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.message-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: 160px minmax(220px, 420px) auto auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--message-line);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.message-list,
|
||||
.sent-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.message-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: 138px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border: 1px solid var(--message-line);
|
||||
border-radius: 10px;
|
||||
transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease;
|
||||
}
|
||||
|
||||
.message-card.linked {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.message-card:hover {
|
||||
border-color: #c7d2e2;
|
||||
box-shadow: 0 8px 22px rgb(15 23 42 / 7%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.message-card.unread {
|
||||
background: linear-gradient(90deg, #f7faff 0, #fff 42%);
|
||||
border-color: #bfdbfe;
|
||||
}
|
||||
|
||||
.category-rail {
|
||||
width: 5px;
|
||||
flex: 0 0 5px;
|
||||
background: #64748b;
|
||||
}
|
||||
|
||||
.message-card.approval .category-rail { background: #d97706; }
|
||||
.message-card.schedule .category-rail { background: #7c3aed; }
|
||||
.message-card.grade .category-rail { background: #059669; }
|
||||
.message-card.attendance .category-rail { background: #db2777; }
|
||||
.message-card.selection .category-rail { background: #0891b2; }
|
||||
.message-card.warning .category-rail { background: #dc2626; }
|
||||
|
||||
.message-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
padding: 17px 20px 15px;
|
||||
}
|
||||
|
||||
.message-main header,
|
||||
.sent-card header,
|
||||
.sent-card footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.message-labels {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.category-label,
|
||||
.manual-label,
|
||||
.unread-label,
|
||||
.category-pill {
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.category-label {
|
||||
color: #334155;
|
||||
background: #eef2f7;
|
||||
}
|
||||
|
||||
.manual-label {
|
||||
color: #075985;
|
||||
background: #e0f2fe;
|
||||
}
|
||||
|
||||
.unread-label {
|
||||
color: #1d4ed8;
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.message-main time,
|
||||
.sent-card time {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.message-main h3,
|
||||
.sent-card h3,
|
||||
.compose-workspace h3 {
|
||||
margin: 11px 0 6px;
|
||||
color: var(--message-ink);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.message-main p,
|
||||
.sent-card p {
|
||||
margin: 0;
|
||||
color: #526078;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.message-main footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 18px;
|
||||
margin-top: auto;
|
||||
padding-top: 12px;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.open-link {
|
||||
margin-left: auto;
|
||||
color: var(--message-blue);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.message-pager {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.compose-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 290px) minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border: 1px solid var(--message-line);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.audience-panel {
|
||||
padding: 28px 24px;
|
||||
color: #dbeafe;
|
||||
background:
|
||||
linear-gradient(155deg, rgb(37 99 235 / 96%), rgb(30 64 175 / 98%)),
|
||||
#1d4ed8;
|
||||
}
|
||||
|
||||
.panel-kicker {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .14em;
|
||||
}
|
||||
|
||||
.audience-panel h3 {
|
||||
margin-top: 7px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.audience-mark {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin: 34px 0 8px;
|
||||
}
|
||||
|
||||
.audience-mark b {
|
||||
color: #fff;
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.audience-panel strong {
|
||||
display: block;
|
||||
margin-top: 18px;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.audience-panel p {
|
||||
margin: 12px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.compose-form {
|
||||
padding: 28px 32px 30px;
|
||||
}
|
||||
|
||||
.compose-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.compose-heading h3 {
|
||||
margin-top: 5px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.compose-heading > span {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.field-help {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
color: #94a3b8;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.task-option {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.task-option small {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.compose-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 17px;
|
||||
border-top: 1px solid #e9edf3;
|
||||
}
|
||||
|
||||
.compose-actions span {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sent-card {
|
||||
padding: 18px 20px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--message-line);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.sent-card footer {
|
||||
margin-top: 15px;
|
||||
padding-top: 12px;
|
||||
color: #64748b;
|
||||
border-top: 1px solid #eef2f6;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sent-card footer b {
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.category-pill {
|
||||
color: #334155;
|
||||
background: #eef2f7;
|
||||
}
|
||||
|
||||
.category-pill.grade {
|
||||
color: #047857;
|
||||
background: #d1fae5;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.message-intro,
|
||||
.intro-actions,
|
||||
.compose-actions {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.intro-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.unread-summary {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.center-switcher {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.center-switcher button {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.message-toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.message-card {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.message-main header,
|
||||
.sent-card header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.open-link {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.compose-workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.audience-panel {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.audience-mark {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.compose-form {
|
||||
padding: 22px 18px 24px;
|
||||
}
|
||||
|
||||
.compose-actions {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.compose-actions .el-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.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>
|
||||
|
||||
Reference in New Issue
Block a user