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/public/app.js
CHANGED
|
@@ -57,6 +57,8 @@ const state = {
|
|
|
57
57
|
jobs: {}, // sessionId -> jobs
|
|
58
58
|
history: emptyHistory(),
|
|
59
59
|
errCount: 0,
|
|
60
|
+
streamMode: 'ws', // 'ws' | 'poll'
|
|
61
|
+
pollSeq: { mux: 0, host: 0 },
|
|
60
62
|
refreshTimer: null,
|
|
61
63
|
fs: { path: null, initial: null, loaded: false, upload: null },
|
|
62
64
|
models: { loaded: false, loading: false, groups: [], current: null, failures: [] }
|
|
@@ -76,6 +78,74 @@ function toast(text, kind = '') {
|
|
|
76
78
|
toast._t = setTimeout(() => el.classList.add('hidden'), 3200)
|
|
77
79
|
}
|
|
78
80
|
|
|
81
|
+
/* ---------------- 反馈 ---------------- */
|
|
82
|
+
const FEEDBACK_LINKS = {
|
|
83
|
+
githubIssues: 'https://github.com/Blank-not-black/dsh-Remote/issues',
|
|
84
|
+
giteeIssues: 'https://gitee.com/Blankneverfails/dsh-Remote/issues',
|
|
85
|
+
bili: 'https://space.bilibili.com/419009275/dynamic',
|
|
86
|
+
repo: 'https://github.com/Blank-not-black/dsh-Remote'
|
|
87
|
+
}
|
|
88
|
+
async function copyText(text) {
|
|
89
|
+
try { await navigator.clipboard.writeText(text); return true } catch {}
|
|
90
|
+
try {
|
|
91
|
+
const ta = document.createElement('textarea')
|
|
92
|
+
ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'
|
|
93
|
+
document.body.appendChild(ta); ta.focus(); ta.select()
|
|
94
|
+
const ok = document.execCommand('copy')
|
|
95
|
+
ta.remove(); return ok
|
|
96
|
+
} catch { return false }
|
|
97
|
+
}
|
|
98
|
+
function openFeedbackSheet() {
|
|
99
|
+
$('feedback-backdrop').classList.remove('hidden')
|
|
100
|
+
$('feedback-sheet').classList.remove('hidden')
|
|
101
|
+
$('btn-feedback').setAttribute('aria-expanded', 'true')
|
|
102
|
+
const first = $('feedback-sheet').querySelector('[role="menuitem"]')
|
|
103
|
+
if (first) first.focus()
|
|
104
|
+
}
|
|
105
|
+
function closeFeedbackSheet() {
|
|
106
|
+
$('feedback-backdrop').classList.add('hidden')
|
|
107
|
+
$('feedback-sheet').classList.add('hidden')
|
|
108
|
+
$('btn-feedback').setAttribute('aria-expanded', 'false')
|
|
109
|
+
}
|
|
110
|
+
function toggleFeedbackSheet() {
|
|
111
|
+
$('feedback-sheet').classList.contains('hidden') ? openFeedbackSheet() : closeFeedbackSheet()
|
|
112
|
+
}
|
|
113
|
+
function openFeedbackModal() {
|
|
114
|
+
state.feedbackType = 'bug'
|
|
115
|
+
document.querySelectorAll('#fb-chips .fb-chip').forEach(b => b.classList.toggle('current', b.dataset.fbType === 'bug'))
|
|
116
|
+
$('fb-msg').value = ''
|
|
117
|
+
$('fb-contact').value = ''
|
|
118
|
+
$('modal-feedback').classList.remove('hidden')
|
|
119
|
+
setTimeout(() => $('fb-msg').focus(), 50)
|
|
120
|
+
}
|
|
121
|
+
function closeFeedbackModal() { $('modal-feedback').classList.add('hidden') }
|
|
122
|
+
async function submitFeedback() {
|
|
123
|
+
const type = state.feedbackType || 'bug'
|
|
124
|
+
const message = $('fb-msg').value.trim()
|
|
125
|
+
const contact = $('fb-contact').value.trim()
|
|
126
|
+
if (!message) { toast(t('feedback.empty'), 'err'); return }
|
|
127
|
+
if (message.length > 2000) { toast(t('feedback.tooLong'), 'err'); return }
|
|
128
|
+
const btn = $('fb-submit')
|
|
129
|
+
btn.disabled = true
|
|
130
|
+
try {
|
|
131
|
+
const base = (state.server || '').replace(/\/+$/, '')
|
|
132
|
+
const res = await fetch(base + '/feedback', {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token },
|
|
135
|
+
body: JSON.stringify({ type, message, contact, appVersion: state.localVersion })
|
|
136
|
+
})
|
|
137
|
+
let json = {}
|
|
138
|
+
try { json = await res.json() } catch {}
|
|
139
|
+
if (res.ok && json.ok) { toast(t('feedback.submitted'), 'ok'); closeFeedbackModal() }
|
|
140
|
+
else if (res.status === 429) { toast(json.retryAfter ? t('feedback.rateLimitedAt', { n: json.retryAfter }) : t('feedback.rateLimited'), 'err') }
|
|
141
|
+
else { toast(t('feedback.submitFailed', { msg: json.error || res.status }), 'err') }
|
|
142
|
+
} catch {
|
|
143
|
+
toast(t('feedback.submitFailed', { msg: t('feedback.networkError') }), 'err')
|
|
144
|
+
} finally {
|
|
145
|
+
btn.disabled = false
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
79
149
|
function fmtTime(ts) {
|
|
80
150
|
if (!ts) return ''
|
|
81
151
|
const diff = Date.now() - ts
|
|
@@ -629,12 +699,16 @@ function deleteGroup(name) {
|
|
|
629
699
|
if (state.token) selectFastestServer({ silent: true })
|
|
630
700
|
}
|
|
631
701
|
|
|
632
|
-
/* ---------------- 事件流 (WebSocket) ---------------- */
|
|
702
|
+
/* ---------------- 事件流 (WebSocket + 轮询降级) ---------------- */
|
|
633
703
|
const streams = {}
|
|
634
704
|
state.streamsOk = { mux: false, host: false }
|
|
705
|
+
let pollTimer = null
|
|
706
|
+
let wsRetryTimer = null
|
|
635
707
|
|
|
636
708
|
function openStreams() {
|
|
637
709
|
if (!state.token) return
|
|
710
|
+
if (state.streamMode === 'poll') stopPolling()
|
|
711
|
+
state.streamMode = 'ws'
|
|
638
712
|
openStream('mux', onMuxFrame, true)
|
|
639
713
|
openStream('host', onHostFrame, false)
|
|
640
714
|
}
|
|
@@ -655,6 +729,8 @@ function openStream(kind, handler, refreshOnOpen) {
|
|
|
655
729
|
ws.onopen = () => {
|
|
656
730
|
state.streamsOk[kind] = true
|
|
657
731
|
state.errCount = 0
|
|
732
|
+
// 重连成功:切回 WS 并停止轮询
|
|
733
|
+
if (state.streamMode === 'poll') { stopPolling(); state.streamMode = 'ws' }
|
|
658
734
|
updateConn()
|
|
659
735
|
// mux 每次(重)连接都会重放“仍待处理”的审批/提问基线:
|
|
660
736
|
// 先清空旧列表, 避免“桌面已自定义回答, 手机漏收 question/resolved”后永久残留。
|
|
@@ -678,7 +754,9 @@ function openStream(kind, handler, refreshOnOpen) {
|
|
|
678
754
|
state.streamsOk[kind] = false
|
|
679
755
|
state.errCount++
|
|
680
756
|
updateConn()
|
|
681
|
-
if (state.
|
|
757
|
+
if (state.streamMode === 'poll') return
|
|
758
|
+
// 连续失败 3 次 -> 降级为轮询
|
|
759
|
+
if (state.errCount >= 3) { enterPollMode(); return }
|
|
682
760
|
// 多服务器: 连续掉线若干次就重测速, 自动换到当前可达的最快地址
|
|
683
761
|
if (state.servers.length && state.errCount % 5 === 0) setTimeout(() => selectFastestServer({ silent: true }), 300)
|
|
684
762
|
// 无条件重连; 页面被挂起时定时器暂停, visibilitychange 会再触发一次
|
|
@@ -687,6 +765,78 @@ function openStream(kind, handler, refreshOnOpen) {
|
|
|
687
765
|
ws.onerror = () => { try { ws.close() } catch {} }
|
|
688
766
|
}
|
|
689
767
|
|
|
768
|
+
/* ---------------- 轮询降级模式 ---------------- */
|
|
769
|
+
function enterPollMode() {
|
|
770
|
+
if (state.streamMode === 'poll') return
|
|
771
|
+
state.streamMode = 'poll'
|
|
772
|
+
state.pollSeq = { mux: 0, host: 0 }
|
|
773
|
+
state.streamsOk = { mux: false, host: false }
|
|
774
|
+
try { streams.mux?.close() } catch {}
|
|
775
|
+
try { streams.host?.close() } catch {}
|
|
776
|
+
streams.mux = null
|
|
777
|
+
streams.host = null
|
|
778
|
+
refreshAll()
|
|
779
|
+
startPolling()
|
|
780
|
+
updateConn()
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function stopPolling() {
|
|
784
|
+
clearInterval(pollTimer)
|
|
785
|
+
pollTimer = null
|
|
786
|
+
clearTimeout(wsRetryTimer)
|
|
787
|
+
wsRetryTimer = null
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function startPolling() {
|
|
791
|
+
stopPolling()
|
|
792
|
+
pollTimer = setInterval(pollOnce, 4000)
|
|
793
|
+
wsRetryTimer = setInterval(tryRestoreWs, 30000)
|
|
794
|
+
pollOnce()
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
let pollInFlight = false
|
|
798
|
+
async function pollOnce() {
|
|
799
|
+
if (state.streamMode !== 'poll' || pollInFlight) return
|
|
800
|
+
pollInFlight = true
|
|
801
|
+
try {
|
|
802
|
+
await Promise.all([pollKind('mux'), pollKind('host')])
|
|
803
|
+
} finally {
|
|
804
|
+
pollInFlight = false
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
async function pollKind(kind) {
|
|
809
|
+
if (state.streamMode !== 'poll') return
|
|
810
|
+
const since = state.pollSeq[kind] || 0
|
|
811
|
+
let res
|
|
812
|
+
try {
|
|
813
|
+
const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(5000) : undefined
|
|
814
|
+
const headers = { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' }
|
|
815
|
+
res = signal ? await fetch(apiUrl(`/api/events.poll?kind=${kind}&since=${since}`), { signal, headers }) : await fetch(apiUrl(`/api/events.poll?kind=${kind}&since=${since}`), { headers })
|
|
816
|
+
} catch { return }
|
|
817
|
+
if (res.status === 401) { authFailure(); return }
|
|
818
|
+
if (!res.ok) return
|
|
819
|
+
let data
|
|
820
|
+
try { data = await res.json() } catch { return }
|
|
821
|
+
if (!data || !Array.isArray(data.events)) return
|
|
822
|
+
// 网关重启后 seq 会重置:落后就从头拉当前缓冲
|
|
823
|
+
if (typeof data.latestSeq === 'number' && data.latestSeq < since) state.pollSeq[kind] = 0
|
|
824
|
+
for (const item of data.events) {
|
|
825
|
+
if (item.seq > (state.pollSeq[kind] || 0)) {
|
|
826
|
+
state.pollSeq[kind] = item.seq
|
|
827
|
+
if (kind === 'mux') onMuxFrame(item.event)
|
|
828
|
+
else onHostFrame(item.event)
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function tryRestoreWs() {
|
|
834
|
+
if (state.streamMode !== 'poll' || !state.token) return
|
|
835
|
+
// 轮询继续跑,等 WS 真正 onopen 后再切回,避免重连窗口丢事件
|
|
836
|
+
openStream('mux', onMuxFrame, true)
|
|
837
|
+
openStream('host', onHostFrame, false)
|
|
838
|
+
}
|
|
839
|
+
|
|
690
840
|
/* 回前台恢复: 强制重排修复 MIUI WebView 后台切回时 sticky 顶栏不绘制的问题 */
|
|
691
841
|
function onResume() {
|
|
692
842
|
if (document.visibilityState !== 'visible') return
|
|
@@ -2133,13 +2283,64 @@ async function checkUpdate(silent) {
|
|
|
2133
2283
|
}
|
|
2134
2284
|
}
|
|
2135
2285
|
|
|
2136
|
-
function
|
|
2286
|
+
async function sha256Hex(buffer) {
|
|
2287
|
+
const digest = await crypto.subtle.digest('SHA-256', buffer)
|
|
2288
|
+
return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('')
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
/**
|
|
2292
|
+
* 下载 APK 并用 update.json 的 sha256 校验。
|
|
2293
|
+
* 返回 { ok, skipped } 或 { ok:false, status | corrupted | network }。
|
|
2294
|
+
* 老产物没有 sha256 时跳过校验;crypto.subtle 不可用也跳过(不阻塞老 WebView)。
|
|
2295
|
+
*/
|
|
2296
|
+
async function verifyUpdateApk(info, url) {
|
|
2297
|
+
const expected = String(info.sha256 || '').trim().toLowerCase()
|
|
2298
|
+
if (!expected || !/^[0-9a-f]{64}$/.test(expected)) return { ok: true, skipped: true }
|
|
2299
|
+
let res
|
|
2300
|
+
try {
|
|
2301
|
+
const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(120000) : undefined
|
|
2302
|
+
res = signal ? await fetch(url, { signal }) : await fetch(url)
|
|
2303
|
+
} catch (err) {
|
|
2304
|
+
return { ok: false, network: true, msg: err?.message || '' }
|
|
2305
|
+
}
|
|
2306
|
+
if (!res.ok) return { ok: false, status: res.status }
|
|
2307
|
+
let buf
|
|
2308
|
+
try {
|
|
2309
|
+
buf = await res.arrayBuffer()
|
|
2310
|
+
} catch (err) {
|
|
2311
|
+
return { ok: false, network: true, msg: err?.message || '' }
|
|
2312
|
+
}
|
|
2313
|
+
let actual
|
|
2314
|
+
try {
|
|
2315
|
+
actual = await sha256Hex(buf)
|
|
2316
|
+
} catch {
|
|
2317
|
+
return { ok: true, skipped: true }
|
|
2318
|
+
}
|
|
2319
|
+
if (actual.toLowerCase() !== expected) return { ok: false, corrupted: true }
|
|
2320
|
+
return { ok: true, skipped: false }
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
async function downloadUpdate() {
|
|
2137
2324
|
const info = state.updateInfo
|
|
2138
2325
|
if (!info) return
|
|
2139
2326
|
const base = state.server || ''
|
|
2140
2327
|
let url
|
|
2141
2328
|
try { url = new URL(info.apkUrl || 'dsh-remote.apk', base + '/').href }
|
|
2142
2329
|
catch { url = base + '/' + (info.apkUrl || 'dsh-remote.apk') }
|
|
2330
|
+
|
|
2331
|
+
// 先下载校验再交给原生/浏览器安装;校验失败不进入安装
|
|
2332
|
+
const verify = await verifyUpdateApk(info, url)
|
|
2333
|
+
if (!verify.ok) {
|
|
2334
|
+
if (verify.corrupted) {
|
|
2335
|
+
toast(t('update.corrupted'), 'err')
|
|
2336
|
+
} else if (verify.status) {
|
|
2337
|
+
toast(t('update.serverFileMissing'), 'err')
|
|
2338
|
+
} else {
|
|
2339
|
+
toast(t('update.downloadFailed', { msg: verify.msg || t('fs.networkError') }), 'err')
|
|
2340
|
+
}
|
|
2341
|
+
return
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2143
2344
|
if (CAP?.isNativePlatform?.()) {
|
|
2144
2345
|
// Android WebView 原生桥(不依赖 Capacitor 插件路由)
|
|
2145
2346
|
if (window.NativeUpdate?.downloadAndInstall) {
|
|
@@ -2248,14 +2449,20 @@ function showView(id) {
|
|
|
2248
2449
|
}
|
|
2249
2450
|
|
|
2250
2451
|
function updateConn() {
|
|
2251
|
-
const ok = !!state.streamsOk?.mux
|
|
2252
2452
|
const el = $('conn-badge')
|
|
2253
|
-
el.textContent = ok ? t('conn.on') : t('conn.off')
|
|
2254
|
-
el.className = 'conn-badge ' + (ok ? 'on' : 'off')
|
|
2255
2453
|
const cur = state.servers.find(s => s.url === state.server)
|
|
2256
2454
|
const ms = state.serverLatency[state.server]
|
|
2257
2455
|
const curGroup = cur ? cur.group : state.activeGroup
|
|
2258
2456
|
const curLabel = cur ? (cur.note || cur.url) : (state.server || t('speed.origin'))
|
|
2457
|
+
if (state.streamMode === 'poll') {
|
|
2458
|
+
el.textContent = t('conn.poll')
|
|
2459
|
+
el.className = 'topbar-btn conn-badge off'
|
|
2460
|
+
el.title = t('conn.pollTitle') + ' · ' + t('conn.titleGroup', { group: curGroup, url: curLabel, ms: Number.isFinite(ms) ? ms + 'ms' : '—' })
|
|
2461
|
+
return
|
|
2462
|
+
}
|
|
2463
|
+
const ok = !!state.streamsOk?.mux
|
|
2464
|
+
el.textContent = ok ? t('conn.on') : t('conn.off')
|
|
2465
|
+
el.className = 'topbar-btn conn-badge ' + (ok ? 'on' : 'off')
|
|
2259
2466
|
el.title = t('conn.titleGroup', { group: curGroup, url: curLabel, ms: Number.isFinite(ms) ? ms + 'ms' : '—' })
|
|
2260
2467
|
}
|
|
2261
2468
|
|
|
@@ -2455,8 +2662,30 @@ function bindUi() {
|
|
|
2455
2662
|
$('btn-stats').addEventListener('click', () => { renderSessionCards(); $('modal-stats').classList.remove('hidden') })
|
|
2456
2663
|
$('stats-close').addEventListener('click', () => $('modal-stats').classList.add('hidden'))
|
|
2457
2664
|
$('btn-refresh').addEventListener('click', () => { toast(t('common.refreshing')); openStreams(); refreshAll() })
|
|
2458
|
-
|
|
2459
|
-
|
|
2665
|
+
// 反馈
|
|
2666
|
+
$('btn-feedback').addEventListener('click', (e) => { e.stopPropagation(); toggleFeedbackSheet() })
|
|
2667
|
+
$('feedback-backdrop').addEventListener('click', closeFeedbackSheet)
|
|
2668
|
+
$('feedback-sheet').addEventListener('click', (e) => {
|
|
2669
|
+
if (e.target.closest('a[role="menuitem"]')) closeFeedbackSheet()
|
|
2670
|
+
})
|
|
2671
|
+
$('btn-copy-link').addEventListener('click', async () => {
|
|
2672
|
+
const ok = await copyText(FEEDBACK_LINKS.repo)
|
|
2673
|
+
toast(t(ok ? 'feedback.copied' : 'feedback.copyFailed'), ok ? 'ok' : 'err')
|
|
2674
|
+
closeFeedbackSheet()
|
|
2675
|
+
})
|
|
2676
|
+
$('btn-write-feedback').addEventListener('click', () => { closeFeedbackSheet(); openFeedbackModal() })
|
|
2677
|
+
$('fb-cancel').addEventListener('click', closeFeedbackModal)
|
|
2678
|
+
$('fb-submit').addEventListener('click', submitFeedback)
|
|
2679
|
+
document.querySelectorAll('#fb-chips .fb-chip').forEach(btn =>
|
|
2680
|
+
btn.addEventListener('click', () => {
|
|
2681
|
+
state.feedbackType = btn.dataset.fbType
|
|
2682
|
+
document.querySelectorAll('#fb-chips .fb-chip').forEach(b => b.classList.toggle('current', b === btn))
|
|
2683
|
+
}))
|
|
2684
|
+
document.addEventListener('click', (e) => {
|
|
2685
|
+
if (!e.target.closest('#feedback-sheet') && !e.target.closest('#btn-feedback')) closeFeedbackSheet()
|
|
2686
|
+
})
|
|
2687
|
+
document.addEventListener('keydown', (e) => {
|
|
2688
|
+
if (e.key === 'Escape' && !$('feedback-sheet').classList.contains('hidden')) { closeFeedbackSheet(); $('btn-feedback').focus() }
|
|
2460
2689
|
})
|
|
2461
2690
|
$('btn-new-session').addEventListener('click', newSession)
|
|
2462
2691
|
$('btn-cancel').addEventListener('click', cancelSession)
|
|
@@ -12,6 +12,7 @@ html, body { height: 100%; margin: 0; overflow: hidden; overscroll-behavior: non
|
|
|
12
12
|
.ds-btn.primary { background: var(--dsr-accent-soft); border-color: var(--dsr-accent-line); color: var(--dsr-accent-strong); }
|
|
13
13
|
.ds-btn:hover { filter: brightness(1.06); }
|
|
14
14
|
.ds-btn:active { filter: brightness(.96); }
|
|
15
|
+
a.ds-btn { text-decoration: none; }
|
|
15
16
|
.ds-new-session .ds-btn { width: 100%; justify-content: center; }
|
|
16
17
|
.ds-section-label { font-size: 11px; color: var(--dsr-muted); letter-spacing: .8px; padding: 10px 14px 6px; font-weight: 700; }
|
|
17
18
|
.ds-session-list { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 0 8px; display: flex; flex-direction: column; gap: 3px; }
|
|
@@ -28,6 +29,56 @@ html, body { height: 100%; margin: 0; overflow: hidden; overscroll-behavior: non
|
|
|
28
29
|
.ds-nav-item.active { background: var(--dsr-accent-soft); color: var(--dsr-accent-strong); }
|
|
29
30
|
.ds-nav-ico { width: 18px; text-align: center; }
|
|
30
31
|
|
|
32
|
+
/* 侧边栏底部常驻反馈入口 */
|
|
33
|
+
.ds-feedback { position: relative; margin-top: 4px; }
|
|
34
|
+
.ds-feedback-btn {
|
|
35
|
+
display: flex; align-items: center; gap: 8px; width: 100%;
|
|
36
|
+
text-align: left; border: none; background: transparent; color: var(--dsr-text);
|
|
37
|
+
font: inherit; font-size: 13px; padding: 9px 10px; border-radius: 9px; cursor: pointer;
|
|
38
|
+
}
|
|
39
|
+
.ds-feedback-btn:hover { background: var(--dsr-bg-2); }
|
|
40
|
+
.ds-feedback-btn svg { width: 16px; height: 16px; flex-shrink: 0; }
|
|
41
|
+
.ds-feedback-menu {
|
|
42
|
+
position: absolute; bottom: calc(100% + 8px); left: 0; right: 0; z-index: 45;
|
|
43
|
+
background: var(--dsr-bg-2); border: 1px solid var(--dsr-line); border-radius: 14px;
|
|
44
|
+
box-shadow: 0 12px 34px var(--dsr-shadow); padding: 6px;
|
|
45
|
+
display: flex; flex-direction: column; gap: 2px;
|
|
46
|
+
animation: ds-feedback-in .18s ease;
|
|
47
|
+
}
|
|
48
|
+
.ds-feedback-menu.hidden { display: none; }
|
|
49
|
+
.ds-feedback-item {
|
|
50
|
+
width: 100%; min-height: 44px; display: flex; align-items: center; gap: 10px;
|
|
51
|
+
padding: 7px 8px; border-radius: 10px; border: none; background: transparent; color: var(--dsr-text);
|
|
52
|
+
font: inherit; text-align: left; cursor: pointer; text-decoration: none;
|
|
53
|
+
}
|
|
54
|
+
.ds-feedback-item:hover, .ds-feedback-item:focus-visible { background: var(--dsr-bg); outline: none; }
|
|
55
|
+
.ds-feedback-item.primary { background: var(--dsr-accent-soft); border: 1px solid var(--dsr-accent-line); }
|
|
56
|
+
.ds-feedback-ico {
|
|
57
|
+
width: 32px; height: 32px; border-radius: 9px; flex-shrink: 0;
|
|
58
|
+
display: grid; place-items: center; background: var(--dsr-panel); border: 1px solid var(--dsr-line); color: var(--dsr-accent-strong);
|
|
59
|
+
}
|
|
60
|
+
.ds-feedback-item.primary .ds-feedback-ico { background: var(--dsr-accent-strong); border-color: var(--dsr-accent-strong); color: var(--dsr-on-accent); }
|
|
61
|
+
.ds-feedback-ico svg { width: 16px; height: 16px; fill: currentColor; }
|
|
62
|
+
.ds-feedback-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
|
|
63
|
+
.ds-feedback-name { font-size: 13px; font-weight: 600; }
|
|
64
|
+
.ds-feedback-desc { font-size: 11px; color: var(--dsr-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
65
|
+
@keyframes ds-feedback-in { from { opacity: 0; transform: translateY(6px) } to { opacity: 1; transform: translateY(0) } }
|
|
66
|
+
|
|
67
|
+
/* 写反馈弹层 */
|
|
68
|
+
.ds-fb-chips { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
|
|
69
|
+
.ds-fb-chip {
|
|
70
|
+
min-height: 36px; padding: 7px 14px; border-radius: 999px; font-size: 13px;
|
|
71
|
+
background: var(--dsr-panel); border: 1px solid var(--dsr-line); color: var(--dsr-text); cursor: pointer;
|
|
72
|
+
}
|
|
73
|
+
.ds-fb-chip.current { background: var(--dsr-accent-soft); border-color: var(--dsr-accent-line); color: var(--dsr-accent-strong); font-weight: 600; }
|
|
74
|
+
.ds-fb-textarea, .ds-fb-input {
|
|
75
|
+
width: 100%; box-sizing: border-box; margin-bottom: 10px;
|
|
76
|
+
background: var(--dsr-panel); color: var(--dsr-text); border: 1px solid var(--dsr-line); border-radius: 12px;
|
|
77
|
+
padding: 10px 12px; font: inherit; font-size: 14px; outline: none; resize: vertical;
|
|
78
|
+
}
|
|
79
|
+
.ds-fb-textarea:focus, .ds-fb-input:focus { border-color: var(--dsr-accent-line); }
|
|
80
|
+
.ds-fb-input { min-height: 42px; }
|
|
81
|
+
|
|
31
82
|
.ds-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
|
32
83
|
.ds-topbar { height: 52px; flex: none; display: flex; align-items: center; gap: 10px; padding: 0 14px; border-bottom: 1px solid var(--dsr-line); background: var(--dsr-panel); }
|
|
33
84
|
.ds-icon-btn { width: 32px; height: 32px; border-radius: 9px; border: 1px solid var(--dsr-line); background: var(--dsr-bg-2); color: var(--dsr-text); cursor: pointer; font-size: 15px; display: inline-flex; align-items: center; justify-content: center; }
|
|
@@ -76,6 +127,7 @@ html, body { height: 100%; margin: 0; overflow: hidden; overscroll-behavior: non
|
|
|
76
127
|
.ds-setting-row > div:first-child { flex: 1; min-width: 0; }
|
|
77
128
|
.ds-setting-name { font-size: 13.5px; font-weight: 600; }
|
|
78
129
|
.ds-setting-desc { font-size: 12px; color: var(--dsr-muted); margin-top: 2px; word-break: break-all; }
|
|
130
|
+
.ds-feedback-links { display: flex; gap: 8px; flex-wrap: wrap; padding: 0 13px; }
|
|
79
131
|
.ds-group-bar { display: flex; align-items: center; gap: 10px; padding: 2px 0; }
|
|
80
132
|
.ds-group-bar > label { font-size: 12px; color: var(--dsr-muted); white-space: nowrap; }
|
|
81
133
|
.ds-group-select { flex: 1; min-width: 0; position: relative; }
|
|
@@ -173,6 +225,17 @@ html, body { height: 100%; margin: 0; overflow: hidden; overscroll-behavior: non
|
|
|
173
225
|
.ds-toast { position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%); z-index: 150; background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: 10px; padding: 9px 15px; font-size: 13px; box-shadow: 0 8px 24px rgba(0,0,0,.3); }
|
|
174
226
|
.ds-toast.hidden { display: none; }
|
|
175
227
|
|
|
228
|
+
/* 自定义悬停提示: 定位在元素附近, 限制宽度并夹在视口内 */
|
|
229
|
+
.ds-tip {
|
|
230
|
+
position: fixed; z-index: 160; pointer-events: none;
|
|
231
|
+
max-width: min(260px, calc(100vw - 16px));
|
|
232
|
+
background: var(--dsr-bg-2); border: 1px solid var(--dsr-line); border-radius: 9px;
|
|
233
|
+
padding: 7px 10px; font-size: 12px; line-height: 1.6; color: var(--dsr-text);
|
|
234
|
+
box-shadow: 0 8px 24px var(--dsr-shadow);
|
|
235
|
+
white-space: pre-line; word-break: break-word;
|
|
236
|
+
}
|
|
237
|
+
.ds-tip.hidden { display: none; }
|
|
238
|
+
|
|
176
239
|
.mobile-only { display: none; }
|
|
177
240
|
|
|
178
241
|
/* 内置滚动条(去原生, 沿用皮肤变量) */
|
|
@@ -25,6 +25,34 @@
|
|
|
25
25
|
<div class="ds-sidebar-foot">
|
|
26
26
|
<button class="ds-nav-item" data-view="view-files"><span class="ds-nav-ico">⇅</span><span data-i18n="ds.files">文件传输</span></button>
|
|
27
27
|
<button class="ds-nav-item" data-view="view-settings"><span class="ds-nav-ico">⚙</span><span data-i18n="ds.settings">设置</span></button>
|
|
28
|
+
<div class="ds-feedback">
|
|
29
|
+
<button id="btn-feedback" class="ds-feedback-btn" aria-haspopup="menu" aria-expanded="false" data-i18n-title="ds.feedback" data-i18n-aria="ds.feedback">
|
|
30
|
+
<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>
|
|
31
|
+
<span data-i18n="ds.feedback">反馈</span>
|
|
32
|
+
</button>
|
|
33
|
+
<div id="feedback-menu" class="ds-feedback-menu hidden" role="menu" data-i18n-aria="ds.feedbackTitle">
|
|
34
|
+
<button class="ds-feedback-item" id="btn-write-feedback" role="menuitem">
|
|
35
|
+
<span class="ds-feedback-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg></span>
|
|
36
|
+
<span class="ds-feedback-body"><span class="ds-feedback-name" data-i18n="ds.feedbackWrite">写反馈</span><span class="ds-feedback-desc" data-i18n="ds.feedbackWriteDesc">App 内直接提交</span></span>
|
|
37
|
+
</button>
|
|
38
|
+
<a class="ds-feedback-item primary" href="https://github.com/Blank-not-black/dsh-Remote/issues" target="_blank" rel="noopener" role="menuitem">
|
|
39
|
+
<span class="ds-feedback-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>
|
|
40
|
+
<span class="ds-feedback-body"><span class="ds-feedback-name">GitHub Issues</span><span class="ds-feedback-desc" data-i18n="ds.feedbackGithubDesc">反馈 bug / 提建议</span></span>
|
|
41
|
+
</a>
|
|
42
|
+
<a class="ds-feedback-item" href="https://gitee.com/Blankneverfails/dsh-Remote/issues" target="_blank" rel="noopener" role="menuitem">
|
|
43
|
+
<span class="ds-feedback-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>
|
|
44
|
+
<span class="ds-feedback-body"><span class="ds-feedback-name">Gitee 反馈</span><span class="ds-feedback-desc" data-i18n="ds.feedbackGiteeDesc">国内镜像,无需代理</span></span>
|
|
45
|
+
</a>
|
|
46
|
+
<a class="ds-feedback-item" href="https://space.bilibili.com/419009275/dynamic" target="_blank" rel="noopener" role="menuitem">
|
|
47
|
+
<span class="ds-feedback-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>
|
|
48
|
+
<span class="ds-feedback-body"><span class="ds-feedback-name">B站交流</span><span class="ds-feedback-desc" data-i18n="ds.feedbackBiliDesc">UP 动态页交流</span></span>
|
|
49
|
+
</a>
|
|
50
|
+
<button class="ds-feedback-item" id="btn-copy-link" role="menuitem">
|
|
51
|
+
<span class="ds-feedback-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="8" y="3" width="12" height="12" rx="2"/><path d="M16 8h3a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1v-3"/></svg></span>
|
|
52
|
+
<span class="ds-feedback-body"><span class="ds-feedback-name" data-i18n="ds.feedbackCopyLink">复制项目链接</span><span class="ds-feedback-desc" data-i18n="ds.feedbackCopyDesc">手动分享给朋友</span></span>
|
|
53
|
+
</button>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
28
56
|
</div>
|
|
29
57
|
</aside>
|
|
30
58
|
|
|
@@ -99,6 +127,14 @@
|
|
|
99
127
|
<div><div class="ds-setting-name" data-i18n="ds.langTitle">语言 / Language</div></div>
|
|
100
128
|
<button id="btn-lang" class="ds-btn">EN</button>
|
|
101
129
|
</div>
|
|
130
|
+
<div class="ds-setting-row">
|
|
131
|
+
<div><div class="ds-setting-name" data-i18n="ds.feedbackTitle">反馈渠道</div><div class="ds-setting-desc" data-i18n="ds.feedbackDesc">GitHub / Gitee / B站:反馈 bug、提建议、唠嗑</div></div>
|
|
132
|
+
</div>
|
|
133
|
+
<div class="ds-feedback-links">
|
|
134
|
+
<a class="ds-btn" href="https://github.com/Blank-not-black/dsh-Remote" target="_blank" rel="noopener">GitHub</a>
|
|
135
|
+
<a class="ds-btn" href="https://gitee.com/Blankneverfails/dsh-Remote" target="_blank" rel="noopener">Gitee</a>
|
|
136
|
+
<a class="ds-btn" href="https://space.bilibili.com/419009275/dynamic" target="_blank" rel="noopener">B站</a>
|
|
137
|
+
</div>
|
|
102
138
|
</div>
|
|
103
139
|
</section>
|
|
104
140
|
|
|
@@ -135,7 +171,28 @@
|
|
|
135
171
|
</div>
|
|
136
172
|
</div>
|
|
137
173
|
|
|
174
|
+
<!-- 写反馈模态 -->
|
|
175
|
+
<div id="modal-feedback" class="ds-modal hidden">
|
|
176
|
+
<div class="ds-modal-card">
|
|
177
|
+
<div class="ds-modal-title" data-i18n="ds.feedbackModalTitle">写反馈</div>
|
|
178
|
+
<div class="ds-modal-body">
|
|
179
|
+
<div class="ds-fb-chips" id="fb-chips">
|
|
180
|
+
<button class="ds-fb-chip current" data-fb-type="bug" data-i18n="ds.feedbackTypeBug">Bug</button>
|
|
181
|
+
<button class="ds-fb-chip" data-fb-type="suggestion" data-i18n="ds.feedbackTypeSuggestion">建议</button>
|
|
182
|
+
<button class="ds-fb-chip" data-fb-type="other" data-i18n="ds.feedbackTypeOther">其他</button>
|
|
183
|
+
</div>
|
|
184
|
+
<textarea id="fb-msg" class="ds-fb-textarea" rows="5" maxlength="2000" data-i18n-placeholder="ds.feedbackMessagePlaceholder" placeholder="请描述遇到的问题或建议(必填,≤2000 字)"></textarea>
|
|
185
|
+
<input id="fb-contact" class="ds-fb-input" maxlength="200" data-i18n-placeholder="ds.feedbackContactPlaceholder" placeholder="联系方式(可选):邮箱 / 微信 / B站 ID">
|
|
186
|
+
</div>
|
|
187
|
+
<div class="ds-modal-actions">
|
|
188
|
+
<button id="fb-cancel" class="ds-btn" data-i18n="ds.feedbackCancel">取消</button>
|
|
189
|
+
<button id="fb-submit" class="ds-btn primary" data-i18n="ds.feedbackSubmit">提交</button>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
|
|
138
194
|
<div id="toast" class="ds-toast hidden"></div>
|
|
195
|
+
<div id="ds-tip" class="ds-tip hidden" role="tooltip" aria-hidden="true"></div>
|
|
139
196
|
|
|
140
197
|
<script>
|
|
141
198
|
window.DESKTOP_STR = {
|
|
@@ -147,8 +204,21 @@
|
|
|
147
204
|
'ds.serversTitle': '服务器(分组)', 'ds.speedTest': '测速', 'ds.currentGroup': '当前组',
|
|
148
205
|
'ds.addGroup': '+ 组', 'ds.addServer': '添加', 'ds.tokenTitle': '访问令牌', 'ds.copyToken': '复制',
|
|
149
206
|
'ds.themeTitle': '皮肤', 'ds.themeDesc': '默认深空 / 落日 / 易北爱乐厅 / 草原孤塔', 'ds.langTitle': '语言 / Language',
|
|
207
|
+
'ds.feedbackTitle': '反馈渠道', 'ds.feedbackDesc': 'GitHub / Gitee / B站:反馈 bug、提建议、唠嗑',
|
|
208
|
+
'ds.feedback': '反馈', 'ds.feedbackGithubDesc': '反馈 bug / 提建议', 'ds.feedbackGiteeDesc': '国内镜像,无需代理',
|
|
209
|
+
'ds.feedbackBiliDesc': 'UP 动态页交流', 'ds.feedbackCopyLink': '复制项目链接', 'ds.feedbackCopyDesc': '手动分享给朋友',
|
|
210
|
+
'ds.feedbackCopied': '项目链接已复制', 'ds.feedbackCopyFailed': '复制失败,请手动复制',
|
|
211
|
+
'ds.feedbackWrite': '写反馈', 'ds.feedbackWriteDesc': 'App 内直接提交', 'ds.feedbackModalTitle': '写反馈',
|
|
212
|
+
'ds.feedbackTypeBug': 'Bug', 'ds.feedbackTypeSuggestion': '建议', 'ds.feedbackTypeOther': '其他',
|
|
213
|
+
'ds.feedbackMessagePlaceholder': '请描述遇到的问题或建议(必填,≤2000 字)',
|
|
214
|
+
'ds.feedbackContactPlaceholder': '联系方式(可选):邮箱 / 微信 / B站 ID',
|
|
215
|
+
'ds.feedbackCancel': '取消', 'ds.feedbackSubmit': '提交',
|
|
216
|
+
'ds.feedbackEmpty': '请填写描述内容', 'ds.feedbackTooLong': '描述不能超过 2000 字',
|
|
217
|
+
'ds.feedbackSubmitted': '已提交,感谢反馈',
|
|
218
|
+
'ds.feedbackRateLimited': '提交太频繁,请 5 分钟后再试', 'ds.feedbackSubmitFailed': '提交失败:{msg}', 'ds.feedbackNetworkError': '网络错误',
|
|
150
219
|
'ds.questionTitle': '回答问题', 'ds.ignore': '忽略', 'ds.submit': '提交',
|
|
151
220
|
'ds.connOn': '已连接', 'ds.connOff': '未连接', 'ds.connIng': '连接中',
|
|
221
|
+
'ds.connPollTitle': '当前网络不支持实时推送,已降级为轮询(延迟数秒)',
|
|
152
222
|
'ds.currentServer': '{group} · {url}', 'ds.origin': '当前页面',
|
|
153
223
|
'ds.toastSent': '已发送', 'ds.toastCopied': '令牌已复制', 'ds.toastAuth': '令牌无效',
|
|
154
224
|
'ds.toastConnFailed': '连接失败', 'ds.toastOpFailed': '操作失败',
|
|
@@ -197,8 +267,21 @@
|
|
|
197
267
|
'ds.serversTitle': 'Servers (groups)', 'ds.speedTest': 'Test', 'ds.currentGroup': 'Current group',
|
|
198
268
|
'ds.addGroup': '+ Group', 'ds.addServer': 'Add', 'ds.tokenTitle': 'Access token', 'ds.copyToken': 'Copy',
|
|
199
269
|
'ds.themeTitle': 'Theme', 'ds.themeDesc': 'Deep Space / Sunset / Elbphilharmonie / Prairie Tower', 'ds.langTitle': 'Language',
|
|
270
|
+
'ds.feedbackTitle': 'Feedback', 'ds.feedbackDesc': 'GitHub / Gitee / Bilibili: report bugs, suggest features, or just chat',
|
|
271
|
+
'ds.feedback': 'Feedback', 'ds.feedbackGithubDesc': 'Report bugs · suggest features', 'ds.feedbackGiteeDesc': 'Mirror in China, no proxy needed',
|
|
272
|
+
'ds.feedbackBiliDesc': 'Chat on the UP\'s Bilibili page', 'ds.feedbackCopyLink': 'Copy project link', 'ds.feedbackCopyDesc': 'Share it manually',
|
|
273
|
+
'ds.feedbackCopied': 'Project link copied', 'ds.feedbackCopyFailed': 'Copy failed, copy manually',
|
|
274
|
+
'ds.feedbackWrite': 'Write feedback', 'ds.feedbackWriteDesc': 'Submit from the app', 'ds.feedbackModalTitle': 'Write feedback',
|
|
275
|
+
'ds.feedbackTypeBug': 'Bug', 'ds.feedbackTypeSuggestion': 'Suggestion', 'ds.feedbackTypeOther': 'Other',
|
|
276
|
+
'ds.feedbackMessagePlaceholder': 'Describe the bug or suggestion (required, ≤2000 chars)',
|
|
277
|
+
'ds.feedbackContactPlaceholder': 'Contact (optional): email / WeChat / Bilibili ID',
|
|
278
|
+
'ds.feedbackCancel': 'Cancel', 'ds.feedbackSubmit': 'Submit',
|
|
279
|
+
'ds.feedbackEmpty': 'Please fill in the description', 'ds.feedbackTooLong': 'Description must be ≤2000 characters',
|
|
280
|
+
'ds.feedbackSubmitted': 'Submitted — thanks!',
|
|
281
|
+
'ds.feedbackRateLimited': 'Too frequent, try again in 5 minutes', 'ds.feedbackSubmitFailed': 'Submit failed: {msg}', 'ds.feedbackNetworkError': 'Network error',
|
|
200
282
|
'ds.questionTitle': 'Answer question', 'ds.ignore': 'Ignore', 'ds.submit': 'Submit',
|
|
201
283
|
'ds.connOn': 'Connected', 'ds.connOff': 'Offline', 'ds.connIng': 'Connecting',
|
|
284
|
+
'ds.connPollTitle': 'Realtime push is unavailable on this network; degraded to polling (a few seconds delay)',
|
|
202
285
|
'ds.currentServer': '{group} · {url}', 'ds.origin': 'this page',
|
|
203
286
|
'ds.toastSent': 'Sent', 'ds.toastCopied': 'Token copied', 'ds.toastAuth': 'Invalid token',
|
|
204
287
|
'ds.toastConnFailed': 'Connection failed', 'ds.toastOpFailed': 'Operation failed',
|