Files
Academic-Affairs-System/web/src/views/NotificationCenterView.vue
T
2026-07-27 16:06:02 +08:00

1779 lines
43 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import {
Bell,
Check,
EditPen,
Filter,
Message,
Promotion,
Refresh,
Search,
User,
} from '@element-plus/icons-vue'
import { Ckeditor } from '@ckeditor/ckeditor5-vue'
import {
AutoLink,
BlockQuote,
Bold,
ClassicEditor,
Essentials,
Heading,
Image,
ImageCaption,
ImageInsert,
ImageInsertViaUrl,
ImageResize,
ImageStyle,
ImageToolbar,
Italic,
Link,
LinkImage,
List,
Paragraph,
Table,
TableCaption,
TableCellProperties,
TableColumnResize,
TableProperties,
TableToolbar,
type EditorConfig,
} from 'ckeditor5'
import 'ckeditor5/ckeditor5.css'
import { useRouter } from 'vue-router'
import http, { apiErrorMessage } from '../api/http'
import RichMessageContent from '../components/RichMessageContent.vue'
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 recipientLoading = ref(false)
const page = ref(1)
const sentPage = ref(1)
const recipientPage = ref(1)
const pageSize = 20
const recipientPageSize = 30
const unreadOnly = ref(false)
const category = ref<string>()
const keyword = ref('')
const composer = ref<any>(null)
const recipientMode = ref<'Scope' | 'Filtered' | 'Selected'>('Scope')
const recipientItems = ref<any[]>([])
const recipientTotal = ref(0)
const selectedRecipients = ref<any[]>([])
const selectedNotification = ref<any>(null)
const messageForm = reactive({
teachingTaskId: undefined as string | undefined,
title: '',
content: '',
})
const recipientFilters = reactive({
collegeId: undefined as string | undefined,
role: undefined as string | undefined,
administrativeClassId: undefined as string | undefined,
teachingTaskId: undefined as string | undefined,
keyword: '',
})
const editorConfig: EditorConfig = {
licenseKey: import.meta.env.VITE_CKEDITOR_LICENSE_KEY || 'GPL',
plugins: [
Essentials,
Paragraph,
Heading,
Bold,
Italic,
Link,
AutoLink,
List,
BlockQuote,
Image,
ImageCaption,
ImageInsert,
ImageInsertViaUrl,
ImageResize,
ImageStyle,
ImageToolbar,
LinkImage,
Table,
TableCaption,
TableCellProperties,
TableColumnResize,
TableProperties,
TableToolbar,
],
toolbar: {
items: [
'heading',
'|',
'bold',
'italic',
'link',
'|',
'insertTable',
'insertImage',
'|',
'bulletedList',
'numberedList',
'blockQuote',
'|',
'undo',
'redo',
],
shouldNotGroupWhenFull: false,
},
heading: {
options: [
{ model: 'paragraph', title: '正文', class: 'ck-heading_paragraph' },
{ model: 'heading2', view: 'h2', title: '标题', class: 'ck-heading_heading2' },
{ model: 'heading3', view: 'h3', title: '小标题', class: 'ck-heading_heading3' },
],
},
link: {
addTargetToExternalLinks: true,
defaultProtocol: 'https://',
},
image: {
insert: {
integrations: ['insertImageViaUrl'],
},
toolbar: [
'toggleImageCaption',
'imageTextAlternative',
'|',
'imageStyle:inline',
'imageStyle:wrapText',
'imageStyle:breakText',
'|',
'resizeImage',
'linkImage',
],
},
table: {
contentToolbar: [
'tableColumn',
'tableRow',
'mergeTableCells',
'toggleTableCaption',
'tableProperties',
'tableCellProperties',
],
},
}
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 isAdministratorComposer = computed(() => composer.value?.canFilterRecipients === true)
const recipientCount = computed(() => {
if (composer.value?.audienceType === 'TeachingTask') {
return selectedTask.value?.recipientCount ?? 0
}
if (recipientMode.value === 'Filtered') return recipientTotal.value
if (recipientMode.value === 'Selected') return selectedRecipients.value.length
return composer.value?.recipientCount ?? 0
})
const audienceName = computed(() => {
if (composer.value?.audienceType === 'TeachingTask') {
return selectedTask.value
? `${selectedTask.value.taskNumber} · ${selectedTask.value.courseName}`
: '请选择教学班'
}
if (recipientMode.value === 'Filtered') return '当前筛选结果'
if (recipientMode.value === 'Selected') {
return selectedRecipients.value.length
? `指定的 ${selectedRecipients.value.length} 名收件人`
: '尚未选择收件人'
}
return composer.value?.audienceName ?? '正在读取发信范围'
})
const visibleClasses = computed(() =>
(composer.value?.administrativeClasses ?? []).filter(
(item: any) => !recipientFilters.collegeId ||
item.collegeId === recipientFilters.collegeId))
const visibleTasks = computed(() =>
(composer.value?.teachingTasks ?? []).filter(
(item: any) => !recipientFilters.collegeId ||
item.collegeId === recipientFilters.collegeId))
const selectedRecipientIds = computed(() =>
new Set(selectedRecipients.value.map(item => item.id)))
const editorTextLength = computed(() => {
const holder = document.createElement('div')
holder.innerHTML = messageForm.content
return (holder.textContent ?? '').replace(/\u00a0/g, ' ').trim().length
})
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,
category: category.value || undefined,
keyword: keyword.value.trim() || undefined,
},
})
notifications.value = data.items
total.value = data.total
unreadCount.value = data.unreadCount
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
async function loadSent(reset = false) {
if (reset) sentPage.value = 1
loading.value = true
try {
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
if (composer.value?.canFilterRecipients) {
await loadRecipients(true)
}
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
composerLoading.value = false
}
}
function recipientParams() {
return {
collegeId: recipientFilters.collegeId || undefined,
role: recipientFilters.role || undefined,
administrativeClassId: recipientFilters.administrativeClassId || undefined,
teachingTaskId: recipientFilters.teachingTaskId || undefined,
keyword: recipientFilters.keyword.trim() || undefined,
page: recipientPage.value,
pageSize: recipientPageSize,
}
}
async function loadRecipients(reset = false) {
if (!isAdministratorComposer.value) return
if (reset) recipientPage.value = 1
recipientLoading.value = true
try {
const { data } = await http.get('/notifications/recipients', {
params: recipientParams(),
})
recipientItems.value = data.items
recipientTotal.value = data.total
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
recipientLoading.value = false
}
}
async function changeRecipientMode(value: 'Scope' | 'Filtered' | 'Selected') {
recipientMode.value = value
if (value !== 'Scope') await loadRecipients(true)
}
function onCollegeChange() {
recipientFilters.administrativeClassId = undefined
recipientFilters.teachingTaskId = undefined
loadRecipients(true)
}
function isRecipientSelected(recipient: any) {
return selectedRecipientIds.value.has(recipient.id)
}
function toggleRecipient(recipient: any) {
if (isRecipientSelected(recipient)) {
selectedRecipients.value = selectedRecipients.value.filter(
item => item.id !== recipient.id)
return
}
const maximum = composer.value?.maximumSelectedRecipients ?? 500
if (selectedRecipients.value.length >= maximum) {
ElMessage.warning(`单次最多指定 ${maximum} 名收件人`)
return
}
selectedRecipients.value = [...selectedRecipients.value, recipient]
}
function removeSelectedRecipient(recipientId: string) {
selectedRecipients.value = selectedRecipients.value.filter(
item => item.id !== recipientId)
}
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 {
// 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)
return
}
selectedNotification.value = notification
}
async function markAllRead() {
try {
await http.post('/notifications/read-all')
notifications.value.forEach(notification => {
notification.isRead = true
})
unreadCount.value = 0
ElMessage.success('全部消息已标为已读')
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function sendMessage() {
if (!messageForm.title.trim()) {
ElMessage.warning('请填写消息标题')
return
}
if (!editorTextLength.value) {
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,
recipientMode: isAdministratorComposer.value
? recipientMode.value
: 'Scope',
recipientFilter: recipientMode.value === 'Filtered'
? {
collegeId: recipientFilters.collegeId || null,
role: recipientFilters.role || null,
administrativeClassId:
recipientFilters.administrativeClassId || null,
teachingTaskId: recipientFilters.teachingTaskId || null,
keyword: recipientFilters.keyword.trim() || null,
}
: null,
recipientUserIds: recipientMode.value === 'Selected'
? selectedRecipients.value.map(item => item.id)
: [],
})
ElMessage.success(`已发送给 ${data.recipientCount} 个账号`)
messageForm.title = ''
messageForm.content = ''
selectedRecipients.value = []
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 message-center">
<section class="page-intro message-intro">
<div>
<span class="section-kicker">MESSAGE CENTER</span>
<h2>消息中心</h2>
<p>系统通知审核待办课程变动与成绩消息统一汇总重要信息不再散落在业务页面</p>
</div>
<div class="intro-actions">
<div class="unread-summary" :class="{ quiet: !unreadCount }">
<span>未读消息</span>
<b>{{ unreadCount }}</b>
</div>
<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>
<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"
role="button"
tabindex="0"
:class="[
categoryClass(notification.category),
{ unread: !notification.isRead, linked: notification.linkUrl },
]"
@click="openNotification(notification)"
@keyup.enter="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>
<div class="message-preview">
<RichMessageContent
:content="notification.content"
:rich="notification.isManual"
/>
</div>
<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-shell"
>
<header class="compose-overview">
<div>
<span class="panel-kicker">AUTHORIZED DELIVERY</span>
<h3>编写并发送消息</h3>
<p>收件范围始终受当前账号的数据权限约束发送后不可撤回</p>
</div>
<div class="delivery-summary">
<span>{{ audienceName }}</span>
<b>{{ recipientCount }}</b>
<small>个接收账号</small>
</div>
</header>
<div class="compose-workspace">
<section class="recipient-panel">
<div class="workspace-heading">
<div>
<el-icon><User /></el-icon>
<span>01</span>
</div>
<h3>选择收件人</h3>
</div>
<template v-if="isAdministratorComposer">
<el-segmented
:model-value="recipientMode"
class="recipient-mode"
:options="[
{ label: '权限范围群发', value: 'Scope' },
{ label: '按条件群发', value: 'Filtered' },
{ label: '指定收件人', value: 'Selected' },
]"
@change="changeRecipientMode"
/>
<div v-if="recipientMode !== 'Scope'" class="recipient-filter">
<div class="filter-heading">
<el-icon><Filter /></el-icon>
<span>筛选候选账号</span>
<small> {{ recipientTotal }} 个匹配账号</small>
</div>
<div class="filter-grid">
<el-select
v-if="composer?.colleges?.length > 1"
v-model="recipientFilters.collegeId"
clearable
filterable
placeholder="全部学院"
@change="onCollegeChange"
>
<el-option
v-for="college in composer.colleges"
:key="college.id"
:value="college.id"
:label="college.name"
/>
</el-select>
<el-select
v-model="recipientFilters.role"
clearable
placeholder="全部身份"
@change="loadRecipients(true)"
>
<el-option
v-for="role in composer.roles"
:key="role.value"
:value="role.value"
:label="role.label"
/>
</el-select>
<el-select
v-model="recipientFilters.administrativeClassId"
clearable
filterable
placeholder="全部行政班"
@change="loadRecipients(true)"
>
<el-option
v-for="item in visibleClasses"
:key="item.id"
:value="item.id"
:label="`${item.grade}级 · ${item.name}`"
/>
</el-select>
<el-select
v-model="recipientFilters.teachingTaskId"
clearable
filterable
placeholder="全部教学班"
@change="loadRecipients(true)"
>
<el-option
v-for="task in visibleTasks"
:key="task.id"
:value="task.id"
:label="`${task.taskNumber} · ${task.courseName}`"
/>
</el-select>
<el-input
v-model="recipientFilters.keyword"
clearable
:prefix-icon="Search"
placeholder="姓名、学号、工号或账号"
@keyup.enter="loadRecipients(true)"
@clear="loadRecipients(true)"
/>
<el-button :icon="Search" @click="loadRecipients(true)">
查询
</el-button>
</div>
</div>
<div v-if="recipientMode === 'Scope'" class="scope-notice">
<strong>{{ composer.audienceName }}</strong>
<span>
将覆盖当前权限范围内全部已启用账号如无需群发请切换到条件筛选或指定收件人
</span>
</div>
<div
v-else
v-loading="recipientLoading"
class="recipient-results"
>
<div class="recipient-list">
<button
v-for="recipient in recipientItems"
:key="recipient.id"
type="button"
class="recipient-row"
:class="{ selected: isRecipientSelected(recipient) }"
:disabled="recipientMode === 'Filtered'"
@click="toggleRecipient(recipient)"
>
<span class="recipient-avatar">
{{ recipient.displayName.slice(0, 1) }}
</span>
<span class="recipient-identity">
<b>{{ recipient.displayName }}</b>
<small>
{{ recipient.number || recipient.userName }}
<template v-if="recipient.className">
· {{ recipient.className }}
</template>
</small>
</span>
<span class="recipient-meta">
{{ recipient.collegeName || '校级账号' }}
</span>
<span v-if="recipientMode === 'Selected'" class="recipient-check">
{{ isRecipientSelected(recipient) ? '已选择' : '选择' }}
</span>
</button>
<el-empty
v-if="!recipientLoading && !recipientItems.length"
description="没有符合条件的可用账号"
/>
</div>
<el-pagination
v-if="recipientTotal > recipientPageSize"
v-model:current-page="recipientPage"
small
background
:page-size="recipientPageSize"
:total="recipientTotal"
layout="prev, pager, next"
@current-change="loadRecipients()"
/>
</div>
<div
v-if="recipientMode === 'Selected' && selectedRecipients.length"
class="selected-recipient-strip"
>
<header>
<b>已选 {{ selectedRecipients.length }} </b>
<button type="button" @click="selectedRecipients = []">清空</button>
</header>
<div>
<span
v-for="recipient in selectedRecipients.slice(0, 12)"
:key="recipient.id"
>
{{ recipient.displayName }}
<button
type="button"
:aria-label="`移除 ${recipient.displayName}`"
@click="removeSelectedRecipient(recipient.id)"
>
×
</button>
</span>
<small v-if="selectedRecipients.length > 12">
另有 {{ selectedRecipients.length - 12 }}
</small>
</div>
</div>
</template>
<el-form-item
v-else
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>
<small class="field-help">
任课教师只能向本人所带教学班的有效学生账号发送消息
</small>
</el-form-item>
</section>
<el-form class="compose-form" label-position="top">
<div class="workspace-heading">
<div>
<el-icon><EditPen /></el-icon>
<span>02</span>
</div>
<h3>编辑消息内容</h3>
</div>
<div class="compose-heading">
<div>
<span>普通通知</span>
<small>成绩选课等业务通知仍由对应流程自动发送</small>
</div>
</div>
<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 class="editor-form-item">
<Ckeditor
v-model="messageForm.content"
:editor="ClassicEditor"
:config="editorConfig"
/>
<div class="editor-footnote">
<span>支持表格超链接和外链图片图片请填写 HTTPS 地址不上传本地文件</span>
<b :class="{ warning: messageForm.content.length > 18000 }">
{{ editorTextLength }} · 源码 {{ messageForm.content.length }}/20000
</b>
</div>
</el-form-item>
<div class="compose-actions">
<span>
将发送给 <b>{{ recipientCount }}</b> 个账号
<small>{{ audienceName }}</small>
</span>
<el-button
type="primary"
:icon="Promotion"
:loading="sending"
@click="sendMessage"
>
确认发送
</el-button>
</div>
</el-form>
</div>
</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>
<RichMessageContent :content="item.content" rich />
<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>
<el-dialog
v-model="selectedNotification"
width="min(680px, 92vw)"
class="message-detail-dialog"
destroy-on-close
>
<template #header>
<div v-if="selectedNotification" class="message-detail-heading">
<span :class="['category-pill', categoryClass(selectedNotification.category)]">
{{ categoryLabel(selectedNotification.category) }}
</span>
<h3>{{ selectedNotification.title }}</h3>
<small>
{{ selectedNotification.senderName }} ·
{{ new Date(selectedNotification.createdAt).toLocaleString('zh-CN') }}
</small>
</div>
</template>
<RichMessageContent
v-if="selectedNotification"
:content="selectedNotification.content"
:rich="selectedNotification.isManual"
/>
</el-dialog>
</div>
</template>
<style scoped>
.message-center {
--message-blue: var(--indigo);
--message-ink: var(--ink);
--message-line: var(--line);
}
.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: #f3f5fa;
border: 1px solid var(--message-line);
border-left: 3px solid var(--teal);
border-radius: var(--el-border-radius-base);
}
.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;
border-left-color: #aab2c0;
}
.center-switcher {
display: flex;
gap: 4px;
padding: 4px;
width: fit-content;
background: #f1f3f6;
border: 1px solid var(--message-line);
border-radius: var(--el-border-radius-base);
}
.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: 5px;
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: 0;
}
.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: 0;
cursor: pointer;
transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease;
}
.message-card:hover {
border-color: #c7d2e2;
box-shadow: 0 8px 22px rgb(15 23 42 / 7%);
transform: translateY(-1px);
}
.message-card:focus-visible {
outline: 2px solid var(--indigo);
outline-offset: 2px;
}
.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;
color: #526078;
}
.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-preview {
max-height: 7.2em;
overflow: hidden;
position: relative;
}
.message-preview::after {
content: "";
position: absolute;
right: 0;
bottom: 0;
width: 35%;
height: 1.7em;
pointer-events: none;
background: linear-gradient(90deg, transparent, #fff 58%);
}
.message-card.unread .message-preview::after {
background: linear-gradient(90deg, transparent, #fff 58%);
}
.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-shell {
background: #fff;
border: 1px solid var(--message-line);
}
.compose-overview {
min-height: 98px;
padding: 20px 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
border-bottom: 1px solid var(--message-line);
border-left: 3px solid var(--teal);
}
.compose-overview h3 {
margin: 5px 0 4px;
color: var(--ink);
font-size: 19px;
}
.compose-overview p {
margin: 0;
color: var(--muted);
font-size: 11px;
}
.panel-kicker {
color: var(--teal);
font: 700 9px/1.2 Consolas, monospace;
letter-spacing: .14em;
}
.delivery-summary {
min-width: 260px;
display: grid;
grid-template-columns: 1fr auto auto;
align-items: baseline;
gap: 7px;
padding: 13px 16px;
background: #f5f7fa;
border: 1px solid var(--message-line);
}
.delivery-summary span {
overflow: hidden;
color: var(--muted);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.delivery-summary b {
color: var(--indigo);
font: 700 27px/1 Consolas, monospace;
}
.delivery-summary small {
color: var(--muted);
font-size: 10px;
}
.compose-workspace {
display: grid;
grid-template-columns: minmax(420px, .95fr) minmax(480px, 1.05fr);
min-height: 650px;
}
.recipient-panel,
.compose-form {
min-width: 0;
padding: 24px;
}
.recipient-panel {
border-right: 1px solid var(--message-line);
background: #fbfcfd;
}
.workspace-heading {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.workspace-heading > div {
width: 36px;
height: 36px;
position: relative;
display: grid;
place-items: center;
color: var(--indigo);
background: #edf0f8;
}
.workspace-heading > div span {
position: absolute;
right: -6px;
bottom: -4px;
padding: 1px 3px;
color: #fff;
background: var(--teal);
font: 700 8px/1 Consolas, monospace;
}
.workspace-heading h3 {
margin: 0;
color: var(--ink);
font-size: 15px;
}
.recipient-mode {
width: 100%;
margin-bottom: 16px;
--el-segmented-item-selected-bg-color: var(--indigo);
--el-segmented-item-selected-color: #fff;
}
.recipient-filter {
border: 1px solid var(--message-line);
background: #fff;
}
.filter-heading {
min-height: 43px;
padding: 0 13px;
display: flex;
align-items: center;
gap: 8px;
color: var(--ink);
border-bottom: 1px solid var(--message-line);
font-size: 11px;
font-weight: 650;
}
.filter-heading .el-icon {
color: var(--teal);
}
.filter-heading small {
margin-left: auto;
color: var(--muted);
font-weight: 400;
}
.filter-grid {
padding: 12px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 9px;
}
.filter-grid .el-button {
margin-left: 0;
}
.scope-notice {
min-height: 108px;
padding: 18px;
display: grid;
align-content: center;
gap: 7px;
border: 1px solid var(--message-line);
border-left: 3px solid var(--teal);
background: #f4f8f8;
}
.scope-notice strong {
color: var(--ink);
font-size: 14px;
}
.scope-notice span {
color: var(--muted);
font-size: 11px;
line-height: 1.7;
}
.recipient-results {
min-height: 280px;
margin-top: 12px;
display: grid;
align-content: space-between;
gap: 12px;
}
.recipient-list {
display: grid;
gap: 6px;
}
.recipient-row {
width: 100%;
min-width: 0;
min-height: 52px;
padding: 7px 9px;
display: grid;
grid-template-columns: 34px minmax(120px, 1fr) minmax(80px, auto) auto;
align-items: center;
gap: 9px;
text-align: left;
color: inherit;
background: #fff;
border: 1px solid var(--message-line);
cursor: pointer;
}
.recipient-row:disabled {
cursor: default;
}
.recipient-row.selected {
border-color: var(--teal);
box-shadow: inset 3px 0 var(--teal);
background: #f4faf9;
}
.recipient-avatar {
width: 30px;
height: 30px;
display: grid;
place-items: center;
color: var(--indigo);
background: #edf0f8;
font-size: 11px;
font-weight: 700;
}
.recipient-identity {
min-width: 0;
display: grid;
gap: 3px;
}
.recipient-identity b {
overflow: hidden;
color: var(--ink);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.recipient-identity small,
.recipient-meta {
overflow: hidden;
color: var(--muted);
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.recipient-check {
color: var(--indigo);
font-size: 9px;
font-weight: 650;
}
.selected-recipient-strip {
margin-top: 12px;
padding: 11px 12px;
border: 1px solid var(--message-line);
background: #fff;
}
.selected-recipient-strip header {
margin-bottom: 8px;
display: flex;
justify-content: space-between;
color: var(--ink);
font-size: 10px;
}
.selected-recipient-strip button {
padding: 0;
color: var(--indigo);
background: transparent;
border: 0;
cursor: pointer;
}
.selected-recipient-strip > div {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.selected-recipient-strip > div > span {
padding: 3px 6px;
display: inline-flex;
align-items: center;
gap: 5px;
color: #3f4b60;
background: #eef1f5;
font-size: 9px;
}
.selected-recipient-strip small {
align-self: center;
color: var(--muted);
font-size: 9px;
}
.compose-heading {
display: flex;
align-items: center;
margin: -4px 0 18px;
padding: 10px 12px;
color: #3f4b60;
background: #f5f7fa;
border-left: 3px solid var(--teal);
}
.compose-heading > div {
display: flex;
align-items: baseline;
gap: 10px;
}
.compose-heading span {
color: var(--ink);
font-size: 11px;
font-weight: 650;
}
.compose-heading small {
color: var(--muted);
font-size: 9px;
}
.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;
}
.editor-form-item :deep(.el-form-item__content) {
display: block;
}
.editor-form-item :deep(.ck-editor) {
width: 100%;
}
.editor-form-item :deep(.ck.ck-toolbar) {
border-color: var(--message-line);
border-radius: 0;
background: #f7f8fa;
}
.editor-form-item :deep(.ck.ck-editor__main > .ck-editor__editable) {
min-height: 250px;
max-height: 430px;
color: var(--ink);
border-color: var(--message-line);
border-radius: 0;
box-shadow: none;
font-family: inherit;
font-size: 13px;
}
.editor-form-item :deep(.ck.ck-editor__main > .ck-editor__editable.ck-focused) {
border-color: var(--indigo);
box-shadow: inset 0 0 0 1px var(--indigo);
}
.editor-footnote {
width: 100%;
margin-top: 7px;
display: flex;
justify-content: space-between;
gap: 12px;
color: var(--muted);
font-size: 9px;
}
.editor-footnote b {
color: #6a7384;
font-weight: 500;
}
.editor-footnote b.warning {
color: #b45309;
}
.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: 11px;
}
.compose-actions span b {
color: var(--indigo);
font: 700 16px/1 Consolas, monospace;
}
.compose-actions span small {
display: block;
margin-top: 3px;
color: var(--muted);
font-size: 9px;
}
.sent-card {
padding: 18px 20px;
background: #fff;
border: 1px solid var(--message-line);
border-radius: 0;
}
.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;
}
.message-detail-heading {
padding-right: 28px;
}
.message-detail-heading h3 {
margin: 10px 0 5px;
color: var(--ink);
font-size: 20px;
}
.message-detail-heading small {
color: var(--muted);
font-size: 10px;
}
:deep(.message-detail-dialog .el-dialog__body) {
padding-top: 8px;
color: #3f4b60;
line-height: 1.75;
}
@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-overview {
align-items: stretch;
flex-direction: column;
}
.delivery-summary {
min-width: 0;
}
.compose-workspace {
grid-template-columns: 1fr;
}
.recipient-panel {
padding: 18px;
border-right: 0;
border-bottom: 1px solid var(--message-line);
}
.filter-grid {
grid-template-columns: 1fr;
}
.recipient-row {
grid-template-columns: 34px minmax(0, 1fr) auto;
}
.recipient-meta {
display: none;
}
.compose-form {
padding: 22px 18px 24px;
}
.compose-actions {
flex-direction: column;
gap: 12px;
}
.compose-actions .el-button {
width: 100%;
}
.editor-footnote {
align-items: flex-start;
flex-direction: column;
}
}
</style>