1ca73922d8
- 后端新增 /api/admin/users/:id 返回用户订阅列表 - 后端新增 POST /api/admin/users/:id/test-message 发送测试订阅消息 - 前端用户详情页展示订阅状态(只读,按类型分组) - 前端增加发送版本更新测试消息功能
549 lines
26 KiB
HTML
549 lines
26 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>权益小助手 - 管理后台</title>
|
|
<style>
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
:root {
|
|
--primary: #1a73e8; --primary-hover: #1557b0; --danger: #d93025;
|
|
--bg: #f5f7fa; --card: #fff; --text: #202124; --text-secondary: #5f6368;
|
|
--border: #dadce0; --radius: 8px; --shadow: 0 1px 3px rgba(0,0,0,.1);
|
|
}
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
|
|
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
|
|
|
/* Login */
|
|
.login-wrap { display: flex; justify-content: center; align-items: center; min-height: 100vh; }
|
|
.login-card { background: var(--card); padding: 40px; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,.12); width: 100%; max-width: 400px; text-align: center; }
|
|
.login-card h1 { font-size: 22px; margin-bottom: 8px; }
|
|
.login-card p { color: var(--text-secondary); margin-bottom: 24px; font-size: 14px; }
|
|
.login-card input { width: 100%; padding: 12px 16px; border: 1px solid var(--border); border-radius: var(--radius); font-size: 15px; outline: none; transition: border .2s; }
|
|
.login-card input:focus { border-color: var(--primary); }
|
|
.login-card button { width: 100%; margin-top: 16px; padding: 12px; background: var(--primary); color: #fff; border: none; border-radius: var(--radius); font-size: 15px; cursor: pointer; font-weight: 500; }
|
|
.login-card button:hover { background: var(--primary-hover); }
|
|
.login-card .err { color: var(--danger); margin-top: 12px; font-size: 14px; min-height: 20px; }
|
|
|
|
/* Header */
|
|
.header { background: var(--card); border-bottom: 1px solid var(--border); padding: 0 20px; display: flex; justify-content: space-between; align-items: center; height: 56px; }
|
|
.header h2 { font-size: 18px; font-weight: 600; }
|
|
.header button { padding: 6px 16px; background: none; border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; font-size: 13px; color: var(--text-secondary); }
|
|
.header button:hover { background: #f1f3f4; }
|
|
|
|
/* Stats */
|
|
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 20px; }
|
|
.stat-card { background: var(--card); padding: 20px; border-radius: var(--radius); box-shadow: var(--shadow); }
|
|
.stat-card .label { font-size: 13px; color: var(--text-secondary); margin-bottom: 4px; }
|
|
.stat-card .value { font-size: 28px; font-weight: 700; color: var(--text); }
|
|
|
|
/* Filters */
|
|
.filters { background: var(--card); padding: 16px 20px; border-radius: var(--radius); box-shadow: var(--shadow); margin-bottom: 16px; display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
|
|
.filters input, .filters select { padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; font-size: 14px; outline: none; }
|
|
.filters input:focus, .filters select:focus { border-color: var(--primary); }
|
|
.filters input { flex: 1; min-width: 180px; }
|
|
.filters select { min-width: 100px; }
|
|
.filters label { display: flex; align-items: center; gap: 6px; font-size: 14px; cursor: pointer; white-space: nowrap; }
|
|
.filters button { padding: 8px 16px; border: 1px solid var(--border); border-radius: 6px; font-size: 14px; cursor: pointer; background: var(--card); }
|
|
.filters button:hover { background: #f1f3f4; }
|
|
.filters .btn-search { background: var(--primary); color: #fff; border-color: var(--primary); }
|
|
.filters .btn-search:hover { background: var(--primary-hover); }
|
|
|
|
/* Table */
|
|
.table-wrap { background: var(--card); border-radius: var(--radius); box-shadow: var(--shadow); overflow-x: auto; }
|
|
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
|
th { text-align: left; padding: 12px 16px; background: #f8f9fa; color: var(--text-secondary); font-weight: 600; font-size: 13px; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
|
th.sortable { cursor: pointer; user-select: none; transition: background .2s; }
|
|
th.sortable:hover { background: #e8eaed; }
|
|
th.sortable .arrow { margin-left: 4px; font-size: 11px; color: #999; }
|
|
th.sortable.active { color: var(--primary); }
|
|
th.sortable.active .arrow { color: var(--primary); }
|
|
td { padding: 10px 16px; border-bottom: 1px solid #f1f3f4; }
|
|
tr:hover td { background: #f8f9fa; }
|
|
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 12px; font-weight: 500; }
|
|
.badge-active { background: #e6f4ea; color: #137333; }
|
|
.badge-inactive { background: #fef7e0; color: #b06000; }
|
|
.badge-banned { background: #fce8e6; color: #c5221f; }
|
|
.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; }
|
|
.pagination button { padding: 6px 14px; border: 1px solid var(--border); border-radius: 6px; font-size: 13px; cursor: pointer; background: var(--card); }
|
|
.pagination button:hover:not(:disabled) { background: #f1f3f4; }
|
|
.pagination button:disabled { opacity: .4; cursor: not-allowed; }
|
|
.pagination span { font-size: 13px; color: var(--text-secondary); }
|
|
|
|
/* Modal */
|
|
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,.4); z-index: 1000; display: flex; justify-content: center; align-items: flex-start; padding-top: 40px; }
|
|
.modal { background: var(--card); border-radius: 12px; width: 100%; max-width: 640px; max-height: 85vh; overflow-y: auto; box-shadow: 0 8px 32px rgba(0,0,0,.2); }
|
|
.modal-header { padding: 16px 24px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; position: sticky; top: 0; background: var(--card); z-index: 1; }
|
|
.modal-header h3 { font-size: 16px; }
|
|
.modal-header button { background: none; border: none; font-size: 20px; cursor: pointer; color: var(--text-secondary); padding: 4px; }
|
|
.modal-body { padding: 24px; }
|
|
.modal-footer { padding: 16px 24px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 12px; }
|
|
.modal-footer button { padding: 8px 20px; border-radius: 6px; font-size: 14px; cursor: pointer; }
|
|
.btn-save { background: var(--primary); color: #fff; border: none; }
|
|
.btn-save:hover { background: var(--primary-hover); }
|
|
.btn-cancel { background: var(--card); border: 1px solid var(--border); }
|
|
.btn-cancel:hover { background: #f1f3f4; }
|
|
.field { margin-bottom: 16px; }
|
|
.field label { display: block; font-size: 13px; font-weight: 600; color: var(--text-secondary); margin-bottom: 4px; }
|
|
.field input, .field select { width: 100%; padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; font-size: 14px; outline: none; }
|
|
.field input:focus, .field select:focus { border-color: var(--primary); }
|
|
.field .ro { padding: 8px 12px; background: #f8f9fa; border-radius: 6px; font-size: 14px; word-break: break-all; color: var(--text-secondary); }
|
|
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
.avatar-thumb { width: 48px; height: 48px; border-radius: 50%; object-fit: cover; border: 2px solid var(--border); }
|
|
|
|
/* Toast */
|
|
.toast { position: fixed; top: 20px; right: 20px; z-index: 2000; padding: 12px 24px; border-radius: 8px; color: #fff; font-size: 14px; animation: slideIn .3s; }
|
|
.toast-ok { background: #137333; }
|
|
.toast-err { background: var(--danger); }
|
|
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
|
|
|
/* Loading */
|
|
.spinner { display: inline-block; width: 20px; height: 20px; border: 2px solid #e3e3e3; border-top-color: var(--primary); border-radius: 50%; animation: spin .6s linear infinite; }
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
.loading-row td { text-align: center; padding: 40px; color: var(--text-secondary); }
|
|
.empty-row td { text-align: center; padding: 40px; color: var(--text-secondary); }
|
|
.hidden { display: none !important; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<!-- Login Screen -->
|
|
<div id="loginScreen" class="login-wrap">
|
|
<div class="login-card">
|
|
<h1>权益小助手</h1>
|
|
<p>管理后台</p>
|
|
<input type="password" id="keyInput" placeholder="请输入管理密钥" onkeydown="if(event.key==='Enter')login()">
|
|
<button onclick="login()">登 录</button>
|
|
<div class="err" id="loginErr"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Dashboard Screen -->
|
|
<div id="appScreen" class="hidden">
|
|
<div class="header">
|
|
<h2>权益小助手 · 管理后台</h2>
|
|
<button onclick="logout()">退出登录</button>
|
|
</div>
|
|
<div class="container">
|
|
<div class="stats" id="stats"></div>
|
|
<div class="filters">
|
|
<input type="text" id="searchInput" placeholder="搜索 userId / 昵称..." onkeydown="if(event.key==='Enter')searchUsers()">
|
|
<select id="statusFilter">
|
|
<option value="">全部状态</option>
|
|
<option value="active">活跃</option>
|
|
<option value="inactive">未激活</option>
|
|
<option value="banned">封禁</option>
|
|
</select>
|
|
<label><input type="checkbox" id="vipFilter" onchange="searchUsers()"> 仅VIP</label>
|
|
<button class="btn-search" onclick="searchUsers()">搜索</button>
|
|
<button onclick="resetFilters()">重置</button>
|
|
</div>
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th class="sortable" onclick="toggleSort('userId')" id="th-userId">userId</th>
|
|
<th class="sortable" onclick="toggleSort('nickname')" id="th-nickname">昵称</th>
|
|
<th>状态</th><th>VIP</th>
|
|
<th class="sortable" onclick="toggleSort('ocrCount')" id="th-ocrCount">OCR</th>
|
|
<th class="sortable" onclick="toggleSort('platformCount')" id="th-platformCount">平台</th>
|
|
<th class="sortable" onclick="toggleSort('lastLoginAt')" id="th-lastLoginAt">最后登录</th>
|
|
<th class="sortable" onclick="toggleSort('loginDays')" id="th-loginDays">活跃天数</th>
|
|
<th>操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="tableBody"></tbody>
|
|
</table>
|
|
<div class="pagination" id="pagination"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- User Edit Modal -->
|
|
<div class="modal-overlay hidden" id="modalOverlay">
|
|
<div class="modal">
|
|
<div class="modal-header">
|
|
<h3>用户详情</h3>
|
|
<button onclick="closeModal()">×</button>
|
|
</div>
|
|
<div class="modal-body" id="modalBody"></div>
|
|
<div class="modal-footer">
|
|
<button class="btn-cancel" onclick="closeModal()">取消</button>
|
|
<button class="btn-save" id="btnSave" onclick="saveUser()">保存修改</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const S = {
|
|
key: sessionStorage.getItem('admin_key') || '',
|
|
page: 1,
|
|
pages: 1,
|
|
sortBy: 'createdAt',
|
|
sortOrder: 'desc',
|
|
editingUser: null
|
|
};
|
|
|
|
async function api(path, opts = {}) {
|
|
const headers = { 'Content-Type': 'application/json' };
|
|
if (S.key) headers['Authorization'] = 'Bearer ' + S.key;
|
|
const res = await fetch(path, { headers, ...opts });
|
|
if (res.status === 401) {
|
|
sessionStorage.removeItem('admin_key');
|
|
S.key = '';
|
|
showLogin();
|
|
throw new Error('会话已过期');
|
|
}
|
|
const data = await res.json();
|
|
if (!res.ok && !data.success) throw new Error(data.error || '请求失败');
|
|
return data;
|
|
}
|
|
|
|
function showLogin() {
|
|
document.getElementById('loginScreen').classList.remove('hidden');
|
|
document.getElementById('appScreen').classList.add('hidden');
|
|
document.getElementById('loginErr').textContent = '';
|
|
document.getElementById('keyInput').value = '';
|
|
}
|
|
|
|
function showToast(msg, ok) {
|
|
const t = document.createElement('div');
|
|
t.className = 'toast ' + (ok ? 'toast-ok' : 'toast-err');
|
|
t.textContent = msg;
|
|
document.body.appendChild(t);
|
|
setTimeout(() => t.remove(), 3000);
|
|
}
|
|
|
|
async function login() {
|
|
const key = document.getElementById('keyInput').value.trim();
|
|
if (!key) { document.getElementById('loginErr').textContent = '请输入管理密钥'; return; }
|
|
try {
|
|
const res = await fetch('/api/admin/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ key })
|
|
});
|
|
if (!res.ok) {
|
|
const d = await res.json();
|
|
document.getElementById('loginErr').textContent = d.error || '密钥无效';
|
|
return;
|
|
}
|
|
S.key = key;
|
|
sessionStorage.setItem('admin_key', key);
|
|
document.getElementById('loginScreen').classList.add('hidden');
|
|
document.getElementById('appScreen').classList.remove('hidden');
|
|
await loadAll();
|
|
} catch (e) {
|
|
document.getElementById('loginErr').textContent = '网络错误,请重试';
|
|
}
|
|
}
|
|
|
|
function logout() {
|
|
sessionStorage.removeItem('admin_key');
|
|
S.key = '';
|
|
showLogin();
|
|
}
|
|
|
|
async function loadAll() {
|
|
await Promise.all([loadStats(), loadUsers()]);
|
|
}
|
|
|
|
async function loadStats() {
|
|
try {
|
|
const d = await api('/api/admin/stats');
|
|
const s = d.data;
|
|
document.getElementById('stats').innerHTML = [
|
|
{ label: '总用户', value: s.totalUsers },
|
|
{ label: '活跃用户', value: s.activeUsers },
|
|
{ label: 'VIP 用户', value: s.vipUsers },
|
|
{ label: '封禁用户', value: s.bannedUsers },
|
|
{ label: '今日新增', value: s.todayNewUsers }
|
|
].map(c => `<div class="stat-card"><div class="label">${c.label}</div><div class="value">${c.value}</div></div>`).join('');
|
|
} catch (e) { console.error(e); }
|
|
}
|
|
|
|
async function loadUsers() {
|
|
const tbody = document.getElementById('tableBody');
|
|
tbody.innerHTML = '<tr class="loading-row"><td colspan="9"><div class="spinner"></div> 加载中...</td></tr>';
|
|
try {
|
|
const search = document.getElementById('searchInput').value.trim();
|
|
const status = document.getElementById('statusFilter').value;
|
|
const isVip = document.getElementById('vipFilter').checked;
|
|
const params = new URLSearchParams({ page: S.page, limit: 20 });
|
|
if (search) params.set('search', search);
|
|
if (status) params.set('status', status);
|
|
if (isVip) params.set('isVip', 'true');
|
|
params.set('sortBy', S.sortBy);
|
|
params.set('order', S.sortOrder);
|
|
const d = await api('/api/admin/users?' + params);
|
|
const { users, pagination } = d.data;
|
|
S.pages = pagination.pages || 0;
|
|
if (users.length === 0) {
|
|
tbody.innerHTML = '<tr class="empty-row"><td colspan="9">没有找到匹配的用户</td></tr>';
|
|
} else {
|
|
tbody.innerHTML = users.map(u => `
|
|
<tr>
|
|
<td>${esc(u.userId)}</td>
|
|
<td>${esc(u.nickname || '-')}</td>
|
|
<td>${statusBadge(u.status)}</td>
|
|
<td>${u.isVip ? '<span class="badge badge-vip">是</span>' : '否'}</td>
|
|
<td>${u.ocrCount}/${u.ocrCountTotal}</td>
|
|
<td>${u.platformCount}/${u.platformLimit}</td>
|
|
<td>${fmtDate(u.lastLoginAt)}</td>
|
|
<td>${u.loginDays || 1}</td>
|
|
<td><button class="btn-detail" onclick="openModal('${u._id}')">详情</button></td>
|
|
</tr>`).join('');
|
|
}
|
|
renderPagination();
|
|
} catch (e) {
|
|
tbody.innerHTML = '<tr class="loading-row"><td colspan="9">加载失败</td></tr>';
|
|
}
|
|
}
|
|
|
|
function renderPagination() {
|
|
document.getElementById('pagination').innerHTML = `
|
|
<button onclick="prevPage()" ${S.page <= 1 ? 'disabled' : ''}>上一页</button>
|
|
<span>第 ${S.page} 页 / 共 ${S.pages} 页</span>
|
|
<button onclick="nextPage()" ${S.page >= S.pages ? 'disabled' : ''}>下一页</button>`;
|
|
}
|
|
|
|
function toggleSort(field) {
|
|
if (S.sortBy === field) {
|
|
S.sortOrder = S.sortOrder === 'desc' ? 'asc' : 'desc';
|
|
} else {
|
|
S.sortBy = field;
|
|
S.sortOrder = 'desc';
|
|
}
|
|
S.page = 1;
|
|
renderSortIndicators();
|
|
loadUsers();
|
|
}
|
|
|
|
function renderSortIndicators() {
|
|
document.querySelectorAll('th.sortable').forEach(th => {
|
|
const arrow = th.querySelector('.arrow');
|
|
if (arrow) arrow.remove();
|
|
th.classList.remove('active');
|
|
});
|
|
const active = document.getElementById('th-' + S.sortBy);
|
|
if (active) {
|
|
active.classList.add('active');
|
|
active.innerHTML += '<span class="arrow">' + (S.sortOrder === 'asc' ? '▲' : '▼') + '</span>';
|
|
}
|
|
}
|
|
|
|
function prevPage() { if (S.page > 1) { S.page--; loadUsers(); } }
|
|
function nextPage() { if (S.page < S.pages) { S.page++; loadUsers(); } }
|
|
|
|
function searchUsers() { S.page = 1; loadUsers(); }
|
|
function resetFilters() {
|
|
document.getElementById('searchInput').value = '';
|
|
document.getElementById('statusFilter').value = '';
|
|
document.getElementById('vipFilter').checked = false;
|
|
S.page = 1;
|
|
loadUsers();
|
|
}
|
|
|
|
async function openModal(id) {
|
|
document.getElementById('modalOverlay').classList.remove('hidden');
|
|
document.getElementById('modalBody').innerHTML = '<div class="spinner"></div> 加载中...';
|
|
try {
|
|
const d = await api('/api/admin/users/' + id);
|
|
S.editingUser = d.data;
|
|
S.subscriptions = d.subscriptions || [];
|
|
renderModal(d.data);
|
|
} catch (e) {
|
|
showToast('加载用户详情失败', false);
|
|
closeModal();
|
|
}
|
|
}
|
|
|
|
function renderModal(u) {
|
|
const body = document.getElementById('modalBody');
|
|
body.innerHTML = `
|
|
<div class="field-row">
|
|
<div class="field"><label>用户ID</label><div class="ro">${esc(u.userId)}</div></div>
|
|
<div class="field"><label>OpenID</label><div class="ro">${u.openid ? u.openid.substring(0,6) + '***' + u.openid.slice(-4) : '-'}</div></div>
|
|
</div>
|
|
<div class="field-row">
|
|
<div class="field"><label>头像</label>${u.avatarUrl ? `<img class="avatar-thumb" src="${esc(u.avatarUrl)}" onerror="this.style.display='none'">` : '<div class="ro">无</div>'}</div>
|
|
<div class="field"><label>注册时间</label><div class="ro">${fmtDate(u.createdAt)}</div></div>
|
|
</div>
|
|
<div class="field-row">
|
|
<div class="field"><label>最后登录</label><div class="ro">${fmtDate(u.lastLoginAt)}</div></div>
|
|
<div class="field"><label>更新时间</label><div class="ro">${fmtDate(u.updatedAt)}</div></div>
|
|
</div>
|
|
<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">
|
|
<div class="field-row">
|
|
<div class="field"><label>昵称</label><input id="edit-nickname" value="${esc(u.nickname || '')}"></div>
|
|
<div class="field"><label>手机号</label><input id="edit-phone" value="${esc(u.phoneNumber || '')}"></div>
|
|
</div>
|
|
<div class="field-row">
|
|
<div class="field"><label>状态</label><select id="edit-status">
|
|
<option value="active" ${u.status==='active'?'selected':''}>活跃</option>
|
|
<option value="inactive" ${u.status==='inactive'?'selected':''}>未激活</option>
|
|
<option value="banned" ${u.status==='banned'?'selected':''}>封禁</option>
|
|
</select></div>
|
|
<div class="field"><label>VIP</label><select id="edit-vip" onchange="toggleVipExpire()">
|
|
<option value="true" ${u.isVip?'selected':''}>是</option>
|
|
<option value="false" ${!u.isVip?'selected':''}>否</option>
|
|
</select></div>
|
|
</div>
|
|
<div class="field" id="vipExpireField" style="${u.isVip?'':'display:none'}">
|
|
<label>VIP 到期时间</label>
|
|
<input type="date" id="edit-vipExpire" value="${u.vipExpireAt ? u.vipExpireAt.substring(0,10) : ''}">
|
|
</div>
|
|
<div class="field-row">
|
|
<div class="field"><label>OCR 剩余次数</label><input type="number" id="edit-ocrCount" value="${u.ocrCount || 0}" min="0"></div>
|
|
<div class="field"><label>OCR 总次数</label><input type="number" id="edit-ocrTotal" value="${u.ocrCountTotal || 0}" min="0"></div>
|
|
</div>
|
|
<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() {
|
|
document.getElementById('vipExpireField').style.display =
|
|
document.getElementById('edit-vip').value === 'true' ? '' : 'none';
|
|
}
|
|
|
|
function closeModal() {
|
|
document.getElementById('modalOverlay').classList.add('hidden');
|
|
S.editingUser = null;
|
|
}
|
|
|
|
async function saveUser() {
|
|
if (!S.editingUser) return;
|
|
const btn = document.getElementById('btnSave');
|
|
btn.disabled = true;
|
|
btn.textContent = '保存中...';
|
|
try {
|
|
const body = {
|
|
nickname: document.getElementById('edit-nickname').value.trim(),
|
|
phoneNumber: document.getElementById('edit-phone').value.trim(),
|
|
status: document.getElementById('edit-status').value,
|
|
isVip: document.getElementById('edit-vip').value === 'true',
|
|
vipExpireAt: document.getElementById('edit-vip').value === 'true' ? document.getElementById('edit-vipExpire').value || null : null,
|
|
ocrCount: parseInt(document.getElementById('edit-ocrCount').value) || 0,
|
|
ocrCountTotal: parseInt(document.getElementById('edit-ocrTotal').value) || 0,
|
|
platformLimit: parseInt(document.getElementById('edit-platformLimit').value) || 0,
|
|
platformCount: parseInt(document.getElementById('edit-platformCount').value) || 0
|
|
};
|
|
await api('/api/admin/users/' + S.editingUser._id, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(body)
|
|
});
|
|
closeModal();
|
|
showToast('保存成功', true);
|
|
loadUsers();
|
|
loadStats();
|
|
} catch (e) {
|
|
showToast(e.message || '保存失败', false);
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = '保存修改';
|
|
}
|
|
}
|
|
|
|
function esc(s) { return (s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
|
function statusBadge(s) {
|
|
const map = { active: 'badge-active', inactive: 'badge-inactive', banned: 'badge-banned' };
|
|
const label = { active: '活跃', inactive: '未激活', banned: '封禁' };
|
|
return `<span class="badge ${map[s] || ''}">${label[s] || s}</span>`;
|
|
}
|
|
function fmtDate(d) { if (!d) return '-'; const t = new Date(d); return t.getFullYear()+'-'+String(t.getMonth()+1).padStart(2,'0')+'-'+String(t.getDate()).padStart(2,'0')+' '+String(t.getHours()).padStart(2,'0')+':'+String(t.getMinutes()).padStart(2,'0'); }
|
|
|
|
// Init
|
|
if (S.key) {
|
|
document.getElementById('loginScreen').classList.add('hidden');
|
|
document.getElementById('appScreen').classList.remove('hidden');
|
|
renderSortIndicators();
|
|
loadAll();
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|