@mzzsfy/dsh-usage-stats 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.js ADDED
@@ -0,0 +1,583 @@
1
+ // 用量统计 Client 半区 v2:全屏仪表盘(shell.overlay)+ 输入区费用条 + 侧边栏卡 + 设置区。
2
+ // 以 DSH client-modules 自注册格式发布:__ModuleLoader__.load({id, factory})。
3
+ // 仪表盘形态:client-modules 无插件可注册的全屏路由,按设计文档回退全屏覆盖层(shell.overlay)。
4
+
5
+ window.__ModuleLoader__.load({
6
+ id: 'dsh-usage-stats',
7
+ factory(require) {
8
+ const React = require('react')
9
+ const { useState, useEffect, useMemo } = React
10
+
11
+ const CSS = [
12
+ '.us-panel { display:flex; flex-direction:column; gap:12px; color:inherit; font-size:13px; }',
13
+ '.us-head { display:flex; align-items:center; gap:8px; }',
14
+ '.us-head__title { font-weight:600; font-size:14px; }',
15
+ '.us-head__hint { color:var(--dsw-alias-label-secondary); font-size:12px; }',
16
+ '.us-spacer { flex:1; }',
17
+ '.us-btn { cursor:pointer; border:1px solid var(--dsw-alias-separator-primary, rgba(128,128,128,0.35)); background:transparent;',
18
+ ' color:inherit; border-radius:6px; padding:3px 10px; font-size:12px; }',
19
+ '.us-btn:disabled { opacity:0.45; cursor:default; }',
20
+ '.us-btn--danger { border-color:rgba(220,80,80,0.6); }',
21
+ '.us-table { width:100%; border-collapse:collapse; font-variant-numeric:tabular-nums; }',
22
+ '.us-table th { text-align:left; font-size:12px; color:var(--dsw-alias-label-secondary); font-weight:500; padding:2px 6px;',
23
+ ' border-bottom:1px solid var(--dsw-alias-separator-primary, rgba(128,128,128,0.35)); }',
24
+ '.us-table td { font-size:12px; padding:3px 6px; }',
25
+ '.us-meta { color:var(--dsw-alias-label-secondary); font-size:12px; }',
26
+ '.us-badge { display:inline-block; font-size:11px; padding:1px 6px; border-radius:999px;',
27
+ ' border:1px solid var(--dsw-alias-separator-primary, rgba(128,128,128,0.35)); color:var(--dsw-alias-label-secondary); }',
28
+ '.us-dock { display:inline-flex; align-items:center; gap:6px; font-size:11px; line-height:1.5;',
29
+ ' color:var(--dsw-alias-label-secondary); white-space:nowrap; user-select:none; }',
30
+ '.us-dock__cost { color:inherit; font-variant-numeric:tabular-nums; }',
31
+ '.us-spark { display:inline-flex; align-items:flex-end; gap:1px; height:12px; }',
32
+ '.us-spark__bar { width:3px; background:currentColor; opacity:0.55; border-radius:1px; }',
33
+ '.us-overlay { position:fixed; inset:0; z-index:1000; background:var(--dsw-alias-background-primary, inherit);',
34
+ ' color:inherit; display:flex; flex-direction:column; padding:20px 24px; overflow:auto; box-sizing:border-box; }',
35
+ '.us-overlay__head { display:flex; align-items:center; gap:12px; margin-bottom:16px; }',
36
+ '.us-overlay__title { font-size:18px; font-weight:600; }',
37
+ '.us-hero { display:flex; gap:24px; align-items:baseline; flex-wrap:wrap; }',
38
+ '.us-hero__num { font-size:40px; font-weight:700; font-variant-numeric:tabular-nums; }',
39
+ '.us-hero__sub { color:var(--dsw-alias-label-secondary); font-size:13px; }',
40
+ '.us-kpis { display:flex; gap:16px; flex-wrap:wrap; color:var(--dsw-alias-label-secondary); font-size:12px;',
41
+ ' font-variant-numeric:tabular-nums; }',
42
+ '.us-heat { display:grid; grid-template-columns:repeat(7, 16px); gap:3px; }',
43
+ '.us-heat__cell { width:16px; height:16px; border-radius:3px;',
44
+ ' background:var(--dsw-alias-separator-primary, rgba(128,128,128,0.2)); }',
45
+ '.us-bars { display:flex; align-items:flex-end; gap:2px; height:80px; font-variant-numeric:tabular-nums; }',
46
+ '.us-bars__col { flex:1; display:flex; flex-direction:column; justify-content:flex-end; align-items:center; gap:2px; }',
47
+ '.us-bars__bar { width:70%; min-height:1px; background:var(--dsw-alias-separator-primary, rgba(128,128,128,0.6));',
48
+ ' border-radius:2px 2px 0 0; }',
49
+ '.us-edit { width:100%; min-height:120px; font-family:monospace; font-size:12px; color:inherit;',
50
+ ' background:transparent; border:1px solid var(--dsw-alias-separator-primary, rgba(128,128,128,0.35));',
51
+ ' border-radius:6px; padding:8px; box-sizing:border-box; }',
52
+ ].join('\n')
53
+
54
+ const POLL_INTERVAL_MS = 5 * 1000
55
+ const SPARK_DAYS = 7
56
+ const ID_PREFIX_LEN = 10
57
+ const STORE_KEYS = {
58
+ feeBar: 'usage-stats.feebar.show',
59
+ sidebarCard: 'usage-stats.sidebar.show',
60
+ feeBarSource: 'usage-stats.feebar.source',
61
+ }
62
+ const DEFAULT_FEE_BAR_SOURCE = '(data) => "本会话 " + data.sessionCost.toFixed(4) + " CNY · 本轮 " + data.turnCost.toFixed(4)'
63
+
64
+ async function api(path, options) {
65
+ const response = await fetch(path, {
66
+ headers: { 'content-type': 'application/json' },
67
+ ...options,
68
+ })
69
+ const payload = await response.json()
70
+ if (!response.ok) throw new Error(payload && payload.error ? payload.error : 'HTTP ' + response.status)
71
+ return payload
72
+ }
73
+
74
+ function h(type, props) {
75
+ const children = Array.prototype.slice.call(arguments, 2)
76
+ return React.createElement.apply(React, [type, props || null].concat(children))
77
+ }
78
+
79
+ function readFlag(key) {
80
+ try {
81
+ return window.localStorage.getItem(key) !== '0'
82
+ } catch {
83
+ return true
84
+ }
85
+ }
86
+
87
+ function writeFlag(key, value) {
88
+ try {
89
+ window.localStorage.setItem(key, value ? '1' : '0')
90
+ } catch {
91
+ // 展示偏好持久化失败仅影响下次默认值
92
+ }
93
+ }
94
+
95
+ function readSource() {
96
+ try {
97
+ return window.localStorage.getItem(STORE_KEYS.feeBarSource) || ''
98
+ } catch {
99
+ return ''
100
+ }
101
+ }
102
+
103
+ function writeSource(source) {
104
+ try {
105
+ window.localStorage.setItem(STORE_KEYS.feeBarSource, source)
106
+ } catch {
107
+ // 同上
108
+ }
109
+ }
110
+
111
+ // 费用条 CNY 展示口径:账本 cost 为 USD,展示层按当前汇率折 CNY。
112
+ function fmtCny(usd, rate) {
113
+ const n = Number(usd)
114
+ if (n !== n) return '—'
115
+ const cny = n * (rate || 7.2)
116
+ if (cny > 0 && cny < 0.01) return '¥' + cny.toFixed(4)
117
+ return '¥' + cny.toFixed(2)
118
+ }
119
+
120
+ function fmtTokens(n) {
121
+ const v = Number(n)
122
+ if (v !== v) return '—'
123
+ if (v >= 1000000) return (v / 1000000).toFixed(1) + 'M'
124
+ if (v >= 1000) return (v / 1000).toFixed(1) + 'k'
125
+ return String(v)
126
+ }
127
+
128
+ function fmtInt(value) {
129
+ const n = Number(value)
130
+ return n === n ? String(Math.round(n)) : '—'
131
+ }
132
+
133
+ // 首样本 turnCost 置 0 的差分口径,与 src/feebar.mjs 的 turnCostOf 保持一致。
134
+ function turnCostOf(currentCost, previousCost) {
135
+ if (previousCost === null) return 0
136
+ return Math.max(0, currentCost - previousCost)
137
+ }
138
+
139
+ // 与 src/feebar.mjs 保持一致的最小权限求值:new Function 编译函数表达式,
140
+ // 仅传入结构化快照,要求返回字符串;异常或非字符串返回回退原生渲染。
141
+ function renderFeeBar(source, data) {
142
+ try {
143
+ const factory = new Function('"use strict"; return (' + source + ');')
144
+ const fn = factory()
145
+ if (typeof fn !== 'function') return { fallback: true, error: '费用条函数编译失败' }
146
+ const text = fn(data)
147
+ if (typeof text !== 'string') return { fallback: true, error: '费用条函数返回非字符串' }
148
+ return { fallback: false, text }
149
+ } catch (error) {
150
+ return { fallback: true, error: error && error.message ? error.message : String(error) }
151
+ }
152
+ }
153
+
154
+ // 近 7 天 sparkline:高度按当日费用相对峰值归一。
155
+ function Sparkline(props) {
156
+ const costs = props.costs || []
157
+ const max = Math.max.apply(null, costs.concat([0]))
158
+ if (max <= 0) return null
159
+ return h('span', { className: 'us-spark' },
160
+ costs.map((cost, i) => h('span', {
161
+ key: i,
162
+ className: 'us-spark__bar',
163
+ style: { height: Math.max(1, Math.round((cost / max) * 12)) + 'px' },
164
+ })),
165
+ )
166
+ }
167
+
168
+ // 输入区费用条:本轮费用 + 会话累计 + 近 7 天 sparkline;支持自定义 JS。
169
+ function FeeBar(props) {
170
+ const [session, setSession] = useState(null)
171
+ const [summary, setSummary] = useState(null)
172
+ const sessionId = props && props.sessionId ? props.sessionId : ''
173
+
174
+ useEffect(() => {
175
+ if (!sessionId) return undefined
176
+ let alive = true
177
+ let lastCost = null
178
+ function load() {
179
+ api('/api/usage-stats/session', { method: 'POST', body: JSON.stringify({ sessionId }) })
180
+ .then((res) => {
181
+ if (!alive) return
182
+ res.turnCost = turnCostOf(res.cost, lastCost)
183
+ lastCost = res.cost
184
+ setSession(res)
185
+ })
186
+ .catch(() => {
187
+ // 本地统计缺失时费用条静默隐藏
188
+ })
189
+ }
190
+ load()
191
+ const timer = setInterval(load, POLL_INTERVAL_MS)
192
+ return () => {
193
+ alive = false
194
+ clearInterval(timer)
195
+ }
196
+ }, [sessionId])
197
+
198
+ useEffect(() => {
199
+ let alive = true
200
+ api('/api/usage-stats/summary')
201
+ .then((res) => { if (alive) setSummary(res) })
202
+ .catch(() => {
203
+ // 趋势缺失时仅显示会话读数
204
+ })
205
+ return () => {
206
+ alive = false
207
+ }
208
+ }, [])
209
+
210
+ const source = readSource()
211
+ if (!session || (!session.calls && !session.outputTokens)) return null
212
+ const daily = summary && summary.summary && summary.summary.recentDays
213
+ ? summary.summary.recentDays.slice(0, SPARK_DAYS).map((day) => day.cost)
214
+ : []
215
+ const rate = summary && summary.rate ? summary.rate.rate : null
216
+ const snapshot = {
217
+ turnCost: session.turnCost,
218
+ sessionCost: session.cost,
219
+ sessionTokens: session.inputTokens + session.cacheReadTokens + session.outputTokens,
220
+ recentDailyCosts: daily,
221
+ }
222
+ if (source.trim().length > 0) {
223
+ const rendered = renderFeeBar(source, snapshot)
224
+ if (!rendered.fallback) return h('span', { className: 'us-dock' }, rendered.text)
225
+ // 失败回退原生渲染,错误在设置区可见(此处静默)
226
+ }
227
+ return h('span', {
228
+ className: 'us-dock',
229
+ title: '本轮 ' + fmtCny(snapshot.turnCost, rate) + ' · 输入 ' + fmtTokens(session.inputTokens) +
230
+ ' · 缓存 ' + fmtTokens(session.cacheReadTokens) + ' · 输出 ' + fmtTokens(session.outputTokens),
231
+ },
232
+ h('span', { className: 'us-dock__cost' }, '本会话 ' + fmtCny(session.cost, rate)),
233
+ h('span', null, fmtTokens(snapshot.sessionTokens) + ' tok'),
234
+ h(Sparkline, { costs: daily }),
235
+ )
236
+ }
237
+
238
+ // 侧边栏触发卡:本月费用主数字 + 今日/本周副行(sidebar.footer.action,root 作用域)。
239
+ function SidebarCard() {
240
+ const [data, setData] = useState(null)
241
+ useEffect(() => {
242
+ let alive = true
243
+ function load() {
244
+ api('/api/usage-stats/summary')
245
+ .then((res) => { if (alive) setData(res) })
246
+ .catch(() => {
247
+ // 静默
248
+ })
249
+ }
250
+ load()
251
+ const timer = setInterval(load, POLL_INTERVAL_MS * 6)
252
+ return () => {
253
+ alive = false
254
+ clearInterval(timer)
255
+ }
256
+ }, [])
257
+ if (!data || !data.rate) return null
258
+ const s = data.summary
259
+ const rate = data.rate.rate
260
+ return h('div', { style: { fontSize: '12px', padding: '4px 2px', userSelect: 'none' } },
261
+ h('div', { style: { fontVariantNumeric: 'tabular-nums', fontWeight: 600 } }, fmtCny(s.month.cost, rate)),
262
+ h('div', { style: { color: 'var(--dsw-alias-label-secondary)' } },
263
+ '今日 ' + fmtCny(s.today.cost, rate)),
264
+ )
265
+ }
266
+
267
+ // 设置区自定义单价表:结构化条目(模型 id + 三栏单价 + 币种),经 /prices 通道持久化到 settings。
268
+ function PriceTable(props) {
269
+ const [rows, setRows] = useState(props.rows)
270
+ const [hint, setHint] = useState('')
271
+ function save(next) {
272
+ setRows(next)
273
+ api('/api/usage-stats/prices', { method: 'POST', body: JSON.stringify({ customPrices: next }) })
274
+ .then(() => setHint('已保存'))
275
+ .catch((err) => setHint('保存失败:' + (err && err.message ? err.message : String(err))))
276
+ }
277
+ function edit(i, field, value) {
278
+ const next = rows.map((row, j) => (j === i ? Object.assign({}, row, { [field]: value }) : row))
279
+ setRows(next)
280
+ }
281
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '6px' } },
282
+ h('table', { className: 'us-table' },
283
+ h('thead', null, h('tr', null,
284
+ h('th', null, '模型 id'), h('th', null, '输入'), h('th', null, '缓存命中'), h('th', null, '输出'),
285
+ h('th', null, '币种'), h('th', null, ''))),
286
+ h('tbody', null, rows.map((row, i) =>
287
+ h('tr', { key: i },
288
+ ['model', 'input', 'cacheRead', 'output'].map((field) =>
289
+ h('td', { key: field },
290
+ h('input', {
291
+ value: row[field] === null || row[field] === undefined ? '' : String(row[field]),
292
+ onChange: (e) => edit(i, field, e.target.value),
293
+ style: { width: '100%', background: 'transparent', color: 'inherit', border: '1px solid rgba(128,128,128,0.3)', borderRadius: '4px' },
294
+ }))),
295
+ h('td', null,
296
+ h('select', {
297
+ value: row.currency || 'USD',
298
+ onChange: (e) => edit(i, 'currency', e.target.value),
299
+ style: { background: 'transparent', color: 'inherit' },
300
+ }, h('option', { value: 'USD' }, 'USD'), h('option', { value: 'CNY' }, 'CNY'))),
301
+ h('td', null, h('button', { className: 'us-btn', onClick: () => save(rows.filter((_, j) => j !== i)) }, '删')),
302
+ )))),
303
+ h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center' } },
304
+ h('button', {
305
+ className: 'us-btn',
306
+ onClick: () => save(rows.concat([{ model: '', input: '', cacheRead: '', output: '', currency: 'CNY' }])),
307
+ }, '添加条目'),
308
+ h('button', { className: 'us-btn', onClick: () => save(rows) }, '保存'),
309
+ h('span', { className: 'us-meta' }, hint || '命中顺序:provider/model 精确 -> 模型名精确 -> 最长前缀 -> 目录价'),
310
+ ),
311
+ )
312
+ }
313
+
314
+ // 设置区「费用条自定义」:多行编辑框 + 恢复默认 + 试运行(样例数据即时回显错误)。
315
+ function FeeBarEditor() {
316
+ const [source, setSource] = useState(readSource())
317
+ const [result, setResult] = useState('')
318
+ const SAMPLE = { turnCost: 0.01, sessionCost: 0.5, sessionTokens: 12345, recentDailyCosts: [0.1, 0.2, 0, 0.3, 0.15, 0.05, 0.5] }
319
+ function trial() {
320
+ if (source.trim().length === 0) {
321
+ setResult('未配置,使用原生渲染')
322
+ return
323
+ }
324
+ const rendered = renderFeeBar(source, SAMPLE)
325
+ setResult(rendered.fallback ? '错误:' + rendered.error : '输出:' + rendered.text)
326
+ }
327
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '6px' } },
328
+ h('textarea', { className: 'us-edit', value: source, onChange: (e) => setSource(e.target.value),
329
+ placeholder: '(data) => "本会话 " + data.sessionCost' }),
330
+ h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center' } },
331
+ h('button', { className: 'us-btn', onClick: () => { writeSource(source); setResult('已保存') } }, '保存'),
332
+ h('button', { className: 'us-btn', onClick: () => { writeSource(''); setSource(''); setResult('已恢复默认') } }, '恢复默认'),
333
+ h('button', { className: 'us-btn', onClick: trial }, '试运行'),
334
+ h('span', { className: 'us-meta' }, result),
335
+ ),
336
+ h('span', { className: 'us-meta' },
337
+ '入参:data.turnCost / sessionCost / sessionTokens / recentDailyCosts(近 7 天);须返回字符串。请勿粘贴来源不明的代码。'),
338
+ )
339
+ }
340
+
341
+ function ToggleRow(props) {
342
+ return h('label', { style: { display: 'flex', gap: '6px', alignItems: 'center', cursor: 'pointer' } },
343
+ h('input', { type: 'checkbox', checked: props.value, onChange: (e) => props.onChange(e.target.checked) }),
344
+ props.label,
345
+ )
346
+ }
347
+
348
+ // 设置区:仪表盘入口 / 开关 / 单价表 / JS 编辑 / 导出 / 两段式清零。
349
+ function SettingsPanel(props) {
350
+ const [customPrices, setCustomPrices] = useState([])
351
+ const [rate, setRate] = useState(null)
352
+ const [resetArmed, setResetArmed] = useState(false)
353
+ const [hint, setHint] = useState('')
354
+ const [feeBarShow, setFeeBarShow] = useState(readFlag(STORE_KEYS.feeBar))
355
+ const [sidebarShow, setSidebarShow] = useState(readFlag(STORE_KEYS.sidebar))
356
+ const openDashboard = props && props.openDashboard
357
+
358
+ useEffect(() => {
359
+ api('/api/usage-stats/prices')
360
+ .then((res) => setCustomPrices(res.customPrices || []))
361
+ .catch(() => {
362
+ // 单价表读取失败保持为空表
363
+ })
364
+ api('/api/usage-stats/summary')
365
+ .then((res) => setRate(res.rate))
366
+ .catch(() => {
367
+ // 汇率标注缺失仅影响提示文案
368
+ })
369
+ }, [])
370
+
371
+ function reset() {
372
+ if (!resetArmed) {
373
+ setResetArmed(true)
374
+ setHint('再次点击确认清零,不可恢复')
375
+ return
376
+ }
377
+ api('/api/usage-stats/reset', { method: 'POST', body: '{}' })
378
+ .then(() => {
379
+ setResetArmed(false)
380
+ setHint('账本已清零')
381
+ })
382
+ .catch((err) => setHint('清零失败:' + (err && err.message ? err.message : String(err))))
383
+ }
384
+
385
+ return h('div', { className: 'us-panel' },
386
+ h('style', { dangerouslySetInnerHTML: { __html: CSS } }),
387
+ h('div', { className: 'us-head' },
388
+ h('span', { className: 'us-head__title' }, '用量统计'),
389
+ h('span', { className: 'us-spacer' }),
390
+ h('button', { className: 'us-btn', onClick: openDashboard }, '打开仪表盘'),
391
+ ),
392
+ h('div', { style: { display: 'flex', gap: '16px', flexWrap: 'wrap' } },
393
+ h(ToggleRow, {
394
+ label: '显示输入区费用条',
395
+ value: feeBarShow,
396
+ onChange: (v) => { writeFlag(STORE_KEYS.feeBar, v); setFeeBarShow(v) },
397
+ }),
398
+ h(ToggleRow, {
399
+ label: '显示侧边栏费用卡',
400
+ value: sidebarShow,
401
+ onChange: (v) => { writeFlag(STORE_KEYS.sidebar, v); setSidebarShow(v) },
402
+ }),
403
+ ),
404
+ rate ? h('span', { className: 'us-meta' },
405
+ 'USD->CNY 汇率 ' + rate.rate + (rate.stale ? '(非实时,沿用上次汇率)' : '') +
406
+ (rate.fetchedAt > 0 ? ',更新于 ' + new Date(rate.fetchedAt).toLocaleString() : '')) : null,
407
+ h('span', { className: 'us-head__title' }, '自定义模型单价'),
408
+ h(PriceTable, { rows: customPrices }),
409
+ h('span', { className: 'us-head__title' }, '费用条自定义'),
410
+ h(FeeBarEditor, null),
411
+ h('span', { className: 'us-head__title' }, '导出'),
412
+ h('div', { style: { display: 'flex', gap: '8px' } },
413
+ h('a', { className: 'us-btn', href: '/api/usage-stats/export?kind=days', style: { textDecoration: 'none' } }, '按日 CSV'),
414
+ h('a', { className: 'us-btn', href: '/api/usage-stats/export?kind=sessions', style: { textDecoration: 'none' } }, '按会话 CSV'),
415
+ h('a', { className: 'us-btn', href: '/api/usage-stats/export?kind=json', style: { textDecoration: 'none' } }, '全量 JSON'),
416
+ ),
417
+ h('span', { className: 'us-head__title' }, '维护'),
418
+ h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center' } },
419
+ h('button', { className: 'us-btn us-btn--danger', onClick: reset }, resetArmed ? '确认清零账本' : '清零账本'),
420
+ h('span', { className: 'us-meta' }, hint),
421
+ ),
422
+ )
423
+ }
424
+
425
+ // 趋势柱状图:费用 / Token 双视角,无数据日期不留柱。
426
+ function TrendChart(props) {
427
+ const points = props.points || []
428
+ const max = Math.max.apply(null, points.map((p) => (props.metric === 'cost' ? p.cost || 0 : p.tokens)))
429
+ if (max <= 0) return h('span', { className: 'us-meta' }, '所选范围无数据')
430
+ return h('div', { className: 'us-bars' },
431
+ points.map((p) => {
432
+ const value = props.metric === 'cost' ? p.cost : p.tokens
433
+ return h('div', {
434
+ key: p.date,
435
+ className: 'us-bars__col',
436
+ title: p.date + ' · ' + (p.cost === null ? '无数据' : '¥' + (p.cost * props.rate).toFixed(2) + ' · ' + fmtInt(p.calls) + ' 次'),
437
+ },
438
+ h('div', { className: 'us-bars__bar', style: value === null || value === undefined ? { height: '1px', opacity: 0.2 } : { height: Math.max(1, Math.round((value / max) * 70)) + 'px' } }),
439
+ )
440
+ }),
441
+ )
442
+ }
443
+
444
+ function Heatmap(props) {
445
+ const grid = props.grid
446
+ const TIER_OPACITY = [0.15, 0.4, 0.65, 1]
447
+ return h('div', { className: 'us-heat' },
448
+ grid.days.map((day) => h('div', {
449
+ key: day.date,
450
+ className: 'us-heat__cell',
451
+ title: day.cost === null ? day.date + ' · 无数据' : day.date + ' · ¥' + (day.cost * props.rate).toFixed(2) + ' · ' + fmtInt(day.calls) + ' 次',
452
+ style: day.cost !== null ? { opacity: TIER_OPACITY[day.tier] } : null,
453
+ })),
454
+ )
455
+ }
456
+
457
+ // 全屏仪表盘(shell.overlay 覆盖层):概览 / 趋势 / 热力图 / 明细 / 导出。
458
+ function Dashboard(props) {
459
+ const [data, setData] = useState(null)
460
+ const [error, setError] = useState(null)
461
+ const [range, setRange] = useState(30)
462
+ const [metric, setMetric] = useState('cost')
463
+ useEffect(() => {
464
+ api('/api/usage-stats/dashboard')
465
+ .then(setData)
466
+ .catch((err) => setError(err && err.message ? err.message : String(err)))
467
+ }, [])
468
+ const rate = data && data.dashboard ? data.dashboard.rate.rate : null
469
+ const points = useMemo(() => {
470
+ if (!data || !data.dashboard) return []
471
+ return range === 7 ? data.dashboard.trend7 : data.dashboard.trend30
472
+ }, [data, range])
473
+ return h('div', { className: 'us-overlay' },
474
+ h('style', { dangerouslySetInnerHTML: { __html: CSS } }),
475
+ h('div', { className: 'us-overlay__head' },
476
+ h('span', { className: 'us-overlay__title' }, '用量统计仪表盘'),
477
+ h('span', { className: 'us-spacer' }),
478
+ h('a', { className: 'us-btn', href: '/api/usage-stats/export?kind=days' }, '按日 CSV'),
479
+ h('a', { className: 'us-btn', href: '/api/usage-stats/export?kind=sessions' }, '按会话 CSV'),
480
+ h('a', { className: 'us-btn', href: '/api/usage-stats/export?kind=json' }, '全量 JSON'),
481
+ h('button', { className: 'us-btn', onClick: props.onClose }, '关闭'),
482
+ ),
483
+ error !== null ? h('span', { className: 'us-meta' }, '读取失败:' + error) : null,
484
+ data === null && error === null ? h('span', { className: 'us-meta' }, '加载中…') : null,
485
+ data && data.dashboard ? (function () {
486
+ const dash = data.dashboard
487
+ const o = dash.overview
488
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '20px' } },
489
+ h('div', { className: 'us-hero' },
490
+ h('div', null,
491
+ h('div', { className: 'us-hero__num' }, fmtCny(o.month.cost, rate)),
492
+ h('div', { className: 'us-hero__sub' }, '本月费用(估算,非账单)')),
493
+ h('div', { className: 'us-hero__sub' }, '本月预计 ¥' + (o.projection * (rate || 0)).toFixed(2)),
494
+ h('div', { className: 'us-hero__sub' }, '今日环比 ' + (o.todayRatio === null ? '—' : (o.todayRatio * 100).toFixed(0) + '%')),
495
+ h('div', { className: 'us-hero__sub' }, '本周环比 ' + (o.weekRatio === null ? '—' : (o.weekRatio * 100).toFixed(0) + '%')),
496
+ ),
497
+ h('div', { className: 'us-kpis' },
498
+ h('span', null, '输入 ' + fmtTokens(o.month.inputTokens)),
499
+ h('span', null, '缓存命中 ' + fmtTokens(o.month.cacheReadTokens)),
500
+ h('span', null, '输出 ' + fmtTokens(o.month.outputTokens)),
501
+ h('span', null, fmtInt(o.month.calls) + ' 次调用'),
502
+ h('span', null, '汇率 ' + dash.rate.rate + (dash.rate.stale ? '(非实时)' : '')),
503
+ ),
504
+ h('div', null,
505
+ h('span', { className: 'us-badge' }, '趋势'),
506
+ h('span', { className: 'us-spacer' }),
507
+ h('div', { style: { display: 'flex', gap: '8px', margin: '6px 0' } },
508
+ h('button', { className: 'us-btn', onClick: () => setRange(7) }, '近 7 天'),
509
+ h('button', { className: 'us-btn', onClick: () => setRange(30) }, '近 30 天'),
510
+ h('button', { className: 'us-btn', onClick: () => setMetric('cost') }, '费用'),
511
+ h('button', { className: 'us-btn', onClick: () => setMetric('tokens') }, 'Token'),
512
+ ),
513
+ h(TrendChart, { points, metric, rate }),
514
+ ),
515
+ h('div', null,
516
+ h('span', { className: 'us-badge' }, dash.heatmap.month + ' 热力图'),
517
+ h('div', { style: { marginTop: '8px' } }, h(Heatmap, { grid: dash.heatmap, rate })),
518
+ ),
519
+ h('div', null,
520
+ h('span', { className: 'us-badge' }, '会话明细' + (data.totalSessions > dash.sessions.length ? '(前 ' + dash.sessions.length + ')' : '')),
521
+ data.totalSessions === 0 ? h('div', { className: 'us-meta', style: { marginTop: '6px' } }, '会话明细自账本 v2 启用起累积') : null,
522
+ h('table', { className: 'us-table', style: { marginTop: '6px' } },
523
+ h('thead', null, h('tr', null,
524
+ h('th', null, '会话'), h('th', null, '最后活跃'), h('th', null, '调用'),
525
+ h('th', null, 'Token'), h('th', null, '费用(折 CNY)'))),
526
+ h('tbody', null, dash.sessions.map((row) =>
527
+ h('tr', { key: row.sessionId },
528
+ h('td', null, row.title || row.sessionId.slice(0, ID_PREFIX_LEN) + '…'),
529
+ h('td', null, new Date(row.lastAt).toLocaleString()),
530
+ h('td', null, fmtInt(row.calls)),
531
+ h('td', null, fmtTokens(row.inputTokens + row.cacheReadTokens + row.outputTokens)),
532
+ h('td', null, fmtCny(row.cost, rate)),
533
+ ))),
534
+ ),
535
+ ),
536
+ )
537
+ })() : null,
538
+ )
539
+ }
540
+
541
+ function registerClient(ctx) {
542
+ let setDashboardOpen = null
543
+ ctx.slots.inject('shell.overlay', () =>
544
+ ctx.slots.register(
545
+ { name: 'shell.overlay', id: 'usage-stats-dashboard', order: 90 },
546
+ function DashboardSlot() {
547
+ const [open, setOpen] = useState(false)
548
+ setDashboardOpen = setOpen
549
+ if (!open) return null
550
+ return React.createElement(Dashboard, { onClose: () => setOpen(false) })
551
+ },
552
+ ))
553
+ ctx.slots.inject('settings.section', () =>
554
+ ctx.slots.register(
555
+ { name: 'settings.section', id: 'usage-stats-panel', order: 41, label: '用量统计' },
556
+ () => React.createElement(SettingsPanel, { openDashboard: () => setDashboardOpen && setDashboardOpen(true) }),
557
+ ))
558
+ ctx.slots.inject('conversation.composer.dock', () =>
559
+ ctx.slots.register(
560
+ { name: 'conversation.composer.dock', id: 'usage-stats-badge', order: 12, label: '会话费用' },
561
+ function FeeBarSlot(props) {
562
+ if (!readFlag(STORE_KEYS.feeBar)) return null
563
+ return React.createElement(FeeBar, { sessionId: props && props.sessionId })
564
+ },
565
+ ))
566
+ ctx.slots.inject('sidebar.footer.action', () =>
567
+ ctx.slots.register(
568
+ { name: 'sidebar.footer.action', id: 'usage-stats-sidebar-card', order: 90 },
569
+ function SidebarSlot() {
570
+ if (!readFlag(STORE_KEYS.sidebar)) return null
571
+ return React.createElement(SidebarCard)
572
+ },
573
+ ))
574
+ }
575
+
576
+ return {
577
+ inject: ['slots'],
578
+ apply(ctx) {
579
+ registerClient(ctx)
580
+ },
581
+ }
582
+ },
583
+ })
package/src/csv.mjs ADDED
@@ -0,0 +1,24 @@
1
+ // CSV 序列化:防公式注入 + 全字符集转义,纯函数。
2
+
3
+ // 以公式起始符或制表/回车前缀开头的单元格前置单引号(OWASP CSV 注入防护),阻断表格软件公式求值。
4
+ const FORMULA_PREFIX = /^[=+\-@\t\r]/
5
+ const NEEDS_QUOTING = /[",\r\n]/
6
+ const SEPARATOR = ','
7
+ const LINE_END = '\r\n'
8
+ const CSV_BOM = ''
9
+
10
+ export function csvCell(value) {
11
+ let text = value === null || value === undefined ? '' : String(value)
12
+ if (FORMULA_PREFIX.test(text)) text = "'" + text
13
+ if (NEEDS_QUOTING.test(text)) text = '"' + text.replace(/"/g, '""') + '"'
14
+ return text
15
+ }
16
+
17
+ export function csvRow(values) {
18
+ return values.map(csvCell).join(SEPARATOR)
19
+ }
20
+
21
+ // BOM 保证表格软件按 UTF-8 解析,不串列不乱码。
22
+ export function toCsv(rows) {
23
+ return CSV_BOM + rows.map(csvRow).join(LINE_END)
24
+ }