dsh-deepseek-web-login 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/lib/client.js +275 -0
- package/lib/client.js.map +1 -1
- package/lib/index.js +323 -15
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/tools/panel-preview.mjs +89 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,46 @@
|
|
|
2
2
|
|
|
3
3
|
本项目遵循大致语义化版本;日期为本地时间。
|
|
4
4
|
|
|
5
|
+
## 0.4.2 — 2026-09-26
|
|
6
|
+
|
|
7
|
+
> 新增「Token 统计」页:本地的用量趋势与分布。以前 token 数只在上报途中被算一次就丢了。
|
|
8
|
+
|
|
9
|
+
### 新增
|
|
10
|
+
|
|
11
|
+
- **「Token 统计」页**(标签栏在「模型」之后):把每次调用的 token 用量落到本地
|
|
12
|
+
(`<数据目录>/web-login/usage/`,按天分文件、保留 90 天),并在设置页看趋势与分布。
|
|
13
|
+
- **范围切换**:今天 / 近 7 天 / 近 30 天 / 总计
|
|
14
|
+
- **四格总览**:总 Token / 输入 Token / 输出 Token / 调用次数(副行给出服务端口径占比与成功失败数)
|
|
15
|
+
- **按天用量**:堆叠柱(输入 + 输出)+ 虚线(当天调用次数,右轴),鼠标停在柱上可看具体数字
|
|
16
|
+
- **用量分布**:按账号 / 按模型切换,横条排行带占比与次数
|
|
17
|
+
|
|
18
|
+
### 说明
|
|
19
|
+
|
|
20
|
+
- **口径**:网页端只在**部分**响应里上报总量,其余是按字符估算的 ⇒ 界面显示「服务端口径 N/M 次」,
|
|
21
|
+
不把估算值说成精确值。`server` 标记只认服务端真的给了总量那一次。
|
|
22
|
+
- **与「防风控」页的调用台账是两份存储**:台账保留 7 天、管请求密度与失败分类;
|
|
23
|
+
用量保留 90 天、只看用量。保留期差一个数量级,合并会让两边互相迁就。
|
|
24
|
+
- **懒加载**:切到这一页才读盘,不为没打开它的用户扫描历史文件。
|
|
25
|
+
- ⚠️ **历史数据是空的**:这个存储从本版才开始记,所以趋势图从升级当天开始长。
|
|
26
|
+
|
|
27
|
+
## 0.4.1 — 2026-09-26
|
|
28
|
+
|
|
29
|
+
> 被限流/封禁时能自己接下去。0.4.0 的自动换号,从这个版本起才是真正的兜底。
|
|
30
|
+
|
|
31
|
+
### 改进
|
|
32
|
+
|
|
33
|
+
- **封禁后不再需要手动点「继续」**:账号被封(`user is muted`)时,此前把**解除时间**(可能几小时)
|
|
34
|
+
当作重试间隔交给重试策略 ⇒ 它直接放弃重试 ⇒ 整轮停住、等人手动点「继续」。
|
|
35
|
+
现在只要**还能换号**(自动换号开着、且账号库里还有可用账号),就给 2 秒的短退避 ——
|
|
36
|
+
重试立刻发生,重发时自动换到可用账号,**整轮任务自己接下去**。
|
|
37
|
+
- 没有别的账号可用时仍保持原行为(长退避 ⇒ 放弃重试),不做无用空转
|
|
38
|
+
- 候选账号换完即止 ⇒ **不会无限换号重试**
|
|
39
|
+
- ⚠️ 只在自动换号**开着**时生效(滑块默认 0 = 关闭 ⇒ 行为与 0.4.0 完全一致)
|
|
40
|
+
|
|
41
|
+
### 说明
|
|
42
|
+
|
|
43
|
+
- 限流(`操作过于频繁` / HTTP 429 / 业务码 40029)本来就是自动退避重发、不需要手动干预 —— 这次没动它。
|
|
44
|
+
|
|
5
45
|
## 0.4.0 — 2026-09-26
|
|
6
46
|
|
|
7
47
|
> 两件事:一个「自动换号」滑块,以及一条会把限流误判成"不可重试"的修复。
|
package/lib/client.js
CHANGED
|
@@ -476,6 +476,39 @@ transition:color .15s ease,border-color .15s ease}
|
|
|
476
476
|
.dsw-spark rect{fill:var(--bd2)}
|
|
477
477
|
.dsw-spark rect.on{fill:var(--accent)}
|
|
478
478
|
.dsw-spark rect.bad{fill:var(--err)}
|
|
479
|
+
/* ── Token 统计页 ────────────────────────────────────────────────────
|
|
480
|
+
图全是手写 SVG:插件不引第三方图表库(多一个依赖就多一份与宿主版本的耦合)。
|
|
481
|
+
高度写 auto 而不是固定值 —— viewBox 自带比例,固定高度会把图拉变形或留白。 */
|
|
482
|
+
.dsw-stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--sp-3);
|
|
483
|
+
padding:var(--sp-3) 0;border-top:1px solid var(--bd);border-bottom:1px solid var(--bd)}
|
|
484
|
+
.dsw-stat{min-width:0}
|
|
485
|
+
.dsw-stat .k{font-size:12px;color:var(--fg3);margin:0 0 2px}
|
|
486
|
+
.dsw-stat .v{font-size:20px;font-weight:600;line-height:1.25;letter-spacing:-.01em;
|
|
487
|
+
color:var(--fg);font-variant-numeric:tabular-nums}
|
|
488
|
+
.dsw-stat .n{font-size:11px;color:var(--fg3);margin-top:2px;line-height:1.45}
|
|
489
|
+
@media (max-width:560px){.dsw-stats{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
|
490
|
+
.dsw-trend{display:block;width:100%;height:auto;margin:var(--sp-3) 0 0;overflow:visible}
|
|
491
|
+
.dsw-trend .grid{stroke:var(--bd);stroke-width:.5}
|
|
492
|
+
.dsw-trend .axis{fill:var(--fg3);font-size:10px}
|
|
493
|
+
.dsw-trend .bar-in{fill:var(--accent)}
|
|
494
|
+
.dsw-trend .bar-out{fill:color-mix(in srgb,var(--accent) 45%,transparent)}
|
|
495
|
+
.dsw-trend .line{fill:none;stroke:var(--fg2);stroke-width:1.2;stroke-dasharray:3 2}
|
|
496
|
+
.dsw-legend{display:flex;gap:var(--sp-3);flex-wrap:wrap;font-size:11px;color:var(--fg2);
|
|
497
|
+
margin:var(--sp-2) 0 0}
|
|
498
|
+
.dsw-legend i{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:4px;
|
|
499
|
+
vertical-align:-1px}
|
|
500
|
+
/* 分布排行:一行 = 名称 + 数值 + 占比 + 横条。条宽按**最大值**归一(不是按总量),
|
|
501
|
+
否则只有第一名看得见,其余全是一根短刺。 */
|
|
502
|
+
.dsw-ranks{display:flex;flex-direction:column;gap:var(--sp-3);margin-top:var(--sp-3)}
|
|
503
|
+
.dsw-rank{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px var(--sp-3);
|
|
504
|
+
align-items:baseline}
|
|
505
|
+
.dsw-rank .rk-name{font-size:12px;color:var(--fg);overflow:hidden;text-overflow:ellipsis;
|
|
506
|
+
white-space:nowrap}
|
|
507
|
+
.dsw-rank .rk-val{font-size:12px;color:var(--fg2);font-variant-numeric:tabular-nums;
|
|
508
|
+
white-space:nowrap}
|
|
509
|
+
.dsw-rank .rk-track{grid-column:1/-1;height:6px;border-radius:999px;background:var(--bd);
|
|
510
|
+
overflow:hidden}
|
|
511
|
+
.dsw-rank .rk-fill{height:100%;border-radius:999px;background:var(--accent)}
|
|
479
512
|
.dsw-path{font-family:var(--mono);font-size:11px;color:var(--fg2);word-break:break-all}
|
|
480
513
|
/* 账号库 */
|
|
481
514
|
.dsw-accounts{list-style:none;margin:var(--sp-2) 0 0;padding:0;display:flex;flex-direction:column;gap:var(--sp-2)}
|
|
@@ -588,6 +621,7 @@ background:var(--bg2);white-space:pre-wrap;font-size:12px}
|
|
|
588
621
|
const TAB_KEYS = [
|
|
589
622
|
"account",
|
|
590
623
|
"model",
|
|
624
|
+
"token",
|
|
591
625
|
"gate",
|
|
592
626
|
"transport",
|
|
593
627
|
"context",
|
|
@@ -596,6 +630,7 @@ background:var(--bg2);white-space:pre-wrap;font-size:12px}
|
|
|
596
630
|
const TAB_LABELS = {
|
|
597
631
|
account: "账号",
|
|
598
632
|
model: "模型",
|
|
633
|
+
token: "Token 统计",
|
|
599
634
|
gate: "防风控",
|
|
600
635
|
transport: "传输层",
|
|
601
636
|
context: "上下文",
|
|
@@ -603,6 +638,7 @@ background:var(--bg2);white-space:pre-wrap;font-size:12px}
|
|
|
603
638
|
};
|
|
604
639
|
const accountPane = el("div", "dsw-pane");
|
|
605
640
|
const modelPane = el("div", "dsw-pane");
|
|
641
|
+
const tokenPane = el("div", "dsw-pane");
|
|
606
642
|
const gatePane = el("div", "dsw-pane");
|
|
607
643
|
const transportPane = el("div", "dsw-pane");
|
|
608
644
|
const contextPane = el("div", "dsw-pane");
|
|
@@ -610,18 +646,25 @@ background:var(--bg2);white-space:pre-wrap;font-size:12px}
|
|
|
610
646
|
const panes = {
|
|
611
647
|
account: accountPane,
|
|
612
648
|
model: modelPane,
|
|
649
|
+
token: tokenPane,
|
|
613
650
|
gate: gatePane,
|
|
614
651
|
transport: transportPane,
|
|
615
652
|
context: contextPane,
|
|
616
653
|
about: aboutPane
|
|
617
654
|
};
|
|
618
655
|
const tabButtons = {};
|
|
656
|
+
/**
|
|
657
|
+
* 首次切到某页时才拉的数据。**不做成"启动时全拉"**:Token 统计要扫最多 90 天的
|
|
658
|
+
* jsonl,而多数人根本不打开这一页 —— 不为没看的页付代价。
|
|
659
|
+
*/
|
|
660
|
+
const paneFirstShow = {};
|
|
619
661
|
const selectTab = (key) => {
|
|
620
662
|
for (const each of TAB_KEYS) {
|
|
621
663
|
const on = each === key;
|
|
622
664
|
panes[each].hidden = !on;
|
|
623
665
|
tabButtons[each].classList.toggle("active", on);
|
|
624
666
|
tabButtons[each].setAttribute("aria-selected", String(on));
|
|
667
|
+
if (on) paneFirstShow[each]?.();
|
|
625
668
|
}
|
|
626
669
|
};
|
|
627
670
|
const tabBar = el("div", "dsw-tabs");
|
|
@@ -1767,6 +1810,238 @@ background:var(--bg2);white-space:pre-wrap;font-size:12px}
|
|
|
1767
1810
|
}
|
|
1768
1811
|
};
|
|
1769
1812
|
ledgerRefreshBtn.addEventListener("click", () => void loadLedger());
|
|
1813
|
+
const fmtTokens = (n) => {
|
|
1814
|
+
if (!Number.isFinite(n) || n <= 0) return "0";
|
|
1815
|
+
if (n >= 1e8) return `${(n / 1e8).toFixed(2)} 亿`;
|
|
1816
|
+
if (n >= 1e4) return `${(n / 1e4).toFixed(n >= 1e6 ? 0 : 1)} 万`;
|
|
1817
|
+
return String(Math.round(n));
|
|
1818
|
+
};
|
|
1819
|
+
const fullNum = (n) => Number.isFinite(n) ? Math.round(n).toLocaleString("zh-CN") : "0";
|
|
1820
|
+
const TOKEN_RANGES = [
|
|
1821
|
+
[1, "今天"],
|
|
1822
|
+
[7, "近 7 天"],
|
|
1823
|
+
[30, "近 30 天"],
|
|
1824
|
+
[0, "总计"]
|
|
1825
|
+
];
|
|
1826
|
+
let tokenDays = 30;
|
|
1827
|
+
const tokenRangeBar = el("div", "dsw-subtabs");
|
|
1828
|
+
tokenRangeBar.setAttribute("role", "tablist");
|
|
1829
|
+
const tokenRangeBtns = [];
|
|
1830
|
+
const paintTokenRange = () => {
|
|
1831
|
+
TOKEN_RANGES.forEach(([days], index) => {
|
|
1832
|
+
const on = days === tokenDays;
|
|
1833
|
+
tokenRangeBtns[index].classList.toggle("active", on);
|
|
1834
|
+
tokenRangeBtns[index].setAttribute("aria-selected", String(on));
|
|
1835
|
+
});
|
|
1836
|
+
};
|
|
1837
|
+
for (const [days, label] of TOKEN_RANGES) {
|
|
1838
|
+
const btn = el("button", "dsw-subtab", label);
|
|
1839
|
+
btn.type = "button";
|
|
1840
|
+
btn.setAttribute("role", "tab");
|
|
1841
|
+
btn.addEventListener("click", () => {
|
|
1842
|
+
if (tokenDays === days) return;
|
|
1843
|
+
tokenDays = days;
|
|
1844
|
+
paintTokenRange();
|
|
1845
|
+
loadUsage();
|
|
1846
|
+
});
|
|
1847
|
+
tokenRangeBtns.push(btn);
|
|
1848
|
+
tokenRangeBar.append(btn);
|
|
1849
|
+
}
|
|
1850
|
+
paintTokenRange();
|
|
1851
|
+
tokenPane.append(tokenRangeBar);
|
|
1852
|
+
/** 一格总览。`set` 一次给值和注释,避免三处各写一遍 textContent。 */
|
|
1853
|
+
const statCell = (label) => {
|
|
1854
|
+
const node = el("div", "dsw-stat");
|
|
1855
|
+
const value = el("div", "v", "—");
|
|
1856
|
+
const note = el("div", "n", "");
|
|
1857
|
+
node.append(el("div", "k", label), value, note);
|
|
1858
|
+
return {
|
|
1859
|
+
node,
|
|
1860
|
+
set: (next, hint) => {
|
|
1861
|
+
value.textContent = next;
|
|
1862
|
+
note.textContent = hint ?? "";
|
|
1863
|
+
}
|
|
1864
|
+
};
|
|
1865
|
+
};
|
|
1866
|
+
const sTotal = statCell("总 Token");
|
|
1867
|
+
const sIn = statCell("输入 Token");
|
|
1868
|
+
const sOut = statCell("输出 Token");
|
|
1869
|
+
const sCalls = statCell("调用次数");
|
|
1870
|
+
const tokenStats = el("div", "dsw-stats");
|
|
1871
|
+
tokenStats.append(sTotal.node, sIn.node, sOut.node, sCalls.node);
|
|
1872
|
+
tokenPane.append(tokenStats);
|
|
1873
|
+
const trendCard = el("div", "dsw-card");
|
|
1874
|
+
const tokenHead = el("div", "dsw-cardhead");
|
|
1875
|
+
tokenHead.append(el("span", "name", "按天用量"));
|
|
1876
|
+
const tokenBadge = el("span", "dsw-badge off", "—");
|
|
1877
|
+
tokenHead.append(tokenBadge);
|
|
1878
|
+
trendCard.append(tokenHead);
|
|
1879
|
+
trendCard.append(el("p", "dsw-hint", "柱高=当天的输入 + 输出;虚线=当天调用次数。鼠标停在柱上可看具体数字。"));
|
|
1880
|
+
const legend = el("div", "dsw-legend");
|
|
1881
|
+
legend.innerHTML = "<span><i style=\"background:var(--accent)\"></i>输入</span><span><i style=\"background:color-mix(in srgb,var(--accent) 45%,transparent)\"></i>输出</span><span><i style=\"background:var(--fg2)\"></i>调用次数</span>";
|
|
1882
|
+
const trendBox = el("div");
|
|
1883
|
+
trendCard.append(legend, trendBox);
|
|
1884
|
+
tokenPane.append(trendCard);
|
|
1885
|
+
const rankCard = el("div", "dsw-card");
|
|
1886
|
+
const rankHead = el("div", "dsw-cardhead");
|
|
1887
|
+
rankHead.append(el("span", "name", "用量分布"));
|
|
1888
|
+
const rankTabs = el("div", "dsw-subtabs");
|
|
1889
|
+
let rankKey = "account";
|
|
1890
|
+
const RANK_KEYS = [["account", "按账号"], ["model", "按模型"]];
|
|
1891
|
+
const rankBtns = [];
|
|
1892
|
+
const paintRankTabs = () => {
|
|
1893
|
+
RANK_KEYS.forEach(([key], index) => {
|
|
1894
|
+
const on = key === rankKey;
|
|
1895
|
+
rankBtns[index].classList.toggle("active", on);
|
|
1896
|
+
rankBtns[index].setAttribute("aria-selected", String(on));
|
|
1897
|
+
});
|
|
1898
|
+
};
|
|
1899
|
+
for (const [key, label] of RANK_KEYS) {
|
|
1900
|
+
const btn = el("button", "dsw-subtab", label);
|
|
1901
|
+
btn.type = "button";
|
|
1902
|
+
btn.setAttribute("role", "tab");
|
|
1903
|
+
btn.addEventListener("click", () => {
|
|
1904
|
+
if (rankKey === key) return;
|
|
1905
|
+
rankKey = key;
|
|
1906
|
+
paintRankTabs();
|
|
1907
|
+
renderRanks(rankKey === "account" ? lastUsage?.byAccount : lastUsage?.byModel, rankKey);
|
|
1908
|
+
});
|
|
1909
|
+
rankBtns.push(btn);
|
|
1910
|
+
rankTabs.append(btn);
|
|
1911
|
+
}
|
|
1912
|
+
paintRankTabs();
|
|
1913
|
+
rankHead.append(rankTabs);
|
|
1914
|
+
const ranks = el("div", "dsw-ranks");
|
|
1915
|
+
rankCard.append(rankHead, ranks);
|
|
1916
|
+
tokenPane.append(rankCard);
|
|
1917
|
+
const tokenRow = el("div", "dsw-row");
|
|
1918
|
+
const tokenRefreshBtn = el("button", "dsw-btn ghost", "刷新");
|
|
1919
|
+
tokenRefreshBtn.type = "button";
|
|
1920
|
+
tokenRefreshBtn.addEventListener("click", () => void loadUsage());
|
|
1921
|
+
tokenRow.append(tokenRefreshBtn);
|
|
1922
|
+
tokenPane.append(tokenRow);
|
|
1923
|
+
const tokenMsg = el("p", "dsw-hint dsw-gate-msg", "");
|
|
1924
|
+
tokenPane.append(tokenMsg);
|
|
1925
|
+
/** 账号 id → 给人看的名字(与调用台账同一套映射,拿不到就退回 id)。 */
|
|
1926
|
+
const accountLabel = (id) => {
|
|
1927
|
+
const hit = (Array.isArray(lastAccountsPayload?.accounts) ? lastAccountsPayload.accounts : []).find((item) => item.id === id);
|
|
1928
|
+
return hit ? String(hit.label || hit.display || hit.id) : id;
|
|
1929
|
+
};
|
|
1930
|
+
/**
|
|
1931
|
+
* 按天趋势图。手写 SVG(不引图表库)。
|
|
1932
|
+
* 左轴是 token、右轴是调用次数 —— 两把尺子只共用画布,不共用刻度(把它们画在同一
|
|
1933
|
+
* 坐标系里是最常见的图表错误:量级差几百倍时折线会贴死在底部)。
|
|
1934
|
+
*/
|
|
1935
|
+
const trendSvg = (series) => {
|
|
1936
|
+
const W = 640;
|
|
1937
|
+
const H = 170;
|
|
1938
|
+
const padL = 46;
|
|
1939
|
+
const plotW = 564;
|
|
1940
|
+
const plotH = 142;
|
|
1941
|
+
const n = Math.max(1, series.length);
|
|
1942
|
+
const tokenOf = (d) => (Number(d?.in) || 0) + (Number(d?.out) || 0);
|
|
1943
|
+
const maxTok = Math.max(1, ...series.map(tokenOf));
|
|
1944
|
+
const maxCalls = Math.max(1, ...series.map((d) => Number(d?.calls) || 0));
|
|
1945
|
+
const niceMax = (value) => {
|
|
1946
|
+
const base = Math.pow(10, Math.floor(Math.log10(value)));
|
|
1947
|
+
const norm = value / base;
|
|
1948
|
+
return (norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10) * base;
|
|
1949
|
+
};
|
|
1950
|
+
const top = niceMax(maxTok);
|
|
1951
|
+
const slot = plotW / n;
|
|
1952
|
+
const barW = Math.max(1.5, slot * .62);
|
|
1953
|
+
let out = `<svg class="dsw-trend" viewBox="0 0 ${W} ${H}" role="img" aria-label="按天用量趋势">`;
|
|
1954
|
+
for (const ratio of [
|
|
1955
|
+
0,
|
|
1956
|
+
.5,
|
|
1957
|
+
1
|
|
1958
|
+
]) {
|
|
1959
|
+
const y = 152 - plotH * ratio;
|
|
1960
|
+
out += `<line class="grid" x1="${padL}" y1="${y.toFixed(1)}" x2="610" y2="${y.toFixed(1)}"></line>`;
|
|
1961
|
+
out += `<text class="axis" x="41" y="${(y + 3).toFixed(1)}" text-anchor="end">${fmtTokens(top * ratio)}</text>`;
|
|
1962
|
+
}
|
|
1963
|
+
series.forEach((d, i) => {
|
|
1964
|
+
const x = padL + slot * i + (slot - barW) / 2;
|
|
1965
|
+
const hIn = (Number(d?.in) || 0) / top * plotH;
|
|
1966
|
+
const hOut = (Number(d?.out) || 0) / top * plotH;
|
|
1967
|
+
const yIn = 152 - hIn;
|
|
1968
|
+
out += `<g><title>${d?.date}:总 ${fullNum(tokenOf(d))} · 输入 ${fullNum(Number(d?.in) || 0)} / 输出 ${fullNum(Number(d?.out) || 0)} · 调用 ${Number(d?.calls) || 0} 次</title>`;
|
|
1969
|
+
if (hIn > 0) out += `<rect class="bar-in" x="${x.toFixed(1)}" y="${yIn.toFixed(1)}" width="${barW.toFixed(1)}" height="${hIn.toFixed(1)}" rx="1"></rect>`;
|
|
1970
|
+
if (hOut > 0) out += `<rect class="bar-out" x="${x.toFixed(1)}" y="${(yIn - hOut).toFixed(1)}" width="${barW.toFixed(1)}" height="${hOut.toFixed(1)}" rx="1"></rect>`;
|
|
1971
|
+
if (hIn + hOut <= 0 && (Number(d?.calls) || 0) > 0) out += `<rect class="bar-out" x="${x.toFixed(1)}" y="${150 .toFixed(1)}" width="${barW.toFixed(1)}" height="2" rx="0.5"></rect>`;
|
|
1972
|
+
out += "</g>";
|
|
1973
|
+
});
|
|
1974
|
+
const points = series.map((d, i) => {
|
|
1975
|
+
const x = padL + slot * i + slot / 2;
|
|
1976
|
+
const y = 152 - (Number(d?.calls) || 0) / maxCalls * plotH;
|
|
1977
|
+
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
|
1978
|
+
}).join(" ");
|
|
1979
|
+
out += `<polyline class="line" points="${points}"></polyline>`;
|
|
1980
|
+
out += `<text class="axis" x="614" y="${13 .toFixed(1)}" text-anchor="start">${Math.round(maxCalls)}</text>`;
|
|
1981
|
+
out += `<text class="axis" x="614" y="${155 .toFixed(1)}" text-anchor="start">0</text>`;
|
|
1982
|
+
const labelStep = Math.max(1, Math.ceil(n / 6));
|
|
1983
|
+
series.forEach((d, i) => {
|
|
1984
|
+
if (i % labelStep !== 0 && i !== n - 1) return;
|
|
1985
|
+
const x = padL + slot * i + slot / 2;
|
|
1986
|
+
const parts = String(d?.date ?? "").split("-");
|
|
1987
|
+
out += `<text class="axis" x="${x.toFixed(1)}" y="165" text-anchor="middle">${Number(parts[1])}/${Number(parts[2])}</text>`;
|
|
1988
|
+
});
|
|
1989
|
+
out += "</svg>";
|
|
1990
|
+
return out;
|
|
1991
|
+
};
|
|
1992
|
+
/** 分布排行。条宽按**最大值**归一 —— 按总量归一的话只有第一名看得见,其余全是一根短刺。 */
|
|
1993
|
+
const renderRanks = (rows, key) => {
|
|
1994
|
+
ranks.textContent = "";
|
|
1995
|
+
const items = Array.isArray(rows) ? rows.slice(0, 8) : [];
|
|
1996
|
+
if (items.length === 0) {
|
|
1997
|
+
ranks.append(el("div", "dsw-rank empty", "这段时间还没有用量记录。"));
|
|
1998
|
+
return;
|
|
1999
|
+
}
|
|
2000
|
+
const total = Number(lastUsage?.totals?.total) || 0;
|
|
2001
|
+
const max = Math.max(1, ...items.map((row) => (Number(row?.in) || 0) + (Number(row?.out) || 0)));
|
|
2002
|
+
for (const row of items) {
|
|
2003
|
+
const value = (Number(row?.in) || 0) + (Number(row?.out) || 0);
|
|
2004
|
+
const name = key === "account" ? accountLabel(String(row?.key ?? "")) : String(row?.key ?? "—");
|
|
2005
|
+
const line = el("div", "dsw-rank");
|
|
2006
|
+
line.append(el("div", "rk-name", name));
|
|
2007
|
+
line.append(el("div", "rk-val", `${fmtTokens(value)}${total > 0 ? ` · ${(value / total * 100).toFixed(1)}%` : ""} · ${Number(row?.calls) || 0} 次`));
|
|
2008
|
+
const track = el("div", "rk-track");
|
|
2009
|
+
const fill = el("div", "rk-fill");
|
|
2010
|
+
fill.style.width = `${Math.max(2, value / max * 100).toFixed(1)}%`;
|
|
2011
|
+
track.append(fill);
|
|
2012
|
+
line.append(track);
|
|
2013
|
+
ranks.append(line);
|
|
2014
|
+
}
|
|
2015
|
+
};
|
|
2016
|
+
/** 最近一次 /usage 的响应(切换"按账号/按模型"时不必重新拉)。 */
|
|
2017
|
+
let lastUsage = null;
|
|
2018
|
+
const renderUsage = (data) => {
|
|
2019
|
+
lastUsage = data;
|
|
2020
|
+
const totals = data?.totals ?? {};
|
|
2021
|
+
const total = Number(totals.total) || 0;
|
|
2022
|
+
const calls = Number(totals.calls) || 0;
|
|
2023
|
+
const serverCalls = Number(totals.serverCalls) || 0;
|
|
2024
|
+
const share = (part) => total > 0 ? `占总量 ${(part / total * 100).toFixed(1)}%` : "";
|
|
2025
|
+
sTotal.set(fmtTokens(total), calls > 0 ? `服务端口径 ${serverCalls}/${calls} 次` : "");
|
|
2026
|
+
sIn.set(fmtTokens(Number(totals.in) || 0), share(Number(totals.in) || 0));
|
|
2027
|
+
sOut.set(fmtTokens(Number(totals.out) || 0), share(Number(totals.out) || 0));
|
|
2028
|
+
sCalls.set(String(calls), calls > 0 ? `成功 ${Number(totals.ok) || 0} · 失败 ${Number(totals.failed) || 0}` : "");
|
|
2029
|
+
tokenBadge.textContent = tokenDays === 0 ? "全部" : `近 ${tokenDays} 天`;
|
|
2030
|
+
tokenBadge.className = `dsw-badge ${total > 0 ? "on" : "off"}`;
|
|
2031
|
+
const series = Array.isArray(data?.series) ? data.series : [];
|
|
2032
|
+
trendBox.innerHTML = total > 0 ? trendSvg(series) : "";
|
|
2033
|
+
if (total <= 0) tokenMsg.textContent = calls > 0 ? `有 ${calls} 次调用,但没有可用的 token 数(失败调用或不带用量的响应不算)。` : "这段时间还没有用量记录 —— 跑一次对话后点「刷新」。";
|
|
2034
|
+
else tokenMsg.textContent = `数据覆盖 ${data?.coverage?.from ?? "—"} ~ ${data?.coverage?.to ?? "—"}(保留 ${data?.keepDays ?? 90} 天)。网页端只在部分响应里给出总量,拿不到时按字符估算,所以这两个数是估算值。`;
|
|
2035
|
+
renderRanks(rankKey === "account" ? data?.byAccount : data?.byModel, rankKey);
|
|
2036
|
+
};
|
|
2037
|
+
const loadUsage = async () => {
|
|
2038
|
+
try {
|
|
2039
|
+
renderUsage(await api(`/usage?days=${tokenDays}`));
|
|
2040
|
+
} catch (error) {
|
|
2041
|
+
tokenMsg.textContent = `用量读取失败:${error?.message ?? error}`;
|
|
2042
|
+
}
|
|
2043
|
+
};
|
|
2044
|
+
paneFirstShow.token = () => void loadUsage();
|
|
1770
2045
|
const transportCard = el("div", "dsw-card");
|
|
1771
2046
|
transportCard.append(el("div", "dsw-cardhead", "传输层(指纹)"));
|
|
1772
2047
|
const transportRow = el("div", "dsw-gate-row");
|