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