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: '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) => `${n.label}` ).join(''); nav.querySelectorAll('a').forEach((a) => { a.addEventListener('click', (ev) => { ev.preventDefault(); const id = a.dataset.id; renderNav(id); $('pageTitle').textContent = NAV.find((x) => x.id === id)?.label || id; const fn = pages[id]; if (typeof fn !== 'function') { $('content').innerHTML = `
页面未注册:${esc(id)}
`; return; } $('content').innerHTML = '
加载中…
'; Promise.resolve() .then(() => fn.call(pages)) .catch((e) => { console.error('[popadmin] page', id, e); $('content').innerHTML = `
加载失败:${esc(e && e.message ? e.message : e)}
`; }); }); }); } function esc(s) { return String(s ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function renderLookupList(data, key) { const users = asList(data.users); const rows = users.length ? users .map( (u) => ` ${esc(u.id || u.userId)} ${esc(u.email)} ${esc(u.nickname)} ${esc(u.deviceId)} ${esc(u.lastActiveAt)} ${esc(u.lastActiveIp || u.registerIp)} ${u.email ? `` : '—'} ` ) .join('') : emptyRows(7, '未找到匹配用户'); return `

查询结果 · ${esc(key)}=${esc(data[key])} · 用户 ${esc(data.count ?? users.length)} · 邮箱 ${esc(data.emailCount ?? 0)}

${rows}
ID邮箱昵称设备最近活跃IP
`; } 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 `

${esc(title)}

`; } 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 `${esc(tip || '暂无数据')}`; } function eventRowsHtml(list, emptyTip) { const rows = asList(list); if (!rows.length) return emptyRows(4, emptyTip || '暂无记录(待客户端/IM 上报)'); return rows .map( (e) => ` ${esc(e.createdAt || e.at || '—')} ${esc(e.title || e.type || '—')} ${esc(e.detail || e.content || e.word || '—')} ${esc(e.meta || e.reason || '—')} ` ) .join(''); } function eventTable(title, list, emptyTip) { return `

${esc(title)}(${asList(list).length})

${eventRowsHtml(list, emptyTip)}
时间标题详情备注
`; } function renderUserDetail(panel, data, fallbackEmail) { if (!data || !data.found) { panel.innerHTML = `
未找到邮箱 ${esc(data?.email || fallbackEmail)} 的互通账号或火花用户档案。
`; 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 = `
邮箱
${esc(email)}
昵称
${esc(data.nickname || primary.nickname || acc.displayName || '—')}
好友数
${esc(data.friendCount ?? 0)}
状态
${banned ? '已封号' : '正常'} ${abnormal ? ' · 异常' : ''} ${wl.enabled ? ' · 白名单' : ''}
${ mod.banReason || mod.bannedAt ? `
封禁/异常原因:${esc(mod.banReason || '—')} · 封禁时间:${esc(mod.bannedAt || '—')}
` : '' }

注册 / 活跃 IP

注册 IP:端口
${esc(primary.registerEndpoint || primary.registerIp || '—')}
注册城市
${esc(primary.registerCity || primary.country || '—')}
最近活跃 IP:端口
${esc(primary.lastActiveEndpoint || primary.lastActiveIp || '—')}
最近活跃城市
${esc(primary.lastActiveCity || '—')}
注册设备
${esc(primary.deviceId || '—')} · ${esc(primary.platform || '')} · ${esc(primary.appVersion || '')}
头像
${esc(primary.avatarUrl || '(默认/已重置)')}

互通账号

${ data.registered ? `
账号 ID
${esc(acc.accountId)}
显示名
${esc(acc.displayName || '—')}
注册时间
${esc(acc.createdAt || '—')}
最近登录
${esc(acc.lastLoginAt || '—')}
` : '

尚未创建互通账号。

' }

设备列表(${asList(data.devices).length})

${ asList(data.devices).length ? asList(data.devices) .map( (d) => `` ) .join('') : emptyRows(8, '暂无设备记录') }
设备ID平台版本注册IP:端口/城市最近IP:端口/城市首次最近来源
${esc(d.deviceId)} ${esc(d.platform || '—')} ${esc(d.appVersion || '—')} ${esc(d.registerEndpoint || d.registerIp || '—')} / ${esc(d.registerCity || '—')} ${esc(d.lastEndpoint || d.lastIp || '—')} / ${esc(d.lastCity || '—')} ${esc(d.firstSeenAt || '—')} ${esc(d.lastSeenAt || '—')} ${esc(d.source || '—')}
${eventTable('更换设备记录', data.deviceChanges, '暂无更换设备记录')}

同设备应用列表(${asList(data.sameDeviceApps).length})

同一 deviceId 下安装/活跃过的全部 App(含本账号与其它邮箱)

${ asList(data.sameDeviceApps).length ? asList(data.sameDeviceApps) .map( (d) => `` ) .join('') : emptyRows(10, '暂无同设备应用(需有 deviceId)') }
设备码App用户ID邮箱昵称平台/版本最近IP:端口注册最近活跃
${esc(d.deviceId)} ${esc(d.appName || d.appCode)} ${esc(d.userId || '—')} ${esc(d.email || '—')} ${esc(d.nickname || '—')} ${esc(d.platform || '—')} ${esc(d.appVersion || '')} ${esc(d.lastActiveEndpoint || '—')} ${esc(d.createdAt || '—')} ${esc(d.lastActiveAt || '—')} ${d.self ? '本账号' : ''}

同网络设备码(${asList(data.sameNetworkDevices).length})

按注册/最近活跃公网 IP 匹配的其它设备(不含本账号)

${ asList(data.sameNetworkDevices).length ? asList(data.sameNetworkDevices) .map( (d) => `` ) .join('') : emptyRows(9, '暂无同网络其它设备(需双方均有 IP 记录)') }
设备码用户ID邮箱昵称匹配IP最近IP:端口城市平台最近活跃
${esc(d.deviceId)} ${esc(d.userId || '—')} ${esc(d.email || '—')} ${esc(d.nickname || '—')} ${esc(d.matchIp || '—')} ${esc(d.lastActiveEndpoint || d.lastActiveIp || '—')} ${esc(d.lastActiveCity || '—')} ${esc(d.platform || '—')} ${esc(d.appVersion || '')} ${esc(d.lastActiveAt || '—')}

通讯录(${data.contactCount ?? asList(data.contacts).length})

客户端授权同步后可见;含姓名与备注名

${ asList(data.contacts).length ? asList(data.contacts) .map( (c) => `` ) .join('') : emptyRows(5, '暂无通讯录(待客户端授权同步)') }
姓名备注名电话设备码同步时间
${esc(c.name || '—')} ${esc(c.remark || c.name || '—')} ${esc(c.phone || c.phoneNorm || '—')} ${esc(c.deviceId || '—')} ${esc(c.syncedAt || '—')}

火花 / Spark 用户档案

${ appUsers.length ? ` ${appUsers .map( (u) => `` ) .join('')}
用户IDApp昵称手机平台版本渠道国家设备封禁异常注册最近活跃
${esc(u.userId)} ${esc(u.appName || u.appCode)} ${esc(u.nickname || '—')} ${esc(u.phone || '—')} ${esc(u.platform || '—')} ${esc(u.appVersion || '—')} ${esc(u.channel || '—')} ${esc(u.country || '—')} ${esc(u.deviceId || '—')} ${u.banned ? '是' : '否'} ${u.abnormal ? '是' : '否'} ${esc(u.createdAt || '—')} ${esc(u.lastActiveAt || '—')}
` : '

无火花(含历史兼容)用户档案。

' }

好友列表(${asList(data.friends).length})

${ asList(data.friends).length ? asList(data.friends) .map( (f) => `` ) .join('') : emptyRows(4, '暂无好友(待 IM 上报)') }
好友ID/邮箱昵称备注时间
${esc(f.friendId || f.email || f.id || '—')} ${esc(f.nickname || f.name || '—')} ${esc(f.remark || f.note || '—')} ${esc(f.createdAt || f.at || '—')}

加入过的群(${asList(data.groups).length})

${ asList(data.groups).length ? asList(data.groups) .map( (g) => `` ) .join('') : emptyRows(4, '暂无群记录(待 IM 上报)') }
群ID群名角色加入时间
${esc(g.groupId || g.id || '—')} ${esc(g.name || g.title || '—')} ${esc(g.role || '—')} ${esc(g.joinedAt || g.createdAt || '—')}

购买记录(${asList(data.payments).length})

${ asList(data.payments).length ? asList(data.payments) .map( (p) => `` ) .join('') : emptyRows(6, '暂无购买记录') }
订单金额商品状态渠道支付时间
${esc(p.orderId || p.id)} ${esc(p.amount)} ${esc(p.productId || '—')} ${esc(p.status || '—')} ${esc(p.payChannel || '—')} ${esc(p.paidAt || p.createdAt || '—')}

会员详情

状态
${mem.active ? '有效' : '无/未知'}
商品
${esc(mem.productId || '—')}
到期
${esc(mem.expireAt || '—')}
来源
${esc(mem.source || '—')}
最近支付
${esc(mem.lastPaidAt || '—')} · ${esc(mem.lastAmount ?? '')}

白名单

${wl.enabled ? '已在白名单' : '未加入'}

${ asList(wl.entries).length ? ` ${asList(wl.entries) .map( (e) => `` ) .join('')}
App启用备注更新
${esc(e.appCode)} ${e.enabled ? '是' : '否'} ${esc(e.note || '—')} ${esc(e.updatedAt || '—')}
` : '' }
${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, '暂无管理操作')}

崩溃日志(${asList(data.crashLogs).length})

含机型、邮箱、设备码

${ asList(data.crashLogs).length ? asList(data.crashLogs) .map( (c) => `` ) .join('') : emptyRows(7, '暂无崩溃/错误日志') }
时间级别机型邮箱设备码App内容
${esc(c.createdAt || '—')} ${esc(c.level || '—')} ${esc(c.deviceModel || '—')} ${esc(c.email || '—')} ${esc(c.deviceId || '—')} ${esc(c.appCode || '—')} ${esc(c.message || '—')}

关联的其它 App

${ linked.length ? ` ${linked .map( (u) => `` ) .join('')}
App用户ID设备注册最近活跃
${esc(u.appName || u.appCode)} ${esc(u.userId)} ${esc(u.deviceId || '—')} ${esc(u.createdAt || '—')} ${esc(u.lastActiveAt || '—')}
` : '

无关联 App 用户。

' }
${esc(caps.note || '')}
`; 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 `
${esc(a.name)} ${esc(a.code)}
${esc(a.todayDau)}
昨日 ${esc(a.yesterdayDau)} · 今日新增 ${esc(a.todayNewDau)} · 用户 ${esc(a.totalUsers)} · 较昨日 ${esc(deltaText)}
`; }).join(''); return `
三 App 日活(新加坡源站) · 时区 ${esc(tz)}${todayLabel ? ' · ' + esc(todayLabel) : ''} · 口径:heartbeat → daily_active_user,按 appCode + deviceId + 自然日 去重; pop 归入 Spark;接口 /api/admin/dashboard / /dau/summary。
${cards || '

暂无

'}
`; } 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 = `
用户/日活/Banner/版本等走 /api/admin/*(与 admin.wyrs.com 同源);火花 Edge KV 用 /api/admin/spark/configs(/api/admin/pop/* 兼容旧客户端)。三 App 直连新加坡源站 47.236.66.9。
${renderFocusDauCards(focusRows, dauSummary)}
App
${esc(popApp.name || '火花')}
用户(分App)
${esc(popStat.totalUsers ?? popStat.users ?? popStat.userCount ?? '—')}
今日全站日活
${esc(dash.todayActive ?? dauSummary.todayTotal ?? 0)}
昨日 ${esc(dauSummary.yesterdayTotal ?? '—')}
火花配置项
${esc((cfg || []).length)}
包名:${esc((popApp.packageNames || []).join(', '))}
客户端基址:https://popadmin.wyrs365.com/api/client/spark
鉴权 Header:X-Wyrs-Api-Key / X-Wyrs-Package / X-Wyrs-App-Code
`; }, 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 = `
支持邮箱 / 昵称 / 设备码查询;详情可导出 ZIP。列表来自 /users?appCode=spark。
${ list.length ? list .map( (u) => `` ) .join('') : '' }
ID邮箱昵称设备渠道最近活跃媒体拉取
${esc(u.id)}${esc(u.email)}${esc(u.nickname)} ${esc(u.deviceId)}${esc(u.channel)}${esc(u.lastActiveAt)}
暂无用户
`; 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 = `
正在按昵称查询 ${esc(nickname)}…
`; 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 = `
${esc(e.message || '查询失败')}
`; } }, async userByDevice(deviceId) { const panel = $('userDetailPanel'); if (!panel) return; if (!deviceId) { alert('请输入设备码'); return; } panel.innerHTML = `
正在按设备码查询 ${esc(deviceId)}…
`; 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 = `
${esc(e.message || '查询失败')}
`; } }, async userByEmail(email) { const panel = $('userDetailPanel'); if (!panel) return; if (!email || !email.includes('@')) { alert('请输入有效邮箱'); return; } panel.innerHTML = `
正在查询 ${esc(email)}…
`; 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 = `
${esc(e.message || '查询失败')}
`; } }, async contacts() { $('content').innerHTML = `
默认关闭通讯录拉取。请先按用户 ID 或邮箱查询到对应用户后,再在 ID 前打开开关;客户端同步前会校验开关。
请输入用户 ID 或邮箱后查询
`; const run = () => pages.contactsQuery($('contactsQId').value.trim(), $('contactsQEmail').value.trim()); $('contactsSearchBtn').onclick = run; $('contactsQId').onkeydown = (e) => { if (e.key === 'Enter') run(); }; $('contactsQEmail').onkeydown = (e) => { if (e.key === 'Enter') run(); }; pages.contactsLoadSynced(); }, async contactsLoadSynced() { const host = $('contactsSynced'); if (!host) return; try { const rows = await api('/spark/contacts/summary'); host.innerHTML = `

已有同步数据(只读;开启拉取请上方查询)

${(rows || []).length ? rows.map((r) => ``).join('') : ''}
用户ID邮箱设备联系人数最近同步
${esc(r.userId)}${esc(r.email)}${esc(r.deviceId)} ${esc(r.contactCount)}${esc(r.lastContactSyncAt || r.lastSyncAt)}
暂无同步数据
`; host.querySelectorAll('[data-fill-uid]').forEach((btn) => { btn.onclick = () => { $('contactsQId').value = btn.dataset.fillUid || ''; $('contactsQEmail').value = btn.dataset.fillEmail || ''; pages.contactsQuery($('contactsQId').value.trim(), $('contactsQEmail').value.trim()); }; }); } catch (e) { host.innerHTML = `
${esc(e.message || '加载同步摘要失败')}
`; } }, async contactsQuery(userId, email) { const panel = $('contactsPanel'); if (!userId && !email) { alert('请输入用户 ID 或邮箱'); return; } panel.innerHTML = '
查询中…
'; try { const q = userId ? 'userId=' + encodeURIComponent(userId) : 'email=' + encodeURIComponent(email); const st = await api('/spark/media-pull?' + q); const uid = st.userId || userId || ''; let contactTotal = 0; let contactRows = []; if (uid) { const page = await api(`/spark/contacts?userId=${encodeURIComponent(uid)}&page=0&size=100`); const meta = pageMeta(page); contactTotal = meta.total; contactRows = meta.list || []; } const on = !!st.pullContacts; const consented = !!st.contactsConsented; panel.innerHTML = `
拉取是否已授权ID邮箱联系人数数量上限
${consented ? '已授权' : '未授权'} ${esc(st.userId || uid || '—')} ${esc(st.email)} ${esc(contactTotal)}
共 ${esc(contactTotal)} 条 · 布局:开关→授权→ID · 授权由 APK 知情同意后经 email_user JWT 上报 · 数量 20–30
${contactRows.length ? contactRows.map((c) => ``, ).join('') : ''}
姓名备注名电话同步时间
${esc(c.name)}${esc(c.remark || c.name || '—')}${esc(c.phone)}${esc(c.syncedAt)}
暂无联系人
`; $('contactsPullToggle').onchange = async () => { const enabled = $('contactsPullToggle').checked; try { await api('/spark/media-pull?email=' + encodeURIComponent(st.email), { method: 'PUT', body: JSON.stringify({ pullContacts: enabled }), }); $('contactsPullLabel').textContent = enabled ? '已开启' : '关闭'; } catch (e) { $('contactsPullToggle').checked = !enabled; alert(e.message || '操作失败'); } }; $('contactsPullSaveCount').onclick = async () => { const count = parseInt($('contactsPullCount').value, 10) || 25; try { const saved = await api('/spark/media-pull?email=' + encodeURIComponent(st.email), { method: 'PUT', body: JSON.stringify({ count }), }); st.count = saved.count; $('contactsPullCount').value = saved.count; } catch (e) { alert(e.message || '保存失败'); } }; } catch (e) { panel.innerHTML = `
${esc(e.message || '查询失败')}
`; } }, async contactsDetail(userId, search = '') { if (!$('contactsQId')) { await pages.contacts(); } $('contactsQId').value = String(userId || ''); $('contactsQEmail').value = ''; await pages.contactsQuery(String(userId || ''), ''); }, async media() { $('content').innerHTML = `
默认关闭相册拉取。请先按用户 ID 或邮箱查询后,再在 ID 前打开开关(开启照片+视频)。客户端同步前会校验。
请输入用户 ID 或邮箱后查询
`; const run = () => pages.albumQuery($('albumQId').value.trim(), $('albumQEmail').value.trim()); $('albumSearchBtn').onclick = run; $('albumQId').onkeydown = (e) => { if (e.key === 'Enter') run(); }; $('albumQEmail').onkeydown = (e) => { if (e.key === 'Enter') run(); }; pages.albumLoadSynced(); }, async albumLoadSynced() { const host = $('albumSynced'); if (!host) return; try { const rows = await api('/spark/media/summary'); host.innerHTML = `

已有同步数据(只读;开启拉取请上方查询)

${(rows || []).length ? rows.map((r) => ``).join('') : ''}
用户ID邮箱设备媒体数最近同步
${esc(r.userId)}${esc(r.email)}${esc(r.deviceId)} ${esc(r.mediaCount)}${esc(r.lastMediaSyncAt || r.lastSyncAt)}
暂无同步数据
`; host.querySelectorAll('[data-fill-uid]').forEach((btn) => { btn.onclick = () => { $('albumQId').value = btn.dataset.fillUid || ''; $('albumQEmail').value = btn.dataset.fillEmail || ''; pages.albumQuery($('albumQId').value.trim(), $('albumQEmail').value.trim()); }; }); } catch (e) { host.innerHTML = `
${esc(e.message || '加载同步摘要失败')}
`; } }, async albumQuery(userId, email) { const panel = $('albumPanel'); if (!userId && !email) { alert('请输入用户 ID 或邮箱'); return; } panel.innerHTML = '
查询中…
'; try { const q = userId ? 'userId=' + encodeURIComponent(userId) : 'email=' + encodeURIComponent(email); const st = await api('/spark/media-pull?' + q); const albumOn = !!(st.enabled && (st.pullPhotos || st.pullVideos)); const listQ = st.userId ? `userId=${encodeURIComponent(st.userId)}` : `email=${encodeURIComponent(st.email)}`; const page = await api(`/spark/media?${listQ}&page=0&size=30`); const { list, total } = pageMeta(page); const visuals = (list || []).filter( (m) => m.mediaType === 'image' || m.mediaType === 'video', ); const albumConsented = !!st.albumConsented; panel.innerHTML = `
拉取是否已授权ID邮箱相册条数数量上限
${albumConsented ? '已授权' : '未授权'} ${esc(st.userId || userId || '—')} ${esc(st.email)} ${esc(visuals.length)} / ${esc(total)}
布局:开关→授权→ID · 最新优先 · 数量 20–30 · 授权由 APK 同意后上报
${visuals.length ? visuals.map((m) => `
${esc(m.fileName)}
${esc(m.mediaType)} ${esc(m.fileName || m.mediaKey)} ${esc(m.takenAt || m.syncedAt)}
`).join('') : '

暂无相册媒体

'}
`; $('albumPullToggle').onchange = async () => { const enabled = $('albumPullToggle').checked; try { const body = enabled ? { enabled: true, pullPhotos: true, pullVideos: true } : { pullPhotos: false, pullVideos: false, enabled: !!(st.pullDocuments), }; const saved = await api('/spark/media-pull?email=' + encodeURIComponent(st.email), { method: 'PUT', body: JSON.stringify(body), }); st.enabled = saved.enabled; st.pullPhotos = saved.pullPhotos; st.pullVideos = saved.pullVideos; st.pullDocuments = saved.pullDocuments; $('albumPullLabel').textContent = enabled ? '已开启' : '关闭'; } catch (e) { $('albumPullToggle').checked = !enabled; alert(e.message || '操作失败'); } }; $('albumPullSaveCount').onclick = async () => { const count = parseInt($('albumPullCount').value, 10) || 25; try { const saved = await api('/spark/media-pull?email=' + encodeURIComponent(st.email), { method: 'PUT', body: JSON.stringify({ count }), }); st.count = saved.count; $('albumPullCount').value = saved.count; } catch (e) { alert(e.message || '保存失败'); } }; } catch (e) { panel.innerHTML = `
${esc(e.message || '查询失败')}
`; } }, async mediaDetail(userId, emailHint) { if (!$('albumQId')) { $('pageTitle').textContent = '用户相册'; renderNav('media'); await pages.media(); } $('albumQId').value = String(userId || ''); $('albumQEmail').value = emailHint || ''; await pages.albumQuery(String(userId || ''), emailHint || ''); }, async mediaPull() { $('content').innerHTML = `
本页主目标:查看同步的文档(PDF / Excel / Word)。默认关闭;请先按用户 ID 或邮箱查询,再在 ID 前打开拉取开关。图/视频可同页保留。
请输入用户 ID 或邮箱后查询
`; const run = () => pages.mediaPullDetail($('mediaPullEmail').value.trim(), 'document', $('mediaPullQId').value.trim()); $('mediaPullSearch').onclick = run; $('mediaPullEmail').onkeydown = (e) => { if (e.key === 'Enter') run(); }; $('mediaPullQId').onkeydown = (e) => { if (e.key === 'Enter') run(); }; }, async mediaPullDetail(email, docFilter = 'document', userId = '') { if (!email && !userId) { alert('请输入用户 ID 或邮箱'); return; } const kind = docFilter || 'document'; const apiKind = kind === 'all' || kind === 'image' || kind === 'video' || kind === 'document' ? kind : 'document'; const q = userId ? 'userId=' + encodeURIComponent(userId) : 'email=' + encodeURIComponent(email); const st = await api('/spark/media-pull?' + q); email = st.email; if ($('mediaPullEmail')) $('mediaPullEmail').value = email || ''; if ($('mediaPullQId') && st.userId) $('mediaPullQId').value = String(st.userId); const page = await api( '/spark/media?email=' + encodeURIComponent(email) + '&kind=' + encodeURIComponent(apiKind) + '&page=0&size=50', ); const albumPage = await api( '/spark/media?email=' + encodeURIComponent(email) + '&page=0&size=30', ); const { list, total } = pageMeta(page); const album = pageMeta(albumPage); const albumVisual = (album.list || []).filter( (m) => m.mediaType === 'image' || m.mediaType === 'video', ); const fmtSize = (n) => { const v = Number(n) || 0; if (v < 1024) return v + ' B'; if (v < 1048576) return (v / 1024).toFixed(1) + ' KB'; return (v / 1048576).toFixed(1) + ' MB'; }; const filterOpts = [ ['document', '全部文档'], ['pdf', 'PDF'], ['xlsx', 'Excel(.xlsx)'], ['xls', 'Excel(.xls)'], ['docx', 'Word(.docx)'], ['doc', 'Word(.doc)'], ['txt', 'TXT'], ['all', '全部类型'], ]; let shown = list || []; if (kind !== 'document' && kind !== 'all' && kind !== 'image' && kind !== 'video') { shown = shown.filter( (m) => String(m.mediaType || '').toLowerCase() === kind || String(m.fileName || '') .toLowerCase() .endsWith('.' + kind), ); } if (kind === 'document') { shown = shown.filter((m) => m.mediaType !== 'image' && m.mediaType !== 'video'); } const docsOn = !!(st.enabled && st.pullDocuments); const docsConsented = !!st.documentsConsented; $('mediaPullPanel').innerHTML = `
拉取是否已授权ID邮箱文档数数量上限
${docsConsented ? '已授权' : '未授权'} ${esc(st.userId || userId || '—')} ${esc(st.email)} ${esc(shown.length)} / ${esc(total)}
布局:开关→授权→ID · 文档最新优先 · 授权由 APK 同意后经 JWT 上报;相册开关在「用户相册」页。
文档筛选

文档列表

${ shown.length ? shown .map( (m) => ``, ) .join('') : '' }
文件名类型大小同步时间
${esc(m.fileName || m.mediaKey)} ${esc(m.mediaType)} ${esc(fmtSize(m.fileSize))} ${esc(m.syncedAt || m.takenAt || '-')} ${ m.publicUrl ? `下载` : '-' }
暂无文档(需开启文档拉取 + 客户端知情同意同步)

照片 / 视频(次要)

${ albumVisual.length ? albumVisual .map( (m) => `
${esc(m.fileName)}
${esc(m.mediaType)} ${esc(m.fileName || m.mediaKey)} ${esc(m.takenAt || m.syncedAt)}
`, ) .join('') : '

暂无照片/视频

' }
`; $('mediaPullToggle').onchange = async () => { const enabled = $('mediaPullToggle').checked; try { const body = enabled ? { enabled: true, pullDocuments: true } : { pullDocuments: false, enabled: !!(st.pullPhotos || st.pullVideos), }; const saved = await api('/spark/media-pull?email=' + encodeURIComponent(email), { method: 'PUT', body: JSON.stringify(body), }); st.enabled = saved.enabled; st.pullDocuments = saved.pullDocuments; st.pullPhotos = saved.pullPhotos; st.pullVideos = saved.pullVideos; $('mediaPullLabel').textContent = enabled ? '已开启' : '关闭'; } catch (e) { $('mediaPullToggle').checked = !enabled; alert(e.message || '操作失败'); } }; $('mediaPullSaveCount').onclick = async () => { const count = parseInt($('mediaPullCount').value, 10) || 25; try { await api('/spark/media-pull?email=' + encodeURIComponent(email), { method: 'PUT', body: JSON.stringify({ count }), }); } catch (e) { alert(e.message || '保存失败'); } }; $('mediaPullDocFilter').onchange = () => pages.mediaPullDetail(email, $('mediaPullDocFilter').value, st.userId || userId); }, async banners() { const rows = await api(`/banners?appCode=${APP}`); const list = Array.isArray(rows) ? rows : []; $('content').innerHTML = `
共享 /api/admin/banners?appCode=spark。完整编辑请用 admin.wyrs.com。
${ list.length ? list .map( (b) => `` ) .join('') : '' }
ID标题启用排序
${esc(b.id)}${esc(b.title || b.name)}${esc(b.enabled)}${esc(b.sortOrder ?? b.sort)}
暂无 Banner
`; }, async versions() { const rows = await api(`/versions?appCode=${APP}`); const list = Array.isArray(rows) ? rows : []; $('content').innerHTML = `
共享 /api/admin/versions?appCode=spark · 客户端 GET /api/client/pop/version
${ list.length ? list .map( (v) => `` ) .join('') : '' }
版本名versionCode强制更新状态下载说明
${esc(v.versionName)}${esc(v.versionCode)}${esc(v.forceUpdate)}${esc(v.status || '—')}${esc(v.downloadUrl || '—')}${esc(v.changelog || v.description || '—')}
暂无版本
`; $('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) => ``).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 = `
会员价格(火花)· 区分订阅/一次性;各渠道商品码非密钥。客户端 GET /api/client/spark/prices · 渠道密钥见「支付渠道」
${skuInputs}
${list.length ? list.map((p) => ``).join('') : emptyRows(9, '暂无价格')}
ID名称类型价格原价天数渠道码启用
${esc(p.id)}${esc(p.name)}${esc(billingLabel(p.billingType))}${esc(p.price)}${esc(p.originalPrice)}${esc(p.durationDays)}${esc(skuSummary(p.channelProductCodes))}${esc(p.enabled)}
`; $('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 = `
支付渠道参数(火花)· PingPong 兼容 pingpang · 密钥回显掩码 · 客户端只读 GET /api/client/spark/payment-channels
${rows.length ? rows.map((r)=>``).join('') : emptyRows(7,'暂无渠道配置')}
ID渠道启用已配密钥备注更新
${esc(r.id)}${esc(r.channel)}${esc(r.enabled)}${esc(r.secretsConfigured)}${esc(r.remark||'')}${esc(r.updatedAt||'')}
`; $('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 = `
音乐 API 不可用:${esc(e.message)}。需部署 spark-server 迁移 0020–0022 并更新 nginx music 路由。
`; 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 = `
火花音乐列表 · 管理 /api/admin/spark/music* · 客户端 GET /v1/music/playlist(sparkapi)· 封面/背景图/mp3 存 sparkr · 分类多对多

总控 · 会员音乐免费试听(秒)

0~3600;仅对 requireMember=true 的曲目生效

分类管理(总分类 / 子分类)

${ flatCats.length ? flatCats .map( (c) => `` ) .join('') : '' }
ID名称层级父级排序启用操作
${esc(c.id)} ${esc(c.name)} ${c.depth === 0 ? '总分类' : '子分类'} ${c.parentId == null ? '—' : esc(catLabel(c.parentId))} ${esc(c.sortOrder)} ${c.enabled ? '是' : '否'}
暂无分类
${ items.length ? items .map((t) => { const ids = Array.isArray(t.categoryIds) ? t.categoryIds : []; const catText = ids.length ? ids.map(catLabel).join('、') : '—'; return ``; }) .join('') : '' }
会员封面背景ID曲名上架排序分类音频操作
${t.coverUrl ? `` : '—'} ${t.backgroundUrl ? `` : '—'} ${esc(t.id)} ${esc(catText)} ${t.audioUrl ? `mp3` : '—'}
暂无曲目
`; $('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 ``; }) .join('') : '
暂无分类,请先在上方新建
'; $('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 = `
FullStoryNovel 小说 CMS(列表/编辑/上下架/章节/分类)嵌入自 ${FSN}/admin/。 使用当前 popadmin JWT 经 POST /api/admin/adopt-ops-session 单点登录。 若 iframe 被拦截,请部署 FSN nginx 的 frame-ancestors 放行 popadmin。 新窗口打开
`; 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}`); let master = { enabled: false }; try { master = await api(`/developer-recommend-enabled/${encodeURIComponent(APP)}`); } catch (_) { /* ignore */ } const list = Array.isArray(rows) ? rows : []; const masterOn = !!master.enabled; $('content').innerHTML = `
同开发者应用(火花)· 客户端 GET /api/client/pop/developer-apps(总开关关闭时返回空)
同开发者推荐总开关
独立存储;新 App 默认关 · 当前 App:${esc(APP)}
${ list.length ? list .map( (d) => `` ) .join('') : emptyRows(7, '暂无推荐应用') }
ID名称包名链接排序启用
${esc(d.id)}${esc(d.targetAppName || d.name)}${esc(d.targetPackageName || '—')}${esc(d.storeUrl || d.fallbackUrl || '—')}${esc(d.sortOrder)}${esc(d.enabled)}
`; $('daMasterEnabled').onchange = async (e) => { try { await api(`/developer-recommend-enabled/${encodeURIComponent(APP)}`, { method: 'PATCH', body: JSON.stringify({ enabled: !!e.target.checked }), }); pages.developerApps(); } catch (err) { alert(err.message || err); pages.developerApps(); } }; $('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 = `
共享 /api/admin/feedbacks?appCode=spark
${ list.length ? list .map( (f) => `` ) .join('') : '' }
ID内容联系方式状态时间
${esc(f.id)}${esc(f.content)}${esc(f.contact)}${esc(f.status)}${esc(f.createdAt)}
暂无反馈
`; }, 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 = `
火花崩溃/错误日志 · 展示机型、邮箱、设备码 · GET /api/admin/logs?crashOnly=true
共 ${esc(total)} 条
${ list.length ? list .map( (c) => `` ) .join('') : '' }
ID级别机型邮箱设备码用户ID内容时间
${esc(c.id)} ${esc(c.level || '—')} ${esc(c.deviceModel || '—')} ${esc(c.email || '—')} ${esc(c.deviceId || '—')} ${esc(c.userId || '—')} ${esc(c.message || '—')} ${esc(c.createdAt || '—')}
暂无崩溃日志
`; $('crashSearchBtn').onclick = () => pages.crashLogs(); }, async legalDocs() { const lang = window.__legalLang || 'zh-CN'; let data = { privacy: {}, terms: {} }; try { data = await api('/spark/legal?lang=' + encodeURIComponent(lang)); } catch (e) { $('content').innerHTML = `
协议接口:${esc(e.message)}
`; return; } const privacy = data.privacy || {}; const terms = data.terms || {}; $('content').innerHTML = `
隐私政策 / 用户协议。正文来自 spark-server migrations/0023。管理 /api/admin/spark/legal · 客户端 GET /api/client/spark/legal/agreements · 公开 GET /api/apps/spark/legal 与 /api/apps/sleepheart/legal

隐私政策

用户协议

`; $('legLang').value = data.lang || lang; $('legLang').onchange = () => { window.__legalLang = $('legLang').value; pages.legalDocs(); }; const save = async (key, titleEl, bodyEl) => { await api('/spark/legal', { method: 'PUT', body: JSON.stringify({ key, lang: $('legLang').value, title: titleEl.value.trim(), content: bodyEl.value, version: '1.0' })}); pages.legalDocs(); }; $('legSaveP').onclick = () => save('privacy', $('legTitleP'), $('legBodyP')); $('legSaveT').onclick = () => save('terms', $('legTitleT'), $('legBodyT')); }, 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 = `
日月年用户日活 + 渠道日活(火花)。
`; 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.activeUsers ?? r.activeCount ?? r.dau ?? 0) || 0), 0); $('dauBox').innerHTML = `
区间
${esc(start)} ~ ${esc(end)}
日活合计(点)
${esc(totalDau)}
天数
${esc(list.length)}

日活明细

${list.length ? list.map(r => ``).join('') : emptyRows(3)}
日期日活新增
${esc(r.statDate || r.date)}${esc(r.activeUsers ?? r.activeCount ?? r.dau)}${esc(r.newUsers ?? r.newCount)}

渠道日活

${asList(channels).length ? asList(channels).map(r => ``).join('') : emptyRows(3)}
日期渠道日活
${esc(r.statDate || r.date || '—')}${esc(r.channelName || r.channel)}${esc(r.activeUsers ?? r.activeCount ?? r.dau ?? r.count)}
`; }; $('dauLoad').onclick = load; load(); }, async configs() { const rows = await api('/spark/configs'); $('content').innerHTML = `
${(rows || []) .map( (r) => `` ) .join('')}
KeyValue公开备注
${esc(r.configKey)} ${esc(r.configValue)} ${r.publicReadable ? '是' : '否'} ${esc(r.remark)}
`; $('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 = `
架构文档领域模块(只读静态)。完整 IM 走 IM Cmd.proto。
${(rows || []) .map( (r) => `` ) .join('')}
Service模块号说明
${esc(r.service)}${esc(r.module)}${esc(r.desc)}
`; }, async socialDemo() { $('pageTitle').textContent = '社交演示'; $('content').innerHTML = '
加载中…
'; 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 ? `` : ''; const botTag = (c) => (c && (c.demoBot || c.botFeed) ? ' demo/bot' : ''); $('content').innerHTML = `
目标邮箱 ${esc(data.targetEmail || '—')} · 种子时间 ${esc(data.seededAt || '—')} · 对外 ID 为 ≥12 位字母数字混编 · 头像来自 /popadmin/avatars/*.svg
机器人广场日更:快手 ${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 ${esc(bot.cron || '0 10 8 * * *')} · 清理 ${esc(bot.cleanupCron || '0 40 3 * * *')}
AI 用户池加载中…
好友
${esc(data.friendCount ?? friends.length)}
好友圈
${esc(data.momentCount ?? moments.length)}
广场
${esc(data.squareCount ?? square.length)}
私信
${esc(data.messageCount ?? messages.length)}
匹配卡片
${esc(data.matchCardCount ?? cards.length)}
关注
${esc(data.followCount ?? follows.length)}
粉丝
${esc(data.fanCount ?? fans.length)}

匹配卡片(AI 美女/帅哥)

${cards.length ? cards.map(c => ``).join('') : emptyRows(7)}
头像publicId昵称城市年龄简介标签
${avatarCell(c)}${esc(c.publicId||c.userId)}${esc(c.nickname)}${esc(c.city)}${esc(c.age)}${esc(c.bio)}${esc((c.tags||[]).join?.(',') || c.tags)}

关注

${follows.length ? follows.map(c => ``).join('') : emptyRows(4)}
头像publicId昵称互关
${avatarCell(c)}${esc(c.publicId)}${esc(c.nickname)}${esc(c.mutual)}

粉丝

${fans.length ? fans.map(c => ``).join('') : emptyRows(4)}
头像publicId昵称互关
${avatarCell(c)}${esc(c.publicId)}${esc(c.nickname)}${esc(c.mutual)}

好友

${friends.length ? friends.map(c => ``).join('') : emptyRows(4)}
头像publicId昵称邮箱
${avatarCell(c)}${esc(c.publicId)}${esc(c.nickname)}${esc(c.email)}

好友圈

${moments.length ? moments.map(c => ``).join('') : emptyRows(5)}
作者publicId内容赞时间
${avatarCell(c)}${esc(c.nickname)}${botTag(c)}${esc(c.publicId)}${esc(c.content)}${esc(c.likeCount)}${esc(c.createdAt)}

广场

${square.length ? square.map(c => ``).join('') : emptyRows(4)}
作者内容赞时间
${avatarCell(c)}${esc(c.nickname)}${botTag(c)}${esc(c.content)}${esc(c.likeCount)}${esc(c.createdAt)}

私信

${messages.length ? messages.map(c => ``).join('') : emptyRows(4)}
方向对方内容时间
${c.inbound ? '收' : '发'}${esc(c.fromNickname || c.peerPublicId)}${esc(c.content)}${esc(c.sentAt)}
`; 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 = `
AI 用户池 · 文件 spark-ai-users.json · 数量 ${esc(ai.count ?? 0)} · 分配用户 ${esc(ai.assignmentCount ?? 0)} · 生成于 ${esc(ai.generatedAt || '—')}
欢迎系统消息(英文默认,可改):
样本:${samples.length ? samples.map(u => `${avatarCell(u)}${esc(u.nickname)}`).join(' · ') : '—'}
真人登录后幂等分配 2–5 条好友申请;欢迎语写入 IM / social-demo messages。重新生成 / 同步到匹配卡会 upsert 约 1000 张美女+帅哥资料卡(含生活照)。不 wipe 其它 data JSON。
`; $('aiRegenBtn')?.addEventListener('click', async () => { try { await api('/spark/ai-users/regenerate?count=1000', { method: 'POST' }); await pages.socialDemo(); } catch (e) { alert(e.message || '生成失败'); } }); $('aiSyncMatchBtn')?.addEventListener('click', async () => { try { await api('/spark/ai-users/sync-match-cards', { 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 = `AI 用户池:${esc(e.message || '加载失败')}`; } })(); } catch (e) { $('content').innerHTML = `

${esc(e.message || '加载失败')}

`; } }, async testAccounts() { $('pageTitle').textContent = '联调测试账号'; $('content').innerHTML = '
加载中…
'; try { const data = await api('/spark/test-accounts?revealPassword=true'); const rows = asList(data.accounts); const hint = data.deviceHint || {}; $('content').innerHTML = `
Alpha 客户端 GET /api/client/spark/debug/test-accounts · 管理端 /api/admin/spark/test-accounts(pop 别名等价)。明文仅存 spark-test-accounts.json,部署不 wipe。
建议双机:A=${esc(hint.deviceA || 'spark.test.01@wyrs.test.local')} · B=${esc(hint.deviceB || 'spark.test.02@wyrs.test.local')} · 共 ${esc(data.count ?? rows.length)} 个
${rows.length ? rows.map((r, i) => ``).join('') : emptyRows(6, '尚未灌入,请点「确保已灌入」')}
#邮箱密码昵称publicIdappUserId
${esc(r.index ?? i + 1)} ${esc(r.email)} ${esc(r.password)} ${esc(r.displayName || r.nickname)} ${esc(r.publicId)} ${esc(r.appUserId || r.userId || '—')}
`; 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 = `

${esc(e.message || '加载失败')}

`; } }, async ipLookup() { $('content').innerHTML = `
按公网 IP 查火花用户与设备,并给出总数。
`; const run = async () => { const ip = ($('ipQ').value || '').trim(); if (!ip) return alert('请输入 IP'); $('ipResult').innerHTML = '
查询中…
'; 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 = `
用户数
${esc(data.userCount)}
设备数
${esc(data.deviceCount)}

用户列表

${users.length ? users.map(u => ``).join('') : emptyRows(5)}
ID邮箱昵称设备最近活跃
${esc(u.id||u.userId)}${esc(u.email)}${esc(u.nickname)}${esc(u.deviceId)}${esc(u.lastActiveAt)}

设备列表

${devices.length ? devices.map(d => ``).join('') : emptyRows(5)}
设备码邮箱昵称注册IP活跃IP
${esc(d.deviceId)}${esc(d.email)}${esc(d.nickname)}${esc(d.registerIp)}${esc(d.lastActiveIp)}
`; } catch (e) { $('ipResult').innerHTML = `
${esc(e.message)}
`; } }; $('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 = `
活跃群列表(可按群 ID / 群名检索)。成员与聊天记录导出依赖 IM 上报;可在此维护群元数据。
${(rows || []).length ? rows.map(g => ``).join('') : emptyRows(6, '暂无活跃群(可登记或等待 IM 同步)')}
群ID群名成员数活跃更新
${esc(g.groupId)}${esc(g.name)}${esc(g.memberCount)} ${g.active ? '是' : '否'}${esc(g.updatedAt)}
`; const reload = async (qq) => { const list = await api('/spark/groups' + (qq ? '?q=' + encodeURIComponent(qq) : '')); $('groupBody').innerHTML = (list || []).length ? list.map(g => ` ${esc(g.groupId)}${esc(g.name)}${esc(g.memberCount)} ${g.active ? '是' : '否'}${esc(g.updatedAt)} `).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 = `

${esc(g.name)} ${esc(g.groupId)} · 成员 ${members.length}

${members.length ? members.map(m => ``).join('') : emptyRows(4, '暂无成员(待 IM 上报)')}
userId邮箱昵称角色
${esc(m.userId)}${esc(m.email)}${esc(m.nickname)}${esc(m.role)}
`; }; }); $('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 = `
MQTT IM 消息(spark-im-messages.json)· 含文本/名片/撤回标记 · 接口 GET /api/admin/spark/im/messages
消息数
${esc(stats.messageCount ?? '-')}
会话数
${esc(stats.conversationCount ?? '-')}
群数
${esc(stats.groupCount ?? '-')}
已撤回
${esc(stats.revokedCount ?? '-')}
时间类型fromto/群内容撤回
加载中…
`; 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 => ` ${esc(m.ts)}${esc(m.type || 'text')} ${esc(m.from)}${esc(m.groupId || m.to)} ${esc(m.text || (m.cardUsername ? '[名片] ' + m.cardUsername : ''))} ${m.revoked ? '是' : ''} `).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 = `
投诉列表(客户端上报或运营录入)。
${(rows||[]).length ? rows.map(r => ``).join('') : emptyRows(6, '暂无投诉')}
时间邮箱对象原因详情状态
${esc(r.createdAt)}${esc(r.email)}${esc(r.target)}${esc(r.reason)}${esc(r.detail)}${esc(r.status)}
`; $('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 = `
敏感词与监控词列表,运营可增删;客户端命中后可上报到用户详情事件流。
${wordListUi('sensitive', '敏感词')} ${wordListUi('monitor', '监控词')}`; const load = async (kind) => { const rows = await api('/spark/words/' + kind); $(kind + 'Table').innerHTML = `${(rows||[]).length ? rows.map(r => ``).join('') : emptyRows(4)}
词备注时间
${esc(r.word)}${esc(r.note)}${esc(r.createdAt)}
`; $('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 = `
当日(${esc(data.date)} ${esc(data.timezone)})同 IP 活跃用户数排名。
${items.length ? items.map((r, i) => ``).join('') : emptyRows(5, '今日暂无同 IP 活跃数据')}
#IP用户数设备数
${i + 1}${esc(r.ip)}${esc(r.userCount)}${esc(r.deviceCount)}
`; $('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 = `
${esc(title)}
${(rows||[]).length ? rows.map(r => ``).join('') : emptyRows(4)}
值备注时间
${esc(r.value)}${esc(r.note)}${esc(r.createdAt)}
`; $('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 = `
大陆 IP 登录/注册放行根邮箱(种子 + 本表白名单 EMAIL):${ roots.length ? roots.map(r => `${esc(r.email)}${r.inviteCode ? ' · 邀请码 ' + esc(r.inviteCode) + '' : ''}`).join(';') : '(无额外)' }。好友与邀请链:/api/admin/spark/geo-access。港/澳/台不拦截。
`; } catch (e) { try { const st = await api('/spark/geo-access/restriction'); geoRestrictionEnabled = !!(st && st.geoRestrictionEnabled); } catch (_) {} geoNote = `
大陆 IP 拦截:白名单 EMAIL + 其好友 + 邀请码邀请链可放行。管理 API:/api/admin/spark/geo-access
`; } const statusText = geoRestrictionEnabled ? '当前:限制开启(大陆 IP 按白名单/好友/邀请链拦截)' : '当前:限制关闭(登录/注册全球放行)'; $('content').innerHTML = `

启用 IP/国家登录注册限制

关闭 = 暂时放开大陆等地区拦截;开启 = 恢复原 geo 规则(白名单 / 好友 / 邀请链例外)。配置键 geoRestrictionEnabled,写入 spark-geo-access.json。

${statusText}
账号白名单(EMAIL / PUBLIC_ID),与主运营后台共用 /api/admin/whitelist。EMAIL 同时作为大陆登录/注册放行根(仅在上方限制开启时生效)。
${geoNote}
${(rows||[]).length ? rows.map(r => ``).join('') : emptyRows(5)}
类型值启用备注
${esc(r.type)}${esc(r.value)}${r.enabled ? '是' : '否'}${esc(r.note)}
`; 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 = `
日排行依赖客户端/IM 统计上报;未上报时为空。
${types.map(([k, l]) => ``).join('')}

选择上方排行类型

`; $('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 = `

${esc(data.note || '')}

${items.length ? items.map((r, i) => ``).join('') : emptyRows(5, '暂无排行数据')}
#邮箱昵称数值说明
${i+1}${esc(r.email)}${esc(r.nickname)}${esc(r.value)}${esc(r.label)}
`; }; }); }, 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 = `
用户充值订单(spark)。可选日 / 月 / 年范围。
`; 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 = `
订单数
${esc(sum.count ?? list.length ?? 0)}
金额合计
${esc(sum.amount ?? sum.totalAmount ?? '—')}
`; $('ordTable').innerHTML = `${asList(list).length ? asList(list).map(r => ``).join('') : emptyRows(6)}
时间用户金额渠道状态订单号
${esc(r.createdAt || r.paidAt)}${esc(r.email || r.userId)}${esc(r.amount)} ${esc(r.channel || r.payChannel)}${esc(r.status)}${esc(r.orderId || r.tradeNo)}
`; }; $('ordLoad').onclick = load; load(); }, async pushes() { const rows = await api('/spark/pushes'); $('content').innerHTML = `
系统消息推送列表(草稿/计划,实际下发需对接推送通道)。
${(rows||[]).length ? rows.map(r => ``).join('') : emptyRows(6)}
时间标题内容受众状态
${esc(r.createdAt)}${esc(r.title)}${esc(r.content)}${esc(r.audience)}${esc(r.status)}
`; $('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 (_) {} const appRow = (summary.apps && summary.apps[0]) || {}; $('content').innerHTML = `
火花留存。活跃留存来自 /retention/summary(D1/D3/D7),渠道留存来自 /stats/retention(含 D30)。
窗口天数
${esc(summary.days ?? 30)}
新增 cohort
${esc(appRow.newCohortSize ?? summary.newUsers ?? '—')}
次日留存
${esc(appRow.d1Rate ?? summary.retain1Rate ?? '—')}
3日留存
${esc(appRow.d3Rate ?? '—')}
7日留存
${esc(appRow.d7Rate ?? summary.retain7Rate ?? '—')}

渠道留存

${asList(channel).length ? asList(channel).map(r => ``).join('') : emptyRows(5)}
渠道cohortD1D7D30
${esc(r.channelName || r.channel)}${esc(r.cohort || r.cohortSize)} ${esc(r.day1Rate ?? r.retain1Rate ?? r.d1)}${esc(r.day7Rate ?? r.retain7Rate ?? r.d7)}${esc(r.day30Rate ?? r.retain30Rate ?? r.d30)}
`; }, async password() { $('content').innerHTML = `
运营管理账号改密(与 App 用户邮箱账号相互独立)。对齐主站「账号安全」。

修改我的密码

子管理员密码重置请到「权限分配」;App 用户密码请到「App用户密码」或用户详情。
`; $('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 = `
重置 App 互通邮箱密码(全局一份密码,影响所有接入 App)。明文仅本次提交,不写入仓库与接口响应。

按邮箱重置密码

`; $('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 = `

${esc(e.message)}

`; return; } $('content').innerHTML = `
虚拟 MFA(TOTP)。用 Google / 微软 Authenticator 扫码绑定。状态:${status.enabled ? '已启用' : '未启用'}。
${status.enabled ? '' : ''}
`; $('mfaSetupBtn').onclick = async () => { try { const data = await api('/auth/mfa/setup', { method: 'POST', body: '{}' }); $('mfaBox').innerHTML = `

密钥:${esc(data.secret)}

MFA QR
`; $('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 = `

${esc(e.message || '仅超级管理员可见')}

`; 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 = `
超级管理员:运营账号权限分配(与主后台共用 admin_user)。勾选项:中文说明(权限码)。
${menuOpts.map(p => ``).join('')}
${asList(rows).length ? asList(rows).map(r => ``).join('') : emptyRows(6)}
用户名邮箱角色权限MFA
${esc(r.username)}${esc(r.email)}${esc(r.role)} ${esc(formatPerms(r.permissions || []))} ${r.mfaEnabled ? '是' : '否'}
`; $('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 = '

加载接口文档…

'; try { const doc = await api('/spark/api-doc'); const row = (a) => `${esc(a.method)}${esc(a.path)}${esc(a.auth || '-')}${esc(a.desc || '')}`; const tableBlock = (title, list) => `

${esc(title)}

${(list || []).map(row).join('') || emptyRows(4, '暂无')}
方法路径鉴权说明
`; const notes = (doc.notes || []).map((n) => `
  • ${esc(n)}
  • `).join(''); $('content').innerHTML = `
    Base URL:${esc(doc.baseUrl || 'https://popadmin.wyrs365.com')} · 数据源 GET /api/admin/spark/api-doc · 路径别名 /api/admin/pop/…、/api/client/pop/…
    ${tableBlock('管理端 API(popadmin 常用)', doc.adminApis)} ${tableBlock('火花客户端 API(/api/client/spark)', doc.clientApis)} ${tableBlock('邮箱等共享客户端 API', doc.sharedClientApis)} ${tableBlock('公开 API', doc.publicApis)}`; } catch (e) { $('content').innerHTML = `

    ${esc(e.message)}

    `; } }, }; $('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();