feat: 管理后台用户详情页增加订阅状态和测试消息发送

- 后端新增 /api/admin/users/:id 返回用户订阅列表
- 后端新增 POST /api/admin/users/:id/test-message 发送测试订阅消息
- 前端用户详情页展示订阅状态(只读,按类型分组)
- 前端增加发送版本更新测试消息功能
This commit is contained in:
Developer
2026-05-20 20:01:38 +08:00
parent 344b25a2d8
commit 1ca73922d8
2 changed files with 117 additions and 1 deletions
+78
View File
@@ -67,6 +67,9 @@ tr:hover td { background: #f8f9fa; }
.badge-vip { background: #e8f0fe; color: #1967d2; }
.btn-detail { padding: 4px 12px; background: var(--primary); color: #fff; border: none; border-radius: 4px; font-size: 12px; cursor: pointer; }
.btn-detail:hover { background: var(--primary-hover); }
.btn-send-msg { padding: 8px 16px; background: var(--primary); color: #fff; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; white-space: nowrap; }
.btn-send-msg:hover { background: var(--primary-hover); }
.btn-send-msg:disabled { opacity: .6; cursor: not-allowed; }
/* Pagination */
.pagination { padding: 16px 20px; display: flex; justify-content: center; align-items: center; gap: 12px; }
@@ -355,6 +358,7 @@ async function openModal(id) {
try {
const d = await api('/api/admin/users/' + id);
S.editingUser = d.data;
S.subscriptions = d.subscriptions || [];
renderModal(d.data);
} catch (e) {
showToast('加载用户详情失败', false);
@@ -404,7 +408,81 @@ function renderModal(u) {
<div class="field-row">
<div class="field"><label>平台限额</label><input type="number" id="edit-platformLimit" value="${u.platformLimit || 0}" min="0"></div>
<div class="field"><label>当前平台数</label><input type="number" id="edit-platformCount" value="${u.platformCount || 0}" min="0"></div>
</div>
<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">
<div class="field"><label>订阅通知状态(只读)</label><div id="subscribeStatus" class="ro">加载中...</div></div>
<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">
<div class="field"><label>发送测试消息</label>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input id="test-tplId" value="DaPPZVFsLsHzj6TJAFK5rUp8B4DW-9JOtJF00v-uLMQ" style="flex:1;min-width:180px" placeholder="模板ID">
<input id="test-str1" value="v2.0" style="width:100px" placeholder="character_string1">
<input id="test-t3" value="版本已更新" style="width:100px" placeholder="thing3">
<input id="test-t6" value="请查看更新内容" style="width:120px" placeholder="thing6">
<button class="btn-send-msg" onclick="sendTestMessage()">发送测试</button>
</div>
<div id="testResult" style="margin-top:8px"></div>
</div>`;
if (S.subscriptions) {
renderSubscriptions(S.subscriptions);
}
}
function renderSubscriptions(subs) {
const el = document.getElementById('subscribeStatus');
if (!subs || subs.length === 0) {
el.innerHTML = '<span style="color:var(--text-secondary)">该用户未订阅任何通知</span>';
return;
}
const typeMap = {};
subs.forEach(s => {
if (!typeMap[s.type]) typeMap[s.type] = [];
typeMap[s.type].push(s);
});
let html = '<div style="font-size:13px">';
for (const [type, items] of Object.entries(typeMap)) {
const latest = items[0];
const badgeClass = latest.status === 'active' ? 'badge-active' : (latest.status === 'used' ? 'badge-inactive' : 'badge-banned');
const label = latest.status === 'active' ? '已授权' : (latest.status === 'used' ? '已使用' : latest.status);
const typeName = type === 'version-update' ? '版本更新通知' : (type === 'expiry-reminder' ? '到期提醒通知' : type);
html += `<div style="margin-bottom:8px">
<span class="badge ${badgeClass}">${label}</span>
<span style="margin-left:8px">${typeName}</span>
<span style="margin-left:8px;color:var(--text-secondary)">模板: ${latest.templateId.substring(0,8)}...</span>
<span style="margin-left:8px;color:var(--text-secondary)">订阅: ${fmtDate(latest.subscribedAt)}</span>
</div>`;
}
html += '</div>';
el.innerHTML = html;
}
async function sendTestMessage() {
if (!S.editingUser) return;
const btn = document.querySelector('.btn-send-msg');
const resultEl = document.getElementById('testResult');
btn.disabled = true;
btn.textContent = '发送中...';
resultEl.innerHTML = '';
try {
const res = await api('/api/admin/users/' + S.editingUser._id + '/test-message', {
method: 'POST',
body: JSON.stringify({
templateId: document.getElementById('test-tplId').value.trim(),
character_string1: document.getElementById('test-str1').value.trim(),
thing3: document.getElementById('test-t3').value.trim(),
thing6: document.getElementById('test-t6').value.trim()
})
});
if (res.success) {
resultEl.innerHTML = `<span style="color:#137333">✅ 发送成功 (msgid: ${res.data.messageId})</span>`;
} else {
resultEl.innerHTML = `<span style="color:var(--danger)">❌ 发送失败: errcode=${res.data.errcode} ${res.data.errmsg || ''}</span>`;
}
} catch (e) {
resultEl.innerHTML = `<span style="color:var(--danger)">❌ 异常: ${e.message}</span>`;
} finally {
btn.disabled = false;
btn.textContent = '发送测试';
}
}
function toggleVipExpire() {
+39 -1
View File
@@ -1,6 +1,8 @@
const express = require('express');
const router = express.Router();
const User = require('../models/User');
const UserSubscription = require('../models/UserSubscription');
const WechatSubscribeService = require('../services/wechatSubscribeService');
const { adminAuth } = require('../middleware/adminAuth');
router.post('/login', (req, res) => {
@@ -115,7 +117,43 @@ router.get('/users/:id', adminAuth, async (req, res, next) => {
return res.status(404).json({ success: false, error: '用户不存在' });
}
res.json({ success: true, data: user });
if (!user.openid) {
return res.json({ success: true, data: user, subscriptions: [] });
}
const subscriptions = await UserSubscription.find({ openid: user.openid })
.sort({ subscribedAt: -1 })
.lean();
res.json({ success: true, data: user, subscriptions });
} catch (error) {
next(error);
}
});
router.post('/users/:id/test-message', adminAuth, async (req, res, next) => {
try {
const user = await User.findById(req.params.id).select('-__v').lean();
if (!user) {
return res.status(404).json({ success: false, error: '用户不存在' });
}
if (!user.openid) {
return res.status(400).json({ success: false, error: '该用户无 OpenID' });
}
const { templateId, character_string1, thing3, thing6 } = req.body;
const result = await WechatSubscribeService.sendVersionUpdateMessage({
openid: user.openid,
templateId: templateId || 'DaPPZVFsLsHzj6TJAFK5rUp8B4DW-9JOtJF00v-uLMQ',
character_string1: character_string1 || 'v2.0',
thing3: thing3 || '版本已更新',
thing6: thing6 || '请查看更新内容'
});
res.json({ success: result.success, data: result });
} catch (error) {
next(error);
}