dsh-remote-plugin 0.5.7 → 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/README.en.md CHANGED
@@ -37,6 +37,7 @@ The app is embedded in the plugin package (`apk/dsh-remote.apk`) — install the
37
37
  - Tap **QR code** in the desktop drawer → scan it with the phone to open the admin page → download the app from that page
38
38
  - Or open `http://<gateway-IP>:8787` in a browser to download
39
39
  - App updates are pushed by the gateway too (`update.json` relative path), never touching GitHub
40
+ - A desktop browser opening `http://<gateway-IP>:8787` auto-enters the desktop WebUI (sidebar sessions + files + settings + stats + approval notification stack)
40
41
 
41
42
  The app has built-in multi-server latency switching and offline chat history caching; downloaded files are stored in the system `Download/dsh-remote` folder.
42
43
 
package/README.md CHANGED
@@ -37,6 +37,7 @@ App 已随插件包内嵌(`apk/dsh-remote.apk`),装好插件即可获取
37
37
  - 桌面抽屉点「二维码」→ 手机扫码打开管理页 → 页面提供 App 下载
38
38
  - 或浏览器直接访问 `http://<网关IP>:8787` 下载
39
39
  - App 更新同样走网关自动推送(`update.json` 相对路径),全程不绕 GitHub
40
+ - 电脑浏览器打开 `http://<网关IP>:8787` 自动进入桌面端 WebUI(侧栏会话 + 文件 + 设置 + 统计 + 审批通知卡片栈)
40
41
 
41
42
  App 内置多服务器测速切换与聊天记录离线缓存,下载文件统一放系统「下载/dsh-remote」。
42
43
 
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-plugin",
3
- "version": "0.5.7",
3
+ "version": "0.5.8",
4
4
  "description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
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: [],
@@ -237,29 +241,89 @@ function authFailure() {
237
241
  $('token-desc').textContent = t('token.invalid')
238
242
  }
239
243
 
240
- /* ---------------- 多服务器 + 自动选优 ---------------- */
241
- 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
242
268
  let arr = null
243
269
  try { arr = JSON.parse(LS.get('servers', '')) } catch {}
244
270
  if (!Array.isArray(arr)) {
245
271
  const legacy = LS.get('server', '')
246
272
  arr = legacy ? [legacy] : []
247
273
  }
248
- state.servers = arr.map(s => String(s || '').trim().replace(/\/+$/, ''))
274
+ const urls = arr.map(s => String(s || '').trim().replace(/\/+$/, ''))
249
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 = { '默认': '' }
250
281
  const active = LS.get('activeServer', '')
251
- if (active === 'origin') state.server = '' // 浏览器里明确选了同源页面
252
- 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 || '')
253
312
  }
254
313
 
255
314
  function saveServers() {
256
- LS.set('servers', JSON.stringify(state.servers))
257
- LS.set('server', state.server) // 兼容旧字段
258
- 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 保留但不再写入(回滚兼容)
259
323
  }
260
324
 
261
325
  function serverCandidates() {
262
- const list = [...state.servers]
326
+ const list = activeServers().map(s => s.url)
263
327
  // 浏览器控制台: 当前页面(同源网关)也作为候选, 通常 0 跳内最快
264
328
  if (!CAP?.isNativePlatform?.() && location.origin && !list.includes(location.origin)) list.push(location.origin)
265
329
  return list
@@ -287,21 +351,44 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
287
351
  try {
288
352
  if (!silent) toast(t('speed.testing'))
289
353
  const candidates = serverCandidates()
290
- for (const u of candidates) state.serverLatency[u] = await pingServer(u)
291
- const best = candidates
292
- .filter(u => Number.isFinite(state.serverLatency[u]))
293
- .sort((a, b) => state.serverLatency[a] - state.serverLatency[b])[0]
294
- const sameOrigin = !CAP?.isNativePlatform?.() && best === location.origin
295
- // 全部不可达时保持原服务器, 不硬切到空(同源)地址
296
- 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
+
297
376
  renderServers()
298
377
  if (chosen !== state.server) {
299
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
+ }
300
383
  saveServers()
301
- 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
+ }
302
388
  if (reconnect && state.token) { openStreams(); refreshAll() }
303
389
  } else if (!silent) {
304
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')
305
392
  else toast(t('speed.allDown'), 'err')
306
393
  }
307
394
  return chosen
@@ -310,27 +397,119 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
310
397
  }
311
398
  }
312
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
+
313
429
  function renderServers() {
314
430
  const box = $('server-list')
315
431
  if (!box) return
316
- if (!state.servers.length) {
317
- box.innerHTML = '<div class="server-empty">' + t('servers.empty') + '</div>'
318
- } else {
319
- box.innerHTML = state.servers.map(u => {
320
- const ms = state.serverLatency[u]
321
- let badge = '<span class="server-badge">' + t('servers.untested') + '</span>'
322
- if (Number.isFinite(ms)) badge = `<span class="server-badge ${u === state.server ? 'good' : ''}">${ms}ms${u === state.server ? t('servers.current') : ''}</span>`
323
- else if (ms !== undefined) badge = '<span class="server-badge bad">' + t('servers.unreachable') + '</span>'
324
- return `<div class="server-row ${u === state.server ? 'active' : ''}">
325
- <span class="server-url">${esc(u)}</span>${badge}
326
- <button class="server-del" data-del="${esc(u)}" aria-label="${t('servers.delete')}">✕</button>
327
- </div>`
328
- }).join('')
329
- box.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', () => removeServer(b.dataset.del)))
330
- }
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]
331
509
  $('server-desc').textContent = state.server
332
- ? t('servers.currentDesc', { url: state.server })
510
+ ? t('servers.currentDescGroup', { group: curGroup, url: curNote, ms: Number.isFinite(curMs) ? curMs + 'ms' : '—' })
333
511
  : (CAP?.isNativePlatform?.() ? t('servers.notSet') : t('servers.defaultDesc'))
512
+ updateConn()
334
513
  }
335
514
 
336
515
  async function addServer() {
@@ -343,8 +522,9 @@ async function addServer() {
343
522
  } catch {
344
523
  return toast(t('servers.badProtocol'), 'err')
345
524
  }
346
- if (state.servers.includes(raw)) return toast(t('servers.duplicate'))
347
- 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 })
348
528
  saveServers()
349
529
  if (input) input.value = ''
350
530
  renderServers()
@@ -352,10 +532,40 @@ async function addServer() {
352
532
  if (state.token) selectFastestServer({ silent: false })
353
533
  }
354
534
 
355
- function removeServer(url) {
356
- state.servers = state.servers.filter(s => s !== url)
357
- const wasActive = state.server === url
358
- 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] = ''
359
569
  saveServers()
360
570
  renderServers()
361
571
  if (wasActive) {
@@ -364,6 +574,61 @@ function removeServer(url) {
364
574
  }
365
575
  }
366
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
+
367
632
  /* ---------------- 事件流 (WebSocket) ---------------- */
368
633
  const streams = {}
369
634
  state.streamsOk = { mux: false, host: false }
@@ -1935,6 +2200,42 @@ function notify(title, body) {
1935
2200
  } catch {}
1936
2201
  }
1937
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
+
1938
2239
  /* ---------------- 视图切换 ---------------- */
1939
2240
  function showView(id) {
1940
2241
  for (const v of ['view-home', 'view-files', 'view-session', 'view-activity', 'view-stats', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
@@ -1951,6 +2252,11 @@ function updateConn() {
1951
2252
  const el = $('conn-badge')
1952
2253
  el.textContent = ok ? t('conn.on') : t('conn.off')
1953
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' : '—' })
1954
2260
  }
1955
2261
 
1956
2262
  function autosize(el) {
@@ -1970,7 +2276,9 @@ function applyPairUrl(url) {
1970
2276
  state.token = tok
1971
2277
  LS.set('token', tok)
1972
2278
  state.server = server
1973
- 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
+ }
1974
2282
  saveServers()
1975
2283
  renderServers()
1976
2284
  $('token-desc').textContent = t('token.savedScan')
@@ -2188,6 +2496,11 @@ function bindUi() {
2188
2496
  })
2189
2497
  $('btn-server-speed').addEventListener('click', () => selectFastestServer({ silent: false }))
2190
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
+ })
2191
2504
  $('server-input').addEventListener('keydown', (e) => {
2192
2505
  if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); addServer() }
2193
2506
  })
@@ -2214,6 +2527,25 @@ function bindUi() {
2214
2527
  }
2215
2528
  LS.set('notify', e.target.checked ? '1' : '0')
2216
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()
2217
2549
  $('opt-tools').checked = LS.get('showTools', '1') !== '0'
2218
2550
  $('opt-tools').addEventListener('change', (e) => {
2219
2551
  LS.set('showTools', e.target.checked ? '1' : '0')