.json 的 account.nickname,这里把它与 uid8 拼成可直接辨认的
+// 标签——昵称认人、uid8 供 grep,两者都保留。
+//
+// uid 与 nick 同时为空时返回 "-"(与 UID8 口径一致,避免打出 "(-)")。
+func Label(uid, nick string) string {
+ short := UID8(uid)
+ nick = strings.TrimSpace(nick)
+ if nick == "" {
+ return short
+ }
+ return nick + "(" + short + ")"
+}
+
+// DisplayWidth 返回 s 的终端显示列宽:CJK / 全角 / emoji 记 2 列,其余记 1 列。
+//
+// 存在意义:账号昵称是用户自定的中文("猫" 是 3 字节但占 2 列,"sample" 是 6 字节占
+// 6 列),用 len()(字节数)做表格对齐会导致列宽忽宽忽窄。Go 标准库没有显示宽度函数,
+// 本仓库不引 go-runewidth(保持零第三方依赖),故内置这份覆盖常见宽字符区段的判定。
+func DisplayWidth(s string) int {
+ w := 0
+ for _, r := range s {
+ w += runeWidth(r)
+ }
+ return w
+}
+
+// Pad 把 s 右补空格到 width 显示列宽;已超宽或 width<=0 时原样返回(不截断)。
+// 只补不截:截断会丢信息,超宽时让该行自然变宽,保持内容完整。
+func Pad(s string, width int) string {
+ if width <= 0 {
+ return s
+ }
+ if d := width - DisplayWidth(s); d > 0 {
+ return s + strings.Repeat(" ", d)
+ }
+ return s
+}
+
+// maxShortUALen ShortUA 的返回上限(显示列宽足够放下 "WorkBuddy/5.5.6"、
+// "python-requests/2.31.0" 这类常见客户端标签)。
+const maxShortUALen = 40
+
+// shortUAEngines UA 里只说明渲染引擎、不说明"是什么客户端"的通用 token:浏览器 UA
+// 恒定包含它们,拿它当客户端标签等于没信息。
+var shortUAEngines = map[string]bool{
+ "mozilla": true, "applewebkit": true, "gecko": true, "khtml": true,
+ "like": true, "safari": true, "compatible": true, "msie": true, "trident": true,
+}
+
+// ShortUA 从 User-Agent 提取便于人眼识别的客户端标签("curl/8.4.0"、
+// "WorkBuddy/5.5.6"、"Chrome/120.0.0.0")。
+//
+// 为什么需要:面板「运行日志」与 stdout 流水行都要展示调用来源,而完整 UA 动辄
+// 120+ 字符(浏览器尤其),直接铺进表格会把其它列挤没。这里只留"是什么客户端",
+// 完整 UA 仍存在 reqlog.Event.UserAgent 里供面板悬停查看。
+//
+// 规则:取第一个形如 name/version 且 name 不是渲染引擎的 token;没有则回落整串
+// 的前 maxShortUALen 字节(纯产品名 UA,如 "node")。空 UA 返回空串。
+func ShortUA(ua string) string {
+ ua = strings.TrimSpace(ua)
+ if ua == "" {
+ return ""
+ }
+ for _, tok := range strings.Fields(ua) {
+ tok = strings.Trim(tok, "(),;")
+ name, _, ok := strings.Cut(tok, "/")
+ if !ok || name == "" {
+ continue
+ }
+ if shortUAEngines[strings.ToLower(name)] {
+ continue
+ }
+ return Truncate(tok, maxShortUALen)
+ }
+ return Truncate(ua, maxShortUALen)
+}
+
+// runeWidth 单个 rune 的显示列宽。区段判定取自 Unicode East Asian Width 的
+// Wide/Fullwidth 集合(与 go-runewidth 的默认表口径一致),只保留实际会用到的段。
+func runeWidth(r rune) int {
+ switch {
+ case r == 0:
+ return 0
+ case r < 0x20 || (r >= 0x7f && r < 0xa0):
+ // 控制字符(含 DEL/C1)不占位:日志里若混入 \t \r 不破坏列宽计算。
+ return 0
+ case r < 0x1100:
+ return 1
+ case r <= 0x115f: // Hangul Jamo 初声
+ return 2
+ case r == 0x2329 || r == 0x232a:
+ return 2
+ case r >= 0x2e80 && r <= 0xa4cf && r != 0x303f: // CJK 部首…Yi(303f 是窄字符)
+ return 2
+ case r >= 0xac00 && r <= 0xd7a3: // Hangul 音节
+ return 2
+ case r >= 0xf900 && r <= 0xfaff: // CJK 兼容表意
+ return 2
+ case r >= 0xfe30 && r <= 0xfe6f: // CJK 兼容形式
+ return 2
+ case r >= 0xff00 && r <= 0xff60: // 全角 ASCII
+ return 2
+ case r >= 0xffe0 && r <= 0xffe6: // 全角符号
+ return 2
+ case r >= 0x1f300 && r <= 0x1f9ff: // emoji
+ return 2
+ case r >= 0x20000 && r <= 0x3fffd: // CJK 扩展 B 及以后
+ return 2
+ }
+ return 1
+}
diff --git a/internal/logfmt/shortua_test.go b/internal/logfmt/shortua_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..ab0882fe38a43681dc47391770a09a9eb373ce4c
--- /dev/null
+++ b/internal/logfmt/shortua_test.go
@@ -0,0 +1,50 @@
+package logfmt
+
+import "testing"
+
+func TestShortUA(t *testing.T) {
+ cases := []struct {
+ name string
+ ua string
+ want string
+ }{
+ {"empty", "", ""},
+ {"blank", " ", ""},
+ {"curl", "curl/8.4.0", "curl/8.4.0"},
+ {"python", "python-requests/2.31.0", "python-requests/2.31.0"},
+ {"openai", "OpenAI/Python 1.30.0", "OpenAI/Python"},
+ {"workbuddy", "WorkBuddy/5.5.6 WorkBuddy/5.5.6 CLI/2.137.1", "WorkBuddy/5.5.6"},
+ {
+ "chrome skips engine tokens",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
+ "Chrome/120.0.0.0",
+ },
+ {
+ "firefox",
+ "Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0",
+ "Firefox/121.0",
+ },
+ {"no version token", "node", "node"},
+ {"only engine tokens falls back to whole", "Mozilla/5.0 AppleWebKit/537.36", "Mozilla/5.0 AppleWebKit/537.36"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := ShortUA(tc.ua); got != tc.want {
+ t.Fatalf("ShortUA(%q) = %q want %q", tc.ua, got, tc.want)
+ }
+ })
+ }
+}
+
+// ShortUA 的返回值必须有界:UA 是客户端可控自由文本,超长值不能原样带进日志列。
+func TestShortUABounded(t *testing.T) {
+ long := "VeryLongClientNameThatKeepsGoingAndGoing/1.2.3"
+ if got := ShortUA(long); len(got) > maxShortUALen {
+ t.Fatalf("len(ShortUA) = %d want <= %d (%q)", len(got), maxShortUALen, got)
+ }
+ // 没有 name/version token 时回落整串,同样受上限约束。
+ got := ShortUA("x" + string(make([]byte, 0)) + "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy")
+ if len(got) > maxShortUALen {
+ t.Fatalf("fallback not truncated: %d", len(got))
+ }
+}
diff --git a/internal/panel/app.js b/internal/panel/app.js
new file mode 100644
index 0000000000000000000000000000000000000000..00b571d8db1ae916df19d2b2a6dbda9b58111437
--- /dev/null
+++ b/internal/panel/app.js
@@ -0,0 +1,2604 @@
+'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(); });
+
+/* ── 路由 ─────────────────────────────────────────────────────────── */
+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 === '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 (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 ? ''
+ : '') +
+ '' +
+ ' |
';
+ }).join('');
+}
+
+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 || []);
+ } 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 === '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'],
+ 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'],
+};
+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();
+ if (raw === '') v = undefined;
+ else if (name.endsWith('_hours')) v = raw.split(/[,,\s]+/).filter(Boolean).map(Number);
+ else v = raw;
+ }
+ if (v !== undefined) put(out, path, v);
+ }
+ return out;
+}
+/* Go 时长字段即时校验:空 = 沿用现值(collectConfig 跳过发送);非空必须是
+ ParseDuration 语法(30m / 2h / 600s / 1h30m,可组合可带小数)。与后端
+ config.go normalize() 的 time.ParseDuration 同口径,脏值在前端就地标红,
+ 不再等到保存被拒。 */
+const DURATION_RE = /^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/;
+const DURATION_FIELDS = ['soft_rate', 'soft_rate_max', 'breaker_cooldown', 'breaker_cooldown_max',
+ 'degrade_cooldown', 'degrade_cooldown_max', 'cost_explore_interval', 'expiring_soon', 'ttl'];
+const DURATION_TIP = '格式应为 Go 时长:30m / 2h / 600s / 1h30m';
+function durationBad(name) {
+ const el = $('cfgForm').elements[name];
+ if (!el) return false;
+ const v = el.value.trim();
+ return v !== '' && !DURATION_RE.test(v);
+}
+function markDurationFields() {
+ for (const name of DURATION_FIELDS) {
+ const el = $('cfgForm').elements[name];
+ if (!el) continue;
+ const bad = durationBad(name);
+ el.classList.toggle('invalid', bad);
+ el.title = bad ? DURATION_TIP : '';
+ }
+}
+$('cfgForm').addEventListener('input', ev => {
+ if (DURATION_FIELDS.includes(ev.target.name)) markDurationFields();
+});
+$('btnEye').onclick = () => {
+ const el = $('cfgKey');
+ const show = el.type === 'password';
+ el.type = show ? 'text' : 'password';
+ $('btnEye').textContent = show ? '隐藏' : '显示';
+};
+$('btnCfgReload').onclick = loadConfig;
+$('cfgForm').onsubmit = async ev => {
+ ev.preventDefault();
+ // 时长字段脏值拦截:标红 + toast 点名,不发保存请求(后端同样会拒,这里前置)。
+ markDurationFields();
+ const firstBad = DURATION_FIELDS.find(durationBad);
+ if (firstBad) {
+ const el = $('cfgForm').elements[firstBad];
+ el.focus();
+ toast('「' + (el.closest('.fld')?.querySelector('.lb')?.textContent || firstBad) + '」' + DURATION_TIP, 'err');
+ return;
+ }
+ const btn = $('btnCfgSave');
+ btn.disabled = true; btn.textContent = '保存中…';
+ try {
+ const r = await api('config', { method: 'POST', body: JSON.stringify(collectConfig()) });
+ const n = (r.restart_required || []).length;
+ toast(n ? '配置已保存,其中 ' + n + ' 项需重启进程生效' : '配置已保存并立即生效', 'ok');
+ // 密钥可能已改:本次会话沿用新值,避免下一次轮询被 401。
+ const k = $('cfgKey').value.trim();
+ if (k) localStorage.setItem(LS_KEY, k);
+ loadConfig();
+ loadOverview(true);
+ } catch (e) { toast('保存失败:' + e.message, 'err'); }
+ finally { btn.disabled = false; btn.textContent = '保存配置'; }
+};
+
+/* ── 添加账号 ─────────────────────────────────────────────────────── */
+function openAdd() {
+ $('addVeil').classList.add('on');
+ // 重置到登录标签
+ switchAddTab('login');
+ $('addPick').hidden = false;
+ $('addLoad').hidden = true; $('addReady').hidden = true;
+ $('addDone').hidden = true; $('addErr').hidden = true;
+ $('importDone').hidden = true; $('importErr').hidden = true;
+ $('btnCopyUrl').hidden = true; $('btnOpenUrl').hidden = true;
+ $('btnStartLogin').hidden = false; $('btnStartLogin').disabled = false;
+ stopPoll();
+}
+function switchAddTab(tab) {
+ document.querySelectorAll('#addTabs .tab').forEach(b => b.classList.toggle('on', b.dataset.tab === tab));
+ $('addTabLogin').hidden = tab !== 'login';
+ $('addTabImport').hidden = tab !== 'import';
+}
+document.querySelectorAll('#addTabs .tab').forEach(b => {
+ b.onclick = () => switchAddTab(b.dataset.tab);
+});
+function startAddLogin() {
+ const realm = (document.querySelector('input[name="addRealm"]:checked') || {}).value || 'cn';
+ $('btnStartLogin').disabled = true;
+ $('addLoad').hidden = false; $('addErr').hidden = true;
+ api('login/start', { method: 'POST', body: JSON.stringify({ realm }) }).then(r => {
+ loginState = r.state;
+ $('addUrl').textContent = r.url;
+ $('addPick').hidden = true; // 选域锁定(会话已按该域发起)
+ $('addLoad').hidden = true; $('addReady').hidden = false;
+ $('btnStartLogin').hidden = true;
+ $('btnCopyUrl').hidden = false; $('btnOpenUrl').hidden = false;
+ loginTimer = setInterval(pollLogin, 3000);
+ }).catch(e => {
+ $('addLoad').hidden = true;
+ $('btnStartLogin').disabled = false;
+ $('addErr').hidden = false;
+ $('addErr').textContent = e.message;
+ });
+}
+function stopPoll() { if (loginTimer) { clearInterval(loginTimer); loginTimer = null; } }
+async function pollLogin() {
+ if (!loginState) return;
+ try {
+ const r = await api('login/poll?state=' + encodeURIComponent(loginState));
+ if (r.done) {
+ stopPoll();
+ $('addReady').hidden = true;
+ $('addDone').hidden = false;
+ $('addDone').textContent = '已添加 ' + (r.nickname || r.uid) + (r.realm === 'global' ? '(国际版)' : '') + (r.credits >= 0 ? ' · 积分 ' + r.credits + (r.credits_total > 0 ? '/' + r.credits_total : '') : '') + ',账号已载入池中';
+ setTimeout(() => { closeAdd(); loadOverview(true); }, 1600);
+ }
+ } catch (e) {
+ stopPoll();
+ $('addReady').hidden = true;
+ $('addErr').hidden = false;
+ $('addErr').textContent = e.message + '(关闭后重新添加)';
+ }
+}
+function closeAdd() { stopPoll(); loginState = null; $('addVeil').classList.remove('on'); }
+$('btnCloseAdd').onclick = closeAdd;
+$('btnStartLogin').onclick = startAddLogin;
+$('btnOpenUrl').onclick = () => open($('addUrl').textContent, '_blank');
+$('btnCopyUrl').onclick = () => navigator.clipboard.writeText($('addUrl').textContent)
+ .then(() => toast('链接已复制', 'ok'), () => toast('复制失败,请手动选择复制', 'err'));
+$('importFile').onchange = async () => {
+ const file = $('importFile').files[0];
+ if (!file) return;
+ $('importDone').hidden = true; $('importErr').hidden = true;
+ const fd = new FormData();
+ fd.append('file', file);
+ const h = {};
+ const k = localStorage.getItem(LS_KEY);
+ if (k) h['Authorization'] = 'Bearer ' + k;
+ try {
+ const r = await fetch('/panel/api/import/cockpit', { method: 'POST', body: fd, headers: h });
+ const d = await r.json();
+ if (!r.ok) throw new Error(d.error || ('HTTP ' + r.status));
+ $('importDone').hidden = false;
+ $('importDone').textContent = '导入完成:成功 ' + d.imported + ' 个' + (d.skipped ? ',跳过 ' + d.skipped + ' 个' : '');
+ if (d.errors && d.errors.length) {
+ console.warn('import errors:', d.errors);
+ }
+ loadOverview(true);
+ } catch (e) {
+ $('importErr').hidden = false;
+ $('importErr').textContent = '导入失败:' + e.message;
+ }
+ $('importFile').value = '';
+};
+
+/* ── 顶部动作 ─────────────────────────────────────────────────────── */
+$('btnAdd').onclick = openAdd;
+$('btnRefresh').onclick = async () => {
+ const b = $('btnRefresh');
+ b.disabled = true; b.textContent = '刷新中…';
+ try {
+ await api('balance_all', { method: 'POST' });
+ await loadOverview(true);
+ toast('余额已从上游刷新', 'ok');
+ } catch (e) { toast('刷新失败:' + e.message, 'err'); await loadOverview(true); }
+ finally { b.disabled = false; b.textContent = '刷新'; }
+ if (view === 'logs') loadLogs();
+};
+
+/* ── 轮询 ─────────────────────────────────────────────────────────── */
+function refreshVisible() {
+ if (view === 'accounts') loadOverview(true);
+ else if (view === 'logs') loadLogs();
+ else if (view === 'taskscenter') reattachQueueView();
+}
+function start() {
+ loadOverview(true);
+ if (refTimer) clearInterval(refTimer);
+ refTimer = setInterval(refreshVisible, 5000);
+ checkAuthGate();
+}
+async function checkAuthGate() {
+ try { await api('overview'); }
+ catch (e) { if (String(e.message).includes('密钥') || String(e.message).includes('api_key')) return; }
+}
+start();
+
+/* ── 积分任务 ─────────────────────────────────────────────────────── */
+let taskUID = null;
+
+// 可自动完成的任务(与后端 autoActions 表一致):判据为行为事件、可经网关复现。
+// 其余任务需在官方客户端内交互,面板只展示指引(行 title 提示)。
+// 注意:键含点号(Model_chat_GLM5.2)必须加引号,否则会被解析成属性访问 + 数字字面量。
+const AUTO_TASKS = {
+ 'chat_5': '上报 5 条对话活跃事件(自动补足差额)',
+ 'first_buddy': '上报解锁 → 同意协议 → 领取第一只 Buddy',
+ 'Model_chat_GLM5.2': '接受任务 → glm-5.2 真实对话一次 → 对齐模型上报',
+ 'RichMeow_Chat': '桌面指纹事件链上报(已验证可点亮)',
+ 'Buddy_App': '上报「进入 Buddy 应用」事件链(已验证可点亮)',
+ 'Buddy_App_QQ': '上报「进入企鹅教师助手」事件链(已验证可点亮)',
+ 'automation_1': '上报「定时任务创建」事件(已验证可点亮)',
+ 'Library_read': '上报「读资料库介绍」事件(已验证可点亮)',
+ 'template_5': '上报「使用模板创建任务」事件组 ×5(三账号实测点亮)',
+ 'playbook_prompt': '上报「灵感案例做同款发送 Prompt」事件组(三账号实测点亮)',
+ 'create_canvas': '上报「设计创意画布创建」事件组(三账号实测点亮,+300 分)',
+ 'expert_5': '真实专家召唤+使用链 ×5(专家市场+真实 chat,三账号实测点亮)',
+ 'Expert_team_use_3': '真实专家团召唤+使用链 ×3(三账号实测点亮)',
+ 'Hp_Appearance': '设置主题 API + 皮肤生效事件(两账号实测点亮)',
+ 'black_cat': '夜猫子:23:00–08:00 窗口内 glm-5.2 对话补足(窗口外提示等 23 点排程)',
+ 'Expert_lighthouse': '真实轻量云专家召唤+使用链(真实对话 requestId,两账号实测点亮)',
+ 'skill_1': '真实对话 + skill_info 技能加载事件(实测点亮)',
+ 'school_season': '校园日(小程序口径):accept → mini 对话+activityId 上报 → 领奖(+100c+5e)',
+ 'Sequential_Tasks_1': '小程序首对话(小程序口径):accept → mini 对话上报 → 领奖(+100c+5e)',
+ 'Sequential_Tasks_2': '小程序选专家对话(小程序口径):市场专家 id → accept → expert_actual_use 上报 → 领奖(+200c+5e)',
+ 'Sequential_Tasks_3': '小程序五次对话(小程序口径):accept → mini 对话上报 ×5(自动补差额)→ 领奖(+300c+5e)',
+ 'Sequential_Tasks_4': '小程序定时任务(预留,每日零点解锁一环):accept → 定时任务创建事件 → 领奖(判据待解锁验证)',
+ 'Sequential_Tasks_5': '小程序使用 GLM5.2(预留):accept → 带模型字段的 mini 对话上报 → 领奖(判据待解锁验证)',
+ 'Sequential_Tasks_6': '小程序十次对话(预留):accept → mini 对话上报 ×target(自动补差额)→ 领奖',
+ 'Sequential_Tasks_7': '体验灵感功能(预留,疑 PC 口径):accept → 灵感事件组(PC+mp 双形态)→ 领奖(判据待解锁验证)'
+};
+
+function openTasks(uid) {
+ taskUID = uid;
+ $('taskWho').textContent = uid.slice(0, 16);
+ $('taskVeil').classList.add('on');
+ $('btnTaskReload').hidden = false;
+ loadTasks();
+}
+function closeTasks() { $('taskVeil').classList.remove('on'); taskUID = null; }
+$('btnCloseTask').onclick = closeTasks;
+$('btnTaskReload').onclick = loadTasks;
+
+// 全部接受:把该账号未接受的任务一次性报名(幂等,跳过已接受/已领取)。
+$('btnTaskAcceptAll').onclick = async () => {
+ if (!taskUID) return;
+ const btn = $('btnTaskAcceptAll');
+ btn.disabled = true; btn.textContent = '接受中…';
+ try {
+ const r = await api('accounts/' + encodeURIComponent(taskUID) + '/tasks/accept_all', { method: 'POST' });
+ const n = r.accepted || 0;
+ if (r.failed && r.failed.length) {
+ toast(`已接受 ${n} 个,${r.failed.length} 个被上游拒绝(可重试)`, 'err');
+ } else {
+ toast(n ? `已接受 ${n} 个任务` : (r.message || '所有任务均已接受'), 'ok');
+ }
+ } catch (e) { toast(e.message, 'err'); }
+ finally { btn.disabled = false; btn.textContent = '全部接受'; loadTasks(); }
+};
+
+// 一键完成全部可自动任务(耗时较长:含真实对话,逐项回读验证)。
+$('btnTaskAutoAll').onclick = async () => {
+ if (!taskUID) return;
+ const btn = $('btnTaskAutoAll');
+ if (!confirm('将依次执行:补报对话事件、领取 Buddy、glm-5.2 对话、尝试上报。\n过程约 1-2 分钟(含真实对话),确认继续?')) return;
+ btn.disabled = true; btn.textContent = '执行中…';
+ try {
+ const r = await api('accounts/' + encodeURIComponent(taskUID) + '/tasks/auto_all', { method: 'POST' });
+ const okN = (r.results || []).filter(x => x.status === 'done').length;
+ const skipN = (r.results || []).filter(x => x.status === 'skipped').length;
+ const errN = (r.results || []).filter(x => x.status === 'error').length;
+ toast(`执行完成:成功 ${okN} 项,跳过 ${skipN} 项${errN ? ',失败 ' + errN + ' 项' : ''}`, errN ? 'err' : 'ok');
+ console.log('auto_all results:', r.results);
+ } catch (e) { toast(e.message, 'err'); }
+ finally { btn.disabled = false; btn.textContent = '一键完成可自动任务'; loadTasks(); }
+};
+
+async function loadTasks() {
+ if (!taskUID) return;
+ const st = $('taskState'), tb = $('taskTable');
+ st.hidden = false;
+ st.className = 'state';
+ st.innerHTML = '查询中';
+ tb.hidden = true;
+ try {
+ const d = await api('accounts/' + encodeURIComponent(taskUID) + '/tasks');
+ const list = d.tasks || [];
+ if (!list.length) {
+ st.className = 'state';
+ st.textContent = '该账号暂无任务';
+ return;
+ }
+ // 有进度或可领取的排前面,已领取沉底——一眼看到"现在该做什么"。
+ list.sort((a, b) => (a.claimed - b.claimed) || (b.claimable - a.claimable) || String(a.task_code).localeCompare(String(b.task_code)));
+ $('taskBody').innerHTML = list.map(t => {
+ // 进度:current 可能缺失(0 或被上游省略)——用 ?? 兜底,避免渲染成 "undefined / N"
+ const cur = t.current ?? 0, tgt = t.target ?? 0;
+ const prog = tgt ? cur + ' / ' + tgt : (tgt === 0 && cur > 0 ? String(cur) : '—');
+ const parts = [];
+ if (t.credit) parts.push('+' + t.credit + ' 分');
+ if (t.energy) parts.push('+' + t.energy + ' 能');
+ if (t.reward_buddy) parts.push('Buddy');
+ const reward = parts.length ? parts.join(' ') : '—';
+ const badge = t.claimed ? '已领取'
+ : t.claimable ? '可领取'
+ : t.locked ? '未解锁'
+ : t.accept_status === 'accepted' ? '进行中'
+ : '未接受';
+ const acted = t.claimed || t.locked ? ''
+ : t.claimable ? ''
+ : AUTO_TASKS[t.task_code] ? ''
+ : t.accept_status === 'accepted' ? ''
+ : '';
+ // 操作指引(description/task_desc)挂 title 提示:如何完成交给用户看
+ const tip = [t.title, t.task_desc || t.description, t.jump_url ? '跳转:' + t.jump_url : ''].filter(Boolean).join('\n');
+ return ' | ' +
+ '' + esc(t.title || t.task_code) + ' ' + esc(t.task_code) + (t.tag ? ' · ' + esc(t.tag) : '') + ' | ' +
+ '' + esc(prog) + ' | ' +
+ '' + esc(reward) + ' | ' +
+ '' + badge + ' | ' +
+ '' + acted + ' |
';
+ }).join('');
+ st.hidden = true;
+ tb.hidden = false;
+ } catch (e) {
+ st.className = 'state err';
+ st.textContent = e.message;
+ }
+}
+
+$('taskBody').addEventListener('click', async ev => {
+ const b = ev.target.closest('button[data-t]');
+ if (!b || !taskUID) return;
+ const kind = b.dataset.t, code = b.dataset.c;
+ b.disabled = true;
+ try {
+ if (kind === 'auto') {
+ // 一键完成:后端执行动作 → 回读进度 → 汇报(耗时可到分钟级,含真实对话)
+ b.textContent = '执行中…';
+ const r = await api('accounts/' + encodeURIComponent(taskUID) + '/tasks/auto', {
+ method: 'POST', body: JSON.stringify({ task_code: code })
+ });
+ if (r.skipped) {
+ toast(r.message || '已跳过', 'ok');
+ } else {
+ const advanced = r.progress_before !== r.progress_after;
+ let msg = r.message || '已执行';
+ if (r.progress_after) msg += `(进度 ${r.progress_before} → ${r.progress_after})`;
+ if (r.claimed) msg += ',奖励已自动到账';
+ else if (r.claimable) msg += r.claim_error ? ',可点「领取」重试' : '';
+ else if (r.attempt && !advanced) msg += ';进度未动,该任务可能需要官方客户端';
+ toast(msg, (r.claimed || advanced) ? 'ok' : 'err');
+ }
+ loadOverview(true);
+ } else {
+ const path = 'accounts/' + encodeURIComponent(taskUID) + '/tasks/' + (kind === 'claim' ? 'claim' : 'accept');
+ const body = kind === 'claim' ? { task_code: code } : { task_codes: [code] };
+ await api(path, { method: 'POST', body: JSON.stringify(body) });
+ toast(kind === 'claim' ? '已领取奖励' : '已接受任务', 'ok');
+ if (kind === 'claim') loadOverview(true);
+ }
+ } catch (e) { toast(e.message, 'err'); }
+ finally { loadTasks(); }
+});
+
+/* ── 任务中心:开学季 + 全账号扫描/队列 ──────────────────────────── */
+// 开学季任务单元:✓ 已领(绿)|◐ x/y 进行中(琥珀)|○ 未做(灰)
+function staskHTML(t) {
+ if (!t) return '·—';
+ if (t.status === 'claimed') return '✓已领';
+ if (t.status === 'completed') return '◆可领';
+ if (t.status === 'in_progress') {
+ const fr = t.target_count ? '' + t.progress + '/' + t.target_count + '' : '';
+ return '◐' + fr + '';
+ }
+ return '○未做';
+}
+const LUCK_SVG = '';
+const SCHOOL_TITLES = {
+ share_invite: '分享活动 +100c', desktop_chat_1_time: '桌面端体验 +100c(单次)',
+ chat_3_times: '和 AI 对话 3 次 +50c', expert_use: '召唤开学季专家 +50c',
+ task_student_verify: '学生认证 +100c(需真实认证,不做)',
+};
+
+/* ── 精简 QR 编码器(券码二维码用)────────────────────────────────────
+ 规格子集:byte 模式、ECC L、版本 1-5(全部单纠错块,免块交织)、固定掩码 0。
+ 完整性:规范允许任选掩码(解码器按格式信息位自行去掩码),固定掩码不影响
+ 可扫描性;已用 python qrcode 库对多输入多版本做逐像素交叉验证(强制 byte
+ 模式 + mask 0,5/5 全部 diff=0)。面板 CSP 只允许 self,外链 QR 服务不可用。 */
+// qr_gen.js —— 精简 QR 编码器(浏览器用 + node 可跑交叉验证)
+// 规格子集:byte 模式、ECC L、版本 1-5(全部单纠错块,免块交织)、固定掩码 0。
+// 完整性说明:规范允许编码器任选掩码(解码器按格式信息位自行去掩码),
+// 固定掩码不影响可扫描性;券码为短文本,v1-5(26 字节起)绰绰有余。
+
+// GF(256) 对数/指数表(本原多项式 0x11d)
+const QR_EXP = new Array(512), QR_LOG = new Array(256);
+(() => {
+ let x = 1;
+ for (let i = 0; i < 255; i++) { QR_EXP[i] = x; QR_LOG[x] = i; x <<= 1; if (x & 0x100) x ^= 0x11d; }
+ for (let i = 255; i < 512; i++) QR_EXP[i] = QR_EXP[i - 255];
+})();
+const gmul = (a, b) => (a && b) ? QR_EXP[QR_LOG[a] + QR_LOG[b]] : 0;
+
+// 各版本参数(下标 = 版本-1):[数据码字数, 纠错码字数],ECC L 单块
+const QR_V = [[19, 7], [34, 10], [55, 15], [80, 20], [108, 26]];
+// 对齐图案中心坐标(v2+;与定位图案重叠的位置在放置时跳过)
+const QR_ALIGN = [[], [6, 18], [6, 22], [6, 26], [6, 30]];
+const QR_MASK = (r, c) => (r + c) % 2 === 0; // 掩码模式 0
+
+// 生成多项式(最高次系数在前,g[0] 恒为 1)
+function qrGenPoly(deg) {
+ let g = [1];
+ for (let i = 0; i < deg; i++) {
+ const a = QR_EXP[i], ng = new Array(g.length + 1).fill(0);
+ ng[0] = g[0];
+ for (let j = 1; j < g.length; j++) ng[j] = g[j] ^ gmul(a, g[j - 1]);
+ ng[g.length] = gmul(a, g[g.length - 1]);
+ g = ng;
+ }
+ return g;
+}
+
+// Reed-Solomon 求余(综合除法),返回 deg 个纠错码字
+function rsRem(data, deg) {
+ const g = qrGenPoly(deg);
+ const res = data.concat(new Array(deg).fill(0));
+ for (let i = 0; i < data.length; i++) {
+ const f = res[i];
+ if (f) for (let j = 0; j < g.length; j++) res[i + j] ^= gmul(g[j], f);
+ }
+ return res.slice(data.length);
+}
+
+// 文本 → 码字流(byte 模式:0100 + 8 位计数 + 数据 + 终止符 + 0xEC/0x11 填充)
+function qrDataCodewords(text, dataCap) {
+ const bytes = Array.from(new TextEncoder().encode(text));
+ const bits = [];
+ const push = (val, n) => { for (let i = n - 1; i >= 0; i--) bits.push((val >> i) & 1); };
+ push(4, 4); // byte 模式
+ push(bytes.length, 8); // v1-9 计数 8 位
+ for (const b of bytes) push(b, 8);
+ const cap = dataCap * 8;
+ push(0, Math.min(4, cap - bits.length)); // 终止符
+ while (bits.length % 8) bits.push(0);
+ const out = [];
+ for (let i = 0; i < bits.length; i += 8) {
+ let v = 0; for (const b of bits.slice(i, i + 8)) v = (v << 1) | b;
+ out.push(v);
+ }
+ for (let p = 0; out.length < dataCap; p ^= 1) out.push(p ? 0x11 : 0xEC);
+ return out;
+}
+
+// 主入口:text → 布尔矩阵(true=深色模块)
+function qrMatrix(text) {
+ const bytes = Array.from(new TextEncoder().encode(text));
+ // 版本选择:需求 ≈ 2 码字头 + 文本长度,取首个放得下的版本
+ let ver = 0;
+ for (let v = 0; v < QR_V.length; v++) { if (bytes.length + 2 <= QR_V[v][0]) { ver = v + 1; break; } }
+ if (!ver) throw new Error('QR: text too long (>' + QR_V[4][0] + ' bytes)');
+ const [dataCap, ecCap] = QR_V[ver - 1];
+ const n = 17 + 4 * ver;
+
+ const M = Array.from({ length: n }, () => new Array(n).fill(false));
+ const F = Array.from({ length: n }, () => new Array(n).fill(false)); // 功能模块占位
+
+ const setF = (r, c, v) => { M[r][c] = v; F[r][c] = true; };
+ // 定位图案 + 分隔带
+ const finder = (r0, c0) => {
+ for (let r = -1; r <= 7; r++) for (let c = -1; c <= 7; c++) {
+ const rr = r0 + r, cc = c0 + c;
+ if (rr < 0 || cc < 0 || rr >= n || cc >= n) continue;
+ const dark = r >= 0 && r <= 6 && c >= 0 && c <= 6 && (r === 0 || r === 6 || c === 0 || c === 6 || (r >= 2 && r <= 4 && c >= 2 && c <= 4));
+ setF(rr, cc, dark);
+ }
+ };
+ finder(0, 0); finder(0, n - 7); finder(n - 7, 0);
+ // 校正图形(仅贯穿两定位图案之间:8..n-9,不得覆盖定位图案本体)
+ for (let r = 8; r <= n - 9; r++) setF(r, 6, r % 2 === 0);
+ for (let c = 8; c <= n - 9; c++) setF(6, c, c % 2 === 0);
+ // 对齐图案(v2+,跳过与定位重叠处)
+ const align = QR_ALIGN[ver - 1] || [];
+ for (const ar of align) for (const ac of align) {
+ if (F[ar][ac]) continue;
+ for (let r = -2; r <= 2; r++) for (let c = -2; c <= 2; c++)
+ setF(ar + r, ac + c, Math.max(Math.abs(r), Math.abs(c)) !== 1);
+ }
+ // 暗模块 + 格式信息(ECC L=01,掩码 0)——BCH(15,5) + 0x5412 异或。
+ // 位序遵循规范(与 python qrcode 逐位对齐验证):bit i 从 LSB 起数,
+ // 副本一走左上角 L 形、副本二走右下 L 形。
+ let fmt = (1 << 3) | 0; // L<<3 | mask
+ let rem = fmt << 10;
+ for (let i = 14; i >= 10; i--) if ((rem >> i) & 1) rem ^= 0x537 << (i - 10);
+ fmt = ((fmt << 10) | rem) ^ 0x5412; // 15 位
+ const fb = i => (fmt >> i) & 1;
+ // 副本一(左上):位 0..5 → (i,8);6 → (7,8);7 → (8,8)
+ for (let i = 0; i <= 5; i++) setF(i, 8, !!fb(i));
+ setF(7, 8, !!fb(6)); setF(8, 8, !!fb(7));
+ // 副本一续 + 副本二(右下):位 8..14 → (n-15+i, 8);位 0..7 → (8, n-1-i);8 → (8,7);9..14 → (8,14-i)
+ for (let i = 8; i <= 14; i++) setF(n - 15 + i, 8, !!fb(i));
+ for (let i = 0; i <= 7; i++) setF(8, n - 1 - i, !!fb(i));
+ setF(8, 7, !!fb(8));
+ for (let i = 9; i <= 14; i++) setF(8, 14 - i, !!fb(i));
+ // 暗模块(恒为深色,位于副本一垂直段末端)
+ setF(n - 8, 8, true);
+
+
+ // 数据码字 + 纠错码字 → 位流
+ const dcw = qrDataCodewords(text, dataCap);
+ const cw = dcw.concat(rsRem(dcw, ecCap));
+ const bits = [];
+ for (const b of cw) for (let i = 7; i >= 0; i--) bits.push((b >> i) & 1);
+
+ // 蛇形放置(成对列,从右向左,跳过第 6 列),写数据时直接异或掩码
+ let bi = 0, up = true;
+ for (let x = n - 1; x > 0; x -= 2) {
+ if (x === 6) x--;
+ for (let i = 0; i < n; i++) {
+ const r = up ? n - 1 - i : i;
+ for (const c of [x, x - 1]) {
+ if (F[r][c]) continue;
+ const bit = bi < bits.length ? bits[bi++] : 0;
+ M[r][c] = bit ? !QR_MASK(r, c) : QR_MASK(r, c);
+ }
+ }
+ up = !up;
+ }
+ return M;
+}
+
+// 矩阵 → SVG(quiet zone 4 模块)
+function qrSVG(M, px) {
+ const n = M.length, q = 4, total = n + q * 2;
+ let s = '';
+}
+
+/* ── 开学季券码查询(弹窗,仿活动页 #/prizes?tab=vouchers)──────────── */
+/* copyText:clipboard API 只在 secure context(https/localhost)可用,
+ 远程 http 面板会拿不到 navigator.clipboard → 降级 execCommand。 */
+function copyText(text) {
+ if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
+ return new Promise((resolve, reject) => {
+ const ta = document.createElement('textarea');
+ ta.value = text;
+ ta.style.cssText = 'position:fixed;opacity:0';
+ document.body.appendChild(ta);
+ ta.select();
+ try { document.execCommand('copy') ? resolve() : reject(new Error('copy failed')); }
+ catch (e) { reject(e); }
+ finally { ta.remove(); }
+ });
+}
+
+function vcCard(v) {
+ const expired = v.valid_to && new Date(v.valid_to) < new Date();
+ return '' +
+ '
' + esc(v.prize_name || v.sku_code || '券') + '' +
+ (expired ? '已过期' : '可使用') + '
' +
+ '
' +
+ (v.valid_to ? '有效期至 ' + esc(v.valid_to) : '长期有效') +
+ (v.granted_at ? ' · ' + esc(v.granted_at.slice(0, 10)) + ' 抽中' : '') +
+ '
' +
+ '
' +
+ '
券码' + esc(v.code || '-') + '' +
+ '' +
+ (v.code ? '' : '') +
+ '' +
+ '
' +
+ '
';
+}
+
+async function loadSchoolVouchers() {
+ const body = $('vcBody');
+ $('vcVeil').classList.add('on');
+ body.innerHTML = '查询中
';
+ $('vcNote').textContent = '';
+ try {
+ const d = await api('school/vouchers');
+ const arr = d.accounts || [];
+ const ok = arr.filter(a => !a.error);
+ const total = ok.reduce((n, a) => n + (a.vouchers || []).length, 0);
+ body.innerHTML = ok.filter(a => (a.vouchers || []).length).map(a =>
+ '' + esc(a.nickname || a.uid) + '' +
+ '' + a.vouchers.length + ' 张
' +
+ a.vouchers.map(vcCard).join('')
+ ).join('') || '';
+ $('vcNote').textContent = total ? total + ' 张券 · ' + ok.filter(a => !(a.vouchers || []).length).length + ' 个账号未抽中' : '';
+ const errs = arr.filter(a => a.error);
+ if (errs.length) {
+ body.insertAdjacentHTML('beforeend', '查询失败:' +
+ errs.map(a => esc(a.nickname || a.uid.slice(0, 8)) + '(' + esc(a.error) + ')').join('、') + '
');
+ }
+ body.querySelectorAll('button[data-copy]').forEach(b => b.onclick = async () => {
+ try { await copyText(b.dataset.copy); toast('券码已复制', 'ok'); }
+ catch (e) { toast('复制失败,请手动选择券码', 'err'); }
+ });
+ // 二维码:券码本体编码为 QR(到店出示扫描),点击切换显示/隐藏
+ body.querySelectorAll('button[data-qr]').forEach(b => b.onclick = () => {
+ const card = b.closest('.vc');
+ const old = card.querySelector('.vc-qr');
+ if (old) { old.remove(); return; }
+ const box = document.createElement('div');
+ box.className = 'vc-qr';
+ try { box.innerHTML = qrSVG(qrMatrix(b.dataset.qr), 148); }
+ catch (e) { box.innerHTML = '二维码生成失败:' + esc(e.message) + ''; }
+ card.appendChild(box);
+ });
+ } catch (e) {
+ body.innerHTML = '' + esc(e.message) + '
';
+ }
+}
+$('btnSchoolVouchers').onclick = loadSchoolVouchers;
+$('btnVcClose').onclick = () => $('vcVeil').classList.remove('on');
+$('btnVcRefresh').onclick = loadSchoolVouchers;
+
+/* 成长任务队列。lastQueueSeq 记录本页启动过的队列代次:执行结束后的残留 items
+ (running=false 但 seq 停在旧值)不再回写视图——否则扫描结果 3 秒后被上一轮
+ 队列状态覆盖。 */
+let queueTimer = null, lastQueueSeq = 0;
+const GROWTH_TITLES = {}; // code → 展示名(扫描时从任务列表带出)
+$('btnScanAll').onclick = async () => {
+ const b = $('btnScanAll');
+ // 停掉队列轮询:显式扫描 = 切到待办视图。否则在途队列的下一 tick 会把扫描
+ // 结果冲掉重渲染回队列视图(服务端执行不受影响,只是不再实时回写本视图)。
+ if (queueTimer) { clearInterval(queueTimer); queueTimer = null; }
+ b.disabled = true; b.textContent = '扫描中…';
+ try {
+ const d = await api('tasks/scan_all', { method: 'POST' });
+ renderQueue(groupItems(d), null, '没有待办任务 🎉', '全部账号的成长任务与开学季活动都已完成,明日再来。');
+ } catch (e) { toast(e.message, 'err'); }
+ finally { b.disabled = false; b.textContent = '扫描待办'; }
+};
+$('btnRunQueue').onclick = async () => {
+ const conc = Number($('qcConc').value) || 1;
+ if (!confirm('扫描全部账号待办并排队执行(账号并发 ' + conc + ',账号内串行)。\n含真实对话的任务耗时较长,确认继续?')) return;
+ const b = $('btnRunQueue');
+ b.disabled = true; b.textContent = '启动中…';
+ try {
+ const r = await api('tasks/run_queue', { method: 'POST', body: JSON.stringify({ concurrency: conc }) });
+ if (!r.started) { toast(r.message || '没有待办任务', 'ok'); return; }
+ lastQueueSeq = r.seq || 0;
+ toast('队列已启动:' + r.total + ' 项(并发 ' + conc + ')', 'ok');
+ startQueuePolling();
+ } catch (e) { toast(e.message, 'err'); }
+ finally { b.disabled = false; b.textContent = '执行全部待办'; }
+};
+// 扫描结果 → 分组条目(无执行状态)
+function groupItems(d) {
+ const groups = [];
+ for (const a of (d.accounts || [])) {
+ const rows = [];
+ for (const t of (a.growth || [])) {
+ GROWTH_TITLES[t.task_code] = t.title || t.task_code;
+ rows.push({ kind: 'growth', code: t.task_code, prog: t.target ? t.current + '/' + t.target : '—', status: 'scan' });
+ }
+ if (rows.length) groups.push({ uid: a.uid, nick: a.nickname, rows });
+ }
+ return groups;
+}
+const ST_WORDS = { done: '完成', running: '执行中', error: '失败', skipped: '跳过', pending: '排队', scan: '待执行' };
+function qrowHTML(it) {
+ const isSchool = it.kind === 'school';
+ const title = isSchool ? '开学季闭环' : (GROWTH_TITLES[it.code] || it.code);
+ const dotCls = it.status === 'scan' ? 'wait' : it.status === 'running' ? 'run' : it.status === 'error' ? 'err' : it.status === 'skipped' ? 'skip' : it.status === 'done' ? 'done' : 'wait';
+ const stWord = it.status === 'scan' ? '待执行' : (ST_WORDS[it.status] || it.status);
+ return '' +
+ '' + esc(it.code) + '' +
+ '' + esc(title) + '' + (isSchool ? '开学季' : '') + '' +
+ '' + esc(it.prog || '') + '' +
+ '' + stWord + '' +
+ '' + esc(it.message || '') + '' +
+ '
';
+}
+function renderQueue(groups, progress, emptyTitle, emptyDesc) {
+ const empty = $('tcEmpty'), list = $('qcList');
+ if (!groups.length) {
+ empty.style.display = '';
+ if (emptyTitle) empty.querySelector('.t').textContent = emptyTitle;
+ if (emptyDesc) empty.querySelector('.d').textContent = emptyDesc;
+ list.innerHTML = '';
+ $('qProg').hidden = true; $('qcSummary').textContent = '';
+ return;
+ }
+ empty.style.display = 'none';
+ empty.style.display = 'none';
+ let total = 0;
+ list.innerHTML = groups.map(g => {
+ total += g.rows.length;
+ return '' + esc(g.nick || g.uid.slice(0, 12)) + '' + g.rows.length + ' 项待办' +
+ g.rows.map(qrowHTML).join('') + '
';
+ }).join('');
+ $('qcSummary').textContent = total + ' 项';
+ updateProgress(progress);
+}
+function updateProgress(q) {
+ if (!q || !q.items) { $('qProg').hidden = true; return; }
+ const total = q.items.length;
+ const done = q.items.filter(it => it.status === 'done' || it.status === 'error' || it.status === 'skipped').length;
+ $('qProg').hidden = false;
+ $('qBarFill').style.width = (total ? Math.round(done / total * 100) : 0) + '%';
+ $('qProgText').textContent = (q.running ? '执行中 ' : '已结束 ') + done + ' / ' + total;
+}
+// 队列状态 → 分组(执行时轮询)
+function groupsFromQueue(items) {
+ const by = new Map();
+ for (const it of items) {
+ if (!by.has(it.uid)) by.set(it.uid, { uid: it.uid, nick: it.nickname, rows: [] });
+ by.get(it.uid).rows.push({
+ kind: it.kind, code: it.code,
+ prog: it.kind === 'school' ? '—' : '',
+ status: it.status, message: it.message,
+ });
+ }
+ return Array.from(by.values());
+}
+function startQueuePolling() {
+ if (queueTimer) clearInterval(queueTimer);
+ queueTimer = setInterval(async () => {
+ let q;
+ try { q = await api('tasks/queue'); } catch (e) { return; }
+ if (!q.started) return;
+ // 只渲染本页启动过的那轮队列(刷新页面后不再接管旧队列)。
+ if (lastQueueSeq && q.seq !== lastQueueSeq) return;
+ if (q.running) {
+ renderQueue(groupsFromQueue(q.items || []), q);
+ return;
+ }
+ // 结束:终态只渲染这一次,随即停表。此后残留的 items(running=false)不再
+ // 回写视图——曾把用户刚点开的「扫描待办」结果在下一个 tick 冲掉。
+ renderQueue(groupsFromQueue(q.items || []), q);
+ clearInterval(queueTimer); queueTimer = null;
+ toast('任务队列执行结束', 'ok');
+ }, 3000);
+}
+// reattachQueueView 切回任务中心视图时恢复队列进度:仅当本页启动的队列仍在
+// 执行才重新开轮询(残留态/别页队列不接管——视图不被旧结果冲掉)。
+function reattachQueueView() {
+ // 全程异步:go() 在顶层(app.js ~143 行)被调用时,本文件下方 let/const
+ //(queueTimer/lastQueueSeq 等)尚未初始化——同步读取即 TDZ ReferenceError
+ // 使整个脚本中断。await 之后才碰它们(旧 pollQueueOnce 正是靠开头的 await
+ // 侥幸安全)。queueTimer 的"已在跑"判定也挪到 await 后,语义不变。
+ (async () => {
+ try {
+ const q = await api('tasks/queue');
+ if (queueTimer) return; // 轮询已在跑(跨视图不中断)
+ if (q.started && q.running && (!lastQueueSeq || q.seq === lastQueueSeq)) startQueuePolling();
+ } catch (e) { /* 静默 */ }
+ })();
+}
+
+/* ── 用量 ─────────────────────────────────────────────────────────── */
+/* 图表用原生 SVG 手绘:面板是 go:embed 单文件、无构建步骤,引入图表库
+ 就得带上打包器,得不偿失。这里只需要堆叠柱状图,二十行足够。 */
+
+function fmtTok(n) {
+ n = Number(n || 0);
+ if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
+ if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
+ return String(n);
+}
+function fmtMs(ms) {
+ ms = Number(ms || 0);
+ if (!ms) return '—';
+ if (ms >= 1000) return (ms / 1000).toFixed(2) + 's';
+ return Math.round(ms) + 'ms';
+}
+function fmtRate(r) { return r ? Number(r).toFixed(1) + ' tok/s' : '—'; }
+function trimFixed(s) {
+ if (!String(s).includes('.')) return String(s);
+ return String(s).replace(/0+$/, '').replace(/\.$/, '');
+}
+function fmtCredit(n) {
+ const v = Number(n || 0);
+ if (!Number.isFinite(v)) return '—';
+ return trimFixed(v.toFixed(2));
+}
+function fmtCreditRatio(v, samples, tokens) {
+ if (!samples || !tokens) return '—';
+ const n = Number(v || 0);
+ if (!Number.isFinite(n)) return '—';
+ return trimFixed(n.toFixed(4)) + ' / 1M';
+}
+function fmtModelRate(rate) {
+ const s = String(rate || '').trim();
+ return s ? 'x' + s : '—';
+}
+
+function usStat(v, k, cls) {
+ return '' + esc(v) +
+ '
' + esc(k) + '
';
+}
+
+/* usKpi 用量页的指标卡。比账号池的 .stat 多两样:语义色轨(cls)与副标题(sub,
+ 放"占比 / 均速率"这类解释性数字);bar 是卡片内的构成条 HTML,只有需要时才传。 */
+function usKpi(v, k, cls, sub, bar) {
+ return '' +
+ '
' + esc(k) + '
' +
+ '
' + esc(v) + '
' +
+ (bar || '') +
+ (sub ? '
' + esc(sub) + '
' : '') +
+ '
';
+}
+
+/* usMixBar prompt/completion 占比条。宽度按百分比而不是固定像素:表列宽随窗口变化,
+ 像素宽度在窄屏会溢出、宽屏又显得没信息。 */
+function usMixBar(prompt, completion, total) {
+ const t = Number(total || 0);
+ if (!t) return '';
+ const pp = Math.max(0, Math.min(100, Number(prompt || 0) / t * 100));
+ const pc = Math.max(0, Math.min(100, Number(completion || 0) / t * 100));
+ return '' +
+ '' +
+ '' +
+ '';
+}
+
+/* usPct 占比文案(0 值不显示 "0.0%",直接 —,避免一行全是零)。 */
+function usPct(part, total) {
+ const t = Number(total || 0);
+ if (!t) return '—';
+ return (Number(part || 0) / t * 100).toFixed(1) + '%';
+}
+
+/* usRow 生成一行。mid 是插在「名称」之后、请求数之前的额外单元格(如「域」列)。
+ withPerf 控制延迟/速率两列;列开关显式传入,避免调用方改动后与表头错列。 */
+function usRow(name, sub, a, mid, withPerf) {
+ return '' +
+ ' | ' +
+ '' + esc(name) + (sub ? ' ' + esc(sub) + ' ' : '') + ' | ' +
+ (mid || '') +
+ '' + fmtTok(a.requests) + ' | ' +
+ '' + (a.errors ? '' + fmtTok(a.errors) + '' : '—') + ' | ' +
+ '' + fmtTok(a.prompt_tokens) + ' | ' +
+ '' + fmtTok(a.completion_tokens) + ' | ' +
+ '' + fmtTok(a.total_tokens) +
+ usMixBar(a.prompt_tokens, a.completion_tokens, a.total_tokens) + ' | ' +
+ (withPerf
+ ? '' + fmtMs(a.avg_latency_ms) + ' | ' +
+ '' + fmtRate(a.avg_tokens_per_second) + ' | '
+ : '') +
+ '
';
+}
+
+/* ── 用量明细:三个维度共用一张表 + 页内切换 ──────────────────────────
+ 账号 / 模型 / 域三张表此前各自占一个 box,页面纵向拉得很长且表头结构几乎一样。
+ 现在合成一个 box:表头由维度定义生成,行渲染复用 usRow,切换零请求。 */
+const US_DIMS = {
+ account: { key: 'by_account', title: '账号', withRealm: true, withPerf: true, span: 10 },
+ model: { key: 'by_model', title: '模型', withRealm: false, withPerf: false, span: 7 },
+ realm: { key: 'by_realm', title: 'realm', withRealm: false, withPerf: false, span: 7 },
+};
+
+// usSortRows 按当前排序字段降序(默认合计 Token,最大者最相关)。
+function usSortRows(rows, sort) {
+ const out = rows.slice();
+ const num = v => { const n = Number(v || 0); return Number.isFinite(n) ? n : 0; };
+ const val = a => sort === 'requests' ? num(a.requests)
+ : sort === 'errors' ? num(a.errors)
+ : sort === 'latency' ? num(a.avg_latency_ms)
+ : num(a.total_tokens);
+ out.sort((a, b) => val(b) - val(a));
+ return out;
+}
+
+function usDimHead(dim) {
+ const m = US_DIMS[dim] || US_DIMS.account;
+ return ' | ' + esc(m.title) + ' | ' +
+ (m.withRealm ? '域 | ' : '') +
+ '请求 | 失败 | ' +
+ 'Prompt | Completion | 合计 | ' +
+ (m.withPerf ? '均延迟 | 均速率 | ' : '') +
+ '
';
+}
+
+/* usTabsHtml 维度切换按钮(带条数徽标)。整段 innerHTML 重写而不是逐个改 class:
+ 容器的 click 监听是委托式的,换掉子节点不会丢事件,代码也更短。 */
+function usTabsHtml(dims, active, counts) {
+ return dims.map(([k, label]) => {
+ const n = counts ? counts[k] : null;
+ return '';
+ }).join('');
+}
+
+const US_DIM_TABS = [['account', '按账号'], ['model', '按模型'], ['realm', '按域']];
+
+function renderUsageDim() {
+ const d = usageData || {};
+ const m = US_DIMS[usDim] || US_DIMS.account;
+ const rows = usSortRows(d[m.key] || [], usSort);
+ $('usDimTabs').innerHTML = usTabsHtml(US_DIM_TABS, usDim, {
+ account: (d.by_account || []).length,
+ model: (d.by_model || []).length,
+ realm: (d.by_realm || []).length,
+ });
+ $('usDimHead').innerHTML = usDimHead(usDim);
+ $('usDimBody').innerHTML = rows.map(x =>
+ usRow(usDim === 'account' ? String(x.key || '').slice(0, 8) : x.key,
+ usDim === 'account' ? (x.extra || '') : '',
+ x,
+ usDim === 'account' ? '' + esc(x.realm || '') + ' | ' : '',
+ m.withPerf)
+ ).join('') || '| 暂无数据 |
';
+ $('usDimNote').textContent = rows.length + ' 行 · 请求数含失败尝试';
+}
+
+/* 积分扣除:按账号 / 按模型两个维度共用一张表(同上,两个 box 合成一个)。 */
+function usCreditHead(dim) {
+ return dim === 'model'
+ ? ' | 模型 | 积分倍率 | ' +
+ '请求 | 扣除积分 | ' +
+ '有效样本 Token | 积分 / 1M Token | 缓存命中率 |
'
+ : ' | 账号 | ' +
+ '请求 | 扣除积分 | ' +
+ '有效样本 Token | 积分 / 1M Token | 缓存命中率 |
';
+}
+
+/* 缓存命中率(issue #92):颜色即健康度——≥90% 绿 / 80–90% 黄 / <80% 红,
+ 样本不足灰。title 带命中/未命中绝对量,供逐项核对。 */
+function cacheRateCell(hit, miss) {
+ const h = Number(hit || 0), m = Number(miss || 0), total = h + m;
+ if (!total) return '—';
+ const pct = h / total * 100;
+ const color = pct >= 90 ? 'var(--ok)' : (pct >= 80 ? 'var(--warn)' : 'var(--bad)');
+ const txt = trimFixed(pct.toFixed(1)) + '%';
+ return '' + txt + '';
+}
+
+function renderCreditDim() {
+ const d = usageData || {};
+ $('usCreditHead').innerHTML = usCreditHead(usCreditDim);
+ const empty = '暂无积分扣除记录;升级前仅含 Token 的历史不会伪造积分。';
+ if (usCreditDim === 'model') {
+ const models = d.credit_by_model || [];
+ $('usCreditBody').innerHTML = models.map(row =>
+ '' +
+ ' | ' +
+ '' + esc(row.key || '—') + ' | ' +
+ '' + esc(fmtModelRate(row.rate)) + ' | ' +
+ '' + fmtTok(row.requests) + ' | ' +
+ '' + fmtCredit(row.credits) + ' | ' +
+ '' + fmtTok(row.credit_tokens) + ' | ' +
+ '' + fmtCreditRatio(row.credits_per_1m_tokens, row.credit_samples, row.credit_tokens) + ' | ' +
+ '' + cacheRateCell(row.cache_hit_tokens, row.cache_miss_tokens) + ' | ' +
+ '
'
+ ).join('') || '| ' + empty + ' |
';
+ } else {
+ const accounts = d.credit_by_account || [];
+ $('usCreditBody').innerHTML = accounts.map(row => {
+ const uid = String(row.key || '');
+ const account = row.nickname || uid.slice(0, 8) || '—';
+ return '' +
+ ' | ' +
+ '' + esc(account) + ' ' + esc(row.realm || '') + ' · ' + esc(uid.slice(0, 8)) + ' | ' +
+ '' + fmtTok(row.requests) + ' | ' +
+ '' + fmtCredit(row.credits) + ' | ' +
+ '' + fmtTok(row.credit_tokens) + ' | ' +
+ '' + fmtCreditRatio(row.credits_per_1m_tokens, row.credit_samples, row.credit_tokens) + ' | ' +
+ '' + cacheRateCell(row.cache_hit_tokens, row.cache_miss_tokens) + ' | ' +
+ '
';
+ }).join('') || '| ' + empty + ' |
';
+ }
+ $('usCreditTabs').innerHTML = usTabsHtml([['account', '按账号'], ['model', '按模型']], usCreditDim, {
+ account: (d.credit_by_account || []).length,
+ model: (d.credit_by_model || []).length,
+ });
+}
+
+function renderUsage(d) {
+ usageData = d || {};
+ const t = usageData.totals || {};
+ const total = Number(t.total_tokens || 0);
+ const pt = Number(t.prompt_tokens || 0);
+ const ct = Number(t.completion_tokens || 0);
+ const reqs = Number(t.requests || 0);
+ const errs = Number(t.errors || 0);
+ const okRate = reqs ? (reqs - errs) / reqs * 100 : null;
+ // 构成条要的是合法 CSS 宽度,usPct 在无样本时返回 "—",不能直接拼进 style。
+ const pctW = (part) => total ? Math.max(0, Math.min(100, Number(part || 0) / total * 100)).toFixed(2) + '%' : '0%';
+ // 六张卡:主指标用强调色,completion 用成功色(与图表里的绿柱呼应),
+ // 失败/延迟只在有值时上语义色——全绿全黄的仪表盘等于没有重点。
+ $('usStats').innerHTML =
+ usKpi(fmtTok(reqs), '请求数', 'c-accent',
+ errs ? '其中失败 ' + errs + ' 次' : '全部成功') +
+ usKpi(fmtTok(total), '总 token', 'c-accent',
+ 'prompt ' + usPct(pt, total) + ' · completion ' + usPct(ct, total),
+ '' +
+ '
') +
+ usKpi(fmtTok(pt), 'prompt', 'c-soft', '占比 ' + usPct(pt, total)) +
+ usKpi(fmtTok(ct), 'completion', 'c-ok', '占比 ' + usPct(ct, total)) +
+ usKpi(String(errs), '失败尝试', errs ? 'c-warn' : 'c-mute',
+ okRate == null ? '—' : (errs ? '成功率 ' + okRate.toFixed(1) + '%' : '成功率 100%')) +
+ usKpi(fmtMs(t.avg_latency_ms), '平均延迟', 'c-soft',
+ t.avg_tokens_per_second ? '吐字 ' + fmtRate(t.avg_tokens_per_second) : '无速率样本');
+
+ // 卡片、明细表与时序图全部按所选窗口统计(切窗口数字随之变化);
+ // 「全部历史」含 90 天前折叠出的日桶。这里标注当前口径与数据起点。
+ const winLabel = trangeLabel('usRange');
+ // 服务端回显的实际区间优先(自定义区间下它就是权威口径);滚动窗口没有回显,
+ // 用控件自己的标签。
+ const rangeEcho = usageData.window_from
+ ? String(usageData.window_from).replace('T', ' ').slice(0, 16) +
+ (usageData.window_to ? ' → ' + String(usageData.window_to).replace('T', ' ').slice(0, 16) : ' → 现在')
+ : '';
+ const note = (rangeEcho || winLabel ? (rangeEcho || winLabel) + ' · ' : '') +
+ (usageData.buckets || 0) + ' 个分桶' +
+ (usageData.since ? ' · 数据自 ' + usageData.since.replace('T', ' ') : '') +
+ (usageData.file_bytes ? ' · 文件 ' + (usageData.file_bytes / 1024).toFixed(1) + ' KB' : '');
+ $('usNote').textContent = note;
+ $('usNote').title = note; // 窄屏单行截断时靠悬停看全
+
+ // 积分扣除的四张卡片与说明。
+ $('usCreditStats').innerHTML =
+ usKpi(fmtCredit(t.credits), '扣除积分', 'c-accent', '按上游 usage.credit 累计') +
+ usKpi(fmtTok(t.credit_tokens), '匹配 Token', 'c-mute', '与积分同时观测到的 Token') +
+ usKpi(fmtCreditRatio(t.credits_per_1m_tokens, t.credit_samples, t.credit_tokens),
+ '平均积分 / 1M Token', 'c-ok', '越低越划算') +
+ usKpi(String(t.credit_samples || 0), '有效积分样本', 'c-mute', '缺字段的历史不参与折算') +
+ usKpi(cacheRateText(t.cache_hit_tokens, t.cache_miss_tokens), '缓存命中率', 'c-mute',
+ '上游前缀缓存命中 / (命中+未命中);低命中意味着费用数倍放大');
+ $('usCreditNote').textContent =
+ (usageData.credit_by_account || []).length + ' 个账号 · ' +
+ (usageData.credit_by_model || []).length + ' 个模型倍率分组 · 仅统计与积分同时观测到的 Token';
+
+ renderUsageDim();
+ renderCreditDim();
+ renderUsageChart(usageData.series || []);
+}
+
+// 维度切换 / 排序控件。
+if ($('usDimTabs')) $('usDimTabs').addEventListener('click', ev => {
+ const b = ev.target.closest('button[data-dim]');
+ if (!b) return;
+ usDim = b.dataset.dim;
+ renderUsageDim();
+});
+if ($('usCreditTabs')) $('usCreditTabs').addEventListener('click', ev => {
+ const b = ev.target.closest('button[data-dim]');
+ if (!b) return;
+ usCreditDim = b.dataset.dim;
+ renderCreditDim();
+});
+if ($('usSort')) $('usSort').onchange = () => {
+ usSort = $('usSort').value;
+ renderUsageDim();
+};
+
+/* renderUsageChart 画堆叠柱状图。
+ *
+ * x 轴是**真实时间轴**,不是按序号等距。这一点很重要:数据里存在 1 小时的
+ * 间隔,也存在 6~8 小时的断档(没请求的时段不产生桶),等距排布会把 8 小时
+ * 画得和 1 小时一样宽,让「什么时候用的」完全失真。
+ *
+ * 另外不再用 preserveAspectRatio="none":那会把 viewBox 横向拉伸到容器宽度,
+ * 柱子和文字都变形。改为固定比例、按容器宽度自适应高度。
+ *
+ * viewBox 取 1200×200(原 760×180):SVG 以 width:100% 渲染,高宽比决定实际
+ * 高度——旧比例在 1500px 宽的主区里会撑到 ~355px,只有一两根柱子时整块几乎是
+ * 空白。宽 viewBox 把同宽度下的高度压到 ~250px,与下方表格的视觉重量相当。
+ *
+ * 时间轴用本地时间解析(后端返回的就是本地时区),day 点按当天 00:00 参与定位,
+ * 与 hour 点在同一个连续轴上——日桶本来就是他那天所有小时的聚合。
+ */
+
+/* parsePointTime 把后端的 t 解析成毫秒时间戳。 */
+function parsePointTime(p) {
+ // hour: "2026-09-16T13" day: "2026-09-16"
+ const s = p.t.length === 13 ? p.t + ':00:00' : p.t + 'T00:00:00';
+ const d = new Date(s);
+ return isNaN(d.getTime()) ? null : d.getTime();
+}
+
+/* fmtTokTimeLabel 时间桶的短标签,与 x 轴刻度同一口径(日桶 MM-DD,小时桶 HH:00)。 */
+function fmtTokTimeLabel(p) {
+ const d = new Date(p.t);
+ return p.scope === 'day'
+ ? (d.getMonth() + 1) + '-' + String(d.getDate()).padStart(2, '0')
+ : String(d.getHours()).padStart(2, '0') + ':00';
+}
+
+function renderUsageChart(series) {
+ const host = $('usChart');
+
+ // 丢掉时间解析不出来的点,而不是让 NaN 传染整张图。
+ const pts = [];
+ for (const p of series) {
+ const t = parsePointTime(p);
+ if (t === null) continue;
+ const pt = Number(p.prompt_tokens || 0);
+ const ct = Number(p.completion_tokens || 0);
+ pts.push({ t, scope: p.scope, raw: p.t, pt, ct, tt: Number(p.total_tokens || 0) || (pt + ct),
+ req: p.requests || 0 });
+ }
+ if (!pts.length) {
+ host.innerHTML = '暂无用量数据。发起一次对话后再刷新。
';
+ $('usChartNote').textContent = '—';
+ return;
+ }
+
+ const W = 1200, H = 200, PL = 58, PR = 14, PT = 18, PB = 30;
+ const iw = W - PL - PR, ih = H - PT - PB;
+
+ const t0 = pts[0].t;
+ const t1 = pts[pts.length - 1].t;
+ const span = Math.max(1, t1 - t0);
+
+ const max = Math.max(1, ...pts.map(p => p.tt));
+ const peak = pts.reduce((a, b) => (b.tt > a.tt ? b : a), pts[0]);
+ const avg = pts.reduce((s, p) => s + p.tt, 0) / pts.length;
+ $('usChartNote').textContent =
+ pts.length + ' 个点 · 峰值 ' + fmtTok(peak.tt) + ' @ ' + fmtTokTimeLabel(peak) +
+ ' · 均值 ' + fmtTok(avg);
+
+ // 柱宽取「最小真实间隔」的 70%,并夹在合理区间内——窗口拉到 30 天时柱子会
+ // 变细,但不会细到看不见。
+ let minGap = Infinity;
+ for (let i = 1; i < pts.length; i++) minGap = Math.min(minGap, pts[i].t - pts[i - 1].t);
+ if (!isFinite(minGap) || minGap <= 0) minGap = span;
+ const slot = iw * (minGap / span);
+ const bw = Math.max(2, Math.min(30, slot * 0.7));
+
+ // 首尾各让出半个柱宽:否则第一个点和最后一个点的柱子会各有一半跑到绘图区外
+ // (末点柱子贴着卡片右边缘被切掉),刻度仍用同一个 xOf,标签与柱子始终对齐。
+ const xOf = t => PL + bw / 2 + (t - t0) / span * Math.max(1, iw - bw);
+ const yOf = v => PT + ih - ih * (v / max);
+
+ let out = '';
+ host.innerHTML = out;
+}
+
+function fmtTokTip(v) { return fmtTok(v); }
+
+let usageRateWarmAt = 0;
+async function warmUsageModelRates() {
+ if (Date.now() - usageRateWarmAt < 10 * 60 * 1000) return;
+ try {
+ await api('models');
+ } catch (e) {
+ // 倍率回填是可选增强;失败不阻塞用量统计,10 分钟后再试。
+ }
+ usageRateWarmAt = Date.now();
+}
+
+async function loadUsage() {
+ const q = trangeQuery('usRange', true);
+ try {
+ await warmUsageModelRates();
+ const d = await api('usage?' + q.toString());
+ renderUsage(d);
+ } catch (e) {
+ // 失败时三块都要清干净:只改图表会留下上一次窗口的数字,看起来像"刷新成功"。
+ usageData = null;
+ $('usChart').innerHTML = '读取用量失败:' + esc(e.message) + '
';
+ $('usChartNote').textContent = '—';
+ $('usStats').innerHTML = '';
+ $('usCreditStats').innerHTML = '';
+ $('usNote').textContent = '—';
+ $('usCreditNote').textContent = '—';
+ $('usDimNote').textContent = '—';
+ $('usDimHead').innerHTML = '';
+ $('usCreditHead').innerHTML = '';
+ $('usDimTabs').innerHTML = usTabsHtml(US_DIM_TABS, usDim, null);
+ $('usCreditTabs').innerHTML = usTabsHtml([['account', '按账号'], ['model', '按模型']], usCreditDim, null);
+ $('usDimBody').innerHTML = '| 读取用量失败 |
';
+ $('usCreditBody').innerHTML = '| 读取用量失败 |
';
+ }
+}
+
+if ($('btnUsage')) $('btnUsage').onclick = loadUsage;
+// 时间范围控件绑定:任何改动(预设切换 / 自定义起止)都重新拉一次用量。
+if ($('usRange')) trangeBind('usRange', loadUsage);
+
+/* ── 积分构成 ─────────────────────────────────────────────────────── */
+/* 一个账号的余额是若干积分包之和。包按来源命名(「国内运营裂变包」「拉新权益包」
+ 「个人体验版」…),面额从 6 到 1500 不等,且**按次发放**。所以两个任务完成度
+ 完全一致的账号,余额可能差上千——差别只在包里。这里把逐包明细摊开,并给每个
+ 包名一个稳定配色,跨账号对比时同色即同类。 */
+
+const PK_COLORS = ['#4f8cff', '#25b08b', '#e8a33d', '#c96bd6', '#e2607a',
+ '#5aa9e6', '#8fbf3f', '#b58b5a', '#7d8fa8', '#d4785c'];
+const PK_ACCOUNT_COLORS = ['#4f8cff', '#25b08b', '#e8a33d', '#c96bd6',
+ '#e2607a', '#20a4a4', '#8fbf3f', '#d4785c',
+ '#7c83db', '#c48a2f', '#b45f8c', '#5aa9e6'];
+
+function pkColor(i) { return PK_COLORS[i % PK_COLORS.length]; }
+
+// pkAccountColorMap 按 UID 稳定分配颜色:排序后分配,账号刷新/重排不会换色。
+function pkAccountColorMap(list) {
+ const uids = (list || [])
+ .filter(a => a && !a.error && a.uid)
+ .map(a => String(a.uid))
+ .sort();
+ const colors = new Map();
+ uids.forEach((uid, i) => colors.set(uid, PK_ACCOUNT_COLORS[i % PK_ACCOUNT_COLORS.length]));
+ return colors;
+}
+
+/* pkBySource 把包按名称归并,得到「来源 → 面额/余额/个数」。这是对比的关键视图:
+ 两个号的差异一定体现在某几个来源的面额上。 */
+function pkBySource(packs) {
+ const m = new Map();
+ for (const p of packs) {
+ // 分组键用 code + name,而不是只 name:上游给「首登赠送」和普通活动包用了
+ // **同一个 PackageName 和同一个 PackageCode**,只按 name 会把两类混成一类,
+ // 那正是当初「两个号为何差 1500」看不出来的原因。这里至少把 code 带进键里,
+ // 并在卡片上显示最早的发放时间。
+ const k = (p.package_code || '') + '|' + (p.name || '(未命名)');
+ const e = m.get(k) || {
+ key: k, name: p.name || '(未命名)', code: p.package_code || '',
+ n: 0, remain: 0, size: 0, used: 0, minEnd: '', minCreated: '',
+ };
+ e.n += 1;
+ e.remain += Number(p.remain || 0);
+ e.size += Number(p.size || 0);
+ e.used += Number(p.used || 0);
+ const t = (p.end_time || '').slice(0, 10);
+ if (t && (!e.minEnd || t < e.minEnd)) e.minEnd = t;
+ const c = (p.created_at || '').slice(0, 10);
+ if (c && (!e.minCreated || c < e.minCreated)) e.minCreated = c;
+ m.set(k, e);
+ }
+ return [...m.values()].sort((a, b) => b.size - a.size);
+}
+
+const PK_DEFAULT_DETAIL_LIMIT = 5;
+
+function pkDetailLimitValue(raw) {
+ const n = Number(raw);
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : PK_DEFAULT_DETAIL_LIMIT;
+}
+
+function pkDetailLimit(cfg) {
+ return pkDetailLimitValue(cfg && cfg.panel && cfg.panel.package_detail_limit);
+}
+
+const PK_DAY_MS = 24 * 3600 * 1000;
+
+function pkExpiryMs(p) {
+ const raw = Number(p && p.expires_at);
+ if (Number.isFinite(raw) && raw > 0) return raw;
+ const text = String((p && p.end_time) || '').trim();
+ if (!text) return null;
+ let iso = text.includes('T') ? text : text.replace(' ', 'T');
+ if (!/(?:Z|[+-]\d\d:\d\d)$/.test(iso)) iso += '+08:00';
+ const parsed = Date.parse(iso);
+ return Number.isFinite(parsed) ? parsed : null;
+}
+
+// pkDetailGroups 只服务单账号逐包明细:正余额包先按到期时间挑选默认展示项,
+// 其余正余额包与已用完包分别折叠;同一到期时间按面额降序。
+function pkDetailCompare(a, b) {
+ const sizeOf = p => {
+ const n = Number(p && p.size);
+ return Number.isFinite(n) ? n : 0;
+ };
+ const ea = pkExpiryMs(a), eb = pkExpiryMs(b);
+ if (ea == null && eb != null) return 1;
+ if (ea != null && eb == null) return -1;
+ if (ea != null && eb != null && ea !== eb) return ea - eb;
+ return sizeOf(b) - sizeOf(a);
+}
+
+function pkDetailGroups(packs, limit) {
+ const active = [], used = [];
+ let usedSize = 0, restSize = 0, restRemain = 0;
+ for (const p of packs || []) {
+ const remain = Number(p && p.remain);
+ if (remain > 0) {
+ active.push(p);
+ continue;
+ }
+ used.push(p);
+ const size = Number(p && p.size);
+ if (Number.isFinite(size)) usedSize += size;
+ }
+ active.sort(pkDetailCompare);
+ used.sort(pkDetailCompare);
+ const visible = active.slice(0, pkDetailLimitValue(limit));
+ const rest = active.slice(visible.length);
+ for (const p of rest) {
+ const size = Number(p && p.size);
+ if (Number.isFinite(size)) restSize += size;
+ const remain = Number(p && p.remain);
+ if (Number.isFinite(remain)) restRemain += remain;
+ }
+ return { visible, rest, used, restSize, restRemain, usedSize };
+}
+
+function pkCreditOpacity(days) {
+ if (days == null || !Number.isFinite(Number(days))) return 1;
+ return 0.25 + 0.75 * Math.max(0, Math.min(29, Number(days) - 1)) / 29;
+}
+
+function pkExpiryText(expiresAt) {
+ if (!expiresAt) return '无到期时间';
+ const diff = expiresAt - Date.now();
+ if (diff <= 0) return '已到期';
+ const minutes = Math.max(1, Math.ceil(diff / 60000));
+ if (minutes < 60) return '剩余 ' + minutes + ' 分钟';
+ const hours = Math.ceil(diff / 3600000);
+ if (hours < 24) return '剩余 ' + hours + ' 小时';
+ return '剩余 ' + Math.ceil(diff / PK_DAY_MS) + ' 天';
+}
+
+function pkExpiryDateTime(expiresAt) {
+ if (!expiresAt) return '—';
+ return new Date(expiresAt).toLocaleString('zh-CN', {
+ timeZone: 'Asia/Shanghai', hour12: false,
+ year: 'numeric', month: '2-digit', day: '2-digit',
+ hour: '2-digit', minute: '2-digit', second: '2-digit',
+ });
+}
+
+function pkAccountSegments(a, now) {
+ let balance = Math.max(0, Number(a.remain || 0));
+ const out = [];
+ for (const p of a.packages || []) {
+ const remain = Number(p.remain || 0);
+ if (!Number.isFinite(remain) || remain <= 0 || balance <= 0) continue;
+ const amount = Math.min(balance, remain);
+ const expiresAt = pkExpiryMs(p);
+ out.push({
+ amount,
+ expiresAt,
+ days: expiresAt == null ? null : Math.max(0, Math.ceil((expiresAt - now) / PK_DAY_MS)),
+ source: p.name || '积分',
+ uid: String(a.uid || ''),
+ accountName: a.nickname || String(a.uid || '').slice(0, 8) || '未命名账号',
+ });
+ balance -= amount;
+ }
+ return out.sort((x, y) => {
+ if (x.expiresAt == null && y.expiresAt != null) return 1;
+ if (x.expiresAt != null && y.expiresAt == null) return -1;
+ return (x.expiresAt || 0) - (y.expiresAt || 0);
+ });
+}
+
+// summarizeCreditDays 对齐 WorkDaddy:按精确剩余天数逐行聚合,无有效到期时间的余额
+// 不进入图表,也不猜测到期日。账号内先按总余额约束逐包金额,避免上游重复记录膨胀。
+function summarizeCreditDays(list, now) {
+ const buckets = new Map();
+ let unavailable = 0;
+ for (const a of list || []) {
+ if (a.error || !Number.isFinite(Number(a.remain))) {
+ unavailable++;
+ continue;
+ }
+ for (const segment of pkAccountSegments(a, now)) {
+ if (segment.days == null) continue;
+ let row = buckets.get(segment.days);
+ if (!row) {
+ row = { days: segment.days, credits: 0, segments: [] };
+ buckets.set(segment.days, row);
+ }
+ row.credits += segment.amount;
+ row.segments.push(segment);
+ }
+ }
+ const rows = [...buckets.values()].sort((a, b) => a.days - b.days);
+ for (const row of rows) {
+ row.segments.sort((a, b) =>
+ (a.expiresAt || Infinity) - (b.expiresAt || Infinity) ||
+ a.accountName.localeCompare(b.accountName) ||
+ a.source.localeCompare(b.source));
+ }
+ return { rows, accountCount: (list || []).length, unavailable };
+}
+
+function renderExpiryDistribution(list, now) {
+ const summary = summarizeCreditDays(list, now);
+ const colors = pkAccountColorMap(list);
+ const rows = summary.rows.map(row => {
+ const total = row.credits || 1;
+ const nodes = row.segments.map(segment => {
+ const color = colors.get(segment.uid) || 'var(--accent)';
+ const title = segment.source + '\n' + fmtTok(segment.amount) + ' 积分\n到期时间 ' +
+ pkExpiryDateTime(segment.expiresAt) + '(' + pkExpiryText(segment.expiresAt) + ')\n' +
+ segment.accountName;
+ return '';
+ }).join('');
+ return '' + esc(row.days === 0 ? '已到期' : row.days + ' 天') +
+ '' + nodes + '
' + esc(fmtTok(row.credits)) +
+ ' ';
+ }).join('');
+ const foot = summary.accountCount + ' 个账号' +
+ (summary.unavailable ? ' · ' + summary.unavailable + ' 个未获取余额' : '');
+ const legend = (list || []).filter(a =>
+ a && !a.error && a.uid && pkAccountSegments(a, now).some(s => s.days != null)
+ ).map(a => '' + esc(a.nickname || String(a.uid).slice(0, 8)) + '').join('');
+ const hdr = '剩余天数各账号该批剩余剩余积分
';
+ $('pkExpiry').innerHTML = (rows
+ ? hdr + '' + rows + '
'
+ : '暂无可汇总积分
') +
+ (legend ? '' + legend + '
' : '') +
+ '';
+}
+
+function renderPackages(d, detailLimit) {
+ const list = (d.accounts || []);
+ const now = Date.now();
+ const expiryColors = pkAccountColorMap(list);
+ renderExpiryDistribution(list, now);
+ if (!list.length) {
+ $('pkSummary').innerHTML = '没有账号
';
+ return;
+ }
+
+ // 包名 → 稳定色号(跨账号一致,方便肉眼对齐)
+ const names = [];
+ for (const a of list) for (const s of pkBySource(a.packages || [])) {
+ if (!names.includes(s.key)) names.push(s.key);
+ }
+ names.sort((x, y) => {
+ const sz = n => Math.max(...list.map(a => {
+ const f = pkBySource(a.packages || []).find(s => s.key === n);
+ return f ? f.size : 0;
+ }));
+ return sz(y) - sz(x);
+ });
+ const colorOf = n => pkColor(names.indexOf(n));
+ // 键 → 展示名,供卡片与明细表共用(同一来源必然同色同名)。
+ const labelOf = {};
+ for (const a of list) for (const s of pkBySource(a.packages || [])) labelOf[s.key] = s;
+
+ const maxRemain = Math.max(1, ...list.map(a => Number(a.remain || 0)));
+
+ $('pkSummary').innerHTML = list.map(a => {
+ if (a.error) {
+ return '' +
+ esc((a.nickname || a.uid.slice(0, 8))) + '' +
+ '' + esc(a.realm || '') + '
' +
+ '
查询失败:' + esc(a.error) + '
';
+ }
+ const srcs = pkBySource(a.packages || []);
+ const total = Math.max(1, Number(a.size || 0));
+ const bar = srcs.map(s =>
+ ''
+ ).join('');
+ const legend = srcs.map(s =>
+ '' +
+ esc(s.name.replace(/^CodeBuddy/, '')) + ' x' + s.n + ' · ' + fmtTok(s.size) +
+ (s.minCreated ? ' · 首发 ' + esc(s.minCreated.slice(5)) : '') + ''
+ ).join('');
+ const expiry = pkAccountSegments(a, now);
+ const expiryTotal = Math.max(1, expiry.reduce((sum, s) => sum + s.amount, 0));
+ const expiryColor = expiryColors.get(String(a.uid)) || 'var(--accent)';
+ const expiryBar = expiry.length ? '' +
+ expiry.map(s => {
+ const title = s.source + '\n' + fmtTok(s.amount) + ' 积分\n到期时间 ' +
+ pkExpiryDateTime(s.expiresAt) + '(' + pkExpiryText(s.expiresAt) + ')';
+ return '';
+ }).join('') + '
' : '';
+ return '' +
+ '
' + esc(a.nickname || a.uid.slice(0, 8)) + '' +
+ '' + esc(a.realm || '') + '
' +
+ '
' + fmtTok(a.remain) + '
' +
+ '
共 ' + fmtTok(a.size) + ' · ' + (a.packages || []).length +
+ ' 个包 · 占最高 ' + (Number(a.remain || 0) / maxRemain * 100).toFixed(0) + '%
' +
+ '
' + bar + '
' +
+ expiryBar +
+ '
' + legend + '
' +
+ '
';
+ }).join('');
+
+ $('pkNote').textContent = list.length + ' 个账号 · 实时查询上游';
+
+ // 逐包明细:每个账号一个表,包的**面额**列是重点
+ $('pkDetail').innerHTML = list.map(a => {
+ if (a.error) return '';
+ const groups = pkDetailGroups(a.packages || [], detailLimit);
+ const rowOf = (p, rowGroup) => {
+ const k = (p.package_code || '') + '|' + (p.name || '(未命名)');
+ const sub = (p.sub_product_code || '').replace(/^sp_tcaca_codebuddyide_?/, '') ||
+ (p.package_code || '').replace(/^TCACA_/, '');
+ return ' | ' +
+ '' + esc(p.name || '(未命名)') +
+ (sub ? ' ' + esc(sub) + ' ' : '') + ' | ' +
+ '' + fmtTok(p.size) + ' | ' +
+ '' + fmtTok(p.remain) + ' | ' +
+ '' + fmtTok(p.used) + ' | ' +
+ '' + esc((p.created_at || '').slice(0, 16).replace('T', ' ') || '—') + ' | ' +
+ '' + esc((p.end_time || '').slice(0, 10) || '—') + ' | ' +
+ '
';
+ };
+ const groupSummary = (group, label, count, size, remain) =>
+ ' |
';
+ const rows = groups.visible.map(p => rowOf(p, '')).join('');
+ const restSummary = groups.rest.length
+ ? groupSummary('rest', '其余未用完 ' + groups.rest.length + ' 个包(面额合计 ' +
+ fmtTok(groups.restSize) + ' · 剩余 ' + fmtTok(groups.restRemain) + ')',
+ groups.rest.length, groups.restSize, groups.restRemain) +
+ groups.rest.map(p => rowOf(p, 'rest')).join('')
+ : '';
+ const usedSummary = groups.used.length
+ ? groupSummary('used', '已用完 ' + groups.used.length + ' 个包(面额合计 ' +
+ fmtTok(groups.usedSize) + ')', groups.used.length, groups.usedSize, 0) +
+ groups.used.map(p => rowOf(p, 'used')).join('')
+ : '';
+ return '' +
+ esc(a.nickname || a.uid.slice(0, 8)) + ' · ' + esc(a.realm || '') +
+ '
余额 ' + fmtTok(a.remain) +
+ ' / 总额 ' + fmtTok(a.size) + ' · 可用 ' + (groups.visible.length + groups.rest.length) + ' 个包' +
+ (groups.used.length ? ' / 已用完 ' + groups.used.length + ' 个' : '') +
+ ' · 默认展示最早到期 ' + pkDetailLimitValue(detailLimit) + ' 条' +
+ '' +
+ ' | 包名 / 来源 | ' +
+ '面额 | 剩余 | 已用 | ' +
+ '发放 | 到期 | ' +
+ '
' + 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 + ' 个包';
+ }
+});
+
+async function loadPackages() {
+ $('pkSummary').innerHTML = '查询中…(逐账号向上游实时查询)
';
+ $('pkDetail').innerHTML = '';
+ $('pkExpiry').innerHTML = '查询中…
';
+ try {
+ const [d, c] = await Promise.all([
+ api('packages'),
+ api('config').catch(() => null),
+ ]);
+ renderPackages(d, pkDetailLimit(c && c.config));
+ } catch (e) {
+ $('pkSummary').innerHTML = '读取失败:' + esc(e.message) + '
';
+ $('pkExpiry').innerHTML = '读取失败:' + esc(e.message) + '
';
+ }
+}
+
+if ($('btnPk')) $('btnPk').onclick = loadPackages;
diff --git a/internal/panel/autotask.go b/internal/panel/autotask.go
new file mode 100644
index 0000000000000000000000000000000000000000..bfe1a2f80cab2d1521ae32578f0f368c8a80713d
--- /dev/null
+++ b/internal/panel/autotask.go
@@ -0,0 +1,1265 @@
+// autotask.go 面板「一键完成」任务的动作实现。
+//
+// 设计依据:上游 scripts/task_*.py 实测结论 + 2026-09-12 桌面指纹协议逆向
+// (data/desktop-task-protocol.md)——
+// - first_buddy(+300 分):report(前置解锁)→ agreement → buddy/first
+// - chat_5(+100 分):累计 5 条 chat_request_send 上报
+// - Model_chat_GLM5.2(+100 分):accept → 用 glm-5.2 真实对话一次 → 上报(模型字段对齐)
+// - RichMeow_Chat:桌面指纹(workbuddy-desktop)完整对话事件链,纯 API 可点亮(三账号实测)
+// - Buddy_App / Buddy_App_QQ:buddyapp 五连事件,纯 API 可点亮(两账号实测)
+// - automation_1:automated_task_create_suc 事件,纯 API 可点亮(两账号实测)
+// - Library_read:web 域 web_element_click(library_doc_intro_click)(三账号实测)
+// - template_5 / playbook_prompt / create_canvas:asar 逆向出的判据事件
+// (template_used / playbook_prompt_send / wbx_design_canvas_*),纯 API 可点亮(三账号实测)
+// - expert_5 / Expert_team_use_3:真实专家列表 + 召唤链 + 真实 chat(服务端 requestId)
+// - expert_actual_use(三账号实测)
+// - Hp_Appearance:appearance/set + appearance_skin_apply 事件(两账号实测)
+//
+// 仍未破解:skill_1(疑似要求真实 Skill 工具调用)。
+// 不做:Expert_lighthouse(需真实连接器授权)、Expert_Philanthropy(真实捐款)。
+//
+// 所有动作幂等:已 claimed/已达标的任务直接跳过,不重复消耗上游配额。
+package panel
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "math/rand/v2"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/linguo2625469/workbuddy2api-panel/internal/auth"
+ "github.com/linguo2625469/workbuddy2api-panel/internal/logfmt"
+ "github.com/linguo2625469/workbuddy2api-panel/internal/upstream"
+)
+
+// autoAction 一个可自动化的任务动作。
+type autoAction struct {
+ TaskCode string // 目标任务 code
+ Desc string // 展示用说明
+ Attempt bool // true = 尝试型(上游未证实可脚本化,跑了可能不点亮)
+ run func(p *Panel, a *auth.Auth) (string, error)
+}
+
+// autoActions 已实现的任务动作表(顺序即执行顺序:先解锁依赖项)。
+// first_buddy 依赖活跃上报解锁,故 chat_5/first_buddy 的执行都自带 report 步骤。
+var autoActions = []autoAction{
+ {
+ TaskCode: "chat_5",
+ Desc: "上报 5 条对话活跃事件(自动补足差额)",
+ run: runChat5,
+ },
+ {
+ TaskCode: "first_buddy",
+ Desc: "上报解锁 → 同意协议 → 领取第一只 Buddy(+300 分)",
+ run: runFirstBuddy,
+ },
+ {
+ TaskCode: "Model_chat_GLM5.2",
+ Desc: "接受任务 → glm-5.2 真实对话一次 → 对齐模型上报",
+ run: runModelChat,
+ },
+ {
+ TaskCode: "RichMeow_Chat",
+ Desc: "桌面指纹事件链上报(已验证:纯 API 可点亮,三账号实测)",
+ run: runRichMeow,
+ },
+ {
+ TaskCode: "Buddy_App",
+ Desc: "上报「进入 Buddy 应用」事件链(已验证:纯 API 可点亮)",
+ run: runBuddyApp,
+ },
+ {
+ TaskCode: "Buddy_App_QQ",
+ Desc: "上报「进入企鹅教师助手」事件链(已验证:纯 API 可点亮)",
+ run: runBuddyApp,
+ },
+ {
+ TaskCode: "automation_1",
+ Desc: "上报「定时任务创建」事件(已验证:纯 API 可点亮)",
+ run: runAutomationCreate,
+ },
+ {
+ TaskCode: "Library_read",
+ Desc: "上报「读资料库介绍」事件(已验证:纯 API 可点亮)",
+ run: runLibraryRead,
+ },
+ {
+ TaskCode: "template_5",
+ Desc: "上报「使用模板创建任务」事件组 ×5(已验证:三账号点亮)",
+ run: runTemplateUse,
+ },
+ {
+ TaskCode: "playbook_prompt",
+ Desc: "上报「灵感案例做同款发送 Prompt」事件组(已验证:三账号点亮)",
+ run: runPlaybookPrompt,
+ },
+ {
+ TaskCode: "create_canvas",
+ Desc: "上报「设计创意画布创建」事件组(已验证:三账号点亮,+300 分)",
+ run: runCreateCanvas,
+ },
+ {
+ TaskCode: "expert_5",
+ Desc: "真实专家召唤+使用链 ×5(专家市场列表+真实 chat,已验证:三账号点亮)",
+ run: runExpertUse,
+ },
+ {
+ TaskCode: "Expert_team_use_3",
+ Desc: "真实专家团召唤+使用链 ×3(已验证:三账号点亮)",
+ run: runExpertTeamUse,
+ },
+ {
+ TaskCode: "Hp_Appearance",
+ Desc: "设置主题 API + 皮肤生效事件(已验证:两账号点亮)",
+ run: runAppearance,
+ },
+ {
+ TaskCode: "skill_1",
+ Desc: "真实对话 + skill_info 技能加载事件(已验证:人杰2 点亮)",
+ run: runSkillFresh,
+ },
+ {
+ TaskCode: "Expert_lighthouse",
+ Desc: "真实轻量云专家召唤+使用链(chat 链带 has_expert,已验证:两账号点亮)",
+ run: runExpertLighthouse,
+ },
+ {
+ TaskCode: "black_cat",
+ Desc: "夜猫子:23:00–08:00 窗口内 glm-5.2 对话补足(窗口外提示稍后再试)",
+ Attempt: true,
+ run: runBlackCat,
+ },
+ {
+ TaskCode: "school_season",
+ Desc: "校园日(小程序口径):accept → mini 对话+activityId 上报 → 领奖(+100c+5e)",
+ run: runSchoolSeason,
+ },
+ {
+ TaskCode: "Sequential_Tasks_1",
+ Desc: "小程序首对话(小程序口径):accept → mini 对话上报 → 领奖(+100c+5e)",
+ run: runSequentialChat,
+ },
+ {
+ TaskCode: "Sequential_Tasks_2",
+ Desc: "小程序选专家对话(小程序口径):市场专家 id → accept → expert_actual_use 上报 → 领奖(+200c+5e)",
+ run: runMiniExpert,
+ },
+ {
+ TaskCode: "Sequential_Tasks_3",
+ Desc: "小程序五次对话(小程序口径):accept → mini 对话上报 ×5(自动补差额)→ 领奖(+300c+5e)",
+ run: runSequentialChat5,
+ },
+ {
+ TaskCode: "Sequential_Tasks_4",
+ Desc: "小程序定时任务(预留,每日零点解锁一环):accept → 定时任务创建事件(PC 同源)→ 领奖(判据待解锁验证)",
+ run: runSequentialAutomation,
+ },
+ {
+ TaskCode: "Sequential_Tasks_5",
+ Desc: "小程序使用 GLM5.2(预留):accept → 带模型字段的 mini 对话上报 → 领奖(判据待解锁验证)",
+ run: runSequentialModelChat,
+ },
+ {
+ TaskCode: "Sequential_Tasks_6",
+ Desc: "小程序十次对话(预留):accept → mini 对话上报 ×target(自动补差额)→ 领奖",
+ run: runSequentialChat10,
+ },
+ {
+ TaskCode: "Sequential_Tasks_7",
+ Desc: "体验灵感功能(预留,疑 PC 口径 +500c+5e):accept → 灵感事件组(PC+mp 双形态)→ 领奖(判据待解锁验证)",
+ run: runSequentialPlaybook,
+ },
+}
+
+// autoActionFor 查任务对应的动作;无则返回 nil(不可自动化)。
+func autoActionFor(code string) *autoAction {
+ for i := range autoActions {
+ if autoActions[i].TaskCode == strings.TrimSpace(code) {
+ return &autoActions[i]
+ }
+ }
+ return nil
+}
+
+// autoActionIndex 任务在 autoActions 中的顺序(队列执行按依赖序排;未知返回大值)。
+func autoActionIndex(code string) int {
+ for i := range autoActions {
+ if autoActions[i].TaskCode == code {
+ return i
+ }
+ }
+ return 1 << 20
+}
+
+// mpTaskCodes 小程序口径专属下发的成长任务:默认(无 mp 头)列表不出现,
+// accept/claim 均要求 X-Client-Platform: miniprogram。新任务出现时在此登记。
+var mpTaskCodes = map[string]bool{
+ "school_season": true, // 校园日(mini chat + activityId)
+ "Sequential_Tasks_1": true, // 小程序首对话(mini chat,无 activityId)
+ "Sequential_Tasks_2": true, // 小程序选中专家并完成有效对话(mp 指纹 expert_actual_use)
+ "Sequential_Tasks_3": true, // 小程序完成 5 次对话(与 Tasks_1 同形状,target=5 逐条累加)
+ // Tasks_4..7 存在性已实测(accept 返回 prerequisite not met 链式依赖;Tasks_8 not
+ // found 封顶)。链条每日零点解锁一环(task locked until 次日),判据为 issue #42
+ // 描述 + mpsrc 事件形状预置,解锁后逐个实测校正。
+ "Sequential_Tasks_4": true,
+ "Sequential_Tasks_5": true,
+ "Sequential_Tasks_6": true,
+ "Sequential_Tasks_7": true,
+}
+
+// isMPTaskCode 报告任务是否小程序口径专属(决定回读/接受/领奖走 mp 变体)。
+func isMPTaskCode(code string) bool { return mpTaskCodes[code] }
+
+// taskByCode 拉取任务列表并定位单个任务;未找到返回 nil(不视为错误)。
+// 双口径:mp 专属任务在默认列表查不到,自动回落 mp 列表(仅对已登记的 mp 码,
+// 未知码不多打一次上游)。
+func (p *Panel) taskByCode(a *auth.Auth, code string) (*upstream.Task, error) {
+ tasks, err := p.cfg.Upstream.ListTasks(a)
+ if err != nil {
+ return nil, err
+ }
+ for i := range tasks {
+ if tasks[i].TaskCode == code {
+ return &tasks[i], nil
+ }
+ }
+ if isMPTaskCode(code) {
+ return p.taskByCodeMP(a, code)
+ }
+ return nil, nil
+}
+
+// claimPollAttempts / claimPollGap 达标回读的有界轮询参数。
+// 背景:上游计分是**异步**的——行为事件上报后进度要数秒才刷新(实测 Model_chat
+// 对话完成后立即回读仍是 0/1,约 5-8 秒后才变 1/1)。一次性回读会误判"未达标",
+// 从而跳过自动领奖。这里最多轮询 N 次、每次间隔 gap,总预算约 12 秒。
+var (
+ claimPollAttempts = 4
+ claimPollGap = 3 * time.Second
+)
+
+// taskByCodeWaiting 回读任务,若未达标则在有界预算内轮询等待(上游异步计分)。
+// 已达标(claimable)立即返回;预算耗尽返回最后一次结果(可能仍未达标)。
+func (p *Panel) taskByCodeWaiting(a *auth.Auth, code string) (*upstream.Task, error) {
+ t, err := p.taskByCode(a, code)
+ if err != nil || t == nil {
+ return t, err
+ }
+ if t.Claimable || t.Claimed {
+ return t, nil
+ }
+ for i := 1; i < claimPollAttempts; i++ {
+ time.Sleep(claimPollGap)
+ t2, err2 := p.taskByCode(a, code)
+ if err2 != nil {
+ return t, nil // 轮询期间的查询失败不覆盖已拿到的结果
+ }
+ if t2 != nil {
+ t = t2
+ if t.Claimable || t.Claimed {
+ return t, nil
+ }
+ }
+ }
+ return t, nil
+}
+
+// taskByCodeMP 以小程序口径拉取任务列表并定位单个任务;未找到返回 nil。
+// 小程序限定任务(school_season / Sequential_Tasks_1)在默认口径列表不出现。
+func (p *Panel) taskByCodeMP(a *auth.Auth, code string) (*upstream.Task, error) {
+ tasks, err := p.cfg.Upstream.ListTasksMP(a)
+ if err != nil {
+ return nil, err
+ }
+ for i := range tasks {
+ if tasks[i].TaskCode == code {
+ return &tasks[i], nil
+ }
+ }
+ return nil, nil
+}
+
+// acceptWithVerifyMP accept 并回读验证登记生效:上游存在 200+OK 但 accept 未真正
+// 登记的形态(此时上报事件全部不归账,任务永远点不亮,上游 task_runner c793ae3
+// 实测)——判定以回读 accept_status 为准,未生效重试一次。
+func (p *Panel) acceptWithVerifyMP(a *auth.Auth, code string) bool {
+ for attempt := 1; attempt <= 2; attempt++ {
+ if err := p.cfg.Upstream.AcceptTasksMP(a, []string{code}); err != nil {
+ log.Printf("autotask %s %s: accept 尝试%d: %v", logfmt.Label(a.UID, a.Nickname), code, attempt, err)
+ continue
+ }
+ time.Sleep(mpActionGap)
+ t, err := p.taskByCodeMP(a, code)
+ if err == nil && t != nil && t.AcceptStatus != "not_accepted" && t.AcceptStatus != "" {
+ return true
+ }
+ log.Printf("autotask %s %s: accept 尝试%d 未登记生效(回读=%q)", logfmt.Label(a.UID, a.Nickname), code, attempt, acceptStatusOr(t))
+ }
+ return false
+}
+
+// acceptStatusOr 安全读取任务 accept_status(nil 任务返回 "?")。
+func acceptStatusOr(t *upstream.Task) string {
+ if t == nil {
+ return "?"
+ }
+ if t.AcceptStatus == "" {
+ return "?"
+ }
+ return t.AcceptStatus
+}
+
+// mpActionGap mp 任务写动作间隔(accept/上报/领奖之间,防频控)。
+var mpActionGap = 2 * time.Second
+
+// mpChatEventGap mp 对话事件(chat_request_send)的真人节奏间隔。上游对
+// Sequential_Tasks_3「5 次有效对话」有反作弊校验:数秒级连发的事件会先被计入
+// 进度(回读 5/5、accept_status 甚至短暂转 completed),随后被判定无效整体回滚
+// (进度回落、claim 返回 400 "task not completed")——2026-09-26 实测 2s 连发
+// 4 条全灭,45s 间隔逐条上报全存活且 claim +300c+5e 成功。每条上报前
+// sleep gap + 0~10s 抖动;首条也等(上一轮残留进度被回滚后立即重报同样无效)。
+var mpChatEventGap = 45 * time.Second
+
+// runMPMiniChatTask growth 域小程序限定任务通用闭环:
+// mp 查询 → accept(带登记回读验证)→ mini chat 事件上报(withActivityId 决定
+// 是否带开学季 activityId:school_season 必带,Sequential_Tasks_1 不带——服务端按
+// source=mini_program 指纹关联)→ 回读 → 达标即领奖。
+func (p *Panel) runMPMiniChatTask(a *auth.Auth, code string, withActivityId bool) (string, error) {
+ t, err := p.taskByCodeMP(a, code)
+ if err != nil {
+ return "", err
+ }
+ if t == nil {
+ return "mp 口径未下发该任务(活动可能已结束)", nil
+ }
+ if t.Claimed {
+ return "已领取", nil
+ }
+ if t.AcceptStatus == "not_accepted" || t.AcceptStatus == "" {
+ if !p.acceptWithVerifyMP(a, code) {
+ return "accept 未登记生效(上游 200+OK 但未落账形态),待下次重试", nil
+ }
+ // accept 前的任务进度为 null(target 下发 0),兜底 target=1 会少报——
+ // Tasks_6 首轮实测:accept 后真实 target=10,只补 1 条就误判达标去领奖
+ // (claim 400 task not completed)。接受后回读一次拿真实 target/current。
+ if t2, err := p.taskByCodeMP(a, code); err == nil && t2 != nil {
+ t = t2
+ }
+ }
+ // 已达标(含 completed 未领):直接领奖。
+ target := t.Target
+ if target <= 0 {
+ target = 1
+ }
+ if t.Current >= target || t.AcceptStatus == "completed" {
+ credit, energy, err := p.cfg.Upstream.ClaimRewardMP(a, code)
+ if err != nil {
+ return "", err
+ }
+ return fmt.Sprintf("已领取奖励(+%dc +%de)", credit, energy), nil
+ }
+ // 判据上报:按差额补 mini chat 事件。每条前 sleep mpChatEventGap+抖动——
+ // 连发会被上游反作弊判无效(见 mpChatEventGap 注释),宁可慢不可白报。
+ need := target - t.Current
+ for i := int64(0); i < need; i++ {
+ time.Sleep(mpChatEventGap + time.Duration(rand.Int64N(int64(10*time.Second))))
+ conv := fmt.Sprintf("wb2api-mp-%d-%d", time.Now().UnixMilli(), i)
+ var ev map[string]any
+ if withActivityId {
+ ev = upstream.SchoolSeasonChatEvent(conv)
+ } else {
+ ev = upstream.SchoolChatTimesEvents(conv)
+ }
+ if err := p.cfg.Upstream.ReportMPEvent(a, ev); err != nil {
+ return fmt.Sprintf("完成 %d/%d 次上报后中断: %v", i, need, err), nil
+ }
+ }
+ // 回读(异步计分,有界轮询复用 claimPoll 预算的紧凑版:两轮各隔 3s)。
+ for i := 0; i < 2; i++ {
+ time.Sleep(claimPollGap)
+ t2, err2 := p.taskByCodeMP(a, code)
+ if err2 != nil || t2 == nil {
+ continue
+ }
+ t = t2
+ if t.Claimable || t.Claimed || t.Current >= target {
+ break
+ }
+ }
+ if t.Claimed {
+ return "本轮已入账(claimed)", nil
+ }
+ if t.Current < target {
+ return fmt.Sprintf("已上报 %d 次但进度未达 %d/%d(异步计分未归账,下次重试)", need, t.Current, target), nil
+ }
+ credit, energy, err := p.cfg.Upstream.ClaimRewardMP(a, code)
+ if err != nil {
+ return "", err
+ }
+ return fmt.Sprintf("任务点亮并领取奖励(+%dc +%de)", credit, energy), nil
+}
+
+// runSchoolSeason 完成 school_season「校园日」(growth 域小程序限定)。
+// 判据 = mini chat_request_send + activityId=school_open_day_2026(无 activityId
+// 不点亮,与 school 域开学季同活动关联;上游 task_runner e2e 实测 +100c+5e)。
+func runSchoolSeason(p *Panel, a *auth.Auth) (string, error) {
+ return p.runMPMiniChatTask(a, "school_season", true)
+}
+
+// runSequentialChat 完成 Sequential_Tasks_1「小程序内完成 1 次有效对话」。
+// 判据 = mini chat_request_send(无 activityId,服务端按 source=mini_program
+// 指纹关联;上游 task_runner 实测 +100c+5e)。
+func runSequentialChat(p *Panel, a *auth.Auth) (string, error) {
+ return p.runMPMiniChatTask(a, "Sequential_Tasks_1", false)
+}
+
+// runSequentialChat5 完成 Sequential_Tasks_3「在小程序内完成 5 次有效对话」。
+// 判据与 Sequential_Tasks_1 同形状(mini 指纹 chat_request_send,无 activityId),
+// 仅 target=5——服务端按上报条数累加进度,但**要求真人节奏**:连发事件先计数
+// 后被反作弊回滚(claim 400 "task not completed"),由 mpChatEventGap 间隔保证
+// (2026-09-26 实测:45s 间隔补满 5/5 → claim +300c+5e 成功,领后 accept_status
+// =claimed 稳定不回滚)。
+func runSequentialChat5(p *Panel, a *auth.Auth) (string, error) {
+ return p.runMPMiniChatTask(a, "Sequential_Tasks_3", false)
+}
+
+// runSequentialChat10 完成 Sequential_Tasks_6「在小程序内完成 10 次有效对话」(预留)。
+// 判据假定与 Tasks_1/3 同形状(mini chat_request_send),target 由任务自带(回读),
+// runMPMiniChatTask 按差额补报——issue #42 称 target=10,以解锁后实际下发为准。
+// 真人节奏间隔同样适用(mpChatEventGap):9 条 × ~50s ≈ 8 分钟/账号,夜间队列可接受。
+func runSequentialChat10(p *Panel, a *auth.Auth) (string, error) {
+ return p.runMPMiniChatTask(a, "Sequential_Tasks_6", false)
+}
+
+// runSequentialEventTask Sequential 链预留任务通用骨架:mp 查询 → accept(带验证)
+// → 判据事件上报(primary;未点亮且 fallback 非空时补一轮)→ 回读 → 达标领奖。
+// 每日零点解锁一环:locked 期间 accept 不落账,返回等下次调度(无需人工干预)。
+func (p *Panel) runSequentialEventTask(a *auth.Auth, code string, primary, fallback func() error) (string, error) {
+ t, err := p.taskByCodeMP(a, code)
+ if err != nil {
+ return "", err
+ }
+ if t == nil {
+ return "mp 口径未下发该任务(前置任务未完成或活动未开始)", nil
+ }
+ if t.Claimed {
+ return "已领取", nil
+ }
+ target := t.Target
+ if target <= 0 {
+ target = 1
+ }
+ if t.Current >= target || t.AcceptStatus == "completed" {
+ credit, energy, err := p.cfg.Upstream.ClaimRewardMP(a, code)
+ if err != nil {
+ return "", err
+ }
+ return fmt.Sprintf("已领取奖励(+%dc +%de)", credit, energy), nil
+ }
+ if t.AcceptStatus == "not_accepted" || t.AcceptStatus == "" {
+ if !p.acceptWithVerifyMP(a, code) {
+ return "accept 未登记生效(任务可能处于每日锁定窗口,等解锁后自动重试)", nil
+ }
+ }
+ if err := primary(); err != nil {
+ return fmt.Sprintf("判据上报失败: %v", err), nil
+ }
+ // 回读(两轮各隔 3s);未点亮且有 fallback 时补报一轮再读。
+ for round := 0; round < 2; round++ {
+ time.Sleep(claimPollGap)
+ t2, err2 := p.taskByCodeMP(a, code)
+ if err2 != nil || t2 == nil {
+ continue
+ }
+ t = t2
+ if t.Claimable || t.Claimed || t.Current >= target {
+ break
+ }
+ if round == 0 && fallback != nil {
+ if err := fallback(); err != nil {
+ return fmt.Sprintf("备选判据上报失败: %v", err), nil
+ }
+ }
+ }
+ if t.Claimed {
+ return "本轮已入账(claimed)", nil
+ }
+ if t.Current < target {
+ return "已上报但进度未点亮(判据形态待解锁后校正,下次重试)", nil
+ }
+ credit, energy, err := p.cfg.Upstream.ClaimRewardMP(a, code)
+ if err != nil {
+ return "", err
+ }
+ return fmt.Sprintf("任务点亮并领取奖励(+%dc +%de)", credit, energy), nil
+}
+
+// runSequentialAutomation 完成 Sequential_Tasks_4「创建定时任务」(预留)。
+// mp 源码无 automation 事件发射点 → 判据疑为 PC 口径:复用 automation_1 同源
+// 事件(DesktopAutomationCreateEvent,PC 任务三账号实测点亮)。
+func runSequentialAutomation(p *Panel, a *auth.Auth) (string, error) {
+ return p.runSequentialEventTask(a, "Sequential_Tasks_4",
+ func() error {
+ return p.cfg.Upstream.ReportDesktopEvent(a, upstream.DesktopAutomationCreateEvent("wb2api 自动化"))
+ }, nil)
+}
+
+// runSequentialModelChat 完成 Sequential_Tasks_5「使用 GLM5.2」(预留)。
+// primary:mp 对话事件带 requestModelId/requestModelName=glm-5.2(mpsrc main 32904
+// 发射点实测形状);fallback:PC 域模型活跃上报(Model_chat_GLM5.2 同源)。
+func runSequentialModelChat(p *Panel, a *auth.Auth) (string, error) {
+ return p.runSequentialEventTask(a, "Sequential_Tasks_5",
+ func() error {
+ conv := fmt.Sprintf("wb2api-mp-glm-%d", time.Now().UnixMilli())
+ return p.cfg.Upstream.ReportMPEvent(a, upstream.MiniChatModelEvent(conv, "glm-5.2", "GLM-5.2"))
+ },
+ func() error {
+ return p.cfg.Upstream.ReportChatActivityModel(a, fmt.Sprintf("wb2api-mp-glm-%d", time.Now().UnixMilli()), "", "glm-5.2", "GLM-5.2")
+ })
+}
+
+// runSequentialPlaybook 完成 Sequential_Tasks_7「体验灵感功能」(预留,疑 PC 口径)。
+// primary:PC 灵感事件组(DesktopPlaybookPromptSequence,playbook_prompt 三账号
+// 实测点亮);fallback:mp 指纹灵感事件组(MiniPlaybookEvents,mpsrc 形状)。
+func runSequentialPlaybook(p *Panel, a *auth.Auth) (string, error) {
+ ms := time.Now().UnixMilli()
+ return p.runSequentialEventTask(a, "Sequential_Tasks_7",
+ func() error {
+ conv := fmt.Sprintf("wb2api-pb-%d", ms)
+ req := fmt.Sprintf("wb2api-pb-req-%d", ms)
+ return p.cfg.Upstream.ReportDesktopEvent(a,
+ upstream.DesktopPlaybookPromptSequence(conv, req, "pm-gtm-launch-plan", "新产品上市 GTM 发布计划一页纸")...)
+ },
+ func() error {
+ return p.cfg.Upstream.ReportMPEvent(a, upstream.MiniPlaybookEvents("pm-gtm-launch-plan", "新产品上市 GTM 发布计划一页纸")...)
+ })
+}
+
+// runMiniExpert 完成 Sequential_Tasks_2「在小程序内选中专家并完成有效对话」。
+// 判据 = mp 指纹 expert_actual_use(**不带** activityId/conversationId、
+// extVersion=2.2.8、type=send_message——小程序源码实测形状,与 school 域 expert
+// 事件两套口径勿混;上游 task_runner 实测上报即 completed,claim +200c+5e)。
+// 专家 id 必须是市场真实 ex_ id(空 id 服务端不入账)→ **accept 之前**先解析市场
+// 列表:拉不到就整任务不动作,避免留下「已登记未上报」的半程态(上游 9a26ae7
+// 的 ids 前置判定同款)。复用既有 MarketExpertList(expert_5 任务同源,实测可用)。
+func runMiniExpert(p *Panel, a *auth.Auth) (string, error) {
+ const code = "Sequential_Tasks_2"
+ t, err := p.taskByCodeMP(a, code)
+ if err != nil {
+ return "", err
+ }
+ if t == nil {
+ return "mp 口径未下发该任务(活动可能已结束)", nil
+ }
+ if t.Claimed {
+ return "已领取", nil
+ }
+ target := t.Target
+ if target <= 0 {
+ target = 1 // 未 accept 的 mp 任务 progress 为 null,target 兜底(上游实测)
+ }
+ // 已达标(含 completed 未领):直接领奖。
+ if t.Current >= target || t.AcceptStatus == "completed" {
+ credit, energy, err := p.cfg.Upstream.ClaimRewardMP(a, code)
+ if err != nil {
+ return "", err
+ }
+ return fmt.Sprintf("已领取奖励(+%dc +%de)", credit, energy), nil
+ }
+ // 判据载体前置(accept 之前):市场真实专家 id。
+ experts, merr := p.cfg.Upstream.MarketExpertList(a, "")
+ if merr != nil || len(experts) == 0 {
+ return fmt.Sprintf("专家市场不可用(%v),跳过以防半程态", merr), nil
+ }
+ e := experts[0]
+ name := e.DisplayNameZH
+ if name == "" {
+ name = e.ProfessionZH
+ }
+ if t.AcceptStatus == "not_accepted" || t.AcceptStatus == "" {
+ if !p.acceptWithVerifyMP(a, code) {
+ return "accept 未登记生效(上游 200+OK 但未落账形态),待下次重试", nil
+ }
+ }
+ ev := upstream.MiniExpertUseEvent(e.ExpertID, name, e.ExpertType)
+ if err := p.cfg.Upstream.ReportMPEvent(a, ev); err != nil {
+ return fmt.Sprintf("上报 expert_actual_use 失败: %v", err), nil
+ }
+ // 回读(异步计分,两轮各隔 3s——与 runMPMiniChatTask 同预算)。
+ for i := 0; i < 2; i++ {
+ time.Sleep(claimPollGap)
+ t2, err2 := p.taskByCodeMP(a, code)
+ if err2 != nil || t2 == nil {
+ continue
+ }
+ t = t2
+ if t.Claimable || t.Claimed || t.Current >= target {
+ break
+ }
+ }
+ if t.Claimed {
+ return "本轮已入账(claimed)", nil
+ }
+ if t.Current < target {
+ return "已上报但进度未归账(异步计分,下次重试)", nil
+ }
+ credit, energy, err := p.cfg.Upstream.ClaimRewardMP(a, code)
+ if err != nil {
+ return "", err
+ }
+ return fmt.Sprintf("任务点亮并领取奖励(+%dc +%de)", credit, energy), nil
+}
+
+// accountTaskAuto 一键完成单个任务:执行对应动作 → 回读进度 → 汇报结果。
+func (p *Panel) accountTaskAuto(w http.ResponseWriter, r *http.Request) {
+ uid := r.PathValue("uid")
+ a := p.accountByUID(w, uid)
+ if a == nil {
+ return
+ }
+ var body struct {
+ TaskCode string `json:"task_code"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.TaskCode == "" {
+ writeErr(w, http.StatusBadRequest, "task_code required")
+ return
+ }
+ act := autoActionFor(body.TaskCode)
+ if act == nil {
+ writeErr(w, http.StatusNotImplemented,
+ "该任务需要客户端内交互(无对应接口),无法自动完成;请按任务说明在官方客户端操作")
+ return
+ }
+ // per-account 互斥:同账号的任务动作正在跑(单任务或全量)时直接 409,
+ // 不并发重跑(动作幂等但 expert/skill 系含真实对话,重跑浪费配额)。
+ if !p.tryLockAccount(uid) {
+ writeErr(w, http.StatusConflict, "该账号有任务动作正在执行中,请等本轮结束后再试")
+ return
+ }
+ defer p.unlockAccount(uid)
+ // 前置读取:已完成的任务直接跳过(幂等,不浪费上游调用)。
+ // taskByCode 已双口径(mp 专属码自动回落 mp 列表)。
+ before, err := p.taskByCode(a, act.TaskCode)
+ if err != nil {
+ writeErr(w, http.StatusBadGateway, "list tasks: "+err.Error())
+ return
+ }
+ if before == nil {
+ writeErr(w, http.StatusNotFound, "该账号没有此任务")
+ return
+ }
+ isMP := isMPTaskCode(act.TaskCode)
+ if before.Claimed {
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "skipped": true, "message": "该任务已领取过奖励"})
+ return
+ }
+ msg, err := act.run(p, a)
+ if err != nil {
+ writeErr(w, http.StatusBadGateway, "执行失败: "+err.Error())
+ return
+ }
+ // 回读验证:上报 200 ≠ 计分(上游可能静默丢弃 + 计分异步),
+ // 用有界轮询等异步计时落定,再决定是否自动领奖(mp 任务同口径回读)。
+ var after *upstream.Task
+ var aerr error
+ if isMP {
+ after, aerr = p.taskByCodeMP(a, act.TaskCode)
+ } else {
+ after, aerr = p.taskByCodeWaiting(a, act.TaskCode)
+ }
+ progressBefore, progressAfter := taskProgressText(before), ""
+ claimable := false
+ if aerr == nil && after != nil {
+ progressAfter = taskProgressText(after)
+ claimable = after.Claimable
+ }
+ resp := map[string]any{
+ "ok": true,
+ "message": msg,
+ "progress_before": progressBefore,
+ "progress_after": progressAfter,
+ "claimable": claimable,
+ "attempt": act.Attempt,
+ "verify_supported": true,
+ }
+ // 达标即自动领奖(Web 端 claim):把"完成→领奖"收敛成一步,无需用户再点一次。
+ if claimable {
+ var credit, energy int64
+ var cerr error
+ if isMP {
+ credit, energy, cerr = p.cfg.Upstream.ClaimRewardMP(a, act.TaskCode)
+ } else {
+ credit, energy, cerr = p.cfg.Upstream.ClaimReward(a, act.TaskCode)
+ }
+ if cerr == nil {
+ resp["claimed"] = true
+ resp["credit"] = credit
+ resp["energy"] = energy
+ if credit > 0 || energy > 0 {
+ resp["message"] = msg + fmt.Sprintf(";已自动领奖 +%d 分 +%d 能", credit, energy)
+ } else {
+ resp["message"] = msg + ";奖励此前已领取"
+ }
+ } else {
+ resp["claim_error"] = cerr.Error()
+ resp["message"] = msg + ";达标但领奖失败,可在任务列表手动点「领取」重试"
+ }
+ }
+ log.Printf("panel: 任务动作 uid=%s code=%s progress %s -> %s claimable=%v claimed=%v",
+ uid, act.TaskCode, progressBefore, progressAfter, claimable, resp["claimed"])
+ writeJSON(w, http.StatusOK, resp)
+}
+
+// taskProgressText 任务进度的可读表示(回读对比用)。
+func taskProgressText(t *upstream.Task) string {
+ if t == nil {
+ return "?"
+ }
+ if t.Target > 0 {
+ return fmt.Sprintf("%d/%d", t.Current, t.Target)
+ }
+ if t.Claimed {
+ return "claimed"
+ }
+ return t.AcceptStatus
+}
+
+// truncateStr 截断错误文本(避免把上游长响应原样透给前端)。
+func truncateStr(s string, n int) string {
+ if len(s) <= n {
+ return s
+ }
+ return s[:n] + "…"
+}
+
+// ---------------------------------------------------------------------------
+// 各任务动作实现
+// ---------------------------------------------------------------------------
+
+// reportGap 连续上报之间的间隔(对齐上游脚本实测的 1.05s 口径,避免风控)。
+var reportGap = 1050 * time.Millisecond
+
+// runChat5 补足 chat_5 的进度:按差额上报 chat_request_send。
+func runChat5(p *Panel, a *auth.Auth) (string, error) {
+ t, err := p.taskByCode(a, "chat_5")
+ if err != nil {
+ return "", err
+ }
+ if t == nil {
+ return "", fmt.Errorf("任务不存在")
+ }
+ target := t.Target
+ if target <= 0 {
+ target = 5
+ }
+ need := target - t.Current
+ if need <= 0 {
+ return "进度已达标,无需上报", nil
+ }
+ for i := int64(0); i < need; i++ {
+ cid := fmt.Sprintf("wb2api-chat5-%d-%d", time.Now().UnixMilli(), i)
+ if err := p.cfg.Upstream.ReportChatActivity(a, cid, ""); err != nil {
+ return fmt.Sprintf("上报第 %d/%d 条失败: %v", i+1, need, err), nil
+ }
+ if i < need-1 {
+ time.Sleep(reportGap)
+ }
+ }
+ return fmt.Sprintf("已补报 %d 条对话事件", need), nil
+}
+
+// runFirstBuddy 领养:report(解锁前置)→ agreement → first。
+func runFirstBuddy(p *Panel, a *auth.Auth) (string, error) {
+ if err := p.cfg.Upstream.ReportChatActivity(a, fmt.Sprintf("wb2api-adopt-%d", time.Now().UnixMilli()), ""); err != nil {
+ return "", fmt.Errorf("前置上报: %w", err)
+ }
+ time.Sleep(reportGap) // 给上游事件处理留时间(脚本实测口径)
+ if err := p.cfg.Upstream.BuddyAgreement(a); err != nil {
+ return "", fmt.Errorf("同意协议: %w", err)
+ }
+ if err := p.cfg.Upstream.BuddyFirst(a); err != nil {
+ if upstream.IsBuddyTaskIncomplete(err) {
+ return "前置已上报,但领养门槛未过(上游要求当日活跃),请稍后重试", nil
+ }
+ return "", fmt.Errorf("领取 Buddy: %w", err)
+ }
+ return "已领取 Buddy(+300 分 +8 能量)", nil
+}
+
+// runModelChat 完成 Model_chat_GLM5.2:accept → 真实对话 → 对齐模型上报。
+func runModelChat(p *Panel, a *auth.Auth) (string, error) {
+ const code, modelID, modelName = "Model_chat_GLM5.2", "glm-5.2", "GLM-5.2"
+ // 1. accept(报名;失败不阻塞——行为事件才是判据)
+ if err := p.cfg.Upstream.AcceptTasks(a, []string{code}); err != nil {
+ log.Printf("panel: accept %s: %v(继续走行为链路)", code, err)
+ }
+ time.Sleep(reportGap)
+ // 2. 真实对话一次(判据的最直接证据)
+ body, _ := json.Marshal(map[string]any{
+ "model": modelID,
+ "messages": []map[string]any{
+ {"role": "user", "content": "hi,请回复一句话"},
+ },
+ "stream": true,
+ })
+ rc, status, respBody, err := p.cfg.Upstream.ChatStream(a, body, "", upstream.ChatMeta{})
+ if err != nil {
+ return "", fmt.Errorf("对话请求: %w", err)
+ }
+ if status >= 400 {
+ rc.Close()
+ return "", fmt.Errorf("对话失败 http=%d: %s", status, truncateStr(string(respBody), 160))
+ }
+ // 读干 SSE(网关对上游强制 flow:不读完会残留连接)
+ _, _ = io.Copy(io.Discard, io.LimitReader(rc, 1<<20))
+ rc.Close()
+ time.Sleep(reportGap)
+ // 3. 对齐模型的上报(触发进度)
+ if err := p.cfg.Upstream.ReportChatActivityModel(a, fmt.Sprintf("wb2api-glm52-%d", time.Now().UnixMilli()), "", modelID, modelName); err != nil {
+ return "对话已完成,但进度上报失败:" + err.Error(), nil
+ }
+ return "已完成 glm-5.2 对话并上报", nil
+}
+
+// runRichMeow 完成 RichMeow_Chat(桌面端对话1次)。
+// 2026-09-12 三账号实测验证:以桌面指纹(extName=workbuddy-desktop)向
+// copilot.tencent.com/v2/report 上报完整对话事件链(agent_task_created →
+// chat_message_response isSuccessful=true 等 6 事件),纯 API 即可点亮并领奖
+// (紫川/人杰2 两账号无桌面客户端登录状态下 3 秒内 0/1 → 1/1)。
+// Hp_Appearance 的纯 API set 不计分(需客户端在主题下活跃),区别对待。
+func runRichMeow(p *Panel, a *auth.Auth) (string, error) {
+ ms := time.Now().UnixMilli()
+ conv := fmt.Sprintf("wb2api-rm-%d", ms)
+ req := fmt.Sprintf("wb2api-rm-req-%d", ms)
+ msg := fmt.Sprintf("req-%d-user", ms)
+ events := upstream.DesktopChatSequence(conv, req, msg, "fast-model", "fast-model")
+ if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil {
+ return "", err
+ }
+ return "已按桌面端指纹上报完整对话事件链(agent_task_created→chat_response)", nil
+}
+
+// runBuddyApp 完成 Buddy_App / Buddy_App_QQ(进入 Buddy 应用)。
+// 2026-09-12 两账号实测:buddyapp 五连事件(discover→show→enter→auth_confirm→
+// bind_skip)以 workbuddy-desktop 指纹上报即点亮,服务端不校验真实授权。
+// 用企鹅教师助手(Buddy_App_QQ 判据应用)作载体,同一组事件同时满足
+// Buddy_App「进入任一应用」——两个表项共用本 run,幂等由任务状态跳过兜底。
+func runBuddyApp(p *Panel, a *auth.Auth) (string, error) {
+ events := upstream.DesktopBuddyAppSequence("cb_y5Dy46tPQGGWtueMxXbe", "企鹅教师助手")
+ if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil {
+ return "", err
+ }
+ return "已上报 buddyapp 进入五连事件(同时覆盖 Buddy_App 与 Buddy_App_QQ)", nil
+}
+
+// runAutomationCreate 完成 automation_1(设置自动化任务)。
+// 2026-09-12 两账号实测:automated_task_create_suc 事件纯 API 上报即点亮,
+// 无需真实创建定时任务。
+func runAutomationCreate(p *Panel, a *auth.Auth) (string, error) {
+ if err := p.cfg.Upstream.ReportDesktopEvent(a,
+ upstream.DesktopAutomationCreateEvent("wb2api 自动化")); err != nil {
+ return "", err
+ }
+ return "已上报定时任务创建事件", nil
+}
+
+// runLibraryRead 完成 Library_read(体验资料库)。
+// 2026-09-12 三账号实测:web 域 /v2/report 上报 web_element_click
+// (elementId=library_doc_intro_click) 即点亮(space 的 open/WS/inlong 都不是判据)。
+func runLibraryRead(p *Panel, a *auth.Auth) (string, error) {
+ const docURL = "https://www.workbuddy.cn/space/d/o0KWYeynteVv06UnAZqIFm"
+ if err := p.cfg.Upstream.ReportWebEvent(a, "web_element_click", docURL,
+ "library_doc_intro_click", "WorkBuddy资料库介绍"); err != nil {
+ return "", err
+ }
+ return "已上报资料库介绍阅读事件", nil
+}
+
+// runBlackCat 完成 black_cat(夜猫子,夜间 23:00–08:00 计数)。
+// 判据 = 夜间窗口内 glm-5.2 真实对话 + chat 事件上报(WorkBuddy-Daily 实测口径)。
+// 窗口外不做(提示等排程);网关 blackcat_hours(默认 23 点)排程会自动补足。
+func runBlackCat(p *Panel, a *auth.Auth) (string, error) {
+ if !upstream.InNightWindow(time.Now()) {
+ return "当前不在 23:00–08:00 计数窗口,行为不计分;网关会在每日 23 点自动补足", nil
+ }
+ need, err := p.cfg.Upstream.BlackcatNeed(a)
+ if err != nil {
+ return "", err
+ }
+ if need <= 0 {
+ return "进度已达标,无需补足", nil
+ }
+ ok, err := p.cfg.Upstream.RunNightChats(a, int(need))
+ if err != nil {
+ return fmt.Sprintf("完成 %d/%d 次后中断: %v", ok, need, err), nil
+ }
+ return fmt.Sprintf("已完成 %d 次夜间对话并上报", ok), nil
+}
+
+// runSkillFresh 完成 skill_1(尝鲜热门技能)。
+// 2026-09-12 判据(紫川手动完成抓包 row 209):`skill_info` 事件(桌面指纹)——
+// {id:<技能名>, skillId, skillVersion, toolStatus:"success", fileCount,
+// source:"workbuddy-desktop"} JOIN 真实会话(conversationId/requestId=服务端
+// id)。此前的 skill_request_send/skill_installed/skill_action 全是错误方向。
+// 人杰2 实测 0/1 → 1/1 点亮。
+func runSkillFresh(p *Panel, a *auth.Auth) (string, error) {
+ conv, req, err := p.cfg.Upstream.DesktopChatWithExpert(a, "")
+ if err != nil {
+ return "", fmt.Errorf("真实对话: %w", err)
+ }
+ msgID := "msg-" + req[len(req)-8:]
+ events := upstream.DesktopChatSequence(conv, req, msgID, "fast-model", "fast-model")
+ for _, ev := range events {
+ if ev["eventCode"] == "chat_message_response" {
+ ev["finishReason"] = "tool_calls" // 模型发起工具调用(技能加载)语义
+ }
+ }
+ events = append(events, upstream.DesktopEvent{
+ "eventCode": "skill_info",
+ "id": "润泽小馆·日报撰写",
+ "skillId": "skill_2097350077599879168",
+ "skillVersion": "1.0.0",
+ "toolStatus": "success",
+ "fileCount": 56,
+ "source": "workbuddy-desktop",
+ "conversationId": conv, "requestId": req, "messageId": msgID,
+ "requestModelId": "fast-model", "requestModelName": "fast-model",
+ "traceId": req,
+ })
+ if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil {
+ return "", fmt.Errorf("skill_info 事件: %w", err)
+ }
+ return "已上报真实对话 + skill_info 技能加载事件", nil
+}
+
+// runExpertLighthouse 完成 Expert_lighthouse(体验「腾讯轻量云」专家)。
+// 2026-09-12 判据(真实样本 Sunny row 868):与 expert_5 同构,但两处差异——
+// chat 链的 agent_task_created 需带 has_expert:true + expert_id(我们默认 false),
+// expert_actual_use 的 mode 为 "LOCAL"(非 craft)。id 固定为轻量云专家
+// ex_2cvvUZQhDyeJ;requestId 必须是真实 chat 的服务端 id。紫川/人杰2 实测点亮。
+func runExpertLighthouse(p *Panel, a *auth.Auth) (string, error) {
+ const lhID = "ex_2cvvUZQhDyeJ"
+ lh := upstream.MarketExpert{
+ ExpertID: lhID, ExpertType: "agent",
+ DisplayNameZH: "腾讯轻量云专家", ProfessionZH: "腾讯轻量云专家", Version: "1.0.2",
+ }
+ // 市场列表若命中真实条目则用其信息(version 等以服务端为准)。
+ if experts, err := p.cfg.Upstream.MarketExpertList(a, "agent"); err == nil {
+ for _, e := range experts {
+ if e.ExpertID == lhID {
+ lh = e
+ break
+ }
+ }
+ }
+ if err := p.cfg.Upstream.ReportDesktopEvent(a, upstream.DesktopExpertSummonSequence(lh)...); err != nil {
+ return "", fmt.Errorf("召唤链: %w", err)
+ }
+ conv, req, err := p.cfg.Upstream.DesktopChatWithExpert(a, lhID)
+ if err != nil {
+ return "", fmt.Errorf("真实对话: %w", err)
+ }
+ events := upstream.DesktopChatSequence(conv, req, "msg-"+req[len(req)-8:], "fast-model", "fast-model")
+ for _, ev := range events {
+ if ev["eventCode"] == "agent_task_created" {
+ ev["has_expert"] = true
+ ev["expert_id"] = lh.ExpertID
+ ev["expert_name"] = lh.DisplayNameZH
+ ev["expert_industry_id"] = ""
+ }
+ }
+ events = append(events, upstream.DesktopExpertActualUseLocal(lh, conv, req))
+ // 对齐真实样本细节:轻量云专家 actual_use 的 type 为空、cost=0。
+ events[len(events)-1]["type"] = ""
+ events[len(events)-1]["cost"] = 0
+ if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil {
+ return "", fmt.Errorf("使用事件: %w", err)
+ }
+ return "已上报轻量云专家召唤+使用链(真实对话 requestId)", nil
+}
+
+// runAppearance 完成 Hp_Appearance(换主题)。
+// 2026-09-12 紫川/人杰2 实测(desktopverify12):判据是 appearance_skin_apply
+// 事件 {action:"apply",source:"settings_close",id:,...}(客户端在
+// 主题生效状态下离开设置页时上报)——早期"纯 API set 不计分"的结论不准确,
+// 真相是当时只调了 appearance/set 没发事件。组合:set API 留痕 + 事件上报。
+func runAppearance(p *Panel, a *auth.Auth) (string, error) {
+ const themeKey = "theme-tkmw7j" // 和平精英激战金秋(Hp_Appearance 判据主题)
+ if err := p.cfg.Upstream.SetAppearanceTheme(a, themeKey); err != nil {
+ return "", fmt.Errorf("设置主题: %w", err)
+ }
+ time.Sleep(2 * time.Second)
+ if err := p.cfg.Upstream.ReportDesktopEvent(a, upstream.DesktopEvent{
+ "eventCode": "appearance_skin_apply", "action": "apply", "source": "settings_close",
+ "id": themeKey, "vipLevel": 0, "series": "", "type": "unknown",
+ }); err != nil {
+ return "", err
+ }
+ return "已设置主题并上报皮肤生效事件", nil
+}
+
+// runTemplateUse 完成 template_5(使用 5 个模板创建任务)。
+// 2026-09-12 三账号实测:agent_task_created_with_template + template_used 事件组
+// (JOIN chat 链)一次上报 5 组即 5/5 点亮。template_id 服务端不校验真实性。
+func runTemplateUse(p *Panel, a *auth.Auth) (string, error) {
+ templates := [][2]string{{"1", "深度研究"}, {"2", "周报生成"}, {"3", "竞品分析"}, {"4", "活动策划"}, {"5", "代码评审"}}
+ for i, tp := range templates {
+ ms := time.Now().UnixMilli()
+ conv := fmt.Sprintf("wb2api-tpl-%d-%d", ms, i)
+ req := fmt.Sprintf("wb2api-tpl-req-%d-%d", ms, i)
+ events := upstream.DesktopTemplateUseSequence(conv, req, tp[0], tp[1])
+ if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil {
+ return fmt.Sprintf("第 %d 组模板事件上报失败: %v", i+1, err), nil
+ }
+ time.Sleep(300 * time.Millisecond)
+ }
+ return "已上报 template_used ×5", nil
+}
+
+// runPlaybookPrompt 完成 playbook_prompt(灵感案例 Dialog 中发送 Prompt)。
+// 判据是 playbook_prompt_send(Dialog 发送)而非卡片曝光/点击——asar 逆向确认。
+func runPlaybookPrompt(p *Panel, a *auth.Auth) (string, error) {
+ ms := time.Now().UnixMilli()
+ conv := fmt.Sprintf("wb2api-pb-%d", ms)
+ req := fmt.Sprintf("wb2api-pb-req-%d", ms)
+ events := upstream.DesktopPlaybookPromptSequence(conv, req, "pm-gtm-launch-plan", "新产品上市 GTM 发布计划一页纸")
+ if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil {
+ return "", err
+ }
+ return "已上报 playbook_cta_click + playbook_prompt_send", nil
+}
+
+// runCreateCanvas 完成 create_canvas(设计创意模式创建画布,+300 分)。
+// 判据是 wbx_design_canvas_task_create/open(Ardot create_design 工具完成遥测)。
+func runCreateCanvas(p *Panel, a *auth.Auth) (string, error) {
+ ms := time.Now().UnixMilli()
+ conv := fmt.Sprintf("wb2api-canvas-%d", ms)
+ req := fmt.Sprintf("wb2api-canvas-req-%d", ms)
+ events := upstream.DesktopDesignCanvasSequence(conv, req)
+ if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil {
+ return "", err
+ }
+ return "已上报 wbx_design_canvas_task_create/open", nil
+}
+
+// expertSummonGap 专家召唤链的间隔(真实使用节奏,v11 实测 8s 成功率 100%)。
+const expertSummonGap = 6 * time.Second
+
+// runExpertUse 完成 expert_5(使用 5 个平台专家)。
+// 2026-09-12 三账号实测公式:真实专家列表(id 必须真实存在)→ 召唤链
+// (summon_click/summoned)→ 真实 chat 拿服务端 requestId → expert_actual_use。
+// 自造专家 id 或自造 requestId 均不计数。
+func runExpertUse(p *Panel, a *auth.Auth) (string, error) {
+ return runExpertBatch(p, a, "agent", 5)
+}
+
+// runExpertTeamUse 完成 Expert_team_use_3(使用 3 个专家团,expertType=team)。
+func runExpertTeamUse(p *Panel, a *auth.Auth) (string, error) {
+ return runExpertBatch(p, a, "team", 3)
+}
+
+// runExpertBatch 专家召唤+使用的公共实现。失败逐个继续,返回汇总信息。
+func runExpertBatch(p *Panel, a *auth.Auth, expertType string, count int) (string, error) {
+ experts, err := p.cfg.Upstream.MarketExpertList(a, expertType)
+ if err != nil {
+ return "", fmt.Errorf("拉取专家列表: %w", err)
+ }
+ if len(experts) == 0 {
+ return "", fmt.Errorf("专家市场列表为空")
+ }
+ ok, fail := 0, 0
+ for i, e := range experts {
+ if ok >= count {
+ break
+ }
+ // 召唤链(web_element_click + summon_click + summoned)。
+ summonEvents := upstream.DesktopExpertSummonSequence(e)
+ if err := p.cfg.Upstream.ReportDesktopEvent(a, summonEvents...); err != nil {
+ fail++
+ continue
+ }
+ // 真实 chat(带 X-Expert-Id)→ 服务端 requestId。
+ conv, req, cerr := p.cfg.Upstream.DesktopChatWithExpert(a, e.ExpertID)
+ if cerr != nil {
+ fail++
+ continue
+ }
+ // 使用事件(JOIN 服务端 requestId)+ chat 链。
+ events := append(upstream.DesktopChatSequence(conv, req, "msg-"+req[len(req)-8:], "fast-model", "fast-model"),
+ upstream.DesktopExpertActualUseEvent(e, conv, req))
+ if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil {
+ fail++
+ continue
+ }
+ ok++
+ if i < len(experts)-1 {
+ time.Sleep(expertSummonGap)
+ }
+ }
+ _ = fail
+ return fmt.Sprintf("已对 %d 位真实专家完成召唤+使用链(类型 %s)", ok, expertType), nil
+}
+
+// ---------------------------------------------------------------------------
+// 全量自动完成
+// ---------------------------------------------------------------------------
+
+// runAutoAll 对单账号依次执行所有可自动化任务,返回逐项结果。
+// 供「一键完成全部可自动任务」使用;单项失败不影响后续项。
+//
+// 流程:先把所有未接受的任务批量 accept(规范状态机;上游脚本建议"先 accept"),
+// 再逐项执行行为链路。accept 不是进度产生的必要条件,但让后续状态流转规范。
+func (p *Panel) runAutoAll(a *auth.Auth) []map[string]any {
+ var out []map[string]any
+
+ // 阶段 0:批量接受尚未接受的任务(失败不阻塞——行为事件才是进度唯一判据)。
+ if tasks, err := p.cfg.Upstream.ListTasks(a); err == nil {
+ var codes []string
+ for _, t := range tasks {
+ if !t.Claimed && !t.Locked && t.AcceptStatus != "accepted" && t.AcceptStatus != "completed" {
+ codes = append(codes, t.TaskCode)
+ }
+ }
+ if len(codes) > 0 {
+ if err := p.cfg.Upstream.AcceptTasks(a, codes); err != nil {
+ out = append(out, map[string]any{
+ "task_code": "(批量接受)", "status": "error",
+ "message": "接受任务失败(不阻塞后续): " + err.Error(),
+ })
+ } else {
+ out = append(out, map[string]any{
+ "task_code": "(批量接受)", "status": "done",
+ "message": fmt.Sprintf("已接受 %d 个任务", len(codes)),
+ })
+ time.Sleep(reportGap)
+ }
+ }
+ }
+
+ // 阶段 0b:小程序口径任务单独接受(默认列表不含 mp 码;失败不阻塞)。
+ if mpTasks, err := p.cfg.Upstream.ListTasksMP(a); err == nil {
+ var mpCodes []string
+ for _, t := range mpTasks {
+ if !t.Claimed && !t.Locked && t.AcceptStatus != "accepted" && t.AcceptStatus != "completed" {
+ mpCodes = append(mpCodes, t.TaskCode)
+ }
+ }
+ if len(mpCodes) > 0 {
+ if err := p.cfg.Upstream.AcceptTasksMP(a, mpCodes); err != nil {
+ out = append(out, map[string]any{
+ "task_code": "(批量接受-mp)", "status": "error",
+ "message": "接受小程序任务失败(不阻塞后续): " + err.Error(),
+ })
+ } else {
+ out = append(out, map[string]any{
+ "task_code": "(批量接受-mp)", "status": "done",
+ "message": fmt.Sprintf("已接受 %d 个小程序任务", len(mpCodes)),
+ })
+ time.Sleep(reportGap)
+ }
+ }
+ }
+
+ for _, act := range autoActions {
+ item := map[string]any{"task_code": act.TaskCode, "desc": act.Desc}
+ before, err := p.taskByCode(a, act.TaskCode)
+ if err != nil {
+ item["status"] = "error"
+ item["message"] = "查询失败: " + err.Error()
+ out = append(out, item)
+ continue
+ }
+ if before == nil {
+ item["status"] = "skipped"
+ item["message"] = "该账号无此任务"
+ out = append(out, item)
+ continue
+ }
+ if before.Claimed || before.Current >= before.Target && before.Target > 0 {
+ item["status"] = "skipped"
+ item["message"] = "已完成(" + taskProgressText(before) + ")"
+ out = append(out, item)
+ continue
+ }
+ msg, err := act.run(p, a)
+ if err != nil {
+ item["status"] = "error"
+ item["message"] = err.Error()
+ out = append(out, item)
+ continue
+ }
+ var after *upstream.Task
+ if isMPTaskCode(act.TaskCode) {
+ after, _ = p.taskByCodeMP(a, act.TaskCode)
+ } else {
+ after, _ = p.taskByCodeWaiting(a, act.TaskCode)
+ }
+ item["status"] = "done"
+ item["message"] = msg
+ item["progress_after"] = taskProgressText(after)
+ // 进度达标即自动领奖(mp 任务走 chat 域 mp 口径,其余 Web 端接口)。
+ // 领奖失败不掩盖主流程结果:status 仍为 done,附加 claim_error 供前端提示。
+ if after != nil && after.Claimable {
+ item["claimable"] = true
+ var credit, energy int64
+ var cerr error
+ if isMPTaskCode(act.TaskCode) {
+ credit, energy, cerr = p.cfg.Upstream.ClaimRewardMP(a, act.TaskCode)
+ } else {
+ credit, energy, cerr = p.cfg.Upstream.ClaimReward(a, act.TaskCode)
+ }
+ if cerr == nil {
+ item["claimed"] = true
+ item["credit"] = credit
+ item["energy"] = energy
+ if credit > 0 || energy > 0 {
+ item["message"] = msg + fmt.Sprintf(";已自动领奖 +%d 分 +%d 能", credit, energy)
+ } else {
+ item["message"] = msg + ";奖励此前已领取"
+ }
+ } else {
+ item["claim_error"] = cerr.Error()
+ item["message"] = msg + ";达标但领奖失败(可在列表手动重试)"
+ }
+ }
+ out = append(out, item)
+ time.Sleep(reportGap) // 项间节流
+ }
+ return out
+}
+
+// accountTaskAutoAll 一键完成该账号全部可自动任务。
+func (p *Panel) accountTaskAutoAll(w http.ResponseWriter, r *http.Request) {
+ uid := r.PathValue("uid")
+ a := p.accountByUID(w, uid)
+ if a == nil {
+ return
+ }
+ // per-account 互斥(与单任务动作共用一把锁):重复点击 409。
+ if !p.tryLockAccount(uid) {
+ writeErr(w, http.StatusConflict, "该账号有任务动作正在执行中,请等本轮结束后再试")
+ return
+ }
+ // 用 context 兜底超时(多项任务串联 + 每项含真实对话,可能耗时较长)。
+ ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
+ defer cancel()
+ done := make(chan []map[string]any, 1)
+ go func() {
+ defer p.unlockAccount(uid) // 流水线真正结束(而非 HTTP 超时返回)才放锁
+ done <- p.runAutoAll(a)
+ }()
+ select {
+ case results := <-done:
+ log.Printf("panel: 一键完成可自动任务 uid=%s 共 %d 项", uid, len(results))
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "results": results})
+ case <-ctx.Done():
+ // HTTP 侧超时返回,但后台流水线仍在跑——锁在流水线 goroutine 内释放,
+ // 期间重复点击会被 409 挡住,不会出现两轮并发。
+ writeErr(w, http.StatusGatewayTimeout, "执行超时(任务仍在后台继续)")
+ }
+}
diff --git a/internal/panel/autotask_lock_test.go b/internal/panel/autotask_lock_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..e7d0e6706cc051ec070cf4206e914c1cf4ae2e7c
--- /dev/null
+++ b/internal/panel/autotask_lock_test.go
@@ -0,0 +1,52 @@
+package panel
+
+import (
+ "testing"
+)
+
+// TestTaskAccountLockSameAccountExclusive 同一账号的任务锁互斥:第二次 tryLock 必须失败,
+// 解锁后可再次获取。这是「重复点一键完成不并发重跑」的核心保障。
+func TestTaskAccountLockSameAccountExclusive(t *testing.T) {
+ p := &Panel{}
+ uid := "u1"
+
+ if !p.tryLockAccount(uid) {
+ t.Fatal("首次加锁应成功")
+ }
+ if p.tryLockAccount(uid) {
+ t.Fatal("同账号第二次加锁应失败(互斥)")
+ }
+ p.unlockAccount(uid)
+
+ if !p.tryLockAccount(uid) {
+ t.Fatal("解锁后应可再次加锁")
+ }
+ p.unlockAccount(uid)
+}
+
+// TestTaskAccountLockDifferentAccountsIndependent 不同账号的锁互不影响(并行照旧)。
+func TestTaskAccountLockDifferentAccountsIndependent(t *testing.T) {
+ p := &Panel{}
+ if !p.tryLockAccount("u1") {
+ t.Fatal("u1 加锁应成功")
+ }
+ if !p.tryLockAccount("u2") {
+ t.Fatal("u2 加锁应成功(不同账号不互斥)")
+ }
+ p.unlockAccount("u2")
+ p.unlockAccount("u1")
+}
+
+// TestTaskAccountLockCrossEntryShared 单任务 auto 与全量 auto_all 共用同一把账号锁
+// (在 handler 层都走 tryLockAccount,这里验证锁命名空间一致)。
+func TestTaskAccountLockCrossEntryShared(t *testing.T) {
+ p := &Panel{}
+ if !p.tryLockAccount("u1") {
+ t.Fatal("u1 加锁应成功")
+ }
+ // 模拟全量入口对同一 uid 加锁——必须被挡(否则两入口可并发)。
+ if p.tryLockAccount("u1") {
+ t.Fatal("同 uid 跨入口加锁应失败(共用锁)")
+ }
+ p.unlockAccount("u1")
+}
diff --git a/internal/panel/config.go b/internal/panel/config.go
new file mode 100644
index 0000000000000000000000000000000000000000..efda8ae327dd8e6099bacd1a8ee7e63f6c6d8bcc
--- /dev/null
+++ b/internal/panel/config.go
@@ -0,0 +1,57 @@
+// config.go 面板配置页接口:读取当前配置、校验并保存(热生效 + 重启项标注)。
+//
+// 分工:cmd/server 持有 Config 类型与校验逻辑(Load/normalize),此处只做
+// HTTP 编排——GET 回显、POST 透传给注入的 SaveConfig 闭包(由 main 完成
+// "校验 → 落盘 → 热应用 → 返回需重启字段列表")。
+package panel
+
+import (
+ "io"
+ "log"
+ "net/http"
+)
+
+// getConfig 返回当前配置文件内容与路径(前端按 schema 渲染表单)。
+func (p *Panel) getConfig(w http.ResponseWriter, r *http.Request) {
+ if p.cfg.LoadConfig == nil {
+ writeErr(w, http.StatusNotImplemented, "config api not available")
+ return
+ }
+ cfg, err := p.cfg.LoadConfig()
+ if err != nil {
+ writeErr(w, http.StatusInternalServerError, "load config: "+err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{
+ "ok": true,
+ "path": p.cfg.ConfigPath,
+ "config": cfg,
+ })
+}
+
+// saveConfig 保存配置:body 直接是配置 JSON(前端按 schema 组装完整对象)。
+// SaveConfig 闭包内部完成校验+落盘+热应用;校验失败返回 400 且不写盘。
+func (p *Panel) saveConfig(w http.ResponseWriter, r *http.Request) {
+ if p.cfg.SaveConfig == nil {
+ writeErr(w, http.StatusNotImplemented, "config api not available")
+ return
+ }
+ raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
+ if err != nil {
+ writeErr(w, http.StatusBadRequest, "read body: "+err.Error())
+ return
+ }
+ restartRequired, err := p.cfg.SaveConfig(raw)
+ if err != nil {
+ writeErr(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ if restartRequired == nil {
+ restartRequired = []string{}
+ }
+ log.Printf("panel: 配置已保存(热生效完成;需重启字段 %d 个)", len(restartRequired))
+ writeJSON(w, http.StatusOK, map[string]any{
+ "ok": true,
+ "restart_required": restartRequired,
+ })
+}
diff --git a/internal/panel/frontend_test.go b/internal/panel/frontend_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..d9d3873026875b53875bd108a47c9fa0d121c869
--- /dev/null
+++ b/internal/panel/frontend_test.go
@@ -0,0 +1,647 @@
+package panel
+
+import (
+ "bytes"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+)
+
+// TestAppJSSyntax app.js 必须能通过 JS 解析器语法校验。
+//
+// 为什么需要:app.js 是 go:embed 进二进制的静态资源,Go 编译器不检查其内容——
+// 一次对象字面量键名未加引号(Model_chat_GLM5.2 被解析成属性访问 + 数字字面量)
+// 就让整个面板白屏,而所有 Go 测试依然全绿。此测试把语法校验前移到 CI。
+// 无 node 环境时跳过(不阻塞无 Node 的构建机)。
+func TestAppJSSyntax(t *testing.T) {
+ node, err := exec.LookPath("node")
+ if err != nil {
+ t.Skip("node not available; skipping JS syntax check")
+ }
+ path, err := filepath.Abs("app.js")
+ if err != nil {
+ t.Fatal(err)
+ }
+ out, err := exec.Command(node, "--check", path).CombinedOutput()
+ if err != nil {
+ t.Fatalf("app.js syntax error:\n%s", out)
+ }
+}
+
+// TestIndexHTMLNoInlineScript index.html 不得含内联
+