dsh-remote-plugin 0.5.7 → 0.5.8
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 +1 -0
- package/README.md +1 -0
- package/apk/dsh-remote.apk +0 -0
- package/package.json +1 -1
- package/public/app.js +373 -41
- package/public/desktop/desktop.css +207 -0
- package/public/desktop/desktop.html +247 -0
- package/public/desktop/desktop.js +917 -0
- package/public/desktop/i18n.js +53 -0
- package/public/index.html +53 -0
- package/public/styles.css +28 -351
- package/public/theme-vars.css +351 -0
- package/public/update.json +3 -3
- package/public/version.json +1 -1
package/README.en.md
CHANGED
|
@@ -37,6 +37,7 @@ The app is embedded in the plugin package (`apk/dsh-remote.apk`) — install the
|
|
|
37
37
|
- Tap **QR code** in the desktop drawer → scan it with the phone to open the admin page → download the app from that page
|
|
38
38
|
- Or open `http://<gateway-IP>:8787` in a browser to download
|
|
39
39
|
- App updates are pushed by the gateway too (`update.json` relative path), never touching GitHub
|
|
40
|
+
- A desktop browser opening `http://<gateway-IP>:8787` auto-enters the desktop WebUI (sidebar sessions + files + settings + stats + approval notification stack)
|
|
40
41
|
|
|
41
42
|
The app has built-in multi-server latency switching and offline chat history caching; downloaded files are stored in the system `Download/dsh-remote` folder.
|
|
42
43
|
|
package/README.md
CHANGED
|
@@ -37,6 +37,7 @@ App 已随插件包内嵌(`apk/dsh-remote.apk`),装好插件即可获取
|
|
|
37
37
|
- 桌面抽屉点「二维码」→ 手机扫码打开管理页 → 页面提供 App 下载
|
|
38
38
|
- 或浏览器直接访问 `http://<网关IP>:8787` 下载
|
|
39
39
|
- App 更新同样走网关自动推送(`update.json` 相对路径),全程不绕 GitHub
|
|
40
|
+
- 电脑浏览器打开 `http://<网关IP>:8787` 自动进入桌面端 WebUI(侧栏会话 + 文件 + 设置 + 统计 + 审批通知卡片栈)
|
|
40
41
|
|
|
41
42
|
App 内置多服务器测速切换与聊天记录离线缓存,下载文件统一放系统「下载/dsh-remote」。
|
|
42
43
|
|
package/apk/dsh-remote.apk
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-plugin",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.8",
|
|
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
|
@@ -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: [],
|
|
@@ -237,29 +241,89 @@ function authFailure() {
|
|
|
237
241
|
$('token-desc').textContent = t('token.invalid')
|
|
238
242
|
}
|
|
239
243
|
|
|
240
|
-
/* ----------------
|
|
241
|
-
function
|
|
244
|
+
/* ---------------- 多服务器分组管理 + 自动选优 ---------------- */
|
|
245
|
+
function newServerId() {
|
|
246
|
+
return 's' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function ensureGroup(name) {
|
|
250
|
+
if (!name) name = '默认'
|
|
251
|
+
if (!state.groups.includes(name)) state.groups.push(name)
|
|
252
|
+
if (!(name in state.autoSelect)) state.autoSelect[name] = true
|
|
253
|
+
if (!(name in state.groupActive)) state.groupActive[name] = ''
|
|
254
|
+
return name
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function groupServers(group) {
|
|
258
|
+
return state.servers.filter(s => s.group === group)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function activeServers() {
|
|
262
|
+
return groupServers(state.activeGroup)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** 旧结构迁移: servers(string[]) + server/activeServer -> 新分组结构, 幂等(仅当 servers-v2 不存在时执行)。 */
|
|
266
|
+
function migrateServersV1() {
|
|
267
|
+
if (LS.get('servers-v2', null) !== null) return
|
|
242
268
|
let arr = null
|
|
243
269
|
try { arr = JSON.parse(LS.get('servers', '')) } catch {}
|
|
244
270
|
if (!Array.isArray(arr)) {
|
|
245
271
|
const legacy = LS.get('server', '')
|
|
246
272
|
arr = legacy ? [legacy] : []
|
|
247
273
|
}
|
|
248
|
-
|
|
274
|
+
const urls = arr.map(s => String(s || '').trim().replace(/\/+$/, ''))
|
|
249
275
|
.filter(s => /^https?:\/\//i.test(s))
|
|
276
|
+
state.servers = urls.map((url, i) => ({ id: 's' + (i + 1), url, note: '', group: '默认' }))
|
|
277
|
+
state.groups = ['默认']
|
|
278
|
+
state.activeGroup = '默认'
|
|
279
|
+
state.autoSelect = { '默认': true }
|
|
280
|
+
state.groupActive = { '默认': '' }
|
|
250
281
|
const active = LS.get('activeServer', '')
|
|
251
|
-
if (active === 'origin')
|
|
252
|
-
|
|
282
|
+
if (active === 'origin') {
|
|
283
|
+
state.server = ''
|
|
284
|
+
} else {
|
|
285
|
+
const hit = state.servers.find(s => s.url === active)
|
|
286
|
+
state.server = hit ? hit.url : (state.servers[0]?.url || '')
|
|
287
|
+
state.groupActive['默认'] = hit ? hit.id : (state.servers[0]?.id || '')
|
|
288
|
+
}
|
|
289
|
+
saveServers()
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function loadServers() {
|
|
293
|
+
let data = null
|
|
294
|
+
try { data = JSON.parse(LS.get('servers-v2', '')) } catch {}
|
|
295
|
+
if (!data || !Array.isArray(data.servers)) {
|
|
296
|
+
migrateServersV1()
|
|
297
|
+
return
|
|
298
|
+
}
|
|
299
|
+
state.servers = data.servers.filter(s => s && typeof s.url === 'string')
|
|
300
|
+
.map(s => ({ id: s.id || newServerId(), url: s.url.replace(/\/+$/, ''), note: s.note || '', group: s.group || '默认' }))
|
|
301
|
+
state.groups = Array.isArray(data.groups) && data.groups.length ? data.groups : ['默认']
|
|
302
|
+
state.activeGroup = state.groups.includes(data.activeGroup) ? data.activeGroup : '默认'
|
|
303
|
+
state.autoSelect = data.autoSelect || {}
|
|
304
|
+
state.groupActive = data.groupActive || {}
|
|
305
|
+
ensureGroup('默认')
|
|
306
|
+
for (const s of state.servers) ensureGroup(s.group)
|
|
307
|
+
if (!state.groups.includes(state.activeGroup)) state.activeGroup = '默认'
|
|
308
|
+
// 恢复当前连接地址
|
|
309
|
+
const manual = state.groupActive[state.activeGroup]
|
|
310
|
+
const manualSrv = manual ? state.servers.find(s => s.id === manual) : null
|
|
311
|
+
state.server = manualSrv ? manualSrv.url : (activeServers()[0]?.url || '')
|
|
253
312
|
}
|
|
254
313
|
|
|
255
314
|
function saveServers() {
|
|
256
|
-
LS.set('servers', JSON.stringify(
|
|
257
|
-
|
|
258
|
-
|
|
315
|
+
LS.set('servers-v2', JSON.stringify({
|
|
316
|
+
servers: state.servers,
|
|
317
|
+
groups: state.groups,
|
|
318
|
+
activeGroup: state.activeGroup,
|
|
319
|
+
autoSelect: state.autoSelect,
|
|
320
|
+
groupActive: state.groupActive,
|
|
321
|
+
}))
|
|
322
|
+
// 旧 key 保留但不再写入(回滚兼容)
|
|
259
323
|
}
|
|
260
324
|
|
|
261
325
|
function serverCandidates() {
|
|
262
|
-
const list =
|
|
326
|
+
const list = activeServers().map(s => s.url)
|
|
263
327
|
// 浏览器控制台: 当前页面(同源网关)也作为候选, 通常 0 跳内最快
|
|
264
328
|
if (!CAP?.isNativePlatform?.() && location.origin && !list.includes(location.origin)) list.push(location.origin)
|
|
265
329
|
return list
|
|
@@ -287,21 +351,44 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
|
|
|
287
351
|
try {
|
|
288
352
|
if (!silent) toast(t('speed.testing'))
|
|
289
353
|
const candidates = serverCandidates()
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
354
|
+
let chosen = ''
|
|
355
|
+
let best = null
|
|
356
|
+
let ms = Infinity
|
|
357
|
+
|
|
358
|
+
if (state.autoSelect[state.activeGroup] !== false) {
|
|
359
|
+
for (const u of candidates) state.serverLatency[u] = await pingServer(u)
|
|
360
|
+
best = candidates
|
|
361
|
+
.filter(u => Number.isFinite(state.serverLatency[u]))
|
|
362
|
+
.sort((a, b) => state.serverLatency[a] - state.serverLatency[b])[0] || null
|
|
363
|
+
const sameOrigin = !CAP?.isNativePlatform?.() && best === location.origin
|
|
364
|
+
chosen = best ? (sameOrigin && !activeServers().some(s => s.url === best) ? '' : best) : (state.server || '')
|
|
365
|
+
ms = best ? state.serverLatency[best] : Infinity
|
|
366
|
+
} else {
|
|
367
|
+
const manual = state.groupActive[state.activeGroup]
|
|
368
|
+
const manualSrv = manual ? state.servers.find(s => s.id === manual) : null
|
|
369
|
+
chosen = manualSrv ? manualSrv.url : (activeServers()[0]?.url || '')
|
|
370
|
+
if (chosen && !silent) {
|
|
371
|
+
state.serverLatency[chosen] = await pingServer(chosen)
|
|
372
|
+
ms = state.serverLatency[chosen]
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
297
376
|
renderServers()
|
|
298
377
|
if (chosen !== state.server) {
|
|
299
378
|
state.server = chosen
|
|
379
|
+
if (state.autoSelect[state.activeGroup] !== false && best) {
|
|
380
|
+
const srv = state.servers.find(s => s.url === best)
|
|
381
|
+
if (srv) state.groupActive[state.activeGroup] = srv.id
|
|
382
|
+
}
|
|
300
383
|
saveServers()
|
|
301
|
-
if (!silent)
|
|
384
|
+
if (!silent) {
|
|
385
|
+
if (chosen) toast(t('speed.switched', { url: chosen, ms: Number.isFinite(ms) ? ms : 0 }), 'ok')
|
|
386
|
+
else toast(t('speed.switchedOrigin'), 'ok')
|
|
387
|
+
}
|
|
302
388
|
if (reconnect && state.token) { openStreams(); refreshAll() }
|
|
303
389
|
} else if (!silent) {
|
|
304
390
|
if (best) toast(t('speed.alreadyBest', { url: chosen || t('speed.origin'), ms: state.serverLatency[best] }), 'ok')
|
|
391
|
+
else if (chosen) toast(t('speed.manualUsing', { url: chosen, ms: Number.isFinite(ms) ? ms : '—' }), 'ok')
|
|
305
392
|
else toast(t('speed.allDown'), 'err')
|
|
306
393
|
}
|
|
307
394
|
return chosen
|
|
@@ -310,27 +397,119 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
|
|
|
310
397
|
}
|
|
311
398
|
}
|
|
312
399
|
|
|
400
|
+
function serverTitle(s) {
|
|
401
|
+
return s.note || s.url
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function renderGroupSelect() {
|
|
405
|
+
const label = $('group-select-label')
|
|
406
|
+
const menu = $('group-select-menu')
|
|
407
|
+
if (!label || !menu) return
|
|
408
|
+
label.textContent = state.activeGroup
|
|
409
|
+
menu.innerHTML = state.groups.map(g =>
|
|
410
|
+
`<button type="button" class="group-option ${g === state.activeGroup ? 'current' : ''}" data-group-option="${esc(g)}">${esc(g)}${g === state.activeGroup ? ' ✓' : ''}</button>`).join('')
|
|
411
|
+
menu.querySelectorAll('[data-group-option]').forEach(b =>
|
|
412
|
+
b.addEventListener('click', () => {
|
|
413
|
+
closeGroupMenu()
|
|
414
|
+
if (b.dataset.groupOption !== state.activeGroup) switchGroup(b.dataset.groupOption)
|
|
415
|
+
}))
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function toggleGroupMenu() {
|
|
419
|
+
const menu = $('group-select-menu')
|
|
420
|
+
if (!menu) return
|
|
421
|
+
menu.classList.toggle('hidden')
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function closeGroupMenu() {
|
|
425
|
+
const menu = $('group-select-menu')
|
|
426
|
+
if (menu) menu.classList.add('hidden')
|
|
427
|
+
}
|
|
428
|
+
|
|
313
429
|
function renderServers() {
|
|
314
430
|
const box = $('server-list')
|
|
315
431
|
if (!box) return
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
<
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
432
|
+
renderGroupSelect()
|
|
433
|
+
const groupsHtml = state.groups.map(g => {
|
|
434
|
+
const list = groupServers(g)
|
|
435
|
+
const auto = state.autoSelect[g] !== false
|
|
436
|
+
const activeManual = state.groupActive[g] || ''
|
|
437
|
+
return `<div class="srv-group" data-group="${esc(g)}">
|
|
438
|
+
<div class="srv-group-head">
|
|
439
|
+
<button class="srv-group-name" data-group-name="${esc(g)}">${g === state.activeGroup ? '▾' : '▸'} ${esc(g)} <span class="srv-group-count">${list.length}</span></button>
|
|
440
|
+
<button class="srv-group-speed mini-btn" data-speed-group="${esc(g)}" title="${t('groups.speedTest')}">⚡</button>
|
|
441
|
+
<label class="switch small" title="${t('groups.autoSelect')}">
|
|
442
|
+
<input type="checkbox" data-auto-group="${esc(g)}" ${auto ? 'checked' : ''}>
|
|
443
|
+
<span class="slider"></span>
|
|
444
|
+
</label>
|
|
445
|
+
${g !== '默认' ? `<button class="srv-group-del" data-del-group="${esc(g)}" title="${t('groups.delete')}">✕</button>` : ''}
|
|
446
|
+
</div>
|
|
447
|
+
<div class="srv-group-body ${g === state.activeGroup ? '' : 'hidden'}">
|
|
448
|
+
${list.map(s => {
|
|
449
|
+
const ms = state.serverLatency[s.url]
|
|
450
|
+
let badge = `<span class="server-badge">${t('servers.untested')}</span>`
|
|
451
|
+
if (Number.isFinite(ms)) badge = `<span class="server-badge ${s.url === state.server ? 'good' : ''}">${ms}ms${s.url === state.server ? t('servers.current') : ''}</span>`
|
|
452
|
+
else if (ms !== undefined) badge = '<span class="server-badge bad">' + t('servers.unreachable') + '</span>'
|
|
453
|
+
const activeInGroup = auto ? s.url === state.server : s.id === activeManual
|
|
454
|
+
return `<div class="server-row ${activeInGroup ? 'active' : ''}" data-use-server="${esc(s.id)}">
|
|
455
|
+
<span class="server-main"><span class="server-note">${esc(serverTitle(s))}</span>${s.note ? `<span class="server-url">${esc(s.url)}</span>` : ''}</span>${badge}
|
|
456
|
+
<button class="server-edit" data-edit-server="${esc(s.id)}" title="${t('servers.edit')}">✎</button>
|
|
457
|
+
<button class="server-del" data-del-server="${esc(s.id)}" aria-label="${t('servers.delete')}">✕</button>
|
|
458
|
+
</div>`
|
|
459
|
+
}).join('') || '<div class="server-empty">' + t('groups.noServer') + '</div>'}
|
|
460
|
+
</div>
|
|
461
|
+
</div>`
|
|
462
|
+
}).join('')
|
|
463
|
+
|
|
464
|
+
box.innerHTML = state.servers.length
|
|
465
|
+
? groupsHtml
|
|
466
|
+
: '<div class="server-empty">' + t('servers.empty') + '</div>'
|
|
467
|
+
|
|
468
|
+
box.querySelectorAll('[data-group-name]').forEach(b => {
|
|
469
|
+
b.title = t('groups.switchHint')
|
|
470
|
+
b.addEventListener('click', () => switchGroup(b.dataset.groupName))
|
|
471
|
+
b.addEventListener('dblclick', () => renameGroup(b.dataset.groupName))
|
|
472
|
+
})
|
|
473
|
+
box.querySelectorAll('[data-speed-group]').forEach(b => b.addEventListener('click', () => { state.activeGroup = b.dataset.speedGroup; saveServers(); selectFastestServer({ silent: false }) }))
|
|
474
|
+
box.querySelectorAll('[data-auto-group]').forEach(chk => chk.addEventListener('change', (e) => {
|
|
475
|
+
const g = e.target.dataset.autoGroup
|
|
476
|
+
state.autoSelect[g] = e.target.checked
|
|
477
|
+
saveServers()
|
|
478
|
+
if (g === state.activeGroup) selectFastestServer({ silent: false })
|
|
479
|
+
toast(t(e.target.checked ? 'groups.autoOn' : 'groups.autoOff', { group: g }), 'ok')
|
|
480
|
+
}))
|
|
481
|
+
box.querySelectorAll('[data-del-group]').forEach(b => b.addEventListener('click', () => deleteGroup(b.dataset.delGroup)))
|
|
482
|
+
box.querySelectorAll('[data-del-server]').forEach(b => b.addEventListener('click', (e) => { e.stopPropagation(); removeServer(b.dataset.delServer) }))
|
|
483
|
+
box.querySelectorAll('[data-edit-server]').forEach(b => b.addEventListener('click', (e) => { e.stopPropagation(); editServer(b.dataset.editServer) }))
|
|
484
|
+
box.querySelectorAll('[data-use-server]').forEach(row => row.addEventListener('click', (e) => {
|
|
485
|
+
if (e.target.closest('button')) return
|
|
486
|
+
const id = row.dataset.useServer
|
|
487
|
+
const s = state.servers.find(x => x.id === id)
|
|
488
|
+
if (!s) return
|
|
489
|
+
const group = s.group
|
|
490
|
+
if (state.autoSelect[group] !== false) {
|
|
491
|
+
// 自动选择模式下点击条目 = 编辑
|
|
492
|
+
editServer(id)
|
|
493
|
+
return
|
|
494
|
+
}
|
|
495
|
+
// 手动模式: 点击条目 = 选中该服务器连接
|
|
496
|
+
state.groupActive[group] = id
|
|
497
|
+
state.activeGroup = group
|
|
498
|
+
state.server = s.url
|
|
499
|
+
saveServers()
|
|
500
|
+
renderServers()
|
|
501
|
+
toast(t('servers.manualSelected', { url: serverTitle(s) }), 'ok')
|
|
502
|
+
if (state.token) { openStreams(); refreshAll() }
|
|
503
|
+
}))
|
|
504
|
+
|
|
505
|
+
const cur = state.servers.find(s => s.url === state.server)
|
|
506
|
+
const curNote = cur ? (cur.note || cur.url) : ''
|
|
507
|
+
const curGroup = cur ? cur.group : state.activeGroup
|
|
508
|
+
const curMs = state.serverLatency[state.server]
|
|
331
509
|
$('server-desc').textContent = state.server
|
|
332
|
-
? t('servers.
|
|
510
|
+
? t('servers.currentDescGroup', { group: curGroup, url: curNote, ms: Number.isFinite(curMs) ? curMs + 'ms' : '—' })
|
|
333
511
|
: (CAP?.isNativePlatform?.() ? t('servers.notSet') : t('servers.defaultDesc'))
|
|
512
|
+
updateConn()
|
|
334
513
|
}
|
|
335
514
|
|
|
336
515
|
async function addServer() {
|
|
@@ -343,8 +522,9 @@ async function addServer() {
|
|
|
343
522
|
} catch {
|
|
344
523
|
return toast(t('servers.badProtocol'), 'err')
|
|
345
524
|
}
|
|
346
|
-
if (state.servers.
|
|
347
|
-
|
|
525
|
+
if (state.servers.some(s => s.url === raw)) return toast(t('servers.duplicate'))
|
|
526
|
+
let note = prompt(t('servers.promptNote'), '') || ''
|
|
527
|
+
state.servers.push({ id: newServerId(), url: raw, note: note.trim(), group: state.activeGroup })
|
|
348
528
|
saveServers()
|
|
349
529
|
if (input) input.value = ''
|
|
350
530
|
renderServers()
|
|
@@ -352,10 +532,40 @@ async function addServer() {
|
|
|
352
532
|
if (state.token) selectFastestServer({ silent: false })
|
|
353
533
|
}
|
|
354
534
|
|
|
355
|
-
function
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
535
|
+
function editServer(id) {
|
|
536
|
+
const s = state.servers.find(x => x.id === id)
|
|
537
|
+
if (!s) return
|
|
538
|
+
const raw = (prompt(t('servers.promptEditUrl'), s.url) || '').trim().replace(/\/+$/, '')
|
|
539
|
+
if (!raw) return
|
|
540
|
+
try {
|
|
541
|
+
const u = new URL(raw)
|
|
542
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('bad')
|
|
543
|
+
} catch {
|
|
544
|
+
return toast(t('servers.badProtocol'), 'err')
|
|
545
|
+
}
|
|
546
|
+
if (state.servers.some(x => x.id !== id && x.url === raw)) return toast(t('servers.duplicate'))
|
|
547
|
+
const note = prompt(t('servers.promptEditNote', { url: raw }), s.note || '')
|
|
548
|
+
if (note === null) return
|
|
549
|
+
const group = prompt(t('servers.promptEditGroup'), s.group || '默认')
|
|
550
|
+
if (group === null) return
|
|
551
|
+
const wasActive = state.server === s.url
|
|
552
|
+
s.url = raw
|
|
553
|
+
s.note = note.trim()
|
|
554
|
+
s.group = ensureGroup(group.trim() || '默认')
|
|
555
|
+
if (wasActive) state.server = raw
|
|
556
|
+
saveServers()
|
|
557
|
+
renderServers()
|
|
558
|
+
toast(t('servers.edited'), 'ok')
|
|
559
|
+
if (wasActive && state.token) selectFastestServer({ silent: true })
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function removeServer(id) {
|
|
563
|
+
const s = state.servers.find(x => x.id === id)
|
|
564
|
+
if (!s) return
|
|
565
|
+
state.servers = state.servers.filter(x => x.id !== id)
|
|
566
|
+
const wasActive = state.server === s.url
|
|
567
|
+
// 清理该组手动指定
|
|
568
|
+
for (const g of state.groups) if (state.groupActive[g] === id) state.groupActive[g] = ''
|
|
359
569
|
saveServers()
|
|
360
570
|
renderServers()
|
|
361
571
|
if (wasActive) {
|
|
@@ -364,6 +574,61 @@ function removeServer(url) {
|
|
|
364
574
|
}
|
|
365
575
|
}
|
|
366
576
|
|
|
577
|
+
/* ---------------- 组管理 ---------------- */
|
|
578
|
+
function switchGroup(name) {
|
|
579
|
+
if (!state.groups.includes(name)) return
|
|
580
|
+
state.activeGroup = name
|
|
581
|
+
saveServers()
|
|
582
|
+
renderServers()
|
|
583
|
+
toast(t('groups.switched', { group: name }), 'ok')
|
|
584
|
+
selectFastestServer({ silent: false })
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function addGroup() {
|
|
588
|
+
const name = (prompt(t('groups.promptAdd')) || '').trim()
|
|
589
|
+
if (!name) return
|
|
590
|
+
if (state.groups.includes(name)) return toast(t('groups.duplicate'), 'err')
|
|
591
|
+
ensureGroup(name)
|
|
592
|
+
state.activeGroup = name
|
|
593
|
+
saveServers()
|
|
594
|
+
renderServers()
|
|
595
|
+
toast(t('groups.added', { group: name }), 'ok')
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function renameGroup(oldName) {
|
|
599
|
+
if (oldName === '默认') return
|
|
600
|
+
const name = (prompt(t('groups.promptRename', { group: oldName }), oldName) || '').trim()
|
|
601
|
+
if (!name || name === oldName) return
|
|
602
|
+
if (state.groups.includes(name)) return toast(t('groups.duplicate'), 'err')
|
|
603
|
+
const idx = state.groups.indexOf(oldName)
|
|
604
|
+
state.groups[idx] = name
|
|
605
|
+
for (const s of state.servers) if (s.group === oldName) s.group = name
|
|
606
|
+
if (state.activeGroup === oldName) state.activeGroup = name
|
|
607
|
+
if (name in state.autoSelect) delete state.autoSelect[name]
|
|
608
|
+
state.autoSelect[name] = state.autoSelect[oldName] !== false
|
|
609
|
+
delete state.autoSelect[oldName]
|
|
610
|
+
state.groupActive[name] = state.groupActive[oldName] || ''
|
|
611
|
+
delete state.groupActive[oldName]
|
|
612
|
+
saveServers()
|
|
613
|
+
renderServers()
|
|
614
|
+
toast(t('groups.renamed', { group: name }), 'ok')
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function deleteGroup(name) {
|
|
618
|
+
if (name === '默认') return toast(t('groups.cannotDeleteDefault'), 'err')
|
|
619
|
+
if (!state.groups.includes(name)) return
|
|
620
|
+
if (!confirm(t('groups.confirmDelete', { group: name }))) return
|
|
621
|
+
state.groups = state.groups.filter(g => g !== name)
|
|
622
|
+
for (const s of state.servers) if (s.group === name) s.group = '默认'
|
|
623
|
+
delete state.autoSelect[name]
|
|
624
|
+
delete state.groupActive[name]
|
|
625
|
+
if (state.activeGroup === name) state.activeGroup = '默认'
|
|
626
|
+
saveServers()
|
|
627
|
+
renderServers()
|
|
628
|
+
toast(t('groups.deleted'), 'ok')
|
|
629
|
+
if (state.token) selectFastestServer({ silent: true })
|
|
630
|
+
}
|
|
631
|
+
|
|
367
632
|
/* ---------------- 事件流 (WebSocket) ---------------- */
|
|
368
633
|
const streams = {}
|
|
369
634
|
state.streamsOk = { mux: false, host: false }
|
|
@@ -1935,6 +2200,42 @@ function notify(title, body) {
|
|
|
1935
2200
|
} catch {}
|
|
1936
2201
|
}
|
|
1937
2202
|
|
|
2203
|
+
/* ---------------- 峰谷计费提醒(每天 9/12/14/18 点本地通知) ---------------- */
|
|
2204
|
+
const PEAK_REMIND_NOTIFS = [
|
|
2205
|
+
{ id: 8801, hour: 9, periodKey: 'peak0912', enterKey: 'enterPeak' },
|
|
2206
|
+
{ id: 8802, hour: 12, periodKey: 'off1214', enterKey: 'enterOff' },
|
|
2207
|
+
{ id: 8803, hour: 14, periodKey: 'peak1418', enterKey: 'enterPeak' },
|
|
2208
|
+
{ id: 8804, hour: 18, periodKey: 'off1809', enterKey: 'enterOff' },
|
|
2209
|
+
]
|
|
2210
|
+
function peakRemindOn() { return LS.get('peakRemind', '0') === '1' }
|
|
2211
|
+
|
|
2212
|
+
async function schedulePeakReminders() {
|
|
2213
|
+
if (!CAP?.isNativePlatform?.()) return false
|
|
2214
|
+
const L = CAP.Plugins?.LocalNotifications
|
|
2215
|
+
if (!L?.schedule) return false
|
|
2216
|
+
try {
|
|
2217
|
+
await L.schedule({
|
|
2218
|
+
notifications: PEAK_REMIND_NOTIFS.map(n => ({
|
|
2219
|
+
id: n.id,
|
|
2220
|
+
title: 'DSH Remote',
|
|
2221
|
+
body: `${t('peakRemind.' + n.enterKey)} · ${t('peakRemind.' + n.periodKey)}`,
|
|
2222
|
+
schedule: { every: 'day', on: { hour: n.hour, minute: 0 } },
|
|
2223
|
+
}))
|
|
2224
|
+
})
|
|
2225
|
+
return true
|
|
2226
|
+
} catch { return false }
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
async function cancelPeakReminders() {
|
|
2230
|
+
if (!CAP?.isNativePlatform?.()) return false
|
|
2231
|
+
const L = CAP.Plugins?.LocalNotifications
|
|
2232
|
+
if (!L?.cancel) return false
|
|
2233
|
+
try {
|
|
2234
|
+
await L.cancel({ notifications: PEAK_REMIND_NOTIFS.map(n => ({ id: n.id })) })
|
|
2235
|
+
return true
|
|
2236
|
+
} catch { return false }
|
|
2237
|
+
}
|
|
2238
|
+
|
|
1938
2239
|
/* ---------------- 视图切换 ---------------- */
|
|
1939
2240
|
function showView(id) {
|
|
1940
2241
|
for (const v of ['view-home', 'view-files', 'view-session', 'view-activity', 'view-stats', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
|
|
@@ -1951,6 +2252,11 @@ function updateConn() {
|
|
|
1951
2252
|
const el = $('conn-badge')
|
|
1952
2253
|
el.textContent = ok ? t('conn.on') : t('conn.off')
|
|
1953
2254
|
el.className = 'conn-badge ' + (ok ? 'on' : 'off')
|
|
2255
|
+
const cur = state.servers.find(s => s.url === state.server)
|
|
2256
|
+
const ms = state.serverLatency[state.server]
|
|
2257
|
+
const curGroup = cur ? cur.group : state.activeGroup
|
|
2258
|
+
const curLabel = cur ? (cur.note || cur.url) : (state.server || t('speed.origin'))
|
|
2259
|
+
el.title = t('conn.titleGroup', { group: curGroup, url: curLabel, ms: Number.isFinite(ms) ? ms + 'ms' : '—' })
|
|
1954
2260
|
}
|
|
1955
2261
|
|
|
1956
2262
|
function autosize(el) {
|
|
@@ -1970,7 +2276,9 @@ function applyPairUrl(url) {
|
|
|
1970
2276
|
state.token = tok
|
|
1971
2277
|
LS.set('token', tok)
|
|
1972
2278
|
state.server = server
|
|
1973
|
-
if (!state.servers.
|
|
2279
|
+
if (!state.servers.some(s => s.url === server)) {
|
|
2280
|
+
state.servers.unshift({ id: newServerId(), url: server, note: '', group: state.activeGroup })
|
|
2281
|
+
}
|
|
1974
2282
|
saveServers()
|
|
1975
2283
|
renderServers()
|
|
1976
2284
|
$('token-desc').textContent = t('token.savedScan')
|
|
@@ -2188,6 +2496,11 @@ function bindUi() {
|
|
|
2188
2496
|
})
|
|
2189
2497
|
$('btn-server-speed').addEventListener('click', () => selectFastestServer({ silent: false }))
|
|
2190
2498
|
$('btn-server-add').addEventListener('click', addServer)
|
|
2499
|
+
$('btn-group-add').addEventListener('click', addGroup)
|
|
2500
|
+
$('group-select-btn').addEventListener('click', (e) => { e.stopPropagation(); toggleGroupMenu() })
|
|
2501
|
+
document.addEventListener('click', (e) => {
|
|
2502
|
+
if (!e.target.closest('#group-select')) closeGroupMenu()
|
|
2503
|
+
})
|
|
2191
2504
|
$('server-input').addEventListener('keydown', (e) => {
|
|
2192
2505
|
if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); addServer() }
|
|
2193
2506
|
})
|
|
@@ -2214,6 +2527,25 @@ function bindUi() {
|
|
|
2214
2527
|
}
|
|
2215
2528
|
LS.set('notify', e.target.checked ? '1' : '0')
|
|
2216
2529
|
})
|
|
2530
|
+
$('opt-peak-remind').checked = peakRemindOn()
|
|
2531
|
+
$('opt-peak-remind').addEventListener('change', async (e) => {
|
|
2532
|
+
if (e.target.checked) {
|
|
2533
|
+
if (!CAP?.isNativePlatform?.()) {
|
|
2534
|
+
e.target.checked = false
|
|
2535
|
+
return toast(t('peakRemind.browserOnly'), 'err')
|
|
2536
|
+
}
|
|
2537
|
+
const ok = await ensureNotify()
|
|
2538
|
+
if (!ok) { e.target.checked = false; return toast(t('settings.notifyDenied')) }
|
|
2539
|
+
await schedulePeakReminders()
|
|
2540
|
+
toast(t('peakRemind.on'), 'ok')
|
|
2541
|
+
} else {
|
|
2542
|
+
await cancelPeakReminders()
|
|
2543
|
+
toast(t('peakRemind.off'), 'ok')
|
|
2544
|
+
}
|
|
2545
|
+
LS.set('peakRemind', e.target.checked ? '1' : '0')
|
|
2546
|
+
})
|
|
2547
|
+
// 已开启则启动时重新调度, 防止系统清理后丢失
|
|
2548
|
+
if (peakRemindOn() && CAP?.isNativePlatform?.()) schedulePeakReminders()
|
|
2217
2549
|
$('opt-tools').checked = LS.get('showTools', '1') !== '0'
|
|
2218
2550
|
$('opt-tools').addEventListener('change', (e) => {
|
|
2219
2551
|
LS.set('showTools', e.target.checked ? '1' : '0')
|