可选 Bearer 写入 auth_type/uid,游客必填 contact;Admin 与 popadmin 可按类型筛选。 Co-authored-by: Cursor <cursoragent@cursor.com>
5466 lines
154 KiB
JavaScript
5466 lines
154 KiB
JavaScript
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: 'legalDocs', label: '隐私协议' },
|
||
|
||
{ id: 'subPlans', label: '订阅配置' },
|
||
|
||
{ id: 'sparkFeedbacks', label: 'App反馈' },
|
||
|
||
{ 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, '&')
|
||
|
||
.replace(/</g, '<')
|
||
|
||
.replace(/>/g, '>')
|
||
|
||
.replace(/"/g, '"');
|
||
|
||
}
|
||
|
||
|
||
|
||
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 legalDocs() {
|
||
let data = { privacy: {}, terms: {} };
|
||
try { data = await api('/spark/legal'); } catch (e) {
|
||
$('content').innerHTML = `<div class="note error">法律文档 API:${esc(e.message)}。需部署 spark-server 0023 迁移。</div>`;
|
||
return;
|
||
}
|
||
const privacy = data.privacy || {};
|
||
const terms = data.terms || {};
|
||
$('content').innerHTML = `
|
||
<div class="note">SleepHeart 隐私政策 / 用户协议 · 管理 <code>/api/admin/spark/legal</code> · 客户端 <code>GET /v1/legal/agreements</code></div>
|
||
<div class="section-block" style="margin-bottom:16px">
|
||
<h3>隐私政策</h3>
|
||
<div class="toolbar" style="flex-wrap:wrap;gap:8px">
|
||
<select id="legLangP"><option value="zh-CN">zh-CN</option><option value="en">en</option><option value="zh-TW">zh-TW</option></select>
|
||
<input id="legTitleP" placeholder="标题" value="${esc(privacy.title||'')}" style="flex:1;min-width:160px;padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
|
||
<input id="legVerP" placeholder="版本" value="${esc(privacy.version||'1.0')}" style="width:100px;padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
|
||
</div>
|
||
<textarea id="legBodyP" rows="8" style="width:100%;margin-top:8px;padding:10px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">${esc(privacy.content||'')}</textarea>
|
||
<button type="button" id="legSaveP" style="margin-top:8px">保存隐私政策</button>
|
||
</div>
|
||
<div class="section-block">
|
||
<h3>用户协议</h3>
|
||
<div class="toolbar" style="flex-wrap:wrap;gap:8px">
|
||
<select id="legLangT"><option value="zh-CN">zh-CN</option><option value="en">en</option><option value="zh-TW">zh-TW</option></select>
|
||
<input id="legTitleT" placeholder="标题" value="${esc(terms.title||'')}" style="flex:1;min-width:160px;padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
|
||
<input id="legVerT" placeholder="版本" value="${esc(terms.version||'1.0')}" style="width:100px;padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
|
||
</div>
|
||
<textarea id="legBodyT" rows="8" style="width:100%;margin-top:8px;padding:10px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">${esc(terms.content||'')}</textarea>
|
||
<button type="button" id="legSaveT" style="margin-top:8px">保存用户协议</button>
|
||
</div>`;
|
||
if (privacy.lang) $('legLangP').value = privacy.lang;
|
||
if (terms.lang) $('legLangT').value = terms.lang;
|
||
const save = async (key, titleEl, bodyEl, verEl, langEl) => {
|
||
await api('/spark/legal', { method: 'PUT', body: JSON.stringify({
|
||
key, lang: langEl.value, title: titleEl.value.trim(), content: bodyEl.value, version: verEl.value.trim() || '1.0'
|
||
})});
|
||
pages.legalDocs();
|
||
};
|
||
$('legSaveP').onclick = () => save('privacy', $('legTitleP'), $('legBodyP'), $('legVerP'), $('legLangP'));
|
||
$('legSaveT').onclick = () => save('terms', $('legTitleT'), $('legBodyT'), $('legVerT'), $('legLangT'));
|
||
},
|
||
|
||
async subPlans() {
|
||
let items = [];
|
||
try {
|
||
const data = await api('/spark/subscriptions/plans?lang=zh-CN');
|
||
items = (data && data.items) || [];
|
||
} catch (e) {
|
||
$('content').innerHTML = `<div class="note error">订阅配置 API:${esc(e.message)}</div>`;
|
||
return;
|
||
}
|
||
$('content').innerHTML = `
|
||
<div class="note">SleepHeart 订阅引导 · <code>/api/admin/spark/subscriptions/plans</code> · 客户端 <code>GET /v1/subscriptions/plans</code> · plan_id=week|month|quarter|year</div>
|
||
<div class="toolbar" style="flex-wrap:wrap;gap:8px">
|
||
<select id="spPlan"><option value="week">week</option><option value="month">month</option><option value="quarter">quarter</option><option value="year">year</option></select>
|
||
<select id="spLang"><option value="zh-CN">zh-CN</option><option value="en">en</option><option value="zh-TW">zh-TW</option></select>
|
||
<input id="spTitle" placeholder="标题" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:120px">
|
||
<input id="spSub" placeholder="副标题" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:140px">
|
||
<input id="spPrice" placeholder="展示价" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:90px">
|
||
<input id="spCents" type="number" placeholder="分" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:90px">
|
||
<input id="spDays" type="number" placeholder="天数" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:80px">
|
||
<input id="spBadge" placeholder="角标" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:80px">
|
||
<input id="spProduct" placeholder="product_id" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:140px">
|
||
<input id="spSort" type="number" value="1" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:70px">
|
||
<label style="display:flex;align-items:center;gap:6px"><input type="checkbox" id="spEnabled" checked>启用</label>
|
||
<button type="button" id="spSave">保存套餐</button>
|
||
<button type="button" class="secondary" id="spReload">刷新</button>
|
||
</div>
|
||
<table>
|
||
<thead><tr><th>plan</th><th>lang</th><th>标题</th><th>价格</th><th>天数</th><th>角标</th><th>product</th><th>启用</th></tr></thead>
|
||
<tbody>
|
||
${items.length ? items.map((p)=>`<tr><td>${esc(p.plan_id)}</td><td>${esc(p.lang)}</td><td>${esc(p.title)}<div class="note" style="margin:0">${esc(p.subtitle||'')}</div></td><td>${esc(p.price_display)} (${esc(p.price_cents)})</td><td>${esc(p.period_days)}</td><td>${esc(p.badge||'')}</td><td>${esc(p.product_id)}</td><td>${esc(p.enabled)}</td></tr>`).join('') : '<tr><td colspan="8">暂无套餐</td></tr>'}
|
||
</tbody>
|
||
</table>`;
|
||
$('spSave').onclick = async () => {
|
||
await api('/spark/subscriptions/plans', { method: 'PUT', body: JSON.stringify({
|
||
plan_id: $('spPlan').value, lang: $('spLang').value, title: $('spTitle').value.trim(),
|
||
subtitle: $('spSub').value.trim(), price_display: $('spPrice').value.trim(),
|
||
price_cents: parseInt($('spCents').value,10)||0, currency: 'CNY',
|
||
period_days: parseInt($('spDays').value,10)||7, badge: $('spBadge').value.trim(),
|
||
sort_order: parseInt($('spSort').value,10)||1, enabled: !!$('spEnabled').checked,
|
||
product_id: $('spProduct').value.trim()
|
||
})});
|
||
pages.subPlans();
|
||
};
|
||
$('spReload').onclick = () => pages.subPlans();
|
||
},
|
||
|
||
async sparkFeedbacks() {
|
||
const authType = ($('fbAuthType') && $('fbAuthType').value) || '';
|
||
let q = `?page=0&size=50`;
|
||
if (authType) q += `&auth_type=${encodeURIComponent(authType)}`;
|
||
let data = { list: [], total: 0 };
|
||
try { data = await api('/spark/feedbacks' + q); } catch (e) {
|
||
$('content').innerHTML = `<div class="note error">App反馈 API:${esc(e.message)}</div>`;
|
||
return;
|
||
}
|
||
const list = data.list || [];
|
||
const authLabel = (t) => t === 'login' ? '已登录' : (t === 'guest' ? '未登录' : (t || '-'));
|
||
const identity = (f) => {
|
||
if (f.auth_type === 'login' || (f.user_id != null && f.user_id !== '')) {
|
||
return `uid ${esc(f.user_id??'')}`;
|
||
}
|
||
return esc(f.contact || f.email || '-');
|
||
};
|
||
$('content').innerHTML = `
|
||
<div class="note">意见反馈 · <code>/api/admin/spark/feedbacks</code> · 客户端 <code>POST /v1/feedback</code>(可选 Bearer)· 共 ${esc(data.total||0)} 条</div>
|
||
<div class="toolbar" style="margin:8px 0;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||
<label>登录状态
|
||
<select id="fbAuthType">
|
||
<option value="">全部</option>
|
||
<option value="login"${authType==='login'?' selected':''}>已登录</option>
|
||
<option value="guest"${authType==='guest'?' selected':''}>未登录</option>
|
||
</select>
|
||
</label>
|
||
<button type="button" id="fbReload" class="secondary">刷新</button>
|
||
</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((f)=>`<tr>
|
||
<td>${esc(f.id)}</td>
|
||
<td>${esc(authLabel(f.auth_type))}</td>
|
||
<td>${identity(f)}</td>
|
||
<td style="max-width:280px">${esc(f.content)}</td>
|
||
<td>${esc(f.app_version||'')}</td>
|
||
<td>${esc(f.language||'')}</td>
|
||
<td>${esc(f.status||'')}</td>
|
||
<td>${esc(f.created_at||'')}</td>
|
||
<td>
|
||
<button type="button" class="secondary btn-sm" data-fdone="${esc(f.id)}">完成</button>
|
||
<button type="button" class="secondary btn-sm" data-fign="${esc(f.id)}">忽略</button>
|
||
</td></tr>`).join('') : '<tr><td colspan="9">暂无反馈</td></tr>'}
|
||
</tbody>
|
||
</table>`;
|
||
$('fbReload').onclick = () => pages.sparkFeedbacks();
|
||
$('fbAuthType').onchange = () => pages.sparkFeedbacks();
|
||
$('content').querySelectorAll('[data-fdone]').forEach((btn)=>{
|
||
btn.onclick = async () => { await api('/spark/feedbacks/'+btn.dataset.fdone,{method:'PATCH',body:JSON.stringify({status:'done'})}); pages.sparkFeedbacks(); };
|
||
});
|
||
$('content').querySelectorAll('[data-fign]').forEach((btn)=>{
|
||
btn.onclick = async () => { await api('/spark/feedbacks/'+btn.dataset.fign,{method:'PATCH',body:JSON.stringify({status:'ignored'})}); pages.sparkFeedbacks(); };
|
||
});
|
||
},
|
||
|
||
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();
|
||
|