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

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/public/app.js CHANGED
@@ -38,7 +38,11 @@ function writeHistoryCache(cache) {
38
38
  const state = {
39
39
  token: '',
40
40
  server: '', // 当前生效的网关地址, 空 = 同源(浏览器模式)
41
- servers: [], // 备选服务器列表(局域网/远程多地址)
41
+ servers: [], // 服务器列表: [{id,url,note,group}]
42
+ groups: ['默认'], // 组名列表(顺序保留)
43
+ activeGroup: '默认', // 当前连接组
44
+ autoSelect: { '默认': true }, // 组内自动测速选优 / 手动指定
45
+ groupActive: { '默认': '' }, // 每组当前生效的 server id(手动模式)
42
46
  serverLatency: {}, // url -> 最近一次 /health 测速毫秒数
43
47
  selectingServer: false, // 防重入: 测速/切换中
44
48
  sessions: [],
@@ -108,6 +112,92 @@ function fmtFullTime(ts) {
108
112
  function apiUrl(path) {
109
113
  return (state.server || '') + path
110
114
  }
115
+
116
+ function fmtCost(n) {
117
+ return '¥' + (Number(n) || 0).toFixed(2)
118
+ }
119
+
120
+ function bucketTokens(b) {
121
+ return (b.input || 0) + (b.cacheRead || 0) + (b.cacheWrite || 0) + (b.output || 0)
122
+ }
123
+
124
+ /* ---------------- Token 统计页 ---------------- */
125
+ async function loadStats() {
126
+ const cards = $('stats-cards')
127
+ const chart = $('stats-chart')
128
+ const note = $('stats-note')
129
+ if (!state.token) {
130
+ cards.innerHTML = ''
131
+ chart.innerHTML = `<div class="stats-empty">${t('statsPage.gatewayDown')}</div>`
132
+ note.textContent = ''
133
+ $('stats-legend').innerHTML = ''
134
+ return
135
+ }
136
+ try {
137
+ const res = await fetch(apiUrl('/stats/summary?days=7'), {
138
+ headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' }
139
+ })
140
+ if (res.status === 401) { authFailure(); return }
141
+ if (!res.ok) throw new Error('HTTP ' + res.status)
142
+ const json = await res.json()
143
+ renderStats(json.days || [])
144
+ } catch (e) {
145
+ cards.innerHTML = ''
146
+ chart.innerHTML = `<div class="stats-empty">${t('statsPage.gatewayDown')}</div>`
147
+ note.textContent = ''
148
+ $('stats-legend').innerHTML = ''
149
+ }
150
+ }
151
+
152
+ function renderStats(days) {
153
+ const cards = $('stats-cards')
154
+ const chart = $('stats-chart')
155
+ const note = $('stats-note')
156
+ if (!days.length) {
157
+ cards.innerHTML = ''
158
+ chart.innerHTML = `<div class="stats-empty">${t('statsPage.empty')}</div>`
159
+ note.textContent = t('statsPage.note')
160
+ $('stats-sub').textContent = ''
161
+ $('stats-legend').innerHTML = ''
162
+ return
163
+ }
164
+ const today = days[days.length - 1]
165
+ const totalTokens = bucketTokens(today.total)
166
+ const peakCost = today.peak.cost || 0
167
+ const offCost = today.off.cost || 0
168
+ const totalCost = peakCost + offCost
169
+ const peakShare = totalCost > 0 ? Math.round(peakCost / totalCost * 100) : 0
170
+ cards.innerHTML = `
171
+ <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>
172
+ <div class="bucket-grid">
173
+ <div class="b"><span class="n">${t('statsPage.input')}</span><span class="t">${fmtTokens(today.total.input)}</span></div>
174
+ <div class="b"><span class="n">${t('statsPage.cacheRead')}</span><span class="t">${fmtTokens(today.total.cacheRead)}</span></div>
175
+ <div class="b"><span class="n">${t('statsPage.cacheWrite')}</span><span class="t">${fmtTokens(today.total.cacheWrite)}</span></div>
176
+ <div class="b"><span class="n">${t('statsPage.output')}</span><span class="t">${fmtTokens(today.total.output)}</span></div>
177
+ </div></div>
178
+ <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>
179
+ <div class="scard"><div class="v">${peakShare}%</div><div class="k">${t('statsPage.peakShare')}<br>${t('statsPage.days', { n: days.length })}</div></div>`
180
+ $('stats-sub').textContent = today.date
181
+ note.textContent = t('statsPage.note')
182
+ $('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>`
183
+ const maxCost = Math.max(...days.map(d => (d.total.cost || 0)), 0.0001)
184
+ chart.innerHTML = days.map(d => {
185
+ const cost = d.total.cost || 0
186
+ // 峰/谷按该日实际费用占比堆叠, 柱总高按当日费用相对窗口最大值; 不再加最小高度, 保证占比真实
187
+ const peakH = cost > 0 ? Math.round((d.peak.cost || 0) / cost * 100) : 0
188
+ const offH = cost > 0 ? Math.max(0, 100 - peakH) : 0
189
+ const totalH = cost > 0 ? Math.max(3, Math.round(cost / maxCost * 100)) : 0
190
+ const label = d.date.slice(5)
191
+ 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))}">
192
+ <div class="bars" style="height:${totalH}%">
193
+ <div class="seg peak" style="height:${peakH}%"></div>
194
+ <div class="seg off" style="height:${offH}%"></div>
195
+ </div>
196
+ <div class="val">${cost > 0 ? fmtCost(cost) : ''}</div>
197
+ <div class="lbl">${label}</div>
198
+ </div>`
199
+ }).join('')
200
+ }
111
201
  async function rpc(method, payload = {}) {
112
202
  const res = await fetch(apiUrl('/api/' + method), {
113
203
  method: 'POST',
@@ -151,29 +241,89 @@ function authFailure() {
151
241
  $('token-desc').textContent = t('token.invalid')
152
242
  }
153
243
 
154
- /* ---------------- 多服务器 + 自动选优 ---------------- */
155
- function loadServers() {
244
+ /* ---------------- 多服务器分组管理 + 自动选优 ---------------- */
245
+ function newServerId() {
246
+ return 's' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
247
+ }
248
+
249
+ function ensureGroup(name) {
250
+ if (!name) name = '默认'
251
+ if (!state.groups.includes(name)) state.groups.push(name)
252
+ if (!(name in state.autoSelect)) state.autoSelect[name] = true
253
+ if (!(name in state.groupActive)) state.groupActive[name] = ''
254
+ return name
255
+ }
256
+
257
+ function groupServers(group) {
258
+ return state.servers.filter(s => s.group === group)
259
+ }
260
+
261
+ function activeServers() {
262
+ return groupServers(state.activeGroup)
263
+ }
264
+
265
+ /** 旧结构迁移: servers(string[]) + server/activeServer -> 新分组结构, 幂等(仅当 servers-v2 不存在时执行)。 */
266
+ function migrateServersV1() {
267
+ if (LS.get('servers-v2', null) !== null) return
156
268
  let arr = null
157
269
  try { arr = JSON.parse(LS.get('servers', '')) } catch {}
158
270
  if (!Array.isArray(arr)) {
159
271
  const legacy = LS.get('server', '')
160
272
  arr = legacy ? [legacy] : []
161
273
  }
162
- state.servers = arr.map(s => String(s || '').trim().replace(/\/+$/, ''))
274
+ const urls = arr.map(s => String(s || '').trim().replace(/\/+$/, ''))
163
275
  .filter(s => /^https?:\/\//i.test(s))
276
+ state.servers = urls.map((url, i) => ({ id: 's' + (i + 1), url, note: '', group: '默认' }))
277
+ state.groups = ['默认']
278
+ state.activeGroup = '默认'
279
+ state.autoSelect = { '默认': true }
280
+ state.groupActive = { '默认': '' }
164
281
  const active = LS.get('activeServer', '')
165
- if (active === 'origin') state.server = '' // 浏览器里明确选了同源页面
166
- else state.server = state.servers.includes(active) ? active : (state.servers[0] || '')
282
+ if (active === 'origin') {
283
+ state.server = ''
284
+ } else {
285
+ const hit = state.servers.find(s => s.url === active)
286
+ state.server = hit ? hit.url : (state.servers[0]?.url || '')
287
+ state.groupActive['默认'] = hit ? hit.id : (state.servers[0]?.id || '')
288
+ }
289
+ saveServers()
290
+ }
291
+
292
+ function loadServers() {
293
+ let data = null
294
+ try { data = JSON.parse(LS.get('servers-v2', '')) } catch {}
295
+ if (!data || !Array.isArray(data.servers)) {
296
+ migrateServersV1()
297
+ return
298
+ }
299
+ state.servers = data.servers.filter(s => s && typeof s.url === 'string')
300
+ .map(s => ({ id: s.id || newServerId(), url: s.url.replace(/\/+$/, ''), note: s.note || '', group: s.group || '默认' }))
301
+ state.groups = Array.isArray(data.groups) && data.groups.length ? data.groups : ['默认']
302
+ state.activeGroup = state.groups.includes(data.activeGroup) ? data.activeGroup : '默认'
303
+ state.autoSelect = data.autoSelect || {}
304
+ state.groupActive = data.groupActive || {}
305
+ ensureGroup('默认')
306
+ for (const s of state.servers) ensureGroup(s.group)
307
+ if (!state.groups.includes(state.activeGroup)) state.activeGroup = '默认'
308
+ // 恢复当前连接地址
309
+ const manual = state.groupActive[state.activeGroup]
310
+ const manualSrv = manual ? state.servers.find(s => s.id === manual) : null
311
+ state.server = manualSrv ? manualSrv.url : (activeServers()[0]?.url || '')
167
312
  }
168
313
 
169
314
  function saveServers() {
170
- LS.set('servers', JSON.stringify(state.servers))
171
- LS.set('server', state.server) // 兼容旧字段
172
- LS.set('activeServer', state.server === '' && !CAP?.isNativePlatform?.() ? 'origin' : state.server)
315
+ LS.set('servers-v2', JSON.stringify({
316
+ servers: state.servers,
317
+ groups: state.groups,
318
+ activeGroup: state.activeGroup,
319
+ autoSelect: state.autoSelect,
320
+ groupActive: state.groupActive,
321
+ }))
322
+ // 旧 key 保留但不再写入(回滚兼容)
173
323
  }
174
324
 
175
325
  function serverCandidates() {
176
- const list = [...state.servers]
326
+ const list = activeServers().map(s => s.url)
177
327
  // 浏览器控制台: 当前页面(同源网关)也作为候选, 通常 0 跳内最快
178
328
  if (!CAP?.isNativePlatform?.() && location.origin && !list.includes(location.origin)) list.push(location.origin)
179
329
  return list
@@ -201,21 +351,44 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
201
351
  try {
202
352
  if (!silent) toast(t('speed.testing'))
203
353
  const candidates = serverCandidates()
204
- for (const u of candidates) state.serverLatency[u] = await pingServer(u)
205
- const best = candidates
206
- .filter(u => Number.isFinite(state.serverLatency[u]))
207
- .sort((a, b) => state.serverLatency[a] - state.serverLatency[b])[0]
208
- const sameOrigin = !CAP?.isNativePlatform?.() && best === location.origin
209
- // 全部不可达时保持原服务器, 不硬切到空(同源)地址
210
- const chosen = best ? (sameOrigin && !state.servers.includes(best) ? '' : best) : (state.server || '')
354
+ let chosen = ''
355
+ let best = null
356
+ let ms = Infinity
357
+
358
+ if (state.autoSelect[state.activeGroup] !== false) {
359
+ for (const u of candidates) state.serverLatency[u] = await pingServer(u)
360
+ best = candidates
361
+ .filter(u => Number.isFinite(state.serverLatency[u]))
362
+ .sort((a, b) => state.serverLatency[a] - state.serverLatency[b])[0] || null
363
+ const sameOrigin = !CAP?.isNativePlatform?.() && best === location.origin
364
+ chosen = best ? (sameOrigin && !activeServers().some(s => s.url === best) ? '' : best) : (state.server || '')
365
+ ms = best ? state.serverLatency[best] : Infinity
366
+ } else {
367
+ const manual = state.groupActive[state.activeGroup]
368
+ const manualSrv = manual ? state.servers.find(s => s.id === manual) : null
369
+ chosen = manualSrv ? manualSrv.url : (activeServers()[0]?.url || '')
370
+ if (chosen && !silent) {
371
+ state.serverLatency[chosen] = await pingServer(chosen)
372
+ ms = state.serverLatency[chosen]
373
+ }
374
+ }
375
+
211
376
  renderServers()
212
377
  if (chosen !== state.server) {
213
378
  state.server = chosen
379
+ if (state.autoSelect[state.activeGroup] !== false && best) {
380
+ const srv = state.servers.find(s => s.url === best)
381
+ if (srv) state.groupActive[state.activeGroup] = srv.id
382
+ }
214
383
  saveServers()
215
- if (!silent) toast(t('speed.switched', { url: chosen || t('speed.origin'), ms: state.serverLatency[best] }), 'ok')
384
+ if (!silent) {
385
+ if (chosen) toast(t('speed.switched', { url: chosen, ms: Number.isFinite(ms) ? ms : 0 }), 'ok')
386
+ else toast(t('speed.switchedOrigin'), 'ok')
387
+ }
216
388
  if (reconnect && state.token) { openStreams(); refreshAll() }
217
389
  } else if (!silent) {
218
390
  if (best) toast(t('speed.alreadyBest', { url: chosen || t('speed.origin'), ms: state.serverLatency[best] }), 'ok')
391
+ else if (chosen) toast(t('speed.manualUsing', { url: chosen, ms: Number.isFinite(ms) ? ms : '—' }), 'ok')
219
392
  else toast(t('speed.allDown'), 'err')
220
393
  }
221
394
  return chosen
@@ -224,27 +397,119 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
224
397
  }
225
398
  }
226
399
 
400
+ function serverTitle(s) {
401
+ return s.note || s.url
402
+ }
403
+
404
+ function renderGroupSelect() {
405
+ const label = $('group-select-label')
406
+ const menu = $('group-select-menu')
407
+ if (!label || !menu) return
408
+ label.textContent = state.activeGroup
409
+ menu.innerHTML = state.groups.map(g =>
410
+ `<button type="button" class="group-option ${g === state.activeGroup ? 'current' : ''}" data-group-option="${esc(g)}">${esc(g)}${g === state.activeGroup ? ' ✓' : ''}</button>`).join('')
411
+ menu.querySelectorAll('[data-group-option]').forEach(b =>
412
+ b.addEventListener('click', () => {
413
+ closeGroupMenu()
414
+ if (b.dataset.groupOption !== state.activeGroup) switchGroup(b.dataset.groupOption)
415
+ }))
416
+ }
417
+
418
+ function toggleGroupMenu() {
419
+ const menu = $('group-select-menu')
420
+ if (!menu) return
421
+ menu.classList.toggle('hidden')
422
+ }
423
+
424
+ function closeGroupMenu() {
425
+ const menu = $('group-select-menu')
426
+ if (menu) menu.classList.add('hidden')
427
+ }
428
+
227
429
  function renderServers() {
228
430
  const box = $('server-list')
229
431
  if (!box) return
230
- if (!state.servers.length) {
231
- box.innerHTML = '<div class="server-empty">' + t('servers.empty') + '</div>'
232
- } else {
233
- box.innerHTML = state.servers.map(u => {
234
- const ms = state.serverLatency[u]
235
- let badge = '<span class="server-badge">' + t('servers.untested') + '</span>'
236
- if (Number.isFinite(ms)) badge = `<span class="server-badge ${u === state.server ? 'good' : ''}">${ms}ms${u === state.server ? t('servers.current') : ''}</span>`
237
- else if (ms !== undefined) badge = '<span class="server-badge bad">' + t('servers.unreachable') + '</span>'
238
- return `<div class="server-row ${u === state.server ? 'active' : ''}">
239
- <span class="server-url">${esc(u)}</span>${badge}
240
- <button class="server-del" data-del="${esc(u)}" aria-label="${t('servers.delete')}">✕</button>
241
- </div>`
242
- }).join('')
243
- box.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', () => removeServer(b.dataset.del)))
244
- }
432
+ renderGroupSelect()
433
+ const groupsHtml = state.groups.map(g => {
434
+ const list = groupServers(g)
435
+ const auto = state.autoSelect[g] !== false
436
+ const activeManual = state.groupActive[g] || ''
437
+ return `<div class="srv-group" data-group="${esc(g)}">
438
+ <div class="srv-group-head">
439
+ <button class="srv-group-name" data-group-name="${esc(g)}">${g === state.activeGroup ? '▾' : '▸'} ${esc(g)} <span class="srv-group-count">${list.length}</span></button>
440
+ <button class="srv-group-speed mini-btn" data-speed-group="${esc(g)}" title="${t('groups.speedTest')}">⚡</button>
441
+ <label class="switch small" title="${t('groups.autoSelect')}">
442
+ <input type="checkbox" data-auto-group="${esc(g)}" ${auto ? 'checked' : ''}>
443
+ <span class="slider"></span>
444
+ </label>
445
+ ${g !== '默认' ? `<button class="srv-group-del" data-del-group="${esc(g)}" title="${t('groups.delete')}">✕</button>` : ''}
446
+ </div>
447
+ <div class="srv-group-body ${g === state.activeGroup ? '' : 'hidden'}">
448
+ ${list.map(s => {
449
+ const ms = state.serverLatency[s.url]
450
+ let badge = `<span class="server-badge">${t('servers.untested')}</span>`
451
+ if (Number.isFinite(ms)) badge = `<span class="server-badge ${s.url === state.server ? 'good' : ''}">${ms}ms${s.url === state.server ? t('servers.current') : ''}</span>`
452
+ else if (ms !== undefined) badge = '<span class="server-badge bad">' + t('servers.unreachable') + '</span>'
453
+ const activeInGroup = auto ? s.url === state.server : s.id === activeManual
454
+ return `<div class="server-row ${activeInGroup ? 'active' : ''}" data-use-server="${esc(s.id)}">
455
+ <span class="server-main"><span class="server-note">${esc(serverTitle(s))}</span>${s.note ? `<span class="server-url">${esc(s.url)}</span>` : ''}</span>${badge}
456
+ <button class="server-edit" data-edit-server="${esc(s.id)}" title="${t('servers.edit')}">✎</button>
457
+ <button class="server-del" data-del-server="${esc(s.id)}" aria-label="${t('servers.delete')}">✕</button>
458
+ </div>`
459
+ }).join('') || '<div class="server-empty">' + t('groups.noServer') + '</div>'}
460
+ </div>
461
+ </div>`
462
+ }).join('')
463
+
464
+ box.innerHTML = state.servers.length
465
+ ? groupsHtml
466
+ : '<div class="server-empty">' + t('servers.empty') + '</div>'
467
+
468
+ box.querySelectorAll('[data-group-name]').forEach(b => {
469
+ b.title = t('groups.switchHint')
470
+ b.addEventListener('click', () => switchGroup(b.dataset.groupName))
471
+ b.addEventListener('dblclick', () => renameGroup(b.dataset.groupName))
472
+ })
473
+ box.querySelectorAll('[data-speed-group]').forEach(b => b.addEventListener('click', () => { state.activeGroup = b.dataset.speedGroup; saveServers(); selectFastestServer({ silent: false }) }))
474
+ box.querySelectorAll('[data-auto-group]').forEach(chk => chk.addEventListener('change', (e) => {
475
+ const g = e.target.dataset.autoGroup
476
+ state.autoSelect[g] = e.target.checked
477
+ saveServers()
478
+ if (g === state.activeGroup) selectFastestServer({ silent: false })
479
+ toast(t(e.target.checked ? 'groups.autoOn' : 'groups.autoOff', { group: g }), 'ok')
480
+ }))
481
+ box.querySelectorAll('[data-del-group]').forEach(b => b.addEventListener('click', () => deleteGroup(b.dataset.delGroup)))
482
+ box.querySelectorAll('[data-del-server]').forEach(b => b.addEventListener('click', (e) => { e.stopPropagation(); removeServer(b.dataset.delServer) }))
483
+ box.querySelectorAll('[data-edit-server]').forEach(b => b.addEventListener('click', (e) => { e.stopPropagation(); editServer(b.dataset.editServer) }))
484
+ box.querySelectorAll('[data-use-server]').forEach(row => row.addEventListener('click', (e) => {
485
+ if (e.target.closest('button')) return
486
+ const id = row.dataset.useServer
487
+ const s = state.servers.find(x => x.id === id)
488
+ if (!s) return
489
+ const group = s.group
490
+ if (state.autoSelect[group] !== false) {
491
+ // 自动选择模式下点击条目 = 编辑
492
+ editServer(id)
493
+ return
494
+ }
495
+ // 手动模式: 点击条目 = 选中该服务器连接
496
+ state.groupActive[group] = id
497
+ state.activeGroup = group
498
+ state.server = s.url
499
+ saveServers()
500
+ renderServers()
501
+ toast(t('servers.manualSelected', { url: serverTitle(s) }), 'ok')
502
+ if (state.token) { openStreams(); refreshAll() }
503
+ }))
504
+
505
+ const cur = state.servers.find(s => s.url === state.server)
506
+ const curNote = cur ? (cur.note || cur.url) : ''
507
+ const curGroup = cur ? cur.group : state.activeGroup
508
+ const curMs = state.serverLatency[state.server]
245
509
  $('server-desc').textContent = state.server
246
- ? t('servers.currentDesc', { url: state.server })
510
+ ? t('servers.currentDescGroup', { group: curGroup, url: curNote, ms: Number.isFinite(curMs) ? curMs + 'ms' : '—' })
247
511
  : (CAP?.isNativePlatform?.() ? t('servers.notSet') : t('servers.defaultDesc'))
512
+ updateConn()
248
513
  }
249
514
 
250
515
  async function addServer() {
@@ -257,8 +522,9 @@ async function addServer() {
257
522
  } catch {
258
523
  return toast(t('servers.badProtocol'), 'err')
259
524
  }
260
- if (state.servers.includes(raw)) return toast(t('servers.duplicate'))
261
- state.servers.push(raw)
525
+ if (state.servers.some(s => s.url === raw)) return toast(t('servers.duplicate'))
526
+ let note = prompt(t('servers.promptNote'), '') || ''
527
+ state.servers.push({ id: newServerId(), url: raw, note: note.trim(), group: state.activeGroup })
262
528
  saveServers()
263
529
  if (input) input.value = ''
264
530
  renderServers()
@@ -266,10 +532,40 @@ async function addServer() {
266
532
  if (state.token) selectFastestServer({ silent: false })
267
533
  }
268
534
 
269
- function removeServer(url) {
270
- state.servers = state.servers.filter(s => s !== url)
271
- const wasActive = state.server === url
272
- if (wasActive) state.server = ''
535
+ function editServer(id) {
536
+ const s = state.servers.find(x => x.id === id)
537
+ if (!s) return
538
+ const raw = (prompt(t('servers.promptEditUrl'), s.url) || '').trim().replace(/\/+$/, '')
539
+ if (!raw) return
540
+ try {
541
+ const u = new URL(raw)
542
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('bad')
543
+ } catch {
544
+ return toast(t('servers.badProtocol'), 'err')
545
+ }
546
+ if (state.servers.some(x => x.id !== id && x.url === raw)) return toast(t('servers.duplicate'))
547
+ const note = prompt(t('servers.promptEditNote', { url: raw }), s.note || '')
548
+ if (note === null) return
549
+ const group = prompt(t('servers.promptEditGroup'), s.group || '默认')
550
+ if (group === null) return
551
+ const wasActive = state.server === s.url
552
+ s.url = raw
553
+ s.note = note.trim()
554
+ s.group = ensureGroup(group.trim() || '默认')
555
+ if (wasActive) state.server = raw
556
+ saveServers()
557
+ renderServers()
558
+ toast(t('servers.edited'), 'ok')
559
+ if (wasActive && state.token) selectFastestServer({ silent: true })
560
+ }
561
+
562
+ function removeServer(id) {
563
+ const s = state.servers.find(x => x.id === id)
564
+ if (!s) return
565
+ state.servers = state.servers.filter(x => x.id !== id)
566
+ const wasActive = state.server === s.url
567
+ // 清理该组手动指定
568
+ for (const g of state.groups) if (state.groupActive[g] === id) state.groupActive[g] = ''
273
569
  saveServers()
274
570
  renderServers()
275
571
  if (wasActive) {
@@ -278,6 +574,61 @@ function removeServer(url) {
278
574
  }
279
575
  }
280
576
 
577
+ /* ---------------- 组管理 ---------------- */
578
+ function switchGroup(name) {
579
+ if (!state.groups.includes(name)) return
580
+ state.activeGroup = name
581
+ saveServers()
582
+ renderServers()
583
+ toast(t('groups.switched', { group: name }), 'ok')
584
+ selectFastestServer({ silent: false })
585
+ }
586
+
587
+ function addGroup() {
588
+ const name = (prompt(t('groups.promptAdd')) || '').trim()
589
+ if (!name) return
590
+ if (state.groups.includes(name)) return toast(t('groups.duplicate'), 'err')
591
+ ensureGroup(name)
592
+ state.activeGroup = name
593
+ saveServers()
594
+ renderServers()
595
+ toast(t('groups.added', { group: name }), 'ok')
596
+ }
597
+
598
+ function renameGroup(oldName) {
599
+ if (oldName === '默认') return
600
+ const name = (prompt(t('groups.promptRename', { group: oldName }), oldName) || '').trim()
601
+ if (!name || name === oldName) return
602
+ if (state.groups.includes(name)) return toast(t('groups.duplicate'), 'err')
603
+ const idx = state.groups.indexOf(oldName)
604
+ state.groups[idx] = name
605
+ for (const s of state.servers) if (s.group === oldName) s.group = name
606
+ if (state.activeGroup === oldName) state.activeGroup = name
607
+ if (name in state.autoSelect) delete state.autoSelect[name]
608
+ state.autoSelect[name] = state.autoSelect[oldName] !== false
609
+ delete state.autoSelect[oldName]
610
+ state.groupActive[name] = state.groupActive[oldName] || ''
611
+ delete state.groupActive[oldName]
612
+ saveServers()
613
+ renderServers()
614
+ toast(t('groups.renamed', { group: name }), 'ok')
615
+ }
616
+
617
+ function deleteGroup(name) {
618
+ if (name === '默认') return toast(t('groups.cannotDeleteDefault'), 'err')
619
+ if (!state.groups.includes(name)) return
620
+ if (!confirm(t('groups.confirmDelete', { group: name }))) return
621
+ state.groups = state.groups.filter(g => g !== name)
622
+ for (const s of state.servers) if (s.group === name) s.group = '默认'
623
+ delete state.autoSelect[name]
624
+ delete state.groupActive[name]
625
+ if (state.activeGroup === name) state.activeGroup = '默认'
626
+ saveServers()
627
+ renderServers()
628
+ toast(t('groups.deleted'), 'ok')
629
+ if (state.token) selectFastestServer({ silent: true })
630
+ }
631
+
281
632
  /* ---------------- 事件流 (WebSocket) ---------------- */
282
633
  const streams = {}
283
634
  state.streamsOk = { mux: false, host: false }
@@ -1849,14 +2200,51 @@ function notify(title, body) {
1849
2200
  } catch {}
1850
2201
  }
1851
2202
 
2203
+ /* ---------------- 峰谷计费提醒(每天 9/12/14/18 点本地通知) ---------------- */
2204
+ const PEAK_REMIND_NOTIFS = [
2205
+ { id: 8801, hour: 9, periodKey: 'peak0912', enterKey: 'enterPeak' },
2206
+ { id: 8802, hour: 12, periodKey: 'off1214', enterKey: 'enterOff' },
2207
+ { id: 8803, hour: 14, periodKey: 'peak1418', enterKey: 'enterPeak' },
2208
+ { id: 8804, hour: 18, periodKey: 'off1809', enterKey: 'enterOff' },
2209
+ ]
2210
+ function peakRemindOn() { return LS.get('peakRemind', '0') === '1' }
2211
+
2212
+ async function schedulePeakReminders() {
2213
+ if (!CAP?.isNativePlatform?.()) return false
2214
+ const L = CAP.Plugins?.LocalNotifications
2215
+ if (!L?.schedule) return false
2216
+ try {
2217
+ await L.schedule({
2218
+ notifications: PEAK_REMIND_NOTIFS.map(n => ({
2219
+ id: n.id,
2220
+ title: 'DSH Remote',
2221
+ body: `${t('peakRemind.' + n.enterKey)} · ${t('peakRemind.' + n.periodKey)}`,
2222
+ schedule: { every: 'day', on: { hour: n.hour, minute: 0 } },
2223
+ }))
2224
+ })
2225
+ return true
2226
+ } catch { return false }
2227
+ }
2228
+
2229
+ async function cancelPeakReminders() {
2230
+ if (!CAP?.isNativePlatform?.()) return false
2231
+ const L = CAP.Plugins?.LocalNotifications
2232
+ if (!L?.cancel) return false
2233
+ try {
2234
+ await L.cancel({ notifications: PEAK_REMIND_NOTIFS.map(n => ({ id: n.id })) })
2235
+ return true
2236
+ } catch { return false }
2237
+ }
2238
+
1852
2239
  /* ---------------- 视图切换 ---------------- */
1853
2240
  function showView(id) {
1854
- for (const v of ['view-home', 'view-files', 'view-session', 'view-activity', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
2241
+ for (const v of ['view-home', 'view-files', 'view-session', 'view-activity', 'view-stats', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
1855
2242
  // 离开会话页必须清掉 in-session, 否则其他页面顶栏被 body 样式隐藏
1856
2243
  document.body.classList.toggle('in-session', id === 'view-session')
1857
2244
  document.querySelectorAll('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === id))
1858
2245
  window.scrollTo(0, 0)
1859
2246
  if (id === 'view-files' && !state.fs.loaded) loadFs(null, { silent: true })
2247
+ if (id === 'view-stats') loadStats()
1860
2248
  }
1861
2249
 
1862
2250
  function updateConn() {
@@ -1864,6 +2252,11 @@ function updateConn() {
1864
2252
  const el = $('conn-badge')
1865
2253
  el.textContent = ok ? t('conn.on') : t('conn.off')
1866
2254
  el.className = 'conn-badge ' + (ok ? 'on' : 'off')
2255
+ const cur = state.servers.find(s => s.url === state.server)
2256
+ const ms = state.serverLatency[state.server]
2257
+ const curGroup = cur ? cur.group : state.activeGroup
2258
+ const curLabel = cur ? (cur.note || cur.url) : (state.server || t('speed.origin'))
2259
+ el.title = t('conn.titleGroup', { group: curGroup, url: curLabel, ms: Number.isFinite(ms) ? ms + 'ms' : '—' })
1867
2260
  }
1868
2261
 
1869
2262
  function autosize(el) {
@@ -1883,7 +2276,9 @@ function applyPairUrl(url) {
1883
2276
  state.token = tok
1884
2277
  LS.set('token', tok)
1885
2278
  state.server = server
1886
- if (!state.servers.includes(server)) state.servers.unshift(server)
2279
+ if (!state.servers.some(s => s.url === server)) {
2280
+ state.servers.unshift({ id: newServerId(), url: server, note: '', group: state.activeGroup })
2281
+ }
1887
2282
  saveServers()
1888
2283
  renderServers()
1889
2284
  $('token-desc').textContent = t('token.savedScan')
@@ -2101,6 +2496,11 @@ function bindUi() {
2101
2496
  })
2102
2497
  $('btn-server-speed').addEventListener('click', () => selectFastestServer({ silent: false }))
2103
2498
  $('btn-server-add').addEventListener('click', addServer)
2499
+ $('btn-group-add').addEventListener('click', addGroup)
2500
+ $('group-select-btn').addEventListener('click', (e) => { e.stopPropagation(); toggleGroupMenu() })
2501
+ document.addEventListener('click', (e) => {
2502
+ if (!e.target.closest('#group-select')) closeGroupMenu()
2503
+ })
2104
2504
  $('server-input').addEventListener('keydown', (e) => {
2105
2505
  if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); addServer() }
2106
2506
  })
@@ -2127,6 +2527,25 @@ function bindUi() {
2127
2527
  }
2128
2528
  LS.set('notify', e.target.checked ? '1' : '0')
2129
2529
  })
2530
+ $('opt-peak-remind').checked = peakRemindOn()
2531
+ $('opt-peak-remind').addEventListener('change', async (e) => {
2532
+ if (e.target.checked) {
2533
+ if (!CAP?.isNativePlatform?.()) {
2534
+ e.target.checked = false
2535
+ return toast(t('peakRemind.browserOnly'), 'err')
2536
+ }
2537
+ const ok = await ensureNotify()
2538
+ if (!ok) { e.target.checked = false; return toast(t('settings.notifyDenied')) }
2539
+ await schedulePeakReminders()
2540
+ toast(t('peakRemind.on'), 'ok')
2541
+ } else {
2542
+ await cancelPeakReminders()
2543
+ toast(t('peakRemind.off'), 'ok')
2544
+ }
2545
+ LS.set('peakRemind', e.target.checked ? '1' : '0')
2546
+ })
2547
+ // 已开启则启动时重新调度, 防止系统清理后丢失
2548
+ if (peakRemindOn() && CAP?.isNativePlatform?.()) schedulePeakReminders()
2130
2549
  $('opt-tools').checked = LS.get('showTools', '1') !== '0'
2131
2550
  $('opt-tools').addEventListener('change', (e) => {
2132
2551
  LS.set('showTools', e.target.checked ? '1' : '0')