This commit is contained in:
2026-10-20 23:34:11 +08:00
parent f155976f52
commit 13e4aa56ec
321 changed files with 6756 additions and 2110 deletions
@@ -256,7 +256,7 @@ describe('notification native dialogs and drawers', () => {
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(confirm).toHaveBeenCalledWith(expect.any(String), expect.stringContaining(task.taskNo), expect.objectContaining({ customClass: 'admin-density-confirm admin-workbench' }))
expect(action === 'cancel' ? api.cancelTask : api.retryTask).toHaveBeenCalledWith(task.id)
})
it.each(['DRAFT', 'ACTIVE'] as const)('keeps template %s confirmation and version', async status => {
@@ -265,7 +265,7 @@ describe('notification native dialogs and drawers', () => {
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(confirm).toHaveBeenCalledWith(expect.any(String), expect.stringContaining(template.templateName), expect.objectContaining({ customClass: 'admin-density-confirm admin-workbench' }))
expect(api.changeTemplateStatus).toHaveBeenCalledWith(template.id, status === 'DRAFT' ? 'ACTIVE' : 'DISABLED', 7)
})
})
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
@@ -63,10 +64,10 @@ async function submit() {
</script>
<template>
<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">
<AppDialog class="admin-density-dialog admin-workbench is-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="density-form density-dialog-sections" label-position="left" label-width="100px" :disabled="submitting">
<section class="density-dialog-section" data-tone="work" aria-label="发送设置">
<section class="density-dialog-section workbench-evidence" 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>
@@ -79,7 +80,7 @@ async function submit() {
<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="模板变量">
<section v-if="selectedTemplate?.variableWhitelist.length" class="density-dialog-section workbench-evidence" 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>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
@@ -37,10 +38,10 @@ async function submit() {
</script>
<template>
<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>
<AppDialog class="admin-density-dialog admin-workbench is-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-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="模板定义">
<section class="density-dialog-section workbench-evidence" 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>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { onMounted, ref, watch } from 'vue'
import { Plus } from '@lucide/vue'
@@ -18,18 +19,18 @@ 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){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'?'取消任务':'重试任务'} · ${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=''}}
async function action(row:NotificationTask,type:'cancel'|'retry'){try{await ElMessageBox.confirm(type==='cancel'?'确定取消该消息任务吗?':'确定重试该消息任务吗?',`${type==='cancel'?'取消任务':'重试任务'} · ${row.taskNo || '—'}`,{type:'warning',customClass: 'admin-density-confirm admin-workbench'});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="暂无消息任务">
<template><AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="消息管理列表"><section class="workbench-table is-page-surface"><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="暂无消息任务">
<template #toolbar-start><h1>消息管理</h1></template>
<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 admin-density-drawer--detail" v-model="detailOpen" :title="`消息任务详情 · ${detail?.taskNo || '—'}`" size="min(820px, 94vw)" :loading="detailLoading" :show-footer="false" destroy-on-close>
<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 admin-workbench is-drawer" 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="任务资料">
<section class="density-dialog-section workbench-evidence" 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>
@@ -40,13 +41,13 @@ watch([page,pageSize],load);onMounted(()=>{void load();void loadTemplates()})
<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">
<div class="workbench-table"><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>
</el-table></div>
</section>
</div>
</AppDrawer></AdminPage></template>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
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'
@@ -8,10 +9,10 @@ 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'?'激活模板':'禁用模板'} · ${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=''}}
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 admin-workbench'});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="暂无消息模板">
<template><AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="消息模板列表"><section class="workbench-table is-page-surface"><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="暂无消息模板">
<template #toolbar-start><h1>模板管理</h1></template>
<template #filters><QueryToolbar v-model="filters" ariaLabel="消息模板筛选" :fields="queryFields" preference-key="notifications-templates-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>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, onMounted, ref, watch } from 'vue'
import { CheckCheck } from '@lucide/vue'
@@ -88,11 +89,11 @@ onMounted(() => void load())
</script>
<template>
<AdminPage class="admin-density">
<AdminPage class="admin-density admin-workbench">
<ListPageLayout aria-label="我的消息列表">
<section><DataTable preference-key="notifications-platformnotificationsview" 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" :loading="loading" :total="result.total" row-key="id" empty-text="暂无消息">
<section class="workbench-table is-page-surface"><DataTable preference-key="notifications-platformnotificationsview" 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" :loading="loading" :total="result.total" row-key="id" empty-text="暂无消息">
<template #toolbar-start><h1>我的消息</h1></template>
<template #filters><QueryToolbar v-model="filters" ariaLabel="我的消息筛选" :fields="queryFields" preference-key="notifications-inbox-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>
@@ -106,7 +107,7 @@ 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 admin-density-drawer--detail" v-model="detailOpen" title="消息详情" size="min(680px, 94vw)" :loading="detailLoading" :show-footer="false" destroy-on-close>
<AppDrawer class="admin-density admin-density-drawer admin-density-drawer--detail admin-workbench is-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" @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="消息资料">