feat: 订阅消息通知体系——登录后请求订阅授权(额度记账)+服务端subscribe/send(access_token缓存/原子扣减/失败回补/发送记录)+管理后台通知推送页;版本v1.4.4

This commit is contained in:
Hermes Agent
2026-09-19 19:28:40 +08:00
parent 9ea09c95f2
commit 6d463b9296
11 changed files with 297 additions and 4 deletions
+103
View File
@@ -0,0 +1,103 @@
<template>
<div class="page">
<div class="cards">
<div class="card"><b>{{ stats.subscribed ?? '-' }}</b><span>可通知用户</span><i>已订阅授权 {{ stats.totalUsers ?? 0 }} 用户中</i></div>
<div class="card"><b>{{ stats.sentToday ?? '-' }}</b><span>今日已发送</span><i>累计 {{ stats.totalSent ?? 0 }} 条</i></div>
<div class="card"><b>{{ stats.templateConfigured ? '已配置' : '未配置' }}</b><span>消息模板</span><i>{{ stats.templateId ? stats.templateId.slice(0, 14) + '…' : '设置 WX_NOTIFY_TEMPLATE_ID' }}</i></div>
</div>
<div class="panel">
<h3>发送通知</h3>
<el-form label-width="90px" style="max-width: 560px">
<el-form-item label="场景">
<el-radio-group v-model="scene">
<el-radio value="release">版本发布</el-radio>
<el-radio value="manual">运营通知</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="标题"><el-input v-model="title" placeholder="通知标题(20字内)" maxlength="20" show-word-limit /></el-form-item>
<el-form-item label="内容"><el-input v-model="thing1" type="textarea" :rows="2" placeholder="主要内容(20字内,模板字段限制)" maxlength="20" show-word-limit /></el-form-item>
<el-form-item label="补充"><el-input v-model="thing2" placeholder="补充说明(20字内,选填)" maxlength="20" show-word-limit /></el-form-item>
<el-form-item>
<el-button type="primary" :loading="sending" :disabled="!stats.subscribed" @click="send">发送给 {{ stats.subscribed || 0 }} 位订阅用户</el-button>
</el-form-item>
</el-form>
<el-alert v-if="!stats.templateConfigured" type="warning" :closable="false"
title="未配置模板:微信公众平台→功能→订阅消息→选「新版上线提醒」类模板→复制模板ID→服务器 compose 加 WX_NOTIFY_TEMPLATE_ID 环境变量" />
<el-alert v-else type="info" :closable="false"
title="订阅消息为一次性授权:每次用户登录小程序会弹一次授权,授权一次可发一条。发完需等用户下次登录再授权。" />
</div>
<div class="panel">
<h3>发送记录</h3>
<el-table :data="logs" size="small" stripe v-loading="loadingLogs">
<el-table-column label="时间" width="160"><template #default="{ row }">{{ fmt(row.createdAt) }}</template></el-table-column>
<el-table-column label="用户" width="120"><template #default="{ row }">{{ row.userId?.nickName || row.openid?.slice(0, 8) || '—' }}</template></el-table-column>
<el-table-column prop="title" label="标题" min-width="140" show-overflow-tooltip />
<el-table-column label="场景" width="90">
<template #default="{ row }">{{ row.scene === 'release' ? '版本发布' : '运营通知' }}</template>
</el-table-column>
<el-table-column label="结果" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 'sent' ? 'success' : 'danger'" size="small">{{ row.status === 'sent' ? '成功' : '失败' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="errMsg" label="失败原因" min-width="140" show-overflow-tooltip />
</el-table>
<el-pagination background layout="total, prev, pager, next" :total="logsTotal" :page-size="20" v-model:current-page="logsPage" @current-change="loadLogs" style="margin-top: 12px" />
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { apiGet, apiPost } from '../api'
const stats = ref({})
const scene = ref('release')
const title = ref('')
const thing1 = ref('')
const thing2 = ref('')
const sending = ref(false)
const logs = ref([])
const logsTotal = ref(0)
const logsPage = ref(1)
const loadingLogs = ref(false)
function fmt(t) { return t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '—' }
async function loadStats() {
try { stats.value = await apiGet('/api/admin/notify/stats') || {} } catch (e) { stats.value = {} }
}
async function loadLogs() {
loadingLogs.value = true
try {
const r = await apiGet(`/api/admin/notify/logs?page=${logsPage.value}&limit=20`)
logs.value = r.items || []
logsTotal.value = r.total || 0
} finally { loadingLogs.value = false }
}
async function send() {
if (!title.value.trim() || !thing1.value.trim()) return ElMessage.warning('请填写标题和内容')
sending.value = true
try {
const r = await apiPost('/api/admin/notify/send', { title: title.value, thing1: thing1.value, thing2: thing2.value, scene: scene.value })
ElMessage.success(r.message || '发送完成')
loadStats(); loadLogs()
title.value = ''; thing1.value = ''; thing2.value = ''
} catch (e) { ElMessage.error(e.message || '发送失败') } finally { sending.value = false }
}
onMounted(() => { loadStats(); loadLogs() })
</script>
<style scoped>
.page { display: flex; flex-direction: column; gap: 14px; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; }
.card { background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
.card b { font-size: 22px; color: #1f2333; }
.card span { font-size: 13px; color: #6b7080; display: block; }
.card i { font-size: 11px; color: #a6abbb; font-style: normal; }
.panel { background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
.panel h3 { margin: 0 0 14px; font-size: 14px; }
</style>