This commit is contained in:
2026-09-15 09:58:15 +08:00
parent 3f998bdb88
commit 4bf66d9571
90 changed files with 1599 additions and 1388 deletions
@@ -1,20 +1,20 @@
# Admin 消息中心原型核对矩阵
原型事实来源:`docs/悦/货代SAAS平台后台/货代SaaS平台原型.html`。接口事实来源:`iam/src/main/resources/openapi/notification-v1.yaml`。当前实现仅支持站内信,不扩展微信、邮件或短信渠道。
原型事实来源:`docs/悦/货代SAAS平台后台/prototype-src/modules/11-system-messages.html`(260–290 行)与 `prototype-src/overlays.html`(354–395 行)。接口事实来源:`iam/src/main/resources/openapi/notification-v1.yaml`。当前实现仅支持站内信,不扩展微信、邮件或短信渠道。
| 页面 | 操作 | 原型承载方式 | 当前实现 | 契约差异与结论 |
|---|---|---|---|---|
| 消息管理 | 列表/查询 | `page-msg-manage` 页面内表格 | `/notifications/messages` 展示通知任务,支持任务号/标题、分类、状态查询;操作列固定第一列 | 冻结契约只提供任务列表,未提供按消息正文管理的列表;按任务 DTO 展示任务号、接收人数、定时/发送时间与状态 |
| 消息管理 | 新建消息 | `modal-msg-create`,约 680px 居中弹框 | 680px `AppDialog`;选择激活模板、平台员工/指定企业范围、接收对象、模板变量 | `tenantId` 仅在适配边界作为兼容字段;页面统一使用“企业/客户”文案 |
| 消息管理 | 新建消息 | `modal-msg-create`,约 680px 居中弹框 | 680px `AppDialog`;选择激活模板、平台员工/指定企业范围、接收对象、模板变量 | 企业选择按现有 API 的 `tenantId` 字段提交;页面统一使用“企业/客户”文案 |
| 消息管理 | 立即/定时发送 | 新建消息弹框内 | 调用 `POST /platform/v1/notifications`;立即发送传 `scheduledAt=null`,定时发送传 ISO 时间;生成幂等键 | 契约已覆盖两种发送方式;仅创建任务,不静默模拟发送成功 |
| 消息管理 | 草稿 | 新建消息弹框内“存为草稿” | 不展示 | 冻结契约没有消息草稿创建、更新或查询 API,不能提供伪草稿 |
| 消息管理 | 取消/重试 | 原型未明确 | CREATED/SCHEDULED 可取消,RETRYING/DEAD 可重试 | 按冻结任务 API 与状态约束补充,分别受 `task:cancel`、`task:retry` 权限控制 |
| 消息管理 | 详情/发送统计 | 原型仅有详情按钮和“已读/总数”聚合列,未定义容器 | 不展示操作或伪统计 | 冻结契约无任务详情、收件人明细、已读统计 API;待后端补充契约后再承载 |
| 消息管理 | 详情/投递明细 | 原型仅有详情按钮,未定义容器 | 当前 `getTask` 打开 820px `AppDrawer`,展示任务摘要、失败原因与收件人投递明细 | 保留当前正式能力;不把投递明细改称已读统计 |
| 模板管理 | 列表/查询 | `page-msg-template` 页面内表格 | `/notifications/templates` 查询 CODE、分类、名称/标题、状态;操作列固定第一列 | 对接模板分页 API;原型导入/导出因无 API 不展示 |
| 模板管理 | 新增模板 | `modal-msg-template-create`,约 760px 居中弹框 | 760px `AppDialog`;CODE、名称、分类、标题/正文、变量白名单、预览;保存为 DRAFT | 仅支持 `IN_APP`,不展示真实渠道选择 |
| 模板管理 | 编辑模板 | 原型仅有编辑按钮,未定义容器 | 复用同尺寸模板弹框,仅 DRAFT 可编辑,CODE 不可变并携带 version | 冻结契约明确只允许编辑草稿,复用表单避免重复交互 |
| 模板管理 | 状态流转 | 原型未定义 | DRAFT 可激活,ACTIVE 可禁用,DISABLED 无操作 | 严格执行 `DRAFT -> ACTIVE -> DISABLED`;禁用后不可恢复 |
| 我的消息 | 页面归属 | 原型保留 `page-msg-my` 历史页面 | Admin 不实现、不注册路由 | 产品确认“我的消息”仅属于企业客户端 |
| 顶部入口 | 管理入口 | `topMessageMenu` 顶部下拉,左侧无消息菜单 | 按任务/模板权限展示“消息管理”“模板管理” | Admin 仅保留后台管理能力,不展示个人收件箱与未读数 |
| 我的消息 | 列表、已读与详情 | `page-msg-my`;详情容器原型未定义 | 当前 `/notifications` 正式路由及顶部入口,详情为 680px `AppDrawer` | 按实际平台收件箱入口保留权限和标记已读逻辑 |
| 顶部入口 | 消息中心 | `topMessageMenu` 顶部下拉,左侧无消息菜单 | 当前按权限展示“我的消息”“消息管理”“模板管理” | 三个正式路由均纳入样式核对,见 `docs/Admin消息中心样式核对记录.md` |
接口依赖:所有响应经 `{code,data}` 解包;分页统一读取 `records/page/size/total/pages`;响应中的 `int64` ID 在前端保持字符串。写请求中的企业、用户和成员 ID 也在类型适配边界保持数字字符串,避免 JavaScript 大整数精度损失,由服务端 Long DTO 解析。
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
@@ -60,9 +61,9 @@ async function submit() {
</script>
<template>
<AppDialog v-model="open" title="新建消息" width="min(680px, calc(100vw - 28px))" :show-confirm="false" destroy-on-close @cancel="reset">
<AppDialog class="admin-density-dialog" v-model="open" title="新建消息" width="min(680px, calc(100vw - 28px))" :show-confirm="false" destroy-on-close @cancel="reset">
<OperationErrorToast :details="error" />
<el-form class="message-form" label-position="top">
<el-form class="message-form density-form" label-position="top">
<div class="form-grid"><el-form-item label="发送范围" required><el-segmented v-model="scope" :options="[{ label: '平台员工', value: 'platform' }, { label: '指定企业', value: 'customer' }]" /></el-form-item><el-form-item label="发送方式"><el-input model-value="站内信" disabled /></el-form-item></div>
<PlatformTenantSelector v-if="scope === 'customer'" v-model="form.tenantId" label="目标企业" />
<el-form-item label="消息模板" required><el-select v-model="form.templateCode" filterable placeholder="请选择已激活模板"><el-option v-for="item in templates" :key="item.id" :label="`${item.templateName}(${item.templateCode})`" :value="item.templateCode" /></el-select></el-form-item>
@@ -76,5 +77,5 @@ async function submit() {
</template>
<style scoped>
.message-form { margin-top: 18px; }.form-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 16px; }.form-grid :deep(.el-select),.message-form > :deep(.el-form-item .el-select),.schedule-row :deep(.el-date-editor) { width: 100%; }.variables { margin-bottom: 16px; padding: 14px; border: 1px solid var(--cc-border); border-radius: 8px; }.variables > strong { display: block; margin-bottom: 12px; font-size: 13px; }.schedule-row { display:grid;width:100%;grid-template-columns:auto auto minmax(180px,1fr);align-items:center;gap:8px }.dialog-actions {display:flex;justify-content:flex-end;gap:10px}.dialog-actions :deep(.el-button){margin-left:0}@media(max-width:560px){.form-grid{grid-template-columns:1fr;gap:0}.schedule-row{grid-template-columns:auto 1fr}.schedule-row :deep(.el-date-editor){grid-column:1/-1}}
.message-form { margin-top: var(--admin-space-5); }.form-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: var(--admin-space-5); }.form-grid :deep(.el-select),.message-form > :deep(.el-form-item .el-select),.schedule-row :deep(.el-date-editor) { width: 100%; }.variables { margin-bottom: var(--admin-space-5); padding: var(--admin-space-5); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); }.variables > strong { display: block; margin-bottom: var(--admin-space-5); font-size: var(--admin-font-body); }.schedule-row { display:grid;width:100%;grid-template-columns:auto auto minmax(180px,1fr);align-items:center;gap:var(--admin-space-3) }.dialog-actions {display:flex;justify-content:flex-end;gap:var(--admin-space-4)}.dialog-actions :deep(.el-button){margin-left:0}@media(max-width:560px){.form-grid{grid-template-columns:1fr;gap:0}.schedule-row{grid-template-columns:auto 1fr}.schedule-row :deep(.el-date-editor){grid-column:1/-1}}
</style>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
@@ -34,14 +35,14 @@ async function submit() {
</script>
<template>
<AppDialog v-model="open" :title="editing ? '编辑消息模板' : '新增消息模板'" width="min(760px, calc(100vw - 28px))" :show-confirm="false" destroy-on-close>
<AppDialog class="admin-density-dialog" v-model="open" :title="editing ? '编辑消息模板' : '新增消息模板'" width="min(760px, calc(100vw - 28px))" :show-confirm="false" destroy-on-close>
<OperationErrorToast :details="error" />
<el-alert title="首期仅支持站内信;新模板保存为草稿,草稿激活后不可再编辑。" type="info" :closable="false" show-icon />
<el-form class="template-form" label-position="top"><div class="form-grid"><el-form-item label="模板CODE" required><el-input :model-value="form.code" maxlength="64" :disabled="editing" placeholder="如:ORDER_SHIPPED_NOTICE" @update:model-value="normalizeCode(String($event))" /></el-form-item><el-form-item label="模板名称" required><el-input v-model="form.name" maxlength="128" placeholder="请输入模板名称" /></el-form-item></div><div class="form-grid"><el-form-item label="业务分类" required><el-input v-model="form.category" maxlength="64" placeholder="请输入业务分类" /></el-form-item><el-form-item label="模板类型"><el-input model-value="站内信" disabled /></el-form-item></div><el-form-item label="消息标题模板" required><el-input v-model="form.title" maxlength="256" show-word-limit placeholder="请输入消息标题模板" /></el-form-item><el-form-item label="消息正文模板" required><el-input ref="contentInput" v-model="form.content" type="textarea" :rows="6" maxlength="10000" show-word-limit placeholder="请输入消息正文模板" /></el-form-item><el-form-item label="变量白名单"><el-input v-model="whitelistText" placeholder="多个变量使用逗号分隔,如 customer_name, order_no" /></el-form-item><div class="variable-row"><span>常用变量</span><el-button v-for="name in ['customer_name','order_no','container_no','amount','date']" :key="name" size="small" @click="insertVariable(name)">{{ name }}</el-button></div><section class="template-preview"><strong>内容预览</strong><p>{{ preview }}</p></section></el-form>
<el-form class="template-form density-form" label-position="top"><div class="form-grid"><el-form-item label="模板CODE" required><el-input :model-value="form.code" maxlength="64" :disabled="editing" placeholder="如:ORDER_SHIPPED_NOTICE" @update:model-value="normalizeCode(String($event))" /></el-form-item><el-form-item label="模板名称" required><el-input v-model="form.name" maxlength="128" placeholder="请输入模板名称" /></el-form-item></div><div class="form-grid"><el-form-item label="业务分类" required><el-input v-model="form.category" maxlength="64" placeholder="请输入业务分类" /></el-form-item><el-form-item label="模板类型"><el-input model-value="站内信" disabled /></el-form-item></div><el-form-item label="消息标题模板" required><el-input v-model="form.title" maxlength="256" show-word-limit placeholder="请输入消息标题模板" /></el-form-item><el-form-item label="消息正文模板" required><el-input ref="contentInput" v-model="form.content" type="textarea" :rows="6" maxlength="10000" show-word-limit placeholder="请输入消息正文模板" /></el-form-item><el-form-item label="变量白名单"><el-input v-model="whitelistText" placeholder="多个变量使用逗号分隔,如 customer_name, order_no" /></el-form-item><div class="variable-row"><span>常用变量</span><el-button v-for="name in ['customer_name','order_no','container_no','amount','date']" :key="name" size="small" @click="insertVariable(name)">{{ name }}</el-button></div><section class="template-preview"><strong>内容预览</strong><p>{{ preview }}</p></section></el-form>
<template #footer="{ close }"><div class="dialog-actions"><el-button :disabled="submitting" @click="close">取消</el-button><el-button type="primary" :loading="submitting" @click="submit">保存模板</el-button></div></template>
</AppDialog>
</template>
<style scoped>
.template-form{margin-top:18px}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.variable-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:18px;color:var(--cc-muted);font-size:12px}.variable-row :deep(.el-button){margin-left:0}.template-preview{overflow:hidden;border:1px solid var(--cc-border);border-radius:8px}.template-preview strong{display:block;padding:9px 14px;border-bottom:1px solid var(--cc-border);background:#f8fafc;font-size:13px}.template-preview p{min-height:76px;margin:0;padding:16px;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--cc-muted);font-size:13px;line-height:1.7}.dialog-actions{display:flex;justify-content:flex-end;gap:10px}.dialog-actions :deep(.el-button){margin-left:0}@media(max-width:560px){.form-grid{grid-template-columns:1fr;gap:0}}
.template-form{margin-top:var(--admin-space-5)}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-5)}.variable-row{display:flex;flex-wrap:wrap;align-items:center;gap:var(--admin-space-3);margin-bottom:var(--admin-space-5);color:var(--cc-muted);font-size:var(--admin-font-caption)}.variable-row :deep(.el-button){margin-left:0}.template-preview{overflow:hidden;border:1px solid var(--cc-border);border-radius:var(--admin-radius)}.template-preview strong{display:block;padding:var(--admin-space-3) var(--admin-space-5);border-bottom:1px solid var(--cc-border);background:var(--admin-label-bg);font-size:var(--admin-font-body)}.template-preview p{min-height:76px;margin:0;padding:var(--admin-space-5);white-space:pre-wrap;overflow-wrap:anywhere;color:var(--cc-muted);font-size:var(--admin-font-body);line-height:1.7}.dialog-actions{display:flex;justify-content:flex-end;gap:var(--admin-space-4)}.dialog-actions :deep(.el-button){margin-left:0}@media(max-width:560px){.form-grid{grid-template-columns:1fr;gap:0}}
</style>
@@ -0,0 +1,135 @@
import ElementPlus, { ElDrawer, ElSelect } from 'element-plus'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import PlatformNotificationsView from './pages/PlatformNotificationsView.vue'
import MessageManagementView from './pages/MessageManagementView.vue'
import MessageTemplatesView from './pages/MessageTemplatesView.vue'
import MessageComposerDialog from './components/MessageComposerDialog.vue'
import TemplateEditorDialog from './components/TemplateEditorDialog.vue'
import * as api from './api/notificationCenter'
import type { NotificationTemplate, NotificationTask, NotificationInboxDetail, PageResult } from './types'
const can = vi.hoisted(() => vi.fn(() => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('./api/notificationCenter', () => ({
listMyNotifications: vi.fn(), getMyUnreadNotificationCount: vi.fn(), getMyNotification: vi.fn(),
markMyNotificationRead: vi.fn(), markAllMyNotificationsRead: vi.fn(), listTasks: vi.fn(), getTask: vi.fn(),
listTemplates: vi.fn(), createNotification: vi.fn(), createTemplate: vi.fn(), updateTemplate: vi.fn(),
cancelTask: vi.fn(), retryTask: vi.fn(), changeTemplateStatus: vi.fn(),
}))
const template: NotificationTemplate = { id: 'template-1', templateCode: 'ORDER_NOTICE', templateName: '订单通知', category: 'ORDER', channel: 'IN_APP', titleTemplate: '订单更新', bodyTemplate: '订单已更新', variableWhitelist: [], status: 'DRAFT', version: 3 }
const task: NotificationTask = { id: 'task-1', taskNo: 'NT202609140001', category: 'ORDER', title: '订单更新', status: 'CREATED', recipientCount: 1, scheduledAt: null, sentAt: null, duplicated: false }
const inbox: NotificationInboxDetail = { id: 'inbox-1', category: 'ORDER', title: '订单更新', summary: '订单状态有更新', body: '第一行\n第二行', status: 'UNREAD', sentAt: '2026-09-14T01:00:00Z', readAt: null, sourceType: null, sourceId: null }
const page = <T,>(records: T[]): PageResult<T> => ({ records, page: 1, size: 20, total: records.length, pages: 1 })
const options = () => ({ attachTo: document.body, global: { plugins: [ElementPlus] } })
async function click(wrapper: VueWrapper, label: string, selector = 'button') {
const button = wrapper.findAll(selector).find(item => item.text().trim() === label)
expect(button, label).toBeDefined()
await button!.trigger('click')
await flushPromises()
}
enableAutoUnmount(afterEach)
afterEach(() => { vi.unstubAllGlobals(); document.body.innerHTML = '' })
beforeEach(() => {
vi.clearAllMocks()
can.mockReturnValue(true)
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
vi.mocked(api.listMyNotifications).mockResolvedValue(page([inbox]))
vi.mocked(api.getMyUnreadNotificationCount).mockResolvedValue(1)
vi.mocked(api.getMyNotification).mockResolvedValue(inbox)
vi.mocked(api.markMyNotificationRead).mockResolvedValue({ ...inbox, status: 'READ' })
vi.mocked(api.listTasks).mockResolvedValue(page([task]))
vi.mocked(api.getTask).mockResolvedValue({ ...task, attemptCount: 1, lastError: '测试投递失败原因', recipients: [{ id: 'recipient-1', displayName: '测试员工', appCode: 'ADMIN', status: 'FAILED', failureReason: '测试收件失败', deliveredAt: null }] })
vi.mocked(api.listTemplates).mockResolvedValue(page([template]))
})
describe('notification density in native lists and overlays', () => {
it('keeps inbox actions visible and opens the styled drawer with read marking', async () => {
const wrapper = mount(PlatformNotificationsView, options())
await flushPromises()
const actions = wrapper.get('.el-table__body-wrapper .density-actions')
expect(actions.findAll('button').map(item => item.text())).toEqual(['查看', '标为已读'])
expect(actions.find('svg').exists()).toBe(false)
await click(wrapper, '查看', '.el-table__body-wrapper button')
const drawer = document.querySelector('.el-drawer.admin-density-drawer')!
expect(drawer.textContent).toContain('第一行\n第二行')
expect(wrapper.getComponent(ElDrawer).props('size')).toBe('min(680px, 94vw)')
expect(api.markMyNotificationRead).toHaveBeenCalledWith(inbox.id)
})
it('retains read-only inbox viewing without read permission', async () => {
can.mockReturnValue(false)
const wrapper = mount(PlatformNotificationsView, options())
await flushPromises()
expect(wrapper.get('.el-table__body-wrapper .density-actions').findAll('button').map(item => item.text())).toEqual(['查看'])
await click(wrapper, '查看', '.el-table__body-wrapper button')
expect(document.querySelector('.el-drawer')?.textContent).toContain('第一行')
expect(api.markMyNotificationRead).not.toHaveBeenCalled()
})
it.each([
['CREATED', ['查看', '取消']], ['SENT', ['查看']], ['DEAD', ['查看', '重试']],
] as const)('keeps task actions for %s and shows delivery evidence in the drawer', async (status, labels) => {
vi.mocked(api.listTasks).mockResolvedValue(page([{ ...task, status }]))
const wrapper = mount(MessageManagementView, options())
await flushPromises()
const actions = wrapper.get('.el-table__body-wrapper .density-actions')
expect(actions.findAll('button').map(item => item.text())).toEqual(labels)
expect(actions.find('svg').exists()).toBe(false)
await click(wrapper, '查看', '.el-table__body-wrapper button')
const drawer = document.querySelector('.el-drawer.admin-density-drawer')!
expect(wrapper.getComponent(ElDrawer).props('size')).toBe('min(820px, 94vw)')
expect(drawer.querySelectorAll('.density-fields > div')).toHaveLength(6)
expect(drawer.textContent).toContain(task.taskNo)
expect(drawer.textContent).toContain('测试员工')
expect(drawer.textContent).toContain('测试投递失败原因')
})
it.each([
['DRAFT', ['编辑', '激活']], ['ACTIVE', ['禁用']], ['DISABLED', []],
] as const)('keeps template state actions for %s', async (status, labels) => {
vi.mocked(api.listTemplates).mockResolvedValue(page([{ ...template, status }]))
const wrapper = mount(MessageTemplatesView, options())
await flushPromises()
const actions = wrapper.get('.el-table__body-wrapper .density-actions')
expect(actions.findAll('button').map(item => item.text())).toEqual(labels)
expect(actions.find('svg').exists()).toBe(false)
if (status === 'DRAFT') {
await click(wrapper, '编辑', '.el-table__body-wrapper button')
expect(document.querySelector('.el-dialog.admin-density-dialog .density-form')).not.toBeNull()
}
})
it('preserves template code, variable preview, version and draft editing in the native modal', async () => {
vi.mocked(api.updateTemplate).mockResolvedValue(template)
const wrapper = mount(TemplateEditorDialog, { ...options(), props: { modelValue: true, template } })
await flushPromises()
const modal = wrapper.get('.el-dialog.admin-density-dialog')
expect((modal.element as HTMLElement).style.getPropertyValue('--el-dialog-width')).toContain('760px')
expect(wrapper.get('input[placeholder="如:ORDER_SHIPPED_NOTICE"]').attributes('disabled')).toBeDefined()
await click(wrapper, 'order_no')
expect(wrapper.get('.template-preview').text()).toContain('${order_no}')
await click(wrapper, '保存模板')
expect(api.updateTemplate).toHaveBeenCalledWith(template.id, expect.objectContaining({ version: 3, variableWhitelist: ['order_no'], bodyTemplate: expect.stringContaining('${order_no}') }))
expect(wrapper.emitted('success')).toHaveLength(1)
})
it('keeps required composer input and scheduled payload in the native modal', async () => {
vi.mocked(api.createNotification).mockResolvedValue(task)
const wrapper = mount(MessageComposerDialog, { ...options(), props: { modelValue: true, templates: [{ ...template, status: 'ACTIVE' }] } })
await flushPromises()
const modal = wrapper.get('.el-dialog.admin-density-dialog')
expect((modal.element as HTMLElement).style.getPropertyValue('--el-dialog-width')).toContain('680px')
expect(modal.find('.density-form').exists()).toBe(true)
await click(wrapper, '立即发送')
expect(api.createNotification).not.toHaveBeenCalled()
wrapper.findAllComponents(ElSelect)[0]!.vm.$emit('update:modelValue', template.templateCode)
await wrapper.get('input[placeholder="请输入服务端角色编码"]').setValue('TEST_OPERATOR')
await wrapper.get('[role="switch"]').trigger('click')
await flushPromises()
await click(wrapper, '确认定时发送')
expect(api.createNotification).toHaveBeenCalledWith(expect.objectContaining({ tenantId: '0', templateCode: template.templateCode, recipientType: 'PLATFORM_ROLE', roleCode: 'TEST_OPERATOR', variables: {}, scheduledAt: expect.any(String), idempotencyKey: expect.any(String) }))
expect(wrapper.emitted('success')).toHaveLength(1)
})
})
@@ -1,6 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { onMounted, ref, watch } from 'vue'
import { Eye, Plus, RotateCcw, XCircle } from '@lucide/vue'
import { Plus } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import AdminPage from '@/shared/components/AdminPage.vue'; import AppDrawer from '@/shared/components/AppDrawer.vue'; import DataTable,{type DataTableColumn} from '@/shared/components/DataTable.vue'; import ListPageLayout from '@/shared/components/ListPageLayout.vue'; import QueryToolbar,{type QueryField,type QueryModel} from '@/shared/components/QueryToolbar.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'; import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import MessageComposerDialog from '../components/MessageComposerDialog.vue'; import { cancelTask, getTask, listTasks, listTemplates, retryTask } from '../api/notificationCenter'; import type { NotificationTask, NotificationTaskDetail, NotificationTaskStatus, NotificationTemplate } from '../types'; import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'; import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -14,8 +15,14 @@ async function load(){loading.value=true;error.value=null;try{const keyword=Stri
async function loadTemplates(){if(!access.can('platform:notification:create'))return;try{const data=await listTemplates({page:1,size:200,status:'ACTIVE'});templates.value=data.records}catch(e){ElMessage.error(getApiErrorMessage(e,'可用模板加载失败'))}}
async function openDetail(row:NotificationTask){detailOpen.value=true;detailLoading.value=true;detailError.value=null;detail.value=null;try{detail.value=await getTask(row.id)}catch(e){detailError.value=getApiErrorDetails(e,'消息任务详情加载失败')}finally{detailLoading.value=false}}
function search(){page.value===1?void load():page.value=1}
async function action(row:NotificationTask,type:'cancel'|'retry'){try{await ElMessageBox.confirm(type==='cancel'?'确定取消该消息任务吗?':'确定重试该消息任务吗?',type==='cancel'?'取消任务':'重试任务',{type:'warning'});actionId.value=row.id;if(type==='cancel')await cancelTask(row.id);else await retryTask(row.id);ElMessage.success(type==='cancel'?'任务已取消':'任务已提交重试');await load()}catch(e){if(e!=='cancel'&&e!=='close')ElMessage.error(getApiErrorMessage(e,type==='cancel'?'取消失败':'重试失败'))}finally{actionId.value=''}}
async function action(row:NotificationTask,type:'cancel'|'retry'){try{await ElMessageBox.confirm(type==='cancel'?'确定取消该消息任务吗?':'确定重试该消息任务吗?',type==='cancel'?'取消任务':'重试任务',{type:'warning',customClass:'admin-density-confirm'});actionId.value=row.id;if(type==='cancel')await cancelTask(row.id);else await retryTask(row.id);ElMessage.success(type==='cancel'?'任务已取消':'任务已提交重试');await load()}catch(e){if(e!=='cancel'&&e!=='close')ElMessage.error(getApiErrorMessage(e,type==='cancel'?'取消失败':'重试失败'))}finally{actionId.value=''}}
watch([page,pageSize],load);onMounted(()=>{void load();void loadTemplates()})
</script>
<template><AdminPage><ListPageLayout aria-label="消息管理列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="消息任务筛选" :fields="queryFields" preference-key="notifications-messages-query" @search="search" /></template><template v-if="error" #state><LoadErrorState title="消息任务加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template><section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="notifications-messages-table" :loading="loading" :total="result.total" row-key="id" empty-text="暂无消息任务"><template #toolbar-actions><el-button v-if="access.can('platform:notification:create')" type="primary" :disabled="!templates.length" @click="composerOpen=true"><Plus :size="16"/>新建消息</el-button></template><template #pagination-left><span>共 {{ result.total }} 个任务</span></template><template #actions="{row}"><div class="table-actions"><el-button link type="primary" @click="openDetail(row as NotificationTask)"><Eye :size="15"/>查看</el-button><el-button v-if="access.can('platform:notification:task:cancel')&&['CREATED','SCHEDULED'].includes((row as NotificationTask).status)" link type="danger" :loading="actionId===(row as NotificationTask).id" @click="action(row as NotificationTask,'cancel')"><XCircle :size="15"/>取消</el-button><el-button v-if="access.can('platform:notification:task:retry')&&['RETRYING','DEAD'].includes((row as NotificationTask).status)" link type="primary" :loading="actionId===(row as NotificationTask).id" @click="action(row as NotificationTask,'retry')"><RotateCcw :size="15"/>重试</el-button></div></template><template #category="{row}">{{notificationCategoryLabel((row as NotificationTask).category)}}</template><template #scheduledAt="{row}">{{formatDate((row as NotificationTask).scheduledAt)}}</template><template #sentAt="{row}">{{formatDate((row as NotificationTask).sentAt)}}</template><template #status="{row}"><el-tag>{{statusLabel((row as NotificationTask).status)}}</el-tag></template></DataTable></section></ListPageLayout><MessageComposerDialog v-model="composerOpen" :templates="templates" @success="load"/><AppDrawer v-model="detailOpen" title="消息任务详情" size="min(820px, 94vw)" :loading="detailLoading" :show-footer="false" destroy-on-close><LoadErrorState v-if="detailError" title="消息任务详情加载失败" :message="detailError.message" :loading="detailLoading"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState><template v-else-if="detail"><dl class="detail-grid"><div><dt>任务号</dt><dd>{{detail.taskNo}}</dd></div><div><dt>状态</dt><dd>{{statusLabel(detail.status)}}</dd></div><div><dt>业务分类</dt><dd>{{notificationCategoryLabel(detail.category)}}</dd></div><div><dt>接收人数</dt><dd>{{detail.recipientCount}}</dd></div><div><dt>尝试次数</dt><dd>{{detail.attemptCount}}</dd></div><div><dt>发送时间</dt><dd>{{formatDate(detail.sentAt)}}</dd></div></dl><section v-if="detail.lastError" class="failure-panel" role="alert"><strong>任务失败原因</strong><p>{{detail.lastError}}</p></section><h3 class="recipient-heading">收件人投递明细</h3><el-table :data="detail.recipients" row-key="id" empty-text="暂无收件人明细" size="small"><el-table-column prop="displayName" label="收件人" min-width="150"/><el-table-column label="应用端" width="110"><template #default="{row}">{{notificationAppLabel(row.appCode)}}</template></el-table-column><el-table-column label="投递状态" width="120"><template #default="{row}">{{notificationRecipientStatusLabel(row.status)}}</template></el-table-column><el-table-column label="送达时间" width="180"><template #default="{row}">{{formatDate(row.deliveredAt)}}</template></el-table-column><el-table-column prop="failureReason" label="失败原因" min-width="220"><template #default="{row}">{{row.failureReason||'—'}}</template></el-table-column></el-table></template></AppDrawer></AdminPage></template>
<style scoped>.table-actions{display:flex;min-height:32px;align-items:center;gap:8px}.table-actions :deep(.el-button+.el-button){margin-left:0}.detail-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:0 0 20px}.detail-grid div{min-width:0;padding:12px;border:1px solid var(--border-color,#e5e7eb);border-radius:6px}.detail-grid dt{color:var(--text-secondary,#64748b);font-size:12px}.detail-grid dd{margin:6px 0 0;overflow-wrap:anywhere;color:var(--text-primary,#1f2937);font-weight:600}.failure-panel{margin-bottom:20px;padding:12px;border:1px solid #f2b8b5;border-radius:6px;background:#fff7f6;color:#9f2d27}.failure-panel p{margin:6px 0 0;white-space:pre-wrap;overflow-wrap:anywhere}.recipient-heading{margin:0 0 12px;font-size:15px}@media(max-width:720px){.detail-grid{grid-template-columns:1fr}}</style>
<template><AdminPage class="admin-density"><ListPageLayout aria-label="消息管理列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="消息任务筛选" :fields="queryFields" preference-key="notifications-messages-query" @search="search" /></template><template v-if="error" #state><LoadErrorState title="消息任务加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template><section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="notifications-messages-table" :loading="loading" :total="result.total" row-key="id" empty-text="暂无消息任务"><template #toolbar-actions><el-button v-if="access.can('platform:notification:create')" type="primary" :disabled="!templates.length" @click="composerOpen=true"><Plus :size="16"/>新建消息</el-button></template><template #pagination-left><span>共 {{ result.total }} 个任务</span></template><template #actions="{row}"><div class="density-actions"><el-button class="density-action" data-tone="neutral" @click="openDetail(row as NotificationTask)">查看</el-button><el-button v-if="access.can('platform:notification:task:cancel')&&['CREATED','SCHEDULED'].includes((row as NotificationTask).status)" class="density-action" data-tone="danger" :loading="actionId===(row as NotificationTask).id" @click="action(row as NotificationTask,'cancel')">取消</el-button><el-button v-if="access.can('platform:notification:task:retry')&&['RETRYING','DEAD'].includes((row as NotificationTask).status)" class="density-action" data-tone="neutral" :loading="actionId===(row as NotificationTask).id" @click="action(row as NotificationTask,'retry')">重试</el-button></div></template><template #category="{row}">{{notificationCategoryLabel((row as NotificationTask).category)}}</template><template #scheduledAt="{row}">{{formatDate((row as NotificationTask).scheduledAt)}}</template><template #sentAt="{row}">{{formatDate((row as NotificationTask).sentAt)}}</template><template #status="{row}"><el-tag>{{statusLabel((row as NotificationTask).status)}}</el-tag></template></DataTable></section></ListPageLayout><MessageComposerDialog v-model="composerOpen" :templates="templates" @success="load"/><AppDrawer class="admin-density admin-density-drawer" v-model="detailOpen" title="消息任务详情" size="min(820px, 94vw)" :loading="detailLoading" :show-footer="false" destroy-on-close><LoadErrorState v-if="detailError" title="消息任务详情加载失败" :message="detailError.message" :loading="detailLoading"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState><template v-else-if="detail"><dl class="detail-grid density-fields"><div><dt>任务号</dt><dd>{{detail.taskNo}}</dd></div><div><dt>状态</dt><dd>{{statusLabel(detail.status)}}</dd></div><div><dt>业务分类</dt><dd>{{notificationCategoryLabel(detail.category)}}</dd></div><div><dt>接收人数</dt><dd>{{detail.recipientCount}}</dd></div><div><dt>尝试次数</dt><dd>{{detail.attemptCount}}</dd></div><div><dt>发送时间</dt><dd>{{formatDate(detail.sentAt)}}</dd></div></dl><section v-if="detail.lastError" class="failure-panel" role="alert"><strong>任务失败原因</strong><p>{{detail.lastError}}</p></section><h3 class="recipient-heading">收件人投递明细</h3><el-table :data="detail.recipients" row-key="id" empty-text="暂无收件人明细" size="small"><el-table-column prop="displayName" label="收件人" min-width="150"/><el-table-column label="应用端" width="110"><template #default="{row}">{{notificationAppLabel(row.appCode)}}</template></el-table-column><el-table-column label="投递状态" width="120"><template #default="{row}">{{notificationRecipientStatusLabel(row.status)}}</template></el-table-column><el-table-column label="送达时间" width="180"><template #default="{row}">{{formatDate(row.deliveredAt)}}</template></el-table-column><el-table-column prop="failureReason" label="失败原因" min-width="220"><template #default="{row}">{{row.failureReason||'—'}}</template></el-table-column></el-table></template></AppDrawer></AdminPage></template>
<style scoped>
.detail-grid.density-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-bottom: var(--admin-space-6); }
.failure-panel { margin-bottom: var(--admin-space-6); padding: var(--admin-space-5); border: 1px solid #f2b8b5; border-radius: var(--admin-radius); background: #fff7f6; color: #9f2d27; }
.failure-panel p { margin: var(--admin-space-2) 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
.recipient-heading { margin: 0 0 var(--admin-space-5); font-size: var(--admin-font-section); }
@media(max-width:720px) { .detail-grid.density-fields { grid-template-columns: 1fr; } }
</style>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'; import { Pencil, Plus, Power } from '@lucide/vue'; import { ElMessage, ElMessageBox } from 'element-plus'
import '@/styles/adminDensity.css'
import { onMounted, ref, watch } from 'vue'; import { Plus } from '@lucide/vue'; import { ElMessage, ElMessageBox } from 'element-plus'
import AdminPage from '@/shared/components/AdminPage.vue'; import DataTable,{type DataTableColumn} from '@/shared/components/DataTable.vue'; import ListPageLayout from '@/shared/components/ListPageLayout.vue'; import QueryToolbar,{type QueryField,type QueryModel} from '@/shared/components/QueryToolbar.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'; import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'; import TemplateEditorDialog from '../components/TemplateEditorDialog.vue'; import { changeTemplateStatus, listTemplates } from '../api/notificationCenter'; import type { NotificationTemplate, TemplateStatus } from '../types'; import { getApiErrorDetails,getApiErrorMessage,type ApiErrorDetails } from '@/shared/api/http'; import { useAccessPreviewStore } from '@/core/access/accessPreview'
const access=useAccessPreviewStore(),page=ref(1),pageSize=ref(20),loading=ref(false),actionId=ref(''),error=ref<ApiErrorDetails|null>(null),editorOpen=ref(false),editing=ref<NotificationTemplate|null>(null),result=ref({records:[] as NotificationTemplate[],total:0,pages:0}),filters=ref<QueryModel>({code:'',category:'',title:'',status:''})
const queryFields:QueryField[]=[{key:'code',type:'input',label:'模板CODE',placeholder:'模板CODE'},{key:'category',type:'input',label:'业务分类',placeholder:'业务分类'},{key:'title',type:'input',label:'名称或标题',placeholder:'模板名称/标题'},{key:'status',type:'select',label:'状态',placeholder:'全部状态',options:[{label:'草稿',value:'DRAFT'},{label:'已激活',value:'ACTIVE'},{label:'已禁用',value:'DISABLED'}]}]
@@ -7,8 +8,7 @@ const columns:DataTableColumn[]=[{key:'actions',label:'操作',width:190,fixed:'
function statusLabel(v:TemplateStatus){return {DRAFT:'草稿',ACTIVE:'已激活',DISABLED:'已禁用'}[v]}
async function load(){loading.value=true;error.value=null;try{const d=await listTemplates({page:page.value,size:pageSize.value,templateCode:String(filters.value.code||'').trim()||undefined,category:String(filters.value.category||'').trim()||undefined,title:String(filters.value.title||'').trim()||undefined,status:(filters.value.status||undefined) as TemplateStatus|undefined});result.value={records:d.records,total:d.total,pages:d.pages}}catch(e){error.value=getApiErrorDetails(e,'模板加载失败')}finally{loading.value=false}}
function search(){page.value===1?void load():page.value=1}function create(){editing.value=null;editorOpen.value=true}function edit(row:NotificationTemplate){editing.value=row;editorOpen.value=true}
async function changeStatus(row:NotificationTemplate){const target=row.status==='DRAFT'?'ACTIVE':'DISABLED';try{await ElMessageBox.confirm(target==='ACTIVE'?'激活后模板不可再编辑,确定继续吗?':'禁用后首期不可重新启用,确定继续吗?',target==='ACTIVE'?'激活模板':'禁用模板',{type:'warning'});actionId.value=row.id;await changeTemplateStatus(row.id,target,row.version);ElMessage.success(target==='ACTIVE'?'模板已激活':'模板已禁用');await load()}catch(e){if(e!=='cancel'&&e!=='close')ElMessage.error(getApiErrorMessage(e,'模板状态变更失败'))}finally{actionId.value=''}}
async function changeStatus(row:NotificationTemplate){const target=row.status==='DRAFT'?'ACTIVE':'DISABLED';try{await ElMessageBox.confirm(target==='ACTIVE'?'激活后模板不可再编辑,确定继续吗?':'禁用后首期不可重新启用,确定继续吗?',target==='ACTIVE'?'激活模板':'禁用模板',{type:'warning',customClass:'admin-density-confirm'});actionId.value=row.id;await changeTemplateStatus(row.id,target,row.version);ElMessage.success(target==='ACTIVE'?'模板已激活':'模板已禁用');await load()}catch(e){if(e!=='cancel'&&e!=='close')ElMessage.error(getApiErrorMessage(e,'模板状态变更失败'))}finally{actionId.value=''}}
watch([page,pageSize],load);onMounted(load)
</script>
<template><AdminPage><ListPageLayout aria-label="消息模板列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="消息模板筛选" :fields="queryFields" preference-key="notifications-templates-query" @search="search"/></template><template v-if="error" #state><LoadErrorState title="模板加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error"/></template></LoadErrorState></template><section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="notifications-templates-table" :loading="loading" :total="result.total" row-key="id" empty-text="暂无消息模板"><template #toolbar-actions><el-button v-if="access.can('platform:notification:template:create')" type="primary" @click="create"><Plus :size="16"/>新增模板</el-button></template><template #pagination-left><span>共 {{result.total}} 个模板</span></template><template #actions="{row}"><div class="table-actions"><el-button v-if="access.can('platform:notification:template:edit')&&(row as NotificationTemplate).status==='DRAFT'" link type="primary" @click="edit(row as NotificationTemplate)"><Pencil :size="15"/>编辑</el-button><el-button v-if="access.can('platform:notification:template:status')&&(row as NotificationTemplate).status!=='DISABLED'" link :type="(row as NotificationTemplate).status==='ACTIVE'?'danger':'primary'" :loading="actionId===(row as NotificationTemplate).id" @click="changeStatus(row as NotificationTemplate)"><Power :size="15"/>{{(row as NotificationTemplate).status==='DRAFT'?'激活':'禁用'}}</el-button></div></template><template #status="{row}"><el-tag>{{statusLabel((row as NotificationTemplate).status)}}</el-tag></template></DataTable></section></ListPageLayout><TemplateEditorDialog v-model="editorOpen" :template="editing" @success="load"/></AdminPage></template>
<style scoped>.table-actions{display:flex;min-height:32px;align-items:center;gap:8px}.table-actions :deep(.el-button+.el-button){margin-left:0}</style>
<template><AdminPage class="admin-density"><ListPageLayout aria-label="消息模板列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="消息模板筛选" :fields="queryFields" preference-key="notifications-templates-query" @search="search"/></template><template v-if="error" #state><LoadErrorState title="模板加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error"/></template></LoadErrorState></template><section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="notifications-templates-table" :loading="loading" :total="result.total" row-key="id" empty-text="暂无消息模板"><template #toolbar-actions><el-button v-if="access.can('platform:notification:template:create')" type="primary" @click="create"><Plus :size="16"/>新增模板</el-button></template><template #pagination-left><span>共 {{result.total}} 个模板</span></template><template #actions="{row}"><div class="density-actions"><el-button v-if="access.can('platform:notification:template:edit')&&(row as NotificationTemplate).status==='DRAFT'" class="density-action" data-tone="neutral" @click="edit(row as NotificationTemplate)">编辑</el-button><el-button v-if="access.can('platform:notification:template:status')&&(row as NotificationTemplate).status!=='DISABLED'" class="density-action" :data-tone="(row as NotificationTemplate).status==='ACTIVE'?'danger':'success'" :loading="actionId===(row as NotificationTemplate).id" @click="changeStatus(row as NotificationTemplate)">{{(row as NotificationTemplate).status==='DRAFT'?'激活':'禁用'}}</el-button></div></template><template #status="{row}"><el-tag>{{statusLabel((row as NotificationTemplate).status)}}</el-tag></template></DataTable></section></ListPageLayout><TemplateEditorDialog v-model="editorOpen" :template="editing" @success="load"/></AdminPage></template>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, ref, watch } from 'vue'
import { Bell, CheckCheck, Eye, RefreshCcw } from '@lucide/vue'
import { CheckCheck } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
@@ -78,7 +79,7 @@ onMounted(() => void load())
</script>
<template>
<AdminPage>
<AdminPage class="admin-density">
<ListPageLayout aria-label="我的消息列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="我的消息筛选" :fields="queryFields" preference-key="notifications-inbox-query" @search="search" /></template>
<template v-if="error" #state><LoadErrorState title="我的消息加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
@@ -86,14 +87,14 @@ onMounted(() => void load())
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" :loading="loading" :total="result.total" row-key="id" empty-text="暂无消息">
<template #toolbar-actions><el-button v-if="canReadAll && unreadCount" type="primary" plain :disabled="loading" @click="markAllRead"><CheckCheck :size="16" />全部标为已读</el-button></template>
<template #pagination-left><span>共 {{ result.total }} 条<span v-if="unreadCount">,未读 {{ unreadCount }} 条</span></span></template>
<template #actions="{ row }"><div class="table-actions"><el-button link type="primary" @click="openDetail(row as NotificationInboxItem)"><Eye :size="15" />查看</el-button><el-button v-if="canRead && (row as NotificationInboxItem).status === 'UNREAD'" link type="primary" @click="markRead(row as NotificationInboxItem)"><CheckCheck :size="15" />标为已读</el-button></div></template>
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" data-tone="neutral" @click="openDetail(row as NotificationInboxItem)">查看</el-button><el-button v-if="canRead && (row as NotificationInboxItem).status === 'UNREAD'" class="density-action" data-tone="neutral" @click="markRead(row as NotificationInboxItem)">标为已读</el-button></div></template>
<template #category="{ row }">{{ notificationCategoryLabel((row as NotificationInboxItem).category) }}</template>
<template #sentAt="{ row }">{{ formatDate((row as NotificationInboxItem).sentAt) }}</template>
<template #status="{ row }"><el-tag :type="(row as NotificationInboxItem).status === 'UNREAD' ? 'danger' : 'info'">{{ statusLabel((row as NotificationInboxItem).status) }}</el-tag></template>
</DataTable>
</section>
</ListPageLayout>
<AppDrawer v-model="detailOpen" title="消息详情" size="min(680px, 94vw)" :loading="detailLoading" :show-footer="false" destroy-on-close>
<AppDrawer class="admin-density admin-density-drawer" v-model="detailOpen" title="消息详情" size="min(680px, 94vw)" :loading="detailLoading" :show-footer="false" destroy-on-close>
<LoadErrorState v-if="detailError" title="消息详情加载失败" :message="detailError.message" :loading="detailLoading"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<article v-else-if="detail" class="notification-detail"><header><h2>{{ detail.title }}</h2><el-tag :type="detail.status === 'UNREAD' ? 'danger' : 'info'">{{ statusLabel(detail.status) }}</el-tag></header><p class="meta">{{ notificationCategoryLabel(detail.category) }} · {{ formatDate(detail.sentAt) }}</p><div class="body">{{ detail.body || '暂无消息内容' }}</div></article>
</AppDrawer>
@@ -101,10 +102,8 @@ onMounted(() => void load())
</template>
<style scoped>
.table-actions { display: flex; min-height: 32px; align-items: center; gap: 8px; }
.table-actions :deep(.el-button + .el-button) { margin-left: 0; }
.notification-detail header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.notification-detail h2 { margin: 0; color: var(--text-primary, #1f2937); font-size: 20px; line-height: 1.4; }
.notification-detail .meta { color: var(--text-secondary, #64748b); font-size: 12px; }
.notification-detail .body { padding-top: 16px; border-top: 1px solid var(--border-color, #e5e7eb); color: var(--text-primary, #1f2937); white-space: pre-wrap; line-height: 1.8; }
.notification-detail header { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); }
.notification-detail h2 { min-width: 0; margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: var(--admin-font-title); line-height: 1.4; }
.notification-detail .meta { margin: var(--admin-space-5) 0; color: var(--cc-muted); font-size: var(--admin-font-caption); }
.notification-detail .body { padding-top: var(--admin-space-6); border-top: 1px solid var(--cc-border); color: var(--cc-text); white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.8; }
</style>