dsh-remote-plugin 0.5.7-rc.1 → 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/gateway-stats.cjs +26 -12
- package/package.json +1 -1
- package/public/admin.html +15 -1
- package/public/admin.js +18 -6
- package/public/app.js +461 -42
- 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 +77 -2
- package/public/styles.css +54 -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: [],
|
|
@@ -108,6 +112,92 @@ function fmtFullTime(ts) {
|
|
|
108
112
|
function apiUrl(path) {
|
|
109
113
|
return (state.server || '') + path
|
|
110
114
|
}
|
|
115
|
+
|
|
116
|
+
function fmtCost(n) {
|
|
117
|
+
return '¥' + (Number(n) || 0).toFixed(2)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function bucketTokens(b) {
|
|
121
|
+
return (b.input || 0) + (b.cacheRead || 0) + (b.cacheWrite || 0) + (b.output || 0)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* ---------------- Token 统计页 ---------------- */
|
|
125
|
+
async function loadStats() {
|
|
126
|
+
const cards = $('stats-cards')
|
|
127
|
+
const chart = $('stats-chart')
|
|
128
|
+
const note = $('stats-note')
|
|
129
|
+
if (!state.token) {
|
|
130
|
+
cards.innerHTML = ''
|
|
131
|
+
chart.innerHTML = `<div class="stats-empty">${t('statsPage.gatewayDown')}</div>`
|
|
132
|
+
note.textContent = ''
|
|
133
|
+
$('stats-legend').innerHTML = ''
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const res = await fetch(apiUrl('/stats/summary?days=7'), {
|
|
138
|
+
headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' }
|
|
139
|
+
})
|
|
140
|
+
if (res.status === 401) { authFailure(); return }
|
|
141
|
+
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
142
|
+
const json = await res.json()
|
|
143
|
+
renderStats(json.days || [])
|
|
144
|
+
} catch (e) {
|
|
145
|
+
cards.innerHTML = ''
|
|
146
|
+
chart.innerHTML = `<div class="stats-empty">${t('statsPage.gatewayDown')}</div>`
|
|
147
|
+
note.textContent = ''
|
|
148
|
+
$('stats-legend').innerHTML = ''
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function renderStats(days) {
|
|
153
|
+
const cards = $('stats-cards')
|
|
154
|
+
const chart = $('stats-chart')
|
|
155
|
+
const note = $('stats-note')
|
|
156
|
+
if (!days.length) {
|
|
157
|
+
cards.innerHTML = ''
|
|
158
|
+
chart.innerHTML = `<div class="stats-empty">${t('statsPage.empty')}</div>`
|
|
159
|
+
note.textContent = t('statsPage.note')
|
|
160
|
+
$('stats-sub').textContent = ''
|
|
161
|
+
$('stats-legend').innerHTML = ''
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
const today = days[days.length - 1]
|
|
165
|
+
const totalTokens = bucketTokens(today.total)
|
|
166
|
+
const peakCost = today.peak.cost || 0
|
|
167
|
+
const offCost = today.off.cost || 0
|
|
168
|
+
const totalCost = peakCost + offCost
|
|
169
|
+
const peakShare = totalCost > 0 ? Math.round(peakCost / totalCost * 100) : 0
|
|
170
|
+
cards.innerHTML = `
|
|
171
|
+
<div class="scard span2"><div class="v">${fmtTokens(totalTokens)} <span style="font-size:11px;font-weight:500;color:var(--dsr-muted)">${t('statsPage.todayTokens')}</span></div>
|
|
172
|
+
<div class="bucket-grid">
|
|
173
|
+
<div class="b"><span class="n">${t('statsPage.input')}</span><span class="t">${fmtTokens(today.total.input)}</span></div>
|
|
174
|
+
<div class="b"><span class="n">${t('statsPage.cacheRead')}</span><span class="t">${fmtTokens(today.total.cacheRead)}</span></div>
|
|
175
|
+
<div class="b"><span class="n">${t('statsPage.cacheWrite')}</span><span class="t">${fmtTokens(today.total.cacheWrite)}</span></div>
|
|
176
|
+
<div class="b"><span class="n">${t('statsPage.output')}</span><span class="t">${fmtTokens(today.total.output)}</span></div>
|
|
177
|
+
</div></div>
|
|
178
|
+
<div class="scard"><div class="v">${fmtCost(totalCost)}</div><div class="k">${t('statsPage.todayCost')}<br>${t('statsPage.peak')} ${fmtCost(peakCost)}<br>${t('statsPage.off')} ${fmtCost(offCost)}</div></div>
|
|
179
|
+
<div class="scard"><div class="v">${peakShare}%</div><div class="k">${t('statsPage.peakShare')}<br>${t('statsPage.days', { n: days.length })}</div></div>`
|
|
180
|
+
$('stats-sub').textContent = today.date
|
|
181
|
+
note.textContent = t('statsPage.note')
|
|
182
|
+
$('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>`
|
|
183
|
+
const maxCost = Math.max(...days.map(d => (d.total.cost || 0)), 0.0001)
|
|
184
|
+
chart.innerHTML = days.map(d => {
|
|
185
|
+
const cost = d.total.cost || 0
|
|
186
|
+
// 峰/谷按该日实际费用占比堆叠, 柱总高按当日费用相对窗口最大值; 不再加最小高度, 保证占比真实
|
|
187
|
+
const peakH = cost > 0 ? Math.round((d.peak.cost || 0) / cost * 100) : 0
|
|
188
|
+
const offH = cost > 0 ? Math.max(0, 100 - peakH) : 0
|
|
189
|
+
const totalH = cost > 0 ? Math.max(3, Math.round(cost / maxCost * 100)) : 0
|
|
190
|
+
const label = d.date.slice(5)
|
|
191
|
+
return `<div class="stats-bar" title="${d.date} · ${t('statsPage.peak')} ${fmtCost(d.peak.cost)} · ${t('statsPage.off')} ${fmtCost(d.off.cost)} · tokens ${fmtTokens(bucketTokens(d.total))}">
|
|
192
|
+
<div class="bars" style="height:${totalH}%">
|
|
193
|
+
<div class="seg peak" style="height:${peakH}%"></div>
|
|
194
|
+
<div class="seg off" style="height:${offH}%"></div>
|
|
195
|
+
</div>
|
|
196
|
+
<div class="val">${cost > 0 ? fmtCost(cost) : ''}</div>
|
|
197
|
+
<div class="lbl">${label}</div>
|
|
198
|
+
</div>`
|
|
199
|
+
}).join('')
|
|
200
|
+
}
|
|
111
201
|
async function rpc(method, payload = {}) {
|
|
112
202
|
const res = await fetch(apiUrl('/api/' + method), {
|
|
113
203
|
method: 'POST',
|
|
@@ -151,29 +241,89 @@ function authFailure() {
|
|
|
151
241
|
$('token-desc').textContent = t('token.invalid')
|
|
152
242
|
}
|
|
153
243
|
|
|
154
|
-
/* ----------------
|
|
155
|
-
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
|
|
156
268
|
let arr = null
|
|
157
269
|
try { arr = JSON.parse(LS.get('servers', '')) } catch {}
|
|
158
270
|
if (!Array.isArray(arr)) {
|
|
159
271
|
const legacy = LS.get('server', '')
|
|
160
272
|
arr = legacy ? [legacy] : []
|
|
161
273
|
}
|
|
162
|
-
|
|
274
|
+
const urls = arr.map(s => String(s || '').trim().replace(/\/+$/, ''))
|
|
163
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 = { '默认': '' }
|
|
164
281
|
const active = LS.get('activeServer', '')
|
|
165
|
-
if (active === 'origin')
|
|
166
|
-
|
|
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 || '')
|
|
167
312
|
}
|
|
168
313
|
|
|
169
314
|
function saveServers() {
|
|
170
|
-
LS.set('servers', JSON.stringify(
|
|
171
|
-
|
|
172
|
-
|
|
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 保留但不再写入(回滚兼容)
|
|
173
323
|
}
|
|
174
324
|
|
|
175
325
|
function serverCandidates() {
|
|
176
|
-
const list =
|
|
326
|
+
const list = activeServers().map(s => s.url)
|
|
177
327
|
// 浏览器控制台: 当前页面(同源网关)也作为候选, 通常 0 跳内最快
|
|
178
328
|
if (!CAP?.isNativePlatform?.() && location.origin && !list.includes(location.origin)) list.push(location.origin)
|
|
179
329
|
return list
|
|
@@ -201,21 +351,44 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
|
|
|
201
351
|
try {
|
|
202
352
|
if (!silent) toast(t('speed.testing'))
|
|
203
353
|
const candidates = serverCandidates()
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
+
|
|
211
376
|
renderServers()
|
|
212
377
|
if (chosen !== state.server) {
|
|
213
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
|
+
}
|
|
214
383
|
saveServers()
|
|
215
|
-
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
|
+
}
|
|
216
388
|
if (reconnect && state.token) { openStreams(); refreshAll() }
|
|
217
389
|
} else if (!silent) {
|
|
218
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')
|
|
219
392
|
else toast(t('speed.allDown'), 'err')
|
|
220
393
|
}
|
|
221
394
|
return chosen
|
|
@@ -224,27 +397,119 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
|
|
|
224
397
|
}
|
|
225
398
|
}
|
|
226
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
|
+
|
|
227
429
|
function renderServers() {
|
|
228
430
|
const box = $('server-list')
|
|
229
431
|
if (!box) return
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
<
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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]
|
|
245
509
|
$('server-desc').textContent = state.server
|
|
246
|
-
? t('servers.
|
|
510
|
+
? t('servers.currentDescGroup', { group: curGroup, url: curNote, ms: Number.isFinite(curMs) ? curMs + 'ms' : '—' })
|
|
247
511
|
: (CAP?.isNativePlatform?.() ? t('servers.notSet') : t('servers.defaultDesc'))
|
|
512
|
+
updateConn()
|
|
248
513
|
}
|
|
249
514
|
|
|
250
515
|
async function addServer() {
|
|
@@ -257,8 +522,9 @@ async function addServer() {
|
|
|
257
522
|
} catch {
|
|
258
523
|
return toast(t('servers.badProtocol'), 'err')
|
|
259
524
|
}
|
|
260
|
-
if (state.servers.
|
|
261
|
-
|
|
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 })
|
|
262
528
|
saveServers()
|
|
263
529
|
if (input) input.value = ''
|
|
264
530
|
renderServers()
|
|
@@ -266,10 +532,40 @@ async function addServer() {
|
|
|
266
532
|
if (state.token) selectFastestServer({ silent: false })
|
|
267
533
|
}
|
|
268
534
|
|
|
269
|
-
function
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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] = ''
|
|
273
569
|
saveServers()
|
|
274
570
|
renderServers()
|
|
275
571
|
if (wasActive) {
|
|
@@ -278,6 +574,61 @@ function removeServer(url) {
|
|
|
278
574
|
}
|
|
279
575
|
}
|
|
280
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
|
+
|
|
281
632
|
/* ---------------- 事件流 (WebSocket) ---------------- */
|
|
282
633
|
const streams = {}
|
|
283
634
|
state.streamsOk = { mux: false, host: false }
|
|
@@ -1849,14 +2200,51 @@ function notify(title, body) {
|
|
|
1849
2200
|
} catch {}
|
|
1850
2201
|
}
|
|
1851
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
|
+
|
|
1852
2239
|
/* ---------------- 视图切换 ---------------- */
|
|
1853
2240
|
function showView(id) {
|
|
1854
|
-
for (const v of ['view-home', 'view-files', 'view-session', 'view-activity', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
|
|
2241
|
+
for (const v of ['view-home', 'view-files', 'view-session', 'view-activity', 'view-stats', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
|
|
1855
2242
|
// 离开会话页必须清掉 in-session, 否则其他页面顶栏被 body 样式隐藏
|
|
1856
2243
|
document.body.classList.toggle('in-session', id === 'view-session')
|
|
1857
2244
|
document.querySelectorAll('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === id))
|
|
1858
2245
|
window.scrollTo(0, 0)
|
|
1859
2246
|
if (id === 'view-files' && !state.fs.loaded) loadFs(null, { silent: true })
|
|
2247
|
+
if (id === 'view-stats') loadStats()
|
|
1860
2248
|
}
|
|
1861
2249
|
|
|
1862
2250
|
function updateConn() {
|
|
@@ -1864,6 +2252,11 @@ function updateConn() {
|
|
|
1864
2252
|
const el = $('conn-badge')
|
|
1865
2253
|
el.textContent = ok ? t('conn.on') : t('conn.off')
|
|
1866
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' : '—' })
|
|
1867
2260
|
}
|
|
1868
2261
|
|
|
1869
2262
|
function autosize(el) {
|
|
@@ -1883,7 +2276,9 @@ function applyPairUrl(url) {
|
|
|
1883
2276
|
state.token = tok
|
|
1884
2277
|
LS.set('token', tok)
|
|
1885
2278
|
state.server = server
|
|
1886
|
-
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
|
+
}
|
|
1887
2282
|
saveServers()
|
|
1888
2283
|
renderServers()
|
|
1889
2284
|
$('token-desc').textContent = t('token.savedScan')
|
|
@@ -2101,6 +2496,11 @@ function bindUi() {
|
|
|
2101
2496
|
})
|
|
2102
2497
|
$('btn-server-speed').addEventListener('click', () => selectFastestServer({ silent: false }))
|
|
2103
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
|
+
})
|
|
2104
2504
|
$('server-input').addEventListener('keydown', (e) => {
|
|
2105
2505
|
if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); addServer() }
|
|
2106
2506
|
})
|
|
@@ -2127,6 +2527,25 @@ function bindUi() {
|
|
|
2127
2527
|
}
|
|
2128
2528
|
LS.set('notify', e.target.checked ? '1' : '0')
|
|
2129
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()
|
|
2130
2549
|
$('opt-tools').checked = LS.get('showTools', '1') !== '0'
|
|
2131
2550
|
$('opt-tools').addEventListener('change', (e) => {
|
|
2132
2551
|
LS.set('showTools', e.target.checked ? '1' : '0')
|