spark-server/deploy/popadmin/popadmin.js
DESKTOP-L8OUL9F\gfl0034 1498c0258f fix(popadmin): 音乐分类可编辑与勾选绑定
分类列表提供编辑面板(名称/排序/启用),单曲分类改为勾选保存,走既有 PATCH/PUT API。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 11:39:09 +08:00

5314 lines
144 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const TOKEN_KEY = 'wyrs_admin_token';
const API = '/api/admin';
const APP = 'spark';
let token = localStorage.getItem(TOKEN_KEY);
let username = localStorage.getItem('wyrs_admin_name') || '';
const $ = (id) => document.getElementById(id);
const NAV = [
{ id: 'overview', label: '概览' },
{ id: 'apiDoc', label: '接口文档' },
{ id: 'users', label: '用户' },
{ id: 'socialDemo', label: '社交演示' },
{ id: 'testAccounts', label: '联调测试账号' },
{ id: 'userPasswords', label: 'App用户密码' },
{ id: 'ipLookup', label: 'IP 查询' },
{ id: 'groups', label: '活跃群' },
{ id: 'imMessages', label: 'IM消息' },
{ id: 'feedbacks', label: '反馈' },
{ id: 'complaints', label: '投诉' },
{ id: 'words', label: '敏感/监控词' },
{ id: 'sameIpRank', label: '同IP排名' },
{ id: 'ipBlacklist', label: 'IP黑名单' },
{ id: 'accountWhitelist', label: '账号白名单' },
{ id: 'domainWhitelist', label: '域名白名单' },
{ id: 'rankings', label: '日排行' },
{ id: 'orders', label: '充值订单' },
{ id: 'pushes', label: '系统推送' },
{ id: 'contacts', label: '通讯录' },
{ id: 'media', label: '用户相册' },
{ id: 'mediaPull', label: '媒体拉取' },
{ id: 'banners', label: 'Banner' },
{ id: 'versions', label: '版本' },
{ id: 'prices', label: '会员价格' },
{ id: 'paymentChannels', label: '支付渠道' },
{ id: 'music', label: '音乐列表' },
{ id: 'novelCms', label: '小说内容管理' },
{ id: 'developerApps', label: '同开发者应用' },
{ id: 'crashLogs', label: '崩溃日志' },
{ id: 'dau', label: '日活' },
{ id: 'retention', label: '留存' },
{ id: 'configs', label: '火花运营配置' },
{ id: 'modules', label: '架构模块' },
{ id: 'password', label: '账号安全' },
{ id: 'mfa', label: 'MFA绑定' },
{ id: 'staff', label: '权限分配' },
];
function showLogin() {
$('loginPage').classList.remove('hidden');
$('appPage').classList.add('hidden');
}
function showApp() {
$('loginPage').classList.add('hidden');
$('appPage').classList.remove('hidden');
$('adminName').textContent = username || '';
renderNav('overview');
pages.overview();
}
function logout() {
token = null;
username = '';
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem('wyrs_admin_name');
showLogin();
}
async function api(path, opts = {}) {
const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) };
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(API + path, { ...opts, headers });
const isLogin = path.startsWith('/auth/login');
// 仅 401 视为登录失效;403 多为权限不足(如非超管看权限页),勿清会话
if (!isLogin && token && res.status === 401) {
logout();
throw new Error('登录已失效,请重新登录');
}
const json = await res.json().catch(() => ({}));
if (!res.ok || json.success === false) {
throw new Error(json.message || (res.status === 403 ? '无权限访问该功能' : '请求失败 ' + res.status));
}
return json.data !== undefined ? json.data : json;
}
async function apiUpload(path, formData) {
const headers = {};
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(API + path, { method: 'POST', headers, body: formData });
if (token && res.status === 401) {
logout();
throw new Error('登录已失效,请重新登录');
}
const json = await res.json().catch(() => ({}));
if (!res.ok || json.success === false) {
throw new Error(json.message || '上传失败 ' + res.status);
}
return json.data !== undefined ? json.data : json;
}
function renderNav(active) {
const nav = $('nav');
nav.innerHTML = NAV.map(
(n) => `<a data-id="${n.id}" class="${n.id === active ? 'active' : ''}">${n.label}</a>`
).join('');
nav.querySelectorAll('a').forEach((a) => {
a.addEventListener('click', () => {
const id = a.dataset.id;
renderNav(id);
$('pageTitle').textContent = NAV.find((x) => x.id === id)?.label || id;
pages[id]();
});
});
}
function esc(s) {
return String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function renderLookupList(data, key) {
const users = asList(data.users);
const rows = users.length
? users
.map(
(u) => `<tr>
<td>${esc(u.id || u.userId)}</td>
<td>${esc(u.email)}</td>
<td>${esc(u.nickname)}</td>
<td>${esc(u.deviceId)}</td>
<td>${esc(u.lastActiveAt)}</td>
<td>${esc(u.lastActiveIp || u.registerIp)}</td>
<td>${u.email ? `<button type="button" class="secondary btn-sm" onclick="pages.users('${esc(u.email)}')">${esc('详情')}</button>` : '—'}</td>
</tr>`
)
.join('')
: emptyRows(7, '未找到匹配用户');
return `
<div class="section-block note">
<h3>查询结果 · ${esc(key)}=${esc(data[key])} · 用户 ${esc(data.count ?? users.length)} · 邮箱 ${esc(data.emailCount ?? 0)}</h3>
<table>
<thead><tr><th>ID</th><th>邮箱</th><th>昵称</th><th>设备</th><th>最近活跃</th><th>IP</th><th></th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}
async function downloadZip(path, filename) {
const headers = {};
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(API + path, { headers });
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(json.message || '下载失败 ' + res.status);
}
const blob = await res.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename || 'export.zip';
a.click();
URL.revokeObjectURL(a.href);
}
function wordListUi(kind, title) {
return `
<div class="section-block" style="margin-bottom:16px">
<h3>${esc(title)}</h3>
<div class="toolbar">
<input id="${kind}Word" placeholder="词条" style="flex:1;max-width:240px;padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<input id="${kind}Note" placeholder="备注" style="flex:1;max-width:200px;padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<button type="button" data-add-word="${kind}">添加</button>
</div>
<div id="${kind}Table"></div>
</div>`;
}
function pageMeta(page) {
return page && typeof page === 'object'
? { total: page.totalElements ?? (page.content || []).length, list: page.content || page }
: { total: 0, list: [] };
}
function asList(v) {
return Array.isArray(v) ? v : [];
}
function emptyRows(cols, tip) {
return `<tr><td colspan="${cols}">${esc(tip || '暂无数据')}</td></tr>`;
}
function eventRowsHtml(list, emptyTip) {
const rows = asList(list);
if (!rows.length) return emptyRows(4, emptyTip || '暂无记录(待客户端/IM 上报)');
return rows
.map(
(e) => `<tr>
<td>${esc(e.createdAt || e.at || '—')}</td>
<td>${esc(e.title || e.type || '—')}</td>
<td>${esc(e.detail || e.content || e.word || '—')}</td>
<td>${esc(e.meta || e.reason || '—')}</td>
</tr>`
)
.join('');
}
function eventTable(title, list, emptyTip) {
return `
<div class="section-block note">
<h3>${esc(title)}(${asList(list).length})</h3>
<table>
<thead><tr><th>时间</th><th>标题</th><th>详情</th><th>备注</th></tr></thead>
<tbody>${eventRowsHtml(list, emptyTip)}</tbody>
</table>
</div>`;
}
function renderUserDetail(panel, data, fallbackEmail) {
if (!data || !data.found) {
panel.innerHTML = `
<div class="note">未找到邮箱 <code>${esc(data?.email || fallbackEmail)}</code> 的互通账号或火花用户档案。</div>`;
return;
}
const acc = data.account || {};
const appUsers = asList(data.appUsers);
const linked = asList(data.linkedApps);
const primary = data.primaryUser || appUsers[0] || {};
const mod = data.moderation || {};
const mem = data.membership || {};
const wl = data.whitelist || {};
const caps = data.capabilities || {};
const email = data.email || fallbackEmail;
const banned = !!mod.banned;
const abnormal = !!mod.abnormal;
panel.innerHTML = `
<div class="cards" style="margin-bottom:12px">
<div class="card"><div class="label">邮箱</div><div class="value" style="font-size:16px">${esc(email)}</div></div>
<div class="card"><div class="label">昵称</div><div class="value" style="font-size:16px">${esc(data.nickname || primary.nickname || acc.displayName || '—')}</div></div>
<div class="card"><div class="label">好友数</div><div class="value">${esc(data.friendCount ?? 0)}</div></div>
<div class="card"><div class="label">状态</div><div class="value" style="font-size:14px">
${banned ? '<span class="flag-bad">已封号</span>' : '<span class="flag-ok">正常</span>'}
${abnormal ? ' · <span class="flag-warn">异常</span>' : ''}
${wl.enabled ? ' · <span class="tag">白名单</span>' : ''}
</div></div>
</div>
<div class="action-bar">
<button type="button" class="secondary" data-act="reset-avatar">重置头像</button>
<button type="button" class="secondary" data-act="reset-nickname">重置用户名</button>
<button type="button" class="secondary" data-act="reset-password">重置密码</button>
<button type="button" class="btn warn" data-act="mark-abnormal">${abnormal ? '取消异常' : '标记异常'}</button>
<button type="button" class="btn danger" data-act="ban">${banned ? '解封' : '封号'}</button>
<button type="button" data-act="whitelist">加入白名单</button>
</div>
${
mod.banReason || mod.bannedAt
? `<div class="note" style="margin-bottom:12px">封禁/异常原因:${esc(mod.banReason || '—')} · 封禁时间:${esc(mod.bannedAt || '—')}</div>`
: ''
}
<div class="section-block note">
<h3>注册 / 活跃 IP</h3>
<dl class="detail-dl">
<dt>注册 IP:端口</dt><dd>${esc(primary.registerEndpoint || primary.registerIp || '—')}</dd>
<dt>注册城市</dt><dd>${esc(primary.registerCity || primary.country || '—')}</dd>
<dt>最近活跃 IP:端口</dt><dd>${esc(primary.lastActiveEndpoint || primary.lastActiveIp || '—')}</dd>
<dt>最近活跃城市</dt><dd>${esc(primary.lastActiveCity || '—')}</dd>
<dt>注册设备</dt><dd>${esc(primary.deviceId || '—')} · ${esc(primary.platform || '')} · ${esc(primary.appVersion || '')}</dd>
<dt>头像</dt><dd>${esc(primary.avatarUrl || '(默认/已重置)')}</dd>
</dl>
</div>
<div class="section-block note">
<h3>互通账号</h3>
${
data.registered
? `<dl class="detail-dl">
<dt>账号 ID</dt><dd>${esc(acc.accountId)}</dd>
<dt>显示名</dt><dd>${esc(acc.displayName || '—')}</dd>
<dt>注册时间</dt><dd>${esc(acc.createdAt || '—')}</dd>
<dt>最近登录</dt><dd>${esc(acc.lastLoginAt || '—')}</dd>
</dl>`
: '<p style="margin-top:8px">尚未创建互通账号。</p>'
}
</div>
<div class="section-block note">
<h3>设备列表(${asList(data.devices).length})</h3>
<table>
<thead><tr><th>设备ID</th><th>平台</th><th>版本</th><th>注册IP:端口/城市</th><th>最近IP:端口/城市</th><th>首次</th><th>最近</th><th>来源</th></tr></thead>
<tbody>
${
asList(data.devices).length
? asList(data.devices)
.map(
(d) => `<tr>
<td>${esc(d.deviceId)}</td>
<td>${esc(d.platform || '—')}</td>
<td>${esc(d.appVersion || '—')}</td>
<td>${esc(d.registerEndpoint || d.registerIp || '—')} / ${esc(d.registerCity || '—')}</td>
<td>${esc(d.lastEndpoint || d.lastIp || '—')} / ${esc(d.lastCity || '—')}</td>
<td>${esc(d.firstSeenAt || '—')}</td>
<td>${esc(d.lastSeenAt || '—')}</td>
<td>${esc(d.source || '—')}</td>
</tr>`
)
.join('')
: emptyRows(8, '暂无设备记录')
}
</tbody>
</table>
</div>
${eventTable('更换设备记录', data.deviceChanges, '暂无更换设备记录')}
<div class="section-block note">
<h3>同设备应用列表(${asList(data.sameDeviceApps).length})</h3>
<p style="margin:0 0 8px;color:var(--muted);font-size:12px">同一 deviceId 下安装/活跃过的全部 App(含本账号与其它邮箱)</p>
<table>
<thead><tr><th>设备码</th><th>App</th><th>用户ID</th><th>邮箱</th><th>昵称</th><th>平台/版本</th><th>最近IP:端口</th><th>注册</th><th>最近活跃</th><th></th></tr></thead>
<tbody>
${
asList(data.sameDeviceApps).length
? asList(data.sameDeviceApps)
.map(
(d) => `<tr>
<td><code>${esc(d.deviceId)}</code></td>
<td>${esc(d.appName || d.appCode)}</td>
<td>${esc(d.userId || '—')}</td>
<td>${esc(d.email || '—')}</td>
<td>${esc(d.nickname || '—')}</td>
<td>${esc(d.platform || '—')} ${esc(d.appVersion || '')}</td>
<td>${esc(d.lastActiveEndpoint || '—')}</td>
<td>${esc(d.createdAt || '—')}</td>
<td>${esc(d.lastActiveAt || '—')}</td>
<td>${d.self ? '<span class="tag">本账号</span>' : ''}</td>
</tr>`
)
.join('')
: emptyRows(10, '暂无同设备应用(需有 deviceId)')
}
</tbody>
</table>
</div>
<div class="section-block note">
<h3>同网络设备码(${asList(data.sameNetworkDevices).length})</h3>
<p style="margin:0 0 8px;color:var(--muted);font-size:12px">按注册/最近活跃公网 IP 匹配的其它设备(不含本账号)</p>
<table>
<thead><tr><th>设备码</th><th>用户ID</th><th>邮箱</th><th>昵称</th><th>匹配IP</th><th>最近IP:端口</th><th>城市</th><th>平台</th><th>最近活跃</th></tr></thead>
<tbody>
${
asList(data.sameNetworkDevices).length
? asList(data.sameNetworkDevices)
.map(
(d) => `<tr>
<td><code>${esc(d.deviceId)}</code></td>
<td>${esc(d.userId || '—')}</td>
<td>${esc(d.email || '—')}</td>
<td>${esc(d.nickname || '—')}</td>
<td>${esc(d.matchIp || '—')}</td>
<td>${esc(d.lastActiveEndpoint || d.lastActiveIp || '—')}</td>
<td>${esc(d.lastActiveCity || '—')}</td>
<td>${esc(d.platform || '—')} ${esc(d.appVersion || '')}</td>
<td>${esc(d.lastActiveAt || '—')}</td>
</tr>`
)
.join('')
: emptyRows(9, '暂无同网络其它设备(需双方均有 IP 记录)')
}
</tbody>
</table>
</div>
<div class="section-block note">
<h3>通讯录(${data.contactCount ?? asList(data.contacts).length})</h3>
<p style="margin:0 0 8px;color:var(--muted);font-size:12px">客户端授权同步后可见;含姓名与备注名</p>
<table>
<thead><tr><th>姓名</th><th>备注名</th><th>电话</th><th>设备码</th><th>同步时间</th></tr></thead>
<tbody>
${
asList(data.contacts).length
? asList(data.contacts)
.map(
(c) => `<tr>
<td>${esc(c.name || '—')}</td>
<td>${esc(c.remark || c.name || '—')}</td>
<td>${esc(c.phone || c.phoneNorm || '—')}</td>
<td>${esc(c.deviceId || '—')}</td>
<td>${esc(c.syncedAt || '—')}</td>
</tr>`
)
.join('')
: emptyRows(5, '暂无通讯录(待客户端授权同步)')
}
</tbody>
</table>
</div>
<div class="section-block note">
<h3>火花 / Spark 用户档案</h3>
${
appUsers.length
? `<table>
<thead><tr><th>用户ID</th><th>App</th><th>昵称</th><th>手机</th><th>平台</th><th>版本</th><th>渠道</th><th>国家</th><th>设备</th><th>封禁</th><th>异常</th><th>注册</th><th>最近活跃</th><th></th></tr></thead>
<tbody>
${appUsers
.map(
(u) => `<tr>
<td>${esc(u.userId)}</td>
<td>${esc(u.appName || u.appCode)}</td>
<td>${esc(u.nickname || '—')}</td>
<td>${esc(u.phone || '—')}</td>
<td>${esc(u.platform || '—')}</td>
<td>${esc(u.appVersion || '—')}</td>
<td>${esc(u.channel || '—')}</td>
<td>${esc(u.country || '—')}</td>
<td>${esc(u.deviceId || '—')}</td>
<td>${u.banned ? '<span class="flag-bad">是</span>' : '否'}</td>
<td>${u.abnormal ? '<span class="flag-warn">是</span>' : '否'}</td>
<td>${esc(u.createdAt || '—')}</td>
<td>${esc(u.lastActiveAt || '—')}</td>
<td>
<button type="button" class="secondary btn-sm" data-contacts="${esc(u.userId)}">通讯录</button>
<button type="button" class="secondary btn-sm" data-media="${esc(u.userId)}">相册</button>
</td>
</tr>`
)
.join('')}
</tbody>
</table>`
: '<p style="margin-top:8px">无火花(含历史兼容)用户档案。</p>'
}
</div>
<div class="section-block note">
<h3>好友列表(${asList(data.friends).length})</h3>
<table>
<thead><tr><th>好友ID/邮箱</th><th>昵称</th><th>备注</th><th>时间</th></tr></thead>
<tbody>
${
asList(data.friends).length
? asList(data.friends)
.map(
(f) => `<tr>
<td>${esc(f.friendId || f.email || f.id || '—')}</td>
<td>${esc(f.nickname || f.name || '—')}</td>
<td>${esc(f.remark || f.note || '—')}</td>
<td>${esc(f.createdAt || f.at || '—')}</td>
</tr>`
)
.join('')
: emptyRows(4, '暂无好友(待 IM 上报)')
}
</tbody>
</table>
</div>
<div class="section-block note">
<h3>加入过的群(${asList(data.groups).length})</h3>
<table>
<thead><tr><th>群ID</th><th>群名</th><th>角色</th><th>加入时间</th></tr></thead>
<tbody>
${
asList(data.groups).length
? asList(data.groups)
.map(
(g) => `<tr>
<td>${esc(g.groupId || g.id || '—')}</td>
<td>${esc(g.name || g.title || '—')}</td>
<td>${esc(g.role || '—')}</td>
<td>${esc(g.joinedAt || g.createdAt || '—')}</td>
</tr>`
)
.join('')
: emptyRows(4, '暂无群记录(待 IM 上报)')
}
</tbody>
</table>
</div>
<div class="section-block note">
<h3>购买记录(${asList(data.payments).length})</h3>
<table>
<thead><tr><th>订单</th><th>金额</th><th>商品</th><th>状态</th><th>渠道</th><th>支付时间</th></tr></thead>
<tbody>
${
asList(data.payments).length
? asList(data.payments)
.map(
(p) => `<tr>
<td>${esc(p.orderId || p.id)}</td>
<td>${esc(p.amount)}</td>
<td>${esc(p.productId || '—')}</td>
<td>${esc(p.status || '—')}</td>
<td>${esc(p.payChannel || '—')}</td>
<td>${esc(p.paidAt || p.createdAt || '—')}</td>
</tr>`
)
.join('')
: emptyRows(6, '暂无购买记录')
}
</tbody>
</table>
</div>
<div class="section-block note">
<h3>会员详情</h3>
<dl class="detail-dl">
<dt>状态</dt><dd>${mem.active ? '<span class="flag-ok">有效</span>' : '无/未知'}</dd>
<dt>商品</dt><dd>${esc(mem.productId || '—')}</dd>
<dt>到期</dt><dd>${esc(mem.expireAt || '—')}</dd>
<dt>来源</dt><dd>${esc(mem.source || '—')}</dd>
<dt>最近支付</dt><dd>${esc(mem.lastPaidAt || '—')} · ${esc(mem.lastAmount ?? '')}</dd>
</dl>
</div>
<div class="section-block note">
<h3>白名单</h3>
<p>${wl.enabled ? '<span class="tag">已在白名单</span>' : '未加入'}</p>
${
asList(wl.entries).length
? `<table style="margin-top:8px">
<thead><tr><th>App</th><th>启用</th><th>备注</th><th>更新</th></tr></thead>
<tbody>
${asList(wl.entries)
.map(
(e) => `<tr>
<td>${esc(e.appCode)}</td>
<td>${e.enabled ? '是' : '否'}</td>
<td>${esc(e.note || '—')}</td>
<td>${esc(e.updatedAt || '—')}</td>
</tr>`
)
.join('')}
</tbody>
</table>`
: ''
}
</div>
${eventTable('违规行为记录', data.violations)}
${eventTable('违规敏感词记录', data.sensitiveWords)}
${eventTable('好友圈被拒记录', data.momentRejects)}
${eventTable('广场被拒记录', data.squareRejects)}
${eventTable('被投诉记录', data.complaints)}
${eventTable('举报记录', data.reports)}
${eventTable('余额消费记录', data.balanceLogs)}
${eventTable('积分消费记录', data.pointLogs)}
${eventTable('管理操作记录', data.moderationEvents, '暂无管理操作')}
<div class="section-block note">
<h3>崩溃日志(${asList(data.crashLogs).length})</h3>
<p style="margin:0 0 8px;color:var(--muted);font-size:12px">含机型、邮箱、设备码</p>
<table>
<thead><tr><th>时间</th><th>级别</th><th>机型</th><th>邮箱</th><th>设备码</th><th>App</th><th>内容</th></tr></thead>
<tbody>
${
asList(data.crashLogs).length
? asList(data.crashLogs)
.map(
(c) => `<tr>
<td>${esc(c.createdAt || '—')}</td>
<td>${esc(c.level || '—')}</td>
<td>${esc(c.deviceModel || '—')}</td>
<td>${esc(c.email || '—')}</td>
<td><code>${esc(c.deviceId || '—')}</code></td>
<td>${esc(c.appCode || '—')}</td>
<td style="max-width:360px;word-break:break-all">${esc(c.message || '—')}</td>
</tr>`
)
.join('')
: emptyRows(7, '暂无崩溃/错误日志')
}
</tbody>
</table>
</div>
<div class="section-block note">
<h3>关联的其它 App</h3>
${
linked.length
? `<table>
<thead><tr><th>App</th><th>用户ID</th><th>设备</th><th>注册</th><th>最近活跃</th></tr></thead>
<tbody>
${linked
.map(
(u) => `<tr>
<td>${esc(u.appName || u.appCode)}</td>
<td>${esc(u.userId)}</td>
<td>${esc(u.deviceId || '—')}</td>
<td>${esc(u.createdAt || '—')}</td>
<td>${esc(u.lastActiveAt || '—')}</td>
</tr>`
)
.join('')}
</tbody>
</table>`
: '<p style="margin-top:8px">无关联 App 用户。</p>'
}
</div>
<div class="note" style="color:var(--muted)">${esc(caps.note || '')}</div>`;
panel.querySelectorAll('[data-contacts]').forEach((btn) => {
btn.onclick = () => pages.contactsDetail(btn.dataset.contacts);
});
panel.querySelectorAll('[data-media]').forEach((btn) => {
btn.onclick = () => pages.mediaDetail(btn.dataset.media);
});
const runAction = async (act) => {
const body = { email, appCode: APP };
let path = '';
if (act === 'reset-avatar') {
if (!confirm('确认重置该用户头像?')) return;
path = '/users/by-email/reset-avatar';
} else if (act === 'reset-nickname') {
if (!confirm('确认重置该用户昵称?')) return;
path = '/users/by-email/reset-nickname';
} else if (act === 'reset-password') {
if (!data.registered) {
alert('该邮箱尚未创建互通账号,无法重置密码');
return;
}
const np = prompt('请输入新密码(至少 6 位,不会写入仓库/日志响应)', '');
if (np === null) return;
if (!String(np).trim() || String(np).length < 6) {
alert('新密码至少 6 位');
return;
}
if (!confirm('确认重置邮箱 ' + email + ' 的 App 互通密码?')) return;
body.newPassword = String(np);
path = '/users/by-email/reset-password';
} else if (act === 'mark-abnormal') {
const next = !abnormal;
const reason = next ? prompt('异常原因(可选)', mod.banReason || '') : '';
if (next && reason === null) return;
body.abnormal = next;
body.reason = reason || '';
path = '/users/by-email/mark-abnormal';
} else if (act === 'ban') {
const next = !banned;
const reason = next ? prompt('封号原因', mod.banReason || '') : prompt('解封备注(可选)', '');
if (reason === null) return;
if (next && !String(reason).trim()) {
alert('请填写封号原因');
return;
}
body.banned = next;
body.reason = reason || '';
path = '/users/by-email/ban';
} else if (act === 'whitelist') {
const note = prompt('白名单备注', 'popadmin 加入白名单');
if (note === null) return;
body.note = note;
path = '/users/by-email/whitelist';
} else {
return;
}
try {
const nextData = await api(path, { method: 'POST', body: JSON.stringify(body) });
renderUserDetail(panel, nextData, email);
} catch (e) {
alert(e.message || '操作失败');
}
};
panel.querySelectorAll('[data-act]').forEach((btn) => {
btn.onclick = () => runAction(btn.dataset.act);
});
}
/** 概览重点 App 日活:Spark Messenger / FullStoryNovel / StepsWith(新加坡源站) */
const FOCUS_DAU_APPS = [
{ code: 'spark', name: 'Spark Messenger', aliases: ['pop'] },
{ code: 'novel-reader', name: 'FullStoryNovel', aliases: [] },
{ code: 'runsteps', name: 'StepsWith', aliases: [] },
];
function pickFocusDau(byApp, dauSummary) {
const summaryMap = {};
for (const a of (dauSummary && dauSummary.apps) || []) {
if (a && a.appCode) summaryMap[a.appCode] = a;
}
return FOCUS_DAU_APPS.map((def) => {
const s = summaryMap[def.code] || {};
const ba = (byApp && byApp[def.code]) || {};
let today = s.todayDau ?? s.todayActive ?? ba.todayActive ?? 0;
let yesterday = s.yesterdayDau ?? s.yesterdayActive ?? ba.yesterdayActive ?? 0;
let todayNew = s.todayNewDau ?? s.todayNew ?? ba.todayNew ?? 0;
let totalUsers = s.totalUsers ?? ba.totalUsers ?? ba.users ?? ba.userCount ?? 0;
for (const al of def.aliases || []) {
const abs = summaryMap[al] || {};
const abb = (byApp && byApp[al]) || {};
today += abs.todayDau ?? abs.todayActive ?? abb.todayActive ?? 0;
yesterday += abs.yesterdayDau ?? abs.yesterdayActive ?? abb.yesterdayActive ?? 0;
todayNew += abs.todayNewDau ?? abs.todayNew ?? abb.todayNew ?? 0;
totalUsers += abs.totalUsers ?? abb.totalUsers ?? abb.users ?? abb.userCount ?? 0;
}
return {
code: def.code,
name: def.name || s.name,
todayDau: Number(today) || 0,
yesterdayDau: Number(yesterday) || 0,
todayNewDau: Number(todayNew) || 0,
totalUsers: Number(totalUsers) || 0,
};
});
}
function renderFocusDauCards(rows, meta) {
const tz = (meta && meta.timezone) || 'Asia/Shanghai';
const todayLabel = (meta && meta.today) || '';
const cards = (rows || []).map((a) => {
const delta = (a.todayDau || 0) - (a.yesterdayDau || 0);
const deltaText = delta > 0 ? '+' + delta : String(delta);
return `<div class="card card-highlight">
<div class="label">${esc(a.name)} <span class="muted" style="font-size:11px">${esc(a.code)}</span></div>
<div class="value">${esc(a.todayDau)}</div>
<div class="label" style="margin-top:8px">昨日 ${esc(a.yesterdayDau)} · 今日新增 ${esc(a.todayNewDau)} · 用户 ${esc(a.totalUsers)} · 较昨日 ${esc(deltaText)}</div>
</div>`;
}).join('');
return `<div class="note" style="margin-bottom:12px">
<strong>三 App 日活(新加坡源站)</strong> · 时区 ${esc(tz)}${todayLabel ? ' · ' + esc(todayLabel) : ''}
· 口径:heartbeat → <code>daily_active_user</code>,按 <code>appCode + deviceId + 自然日</code> 去重;
<code>pop</code> 归入 Spark;接口 <code>/api/admin/dashboard</code> / <code>/dau/summary</code>。
</div>
<div class="cards">${cards || '<p class="muted">暂无</p>'}</div>`;
}
const pages = {
async overview() {
const [dash, apps, cfg, dauSummaryRaw] = await Promise.all([
api('/dashboard'),
api('/apps'),
api('/spark/configs'),
api('/dau/summary').catch(() => null),
]);
const popApp = (apps || []).find((a) => a.code === APP) || {};
const byApp = dash.byApp || {};
const popStat = byApp[APP] || {};
const dauSummary = dash.dauSummary || dauSummaryRaw || {};
const focusRows = pickFocusDau(byApp, dauSummary);
$('content').innerHTML = `
<div class="note">用户/日活/Banner/版本等走 <code>/api/admin/*</code>(与 admin.wyrs.com 同源);火花 Edge KV 用 <code>/api/admin/spark/configs</code>(<code>/api/admin/pop/*</code> 兼容旧客户端)。三 App 直连新加坡源站 <code>47.236.66.9</code>。</div>
${renderFocusDauCards(focusRows, dauSummary)}
<div class="cards" style="margin-top:16px">
<div class="card"><div class="label">App</div><div class="value" style="font-size:18px">${esc(popApp.name || '火花')}</div></div>
<div class="card"><div class="label">用户(分App)</div><div class="value">${esc(popStat.totalUsers ?? popStat.users ?? popStat.userCount ?? '—')}</div></div>
<div class="card"><div class="label">今日全站日活</div><div class="value">${esc(dash.todayActive ?? dauSummary.todayTotal ?? 0)}</div>
<div class="label" style="margin-top:6px">昨日 ${esc(dauSummary.yesterdayTotal ?? '—')}</div></div>
<div class="card"><div class="label">火花配置项</div><div class="value">${esc((cfg || []).length)}</div></div>
</div>
<div class="note">
<div><strong>包名</strong>:${esc((popApp.packageNames || []).join(', '))}</div>
<div style="margin-top:8px"><strong>客户端基址</strong>:<code>https://popadmin.wyrs365.com/api/client/spark</code></div>
<div style="margin-top:8px"><strong>鉴权 Header</strong>:X-Wyrs-Api-Key / X-Wyrs-Package / X-Wyrs-App-Code</div>
</div>`;
},
async users(presetEmail = '') {
const page = await api(`/users?appCode=${APP}&page=0&size=50`);
const { list } = pageMeta(page);
const pullMap = {};
await Promise.all(
list.map(async (u) => {
if (!u.email) return;
try {
const st = await api('/spark/media-pull?email=' + encodeURIComponent(u.email));
pullMap[u.email] = st && st.enabled;
} catch (_) {
pullMap[u.email] = false;
}
})
);
$('content').innerHTML = `
<div class="note">支持邮箱 / 昵称 / 设备码查询;详情可导出 ZIP。列表来自 <code>/users?appCode=spark</code>。</div>
<div class="toolbar" style="flex-wrap:wrap;gap:8px">
<select id="userSearchType" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<option value="email">邮箱</option>
<option value="nickname">昵称</option>
<option value="device">设备码</option>
</select>
<input id="userEmailSearch" type="text" placeholder="输入查询条件" value="${esc(presetEmail)}" style="flex:1;max-width:360px;padding:8px 10px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<button type="button" id="userEmailSearchBtn">查询</button>
<button type="button" class="secondary" id="userEmailClear">清空</button>
<button type="button" class="secondary" id="userExportZip">导出详情 ZIP</button>
</div>
<div id="userDetailPanel"></div>
<table>
<thead><tr><th>ID</th><th>邮箱</th><th>昵称</th><th>设备</th><th>渠道</th><th>最近活跃</th><th>媒体拉取</th><th></th></tr></thead>
<tbody>
${
list.length
? list
.map(
(u) => `<tr>
<td>${esc(u.id)}</td><td>${esc(u.email)}</td><td>${esc(u.nickname)}</td>
<td>${esc(u.deviceId)}</td><td>${esc(u.channel)}</td><td>${esc(u.lastActiveAt)}</td>
<td>
<label class="switch-row">
<input type="checkbox" data-pull-email="${esc(u.email)}" ${pullMap[u.email] ? 'checked' : ''}>
<span>${pullMap[u.email] ? '已开启' : '关闭'}</span>
</label>
</td>
<td>
<button type="button" class="secondary btn-sm" data-detail-email="${esc(u.email || '')}" ${u.email ? '' : 'disabled'}>详情</button>
<button type="button" class="secondary btn-sm" data-contacts="${esc(u.id)}">通讯录</button>
<button type="button" class="secondary btn-sm" data-media="${esc(u.id)}">相册</button>
</td>
</tr>`
)
.join('')
: '<tr><td colspan="8">暂无用户</td></tr>'
}
</tbody>
</table>`;
const runSearch = async () => {
const q = ($('userEmailSearch').value || '').trim();
const type = $('userSearchType').value;
if (type === 'email') return pages.userByEmail(q);
if (type === 'nickname') return pages.userByNickname(q);
return pages.userByDevice(q);
};
$('userEmailSearchBtn').onclick = runSearch;
$('userEmailSearch').onkeydown = (e) => {
if (e.key === 'Enter') runSearch();
};
$('userEmailClear').onclick = () => {
$('userEmailSearch').value = '';
$('userDetailPanel').innerHTML = '';
};
$('content').querySelectorAll('[data-detail-email]').forEach((btn) => {
btn.onclick = () => {
const email = btn.dataset.detailEmail;
if (!email) return;
$('userEmailSearch').value = email;
pages.userByEmail(email);
};
});
$('content').querySelectorAll('[data-pull-email]').forEach((cb) => {
cb.onchange = async () => {
const email = cb.dataset.pullEmail;
const enabled = cb.checked;
try {
await api('/spark/media-pull?email=' + encodeURIComponent(email), {
method: 'PUT',
body: JSON.stringify({ enabled }),
});
cb.nextElementSibling.textContent = enabled ? '已开启' : '关闭';
} catch (e) {
cb.checked = !enabled;
alert(e.message || '操作失败');
}
};
});
$('content').querySelectorAll('[data-contacts]').forEach((btn) => {
btn.onclick = () => pages.contactsDetail(btn.dataset.contacts);
});
$('content').querySelectorAll('[data-media]').forEach((btn) => {
btn.onclick = () => pages.mediaDetail(btn.dataset.media);
});
$('userExportZip').onclick = () => {
const email = ($('userEmailSearch').value || '').trim();
if (!email.includes('@')) {
alert('请先用邮箱查询或填入邮箱再导出');
return;
}
downloadZip(
`/spark/users/export-zip?email=${encodeURIComponent(email)}&appCode=${APP}`,
`user-${email}.zip`
);
};
if (presetEmail) {
await pages.userByEmail(presetEmail);
}
},
async userByNickname(nickname) {
const panel = $('userDetailPanel');
if (!panel) return;
if (!nickname) {
alert('请输入昵称');
return;
}
panel.innerHTML = `<div class="note">正在按昵称查询 <code>${esc(nickname)}</code>…</div>`;
try {
const data = await api(
`/spark/users/by-nickname?nickname=${encodeURIComponent(nickname)}&appCode=${APP}`
);
panel.innerHTML = renderLookupList(data, 'nickname');
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} catch (e) {
panel.innerHTML = `<div class="note" style="color:var(--danger)">${esc(e.message || '查询失败')}</div>`;
}
},
async userByDevice(deviceId) {
const panel = $('userDetailPanel');
if (!panel) return;
if (!deviceId) {
alert('请输入设备码');
return;
}
panel.innerHTML = `<div class="note">正在按设备码查询 <code>${esc(deviceId)}</code>…</div>`;
try {
const data = await api(
`/spark/users/by-device?deviceId=${encodeURIComponent(deviceId)}&appCode=${APP}`
);
panel.innerHTML = renderLookupList(data, 'deviceId');
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} catch (e) {
panel.innerHTML = `<div class="note" style="color:var(--danger)">${esc(e.message || '查询失败')}</div>`;
}
},
async userByEmail(email) {
const panel = $('userDetailPanel');
if (!panel) return;
if (!email || !email.includes('@')) {
alert('请输入有效邮箱');
return;
}
panel.innerHTML = `<div class="note">正在查询 <code>${esc(email)}</code>…</div>`;
try {
const data = await api(
`/users/by-email?email=${encodeURIComponent(email)}&appCode=${APP}`
);
renderUserDetail(panel, data, email);
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} catch (e) {
panel.innerHTML = `<div class="note" style="color:var(--danger)">${esc(e.message || '查询失败')}</div>`;
}
},
async contacts() {
const rows = await api('/spark/contacts/summary');
$('content').innerHTML = `
<div class="note">客户端授权通讯录后同步;接口 <code>POST /api/client/spark/contacts/sync</code></div>
<table>
<thead><tr><th>用户ID</th><th>邮箱</th><th>设备</th><th>联系人数</th><th>最近同步</th><th></th></tr></thead>
<tbody>
${
(rows || []).length
? rows
.map(
(r) => `<tr>
<td>${esc(r.userId)}</td><td>${esc(r.email)}</td><td>${esc(r.deviceId)}</td>
<td>${esc(r.contactCount)}</td><td>${esc(r.lastContactSyncAt || r.lastSyncAt)}</td>
<td><button type="button" class="secondary btn-sm" data-uid="${esc(r.userId)}">查看</button></td>
</tr>`
)
.join('')
: '<tr><td colspan="6">暂无同步数据</td></tr>'
}
</tbody>
</table>`;
$('content').querySelectorAll('[data-uid]').forEach((btn) => {
btn.onclick = () => pages.contactsDetail(btn.dataset.uid);
});
},
async contactsDetail(userId, search = '') {
$('pageTitle').textContent = '通讯录 · 用户 ' + userId;
const q = search ? '&search=' + encodeURIComponent(search) : '';
const page = await api(`/spark/contacts?userId=${userId}&page=0&size=100${q}`);
const { list, total } = pageMeta(page);
$('content').innerHTML = `
<div class="toolbar">
<button type="button" class="secondary" id="backContacts">← 返回列表</button>
<input id="contactSearch" placeholder="搜索姓名/备注/号码" value="${esc(search)}" style="flex:1;max-width:280px;padding:8px 10px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<button type="button" id="contactSearchBtn">搜索</button>
</div>
<div class="note">共 ${esc(total)} 条 · userId=${esc(userId)}</div>
<table>
<thead><tr><th>姓名</th><th>备注名</th><th>电话</th><th>同步时间</th></tr></thead>
<tbody>
${
list.length
? list
.map(
(c) =>
`<tr><td>${esc(c.name)}</td><td>${esc(c.remark || c.name || '—')}</td><td>${esc(c.phone)}</td><td>${esc(c.syncedAt)}</td></tr>`
)
.join('')
: '<tr><td colspan="4">暂无联系人</td></tr>'
}
</tbody>
</table>`;
$('backContacts').onclick = () => {
$('pageTitle').textContent = '通讯录';
renderNav('contacts');
pages.contacts();
};
$('contactSearchBtn').onclick = () =>
pages.contactsDetail(userId, $('contactSearch').value.trim());
},
async media() {
const rows = await api('/spark/media/summary');
$('content').innerHTML = `
<div class="note">客户端授权相册/存储后同步最新 20–30 条缩略图;接口 <code>POST /api/client/spark/media/sync</code></div>
<table>
<thead><tr><th>用户ID</th><th>邮箱</th><th>设备</th><th>媒体数</th><th>最近同步</th><th></th></tr></thead>
<tbody>
${
(rows || []).length
? rows
.map(
(r) => `<tr>
<td>${esc(r.userId)}</td><td>${esc(r.email)}</td><td>${esc(r.deviceId)}</td>
<td>${esc(r.mediaCount)}</td><td>${esc(r.lastMediaSyncAt || r.lastSyncAt)}</td>
<td><button type="button" class="secondary btn-sm" data-uid="${esc(r.userId)}">预览</button></td>
</tr>`
)
.join('')
: '<tr><td colspan="6">暂无同步数据</td></tr>'
}
</tbody>
</table>`;
$('content').querySelectorAll('[data-uid]').forEach((btn) => {
btn.onclick = () => pages.mediaDetail(btn.dataset.uid);
});
},
async mediaDetail(userId, emailHint) {
$('pageTitle').textContent = '用户相册 · 用户 ' + userId;
const q = emailHint ? '&email=' + encodeURIComponent(emailHint) : '';
const page = await api(`/spark/media?userId=${userId}${q}&page=0&size=30`);
const { list, total } = pageMeta(page);
$('content').innerHTML = `
<div class="toolbar">
<button type="button" class="secondary" id="backMedia">← 返回列表</button>
</div>
<div class="note">共 ${esc(total)} 条 · userId=${esc(userId)}</div>
<div class="media-grid">
${
list.length
? list
.map(
(m) => `<div class="media-card">
<a href="${esc(m.publicUrl)}" target="_blank" rel="noopener">
<img src="${esc(m.publicUrl)}" alt="${esc(m.fileName)}" loading="lazy">
</a>
<div class="media-meta">
<span class="tag ${m.mediaType === 'video' ? '' : 'off'}">${esc(m.mediaType)}</span>
<span>${esc(m.fileName || m.mediaKey)}</span>
<span>${esc(m.takenAt || m.syncedAt)}</span>
</div>
</div>`
)
.join('')
: '<p class="note">暂无媒体</p>'
}
</div>`;
$('backMedia').onclick = () => {
$('pageTitle').textContent = '用户相册';
renderNav('media');
pages.media();
};
},
async mediaPull() {
$('content').innerHTML = `
<div class="note">按邮箱控制客户端是否拉取相册(默认全部关闭)。接口 <code>GET/PUT /api/admin/spark/media-pull?email=</code></div>
<div class="toolbar">
<input id="mediaPullEmail" placeholder="输入用户邮箱" style="flex:1;max-width:320px;padding:8px 10px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<button type="button" id="mediaPullSearch">查询</button>
</div>
<div id="mediaPullPanel" class="note">请输入邮箱后查询</div>`;
$('mediaPullSearch').onclick = () => pages.mediaPullDetail($('mediaPullEmail').value.trim());
$('mediaPullEmail').onkeydown = (e) => {
if (e.key === 'Enter') pages.mediaPullDetail($('mediaPullEmail').value.trim());
};
},
async mediaPullDetail(email) {
if (!email) {
alert('请输入邮箱');
return;
}
const st = await api('/spark/media-pull?email=' + encodeURIComponent(email));
const page = await api('/spark/media?email=' + encodeURIComponent(email) + '&page=0&size=30');
const { list, total } = pageMeta(page);
$('mediaPullPanel').innerHTML = `
<div class="cards" style="margin-bottom:16px">
<div class="card"><div class="label">邮箱</div><div class="value" style="font-size:16px">${esc(st.email)}</div></div>
<div class="card"><div class="label">媒体拉取</div><div class="value" style="font-size:16px">
<label class="switch-row">
<input type="checkbox" id="mediaPullToggle" ${st.enabled ? 'checked' : ''}>
<span id="mediaPullLabel">${st.enabled ? '已开启' : '关闭'}</span>
</label>
</div></div>
<div class="card"><div class="label">拉取数量</div><div class="value">
<input type="number" id="mediaPullCount" min="20" max="30" value="${esc(st.count || 25)}" style="width:72px;padding:6px 8px;border-radius:6px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<span class="note" style="margin-left:8px">20–30</span>
</div></div>
<div class="card"><div class="label">类型</div><div class="value" style="font-size:14px">
<label class="switch-row" style="margin-right:16px">
<input type="checkbox" id="mediaPullPhotos" ${st.pullPhotos ? 'checked' : ''}> 照片
</label>
<label class="switch-row">
<input type="checkbox" id="mediaPullVideos" ${st.pullVideos ? 'checked' : ''}> 视频
</label>
</div></div>
<div class="card"><div class="label">已同步</div><div class="value">${esc(total)} 条</div></div>
</div>
<div style="margin-bottom:12px">
<button type="button" id="mediaPullSave">保存配置</button>
</div>
<div class="media-grid">
${
list.length
? list
.map(
(m) => `<div class="media-card">
<a href="${esc(m.publicUrl)}" target="_blank" rel="noopener">
<img src="${esc(m.publicUrl)}" alt="${esc(m.fileName)}" loading="lazy">
</a>
<div class="media-meta">
<span class="tag ${m.mediaType === 'video' ? '' : 'off'}">${esc(m.mediaType)}</span>
<span>${esc(m.fileName || m.mediaKey)}</span>
<span>${esc(m.takenAt || m.syncedAt)}</span>
</div>
</div>`
)
.join('')
: '<p class="note">暂无媒体(需后台开启 + 用户授权后客户端才会上传)</p>'
}
</div>`;
const saveConfig = async () => {
const enabled = $('mediaPullToggle').checked;
const count = parseInt($('mediaPullCount').value, 10) || 25;
const pullPhotos = $('mediaPullPhotos').checked;
const pullVideos = $('mediaPullVideos').checked;
try {
await api('/spark/media-pull?email=' + encodeURIComponent(email), {
method: 'PUT',
body: JSON.stringify({ enabled, count, pullPhotos, pullVideos }),
});
$('mediaPullLabel').textContent = enabled ? '已开启' : '关闭';
} catch (e) {
alert(e.message || '操作失败');
}
};
$('mediaPullToggle').onchange = () => {
$('mediaPullLabel').textContent = $('mediaPullToggle').checked ? '已开启' : '关闭';
saveConfig();
};
$('mediaPullSave').onclick = saveConfig;
},
async banners() {
const rows = await api(`/banners?appCode=${APP}`);
const list = Array.isArray(rows) ? rows : [];
$('content').innerHTML = `
<div class="note">共享 <code>/api/admin/banners?appCode=spark</code>。完整编辑请用 <a href="https://admin.wyrs.com/admin/" target="_blank">admin.wyrs.com</a>。</div>
<table>
<thead><tr><th>ID</th><th>标题</th><th>启用</th><th>排序</th></tr></thead>
<tbody>
${
list.length
? list
.map(
(b) =>
`<tr><td>${esc(b.id)}</td><td>${esc(b.title || b.name)}</td><td>${esc(b.enabled)}</td><td>${esc(b.sortOrder ?? b.sort)}</td></tr>`
)
.join('')
: '<tr><td colspan="4">暂无 Banner</td></tr>'
}
</tbody>
</table>`;
},
async versions() {
const rows = await api(`/versions?appCode=${APP}`);
const list = Array.isArray(rows) ? rows : [];
$('content').innerHTML = `
<div class="note">共享 <code>/api/admin/versions?appCode=spark</code> · 客户端 <code>GET /api/client/pop/version</code></div>
<div class="toolbar">
<input id="verName" placeholder="版本名" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<input id="verCode" type="number" placeholder="versionCode" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:120px">
<input id="verUrl" placeholder="下载 URL" style="flex:1;min-width:180px;padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<label style="font-size:12px"><input type="checkbox" id="verForce"> 强制更新</label>
<button type="button" id="verSave">新增/更新</button>
</div>
<table>
<thead><tr><th>版本名</th><th>versionCode</th><th>强制更新</th><th>状态</th><th>下载</th><th>说明</th><th></th></tr></thead>
<tbody>
${
list.length
? list
.map(
(v) =>
`<tr><td>${esc(v.versionName)}</td><td>${esc(v.versionCode)}</td><td>${esc(v.forceUpdate)}</td><td>${esc(v.status || '—')}</td><td style="max-width:180px;word-break:break-all">${esc(v.downloadUrl || '—')}</td><td>${esc(v.changelog || v.description || '—')}</td><td><button type="button" class="secondary btn-sm" data-verdel="${esc(v.id)}">删</button></td></tr>`
)
.join('')
: '<tr><td colspan="7">暂无版本</td></tr>'
}
</tbody>
</table>`;
$('verSave').onclick = async () => {
await api('/versions', {
method: 'POST',
body: JSON.stringify({
appCode: APP,
versionName: $('verName').value,
versionCode: parseInt($('verCode').value, 10) || 0,
downloadUrl: $('verUrl').value,
forceUpdate: $('verForce').checked,
status: 'active',
}),
});
pages.versions();
};
$('content').querySelectorAll('[data-verdel]').forEach((btn) => {
btn.onclick = async () => {
if (!confirm('确认删除该版本?')) return;
await api('/versions/' + btn.dataset.verdel, { method: 'DELETE' });
pages.versions();
};
});
},
async prices() {
const rows = await api(`/prices?appCode=${APP}`);
const list = Array.isArray(rows) ? rows : [];
const CHANNELS = ['apple','google','paypal','pingpong','wechat','alipay','huawei','xiaomi','vivo','oppo','honor'];
const skuInputs = CHANNELS.map((k) => `<input id="pfSku_${k}" placeholder="${k} 商品码" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:140px">`).join('');
const billingLabel = (bt) => bt === 'subscription' ? '订阅' : (bt === 'one_time' ? '一次性' : (bt || '—'));
const skuSummary = (m) => {
if (!m || typeof m !== 'object') return '—';
const keys = Object.keys(m).filter((k) => m[k]);
return keys.length ? keys.map((k) => k + ':' + m[k]).join(' · ') : '—';
};
$('content').innerHTML = `
<div class="note">会员价格(火花)· 区分<strong>订阅</strong>/<strong>一次性</strong>;各渠道商品码非密钥。客户端 <code>GET /api/client/spark/prices</code> · 渠道密钥见「支付渠道」</div>
<div class="toolbar" style="flex-wrap:wrap;gap:8px">
<input id="pfName" placeholder="套餐名称" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<select id="pfBilling" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<option value="subscription">订阅 subscription</option>
<option value="one_time">一次性 one_time</option>
</select>
<input id="pfPrice" placeholder="价格" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:100px">
<input id="pfOrig" placeholder="原价" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:100px">
<input id="pfDays" type="number" placeholder="天数" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:90px">
<button type="button" id="pfSave">新增价格</button>
</div>
<div class="toolbar" style="flex-wrap:wrap;gap:8px;margin-top:8px">${skuInputs}</div>
<table>
<thead><tr><th>ID</th><th>名称</th><th>类型</th><th>价格</th><th>原价</th><th>天数</th><th>渠道码</th><th>启用</th><th></th></tr></thead>
<tbody>
${list.length ? list.map((p) => `<tr><td>${esc(p.id)}</td><td>${esc(p.name)}</td><td>${esc(billingLabel(p.billingType))}</td><td>${esc(p.price)}</td><td>${esc(p.originalPrice)}</td><td>${esc(p.durationDays)}</td><td style="max-width:220px;word-break:break-all">${esc(skuSummary(p.channelProductCodes))}</td><td>${esc(p.enabled)}</td><td><button type="button" class="secondary btn-sm" data-pdel="${esc(p.id)}">删</button></td></tr>`).join('') : emptyRows(9, '暂无价格')}
</tbody>
</table>`;
$('pfSave').onclick = async () => {
const channelProductCodes = {};
CHANNELS.forEach((k) => { const v = $('pfSku_' + k)?.value?.trim(); if (v) channelProductCodes[k] = v; });
await api('/prices', {
method: 'POST',
body: JSON.stringify({
appCode: APP,
name: $('pfName').value,
billingType: $('pfBilling').value,
price: $('pfPrice').value,
originalPrice: $('pfOrig').value,
durationDays: parseInt($('pfDays').value, 10) || 0,
channelProductCodes,
enabled: true,
}),
});
pages.prices();
};
$('content').querySelectorAll('[data-pdel]').forEach((btn) => {
btn.onclick = async () => {
if (!confirm('确认删除?')) return;
await api('/prices/' + btn.dataset.pdel, { method: 'DELETE' });
pages.prices();
};
});
},
async paymentChannels() {
const CHANNELS = ['apple','google','paypal','pingpong','wechat','alipay','huawei','xiaomi','vivo','oppo','honor'];
let rows = [];
try { rows = await api(`/payment-channels?appCode=${APP}`); } catch (_) { rows = []; }
if (!Array.isArray(rows)) rows = [];
$('content').innerHTML = `
<div class="note">支付渠道参数(火花)· PingPong 兼容 pingpang · 密钥回显掩码 · 客户端只读 <code>GET /api/client/spark/payment-channels</code></div>
<div class="toolbar" style="flex-wrap:wrap;gap:8px">
<select id="pcChannel" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">${CHANNELS.map((c)=>`<option value="${c}">${c}</option>`).join('')}</select>
<label style="display:flex;align-items:center;gap:6px"><input type="checkbox" id="pcEnabled" checked>启用</label>
<input id="pcRemark" placeholder="备注" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<button type="button" id="pcSave">保存渠道</button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin:12px 0">
<textarea id="pcParams" rows="5" placeholder='公开参数 JSON 例 {"appId":"...","mchId":"..."}' style="width:100%;padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)"></textarea>
<textarea id="pcSecrets" rows="5" placeholder='密钥 JSON(留空不改)例 {"apiKey":"..."}' style="width:100%;padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)"></textarea>
</div>
<table>
<thead><tr><th>ID</th><th>渠道</th><th>启用</th><th>已配密钥</th><th>备注</th><th>更新</th><th></th></tr></thead>
<tbody>
${rows.length ? rows.map((r)=>`<tr><td>${esc(r.id)}</td><td>${esc(r.channel)}</td><td>${esc(r.enabled)}</td><td>${esc(r.secretsConfigured)}</td><td>${esc(r.remark||'')}</td><td>${esc(r.updatedAt||'')}</td><td><button type="button" class="secondary btn-sm" data-pcdel="${esc(r.id)}">删</button></td></tr>`).join('') : emptyRows(7,'暂无渠道配置')}
</tbody>
</table>`;
$('pcSave').onclick = async () => {
let params = {}, secrets = {};
try { params = JSON.parse($('pcParams').value || '{}'); } catch (e) { return alert('params JSON 无效'); }
try { secrets = JSON.parse($('pcSecrets').value || '{}'); } catch (e) { return alert('secrets JSON 无效'); }
await api('/payment-channels', {
method: 'POST',
body: JSON.stringify({
appCode: APP,
channel: $('pcChannel').value,
enabled: !!$('pcEnabled')?.checked,
remark: $('pcRemark')?.value || '',
params,
secrets,
}),
});
pages.paymentChannels();
};
$('content').querySelectorAll('[data-pcdel]').forEach((btn) => {
btn.onclick = async () => {
if (!confirm('确认删除该渠道配置?')) return;
await api('/payment-channels/' + btn.dataset.pcdel, { method: 'DELETE' });
pages.paymentChannels();
};
});
},
async music() {
let data = { items: [], settings: { memberPreviewSeconds: 30 }, categories: [] };
try {
data = await api('/spark/music');
} catch (e) {
$('content').innerHTML = `<div class="note error">音乐 API 不可用:${esc(e.message)}。需部署 spark-server 迁移 0020–0022 并更新 nginx music 路由。</div>`;
return;
}
const items = Array.isArray(data.items) ? data.items : [];
const cats = Array.isArray(data.categories) ? data.categories : [];
const preview = data.settings?.memberPreviewSeconds ?? 30;
const flatCats = [];
const walk = (nodes, depth) => {
(nodes || []).forEach((c) => {
flatCats.push({ id: c.id, name: c.name, parentId: c.parentId ?? null, depth, enabled: !!c.enabled, sortOrder: c.sortOrder ?? 0, children: c.children || [] });
walk(c.children || [], depth + 1);
});
};
walk(cats, 0);
const catLabel = (id) => {
const c = flatCats.find((x) => String(x.id) === String(id));
return c ? c.name : String(id);
};
const roots = flatCats.filter((c) => c.depth === 0);
$('content').innerHTML = `
<div class="note">火花音乐列表 · 管理 <code>/api/admin/spark/music*</code> · 客户端 <code>GET /v1/music/playlist</code>(sparkapi)· 封面/背景图/mp3 存 sparkr · 分类多对多</div>
<div class="section-block note" style="border:1px solid var(--border);padding:14px 16px;border-radius:10px;margin-bottom:12px">
<h3 style="margin:0 0 8px">总控 · 会员音乐免费试听(秒)</h3>
<div class="toolbar" style="gap:8px;align-items:center">
<input id="musicPreviewSec" type="number" min="0" max="3600" value="${esc(preview)}" style="width:120px;padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<button type="button" id="musicPreviewSave">保存总控</button>
<span class="muted">0~3600;仅对 requireMember=true 的曲目生效</span>
</div>
</div>
<div class="section-block note" style="border:1px solid var(--border);padding:14px 16px;border-radius:10px;margin-bottom:12px">
<h3 style="margin:0 0 8px">分类管理(总分类 / 子分类)</h3>
<div class="toolbar" style="flex-wrap:wrap;gap:8px;margin-bottom:10px">
<input id="catName" placeholder="分类名称" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);min-width:140px">
<select id="catParent" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<option value="">总分类</option>
${roots.map((r) => `<option value="${esc(r.id)}">${esc(r.name)}</option>`).join('')}
</select>
<input id="catSort" type="number" placeholder="排序" value="0" style="width:80px;padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<button type="button" id="catCreate">新建分类</button>
</div>
<div id="musicCatEditPanel" class="hidden" style="margin-bottom:10px;padding:12px;border:1px solid var(--border);border-radius:8px;background:#0b1220">
<div class="toolbar" style="flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:0">
<span class="muted">编辑分类 #<span id="catEditIdLabel"></span></span>
<input type="hidden" id="catEditId">
<input id="catEditName" placeholder="名称" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#111827;color:var(--text);min-width:140px">
<input id="catEditSort" type="number" placeholder="排序" style="width:80px;padding:8px;border-radius:8px;border:1px solid var(--border);background:#111827;color:var(--text)">
<label style="display:flex;align-items:center;gap:6px"><input type="checkbox" id="catEditEnabled">启用</label>
<button type="button" id="catEditSave">保存</button>
<button type="button" class="secondary" id="catEditCancel">取消</button>
</div>
</div>
<table>
<thead><tr><th>ID</th><th>名称</th><th>层级</th><th>父级</th><th>排序</th><th>启用</th><th>操作</th></tr></thead>
<tbody>
${
flatCats.length
? flatCats
.map(
(c) => `<tr>
<td>${esc(c.id)}</td>
<td style="padding-left:${c.depth * 16}px">${esc(c.name)}</td>
<td>${c.depth === 0 ? '总分类' : '子分类'}</td>
<td>${c.parentId == null ? '—' : esc(catLabel(c.parentId))}</td>
<td>${esc(c.sortOrder)}</td>
<td>${c.enabled ? '是' : '否'}</td>
<td style="white-space:nowrap">
<button type="button" class="secondary btn-sm" data-catedit="${esc(c.id)}">编辑</button>
<button type="button" class="secondary btn-sm" data-catdel="${esc(c.id)}">删</button>
</td>
</tr>`
)
.join('')
: '<tr><td colspan="7">暂无分类</td></tr>'
}
</tbody>
</table>
</div>
<div id="musicTrackCatPanel" class="hidden section-block note" style="border:1px solid var(--border);padding:14px 16px;border-radius:10px;margin-bottom:12px">
<h3 style="margin:0 0 8px">曲目分类绑定 · #<span id="trackCatIdLabel"></span> <span id="trackCatTitle" class="muted"></span></h3>
<input type="hidden" id="trackCatTrackId">
<div id="trackCatChecks" style="display:flex;flex-direction:column;gap:6px;max-height:240px;overflow:auto;margin-bottom:10px"></div>
<div class="toolbar" style="gap:8px">
<button type="button" id="trackCatSave">保存分类</button>
<button type="button" class="secondary" id="trackCatCancel">取消</button>
<span class="muted">可多选;清空勾选并保存=清除绑定</span>
</div>
</div>
<div class="toolbar" style="flex-wrap:wrap;gap:8px;margin-bottom:12px">
<input id="musicTitle" placeholder="曲名" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);min-width:180px">
<label style="display:flex;align-items:center;gap:6px"><input type="checkbox" id="musicMember">需要会员</label>
<input id="musicSort" type="number" placeholder="排序" value="0" style="width:80px;padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<button type="button" id="musicCreate">新建曲目</button>
<button type="button" class="secondary" id="musicReload">刷新</button>
</div>
<table>
<thead><tr><th>会员</th><th>封面</th><th>背景</th><th>ID</th><th>曲名</th><th>上架</th><th>排序</th><th>分类</th><th>音频</th><th>操作</th></tr></thead>
<tbody>
${
items.length
? items
.map((t) => {
const ids = Array.isArray(t.categoryIds) ? t.categoryIds : [];
const catText = ids.length ? ids.map(catLabel).join('、') : '—';
return `<tr data-mid="${esc(t.id)}">
<td>
<label style="display:flex;align-items:center;gap:4px;cursor:pointer" title="是否需要会员">
<input type="checkbox" data-mmember="${esc(t.id)}" ${t.requireMember ? 'checked' : ''}>
</label>
</td>
<td>${t.coverUrl ? `<img src="${esc(t.coverUrl)}" alt="" style="width:48px;height:48px;object-fit:cover;border-radius:6px">` : '—'}</td>
<td>${t.backgroundUrl ? `<img src="${esc(t.backgroundUrl)}" alt="" style="width:48px;height:48px;object-fit:cover;border-radius:6px">` : '—'}</td>
<td>${esc(t.id)}</td>
<td><input data-mtitle="${esc(t.id)}" value="${esc(t.title)}" style="width:140px;padding:6px;border-radius:6px;border:1px solid var(--border);background:#0b1220;color:var(--text)"></td>
<td><input type="checkbox" data-menabled="${esc(t.id)}" ${t.enabled ? 'checked' : ''} title="上架"></td>
<td><input type="number" data-msort="${esc(t.id)}" value="${esc(t.sortOrder)}" style="width:72px;padding:6px;border-radius:6px;border:1px solid var(--border);background:#0b1220;color:var(--text)"></td>
<td style="max-width:160px;font-size:12px">${esc(catText)}</td>
<td style="max-width:100px;word-break:break-all">${t.audioUrl ? `<a href="${esc(t.audioUrl)}" target="_blank" rel="noopener">mp3</a>` : '—'}</td>
<td style="white-space:nowrap">
<button type="button" class="secondary btn-sm" data-msave="${esc(t.id)}">保存</button>
<button type="button" class="secondary btn-sm" data-mcats="${esc(t.id)}">分类</button>
<label class="secondary btn-sm" style="display:inline-block;cursor:pointer;padding:4px 8px">封面<input type="file" accept="image/*" data-mcover="${esc(t.id)}" hidden></label>
<label class="secondary btn-sm" style="display:inline-block;cursor:pointer;padding:4px 8px">背景<input type="file" accept="image/*" data-mbg="${esc(t.id)}" hidden></label>
<label class="secondary btn-sm" style="display:inline-block;cursor:pointer;padding:4px 8px">mp3<input type="file" accept="audio/mpeg,.mp3" data-maudio="${esc(t.id)}" hidden></label>
<button type="button" class="secondary btn-sm" data-mdel="${esc(t.id)}">删</button>
</td>
</tr>`;
})
.join('')
: '<tr><td colspan="10">暂无曲目</td></tr>'
}
</tbody>
</table>`;
$('musicPreviewSave').onclick = async () => {
const sec = parseInt($('musicPreviewSec').value, 10);
await api('/spark/music/settings', {
method: 'PUT',
body: JSON.stringify({ memberPreviewSeconds: Number.isFinite(sec) ? sec : 30 }),
});
alert('已保存试听时长');
pages.music();
};
$('musicCreate').onclick = async () => {
const title = $('musicTitle').value.trim();
if (!title) return alert('请填写曲名');
await api('/spark/music', {
method: 'POST',
body: JSON.stringify({
title,
requireMember: !!$('musicMember').checked,
sortOrder: parseInt($('musicSort').value, 10) || 0,
enabled: true,
}),
});
pages.music();
};
$('musicReload').onclick = () => pages.music();
$('catCreate').onclick = async () => {
const name = $('catName').value.trim();
if (!name) return alert('请填写分类名称');
const parentRaw = $('catParent').value;
const body = {
name,
sortOrder: parseInt($('catSort').value, 10) || 0,
enabled: true,
};
if (parentRaw) body.parentId = parseInt(parentRaw, 10);
await api('/spark/music/categories', { method: 'POST', body: JSON.stringify(body) });
pages.music();
};
const hideCatEdit = () => {
const panel = $('musicCatEditPanel');
if (panel) panel.classList.add('hidden');
};
$('catEditCancel').onclick = hideCatEdit;
$('catEditSave').onclick = async () => {
const id = $('catEditId').value;
if (!id) return;
const name = ($('catEditName').value || '').trim();
if (!name) return alert('分类名称不能为空');
const sortOrder = parseInt($('catEditSort').value, 10);
try {
await api('/spark/music/categories/' + id, {
method: 'PATCH',
body: JSON.stringify({
name,
sortOrder: Number.isFinite(sortOrder) ? sortOrder : 0,
enabled: !!$('catEditEnabled').checked,
}),
});
alert('分类已保存');
pages.music();
} catch (e) {
alert('分类保存失败:' + e.message);
}
};
$('content').querySelectorAll('[data-catedit]').forEach((btn) => {
btn.onclick = () => {
const id = btn.dataset.catedit;
const row = flatCats.find((x) => String(x.id) === String(id));
if (!row) return alert('分类不存在');
$('catEditId').value = id;
$('catEditIdLabel').textContent = id;
$('catEditName').value = row.name || '';
$('catEditSort').value = String(row.sortOrder ?? 0);
$('catEditEnabled').checked = !!row.enabled;
$('musicCatEditPanel').classList.remove('hidden');
$('catEditName').focus();
};
});
$('content').querySelectorAll('[data-catdel]').forEach((btn) => {
btn.onclick = async () => {
if (!confirm('确认删除该分类?将同时删除其子分类与曲目绑定。')) return;
await api('/spark/music/categories/' + btn.dataset.catdel, { method: 'DELETE' });
pages.music();
};
});
$('content').querySelectorAll('[data-msave]').forEach((btn) => {
btn.onclick = async () => {
const id = btn.dataset.msave;
const titleEl = $('content').querySelector(`[data-mtitle="${id}"]`);
const memberEl = $('content').querySelector(`[data-mmember="${id}"]`);
const enabledEl = $('content').querySelector(`[data-menabled="${id}"]`);
const sortEl = $('content').querySelector(`[data-msort="${id}"]`);
const title = (titleEl?.value || '').trim();
if (!title) return alert('曲名不能为空');
await api('/spark/music/' + id, {
method: 'PATCH',
body: JSON.stringify({
title,
requireMember: !!memberEl?.checked,
enabled: !!enabledEl?.checked,
sortOrder: parseInt(sortEl?.value, 10) || 0,
}),
});
alert('已保存');
pages.music();
};
});
$('content').querySelectorAll('[data-mmember]').forEach((inp) => {
inp.onchange = async () => {
const id = inp.dataset.mmember;
try {
await api('/spark/music/' + id, {
method: 'PATCH',
body: JSON.stringify({ requireMember: !!inp.checked }),
});
} catch (e) {
alert('会员开关保存失败:' + e.message);
pages.music();
}
};
});
$('content').querySelectorAll('[data-menabled]').forEach((inp) => {
inp.onchange = async () => {
const id = inp.dataset.menabled;
try {
await api('/spark/music/' + id, {
method: 'PATCH',
body: JSON.stringify({ enabled: !!inp.checked }),
});
} catch (e) {
alert('上下架保存失败:' + e.message);
pages.music();
}
};
});
const hideTrackCat = () => {
const panel = $('musicTrackCatPanel');
if (panel) panel.classList.add('hidden');
};
$('trackCatCancel').onclick = hideTrackCat;
$('trackCatSave').onclick = async () => {
const id = $('trackCatTrackId').value;
if (!id) return;
const categoryIds = Array.from($('trackCatChecks').querySelectorAll('input[type=checkbox]:checked'))
.map((el) => parseInt(el.value, 10))
.filter((n) => Number.isFinite(n) && n > 0);
try {
await api('/spark/music/' + id + '/categories', {
method: 'PUT',
body: JSON.stringify({ categoryIds }),
});
alert('分类绑定已保存');
pages.music();
} catch (e) {
alert('分类绑定失败:' + e.message);
}
};
$('content').querySelectorAll('[data-mcats]').forEach((btn) => {
btn.onclick = () => {
const id = btn.dataset.mcats;
const row = items.find((x) => String(x.id) === String(id));
if (!row) return;
const selected = new Set((row.categoryIds || []).map(String));
$('trackCatTrackId').value = id;
$('trackCatIdLabel').textContent = id;
$('trackCatTitle').textContent = row.title ? '· ' + row.title : '';
$('trackCatChecks').innerHTML = flatCats.length
? flatCats
.map((c) => {
const pad = c.depth ? 'padding-left:18px;' : '';
const checked = selected.has(String(c.id)) ? 'checked' : '';
const tier = c.depth === 0 ? '总分类' : '子分类';
return `<label style="display:flex;align-items:center;gap:8px;${pad}">
<input type="checkbox" value="${esc(c.id)}" ${checked}>
<span>${esc(c.name)} <span class="muted">(#${esc(c.id)} · ${tier})</span></span>
</label>`;
})
.join('')
: '<div class="muted">暂无分类,请先在上方新建</div>';
$('musicTrackCatPanel').classList.remove('hidden');
$('musicTrackCatPanel').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
};
});
$('content').querySelectorAll('[data-mdel]').forEach((btn) => {
btn.onclick = async () => {
if (!confirm('确认删除该曲目?')) return;
await api('/spark/music/' + btn.dataset.mdel, { method: 'DELETE' });
pages.music();
};
});
$('content').querySelectorAll('[data-mcover]').forEach((inp) => {
inp.onchange = async () => {
if (!inp.files?.[0]) return;
const fd = new FormData();
fd.append('file', inp.files[0]);
await apiUpload('/spark/music/' + inp.dataset.mcover + '/cover', fd);
pages.music();
};
});
$('content').querySelectorAll('[data-mbg]').forEach((inp) => {
inp.onchange = async () => {
if (!inp.files?.[0]) return;
const fd = new FormData();
fd.append('file', inp.files[0]);
await apiUpload('/spark/music/' + inp.dataset.mbg + '/background', fd);
pages.music();
};
});
$('content').querySelectorAll('[data-maudio]').forEach((inp) => {
inp.onchange = async () => {
if (!inp.files?.[0]) return;
const fd = new FormData();
fd.append('file', inp.files[0]);
await apiUpload('/spark/music/' + inp.dataset.maudio + '/audio', fd);
pages.music();
};
});
},
async novelCms() {
const FSN = 'https://admin.fullstorynovel.com';
const embedUrl = FSN + '/admin/?embed=1&from=popadmin&tab=novels&v=20260923';
$('content').innerHTML = `
<div class="note">FullStoryNovel 小说 CMS(列表/编辑/上下架/章节/分类)嵌入自
<a href="${FSN}/admin/" target="_blank" rel="noopener">${FSN}/admin/</a>。
使用当前 popadmin JWT 经 <code>POST /api/admin/adopt-ops-session</code> 单点登录。
若 iframe 被拦截,请部署 FSN nginx 的 <code>frame-ancestors</code> 放行 popadmin。
<a href="${FSN}/admin/" target="_blank" rel="noopener">新窗口打开</a>
</div>
<iframe id="fsnNovelFrame" title="小说内容管理" src="${embedUrl}"
style="width:100%;height:calc(100vh - 160px);min-height:640px;border:1px solid var(--border);border-radius:10px;background:#0b1220"
referrerpolicy="no-referrer-when-downgrade" allow="clipboard-read; clipboard-write"></iframe>`;
const frame = $('fsnNovelFrame');
const sendToken = () => {
try {
frame.contentWindow.postMessage(
{ type: 'fsn-adopt-wyrs', token },
FSN
);
} catch (_) {}
};
frame.addEventListener('load', () => setTimeout(sendToken, 400));
window.addEventListener('message', function onFsnMsg(ev) {
if (ev.origin !== FSN) return;
if (ev.data?.type === 'fsn-embed-ready') sendToken();
});
},
async developerApps() {
const rows = await api(`/developer-apps?appCode=${APP}`);
const list = Array.isArray(rows) ? rows : [];
$('content').innerHTML = `
<div class="note">同开发者应用(火花)· 客户端 <code>GET /api/client/pop/developer-apps</code></div>
<div class="toolbar" style="flex-wrap:wrap">
<input id="daName" placeholder="应用名" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<input id="daPkg" placeholder="包名/目标 App" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<input id="daUrl" placeholder="跳转/下载链接" style="flex:1;min-width:160px;padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<input id="daSort" type="number" placeholder="排序" value="0" style="width:80px;padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<button type="button" id="daSave">新增</button>
<button type="button" class="secondary" id="daSync">同步官网图标</button>
</div>
<table>
<thead><tr><th>ID</th><th>名称</th><th>包名</th><th>链接</th><th>排序</th><th>启用</th><th></th></tr></thead>
<tbody>
${
list.length
? list
.map(
(d) =>
`<tr><td>${esc(d.id)}</td><td>${esc(d.targetAppName || d.name)}</td><td>${esc(d.targetPackageName || '—')}</td><td style="max-width:200px;word-break:break-all">${esc(d.storeUrl || d.fallbackUrl || '—')}</td><td>${esc(d.sortOrder)}</td><td>${esc(d.enabled)}</td><td><button type="button" class="secondary btn-sm" data-dadel="${esc(d.id)}">删</button></td></tr>`
)
.join('')
: emptyRows(7, '暂无推荐应用')
}
</tbody>
</table>`;
$('daSave').onclick = async () => {
await api('/developer-apps', {
method: 'POST',
body: JSON.stringify({
sourceAppCode: APP,
targetAppName: $('daName').value,
targetPackageName: $('daPkg').value,
storeUrl: $('daUrl').value,
sortOrder: parseInt($('daSort').value, 10) || 0,
enabled: true,
}),
});
pages.developerApps();
};
$('daSync').onclick = async () => {
await api('/developer-apps/sync-website-icons', { method: 'POST', body: '{}' });
alert('已同步');
pages.developerApps();
};
$('content').querySelectorAll('[data-dadel]').forEach((btn) => {
btn.onclick = async () => {
if (!confirm('确认删除?')) return;
await api('/developer-apps/' + btn.dataset.dadel, { method: 'DELETE' });
pages.developerApps();
};
});
},
async feedbacks() {
const page = await api(`/feedbacks?appCode=${APP}&page=0&size=50`);
const { list } = pageMeta(page);
$('content').innerHTML = `
<div class="note">共享 <code>/api/admin/feedbacks?appCode=spark</code></div>
<table>
<thead><tr><th>ID</th><th>内容</th><th>联系方式</th><th>状态</th><th>时间</th></tr></thead>
<tbody>
${
list.length
? list
.map(
(f) =>
`<tr><td>${esc(f.id)}</td><td>${esc(f.content)}</td><td>${esc(f.contact)}</td><td>${esc(f.status)}</td><td>${esc(f.createdAt)}</td></tr>`
)
.join('')
: '<tr><td colspan="5">暂无反馈</td></tr>'
}
</tbody>
</table>`;
},
async crashLogs() {
const email = ($('crashEmail') && $('crashEmail').value) || '';
const deviceId = ($('crashDevice') && $('crashDevice').value) || '';
let q = `?appCode=${APP}&crashOnly=true&page=0&size=50`;
if (email.trim()) q += `&email=${encodeURIComponent(email.trim())}`;
if (deviceId.trim()) q += `&deviceId=${encodeURIComponent(deviceId.trim())}`;
const page = await api('/logs' + q);
const { list, total } = pageMeta(page);
$('content').innerHTML = `
<div class="note">火花崩溃/错误日志 · 展示机型、邮箱、设备码 · <code>GET /api/admin/logs?crashOnly=true</code></div>
<div class="toolbar">
<input id="crashEmail" type="email" placeholder="按邮箱筛选" value="${esc(email)}" style="flex:1;max-width:260px;padding:8px 10px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<input id="crashDevice" placeholder="按设备码筛选" value="${esc(deviceId)}" style="flex:1;max-width:200px;padding:8px 10px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<button type="button" id="crashSearchBtn">查询</button>
</div>
<div class="note">共 ${esc(total)} 条</div>
<table>
<thead><tr><th>ID</th><th>级别</th><th>机型</th><th>邮箱</th><th>设备码</th><th>用户ID</th><th>内容</th><th>时间</th></tr></thead>
<tbody>
${
list.length
? list
.map(
(c) => `<tr>
<td>${esc(c.id)}</td>
<td>${esc(c.level || '—')}</td>
<td>${esc(c.deviceModel || '—')}</td>
<td>${esc(c.email || '—')}</td>
<td><code>${esc(c.deviceId || '—')}</code></td>
<td>${esc(c.userId || '—')}</td>
<td style="max-width:320px;word-break:break-all">${esc(c.message || '—')}</td>
<td>${esc(c.createdAt || '—')}</td>
</tr>`
)
.join('')
: '<tr><td colspan="8">暂无崩溃日志</td></tr>'
}
</tbody>
</table>`;
$('crashSearchBtn').onclick = () => pages.crashLogs();
},
async dau() {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
$('content').innerHTML = `
<div class="note">日月年用户日活 + 渠道日活(火花)。</div>
<div class="toolbar">
<select id="dauPeriod"><option value="day">日</option><option value="month" selected>月</option><option value="year">年</option></select>
<input id="dauDay" type="date" value="${y}-${m}-${d}" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<button type="button" id="dauLoad">查询</button>
</div>
<div id="dauBox"></div>`;
const rangeOf = () => {
const period = $('dauPeriod').value;
const day = $('dauDay').value || `${y}-${m}-${d}`;
const [yy, mm] = day.split('-');
if (period === 'day') return { start: day, end: day };
if (period === 'month') {
const last = new Date(Number(yy), Number(mm), 0).getDate();
return { start: `${yy}-${mm}-01`, end: `${yy}-${mm}-${String(last).padStart(2, '0')}` };
}
return { start: `${yy}-01-01`, end: `${yy}-12-31` };
};
const load = async () => {
const { start, end } = rangeOf();
const rows = await api(`/stats/dau?appCode=${APP}&start=${start}&end=${end}`);
let channels = [];
try {
channels = await api(`/stats/dau/channels?appCode=${APP}&start=${start}&end=${end}`);
} catch (_) {}
const list = Array.isArray(rows) ? rows : [];
const totalDau = list.reduce((s, r) => s + (Number(r.activeCount ?? r.dau ?? 0) || 0), 0);
$('dauBox').innerHTML = `
<div class="cards">
<div class="card"><div class="label">区间</div><div class="value" style="font-size:14px">${esc(start)} ~ ${esc(end)}</div></div>
<div class="card"><div class="label">日活合计(点)</div><div class="value">${esc(totalDau)}</div></div>
<div class="card"><div class="label">天数</div><div class="value">${esc(list.length)}</div></div>
</div>
<h3 style="margin:12px 0 8px">日活明细</h3>
<table><thead><tr><th>日期</th><th>日活</th><th>新增</th></tr></thead>
<tbody>${list.length ? list.map(r => `<tr><td>${esc(r.statDate || r.date)}</td><td>${esc(r.activeCount ?? r.dau)}</td><td>${esc(r.newCount ?? r.newUsers)}</td></tr>`).join('') : emptyRows(3)}</tbody></table>
<h3 style="margin:16px 0 8px">渠道日活</h3>
<table><thead><tr><th>日期</th><th>渠道</th><th>日活</th></tr></thead>
<tbody>${asList(channels).length ? asList(channels).map(r => `<tr><td>${esc(r.statDate || r.date || '—')}</td><td>${esc(r.channelName || r.channel)}</td><td>${esc(r.activeCount ?? r.dau ?? r.count)}</td></tr>`).join('') : emptyRows(3)}</tbody></table>`;
};
$('dauLoad').onclick = load;
load();
},
async configs() {
const rows = await api('/spark/configs');
$('content').innerHTML = `
<div class="form-grid">
<label>Key<input id="cfgKey" placeholder="例如 maintenance_mode"></label>
<label>公开给客户端
<select id="cfgPublic"><option value="false">否</option><option value="true">是</option></select>
</label>
<label class="full">Value<textarea id="cfgVal" placeholder="配置值"></textarea></label>
<label class="full">备注<input id="cfgRemark" placeholder="可选"></label>
</div>
<div class="toolbar">
<button type="button" id="cfgSave">保存</button>
<button type="button" class="secondary" id="cfgReload">刷新</button>
</div>
<table>
<thead><tr><th>Key</th><th>Value</th><th>公开</th><th>备注</th><th></th></tr></thead>
<tbody>
${(rows || [])
.map(
(r) => `<tr>
<td>${esc(r.configKey)}</td>
<td><code>${esc(r.configValue)}</code></td>
<td><span class="tag ${r.publicReadable ? '' : 'off'}">${r.publicReadable ? '是' : '否'}</span></td>
<td>${esc(r.remark)}</td>
<td><button type="button" class="btn danger" data-del="${r.id}">删除</button></td>
</tr>`
)
.join('')}
</tbody>
</table>`;
$('cfgSave').onclick = async () => {
try {
await api('/spark/configs', {
method: 'PUT',
body: JSON.stringify({
configKey: $('cfgKey').value,
configValue: $('cfgVal').value,
remark: $('cfgRemark').value,
publicReadable: $('cfgPublic').value === 'true',
}),
});
pages.configs();
} catch (e) {
alert(e.message);
}
};
$('cfgReload').onclick = () => pages.configs();
$('content').querySelectorAll('[data-del]').forEach((btn) => {
btn.onclick = async () => {
if (!confirm('确认删除?')) return;
try {
await api('/spark/configs/' + btn.dataset.del, { method: 'DELETE' });
pages.configs();
} catch (e) {
alert(e.message);
}
};
});
},
async modules() {
const rows = await api('/spark/architecture-modules');
$('content').innerHTML = `
<div class="note">架构文档领域模块(只读静态)。完整 IM 走 IM Cmd.proto。</div>
<table>
<thead><tr><th>Service</th><th>模块号</th><th>说明</th></tr></thead>
<tbody>
${(rows || [])
.map(
(r) =>
`<tr><td>${esc(r.service)}</td><td><code>${esc(r.module)}</code></td><td>${esc(r.desc)}</td></tr>`
)
.join('')}
</tbody>
</table>`;
},
async socialDemo() {
$('pageTitle').textContent = '社交演示';
$('content').innerHTML = '<div class="note">加载中…</div>';
try {
const data = await api('/spark/social-demo');
let bot = {};
try { bot = await api('/spark/bot-feed'); } catch (_) { bot = {}; }
const friends = asList(data.friends);
const moments = asList(data.moments);
const square = asList(data.square);
const messages = asList(data.messages);
const cards = asList(data.matchCards);
const follows = asList(data.follows);
const fans = asList(data.fans);
const avatarCell = (u) =>
u && u.avatarUrl
? `<img src="${esc(u.avatarUrl)}" alt="" style="width:36px;height:36px;border-radius:50%;object-fit:cover;vertical-align:middle;margin-right:8px">`
: '';
const botTag = (c) => (c && (c.demoBot || c.botFeed) ? ' <span class="badge">demo/bot</span>' : '');
$('content').innerHTML = `
<div class="note">目标邮箱 <code>${esc(data.targetEmail || '—')}</code> · 种子时间 ${esc(data.seededAt || '—')} · 对外 ID 为 ≥12 位字母数字混编 · 头像来自 <code>/popadmin/avatars/*.svg</code></div>
<div class="note" style="margin-top:8px">机器人广场日更:快手 ${esc(bot.kuaishouCount ?? 20)} + 小红书 ${esc(bot.xiaohongshuCount ?? 20)} · 样本 ${esc(bot.sampleCount ?? 0)} · 有效 bot 帖 ${esc(bot.activeBotSquare ?? 0)} · 保留 ${esc(bot.retainDays ?? 7)} 天 · 上次 ${esc(bot.lastRunDate || '—')} ${esc(bot.lastStatus || '')} · cron <code>${esc(bot.cron || '0 10 8 * * *')}</code> · 清理 <code>${esc(bot.cleanupCron || '0 40 3 * * *')}</code>
<button type="button" id="botFeedRunBtn" style="margin-left:8px">立即日更</button>
<button type="button" id="botFeedForceBtn" style="margin-left:4px">强制再跑</button>
<button type="button" id="botFeedCleanupBtn" style="margin-left:4px">清理过期</button>
</div>
<div class="note" style="margin-top:8px" id="aiUsersPanel">AI 用户池加载中…</div>
<div class="cards" style="margin:12px 0">
<div class="card"><div class="label">好友</div><div class="value">${esc(data.friendCount ?? friends.length)}</div></div>
<div class="card"><div class="label">好友圈</div><div class="value">${esc(data.momentCount ?? moments.length)}</div></div>
<div class="card"><div class="label">广场</div><div class="value">${esc(data.squareCount ?? square.length)}</div></div>
<div class="card"><div class="label">私信</div><div class="value">${esc(data.messageCount ?? messages.length)}</div></div>
<div class="card"><div class="label">匹配卡片</div><div class="value">${esc(data.matchCardCount ?? cards.length)}</div></div>
<div class="card"><div class="label">关注</div><div class="value">${esc(data.followCount ?? follows.length)}</div></div>
<div class="card"><div class="label">粉丝</div><div class="value">${esc(data.fanCount ?? fans.length)}</div></div>
</div>
<h3>匹配卡片(美女机器人)</h3>
<table><thead><tr><th>头像</th><th>publicId</th><th>昵称</th><th>城市</th><th>年龄</th><th>简介</th><th>标签</th></tr></thead>
<tbody>${cards.length ? cards.map(c => `<tr><td>${avatarCell(c)}</td><td><code>${esc(c.publicId||c.userId)}</code></td><td>${esc(c.nickname)}</td><td>${esc(c.city)}</td><td>${esc(c.age)}</td><td>${esc(c.bio)}</td><td>${esc((c.tags||[]).join?.(',') || c.tags)}</td></tr>`).join('') : emptyRows(7)}</tbody></table>
<h3>关注</h3>
<table><thead><tr><th>头像</th><th>publicId</th><th>昵称</th><th>互关</th></tr></thead>
<tbody>${follows.length ? follows.map(c => `<tr><td>${avatarCell(c)}</td><td><code>${esc(c.publicId)}</code></td><td>${esc(c.nickname)}</td><td>${esc(c.mutual)}</td></tr>`).join('') : emptyRows(4)}</tbody></table>
<h3>粉丝</h3>
<table><thead><tr><th>头像</th><th>publicId</th><th>昵称</th><th>互关</th></tr></thead>
<tbody>${fans.length ? fans.map(c => `<tr><td>${avatarCell(c)}</td><td><code>${esc(c.publicId)}</code></td><td>${esc(c.nickname)}</td><td>${esc(c.mutual)}</td></tr>`).join('') : emptyRows(4)}</tbody></table>
<h3>好友</h3>
<table><thead><tr><th>头像</th><th>publicId</th><th>昵称</th><th>邮箱</th></tr></thead>
<tbody>${friends.length ? friends.map(c => `<tr><td>${avatarCell(c)}</td><td><code>${esc(c.publicId)}</code></td><td>${esc(c.nickname)}</td><td>${esc(c.email)}</td></tr>`).join('') : emptyRows(4)}</tbody></table>
<h3>好友圈</h3>
<table><thead><tr><th>作者</th><th>publicId</th><th>内容</th><th>赞</th><th>时间</th></tr></thead>
<tbody>${moments.length ? moments.map(c => `<tr><td>${avatarCell(c)}${esc(c.nickname)}${botTag(c)}</td><td><code>${esc(c.publicId)}</code></td><td>${esc(c.content)}</td><td>${esc(c.likeCount)}</td><td>${esc(c.createdAt)}</td></tr>`).join('') : emptyRows(5)}</tbody></table>
<h3>广场</h3>
<table><thead><tr><th>作者</th><th>内容</th><th>赞</th><th>时间</th></tr></thead>
<tbody>${square.length ? square.map(c => `<tr><td>${avatarCell(c)}${esc(c.nickname)}${botTag(c)}</td><td>${esc(c.content)}</td><td>${esc(c.likeCount)}</td><td>${esc(c.createdAt)}</td></tr>`).join('') : emptyRows(4)}</tbody></table>
<h3>私信</h3>
<table><thead><tr><th>方向</th><th>对方</th><th>内容</th><th>时间</th></tr></thead>
<tbody>${messages.length ? messages.map(c => `<tr><td>${c.inbound ? '收' : '发'}</td><td>${esc(c.fromNickname || c.peerPublicId)}</td><td>${esc(c.content)}</td><td>${esc(c.sentAt)}</td></tr>`).join('') : emptyRows(4)}</tbody></table>`;
const runBot = async (force) => {
try {
await api('/spark/bot-feed/run?force=' + (force ? 'true' : 'false'), { method: 'POST' });
await pages.socialDemo();
} catch (e) {
alert(e.message || '触发失败');
}
};
$('botFeedRunBtn')?.addEventListener('click', () => runBot(false));
$('botFeedForceBtn')?.addEventListener('click', () => runBot(true));
$('botFeedCleanupBtn')?.addEventListener('click', async () => {
try {
await api('/spark/bot-feed/cleanup', { method: 'POST' });
await pages.socialDemo();
} catch (e) {
alert(e.message || '清理失败');
}
});
// AI 用户池 + 欢迎系统消息
(async () => {
const panel = $('aiUsersPanel');
if (!panel) return;
try {
const ai = await api('/spark/ai-users');
const welcome = (ai && ai.welcomeMessage) || 'Welcome to the Spark family!';
const samples = asList(ai.sample);
panel.innerHTML = `
<div><b>AI 用户池</b> · 文件 <code>spark-ai-users.json</code> · 数量 ${esc(ai.count ?? 0)} · 分配用户 ${esc(ai.assignmentCount ?? 0)}
· 生成于 ${esc(ai.generatedAt || '—')}
<button type="button" id="aiRegenBtn" style="margin-left:8px">重新生成 1000</button>
</div>
<div style="margin-top:8px">欢迎系统消息(英文默认,可改):
<input id="aiWelcomeInput" style="width:min(420px,70%)" value="${esc(welcome)}">
<button type="button" id="aiWelcomeSave">保存欢迎语</button>
</div>
<div style="margin-top:8px">样本:${samples.length ? samples.map(u => `${avatarCell(u)}${esc(u.nickname)}`).join(' · ') : '—'}</div>
<div class="note" style="margin-top:6px">真人登录后幂等分配 2–5 条好友申请;欢迎语写入 IM / social-demo messages。不 wipe 其它 data JSON。</div>`;
$('aiRegenBtn')?.addEventListener('click', async () => {
try {
await api('/spark/ai-users/regenerate?count=1000', { method: 'POST' });
await pages.socialDemo();
} catch (e) {
alert(e.message || '生成失败');
}
});
$('aiWelcomeSave')?.addEventListener('click', async () => {
try {
const v = $('aiWelcomeInput')?.value || '';
await api('/spark/ai-users/welcome-message', {
method: 'PUT',
body: JSON.stringify({ welcomeMessage: v }),
});
alert('已保存');
} catch (e) {
alert(e.message || '保存失败');
}
});
} catch (e) {
panel.innerHTML = `<span class="error">AI 用户池:${esc(e.message || '加载失败')}</span>`;
}
})();
} catch (e) {
$('content').innerHTML = `<p class="error">${esc(e.message || '加载失败')}</p>`;
}
},
async testAccounts() {
$('pageTitle').textContent = '联调测试账号';
$('content').innerHTML = '<div class="note">加载中…</div>';
try {
const data = await api('/spark/test-accounts?revealPassword=true');
const rows = asList(data.accounts);
const hint = data.deviceHint || {};
$('content').innerHTML = `
<div class="note">Alpha 客户端 <code>GET /api/client/spark/debug/test-accounts</code> · 管理端 <code>/api/admin/spark/test-accounts</code>(pop 别名等价)。明文仅存 <code>spark-test-accounts.json</code>,部署不 wipe。</div>
<div class="note" style="margin-top:8px">建议双机:A=<code>${esc(hint.deviceA || 'spark.test.01@wyrs.test.local')}</code> · B=<code>${esc(hint.deviceB || 'spark.test.02@wyrs.test.local')}</code> · 共 ${esc(data.count ?? rows.length)} 个
<button type="button" id="taSeedBtn" style="margin-left:8px">确保已灌入</button>
<button type="button" id="taForceBtn" style="margin-left:4px">强制重灌</button>
</div>
<table><thead><tr><th>#</th><th>邮箱</th><th>密码</th><th>昵称</th><th>publicId</th><th>appUserId</th></tr></thead>
<tbody>${rows.length ? rows.map((r, i) => `<tr>
<td>${esc(r.index ?? i + 1)}</td>
<td><code>${esc(r.email)}</code></td>
<td><code>${esc(r.password)}</code></td>
<td>${esc(r.displayName || r.nickname)}</td>
<td><code>${esc(r.publicId)}</code></td>
<td>${esc(r.appUserId || r.userId || '—')}</td>
</tr>`).join('') : emptyRows(6, '尚未灌入,请点「确保已灌入」')}</tbody></table>`;
const seed = async (force) => {
try {
await api('/spark/test-accounts/seed', {
method: 'POST',
body: JSON.stringify({ force: !!force }),
});
await pages.testAccounts();
} catch (e) {
alert(e.message || '操作失败');
}
};
$('taSeedBtn')?.addEventListener('click', () => seed(false));
$('taForceBtn')?.addEventListener('click', () => {
if (confirm('将重写测试账号密码并写回 spark-test-accounts.json,确认?')) seed(true);
});
} catch (e) {
$('content').innerHTML = `<p class="error">${esc(e.message || '加载失败')}</p>`;
}
},
async ipLookup() {
$('content').innerHTML = `
<div class="note">按公网 IP 查火花用户与设备,并给出总数。</div>
<div class="toolbar">
<input id="ipQ" placeholder="例如 1.2.3.4" style="flex:1;max-width:280px;padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<button type="button" id="ipSearchBtn">查询</button>
</div>
<div id="ipResult"></div>`;
const run = async () => {
const ip = ($('ipQ').value || '').trim();
if (!ip) return alert('请输入 IP');
$('ipResult').innerHTML = '<div class="note">查询中…</div>';
try {
const data = await api(`/spark/users/by-ip?ip=${encodeURIComponent(ip)}&appCode=${APP}`);
const users = asList(data.users);
const devices = asList(data.devices);
$('ipResult').innerHTML = `
<div class="cards">
<div class="card"><div class="label">用户数</div><div class="value">${esc(data.userCount)}</div></div>
<div class="card"><div class="label">设备数</div><div class="value">${esc(data.deviceCount)}</div></div>
</div>
<h3 style="margin:12px 0 8px">用户列表</h3>
<table><thead><tr><th>ID</th><th>邮箱</th><th>昵称</th><th>设备</th><th>最近活跃</th></tr></thead>
<tbody>${users.length ? users.map(u => `<tr><td>${esc(u.id||u.userId)}</td><td>${esc(u.email)}</td><td>${esc(u.nickname)}</td><td>${esc(u.deviceId)}</td><td>${esc(u.lastActiveAt)}</td></tr>`).join('') : emptyRows(5)}</tbody></table>
<h3 style="margin:16px 0 8px">设备列表</h3>
<table><thead><tr><th>设备码</th><th>邮箱</th><th>昵称</th><th>注册IP</th><th>活跃IP</th></tr></thead>
<tbody>${devices.length ? devices.map(d => `<tr><td>${esc(d.deviceId)}</td><td>${esc(d.email)}</td><td>${esc(d.nickname)}</td><td>${esc(d.registerIp)}</td><td>${esc(d.lastActiveIp)}</td></tr>`).join('') : emptyRows(5)}</tbody></table>`;
} catch (e) {
$('ipResult').innerHTML = `<div class="note" style="color:var(--danger)">${esc(e.message)}</div>`;
}
};
$('ipSearchBtn').onclick = run;
$('ipQ').onkeydown = (e) => e.key === 'Enter' && run();
},
async groups() {
const q = '';
const rows = await api('/spark/groups' + (q ? '?q=' + encodeURIComponent(q) : ''));
$('content').innerHTML = `
<div class="note">活跃群列表(可按群 ID / 群名检索)。成员与聊天记录导出依赖 IM 上报;可在此维护群元数据。</div>
<div class="toolbar">
<input id="groupQ" placeholder="群ID或群名" style="flex:1;max-width:240px;padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<button type="button" id="groupSearch">查询</button>
<button type="button" class="secondary" id="groupAdd">登记群</button>
</div>
<div id="groupDetail"></div>
<table>
<thead><tr><th>群ID</th><th>群名</th><th>成员数</th><th>活跃</th><th>更新</th><th></th></tr></thead>
<tbody id="groupBody">
${(rows || []).length ? rows.map(g => `<tr>
<td><code>${esc(g.groupId)}</code></td><td>${esc(g.name)}</td><td>${esc(g.memberCount)}</td>
<td>${g.active ? '是' : '否'}</td><td>${esc(g.updatedAt)}</td>
<td>
<button type="button" class="secondary btn-sm" data-g="${esc(g.groupId)}">成员</button>
<button type="button" class="secondary btn-sm" data-gz="${esc(g.groupId)}">导出ZIP</button>
</td>
</tr>`).join('') : emptyRows(6, '暂无活跃群(可登记或等待 IM 同步)')}
</tbody>
</table>`;
const reload = async (qq) => {
const list = await api('/spark/groups' + (qq ? '?q=' + encodeURIComponent(qq) : ''));
$('groupBody').innerHTML = (list || []).length
? list.map(g => `<tr>
<td><code>${esc(g.groupId)}</code></td><td>${esc(g.name)}</td><td>${esc(g.memberCount)}</td>
<td>${g.active ? '是' : '否'}</td><td>${esc(g.updatedAt)}</td>
<td>
<button type="button" class="secondary btn-sm" data-g="${esc(g.groupId)}">成员</button>
<button type="button" class="secondary btn-sm" data-gz="${esc(g.groupId)}">导出ZIP</button>
</td>
</tr>`).join('')
: emptyRows(6, '暂无匹配群');
bindGroupBtns();
};
const bindGroupBtns = () => {
$('content').querySelectorAll('[data-g]').forEach((btn) => {
btn.onclick = async () => {
const g = await api('/spark/groups/' + encodeURIComponent(btn.dataset.g));
const members = asList(g.members);
$('groupDetail').innerHTML = `
<div class="section-block note">
<h3>${esc(g.name)} <code>${esc(g.groupId)}</code> · 成员 ${members.length}</h3>
<table><thead><tr><th>userId</th><th>邮箱</th><th>昵称</th><th>角色</th></tr></thead>
<tbody>${members.length ? members.map(m => `<tr><td>${esc(m.userId)}</td><td>${esc(m.email)}</td><td>${esc(m.nickname)}</td><td>${esc(m.role)}</td></tr>`).join('') : emptyRows(4, '暂无成员(待 IM 上报)')}</tbody></table>
</div>`;
};
});
$('content').querySelectorAll('[data-gz]').forEach((btn) => {
btn.onclick = () =>
downloadZip(
`/spark/groups/${encodeURIComponent(btn.dataset.gz)}/export-zip`,
`group-${btn.dataset.gz}.zip`
).catch((e) => alert(e.message));
});
};
bindGroupBtns();
$('groupSearch').onclick = () => reload(($('groupQ').value || '').trim());
$('groupAdd').onclick = async () => {
const groupId = prompt('群 ID(可空自动生成)') || '';
const name = prompt('群名');
if (name == null) return;
await api('/spark/groups', { method: 'POST', body: JSON.stringify({ groupId, name, active: true }) });
reload(($('groupQ').value || '').trim());
};
},
async imMessages() {
const stats = await api('/spark/im/stats').catch(() => ({}));
$('content').innerHTML = `
<div class="note">MQTT IM 消息(spark-im-messages.json)· 含文本/名片/撤回标记 · 接口 <code>GET /api/admin/spark/im/messages</code></div>
<div class="cards">
<div class="card"><div class="label">消息数</div><div class="value">${esc(stats.messageCount ?? '-')}</div></div>
<div class="card"><div class="label">会话数</div><div class="value">${esc(stats.conversationCount ?? '-')}</div></div>
<div class="card"><div class="label">群数</div><div class="value">${esc(stats.groupCount ?? '-')}</div></div>
<div class="card"><div class="label">已撤回</div><div class="value">${esc(stats.revokedCount ?? '-')}</div></div>
</div>
<div class="toolbar" style="margin-top:12px">
<input id="imQ" placeholder="搜 text / from / to / id / type" style="flex:1;max-width:320px;padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<button type="button" id="imSearch">查询</button>
</div>
<table>
<thead><tr><th>时间</th><th>类型</th><th>from</th><th>to/群</th><th>内容</th><th>撤回</th></tr></thead>
<tbody id="imBody"><tr><td colspan="6">加载中…</td></tr></tbody>
</table>`;
const load = async (q) => {
try {
const data = await api('/spark/im/messages?limit=100' + (q ? '&q=' + encodeURIComponent(q) : ''));
const rows = asList(data.messages || data);
$('imBody').innerHTML = rows.length
? rows.map(m => `<tr>
<td>${esc(m.ts)}</td><td>${esc(m.type || 'text')}</td>
<td>${esc(m.from)}</td><td>${esc(m.groupId || m.to)}</td>
<td style="max-width:280px;overflow:hidden;text-overflow:ellipsis">${esc(m.text || (m.cardUsername ? '[名片] ' + m.cardUsername : ''))}</td>
<td>${m.revoked ? '是' : ''}</td>
</tr>`).join('')
: emptyRows(6, '暂无 IM 消息(客户端发消息后落库)');
} catch (e) {
$('imBody').innerHTML = emptyRows(6, esc(e.message || '加载失败'));
}
};
await load('');
$('imSearch').onclick = () => load(($('imQ').value || '').trim());
},
async complaints() {
const rows = await api('/spark/complaints');
$('content').innerHTML = `
<div class="note">投诉列表(客户端上报或运营录入)。</div>
<div class="toolbar">
<input id="cEmail" placeholder="投诉人邮箱" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<input id="cTarget" placeholder="被投诉对象" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<input id="cReason" placeholder="原因" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<button type="button" id="cAdd">录入</button>
</div>
<table><thead><tr><th>时间</th><th>邮箱</th><th>对象</th><th>原因</th><th>详情</th><th>状态</th></tr></thead>
<tbody>${(rows||[]).length ? rows.map(r => `<tr><td>${esc(r.createdAt)}</td><td>${esc(r.email)}</td><td>${esc(r.target)}</td><td>${esc(r.reason)}</td><td>${esc(r.detail)}</td><td>${esc(r.status)}</td></tr>`).join('') : emptyRows(6, '暂无投诉')}</tbody></table>`;
$('cAdd').onclick = async () => {
await api('/spark/complaints', {
method: 'POST',
body: JSON.stringify({
email: $('cEmail').value,
target: $('cTarget').value,
reason: $('cReason').value,
}),
});
pages.complaints();
};
},
async words() {
$('content').innerHTML = `
<div class="note">敏感词与监控词列表,运营可增删;客户端命中后可上报到用户详情事件流。</div>
${wordListUi('sensitive', '敏感词')}
${wordListUi('monitor', '监控词')}`;
const load = async (kind) => {
const rows = await api('/spark/words/' + kind);
$(kind + 'Table').innerHTML = `<table><thead><tr><th>词</th><th>备注</th><th>时间</th><th></th></tr></thead>
<tbody>${(rows||[]).length ? rows.map(r => `<tr><td>${esc(r.word)}</td><td>${esc(r.note)}</td><td>${esc(r.createdAt)}</td>
<td><button type="button" class="secondary btn-sm" data-del-word="${kind}:${r.id}">删除</button></td></tr>`).join('') : emptyRows(4)}</tbody></table>`;
$('content').querySelectorAll(`[data-del-word^="${kind}:"]`).forEach((btn) => {
btn.onclick = async () => {
const [k, id] = btn.dataset.delWord.split(':');
await api(`/spark/words/${k}/${id}`, { method: 'DELETE' });
load(k);
};
});
};
await load('sensitive');
await load('monitor');
$('content').querySelectorAll('[data-add-word]').forEach((btn) => {
btn.onclick = async () => {
const kind = btn.dataset.addWord;
await api('/spark/words/' + kind, {
method: 'POST',
body: JSON.stringify({ word: $(kind + 'Word').value, note: $(kind + 'Note').value }),
});
$(kind + 'Word').value = '';
load(kind);
};
});
},
async sameIpRank() {
const data = await api('/spark/rankings/same-ip?appCode=' + APP + '&limit=50');
const items = asList(data.items);
$('content').innerHTML = `
<div class="note">当日(${esc(data.date)} ${esc(data.timezone)})同 IP 活跃用户数排名。</div>
<table><thead><tr><th>#</th><th>IP</th><th>用户数</th><th>设备数</th><th></th></tr></thead>
<tbody>${items.length ? items.map((r, i) => `<tr>
<td>${i + 1}</td><td><code>${esc(r.ip)}</code></td><td>${esc(r.userCount)}</td><td>${esc(r.deviceCount)}</td>
<td><button type="button" class="secondary btn-sm" data-ip="${esc(r.ip)}">查看</button></td>
</tr>`).join('') : emptyRows(5, '今日暂无同 IP 活跃数据')}</tbody></table>`;
$('content').querySelectorAll('[data-ip]').forEach((btn) => {
btn.onclick = () => {
renderNav('ipLookup');
$('pageTitle').textContent = 'IP 查询';
pages.ipLookup().then(() => {
$('ipQ').value = btn.dataset.ip;
$('ipSearchBtn').click();
});
};
});
},
async ipBlacklist() {
await pages._simpleList('ip', 'IP 黑名单', 'IP 地址');
},
async domainWhitelist() {
await pages._simpleList('domain', '域名白名单', '域名');
},
async _simpleList(kind, title, ph) {
const rows = await api('/spark/lists/' + kind);
$('content').innerHTML = `
<div class="note">${esc(title)}</div>
<div class="toolbar">
<input id="listVal" placeholder="${esc(ph)}" style="flex:1;max-width:280px;padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<input id="listNote" placeholder="备注" style="flex:1;max-width:200px;padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<button type="button" id="listAdd">添加</button>
</div>
<table><thead><tr><th>值</th><th>备注</th><th>时间</th><th></th></tr></thead>
<tbody>${(rows||[]).length ? rows.map(r => `<tr><td>${esc(r.value)}</td><td>${esc(r.note)}</td><td>${esc(r.createdAt)}</td>
<td><button type="button" class="secondary btn-sm" data-del="${r.id}">删除</button></td></tr>`).join('') : emptyRows(4)}</tbody></table>`;
$('listAdd').onclick = async () => {
await api('/spark/lists/' + kind, {
method: 'POST',
body: JSON.stringify({ value: $('listVal').value, note: $('listNote').value }),
});
pages._simpleList(kind, title, ph);
};
$('content').querySelectorAll('[data-del]').forEach((btn) => {
btn.onclick = async () => {
await api(`/spark/lists/${kind}/${btn.dataset.del}`, { method: 'DELETE' });
pages._simpleList(kind, title, ph);
};
});
},
async accountWhitelist() {
const rows = await api('/whitelist?appCode=' + APP);
let geoNote = '';
let geoRestrictionEnabled = false;
try {
const geo = await api('/spark/geo-access/roots');
const roots = (geo && geo.items) ? geo.items : [];
geoRestrictionEnabled = !!(geo && geo.geoRestrictionEnabled);
geoNote = `<div class="note" style="margin-top:8px">大陆 IP 登录/注册放行根邮箱(种子 + 本表白名单 EMAIL):${
roots.length ? roots.map(r => `<code>${esc(r.email)}</code>${r.inviteCode ? ' · 邀请码 <code>' + esc(r.inviteCode) + '</code>' : ''}`).join(';') : '(无额外)'
}。好友与邀请链:<code>/api/admin/spark/geo-access</code>。港/澳/台不拦截。</div>`;
} catch (e) {
try {
const st = await api('/spark/geo-access/restriction');
geoRestrictionEnabled = !!(st && st.geoRestrictionEnabled);
} catch (_) {}
geoNote = `<div class="note" style="margin-top:8px">大陆 IP 拦截:白名单 EMAIL + 其好友 + 邀请码邀请链可放行。管理 API:<code>/api/admin/spark/geo-access</code></div>`;
}
const statusText = geoRestrictionEnabled
? '当前:限制开启(大陆 IP 按白名单/好友/邀请链拦截)'
: '当前:限制关闭(登录/注册全球放行)';
$('content').innerHTML = `
<div class="section-block note" style="border:1px solid var(--border);padding:14px 16px;border-radius:10px;margin-bottom:12px">
<h3 style="margin:0 0 8px;font-size:15px">启用 IP/国家登录注册限制</h3>
<p class="muted" style="margin:0 0 10px;font-size:12px">关闭 = 暂时放开大陆等地区拦截;开启 = 恢复原 geo 规则(白名单 / 好友 / 邀请链例外)。配置键 <code>geoRestrictionEnabled</code>,写入 <code>spark-geo-access.json</code>。</p>
<label class="switch-row" style="font-size:14px">
<input type="checkbox" id="geoRestrictToggle" ${geoRestrictionEnabled ? 'checked' : ''}>
<span id="geoRestrictLabel">${geoRestrictionEnabled ? '已开启限制' : '限制已关闭(放行)'}</span>
</label>
<div id="geoRestrictStatus" class="note" style="margin-top:8px">${statusText}</div>
</div>
<div class="note">账号白名单(EMAIL / PUBLIC_ID),与主运营后台共用 <code>/api/admin/whitelist</code>。EMAIL 同时作为<strong>大陆登录/注册放行根</strong>(仅在上方限制开启时生效)。</div>
${geoNote}
<div class="toolbar">
<select id="wlType"><option value="EMAIL">EMAIL</option><option value="PUBLIC_ID">PUBLIC_ID</option></select>
<input id="wlVal" placeholder="邮箱或专属ID" style="flex:1;max-width:280px;padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<input id="wlNote" placeholder="备注" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<button type="button" id="wlAdd">添加</button>
</div>
<table><thead><tr><th>类型</th><th>值</th><th>启用</th><th>备注</th><th></th></tr></thead>
<tbody>${(rows||[]).length ? rows.map(r => `<tr>
<td>${esc(r.type)}</td><td>${esc(r.value)}</td><td>${r.enabled ? '是' : '否'}</td><td>${esc(r.note)}</td>
<td><button type="button" class="secondary btn-sm" data-wldel="${r.id}">删除</button></td>
</tr>`).join('') : emptyRows(5)}</tbody></table>`;
const geoToggle = $('geoRestrictToggle');
if (geoToggle) {
geoToggle.onchange = async () => {
const enabled = !!geoToggle.checked;
try {
const res = await api('/spark/geo-access/restriction', {
method: 'PUT',
body: JSON.stringify({ enabled }),
});
const on = !!(res && res.geoRestrictionEnabled);
geoToggle.checked = on;
$('geoRestrictLabel').textContent = on ? '已开启限制' : '限制已关闭(放行)';
const statusEl = $('geoRestrictStatus');
if (statusEl) {
statusEl.textContent = on
? '当前:限制开启(大陆 IP 按白名单/好友/邀请链拦截)'
: '当前:限制关闭(登录/注册全球放行)';
}
} catch (e) {
geoToggle.checked = !enabled;
alert(e.message || '更新限制开关失败');
}
};
}
$('wlAdd').onclick = async () => {
await api('/whitelist', {
method: 'POST',
body: JSON.stringify({
appCode: APP,
type: $('wlType').value,
value: $('wlVal').value,
note: $('wlNote').value,
enabled: true,
}),
});
pages.accountWhitelist();
};
$('content').querySelectorAll('[data-wldel]').forEach((btn) => {
btn.onclick = async () => {
await api('/whitelist/' + btn.dataset.wldel, { method: 'DELETE' });
pages.accountWhitelist();
};
});
},
async rankings() {
const types = [
['messages', '日消息量'],
['images', '日发图片数'],
['domains', '日发域名'],
['postLikes', '日点赞帖子'],
];
$('content').innerHTML = `
<div class="note">日排行依赖客户端/IM 统计上报;未上报时为空。</div>
<div class="toolbar">${types.map(([k, l]) => `<button type="button" class="secondary" data-rk="${k}">${l}</button>`).join('')}</div>
<div id="rankBox"><p class="muted">选择上方排行类型</p></div>`;
$('content').querySelectorAll('[data-rk]').forEach((btn) => {
btn.onclick = async () => {
const data = await api('/spark/rankings/' + btn.dataset.rk);
const items = asList(data.items);
$('rankBox').innerHTML = `
<p class="muted">${esc(data.note || '')}</p>
<table><thead><tr><th>#</th><th>邮箱</th><th>昵称</th><th>数值</th><th>说明</th></tr></thead>
<tbody>${items.length ? items.map((r, i) => `<tr><td>${i+1}</td><td>${esc(r.email)}</td><td>${esc(r.nickname)}</td><td>${esc(r.value)}</td><td>${esc(r.label)}</td></tr>`).join('') : emptyRows(5, '暂无排行数据')}</tbody></table>`;
};
});
},
async orders() {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
$('content').innerHTML = `
<div class="note">用户充值订单(spark)。可选日 / 月 / 年范围。</div>
<div class="toolbar">
<select id="ordPeriod"><option value="day">日</option><option value="month" selected>月</option><option value="year">年</option></select>
<input id="ordDay" type="date" value="${y}-${m}-${d}" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<button type="button" id="ordLoad">查询</button>
</div>
<div id="ordSum" class="cards"></div>
<div id="ordTable"></div>`;
const load = async () => {
const period = $('ordPeriod').value;
const day = $('ordDay').value || `${y}-${m}-${d}`;
const [yy, mm] = day.split('-');
let start;
let end;
if (period === 'day') {
start = day;
end = day;
} else if (period === 'month') {
start = `${yy}-${mm}-01`;
const last = new Date(Number(yy), Number(mm), 0).getDate();
end = `${yy}-${mm}-${String(last).padStart(2, '0')}`;
} else {
start = `${yy}-01-01`;
end = `${yy}-12-31`;
}
const page = await api(
`/payments?appCode=${APP}&start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}&page=0&size=100`
);
let sum = {};
try {
sum = await api(
`/payments/summary?appCode=${APP}&start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`
);
} catch (_) {}
const list = page.content || page || [];
$('ordSum').innerHTML = `
<div class="card"><div class="label">订单数</div><div class="value">${esc(sum.count ?? list.length ?? 0)}</div></div>
<div class="card"><div class="label">金额合计</div><div class="value">${esc(sum.amount ?? sum.totalAmount ?? '—')}</div></div>`;
$('ordTable').innerHTML = `<table><thead><tr><th>时间</th><th>用户</th><th>金额</th><th>渠道</th><th>状态</th><th>订单号</th></tr></thead>
<tbody>${asList(list).length ? asList(list).map(r => `<tr>
<td>${esc(r.createdAt || r.paidAt)}</td><td>${esc(r.email || r.userId)}</td><td>${esc(r.amount)}</td>
<td>${esc(r.channel || r.payChannel)}</td><td>${esc(r.status)}</td><td>${esc(r.orderId || r.tradeNo)}</td>
</tr>`).join('') : emptyRows(6)}</tbody></table>`;
};
$('ordLoad').onclick = load;
load();
},
async pushes() {
const rows = await api('/spark/pushes');
$('content').innerHTML = `
<div class="note">系统消息推送列表(草稿/计划,实际下发需对接推送通道)。</div>
<div class="toolbar" style="flex-wrap:wrap">
<input id="pTitle" placeholder="标题" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<input id="pContent" placeholder="内容" style="flex:1;min-width:200px;padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<input id="pAud" placeholder="受众 all" value="all" style="width:100px;padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<button type="button" id="pAdd">新建</button>
</div>
<table><thead><tr><th>时间</th><th>标题</th><th>内容</th><th>受众</th><th>状态</th><th></th></tr></thead>
<tbody>${(rows||[]).length ? rows.map(r => `<tr>
<td>${esc(r.createdAt)}</td><td>${esc(r.title)}</td><td>${esc(r.content)}</td><td>${esc(r.audience)}</td><td>${esc(r.status)}</td>
<td><button type="button" class="secondary btn-sm" data-pdel="${r.id}">删除</button></td>
</tr>`).join('') : emptyRows(6)}</tbody></table>`;
$('pAdd').onclick = async () => {
await api('/spark/pushes', {
method: 'POST',
body: JSON.stringify({
title: $('pTitle').value,
content: $('pContent').value,
audience: $('pAud').value,
}),
});
pages.pushes();
};
$('content').querySelectorAll('[data-pdel]').forEach((btn) => {
btn.onclick = async () => {
await api('/spark/pushes/' + btn.dataset.pdel, { method: 'DELETE' });
pages.pushes();
};
});
},
async retention() {
let summary = {};
let channel = [];
try {
summary = await api('/retention/summary?appCode=' + APP + '&days=30');
} catch (_) {}
try {
channel = await api('/stats/retention?appCode=' + APP + '&cohortDays=7');
} catch (_) {}
$('content').innerHTML = `
<div class="note">日月年用户留存(火花)。摘要来自 <code>/retention/summary</code>,渠道留存来自 <code>/stats/retention</code>。</div>
<div class="cards">
<div class="card"><div class="label">窗口天数</div><div class="value">${esc(summary.days ?? 30)}</div></div>
<div class="card"><div class="label">新增</div><div class="value">${esc(summary.newUsers ?? summary.cohortSize ?? '—')}</div></div>
<div class="card"><div class="label">次日留存</div><div class="value">${esc(summary.retain1Rate ?? summary.d1 ?? '—')}</div></div>
<div class="card"><div class="label">7日留存</div><div class="value">${esc(summary.retain7Rate ?? summary.d7 ?? '—')}</div></div>
<div class="card"><div class="label">30日留存</div><div class="value">${esc(summary.retain30Rate ?? summary.d30 ?? '—')}</div></div>
</div>
<h3 style="margin:12px 0">渠道留存</h3>
<table><thead><tr><th>渠道</th><th>cohort</th><th>D1</th><th>D7</th><th>D30</th></tr></thead>
<tbody>${asList(channel).length ? asList(channel).map(r => `<tr>
<td>${esc(r.channelName || r.channel)}</td><td>${esc(r.cohort || r.cohortSize)}</td>
<td>${esc(r.retain1Rate ?? r.d1)}</td><td>${esc(r.retain7Rate ?? r.d7)}</td><td>${esc(r.retain30Rate ?? r.d30)}</td>
</tr>`).join('') : emptyRows(5)}</tbody></table>`;
},
async password() {
$('content').innerHTML = `
<div class="note">运营管理账号改密(与 App 用户邮箱账号相互独立)。对齐主站「账号安全」。</div>
<div class="section-block note" style="max-width:480px">
<h3>修改我的密码</h3>
<div class="toolbar" style="flex-direction:column;align-items:stretch;gap:8px">
<input id="oldPass" type="password" placeholder="原密码" autocomplete="current-password" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<input id="newPass" type="password" placeholder="新密码(至少 6 位)" autocomplete="new-password" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<button type="button" id="pwdSave">确认修改</button>
<p id="pwdMsg" class="error"></p>
</div>
</div>
<div class="note">子管理员密码重置请到「权限分配」;App 用户密码请到「App用户密码」或用户详情。</div>`;
$('pwdSave').onclick = async () => {
try {
await api('/auth/change-password', {
method: 'POST',
body: JSON.stringify({
oldPassword: $('oldPass').value,
newPassword: $('newPass').value,
}),
});
$('pwdMsg').style.color = 'var(--ok, #34d399)';
$('pwdMsg').textContent = '密码修改成功';
$('oldPass').value = '';
$('newPass').value = '';
} catch (e) {
$('pwdMsg').style.color = '';
$('pwdMsg').textContent = e.message || '修改失败';
}
};
},
async userPasswords() {
$('content').innerHTML = `
<div class="note">重置 App 互通邮箱密码(全局一份密码,影响所有接入 App)。明文仅本次提交,不写入仓库与接口响应。</div>
<div class="section-block note" style="max-width:520px">
<h3>按邮箱重置密码</h3>
<div class="toolbar" style="flex-direction:column;align-items:stretch;gap:8px">
<input id="upEmail" type="email" placeholder="用户邮箱" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<input id="upPass" type="password" placeholder="新密码(至少 6 位)" autocomplete="new-password" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<input id="upPass2" type="password" placeholder="再次确认新密码" autocomplete="new-password" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<button type="button" id="upSave">重置密码</button>
<p id="upMsg" class="error"></p>
</div>
</div>
<div class="toolbar"><button type="button" class="secondary" id="upGotoUser">打开用户详情</button></div>`;
$('upSave').onclick = async () => {
const email = ($('upEmail').value || '').trim();
const p1 = $('upPass').value || '';
const p2 = $('upPass2').value || '';
if (!email || !email.includes('@')) {
$('upMsg').textContent = '请输入有效邮箱';
return;
}
if (p1.length < 6) {
$('upMsg').textContent = '新密码至少 6 位';
return;
}
if (p1 !== p2) {
$('upMsg').textContent = '两次密码不一致';
return;
}
if (!confirm('确认重置 ' + email + ' 的 App 密码?')) return;
try {
await api('/users/by-email/reset-password', {
method: 'POST',
body: JSON.stringify({ email, appCode: APP, newPassword: p1 }),
});
$('upMsg').style.color = 'var(--ok, #34d399)';
$('upMsg').textContent = '已重置(请线下告知用户新密码)';
$('upPass').value = '';
$('upPass2').value = '';
} catch (e) {
$('upMsg').style.color = '';
$('upMsg').textContent = e.message || '重置失败';
}
};
$('upGotoUser').onclick = () => {
const email = ($('upEmail').value || '').trim();
if (!email) {
alert('请先填写邮箱');
return;
}
renderNav('users');
$('pageTitle').textContent = '用户';
pages.users(email);
};
},
async mfa() {
let status = { enabled: false };
try {
status = await api('/auth/mfa/status');
} catch (e) {
$('content').innerHTML = `<p class="error">${esc(e.message)}</p>`;
return;
}
$('content').innerHTML = `
<div class="note">虚拟 MFA(TOTP)。用 Google / 微软 Authenticator 扫码绑定。状态:${status.enabled ? '已启用' : '未启用'}。</div>
<div class="toolbar">
<button type="button" id="mfaSetupBtn">生成绑定二维码</button>
${status.enabled ? '<button type="button" class="secondary" id="mfaDisableBtn">关闭 MFA</button>' : ''}
</div>
<div id="mfaBox"></div>`;
$('mfaSetupBtn').onclick = async () => {
try {
const data = await api('/auth/mfa/setup', { method: 'POST', body: '{}' });
$('mfaBox').innerHTML = `
<p class="muted">密钥:<code>${esc(data.secret)}</code></p>
<img src="${esc(data.qrCodeDataUrl)}" alt="MFA QR" style="width:200px;height:200px;background:#fff;border-radius:8px;padding:8px">
<div class="toolbar" style="margin-top:8px">
<input id="mfaCode" placeholder="6位验证码" maxlength="8" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<button type="button" id="mfaConfirmBtn">确认启用</button>
</div>`;
$('mfaConfirmBtn').onclick = async () => {
await api('/auth/mfa/confirm', {
method: 'POST',
body: JSON.stringify({ code: ($('mfaCode').value || '').trim() }),
});
alert('MFA 已启用');
pages.mfa();
};
} catch (e) {
alert(e.message);
}
};
const dis = $('mfaDisableBtn');
if (dis) {
dis.onclick = async () => {
const code = prompt('输入当前 MFA 验证码以关闭');
if (code == null) return;
await api('/auth/mfa/disable', { method: 'POST', body: JSON.stringify({ code }) });
pages.mfa();
};
}
},
async staff() {
let rows = [];
let catalog = { popadminMenu: [] };
try {
rows = await api('/auth/staff');
const st = await api('/auth/mfa/status');
catalog = st.permissionCatalog || catalog;
} catch (e) {
$('content').innerHTML = `<p class="error">${esc(e.message || '仅超级管理员可见')}</p>`;
return;
}
const menuOpts = catalog.popadminMenu || catalog.adminMenu || [];
const labels = catalog.labels || {};
window.__permLabels = labels;
const permLabel = (code) => {
if (!code) return '';
if (code === '*') return '全部权限';
if (labels[code]) return labels[code];
const lower = String(code).toLowerCase();
for (const k of Object.keys(labels)) {
if (k.toLowerCase() === lower) return labels[k];
}
return code;
};
const formatPerms = (perms) => {
if (!perms || !perms.length) return '—';
if (perms.includes('*')) return '全部权限 (*)';
return perms.map((p) => `${permLabel(p)}(${p})`).join('、');
};
$('content').innerHTML = `
<div class="note">超级管理员:运营账号权限分配(与主后台共用 admin_user)。勾选项:中文说明(权限码)。</div>
<div class="toolbar" style="flex-wrap:wrap">
<input id="stUser" placeholder="用户名" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<input id="stEmail" placeholder="绑定邮箱" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<input id="stPass" type="password" placeholder="初始密码" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<select id="stRole"><option value="ADMIN">ADMIN</option><option value="SUPER_ADMIN">SUPER_ADMIN</option></select>
<button type="button" id="stCreate">创建</button>
</div>
<div id="stPerms" style="display:flex;flex-wrap:wrap;gap:8px;margin:12px 0">${menuOpts.map(p => `<label style="font-size:12px" title="${esc(p)}"><input type="checkbox" value="${esc(p)}"> ${esc(permLabel(p))} <code style="opacity:.7">${esc(p)}</code></label>`).join('')}</div>
<table><thead><tr><th>用户名</th><th>邮箱</th><th>角色</th><th>权限</th><th>MFA</th><th></th></tr></thead>
<tbody>${asList(rows).length ? asList(rows).map(r => `<tr>
<td>${esc(r.username)}</td><td>${esc(r.email)}</td><td>${esc(r.role)}</td>
<td style="font-size:12px">${esc(formatPerms(r.permissions || []))}</td>
<td>${r.mfaEnabled ? '是' : '否'}</td>
<td>
<button type="button" class="secondary btn-sm" data-stperm="${r.id}">改权限</button>
<button type="button" class="secondary btn-sm" data-stpass="${r.id}" data-stun="${esc(r.username)}">重置密码</button>
<button type="button" class="secondary btn-sm" data-stmfa="${r.id}">重置MFA</button>
</td>
</tr>`).join('') : emptyRows(6)}</tbody></table>`;
$('stCreate').onclick = async () => {
const permissions = [...document.querySelectorAll('#stPerms input:checked')].map((c) => c.value);
await api('/auth/staff', {
method: 'POST',
body: JSON.stringify({
username: $('stUser').value,
email: $('stEmail').value,
password: $('stPass').value,
role: $('stRole').value,
permissions,
}),
});
pages.staff();
};
$('content').querySelectorAll('[data-stperm]').forEach((btn) => {
btn.onclick = async () => {
const permissions = [...document.querySelectorAll('#stPerms input:checked')].map((c) => c.value);
const role = prompt('角色 ADMIN / SUPER_ADMIN', 'ADMIN');
if (role == null) return;
await api('/auth/staff/' + btn.dataset.stperm + '/permissions', {
method: 'PUT',
body: JSON.stringify({ role, permissions }),
});
pages.staff();
};
});
$('content').querySelectorAll('[data-stmfa]').forEach((btn) => {
btn.onclick = async () => {
if (!confirm('确认重置该账号 MFA?')) return;
await api('/auth/staff/' + btn.dataset.stmfa + '/reset-mfa', { method: 'POST', body: '{}' });
alert('已重置');
pages.staff();
};
});
$('content').querySelectorAll('[data-stpass]').forEach((btn) => {
btn.onclick = async () => {
const un = btn.dataset.stun || '';
const np = prompt('为运营账号 ' + un + ' 设置新密码(至少 6 位)', '');
if (np === null) return;
if (String(np).length < 6) {
alert('新密码至少 6 位');
return;
}
if (!confirm('确认重置子管理员密码?')) return;
await api('/auth/staff/' + btn.dataset.stpass + '/reset-password', {
method: 'POST',
body: JSON.stringify({ newPassword: String(np) }),
});
alert('已重置(请线下告知对方)');
pages.staff();
};
});
},
async apiDoc() {
$('content').innerHTML = '<p class="muted">加载接口文档…</p>';
try {
const doc = await api('/spark/api-doc');
const row = (a) =>
`<tr><td>${esc(a.method)}</td><td><code>${esc(a.path)}</code></td><td>${esc(a.auth || '-')}</td><td>${esc(a.desc || '')}</td></tr>`;
const tableBlock = (title, list) => `
<div class="section-block" style="margin-bottom:16px">
<h3>${esc(title)}</h3>
<table>
<thead><tr><th>方法</th><th>路径</th><th>鉴权</th><th>说明</th></tr></thead>
<tbody>${(list || []).map(row).join('') || emptyRows(4, '暂无')}</tbody>
</table>
</div>`;
const notes = (doc.notes || []).map((n) => `<li>${esc(n)}</li>`).join('');
$('content').innerHTML = `
<div class="note" style="margin-bottom:12px">
Base URL:<code>${esc(doc.baseUrl || 'https://popadmin.wyrs365.com')}</code>
· 数据源 <code>GET /api/admin/spark/api-doc</code>
· 路径别名 <code>/api/admin/pop/…</code>、<code>/api/client/pop/…</code>
</div>
<ul class="note" style="margin-bottom:16px;padding-left:18px">${notes}</ul>
${tableBlock('管理端 API(popadmin 常用)', doc.adminApis)}
${tableBlock('火花客户端 API(/api/client/spark)', doc.clientApis)}
${tableBlock('邮箱等共享客户端 API', doc.sharedClientApis)}
${tableBlock('公开 API', doc.publicApis)}`;
} catch (e) {
$('content').innerHTML = `<p class="error">${esc(e.message)}</p>`;
}
},
};
$('loginBtn').addEventListener('click', async () => {
$('loginError').textContent = '';
try {
const identity = ($('loginEmail').value || '').trim();
const password = $('loginPass').value || '';
if (!identity || !password) throw new Error('请输入账号和密码');
const data = await api('/auth/login', {
method: 'POST',
body: JSON.stringify(
identity.includes('@')
? { email: identity, password }
: { username: identity, password }
),
});
if (data.mfaRequired && data.mfaToken) {
pendingMfaToken = data.mfaToken;
$('loginStepPrimary')?.classList.add('hidden');
$('loginStepMfa')?.classList.remove('hidden');
if ($('loginMfaCode')) $('loginMfaCode').value = '';
return;
}
finishPopLogin(data, identity);
} catch (e) {
$('loginError').textContent = e.message;
}
});
let pendingMfaToken = null;
function finishPopLogin(data, fallbackName) {
token = data.token;
username = data.email || data.username || fallbackName || '';
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem('wyrs_admin_name', username);
pendingMfaToken = null;
$('loginStepMfa')?.classList.add('hidden');
$('loginStepPrimary')?.classList.remove('hidden');
showApp();
}
$('loginMfaBtn')?.addEventListener('click', async () => {
$('loginError').textContent = '';
try {
const code = ($('loginMfaCode')?.value || '').trim();
if (!pendingMfaToken || !code) throw new Error('请输入 MFA 验证码');
const data = await api('/auth/login-mfa', {
method: 'POST',
body: JSON.stringify({ mfaToken: pendingMfaToken, code }),
});
finishPopLogin(data);
} catch (e) {
$('loginError').textContent = e.message;
}
});
$('loginMfaBack')?.addEventListener('click', () => {
pendingMfaToken = null;
$('loginStepMfa')?.classList.add('hidden');
$('loginStepPrimary')?.classList.remove('hidden');
$('loginError').textContent = '';
});
$('logoutBtn').addEventListener('click', logout);
if (token) showApp();
else showLogin();