zcode-usage-stats 0.1.0

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/lib/client.js ADDED
@@ -0,0 +1,1124 @@
1
+ // dsh-usage-stats —— 使用统计(客户端)
2
+ // 排版:固定 px 字阶、WCAG 对比度达标、4 的倍数间距阶梯
3
+ // 图表:日历点阵 / 发丝折线 / 刻度环,配色与排版自研
4
+ // 关键:按实测容器宽度 1:1 出图,不做 viewBox 缩放(缩放会让小字号变成看不见)
5
+ window.__ModuleLoader__.load({
6
+ id: "zcode-usage-stats",
7
+ factory: (require) => {
8
+ var module = { exports: {} };
9
+ var exports = module.exports;
10
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
11
+ const react = require("react");
12
+ const element = react.createElement;
13
+
14
+ const inject = ["slots"];
15
+
16
+ // ---------- 格式化 ----------
17
+ function fmtLarge(n) {
18
+ n = Number(n) || 0;
19
+ if (n >= 1e8) return (n / 1e8).toFixed(2) + "亿";
20
+ if (n >= 1e4) return (n / 1e4).toFixed(1) + "万";
21
+ return String(Math.round(n));
22
+ }
23
+ function fmtFull(n) {
24
+ return (Number(n) || 0).toLocaleString("zh-CN");
25
+ }
26
+ function fmtDuration(ms) {
27
+ const totalMin = Math.floor((Number(ms) || 0) / 60000);
28
+ if (totalMin <= 0) return "0 分";
29
+ const d = Math.floor(totalMin / 1440);
30
+ const h = Math.floor((totalMin % 1440) / 60);
31
+ const m = totalMin % 60;
32
+ if (d > 0) return d + " 天 " + h + " 时";
33
+ if (h > 0) return h + " 时 " + m + " 分";
34
+ return m + " 分";
35
+ }
36
+ function fmtClock(ms) {
37
+ const n = Number(ms);
38
+ if (!n) return "";
39
+ const d = new Date(n);
40
+ return String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0");
41
+ }
42
+ function fmtDayLabel(dateStr) {
43
+ const p = String(dateStr || "").split("-");
44
+ if (p.length !== 3) return dateStr;
45
+ return Number(p[1]) + " 月 " + Number(p[2]) + " 日";
46
+ }
47
+ function dateKey(d) {
48
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
49
+ }
50
+ function addDays(key, n) {
51
+ const p = key.split("-").map(Number);
52
+ const d = new Date(p[0], p[1] - 1, p[2]);
53
+ d.setDate(d.getDate() + n);
54
+ return dateKey(d);
55
+ }
56
+ function rangeKeyOf(y, m, d) {
57
+ return y + "-" + String(m).padStart(2, "0") + "-" + String(d).padStart(2, "0");
58
+ }
59
+
60
+ // ---------- 容器宽度实测:图表 1:1 出图,不缩放 ----------
61
+ function useMeasure() {
62
+ const ref = react.useRef(null);
63
+ const [w, setW] = react.useState(0);
64
+ react.useEffect(() => {
65
+ const el = ref.current;
66
+ if (!el) return;
67
+ const read = () => {
68
+ const next = Math.round(el.getBoundingClientRect().width);
69
+ setW((cur) => (Math.abs(cur - next) >= 2 ? next : cur));
70
+ };
71
+ read();
72
+ if (typeof ResizeObserver !== "undefined") {
73
+ const ro = new ResizeObserver(read);
74
+ ro.observe(el);
75
+ return () => ro.disconnect();
76
+ }
77
+ window.addEventListener("resize", read);
78
+ return () => window.removeEventListener("resize", read);
79
+ }, []);
80
+ return [ref, w];
81
+ }
82
+
83
+ // ---------- 颜色 ----------
84
+ // 类目色(色相 = 模型身份):用于趋势线、环形刻度、图例、模型列表
85
+ // 色相只在 6 个之间循环,超出时颜色不重复承担识别——列表里始终带模型名
86
+ const CATN = 6;
87
+ const cF = (i) => "ust-cf" + (i % CATN);
88
+ const cS = (i) => "ust-cs" + (i % CATN);
89
+ const cB = (i) => "ust-cb" + (i % CATN);
90
+ const cVar = (i) => "var(--c" + (i % CATN) + ")";
91
+ // 序数色(明度 = 用量大小):只用于热力图,单色相蓝阶
92
+ // 档位:0 无用量(quiet)/ 2 少量 / 3 中等 / 4 最多
93
+ const HEAT_LEVELS = 3;
94
+ const qLevel = (v, max) => {
95
+ if (!(v > 0) || !(max > 0)) return 0;
96
+ const f = v / max;
97
+ return f > 0.66 ? HEAT_LEVELS : f > 0.33 ? 2 : 1;
98
+ };
99
+ const qF = (lv) => "ust-qf" + lv;
100
+ const qB = (lv) => "ust-qb" + lv;
101
+ const rnd = (i, k) => Math.abs(((i * 73856093) ^ (k * 19349663)) % 1000) / 1000;
102
+
103
+ // ---------- 时间范围 ----------
104
+ const RANGES = [
105
+ { id: "today", label: "今天", build: (now) => [rangeKeyOf(now.getFullYear(), now.getMonth() + 1, now.getDate())] },
106
+ { id: "yesterday", label: "昨天", build: (now) => { const d = new Date(now); d.setDate(now.getDate() - 1); return [rangeKeyOf(d.getFullYear(), d.getMonth() + 1, d.getDate())]; } },
107
+ { id: "last7", label: "近 7 天", build: (now) => { const a = []; for (let i = 6; i >= 0; i--) { const d = new Date(now); d.setDate(now.getDate() - i); a.push(rangeKeyOf(d.getFullYear(), d.getMonth() + 1, d.getDate())); } return a; } },
108
+ { id: "last30", label: "近 30 天", build: (now) => { const a = []; for (let i = 29; i >= 0; i--) { const d = new Date(now); d.setDate(now.getDate() - i); a.push(rangeKeyOf(d.getFullYear(), d.getMonth() + 1, d.getDate())); } return a; } },
109
+ { id: "month", label: "本月", build: (now) => { const y = now.getFullYear(), m = now.getMonth() + 1; const last = new Date(y, m, 0).getDate(); const to = Math.min(last, now.getDate()); const a = []; for (let i = 1; i <= to; i++) a.push(rangeKeyOf(y, m, i)); return a; } },
110
+ { id: "prevMonth", label: "上月", build: (now) => { const p = new Date(now.getFullYear(), now.getMonth() - 1, 1); const y = p.getFullYear(), m = p.getMonth() + 1; const last = new Date(y, m, 0).getDate(); const a = []; for (let i = 1; i <= last; i++) a.push(rangeKeyOf(y, m, i)); return a; } },
111
+ { id: "all", label: "全部", build: () => [] },
112
+ ];
113
+
114
+ function customKeys(from, to) {
115
+ const a = [];
116
+ if (!from || !to || from > to) return a;
117
+ let k = from, guard = 0;
118
+ while (k <= to && guard < 3660) { a.push(k); k = addDays(k, 1); guard++; }
119
+ return a;
120
+ }
121
+ function resolveWindow(range, now = new Date()) {
122
+ const r = typeof range === "string" ? { id: range } : (range || {});
123
+ if (r.id === "custom") {
124
+ const dateKeys = customKeys(r.from, r.to);
125
+ return { id: "custom", label: r.from || r.to ? (r.from + " 至 " + r.to) : "自定义", dateKeys };
126
+ }
127
+ const def = RANGES.find((x) => x.id === r.id) || RANGES[3];
128
+ return { id: def.id, label: def.label, dateKeys: def.build(now) };
129
+ }
130
+ function fillDays(days, wnd) {
131
+ const map = new Map();
132
+ (days || []).forEach((d) => map.set(d.date, d));
133
+ return (wnd.dateKeys || []).map((k) => map.get(k) || { date: k, total: 0, turns: 0, byModel: [] });
134
+ }
135
+
136
+ // ---------- 图标(1.5 描边,随字色) ----------
137
+ function icon(paths) {
138
+ return element("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
139
+ paths.map((d, i) => element("path", { key: i, d })));
140
+ }
141
+ const IconToken = () => icon(["M12 2L2 7l10 5 10-5-10-5z", "M2 17l10 5 10-5", "M2 12l10 5 10-5"]);
142
+ const IconBolt = () => icon(["M13 2L3 14h9l-1 8 10-12h-9l1-8z"]);
143
+ const IconChat = () => icon(["M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"]);
144
+ const IconCalendar = () => icon(["M3 4h18v18H3z", "M16 2v4", "M8 2v4", "M3 10h18"]);
145
+ const IconTrend = () => icon(["M23 6l-9.5 9.5-5-5L1 18", "M17 6h6v6"]);
146
+ const IconMail = () => icon(["M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z", "M22 6l-10 7L2 6"]);
147
+ const IconRefresh = () => icon(["M23 4v6h-6", "M1 20v-6h6", "M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"]);
148
+
149
+ const CSS = `/* ═══════════════════════════════════════════════════════════════
150
+ 使用统计 · 设置面板样式
151
+ 排版:固定字阶 / 对比度达标 / 间距阶梯(详见下)
152
+ · 固定 px 字阶(产品界面不用 fluid type),1.125–1.2 级差
153
+ · 正文对比度 ≥ 4.5:1,次要文字 ≥ 3:1
154
+ · 行高:正文 1.5 / 标题 1.25 / 说明 1.4
155
+ · 间距取自 4 的倍数阶梯:4 8 12 16 24 32
156
+ · 数字统一 tabular-nums 对齐
157
+ 图表配色:类目色 = 模型身份(跨色相),序数色 = 用量深浅(单色相明度阶)
158
+ 容器宽度自适应:图表按实测宽度 1:1 出图,不做缩放
159
+ ═══════════════════════════════════════════════════════════════ */
160
+
161
+ .ust-root {
162
+ /* 浅色(默认) */
163
+ --ink: #191918;
164
+ --muted: #5F5E59;
165
+ --faint: #86857F;
166
+ --rule: #C4C3BC;
167
+ --surface: #F7F6F3;
168
+ --hover: #EDECE7;
169
+ --l0: #191918; --l1: #3F3E3A; --l2: #5A5954;
170
+ --l3: #78776F; --l4: #93928A; --l5: #A9A8A0;
171
+ --quiet: #C4C3BC;
172
+ --tip-bg: #191918; --tip-fg: #F7F6F3;
173
+ /* 类目色:6 个跨色相,承载模型身份;两两色距 >=60 保证一眼可分 */
174
+ --c0: #C62828; --c1: #C77800; --c2: #2F7D4F; --c3: #1D5FD0; --c4: #6D4AC4; --c5: #0E7C86;
175
+ /* 序数色:单色相蓝阶,明度 = 用量大小(浅→深) */
176
+ --q1: #9CBEE6; --q2: #5B8FD4; --q3: #2C6BC4; --q4: #123C74;
177
+
178
+ display: flex; flex-direction: column; gap: 32px;
179
+ padding: 0; color: var(--ink);
180
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
181
+ font-size: 13px; line-height: 1.5;
182
+ font-variant-numeric: tabular-nums;
183
+ -webkit-font-smoothing: antialiased;
184
+ }
185
+ body[data-ds-dark-theme] .ust-root {
186
+ --ink: #F2F1ED;
187
+ --muted: #A5A49E;
188
+ --faint: #74736E;
189
+ --rule: #3B3A35;
190
+ --surface: #24231F;
191
+ --hover: #2C2B27;
192
+ --l0: #F2F1ED; --l1: #D6D5CF; --l2: #B4B3AD;
193
+ --l3: #8F8E88; --l4: #6A6963; --l5: #4A4944;
194
+ --quiet: #35342F;
195
+ --tip-bg: #F2F1ED; --tip-fg: #191918;
196
+ /* 类目色(暗色主题):同 6 色相提亮,暗底对比 >=3:1 */
197
+ --c0: #FF6B6B; --c1: #FFA53D; --c2: #52C77A; --c3: #5B9DFF; --c4: #A78BFA; --c5: #2DD4BF;
198
+ /* 序数色(暗色主题):越亮 = 用量越大 */
199
+ --q1: #2A4C7A; --q2: #3F6EA8; --q3: #5F97D4; --q4: #9CC4EE;
200
+ }
201
+ .ust-root *, .ust-root *::before, .ust-root *::after { box-sizing: border-box; }
202
+ .ust-root svg text { font-family: inherit; font-variant-numeric: tabular-nums; }
203
+
204
+ /* ── 排版层级 ────────────────────────────────────────────── */
205
+ .ust-page-title { margin: 0; font-size: 18px; font-weight: 700; line-height: 1.25; letter-spacing: -.01em; color: var(--ink); }
206
+ .ust-h2 { margin: 0; font-size: 15px; font-weight: 650; line-height: 1.3; letter-spacing: -.005em; color: var(--ink); }
207
+ .ust-sub { margin: 3px 0 0; font-size: 12px; line-height: 1.45; color: var(--muted); }
208
+ .ust-src { margin: 12px 0 0; font-size: 10px; font-weight: 600; line-height: 1.4; letter-spacing: .08em; text-transform: uppercase; color: var(--faint); }
209
+
210
+ /* ── 顶部:标题 + 时间范围 ───────────────────────────────── */
211
+ .ust-header { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 12px 16px; }
212
+ .ust-rangebox { position: relative; }
213
+ .ust-tabs { display: flex; flex-wrap: wrap; gap: 2px 4px; }
214
+ .ust-tab {
215
+ padding: 4px 9px; font-size: 12px; font-weight: 600; line-height: 1.4;
216
+ color: var(--muted); border-radius: 6px; cursor: pointer;
217
+ transition: background-color .15s ease, color .15s ease;
218
+ }
219
+ .ust-tab:hover { background: var(--hover); color: var(--ink); }
220
+ .ust-tab.active { background: var(--ink); color: var(--surface); }
221
+ body[data-ds-dark-theme] .ust-tab.active { background: var(--ink); color: #191918; }
222
+ .ust-tab:focus-visible { outline: 2px solid var(--ink); outline-offset: 1px; }
223
+
224
+ /* ── 指标组 ─────────────────────────────────────────────── */
225
+ .ust-metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(112px, 1fr)); gap: 16px 20px; }
226
+ .ust-metric { border-top: 1px solid var(--rule); padding-top: 10px; min-width: 0; }
227
+ .ust-metric-label {
228
+ display: flex; align-items: center; gap: 5px;
229
+ font-size: 11px; font-weight: 600; line-height: 1.4; letter-spacing: .04em;
230
+ color: var(--muted); text-transform: uppercase;
231
+ }
232
+ .ust-metric-label svg { width: 11px; height: 11px; flex: none; opacity: .85; }
233
+ .ust-metric-value { margin-top: 6px; font-size: 25px; font-weight: 700; line-height: 1.15; letter-spacing: -.02em; color: var(--ink); overflow-wrap: anywhere; }
234
+ .ust-metric-value.is-text { font-size: 15px; font-weight: 650; line-height: 1.35; letter-spacing: 0; overflow-wrap: anywhere; hyphens: auto; }
235
+ .ust-metric-sub { margin-top: 4px; font-size: 12px; line-height: 1.4; color: var(--muted); }
236
+
237
+ /* ── 图表区块 ───────────────────────────────────────────── */
238
+ .ust-block { display: flex; flex-direction: column; }
239
+ .ust-block-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 8px 16px; margin-bottom: 14px; }
240
+ .ust-block-head .ust-tabs { gap: 2px; }
241
+
242
+ /* 图表容器:等实测宽度后 1:1 出图 */
243
+ .ust-plot { width: 100%; }
244
+ .ust-plot svg { display: block; width: 100%; height: auto; }
245
+ .ust-plot.is-scroll { overflow-x: auto; overflow-y: hidden; scrollbar-width: thin; scrollbar-color: var(--rule) transparent; cursor: pointer; }
246
+ .ust-plot.is-scroll svg { width: auto; max-width: none; }
247
+ .ust-plot.is-scroll::-webkit-scrollbar { height: 6px; }
248
+ .ust-plot.is-scroll::-webkit-scrollbar-track { background: transparent; }
249
+ .ust-plot.is-scroll::-webkit-scrollbar-thumb { background: var(--rule); border-radius: 3px; }
250
+ .ust-plot.is-scroll::-webkit-scrollbar-thumb:hover { background: var(--faint); }
251
+
252
+ /* ── 图表内的图形语法 ───────────────────────────────────── */
253
+ .ust-hitcell { fill: transparent; cursor: pointer; }
254
+ .ust-hitband { fill: transparent; cursor: pointer; }
255
+ .ust-axis { font-size: 11px; font-weight: 500; fill: var(--muted); }
256
+ .ust-axis-strong { font-size: 11px; font-weight: 650; fill: var(--ink); }
257
+ .ust-grid { stroke: var(--rule); stroke-width: 1; }
258
+ .ust-baseline { stroke: var(--faint); stroke-width: 1; }
259
+ .ust-floor-tick { stroke: var(--rule); stroke-width: 1; }
260
+ .ust-hair { fill: none; stroke-width: 2.7; stroke-linejoin: round; stroke-linecap: round; }
261
+ .ust-dot { stroke: none; }
262
+ .ust-dot-quiet { fill: var(--quiet); }
263
+ .ust-cursor { stroke: var(--faint); stroke-width: 1; stroke-dasharray: 3 3; pointer-events: none; }
264
+ .ust-hit { fill: transparent; cursor: crosshair; }
265
+ .ust-callout { font-size: 11px; font-weight: 700; fill: var(--ink); paint-order: stroke; stroke: var(--surface); stroke-width: 3px; }
266
+ .ust-peak-ring { fill: none; stroke: var(--ink); stroke-width: 1; stroke-dasharray: 2 3; }
267
+ .ust-tick { stroke-width: 3.6; stroke-linecap: round; pointer-events: none; }
268
+ .ust-tickmark { fill: var(--faint); }
269
+ .ust-caption { font-size: 10px; font-weight: 600; letter-spacing: .06em; fill: var(--faint); text-transform: uppercase; }
270
+
271
+ /* 类目色(模型身份):填充 / 描边 / 圆点三套,色相区分模型 */
272
+ .ust-cf0 { fill: var(--c0); } .ust-cf1 { fill: var(--c1); } .ust-cf2 { fill: var(--c2); }
273
+ .ust-cf3 { fill: var(--c3); } .ust-cf4 { fill: var(--c4); } .ust-cf5 { fill: var(--c5); }
274
+ .ust-cs0 { stroke: var(--c0); } .ust-cs1 { stroke: var(--c1); } .ust-cs2 { stroke: var(--c2); }
275
+ .ust-cs3 { stroke: var(--c3); } .ust-cs4 { stroke: var(--c4); } .ust-cs5 { stroke: var(--c5); }
276
+ .ust-cb0 { background: var(--c0); } .ust-cb1 { background: var(--c1); } .ust-cb2 { background: var(--c2); }
277
+ .ust-cb3 { background: var(--c3); } .ust-cb4 { background: var(--c4); } .ust-cb5 { background: var(--c5); }
278
+
279
+ /* 序数色(用量深浅):单色相,明度即数值 */
280
+ .ust-qf1 { fill: var(--q1); } .ust-qf2 { fill: var(--q2); } .ust-qf3 { fill: var(--q3); } .ust-qf4 { fill: var(--q4); }
281
+ .ust-qs1 { stroke: var(--q1); } .ust-qs2 { stroke: var(--q2); } .ust-qs3 { stroke: var(--q3); } .ust-qs4 { stroke: var(--q4); }
282
+ .ust-qb1 { background: var(--q1); } .ust-qb2 { background: var(--q2); } .ust-qb3 { background: var(--q3); } .ust-qb4 { background: var(--q4); }
283
+
284
+ /* 明度阶梯(明度即数据:最重要 = 对比度最高) */
285
+ .ust-f0 { fill: var(--l0); } .ust-f1 { fill: var(--l1); } .ust-f2 { fill: var(--l2); }
286
+ .ust-f3 { fill: var(--l3); } .ust-f4 { fill: var(--l4); } .ust-f5 { fill: var(--l5); }
287
+ .ust-s0 { stroke: var(--l0); } .ust-s1 { stroke: var(--l1); } .ust-s2 { stroke: var(--l2); }
288
+ .ust-s3 { stroke: var(--l3); } .ust-s4 { stroke: var(--l4); } .ust-s5 { stroke: var(--l5); }
289
+ .ust-b0 { background: var(--l0); } .ust-b1 { background: var(--l1); } .ust-b2 { background: var(--l2); }
290
+ .ust-b3 { background: var(--l3); } .ust-b4 { background: var(--l4); } .ust-b5 { background: var(--l5); }
291
+
292
+ /* ── 色阶图例(热力图:少 → 多) ───────────────────────── */
293
+ .ust-scale { display: flex; align-items: center; gap: 5px; margin-top: 6px; }
294
+ .ust-scale-lab { font-size: 11px; font-weight: 600; color: var(--muted); }
295
+ .ust-scale-sw { width: 9px; height: 9px; border-radius: 50%; }
296
+
297
+ /* ── 模型列表 ───────────────────────────────────────────── */
298
+ .ust-models { list-style: none; margin: 0; padding: 0; }
299
+ .ust-model {
300
+ display: flex; align-items: center; justify-content: space-between; gap: 12px;
301
+ padding: 9px 0; border-bottom: 1px solid var(--rule); cursor: default;
302
+ }
303
+ .ust-model:last-child { border-bottom: 0; }
304
+ .ust-model-name { display: flex; align-items: center; gap: 8px; min-width: 0; font-size: 13px; font-weight: 600; color: var(--ink); }
305
+ .ust-model-name span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
306
+ .ust-model-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
307
+ .ust-model-val { flex: none; font-size: 13px; font-weight: 650; color: var(--ink); }
308
+ .ust-model-sub { flex: none; font-size: 12px; color: var(--muted); }
309
+ .ust-model-row { display: flex; align-items: baseline; gap: 10px; }
310
+
311
+ /* ── 图表下方的图例(可点击显隐) ───────────────────────── */
312
+ .ust-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 12px; }
313
+ .ust-legend-item {
314
+ display: flex; align-items: center; gap: 7px;
315
+ font-size: 12px; font-weight: 550; line-height: 1.5; color: var(--muted);
316
+ cursor: pointer; transition: color .15s ease, opacity .15s ease;
317
+ }
318
+ .ust-legend-item:hover { color: var(--ink); }
319
+ .ust-legend-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
320
+
321
+ /* ── 环形图 ─────────────────────────────────────────────── */
322
+ .ust-donut-wrap { display: flex; flex-direction: column; gap: 16px; }
323
+ .ust-donut { position: relative; width: 168px; height: 168px; margin: 4px auto 0; flex: none; }
324
+ .ust-donut svg { display: block; width: 100%; height: 100%; overflow: visible; }
325
+ .ust-donut-center {
326
+ position: absolute; inset: 0; display: flex; flex-direction: column;
327
+ align-items: center; justify-content: center; pointer-events: none;
328
+ }
329
+ .ust-donut-center .num { font-size: 22px; font-weight: 700; line-height: 1.1; letter-spacing: -.02em; color: var(--ink); }
330
+ .ust-donut-center .lab { margin-top: 2px; font-size: 10px; font-weight: 600; letter-spacing: .1em; color: var(--muted); }
331
+
332
+ /* ── 提示条 ─────────────────────────────────────────────── */
333
+ .ust-warn {
334
+ padding: 10px 14px; font-size: 12px; line-height: 1.5; color: var(--ink);
335
+ background: var(--surface); border: 1px solid var(--rule); border-radius: 8px;
336
+ }
337
+
338
+ /* ── 底部:页脚 + 按钮 ─────────────────────────────────── */
339
+ .ust-footer {
340
+ display: flex; align-items: center; justify-content: space-between; gap: 16px;
341
+ margin-top: 4px; padding-top: 16px; border-top: 1px solid var(--rule);
342
+ }
343
+ .ust-meta { font-size: 12px; line-height: 1.5; color: var(--muted); font-variant-numeric: tabular-nums; }
344
+ .ust-actions { display: flex; justify-content: flex-end; }
345
+ .ust-btn {
346
+ display: inline-flex; align-items: center; gap: 7px;
347
+ padding: 7px 14px; font-family: inherit; font-size: 12.5px; font-weight: 600; line-height: 1.4;
348
+ letter-spacing: .01em; color: var(--ink);
349
+ background: var(--surface); border: 1px solid var(--rule);
350
+ border-radius: 8px; cursor: pointer;
351
+ transition: background-color .15s ease, border-color .15s ease, color .15s ease;
352
+ }
353
+ .ust-btn:hover:not(:disabled) { background: var(--hover); border-color: var(--faint); }
354
+ .ust-btn:active:not(:disabled) { background: var(--rule); }
355
+ .ust-btn:disabled { opacity: .55; cursor: progress; }
356
+ .ust-btn:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
357
+ .ust-btn--primary { background: var(--ink); border-color: var(--ink); color: var(--surface); }
358
+ body[data-ds-dark-theme] .ust-btn--primary { color: #191918; }
359
+ .ust-btn--primary:hover:not(:disabled) { background: var(--ink); border-color: var(--faint); }
360
+ .ust-btn--ghost { background: transparent; border-color: transparent; color: var(--muted); }
361
+ .ust-btn--ghost:hover:not(:disabled) { background: var(--hover); border-color: var(--rule); color: var(--ink); }
362
+ .ust-btn-ico { display: inline-flex; width: 13px; height: 13px; }
363
+ .ust-btn-ico svg { width: 13px; height: 13px; }
364
+ .ust-btn-ico.is-spin { animation: ustSpin 900ms linear infinite; }
365
+ @keyframes ustSpin { to { transform: rotate(360deg); } }
366
+ @media (prefers-reduced-motion: reduce) { .ust-btn-ico.is-spin { animation: none; } }
367
+
368
+ /* ── 自定义区间面板 ─────────────────────────────────────── */
369
+ .ust-custom {
370
+ position: absolute; top: calc(100% + 8px); right: 0; z-index: 60;
371
+ display: flex; flex-direction: column; gap: 10px;
372
+ min-width: 272px; padding: 14px;
373
+ background: var(--surface); border: 1px solid var(--rule); border-radius: 12px;
374
+ }
375
+ .ust-custom-row { display: flex; align-items: center; gap: 8px; }
376
+ .ust-custom-sep { font-size: 12px; color: var(--muted); }
377
+ .ust-custom-actions { display: flex; justify-content: flex-end; gap: 8px; }
378
+ .ust-date {
379
+ flex: 1; min-width: 0; padding: 6px 10px;
380
+ font-family: inherit; font-size: 12px; color: var(--ink);
381
+ background: transparent; border: 1px solid var(--rule); border-radius: 8px;
382
+ }
383
+ .ust-date:focus-visible { outline: 2px solid var(--ink); outline-offset: 1px; border-color: transparent; }
384
+ .ust-date::-webkit-calendar-picker-indicator { filter: none; opacity: .6; cursor: pointer; }
385
+ body[data-ds-dark-theme] .ust-date::-webkit-calendar-picker-indicator { filter: invert(1); opacity: .7; }
386
+
387
+ /* ── 浮动提示 ───────────────────────────────────────────── */
388
+ .ust-tip {
389
+ position: fixed; z-index: 10000; pointer-events: none;
390
+ max-width: 260px; padding: 9px 12px;
391
+ font-size: 12px; line-height: 1.45;
392
+ color: var(--tip-fg); background: var(--tip-bg);
393
+ border-radius: 8px;
394
+ }
395
+ .ust-tip-title { font-weight: 700; }
396
+ .ust-tip-body { margin-top: 2px; opacity: .75; }
397
+ .ust-tip-row { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin-top: 5px; }
398
+ .ust-tip-row:first-of-type { margin-top: 7px; }
399
+ .ust-tip-key { display: flex; align-items: center; gap: 7px; min-width: 0; }
400
+ .ust-tip-key span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
401
+ .ust-tip-swatch { width: 8px; height: 8px; border-radius: 50%; flex: none; }
402
+ .ust-tip-val { flex: none; opacity: .75; }
403
+
404
+ /* ── 载入 / 空态 ────────────────────────────────────────── */
405
+ .ust-placeholder { padding: 32px 0; font-size: 13px; line-height: 1.5; color: var(--muted); text-align: center; }
406
+ .ust-skeleton {
407
+ height: 12px; margin-top: 10px; border-radius: 6px;
408
+ background: var(--hover); animation: ustPulse 1.4s ease-in-out infinite;
409
+ }
410
+ @keyframes ustPulse { 0%, 100% { opacity: 1; } 50% { opacity: .45; } }
411
+
412
+ /* ── 动画(150–250ms,只表达状态,不做装饰) ────────────── */
413
+ .ust-fade { animation: ustFade .24s ease-out both; }
414
+ @keyframes ustFade { from { opacity: 0; } }
415
+ .ust-draw { stroke-dasharray: 1; stroke-dashoffset: 1; animation: ustDraw .5s ease-out both; }
416
+ @keyframes ustDraw { to { stroke-dashoffset: 0; } }
417
+ .ust-pop { transform-box: fill-box; transform-origin: center; animation: ustPop .22s ease-out both; }
418
+ @keyframes ustPop { from { opacity: 0; transform: scale(.6); } to { opacity: 1; transform: none; } }
419
+ @media (prefers-reduced-motion: reduce) {
420
+ .ust-fade, .ust-draw, .ust-pop, .ust-skeleton { animation: none; }
421
+ .ust-draw { stroke-dasharray: none; stroke-dashoffset: 0; }
422
+ }
423
+ `;
424
+
425
+ // ---------- 数据辅助 ----------
426
+ const totalOf = (d) => (d ? d.total : 0);
427
+ const turnsOf = (d) => (d ? d.turns : 0);
428
+ function calcStats(days, wnd) {
429
+ const list = fillDays(days, wnd);
430
+ const total = list.reduce((s, d) => s + totalOf(d), 0);
431
+ const turns = list.reduce((s, d) => s + turnsOf(d), 0);
432
+ const activeDays = list.filter((d) => totalOf(d) > 0).length;
433
+ return { total, turns, activeDays };
434
+ }
435
+ // 尾部模型并入「其他」:颜色才能落在阶梯上,且图例不爆
436
+ function foldModels(list, n = 4) {
437
+ const sorted = [...list].sort((a, b) => b.total - a.total);
438
+ if (sorted.length <= n + 1) return sorted.map((m, i) => ({ ...m, rank: i }));
439
+ const head = sorted.slice(0, n).map((m, i) => ({ ...m, rank: i }));
440
+ const tail = sorted.slice(n);
441
+ head.push({
442
+ model: "其他 " + tail.length + " 个模型",
443
+ total: tail.reduce((s, m) => s + m.total, 0),
444
+ children: tail,
445
+ rank: n,
446
+ });
447
+ return head;
448
+ }
449
+ function modelTotalsIn(days, wnd) {
450
+ const m = new Map();
451
+ fillDays(days, wnd).forEach((d) => (d.byModel || []).forEach((x) => m.set(x.model, (m.get(x.model) || 0) + x.total)));
452
+ return [...m.entries()].map(([model, total]) => ({ model, total }));
453
+ }
454
+
455
+ // ---------- 指标 ----------
456
+ function Metric({ icon, label, value, sub, text }) {
457
+ return element("div", { className: "ust-metric" },
458
+ element("div", { className: "ust-metric-label" }, icon, element("span", null, label)),
459
+ element("div", { className: "ust-metric-value" + (text ? " is-text" : "") }, value),
460
+ sub ? element("div", { className: "ust-metric-sub" }, sub) : null,
461
+ );
462
+ }
463
+
464
+ function SummaryMetrics({ summary }) {
465
+ const s = summary || {};
466
+ return element("div", { className: "ust-metrics" },
467
+ element(Metric, { icon: element(IconToken), label: "累计 Token", value: fmtLarge(s.totalTokens), sub: fmtFull(s.totalTokens || 0) }),
468
+ element(Metric, { icon: element(IconBolt), label: "单日峰值", value: fmtLarge(s.peakDayTokens), sub: "历史最高一天" }),
469
+ element(Metric, { icon: element(IconChat), label: "最长会话", value: fmtDuration(s.longestSessionMs), sub: "单个会话持续最久", text: true }),
470
+ element(Metric, { icon: element(IconCalendar), label: "当前连续", value: String(s.currentStreak || 0) + " 天", sub: "有用量的连续天" }),
471
+ element(Metric, { icon: element(IconTrend), label: "最长连续", value: String(s.longestStreak || 0) + " 天", sub: "历史最长连续" }),
472
+ );
473
+ }
474
+
475
+ // ---------- 提示框(跟随鼠标,边界内翻转) ----------
476
+ function useTip() {
477
+ const [tip, setTip] = react.useState(null);
478
+ const place = (e, node) => {
479
+ const W = 260, H = 150;
480
+ let x = e.clientX + 14, y = e.clientY + 14;
481
+ if (x + W > window.innerWidth) x = e.clientX - W - 14;
482
+ if (y + H > window.innerHeight) y = e.clientY - H - 14;
483
+ if (x < 8) x = 8;
484
+ if (y < 8) y = 8;
485
+ setTip({ node, x, y });
486
+ };
487
+ const clear = () => setTip(null);
488
+ const el = tip ? element("div", { className: "ust-tip", style: { left: tip.x + "px", top: tip.y + "px" } }, tip.node) : null;
489
+ return [place, clear, el];
490
+ }
491
+
492
+ // ---------- 1) 活跃分布 ----------
493
+ // 日历点阵:一格一天,点面积 = 当天用量(sqrt 换算,面积才与数值成正比)
494
+ const HEAT_MODES = [
495
+ { id: "daily", label: "每日" },
496
+ { id: "weekly", label: "每周" },
497
+ { id: "cumulative", label: "累计" },
498
+ ];
499
+ const WD = ["日", "一", "二", "三", "四", "五", "六"];
500
+
501
+ function Heatmap({ days, window: wnd }) {
502
+ const [mode, setMode] = react.useState("daily");
503
+ const [boxRef, boxW] = useMeasure();
504
+ const [placeTip, clearTip, tipEl] = useTip();
505
+ const scrollRef = react.useRef(null);
506
+
507
+ // 视窗:数据首日到今天,最少 30 天,最多 365 天
508
+ const model = react.useMemo(() => {
509
+ const map = new Map();
510
+ (days || []).forEach((d) => map.set(d.date, d));
511
+ const today = new Date();
512
+ today.setHours(0, 0, 0, 0);
513
+ let start = new Date(today);
514
+ start.setDate(start.getDate() - 29);
515
+ const keys = ((wnd && wnd.dateKeys) || []).filter(Boolean).sort();
516
+ if (keys.length) {
517
+ const first = new Date(keys[0] + "T00:00:00");
518
+ if (!Number.isNaN(first.getTime()) && first < start) start = first;
519
+ }
520
+ const floorDate = new Date(today);
521
+ floorDate.setDate(floorDate.getDate() - 364);
522
+ if (start < floorDate) start = floorDate;
523
+ // 首列对齐周日
524
+ const gridStart = new Date(start);
525
+ gridStart.setDate(gridStart.getDate() - gridStart.getDay());
526
+
527
+ const cumMap = new Map();
528
+ let cum = 0;
529
+ (days || []).forEach((d) => { cum += totalOf(d); cumMap.set(d.date, cum); });
530
+
531
+ const cells = [];
532
+ const weeks = new Map();
533
+ let maxDay = 0, maxCum = 0, peak = null;
534
+ for (let cur = new Date(gridStart); cur <= today; cur.setDate(cur.getDate() + 1)) {
535
+ const key = dateKey(cur);
536
+ const day = map.get(key);
537
+ const total = totalOf(day);
538
+ if (cumMap.has(key)) cum = cumMap.get(key);
539
+ const col = Math.round((cur - gridStart) / 86400000 / 7);
540
+ const cell = { key, col, row: cur.getDay(), total, cum, turns: turnsOf(day), month: cur.getMonth() + 1, dom: cur.getDate(), idx: cells.length };
541
+ cells.push(cell);
542
+ if (total > maxDay) { maxDay = total; peak = cell; }
543
+ if (cum > maxCum) maxCum = cum;
544
+ // 每周档:同一「周列」(col 相同) 即同一周,按列号分桶
545
+ const wk = col;
546
+ let bucket = weeks.get(wk);
547
+ if (!bucket) { bucket = { col, total: 0, first: key, last: key }; weeks.set(wk, bucket); }
548
+ bucket.total += total;
549
+ bucket.last = key;
550
+ if (cur.getDate() === 1) bucket.mark = cur.getMonth() + 1;
551
+ }
552
+ const weekCells = [...weeks.values()];
553
+ let maxWeek = 0;
554
+ weekCells.forEach((w) => { if (w.total > maxWeek) maxWeek = w.total; });
555
+ const cols = Math.max(1, ...cells.map((c) => c.col + 1), ...weekCells.map((w) => w.col + 1));
556
+ return { cells, cols, maxDay, maxCum, weekCells, maxWeek, peak, today };
557
+ }, [days, wnd]);
558
+
559
+ const isWeekly = mode === "weekly";
560
+ const isCum = mode === "cumulative";
561
+ const maxVal = isCum ? model.maxCum : model.maxDay;
562
+ const maxWk = model.maxWeek;
563
+
564
+ // 窗口 ≤ 70 天:单行点阵(窄面板里最省空间、点最大);
565
+ // 更长:7 行日历(列 = 周,行 = 星期),列多时横向滚动。
566
+ const singleRow = !isWeekly && model.cells.length <= 70;
567
+ const rows = (isWeekly || singleRow) ? 1 : 7;
568
+ const nCols = isWeekly ? model.weekCells.length : (singleRow ? model.cells.length : model.cols);
569
+ const PAD_L = rows === 1 ? 6 : 26, PAD_T = 26, PAD_B = 30;
570
+ const avail = Math.max(180, boxW - PAD_L - 6);
571
+ const cap = rows === 1 ? 26 : 17;
572
+ const floor = rows === 1 ? 9 : 11; // 单行档可压到 9px,优先一屏放下
573
+ const pitch = Math.max(floor, Math.min(cap, avail / Math.max(1, nCols)));
574
+ const gridW = nCols * pitch;
575
+ const W = Math.max(boxW, PAD_L + gridW + 6);
576
+ const H = PAD_T + rows * pitch + PAD_B;
577
+ const scrolls = W > boxW + 2;
578
+ // 单行档:点按序号排;7 行档:点按周列 × 星期行
579
+ const colOf = (c) => (isWeekly ? c.col : (singleRow ? c.idx : c.col));
580
+ const rowOf = (c) => (isWeekly || singleRow ? 0 : c.row);
581
+
582
+ react.useEffect(() => {
583
+ const el = scrollRef.current;
584
+ if (el && scrolls) requestAnimationFrame(() => { el.scrollLeft = el.scrollWidth; });
585
+ }, [mode, scrolls, model]);
586
+
587
+ // 交互命中格:每格一个透明矩形铺满 pitch×pitch。
588
+ // 圆点半径受 pitch 限制做不到 ≥7px 又不互相重叠,矩形可无缝铺满 = 无死角、零重叠
589
+ const hitCell = (col, row, key, title, body) => element("rect", {
590
+ key, className: "ust-hitcell",
591
+ x: PAD_L + col * pitch, y: PAD_T + row * pitch, width: pitch, height: pitch,
592
+ onMouseEnter: (e) => placeTip(e, element("div", null,
593
+ element("div", { className: "ust-tip-title" }, title),
594
+ element("div", { className: "ust-tip-body" }, body),
595
+ )),
596
+ onMouseLeave: clearTip,
597
+ });
598
+ // 半径上限 = 间距的 42%:相邻点直径不超过间距,永不重叠
599
+ const rMax = pitch * 0.42;
600
+ const rOf = (v, max) => (v > 0 && max > 0 ? Math.max(Math.min(2.4, rMax), Math.sqrt(v / max) * rMax) : 0);
601
+ const cx = (col) => PAD_L + col * pitch + pitch / 2;
602
+ const cy = (row) => PAD_T + row * pitch + pitch / 2;
603
+
604
+ const marks = [];
605
+ if (isWeekly) {
606
+ model.weekCells.forEach((w, i) => {
607
+ const x = cx(i), y = cy(0);
608
+ const title = fmtDayLabel(w.first) + " 至 " + fmtDayLabel(w.last);
609
+ if (!(w.total > 0)) {
610
+ marks.push(element("circle", { key: "q" + i, className: "ust-dot-quiet", cx: x, cy: y, r: 1.4 }));
611
+ } else {
612
+ marks.push(element("circle", { key: "w" + i, className: qF(qLevel(w.total, maxWk)), cx: x, cy: y, r: rOf(w.total, maxWk) }));
613
+ }
614
+ marks.push(hitCell(i, 0, "h" + i, title, w.total > 0 ? fmtFull(w.total) + " tokens" : "当周无用量"));
615
+ // 日期标签放在点带下方,不与点重叠
616
+ if (i % 4 === 0) marks.push(element("text", { key: "wl" + i, className: "ust-axis", x: x, y: cy(0) + rMax + 13, textAnchor: "middle" }, fmtDayLabel(w.first).replace(" ", "")));
617
+ });
618
+ } else {
619
+ model.cells.forEach((c, i) => {
620
+ const x = cx(colOf(c)), y = cy(rowOf(c));
621
+ const v = isCum ? c.cum : c.total;
622
+ if (v > 0) {
623
+ marks.push(element("circle", { key: "d" + i, className: qF(qLevel(v, maxVal)), cx: x, cy: y, r: rOf(v, maxVal) }));
624
+ } else {
625
+ marks.push(element("circle", { key: "q" + i, className: "ust-dot-quiet", cx: x, cy: y, r: 1.4 }));
626
+ }
627
+ // 视觉点不绑事件,交互统一交给命中格
628
+ const body = isCum
629
+ ? "累计 " + fmtFull(c.cum) + " tokens"
630
+ : (v > 0 ? fmtFull(c.total) + " tokens · " + c.turns + " 轮" : "当日无用量");
631
+ marks.push(hitCell(colOf(c), rowOf(c), "h" + i, fmtDayLabel(c.key), body));
632
+ });
633
+ }
634
+
635
+ // 星期轴(周日在上)
636
+ const wdAxis = (isWeekly || singleRow) ? null : WD.map((lab, k) =>
637
+ k % 2 === 0 ? element("text", { key: "wd" + k, className: "ust-axis", x: PAD_L - 8, y: cy(k) + 4, textAnchor: "end" }, lab) : null);
638
+
639
+ // 月份标(该列含 1 号)
640
+ const marks2 = (isWeekly ? model.weekCells : model.cells)
641
+ .filter((c) => (isWeekly ? c.mark : (singleRow ? (c.dom === 1 || c.idx % 5 === 0) : c.dom === 1)))
642
+ .map((c, i) => {
643
+ const x = cx(isWeekly ? c.col : (singleRow ? c.idx : c.col));
644
+ return element("g", { key: "m" + i },
645
+ element("text", { className: "ust-axis", x: x, y: PAD_T - 9, textAnchor: "middle" }, singleRow ? (c.dom === 1 ? c.month + "月" : c.dom + "日") : (c.mark || c.month) + "月"),
646
+ element("line", { className: "ust-floor-tick", x1: x, y1: PAD_T - 5, x2: x, y2: PAD_T - 2 }),
647
+ );
648
+ });
649
+
650
+ // 峰值标注(仅每日档,且不与月份标冲突)
651
+ // 峰值标注:默认放点的下方(上方是日期轴);贴底时改放上方
652
+ const peakMark = (!isWeekly && !isCum && model.peak && model.peak.total > 0)
653
+ ? (() => {
654
+ const x = cx(colOf(model.peak)), y = cy(rowOf(model.peak)), r = rOf(model.peak.total, maxVal);
655
+ const below = y + r + 16 <= H - 4;
656
+ const ty = below ? y + r + 13 : y - r - 8;
657
+ return element("g", null,
658
+ element("circle", { className: "ust-peak-ring", cx: x, cy: y, r: r + 4 }),
659
+ element("text", {
660
+ className: "ust-callout", x: x, y: ty,
661
+ textAnchor: x > W - 60 ? "end" : x < 60 ? "start" : "middle",
662
+ }, "峰值 " + fmtLarge(model.peak.total)),
663
+ );
664
+ })()
665
+ : null;
666
+
667
+ const caption = isWeekly
668
+ ? "一点 = 一周,点面积 = 当周 Token,小点 = 当周无用量"
669
+ : isCum
670
+ ? "一点 = 一天,点面积 = 累计 Token,小点 = 当日无用量"
671
+ : "一点 = 一天,点面积 = 当日 Token,小点 = 当日无用量";
672
+
673
+ return element("div", { className: "ust-block" },
674
+ element("div", { className: "ust-block-head" },
675
+ element("div", null,
676
+ element("h2", { className: "ust-h2" }, "活跃分布"),
677
+ element("div", { className: "ust-sub" }, caption),
678
+ element("div", { className: "ust-scale" },
679
+ element("span", { className: "ust-scale-lab" }, "少"),
680
+ element("span", { className: "ust-scale-sw ust-qb1" }),
681
+ element("span", { className: "ust-scale-sw ust-qb2" }),
682
+ element("span", { className: "ust-scale-sw ust-qb3" }),
683
+ element("span", { className: "ust-scale-lab" }, "多"),
684
+ ),
685
+ ),
686
+ element("div", { className: "ust-tabs", role: "tablist" },
687
+ HEAT_MODES.map((m) => element("span", {
688
+ key: m.id, role: "tab", "aria-selected": mode === m.id,
689
+ className: "ust-tab" + (mode === m.id ? " active" : ""),
690
+ onClick: () => setMode(m.id),
691
+ }, m.label)),
692
+ ),
693
+ ),
694
+ element("div", { ref: boxRef },
695
+ element("div", { className: "ust-plot" + (scrolls ? " is-scroll" : ""), ref: scrollRef },
696
+ boxW > 0 ? element("svg", { width: W, height: H, viewBox: "0 0 " + W + " " + H, role: "img", "aria-label": "活跃分布热力图" },
697
+ wdAxis, marks2, marks, peakMark,
698
+ ) : null,
699
+ ),
700
+ ),
701
+ scrolls ? element("div", { className: "ust-src", style: { marginTop: 8 } }, "← 左右滑动查看完整时间轴") : null,
702
+ tipEl,
703
+ );
704
+ }
705
+
706
+ // ---------- 2) 用量趋势 ----------
707
+ // 一条线 = 一个模型,圆点 = 当天用量,峰值直接标数
708
+ function Trend({ days, window: wnd }) {
709
+ const [boxRef, boxW] = useMeasure();
710
+ const [hidden, setHidden] = react.useState({});
711
+ const [hover, setHover] = react.useState(null);
712
+ const [placeTip, clearTip, tipEl] = useTip();
713
+ const scrollRef = react.useRef(null);
714
+
715
+ const isHour = (wnd.dateKeys || []).length === 1;
716
+ const singleDate = isHour ? wnd.dateKeys[0] : null;
717
+ const dayList = fillDays(days, wnd);
718
+ const singleDay = dayList[0] || null;
719
+ const hourMap = new Map((isHour && singleDay && Array.isArray(singleDay.hours) ? singleDay.hours : []).map((h) => [h.hour, h]));
720
+ const xData = isHour
721
+ ? Array.from({ length: 24 }, (_, h) => hourMap.get(h) || { hour: h, total: 0, byModel: [] })
722
+ : dayList;
723
+ const xLabel = (d) => (isHour ? String(d.hour).padStart(2, "0") + ":00" : fmtDayLabel(d.date));
724
+
725
+ // 窄面板里线太多会糊成一团:按宽度决定显示几条
726
+ const series = react.useMemo(() => foldModels(modelTotalsIn(days, wnd), (boxW || 400) < 520 ? 4 : 5), [days, wnd, boxW]);
727
+ const values = react.useMemo(() => xData.map((d) => {
728
+ const by = d.byModel || [];
729
+ const vals = series.map((s) => {
730
+ if (s.children) return s.children.reduce((acc, c) => acc + ((by.find((x) => x.model === c.model) || {}).total || 0), 0);
731
+ return (by.find((x) => x.model === s.model) || {}).total || 0;
732
+ });
733
+ return { total: vals.reduce((a, b) => a + b, 0), vals };
734
+ }), [xData, series]);
735
+
736
+ const visible = series.map((s) => !hidden[s.model]);
737
+ const visTotals = values.map((v) => v.vals.reduce((a, b, i) => a + (visible[i] ? b : 0), 0));
738
+ const peakTotal = Math.max(1, ...visTotals);
739
+
740
+ const N = xData.length || 1;
741
+ // 极窄容器压缩左右留白,保证 svg 不超容器宽(避免横向溢出)
742
+ const narrow = (boxW || 400) < 380;
743
+ const PAD_L = narrow ? 30 : 40, PAD_R = narrow ? 6 : 10, PAD_T = 20, PAD_B = 34;
744
+ const avail = Math.max(120, boxW - PAD_L - PAD_R);
745
+ const step = N > 1 ? avail / (N - 1) : 0;
746
+ // 单点太挤时横向滚动,保证点间距不小于 9px
747
+ const minStep = isHour ? 14 : (narrow ? 7.5 : 9);
748
+ const needScroll = step > 0 && step < minStep;
749
+ let plotW = needScroll ? (N - 1) * minStep : avail;
750
+ // 不滚动时必须严丝合缝落在容器内(避免 1–2px 溢出触发横向滚动条)
751
+ if (!needScroll) plotW = Math.max(80, Math.min(plotW, (boxW || 400) - PAD_L - PAD_R));
752
+ const W = PAD_L + plotW + PAD_R;
753
+ const H = (boxW || 400) < 520 ? 168 : 200;
754
+ const baseY = H - PAD_B;
755
+ const topY = PAD_T;
756
+ const x = (i) => PAD_L + (needScroll ? i * minStep : i * (plotW / Math.max(1, N - 1)));
757
+ const y = (v) => baseY - (v / peakTotal) * (baseY - topY);
758
+
759
+ // Y 轴:0 与峰值两档刻度,避免密集小字
760
+ const grid = [
761
+ element("line", { key: "g0", className: "ust-grid", x1: PAD_L, y1: topY, x2: PAD_L + plotW, y2: topY }),
762
+ element("line", { key: "g1", className: "ust-baseline", x1: PAD_L, y1: baseY, x2: PAD_L + plotW, y2: baseY }),
763
+ element("text", { key: "t0", className: "ust-axis", x: PAD_L - 7, y: topY + 4, textAnchor: "end" }, fmtLarge(peakTotal)),
764
+ element("text", { key: "t1", className: "ust-axis", x: PAD_L - 7, y: baseY + 4, textAnchor: "end" }, "0"),
765
+ ];
766
+
767
+ // X 轴标签:首、尾、中间均匀 3 个,共 5 个
768
+ const labelEvery = narrow ? Math.max(1, Math.ceil(N / 3)) : Math.max(1, Math.ceil(N / 5));
769
+ const labelIdx = N <= 1 ? [0] : (() => {
770
+ const set = new Set([0, N - 1]);
771
+ for (let i = labelEvery; i < N - 1; i += labelEvery) set.add(i);
772
+ return [...set].sort((a, b) => a - b);
773
+ })();
774
+ const xAxis = labelIdx.map((i) => element("text", {
775
+ key: "x" + i, className: "ust-axis", x: x(i), y: baseY + 20, textAnchor: i === 0 ? "start" : i === N - 1 ? "end" : "middle",
776
+ }, xLabel(xData[i])));
777
+
778
+ // 峰值点
779
+ let peakIdx = 0, peakVal = -1;
780
+ visTotals.forEach((v, i) => { if (v > peakVal) { peakVal = v; peakIdx = i; } });
781
+
782
+ const lines = series.map((s, k) => {
783
+ if (!visible[k]) return null;
784
+ const pts = values.map((v, i) => x(i) + " " + y(v.vals[k]));
785
+ const dotR = (boxW || 400) < 520 ? 3 : 2.6;
786
+ const dots = values.map((v, i) => (v.vals[k] > 0
787
+ ? element("circle", { key: "d" + i, className: cF(s.rank) + " ust-dot", cx: x(i), cy: y(v.vals[k]), r: dotR })
788
+ : null));
789
+ return element("g", { key: s.model },
790
+ element("path", { className: cS(s.rank) + " ust-hair", d: "M" + pts.join(" L ") }),
791
+ dots,
792
+ );
793
+ });
794
+
795
+ // 峰值标注:优先放点下方(避开顶部 Y 轴刻度),贴底则放上方
796
+ const peakMark = peakVal > 0 ? (() => {
797
+ const py = y(peakVal);
798
+ const below = py + 20 <= baseY - 2;
799
+ return element("g", null,
800
+ element("circle", { className: "ust-peak-ring", cx: x(peakIdx), cy: py, r: 6 }),
801
+ element("text", {
802
+ className: "ust-callout", x: x(peakIdx), y: below ? py + 20 : py - 11,
803
+ textAnchor: peakIdx === 0 ? "start" : peakIdx === N - 1 ? "end" : "middle",
804
+ }, fmtLarge(peakVal)),
805
+ );
806
+ })() : null;
807
+
808
+ const onMove = (e) => {
809
+ if (!boxW) return;
810
+ const rect = e.currentTarget.getBoundingClientRect();
811
+ const sc = scrollRef.current ? scrollRef.current.scrollLeft : 0;
812
+ const local = e.clientX - rect.left + sc;
813
+ const i = needScroll
814
+ ? Math.max(0, Math.min(N - 1, Math.round((local - PAD_L) / minStep)))
815
+ : Math.max(0, Math.min(N - 1, Math.round(((local - PAD_L) / plotW) * (N - 1))));
816
+ setHover(i);
817
+ const rows = series.map((s, k) => visible[k]
818
+ ? element("div", { key: s.model, className: "ust-tip-row" },
819
+ element("span", { className: "ust-tip-key" },
820
+ element("span", { className: "ust-tip-swatch", style: { background: cVar(s.rank) } }),
821
+ element("span", null, s.model)),
822
+ element("span", { className: "ust-tip-val" }, fmtFull(values[i].vals[k])))
823
+ : null);
824
+ placeTip(e, element("div", null,
825
+ element("div", { className: "ust-tip-title" }, (isHour ? fmtDayLabel(singleDate) + " " : "") + xLabel(xData[i])),
826
+ element("div", { className: "ust-tip-body" }, fmtFull(visTotals[i]) + " tokens · " + fmtLarge(visTotals[i])),
827
+ rows,
828
+ ));
829
+ };
830
+
831
+ return element("div", { className: "ust-block" },
832
+ element("div", { className: "ust-block-head" },
833
+ element("div", null,
834
+ element("h2", { className: "ust-h2" }, isHour ? "按小时的用量分布" : "用量趋势"),
835
+ element("div", { className: "ust-sub" }, "一条线 = 一个模型,圆点 = 当期用量,颜色 = 模型"),
836
+ ),
837
+ element("div", { className: "ust-sub" }, wnd.label),
838
+ ),
839
+ element("div", { ref: boxRef },
840
+ element("div", { className: "ust-plot" + (needScroll ? " is-scroll" : ""), ref: scrollRef },
841
+ boxW > 0 ? element("svg", { width: W, height: H, viewBox: "0 0 " + W + " " + H, role: "img", "aria-label": "用量趋势折线图" },
842
+ grid, xAxis, lines, peakMark,
843
+ hover !== null && element("line", { className: "ust-cursor", x1: x(hover), y1: topY, x2: x(hover), y2: baseY }),
844
+ element("rect", { className: "ust-hit", x: 0, y: 0, width: W, height: H, onMouseMove: onMove, onMouseLeave: () => { setHover(null); clearTip(); } }),
845
+ ) : null,
846
+ ),
847
+ ),
848
+ element("div", { className: "ust-legend" },
849
+ series.map((s) => element("span", {
850
+ key: s.model,
851
+ className: "ust-legend-item",
852
+ style: { opacity: hidden[s.model] ? 0.4 : 1 },
853
+ onClick: () => setHidden((h) => ({ ...h, [s.model]: !h[s.model] })),
854
+ role: "button", tabIndex: 0,
855
+ },
856
+ element("span", { className: "ust-legend-dot " + cB(s.rank) }),
857
+ element("span", null, s.model),
858
+ )),
859
+ ),
860
+ tipEl,
861
+ );
862
+ }
863
+
864
+ // ---------- 3) 模型用量 ----------
865
+ // 一刻度 = 1%,段内刻度同色,段名与数值由下方列表承担(窄容器不放外圈标签)
866
+ function Donut({ days, window: wnd }) {
867
+ const [boxRef, boxW] = useMeasure();
868
+ const [active, setActive] = react.useState(null);
869
+ const [placeTip, clearTip, tipEl] = useTip();
870
+
871
+ const list = modelTotalsIn(days, wnd);
872
+ const sum = list.reduce((s, m) => s + m.total, 0);
873
+ const segs = foldModels(list, 5);
874
+ const TICKS = 100;
875
+ let used = 0;
876
+ const counts = segs.map((m) => {
877
+ const t = sum > 0 ? Math.round((m.total / sum) * TICKS) : 0;
878
+ used += t;
879
+ return t;
880
+ });
881
+ if (sum > 0 && counts.length) counts[0] += TICKS - used;
882
+
883
+ const SIZE = Math.max(140, Math.min(176, boxW || 168));
884
+ const CX = SIZE / 2, CY = SIZE / 2;
885
+ const R = SIZE / 2 - 20;
886
+ const C = SIZE / 2;
887
+
888
+ // 极坐标:0° 指向 12 点,顺时针
889
+ const pol = (r, deg) => {
890
+ const a = (deg - 90) * Math.PI / 180;
891
+ return [CX + r * Math.cos(a), CY + r * Math.sin(a)];
892
+ };
893
+ // 环形扇区:内外两段弧 + 两条径向边
894
+ const annulus = (d0, d1, r0, r1) => {
895
+ const [ax0, ay0] = pol(r0, d0), [ax1, ay1] = pol(r0, d1);
896
+ const [bx1, by1] = pol(r1, d1), [bx0, by0] = pol(r1, d0);
897
+ const big = d1 - d0 > 180 ? 1 : 0;
898
+ return "M" + ax0 + " " + ay0 + " A" + r0 + " " + r0 + " 0 " + big + " 1 " + ax1 + " " + ay1 +
899
+ " L" + bx1 + " " + by1 + " A" + r1 + " " + r1 + " 0 " + big + " 0 " + bx0 + " " + by0 + " Z";
900
+ };
901
+ const TIP_MAX = 15; // 刻度最长 9 + 6
902
+ const ticks = [];
903
+ const hitBands = [];
904
+ let acc = 0;
905
+ segs.forEach((s, si) => {
906
+ const n = counts[si];
907
+ for (let k = 0; k < n; k++) {
908
+ const idx = acc + k;
909
+ const a = (idx * 3.6 - 90) * Math.PI / 180;
910
+ const len = 9 + rnd(idx + 1, si + 2) * 6;
911
+ // 视觉刻度不绑事件:缝隙处会丢命中,统一交给下面的扇区
912
+ ticks.push(element("line", {
913
+ key: "t" + idx,
914
+ className: cS(s.rank) + " ust-tick",
915
+ x1: CX + R * Math.cos(a), y1: CY + R * Math.sin(a),
916
+ x2: CX + (R + len) * Math.cos(a), y2: CY + (R + len) * Math.sin(a),
917
+ }));
918
+ if (idx % 10 === 0) {
919
+ ticks.push(element("circle", { key: "m" + idx, className: "ust-tickmark", cx: CX + (R - 6) * Math.cos(a), cy: CY + (R - 6) * Math.sin(a), r: 1.2 }));
920
+ }
921
+ }
922
+ if (n > 0) {
923
+ // 命中扇区:从本段第一刻中心到末刻中心,左右各扩半格 = 整圈无缝
924
+ const tip = element("div", null,
925
+ element("div", { className: "ust-tip-title" }, s.model),
926
+ element("div", { className: "ust-tip-body" }, fmtFull(s.total) + " tokens · " + (sum > 0 ? (s.total / sum * 100).toFixed(1) : "0") + "%"),
927
+ s.children ? element("div", { className: "ust-tip-body" }, "含 " + s.children.map((c) => c.model).join("、")) : null,
928
+ );
929
+ const show = (e) => { setActive(s.model); placeTip(e, tip); };
930
+ hitBands.push(element("path", {
931
+ key: "hb" + si, className: "ust-hitband",
932
+ d: annulus((acc - 0.5) * 3.6, (acc + n - 0.5) * 3.6, R - 5, R + TIP_MAX + 6),
933
+ onMouseEnter: show, onMouseMove: show,
934
+ onMouseLeave: () => { setActive(null); clearTip(); },
935
+ }));
936
+ }
937
+ acc += n;
938
+ });
939
+
940
+ return element("div", { className: "ust-block" },
941
+ element("div", { className: "ust-block-head" },
942
+ element("div", null,
943
+ element("h2", { className: "ust-h2" }, "模型用量"),
944
+ element("div", { className: "ust-sub" }, "一刻度 = 1%,每 10 刻一枚锚点,颜色 = 模型"),
945
+ ),
946
+ element("div", { className: "ust-sub" }, wnd.label),
947
+ ),
948
+ element("div", { ref: boxRef },
949
+ element("div", { className: "ust-donut-wrap" },
950
+ element("div", { className: "ust-donut", style: { width: SIZE + "px", height: SIZE + "px" } },
951
+ element("svg", { viewBox: "0 0 " + SIZE + " " + SIZE, role: "img", "aria-label": "模型用量刻度环" }, ticks, hitBands),
952
+ element("div", { className: "ust-donut-center" },
953
+ element("div", { className: "num" }, fmtLarge(sum)),
954
+ element("div", { className: "lab" }, "TOKENS"),
955
+ ),
956
+ ),
957
+ element("ul", { className: "ust-models" },
958
+ segs.map((s) => element("li", {
959
+ key: s.model,
960
+ className: "ust-model",
961
+ style: { opacity: active && active !== s.model ? 0.45 : 1 },
962
+ onMouseEnter: () => setActive(s.model),
963
+ onMouseLeave: () => setActive(null),
964
+ },
965
+ element("span", { className: "ust-model-name" },
966
+ element("span", { className: "ust-model-dot " + cB(s.rank) }),
967
+ element("span", { title: s.children ? s.children.map((c) => c.model).join("、") : s.model }, s.model)),
968
+ element("span", { className: "ust-model-row" },
969
+ element("span", { className: "ust-model-sub" }, fmtLarge(s.total)),
970
+ element("span", { className: "ust-model-val" }, (sum > 0 ? Math.round((s.total / sum) * 100) : 0) + "%")),
971
+ )),
972
+ ),
973
+ ),
974
+ ),
975
+ tipEl,
976
+ );
977
+ }
978
+
979
+ // ---------- 主视图 ----------
980
+ function UsageStatsView() {
981
+ const [data, setData] = react.useState(null);
982
+ const [error, setError] = react.useState(null);
983
+ const [loading, setLoading] = react.useState(true);
984
+ const [range, setRange] = react.useState({ id: "last30" });
985
+ const [showCustom, setShowCustom] = react.useState(false);
986
+ const [from, setFrom] = react.useState("");
987
+ const [to, setTo] = react.useState("");
988
+
989
+ const load = react.useCallback(async (force) => {
990
+ setLoading(true);
991
+ try {
992
+ const res = await fetch("/api/usage-stats" + (force ? "?refresh=1" : ""), { credentials: "same-origin" });
993
+ if (!res.ok) throw new Error("HTTP " + res.status);
994
+ const json = await res.json();
995
+ if (!json || json.ok === false) throw new Error((json && json.error) || "响应异常");
996
+ setData(json);
997
+ setError(null);
998
+ } catch (e) {
999
+ setError(String((e && e.message) || e));
1000
+ }
1001
+ setLoading(false);
1002
+ }, []);
1003
+
1004
+ react.useEffect(() => { load(false); }, [load]);
1005
+
1006
+ const days = (data && data.days) || [];
1007
+ const hasData = days.length > 0;
1008
+ const wnd = react.useMemo(() => {
1009
+ if ((typeof range === "string" ? range : range.id) === "all") {
1010
+ return { id: "all", label: "全部", dateKeys: days.map((d) => d.date).sort() };
1011
+ }
1012
+ return resolveWindow(range);
1013
+ }, [range, days]);
1014
+
1015
+ if (loading && !data) {
1016
+ return element("div", { className: "ust-root" },
1017
+ element("div", { className: "ust-skeleton", style: { width: "40%" } }),
1018
+ element("div", { className: "ust-skeleton", style: { width: "100%", height: 96 } }),
1019
+ element("div", { className: "ust-placeholder" }, "统计加载中"),
1020
+ );
1021
+ }
1022
+ if (error && !data) {
1023
+ return element("div", { className: "ust-root" },
1024
+ element("div", { className: "ust-warn" }, "加载失败:" + error),
1025
+ element("div", { className: "ust-actions", style: { marginTop: 16 } },
1026
+ element("button", { className: "ust-btn", onClick: () => load(true) }, "重试")),
1027
+ );
1028
+ }
1029
+
1030
+ const stats = calcStats(days, wnd);
1031
+ const winList = modelTotalsIn(days, wnd);
1032
+ const winTop = winList.length ? [...winList].sort((a, b) => b.total - a.total)[0] : null;
1033
+ const topName = winTop ? winTop.model : ((data.topModel && data.topModel.name) || "无");
1034
+ const topSub = winTop && stats.total > 0
1035
+ ? "占本窗口 " + Math.round((winTop.total / stats.total) * 100) + "%"
1036
+ : "";
1037
+ const avgPerActive = stats.activeDays > 0 ? stats.total / stats.activeDays : 0;
1038
+ const invalid = !!(from && to && from > to);
1039
+
1040
+ return element("div", { className: "ust-root" },
1041
+ element(SummaryMetrics, { summary: data.summary }),
1042
+ element("div", { className: "ust-header" },
1043
+ element("h1", { className: "ust-page-title" }, "使用统计"),
1044
+ element("div", { className: "ust-rangebox" },
1045
+ element("div", { className: "ust-tabs", role: "tablist" },
1046
+ RANGES.map((r) => element("span", {
1047
+ key: r.id, role: "tab", "aria-selected": range.id === r.id,
1048
+ className: "ust-tab" + (range.id === r.id ? " active" : ""),
1049
+ onClick: () => { setRange({ id: r.id }); setShowCustom(false); },
1050
+ }, r.label)),
1051
+ element("span", {
1052
+ role: "tab", "aria-selected": range.id === "custom",
1053
+ className: "ust-tab" + (range.id === "custom" ? " active" : ""),
1054
+ onClick: () => setShowCustom((v) => !v),
1055
+ }, "自定义"),
1056
+ ),
1057
+ showCustom ? element("div", { className: "ust-custom" },
1058
+ element("div", { className: "ust-custom-row" },
1059
+ element("input", { className: "ust-date", type: "date", value: from, max: to || undefined, "aria-label": "开始日期", onChange: (e) => setFrom(e.target.value) }),
1060
+ element("span", { className: "ust-custom-sep" }, "至"),
1061
+ element("input", { className: "ust-date", type: "date", value: to, min: from || undefined, "aria-label": "结束日期", onChange: (e) => setTo(e.target.value) }),
1062
+ ),
1063
+ invalid ? element("div", { className: "ust-warn" }, "结束日期不能早于开始日期") : null,
1064
+ element("div", { className: "ust-custom-actions" },
1065
+ element("button", { className: "ust-btn ust-btn--ghost", onClick: () => { setShowCustom(false); setFrom(""); setTo(""); } }, "取消"),
1066
+ element("button", {
1067
+ className: "ust-btn ust-btn--primary",
1068
+ disabled: !(from && to) || invalid,
1069
+ onClick: () => { setRange({ id: "custom", from, to }); setShowCustom(false); },
1070
+ }, "应用"),
1071
+ ),
1072
+ ) : null,
1073
+ ),
1074
+ ),
1075
+ element("div", { className: "ust-metrics" },
1076
+ element(Metric, { icon: element(IconToken), label: "窗口 Token", value: fmtLarge(stats.total), sub: fmtFull(stats.total) }),
1077
+ element(Metric, { icon: element(IconCalendar), label: "活跃天数", value: String(stats.activeDays) + " 天", sub: "有用量的天" }),
1078
+ element(Metric, { icon: element(IconTrend), label: "日均", value: fmtLarge(avgPerActive), sub: "按活跃天平均" }),
1079
+ element(Metric, { icon: element(IconChat), label: "会话累计", value: fmtFull((data && data.sessionCount) || 0), sub: "全部历史" }),
1080
+ element(Metric, { icon: element(IconMail), label: "消息累计", value: fmtFull((data && data.messageCount) || 0), sub: "全部历史" }),
1081
+ element(Metric, { icon: element(IconBolt), label: "最常用模型", value: topName, sub: topSub, text: true }),
1082
+ ),
1083
+ wnd.dateKeys.length === 0 ? element("div", { className: "ust-warn" }, "当前时间范围没有数据") : null,
1084
+ data.warnings && data.warnings.length > 0 ? element("div", { className: "ust-warn" }, data.warnings.join(";")) : null,
1085
+ hasData ? element(Heatmap, { days, window: wnd }) : null,
1086
+ hasData && wnd.dateKeys.length > 0 ? element(Trend, { days, window: wnd }) : null,
1087
+ hasData && wnd.dateKeys.length > 0 ? element(Donut, { days, window: wnd }) : null,
1088
+ element("div", { className: "ust-footer" },
1089
+ element("div", { className: "ust-meta" },
1090
+ loading ? "正在重新扫描全部会话…" : (data.generatedAt ? "数据更新于 " + fmtClock(data.generatedAt) : ""),
1091
+ ),
1092
+ element("button", {
1093
+ className: "ust-btn", disabled: loading, onClick: () => load(true),
1094
+ title: "重新扫描本机全部会话记录",
1095
+ },
1096
+ element("span", { className: "ust-btn-ico" + (loading ? " is-spin" : "") }, element(IconRefresh)),
1097
+ loading ? "刷新中" : "刷新",
1098
+ ),
1099
+ ),
1100
+ );
1101
+ }
1102
+
1103
+ function apply(ctx) {
1104
+ ctx.effect(() => {
1105
+ const style = document.createElement("style");
1106
+ style.dataset.plugin = "dsh-usage-stats";
1107
+ style.textContent = CSS;
1108
+ document.head.appendChild(style);
1109
+ return () => { style.remove(); };
1110
+ });
1111
+ ctx.slots.inject("settings.section", () => ctx.slots.register({
1112
+ name: "settings.section",
1113
+ id: "usage-stats",
1114
+ order: 80,
1115
+ label: () => "使用统计",
1116
+ inject: () => ({}),
1117
+ }, UsageStatsView));
1118
+ }
1119
+
1120
+ exports.apply = apply;
1121
+ exports.inject = inject;
1122
+ return module.exports;
1123
+ }
1124
+ });