1779 lines
43 KiB
Vue
1779 lines
43 KiB
Vue
<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>
|