style(admin): unify dialog layouts and dense readonly presentation
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import MessageComposerDialog from './components/MessageComposerDialog.vue'
|
||||
import TemplateEditorDialog from './components/TemplateEditorDialog.vue'
|
||||
import PlatformNotificationsView from './pages/PlatformNotificationsView.vue'
|
||||
import MessageManagementView from './pages/MessageManagementView.vue'
|
||||
import MessageTemplatesView from './pages/MessageTemplatesView.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import * as api from './api/notificationCenter'
|
||||
import type { NotificationInboxDetail, NotificationTask, NotificationTaskDetail, NotificationTemplate, PageResult } from './types'
|
||||
|
||||
const { default: ElementPlus, ElDialog, ElDrawer, ElSelect } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
|
||||
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: '9223372036854775001', templateCode: 'ORDER_NOTICE', templateName: '订单通知', category: 'ORDER', channel: 'IN_APP', titleTemplate: '订单更新', bodyTemplate: '订单已更新', variableWhitelist: [], status: 'DRAFT', version: 7 }
|
||||
const task: NotificationTask = { id: '9223372036854775002', taskNo: 'NT202610200001', category: 'ORDER', title: '订单更新', status: 'CREATED', recipientCount: 0, scheduledAt: null, sentAt: null, duplicated: false }
|
||||
const taskDetail: NotificationTaskDetail = { ...task, attemptCount: 0, lastError: null, recipients: [] }
|
||||
const inbox: NotificationInboxDetail = { id: '9223372036854775003', category: 'ORDER', title: '订单更新', summary: '更新', body: '第一行\n第二行', status: 'UNREAD', sentAt: '2026-10-20T01: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],
|
||||
stubs: {
|
||||
PlatformTenantSelector: {
|
||||
props: ['modelValue', 'disabled', 'label'], emits: ['update:modelValue'],
|
||||
template: '<input aria-label="目标企业" :disabled="disabled" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
async function click(wrapper: VueWrapper, text: string, selector = 'button') {
|
||||
const button = wrapper.findAll(selector).find(item => item.text().trim() === text)
|
||||
expect(button, text).toBeDefined()
|
||||
await button!.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
async function composer(variables: string[] = []) {
|
||||
const wrapper = mount(MessageComposerDialog, { ...options(), props: { modelValue: true, templates: [{ ...template, status: 'ACTIVE', variableWhitelist: variables }] } })
|
||||
await flushPromises()
|
||||
wrapper.findAllComponents(ElSelect)[0]!.vm.$emit('update:modelValue', template.templateCode)
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
async function editor() {
|
||||
const wrapper = mount(TemplateEditorDialog, { ...options(), props: { modelValue: true, template } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
const failure = () => new ApiResponseError('测试拒绝保存', 'TEST_REJECTED', 409, null, 'notification-test-request')
|
||||
enableAutoUnmount(afterEach)
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
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(taskDetail)
|
||||
vi.mocked(api.listTemplates).mockResolvedValue(page([template]))
|
||||
vi.mocked(api.createNotification).mockResolvedValue(task)
|
||||
vi.mocked(api.createTemplate).mockResolvedValue(template)
|
||||
vi.mocked(api.updateTemplate).mockResolvedValue(template)
|
||||
vi.mocked(api.cancelTask).mockResolvedValue({ ...task, status: 'CANCELLED' })
|
||||
vi.mocked(api.retryTask).mockResolvedValue({ ...task, status: 'RETRYING' })
|
||||
vi.mocked(api.changeTemplateStatus).mockResolvedValue({ ...template, status: 'ACTIVE' })
|
||||
})
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' })
|
||||
|
||||
describe('notification native dialogs and drawers', () => {
|
||||
it('keeps fullscreen drafts and clears disabled scheduling before platform send', async () => {
|
||||
const wrapper = await composer()
|
||||
await wrapper.get('input[placeholder="请输入服务端角色编码"]').setValue(' TEST_OPERATOR ')
|
||||
await wrapper.get('[role="switch"]').trigger('click')
|
||||
await wrapper.get('[role="switch"]').trigger('click')
|
||||
await wrapper.get('button[aria-label="展开全屏"]').trigger('click')
|
||||
expect(wrapper.getComponent(ElDialog).props('fullscreen')).toBe(true)
|
||||
expect((wrapper.get('input[placeholder="请输入服务端角色编码"]').element as HTMLInputElement).value).toBe(' TEST_OPERATOR ')
|
||||
await click(wrapper, '立即发送')
|
||||
expect(api.createNotification).toHaveBeenCalledWith(expect.objectContaining({ tenantId: '0', recipientType: 'PLATFORM_ROLE', roleCode: 'TEST_OPERATOR', scheduledAt: null, variables: {}, idempotencyKey: expect.any(String) }))
|
||||
})
|
||||
it('retains numeric long recipient IDs, removes duplicates and omits role fields', async () => {
|
||||
const wrapper = await composer()
|
||||
wrapper.findAllComponents(ElSelect)[1]!.vm.$emit('update:modelValue', 'USER')
|
||||
await flushPromises()
|
||||
await wrapper.get('textarea').setValue('9223372036854775000,9223372036854775001\n9223372036854775000')
|
||||
await click(wrapper, '立即发送')
|
||||
expect(api.createNotification).toHaveBeenCalledWith(expect.objectContaining({ recipientType: 'USER', recipientIds: ['9223372036854775000', '9223372036854775001'] }))
|
||||
expect(vi.mocked(api.createNotification).mock.calls[0]![0]).not.toHaveProperty('roleCode')
|
||||
})
|
||||
it.each(['9223372036854775808', '123x', ''])('rejects invalid explicit recipients %s', async text => {
|
||||
const wrapper = await composer()
|
||||
wrapper.findAllComponents(ElSelect)[1]!.vm.$emit('update:modelValue', 'USER')
|
||||
await flushPromises()
|
||||
await wrapper.get('textarea').setValue(text)
|
||||
await click(wrapper, '立即发送')
|
||||
expect(api.createNotification).not.toHaveBeenCalled()
|
||||
})
|
||||
it('keeps customer selection, variable whitelist and scheduled request intact', async () => {
|
||||
const wrapper = await composer(['order_no'])
|
||||
wrapper.getComponent({ name: 'ElSegmented' }).vm.$emit('update:modelValue', 'customer')
|
||||
await flushPromises()
|
||||
await click(wrapper, '立即发送')
|
||||
expect(api.createNotification).not.toHaveBeenCalled()
|
||||
await wrapper.get('input[aria-label="目标企业"]').setValue('9223372036854775100')
|
||||
await click(wrapper, '立即发送')
|
||||
expect(api.createNotification).not.toHaveBeenCalled()
|
||||
await wrapper.get('input[placeholder="请输入变量值"]').setValue('NYEXX2610200001')
|
||||
await wrapper.get('[role="switch"]').trigger('click')
|
||||
await click(wrapper, '确认定时发送')
|
||||
expect(api.createNotification).toHaveBeenCalledWith(expect.objectContaining({ tenantId: '9223372036854775100', recipientType: 'TENANT_ALL', variables: { order_no: 'NYEXX2610200001' }, scheduledAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/) }))
|
||||
})
|
||||
it('locks composer close paths and retains the draft after a rejected send', async () => {
|
||||
const pending = deferred<NotificationTask>()
|
||||
vi.mocked(api.createNotification).mockReturnValue(pending.promise)
|
||||
const wrapper = await composer()
|
||||
await wrapper.get('input[placeholder="请输入服务端角色编码"]').setValue('TEST_OPERATOR')
|
||||
await click(wrapper, '立即发送')
|
||||
expect(wrapper.getComponent(ElDialog).props()).toMatchObject({ showClose: false, closeOnPressEscape: false, closeOnClickModal: false })
|
||||
expect(wrapper.get('input[placeholder="请输入服务端角色编码"]').attributes('disabled')).toBeDefined()
|
||||
await click(wrapper, '取消')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
pending.reject(failure())
|
||||
await flushPromises()
|
||||
expect(wrapper.getComponent(OperationErrorToast).props('details')).toMatchObject({ message: '测试拒绝保存', requestId: 'notification-test-request' })
|
||||
expect((wrapper.get('input[placeholder="请输入服务端角色编码"]').element as HTMLInputElement).value).toBe('TEST_OPERATOR')
|
||||
await click(wrapper, '立即发送')
|
||||
expect(api.createNotification).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
it('creates a normalized draft and inserts a whitelisted variable at the cursor', async () => {
|
||||
const wrapper = mount(TemplateEditorDialog, { ...options(), props: { modelValue: true } })
|
||||
await flushPromises()
|
||||
await wrapper.get('input[placeholder="如:ORDER_SHIPPED_NOTICE"]').setValue('order_notice')
|
||||
await wrapper.get('input[placeholder="请输入模板名称"]').setValue('测试模板')
|
||||
await wrapper.get('input[placeholder="请输入业务分类"]').setValue('ORDER')
|
||||
await wrapper.get('input[placeholder="请输入消息标题模板"]').setValue('测试标题')
|
||||
await wrapper.get('textarea').setValue('前后')
|
||||
;(wrapper.get('textarea').element as HTMLTextAreaElement).setSelectionRange(1, 1)
|
||||
await click(wrapper, 'order_no')
|
||||
expect((wrapper.get('textarea').element as HTMLTextAreaElement).value).toBe('前${order_no}后')
|
||||
expect(wrapper.get('.template-preview').text()).toContain('前${order_no}后')
|
||||
await click(wrapper, '保存模板')
|
||||
expect(api.createTemplate).toHaveBeenCalledWith({ templateCode: 'ORDER_NOTICE', templateName: '测试模板', category: 'ORDER', titleTemplate: '测试标题', bodyTemplate: '前${order_no}后', variableWhitelist: ['order_no'] })
|
||||
})
|
||||
it('keeps template code plain and preserves version and draft across fullscreen', async () => {
|
||||
const wrapper = await editor()
|
||||
expect(wrapper.find('input[placeholder="如:ORDER_SHIPPED_NOTICE"]').exists()).toBe(false)
|
||||
expect(wrapper.get('.density-table tr:first-child').text()).toContain(template.templateCode)
|
||||
await wrapper.get('input[placeholder="请输入模板名称"]').setValue('改名')
|
||||
await wrapper.get('button[aria-label="展开全屏"]').trigger('click')
|
||||
expect(wrapper.getComponent(ElDialog).props('fullscreen')).toBe(true)
|
||||
await click(wrapper, '保存模板')
|
||||
expect(api.updateTemplate).toHaveBeenCalledWith(template.id, expect.objectContaining({ templateName: '改名', version: 7 }))
|
||||
expect(vi.mocked(api.updateTemplate).mock.calls[0]![1]).not.toHaveProperty('templateCode')
|
||||
})
|
||||
it('validates template content and whitelist before saving', async () => {
|
||||
const wrapper = await editor()
|
||||
await wrapper.get('textarea').setValue('')
|
||||
await click(wrapper, '保存模板')
|
||||
expect(api.updateTemplate).not.toHaveBeenCalled()
|
||||
await wrapper.get('textarea').setValue('消息内容')
|
||||
await wrapper.get('input[placeholder^="多个变量"]').setValue('INVALID')
|
||||
await click(wrapper, '保存模板')
|
||||
expect(api.updateTemplate).not.toHaveBeenCalled()
|
||||
})
|
||||
it('locks template controls and retains rejected content and diagnostics', async () => {
|
||||
const pending = deferred<NotificationTemplate>()
|
||||
vi.mocked(api.updateTemplate).mockReturnValue(pending.promise)
|
||||
const wrapper = await editor()
|
||||
await wrapper.get('textarea').setValue('保留正文\n第二行')
|
||||
await click(wrapper, '保存模板')
|
||||
expect(wrapper.getComponent(ElDialog).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
|
||||
expect(wrapper.get('textarea').attributes('disabled')).toBeDefined()
|
||||
pending.reject(failure())
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.template-preview').text()).toContain('保留正文\n第二行')
|
||||
expect(wrapper.getComponent(OperationErrorToast).props('details')).toMatchObject({ requestId: 'notification-test-request' })
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
})
|
||||
it('renders long inbox text without exposing source IDs in read-only mode', async () => {
|
||||
can.mockReturnValue(false)
|
||||
const body = '完整正文'.repeat(1000) + '\n结束'
|
||||
vi.mocked(api.getMyNotification).mockResolvedValue({ ...inbox, body, sourceId: 'internal-source-id' })
|
||||
const wrapper = mount(PlatformNotificationsView, options())
|
||||
await flushPromises()
|
||||
await click(wrapper, '查看', '.el-table__body-wrapper button')
|
||||
const drawer = document.querySelector('.admin-density-drawer--detail')!
|
||||
expect(drawer.querySelector('.message-body')!.textContent).toBe(body)
|
||||
expect(drawer.textContent).not.toContain('internal-source-id')
|
||||
expect(drawer.querySelectorAll('input, textarea')).toHaveLength(0)
|
||||
expect(api.markMyNotificationRead).not.toHaveBeenCalled()
|
||||
})
|
||||
it.each(['inbox', 'task'] as const)('retries the same %s detail after a load error', async kind => {
|
||||
const get = kind === 'inbox' ? vi.mocked(api.getMyNotification) : vi.mocked(api.getTask)
|
||||
get.mockRejectedValueOnce(failure())
|
||||
const wrapper = mount(kind === 'inbox' ? PlatformNotificationsView : MessageManagementView, options())
|
||||
await flushPromises()
|
||||
await click(wrapper, '查看', '.el-table__body-wrapper button')
|
||||
const retry = document.querySelector('.el-drawer .load-error-state__actions button') as HTMLButtonElement
|
||||
expect(retry).not.toBeNull()
|
||||
retry.click()
|
||||
await flushPromises()
|
||||
expect(get).toHaveBeenCalledTimes(2)
|
||||
expect(document.querySelector('.el-drawer .load-error-state')).toBeNull()
|
||||
expect(document.querySelector('.el-drawer .density-dialog-section')).not.toBeNull()
|
||||
})
|
||||
it.each(['inbox', 'task'] as const)('ignores closed %s detail responses', async kind => {
|
||||
const pendingInbox = deferred<NotificationInboxDetail>()
|
||||
const pendingTask = deferred<NotificationTaskDetail>()
|
||||
if (kind === 'inbox') vi.mocked(api.getMyNotification).mockReturnValue(pendingInbox.promise)
|
||||
else vi.mocked(api.getTask).mockReturnValue(pendingTask.promise)
|
||||
const wrapper = mount(kind === 'inbox' ? PlatformNotificationsView : MessageManagementView, options())
|
||||
await flushPromises()
|
||||
await click(wrapper, '查看', '.el-table__body-wrapper button')
|
||||
wrapper.getComponent(ElDrawer).vm.$emit('update:modelValue', false)
|
||||
await flushPromises()
|
||||
pendingInbox.resolve(inbox)
|
||||
pendingTask.resolve(taskDetail)
|
||||
await flushPromises()
|
||||
expect(wrapper.getComponent(ElDrawer).props('modelValue')).toBe(false)
|
||||
expect(api.markMyNotificationRead).not.toHaveBeenCalled()
|
||||
})
|
||||
it('retains zero task facts and all delivery failure text', async () => {
|
||||
const reason = '完整失败原因'.repeat(200)
|
||||
vi.mocked(api.getTask).mockResolvedValue({ ...taskDetail, taskNo: '', lastError: reason, recipients: [{ id: 'internal-recipient-id', displayName: '测试员工', appCode: 'ADMIN', status: 'FAILED', deliveredAt: null, failureReason: reason }] })
|
||||
const wrapper = mount(MessageManagementView, options())
|
||||
await flushPromises()
|
||||
await click(wrapper, '查看', '.el-table__body-wrapper button')
|
||||
const drawer = document.querySelector('.admin-density-drawer--detail')!
|
||||
const facts = [...drawer.querySelectorAll('.task-facts td')].map(item => item.textContent)
|
||||
expect(facts).toEqual(['—', '已创建', '订单消息', '0', '0', '—'])
|
||||
expect(drawer.querySelector('.el-alert .message-body')!.textContent).toBe(reason)
|
||||
expect(drawer.querySelector('.el-table__body .message-body')!.textContent).toBe(reason)
|
||||
expect(drawer.textContent).not.toContain('internal-recipient-id')
|
||||
})
|
||||
it.each(['cancel', 'retry'] as const)('uses task business number and the existing %s command', async action => {
|
||||
vi.mocked(api.listTasks).mockResolvedValue(page([{ ...task, status: action === 'cancel' ? 'CREATED' : 'DEAD' }]))
|
||||
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(Object.assign('confirm', { value: '', action: 'confirm' as const }))
|
||||
const wrapper = mount(MessageManagementView, options())
|
||||
await flushPromises()
|
||||
await click(wrapper, action === 'cancel' ? '取消' : '重试', '.el-table__body-wrapper button')
|
||||
expect(confirm).toHaveBeenCalledWith(expect.any(String), expect.stringContaining(task.taskNo), expect.objectContaining({ customClass: 'admin-density-confirm' }))
|
||||
expect(action === 'cancel' ? api.cancelTask : api.retryTask).toHaveBeenCalledWith(task.id)
|
||||
})
|
||||
it.each(['DRAFT', 'ACTIVE'] as const)('keeps template %s confirmation and version', async status => {
|
||||
vi.mocked(api.listTemplates).mockResolvedValue(page([{ ...template, status }]))
|
||||
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(Object.assign('confirm', { value: '', action: 'confirm' as const }))
|
||||
const wrapper = mount(MessageTemplatesView, options())
|
||||
await flushPromises()
|
||||
await click(wrapper, status === 'DRAFT' ? '激活' : '禁用', '.el-table__body-wrapper button')
|
||||
expect(confirm).toHaveBeenCalledWith(expect.any(String), expect.stringContaining(template.templateName), expect.objectContaining({ customClass: 'admin-density-confirm' }))
|
||||
expect(api.changeTemplateStatus).toHaveBeenCalledWith(template.id, status === 'DRAFT' ? 'ACTIVE' : 'DISABLED', 7)
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { PlatformTenantSelector } from '@/modules/iam'
|
||||
import { createNotification } from '../api/notificationCenter'
|
||||
import type { NotificationTemplate, RecipientType } from '../types'
|
||||
@@ -44,6 +45,7 @@ const MAX_LONG_ID = 9_223_372_036_854_775_807n
|
||||
function parseIds() { return [...new Set(recipientIdsText.value.split(/[\s,,]+/).map((value) => value.trim()).filter(Boolean))] }
|
||||
function idempotencyKey() { return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `notification-${Date.now()}-${Math.random().toString(36).slice(2)}` }
|
||||
async function submit() {
|
||||
if (submitting.value) return
|
||||
error.value = null
|
||||
const recipientIds = parseIds()
|
||||
if (!form.templateCode) return ElMessage.warning('请选择已激活模板')
|
||||
@@ -61,21 +63,33 @@ async function submit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog class="admin-density-dialog" 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))" fullscreenable :confirm-loading="submitting" :show-confirm="false" destroy-on-close @cancel="reset">
|
||||
<OperationErrorToast :details="error" />
|
||||
<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>
|
||||
<div class="form-grid"><el-form-item label="接收对象" required><el-select v-model="form.recipientType"><el-option v-for="item in allowedRecipients" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item v-if="needsRole" label="角色编码" required><el-input v-model="form.roleCode" maxlength="64" placeholder="请输入服务端角色编码" /></el-form-item></div>
|
||||
<el-form-item v-if="needsIds" label="接收人用户 ID" required><el-input v-model="recipientIdsText" type="textarea" :rows="2" placeholder="多个用户 ID 使用逗号或换行分隔,最多 1000 个" /></el-form-item>
|
||||
<section v-if="selectedTemplate?.variableWhitelist.length" class="variables"><strong>模板变量</strong><el-form-item v-for="key in selectedTemplate.variableWhitelist" :key="key" :label="key" required><el-input v-model="variables[key]" maxlength="2000" placeholder="请输入变量值" /></el-form-item></section>
|
||||
<el-form-item label="定时发送"><div class="schedule-row"><el-switch v-model="scheduled" aria-label="启用定时发送" /><span>启用</span><el-date-picker v-model="form.scheduledAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="选择发送时间" :disabled="!scheduled" /></div></el-form-item>
|
||||
<el-form class="density-form density-dialog-sections" label-position="left" label-width="100px" :disabled="submitting">
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="发送设置">
|
||||
<h3 class="density-dialog-section__title">发送设置 <InlineHelp title="发送说明">消息由已激活模板生成,仅发送站内信;可指定平台员工或企业接收人,并按需启用定时发送。</InlineHelp></h3>
|
||||
<table class="density-table density-table--editable"><tbody>
|
||||
<tr><td><el-form-item label="发送范围" required><el-segmented v-model="scope" :options="[{ label: '平台员工', value: 'platform' }, { label: '指定企业', value: 'customer' }]" /></el-form-item></td></tr>
|
||||
<tr v-if="scope === 'customer'"><td><PlatformTenantSelector v-model="form.tenantId" label="目标企业" :disabled="submitting" /></td></tr>
|
||||
<tr><td><el-form-item label="发送方式"><span>站内信</span></el-form-item></td></tr>
|
||||
<tr><td><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></td></tr>
|
||||
<tr><td><el-form-item label="接收对象" required><el-select v-model="form.recipientType"><el-option v-for="item in allowedRecipients" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item></td></tr>
|
||||
<tr v-if="needsRole"><td><el-form-item label="角色编码" required><el-input v-model="form.roleCode" maxlength="64" placeholder="请输入服务端角色编码" /></el-form-item></td></tr>
|
||||
<tr v-if="needsIds"><td><el-form-item label="接收人用户 ID" required><el-input v-model="recipientIdsText" type="textarea" :rows="2" placeholder="多个用户 ID 使用逗号或换行分隔,最多 1000 个" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="定时发送"><div class="schedule-row"><el-switch v-model="scheduled" aria-label="启用定时发送" /><span>启用</span><el-date-picker v-model="form.scheduledAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="选择发送时间" :disabled="!scheduled" /></div></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section v-if="selectedTemplate?.variableWhitelist.length" class="density-dialog-section" data-tone="work" aria-label="模板变量">
|
||||
<h3 class="density-dialog-section__title">模板变量</h3>
|
||||
<table class="density-table density-table--editable"><tbody><tr v-for="key in selectedTemplate.variableWhitelist" :key="key"><td><el-form-item :label="key" required><el-input v-model="variables[key]" maxlength="2000" placeholder="请输入变量值" /></el-form-item></td></tr></tbody></table>
|
||||
</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">{{ scheduled ? '确认定时发送' : '立即发送' }}</el-button></div></template>
|
||||
<template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button :disabled="submitting" @click="close">取消</el-button><el-button type="primary" :loading="submitting" @click="submit">{{ scheduled ? '确认定时发送' : '立即发送' }}</el-button></div></template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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}}
|
||||
.schedule-row { display: grid; width: 100%; grid-template-columns: auto auto minmax(180px, 1fr); align-items: center; gap: var(--admin-space-3); }
|
||||
.schedule-row :deep(.el-date-editor) { width: 100%; }
|
||||
@media(max-width:560px) { .schedule-row { grid-template-columns: auto 1fr; } .schedule-row :deep(.el-date-editor) { grid-column: 1 / -1; } }
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { createTemplate, updateTemplate } from '../api/notificationCenter'
|
||||
import type { NotificationTemplate } from '../types'
|
||||
@@ -24,6 +25,7 @@ watch([open, () => props.template], ([isOpen]) => { if (!isOpen) return; const i
|
||||
function normalizeCode(value: string) { form.code = value.replace(/[^A-Za-z0-9_]/g, '').toUpperCase() }
|
||||
function insertVariable(name: string) { if (!whitelist.value.includes(name)) whitelistText.value = [...whitelist.value, name].join(', '); const textarea = contentInput.value?.textarea; const value = `\${${name}}`; const start = textarea?.selectionStart ?? form.content.length; const end = textarea?.selectionEnd ?? start; form.content = `${form.content.slice(0,start)}${value}${form.content.slice(end)}`.slice(0,10000) }
|
||||
async function submit() {
|
||||
if (submitting.value) return
|
||||
error.value = null
|
||||
if (!/^[A-Z][A-Z0-9_]{0,63}$/.test(form.code)) return ElMessage.warning('模板CODE须以字母开头,仅支持大写字母、数字和下划线')
|
||||
if (!form.name.trim() || !form.category.trim() || !form.title.trim() || !form.content.trim()) return ElMessage.warning('请填写完整模板信息')
|
||||
@@ -35,14 +37,30 @@ async function submit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog class="admin-density-dialog" 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 ? `编辑消息模板 · ${template?.templateCode || '—'}` : '新增消息模板'" width="min(760px, calc(100vw - 28px))" fullscreenable :confirm-loading="submitting" :show-confirm="false" destroy-on-close>
|
||||
<OperationErrorToast :details="error" />
|
||||
<el-alert title="首期仅支持站内信;新模板保存为草稿,草稿激活后不可再编辑。" type="info" :closable="false" show-icon />
|
||||
<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>
|
||||
<el-form class="density-form density-dialog-sections" label-position="left" label-width="110px" :disabled="submitting">
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="模板定义">
|
||||
<h3 class="density-dialog-section__title">模板定义 <InlineHelp title="模板说明">仅支持站内信;新模板保存为草稿,草稿激活后不可再编辑。变量须登记到白名单,发送时逐项填写。</InlineHelp></h3>
|
||||
<table class="density-table density-table--editable"><tbody>
|
||||
<tr><td><el-form-item label="模板CODE" required><span v-if="editing">{{ form.code }}</span><el-input v-else :model-value="form.code" maxlength="64" placeholder="如:ORDER_SHIPPED_NOTICE" @update:model-value="normalizeCode(String($event))" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="模板名称" required><el-input v-model="form.name" maxlength="128" placeholder="请输入模板名称" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="业务分类" required><el-input v-model="form.category" maxlength="64" placeholder="请输入业务分类" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="模板类型"><span>站内信</span></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="消息标题模板" required><el-input v-model="form.title" maxlength="256" show-word-limit placeholder="请输入消息标题模板" /></el-form-item></td></tr>
|
||||
<tr><td><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></td></tr>
|
||||
<tr><td><el-form-item label="变量白名单"><el-input v-model="whitelistText" placeholder="多个变量使用逗号分隔,如 customer_name, order_no" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="常用变量"><div class="variable-row"><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></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section template-preview" data-tone="source" aria-label="内容预览"><h3 class="density-dialog-section__title">内容预览</h3><p class="density-dialog-note message-body">{{ preview }}</p></section>
|
||||
</el-form>
|
||||
<template #footer="{ close }"><div class="density-actions density-actions--footer"><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: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}}
|
||||
.variable-row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--admin-space-3); }
|
||||
.variable-row :deep(.el-button) { margin-left: 0; }
|
||||
.message-body { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
</style>
|
||||
|
||||
@@ -80,7 +80,7 @@ describe('notification density in native lists and overlays', () => {
|
||||
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.querySelectorAll('.task-facts th')).toHaveLength(6)
|
||||
expect(drawer.textContent).toContain(task.taskNo)
|
||||
expect(drawer.textContent).toContain('测试员工')
|
||||
expect(drawer.textContent).toContain('测试投递失败原因')
|
||||
@@ -107,7 +107,8 @@ describe('notification density in native lists and overlays', () => {
|
||||
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()
|
||||
expect(wrapper.find('input[placeholder="如:ORDER_SHIPPED_NOTICE"]').exists()).toBe(false)
|
||||
expect(wrapper.get('.density-table tr:first-child').text()).toContain(template.templateCode)
|
||||
await click(wrapper, 'order_no')
|
||||
expect(wrapper.get('.template-preview').text()).toContain('${order_no}')
|
||||
await click(wrapper, '保存模板')
|
||||
|
||||
@@ -9,13 +9,16 @@ import { notificationAppLabel, notificationCategoryLabel, notificationRecipientS
|
||||
const access=useAccessPreviewStore(),page=ref(1),pageSize=ref(20),loading=ref(false),actionId=ref(''),error=ref<ApiErrorDetails|null>(null),composerOpen=ref(false),detailOpen=ref(false),detailLoading=ref(false),detailError=ref<ApiErrorDetails|null>(null),detail=ref<NotificationTaskDetail|null>(null),templates=ref<NotificationTemplate[]>([]),result=ref({records:[] as NotificationTask[],total:0,pages:0}),filters=ref<QueryModel>({category:'',status:'',keyword:''})
|
||||
const queryFields:QueryField[]=[{key:'category',type:'input',label:'业务分类',placeholder:'业务分类'},{key:'status',type:'select',label:'任务状态',placeholder:'全部状态',options:['CREATED','SCHEDULED','DISPATCHING','RETRYING','SENT','DEAD','CANCELLED'].map(value=>({label:statusLabel(value as NotificationTaskStatus),value}))},{key:'keyword',type:'input',label:'任务号或标题',placeholder:'任务号/消息标题'}]
|
||||
const columns:DataTableColumn[]=[{key:'actions',label:'操作',width:240,fixed:'left',slotName:'actions'},{key:'taskNo',prop:'taskNo',label:'任务号',minWidth:190},{key:'title',prop:'title',label:'标题',minWidth:220},{key:'category',label:'业务分类',width:130,slotName:'category'},{key:'recipientCount',prop:'recipientCount',label:'接收人数',width:100},{key:'scheduledAt',label:'定时发送',width:170,slotName:'scheduledAt'},{key:'sentAt',label:'发送时间',width:170,slotName:'sentAt'},{key:'status',label:'状态',width:110,slotName:'status'}]
|
||||
const detailRow = ref<NotificationTask | null>(null)
|
||||
let detailRequest = 0
|
||||
watch(detailOpen, value => { if (!value) { detailRequest++; detailRow.value = null } })
|
||||
function statusLabel(v:NotificationTaskStatus){return {CREATED:'已创建',SCHEDULED:'待定时发送',DISPATCHING:'发送中',RETRYING:'重试中',SENT:'已发送',DEAD:'发送失败',CANCELLED:'已取消'}[v]}
|
||||
function formatDate(v:string|null){return v?new Date(v).toLocaleString('zh-CN',{hour12:false}):'—'}
|
||||
async function load(){loading.value=true;error.value=null;try{const keyword=String(filters.value.keyword||'').trim();const data=await listTasks({page:page.value,size:pageSize.value,category:String(filters.value.category||'').trim()||undefined,status:(filters.value.status||undefined) as NotificationTaskStatus|undefined,...(keyword.startsWith('NT')?{taskNo:keyword}:{title:keyword||undefined})});result.value={records:data.records,total:data.total,pages:data.pages}}catch(e){error.value=getApiErrorDetails(e,'消息任务加载失败')}finally{loading.value=false}}
|
||||
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}}
|
||||
async function openDetail(row:NotificationTask){const request=++detailRequest;detailRow.value=row;detailOpen.value=true;detailLoading.value=true;detailError.value=null;detail.value=null;try{const result=await getTask(row.id);if(request===detailRequest&&detailOpen.value)detail.value=result}catch(e){if(request===detailRequest&&detailOpen.value)detailError.value=getApiErrorDetails(e,'消息任务详情加载失败')}finally{if(request===detailRequest)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',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=''}}
|
||||
async function action(row:NotificationTask,type:'cancel'|'retry'){try{await ElMessageBox.confirm(type==='cancel'?'确定取消该消息任务吗?':'确定重试该消息任务吗?',`${type==='cancel'?'取消任务':'重试任务'} · ${row.taskNo || '—'}`,{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 class="admin-density"><ListPageLayout aria-label="消息管理列表"><section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!error" 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="暂无消息任务">
|
||||
@@ -23,11 +26,32 @@ watch([page,pageSize],load);onMounted(()=>{void load();void loadTemplates()})
|
||||
<template #filters><QueryToolbar v-model="filters" ariaLabel="消息任务筛选" :fields="queryFields" preference-key="notifications-messages-query" @search="search" /></template>
|
||||
<template #notices><template v-if="error"><LoadErrorState title="消息任务加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template></template>
|
||||
|
||||
<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 row-actions admin-text-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>
|
||||
<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 row-actions admin-text-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 admin-density-drawer--detail" v-model="detailOpen" :title="`消息任务详情 · ${detail?.taskNo || '—'}`" size="min(820px, 94vw)" :loading="detailLoading" :show-footer="false" destroy-on-close>
|
||||
<LoadErrorState v-if="detailError" title="消息任务详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="detailRow && openDetail(detailRow)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
<div v-else-if="detail" class="density-dialog-sections">
|
||||
<section class="density-dialog-section" data-tone="summary" aria-label="任务资料">
|
||||
<h3 class="density-dialog-section__title">{{ detail.title || '消息任务' }}</h3>
|
||||
<table class="density-table task-facts"><tbody>
|
||||
<tr><th scope="row">任务号</th><td>{{ detail.taskNo || '—' }}</td><th scope="row">状态</th><td>{{ statusLabel(detail.status) }}</td></tr>
|
||||
<tr><th scope="row">业务分类</th><td>{{notificationCategoryLabel(detail.category)}}</td><th scope="row">接收人数</th><td>{{ detail.recipientCount }}</td></tr>
|
||||
<tr><th scope="row">尝试次数</th><td>{{ detail.attemptCount }}</td><th scope="row">发送时间</th><td>{{ formatDate(detail.sentAt) }}</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<el-alert v-if="detail.lastError" title="任务失败原因" type="error" :closable="false" show-icon><div class="message-body">{{ detail.lastError }}</div></el-alert>
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="收件人投递明细">
|
||||
<h3 class="density-dialog-section__title">收件人投递明细</h3>
|
||||
<el-table :data="detail.recipients" row-key="id" empty-text="暂无收件人明细" size="small">
|
||||
<el-table-column prop="displayName" label="收件人" min-width="150" show-overflow-tooltip />
|
||||
<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}"><span class="message-body">{{row.failureReason||'—'}}</span></template></el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
</div>
|
||||
</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; } }
|
||||
.task-facts { table-layout: fixed; }
|
||||
.task-facts th { width: var(--admin-label-width); }
|
||||
.message-body { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
</style>
|
||||
|
||||
@@ -8,7 +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',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=''}}
|
||||
async function changeStatus(row:NotificationTemplate){const target=row.status==='DRAFT'?'ACTIVE':'DISABLED';try{await ElMessageBox.confirm(target==='ACTIVE'?'激活后模板不可再编辑,确定继续吗?':'禁用后首期不可重新启用,确定继续吗?',`${target==='ACTIVE'?'激活模板':'禁用模板'} · ${row.templateName || row.templateCode || '—'}`,{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 class="admin-density"><ListPageLayout aria-label="消息模板列表"><section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!error" 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="暂无消息模板">
|
||||
|
||||
@@ -27,6 +27,9 @@ const detailOpen = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailError = ref<ApiErrorDetails | null>(null)
|
||||
const detail = ref<NotificationInboxDetail | null>(null)
|
||||
const detailRow = ref<NotificationInboxItem | null>(null)
|
||||
let detailRequest = 0
|
||||
watch(detailOpen, value => { if (!value) { detailRequest++; detailRow.value = null } })
|
||||
const access = useAccessPreviewStore()
|
||||
const canRead = computed(() => access.can('platform:notification:read'))
|
||||
const canReadAll = computed(() => access.can('platform:notification:read-all'))
|
||||
@@ -58,14 +61,20 @@ async function load() {
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
async function openDetail(row: NotificationInboxItem) {
|
||||
const request = ++detailRequest
|
||||
detailRow.value = row
|
||||
detailOpen.value = true; detailLoading.value = true; detailError.value = null; detail.value = null
|
||||
try {
|
||||
detail.value = await getMyNotification(row.id)
|
||||
if (detail.value.status === 'UNREAD' && canRead.value) {
|
||||
detail.value = await markMyNotificationRead(row.id)
|
||||
const result = await getMyNotification(row.id)
|
||||
if (request !== detailRequest || !detailOpen.value) return
|
||||
detail.value = result
|
||||
if (result.status === 'UNREAD' && canRead.value) {
|
||||
const read = await markMyNotificationRead(row.id)
|
||||
if (request !== detailRequest || !detailOpen.value) return
|
||||
detail.value = read
|
||||
await load()
|
||||
}
|
||||
} catch (reason) { detailError.value = getApiErrorDetails(reason, '消息详情加载失败') } finally { detailLoading.value = false }
|
||||
} catch (reason) { if (request === detailRequest && detailOpen.value) detailError.value = getApiErrorDetails(reason, '消息详情加载失败') } finally { if (request === detailRequest) detailLoading.value = false }
|
||||
}
|
||||
async function markRead(row: NotificationInboxItem) {
|
||||
if (!canRead.value || row.status !== 'UNREAD') return
|
||||
@@ -97,16 +106,21 @@ onMounted(() => void load())
|
||||
<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 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 class="admin-density admin-density-drawer admin-density-drawer--detail" 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" @retry="detailRow && openDetail(detailRow)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
<article v-else-if="detail" class="density-dialog-sections notification-detail">
|
||||
<section class="density-dialog-section" data-tone="summary" aria-label="消息资料">
|
||||
<h3 class="density-dialog-section__title message-heading"><span>{{ detail.title || '消息详情' }}</span><el-tag :type="detail.status === 'UNREAD' ? 'danger' : 'info'">{{ statusLabel(detail.status) }}</el-tag></h3>
|
||||
<p class="density-dialog-note">{{ notificationCategoryLabel(detail.category) }} · {{ formatDate(detail.sentAt) }}</p>
|
||||
</section>
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="消息正文"><h3 class="density-dialog-section__title">消息正文</h3><div class="message-body">{{ detail.body || '暂无消息内容' }}</div></section>
|
||||
</article>
|
||||
</AppDrawer>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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; }
|
||||
.message-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--admin-space-5); }
|
||||
.message-heading > span { min-width: 0; overflow-wrap: anywhere; }
|
||||
.message-body { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user