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/LICENSE +21 -0
- package/README.en.md +316 -0
- package/README.md +320 -0
- package/cordis.patch.yml +6 -0
- package/docs/provider-pricing.json +777 -0
- package/lib/backfill.js +405 -0
- package/lib/client.js +4116 -0
- package/lib/coding-plans.js +346 -0
- package/lib/custom-balance.js +147 -0
- package/lib/index.js +975 -0
- package/lib/pricing.js +799 -0
- package/lib/store.js +990 -0
- package/lib/typert.host.js +402 -0
- package/package.json +76 -0
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
|
+
})
|