dsh-remote-plugin 0.5.7-rc.1 → 0.5.7

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.
Binary file
package/gateway-stats.cjs CHANGED
@@ -47,6 +47,8 @@ const BUCKET_PRICE_KEY = {
47
47
  }
48
48
 
49
49
  const BJ_OFFSET_MS = 8 * 3600 * 1000
50
+ // 定价生效日(北京时间): 2026-08-17 零点更新, 此前的 token 不计入费用统计
51
+ const PRICING_START_DATE = '2026-08-17'
50
52
  const DAYS_DIR = 'days'
51
53
  const CURSORS_FILE = 'cursors.json'
52
54
 
@@ -245,6 +247,7 @@ class StatsStore {
245
247
 
246
248
  const model = eventModel(event) || fallbackModel || 'unknown'
247
249
  const { date, hour, period } = eventKey(time)
250
+ if (date < PRICING_START_DATE) return { processed: false, gap: false, skip: true }
248
251
  const day = this._loadDay(date)
249
252
  const hourBucket = day.hours[hour] || (day.hours[hour] = {})
250
253
  const modelBucket = hourBucket[model] || (hourBucket[model] = emptyBucket())
@@ -313,12 +316,14 @@ class StatsStore {
313
316
  if (usage) {
314
317
  const model = eventModel(event) || currentModel || 'unknown'
315
318
  const { date, hour, period } = eventKey(event.time)
316
- const day = this._loadDay(date)
317
- const hourBucket = day.hours[hour] || (day.hours[hour] = {})
318
- const modelBucket = hourBucket[model] || (hourBucket[model] = emptyBucket())
319
- addUsage(modelBucket, model, period, usage)
320
- this._saveDay(day)
321
- processed++
319
+ if (date >= PRICING_START_DATE) {
320
+ const day = this._loadDay(date)
321
+ const hourBucket = day.hours[hour] || (day.hours[hour] = {})
322
+ const modelBucket = hourBucket[model] || (hourBucket[model] = emptyBucket())
323
+ addUsage(modelBucket, model, period, usage)
324
+ this._saveDay(day)
325
+ processed++
326
+ }
322
327
  }
323
328
  }
324
329
  lastSeq = event.seq
@@ -362,10 +367,16 @@ class StatsStore {
362
367
  summary(days) {
363
368
  const n = Math.max(1, Math.min(Number(days) || 7, 90))
364
369
  const out = []
365
- for (let i = n - 1; i >= 0; i--) {
366
- const d = new Date(Date.now() + BJ_OFFSET_MS)
367
- d.setUTCDate(d.getUTCDate() - i)
368
- const date = `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`
370
+ // 查询窗口起点: 不早于定价生效日(生效日之前不统计)
371
+ const today = beijingDate(Date.now())
372
+ const startD = new Date(Date.now() + BJ_OFFSET_MS)
373
+ startD.setUTCDate(startD.getUTCDate() - (n - 1))
374
+ const windowStart = `${startD.getUTCFullYear()}-${pad2(startD.getUTCMonth() + 1)}-${pad2(startD.getUTCDate())}`
375
+ const first = windowStart > PRICING_START_DATE ? windowStart : PRICING_START_DATE
376
+ const [fy, fm, fd] = first.split('-').map(Number)
377
+ for (let cur = Date.UTC(fy, fm - 1, fd); ; cur += 86400_000) {
378
+ const date = beijingDate(cur)
379
+ if (date > today) break
369
380
  const day = this._loadDay(date)
370
381
  const s = dayTotals(day)
371
382
  const byModel = {}
@@ -383,7 +394,9 @@ class StatsStore {
383
394
  }
384
395
 
385
396
  detail(date) {
386
- const day = this._loadDay(date)
397
+ // 生效日前不返回统计(页面会展示空态与生效日提示)
398
+ const effective = date >= PRICING_START_DATE ? date : PRICING_START_DATE
399
+ const day = this._loadDay(effective)
387
400
  const hours = []
388
401
  for (let hour = 0; hour < 24; hour++) {
389
402
  const models = day.hours[hour] || {}
@@ -391,12 +404,13 @@ class StatsStore {
391
404
  for (const bucket of Object.values(models)) mergeBucket(total, bucket)
392
405
  hours.push({ hour, period: periodOfHour(hour), models, total })
393
406
  }
394
- return { date, hours }
407
+ return { date: effective, pricingStart: PRICING_START_DATE, hours }
395
408
  }
396
409
  }
397
410
 
398
411
  module.exports = {
399
412
  BJ_OFFSET_MS,
413
+ PRICING_START_DATE,
400
414
  PEAK_HOURS,
401
415
  PRICES,
402
416
  beijingHour,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-plugin",
3
- "version": "0.5.7-rc.1",
3
+ "version": "0.5.7",
4
4
  "description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
package/public/admin.html CHANGED
@@ -20,7 +20,16 @@
20
20
  .stat-card.ok .v { color: var(--dsr-success); } .stat-card.warn .v { color: var(--dsr-warning); }
21
21
  .stats-dash { margin-bottom: 14px; }
22
22
  .stats-dash .stat-grid { margin-bottom: 8px; }
23
- .stats-chart { display: flex; align-items: flex-end; gap: 8px; height: 150px; padding: 12px 14px 10px; background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: var(--dsr-radius); overflow-x: auto; overflow-y: hidden; }
23
+ .stats-chart { display: flex; align-items: flex-end; gap: 8px; height: 180px; padding: 12px 14px 10px; background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: var(--dsr-radius); overflow-x: auto; overflow-y: hidden; }
24
+ .stats-legend { display: flex; gap: 12px; font-size: 11px; color: var(--dsr-muted); margin: 6px 2px 0; }
25
+ .stats-legend .lg { display: inline-flex; align-items: center; gap: 4px; }
26
+ .stats-legend .sw { width: 9px; height: 9px; border-radius: 2px; display: inline-block; }
27
+ .stats-legend .sw.peak { background: var(--dsr-accent-strong); }
28
+ .stats-legend .sw.off { background: var(--dsr-accent-2); }
29
+ .bucket-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 3px 12px; margin-top: 6px; }
30
+ .bucket-grid .b { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; font-size: 11px; min-width: 0; }
31
+ .bucket-grid .b .n { color: var(--dsr-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
32
+ .bucket-grid .b .t { font-weight: 600; white-space: nowrap; }
24
33
  .stats-bar { flex: 1; min-width: 38px; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; gap: 5px; }
25
34
  .stats-bar .bars { width: 100%; max-width: 46px; height: 100%; display: flex; flex-direction: column; justify-content: flex-end; border-radius: 6px 6px 0 0; overflow: hidden; background: var(--dsr-accent-soft); }
26
35
  .stats-bar .seg { width: 100%; }
@@ -29,6 +38,7 @@
29
38
  .stats-bar .lbl { font-size: 10px; color: var(--dsr-muted); white-space: nowrap; }
30
39
  .stats-bar .val { font-size: 10px; color: var(--dsr-text); white-space: nowrap; }
31
40
  .stats-empty { color: var(--dsr-muted); text-align: center; padding: 20px 0; font-size: 12px; }
41
+ .stats-note { color: var(--dsr-muted); font-size: 11px; line-height: 1.6; margin-top: 8px; }
32
42
  .table-wrap { background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: var(--dsr-radius); overflow-x: auto; }
33
43
  table { width: 100%; border-collapse: collapse; font-size: 13px; min-width: 0; }
34
44
  th, td { text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--dsr-line); vertical-align: top; }
@@ -158,7 +168,9 @@
158
168
  <div class="stats-dash">
159
169
  <div class="section-head"><span data-i18n="stats.title">Token 统计</span><span id="stats-sub" class="muted"></span></div>
160
170
  <div class="stat-grid" id="stats-cards"></div>
171
+ <div id="stats-legend" class="stats-legend"></div>
161
172
  <div id="stats-chart" class="stats-chart"></div>
173
+ <div id="stats-note" class="stats-note"></div>
162
174
  </div>
163
175
 
164
176
  <div class="section-head"><span data-i18n="devices">已连接设备</span><span id="device-summary" class="muted"></span></div>
@@ -237,6 +249,7 @@
237
249
  'stats.peak': '高峰', 'stats.off': '空闲',
238
250
  'stats.days': '近 {n} 天',
239
251
  'stats.gatewayDown': '统计需要 8787 网关运行', 'stats.empty': '暂无统计,产生会话后自动聚合',
252
+ 'stats.note': '注:本数据仅在使用 DeepSeek 官方 API 时估算;基于 token 计算,与官网账单可能有出入,一切以官网为准。统计自 2026-08-17 定价生效日起。',
240
253
  'unit.sec': ' 秒', 'unit.min': ' 分钟', 'unit.hour': ' 小时 ', 'unit.minShort': ' 分', 'unit.day': ' 天 ',
241
254
  'device.installedNotRunning': '网关已安装 · 当前未运行', 'device.noGatewayBinary': '未检测到网关程序',
242
255
  'device.ipRefresh': '{n} 个 IP · 每 5 秒刷新',
@@ -308,6 +321,7 @@
308
321
  'stats.peak': 'Peak', 'stats.off': 'Off-peak',
309
322
  'stats.days': 'Last {n} days',
310
323
  'stats.gatewayDown': 'Stats require the gateway on 8787', 'stats.empty': 'No stats yet — they aggregate as sessions happen',
324
+ 'stats.note': 'Note: estimates assume the official DeepSeek API. Token-based calculation may differ from the official bill; always defer to deepseek.com. Stats start from the 2026-08-17 pricing date.',
311
325
  'unit.sec': 's', 'unit.min': 'min', 'unit.hour': 'h ', 'unit.minShort': 'm', 'unit.day': 'd ',
312
326
  'device.installedNotRunning': 'Gateway installed · not running', 'device.noGatewayBinary': 'Gateway binary not found',
313
327
  'device.ipRefresh': '{n} IPs · refreshed every 5s',
package/public/admin.js CHANGED
@@ -59,6 +59,8 @@ async function loadStats() {
59
59
  $('stats-cards').innerHTML = ''
60
60
  $('stats-chart').innerHTML = `<div class="stats-empty">${t('stats.gatewayDown')}</div>`
61
61
  $('stats-sub').textContent = ''
62
+ $('stats-note').textContent = ''
63
+ $('stats-legend').innerHTML = ''
62
64
  }
63
65
  }
64
66
 
@@ -67,6 +69,8 @@ function renderStats(days) {
67
69
  $('stats-cards').innerHTML = ''
68
70
  $('stats-chart').innerHTML = `<div class="stats-empty">${t('stats.empty')}</div>`
69
71
  $('stats-sub').textContent = ''
72
+ $('stats-note').textContent = t('stats.note')
73
+ $('stats-legend').innerHTML = ''
70
74
  return
71
75
  }
72
76
  const today = days[days.length - 1]
@@ -76,21 +80,29 @@ function renderStats(days) {
76
80
  const totalCost = peakCost + offCost
77
81
  const peakShare = totalCost > 0 ? Math.round(peakCost / totalCost * 100) : 0
78
82
  $('stats-cards').innerHTML = `
79
- <div class="stat-card"><div class="v">${fmtTokens(totalTokens)}</div><div class="k">${t('stats.todayTokens')} · ${t('stats.input')} ${fmtTokens(today.total.input)} · ${t('stats.cacheRead')} ${fmtTokens(today.total.cacheRead)} · ${t('stats.cacheWrite')} ${fmtTokens(today.total.cacheWrite)} · ${t('stats.output')} ${fmtTokens(today.total.output)}</div></div>
83
+ <div class="stat-card"><div class="v">${fmtTokens(totalTokens)} <span style="font-size:12px;font-weight:500;color:var(--dsr-muted)">${t('stats.todayTokens')}</span></div>
84
+ <div class="bucket-grid">
85
+ <div class="b"><span class="n">${t('stats.input')}</span><span class="t">${fmtTokens(today.total.input)}</span></div>
86
+ <div class="b"><span class="n">${t('stats.cacheRead')}</span><span class="t">${fmtTokens(today.total.cacheRead)}</span></div>
87
+ <div class="b"><span class="n">${t('stats.cacheWrite')}</span><span class="t">${fmtTokens(today.total.cacheWrite)}</span></div>
88
+ <div class="b"><span class="n">${t('stats.output')}</span><span class="t">${fmtTokens(today.total.output)}</span></div>
89
+ </div></div>
80
90
  <div class="stat-card"><div class="v">${fmtCost(totalCost)}</div><div class="k">${t('stats.todayCost')} · ${t('stats.peak')} ${fmtCost(peakCost)} / ${t('stats.off')} ${fmtCost(offCost)}</div></div>
81
91
  <div class="stat-card ${peakShare >= 50 ? 'warn' : 'ok'}"><div class="v">${peakShare}%</div><div class="k">${t('stats.peakShare')} · ${t('stats.days', { n: days.length })}</div></div>`
82
92
  $('stats-sub').textContent = today.date
93
+ $('stats-note').textContent = t('stats.note')
94
+ $('stats-legend').innerHTML = `<span class="lg"><span class="sw peak"></span>${t('stats.peak')}</span><span class="lg"><span class="sw off"></span>${t('stats.off')}</span>`
83
95
 
84
- // 近 7 日柱状图: 每根按费用堆叠高峰/空闲, 高度按费用相对比例
96
+ // 近 7 日柱状图: 柱总高按当日费用相对窗口最大值, 柱内峰/谷按当日实际占比堆叠
85
97
  const maxCost = Math.max(...days.map(d => (d.total.cost || 0)), 0.0001)
86
98
  $('stats-chart').innerHTML = days.map(d => {
87
99
  const cost = d.total.cost || 0
88
- const peakH = Math.max(2, Math.round((d.peak.cost || 0) / maxCost * 100))
89
- const offH = Math.max(0, Math.round((d.off.cost || 0) / maxCost * 100))
90
- const total = peakH + offH
100
+ const peakH = cost > 0 ? Math.round((d.peak.cost || 0) / cost * 100) : 0
101
+ const offH = cost > 0 ? Math.max(0, 100 - peakH) : 0
102
+ const totalH = cost > 0 ? Math.max(3, Math.round(cost / maxCost * 100)) : 0
91
103
  const label = d.date.slice(5)
92
104
  return `<div class="stats-bar" title="${d.date} · ${t('stats.peak')} ${fmtCost(d.peak.cost)} · ${t('stats.off')} ${fmtCost(d.off.cost)} · tokens ${fmtTokens(bucketTokens(d.total))}">
93
- <div class="bars" style="height:${Math.max(total, 2)}%">
105
+ <div class="bars" style="height:${totalH}%">
94
106
  <div class="seg peak" style="height:${peakH}%"></div>
95
107
  <div class="seg off" style="height:${offH}%"></div>
96
108
  </div>
package/public/app.js CHANGED
@@ -108,6 +108,92 @@ function fmtFullTime(ts) {
108
108
  function apiUrl(path) {
109
109
  return (state.server || '') + path
110
110
  }
111
+
112
+ function fmtCost(n) {
113
+ return '¥' + (Number(n) || 0).toFixed(2)
114
+ }
115
+
116
+ function bucketTokens(b) {
117
+ return (b.input || 0) + (b.cacheRead || 0) + (b.cacheWrite || 0) + (b.output || 0)
118
+ }
119
+
120
+ /* ---------------- Token 统计页 ---------------- */
121
+ async function loadStats() {
122
+ const cards = $('stats-cards')
123
+ const chart = $('stats-chart')
124
+ const note = $('stats-note')
125
+ if (!state.token) {
126
+ cards.innerHTML = ''
127
+ chart.innerHTML = `<div class="stats-empty">${t('statsPage.gatewayDown')}</div>`
128
+ note.textContent = ''
129
+ $('stats-legend').innerHTML = ''
130
+ return
131
+ }
132
+ try {
133
+ const res = await fetch(apiUrl('/stats/summary?days=7'), {
134
+ headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' }
135
+ })
136
+ if (res.status === 401) { authFailure(); return }
137
+ if (!res.ok) throw new Error('HTTP ' + res.status)
138
+ const json = await res.json()
139
+ renderStats(json.days || [])
140
+ } catch (e) {
141
+ cards.innerHTML = ''
142
+ chart.innerHTML = `<div class="stats-empty">${t('statsPage.gatewayDown')}</div>`
143
+ note.textContent = ''
144
+ $('stats-legend').innerHTML = ''
145
+ }
146
+ }
147
+
148
+ function renderStats(days) {
149
+ const cards = $('stats-cards')
150
+ const chart = $('stats-chart')
151
+ const note = $('stats-note')
152
+ if (!days.length) {
153
+ cards.innerHTML = ''
154
+ chart.innerHTML = `<div class="stats-empty">${t('statsPage.empty')}</div>`
155
+ note.textContent = t('statsPage.note')
156
+ $('stats-sub').textContent = ''
157
+ $('stats-legend').innerHTML = ''
158
+ return
159
+ }
160
+ const today = days[days.length - 1]
161
+ const totalTokens = bucketTokens(today.total)
162
+ const peakCost = today.peak.cost || 0
163
+ const offCost = today.off.cost || 0
164
+ const totalCost = peakCost + offCost
165
+ const peakShare = totalCost > 0 ? Math.round(peakCost / totalCost * 100) : 0
166
+ cards.innerHTML = `
167
+ <div class="scard span2"><div class="v">${fmtTokens(totalTokens)} <span style="font-size:11px;font-weight:500;color:var(--dsr-muted)">${t('statsPage.todayTokens')}</span></div>
168
+ <div class="bucket-grid">
169
+ <div class="b"><span class="n">${t('statsPage.input')}</span><span class="t">${fmtTokens(today.total.input)}</span></div>
170
+ <div class="b"><span class="n">${t('statsPage.cacheRead')}</span><span class="t">${fmtTokens(today.total.cacheRead)}</span></div>
171
+ <div class="b"><span class="n">${t('statsPage.cacheWrite')}</span><span class="t">${fmtTokens(today.total.cacheWrite)}</span></div>
172
+ <div class="b"><span class="n">${t('statsPage.output')}</span><span class="t">${fmtTokens(today.total.output)}</span></div>
173
+ </div></div>
174
+ <div class="scard"><div class="v">${fmtCost(totalCost)}</div><div class="k">${t('statsPage.todayCost')}<br>${t('statsPage.peak')} ${fmtCost(peakCost)}<br>${t('statsPage.off')} ${fmtCost(offCost)}</div></div>
175
+ <div class="scard"><div class="v">${peakShare}%</div><div class="k">${t('statsPage.peakShare')}<br>${t('statsPage.days', { n: days.length })}</div></div>`
176
+ $('stats-sub').textContent = today.date
177
+ note.textContent = t('statsPage.note')
178
+ $('stats-legend').innerHTML = `<span class="lg"><span class="sw peak"></span>${t('statsPage.peak')}</span><span class="lg"><span class="sw off"></span>${t('statsPage.off')}</span>`
179
+ const maxCost = Math.max(...days.map(d => (d.total.cost || 0)), 0.0001)
180
+ chart.innerHTML = days.map(d => {
181
+ const cost = d.total.cost || 0
182
+ // 峰/谷按该日实际费用占比堆叠, 柱总高按当日费用相对窗口最大值; 不再加最小高度, 保证占比真实
183
+ const peakH = cost > 0 ? Math.round((d.peak.cost || 0) / cost * 100) : 0
184
+ const offH = cost > 0 ? Math.max(0, 100 - peakH) : 0
185
+ const totalH = cost > 0 ? Math.max(3, Math.round(cost / maxCost * 100)) : 0
186
+ const label = d.date.slice(5)
187
+ return `<div class="stats-bar" title="${d.date} · ${t('statsPage.peak')} ${fmtCost(d.peak.cost)} · ${t('statsPage.off')} ${fmtCost(d.off.cost)} · tokens ${fmtTokens(bucketTokens(d.total))}">
188
+ <div class="bars" style="height:${totalH}%">
189
+ <div class="seg peak" style="height:${peakH}%"></div>
190
+ <div class="seg off" style="height:${offH}%"></div>
191
+ </div>
192
+ <div class="val">${cost > 0 ? fmtCost(cost) : ''}</div>
193
+ <div class="lbl">${label}</div>
194
+ </div>`
195
+ }).join('')
196
+ }
111
197
  async function rpc(method, payload = {}) {
112
198
  const res = await fetch(apiUrl('/api/' + method), {
113
199
  method: 'POST',
@@ -1851,12 +1937,13 @@ function notify(title, body) {
1851
1937
 
1852
1938
  /* ---------------- 视图切换 ---------------- */
1853
1939
  function showView(id) {
1854
- for (const v of ['view-home', 'view-files', 'view-session', 'view-activity', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
1940
+ for (const v of ['view-home', 'view-files', 'view-session', 'view-activity', 'view-stats', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
1855
1941
  // 离开会话页必须清掉 in-session, 否则其他页面顶栏被 body 样式隐藏
1856
1942
  document.body.classList.toggle('in-session', id === 'view-session')
1857
1943
  document.querySelectorAll('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === id))
1858
1944
  window.scrollTo(0, 0)
1859
1945
  if (id === 'view-files' && !state.fs.loaded) loadFs(null, { silent: true })
1946
+ if (id === 'view-stats') loadStats()
1860
1947
  }
1861
1948
 
1862
1949
  function updateConn() {
package/public/index.html CHANGED
@@ -128,6 +128,15 @@
128
128
  <div id="jobs-list" class="jobs-list"></div>
129
129
  </section>
130
130
 
131
+ <!-- Token 统计 -->
132
+ <section id="view-stats" class="view hidden">
133
+ <div class="section-head"><span data-i18n="statsPage.title">Token 统计</span><span id="stats-sub" class="muted"></span></div>
134
+ <div class="stats-cards" id="stats-cards"></div>
135
+ <div id="stats-legend" class="stats-legend"></div>
136
+ <div id="stats-chart" class="stats-chart"></div>
137
+ <div id="stats-note" class="stats-note"></div>
138
+ </section>
139
+
131
140
  <!-- 设置 -->
132
141
  <section id="view-settings" class="view hidden">
133
142
  <div class="settings-group">
@@ -189,6 +198,7 @@
189
198
  <button data-view="view-home" class="nav-btn active"><span class="nav-ico">▤</span><span data-i18n="nav.sessions">会话</span></button>
190
199
  <button data-view="view-files" class="nav-btn"><span class="nav-ico">⇅</span><span data-i18n="nav.files">文件</span></button>
191
200
  <button data-view="view-activity" class="nav-btn"><span class="nav-ico">◷</span><span data-i18n="nav.pending">待办</span><b id="nav-pending" class="nav-badge hidden"></b></button>
201
+ <button data-view="view-stats" class="nav-btn"><span class="nav-ico">▦</span><span data-i18n="nav.stats">统计</span></button>
192
202
  <button data-view="view-settings" class="nav-btn"><span class="nav-ico">⚙</span><span data-i18n="nav.settings">设置</span></button>
193
203
  </nav>
194
204
 
@@ -258,7 +268,13 @@
258
268
  'a11y.hostAdmin': '主机管理', 'a11y.refresh': '刷新', 'a11y.more': '更多操作', 'a11y.moreTitle': '指令/权限/模型',
259
269
  'conn.on': '已连接', 'conn.off': '未连接', 'conn.reconnecting': '连接中断,正在重连…',
260
270
  'common.refreshing': '刷新中…',
261
- 'nav.sessions': '会话', 'nav.files': '文件', 'nav.pending': '待办', 'nav.settings': '设置',
271
+ 'nav.sessions': '会话', 'nav.files': '文件', 'nav.pending': '待办', 'nav.stats': '统计', 'nav.settings': '设置',
272
+ 'statsPage.title': 'Token 统计',
273
+ 'statsPage.todayTokens': '今日 Token', 'statsPage.todayCost': '今日费用', 'statsPage.peakShare': '高峰占比',
274
+ 'statsPage.input': '未缓存输入', 'statsPage.cacheRead': '缓存命中', 'statsPage.cacheWrite': '缓存写入', 'statsPage.output': '输出',
275
+ 'statsPage.peak': '高峰', 'statsPage.off': '空闲', 'statsPage.days': '近 {n} 天',
276
+ 'statsPage.gatewayDown': '统计需要网关运行', 'statsPage.empty': '暂无统计,产生会话后自动聚合',
277
+ 'statsPage.note': '注:本数据仅在使用 DeepSeek 官方 API 时估算;基于 token 计算,与官网账单可能有出入,一切以官网为准。统计自 2026-08-17 定价生效日起。',
262
278
  'home.newSession': '+ 新会话', 'home.empty': '暂无会话', 'home.createFailed': '新建会话失败', 'home.created': '会话已创建',
263
279
  'sessions.running': '运行中', 'sessions.statRunning': '运行中', 'sessions.statPending': '待处理', 'sessions.statTotal': '会话总数',
264
280
  'sessions.queueBadge': '队列 {n}', 'sessions.goalBadge': '目标·{phase}',
@@ -375,7 +391,13 @@
375
391
  'a11y.hostAdmin': 'Host admin', 'a11y.refresh': 'Refresh', 'a11y.more': 'More actions', 'a11y.moreTitle': 'Commands / Permissions / Models',
376
392
  'conn.on': 'Connected', 'conn.off': 'Offline', 'conn.reconnecting': 'Connection lost, reconnecting…',
377
393
  'common.refreshing': 'Refreshing…',
378
- 'nav.sessions': 'Sessions', 'nav.files': 'Files', 'nav.pending': 'Inbox', 'nav.settings': 'Settings',
394
+ 'nav.sessions': 'Sessions', 'nav.files': 'Files', 'nav.pending': 'Inbox', 'nav.stats': 'Stats', 'nav.settings': 'Settings',
395
+ 'statsPage.title': 'Token stats',
396
+ 'statsPage.todayTokens': 'Tokens today', 'statsPage.todayCost': 'Cost today', 'statsPage.peakShare': 'Peak share',
397
+ 'statsPage.input': 'Uncached input', 'statsPage.cacheRead': 'Cache read', 'statsPage.cacheWrite': 'Cache write', 'statsPage.output': 'Output',
398
+ 'statsPage.peak': 'Peak', 'statsPage.off': 'Off-peak', 'statsPage.days': 'Last {n} days',
399
+ 'statsPage.gatewayDown': 'Stats require the gateway', 'statsPage.empty': 'No stats yet — they aggregate as sessions happen',
400
+ 'statsPage.note': 'Note: estimates assume the official DeepSeek API. Token-based calculation may differ from the official bill; always defer to deepseek.com. Stats start from the 2026-08-17 pricing date.',
379
401
  'home.newSession': '+ New session', 'home.empty': 'No sessions yet', 'home.createFailed': 'Failed to create session', 'home.created': 'Session created',
380
402
  'sessions.running': 'Running', 'sessions.statRunning': 'Running', 'sessions.statPending': 'Pending', 'sessions.statTotal': 'Sessions',
381
403
  'sessions.queueBadge': 'Queue {n}', 'sessions.goalBadge': 'Goal·{phase}',
package/public/styles.css CHANGED
@@ -450,6 +450,32 @@ button {
450
450
  }
451
451
  .section-head.small { margin-top: 6px; }
452
452
 
453
+ /* ---------- Token 统计(移动端) ---------- */
454
+ .stats-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
455
+ .scard { background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: var(--dsr-radius); padding: 10px 11px; min-width: 0; }
456
+ .scard.span2 { grid-column: span 2; }
457
+ .scard .v { font-size: 17px; font-weight: 700; line-height: 1.25; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
458
+ .scard .k { font-size: 10px; color: var(--dsr-muted); margin-top: 2px; line-height: 1.4; }
459
+ .bucket-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 3px 10px; margin-top: 6px; }
460
+ .bucket-grid .b { display: flex; align-items: baseline; justify-content: space-between; gap: 6px; font-size: 10.5px; min-width: 0; }
461
+ .bucket-grid .b .n { color: var(--dsr-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
462
+ .bucket-grid .b .t { font-weight: 600; white-space: nowrap; }
463
+ .stats-chart { display: flex; align-items: flex-end; gap: 7px; height: 190px; padding: 12px 12px 10px; margin-top: 10px; background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: var(--dsr-radius); overflow-x: auto; overflow-y: hidden; }
464
+ .stats-legend { display: flex; gap: 12px; font-size: 10px; color: var(--dsr-muted); margin-top: 6px; }
465
+ .stats-legend .lg { display: inline-flex; align-items: center; gap: 4px; }
466
+ .stats-legend .sw { width: 8px; height: 8px; border-radius: 2px; display: inline-block; }
467
+ .stats-legend .sw.peak { background: var(--dsr-accent-strong); }
468
+ .stats-legend .sw.off { background: var(--dsr-accent-2); }
469
+ .stats-bar { flex: 1; min-width: 36px; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; gap: 4px; }
470
+ .stats-bar .bars { width: 100%; max-width: 42px; height: 100%; display: flex; flex-direction: column; justify-content: flex-end; border-radius: 6px 6px 0 0; overflow: hidden; background: var(--dsr-accent-soft); }
471
+ .stats-bar .seg { width: 100%; }
472
+ .stats-bar .seg.peak { background: var(--dsr-accent-strong); }
473
+ .stats-bar .seg.off { background: var(--dsr-accent-2); }
474
+ .stats-bar .lbl { font-size: 9px; color: var(--dsr-muted); white-space: nowrap; }
475
+ .stats-bar .val { font-size: 9px; color: var(--dsr-text); white-space: nowrap; }
476
+ .stats-note { font-size: 11px; color: var(--dsr-muted); line-height: 1.6; margin-top: 8px; }
477
+ .stats-empty { color: var(--dsr-muted); text-align: center; padding: 18px 0; font-size: 12px; }
478
+
453
479
  /* ---------- 会话列表 ---------- */
454
480
  .session-list { display: flex; flex-direction: column; gap: 8px; }
455
481
  .session-card {
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "0.5.7-rc.1",
2
+ "version": "0.5.7",
3
3
  "apkUrl": "dsh-remote.apk",
4
- "releasedAt": "2026-08-17T05:34:41.185Z",
5
- "notes": "新增:Token 统计 v1 管理页展示今日四桶/费用/高峰占比与近 7 日柱状图,按北京时间高峰计费;历史会话自动回填。"
4
+ "releasedAt": "2026-08-17T06:01:38.138Z",
5
+ "notes": "新增:Token 统计 v1(管理页 + App 统计页):今日四桶/费用/高峰占比与近 7 日柱状图,按北京时间高峰计费,统计自 2026-08-17 定价生效日起;数据基于 token 估算,以官网账单为准。"
6
6
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.5.7-rc.1"
2
+ "version": "0.5.7"
3
3
  }