Skip to content

Commit 5d51ab8

Browse files
committed
用量统计对齐站点口径:今天取仪表盘同款数字、时区推断、4 位精度、近 24 小时 (v1.2.0)
- 「今天」的合计(Tokens / 消耗 / 请求)直接取 Sub2API 站点仪表盘的 today_* 字段,与站点页面显示逐字一致(各部署对 timezone 参数处理不一, 这是唯一稳定口径);New API 按浏览器时区的自然日精确统计 - 小时级趋势时区推断:站点小时标签不带时区且口径混乱,以「最后一桶=当前 小时」为锚点反推偏移(取整 15 分钟),统一到浏览器时区展示与截窗 - 新增「近 24 小时」滚动窗口;Sub2API 模型明细只能按自然日给,页面注明口径 - 用量页按浏览器时区分桶(tz 随请求传递,窗口/日期串/缓存键都按该时区) - 金额精确到 4 位小数,表格/悬停显示精确 token 数(千分位) - history.json 改为临时文件 + rename 原子写,进程被杀不再可能留下损坏文件 - 版本 1.2.0
1 parent ff78850 commit 5d51ab8

6 files changed

Lines changed: 176 additions & 64 deletions

File tree

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99

1010
- **总览面板**:总剩余余额、**今日总消耗**、日均消耗(估算)、低余额 / 耗尽、查询异常统计;**总余额趋势图**(全站合计、24 小时~30 天切换、悬停查看分站明细)与**日均消耗对比图**;每站余额、今日消耗、用量进度条、状态标签、查询延迟
1111
- **今日消耗与站点一致**:Sub2API 站点直接读取站点用户仪表盘同款接口(`/api/v1/usage/dashboard/stats``today_actual_cost`,即今日实际扣费),与站点页面显示的数值完全一致;其他类型按余额历史推算并以 ≈ 标注
12-
- **用量统计页**:分站点、分模型、分时段的 Token 消耗——今天(按小时)/ 近 7 天 / 近 30 天,
13-
含消耗趋势柱状图、分模型条形图与明细表(请求数 / 输入输出 / 总 Tokens / 实际消耗)。
12+
- **用量统计页**:分站点、分模型、分时段的 Token 消耗——今天(按小时)/ 近 24 小时(滚动窗口)/
13+
近 7 天 / 近 30 天,含消耗趋势柱状图、分模型条形图与明细表(请求数 / 输入输出 / 总 Tokens / 实际消耗,
14+
金额精确到 4 位小数)。「今天」的合计直接取站点仪表盘同款数字,与站点页面逐字一致;
15+
小时趋势通过「最后一桶=当前小时」推断站点时区,跨部署统一到浏览器时区展示。
1416
数据源:Sub2API 用 `/usage/dashboard/snapshot-v2`(老版本自动回退 trend + models 接口),
1517
New API 用 `/api/data/self`(需站点开启数据看板);sk 密钥模式无用量明细接口
1618
- **Sub2API 账号密码模式**:只填邮箱 + 密码,面板自动登录换取令牌;令牌过期时自动用 refresh_token 刷新(支持轮换),刷新失败自动用密码重新登录——**全程无需人工干预**

lib/history.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// 余额历史快照 + 耗尽预测(线性回归)
2-
import { readFile, writeFile, mkdir } from "node:fs/promises";
2+
import { readFile, writeFile, mkdir, rename } from "node:fs/promises";
33
import { dirname } from "node:path";
44

55
const MAX_POINTS = 5000; // 每站上限
@@ -25,14 +25,17 @@ export class History {
2525
return this;
2626
}
2727

28-
// 合并写盘,避免每 60 秒全量写多次
28+
// 合并写盘,避免每 60 秒全量写多次;临时文件 + rename 原子替换,
29+
// 进程在写一半时被杀不会留下损坏的 history.json
2930
scheduleSave() {
3031
if (this._saveTimer) return;
3132
this._saveTimer = setTimeout(async () => {
3233
this._saveTimer = null;
3334
try {
3435
await mkdir(dirname(this.file), { recursive: true });
35-
await writeFile(this.file, JSON.stringify(this.data), "utf8");
36+
const tmp = this.file + ".tmp";
37+
await writeFile(tmp, JSON.stringify(this.data), "utf8");
38+
await rename(tmp, this.file);
3639
} catch {}
3740
}, 1500);
3841
}

lib/providers.js

Lines changed: 82 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -284,17 +284,43 @@ async function querySub2Api(station) {
284284

285285
// ---- 用量明细(分模型 / 分时间)--------------------------------------------
286286

287-
function localDateStr(ms) {
288-
const d = new Date(ms);
289-
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
287+
// 指定时区下某时刻的 YYYY-MM-DD(en-CA 的日期格式正好是这个)
288+
function dateStrInTz(ms, tz) {
289+
return new Intl.DateTimeFormat("en-CA", { timeZone: tz }).format(new Date(ms));
290290
}
291291

292-
// 尝试把提供商返回的时间标签解析成时间戳;解析失败返回 null,前端按原始标签展示
293-
function parseDateLabel(s) {
292+
// 某时刻 tz 相对 UTC 的偏移毫秒(东八区为 +8h)
293+
function tzOffsetMs(tz, at) {
294+
const p = new Intl.DateTimeFormat("en-US", {
295+
timeZone: tz, hour12: false,
296+
year: "numeric", month: "2-digit", day: "2-digit",
297+
hour: "2-digit", minute: "2-digit", second: "2-digit",
298+
}).formatToParts(new Date(at));
299+
const get = (k) => Number(p.find((x) => x.type === k).value);
300+
const asUtc = Date.UTC(get("year"), get("month") - 1, get("day"), get("hour") % 24, get("minute"), get("second"));
301+
return asUtc - Math.floor(at / 1000) * 1000;
302+
}
303+
304+
// 把「2026-07-13 05:00」这类无时区标签按指定时区解析成时间戳;失败返回 null
305+
function parseDateLabel(s, tz) {
294306
if (!s) return null;
295-
let t = Date.parse(s);
296-
if (Number.isNaN(t)) t = Date.parse(String(s).replace(" ", "T"));
297-
return Number.isNaN(t) ? null : t;
307+
const utc = Date.parse(String(s).replace(" ", "T") + (String(s).length <= 10 ? "T00:00:00Z" : "Z"));
308+
if (Number.isNaN(utc)) return null;
309+
return utc - tzOffsetMs(tz, utc);
310+
}
311+
312+
// 小时级趋势的时区推断:站点返回的小时标签不带时区,且不同部署对 timezone
313+
// 参数的处理不一致。把「最后一个小时桶 = 当前小时」作为锚点反推偏移,
314+
// 得到真实时间戳(偏移取整到 15 分钟,容忍半点时区)。
315+
function inferHourlyTimes(trend) {
316+
let lastNaive = null;
317+
for (let i = trend.length - 1; i >= 0; i--) {
318+
if (trend[i]._naiveUtc != null) { lastNaive = trend[i]._naiveUtc; break; }
319+
}
320+
if (lastNaive == null) return trend.map(({ _naiveUtc, ...p }) => ({ ...p, t: null }));
321+
const nowHour = Math.floor(Date.now() / 3600000) * 3600000;
322+
const offset = Math.round((lastNaive - nowHour) / 900000) * 900000;
323+
return trend.map(({ _naiveUtc, ...p }) => ({ ...p, t: _naiveUtc != null ? _naiveUtc - offset : null }));
298324
}
299325

300326
// 取可用的 Sub2API bearer;密码模式沿用 s2Tokens 缓存并自动登录/刷新
@@ -326,25 +352,54 @@ function normSub2Models(models) {
326352
})).sort((a, b) => b.tokens - a.tokens);
327353
}
328354

329-
function normSub2Trend(trend) {
330-
return (trend || []).map((p) => ({
331-
t: parseDateLabel(p.date),
355+
function normSub2Trend(trend, tz, granularity) {
356+
const rows = (trend || []).map((p) => ({
357+
t: granularity === "hour" ? null : parseDateLabel(p.date, tz),
358+
_naiveUtc: granularity === "hour"
359+
? (Number.isNaN(Date.parse(String(p.date ?? "").replace(" ", "T") + "Z")) ? null : Date.parse(String(p.date).replace(" ", "T") + "Z"))
360+
: null,
332361
label: String(p.date ?? ""),
333362
tokens: num(p.total_tokens),
334363
cost: num(p.actual_cost ?? p.cost),
335364
requests: num(p.requests),
336365
}));
366+
return granularity === "hour" ? inferHourlyTimes(rows) : rows.map(({ _naiveUtc, ...p }) => p);
337367
}
338368

339-
async function sub2apiUsage(station, { startMs, endMs, granularity, tz }) {
369+
async function sub2apiUsage(station, { startMs, endMs, granularity, tz, exactWindow, wantToday }) {
340370
const base = trimBase(station.baseUrl);
341371
if (!base) throw new Error("缺少站点地址");
342372
const params = {
343-
start_date: localDateStr(startMs),
344-
end_date: localDateStr(endMs),
373+
start_date: dateStrInTz(startMs, tz),
374+
end_date: dateStrInTz(endMs, tz),
345375
granularity,
346376
timezone: tz,
347377
};
378+
// Sub2API 的用量接口只支持自然日参数:滚动窗口(近 24 小时)时查两天、
379+
// 小时趋势截取到窗口内;模型明细无法按小时截取,标记后由前端注明口径
380+
const finish = async (models, trend) => {
381+
const out = {
382+
models,
383+
trend: exactWindow ? trend.filter((p) => p.t == null || p.t >= startMs - 3600000) : trend,
384+
modelsWindow: exactWindow ? "date" : "exact",
385+
};
386+
// 「今天」的合计直接取站点仪表盘同款数字(today_actual_cost 等),
387+
// 各部署对 timezone 参数处理不一,这是唯一和站点页面显示逐字一致的口径
388+
if (wantToday) {
389+
try {
390+
const st = await get("/api/v1/usage/dashboard/stats", {});
391+
if (st.status < 300 && st.body?.code === 0 && st.body.data) {
392+
const d = st.body.data;
393+
out.summary = {
394+
cost: num(d.today_actual_cost),
395+
tokens: num(d.today_tokens),
396+
requests: num(d.today_requests),
397+
};
398+
}
399+
} catch { /* 拿不到就用 models 求和兜底 */ }
400+
}
401+
return out;
402+
};
348403
const get = async (path, extra) => {
349404
let bearer = await sub2apiBearer(station, base);
350405
let r = await request(`${base}${path}?${new URLSearchParams({ ...params, ...extra })}`, {
@@ -365,17 +420,17 @@ async function sub2apiUsage(station, { startMs, endMs, granularity, tz }) {
365420
});
366421
if (snap.status < 300 && snap.body?.code === 0 && snap.body.data) {
367422
const d = snap.body.data;
368-
return { models: normSub2Models(d.models), trend: normSub2Trend(d.trend) };
423+
return finish(normSub2Models(d.models), normSub2Trend(d.trend, tz, granularity));
369424
}
370425
const [tr, mo] = await Promise.all([
371426
get("/api/v1/usage/dashboard/trend"),
372427
get("/api/v1/usage/dashboard/models"),
373428
]);
374429
if (tr.status >= 300 && mo.status >= 300) throw new Error(httpErrorMessage(tr));
375-
return {
376-
models: mo.status < 300 && mo.body?.code === 0 ? normSub2Models(mo.body.data?.models) : [],
377-
trend: tr.status < 300 && tr.body?.code === 0 ? normSub2Trend(tr.body.data?.trend) : [],
378-
};
430+
return finish(
431+
mo.status < 300 && mo.body?.code === 0 ? normSub2Models(mo.body.data?.models) : [],
432+
tr.status < 300 && tr.body?.code === 0 ? normSub2Trend(tr.body.data?.trend, tz, granularity) : []
433+
);
379434
}
380435

381436
async function newApiUsage(station, { startMs, endMs }) {
@@ -407,9 +462,16 @@ async function newApiUsage(station, { startMs, endMs }) {
407462
tr.tokens += tokens; tr.cost += cost; tr.requests += reqs;
408463
trend.set(t, tr);
409464
}
465+
const modelList = [...models.values()].sort((a, b) => b.tokens - a.tokens);
410466
return {
411-
models: [...models.values()].sort((a, b) => b.tokens - a.tokens),
467+
models: modelList,
412468
trend: [...trend.values()].sort((a, b) => a.t - b.t),
469+
modelsWindow: "exact", // data/self 按精确时间戳过滤,模型明细与窗口完全一致
470+
summary: {
471+
cost: modelList.reduce((a, m) => a + m.cost, 0),
472+
tokens: modelList.reduce((a, m) => a + m.tokens, 0),
473+
requests: modelList.reduce((a, m) => a + m.requests, 0),
474+
},
413475
};
414476
}
415477

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "relay-monitor",
3-
"version": "1.1.0",
3+
"version": "1.2.0",
44
"private": true,
55
"description": "监控 sub2api / new-api 中转站余额的网页面板",
66
"type": "module",

public/app.js

Lines changed: 49 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ const api = {
5353
refreshAll: () => call("/api/refresh", { method: "POST", body: {} }),
5454
historyOf: (id, hours) => call(`/api/stations/${id}/history?hours=${hours}`),
5555
overview: (hours) => call(`/api/history/overview?hours=${hours}`),
56-
usage: (range) => call(`/api/usage?range=${range}`),
56+
usage: (range) => call(`/api/usage?range=${range}&tz=${encodeURIComponent(Intl.DateTimeFormat().resolvedOptions().timeZone)}`),
5757
saveSettings: (b) => call("/api/settings", { method: "PUT", body: b }),
5858
notifications: () => call("/api/notifications"),
5959
addChannel: (b) => call("/api/notifications/channels", { body: b }),
@@ -549,7 +549,9 @@ function etaRuleDisplay(r) {
549549
}
550550

551551
// ---- 用量统计页 ----------------------------------------------------------------
552-
const USAGE_RANGES = [["today", "今天"], ["7d", "近 7 天"], ["30d", "近 30 天"]];
552+
const USAGE_RANGES = [["today", "今天"], ["24h", "近 24 小时"], ["7d", "近 7 天"], ["30d", "近 30 天"]];
553+
// 消耗金额显示到 4 位小数,与 sub2api 站点精度一致
554+
const usd4 = (n) => "$" + Number(n ?? 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 4 });
553555

554556
function renderUsage() {
555557
$("#headerActions").innerHTML = `
@@ -604,34 +606,53 @@ function renderUsageBody() {
604606
}
605607
const models = [...mmap.values()].sort((a, b) => b.tokens - a.tokens);
606608

607-
// 按时间桶合并(不同提供商的桶统一成展示标签)
609+
// 按时间桶合并:能解析出时间戳的按小时/天取整分桶(跨天时小时标签会重复,
610+
// 不能拿标签当键),解析不出的按原始标签
611+
const hourly = data.granularity === "hour";
612+
const bucketKey = (p) => {
613+
if (p.t == null) return "l:" + (p.label || "?");
614+
if (hourly) return "t:" + Math.floor(p.t / 3600000);
615+
const d = new Date(p.t);
616+
return "d:" + d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate();
617+
};
618+
const today = new Date();
608619
const bucketLabel = (p) => {
609-
if (p.t != null) {
610-
const d = new Date(p.t);
611-
return data.granularity === "hour"
612-
? `${String(d.getHours()).padStart(2, "0")}:00`
613-
: `${d.getMonth() + 1}/${d.getDate()}`;
614-
}
615-
return p.label || "?";
620+
if (p.t == null) return p.label || "?";
621+
const d = new Date(p.t);
622+
if (!hourly) return `${d.getMonth() + 1}/${d.getDate()}`;
623+
const hh = `${String(d.getHours()).padStart(2, "0")}:00`;
624+
return d.getDate() === today.getDate() && d.getMonth() === today.getMonth()
625+
? hh : `${d.getMonth() + 1}/${d.getDate()} ${hh}`;
616626
};
617627
const bmap = new Map();
618628
for (const s of okSts) for (const p of s.trend || []) {
619-
const k = bucketLabel(p);
620-
const acc = bmap.get(k) || { label: k, t: p.t ?? Infinity, tokens: 0, cost: 0, requests: 0 };
629+
const k = bucketKey(p);
630+
const acc = bmap.get(k) || { label: bucketLabel(p), t: p.t ?? Infinity, tokens: 0, cost: 0, requests: 0 };
621631
acc.tokens += p.tokens || 0; acc.cost += p.cost || 0; acc.requests += p.requests || 0;
622632
acc.t = Math.min(acc.t, p.t ?? Infinity);
623633
bmap.set(k, acc);
624634
}
625635
const buckets = [...bmap.values()].sort((a, b) => a.t - b.t);
626636

627-
const totTokens = models.reduce((a, m) => a + m.tokens, 0);
628-
const totCost = models.reduce((a, m) => a + m.cost, 0);
629-
const totReqs = models.reduce((a, m) => a + m.requests, 0);
637+
// 合计口径按范围选:今天 = 站点仪表盘同款数字(和站点页面显示一致);
638+
// 近 24 小时 = 截好窗的趋势求和;7/30 天 = 模型明细求和
639+
let totTokens, totCost, totReqs;
640+
if (data.range === "today") {
641+
totTokens = okSts.reduce((a, s) => a + (s.summary?.tokens ?? (s.models || []).reduce((x, m) => x + m.tokens, 0)), 0);
642+
totCost = okSts.reduce((a, s) => a + (s.summary?.cost ?? (s.models || []).reduce((x, m) => x + m.cost, 0)), 0);
643+
totReqs = okSts.reduce((a, s) => a + (s.summary?.requests ?? (s.models || []).reduce((x, m) => x + m.requests, 0)), 0);
644+
} else {
645+
const src = data.range === "24h" ? buckets : models;
646+
totTokens = src.reduce((a, m) => a + m.tokens, 0);
647+
totCost = src.reduce((a, m) => a + m.cost, 0);
648+
totReqs = src.reduce((a, m) => a + m.requests, 0);
649+
}
650+
const modelsByDate = data.range === "24h" && okSts.some((s) => s.modelsWindow === "date");
630651

631652
el.innerHTML = `
632653
<div class="stats">
633-
<div class="stat-card"><div class="label">总 Tokens</div><div class="value">${fmtTokens(totTokens)}</div></div>
634-
<div class="stat-card"><div class="label">实际消耗</div><div class="value">${usd(totCost)}</div></div>
654+
<div class="stat-card"><div class="label">总 Tokens</div><div class="value" title="${totTokens.toLocaleString("en-US")}">${fmtTokens(totTokens)}</div></div>
655+
<div class="stat-card"><div class="label">实际消耗</div><div class="value">${usd4(totCost)}</div></div>
635656
<div class="stat-card"><div class="label">请求数</div><div class="value">${totReqs.toLocaleString("en-US")}</div></div>
636657
<div class="stat-card"><div class="label">数据来源</div><div class="value">${okSts.length}<small>/ ${sts.length} 个站点</small></div></div>
637658
</div>
@@ -643,7 +664,9 @@ function renderUsageBody() {
643664
<div class="chart-wrap" id="usageTrendChart"></div>
644665
</div>
645666
<div class="panel chart-card">
646-
<div class="chart-card-head"><div><h3>分模型 Token</h3><div class="chart-sub">按用量降序,最多显示 10 项</div></div></div>
667+
<div class="chart-card-head"><div><h3>分模型 Token</h3><div class="chart-sub">${
668+
modelsByDate ? "Sub2API 模型明细按自然日(昨日+今日)统计" : "按用量降序,最多显示 10 项"
669+
}</div></div></div>
647670
<div class="chart-wrap" id="usageModelChart"></div>
648671
</div>
649672
</div>
@@ -655,10 +678,10 @@ function renderUsageBody() {
655678
${models.map((m) => `<tr>
656679
<td class="mono">${esc(m.model)}</td>
657680
<td>${m.requests.toLocaleString("en-US")}</td>
658-
<td>${m.hasIO ? fmtTokens(m.inputTokens) : "—"}</td>
659-
<td>${m.hasIO ? fmtTokens(m.outputTokens) : "—"}</td>
660-
<td>${fmtTokens(m.tokens)}</td>
661-
<td>${usd(m.cost)}</td>
681+
<td>${m.hasIO ? m.inputTokens.toLocaleString("en-US") : "—"}</td>
682+
<td>${m.hasIO ? m.outputTokens.toLocaleString("en-US") : "—"}</td>
683+
<td>${m.tokens.toLocaleString("en-US")}</td>
684+
<td>${usd4(m.cost)}</td>
662685
</tr>`).join("") || '<tr><td colspan="6" class="u-empty">该范围内暂无用量数据</td></tr>'}
663686
</tbody>
664687
</table>
@@ -707,8 +730,8 @@ function drawUsageTrend(wrap, buckets) {
707730
wrap.innerHTML = `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="Token 消耗趋势">${grid}${labels}${cols}</svg><div class="chart-tip"></div>`;
708731
attachUsageTip(wrap, (i) => {
709732
const b = buckets[i];
710-
return `<div class="t">${esc(b.label)}</div><div class="v">${fmtTokens(b.tokens)} tokens</div>` +
711-
`<div class="r"><span>消耗</span><b>${usd(b.cost)}</b></div><div class="r"><span>请求</span><b>${b.requests.toLocaleString("en-US")}</b></div>`;
733+
return `<div class="t">${esc(b.label)}</div><div class="v">${b.tokens.toLocaleString("en-US")} tokens</div>` +
734+
`<div class="r"><span>消耗</span><b>${usd4(b.cost)}</b></div><div class="r"><span>请求</span><b>${b.requests.toLocaleString("en-US")}</b></div>`;
712735
});
713736
}
714737

@@ -748,8 +771,8 @@ function drawUsageModels(wrap, models) {
748771
wrap.innerHTML = `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="分模型 Token 用量">${rows}</svg><div class="chart-tip"></div>`;
749772
attachUsageTip(wrap, (i) => {
750773
const m = items[i];
751-
return `<div class="t">${esc(m.model)}</div><div class="v">${fmtTokens(m.tokens)} tokens</div>` +
752-
`<div class="r"><span>消耗</span><b>${usd(m.cost)}</b></div><div class="r"><span>请求</span><b>${m.requests.toLocaleString("en-US")}</b></div>`;
774+
return `<div class="t">${esc(m.model)}</div><div class="v">${m.tokens.toLocaleString("en-US")} tokens</div>` +
775+
`<div class="r"><span>消耗</span><b>${usd4(m.cost)}</b></div><div class="r"><span>请求</span><b>${m.requests.toLocaleString("en-US")}</b></div>`;
753776
});
754777
}
755778

0 commit comments

Comments
 (0)