style(admin): unify dialog layouts and dense readonly presentation

This commit is contained in:
2026-10-20 10:11:31 +08:00
parent 7ff7c9914f
commit 3267679758
214 changed files with 10931 additions and 2654 deletions
@@ -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)
})
})