dsh-remote-plugin 0.5.7 → 0.5.9

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: [],
@@ -72,6 +76,74 @@ function toast(text, kind = '') {
72
76
  toast._t = setTimeout(() => el.classList.add('hidden'), 3200)
73
77
  }
74
78
 
79
+ /* ---------------- 反馈 ---------------- */
80
+ const FEEDBACK_LINKS = {
81
+ githubIssues: 'https://github.com/Blank-not-black/dsh-Remote/issues',
82
+ giteeIssues: 'https://gitee.com/Blankneverfails/dsh-Remote/issues',
83
+ bili: 'https://space.bilibili.com/419009275/dynamic',
84
+ repo: 'https://github.com/Blank-not-black/dsh-Remote'
85
+ }
86
+ async function copyText(text) {
87
+ try { await navigator.clipboard.writeText(text); return true } catch {}
88
+ try {
89
+ const ta = document.createElement('textarea')
90
+ ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'
91
+ document.body.appendChild(ta); ta.focus(); ta.select()
92
+ const ok = document.execCommand('copy')
93
+ ta.remove(); return ok
94
+ } catch { return false }
95
+ }
96
+ function openFeedbackSheet() {
97
+ $('feedback-backdrop').classList.remove('hidden')
98
+ $('feedback-sheet').classList.remove('hidden')
99
+ $('btn-feedback').setAttribute('aria-expanded', 'true')
100
+ const first = $('feedback-sheet').querySelector('[role="menuitem"]')
101
+ if (first) first.focus()
102
+ }
103
+ function closeFeedbackSheet() {
104
+ $('feedback-backdrop').classList.add('hidden')
105
+ $('feedback-sheet').classList.add('hidden')
106
+ $('btn-feedback').setAttribute('aria-expanded', 'false')
107
+ }
108
+ function toggleFeedbackSheet() {
109
+ $('feedback-sheet').classList.contains('hidden') ? openFeedbackSheet() : closeFeedbackSheet()
110
+ }
111
+ function openFeedbackModal() {
112
+ state.feedbackType = 'bug'
113
+ document.querySelectorAll('#fb-chips .fb-chip').forEach(b => b.classList.toggle('current', b.dataset.fbType === 'bug'))
114
+ $('fb-msg').value = ''
115
+ $('fb-contact').value = ''
116
+ $('modal-feedback').classList.remove('hidden')
117
+ setTimeout(() => $('fb-msg').focus(), 50)
118
+ }
119
+ function closeFeedbackModal() { $('modal-feedback').classList.add('hidden') }
120
+ async function submitFeedback() {
121
+ const type = state.feedbackType || 'bug'
122
+ const message = $('fb-msg').value.trim()
123
+ const contact = $('fb-contact').value.trim()
124
+ if (!message) { toast(t('feedback.empty'), 'err'); return }
125
+ if (message.length > 2000) { toast(t('feedback.tooLong'), 'err'); return }
126
+ const btn = $('fb-submit')
127
+ btn.disabled = true
128
+ try {
129
+ const base = (state.server || '').replace(/\/+$/, '')
130
+ const res = await fetch(base + '/feedback', {
131
+ method: 'POST',
132
+ headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token },
133
+ body: JSON.stringify({ type, message, contact, appVersion: state.localVersion })
134
+ })
135
+ let json = {}
136
+ try { json = await res.json() } catch {}
137
+ if (res.ok && json.ok) { toast(t('feedback.submitted'), 'ok'); closeFeedbackModal() }
138
+ else if (res.status === 429) { toast(json.retryAfter ? t('feedback.rateLimitedAt', { n: json.retryAfter }) : t('feedback.rateLimited'), 'err') }
139
+ else { toast(t('feedback.submitFailed', { msg: json.error || res.status }), 'err') }
140
+ } catch {
141
+ toast(t('feedback.submitFailed', { msg: t('feedback.networkError') }), 'err')
142
+ } finally {
143
+ btn.disabled = false
144
+ }
145
+ }
146
+
75
147
  function fmtTime(ts) {
76
148
  if (!ts) return ''
77
149
  const diff = Date.now() - ts
@@ -237,29 +309,89 @@ function authFailure() {
237
309
  $('token-desc').textContent = t('token.invalid')
238
310
  }
239
311
 
240
- /* ---------------- 多服务器 + 自动选优 ---------------- */
241
- function loadServers() {
312
+ /* ---------------- 多服务器分组管理 + 自动选优 ---------------- */
313
+ function newServerId() {
314
+ return 's' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
315
+ }
316
+
317
+ function ensureGroup(name) {
318
+ if (!name) name = '默认'
319
+ if (!state.groups.includes(name)) state.groups.push(name)
320
+ if (!(name in state.autoSelect)) state.autoSelect[name] = true
321
+ if (!(name in state.groupActive)) state.groupActive[name] = ''
322
+ return name
323
+ }
324
+
325
+ function groupServers(group) {
326
+ return state.servers.filter(s => s.group === group)
327
+ }
328
+
329
+ function activeServers() {
330
+ return groupServers(state.activeGroup)
331
+ }
332
+
333
+ /** 旧结构迁移: servers(string[]) + server/activeServer -> 新分组结构, 幂等(仅当 servers-v2 不存在时执行)。 */
334
+ function migrateServersV1() {
335
+ if (LS.get('servers-v2', null) !== null) return
242
336
  let arr = null
243
337
  try { arr = JSON.parse(LS.get('servers', '')) } catch {}
244
338
  if (!Array.isArray(arr)) {
245
339
  const legacy = LS.get('server', '')
246
340
  arr = legacy ? [legacy] : []
247
341
  }
248
- state.servers = arr.map(s => String(s || '').trim().replace(/\/+$/, ''))
342
+ const urls = arr.map(s => String(s || '').trim().replace(/\/+$/, ''))
249
343
  .filter(s => /^https?:\/\//i.test(s))
344
+ state.servers = urls.map((url, i) => ({ id: 's' + (i + 1), url, note: '', group: '默认' }))
345
+ state.groups = ['默认']
346
+ state.activeGroup = '默认'
347
+ state.autoSelect = { '默认': true }
348
+ state.groupActive = { '默认': '' }
250
349
  const active = LS.get('activeServer', '')
251
- if (active === 'origin') state.server = '' // 浏览器里明确选了同源页面
252
- else state.server = state.servers.includes(active) ? active : (state.servers[0] || '')
350
+ if (active === 'origin') {
351
+ state.server = ''
352
+ } else {
353
+ const hit = state.servers.find(s => s.url === active)
354
+ state.server = hit ? hit.url : (state.servers[0]?.url || '')
355
+ state.groupActive['默认'] = hit ? hit.id : (state.servers[0]?.id || '')
356
+ }
357
+ saveServers()
358
+ }
359
+
360
+ function loadServers() {
361
+ let data = null
362
+ try { data = JSON.parse(LS.get('servers-v2', '')) } catch {}
363
+ if (!data || !Array.isArray(data.servers)) {
364
+ migrateServersV1()
365
+ return
366
+ }
367
+ state.servers = data.servers.filter(s => s && typeof s.url === 'string')
368
+ .map(s => ({ id: s.id || newServerId(), url: s.url.replace(/\/+$/, ''), note: s.note || '', group: s.group || '默认' }))
369
+ state.groups = Array.isArray(data.groups) && data.groups.length ? data.groups : ['默认']
370
+ state.activeGroup = state.groups.includes(data.activeGroup) ? data.activeGroup : '默认'
371
+ state.autoSelect = data.autoSelect || {}
372
+ state.groupActive = data.groupActive || {}
373
+ ensureGroup('默认')
374
+ for (const s of state.servers) ensureGroup(s.group)
375
+ if (!state.groups.includes(state.activeGroup)) state.activeGroup = '默认'
376
+ // 恢复当前连接地址
377
+ const manual = state.groupActive[state.activeGroup]
378
+ const manualSrv = manual ? state.servers.find(s => s.id === manual) : null
379
+ state.server = manualSrv ? manualSrv.url : (activeServers()[0]?.url || '')
253
380
  }
254
381
 
255
382
  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)
383
+ LS.set('servers-v2', JSON.stringify({
384
+ servers: state.servers,
385
+ groups: state.groups,
386
+ activeGroup: state.activeGroup,
387
+ autoSelect: state.autoSelect,
388
+ groupActive: state.groupActive,
389
+ }))
390
+ // 旧 key 保留但不再写入(回滚兼容)
259
391
  }
260
392
 
261
393
  function serverCandidates() {
262
- const list = [...state.servers]
394
+ const list = activeServers().map(s => s.url)
263
395
  // 浏览器控制台: 当前页面(同源网关)也作为候选, 通常 0 跳内最快
264
396
  if (!CAP?.isNativePlatform?.() && location.origin && !list.includes(location.origin)) list.push(location.origin)
265
397
  return list
@@ -287,21 +419,44 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
287
419
  try {
288
420
  if (!silent) toast(t('speed.testing'))
289
421
  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 || '')
422
+ let chosen = ''
423
+ let best = null
424
+ let ms = Infinity
425
+
426
+ if (state.autoSelect[state.activeGroup] !== false) {
427
+ for (const u of candidates) state.serverLatency[u] = await pingServer(u)
428
+ best = candidates
429
+ .filter(u => Number.isFinite(state.serverLatency[u]))
430
+ .sort((a, b) => state.serverLatency[a] - state.serverLatency[b])[0] || null
431
+ const sameOrigin = !CAP?.isNativePlatform?.() && best === location.origin
432
+ chosen = best ? (sameOrigin && !activeServers().some(s => s.url === best) ? '' : best) : (state.server || '')
433
+ ms = best ? state.serverLatency[best] : Infinity
434
+ } else {
435
+ const manual = state.groupActive[state.activeGroup]
436
+ const manualSrv = manual ? state.servers.find(s => s.id === manual) : null
437
+ chosen = manualSrv ? manualSrv.url : (activeServers()[0]?.url || '')
438
+ if (chosen && !silent) {
439
+ state.serverLatency[chosen] = await pingServer(chosen)
440
+ ms = state.serverLatency[chosen]
441
+ }
442
+ }
443
+
297
444
  renderServers()
298
445
  if (chosen !== state.server) {
299
446
  state.server = chosen
447
+ if (state.autoSelect[state.activeGroup] !== false && best) {
448
+ const srv = state.servers.find(s => s.url === best)
449
+ if (srv) state.groupActive[state.activeGroup] = srv.id
450
+ }
300
451
  saveServers()
301
- if (!silent) toast(t('speed.switched', { url: chosen || t('speed.origin'), ms: state.serverLatency[best] }), 'ok')
452
+ if (!silent) {
453
+ if (chosen) toast(t('speed.switched', { url: chosen, ms: Number.isFinite(ms) ? ms : 0 }), 'ok')
454
+ else toast(t('speed.switchedOrigin'), 'ok')
455
+ }
302
456
  if (reconnect && state.token) { openStreams(); refreshAll() }
303
457
  } else if (!silent) {
304
458
  if (best) toast(t('speed.alreadyBest', { url: chosen || t('speed.origin'), ms: state.serverLatency[best] }), 'ok')
459
+ else if (chosen) toast(t('speed.manualUsing', { url: chosen, ms: Number.isFinite(ms) ? ms : '—' }), 'ok')
305
460
  else toast(t('speed.allDown'), 'err')
306
461
  }
307
462
  return chosen
@@ -310,27 +465,119 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
310
465
  }
311
466
  }
312
467
 
468
+ function serverTitle(s) {
469
+ return s.note || s.url
470
+ }
471
+
472
+ function renderGroupSelect() {
473
+ const label = $('group-select-label')
474
+ const menu = $('group-select-menu')
475
+ if (!label || !menu) return
476
+ label.textContent = state.activeGroup
477
+ menu.innerHTML = state.groups.map(g =>
478
+ `<button type="button" class="group-option ${g === state.activeGroup ? 'current' : ''}" data-group-option="${esc(g)}">${esc(g)}${g === state.activeGroup ? ' ✓' : ''}</button>`).join('')
479
+ menu.querySelectorAll('[data-group-option]').forEach(b =>
480
+ b.addEventListener('click', () => {
481
+ closeGroupMenu()
482
+ if (b.dataset.groupOption !== state.activeGroup) switchGroup(b.dataset.groupOption)
483
+ }))
484
+ }
485
+
486
+ function toggleGroupMenu() {
487
+ const menu = $('group-select-menu')
488
+ if (!menu) return
489
+ menu.classList.toggle('hidden')
490
+ }
491
+
492
+ function closeGroupMenu() {
493
+ const menu = $('group-select-menu')
494
+ if (menu) menu.classList.add('hidden')
495
+ }
496
+
313
497
  function renderServers() {
314
498
  const box = $('server-list')
315
499
  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
- }
500
+ renderGroupSelect()
501
+ const groupsHtml = state.groups.map(g => {
502
+ const list = groupServers(g)
503
+ const auto = state.autoSelect[g] !== false
504
+ const activeManual = state.groupActive[g] || ''
505
+ return `<div class="srv-group" data-group="${esc(g)}">
506
+ <div class="srv-group-head">
507
+ <button class="srv-group-name" data-group-name="${esc(g)}">${g === state.activeGroup ? '▾' : '▸'} ${esc(g)} <span class="srv-group-count">${list.length}</span></button>
508
+ <button class="srv-group-speed mini-btn" data-speed-group="${esc(g)}" title="${t('groups.speedTest')}">⚡</button>
509
+ <label class="switch small" title="${t('groups.autoSelect')}">
510
+ <input type="checkbox" data-auto-group="${esc(g)}" ${auto ? 'checked' : ''}>
511
+ <span class="slider"></span>
512
+ </label>
513
+ ${g !== '默认' ? `<button class="srv-group-del" data-del-group="${esc(g)}" title="${t('groups.delete')}">✕</button>` : ''}
514
+ </div>
515
+ <div class="srv-group-body ${g === state.activeGroup ? '' : 'hidden'}">
516
+ ${list.map(s => {
517
+ const ms = state.serverLatency[s.url]
518
+ let badge = `<span class="server-badge">${t('servers.untested')}</span>`
519
+ if (Number.isFinite(ms)) badge = `<span class="server-badge ${s.url === state.server ? 'good' : ''}">${ms}ms${s.url === state.server ? t('servers.current') : ''}</span>`
520
+ else if (ms !== undefined) badge = '<span class="server-badge bad">' + t('servers.unreachable') + '</span>'
521
+ const activeInGroup = auto ? s.url === state.server : s.id === activeManual
522
+ return `<div class="server-row ${activeInGroup ? 'active' : ''}" data-use-server="${esc(s.id)}">
523
+ <span class="server-main"><span class="server-note">${esc(serverTitle(s))}</span>${s.note ? `<span class="server-url">${esc(s.url)}</span>` : ''}</span>${badge}
524
+ <button class="server-edit" data-edit-server="${esc(s.id)}" title="${t('servers.edit')}">✎</button>
525
+ <button class="server-del" data-del-server="${esc(s.id)}" aria-label="${t('servers.delete')}">✕</button>
526
+ </div>`
527
+ }).join('') || '<div class="server-empty">' + t('groups.noServer') + '</div>'}
528
+ </div>
529
+ </div>`
530
+ }).join('')
531
+
532
+ box.innerHTML = state.servers.length
533
+ ? groupsHtml
534
+ : '<div class="server-empty">' + t('servers.empty') + '</div>'
535
+
536
+ box.querySelectorAll('[data-group-name]').forEach(b => {
537
+ b.title = t('groups.switchHint')
538
+ b.addEventListener('click', () => switchGroup(b.dataset.groupName))
539
+ b.addEventListener('dblclick', () => renameGroup(b.dataset.groupName))
540
+ })
541
+ box.querySelectorAll('[data-speed-group]').forEach(b => b.addEventListener('click', () => { state.activeGroup = b.dataset.speedGroup; saveServers(); selectFastestServer({ silent: false }) }))
542
+ box.querySelectorAll('[data-auto-group]').forEach(chk => chk.addEventListener('change', (e) => {
543
+ const g = e.target.dataset.autoGroup
544
+ state.autoSelect[g] = e.target.checked
545
+ saveServers()
546
+ if (g === state.activeGroup) selectFastestServer({ silent: false })
547
+ toast(t(e.target.checked ? 'groups.autoOn' : 'groups.autoOff', { group: g }), 'ok')
548
+ }))
549
+ box.querySelectorAll('[data-del-group]').forEach(b => b.addEventListener('click', () => deleteGroup(b.dataset.delGroup)))
550
+ box.querySelectorAll('[data-del-server]').forEach(b => b.addEventListener('click', (e) => { e.stopPropagation(); removeServer(b.dataset.delServer) }))
551
+ box.querySelectorAll('[data-edit-server]').forEach(b => b.addEventListener('click', (e) => { e.stopPropagation(); editServer(b.dataset.editServer) }))
552
+ box.querySelectorAll('[data-use-server]').forEach(row => row.addEventListener('click', (e) => {
553
+ if (e.target.closest('button')) return
554
+ const id = row.dataset.useServer
555
+ const s = state.servers.find(x => x.id === id)
556
+ if (!s) return
557
+ const group = s.group
558
+ if (state.autoSelect[group] !== false) {
559
+ // 自动选择模式下点击条目 = 编辑
560
+ editServer(id)
561
+ return
562
+ }
563
+ // 手动模式: 点击条目 = 选中该服务器连接
564
+ state.groupActive[group] = id
565
+ state.activeGroup = group
566
+ state.server = s.url
567
+ saveServers()
568
+ renderServers()
569
+ toast(t('servers.manualSelected', { url: serverTitle(s) }), 'ok')
570
+ if (state.token) { openStreams(); refreshAll() }
571
+ }))
572
+
573
+ const cur = state.servers.find(s => s.url === state.server)
574
+ const curNote = cur ? (cur.note || cur.url) : ''
575
+ const curGroup = cur ? cur.group : state.activeGroup
576
+ const curMs = state.serverLatency[state.server]
331
577
  $('server-desc').textContent = state.server
332
- ? t('servers.currentDesc', { url: state.server })
578
+ ? t('servers.currentDescGroup', { group: curGroup, url: curNote, ms: Number.isFinite(curMs) ? curMs + 'ms' : '—' })
333
579
  : (CAP?.isNativePlatform?.() ? t('servers.notSet') : t('servers.defaultDesc'))
580
+ updateConn()
334
581
  }
335
582
 
336
583
  async function addServer() {
@@ -343,8 +590,9 @@ async function addServer() {
343
590
  } catch {
344
591
  return toast(t('servers.badProtocol'), 'err')
345
592
  }
346
- if (state.servers.includes(raw)) return toast(t('servers.duplicate'))
347
- state.servers.push(raw)
593
+ if (state.servers.some(s => s.url === raw)) return toast(t('servers.duplicate'))
594
+ let note = prompt(t('servers.promptNote'), '') || ''
595
+ state.servers.push({ id: newServerId(), url: raw, note: note.trim(), group: state.activeGroup })
348
596
  saveServers()
349
597
  if (input) input.value = ''
350
598
  renderServers()
@@ -352,10 +600,40 @@ async function addServer() {
352
600
  if (state.token) selectFastestServer({ silent: false })
353
601
  }
354
602
 
355
- function removeServer(url) {
356
- state.servers = state.servers.filter(s => s !== url)
357
- const wasActive = state.server === url
358
- if (wasActive) state.server = ''
603
+ function editServer(id) {
604
+ const s = state.servers.find(x => x.id === id)
605
+ if (!s) return
606
+ const raw = (prompt(t('servers.promptEditUrl'), s.url) || '').trim().replace(/\/+$/, '')
607
+ if (!raw) return
608
+ try {
609
+ const u = new URL(raw)
610
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('bad')
611
+ } catch {
612
+ return toast(t('servers.badProtocol'), 'err')
613
+ }
614
+ if (state.servers.some(x => x.id !== id && x.url === raw)) return toast(t('servers.duplicate'))
615
+ const note = prompt(t('servers.promptEditNote', { url: raw }), s.note || '')
616
+ if (note === null) return
617
+ const group = prompt(t('servers.promptEditGroup'), s.group || '默认')
618
+ if (group === null) return
619
+ const wasActive = state.server === s.url
620
+ s.url = raw
621
+ s.note = note.trim()
622
+ s.group = ensureGroup(group.trim() || '默认')
623
+ if (wasActive) state.server = raw
624
+ saveServers()
625
+ renderServers()
626
+ toast(t('servers.edited'), 'ok')
627
+ if (wasActive && state.token) selectFastestServer({ silent: true })
628
+ }
629
+
630
+ function removeServer(id) {
631
+ const s = state.servers.find(x => x.id === id)
632
+ if (!s) return
633
+ state.servers = state.servers.filter(x => x.id !== id)
634
+ const wasActive = state.server === s.url
635
+ // 清理该组手动指定
636
+ for (const g of state.groups) if (state.groupActive[g] === id) state.groupActive[g] = ''
359
637
  saveServers()
360
638
  renderServers()
361
639
  if (wasActive) {
@@ -364,6 +642,61 @@ function removeServer(url) {
364
642
  }
365
643
  }
366
644
 
645
+ /* ---------------- 组管理 ---------------- */
646
+ function switchGroup(name) {
647
+ if (!state.groups.includes(name)) return
648
+ state.activeGroup = name
649
+ saveServers()
650
+ renderServers()
651
+ toast(t('groups.switched', { group: name }), 'ok')
652
+ selectFastestServer({ silent: false })
653
+ }
654
+
655
+ function addGroup() {
656
+ const name = (prompt(t('groups.promptAdd')) || '').trim()
657
+ if (!name) return
658
+ if (state.groups.includes(name)) return toast(t('groups.duplicate'), 'err')
659
+ ensureGroup(name)
660
+ state.activeGroup = name
661
+ saveServers()
662
+ renderServers()
663
+ toast(t('groups.added', { group: name }), 'ok')
664
+ }
665
+
666
+ function renameGroup(oldName) {
667
+ if (oldName === '默认') return
668
+ const name = (prompt(t('groups.promptRename', { group: oldName }), oldName) || '').trim()
669
+ if (!name || name === oldName) return
670
+ if (state.groups.includes(name)) return toast(t('groups.duplicate'), 'err')
671
+ const idx = state.groups.indexOf(oldName)
672
+ state.groups[idx] = name
673
+ for (const s of state.servers) if (s.group === oldName) s.group = name
674
+ if (state.activeGroup === oldName) state.activeGroup = name
675
+ if (name in state.autoSelect) delete state.autoSelect[name]
676
+ state.autoSelect[name] = state.autoSelect[oldName] !== false
677
+ delete state.autoSelect[oldName]
678
+ state.groupActive[name] = state.groupActive[oldName] || ''
679
+ delete state.groupActive[oldName]
680
+ saveServers()
681
+ renderServers()
682
+ toast(t('groups.renamed', { group: name }), 'ok')
683
+ }
684
+
685
+ function deleteGroup(name) {
686
+ if (name === '默认') return toast(t('groups.cannotDeleteDefault'), 'err')
687
+ if (!state.groups.includes(name)) return
688
+ if (!confirm(t('groups.confirmDelete', { group: name }))) return
689
+ state.groups = state.groups.filter(g => g !== name)
690
+ for (const s of state.servers) if (s.group === name) s.group = '默认'
691
+ delete state.autoSelect[name]
692
+ delete state.groupActive[name]
693
+ if (state.activeGroup === name) state.activeGroup = '默认'
694
+ saveServers()
695
+ renderServers()
696
+ toast(t('groups.deleted'), 'ok')
697
+ if (state.token) selectFastestServer({ silent: true })
698
+ }
699
+
367
700
  /* ---------------- 事件流 (WebSocket) ---------------- */
368
701
  const streams = {}
369
702
  state.streamsOk = { mux: false, host: false }
@@ -1935,6 +2268,42 @@ function notify(title, body) {
1935
2268
  } catch {}
1936
2269
  }
1937
2270
 
2271
+ /* ---------------- 峰谷计费提醒(每天 9/12/14/18 点本地通知) ---------------- */
2272
+ const PEAK_REMIND_NOTIFS = [
2273
+ { id: 8801, hour: 9, periodKey: 'peak0912', enterKey: 'enterPeak' },
2274
+ { id: 8802, hour: 12, periodKey: 'off1214', enterKey: 'enterOff' },
2275
+ { id: 8803, hour: 14, periodKey: 'peak1418', enterKey: 'enterPeak' },
2276
+ { id: 8804, hour: 18, periodKey: 'off1809', enterKey: 'enterOff' },
2277
+ ]
2278
+ function peakRemindOn() { return LS.get('peakRemind', '0') === '1' }
2279
+
2280
+ async function schedulePeakReminders() {
2281
+ if (!CAP?.isNativePlatform?.()) return false
2282
+ const L = CAP.Plugins?.LocalNotifications
2283
+ if (!L?.schedule) return false
2284
+ try {
2285
+ await L.schedule({
2286
+ notifications: PEAK_REMIND_NOTIFS.map(n => ({
2287
+ id: n.id,
2288
+ title: 'DSH Remote',
2289
+ body: `${t('peakRemind.' + n.enterKey)} · ${t('peakRemind.' + n.periodKey)}`,
2290
+ schedule: { every: 'day', on: { hour: n.hour, minute: 0 } },
2291
+ }))
2292
+ })
2293
+ return true
2294
+ } catch { return false }
2295
+ }
2296
+
2297
+ async function cancelPeakReminders() {
2298
+ if (!CAP?.isNativePlatform?.()) return false
2299
+ const L = CAP.Plugins?.LocalNotifications
2300
+ if (!L?.cancel) return false
2301
+ try {
2302
+ await L.cancel({ notifications: PEAK_REMIND_NOTIFS.map(n => ({ id: n.id })) })
2303
+ return true
2304
+ } catch { return false }
2305
+ }
2306
+
1938
2307
  /* ---------------- 视图切换 ---------------- */
1939
2308
  function showView(id) {
1940
2309
  for (const v of ['view-home', 'view-files', 'view-session', 'view-activity', 'view-stats', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
@@ -1950,7 +2319,12 @@ function updateConn() {
1950
2319
  const ok = !!state.streamsOk?.mux
1951
2320
  const el = $('conn-badge')
1952
2321
  el.textContent = ok ? t('conn.on') : t('conn.off')
1953
- el.className = 'conn-badge ' + (ok ? 'on' : 'off')
2322
+ el.className = 'topbar-btn conn-badge ' + (ok ? 'on' : 'off')
2323
+ const cur = state.servers.find(s => s.url === state.server)
2324
+ const ms = state.serverLatency[state.server]
2325
+ const curGroup = cur ? cur.group : state.activeGroup
2326
+ const curLabel = cur ? (cur.note || cur.url) : (state.server || t('speed.origin'))
2327
+ el.title = t('conn.titleGroup', { group: curGroup, url: curLabel, ms: Number.isFinite(ms) ? ms + 'ms' : '—' })
1954
2328
  }
1955
2329
 
1956
2330
  function autosize(el) {
@@ -1970,7 +2344,9 @@ function applyPairUrl(url) {
1970
2344
  state.token = tok
1971
2345
  LS.set('token', tok)
1972
2346
  state.server = server
1973
- if (!state.servers.includes(server)) state.servers.unshift(server)
2347
+ if (!state.servers.some(s => s.url === server)) {
2348
+ state.servers.unshift({ id: newServerId(), url: server, note: '', group: state.activeGroup })
2349
+ }
1974
2350
  saveServers()
1975
2351
  renderServers()
1976
2352
  $('token-desc').textContent = t('token.savedScan')
@@ -2147,8 +2523,30 @@ function bindUi() {
2147
2523
  $('btn-stats').addEventListener('click', () => { renderSessionCards(); $('modal-stats').classList.remove('hidden') })
2148
2524
  $('stats-close').addEventListener('click', () => $('modal-stats').classList.add('hidden'))
2149
2525
  $('btn-refresh').addEventListener('click', () => { toast(t('common.refreshing')); openStreams(); refreshAll() })
2150
- $('btn-admin').addEventListener('click', () => {
2151
- location.href = state.server ? state.server.replace(/\/+$/, '') + '/admin' : 'admin'
2526
+ // 反馈
2527
+ $('btn-feedback').addEventListener('click', (e) => { e.stopPropagation(); toggleFeedbackSheet() })
2528
+ $('feedback-backdrop').addEventListener('click', closeFeedbackSheet)
2529
+ $('feedback-sheet').addEventListener('click', (e) => {
2530
+ if (e.target.closest('a[role="menuitem"]')) closeFeedbackSheet()
2531
+ })
2532
+ $('btn-copy-link').addEventListener('click', async () => {
2533
+ const ok = await copyText(FEEDBACK_LINKS.repo)
2534
+ toast(t(ok ? 'feedback.copied' : 'feedback.copyFailed'), ok ? 'ok' : 'err')
2535
+ closeFeedbackSheet()
2536
+ })
2537
+ $('btn-write-feedback').addEventListener('click', () => { closeFeedbackSheet(); openFeedbackModal() })
2538
+ $('fb-cancel').addEventListener('click', closeFeedbackModal)
2539
+ $('fb-submit').addEventListener('click', submitFeedback)
2540
+ document.querySelectorAll('#fb-chips .fb-chip').forEach(btn =>
2541
+ btn.addEventListener('click', () => {
2542
+ state.feedbackType = btn.dataset.fbType
2543
+ document.querySelectorAll('#fb-chips .fb-chip').forEach(b => b.classList.toggle('current', b === btn))
2544
+ }))
2545
+ document.addEventListener('click', (e) => {
2546
+ if (!e.target.closest('#feedback-sheet') && !e.target.closest('#btn-feedback')) closeFeedbackSheet()
2547
+ })
2548
+ document.addEventListener('keydown', (e) => {
2549
+ if (e.key === 'Escape' && !$('feedback-sheet').classList.contains('hidden')) { closeFeedbackSheet(); $('btn-feedback').focus() }
2152
2550
  })
2153
2551
  $('btn-new-session').addEventListener('click', newSession)
2154
2552
  $('btn-cancel').addEventListener('click', cancelSession)
@@ -2188,6 +2586,11 @@ function bindUi() {
2188
2586
  })
2189
2587
  $('btn-server-speed').addEventListener('click', () => selectFastestServer({ silent: false }))
2190
2588
  $('btn-server-add').addEventListener('click', addServer)
2589
+ $('btn-group-add').addEventListener('click', addGroup)
2590
+ $('group-select-btn').addEventListener('click', (e) => { e.stopPropagation(); toggleGroupMenu() })
2591
+ document.addEventListener('click', (e) => {
2592
+ if (!e.target.closest('#group-select')) closeGroupMenu()
2593
+ })
2191
2594
  $('server-input').addEventListener('keydown', (e) => {
2192
2595
  if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); addServer() }
2193
2596
  })
@@ -2214,6 +2617,25 @@ function bindUi() {
2214
2617
  }
2215
2618
  LS.set('notify', e.target.checked ? '1' : '0')
2216
2619
  })
2620
+ $('opt-peak-remind').checked = peakRemindOn()
2621
+ $('opt-peak-remind').addEventListener('change', async (e) => {
2622
+ if (e.target.checked) {
2623
+ if (!CAP?.isNativePlatform?.()) {
2624
+ e.target.checked = false
2625
+ return toast(t('peakRemind.browserOnly'), 'err')
2626
+ }
2627
+ const ok = await ensureNotify()
2628
+ if (!ok) { e.target.checked = false; return toast(t('settings.notifyDenied')) }
2629
+ await schedulePeakReminders()
2630
+ toast(t('peakRemind.on'), 'ok')
2631
+ } else {
2632
+ await cancelPeakReminders()
2633
+ toast(t('peakRemind.off'), 'ok')
2634
+ }
2635
+ LS.set('peakRemind', e.target.checked ? '1' : '0')
2636
+ })
2637
+ // 已开启则启动时重新调度, 防止系统清理后丢失
2638
+ if (peakRemindOn() && CAP?.isNativePlatform?.()) schedulePeakReminders()
2217
2639
  $('opt-tools').checked = LS.get('showTools', '1') !== '0'
2218
2640
  $('opt-tools').addEventListener('change', (e) => {
2219
2641
  LS.set('showTools', e.target.checked ? '1' : '0')