dsh-remote-plugin 0.6.16 → 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/gateway.cjs +87 -8
- package/index.mjs +21 -3
- package/package.json +1 -1
- package/public/admin.html +11 -6
- package/public/admin.js +88 -14
- package/public/app.js +430 -86
- package/public/desktop/desktop.css +5 -0
- package/public/desktop/desktop.html +20 -15
- package/public/desktop/desktop.js +281 -52
- package/public/index.html +58 -19
- package/public/styles.css +87 -19
- package/public/update.json +12 -12
- package/public/version.json +1 -1
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: [],
|
|
@@ -90,7 +93,7 @@ const state = {
|
|
|
90
93
|
streamMode: 'ws', // 'ws' | 'poll'
|
|
91
94
|
pollSeq: { mux: 0, host: 0 },
|
|
92
95
|
refreshTimer: null,
|
|
93
|
-
fs: { path: null, initial: null, loaded: false, upload: null, workspaceId: LS.get('fsWorkspaceIdV1', ''), preview: null },
|
|
96
|
+
fs: { path: null, initial: null, loaded: false, upload: null, workspaceId: LS.get('fsWorkspaceIdV1', ''), roots: [], rootIndex: 0, preview: null },
|
|
94
97
|
composerImages: [], // 当前草稿中的图片附件:只在发送成功后释放
|
|
95
98
|
models: { loaded: false, loading: false, groups: [], current: null, failures: [] },
|
|
96
99
|
modelSettings: { status: 'idle', error: '', writable: false, hasDocument: false, providers: [], namespaces: [], credentials: {} },
|
|
@@ -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 = {
|
|
@@ -536,6 +628,55 @@ async function safeRpc(method, payload, errText) {
|
|
|
536
628
|
}
|
|
537
629
|
}
|
|
538
630
|
|
|
631
|
+
let hostDescribePromise = null
|
|
632
|
+
let hostDescribeRetryTimer = null
|
|
633
|
+
let hostDescribeFailures = 0
|
|
634
|
+
|
|
635
|
+
function scheduleHostDescribeRetry() {
|
|
636
|
+
if (!state.token || hostDescribeRetryTimer) return
|
|
637
|
+
const delays = [2000, 5000, 15000, 30000, 60000]
|
|
638
|
+
const delay = delays[Math.min(Math.max(0, hostDescribeFailures - 1), delays.length - 1)]
|
|
639
|
+
hostDescribeRetryTimer = setTimeout(() => {
|
|
640
|
+
hostDescribeRetryTimer = null
|
|
641
|
+
void refreshHostDescription()
|
|
642
|
+
}, delay)
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
async function refreshHostDescription({ notify = false } = {}) {
|
|
646
|
+
if (!state.token) return null
|
|
647
|
+
if (hostDescribePromise) return hostDescribePromise
|
|
648
|
+
const server = state.server
|
|
649
|
+
hostDescribePromise = (async () => {
|
|
650
|
+
try {
|
|
651
|
+
const host = await rpc('host.describe', {}, 5000)
|
|
652
|
+
if (server !== state.server) return null
|
|
653
|
+
state.hostInfo = host
|
|
654
|
+
const health = activeGatewayHealth()
|
|
655
|
+
if (health) health.upstreamReachable = true
|
|
656
|
+
hostDescribeFailures = 0
|
|
657
|
+
if (hostDescribeRetryTimer) clearTimeout(hostDescribeRetryTimer)
|
|
658
|
+
hostDescribeRetryTimer = null
|
|
659
|
+
const desc = $('host-desc')
|
|
660
|
+
if (desc && host) desc.textContent = t('settings.hostDesc', { version: host.version, cwd: host.cwd, n: host.attachedSessions })
|
|
661
|
+
renderOverview()
|
|
662
|
+
return host
|
|
663
|
+
} catch (error) {
|
|
664
|
+
if (server !== state.server) return null
|
|
665
|
+
if (error.message === 'AUTH') authFailure()
|
|
666
|
+
else {
|
|
667
|
+
hostDescribeFailures++
|
|
668
|
+
scheduleHostDescribeRetry()
|
|
669
|
+
if (notify) toast(`${t('settings.probeFailed')}:${error.message}`, 'err')
|
|
670
|
+
}
|
|
671
|
+
return null
|
|
672
|
+
} finally {
|
|
673
|
+
hostDescribePromise = null
|
|
674
|
+
if (server !== state.server) scheduleHostDescribeRetry()
|
|
675
|
+
}
|
|
676
|
+
})()
|
|
677
|
+
return hostDescribePromise
|
|
678
|
+
}
|
|
679
|
+
|
|
539
680
|
function authFailure() {
|
|
540
681
|
toast(t('err.accessDenied'), 'err')
|
|
541
682
|
showView('view-settings')
|
|
@@ -563,6 +704,30 @@ function activeServers() {
|
|
|
563
704
|
return groupServers(state.activeGroup)
|
|
564
705
|
}
|
|
565
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
|
+
|
|
566
731
|
/** 旧结构迁移: servers(string[]) + server/activeServer -> 新分组结构, 幂等(仅当 servers-v2 不存在时执行)。 */
|
|
567
732
|
function migrateServersV1() {
|
|
568
733
|
if (LS.get('servers-v2', null) !== null) return
|
|
@@ -574,7 +739,7 @@ function migrateServersV1() {
|
|
|
574
739
|
}
|
|
575
740
|
const urls = arr.map(s => String(s || '').trim().replace(/\/+$/, ''))
|
|
576
741
|
.filter(s => /^https?:\/\//i.test(s))
|
|
577
|
-
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) }))
|
|
578
743
|
state.groups = ['默认']
|
|
579
744
|
state.activeGroup = '默认'
|
|
580
745
|
state.autoSelect = { '默认': true }
|
|
@@ -597,8 +762,9 @@ function loadServers() {
|
|
|
597
762
|
migrateServersV1()
|
|
598
763
|
return
|
|
599
764
|
}
|
|
765
|
+
const legacyToken = normalizedServerToken(state.token || LS.get('token', ''))
|
|
600
766
|
state.servers = data.servers.filter(s => s && typeof s.url === 'string')
|
|
601
|
-
.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) }))
|
|
602
768
|
state.groups = Array.isArray(data.groups) && data.groups.length ? data.groups : ['默认']
|
|
603
769
|
state.activeGroup = state.groups.includes(data.activeGroup) ? data.activeGroup : '默认'
|
|
604
770
|
state.autoSelect = data.autoSelect || {}
|
|
@@ -609,7 +775,12 @@ function loadServers() {
|
|
|
609
775
|
// 恢复当前连接地址
|
|
610
776
|
const manual = state.groupActive[state.activeGroup]
|
|
611
777
|
const manualSrv = manual ? state.servers.find(s => s.id === manual) : null
|
|
612
|
-
|
|
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
|
+
}
|
|
613
784
|
}
|
|
614
785
|
|
|
615
786
|
function saveServers() {
|
|
@@ -624,9 +795,11 @@ function saveServers() {
|
|
|
624
795
|
}
|
|
625
796
|
|
|
626
797
|
function serverCandidates() {
|
|
627
|
-
const list = activeServers().map(s => s
|
|
798
|
+
const list = activeServers().map(s => ({ ...s, token: serverToken(s) }))
|
|
628
799
|
// 浏览器控制台: 当前页面(同源网关)也作为候选, 通常 0 跳内最快
|
|
629
|
-
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
|
+
}
|
|
630
803
|
return list
|
|
631
804
|
}
|
|
632
805
|
|
|
@@ -640,7 +813,10 @@ async function pingServer(base) {
|
|
|
640
813
|
const res = await fetch(u + '/health?t=' + Date.now(), { signal: ctrl.signal, cache: 'no-store' })
|
|
641
814
|
if (!res.ok) return Infinity
|
|
642
815
|
const health = await res.json().catch(() => null)
|
|
643
|
-
if (health && typeof health === 'object')
|
|
816
|
+
if (health && typeof health === 'object') {
|
|
817
|
+
state.gatewayHealth[u] = health
|
|
818
|
+
renderOverview()
|
|
819
|
+
}
|
|
644
820
|
return Math.round(performance.now() - t0)
|
|
645
821
|
} catch {
|
|
646
822
|
return Infinity
|
|
@@ -650,12 +826,22 @@ async function pingServer(base) {
|
|
|
650
826
|
}
|
|
651
827
|
|
|
652
828
|
function activeGatewayCapability(name) {
|
|
653
|
-
const
|
|
654
|
-
const capabilities = state.gatewayHealth[key]?.capabilities
|
|
829
|
+
const capabilities = activeGatewayHealth()?.capabilities
|
|
655
830
|
if (!capabilities || !Object.prototype.hasOwnProperty.call(capabilities, name)) return null
|
|
656
831
|
return Number(capabilities[name]) > 0
|
|
657
832
|
}
|
|
658
833
|
|
|
834
|
+
function activeGatewayHealth() {
|
|
835
|
+
const key = String(state.server || location.origin || '').replace(/\/+$/, '')
|
|
836
|
+
return state.gatewayHealth[key] || null
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function dshReachable() {
|
|
840
|
+
const health = activeGatewayHealth()
|
|
841
|
+
if (health && typeof health.upstreamReachable === 'boolean') return health.upstreamReachable
|
|
842
|
+
return !!state.hostInfo
|
|
843
|
+
}
|
|
844
|
+
|
|
659
845
|
async function selectFastestServer({ silent = false, reconnect = true } = {}) {
|
|
660
846
|
if (state.selectingServer) return null
|
|
661
847
|
state.selectingServer = true
|
|
@@ -663,44 +849,56 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
|
|
|
663
849
|
if (!silent) toast(t('speed.testing'))
|
|
664
850
|
const candidates = serverCandidates()
|
|
665
851
|
let chosen = ''
|
|
852
|
+
let chosenToken = state.token
|
|
666
853
|
let best = null
|
|
667
854
|
let ms = Infinity
|
|
668
855
|
|
|
669
856
|
if (state.autoSelect[state.activeGroup] !== false) {
|
|
670
|
-
const measured = await Promise.all(candidates.map(async (
|
|
671
|
-
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
|
|
672
859
|
best = candidates
|
|
673
|
-
.filter(
|
|
674
|
-
.sort((a, b) => state.serverLatency[a] - state.serverLatency[b])[0] || null
|
|
675
|
-
const sameOrigin = !CAP?.isNativePlatform?.() && best === location.origin
|
|
676
|
-
chosen = best ? (sameOrigin &&
|
|
677
|
-
|
|
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
|
|
678
866
|
} else {
|
|
679
867
|
const manual = state.groupActive[state.activeGroup]
|
|
680
868
|
const manualSrv = manual ? state.servers.find(s => s.id === manual) : null
|
|
681
869
|
chosen = manualSrv ? manualSrv.url : (activeServers()[0]?.url || '')
|
|
870
|
+
chosenToken = manualSrv ? serverToken(manualSrv) : state.token
|
|
682
871
|
if (chosen && !silent) {
|
|
683
|
-
|
|
684
|
-
|
|
872
|
+
const key = serverKey(manualSrv || { url: chosen, token: state.token })
|
|
873
|
+
state.serverLatency[key] = await pingServer(chosen)
|
|
874
|
+
ms = state.serverLatency[key]
|
|
685
875
|
}
|
|
686
876
|
}
|
|
687
877
|
|
|
688
878
|
renderServers()
|
|
689
|
-
if (chosen !== state.server) {
|
|
879
|
+
if (chosen !== state.server || chosenToken !== state.token) {
|
|
880
|
+
state.hostInfo = null
|
|
881
|
+
hostDescribeFailures = 0
|
|
882
|
+
if (hostDescribeRetryTimer) clearTimeout(hostDescribeRetryTimer)
|
|
883
|
+
hostDescribeRetryTimer = null
|
|
884
|
+
resetFsForServer()
|
|
690
885
|
state.server = chosen
|
|
886
|
+
state.token = chosenToken
|
|
887
|
+
LS.set('token', state.token)
|
|
691
888
|
if (state.autoSelect[state.activeGroup] !== false && best) {
|
|
692
|
-
const srv = state.servers.find(s => s.
|
|
889
|
+
const srv = state.servers.find(s => s.id === best.id)
|
|
693
890
|
if (srv) state.groupActive[state.activeGroup] = srv.id
|
|
694
891
|
}
|
|
695
892
|
saveServers()
|
|
696
893
|
syncBgConfig()
|
|
894
|
+
renderServers()
|
|
697
895
|
if (!silent) {
|
|
698
896
|
if (chosen) toast(t('speed.switched', { url: chosen, ms: Number.isFinite(ms) ? ms : 0 }), 'ok')
|
|
699
897
|
else toast(t('speed.switchedOrigin'), 'ok')
|
|
700
898
|
}
|
|
701
899
|
if (reconnect && state.token) { openStreams(); refreshAll() }
|
|
702
900
|
} else if (!silent) {
|
|
703
|
-
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')
|
|
704
902
|
else if (chosen) toast(t('speed.manualUsing', { url: chosen, ms: Number.isFinite(ms) ? ms : '—' }), 'ok')
|
|
705
903
|
else toast(t('speed.allDown'), 'err')
|
|
706
904
|
}
|
|
@@ -760,13 +958,14 @@ function renderServers() {
|
|
|
760
958
|
</div>
|
|
761
959
|
<div class="srv-group-body ${g === state.activeGroup ? '' : 'hidden'}">
|
|
762
960
|
${list.map(s => {
|
|
763
|
-
const ms = state.serverLatency[s
|
|
961
|
+
const ms = state.serverLatency[serverKey(s)]
|
|
764
962
|
let badge = `<span class="server-badge">${t('servers.untested')}</span>`
|
|
765
|
-
|
|
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>`
|
|
766
965
|
else if (ms !== undefined) badge = '<span class="server-badge bad">' + t('servers.unreachable') + '</span>'
|
|
767
|
-
const activeInGroup = auto ?
|
|
966
|
+
const activeInGroup = auto ? active : s.id === activeManual
|
|
768
967
|
return `<div class="server-row ${activeInGroup ? 'active' : ''}" data-use-server="${esc(s.id)}">
|
|
769
|
-
<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}
|
|
770
969
|
<button class="server-edit" data-edit-server="${esc(s.id)}" title="${t('servers.edit')}">✎</button>
|
|
771
970
|
<button class="server-del" data-del-server="${esc(s.id)}" aria-label="${t('servers.delete')}">✕</button>
|
|
772
971
|
</div>`
|
|
@@ -809,17 +1008,21 @@ function renderServers() {
|
|
|
809
1008
|
// 手动模式: 点击条目 = 选中该服务器连接
|
|
810
1009
|
state.groupActive[group] = id
|
|
811
1010
|
state.activeGroup = group
|
|
1011
|
+
if (state.server !== s.url) resetFsForServer()
|
|
812
1012
|
state.server = s.url
|
|
1013
|
+
state.token = serverToken(s)
|
|
1014
|
+
LS.set('token', state.token)
|
|
813
1015
|
saveServers()
|
|
1016
|
+
syncBgConfig()
|
|
814
1017
|
renderServers()
|
|
815
1018
|
toast(t('servers.manualSelected', { url: serverTitle(s) }), 'ok')
|
|
816
1019
|
if (state.token) { openStreams(); refreshAll() }
|
|
817
1020
|
}))
|
|
818
1021
|
|
|
819
|
-
const cur =
|
|
1022
|
+
const cur = currentServerEntry()
|
|
820
1023
|
const curNote = cur ? (cur.note || cur.url) : ''
|
|
821
1024
|
const curGroup = cur ? cur.group : state.activeGroup
|
|
822
|
-
const curMs = state.serverLatency[
|
|
1025
|
+
const curMs = cur ? state.serverLatency[serverKey(cur)] : undefined
|
|
823
1026
|
$('server-desc').textContent = state.server
|
|
824
1027
|
? t('servers.currentDescGroup', { group: curGroup, url: curNote, ms: Number.isFinite(curMs) ? curMs + 'ms' : '—' })
|
|
825
1028
|
: (CAP?.isNativePlatform?.() ? t('servers.notSet') : t('servers.defaultDesc'))
|
|
@@ -836,9 +1039,10 @@ async function addServer() {
|
|
|
836
1039
|
} catch {
|
|
837
1040
|
return toast(t('servers.badProtocol'), 'err')
|
|
838
1041
|
}
|
|
839
|
-
|
|
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'))
|
|
840
1044
|
let note = prompt(t('servers.promptNote'), '') || ''
|
|
841
|
-
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 })
|
|
842
1046
|
saveServers()
|
|
843
1047
|
if (input) input.value = ''
|
|
844
1048
|
renderServers()
|
|
@@ -857,16 +1061,26 @@ function editServer(id) {
|
|
|
857
1061
|
} catch {
|
|
858
1062
|
return toast(t('servers.badProtocol'), 'err')
|
|
859
1063
|
}
|
|
860
|
-
|
|
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'))
|
|
861
1068
|
const note = prompt(t('servers.promptEditNote', { url: raw }), s.note || '')
|
|
862
1069
|
if (note === null) return
|
|
863
1070
|
const group = prompt(t('servers.promptEditGroup'), s.group || '默认')
|
|
864
1071
|
if (group === null) return
|
|
865
|
-
const wasActive = state.server === s.url
|
|
1072
|
+
const wasActive = state.server === s.url && state.token === serverToken(s)
|
|
1073
|
+
const changedActiveUrl = wasActive && state.server !== raw
|
|
866
1074
|
s.url = raw
|
|
867
1075
|
s.note = note.trim()
|
|
868
1076
|
s.group = ensureGroup(group.trim() || '默认')
|
|
869
|
-
|
|
1077
|
+
s.token = token
|
|
1078
|
+
if (wasActive) {
|
|
1079
|
+
if (changedActiveUrl) resetFsForServer()
|
|
1080
|
+
state.server = raw
|
|
1081
|
+
state.token = token
|
|
1082
|
+
LS.set('token', token)
|
|
1083
|
+
}
|
|
870
1084
|
saveServers()
|
|
871
1085
|
renderServers()
|
|
872
1086
|
toast(t('servers.edited'), 'ok')
|
|
@@ -877,7 +1091,7 @@ function removeServer(id) {
|
|
|
877
1091
|
const s = state.servers.find(x => x.id === id)
|
|
878
1092
|
if (!s) return
|
|
879
1093
|
state.servers = state.servers.filter(x => x.id !== id)
|
|
880
|
-
const wasActive = state.server === s.url
|
|
1094
|
+
const wasActive = state.server === s.url && state.token === serverToken(s)
|
|
881
1095
|
// 清理该组手动指定
|
|
882
1096
|
for (const g of state.groups) if (state.groupActive[g] === id) state.groupActive[g] = ''
|
|
883
1097
|
saveServers()
|
|
@@ -1090,7 +1304,11 @@ function openStream(kind, handler, refreshOnOpen, isRestore, ticket = null) {
|
|
|
1090
1304
|
renderPending()
|
|
1091
1305
|
}
|
|
1092
1306
|
if (refreshOnOpen) refreshAll()
|
|
1093
|
-
if (allStreamsOpen())
|
|
1307
|
+
if (allStreamsOpen()) {
|
|
1308
|
+
resyncAfterStreamOpen()
|
|
1309
|
+
void pingServer(state.server || location.origin)
|
|
1310
|
+
void refreshHostDescription()
|
|
1311
|
+
}
|
|
1094
1312
|
}
|
|
1095
1313
|
ws.onmessage = (msg) => {
|
|
1096
1314
|
if (!streamIsCurrent(kind, ws, generation)) return
|
|
@@ -1573,7 +1791,13 @@ function workspaceOptionLabel(workspace) {
|
|
|
1573
1791
|
function workspaceOptionsHtml({ all = false, ungrouped = false, root = false, selected = '' } = {}) {
|
|
1574
1792
|
const rows = []
|
|
1575
1793
|
if (all) rows.push({ id: '', label: t('workspace.all') })
|
|
1576
|
-
if (root)
|
|
1794
|
+
if (root) {
|
|
1795
|
+
const roots = state.fs.roots.length ? state.fs.roots : ['']
|
|
1796
|
+
roots.forEach((rootPath, index) => rows.push({
|
|
1797
|
+
id: index === 0 ? '' : `__fs_root__:${index}`,
|
|
1798
|
+
label: rootPath && roots.length > 1 ? `${t('fs.root')} — ${rootPath}` : t('fs.root'),
|
|
1799
|
+
}))
|
|
1800
|
+
}
|
|
1577
1801
|
for (const workspace of workspaceItems()) rows.push({ id: workspace.workspaceId, label: workspaceOptionLabel(workspace) })
|
|
1578
1802
|
if (ungrouped) rows.push({ id: WORKSPACE_UNGROUPED, label: t('workspace.ungrouped') })
|
|
1579
1803
|
return rows.map(row => `<option value="${esc(row.id)}"${row.id === selected ? ' selected' : ''}>${esc(row.label)}</option>`).join('')
|
|
@@ -1601,7 +1825,8 @@ function renderWorkspaceNavigation() {
|
|
|
1601
1825
|
}
|
|
1602
1826
|
const fsSelect = $('fs-workspace')
|
|
1603
1827
|
if (fsSelect) {
|
|
1604
|
-
|
|
1828
|
+
const selected = state.fs.workspaceId || (state.fs.rootIndex > 0 ? `__fs_root__:${state.fs.rootIndex}` : '')
|
|
1829
|
+
fsSelect.innerHTML = workspaceOptionsHtml({ root: true, selected })
|
|
1605
1830
|
syncCustomSelect(fsSelect)
|
|
1606
1831
|
}
|
|
1607
1832
|
if ($('modal-new-session') && !$('modal-new-session').classList.contains('hidden')) renderNewSessionWorkspace()
|
|
@@ -3036,7 +3261,7 @@ function renderOverview() {
|
|
|
3036
3261
|
// 独立网关页面默认走同源,此时 state.server 合法地为空;不能因此把
|
|
3037
3262
|
// 已连接网关误报为离线。Capacitor 等非 HTTP 页面仍要求显式服务器。
|
|
3038
3263
|
gateway: !!state.token && (!!state.server || /^https?:$/.test(location.protocol)),
|
|
3039
|
-
dsh:
|
|
3264
|
+
dsh: dshReachable(),
|
|
3040
3265
|
mux: !!state.streamsOk?.mux,
|
|
3041
3266
|
host: !!state.streamsOk?.host
|
|
3042
3267
|
}
|
|
@@ -3280,14 +3505,80 @@ function fsHeaders() {
|
|
|
3280
3505
|
}
|
|
3281
3506
|
|
|
3282
3507
|
function fsJoin(dir, name) {
|
|
3283
|
-
|
|
3508
|
+
const meta = fsPathMeta(dir)
|
|
3509
|
+
if (!meta.value) return String(name || '')
|
|
3510
|
+
return meta.value.endsWith(meta.separator) ? meta.value + name : meta.value + meta.separator + name
|
|
3284
3511
|
}
|
|
3285
3512
|
|
|
3286
3513
|
function fsParent(p) {
|
|
3287
|
-
const
|
|
3288
|
-
|
|
3289
|
-
if (
|
|
3290
|
-
|
|
3514
|
+
const meta = fsPathMeta(p)
|
|
3515
|
+
if (!meta.value) return ''
|
|
3516
|
+
if (meta.root && fsPathEqual(meta.value, meta.root)) return meta.root
|
|
3517
|
+
const idx = meta.value.lastIndexOf(meta.separator)
|
|
3518
|
+
if (idx < 0) return meta.root || ''
|
|
3519
|
+
const parent = meta.value.slice(0, idx)
|
|
3520
|
+
return meta.root && parent.length < meta.root.length ? meta.root : (parent || meta.root)
|
|
3521
|
+
}
|
|
3522
|
+
|
|
3523
|
+
function fsPathMeta(input) {
|
|
3524
|
+
const source = String(input || '').trim()
|
|
3525
|
+
const windows = /^[A-Za-z]:(?:[\\/]|$)/.test(source) || /^[\\/]{2}[^\\/]+[\\/][^\\/]+/.test(source) || source.includes('\\')
|
|
3526
|
+
if (!windows) {
|
|
3527
|
+
let value = source.replace(/\/+/g, '/')
|
|
3528
|
+
const root = value.startsWith('/') ? '/' : ''
|
|
3529
|
+
if (root && value.length > root.length) value = value.replace(/\/+$/, '')
|
|
3530
|
+
return { value, root, separator: '/', windows: false }
|
|
3531
|
+
}
|
|
3532
|
+
let value = source.replace(/\//g, '\\')
|
|
3533
|
+
const unc = /^\\{2,}/.test(value)
|
|
3534
|
+
value = unc
|
|
3535
|
+
? '\\\\' + value.replace(/^\\+/, '').replace(/\\+/g, '\\')
|
|
3536
|
+
: value.replace(/\\+/g, '\\')
|
|
3537
|
+
let root = ''
|
|
3538
|
+
if (unc) {
|
|
3539
|
+
const parts = value.slice(2).split('\\').filter(Boolean)
|
|
3540
|
+
root = parts.length >= 2 ? `\\\\${parts[0]}\\${parts[1]}` : value
|
|
3541
|
+
} else {
|
|
3542
|
+
const drive = /^([A-Za-z]:)/.exec(value)
|
|
3543
|
+
if (drive) {
|
|
3544
|
+
root = drive[1] + '\\'
|
|
3545
|
+
if (value === drive[1]) value = root
|
|
3546
|
+
}
|
|
3547
|
+
}
|
|
3548
|
+
if (root && value.length > root.length) value = value.replace(/\\+$/, '')
|
|
3549
|
+
return { value, root, separator: '\\', windows: true }
|
|
3550
|
+
}
|
|
3551
|
+
|
|
3552
|
+
function fsPathKey(value) {
|
|
3553
|
+
const meta = fsPathMeta(value)
|
|
3554
|
+
return meta.windows ? meta.value.toLowerCase() : meta.value
|
|
3555
|
+
}
|
|
3556
|
+
|
|
3557
|
+
function fsPathEqual(left, right) {
|
|
3558
|
+
return fsPathKey(left) === fsPathKey(right)
|
|
3559
|
+
}
|
|
3560
|
+
|
|
3561
|
+
function fsPathInside(candidate, root) {
|
|
3562
|
+
const child = fsPathMeta(candidate)
|
|
3563
|
+
const boundary = fsPathMeta(root)
|
|
3564
|
+
if (!child.value || !boundary.value || child.windows !== boundary.windows) return false
|
|
3565
|
+
const childKey = child.windows ? child.value.toLowerCase() : child.value
|
|
3566
|
+
const rootKey = boundary.windows ? boundary.value.toLowerCase() : boundary.value
|
|
3567
|
+
if (childKey === rootKey) return true
|
|
3568
|
+
const prefix = rootKey.endsWith(boundary.separator) ? rootKey : rootKey + boundary.separator
|
|
3569
|
+
return childKey.startsWith(prefix)
|
|
3570
|
+
}
|
|
3571
|
+
|
|
3572
|
+
function resetFsForServer() {
|
|
3573
|
+
state.fs.path = null
|
|
3574
|
+
state.fs.initial = null
|
|
3575
|
+
state.fs.loaded = false
|
|
3576
|
+
state.fs.upload = null
|
|
3577
|
+
state.fs.preview = null
|
|
3578
|
+
state.fs.roots = []
|
|
3579
|
+
state.fs.rootIndex = 0
|
|
3580
|
+
state.fs.workspaceId = ''
|
|
3581
|
+
LS.del('fsWorkspaceIdV1')
|
|
3291
3582
|
}
|
|
3292
3583
|
|
|
3293
3584
|
const FS_PREVIEW_EXTENSIONS = new Set([
|
|
@@ -3403,7 +3694,14 @@ async function loadFs(dir, { silent = false, resetRoot = false } = {}) {
|
|
|
3403
3694
|
if (!res.ok || !Array.isArray(data.entries)) throw new Error(data.error === 'not-found' ? t('fs.notFound') : data.error === 'forbidden' ? t('fs.forbidden') : data.error || ('HTTP ' + res.status))
|
|
3404
3695
|
state.fs.path = data.path
|
|
3405
3696
|
if (!state.fs.initial) state.fs.initial = data.path
|
|
3697
|
+
if (Array.isArray(data.roots) && data.roots.length) state.fs.roots = data.roots.map(value => String(value || '')).filter(Boolean)
|
|
3698
|
+
if (!state.fs.roots.length) state.fs.roots = [data.path]
|
|
3699
|
+
if (!state.fs.workspaceId) {
|
|
3700
|
+
const rootIndex = state.fs.roots.findIndex(root => fsPathInside(data.path, root))
|
|
3701
|
+
if (rootIndex >= 0) state.fs.rootIndex = rootIndex
|
|
3702
|
+
}
|
|
3406
3703
|
state.fs.loaded = true
|
|
3704
|
+
renderWorkspaceNavigation()
|
|
3407
3705
|
renderFs(data)
|
|
3408
3706
|
} catch (e) {
|
|
3409
3707
|
if (e.message === 'AUTH') return
|
|
@@ -3807,11 +4105,12 @@ async function runFsUpload(up) {
|
|
|
3807
4105
|
|
|
3808
4106
|
function fsUp() {
|
|
3809
4107
|
if (!state.fs.path || !state.fs.initial) return
|
|
3810
|
-
|
|
4108
|
+
const parent = fsParent(state.fs.path)
|
|
4109
|
+
if (fsPathEqual(state.fs.path, state.fs.initial) || !fsPathInside(parent, state.fs.initial)) {
|
|
3811
4110
|
toast(t('fs.alreadyRoot'))
|
|
3812
4111
|
return
|
|
3813
4112
|
}
|
|
3814
|
-
loadFs(
|
|
4113
|
+
loadFs(parent)
|
|
3815
4114
|
}
|
|
3816
4115
|
|
|
3817
4116
|
function bindFsPullRefresh() {
|
|
@@ -4187,22 +4486,42 @@ async function submitAnnouncementVote() {
|
|
|
4187
4486
|
if (!poll || !option) return toast(t('announcement.voteChoose'), 'err')
|
|
4188
4487
|
const button = $('announcement-poll-submit')
|
|
4189
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
|
+
}
|
|
4190
4497
|
try {
|
|
4191
4498
|
const base = updateBase()
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
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 } : {})
|
|
4203
4522
|
})
|
|
4204
|
-
|
|
4205
|
-
|
|
4523
|
+
data = await res.json().catch(() => ({}))
|
|
4524
|
+
}
|
|
4206
4525
|
if (res.ok && data.ok) {
|
|
4207
4526
|
storeAnnouncementVote(item.id, poll.id, option.id)
|
|
4208
4527
|
markAnnouncementSeen(item.id)
|
|
@@ -5441,8 +5760,8 @@ function updateConn() {
|
|
|
5441
5760
|
// mux/host 的打开顺序不稳定;连接状态改变时同步重绘总览,避免后打开的
|
|
5442
5761
|
// 通道只更新顶栏、总览却永久停留在 3/4。
|
|
5443
5762
|
renderOverview()
|
|
5444
|
-
const cur =
|
|
5445
|
-
const ms = state.serverLatency[
|
|
5763
|
+
const cur = currentServerEntry()
|
|
5764
|
+
const ms = cur ? state.serverLatency[serverKey(cur)] : undefined
|
|
5446
5765
|
const curGroup = cur ? cur.group : state.activeGroup
|
|
5447
5766
|
const curLabel = cur ? (cur.note || cur.url) : (state.server || t('speed.origin'))
|
|
5448
5767
|
const titleBase = t('conn.titleGroup', { group: curGroup, url: curLabel, ms: Number.isFinite(ms) ? ms + 'ms' : '—' })
|
|
@@ -5568,15 +5887,23 @@ function applyPairUrl(url) {
|
|
|
5568
5887
|
.map(value => value.trim().replace(/\/+$/, ''))
|
|
5569
5888
|
.filter(value => /^https?:\/\//i.test(value)))]
|
|
5570
5889
|
if (!tok || !servers.length) return false
|
|
5890
|
+
const previousServer = state.server
|
|
5891
|
+
const previousToken = state.token
|
|
5571
5892
|
state.token = tok
|
|
5572
5893
|
LS.set('token', tok)
|
|
5573
|
-
state.server = servers[0]
|
|
5574
5894
|
for (let i = servers.length - 1; i >= 0; i--) {
|
|
5575
5895
|
const server = servers[i]
|
|
5576
|
-
if (!state.servers.some(s => s.url === server)) {
|
|
5577
|
-
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 })
|
|
5578
5898
|
}
|
|
5579
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
|
|
5580
5907
|
saveServers()
|
|
5581
5908
|
renderServers()
|
|
5582
5909
|
$('token-desc').textContent = t('token.savedScan')
|
|
@@ -5869,6 +6196,14 @@ function initToken() {
|
|
|
5869
6196
|
state.token = LS.get('token', '')
|
|
5870
6197
|
}
|
|
5871
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
|
+
}
|
|
5872
6207
|
$('token-desc').textContent = state.token ? t('token.savedLocal') : t('token.notSet')
|
|
5873
6208
|
$('server-desc').textContent = state.server || t('servers.defaultDesc')
|
|
5874
6209
|
}
|
|
@@ -6003,7 +6338,10 @@ function renderDshControlStatus(value) {
|
|
|
6003
6338
|
async function loadDshControl() {
|
|
6004
6339
|
if (!state.token || !$('dsh-control-desc')) return
|
|
6005
6340
|
if (activeGatewayCapability('dshLifecycle') === false) {
|
|
6006
|
-
renderDshControlStatus({
|
|
6341
|
+
renderDshControlStatus({
|
|
6342
|
+
supported: false,
|
|
6343
|
+
message: activeGatewayHealth()?.dshControl?.message || t('settings.dshUnsupported')
|
|
6344
|
+
})
|
|
6007
6345
|
return
|
|
6008
6346
|
}
|
|
6009
6347
|
try {
|
|
@@ -6149,6 +6487,9 @@ function bindUi() {
|
|
|
6149
6487
|
$('notes-pages').addEventListener('scroll', updateNotesPage)
|
|
6150
6488
|
$('modal-notes').addEventListener('click', (e) => { if (e.target === $('modal-notes')) closeNotesModal() })
|
|
6151
6489
|
renderServers()
|
|
6490
|
+
document.querySelectorAll('[data-stats-mode]').forEach(button =>
|
|
6491
|
+
button.addEventListener('click', () => setStatsChartMode(button.dataset.statsMode)))
|
|
6492
|
+
applyStatsChartMode()
|
|
6152
6493
|
// 底部导航
|
|
6153
6494
|
document.querySelectorAll('.nav-btn').forEach(b =>
|
|
6154
6495
|
b.addEventListener('click', () => showView(b.dataset.view)))
|
|
@@ -6282,12 +6623,20 @@ function bindUi() {
|
|
|
6282
6623
|
renderSessions()
|
|
6283
6624
|
})
|
|
6284
6625
|
$('fs-workspace').addEventListener('change', (e) => {
|
|
6285
|
-
|
|
6626
|
+
const rootMatch = /^__fs_root__:(\d+)$/.exec(e.target.value)
|
|
6627
|
+
if (rootMatch) {
|
|
6628
|
+
state.fs.rootIndex = Math.min(Number(rootMatch[1]), Math.max(0, state.fs.roots.length - 1))
|
|
6629
|
+
state.fs.workspaceId = ''
|
|
6630
|
+
} else {
|
|
6631
|
+
state.fs.rootIndex = 0
|
|
6632
|
+
state.fs.workspaceId = e.target.value
|
|
6633
|
+
}
|
|
6286
6634
|
if (state.fs.workspaceId) LS.set('fsWorkspaceIdV1', state.fs.workspaceId)
|
|
6287
6635
|
else LS.del('fsWorkspaceIdV1')
|
|
6288
6636
|
state.fs.loaded = false
|
|
6289
6637
|
const workspace = workspaceById(state.fs.workspaceId)
|
|
6290
|
-
|
|
6638
|
+
const rootPath = !workspace && rootMatch ? state.fs.roots[state.fs.rootIndex] : null
|
|
6639
|
+
loadFs(workspace?.path || rootPath || null, { resetRoot: true })
|
|
6291
6640
|
})
|
|
6292
6641
|
$('file-preview-close').addEventListener('click', closeFsPreview)
|
|
6293
6642
|
$('file-preview-done').addEventListener('click', closeFsPreview)
|
|
@@ -6446,7 +6795,7 @@ function bindUi() {
|
|
|
6446
6795
|
$('modal-scan-live')?.addEventListener('click', e => { if (e.target === $('modal-scan-live')) closeLiveScan('') })
|
|
6447
6796
|
$('btn-change-token').addEventListener('click', () => {
|
|
6448
6797
|
const input = prompt(t('token.prompt'), state.token)
|
|
6449
|
-
if (input && input
|
|
6798
|
+
if (input && setCurrentAccessToken(input)) { $('token-desc').textContent = t('token.saved'); toast(t('token.savedReconnect'), 'ok'); openStreams(); refreshAll(); syncBgConfig() }
|
|
6450
6799
|
})
|
|
6451
6800
|
$('btn-server-speed').addEventListener('click', () => selectFastestServer({ silent: false }))
|
|
6452
6801
|
$('btn-server-add').addEventListener('click', addServer)
|
|
@@ -6459,11 +6808,7 @@ function bindUi() {
|
|
|
6459
6808
|
if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); addServer() }
|
|
6460
6809
|
})
|
|
6461
6810
|
$('btn-host-describe').addEventListener('click', async () => {
|
|
6462
|
-
|
|
6463
|
-
if (v) {
|
|
6464
|
-
state.hostInfo = v
|
|
6465
|
-
$('host-desc').textContent = t('settings.hostDesc', { version: v.version, cwd: v.cwd, n: v.attachedSessions })
|
|
6466
|
-
}
|
|
6811
|
+
await refreshHostDescription({ notify: true })
|
|
6467
6812
|
})
|
|
6468
6813
|
$('btn-dsh-start')?.addEventListener('click', () => controlDsh('start'))
|
|
6469
6814
|
$('btn-dsh-restart')?.addEventListener('click', () => controlDsh('restart'))
|
|
@@ -6632,8 +6977,7 @@ async function boot() {
|
|
|
6632
6977
|
await maybeWarnAppBehindGateway({ probe: true })
|
|
6633
6978
|
openStreams()
|
|
6634
6979
|
await refreshAll()
|
|
6635
|
-
|
|
6636
|
-
if (host) { state.hostInfo = host; $('host-desc').textContent = t('settings.hostDesc', { version: host.version, cwd: host.cwd, n: host.attachedSessions }) }
|
|
6980
|
+
await refreshHostDescription()
|
|
6637
6981
|
loadDshControl()
|
|
6638
6982
|
}
|
|
6639
6983
|
// 网关从中央 HTTPS 公告源读取并在不可达时回退内置文件。前台每 30 秒检查,
|