dsh-remote-plugin 0.5.8 → 0.6.0
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.cjs +247 -0
- package/package.json +1 -1
- package/public/admin.html +91 -15
- package/public/admin.js +26 -0
- package/public/app.js +237 -8
- package/public/desktop/desktop.css +63 -0
- package/public/desktop/desktop.html +83 -0
- package/public/desktop/desktop.js +203 -5
- package/public/index.html +97 -3
- package/public/styles.css +72 -0
- package/public/update.json +4 -3
- package/public/version.json +1 -1
package/README.en.md
CHANGED
|
@@ -29,6 +29,7 @@ dsh plugin --profile web add "github:Blank-not-black/dsh-Remote#main&path:/packa
|
|
|
29
29
|
- Token lives in `~/.dsh-remote/token` (auto-generated on first run, reused and never overwritten), shown in the drawer and copyable; supports **QR pairing** and **one-click rotation**.
|
|
30
30
|
- Env var `DSH_REMOTE_AUTOSTART=0` disables auto management.
|
|
31
31
|
- File endpoints: `/fs/list` (list directory), `/fs/file` (download with Range support), `/fs/upload` (chunked resume with pause/cancel, SHA-256 verified before writing to disk); default root is `~`, and `DSH_REMOTE_FS_ROOT` opens multiple roots (`:`-separated).
|
|
32
|
+
- Feedback endpoint: `POST /feedback` (the app / desktop "Write feedback" dialog), forwarded by the gateway to the feedback collector; default `http://100.84.128.29/submit` (Tailscale internal network), overridable via `DSH_REMOTE_FEEDBACK_URL` — no tokens to configure.
|
|
32
33
|
|
|
33
34
|
## Mobile App
|
|
34
35
|
|
package/README.md
CHANGED
|
@@ -29,6 +29,7 @@ dsh plugin --profile web add "github:Blank-not-black/dsh-Remote#main&path:/packa
|
|
|
29
29
|
- 令牌在 `~/.dsh-remote/token`(首次自动生成,重复使用不覆盖),抽屉里显示并可复制;支持**二维码扫码配对**与**一键轮换**。
|
|
30
30
|
- 环境变量 `DSH_REMOTE_AUTOSTART=0` 可关闭自动管理。
|
|
31
31
|
- 文件端点:`/fs/list`(列目录)、`/fs/file`(下载,支持 Range)、`/fs/upload`(分块续传,支持暂停/取消,落盘前 SHA-256 校验);默认根目录 `~`,`DSH_REMOTE_FS_ROOT` 可开多根(`:` 分隔)。
|
|
32
|
+
- 反馈端点:`POST /feedback`(App / 桌面端「写反馈」),网关转发到反馈收集器;默认 `http://100.84.128.29/submit`(Tailscale 内网),可用 `DSH_REMOTE_FEEDBACK_URL` 覆盖,无需配置任何 token。
|
|
32
33
|
|
|
33
34
|
## 手机 App
|
|
34
35
|
|
package/apk/dsh-remote.apk
CHANGED
|
Binary file
|
package/gateway.cjs
CHANGED
|
@@ -182,9 +182,18 @@ function authorized(req, url) {
|
|
|
182
182
|
|
|
183
183
|
// ---------- 设备监控 ----------
|
|
184
184
|
const devices = new Map() // ip -> device
|
|
185
|
+
// 设备 TTL 是“记录保留时间”,和下方 online 判断的 60s 活跃窗口是两回事:
|
|
186
|
+
// online 只看最近 60s 是否有请求;TTL 用于防止长期运行的网关内存/响应无限膨胀。
|
|
187
|
+
const DEVICE_TTL_MS = 24 * 60 * 60 * 1000
|
|
185
188
|
let totalRequests = 0
|
|
186
189
|
let authFailures = 0
|
|
187
190
|
|
|
191
|
+
function pruneDevices(now = Date.now()) {
|
|
192
|
+
for (const [ip, d] of devices) {
|
|
193
|
+
if (now - d.lastSeen > DEVICE_TTL_MS) devices.delete(ip)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
188
197
|
function loadNotes() {
|
|
189
198
|
try { return JSON.parse(fs.readFileSync(NOTES_FILE, 'utf8')) } catch { return {} }
|
|
190
199
|
}
|
|
@@ -211,6 +220,7 @@ function kindOf(req) {
|
|
|
211
220
|
}
|
|
212
221
|
|
|
213
222
|
function touchDevice(req, extra = {}) {
|
|
223
|
+
pruneDevices()
|
|
214
224
|
const ip = ipOf(req)
|
|
215
225
|
totalRequests++
|
|
216
226
|
let d = devices.get(ip)
|
|
@@ -402,6 +412,120 @@ function cors(res) {
|
|
|
402
412
|
res.setHeader('access-control-allow-methods', 'GET, POST, OPTIONS')
|
|
403
413
|
}
|
|
404
414
|
|
|
415
|
+
// ---------- 事件轮询缓冲 ----------
|
|
416
|
+
// 网关自己维护到 DSH 的 mux/host WebSocket,把事件写入内存环形缓冲;
|
|
417
|
+
// 前端在 WebSocket 被隧道/受限网络阻断时改走 GET /api/events.poll 增量拉取。
|
|
418
|
+
const EVENT_BUFFER_MAX = 300
|
|
419
|
+
const EVENT_MAX_STRING = 16 * 1024
|
|
420
|
+
const eventBuffers = { mux: [], host: [] }
|
|
421
|
+
const eventNextSeq = { mux: 1, host: 1 }
|
|
422
|
+
|
|
423
|
+
/** 递归截断超大字段,避免单条超大事件撑爆环形缓冲。 */
|
|
424
|
+
function truncateEventValue(v, depth = 0) {
|
|
425
|
+
if (typeof v === 'string') return v.length > EVENT_MAX_STRING ? v.slice(0, EVENT_MAX_STRING) + '…[truncated]' : v
|
|
426
|
+
if (Array.isArray(v)) {
|
|
427
|
+
if (depth > 3 || v.length > 200) return v.slice(0, 200)
|
|
428
|
+
return v.map(x => truncateEventValue(x, depth + 1))
|
|
429
|
+
}
|
|
430
|
+
if (v && typeof v === 'object' && depth <= 3) {
|
|
431
|
+
const out = {}
|
|
432
|
+
for (const k of Object.keys(v)) out[k] = truncateEventValue(v[k], depth + 1)
|
|
433
|
+
return out
|
|
434
|
+
}
|
|
435
|
+
return v
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function pushEvent(kind, full) {
|
|
439
|
+
if (!eventBuffers[kind] || !full || typeof full !== 'object') return
|
|
440
|
+
const buf = eventBuffers[kind]
|
|
441
|
+
buf.push({ seq: eventNextSeq[kind]++, ts: Date.now(), event: truncateEventValue(full) })
|
|
442
|
+
if (buf.length > EVENT_BUFFER_MAX) buf.shift()
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function serveEventPoll(req, res, url) {
|
|
446
|
+
if (req.method !== 'GET') {
|
|
447
|
+
res.writeHead(405, { allow: 'GET' })
|
|
448
|
+
res.end()
|
|
449
|
+
return
|
|
450
|
+
}
|
|
451
|
+
if (!authorized(req, url)) {
|
|
452
|
+
authFailures++
|
|
453
|
+
touchDevice(req, { failedAuth: true })
|
|
454
|
+
cors(res)
|
|
455
|
+
res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
|
|
456
|
+
res.end(JSON.stringify({ error: 'unauthorized' }))
|
|
457
|
+
return
|
|
458
|
+
}
|
|
459
|
+
touchDevice(req)
|
|
460
|
+
const kind = url.searchParams.get('kind')
|
|
461
|
+
if (kind !== 'mux' && kind !== 'host') {
|
|
462
|
+
cors(res)
|
|
463
|
+
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
464
|
+
res.end(JSON.stringify({ error: 'bad-kind', detail: 'kind 必须是 mux 或 host' }))
|
|
465
|
+
return
|
|
466
|
+
}
|
|
467
|
+
const sinceRaw = url.searchParams.get('since')
|
|
468
|
+
const since = sinceRaw === null ? 0 : Number(sinceRaw)
|
|
469
|
+
if (!Number.isSafeInteger(since) || since < 0) {
|
|
470
|
+
cors(res)
|
|
471
|
+
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
472
|
+
res.end(JSON.stringify({ error: 'bad-since', detail: 'since 必须是非负整数' }))
|
|
473
|
+
return
|
|
474
|
+
}
|
|
475
|
+
const buf = eventBuffers[kind]
|
|
476
|
+
const events = buf.filter(r => r.seq > since)
|
|
477
|
+
const latestSeq = buf.length ? buf[buf.length - 1].seq : 0
|
|
478
|
+
const truncated = buf.length > 0 && since < buf[0].seq - 1
|
|
479
|
+
cors(res)
|
|
480
|
+
res.writeHead(200, {
|
|
481
|
+
'content-type': 'application/json; charset=utf-8',
|
|
482
|
+
'cache-control': 'no-store'
|
|
483
|
+
})
|
|
484
|
+
res.end(JSON.stringify({ ok: true, kind, since, latestSeq, truncated, events }))
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** 网关自带上游事件采集:mux/host 各一条 WS,断线自动重连。 */
|
|
488
|
+
function startEventCollector(kind) {
|
|
489
|
+
if (typeof WebSocket !== 'function') return null
|
|
490
|
+
let ws = null
|
|
491
|
+
let stopped = false
|
|
492
|
+
let retryTimer = null
|
|
493
|
+
const url = `ws://${UPSTREAM.hostname}:${UPSTREAM.port}/api/events.${kind}?client=web`
|
|
494
|
+
const connect = () => {
|
|
495
|
+
if (stopped) return
|
|
496
|
+
try {
|
|
497
|
+
ws = new WebSocket(url)
|
|
498
|
+
} catch {
|
|
499
|
+
retryTimer = setTimeout(connect, 3000)
|
|
500
|
+
return
|
|
501
|
+
}
|
|
502
|
+
ws.onopen = () => {
|
|
503
|
+
if (stopped) { try { ws.close() } catch {} }
|
|
504
|
+
}
|
|
505
|
+
ws.onmessage = (ev) => {
|
|
506
|
+
if (stopped) return
|
|
507
|
+
try {
|
|
508
|
+
const data = typeof ev.data === 'string' ? ev.data : Buffer.isBuffer(ev.data) ? ev.data.toString() : String(ev.data)
|
|
509
|
+
pushEvent(kind, JSON.parse(data))
|
|
510
|
+
} catch {}
|
|
511
|
+
}
|
|
512
|
+
ws.onclose = () => {
|
|
513
|
+
ws = null
|
|
514
|
+
if (!stopped) retryTimer = setTimeout(connect, 3000)
|
|
515
|
+
}
|
|
516
|
+
ws.onerror = () => { try { ws.close() } catch {} }
|
|
517
|
+
}
|
|
518
|
+
connect()
|
|
519
|
+
return {
|
|
520
|
+
kind,
|
|
521
|
+
close() {
|
|
522
|
+
stopped = true
|
|
523
|
+
clearTimeout(retryTimer)
|
|
524
|
+
try { ws?.close() } catch {}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
405
529
|
// ---------- 统计 API ----------
|
|
406
530
|
let statsScanning = false
|
|
407
531
|
async function scanStatsOnce(delay) {
|
|
@@ -493,6 +617,124 @@ function serveStats(req, res, url) {
|
|
|
493
617
|
res.end(JSON.stringify({ error: 'not found' }))
|
|
494
618
|
}
|
|
495
619
|
|
|
620
|
+
// ---------- 反馈提交 ----------
|
|
621
|
+
const feedbackThrottle = new Map() // ip -> 上次受理时间戳
|
|
622
|
+
const FEEDBACK_WINDOW_MS = 60 * 1000
|
|
623
|
+
// 反馈收集器: 环境变量可覆盖, 默认 Tailscale 内网地址
|
|
624
|
+
const FEEDBACK_URL = process.env.DSH_REMOTE_FEEDBACK_URL || 'http://100.84.128.29/submit'
|
|
625
|
+
|
|
626
|
+
function maskIp(ip) {
|
|
627
|
+
if (!ip) return 'unknown'
|
|
628
|
+
const s = String(ip).replace(/^::ffff:/, '')
|
|
629
|
+
if (s.includes(':')) {
|
|
630
|
+
const groups = s.split(':').filter(Boolean)
|
|
631
|
+
return (groups.slice(0, 2).join(':') || '::') + '::x'
|
|
632
|
+
}
|
|
633
|
+
const parts = s.split('.')
|
|
634
|
+
if (parts.length === 4) return parts.slice(0, 3).join('.') + '.x'
|
|
635
|
+
return s
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function serveFeedback(req, res, url) {
|
|
639
|
+
cors(res)
|
|
640
|
+
if (req.method === 'OPTIONS') {
|
|
641
|
+
res.writeHead(204)
|
|
642
|
+
res.end()
|
|
643
|
+
return
|
|
644
|
+
}
|
|
645
|
+
if (req.method !== 'POST') {
|
|
646
|
+
res.writeHead(405, { 'content-type': 'application/json; charset=utf-8' })
|
|
647
|
+
res.end(JSON.stringify({ error: 'method not allowed' }))
|
|
648
|
+
return
|
|
649
|
+
}
|
|
650
|
+
if (!authorized(req, url)) {
|
|
651
|
+
authFailures++
|
|
652
|
+
touchDevice(req, { failedAuth: true })
|
|
653
|
+
res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
|
|
654
|
+
res.end(JSON.stringify({ error: 'unauthorized' }))
|
|
655
|
+
return
|
|
656
|
+
}
|
|
657
|
+
touchDevice(req)
|
|
658
|
+
|
|
659
|
+
let body = ''
|
|
660
|
+
req.on('data', c => { body += c; if (body.length > 16 * 1024) req.destroy() })
|
|
661
|
+
req.on('end', () => {
|
|
662
|
+
let payload
|
|
663
|
+
try {
|
|
664
|
+
payload = JSON.parse(body || '{}')
|
|
665
|
+
} catch {
|
|
666
|
+
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
667
|
+
res.end(JSON.stringify({ error: 'invalid json' }))
|
|
668
|
+
return
|
|
669
|
+
}
|
|
670
|
+
const type = payload.type
|
|
671
|
+
const message = String(payload.message || '').trim()
|
|
672
|
+
const contact = String(payload.contact || '').trim()
|
|
673
|
+
const appVersion = String(payload.appVersion || '').trim()
|
|
674
|
+
if (!['bug', 'suggestion', 'other'].includes(type)) {
|
|
675
|
+
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
676
|
+
res.end(JSON.stringify({ error: 'invalid type', expect: 'bug|suggestion|other' }))
|
|
677
|
+
return
|
|
678
|
+
}
|
|
679
|
+
if (!message) {
|
|
680
|
+
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
681
|
+
res.end(JSON.stringify({ error: 'message required' }))
|
|
682
|
+
return
|
|
683
|
+
}
|
|
684
|
+
if (message.length > 2000) {
|
|
685
|
+
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
686
|
+
res.end(JSON.stringify({ error: 'message too long', max: 2000 }))
|
|
687
|
+
return
|
|
688
|
+
}
|
|
689
|
+
if (contact.length > 200) {
|
|
690
|
+
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
691
|
+
res.end(JSON.stringify({ error: 'contact too long', max: 200 }))
|
|
692
|
+
return
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const ip = ipOf(req)
|
|
696
|
+
const now = Date.now()
|
|
697
|
+
const last = feedbackThrottle.get(ip) || 0
|
|
698
|
+
if (now - last < FEEDBACK_WINDOW_MS) {
|
|
699
|
+
res.writeHead(429, { 'content-type': 'application/json; charset=utf-8', 'retry-after': String(Math.ceil((FEEDBACK_WINDOW_MS - (now - last)) / 1000)) })
|
|
700
|
+
res.end(JSON.stringify({ error: 'rate_limited', retryAfter: Math.ceil((FEEDBACK_WINDOW_MS - (now - last)) / 1000) }))
|
|
701
|
+
return
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// 转发收集器(收集器服务端已做校验/节流/落盘)。节流只在收集器确认成功后占位,
|
|
705
|
+
// 失败(429/502/网络错误)不占位, 用户可立即重试。
|
|
706
|
+
fetch(FEEDBACK_URL, {
|
|
707
|
+
method: 'POST',
|
|
708
|
+
headers: { 'content-type': 'application/json' },
|
|
709
|
+
body: JSON.stringify({
|
|
710
|
+
type,
|
|
711
|
+
message,
|
|
712
|
+
contact: contact || undefined,
|
|
713
|
+
appVersion: appVersion || 'unknown',
|
|
714
|
+
gatewayVersion: VERSION,
|
|
715
|
+
clientIp: maskIp(ip)
|
|
716
|
+
}),
|
|
717
|
+
signal: AbortSignal.timeout(8000)
|
|
718
|
+
}).then(async (r) => {
|
|
719
|
+
const data = await r.json().catch(() => ({}))
|
|
720
|
+
if (r.status === 429) {
|
|
721
|
+
res.writeHead(429, { 'content-type': 'application/json; charset=utf-8' })
|
|
722
|
+
res.end(JSON.stringify({ error: 'rate_limited' }))
|
|
723
|
+
} else if (r.ok && data.ok) {
|
|
724
|
+
feedbackThrottle.set(ip, now)
|
|
725
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
726
|
+
res.end(JSON.stringify({ ok: true }))
|
|
727
|
+
} else {
|
|
728
|
+
res.writeHead(502, { 'content-type': 'application/json; charset=utf-8' })
|
|
729
|
+
res.end(JSON.stringify({ error: 'upstream_error' }))
|
|
730
|
+
}
|
|
731
|
+
}).catch(() => {
|
|
732
|
+
res.writeHead(502, { 'content-type': 'application/json; charset=utf-8' })
|
|
733
|
+
res.end(JSON.stringify({ error: 'feedback_service_unavailable' }))
|
|
734
|
+
})
|
|
735
|
+
})
|
|
736
|
+
}
|
|
737
|
+
|
|
496
738
|
// ---------- 静态文件 ----------
|
|
497
739
|
function serveStatic(req, res, url) {
|
|
498
740
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
@@ -1427,8 +1669,10 @@ const server = http.createServer((req, res) => {
|
|
|
1427
1669
|
try {
|
|
1428
1670
|
const url = new URL(req.url, 'http://dsh-remote.local')
|
|
1429
1671
|
if (url.pathname === '/fs' || url.pathname.startsWith('/fs/')) return serveFs(req, res, url)
|
|
1672
|
+
if (url.pathname === '/feedback') return serveFeedback(req, res, url)
|
|
1430
1673
|
if (url.pathname.startsWith('/admin/api')) return serveAdminApi(req, res, url)
|
|
1431
1674
|
if (url.pathname.startsWith('/stats')) return serveStats(req, res, url)
|
|
1675
|
+
if (url.pathname === '/api/events.poll') return serveEventPoll(req, res, url)
|
|
1432
1676
|
if (url.pathname.startsWith('/api/')) return proxyApi(req, res, url)
|
|
1433
1677
|
if (url.pathname === '/health') return serveHealth(res)
|
|
1434
1678
|
touchDevice(req)
|
|
@@ -1550,6 +1794,9 @@ server.listen(PORT, HOST, () => {
|
|
|
1550
1794
|
console.log(' 提示: 监听在 127.0.0.1, 手机请改用 Tailscale serve 或设置 HOST=0.0.0.0')
|
|
1551
1795
|
}
|
|
1552
1796
|
console.log(' 上游: ' + UPSTREAM.origin + ' (Ctrl+C 退出)')
|
|
1797
|
+
// 事件轮询缓冲:网关自身上游 WS 采集,断线自动重连
|
|
1798
|
+
startEventCollector('mux')
|
|
1799
|
+
startEventCollector('host')
|
|
1553
1800
|
// 启动 8 秒后首查, 之后每 6 小时查一次 GitHub/镜像最新版
|
|
1554
1801
|
setTimeout(() => checkForUpdates(false), 8000)
|
|
1555
1802
|
setInterval(() => checkForUpdates(false), UPDATE_INTERVAL_MS)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
package/public/admin.html
CHANGED
|
@@ -9,7 +9,13 @@
|
|
|
9
9
|
<link rel="stylesheet" href="../styles.css">
|
|
10
10
|
<style>
|
|
11
11
|
.admin-wrap { max-width: 1180px; margin: 0 auto; padding: calc(env(safe-area-inset-top, 0px) + 14px) 14px 40px; }
|
|
12
|
-
.admin-title {
|
|
12
|
+
.admin-title {
|
|
13
|
+
position: sticky; top: 0; z-index: 40;
|
|
14
|
+
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
|
15
|
+
margin-bottom: 14px; padding: 10px 0;
|
|
16
|
+
background: var(--dsr-bg); /* 实色背景: 滚动时内容不穿透, 不用毛玻璃(小米/MIUI WebView 图层丢花屏) */
|
|
17
|
+
backdrop-filter: none;
|
|
18
|
+
}
|
|
13
19
|
.admin-title h1 { font-size: 20px; margin: 0; white-space: nowrap; }
|
|
14
20
|
.login-card { background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: var(--dsr-radius); padding: 16px; display: flex; gap: 8px; }
|
|
15
21
|
.login-card input { flex: 1; background: var(--dsr-bg); color: var(--dsr-text); border: 1px solid var(--dsr-line); border-radius: 10px; padding: 10px 12px; font: inherit; outline: none; }
|
|
@@ -78,7 +84,6 @@
|
|
|
78
84
|
@media (max-width: 720px) {
|
|
79
85
|
.admin-title h1 { font-size: 16px; }
|
|
80
86
|
.admin-title .right { gap: 6px; }
|
|
81
|
-
.gh-btn { display: none; }
|
|
82
87
|
#btn-close-drawer span { display: none; }
|
|
83
88
|
#btn-close-drawer { padding: 5px 9px; }
|
|
84
89
|
#btn-console span { display: none; }
|
|
@@ -109,34 +114,100 @@
|
|
|
109
114
|
a.mini-btn { text-decoration: none; display: inline-flex; align-items: center; }
|
|
110
115
|
.gh-btn { display: inline-flex; align-items: center; gap: 6px; text-decoration: none; color: var(--dsr-text); }
|
|
111
116
|
.gh-btn svg { width: 16px; height: 16px; fill: currentColor; }
|
|
112
|
-
.admin-title .
|
|
113
|
-
|
|
117
|
+
.admin-title .left { display: flex; align-items: center; gap: 10px; flex: 1 1 auto; min-width: 0; }
|
|
118
|
+
.admin-title .right { display: flex; align-items: center; gap: 8px; flex: 0 1 auto; min-width: 0; justify-content: flex-end; }
|
|
119
|
+
/* 顶栏按钮统一高度/基线: GitHub / 反馈 / 主题 / 语言 / 连接徽章 */
|
|
120
|
+
.tb-btn {
|
|
121
|
+
height: 36px; box-sizing: border-box; max-width: 100%;
|
|
122
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
123
|
+
padding: 0 12px; line-height: 1; vertical-align: middle;
|
|
124
|
+
flex-shrink: 0; white-space: nowrap;
|
|
125
|
+
}
|
|
126
|
+
.tb-btn svg { flex-shrink: 0; }
|
|
127
|
+
.conn-badge.tb-btn { border-radius: 999px; font-size: 12px; }
|
|
128
|
+
.fb-wrap { position: relative; flex-shrink: 0; }
|
|
129
|
+
.fb-btn { display: inline-flex; align-items: center; gap: 6px; justify-content: center; }
|
|
130
|
+
.fb-btn svg { width: 17px; height: 17px; }
|
|
131
|
+
.fb-menu {
|
|
132
|
+
position: absolute; right: 0; top: calc(100% + 8px); z-index: 50;
|
|
133
|
+
width: 248px; background: var(--dsr-bg-2); border: 1px solid var(--dsr-line); border-radius: 14px;
|
|
134
|
+
box-shadow: 0 12px 34px var(--dsr-shadow); padding: 6px;
|
|
135
|
+
display: flex; flex-direction: column; gap: 2px;
|
|
136
|
+
animation: fb-in .18s ease;
|
|
137
|
+
}
|
|
138
|
+
.fb-item {
|
|
139
|
+
min-height: 44px; display: flex; align-items: center; gap: 10px;
|
|
140
|
+
padding: 7px 8px; border-radius: 10px; border: none; background: transparent; color: var(--dsr-text);
|
|
141
|
+
font: inherit; font-size: 13px; text-align: left; cursor: pointer; text-decoration: none;
|
|
142
|
+
}
|
|
143
|
+
.fb-item:hover, .fb-item:focus-visible { background: var(--dsr-bg); outline: none; }
|
|
144
|
+
.fb-item.primary { background: var(--dsr-accent-soft); border: 1px solid var(--dsr-accent-line); }
|
|
145
|
+
.fb-ico {
|
|
146
|
+
width: 32px; height: 32px; border-radius: 9px; flex-shrink: 0;
|
|
147
|
+
display: grid; place-items: center; background: var(--dsr-panel); border: 1px solid var(--dsr-line); color: var(--dsr-accent-strong);
|
|
148
|
+
}
|
|
149
|
+
.fb-item.primary .fb-ico { background: var(--dsr-accent-strong); border-color: var(--dsr-accent-strong); color: var(--dsr-on-accent); }
|
|
150
|
+
.fb-ico svg { width: 16px; height: 16px; fill: currentColor; }
|
|
151
|
+
.fb-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
|
|
152
|
+
.fb-name { font-weight: 600; }
|
|
153
|
+
.fb-desc { font-size: 11px; color: var(--dsr-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
154
|
+
@keyframes fb-in { from { opacity: 0; transform: translateY(-6px) } to { opacity: 1; transform: translateY(0) } }
|
|
155
|
+
/* 窄屏压缩顶栏: 图标化文字按钮, 保证全部按钮完整可见 */
|
|
156
|
+
@media (max-width: 900px) {
|
|
157
|
+
.gh-label, .t-label { display: none; }
|
|
158
|
+
.tb-btn { padding: 0 10px; }
|
|
159
|
+
.admin-title .right { gap: 6px; }
|
|
160
|
+
.admin-title .left { gap: 6px; }
|
|
161
|
+
}
|
|
114
162
|
@media (max-width: 720px) {
|
|
115
|
-
|
|
163
|
+
#btn-console span, #btn-close-drawer span { display: none; }
|
|
164
|
+
#btn-console, #btn-close-drawer { padding: 0 9px; }
|
|
165
|
+
.admin-title h1 { font-size: 17px; }
|
|
116
166
|
}
|
|
117
|
-
@media (max-width:
|
|
118
|
-
.admin-title h1
|
|
167
|
+
@media (max-width: 440px) {
|
|
168
|
+
.admin-title h1 { display: none; }
|
|
169
|
+
.admin-title .right { gap: 4px; }
|
|
170
|
+
.tb-btn { padding: 0 8px; }
|
|
119
171
|
}
|
|
120
172
|
</style>
|
|
121
173
|
</head>
|
|
122
174
|
<body>
|
|
123
175
|
<div class="admin-wrap">
|
|
124
176
|
<div class="admin-title">
|
|
125
|
-
<div
|
|
126
|
-
<a id="btn-console" class="mini-btn" href="../" data-i18n-title="consoleTitle">‹ <span data-i18n="console">控制台</span></a>
|
|
127
|
-
<button id="btn-close-drawer" class="mini-btn hidden" data-i18n-title="collapse">‹ <span data-i18n="collapse">收起面板</span></button>
|
|
177
|
+
<div class="left">
|
|
178
|
+
<a id="btn-console" class="mini-btn tb-btn" href="../" data-i18n-title="consoleTitle">‹ <span data-i18n="console">控制台</span></a>
|
|
179
|
+
<button id="btn-close-drawer" class="mini-btn tb-btn hidden" data-i18n-title="collapse">‹ <span data-i18n="collapse">收起面板</span></button>
|
|
128
180
|
<h1>DSH Remote <span data-i18n="admin">管理</span></h1>
|
|
129
181
|
</div>
|
|
130
182
|
<div class="right">
|
|
131
|
-
<a class="mini-btn gh-btn" href="https://github.com/Blank-not-black/dsh-Remote" target="_blank" rel="noopener" title="GitHub">
|
|
183
|
+
<a class="mini-btn gh-btn tb-btn" href="https://github.com/Blank-not-black/dsh-Remote" target="_blank" rel="noopener" title="GitHub">
|
|
132
184
|
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8a8.01 8.01 0 0 0 5.47 7.59c.4.08.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/></svg>
|
|
133
|
-
<span data-i18n="repo">仓库</span>
|
|
185
|
+
<span class="gh-label" data-i18n="repo">仓库</span>
|
|
134
186
|
</a>
|
|
135
|
-
<
|
|
187
|
+
<div class="fb-wrap">
|
|
188
|
+
<button id="btn-feedback" class="mini-btn fb-btn tb-btn" data-i18n-title="feedbackTitle" data-i18n-aria="feedbackTitle" aria-haspopup="menu" aria-expanded="false">
|
|
189
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a8 8 0 0 1-8 8H5l-1.5 2L4 20a8 8 0 0 1-1-4 8 8 0 0 1 18-4Z"/></svg>
|
|
190
|
+
</button>
|
|
191
|
+
<div id="fb-menu" class="fb-menu hidden" role="menu" data-i18n-aria="feedbackTitle">
|
|
192
|
+
<a class="fb-item primary" href="https://github.com/Blank-not-black/dsh-Remote" target="_blank" rel="noopener" role="menuitem">
|
|
193
|
+
<span class="fb-ico"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8a8.01 8.01 0 0 0 5.47 7.59c.4.08.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/></svg></span>
|
|
194
|
+
<span class="fb-body"><span class="fb-name">GitHub</span><span class="fb-desc" data-i18n="feedback.githubDesc">反馈 bug / 提建议</span></span>
|
|
195
|
+
</a>
|
|
196
|
+
<a class="fb-item" href="https://gitee.com/Blankneverfails/dsh-Remote" target="_blank" rel="noopener" role="menuitem">
|
|
197
|
+
<span class="fb-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="9" r="6"/><path d="M7.5 13.5 7 19a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l-.5-5.5"/><circle cx="10" cy="8.6" r=".5" fill="currentColor" stroke="none"/><circle cx="14" cy="8.6" r=".5" fill="currentColor" stroke="none"/><path d="M11 10.8c.6.5 1.4.5 2 0"/></svg></span>
|
|
198
|
+
<span class="fb-body"><span class="fb-name">Gitee</span><span class="fb-desc" data-i18n="feedback.giteeDesc">国内镜像,无需代理</span></span>
|
|
199
|
+
</a>
|
|
200
|
+
<a class="fb-item" href="https://space.bilibili.com/419009275/dynamic" target="_blank" rel="noopener" role="menuitem">
|
|
201
|
+
<span class="fb-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3.5" y="6" width="17" height="11" rx="2"/><path d="M8.5 3.5v3M15.5 3.5v3"/><circle cx="9.2" cy="10.6" r=".5" fill="currentColor" stroke="none"/><circle cx="14.8" cy="10.6" r=".5" fill="currentColor" stroke="none"/><path d="M9.5 14c1.4 1 3.6 1 5 0"/></svg></span>
|
|
202
|
+
<span class="fb-body"><span class="fb-name">B站</span><span class="fb-desc" data-i18n="feedback.biliDesc">UP 动态页交流</span></span>
|
|
203
|
+
</a>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
<button id="btn-theme" class="mini-btn tb-btn" title="">
|
|
136
207
|
<span id="theme-swatch" class="theme-swatch-dot"></span><span id="theme-label" class="t-label">默认深空</span>
|
|
137
208
|
</button>
|
|
138
|
-
<button id="btn-lang" class="mini-btn" title="Language / 语言">EN</button>
|
|
139
|
-
<button id="conn-badge" class="conn-badge off" title="网关面板"><span data-i18n="unauth">未认证</span></button>
|
|
209
|
+
<button id="btn-lang" class="mini-btn tb-btn" title="Language / 语言">EN</button>
|
|
210
|
+
<button id="conn-badge" class="conn-badge tb-btn off" title="网关面板"><span data-i18n="unauth">未认证</span></button>
|
|
140
211
|
</div>
|
|
141
212
|
</div>
|
|
142
213
|
|
|
@@ -185,6 +256,7 @@
|
|
|
185
256
|
</table>
|
|
186
257
|
<div id="device-empty" class="empty hidden" data-i18n="noDevices">暂无设备记录</div>
|
|
187
258
|
</div>
|
|
259
|
+
|
|
188
260
|
</div>
|
|
189
261
|
</div>
|
|
190
262
|
|
|
@@ -210,6 +282,8 @@
|
|
|
210
282
|
'theme.default': '默认深空', 'theme.dark': '落日', 'theme.light': '易北爱乐厅', 'theme.neutral': '草原孤塔',
|
|
211
283
|
'theme.panelTitle': '选择配色', 'theme.close': '关闭',
|
|
212
284
|
'repo': '仓库',
|
|
285
|
+
'feedbackTitle': '反馈渠道',
|
|
286
|
+
'feedback.githubDesc': '反馈 bug / 提建议', 'feedback.giteeDesc': '国内镜像,无需代理', 'feedback.biliDesc': 'UP 动态页交流',
|
|
213
287
|
'unauth': '未认证',
|
|
214
288
|
'tokenPlaceholder': '输入网关访问令牌',
|
|
215
289
|
'enter': '进入',
|
|
@@ -282,6 +356,8 @@
|
|
|
282
356
|
'theme.default': 'Default', 'theme.dark': 'Sunset', 'theme.light': 'Elbphilharmonie', 'theme.neutral': 'Prairie Tower',
|
|
283
357
|
'theme.panelTitle': 'Choose theme', 'theme.close': 'Close',
|
|
284
358
|
'repo': 'Repo',
|
|
359
|
+
'feedbackTitle': 'Feedback',
|
|
360
|
+
'feedback.githubDesc': 'Report bugs · suggest features', 'feedback.giteeDesc': 'Mirror in China, no proxy needed', 'feedback.biliDesc': 'Chat on the UP\'s Bilibili page',
|
|
285
361
|
'unauth': 'Not connected',
|
|
286
362
|
'tokenPlaceholder': 'Gateway access token',
|
|
287
363
|
'enter': 'Enter',
|
package/public/admin.js
CHANGED
|
@@ -120,6 +120,21 @@ function toast(text, kind = '') {
|
|
|
120
120
|
toast._t = setTimeout(() => el.classList.add('hidden'), 2600)
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/* ---------------- 反馈 ---------------- */
|
|
124
|
+
function openFeedbackMenu() {
|
|
125
|
+
$('fb-menu').classList.remove('hidden')
|
|
126
|
+
$('btn-feedback').setAttribute('aria-expanded', 'true')
|
|
127
|
+
const first = $('fb-menu').querySelector('[role="menuitem"]')
|
|
128
|
+
if (first) first.focus()
|
|
129
|
+
}
|
|
130
|
+
function closeFeedbackMenu() {
|
|
131
|
+
$('fb-menu').classList.add('hidden')
|
|
132
|
+
$('btn-feedback').setAttribute('aria-expanded', 'false')
|
|
133
|
+
}
|
|
134
|
+
function toggleFeedbackMenu() {
|
|
135
|
+
$('fb-menu').classList.contains('hidden') ? openFeedbackMenu() : closeFeedbackMenu()
|
|
136
|
+
}
|
|
137
|
+
|
|
123
138
|
function fmtUptime(sec) {
|
|
124
139
|
if (sec < 60) return sec + t('unit.sec')
|
|
125
140
|
if (sec < 3600) return Math.floor(sec / 60) + t('unit.min')
|
|
@@ -477,6 +492,17 @@ $('btn-lang').addEventListener('click', () => {
|
|
|
477
492
|
|
|
478
493
|
$('btn-theme').addEventListener('click', openThemePanel)
|
|
479
494
|
$('theme-close').addEventListener('click', () => $('modal-theme').classList.add('hidden'))
|
|
495
|
+
// 反馈
|
|
496
|
+
$('btn-feedback').addEventListener('click', (e) => { e.stopPropagation(); toggleFeedbackMenu() })
|
|
497
|
+
$('fb-menu').addEventListener('click', (e) => {
|
|
498
|
+
if (e.target.closest('a[role="menuitem"]')) closeFeedbackMenu()
|
|
499
|
+
})
|
|
500
|
+
document.addEventListener('click', (e) => {
|
|
501
|
+
if (!e.target.closest('.fb-wrap')) closeFeedbackMenu()
|
|
502
|
+
})
|
|
503
|
+
document.addEventListener('keydown', (e) => {
|
|
504
|
+
if (e.key === 'Escape' && !$('fb-menu').classList.contains('hidden')) { closeFeedbackMenu(); $('btn-feedback').focus() }
|
|
505
|
+
})
|
|
480
506
|
|
|
481
507
|
function start(showLogin) {
|
|
482
508
|
if (!showLogin) {
|