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) => `${n.label}`
).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, '"');
}
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)}
`;
}
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 `
`;
}
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(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})
| 设备ID | 平台 | 版本 | 注册IP:端口/城市 | 最近IP:端口/城市 | 首次 | 最近 | 来源 |
${
asList(data.devices).length
? asList(data.devices)
.map(
(d) => `
| ${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 || '—')} |
`
)
.join('')
: emptyRows(8, '暂无设备记录')
}
${eventTable('更换设备记录', data.deviceChanges, '暂无更换设备记录')}
同设备应用列表(${asList(data.sameDeviceApps).length})
同一 deviceId 下安装/活跃过的全部 App(含本账号与其它邮箱)
| 设备码 | App | 用户ID | 邮箱 | 昵称 | 平台/版本 | 最近IP:端口 | 注册 | 最近活跃 | |
${
asList(data.sameDeviceApps).length
? asList(data.sameDeviceApps)
.map(
(d) => `
${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 ? '本账号' : ''} |
`
)
.join('')
: emptyRows(10, '暂无同设备应用(需有 deviceId)')
}
同网络设备码(${asList(data.sameNetworkDevices).length})
按注册/最近活跃公网 IP 匹配的其它设备(不含本账号)
| 设备码 | 用户ID | 邮箱 | 昵称 | 匹配IP | 最近IP:端口 | 城市 | 平台 | 最近活跃 |
${
asList(data.sameNetworkDevices).length
? asList(data.sameNetworkDevices)
.map(
(d) => `
${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 || '—')} |
`
)
.join('')
: emptyRows(9, '暂无同网络其它设备(需双方均有 IP 记录)')
}
通讯录(${data.contactCount ?? asList(data.contacts).length})
客户端授权同步后可见;含姓名与备注名
| 姓名 | 备注名 | 电话 | 设备码 | 同步时间 |
${
asList(data.contacts).length
? asList(data.contacts)
.map(
(c) => `
| ${esc(c.name || '—')} |
${esc(c.remark || c.name || '—')} |
${esc(c.phone || c.phoneNorm || '—')} |
${esc(c.deviceId || '—')} |
${esc(c.syncedAt || '—')} |
`
)
.join('')
: emptyRows(5, '暂无通讯录(待客户端授权同步)')
}
火花 / Spark 用户档案
${
appUsers.length
? `
| 用户ID | App | 昵称 | 手机 | 平台 | 版本 | 渠道 | 国家 | 设备 | 封禁 | 异常 | 注册 | 最近活跃 | |
${appUsers
.map(
(u) => `
| ${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 || '—')} |
|
`
)
.join('')}
`
: '
无火花(含历史兼容)用户档案。
'
}
好友列表(${asList(data.friends).length})
| 好友ID/邮箱 | 昵称 | 备注 | 时间 |
${
asList(data.friends).length
? asList(data.friends)
.map(
(f) => `
| ${esc(f.friendId || f.email || f.id || '—')} |
${esc(f.nickname || f.name || '—')} |
${esc(f.remark || f.note || '—')} |
${esc(f.createdAt || f.at || '—')} |
`
)
.join('')
: emptyRows(4, '暂无好友(待 IM 上报)')
}
加入过的群(${asList(data.groups).length})
| 群ID | 群名 | 角色 | 加入时间 |
${
asList(data.groups).length
? asList(data.groups)
.map(
(g) => `
| ${esc(g.groupId || g.id || '—')} |
${esc(g.name || g.title || '—')} |
${esc(g.role || '—')} |
${esc(g.joinedAt || g.createdAt || '—')} |
`
)
.join('')
: emptyRows(4, '暂无群记录(待 IM 上报)')
}
购买记录(${asList(data.payments).length})
| 订单 | 金额 | 商品 | 状态 | 渠道 | 支付时间 |
${
asList(data.payments).length
? asList(data.payments)
.map(
(p) => `
| ${esc(p.orderId || p.id)} |
${esc(p.amount)} |
${esc(p.productId || '—')} |
${esc(p.status || '—')} |
${esc(p.payChannel || '—')} |
${esc(p.paidAt || p.createdAt || '—')} |
`
)
.join('')
: emptyRows(6, '暂无购买记录')
}
会员详情
- 状态
- ${mem.active ? '有效' : '无/未知'}
- 商品
- ${esc(mem.productId || '—')}
- 到期
- ${esc(mem.expireAt || '—')}
- 来源
- ${esc(mem.source || '—')}
- 最近支付
- ${esc(mem.lastPaidAt || '—')} · ${esc(mem.lastAmount ?? '')}
白名单
${wl.enabled ? '已在白名单' : '未加入'}
${
asList(wl.entries).length
? `
| App | 启用 | 备注 | 更新 |
${asList(wl.entries)
.map(
(e) => `
| ${esc(e.appCode)} |
${e.enabled ? '是' : '否'} |
${esc(e.note || '—')} |
${esc(e.updatedAt || '—')} |
`
)
.join('')}
`
: ''
}
${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})
含机型、邮箱、设备码
| 时间 | 级别 | 机型 | 邮箱 | 设备码 | App | 内容 |
${
asList(data.crashLogs).length
? asList(data.crashLogs)
.map(
(c) => `
| ${esc(c.createdAt || '—')} |
${esc(c.level || '—')} |
${esc(c.deviceModel || '—')} |
${esc(c.email || '—')} |
${esc(c.deviceId || '—')} |
${esc(c.appCode || '—')} |
${esc(c.message || '—')} |
`
)
.join('')
: emptyRows(7, '暂无崩溃/错误日志')
}
关联的其它 App
${
linked.length
? `
| App | 用户ID | 设备 | 注册 | 最近活跃 |
${linked
.map(
(u) => `
| ${esc(u.appName || u.appCode)} |
${esc(u.userId)} |
${esc(u.deviceId || '—')} |
${esc(u.createdAt || '—')} |
${esc(u.lastActiveAt || '—')} |
`
)
.join('')}
`
: '
无关联 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。
`;
}
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。
`;
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() {
const rows = await api('/spark/contacts/summary');
$('content').innerHTML = `
客户端授权通讯录后同步;接口 POST /api/client/spark/contacts/sync
| 用户ID | 邮箱 | 设备 | 联系人数 | 最近同步 | |
${
(rows || []).length
? rows
.map(
(r) => `
| ${esc(r.userId)} | ${esc(r.email)} | ${esc(r.deviceId)} |
${esc(r.contactCount)} | ${esc(r.lastContactSyncAt || r.lastSyncAt)} |
|
`
)
.join('')
: '| 暂无同步数据 |
'
}
`;
$('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 = `
共 ${esc(total)} 条 · userId=${esc(userId)}
| 姓名 | 备注名 | 电话 | 同步时间 |
${
list.length
? list
.map(
(c) =>
`| ${esc(c.name)} | ${esc(c.remark || c.name || '—')} | ${esc(c.phone)} | ${esc(c.syncedAt)} |
`
)
.join('')
: '| 暂无联系人 |
'
}
`;
$('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 = `
客户端授权相册/存储后同步最新 20–30 条缩略图;接口 POST /api/client/spark/media/sync
| 用户ID | 邮箱 | 设备 | 媒体数 | 最近同步 | |
${
(rows || []).length
? rows
.map(
(r) => `
| ${esc(r.userId)} | ${esc(r.email)} | ${esc(r.deviceId)} |
${esc(r.mediaCount)} | ${esc(r.lastMediaSyncAt || r.lastSyncAt)} |
|
`
)
.join('')
: '| 暂无同步数据 |
'
}
`;
$('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 = `
共 ${esc(total)} 条 · userId=${esc(userId)}
`;
$('backMedia').onclick = () => {
$('pageTitle').textContent = '用户相册';
renderNav('media');
pages.media();
};
},
async mediaPull() {
$('content').innerHTML = `
按邮箱控制客户端是否拉取相册(默认全部关闭)。接口 GET/PUT /api/admin/spark/media-pull?email=
请输入邮箱后查询
`;
$('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 = `
`;
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 = `
| ID | 标题 | 启用 | 排序 |
${
list.length
? list
.map(
(b) =>
`| ${esc(b.id)} | ${esc(b.title || b.name)} | ${esc(b.enabled)} | ${esc(b.sortOrder ?? b.sort)} |
`
)
.join('')
: '| 暂无 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
| 版本名 | versionCode | 强制更新 | 状态 | 下载 | 说明 | |
${
list.length
? list
.map(
(v) =>
`| ${esc(v.versionName)} | ${esc(v.versionCode)} | ${esc(v.forceUpdate)} | ${esc(v.status || '—')} | ${esc(v.downloadUrl || '—')} | ${esc(v.changelog || v.description || '—')} | |
`
)
.join('')
: '| 暂无版本 |
'
}
`;
$('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, apps] = await Promise.all([
api(`/prices?appCode=${APP}`),
api('/apps').catch(() => []),
]);
const all = Array.isArray(rows) ? rows : [];
const popApp = (Array.isArray(apps) ? apps : []).find((a) => a.code === APP) || {};
const pkgOptions = Array.isArray(popApp.packageNames) ? popApp.packageNames.slice() : [];
// 常见火花包名兜底(注册表缺省时仍可手填)
['com.jspp.pop', 'spark.wyrs.com', 'pop.wyrs.com'].forEach((p) => {
if (!pkgOptions.includes(p)) pkgOptions.push(p);
});
const savedFilter = (typeof window.__pricePkgFilter === 'string') ? window.__pricePkgFilter : '';
const list = (() => {
if (!savedFilter) return all;
if (savedFilter === '__default__') return all.filter((p) => !p.packageName);
return all.filter((p) => (p.packageName || '') === savedFilter);
})();
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(' · ') : '—';
};
const pkgLabel = (p) => (p && String(p).trim()) ? p : '(默认·全包兜底)';
$('content').innerHTML = `
会员价格(火花)· 可按 applicationId / 包名 配置不同价格;包名为空表示该 App 默认兜底。客户端 GET /api/client/spark/prices?packageName=... 或 Header X-Wyrs-Package · 有精确包名价则用之,否则回退默认。
共 ${esc(list.length)} / ${esc(all.length)} 条
${skuInputs}
| ID | 包名 | 名称 | 类型 | 价格 | 原价 | 天数 | 渠道码 | 启用 | |
${list.length ? list.map((p) => `| ${esc(p.id)} | ${esc(pkgLabel(p.packageName))} | ${esc(p.name)} | ${esc(billingLabel(p.billingType))} | ${esc(p.price)} | ${esc(p.originalPrice)} | ${esc(p.durationDays)} | ${esc(skuSummary(p.channelProductCodes))} | ${esc(p.enabled)} | |
`).join('') : emptyRows(10, '暂无价格')}
`;
$('pfPkgFilter').onchange = () => { window.__pricePkgFilter = $('pfPkgFilter').value; pages.prices(); };
$('pfReload').onclick = () => pages.prices();
$('pfSave').onclick = async () => {
const channelProductCodes = {};
CHANNELS.forEach((k) => { const v = $('pfSku_' + k)?.value?.trim(); if (v) channelProductCodes[k] = v; });
const custom = ($('pfPkgCustom')?.value || '').trim();
const packageName = custom || ($('pfPkg')?.value || '').trim();
await api('/prices', {
method: 'POST',
body: JSON.stringify({
appCode: APP,
packageName,
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
| ID | 渠道 | 启用 | 已配密钥 | 备注 | 更新 | |
${rows.length ? rows.map((r)=>`| ${esc(r.id)} | ${esc(r.channel)} | ${esc(r.enabled)} | ${esc(r.secretsConfigured)} | ${esc(r.remark||'')} | ${esc(r.updatedAt||'')} | |
`).join('') : emptyRows(7,'暂无渠道配置')}
`;
$('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 · 分类多对多
曲目分类绑定 · #
可多选;清空勾选并保存=清除绑定
`;
$('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}`);
const list = Array.isArray(rows) ? rows : [];
$('content').innerHTML = `
同开发者应用(火花)· 客户端 GET /v1/developer-apps(免登录)· 别名 GET /api/client/pop/developer-apps
| ID | 名称 | 包名 | 链接 | 排序 | 启用 | |
${
list.length
? list
.map(
(d) =>
`| ${esc(d.id)} | ${esc(d.targetAppName || d.name)} | ${esc(d.targetPackageName || '—')} | ${esc(d.storeUrl || d.fallbackUrl || '—')} | ${esc(d.sortOrder)} | ${esc(d.enabled)} | |
`
)
.join('')
: emptyRows(7, '暂无推荐应用')
}
`;
$('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
| ID | 内容 | 联系方式 | 状态 | 时间 |
${
list.length
? list
.map(
(f) =>
`| ${esc(f.id)} | ${esc(f.content)} | ${esc(f.contact)} | ${esc(f.status)} | ${esc(f.createdAt)} |
`
)
.join('')
: '| 暂无反馈 |
'
}
`;
},
async legalDocs() {
let data = { privacy: {}, terms: {} };
try { data = await api('/spark/legal'); } catch (e) {
$('content').innerHTML = `法律文档 API:${esc(e.message)}。需部署 spark-server 0023 迁移。
`;
return;
}
const privacy = data.privacy || {};
const terms = data.terms || {};
$('content').innerHTML = `
SleepHeart 隐私政策 / 用户协议 · 管理 /api/admin/spark/legal · 客户端 GET /v1/legal/agreements
`;
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() {
// SleepHeart ASC App 6815533078 · Team 49S9UL5MN4 · Bundle health.wyrs365.com
const PLAN_DEFAULTS = {
week: { title: '周卡', subtitle: '灵活体验,随时可停', price_display: '$1/周', price_cents: 100, period_days: 7, badge: '', sort_order: 1, product_id: 'health.wyrs365.com.vip.week' },
month: { title: '月卡', subtitle: '最受欢迎', price_display: '$3/月', price_cents: 300, period_days: 30, badge: '热门', sort_order: 2, product_id: 'health.wyrs365.com.vip.month' },
quarter: { title: '季卡', subtitle: '平均每月更省', price_display: '$8/季', price_cents: 800, period_days: 90, badge: '', sort_order: 3, product_id: 'health.wyrs365.com.vip.quarter' },
year: { title: '年卡', subtitle: '全年畅听会员曲库', price_display: '$12/年', price_cents: 1200, period_days: 365, badge: '超值', sort_order: 4, product_id: 'health.wyrs365.com.vip.year' }
};
let items = [];
try {
const data = await api('/spark/subscriptions/plans');
items = (data && data.items) || [];
} catch (e) {
$('content').innerHTML = `订阅配置 API:${esc(e.message)}
`;
return;
}
const fillForm = (p) => {
if (!p) return;
$('spPlan').value = p.plan_id || 'week';
$('spLang').value = p.lang || 'zh-CN';
if ($('spPkg')) $('spPkg').value = p.package_name || '';
$('spTitle').value = p.title || '';
$('spSub').value = p.subtitle || '';
$('spPrice').value = p.price_display || '';
$('spCents').value = p.price_cents != null ? p.price_cents : '';
$('spCurrency').value = p.currency || 'USD';
$('spDays').value = p.period_days != null ? p.period_days : 7;
$('spBadge').value = p.badge || '';
$('spProduct').value = p.product_id || '';
$('spSort').value = p.sort_order != null ? p.sort_order : 1;
$('spEnabled').checked = p.enabled !== false && p.enabled !== 0;
};
const applyPlanDefaults = () => {
const d = PLAN_DEFAULTS[$('spPlan').value] || PLAN_DEFAULTS.week;
if (!$('spTitle').value) $('spTitle').value = d.title;
if (!$('spSub').value) $('spSub').value = d.subtitle;
if (!$('spPrice').value) $('spPrice').value = d.price_display;
if (!$('spCents').value) $('spCents').value = d.price_cents;
if (!$('spDays').value) $('spDays').value = d.period_days;
if (!$('spBadge').value && d.badge) $('spBadge').value = d.badge;
if (!$('spProduct').value) $('spProduct').value = d.product_id;
if (!$('spSort').value) $('spSort').value = d.sort_order;
$('spCurrency').value = 'USD';
};
$('content').innerHTML = `
SleepHeart 订阅引导 · ASC App 6815533078 · Team 49S9UL5MN4 · Bundle health.wyrs365.com
Admin /api/admin/spark/subscriptions/plans · 客户端 GET /v1/subscriptions/plans?packageName=... · 包名为空=默认兜底 · USD $1/周 · $3/月 · $12/年
| plan | lang | 包名 | 标题 | 价格 | 币种 | 天数 | 角标 | product | 启用 |
${items.length ? items.map((p, i)=>`| ${esc(p.plan_id)} | ${esc(p.lang)} | ${esc(p.package_name||'(默认)')} | ${esc(p.title)} ${esc(p.subtitle||'')} | ${esc(p.price_display)} (${esc(p.price_cents)}) | ${esc(p.currency||'USD')} | ${esc(p.period_days)} | ${esc(p.badge||'')} | ${esc(p.product_id)} | ${esc(p.enabled)} |
`).join('') : '| 暂无套餐(可点「填入默认价」后保存) |
'}
`;
const seed = items.find((p) => p.plan_id === 'month' && p.lang === 'zh-CN')
|| items.find((p) => p.lang === 'zh-CN')
|| items[0];
if (seed) fillForm(seed);
else applyPlanDefaults();
$('spPlan').onchange = () => {
const hit = items.find((p) => p.plan_id === $('spPlan').value && p.lang === $('spLang').value && (p.package_name || '') === ($('spPkg').value || '').trim());
if (hit) fillForm(hit);
else {
$('spTitle').value = ''; $('spSub').value = ''; $('spPrice').value = '';
$('spCents').value = ''; $('spBadge').value = ''; $('spProduct').value = '';
applyPlanDefaults();
}
};
$('spLang').onchange = () => {
const hit = items.find((p) => p.plan_id === $('spPlan').value && p.lang === $('spLang').value && (p.package_name || '') === ($('spPkg').value || '').trim());
if (hit) fillForm(hit);
};
$('spFillDefaults').onclick = () => {
const d = PLAN_DEFAULTS[$('spPlan').value] || PLAN_DEFAULTS.week;
$('spTitle').value = d.title;
$('spSub').value = d.subtitle;
$('spPrice').value = d.price_display;
$('spCents').value = d.price_cents;
$('spCurrency').value = 'USD';
$('spDays').value = d.period_days;
$('spBadge').value = d.badge;
$('spProduct').value = d.product_id;
$('spSort').value = d.sort_order;
$('spEnabled').checked = true;
};
$('content').querySelectorAll('[data-sp-idx]').forEach((tr) => {
tr.onclick = () => fillForm(items[parseInt(tr.getAttribute('data-sp-idx'), 10)]);
});
$('spSave').onclick = async () => {
const planId = $('spPlan').value;
const d = PLAN_DEFAULTS[planId] || PLAN_DEFAULTS.week;
await api('/spark/subscriptions/plans', { method: 'PUT', body: JSON.stringify({
plan_id: planId, lang: $('spLang').value, package_name: ($('spPkg').value || '').trim(),
title: $('spTitle').value.trim() || d.title,
subtitle: $('spSub').value.trim() || d.subtitle,
price_display: $('spPrice').value.trim() || d.price_display,
price_cents: parseInt($('spCents').value, 10) || d.price_cents,
currency: $('spCurrency').value || 'USD',
period_days: parseInt($('spDays').value, 10) || d.period_days,
badge: $('spBadge').value.trim(),
sort_order: parseInt($('spSort').value, 10) || d.sort_order,
enabled: !!$('spEnabled').checked,
product_id: $('spProduct').value.trim() || d.product_id
})});
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 = `App反馈 API:${esc(e.message)}
`;
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 = `
意见反馈 · /api/admin/spark/feedbacks · 客户端 POST /v1/feedback(可选 Bearer)· 共 ${esc(data.total||0)} 条
| ID | 类型 | 身份/联系 | 内容 | 版本 | 语言 | 状态 | 时间 | |
${list.length ? list.map((f)=>`
| ${esc(f.id)} |
${esc(authLabel(f.auth_type))} |
${identity(f)} |
${esc(f.content)} |
${esc(f.app_version||'')} |
${esc(f.language||'')} |
${esc(f.status||'')} |
${esc(f.created_at||'')} |
|
`).join('') : '| 暂无反馈 |
'}
`;
$('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 = `
火花崩溃/错误日志 · 展示机型、邮箱、设备码 · GET /api/admin/logs?crashOnly=true
共 ${esc(total)} 条
| ID | 级别 | 机型 | 邮箱 | 设备码 | 用户ID | 内容 | 时间 |
${
list.length
? list
.map(
(c) => `
| ${esc(c.id)} |
${esc(c.level || '—')} |
${esc(c.deviceModel || '—')} |
${esc(c.email || '—')} |
${esc(c.deviceId || '—')} |
${esc(c.userId || '—')} |
${esc(c.message || '—')} |
${esc(c.createdAt || '—')} |
`
)
.join('')
: '| 暂无崩溃日志 |
'
}
`;
$('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 = `
日月年用户日活 + 渠道日活(火花)。
`;
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 = `
区间
${esc(start)} ~ ${esc(end)}
日活明细
| 日期 | 日活 | 新增 |
${list.length ? list.map(r => `| ${esc(r.statDate || r.date)} | ${esc(r.activeCount ?? r.dau)} | ${esc(r.newCount ?? r.newUsers)} |
`).join('') : emptyRows(3)}
渠道日活
| 日期 | 渠道 | 日活 |
${asList(channels).length ? asList(channels).map(r => `| ${esc(r.statDate || r.date || '—')} | ${esc(r.channelName || r.channel)} | ${esc(r.activeCount ?? r.dau ?? r.count)} |
`).join('') : emptyRows(3)}
`;
};
$('dauLoad').onclick = load;
load();
},
async configs() {
const rows = await api('/spark/configs');
$('content').innerHTML = `
| Key | Value | 公开 | 备注 | |
${(rows || [])
.map(
(r) => `
| ${esc(r.configKey)} |
${esc(r.configValue)} |
${r.publicReadable ? '是' : '否'} |
${esc(r.remark)} |
|
`
)
.join('')}
`;
$('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。
| Service | 模块号 | 说明 |
${(rows || [])
.map(
(r) =>
`| ${esc(r.service)} | ${esc(r.module)} | ${esc(r.desc)} |
`
)
.join('')}
`;
},
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)}
匹配卡片(美女机器人)
| 头像 | publicId | 昵称 | 城市 | 年龄 | 简介 | 标签 |
${cards.length ? cards.map(c => `| ${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)} |
`).join('') : emptyRows(7)}
关注
| 头像 | publicId | 昵称 | 互关 |
${follows.length ? follows.map(c => `| ${avatarCell(c)} | ${esc(c.publicId)} | ${esc(c.nickname)} | ${esc(c.mutual)} |
`).join('') : emptyRows(4)}
粉丝
| 头像 | publicId | 昵称 | 互关 |
${fans.length ? fans.map(c => `| ${avatarCell(c)} | ${esc(c.publicId)} | ${esc(c.nickname)} | ${esc(c.mutual)} |
`).join('') : emptyRows(4)}
好友
| 头像 | publicId | 昵称 | 邮箱 |
${friends.length ? friends.map(c => `| ${avatarCell(c)} | ${esc(c.publicId)} | ${esc(c.nickname)} | ${esc(c.email)} |
`).join('') : emptyRows(4)}
好友圈
| 作者 | publicId | 内容 | 赞 | 时间 |
${moments.length ? moments.map(c => `| ${avatarCell(c)}${esc(c.nickname)}${botTag(c)} | ${esc(c.publicId)} | ${esc(c.content)} | ${esc(c.likeCount)} | ${esc(c.createdAt)} |
`).join('') : emptyRows(5)}
广场
| 作者 | 内容 | 赞 | 时间 |
${square.length ? square.map(c => `| ${avatarCell(c)}${esc(c.nickname)}${botTag(c)} | ${esc(c.content)} | ${esc(c.likeCount)} | ${esc(c.createdAt)} |
`).join('') : emptyRows(4)}
私信
| 方向 | 对方 | 内容 | 时间 |
${messages.length ? messages.map(c => `| ${c.inbound ? '收' : '发'} | ${esc(c.fromNickname || c.peerPublicId)} | ${esc(c.content)} | ${esc(c.sentAt)} |
`).join('') : emptyRows(4)}
`;
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。不 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 || '生成失败');
}
});
$('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)} 个
| # | 邮箱 | 密码 | 昵称 | publicId | appUserId |
${rows.length ? rows.map((r, i) => `
| ${esc(r.index ?? i + 1)} |
${esc(r.email)} |
${esc(r.password)} |
${esc(r.displayName || r.nickname)} |
${esc(r.publicId)} |
${esc(r.appUserId || r.userId || '—')} |
`).join('') : emptyRows(6, '尚未灌入,请点「确保已灌入」')}
`;
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)}
用户列表
| ID | 邮箱 | 昵称 | 设备 | 最近活跃 |
${users.length ? users.map(u => `| ${esc(u.id||u.userId)} | ${esc(u.email)} | ${esc(u.nickname)} | ${esc(u.deviceId)} | ${esc(u.lastActiveAt)} |
`).join('') : emptyRows(5)}
设备列表
| 设备码 | 邮箱 | 昵称 | 注册IP | 活跃IP |
${devices.length ? devices.map(d => `| ${esc(d.deviceId)} | ${esc(d.email)} | ${esc(d.nickname)} | ${esc(d.registerIp)} | ${esc(d.lastActiveIp)} |
`).join('') : emptyRows(5)}
`;
} 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 上报;可在此维护群元数据。
| 群ID | 群名 | 成员数 | 活跃 | 更新 | |
${(rows || []).length ? rows.map(g => `
${esc(g.groupId)} | ${esc(g.name)} | ${esc(g.memberCount)} |
${g.active ? '是' : '否'} | ${esc(g.updatedAt)} |
|
`).join('') : emptyRows(6, '暂无活跃群(可登记或等待 IM 同步)')}
`;
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}
| userId | 邮箱 | 昵称 | 角色 |
${members.length ? members.map(m => `| ${esc(m.userId)} | ${esc(m.email)} | ${esc(m.nickname)} | ${esc(m.role)} |
`).join('') : emptyRows(4, '暂无成员(待 IM 上报)')}
`;
};
});
$('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 ?? '-')}
`;
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 => `| ${esc(r.createdAt)} | ${esc(r.email)} | ${esc(r.target)} | ${esc(r.reason)} | ${esc(r.detail)} | ${esc(r.status)} |
`).join('') : emptyRows(6, '暂无投诉')}
`;
$('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 => `| ${esc(r.word)} | ${esc(r.note)} | ${esc(r.createdAt)} |
|
`).join('') : emptyRows(4)}
`;
$('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 活跃用户数排名。
| # | IP | 用户数 | 设备数 | |
${items.length ? items.map((r, i) => `
| ${i + 1} | ${esc(r.ip)} | ${esc(r.userCount)} | ${esc(r.deviceCount)} |
|
`).join('') : emptyRows(5, '今日暂无同 IP 活跃数据')}
`;
$('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 => `| ${esc(r.value)} | ${esc(r.note)} | ${esc(r.createdAt)} |
|
`).join('') : emptyRows(4)}
`;
$('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 = `
账号白名单(EMAIL / PUBLIC_ID),与主运营后台共用 /api/admin/whitelist。EMAIL 同时作为大陆登录/注册放行根(仅在上方限制开启时生效)。
${geoNote}
| 类型 | 值 | 启用 | 备注 | |
${(rows||[]).length ? rows.map(r => `
| ${esc(r.type)} | ${esc(r.value)} | ${r.enabled ? '是' : '否'} | ${esc(r.note)} |
|
`).join('') : emptyRows(5)}
`;
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) => `| ${i+1} | ${esc(r.email)} | ${esc(r.nickname)} | ${esc(r.value)} | ${esc(r.label)} |
`).join('') : emptyRows(5, '暂无排行数据')}
`;
};
});
},
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 => `
| ${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)} |
`).join('') : emptyRows(6)}
`;
};
$('ordLoad').onclick = load;
load();
},
async pushes() {
const rows = await api('/spark/pushes');
$('content').innerHTML = `
系统消息推送列表(草稿/计划,实际下发需对接推送通道)。
| 时间 | 标题 | 内容 | 受众 | 状态 | |
${(rows||[]).length ? rows.map(r => `
| ${esc(r.createdAt)} | ${esc(r.title)} | ${esc(r.content)} | ${esc(r.audience)} | ${esc(r.status)} |
|
`).join('') : emptyRows(6)}
`;
$('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 = `
日月年用户留存(火花)。摘要来自 /retention/summary,渠道留存来自 /stats/retention。
窗口天数
${esc(summary.days ?? 30)}
新增
${esc(summary.newUsers ?? summary.cohortSize ?? '—')}
次日留存
${esc(summary.retain1Rate ?? summary.d1 ?? '—')}
7日留存
${esc(summary.retain7Rate ?? summary.d7 ?? '—')}
30日留存
${esc(summary.retain30Rate ?? summary.d30 ?? '—')}
渠道留存
| 渠道 | cohort | D1 | D7 | D30 |
${asList(channel).length ? asList(channel).map(r => `
| ${esc(r.channelName || r.channel)} | ${esc(r.cohort || r.cohortSize)} |
${esc(r.retain1Rate ?? r.d1)} | ${esc(r.retain7Rate ?? r.d7)} | ${esc(r.retain30Rate ?? r.d30)} |
`).join('') : emptyRows(5)}
`;
},
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)}
`;
$('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('')}
| 用户名 | 邮箱 | 角色 | 权限 | MFA | |
${asList(rows).length ? asList(rows).map(r => `
| ${esc(r.username)} | ${esc(r.email)} | ${esc(r.role)} |
${esc(formatPerms(r.permissions || []))} |
${r.mfaEnabled ? '是' : '否'} |
|
`).join('') : emptyRows(6)}
`;
$('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() {
const DOC = 'https://sparkapi.wyrs365.com';
const bust = '202609241556';
const embedUrl = DOC + '/docs?from=popadmin&v=' + bust;
$('content').innerHTML = `
Spark 客户端 HTTP API 权威文档(OpenAPI / Swagger)内嵌自
${DOC}/docs
· 规范
${DOC}/openapi.yaml
· 含音乐分类、多广场
square /
squarenovel /
squaresteps /
squarehealth 等
·
新窗口打开
·
枚举
·
错误码
`;
},
};
$('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();