dsh-remote-plugin 0.6.17 → 0.6.18

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
@@ -186,10 +186,11 @@ function tokenKeyName(key) {
186
186
 
187
187
  /** 统计存储: 单文件按天 + 游标。写操作由网关单进程调用, 内部用同步队列串行化。 */
188
188
  class StatsStore {
189
- constructor(dir) {
189
+ constructor(dir, options = {}) {
190
190
  this.dir = dir || path.join(os.homedir(), '.dsh-remote', 'stats')
191
191
  this.daysDir = path.join(this.dir, DAYS_DIR)
192
192
  this.cursorsFile = path.join(this.dir, CURSORS_FILE)
193
+ this.spawn = typeof options.spawn === 'function' ? options.spawn : spawn
193
194
  this.cursors = null
194
195
  this.queue = Promise.resolve()
195
196
  fs.mkdirSync(this.daysDir, { recursive: true })
@@ -313,7 +314,7 @@ class StatsStore {
313
314
  if (lastSeq >= 0) this._setCursor(sessionId, lastSeq)
314
315
  }
315
316
 
316
- const zstd = spawn('zstd', ['-dc', file], { stdio: ['ignore', 'pipe', 'ignore'] })
317
+ const zstd = this.spawn('zstd', ['-dc', file], { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] })
317
318
  const rl = readline.createInterface({ input: zstd.stdout })
318
319
 
319
320
  zstd.on('error', (err) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-plugin",
3
- "version": "0.6.17",
3
+ "version": "0.6.18",
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
@@ -4,6 +4,9 @@
4
4
  const I18N = window.I18N
5
5
  const t = (k, v) => I18N.t(k, v)
6
6
  I18N.init(window.APP_STR)
7
+ // 投票是公开公告的低风险写入;网关不可达时允许 App 直连同一公网收集器托底。
8
+ // 普通反馈仍只走网关,避免绕过网关的鉴权与隐私处理。
9
+ const DIRECT_POLL_FEEDBACK_URL = 'https://vm-0-2-ubuntu.tail1f6fc4.ts.net/submit'
7
10
 
8
11
  /* ---------------- 状态 ---------------- */
9
12
  const LS = {
@@ -51,12 +54,12 @@ const state = {
51
54
  token: '',
52
55
  wsTicket: { token: '', server: '', value: '', expiresAt: 0 },
53
56
  server: '', // 当前生效的网关地址, 空 = 同源(浏览器模式)
54
- servers: [], // 服务器列表: [{id,url,note,group}]
57
+ servers: [], // 服务器列表: [{id,url,note,group,token}];连接身份 = 地址 + 令牌
55
58
  groups: ['默认'], // 组名列表(顺序保留)
56
59
  activeGroup: '默认', // 当前连接组
57
60
  autoSelect: { '默认': true }, // 组内自动测速选优 / 手动指定
58
61
  groupActive: { '默认': '' }, // 每组当前生效的 server id(手动模式)
59
- serverLatency: {}, // url -> 最近一次 /health 测速毫秒数
62
+ serverLatency: {}, // 地址 + 令牌 -> 最近一次 /health 测速毫秒数
60
63
  gatewayHealth: {}, // url -> /health 协议版本与能力声明
61
64
  selectingServer: false, // 防重入: 测速/切换中
62
65
  sessions: [],
@@ -341,6 +344,18 @@ function fmtTokens(n) {
341
344
  return String(n)
342
345
  }
343
346
 
347
+ function fmtStatsDate(value, withYear = false) {
348
+ const raw = String(value || '')
349
+ const date = new Date(`${raw}T00:00:00`)
350
+ if (!raw || Number.isNaN(date.getTime())) return raw
351
+ try {
352
+ const locale = I18N.lang === 'en' ? 'en-US' : 'zh-CN'
353
+ return new Intl.DateTimeFormat(locale, withYear
354
+ ? { year: 'numeric', month: 'short', day: 'numeric' }
355
+ : { month: 'numeric', day: 'numeric' }).format(date)
356
+ } catch { return raw.slice(5) }
357
+ }
358
+
344
359
  function fmtSize(n) {
345
360
  if (n == null || Number.isNaN(Number(n))) return '—'
346
361
  const b = Number(n)
@@ -415,15 +430,42 @@ function bucketTokens(b) {
415
430
  }
416
431
 
417
432
  /* ---------------- Token 统计页 ---------------- */
433
+ let statsChartMode = LS.get('statsChartModeV1', 'token') === 'cost' ? 'cost' : 'token'
434
+
435
+ function applyStatsChartMode() {
436
+ document.querySelectorAll('[data-stats-panel]').forEach(panel => {
437
+ const active = panel.dataset.statsPanel === statsChartMode
438
+ panel.classList.toggle('hidden', !active)
439
+ panel.setAttribute('aria-hidden', active ? 'false' : 'true')
440
+ })
441
+ document.querySelectorAll('[data-stats-mode]').forEach(button => {
442
+ const active = button.dataset.statsMode === statsChartMode
443
+ button.classList.toggle('active', active)
444
+ button.setAttribute('aria-pressed', active ? 'true' : 'false')
445
+ })
446
+ }
447
+
448
+ function setStatsChartMode(mode) {
449
+ if (mode !== 'token' && mode !== 'cost') return
450
+ statsChartMode = mode
451
+ LS.set('statsChartModeV1', mode)
452
+ applyStatsChartMode()
453
+ }
454
+
418
455
  async function loadStats() {
419
456
  const cards = $('stats-cards')
420
457
  const chart = $('stats-chart')
458
+ const tokenChart = $('stats-token-chart')
421
459
  const note = $('stats-note')
422
460
  if (!state.token) {
423
461
  cards.innerHTML = ''
424
462
  chart.innerHTML = `<div class="stats-empty">${t('statsPage.gatewayDown')}</div>`
463
+ tokenChart.innerHTML = `<div class="stats-empty">${t('statsPage.gatewayDown')}</div>`
425
464
  note.textContent = ''
465
+ $('stats-sub').textContent = ''
466
+ $('stats-sub').textContent = ''
426
467
  $('stats-legend').innerHTML = ''
468
+ $('stats-token-legend').innerHTML = ''
427
469
  return
428
470
  }
429
471
  try {
@@ -437,21 +479,28 @@ async function loadStats() {
437
479
  } catch (e) {
438
480
  cards.innerHTML = ''
439
481
  chart.innerHTML = `<div class="stats-empty">${t('statsPage.gatewayDown')}</div>`
482
+ tokenChart.innerHTML = `<div class="stats-empty">${t('statsPage.gatewayDown')}</div>`
440
483
  note.textContent = ''
484
+ $('stats-sub').textContent = ''
485
+ $('stats-sub').textContent = ''
441
486
  $('stats-legend').innerHTML = ''
487
+ $('stats-token-legend').innerHTML = ''
442
488
  }
443
489
  }
444
490
 
445
491
  function renderStats(days) {
446
492
  const cards = $('stats-cards')
447
493
  const chart = $('stats-chart')
494
+ const tokenChart = $('stats-token-chart')
448
495
  const note = $('stats-note')
449
496
  if (!days.length) {
450
497
  cards.innerHTML = ''
451
498
  chart.innerHTML = `<div class="stats-empty">${t('statsPage.empty')}</div>`
499
+ tokenChart.innerHTML = `<div class="stats-empty">${t('statsPage.empty')}</div>`
452
500
  note.textContent = t('statsPage.note')
453
501
  $('stats-sub').textContent = ''
454
502
  $('stats-legend').innerHTML = ''
503
+ $('stats-token-legend').innerHTML = ''
455
504
  return
456
505
  }
457
506
  const today = days[days.length - 1]
@@ -460,19 +509,49 @@ function renderStats(days) {
460
509
  const offCost = today.off.cost || 0
461
510
  const totalCost = peakCost + offCost
462
511
  const peakShare = totalCost > 0 ? Math.round(peakCost / totalCost * 100) : 0
512
+ const todayLabel = fmtStatsDate(today.date)
513
+ const todayFullLabel = fmtStatsDate(today.date, true)
463
514
  cards.innerHTML = `
464
- <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>
515
+ <article class="scard scard-primary">
516
+ <div class="scard-head">
517
+ <div class="scard-label"><span class="scard-icon token" aria-hidden="true">↗</span><span>${t('statsPage.todayTokens')}</span></div>
518
+ <span class="scard-badge">${esc(t('statsPage.today'))} · ${esc(todayLabel)}</span>
519
+ </div>
520
+ <div class="scard-value">${fmtTokens(totalTokens)}<span class="scard-unit">${esc(t('statsPage.tokenUnit'))}</span></div>
521
+ <div class="scard-helper">${esc(t('statsPage.breakdown'))}</div>
465
522
  <div class="bucket-grid">
466
- <div class="b"><span class="n">${t('statsPage.input')}</span><span class="t">${fmtTokens(today.total.input)}</span></div>
467
- <div class="b"><span class="n">${t('statsPage.cacheRead')}</span><span class="t">${fmtTokens(today.total.cacheRead)}</span></div>
468
- <div class="b"><span class="n">${t('statsPage.cacheWrite')}</span><span class="t">${fmtTokens(today.total.cacheWrite)}</span></div>
469
- <div class="b"><span class="n">${t('statsPage.output')}</span><span class="t">${fmtTokens(today.total.output)}</span></div>
470
- </div></div>
471
- <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>
472
- <div class="scard"><div class="v">${peakShare}%</div><div class="k">${t('statsPage.peakShare')}<br>${t('statsPage.days', { n: days.length })}</div></div>`
473
- $('stats-sub').textContent = today.date
523
+ <div class="b"><span class="bucket-dot input" aria-hidden="true"></span><span class="n">${t('statsPage.input')}</span><strong class="t">${fmtTokens(today.total.input)}</strong></div>
524
+ <div class="b"><span class="bucket-dot cache-read" aria-hidden="true"></span><span class="n">${t('statsPage.cacheRead')}</span><strong class="t">${fmtTokens(today.total.cacheRead)}</strong></div>
525
+ <div class="b"><span class="bucket-dot cache-write" aria-hidden="true"></span><span class="n">${t('statsPage.cacheWrite')}</span><strong class="t">${fmtTokens(today.total.cacheWrite)}</strong></div>
526
+ <div class="b"><span class="bucket-dot output" aria-hidden="true"></span><span class="n">${t('statsPage.output')}</span><strong class="t">${fmtTokens(today.total.output)}</strong></div>
527
+ </div>
528
+ </article>
529
+ <article class="scard scard-cost">
530
+ <div class="scard-head">
531
+ <div class="scard-label"><span class="scard-icon cost" aria-hidden="true">¥</span><span>${t('statsPage.todayCost')}</span></div>
532
+ <span class="scard-badge">${esc(t('statsPage.estimated'))}</span>
533
+ </div>
534
+ <div class="scard-value">${fmtCost(totalCost)}</div>
535
+ <div class="stats-split">
536
+ <div class="stats-split-item"><span><i class="split-dot peak" aria-hidden="true"></i>${t('statsPage.peak')}</span><strong>${fmtCost(peakCost)}</strong></div>
537
+ <div class="stats-split-item"><span><i class="split-dot off" aria-hidden="true"></i>${t('statsPage.off')}</span><strong>${fmtCost(offCost)}</strong></div>
538
+ </div>
539
+ </article>
540
+ <article class="scard scard-share">
541
+ <div class="scard-head">
542
+ <div class="scard-label"><span class="scard-icon share" aria-hidden="true">%</span><span>${t('statsPage.peakShare')}</span></div>
543
+ </div>
544
+ <div class="share-layout">
545
+ <div class="share-ring" style="--share:${peakShare}%" role="img" aria-label="${esc(t('statsPage.peakShareLabel', { n: peakShare }))}"><span>${peakShare}%</span></div>
546
+ <div class="share-copy"><strong>${t('statsPage.peak')}</strong><span>${t('statsPage.days', { n: days.length })}</span><small>${esc(todayFullLabel)}</small></div>
547
+ </div>
548
+ </article>`
549
+ $('stats-sub').textContent = todayFullLabel
474
550
  note.textContent = t('statsPage.note')
475
551
  $('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>`
552
+ $('stats-token-legend').innerHTML = `<span class="lg"><span class="sw token"></span>${t('statsPage.tokenTotal')}</span>`
553
+ chart.setAttribute('aria-label', t('statsPage.costChartLabel'))
554
+ tokenChart.setAttribute('aria-label', t('statsPage.tokenChartLabel'))
476
555
  const maxCost = Math.max(...days.map(d => (d.total.cost || 0)), 0.0001)
477
556
  chart.innerHTML = days.map(d => {
478
557
  const cost = d.total.cost || 0
@@ -480,8 +559,9 @@ function renderStats(days) {
480
559
  const peakH = cost > 0 ? Math.round((d.peak.cost || 0) / cost * 100) : 0
481
560
  const offH = cost > 0 ? Math.max(0, 100 - peakH) : 0
482
561
  const totalH = cost > 0 ? Math.max(3, Math.round(cost / maxCost * 100)) : 0
483
- const label = d.date.slice(5)
484
- 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))}">
562
+ const label = fmtStatsDate(d.date)
563
+ const fullLabel = `${fmtStatsDate(d.date, true)} · ${t('statsPage.peak')} ${fmtCost(d.peak.cost)} · ${t('statsPage.off')} ${fmtCost(d.off.cost)} · ${t('statsPage.tokenTotal')} ${fmtTokens(bucketTokens(d.total))}`
564
+ return `<div class="stats-bar" role="listitem" title="${esc(fullLabel)}" aria-label="${esc(fullLabel)}">
485
565
  <div class="bars" style="height:${totalH}%">
486
566
  <div class="seg peak" style="height:${peakH}%"></div>
487
567
  <div class="seg off" style="height:${offH}%"></div>
@@ -490,6 +570,18 @@ function renderStats(days) {
490
570
  <div class="lbl">${label}</div>
491
571
  </div>`
492
572
  }).join('')
573
+ const maxTokens = Math.max(...days.map(d => bucketTokens(d.total)), 1)
574
+ tokenChart.innerHTML = days.map(d => {
575
+ const tokens = bucketTokens(d.total)
576
+ const totalH = tokens > 0 ? Math.max(3, Math.round(tokens / maxTokens * 100)) : 0
577
+ const label = fmtStatsDate(d.date)
578
+ const tip = `${fmtStatsDate(d.date, true)} · ${t('statsPage.tokenTotal')} ${fmtTokens(tokens)} · ${t('statsPage.input')} ${fmtTokens(d.total.input)} · ${t('statsPage.output')} ${fmtTokens(d.total.output)}`
579
+ return `<div class="stats-bar" role="listitem" title="${esc(tip)}" aria-label="${esc(tip)}">
580
+ <div class="bars token" style="height:${totalH}%"></div>
581
+ <div class="val">${tokens > 0 ? fmtTokens(tokens) : ''}</div>
582
+ <div class="lbl">${label}</div>
583
+ </div>`
584
+ }).join('')
493
585
  }
494
586
  async function rpc(method, payload = {}, timeoutMs = 45000) {
495
587
  const opts = {
@@ -612,6 +704,30 @@ function activeServers() {
612
704
  return groupServers(state.activeGroup)
613
705
  }
614
706
 
707
+ function normalizedServerToken(token) { return typeof token === 'string' ? token.trim() : '' }
708
+ function serverIdentity(url, token) {
709
+ return `${String(url || '').replace(/\/+$/, '')}\u0000${normalizedServerToken(token)}`
710
+ }
711
+ function serverKey(server) {
712
+ return serverIdentity(server?.url ?? server, server?.token)
713
+ }
714
+ function serverToken(server) { return normalizedServerToken(server?.token) }
715
+ function currentServerEntry() {
716
+ return state.servers.find(s => s.url === state.server && serverToken(s) === state.token)
717
+ || state.servers.find(s => s.url === state.server)
718
+ || null
719
+ }
720
+ function setCurrentAccessToken(value) {
721
+ const token = normalizedServerToken(value)
722
+ if (!token) return false
723
+ const current = currentServerEntry()
724
+ if (current) current.token = token
725
+ state.token = token
726
+ LS.set('token', token)
727
+ saveServers()
728
+ return true
729
+ }
730
+
615
731
  /** 旧结构迁移: servers(string[]) + server/activeServer -> 新分组结构, 幂等(仅当 servers-v2 不存在时执行)。 */
616
732
  function migrateServersV1() {
617
733
  if (LS.get('servers-v2', null) !== null) return
@@ -623,7 +739,7 @@ function migrateServersV1() {
623
739
  }
624
740
  const urls = arr.map(s => String(s || '').trim().replace(/\/+$/, ''))
625
741
  .filter(s => /^https?:\/\//i.test(s))
626
- state.servers = urls.map((url, i) => ({ id: 's' + (i + 1), url, note: '', group: '默认' }))
742
+ state.servers = urls.map((url, i) => ({ id: 's' + (i + 1), url, note: '', group: '默认', token: normalizedServerToken(state.token) }))
627
743
  state.groups = ['默认']
628
744
  state.activeGroup = '默认'
629
745
  state.autoSelect = { '默认': true }
@@ -646,8 +762,9 @@ function loadServers() {
646
762
  migrateServersV1()
647
763
  return
648
764
  }
765
+ const legacyToken = normalizedServerToken(state.token || LS.get('token', ''))
649
766
  state.servers = data.servers.filter(s => s && typeof s.url === 'string')
650
- .map(s => ({ id: s.id || newServerId(), url: s.url.replace(/\/+$/, ''), note: s.note || '', group: s.group || '默认' }))
767
+ .map(s => ({ id: s.id || newServerId(), url: s.url.replace(/\/+$/, ''), note: s.note || '', group: s.group || '默认', token: s.token == null ? legacyToken : normalizedServerToken(s.token) }))
651
768
  state.groups = Array.isArray(data.groups) && data.groups.length ? data.groups : ['默认']
652
769
  state.activeGroup = state.groups.includes(data.activeGroup) ? data.activeGroup : '默认'
653
770
  state.autoSelect = data.autoSelect || {}
@@ -658,7 +775,12 @@ function loadServers() {
658
775
  // 恢复当前连接地址
659
776
  const manual = state.groupActive[state.activeGroup]
660
777
  const manualSrv = manual ? state.servers.find(s => s.id === manual) : null
661
- state.server = manualSrv ? manualSrv.url : (activeServers()[0]?.url || '')
778
+ const selected = manualSrv || activeServers()[0]
779
+ state.server = selected?.url || ''
780
+ if (selected) {
781
+ state.token = serverToken(selected)
782
+ LS.set('token', state.token)
783
+ }
662
784
  }
663
785
 
664
786
  function saveServers() {
@@ -673,9 +795,11 @@ function saveServers() {
673
795
  }
674
796
 
675
797
  function serverCandidates() {
676
- const list = activeServers().map(s => s.url)
798
+ const list = activeServers().map(s => ({ ...s, token: serverToken(s) }))
677
799
  // 浏览器控制台: 当前页面(同源网关)也作为候选, 通常 0 跳内最快
678
- if (!CAP?.isNativePlatform?.() && location.origin && !list.includes(location.origin)) list.push(location.origin)
800
+ if (!CAP?.isNativePlatform?.() && location.origin && !list.some(s => s.url === location.origin)) {
801
+ list.push({ id: 'origin', url: location.origin, token: state.token, origin: true })
802
+ }
679
803
  return list
680
804
  }
681
805
 
@@ -725,49 +849,56 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
725
849
  if (!silent) toast(t('speed.testing'))
726
850
  const candidates = serverCandidates()
727
851
  let chosen = ''
852
+ let chosenToken = state.token
728
853
  let best = null
729
854
  let ms = Infinity
730
855
 
731
856
  if (state.autoSelect[state.activeGroup] !== false) {
732
- const measured = await Promise.all(candidates.map(async (u) => [u, await pingServer(u)]))
733
- for (const [u, latency] of measured) state.serverLatency[u] = latency
857
+ const measured = await Promise.all(candidates.map(async (server) => [server, await pingServer(server.url)]))
858
+ for (const [server, latency] of measured) state.serverLatency[serverKey(server)] = latency
734
859
  best = candidates
735
- .filter(u => Number.isFinite(state.serverLatency[u]))
736
- .sort((a, b) => state.serverLatency[a] - state.serverLatency[b])[0] || null
737
- const sameOrigin = !CAP?.isNativePlatform?.() && best === location.origin
738
- chosen = best ? (sameOrigin && !activeServers().some(s => s.url === best) ? '' : best) : (state.server || '')
739
- ms = best ? state.serverLatency[best] : Infinity
860
+ .filter(server => Number.isFinite(state.serverLatency[serverKey(server)]))
861
+ .sort((a, b) => state.serverLatency[serverKey(a)] - state.serverLatency[serverKey(b)])[0] || null
862
+ const sameOrigin = !CAP?.isNativePlatform?.() && best?.url === location.origin
863
+ chosen = best ? (sameOrigin && best.origin ? '' : best.url) : (state.server || '')
864
+ chosenToken = best ? serverToken(best) : state.token
865
+ ms = best ? state.serverLatency[serverKey(best)] : Infinity
740
866
  } else {
741
867
  const manual = state.groupActive[state.activeGroup]
742
868
  const manualSrv = manual ? state.servers.find(s => s.id === manual) : null
743
869
  chosen = manualSrv ? manualSrv.url : (activeServers()[0]?.url || '')
870
+ chosenToken = manualSrv ? serverToken(manualSrv) : state.token
744
871
  if (chosen && !silent) {
745
- state.serverLatency[chosen] = await pingServer(chosen)
746
- ms = state.serverLatency[chosen]
872
+ const key = serverKey(manualSrv || { url: chosen, token: state.token })
873
+ state.serverLatency[key] = await pingServer(chosen)
874
+ ms = state.serverLatency[key]
747
875
  }
748
876
  }
749
877
 
750
878
  renderServers()
751
- if (chosen !== state.server) {
879
+ if (chosen !== state.server || chosenToken !== state.token) {
752
880
  state.hostInfo = null
753
881
  hostDescribeFailures = 0
754
882
  if (hostDescribeRetryTimer) clearTimeout(hostDescribeRetryTimer)
755
883
  hostDescribeRetryTimer = null
756
884
  resetFsForServer()
757
885
  state.server = chosen
886
+ state.token = chosenToken
887
+ LS.set('token', state.token)
758
888
  if (state.autoSelect[state.activeGroup] !== false && best) {
759
- const srv = state.servers.find(s => s.url === best)
889
+ const srv = state.servers.find(s => s.id === best.id)
760
890
  if (srv) state.groupActive[state.activeGroup] = srv.id
761
891
  }
762
892
  saveServers()
763
893
  syncBgConfig()
894
+ renderServers()
764
895
  if (!silent) {
765
896
  if (chosen) toast(t('speed.switched', { url: chosen, ms: Number.isFinite(ms) ? ms : 0 }), 'ok')
766
897
  else toast(t('speed.switchedOrigin'), 'ok')
767
898
  }
768
899
  if (reconnect && state.token) { openStreams(); refreshAll() }
769
900
  } else if (!silent) {
770
- if (best) toast(t('speed.alreadyBest', { url: chosen || t('speed.origin'), ms: state.serverLatency[best] }), 'ok')
901
+ if (best) toast(t('speed.alreadyBest', { url: chosen || t('speed.origin'), ms: state.serverLatency[serverKey(best)] }), 'ok')
771
902
  else if (chosen) toast(t('speed.manualUsing', { url: chosen, ms: Number.isFinite(ms) ? ms : '—' }), 'ok')
772
903
  else toast(t('speed.allDown'), 'err')
773
904
  }
@@ -827,13 +958,14 @@ function renderServers() {
827
958
  </div>
828
959
  <div class="srv-group-body ${g === state.activeGroup ? '' : 'hidden'}">
829
960
  ${list.map(s => {
830
- const ms = state.serverLatency[s.url]
961
+ const ms = state.serverLatency[serverKey(s)]
831
962
  let badge = `<span class="server-badge">${t('servers.untested')}</span>`
832
- if (Number.isFinite(ms)) badge = `<span class="server-badge ${s.url === state.server ? 'good' : ''}">${ms}ms${s.url === state.server ? t('servers.current') : ''}</span>`
963
+ const active = s.url === state.server && serverToken(s) === state.token
964
+ if (Number.isFinite(ms)) badge = `<span class="server-badge ${active ? 'good' : ''}">${ms}ms${active ? t('servers.current') : ''}</span>`
833
965
  else if (ms !== undefined) badge = '<span class="server-badge bad">' + t('servers.unreachable') + '</span>'
834
- const activeInGroup = auto ? s.url === state.server : s.id === activeManual
966
+ const activeInGroup = auto ? active : s.id === activeManual
835
967
  return `<div class="server-row ${activeInGroup ? 'active' : ''}" data-use-server="${esc(s.id)}">
836
- <span class="server-main"><span class="server-note">${esc(serverTitle(s))}</span>${s.note ? `<span class="server-url">${esc(s.url)}</span>` : ''}</span>${badge}
968
+ <span class="server-main"><span class="server-note">${esc(serverTitle(s))}</span>${s.note ? `<span class="server-url">${esc(s.url)}</span>` : ''}<span class="server-token-badge">${s.token ? t('servers.tokenSet') : t('servers.tokenMissing')}</span></span>${badge}
837
969
  <button class="server-edit" data-edit-server="${esc(s.id)}" title="${t('servers.edit')}">✎</button>
838
970
  <button class="server-del" data-del-server="${esc(s.id)}" aria-label="${t('servers.delete')}">✕</button>
839
971
  </div>`
@@ -878,16 +1010,19 @@ function renderServers() {
878
1010
  state.activeGroup = group
879
1011
  if (state.server !== s.url) resetFsForServer()
880
1012
  state.server = s.url
1013
+ state.token = serverToken(s)
1014
+ LS.set('token', state.token)
881
1015
  saveServers()
1016
+ syncBgConfig()
882
1017
  renderServers()
883
1018
  toast(t('servers.manualSelected', { url: serverTitle(s) }), 'ok')
884
1019
  if (state.token) { openStreams(); refreshAll() }
885
1020
  }))
886
1021
 
887
- const cur = state.servers.find(s => s.url === state.server)
1022
+ const cur = currentServerEntry()
888
1023
  const curNote = cur ? (cur.note || cur.url) : ''
889
1024
  const curGroup = cur ? cur.group : state.activeGroup
890
- const curMs = state.serverLatency[state.server]
1025
+ const curMs = cur ? state.serverLatency[serverKey(cur)] : undefined
891
1026
  $('server-desc').textContent = state.server
892
1027
  ? t('servers.currentDescGroup', { group: curGroup, url: curNote, ms: Number.isFinite(curMs) ? curMs + 'ms' : '—' })
893
1028
  : (CAP?.isNativePlatform?.() ? t('servers.notSet') : t('servers.defaultDesc'))
@@ -904,9 +1039,10 @@ async function addServer() {
904
1039
  } catch {
905
1040
  return toast(t('servers.badProtocol'), 'err')
906
1041
  }
907
- if (state.servers.some(s => s.url === raw)) return toast(t('servers.duplicate'))
1042
+ const token = normalizedServerToken(state.token)
1043
+ if (state.servers.some(s => serverIdentity(s.url, serverToken(s)) === serverIdentity(raw, token))) return toast(t('servers.duplicate'))
908
1044
  let note = prompt(t('servers.promptNote'), '') || ''
909
- state.servers.push({ id: newServerId(), url: raw, note: note.trim(), group: state.activeGroup })
1045
+ state.servers.push({ id: newServerId(), url: raw, note: note.trim(), group: state.activeGroup, token })
910
1046
  saveServers()
911
1047
  if (input) input.value = ''
912
1048
  renderServers()
@@ -925,19 +1061,25 @@ function editServer(id) {
925
1061
  } catch {
926
1062
  return toast(t('servers.badProtocol'), 'err')
927
1063
  }
928
- if (state.servers.some(x => x.id !== id && x.url === raw)) return toast(t('servers.duplicate'))
1064
+ const tokenInput = prompt(t('servers.promptEditToken'), '')
1065
+ if (tokenInput === null) return
1066
+ const token = normalizedServerToken(tokenInput) || serverToken(s)
1067
+ if (state.servers.some(x => x.id !== id && serverIdentity(x.url, serverToken(x)) === serverIdentity(raw, token))) return toast(t('servers.duplicate'))
929
1068
  const note = prompt(t('servers.promptEditNote', { url: raw }), s.note || '')
930
1069
  if (note === null) return
931
1070
  const group = prompt(t('servers.promptEditGroup'), s.group || '默认')
932
1071
  if (group === null) return
933
- const wasActive = state.server === s.url
1072
+ const wasActive = state.server === s.url && state.token === serverToken(s)
934
1073
  const changedActiveUrl = wasActive && state.server !== raw
935
1074
  s.url = raw
936
1075
  s.note = note.trim()
937
1076
  s.group = ensureGroup(group.trim() || '默认')
1077
+ s.token = token
938
1078
  if (wasActive) {
939
1079
  if (changedActiveUrl) resetFsForServer()
940
1080
  state.server = raw
1081
+ state.token = token
1082
+ LS.set('token', token)
941
1083
  }
942
1084
  saveServers()
943
1085
  renderServers()
@@ -949,7 +1091,7 @@ function removeServer(id) {
949
1091
  const s = state.servers.find(x => x.id === id)
950
1092
  if (!s) return
951
1093
  state.servers = state.servers.filter(x => x.id !== id)
952
- const wasActive = state.server === s.url
1094
+ const wasActive = state.server === s.url && state.token === serverToken(s)
953
1095
  // 清理该组手动指定
954
1096
  for (const g of state.groups) if (state.groupActive[g] === id) state.groupActive[g] = ''
955
1097
  saveServers()
@@ -4344,22 +4486,42 @@ async function submitAnnouncementVote() {
4344
4486
  if (!poll || !option) return toast(t('announcement.voteChoose'), 'err')
4345
4487
  const button = $('announcement-poll-submit')
4346
4488
  button.disabled = true
4489
+ const payload = {
4490
+ type: 'poll',
4491
+ message: `Poll ${poll.id}: ${option.id}`,
4492
+ announcementId: item.id,
4493
+ pollId: poll.id,
4494
+ optionId: option.id,
4495
+ appVersion: state.localVersion
4496
+ }
4347
4497
  try {
4348
4498
  const base = updateBase()
4349
- if (!base) throw new Error(t('announcement.voteNetworkError'))
4350
- const res = await fetch(base + '/feedback', {
4351
- method: 'POST',
4352
- headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token },
4353
- body: JSON.stringify({
4354
- type: 'poll',
4355
- message: `Poll ${poll.id}: ${option.id}`,
4356
- announcementId: item.id,
4357
- pollId: poll.id,
4358
- optionId: option.id,
4359
- appVersion: state.localVersion
4499
+ let res = null
4500
+ let data = {}
4501
+ const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(8000) : undefined
4502
+ if (base) {
4503
+ try {
4504
+ res = await fetch(base + '/feedback', {
4505
+ method: 'POST',
4506
+ headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token },
4507
+ body: JSON.stringify(payload),
4508
+ ...(signal ? { signal } : {})
4509
+ })
4510
+ data = await res.json().catch(() => ({}))
4511
+ } catch {}
4512
+ }
4513
+ // 没有网关地址、网关网络错误或令牌失效时,投票改走公网收集器。
4514
+ if (!res || res.status === 401) {
4515
+ const directSignal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(8000) : undefined
4516
+ res = await fetch(DIRECT_POLL_FEEDBACK_URL, {
4517
+ method: 'POST',
4518
+ mode: 'cors',
4519
+ headers: { 'content-type': 'application/json' },
4520
+ body: JSON.stringify(payload),
4521
+ ...(directSignal ? { signal: directSignal } : {})
4360
4522
  })
4361
- })
4362
- const data = await res.json().catch(() => ({}))
4523
+ data = await res.json().catch(() => ({}))
4524
+ }
4363
4525
  if (res.ok && data.ok) {
4364
4526
  storeAnnouncementVote(item.id, poll.id, option.id)
4365
4527
  markAnnouncementSeen(item.id)
@@ -5598,8 +5760,8 @@ function updateConn() {
5598
5760
  // mux/host 的打开顺序不稳定;连接状态改变时同步重绘总览,避免后打开的
5599
5761
  // 通道只更新顶栏、总览却永久停留在 3/4。
5600
5762
  renderOverview()
5601
- const cur = state.servers.find(s => s.url === state.server)
5602
- const ms = state.serverLatency[state.server]
5763
+ const cur = currentServerEntry()
5764
+ const ms = cur ? state.serverLatency[serverKey(cur)] : undefined
5603
5765
  const curGroup = cur ? cur.group : state.activeGroup
5604
5766
  const curLabel = cur ? (cur.note || cur.url) : (state.server || t('speed.origin'))
5605
5767
  const titleBase = t('conn.titleGroup', { group: curGroup, url: curLabel, ms: Number.isFinite(ms) ? ms + 'ms' : '—' })
@@ -5725,16 +5887,23 @@ function applyPairUrl(url) {
5725
5887
  .map(value => value.trim().replace(/\/+$/, ''))
5726
5888
  .filter(value => /^https?:\/\//i.test(value)))]
5727
5889
  if (!tok || !servers.length) return false
5890
+ const previousServer = state.server
5891
+ const previousToken = state.token
5728
5892
  state.token = tok
5729
5893
  LS.set('token', tok)
5730
- if (state.server !== servers[0]) resetFsForServer()
5731
- state.server = servers[0]
5732
5894
  for (let i = servers.length - 1; i >= 0; i--) {
5733
5895
  const server = servers[i]
5734
- if (!state.servers.some(s => s.url === server)) {
5735
- state.servers.unshift({ id: newServerId(), url: server, note: '', group: state.activeGroup })
5896
+ if (!state.servers.some(s => s.url === server && normalizedServerToken(s.token) === tok)) {
5897
+ state.servers.unshift({ id: newServerId(), url: server, note: '', group: state.activeGroup, token: tok })
5736
5898
  }
5737
5899
  }
5900
+ const first = state.servers.find(s => s.url === servers[0] && normalizedServerToken(s.token) === tok)
5901
+ if (!first) return false
5902
+ if (previousServer !== first.url || previousToken !== tok) resetFsForServer()
5903
+ state.server = first.url
5904
+ state.token = tok
5905
+ if (!state.groupActive || typeof state.groupActive !== 'object') state.groupActive = {}
5906
+ state.groupActive[state.activeGroup] = first.id
5738
5907
  saveServers()
5739
5908
  renderServers()
5740
5909
  $('token-desc').textContent = t('token.savedScan')
@@ -6027,6 +6196,14 @@ function initToken() {
6027
6196
  state.token = LS.get('token', '')
6028
6197
  }
6029
6198
  loadServers()
6199
+ if (urlToken) {
6200
+ state.token = normalizedServerToken(urlToken)
6201
+ LS.set('token', state.token)
6202
+ const currentId = state.groupActive[state.activeGroup]
6203
+ const current = state.servers.find(s => s.id === currentId) || state.servers.find(s => s.url === state.server)
6204
+ if (current) current.token = state.token
6205
+ saveServers()
6206
+ }
6030
6207
  $('token-desc').textContent = state.token ? t('token.savedLocal') : t('token.notSet')
6031
6208
  $('server-desc').textContent = state.server || t('servers.defaultDesc')
6032
6209
  }
@@ -6310,6 +6487,9 @@ function bindUi() {
6310
6487
  $('notes-pages').addEventListener('scroll', updateNotesPage)
6311
6488
  $('modal-notes').addEventListener('click', (e) => { if (e.target === $('modal-notes')) closeNotesModal() })
6312
6489
  renderServers()
6490
+ document.querySelectorAll('[data-stats-mode]').forEach(button =>
6491
+ button.addEventListener('click', () => setStatsChartMode(button.dataset.statsMode)))
6492
+ applyStatsChartMode()
6313
6493
  // 底部导航
6314
6494
  document.querySelectorAll('.nav-btn').forEach(b =>
6315
6495
  b.addEventListener('click', () => showView(b.dataset.view)))
@@ -6615,7 +6795,7 @@ function bindUi() {
6615
6795
  $('modal-scan-live')?.addEventListener('click', e => { if (e.target === $('modal-scan-live')) closeLiveScan('') })
6616
6796
  $('btn-change-token').addEventListener('click', () => {
6617
6797
  const input = prompt(t('token.prompt'), state.token)
6618
- if (input && input.trim()) { state.token = input.trim(); LS.set('token', input.trim()); $('token-desc').textContent = t('token.saved'); toast(t('token.savedReconnect'), 'ok'); openStreams(); refreshAll(); syncBgConfig() }
6798
+ if (input && setCurrentAccessToken(input)) { $('token-desc').textContent = t('token.saved'); toast(t('token.savedReconnect'), 'ok'); openStreams(); refreshAll(); syncBgConfig() }
6619
6799
  })
6620
6800
  $('btn-server-speed').addEventListener('click', () => selectFastestServer({ silent: false }))
6621
6801
  $('btn-server-add').addEventListener('click', addServer)
@@ -387,6 +387,7 @@ button.ds-overview-attention-item, button.ds-overview-session-item { cursor:poin
387
387
  .ds-server-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
388
388
  .ds-server-note { font-size: 13px; word-break: break-all; }
389
389
  .ds-server-url { font-size: 11.5px; color: var(--dsr-muted); word-break: break-all; }
390
+ .ds-server-token-badge { color: var(--dsr-muted); font-size: 10px; }
390
391
  .ds-server-badge { flex-shrink: 0; font-size: 11px; padding: 1px 7px; border-radius: 999px; background: var(--dsr-accent-soft); color: var(--dsr-accent-strong); border: 1px solid var(--dsr-accent-line); white-space: nowrap; }
391
392
  .ds-server-badge.good { background: var(--dsr-success-soft); color: var(--dsr-success); border-color: var(--dsr-success-line); }
392
393
  .ds-server-badge.bad { background: var(--dsr-warning-soft); color: var(--dsr-warning); border-color: var(--dsr-warning-line); }
@@ -414,12 +415,15 @@ button.ds-overview-attention-item, button.ds-overview-session-item { cursor:poin
414
415
  .ds-stats-legend .sw { width: 9px; height: 9px; border-radius: 2px; display: inline-block; margin-right: 4px; }
415
416
  .ds-stats-legend .sw.peak { background: var(--dsr-accent-strong); }
416
417
  .ds-stats-legend .sw.off { background: var(--dsr-accent-2); }
418
+ .ds-stats-legend .sw.token { background: var(--dsr-accent); }
419
+ .ds-stats-chart-title { margin-top: 13px; font-size: 11px; font-weight: 650; color: var(--dsr-muted); }
417
420
  .ds-stats-chart { display: flex; align-items: flex-end; gap: 8px; height: 200px; padding: 12px 14px 10px; background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: 12px; overflow-x: auto; }
418
421
  .ds-stats-bar { flex: 1; min-width: 40px; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; gap: 5px; }
419
422
  .ds-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); }
420
423
  .ds-stats-bar .seg { width: 100%; }
421
424
  .ds-stats-bar .seg.peak { background: var(--dsr-accent-strong); }
422
425
  .ds-stats-bar .seg.off { background: var(--dsr-accent-2); }
426
+ .ds-stats-bar .bars.token { background: var(--dsr-accent); }
423
427
  .ds-stats-bar .lbl { font-size: 10px; color: var(--dsr-muted); white-space: nowrap; }
424
428
  .ds-stats-bar .val { font-size: 10px; white-space: nowrap; }
425
429
  .ds-stats-note { font-size: 11px; color: var(--dsr-muted); line-height: 1.6; margin-top: 8px; }