|
|
|
@@ -0,0 +1,89 @@
|
|
|
|
|
<template>
|
|
|
|
|
<section class="notification-page">
|
|
|
|
|
<el-alert v-if="error" :title="error" type="error" show-icon closable @close="error = ''" />
|
|
|
|
|
<section class="notification-metrics">
|
|
|
|
|
<span><small>通知模板</small><strong>{{ templates.length }}</strong></span>
|
|
|
|
|
<span><small>路由规则</small><strong>{{ routes.length }}</strong></span>
|
|
|
|
|
<span><small>待重试</small><strong>{{ retryCount }}</strong></span>
|
|
|
|
|
<span><small>投递失败</small><strong>{{ failedCount }}</strong></span>
|
|
|
|
|
</section>
|
|
|
|
|
<section class="panel">
|
|
|
|
|
<header class="notification-toolbar">
|
|
|
|
|
<div><p class="section-kicker">M10-A · 通知中心</p><h3>事件路由、模板与投递追踪</h3></div>
|
|
|
|
|
<div class="toolbar-actions"><el-select v-model="storeId" clearable filterable placeholder="全部授权门店" @change="loadAll"><el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" /></el-select><el-button :loading="loading" @click="loadAll">刷新</el-button></div>
|
|
|
|
|
</header>
|
|
|
|
|
<el-tabs v-model="activeTab">
|
|
|
|
|
<el-tab-pane :label="`投递记录 (${deliveries.total})`" name="deliveries">
|
|
|
|
|
<div class="tab-tools"><el-select v-model="deliveryStatus" clearable placeholder="全部状态" @change="loadDeliveries"><el-option v-for="status in statuses" :key="status" :label="status" :value="status" /></el-select><el-select v-model="deliveryChannel" clearable placeholder="全部渠道" @change="loadDeliveries"><el-option v-for="item in channels" :key="item" :label="channelLabel(item)" :value="item" /></el-select></div>
|
|
|
|
|
<el-table v-loading="loading" :data="deliveries.items" row-key="id">
|
|
|
|
|
<el-table-column label="事件 / 内容" min-width="260"><template #default="{ row }"><div class="stack"><strong>{{ row.title }}</strong><span>{{ row.eventType }} · {{ row.body }}</span></div></template></el-table-column>
|
|
|
|
|
<el-table-column label="渠道 / 接收人" min-width="180"><template #default="{ row }"><div class="stack"><strong>{{ channelLabel(row.channel) }}</strong><span>{{ row.recipientMasked }}</span></div></template></el-table-column>
|
|
|
|
|
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ row.status }}</el-tag></template></el-table-column>
|
|
|
|
|
<el-table-column label="尝试" width="100"><template #default="{ row }">{{ row.attempts }} / {{ row.maxAttempts }}</template></el-table-column>
|
|
|
|
|
<el-table-column label="异常 / 时间" min-width="210"><template #default="{ row }"><div class="stack"><strong>{{ row.lastErrorCode || '-' }}</strong><span>{{ row.lastErrorMessage || dateText(row.sentAt || row.createdAt) }}</span></div></template></el-table-column>
|
|
|
|
|
<el-table-column v-if="canManage" label="操作" width="110" fixed="right"><template #default="{ row }"><el-button v-if="row.status === 'FAILED'" size="small" type="primary" plain @click="retry(row)">人工补发</el-button></template></el-table-column>
|
|
|
|
|
</el-table>
|
|
|
|
|
</el-tab-pane>
|
|
|
|
|
<el-tab-pane :label="`模板 (${templates.length})`" name="templates">
|
|
|
|
|
<div class="tab-tools"><el-button v-if="canManage" type="primary" @click="openTemplate">新增模板</el-button></div>
|
|
|
|
|
<el-table :data="templates" row-key="id"><el-table-column label="模板" min-width="220"><template #default="{ row }"><div class="stack"><strong>{{ row.templateCode }}</strong><span>{{ row.titleTemplate }}</span></div></template></el-table-column><el-table-column prop="eventType" label="事件" min-width="210" /><el-table-column label="渠道" width="140"><template #default="{ row }">{{ channelLabel(row.channel) }}</template></el-table-column><el-table-column prop="bodyTemplate" label="正文" min-width="260" /><el-table-column prop="status" label="状态" width="100" /></el-table>
|
|
|
|
|
</el-tab-pane>
|
|
|
|
|
<el-tab-pane :label="`路由 (${routes.length})`" name="routes">
|
|
|
|
|
<div class="tab-tools"><el-button v-if="canManage" type="primary" :disabled="!templates.length" @click="openRoute">新增路由</el-button></div>
|
|
|
|
|
<el-table :data="routes" row-key="id"><el-table-column prop="eventType" label="事件" min-width="210" /><el-table-column label="模板 / 渠道" min-width="210"><template #default="{ row }"><div class="stack"><strong>{{ row.templateCode }}</strong><span>{{ channelLabel(row.channel) }}</span></div></template></el-table-column><el-table-column label="接收人" min-width="190"><template #default="{ row }">{{ row.recipientType }} · {{ row.recipientValue }}</template></el-table-column><el-table-column label="静默时段" width="160"><template #default="{ row }">{{ row.quietStart ? `${row.quietStart} - ${row.quietEnd}` : '未设置' }}</template></el-table-column><el-table-column prop="status" label="状态" width="100" /></el-table>
|
|
|
|
|
</el-tab-pane>
|
|
|
|
|
</el-tabs>
|
|
|
|
|
</section>
|
|
|
|
|
<el-dialog v-model="templateDialog" title="新增通知模板" width="min(620px, 94vw)"><el-form label-position="top"><el-form-item label="模板编码"><el-input v-model="templateForm.templateCode" /></el-form-item><el-form-item label="事件类型"><el-input v-model="templateForm.eventType" /></el-form-item><el-form-item label="渠道"><el-select v-model="templateForm.channel"><el-option v-for="item in channels" :key="item" :label="channelLabel(item)" :value="item" /></el-select></el-form-item><el-form-item label="标题模板"><el-input v-model="templateForm.titleTemplate" /></el-form-item><el-form-item label="正文模板"><el-input v-model="templateForm.bodyTemplate" type="textarea" :rows="4" placeholder="支持 {{field}} 占位符" /></el-form-item><el-form-item label="外部模板 ID"><el-input v-model="templateForm.externalTemplateId" /></el-form-item></el-form><template #footer><el-button @click="templateDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveTemplate">保存</el-button></template></el-dialog>
|
|
|
|
|
<el-dialog v-model="routeDialog" title="新增通知路由" width="min(620px, 94vw)"><el-form label-position="top"><el-form-item label="模板"><el-select v-model="routeForm.templateId" filterable @change="syncRouteEvent"><el-option v-for="item in templates" :key="item.id" :label="`${item.templateCode} · ${item.eventType}`" :value="item.id" /></el-select></el-form-item><el-form-item label="事件类型"><el-input v-model="routeForm.eventType" disabled /></el-form-item><el-form-item label="接收人类型"><el-select v-model="routeForm.recipientType"><el-option label="事件顾客" value="CUSTOMER" /><el-option label="指定用户" value="USER" /><el-option label="角色" value="ROLE" /><el-option label="门店 Webhook" value="STORE_WEBHOOK" /></el-select></el-form-item><el-form-item label="接收人值"><el-input v-model="routeForm.recipientValue" :disabled="['CUSTOMER', 'STORE_WEBHOOK'].includes(routeForm.recipientType)" placeholder="角色编码或用户 ID" /></el-form-item><el-form-item label="静默开始/结束"><div class="quiet-row"><el-time-picker v-model="routeForm.quietStart" value-format="HH:mm:ss" placeholder="开始" /><el-time-picker v-model="routeForm.quietEnd" value-format="HH:mm:ss" placeholder="结束" /></div></el-form-item></el-form><template #footer><el-button @click="routeDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveRoute">保存</el-button></template></el-dialog>
|
|
|
|
|
</section>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
import { computed, reactive, ref, watch } from 'vue';
|
|
|
|
|
import { ElMessage } from 'element-plus';
|
|
|
|
|
import { ApiError, createNotificationRoute, listManagedStores, listNotificationDeliveries, listNotificationRoutes, listNotificationTemplates, retryNotificationDelivery, saveNotificationTemplate, type ApiSession } from '../api';
|
|
|
|
|
import { adminSessionState } from '../session';
|
|
|
|
|
import type { ManagedStore, NotificationChannel, NotificationDelivery, NotificationRoute, NotificationTemplate, PageResult } from '../types';
|
|
|
|
|
const props = defineProps<{ session: ApiSession }>();
|
|
|
|
|
const stores = ref<ManagedStore[]>([]); const storeId = ref(''); const activeTab = ref('deliveries');
|
|
|
|
|
const templates = ref<NotificationTemplate[]>([]); const routes = ref<NotificationRoute[]>([]);
|
|
|
|
|
const deliveries = reactive<PageResult<NotificationDelivery>>({ items: [], total: 0, page: 1, pageSize: 50 });
|
|
|
|
|
const channels: NotificationChannel[] = ['IN_APP', 'WECHAT_SUBSCRIBE', 'WE_COM', 'WEBHOOK', 'CLOUD_SPEAKER'];
|
|
|
|
|
const statuses = ['PENDING', 'PROCESSING', 'SENT', 'RETRY', 'FAILED', 'SUPPRESSED'];
|
|
|
|
|
const deliveryStatus = ref(''); const deliveryChannel = ref<NotificationChannel | ''>('');
|
|
|
|
|
const loading = ref(false); const saving = ref(false); const error = ref('');
|
|
|
|
|
const templateDialog = ref(false); const routeDialog = ref(false);
|
|
|
|
|
const templateForm = reactive({ templateCode: '', eventType: '', channel: 'IN_APP' as NotificationChannel, titleTemplate: '', bodyTemplate: '', externalTemplateId: '' });
|
|
|
|
|
const routeForm = reactive({ templateId: '', eventType: '', recipientType: 'ROLE' as 'CUSTOMER' | 'USER' | 'ROLE' | 'STORE_WEBHOOK', recipientValue: 'STORE_ADMIN', quietStart: null as string | null, quietEnd: null as string | null });
|
|
|
|
|
const canManage = computed(() => adminSessionState.access.roles.includes('PLATFORM_ADMIN') || adminSessionState.access.capabilities.some((item) => ['notification.manage', 'tenant.manage', 'platform.manage'].includes(item)));
|
|
|
|
|
const retryCount = computed(() => deliveries.items.filter((item) => item.status === 'RETRY').length);
|
|
|
|
|
const failedCount = computed(() => deliveries.items.filter((item) => item.status === 'FAILED').length);
|
|
|
|
|
async function capture<T>(work: () => Promise<T>) { error.value = ''; try { return await work(); } catch (reason) { error.value = reason instanceof ApiError ? reason.code : reason instanceof Error ? reason.message : '操作失败'; throw reason; } }
|
|
|
|
|
async function loadAll() { if (!props.session.token) return; loading.value = true; try { const [nextStores, nextTemplates, nextRoutes, nextDeliveries] = await Promise.all([stores.value.length ? Promise.resolve(stores.value) : capture(() => listManagedStores(props.session)), capture(() => listNotificationTemplates(props.session, { storeId: storeId.value || undefined })), capture(() => listNotificationRoutes(props.session, { storeId: storeId.value || undefined })), capture(() => listNotificationDeliveries(props.session, { storeId: storeId.value || undefined, status: deliveryStatus.value || undefined, channel: deliveryChannel.value || undefined, pageSize: 50 }))]); stores.value = nextStores; templates.value = nextTemplates; routes.value = nextRoutes; Object.assign(deliveries, nextDeliveries); } catch { /* displayed */ } finally { loading.value = false; } }
|
|
|
|
|
async function loadDeliveries() { loading.value = true; try { Object.assign(deliveries, await capture(() => listNotificationDeliveries(props.session, { storeId: storeId.value || undefined, status: deliveryStatus.value || undefined, channel: deliveryChannel.value || undefined, pageSize: 50 }))); } catch { /* displayed */ } finally { loading.value = false; } }
|
|
|
|
|
function openTemplate() { Object.assign(templateForm, { templateCode: '', eventType: '', channel: 'IN_APP', titleTemplate: '', bodyTemplate: '', externalTemplateId: '' }); templateDialog.value = true; }
|
|
|
|
|
async function saveTemplate() { saving.value = true; try { await capture(() => saveNotificationTemplate(props.session, { ...templateForm, storeId: storeId.value || null })); templateDialog.value = false; ElMessage.success('通知模板已保存'); await loadAll(); } catch { /* displayed */ } finally { saving.value = false; } }
|
|
|
|
|
function openRoute() { const first = templates.value[0]; Object.assign(routeForm, { templateId: first?.id || '', eventType: first?.eventType || '', recipientType: 'ROLE', recipientValue: 'STORE_ADMIN', quietStart: null, quietEnd: null }); routeDialog.value = true; }
|
|
|
|
|
function syncRouteEvent() { routeForm.eventType = templates.value.find((item) => item.id === routeForm.templateId)?.eventType || ''; }
|
|
|
|
|
async function saveRoute() { saving.value = true; try { await capture(() => createNotificationRoute(props.session, { ...routeForm, storeId: storeId.value || null, recipientValue: ['CUSTOMER', 'STORE_WEBHOOK'].includes(routeForm.recipientType) ? undefined : routeForm.recipientValue })); routeDialog.value = false; ElMessage.success('通知路由已保存'); await loadAll(); } catch { /* displayed */ } finally { saving.value = false; } }
|
|
|
|
|
async function retry(row: NotificationDelivery) { try { await capture(() => retryNotificationDelivery(props.session, row.id)); ElMessage.success('补发任务已入队'); await loadDeliveries(); } catch { /* displayed */ } }
|
|
|
|
|
function channelLabel(value: string) { return ({ IN_APP: '后台站内', WECHAT_SUBSCRIBE: '微信订阅', WE_COM: '企业微信', WEBHOOK: 'Webhook', CLOUD_SPEAKER: '云喇叭' } as Record<string, string>)[value] || value; }
|
|
|
|
|
function statusType(value: string) { return value === 'SENT' ? 'success' : value === 'FAILED' ? 'danger' : ['RETRY', 'SUPPRESSED'].includes(value) ? 'warning' : 'info'; }
|
|
|
|
|
function dateText(value: string | null) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '-'; }
|
|
|
|
|
watch(() => props.session.token, (token) => { if (token) void loadAll(); }, { immediate: true });
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
.notification-page { display: grid; gap: 18px; }
|
|
|
|
|
.notification-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; }
|
|
|
|
|
.notification-metrics span { display: grid; gap: 5px; padding: 18px; border: 1px solid var(--line); border-radius: 16px; background: #fff; }
|
|
|
|
|
.notification-metrics small, .stack span { color: var(--muted); }
|
|
|
|
|
.notification-metrics strong { font-size: 28px; }
|
|
|
|
|
.panel { padding: 20px; border: 1px solid var(--line); border-radius: 18px; background: #fff; }
|
|
|
|
|
.notification-toolbar, .toolbar-actions, .tab-tools, .quiet-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
|
|
|
|
.notification-toolbar { justify-content: space-between; margin-bottom: 12px; }
|
|
|
|
|
.notification-toolbar h3 { margin: 2px 0 0; }.section-kicker { margin: 0; color: #2563eb; font-size: 12px; font-weight: 700; }
|
|
|
|
|
.tab-tools { justify-content: flex-end; margin-bottom: 14px; }.stack { display: grid; gap: 4px; }.stack span { font-size: 12px; }
|
|
|
|
|
@media (max-width: 760px) { .notification-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }.panel { padding: 12px; }.toolbar-actions, .toolbar-actions .el-select { width: 100%; } }
|
|
|
|
|
</style>
|