Files
admin-web/src/modules/notifications/pages/MessageManagementView.vue
T
2026-10-21 13:22:42 +08:00

59 lines
11 KiB
Vue

<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 AppDrawer from '@/shared/components/AppDrawer.vue'; import DataTable,{type DataTableColumn} from '@/shared/components/DataTable.vue'; import ListPageLayout from '@/shared/components/ListPageLayout.vue'; import QueryToolbar,{type QueryField,type QueryModel} from '@/shared/components/QueryToolbar.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'; import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import MessageComposerDialog from '../components/MessageComposerDialog.vue'; import { cancelTask, getTask, listTasks, listTemplates, retryTask } from '../api/notificationCenter'; import type { NotificationTask, NotificationTaskDetail, NotificationTaskStatus, NotificationTemplate } from '../types'; import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'; import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { notificationAppLabel, notificationCategoryLabel, notificationRecipientStatusLabel } from '../presentation'
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){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 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 admin-workbench"><ListPageLayout aria-label="消息管理列表"><section class="workbench-table is-page-surface"><DataTable appearance="integrated" 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 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 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>
<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>
<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></div>
</section>
</div>
</AppDrawer></AdminPage></template>
<style scoped>
.task-facts { table-layout: fixed; }
.task-facts th { width: var(--admin-label-width); }
.message-body { white-space: pre-wrap; overflow-wrap: anywhere; }
</style>