// ==UserScript== // @name CodeBuddy 每日积分消耗汇总 // @namespace https://docs.scriptcat.org/ // @version 1.5.0 // @description 在 CodeBuddy「套餐用量」页面按天汇总积分消耗:顶部居中入口按钮 + 自右侧滑入的抽屉面板(点击抽屉外自动收起),支持自定义区间、趋势柱状图、模型分布、CSV 导出 // @author You // @match https://www.codebuddy.cn/profile/* // @grant none // @noframes // @run-at document-idle // @license MIT // ==/UserScript== /* ───────────────────────────────────────────────────────────────────────────── * 接口实测结论(决定了下面的采集策略,改动前请务必先看这里) * * POST /billing/meter/get-user-request-usage * body: { startTime:"YYYY-MM-DD HH:mm:ss", endTime:"YYYY-MM-DD HH:mm:ss", * pageNum:1, pageSize:1000 } * 响应: { code:0, data:{ total:, data:[{ requestId, credit, model, * client, requestTime:"YYYY-MM-DD HH:mm:ss", input, agentPurpose }] } } * * 实测到的三个坑: * * ① total 硬上限 3000,且宽区间会【静默丢弃最新的数据】。 * 例:8/24~9/10 实际 3346 条,接口只返回 3000 条,结果 9/9、9/10 整天为 0, * 合计少算 809 积分(8.3%)。区间越宽丢得越多。 * → 不能依赖宽区间查询。 * * ② 单次查询的时间跨度上限约 32 天,超过(实测 35 天)直接返回 total=0。 * * ③ 【跨边界记录的 credit 随查询窗口变化】。 * 同一条 requestId=4a7946d6…(requestTime=2026-08-26 23:56:00): * 窗口 8/26 单日 → credit = 8.43 * 窗口 8/25~8/26 → credit = 8.43 * 窗口 8/26~8/27 → credit = 8.74 ← 饱和值 * 窗口 8/24~8/31 → credit = 8.74 * 窗口 8/01~8/31 → credit = 8.74 * 即窗口一旦延伸到该记录之后,取值就稳定下来。窗口卡在当天会漏掉尾部结算。 * → 每日查询时把 endTime 向后多含 1 天,再按 requestTime 归属回当天。 * * 单日查询是确定性的:同一天连查 3 次、换不同 pageSize 翻页,结果完全一致。 * * 结论:采用【逐日查询 + 向后多含 1 天窗口】,30 天约 30 次请求,并发 4, * 既绕开 3000 截断,又能取到饱和的积分值。 * ───────────────────────────────────────────────────────────────────────────── */ (function () { 'use strict'; /* ========================================================================== * 0. 常量 * ======================================================================== */ const API_PATH = '/billing/meter/get-user-request-usage'; const PAGE_SIZE = 1000; // 实测 1000 可用 const CONCURRENCY = 4; // 并发请求上限 const TOTAL_CAP = 3000; // 服务端 total 上限,达到即认为被截断 const TAIL_DAYS = 1; // 窗口向后多含的天数(见上文 ③) const CACHE_KEY = 'cb-usage-daily-summary-v2'; const SOFT_MAX_DAYS = 180; // 超过这个跨度给个提醒 /* ========================================================================== * 1. 通用工具 * ======================================================================== */ const pad2 = (n) => String(n).padStart(2, '0'); /** 'YYYY-MM-DD' -> Date(按本地时区解析,避免 new Date(str) 的 UTC 坑) */ function parseDay(s) { const [y, m, d] = String(s).split('-').map(Number); return new Date(y, m - 1, d); } /** Date -> 'YYYY-MM-DD' */ function fmtDay(d) { return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; } function addDays(d, n) { const x = new Date(d.getFullYear(), d.getMonth(), d.getDate()); x.setDate(x.getDate() + n); return x; } function today() { const n = new Date(); return new Date(n.getFullYear(), n.getMonth(), n.getDate()); } const withStart = (s) => `${s} 00:00:00`; const withEnd = (s) => `${s} 23:59:59`; /** 积分为「分」的整数展示 */ function fmtCredit(cents) { return (cents / 100).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2, }); } function fmtInt(n) { return Number(n).toLocaleString('zh-CN'); } /** 并发闸门:把不限量的并发调用压到 max 以内 */ function createLimiter(max) { let active = 0; const queue = []; const pump = () => { if (active >= max || queue.length === 0) return; active++; const task = queue.shift(); task.fn().then(task.resolve, task.reject).finally(() => { active--; pump(); }); }; return (fn) => new Promise((resolve, reject) => { queue.push({ fn, resolve, reject }); pump(); }); } /* ========================================================================== * 2. 接口层 * ======================================================================== */ /** 拉取单页 */ async function fetchPage(startTime, endTime, pageNum, pageSize) { const res = await fetch(API_PATH, { method: 'POST', credentials: 'same-origin', headers: { accept: 'application/json, text/plain, */*', 'content-type': 'application/json', 'x-client-platform': 'web', }, body: JSON.stringify({ startTime, endTime, pageNum, pageSize }), }); if (res.status === 401 || res.status === 403) { throw new Error('登录状态已失效,请先刷新页面重新登录'); } if (!res.ok) throw new Error(`接口返回 HTTP ${res.status}`); const json = await res.json(); if (json.code !== 0) throw new Error(json.msg || `接口业务错误 code=${json.code}`); const total = Number(json.data && json.data.total) || 0; const list = Array.isArray(json.data && json.data.data) ? json.data.data : []; return { total, list, capped: total >= TOTAL_CAP }; } /** 拉取一个时间窗口内的全部记录(自动翻页),返回 { records, total, capped } */ async function fetchWindow(startTime, endTime) { const first = await fetchPage(startTime, endTime, 1, PAGE_SIZE); const all = first.list.slice(); const seen = new Set(all.map((r) => r.requestId)); // total 已被服务端截断在 3000,最多翻到 3000 条为止 let pageNum = 2; while (all.length < first.total && pageNum * PAGE_SIZE <= TOTAL_CAP + PAGE_SIZE) { const next = await fetchPage(startTime, endTime, pageNum, PAGE_SIZE); if (!next.list.length) break; for (const r of next.list) { const k = r.requestId || `${r.requestTime}|${r.model}|${r.credit}`; if (!seen.has(k)) { seen.add(k); all.push(r); } } pageNum++; if (pageNum > 10) break; // 安全阀 } return { records: all, total: first.total, capped: first.capped }; } /** * 查询某一天的记录。 * 窗口 = [当天 00:00:00, 次日 23:59:59],再按 requestTime 过滤回当天。 * 向后多含一天是为了取到跨日结算记录的「饱和值」(见文件头 ③)。 */ async function fetchDay(day) { const tail = fmtDay(addDays(parseDay(day), TAIL_DAYS)); let res = await fetchWindow(withStart(day), withEnd(tail)); if (res.capped) { // 两天窗口都触顶了,退回严格单日窗口(会牺牲尾部结算的精度) res = await fetchWindow(withStart(day), withEnd(day)); } const out = new Map(); for (const r of res.records) { if (String(r.requestTime || '').slice(0, 10) !== day) continue; // 只留归属当天的 const k = r.requestId || `${r.requestTime}|${r.model}|${r.credit}`; if (!out.has(k)) out.set(k, r); } return { records: [...out.values()], incomplete: res.capped }; } /* ========================================================================== * 3. 日期区间 * ======================================================================== */ /** 列出闭区间内每一天(升序) */ function eachDay(dayFrom, dayTo) { const out = []; const end = parseDay(dayTo); for (let d = parseDay(dayFrom); d <= end; d = addDays(d, 1)) out.push(fmtDay(d)); return out; } /* ========================================================================== * 4. 采集 * ======================================================================== */ /** * 逐日采集。返回: * days Map<'YYYY-MM-DD', record[]> * requests 实际发起的接口请求数 * incompleteDays 因触顶可能不完整的日子 * failedDays { day, message } 列表 */ async function collect(dayFrom, dayTo, onProgress) { const limit = createLimiter(CONCURRENCY); const allDays = eachDay(dayFrom, dayTo); const days = new Map(); const incompleteDays = []; const failedDays = []; let done = 0; let requests = 0; await Promise.all( allDays.map((day) => limit(async () => { try { const res = await fetchDay(day); requests++; days.set(day, res.records); if (res.incomplete) incompleteDays.push(day); } catch (err) { // 单日失败不影响其他日期,最后统一汇报 days.set(day, []); failedDays.push({ day, message: err && err.message ? err.message : String(err) }); } finally { done++; onProgress(`正在统计 ${day} …(${done}/${allDays.length} 天,已请求 ${requests} 次)`); } }) ) ); return { days, requests, incompleteDays, failedDays }; } /** * 聚合:按天汇总积分与次数,同时统计模型维度。 * 积分统一换算成「分」做整数累加,规避浮点误差 * (服务端返回 1275.39999926 这类值,直接浮点相加会累积偏差)。 */ function aggregate(daysMap, allDays, dayFrom, dayTo) { const byModel = new Map(); let totalCents = 0; let totalCount = 0; const rows = allDays.map((day) => { const records = daysMap.get(day) || []; let cents = 0; for (const r of records) { const c = Math.round((Number(r.credit) || 0) * 100); cents += c; const name = r.model || '未知'; const m = byModel.get(name) || { model: name, cents: 0, count: 0 }; m.cents += c; m.count += 1; byModel.set(name, m); } totalCents += cents; totalCount += records.length; return { day, cents, count: records.length }; }); // 表格默认按日期倒序展示 rows.reverse(); const models = [...byModel.values()].sort((a, b) => b.cents - a.cents || b.count - a.count); const maxCents = rows.reduce((mx, d) => Math.max(mx, d.cents), 0); return { rows, models, totalCents, totalCount, maxCents, spanDays: allDays.length, dayFrom, dayTo }; } /* ========================================================================== * 5. 缓存 * ======================================================================== */ function saveCache(payload) { try { localStorage.setItem(CACHE_KEY, JSON.stringify(payload)); } catch (_) { /* 隐私模式等场景下可能失败,忽略 */ } } function loadCache() { try { const raw = localStorage.getItem(CACHE_KEY); if (!raw) return null; const data = JSON.parse(raw); if (!data || !Array.isArray(data.days)) return null; return data; } catch (_) { return null; } } /* ========================================================================== * 6. 样式 * ======================================================================== */ const CSS = ` :host { /* 顺序不能反:all 会把写在前面的自定义属性一起重置掉, 所以先 all: initial 隔离宿主页面继承来的样式,再声明变量。 */ all: initial; --bg: #ffffff; --bg-soft: #f6f8fb; --fg: #1f2328; --fg-sub: #6b7280; --line: #e5e8ee; --accent: #3b6ef0; --accent-soft: #eaf0ff; --accent-2: #7c5cf0; --warn: #b8730a; --warn-soft: #fdf4e3; --danger: #d9453d; --ok: #2e9e5b; --shadow: 0 12px 40px rgba(15, 23, 42, .18); --shadow-left: -18px 0 44px rgba(15, 23, 42, .16); /* 入口按钮距页面顶部的留白。若是压住了站点自带顶栏,把这个值调大即可, 例如改成 72px 就落到顶栏下方。(数据面板是右侧抽屉,不受它影响。) */ --cbs-top: 16px; display: block; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; } @media (prefers-color-scheme: dark) { :host { --bg: #1b1e26; --bg-soft: #232733; --fg: #e8ebf2; --fg-sub: #9aa3b2; --line: #2f3644; --accent: #6c93ff; --accent-soft: #253052; --accent-2: #a48bff; --warn: #e0b062; --warn-soft: #3a3020; --danger: #ff7b72; --ok: #56d364; --shadow: 0 12px 40px rgba(0, 0, 0, .55); --shadow-left: -18px 0 44px rgba(0, 0, 0, .5); } } * { box-sizing: border-box; } /* ---------- 入口按钮(顶部居中) ---------- */ /* 居中靠 left:50% + translateX(-50%),所以 hover 的位移必须写成 translate(-50%, -2px), 一旦覆盖成 translateY 就会丢失水平居中偏移,按钮会向右偏半个自身宽度。 */ .fab { position: fixed; top: var(--cbs-top); left: 50%; transform: translateX(-50%); z-index: 2147483000; display: inline-flex; align-items: center; gap: 8px; padding: 11px 18px; border: none; border-radius: 999px; cursor: pointer; font-size: 14px; font-weight: 600; color: #fff; background: linear-gradient(135deg, var(--accent), var(--accent-2)); box-shadow: 0 6px 20px rgba(59, 110, 240, .38); transition: transform .18s ease, box-shadow .18s ease; } .fab:hover { transform: translate(-50%, -2px); box-shadow: 0 10px 26px rgba(59, 110, 240, .48); } .fab:active { transform: translate(-50%, 0); } .fab[hidden] { display: none; } /* ---------- 数据面板(贴窗口右缘的抽屉,自右向左滑入) ---------- */ /* 显隐用 visibility 而不是 display: display: none 的元素不参与过渡,抽屉会「凭空出现」而不是滑入。 所以收起态保留布局(translateX(100%) 停在视口外), 并把 visibility 的切换延迟到滑出动画结束,避免动画途中就消失。 展开态则把延迟归零,让 visibility 立即生效。 */ .panel { position: fixed; top: 0; right: 0; bottom: 0; z-index: 2147483000; width: 780px; max-width: calc(100vw - 16px); display: flex; flex-direction: column; background: var(--bg); color: var(--fg); border-left: 1px solid var(--line); box-shadow: var(--shadow-left); overflow: hidden; transform: translateX(100%); visibility: hidden; transition: transform .3s cubic-bezier(.22, .61, .36, 1), visibility 0s linear .3s; } .panel.open { transform: translateX(0); visibility: visible; transition: transform .3s cubic-bezier(.22, .61, .36, 1), visibility 0s; } /* 系统开启「减弱动态效果」时不做位移动画。 必须加 !important:.panel.open 的 transition 优先级(0,2,0)高于 .panel(0,1,0), 否则这条覆盖对「展开」这一半根本不生效。 */ @media (prefers-reduced-motion: reduce) { .panel, .fab { transition: none !important; } } .head { display: flex; align-items: center; gap: 10px; padding: 14px 16px; border-bottom: 1px solid var(--line); background: var(--bg-soft); flex: 0 0 auto; } .head h3 { margin: 0; font-size: 15px; font-weight: 700; } .head .tag { font-size: 11px; padding: 2px 7px; border-radius: 999px; background: var(--accent-soft); color: var(--accent); font-weight: 600; } .head .spacer { flex: 1; } .icon-btn { border: 1px solid var(--line); background: var(--bg); color: var(--fg-sub); width: 28px; height: 28px; border-radius: 8px; cursor: pointer; font-size: 15px; line-height: 1; display: grid; place-items: center; } .icon-btn:hover { color: var(--fg); border-color: var(--fg-sub); } .body { padding: 14px 16px 18px; overflow: auto; flex: 1 1 auto; } /* ---------- 查询条 ---------- */ .toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } .chip { border: 1px solid var(--line); background: var(--bg); color: var(--fg-sub); padding: 6px 12px; border-radius: 999px; font-size: 12.5px; cursor: pointer; font-family: inherit; } .chip:hover { border-color: var(--accent); color: var(--accent); } .chip.active { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); font-weight: 600; } .sep { width: 1px; height: 20px; background: var(--line); margin: 0 2px; } input[type="date"] { border: 1px solid var(--line); background: var(--bg); color: var(--fg); padding: 6px 8px; border-radius: 8px; font-size: 12.5px; font-family: inherit; color-scheme: light dark; } .till { color: var(--fg-sub); font-size: 12.5px; } .primary { border: none; background: var(--accent); color: #fff; font-weight: 600; padding: 7px 16px; border-radius: 8px; cursor: pointer; font-size: 13px; font-family: inherit; } .primary:hover:not(:disabled) { filter: brightness(1.08); } .primary:disabled { opacity: .55; cursor: not-allowed; } .ghost { border: 1px solid var(--line); background: var(--bg); color: var(--fg); padding: 7px 14px; border-radius: 8px; cursor: pointer; font-size: 13px; font-family: inherit; } .ghost:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); } .ghost:disabled { opacity: .5; cursor: not-allowed; } .status { margin-top: 12px; font-size: 12.5px; color: var(--fg-sub); display: flex; align-items: center; gap: 8px; min-height: 18px; } .status.error { color: var(--danger); } .status.done { color: var(--ok); } .status.warn { color: var(--warn); } .spinner { width: 13px; height: 13px; border-radius: 50%; flex: 0 0 auto; border: 2px solid var(--line); border-top-color: var(--accent); animation: cbs-spin .7s linear infinite; } @keyframes cbs-spin { to { transform: rotate(360deg); } } .progress { height: 3px; margin-top: 8px; border-radius: 2px; background: var(--line); overflow: hidden; } .progress > i { display: block; height: 100%; width: 30%; background: linear-gradient(90deg, var(--accent), var(--accent-2)); animation: cbs-slide 1.1s ease-in-out infinite; } @keyframes cbs-slide { 0% { margin-left: -30%; } 100% { margin-left: 100%; } } /* ---------- 概览卡片 ---------- */ .cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-top: 14px; } .card { border: 1px solid var(--line); border-radius: 10px; padding: 11px 12px; background: var(--bg-soft); } .card .k { font-size: 11.5px; color: var(--fg-sub); margin-bottom: 5px; } .card .v { font-size: 19px; font-weight: 700; letter-spacing: -.3px; } .card .v small { font-size: 11.5px; font-weight: 500; color: var(--fg-sub); margin-left: 3px; } .card.hl .v { color: var(--accent); } /* ---------- 柱状图 ---------- */ .chart-wrap { margin-top: 18px; } .sec-title { font-size: 12.5px; font-weight: 700; color: var(--fg-sub); margin: 0 0 9px; display: flex; align-items: center; gap: 8px; } .sec-title::after { content: ""; flex: 1; height: 1px; background: var(--line); } .chart { display: flex; align-items: flex-end; gap: 3px; height: 132px; padding: 8px 2px 0; border-bottom: 1px solid var(--line); overflow-x: auto; } .bar { flex: 1 0 12px; min-width: 12px; display: flex; flex-direction: column; justify-content: flex-end; height: 100%; position: relative; } .bar > i { display: block; width: 100%; border-radius: 3px 3px 0 0; background: linear-gradient(180deg, var(--accent), var(--accent-2)); min-height: 2px; transition: opacity .15s; } .bar.zero > i { background: var(--line); } .bar:hover > i { opacity: .72; } .bar .tip { /* 用 display 而非 opacity 隐藏:不可见但仍在布局里的宽 tooltip 会把 .chart 撑出横向滚动条。锚在图表内容区顶部,避免向上溢出。 首尾两根柱子改为贴边对齐,防止 tooltip 从左右两侧溢出。 */ position: absolute; top: 2px; left: 50%; transform: translateX(-50%); background: #111827; color: #fff; font-size: 11px; padding: 4px 8px; border-radius: 6px; white-space: nowrap; display: none; pointer-events: none; z-index: 5; } .bar:hover .tip { display: block; animation: cbs-tip-in .12s ease both; } .bar:first-child .tip { left: 0; transform: none; } .bar:last-child .tip { left: auto; right: 0; transform: none; } @keyframes cbs-tip-in { from { opacity: 0; } to { opacity: 1; } } .axis { display: flex; gap: 3px; padding: 0 2px; margin-top: 5px; } .axis span { flex: 1 0 12px; min-width: 12px; font-size: 9.5px; color: var(--fg-sub); text-align: center; overflow: hidden; } /* ---------- 表格 ---------- */ .table-wrap { margin-top: 6px; max-height: 300px; overflow: auto; border: 1px solid var(--line); border-radius: 10px; } table { width: 100%; border-collapse: collapse; font-size: 12.5px; } thead th { position: sticky; top: 0; z-index: 2; background: var(--bg-soft); text-align: right; padding: 8px 12px; font-weight: 600; color: var(--fg-sub); border-bottom: 1px solid var(--line); white-space: nowrap; font-size: 12px; } thead th:first-child { text-align: left; } tbody td { padding: 7px 12px; border-bottom: 1px solid var(--line); text-align: right; font-variant-numeric: tabular-nums; } tbody td:first-child { text-align: left; font-variant-numeric: normal; } tbody tr:last-child td { border-bottom: none; } tbody tr:hover td { background: var(--bg-soft); } tbody tr.best td { background: var(--accent-soft); } tbody tr.best td:first-child::after { content: "峰值"; margin-left: 7px; font-size: 10px; color: var(--accent); border: 1px solid currentColor; border-radius: 4px; padding: 0 3px; } tbody tr.zero-row td { color: var(--fg-sub); } tfoot td { padding: 8px 12px; text-align: right; font-weight: 700; background: var(--bg-soft); border-top: 1px solid var(--line); position: sticky; bottom: 0; font-variant-numeric: tabular-nums; } tfoot td:first-child { text-align: left; } .ratio { display: inline-flex; align-items: center; gap: 6px; justify-content: flex-end; width: 100%; } .ratio .track { flex: 1; max-width: 90px; height: 5px; border-radius: 3px; background: var(--line); overflow: hidden; } .ratio .track > i { display: block; height: 100%; background: var(--accent); border-radius: 3px; } .ratio .pct { width: 42px; text-align: right; color: var(--fg-sub); font-size: 11.5px; } .cb-line { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--fg-sub); cursor: pointer; user-select: none; font-weight: 400; } .cb-line input { accent-color: var(--accent); } /* ---------- 模型分布 ---------- */ .models { margin-top: 16px; display: flex; flex-direction: column; gap: 7px; } .model-row { display: flex; align-items: center; gap: 10px; font-size: 12.5px; } .model-row .name { width: 170px; flex: 0 0 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .model-row .track { flex: 1; height: 7px; border-radius: 4px; background: var(--bg-soft); overflow: hidden; } .model-row .track > i { display: block; height: 100%; border-radius: 4px; background: linear-gradient(90deg, var(--accent), var(--accent-2)); } .model-row .num { width: 160px; flex: 0 0 auto; text-align: right; color: var(--fg-sub); font-variant-numeric: tabular-nums; } /* ---------- 提示条 ---------- */ .notice { margin-top: 12px; padding: 9px 12px; border-radius: 9px; font-size: 12px; background: var(--warn-soft); color: var(--warn); border: 1px solid currentColor; line-height: 1.6; } .foot { display: flex; align-items: center; gap: 8px; margin-top: 16px; padding-top: 13px; border-top: 1px solid var(--line); } .foot .spacer { flex: 1; } .foot .meta { font-size: 11.5px; color: var(--fg-sub); } .empty { padding: 34px 0; text-align: center; color: var(--fg-sub); font-size: 13px; } `; /* ========================================================================== * 7. 界面 * ======================================================================== */ let ui = null; let running = false; let panelOpen = false; // 抽屉是否展开(hidden 属性已不再承担这个状态) const state = { result: null, // 采集结果 showEmptyDays: true, // 表格是否补齐无消耗的日期 }; function buildUI(root) { root.innerHTML = `

每日积分消耗汇总

CodeBuddy
`; ui = { fab: root.getElementById('cbs-fab'), panel: root.getElementById('cbs-panel'), close: root.getElementById('cbs-close'), inputFrom: root.getElementById('cbs-from'), inputTo: root.getElementById('cbs-to'), run: root.getElementById('cbs-run'), status: root.getElementById('cbs-status'), progress: root.getElementById('cbs-progress'), result: root.getElementById('cbs-result'), chips: root.querySelectorAll('.chip'), }; // ---- 事件 ---- ui.fab.addEventListener('click', () => togglePanel(true)); ui.close.addEventListener('click', () => togglePanel(false)); ui.run.addEventListener('click', () => runCollect()); ui.chips.forEach((chip) => { chip.addEventListener('click', () => { ui.chips.forEach((c) => c.classList.toggle('active', c === chip)); applyQuick(chip.dataset.quick); }); }); // 手动改日期时取消快捷按钮的选中态 [ui.inputFrom, ui.inputTo].forEach((el) => { el.addEventListener('change', () => ui.chips.forEach((c) => c.classList.remove('active'))); }); // 结果区交互(补齐日期 / 导出 / 复制)走事件委托 ui.result.addEventListener('change', (e) => { if (e.target && e.target.id === 'cbs-show-empty') { state.showEmptyDays = e.target.checked; renderResult(); } }); ui.result.addEventListener('click', (e) => { const btn = e.target && e.target.closest('button'); if (!btn) return; if (btn.id === 'cbs-export') exportCSV(); if (btn.id === 'cbs-copy') copySummary(btn); }); // Esc 收起抽屉。挂在 document 上(焦点可能不在 shadow root 内), // 但只在抽屉展开时响应,且不拦截事件,避免影响宿主页面自己的 Esc 逻辑。 document.addEventListener('keydown', onKeydown); // 点击抽屉之外的页面区域自动收起。 // 挂在【捕获阶段】:宿主页面若在自己的处理函数里 stopPropagation, // 冒泡阶段挂在 document 上的监听就永远收不到事件,抽屉会关不掉。 document.addEventListener('pointerdown', onPointerDown, true); } // 用函数声明(会提升),避免依赖它与 buildUI 的书写顺序 function onKeydown(e) { if (e.key === 'Escape' && ui && panelOpen) togglePanel(false); } /** * 点击抽屉外部 → 收起。 * * 内外判断用 host.contains(e.target):事件从 shadow 内部冒出来时, * 在 document 这一层观察到的 target 会被【重定向成宿主元素】, * 所以「点在抽屉里」在 document 看来就是 target === host。 * * 只处理鼠标左键/触摸主触点,右键呼出上下文菜单不应关闭抽屉。 * 不调用 stopPropagation,让这次点击照常传给底下的页面元素 —— * 既关掉抽屉,也不牺牲页面本身的交互。 */ function onPointerDown(e) { if (!ui || !panelOpen) return; // 未展开时完全不介入 if (e.button !== 0) return; const host = document.getElementById(HOST_ID); if (!host) return; if (e.target && host.contains(e.target)) return; // 点在抽屉内部(含入口按钮) togglePanel(false); } function togglePanel(open) { panelOpen = open; ui.panel.classList.toggle('open', open); ui.fab.hidden = open; if (open) bootstrapPanel(); } /** 打开面板:优先回填上次的结果 */ function bootstrapPanel() { const cache = loadCache(); if (cache && cache.dayFrom && cache.dayTo) { state.result = cache; ui.inputFrom.value = cache.dayFrom; ui.inputTo.value = cache.dayTo; ui.chips.forEach((c) => c.classList.remove('active')); renderResult(); renderNotice(cache); const mins = Math.round((Date.now() - (cache.ts || 0)) / 60000); setStatus(`已载入本地缓存(${mins <= 0 ? '刚刚' : mins + ' 分钟前'}更新),点「开始统计」可刷新`, 'info'); } else { applyQuick('30'); renderResult(); } } function applyQuick(kind) { const t = today(); let from; let to; if (kind === '7') { from = addDays(t, -6); to = t; } else if (kind === '30') { from = addDays(t, -29); to = t; } else if (kind === 'month') { from = new Date(t.getFullYear(), t.getMonth(), 1); to = t; } else { from = new Date(t.getFullYear(), t.getMonth() - 1, 1); to = new Date(t.getFullYear(), t.getMonth(), 0); } ui.inputFrom.value = fmtDay(from); ui.inputTo.value = fmtDay(to); } function setStatus(text, kind) { if (!ui) return; ui.status.className = `status${kind ? ' ' + kind : ''}`; if (!text) { ui.status.textContent = ''; return; } ui.status.innerHTML = kind === 'loading' ? '' : ''; ui.status.lastElementChild.textContent = text; } /* ---------------------------------------------------------------------- */ async function runCollect() { if (running) return; const from = ui.inputFrom.value; const to = ui.inputTo.value; if (!from || !to) { setStatus('请先选择起止日期', 'error'); return; } if (parseDay(from) > parseDay(to)) { setStatus('开始日期不能晚于结束日期', 'error'); return; } const span = Math.round((parseDay(to) - parseDay(from)) / 86400000) + 1; if (span > SOFT_MAX_DAYS) { setStatus(`跨度 ${span} 天偏大,需要 ${span} 次接口请求,请耐心等待…`, 'loading'); } running = true; ui.run.disabled = true; ui.progress.hidden = false; setStatus('准备中…', 'loading'); const t0 = Date.now(); try { const allDays = eachDay(from, to); const { days, requests, incompleteDays, failedDays } = await collect(from, to, (msg) => setStatus(msg, 'loading') ); const agg = aggregate(days, allDays, from, to); const result = { dayFrom: from, dayTo: to, ts: Date.now(), requests, costMs: Date.now() - t0, spanDays: agg.spanDays, totalCents: agg.totalCents, totalCount: agg.totalCount, maxCents: agg.maxCents, days: agg.rows, models: agg.models, incompleteDays, failedDays, }; state.result = result; saveCache(result); renderResult(); renderNotice(result); if (failedDays.length) { setStatus(`完成,但有 ${failedDays.length} 天请求失败(详见下方提示),可重试`, 'warn'); } else if (incompleteDays.length) { setStatus(`完成,但有 ${incompleteDays.length} 天数据可能不完整(详见下方提示)`, 'warn'); } else { setStatus( `统计完成:${fmtInt(agg.totalCount)} 条记录 / ${requests} 次请求 / 耗时 ${( (Date.now() - t0) / 1000 ).toFixed(1)}s`, 'done' ); } } catch (err) { console.error('[每日积分汇总]', err); setStatus(`统计失败:${err && err.message ? err.message : err}`, 'error'); } finally { running = false; if (ui) { ui.run.disabled = false; ui.progress.hidden = true; } } } /* ---------------------------------------------------------------------- */ function renderNotice(res) { if (!ui) return; const box = ui.result.querySelector('#cbs-notice-slot'); if (!box) return; const parts = []; if (res.failedDays && res.failedDays.length) { const list = res.failedDays.slice(0, 6).map((d) => `${d.day}(${d.message})`).join('、'); const more = res.failedDays.length > 6 ? ` 等 ${res.failedDays.length} 天` : ''; parts.push(`⚠️ 以下日期请求失败,未计入统计:${list}${more}`); } if (res.incompleteDays && res.incompleteDays.length) { parts.push( `⚠️ ${res.incompleteDays.join('、')} 单日记录数达到接口 3000 条上限,这些天的数值可能偏小。` ); } box.innerHTML = parts.length ? `
${parts.join('
')}
` : ''; } function renderResult() { if (!ui) return; const res = state.result; if (!res || !res.days || !res.days.length) { ui.result.innerHTML = `
暂无数据,选择时间范围后点击「开始统计」
`; return; } const spanDays = res.spanDays || res.days.length; const avgCents = spanDays > 0 ? Math.round(res.totalCents / spanDays) : 0; const best = res.days.reduce((a, b) => (b.cents > (a ? a.cents : -1) ? b : a), null); const maxCents = res.maxCents || 1; // ---- 概览卡片 ---- const cards = `
总消耗积分
${fmtCredit(res.totalCents)}
请求次数
${fmtInt(res.totalCount)}
日均消耗(按 ${spanDays} 天)
${fmtCredit(avgCents)}
单日峰值
${fmtCredit(res.maxCents)}${best ? best.day.slice(5) : ''}
`; // ---- 柱状图(按时间正序)---- const chartDays = res.days.slice().sort((a, b) => a.day.localeCompare(b.day)); const bars = chartDays .map((d) => { const h = Math.max(2, Math.round((d.cents / maxCents) * 100)); return `
${d.day}
${fmtCredit(d.cents)} 积分 · ${fmtInt(d.count)} 次
`; }) .join(''); const axis = chartDays.map((d) => `${d.day.slice(8)}`).join(''); const chart = `

每日积分趋势(${res.dayFrom} ~ ${res.dayTo})

${bars}
${axis}
`; // ---- 表格 ---- const tableDays = state.showEmptyDays ? res.days : res.days.filter((d) => d.count > 0); const rows = tableDays .map((d) => { const pct = res.totalCents ? (d.cents / res.totalCents) * 100 : 0; const isBest = best && d.day === best.day && d.cents > 0; return ` ${d.day} ${fmtCredit(d.cents)} ${fmtInt(d.count)} ${pct.toFixed(1)}% `; }) .join(''); const table = `

每日明细

${rows}
日期消耗积分请求次数占比
合计(${spanDays} 天) ${fmtCredit(res.totalCents)} ${fmtInt(res.totalCount)}
`; // ---- 模型分布 ---- let modelsHtml = ''; const models = (res.models || []).slice(0, 6).filter((m) => m.count > 0); if (models.length) { const mMax = models[0].cents || 1; modelsHtml = `

模型消耗分布(Top ${models.length})

${models .map( (m) => `
${escapeHtml(m.model)} ${fmtCredit(m.cents)} · ${fmtInt(m.count)} 次
` ) .join('')}
`; } // ---- 底部 ---- const ts = new Date(res.ts || Date.now()); const foot = `
更新于 ${pad2(ts.getHours())}:${pad2(ts.getMinutes())}:${pad2(ts.getSeconds())} · ${res.requests || 0} 次接口请求
`; ui.result.innerHTML = cards + chart + table + modelsHtml + `
` + foot; } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]) ); } /* ---------------------------------------------------------------------- */ /** 导出用的表格数据(按日期升序) */ function summaryRows() { const res = state.result; const rows = [['日期', '消耗积分', '请求次数']]; if (state.showEmptyDays) { res.days .slice() .sort((a, b) => a.day.localeCompare(b.day)) .forEach((d) => rows.push([d.day, (d.cents / 100).toFixed(2), String(d.count)])); } else { res.days .filter((d) => d.count > 0) .sort((a, b) => a.day.localeCompare(b.day)) .forEach((d) => rows.push([d.day, (d.cents / 100).toFixed(2), String(d.count)])); } rows.push(['合计', (res.totalCents / 100).toFixed(2), String(res.totalCount)]); return rows; } function exportCSV() { const res = state.result; if (!res) return; // 带 BOM,避免 Excel 打开中文乱码 const csv = '\ufeff' + summaryRows().map((r) => r.join(',')).join('\r\n'); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `CodeBuddy积分消耗_${res.dayFrom}_${res.dayTo}.csv`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 2000); } async function copySummary(btn) { const res = state.result; if (!res) return; const pad = (s, n) => String(s).padEnd(n, ' '); const text = summaryRows() .map((r) => `${pad(r[0], 12)}${pad(r[1], 14)}${r[2]}`) .join('\n'); try { await navigator.clipboard.writeText(text); const old = btn.textContent; btn.textContent = '已复制 ✓'; setTimeout(() => (btn.textContent = old), 1500); } catch (_) { window.prompt('复制失败,请手动复制:', text); } } /* ========================================================================== * 8. 挂载 * ======================================================================== */ const HOST_ID = 'cb-usage-summary-host'; // 只在「套餐用量」页面展示。判断依据是 pathname,不是 @match。 // // 关于 @match 为什么是 /profile/* 而不是精确到 /profile/plans-usage: // 实测 /profile/* 属于独立的 usercenter 单页应用 // (bundle 在 download.codebuddy.cn/web/usercenter/…,与官网 /home/ 不是同一套), // 站内从侧边栏点进本页很可能是 pushState 的客户端路由 —— 精确匹配会导致 // 脚本根本没被注入,页面里什么都不出现。放到 /profile/* 只多注入一层壳, // 真正的展示范围依然由下面的 PATH_RE 严格限制。 const PATH_RE = /^\/profile\/plans-usage(\/|$)/; function isTargetPage() { return PATH_RE.test(location.pathname); } function mount() { const host = document.createElement('div'); host.id = HOST_ID; const shadow = host.attachShadow({ mode: 'open' }); document.body.appendChild(host); // 注意顺序:buildUI 内部用 innerHTML 渲染,而 innerHTML 会替换全部子节点, // 所以