'use strict';
/* ── 状态 ─────────────────────────────────────────────────────────── */
const LS_KEY = 'wb2api.key', LS_THEME = 'wb2api.theme';
let theme = localStorage.getItem(LS_THEME) || 'auto'; // auto | light | dark
let view = 'accounts';
let overviewData = null, cfgLoaded = null;
let logPin = true, loginState = null, loginTimer = null;
let refTimer = null;
/* 视图级筛选状态(模块级声明放在文件顶部,避免顶层 go() 早于声明执行时踩 TDZ)。 */
let mdFilter = { q: '', realm: '', cap: '', effort: '', promo: '', sort: 'default' };
let mdAll = [], mdProbes = {}, mdProbeOf = () => undefined;
let reqFilter = { q: '', outcome: '' };
let reqEntries = [];
let usDim = 'account', usCreditDim = 'account', usSort = 'total';
let usageData = null;
let reqRangeState = null; // 请求记录的时间范围(用量页的见 trangeState)
const $ = id => document.getElementById(id);
/* ── 主题 ─────────────────────────────────────────────────────────── */
/* 两态翻转(浅/深),首次访问跟随系统偏好;点击总是切换可见外观,符合直觉。 */
function effTheme() {
return theme === 'auto' ? (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark') : theme;
}
function applyTheme() {
const eff = effTheme();
document.documentElement.dataset.theme = eff;
$('icoTheme').innerHTML = eff === 'light'
? ''
: '';
$('btnTheme').title = eff === 'light' ? '切换到深色' : '切换到浅色';
}
addEventListener('change', applyTheme);
$('btnTheme').onclick = () => {
theme = effTheme() === 'light' ? 'dark' : 'light';
localStorage.setItem(LS_THEME, theme);
applyTheme();
};
applyTheme();
/* ── 请求 ─────────────────────────────────────────────────────────── */
async function api(path, opts = {}) {
const h = Object.assign({}, opts.headers || {});
const k = localStorage.getItem(LS_KEY);
if (k) h['Authorization'] = 'Bearer ' + k;
if (opts.body) h['Content-Type'] = 'application/json';
const r = await fetch('/panel/api/' + path, Object.assign({}, opts, { headers: h }));
if (r.status === 401) { openKey(); throw new Error('密钥无效或未填写'); }
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.error || ('HTTP ' + r.status));
return d;
}
function toast(msg, cls) {
const el = document.createElement('div');
el.className = 'tst ' + (cls || '');
el.textContent = msg;
$('toasts').appendChild(el);
setTimeout(() => el.remove(), 3600);
}
// esc 文本/属性双安全转义。不能只用 div.innerHTML(它转义 <>& 但不转义引号),
// 否则字符串拼进 HTML 属性(如 title="uid: ...")时引号可闭合属性并注入事件处理器。
// 显式替换 5 个字符:& < > " '(& 必须最先,避免二次转义)。
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function ago(iso) {
if (!iso || iso.startsWith('0001-')) return '—';
const s = (Date.now() - new Date(iso)) / 1000;
if (s < 0) return '刚刚';
if (s < 60) return Math.floor(s) + ' 秒前';
if (s < 3600) return Math.floor(s / 60) + ' 分钟前';
if (s < 86400) return Math.floor(s / 3600) + ' 小时前';
return Math.floor(s / 86400) + ' 天前';
}
function dur(sec) {
sec = Math.max(0, Math.round(sec));
const h = Math.floor(sec / 3600), m = Math.floor(sec % 3600 / 60), s = sec % 60;
return h ? h + '时' + String(m).padStart(2, '0') + '分' : m ? m + '分' + String(s).padStart(2, '0') + '秒' : s + '秒';
}
function parseAPITime(value) {
const text = String(value || '');
if (!text || text.startsWith('0001-')) return 0;
const ms = Date.parse(text);
return Number.isFinite(ms) ? ms : 0;
}
function fmtLocalDateTime(ms) {
const d = new Date(ms);
const p = n => String(n).padStart(2, '0');
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' +
p(d.getHours()) + ':' + p(d.getMinutes());
}
/* ── 时间范围控件(用量 / 请求记录共用)────────────────────────────────
预设项:今天 / 近 24 小时 / 近 3 天 / 近 7 天 / 近 30 天 / 全部历史 / 自定义。
为什么区间一律由前端算好再发:
- 「今天」必须是**浏览器本地时区**的 00:00 起。服务端时区未必与浏览器一致
(容器常挂 TZ=Asia/Shanghai,而浏览器可能在任何时区),让服务端算"今天"
会在跨时区时切错日子。
- 「自定义」本来就是用户挑的具体时刻,没有任何服务端推导空间。
滚动预设(近 N 小时/天)则保留 hours 参数:服务端按整点对齐的滚动窗口与旧
行为逐位一致,前端自己减 N 小时会多算/少算一个边界桶。 */
const TRANGE_PRESETS = [
['today', '今天'],
['24', '近 24 小时'],
['72', '近 3 天'],
['168', '近 7 天'],
['720', '近 30 天'],
['0', '全部历史'],
['custom', '自定义…'],
];
const TRANGE_DEFAULT = '72';
const trangeStates = new Map(); // hostId → { preset, from: Date|null, to: Date|null }
// dtLocalValue / dtLocalParse 与 的取值格式互转
// (YYYY-MM-DDTHH:mm,本地时区;ES 里"带时间的日期串"按本地解析,正是我们要的)。
function dtLocalValue(d) {
const p = n => String(n).padStart(2, '0');
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + 'T' +
p(d.getHours()) + ':' + p(d.getMinutes());
}
function dtLocalParse(s) {
if (!s) return null;
const d = new Date(s);
return isNaN(d.getTime()) ? null : d;
}
// trangeMidnight 今天 00:00(本地时区)。
function trangeMidnight() {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}
function trangeState(id) {
if (!trangeStates.has(id)) {
// 「自定义」的初始值给一段有意义的默认:今天 00:00 → 现在。
trangeStates.set(id, { preset: TRANGE_DEFAULT, from: trangeMidnight(), to: new Date() });
}
return trangeStates.get(id);
}
// trangeRender 画出控件骨架(幂等:重复调用会保留当前状态)。
function trangeRender(id) {
const host = $(id);
if (!host) return;
const st = trangeState(id);
const custom = st.preset === 'custom';
host.innerHTML =
'' +
'' +
'' +
'→' +
'' +
'';
const preset = host.querySelector('.tr-preset');
if (preset) preset.onchange = () => {
st.preset = preset.value;
// 从别的预设切到自定义时,把区间重置为"今天 00:00 → 现在",
// 免得用户上次留下的半年区间被无声沿用。
if (st.preset === 'custom' && (!st.from || !st.to)) { st.from = trangeMidnight(); st.to = new Date(); }
trangeRender(id);
trangeEmit(id);
};
const fromEl = host.querySelector('.tr-from');
const toEl = host.querySelector('.tr-to');
const readCustom = () => {
st.from = dtLocalParse(fromEl.value);
st.to = dtLocalParse(toEl.value);
// 起止颠倒就地标红(不静默纠正:用户可能正输到一半)。
const bad = st.from && st.to && st.from > st.to;
fromEl.classList.toggle('tr-bad', !!bad);
toEl.classList.toggle('tr-bad', !!bad);
if (bad) return;
trangeEmit(id);
};
if (fromEl) fromEl.onchange = readCustom;
if (toEl) toEl.onchange = readCustom;
}
const trangeHandlers = new Map();
// trangeBind 渲染控件并登记变化回调。**不**在绑定时触发回调:各视图的首次加载
// 由 go() 统一驱动,这里再触发一次会让打开页面时打两遍接口。
function trangeBind(id, onChange, preset) {
trangeHandlers.set(id, onChange);
if (preset) trangeState(id).preset = preset;
trangeRender(id);
}
function trangeEmit(id) {
const fn = trangeHandlers.get(id);
if (fn) fn();
}
// trangeQuery 把当前选择翻译成查询参数。
// rolling=true → 滚动预设发 hours(服务端整点对齐),今天/自定义发 from/to
// rolling=false → 一律发 from/to(归档是线性日志,前端算区间更直观)
// 「全部历史」两者都不发。
function trangeQuery(id, rolling) {
const st = trangeState(id);
const q = new URLSearchParams();
const sec = d => Math.floor(d.getTime() / 1000);
if (st.preset === 'custom') {
if (st.from) q.set('from', sec(st.from));
if (st.to) q.set('to', sec(st.to));
return q;
}
if (st.preset === 'today') {
q.set('from', sec(trangeMidnight()));
return q;
}
if (st.preset === '0') return q;
if (rolling) { q.set('hours', st.preset); return q; }
q.set('from', sec(new Date(Date.now() - Number(st.preset) * 3600 * 1000)));
return q;
}
// trangeLabel 人读口径,用于「用量总览」右上角这类需要回显区间的位置。
function trangeLabel(id) {
const st = trangeState(id);
const found = TRANGE_PRESETS.find(p => p[0] === st.preset);
if (st.preset !== 'custom') return found ? found[1] : '';
if (!st.from && !st.to) return '自定义';
const f = d => d ? (d.getMonth() + 1) + '-' + String(d.getDate()).padStart(2, '0') + ' ' +
String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') : '…';
return f(st.from) + ' → ' + f(st.to);
}
function rateLimitMeta(row, now) {
const model = String(row && row.model || '未知模型');
const kind = String(row && row.kind || 'rate_limit');
const resetAt = parseAPITime(row && row.reset_at);
const until = parseAPITime(row && row.until);
const deadline = resetAt || until;
const remaining = deadline > now ? Math.round((deadline - now) / 1000) : 0;
if (kind === 'model_unavailable') {
return {
model,
kind,
detail: remaining ? '预计 ' + dur(remaining) + ' 后重试' : '等待重新探测',
title: model + '\n模型当前不可用' + (deadline ? '\n最早重试:' + fmtLocalDateTime(deadline) : ''),
};
}
let detail = resetAt
? '预计 ' + fmtLocalDateTime(resetAt) + ' 解封' + (remaining ? '(剩余 ' + dur(remaining) + ')' : '')
: (until ? '预计 ' + fmtLocalDateTime(until) + ' 恢复(剩余 ' + dur(remaining) + ')' : '预计解封时间未知');
const title = [model, resetAt ? '上游重置:' + fmtLocalDateTime(resetAt) : '上游重置:时间未知'];
if (until && resetAt && until < resetAt) {
detail += ' · 网关最快 ' + dur(Math.max(0, Math.round((until - now) / 1000))) + ' 后重试';
title.push('网关最早重试:' + fmtLocalDateTime(until));
}
return { model, kind, detail, title: title.join('\n') };
}
function rateLimitRowsHtml(rows, now) {
const list = Array.isArray(rows) ? rows.filter(row => row && row.model) : [];
if (!list.length) return '';
return '
' + list.map(row => {
const m = rateLimitMeta(row, now);
return '
' + esc(m.model) + '' + esc(m.detail) + '
';
}).join('') + '
';
}
function formatTokenCount(tokens) {
if (tokens == null || tokens === '') return '—';
const n = Number(tokens);
if (!Number.isFinite(n) || n < 0) return '—';
if (n < 1000) return String(Math.round(n));
const units = [['k', 1e3], ['m', 1e6], ['b', 1e9]];
let unit = units[0];
for (const candidate of units) {
if (n >= candidate[1]) unit = candidate;
}
let value = n / unit[1];
let rounded = Number(value.toFixed(1));
// 999999 → 1m,而不是 1000k;四舍五入后自动升级单位。
const next = units[units.indexOf(unit) + 1];
if (next && rounded >= 1000) {
unit = next;
value = n / unit[1];
rounded = Number(value.toFixed(1));
}
return rounded + unit[0];
}
function formatLatency(ms) {
if (ms == null || ms === '') return '—';
const n = Number(ms);
if (!Number.isFinite(n) || n <= 0) return '—';
return n < 1000 ? Math.round(n) + 'ms' : (n / 1000).toFixed(1).replace(/\.0$/, '') + 's';
}
function formatRate(rate) {
if (rate == null || rate === '') return '—';
const n = Number(rate);
if (!Number.isFinite(n) || n < 0) return '—';
return n.toFixed(1) + 'tok/s';
}
/* ── 密钥门 ───────────────────────────────────────────────────────── */
function openKey() { $('keyVeil').classList.add('on'); setTimeout(() => $('keyInput').focus(), 60); }
$('btnKey').onclick = async () => {
const v = $('keyInput').value.trim();
if (!v) return;
localStorage.setItem(LS_KEY, v);
try {
await api('overview');
$('keyErr').hidden = true;
$('keyVeil').classList.remove('on');
start();
} catch (e) { $('keyErr').hidden = false; }
};
$('keyInput').addEventListener('keydown', e => { if (e.key === 'Enter') $('btnKey').click(); });
/* ── 路由 ─────────────────────────────────────────────────────────── */
/* 积分包明细共享缓存:「积分构成」视图与首页「积分到期提醒」卡片共用同一份
/panel/api/packages 数据(逐账号实时查上游,能省一次是一次)。声明在路由区
是因为 go() 的初始调用就会触发首页卡片的渲染,必须先于它就位。 */
let lastPackages = null;
let lastPackagesAt = 0;
let expFetching = false; // 到期卡片在途标记(防重复打上游)
const EXP_FRESH_MS = 2 * 60 * 1000; // 缓存新鲜窗口:2 分钟内复用
const TITLES = { accounts: '账号池', usage: '用量', packages: '积分构成', taskscenter: '任务中心', models: '模型与档位', config: '配置', logs: '运行日志' };
function go(v) {
view = v;
document.querySelectorAll('.view').forEach(s => s.hidden = s.id !== 'view-' + v);
document.querySelectorAll('.nav a').forEach(a => a.classList.toggle('on', a.dataset.view === v));
$('ttl').textContent = TITLES[v];
if (v === 'models' && !$('mdBody').children.length) loadModels();
if (v === 'config') loadConfig();
if (v === 'logs') loadLogs();
if (v === 'usage') loadUsage();
if (v === 'packages') loadPackages();
if (v === 'accounts') loadExpiry();
if (v === 'taskscenter') reattachQueueView();
}
document.querySelectorAll('.nav a').forEach(a => a.onclick = e => { e.preventDefault(); go(a.dataset.view); history.replaceState(null, '', '#' + a.dataset.view); });
/* 首次进入延到本轮脚本求值之后再 go()。
原因:go() 会同步触发视图的数据加载(loadUsage/loadLogs/loadPackages…),而这些
函数读到的模块级 let/const(usageRateWarmAt、PK_* 等)在文件后半段才初始化——
直接深链 #usage / #packages 打开页面时会踩 TDZ("Cannot access 'x' before
initialization"),表现为该页永远显示"读取失败",而点导航进去一切正常。
延迟 0ms 让整份脚本先求值完,是修这一类问题最省事也最不容易再犯的办法。 */
setTimeout(() => {
const hash = (location.hash || '#accounts').slice(1);
go(hash in TITLES ? hash : 'accounts');
}, 0);
/* ── 账号池 ───────────────────────────────────────────────────────── */
function renderAccounts(list) {
const tb = $('accBody');
if (!list.length) {
tb.innerHTML = '账号池是空的 点击右上角「添加账号」,用浏览器登录一个 WorkBuddy 账号 |
';
return;
}
// 有总额度(credits_total)→ 进度条按自身 剩余/总额 百分比;旧数据无总额 → 退回池内最高=100%
const maxCred = Math.max(1, ...list.map(s => s.credits || 0));
tb.innerHTML = list.map(s => {
const bl = (new Date(s.breaker_until || 0) - Date.now()) / 1000;
const dg = (new Date(s.degrade_until || 0) - Date.now()) / 1000;
const cool = Math.max(s.cool_remaining_sec || 0, bl > 0 ? bl : 0, dg > 0 ? dg : 0);
let cls = '', tag;
if (s.disabled) { cls = 'off'; tag = '已禁用'; }
else if (s.paused) { cls = 'off'; tag = '已暂停选号'; }
else if (cool > 0) {
cls = 'cool';
const kind = bl > Math.max(s.cool_remaining_sec || 0, dg > 0 ? dg : 0) ? '熔断'
: (dg > (s.cool_remaining_sec || 0) ? '连败降权' : (s.cool_kind === 'hard_credit' ? '积分冷却' : '限流冷却'));
tag = '' + kind + ' · ' + dur(cool) + '';
} else tag = '可用' + (s.in_flight ? '' : '');
const note = s.reason ? '' + esc(s.reason) + '
' : '';
const rateLimits = rateLimitRowsHtml(s.rate_limited_models, Date.now());
const short = s.uid.length > 16 ? s.uid.slice(0, 16) + '…' : s.uid;
const cred = s.credits == null ? '—' : (s.credits_total > 0 ? s.credits + '/' + s.credits_total + '' : String(s.credits));
const pct = s.credits_total > 0
? Math.min(100, Math.round((s.credits || 0) / s.credits_total * 100))
: Math.round((s.credits || 0) / maxCred * 100);
// 成本台账 tooltip(model_costs):每模型实测单价(≤0 = 实测免费),运维据此
// 看「为什么总选它」——免费号垄断 / 单价排序一眼可见。
let credTip = s.credits_total > 0 ? '剩余 ' + s.credits + ' / 总额 ' + s.credits_total + '(' + pct + '%)' : '积分(相对池内最高)';
const costs = (s.model_costs || []).filter(c => c.model);
if (costs.length) {
credTip += '\n实测单价(credits/1K):\n' + costs.map(c =>
' ' + c.model + ':' + (c.cost_per_1k <= 0 ? '免费' : c.cost_per_1k)).join('\n');
}
const frozen = s.disabled || cool > 0;
const tu = s.token_usage || {};
const req = tu.request_count || 0;
const totalTok = formatTokenCount(tu.total_tokens);
const totalTokUnit = totalTok === '—' ? '' : 'tok';
const latency = formatLatency(tu.last_latency_ms);
const rate = formatRate(tu.last_tokens_per_second);
const usageTitle = '最近一次:' + req + ' 次 / ' + totalTok + ' / 延迟 ' + latency + ' / ' + rate;
return '' +
' | ' +
'' + (s.nickname ? esc(s.nickname) : '未命名') + (s.realm === 'global' ? ' 国际版' : '') + ' ' + esc(short) + ' | ' +
'' + tag + note + rateLimits + ' | ' +
'' + cred + '
| ' +
'' + (s.success_count || 0) + ' / ' + (s.err_total || 0) + ' | ' +
'' + (s.in_flight || 0) + ' | ' +
'' +
'' + req + '次' +
'' + totalTok + '' + totalTokUnit + '' +
'' + latency + '' +
'' + rate + '' +
' | ' +
'' + ago(s.last_success) + ' | ' +
'' +
'' +
'' +
'' +
(frozen ? ''
: (s.paused ? ''
: '')) +
(s.disabled ? '' : '') +
'' +
' |
';
}).join('');
}
// renderModelLocks 模型锁池:哪些模型不能用、锁了几个号、还要锁多久。
// 后端 model_locks 已按「整池不可用 → 没号可用 → 部分限流」排好序,这里只做展示。
function renderModelLocks(rows) {
const tb = $('mlBody');
if (!tb) return;
const note = $('mlNote');
if (!rows || !rows.length) {
tb.innerHTML = '当前没有模型级限流 —— 所有模型均可选 |
';
if (note) note.textContent = '';
return;
}
const STATE = { locked: ['bad', '整池不可用'], starved: ['warn', '没号可用'], partial: ['warn', '部分限流'] };
const left = iso => {
const ms = parseAPITime(iso);
return ms ? dur(Math.max(0, Math.round((ms - Date.now()) / 1000))) : '—';
};
tb.innerHTML = rows.map(r => {
const st = STATE[r.state] || ['mute', r.state || '—'];
const realm = r.realm === 'global' ? '国际版' : '国内版';
return '' +
'| ' + esc(r.model) + ' | ' +
'' + realm + ' | ' +
'' + st[1] + ' | ' +
'' + (r.servable || 0) + ' / ' + (r.total || 0) + ' | ' +
'' + (r.locked || 0) + ' | ' +
'' + left(r.unlock_at || r.fully_unlock_at) + ' | ' +
'' + left(r.fully_unlock_at) + ' | ' +
'' + (r.reason ? ' ' + esc(r.reason) + ' ' : '—') + ' | ' +
'
';
}).join('');
if (note) {
const bad = rows.filter(r => r.state === 'locked' || r.state === 'starved').length;
note.textContent = bad ? bad + ' 个模型整池不可用' : rows.length + ' 个模型部分限流';
}
}
async function loadOverview(quiet) {
try {
const d = await api('overview');
overviewData = d;
$('sTotal').textContent = d.total;
$('sHealthy').textContent = d.healthy;
$('sCooling').textContent = d.cooling;
$('sDisabled').textContent = d.disabled;
const remSum = (d.accounts || []).reduce((a, s) => a + (s.credits || 0), 0);
const totSum = (d.accounts || []).reduce((a, s) => a + (s.credits_total || 0), 0);
$('sCredits').textContent = totSum > 0 ? remSum + ' / ' + totSum : remSum;
$('sSticky').textContent = d.sticky_sessions;
$('navSub').textContent = 'v' + d.version;
$('navVer').textContent = 'v' + d.version;
$('navRedis').textContent = d.redis_mode === 'upstash' ? 'Redis 镜像' : '本地内存';
$('navState').textContent = d.healthy > 0 ? '服务正常' : (d.total ? '无可用账号' : '待添加账号');
const p = $('navPulse');
p.className = 'pulse' + (d.healthy > 0 ? '' : (d.total ? ' warn' : ' bad'));
$('accNote').textContent = d.in_flight_full ? d.in_flight_full + ' 个账号在途占满' : '';
const up = Math.floor(d.uptime_sec);
$('subMeta').textContent = '运行 ' + (up >= 86400 ? Math.floor(up / 86400) + ' 天 ' : '') + Math.floor(up % 86400 / 3600) + ' 时 ' + Math.floor(up % 3600 / 60) + ' 分';
renderAccounts(d.accounts || []);
renderModelLocks(d.model_locks);
} catch (e) { if (!quiet) toast(e.message, 'err'); }
}
$('accBody').addEventListener('click', async ev => {
const b = ev.target.closest('button[data-a]');
if (!b) return;
const u = b.dataset.u, a = b.dataset.a;
if (a === 'remove' && !confirm('移除账号将删除池状态与 auths/ 下的凭证文件,且不可恢复。确认移除?')) return;
if (a === 'disable' && !confirm('禁用后该账号不再参与选号(保号任务默认也跳过),需手动解冻才能恢复。若只是想临时让位、仍要保号,请改用「暂停选号」。确认禁用?')) return;
b.disabled = true;
try {
if (a === 'checkin') {
const r = await api('accounts/' + encodeURIComponent(u) + '/checkin', { method: 'POST' });
toast('签到完成' + (r.credits != null ? ',积分 ' + r.credits + (r.credits_total > 0 ? '/' + r.credits_total : '') : '') + (r.checkin_message ? '(' + r.checkin_message + ')' : ''), 'ok');
} else if (a === 'balance') {
const r = await api('accounts/' + encodeURIComponent(u) + '/balance', { method: 'POST' });
toast('余额已更新:' + r.credits + (r.credits_total > 0 ? ' / ' + r.credits_total : ''), 'ok');
} else if (a === 'revive') {
await api('accounts/' + encodeURIComponent(u) + '/revive', { method: 'POST' });
toast('已解冻', 'ok');
} else if (a === 'disable') {
await api('accounts/' + encodeURIComponent(u) + '/disable', { method: 'POST' });
toast('已禁用', 'ok');
} else if (a === 'pause') {
await api('accounts/' + encodeURIComponent(u) + '/pause', { method: 'POST' });
toast('已暂停选号(签到 / 保活照常)', 'ok');
} else if (a === 'resume') {
await api('accounts/' + encodeURIComponent(u) + '/resume', { method: 'POST' });
toast('已恢复选号', 'ok');
} else if (a === 'tasks') {
openTasks(u);
} else if (a === 'remove') {
const r = await api('accounts/' + encodeURIComponent(u) + '/remove', { method: 'POST' });
toast(r.file_error ? '已移除(凭证文件删除失败:' + r.file_error + ')' : '已移除', 'ok');
}
} catch (e) { toast(e.message, 'err'); }
finally { b.disabled = false; loadOverview(true); }
});
$('btnCheckinAll').onclick = async () => {
try { await api('checkin_all', { method: 'POST' }); toast('全部签到已开始,结果见日志', 'ok'); }
catch (e) { toast(e.message, 'err'); }
};
$('btnKeepaliveAll').onclick = async () => {
try { await api('keepalive_all', { method: 'POST' }); toast('全部保活已开始,结果见日志', 'ok'); }
catch (e) { toast(e.message, 'err'); }
};
$('btnTravelAll').onclick = async () => {
try { await api('travel_all', { method: 'POST' }); toast('旅行巡检已开始(含领养链路),结果见日志', 'ok'); }
catch (e) { toast(e.message, 'err'); }
};
$('btnActivityAll').onclick = async () => {
try { await api('activity_all', { method: 'POST' }); toast('活跃上报已开始,结果见日志', 'ok'); }
catch (e) { toast(e.message, 'err'); }
};
/* ── 模型 ─────────────────────────────────────────────────────────── */
/* 实测上限标注:scripts/probe_max_tokens.py --panel-out 写入探测结果,
/panel/api/model_probes 只读透传。探测键带域前缀(cn:glm-5.2),模型表
显示裸名,按「精确命中或 :后缀」关联。无数据时本列退回上游声称值。 */
function fmtK(n) { n = Number(n || 0); return n >= 1000 ? Math.round(n / 1000) + 'K' : String(n); }
function probeDays(ts) {
if (!ts) return null;
const t = new Date(String(ts).replace(' ', 'T'));
const d = (Date.now() - t.getTime()) / 86400000;
return isNaN(d) ? null : Math.floor(d);
}
function outCell(m, pr) {
if (!pr) return '' + (m.max_output_tokens ? fmtK(m.max_output_tokens) : '—') + ' | ';
const tip = '声称 ' + (pr.claimed ? fmtK(pr.claimed) : '?') + ' · 实测 ' + (pr.measured ? fmtK(pr.measured) : '?') +
(pr.note ? ' · ' + pr.note : '') + (pr.tested_at ? ' · 探测于 ' + pr.tested_at : '');
const days = probeDays(pr.tested_at);
const stale = days !== null && days > 30 ? ' · ' + days + ' 天前' : '';
if (pr.verdict === 'clamped' && pr.measured) {
if (pr.claimed && pr.measured < pr.claimed) {
const x = pr.claimed / pr.measured;
const xs = (x >= 10 ? Math.round(x) : Math.round(x * 10) / 10) + '×';
return '' +
fmtK(pr.measured) + ' ⚠ 钳制 ' + xs + stale + ' | ';
}
return '' + fmtK(pr.measured) +
(pr.claimed && pr.measured > pr.claimed ? ' ↑' : ' ✓') + ' | ';
}
if (pr.verdict === 'at_least' && pr.measured)
return '≥' + fmtK(pr.measured) + ' | ';
return '? 未测出' + stale + ' | ';
}
/* rateCell 倍率列:牌价 vs 生效价。上游 credits 是牌价(转正后基准倍率),
modelPromotions 给当前生效折扣(限时免费 factor=0 / 夜间五折 0.5 等)——
WorkBuddy 客户端显示的正是生效价。有折扣:生效价大字 + 标签 + 划线牌价,
悬停带时段说明;无 factor 只有标签(错峰类):牌价 + 标签。 */
function rateCell(m) {
const tip = m.promo_note ? ' title="' + esc(m.promo_note) + '"' : '';
if (m.promo_factor != null && m.promo_credits) {
const base = m.credits ? ' ' + esc(m.credits) + '' : '';
const label = m.promo_label ? ' ' + esc(m.promo_label) + '' : '';
return '' + esc(m.promo_credits) + '' + label + base + '';
}
if (m.promo_label) {
return '' + (m.credits ? esc(m.credits) : '—') +
' ' + esc(m.promo_label) + '';
}
return m.credits ? esc(m.credits) : '—';
}
async function loadModels() {
const tb = $('mdBody');
tb.innerHTML = '正在向上游查询… |
';
try {
// 探测数据是可选增强:拉取失败不影响模型列表本身
const [d, pr] = await Promise.all([api('models'), api('model_probes').catch(() => ({}))]);
mdAll = d.models || [];
mdProbes = pr.probes || {};
if (!mdAll.length) {
tb.innerHTML = '上游未返回模型 |
';
$('mdCount').textContent = '';
$('mdNote').textContent = '上游未返回模型';
return;
}
// 探测键带域前缀(cn:glm-5.2),模型表显示裸名,按「精确命中或 :后缀」关联。
const probeKeys = Object.keys(mdProbes);
mdProbeOf = id => mdProbes[id] || mdProbes[probeKeys.find(k => k.endsWith(':' + id))];
const hit = mdAll.filter(m => mdProbeOf(m.id)).length;
$('mdNote').textContent = mdAll.length + ' 个模型 · 已刷新降级缓存' + (hit ? ' · ' + hit + ' 个有实测上限' : '');
renderModels();
} catch (e) {
mdAll = [];
tb.innerHTML = '' + esc(e.message) + ' |
';
$('mdCount').textContent = '';
}
}
/* ── 模型筛选(按条件查询)───────────────────────────────────────────
模型目录一次拉全(几十条),筛选与排序全部在前端完成:改条件零延迟,且不会
因为调一次筛选就打一次上游——/panel/api/models 是直连上游的实时查询,很贵。
条件之间是 AND;每个条件为空即不参与判定。 */
// mdRateValue 当前生效的积分倍率数值:优先促销价(限时免费 = 0),无倍率记为
// Infinity 排到最后(排序时"没有价格"不该冒充最便宜)。
function mdRateValue(m) {
const raw = (m.promo_credits != null && m.promo_credits !== '') ? m.promo_credits : m.credits;
const n = parseFloat(String(raw == null ? '' : raw).replace(/[^\d.]/g, ''));
return Number.isFinite(n) ? n : Infinity;
}
// mdSearchText 参与关键字搜索的字段(ID / 展示名 / 厂商 / 描述 / 标签)。
function mdSearchText(m) {
return [m.id, m.name, m.vendor, m.description, (m.tags || []).join(' ')]
.filter(Boolean).join(' ').toLowerCase();
}
// mdMatch 单个模型是否满足全部筛选条件。
function mdMatch(m, f) {
f = f || mdFilter;
if (f.q) {
const text = mdSearchText(m);
// 空格分词后逐个匹配:多关键词是 AND,便于"cn 视觉"这类组合查询。
for (const kw of f.q.toLowerCase().split(/\s+/).filter(Boolean)) {
if (!text.includes(kw)) return false;
}
}
if (f.realm && !String(m.id || '').startsWith(f.realm + ':')) return false;
if (f.cap === 'tool' && !m.supports_tool_call) return false;
if (f.cap === 'vision' && !m.supports_images) return false;
if (f.cap === 'reasoning' && !m.supports_reasoning) return false;
if (f.cap === 'default' && !m.is_default) return false;
if (f.effort === 'off') {
if (!m.can_disable_thinking) return false;
} else if (f.effort && !(m.supported_efforts || []).includes(f.effort)) {
return false;
}
const factor = m.promo_factor == null ? null : Number(m.promo_factor);
if (f.promo === 'promo' && factor == null && !m.promo_label) return false;
if (f.promo === 'free' && !(factor === 0)) return false;
if (f.promo === 'discount' && !(factor != null && factor > 0)) return false;
return true;
}
// mdSortList 按当前排序条件返回新数组(不改动入参,保持上游原始顺序可回溯)。
function mdSortList(list, f) {
f = f || mdFilter;
const out = list.slice();
const num = v => { const n = Number(v || 0); return Number.isFinite(n) ? n : 0; };
if (f.sort === 'rate') out.sort((a, b) => mdRateValue(a) - mdRateValue(b));
else if (f.sort === 'context') out.sort((a, b) => num(b.context_length) - num(a.context_length));
else if (f.sort === 'output') out.sort((a, b) => num(b.max_output_tokens) - num(a.max_output_tokens));
else if (f.sort === 'name') out.sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')));
return out;
}
// mdRowHtml 单个模型行(纯渲染,便于独立测试)。
function mdRowHtml(m, pr) {
const eff = (m.supported_efforts || []).slice();
if (m.can_disable_thinking && eff.length && !eff.includes('off')) eff.push('off(可关)');
const effs = eff.length ? eff.map(e => '' + esc(e) + '').join(' ')
: '' + (m.supports_reasoning ? '固定档 · 默认 ' + esc(m.default_effort || '?') : '不支持思考') + '';
// 能力徽标:默认模型 / 工具调用 / 视觉 / 纯推理(上游目录全字段透出,缺失不显示)
const caps = [];
if (m.is_default) caps.push('默认');
if (m.supports_tool_call) caps.push('工具');
if (m.supports_images) caps.push('视觉');
if (m.supports_reasoning && !m.can_disable_thinking) caps.push('思考常开');
const capHtml = caps.length ? '' + caps.join(' ') + '
' : '';
const tip = m.description ? ' title="' + esc(m.description) + '"' : '';
return ' | ' + esc(m.id) + ' ' + esc(m.name || '') + ' ' + capHtml + ' | ' +
'' + rateCell(m) + ' | ' +
'' + (m.default_effort ? '' + esc(m.default_effort) + '' : '—') + ' | ' +
'' + effs + ' | ' +
'' + (m.context_length ? Math.round(m.context_length / 1000) + 'K' : '—') + ' | ' +
outCell(m, pr) + '
';
}
function renderModels() {
const tb = $('mdBody');
const list = mdSortList(mdAll.filter(m => mdMatch(m)));
if (!list.length) {
tb.innerHTML = '没有符合当前筛选条件的模型 |
';
} else {
tb.innerHTML = list.map(m => mdRowHtml(m, mdProbeOf(m.id))).join('');
}
const filtered = list.length !== mdAll.length;
$('mdCount').textContent = !mdAll.length ? ''
: filtered ? '命中 ' + list.length + ' / ' + mdAll.length + ' 个模型'
: mdAll.length + ' 个模型';
$('mdCount').className = filtered ? 'note src-off' : 'note';
}
function resetModelFilter() {
mdFilter = { q: '', realm: '', cap: '', effort: '', promo: '', sort: 'default' };
$('mdQ').value = ''; $('mdRealm').value = ''; $('mdCap').value = '';
$('mdEffort').value = ''; $('mdPromo').value = ''; $('mdSort').value = 'default';
renderModels();
}
// 筛选控件:输入框防抖 120ms(长列表逐字符重排不必每键一次),下拉即时。
let mdQTimer = null;
$('mdQ').oninput = () => {
clearTimeout(mdQTimer);
mdQTimer = setTimeout(() => { mdFilter.q = $('mdQ').value.trim(); renderModels(); }, 120);
};
for (const [id, key] of [['mdRealm', 'realm'], ['mdCap', 'cap'], ['mdEffort', 'effort'], ['mdPromo', 'promo'], ['mdSort', 'sort']]) {
const el = $(id);
if (!el) continue;
el.onchange = () => { mdFilter[key] = el.value; renderModels(); };
}
$('mdReset').onclick = resetModelFilter;
$('btnModels').onclick = loadModels;
/* ── 日志(频道:全部/任务/对话/系统) ─────────────────────────────── */
let logCh = 'all';
$('logChips').addEventListener('click', ev => {
const b = ev.target.closest('button[data-ch]');
if (!b) return;
logCh = b.dataset.ch;
document.querySelectorAll('#logChips .chip').forEach(c => c.classList.toggle('on', c === b));
loadLogs();
});
async function loadLogs() {
const box = $('logBox');
const atEnd = box.scrollTop + box.clientHeight >= box.scrollHeight - 24;
const limit = ($('reqLimit') && $('reqLimit').value) || 100;
// 时间范围由归档侧过滤(不是前端筛已拉取的条目):区间落在更早的时间段时,
// 「最近 N 条」里根本不会有那些记录,必须让服务端按时间取。
const rq = trangeQuery('reqRange', false);
rq.set('limit', limit);
try {
const [d, metrics, requestRows] = await Promise.all([
api('logs'),
api('request_metrics').catch(() => ({})),
api('request_logs?' + rq.toString()).catch(() => ({ entries: [] })),
]);
// 归档开启时以归档为准——「区间内没有记录」是一个真实结果,不能回落成内存里
// 的最近 100 条(那会把筛选条件之外、时间范围之外的请求显示出来)。
// 只有归档关闭时才回落到内存指标,保证没有归档的部署仍能看到最近请求。
const archiveOn = !!(metrics && metrics.archive && metrics.archive.enabled);
const recent = archiveOn ? (requestRows.entries || []) : (metrics.recent || []);
renderRequestMetrics(metrics, recent);
const entries = (d.entries || []).filter(e => logCh === 'all' || e.ch === logCh);
box.innerHTML = entries.length
? entries.map(e => {
const lvl = /error|失败|错误/.test(e.text) ? ' e' : /warn|冷却|熔断/.test(e.text) ? ' w' : '';
const t = e.ts ? new Date(e.ts).toLocaleTimeString('zh-CN', { hour12: false }) : '';
const ch = logCh === 'all' ? '' + ({ task: '任务', chat: '对话', sys: '系统' }[e.ch] || e.ch) + '' : '';
return '' + ch + esc(t + ' ' + e.text) + '';
}).join('')
: '暂无日志';
if (logPin && atEnd) box.scrollTop = box.scrollHeight;
const counts = {};
for (const e of (d.entries || [])) counts[e.ch] = (counts[e.ch] || 0) + 1;
$('logNote').textContent = logCh === 'all'
? '任务 ' + (counts.task || 0) + ' · 对话 ' + (counts.chat || 0) + ' · 系统 ' + (counts.sys || 0)
: (logCh === 'task' ? '任务' : logCh === 'chat' ? '对话' : '系统') + ' ' + entries.length + ' 行';
} catch (e) { /* 概览已提示 */ }
}
function renderRequestMetrics(m, entries) {
m = m || {};
const a = m.archive || {};
$('reqSummary').textContent =
'已完成 ' + fmtTok(m.completed) +
' · 成功 ' + (m.success_rate == null ? '—' : Number(m.success_rate).toFixed(1) + '%') +
' · HTTP ' + (m.http_success_rate == null ? '—' : Number(m.http_success_rate).toFixed(1) + '%') +
' · 平均 ' + fmtMs(m.avg_duration_ms) +
' · 进行中 ' + String(m.in_flight || 0);
$('reqNote').textContent = a.enabled
? 'JSONL 归档 ' + fmtBytes(a.bytes) + (a.dropped_writes ? ' · 丢弃 ' + a.dropped_writes + ' 条' : '') +
(a.last_error ? ' · 错误:' + a.last_error : '')
: '仅内存指标,JSONL 归档已关闭';
reqEntries = entries || [];
renderRequestTable();
}
/* reqMatch 请求记录筛选:q 对 IP/UA/模型/账号/请求 ID 做空格分词的 AND 包含匹配,
outcome 精确匹配。两者都在已拉取的条目上做(最多 1000 条),不发新请求。 */
function reqMatch(e, f) {
f = f || reqFilter;
if (f.outcome && String(e && e.outcome || '') !== f.outcome) return false;
if (f.q) {
const text = [e && e.client_ip, e && e.user_agent, e && e.model, e && e.account, e && e.request_id]
.filter(Boolean).join(' ').toLowerCase();
for (const kw of f.q.toLowerCase().split(/\s+/).filter(Boolean)) {
if (!text.includes(kw)) return false;
}
}
return true;
}
function reqOutcomeTag(e) {
const outcome = String(e && e.outcome || '');
const label = { success: '成功', http_error: 'HTTP 错误', stream_error: '流错误', interrupted: '中断' }[outcome] || outcome || '—';
const cls = outcome === 'success' ? 'ok'
: outcome === 'interrupted' ? 'warn'
: outcome ? 'bad' : 'mute';
return '' + esc(String(e && e.status || '—') + ' ' + label) + '';
}
function reqTokenCell(e) {
const total = Number(e && e.total_tokens || 0) ||
(Number(e && e.prompt_tokens || 0) + Number(e && e.completion_tokens || 0));
return total ? fmtTok(total) : '—';
}
function reqCreditCell(e) {
if (!e || !e.credit_known) return '—';
const v = Number(e.credit);
return Number.isFinite(v) ? trimFixed(v.toFixed(2)) : '—';
}
/* renderRequestTable 渲染请求记录表。来源列是这一版的重点:IP 用等宽字体方便扫,
UA 单行截断(完整值在 title 里,行本身用 requestLogText 作 tooltip)。 */
function renderRequestTable() {
const list = reqEntries.filter(e => reqMatch(e));
const tb = $('reqBody');
if (!tb) return;
tb.innerHTML = list.map(e => {
const when = e && e.time ? new Date(e.time).toLocaleTimeString('zh-CN', { hour12: false }) : '—';
const ip = e && e.client_ip ? e.client_ip : '';
const ua = e && e.user_agent ? e.user_agent : '';
const rid = e && e.request_id ? e.request_id : '';
return '' +
'| ' + esc(when) + ' | ' +
'' + reqOutcomeTag(e) + ' | ' +
'' + esc(e && e.model || '—') + ' | ' +
'' + esc(e && e.account || '—') + ' | ' +
'' + (ip ? '' + esc(ip) + '' : '—') + ' | ' +
'' + (ua ? '' + esc(ua) + '' : '—') + ' | ' +
'' + fmtMs(e && e.duration_ms) + ' | ' +
'' + reqTokenCell(e) + ' | ' +
'' + reqCreditCell(e) + ' | ' +
'' + (rid ? '' + esc(rid) + '' : '—') + ' | ' +
'
';
}).join('') || '| ' +
(reqEntries.length ? '没有符合当前筛选条件的请求记录' : '暂无请求记录') + ' |
';
const filtered = list.length !== reqEntries.length;
// 归档里的旧条目没有来源字段(该功能上线前写入):这时提示开关/历史原因,
// 而不是让人以为筛选坏了。
const hasSource = reqEntries.some(e => e && (e.client_ip || e.user_agent));
$('reqCount').textContent = !reqEntries.length ? ''
: (filtered ? '命中 ' + list.length + ' / ' + reqEntries.length + ' 条' : reqEntries.length + ' 条') +
(hasSource ? '' : ' · 来源未记录');
$('reqCount').className = (filtered || !hasSource) ? 'note src-off' : 'note';
}
/* 请求记录筛选控件。搜索框防抖 150ms:最多 1000 行重渲染,不必每键一次。
这段顶层绑定放在 requestLogText 之前,是为了让"纯函数切片"式前端测试
(slice requestLogText → fmtBytes)只拿到无副作用的格式化函数。 */
let reqQTimer = null;
if ($('reqQ')) $('reqQ').oninput = () => {
clearTimeout(reqQTimer);
reqQTimer = setTimeout(() => { reqFilter.q = $('reqQ').value.trim(); renderRequestTable(); }, 150);
};
if ($('reqOutcome')) $('reqOutcome').onchange = () => {
reqFilter.outcome = $('reqOutcome').value;
renderRequestTable();
};
if ($('reqLimit')) $('reqLimit').onchange = loadLogs;
if ($('btnReqReload')) $('btnReqReload').onclick = loadLogs;
// 时间范围:默认「全部历史」——请求记录页的历史行为就是"取最近 N 条",
// 加一个默认收窄的区间会让打开页面时看到的条数凭空变少。
if ($('reqRange')) trangeBind('reqRange', loadLogs, '0');
function requestLogText(e) {
const when = e && e.time ? new Date(e.time).toLocaleTimeString('zh-CN', { hour12: false }) : '—';
const outcomeLabel = { success: '成功', http_error: 'HTTP 错误', stream_error: '流错误', interrupted: '中断' };
const token = Number(e && e.total_tokens || 0) ||
(Number(e && e.prompt_tokens || 0) + Number(e && e.completion_tokens || 0));
let credit = 'credit —';
if (e && e.credit_known) {
const value = Number(e.credit);
if (Number.isFinite(value)) credit = String(Number(value.toFixed(2))) + ' credit';
}
return [
when,
String(e && e.status || '—') + ' ' + (outcomeLabel[e && e.outcome] || (e && e.outcome) || '—'),
e && e.model || '—',
e && e.account || '—',
e && e.client_ip || '—',
e && e.user_agent || '—',
fmtMs(e && e.duration_ms),
fmtTok(token) + ' tok',
credit,
cacheRateText(e && e.cache_hit_tokens, e && e.cache_miss_tokens) === '—' ? '' : '命中 ' + cacheRateText(e && e.cache_hit_tokens, e && e.cache_miss_tokens),
e && e.request_id || '—',
].filter(Boolean).join(' | ');
}
/* 缓存命中率纯文本(issue #92):requestLogText 与积分表/kpi 卡共用。
自包含(不依赖 trimFixed):前端纯函数切片测试只截取本段。 */
function cacheRateText(hit, miss) {
const h = Number(hit || 0), m = Number(miss || 0), total = h + m;
if (!total) return '—';
return String(Math.round(h / total * 1000) / 10) + '%';
}
function fmtBytes(bytes) {
const n = Number(bytes || 0);
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
return (n / 1024 / 1024).toFixed(1) + ' MB';
}
$('btnLogPin').onclick = () => {
logPin = !logPin;
$('btnLogPin').textContent = '自动滚动:' + (logPin ? '开' : '关');
};
/* ── 配置 ─────────────────────────────────────────────────────────── */
const CFG_MAP = {
listen: ['listen'], api_key: ['api_key'],
package_detail_limit: ['panel', 'package_detail_limit'],
checkin_hours: ['schedule', 'checkin_hours'], checkin_enabled: ['schedule', 'checkin_enabled'], growth_hours: ['schedule', 'growth_hours'], growth_enabled: ['schedule', 'growth_enabled'],
travel_hours: ['schedule', 'travel_hours'], travel_enabled: ['schedule', 'travel_enabled'],
activity_hours: ['schedule', 'activity_hours'], activity_enabled: ['schedule', 'activity_enabled'],
keepalive_hours: ['schedule', 'keepalive_hours'], keepalive_enabled: ['schedule', 'keepalive_enabled'],
balance_refresh_enabled: ['schedule', 'balance_refresh_enabled'], balance_refresh_minutes: ['schedule', 'balance_refresh_minutes'],
include_disabled_in_tasks: ['schedule', 'include_disabled_in_tasks'],
max_in_flight: ['pool', 'max_in_flight'], max_in_flight_global: ['pool', 'max_in_flight_global'],
breaker_threshold: ['pool', 'breaker_threshold'],
degrade_threshold: ['pool', 'degrade_threshold'], degrade_cooldown: ['pool', 'degrade_cooldown'],
degrade_cooldown_max: ['pool', 'degrade_cooldown_max'],
cost_explore_interval: ['pool', 'cost_explore_interval'],
credit_floor: ['pool', 'credit_floor'],
prefer_expiring: ['pool', 'prefer_expiring'], expiring_soon: ['pool', 'expiring_soon'],
soft_rate: ['cooldown', 'soft_rate'], soft_rate_max: ['cooldown', 'soft_rate_max'],
breaker_cooldown: ['pool', 'breaker_cooldown'], breaker_cooldown_max: ['pool', 'breaker_cooldown_max'],
idle_weight_per_hour: ['pool', 'idle_weight_per_hour'], idle_weight_max: ['pool', 'idle_weight_max'],
ttl: ['session_sticky', 'ttl'],
timeout_seconds: ['upstream', 'timeout_seconds'], header_timeout_seconds: ['upstream', 'header_timeout_seconds'],
idle_timeout_seconds: ['upstream', 'idle_timeout_seconds'], user_agent: ['upstream', 'user_agent'],
prompt_mode: ['prompt', 'mode'], prompt_file: ['prompt', 'file'],
sanitize_blacklist_fingerprints: ['features', 'sanitize_blacklist_fingerprints'],
session_sticky_enabled: ['session_sticky', 'enabled'],
request_client_info: ['logging', 'request_client_info'],
};
/* 「覆盖型」文本字段:空串本身是有意义的取值(= 回落到内置默认),必须照发。
*
* 其余文本字段保持「空 = 不下发」的既有语义——那是防误清空的保护,不是 bug:
* 表单里某个框没填,通常意味着"没改",把它当成"请清空"会静默抹掉配置。
*
* 但覆盖型字段正好相反:清空 = 明确要求回到默认。漏发它们会让面板显示"已保存"
* 而值其实没变(issue #102 附带发现 2:user_agent 清空后 config.json 里仍是旧值)。
*
* 刻意不含 api_key:清空它 = 关闭整个鉴权,误触代价是网关变成无鉴权公开服务。
* 该字段(以及提示文案"留空 = 不鉴权"与现状不符的问题)单独处理。
*/
const CLEARABLE_CFG = new Set(['user_agent', 'prompt_file']);
function dig(obj, path) { return path.reduce((o, k) => (o == null ? undefined : o[k]), obj); }
function put(obj, path, val) {
let o = obj;
for (let i = 0; i < path.length - 1; i++) { if (typeof o[path[i]] !== 'object' || o[path[i]] === null) o[path[i]] = {}; o = o[path[i]]; }
o[path[path.length - 1]] = val;
}
async function loadConfig() {
try {
const d = await api('config');
cfgLoaded = d.config;
$('cfgPath').textContent = d.path || '';
const f = $('cfgForm');
for (const [name, path] of Object.entries(CFG_MAP)) {
const el = f.elements[name];
if (!el) continue;
const v = dig(cfgLoaded, path);
if (el.type === 'checkbox') el.checked = !!v;
else if (Array.isArray(v)) el.value = v.join(', ');
else el.value = v == null ? '' : v;
}
markDurationFields(); // 回填后重置校验态(清掉残留红框;现值来自后端必然合法)
$('cfgNote').textContent = '';
} catch (e) { toast('读取配置失败:' + e.message, 'err'); }
}
function collectConfig() {
const f = $('cfgForm'), out = {};
for (const [name, path] of Object.entries(CFG_MAP)) {
const el = f.elements[name];
if (!el) continue;
let v;
if (el.type === 'checkbox') v = el.checked;
else if (el.type === 'number') { v = el.value.trim() === '' ? undefined : Number(el.value); }
else {
const raw = el.value.trim();
// 覆盖型字段空串照发(见 CLEARABLE_CFG);其余空 = 不下发。
if (raw === '') v = CLEARABLE_CFG.has(name) ? '' : undefined;
else if (name.endsWith('_hours')) v = raw.split(/[,,\s]+/).filter(Boolean).map(Number);
else v = raw;
}
if (v !== undefined) put(out, path, v);
}
return out;
}
/* Go 时长字段即时校验:空 = 沿用现值(collectConfig 跳过发送);非空必须是
ParseDuration 语法(30m / 2h / 600s / 1h30m,可组合可带小数)。与后端
config.go normalize() 的 time.ParseDuration 同口径,脏值在前端就地标红,
不再等到保存被拒。 */
const DURATION_RE = /^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/;
const DURATION_FIELDS = ['soft_rate', 'soft_rate_max', 'breaker_cooldown', 'breaker_cooldown_max',
'degrade_cooldown', 'degrade_cooldown_max', 'cost_explore_interval', 'expiring_soon', 'ttl'];
const DURATION_TIP = '格式应为 Go 时长:30m / 2h / 600s / 1h30m';
function durationBad(name) {
const el = $('cfgForm').elements[name];
if (!el) return false;
const v = el.value.trim();
return v !== '' && !DURATION_RE.test(v);
}
function markDurationFields() {
for (const name of DURATION_FIELDS) {
const el = $('cfgForm').elements[name];
if (!el) continue;
const bad = durationBad(name);
el.classList.toggle('invalid', bad);
el.title = bad ? DURATION_TIP : '';
}
}
$('cfgForm').addEventListener('input', ev => {
if (DURATION_FIELDS.includes(ev.target.name)) markDurationFields();
});
$('btnEye').onclick = () => {
const el = $('cfgKey');
const show = el.type === 'password';
el.type = show ? 'text' : 'password';
$('btnEye').textContent = show ? '隐藏' : '显示';
};
$('btnCfgReload').onclick = loadConfig;
$('cfgForm').onsubmit = async ev => {
ev.preventDefault();
// 时长字段脏值拦截:标红 + toast 点名,不发保存请求(后端同样会拒,这里前置)。
markDurationFields();
const firstBad = DURATION_FIELDS.find(durationBad);
if (firstBad) {
const el = $('cfgForm').elements[firstBad];
el.focus();
toast('「' + (el.closest('.fld')?.querySelector('.lb')?.textContent || firstBad) + '」' + DURATION_TIP, 'err');
return;
}
const btn = $('btnCfgSave');
btn.disabled = true; btn.textContent = '保存中…';
try {
const r = await api('config', { method: 'POST', body: JSON.stringify(collectConfig()) });
const n = (r.restart_required || []).length;
toast(n ? '配置已保存,其中 ' + n + ' 项需重启进程生效' : '配置已保存并立即生效', 'ok');
// 密钥可能已改:本次会话沿用新值,避免下一次轮询被 401。
const k = $('cfgKey').value.trim();
if (k) localStorage.setItem(LS_KEY, k);
loadConfig();
loadOverview(true);
} catch (e) { toast('保存失败:' + e.message, 'err'); }
finally { btn.disabled = false; btn.textContent = '保存配置'; }
};
/* ── 添加账号 ─────────────────────────────────────────────────────── */
function openAdd() {
$('addVeil').classList.add('on');
// 重置到登录标签
switchAddTab('login');
$('addPick').hidden = false;
$('addLoad').hidden = true; $('addReady').hidden = true;
$('addDone').hidden = true; $('addErr').hidden = true;
$('importDone').hidden = true; $('importErr').hidden = true;
$('btnCopyUrl').hidden = true; $('btnOpenUrl').hidden = true;
$('btnStartLogin').hidden = false; $('btnStartLogin').disabled = false;
stopPoll();
}
function switchAddTab(tab) {
document.querySelectorAll('#addTabs .tab').forEach(b => b.classList.toggle('on', b.dataset.tab === tab));
$('addTabLogin').hidden = tab !== 'login';
$('addTabImport').hidden = tab !== 'import';
}
document.querySelectorAll('#addTabs .tab').forEach(b => {
b.onclick = () => switchAddTab(b.dataset.tab);
});
function startAddLogin() {
const realm = (document.querySelector('input[name="addRealm"]:checked') || {}).value || 'cn';
$('btnStartLogin').disabled = true;
$('addLoad').hidden = false; $('addErr').hidden = true;
api('login/start', { method: 'POST', body: JSON.stringify({ realm }) }).then(r => {
loginState = r.state;
$('addUrl').textContent = r.url;
$('addPick').hidden = true; // 选域锁定(会话已按该域发起)
$('addLoad').hidden = true; $('addReady').hidden = false;
$('btnStartLogin').hidden = true;
$('btnCopyUrl').hidden = false; $('btnOpenUrl').hidden = false;
loginTimer = setInterval(pollLogin, 3000);
}).catch(e => {
$('addLoad').hidden = true;
$('btnStartLogin').disabled = false;
$('addErr').hidden = false;
$('addErr').textContent = e.message;
});
}
function stopPoll() { if (loginTimer) { clearInterval(loginTimer); loginTimer = null; } }
async function pollLogin() {
if (!loginState) return;
try {
const r = await api('login/poll?state=' + encodeURIComponent(loginState));
if (r.done) {
stopPoll();
$('addReady').hidden = true;
$('addDone').hidden = false;
$('addDone').textContent = '已添加 ' + (r.nickname || r.uid) + (r.realm === 'global' ? '(国际版)' : '') + (r.credits >= 0 ? ' · 积分 ' + r.credits + (r.credits_total > 0 ? '/' + r.credits_total : '') : '') + ',账号已载入池中';
setTimeout(() => { closeAdd(); loadOverview(true); }, 1600);
}
} catch (e) {
stopPoll();
$('addReady').hidden = true;
$('addErr').hidden = false;
$('addErr').textContent = e.message + '(关闭后重新添加)';
}
}
function closeAdd() { stopPoll(); loginState = null; $('addVeil').classList.remove('on'); }
$('btnCloseAdd').onclick = closeAdd;
$('btnStartLogin').onclick = startAddLogin;
$('btnOpenUrl').onclick = () => open($('addUrl').textContent, '_blank');
$('btnCopyUrl').onclick = () => navigator.clipboard.writeText($('addUrl').textContent)
.then(() => toast('链接已复制', 'ok'), () => toast('复制失败,请手动选择复制', 'err'));
$('importFile').onchange = async () => {
const file = $('importFile').files[0];
if (!file) return;
$('importDone').hidden = true; $('importErr').hidden = true;
const fd = new FormData();
fd.append('file', file);
const h = {};
const k = localStorage.getItem(LS_KEY);
if (k) h['Authorization'] = 'Bearer ' + k;
try {
const r = await fetch('/panel/api/import/cockpit', { method: 'POST', body: fd, headers: h });
const d = await r.json();
if (!r.ok) throw new Error(d.error || ('HTTP ' + r.status));
$('importDone').hidden = false;
$('importDone').textContent = '导入完成:成功 ' + d.imported + ' 个' + (d.skipped ? ',跳过 ' + d.skipped + ' 个' : '');
if (d.errors && d.errors.length) {
console.warn('import errors:', d.errors);
}
loadOverview(true);
} catch (e) {
$('importErr').hidden = false;
$('importErr').textContent = '导入失败:' + e.message;
}
$('importFile').value = '';
};
/* ── 顶部动作 ─────────────────────────────────────────────────────── */
$('btnAdd').onclick = openAdd;
$('btnRefresh').onclick = async () => {
const b = $('btnRefresh');
b.disabled = true; b.textContent = '刷新中…';
try {
await api('balance_all', { method: 'POST' });
await loadOverview(true);
toast('余额已从上游刷新', 'ok');
} catch (e) { toast('刷新失败:' + e.message, 'err'); await loadOverview(true); }
finally { b.disabled = false; b.textContent = '刷新'; }
if (view === 'logs') loadLogs();
};
/* ── 轮询 ─────────────────────────────────────────────────────────── */
function refreshVisible() {
if (view === 'accounts') loadOverview(true);
else if (view === 'logs') loadLogs();
else if (view === 'taskscenter') reattachQueueView();
}
function start() {
loadOverview(true);
if (refTimer) clearInterval(refTimer);
refTimer = setInterval(refreshVisible, 5000);
checkAuthGate();
}
async function checkAuthGate() {
try { await api('overview'); }
catch (e) { if (String(e.message).includes('密钥') || String(e.message).includes('api_key')) return; }
}
start();
/* ── 积分任务 ─────────────────────────────────────────────────────── */
let taskUID = null;
// 可自动完成的任务(与后端 autoActions 表一致):判据为行为事件、可经网关复现。
// 其余任务需在官方客户端内交互,面板只展示指引(行 title 提示)。
// 注意:键含点号(Model_chat_GLM5.2)必须加引号,否则会被解析成属性访问 + 数字字面量。
const AUTO_TASKS = {
'chat_5': '上报 5 条对话活跃事件(自动补足差额)',
'first_buddy': '上报解锁 → 同意协议 → 领取第一只 Buddy',
'Model_chat_GLM5.2': '接受任务 → glm-5.2 真实对话一次 → 对齐模型上报',
'RichMeow_Chat': '桌面指纹事件链上报(已验证可点亮)',
'Buddy_App': '上报「进入 Buddy 应用」事件链(已验证可点亮)',
'Buddy_App_QQ': '上报「进入企鹅教师助手」事件链(已验证可点亮)',
'automation_1': '上报「定时任务创建」事件(已验证可点亮)',
'Library_read': '上报「读资料库介绍」事件(已验证可点亮)',
'template_5': '上报「使用模板创建任务」事件组 ×5(三账号实测点亮)',
'playbook_prompt': '上报「灵感案例做同款发送 Prompt」事件组(三账号实测点亮)',
'create_canvas': '上报「设计创意画布创建」事件组(三账号实测点亮,+300 分)',
'expert_5': '真实专家召唤+使用链 ×5(专家市场+真实 chat,三账号实测点亮)',
'Expert_team_use_3': '真实专家团召唤+使用链 ×3(三账号实测点亮)',
'Hp_Appearance': '设置主题 API + 皮肤生效事件(两账号实测点亮)',
'black_cat': '夜猫子:23:00–08:00 窗口内 glm-5.2 对话补足(窗口外提示等 23 点排程)',
'Expert_lighthouse': '真实轻量云专家召唤+使用链(真实对话 requestId,两账号实测点亮)',
'skill_1': '真实对话 + skill_info 技能加载事件(实测点亮)',
'school_season': '校园日(小程序口径):accept → mini 对话+activityId 上报 → 领奖(+100c+5e)',
'Sequential_Tasks_1': '小程序首对话(小程序口径):accept → mini 对话上报 → 领奖(+100c+5e)',
'Sequential_Tasks_2': '小程序选专家对话(小程序口径):市场专家 id → accept → expert_actual_use 上报 → 领奖(+200c+5e)',
'Sequential_Tasks_3': '小程序五次对话(小程序口径):accept → mini 对话上报 ×5(自动补差额)→ 领奖(+300c+5e)',
'Sequential_Tasks_4': '小程序定时任务(预留,每日零点解锁一环):accept → 定时任务创建事件 → 领奖(判据待解锁验证)',
'Sequential_Tasks_5': '小程序使用 GLM5.2(预留):accept → 带模型字段的 mini 对话上报 → 领奖(判据待解锁验证)',
'Sequential_Tasks_6': '小程序十次对话(预留):accept → mini 对话上报 ×target(自动补差额)→ 领奖',
'Sequential_Tasks_7': '体验灵感功能(预留,疑 PC 口径):accept → 灵感事件组(PC+mp 双形态)→ 领奖(判据待解锁验证)'
};
function openTasks(uid) {
taskUID = uid;
$('taskWho').textContent = uid.slice(0, 16);
$('taskVeil').classList.add('on');
$('btnTaskReload').hidden = false;
loadTasks();
}
function closeTasks() { $('taskVeil').classList.remove('on'); taskUID = null; }
$('btnCloseTask').onclick = closeTasks;
$('btnTaskReload').onclick = loadTasks;
// 全部接受:把该账号未接受的任务一次性报名(幂等,跳过已接受/已领取)。
$('btnTaskAcceptAll').onclick = async () => {
if (!taskUID) return;
const btn = $('btnTaskAcceptAll');
btn.disabled = true; btn.textContent = '接受中…';
try {
const r = await api('accounts/' + encodeURIComponent(taskUID) + '/tasks/accept_all', { method: 'POST' });
const n = r.accepted || 0;
if (r.failed && r.failed.length) {
toast(`已接受 ${n} 个,${r.failed.length} 个被上游拒绝(可重试)`, 'err');
} else {
toast(n ? `已接受 ${n} 个任务` : (r.message || '所有任务均已接受'), 'ok');
}
} catch (e) { toast(e.message, 'err'); }
finally { btn.disabled = false; btn.textContent = '全部接受'; loadTasks(); }
};
// 一键完成全部可自动任务(耗时较长:含真实对话,逐项回读验证)。
$('btnTaskAutoAll').onclick = async () => {
if (!taskUID) return;
const btn = $('btnTaskAutoAll');
if (!confirm('将依次执行:补报对话事件、领取 Buddy、glm-5.2 对话、尝试上报。\n过程约 1-2 分钟(含真实对话),确认继续?')) return;
btn.disabled = true; btn.textContent = '执行中…';
try {
const r = await api('accounts/' + encodeURIComponent(taskUID) + '/tasks/auto_all', { method: 'POST' });
const okN = (r.results || []).filter(x => x.status === 'done').length;
const skipN = (r.results || []).filter(x => x.status === 'skipped').length;
const errN = (r.results || []).filter(x => x.status === 'error').length;
toast(`执行完成:成功 ${okN} 项,跳过 ${skipN} 项${errN ? ',失败 ' + errN + ' 项' : ''}`, errN ? 'err' : 'ok');
console.log('auto_all results:', r.results);
} catch (e) { toast(e.message, 'err'); }
finally { btn.disabled = false; btn.textContent = '一键完成可自动任务'; loadTasks(); }
};
async function loadTasks() {
if (!taskUID) return;
const st = $('taskState'), tb = $('taskTable');
st.hidden = false;
st.className = 'state';
st.innerHTML = '查询中';
tb.hidden = true;
try {
const d = await api('accounts/' + encodeURIComponent(taskUID) + '/tasks');
const list = d.tasks || [];
if (!list.length) {
st.className = 'state';
st.textContent = '该账号暂无任务';
return;
}
// 有进度或可领取的排前面,已领取沉底——一眼看到"现在该做什么"。
list.sort((a, b) => (a.claimed - b.claimed) || (b.claimable - a.claimable) || String(a.task_code).localeCompare(String(b.task_code)));
$('taskBody').innerHTML = list.map(t => {
// 进度:current 可能缺失(0 或被上游省略)——用 ?? 兜底,避免渲染成 "undefined / N"
const cur = t.current ?? 0, tgt = t.target ?? 0;
const prog = tgt ? cur + ' / ' + tgt : (tgt === 0 && cur > 0 ? String(cur) : '—');
const parts = [];
if (t.credit) parts.push('+' + t.credit + ' 分');
if (t.energy) parts.push('+' + t.energy + ' 能');
if (t.reward_buddy) parts.push('Buddy');
const reward = parts.length ? parts.join(' ') : '—';
const badge = t.claimed ? '已领取'
: t.claimable ? '可领取'
: t.locked ? '未解锁'
: t.accept_status === 'accepted' ? '进行中'
: '未接受';
const acted = t.claimed || t.locked ? ''
: t.claimable ? ''
: AUTO_TASKS[t.task_code] ? ''
: t.accept_status === 'accepted' ? ''
: '';
// 操作指引(description/task_desc)挂 title 提示:如何完成交给用户看
const tip = [t.title, t.task_desc || t.description, t.jump_url ? '跳转:' + t.jump_url : ''].filter(Boolean).join('\n');
return ' | ' +
'' + esc(t.title || t.task_code) + ' ' + esc(t.task_code) + (t.tag ? ' · ' + esc(t.tag) : '') + ' | ' +
'' + esc(prog) + ' | ' +
'' + esc(reward) + ' | ' +
'' + badge + ' | ' +
'' + acted + ' |
';
}).join('');
st.hidden = true;
tb.hidden = false;
} catch (e) {
st.className = 'state err';
st.textContent = e.message;
}
}
$('taskBody').addEventListener('click', async ev => {
const b = ev.target.closest('button[data-t]');
if (!b || !taskUID) return;
const kind = b.dataset.t, code = b.dataset.c;
b.disabled = true;
try {
if (kind === 'auto') {
// 一键完成:后端执行动作 → 回读进度 → 汇报(耗时可到分钟级,含真实对话)
b.textContent = '执行中…';
const r = await api('accounts/' + encodeURIComponent(taskUID) + '/tasks/auto', {
method: 'POST', body: JSON.stringify({ task_code: code })
});
if (r.skipped) {
toast(r.message || '已跳过', 'ok');
} else {
const advanced = r.progress_before !== r.progress_after;
let msg = r.message || '已执行';
if (r.progress_after) msg += `(进度 ${r.progress_before} → ${r.progress_after})`;
if (r.claimed) msg += ',奖励已自动到账';
else if (r.claimable) msg += r.claim_error ? ',可点「领取」重试' : '';
else if (r.attempt && !advanced) msg += ';进度未动,该任务可能需要官方客户端';
toast(msg, (r.claimed || advanced) ? 'ok' : 'err');
}
loadOverview(true);
} else {
const path = 'accounts/' + encodeURIComponent(taskUID) + '/tasks/' + (kind === 'claim' ? 'claim' : 'accept');
const body = kind === 'claim' ? { task_code: code } : { task_codes: [code] };
await api(path, { method: 'POST', body: JSON.stringify(body) });
toast(kind === 'claim' ? '已领取奖励' : '已接受任务', 'ok');
if (kind === 'claim') loadOverview(true);
}
} catch (e) { toast(e.message, 'err'); }
finally { loadTasks(); }
});
/* ── 任务中心:开学季 + 全账号扫描/队列 ──────────────────────────── */
// 开学季任务单元:✓ 已领(绿)|◐ x/y 进行中(琥珀)|○ 未做(灰)
function staskHTML(t) {
if (!t) return '·—';
if (t.status === 'claimed') return '✓已领';
if (t.status === 'completed') return '◆可领';
if (t.status === 'in_progress') {
const fr = t.target_count ? '' + t.progress + '/' + t.target_count + '' : '';
return '◐' + fr + '';
}
return '○未做';
}
const LUCK_SVG = '';
const SCHOOL_TITLES = {
share_invite: '分享活动 +100c', desktop_chat_1_time: '桌面端体验 +100c(单次)',
chat_3_times: '和 AI 对话 3 次 +50c', expert_use: '召唤开学季专家 +50c',
task_student_verify: '学生认证 +100c(需真实认证,不做)',
};
/* ── 精简 QR 编码器(券码二维码用)────────────────────────────────────
规格子集:byte 模式、ECC L、版本 1-5(全部单纠错块,免块交织)、固定掩码 0。
完整性:规范允许任选掩码(解码器按格式信息位自行去掩码),固定掩码不影响
可扫描性;已用 python qrcode 库对多输入多版本做逐像素交叉验证(强制 byte
模式 + mask 0,5/5 全部 diff=0)。面板 CSP 只允许 self,外链 QR 服务不可用。 */
// qr_gen.js —— 精简 QR 编码器(浏览器用 + node 可跑交叉验证)
// 规格子集:byte 模式、ECC L、版本 1-5(全部单纠错块,免块交织)、固定掩码 0。
// 完整性说明:规范允许编码器任选掩码(解码器按格式信息位自行去掩码),
// 固定掩码不影响可扫描性;券码为短文本,v1-5(26 字节起)绰绰有余。
// GF(256) 对数/指数表(本原多项式 0x11d)
const QR_EXP = new Array(512), QR_LOG = new Array(256);
(() => {
let x = 1;
for (let i = 0; i < 255; i++) { QR_EXP[i] = x; QR_LOG[x] = i; x <<= 1; if (x & 0x100) x ^= 0x11d; }
for (let i = 255; i < 512; i++) QR_EXP[i] = QR_EXP[i - 255];
})();
const gmul = (a, b) => (a && b) ? QR_EXP[QR_LOG[a] + QR_LOG[b]] : 0;
// 各版本参数(下标 = 版本-1):[数据码字数, 纠错码字数],ECC L 单块
const QR_V = [[19, 7], [34, 10], [55, 15], [80, 20], [108, 26]];
// 对齐图案中心坐标(v2+;与定位图案重叠的位置在放置时跳过)
const QR_ALIGN = [[], [6, 18], [6, 22], [6, 26], [6, 30]];
const QR_MASK = (r, c) => (r + c) % 2 === 0; // 掩码模式 0
// 生成多项式(最高次系数在前,g[0] 恒为 1)
function qrGenPoly(deg) {
let g = [1];
for (let i = 0; i < deg; i++) {
const a = QR_EXP[i], ng = new Array(g.length + 1).fill(0);
ng[0] = g[0];
for (let j = 1; j < g.length; j++) ng[j] = g[j] ^ gmul(a, g[j - 1]);
ng[g.length] = gmul(a, g[g.length - 1]);
g = ng;
}
return g;
}
// Reed-Solomon 求余(综合除法),返回 deg 个纠错码字
function rsRem(data, deg) {
const g = qrGenPoly(deg);
const res = data.concat(new Array(deg).fill(0));
for (let i = 0; i < data.length; i++) {
const f = res[i];
if (f) for (let j = 0; j < g.length; j++) res[i + j] ^= gmul(g[j], f);
}
return res.slice(data.length);
}
// 文本 → 码字流(byte 模式:0100 + 8 位计数 + 数据 + 终止符 + 0xEC/0x11 填充)
function qrDataCodewords(text, dataCap) {
const bytes = Array.from(new TextEncoder().encode(text));
const bits = [];
const push = (val, n) => { for (let i = n - 1; i >= 0; i--) bits.push((val >> i) & 1); };
push(4, 4); // byte 模式
push(bytes.length, 8); // v1-9 计数 8 位
for (const b of bytes) push(b, 8);
const cap = dataCap * 8;
push(0, Math.min(4, cap - bits.length)); // 终止符
while (bits.length % 8) bits.push(0);
const out = [];
for (let i = 0; i < bits.length; i += 8) {
let v = 0; for (const b of bits.slice(i, i + 8)) v = (v << 1) | b;
out.push(v);
}
for (let p = 0; out.length < dataCap; p ^= 1) out.push(p ? 0x11 : 0xEC);
return out;
}
// 主入口:text → 布尔矩阵(true=深色模块)
function qrMatrix(text) {
const bytes = Array.from(new TextEncoder().encode(text));
// 版本选择:需求 ≈ 2 码字头 + 文本长度,取首个放得下的版本
let ver = 0;
for (let v = 0; v < QR_V.length; v++) { if (bytes.length + 2 <= QR_V[v][0]) { ver = v + 1; break; } }
if (!ver) throw new Error('QR: text too long (>' + QR_V[4][0] + ' bytes)');
const [dataCap, ecCap] = QR_V[ver - 1];
const n = 17 + 4 * ver;
const M = Array.from({ length: n }, () => new Array(n).fill(false));
const F = Array.from({ length: n }, () => new Array(n).fill(false)); // 功能模块占位
const setF = (r, c, v) => { M[r][c] = v; F[r][c] = true; };
// 定位图案 + 分隔带
const finder = (r0, c0) => {
for (let r = -1; r <= 7; r++) for (let c = -1; c <= 7; c++) {
const rr = r0 + r, cc = c0 + c;
if (rr < 0 || cc < 0 || rr >= n || cc >= n) continue;
const dark = r >= 0 && r <= 6 && c >= 0 && c <= 6 && (r === 0 || r === 6 || c === 0 || c === 6 || (r >= 2 && r <= 4 && c >= 2 && c <= 4));
setF(rr, cc, dark);
}
};
finder(0, 0); finder(0, n - 7); finder(n - 7, 0);
// 校正图形(仅贯穿两定位图案之间:8..n-9,不得覆盖定位图案本体)
for (let r = 8; r <= n - 9; r++) setF(r, 6, r % 2 === 0);
for (let c = 8; c <= n - 9; c++) setF(6, c, c % 2 === 0);
// 对齐图案(v2+,跳过与定位重叠处)
const align = QR_ALIGN[ver - 1] || [];
for (const ar of align) for (const ac of align) {
if (F[ar][ac]) continue;
for (let r = -2; r <= 2; r++) for (let c = -2; c <= 2; c++)
setF(ar + r, ac + c, Math.max(Math.abs(r), Math.abs(c)) !== 1);
}
// 暗模块 + 格式信息(ECC L=01,掩码 0)——BCH(15,5) + 0x5412 异或。
// 位序遵循规范(与 python qrcode 逐位对齐验证):bit i 从 LSB 起数,
// 副本一走左上角 L 形、副本二走右下 L 形。
let fmt = (1 << 3) | 0; // L<<3 | mask
let rem = fmt << 10;
for (let i = 14; i >= 10; i--) if ((rem >> i) & 1) rem ^= 0x537 << (i - 10);
fmt = ((fmt << 10) | rem) ^ 0x5412; // 15 位
const fb = i => (fmt >> i) & 1;
// 副本一(左上):位 0..5 → (i,8);6 → (7,8);7 → (8,8)
for (let i = 0; i <= 5; i++) setF(i, 8, !!fb(i));
setF(7, 8, !!fb(6)); setF(8, 8, !!fb(7));
// 副本一续 + 副本二(右下):位 8..14 → (n-15+i, 8);位 0..7 → (8, n-1-i);8 → (8,7);9..14 → (8,14-i)
for (let i = 8; i <= 14; i++) setF(n - 15 + i, 8, !!fb(i));
for (let i = 0; i <= 7; i++) setF(8, n - 1 - i, !!fb(i));
setF(8, 7, !!fb(8));
for (let i = 9; i <= 14; i++) setF(8, 14 - i, !!fb(i));
// 暗模块(恒为深色,位于副本一垂直段末端)
setF(n - 8, 8, true);
// 数据码字 + 纠错码字 → 位流
const dcw = qrDataCodewords(text, dataCap);
const cw = dcw.concat(rsRem(dcw, ecCap));
const bits = [];
for (const b of cw) for (let i = 7; i >= 0; i--) bits.push((b >> i) & 1);
// 蛇形放置(成对列,从右向左,跳过第 6 列),写数据时直接异或掩码
let bi = 0, up = true;
for (let x = n - 1; x > 0; x -= 2) {
if (x === 6) x--;
for (let i = 0; i < n; i++) {
const r = up ? n - 1 - i : i;
for (const c of [x, x - 1]) {
if (F[r][c]) continue;
const bit = bi < bits.length ? bits[bi++] : 0;
M[r][c] = bit ? !QR_MASK(r, c) : QR_MASK(r, c);
}
}
up = !up;
}
return M;
}
// 矩阵 → SVG(quiet zone 4 模块)
function qrSVG(M, px) {
const n = M.length, q = 4, total = n + q * 2;
let s = '';
}
/* ── 开学季券码查询(弹窗,仿活动页 #/prizes?tab=vouchers)──────────── */
/* copyText:clipboard API 只在 secure context(https/localhost)可用,
远程 http 面板会拿不到 navigator.clipboard → 降级 execCommand。 */
function copyText(text) {
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
return new Promise((resolve, reject) => {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;opacity:0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy') ? resolve() : reject(new Error('copy failed')); }
catch (e) { reject(e); }
finally { ta.remove(); }
});
}
function vcCard(v) {
const expired = v.valid_to && new Date(v.valid_to) < new Date();
return '' +
'
' + esc(v.prize_name || v.sku_code || '券') + '' +
(expired ? '已过期' : '可使用') + '
' +
'
' +
(v.valid_to ? '有效期至 ' + esc(v.valid_to) : '长期有效') +
(v.granted_at ? ' · ' + esc(v.granted_at.slice(0, 10)) + ' 抽中' : '') +
'
' +
'
' +
'
券码' + esc(v.code || '-') + '' +
'' +
(v.code ? '' : '') +
'' +
'
' +
'
';
}
async function loadSchoolVouchers() {
const body = $('vcBody');
$('vcVeil').classList.add('on');
body.innerHTML = '查询中
';
$('vcNote').textContent = '';
try {
const d = await api('school/vouchers');
const arr = d.accounts || [];
const ok = arr.filter(a => !a.error);
const total = ok.reduce((n, a) => n + (a.vouchers || []).length, 0);
body.innerHTML = ok.filter(a => (a.vouchers || []).length).map(a =>
'' + esc(a.nickname || a.uid) + '' +
'' + a.vouchers.length + ' 张
' +
a.vouchers.map(vcCard).join('')
).join('') || '';
$('vcNote').textContent = total ? total + ' 张券 · ' + ok.filter(a => !(a.vouchers || []).length).length + ' 个账号未抽中' : '';
const errs = arr.filter(a => a.error);
if (errs.length) {
body.insertAdjacentHTML('beforeend', '查询失败:' +
errs.map(a => esc(a.nickname || a.uid.slice(0, 8)) + '(' + esc(a.error) + ')').join('、') + '
');
}
body.querySelectorAll('button[data-copy]').forEach(b => b.onclick = async () => {
try { await copyText(b.dataset.copy); toast('券码已复制', 'ok'); }
catch (e) { toast('复制失败,请手动选择券码', 'err'); }
});
// 二维码:券码本体编码为 QR(到店出示扫描),点击切换显示/隐藏
body.querySelectorAll('button[data-qr]').forEach(b => b.onclick = () => {
const card = b.closest('.vc');
const old = card.querySelector('.vc-qr');
if (old) { old.remove(); return; }
const box = document.createElement('div');
box.className = 'vc-qr';
try { box.innerHTML = qrSVG(qrMatrix(b.dataset.qr), 148); }
catch (e) { box.innerHTML = '二维码生成失败:' + esc(e.message) + ''; }
card.appendChild(box);
});
} catch (e) {
body.innerHTML = '' + esc(e.message) + '
';
}
}
$('btnSchoolVouchers').onclick = loadSchoolVouchers;
$('btnVcClose').onclick = () => $('vcVeil').classList.remove('on');
$('btnVcRefresh').onclick = loadSchoolVouchers;
/* 成长任务队列。lastQueueSeq 记录本页启动过的队列代次:执行结束后的残留 items
(running=false 但 seq 停在旧值)不再回写视图——否则扫描结果 3 秒后被上一轮
队列状态覆盖。 */
let queueTimer = null, lastQueueSeq = 0;
const GROWTH_TITLES = {}; // code → 展示名(扫描时从任务列表带出)
$('btnScanAll').onclick = async () => {
const b = $('btnScanAll');
// 停掉队列轮询:显式扫描 = 切到待办视图。否则在途队列的下一 tick 会把扫描
// 结果冲掉重渲染回队列视图(服务端执行不受影响,只是不再实时回写本视图)。
if (queueTimer) { clearInterval(queueTimer); queueTimer = null; }
b.disabled = true; b.textContent = '扫描中…';
try {
const d = await api('tasks/scan_all', { method: 'POST' });
renderQueue(groupItems(d), null, '没有待办任务 🎉', '全部账号的成长任务与开学季活动都已完成,明日再来。');
} catch (e) { toast(e.message, 'err'); }
finally { b.disabled = false; b.textContent = '扫描待办'; }
};
$('btnRunQueue').onclick = async () => {
const conc = Number($('qcConc').value) || 1;
if (!confirm('扫描全部账号待办并排队执行(账号并发 ' + conc + ',账号内串行)。\n含真实对话的任务耗时较长,确认继续?')) return;
const b = $('btnRunQueue');
b.disabled = true; b.textContent = '启动中…';
try {
const r = await api('tasks/run_queue', { method: 'POST', body: JSON.stringify({ concurrency: conc }) });
if (!r.started) { toast(r.message || '没有待办任务', 'ok'); return; }
lastQueueSeq = r.seq || 0;
toast('队列已启动:' + r.total + ' 项(并发 ' + conc + ')', 'ok');
startQueuePolling();
} catch (e) { toast(e.message, 'err'); }
finally { b.disabled = false; b.textContent = '执行全部待办'; }
};
// 扫描结果 → 分组条目(无执行状态)
function groupItems(d) {
const groups = [];
for (const a of (d.accounts || [])) {
const rows = [];
for (const t of (a.growth || [])) {
GROWTH_TITLES[t.task_code] = t.title || t.task_code;
rows.push({ kind: 'growth', code: t.task_code, prog: t.target ? t.current + '/' + t.target : '—', status: 'scan' });
}
if (rows.length) groups.push({ uid: a.uid, nick: a.nickname, rows });
}
return groups;
}
const ST_WORDS = { done: '完成', running: '执行中', error: '失败', skipped: '跳过', pending: '排队', scan: '待执行' };
function qrowHTML(it) {
const isSchool = it.kind === 'school';
const title = isSchool ? '开学季闭环' : (GROWTH_TITLES[it.code] || it.code);
const dotCls = it.status === 'scan' ? 'wait' : it.status === 'running' ? 'run' : it.status === 'error' ? 'err' : it.status === 'skipped' ? 'skip' : it.status === 'done' ? 'done' : 'wait';
const stWord = it.status === 'scan' ? '待执行' : (ST_WORDS[it.status] || it.status);
return '' +
'' + esc(it.code) + '' +
'' + esc(title) + '' + (isSchool ? '开学季' : '') + '' +
'' + esc(it.prog || '') + '' +
'' + stWord + '' +
'' + esc(it.message || '') + '' +
'
';
}
function renderQueue(groups, progress, emptyTitle, emptyDesc) {
const empty = $('tcEmpty'), list = $('qcList');
if (!groups.length) {
empty.style.display = '';
if (emptyTitle) empty.querySelector('.t').textContent = emptyTitle;
if (emptyDesc) empty.querySelector('.d').textContent = emptyDesc;
list.innerHTML = '';
$('qProg').hidden = true; $('qcSummary').textContent = '';
return;
}
empty.style.display = 'none';
empty.style.display = 'none';
let total = 0;
list.innerHTML = groups.map(g => {
total += g.rows.length;
return '' + esc(g.nick || g.uid.slice(0, 12)) + '' + g.rows.length + ' 项待办' +
g.rows.map(qrowHTML).join('') + '
';
}).join('');
$('qcSummary').textContent = total + ' 项';
updateProgress(progress);
}
function updateProgress(q) {
if (!q || !q.items) { $('qProg').hidden = true; return; }
const total = q.items.length;
const done = q.items.filter(it => it.status === 'done' || it.status === 'error' || it.status === 'skipped').length;
$('qProg').hidden = false;
$('qBarFill').style.width = (total ? Math.round(done / total * 100) : 0) + '%';
$('qProgText').textContent = (q.running ? '执行中 ' : '已结束 ') + done + ' / ' + total;
}
// 队列状态 → 分组(执行时轮询)
function groupsFromQueue(items) {
const by = new Map();
for (const it of items) {
if (!by.has(it.uid)) by.set(it.uid, { uid: it.uid, nick: it.nickname, rows: [] });
by.get(it.uid).rows.push({
kind: it.kind, code: it.code,
prog: it.kind === 'school' ? '—' : '',
status: it.status, message: it.message,
});
}
return Array.from(by.values());
}
function startQueuePolling() {
if (queueTimer) clearInterval(queueTimer);
queueTimer = setInterval(async () => {
let q;
try { q = await api('tasks/queue'); } catch (e) { return; }
if (!q.started) return;
// 只渲染本页启动过的那轮队列(刷新页面后不再接管旧队列)。
if (lastQueueSeq && q.seq !== lastQueueSeq) return;
if (q.running) {
renderQueue(groupsFromQueue(q.items || []), q);
return;
}
// 结束:终态只渲染这一次,随即停表。此后残留的 items(running=false)不再
// 回写视图——曾把用户刚点开的「扫描待办」结果在下一个 tick 冲掉。
renderQueue(groupsFromQueue(q.items || []), q);
clearInterval(queueTimer); queueTimer = null;
toast('任务队列执行结束', 'ok');
}, 3000);
}
// reattachQueueView 切回任务中心视图时恢复队列进度:仅当本页启动的队列仍在
// 执行才重新开轮询(残留态/别页队列不接管——视图不被旧结果冲掉)。
function reattachQueueView() {
// 全程异步:go() 在顶层(app.js ~143 行)被调用时,本文件下方 let/const
//(queueTimer/lastQueueSeq 等)尚未初始化——同步读取即 TDZ ReferenceError
// 使整个脚本中断。await 之后才碰它们(旧 pollQueueOnce 正是靠开头的 await
// 侥幸安全)。queueTimer 的"已在跑"判定也挪到 await 后,语义不变。
(async () => {
try {
const q = await api('tasks/queue');
if (queueTimer) return; // 轮询已在跑(跨视图不中断)
if (q.started && q.running && (!lastQueueSeq || q.seq === lastQueueSeq)) startQueuePolling();
} catch (e) { /* 静默 */ }
})();
}
/* ── 用量 ─────────────────────────────────────────────────────────── */
/* 图表用原生 SVG 手绘:面板是 go:embed 单文件、无构建步骤,引入图表库
就得带上打包器,得不偿失。这里只需要堆叠柱状图,二十行足够。 */
function fmtTok(n) {
n = Number(n || 0);
if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
return String(n);
}
function fmtMs(ms) {
ms = Number(ms || 0);
if (!ms) return '—';
if (ms >= 1000) return (ms / 1000).toFixed(2) + 's';
return Math.round(ms) + 'ms';
}
function fmtRate(r) { return r ? Number(r).toFixed(1) + ' tok/s' : '—'; }
function trimFixed(s) {
if (!String(s).includes('.')) return String(s);
return String(s).replace(/0+$/, '').replace(/\.$/, '');
}
function fmtCredit(n) {
const v = Number(n || 0);
if (!Number.isFinite(v)) return '—';
return trimFixed(v.toFixed(2));
}
function fmtCreditRatio(v, samples, tokens) {
if (!samples || !tokens) return '—';
const n = Number(v || 0);
if (!Number.isFinite(n)) return '—';
return trimFixed(n.toFixed(4)) + ' / 1M';
}
function fmtModelRate(rate) {
const s = String(rate || '').trim();
return s ? 'x' + s : '—';
}
function usStat(v, k, cls) {
return '' + esc(v) +
'
' + esc(k) + '
';
}
/* usKpi 用量页的指标卡。比账号池的 .stat 多两样:语义色轨(cls)与副标题(sub,
放"占比 / 均速率"这类解释性数字);bar 是卡片内的构成条 HTML,只有需要时才传。 */
function usKpi(v, k, cls, sub, bar) {
return '' +
'
' + esc(k) + '
' +
'
' + esc(v) + '
' +
(bar || '') +
(sub ? '
' + esc(sub) + '
' : '') +
'
';
}
/* usMixBar prompt/completion 占比条。宽度按百分比而不是固定像素:表列宽随窗口变化,
像素宽度在窄屏会溢出、宽屏又显得没信息。 */
function usMixBar(prompt, completion, total) {
const t = Number(total || 0);
if (!t) return '';
const pp = Math.max(0, Math.min(100, Number(prompt || 0) / t * 100));
const pc = Math.max(0, Math.min(100, Number(completion || 0) / t * 100));
return '' +
'' +
'' +
'';
}
/* usPct 占比文案(0 值不显示 "0.0%",直接 —,避免一行全是零)。 */
function usPct(part, total) {
const t = Number(total || 0);
if (!t) return '—';
return (Number(part || 0) / t * 100).toFixed(1) + '%';
}
/* usRow 生成一行。mid 是插在「名称」之后、请求数之前的额外单元格(如「域」列)。
withPerf 控制延迟/速率两列;列开关显式传入,避免调用方改动后与表头错列。 */
function usRow(name, sub, a, mid, withPerf) {
return '' +
' | ' +
'' + esc(name) + (sub ? ' ' + esc(sub) + ' ' : '') + ' | ' +
(mid || '') +
'' + fmtTok(a.requests) + ' | ' +
'' + (a.errors ? '' + fmtTok(a.errors) + '' : '—') + ' | ' +
'' + fmtTok(a.prompt_tokens) + ' | ' +
'' + fmtTok(a.completion_tokens) + ' | ' +
'' + fmtTok(a.total_tokens) +
usMixBar(a.prompt_tokens, a.completion_tokens, a.total_tokens) + ' | ' +
(withPerf
? '' + fmtMs(a.avg_latency_ms) + ' | ' +
'' + fmtRate(a.avg_tokens_per_second) + ' | '
: '') +
'
';
}
/* ── 用量明细:三个维度共用一张表 + 页内切换 ──────────────────────────
账号 / 模型 / 域三张表此前各自占一个 box,页面纵向拉得很长且表头结构几乎一样。
现在合成一个 box:表头由维度定义生成,行渲染复用 usRow,切换零请求。 */
const US_DIMS = {
account: { key: 'by_account', title: '账号', withRealm: true, withPerf: true, span: 10 },
model: { key: 'by_model', title: '模型', withRealm: false, withPerf: false, span: 7 },
realm: { key: 'by_realm', title: 'realm', withRealm: false, withPerf: false, span: 7 },
};
// usSortRows 按当前排序字段降序(默认合计 Token,最大者最相关)。
function usSortRows(rows, sort) {
const out = rows.slice();
const num = v => { const n = Number(v || 0); return Number.isFinite(n) ? n : 0; };
const val = a => sort === 'requests' ? num(a.requests)
: sort === 'errors' ? num(a.errors)
: sort === 'latency' ? num(a.avg_latency_ms)
: num(a.total_tokens);
out.sort((a, b) => val(b) - val(a));
return out;
}
function usDimHead(dim) {
const m = US_DIMS[dim] || US_DIMS.account;
return ' | ' + esc(m.title) + ' | ' +
(m.withRealm ? '域 | ' : '') +
'请求 | 失败 | ' +
'Prompt | Completion | 合计 | ' +
(m.withPerf ? '均延迟 | 均速率 | ' : '') +
'
';
}
/* usTabsHtml 维度切换按钮(带条数徽标)。整段 innerHTML 重写而不是逐个改 class:
容器的 click 监听是委托式的,换掉子节点不会丢事件,代码也更短。 */
function usTabsHtml(dims, active, counts) {
return dims.map(([k, label]) => {
const n = counts ? counts[k] : null;
return '';
}).join('');
}
const US_DIM_TABS = [['account', '按账号'], ['model', '按模型'], ['realm', '按域']];
function renderUsageDim() {
const d = usageData || {};
const m = US_DIMS[usDim] || US_DIMS.account;
const rows = usSortRows(d[m.key] || [], usSort);
$('usDimTabs').innerHTML = usTabsHtml(US_DIM_TABS, usDim, {
account: (d.by_account || []).length,
model: (d.by_model || []).length,
realm: (d.by_realm || []).length,
});
$('usDimHead').innerHTML = usDimHead(usDim);
$('usDimBody').innerHTML = rows.map(x =>
usRow(usDim === 'account' ? String(x.key || '').slice(0, 8) : x.key,
usDim === 'account' ? (x.extra || '') : '',
x,
usDim === 'account' ? '' + esc(x.realm || '') + ' | ' : '',
m.withPerf)
).join('') || '| 暂无数据 |
';
$('usDimNote').textContent = rows.length + ' 行 · 请求数含失败尝试';
}
/* 积分扣除:按账号 / 按模型两个维度共用一张表(同上,两个 box 合成一个)。 */
function usCreditHead(dim) {
return dim === 'model'
? ' | 模型 | 积分倍率 | ' +
'请求 | 扣除积分 | ' +
'有效样本 Token | 积分 / 1M Token | 缓存命中率 |
'
: ' | 账号 | ' +
'请求 | 扣除积分 | ' +
'有效样本 Token | 积分 / 1M Token | 缓存命中率 |
';
}
/* 缓存命中率(issue #92):颜色即健康度——≥90% 绿 / 80–90% 黄 / <80% 红,
样本不足灰。title 带命中/未命中绝对量,供逐项核对。 */
function cacheRateCell(hit, miss) {
const h = Number(hit || 0), m = Number(miss || 0), total = h + m;
if (!total) return '—';
const pct = h / total * 100;
const color = pct >= 90 ? 'var(--ok)' : (pct >= 80 ? 'var(--warn)' : 'var(--bad)');
const txt = trimFixed(pct.toFixed(1)) + '%';
return '' + txt + '';
}
function renderCreditDim() {
const d = usageData || {};
$('usCreditHead').innerHTML = usCreditHead(usCreditDim);
const empty = '暂无积分扣除记录;升级前仅含 Token 的历史不会伪造积分。';
if (usCreditDim === 'model') {
const models = d.credit_by_model || [];
$('usCreditBody').innerHTML = models.map(row =>
'' +
' | ' +
'' + esc(row.key || '—') + ' | ' +
'' + esc(fmtModelRate(row.rate)) + ' | ' +
'' + fmtTok(row.requests) + ' | ' +
'' + fmtCredit(row.credits) + ' | ' +
'' + fmtTok(row.credit_tokens) + ' | ' +
'' + fmtCreditRatio(row.credits_per_1m_tokens, row.credit_samples, row.credit_tokens) + ' | ' +
'' + cacheRateCell(row.cache_hit_tokens, row.cache_miss_tokens) + ' | ' +
'
'
).join('') || '| ' + empty + ' |
';
} else {
const accounts = d.credit_by_account || [];
$('usCreditBody').innerHTML = accounts.map(row => {
const uid = String(row.key || '');
const account = row.nickname || uid.slice(0, 8) || '—';
return '' +
' | ' +
'' + esc(account) + ' ' + esc(row.realm || '') + ' · ' + esc(uid.slice(0, 8)) + ' | ' +
'' + fmtTok(row.requests) + ' | ' +
'' + fmtCredit(row.credits) + ' | ' +
'' + fmtTok(row.credit_tokens) + ' | ' +
'' + fmtCreditRatio(row.credits_per_1m_tokens, row.credit_samples, row.credit_tokens) + ' | ' +
'' + cacheRateCell(row.cache_hit_tokens, row.cache_miss_tokens) + ' | ' +
'
';
}).join('') || '| ' + empty + ' |
';
}
$('usCreditTabs').innerHTML = usTabsHtml([['account', '按账号'], ['model', '按模型']], usCreditDim, {
account: (d.credit_by_account || []).length,
model: (d.credit_by_model || []).length,
});
}
function renderUsage(d) {
usageData = d || {};
const t = usageData.totals || {};
const total = Number(t.total_tokens || 0);
const pt = Number(t.prompt_tokens || 0);
const ct = Number(t.completion_tokens || 0);
const reqs = Number(t.requests || 0);
const errs = Number(t.errors || 0);
const okRate = reqs ? (reqs - errs) / reqs * 100 : null;
// 构成条要的是合法 CSS 宽度,usPct 在无样本时返回 "—",不能直接拼进 style。
const pctW = (part) => total ? Math.max(0, Math.min(100, Number(part || 0) / total * 100)).toFixed(2) + '%' : '0%';
// 六张卡:主指标用强调色,completion 用成功色(与图表里的绿柱呼应),
// 失败/延迟只在有值时上语义色——全绿全黄的仪表盘等于没有重点。
$('usStats').innerHTML =
usKpi(fmtTok(reqs), '请求数', 'c-accent',
errs ? '其中失败 ' + errs + ' 次' : '全部成功') +
usKpi(fmtTok(total), '总 token', 'c-accent',
'prompt ' + usPct(pt, total) + ' · completion ' + usPct(ct, total),
'' +
'
') +
usKpi(fmtTok(pt), 'prompt', 'c-soft', '占比 ' + usPct(pt, total)) +
usKpi(fmtTok(ct), 'completion', 'c-ok', '占比 ' + usPct(ct, total)) +
usKpi(String(errs), '失败尝试', errs ? 'c-warn' : 'c-mute',
okRate == null ? '—' : (errs ? '成功率 ' + okRate.toFixed(1) + '%' : '成功率 100%')) +
usKpi(fmtMs(t.avg_latency_ms), '平均延迟', 'c-soft',
t.avg_tokens_per_second ? '吐字 ' + fmtRate(t.avg_tokens_per_second) : '无速率样本');
// 卡片、明细表与时序图全部按所选窗口统计(切窗口数字随之变化);
// 「全部历史」含 90 天前折叠出的日桶。这里标注当前口径与数据起点。
const winLabel = trangeLabel('usRange');
// 服务端回显的实际区间优先(自定义区间下它就是权威口径);滚动窗口没有回显,
// 用控件自己的标签。
const rangeEcho = usageData.window_from
? String(usageData.window_from).replace('T', ' ').slice(0, 16) +
(usageData.window_to ? ' → ' + String(usageData.window_to).replace('T', ' ').slice(0, 16) : ' → 现在')
: '';
const note = (rangeEcho || winLabel ? (rangeEcho || winLabel) + ' · ' : '') +
(usageData.buckets || 0) + ' 个分桶' +
(usageData.since ? ' · 数据自 ' + usageData.since.replace('T', ' ') : '') +
(usageData.file_bytes ? ' · 文件 ' + (usageData.file_bytes / 1024).toFixed(1) + ' KB' : '');
$('usNote').textContent = note;
$('usNote').title = note; // 窄屏单行截断时靠悬停看全
// 积分扣除的四张卡片与说明。
$('usCreditStats').innerHTML =
usKpi(fmtCredit(t.credits), '扣除积分', 'c-accent', '按上游 usage.credit 累计') +
usKpi(fmtTok(t.credit_tokens), '匹配 Token', 'c-mute', '与积分同时观测到的 Token') +
usKpi(fmtCreditRatio(t.credits_per_1m_tokens, t.credit_samples, t.credit_tokens),
'平均积分 / 1M Token', 'c-ok', '越低越划算') +
usKpi(String(t.credit_samples || 0), '有效积分样本', 'c-mute', '缺字段的历史不参与折算') +
usKpi(cacheRateText(t.cache_hit_tokens, t.cache_miss_tokens), '缓存命中率', 'c-mute',
'上游前缀缓存命中 / (命中+未命中);低命中意味着费用数倍放大');
$('usCreditNote').textContent =
(usageData.credit_by_account || []).length + ' 个账号 · ' +
(usageData.credit_by_model || []).length + ' 个模型倍率分组 · 仅统计与积分同时观测到的 Token';
renderUsageDim();
renderCreditDim();
renderUsageChart(usageData.series || []);
}
// 维度切换 / 排序控件。
if ($('usDimTabs')) $('usDimTabs').addEventListener('click', ev => {
const b = ev.target.closest('button[data-dim]');
if (!b) return;
usDim = b.dataset.dim;
renderUsageDim();
});
if ($('usCreditTabs')) $('usCreditTabs').addEventListener('click', ev => {
const b = ev.target.closest('button[data-dim]');
if (!b) return;
usCreditDim = b.dataset.dim;
renderCreditDim();
});
if ($('usSort')) $('usSort').onchange = () => {
usSort = $('usSort').value;
renderUsageDim();
};
/* renderUsageChart 画堆叠柱状图。
*
* x 轴是**真实时间轴**,不是按序号等距。这一点很重要:数据里存在 1 小时的
* 间隔,也存在 6~8 小时的断档(没请求的时段不产生桶),等距排布会把 8 小时
* 画得和 1 小时一样宽,让「什么时候用的」完全失真。
*
* 另外不再用 preserveAspectRatio="none":那会把 viewBox 横向拉伸到容器宽度,
* 柱子和文字都变形。改为固定比例、按容器宽度自适应高度。
*
* viewBox 取 1200×200(原 760×180):SVG 以 width:100% 渲染,高宽比决定实际
* 高度——旧比例在 1500px 宽的主区里会撑到 ~355px,只有一两根柱子时整块几乎是
* 空白。宽 viewBox 把同宽度下的高度压到 ~250px,与下方表格的视觉重量相当。
*
* 时间轴用本地时间解析(后端返回的就是本地时区),day 点按当天 00:00 参与定位,
* 与 hour 点在同一个连续轴上——日桶本来就是他那天所有小时的聚合。
*/
/* parsePointTime 把后端的 t 解析成毫秒时间戳。 */
function parsePointTime(p) {
// hour: "2026-09-16T13" day: "2026-09-16"
const s = p.t.length === 13 ? p.t + ':00:00' : p.t + 'T00:00:00';
const d = new Date(s);
return isNaN(d.getTime()) ? null : d.getTime();
}
/* fmtTokTimeLabel 时间桶的短标签,与 x 轴刻度同一口径(日桶 MM-DD,小时桶 HH:00)。 */
function fmtTokTimeLabel(p) {
const d = new Date(p.t);
return p.scope === 'day'
? (d.getMonth() + 1) + '-' + String(d.getDate()).padStart(2, '0')
: String(d.getHours()).padStart(2, '0') + ':00';
}
function renderUsageChart(series) {
const host = $('usChart');
// 丢掉时间解析不出来的点,而不是让 NaN 传染整张图。
const pts = [];
for (const p of series) {
const t = parsePointTime(p);
if (t === null) continue;
const pt = Number(p.prompt_tokens || 0);
const ct = Number(p.completion_tokens || 0);
pts.push({ t, scope: p.scope, raw: p.t, pt, ct, tt: Number(p.total_tokens || 0) || (pt + ct),
req: p.requests || 0 });
}
if (!pts.length) {
host.innerHTML = '暂无用量数据。发起一次对话后再刷新。
';
$('usChartNote').textContent = '—';
return;
}
const W = 1200, H = 200, PL = 58, PR = 14, PT = 18, PB = 30;
const iw = W - PL - PR, ih = H - PT - PB;
const t0 = pts[0].t;
const t1 = pts[pts.length - 1].t;
const span = Math.max(1, t1 - t0);
const max = Math.max(1, ...pts.map(p => p.tt));
const peak = pts.reduce((a, b) => (b.tt > a.tt ? b : a), pts[0]);
const avg = pts.reduce((s, p) => s + p.tt, 0) / pts.length;
$('usChartNote').textContent =
pts.length + ' 个点 · 峰值 ' + fmtTok(peak.tt) + ' @ ' + fmtTokTimeLabel(peak) +
' · 均值 ' + fmtTok(avg);
// 柱宽取「最小真实间隔」的 70%,并夹在合理区间内——窗口拉到 30 天时柱子会
// 变细,但不会细到看不见。
let minGap = Infinity;
for (let i = 1; i < pts.length; i++) minGap = Math.min(minGap, pts[i].t - pts[i - 1].t);
if (!isFinite(minGap) || minGap <= 0) minGap = span;
const slot = iw * (minGap / span);
const bw = Math.max(2, Math.min(30, slot * 0.7));
// 首尾各让出半个柱宽:否则第一个点和最后一个点的柱子会各有一半跑到绘图区外
// (末点柱子贴着卡片右边缘被切掉),刻度仍用同一个 xOf,标签与柱子始终对齐。
const xOf = t => PL + bw / 2 + (t - t0) / span * Math.max(1, iw - bw);
const yOf = v => PT + ih - ih * (v / max);
let out = '';
host.innerHTML = out;
}
function fmtTokTip(v) { return fmtTok(v); }
let usageRateWarmAt = 0;
async function warmUsageModelRates() {
if (Date.now() - usageRateWarmAt < 10 * 60 * 1000) return;
try {
await api('models');
} catch (e) {
// 倍率回填是可选增强;失败不阻塞用量统计,10 分钟后再试。
}
usageRateWarmAt = Date.now();
}
async function loadUsage() {
const q = trangeQuery('usRange', true);
try {
await warmUsageModelRates();
const d = await api('usage?' + q.toString());
renderUsage(d);
} catch (e) {
// 失败时三块都要清干净:只改图表会留下上一次窗口的数字,看起来像"刷新成功"。
usageData = null;
$('usChart').innerHTML = '读取用量失败:' + esc(e.message) + '
';
$('usChartNote').textContent = '—';
$('usStats').innerHTML = '';
$('usCreditStats').innerHTML = '';
$('usNote').textContent = '—';
$('usCreditNote').textContent = '—';
$('usDimNote').textContent = '—';
$('usDimHead').innerHTML = '';
$('usCreditHead').innerHTML = '';
$('usDimTabs').innerHTML = usTabsHtml(US_DIM_TABS, usDim, null);
$('usCreditTabs').innerHTML = usTabsHtml([['account', '按账号'], ['model', '按模型']], usCreditDim, null);
$('usDimBody').innerHTML = '| 读取用量失败 |
';
$('usCreditBody').innerHTML = '| 读取用量失败 |
';
}
}
if ($('btnUsage')) $('btnUsage').onclick = loadUsage;
// 时间范围控件绑定:任何改动(预设切换 / 自定义起止)都重新拉一次用量。
if ($('usRange')) trangeBind('usRange', loadUsage);
/* ── 积分构成 ─────────────────────────────────────────────────────── */
/* 一个账号的余额是若干积分包之和。包按来源命名(「国内运营裂变包」「拉新权益包」
「个人体验版」…),面额从 6 到 1500 不等,且**按次发放**。所以两个任务完成度
完全一致的账号,余额可能差上千——差别只在包里。这里把逐包明细摊开,并给每个
包名一个稳定配色,跨账号对比时同色即同类。 */
const PK_COLORS = ['#4f8cff', '#25b08b', '#e8a33d', '#c96bd6', '#e2607a',
'#5aa9e6', '#8fbf3f', '#b58b5a', '#7d8fa8', '#d4785c'];
const PK_ACCOUNT_COLORS = ['#4f8cff', '#25b08b', '#e8a33d', '#c96bd6',
'#e2607a', '#20a4a4', '#8fbf3f', '#d4785c',
'#7c83db', '#c48a2f', '#b45f8c', '#5aa9e6'];
function pkColor(i) { return PK_COLORS[i % PK_COLORS.length]; }
// pkAccountColorMap 按 UID 稳定分配颜色:排序后分配,账号刷新/重排不会换色。
function pkAccountColorMap(list) {
const uids = (list || [])
.filter(a => a && !a.error && a.uid)
.map(a => String(a.uid))
.sort();
const colors = new Map();
uids.forEach((uid, i) => colors.set(uid, PK_ACCOUNT_COLORS[i % PK_ACCOUNT_COLORS.length]));
return colors;
}
/* pkBySource 把包按名称归并,得到「来源 → 面额/余额/个数」。这是对比的关键视图:
两个号的差异一定体现在某几个来源的面额上。 */
function pkBySource(packs) {
const m = new Map();
for (const p of packs) {
// 分组键用 code + name,而不是只 name:上游给「首登赠送」和普通活动包用了
// **同一个 PackageName 和同一个 PackageCode**,只按 name 会把两类混成一类,
// 那正是当初「两个号为何差 1500」看不出来的原因。这里至少把 code 带进键里,
// 并在卡片上显示最早的发放时间。
const k = (p.package_code || '') + '|' + (p.name || '(未命名)');
const e = m.get(k) || {
key: k, name: p.name || '(未命名)', code: p.package_code || '',
n: 0, remain: 0, size: 0, used: 0, minEnd: '', minCreated: '',
};
e.n += 1;
e.remain += Number(p.remain || 0);
e.size += Number(p.size || 0);
e.used += Number(p.used || 0);
const t = (p.end_time || '').slice(0, 10);
if (t && (!e.minEnd || t < e.minEnd)) e.minEnd = t;
const c = (p.created_at || '').slice(0, 10);
if (c && (!e.minCreated || c < e.minCreated)) e.minCreated = c;
m.set(k, e);
}
return [...m.values()].sort((a, b) => b.size - a.size);
}
const PK_DEFAULT_DETAIL_LIMIT = 5;
function pkDetailLimitValue(raw) {
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : PK_DEFAULT_DETAIL_LIMIT;
}
function pkDetailLimit(cfg) {
return pkDetailLimitValue(cfg && cfg.panel && cfg.panel.package_detail_limit);
}
const PK_DAY_MS = 24 * 3600 * 1000;
function pkExpiryMs(p) {
const raw = Number(p && p.expires_at);
if (Number.isFinite(raw) && raw > 0) return raw;
const text = String((p && p.end_time) || '').trim();
if (!text) return null;
let iso = text.includes('T') ? text : text.replace(' ', 'T');
if (!/(?:Z|[+-]\d\d:\d\d)$/.test(iso)) iso += '+08:00';
const parsed = Date.parse(iso);
return Number.isFinite(parsed) ? parsed : null;
}
// pkDetailCompare 只服务单账号逐包明细:正余额包先按到期时间挑选默认展示项,
// 其余正余额包与已用完包分别折叠;同一到期时间按面额降序。主键跟随视图排序
// 模式(pkSortMode,声明在本区块末尾的绑定块):end_asc 到期升序、size_desc
// 面额降序(同面额按到期升序)。typeof 守卫:前端 harness 的区域切片求值里
// 没有该全局,回落 end_asc(= 上游原有语义,切片测试的期望序不受影响)。
function pkDetailCompare(a, b) {
const sizeOf = p => {
const n = Number(p && p.size);
return Number.isFinite(n) ? n : 0;
};
const mode = (typeof pkSortMode === 'string' && pkSortMode) || 'end_asc';
if (mode === 'size_desc') {
const d = sizeOf(b) - sizeOf(a);
if (d !== 0) return d;
}
const ea = pkExpiryMs(a), eb = pkExpiryMs(b);
if (ea == null && eb != null) return 1;
if (ea != null && eb == null) return -1;
if (ea != null && eb != null && ea !== eb) return ea - eb;
return sizeOf(b) - sizeOf(a);
}
function pkDetailGroups(packs, limit) {
const active = [], used = [];
let usedSize = 0, restSize = 0, restRemain = 0;
for (const p of packs || []) {
const remain = Number(p && p.remain);
if (remain > 0) {
active.push(p);
continue;
}
used.push(p);
const size = Number(p && p.size);
if (Number.isFinite(size)) usedSize += size;
}
active.sort(pkDetailCompare);
used.sort(pkDetailCompare);
const visible = active.slice(0, pkDetailLimitValue(limit));
const rest = active.slice(visible.length);
for (const p of rest) {
const size = Number(p && p.size);
if (Number.isFinite(size)) restSize += size;
const remain = Number(p && p.remain);
if (Number.isFinite(remain)) restRemain += remain;
}
return { visible, rest, used, restSize, restRemain, usedSize };
}
function pkCreditOpacity(days) {
if (days == null || !Number.isFinite(Number(days))) return 1;
return 0.25 + 0.75 * Math.max(0, Math.min(29, Number(days) - 1)) / 29;
}
function pkExpiryText(expiresAt) {
if (!expiresAt) return '无到期时间';
const diff = expiresAt - Date.now();
if (diff <= 0) return '已到期';
const minutes = Math.max(1, Math.ceil(diff / 60000));
if (minutes < 60) return '剩余 ' + minutes + ' 分钟';
const hours = Math.ceil(diff / 3600000);
if (hours < 24) return '剩余 ' + hours + ' 小时';
return '剩余 ' + Math.ceil(diff / PK_DAY_MS) + ' 天';
}
function pkExpiryDateTime(expiresAt) {
if (!expiresAt) return '—';
return new Date(expiresAt).toLocaleString('zh-CN', {
timeZone: 'Asia/Shanghai', hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
});
}
function pkAccountSegments(a, now) {
let balance = Math.max(0, Number(a.remain || 0));
const out = [];
for (const p of a.packages || []) {
const remain = Number(p.remain || 0);
if (!Number.isFinite(remain) || remain <= 0 || balance <= 0) continue;
const amount = Math.min(balance, remain);
const expiresAt = pkExpiryMs(p);
out.push({
amount,
expiresAt,
days: expiresAt == null ? null : Math.max(0, Math.ceil((expiresAt - now) / PK_DAY_MS)),
source: p.name || '积分',
uid: String(a.uid || ''),
accountName: a.nickname || String(a.uid || '').slice(0, 8) || '未命名账号',
});
balance -= amount;
}
return out.sort((x, y) => {
if (x.expiresAt == null && y.expiresAt != null) return 1;
if (x.expiresAt != null && y.expiresAt == null) return -1;
return (x.expiresAt || 0) - (y.expiresAt || 0);
});
}
// summarizeCreditDays 对齐 WorkDaddy:按精确剩余天数逐行聚合,无有效到期时间的余额
// 不进入图表,也不猜测到期日。账号内先按总余额约束逐包金额,避免上游重复记录膨胀。
function summarizeCreditDays(list, now) {
const buckets = new Map();
let unavailable = 0;
for (const a of list || []) {
if (a.error || !Number.isFinite(Number(a.remain))) {
unavailable++;
continue;
}
for (const segment of pkAccountSegments(a, now)) {
if (segment.days == null) continue;
let row = buckets.get(segment.days);
if (!row) {
row = { days: segment.days, credits: 0, segments: [] };
buckets.set(segment.days, row);
}
row.credits += segment.amount;
row.segments.push(segment);
}
}
const rows = [...buckets.values()].sort((a, b) => a.days - b.days);
for (const row of rows) {
row.segments.sort((a, b) =>
(a.expiresAt || Infinity) - (b.expiresAt || Infinity) ||
a.accountName.localeCompare(b.accountName) ||
a.source.localeCompare(b.source));
}
return { rows, accountCount: (list || []).length, unavailable };
}
function renderExpiryDistribution(list, now) {
const summary = summarizeCreditDays(list, now);
const colors = pkAccountColorMap(list);
const rows = summary.rows.map(row => {
const total = row.credits || 1;
const nodes = row.segments.map(segment => {
const color = colors.get(segment.uid) || 'var(--accent)';
const title = segment.source + '\n' + fmtTok(segment.amount) + ' 积分\n到期时间 ' +
pkExpiryDateTime(segment.expiresAt) + '(' + pkExpiryText(segment.expiresAt) + ')\n' +
segment.accountName;
return '';
}).join('');
return '' + esc(row.days === 0 ? '已到期' : row.days + ' 天') +
'' + nodes + '
' + esc(fmtTok(row.credits)) +
' ';
}).join('');
const foot = summary.accountCount + ' 个账号' +
(summary.unavailable ? ' · ' + summary.unavailable + ' 个未获取余额' : '');
const legend = (list || []).filter(a =>
a && !a.error && a.uid && pkAccountSegments(a, now).some(s => s.days != null)
).map(a => '' + esc(a.nickname || String(a.uid).slice(0, 8)) + '').join('');
const hdr = '剩余天数各账号该批剩余剩余积分
';
$('pkExpiry').innerHTML = (rows
? hdr + '' + rows + '
'
: '暂无可汇总积分
') +
(legend ? '' + legend + '
' : '') +
'';
}
function renderPackages(d, detailLimit) {
const list = (d.accounts || []);
const now = Date.now();
const expiryColors = pkAccountColorMap(list);
renderExpiryDistribution(list, now);
if (!list.length) {
$('pkSummary').innerHTML = '没有账号
';
return;
}
// 包名 → 稳定色号(跨账号一致,方便肉眼对齐)
const names = [];
for (const a of list) for (const s of pkBySource(a.packages || [])) {
if (!names.includes(s.key)) names.push(s.key);
}
names.sort((x, y) => {
const sz = n => Math.max(...list.map(a => {
const f = pkBySource(a.packages || []).find(s => s.key === n);
return f ? f.size : 0;
}));
return sz(y) - sz(x);
});
const colorOf = n => pkColor(names.indexOf(n));
// 键 → 展示名,供卡片与明细表共用(同一来源必然同色同名)。
const labelOf = {};
for (const a of list) for (const s of pkBySource(a.packages || [])) labelOf[s.key] = s;
const maxRemain = Math.max(1, ...list.map(a => Number(a.remain || 0)));
$('pkSummary').innerHTML = list.map(a => {
if (a.error) {
return '' +
esc((a.nickname || a.uid.slice(0, 8))) + '' +
'' + esc(a.realm || '') + '
' +
'
查询失败:' + esc(a.error) + '
';
}
const srcs = pkBySource(a.packages || []);
const total = Math.max(1, Number(a.size || 0));
const bar = srcs.map(s =>
''
).join('');
const legend = srcs.map(s =>
'' +
esc(s.name.replace(/^CodeBuddy/, '')) + ' x' + s.n + ' · ' + fmtTok(s.size) +
(s.minCreated ? ' · 首发 ' + esc(s.minCreated.slice(5)) : '') + ''
).join('');
const expiry = pkAccountSegments(a, now);
const expiryTotal = Math.max(1, expiry.reduce((sum, s) => sum + s.amount, 0));
const expiryColor = expiryColors.get(String(a.uid)) || 'var(--accent)';
const expiryBar = expiry.length ? '' +
expiry.map(s => {
const title = s.source + '\n' + fmtTok(s.amount) + ' 积分\n到期时间 ' +
pkExpiryDateTime(s.expiresAt) + '(' + pkExpiryText(s.expiresAt) + ')';
return '';
}).join('') + '
' : '';
return '' +
'
' + esc(a.nickname || a.uid.slice(0, 8)) + '' +
'' + esc(a.realm || '') + '
' +
'
' + fmtTok(a.remain) + '
' +
'
共 ' + fmtTok(a.size) + ' · ' + (a.packages || []).length +
' 个包 · 占最高 ' + (Number(a.remain || 0) / maxRemain * 100).toFixed(0) + '%
' +
'
' + bar + '
' +
expiryBar +
'
' + legend + '
' +
'
';
}).join('');
$('pkNote').textContent = list.length + ' 个账号 · 实时查询上游';
// 逐包明细:每个账号一个表,排序规则由视图顶部的选择器决定(默认到期近的在前)
$('pkDetail').innerHTML = list.map(a => {
if (a.error) return '';
const groups = pkDetailGroups(a.packages || [], detailLimit);
const rowOf = (p, rowGroup) => {
const k = (p.package_code || '') + '|' + (p.name || '(未命名)');
const sub = (p.sub_product_code || '').replace(/^sp_tcaca_codebuddyide_?/, '') ||
(p.package_code || '').replace(/^TCACA_/, '');
return ' | ' +
'' + esc(p.name || '(未命名)') +
(sub ? ' ' + esc(sub) + ' ' : '') + ' | ' +
'' + fmtTok(p.size) + ' | ' +
'' + fmtTok(p.remain) + ' | ' +
'' + fmtTok(p.used) + ' | ' +
'' + esc((p.created_at || '').slice(0, 16).replace('T', ' ') || '—') + ' | ' +
'' + esc((p.end_time || '').slice(0, 10) || '—') + ' | ' +
'
';
};
const groupSummary = (group, label, count, size, remain) =>
' |
';
const rows = groups.visible.map(p => rowOf(p, '')).join('');
const restSummary = groups.rest.length
? groupSummary('rest', '其余未用完 ' + groups.rest.length + ' 个包(面额合计 ' +
fmtTok(groups.restSize) + ' · 剩余 ' + fmtTok(groups.restRemain) + ')',
groups.rest.length, groups.restSize, groups.restRemain) +
groups.rest.map(p => rowOf(p, 'rest')).join('')
: '';
const usedSummary = groups.used.length
? groupSummary('used', '已用完 ' + groups.used.length + ' 个包(面额合计 ' +
fmtTok(groups.usedSize) + ')', groups.used.length, groups.usedSize, 0) +
groups.used.map(p => rowOf(p, 'used')).join('')
: '';
return '' +
esc(a.nickname || a.uid.slice(0, 8)) + ' · ' + esc(a.realm || '') +
'
余额 ' + fmtTok(a.remain) +
' / 总额 ' + fmtTok(a.size) + ' · 可用 ' + (groups.visible.length + groups.rest.length) + ' 个包' +
(groups.used.length ? ' / 已用完 ' + groups.used.length + ' 个' : '') +
' · 默认展示最早到期 ' + pkDetailLimitValue(detailLimit) + ' 条(' +
esc(PK_SORT_LABELS[pkSortMode] || '') + ')' +
'' +
' | 包名 / 来源 | ' +
'面额 | 剩余 | 已用 | ' +
'发放 | 到期 | ' +
'
' + rows + restSummary + usedSummary + '
';
}).join('');
}
if ($('pkDetail')) $('pkDetail').addEventListener('click', ev => {
const btn = ev.target.closest('button[data-pk-group]');
if (!btn) return;
const body = btn.closest('tbody');
if (!body) return;
const group = btn.dataset.pkGroup;
const expanded = btn.getAttribute('aria-expanded') === 'true';
body.querySelectorAll('tr[data-pk-row="' + group + '"]').forEach(row => { row.hidden = expanded; });
const count = btn.dataset.count || '0';
const size = btn.dataset.size || '0';
const remain = btn.dataset.remain || '0';
btn.setAttribute('aria-expanded', String(!expanded));
if (group === 'rest') {
btn.textContent = expanded
? '其余未用完 ' + count + ' 个包(面额合计 ' + fmtTok(size) + ' · 剩余 ' +
fmtTok(remain) + '),展开'
: '收起其余未用完 ' + count + ' 个包';
} else {
btn.textContent = expanded
? '已用完 ' + count + ' 个包(面额合计 ' + fmtTok(size) + '),展开'
: '收起已用完 ' + count + ' 个包';
}
});
/* ── 逐包明细排序模式 ─────────────────────────────────────────────── */
/* 逐包明细的排序规则(选择持久化在 localStorage,跨会话记住):
end_asc 到期升序(默认)——快过期的包排最前,提醒优先消耗;无到期时间的
包(上游没下发 end_time)没有可比的日期,统一垫底,不掺进日期序里;
size_desc 面额降序——原来的展示口径,看「钱从哪来」。
行序统一由 pkDetailCompare 实现(明细折叠分组共用同一比较器,切换模式时
折叠组内行序同步跟随)。本块整体放在 renderPackages / renderExpiryDistribution
之后:前端 harness 按区域切片求值([PK_ACCOUNT_COLORS, renderExpiryDistribution)
与 [PK_DEFAULT_DETAIL_LIMIT, renderPackages)),顶层 localStorage/$ 语句落进
切片区会让无 DOM 桩的求值环境 ReferenceError——上游测试的切片边界不动。 */
const LS_PK_SORT = 'pkSortMode';
let pkSortMode = localStorage.getItem(LS_PK_SORT) || 'end_asc';
const PK_SORT_LABELS = { end_asc: '按到期升序 · 近的在前', size_desc: '按面额降序' };
// 排序切换时重排明细需要 detailLimit(上游折叠配置),loadPackages 拉到后缓存。
let lastDetailLimit = PK_DEFAULT_DETAIL_LIMIT;
// 排序规则控件:恢复上次选择并绑定切换。
if ($('pkSort')) {
$('pkSort').value = pkSortMode;
if ($('pkSort').value !== pkSortMode) { // localStorage 里存了废弃值:回落默认
pkSortMode = 'end_asc';
localStorage.setItem(LS_PK_SORT, pkSortMode); // 不用 removeItem:harness 桩无此方法
}
$('pkSort').onchange = () => {
pkSortMode = $('pkSort').value;
localStorage.setItem(LS_PK_SORT, pkSortMode);
if (lastPackages) renderPackages(lastPackages, lastDetailLimit); // 数据在内存,直接重排
};
}
async function loadPackages() {
$('pkSummary').innerHTML = '查询中…(逐账号向上游实时查询)
';
$('pkDetail').innerHTML = '';
$('pkExpiry').innerHTML = '查询中…
';
try {
const [d, c] = await Promise.all([
api('packages'),
api('config').catch(() => null),
]);
lastPackages = d; // 缓存供首页到期卡片与排序切换复用
lastPackagesAt = Date.now();
lastDetailLimit = pkDetailLimit(c && c.config);
renderPackages(d, lastDetailLimit);
} catch (e) {
$('pkSummary').innerHTML = '读取失败:' + esc(e.message) + '
';
$('pkExpiry').innerHTML = '读取失败:' + esc(e.message) + '
';
}
}
/* ── 积分到期提醒(首页卡片)────────────────────────────────────────── */
/* 积分不是永久的:签到/任务发的裂变包约一个月失效。只看「剩余积分 ÷ 日消耗」
会系统性偏乐观——用不完的部分到期直接蒸发。这里把「最近要过期的是哪批、
有多少、到期前每天要至少消耗多少」顶到首页,数据源与「积分构成」共用
(lastPackages 缓存,EXP_FRESH_MS 内复用,不重复打上游)。 */
// expBatches 把某账号的包聚合成「到期日 → 该日作废积分」升序列表。
// 只统计 remain>0 且有到期时间的包——没余额/长期包到期没有任何影响。
function expBatches(packs) {
const byDay = new Map();
for (const p of packs || []) {
const r = Number(p.remain || 0);
const t = (p.end_time || '').slice(0, 10);
if (r <= 0 || !t) continue;
byDay.set(t, (byDay.get(t) || 0) + r);
}
return [...byDay.entries()]
.map(([date, remain]) => ({ date, remain }))
.sort((a, b) => (a.date < b.date ? -1 : 1));
}
function expDaysLeft(dateStr, today) {
return Math.round((new Date(dateStr + 'T00:00:00') - today) / 86400000);
}
function renderExpiry(d) {
const list = (d.accounts || []);
const today = new Date(); today.setHours(0, 0, 0, 0);
const rows = list.map(a => {
if (a.error) {
return '' +
'' + esc(a.nickname || a.uid.slice(0, 8)) + '' +
'查询失败:' + esc(a.error) + '
';
}
const bs = expBatches(a.packages).filter(b => expDaysLeft(b.date, today) >= 0);
if (!bs.length) {
return '' +
'' + esc(a.nickname || a.uid.slice(0, 8)) + '' +
'7 天内无到期积分
';
}
const first = bs[0];
const days = expDaysLeft(first.date, today);
const daily = Math.ceil(first.remain / Math.max(1, days));
const week = bs.filter(b => expDaysLeft(b.date, today) <= 7)
.reduce((s, b) => s + b.remain, 0);
// 危险度:≤3 天红(不抓紧就真没了)、≤7 天琥珀、更远绿。
// 上游扣包是 FEFO(按失效时刻升序,实测两号口径一致):这些快过期批次正是
// 被消耗得最快的,日均需耗给的是「哪怕单靠这个账号的自然流量也能对齐」的参照。
const cls = days <= 3 ? 'var(--bad)' : days <= 7 ? 'var(--warn)' : 'var(--ok)';
const dayWord = days === 0 ? '今天到期' : days === 1 ? '明天到期' : days + ' 天后到期';
const more = bs.length > 4 ? ' 等 ' + bs.length + ' 批' : '';
const rest = bs.slice(1, 4).map(b =>
'随后 ' + esc(b.date.slice(5)) + ' · ' + fmtTok(b.remain)).join(' ') + more;
return '' +
'
' + esc(a.nickname || a.uid.slice(0, 8)) + '' +
'
最近到期 ' + esc(first.date) + '(' + dayWord +
')· 该批 ' + fmtTok(first.remain) + ' 积分 · 到期前日均需耗 ≥' +
fmtTok(daily) + '' +
(week > first.remain ? ' · 7 天内合计 ' + fmtTok(week) : '') +
(rest ? '' + rest + '
' : '') +
' ';
}).join('');
$('expList').innerHTML = rows || '没有账号
';
// 数据新鲜度透明化:走缓存时标注年龄,免得把旧数据误当实时。
const ageMin = lastPackages ? Math.floor((Date.now() - lastPackagesAt) / 60000) : 0;
$('expNote').textContent = (lastPackagesAt && ageMin > 0)
? list.length + ' 个账号 · ' + ageMin + ' 分钟前的数据,可点「检查」刷新'
: list.length + ' 个账号 · 实时查询上游';
$('expBox').hidden = false;
}
async function loadExpiry(force) {
if (!$('expBox')) return;
if (expFetching) return;
const fresh = lastPackages && (Date.now() - lastPackagesAt) < EXP_FRESH_MS;
if (fresh && !force) { renderExpiry(lastPackages); return; }
expFetching = true;
$('expBox').hidden = false;
if (!$('expList').children.length) $('expList').innerHTML = '查询中…(逐账号向上游实时查询)
';
$('expNote').textContent = '查询中…';
try {
const d = await api('packages');
lastPackages = d; // 与「积分构成」视图共用同一份缓存
lastPackagesAt = Date.now();
renderExpiry(d);
} catch (e) {
$('expNote').textContent = '查询失败:' + esc(e.message);
}
expFetching = false;
}
if ($('btnExp')) $('btnExp').onclick = () => loadExpiry(true);
if ($('btnPk')) $('btnPk').onclick = loadPackages;