@angelyeye/dsh-cost-tracker 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js ADDED
@@ -0,0 +1,1255 @@
1
+ // ============================================================
2
+ // DSH 花费统计插件 —— Client 半端(静态版)
3
+ // 由动态版 cost-tracker.client.js 改造而来:
4
+ // host.call → fetch('/api/cost-tracker/<method>')
5
+ // styles.insert → 原生 <style> 标签注入
6
+ // React → require('react')
7
+ // 输出为浏览器 ModuleLoader bundle:exports.apply / exports.inject
8
+ // ============================================================
9
+ window.__ModuleLoader__.load({
10
+ id: "dsh-cost-tracker",
11
+ factory: (require) => {
12
+ var module = { exports: {} };
13
+ var exports = module.exports;
14
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
15
+ const React = require("react");
16
+
17
+ // ---------- api helper (replaces host.call) ----------
18
+ function apiCall(method, args) {
19
+ return fetch("/api/cost-tracker/" + method, {
20
+ method: "POST",
21
+ headers: { "content-type": "application/json" },
22
+ body: JSON.stringify(args || {}),
23
+ }).then((r) => r.json());
24
+ }
25
+
26
+ const e = React.createElement;
27
+ const useState = React.useState;
28
+ const useEffect = React.useEffect;
29
+ const useRef = React.useRef;
30
+
31
+ const BLUE = "var(--dsw-alias-state-business-primary, #4176e6)";
32
+ const AMBER = "var(--dsw-alias-state-warn-primary, #d97706)";
33
+ const GREEN = "var(--dsw-alias-state-success-primary, #16a34a)";
34
+ const RED = "var(--dsw-alias-state-error-primary, #dc2626)";
35
+ const GRAY = "var(--dsw-alias-label-tertiary, #9ca3af)";
36
+ const INK2 = "var(--dsw-alias-label-secondary, #6b7280)";
37
+ const GRID = "var(--dsw-alias-border-l1, #e5e7eb)";
38
+ const TOKEN_COLORS = ["rgb(30,64,175)", "rgb(37,99,235)", "rgb(96,165,250)", "rgb(191,219,254)"];
39
+ const TOKEN_NAMES = ["输入", "缓存写入", "输出", "缓存命中"];
40
+ // 「顺序渐变色阶」配色方案:第 1 名(总消费最高)最深垫底,名次越靠后越浅,
41
+ // 色相按各自方向轻微漂移。三套可选(页内切换,localStorage 记忆):
42
+ // 橙→黄:hue 20°→64° 蓝→紫:hue 212°→250° 蓝→浅蓝:hue 212°→200°
43
+ const COLOR_SCHEMES = {
44
+ "orange-yellow": { label: "橙→黄", dot: "#F96100", hue: 20, sat: 95, hueStep: 4 },
45
+ "blue-purple": { label: "蓝→紫", dot: "#2E7CE6", hue: 212, sat: 75, hueStep: 3.5 },
46
+ "blue-light": { label: "蓝→浅蓝", dot: "#7CC0F5", hue: 212, sat: 75, hueStep: -1 },
47
+ };
48
+ function schemeColor(i, s) {
49
+ const light = Math.min(50 + i * 5, 78);
50
+ const hue = s.hue + i * s.hueStep;
51
+ const sat = Math.max(s.sat - i * 2, 65);
52
+ return "hsl(" + hue + ", " + sat + "%, " + light + "%)";
53
+ }
54
+ // 色款:该方案从深(底)到浅(顶)的渐变条,直观展示色阶走向
55
+ function schemeSwatch(k) {
56
+ const s = COLOR_SCHEMES[k];
57
+ return "linear-gradient(to top, " + schemeColor(0, s) + ", " + schemeColor(7, s) + ")";
58
+ }
59
+ const MEMBERSHIP = { LEVEL_FREE: "免费版", LEVEL_BASIC: "基础版", LEVEL_INTERMEDIATE: "进阶版", LEVEL_ADVANCED: "高级版" };
60
+
61
+ // ---------- styles ----------
62
+ function applyStyles(ctx) {
63
+ const css = `
64
+ .cost-wrap { padding: 4px 2px 28px; color: var(--dsw-alias-label-primary, #171a1f); font-size: 13px; }
65
+ .cost-h1 { font-size: 16px; font-weight: 600; margin: 2px 0 12px; display: flex; align-items: center; gap: 8px; }
66
+ .cost-h1 .cost-icon { flex: none; }
67
+ .cost-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
68
+ .cost-spacer { flex: 1; }
69
+ .cost-hint { color: var(--dsw-alias-label-secondary, #5b6472); font-size: 12px; }
70
+ .cost-err { color: var(--dsw-alias-state-error-primary, #dc2626); font-size: 12px; }
71
+ .cost-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; margin: 12px 0; }
72
+ .cost-card { background: var(--dsw-alias-bg-layer-1, #fff); border: 1px solid var(--dsw-alias-border-l1, #e5e7eb); border-radius: 10px; padding: 14px 16px; }
73
+ .cost-card-title { color: var(--dsw-alias-label-secondary, #5b6472); font-size: 12px; }
74
+ .cost-card-value { font-size: 24px; font-weight: 650; margin-top: 6px; font-variant-numeric: tabular-nums; }
75
+ .cost-card-sub { color: var(--dsw-alias-label-secondary, #5b6472); font-size: 12px; margin-top: 6px; }
76
+ .cost-panel { background: var(--dsw-alias-bg-layer-1, #fff); border: 1px solid var(--dsw-alias-border-l1, #e5e7eb); border-radius: 10px; padding: 14px 16px; margin: 12px 0; }
77
+ .cost-panel-title { font-size: 14px; font-weight: 600; }
78
+ .cost-tabs { display: inline-flex; border: 1px solid var(--dsw-alias-border-l1, #e5e7eb); border-radius: 999px; overflow: hidden; }
79
+ .cost-tab { padding: 3px 12px; font-size: 12px; cursor: pointer; background: transparent; border: none; color: var(--dsw-alias-label-secondary, #5b6472); }
80
+ .cost-tab-on { background: var(--dsw-alias-state-business-primary, #4176e6); color: #fff; }
81
+ .cost-badge { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: 11px; border: 1px solid var(--dsw-alias-border-l2, #d1d5db); color: var(--dsw-alias-label-secondary, #5b6472); }
82
+ .cost-btn { padding: 4px 12px; font-size: 12px; border-radius: 6px; border: 1px solid var(--dsw-alias-border-l2, #d1d5db); background: var(--dsw-alias-bg-layer-1, #fff); color: var(--dsw-alias-label-primary, #171a1f); cursor: pointer; }
83
+ .cost-btn:hover { background: var(--dsw-alias-bg-layer-2, #f3f4f6); }
84
+ .cost-btn:disabled { opacity: 0.6; cursor: default; }
85
+ .cost-select { padding: 4px 8px; font-size: 12px; border-radius: 6px; border: 1px solid var(--dsw-alias-border-l2, #d1d5db); background: var(--dsw-alias-bg-layer-1, #fff); color: var(--dsw-alias-label-primary, #171a1f); }
86
+ .cost-input { padding: 4px 8px; font-size: 12px; border-radius: 6px; border: 1px solid var(--dsw-alias-border-l2, #d1d5db); background: var(--dsw-alias-bg-layer-1, #fff); color: var(--dsw-alias-label-primary, #171a1f); width: 220px; }
87
+ .cost-legend { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 8px; font-size: 12px; color: var(--dsw-alias-label-secondary, #5b6472); }
88
+ .cost-dot { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 5px; }
89
+ .cost-bar-track { height: 8px; border-radius: 999px; background: var(--dsw-alias-bg-layer-2, #eef0f3); overflow: hidden; margin-top: 6px; }
90
+ .cost-bar-fill { height: 100%; border-radius: 999px; }
91
+ .cost-grid2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px; }
92
+ .cost-table { width: 100%; border-collapse: collapse; font-size: 12px; }
93
+ .cost-table th { text-align: left; color: var(--dsw-alias-label-secondary, #5b6472); font-weight: 500; padding: 6px 8px; border-bottom: 1px solid var(--dsw-alias-border-l1, #e5e7eb); }
94
+ .cost-table td { padding: 6px 8px; border-bottom: 1px solid var(--dsw-alias-border-l1, #eef0f2); font-variant-numeric: tabular-nums; }
95
+ .cost-dock { font-size: 12px; color: var(--dsw-alias-label-secondary, #5b6472); display: flex; gap: 6px; align-items: center; flex-wrap: wrap; line-height: 1; }
96
+ .cost-seg { display: inline-flex; align-items: center; gap: 6px; padding-right: 2px; }
97
+ .cost-seg + .cost-seg::before { content: ""; display: inline-block; width: 1px; height: 14px; margin-right: 6px; background: var(--dsw-alias-border-l2, #d1d5db); vertical-align: middle; }
98
+ .cost-pill { display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px; border-radius: 999px; border: 1px solid var(--dsw-alias-border-l2, #d1d5db); background: var(--dsw-alias-bg-layer-2, #f3f4f6); line-height: 1.3; }
99
+ .cost-dock-main { color: var(--dsw-alias-label-primary, #171a1f); font-weight: 500; font-variant-numeric: tabular-nums; letter-spacing: 0.2px; }
100
+ .cost-pill-sub { font-variant-numeric: tabular-nums; }
101
+ .cost-chip { display: inline-block; padding: 1px 7px; border-radius: 999px; font-size: 11px; border: 1px solid var(--dsw-alias-border-l2, #d1d5db); color: var(--dsw-alias-label-secondary, #5b6472); background: var(--dsw-alias-bg-layer-1, #fff); font-variant-numeric: tabular-nums; }
102
+ .cost-pill-sub-badge { border-color: var(--dsw-alias-state-warn-primary, #d97706); color: var(--dsw-alias-state-warn-primary, #d97706); background: color-mix(in srgb, var(--dsw-alias-state-warn-primary, #d97706) 8%, transparent); font-weight: 500; }
103
+ .cost-model-toggle { padding: 1px 6px; font-size: 11px; line-height: 1.4; border-radius: 6px; border: 1px solid var(--dsw-alias-border-l2, #d1d5db); background: transparent; color: var(--dsw-alias-label-secondary, #5b6472); cursor: pointer; }
104
+ .cost-model-toggle:hover { background: var(--dsw-alias-bg-layer-2, #f3f4f6); }
105
+ .cost-chart-host { position: relative; }
106
+ .cost-tip { position: absolute; top: 4px; background: var(--dsw-alias-bg-layer-1, #fff); border: 1px solid var(--dsw-alias-border-l1, #e5e7eb); border-radius: 10px; padding: 9px 12px; box-shadow: 0 6px 20px rgba(15, 23, 42, .10); font-size: 12px; pointer-events: none; white-space: nowrap; z-index: 10; }
107
+ .cost-tip-title { display: flex; justify-content: space-between; align-items: baseline; gap: 20px; font-weight: 600; margin-bottom: 5px; }
108
+ .cost-tip-title .cost-tip-total { font-variant-numeric: tabular-nums; }
109
+ .cost-tip-row { display: flex; align-items: center; gap: 6px; margin-top: 3px; }
110
+ .cost-tip-dot { display: inline-block; width: 8px; height: 8px; border-radius: 2px; flex: none; }
111
+ .cost-tip-name { flex: 1; color: var(--dsw-alias-label-secondary, #5b6472); padding-right: 20px; }
112
+ .cost-tip-val { font-variant-numeric: tabular-nums; text-align: right; }
113
+ /* 用量热力图(Codex 风格 26 周方格) */
114
+ .cost-ug { display: flex; flex-direction: column; gap: 12px; }
115
+ .cost-ug-total { font-size: 13px; color: var(--dsw-alias-label-primary, #171a1f); }
116
+ .cost-ug-grid { display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, auto); gap: 3px; width: 100%; }
117
+ .cost-ug-cell { width: 100%; aspect-ratio: 1/1; border-radius: 3px; box-sizing: border-box; background: color-mix(in srgb, var(--dsw-alias-label-primary, #171a1f) 8%, transparent); border: 1px solid var(--dsw-alias-border-l1, #e5e7eb); cursor: default; }
118
+ .cost-ug-cell.l1 { background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #4176e6) 25%, var(--dsw-alias-bg-layer-3, #eef0f3)); border-color: transparent; }
119
+ .cost-ug-cell.l2 { background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #4176e6) 50%, var(--dsw-alias-bg-layer-3, #eef0f3)); border-color: transparent; }
120
+ .cost-ug-cell.l3 { background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #4176e6) 75%, var(--dsw-alias-bg-layer-3, #eef0f3)); border-color: transparent; }
121
+ .cost-ug-cell.l4 { background: var(--dsw-alias-state-business-primary, #4176e6); border-color: transparent; }
122
+ .cost-ug-cell.today { outline: 1px solid var(--dsw-alias-label-secondary, #6b7280); outline-offset: 1px; }
123
+ .cost-ug-months { display: grid; grid-auto-flow: column; gap: 3px; width: 100%; font-size: 10px; color: var(--dsw-alias-label-tertiary, #9ca3af); margin-top: 4px; }
124
+ .cost-ug-month { white-space: nowrap; }
125
+ .cost-ug-host { position: relative; }
126
+ .cost-ug-tip { position: absolute; background: var(--dsw-alias-bg-layer-1, #fff); border: 1px solid var(--dsw-alias-border-l1, #e5e7eb); border-radius: 10px; padding: 9px 12px; box-shadow: 0 6px 20px rgba(15, 23, 42, .10); font-size: 12px; pointer-events: none; white-space: nowrap; z-index: 10; }
127
+ .cost-ug-tip-total { font-weight: 600; font-variant-numeric: tabular-nums; margin-bottom: 4px; }
128
+ .cost-ug-tip-row { display: flex; align-items: center; gap: 6px; margin-top: 3px; }
129
+ .cost-ug-tip-name { flex: 1; color: var(--dsw-alias-label-secondary, #5b6472); padding-right: 16px; }
130
+ .cost-ug-tip-val { font-variant-numeric: tabular-nums; text-align: right; }
131
+ /* 峰谷时段条(侧边栏 / 设置面板)——对标 dsh-cost-meter */
132
+ .cost-ps { display: flex; align-items: center; gap: 6px; min-width: 0; flex-wrap: wrap; }
133
+ .cost-ps-stack { flex-direction: column; align-items: stretch; gap: 4px; }
134
+ /* 双行紧凑:column 布局下轨道的 flex-basis(72px) 会落到纵轴变成高度,须收回,保持与单行一致的 6px 细条 */
135
+ .cost-ps-stack .cost-ps-track { flex: 0 0 auto; width: 100%; min-width: 0; }
136
+ .cost-ps-track { position: relative; display: flex; flex: 1 1 72px; min-width: 72px; height: 6px; border-radius: 999px; overflow: hidden; border: 1px solid var(--dsw-alias-border-l1, #e5e7eb); background: var(--dsw-alias-bg-layer-3, #eef0f3); }
137
+ .cost-ps-seg { height: 100%; flex: 1; }
138
+ /* 单行紧凑:24h 比例轨道(橙=高峰/蓝=平价按窗口比例绝对定位,白线=实时进度) */
139
+ .cost-ps-track .cost-ps-seg { position: absolute; top: 0; bottom: 0; flex: none; }
140
+ .cost-ps-peakseg { background: #ff9800; }
141
+ .cost-ps-offseg { background: var(--dsw-alias-state-business-primary, #4176e6); }
142
+ .cost-ps-marker { position: absolute; top: 0; left: 50%; width: 2px; height: 100%; background: var(--dsw-alias-bg-base, #fff); box-shadow: 0 0 0 1px var(--dsw-alias-label-tertiary, #9ca3af); transform: translateX(-50%); transition: left .4s ease; z-index: 2; }
143
+ .cost-ps-chip { font-size: 11px; font-weight: 600; line-height: 1.2; white-space: nowrap; color: var(--dsw-alias-label-secondary, #5b6472); }
144
+ .cost-ps.peak .cost-ps-chip { color: #ff9800; }
145
+ .cost-ps.off .cost-ps-chip { color: var(--dsw-alias-state-business-primary, #4176e6); }
146
+ .cost-ps.weekend .cost-ps-chip { color: #34a853; }
147
+ .cost-ps-foot { font-size: 11px; color: var(--dsw-alias-label-tertiary, #9ca3af); white-space: nowrap; }
148
+ /* 环形表盘(classic 改造):24h 中空圆环,蓝=平价、橙=高峰、绿=周末 */
149
+ .cost-ps-ring { flex-direction: column; align-items: center; justify-content: center; gap: 4px; }
150
+ .cost-ps-ring svg { display: block; max-width: 100%; }
151
+ .cost-ps-ringfoot { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: center; }
152
+ /* 窄栏(rail)态:只显示竖排短词 */
153
+ .cost-ps-rail { display: inline-flex; flex-direction: column; gap: 1px; }
154
+ .cost-ps-rail .cost-ps-word { font-size: 10px; font-weight: 600; line-height: 1.1; white-space: nowrap; color: var(--dsw-alias-label-secondary, #5b6472); }
155
+ .cost-ps-rail.peak .cost-ps-word { color: #ff9800; }
156
+ .cost-ps-rail.off .cost-ps-word { color: var(--dsw-alias-state-business-primary, #4176e6); }
157
+ .cost-ps-rail.weekend .cost-ps-word { color: #34a853; }
158
+ /* 侧边栏底部(sidebar.footer.action)多插件 UI 兼容:
159
+ DSH 渲染器把该槽锚点设为 display:contents(见 dsh-client-ui-renderer ANCHOR_STYLE),
160
+ 多个插件内容会被并进同一行(如与 linxin666/dsh-web-ui-all 冲突);改为纵向堆叠即可共存。
161
+ 采用稳定 data-slot 选择器,收起(rail)态仅显示短词、内容极窄,天然不受影响。 */
162
+ [data-slot="sidebar.footer.action"] { display: flex !important; flex-direction: column !important; align-items: stretch !important; gap: 4px !important; flex: 1 1 auto !important; min-width: 0 !important; }
163
+ /* 峰谷切换前弹窗 */
164
+ .cost-pa { position: fixed; z-index: 9999; width: 340px; max-width: calc(100vw - 32px); padding: 14px 16px; border-radius: 14px; background: var(--dsw-alias-bg-layer-2, #fff); border: 1px solid var(--dsw-alias-border-l2, #d1d5db); box-shadow: 0 14px 36px rgba(0,0,0,.22); display: flex; flex-direction: column; gap: 8px; font-size: 13px; animation: cost-pa-in .22s cubic-bezier(.2,.8,.2,1); }
165
+ .cost-pa.cost-pa-corner { right: 20px; bottom: 20px; }
166
+ .cost-pa.cost-pa-center { top: 50%; left: 50%; transform: translate(-50%,-50%); animation-name: cost-pa-in-center; }
167
+ .cost-pa-peak { border-top: 3px solid var(--dsw-alias-state-warn-primary, #d97706); }
168
+ .cost-pa-offpeak { border-top: 3px solid var(--dsw-alias-state-info-primary, #3b82f6); }
169
+ .cost-pa-badge { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; font-weight: 600; letter-spacing: .4px; text-transform: uppercase; }
170
+ .cost-pa-badge::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: currentColor; box-shadow: 0 0 0 4px color-mix(in srgb, currentColor 16%, transparent); }
171
+ .cost-pa-peak .cost-pa-badge { color: var(--dsw-alias-state-warn-primary, #d97706); }
172
+ .cost-pa-offpeak .cost-pa-badge { color: var(--dsw-alias-state-info-primary, #3b82f6); }
173
+ .cost-pa-title { font-size: 14px; font-weight: 600; color: var(--dsw-alias-label-primary, #171a1f); }
174
+ .cost-pa-body { color: var(--dsw-alias-label-secondary, #5b6472); line-height: 1.55; }
175
+ .cost-pa-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 2px; }
176
+ @keyframes cost-pa-in { from { opacity: 0; transform: translateY(10px) scale(.98); } to { opacity: 1; transform: translateY(0) scale(1); } }
177
+ @keyframes cost-pa-in-center { from { opacity: 0; transform: translate(-50%, calc(-50% + 10px)); } to { opacity: 1; transform: translate(-50%,-50%); } }
178
+ `;
179
+ const tag = document.createElement("style");
180
+ tag.setAttribute("data-plugin-css", "cost-tracker-plugin");
181
+ tag.textContent = css;
182
+ document.head.appendChild(tag);
183
+ ctx.effect(() => () => { tag.remove(); }, "cost-tracker: styles");
184
+ }
185
+
186
+ function pluginIcon(size) {
187
+ const s = size || 16;
188
+ return e("svg", {
189
+ className: "cost-icon", width: s, height: s, viewBox: "0 0 16 16",
190
+ fill: "none", xmlns: "http://www.w3.org/2000/svg", "aria-hidden": "true",
191
+ },
192
+ e("rect", { x: 1.5, y: 8.6, width: 3.1, height: 5.9, rx: 0.9, fill: "currentColor" }),
193
+ e("rect", { x: 5.9, y: 5.2, width: 3.1, height: 9.3, rx: 0.9, fill: "currentColor" }),
194
+ e("g", { fill: "none", stroke: "currentColor", strokeWidth: 1.3, strokeLinecap: "round", strokeLinejoin: "round" },
195
+ e("path", { d: "M10.7 4.9 L12.3 7.2 L13.9 4.9" }),
196
+ e("path", { d: "M12.3 7.2 L12.3 10.9" }),
197
+ e("path", { d: "M10.9 7.8 L13.7 7.8" }),
198
+ e("path", { d: "M10.9 9.5 L13.7 9.5" })));
199
+ }
200
+
201
+ function fmtInt(n) { return String(Math.round(Number(n) || 0)).replace(/\B(?=(\d{3})+(?!\d))/g, ","); }
202
+ function fmtMoney(n) {
203
+ const x = Number(n) || 0;
204
+ const s = Math.abs(x) >= 1 ? x.toFixed(2) : x.toFixed(4);
205
+ const parts = s.split(".");
206
+ parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
207
+ return parts.join(".");
208
+ }
209
+ function fmtCompact(n) {
210
+ n = Number(n) || 0;
211
+ if (n >= 1000000) return (n / 1000000).toFixed(1) + "M";
212
+ if (n >= 1000) return (n / 1000).toFixed(1) + "K";
213
+ return String(Math.round(n));
214
+ }
215
+ // 热力图 token 显示:<1000 原样;≥1000 用 K(≥100 时取整),≥1000000 用 M(≥100 时取整)
216
+ function fmtTokens(n) {
217
+ const v = Math.max(0, Number(n) || 0);
218
+ const scaled = (x) => (x >= 100 ? String(Math.round(x)) : String(Math.round(x * 10) / 10));
219
+ if (v < 1000) return String(Math.round(v));
220
+ if (v < 1000000) return scaled(v / 1000) + "K";
221
+ return scaled(v / 1000000) + "M";
222
+ }
223
+ function fmtAxisMoney(v) {
224
+ if (v <= 0) return "0";
225
+ if (v >= 1000) return (v / 1000).toFixed(1) + "k";
226
+ if (v >= 100) return String(Math.round(v));
227
+ if (v >= 1) return v.toFixed(1);
228
+ return v.toFixed(2);
229
+ }
230
+ function fmtTickInt(v) { return v >= 1000 ? fmtCompact(v) : String(Math.round(v)); }
231
+ function periodText(p) { return p === "peak" ? "高峰" : p === "off-peak" ? "闲时" : "平峰"; }
232
+ function shortModel(m) { const i = m.lastIndexOf("/"); return i >= 0 ? m.slice(i + 1) : m; }
233
+ // 订阅套餐友好名称:kimi-coding / kimi → Kimi Coding Plan,其余保留 provider 名
234
+ function subPlanName(p) {
235
+ const np = String(p || "").toLowerCase().replace(/-official$/, "");
236
+ if (np === "kimi-coding" || np === "kimi") return "Kimi Coding Plan";
237
+ return np || "订阅";
238
+ }
239
+ function countdown(resetTime, now) {
240
+ const t = Date.parse(resetTime);
241
+ if (!t) return "";
242
+ let ms = t - now;
243
+ if (ms <= 0) return "即将重置";
244
+ const d = Math.floor(ms / 86400000); ms -= d * 86400000;
245
+ const h = Math.floor(ms / 3600000); ms -= h * 3600000;
246
+ const m = Math.floor(ms / 60000);
247
+ return (d > 0 ? d + " 天 " : "") + h + " 小时 " + m + " 分钟后重置";
248
+ }
249
+ function windowLabel(w) {
250
+ if (w.timeUnit === "TIME_UNIT_MINUTE") {
251
+ return (w.duration >= 60 ? (Math.round(w.duration / 6) / 10) + " 小时" : w.duration + " 分钟") + "滚动窗口限额";
252
+ }
253
+ if (w.timeUnit === "TIME_UNIT_HOUR") return w.duration + " 小时滚动窗口限额";
254
+ if (w.timeUnit === "TIME_UNIT_DAY") return w.duration + " 天滚动窗口限额";
255
+ return w.duration + " " + (w.timeUnit || "") + " 窗口限额";
256
+ }
257
+
258
+ function legendRow(segs) {
259
+ return e("div", { className: "cost-legend" }, segs.map((s, i) =>
260
+ e("span", { key: i }, e("span", { className: "cost-dot", style: { background: s.color } }), s.name)));
261
+ }
262
+
263
+ function StackedBarsChart(props) {
264
+ const labels = props.labels;
265
+ const titles = props.titles || props.labels;
266
+ const segs = props.segs;
267
+ const rows = props.rows;
268
+ const daySegs = props.daySegs; // 可选:按天预排好的段列表 [{name,color,value}](大段在前/底部)
269
+ const fmtY = props.fmtY;
270
+ const fmtValue = props.fmtValue || props.fmtY;
271
+ const [hover, setHover] = useState(-1);
272
+ const W = 640, H = 200, ml = 44, mr = 6, mt = 8, mb = 20;
273
+ const pw = W - ml - mr, ph = H - mt - mb;
274
+ const n = daySegs ? daySegs.length : rows.length;
275
+ const perDay = (i) => daySegs ? daySegs[i] : rows[i].map((v, j) => ({ name: segs[j].name, color: segs[j].color, value: v }));
276
+ let max = 0;
277
+ for (let i = 0; i < n; i++) { let s = 0; for (const sg of perDay(i)) s += sg.value; if (s > max) max = s; }
278
+ if (max <= 0) max = 1;
279
+ const step = n > 0 ? pw / n : 0;
280
+ const children = [];
281
+ const ticks = [0, max / 2, max];
282
+ for (let ti = 0; ti < 3; ti++) {
283
+ const t = ticks[ti];
284
+ const y = mt + ph - (t / max) * ph;
285
+ children.push(e("line", { key: "g" + ti, x1: ml, y1: y, x2: W - mr, y2: y, strokeWidth: 1, style: { stroke: GRID } }));
286
+ children.push(e("text", { key: "t" + ti, x: ml - 5, y: y + 3, textAnchor: "end", fontSize: 10, style: { fill: INK2 } }, fmtY(t)));
287
+ }
288
+ if (n > 0) {
289
+ const bw = Math.max(2, Math.min(36, step * 0.55));
290
+ const stride = Math.max(1, Math.ceil(n / 6));
291
+ for (let i = 0; i < n; i++) {
292
+ let acc = 0;
293
+ const x = ml + i * step + (step - bw) / 2;
294
+ const r = perDay(i);
295
+ for (let j = 0; j < r.length; j++) {
296
+ const v = r[j].value;
297
+ if (v > 0) {
298
+ const h = Math.max(0.6, (v / max) * ph);
299
+ const y = mt + ph - ((acc + v) / max) * ph;
300
+ children.push(e("rect", { key: "b" + i + "-" + j, x: x, y: y, width: bw, height: h, rx: 1, style: { fill: r[j].color } }));
301
+ }
302
+ acc += v;
303
+ }
304
+ if (i % stride === 0 || i === n - 1) {
305
+ children.push(e("text", { key: "x" + i, x: ml + i * step + step / 2, y: H - 5, textAnchor: "middle", fontSize: 10, style: { fill: INK2 } }, labels[i]));
306
+ }
307
+ }
308
+ }
309
+ if (hover >= 0 && hover < n) {
310
+ const ix = ml + hover * step + step / 2;
311
+ children.push(e("line", { key: "hover", x1: ix, y1: mt, x2: ix, y2: mt + ph, strokeDasharray: "3 3", strokeWidth: 1, style: { stroke: INK2 } }));
312
+ }
313
+ function onMove(ev) {
314
+ if (n <= 0) { setHover(-1); return; }
315
+ const rect = ev.currentTarget.getBoundingClientRect();
316
+ const vx = (ev.clientX - rect.left) / rect.width * W;
317
+ const i = Math.floor((vx - ml) / step);
318
+ setHover(i >= 0 && i < n ? i : -1);
319
+ }
320
+ function onLeave() { setHover(-1); }
321
+ let tip = null;
322
+ if (hover >= 0 && hover < n) {
323
+ const row = perDay(hover);
324
+ let total = 0;
325
+ for (const sg of row) total += sg.value;
326
+ const segRows = [];
327
+ for (let j = 0; j < row.length; j++) {
328
+ const sg = row[j];
329
+ if (sg.value <= 0) continue;
330
+ segRows.push(e("div", { key: j, className: "cost-tip-row" },
331
+ e("span", { className: "cost-tip-dot", style: { background: sg.color } }),
332
+ e("span", { className: "cost-tip-name" }, sg.name),
333
+ e("span", { className: "cost-tip-val" }, fmtValue(sg.value))));
334
+ }
335
+ const pct = (ml + hover * step + step / 2) / W * 100;
336
+ const flip = pct > 62;
337
+ tip = e("div", { className: "cost-tip", style: { left: pct + "%", transform: flip ? "translateX(calc(-100% - 8px))" : "translateX(8px)" } },
338
+ e("div", { className: "cost-tip-title" },
339
+ e("span", null, titles[hover] !== undefined ? titles[hover] : labels[hover]),
340
+ e("span", { className: "cost-tip-total" }, fmtValue(total))),
341
+ segRows);
342
+ }
343
+ return e("div", { className: "cost-chart-host", onMouseMove: onMove, onMouseLeave: onLeave },
344
+ e("svg", { viewBox: "0 0 " + W + " " + H, width: "100%", preserveAspectRatio: "xMidYMid meet" }, children),
345
+ tip);
346
+ }
347
+
348
+ function AreaChart(props) {
349
+ const labels = props.labels;
350
+ const titles = props.titles || props.labels;
351
+ const values = props.values;
352
+ const fmtY = props.fmtY;
353
+ const fmtValue = props.fmtValue || props.fmtY;
354
+ const valueLabel = props.valueLabel || "";
355
+ const gid = props.gid;
356
+ const [hover, setHover] = useState(-1);
357
+ const W = 640, H = 180, ml = 40, mr = 6, mt = 8, mb = 20;
358
+ const pw = W - ml - mr, ph = H - mt - mb;
359
+ const n = values.length;
360
+ let max = 0;
361
+ for (const v of values) if (v > max) max = v;
362
+ if (max <= 0) max = 1;
363
+ const stepX = n > 1 ? pw / (n - 1) : 0;
364
+ const children = [];
365
+ const ticks = [0, max / 2, max];
366
+ for (let ti = 0; ti < 3; ti++) {
367
+ const t = ticks[ti];
368
+ const y = mt + ph - (t / max) * ph;
369
+ children.push(e("line", { key: "g" + ti, x1: ml, y1: y, x2: W - mr, y2: y, strokeWidth: 1, style: { stroke: GRID } }));
370
+ children.push(e("text", { key: "t" + ti, x: ml - 5, y: y + 3, textAnchor: "end", fontSize: 10, style: { fill: INK2 } }, fmtY(t)));
371
+ }
372
+ const pts = [];
373
+ if (n > 0) {
374
+ for (let i = 0; i < n; i++) {
375
+ pts.push({ x: n > 1 ? ml + i * stepX : ml + pw / 2, y: mt + ph - (values[i] / max) * ph });
376
+ }
377
+ let line = "";
378
+ for (let i = 0; i < pts.length; i++) line += (i === 0 ? "M" : "L") + pts[i].x.toFixed(1) + " " + pts[i].y.toFixed(1);
379
+ const base = mt + ph;
380
+ const area = line + "L" + pts[pts.length - 1].x.toFixed(1) + " " + base + "L" + pts[0].x.toFixed(1) + " " + base + "Z";
381
+ children.push(e("defs", { key: "d" }, e("linearGradient", { id: gid, x1: 0, y1: 0, x2: 0, y2: 1 },
382
+ e("stop", { offset: "0%", style: { stopColor: BLUE, stopOpacity: 0.3 } }),
383
+ e("stop", { offset: "100%", style: { stopColor: BLUE, stopOpacity: 0.02 } }))));
384
+ children.push(e("path", { key: "a", d: area, style: { fill: "url(#" + gid + ")" } }));
385
+ children.push(e("path", { key: "l", d: line, fill: "none", strokeWidth: 1.5, style: { stroke: BLUE } }));
386
+ if (n === 1) children.push(e("circle", { key: "p0", cx: pts[0].x, cy: pts[0].y, r: 2.5, style: { fill: BLUE } }));
387
+ const stride = Math.max(1, Math.ceil(n / 6));
388
+ for (let i = 0; i < n; i++) {
389
+ if (i % stride === 0 || i === n - 1) {
390
+ children.push(e("text", { key: "x" + i, x: pts[i].x, y: H - 5, textAnchor: "middle", fontSize: 10, style: { fill: INK2 } }, labels[i]));
391
+ }
392
+ }
393
+ }
394
+ if (hover >= 0 && hover < n) {
395
+ const px = pts[hover].x, py = pts[hover].y;
396
+ children.push(e("line", { key: "hover", x1: px, y1: mt, x2: px, y2: mt + ph, strokeDasharray: "3 3", strokeWidth: 1, style: { stroke: INK2 } }));
397
+ children.push(e("circle", { key: "hoverdot", cx: px, cy: py, r: 3.5, strokeWidth: 2, style: { fill: BLUE, stroke: "var(--dsw-alias-bg-layer-1, #fff)" } }));
398
+ }
399
+ function onMove(ev) {
400
+ if (n <= 0) { setHover(-1); return; }
401
+ if (n === 1) { setHover(0); return; }
402
+ const rect = ev.currentTarget.getBoundingClientRect();
403
+ const vx = (ev.clientX - rect.left) / rect.width * W;
404
+ const i = Math.round((vx - ml) / stepX);
405
+ setHover(i >= 0 && i < n ? i : -1);
406
+ }
407
+ function onLeave() { setHover(-1); }
408
+ let tip = null;
409
+ if (hover >= 0 && hover < n) {
410
+ const pct = pts[hover].x / W * 100;
411
+ const flip = pct > 62;
412
+ tip = e("div", { className: "cost-tip", style: { left: pct + "%", transform: flip ? "translateX(calc(-100% - 8px))" : "translateX(8px)" } },
413
+ e("div", { className: "cost-tip-title" },
414
+ e("span", null, titles[hover] !== undefined ? titles[hover] : labels[hover])),
415
+ e("div", { className: "cost-tip-row" },
416
+ e("span", { className: "cost-tip-dot", style: { background: BLUE } }),
417
+ e("span", { className: "cost-tip-name" }, valueLabel),
418
+ e("span", { className: "cost-tip-val" }, fmtValue(values[hover]))));
419
+ }
420
+ return e("div", { className: "cost-chart-host", onMouseMove: onMove, onMouseLeave: onLeave },
421
+ e("svg", { viewBox: "0 0 " + W + " " + H, width: "100%", preserveAspectRatio: "xMidYMid meet" }, children),
422
+ tip);
423
+ }
424
+
425
+ function progressBar(label, used, limit, remaining, resetTime, now) {
426
+ const pct = limit > 0 ? Math.min(100, Math.round(used / limit * 100)) : 0;
427
+ const color = pct >= 90 ? RED : pct >= 70 ? AMBER : BLUE;
428
+ return e("div", { style: { marginTop: "10px" } },
429
+ e("div", { className: "cost-row" },
430
+ e("span", { className: "cost-hint" }, label),
431
+ e("span", { className: "cost-spacer" }),
432
+ e("span", { style: { fontVariantNumeric: "tabular-nums" } }, fmtInt(used) + " / " + fmtInt(limit) + " · " + pct + "%")),
433
+ e("div", { className: "cost-bar-track" },
434
+ e("div", { className: "cost-bar-fill", style: { width: Math.max(pct, used > 0 ? 1 : 0) + "%", background: color } })),
435
+ e("div", { className: "cost-hint", style: { marginTop: "4px" } }, "剩余 " + fmtInt(remaining) + (resetTime ? " · " + countdown(resetTime, now) : "")));
436
+ }
437
+
438
+ function statCard(title, value, sub) {
439
+ return e("div", { className: "cost-card" },
440
+ e("div", { className: "cost-card-title" }, title),
441
+ e("div", { className: "cost-card-value" }, value),
442
+ e("div", { className: "cost-card-sub" }, sub));
443
+ }
444
+
445
+ function filterRow(days, setDays, onExport, onRefresh, msg, busy, peakWindows) {
446
+ return e("div", { className: "cost-row" },
447
+ e("select", { className: "cost-select", value: String(days), onChange: ev => setDays(parseInt(ev.target.value, 10)) },
448
+ e("option", { value: "7" }, "近 7 天"),
449
+ e("option", { value: "14" }, "近 14 天"),
450
+ e("option", { value: "30" }, "近 30 天"),
451
+ e("option", { value: "0" }, "全部")),
452
+ e("button", { className: "cost-btn", onClick: onExport, disabled: busy }, "导出 CSV"),
453
+ e("button", { className: "cost-btn", onClick: onRefresh, disabled: busy }, busy ? "刷新中…" : "刷新"),
454
+ msg ? e("span", { className: "cost-hint" }, msg) : null,
455
+ e("span", { className: "cost-spacer" }),
456
+ e("span", { className: "cost-hint" }, "峰谷时段(北京时间):" + peakWindows + " · 闲时半价"));
457
+ }
458
+
459
+ function statCards(dash, balance) {
460
+ const today = dash.today || { real: 0, calls: 0, tokens: 0, sub: 0, subCalls: 0, subTokens: 0 };
461
+ const month = dash.month || { real: 0, calls: 0, tokens: 0, sub: 0, subCalls: 0, subTokens: 0 };
462
+ const all = dash.all || { real: 0, calls: 0, tokens: 0, sub: 0, subCalls: 0, subTokens: 0 };
463
+ // 金额卡:主值为按量消费(不含订阅会员等效费用);副行附注订阅等效
464
+ const moneySub = (t) => "调用 " + fmtInt(t.calls) + " 次 · Tokens " + fmtCompact(t.tokens) + (t.sub > 0 ? " · 订阅 ¥" + fmtMoney(t.sub) : "");
465
+ // 余额卡
466
+ let balValue = "—", balSub = "查询中…";
467
+ if (balance && balance.ok) {
468
+ balValue = "¥" + (balance.total || "0");
469
+ balSub = (balance.available ? "可用" : "不可用") + " · 充值 ¥" + (balance.toppedUp || "0") + " · 赠送 ¥" + (balance.granted || "0") + " · " + (balance.keySource || "");
470
+ } else if (balance && balance.error) {
471
+ balSub = balance.error;
472
+ }
473
+ return e("div", { className: "cost-cards" },
474
+ statCard("今日费用(CNY)", "¥" + fmtMoney(today.real), moneySub(today)),
475
+ statCard("本月费用(CNY)", "¥" + fmtMoney(month.real), moneySub(month)),
476
+ statCard("总花费(CNY)", "¥" + fmtMoney(all.real), "调用 " + fmtInt(all.calls) + " 次 · Tokens " + fmtCompact(all.tokens) + (all.sub > 0 ? " · 订阅 ¥" + fmtMoney(all.sub) : "")),
477
+ statCard("API 请求次数", fmtInt(all.calls + all.subCalls),
478
+ "按量 " + fmtInt(all.calls) + " · 订阅 " + fmtInt(all.subCalls)),
479
+ statCard("Tokens", fmtInt(all.tokens + all.subTokens),
480
+ "按量 " + fmtCompact(all.tokens) + " · 订阅 " + fmtCompact(all.subTokens)),
481
+ statCard("总余额(CNY)", balValue, balSub));
482
+ }
483
+
484
+ function mainPanel(dash, tab, setTab, scheme, setScheme) {
485
+ let chart = null;
486
+ let legend = [];
487
+ const labels = dash.byDay.map(d => d.label);
488
+ const titles = dash.byDay.map(d => d.date);
489
+ const fmtMoneyValue = (v) => "¥" + fmtMoney(v);
490
+ if (tab === "period") {
491
+ const segs = [{ name: "闲时", color: BLUE }, { name: "高峰", color: AMBER }, { name: "平峰", color: GRAY }];
492
+ const rows = dash.byDay.map(d => [d.off, d.peak, d.flat]);
493
+ chart = e(StackedBarsChart, { labels, titles, segs, rows, fmtY: fmtAxisMoney, fmtValue: fmtMoneyValue });
494
+ legend = segs;
495
+ } else {
496
+ // 模型按总消费降序排名(byModelDay 已按费用降序),全部展示、不合并、不循环:
497
+ // 第 1 名取色阶首色(最深,垫底),名次越靠后越浅;色相按所选方案漂移
498
+ const models = dash.byModelDay.filter(m => !m.subscription);
499
+ const sc = COLOR_SCHEMES[scheme] || COLOR_SCHEMES["orange-yellow"];
500
+ const segs = models.map((m, i) => ({ name: shortModel(m.model), color: schemeColor(i, sc) }));
501
+ const daySegs = dash.byDay.map((d, di) => {
502
+ const list = [];
503
+ for (let i = 0; i < models.length; i++) {
504
+ const c = models[i].days[di] ? models[i].days[di].cost : 0;
505
+ if (c > 0) list.push({ name: segs[i].name, color: segs[i].color, value: c });
506
+ }
507
+ return list;
508
+ });
509
+ chart = e(StackedBarsChart, { labels, titles, segs, daySegs, rows: [], fmtY: fmtAxisMoney, fmtValue: fmtMoneyValue });
510
+ legend = segs;
511
+ }
512
+ return e("div", { className: "cost-panel" },
513
+ e("div", { className: "cost-row" },
514
+ e("span", { className: "cost-panel-title" }, "消费金额(CNY)¥" + fmtMoney(dash.realCost)),
515
+ tab === "model" ? e("span", { style: { display: "inline-flex", alignItems: "center", gap: "4px", marginLeft: "10px" } },
516
+ Object.keys(COLOR_SCHEMES).map(k => e("button", {
517
+ key: k,
518
+ className: "cost-tab" + (scheme === k ? " cost-tab-on" : ""),
519
+ onClick: () => setScheme(k),
520
+ title: COLOR_SCHEMES[k].label,
521
+ style: { padding: "3px 4px" },
522
+ },
523
+ e("span", { style: { display: "block", width: "20px", height: "14px", borderRadius: "2px", background: schemeSwatch(k) } })))) : null,
524
+ e("span", { className: "cost-spacer" }),
525
+ e("span", { className: "cost-tabs" },
526
+ e("button", { className: "cost-tab" + (tab === "period" ? " cost-tab-on" : ""), onClick: () => setTab("period") }, "按峰谷"),
527
+ e("button", { className: "cost-tab" + (tab === "model" ? " cost-tab-on" : ""), onClick: () => setTab("model") }, "按模型"))),
528
+ e("div", { style: { marginTop: "10px" } }, chart),
529
+ legendRow(legend),
530
+ e("div", { className: "cost-hint", style: { marginTop: "6px" } }, "仅含按量计费模型;订阅制套餐不计入消费金额。"));
531
+ }
532
+
533
+ function subPanel(kimi, dash, now, onForce) {
534
+ const ok = kimi && kimi.ok;
535
+ const level = ok ? (MEMBERSHIP[kimi.membership] || kimi.membership || "未知等级") : "";
536
+ let body;
537
+ if (!kimi) {
538
+ body = e("div", { className: "cost-hint", style: { marginTop: "8px" } }, "配额查询中…");
539
+ } else if (!kimi.ok) {
540
+ body = e("div", { style: { marginTop: "8px" } },
541
+ e("div", { className: "cost-err" }, "配额查询不可用:" + (kimi.error || "未知错误")),
542
+ e("div", { className: "cost-hint", style: { marginTop: "4px" } }, "需要 Kimi Coding Plan 的 API Key(形如 sk-kimi-…,通常配置为 KIMI_CODING_API_KEY)。当前尝试的 Key:" + (kimi.keyEnv || "未知") + "(来源:" + (kimi.keySource || "无") + ")。"));
543
+ } else {
544
+ const bars = [progressBar("本周配额", kimi.weekly.used, kimi.weekly.limit, kimi.weekly.remaining, kimi.weekly.resetTime, now)];
545
+ const wins = kimi.windows || [];
546
+ for (let i = 0; i < wins.length; i++) {
547
+ const w = wins[i];
548
+ bars.push(e("div", { key: "w" + i }, progressBar(windowLabel(w), w.used, w.limit, w.remaining, w.resetTime, now)));
549
+ }
550
+ body = e("div", null, bars);
551
+ }
552
+ const stats = dash
553
+ ? "累计请求 " + fmtInt(dash.subCalls) + " 次 · Tokens " + fmtInt(dash.subTokens) + " · 等效按量费用 ¥" + fmtMoney(dash.subEquivalent)
554
+ : "加载中…";
555
+ return e("div", { className: "cost-panel" },
556
+ e("div", { className: "cost-row" },
557
+ e("span", { className: "cost-panel-title" }, "订阅套餐用量 · Kimi Coding Plan"),
558
+ ok ? e("span", { className: "cost-badge" }, level) : null,
559
+ ok && kimi.parallel ? e("span", { className: "cost-badge" }, "并发上限 " + kimi.parallel) : null,
560
+ e("span", { className: "cost-spacer" }),
561
+ e("button", { className: "cost-btn", onClick: onForce }, "刷新配额")),
562
+ body,
563
+ e("div", { className: "cost-hint", style: { marginTop: "10px" } }, "订阅用量统计:" + stats + "(订阅已覆盖,等效费用仅供参考)"));
564
+ }
565
+
566
+ function kv(k, v) {
567
+ return e("span", { style: { marginRight: "18px" } },
568
+ e("span", { className: "cost-hint" }, k + ":"),
569
+ e("span", { style: { fontWeight: 600 } }, v));
570
+ }
571
+
572
+ function balancePanel(balance, manualKey, setManualKey, onQuery) {
573
+ let body;
574
+ if (!balance) {
575
+ body = e("div", { className: "cost-hint" }, "查询中…");
576
+ } else if (!balance.ok) {
577
+ body = e("div", { className: "cost-err" }, "余额查询失败:" + (balance.error || "未知错误"));
578
+ } else {
579
+ body = e("div", { className: "cost-row", style: { gap: "4px" } },
580
+ kv("总余额", "¥" + balance.total),
581
+ kv("充值余额", "¥" + balance.toppedUp),
582
+ kv("赠送余额", "¥" + balance.granted),
583
+ kv("状态", balance.available ? "可用" : "不可用"),
584
+ e("span", { className: "cost-hint" }, "Key 来源:" + balance.keySource));
585
+ }
586
+ return e("div", { className: "cost-panel" },
587
+ e("div", { className: "cost-row" }, e("span", { className: "cost-panel-title" }, "账户余额 · DeepSeek")),
588
+ e("div", { style: { marginTop: "8px" } }, body),
589
+ e("div", { className: "cost-row", style: { marginTop: "10px" } },
590
+ e("input", { className: "cost-input", placeholder: "手动输入 DeepSeek API Key(sk-…)", value: manualKey, onChange: ev => setManualKey(ev.target.value) }),
591
+ e("button", { className: "cost-btn", onClick: () => onQuery(manualKey) }, "查询")));
592
+ }
593
+
594
+ function modelSections(dash) {
595
+ if (!dash.byModelDay || dash.byModelDay.length === 0) return null;
596
+ return dash.byModelDay.map((m, idx) => {
597
+ const badge = m.subscription ? "订阅制 · 金额为等效按量参考" : (m.estimated ? "按量计费 · 价格为估算" : "按量计费");
598
+ const labels = [];
599
+ const titles = [];
600
+ const calls = [];
601
+ const tokenRows = [];
602
+ for (const d of m.days) {
603
+ labels.push(d.label);
604
+ titles.push(d.date);
605
+ calls.push(d.calls);
606
+ tokenRows.push([d.input, d.cacheWrite, d.output, d.cacheRead]);
607
+ }
608
+ const gid = "cost-g-" + idx;
609
+ const tokenSegs = TOKEN_NAMES.map((name, i) => ({ name, color: TOKEN_COLORS[i] }));
610
+ return e("div", { className: "cost-panel", key: m.model },
611
+ e("div", { className: "cost-row" },
612
+ e("span", { className: "cost-panel-title" }, m.model),
613
+ e("span", { className: "cost-badge" }, badge)),
614
+ e("div", { className: "cost-grid2", style: { marginTop: "10px" } },
615
+ e("div", null,
616
+ e("div", { className: "cost-hint" }, "API 请求次数"),
617
+ e(AreaChart, { labels, titles, values: calls, fmtY: fmtTickInt, fmtValue: fmtInt, valueLabel: "请求次数", gid })),
618
+ e("div", null,
619
+ e("div", { className: "cost-hint" }, "Tokens"),
620
+ e(StackedBarsChart, { labels, titles, segs: tokenSegs, rows: tokenRows, fmtY: fmtCompact, fmtValue: fmtInt }),
621
+ legendRow(tokenSegs))));
622
+ });
623
+ }
624
+
625
+ function recentPanel(dash) {
626
+ const rows = [];
627
+ for (let i = 0; i < dash.recent.length; i++) {
628
+ const r = dash.recent[i];
629
+ rows.push(e("tr", { key: i },
630
+ e("td", null, r.time),
631
+ e("td", null, r.provider + "/" + r.model),
632
+ e("td", null, r.subscription ? e("span", { className: "cost-badge" }, "订阅") : periodText(r.period)),
633
+ e("td", null, fmtInt(r.input + r.cacheRead + r.cacheWrite) + " / " + fmtInt(r.output)),
634
+ e("td", null, "¥" + fmtMoney(r.cost))));
635
+ }
636
+ return e("div", { className: "cost-panel" },
637
+ e("div", { className: "cost-row" }, e("span", { className: "cost-panel-title" }, "最近记录")),
638
+ e("div", { style: { marginTop: "8px", overflowX: "auto" } },
639
+ e("table", { className: "cost-table" },
640
+ e("thead", null, e("tr", null,
641
+ e("th", null, "时间"), e("th", null, "模型"), e("th", null, "时段"), e("th", null, "入/出 Tokens"), e("th", null, "费用"))),
642
+ e("tbody", null, rows.length ? rows : e("tr", null, e("td", { colSpan: 5 }, e("span", { className: "cost-hint" }, "暂无记录")))))));
643
+ }
644
+
645
+ function UsageHeatmap(props) {
646
+ const data = props.data;
647
+ const days = Array.isArray(data.days) ? data.days : [];
648
+ const byDate = new Map();
649
+ for (const d of days) byDate.set(d.date, d);
650
+ const total = data.total || { tokens: 0, input: 0, cache: 0, output: 0, calls: 0, cost: 0 };
651
+ const [tip, setTip] = useState(null);
652
+ const hostRef = useRef(null);
653
+
654
+ // 与服务端 dayKey 同口径:一律按北京时间(UTC+8)生成 YYYY-MM-DD,
655
+ // 以「北京日序号」D = floor((ts+8h)/天) 表示,读 UTC 分量即为北京日期。
656
+ const DAY_MS = 86400000;
657
+ const pad2 = (n) => String(n).padStart(2, "0");
658
+ const keyOfDay = (D) => {
659
+ const d = new Date(D * DAY_MS);
660
+ return d.getUTCFullYear() + "-" + pad2(d.getUTCMonth() + 1) + "-" + pad2(d.getUTCDate());
661
+ };
662
+ const D0 = Math.floor((Date.now() + 28800000) / DAY_MS); // 今天的北京日序号
663
+ const todayDOW = new Date(D0 * DAY_MS).getUTCDay(); // 0=周日
664
+ const Dend = D0 + (6 - ((todayDOW + 6) % 7)); // 本周周日(北京)
665
+
666
+ const WEEKS = 26;
667
+ const columns = [];
668
+ const monthLabels = [];
669
+ let lastMonth = -1;
670
+ for (let w = WEEKS - 1; w >= 0; w -= 1) {
671
+ for (let i = 0; i < 7; i += 1) {
672
+ const Dcell = Dend - (w * 7 + (6 - i));
673
+ const key = keyOfDay(Dcell);
674
+ const day = byDate.get(key);
675
+ columns.push(day !== undefined ? { day, tokens: day.tokens } : { day: { date: key, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, calls: 0, cost: 0, tokens: 0 }, tokens: 0 });
676
+ }
677
+ const m = new Date((Dend - (w * 7 + 6)) * DAY_MS);
678
+ monthLabels.push(m.getUTCMonth() !== lastMonth ? String(m.getUTCMonth() + 1) + "月" : "");
679
+ lastMonth = m.getUTCMonth();
680
+ }
681
+ const todayKey = keyOfDay(D0);
682
+
683
+ let maxDay = 0;
684
+ for (const c of columns) if (c.tokens > maxDay) maxDay = c.tokens;
685
+ if (maxDay <= 0) maxDay = 1;
686
+ const levelOf = (tokens) => {
687
+ const ratio = tokens / maxDay;
688
+ return ratio < 0.25 ? 1 : ratio < 0.5 ? 2 : ratio < 0.75 ? 3 : 4;
689
+ };
690
+
691
+ function onCellEnter(ev, entry) {
692
+ const host = hostRef.current;
693
+ if (!host) return;
694
+ const hr = host.getBoundingClientRect();
695
+ const cr = ev.currentTarget.getBoundingClientRect();
696
+ const cellLeft = cr.left - hr.left;
697
+ const cellRight = cr.right - hr.left;
698
+ const cellCenter = cellLeft + cr.width / 2;
699
+ const top = cr.top - hr.top;
700
+ const hostWidth = hr.width;
701
+ setTip({ entry, cellLeft, cellRight, cellCenter, top, hostWidth });
702
+ }
703
+
704
+ const t = total;
705
+ const cells = columns.map((entry, i) => {
706
+ const day = entry.day;
707
+ const tokens = entry.tokens;
708
+ const cls = "cost-ug-cell" + (tokens > 0 ? " l" + levelOf(tokens) : "") + (day.date === todayKey ? " today" : "");
709
+ return e("div", {
710
+ key: i, className: cls,
711
+ onMouseEnter: (ev) => onCellEnter(ev, entry),
712
+ onMouseLeave: () => setTip(null),
713
+ });
714
+ });
715
+
716
+ let tipEl = null;
717
+ if (tip) {
718
+ const day = tip.entry.day;
719
+ const tokens = tip.entry.tokens;
720
+ const top = tip.top;
721
+ // 浮层自适应水平定位:默认以格中心居中;若靠近左/右边界则改为贴边对齐,避免被裁剪
722
+ const tipWidth = 170; // 估算浮层宽度(px)
723
+ let left = tip.cellCenter;
724
+ let transform = "translate(-50%, -100%)";
725
+ if (tip.cellCenter - tipWidth / 2 < 4) {
726
+ left = tip.cellLeft;
727
+ transform = "translate(0, -100%)";
728
+ } else if (tip.cellCenter + tipWidth / 2 > tip.hostWidth - 4) {
729
+ left = tip.cellRight;
730
+ transform = "translate(-100%, -100%)";
731
+ }
732
+ // 顶部格子:浮层上移后可能顶到容器顶边,改为贴底显示,避免被上边界裁掉
733
+ if (top < 60) {
734
+ transform = transform.replace(", -100%)", ", 8px)");
735
+ }
736
+ tipEl = e("div", {
737
+ className: "cost-ug-tip",
738
+ style: { left: left + "px", top: top + "px", transform: transform },
739
+ },
740
+ e("div", { className: "cost-ug-tip-total" }, day.date + " · " + fmtTokens(tokens) + " tokens" + (day.calls ? " · " + fmtInt(day.calls) + " 次调用" : "")),
741
+ e("div", { className: "cost-ug-tip-row" }, e("span", { className: "cost-ug-tip-name" }, "输入"), e("span", { className: "cost-ug-tip-val" }, fmtTokens(day.input))),
742
+ e("div", { className: "cost-ug-tip-row" }, e("span", { className: "cost-ug-tip-name" }, "缓存"), e("span", { className: "cost-ug-tip-val" }, fmtTokens((day.cacheRead || 0) + (day.cacheWrite || 0)))),
743
+ e("div", { className: "cost-ug-tip-row" }, e("span", { className: "cost-ug-tip-name" }, "输出"), e("span", { className: "cost-ug-tip-val" }, fmtTokens(day.output))),
744
+ e("div", { className: "cost-ug-tip-row" }, e("span", { className: "cost-ug-tip-name" }, "费用"), e("span", { className: "cost-ug-tip-val" }, "¥" + fmtMoney(day.cost))));
745
+ }
746
+
747
+ return e("div", { className: "cost-ug" },
748
+ e("div", { className: "cost-ug-total" },
749
+ "累计 " + fmtTokens(t.tokens) + " tokens · 输入 " + fmtTokens(t.input) + " · 缓存 " + fmtTokens(t.cache) + " · 输出 " + fmtTokens(t.output) + " · " + fmtInt(t.calls) + " 次调用"),
750
+ e("div", { className: "cost-ug-host", ref: hostRef },
751
+ e("div", { className: "cost-ug-grid", style: { gridTemplateColumns: "repeat(" + WEEKS + ", 1fr)" } }, cells),
752
+ tipEl),
753
+ e("div", { className: "cost-ug-months", style: { gridTemplateColumns: "repeat(" + WEEKS + ", 1fr)" } },
754
+ monthLabels.map((m, i) => e("span", { key: "m" + i, className: "cost-ug-month" }, m))));
755
+ }
756
+
757
+ // ---------- 峰谷计价提示 ----------
758
+ // 相位助记(与 dsh-cost-meter 一致):weekend → 「周末全谷」;inPeak → 「峰时」;否则「平价」。
759
+ function peakWord(p) {
760
+ if (!p) return "";
761
+ if (p.weekend) return "周末全谷";
762
+ return p.inPeak ? "峰时" : "平价";
763
+ }
764
+ function peakWordClass(p) {
765
+ if (!p) return "";
766
+ return p.weekend ? " weekend" : p.inPeak ? " peak" : " off";
767
+ }
768
+ // 倒计时文本:向上取整到分钟。与 dsh-cost-meter 一致:{time}后进入高峰/平价。
769
+ function peakCountdown(p, now) {
770
+ if (!p) return "";
771
+ return peakCountdownTime(p, now) + "后进入" + (p.nextIntoPeak ? "高峰" : "平价");
772
+ }
773
+ // 仅倒计时(分钟粒度的时长文本),用于弹窗正文「约 X 后…」。
774
+ function peakCountdownTime(p, now) {
775
+ if (!p) return "";
776
+ const ms = Math.max(0, p.nextAtMs - now);
777
+ const totalMin = Math.max(1, Math.ceil(ms / 60000));
778
+ const h = Math.floor(totalMin / 60);
779
+ const m = totalMin % 60;
780
+ return h > 0 ? (m > 0 ? h + "小时" + m + "分" : h + "小时") : m + "分";
781
+ }
782
+ // 当前相位在轨道上的标记位置:峰=25%、平价=75%;周末中点=50%。
783
+ function peakMarkerLeft(p) {
784
+ if (!p) return "50%";
785
+ if (p.weekend) return "50%";
786
+ return p.inPeak ? "25%" : "75%";
787
+ }
788
+ // 24h 环形表盘(classic 改造):中空圆环。底环蓝=平价铺满 24h,高峰窗口叠加橙色弧段,
789
+ // 指针随当前时刻旋转,圆心显示相位 + 倒计时。窗口缺口数据来自后端 peakHours,与 isPeak/peakPhaseAt 同口径。
790
+ function peakBeijingMinute(now) {
791
+ const d = new Date((Number.isFinite(now) ? now : Date.now()) + 28800000);
792
+ return d.getUTCHours() * 60 + d.getUTCMinutes();
793
+ }
794
+ function PeakDial(props) {
795
+ const p = props.phase;
796
+ if (!p) return null;
797
+ const now = props.now || Date.now();
798
+ const windows = props.windows && props.windows.length ? props.windows : [{ start: 9, end: 12 }, { start: 14, end: 18 }];
799
+ const size = props.size || 150;
800
+ const showTimeLabels = props.showTimeLabels !== false;
801
+ const CX = 90, CY = 90, R = 62, SW = 18; // viewBox 180
802
+ const peakC = "#ff9800", offC = "#4176e6", weekC = "#34a853";
803
+ const dim = "#9ca3af";
804
+ const deg = (h) => h * 15; // 0:00 = 顶部,顺时针;6:00 右 · 12:00 底 · 18:00 左
805
+ const polar = (d, r) => [CX + r * Math.sin(d * Math.PI / 180), CY - r * Math.cos(d * Math.PI / 180)];
806
+ const children = [
807
+ // 平价底环(蓝)
808
+ e("circle", { cx: CX, cy: CY, r: R, fill: "none", stroke: offC, "stroke-width": SW }),
809
+ ];
810
+ // 高峰弧(橙);周末则不画任何峰时弧段
811
+ if (!p.weekend) {
812
+ for (const w of windows) {
813
+ const a = deg(w.start), b = deg(w.end);
814
+ const s = polar(a, R), e2 = polar(b, R);
815
+ const large = ((b - a) % 360) > 180 ? 1 : 0;
816
+ children.push(e("path", { d: "M " + s[0] + " " + s[1] + " A " + R + " " + R + " 0 " + large + " 1 " + e2[0] + " " + e2[1], fill: "none", stroke: peakC, "stroke-width": SW }));
817
+ }
818
+ }
819
+ // 时间刻度(每 3h 一个标签,随缩放等比;可由「显示时间」开关控制)
820
+ if (showTimeLabels) {
821
+ for (let h = 0; h < 24; h += 3) {
822
+ const pt = polar(deg(h), R + SW / 2 + 13);
823
+ children.push(e("text", { x: pt[0], y: pt[1], "text-anchor": "middle", "dominant-baseline": "middle", "font-size": "8.5", fill: dim }, (h < 10 ? "0" : "") + h + ":00"));
824
+ }
825
+ }
826
+ // 当前相位颜色(峰橙 / 平蓝 / 周末绿)—— 同时用于标记点与圆心文案
827
+ const color = p.weekend ? weekC : p.inPeak ? peakC : offC;
828
+ const word = p.weekend ? "周末全谷" : p.inPeak ? "高峰时段" : "平价时段";
829
+ // 当前时刻标记:相位色点(环上,随相位变色 + 白色描边)
830
+ const m = peakBeijingMinute(now);
831
+ const tip = polar(m / 1440 * 360, R);
832
+ children.push(e("circle", { cx: tip[0], cy: tip[1], r: "7", fill: color, stroke: "#fff", "stroke-width": "2.5" }));
833
+ // 圆心:当前相位 + 倒计时
834
+ children.push(e("text", { x: CX, y: CY - 4, "text-anchor": "middle", "dominant-baseline": "middle", "font-size": "12", "font-weight": "700", fill: color }, word));
835
+ children.push(e("text", { x: CX, y: CY + 12, "text-anchor": "middle", "dominant-baseline": "middle", "font-size": "8.5", fill: dim }, peakCountdown(p, now)));
836
+ return e("svg", { viewBox: "0 0 180 180", width: size, height: size, role: "img", style: { overflow: "visible" } }, ...children);
837
+ }
838
+ // 单行时段条:两段轨道(橙+蓝)+ 标记线 + 着色 chip。对标 dsh-cost-meter 简洁款。
839
+ function PeakStrip(props) {
840
+ const snap = props.snap;
841
+ const style = props.style || "compact";
842
+ const wide = props.wide !== false;
843
+ if (!snap || !snap.phase || snap.notice === false) return null;
844
+ const p = snap.phase;
845
+ const now = props.now || Date.now();
846
+ const countdown = peakCountdown(p, now);
847
+ const wordClass = peakWordClass(p);
848
+ if (!wide) {
849
+ // 窄栏(rail):竖排短词
850
+ return e("div", { className: "cost-ps-rail" + wordClass, title: peakWord(p) + " · " + countdown },
851
+ e("span", { className: "cost-ps-word" }, peakWord(p)));
852
+ }
853
+ if (style === "classic") {
854
+ // 环形表盘(classic 改造):24h 中空圆环 + 圆心相位/倒计时
855
+ return e("div", { className: "cost-ps cost-ps-ring" + wordClass, title: countdown },
856
+ e(PeakDial, { phase: p, windows: snap.peakHours, now: now, size: props.ringSize || 150, showTimeLabels: !snap.config || snap.config.peakShowTickLabels !== false }),
857
+ e("div", { className: "cost-ps-ringfoot" },
858
+ e("span", { className: "cost-ps-chip" }, peakWord(p) + " · " + countdown)));
859
+ }
860
+ // compact:单行「24h 比例轨道(橙=高峰/蓝=平价)+ 白色实时进度线 + chip·倒计时」
861
+ const win = snap.peakHours && snap.peakHours.length ? snap.peakHours : [{ start: 9, end: 12 }, { start: 14, end: 18 }];
862
+ const segs = [
863
+ // 平价底(蓝)铺满 24h
864
+ e("div", { className: "cost-ps-seg cost-ps-offseg", style: { left: "0%", width: "100%" } }),
865
+ ];
866
+ // 高峰段(橙)按窗口在 24h 中的比例定位
867
+ if (!p.weekend) {
868
+ for (const w of win) {
869
+ segs.push(e("div", { className: "cost-ps-seg cost-ps-peakseg", style: { left: (w.start / 24 * 100) + "%", width: ((w.end - w.start) / 24 * 100) + "%" } }));
870
+ }
871
+ }
872
+ // 白色分割线:实时进度(北京时间当日占比)
873
+ segs.push(e("div", { className: "cost-ps-marker", style: { left: (peakBeijingMinute(now) / 1440 * 100) + "%" } }));
874
+ const track = e("div", { className: "cost-ps-track" }, segs);
875
+ const chip = e("span", { className: "cost-ps-chip" }, peakWord(p) + " · " + countdown);
876
+ const cfg = snap.config || {};
877
+ // 双行紧凑:勾选后由左右单行改为上下布局,可选「条上文下」或「文上条下」
878
+ if (cfg.peakCompactStack === true) {
879
+ const kids = cfg.peakCompactOrder === "text-first" ? [chip, track] : [track, chip];
880
+ return e("div", { className: "cost-ps cost-ps-stack" + wordClass, title: countdown }, kids[0], kids[1]);
881
+ }
882
+ return e("div", { className: "cost-ps" + wordClass, title: countdown }, track, chip);
883
+ }
884
+
885
+ // ---------- 峰谷切换前弹窗(真实 + 预览) ----------
886
+ // 常驻挂在 sidebar.footer.action;用 nextAtMs 作为唯一提醒点,同一切换点只提醒一次。
887
+ const PEAK_PREVIEW_EVENT = "dsh-cost-tracker-peak-preview";
888
+ let lastPeakNotifyAtMs = 0;
889
+ function PeakAlertPopup(props) {
890
+ const snap = props.snap;
891
+ const now = props.now || Date.now();
892
+ const onDismiss = props.onDismiss || (() => {});
893
+ if (!snap) return null;
894
+ const cfg = snap.config || {};
895
+ const alert = snap.alert || {};
896
+ // 定位/外观统一由宿主 snap 决定;可被预览配置覆盖。
897
+ const intoPeak = props.preview
898
+ ? props.preview === "peak"
899
+ : (snap.phase ? snap.phase.nextIntoPeak === true : false);
900
+ const position = props.position || (alert.position === "center" ? "cost-pa-center" : "cost-pa-corner");
901
+ const title = intoPeak ? "即将进入峰时" : "即将进入谷时";
902
+ const body = "约 " + (props.countdownText || (snap.phase ? peakCountdownTime(snap.phase, now) : "2 分")) + " 后计费档位切换为" + (intoPeak ? "峰时" : "谷时") + "价,请注意本时段调用成本。";
903
+ const badge = intoPeak ? "峰价提醒" : "谷价提醒";
904
+ return e("div", { className: "cost-pa " + position + " " + (intoPeak ? "cost-pa-peak" : "cost-pa-offpeak"), role: "alert" },
905
+ e("div", { className: "cost-pa-badge" }, badge),
906
+ e("div", { className: "cost-pa-title" }, title),
907
+ e("div", { className: "cost-pa-body" }, body),
908
+ e("div", { className: "cost-pa-actions" },
909
+ e("button", { className: "cost-btn", onClick: onDismiss }, "知道了")));
910
+ }
911
+
912
+ // 侧边栏峰谷组件:时段条 + 切换前弹窗宿主(常驻,无需活跃会话)。
913
+ function PeakSidebar(props) {
914
+ const wide = props && props.wide !== false;
915
+ const [snap, setSnap] = useState(null);
916
+ const [now, setNow] = useState(Date.now());
917
+ const [dismissedAt, setDismissedAt] = useState(null);
918
+ const [preview, setPreview] = useState(null);
919
+ useEffect(() => {
920
+ let alive = true;
921
+ function load() {
922
+ apiCall("peak", {}).then(v => { if (alive && v && v.ok) setSnap(v); }).catch(() => {});
923
+ }
924
+ load();
925
+ const id = setInterval(load, 30000);
926
+ return () => { alive = false; clearInterval(id); };
927
+ }, []);
928
+ useEffect(() => {
929
+ const id = setInterval(() => setNow(Date.now()), 10000);
930
+ return () => clearInterval(id);
931
+ }, []);
932
+ // 预览通道(设置面板触发)
933
+ useEffect(() => {
934
+ const onPreview = (event) => {
935
+ const kind = event.detail && event.detail.kind === "offpeak" ? "offpeak" : "peak";
936
+ setPreview(kind);
937
+ // 预览系统通知
938
+ const cfg = snap && snap.config;
939
+ if (cfg && cfg.peakAlertWebNotify === true && window.Notification && Notification.permission === "granted") {
940
+ try {
941
+ new Notification((kind === "peak" ? "即将进入峰时" : "即将进入谷时") + "(预览)",
942
+ { body: "约 2 分 后计费档位切换为" + (kind === "peak" ? "峰时" : "谷时") + "价。" });
943
+ } catch (_) {}
944
+ }
945
+ };
946
+ window.addEventListener(PEAK_PREVIEW_EVENT, onPreview);
947
+ return () => window.removeEventListener(PEAK_PREVIEW_EVENT, onPreview);
948
+ }, [snap]);
949
+ // 真实弹窗判定:提醒开启 + 峰谷启用生效 + 距下次切换 <= ahead + 类型匹配 + 未关闭过该切换点
950
+ let real = null;
951
+ if (snap && snap.enabled && snap.effective && snap.alert && snap.alert.enabled && snap.phase) {
952
+ const aheadMs = (Number.isFinite(snap.alert.ahead) && snap.alert.ahead >= 1 ? snap.alert.ahead : 2) * 60000;
953
+ const tgt = snap.alert.target || "both";
954
+ const intoPeak = snap.phase.nextIntoPeak === true;
955
+ if ((tgt === "both" || tgt === (intoPeak ? "peak" : "offpeak"))
956
+ && now >= snap.phase.nextAtMs - aheadMs && now < snap.phase.nextAtMs && dismissedAt !== snap.phase.nextAtMs) real = snap.phase;
957
+ }
958
+ // 系统通知(Web):与弹窗同判定,用 nextAtMs 去重
959
+ useEffect(() => {
960
+ if (!snap || !snap.enabled || !snap.effective) return;
961
+ const alert = snap.alert || {};
962
+ if (alert.enabled !== true || alert.webNotify !== true || !window.Notification || Notification.permission !== "granted") return;
963
+ if (!snap.phase) return;
964
+ const aheadMs = (Number.isFinite(alert.ahead) && alert.ahead >= 1 ? alert.ahead : 2) * 60000;
965
+ const intoPeak = snap.phase.nextIntoPeak === true;
966
+ const tgt = alert.target || "both";
967
+ if (now < snap.phase.nextAtMs - aheadMs || now >= snap.phase.nextAtMs) return;
968
+ if ((tgt !== "both" && tgt !== (intoPeak ? "peak" : "offpeak"))) return;
969
+ if (lastPeakNotifyAtMs === snap.phase.nextAtMs) return;
970
+ lastPeakNotifyAtMs = snap.phase.nextAtMs;
971
+ try {
972
+ new Notification(intoPeak ? "即将进入峰时" : "即将进入谷时",
973
+ { body: peakCountdownTime(snap.phase, now) + " 后计费档位切换为" + (intoPeak ? "峰时" : "谷时") + "价。" });
974
+ } catch (_) {}
975
+ }, [now, snap]);
976
+ let popup = null;
977
+ if (real !== null) {
978
+ popup = e(PeakAlertPopup, { snap, now, onDismiss: () => setDismissedAt(real.nextAtMs) });
979
+ } else if (preview !== null) {
980
+ // 预览跟随用户配置的弹窗位置(右下角 / 屏幕中心),不强制覆盖
981
+ popup = e(PeakAlertPopup, { snap, preview, now: now + 120000, countdownText: "2 分", onDismiss: () => setPreview(null) });
982
+ }
983
+ return e("div", null,
984
+ e(PeakStrip, { snap, style: snap ? snap.style : "compact", wide, now, ringSize: 112 }),
985
+ popup);
986
+ }
987
+
988
+ // 设置面板:峰谷计价与提示
989
+ function PeakPanel(props) {
990
+ const [snap, setSnap] = useState(null);
991
+ const [draft, setDraft] = useState(null);
992
+ const [now, setNow] = useState(Date.now());
993
+ const [savedMsg, setSavedMsg] = useState("");
994
+ function load() {
995
+ apiCall("peak", {}).then(v => {
996
+ if (v && v.ok) {
997
+ setSnap(v);
998
+ setDraft(Object.assign({}, v.config || {}));
999
+ }
1000
+ }).catch(() => {});
1001
+ }
1002
+ useEffect(() => { load(); }, []);
1003
+ useEffect(() => {
1004
+ const id = setInterval(() => setNow(Date.now()), 30000);
1005
+ return () => clearInterval(id);
1006
+ }, []);
1007
+ function setField(k, v) {
1008
+ setDraft(d => Object.assign({}, d, { [k]: v }));
1009
+ }
1010
+ function save() {
1011
+ apiCall("peak-config", draft || {}).then(v => {
1012
+ if (v && typeof v === 'object') {
1013
+ setSnap(s => Object.assign({}, s, { config: v, enabled: v.peakEnabled, notice: v.peakNotice, style: v.peakStyle, alert: { enabled: v.peakAlertEnabled, ahead: v.peakAlertAhead, target: v.peakAlertTarget, position: v.peakAlertPosition, webNotify: v.peakAlertWebNotify } }));
1014
+ setDraft(Object.assign({}, v));
1015
+ setSavedMsg("已保存");
1016
+ setTimeout(() => setSavedMsg(""), 2000);
1017
+ }
1018
+ }).catch(() => setSavedMsg("保存失败"));
1019
+ }
1020
+ function preview(kind) {
1021
+ window.dispatchEvent(new CustomEvent(PEAK_PREVIEW_EVENT, { detail: { kind } }));
1022
+ }
1023
+ const d = draft || {};
1024
+ const cfgSnap = snap ? Object.assign({}, snap, { config: d }) : null;
1025
+ const noticeOn = snap && snap.notice !== false && d.peakNotice !== false;
1026
+ return e("div", { className: "cost-panel" },
1027
+ e("div", { className: "cost-row" },
1028
+ e("span", { className: "cost-panel-title" }, "峰谷计价与提示"),
1029
+ e("span", { className: "cost-spacer" }),
1030
+ e("button", { className: "cost-btn", onClick: save, disabled: !draft }, "保存设置")),
1031
+ e("div", { style: { marginTop: "8px", display: "grid", gap: "6px" } },
1032
+ e("label", { className: "cost-row", style: { gap: "8px" } },
1033
+ e("input", { type: "checkbox", checked: d.peakEnabled !== false, onChange: ev => setField("peakEnabled", ev.target.checked) }),
1034
+ e("span", null, "启用 DeepSeek 峰谷时段价格")),
1035
+ e("label", { className: "cost-row", style: { gap: "8px" } },
1036
+ e("input", { type: "checkbox", checked: d.peakNotice !== false, onChange: ev => setField("peakNotice", ev.target.checked) }),
1037
+ e("span", null, "峰时高价时段显著提示(时段条显示)")),
1038
+ e("div", { className: "cost-row", style: { gap: "8px" } },
1039
+ e("span", null, "时段条样式"),
1040
+ e("select", { className: "cost-select", value: d.peakStyle === "classic" ? "classic" : "compact", onChange: ev => setField("peakStyle", ev.target.value) },
1041
+ e("option", { value: "compact" }, "简洁(单行紧凑)"),
1042
+ e("option", { value: "classic" }, "环形表盘(24h)"))),
1043
+ d.peakStyle === "classic" ? e("label", { className: "cost-row", style: { gap: "8px" } },
1044
+ e("input", { type: "checkbox", checked: d.peakShowTickLabels !== false, onChange: ev => setField("peakShowTickLabels", ev.target.checked) }),
1045
+ e("span", null, "显示时间(00:00–21:00 刻度)"))
1046
+ : e("label", { className: "cost-row", style: { gap: "8px" } },
1047
+ e("input", { type: "checkbox", checked: d.peakCompactStack === true, onChange: ev => setField("peakCompactStack", ev.target.checked) }),
1048
+ e("span", null, "双行紧凑(上下布局)"),
1049
+ d.peakCompactStack === true ? e("select", { className: "cost-select", value: d.peakCompactOrder === "text-first" ? "text-first" : "bar-first", onChange: ev => setField("peakCompactOrder", ev.target.value) },
1050
+ e("option", { value: "bar-first" }, "时段条在上·文字在下"),
1051
+ e("option", { value: "text-first" }, "文字在上·时段条在下"))
1052
+ : null),
1053
+ e("label", { className: "cost-row", style: { gap: "8px" } },
1054
+ e("input", { type: "checkbox", checked: d.peakAlertEnabled !== false, onChange: ev => setField("peakAlertEnabled", ev.target.checked) }),
1055
+ e("span", null, "峰/谷切换前弹窗提醒")),
1056
+ d.peakAlertEnabled !== false ? e("div", { className: "cost-row", style: { gap: "8px" } },
1057
+ e("span", null, "提前提醒(分钟,1-30)"),
1058
+ e("input", { className: "cost-input", style: { width: "80px" }, type: "number", min: 1, max: 30, value: String(d.peakAlertAhead == null ? 2 : d.peakAlertAhead), onChange: ev => { const n = parseInt(ev.target.value, 10); if (Number.isInteger(n) && n >= 1 && n <= 30) setField("peakAlertAhead", n); } }),
1059
+ e("span", null, "提醒类型"),
1060
+ e("select", { className: "cost-select", value: d.peakAlertTarget === "peak" || d.peakAlertTarget === "offpeak" ? d.peakAlertTarget : "both", onChange: ev => setField("peakAlertTarget", ev.target.value) },
1061
+ e("option", { value: "both" }, "峰和谷"),
1062
+ e("option", { value: "peak" }, "进入峰时"),
1063
+ e("option", { value: "offpeak" }, "进入谷时")),
1064
+ e("span", null, "弹窗位置"),
1065
+ e("select", { className: "cost-select", value: d.peakAlertPosition === "center" ? "center" : "corner", onChange: ev => setField("peakAlertPosition", ev.target.value) },
1066
+ e("option", { value: "corner" }, "右下角"),
1067
+ e("option", { value: "center" }, "屏幕中心")))
1068
+ : null,
1069
+ e("label", { className: "cost-row", style: { gap: "8px" } },
1070
+ e("input", { type: "checkbox", checked: d.peakAlertWebNotify === true, onChange: ev => setField("peakAlertWebNotify", ev.target.checked) }),
1071
+ e("span", null, "同步发送系统通知(需授权通知权限)")),
1072
+ e("div", { className: "cost-row", style: { gap: "8px", marginTop: "4px" } },
1073
+ e("span", null, "预览弹窗"),
1074
+ e("button", { className: "cost-btn", onClick: () => preview("peak") }, "预览进入峰"),
1075
+ e("button", { className: "cost-btn", onClick: () => preview("offpeak") }, "预览进入谷"),
1076
+ savedMsg ? e("span", { className: "cost-hint" }, savedMsg) : null),
1077
+ e("div", { className: "cost-hint", style: { marginTop: "6px" } },
1078
+ "峰时段(北京时间):" + (snap ? snap.peakWindows : "周一至周五 9:00-12:00 · 14:00-18:00(周末全天闲时)") + "。生效时间:" + (snap ? snap.effectiveAt : "2026-08-01T00:00:00Z") + "。当前:" + (cfgSnap && cfgSnap.phase ? (cfgSnap.phase.weekend ? "周末全谷价" : cfgSnap.phase.inPeak ? "高峰时段" : "平价时段") : "…"))),
1079
+ noticeOn ? e("div", { style: { marginTop: "8px" } }, e(PeakStrip, { snap: cfgSnap, style: d.peakStyle || "compact", wide: true, now, ringSize: 150 }))
1080
+ : e("p", { className: "cost-hint", style: { marginTop: "8px" } }, "提示已隐藏:需启用峰谷计价并开启「峰时高价时段显著提示」。"));
1081
+ }
1082
+
1083
+ function Dashboard() {
1084
+ const [days, setDays] = useState(7);
1085
+ const [dash, setDash] = useState(null);
1086
+ const [dashErr, setDashErr] = useState("");
1087
+ const [kimi, setKimi] = useState(null);
1088
+ const [balance, setBalance] = useState(null);
1089
+ const [tab, setTab] = useState("period");
1090
+ const [scheme, setSchemeState] = useState(() => {
1091
+ try { return localStorage.getItem("dsh-cost-tracker-scheme") || "orange-yellow" } catch (e) { return "orange-yellow" }
1092
+ });
1093
+ function setScheme(k) {
1094
+ setSchemeState(k);
1095
+ try { localStorage.setItem("dsh-cost-tracker-scheme", k) } catch (e) {}
1096
+ }
1097
+ const [msg, setMsg] = useState("");
1098
+ const [busy, setBusy] = useState(false);
1099
+ const [now, setNow] = useState(Date.now());
1100
+ const [manualKey, setManualKey] = useState("");
1101
+ const [usage, setUsage] = useState(null);
1102
+ const [usageErr, setUsageErr] = useState("");
1103
+
1104
+ function loadDash(d) {
1105
+ apiCall("dashboard", { days: d }).then(v => {
1106
+ if (v && v.ok) { setDash(v); setDashErr(""); }
1107
+ else setDashErr(v && v.error ? String(v.error) : "数据加载失败");
1108
+ }).catch(err => setDashErr(String(err && err.message ? err.message : err)));
1109
+ }
1110
+ function loadKimi(force) {
1111
+ apiCall("kimi-usage", { force: !!force }).then(v => setKimi(v)).catch(() => {});
1112
+ }
1113
+ function loadUsage() {
1114
+ apiCall("usage", {}).then(v => {
1115
+ if (v && v.ok) { setUsage(v); setUsageErr(""); }
1116
+ else setUsageErr(v && v.error ? String(v.error) : "数据加载失败");
1117
+ }).catch(err => setUsageErr(String(err && err.message ? err.message : err)));
1118
+ }
1119
+ function loadBalance(key) {
1120
+ apiCall("balance", key ? { apiKey: key } : {}).then(v => setBalance(v)).catch(() => {});
1121
+ }
1122
+ function onExport() {
1123
+ setBusy(true); setMsg("导出中…");
1124
+ apiCall("export", {}).then(v => {
1125
+ setBusy(false);
1126
+ setMsg(v && v.ok ? "已导出 " + v.count + " 条到 " + v.path : "导出失败:" + (v && v.error ? v.error : "未知错误"));
1127
+ }).catch(err => { setBusy(false); setMsg("导出失败:" + String(err && err.message ? err.message : err)); });
1128
+ }
1129
+ function onRefresh() {
1130
+ setBusy(true);
1131
+ apiCall("dashboard", { days }).then(v => { if (v && v.ok) setDash(v); }).catch(() => {});
1132
+ apiCall("usage", {}).then(v => {
1133
+ if (v && v.ok) { setUsage(v); setUsageErr(""); }
1134
+ else setUsageErr(v && v.error ? String(v.error) : "数据加载失败");
1135
+ }).catch(err => setUsageErr(String(err && err.message ? err.message : err)));
1136
+ apiCall("kimi-usage", { force: false }).then(v => setKimi(v)).catch(() => {});
1137
+ apiCall("balance", {}).then(v => { setBalance(v); setBusy(false); }).catch(() => setBusy(false));
1138
+ }
1139
+
1140
+ useEffect(() => { loadDash(days); }, [days]);
1141
+ useEffect(() => { loadKimi(false); loadBalance(""); loadUsage(); }, []);
1142
+ useEffect(() => {
1143
+ const id = setInterval(() => setNow(Date.now()), 30000);
1144
+ return () => clearInterval(id);
1145
+ }, []);
1146
+
1147
+ return e("div", { className: "cost-wrap" },
1148
+ e("div", { className: "cost-h1" }, pluginIcon(18), "花费统计"),
1149
+ filterRow(days, setDays, onExport, onRefresh, msg, busy, dash ? dash.peakWindows : "周一至周五 9:00-12:00 · 14:00-18:00(周末全天闲时)"),
1150
+ dashErr ? e("div", { className: "cost-err", style: { marginTop: "8px" } }, dashErr) : null,
1151
+ !dash && !dashErr ? e("div", { className: "cost-hint", style: { marginTop: "12px" } }, "加载中…") : null,
1152
+ dash ? statCards(dash, balance) : null,
1153
+ dash ? mainPanel(dash, tab, setTab, scheme, setScheme) : null,
1154
+ e("div", { className: "cost-panel" },
1155
+ e("div", { className: "cost-row" }, e("span", { className: "cost-panel-title" }, "Token 用量统计")),
1156
+ e("div", { style: { marginTop: "8px" } },
1157
+ usage ? e(UsageHeatmap, { data: usage })
1158
+ : usageErr ? e("div", { className: "cost-err" }, "加载失败:" + usageErr)
1159
+ : e("div", { className: "cost-hint" }, "加载中…"))),
1160
+ subPanel(kimi, dash, now, () => loadKimi(true)),
1161
+ balancePanel(balance, manualKey, setManualKey, k => loadBalance(k)),
1162
+ dash ? modelSections(dash) : null,
1163
+ dash ? recentPanel(dash) : null,
1164
+ e(PeakPanel, {}));
1165
+ }
1166
+
1167
+ function StatusLine(props) {
1168
+ const sessionId = props && props.sessionId ? String(props.sessionId) : "";
1169
+ const [s, setS] = useState(null);
1170
+ const [expanded, setExpanded] = useState(false);
1171
+ useEffect(() => {
1172
+ let alive = true;
1173
+ function load() {
1174
+ apiCall("summary", { sessionId }).then(v => { if (alive) setS(v); }).catch(() => {});
1175
+ }
1176
+ load();
1177
+ const id = setInterval(load, 30000);
1178
+ return () => { alive = false; clearInterval(id); };
1179
+ }, [sessionId]);
1180
+ if (!s) return null;
1181
+ // 按会话实际内容决定显示(而不是按当前选中的模型):
1182
+ // - realModels 按量模型 / subModels 订阅模型(分开)
1183
+ // - 订阅只用一个着色徽标展示套餐名 + 总等效费用,订阅模型不再单独进模型区(避免重复)
1184
+ // 布局:胶囊分段(主胶囊 / 订阅胶囊 / 模型胶囊),用细竖线分隔,悬停或点击展开模型。
1185
+ const realModels = Array.isArray(s.sessionRealModels) ? s.sessionRealModels : [];
1186
+ const subModels = Array.isArray(s.sessionSubModels) ? s.sessionSubModels : [];
1187
+ const hasReal = realModels.length > 0 || s.sessionCost > 0;
1188
+ const hasSub = subModels.length > 0 || s.sessionSub > 0;
1189
+ const chip = (m, i) => e("span", { key: "r" + i, className: "cost-chip", title: m.model },
1190
+ shortModel(m.model) + " ¥" + fmtMoney(m.cost));
1191
+ // 主胶囊:标明「本会话」+ 本会话花费(不需要累计)
1192
+ const costPill = e("span", { className: "cost-pill" },
1193
+ e("span", { className: "cost-hint" }, "本会话"),
1194
+ e("span", { className: "cost-dock-main" }, "¥" + fmtMoney(s.sessionCost)));
1195
+ // 订阅徽标(单个着色胶囊,不再把订阅模型放进模型区)
1196
+ let subPill = null;
1197
+ if (hasSub) {
1198
+ const plan = subPlanName(subModels.length ? subModels[0].provider : s.provider);
1199
+ subPill = e("span", { className: "cost-badge cost-pill-sub-badge" }, plan + " · ¥" + fmtMoney(s.sessionSub));
1200
+ }
1201
+ // 模型胶囊:默认 top2 汇总,点击展开全部(仅按量模型)
1202
+ let modelPill = null;
1203
+ const modelCount = realModels.length;
1204
+ if (realModels.length > 0) {
1205
+ const topN = realModels.slice(0, 2);
1206
+ const extraReal = realModels.length - 2;
1207
+ const toggle = e("button", {
1208
+ className: "cost-model-toggle",
1209
+ onClick: () => setExpanded(!expanded),
1210
+ title: expanded ? "收起模型明细" : "展开模型明细",
1211
+ "aria-expanded": expanded,
1212
+ }, expanded ? "▾" : "▸");
1213
+ const modelLabel = e("span", { className: "cost-hint" }, "模型 ×" + modelCount + " ");
1214
+ if (expanded) {
1215
+ modelPill = e("span", { className: "cost-pill" }, modelLabel,
1216
+ realModels.map((m, i) => chip(m, i)), toggle);
1217
+ } else {
1218
+ const topChips = topN.map((m, i) => chip(m, i));
1219
+ const more = extraReal > 0 ? e("span", { className: "cost-hint" }, "+" + extraReal) : null;
1220
+ modelPill = e("span", { className: "cost-pill" }, modelLabel, topChips, more, toggle);
1221
+ }
1222
+ }
1223
+ // 分段组装:主胶囊 | 订阅胶囊 | 模型胶囊(细竖线分隔,换行时自动分段)
1224
+ const segs = [];
1225
+ segs.push(costPill);
1226
+ if (subPill) segs.push(subPill);
1227
+ if (modelPill) segs.push(modelPill);
1228
+ return e("div", { className: "cost-dock" }, segs.map((seg, i) => e("span", { key: "seg" + i, className: "cost-seg" }, seg)));
1229
+ }
1230
+
1231
+ const inject = ["slots"];
1232
+ function apply(ctx) {
1233
+ applyStyles(ctx);
1234
+ const slots = ctx.get("slots");
1235
+ if (slots === undefined) return;
1236
+ slots.inject("settings.section", () => slots.register(
1237
+ { name: "settings.section", id: "cost-dashboard", order: 30, label: "花费统计" },
1238
+ () => e(Dashboard, {}),
1239
+ ));
1240
+ slots.inject("conversation.composer.dock", () => slots.register(
1241
+ { name: "conversation.composer.dock", id: "cost", order: 1 },
1242
+ (props) => e(StatusLine, props || {}),
1243
+ ));
1244
+ // 侧边栏底部峰谷时段条 + 切换前弹窗(常驻,无需活跃会话;wide=false 为窄栏态)
1245
+ slots.inject("sidebar.footer.action", () => slots.register(
1246
+ { name: "sidebar.footer.action", id: "cost-peak", order: 8 },
1247
+ (props) => e(PeakSidebar, props || {}),
1248
+ ));
1249
+ }
1250
+
1251
+ exports.apply = apply;
1252
+ exports.inject = inject;
1253
+ return module.exports;
1254
+ }
1255
+ });