dsh-dock 0.11.2 → 0.11.3

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.
Files changed (38) hide show
  1. package/LICENSE +18 -18
  2. package/README.md +242 -396
  3. package/client.js +173 -9
  4. package/cordis.patch.yml +2 -2
  5. package/features/animation/host.js +119 -119
  6. package/features/animation/view.jsx +1662 -1662
  7. package/features/balance/host.js +494 -494
  8. package/features/balance/view.js +351 -351
  9. package/features/games/games/breakout.jsx +227 -227
  10. package/features/games/games/gomoku.jsx +244 -244
  11. package/features/games/games/racer.jsx +169 -169
  12. package/features/games/games/shared.jsx +118 -118
  13. package/features/games/games/snake.jsx +144 -144
  14. package/features/games/games/sokoban.jsx +180 -180
  15. package/features/games/games/tank.jsx +128 -128
  16. package/features/games/games/tetris.jsx +308 -308
  17. package/features/games/games/xiangqi.jsx +277 -277
  18. package/features/games/tank-core.js +152 -152
  19. package/features/games/view.jsx +639 -639
  20. package/features/games/xiangqi-core.js +299 -299
  21. package/features/heartbeat/view.js +35 -35
  22. package/features/mobile-relay/gateway.js +472 -472
  23. package/features/mobile-relay/host.js +570 -570
  24. package/features/mobile-relay/view.jsx +300 -300
  25. package/features/modelconfig/host.js +406 -406
  26. package/features/modelconfig/view.js +490 -490
  27. package/features/notify/host.js +374 -374
  28. package/features/notify/view.jsx +751 -751
  29. package/features/theme/view.js +61 -61
  30. package/features/tokenlog/host.js +882 -883
  31. package/features/tokenlog/view.jsx +1117 -932
  32. package/features/visionproxy/host.js +315 -315
  33. package/index.js +195 -195
  34. package/package.json +66 -64
  35. package/src/client.jsx +677 -677
  36. package/src/host-core.js +207 -207
  37. package/src/shared.js +192 -192
  38. package/src/task-track.js +354 -359
@@ -1,932 +1,1117 @@
1
- // dsh-dock · 功能模块【用量记录】· 客户端视图(v0.4.0,移植自 @wycto/dsh-token-usage client v6,适配 dock 嵌入)
2
- //
3
- // 嵌入功能坞面板的统计视图(无独立 overlay 外壳;全屏用 dock 弹窗自带的「最大化」):
4
- // - 秒级时间范围查询 + 会话ID/提供商/模型(联动)/状态/推理强度 筛选,条件本地暂存
5
- // - 9 张 KPI 卡 + 分组统计表 + 明细表(点击表头排序、会话ID点击即筛选、100 行/页上下双分页)
6
- // - 状态列显示 HTTP 状态码徽章,行内【查看详情】弹窗展示完整信息;CSV 导出
7
- // - 独立的「单价设置」子弹窗:按模型配置单价(支持多段分时价)并持久化,费用按自填单价重算
8
- // - 挂载即扫描历史+按暂存条件查询;挂载期间每 5s 静默自动刷新
9
- // Host 通信:fetch('/dsh-dock/tokenlog/<method>')(见 features/tokenlog/host.js)。
10
- import react, { useState, useEffect, useCallback, useMemo, useRef } from "react";
11
- import { openPanel } from "../../src/shared.js";
12
-
13
- // ---------- Host RPC 桥接 ----------
14
- function rpcCall(method, args) {
15
- return fetch("/dsh-dock/tokenlog/" + method, {
16
- method: "POST",
17
- headers: { "content-type": "application/json" },
18
- body: JSON.stringify(args === undefined ? {} : args),
19
- })
20
- .then(async (res) => {
21
- const data = await res.json().catch(() => ({}));
22
- if (res.ok && data && data.ok === true) return data.data;
23
- // 旧宿主进程没有用量路由(405/404):给出可操作提示而非裸状态码
24
- if (res.status === 405 || res.status === 404) {
25
- throw new Error("宿主进程是旧版本(没有用量记录路由),重启 dsh web 后重试");
26
- }
27
- if (data && data.ok === false) throw new Error((data.error && data.error.message) || ("HTTP " + res.status));
28
- throw new Error("HTTP " + res.status + (data && data.error && data.error.message ? ": " + data.error.message : ""));
29
- });
30
- }
31
-
32
- // ---------- 格式化 ----------
33
- function fmtNum(n) { return (Number(n) || 0).toLocaleString("en-US"); }
34
- function fmtCompact(n) {
35
- n = Number(n) || 0;
36
- if (n >= 1e9) return (n / 1e9).toFixed(2) + "B";
37
- if (n >= 1e6) return (n / 1e6).toFixed(2) + "M";
38
- if (n >= 1e3) return (n / 1e3).toFixed(1) + "K";
39
- return String(Math.round(n));
40
- }
41
- // 金额记录在 RPC 中以 USD 传输;界面统一按人民币显示。
42
- function fmtCostCny(usd, rate) {
43
- const v = (Number(usd) || 0) * (Number(rate) > 0 ? Number(rate) : 7.2);
44
- if (v >= 10000) return "¥" + (v / 10000).toFixed(2) + "万";
45
- if (v >= 100) return "¥" + v.toFixed(0);
46
- return "¥" + v.toFixed(2);
47
- }
48
- function fmtDuration(ms) {
49
- if (ms === null || ms === undefined || isNaN(ms)) return "—";
50
- if (ms < 1000) return Math.round(ms) + "ms";
51
- if (ms < 60000) return (ms / 1000).toFixed(1) + "s";
52
- return Math.floor(ms / 60000) + "m" + Math.round((ms % 60000) / 1000) + "s";
53
- }
54
- function fmtTime(ts) {
55
- if (!ts) return "";
56
- const d = new Date(ts);
57
- const p = (x) => String(x).padStart(2, "0");
58
- return d.getFullYear() + "-" + p(d.getMonth() + 1) + "-" + p(d.getDate()) + " " + p(d.getHours()) + ":" + p(d.getMinutes()) + ":" + p(d.getSeconds());
59
- }
60
-
61
- // ---------- 筛选条件暂存(本地) ----------
62
- // 打开视图时恢复上次保存的条件; 从未保存过则时间范围不选(显示全部记录)。
63
- const STORE_KEY = "dsh-dock/tokenlog/filters/v1";
64
- const FILTER_FIELDS = ["fromStr", "toStr", "provider", "model", "status", "effort", "sessionId", "dim"];
65
- function loadSavedFilters() {
66
- try {
67
- if (typeof localStorage === "undefined") return null;
68
- const raw = localStorage.getItem(STORE_KEY);
69
- if (!raw) return null;
70
- const obj = JSON.parse(raw);
71
- if (!obj || typeof obj !== "object") return null;
72
- const out = {};
73
- for (const k of FILTER_FIELDS) out[k] = typeof obj[k] === "string" ? obj[k] : "";
74
- return out;
75
- } catch (e) { return null; }
76
- }
77
- function saveFilters(f) {
78
- try {
79
- if (typeof localStorage === "undefined") return;
80
- localStorage.setItem(STORE_KEY, JSON.stringify(f));
81
- } catch (e) { /* localStorage 不可用时静默 */ }
82
- }
83
- function shortId(sid) {
84
- if (!sid) return "";
85
- if (sid.length <= 16) return sid;
86
- return sid.slice(0, 8) + "…" + sid.slice(-6);
87
- }
88
- function statusInfo(r) {
89
- if (r.status === "completed") return { code: 200, label: "200", cls: "ok", title: "成功" };
90
- if (r.status === "max-tokens") return { code: 200, label: "200", cls: "warn", title: "完成(达到输出上限)" };
91
- if (r.status === "error") return { code: r.statusCode || 500, label: String(r.statusCode || 500), cls: "err", title: r.errorMsg || r.errorCode || "调用失败" };
92
- if (r.status === "aborted") return { code: 499, label: "499", cls: "warn", title: "已取消" };
93
- if (r.status === "blocked") return { code: 403, label: "403", cls: "warn", title: "已阻止" };
94
- if (r.status === "interrupted") return { code: 500, label: "500", cls: "warn", title: "中断" };
95
- return { code: 0, label: "…", cls: "pend", title: "进行中" };
96
- }
97
-
98
- // ---------- 样式(dtok- 前缀,dock 面板内自适应:宽度铺满、表格横向滚动) ----------
99
- const css = `
100
- .dtok-root{display:flex;flex-direction:column;gap:8px;width:100%;min-width:0;}
101
- .dtok-status{display:flex;align-items:center;gap:10px;font-size:12px;color:var(--dsw-alias-label-secondary);flex-wrap:wrap;}
102
- .dtok-status .count{color:var(--dsw-alias-label-primary);}
103
- .dtok-filter{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:8px 10px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1);}
104
- .dtok-filter label{font-size:12px;color:var(--dsw-alias-label-secondary);white-space:nowrap;flex:none;}
105
- .dtok-input,.dtok-select{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:4px 8px;font-size:12px;font-family:inherit;max-width:170px;}
106
- .dtok-input[type="datetime-local"]{width:158px;}
107
- .dtok-btn{cursor:pointer;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);padding:4px 12px;font-size:12px;font-family:inherit;flex:none;}
108
- .dtok-btn:hover{background:var(--dsw-alias-interactive-bg-hover);}
109
- .dtok-btn[disabled]{opacity:.5;cursor:default;}
110
- .dtok-btn.primary{background:var(--dk-accent);border-color:var(--dk-accent);color:#fff;}
111
- .dtok-btn.primary:hover{filter:brightness(1.1);}
112
- .dtok-body{display:flex;flex-direction:column;gap:8px;min-width:0;}
113
- .dtok-cards{display:flex;gap:8px;flex-wrap:wrap;}
114
- .dtok-card{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:8px 14px;min-width:112px;}
115
- .dtok-card .v{font-size:18px;font-weight:700;color:var(--dsw-alias-label-primary);}
116
- .dtok-card .l{font-size:11px;color:var(--dsw-alias-label-secondary);margin-top:2px;}
117
- .dtok-section-title{font-size:13px;font-weight:600;margin:6px 0 0;color:var(--dsw-alias-label-primary);}
118
- .dtok-table-wrap{overflow:auto;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;max-width:100%;}
119
- .dtok-table{border-collapse:collapse;width:100%;font-size:12px;white-space:nowrap;color:var(--dsw-alias-label-primary);}
120
- .dtok-table th{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);text-align:left;padding:7px 10px;position:sticky;top:0;z-index:1;border-bottom:1px solid var(--dsw-alias-border-l1);font-weight:600;user-select:none;cursor:pointer;}
121
- .dtok-table th:hover{color:var(--dsw-alias-label-primary);}
122
- .dtok-table td{padding:6px 10px;border-bottom:1px solid var(--dk-tdim);}
123
- .dtok-table tr:hover td{background:var(--dk-hover-tint);}
124
- .dtok-empty{text-align:center;color:var(--dsw-alias-label-secondary);padding:32px 0;font-size:13px;}
125
- .dtok-sid{color:var(--dk-accent);cursor:pointer;text-decoration:underline dotted;}
126
- .dtok-sid:hover{filter:brightness(1.25);}
127
- .dtok-code{font-weight:700;font-family:ui-monospace,monospace;}
128
- .dtok-code.ok{color:var(--dsw-alias-state-success-primary,#4ade80);}
129
- .dtok-code.warn{color:var(--dk-warn);}
130
- .dtok-code.err{color:var(--dsw-alias-state-error-primary,#f87171);}
131
- .dtok-code.pend{color:var(--dsw-alias-label-tertiary,#94a3b8);}
132
- .dtok-detail-link{color:var(--dk-accent);cursor:pointer;font-size:11px;}
133
- .dtok-detail-link:hover{text-decoration:underline;}
134
- .dtok-detail{position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;}
135
- .dtok-detail-card{background:var(--dsw-alias-bg-layer-2,#1c212b);border:1px solid var(--dsw-alias-border-l2,#3a4150);border-radius:12px;padding:20px 24px;max-width:640px;width:92%;max-height:80vh;overflow:auto;color:var(--dsw-alias-label-primary,#e8eaf0);}
136
- .dtok-detail-card h3{margin:0 0 12px;font-size:15px;}
137
- .dtok-detail-row{display:flex;gap:8px;padding:4px 0;font-size:12px;border-bottom:1px solid var(--dk-tdim);}
138
- .dtok-detail-row .k{color:var(--dsw-alias-label-secondary,#9aa3b5);min-width:110px;flex-shrink:0;}
139
- .dtok-detail-row .v{word-break:break-all;}
140
- .dtok-sort-mark{opacity:0.6;margin-left:3px;}
141
- .dtok-pager{display:flex;gap:8px;align-items:center;font-size:12px;color:var(--dsw-alias-label-secondary);}
142
- .dtok-pager.top{margin:0;}
143
- .dtok-pager.bottom{margin:0;}
144
- .dtok-err{color:var(--dsw-alias-state-error-primary,#ff7a7a);font-size:12px;}
145
- /* ---- 单价设置(子弹窗) ---- */
146
- .dtok-pm-backdrop{position:fixed;inset:0;z-index:2100;background:rgba(15,17,21,.5);backdrop-filter:blur(3px);display:flex;align-items:center;justify-content:center;}
147
- .dtok-pm{box-sizing:border-box;width:min(980px,calc(100vw - 32px));height:min(760px,calc(100vh - 32px));display:flex;flex-direction:column;border-radius:14px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2,#1c212b);color:var(--dsw-alias-label-primary);box-shadow:0 20px 64px rgb(0 0 0 / .4);overflow:hidden;}
148
- .dtok-pm.max{border-radius:10px;}
149
- .dtok-pm-head{display:flex;align-items:center;gap:10px;padding:10px 12px;border-bottom:1px solid var(--dsw-alias-border-l1);cursor:move;user-select:none;flex:none;background:var(--dsw-alias-bg-layer-1);}
150
- .dtok-pm.max .dtok-pm-head{cursor:default;}
151
- .dtok-pm-head b{font-size:14px;}
152
- .dtok-pm-sub{font-size:11px;color:var(--dsw-alias-label-secondary);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
153
- .dtok-pm-ctrls{display:flex;gap:6px;flex:none;}
154
- .dtok-pm-body{flex:1;min-height:0;overflow-y:auto;padding:12px 14px;}
155
- .dtok-pm-resize{position:absolute;right:0;bottom:0;width:16px;height:16px;cursor:nwse-resize;touch-action:none;}
156
- .dtok-pm{position:relative;}
157
- /* ---- 单价设置(表单内容) ---- */
158
- .dtok-price{display:flex;flex-direction:column;gap:12px;}
159
- .dtok-price-grid{display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;}
160
- .dtok-price-field{display:flex;flex-direction:column;gap:3px;}
161
- .dtok-price-field label{font-size:11px;color:var(--dsw-alias-label-secondary);}
162
- .dtok-price-num{width:92px;}
163
- .dtok-price input[type="text"],.dtok-price-num{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:4px 6px;font-size:12px;font-family:inherit;}
164
- .dtok-price .dtok-btn.tiny{padding:2px 8px;font-size:11px;}
165
- .dtok-price-msg{font-size:12px;}
166
- .dtok-price-msg.ok{color:var(--dsw-alias-state-success-primary,#4ade80);}
167
- .dtok-price-msg.err{color:var(--dsw-alias-state-error-primary,#ff7a7a);}
168
- .dtok-price-hint{font-size:11px;color:var(--dsw-alias-label-secondary);line-height:1.6;}
169
- .dtok-prow{border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:8px 10px;margin-bottom:8px;background:var(--dsw-alias-bg-layer-1);}
170
- .dtok-prow-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:6px;}
171
- .dtok-pseg{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin:4px 0;}
172
- .dtok-pseg-name{font-size:11px;color:var(--dsw-alias-label-secondary);min-width:112px;}
173
- .dtok-pseg-tilde{font-size:11px;color:var(--dsw-alias-label-secondary);}
174
- .dtok-src{font-size:11px;color:var(--dsw-alias-label-tertiary,#94a3b8);}
175
- .dtok-src.custom{color:var(--dsw-alias-state-success-primary,#4ade80);}
176
- .dtok-src.fallback{color:var(--dk-warn);}
177
- `;
178
-
179
- // ---------- 详情弹窗 ----------
180
- function Detail({ rec, onClose, rate }) {
181
- const st = statusInfo(rec);
182
- const rateCny = Number(rate) > 0 ? Number(rate) : 7.2;
183
- const rows = [
184
- ["会话 ID", rec.sessionId],
185
- ["时间", fmtTime(rec.time)],
186
- ["提供商", rec.provider],
187
- ["模型", rec.model],
188
- ["状态码", st.label + " (" + st.title + ")"],
189
- ["错误信息", rec.errorMsg || "—"],
190
- ["错误码", rec.errorCode || "—"],
191
- ["Request ID", rec.requestId || "—"],
192
- ["输入 Token(缓存命中)", fmtNum(rec.cacheReadTokens)],
193
- ["输入 Token(缓存未命中)", fmtNum(rec.inputTokens)],
194
- ["输出 Token", fmtNum(rec.outputTokens)],
195
- ["缓存写入 Token", fmtNum(rec.cacheWriteTokens)],
196
- ["推理 Token", fmtNum(rec.reasoningTokens)],
197
- ["计费输入", fmtNum(rec.billedInput)],
198
- ["缓存命中率", rec.cacheHitPercent + "%"],
199
- ["总 Token", fmtNum(rec.totalTokens)],
200
- ["消耗金额(估算)", fmtCostCny(rec.cost, rateCny)],
201
- ["计价来源", rec.pricingSource ? ({ custom: "自定义单价", official: "官网同步价", builtin: "内置默认价", fallback: "兜底单价" }[rec.pricingSource] || rec.pricingSource) : "—"],
202
- ["推理强度", rec.effort || "—"],
203
- ["耗时", fmtDuration(rec.llmMs)],
204
- ["Turn / Step", rec.turn + " / " + rec.step],
205
- ];
206
- return (
207
- <div className="dtok-detail" onClick={onClose}>
208
- <div className="dtok-detail-card" onClick={(e) => e.stopPropagation()}>
209
- <h3>调用详情</h3>
210
- {rows.map(([k, v]) => (
211
- <div className="dtok-detail-row" key={k}>
212
- <span className="k">{k}</span>
213
- <span className="v">{v}</span>
214
- </div>
215
- ))}
216
- <button className="dtok-btn" style={{ marginTop: 12 }} onClick={onClose}>关闭</button>
217
- </div>
218
- </div>
219
- );
220
- }
221
-
222
- // ---------- 单价设置 ----------
223
- // 官网抓取的刊例价常与实际计费不符(第三方中转/折扣/自建端点),这里让用户按模型填写
224
- // 真实单价并持久化到宿主 settings;保存后费用即时按新价重算(无需重扫历史)。
225
- // 独立子弹窗(可最大化 / 拖动 / 缩放),与功能坞面板解耦,方便配置较长时段表。
226
- function numStr(v) { return v === undefined || v === null ? "" : String(v); }
227
-
228
- /** 时段草稿:一个 { start,end,input,output,cacheRead,cacheWrite } 的可编辑副本。 */
229
- function segToDraft(s) {
230
- return {
231
- start: numStr(s && s.start), end: numStr(s && s.end),
232
- input: numStr(s && s.input), output: numStr(s && s.output),
233
- cacheRead: numStr(s && s.cacheRead), cacheWrite: numStr(s && s.cacheWrite),
234
- };
235
- }
236
- function rowToDraft(r) {
237
- return {
238
- match: r.match || "",
239
- input: numStr(r.input), output: numStr(r.output),
240
- cacheRead: numStr(r.cacheRead), cacheWrite: numStr(r.cacheWrite),
241
- // 旧数据可能是单段 peak(宿主已归一为 peaks,这里再兼容一次直连旧宿主的情况)
242
- peaks: (Array.isArray(r.peaks) && r.peaks.length ? r.peaks
243
- : (r.peak && typeof r.peak === "object" ? [r.peak] : [])).map(segToDraft),
244
- };
245
- }
246
- /** 默认新时段草稿:给一组常见值,减少手填。 */
247
- function newSegDraft() { return segToDraft({ start: 9, end: 14, input: "", output: "", cacheRead: "", cacheWrite: "" }); }
248
-
249
- // ---------- 单价设置子弹窗(独立于功能坞弹层的覆盖层,可最大化/拖动/缩放) ----------
250
- // 挂在 document.body 上:功能坞弹层自身 z-index:200,子弹窗用更高层级;面板滚动不影响定位。
251
- function PriceModal({ onClose, onSaved }) {
252
- const [maxed, setMaxed] = useState(false);
253
- const [geom, setGeom] = useState(null); // { x, y, w, h };null = CSS 默认居中
254
- const dlgRef = useRef(null);
255
- const dragRef = useRef(null);
256
-
257
- // 拖动(标题栏)/ 缩放(右下角手柄):用 window 级 pointer 监听,指针移出元素也不丢
258
- const startDrag = useCallback((e, kind) => {
259
- if (e.button !== undefined && e.button !== 0) return;
260
- if (kind === "move" && e.target && e.target.closest && e.target.closest("button,select,input,label,details,summary")) return;
261
- const node = dlgRef.current;
262
- if (!node) return;
263
- const rect = node.getBoundingClientRect();
264
- dragRef.current = { kind, startX: e.clientX, startY: e.clientY, left: rect.left, top: rect.top, w: rect.width, h: rect.height };
265
- if (e.preventDefault) e.preventDefault();
266
- }, []);
267
- useEffect(() => {
268
- const onMove = (ev) => {
269
- const d = dragRef.current;
270
- if (!d) return;
271
- const dx = ev.clientX - d.startX, dy = ev.clientY - d.startY;
272
- const vw = window.innerWidth, vh = window.innerHeight;
273
- if (d.kind === "move") {
274
- const x = Math.min(Math.max(0, d.left + dx), Math.max(0, vw - 80));
275
- const y = Math.min(Math.max(0, d.top + dy), Math.max(0, vh - 40));
276
- setGeom({ x: x, y: y, w: d.w, h: d.h });
277
- } else {
278
- const w = Math.max(560, Math.min(d.w + dx, vw - 16));
279
- const h = Math.max(360, Math.min(d.h + dy, vh - 16));
280
- setGeom({ x: d.left, y: d.top, w: w, h: h });
281
- }
282
- };
283
- const onUp = () => { dragRef.current = null; };
284
- window.addEventListener("pointermove", onMove);
285
- window.addEventListener("pointerup", onUp);
286
- return () => {
287
- window.removeEventListener("pointermove", onMove);
288
- window.removeEventListener("pointerup", onUp);
289
- };
290
- }, []);
291
- // Esc 关闭(子弹窗在弹层之上,先关自己)
292
- useEffect(() => {
293
- const onKey = (e) => { if (e.key === "Escape") onClose(); };
294
- window.addEventListener("keydown", onKey);
295
- return () => window.removeEventListener("keydown", onKey);
296
- }, [onClose]);
297
-
298
- const style = maxed
299
- ? { position: "fixed", left: 8, top: 8, right: 8, bottom: 8, width: "auto", height: "auto" }
300
- : geom
301
- ? { position: "fixed", left: geom.x, top: geom.y, width: geom.w, height: geom.h }
302
- : null;
303
- // 挂到 document.body:功能坞弹层祖先带 backdrop-filter(会改变 fixed 定位的包含块),
304
- // 设置页祖先也不可控;portal 到 body 才能保证覆盖整屏、不被裁剪。
305
- // 无 document(测试沙箱)/无 createPortal 时退回就地渲染,行为不变。
306
- const portalTarget = typeof document !== "undefined" && document.body ? document.body : null;
307
- const overlay = (
308
- <div className="dtok-pm-backdrop" onClick={onClose}>
309
- <div ref={dlgRef} className={"dtok-pm" + (maxed ? " max" : "")} style={style} onClick={(e) => e.stopPropagation()}>
310
- <div className="dtok-pm-head" onPointerDown={(e) => startDrag(e, "move")} onDoubleClick={() => setMaxed((v) => !v)}>
311
- <b>单价设置</b>
312
- <span className="dtok-pm-sub">人民币元 / 百万 tokens · 自定义单价优先于官网价与内置价</span>
313
- <span className="dtok-pm-ctrls">
314
- <button type="button" className="dtok-btn tiny" title={maxed ? "还原" : "最大化"} onClick={() => setMaxed((v) => !v)}>{maxed ? "❐" : "▢"}</button>
315
- <button type="button" className="dtok-btn tiny" title="关闭" onClick={onClose}>✕</button>
316
- </span>
317
- </div>
318
- <div className="dtok-pm-body">
319
- <PricingEditor onClose={onClose} onSaved={onSaved} embedded />
320
- </div>
321
- {maxed ? null : <div className="dtok-pm-resize" title="拖动缩放" onPointerDown={(e) => startDrag(e, "resize")} />}
322
- </div>
323
- </div>
324
- );
325
- return portalTarget && typeof react.createPortal === "function"
326
- ? react.createPortal(overlay, portalTarget)
327
- : overlay;
328
- }
329
-
330
- function PricingEditor({ onClose, onSaved, embedded }) {
331
- const [cfg, setCfg] = useState(null);
332
- const [rows, setRows] = useState([]);
333
- const [rate, setRate] = useState("7.2");
334
- const [fetchOn, setFetchOn] = useState(true);
335
- const [fb, setFb] = useState({ input: "", output: "", cacheRead: "", cacheWrite: "" });
336
- const [newMatch, setNewMatch] = useState("");
337
- const [msg, setMsg] = useState(null);
338
- const [saving, setSaving] = useState(false);
339
-
340
- useEffect(() => {
341
- let cancel = false;
342
- rpcCall("pricing", {})
343
- .then((d) => {
344
- if (cancel) return;
345
- setCfg(d);
346
- setRate(numStr(d.usdCnyRate));
347
- setFetchOn(!!d.fetchOfficial);
348
- setFb({
349
- input: numStr(d.fallback && d.fallback.input), output: numStr(d.fallback && d.fallback.output),
350
- cacheRead: numStr(d.fallback && d.fallback.cacheRead), cacheWrite: numStr(d.fallback && d.fallback.cacheWrite),
351
- });
352
- setRows((d.pricing || []).map(rowToDraft));
353
- })
354
- .catch((e) => { if (!cancel) setMsg({ ok: false, text: String((e && e.message) || e) }); });
355
- return () => { cancel = true; };
356
- }, []);
357
-
358
- const patchRow = (i, patch) => setRows((rs) => rs.map((r, j) => (j === i ? Object.assign({}, r, patch) : r)));
359
- const delRow = (i) => setRows((rs) => rs.filter((_, j) => j !== i));
360
- // 时段增删改:都作用在 rows[i].peaks 数组上
361
- const addSeg = (i) => setRows((rs) => rs.map((r, j) => (j === i ? Object.assign({}, r, { peaks: r.peaks.concat([newSegDraft()]) }) : r)));
362
- const delSeg = (i, k) => setRows((rs) => rs.map((r, j) => (j === i ? Object.assign({}, r, { peaks: r.peaks.filter((_, x) => x !== k) }) : r)));
363
- const patchSeg = (i, k, patch) => setRows((rs) => rs.map((r, j) => (j === i
364
- ? Object.assign({}, r, { peaks: r.peaks.map((s, x) => (x === k ? Object.assign({}, s, patch) : s)) }) : r)));
365
- const addRow = (m) => {
366
- const match = String(m === undefined ? newMatch : m).trim();
367
- if (!match) return;
368
- if (rows.some((r) => r.match.toLowerCase() === match.toLowerCase())) { setMsg({ ok: false, text: "「" + match + "」已存在" }); return; }
369
- setRows((rs) => rs.concat([rowToDraft({ match: match, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 })]));
370
- setNewMatch("");
371
- setMsg(null);
372
- };
373
-
374
- const save = () => {
375
- if (saving) return;
376
- const payload = { usdCnyRate: Number(rate), fetchOfficial: fetchOn };
377
- payload.pricing = rows.map((r) => {
378
- const out = {
379
- match: String(r.match || "").trim(),
380
- input: Number(r.input || 0), output: Number(r.output || 0),
381
- cacheRead: Number(r.cacheRead || 0), cacheWrite: Number(r.cacheWrite || 0),
382
- };
383
- const peaks = (r.peaks || []).map((s) => ({
384
- start: Number(s.start || 0), end: Number(s.end || 0),
385
- input: Number(s.input || 0), output: Number(s.output || 0),
386
- cacheRead: Number(s.cacheRead || 0), cacheWrite: Number(s.cacheWrite || 0),
387
- }));
388
- if (peaks.length) out.peaks = peaks;
389
- return out;
390
- });
391
- payload.fallback = {
392
- input: Number(fb.input || 0), output: Number(fb.output || 0),
393
- cacheRead: Number(fb.cacheRead || 0), cacheWrite: Number(fb.cacheWrite || 0),
394
- };
395
- setSaving(true); setMsg(null);
396
- rpcCall("setpricing", payload)
397
- .then((d) => {
398
- setCfg(d);
399
- setRows((d.pricing || []).map(rowToDraft));
400
- setMsg({ ok: true, text: "单价已保存,费用已按新价即时重算" });
401
- if (onSaved) onSaved();
402
- })
403
- .catch((e) => setMsg({ ok: false, text: String((e && e.message) || e) }))
404
- .finally(() => setSaving(false));
405
- };
406
-
407
- const configured = rows.map((r) => String(r.match).toLowerCase());
408
- const candidates = ((cfg && cfg.models) || []).filter((m) => configured.indexOf(String(m).toLowerCase()) < 0);
409
- const srcOf = (m) => (cfg && cfg.sourcesByModel && cfg.sourcesByModel[m]) || "";
410
- // 哪些已用模型当前走兜底价——提示用户优先补这些
411
- const onFallback = ((cfg && cfg.models) || []).filter((m) => srcOf(m) === "兜底");
412
- const num = (value, onChange, extra) => (
413
- <input className="dtok-price-num" type="number" min="0" step="0.01" value={value} onChange={(e) => onChange(e.target.value)} style={extra} />
414
- );
415
- // 时段价输入带占位提示(四个数值框从左到右含义不同,仅靠下方说明易填错)
416
- const segNum = (value, onChange, placeholder) => (
417
- <input className="dtok-price-num" type="number" min="0" step="0.01" value={value} placeholder={placeholder}
418
- onChange={(e) => onChange(e.target.value)} style={{ width: 80 }} />
419
- );
420
- const segLabel = (s) => {
421
- const a = numStr(s.start), b = numStr(s.end);
422
- if (a === "" || b === "") return "时段";
423
- return a + "~" + b + (Number(a) === Number(b) ? "(全天)" : Number(a) > Number(b) ? "(跨零点)" : "");
424
- };
425
-
426
- return (
427
- <div className={embedded ? "dtok-price emb" : "dtok-price"} onClick={(e) => e.stopPropagation()}>
428
- <div className="dtok-price-grid">
429
- <div className="dtok-price-field">
430
- <label>USD→CNY 汇率</label>
431
- {num(rate, setRate)}
432
- </div>
433
- <label style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 12, color: "var(--dsw-alias-label-secondary)" }}>
434
- <input type="checkbox" checked={fetchOn} onChange={(e) => setFetchOn(e.target.checked)} />
435
- 官网价目自动同步(关闭后仅用自定义/内置价)
436
- </label>
437
- </div>
438
-
439
- <div>
440
- <div className="dtok-section-title" style={{ margin: "0 0 4px" }}>按模型配置单价({rows.length} 条)</div>
441
- {onFallback.length > 0 ? (
442
- <div className="dtok-price-hint">以下用过的模型当前走「兜底价」,建议优先补单价:{onFallback.join("、")}</div>
443
- ) : null}
444
- {rows.length === 0 ? (
445
- <div className="dtok-price-hint">尚未配置自定义单价,当前使用官网同步价 / 内置价 / 兜底价。</div>
446
- ) : rows.map((r, i) => (
447
- <div className="dtok-prow" key={i}>
448
- <div className="dtok-prow-head">
449
- <input className="dtok-input" style={{ maxWidth: 240 }} type="text" value={r.match} placeholder="模型匹配(子串)" onChange={(e) => patchRow(i, { match: e.target.value })} />
450
- <span className={"dtok-src " + (srcOf(r.match) === "自定义" ? "custom" : srcOf(r.match) === "兜底" ? "fallback" : "")}>当前来源:{srcOf(r.match) || "—"}</span>
451
- <button className="dtok-btn tiny" onClick={() => delRow(i)}>删除模型</button>
452
- </div>
453
- <div className="dtok-price-grid">
454
- <div className="dtok-price-field"><label>基准输入(缓存命中)</label>{num(r.cacheRead, (v) => patchRow(i, { cacheRead: v }))}</div>
455
- <div className="dtok-price-field"><label>基准输入(缓存未命中)</label>{num(r.input, (v) => patchRow(i, { input: v }))}</div>
456
- <div className="dtok-price-field"><label>基准输出</label>{num(r.output, (v) => patchRow(i, { output: v }))}</div>
457
- <div className="dtok-price-field"><label>基准缓存写入</label>{num(r.cacheWrite, (v) => patchRow(i, { cacheWrite: v }))}</div>
458
- </div>
459
- <div className="dtok-price-hint" style={{ marginTop: 2 }}>
460
- 缓存写入=未命中缓存、本次新建缓存的那部分输入(Claude 等按溢价单独收建缓存费);DeepSeek 不收此项,填 0 即可。
461
- </div>
462
- <div className="dtok-price-hint" style={{ marginTop: 2 }}>
463
- 分时段价(可多段,命中哪段用哪段;时段外回落到基准价。start&gt;end 表示跨零点,如 23~7;start=end 表示全天)
464
- </div>
465
- {r.peaks.length === 0 ? <div className="dtok-price-hint">(未设置分时段,按基准价计费)</div> : null}
466
- {r.peaks.map((s, k) => (
467
- <div className="dtok-pseg" key={k}>
468
- <span className="dtok-pseg-name">{segLabel(s)}</span>
469
- {num(s.start, (v) => patchSeg(i, k, { start: v }), { width: 56 })}
470
- <span className="dtok-pseg-tilde">~</span>
471
- {num(s.end, (v) => patchSeg(i, k, { end: v }), { width: 56 })}
472
- <span className="dtok-pseg-tilde">时</span>
473
- {segNum(s.cacheRead, (v) => patchSeg(i, k, { cacheRead: v }), "缓存命中")}
474
- {segNum(s.input, (v) => patchSeg(i, k, { input: v }), "未命中")}
475
- {segNum(s.output, (v) => patchSeg(i, k, { output: v }), "输出")}
476
- {segNum(s.cacheWrite, (v) => patchSeg(i, k, { cacheWrite: v }), "写入")}
477
- <button className="dtok-btn tiny" onClick={() => delSeg(i, k)}>删除时段</button>
478
- </div>
479
- ))}
480
- <div className="dtok-price-hint" style={{ opacity: .85 }}>时段价从左到右:输入(命中)/ 输入(未命中)/ 输出 / 缓存写入</div>
481
- <button className="dtok-btn tiny" onClick={() => addSeg(i)}>+ 添加时段</button>
482
- </div>
483
- ))}
484
- <div className="dtok-price-grid" style={{ marginTop: 6 }}>
485
- <div className="dtok-price-field" style={{ flex: "0 0 220px" }}>
486
- <label>新增模型匹配</label>
487
- <input className="dtok-input" style={{ maxWidth: 220 }} type="text" list="dtok-model-candidates" placeholder="如 deepseek-v4-flash 或某中转模型名" value={newMatch} onChange={(e) => setNewMatch(e.target.value)} />
488
- <datalist id="dtok-model-candidates">{candidates.map((m) => <option key={m} value={m} />)}</datalist>
489
- </div>
490
- <button className="dtok-btn" onClick={() => addRow()}>+ 添加</button>
491
- </div>
492
- <div className="dtok-price-hint">提示:匹配是「模型名包含该子串」,可只写关键片段(如 <code>v4-flash</code>);越具体的条目建议放越前(当前按列表顺序命中)。</div>
493
- </div>
494
-
495
- <div>
496
- <div className="dtok-section-title" style={{ margin: "0 0 4px" }}>兜底单价(未匹配任何条目时使用)</div>
497
- <div className="dtok-price-grid">
498
- <div className="dtok-price-field"><label>输入(缓存命中)</label>{num(fb.cacheRead, (v) => setFb((s) => Object.assign({}, s, { cacheRead: v })))}</div>
499
- <div className="dtok-price-field"><label>输入(缓存未命中)</label>{num(fb.input, (v) => setFb((s) => Object.assign({}, s, { input: v })))}</div>
500
- <div className="dtok-price-field"><label>输出</label>{num(fb.output, (v) => setFb((s) => Object.assign({}, s, { output: v })))}</div>
501
- <div className="dtok-price-field"><label>缓存写入</label>{num(fb.cacheWrite, (v) => setFb((s) => Object.assign({}, s, { cacheWrite: v })))}</div>
502
- </div>
503
- </div>
504
-
505
- {cfg && cfg.builtin && cfg.builtin.length > 0 ? (
506
- <details>
507
- <summary className="dtok-price-hint" style={{ cursor: "pointer" }}>查看内置默认单价({cfg.builtin.length} 条,自定义配置未命中时按此兜底)</summary>
508
- <div className="dtok-table-wrap" style={{ marginTop: 6 }}>
509
- <table className="dtok-table">
510
- <thead><tr><th>匹配</th><th>输入(命中)</th><th>输入(未命中)</th><th>输出</th><th>缓存写入</th><th>分时段</th></tr></thead>
511
- <tbody>
512
- {cfg.builtin.map((b) => (
513
- <tr key={b.match}>
514
- <td>{b.match}</td><td>{b.cacheRead}</td><td>{b.input}</td><td>{b.output}</td><td>{b.cacheWrite}</td>
515
- <td>{b.peaks && b.peaks.length ? b.peaks.map((s) => s.start + "~" + s.end).join("、") + "时" : "—"}</td>
516
- </tr>
517
- ))}
518
- </tbody>
519
- </table>
520
- </div>
521
- </details>
522
- ) : null}
523
-
524
- <div className="dtok-price-grid">
525
- <button className="dtok-btn primary" disabled={saving} onClick={save}>{saving ? "保存中…" : "保存单价"}</button>
526
- {msg ? <span className={"dtok-price-msg " + (msg.ok ? "ok" : "err")}>{msg.text}</span> : null}
527
- </div>
528
- </div>
529
- );
530
- }
531
-
532
- // ---------- 主视图(嵌入 dock 面板内容区) ----------
533
- export function TokenLogView(props) {
534
- // props.params.sessionId(chips 点击带入):立即按该会话筛选并查询。
535
- // navAt:每次 chip 点击的时间戳——面板已打开时再次点击(即便同一会话)也触发重查。
536
- const navSession = props && props.params && props.params.sessionId ? props.params.sessionId : null;
537
- const navAt = props && props.params && props.params.navAt ? props.params.navAt : 0;
538
- // 挂载即视为「打开」:恢复上次暂存的条件(时间不选=显示全部记录); savedFilters 稳定快照, 仅初始化时读取一次
539
- const [savedFilters] = useState(loadSavedFilters);
540
- const [fromStr, setFromStr] = useState(() => (savedFilters && savedFilters.fromStr) || "");
541
- const [toStr, setToStr] = useState(() => (savedFilters && savedFilters.toStr) || "");
542
- const [provider, setProvider] = useState(() => (savedFilters && savedFilters.provider) || "");
543
- const [model, setModel] = useState(() => (savedFilters && savedFilters.model) || "");
544
- const [status, setStatus] = useState(() => (savedFilters && savedFilters.status) || "");
545
- const [effort, setEffort] = useState(() => (savedFilters && savedFilters.effort) || "");
546
- // 会话入口(chip/深链)带入的 sessionId 优先于暂存条件:下拉框初值即选中该会话
547
- const [sessionId, setSessionId] = useState(() => navSession || (savedFilters && savedFilters.sessionId) || "");
548
- const [dim, setDim] = useState(() => (savedFilters && savedFilters.dim) || "");
549
- const [sortKey, setSortKey] = useState("time");
550
- const [sortDir, setSortDir] = useState("desc");
551
- const [data, setData] = useState(null);
552
- const [loading, setLoading] = useState(false);
553
- const [err, setErr] = useState("");
554
- const [page, setPage] = useState(0);
555
- const [detailRec, setDetailRec] = useState(null);
556
- // 「单价设置」折叠面板:默认收起(费用不准确时才需要展开调整)
557
- // 「单价设置」子弹窗:默认收起;支持经 params.openPricing 直接打开(深链/测试用)
558
- const [showPricing, setShowPricing] = useState(() => !!(props && props.params && props.params.openPricing));
559
- const pageSize = 100;
560
-
561
- // 暂存筛选条件: 任一筛选变化即写入 localStorage, 下次打开恢复同样条件
562
- useEffect(() => {
563
- saveFilters({ fromStr, toStr, provider, model, status, effort, sessionId, dim });
564
- }, [fromStr, toStr, provider, model, status, effort, sessionId, dim]);
565
-
566
- // 挂载时: 扫描历史后按暂存条件查询(未设置时间则不限制, 显示全部记录)
567
- useEffect(() => {
568
- let cancel = false;
569
- setLoading(true); setErr("");
570
- const q = {};
571
- if (savedFilters) {
572
- if (savedFilters.fromStr) q.from = new Date(savedFilters.fromStr).getTime();
573
- if (savedFilters.toStr) q.to = new Date(savedFilters.toStr).getTime();
574
- if (savedFilters.provider) q.provider = savedFilters.provider;
575
- if (savedFilters.model) q.model = savedFilters.model;
576
- if (savedFilters.status) q.status = savedFilters.status;
577
- if (savedFilters.effort) q.effort = savedFilters.effort;
578
- if (savedFilters.sessionId) q.sessionId = savedFilters.sessionId;
579
- if (savedFilters.dim) q.dim = savedFilters.dim;
580
- }
581
- // 从会话入口点入时按带入的会话查(优先于暂存条件):打开即出当前会话结果,
582
- // 不再需要手动点「查询」。只保留这一条初始查询,避免并发的会话查询被本链路覆盖。
583
- if (navSession) q.sessionId = navSession;
584
- rpcCall("scan", {})
585
- .then(() => (cancel ? null : rpcCall("query", q)))
586
- .then((d) => {
587
- if (cancel) return;
588
- setData(d);
589
- })
590
- .catch((e) => { if (!cancel) setErr(String((e && e.message) || e)); })
591
- .finally(() => { if (!cancel) setLoading(false); });
592
- return () => { cancel = true; };
593
- }, [savedFilters]);
594
-
595
- const buildQ = useCallback((withDim, dimOverride) => {
596
- const q = {};
597
- if (fromStr) q.from = new Date(fromStr).getTime();
598
- if (toStr) q.to = new Date(toStr).getTime();
599
- if (provider) q.provider = provider;
600
- if (model) q.model = model;
601
- if (status) q.status = status;
602
- if (effort) q.effort = effort;
603
- if (sessionId) q.sessionId = sessionId;
604
- const dimNow = dimOverride !== undefined ? dimOverride : dim;
605
- if (withDim && dimNow) q.dim = dimNow;
606
- return q;
607
- }, [fromStr, toStr, provider, model, status, effort, sessionId, dim]);
608
-
609
- // dimOverride:分组按钮点击时按「即将切换到」的维度立即查询(闭包里的 dim 还是旧值)
610
- const runQuery = useCallback((dimOverride) => {
611
- setLoading(true); setErr("");
612
- rpcCall("query", buildQ(true, dimOverride))
613
- .then((d) => { setData(d); setPage(0); })
614
- .catch((e) => setErr(String((e && e.message) || e)))
615
- .finally(() => setLoading(false));
616
- }, [buildQ]);
617
-
618
- // chips 定位:面板已打开时再次从会话入口点击 → 立即按该会话筛选查询。
619
- // 挂载时的首次带入已并入初始查询(sessionId 初值 + 挂载 effect),以 navAt 判同跳过,
620
- // 避免打开面板时同一条件跑两遍、且挂载查询链路较慢把会话结果覆盖回全量。
621
- const navAppliedRef = useRef(navAt);
622
- useEffect(() => {
623
- if (!navSession || navAppliedRef.current === navAt) return;
624
- navAppliedRef.current = navAt;
625
- setSessionId(navSession);
626
- setLoading(true); setErr("");
627
- rpcCall("query", Object.assign({}, buildQ(true), { sessionId: navSession }))
628
- .then((d) => { setData(d); setPage(0); })
629
- .catch((e) => setErr(String((e && e.message) || e)))
630
- .finally(() => setLoading(false));
631
- }, [navAt]);
632
-
633
- // 重置: 清空所有筛选(时间不选=显示全部记录), 并立即查询
634
- const resetFilters = useCallback(() => { setFromStr(""); setToStr("");
635
- setProvider(""); setModel(""); setStatus(""); setEffort(""); setSessionId(""); setDim("");
636
- setLoading(true); setErr("");
637
- rpcCall("query", {})
638
- .then((d) => { setData(d); setPage(0); })
639
- .catch((e) => setErr(String((e && e.message) || e)))
640
- .finally(() => setLoading(false));
641
- }, []);
642
-
643
- const exportCsv = useCallback(() => {
644
- rpcCall("export", buildQ(false))
645
- .then((d) => {
646
- const blob = new Blob([d.csv], { type: "text/csv;charset=utf-8" });
647
- const url = URL.createObjectURL(blob);
648
- const a = document.createElement("a");
649
- a.href = url;
650
- a.download = "dsh-dock-tokenlog-" + Date.now() + ".csv";
651
- a.click();
652
- URL.revokeObjectURL(url);
653
- })
654
- .catch((e) => setErr(String((e && e.message) || e)));
655
- }, [buildQ]);
656
-
657
- // 自动刷新: 挂载期间每 5s 按当前筛选静默刷新(不闪烁 loading)。
658
- // 注意: 依赖数组 [buildQ] 在声明时即求值, 必须放在 buildQ 定义之后(否则 TDZ 崩溃)。
659
- useEffect(() => {
660
- const timer = setInterval(() => {
661
- rpcCall("query", buildQ(true))
662
- .then((d) => { setData(d); setErr(""); })
663
- .catch(() => {});
664
- }, 5000);
665
- return () => clearInterval(timer);
666
- }, [buildQ]);
667
-
668
- const toggleSort = useCallback((key) => {
669
- if (sortKey === key) setSortDir(sortDir === "asc" ? "desc" : "asc");
670
- else { setSortKey(key); setSortDir("asc"); }
671
- }, [sortKey, sortDir]);
672
-
673
- const records = (data && data.records) || [];
674
- const totals = data && data.totals;
675
- const sessionIds = (data && data.sessionIds) || [];
676
- const sorted = useMemo(() => {
677
- const arr = records.slice();
678
- const key = sortKey;
679
- const dir = sortDir === "asc" ? 1 : -1;
680
- arr.sort((a, b) => {
681
- const av = a[key]; const bv = b[key];
682
- if (av === null || av === undefined || av === "") return 1;
683
- if (bv === null || bv === undefined || bv === "") return -1;
684
- if (typeof av === "string") return av.localeCompare(String(bv)) * dir;
685
- return (Number(av) - Number(bv)) * dir;
686
- });
687
- return arr;
688
- }, [records, sortKey, sortDir]);
689
- const pageCount = Math.max(1, Math.ceil(sorted.length / pageSize));
690
- const pageSafe = Math.min(page, pageCount - 1);
691
- const pageRows = sorted.slice(pageSafe * pageSize, (pageSafe + 1) * pageSize);
692
-
693
- // USD→CNY 汇率: host 返回(默认 7.2, 可被 settings dsh-dock.tokenlog.usdCnyRate 覆盖)。
694
- // 注意: 必须在本文件所有引用它的表达式(cards/summaryRows/detailRows/Detail)之前声明。
695
- const rateCny = (data && data.rateUsdCny) || 7.2;
696
-
697
- const cards = totals ? [
698
- { v: fmtNum(totals.calls), l: "调用次数" },
699
- { v: fmtCompact(totals.totalTokens), l: "总 Token" },
700
- { v: fmtCompact(totals.cacheReadTokens), l: "输入(命中)" },
701
- { v: fmtCompact(totals.inputTokens), l: "输入(未命中)" },
702
- { v: totals.cacheHitPct + "%", l: "缓存命中率" },
703
- { v: fmtCompact(totals.outputTokens), l: "输出" },
704
- { v: fmtCostCny(totals.cost, rateCny), l: "消耗金额(估算·人民币)" },
705
- { v: fmtDuration(totals.llmMs), l: "累计耗时" + (totals.timed ? " (" + totals.timed + "步)" : "") },
706
- ] : [];
707
-
708
- const opts = (arr) => (arr || []).map((x) => <option key={x || "(none)"} value={x}>{x || "(空)"}</option>);
709
-
710
- // 模型下拉联动: 选中提供商后只显示该提供商下的模型; 未选提供商显示全部模型。
711
- // modelsByProvider 由 host 返回({provider: [models]})。
712
- const modelChoices = provider && data && data.modelsByProvider
713
- ? (data.modelsByProvider[provider] || [])
714
- : (data && data.models) || [];
715
-
716
- const summaryRows = (data && data.summary || []).map((r) => (
717
- <tr key={r.key}>
718
- <td>{r.key}</td><td>{fmtNum(r.calls)}</td><td>{fmtCompact(r.cacheReadTokens)}</td>
719
- <td>{fmtCompact(r.inputTokens)}</td><td>{r.cacheHitPct + "%"}</td><td>{fmtCompact(r.outputTokens)}</td>
720
- <td>{fmtCompact(r.totalTokens)}</td>
721
- <td>{fmtCostCny(r.cost, rateCny)}</td>
722
- <td>{fmtDuration(r.llmMs)}</td>
723
- </tr>
724
- ));
725
-
726
- const sortTh = (label, key) => (
727
- <th onClick={() => toggleSort(key)} title="点击排序">
728
- {label}{sortKey === key ? <span className="dtok-sort-mark">{sortDir === "asc" ? "▲" : "▼"}</span> : null}
729
- </th>
730
- );
731
-
732
- const detailRows = pageRows.map((r) => {
733
- const st = statusInfo(r);
734
- return (
735
- <tr key={r.id}>
736
- <td>{fmtTime(r.time)}</td>
737
- <td><span className="dtok-sid" title="点击按此会话筛选" onClick={() => { setSessionId(r.sessionId); runQuery(); }}>{shortId(r.sessionId)}</span></td>
738
- <td>{r.provider || "—"}</td>
739
- <td>{r.model || "—"}</td>
740
- <td>{fmtNum(r.cacheReadTokens)}</td>
741
- <td>{fmtNum(r.inputTokens)}</td>
742
- <td>{r.cacheHitPercent + "%"}</td>
743
- <td>{fmtNum(r.outputTokens)}</td>
744
- <td>{fmtNum(r.reasoningTokens)}</td>
745
- <td>{fmtNum(r.totalTokens)}</td>
746
- <td title={r.pricingSource ? "计价来源:" + ({ custom: "自定义单价", official: "官网同步价", builtin: "内置默认价", fallback: "兜底单价" }[r.pricingSource] || r.pricingSource) : ""}>
747
- {fmtCostCny(r.cost, rateCny)}
748
- {r.pricingSource === "fallback" ? <span className="dtok-src fallback">{" "}兜底</span> : null}
749
- </td>
750
- <td>{r.effort || "—"}</td>
751
- <td>
752
- <div><span className={"dtok-code " + st.cls}>{st.label}</span></div>
753
- <div><a className="dtok-detail-link" onClick={() => setDetailRec(r)}>查看详情</a></div>
754
- </td>
755
- <td>{fmtDuration(r.llmMs)}</td>
756
- </tr>
757
- );
758
- });
759
-
760
- const bodyNodes = [];
761
- if (err) bodyNodes.push(<div key="err" className="dtok-err">错误: {err}</div>);
762
- if (cards.length) bodyNodes.push(
763
- <div className="dtok-cards" key="cards">
764
- {cards.map((c) => <div className="dtok-card" key={c.l}><div className="v">{c.v}</div><div className="l">{c.l}</div></div>)}
765
- </div>
766
- );
767
- if (summaryRows.length) bodyNodes.push(
768
- <div key="sum" className="dtok-section-title">统计分组: {dim || "无"} ({summaryRows.length} 组)</div>,
769
- <div key="sumtab" className="dtok-table-wrap">
770
- <table className="dtok-table">
771
- <thead><tr>
772
- <th>维度</th><th>调用</th><th>输入(命中)</th><th>输入(未命中)</th><th>命中率</th><th>输出</th><th>总Token</th><th>金额(人民币)</th><th>耗时</th>
773
- </tr></thead>
774
- <tbody>{summaryRows}</tbody>
775
- </table>
776
- </div>
777
- );
778
- bodyNodes.push(<div key="dimtitle" className="dtok-section-title">按维度统计</div>);
779
- bodyNodes.push(
780
- <div key="dimrow" className="dtok-filter" style={{ padding: "4px 0 0", border: "none", background: "transparent" }}>
781
- {["", "provider", "model", "status", "effort"].map((d) => (
782
- <button key={d} className={"dtok-btn" + (dim === d ? " primary" : "")} onClick={() => { setDim(d); runQuery(d); }}>{d === "" ? "无分组" : d}</button>
783
- ))}
784
- </div>
785
- );
786
- bodyNodes.push(<div key="dettitle" className="dtok-section-title">调用明细</div>);
787
- if (sorted.length === 0) {
788
- bodyNodes.push(<div key="empty" className="dtok-empty">{loading ? "加载中…" : "无匹配记录"}</div>);
789
- } else {
790
- // 分页控件: 明细表上方+下方各一份, 免去翻页时滑到底部
791
- const pager = (key, cls) => (
792
- <div key={key} className={"dtok-pager " + cls}>
793
- <button className="dtok-btn" disabled={pageSafe <= 0} onClick={() => setPage(pageSafe - 1)}>上一页</button>
794
- <span>第 {pageSafe + 1} / {pageCount} 页 · 共 {sorted.length} 条</span>
795
- <button className="dtok-btn" disabled={pageSafe >= pageCount - 1} onClick={() => setPage(pageSafe + 1)}>下一页</button>
796
- </div>
797
- );
798
- bodyNodes.push(pager("pager-top", "top"));
799
- bodyNodes.push(
800
- <div key="detail" className="dtok-table-wrap">
801
- <table className="dtok-table">
802
- <thead><tr>
803
- {sortTh("时间", "time")}{sortTh("会话ID", "sessionId")}{sortTh("提供商", "provider")}{sortTh("模型", "model")}
804
- {sortTh("输入(命中)", "cacheReadTokens")}{sortTh("输入(未命中)", "inputTokens")}{sortTh("命中%", "cacheHitPercent")}{sortTh("输出", "outputTokens")}
805
- {sortTh("推理", "reasoningTokens")}{sortTh("总额", "totalTokens")}{sortTh("金额(人民币)", "cost")}{sortTh("强度", "effort")}
806
- {sortTh("状态", "status")}{sortTh("耗时", "llmMs")}
807
- </tr></thead>
808
- <tbody>{detailRows}</tbody>
809
- </table>
810
- </div>
811
- );
812
- bodyNodes.push(pager("pager-bottom", "bottom"));
813
- }
814
-
815
- return (
816
- <div className="dtok-root" onClick={(e) => e.stopPropagation()}>
817
- <div className="dtok-status">
818
- <span className="count">{loading ? "加载中…" : (data ? data.counts.matching + " / " + data.counts.total + " 条" : "")}</span>
819
- <span>全屏请用面板右上角「最大化」;面板打开期间每 5 秒自动刷新</span>
820
- </div>
821
- <div className="dtok-filter">
822
- <label>起</label>
823
- <input className="dtok-input" type="datetime-local" step="1" value={fromStr} onChange={(e) => setFromStr(e.target.value)} />
824
- <label>止</label>
825
- <input className="dtok-input" type="datetime-local" step="1" value={toStr} onChange={(e) => setToStr(e.target.value)} />
826
- <label>会话</label>
827
- <select className="dtok-select" value={sessionId} onChange={(e) => setSessionId(e.target.value)}>
828
- <option value="">全部</option>{opts(sessionIds)}
829
- </select>
830
- <label>提供商</label>
831
- <select className="dtok-select" value={provider} onChange={(e) => { setProvider(e.target.value); setModel("") }}>
832
- <option value="">全部</option>{opts(data && data.providers)}
833
- </select>
834
- <label>模型</label>
835
- <select className="dtok-select" value={model} onChange={(e) => setModel(e.target.value)}>
836
- <option value="">全部</option>{opts(modelChoices)}
837
- </select>
838
- <label>状态</label>
839
- <select className="dtok-select" value={status} onChange={(e) => setStatus(e.target.value)}>
840
- <option value="">全部</option>{opts(data && data.statuses)}
841
- </select>
842
- <label>推理强度</label>
843
- <select className="dtok-select" value={effort} onChange={(e) => setEffort(e.target.value)}>
844
- <option value="">全部</option>{opts(data && data.efforts)}
845
- </select>
846
- <button className="dtok-btn primary" onClick={runQuery}>查询</button>
847
- <button className="dtok-btn" onClick={resetFilters}>重置</button>
848
- <button className="dtok-btn" onClick={exportCsv}>导出 CSV</button>
849
- <button className={"dtok-btn" + (showPricing ? " primary" : "")} onClick={() => setShowPricing(true)} title="配置各模型单价,费用按自填单价计算">单价设置</button>
850
- </div>
851
- {showPricing ? <PriceModal onClose={() => setShowPricing(false)} onSaved={runQuery} /> : null}
852
- <div className="dtok-status">
853
- {data && data.pricingInfo ? (
854
- <span>
855
- 计价:{data.pricingInfo.hasCustom ? "自定义单价 " + data.pricingInfo.customCount + " 条" : "未配置自定义单价"}
856
- {" · 官网自动同步:" + (data.pricingInfo.fetchOfficial ? "开" : "关")}
857
- {data.pricingInfo.sources && data.pricingInfo.sources.length ? " · 本页涉及来源:" + data.pricingInfo.sources.join("/") : ""}
858
- </span>
859
- ) : null}
860
- </div>
861
- <div className="dtok-body">{bodyNodes}</div>
862
- {detailRec ? <Detail rec={detailRec} onClose={() => setDetailRec(null)} rate={rateCny} /> : null}
863
- </div>
864
- );
865
- }
866
-
867
- // ---------- 首页总揽卡片:今日调用 / 今日 Token / 今日花费 ----------
868
- function todayStart() {
869
- const d = new Date();
870
- d.setHours(0, 0, 0, 0);
871
- return d.getTime();
872
- }
873
- function TokenLogHomeStat() {
874
- const [snap, setSnap] = useState({ totals: null, rate: 7.2, err: "" });
875
- useEffect(() => {
876
- let cancel = false;
877
- const load = () => rpcCall("query", { from: todayStart() })
878
- .then((d) => { if (!cancel) setSnap({ totals: d && d.totals, rate: (d && d.rateUsdCny) || 7.2, err: "" }); })
879
- .catch((e) => { if (!cancel) setSnap((s) => ({ totals: s.totals, rate: s.rate, err: String((e && e.message) || e) })); });
880
- load();
881
- const timer = setInterval(load, 60000);
882
- return () => { cancel = true; clearInterval(timer); };
883
- }, []);
884
- if (!snap.totals) return <span>{snap.err ? "用量查询失败(点击进入查看)" : "正在拉取今日用量…"}</span>;
885
- const t = snap.totals;
886
- return <span>今日 {fmtNum(t.calls)} 次调用 · {fmtCompact(t.totalTokens)} Token · {fmtCostCny(t.cost, snap.rate)}</span>;
887
- }
888
-
889
- // ---------- 会话输入区 chip:显示当前会话总 Token(10s 静默刷新),点击打开功能坞并按该会话筛选 ----------
890
- export function TokenLogChip(props) {
891
- const sid = (props && props.sessionId) || (props && props.session && props.session.sessionId) || null;
892
- const [snap, setSnap] = useState({ totals: null, rate: 7.2, err: "" });
893
- useEffect(() => {
894
- if (!sid) return;
895
- let cancel = false;
896
- const load = () => rpcCall("query", { sessionId: sid })
897
- .then((d) => { if (!cancel) setSnap({ totals: d && d.totals, rate: (d && d.rateUsdCny) || 7.2, err: "" }); })
898
- .catch((e) => { if (!cancel) setSnap((s) => ({ totals: s.totals, rate: s.rate, err: String((e && e.message) || e) })); });
899
- load();
900
- const timer = setInterval(load, 10000);
901
- return () => { cancel = true; clearInterval(timer); };
902
- }, [sid]);
903
- if (!sid) return null;
904
- const t = snap.totals;
905
- // chip 文案用紧凑人民币金额(2 位小数),避免撑爆输入卡工具行;
906
- // 完整金额在 title 悬浮提示里。
907
- const label = snap.err && !t
908
- ? (snap.err.includes("重启") ? "用量·需重启宿主" : "用量·失败")
909
- : (t ? "⛁ " + fmtCompact(t.totalTokens) + (t.cost > 0 ? " · " + fmtCostCny(t.cost, snap.rate) : "") : "⛁ …");
910
- const title = t
911
- ? "本会话 " + fmtNum(t.calls) + " 次调用 · " + fmtNum(t.totalTokens) + " Token · " + fmtCostCny(t.cost, snap.rate) + "(估算)\n点击在功能坞查看用量记录"
912
- : (snap.err ? snap.err + "\n" : "") + "点击在功能坞查看用量记录";
913
- return (
914
- <button type="button" className={"dockchip" + (snap.err && !t ? " err" : "")} title={title} aria-label="会话用量"
915
- onClick={() => openPanel("tokenlog", { sessionId: sid, navAt: Date.now() })}>
916
- <span className="dockchip-dot" style={{ background: "var(--dk-warn)" }} />
917
- <span>{label}</span>
918
- </button>
919
- );
920
- }
921
-
922
- export const feature = {
923
- id: "tokenlog",
924
- name: "用量记录",
925
- order: 110,
926
- accent: "#fbbf24",
927
- description: "记录全部 LLM API 调用:秒级时间筛选、Token/费用统计(可配置各模型单价,持久保存并按自填单价计费;内置峰谷计价+官网价目自动同步作兜底)、分组汇总、明细检索与 CSV 导出",
928
- css,
929
- View: TokenLogView,
930
- HomeStat: TokenLogHomeStat,
931
- Chip: TokenLogChip,
932
- };
1
+ // dsh-dock · 功能模块【用量记录】· 客户端视图(v0.4.0,移植自 @wycto/dsh-token-usage client v6,适配 dock 嵌入)
2
+ //
3
+ // 嵌入功能坞面板的统计视图(无独立 overlay 外壳;全屏用 dock 弹窗自带的「最大化」):
4
+ // - 秒级时间范围查询 + 会话ID/提供商/模型(联动)/状态/推理强度 筛选,条件本地暂存
5
+ // - 9 张 KPI 卡 + 分组统计表 + 明细表(点击表头排序、会话ID点击即筛选、100 行/页上下双分页)
6
+ // - 状态列显示 HTTP 状态码徽章,行内【查看详情】弹窗展示完整信息;CSV 导出(中文表头,列序与明细表一致 + 合计行)
7
+ // - 独立的「单价设置」子弹窗:按模型配置单价(支持多段分时价)并持久化,费用按自填单价重算
8
+ // - 挂载即扫描历史+按暂存条件查询;挂载期间每 5s 静默自动刷新
9
+ // - 查询等待动画:点「查询」后按钮转圈扫光 + 数据区顶部「记账小队清点」banner(轮换俏皮文案,
10
+ // 等久了换语气),旧数据压暗禁点;结果到位时闪一次「✓ 数据已更新」并让 KPI 卡回弹
11
+ // Host 通信:fetch('/dsh-dock/tokenlog/<method>')(见 features/tokenlog/host.js)。
12
+ import react, { useState, useEffect, useCallback, useMemo, useRef } from "react";
13
+ import { openPanel } from "../../src/shared.js";
14
+
15
+ // ---------- Host RPC 桥接 ----------
16
+ function rpcCall(method, args) {
17
+ return fetch("/dsh-dock/tokenlog/" + method, {
18
+ method: "POST",
19
+ headers: { "content-type": "application/json" },
20
+ body: JSON.stringify(args === undefined ? {} : args),
21
+ })
22
+ .then(async (res) => {
23
+ const data = await res.json().catch(() => ({}));
24
+ if (res.ok && data && data.ok === true) return data.data;
25
+ // 旧宿主进程没有用量路由(405/404):给出可操作提示而非裸状态码
26
+ if (res.status === 405 || res.status === 404) {
27
+ throw new Error("宿主进程是旧版本(没有用量记录路由),重启 dsh web 后重试");
28
+ }
29
+ if (data && data.ok === false) throw new Error((data.error && data.error.message) || ("HTTP " + res.status));
30
+ throw new Error("HTTP " + res.status + (data && data.error && data.error.message ? ": " + data.error.message : ""));
31
+ });
32
+ }
33
+
34
+ // ---------- 格式化 ----------
35
+ function fmtNum(n) { return (Number(n) || 0).toLocaleString("en-US"); }
36
+ function fmtCompact(n) {
37
+ n = Number(n) || 0;
38
+ if (n >= 1e9) return (n / 1e9).toFixed(2) + "B";
39
+ if (n >= 1e6) return (n / 1e6).toFixed(2) + "M";
40
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + "K";
41
+ return String(Math.round(n));
42
+ }
43
+ // 金额记录在 RPC 中以 USD 传输;界面统一按人民币显示。
44
+ function fmtCostCny(usd, rate) {
45
+ const v = (Number(usd) || 0) * (Number(rate) > 0 ? Number(rate) : 7.2);
46
+ if (v >= 10000) return "¥" + (v / 10000).toFixed(2) + "万";
47
+ if (v >= 100) return "¥" + v.toFixed(0);
48
+ return "¥" + v.toFixed(2);
49
+ }
50
+ function fmtDuration(ms) {
51
+ if (ms === null || ms === undefined || isNaN(ms)) return "—";
52
+ if (ms < 1000) return Math.round(ms) + "ms";
53
+ if (ms < 60000) return (ms / 1000).toFixed(1) + "s";
54
+ return Math.floor(ms / 60000) + "m" + Math.round((ms % 60000) / 1000) + "s";
55
+ }
56
+ function fmtTime(ts) {
57
+ if (!ts) return "";
58
+ const d = new Date(ts);
59
+ const p = (x) => String(x).padStart(2, "0");
60
+ return d.getFullYear() + "-" + p(d.getMonth() + 1) + "-" + p(d.getDate()) + " " + p(d.getHours()) + ":" + p(d.getMinutes()) + ":" + p(d.getSeconds());
61
+ }
62
+ // 金额: CSV 里逐条导出时保精确(不做 ¥xx万/取整), 否则对账会丢分。
63
+ function fmtCostCnyExact(usd, rate) {
64
+ const v = (Number(usd) || 0) * (Number(rate) > 0 ? Number(rate) : 7.2);
65
+ return "¥" + v.toFixed(4);
66
+ }
67
+
68
+ // ---------- CSV 导出 ----------
69
+ // 表头即「调用明细」表的中文列名, 列序/取值格式与页面完全一致(日期时间、千分位、命中%、¥xx.xxxx、
70
+ // 耗时 1.2s、状态徽章标签)。host 的 export 返回原始 rows, 由这里统一格式化, 保证界面与导出同源。
71
+ const EXPORT_COLUMNS = [
72
+ ["时间", (r) => fmtTime(r.time)],
73
+ ["会话ID", (r) => r.sessionId || ""],
74
+ ["提供商", (r) => r.provider || "—"],
75
+ ["模型", (r) => r.model || "—"],
76
+ ["输入(命中)", (r) => fmtNum(r.cacheReadTokens)],
77
+ ["输入(未命中)", (r) => fmtNum(r.inputTokens)],
78
+ ["命中%", (r) => (Number(r.cacheHitPercent) || 0) + "%"],
79
+ ["输出", (r) => fmtNum(r.outputTokens)],
80
+ ["推理", (r) => fmtNum(r.reasoningTokens)],
81
+ ["总额", (r) => fmtNum(r.totalTokens)],
82
+ ["金额(人民币)", (r, rate) => fmtCostCnyExact(r.cost, rate)],
83
+ ["强度", (r) => r.effort || "—"],
84
+ ["状态", (r) => statusInfo(r).label],
85
+ ["耗时", (r) => fmtDuration(r.llmMs)],
86
+ ];
87
+ function csvCell(v) {
88
+ const s = String(v === undefined || v === null ? "" : v);
89
+ return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
90
+ }
91
+ /** rows → CSV 文本(不含 BOM)。末尾追加「合计」行: 调用数/各 Token 合计/金额合计, 与页面 KPI 卡口径一致。 */
92
+ function buildExportCsv(rows, rate) {
93
+ const list = Array.isArray(rows) ? rows : [];
94
+ const lines = [EXPORT_COLUMNS.map(([title]) => csvCell(title)).join(",")];
95
+ for (const r of list) lines.push(EXPORT_COLUMNS.map(([, get]) => csvCell(get(r, rate))).join(","));
96
+ if (list.length) {
97
+ const sum = (k) => list.reduce((a, r) => a + (Number(r[k]) || 0), 0);
98
+ const totalInput = sum("inputTokens") + sum("cacheReadTokens");
99
+ const hitPct = totalInput > 0 ? Math.round((sum("cacheReadTokens") / totalInput) * 100) : 0;
100
+ const totals = {
101
+ "调用次数": String(list.length),
102
+ "输入(命中)": fmtNum(sum("cacheReadTokens")),
103
+ "输入(未命中)": fmtNum(sum("inputTokens")),
104
+ "命中%": hitPct + "%",
105
+ "输出": fmtNum(sum("outputTokens")),
106
+ "推理": fmtNum(sum("reasoningTokens")),
107
+ "总额": fmtNum(sum("totalTokens")),
108
+ "金额(人民币)": fmtCostCnyExact(sum("cost"), rate),
109
+ };
110
+ lines.push(EXPORT_COLUMNS.map(([title]) => {
111
+ if (title === "提供商") return csvCell("合计(" + list.length + " 条调用)");
112
+ return csvCell(totals[title] === undefined ? "" : totals[title]);
113
+ }).join(","));
114
+ }
115
+ return lines.join("\r\n");
116
+ }
117
+
118
+ // ---------- 筛选条件暂存(本地) ----------
119
+ // 打开视图时恢复上次保存的条件; 从未保存过则时间范围不选(显示全部记录)。
120
+ const STORE_KEY = "dsh-dock/tokenlog/filters/v1";
121
+ const FILTER_FIELDS = ["fromStr", "toStr", "provider", "model", "status", "effort", "sessionId", "dim"];
122
+ function loadSavedFilters() {
123
+ try {
124
+ if (typeof localStorage === "undefined") return null;
125
+ const raw = localStorage.getItem(STORE_KEY);
126
+ if (!raw) return null;
127
+ const obj = JSON.parse(raw);
128
+ if (!obj || typeof obj !== "object") return null;
129
+ const out = {};
130
+ for (const k of FILTER_FIELDS) out[k] = typeof obj[k] === "string" ? obj[k] : "";
131
+ return out;
132
+ } catch (e) { return null; }
133
+ }
134
+ function saveFilters(f) {
135
+ try {
136
+ if (typeof localStorage === "undefined") return;
137
+ localStorage.setItem(STORE_KEY, JSON.stringify(f));
138
+ } catch (e) { /* localStorage 不可用时静默 */ }
139
+ }
140
+ function shortId(sid) {
141
+ if (!sid) return "";
142
+ if (sid.length <= 16) return sid;
143
+ return sid.slice(0, 8) + "…" + sid.slice(-6);
144
+ }
145
+ function statusInfo(r) {
146
+ if (r.status === "completed") return { code: 200, label: "200", cls: "ok", title: "成功" };
147
+ if (r.status === "max-tokens") return { code: 200, label: "200", cls: "warn", title: "完成(达到输出上限)" };
148
+ if (r.status === "error") return { code: r.statusCode || 500, label: String(r.statusCode || 500), cls: "err", title: r.errorMsg || r.errorCode || "调用失败" };
149
+ if (r.status === "aborted") return { code: 499, label: "499", cls: "warn", title: "已取消" };
150
+ if (r.status === "blocked") return { code: 403, label: "403", cls: "warn", title: "已阻止" };
151
+ if (r.status === "interrupted") return { code: 500, label: "500", cls: "warn", title: "中断" };
152
+ return { code: 0, label: "…", cls: "pend", title: "进行中" };
153
+ }
154
+
155
+ // ---------- 样式(dtok- 前缀,dock 面板内自适应:宽度铺满、表格横向滚动) ----------
156
+ const css = `
157
+ .dtok-root{display:flex;flex-direction:column;gap:8px;width:100%;min-width:0;}
158
+ .dtok-status{display:flex;align-items:center;gap:10px;font-size:12px;color:var(--dsw-alias-label-secondary);flex-wrap:wrap;}
159
+ .dtok-status .count{color:var(--dsw-alias-label-primary);}
160
+ .dtok-filter{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:8px 10px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1);}
161
+ .dtok-filter label{font-size:12px;color:var(--dsw-alias-label-secondary);white-space:nowrap;flex:none;}
162
+ .dtok-input,.dtok-select{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:4px 8px;font-size:12px;font-family:inherit;max-width:170px;}
163
+ .dtok-input[type="datetime-local"]{width:158px;}
164
+ .dtok-btn{cursor:pointer;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);padding:4px 12px;font-size:12px;font-family:inherit;flex:none;}
165
+ .dtok-btn:hover{background:var(--dsw-alias-interactive-bg-hover);}
166
+ .dtok-btn[disabled]{opacity:.5;cursor:default;}
167
+ .dtok-btn.primary{background:var(--dk-accent);border-color:var(--dk-accent);color:#fff;}
168
+ .dtok-btn.primary:hover{filter:brightness(1.1);}
169
+ .dtok-body{display:flex;flex-direction:column;gap:8px;min-width:0;}
170
+ .dtok-cards{display:flex;gap:8px;flex-wrap:wrap;}
171
+ .dtok-card{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:8px 14px;min-width:112px;}
172
+ .dtok-card .v{font-size:18px;font-weight:700;color:var(--dsw-alias-label-primary);}
173
+ .dtok-card .l{font-size:11px;color:var(--dsw-alias-label-secondary);margin-top:2px;}
174
+ .dtok-section-title{font-size:13px;font-weight:600;margin:6px 0 0;color:var(--dsw-alias-label-primary);}
175
+ .dtok-table-wrap{overflow:auto;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;max-width:100%;}
176
+ .dtok-table{border-collapse:collapse;width:100%;font-size:12px;white-space:nowrap;color:var(--dsw-alias-label-primary);}
177
+ .dtok-table th{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);text-align:left;padding:7px 10px;position:sticky;top:0;z-index:1;border-bottom:1px solid var(--dsw-alias-border-l1);font-weight:600;user-select:none;cursor:pointer;}
178
+ .dtok-table th:hover{color:var(--dsw-alias-label-primary);}
179
+ .dtok-table td{padding:6px 10px;border-bottom:1px solid var(--dk-tdim);}
180
+ .dtok-table tr:hover td{background:var(--dk-hover-tint);}
181
+ .dtok-empty{text-align:center;color:var(--dsw-alias-label-secondary);padding:32px 0;font-size:13px;}
182
+ .dtok-sid{color:var(--dk-accent);cursor:pointer;text-decoration:underline dotted;}
183
+ .dtok-sid:hover{filter:brightness(1.25);}
184
+ .dtok-code{font-weight:700;font-family:ui-monospace,monospace;}
185
+ .dtok-code.ok{color:var(--dsw-alias-state-success-primary,#4ade80);}
186
+ .dtok-code.warn{color:var(--dk-warn);}
187
+ .dtok-code.err{color:var(--dsw-alias-state-error-primary,#f87171);}
188
+ .dtok-code.pend{color:var(--dsw-alias-label-tertiary,#94a3b8);}
189
+ .dtok-detail-link{color:var(--dk-accent);cursor:pointer;font-size:11px;}
190
+ .dtok-detail-link:hover{text-decoration:underline;}
191
+ .dtok-detail{position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;}
192
+ .dtok-detail-card{background:var(--dsw-alias-bg-layer-2,#1c212b);border:1px solid var(--dsw-alias-border-l2,#3a4150);border-radius:12px;padding:20px 24px;max-width:640px;width:92%;max-height:80vh;overflow:auto;color:var(--dsw-alias-label-primary,#e8eaf0);}
193
+ .dtok-detail-card h3{margin:0 0 12px;font-size:15px;}
194
+ .dtok-detail-row{display:flex;gap:8px;padding:4px 0;font-size:12px;border-bottom:1px solid var(--dk-tdim);}
195
+ .dtok-detail-row .k{color:var(--dsw-alias-label-secondary,#9aa3b5);min-width:110px;flex-shrink:0;}
196
+ .dtok-detail-row .v{word-break:break-all;}
197
+ .dtok-sort-mark{opacity:0.6;margin-left:3px;}
198
+ .dtok-pager{display:flex;gap:8px;align-items:center;font-size:12px;color:var(--dsw-alias-label-secondary);}
199
+ .dtok-pager.top{margin:0;}
200
+ .dtok-pager.bottom{margin:0;}
201
+ .dtok-err{color:var(--dsw-alias-state-error-primary,#ff7a7a);font-size:12px;}
202
+ /* ---- 查询加载动画 ----
203
+ 数据量大时「查询」要等好几秒,旧版只有一行「加载中…」:用户既不确定点上没点上,
204
+ 也不知道数据什么时候换掉。这里用一段有趣的「记账小队清点」动画填满等待,并在结果
205
+ 真正到位时给一次「✓ 数据已更新」+ KPI 卡回弹,让「数据变了」这件事被看见。 */
206
+ .dtok-btn{transition:transform .08s ease,background .15s ease,filter .15s ease,border-color .15s ease;}
207
+ .dtok-btn.primary:not([disabled]):active{transform:scale(.95);}
208
+ .dtok-btn.loading{position:relative;overflow:hidden;opacity:1;cursor:progress;border-color:var(--dk-accent);}
209
+ .dtok-btn.loading::after{content:"";position:absolute;inset:0;border-radius:inherit;pointer-events:none;background:linear-gradient(100deg,transparent 18%,rgb(255 255 255 / .4) 50%,transparent 82%);transform:translateX(-130%);animation:dtok-btn-sheen 1.05s linear infinite;}
210
+ @keyframes dtok-btn-sheen{to{transform:translateX(130%);}}
211
+ .dtok-spin{display:inline-block;width:10px;height:10px;margin-right:5px;vertical-align:-1px;border:2px solid rgb(255 255 255 / .35);border-top-color:#fff;border-radius:50%;animation:dtok-rotate .7s linear infinite;}
212
+ .dtok-spin.inline{width:9px;height:9px;margin:0;vertical-align:0;border-color:rgb(127 127 127 / .35);border-top-color:var(--dk-accent);}
213
+ @keyframes dtok-rotate{to{transform:rotate(360deg);}}
214
+ /* 状态行里的「⟳ 加载中...」:转圈 + 省略号一个一个蹦出来,最经典的那种加载动画 */
215
+ .dtok-busy{display:inline-flex;align-items:center;gap:5px;}
216
+ .dtok-dots{display:inline-block;width:12px;text-align:left;}
217
+ .dtok-dots i{font-style:normal;opacity:0;animation:dtok-dot 1.2s linear infinite;}
218
+ .dtok-dots i:nth-child(2){animation-delay:.2s;}
219
+ .dtok-dots i:nth-child(3){animation-delay:.4s;}
220
+ @keyframes dtok-dot{0%{opacity:0;}25%,80%{opacity:1;}100%{opacity:0;}}
221
+ .dtok-body-inner{display:flex;flex-direction:column;gap:8px;min-width:0;transition:opacity .25s ease;}
222
+ .dtok-body-inner.busy{opacity:.45;pointer-events:none;}
223
+ .dtok-loading{position:sticky;top:0;z-index:6;display:flex;align-items:center;flex-wrap:wrap;gap:10px 12px;padding:9px 14px;border-radius:12px;border:1px solid var(--dk-accent);background:linear-gradient(180deg,rgb(255 255 255 / .07),rgb(0 0 0 / .1)),var(--dsw-alias-bg-layer-2,#1c212b);box-shadow:0 10px 26px rgb(0 0 0 / .3);animation:dtok-load-in .3s ease;}
224
+ @keyframes dtok-load-in{from{opacity:0;transform:translateY(-6px);}to{opacity:1;transform:none;}}
225
+ .dtok-load-scene{position:relative;display:flex;align-items:flex-end;gap:1px;height:30px;flex:none;padding:0 6px;}
226
+ .dtok-load-emoji{display:inline-block;font-size:19px;line-height:1;animation:dtok-hop 1.1s cubic-bezier(.36,.07,.19,.97) infinite;filter:drop-shadow(0 2px 3px rgb(0 0 0 / .3));}
227
+ .dtok-load-emoji.e2{animation-delay:.15s;}
228
+ .dtok-load-emoji.e3{animation-delay:.3s;}
229
+ @keyframes dtok-hop{0%,100%{transform:translateY(0) rotate(-5deg);}35%{transform:translateY(-9px) rotate(2deg) scale(1.1);}65%{transform:translateY(0) rotate(5deg);}}
230
+ .dtok-load-spark{position:absolute;font-size:11px;color:var(--dk-accent);animation:dtok-twinkle 1.4s ease-in-out infinite;}
231
+ .dtok-load-spark.s1{left:-2px;top:-4px;}
232
+ .dtok-load-spark.s2{right:-2px;top:2px;animation-delay:.6s;}
233
+ @keyframes dtok-twinkle{0%,100%{opacity:0;transform:scale(.4) rotate(0);}45%{opacity:1;transform:scale(1.2) rotate(90deg);}}
234
+ .dtok-load-msg{flex:1 1 170px;min-width:0;display:flex;flex-direction:column;gap:2px;}
235
+ .dtok-load-msg b{font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;background:linear-gradient(90deg,var(--dsw-alias-label-primary),var(--dk-accent),var(--dsw-alias-label-primary));background-size:220% 100%;-webkit-background-clip:text;background-clip:text;color:transparent;animation:dtok-shine 2.6s linear infinite,dtok-msg-in .32s ease;}
236
+ @keyframes dtok-shine{0%{background-position:130% 0;}100%{background-position:-130% 0;}}
237
+ @keyframes dtok-msg-in{from{opacity:0;transform:translateY(4px);}to{opacity:1;transform:none;}}
238
+ .dtok-load-sub{font-size:11px;color:var(--dsw-alias-label-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
239
+ .dtok-load-sub.wait{color:var(--dk-warn);}
240
+ .dtok-load-track{position:relative;flex:0 1 140px;min-width:88px;height:6px;border-radius:999px;background:rgb(127 127 127 / .22);overflow:hidden;}
241
+ .dtok-load-fill{position:absolute;top:0;bottom:0;width:38%;border-radius:999px;background:linear-gradient(90deg,transparent,var(--dk-accent),transparent);animation:dtok-sweep 1.15s cubic-bezier(.45,.05,.55,.95) infinite;}
242
+ @keyframes dtok-sweep{0%{transform:translateX(-115%);}100%{transform:translateX(380%);}}
243
+ .dtok-cards.dtok-pop .dtok-card{animation:dtok-card-in .5s cubic-bezier(.2,.9,.3,1.3) both;animation-delay:calc(var(--i,0) * 45ms);}
244
+ @keyframes dtok-card-in{from{opacity:0;transform:translateY(7px) scale(.96);}to{opacity:1;transform:none;}}
245
+ .dtok-updated{font-size:11px;color:var(--dsw-alias-state-success-primary,#4ade80);animation:dtok-updated 2.4s ease forwards;}
246
+ @keyframes dtok-updated{0%{opacity:0;transform:translateY(-3px) scale(.85);}12%{opacity:1;transform:none;}70%{opacity:1;}100%{opacity:0;}}
247
+ @media (prefers-reduced-motion:reduce){
248
+ .dtok-load-emoji,.dtok-load-spark,.dtok-load-fill,.dtok-load-msg b,.dtok-btn.loading::after,.dtok-spin,.dtok-loading,.dtok-cards.dtok-pop .dtok-card{animation:none !important;}
249
+ .dtok-dots i{animation:none !important;opacity:1;}
250
+ .dtok-load-emoji{transform:none;}
251
+ .dtok-body-inner.busy{opacity:.75;}
252
+ .dtok-btn.primary:not([disabled]):active{transform:none;}
253
+ }
254
+ /* ---- 单价设置(子弹窗) ---- */
255
+ .dtok-pm-backdrop{position:fixed;inset:0;z-index:2100;background:rgba(15,17,21,.5);backdrop-filter:blur(3px);display:flex;align-items:center;justify-content:center;}
256
+ .dtok-pm{box-sizing:border-box;width:min(980px,calc(100vw - 32px));height:min(760px,calc(100vh - 32px));display:flex;flex-direction:column;border-radius:14px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2,#1c212b);color:var(--dsw-alias-label-primary);box-shadow:0 20px 64px rgb(0 0 0 / .4);overflow:hidden;}
257
+ .dtok-pm.max{border-radius:10px;}
258
+ .dtok-pm-head{display:flex;align-items:center;gap:10px;padding:10px 12px;border-bottom:1px solid var(--dsw-alias-border-l1);cursor:move;user-select:none;flex:none;background:var(--dsw-alias-bg-layer-1);}
259
+ .dtok-pm.max .dtok-pm-head{cursor:default;}
260
+ .dtok-pm-head b{font-size:14px;}
261
+ .dtok-pm-sub{font-size:11px;color:var(--dsw-alias-label-secondary);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
262
+ .dtok-pm-ctrls{display:flex;gap:6px;flex:none;}
263
+ .dtok-pm-body{flex:1;min-height:0;overflow-y:auto;padding:12px 14px;}
264
+ .dtok-pm-resize{position:absolute;right:0;bottom:0;width:16px;height:16px;cursor:nwse-resize;touch-action:none;}
265
+ .dtok-pm{position:relative;}
266
+ /* ---- 单价设置(表单内容) ---- */
267
+ .dtok-price{display:flex;flex-direction:column;gap:12px;}
268
+ .dtok-price-grid{display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;}
269
+ .dtok-price-field{display:flex;flex-direction:column;gap:3px;}
270
+ .dtok-price-field label{font-size:11px;color:var(--dsw-alias-label-secondary);}
271
+ .dtok-price-num{width:92px;}
272
+ .dtok-price input[type="text"],.dtok-price-num{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:4px 6px;font-size:12px;font-family:inherit;}
273
+ .dtok-price .dtok-btn.tiny{padding:2px 8px;font-size:11px;}
274
+ .dtok-price-msg{font-size:12px;}
275
+ .dtok-price-msg.ok{color:var(--dsw-alias-state-success-primary,#4ade80);}
276
+ .dtok-price-msg.err{color:var(--dsw-alias-state-error-primary,#ff7a7a);}
277
+ .dtok-price-hint{font-size:11px;color:var(--dsw-alias-label-secondary);line-height:1.6;}
278
+ .dtok-prow{border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:8px 10px;margin-bottom:8px;background:var(--dsw-alias-bg-layer-1);}
279
+ .dtok-prow-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:6px;}
280
+ .dtok-pseg{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin:4px 0;}
281
+ .dtok-pseg-name{font-size:11px;color:var(--dsw-alias-label-secondary);min-width:112px;}
282
+ .dtok-pseg-tilde{font-size:11px;color:var(--dsw-alias-label-secondary);}
283
+ .dtok-src{font-size:11px;color:var(--dsw-alias-label-tertiary,#94a3b8);}
284
+ .dtok-src.custom{color:var(--dsw-alias-state-success-primary,#4ade80);}
285
+ .dtok-src.fallback{color:var(--dk-warn);}
286
+ `;
287
+
288
+ // ---------- 详情弹窗 ----------
289
+ function Detail({ rec, onClose, rate }) {
290
+ const st = statusInfo(rec);
291
+ const rateCny = Number(rate) > 0 ? Number(rate) : 7.2;
292
+ const rows = [
293
+ ["会话 ID", rec.sessionId],
294
+ ["时间", fmtTime(rec.time)],
295
+ ["提供商", rec.provider],
296
+ ["模型", rec.model],
297
+ ["状态码", st.label + " (" + st.title + ")"],
298
+ ["错误信息", rec.errorMsg || "—"],
299
+ ["错误码", rec.errorCode || "—"],
300
+ ["Request ID", rec.requestId || "—"],
301
+ ["输入 Token(缓存命中)", fmtNum(rec.cacheReadTokens)],
302
+ ["输入 Token(缓存未命中)", fmtNum(rec.inputTokens)],
303
+ ["输出 Token", fmtNum(rec.outputTokens)],
304
+ ["缓存写入 Token", fmtNum(rec.cacheWriteTokens)],
305
+ ["推理 Token", fmtNum(rec.reasoningTokens)],
306
+ ["计费输入", fmtNum(rec.billedInput)],
307
+ ["缓存命中率", rec.cacheHitPercent + "%"],
308
+ ["总 Token", fmtNum(rec.totalTokens)],
309
+ ["消耗金额(估算)", fmtCostCny(rec.cost, rateCny)],
310
+ ["计价来源", rec.pricingSource ? ({ custom: "自定义单价", official: "官网同步价", builtin: "内置默认价", fallback: "兜底单价" }[rec.pricingSource] || rec.pricingSource) : "—"],
311
+ ["推理强度", rec.effort || "—"],
312
+ ["耗时", fmtDuration(rec.llmMs)],
313
+ ["Turn / Step", rec.turn + " / " + rec.step],
314
+ ];
315
+ return (
316
+ <div className="dtok-detail" onClick={onClose}>
317
+ <div className="dtok-detail-card" onClick={(e) => e.stopPropagation()}>
318
+ <h3>调用详情</h3>
319
+ {rows.map(([k, v]) => (
320
+ <div className="dtok-detail-row" key={k}>
321
+ <span className="k">{k}</span>
322
+ <span className="v">{v}</span>
323
+ </div>
324
+ ))}
325
+ <button className="dtok-btn" style={{ marginTop: 12 }} onClick={onClose}>关闭</button>
326
+ </div>
327
+ </div>
328
+ );
329
+ }
330
+
331
+ // ---------- 单价设置 ----------
332
+ // 官网抓取的刊例价常与实际计费不符(第三方中转/折扣/自建端点),这里让用户按模型填写
333
+ // 真实单价并持久化到宿主 settings;保存后费用即时按新价重算(无需重扫历史)。
334
+ // 独立子弹窗(可最大化 / 拖动 / 缩放),与功能坞面板解耦,方便配置较长时段表。
335
+ function numStr(v) { return v === undefined || v === null ? "" : String(v); }
336
+
337
+ /** 时段草稿:一个 { start,end,input,output,cacheRead,cacheWrite } 的可编辑副本。 */
338
+ function segToDraft(s) {
339
+ return {
340
+ start: numStr(s && s.start), end: numStr(s && s.end),
341
+ input: numStr(s && s.input), output: numStr(s && s.output),
342
+ cacheRead: numStr(s && s.cacheRead), cacheWrite: numStr(s && s.cacheWrite),
343
+ };
344
+ }
345
+ function rowToDraft(r) {
346
+ return {
347
+ match: r.match || "",
348
+ input: numStr(r.input), output: numStr(r.output),
349
+ cacheRead: numStr(r.cacheRead), cacheWrite: numStr(r.cacheWrite),
350
+ // 旧数据可能是单段 peak(宿主已归一为 peaks,这里再兼容一次直连旧宿主的情况)
351
+ peaks: (Array.isArray(r.peaks) && r.peaks.length ? r.peaks
352
+ : (r.peak && typeof r.peak === "object" ? [r.peak] : [])).map(segToDraft),
353
+ };
354
+ }
355
+ /** 默认新时段草稿:给一组常见值,减少手填。 */
356
+ function newSegDraft() { return segToDraft({ start: 9, end: 14, input: "", output: "", cacheRead: "", cacheWrite: "" }); }
357
+
358
+ // ---------- 单价设置子弹窗(独立于功能坞弹层的覆盖层,可最大化/拖动/缩放) ----------
359
+ // 挂在 document.body 上:功能坞弹层自身 z-index:200,子弹窗用更高层级;面板滚动不影响定位。
360
+ function PriceModal({ onClose, onSaved }) {
361
+ const [maxed, setMaxed] = useState(false);
362
+ const [geom, setGeom] = useState(null); // { x, y, w, h };null = CSS 默认居中
363
+ const dlgRef = useRef(null);
364
+ const dragRef = useRef(null);
365
+
366
+ // 拖动(标题栏)/ 缩放(右下角手柄):用 window 级 pointer 监听,指针移出元素也不丢
367
+ const startDrag = useCallback((e, kind) => {
368
+ if (e.button !== undefined && e.button !== 0) return;
369
+ if (kind === "move" && e.target && e.target.closest && e.target.closest("button,select,input,label,details,summary")) return;
370
+ const node = dlgRef.current;
371
+ if (!node) return;
372
+ const rect = node.getBoundingClientRect();
373
+ dragRef.current = { kind, startX: e.clientX, startY: e.clientY, left: rect.left, top: rect.top, w: rect.width, h: rect.height };
374
+ if (e.preventDefault) e.preventDefault();
375
+ }, []);
376
+ useEffect(() => {
377
+ const onMove = (ev) => {
378
+ const d = dragRef.current;
379
+ if (!d) return;
380
+ const dx = ev.clientX - d.startX, dy = ev.clientY - d.startY;
381
+ const vw = window.innerWidth, vh = window.innerHeight;
382
+ if (d.kind === "move") {
383
+ const x = Math.min(Math.max(0, d.left + dx), Math.max(0, vw - 80));
384
+ const y = Math.min(Math.max(0, d.top + dy), Math.max(0, vh - 40));
385
+ setGeom({ x: x, y: y, w: d.w, h: d.h });
386
+ } else {
387
+ const w = Math.max(560, Math.min(d.w + dx, vw - 16));
388
+ const h = Math.max(360, Math.min(d.h + dy, vh - 16));
389
+ setGeom({ x: d.left, y: d.top, w: w, h: h });
390
+ }
391
+ };
392
+ const onUp = () => { dragRef.current = null; };
393
+ window.addEventListener("pointermove", onMove);
394
+ window.addEventListener("pointerup", onUp);
395
+ return () => {
396
+ window.removeEventListener("pointermove", onMove);
397
+ window.removeEventListener("pointerup", onUp);
398
+ };
399
+ }, []);
400
+ // Esc 关闭(子弹窗在弹层之上,先关自己)
401
+ useEffect(() => {
402
+ const onKey = (e) => { if (e.key === "Escape") onClose(); };
403
+ window.addEventListener("keydown", onKey);
404
+ return () => window.removeEventListener("keydown", onKey);
405
+ }, [onClose]);
406
+
407
+ const style = maxed
408
+ ? { position: "fixed", left: 8, top: 8, right: 8, bottom: 8, width: "auto", height: "auto" }
409
+ : geom
410
+ ? { position: "fixed", left: geom.x, top: geom.y, width: geom.w, height: geom.h }
411
+ : null;
412
+ // 挂到 document.body:功能坞弹层祖先带 backdrop-filter(会改变 fixed 定位的包含块),
413
+ // 设置页祖先也不可控;portal 到 body 才能保证覆盖整屏、不被裁剪。
414
+ // 无 document(测试沙箱)/无 createPortal 时退回就地渲染,行为不变。
415
+ const portalTarget = typeof document !== "undefined" && document.body ? document.body : null;
416
+ const overlay = (
417
+ <div className="dtok-pm-backdrop" onClick={onClose}>
418
+ <div ref={dlgRef} className={"dtok-pm" + (maxed ? " max" : "")} style={style} onClick={(e) => e.stopPropagation()}>
419
+ <div className="dtok-pm-head" onPointerDown={(e) => startDrag(e, "move")} onDoubleClick={() => setMaxed((v) => !v)}>
420
+ <b>单价设置</b>
421
+ <span className="dtok-pm-sub">人民币元 / 百万 tokens · 自定义单价优先于官网价与内置价</span>
422
+ <span className="dtok-pm-ctrls">
423
+ <button type="button" className="dtok-btn tiny" title={maxed ? "还原" : "最大化"} onClick={() => setMaxed((v) => !v)}>{maxed ? "❐" : "▢"}</button>
424
+ <button type="button" className="dtok-btn tiny" title="关闭" onClick={onClose}>✕</button>
425
+ </span>
426
+ </div>
427
+ <div className="dtok-pm-body">
428
+ <PricingEditor onClose={onClose} onSaved={onSaved} embedded />
429
+ </div>
430
+ {maxed ? null : <div className="dtok-pm-resize" title="拖动缩放" onPointerDown={(e) => startDrag(e, "resize")} />}
431
+ </div>
432
+ </div>
433
+ );
434
+ return portalTarget && typeof react.createPortal === "function"
435
+ ? react.createPortal(overlay, portalTarget)
436
+ : overlay;
437
+ }
438
+
439
+ function PricingEditor({ onClose, onSaved, embedded }) {
440
+ const [cfg, setCfg] = useState(null);
441
+ const [rows, setRows] = useState([]);
442
+ const [rate, setRate] = useState("7.2");
443
+ const [fetchOn, setFetchOn] = useState(true);
444
+ const [fb, setFb] = useState({ input: "", output: "", cacheRead: "", cacheWrite: "" });
445
+ const [newMatch, setNewMatch] = useState("");
446
+ const [msg, setMsg] = useState(null);
447
+ const [saving, setSaving] = useState(false);
448
+
449
+ useEffect(() => {
450
+ let cancel = false;
451
+ rpcCall("pricing", {})
452
+ .then((d) => {
453
+ if (cancel) return;
454
+ setCfg(d);
455
+ setRate(numStr(d.usdCnyRate));
456
+ setFetchOn(!!d.fetchOfficial);
457
+ setFb({
458
+ input: numStr(d.fallback && d.fallback.input), output: numStr(d.fallback && d.fallback.output),
459
+ cacheRead: numStr(d.fallback && d.fallback.cacheRead), cacheWrite: numStr(d.fallback && d.fallback.cacheWrite),
460
+ });
461
+ setRows((d.pricing || []).map(rowToDraft));
462
+ })
463
+ .catch((e) => { if (!cancel) setMsg({ ok: false, text: String((e && e.message) || e) }); });
464
+ return () => { cancel = true; };
465
+ }, []);
466
+
467
+ const patchRow = (i, patch) => setRows((rs) => rs.map((r, j) => (j === i ? Object.assign({}, r, patch) : r)));
468
+ const delRow = (i) => setRows((rs) => rs.filter((_, j) => j !== i));
469
+ // 时段增删改:都作用在 rows[i].peaks 数组上
470
+ const addSeg = (i) => setRows((rs) => rs.map((r, j) => (j === i ? Object.assign({}, r, { peaks: r.peaks.concat([newSegDraft()]) }) : r)));
471
+ const delSeg = (i, k) => setRows((rs) => rs.map((r, j) => (j === i ? Object.assign({}, r, { peaks: r.peaks.filter((_, x) => x !== k) }) : r)));
472
+ const patchSeg = (i, k, patch) => setRows((rs) => rs.map((r, j) => (j === i
473
+ ? Object.assign({}, r, { peaks: r.peaks.map((s, x) => (x === k ? Object.assign({}, s, patch) : s)) }) : r)));
474
+ const addRow = (m) => {
475
+ const match = String(m === undefined ? newMatch : m).trim();
476
+ if (!match) return;
477
+ if (rows.some((r) => r.match.toLowerCase() === match.toLowerCase())) { setMsg({ ok: false, text: "「" + match + "」已存在" }); return; }
478
+ setRows((rs) => rs.concat([rowToDraft({ match: match, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 })]));
479
+ setNewMatch("");
480
+ setMsg(null);
481
+ };
482
+
483
+ const save = () => {
484
+ if (saving) return;
485
+ const payload = { usdCnyRate: Number(rate), fetchOfficial: fetchOn };
486
+ payload.pricing = rows.map((r) => {
487
+ const out = {
488
+ match: String(r.match || "").trim(),
489
+ input: Number(r.input || 0), output: Number(r.output || 0),
490
+ cacheRead: Number(r.cacheRead || 0), cacheWrite: Number(r.cacheWrite || 0),
491
+ };
492
+ const peaks = (r.peaks || []).map((s) => ({
493
+ start: Number(s.start || 0), end: Number(s.end || 0),
494
+ input: Number(s.input || 0), output: Number(s.output || 0),
495
+ cacheRead: Number(s.cacheRead || 0), cacheWrite: Number(s.cacheWrite || 0),
496
+ }));
497
+ if (peaks.length) out.peaks = peaks;
498
+ return out;
499
+ });
500
+ payload.fallback = {
501
+ input: Number(fb.input || 0), output: Number(fb.output || 0),
502
+ cacheRead: Number(fb.cacheRead || 0), cacheWrite: Number(fb.cacheWrite || 0),
503
+ };
504
+ setSaving(true); setMsg(null);
505
+ rpcCall("setpricing", payload)
506
+ .then((d) => {
507
+ setCfg(d);
508
+ setRows((d.pricing || []).map(rowToDraft));
509
+ setMsg({ ok: true, text: "单价已保存,费用已按新价即时重算" });
510
+ if (onSaved) onSaved();
511
+ })
512
+ .catch((e) => setMsg({ ok: false, text: String((e && e.message) || e) }))
513
+ .finally(() => setSaving(false));
514
+ };
515
+
516
+ const configured = rows.map((r) => String(r.match).toLowerCase());
517
+ const candidates = ((cfg && cfg.models) || []).filter((m) => configured.indexOf(String(m).toLowerCase()) < 0);
518
+ const srcOf = (m) => (cfg && cfg.sourcesByModel && cfg.sourcesByModel[m]) || "";
519
+ // 哪些已用模型当前走兜底价——提示用户优先补这些
520
+ const onFallback = ((cfg && cfg.models) || []).filter((m) => srcOf(m) === "兜底");
521
+ const num = (value, onChange, extra) => (
522
+ <input className="dtok-price-num" type="number" min="0" step="0.01" value={value} onChange={(e) => onChange(e.target.value)} style={extra} />
523
+ );
524
+ // 时段价输入带占位提示(四个数值框从左到右含义不同,仅靠下方说明易填错)
525
+ const segNum = (value, onChange, placeholder) => (
526
+ <input className="dtok-price-num" type="number" min="0" step="0.01" value={value} placeholder={placeholder}
527
+ onChange={(e) => onChange(e.target.value)} style={{ width: 80 }} />
528
+ );
529
+ const segLabel = (s) => {
530
+ const a = numStr(s.start), b = numStr(s.end);
531
+ if (a === "" || b === "") return "时段";
532
+ return a + "~" + b + (Number(a) === Number(b) ? "(全天)" : Number(a) > Number(b) ? "(跨零点)" : "");
533
+ };
534
+
535
+ return (
536
+ <div className={embedded ? "dtok-price emb" : "dtok-price"} onClick={(e) => e.stopPropagation()}>
537
+ <div className="dtok-price-grid">
538
+ <div className="dtok-price-field">
539
+ <label>USD→CNY 汇率</label>
540
+ {num(rate, setRate)}
541
+ </div>
542
+ <label style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 12, color: "var(--dsw-alias-label-secondary)" }}>
543
+ <input type="checkbox" checked={fetchOn} onChange={(e) => setFetchOn(e.target.checked)} />
544
+ 官网价目自动同步(关闭后仅用自定义/内置价)
545
+ </label>
546
+ </div>
547
+
548
+ <div>
549
+ <div className="dtok-section-title" style={{ margin: "0 0 4px" }}>按模型配置单价({rows.length} 条)</div>
550
+ {onFallback.length > 0 ? (
551
+ <div className="dtok-price-hint">以下用过的模型当前走「兜底价」,建议优先补单价:{onFallback.join("、")}</div>
552
+ ) : null}
553
+ {rows.length === 0 ? (
554
+ <div className="dtok-price-hint">尚未配置自定义单价,当前使用官网同步价 / 内置价 / 兜底价。</div>
555
+ ) : rows.map((r, i) => (
556
+ <div className="dtok-prow" key={i}>
557
+ <div className="dtok-prow-head">
558
+ <input className="dtok-input" style={{ maxWidth: 240 }} type="text" value={r.match} placeholder="模型匹配(子串)" onChange={(e) => patchRow(i, { match: e.target.value })} />
559
+ <span className={"dtok-src " + (srcOf(r.match) === "自定义" ? "custom" : srcOf(r.match) === "兜底" ? "fallback" : "")}>当前来源:{srcOf(r.match) || "—"}</span>
560
+ <button className="dtok-btn tiny" onClick={() => delRow(i)}>删除模型</button>
561
+ </div>
562
+ <div className="dtok-price-grid">
563
+ <div className="dtok-price-field"><label>基准输入(缓存命中)</label>{num(r.cacheRead, (v) => patchRow(i, { cacheRead: v }))}</div>
564
+ <div className="dtok-price-field"><label>基准输入(缓存未命中)</label>{num(r.input, (v) => patchRow(i, { input: v }))}</div>
565
+ <div className="dtok-price-field"><label>基准输出</label>{num(r.output, (v) => patchRow(i, { output: v }))}</div>
566
+ <div className="dtok-price-field"><label>基准缓存写入</label>{num(r.cacheWrite, (v) => patchRow(i, { cacheWrite: v }))}</div>
567
+ </div>
568
+ <div className="dtok-price-hint" style={{ marginTop: 2 }}>
569
+ 缓存写入=未命中缓存、本次新建缓存的那部分输入(Claude 等按溢价单独收建缓存费);DeepSeek 不收此项,填 0 即可。
570
+ </div>
571
+ <div className="dtok-price-hint" style={{ marginTop: 2 }}>
572
+ 分时段价(可多段,命中哪段用哪段;时段外回落到基准价。start&gt;end 表示跨零点,如 23~7;start=end 表示全天)
573
+ </div>
574
+ {r.peaks.length === 0 ? <div className="dtok-price-hint">(未设置分时段,按基准价计费)</div> : null}
575
+ {r.peaks.map((s, k) => (
576
+ <div className="dtok-pseg" key={k}>
577
+ <span className="dtok-pseg-name">{segLabel(s)}</span>
578
+ {num(s.start, (v) => patchSeg(i, k, { start: v }), { width: 56 })}
579
+ <span className="dtok-pseg-tilde">~</span>
580
+ {num(s.end, (v) => patchSeg(i, k, { end: v }), { width: 56 })}
581
+ <span className="dtok-pseg-tilde">时</span>
582
+ {segNum(s.cacheRead, (v) => patchSeg(i, k, { cacheRead: v }), "缓存命中")}
583
+ {segNum(s.input, (v) => patchSeg(i, k, { input: v }), "未命中")}
584
+ {segNum(s.output, (v) => patchSeg(i, k, { output: v }), "输出")}
585
+ {segNum(s.cacheWrite, (v) => patchSeg(i, k, { cacheWrite: v }), "写入")}
586
+ <button className="dtok-btn tiny" onClick={() => delSeg(i, k)}>删除时段</button>
587
+ </div>
588
+ ))}
589
+ <div className="dtok-price-hint" style={{ opacity: .85 }}>时段价从左到右:输入(命中)/ 输入(未命中)/ 输出 / 缓存写入</div>
590
+ <button className="dtok-btn tiny" onClick={() => addSeg(i)}>+ 添加时段</button>
591
+ </div>
592
+ ))}
593
+ <div className="dtok-price-grid" style={{ marginTop: 6 }}>
594
+ <div className="dtok-price-field" style={{ flex: "0 0 220px" }}>
595
+ <label>新增模型匹配</label>
596
+ <input className="dtok-input" style={{ maxWidth: 220 }} type="text" list="dtok-model-candidates" placeholder="如 deepseek-v4-flash 或某中转模型名" value={newMatch} onChange={(e) => setNewMatch(e.target.value)} />
597
+ <datalist id="dtok-model-candidates">{candidates.map((m) => <option key={m} value={m} />)}</datalist>
598
+ </div>
599
+ <button className="dtok-btn" onClick={() => addRow()}>+ 添加</button>
600
+ </div>
601
+ <div className="dtok-price-hint">提示:匹配是「模型名包含该子串」,可只写关键片段(如 <code>v4-flash</code>);越具体的条目建议放越前(当前按列表顺序命中)。</div>
602
+ </div>
603
+
604
+ <div>
605
+ <div className="dtok-section-title" style={{ margin: "0 0 4px" }}>兜底单价(未匹配任何条目时使用)</div>
606
+ <div className="dtok-price-grid">
607
+ <div className="dtok-price-field"><label>输入(缓存命中)</label>{num(fb.cacheRead, (v) => setFb((s) => Object.assign({}, s, { cacheRead: v })))}</div>
608
+ <div className="dtok-price-field"><label>输入(缓存未命中)</label>{num(fb.input, (v) => setFb((s) => Object.assign({}, s, { input: v })))}</div>
609
+ <div className="dtok-price-field"><label>输出</label>{num(fb.output, (v) => setFb((s) => Object.assign({}, s, { output: v })))}</div>
610
+ <div className="dtok-price-field"><label>缓存写入</label>{num(fb.cacheWrite, (v) => setFb((s) => Object.assign({}, s, { cacheWrite: v })))}</div>
611
+ </div>
612
+ </div>
613
+
614
+ {cfg && cfg.builtin && cfg.builtin.length > 0 ? (
615
+ <details>
616
+ <summary className="dtok-price-hint" style={{ cursor: "pointer" }}>查看内置默认单价({cfg.builtin.length} 条,自定义配置未命中时按此兜底)</summary>
617
+ <div className="dtok-table-wrap" style={{ marginTop: 6 }}>
618
+ <table className="dtok-table">
619
+ <thead><tr><th>匹配</th><th>输入(命中)</th><th>输入(未命中)</th><th>输出</th><th>缓存写入</th><th>分时段</th></tr></thead>
620
+ <tbody>
621
+ {cfg.builtin.map((b) => (
622
+ <tr key={b.match}>
623
+ <td>{b.match}</td><td>{b.cacheRead}</td><td>{b.input}</td><td>{b.output}</td><td>{b.cacheWrite}</td>
624
+ <td>{b.peaks && b.peaks.length ? b.peaks.map((s) => s.start + "~" + s.end).join("、") + "时" : "—"}</td>
625
+ </tr>
626
+ ))}
627
+ </tbody>
628
+ </table>
629
+ </div>
630
+ </details>
631
+ ) : null}
632
+
633
+ <div className="dtok-price-grid">
634
+ <button className="dtok-btn primary" disabled={saving} onClick={save}>{saving ? "保存中…" : "保存单价"}</button>
635
+ {msg ? <span className={"dtok-price-msg " + (msg.ok ? "ok" : "err")}>{msg.text}</span> : null}
636
+ </div>
637
+ </div>
638
+ );
639
+ }
640
+
641
+ // ---------- 查询加载动画(数据多时要等几秒,用有梗的画面把等待变得好过一点) ----------
642
+ // 每 1.3s 换一句文案;等久了(第 4 句起)语气变一变,顺便给个「数据较多」的安抚。
643
+ const LOAD_MSGS = [
644
+ "正在翻开账本",
645
+ "正在一枚一枚数 Token",
646
+ "正在给每条调用贴价签",
647
+ "正在核对缓存命中率",
648
+ "正在把单价乘进每一行",
649
+ "正在把表格码整齐",
650
+ ];
651
+ function LoadingBanner() {
652
+ const [tick, setTick] = useState(0);
653
+ useEffect(() => {
654
+ const timer = setInterval(() => setTick((t) => t + 1), 1300);
655
+ return () => clearInterval(timer);
656
+ }, []);
657
+ const long = tick >= 4;
658
+ return (
659
+ <div className="dtok-loading" role="status" aria-live="polite">
660
+ <span className="dtok-load-scene" aria-hidden="true">
661
+ <span className="dtok-load-spark s1">✦</span>
662
+ <span className="dtok-load-emoji e1">🧮</span>
663
+ <span className="dtok-load-emoji e2">📒</span>
664
+ <span className="dtok-load-emoji e3">🔍</span>
665
+ <span className="dtok-load-spark s2">✦</span>
666
+ </span>
667
+ <span className="dtok-load-msg">
668
+ <b key={tick}>{LOAD_MSGS[tick % LOAD_MSGS.length]}…</b>
669
+ <span className={"dtok-load-sub" + (long ? " wait" : "")}>
670
+ {long ? "数据较多,再等一小会儿,正在全力清点~" : "正在按当前条件重新统计,通常几秒内完成"}
671
+ </span>
672
+ </span>
673
+ <span className="dtok-load-track" aria-hidden="true"><span className="dtok-load-fill" /></span>
674
+ </div>
675
+ );
676
+ }
677
+
678
+ // ---------- 主视图(嵌入 dock 面板内容区) ----------
679
+ export function TokenLogView(props) {
680
+ // props.params.sessionId(chips 点击带入):立即按该会话筛选并查询。
681
+ // navAt:每次 chip 点击的时间戳——面板已打开时再次点击(即便同一会话)也触发重查。
682
+ const navSession = props && props.params && props.params.sessionId ? props.params.sessionId : null;
683
+ const navAt = props && props.params && props.params.navAt ? props.params.navAt : 0;
684
+ // 挂载即视为「打开」:恢复上次暂存的条件(时间不选=显示全部记录); savedFilters 稳定快照, 仅初始化时读取一次
685
+ const [savedFilters] = useState(loadSavedFilters);
686
+ const [fromStr, setFromStr] = useState(() => (savedFilters && savedFilters.fromStr) || "");
687
+ const [toStr, setToStr] = useState(() => (savedFilters && savedFilters.toStr) || "");
688
+ const [provider, setProvider] = useState(() => (savedFilters && savedFilters.provider) || "");
689
+ const [model, setModel] = useState(() => (savedFilters && savedFilters.model) || "");
690
+ const [status, setStatus] = useState(() => (savedFilters && savedFilters.status) || "");
691
+ const [effort, setEffort] = useState(() => (savedFilters && savedFilters.effort) || "");
692
+ // 会话入口(chip/深链)带入的 sessionId 优先于暂存条件:下拉框初值即选中该会话
693
+ const [sessionId, setSessionId] = useState(() => navSession || (savedFilters && savedFilters.sessionId) || "");
694
+ const [dim, setDim] = useState(() => (savedFilters && savedFilters.dim) || "");
695
+ const [sortKey, setSortKey] = useState("time");
696
+ const [sortDir, setSortDir] = useState("desc");
697
+ const [data, setData] = useState(null);
698
+ const [loading, setLoading] = useState(false);
699
+ const [err, setErr] = useState("");
700
+ const [page, setPage] = useState(0);
701
+ const [detailRec, setDetailRec] = useState(null);
702
+ // 「单价设置」折叠面板:默认收起(费用不准确时才需要展开调整)
703
+ // 「单价设置」子弹窗:默认收起;支持经 params.openPricing 直接打开(深链/测试用)
704
+ const [showPricing, setShowPricing] = useState(() => !!(props && props.params && props.params.openPricing));
705
+ // 查询完成信号:loading 由 true→false 时记一次时间戳,用于「✓ 数据已更新」提示与 KPI 卡回弹。
706
+ // 5 秒静默自动刷新不置 loading,所以轮询不会每 5 秒闪一下。
707
+ const [fetchedAt, setFetchedAt] = useState(0);
708
+ const [justUpdated, setJustUpdated] = useState(false);
709
+ const wasLoadingRef = useRef(false);
710
+ useEffect(() => {
711
+ if (wasLoadingRef.current && !loading && !err) setFetchedAt(Date.now());
712
+ wasLoadingRef.current = loading;
713
+ }, [loading, err]);
714
+ // 「✓ 数据已更新」亮 2.4s 后必须真卸载(只靠动画 forwards 会留下一个透明的占位元素,
715
+ // 状态行会莫名多出一截空隙);按 fetchedAt 重置计时,连续查询也不会提前熄灭。
716
+ useEffect(() => {
717
+ if (!fetchedAt) return;
718
+ setJustUpdated(true);
719
+ const timer = setTimeout(() => setJustUpdated(false), 2400);
720
+ return () => clearTimeout(timer);
721
+ }, [fetchedAt]);
722
+ const pageSize = 100;
723
+
724
+ // 暂存筛选条件: 任一筛选变化即写入 localStorage, 下次打开恢复同样条件
725
+ useEffect(() => {
726
+ saveFilters({ fromStr, toStr, provider, model, status, effort, sessionId, dim });
727
+ }, [fromStr, toStr, provider, model, status, effort, sessionId, dim]);
728
+
729
+ // 挂载时: 扫描历史后按暂存条件查询(未设置时间则不限制, 显示全部记录)
730
+ useEffect(() => {
731
+ let cancel = false;
732
+ setLoading(true); setErr("");
733
+ const q = {};
734
+ if (savedFilters) {
735
+ if (savedFilters.fromStr) q.from = new Date(savedFilters.fromStr).getTime();
736
+ if (savedFilters.toStr) q.to = new Date(savedFilters.toStr).getTime();
737
+ if (savedFilters.provider) q.provider = savedFilters.provider;
738
+ if (savedFilters.model) q.model = savedFilters.model;
739
+ if (savedFilters.status) q.status = savedFilters.status;
740
+ if (savedFilters.effort) q.effort = savedFilters.effort;
741
+ if (savedFilters.sessionId) q.sessionId = savedFilters.sessionId;
742
+ if (savedFilters.dim) q.dim = savedFilters.dim;
743
+ }
744
+ // 从会话入口点入时按带入的会话查(优先于暂存条件):打开即出当前会话结果,
745
+ // 不再需要手动点「查询」。只保留这一条初始查询,避免并发的会话查询被本链路覆盖。
746
+ if (navSession) q.sessionId = navSession;
747
+ rpcCall("scan", {})
748
+ .then(() => (cancel ? null : rpcCall("query", q)))
749
+ .then((d) => {
750
+ if (cancel) return;
751
+ setData(d);
752
+ })
753
+ .catch((e) => { if (!cancel) setErr(String((e && e.message) || e)); })
754
+ .finally(() => { if (!cancel) setLoading(false); });
755
+ return () => { cancel = true; };
756
+ }, [savedFilters]);
757
+
758
+ const buildQ = useCallback((withDim, dimOverride) => {
759
+ const q = {};
760
+ if (fromStr) q.from = new Date(fromStr).getTime();
761
+ if (toStr) q.to = new Date(toStr).getTime();
762
+ if (provider) q.provider = provider;
763
+ if (model) q.model = model;
764
+ if (status) q.status = status;
765
+ if (effort) q.effort = effort;
766
+ if (sessionId) q.sessionId = sessionId;
767
+ const dimNow = dimOverride !== undefined ? dimOverride : dim;
768
+ if (withDim && dimNow) q.dim = dimNow;
769
+ return q;
770
+ }, [fromStr, toStr, provider, model, status, effort, sessionId, dim]);
771
+
772
+ // dimOverride:分组按钮点击时按「即将切换到」的维度立即查询(闭包里的 dim 还是旧值)
773
+ const runQuery = useCallback((dimOverride) => {
774
+ setLoading(true); setErr("");
775
+ rpcCall("query", buildQ(true, dimOverride))
776
+ .then((d) => { setData(d); setPage(0); })
777
+ .catch((e) => setErr(String((e && e.message) || e)))
778
+ .finally(() => setLoading(false));
779
+ }, [buildQ]);
780
+
781
+ // chips 定位:面板已打开时再次从会话入口点击 → 立即按该会话筛选查询。
782
+ // 挂载时的首次带入已并入初始查询(sessionId 初值 + 挂载 effect),以 navAt 判同跳过,
783
+ // 避免打开面板时同一条件跑两遍、且挂载查询链路较慢把会话结果覆盖回全量。
784
+ const navAppliedRef = useRef(navAt);
785
+ useEffect(() => {
786
+ if (!navSession || navAppliedRef.current === navAt) return;
787
+ navAppliedRef.current = navAt;
788
+ setSessionId(navSession);
789
+ setLoading(true); setErr("");
790
+ rpcCall("query", Object.assign({}, buildQ(true), { sessionId: navSession }))
791
+ .then((d) => { setData(d); setPage(0); })
792
+ .catch((e) => setErr(String((e && e.message) || e)))
793
+ .finally(() => setLoading(false));
794
+ }, [navAt]);
795
+
796
+ // USD→CNY 汇率: host 返回(默认 7.2, 可被 settings dsh-dock.tokenlog.usdCnyRate 覆盖)。
797
+ // 必须在所有引用它的表达式(cards/summaryRows/detailRows/Detail/exportCsv)之前声明,
798
+ // 否则 const 的 TDZ 会让组件渲染或导出直接抛错。
799
+ const rateCny = (data && data.rateUsdCny) || 7.2;
800
+
801
+ // 重置: 清空所有筛选(时间不选=显示全部记录), 并立即查询
802
+ const resetFilters = useCallback(() => { setFromStr(""); setToStr("");
803
+ setProvider(""); setModel(""); setStatus(""); setEffort(""); setSessionId(""); setDim("");
804
+ setLoading(true); setErr("");
805
+ rpcCall("query", {})
806
+ .then((d) => { setData(d); setPage(0); })
807
+ .catch((e) => setErr(String((e && e.message) || e)))
808
+ .finally(() => setLoading(false));
809
+ }, []);
810
+
811
+ const exportCsv = useCallback(() => {
812
+ rpcCall("export", buildQ(false))
813
+ .then((d) => {
814
+ // host 返回结构化 rows 时本地格式化(中文表头、与页面同款取值/合计行)。
815
+ // 旧宿主进程只回 csv 字段(英文表头):回退到原样导出,避免导出空文件。
816
+ const csv = d && Array.isArray(d.rows)
817
+ ? buildExportCsv(d.rows, (d && d.rateUsdCny) || rateCny)
818
+ : String((d && d.csv) || "");
819
+ // BOM 前置: 否则 Excel 按本地编码打开, 中文表头会乱码
820
+ const blob = new Blob(["\ufeff" + csv], { type: "text/csv;charset=utf-8" });
821
+ const url = URL.createObjectURL(blob);
822
+ const a = document.createElement("a");
823
+ a.href = url;
824
+ a.download = "dsh-dock-tokenlog-" + Date.now() + ".csv";
825
+ a.click();
826
+ URL.revokeObjectURL(url);
827
+ })
828
+ .catch((e) => setErr(String((e && e.message) || e)));
829
+ }, [buildQ, rateCny]);
830
+
831
+ // 自动刷新: 挂载期间每 5s 按当前筛选静默刷新(不闪烁 loading)。
832
+ // 注意: 依赖数组 [buildQ] 在声明时即求值, 必须放在 buildQ 定义之后(否则 TDZ 崩溃)。
833
+ useEffect(() => {
834
+ const timer = setInterval(() => {
835
+ rpcCall("query", buildQ(true))
836
+ .then((d) => { setData(d); setErr(""); })
837
+ .catch(() => {});
838
+ }, 5000);
839
+ return () => clearInterval(timer);
840
+ }, [buildQ]);
841
+
842
+ const toggleSort = useCallback((key) => {
843
+ if (sortKey === key) setSortDir(sortDir === "asc" ? "desc" : "asc");
844
+ else { setSortKey(key); setSortDir("asc"); }
845
+ }, [sortKey, sortDir]);
846
+
847
+ const records = (data && data.records) || [];
848
+ const totals = data && data.totals;
849
+ const sessionIds = (data && data.sessionIds) || [];
850
+ const sorted = useMemo(() => {
851
+ const arr = records.slice();
852
+ const key = sortKey;
853
+ const dir = sortDir === "asc" ? 1 : -1;
854
+ arr.sort((a, b) => {
855
+ const av = a[key]; const bv = b[key];
856
+ if (av === null || av === undefined || av === "") return 1;
857
+ if (bv === null || bv === undefined || bv === "") return -1;
858
+ if (typeof av === "string") return av.localeCompare(String(bv)) * dir;
859
+ return (Number(av) - Number(bv)) * dir;
860
+ });
861
+ return arr;
862
+ }, [records, sortKey, sortDir]);
863
+ const pageCount = Math.max(1, Math.ceil(sorted.length / pageSize));
864
+ const pageSafe = Math.min(page, pageCount - 1);
865
+ const pageRows = sorted.slice(pageSafe * pageSize, (pageSafe + 1) * pageSize);
866
+
867
+ const cards = totals ? [
868
+ { v: fmtNum(totals.calls), l: "调用次数" },
869
+ { v: fmtCompact(totals.totalTokens), l: "总 Token" },
870
+ { v: fmtCompact(totals.cacheReadTokens), l: "输入(命中)" },
871
+ { v: fmtCompact(totals.inputTokens), l: "输入(未命中)" },
872
+ { v: totals.cacheHitPct + "%", l: "缓存命中率" },
873
+ { v: fmtCompact(totals.outputTokens), l: "输出" },
874
+ { v: fmtCostCny(totals.cost, rateCny), l: "消耗金额(估算·人民币)" },
875
+ { v: fmtDuration(totals.llmMs), l: "累计耗时" + (totals.timed ? " (" + totals.timed + "步)" : "") },
876
+ ] : [];
877
+
878
+ const opts = (arr) => (arr || []).map((x) => <option key={x || "(none)"} value={x}>{x || "(空)"}</option>);
879
+
880
+ // 模型下拉联动: 选中提供商后只显示该提供商下的模型; 未选提供商显示全部模型。
881
+ // modelsByProvider 由 host 返回({provider: [models]})。
882
+ const modelChoices = provider && data && data.modelsByProvider
883
+ ? (data.modelsByProvider[provider] || [])
884
+ : (data && data.models) || [];
885
+
886
+ const summaryRows = (data && data.summary || []).map((r) => (
887
+ <tr key={r.key}>
888
+ <td>{r.key}</td><td>{fmtNum(r.calls)}</td><td>{fmtCompact(r.cacheReadTokens)}</td>
889
+ <td>{fmtCompact(r.inputTokens)}</td><td>{r.cacheHitPct + "%"}</td><td>{fmtCompact(r.outputTokens)}</td>
890
+ <td>{fmtCompact(r.totalTokens)}</td>
891
+ <td>{fmtCostCny(r.cost, rateCny)}</td>
892
+ <td>{fmtDuration(r.llmMs)}</td>
893
+ </tr>
894
+ ));
895
+
896
+ const sortTh = (label, key) => (
897
+ <th onClick={() => toggleSort(key)} title="点击排序">
898
+ {label}{sortKey === key ? <span className="dtok-sort-mark">{sortDir === "asc" ? "▲" : "▼"}</span> : null}
899
+ </th>
900
+ );
901
+
902
+ const detailRows = pageRows.map((r) => {
903
+ const st = statusInfo(r);
904
+ return (
905
+ <tr key={r.id}>
906
+ <td>{fmtTime(r.time)}</td>
907
+ <td><span className="dtok-sid" title="点击按此会话筛选" onClick={() => { setSessionId(r.sessionId); runQuery(); }}>{shortId(r.sessionId)}</span></td>
908
+ <td>{r.provider || "—"}</td>
909
+ <td>{r.model || "—"}</td>
910
+ <td>{fmtNum(r.cacheReadTokens)}</td>
911
+ <td>{fmtNum(r.inputTokens)}</td>
912
+ <td>{r.cacheHitPercent + "%"}</td>
913
+ <td>{fmtNum(r.outputTokens)}</td>
914
+ <td>{fmtNum(r.reasoningTokens)}</td>
915
+ <td>{fmtNum(r.totalTokens)}</td>
916
+ <td title={r.pricingSource ? "计价来源:" + ({ custom: "自定义单价", official: "官网同步价", builtin: "内置默认价", fallback: "兜底单价" }[r.pricingSource] || r.pricingSource) : ""}>
917
+ {fmtCostCny(r.cost, rateCny)}
918
+ {r.pricingSource === "fallback" ? <span className="dtok-src fallback">{" "}兜底</span> : null}
919
+ </td>
920
+ <td>{r.effort || "—"}</td>
921
+ <td>
922
+ <div><span className={"dtok-code " + st.cls}>{st.label}</span></div>
923
+ <div><a className="dtok-detail-link" onClick={() => setDetailRec(r)}>查看详情</a></div>
924
+ </td>
925
+ <td>{fmtDuration(r.llmMs)}</td>
926
+ </tr>
927
+ );
928
+ });
929
+
930
+ const bodyNodes = [];
931
+ if (err) bodyNodes.push(<div key="err" className="dtok-err">错误: {err}</div>);
932
+ if (cards.length) bodyNodes.push(
933
+ // key 带 fetchedAt:每次「手动查询 / 首次加载」拿到新数据时重建一次,KPI 卡重播回弹动画
934
+ <div className={"dtok-cards" + (fetchedAt ? " dtok-pop" : "")} key={"cards-" + fetchedAt}>
935
+ {cards.map((c, i) => <div className="dtok-card" key={c.l} style={{ "--i": String(i) }}><div className="v">{c.v}</div><div className="l">{c.l}</div></div>)}
936
+ </div>
937
+ );
938
+ if (summaryRows.length) bodyNodes.push(
939
+ <div key="sum" className="dtok-section-title">统计分组: {dim || "无"} ({summaryRows.length} 组)</div>,
940
+ <div key="sumtab" className="dtok-table-wrap">
941
+ <table className="dtok-table">
942
+ <thead><tr>
943
+ <th>维度</th><th>调用</th><th>输入(命中)</th><th>输入(未命中)</th><th>命中率</th><th>输出</th><th>总Token</th><th>金额(人民币)</th><th>耗时</th>
944
+ </tr></thead>
945
+ <tbody>{summaryRows}</tbody>
946
+ </table>
947
+ </div>
948
+ );
949
+ bodyNodes.push(<div key="dimtitle" className="dtok-section-title">按维度统计</div>);
950
+ bodyNodes.push(
951
+ <div key="dimrow" className="dtok-filter" style={{ padding: "4px 0 0", border: "none", background: "transparent" }}>
952
+ {["", "provider", "model", "status", "effort"].map((d) => (
953
+ <button key={d} className={"dtok-btn" + (dim === d ? " primary" : "")} onClick={() => { setDim(d); runQuery(d); }}>{d === "" ? "无分组" : d}</button>
954
+ ))}
955
+ </div>
956
+ );
957
+ bodyNodes.push(<div key="dettitle" className="dtok-section-title">调用明细</div>);
958
+ if (sorted.length === 0) {
959
+ bodyNodes.push(<div key="empty" className="dtok-empty">{loading ? "加载中…" : "无匹配记录"}</div>);
960
+ } else {
961
+ // 分页控件: 明细表上方+下方各一份, 免去翻页时滑到底部
962
+ const pager = (key, cls) => (
963
+ <div key={key} className={"dtok-pager " + cls}>
964
+ <button className="dtok-btn" disabled={pageSafe <= 0} onClick={() => setPage(pageSafe - 1)}>上一页</button>
965
+ <span>第 {pageSafe + 1} / {pageCount} 页 · 共 {sorted.length} 条</span>
966
+ <button className="dtok-btn" disabled={pageSafe >= pageCount - 1} onClick={() => setPage(pageSafe + 1)}>下一页</button>
967
+ </div>
968
+ );
969
+ bodyNodes.push(pager("pager-top", "top"));
970
+ bodyNodes.push(
971
+ <div key="detail" className="dtok-table-wrap">
972
+ <table className="dtok-table">
973
+ <thead><tr>
974
+ {sortTh("时间", "time")}{sortTh("会话ID", "sessionId")}{sortTh("提供商", "provider")}{sortTh("模型", "model")}
975
+ {sortTh("输入(命中)", "cacheReadTokens")}{sortTh("输入(未命中)", "inputTokens")}{sortTh("命中%", "cacheHitPercent")}{sortTh("输出", "outputTokens")}
976
+ {sortTh("推理", "reasoningTokens")}{sortTh("总额", "totalTokens")}{sortTh("金额(人民币)", "cost")}{sortTh("强度", "effort")}
977
+ {sortTh("状态", "status")}{sortTh("耗时", "llmMs")}
978
+ </tr></thead>
979
+ <tbody>{detailRows}</tbody>
980
+ </table>
981
+ </div>
982
+ );
983
+ bodyNodes.push(pager("pager-bottom", "bottom"));
984
+ }
985
+
986
+ return (
987
+ <div className="dtok-root" onClick={(e) => e.stopPropagation()}>
988
+ <div className="dtok-status">
989
+ <span className="count">
990
+ {loading ? (
991
+ <span className="dtok-busy">
992
+ <span className="dtok-spin inline" aria-hidden="true" />
993
+ 加载中<span className="dtok-dots" aria-hidden="true"><i>.</i><i>.</i><i>.</i></span>
994
+ </span>
995
+ ) : (data ? data.counts.matching + " / " + data.counts.total + " 条" : "")}
996
+ </span>
997
+ {!loading && justUpdated && !err ? <span key={fetchedAt} className="dtok-updated">✓ 数据已更新</span> : null}
998
+ <span>全屏请用面板右上角「最大化」;面板打开期间每 5 秒自动刷新</span>
999
+ </div>
1000
+ <div className="dtok-filter">
1001
+ <label>起</label>
1002
+ <input className="dtok-input" type="datetime-local" step="1" value={fromStr} onChange={(e) => setFromStr(e.target.value)} />
1003
+ <label>止</label>
1004
+ <input className="dtok-input" type="datetime-local" step="1" value={toStr} onChange={(e) => setToStr(e.target.value)} />
1005
+ <label>会话</label>
1006
+ <select className="dtok-select" value={sessionId} onChange={(e) => setSessionId(e.target.value)}>
1007
+ <option value="">全部</option>{opts(sessionIds)}
1008
+ </select>
1009
+ <label>提供商</label>
1010
+ <select className="dtok-select" value={provider} onChange={(e) => { setProvider(e.target.value); setModel("") }}>
1011
+ <option value="">全部</option>{opts(data && data.providers)}
1012
+ </select>
1013
+ <label>模型</label>
1014
+ <select className="dtok-select" value={model} onChange={(e) => setModel(e.target.value)}>
1015
+ <option value="">全部</option>{opts(modelChoices)}
1016
+ </select>
1017
+ <label>状态</label>
1018
+ <select className="dtok-select" value={status} onChange={(e) => setStatus(e.target.value)}>
1019
+ <option value="">全部</option>{opts(data && data.statuses)}
1020
+ </select>
1021
+ <label>推理强度</label>
1022
+ <select className="dtok-select" value={effort} onChange={(e) => setEffort(e.target.value)}>
1023
+ <option value="">全部</option>{opts(data && data.efforts)}
1024
+ </select>
1025
+ <button className={"dtok-btn primary" + (loading ? " loading" : "")} disabled={loading} title="按当前条件查询" onClick={() => runQuery()}>
1026
+ {loading ? <span className="dtok-spin" aria-hidden="true" /> : null}{loading ? "查询中…" : "查询"}
1027
+ </button>
1028
+ <button className="dtok-btn" onClick={resetFilters}>重置</button>
1029
+ <button className="dtok-btn" onClick={exportCsv}>导出 CSV</button>
1030
+ <button className={"dtok-btn" + (showPricing ? " primary" : "")} onClick={() => setShowPricing(true)} title="配置各模型单价,费用按自填单价计算">单价设置</button>
1031
+ </div>
1032
+ {showPricing ? <PriceModal onClose={() => setShowPricing(false)} onSaved={runQuery} /> : null}
1033
+ <div className="dtok-status">
1034
+ {data && data.pricingInfo ? (
1035
+ <span>
1036
+ 计价:{data.pricingInfo.hasCustom ? "自定义单价 " + data.pricingInfo.customCount + " 条" : "未配置自定义单价"}
1037
+ {" · 官网自动同步:" + (data.pricingInfo.fetchOfficial ? "开" : "关")}
1038
+ {data.pricingInfo.sources && data.pricingInfo.sources.length ? " · 本页涉及来源:" + data.pricingInfo.sources.join("/") : ""}
1039
+ </span>
1040
+ ) : null}
1041
+ </div>
1042
+ <div className="dtok-body">
1043
+ {loading ? <LoadingBanner /> : null}
1044
+ {/* 加载期间旧数据整体压暗并禁用点击:既表明「这不是最新结果」,也避免照旧点进详情看错数 */}
1045
+ <div className={"dtok-body-inner" + (loading ? " busy" : "")}>{bodyNodes}</div>
1046
+ </div>
1047
+ {detailRec ? <Detail rec={detailRec} onClose={() => setDetailRec(null)} rate={rateCny} /> : null}
1048
+ </div>
1049
+ );
1050
+ }
1051
+
1052
+ // ---------- 首页总揽卡片:今日调用 / 今日 Token / 今日花费 ----------
1053
+ function todayStart() {
1054
+ const d = new Date();
1055
+ d.setHours(0, 0, 0, 0);
1056
+ return d.getTime();
1057
+ }
1058
+ function TokenLogHomeStat() {
1059
+ const [snap, setSnap] = useState({ totals: null, rate: 7.2, err: "" });
1060
+ useEffect(() => {
1061
+ let cancel = false;
1062
+ const load = () => rpcCall("query", { from: todayStart() })
1063
+ .then((d) => { if (!cancel) setSnap({ totals: d && d.totals, rate: (d && d.rateUsdCny) || 7.2, err: "" }); })
1064
+ .catch((e) => { if (!cancel) setSnap((s) => ({ totals: s.totals, rate: s.rate, err: String((e && e.message) || e) })); });
1065
+ load();
1066
+ const timer = setInterval(load, 60000);
1067
+ return () => { cancel = true; clearInterval(timer); };
1068
+ }, []);
1069
+ if (!snap.totals) return <span>{snap.err ? "用量查询失败(点击进入查看)" : "正在拉取今日用量…"}</span>;
1070
+ const t = snap.totals;
1071
+ return <span>今日 {fmtNum(t.calls)} 次调用 · {fmtCompact(t.totalTokens)} Token · {fmtCostCny(t.cost, snap.rate)}</span>;
1072
+ }
1073
+
1074
+ // ---------- 会话输入区 chip:显示当前会话总 Token(10s 静默刷新),点击打开功能坞并按该会话筛选 ----------
1075
+ export function TokenLogChip(props) {
1076
+ const sid = (props && props.sessionId) || (props && props.session && props.session.sessionId) || null;
1077
+ const [snap, setSnap] = useState({ totals: null, rate: 7.2, err: "" });
1078
+ useEffect(() => {
1079
+ if (!sid) return;
1080
+ let cancel = false;
1081
+ const load = () => rpcCall("query", { sessionId: sid })
1082
+ .then((d) => { if (!cancel) setSnap({ totals: d && d.totals, rate: (d && d.rateUsdCny) || 7.2, err: "" }); })
1083
+ .catch((e) => { if (!cancel) setSnap((s) => ({ totals: s.totals, rate: s.rate, err: String((e && e.message) || e) })); });
1084
+ load();
1085
+ const timer = setInterval(load, 10000);
1086
+ return () => { cancel = true; clearInterval(timer); };
1087
+ }, [sid]);
1088
+ if (!sid) return null;
1089
+ const t = snap.totals;
1090
+ // chip 文案用紧凑人民币金额(2 位小数),避免撑爆输入卡工具行;
1091
+ // 完整金额在 title 悬浮提示里。
1092
+ const label = snap.err && !t
1093
+ ? (snap.err.includes("重启") ? "用量·需重启宿主" : "用量·失败")
1094
+ : (t ? "⛁ " + fmtCompact(t.totalTokens) + (t.cost > 0 ? " · " + fmtCostCny(t.cost, snap.rate) : "") : "⛁ …");
1095
+ const title = t
1096
+ ? "本会话 " + fmtNum(t.calls) + " 次调用 · " + fmtNum(t.totalTokens) + " Token · " + fmtCostCny(t.cost, snap.rate) + "(估算)\n点击在功能坞查看用量记录"
1097
+ : (snap.err ? snap.err + "\n" : "") + "点击在功能坞查看用量记录";
1098
+ return (
1099
+ <button type="button" className={"dockchip" + (snap.err && !t ? " err" : "")} title={title} aria-label="会话用量"
1100
+ onClick={() => openPanel("tokenlog", { sessionId: sid, navAt: Date.now() })}>
1101
+ <span className="dockchip-dot" style={{ background: "var(--dk-warn)" }} />
1102
+ <span>{label}</span>
1103
+ </button>
1104
+ );
1105
+ }
1106
+
1107
+ export const feature = {
1108
+ id: "tokenlog",
1109
+ name: "用量记录",
1110
+ order: 110,
1111
+ accent: "#fbbf24",
1112
+ description: "记录全部 LLM API 调用:秒级时间筛选、Token/费用统计(可配置各模型单价,持久保存并按自填单价计费;内置峰谷计价+官网价目自动同步作兜底)、分组汇总、明细检索与 CSV 导出",
1113
+ css,
1114
+ View: TokenLogView,
1115
+ HomeStat: TokenLogHomeStat,
1116
+ Chip: TokenLogChip,
1117
+ };