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.
- package/apk/dsh-remote.apk +0 -0
- package/gateway-stats.cjs +3 -2
- package/package.json +1 -1
- package/public/app.js +241 -61
- package/public/desktop/desktop.css +4 -0
- package/public/desktop/desktop.html +16 -12
- package/public/desktop/desktop.js +114 -32
- package/public/index.html +58 -19
- package/public/styles.css +87 -19
- package/public/update.json +8 -8
- package/public/version.json +1 -1
package/apk/dsh-remote.apk
CHANGED
|
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.
|
|
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: {}, //
|
|
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
|
-
<
|
|
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><
|
|
467
|
-
<div class="b"><span class="n">${t('statsPage.cacheRead')}</span><
|
|
468
|
-
<div class="b"><span class="n">${t('statsPage.cacheWrite')}</span><
|
|
469
|
-
<div class="b"><span class="n">${t('statsPage.output')}</span><
|
|
470
|
-
</div
|
|
471
|
-
|
|
472
|
-
<
|
|
473
|
-
|
|
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
|
|
484
|
-
|
|
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
|
-
|
|
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
|
|
798
|
+
const list = activeServers().map(s => ({ ...s, token: serverToken(s) }))
|
|
677
799
|
// 浏览器控制台: 当前页面(同源网关)也作为候选, 通常 0 跳内最快
|
|
678
|
-
if (!CAP?.isNativePlatform?.() && location.origin && !list.
|
|
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 (
|
|
733
|
-
for (const [
|
|
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(
|
|
736
|
-
.sort((a, b) => state.serverLatency[a] - state.serverLatency[b])[0] || null
|
|
737
|
-
const sameOrigin = !CAP?.isNativePlatform?.() && best === location.origin
|
|
738
|
-
chosen = best ? (sameOrigin &&
|
|
739
|
-
|
|
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
|
-
|
|
746
|
-
|
|
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.
|
|
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
|
|
961
|
+
const ms = state.serverLatency[serverKey(s)]
|
|
831
962
|
let badge = `<span class="server-badge">${t('servers.untested')}</span>`
|
|
832
|
-
|
|
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 ?
|
|
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 =
|
|
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[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
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
|
-
|
|
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 =
|
|
5602
|
-
const ms = state.serverLatency[
|
|
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
|
|
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; }
|