feat(notify): 管理端微信消息通知配置与发送记录页

单菜单 Tabs 管理模板配置与发送记录,权限合并为 wechat-config;
三方平台微信公众号文案同步支持模板通知。
This commit is contained in:
DaxPay Dev
2026-07-09 17:31:48 +08:00
parent 5afaf79cbe
commit 3c545f18ff
9 changed files with 545 additions and 16 deletions

View File

@@ -0,0 +1,101 @@
import type { BaseEntity, PageResult, Result } from '#/types/web';
import { defHttp } from '#/api/request';
/**
* 微信消息通知配置 API
* 仅管理场景模板 Id(存 system_platform_config / wechat_notify);
* 公众号 AppId/AppSecret 在三方平台管理(加密配置 wechat_mp_auth)
*/
export const WechatConfigApi = {
/** 查询配置 */
find(): Promise<Result<WechatConfigResult>> {
return defHttp.get({ url: '/notify/wechat/config/find' });
},
/** 更新配置 */
update(data: WechatConfigParam): Promise<Result<void>> {
return defHttp.post({ url: '/notify/wechat/config/update', data });
},
};
/**
* 微信通知记录 API
*/
export const WechatMessageApi = {
/** 分页查询 */
page(params: any): Promise<Result<PageResult<WechatMessageRecord>>> {
return defHttp.get({ url: '/notify/wechat/message/page', params });
},
/** 记录详情 */
findById(id: string): Promise<Result<WechatMessageRecord>> {
return defHttp.get({ url: '/notify/wechat/message/get', params: { id } });
},
/** 重发失败消息 */
resend(id: string): Promise<Result<void>> {
return defHttp.post({ url: '/notify/wechat/message/resend', params: { id } });
},
/** 测试发送(发给当前登录用户, 验证三方平台凭据+绑定+模板链路) */
testSend(): Promise<Result<MessageSendResult>> {
return defHttp.post({ url: '/notify/wechat/message/test-send' });
},
};
/**
* 微信消息通知配置参数(编辑)
*/
export interface WechatConfigParam {
/** 交易通知模板Id */
tradeTemplateId?: string;
/** 操作通知模板Id */
operateTemplateId?: string;
}
/**
* 微信消息通知配置结果(平台非加密配置, 无 BaseEntity 字段)
*/
export interface WechatConfigResult {
tradeTemplateId?: string;
operateTemplateId?: string;
}
/**
* 微信消息记录
*/
export interface WechatMessageRecord extends BaseEntity {
/** 接收平台用户ID */
userId?: string;
/** 消息类型(template/uniform) */
messageType?: string;
/** 接收者 OpenId */
openId?: string;
/** 模板ID */
templateId?: string;
/** 模板数据(JSON) */
templateData?: string;
/** 跳转链接 */
url?: string;
/** 发送状态(success/failed/sending) */
status?: string;
/** 微信消息ID */
msgId?: string;
/** 错误码 */
errorCode?: string;
/** 错误信息 */
errorMsg?: string;
/** 发送时间 */
sendTime?: string;
/** 业务场景(trade/operate) */
scene?: string;
/** 使用的 AppId */
wxAppId?: string;
}
/**
* 消息发送结果
*/
export interface MessageSendResult {
success?: boolean;
msgId?: string;
errorCode?: string;
errorMsg?: string;
}

View File

@@ -254,15 +254,12 @@ export const PermCodes = {
MANAGE: 'system:notify:notice:manage',
PUBLISH: 'system:notify:notice:publish',
},
/** 公众号配置 menuCode=system:notify:wechat-config */
WechatConfig: {
/** 微信消息通知 menuCode=system:notify:wechat-config(配置 + 发送记录 Tabs */
WechatNotify: {
MANAGE: 'system:notify:wechat-config:manage',
},
/** 微信通知记录 menuCode=system:notify:wechat-message */
WechatMessage: {
VIEW: 'system:notify:wechat-message:view',
RESEND: 'system:notify:wechat-message:resend',
TEST: 'system:notify:wechat-message:test',
VIEW: 'system:notify:wechat-config:view',
RESEND: 'system:notify:wechat-config:resend',
TEST: 'system:notify:wechat-config:test',
},
/** 存储文件 menuCode=system:file:platform */
FilePlatform: {

View File

@@ -43,5 +43,34 @@
"isTopRequired": "Please confirm whether to pin",
"effectiveTimeHelp": "If set, the notice becomes visible after this time. Leave empty to show it immediately after publishing.",
"expireTimeHelp": "If set, the notice is hidden after this time. Leave empty to keep it permanently.",
"confirmCloseContent": "Content has been modified. Discard changes and close?"
"confirmCloseContent": "Content has been modified. Discard changes and close?",
"wechatConfigTitle": "WeChat Message Notification",
"wechatConfigDesc": "Configure WeChat official account template data for each business scene. AppId/AppSecret are managed under Third-party Platform → WeChat Official Account.",
"wechatTabConfig": "Notification Config",
"wechatTabRecord": "Send Records",
"tradeTemplateId": "Trade Notice Template ID",
"operateTemplateId": "Operation Notice Template ID",
"testSend": "Test Send",
"confirmTestSend": "A test notification will be sent to the current user (requires MP credentials in Third-party Platform and the user bound to this official account). Continue?",
"testSendSuccess": "Test message sent. Please check it in WeChat.",
"testSendFail": "Send failed: {msg}",
"wechatMessageType": "Message Type",
"userId": "User ID",
"sendStatus": "Send Status",
"openId": "OpenId",
"templateIdField": "Template ID",
"sendTime": "Send Time",
"scene": "Scene",
"errorMsgField": "Error Message",
"msgIdField": "WeChat Msg ID",
"resend": "Resend",
"confirmResend": "Resend this message?",
"statusFilterPlaceholder": "Select send status",
"statusSending": "Sending",
"statusSuccess": "Success",
"statusFailed": "Failed",
"typeTemplate": "OA Template",
"typeUniform": "Mini Program Uniform",
"sceneTrade": "Trade Notice",
"sceneOperate": "Operation Notice"
}

View File

@@ -52,10 +52,10 @@
},
"wechatMp": {
"tabTitle": "WeChat Official Account Config",
"tabDescription": "WeChat MP H5 OAuth credentials for WeChat login",
"tabDescription": "WeChat MP credentials for login and template notifications",
"title": "WeChat Official Account Auth Configuration",
"description": "Manage WeChat MP H5 web authorization credentials (appId/appSecret) to get user openId.",
"usageTip": "This configuration serves WeChat MP H5 web authorization to get user openId.",
"description": "Manage WeChat MP credentials (appId/appSecret) for H5 OAuth openId and notification-center template messages.",
"usageTip": "This configuration serves: 1) WeChat MP H5 OAuth login; 2) Notification-center template message sending.",
"confirmSaveContent": "Are you sure to save the WeChat Official Account configuration?",
"section": {
"credential": "Credential Configuration"

View File

@@ -43,5 +43,34 @@
"isTopRequired": "请确认是否置顶",
"effectiveTimeHelp": "设置后公告在该时间后才对用户可见,不填则发布后立即可见",
"expireTimeHelp": "设置后公告在该时间后不再可见,不填则永久有效",
"confirmCloseContent": "正文内容已修改,确认放弃修改并关闭吗?"
"confirmCloseContent": "正文内容已修改,确认放弃修改并关闭吗?",
"wechatConfigTitle": "微信消息通知",
"wechatConfigDesc": "配置各业务场景公众号模板数据。AppId/AppSecret 请在「三方平台管理 → 微信公众号」配置。",
"wechatTabConfig": "通知配置",
"wechatTabRecord": "发送记录",
"tradeTemplateId": "交易通知模板Id",
"operateTemplateId": "操作通知模板Id",
"testSend": "测试发送",
"confirmTestSend": "将向当前登录用户发送一条测试通知(需已在三方平台配置公众号凭据, 且当前用户已绑定该公众号), 确认发送吗?",
"testSendSuccess": "测试消息已发送, 请在微信公众号查看",
"testSendFail": "发送失败: {msg}",
"wechatMessageType": "消息类型",
"userId": "用户ID",
"sendStatus": "发送状态",
"openId": "OpenId",
"templateIdField": "模板ID",
"sendTime": "发送时间",
"scene": "业务场景",
"errorMsgField": "错误信息",
"msgIdField": "微信消息ID",
"resend": "重发",
"confirmResend": "确认重发该消息吗?",
"statusFilterPlaceholder": "请选择发送状态",
"statusSending": "发送中",
"statusSuccess": "成功",
"statusFailed": "失败",
"typeTemplate": "公众号模板",
"typeUniform": "小程序统一消息",
"sceneTrade": "交易通知",
"sceneOperate": "操作通知"
}

View File

@@ -52,10 +52,10 @@
},
"wechatMp": {
"tabTitle": "微信公众号配置",
"tabDescription": "公众号 H5 网页授权凭据, 服务于微信登录",
"tabDescription": "公众号凭据, 服务于微信登录与模版通知",
"title": "微信公众号认证配置",
"description": "管理微信公众号网页授权凭据(appId/appSecret), 用于公众号 H5 授权获取用户 openId。",
"usageTip": "本配置服务于公众号 H5 网页授权, 用于获取用户 openId。",
"description": "管理微信公众号凭据(appId/appSecret), 用于公众号 H5 授权获取用户 openId, 以及通知中心模版消息发送。",
"usageTip": "本配置同时服务于: 1) 公众号 H5 网页授权登录; 2) 通知中心模版消息发送。",
"confirmSaveContent": "确定要保存微信公众号配置吗?",
"section": {
"credential": "凭据配置"

View File

@@ -0,0 +1,129 @@
<script lang="ts" setup>
import type { WechatConfigParam, WechatConfigResult } from '#/api/system/notify/wechat.api';
import { onMounted, reactive, ref } from 'vue';
import { $t } from '@vben/locales';
import { WechatConfigApi, WechatMessageApi } from '#/api/system/notify/wechat.api';
import { PermCodes } from '#/constants/perm-codes';
import { useMessage } from '#/hooks/useMessage';
import { usePermission } from '#/hooks/usePermission';
defineOptions({ name: 'WechatNotifyConfig' });
const { confirm, message } = useMessage();
const { hasPermission } = usePermission();
const loading = ref(false);
const submitLoading = ref(false);
const formData = reactive<WechatConfigParam>({
tradeTemplateId: '',
operateTemplateId: '',
});
/**
* 加载配置
*/
function loadConfig() {
loading.value = true;
WechatConfigApi.find()
.then((res) => {
const data: WechatConfigResult = res.data || {};
Object.assign(formData, {
tradeTemplateId: data.tradeTemplateId ?? '',
operateTemplateId: data.operateTemplateId ?? '',
});
})
.finally(() => {
loading.value = false;
});
}
/**
* 提交保存
*/
async function handleSubmit() {
submitLoading.value = true;
try {
await WechatConfigApi.update({ ...formData });
message.success($t('common.saveSuccess'));
loadConfig();
} finally {
submitLoading.value = false;
}
}
/**
* 测试发送(给当前登录用户发一条, 验证三方平台凭据+绑定+模板链路)
*/
function handleTestSend() {
confirm({
title: $t('system.notify.testSend'),
content: $t('system.notify.confirmTestSend'),
onOk: () => {
WechatMessageApi.testSend().then((res) => {
const result = res.data;
if (result?.success) {
message.success($t('system.notify.testSendSuccess'));
} else {
message.error($t('system.notify.testSendFail', { msg: result?.errorMsg || '' }));
}
});
},
});
}
onMounted(() => {
loadConfig();
});
</script>
<template>
<div>
<div class="mb-4 flex items-start justify-between gap-3">
<a-alert class="flex-1" :message="$t('system.notify.wechatConfigDesc')" type="info" banner />
<a-button
v-if="hasPermission(PermCodes.System.WechatNotify.TEST)"
:loading="submitLoading"
@click="handleTestSend"
>
{{ $t('system.notify.testSend') }}
</a-button>
</div>
<a-spin :spinning="loading">
<a-form :model="formData" layout="vertical" class="max-w-2xl">
<a-form-item :label="$t('system.notify.tradeTemplateId')" name="tradeTemplateId">
<a-input
v-model:value="formData.tradeTemplateId"
:placeholder="$t('common.pleaseInput')"
:disabled="submitLoading"
/>
</a-form-item>
<a-form-item :label="$t('system.notify.operateTemplateId')" name="operateTemplateId">
<a-input
v-model:value="formData.operateTemplateId"
:placeholder="$t('common.pleaseInput')"
:disabled="submitLoading"
/>
</a-form-item>
</a-form>
</a-spin>
<div class="mt-4">
<a-space>
<a-button :disabled="submitLoading" @click="loadConfig">{{ $t('common.reset') }}</a-button>
<a-button
v-if="hasPermission(PermCodes.System.WechatNotify.MANAGE)"
type="primary"
:loading="submitLoading"
@click="handleSubmit"
>
{{ $t('common.save') }}
</a-button>
</a-space>
</div>
</div>
</template>

View File

@@ -0,0 +1,211 @@
<script lang="ts" setup>
import type { VxeTableInstance, VxeToolbarInstance } from 'vxe-table';
import { computed, onMounted, ref } from 'vue';
import { $t } from '@vben/locales';
import { WechatMessageApi } from '#/api/system/notify/wechat.api';
import { BQuery, type QueryField } from '#/components/query';
import { PermCodes } from '#/constants/perm-codes';
import { useMessage } from '#/hooks/useMessage';
import { usePermission } from '#/hooks/usePermission';
defineOptions({ name: 'WechatNotifyRecord' });
const { confirm, message } = useMessage();
const { hasPermission } = usePermission();
const loading = ref(false);
const xTable = ref<VxeTableInstance>();
const xToolbar = ref<VxeToolbarInstance>();
// 查询条件
const queryForm = ref<Record<string, any>>({});
// 查询字段配置
const queryFields = computed<QueryField[]>(() => [
{
type: 'string',
field: 'openId',
name: $t('system.notify.openId'),
placeholder: $t('system.notify.openId'),
},
{
type: 'list',
field: 'status',
name: $t('system.notify.sendStatus'),
placeholder: $t('system.notify.statusFilterPlaceholder'),
selectList: [
{ label: $t('system.notify.statusSuccess'), value: 'success' },
{ label: $t('system.notify.statusFailed'), value: 'failed' },
{ label: $t('system.notify.statusSending'), value: 'sending' },
],
},
]);
// 分页配置
const pageConfig = ref({
currentPage: 1,
pageSize: 10,
total: 0,
});
// 表格数据
const tableData = ref<any[]>([]);
onMounted(() => {
xTable.value?.connectToolbar(xToolbar.value as VxeToolbarInstance);
queryPage();
});
/**
* 查询分页数据
*/
function queryPage() {
loading.value = true;
WechatMessageApi.page({
current: pageConfig.value.currentPage,
size: pageConfig.value.pageSize,
...queryForm.value,
})
.then((res: any) => {
tableData.value = res.data.records || [];
pageConfig.value.total = Number(res.data.total) || 0;
loading.value = false;
})
.catch(() => {
loading.value = false;
});
return Promise.resolve();
}
/**
* 重置查询
*/
function resetQuery() {
queryForm.value = {};
pageConfig.value.currentPage = 1;
queryPage();
}
/**
* 重发失败消息
*/
function handleResend(row: any) {
confirm({
title: $t('system.notify.resend'),
content: $t('system.notify.confirmResend'),
onOk: () => {
WechatMessageApi.resend(row.id).then(() => {
message.success($t('common.operateSuccess'));
queryPage();
});
},
});
}
/**
* 消息类型展示
*/
function formatMessageType(type?: string) {
if (type === 'template') {
return $t('system.notify.typeTemplate');
}
if (type === 'uniform') {
return $t('system.notify.typeUniform');
}
return type || '-';
}
/**
* 业务场景展示
*/
function formatScene(scene?: string) {
if (scene === 'trade') {
return $t('system.notify.sceneTrade');
}
if (scene === 'operate') {
return $t('system.notify.sceneOperate');
}
return scene || '-';
}
/**
* 分页变化
*/
function handlePageChange({ currentPage, pageSize }: any) {
pageConfig.value.currentPage = currentPage;
pageConfig.value.pageSize = pageSize;
queryPage();
}
</script>
<template>
<div class="list-page-compact">
<div class="mb-3">
<BQuery :fields="queryFields" :query-params="queryForm" @query="queryPage" @reset="resetQuery" />
</div>
<vxe-toolbar ref="xToolbar" custom refresh :refresh-options="{ queryMethod: queryPage }" />
<vxe-table ref="xTable" :row-config="{ keyField: 'id' }" :data="tableData" :loading="loading">
<vxe-column type="seq" :title="$t('common.seq')" width="60" align="center" />
<vxe-column field="messageType" :title="$t('system.notify.wechatMessageType')" :min-width="120" align="center">
<template #default="{ row }">
<a-tag>{{ formatMessageType(row.messageType) }}</a-tag>
</template>
</vxe-column>
<vxe-column field="scene" :title="$t('system.notify.scene')" :min-width="110" align="center">
<template #default="{ row }">
{{ formatScene(row.scene) }}
</template>
</vxe-column>
<vxe-column field="userId" :title="$t('system.notify.userId')" :min-width="100" align="center" />
<vxe-column field="openId" :title="$t('system.notify.openId')" :min-width="200" />
<vxe-column field="status" :title="$t('system.notify.sendStatus')" :min-width="100" align="center">
<template #default="{ row }">
<a-tag v-if="row.status === 'success'" color="green">{{ $t('system.notify.statusSuccess') }}</a-tag>
<a-tag v-else-if="row.status === 'failed'" color="red">{{ $t('system.notify.statusFailed') }}</a-tag>
<a-tag v-else>{{ $t('system.notify.statusSending') }}</a-tag>
</template>
</vxe-column>
<vxe-column
field="sendTime"
:title="$t('system.notify.sendTime')"
:min-width="160"
formatter="formatDateTime"
/>
<vxe-column field="errorMsg" :title="$t('system.notify.errorMsgField')" :min-width="220">
<template #default="{ row }">
<span v-if="row.errorMsg" class="text-red-500">{{ row.errorMsg }}</span>
<span v-else>-</span>
</template>
</vxe-column>
<vxe-column fixed="right" width="100" :show-overflow="false" :title="$t('common.operation')">
<template #default="{ row }">
<a-space :size="2">
<template #separator>
<a-divider type="vertical" />
</template>
<a-button
v-if="row.status === 'failed' && hasPermission(PermCodes.System.WechatNotify.RESEND)"
type="link"
size="small"
@click="handleResend(row)"
>
{{ $t('system.notify.resend') }}
</a-button>
</a-space>
</template>
</vxe-column>
</vxe-table>
<vxe-pager
size="medium"
:loading="loading"
:current-page="pageConfig.currentPage"
:page-size="pageConfig.pageSize"
:total="Number(pageConfig.total)"
@page-change="handlePageChange"
/>
</div>
</template>

View File

@@ -0,0 +1,33 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { $t } from '@vben/locales';
import WechatNotifyConfig from './WechatNotifyConfig.vue';
import WechatNotifyRecord from './WechatNotifyRecord.vue';
defineOptions({ name: 'WechatNotify' });
const activeKey = ref<string>('config');
</script>
<template>
<div class="m-3">
<a-card variant="borderless" class="rounded-xl shadow-sm">
<template #title>
<span class="text-lg font-bold text-foreground">
{{ $t('system.notify.wechatConfigTitle') }}
</span>
</template>
<a-tabs v-model:active-key="activeKey" type="card">
<a-tab-pane key="config" :tab="$t('system.notify.wechatTabConfig')">
<WechatNotifyConfig />
</a-tab-pane>
<a-tab-pane key="record" :tab="$t('system.notify.wechatTabRecord')">
<WechatNotifyRecord v-if="activeKey === 'record'" />
</a-tab-pane>
</a-tabs>
</a-card>
</div>
</template>