dsh-cost-meter 1.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js ADDED
@@ -0,0 +1,4116 @@
1
+ /**
2
+ * dsh-cost-meter 浏览器端 bundle(单文件,经 __ModuleLoader__ 加载)。
3
+ *
4
+ * 提供四个界面:
5
+ * - conversation.composer.dock / conversation.session.header.actions:本会话费用;
6
+ * - sidebar.footer.action:当日费用;
7
+ * - settings.section「费用」:汇总卡片、今日会话、历史记录、显示与价格设置、
8
+ * 官方价格同步、历史清除。
9
+ *
10
+ * 数据通道:
11
+ * - costUsage 会话投影(useProjection)+ 客户端价格表 → 本会话费用;
12
+ * - remote.costMeter.*(Typert RPC)→ 账本快照、配置、官方价格同步。
13
+ * 样式全部使用 --dsw-* 主题变量,跟随全局亮/暗主题。
14
+ */
15
+
16
+ window.__ModuleLoader__.load({
17
+ id: 'dsh-cost-meter',
18
+ factory: (require) => {
19
+ var module = { exports: {} }
20
+ var exports = module.exports
21
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
22
+
23
+ const React = require('react')
24
+ const { Tooltip } = require('@deepseek-ai/dsh-client-ui-primitives')
25
+
26
+ // Token 用量统计的显示位置切换(通用设置 / 独立分节)暂时隐藏:仅固定显示在「费用」设置分节内。
27
+ // 恢复三位置切换时改回 true 即可(下拉框、通用设置注入与独立分节注册都会随之恢复)。
28
+ const USAGE_POSITION_SWITCHABLE = false
29
+
30
+ // ── 样式 ────────────────────────────────────────────────────────────────
31
+
32
+ const css = [
33
+ '/* dsh-cost-meter: 会话费用徽章与设置页 */',
34
+ '.cm-root{display:block;text-align:center;max-width:var(--dsh-chat-content-width,720px);width:100%;margin:0 auto;box-sizing:border-box;padding:4px calc(var(--dsh-composer-side-clearance,0px) + 16px) 0;font-size:12px;line-height:20px;color:var(--dsw-alias-label-tertiary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
35
+ '.cm-chip{display:inline-flex;align-items:center;gap:4px;max-width:180px;padding:0 8px;height:22px;border-radius:6px;background:var(--dsw-alias-bg-layer-2);font-size:12px;line-height:22px;color:var(--dsw-alias-label-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
36
+ '.cm-foot{display:flex;align-items:center;gap:6px;height:32px;padding:0 8px;border-radius:8px;font-size:12px;color:var(--dsw-alias-label-secondary);white-space:nowrap;overflow:hidden}',
37
+ '.cm-foot:hover{background:var(--dsw-alias-interactive-bg-hover)}',
38
+ '.cm-foot-rail{width:100%;justify-content:center;padding:0;font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary)}',
39
+ '.cm-foot-rail:hover{background:var(--dsw-alias-interactive-bg-hover)}',
40
+ '.cm-num{font-variant-numeric:tabular-nums}',
41
+ '.cm-go-rail{font-size:11px;font-weight:700;color:var(--dsw-alias-label-primary)}',
42
+ '.cm-go-list{display:flex;flex-direction:column;gap:10px}',
43
+ '.cm-go-row{display:flex;align-items:center;gap:8px;font-size:12px}',
44
+ '.cm-go-label{flex:none;width:88px;color:var(--dsw-alias-label-secondary)}',
45
+ '.cm-go-bar{flex:1;height:6px;border-radius:3px;background:var(--dsw-alias-interactive-bg-hover);overflow:hidden}',
46
+ '.cm-go-fill{height:100%;border-radius:3px;background:var(--dsw-alias-brand-primary)}',
47
+ '.cm-go-num{flex:none;min-width:44px;text-align:right;font-weight:600;font-variant-numeric:tabular-nums}',
48
+ '.cm-go-reset{flex:none;max-width:230px;font-size:11px;color:var(--dsw-alias-label-tertiary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
49
+ '.cm-go-time{font-size:11px;color:var(--dsw-alias-label-tertiary)}',
50
+ '.cm-go-row.main .cm-go-label{font-weight:600;color:var(--dsw-alias-label-primary)}',
51
+ '.cm-corner{display:flex;flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:6px;width:100%;max-width:var(--dsh-chat-content-width,720px);margin:2px auto 0;box-sizing:border-box;padding:0 calc(var(--dsh-composer-side-clearance,0px) + 16px)}',
52
+ '.cm-corner-chip{display:inline-flex;align-items:center;height:20px;padding:0 8px;border-radius:6px;background:var(--dsw-alias-bg-layer-2);font-size:11px;line-height:20px;color:var(--dsw-alias-label-secondary);white-space:nowrap;font-variant-numeric:tabular-nums}',
53
+ '.cm-corner-chip:hover{background:var(--dsw-alias-interactive-bg-hover)}',
54
+ '.cm-corner-chip.warn{color:var(--dsw-alias-state-warn-primary)}',
55
+ '.cm-corner-chip.over{color:var(--dsw-alias-state-error-primary)}',
56
+ '.cm-section{display:flex;flex-direction:column;gap:20px;padding:4px 2px 24px;font-size:13px;color:var(--dsw-alias-label-primary)}',
57
+ '.cm-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}',
58
+ '.cm-card{border:1px solid var(--dsw-alias-border-l1);border-radius:12px;padding:14px 16px;background:var(--dsw-alias-bg-layer-1)}',
59
+ '.cm-card-title{font-size:12px;color:var(--dsw-alias-label-tertiary);margin:0 0 8px}',
60
+ '.cm-card-value{font-size:20px;line-height:28px;font-weight:600}',
61
+ '.cm-card-sub{font-size:12px;color:var(--dsw-alias-label-tertiary);margin-top:4px}',
62
+ '.cm-h{font-size:13px;font-weight:600;margin:0}',
63
+ // 可折叠分节:常规三角展开按钮(caret 三角形,展开朝下/收起朝右)。
64
+ '.cm-collapse-h{display:flex;align-items:center;gap:8px;background:none;border:none;padding:0;margin:0;cursor:pointer;color:inherit;font:inherit;text-align:left}',
65
+ '.cm-collapse-h:hover .cm-h{color:var(--dsw-alias-interactive-text-hover,var(--dsw-alias-label-primary))}',
66
+ '.cm-caret{flex:none;width:0;height:0;border-left:5px solid transparent;border-right:5px solid transparent;border-top:6px solid var(--dsw-alias-label-secondary);transform:rotate(-90deg);transition:transform .15s ease}',
67
+ '.cm-caret.open{transform:rotate(0)}',
68
+ '.cm-collapse-body{display:flex;flex-direction:column;gap:12px;margin-top:12px}',
69
+ // 顶栏:界面语言等即时可见项,右对齐。
70
+ '.cm-toolbar{display:flex;justify-content:flex-end;align-items:center;gap:10px}',
71
+ '.cm-toolbar .cm-field{flex-direction:row;align-items:center;gap:8px}',
72
+ '.cm-toolbar .cm-field label{margin:0;font-size:12px;color:var(--dsw-alias-label-tertiary)}',
73
+ '.cm-toolbar .cm-input{min-width:150px;padding:4px 10px}',
74
+ '.cm-note{font-size:12px;color:var(--dsw-alias-label-tertiary);margin:0}',
75
+ '.cm-table{width:100%;border-collapse:collapse;font-size:12px}',
76
+ '.cm-table th,.cm-table td{text-align:left;padding:7px 10px;border-bottom:1px solid var(--dsw-alias-border-l1);white-space:nowrap}',
77
+ '.cm-table th{color:var(--dsw-alias-label-tertiary);font-weight:500}',
78
+ '.cm-table td.num,.cm-table th.num{text-align:right;font-variant-numeric:tabular-nums}',
79
+ '.cm-table tr:last-child td{border-bottom:none}',
80
+ '.cm-empty{font-size:12px;color:var(--dsw-alias-label-tertiary);padding:8px 0}',
81
+ '.cm-scroll{max-height:320px;overflow:auto;border:1px solid var(--dsw-alias-border-l1);border-radius:10px}',
82
+ '.cm-grid{display:grid;grid-template-columns:repeat(2,minmax(220px,1fr));gap:12px}',
83
+ '.cm-field{display:flex;flex-direction:column;gap:6px}',
84
+ '.cm-field label{font-size:12px;color:var(--dsw-alias-label-secondary)}',
85
+ '.cm-input{font:inherit;font-size:13px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:6px 10px;outline:none}',
86
+ '.cm-input:focus{border-color:var(--dsw-alias-state-business-primary)}',
87
+ '.cm-input.narrow{max-width:120px}',
88
+ '.cm-check{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--dsw-alias-label-primary);cursor:pointer}',
89
+ '.cm-price-card{border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:12px 14px;display:flex;flex-direction:column;gap:10px;background:var(--dsw-alias-bg-layer-1)}',
90
+ '.cm-price-head{display:flex;align-items:center;justify-content:space-between;gap:8px}',
91
+ '.cm-price-name{font-weight:600;font-size:13px}',
92
+ '.cm-price-legacy{font-size:11px;color:var(--dsw-alias-label-tertiary);border:1px solid var(--dsw-alias-border-l1);border-radius:999px;padding:1px 8px}',
93
+ '.cm-price-row{display:grid;grid-template-columns:52px 1fr 1fr 1fr;gap:8px;align-items:center}',
94
+ '.cm-price-row span{font-size:12px;color:var(--dsw-alias-label-tertiary)}',
95
+ '.cm-price-row input{width:100%}',
96
+ '.cm-buttons{display:flex;flex-wrap:wrap;gap:10px;align-items:center}',
97
+ '.cm-btn{font:inherit;font-size:13px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-button-elevated-fill);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:6px 14px;cursor:pointer}',
98
+ '.cm-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}',
99
+ '.cm-btn.primary{background:var(--dsw-alias-state-business-primary);border-color:transparent;color:var(--dsw-alias-label-primary-inverted)}',
100
+ '.cm-btn.primary:hover{opacity:0.88;background:var(--dsw-alias-state-business-primary)}',
101
+ '.cm-btn.danger{color:var(--dsw-alias-state-error-primary)}',
102
+ '.cm-btn.small{padding:3px 10px;font-size:12px}',
103
+ '.cm-msg{font-size:12px;line-height:18px;padding:8px 12px;border-radius:8px;border:1px solid var(--dsw-alias-border-l1)}',
104
+ '.cm-msg.ok{color:var(--dsw-alias-state-success-primary)}',
105
+ '.cm-msg.err{color:var(--dsw-alias-state-error-primary)}',
106
+ '.cm-hint{font-size:12px;color:var(--dsw-alias-label-tertiary)}',
107
+ '.cm-budget{border:1px solid var(--dsw-alias-border-l1);border-radius:12px;padding:14px 16px;background:var(--dsw-alias-bg-layer-1);display:flex;flex-direction:column;gap:10px}',
108
+ '.cm-budget-head{display:flex;align-items:center;justify-content:space-between;gap:8px}',
109
+ '.cm-budget-bar{height:8px;border-radius:999px;background:var(--dsw-alias-bg-layer-3);overflow:hidden}',
110
+ '.cm-budget-fill{height:100%;border-radius:999px;background:var(--dsw-alias-state-business-primary);transition:width .3s ease}',
111
+ '.cm-budget-fill.warn{background:var(--dsw-alias-state-warn-primary)}',
112
+ '.cm-budget-fill.over{background:var(--dsw-alias-state-error-primary)}',
113
+ '.cm-ug{display:flex;flex-direction:column;gap:12px}',
114
+ '.cm-ug-total{font-size:13px;color:var(--dsw-alias-label-primary)}',
115
+ '.cm-ug-grid{display:grid;grid-auto-flow:column;grid-template-rows:repeat(7,auto);gap:3px;width:100%}',
116
+ '.cm-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) 8%,transparent);border:1px solid var(--dsw-alias-border-l1)}',
117
+ '.cm-ug-cell.l1{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 25%,var(--dsw-alias-bg-layer-3));border-color:transparent}',
118
+ '.cm-ug-cell.l2{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 50%,var(--dsw-alias-bg-layer-3));border-color:transparent}',
119
+ '.cm-ug-cell.l3{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 75%,var(--dsw-alias-bg-layer-3));border-color:transparent}',
120
+ '.cm-ug-cell.l4{background:var(--dsw-alias-state-business-primary);border-color:transparent}',
121
+ '.cm-ug-cell.today{outline:1px solid var(--dsw-alias-label-secondary);outline-offset:1px}',
122
+ '.cm-ug-months{display:grid;grid-auto-flow:column;gap:3px;width:100%;font-size:10px;color:var(--dsw-alias-label-tertiary);margin-top:4px}',
123
+ '.cm-ug-monthc{white-space:nowrap}',
124
+ '.cm-budget-line{font-size:13px;color:var(--dsw-alias-label-secondary)}',
125
+ '.cm-budget-line.over{color:var(--dsw-alias-state-error-primary)}',
126
+ '.cm-peak-strip{display:flex;align-items:center;gap:6px;margin-top:4px;min-width:0}',
127
+ '.cm-peak-track{position:relative;display:flex;flex:1;height:6px;min-width:0;border-radius:999px;overflow:hidden;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-3)}',
128
+ '.cm-peak-segment{height:100%;flex:1}',
129
+ '.cm-peak-high{background:#ff9800}',
130
+ '.cm-peak-low{background:var(--dsw-alias-state-business-primary)}',
131
+ '.cm-peak-marker{position:absolute;top:0;left:50%;width:2px;height:100%;background:var(--dsw-alias-bg-base);box-shadow:0 0 0 1px var(--dsw-alias-label-tertiary);transform:translateX(-50%);transition:left .4s ease;z-index:2}',
132
+ '.cm-peak-chip{font-size:11px;font-weight:600;line-height:1.2;white-space:nowrap;color:var(--dsw-alias-label-secondary)}',
133
+ '.cm-peak-strip.peak .cm-peak-chip{color:#ff9800}',
134
+ '.cm-peak-strip.off .cm-peak-chip{color:var(--dsw-alias-state-business-primary)}',
135
+ '.cm-peak-classic{position:relative;display:flex;flex-direction:column;gap:5px;margin-top:6px;min-width:0}',
136
+ '.cm-peak-classic .cm-peak-track{flex:none;height:8px}',
137
+ '.cm-peak-classic-marker{position:absolute;top:0;left:50%;width:4px;height:12px;background:var(--dsw-alias-bg-base);border:1.5px solid #ff9800;box-shadow:0 0 0 1px var(--dsw-alias-bg-base),0 0 0 2px var(--dsw-alias-label-tertiary);transform:translateX(-50%);transition:left .4s ease;z-index:2;border-radius:2px}',
138
+ '.cm-peak-classic-marker::after{content:"";position:absolute;top:-7px;left:50%;transform:translateX(-50%);border-left:5px solid transparent;border-right:5px solid transparent;border-top:6px solid #ff9800}',
139
+ '.cm-peak-classic.off .cm-peak-classic-marker{border-color:var(--dsw-alias-state-business-primary)}',
140
+ '.cm-peak-classic.off .cm-peak-classic-marker::after{border-top-color:var(--dsw-alias-state-business-primary)}',
141
+ '.cm-peak-classic-chip{align-self:flex-start;font-size:11px;font-weight:600;line-height:1.4;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:999px;padding:2px 8px;white-space:nowrap;color:var(--dsw-alias-label-secondary)}',
142
+ '.cm-peak-classic.peak .cm-peak-classic-chip{color:#ff9800}',
143
+ '.cm-peak-classic.off .cm-peak-classic-chip{color:var(--dsw-alias-state-business-primary)}',
144
+ '.cm-peak-rail{display:flex;flex-direction:column;align-items:center;gap:3px;width:40px;box-sizing:border-box}',
145
+ '.cm-peak-rail-track{position:relative;display:flex;flex-direction:column;width:6px;height:56px;border-radius:999px;overflow:hidden;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-3)}',
146
+ '.cm-peak-rail-segment{width:100%;flex:1}',
147
+ '.cm-peak-rail-high{background:#ff9800}',
148
+ '.cm-peak-rail-low{background:var(--dsw-alias-state-business-primary)}',
149
+ '.cm-peak-rail-marker{position:absolute;left:0;top:50%;width:100%;height:2px;background:var(--dsw-alias-bg-base);box-shadow:0 0 0 1px var(--dsw-alias-label-tertiary);transform:translateY(-50%);transition:top .4s ease;z-index:2}',
150
+ '.cm-peak-rail-label{font-size:10px;font-weight:600;line-height:1.2;white-space:nowrap;color:var(--dsw-alias-label-secondary)}',
151
+ '.cm-peak-rail.peak .cm-peak-rail-label{color:#ff9800}',
152
+ '.cm-peak-rail.off .cm-peak-rail-label{color:var(--dsw-alias-state-business-primary)}',
153
+ '.cm-peak-rail-classic{display:flex;flex-direction:column;align-items:center;gap:3px;width:40px;box-sizing:border-box}',
154
+ '.cm-peak-rail-classic-track{position:relative;display:flex;flex-direction:column;width:14px;height:56px;border-radius:999px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-3)}',
155
+ '.cm-peak-rail-classic-segment{position:relative;width:100%;flex:1;overflow:hidden}',
156
+ '.cm-peak-rail-classic-segment.peak{background:#ff9800;border-radius:999px 999px 0 0}',
157
+ '.cm-peak-rail-classic-segment.off{background:var(--dsw-alias-state-business-primary);border-radius:0 0 999px 999px}',
158
+ '.cm-peak-rail-classic-marker{position:absolute;left:50%;top:50%;width:12px;height:4px;background:var(--dsw-alias-bg-base);border:1.5px solid #ff9800;box-shadow:0 0 0 1px var(--dsw-alias-bg-base),0 0 0 2px var(--dsw-alias-label-tertiary);transform:translate(-50%,-50%);transition:top .4s ease;z-index:2;border-radius:2px}',
159
+ '.cm-peak-rail-classic.off .cm-peak-rail-classic-marker{border-color:var(--dsw-alias-state-business-primary)}',
160
+ '.cm-peak-rail-classic-label{font-size:10px;font-weight:600;line-height:1.2;white-space:nowrap}',
161
+ '.cm-peak-rail-classic.peak .cm-peak-rail-classic-label{color:#ff9800}',
162
+ '.cm-peak-rail-classic.off .cm-peak-rail-classic-label{color:var(--dsw-alias-state-business-primary)}',
163
+ '.cm-grid-group{grid-column:1 / -1;margin-top:8px;font-size:12px;font-weight:600;color:var(--dsw-alias-label-tertiary);border-bottom:1px solid var(--dsw-alias-border-l1);padding-bottom:4px}',
164
+ '.cm-grid-group:first-child{margin-top:0}',
165
+ '.cm-peak-preview{margin-top:8px}',
166
+ '.cm-toggle-btn{background:none;border:none;padding:0;font-size:12px;font-weight:600;color:var(--dsw-alias-state-business-primary);cursor:pointer}',
167
+ '.cm-toggle-btn:hover{text-decoration:underline}',
168
+ '.cm-collapsed-note{margin-top:4px}',
169
+ '.cm-catalog-vendor{margin-top:10px;font-size:12px;font-weight:600;color:var(--dsw-alias-label-tertiary);border-bottom:1px solid var(--dsw-alias-border-l1);padding-bottom:2px;display:flex;align-items:center;justify-content:space-between;gap:8px}',
170
+ '.cm-vendor-toggle{cursor:pointer;user-select:none}',
171
+ '.cm-vendor-toggle:hover{color:var(--dsw-alias-label-secondary)}',
172
+ '.cm-vendor-display{display:inline-flex;align-items:center;gap:4px;font-weight:400;font-size:11px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}',
173
+ '.cm-catalog-family{margin:6px 0 2px;font-size:11px;font-weight:600;color:var(--dsw-alias-label-tertiary)}',
174
+ '.cm-catalog-row{display:flex;align-items:center;gap:8px;padding:4px 0;border-top:1px solid var(--dsw-alias-border-l1);font-size:12px}',
175
+ '.cm-catalog-mounted{display:flex;flex-direction:column;gap:4px;padding:6px 0;border-top:1px solid var(--dsw-alias-border-l1)}',
176
+ '.cm-catalog-id{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
177
+ '.cm-catalog-price{color:var(--dsw-alias-label-secondary);white-space:nowrap;font-size:11px}',
178
+ '.cm-catalog-tag{font-size:10px;padding:1px 6px;border-radius:999px;border:1px solid var(--dsw-alias-state-business-primary);color:var(--dsw-alias-state-business-primary);white-space:nowrap}',
179
+ '.cm-mstats-tabs{display:flex;gap:8px;margin-bottom:8px}',
180
+ '.cm-mstats-tab{background:none;border:1px solid var(--dsw-alias-border-l1);border-radius:999px;padding:2px 12px;font-size:12px;color:var(--dsw-alias-label-secondary);cursor:pointer}',
181
+ '.cm-mstats-tab.active{background:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-bg-base);font-weight:600}',
182
+ '.cm-mstats-h{margin:10px 0 4px;font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary)}',
183
+ '.cm-mstats-row{display:grid;grid-template-columns:minmax(90px,170px) 1fr auto;gap:8px;align-items:center;font-size:11px;line-height:1.7}',
184
+ '.cm-mstats-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-secondary)}',
185
+ '.cm-mstats-barbg{position:relative;height:10px;border-radius:999px;background:var(--dsw-alias-bg-layer-3);overflow:hidden;display:flex}',
186
+ '.cm-mstats-bar{height:100%;min-width:0}',
187
+ '.cm-mstats-bar.cost{background:#ff9800;border-radius:999px}',
188
+ '.cm-mstats-bar.hit{background:#34a853;border-radius:999px}',
189
+ '.cm-mstats-bar.value{background:#a142f4;border-radius:999px}',
190
+ '.cm-mstats-seg{height:100%;min-width:0}',
191
+ '.cm-mstats-seg.in{background:var(--dsw-alias-state-business-primary)}',
192
+ '.cm-mstats-seg.cache{background:#ff9800}',
193
+ '.cm-mstats-seg.out{background:#34a853}',
194
+ '.cm-mstats-val{white-space:nowrap;color:var(--dsw-alias-label-secondary)}',
195
+ '.cm-mstats-legend{display:flex;gap:12px;font-size:10px;color:var(--dsw-alias-label-tertiary);margin:2px 0 4px}',
196
+ '.cm-mstats-dot{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:3px}',
197
+ '.cm-mstats-note{margin-top:10px;font-size:10px;line-height:1.6;color:var(--dsw-alias-label-tertiary)}',
198
+ '.cm-match-row{display:flex;align-items:center;gap:8px;padding:3px 0;font-size:12px;flex-wrap:wrap}',
199
+ '.cm-match-row .cm-input{flex:1;min-width:160px}',
200
+ '.cm-budget-controls{display:grid;grid-template-columns:repeat(2,minmax(180px,1fr));gap:12px}',
201
+ '.cm-bbox{border:1px solid var(--dsw-alias-border-l1);border-radius:12px;padding:8px 10px;background:var(--dsw-alias-bg-layer-1);display:flex;flex-direction:column;gap:6px;min-width:148px;box-sizing:border-box}',
202
+ '.cm-bbox.rail{padding:6px;min-width:0;width:40px;align-items:center;justify-content:center;border-radius:10px}',
203
+ '.cm-bbox.warn{border-color:var(--dsw-alias-state-warn-primary)}',
204
+ '.cm-bbox.over{border-color:var(--dsw-alias-state-error-primary)}',
205
+ '.cm-bbox-head{display:flex;align-items:center;justify-content:space-between;gap:8px}',
206
+ '.cm-bbox-label{font-size:12px;color:var(--dsw-alias-label-secondary)}',
207
+ '.cm-bbox-pct{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary)}',
208
+ '.cm-bbox.warn .cm-bbox-pct{color:var(--dsw-alias-state-warn-primary)}',
209
+ '.cm-bbox.over .cm-bbox-pct{color:var(--dsw-alias-state-error-primary)}',
210
+ '.cm-bbox-bar{height:6px;border-radius:999px;background:var(--dsw-alias-bg-layer-3);overflow:hidden}',
211
+ '.cm-bbox-bar.segments{display:flex;height:8px}',
212
+ '.cm-bbox-bar.segments .cm-bbox-fill{flex:none;border-radius:0;height:100%}',
213
+ '.cm-bbox-bar.segments .cm-bbox-fill:first-child{border-radius:999px 0 0 999px}',
214
+ '.cm-bbox-bar.segments .cm-bbox-seg-today{flex:none;height:100%;background:#ff9800}',
215
+ '.cm-bbox-bar.segments .cm-bbox-seg-spent{flex:none;height:100%;background:var(--dsw-alias-interactive-bg-hover);border-radius:0 999px 999px 0}',
216
+ '.cm-bbox-fill{height:100%;border-radius:999px;background:var(--dsw-alias-state-business-primary)}',
217
+ '.cm-bbox-pct.cm-bal-amt{color:var(--dsw-alias-state-business-primary)}',
218
+ '.cm-bbox.warn .cm-bbox-fill{background:var(--dsw-alias-state-warn-primary)}',
219
+ '.cm-bbox.over .cm-bbox-fill{background:var(--dsw-alias-state-error-primary)}',
220
+ '.cm-bbox-line{font-size:12px;color:var(--dsw-alias-label-tertiary)}',
221
+ '.cm-bbox-rail{font-size:11px;font-weight:600;color:var(--dsw-alias-label-primary)}',
222
+ '.cm-bbox.warn .cm-bbox-rail{color:var(--dsw-alias-state-warn-primary)}',
223
+ '.cm-bbox.over .cm-bbox-rail{color:var(--dsw-alias-state-error-primary)}',
224
+ '.cm-bbox-pair{display:flex;flex-direction:column;gap:0;padding:2px 10px}',
225
+ '.cm-bbox-pair .cm-bbox-section{display:flex;flex-direction:column;gap:4px;padding:4px 0}',
226
+ '.cm-bbox-pair .cm-bbox-bar{height:4px}',
227
+ '.cm-bbox-divider{height:1px;background:var(--dsw-alias-border-l1);margin:0}',
228
+ '.cm-bbox-section.warn .cm-bbox-pct{color:var(--dsw-alias-state-warn-primary)}',
229
+ '.cm-bbox-section.over .cm-bbox-pct{color:var(--dsw-alias-state-error-primary)}',
230
+ '.cm-bbox-section.warn .cm-bbox-fill{background:var(--dsw-alias-state-warn-primary)}',
231
+ '.cm-bbox-section.over .cm-bbox-fill{background:var(--dsw-alias-state-error-primary)}',
232
+ '.cm-bal-line{font-size:13px;color:var(--dsw-alias-label-secondary)}',
233
+ '.cm-bal-line.warn{color:var(--dsw-alias-state-warning-primary,#b45309)}',
234
+ '.cm-bal-line.err,.cm-bal-err{color:var(--dsw-alias-state-error-primary)}',
235
+ '.cm-footer-stack{display:flex;flex-direction:column;gap:6px;width:100%;align-items:stretch;box-sizing:border-box}',
236
+ '.cm-footer-stack.rail{align-items:center}',
237
+ '.cm-footer-stack .cm-bbox{width:100%;min-width:0}',
238
+ '.cm-footer-stack .cm-foot{width:100%;box-sizing:border-box}',
239
+ '@media (max-width:640px){.cm-cards{grid-template-columns:1fr}.cm-grid{grid-template-columns:1fr}.cm-budget-controls{grid-template-columns:1fr}}',
240
+ ].join('\n')
241
+ const cssTagId = 'dsh-cost-meter/client.css'
242
+ if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(cssTagId) + ']') === null) {
243
+ const tag = document.createElement('style')
244
+ tag.dataset.plugin = 'dsh-cost-meter'
245
+ tag.dataset.pluginCss = cssTagId
246
+ tag.textContent = css
247
+ document.head.appendChild(tag)
248
+ }
249
+
250
+ // ── 多语言(中/英) ──────────────────────────────────────────────────────
251
+
252
+ /** 全部界面文案:zh / en。{var} 为插值占位。 */
253
+ const MESSAGES = {
254
+ zh: {
255
+ // 会话徽章
256
+ sessionCostTitle: '本会话费用(按每次调用实际时刻精确计费)',
257
+ sessionDetailTokens: '输入 {input} · 缓存 {cache} · 输出 {output}',
258
+ sessionDetailCache: '缓存:读 {read} · 写 {write}(写入按命中价计费)',
259
+ cost: '费用 {amount}',
260
+ sessionLine: '本会话 {amount} · 输入 {input} · 缓存 {cache} · 输出 {output}',
261
+ // 余额行
262
+ balanceQueryFailed: '余额查询失败:{message}',
263
+ unknownError: '未知错误',
264
+ balance: '余额',
265
+ queryFailed: '查询失败',
266
+ balanceTitle: 'DeepSeek 开放平台账户余额',
267
+ reconcileLabel: '官方余额变动与本地账本交叉对账(偏差超阈时提示)',
268
+ totalBalance: '总余额 {amount}',
269
+ grantedToppedUp: '赠送 {granted} · 充值 {toppedUp}',
270
+ updatedAt: '更新时间 {time}',
271
+ // 预算图框
272
+ budgetOf: '预算({period})',
273
+ usedOf: '已用 {used} / {amount}',
274
+ todayShare: '今日 {amount} · 占预算 {pct}',
275
+ monthTotal: '本月 {month} · 累计 {total}',
276
+ budget: '预算',
277
+ todayCostTitle: '今日费用(按官方价格精确计费)',
278
+ callsTokens: '调用 {calls} 次 · 输入 {input} · 缓存 {cache} · 输出 {output}',
279
+ monthCost: '本月 {amount}',
280
+ totalCost: '累计 {amount}',
281
+ today: '今日',
282
+ // 周期
283
+ periodDay: '今日',
284
+ periodMonth: '本月',
285
+ periodAll: '累计',
286
+ periodCustom: '自定义',
287
+ periodCustomRange: '自定义区间',
288
+ // 表格
289
+ noHistory: '暂无历史记录。开始对话后,费用将按天汇总在这里。',
290
+ colDate: '日期',
291
+ colCalls: '调用',
292
+ colInTok: '输入 tok',
293
+ colCacheTok: '缓存 tok',
294
+ colOutTok: '输出 tok',
295
+ colCost: '费用',
296
+ noSessionsToday: '今日暂无会话记录。',
297
+ colSession: '会话',
298
+ // 预算面板
299
+ enableBudget: '启用预算',
300
+ budgetAmountLabel: '预算额度(按显示币种)',
301
+ budgetPeriodLabel: '预算周期',
302
+ startDate: '开始日期',
303
+ endDate: '结束日期(留空 = 今日)',
304
+ rangeText: '统计区间:{range}',
305
+ budgetDisabledNote: '未启用预算。启用后此处显示预算额度、已用金额与已用百分比(按当前币种换算);周期可选今日/本月/累计或自定义日期区间。',
306
+ budgetStatus: '{period}预算 {amount} · 已用 {used} · {pct}%',
307
+ overLimit: '(已超出)',
308
+ nearLimit: '(接近上限)',
309
+ // 价格卡
310
+ legacyModel: '旧模型',
311
+ defaultFallback: '默认回退',
312
+ remove: '移除',
313
+ tierBase: '基础',
314
+ tierOffPeak: '谷时',
315
+ tierPeak: '峰时',
316
+ // 余额面板
317
+ balanceRefreshFailed: '余额刷新失败:{message}',
318
+ balanceLine: '总余额 {total} · 赠送 {granted} · 充值 {toppedUp} · 更新于 {time}',
319
+ balanceQueryFailedHint: '余额查询失败:{message}(使用 设置→模型 中配置的 API Key)',
320
+ balanceNotQueried: '未查询余额',
321
+ accountBalance: '官方账户余额',
322
+ refreshing: '刷新中…',
323
+ refreshBalance: '刷新余额',
324
+ customBalance: '自定义余额',
325
+ customBalanceTitle: '自定义 Provider 余额',
326
+ customBalanceConfigNote: '配置自定义 Provider 的余额查询请求与解析规则。请求头支持 {{ENV_VAR}} 占位符,从 DSH 凭据库或环境变量解析。',
327
+ customBalanceLabelZh: '中文名称',
328
+ customBalanceLabelEn: '英文名称',
329
+ customBalanceUrl: '请求 URL',
330
+ customBalanceMethod: '请求方法',
331
+ customBalanceHeaders: '请求头 (JSON)',
332
+ customBalanceExtract: '解析规则 (JSON)',
333
+ customBalanceDisplayLabel: '自定义余额显示位置',
334
+ customBalanceRefreshInterval: '刷新间隔 (分钟)',
335
+ customBalanceUnitLabel: '余额币种',
336
+ balanceBarRemaining: '余额 {amount}',
337
+ balanceBarToday: '当日已用 {amount}',
338
+ balanceBarSpent: '已用 {amount}',
339
+ balanceShowProgressBar: '余额进度条 (蓝=余额,橙=当日,灰=已用)',
340
+ balanceBudgetCapLabel: '额度上限 (可选)',
341
+ balanceBudgetCapHint: '留空则优先使用 API 返回的 max_budget;仍无则显示整条浅蓝',
342
+ customBalanceInvalidJson: 'JSON 格式无效',
343
+ customBalanceOpenConfig: '展开配置',
344
+ customBalanceCollapseConfig: '收起配置',
345
+ customBalanceLine: '剩余 {remaining} · 已用 {spend} / 额度 {maxBudget} · 更新于 {time}',
346
+ customBalanceRemaining: '剩余 {amount}',
347
+ refreshCustomBalance: '刷新自定义余额',
348
+ customBalanceRefreshFailed: '自定义余额刷新失败:{message}',
349
+ goQuotaTitle: 'OpenCode Go 订阅额度',
350
+ goQuotaRowLabel: 'Go',
351
+ goWindowRolling: '滚动 5 小时',
352
+ goWindowWeekly: '本周',
353
+ goWindowMonthly: '本月',
354
+ goResetAt: '重置:{time}',
355
+ goQuotaPercent: '{percent}%',
356
+ goQuotaFetchedAt: '更新于 {time}',
357
+ goQuotaDisplayLabel: 'Go 额度显示位置',
358
+ goQuotaRefreshIntervalLabel: 'Go 额度刷新间隔(分钟)',
359
+ goQuotaKeyLabel: 'OpenCode Go API Key(可选,留空自动发现)',
360
+ goMainLabel: 'Go 额度主档位',
361
+ goDetailLabel: 'Go 图框详细信息',
362
+ budgetDetailLabel: '预算图框详细信息',
363
+ refreshGoQuota: '刷新额度',
364
+ goQuotaNotQueried: '未查询额度',
365
+ enableGoQuota: '启用 OpenCode Go 额度',
366
+ codingPlansTitle: 'Coding Plan 额度',
367
+ codingPlansNote: '按厂商查询 coding plan 订阅额度(各家独立开关与凭据)。凭据只发送到对应厂商的官方端点;Key 留空时按环境变量与 CLI 登录态自动发现。',
368
+ codingPlansOpen: '展开 Coding Plan 额度配置',
369
+ codingPlansCollapse: '折叠 Coding Plan 额度',
370
+ codingPlansCollapsedHint: '默认收起;展开后可为各家启用、填 Key 并查询额度,展开/收起状态会被记住。',
371
+ codingPlanAnthropic: 'Anthropic(Claude Pro/Max)',
372
+ codingPlanZai: 'Z.ai / 智谱 GLM Coding Plan',
373
+ codingPlanMinimax: 'MiniMax Token Plan',
374
+ enableCodingPlan: '启用额度查询',
375
+ codingPlanKeyLabel: 'API Key(可选,留空自动发现)',
376
+ refreshCodingPlan: '刷新',
377
+ codingPlanNotQueried: '未查询额度',
378
+ codingPlanDisabledNote: '未启用。开启后将按刷新间隔查询该厂商的 coding plan 额度并显示在这里。',
379
+ goQuotaDisabledNote: '未启用额度。开启后将读取 OpenCode Go 订阅额度(滚动 5 小时 / 本周 / 本月)并显示在侧边栏图框、设置页与右下角;没有 Go 订阅时会在这里提示原因。',
380
+ cornerLabel: '右下角显示(dock)',
381
+ cornerEnabledLabel: '在右下角显示 OpenCode Go 额度 / 预算',
382
+ cornerGoRolling: '滚动 5 小时额度',
383
+ cornerGoWeekly: '本周额度',
384
+ cornerGoMonthly: '本月额度',
385
+ cornerBudget: '预算已用%',
386
+ goShortRolling: '5h',
387
+ goShortWeekly: '周',
388
+ goShortMonthly: '月',
389
+ budgetShort: '预算',
390
+ // 设置页
391
+ ledgerReadFailed: '账本读取失败:{message}',
392
+ readingLedger: '正在读取账本…',
393
+ ledgerUnavailable: '账本不可用',
394
+ syncFailed: '同步失败:{message}',
395
+ historyCleared: '历史记录已清除。',
396
+ clearFailed: '清除失败:{message}',
397
+ peakOff: '峰谷计价已关闭,按基础价格计费',
398
+ peakNotEffective: '尚未生效(生效时间:{time}),当前按基础价格计费',
399
+ peakActive: '当前处于峰时段,按峰时价计费',
400
+ peakNotice: '当前为 DeepSeek 峰时高价时段,按峰时价计费',
401
+ offPeakActive: '当前处于谷时段,按谷时价计费',
402
+ peakShort: '峰时',
403
+ offPeakShort: '平价',
404
+ peakStyleLabel: '峰谷时段条样式',
405
+ peakStyleCompact: '简洁(单行紧凑)',
406
+ peakStyleClassic: '经典(分段与胶囊芯片)',
407
+ peakPanelTitle: '峰谷计价与提示',
408
+ peakNoticeHiddenHint: '提示已隐藏:需启用峰谷计价并开启「峰时高价时段显著提示」。',
409
+ groupGeneral: '常规',
410
+ groupMoney: '金额与币种',
411
+ groupSidebar: '侧边栏显示',
412
+ groupCorner: '右下角角标',
413
+ groupDetail: '图框详细信息',
414
+ catalogTitle: '拓展价格表',
415
+ catalogNote: '内置各厂商、按模型家族分类的参考价格目录(只读)。挂载 = 把条目复制进上方可编辑价格表并参与计费;DeepSeek 模型可取消挂载(回退默认价,目录中仍可重新挂载)。',
416
+ catalogOpen: '展开拓展价格表',
417
+ catalogCollapse: '收起拓展价格表',
418
+ mountBtn: '挂载',
419
+ unmountBtn: '取消挂载',
420
+ mountedTag: '已挂载',
421
+ catalogUnpriced: '未核价',
422
+ catalogDeepseekNote: 'DeepSeek 模型支持峰谷两档;取消挂载后按默认价计费且不再被自动匹配命中。',
423
+ modelStatsTitle: '按模型统计(token 与费用)',
424
+ modelStatsToday: '今日',
425
+ modelStatsHistory: '近 90 天',
426
+ modelStatsCostH: '费用排行',
427
+ modelStatsTokensH: 'Token 消耗',
428
+ modelStatsHitH: '缓存命中率',
429
+ modelStatsValueH: '性价比 · 每美元 token 数',
430
+ modelStatsInput: '输入',
431
+ modelStatsCache: '缓存',
432
+ modelStatsOutput: '输出',
433
+ modelStatsEmpty: '该时段暂无用量数据。',
434
+ modelStatsBlended: '综合单价 {price}/M',
435
+ modelStatsNote: '口径:缓存命中率 = 缓存读 ÷ (缓存读 + 非缓存输入);综合单价 = 费用 ÷ 总 token × 1M(USD/M tokens);性价比 = 总 token ÷ 费用,越高越好;费用为账本按每次调用实际时刻计费的美元值。',
436
+ modelStatsLegacy: '未分模型(早期数据 · 按当时记录计费)',
437
+ catalogDisplayLabel: '在费用设置直接显示',
438
+ catalogDisplayHint: '只决定该模型价格卡是否在费用设置「价格表」区直接显示,不影响挂载状态与计费;不直接显示时已挂载模型在拓展价格表内展开厂商后可编辑。',
439
+ priceTableDisplayHint: '各模型是否在「价格表」区直接显示,可在下方拓展价格表中逐模型用「在费用设置直接显示」开关切换;DeepSeek 模型默认直接显示,第三方模型挂载后默认收入拓展表。',
440
+ catalogCustomModels: '手动新增模型',
441
+ priceMatchLabel: '未知模型名自动匹配',
442
+ priceMatchAuto: '自动(去后缀 / 前缀 / 家族相似)',
443
+ priceMatchExact: '仅精确匹配',
444
+ priceMatchNote: '自动匹配顺序:精确 → 手动指定 → 去日期/版本后缀 → 前缀 → 家族相似;未命中时 DeepSeek 回退默认价,其他 provider 不计价。',
445
+ unmatchedTitle: '最近出现但未精确命中的模型',
446
+ unmatchedHint: '为其指定计费所用的价格条目(写入手动匹配覆盖);选择「默认价」即回退 DeepSeek 默认价。',
447
+ overrideTargetDefault: 'DeepSeek 默认价',
448
+ overrideRemove: '移除',
449
+ overrideNone: '暂无手动匹配覆盖。',
450
+ mountedSuffix: '已挂载(参与计费)',
451
+ flatInput: '输入',
452
+ flatCached: '缓存',
453
+ flatOutput: '输出',
454
+ deepseekMountedHeader: 'DeepSeek 价格表(已挂载)',
455
+ codingPlanKimi: 'Kimi / Moonshot',
456
+ codingPlanOpenrouter: 'OpenRouter',
457
+ codingPlanSiliconflow: 'SiliconFlow 硅基流动',
458
+ countdownHoursOnly: '{h}小时',
459
+ countdownHourMinute: '{h}小时{m}分',
460
+ countdownMinute: '{m}分钟',
461
+ nextOffPeakIn: '{time}后进入平价',
462
+ nextPeakIn: '{time}后进入高峰',
463
+ peakSummary: '峰时段(UTC):{windows};生效时间:{time}。{status}',
464
+ noPeakWindows: '未配置峰谷时段。{status}',
465
+ unknown: '未知',
466
+ cardToday: '今日费用',
467
+ cardMonth: '本月费用',
468
+ cardTotal: '累计费用',
469
+ cardTotalSub: '自账本建立以来 · 调用 {calls} 次',
470
+ todaySessions: '今日会话',
471
+ history: '历史记录',
472
+ usageTitle: 'Token 用量统计',
473
+ usageTotal: '累计 {tokens} tokens · 输入 {input} · 缓存 {cache} · 输出 {output} · {calls} 次调用',
474
+ usageDay: '{date}:共 {tokens} tokens(输入 {input} · 缓存 {cache} · 输出 {output})· {calls} 次调用 · {cost}',
475
+ usageEmpty: '暂无历史数据,开始对话后每日用量会汇总在这里',
476
+ usageSectionLabel: '用量',
477
+ usagePositionLabel: 'Token 用量统计位置',
478
+ usagePositionCost: '费用设置',
479
+ usagePositionGeneral: '通用设置',
480
+ usagePositionSection: '独立分节(用量)',
481
+ displaySettings: '显示设置',
482
+ positionLabel: '会话费用显示位置',
483
+ positionDock: '输入区下方',
484
+ positionHeader: '会话标题栏',
485
+ off: '关闭',
486
+ sidebarLabel: '当日费用显示',
487
+ sidebarOn: '侧边栏底部',
488
+ currencyLabel: '货币单位',
489
+ currencyCny: '人民币 CNY',
490
+ currencyUsd: '美元 USD',
491
+ currencyEur: '欧元 EUR',
492
+ symbolLabel: '货币符号',
493
+ rateLabel: '汇率(1 美元 = ? 目标币种)',
494
+ decimalsLabel: '小数位数',
495
+ balanceDisplayLabel: '余额显示位置',
496
+ balanceSidebar: '主页面侧边栏',
497
+ balanceSettings: '设置页',
498
+ balanceBoth: '两者都显示',
499
+ refreshIntervalLabel: '余额刷新间隔(分钟)',
500
+ peakEnabledLabel: '启用 DeepSeek 峰谷时段价格',
501
+ peakNoticeLabel: '峰时高价时段显著提示(侧边栏预算框/今日费用;设置页峰谷面板内预览)',
502
+ badgeNote: '会话徽章为按每次调用实际时刻精确计费(含峰谷档位;峰谷时代分界 2026-08-16 16:00 UTC 之前按当时基础价),与账本当日/月度/累计同口径。输入/缓存/输出 token 分开统计,缓存读写按命中价计费。',
503
+ priceTableTitle: '价格表(美元 / 1M tokens)',
504
+ priceTableNote: '「谷时/峰时」为峰谷计价生效后的价格;分界 2026-08-16 16:00 UTC 之前的调用按当时基础价(legacyBase)计费;缓存写入按缓存命中价格计费(与官方规则一致)。无缓存折扣的模型(如 Anthropic/Gemini 等)可只填输入与输出价,命中价自动取未命中价。所有设置修改后自动保存。',
505
+ defaultModelId: 'default(未匹配模型时回退)',
506
+ newModelPlaceholder: '新模型 ID(如 deepseek-v4-pro)',
507
+ addModel: '添加模型',
508
+ dataSync: '数据与同步',
509
+ saving: '保存中…',
510
+ autoSaveFailed: '自动保存失败:{message}',
511
+ autoSavedAt: '已自动保存 {time}',
512
+ autoSaveHint: '配置修改后自动保存',
513
+ confirmFetch: '确认用官方文档价格覆盖价格表?',
514
+ apply: '应用',
515
+ cancel: '取消',
516
+ syncFromDocs: '从官方文档同步价格',
517
+ confirmReset: '确认清除全部历史?',
518
+ confirmClear: '确认清除',
519
+ clearAllHistory: '清除全部历史',
520
+ lastSync: '最近同步:{time}',
521
+ neverSynced: '从未(使用内置价格)',
522
+ source: ';来源:{source}',
523
+ sourceOfficial: '官方文档',
524
+ sourceBundled: '内置默认',
525
+ // 语言
526
+ languageLabel: '界面语言',
527
+ localeAuto: '跟随浏览器(自动)',
528
+ localeZh: '简体中文',
529
+ localeEn: 'English',
530
+ sectionLabel: '费用',
531
+ // RPC 错误
532
+ rpcFailed: '{method} 调用失败',
533
+ rpcSyncFailed: '同步调用失败',
534
+ rpcBalanceFailed: '余额刷新调用失败',
535
+ },
536
+ en: {
537
+ sessionCostTitle: 'Cost of this session (billed exactly at each call time)',
538
+ sessionDetailTokens: 'Input {input} · Cache {cache} · Output {output}',
539
+ sessionDetailCache: 'Cache: read {read} · write {write} (writes billed at the hit price)',
540
+ cost: 'Cost {amount}',
541
+ sessionLine: 'This session {amount} · Input {input} · Cache {cache} · Output {output}',
542
+ balanceQueryFailed: 'Balance query failed: {message}',
543
+ unknownError: 'Unknown error',
544
+ balance: 'Balance',
545
+ queryFailed: 'Query failed',
546
+ balanceTitle: 'DeepSeek open-platform account balance',
547
+ reconcileLabel: 'Cross-check official balance changes against the local ledger (warn when they diverge)',
548
+ totalBalance: 'Total {amount}',
549
+ grantedToppedUp: 'Granted {granted} · Topped-up {toppedUp}',
550
+ updatedAt: 'Updated {time}',
551
+ budgetOf: 'Budget ({period})',
552
+ usedOf: 'Used {used} / {amount}',
553
+ todayShare: 'Today {amount} · {pct} of budget',
554
+ monthTotal: 'This month {month} · All time {total}',
555
+ budget: 'Budget',
556
+ todayCostTitle: "Today's cost (billed exactly at official prices)",
557
+ callsTokens: 'Calls {calls} · Input {input} · Cache {cache} · Output {output}',
558
+ monthCost: 'This month {amount}',
559
+ totalCost: 'All time {amount}',
560
+ today: 'Today',
561
+ periodDay: 'Today',
562
+ periodMonth: 'This month',
563
+ periodAll: 'All time',
564
+ periodCustom: 'Custom',
565
+ periodCustomRange: 'Custom range',
566
+ noHistory: 'No history yet. Once you start chatting, costs are aggregated here per day.',
567
+ colDate: 'Date',
568
+ colCalls: 'Calls',
569
+ colInTok: 'In tok',
570
+ colCacheTok: 'Cache tok',
571
+ colOutTok: 'Out tok',
572
+ colCost: 'Cost',
573
+ noSessionsToday: 'No sessions recorded today.',
574
+ colSession: 'Session',
575
+ enableBudget: 'Enable budget',
576
+ budgetAmountLabel: 'Budget amount (in display currency)',
577
+ budgetPeriodLabel: 'Budget period',
578
+ startDate: 'Start date',
579
+ endDate: 'End date (empty = today)',
580
+ rangeText: 'Range: {range}',
581
+ budgetDisabledNote: 'Budget is disabled. Once enabled, this panel shows the budget limit, the amount used and the used percentage (converted to the display currency); the period can be today / this month / all time / a custom date range.',
582
+ budgetStatus: '{period} budget {amount} · Used {used} · {pct}%',
583
+ overLimit: ' (over limit)',
584
+ nearLimit: ' (near limit)',
585
+ legacyModel: 'Legacy',
586
+ defaultFallback: 'Default fallback',
587
+ remove: 'Remove',
588
+ tierBase: 'Base',
589
+ tierOffPeak: 'Off-peak',
590
+ tierPeak: 'Peak',
591
+ balanceRefreshFailed: 'Balance refresh failed: {message}',
592
+ balanceLine: 'Total {total} · Granted {granted} · Topped-up {toppedUp} · Updated {time}',
593
+ balanceQueryFailedHint: 'Balance query failed: {message} (uses the API key configured in Settings → Models)',
594
+ balanceNotQueried: 'Balance not queried',
595
+ accountBalance: 'Account balance',
596
+ refreshing: 'Refreshing…',
597
+ refreshBalance: 'Refresh balance',
598
+ customBalance: 'Custom balance',
599
+ customBalanceTitle: 'Custom provider balance',
600
+ customBalanceConfigNote: 'Configure the balance query request and extract rules. Headers support {{ENV_VAR}} placeholders resolved from DSH credentials or environment variables.',
601
+ customBalanceLabelZh: 'Chinese name',
602
+ customBalanceLabelEn: 'English name',
603
+ customBalanceUrl: 'Request URL',
604
+ customBalanceMethod: 'HTTP method',
605
+ customBalanceHeaders: 'Headers (JSON)',
606
+ customBalanceExtract: 'Extract rules (JSON)',
607
+ customBalanceDisplayLabel: 'Custom balance display',
608
+ customBalanceRefreshInterval: 'Refresh interval (minutes)',
609
+ customBalanceUnitLabel: 'Balance currency',
610
+ balanceBarRemaining: 'Balance {amount}',
611
+ balanceBarToday: 'Today {amount}',
612
+ balanceBarSpent: 'Spent {amount}',
613
+ balanceShowProgressBar: 'Balance progress bar (blue=balance, orange=today, gray=spent)',
614
+ balanceBudgetCapLabel: 'Budget cap (optional)',
615
+ balanceBudgetCapHint: 'Leave empty to use API max_budget; if still missing, show full light-blue bar',
616
+ customBalanceInvalidJson: 'Invalid JSON',
617
+ customBalanceOpenConfig: 'Expand config',
618
+ customBalanceCollapseConfig: 'Collapse config',
619
+ customBalanceLine: 'Remaining {remaining} · Spent {spend} / Budget {maxBudget} · Updated {time}',
620
+ customBalanceRemaining: 'Remaining {amount}',
621
+ refreshCustomBalance: 'Refresh custom balance',
622
+ customBalanceRefreshFailed: 'Custom balance refresh failed: {message}',
623
+ goQuotaTitle: 'OpenCode Go subscription quota',
624
+ goQuotaRowLabel: 'Go',
625
+ goWindowRolling: 'Rolling 5h',
626
+ goWindowWeekly: 'Weekly',
627
+ goWindowMonthly: 'Monthly',
628
+ goResetAt: 'Resets: {time}',
629
+ goQuotaPercent: '{percent}%',
630
+ goQuotaFetchedAt: 'Updated {time}',
631
+ goQuotaDisplayLabel: 'Go quota display position',
632
+ goQuotaRefreshIntervalLabel: 'Go quota refresh interval (minutes)',
633
+ goQuotaKeyLabel: 'OpenCode Go API key (optional; empty = auto-detect)',
634
+ goMainLabel: 'Go quota primary window',
635
+ goDetailLabel: 'Go box details',
636
+ budgetDetailLabel: 'Budget box details',
637
+ refreshGoQuota: 'Refresh quota',
638
+ goQuotaNotQueried: 'Quota not queried',
639
+ enableGoQuota: 'Enable OpenCode Go quota',
640
+ codingPlansTitle: 'Coding plan quotas',
641
+ codingPlansNote: 'Query coding plan subscription quotas per vendor (independent enable switch and credentials per vendor). Credentials are only sent to the vendor\'s official endpoint; leave the key empty to auto-detect from environment variables and CLI logins.',
642
+ codingPlansOpen: 'Expand Coding Plan quotas',
643
+ codingPlansCollapse: 'Collapse Coding Plan quotas',
644
+ codingPlansCollapsedHint: 'Collapsed by default; expand to enable providers, enter keys and query quotas. The open/closed state is remembered.',
645
+ codingPlanAnthropic: 'Anthropic (Claude Pro/Max)',
646
+ codingPlanZai: 'Z.ai / Zhipu GLM Coding Plan',
647
+ codingPlanMinimax: 'MiniMax Token Plan',
648
+ enableCodingPlan: 'Enable quota queries',
649
+ codingPlanKeyLabel: 'API key (optional; empty = auto-detect)',
650
+ refreshCodingPlan: 'Refresh',
651
+ codingPlanNotQueried: 'Quota not queried',
652
+ codingPlanDisabledNote: 'Disabled. Enable it to query this vendor\'s coding plan quota on the refresh interval and show it here.',
653
+ goQuotaDisabledNote: 'Quota disabled. Enable it to read the OpenCode Go subscription quota (rolling 5h / weekly / monthly) and show it in the sidebar box, Settings page and bottom-right corner; if you have no Go subscription, the reason will be shown here.',
654
+ cornerLabel: 'Bottom-right (dock) display',
655
+ cornerEnabledLabel: 'Show OpenCode Go quota / budget at the bottom-right',
656
+ cornerGoRolling: 'Rolling-5h quota',
657
+ cornerGoWeekly: 'Weekly quota',
658
+ cornerGoMonthly: 'Monthly quota',
659
+ cornerBudget: 'Budget used %',
660
+ goShortRolling: '5h',
661
+ goShortWeekly: 'Wk',
662
+ goShortMonthly: 'Mo',
663
+ budgetShort: 'Budget',
664
+ ledgerReadFailed: 'Ledger read failed: {message}',
665
+ readingLedger: 'Reading ledger…',
666
+ ledgerUnavailable: 'Ledger unavailable',
667
+ syncFailed: 'Sync failed: {message}',
668
+ historyCleared: 'History cleared.',
669
+ clearFailed: 'Clear failed: {message}',
670
+ peakOff: 'Peak/off-peak pricing is off; base prices are used',
671
+ peakNotEffective: 'Not yet effective (effective at {time}); base prices are currently used',
672
+ peakActive: 'Peak hour now; peak prices apply',
673
+ peakNotice: 'DeepSeek peak-hour pricing is active; current calls are billed at peak prices',
674
+ offPeakActive: 'Off-peak now; off-peak prices apply',
675
+ peakShort: 'Peak',
676
+ offPeakShort: 'Off-peak',
677
+ peakStyleLabel: 'Peak period strip style',
678
+ peakStyleCompact: 'Compact (one-line)',
679
+ peakStyleClassic: 'Classic (segments & chip)',
680
+ peakPanelTitle: 'Peak/off-peak pricing & notice',
681
+ peakNoticeHiddenHint: 'Notice hidden: enable peak/off-peak pricing and the “prominent notice” toggle.',
682
+ groupGeneral: 'General',
683
+ groupMoney: 'Money & currency',
684
+ groupSidebar: 'Sidebar display',
685
+ groupCorner: 'Corner chips',
686
+ groupDetail: 'Box details',
687
+ catalogTitle: 'Extended price catalog',
688
+ catalogNote: 'A built-in, read-only reference catalog grouped by vendor and model family. Mounting copies an entry into the editable price table above so it is used for billing; DeepSeek models can be unmounted (they fall back to the default price and can be re-mounted here).',
689
+ catalogOpen: 'Expand extended price catalog',
690
+ catalogCollapse: 'Collapse extended price catalog',
691
+ mountBtn: 'Mount',
692
+ unmountBtn: 'Unmount',
693
+ mountedTag: 'Mounted',
694
+ catalogUnpriced: 'Unpriced',
695
+ catalogDeepseekNote: 'DeepSeek models support peak/off-peak tiers; once unmounted they bill at the default price and are no longer hit by auto-matching.',
696
+ modelStatsTitle: 'Per-model statistics (tokens & cost)',
697
+ modelStatsToday: 'Today',
698
+ modelStatsHistory: 'Last 90 days',
699
+ modelStatsCostH: 'Cost ranking',
700
+ modelStatsTokensH: 'Token usage',
701
+ modelStatsHitH: 'Cache hit rate',
702
+ modelStatsValueH: 'Value · tokens per USD',
703
+ modelStatsInput: 'Input',
704
+ modelStatsCache: 'Cache',
705
+ modelStatsOutput: 'Output',
706
+ modelStatsEmpty: 'No usage data for this period.',
707
+ modelStatsBlended: 'blended {price}/M',
708
+ modelStatsNote: 'Methodology: cache hit rate = cache reads ÷ (cache reads + non-cached input); blended price = cost ÷ total tokens × 1M (USD per 1M tokens); value = total tokens ÷ cost (higher is better). Costs are USD exactly as billed per call by the ledger.',
709
+ modelStatsLegacy: 'Unattributed (early data · billed as recorded)',
710
+ catalogDisplayLabel: 'Show directly in Cost settings',
711
+ catalogDisplayHint: 'Only decides whether this model\'s price card appears directly in the Cost settings price table; mounting and billing are unaffected. When hidden, the mounted model stays editable inside the expanded vendor section of this catalog.',
712
+ priceTableDisplayHint: 'Use the per-model “Show directly in Cost settings” toggle in the extended price catalog below to choose which models appear here; DeepSeek models default to direct display, third-party models default to the catalog once mounted.',
713
+ catalogCustomModels: 'Custom models',
714
+ priceMatchLabel: 'Auto-match unknown model names',
715
+ priceMatchAuto: 'Auto (strip suffix / prefix / family similarity)',
716
+ priceMatchExact: 'Exact match only',
717
+ priceMatchNote: 'Match order: exact → manual override → strip date/version suffix → prefix → family similarity. Unmatched DeepSeek ids fall back to the default price; other providers stay unpriced.',
718
+ unmatchedTitle: 'Recently seen models without an exact price entry',
719
+ unmatchedHint: 'Pick which price entry each one should bill against (saved as a manual override); “Default price” falls back to the DeepSeek default.',
720
+ overrideTargetDefault: 'DeepSeek default price',
721
+ overrideRemove: 'Remove',
722
+ overrideNone: 'No manual match overrides.',
723
+ mountedSuffix: 'Mounted (billed)',
724
+ flatInput: 'Input',
725
+ flatCached: 'Cached',
726
+ flatOutput: 'Output',
727
+ deepseekMountedHeader: 'DeepSeek price table (mounted)',
728
+ codingPlanKimi: 'Kimi / Moonshot',
729
+ codingPlanOpenrouter: 'OpenRouter',
730
+ codingPlanSiliconflow: 'SiliconFlow',
731
+ countdownHoursOnly: '{h}h',
732
+ countdownHourMinute: '{h}h {m}m',
733
+ countdownMinute: '{m}m',
734
+ nextOffPeakIn: 'Off-peak in {time}',
735
+ nextPeakIn: 'Peak in {time}',
736
+ peakSummary: 'Peak hours (UTC): {windows}; effective: {time}. {status}',
737
+ noPeakWindows: 'No peak windows configured. {status}',
738
+ unknown: 'unknown',
739
+ cardToday: 'Today',
740
+ cardMonth: 'This month',
741
+ cardTotal: 'All time',
742
+ cardTotalSub: 'Since the ledger was created · Calls {calls}',
743
+ todaySessions: "Today's sessions",
744
+ history: 'History',
745
+ usageTitle: 'Token usage stats',
746
+ usageTotal: 'All-time {tokens} tokens · input {input} · cache {cache} · output {output} · {calls} calls',
747
+ usageDay: '{date}: {tokens} tokens (input {input} · cache {cache} · output {output}) · {calls} calls · {cost}',
748
+ usageEmpty: 'No history yet — daily usage will accumulate here',
749
+ usageSectionLabel: 'Usage',
750
+ usagePositionLabel: 'Token usage stats position',
751
+ usagePositionCost: 'Cost settings',
752
+ usagePositionGeneral: 'General settings',
753
+ usagePositionSection: 'Own section (Usage)',
754
+ displaySettings: 'Display settings',
755
+ positionLabel: 'Session cost display position',
756
+ positionDock: 'Below the composer',
757
+ positionHeader: 'Session title bar',
758
+ off: 'Off',
759
+ sidebarLabel: 'Today cost display',
760
+ sidebarOn: 'Sidebar footer',
761
+ currencyLabel: 'Currency',
762
+ currencyCny: 'Chinese Yuan (CNY)',
763
+ currencyUsd: 'US Dollar (USD)',
764
+ currencyEur: 'Euro (EUR)',
765
+ symbolLabel: 'Currency symbol',
766
+ rateLabel: 'Exchange rate (1 USD = ? target currency)',
767
+ decimalsLabel: 'Decimal places',
768
+ balanceDisplayLabel: 'Balance display position',
769
+ balanceSidebar: 'Main sidebar',
770
+ balanceSettings: 'Settings page',
771
+ balanceBoth: 'Both',
772
+ refreshIntervalLabel: 'Balance refresh interval (minutes)',
773
+ peakEnabledLabel: 'Use DeepSeek peak-hour prices',
774
+ peakNoticeLabel: 'Prominent notice during peak hours (sidebar budget box, today\'s cost; previewed in the Settings peak panel)',
775
+ badgeNote: 'The session badge is billed exactly at each call\'s actual time (peak/off-peak tiers; calls before the 2026-08-16 16:00 UTC boundary are billed at the base prices of that era), consistent with the ledger\'s today/month/all-time figures. Input/cache/output tokens are counted separately; cache reads and writes are billed at the cache-hit price.',
776
+ priceTableTitle: 'Price table (USD / 1M tokens)',
777
+ priceTableNote: '"Off-peak / Peak" are the prices used once peak/off-peak pricing takes effect; calls before the 2026-08-16 16:00 UTC boundary are billed at the base prices of that time (legacyBase); cache writes are billed at the cache-hit price (matching the official rule). Models without a cache discount (e.g. Anthropic/Gemini) can be entered with just input and output prices — the hit price is then derived from the miss price. All settings changes are auto-saved.',
778
+ defaultModelId: 'default (fallback for unmatched models)',
779
+ newModelPlaceholder: 'New model ID (e.g. deepseek-v4-pro)',
780
+ addModel: 'Add model',
781
+ dataSync: 'Data & sync',
782
+ saving: 'Saving…',
783
+ autoSaveFailed: 'Auto-save failed: {message}',
784
+ autoSavedAt: 'Auto-saved {time}',
785
+ autoSaveHint: 'Settings are auto-saved',
786
+ confirmFetch: 'Overwrite the price table with prices from the official docs?',
787
+ apply: 'Apply',
788
+ cancel: 'Cancel',
789
+ syncFromDocs: 'Sync prices from official docs',
790
+ confirmReset: 'Clear all history?',
791
+ confirmClear: 'Confirm clear',
792
+ clearAllHistory: 'Clear all history',
793
+ lastSync: 'Last sync: {time}',
794
+ neverSynced: 'Never (bundled prices)',
795
+ source: '; Source: {source}',
796
+ sourceOfficial: 'Official docs',
797
+ sourceBundled: 'Bundled',
798
+ languageLabel: 'Language',
799
+ localeAuto: 'Follow browser (auto)',
800
+ localeZh: 'Simplified Chinese',
801
+ localeEn: 'English',
802
+ sectionLabel: 'Cost',
803
+ rpcFailed: '{method} call failed',
804
+ rpcSyncFailed: 'Sync call failed',
805
+ rpcBalanceFailed: 'Balance refresh call failed',
806
+ },
807
+ }
808
+
809
+ /** 探测浏览器语言:zh* → zh,其余 → en。 */
810
+ function detectBrowserLocale() {
811
+ const lang = typeof navigator !== 'undefined' && typeof navigator.language === 'string' ? navigator.language : ''
812
+ return lang.toLowerCase().startsWith('zh') ? 'zh' : 'en'
813
+ }
814
+
815
+ /** 解析生效语言:显式 zh/en 直接采用;auto/缺失 → 浏览器探测。 */
816
+ function resolveLocale(configLocale) {
817
+ if (configLocale === 'zh' || configLocale === 'en') return configLocale
818
+ return detectBrowserLocale()
819
+ }
820
+
821
+ /** 构造按当前语言取文案的函数 t(key, vars)。 */
822
+ function makeT(locale) {
823
+ const dict = locale === 'zh' ? MESSAGES.zh : MESSAGES.en
824
+ return (key, vars) => {
825
+ let text = dict[key] ?? MESSAGES.en[key] ?? key
826
+ if (vars) for (const name of Object.keys(vars)) text = text.split('{' + name + '}').join(String(vars[name]))
827
+ return text
828
+ }
829
+ }
830
+
831
+ const PERIOD_KEYS = { day: 'periodDay', month: 'periodMonth', all: 'periodAll', custom: 'periodCustom' }
832
+
833
+ // ── 线路校验器(与服务端 zod 清单对应,宽松校验必要字段) ─────────────────
834
+
835
+ function fail(path, expect) {
836
+ throw new Error('dsh-cost-meter: 服务端数据非法 (' + path + ': ' + expect + ')')
837
+ }
838
+ function needNum(v, path) {
839
+ if (typeof v !== 'number' || !Number.isFinite(v)) fail(path, 'number')
840
+ return v
841
+ }
842
+ function needStr(v, path) {
843
+ if (typeof v !== 'string') fail(path, 'string')
844
+ return v
845
+ }
846
+ function needBool(v, path) {
847
+ if (typeof v !== 'boolean') fail(path, 'boolean')
848
+ return v
849
+ }
850
+ function aggregateModelMap(v, path) {
851
+ // 宽容解析模型聚合 map(旧账本条目可能缺字段/带 null/非对象):数值归一为有限非负数。
852
+ const out = {}
853
+ if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
854
+ for (const key of Object.keys(v)) {
855
+ const raw = v[key]
856
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue
857
+ const num = x => (typeof x === 'number' && Number.isFinite(x) && x >= 0 ? x : 0)
858
+ out[key] = {
859
+ input: num(raw.input), output: num(raw.output),
860
+ cacheRead: num(raw.cacheRead), cacheWrite: num(raw.cacheWrite),
861
+ reasoning: num(raw.reasoning), cost: num(raw.cost),
862
+ }
863
+ }
864
+ }
865
+ return out
866
+ }
867
+ function parseSession(v, path) {
868
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) fail(path, 'object')
869
+ return {
870
+ id: needStr(v.id, path + '.id'),
871
+ provider: typeof v.provider === 'string' ? v.provider : '',
872
+ model: typeof v.model === 'string' ? v.model : '',
873
+ input: needNum(v.input, path + '.input'),
874
+ output: needNum(v.output, path + '.output'),
875
+ cacheRead: needNum(v.cacheRead, path + '.cacheRead'),
876
+ cacheWrite: needNum(v.cacheWrite, path + '.cacheWrite'),
877
+ reasoning: v.reasoning === undefined ? 0 : needNum(v.reasoning, path + '.reasoning'),
878
+ calls: needNum(v.calls, path + '.calls'),
879
+ cost: needNum(v.cost, path + '.cost'),
880
+ byModel: aggregateModelMap(v.byModel, path + '.byModel'),
881
+ byProviderModel: aggregateModelMap(v.byProviderModel, path + '.byProviderModel'),
882
+ }
883
+ }
884
+ function parseDay(v, path) {
885
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) fail(path, 'object')
886
+ const out = {
887
+ date: needStr(v.date, path + '.date'),
888
+ input: needNum(v.input, path + '.input'),
889
+ output: needNum(v.output, path + '.output'),
890
+ cacheRead: needNum(v.cacheRead, path + '.cacheRead'),
891
+ cacheWrite: needNum(v.cacheWrite, path + '.cacheWrite'),
892
+ reasoning: v.reasoning === undefined ? 0 : needNum(v.reasoning, path + '.reasoning'),
893
+ calls: needNum(v.calls, path + '.calls'),
894
+ cost: needNum(v.cost, path + '.cost'),
895
+ byModel: aggregateModelMap(v.byModel, path + '.byModel'),
896
+ byProviderModel: aggregateModelMap(v.byProviderModel, path + '.byProviderModel'),
897
+ sessions: [],
898
+ }
899
+ if (v.sessions !== undefined) {
900
+ if (!Array.isArray(v.sessions)) fail(path + '.sessions', 'array')
901
+ out.sessions = v.sessions.map((s, i) => parseSession(s, path + '.sessions[' + i + ']'))
902
+ }
903
+ return out
904
+ }
905
+ function parsePrice(v, path) {
906
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) fail(path, 'object')
907
+ const out = {
908
+ cacheHit: needNum(v.cacheHit, path + '.cacheHit'),
909
+ cacheMiss: needNum(v.cacheMiss, path + '.cacheMiss'),
910
+ output: needNum(v.output, path + '.output'),
911
+ }
912
+ if (v.offPeak !== undefined) {
913
+ out.offPeak = {
914
+ cacheHit: needNum(v.offPeak.cacheHit, path + '.offPeak.cacheHit'),
915
+ cacheMiss: needNum(v.offPeak.cacheMiss, path + '.offPeak.cacheMiss'),
916
+ output: needNum(v.offPeak.output, path + '.offPeak.output'),
917
+ }
918
+ }
919
+ if (v.peak !== undefined) {
920
+ out.peak = {
921
+ cacheHit: needNum(v.peak.cacheHit, path + '.peak.cacheHit'),
922
+ cacheMiss: needNum(v.peak.cacheMiss, path + '.peak.cacheMiss'),
923
+ output: needNum(v.peak.output, path + '.peak.output'),
924
+ }
925
+ }
926
+ if (v.legacyBase !== undefined) {
927
+ out.legacyBase = {
928
+ cacheHit: needNum(v.legacyBase.cacheHit, path + '.legacyBase.cacheHit'),
929
+ cacheMiss: needNum(v.legacyBase.cacheMiss, path + '.legacyBase.cacheMiss'),
930
+ output: needNum(v.legacyBase.output, path + '.legacyBase.output'),
931
+ }
932
+ }
933
+ if (v.legacy !== undefined) out.legacy = needBool(v.legacy, path + '.legacy')
934
+ return out
935
+ }
936
+ function parseConfig(v, path) {
937
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) fail(path, 'object')
938
+ const models = {}
939
+ if (v.prices !== null && typeof v.prices === 'object' && v.prices.models !== null && typeof v.prices.models === 'object') {
940
+ for (const id of Object.keys(v.prices.models)) models[id] = parsePrice(v.prices.models[id], path + '.prices.models.' + id)
941
+ }
942
+ return {
943
+ locale: v.locale === 'zh' || v.locale === 'en' || v.locale === 'auto' ? v.locale : 'auto',
944
+ position: v.position === 'header' || v.position === 'off' ? v.position : 'dock',
945
+ sidebar: v.sidebar !== false,
946
+ currency: typeof v.currency === 'string' ? v.currency : 'CNY',
947
+ symbol: typeof v.symbol === 'string' ? v.symbol : '¥',
948
+ decimals: needNum(v.decimals, path + '.decimals'),
949
+ exchangeRate: needNum(v.exchangeRate, path + '.exchangeRate'),
950
+ peakEnabled: v.peakEnabled === true,
951
+ peakEffectiveAt: typeof v.peakEffectiveAt === 'string' ? v.peakEffectiveAt : '',
952
+ peakWindows: Array.isArray(v.peakWindows)
953
+ ? v.peakWindows.map((w, i) => ({ start: needNum(w.start, path + '.peakWindows[' + i + '].start'), end: needNum(w.end, path + '.peakWindows[' + i + '].end') }))
954
+ : [],
955
+ peakNotice: v.peakNotice !== false,
956
+ peakStyle: v.peakStyle === 'classic' ? 'classic' : 'compact',
957
+ priceMatch: v.priceMatch === 'exact' ? 'exact' : 'auto',
958
+ priceOverrides: (() => {
959
+ const out = {}
960
+ if (v.priceOverrides !== null && typeof v.priceOverrides === 'object' && !Array.isArray(v.priceOverrides)) {
961
+ for (const [k, val] of Object.entries(v.priceOverrides)) if (typeof k === 'string' && typeof val === 'string') out[k] = val
962
+ }
963
+ return out
964
+ })(),
965
+ priceTableDisplay: (() => {
966
+ // 键 'provider:modelId',缺省 = DeepSeek 模型直接显示、第三方收入拓展表。
967
+ const out = {}
968
+ if (v.priceTableDisplay !== null && typeof v.priceTableDisplay === 'object' && !Array.isArray(v.priceTableDisplay)) {
969
+ for (const [k, val] of Object.entries(v.priceTableDisplay)) if (typeof k === 'string') out[k] = val === true
970
+ }
971
+ return out
972
+ })(),
973
+ codingPlans: (() => {
974
+ const out = {}
975
+ if (v.codingPlans !== null && typeof v.codingPlans === 'object' && !Array.isArray(v.codingPlans)) {
976
+ for (const id of Object.keys(v.codingPlans)) {
977
+ const e = v.codingPlans[id]
978
+ if (e === null || typeof e !== 'object' || Array.isArray(e)) continue
979
+ out[id] = {
980
+ enabled: e.enabled === true,
981
+ display: e.display === 'sidebar' || e.display === 'both' || e.display === 'off' ? e.display : 'settings',
982
+ refreshMinutes: typeof e.refreshMinutes === 'number' && Number.isFinite(e.refreshMinutes) ? e.refreshMinutes : 15,
983
+ apiKey: typeof e.apiKey === 'string' ? e.apiKey : '',
984
+ }
985
+ }
986
+ }
987
+ return out
988
+ })(),
989
+ prices: {
990
+ models,
991
+ default: parsePrice(v.prices?.default ?? { cacheHit: 0, cacheMiss: 0, output: 0 }, path + '.prices.default'),
992
+ providers: v.prices?.providers && typeof v.prices.providers === 'object' ? v.prices.providers : {},
993
+ },
994
+ historyDays: needNum(v.historyDays, path + '.historyDays'),
995
+ fetchedAt: v.fetchedAt === null || v.fetchedAt === undefined ? null : needStr(v.fetchedAt, path + '.fetchedAt'),
996
+ priceSource: typeof v.priceSource === 'string' ? v.priceSource : 'bundled',
997
+ budget: {
998
+ enabled: v.budget?.enabled === true,
999
+ amount: typeof v.budget?.amount === 'number' && Number.isFinite(v.budget.amount) ? v.budget.amount : 100,
1000
+ period: v.budget?.period === 'day' || v.budget?.period === 'all' || v.budget?.period === 'custom' ? v.budget.period : 'month',
1001
+ customStart: typeof v.budget?.customStart === 'string' ? v.budget.customStart : null,
1002
+ customEnd: typeof v.budget?.customEnd === 'string' ? v.budget.customEnd : null,
1003
+ detail: v.budget?.detail !== false,
1004
+ },
1005
+ balance: {
1006
+ display: v.balance?.display === 'sidebar' || v.balance?.display === 'settings' || v.balance?.display === 'off' ? v.balance.display : 'both',
1007
+ refreshMinutes: typeof v.balance?.refreshMinutes === 'number' && Number.isFinite(v.balance.refreshMinutes) ? v.balance.refreshMinutes : 5,
1008
+ showProgressBar: v.balance?.showProgressBar === true,
1009
+ budgetCap: typeof v.balance?.budgetCap === 'number' && Number.isFinite(v.balance.budgetCap) && v.balance.budgetCap > 0 ? v.balance.budgetCap : null,
1010
+ },
1011
+ goQuota: {
1012
+ enabled: v.goQuota?.enabled !== false,
1013
+ display: v.goQuota?.display === 'sidebar' || v.goQuota?.display === 'settings' || v.goQuota?.display === 'off' ? v.goQuota.display : 'both',
1014
+ refreshMinutes: typeof v.goQuota?.refreshMinutes === 'number' && Number.isFinite(v.goQuota.refreshMinutes) ? v.goQuota.refreshMinutes : 15,
1015
+ apiKey: typeof v.goQuota?.apiKey === 'string' ? v.goQuota.apiKey : '',
1016
+ main: v.goQuota?.main === 'weekly' || v.goQuota?.main === 'monthly' ? v.goQuota.main : 'rolling',
1017
+ detail: v.goQuota?.detail !== false,
1018
+ },
1019
+ customBalance: v.customBalance === undefined || v.customBalance === null ? undefined : {
1020
+ enabled: v.customBalance.enabled === true,
1021
+ label: typeof v.customBalance.label === 'string' ? v.customBalance.label : '',
1022
+ labelEn: typeof v.customBalance.labelEn === 'string' ? v.customBalance.labelEn : '',
1023
+ display: v.customBalance.display === 'sidebar' || v.customBalance.display === 'settings' || v.customBalance.display === 'off' ? v.customBalance.display : 'both',
1024
+ unit: v.customBalance.unit === 'CNY' || v.customBalance.unit === 'EUR' ? v.customBalance.unit : 'USD',
1025
+ refreshMinutes: typeof v.customBalance.refreshMinutes === 'number' && Number.isFinite(v.customBalance.refreshMinutes) ? v.customBalance.refreshMinutes : 15,
1026
+ request: v.customBalance.request && typeof v.customBalance.request === 'object' ? v.customBalance.request : { url: '' },
1027
+ extract: v.customBalance.extract && typeof v.customBalance.extract === 'object' ? v.customBalance.extract : {},
1028
+ },
1029
+ corner: {
1030
+ enabled: v.corner?.enabled === true,
1031
+ goRolling: v.corner?.goRolling !== false,
1032
+ goWeekly: v.corner?.goWeekly !== false,
1033
+ goMonthly: v.corner?.goMonthly !== false,
1034
+ budget: v.corner?.budget !== false,
1035
+ },
1036
+ usage: {
1037
+ position: v.usage?.position === 'general' || v.usage?.position === 'section' ? v.usage.position : 'cost',
1038
+ },
1039
+ }
1040
+ }
1041
+ function parseBalance(v, path) {
1042
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) fail(path, 'object')
1043
+ return {
1044
+ status: v.status === 'ok' || v.status === 'error' ? v.status : 'off',
1045
+ message: typeof v.message === 'string' ? v.message : '',
1046
+ fetchedAt: typeof v.fetchedAt === 'number' ? v.fetchedAt : 0,
1047
+ currency: typeof v.currency === 'string' ? v.currency : '',
1048
+ totalBalance: typeof v.totalBalance === 'number' && Number.isFinite(v.totalBalance) ? v.totalBalance : 0,
1049
+ grantedBalance: typeof v.grantedBalance === 'number' && Number.isFinite(v.grantedBalance) ? v.grantedBalance : 0,
1050
+ toppedUpBalance: typeof v.toppedUpBalance === 'number' && Number.isFinite(v.toppedUpBalance) ? v.toppedUpBalance : 0,
1051
+ }
1052
+ }
1053
+ function parseGoWindow(v, path) {
1054
+ if (v === null || v === undefined) return null
1055
+ if (typeof v !== 'object' || Array.isArray(v)) fail(path, 'object')
1056
+ return {
1057
+ percent: typeof v.percent === 'number' && Number.isFinite(v.percent) ? v.percent : 0,
1058
+ resetsAt: typeof v.resetsAt === 'string' ? v.resetsAt : '',
1059
+ }
1060
+ }
1061
+ function parseGoQuota(v, path) {
1062
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) fail(path, 'object')
1063
+ return {
1064
+ status: v.status === 'ok' || v.status === 'error' ? v.status : 'off',
1065
+ message: typeof v.message === 'string' ? v.message : '',
1066
+ fetchedAt: typeof v.fetchedAt === 'number' ? v.fetchedAt : 0,
1067
+ rolling: v.rolling === undefined || v.rolling === null ? null : parseGoWindow(v.rolling, path + '.rolling'),
1068
+ weekly: v.weekly === undefined || v.weekly === null ? null : parseGoWindow(v.weekly, path + '.weekly'),
1069
+ monthly: v.monthly === undefined || v.monthly === null ? null : parseGoWindow(v.monthly, path + '.monthly'),
1070
+ }
1071
+ }
1072
+ function parseCustomBalance(v, path) {
1073
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) fail(path, 'object')
1074
+ return {
1075
+ status: v.status === 'ok' || v.status === 'error' ? v.status : 'off',
1076
+ message: typeof v.message === 'string' ? v.message : '',
1077
+ fetchedAt: typeof v.fetchedAt === 'number' ? v.fetchedAt : 0,
1078
+ label: typeof v.label === 'string' ? v.label : '',
1079
+ unit: typeof v.unit === 'string' ? v.unit : 'USD',
1080
+ remaining: typeof v.remaining === 'number' && Number.isFinite(v.remaining) ? v.remaining : 0,
1081
+ maxBudget: typeof v.maxBudget === 'number' && Number.isFinite(v.maxBudget) ? v.maxBudget : null,
1082
+ spend: typeof v.spend === 'number' && Number.isFinite(v.spend) ? v.spend : null,
1083
+ }
1084
+ }
1085
+ function parseState(v, path) {
1086
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) fail(path, 'object')
1087
+ return {
1088
+ today: parseDay(v.today, path + '.today'),
1089
+ month: parseDay(v.month, path + '.month'),
1090
+ total: parseDay(v.total, path + '.total'),
1091
+ budgetUsed: typeof v.budgetUsed === 'number' && Number.isFinite(v.budgetUsed) ? v.budgetUsed : undefined,
1092
+ balance: v.balance === undefined || v.balance === null ? { status: 'off', message: '', fetchedAt: 0, currency: '', totalBalance: 0, grantedBalance: 0, toppedUpBalance: 0 } : parseBalance(v.balance, path + '.balance'),
1093
+ goQuota: v.goQuota === undefined || v.goQuota === null ? { status: 'off', message: '', fetchedAt: 0, rolling: null, weekly: null, monthly: null } : parseGoQuota(v.goQuota, path + '.goQuota'),
1094
+ customBalance: v.customBalance === undefined || v.customBalance === null ? { status: 'off', message: '', fetchedAt: 0, label: '', unit: 'USD', remaining: 0, maxBudget: null, spend: null } : parseCustomBalance(v.customBalance, path + '.customBalance'),
1095
+ codingPlans: v.codingPlans !== null && typeof v.codingPlans === 'object' && !Array.isArray(v.codingPlans) ? v.codingPlans : {},
1096
+ history: Array.isArray(v.history) ? v.history.map((d, i) => parseDay(d, path + '.history[' + i + ']')) : [],
1097
+ config: parseConfig(v.config, path + '.config'),
1098
+ // 扩展价格表目录(宿主只读下发;缺失时 UI 自动隐藏目录面板)。
1099
+ priceCatalog: v.priceCatalog !== null && typeof v.priceCatalog === 'object' && !Array.isArray(v.priceCatalog) ? v.priceCatalog : null,
1100
+ meta: {
1101
+ now: typeof v.meta?.now === 'number' ? v.meta.now : Date.now(),
1102
+ timezoneOffsetMinutes: typeof v.meta?.timezoneOffsetMinutes === 'number' ? v.meta.timezoneOffsetMinutes : 0,
1103
+ dayKey: typeof v.meta?.dayKey === 'string' ? v.meta.dayKey : '',
1104
+ monthKey: typeof v.meta?.monthKey === 'string' ? v.meta.monthKey : '',
1105
+ },
1106
+ }
1107
+ }
1108
+ function parseFetchResult(v, path) {
1109
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) fail(path, 'object')
1110
+ const out = {
1111
+ ok: v.ok === true,
1112
+ message: typeof v.message === 'string' ? v.message : '',
1113
+ }
1114
+ if (v.state !== undefined && v.state !== null) out.state = parseState(v.state, path + '.state')
1115
+ return out
1116
+ }
1117
+ function codecOf(parse) {
1118
+ return { parse }
1119
+ }
1120
+ const stateCodec = codecOf(parseState)
1121
+ const patchCodec = codecOf(v => {
1122
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) fail('patch', 'object')
1123
+ return v
1124
+ })
1125
+ const fetchCodec = codecOf(parseFetchResult)
1126
+ const providerCodec = codecOf(v => {
1127
+ if (typeof v !== 'string') fail('provider', 'string')
1128
+ return v
1129
+ })
1130
+
1131
+ // ── RPC 贡献(与服务端 ./typert 清单一一对应) ───────────────────────────
1132
+
1133
+ const CONTRIBUTION = {
1134
+ package: 'dsh-cost-meter',
1135
+ descriptors: [
1136
+ {
1137
+ id: 'dsh-cost-meter#costMeter/getState', service: 'costMeter', namespace: 'costMeter', method: 'getState',
1138
+ invocation: { kind: 'direct' }, parameters: [],
1139
+ result: { mode: 'strict', typeSymbol: 'dsh-cost-meter#CostState', schema: stateCodec },
1140
+ },
1141
+ {
1142
+ id: 'dsh-cost-meter#costMeter/updateConfig', service: 'costMeter', namespace: 'costMeter', method: 'updateConfig',
1143
+ invocation: { kind: 'direct' },
1144
+ parameters: [{ name: 'patch', wire: 'patch', source: 'json', codec: { mode: 'strict', typeSymbol: 'dsh-cost-meter#ConfigPatch', schema: patchCodec } }],
1145
+ result: { mode: 'strict', typeSymbol: 'dsh-cost-meter#CostState', schema: stateCodec },
1146
+ },
1147
+ {
1148
+ id: 'dsh-cost-meter#costMeter/fetchPrices', service: 'costMeter', namespace: 'costMeter', method: 'fetchPrices',
1149
+ invocation: { kind: 'direct' }, parameters: [],
1150
+ result: { mode: 'strict', typeSymbol: 'dsh-cost-meter#FetchPricesResult', schema: fetchCodec },
1151
+ },
1152
+ {
1153
+ id: 'dsh-cost-meter#costMeter/refreshBalance', service: 'costMeter', namespace: 'costMeter', method: 'refreshBalance',
1154
+ invocation: { kind: 'direct' }, parameters: [],
1155
+ result: { mode: 'strict', typeSymbol: 'dsh-cost-meter#FetchPricesResult', schema: fetchCodec },
1156
+ },
1157
+ {
1158
+ id: 'dsh-cost-meter#costMeter/refreshGoQuota', service: 'costMeter', namespace: 'costMeter', method: 'refreshGoQuota',
1159
+ invocation: { kind: 'direct' }, parameters: [],
1160
+ result: { mode: 'strict', typeSymbol: 'dsh-cost-meter#FetchPricesResult', schema: fetchCodec },
1161
+ },
1162
+ {
1163
+ id: 'dsh-cost-meter#costMeter/refreshCustomBalance', service: 'costMeter', namespace: 'costMeter', method: 'refreshCustomBalance',
1164
+ invocation: { kind: 'direct' }, parameters: [],
1165
+ result: { mode: 'strict', typeSymbol: 'dsh-cost-meter#FetchPricesResult', schema: fetchCodec },
1166
+ },
1167
+ {
1168
+ id: 'dsh-cost-meter#costMeter/refreshCodingPlan', service: 'costMeter', namespace: 'costMeter', method: 'refreshCodingPlan',
1169
+ invocation: { kind: 'direct' },
1170
+ parameters: [{ name: 'provider', wire: 'provider', source: 'json', codec: { mode: 'strict', typeSymbol: 'dsh-cost-meter#CodingPlanProvider', schema: providerCodec } }],
1171
+ result: { mode: 'strict', typeSymbol: 'dsh-cost-meter#FetchPricesResult', schema: fetchCodec },
1172
+ },
1173
+ {
1174
+ id: 'dsh-cost-meter#costMeter/resetHistory', service: 'costMeter', namespace: 'costMeter', method: 'resetHistory',
1175
+ invocation: { kind: 'direct' }, parameters: [],
1176
+ result: { mode: 'strict', typeSymbol: 'dsh-cost-meter#CostState', schema: stateCodec },
1177
+ },
1178
+ ],
1179
+ }
1180
+
1181
+ // ── 计费与显示助手(与服务端 pricing.js 一致) ───────────────────────────
1182
+
1183
+ function priceEntryFor(modelId, table) {
1184
+ const models = table?.models ?? {}
1185
+ if (typeof modelId === 'string' && modelId.length > 0 && models[modelId] !== undefined) return models[modelId]
1186
+ return table?.default ?? { cacheHit: 0, cacheMiss: 0, output: 0 }
1187
+ }
1188
+ function normalizeClientPrice(raw) {
1189
+ if (!raw || typeof raw !== 'object') return null
1190
+ const miss = Number.isFinite(Number(raw.cacheMiss)) ? Number(raw.cacheMiss) : Number(raw.input) || 0
1191
+ const hit = Number.isFinite(Number(raw.cacheHit)) ? Number(raw.cacheHit) : Number(raw.cachedInput ?? raw.cacheRead ?? miss)
1192
+ return { cacheHit: hit, cacheMiss: miss, output: Number(raw.output) || 0, reasoning: Number(raw.reasoning) || 0 }
1193
+ }
1194
+ function isPeakHour(atMs, effectiveAtMs, windows) {
1195
+ if (!Array.isArray(windows) || windows.length === 0) return false
1196
+ if (Number.isFinite(effectiveAtMs) && atMs < effectiveAtMs) return false
1197
+ const hour = new Date(atMs).getUTCHours()
1198
+ return windows.some(w => {
1199
+ const start = Number(w.start)
1200
+ const end = Number(w.end)
1201
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return false
1202
+ return start < end ? hour >= start && hour < end : hour >= start || hour < end
1203
+ })
1204
+ }
1205
+ function tierFor(entry, atMs, peak) {
1206
+ const base = entry ?? { cacheHit: 0, cacheMiss: 0, output: 0 }
1207
+ const asTier = price => ({ cacheHit: price.cacheHit, cacheMiss: price.cacheMiss, output: price.output, reasoning: price.reasoning ?? 0 })
1208
+ if (peak?.enabled !== true) return asTier(base)
1209
+ const effectiveAtMs = typeof peak.effectiveAtMs === 'number' ? peak.effectiveAtMs : undefined
1210
+ if (isPeakHour(atMs, effectiveAtMs, peak.windows)) {
1211
+ const p = base.peak
1212
+ return p === undefined ? asTier(base) : asTier(p)
1213
+ }
1214
+ if (effectiveAtMs !== undefined && atMs >= effectiveAtMs) {
1215
+ const off = base.offPeak
1216
+ return off === undefined ? asTier(base) : asTier(off)
1217
+ }
1218
+ return asTier(base)
1219
+ }
1220
+ function costOfBuckets(buckets, tier) {
1221
+ const input = Math.max(0, Number(buckets.input) || 0)
1222
+ const output = Math.max(0, Number(buckets.output) || 0)
1223
+ const cacheRead = Math.max(0, Number(buckets.cacheRead) || 0)
1224
+ const cacheWrite = Math.max(0, Number(buckets.cacheWrite) || 0)
1225
+ const reasoning = Math.max(0, Number(buckets.reasoning) || 0)
1226
+ return (input * tier.cacheMiss + output * tier.output + (cacheRead + cacheWrite) * tier.cacheHit + reasoning * (tier.reasoning ?? 0)) / 1_000_000
1227
+ }
1228
+ /** 已换算币种金额 → 显示字符串(符号 + 可调小数位)。 */
1229
+ function formatMoneyValue(value, config) {
1230
+ const symbol = typeof config?.symbol === 'string' && config.symbol.length > 0 ? config.symbol : '$'
1231
+ const decimals = Math.max(0, Math.min(10, Math.floor(Number(config?.decimals) || 2)))
1232
+ let effective = decimals
1233
+ if (value > 0 && value < Math.pow(10, -decimals)) effective = decimals + 2
1234
+ const fixed = value.toFixed(effective)
1235
+ const trimmed = fixed.includes('.') ? fixed.replace(/0+$/, '').replace(/\.$/, '') : fixed
1236
+ return symbol + trimmed
1237
+ }
1238
+ function formatMoneyUsd(usd, config) {
1239
+ const rate = Number(config?.exchangeRate)
1240
+ const value = usd * (Number.isFinite(rate) && rate > 0 ? rate : 1)
1241
+ return formatMoneyValue(value, config)
1242
+ }
1243
+ function formatTokens(n) {
1244
+ const v = Math.max(0, Number(n) || 0)
1245
+ const scaled = x => x >= 100 ? String(Math.round(x)) : String(Math.round(x * 10) / 10)
1246
+ if (v < 1000) return String(Math.round(v))
1247
+ if (v < 1000000) return scaled(v / 1000) + 'K'
1248
+ return scaled(v / 1000000) + 'M'
1249
+ }
1250
+ /**
1251
+ * 模型名归一化(与 lib/pricing.js 的 canonModelId 同逻辑;bundle 无法导入,修改时两处同步):
1252
+ * 小写,去括号附注(如 (go)),只保留字母数字——大小写/空格/横杠/点号等差异全部忽略。
1253
+ */
1254
+ function canonModelIdLocal(id) {
1255
+ return String(id ?? '').toLowerCase()
1256
+ .replace(/\([^)]*\)/g, ' ')
1257
+ .replace(/([^)]*)/g, ' ')
1258
+ .replace(/[^a-z0-9]+/g, '')
1259
+ }
1260
+ /**
1261
+ * 模型名自动匹配(与 lib/pricing.js 的 matchModelId 同逻辑;bundle 无法导入,修改时两处同步)。
1262
+ * 精确 → 归一化等价 → 宽泛包含(取最长候选) → 去后缀 → 前缀 → 家族 token 相似。
1263
+ */
1264
+ function matchModelIdLocal(modelId, candidates) {
1265
+ if (typeof modelId !== 'string' || modelId.length === 0) return null
1266
+ const list = Array.isArray(candidates) ? candidates.filter(c => typeof c === 'string' && c.length > 0) : []
1267
+ if (list.length === 0) return null
1268
+ const strip = id => String(id).toLowerCase().replace(/[-@]\d{4}-?\d{2}-?\d{2}$/, '').replace(/[-@]v\d+(\.\d+)*$/, '')
1269
+ const exact = list.find(c => c === modelId)
1270
+ if (exact !== undefined) return exact
1271
+ const canon = canonModelIdLocal(modelId)
1272
+ if (canon.length === 0) return null
1273
+ const byCanon = list.find(c => canonModelIdLocal(c) === canon)
1274
+ if (byCanon !== undefined) return byCanon
1275
+ let containHit = null
1276
+ let containLen = 0
1277
+ for (const c of list) {
1278
+ const cc = canonModelIdLocal(c)
1279
+ if (cc.length < 4 || cc === canon) continue
1280
+ if (canon.includes(cc) && cc.length > containLen) { containHit = c; containLen = cc.length }
1281
+ }
1282
+ if (containHit !== null) return containHit
1283
+ const stripped = strip(modelId)
1284
+ const byStripped = list.find(c => strip(c) === stripped)
1285
+ if (byStripped !== undefined) return byStripped
1286
+ let prefixHit = null
1287
+ for (const c of list) {
1288
+ const cs = strip(c)
1289
+ if (cs.length === 0 || cs === stripped) continue
1290
+ if (stripped.startsWith(cs) && /^[-_./:]/.test(stripped.slice(cs.length))) {
1291
+ if (prefixHit === null || strip(prefixHit).length < cs.length) prefixHit = c
1292
+ }
1293
+ }
1294
+ if (prefixHit !== null) return prefixHit
1295
+ const tokensOf = id => strip(id).split(/[-_./:]+/).filter(Boolean)
1296
+ const mt = tokensOf(modelId)
1297
+ if (mt.length < 2) return null
1298
+ let best = null
1299
+ let bestLen = 0
1300
+ for (const c of list) {
1301
+ const ct = tokensOf(c)
1302
+ let n = 0
1303
+ while (n < mt.length && n < ct.length && mt[n] === ct[n]) n += 1
1304
+ // 防跨版本误配(issue #18,与 pricing.js 同步):分歧位置两侧都是数字/版本号 token 时拒绝匹配。
1305
+ if (n < mt.length && n < ct.length && /^\d+$/.test(mt[n]) && /^\d+$/.test(ct[n])) continue
1306
+ if (n >= 2 && (n > bestLen || (n === bestLen && best !== null && c.length < best.length))) { best = c; bestLen = n }
1307
+ }
1308
+ return best
1309
+ }
1310
+ /**
1311
+ * 客户端价格解析(与 pricing.js providerPriceEntryFor 同口径):手动覆盖 → 精确 → 自动匹配。
1312
+ * @returns { entry, priced, billingMode }。
1313
+ */
1314
+ function resolveClientPrice(providerRaw, modelId, config) {
1315
+ const prices = config?.prices ?? {}
1316
+ const mode = config?.priceMatch === 'exact' ? 'exact' : 'auto'
1317
+ const overrides = config?.priceOverrides && typeof config.priceOverrides === 'object' ? config.priceOverrides : {}
1318
+ let provider = String(providerRaw ?? '').trim().toLowerCase()
1319
+ if (provider.startsWith('llm-')) provider = provider.slice(4)
1320
+ if (provider === '') provider = 'deepseek'
1321
+ let targetProvider = provider
1322
+ let targetModel = modelId
1323
+ const override = overrides[provider + ':' + modelId]
1324
+ if (typeof override === 'string' && override.length > 0) {
1325
+ const sep = override.indexOf(':')
1326
+ if (sep > 0 && override.slice(sep + 1).length > 0) {
1327
+ targetProvider = override.slice(0, sep).trim().toLowerCase()
1328
+ targetModel = override.slice(sep + 1)
1329
+ } else {
1330
+ targetModel = override
1331
+ }
1332
+ if (targetProvider === 'deepseek' && targetModel === '__default__') {
1333
+ return { entry: prices.default ?? { cacheHit: 0, cacheMiss: 0, output: 0 }, priced: true, billingMode: 'deepseek-peak' }
1334
+ }
1335
+ }
1336
+ if (targetProvider === 'deepseek' || targetProvider.includes('deepseek')) {
1337
+ const models = prices.models ?? {}
1338
+ const hit = models[targetModel] !== undefined ? targetModel
1339
+ : (mode === 'auto' ? matchModelIdLocal(targetModel, Object.keys(models)) : null)
1340
+ if (hit !== null) return { entry: models[hit], priced: true, billingMode: 'deepseek-peak' }
1341
+ return { entry: prices.default ?? { cacheHit: 0, cacheMiss: 0, output: 0 }, priced: true, billingMode: 'deepseek-peak' }
1342
+ }
1343
+ const catalog = prices.providers?.[targetProvider]?.models ?? {}
1344
+ const hit = catalog[targetModel] !== undefined ? targetModel
1345
+ : (mode === 'auto' ? matchModelIdLocal(targetModel, Object.keys(catalog)) : null)
1346
+ if (hit !== null) return { entry: catalog[hit], priced: catalog[hit]?.unpriced !== true, billingMode: 'flat' }
1347
+ // 跨厂商兑底(与 pricing.js 同口径):provider 未在价格表登记时按模型名全库查找。
1348
+ if (mode === 'auto') {
1349
+ const dsModels = prices.models ?? {}
1350
+ const dsHit = matchModelIdLocal(targetModel, Object.keys(dsModels))
1351
+ if (dsHit !== null) return { entry: dsModels[dsHit], priced: true, billingMode: 'deepseek-peak' }
1352
+ let bestEntry = null
1353
+ let bestLen = 0
1354
+ for (const [prov, table] of Object.entries(prices.providers ?? {})) {
1355
+ if (prov === targetProvider) continue
1356
+ const models = table?.models ?? {}
1357
+ const h = matchModelIdLocal(targetModel, Object.keys(models))
1358
+ if (h === null || models[h]?.unpriced === true) continue
1359
+ const score = canonModelIdLocal(h).length
1360
+ if (score > bestLen) { bestEntry = models[h]; bestLen = score }
1361
+ }
1362
+ if (bestEntry !== null) return { entry: bestEntry, priced: true, billingMode: 'flat' }
1363
+ }
1364
+ return { entry: null, priced: false, billingMode: 'flat' }
1365
+ }
1366
+ /** 投影 token 桶 → 按当前时刻档位计价的美元成本。 */
1367
+ function usageCost(usage, config) {
1368
+ if (!usage || !config) return 0
1369
+ // 宿主按事件时刻逐次计费的成本(历史正确,含峰谷时代前的旧基础价);
1370
+ // 旧宿主/旧状态缺失 cost 时回退客户端估算。
1371
+ if (typeof usage.cost === 'number' && Number.isFinite(usage.cost)) return usage.cost
1372
+ const peak = {
1373
+ enabled: config.peakEnabled === true,
1374
+ effectiveAtMs: Date.parse(config.peakEffectiveAt || ''),
1375
+ windows: config.peakWindows,
1376
+ }
1377
+ const now = Date.now()
1378
+ const byModel = usage.byProviderModel ?? usage.byModel ?? {}
1379
+ let total = 0
1380
+ for (const providerKey of Object.keys(byModel)) {
1381
+ const separator = providerKey.indexOf(':')
1382
+ const provider = separator > 0 ? providerKey.slice(0, separator) : 'deepseek'
1383
+ const modelId = separator > 0 ? providerKey.slice(separator + 1) : providerKey
1384
+ const resolved = resolveClientPrice(provider, modelId, config)
1385
+ if (resolved.priced) total += costOfBuckets(byModel[providerKey], tierFor(normalizeClientPrice(resolved.entry), now, { ...peak, enabled: resolved.billingMode === 'deepseek-peak' && peak.enabled }))
1386
+ }
1387
+ const modeled = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
1388
+ for (const modelId of Object.keys(byModel)) {
1389
+ modeled.input += byModel[modelId].input ?? 0
1390
+ modeled.output += byModel[modelId].output ?? 0
1391
+ modeled.cacheRead += byModel[modelId].cacheRead ?? 0
1392
+ modeled.cacheWrite += byModel[modelId].cacheWrite ?? 0
1393
+ modeled.reasoning += byModel[modelId].reasoning ?? 0
1394
+ }
1395
+ const leftover = {
1396
+ input: Math.max(0, (usage.input ?? 0) - modeled.input),
1397
+ output: Math.max(0, (usage.output ?? 0) - modeled.output),
1398
+ cacheRead: Math.max(0, (usage.cacheRead ?? 0) - modeled.cacheRead),
1399
+ cacheWrite: Math.max(0, (usage.cacheWrite ?? 0) - modeled.cacheWrite),
1400
+ reasoning: Math.max(0, (usage.reasoning ?? 0) - modeled.reasoning),
1401
+ }
1402
+ total += costOfBuckets(leftover, tierFor(priceEntryFor('default', config.prices), now, peak))
1403
+ return total
1404
+ }
1405
+ function billedInput(usage) {
1406
+ return (usage?.input ?? 0) + (usage?.cacheRead ?? 0) + (usage?.cacheWrite ?? 0)
1407
+ }
1408
+
1409
+ // ── 客户端状态存储 ──────────────────────────────────────────────────────
1410
+
1411
+ function makeStore(initial) {
1412
+ let snapshot = initial
1413
+ const listeners = new Set()
1414
+ return {
1415
+ getSnapshot: () => snapshot,
1416
+ subscribe: fn => {
1417
+ listeners.add(fn)
1418
+ return () => { listeners.delete(fn) }
1419
+ },
1420
+ set: next => {
1421
+ if (next === snapshot) return
1422
+ snapshot = next
1423
+ for (const fn of [...listeners]) fn()
1424
+ },
1425
+ }
1426
+ }
1427
+
1428
+ const { createElement: el, Fragment, useState, useEffect, useMemo, useCallback, useRef } = React
1429
+
1430
+ // ── 钱包图标:官方填充式单色 SVG(16×16),与 @deepseek-ai/dsh-client-ui-primitives 同构 ──
1431
+
1432
+ function WalletIcon({ size = 16, className }) {
1433
+ return el('svg', { width: size, height: size, className, viewBox: '0 0 16 16', fill: 'none', xmlns: 'http://www.w3.org/2000/svg' },
1434
+ el('path', {
1435
+ d: 'M4 4H12A2 2 0 0 1 14 6V11.5A2 2 0 0 1 12 13.5H4A2 2 0 0 1 2 11.5V6A2 2 0 0 1 4 4ZM4 5.3H12A0.7 0.7 0 0 1 12.7 6V11.5A0.7 0.7 0 0 1 12 12.2H4A0.7 0.7 0 0 1 3.3 11.5V6A0.7 0.7 0 0 1 4 5.3Z',
1436
+ fill: 'currentColor',
1437
+ fillRule: 'evenodd',
1438
+ }),
1439
+ el('path', { d: 'M3.3 5.3H12.7V7.1H3.3Z', fill: 'currentColor' }),
1440
+ el('path', { d: 'M8 2.8A1.3 1.3 0 1 0 8 5.4A1.3 1.3 0 1 0 8 2.8Z', fill: 'currentColor' }))
1441
+ }
1442
+
1443
+ // ── 会话费用徽章(dock / header) ────────────────────────────────────────
1444
+
1445
+ function SessionCost(props) {
1446
+ const usage = props.useProjection ? props.useProjection('costUsage') : undefined
1447
+ const costStore = props.useCost ? props.useCost(s => s) : undefined
1448
+ const config = costStore?.state?.config
1449
+ const cost = usageCost(usage, config)
1450
+ const input = billedInput(usage)
1451
+ if (!usage || !config || (input + (usage?.output ?? 0)) === 0) return null
1452
+ const t = makeT(resolveLocale(config.locale))
1453
+ const detail = [
1454
+ t('sessionCostTitle'),
1455
+ t('sessionDetailTokens', {
1456
+ input: formatTokens(usage?.input ?? 0),
1457
+ cache: formatTokens((usage?.cacheRead ?? 0) + (usage?.cacheWrite ?? 0)),
1458
+ output: formatTokens(usage?.output ?? 0),
1459
+ }),
1460
+ t('sessionDetailCache', {
1461
+ read: formatTokens(usage?.cacheRead ?? 0),
1462
+ write: formatTokens(usage?.cacheWrite ?? 0),
1463
+ }),
1464
+ t('cost', { amount: formatMoneyUsd(cost, config) }),
1465
+ ].join('; ')
1466
+ return el(Tooltip, { label: detail, side: 'top', delayMs: 500 },
1467
+ el('div', { className: 'cm-chip' }, t('cost', { amount: formatMoneyUsd(cost, config) })))
1468
+ }
1469
+
1470
+ function DockLine(props) {
1471
+ const usage = props.useProjection ? props.useProjection('costUsage') : undefined
1472
+ const costStore = props.useCost ? props.useCost(s => s) : undefined
1473
+ const config = costStore?.state?.config
1474
+ if (!usage || !config) return null
1475
+ const input = usage.input ?? 0
1476
+ const cache = (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0)
1477
+ const output = usage.output ?? 0
1478
+ if (input + cache + output === 0) return null
1479
+ const cost = usageCost(usage, config)
1480
+ const t = makeT(resolveLocale(config.locale))
1481
+ return el('div', { className: 'cm-root' },
1482
+ t('sessionLine', {
1483
+ amount: formatMoneyUsd(cost, config),
1484
+ input: formatTokens(input),
1485
+ cache: formatTokens(cache),
1486
+ output: formatTokens(output),
1487
+ }))
1488
+ }
1489
+
1490
+ // ── 侧边栏:余额行 + 预算图框/今日徽章(纵向堆叠,位于设置按钮上方) ──────
1491
+
1492
+ function formatBalanceMoney(value, config) {
1493
+ // 余额是官方接口返回的记账币种金额(如 CNY),不经过汇率换算。
1494
+ return formatMoneyValue(value, { symbol: config.symbol, decimals: Math.max(2, Math.min(10, Math.floor(Number(config.decimals) || 2))) })
1495
+ }
1496
+
1497
+ function resolveBalanceCap(config, custom) {
1498
+ const manual = Number(config?.balance?.budgetCap)
1499
+ if (Number.isFinite(manual) && manual > 0) return manual
1500
+ const apiMax = Number(custom?.maxBudget)
1501
+ if (Number.isFinite(apiMax) && apiMax > 0) return apiMax
1502
+ return null
1503
+ }
1504
+
1505
+ function todayUsedInBalanceCurrency(state, config, mode, custom) {
1506
+ const usd = Number(state.today?.cost) || 0
1507
+ if (mode === 'custom') {
1508
+ const unit = customBalanceUnitOf(config, custom)
1509
+ if (unit === 'USD') return usd
1510
+ const rate = Number(config?.exchangeRate)
1511
+ return usd * (Number.isFinite(rate) && rate > 0 ? rate : 1)
1512
+ }
1513
+ const rate = Number(config?.exchangeRate)
1514
+ return usd * (Number.isFinite(rate) && rate > 0 ? rate : 1)
1515
+ }
1516
+
1517
+ function computeBalanceSegments({ remaining, spend, todayUsed, cap }) {
1518
+ if (cap === null || cap <= 0) {
1519
+ return { remainingPct: 100, todayPct: 0, spentPct: 0, hasCap: false, pastSpend: 0, today: 0 }
1520
+ }
1521
+ const rem = Math.max(0, Number(remaining) || 0)
1522
+ const totalSpend = Math.max(0, Number(spend) || 0)
1523
+ const today = Math.max(0, Math.min(totalSpend, Number(todayUsed) || 0))
1524
+ const pastSpend = Math.max(0, totalSpend - today)
1525
+ const remainingPct = Math.max(0, Math.min(100, rem / cap * 100))
1526
+ const todayPct = Math.max(0, Math.min(100 - remainingPct, today / cap * 100))
1527
+ const spentPct = Math.max(0, Math.min(100 - remainingPct - todayPct, pastSpend / cap * 100))
1528
+ return { remainingPct, todayPct, spentPct, hasCap: true, pastSpend: pastSpend, today }
1529
+ }
1530
+
1531
+ function segmentsForCustomBalance(state, config) {
1532
+ const custom = state.customBalance
1533
+ const cap = resolveBalanceCap(config, custom)
1534
+ const remaining = Number(custom?.remaining) || 0
1535
+ const spend = Number(custom?.spend) || 0
1536
+ const todayUsed = todayUsedInBalanceCurrency(state, config, 'custom', custom)
1537
+ return { cap, ...computeBalanceSegments({ remaining, spend, todayUsed, cap }) }
1538
+ }
1539
+
1540
+ function segmentsForOfficialBalance(state, config) {
1541
+ const balance = state.balance
1542
+ const cap = resolveBalanceCap(config, null)
1543
+ const remaining = Number(balance?.totalBalance) || 0
1544
+ const todayUsed = todayUsedInBalanceCurrency(state, config, 'official', null)
1545
+ const spend = cap !== null ? Math.max(0, cap - remaining) : 0
1546
+ return { cap, ...computeBalanceSegments({ remaining, spend, todayUsed, cap }) }
1547
+ }
1548
+
1549
+ function BalanceBar(props) {
1550
+ const { segments } = props
1551
+ const { remainingPct, todayPct, spentPct } = segments
1552
+ const kids = []
1553
+ if (remainingPct > 0) kids.push(el('div', { className: 'cm-bbox-fill', style: { width: remainingPct + '%' } }))
1554
+ if (todayPct > 0) kids.push(el('div', { className: 'cm-bbox-seg-today', style: { width: todayPct + '%' } }))
1555
+ if (spentPct > 0) kids.push(el('div', { className: 'cm-bbox-seg-spent', style: { width: spentPct + '%' } }))
1556
+ if (kids.length === 0) kids.push(el('div', { className: 'cm-bbox-fill', style: { width: '100%' } }))
1557
+ return el('div', { className: 'cm-bbox-bar segments' }, ...kids)
1558
+ }
1559
+
1560
+ function balanceBarTooltipLines(t, formatAmount, segments, remainingAmount, cap) {
1561
+ const lines = [t('balanceBarRemaining', { amount: remainingAmount })]
1562
+ if (segments.today > 0) lines.push(t('balanceBarToday', { amount: formatAmount(segments.today) }))
1563
+ if (segments.pastSpend > 0) lines.push(t('balanceBarSpent', { amount: formatAmount(segments.pastSpend) }))
1564
+ if (cap !== null && cap > 0) lines.push(t('balanceBudgetCapLabel') + ': ' + formatAmount(cap))
1565
+ return lines.join(' · ')
1566
+ }
1567
+
1568
+ function BalanceRowContent(props) {
1569
+ const { state, wide } = props
1570
+ const balance = state.balance
1571
+ const t = makeT(resolveLocale(state.config?.locale))
1572
+ if (!balance || balance.status === 'off') return null
1573
+ if (balance.status === 'error') {
1574
+ return el(Tooltip, { label: t('balanceQueryFailed', { message: balance.message || t('unknownError') }), side: 'right', delayMs: 300 },
1575
+ el('div', { className: 'cm-foot' + (wide ? '' : ' cm-foot-rail') + ' cm-bal-err' },
1576
+ wide ? el(Fragment, null, t('balance'), ' ', el('span', { className: 'cm-num' }, t('queryFailed'))) : '⚠'))
1577
+ }
1578
+ const detail = [
1579
+ t('balanceTitle'),
1580
+ t('totalBalance', { amount: formatBalanceMoney(balance.totalBalance, state.config) }),
1581
+ t('grantedToppedUp', {
1582
+ granted: formatBalanceMoney(balance.grantedBalance, state.config),
1583
+ toppedUp: formatBalanceMoney(balance.toppedUpBalance, state.config),
1584
+ }),
1585
+ t('updatedAt', { time: new Date(balance.fetchedAt).toLocaleTimeString() }),
1586
+ ...(state.reconcile?.ok === false ? ['⚠ ' + state.reconcile.message] : []),
1587
+ ].join('; ')
1588
+ return el(Tooltip, { label: detail, side: 'right', delayMs: 300 },
1589
+ el('div', { className: 'cm-foot' + (wide ? '' : ' cm-foot-rail') },
1590
+ wide ? el(Fragment, null, t('balance'), ' ', el('span', { className: 'cm-num' }, formatBalanceMoney(balance.totalBalance, state.config)), state.reconcile?.ok === false ? ' ⚠' : '') : el(WalletIcon, { size: 16 })))
1591
+ }
1592
+
1593
+ function BalanceBox(props) {
1594
+ const { state, wide } = props
1595
+ const config = state.config
1596
+ const balance = state.balance
1597
+ const t = makeT(resolveLocale(config?.locale))
1598
+ if (!balance || balance.status !== 'ok') return null
1599
+ const segments = segmentsForOfficialBalance(state, config)
1600
+ const remaining = formatBalanceMoney(balance.totalBalance, config)
1601
+ const formatAmt = v => formatBalanceMoney(v, config)
1602
+ const detail = [
1603
+ t('balanceTitle'),
1604
+ balanceBarTooltipLines(t, formatAmt, segments, remaining, segments.cap),
1605
+ t('grantedToppedUp', {
1606
+ granted: formatBalanceMoney(balance.grantedBalance, config),
1607
+ toppedUp: formatBalanceMoney(balance.toppedUpBalance, config),
1608
+ }),
1609
+ t('updatedAt', { time: new Date(balance.fetchedAt).toLocaleTimeString() }),
1610
+ ...(state.reconcile?.ok === false ? ['⚠ ' + state.reconcile.message] : []),
1611
+ ].join('; ')
1612
+ const body = el(Fragment, null,
1613
+ el('div', { className: 'cm-bbox-head' },
1614
+ el('span', { className: 'cm-bbox-label' }, t('balance')),
1615
+ el('span', { className: 'cm-bbox-pct cm-num cm-bal-amt' }, remaining)),
1616
+ el(BalanceBar, { segments }))
1617
+ return el(Tooltip, { label: detail, side: 'right', delayMs: 300 },
1618
+ el('div', { className: 'cm-bbox' + (wide ? '' : ' rail') },
1619
+ wide ? body : el('div', { className: 'cm-bbox-rail cm-num' }, Math.round(segments.remainingPct) + '%')))
1620
+ }
1621
+
1622
+ function resolveCustomBalanceLabel(cfg, locale) {
1623
+ const zh = typeof cfg?.label === 'string' ? cfg.label : ''
1624
+ const en = typeof cfg?.labelEn === 'string' ? cfg.labelEn : ''
1625
+ if (locale === 'en') return en || zh || 'Custom balance'
1626
+ return zh || en || '自定义余额'
1627
+ }
1628
+
1629
+ function customBalanceUnitOf(config, custom) {
1630
+ const unit = config?.customBalance?.unit
1631
+ if (unit === 'CNY' || unit === 'EUR' || unit === 'USD') return unit
1632
+ return custom?.unit === 'CNY' || custom?.unit === 'EUR' ? custom.unit : 'USD'
1633
+ }
1634
+
1635
+ function formatCustomBalanceMoney(amount, config, custom) {
1636
+ const unit = customBalanceUnitOf(config, custom)
1637
+ const decimals = Math.max(2, Math.min(6, Math.floor(Number(config?.decimals) || 4)))
1638
+ const symbol = unit === 'CNY' ? '¥' : unit === 'EUR' ? '€' : '$'
1639
+ const value = Number(amount)
1640
+ if (!Number.isFinite(value)) return '—'
1641
+ let fixed = value.toFixed(decimals)
1642
+ if (fixed.includes('.')) fixed = fixed.replace(/0+$/, '').replace(/\.$/, '')
1643
+ return symbol + fixed
1644
+ }
1645
+
1646
+ function customBalanceDetailText(custom, config, t, state) {
1647
+ const remaining = formatCustomBalanceMoney(custom.remaining, config, custom)
1648
+ const formatAmt = v => formatCustomBalanceMoney(v, config, custom)
1649
+ if (state && config.balance?.showProgressBar === true) {
1650
+ const segments = segmentsForCustomBalance(state, config)
1651
+ return [
1652
+ balanceBarTooltipLines(t, formatAmt, segments, remaining, segments.cap),
1653
+ t('updatedAt', { time: custom.fetchedAt > 0 ? new Date(custom.fetchedAt).toLocaleTimeString() : '—' }),
1654
+ ].join(' · ')
1655
+ }
1656
+ const spend = custom.spend !== null ? formatCustomBalanceMoney(custom.spend, config, custom) : '—'
1657
+ const maxBudget = custom.maxBudget !== null ? formatCustomBalanceMoney(custom.maxBudget, config, custom) : '—'
1658
+ const manualCap = Number(config?.balance?.budgetCap)
1659
+ const capLine = Number.isFinite(manualCap) && manualCap > 0
1660
+ ? t('balanceBudgetCapLabel') + ': ' + formatCustomBalanceMoney(manualCap, config, custom)
1661
+ : ''
1662
+ const base = custom.maxBudget !== null && custom.spend !== null
1663
+ ? t('customBalanceLine', {
1664
+ remaining,
1665
+ spend,
1666
+ maxBudget,
1667
+ time: custom.fetchedAt > 0 ? new Date(custom.fetchedAt).toLocaleTimeString() : '—',
1668
+ })
1669
+ : t('customBalanceRemaining', { amount: remaining })
1670
+ return capLine ? base + ' · ' + capLine : base
1671
+ }
1672
+
1673
+ function customBalanceBoxBody(state, config, t) {
1674
+ const custom = state.customBalance
1675
+ const cfg = config.customBalance ?? {}
1676
+ const label = resolveCustomBalanceLabel(cfg, resolveLocale(config?.locale))
1677
+ const remaining = formatCustomBalanceMoney(custom.remaining, config, custom)
1678
+ const segments = segmentsForCustomBalance(state, config)
1679
+ return {
1680
+ level: 'ok',
1681
+ rail: Math.round(segments.remainingPct) + '%',
1682
+ body: el(Fragment, null,
1683
+ el('div', { className: 'cm-bbox-head' },
1684
+ el('span', { className: 'cm-bbox-label' }, label),
1685
+ el('span', { className: 'cm-bbox-pct cm-num cm-bal-amt' }, remaining)),
1686
+ el(BalanceBar, { segments })),
1687
+ }
1688
+ }
1689
+
1690
+ function CustomBalanceBox(props) {
1691
+ const { state, wide } = props
1692
+ const custom = state.customBalance
1693
+ const config = state.config
1694
+ const t = makeT(resolveLocale(config?.locale))
1695
+ if (!custom || custom.status !== 'ok') return null
1696
+ const view = customBalanceBoxBody(state, config, t)
1697
+ const detail = customBalanceDetailText(custom, config, t, state)
1698
+ return el(Tooltip, { label: detail, side: 'right', delayMs: 300 },
1699
+ el('div', { className: 'cm-bbox' + (view.level === 'ok' ? '' : ' ' + view.level) + (wide ? '' : ' rail') },
1700
+ wide ? view.body : el('div', { className: 'cm-bbox-rail cm-num' }, view.rail)))
1701
+ }
1702
+
1703
+ function CustomBalanceRowContent(props) {
1704
+ const { state, wide } = props
1705
+ const custom = state.customBalance
1706
+ const config = state.config
1707
+ const t = makeT(resolveLocale(config?.locale))
1708
+ if (!custom || custom.status === 'off') return null
1709
+ const label = resolveCustomBalanceLabel(config.customBalance ?? {}, resolveLocale(config?.locale))
1710
+ if (custom.status === 'error') {
1711
+ return el(Tooltip, { label: custom.message || t('unknownError'), side: 'right', delayMs: 300 },
1712
+ el('div', { className: 'cm-foot' + (wide ? '' : ' cm-foot-rail') + ' cm-bal-err' },
1713
+ wide ? el(Fragment, null, label, ' ', el('span', { className: 'cm-num' }, t('queryFailed'))) : '⚠'))
1714
+ }
1715
+ const amount = formatCustomBalanceMoney(custom.remaining, config, custom)
1716
+ const detail = customBalanceDetailText(custom, config, t, state)
1717
+ return el(Tooltip, { label: detail, side: 'right', delayMs: 300 },
1718
+ el('div', { className: 'cm-foot' + (wide ? '' : ' cm-foot-rail') },
1719
+ wide ? el(Fragment, null, label, ' ', el('span', { className: 'cm-num' }, amount)) : el(WalletIcon, { size: 16 })))
1720
+ }
1721
+
1722
+ function CornerChips(props) {
1723
+ const costStore = props.useCost ? props.useCost(s => s) : undefined
1724
+ const state = costStore?.state
1725
+ if (!state) return null
1726
+ const config = state.config
1727
+ const t = makeT(resolveLocale(config?.locale))
1728
+ const corner = config.corner ?? { enabled: false, goRolling: true, goWeekly: true, goMonthly: true, budget: true }
1729
+ const goQuota = state.goQuota
1730
+ const goOk = config?.goQuota?.enabled !== false && goQuota?.status === 'ok'
1731
+ const pctOf = win => (win !== null && typeof win?.percent === 'number')
1732
+ ? Math.round(Math.max(0, Math.min(100, win.percent)))
1733
+ : null
1734
+ const chips = []
1735
+ const pushGo = (on, win, shortKey, labelKey) => {
1736
+ if (!on || !goOk) return
1737
+ const pct = pctOf(win)
1738
+ if (pct === null) return
1739
+ const resets = typeof win.resetsAt === 'string' && win.resetsAt.length > 0
1740
+ ? t('goResetAt', { time: new Date(win.resetsAt).toLocaleString() })
1741
+ : ''
1742
+ chips.push({
1743
+ key: shortKey,
1744
+ text: t(shortKey) + ' ' + pct + '%',
1745
+ tip: t(labelKey) + ': ' + pct + '%' + (resets ? ' · ' + resets : ''),
1746
+ level: pct >= 100 ? 'over' : pct >= 80 ? 'warn' : 'ok',
1747
+ })
1748
+ }
1749
+ const mainKey = config?.goQuota?.main === 'weekly' || config?.goQuota?.main === 'monthly' ? config.goQuota.main : 'rolling'
1750
+ const goOrder = [mainKey, ...['rolling', 'weekly', 'monthly'].filter(k => k !== mainKey)]
1751
+ const defOf = k => k === 'rolling'
1752
+ ? ['goRolling', goQuota?.rolling, 'goShortRolling', 'cornerGoRolling']
1753
+ : k === 'weekly'
1754
+ ? ['goWeekly', goQuota?.weekly, 'goShortWeekly', 'cornerGoWeekly']
1755
+ : ['goMonthly', goQuota?.monthly, 'goShortMonthly', 'cornerGoMonthly']
1756
+ for (const k of goOrder) {
1757
+ const [flagKey, win, shortKey, labelKey] = defOf(k)
1758
+ pushGo(corner[flagKey] === true, win, shortKey, labelKey)
1759
+ }
1760
+ // 预算 chip:预算图框同款口径(≥80% 预警、≥100% 超支)。
1761
+ if (corner.budget === true) {
1762
+ const budget = config.budget ?? { enabled: false, amount: 100, period: 'month' }
1763
+ if (budget.enabled === true) {
1764
+ const rate = Number(config.exchangeRate)
1765
+ const usedUsd = state.budgetUsed ?? (
1766
+ budget.period === 'day' ? state.today.cost
1767
+ : budget.period === 'all' ? state.total.cost
1768
+ : state.month.cost)
1769
+ const used = usedUsd * (Number.isFinite(rate) && rate > 0 ? rate : 1)
1770
+ const amount = Math.max(0, Number(budget.amount) || 0)
1771
+ const pct = amount > 0 ? Math.min(999, used / amount * 100) : null
1772
+ if (pct !== null) {
1773
+ const level = pct >= 100 ? 'over' : pct >= 80 ? 'warn' : 'ok'
1774
+ chips.push({
1775
+ key: 'budget',
1776
+ text: t('budgetShort') + ' ' + pct.toFixed(1) + '%',
1777
+ tip: t('budgetOf', { period: t(PERIOD_KEYS[budget.period] ?? 'periodMonth') }) + ' · '
1778
+ + t('usedOf', { used: formatMoneyValue(used, config), amount: formatMoneyValue(amount, config) }),
1779
+ level,
1780
+ })
1781
+ }
1782
+ }
1783
+ }
1784
+ if (chips.length === 0) return null
1785
+ return el('div', { className: 'cm-corner' },
1786
+ chips.map(c => el(Tooltip, { key: c.key, label: c.tip, side: 'top', delayMs: 500 },
1787
+ el('span', { className: 'cm-corner-chip' + (c.level === 'ok' ? '' : ' ' + c.level) }, c.text))))
1788
+ }
1789
+
1790
+ /**
1791
+ * 峰谷相位与相邻切换点(与 lib/pricing.js 的 peakPhaseAt 同逻辑;bundle 无法导入,
1792
+ * 修改时两处需同步)。窗口半开区间 [start, end),兼容跨午夜窗口。
1793
+ */
1794
+ function peakPhaseAt(atMs, windows) {
1795
+ if (!Array.isArray(windows) || windows.length === 0 || !Number.isFinite(atMs)) return null
1796
+ const hourAt = (dayOffset, hour) => {
1797
+ const date = new Date(atMs)
1798
+ date.setUTCDate(date.getUTCDate() + dayOffset)
1799
+ date.setUTCHours(hour, 0, 0, 0)
1800
+ return date.getTime()
1801
+ }
1802
+ const points = []
1803
+ for (let day = -1; day <= 1; day += 1) {
1804
+ for (const w of windows) {
1805
+ const start = Number(w?.start)
1806
+ const end = Number(w?.end)
1807
+ if (!Number.isFinite(start) || !Number.isFinite(end)) continue
1808
+ points.push({ at: hourAt(day, start), intoPeak: true })
1809
+ points.push({ at: hourAt(end <= start ? day + 1 : day, end), intoPeak: false })
1810
+ }
1811
+ }
1812
+ const inPeak = isPeakHour(atMs, undefined, windows)
1813
+ let prev = null
1814
+ let next = null
1815
+ for (const p of points) {
1816
+ if (p.at <= atMs && (prev === null || p.at > prev.at)) prev = p
1817
+ if (p.at > atMs && (next === null || p.at < next.at)) next = p
1818
+ }
1819
+ if (prev === null || next === null) return null
1820
+ return { inPeak, prevAtMs: prev.at, nextAtMs: next.at, nextIntoPeak: next.intoPeak }
1821
+ }
1822
+ /** 峰谷显示门控:peakNotice 开关 + peakEnabled + peakEffectiveAt + 非空窗口;不满足返回 null。 */
1823
+ function peakView(config, now) {
1824
+ if (!config || config.peakNotice === false || config.peakEnabled !== true) return null
1825
+ const effectiveAtMs = Date.parse(config.peakEffectiveAt || '')
1826
+ if (Number.isFinite(effectiveAtMs) && now < effectiveAtMs) return null
1827
+ const windows = Array.isArray(config.peakWindows) ? config.peakWindows : []
1828
+ return peakPhaseAt(now, windows)
1829
+ }
1830
+
1831
+ /** 倒计时文本(距下次相位切换,向上取整到分钟)。 */
1832
+ function countdownText(view, now, t) {
1833
+ const duration = Math.max(0, view.nextAtMs - now)
1834
+ const minutes = Math.max(1, Math.ceil(duration / 60000))
1835
+ const hours = Math.floor(minutes / 60)
1836
+ const mins = minutes % 60
1837
+ return hours > 0
1838
+ ? (mins > 0 ? t('countdownHourMinute', { h: hours, m: mins }) : t('countdownHoursOnly', { h: hours }))
1839
+ : t('countdownMinute', { m: minutes })
1840
+ }
1841
+
1842
+ /** 展开态简洁样式:单行紧凑时段条——细轨道(左橙右蓝,非当前段淡化)+ 标记线 + 右侧倒计时文本。 */
1843
+ function PeakStrip(props) {
1844
+ const { config, t } = props
1845
+ const [now, setNow] = useState(Date.now())
1846
+ useEffect(() => {
1847
+ const timer = window.setInterval(() => setNow(Date.now()), 30000)
1848
+ return () => window.clearInterval(timer)
1849
+ }, [])
1850
+ const view = peakView(config, now)
1851
+ if (view === null) return null
1852
+ const chipText = view.nextIntoPeak
1853
+ ? t('nextPeakIn', { time: countdownText(view, now, t) })
1854
+ : t('nextOffPeakIn', { time: countdownText(view, now, t) })
1855
+ return el(Tooltip, { label: t(view.inPeak ? 'peakNotice' : 'offPeakActive'), side: 'right', delayMs: 300 },
1856
+ el('div', { className: 'cm-peak-strip ' + (view.inPeak ? 'peak' : 'off') },
1857
+ el('div', { className: 'cm-peak-track' },
1858
+ el('div', { className: 'cm-peak-segment cm-peak-high' }),
1859
+ el('div', { className: 'cm-peak-segment cm-peak-low' }),
1860
+ el('div', { className: 'cm-peak-marker', style: { left: view.inPeak ? '25%' : '75%' } })),
1861
+ el('span', { className: 'cm-peak-chip' }, t(view.inPeak ? 'peakShort' : 'offPeakShort') + ' · ' + chipText)))
1862
+ }
1863
+
1864
+ /** 展开态经典样式:轨道 + 箭头旗标 + 胶囊芯片(两行)。 */
1865
+ function PeakStripClassic(props) {
1866
+ const { config, t } = props
1867
+ const [now, setNow] = useState(Date.now())
1868
+ useEffect(() => {
1869
+ const timer = window.setInterval(() => setNow(Date.now()), 30000)
1870
+ return () => window.clearInterval(timer)
1871
+ }, [])
1872
+ const view = peakView(config, now)
1873
+ if (view === null) return null
1874
+ const chipText = view.nextIntoPeak
1875
+ ? t('nextPeakIn', { time: countdownText(view, now, t) })
1876
+ : t('nextOffPeakIn', { time: countdownText(view, now, t) })
1877
+ return el(Tooltip, { label: t(view.inPeak ? 'peakNotice' : 'offPeakActive'), side: 'right', delayMs: 300 },
1878
+ el('div', { className: 'cm-peak-classic ' + (view.inPeak ? 'peak' : 'off') },
1879
+ el('div', { className: 'cm-peak-classic-marker', style: { left: view.inPeak ? '25%' : '75%' } }),
1880
+ el('div', { className: 'cm-peak-track' },
1881
+ el('div', { className: 'cm-peak-segment cm-peak-high' }),
1882
+ el('div', { className: 'cm-peak-segment cm-peak-low' })),
1883
+ el('span', { className: 'cm-peak-classic-chip' }, t(view.inPeak ? 'peakShort' : 'offPeakShort') + ' · ' + chipText)))
1884
+ }
1885
+ function peakNoticeEl(state, config, t) {
1886
+ return el(config?.peakStyle === 'classic' ? PeakStripClassic : PeakStrip, { config, t })
1887
+ }
1888
+
1889
+ /** 收起(rail)态简洁样式:竖向同构时段条 + 横排短词(「峰时/平价」),倒计时与完整文案在悬停提示中。 */
1890
+ function PeakRailStrip(props) {
1891
+ const { config, t } = props
1892
+ const [now, setNow] = useState(Date.now())
1893
+ useEffect(() => {
1894
+ const timer = window.setInterval(() => setNow(Date.now()), 30000)
1895
+ return () => window.clearInterval(timer)
1896
+ }, [])
1897
+ const view = peakView(config, now)
1898
+ if (view === null) return null
1899
+ const chipText = view.nextIntoPeak
1900
+ ? t('nextPeakIn', { time: countdownText(view, now, t) })
1901
+ : t('nextOffPeakIn', { time: countdownText(view, now, t) })
1902
+ const detail = [t(view.inPeak ? 'peakNotice' : 'offPeakActive'), chipText]
1903
+ return el(Tooltip, { label: detail, side: 'right', delayMs: 300 },
1904
+ el('div', { className: 'cm-peak-rail ' + (view.inPeak ? 'peak' : 'off'), 'aria-label': detail.join('; ') },
1905
+ el('div', { className: 'cm-peak-rail-track' },
1906
+ el('div', { className: 'cm-peak-rail-segment cm-peak-rail-high' }),
1907
+ el('div', { className: 'cm-peak-rail-segment cm-peak-rail-low' }),
1908
+ el('div', { className: 'cm-peak-rail-marker', style: { top: view.inPeak ? '25%' : '75%' } })),
1909
+ el('span', { className: 'cm-peak-rail-label' }, t(view.inPeak ? 'peakShort' : 'offPeakShort'))))
1910
+ }
1911
+
1912
+ /** 收起(rail)态经典样式:竖向胶囊条——上橙下蓝满色分段(与展开态一致,不淡化不填充),标记指向当前时段,下方横排短词。 */
1913
+ function PeakRailStripClassic(props) {
1914
+ const { config, t } = props
1915
+ const [now, setNow] = useState(Date.now())
1916
+ useEffect(() => {
1917
+ const timer = window.setInterval(() => setNow(Date.now()), 30000)
1918
+ return () => window.clearInterval(timer)
1919
+ }, [])
1920
+ const view = peakView(config, now)
1921
+ if (view === null) return null
1922
+ const chipText = view.nextIntoPeak
1923
+ ? t('nextPeakIn', { time: countdownText(view, now, t) })
1924
+ : t('nextOffPeakIn', { time: countdownText(view, now, t) })
1925
+ const detail = [t(view.inPeak ? 'peakNotice' : 'offPeakActive'), chipText]
1926
+ return el(Tooltip, { label: detail, side: 'right', delayMs: 300 },
1927
+ el('div', { className: 'cm-peak-rail-classic ' + (view.inPeak ? 'peak' : 'off'), 'aria-label': detail.join('; ') },
1928
+ el('div', { className: 'cm-peak-rail-classic-track' },
1929
+ el('div', { className: 'cm-peak-rail-classic-segment peak' }),
1930
+ el('div', { className: 'cm-peak-rail-classic-segment off' }),
1931
+ el('div', { className: 'cm-peak-rail-classic-marker', style: { top: view.inPeak ? '25%' : '75%' } })),
1932
+ el('span', { className: 'cm-peak-rail-classic-label' }, t(view.inPeak ? 'peakShort' : 'offPeakShort'))))
1933
+ }
1934
+ function peakNoticeRailEl(state, config, t) {
1935
+ return el(config?.peakStyle === 'classic' ? PeakRailStripClassic : PeakRailStrip, { config, t })
1936
+ }
1937
+
1938
+ /** 预算图框内容(不含外框),供单独显示与「Go+预算」合并卡片复用;详细信息按 budget.detail 开关。 */
1939
+ function budgetBoxBody(state, config, t) {
1940
+ const today = state.today
1941
+ const budget = config.budget ?? { enabled: false, amount: 100, period: 'month' }
1942
+ const rate = Number(config.exchangeRate)
1943
+ const budgetUsedUsd = state.budgetUsed ?? (
1944
+ budget.period === 'day' ? state.today.cost
1945
+ : budget.period === 'all' ? state.total.cost
1946
+ : state.month.cost)
1947
+ const used = budgetUsedUsd * (Number.isFinite(rate) && rate > 0 ? rate : 1)
1948
+ const amount = Math.max(0, Number(budget.amount) || 0)
1949
+ const pct = amount > 0 ? Math.min(999, used / amount * 100) : null
1950
+ const todayUsed = today.cost * (Number.isFinite(rate) && rate > 0 ? rate : 1)
1951
+ const todayPct = amount > 0 ? Math.min(999, todayUsed / amount * 100) : null
1952
+ const detail = budget.detail !== false
1953
+ return {
1954
+ level: pct === null ? 'ok' : pct >= 100 ? 'over' : pct >= 80 ? 'warn' : 'ok',
1955
+ rail: pct === null ? '—' : Math.round(pct) + '%',
1956
+ body: el(Fragment, null,
1957
+ el('div', { className: 'cm-bbox-head' },
1958
+ el('span', { className: 'cm-bbox-label' }, t('budget')),
1959
+ el('span', { className: 'cm-bbox-pct cm-num' }, pct === null ? '—' : pct.toFixed(1) + '%')),
1960
+ el('div', { className: 'cm-bbox-bar' },
1961
+ el('div', { className: 'cm-bbox-fill', style: { width: (pct === null ? 0 : Math.min(100, pct)) + '%' } })),
1962
+ detail
1963
+ ? el(Fragment, null,
1964
+ el('div', { className: 'cm-bbox-line cm-num' },
1965
+ t('todayShare', {
1966
+ amount: formatMoneyUsd(today.cost, config),
1967
+ pct: todayPct === null ? '—' : todayPct.toFixed(1) + '%',
1968
+ })),
1969
+ el('div', { className: 'cm-bbox-line cm-num' },
1970
+ t('usedOf', { used: formatMoneyValue(used, config), amount: formatMoneyValue(amount, config) })))
1971
+ : null,
1972
+ peakNoticeEl(state, config, t)),
1973
+ }
1974
+ }
1975
+
1976
+ /** OpenCode Go 额度图框内容(不含外框),与预算图框同风格;主档位可配(默认 5h),其余档位在下方一行展示;详细信息按 goQuota.detail 开关。 */
1977
+ function goBoxBody(state, config, t) {
1978
+ const goQuota = state.goQuota
1979
+ const mainKey = config?.goQuota?.main === 'weekly' || config?.goQuota?.main === 'monthly' ? config.goQuota.main : 'rolling'
1980
+ const mainWin = goQuota[mainKey]
1981
+ const pct = mainWin !== null && typeof mainWin?.percent === 'number' ? Math.max(0, Math.min(100, Number(mainWin.percent) || 0)) : 0
1982
+ const pctOf = win => (win !== null && typeof win?.percent === 'number') ? Math.round(Math.max(0, Math.min(100, win.percent))) + '%' : '—'
1983
+ const shortOf = k => k === 'rolling' ? t('goShortRolling') : k === 'weekly' ? t('goShortWeekly') : t('goShortMonthly')
1984
+ const others = ['rolling', 'weekly', 'monthly'].filter(k => k !== mainKey)
1985
+ const resets = mainWin !== null && typeof mainWin?.resetsAt === 'string' && mainWin.resetsAt.length > 0
1986
+ ? t('goResetAt', { time: new Date(mainWin.resetsAt).toLocaleString() })
1987
+ : ''
1988
+ const detail = config?.goQuota?.detail !== false
1989
+ return {
1990
+ level: pct >= 100 ? 'over' : pct >= 80 ? 'warn' : 'ok',
1991
+ rail: Math.round(pct) + '%',
1992
+ body: el(Fragment, null,
1993
+ el('div', { className: 'cm-bbox-head' },
1994
+ el('span', { className: 'cm-bbox-label' }, t('goQuotaRowLabel') + ' ' + shortOf(mainKey)),
1995
+ el('span', { className: 'cm-bbox-pct cm-num' }, pct.toFixed(1) + '%')),
1996
+ el('div', { className: 'cm-bbox-bar' },
1997
+ el('div', { className: 'cm-bbox-fill', style: { width: Math.min(100, pct) + '%' } })),
1998
+ detail
1999
+ ? el(Fragment, null,
2000
+ el('div', { className: 'cm-bbox-line cm-num' },
2001
+ others.map(k => shortOf(k) + ' ' + pctOf(goQuota[k])).join(' · ')),
2002
+ resets ? el('div', { className: 'cm-bbox-line' }, resets) : null)
2003
+ : null),
2004
+ }
2005
+ }
2006
+
2007
+ function GoQuotaBox(props) {
2008
+ const { state, wide } = props
2009
+ const goQuota = state.goQuota
2010
+ const t = makeT(resolveLocale(state.config?.locale))
2011
+ if (!goQuota || goQuota.status !== 'ok') return null
2012
+ const mainKey = state.config?.goQuota?.main === 'weekly' || state.config?.goQuota?.main === 'monthly' ? state.config.goQuota.main : 'rolling'
2013
+ if (goQuota[mainKey] === null || typeof goQuota[mainKey]?.percent !== 'number') return null
2014
+ const view = goBoxBody(state, state.config, t)
2015
+ const detail = [
2016
+ t('goQuotaTitle'),
2017
+ t('goWindowRolling') + ': ' + (goQuota.rolling === null ? '—' : Math.round(goQuota.rolling.percent) + '%'),
2018
+ t('goWindowWeekly') + ': ' + (goQuota.weekly === null ? '—' : Math.round(goQuota.weekly.percent) + '%'),
2019
+ t('goWindowMonthly') + ': ' + Math.round(goQuota.monthly.percent) + '%'
2020
+ + (typeof goQuota.monthly.resetsAt === 'string' && goQuota.monthly.resetsAt.length > 0
2021
+ ? ' · ' + t('goResetAt', { time: new Date(goQuota.monthly.resetsAt).toLocaleString() })
2022
+ : ''),
2023
+ t('goQuotaFetchedAt', { time: goQuota.fetchedAt > 0 ? new Date(goQuota.fetchedAt).toLocaleTimeString() : '—' }),
2024
+ ].join('; ')
2025
+ return el(Tooltip, { label: detail, side: 'right', delayMs: 300 },
2026
+ el('div', { className: 'cm-bbox' + (view.level === 'ok' ? '' : ' ' + view.level) + (wide ? '' : ' rail') },
2027
+ wide ? view.body : el('div', { className: 'cm-bbox-rail cm-num' }, view.rail)))
2028
+ }
2029
+
2030
+ function BudgetBoxContent(props) {
2031
+ const { state, wide } = props
2032
+ const today = state.today
2033
+ const config = state.config
2034
+ const t = makeT(resolveLocale(config?.locale))
2035
+ const budget = config.budget ?? { enabled: false, amount: 100, period: 'month' }
2036
+ const rate = Number(config.exchangeRate)
2037
+ const budgetUsedUsd = state.budgetUsed ?? (
2038
+ budget.period === 'day' ? state.today.cost
2039
+ : budget.period === 'all' ? state.total.cost
2040
+ : state.month.cost)
2041
+ const used = budgetUsedUsd * (Number.isFinite(rate) && rate > 0 ? rate : 1)
2042
+ const amount = Math.max(0, Number(budget.amount) || 0)
2043
+ const pct = amount > 0 ? Math.min(999, used / amount * 100) : null
2044
+ const level = pct === null ? 'ok' : pct >= 100 ? 'over' : pct >= 80 ? 'warn' : 'ok'
2045
+
2046
+ if (budget.enabled === true) {
2047
+ // 预算圆角方形图框(渲染在设置按钮上方、余额行下方)。
2048
+ const todayUsed = today.cost * (Number.isFinite(rate) && rate > 0 ? rate : 1)
2049
+ const todayPct = amount > 0 ? Math.min(999, todayUsed / amount * 100) : null
2050
+ const detail = [
2051
+ t('budgetOf', { period: t(PERIOD_KEYS[budget.period] ?? 'periodMonth') }),
2052
+ t('usedOf', { used: formatMoneyValue(used, config), amount: formatMoneyValue(amount, config) })
2053
+ + ' · ' + (pct === null ? '—' : pct.toFixed(1) + '%'),
2054
+ t('todayShare', {
2055
+ amount: formatMoneyUsd(today.cost, config),
2056
+ pct: todayPct === null ? '—' : todayPct.toFixed(1) + '%',
2057
+ }),
2058
+ t('monthTotal', {
2059
+ month: formatMoneyUsd(state.month.cost, config),
2060
+ total: formatMoneyUsd(state.total.cost, config),
2061
+ }),
2062
+ ].join('; ')
2063
+ const view = budgetBoxBody(state, config, t)
2064
+ return el(Tooltip, { label: detail, side: 'right', delayMs: 300 },
2065
+ el('div', { className: 'cm-bbox' + (level === 'ok' ? '' : ' ' + level) + (wide ? '' : ' rail') },
2066
+ wide ? view.body : el('div', { className: 'cm-bbox-rail cm-num' }, view.rail)))
2067
+ }
2068
+
2069
+ const detail = [
2070
+ t('todayCostTitle'),
2071
+ t('callsTokens', {
2072
+ calls: today.calls,
2073
+ input: formatTokens(today.input),
2074
+ cache: formatTokens(today.cacheRead + today.cacheWrite),
2075
+ output: formatTokens(today.output),
2076
+ }),
2077
+ t('monthCost', { amount: formatMoneyUsd(state.month.cost, config) }),
2078
+ t('totalCost', { amount: formatMoneyUsd(state.total.cost, config) }),
2079
+ ].join('; ')
2080
+ return el(Tooltip, { label: detail, side: 'right', delayMs: 300 },
2081
+ el(Fragment, null,
2082
+ el('div', { className: 'cm-foot' + (wide ? '' : ' cm-foot-rail') },
2083
+ wide ? el(Fragment, null, t('today'), ' ', el('span', { className: 'cm-num' }, formatMoneyUsd(today.cost, config))) : el(WalletIcon, { size: 16 })),
2084
+ wide ? peakNoticeEl(state, config, t) : null))
2085
+ }
2086
+
2087
+ function SidebarFooter(props) {
2088
+ const costStore = props.useCost ? props.useCost(s => s) : undefined
2089
+ const state = costStore?.state
2090
+ const wide = !!props.wide
2091
+ const rootRef = useRef(null)
2092
+ // 兼容外壳 footerActions 与其它插件(如 dsh-remote-web-ui 的「更新/远程控制」行)的图标布局:
2093
+ // - 展开(wide):本插件堆叠保持在最左侧;
2094
+ // - 窄栏(rail):把外壳容器改为纵向排布,本插件置底,同一行的其它插件图标上移。
2095
+ useEffect(() => {
2096
+ const root = rootRef.current
2097
+ const parent = root?.parentElement
2098
+ if (!root || !parent) return
2099
+ const apply = () => {
2100
+ if (wide) {
2101
+ if (parent.firstElementChild !== root) parent.insertBefore(root, parent.firstElementChild)
2102
+ } else {
2103
+ if (parent.lastElementChild !== root) parent.appendChild(root)
2104
+ }
2105
+ }
2106
+ apply()
2107
+ const observer = new MutationObserver(() => { if (root.isConnected) apply() })
2108
+ observer.observe(parent, { childList: true })
2109
+ if (wide) {
2110
+ parent.style.flexDirection = ''
2111
+ parent.style.flexWrap = ''
2112
+ parent.style.alignItems = ''
2113
+ parent.style.gap = ''
2114
+ } else {
2115
+ parent.style.flexDirection = 'column'
2116
+ parent.style.flexWrap = 'nowrap'
2117
+ parent.style.alignItems = 'center'
2118
+ parent.style.gap = '6px'
2119
+ }
2120
+ return () => {
2121
+ observer.disconnect()
2122
+ if (!wide) {
2123
+ parent.style.flexDirection = ''
2124
+ parent.style.flexWrap = ''
2125
+ parent.style.alignItems = ''
2126
+ parent.style.gap = ''
2127
+ }
2128
+ }
2129
+ }, [wide, state])
2130
+ if (!state) return null
2131
+ const config = state.config
2132
+ const t = makeT(resolveLocale(config?.locale))
2133
+ const showBalance = config.balance?.display === 'sidebar' || config.balance?.display === 'both'
2134
+ const showCustomBalance = config.customBalance?.enabled === true
2135
+ && (config.customBalance?.display === 'sidebar' || config.customBalance?.display === 'both')
2136
+ && state.customBalance?.status === 'ok'
2137
+ const showBalanceBar = showBalance && config.balance?.showProgressBar === true && state.balance?.status === 'ok'
2138
+ const showCustomBalanceBar = showCustomBalance && config.balance?.showProgressBar === true
2139
+ const goMainKey = config.goQuota?.main === 'weekly' || config.goQuota?.main === 'monthly' ? config.goQuota.main : 'rolling'
2140
+ const goOk = (config.goQuota?.enabled !== false)
2141
+ && (config.goQuota?.display === 'sidebar' || config.goQuota?.display === 'both')
2142
+ && state.goQuota?.status === 'ok' && state.goQuota?.[goMainKey] !== null
2143
+ const budgetOn = (config.budget ?? {}).enabled === true
2144
+ const showToday = config.sidebar !== false
2145
+ if (!showBalance && !showCustomBalance && !goOk && !budgetOn && !showToday) return null
2146
+ const nodes = []
2147
+ if (showBalanceBar) nodes.push(el(BalanceBox, { state, wide }))
2148
+ else if (showBalance) nodes.push(el(BalanceRowContent, { state, wide }))
2149
+ if (showCustomBalanceBar) nodes.push(el(CustomBalanceBox, { state, wide }))
2150
+ else if (showCustomBalance) nodes.push(el(CustomBalanceRowContent, { state, wide }))
2151
+ if (goOk && budgetOn && wide) {
2152
+ // 同时出现:合并为一张卡片(Go 在上、预算在下,细分隔线),各自保留预警色与自己的详细信息开关。
2153
+ const goView = goBoxBody(state, config, t)
2154
+ const budgetView = budgetBoxBody(state, config, t)
2155
+ const level = goView.level === 'over' || budgetView.level === 'over' ? 'over'
2156
+ : goView.level === 'warn' || budgetView.level === 'warn' ? 'warn' : 'ok'
2157
+ nodes.push(el('div', { className: 'cm-bbox cm-bbox-pair' + (level === 'ok' ? '' : ' ' + level) },
2158
+ el('div', { className: 'cm-bbox-section' + (goView.level === 'ok' ? '' : ' ' + goView.level) }, goView.body),
2159
+ el('div', { className: 'cm-bbox-divider' }),
2160
+ el('div', { className: 'cm-bbox-section' + (budgetView.level === 'ok' ? '' : ' ' + budgetView.level) }, budgetView.body)))
2161
+ } else {
2162
+ if (goOk) nodes.push(el(GoQuotaBox, { state, wide }))
2163
+ if (budgetOn) nodes.push(el(BudgetBoxContent, { state, wide }))
2164
+ }
2165
+ if (!budgetOn && showToday) nodes.push(el(BudgetBoxContent, { state, wide }))
2166
+ // 收起(rail)态:无论预算/Go 额度开关状态,统一在图框下方追加竖向峰谷进度条(受 peakNotice 等门控,内部自行返回 null)。
2167
+ if (!wide) nodes.push(peakNoticeRailEl(state, config, t))
2168
+ // 外壳的 footerActions 是横向 flex;这里用自建纵向堆叠保证余额在上、图框在下。
2169
+ return el('div', { ref: rootRef, className: 'cm-footer-stack' + (wide ? '' : ' rail') }, ...nodes)
2170
+ }
2171
+
2172
+ // ── 设置页「费用」 ──────────────────────────────────────────────────────
2173
+
2174
+ function Card(props) {
2175
+ return el('div', { className: 'cm-card' },
2176
+ el('p', { className: 'cm-card-title' }, props.title),
2177
+ el('div', { className: 'cm-card-value cm-num' }, props.value),
2178
+ el('p', { className: 'cm-card-sub' }, props.sub))
2179
+ }
2180
+
2181
+ function HistoryTable(props) {
2182
+ const { state } = props
2183
+ const t = makeT(resolveLocale(state.config?.locale))
2184
+ const rows = state.history ?? []
2185
+ if (rows.length === 0) return el('p', { className: 'cm-empty' }, t('noHistory'))
2186
+ return el('div', { className: 'cm-scroll' },
2187
+ el('table', { className: 'cm-table' },
2188
+ el('thead', null, el('tr', null,
2189
+ el('th', null, t('colDate')), el('th', { className: 'num' }, t('colCalls')),
2190
+ el('th', { className: 'num' }, t('colInTok')), el('th', { className: 'num' }, t('colCacheTok')), el('th', { className: 'num' }, t('colOutTok')),
2191
+ el('th', { className: 'num' }, t('colCost')))),
2192
+ el('tbody', null, rows.map(day => el('tr', { key: day.date },
2193
+ el('td', null, day.date),
2194
+ el('td', { className: 'num' }, String(day.calls)),
2195
+ el('td', { className: 'num' }, formatTokens(day.input)),
2196
+ el('td', { className: 'num' }, formatTokens(day.cacheRead + day.cacheWrite)),
2197
+ el('td', { className: 'num' }, formatTokens(day.output)),
2198
+ el('td', { className: 'num' }, formatMoneyUsd(day.cost, state.config)))))))
2199
+ }
2200
+
2201
+ function TodaySessions(props) {
2202
+ const { state } = props
2203
+ const t = makeT(resolveLocale(state.config?.locale))
2204
+ const sessions = state.today.sessions ?? []
2205
+ if (sessions.length === 0) return el('p', { className: 'cm-empty' }, t('noSessionsToday'))
2206
+ return el('div', { className: 'cm-scroll' },
2207
+ el('table', { className: 'cm-table' },
2208
+ el('thead', null, el('tr', null,
2209
+ el('th', null, t('colSession')), el('th', { className: 'num' }, t('colCalls')),
2210
+ el('th', { className: 'num' }, t('colInTok')), el('th', { className: 'num' }, t('colCacheTok')), el('th', { className: 'num' }, t('colOutTok')),
2211
+ el('th', { className: 'num' }, t('colCost')))),
2212
+ el('tbody', null, sessions.map(session => el('tr', { key: session.id },
2213
+ el('td', null, session.id.slice(0, 14) + '…'),
2214
+ el('td', { className: 'num' }, String(session.calls)),
2215
+ el('td', { className: 'num' }, formatTokens(session.input)),
2216
+ el('td', { className: 'num' }, formatTokens(session.cacheRead + session.cacheWrite)),
2217
+ el('td', { className: 'num' }, formatTokens(session.output)),
2218
+ el('td', { className: 'num' }, formatMoneyUsd(session.cost, state.config)))))))
2219
+ }
2220
+
2221
+ const CURRENCY_PRESETS = {
2222
+ CNY: { symbol: '¥', decimals: 4, exchangeRate: 7.2 },
2223
+ USD: { symbol: '$', decimals: 6, exchangeRate: 1 },
2224
+ EUR: { symbol: '€', decimals: 6, exchangeRate: 0.92 },
2225
+ }
2226
+
2227
+ // ── 预算面板(设置页顶部) ──────────────────────────────────────────────
2228
+
2229
+ function BudgetPanel(props) {
2230
+ const { state, draft, setDraft, t } = props
2231
+ const config = state.config
2232
+ const budget = draft?.budget ?? config.budget
2233
+ const rate = Number(config.exchangeRate)
2234
+ // 已用金额优先用宿主按周期聚合的 budgetUsed(支持自定义区间);缺失时回退客户端计算。
2235
+ const periodCost = state.budgetUsed ?? (
2236
+ budget.period === 'day' ? state.today.cost
2237
+ : budget.period === 'all' ? state.total.cost
2238
+ : state.month.cost)
2239
+ const used = periodCost * (Number.isFinite(rate) && rate > 0 ? rate : 1)
2240
+ const amount = Math.max(0, Number(budget.amount) || 0)
2241
+ const pct = budget.enabled && amount > 0 ? Math.min(999, used / amount * 100) : null
2242
+ const level = pct === null ? 'ok' : pct >= 100 ? 'over' : pct >= 80 ? 'warn' : 'ok'
2243
+ const setBudget = (field, value) => {
2244
+ if (draft === null) return
2245
+ setDraft({ ...draft, budget: { ...(draft.budget ?? config.budget), [field]: value } })
2246
+ }
2247
+ const rangeText = budget.period === 'custom'
2248
+ ? budget.customStart + ' → ' + (budget.customEnd ?? t('periodDay'))
2249
+ : null
2250
+ const statusLine = budget.enabled && pct !== null
2251
+ ? t('budgetStatus', {
2252
+ period: t(PERIOD_KEYS[budget.period] ?? 'periodMonth'),
2253
+ amount: formatMoneyValue(amount, config),
2254
+ used: formatMoneyValue(used, config),
2255
+ pct: pct.toFixed(1),
2256
+ })
2257
+ + (level === 'over' ? t('overLimit') : level === 'warn' ? t('nearLimit') : '')
2258
+ : null
2259
+ return el('div', { className: 'cm-budget' },
2260
+ el('div', { className: 'cm-budget-head' },
2261
+ el('h3', { className: 'cm-h' }, t('budget')),
2262
+ el('label', { className: 'cm-check' },
2263
+ el('input', {
2264
+ type: 'checkbox',
2265
+ checked: budget.enabled === true,
2266
+ onChange: event => setBudget('enabled', event.target.checked),
2267
+ }),
2268
+ el('span', null, t('enableBudget')))),
2269
+ budget.enabled
2270
+ ? el(Fragment, null,
2271
+ el('div', { className: 'cm-budget-bar' },
2272
+ el('div', {
2273
+ className: 'cm-budget-fill ' + (level === 'ok' ? '' : level),
2274
+ style: { width: (pct === null ? 0 : Math.min(100, pct)) + '%' },
2275
+ })),
2276
+ el('div', { className: 'cm-budget-line' + (level === 'over' ? ' over' : '') + ' cm-num' }, statusLine),
2277
+ el('div', { className: 'cm-budget-controls' },
2278
+ el('div', { className: 'cm-field' },
2279
+ el('label', null, t('budgetAmountLabel')),
2280
+ numInput({ value: budget.amount }, v => setBudget('amount', v))),
2281
+ el('div', { className: 'cm-field' },
2282
+ el('label', null, t('budgetPeriodLabel')),
2283
+ el('select', {
2284
+ className: 'cm-input',
2285
+ value: budget.period,
2286
+ onChange: event => setBudget('period', event.target.value),
2287
+ },
2288
+ el('option', { value: 'day' }, t('periodDay')),
2289
+ el('option', { value: 'month' }, t('periodMonth')),
2290
+ el('option', { value: 'all' }, t('periodAll')),
2291
+ el('option', { value: 'custom' }, t('periodCustomRange')))),
2292
+ budget.period === 'custom'
2293
+ ? el(Fragment, null,
2294
+ el('div', { className: 'cm-field' },
2295
+ el('label', null, t('startDate')),
2296
+ el('input', {
2297
+ className: 'cm-input', type: 'date',
2298
+ value: budget.customStart ?? '',
2299
+ onChange: event => setBudget('customStart', event.target.value === '' ? null : event.target.value),
2300
+ })),
2301
+ el('div', { className: 'cm-field' },
2302
+ el('label', null, t('endDate')),
2303
+ el('input', {
2304
+ className: 'cm-input', type: 'date',
2305
+ value: budget.customEnd ?? '',
2306
+ onChange: event => setBudget('customEnd', event.target.value === '' ? null : event.target.value),
2307
+ })))
2308
+ : null),
2309
+ rangeText !== null
2310
+ ? el('p', { className: 'cm-hint' }, t('rangeText', { range: rangeText }))
2311
+ : null)
2312
+ : el('p', { className: 'cm-note' }, t('budgetDisabledNote')))
2313
+ }
2314
+
2315
+ // ── 峰谷面板(独立于预算:启用开关、提示开关、样式切换、时段条预览与窗口状态) ──
2316
+
2317
+ function PeakPanel(props) {
2318
+ const { state, draft, setDraft, t } = props
2319
+ const config = state.config
2320
+ const setField = (field, value) => {
2321
+ if (draft === null) return
2322
+ setDraft({ ...draft, [field]: value })
2323
+ }
2324
+ // 预览与状态行用草稿值,切换开关/样式即时可见效果。
2325
+ const previewConfig = draft === null ? config : { ...config, ...draft }
2326
+ const peakStatusText = (() => {
2327
+ if (previewConfig.peakEnabled !== true) return t('peakOff')
2328
+ const eff = Date.parse(previewConfig.peakEffectiveAt || '')
2329
+ const now = Date.now()
2330
+ if (Number.isFinite(eff) && now < eff) {
2331
+ return t('peakNotEffective', { time: new Date(eff).toLocaleString() })
2332
+ }
2333
+ const windows = previewConfig.peakWindows ?? []
2334
+ const hour = new Date(now).getUTCHours()
2335
+ const inPeak = windows.some(w => {
2336
+ const start = Number(w.start)
2337
+ const end = Number(w.end)
2338
+ return Number.isFinite(start) && Number.isFinite(end)
2339
+ ? (start < end ? hour >= start && hour < end : hour >= start || hour < end)
2340
+ : false
2341
+ })
2342
+ return inPeak ? t('peakActive') : t('offPeakActive')
2343
+ })()
2344
+ const peakText = (previewConfig.peakWindows?.length ?? 0) > 0
2345
+ ? t('peakSummary', {
2346
+ windows: previewConfig.peakWindows.map(w => w.start + ':00-' + w.end + ':00').join(resolveLocale(previewConfig.locale) === 'zh' ? '、' : ', '),
2347
+ time: previewConfig.peakEffectiveAt || t('unknown'),
2348
+ status: peakStatusText,
2349
+ })
2350
+ : t('noPeakWindows', { status: peakStatusText })
2351
+ return el('div', { className: 'cm-budget' },
2352
+ el('div', { className: 'cm-budget-head' },
2353
+ el('h3', { className: 'cm-h' }, t('peakPanelTitle')),
2354
+ el('label', { className: 'cm-check' },
2355
+ el('input', {
2356
+ type: 'checkbox',
2357
+ checked: draft?.peakEnabled !== false,
2358
+ onChange: event => setField('peakEnabled', event.target.checked),
2359
+ }),
2360
+ el('span', null, t('peakEnabledLabel')))),
2361
+ el('div', { className: 'cm-grid' },
2362
+ el('div', { className: 'cm-field' },
2363
+ el('label', { className: 'cm-check' },
2364
+ el('input', {
2365
+ type: 'checkbox',
2366
+ checked: draft?.peakNotice !== false,
2367
+ onChange: event => setField('peakNotice', event.target.checked),
2368
+ }),
2369
+ el('span', null, t('peakNoticeLabel')))),
2370
+ el('div', { className: 'cm-field' },
2371
+ el('label', null, t('peakStyleLabel')),
2372
+ el('select', {
2373
+ className: 'cm-input',
2374
+ value: draft?.peakStyle === 'classic' ? 'classic' : 'compact',
2375
+ onChange: event => setField('peakStyle', event.target.value),
2376
+ },
2377
+ el('option', { value: 'compact' }, t('peakStyleCompact')),
2378
+ el('option', { value: 'classic' }, t('peakStyleClassic'))))),
2379
+ el('div', { className: 'cm-peak-preview' },
2380
+ draft?.peakEnabled !== false && draft?.peakNotice !== false
2381
+ ? peakNoticeEl(state, previewConfig, t)
2382
+ : el('p', { className: 'cm-hint' }, t('peakNoticeHiddenHint'))),
2383
+ el('p', { className: 'cm-hint' }, peakText))
2384
+ }
2385
+
2386
+ function numInput(props, onChange) {
2387
+ const value = props.value
2388
+ return el('input', {
2389
+ className: 'cm-input narrow',
2390
+ type: 'number', step: 'any', min: '0',
2391
+ value: typeof value === 'number' ? String(value) : '',
2392
+ onChange: event => {
2393
+ const text = event.target.value
2394
+ if (text === '') { onChange(0); return }
2395
+ const parsed = Number(text)
2396
+ if (Number.isFinite(parsed)) onChange(parsed)
2397
+ },
2398
+ })
2399
+ }
2400
+
2401
+ function PriceCard(props) {
2402
+ const { modelId, entry, isDefault, draft, setDraft, t } = props
2403
+ const setTier = (tierKey, field, value) => {
2404
+ const nextField = Math.max(0, value)
2405
+ if (isDefault) {
2406
+ const def = draft.prices.default ?? { cacheHit: 0, cacheMiss: 0, output: 0 }
2407
+ let next = { ...def }
2408
+ if (tierKey === 'base') next[field] = nextField
2409
+ else {
2410
+ const tier = { ...(next[tierKey] ?? {}), [field]: nextField }
2411
+ next = { ...next, [tierKey]: tier }
2412
+ }
2413
+ setDraft({ ...draft, prices: { ...draft.prices, default: next } })
2414
+ return
2415
+ }
2416
+ const models = { ...draft.prices.models }
2417
+ const current = models[modelId] ?? { cacheHit: 0, cacheMiss: 0, output: 0 }
2418
+ let next = { ...current }
2419
+ if (tierKey === 'base') next[field] = nextField
2420
+ else {
2421
+ const tier = { ...(current[tierKey] ?? {}), [field]: nextField }
2422
+ next = { ...current, [tierKey]: tier }
2423
+ }
2424
+ models[modelId] = next
2425
+ setDraft({ ...draft, prices: { ...draft.prices, models } })
2426
+ }
2427
+ const remove = () => {
2428
+ const models = { ...draft.prices.models }
2429
+ delete models[modelId]
2430
+ setDraft({ ...draft, prices: { ...draft.prices, models } })
2431
+ }
2432
+ const tierRow = (label, tierKey) => {
2433
+ const tier = tierKey === 'base' ? entry : entry[tierKey] ?? null
2434
+ return el('div', { className: 'cm-price-row', key: tierKey },
2435
+ el('span', null, label),
2436
+ numInput({ value: tier?.cacheHit ?? null }, v => setTier(tierKey, 'cacheHit', v)),
2437
+ numInput({ value: tier?.cacheMiss ?? null }, v => setTier(tierKey, 'cacheMiss', v)),
2438
+ numInput({ value: tier?.output ?? null }, v => setTier(tierKey, 'output', v)))
2439
+ }
2440
+ return el('div', { className: 'cm-price-card' },
2441
+ el('div', { className: 'cm-price-head' },
2442
+ el('span', { className: 'cm-price-name' }, modelId),
2443
+ el(Fragment, null,
2444
+ entry?.legacy === true ? el('span', { className: 'cm-price-legacy' }, t('legacyModel')) : null,
2445
+ isDefault ? el('span', { className: 'cm-price-legacy' }, t('defaultFallback')) : null,
2446
+ isDefault ? null : el('button', { className: 'cm-btn small danger', onClick: remove }, t('remove')))),
2447
+ tierRow(t('tierBase'), 'base'),
2448
+ tierRow(t('tierOffPeak'), 'offPeak'),
2449
+ tierRow(t('tierPeak'), 'peak'))
2450
+ }
2451
+
2452
+ // ── 拓展价格表面板(厂商/家族分类目录 + 挂载/取消挂载) ─────────────
2453
+
2454
+ const CATALOG_VENDOR_LABELS = {
2455
+ deepseek: 'DeepSeek', openai: 'OpenAI', anthropic: 'Anthropic', google: 'Google Gemini',
2456
+ moonshot: 'Moonshot (Kimi)', 'z-ai': 'Z.ai / 智谱', xai: 'xAI', alibaba: '阿里云百炼',
2457
+ minimax: 'MiniMax', tencent: '腾讯混元', xiaomi: '小米', upstage: 'Upstage', nvidia: 'NVIDIA', mistral: 'Mistral', 'opencode-go': 'OpenCode Go',
2458
+ }
2459
+
2460
+ /** 目录条目价格摘要(美元;峰谷两档写 谷/峰)。 */
2461
+ function catalogPriceText(entry, t) {
2462
+ if (entry === null || typeof entry !== 'object') return ''
2463
+ if (entry.unpriced === true) return t('catalogUnpriced')
2464
+ const usd = n => '$' + String(n)
2465
+ const pk = entry.peak !== null && typeof entry.peak === 'object' ? entry.peak : null
2466
+ if (pk !== null) return usd(entry.cacheMiss) + '/' + usd(pk.cacheMiss) + ' in · ' + usd(entry.output) + '/' + usd(pk.output) + ' out'
2467
+ return usd(entry.cacheMiss ?? entry.input ?? 0) + ' in · ' + usd(entry.output ?? 0) + ' out'
2468
+ }
2469
+
2470
+ function PriceCatalogPanel(props) {
2471
+ const { state, draft, setDraft, t } = props
2472
+ const [open, setOpen] = useState(false)
2473
+ // 厂商默认全部折叠;点开某厂商后仅展开该厂商。
2474
+ const [openVendors, setOpenVendors] = useState({})
2475
+ const catalog = state.priceCatalog
2476
+ if (catalog === null || typeof catalog !== 'object') return null
2477
+ const prices = draft?.prices ?? state.config.prices
2478
+ // 「在费用设置直接显示」开关(按模型):仅决定价格卡是否在费用设置「价格表」区直接显示,
2479
+ // 不影响挂载与计费;不直接显示的模型其价格卡在本面板内可编辑。
2480
+ const displayMap = draft?.priceTableDisplay ?? state.config.priceTableDisplay ?? {}
2481
+ // 「在费用设置直接显示」精确到单个模型:键 'provider:modelId',值显式布尔;
2482
+ // 缺省 = 默认策略(DeepSeek 模型直接显示,第三方收入拓展表)。只决定展示位置,不影响挂载与计费。
2483
+ const isDirect = (provider, modelId) => {
2484
+ const value = displayMap[provider + ':' + modelId]
2485
+ return typeof value === 'boolean' ? value : provider === 'deepseek'
2486
+ }
2487
+ const setDirect = (provider, modelId, value) => {
2488
+ if (draft === null) return
2489
+ setDraft({ ...draft, priceTableDisplay: { ...displayMap, [provider + ':' + modelId]: value } })
2490
+ }
2491
+ const isMounted = (provider, modelId) => provider === 'deepseek'
2492
+ ? prices.models?.[modelId] !== undefined
2493
+ : prices.providers?.[provider]?.models?.[modelId] !== undefined
2494
+ const mount = (provider, modelId, entry) => {
2495
+ if (draft === null) return
2496
+ const copy = JSON.parse(JSON.stringify(entry))
2497
+ if (provider === 'deepseek') {
2498
+ setDraft({ ...draft, prices: { ...draft.prices, models: { ...draft.prices.models, [modelId]: copy } } })
2499
+ return
2500
+ }
2501
+ const providers = { ...(draft.prices.providers ?? {}) }
2502
+ const table = providers[provider] ?? {}
2503
+ providers[provider] = { ...table, models: { ...(table.models ?? {}), [modelId]: copy } }
2504
+ setDraft({ ...draft, prices: { ...draft.prices, providers } })
2505
+ }
2506
+ const unmount = (provider, modelId) => {
2507
+ if (draft === null) return
2508
+ if (provider === 'deepseek') {
2509
+ const models = { ...draft.prices.models }
2510
+ delete models[modelId]
2511
+ setDraft({ ...draft, prices: { ...draft.prices, models } })
2512
+ return
2513
+ }
2514
+ const providers = { ...(draft.prices.providers ?? {}) }
2515
+ const table = providers[provider]
2516
+ if (table !== undefined && table.models !== undefined) {
2517
+ const models = { ...table.models }
2518
+ delete models[modelId]
2519
+ providers[provider] = { ...table, models }
2520
+ setDraft({ ...draft, prices: { ...draft.prices, providers } })
2521
+ }
2522
+ }
2523
+ // 厂商顺序:DeepSeek 居首,其余按字母序;每家默认折叠,标题可点开。
2524
+ const providerIds = Object.keys(catalog).sort((a, b) => (a === 'deepseek' ? -1 : b === 'deepseek' ? 1 : a.localeCompare(b)))
2525
+ const countModels = provider => Object.values(catalog[provider]).reduce((n, fam) => n + Object.keys(fam).length, 0)
2526
+ // DeepSeek 目录模型集合:目录之外手动新增的已挂载模型也要能切换直接显示/编辑。
2527
+ const dsCatalogIds = new Set(Object.values(catalog.deepseek ?? {}).flatMap(fam => Object.keys(fam)))
2528
+ const dsExtraMounted = Object.keys(prices.models ?? {}).filter(id => !dsCatalogIds.has(id)).sort()
2529
+ // 单个模型行:已挂载且未直接显示 → 目录内可编辑卡片(带切回直接显示的开关);
2530
+ // 其余 → 只读行(已挂载的带直接显示开关与挂载/取消挂载按钮)。
2531
+ const renderModel = (provider, modelId, entry) => {
2532
+ const mounted = isMounted(provider, modelId)
2533
+ const direct = isDirect(provider, modelId)
2534
+ const directToggle = mounted
2535
+ ? el('label', { className: 'cm-check cm-vendor-display', title: t('catalogDisplayHint') },
2536
+ el('input', {
2537
+ type: 'checkbox',
2538
+ checked: direct,
2539
+ onChange: event => setDirect(provider, modelId, event.target.checked),
2540
+ }),
2541
+ el('span', null, t('catalogDisplayLabel')))
2542
+ : null
2543
+ if (mounted && !direct && draft !== null) {
2544
+ const card = provider === 'deepseek'
2545
+ ? el(PriceCard, { key: modelId, modelId, entry: prices.models[modelId], isDefault: false, draft, setDraft, t })
2546
+ : el(ProviderPriceCard, { key: modelId, provider, modelId, entry: prices.providers?.[provider]?.models?.[modelId], draft, setDraft, t })
2547
+ return el('div', { key: modelId, className: 'cm-catalog-mounted' }, directToggle, card)
2548
+ }
2549
+ return el('div', { key: modelId, className: 'cm-catalog-row' },
2550
+ el('span', { className: 'cm-catalog-id' }, modelId),
2551
+ el('span', { className: 'cm-catalog-price' }, catalogPriceText(entry, t)),
2552
+ mounted ? el('span', { className: 'cm-catalog-tag' }, t('mountedTag')) : null,
2553
+ directToggle,
2554
+ el('button', { className: 'cm-btn small', disabled: !mounted && entry?.unpriced === true, onClick: () => (mounted ? unmount(provider, modelId) : mount(provider, modelId, entry)) },
2555
+ mounted ? t('unmountBtn') : t('mountBtn')))
2556
+ }
2557
+ return el('div', { className: 'cm-budget', style: { marginTop: '8px' } },
2558
+ el('div', { className: 'cm-budget-head' },
2559
+ el('h3', { className: 'cm-h' }, t('catalogTitle')),
2560
+ el('button', { className: 'cm-toggle-btn', onClick: () => setOpen(o => !o) }, open ? t('catalogCollapse') : t('catalogOpen'))),
2561
+ open
2562
+ ? el(Fragment, null,
2563
+ el('p', { className: 'cm-note' }, t('catalogNote')),
2564
+ providerIds.map(provider => {
2565
+ const vendorOpen = openVendors[provider] === true
2566
+ return el('div', { key: provider },
2567
+ el('div', {
2568
+ className: 'cm-catalog-vendor cm-vendor-toggle',
2569
+ onClick: () => setOpenVendors(v => ({ ...v, [provider]: !vendorOpen })),
2570
+ },
2571
+ el('span', null, (vendorOpen ? '▾ ' : '▸ ') + (CATALOG_VENDOR_LABELS[provider] ?? provider) + ' · ' + countModels(provider))),
2572
+ vendorOpen
2573
+ ? el(Fragment, null,
2574
+ provider === 'deepseek' ? el('p', { className: 'cm-hint' }, t('catalogDeepseekNote')) : null,
2575
+ Object.keys(catalog[provider]).sort().map(family =>
2576
+ el('div', { key: family },
2577
+ el('div', { className: 'cm-catalog-family' }, family),
2578
+ Object.keys(catalog[provider][family]).sort().map(modelId =>
2579
+ renderModel(provider, modelId, catalog[provider][family][modelId])))),
2580
+ provider === 'deepseek' && dsExtraMounted.length > 0
2581
+ ? el('div', null,
2582
+ el('div', { className: 'cm-catalog-family' }, t('catalogCustomModels')),
2583
+ dsExtraMounted.map(id => renderModel('deepseek', id, null)))
2584
+ : null)
2585
+ : null)
2586
+ }))
2587
+ : null)
2588
+ }
2589
+
2590
+ /** provider 展示名归一:历史请求携带的 'zen' 是错误叫法,统一展示为 'go'。 */
2591
+ const prettyProvider = provider => (provider === 'zen' ? 'go' : provider)
2592
+ const prettyProviderKey = key => {
2593
+ const sep = key.indexOf(':')
2594
+ if (sep <= 0) return key
2595
+ return prettyProvider(key.slice(0, sep).toLowerCase()) + key.slice(sep)
2596
+ }
2597
+
2598
+ /** 按模型统计面板:今日/近90天两个口径,费用排行、Token 消耗(堆叠)、缓存命中率、性价比。
2599
+ * 纯前端聚合:state.today.byProviderModel 与 state.history[].byProviderModel(宿主已逐次计费)。
2600
+ * 口径:命中率 = 缓存读/(缓存读+非缓存输入);综合单价 = 费用/总token×1M;性价比 = 总token/费用。 */
2601
+ function ModelStatsPanel(props) {
2602
+ const { state, config, t, initialTab } = props
2603
+ const [tab, setTab] = useState(initialTab === 'history' ? 'history' : 'today')
2604
+ // 默认收起,保持设置页简洁;需要时点三角展开。
2605
+ const [open, setOpen] = useState(false)
2606
+ // 旧账本兼容:优先 byProviderModel(provider:model 键);旧格式回退 byModel(纯模型名键);
2607
+ // 两者皆缺时用会话明细按会话 provider/model 近似重建;再兑底为未分模型合计行。
2608
+ const modelMapOf = src => {
2609
+ if (src === null || typeof src !== 'object') return {}
2610
+ if (src.byProviderModel && Object.keys(src.byProviderModel).length > 0) return src.byProviderModel
2611
+ if (src.byModel && Object.keys(src.byModel).length > 0) return src.byModel
2612
+ const rebuilt = {}
2613
+ if (Array.isArray(src.sessions)) {
2614
+ for (const s of src.sessions) {
2615
+ if (s === null || typeof s !== 'object') continue
2616
+ const provider = typeof s.provider === 'string' && s.provider.length > 0 ? s.provider : 'deepseek'
2617
+ const model = typeof s.model === 'string' && s.model.length > 0 ? s.model : 'unknown'
2618
+ const key = provider + ':' + model
2619
+ const row = rebuilt[key] ?? (rebuilt[key] = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: 0 })
2620
+ const num = x => (typeof x === 'number' && Number.isFinite(x) && x >= 0 ? x : 0)
2621
+ row.input += num(s.input); row.output += num(s.output)
2622
+ row.cacheRead += num(s.cacheRead); row.cacheWrite += num(s.cacheWrite)
2623
+ row.reasoning += num(s.reasoning); row.cost += num(s.cost)
2624
+ }
2625
+ }
2626
+ if (Object.keys(rebuilt).length > 0) return rebuilt
2627
+ if ((Number(src.cost) > 0 || Number(src.input) > 0 || Number(src.output) > 0)) {
2628
+ // 更旧版本连模型明细都没有:合计作为未分模型行,保证费用/用量可见。
2629
+ const num = x => (typeof x === 'number' && Number.isFinite(x) && x >= 0 ? x : 0)
2630
+ return { 'deepseek:legacy': { input: num(src.input), output: num(src.output), cacheRead: num(src.cacheRead), cacheWrite: num(src.cacheWrite), reasoning: num(src.reasoning), cost: num(src.cost) } }
2631
+ }
2632
+ return {}
2633
+ }
2634
+ const aggregate = source => {
2635
+ const out = {}
2636
+ const add = map => {
2637
+ for (const key of Object.keys(map ?? {})) {
2638
+ const b = map[key]
2639
+ if (b === null || typeof b !== 'object') continue
2640
+ const row = out[key] ?? (out[key] = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: 0 })
2641
+ row.input += Number(b.input) || 0
2642
+ row.output += Number(b.output) || 0
2643
+ row.cacheRead += Number(b.cacheRead) || 0
2644
+ row.cacheWrite += Number(b.cacheWrite) || 0
2645
+ row.reasoning += Number(b.reasoning) || 0
2646
+ row.cost += Number(b.cost) || 0
2647
+ }
2648
+ }
2649
+ if (source === 'today') add(modelMapOf(state.today))
2650
+ else for (const day of state.history ?? []) add(modelMapOf(day))
2651
+ return Object.entries(out).map(([key, b]) => {
2652
+ const sep = key.indexOf(':')
2653
+ const provider = (sep > 0 ? key.slice(0, sep) : 'deepseek').toLowerCase()
2654
+ const model = sep > 0 ? key.slice(sep + 1) : key
2655
+ const tokens = b.input + b.output + b.cacheRead + b.cacheWrite + b.reasoning
2656
+ const hitDen = b.cacheRead + b.input
2657
+ return {
2658
+ label: key === 'deepseek:legacy' ? t('modelStatsLegacy')
2659
+ : (provider === 'deepseek' || provider === '' ? model : prettyProvider(provider) + ':' + model),
2660
+ ...b, tokens,
2661
+ hitRate: hitDen > 0 ? b.cacheRead / hitDen : null,
2662
+ blended: tokens > 0 && b.cost > 0 ? b.cost / tokens * 1e6 : null,
2663
+ perUsd: b.cost > 0 ? tokens / b.cost : null,
2664
+ }
2665
+ }).filter(r => r.tokens > 0 || r.cost > 0)
2666
+ }
2667
+ const rows = aggregate(tab).sort((a, b) => b.cost - a.cost || b.tokens - a.tokens)
2668
+ const pct = v => (Math.max(0, Math.min(1, v)) * 100).toFixed(1) + '%'
2669
+ const maxCost = rows.reduce((m, r) => Math.max(m, r.cost), 0)
2670
+ const maxTokens = rows.reduce((m, r) => Math.max(m, r.tokens), 0)
2671
+ const maxPerUsd = rows.reduce((m, r) => Math.max(m, r.perUsd ?? 0), 0)
2672
+ const tabBtn = (key, label) => el('button', {
2673
+ className: 'cm-mstats-tab' + (tab === key ? ' active' : ''),
2674
+ onClick: () => setTab(key),
2675
+ }, label)
2676
+ const barRow = (name, frac, barClass, valueText) => el('div', { className: 'cm-mstats-row' },
2677
+ el('span', { className: 'cm-mstats-name' }, name),
2678
+ el('div', { className: 'cm-mstats-barbg' },
2679
+ el('div', { className: 'cm-mstats-bar ' + barClass, style: { width: pct(frac) } })),
2680
+ el('span', { className: 'cm-mstats-val' }, valueText))
2681
+ return el('div', { className: 'cm-budget' },
2682
+ el('div', { className: 'cm-budget-head' },
2683
+ el('button', { type: 'button', className: 'cm-collapse-h', 'aria-expanded': String(open), onClick: () => setOpen(!open) },
2684
+ el('span', { className: 'cm-caret' + (open ? ' open' : '') }),
2685
+ el('h3', { className: 'cm-h' }, t('modelStatsTitle')))),
2686
+ open ? el('div', { className: 'cm-collapse-body' },
2687
+ el('div', { className: 'cm-mstats-tabs' },
2688
+ tabBtn('today', t('modelStatsToday')),
2689
+ tabBtn('history', t('modelStatsHistory'))),
2690
+ rows.length === 0
2691
+ ? el('p', { className: 'cm-note' }, t('modelStatsEmpty'))
2692
+ : el(Fragment, null,
2693
+ // 1) 费用排行(降序,橙色条)。
2694
+ el('div', { className: 'cm-mstats-h' }, t('modelStatsCostH')),
2695
+ rows.map(r => el(Fragment, { key: 'c:' + r.label },
2696
+ barRow(r.label, maxCost > 0 ? r.cost / maxCost : 0, 'cost', formatMoneyUsd(r.cost, config)))),
2697
+ // 2) Token 消耗(堆叠:输入/缓存/输出)。
2698
+ el('div', { className: 'cm-mstats-h' }, t('modelStatsTokensH')),
2699
+ el('div', { className: 'cm-mstats-legend' },
2700
+ el('span', null, el('span', { className: 'cm-mstats-dot', style: { background: 'var(--dsw-alias-state-business-primary)' } }), t('modelStatsInput')),
2701
+ el('span', null, el('span', { className: 'cm-mstats-dot', style: { background: '#ff9800' } }), t('modelStatsCache')),
2702
+ el('span', null, el('span', { className: 'cm-mstats-dot', style: { background: '#34a853' } }), t('modelStatsOutput'))),
2703
+ [...rows].sort((a, b) => b.tokens - a.tokens).map(r => el('div', { className: 'cm-mstats-row', key: 't:' + r.label },
2704
+ el('span', { className: 'cm-mstats-name' }, r.label),
2705
+ el('div', { className: 'cm-mstats-barbg' },
2706
+ el('div', { className: 'cm-mstats-seg in', style: { width: pct(maxTokens > 0 ? r.input / maxTokens : 0) } }),
2707
+ el('div', { className: 'cm-mstats-seg cache', style: { width: pct(maxTokens > 0 ? (r.cacheRead + r.cacheWrite) / maxTokens : 0) } }),
2708
+ el('div', { className: 'cm-mstats-seg out', style: { width: pct(maxTokens > 0 ? (r.output + r.reasoning) / maxTokens : 0) } })),
2709
+ el('span', { className: 'cm-mstats-val' }, formatTokens(r.tokens)))),
2710
+ // 3) 缓存命中率(绿条;无缓存流量的模型显示—)。
2711
+ el('div', { className: 'cm-mstats-h' }, t('modelStatsHitH')),
2712
+ [...rows].sort((a, b) => (b.hitRate ?? -1) - (a.hitRate ?? -1)).map(r => el(Fragment, { key: 'h:' + r.label },
2713
+ barRow(r.label, r.hitRate ?? 0, 'hit', r.hitRate === null ? '—' : (r.hitRate * 100).toFixed(1) + '%'))),
2714
+ // 4) 性价比:每美元 token 数(紫条),右侧附综合单价。
2715
+ el('div', { className: 'cm-mstats-h' }, t('modelStatsValueH')),
2716
+ [...rows].sort((a, b) => (b.perUsd ?? -1) - (a.perUsd ?? -1)).map(r => el(Fragment, { key: 'v:' + r.label },
2717
+ barRow(r.label, maxPerUsd > 0 ? (r.perUsd ?? 0) / maxPerUsd : 0, 'value',
2718
+ r.perUsd === null ? '—' : formatTokens(r.perUsd) + ' tok/$' + (r.blended !== null ? ' · ' + t('modelStatsBlended', { price: '$' + r.blended.toFixed(2) }) : '')))),
2719
+ el('p', { className: 'cm-mstats-note' }, t('modelStatsNote'))))
2720
+ : null)
2721
+ }
2722
+
2723
+ /** 已挂载的第三方模型价格卡(与 DeepSeek 卡片同区展示,可编辑/取消挂载)。 */
2724
+ function ProviderPriceCard(props) {
2725
+ const { provider, modelId, entry, draft, setDraft, t } = props
2726
+ const writeModels = models => {
2727
+ const providers = { ...(draft.prices.providers ?? {}) }
2728
+ const table = providers[provider] ?? {}
2729
+ providers[provider] = { ...table, models }
2730
+ setDraft({ ...draft, prices: { ...draft.prices, providers } })
2731
+ }
2732
+ const setNum = (field, value) => {
2733
+ if (draft === null) return
2734
+ const models = { ...((draft.prices.providers ?? {})[provider]?.models ?? {}) }
2735
+ models[modelId] = { ...(models[modelId] ?? {}), [field]: Math.max(0, value) }
2736
+ writeModels(models)
2737
+ }
2738
+ const remove = () => {
2739
+ if (draft === null) return
2740
+ const models = { ...((draft.prices.providers ?? {})[provider]?.models ?? {}) }
2741
+ delete models[modelId]
2742
+ writeModels(models)
2743
+ }
2744
+ return el('div', { className: 'cm-price-card' },
2745
+ el('div', { className: 'cm-price-head' },
2746
+ el('span', { className: 'cm-price-name' }, modelId),
2747
+ el(Fragment, null,
2748
+ entry?.unpriced === true ? el('span', { className: 'cm-price-legacy' }, t('catalogUnpriced')) : null,
2749
+ el('button', { className: 'cm-btn small danger', onClick: remove }, t('unmountBtn')))),
2750
+ entry?.unpriced === true
2751
+ ? null
2752
+ : el(Fragment, null,
2753
+ el('div', { className: 'cm-price-row' },
2754
+ el('span', null, ''),
2755
+ el('span', null, t('flatInput')), el('span', null, t('flatCached')), el('span', null, t('flatOutput'))),
2756
+ el('div', { className: 'cm-price-row' },
2757
+ el('span', null, 'USD'),
2758
+ numInput({ value: entry?.input ?? null }, v => setNum('input', v)),
2759
+ numInput({ value: entry?.cachedInput ?? null }, v => setNum('cachedInput', v)),
2760
+ numInput({ value: entry?.output ?? null }, v => setNum('output', v)))))
2761
+ }
2762
+
2763
+ // ── 余额面板(设置页,按 balance.display 配置挂载) ────────────────────────
2764
+
2765
+ function BalancePanel(props) {
2766
+ const { state, api, t, draft, setDraft } = props
2767
+ const [busy, setBusy] = useState(false)
2768
+ const [msg, setMsg] = useState(null)
2769
+ const balance = state.balance
2770
+ const config = state.config
2771
+ // 余额差对账(issue #18):drift 时在面板内展示警告行,开关随草稿保存。
2772
+ const reconcile = state.reconcile
2773
+ const reconcileOn = (draft?.balance ?? config.balance ?? {}).reconcile !== false
2774
+ const toggleReconcile = event => {
2775
+ if (draft === null || typeof setDraft !== 'function') return
2776
+ setDraft({ ...draft, balance: { ...(draft.balance ?? config.balance ?? {}), reconcile: event.target.checked } })
2777
+ }
2778
+ const doRefresh = async () => {
2779
+ if (busy) return
2780
+ setBusy(true)
2781
+ setMsg(null)
2782
+ try {
2783
+ const result = await api.refreshBalance()
2784
+ setMsg({ kind: result.ok ? 'ok' : 'err', text: result.message })
2785
+ } catch (error) {
2786
+ setMsg({ kind: 'err', text: t('balanceRefreshFailed', { message: error?.message ?? String(error) }) })
2787
+ } finally {
2788
+ setBusy(false)
2789
+ }
2790
+ }
2791
+ const money = value => formatBalanceMoney(value, config)
2792
+ const body = balance.status === 'ok'
2793
+ ? el('div', { className: 'cm-bal-line' },
2794
+ el('span', null, t('balanceLine', {
2795
+ total: money(balance.totalBalance),
2796
+ granted: money(balance.grantedBalance),
2797
+ toppedUp: money(balance.toppedUpBalance),
2798
+ time: balance.fetchedAt > 0 ? new Date(balance.fetchedAt).toLocaleTimeString() : '—',
2799
+ })))
2800
+ : balance.status === 'error'
2801
+ ? el('div', { className: 'cm-bal-line err' }, t('balanceQueryFailedHint', { message: balance.message || t('unknownError') }))
2802
+ : el('div', { className: 'cm-bal-line' }, t('balanceNotQueried'))
2803
+ return el('div', { className: 'cm-budget' },
2804
+ el('div', { className: 'cm-budget-head' },
2805
+ el('h3', { className: 'cm-h' }, t('accountBalance')),
2806
+ el('button', { className: 'cm-btn small', onClick: doRefresh, disabled: busy }, busy ? t('refreshing') : t('refreshBalance'))),
2807
+ body,
2808
+ reconcile !== undefined && reconcile.ok === false ? el('div', { className: 'cm-bal-line warn' }, '⚠ ' + reconcile.message) : null,
2809
+ el('label', { className: 'cm-check' },
2810
+ el('input', { type: 'checkbox', checked: reconcileOn, onChange: toggleReconcile }),
2811
+ t('reconcileLabel')),
2812
+ msg !== null ? el('div', { className: 'cm-msg ' + msg.kind }, msg.text) : null)
2813
+ }
2814
+
2815
+ const CUSTOM_BALANCE_OPEN_KEY = 'dsh-cost-meter.customBalance.open'
2816
+ function readCustomBalanceOpen() {
2817
+ try { return window.localStorage.getItem(CUSTOM_BALANCE_OPEN_KEY) !== '0' } catch { return true }
2818
+ }
2819
+
2820
+ function CustomBalancePanel(props) {
2821
+ const { state, api, t, draft, setDraft } = props
2822
+ const [busy, setBusy] = useState(false)
2823
+ const [msg, setMsg] = useState(null)
2824
+ const [open, setOpen] = useState(readCustomBalanceOpen)
2825
+ const [headersText, setHeadersText] = useState('')
2826
+ const [extractText, setExtractText] = useState('')
2827
+ const [jsonErr, setJsonErr] = useState({ headers: '', extract: '' })
2828
+ const openRef = useRef(false)
2829
+ const custom = state.customBalance
2830
+ const config = state.config
2831
+ const cfgEntry = draft?.customBalance ?? config.customBalance ?? {}
2832
+ const enabled = cfgEntry.enabled === true
2833
+ const toggleOpen = () => {
2834
+ setOpen(value => {
2835
+ const next = !value
2836
+ try { window.localStorage.setItem(CUSTOM_BALANCE_OPEN_KEY, next ? '1' : '0') } catch { /* ignore */ }
2837
+ return next
2838
+ })
2839
+ }
2840
+ useEffect(() => {
2841
+ if (open) {
2842
+ if (!openRef.current) {
2843
+ setHeadersText(JSON.stringify(cfgEntry.request?.headers ?? {}, null, 2))
2844
+ setExtractText(JSON.stringify(cfgEntry.extract ?? {}, null, 2))
2845
+ setJsonErr({ headers: '', extract: '' })
2846
+ }
2847
+ openRef.current = true
2848
+ } else {
2849
+ openRef.current = false
2850
+ }
2851
+ }, [open, cfgEntry])
2852
+ const setCustomBalance = (field, value) => {
2853
+ if (draft === null) return
2854
+ setDraft({ ...draft, customBalance: { ...(draft.customBalance ?? config.customBalance ?? {}), [field]: value } })
2855
+ }
2856
+ const setCustomBalanceRequest = (field, value) => {
2857
+ if (draft === null) return
2858
+ const cur = draft.customBalance ?? config.customBalance ?? {}
2859
+ setDraft({
2860
+ ...draft,
2861
+ customBalance: {
2862
+ ...cur,
2863
+ request: { ...(cur.request ?? {}), [field]: value },
2864
+ },
2865
+ })
2866
+ }
2867
+ const applyHeadersText = text => {
2868
+ setHeadersText(text)
2869
+ try {
2870
+ const parsed = JSON.parse(text)
2871
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('invalid')
2872
+ if (Object.values(parsed).some(value => typeof value !== 'string')) throw new Error('invalid') // 值必须是字符串(与服务端 strict 校验同口径)
2873
+ setJsonErr(err => ({ ...err, headers: '' }))
2874
+ setCustomBalanceRequest('headers', parsed)
2875
+ } catch {
2876
+ setJsonErr(err => ({ ...err, headers: t('customBalanceInvalidJson') }))
2877
+ }
2878
+ }
2879
+ const applyExtractText = text => {
2880
+ setExtractText(text)
2881
+ try {
2882
+ const parsed = JSON.parse(text)
2883
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('invalid')
2884
+ setJsonErr(err => ({ ...err, extract: '' }))
2885
+ setCustomBalance('extract', parsed)
2886
+ } catch {
2887
+ setJsonErr(err => ({ ...err, extract: t('customBalanceInvalidJson') }))
2888
+ }
2889
+ }
2890
+ const doRefresh = async () => {
2891
+ // 门控用服务端已保存配置(而非草稿):避免刚勾选启用未过防抖保存时点刷新被服务端拒绝。
2892
+ if (busy || config.customBalance?.enabled !== true) return
2893
+ setBusy(true)
2894
+ setMsg(null)
2895
+ try {
2896
+ const result = await api.refreshCustomBalance()
2897
+ setMsg({ kind: result.ok ? 'ok' : 'err', text: result.message })
2898
+ } catch (error) {
2899
+ setMsg({ kind: 'err', text: t('customBalanceRefreshFailed', { message: error?.message ?? String(error) }) })
2900
+ } finally {
2901
+ setBusy(false)
2902
+ }
2903
+ }
2904
+ const preview = custom?.status === 'ok'
2905
+ ? el(Fragment, null,
2906
+ config.balance?.showProgressBar === true
2907
+ ? el('div', { className: 'cm-budget', style: { marginTop: '8px' } },
2908
+ el(BalanceBar, { segments: segmentsForCustomBalance(state, config) }),
2909
+ el('div', { className: 'cm-bal-line' }, customBalanceDetailText(custom, config, t, state)))
2910
+ : el('div', { className: 'cm-bal-line' }, t('customBalanceRemaining', {
2911
+ amount: formatCustomBalanceMoney(custom.remaining, config, custom),
2912
+ })))
2913
+ : custom?.status === 'error'
2914
+ ? el('div', { className: 'cm-bal-line err' }, custom.message || t('unknownError'))
2915
+ : el('div', { className: 'cm-bal-line' }, t('balanceNotQueried'))
2916
+ const configFields = open
2917
+ ? el(Fragment, null,
2918
+ el('p', { className: 'cm-note' }, t('customBalanceConfigNote')),
2919
+ el('div', { className: 'cm-grid' },
2920
+ el('div', { className: 'cm-field' },
2921
+ el('label', null, t('customBalanceLabelZh')),
2922
+ el('input', {
2923
+ className: 'cm-input',
2924
+ value: cfgEntry.label ?? '',
2925
+ onChange: event => setCustomBalance('label', event.target.value),
2926
+ })),
2927
+ el('div', { className: 'cm-field' },
2928
+ el('label', null, t('customBalanceLabelEn')),
2929
+ el('input', {
2930
+ className: 'cm-input',
2931
+ value: cfgEntry.labelEn ?? '',
2932
+ onChange: event => setCustomBalance('labelEn', event.target.value),
2933
+ })),
2934
+ el('div', { className: 'cm-field' },
2935
+ el('label', null, t('customBalanceUnitLabel')),
2936
+ el('select', {
2937
+ className: 'cm-input',
2938
+ value: cfgEntry.unit === 'CNY' || cfgEntry.unit === 'EUR' ? cfgEntry.unit : 'USD',
2939
+ onChange: event => setCustomBalance('unit', event.target.value),
2940
+ },
2941
+ el('option', { value: 'USD' }, 'USD ($)'),
2942
+ el('option', { value: 'CNY' }, 'CNY (¥)'),
2943
+ el('option', { value: 'EUR' }, 'EUR (€)'))),
2944
+ el('div', { className: 'cm-field' },
2945
+ el('label', null, t('customBalanceDisplayLabel')),
2946
+ el('select', {
2947
+ className: 'cm-input',
2948
+ value: cfgEntry.display ?? 'both',
2949
+ onChange: event => setCustomBalance('display', event.target.value),
2950
+ },
2951
+ el('option', { value: 'sidebar' }, t('balanceSidebar')),
2952
+ el('option', { value: 'settings' }, t('balanceSettings')),
2953
+ el('option', { value: 'both' }, t('balanceBoth')),
2954
+ el('option', { value: 'off' }, t('off')))),
2955
+ el('div', { className: 'cm-field' },
2956
+ el('label', null, t('customBalanceRefreshInterval')),
2957
+ numInput({ value: cfgEntry.refreshMinutes ?? 15 }, v => setCustomBalance('refreshMinutes', Math.min(1440, Math.max(1, Math.floor(v)))))),
2958
+ el('div', { className: 'cm-field' },
2959
+ el('label', null, t('customBalanceMethod')),
2960
+ el('select', {
2961
+ className: 'cm-input',
2962
+ value: cfgEntry.request?.method ?? 'GET',
2963
+ onChange: event => setCustomBalanceRequest('method', event.target.value),
2964
+ },
2965
+ el('option', { value: 'GET' }, 'GET'),
2966
+ el('option', { value: 'POST' }, 'POST'))),
2967
+ el('div', { className: 'cm-field', style: { gridColumn: '1 / -1' } },
2968
+ el('label', null, t('customBalanceUrl')),
2969
+ el('input', {
2970
+ className: 'cm-input',
2971
+ value: cfgEntry.request?.url ?? '',
2972
+ placeholder: 'https://example.com/key/info',
2973
+ onChange: event => setCustomBalanceRequest('url', event.target.value),
2974
+ })),
2975
+ el('div', { className: 'cm-field', style: { gridColumn: '1 / -1' } },
2976
+ el('label', null, t('customBalanceHeaders')),
2977
+ el('textarea', {
2978
+ className: 'cm-input',
2979
+ rows: 5,
2980
+ value: headersText,
2981
+ onChange: event => applyHeadersText(event.target.value),
2982
+ }),
2983
+ jsonErr.headers ? el('span', { className: 'cm-hint err' }, jsonErr.headers) : null),
2984
+ el('div', { className: 'cm-field', style: { gridColumn: '1 / -1' } },
2985
+ el('label', null, t('customBalanceExtract')),
2986
+ el('textarea', {
2987
+ className: 'cm-input',
2988
+ rows: 8,
2989
+ value: extractText,
2990
+ onChange: event => applyExtractText(event.target.value),
2991
+ }),
2992
+ jsonErr.extract ? el('span', { className: 'cm-hint err' }, jsonErr.extract) : null)))
2993
+ : el('p', { className: 'cm-note cm-collapsed-note' }, resolveCustomBalanceLabel(cfgEntry, resolveLocale(config?.locale)) || t('customBalanceTitle'))
2994
+ return el('div', { className: 'cm-budget' },
2995
+ el('div', { className: 'cm-budget-head' },
2996
+ el('h3', { className: 'cm-h' }, t('customBalanceTitle')),
2997
+ el('button', { className: 'cm-toggle-btn', onClick: toggleOpen }, open ? t('customBalanceCollapseConfig') : t('customBalanceOpenConfig')),
2998
+ el('button', { className: 'cm-btn small', onClick: doRefresh, disabled: busy || config.customBalance?.enabled !== true }, busy ? t('refreshing') : t('refreshCustomBalance'))),
2999
+ el('label', { className: 'cm-check' },
3000
+ el('input', { type: 'checkbox', checked: enabled === true, onChange: event => setCustomBalance('enabled', event.target.checked) }),
3001
+ el('span', null, t('enable'))),
3002
+ preview,
3003
+ configFields,
3004
+ msg !== null ? el('div', { className: 'cm-msg ' + msg.kind }, msg.text) : null)
3005
+ }
3006
+
3007
+ function GoQuotaPanel(props) {
3008
+ const { state, api, t, draft, setDraft } = props
3009
+ const [busy, setBusy] = useState(false)
3010
+ const [msg, setMsg] = useState(null)
3011
+ const goQuota = state.goQuota
3012
+ const config = state.config
3013
+ const enabled = draft?.goQuota?.enabled ?? config.goQuota?.enabled ?? true
3014
+ const setGoQuota = (field, value) => {
3015
+ if (draft === null) return
3016
+ setDraft({ ...draft, goQuota: { ...(draft.goQuota ?? config.goQuota), [field]: value } })
3017
+ }
3018
+ const doRefresh = async () => {
3019
+ if (busy || enabled === false) return
3020
+ setBusy(true)
3021
+ setMsg(null)
3022
+ try {
3023
+ const result = await api.refreshGoQuota()
3024
+ setMsg({ kind: result.ok ? 'ok' : 'err', text: result.message })
3025
+ } catch (error) {
3026
+ setMsg({ kind: 'err', text: t('syncFailed', { message: error?.message ?? String(error) }) })
3027
+ } finally {
3028
+ setBusy(false)
3029
+ }
3030
+ }
3031
+ const mainKey = config.goQuota?.main === 'weekly' || config.goQuota?.main === 'monthly' ? config.goQuota.main : 'rolling'
3032
+ const goOrder = [mainKey, ...['rolling', 'weekly', 'monthly'].filter(k => k !== mainKey)]
3033
+ const goLabelOf = k => k === 'rolling' ? 'goWindowRolling' : k === 'weekly' ? 'goWindowWeekly' : 'goWindowMonthly'
3034
+ const goWinOf = k => k === 'rolling' ? goQuota.rolling : k === 'weekly' ? goQuota.weekly : goQuota.monthly
3035
+ const windowRow = (labelKey, win, main) => {
3036
+ const percent = win ? Math.max(0, Math.min(100, Number(win.percent) || 0)) : 0
3037
+ const resets = win && typeof win.resetsAt === 'string' && win.resetsAt.length > 0
3038
+ ? t('goResetAt', { time: new Date(win.resetsAt).toLocaleString() })
3039
+ : ''
3040
+ return el('div', { className: 'cm-go-row' + (main ? ' main' : '') },
3041
+ el('span', { className: 'cm-go-label' }, t(labelKey)),
3042
+ el('div', { className: 'cm-go-bar' },
3043
+ el('div', { className: 'cm-go-fill', style: { width: percent + '%' } })),
3044
+ el('span', { className: 'cm-go-num' }, t('goQuotaPercent', { percent: String(Math.round(percent)) })),
3045
+ resets ? el('span', { className: 'cm-go-reset' }, resets) : null)
3046
+ }
3047
+ const body = enabled === false
3048
+ ? el('p', { className: 'cm-note' }, t('goQuotaDisabledNote'))
3049
+ : goQuota.status === 'ok'
3050
+ ? el('div', { className: 'cm-go-list' },
3051
+ goOrder.map(k => windowRow(goLabelOf(k), goWinOf(k), k === mainKey)),
3052
+ el('div', { className: 'cm-go-time' }, t('goQuotaFetchedAt', {
3053
+ time: goQuota.fetchedAt > 0 ? new Date(goQuota.fetchedAt).toLocaleTimeString() : '—',
3054
+ })))
3055
+ : goQuota.status === 'error'
3056
+ ? el('div', { className: 'cm-bal-line err' }, goQuota.message || t('unknownError'))
3057
+ : goQuota.status === 'off' && goQuota.message
3058
+ ? el('p', { className: 'cm-note' }, goQuota.message)
3059
+ : el('div', { className: 'cm-bal-line' }, t('goQuotaNotQueried'))
3060
+ return el('div', { className: 'cm-budget' },
3061
+ el('div', { className: 'cm-budget-head' },
3062
+ el('h3', { className: 'cm-h' }, t('goQuotaTitle')),
3063
+ el('label', { className: 'cm-check' },
3064
+ el('input', {
3065
+ type: 'checkbox',
3066
+ checked: enabled === true,
3067
+ onChange: event => setGoQuota('enabled', event.target.checked),
3068
+ }),
3069
+ el('span', null, t('enableGoQuota'))),
3070
+ el('button', { className: 'cm-btn small', onClick: doRefresh, disabled: busy || enabled === false }, busy ? t('refreshing') : t('refreshGoQuota'))),
3071
+ body,
3072
+ msg !== null ? el('div', { className: 'cm-msg ' + msg.kind }, msg.text) : null)
3073
+ }
3074
+
3075
+ // ── Coding Plan 额度面板(Anthropic / Z.ai·GLM / MiniMax,各家独立开关与凭据) ───
3076
+
3077
+ const CODING_PLAN_ROWS = [
3078
+ { id: 'anthropic', labelKey: 'codingPlanAnthropic' },
3079
+ { id: 'zai', labelKey: 'codingPlanZai' },
3080
+ { id: 'minimax', labelKey: 'codingPlanMinimax' },
3081
+ { id: 'kimi', labelKey: 'codingPlanKimi' },
3082
+ { id: 'openrouter', labelKey: 'codingPlanOpenrouter' },
3083
+ { id: 'siliconflow', labelKey: 'codingPlanSiliconflow' },
3084
+ ]
3085
+
3086
+ /** Coding Plan 面板展开状态:localStorage 记住,默认折叠。 */
3087
+ const CODING_PLANS_OPEN_KEY = 'dsh-cost-meter.codingPlans.open'
3088
+ function readCodingPlansOpen() {
3089
+ try { return window.localStorage.getItem(CODING_PLANS_OPEN_KEY) === '1' } catch { return false }
3090
+ }
3091
+
3092
+ function CodingPlansPanel(props) {
3093
+ const { state, api, t, draft, setDraft } = props
3094
+ const [busyId, setBusyId] = useState(null)
3095
+ const [msgs, setMsgs] = useState({})
3096
+ const [open, setOpen] = useState(readCodingPlansOpen)
3097
+ const toggleOpen = () => {
3098
+ setOpen(o => {
3099
+ const next = !o
3100
+ try { window.localStorage.setItem(CODING_PLANS_OPEN_KEY, next ? '1' : '0') } catch { /* 存储不可用时仅本会话生效 */ }
3101
+ return next
3102
+ })
3103
+ }
3104
+ const plansState = state.codingPlans ?? {}
3105
+ const config = state.config
3106
+ const draftEntry = id => (draft?.codingPlans?.[id] ?? config.codingPlans?.[id] ?? {})
3107
+ const liveEntry = id => plansState[id] ?? { status: 'off', message: '', fetchedAt: 0, windows: {} }
3108
+ const setPlan = (id, field, value) => {
3109
+ if (draft === null) return
3110
+ const base = draft.codingPlans ?? config.codingPlans ?? {}
3111
+ setDraft({ ...draft, codingPlans: { ...base, [id]: { ...(base[id] ?? {}), [field]: value } } })
3112
+ }
3113
+ const doRefresh = async id => {
3114
+ if (busyId !== null) return
3115
+ setBusyId(id)
3116
+ setMsgs(m => ({ ...m, [id]: null }))
3117
+ try {
3118
+ const result = await api.refreshCodingPlan(id)
3119
+ setMsgs(m => ({ ...m, [id]: { kind: result.ok ? 'ok' : 'err', text: result.message } }))
3120
+ } catch (error) {
3121
+ setMsgs(m => ({ ...m, [id]: { kind: 'err', text: t('syncFailed', { message: error?.message ?? String(error) }) } }))
3122
+ } finally {
3123
+ setBusyId(null)
3124
+ }
3125
+ }
3126
+ const windowRow = (name, win) => {
3127
+ // 文本窗口(余额等无百分比的量):直接显示文本行。
3128
+ if (typeof win?.percent !== 'number') {
3129
+ return el('div', { className: 'cm-go-row' },
3130
+ el('span', { className: 'cm-go-label' }, name.replace(/_/g, ' ')),
3131
+ el('span', { className: 'cm-go-num' }, typeof win?.text === 'string' ? win.text : '—'))
3132
+ }
3133
+ const percent = win ? Math.max(0, Math.min(100, Number(win.percent) || 0)) : 0
3134
+ const resets = win && typeof win.resetsAt === 'string' && win.resetsAt.length > 0
3135
+ ? t('goResetAt', { time: new Date(win.resetsAt).toLocaleString() })
3136
+ : ''
3137
+ return el('div', { className: 'cm-go-row' },
3138
+ el('span', { className: 'cm-go-label' }, name.replace(/_/g, ' ')),
3139
+ el('div', { className: 'cm-go-bar' },
3140
+ el('div', { className: 'cm-go-fill', style: { width: percent + '%' } })),
3141
+ el('span', { className: 'cm-go-num' }, t('goQuotaPercent', { percent: String(Math.round(percent)) })),
3142
+ resets ? el('span', { className: 'cm-go-reset' }, resets) : null)
3143
+ }
3144
+ const renderRow = ({ id, labelKey }) => {
3145
+ const cfgEntry = draftEntry(id)
3146
+ const live = liveEntry(id)
3147
+ const enabled = cfgEntry.enabled === true
3148
+ const windows = live.windows !== null && typeof live.windows === 'object' ? live.windows : {}
3149
+ const body = enabled === false
3150
+ ? el('p', { className: 'cm-note' }, t('codingPlanDisabledNote'))
3151
+ : live.status === 'ok'
3152
+ ? el('div', { className: 'cm-go-list' },
3153
+ Object.keys(windows).length > 0
3154
+ ? Object.entries(windows).map(([name, win]) => windowRow(name, win))
3155
+ : el('div', { className: 'cm-bal-line' }, t('codingPlanNotQueried')),
3156
+ el('div', { className: 'cm-go-time' }, t('goQuotaFetchedAt', {
3157
+ time: live.fetchedAt > 0 ? new Date(live.fetchedAt).toLocaleTimeString() : '—',
3158
+ })))
3159
+ : live.status === 'error'
3160
+ ? el('div', { className: 'cm-bal-line err' }, live.message || t('unknownError'))
3161
+ : live.status === 'off' && live.message
3162
+ ? el('p', { className: 'cm-note' }, live.message)
3163
+ : el('div', { className: 'cm-bal-line' }, t('codingPlanNotQueried'))
3164
+ return el('div', { key: id, className: 'cm-budget', style: { marginTop: '8px' } },
3165
+ el('div', { className: 'cm-budget-head' },
3166
+ el('h3', { className: 'cm-h' }, t(labelKey)),
3167
+ el('label', { className: 'cm-check' },
3168
+ el('input', {
3169
+ type: 'checkbox',
3170
+ checked: enabled,
3171
+ onChange: event => setPlan(id, 'enabled', event.target.checked),
3172
+ }),
3173
+ el('span', null, t('enableCodingPlan'))),
3174
+ el('button', { className: 'cm-btn small', onClick: () => { void doRefresh(id) }, disabled: busyId !== null || enabled === false }, busyId === id ? t('refreshing') : t('refreshCodingPlan'))),
3175
+ enabled ? el('div', { className: 'cm-field' },
3176
+ el('label', null, t('codingPlanKeyLabel')),
3177
+ el('input', {
3178
+ className: 'cm-input', type: 'password',
3179
+ value: typeof cfgEntry.apiKey === 'string' ? cfgEntry.apiKey : '',
3180
+ placeholder: 'sk-…',
3181
+ onChange: event => setPlan(id, 'apiKey', event.target.value),
3182
+ })) : null,
3183
+ body,
3184
+ msgs[id] != null ? el('div', { className: 'cm-msg ' + msgs[id].kind }, msgs[id].text) : null)
3185
+ }
3186
+ return el('div', { className: 'cm-budget' },
3187
+ el('div', { className: 'cm-budget-head' },
3188
+ el('h3', { className: 'cm-h' }, t('codingPlansTitle')),
3189
+ el('button', { className: 'cm-toggle-btn', onClick: toggleOpen }, open ? t('codingPlansCollapse') : t('codingPlansOpen'))),
3190
+ open
3191
+ ? el(Fragment, null,
3192
+ el('p', { className: 'cm-note' }, t('codingPlansNote')),
3193
+ CODING_PLAN_ROWS.map(renderRow))
3194
+ : el('p', { className: 'cm-note cm-collapsed-note' }, t('codingPlansCollapsedHint')))
3195
+ }
3196
+
3197
+ // ── Token 用量统计(历史总量 + 每日格子热图;显示位置可配) ────────────────
3198
+
3199
+ const EN_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
3200
+
3201
+ /** 单日 token 总量(输入 + 缓存读写 + 输出)。 */
3202
+ const dayTokensOf = day => (day.input ?? 0) + (day.output ?? 0) + (day.cacheRead ?? 0) + (day.cacheWrite ?? 0)
3203
+
3204
+ function UsagePanel(props) {
3205
+ const costStore = props.useCost ? props.useCost(s => s) : undefined
3206
+ const state = props.state ?? costStore?.state
3207
+ if (!state) return null
3208
+ const config = state.config
3209
+ const locale = resolveLocale(config?.locale)
3210
+ const t = props.t ?? makeT(locale)
3211
+ const history = Array.isArray(state.history) ? state.history : []
3212
+ if (history.length === 0) {
3213
+ return el('div', null,
3214
+ el('h3', { className: 'cm-h' }, t('usageTitle')),
3215
+ el('p', { className: 'cm-empty' }, t('usageEmpty')))
3216
+ }
3217
+ const todayKey = state.meta?.dayKey ?? ''
3218
+ // Codex 用量图风格:最近 26 周的方格热图(列 = 周、行 = 周一至周日),
3219
+ // 格子 aspect-ratio 自适应,横向铺满整个设置页宽度;未来日与零消耗日同款格子,矩形完整;
3220
+ // 月份标签在网格下方,标在月份变化的列;无星期标签(与参考样式一致)。
3221
+ const byDate = new Map(history.map(day => [day.date, day]))
3222
+ const dayKeyOf = d => {
3223
+ const pad = n => String(n).padStart(2, '0')
3224
+ return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
3225
+ }
3226
+ const today = new Date()
3227
+ today.setHours(0, 0, 0, 0)
3228
+ const end = new Date(today)
3229
+ end.setDate(end.getDate() + (6 - (today.getDay() + 6) % 7)) // 对齐到本周周日
3230
+ const WEEKS = 26
3231
+ const columns = []
3232
+ const monthLabels = []
3233
+ let lastMonth = -1
3234
+ for (let w = WEEKS - 1; w >= 0; w -= 1) {
3235
+ for (let i = 0; i < 7; i += 1) {
3236
+ const d = new Date(end)
3237
+ d.setDate(d.getDate() - (w * 7 + (6 - i)))
3238
+ const day = byDate.get(dayKeyOf(d))
3239
+ columns.push(day !== undefined ? { day, tokens: dayTokensOf(day) } : { day: { date: dayKeyOf(d) }, tokens: 0 })
3240
+ }
3241
+ const m = new Date(end)
3242
+ m.setDate(m.getDate() - (w * 7 + 6))
3243
+ monthLabels.push(m.getMonth() !== lastMonth ? (locale === 'en' ? EN_MONTHS[m.getMonth()] : String(m.getMonth() + 1) + '月') : '')
3244
+ lastMonth = m.getMonth()
3245
+ }
3246
+ const maxDay = Math.max(...columns.map(x => x.tokens), 1)
3247
+ const levelOf = tokens => {
3248
+ const ratio = tokens / maxDay
3249
+ return ratio < 0.25 ? 1 : ratio < 0.5 ? 2 : ratio < 0.75 ? 3 : 4
3250
+ }
3251
+ const cell = entry => {
3252
+ const { day, tokens } = entry
3253
+ const cls = 'cm-ug-cell' + (tokens > 0 ? ' l' + levelOf(tokens) : '') + (day.date === todayKey ? ' today' : '')
3254
+ const tip = t('usageDay', {
3255
+ date: day.date,
3256
+ tokens: formatTokens(tokens),
3257
+ input: formatTokens(day.input ?? 0),
3258
+ cache: formatTokens((day.cacheRead ?? 0) + (day.cacheWrite ?? 0)),
3259
+ output: formatTokens(day.output ?? 0),
3260
+ calls: day.calls ?? 0,
3261
+ cost: formatMoneyUsd(day.cost ?? 0, config),
3262
+ })
3263
+ return el(Tooltip, { key: day.date, label: tip, side: 'top', delayMs: 200 },
3264
+ el('div', { className: cls }))
3265
+ }
3266
+ const total = state.total
3267
+ return el('div', { className: 'cm-ug' },
3268
+ el('h3', { className: 'cm-h' }, t('usageTitle')),
3269
+ el('div', { className: 'cm-ug-total' }, t('usageTotal', {
3270
+ tokens: formatTokens(dayTokensOf(total)),
3271
+ input: formatTokens(total.input ?? 0),
3272
+ cache: formatTokens((total.cacheRead ?? 0) + (total.cacheWrite ?? 0)),
3273
+ output: formatTokens(total.output ?? 0),
3274
+ calls: total.calls ?? 0,
3275
+ })),
3276
+ el('div', { className: 'cm-ug-grid', style: { gridTemplateColumns: 'repeat(' + WEEKS + ',1fr)' } },
3277
+ columns.map(cell)),
3278
+ el('div', { className: 'cm-ug-months', style: { gridTemplateColumns: 'repeat(' + WEEKS + ',1fr)' } },
3279
+ monthLabels.map((m, i) => el('span', { key: 'm' + String(i), className: 'cm-ug-monthc' }, m))))
3280
+ }
3281
+
3282
+ function CostSection(props) {
3283
+ const costStore = props.useCost ? props.useCost(s => s) : undefined
3284
+ const api = props.api
3285
+ const state = costStore?.state ?? null
3286
+ const [draft, setDraft] = useState(null)
3287
+ const [message, setMessage] = useState(null)
3288
+ const [confirmFetch, setConfirmFetch] = useState(false)
3289
+ const [confirmReset, setConfirmReset] = useState(false)
3290
+ const [newModelId, setNewModelId] = useState('')
3291
+ const [busy, setBusy] = useState(false)
3292
+ // 价格表折叠开关(默认收起,保持设置页简洁;三角按钮展开)。
3293
+ const [priceOpen, setPriceOpen] = useState(false)
3294
+ // 自动保存状态:idle(无改动) | saving | saved | error。
3295
+ const [saveState, setSaveState] = useState({ status: 'idle', at: 0, error: null })
3296
+ const savedRef = React.useRef(null)
3297
+ // 最近一次已知的服务端配置对象:保存时按顶层键 diff,只提交真正改动的键。
3298
+ const baselineRef = React.useRef(null)
3299
+
3300
+ useEffect(() => {
3301
+ if (state !== null) {
3302
+ const json = JSON.stringify(state.config)
3303
+ baselineRef.current = state.config
3304
+ // 轮询/其它来源的 state 刷新不得覆盖有未保存改动的草稿(#3 的周期轮询引入的回归):
3305
+ // 草稿与已保存快照不一致(正在编辑)时保留草稿,待防抖保存落盘后再对齐。
3306
+ setDraft(prev => (prev !== null && JSON.stringify(prev) !== savedRef.current ? prev : JSON.parse(json)))
3307
+ savedRef.current = json
3308
+ }
3309
+ }, [state])
3310
+
3311
+ // 配置改动 600ms 防抖后即时保存(无需点击保存按钮)。
3312
+ useEffect(() => {
3313
+ if (draft === null || api === undefined) return
3314
+ const json = JSON.stringify(draft)
3315
+ if (json === savedRef.current) return
3316
+ setSaveState(prev => (prev.status === 'saving' ? prev : { ...prev, status: 'saving' }))
3317
+ const timer = setTimeout(() => {
3318
+ // 只提交发生变化的顶层键(diff 补丁):多窗口同开时,旧窗口的草稿
3319
+ // 不再整份覆盖其它窗口已保存的改动(否则会互相回弹)。
3320
+ const patch = {}
3321
+ const base = baselineRef.current
3322
+ if (base !== null && typeof base === 'object') {
3323
+ for (const key of Object.keys(draft)) {
3324
+ if (JSON.stringify(draft[key]) !== JSON.stringify(base[key])) patch[key] = draft[key]
3325
+ }
3326
+ }
3327
+ if (Object.keys(patch).length === 0) {
3328
+ savedRef.current = json
3329
+ setSaveState({ status: 'saved', at: Date.now(), error: null })
3330
+ return
3331
+ }
3332
+ api.updateConfig(patch).then(() => {
3333
+ savedRef.current = json
3334
+ setSaveState({ status: 'saved', at: Date.now(), error: null })
3335
+ }, error => {
3336
+ setSaveState({ status: 'error', at: 0, error: error?.message ?? String(error) })
3337
+ })
3338
+ }, 600)
3339
+ return () => { clearTimeout(timer) }
3340
+ }, [draft, api])
3341
+
3342
+ useEffect(() => {
3343
+ if (costStore?.status === 'error' && costStore.error) setMessage({ kind: 'err', text: t('ledgerReadFailed', { message: costStore.error }) })
3344
+ }, [costStore?.status, costStore?.error])
3345
+
3346
+ // 语言跟随当前草稿(切换语言立即生效),草稿为空时用已保存配置。
3347
+ const locale = resolveLocale((draft ?? state?.config)?.locale)
3348
+ const t = makeT(locale)
3349
+
3350
+ if (costStore === undefined || state === null) {
3351
+ return el('div', { className: 'cm-section' },
3352
+ el('p', { className: 'cm-empty' }, costStore?.status === 'loading' ? t('readingLedger') : t('ledgerUnavailable')))
3353
+ }
3354
+ const config = state.config
3355
+
3356
+ const doFetch = async () => {
3357
+ if (busy) return
3358
+ setBusy(true)
3359
+ setMessage(null)
3360
+ try {
3361
+ const result = await api.fetchPrices()
3362
+ setMessage({ kind: result.ok ? 'ok' : 'err', text: result.message })
3363
+ // 同步成功后,草稿整体对齐到返回的最新配置,价格表等显示立即刷新。
3364
+ if (result.ok && result.state && typeof result.state.config === 'object') {
3365
+ const json = JSON.stringify(result.state.config)
3366
+ setDraft(JSON.parse(json))
3367
+ savedRef.current = json
3368
+ }
3369
+ } catch (error) {
3370
+ setMessage({ kind: 'err', text: t('syncFailed', { message: error?.message ?? String(error) }) })
3371
+ } finally {
3372
+ setBusy(false)
3373
+ setConfirmFetch(false)
3374
+ }
3375
+ }
3376
+ const doReset = async () => {
3377
+ if (busy) return
3378
+ setBusy(true)
3379
+ try {
3380
+ await api.resetHistory()
3381
+ setMessage({ kind: 'ok', text: t('historyCleared') })
3382
+ } catch (error) {
3383
+ setMessage({ kind: 'err', text: t('clearFailed', { message: error?.message ?? String(error) }) })
3384
+ } finally {
3385
+ setBusy(false)
3386
+ setConfirmReset(false)
3387
+ }
3388
+ }
3389
+ const setField = (field, value) => {
3390
+ if (draft === null) return
3391
+ setDraft({ ...draft, [field]: value })
3392
+ }
3393
+ const addModel = () => {
3394
+ const id = newModelId.trim().toLowerCase()
3395
+ if (id.length === 0 || !/^[a-z0-9_.-]+$/.test(id)) return
3396
+ if (draft?.prices.models[id] !== undefined) return
3397
+ const models = { ...draft.prices.models, [id]: { cacheHit: 0, cacheMiss: 0, output: 0 } }
3398
+ setDraft({ ...draft, prices: { ...draft.prices, models } })
3399
+ setNewModelId('')
3400
+ }
3401
+ const priceCards = draft === null ? [] : Object.keys(draft.prices.models)
3402
+ .filter(modelId => {
3403
+ // priceTableDisplay 按模型门控:缺省 DeepSeek 模型直接显示;显式 false 的收入拓展价格表。
3404
+ const displayMap = draft?.priceTableDisplay ?? config.priceTableDisplay ?? {}
3405
+ const value = displayMap['deepseek:' + modelId]
3406
+ return typeof value === 'boolean' ? value : true
3407
+ })
3408
+ .map(modelId => (
3409
+ el(PriceCard, {
3410
+ key: modelId, modelId,
3411
+ entry: draft.prices.models[modelId] ?? { cacheHit: 0, cacheMiss: 0, output: 0 },
3412
+ isDefault: false, draft, setDraft, t,
3413
+ })
3414
+ ))
3415
+ return el('div', { className: 'cm-section' },
3416
+ // 顶栏:界面语言(打开费用设置即见)
3417
+ el('div', { className: 'cm-toolbar' },
3418
+ el('div', { className: 'cm-field' },
3419
+ el('label', null, t('languageLabel')),
3420
+ el('select', {
3421
+ className: 'cm-input',
3422
+ value: draft?.locale ?? 'auto',
3423
+ onChange: event => setField('locale', event.target.value),
3424
+ },
3425
+ el('option', { value: 'auto' }, t('localeAuto')),
3426
+ el('option', { value: 'zh' }, t('localeZh')),
3427
+ el('option', { value: 'en' }, t('localeEn'))))),
3428
+ // Token 用量统计(费用设置最上方)
3429
+ (!USAGE_POSITION_SWITCHABLE || (config.usage?.position ?? 'cost') === 'cost')
3430
+ ? el(UsagePanel, { state, t, locale })
3431
+ : null,
3432
+ // 按模型统计(紧随用量统计;今日/近90天:费用、token、缓存命中率、性价比)
3433
+ el(ModelStatsPanel, { state, config: draft ?? config, t }),
3434
+ // OpenCode Go 订阅额度(含启用开关,像预算面板一样常驻)
3435
+ el(GoQuotaPanel, { state, api, t, draft, setDraft }),
3436
+ // Coding Plan 额度(Anthropic / Z.ai·GLM / MiniMax,各家独立开关)
3437
+ el(CodingPlansPanel, { state, api, t, draft, setDraft }),
3438
+ // 自定义 Provider 余额(可配置 HTTP 查询;与 Coding Plan 同区,可折叠)
3439
+ el(CustomBalancePanel, { state, api, t, draft, setDraft }),
3440
+ // 预算(紧随其后)
3441
+ el(BudgetPanel, { state, draft, setDraft, t }),
3442
+ // 峰谷计价与提示(独立面板:启用/提示开关、样式切换、时段条预览)
3443
+ el(PeakPanel, { state, draft, setDraft, t }),
3444
+ // 官方余额(按显示配置)
3445
+ (config.balance?.display === 'settings' || config.balance?.display === 'both')
3446
+ ? el(BalancePanel, { state, api, t, draft, setDraft })
3447
+ : null,
3448
+ // 汇总卡片
3449
+ el('div', { className: 'cm-cards' },
3450
+ el(Card, {
3451
+ title: t('cardToday'),
3452
+ value: formatMoneyUsd(state.today.cost, config),
3453
+ sub: t('callsTokens', {
3454
+ calls: state.today.calls,
3455
+ input: formatTokens(state.today.input),
3456
+ cache: formatTokens(state.today.cacheRead + state.today.cacheWrite),
3457
+ output: formatTokens(state.today.output),
3458
+ }),
3459
+ }),
3460
+ el(Card, {
3461
+ title: t('cardMonth'),
3462
+ value: formatMoneyUsd(state.month.cost, config),
3463
+ sub: t('callsTokens', {
3464
+ calls: state.month.calls,
3465
+ input: formatTokens(state.month.input),
3466
+ cache: formatTokens(state.month.cacheRead + state.month.cacheWrite),
3467
+ output: formatTokens(state.month.output),
3468
+ }),
3469
+ }),
3470
+ el(Card, {
3471
+ title: t('cardTotal'),
3472
+ value: formatMoneyUsd(state.total.cost, config),
3473
+ sub: t('cardTotalSub', { calls: state.total.calls }),
3474
+ })),
3475
+ // 今日会话
3476
+ el('div', null,
3477
+ el('h3', { className: 'cm-h' }, t('todaySessions')),
3478
+ el(TodaySessions, { state, t })),
3479
+ // 历史
3480
+ el('div', null,
3481
+ el('h3', { className: 'cm-h' }, t('history')),
3482
+ el(HistoryTable, { state, t })),
3483
+ // 显示设置
3484
+ el('div', null,
3485
+ el('h3', { className: 'cm-h' }, t('displaySettings')),
3486
+ el('div', { className: 'cm-grid' },
3487
+ el('div', { className: 'cm-grid-group' }, t('groupGeneral')),
3488
+ USAGE_POSITION_SWITCHABLE
3489
+ ? el('div', { className: 'cm-field' },
3490
+ el('label', null, t('usagePositionLabel')),
3491
+ el('select', {
3492
+ className: 'cm-input',
3493
+ value: draft?.usage?.position ?? 'cost',
3494
+ onChange: event => {
3495
+ if (draft === null) return
3496
+ setDraft({ ...draft, usage: { ...(draft.usage ?? { position: 'cost' }), position: event.target.value } })
3497
+ },
3498
+ },
3499
+ el('option', { value: 'cost' }, t('usagePositionCost')),
3500
+ el('option', { value: 'general' }, t('usagePositionGeneral')),
3501
+ el('option', { value: 'section' }, t('usagePositionSection'))))
3502
+ : null,
3503
+ el('div', { className: 'cm-field' },
3504
+ el('label', null, t('positionLabel')),
3505
+ el('select', {
3506
+ className: 'cm-input',
3507
+ value: draft?.position ?? 'dock',
3508
+ onChange: event => setField('position', event.target.value),
3509
+ },
3510
+ el('option', { value: 'dock' }, t('positionDock')),
3511
+ el('option', { value: 'header' }, t('positionHeader')),
3512
+ el('option', { value: 'off' }, t('off')))),
3513
+ el('div', { className: 'cm-field' },
3514
+ el('label', null, t('sidebarLabel')),
3515
+ el('select', {
3516
+ className: 'cm-input',
3517
+ value: draft?.sidebar === false ? 'off' : 'on',
3518
+ onChange: event => setField('sidebar', event.target.value === 'on'),
3519
+ },
3520
+ el('option', { value: 'on' }, t('sidebarOn')),
3521
+ el('option', { value: 'off' }, t('off')))),
3522
+ el('div', { className: 'cm-grid-group' }, t('groupMoney')),
3523
+ el('div', { className: 'cm-field' },
3524
+ el('label', null, t('currencyLabel')),
3525
+ el('select', {
3526
+ className: 'cm-input',
3527
+ value: draft?.currency ?? 'CNY',
3528
+ onChange: event => {
3529
+ const preset = CURRENCY_PRESETS[event.target.value]
3530
+ if (preset !== undefined && draft !== null) {
3531
+ setDraft({ ...draft, currency: event.target.value, ...preset })
3532
+ }
3533
+ },
3534
+ },
3535
+ el('option', { value: 'CNY' }, t('currencyCny')),
3536
+ el('option', { value: 'USD' }, t('currencyUsd')),
3537
+ el('option', { value: 'EUR' }, t('currencyEur')))),
3538
+ el('div', { className: 'cm-field' },
3539
+ el('label', null, t('symbolLabel')),
3540
+ el('input', {
3541
+ className: 'cm-input narrow', type: 'text',
3542
+ value: draft?.symbol ?? '',
3543
+ onChange: event => setField('symbol', event.target.value),
3544
+ })),
3545
+ el('div', { className: 'cm-field' },
3546
+ el('label', null, t('rateLabel')),
3547
+ numInput({ value: draft?.exchangeRate ?? 1 }, v => setField('exchangeRate', v))),
3548
+ el('div', { className: 'cm-field' },
3549
+ el('label', null, t('decimalsLabel')),
3550
+ numInput({ value: draft?.decimals ?? 2 }, v => setField('decimals', Math.min(10, Math.floor(v))))),
3551
+ el('div', { className: 'cm-grid-group' }, t('groupSidebar')),
3552
+ el('div', { className: 'cm-field' },
3553
+ el('label', null, t('balanceDisplayLabel')),
3554
+ el('select', {
3555
+ className: 'cm-input',
3556
+ value: draft?.balance?.display ?? 'both',
3557
+ onChange: event => {
3558
+ if (draft === null) return
3559
+ setDraft({ ...draft, balance: { ...(draft.balance ?? { display: 'both', refreshMinutes: 5 }), display: event.target.value } })
3560
+ },
3561
+ },
3562
+ el('option', { value: 'sidebar' }, t('balanceSidebar')),
3563
+ el('option', { value: 'settings' }, t('balanceSettings')),
3564
+ el('option', { value: 'both' }, t('balanceBoth')),
3565
+ el('option', { value: 'off' }, t('off')))),
3566
+ el('div', { className: 'cm-field' },
3567
+ el('label', null, t('refreshIntervalLabel')),
3568
+ numInput({ value: draft?.balance?.refreshMinutes ?? 5 }, v => {
3569
+ if (draft === null) return
3570
+ setDraft({ ...draft, balance: { ...(draft.balance ?? { display: 'both', refreshMinutes: 5, showProgressBar: false, budgetCap: null }), refreshMinutes: Math.min(1440, Math.max(1, Math.floor(v))) } })
3571
+ })),
3572
+ el('div', { className: 'cm-field' },
3573
+ el('label', { className: 'cm-check' },
3574
+ el('input', {
3575
+ type: 'checkbox',
3576
+ checked: draft?.balance?.showProgressBar === true,
3577
+ onChange: event => {
3578
+ if (draft === null) return
3579
+ setDraft({ ...draft, balance: { ...(draft.balance ?? { display: 'both', refreshMinutes: 5, showProgressBar: false, budgetCap: null }), showProgressBar: event.target.checked } })
3580
+ },
3581
+ }),
3582
+ el('span', null, t('balanceShowProgressBar')))),
3583
+ el('div', { className: 'cm-field' },
3584
+ el('label', null, t('balanceBudgetCapLabel')),
3585
+ numInput({ value: draft?.balance?.budgetCap ?? '' }, v => {
3586
+ if (draft === null) return
3587
+ setDraft({ ...draft, balance: { ...(draft.balance ?? { display: 'both', refreshMinutes: 5, showProgressBar: false, budgetCap: null }), budgetCap: v > 0 ? v : null } })
3588
+ }),
3589
+ el('span', { className: 'cm-hint' }, t('balanceBudgetCapHint'))),
3590
+ el('div', { className: 'cm-field' },
3591
+ el('label', null, t('customBalanceDisplayLabel')),
3592
+ el('select', {
3593
+ className: 'cm-input',
3594
+ value: draft?.customBalance?.display ?? config.customBalance?.display ?? 'both',
3595
+ onChange: event => {
3596
+ if (draft === null) return
3597
+ setDraft({ ...draft, customBalance: { ...(draft.customBalance ?? config.customBalance ?? {}), display: event.target.value } })
3598
+ },
3599
+ },
3600
+ el('option', { value: 'sidebar' }, t('balanceSidebar')),
3601
+ el('option', { value: 'settings' }, t('balanceSettings')),
3602
+ el('option', { value: 'both' }, t('balanceBoth')),
3603
+ el('option', { value: 'off' }, t('off')))),
3604
+ el('div', { className: 'cm-field' },
3605
+ el('label', null, t('customBalanceRefreshInterval')),
3606
+ numInput({ value: draft?.customBalance?.refreshMinutes ?? config.customBalance?.refreshMinutes ?? 15 }, v => {
3607
+ if (draft === null) return
3608
+ setDraft({ ...draft, customBalance: { ...(draft.customBalance ?? config.customBalance ?? {}), refreshMinutes: Math.min(1440, Math.max(1, Math.floor(v))) } })
3609
+ })),
3610
+ el('div', { className: 'cm-field' },
3611
+ el('label', null, t('goQuotaDisplayLabel')),
3612
+ el('select', {
3613
+ className: 'cm-input',
3614
+ value: draft?.goQuota?.display ?? 'both',
3615
+ onChange: event => {
3616
+ if (draft === null) return
3617
+ setDraft({ ...draft, goQuota: { ...(draft.goQuota ?? { display: 'both', refreshMinutes: 15, apiKey: '' }), display: event.target.value } })
3618
+ },
3619
+ },
3620
+ el('option', { value: 'sidebar' }, t('balanceSidebar')),
3621
+ el('option', { value: 'settings' }, t('balanceSettings')),
3622
+ el('option', { value: 'both' }, t('balanceBoth')),
3623
+ el('option', { value: 'off' }, t('off')))),
3624
+ el('div', { className: 'cm-field' },
3625
+ el('label', null, t('goMainLabel')),
3626
+ el('select', {
3627
+ className: 'cm-input',
3628
+ value: draft?.goQuota?.main ?? 'rolling',
3629
+ onChange: event => {
3630
+ if (draft === null) return
3631
+ setDraft({ ...draft, goQuota: { ...(draft.goQuota ?? { display: 'both', refreshMinutes: 15, apiKey: '', main: 'rolling' }), main: event.target.value } })
3632
+ },
3633
+ },
3634
+ el('option', { value: 'rolling' }, t('goWindowRolling')),
3635
+ el('option', { value: 'weekly' }, t('goWindowWeekly')),
3636
+ el('option', { value: 'monthly' }, t('goWindowMonthly')))),
3637
+ el('div', { className: 'cm-field' },
3638
+ el('label', null, t('goQuotaRefreshIntervalLabel')),
3639
+ numInput({ value: draft?.goQuota?.refreshMinutes ?? 15 }, v => {
3640
+ if (draft === null) return
3641
+ setDraft({ ...draft, goQuota: { ...(draft.goQuota ?? { display: 'both', refreshMinutes: 15, apiKey: '' }), refreshMinutes: Math.min(1440, Math.max(1, Math.floor(v))) } })
3642
+ })),
3643
+ el('div', { className: 'cm-field' },
3644
+ el('label', null, t('goQuotaKeyLabel')),
3645
+ el('input', {
3646
+ className: 'cm-input', type: 'password',
3647
+ value: draft?.goQuota?.apiKey ?? '',
3648
+ placeholder: 'sk-…',
3649
+ onChange: event => {
3650
+ if (draft === null) return
3651
+ setDraft({ ...draft, goQuota: { ...(draft.goQuota ?? { display: 'both', refreshMinutes: 15, apiKey: '' }), apiKey: event.target.value } })
3652
+ },
3653
+ })),
3654
+ el('div', { className: 'cm-grid-group' }, t('groupCorner')),
3655
+ el('div', { className: 'cm-field' },
3656
+ el('label', null, t('cornerLabel')),
3657
+ el('label', { className: 'cm-check' },
3658
+ el('input', {
3659
+ type: 'checkbox',
3660
+ checked: draft?.corner?.enabled === true,
3661
+ onChange: event => {
3662
+ if (draft === null) return
3663
+ setDraft({ ...draft, corner: { ...(draft.corner ?? { enabled: false, goRolling: true, goWeekly: true, goMonthly: true, budget: true }), enabled: event.target.checked } })
3664
+ },
3665
+ }),
3666
+ el('span', null, t('cornerEnabledLabel'))),
3667
+ draft?.corner?.enabled === true
3668
+ ? [['goRolling', 'cornerGoRolling'], ['goWeekly', 'cornerGoWeekly'], ['goMonthly', 'cornerGoMonthly'], ['budget', 'cornerBudget']].map(([key, labelKey]) =>
3669
+ el('label', { key, className: 'cm-check' },
3670
+ el('input', {
3671
+ type: 'checkbox',
3672
+ checked: draft.corner[key] !== false,
3673
+ onChange: event => {
3674
+ if (draft === null) return
3675
+ setDraft({ ...draft, corner: { ...draft.corner, [key]: event.target.checked } })
3676
+ },
3677
+ }),
3678
+ el('span', null, t(labelKey))))
3679
+ : null),
3680
+ el('div', { className: 'cm-grid-group' }, t('groupDetail')),
3681
+ el('div', { className: 'cm-field' },
3682
+ el('label', { className: 'cm-check' },
3683
+ el('input', {
3684
+ type: 'checkbox',
3685
+ checked: draft?.goQuota?.detail !== false,
3686
+ onChange: event => {
3687
+ if (draft === null) return
3688
+ setDraft({ ...draft, goQuota: { ...(draft.goQuota ?? { display: 'both', refreshMinutes: 15, apiKey: '', main: 'rolling', detail: true }), detail: event.target.checked } })
3689
+ },
3690
+ }),
3691
+ el('span', null, t('goDetailLabel')))),
3692
+ el('div', { className: 'cm-field' },
3693
+ el('label', { className: 'cm-check' },
3694
+ el('input', {
3695
+ type: 'checkbox',
3696
+ checked: draft?.budget?.detail !== false,
3697
+ onChange: event => {
3698
+ if (draft === null) return
3699
+ setDraft({ ...draft, budget: { ...(draft.budget ?? { enabled: false, amount: 100, period: 'month', customStart: null, customEnd: null, detail: true }), detail: event.target.checked } })
3700
+ },
3701
+ }),
3702
+ el('span', null, t('budgetDetailLabel'))))),
3703
+ el('p', { className: 'cm-note' }, t('badgeNote'))),
3704
+ // 价格表(可折叠,默认收起;priceTableDisplay 按模型门控:未勾选直接显示的模型收入拓展价格表,该开关只决定展示位置)
3705
+ el('div', null,
3706
+ el('button', { type: 'button', className: 'cm-collapse-h', 'aria-expanded': String(priceOpen), onClick: () => setPriceOpen(!priceOpen) },
3707
+ el('span', { className: 'cm-caret' + (priceOpen ? ' open' : '') }),
3708
+ el('h3', { className: 'cm-h' }, t('priceTableTitle'))),
3709
+ priceOpen ? el('div', { className: 'cm-collapse-body' },
3710
+ el('p', { className: 'cm-note' }, t('priceTableNote')),
3711
+ el('p', { className: 'cm-hint' }, t('priceTableDisplayHint')),
3712
+ el('div', { className: 'cm-catalog-vendor' }, t('deepseekMountedHeader')),
3713
+ priceCards,
3714
+ el(PriceCard, {
3715
+ key: '__default__', modelId: t('defaultModelId'),
3716
+ entry: draft?.prices.default ?? { cacheHit: 0, cacheMiss: 0, output: 0 },
3717
+ isDefault: true, draft, setDraft, t,
3718
+ }),
3719
+ el('div', { className: 'cm-buttons' },
3720
+ el('input', {
3721
+ className: 'cm-input narrow', type: 'text', placeholder: t('newModelPlaceholder'),
3722
+ value: newModelId,
3723
+ onChange: event => setNewModelId(event.target.value),
3724
+ }),
3725
+ el('button', { className: 'cm-btn small', onClick: addModel, disabled: newModelId.trim().length === 0 }, t('addModel'))),
3726
+ // 已挂载的第三方 provider 模型(仅逐模型勾选了「在费用设置直接显示」的条目;其余在拓展价格表内展示与编辑)
3727
+ (() => {
3728
+ const displayMap = draft?.priceTableDisplay ?? config.priceTableDisplay ?? {}
3729
+ const providers = draft?.prices?.providers ?? {}
3730
+ const directIds = p => Object.keys(providers[p]?.models ?? {}).filter(id => displayMap[p + ':' + id] === true)
3731
+ const groups = Object.keys(providers)
3732
+ .filter(p => directIds(p).length > 0)
3733
+ .sort((a, b) => a.localeCompare(b))
3734
+ if (groups.length === 0) return null
3735
+ return groups.map(p =>
3736
+ el('div', { key: p },
3737
+ el('div', { className: 'cm-catalog-vendor' }, (CATALOG_VENDOR_LABELS[p] ?? p) + ' · ' + t('mountedSuffix')),
3738
+ directIds(p).sort().map(id =>
3739
+ el(ProviderPriceCard, { key: p + ':' + id, provider: p, modelId: id, entry: providers[p].models[id], draft, setDraft, t }))))
3740
+ })())
3741
+ : null),
3742
+ // 模型名匹配(自动匹配开关 + 未命中模型的手动指定)
3743
+ (() => {
3744
+ const overrides = draft?.priceOverrides ?? config.priceOverrides ?? {}
3745
+ const pricesNow = draft?.prices ?? config.prices
3746
+ const byProvider = state.today.byProviderModel ?? {}
3747
+ // 今日出现但未精确命中价格表的 provider:model 键(含 DeepSeek 回退默认价的)。
3748
+ const unmatchedKeys = Object.keys(byProvider).filter(key => {
3749
+ const sep = key.indexOf(':')
3750
+ const provider = (sep > 0 ? key.slice(0, sep) : 'deepseek').toLowerCase()
3751
+ const modelId = sep > 0 ? key.slice(sep + 1) : key
3752
+ if (provider === '' || provider === 'deepseek' || provider.includes('deepseek')) {
3753
+ return pricesNow.models?.[modelId] === undefined
3754
+ }
3755
+ return pricesNow.providers?.[provider]?.models?.[modelId] === undefined
3756
+ })
3757
+ const rows = [...new Set([...unmatchedKeys, ...Object.keys(overrides)])]
3758
+ const targetOptions = [
3759
+ { value: 'deepseek:__default__', label: t('overrideTargetDefault') },
3760
+ ...Object.keys(pricesNow.models ?? {}).map(id => ({ value: id, label: 'DeepSeek · ' + id })),
3761
+ ...Object.entries(pricesNow.providers ?? {}).flatMap(([p, table]) =>
3762
+ Object.keys(table?.models ?? {}).map(id => ({ value: p + ':' + id, label: p + ' · ' + id }))),
3763
+ ]
3764
+ const setOverride = (key, value) => {
3765
+ if (draft === null) return
3766
+ const next = { ...(draft.priceOverrides ?? {}) }
3767
+ if (value === '') delete next[key]
3768
+ else next[key] = value
3769
+ setDraft({ ...draft, priceOverrides: next })
3770
+ }
3771
+ return el('div', null,
3772
+ el('h3', { className: 'cm-h' }, t('priceMatchLabel')),
3773
+ el('div', { className: 'cm-field' },
3774
+ el('select', {
3775
+ className: 'cm-input',
3776
+ value: draft?.priceMatch === 'exact' ? 'exact' : 'auto',
3777
+ onChange: event => setField('priceMatch', event.target.value),
3778
+ },
3779
+ el('option', { value: 'auto' }, t('priceMatchAuto')),
3780
+ el('option', { value: 'exact' }, t('priceMatchExact')))),
3781
+ el('p', { className: 'cm-note' }, t('priceMatchNote')),
3782
+ rows.length > 0
3783
+ ? el(Fragment, null,
3784
+ el('h3', { className: 'cm-h' }, t('unmatchedTitle')),
3785
+ el('p', { className: 'cm-hint' }, t('unmatchedHint')),
3786
+ rows.map(key =>
3787
+ el('div', { key, className: 'cm-match-row' },
3788
+ el('span', null, prettyProviderKey(key)),
3789
+ el('select', {
3790
+ className: 'cm-input',
3791
+ value: overrides[key] ?? '',
3792
+ onChange: event => setOverride(key, event.target.value),
3793
+ },
3794
+ el('option', { value: '' }, '—'),
3795
+ targetOptions.map(o => el('option', { key: o.value, value: o.value }, o.label))),
3796
+ overrides[key] !== undefined
3797
+ ? el('button', { className: 'cm-btn small', onClick: () => setOverride(key, '') }, t('overrideRemove'))
3798
+ : null)))
3799
+ : el('p', { className: 'cm-hint' }, t('overrideNone')))
3800
+ })(),
3801
+ // 拓展价格表(厂商/家族分类目录;挂载 ↔ 费用设置价格表)
3802
+ el(PriceCatalogPanel, { state, draft, setDraft, t }),
3803
+ // 操作
3804
+ el('div', null,
3805
+ el('h3', { className: 'cm-h' }, t('dataSync')),
3806
+ el('div', { className: 'cm-buttons' },
3807
+ saveState.status === 'saving'
3808
+ ? el('span', { className: 'cm-hint' }, t('saving'))
3809
+ : saveState.status === 'error'
3810
+ ? el('span', { className: 'cm-msg err' }, t('autoSaveFailed', { message: saveState.error ?? '' }))
3811
+ : el('span', { className: 'cm-hint' }, saveState.status === 'saved' ? t('autoSavedAt', { time: new Date(saveState.at).toLocaleTimeString() }) : t('autoSaveHint')),
3812
+ confirmFetch
3813
+ ? el(Fragment, null,
3814
+ el('span', { className: 'cm-hint' }, t('confirmFetch')),
3815
+ el('button', { className: 'cm-btn', onClick: doFetch, disabled: busy }, t('apply')),
3816
+ el('button', { className: 'cm-btn', onClick: () => setConfirmFetch(false) }, t('cancel')))
3817
+ : el('button', { className: 'cm-btn', onClick: () => setConfirmFetch(true), disabled: busy }, t('syncFromDocs')),
3818
+ confirmReset
3819
+ ? el(Fragment, null,
3820
+ el('span', { className: 'cm-hint' }, t('confirmReset')),
3821
+ el('button', { className: 'cm-btn danger', onClick: doReset, disabled: busy }, t('confirmClear')),
3822
+ el('button', { className: 'cm-btn', onClick: () => setConfirmReset(false) }, t('cancel')))
3823
+ : el('button', { className: 'cm-btn danger', onClick: () => setConfirmReset(true), disabled: busy }, t('clearAllHistory'))),
3824
+ el('p', { className: 'cm-note' },
3825
+ t('lastSync', { time: config.fetchedAt !== null ? new Date(config.fetchedAt).toLocaleString() : t('neverSynced') })
3826
+ + t('source', { source: config.priceSource === 'official' ? t('sourceOfficial') : t('sourceBundled') })),
3827
+ message !== null ? el('div', { className: 'cm-msg ' + message.kind }, message.text) : null))
3828
+ }
3829
+
3830
+ // ── 插件主体 ────────────────────────────────────────────────────────────
3831
+
3832
+ const inject = ['remote']
3833
+
3834
+ async function apply(ctx) {
3835
+ const remote = ctx.remote
3836
+ if (remote === undefined || typeof remote.$mount !== 'function') return
3837
+ const unmount = await remote.$mount(CONTRIBUTION)
3838
+ ctx.effect(() => () => { unmount() }, 'cost-meter: remote contribution')
3839
+ const costMeter = ctx.get('remote.costMeter')
3840
+ if (costMeter === undefined) return
3841
+ const store = makeStore({ status: 'loading', error: null, state: null })
3842
+
3843
+ // RPC 层错误兜底文案(按当前配置语言)。
3844
+ const rpcT = () => makeT(resolveLocale(store.getSnapshot().state?.config?.locale))
3845
+
3846
+ const call = async (method, args) => {
3847
+ const result = await costMeter[method](...(args ?? []))
3848
+ if (result === null || typeof result !== 'object' || result.ok !== true) {
3849
+ throw new Error(result?.error?.message ?? rpcT()('rpcFailed', { method }))
3850
+ }
3851
+ return result.value
3852
+ }
3853
+ let reloading = false
3854
+ const reload = async () => {
3855
+ if (reloading) return // 并发防抖:轮询/手动刷新/重连不叠加 getState,避免乱序覆盖
3856
+ reloading = true
3857
+ const prev = store.getSnapshot()
3858
+ try {
3859
+ const state = await call('getState')
3860
+ store.set({ status: 'ready', error: null, state })
3861
+ // locale=auto 始终动态跟随当前浏览器语言,不要把探测结果持久化成 en/zh。
3862
+ // 否则用户切换浏览器语言后,旧的固定配置会继续覆盖浏览器语言。
3863
+ } catch (error) {
3864
+ store.set({ status: 'error', error: error?.message ?? String(error), state: prev.state })
3865
+ } finally {
3866
+ reloading = false
3867
+ }
3868
+ }
3869
+ ctx.effect(() => ctx.on('connection/reset', () => { void reload() }), 'cost-meter: reconnect reload')
3870
+ // 侧边栏「今日费用/余额」与设置页看板依赖 getState 快照渲染,没有推送通道:
3871
+ // 60s 周期轮询(页面隐藏时跳过) + visibilitychange 重新可见时立即刷新,避免冻结在加载时刻(#3)。
3872
+ const pollTimer = setInterval(() => { if (!document.hidden) void reload() }, 60_000)
3873
+ ctx.effect(() => () => { clearInterval(pollTimer) }, 'cost-meter: poll timer')
3874
+ const onVisible = () => { if (document.visibilityState === 'visible') void reload() }
3875
+ document.addEventListener('visibilitychange', onVisible)
3876
+ ctx.effect(() => () => { document.removeEventListener('visibilitychange', onVisible) }, 'cost-meter: visibility reload')
3877
+
3878
+ const api = {
3879
+ reload,
3880
+ updateConfig: async patch => {
3881
+ const state = await call('updateConfig', [patch])
3882
+ store.set({ status: 'ready', error: null, state })
3883
+ return state
3884
+ },
3885
+ fetchPrices: async () => {
3886
+ const result = await costMeter.fetchPrices()
3887
+ if (result === null || typeof result !== 'object' || result.ok !== true) {
3888
+ throw new Error(result?.error?.message ?? rpcT()('rpcSyncFailed'))
3889
+ }
3890
+ if (result.value.state !== undefined) store.set({ status: 'ready', error: null, state: result.value.state })
3891
+ return result.value
3892
+ },
3893
+ resetHistory: async () => {
3894
+ const state = await call('resetHistory')
3895
+ store.set({ status: 'ready', error: null, state })
3896
+ return state
3897
+ },
3898
+ refreshBalance: async () => {
3899
+ const result = await costMeter.refreshBalance()
3900
+ if (result === null || typeof result !== 'object' || result.ok !== true) {
3901
+ throw new Error(result?.error?.message ?? rpcT()('rpcBalanceFailed'))
3902
+ }
3903
+ if (result.value.state !== undefined) store.set({ status: 'ready', error: null, state: result.value.state })
3904
+ return result.value
3905
+ },
3906
+ refreshGoQuota: async () => {
3907
+ const result = await costMeter.refreshGoQuota()
3908
+ if (result === null || typeof result !== 'object' || result.ok !== true) {
3909
+ throw new Error(result?.error?.message ?? rpcT()('rpcSyncFailed'))
3910
+ }
3911
+ if (result.value.state !== undefined) store.set({ status: 'ready', error: null, state: result.value.state })
3912
+ return result.value
3913
+ },
3914
+ refreshCustomBalance: async () => {
3915
+ const result = await costMeter.refreshCustomBalance()
3916
+ if (result === null || typeof result !== 'object' || result.ok !== true) {
3917
+ throw new Error(result?.error?.message ?? rpcT()('rpcSyncFailed'))
3918
+ }
3919
+ if (result.value.state !== undefined) store.set({ status: 'ready', error: null, state: result.value.state })
3920
+ return result.value
3921
+ },
3922
+ refreshCodingPlan: async provider => {
3923
+ const result = await costMeter.refreshCodingPlan(provider)
3924
+ if (result === null || typeof result !== 'object' || result.ok !== true) {
3925
+ throw new Error(result?.error?.message ?? rpcT()('rpcSyncFailed'))
3926
+ }
3927
+ if (result.value.state !== undefined) store.set({ status: 'ready', error: null, state: result.value.state })
3928
+ return result.value
3929
+ },
3930
+ }
3931
+
3932
+ void reload()
3933
+
3934
+ const slots = ctx.get('slots')
3935
+ if (slots === undefined) return
3936
+
3937
+ const injected = () => ({ hooks: { cost: store }, api })
3938
+ const sectionInjected = () => ({ hooks: { cost: store }, api })
3939
+
3940
+ // 会话徽章按配置位置注册;配置变化时先撤销旧注册再重建。
3941
+ const sessionActive = { gen: 0, dispose: null }
3942
+ const registerSession = position => {
3943
+ if (sessionActive.dispose !== null) { sessionActive.dispose(); sessionActive.dispose = null }
3944
+ sessionActive.gen += 1
3945
+ const gen = sessionActive.gen
3946
+ if (position === 'off') return
3947
+ const slotName = position === 'header' ? 'conversation.session.header.actions' : 'conversation.composer.dock'
3948
+ const options = position === 'header'
3949
+ ? { name: slotName, id: 'cost-meter', order: -5, inject: injected }
3950
+ : { name: slotName, id: 'cost-meter', order: 5, inject: injected }
3951
+ slots.inject(slotName, () => {
3952
+ if (sessionActive.gen !== gen) return
3953
+ const dispose = slots.register(options, position === 'header' ? SessionCost : DockLine)
3954
+ if (sessionActive.gen !== gen) { dispose(); return }
3955
+ sessionActive.dispose = dispose
3956
+ return () => {
3957
+ if (sessionActive.dispose === dispose) sessionActive.dispose = null
3958
+ dispose()
3959
+ }
3960
+ })
3961
+ }
3962
+ const footerActive = { gen: 0, dispose: null }
3963
+ const registerFooter = enabled => {
3964
+ if (footerActive.dispose !== null) { footerActive.dispose(); footerActive.dispose = null }
3965
+ footerActive.gen += 1
3966
+ const gen = footerActive.gen
3967
+ if (!enabled) return
3968
+ slots.inject('sidebar.footer.action', () => {
3969
+ if (footerActive.gen !== gen) return
3970
+ const dispose = slots.register({ name: 'sidebar.footer.action', id: 'cost-meter', order: 0, inject: injected }, SidebarFooter)
3971
+ if (footerActive.gen !== gen) { dispose(); return }
3972
+ footerActive.dispose = dispose
3973
+ return () => {
3974
+ if (footerActive.dispose === dispose) footerActive.dispose = null
3975
+ dispose()
3976
+ }
3977
+ })
3978
+ }
3979
+ // 右下角(dock)的 Go 额度 / 预算 chips:独立于会话费用位置,按 corner.enabled 开关。
3980
+ const cornerActive = { gen: 0, dispose: null }
3981
+ const registerCorner = enabled => {
3982
+ if (cornerActive.dispose !== null) { cornerActive.dispose(); cornerActive.dispose = null }
3983
+ cornerActive.gen += 1
3984
+ const gen = cornerActive.gen
3985
+ if (!enabled) return
3986
+ slots.inject('conversation.composer.dock', () => {
3987
+ if (cornerActive.gen !== gen) return
3988
+ const dispose = slots.register({ name: 'conversation.composer.dock', id: 'cost-meter-corner', order: 9, inject: injected }, CornerChips)
3989
+ if (cornerActive.gen !== gen) { dispose(); return }
3990
+ cornerActive.dispose = dispose
3991
+ return () => {
3992
+ if (cornerActive.dispose === dispose) cornerActive.dispose = null
3993
+ dispose()
3994
+ }
3995
+ })
3996
+ }
3997
+
3998
+ // 设置页「费用/Cost」分节:语言变化时撤销旧注册并重建,让侧边栏标签同步。
3999
+ const sectionActive = { gen: 0, dispose: null }
4000
+ const registerSection = locale => {
4001
+ if (sectionActive.dispose !== null) { sectionActive.dispose(); sectionActive.dispose = null }
4002
+ sectionActive.gen += 1
4003
+ const gen = sectionActive.gen
4004
+ slots.inject('settings.section', () => {
4005
+ if (sectionActive.gen !== gen) return
4006
+ const dispose = slots.register({
4007
+ name: 'settings.section',
4008
+ id: 'cost-meter-' + locale,
4009
+ order: 30,
4010
+ label: locale === 'en' ? MESSAGES.en.sectionLabel : MESSAGES.zh.sectionLabel,
4011
+ inject: sectionInjected,
4012
+ }, CostSection)
4013
+ if (sectionActive.gen !== gen) { dispose(); return }
4014
+ sectionActive.dispose = dispose
4015
+ return () => {
4016
+ if (sectionActive.dispose === dispose) sectionActive.dispose = null
4017
+ dispose()
4018
+ }
4019
+ })
4020
+ }
4021
+
4022
+ // Token 用量统计「通用设置」行(position = general 时,注入宿主通用设置页的 settings.general.item 插槽)。
4023
+ const generalUsageActive = { gen: 0, dispose: null }
4024
+ const registerGeneralUsage = enabled => {
4025
+ if (generalUsageActive.dispose !== null) { generalUsageActive.dispose(); generalUsageActive.dispose = null }
4026
+ generalUsageActive.gen += 1
4027
+ const gen = generalUsageActive.gen
4028
+ if (!enabled) return
4029
+ slots.inject('settings.general.item', () => {
4030
+ if (generalUsageActive.gen !== gen) return
4031
+ const dispose = slots.register({ name: 'settings.general.item', id: 'cost-meter-usage', order: 30, inject: injected }, UsagePanel)
4032
+ if (generalUsageActive.gen !== gen) { dispose(); return }
4033
+ generalUsageActive.dispose = dispose
4034
+ return () => {
4035
+ if (generalUsageActive.dispose === dispose) generalUsageActive.dispose = null
4036
+ dispose()
4037
+ }
4038
+ })
4039
+ }
4040
+ // Token 用量统计「独立分节」(position = section 时,像「费用」一样拥有自己的设置导航项)。
4041
+ const usageSectionActive = { gen: 0, dispose: null }
4042
+ const registerUsageSection = (enabled, locale) => {
4043
+ if (usageSectionActive.dispose !== null) { usageSectionActive.dispose(); usageSectionActive.dispose = null }
4044
+ usageSectionActive.gen += 1
4045
+ const gen = usageSectionActive.gen
4046
+ if (!enabled) return
4047
+ slots.inject('settings.section', () => {
4048
+ if (usageSectionActive.gen !== gen) return
4049
+ const dispose = slots.register({
4050
+ name: 'settings.section',
4051
+ id: 'cost-meter-usage',
4052
+ order: 31,
4053
+ label: locale === 'en' ? MESSAGES.en.usageSectionLabel : MESSAGES.zh.usageSectionLabel,
4054
+ inject: sectionInjected,
4055
+ }, UsagePanel)
4056
+ if (usageSectionActive.gen !== gen) { dispose(); return }
4057
+ usageSectionActive.dispose = dispose
4058
+ return () => {
4059
+ if (usageSectionActive.dispose === dispose) usageSectionActive.dispose = null
4060
+ dispose()
4061
+ }
4062
+ })
4063
+ }
4064
+
4065
+ let lastPosition = null
4066
+ let lastFooter = null
4067
+ let lastCorner = null
4068
+ let lastSectionLocale = null
4069
+ let lastUsagePosition = null
4070
+ const sync = () => {
4071
+ const state = store.getSnapshot().state
4072
+ const position = state?.config?.position ?? 'dock'
4073
+ const showToday = state?.config?.sidebar !== false
4074
+ const balanceDisplay = state?.config?.balance?.display ?? 'both'
4075
+ const showBalance = balanceDisplay === 'sidebar' || balanceDisplay === 'both'
4076
+ const footer = showToday || showBalance
4077
+ const cornerEnabled = state?.config?.corner?.enabled === true
4078
+ const sectionLocale = resolveLocale(state?.config?.locale)
4079
+ const usagePosition = state?.config?.usage?.position ?? 'cost'
4080
+ if (position !== lastPosition) {
4081
+ registerSession(position)
4082
+ lastPosition = position
4083
+ }
4084
+ if (footer !== lastFooter) {
4085
+ registerFooter(footer)
4086
+ lastFooter = footer
4087
+ }
4088
+ if (cornerEnabled !== lastCorner) {
4089
+ registerCorner(cornerEnabled)
4090
+ lastCorner = cornerEnabled
4091
+ }
4092
+ if (sectionLocale !== lastSectionLocale) {
4093
+ registerSection(sectionLocale)
4094
+ lastSectionLocale = sectionLocale
4095
+ }
4096
+ if (usagePosition !== lastUsagePosition) {
4097
+ if (USAGE_POSITION_SWITCHABLE) {
4098
+ registerGeneralUsage(usagePosition === 'general')
4099
+ registerUsageSection(usagePosition === 'section', sectionLocale)
4100
+ }
4101
+ lastUsagePosition = usagePosition
4102
+ } else if (USAGE_POSITION_SWITCHABLE && usagePosition === 'section' && sectionLocale !== lastSectionLocale) {
4103
+ registerUsageSection(true, sectionLocale)
4104
+ }
4105
+ }
4106
+ sync()
4107
+ const stopSync = store.subscribe(sync)
4108
+
4109
+ return () => { stopSync() }
4110
+ }
4111
+
4112
+ exports.apply = apply
4113
+ exports.inject = inject
4114
+ return module.exports
4115
+ },
4116
+ })