feat(M10-A): 完成统一通知中心与投递闭环

This commit is contained in:
Codex
2026-08-11 09:17:18 +08:00
parent 41b9cf7349
commit 5d70114bab
46 changed files with 1724 additions and 74 deletions
+2
View File
@@ -102,6 +102,7 @@ import { computed, onBeforeUnmount, onMounted, ref, type Component } from 'vue';
import { ElMessage } from 'element-plus';
import {
ClipboardList,
BellRing,
Handshake,
Images,
LayoutDashboard,
@@ -150,6 +151,7 @@ const visibleNavigation = computed(() => ADMIN_NAVIGATION.filter(
(item) => isNavigationAllowed(adminSessionState.access, item)
));
const navigationIcons: Record<string, Component> = {
BellRing,
ClipboardList,
Handshake,
Images,
+53
View File
@@ -41,6 +41,10 @@ import type {
ProductOrderReconciliation,
ProductStorage,
InventoryStock,
NotificationChannel,
NotificationDelivery,
NotificationRoute,
NotificationTemplate,
PlatformApplication,
ProfitSharingSnapshot,
PayoutStateFilter,
@@ -349,6 +353,55 @@ export function getBusinessStatistics(
return request<BusinessStatistics>(session, `/statistics?${params}`);
}
export function listNotificationTemplates(session: ApiSession, input: { storeId?: string; eventType?: string } = {}) {
const params = new URLSearchParams();
if (input.storeId) params.set('storeId', input.storeId);
if (input.eventType) params.set('eventType', input.eventType);
return request<NotificationTemplate[]>(session, `/notifications/templates?${params}`);
}
export function saveNotificationTemplate(session: ApiSession, input: {
id?: string; storeId?: string | null; templateCode: string; eventType: string;
channel: NotificationChannel; titleTemplate: string; bodyTemplate: string;
externalTemplateId?: string; status?: 'ACTIVE' | 'DISABLED'; expectedVersion?: number;
}) {
return request<{ id: string; version: number }>(session, '/notifications/templates', {
method: 'PUT', body: JSON.stringify(input)
});
}
export function listNotificationRoutes(session: ApiSession, input: { storeId?: string; eventType?: string } = {}) {
const params = new URLSearchParams();
if (input.storeId) params.set('storeId', input.storeId);
if (input.eventType) params.set('eventType', input.eventType);
return request<NotificationRoute[]>(session, `/notifications/routes?${params}`);
}
export function createNotificationRoute(session: ApiSession, input: {
storeId?: string | null; eventType: string; templateId: string;
recipientType: 'CUSTOMER' | 'USER' | 'ROLE' | 'STORE_WEBHOOK'; recipientValue?: string;
quietStart?: string | null; quietEnd?: string | null;
}) {
return request<{ id: string; version: number }>(session, '/notifications/routes', {
method: 'POST', body: JSON.stringify(input)
});
}
export function listNotificationDeliveries(session: ApiSession, input: {
storeId?: string; status?: string; channel?: NotificationChannel; page?: number; pageSize?: number;
} = {}) {
const params = new URLSearchParams({ page: String(input.page || 1), pageSize: String(input.pageSize || 50) });
if (input.storeId) params.set('storeId', input.storeId);
if (input.status) params.set('status', input.status);
if (input.channel) params.set('channel', input.channel);
return request<PageResult<NotificationDelivery>>(session, `/notifications/deliveries?${params}`);
}
export function retryNotificationDelivery(session: ApiSession, deliveryId: string) {
return request<{ id: string; queued: boolean }>(session,
`/notifications/deliveries/${encodeURIComponent(deliveryId)}/retry`, { method: 'POST' });
}
export function listProductListings(session: ApiSession, storeId: string) {
return request<ProductListing[]>(session,
`/stores/${encodeURIComponent(storeId)}/product-listings?includeInactive=true`);
@@ -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>
+1
View File
@@ -3,6 +3,7 @@ export type AdminMenuKey =
| 'stores'
| 'orders'
| 'products'
| 'notifications'
| 'payments'
| 'thirdParty'
| 'people'
+1
View File
@@ -3,6 +3,7 @@ export const ADMIN_NAVIGATION = Object.freeze([
{ menuKey: 'stores', path: '/stores', stage: 'M08-D', title: '门店与房间管理', label: '门店房间', icon: 'Store', capabilities: ['store.operation.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'orders', path: '/orders', stage: 'M08-D', title: '订单筛选与运营处置', label: '订单运营', icon: 'ClipboardList', capabilities: ['store.operation.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'products', path: '/products', stage: 'M09-D4', title: '商品、库存、订单与寄存', label: '商品运营', icon: 'ShoppingBasket', capabilities: ['product.catalog.read', 'product.catalog.write', 'inventory.read', 'inventory.adjust', 'goods.order.read', 'goods.order.manage', 'goods.storage.read', 'goods.storage.manage', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'notifications', path: '/notifications', stage: 'M10-A', title: '通知模板、路由与投递追踪', label: '通知中心', icon: 'BellRing', capabilities: ['notification.read', 'notification.manage', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'payments', path: '/payments', stage: 'M08-D', title: '支付、退款与分账', label: '支付分账', icon: 'WalletCards', capabilities: ['tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'thirdParty', path: '/third-party', stage: 'M08-D', title: '团购与第三方平台运营', label: '团购平台', icon: 'TicketCheck', capabilities: ['store.operation.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'people', path: '/people', stage: 'M08-D', title: '会员与员工用户管理', label: '会员员工', icon: 'UsersRound', capabilities: ['tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
+1
View File
@@ -12,6 +12,7 @@ const routeComponents = {
stores: () => import('./components/StoresRoomsPanel.vue'),
orders: () => import('./components/OrdersPanel.vue'),
products: () => import('./components/ProductOperationsPanel.vue'),
notifications: () => import('./components/NotificationCenterPanel.vue'),
payments: () => import('./components/PaymentsPanel.vue'),
thirdParty: () => import('./components/ThirdPartyPanel.vue'),
people: () => import('./components/MembersStaffPanel.vue'),
+22
View File
@@ -113,6 +113,28 @@ export interface ProductStorageItem {
totalQuantity: number; remainingQuantity: number;
}
export type NotificationChannel = 'IN_APP' | 'WECHAT_SUBSCRIBE' | 'WE_COM' | 'WEBHOOK' | 'CLOUD_SPEAKER';
export interface NotificationTemplate {
id: string; storeId: string | null; templateCode: string; eventType: string;
channel: NotificationChannel; titleTemplate: string; bodyTemplate: string;
externalTemplateId: string; status: 'ACTIVE' | 'DISABLED'; version: number;
createdAt: string; updatedAt: string;
}
export interface NotificationRoute {
id: string; storeId: string | null; eventType: string; templateId: string;
templateCode: string; channel: NotificationChannel;
recipientType: 'CUSTOMER' | 'USER' | 'ROLE' | 'STORE_WEBHOOK';
recipientValue: string; quietStart: string | null; quietEnd: string | null;
status: 'ACTIVE' | 'DISABLED'; version: number;
}
export interface NotificationDelivery {
id: string; storeId: string | null; outboxEventId: string; eventType: string;
channel: NotificationChannel; recipientType: string; recipientMasked: string;
title: string; body: string; status: string; attempts: number; maxAttempts: number;
lastErrorCode: string; lastErrorMessage: string; providerMessageId: string;
manualRetryCount: number; sentAt: string | null; readAt: string | null; createdAt: string;
}
export interface ManagedStore {
id: string;
name: string;
+7 -1
View File
@@ -9,12 +9,13 @@ import {
assert.deepEqual(
ADMIN_NAVIGATION.map(({ path }) => path),
['/overview', '/stores', '/orders', '/products', '/payments', '/third-party', '/people',
['/overview', '/stores', '/orders', '/products', '/notifications', '/payments', '/third-party', '/people',
'/cleaning', '/devices', '/apps', '/content', '/franchise', '/system']
);
assert.equal(pathForMenu('platformApps'), '/apps');
assert.equal(pathForMenu('thirdParty'), '/third-party');
assert.equal(pathForMenu('products'), '/products');
assert.equal(pathForMenu('notifications'), '/notifications');
const storeOperator = {
roles: ['STORE_ADMIN'],
@@ -33,6 +34,11 @@ const goodsOperator = {
assert.equal(resolveAuthorizedPath(goodsOperator, 'products'), '/products');
assert.equal(firstAuthorizedPath(goodsOperator), '/products');
const notificationOperator = {
roles: ['STORE_ADMIN'], capabilities: ['notification.read'], menus: ['notifications']
};
assert.equal(resolveAuthorizedPath(notificationOperator, 'notifications'), '/notifications');
const forgedSystemMenu = {
roles: ['STORE_ADMIN'],
capabilities: ['store.operation.read'],