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

手动发信:移除“成绩通知”选项,只能发送普通通知,原有角色与范围权限保持不变。
选课通知:管理员关闭一轮选课时,自动向每位参与学生发送一条汇总通知,包含最终选中课程及候补未成功课程;候补失效、轮次关闭和通知生成保持原子性。
关闭选课界面会明确提示发送通知,并显示实际通知人数。
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>
+3 -2
View File
@@ -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))
+861 -78
View File
@@ -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>