'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 = ''; for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) if (M[r][c]) s += ''; return 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 ? '域' : '') + '请求失败' + 'PromptCompletion合计' + (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 = ''; // 柱体渐变:顶部实、底部略透,堆叠时两段仍能一眼分清(纯色块并排会糊成一片)。 // 注意 stop-color 必须走 style 而不是 presentation 属性——Blink/WebKit 不解析 // 属性里的 var(),写成 stop-color="var(--accent)" 会整条渐变失效(柱子全透明)。 out += '' + '' + '' + '' + '' + '' + '' + ''; // y 轴网格 + 刻度 for (let i = 0; i <= 4; i++) { const y = PT + ih - (ih * i / 4); out += ''; out += '' + fmtTok(max * i / 4) + ''; } // 均值参考线:一眼看出"这根是不是异常高",比只给刻度省心。 // 标签放左侧:右侧常被峰值柱占用(峰值柱往往就是最后一根),贴左不会被压住。 if (avg > 0 && avg < max) { const y = yOf(avg); out += ''; out += '均值 ' + fmtTok(avg) + ''; } // 柱子 const yBase = PT + ih; for (const p of pts) { const x = xOf(p.t) - bw / 2; const hTot = ih * (p.tt / max); const hP = p.tt ? hTot * (p.pt / p.tt) : 0; const hC = Math.max(p.tt && p.ct ? 1 : 0, hTot - hP); // 圆角只给堆叠顶端(贴轴的底边保持方角,柱子才像"立"在基线上)。 // 类名用 usbar 而不是 bar:账号池的积分条是 .bar{height:3px},而 SVG2 里 // height 是 rect 的 CSS 几何属性,同名类会把每根柱子压成 3px 高(踩过)。 if (hP > 0) out += ' 0 ? '' : ' rx="1.5"') + '/>'; if (hC > 0) out += ''; out += '' + esc(p.raw) + ' ' + fmtTok(p.pt) + ' prompt / ' + fmtTok(p.ct) + ' completion / ' + p.req + ' 次'; } // 峰值标注:柱子够窄时文字压在柱顶,够宽时贴右侧避免和柱体重叠。 { const px = xOf(peak.t); const py = yOf(peak.tt); const anchor = px > W - PR - 90 ? 'end' : 'middle'; out += '' + '峰值 ' + fmtTok(peak.tt) + ''; } // x 轴基线画在柱子之后,避免压在柱底 out += ''; // x 轴刻度:按真实时间等距取 6 个位置,取该位置**最近的实际柱子**做标签, // 所以标签永远落在有数据的点上,不会指到空档里。 const TICKS = Math.min(6, pts.length); const usedLabel = new Set(); for (let k = 0; k < TICKS; k++) { const target = t0 + span * (TICKS === 1 ? 0.5 : k / (TICKS - 1)); let bi = 0, best = Infinity; for (let i = 0; i < pts.length; i++) { const d = Math.abs(pts[i].t - target); if (d < best) { best = d; bi = i; } } if (usedLabel.has(bi)) continue; usedLabel.add(bi); const p = pts[bi]; // 首尾标签靠边对齐,避免被裁掉 const cx = xOf(p.t); const anchor = cx < PL + 14 ? 'start' : (cx > W - PR - 14 ? 'end' : 'middle'); out += '' + esc(fmtTokTimeLabel(p)) + ''; } // 跨天时补一条日期分隔线,让「日界」在长窗口里可见 let prevDay = null; for (const p of pts) { const d = new Date(p.t).getDate(); if (prevDay !== null && d !== prevDay) { const x = xOf(p.t).toFixed(1); out += ''; } prevDay = d; } 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 + '
' : '') + '
' + esc(foot) + '
'; } 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 ? '' : ''; 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;