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.
@@ -57,10 +57,14 @@ const state = {
57
57
  questionModal: null,
58
58
  streamsOk: { mux: false, host: false },
59
59
  errCount: 0,
60
+ streamMode: 'ws', // 'ws' | 'poll'
61
+ pollSeq: { mux: 0, host: 0 },
60
62
  fs: { path: null, initial: null, loaded: false },
61
63
  view: 'sessions'
62
64
  }
63
65
  const streams = {}
66
+ let pollTimer = null
67
+ let wsRetryTimer = null
64
68
 
65
69
  function esc(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])) }
66
70
  function short(id) { return '…' + String(id).slice(-8) }
@@ -92,6 +96,86 @@ function toast(text, kind = '') {
92
96
  toast._t = setTimeout(() => el.classList.add('hidden'), 2600)
93
97
  }
94
98
 
99
+ /* ---------------- 反馈 ---------------- */
100
+ const FEEDBACK_LINKS = {
101
+ githubIssues: 'https://github.com/Blank-not-black/dsh-Remote/issues',
102
+ giteeIssues: 'https://gitee.com/Blankneverfails/dsh-Remote/issues',
103
+ bili: 'https://space.bilibili.com/419009275/dynamic',
104
+ repo: 'https://github.com/Blank-not-black/dsh-Remote'
105
+ }
106
+ async function copyText(text) {
107
+ try { await navigator.clipboard.writeText(text); return true } catch {}
108
+ try {
109
+ const ta = document.createElement('textarea')
110
+ ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'
111
+ document.body.appendChild(ta); ta.focus(); ta.select()
112
+ const ok = document.execCommand('copy')
113
+ ta.remove(); return ok
114
+ } catch { return false }
115
+ }
116
+ function openFeedbackMenu() {
117
+ $('feedback-menu').classList.remove('hidden')
118
+ $('btn-feedback').setAttribute('aria-expanded', 'true')
119
+ const first = $('feedback-menu').querySelector('[role="menuitem"]')
120
+ if (first) first.focus()
121
+ }
122
+ function closeFeedbackMenu() {
123
+ $('feedback-menu').classList.add('hidden')
124
+ $('btn-feedback').setAttribute('aria-expanded', 'false')
125
+ }
126
+ function toggleFeedbackMenu() {
127
+ $('feedback-menu').classList.contains('hidden') ? openFeedbackMenu() : closeFeedbackMenu()
128
+ }
129
+ function openFeedbackModal() {
130
+ document.querySelectorAll('#fb-chips .ds-fb-chip').forEach(b => b.classList.toggle('current', b.dataset.fbType === 'bug'))
131
+ $('fb-msg').value = ''
132
+ $('fb-contact').value = ''
133
+ $('modal-feedback').classList.remove('hidden')
134
+ setTimeout(() => $('fb-msg').focus(), 50)
135
+ }
136
+ function closeFeedbackModal() { $('modal-feedback').classList.add('hidden') }
137
+ async function submitFeedback() {
138
+ const type = document.querySelector('#fb-chips .ds-fb-chip.current')?.dataset.fbType || 'bug'
139
+ const message = $('fb-msg').value.trim()
140
+ const contact = $('fb-contact').value.trim()
141
+ if (!message) { toast(t('ds.feedbackEmpty'), 'err'); return }
142
+ if (message.length > 2000) { toast(t('ds.feedbackTooLong'), 'err'); return }
143
+ const btn = $('fb-submit')
144
+ btn.disabled = true
145
+ try {
146
+ const res = await fetch(apiUrl('/feedback'), {
147
+ method: 'POST',
148
+ headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token },
149
+ body: JSON.stringify({ type, message, contact, appVersion: '' })
150
+ })
151
+ let json = {}
152
+ try { json = await res.json() } catch {}
153
+ if (res.ok && json.ok) { toast(t('ds.feedbackSubmitted'), 'ok'); closeFeedbackModal() }
154
+ else if (res.status === 429) { toast(t('ds.feedbackRateLimited'), 'err') }
155
+ else { toast(t('ds.feedbackSubmitFailed', { msg: json.error || res.status }), 'err') }
156
+ } catch {
157
+ toast(t('ds.feedbackSubmitFailed', { msg: t('ds.feedbackNetworkError') }), 'err')
158
+ } finally {
159
+ btn.disabled = false
160
+ }
161
+ }
162
+ function showTip(text, anchorRect) {
163
+ const tip = $('ds-tip')
164
+ if (!tip) return
165
+ tip.textContent = text
166
+ tip.classList.remove('hidden')
167
+ const margin = 8
168
+ const tw = tip.offsetWidth
169
+ const th = tip.offsetHeight
170
+ let left = anchorRect.left + anchorRect.width / 2 - tw / 2
171
+ left = Math.max(margin, Math.min(left, window.innerWidth - tw - margin))
172
+ let top = anchorRect.top - th - 10
173
+ if (top < margin) top = anchorRect.bottom + 10
174
+ tip.style.left = left + 'px'
175
+ tip.style.top = top + 'px'
176
+ }
177
+ function hideTip() { const tip = $('ds-tip'); if (tip) tip.classList.add('hidden') }
178
+
95
179
  /* ---------------- API ---------------- */
96
180
  function apiUrl(path) { return (state.server || '') + path }
97
181
  async function rpc(method, payload = {}) {
@@ -403,9 +487,11 @@ function deleteGroup(name) {
403
487
  if (state.token) selectFastestServer({ silent: true })
404
488
  }
405
489
 
406
- /* ---------------- 事件流 ---------------- */
490
+ /* ---------------- 事件流 (WebSocket + 轮询降级) ---------------- */
407
491
  function openStreams() {
408
492
  if (!state.token) return
493
+ if (state.streamMode === 'poll') stopPolling()
494
+ state.streamMode = 'ws'
409
495
  openStream('mux', onMuxFrame, true)
410
496
  openStream('host', onHostFrame, false)
411
497
  }
@@ -420,12 +506,14 @@ function openStream(kind, handler, refreshOnOpen) {
420
506
  ws.onopen = () => {
421
507
  state.streamsOk[kind] = true
422
508
  state.errCount = 0
509
+ if (state.streamMode === 'poll') { stopPolling(); state.streamMode = 'ws' }
423
510
  updateConn()
424
511
  if (kind === 'mux') { state.approvals = []; state.questions = []; renderNotifStack() }
425
512
  if (refreshOnOpen) refreshSessions()
426
513
  }
427
514
  ws.onmessage = (msg) => {
428
515
  state.streamsOk[kind] = true
516
+ state.errCount = 0
429
517
  updateConn()
430
518
  try { handler(JSON.parse(msg.data)) } catch {}
431
519
  }
@@ -433,11 +521,84 @@ function openStream(kind, handler, refreshOnOpen) {
433
521
  state.streamsOk[kind] = false
434
522
  state.errCount++
435
523
  updateConn()
524
+ if (state.streamMode === 'poll') return
525
+ if (state.errCount >= 3) { enterPollMode(); return }
436
526
  if (state.servers.length && state.errCount % 5 === 0) setTimeout(() => selectFastestServer({ silent: true }), 300)
437
527
  if (streams[kind] === ws) setTimeout(() => openStream(kind, handler, refreshOnOpen), 1200)
438
528
  }
439
529
  ws.onerror = () => { try { ws.close() } catch {} }
440
530
  }
531
+
532
+ /* ---------------- 轮询降级模式 ---------------- */
533
+ function enterPollMode() {
534
+ if (state.streamMode === 'poll') return
535
+ state.streamMode = 'poll'
536
+ state.pollSeq = { mux: 0, host: 0 }
537
+ state.streamsOk = { mux: false, host: false }
538
+ try { streams.mux?.close() } catch {}
539
+ try { streams.host?.close() } catch {}
540
+ streams.mux = null
541
+ streams.host = null
542
+ refreshSessions()
543
+ startPolling()
544
+ updateConn()
545
+ }
546
+
547
+ function stopPolling() {
548
+ clearInterval(pollTimer)
549
+ pollTimer = null
550
+ clearTimeout(wsRetryTimer)
551
+ wsRetryTimer = null
552
+ }
553
+
554
+ function startPolling() {
555
+ stopPolling()
556
+ pollTimer = setInterval(pollOnce, 4000)
557
+ wsRetryTimer = setInterval(tryRestoreWs, 30000)
558
+ pollOnce()
559
+ }
560
+
561
+ let pollInFlight = false
562
+ async function pollOnce() {
563
+ if (state.streamMode !== 'poll' || pollInFlight) return
564
+ pollInFlight = true
565
+ try {
566
+ await Promise.all([pollKind('mux'), pollKind('host')])
567
+ } finally {
568
+ pollInFlight = false
569
+ }
570
+ }
571
+
572
+ async function pollKind(kind) {
573
+ if (state.streamMode !== 'poll') return
574
+ const since = state.pollSeq[kind] || 0
575
+ let res
576
+ try {
577
+ const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(5000) : undefined
578
+ const headers = { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web' }
579
+ 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 })
580
+ } catch { return }
581
+ if (res.status === 401) { toast(t('ds.toastAuth'), 'err'); return }
582
+ if (!res.ok) return
583
+ let data
584
+ try { data = await res.json() } catch { return }
585
+ if (!data || !Array.isArray(data.events)) return
586
+ if (typeof data.latestSeq === 'number' && data.latestSeq < since) state.pollSeq[kind] = 0
587
+ for (const item of data.events) {
588
+ if (item.seq > (state.pollSeq[kind] || 0)) {
589
+ state.pollSeq[kind] = item.seq
590
+ if (kind === 'mux') onMuxFrame(item.event)
591
+ else onHostFrame(item.event)
592
+ }
593
+ }
594
+ }
595
+
596
+ function tryRestoreWs() {
597
+ if (state.streamMode !== 'poll' || !state.token) return
598
+ // 轮询继续跑,等 WS 真正 onopen 后再切回,避免重连窗口丢事件
599
+ openStream('mux', onMuxFrame, true)
600
+ openStream('host', onHostFrame, false)
601
+ }
441
602
  function onMuxFrame(full) {
442
603
  const f = full.payload
443
604
  if (!f) return
@@ -814,7 +975,8 @@ function renderStats(days) {
814
975
  const peakH = cost > 0 ? Math.round((d.peak.cost || 0) / cost * 100) : 0
815
976
  const offH = cost > 0 ? Math.max(0, 100 - peakH) : 0
816
977
  const totalH = cost > 0 ? Math.max(3, Math.round(cost / maxCost * 100)) : 0
817
- return `<div class="ds-stats-bar" title="${d.date} · ${t('ds.statsPeak')} ${fmtCost(d.peak.cost)} · ${t('ds.statsOff')} ${fmtCost(d.off.cost)}">
978
+ const tip = `${d.date}\n${t('ds.statsPeak')} ${fmtCost(d.peak.cost)}\n${t('ds.statsOff')} ${fmtCost(d.off.cost)}`
979
+ return `<div class="ds-stats-bar" data-tip="${esc(tip)}">
818
980
  <div class="bars" style="height:${totalH}%"><div class="seg peak" style="height:${peakH}%"></div><div class="seg off" style="height:${offH}%"></div></div>
819
981
  <div class="val">${cost > 0 ? fmtCost(cost) : ''}</div>
820
982
  <div class="lbl">${d.date.slice(5)}</div>
@@ -834,14 +996,21 @@ function showView(id) {
834
996
  }
835
997
  function updateConn() {
836
998
  const el = $('conn-badge')
999
+ const cur = state.servers.find(s => s.url === state.server)
1000
+ const group = cur ? cur.group : state.activeGroup
1001
+ const label = cur ? (cur.note || cur.url) : (state.server || t('ds.origin'))
1002
+ if (state.streamMode === 'poll') {
1003
+ el.textContent = '●'
1004
+ el.className = 'ds-conn off'
1005
+ el.title = t('ds.connPollTitle')
1006
+ $('server-badge').textContent = t('ds.currentServer', { group, url: label })
1007
+ return
1008
+ }
837
1009
  const any = Object.values(state.streamsOk).some(Boolean)
838
1010
  const all = state.streamsOk.mux && state.streamsOk.host
839
1011
  el.textContent = '●'
840
1012
  el.className = 'ds-conn ' + (all ? 'on' : any ? 'ing' : '')
841
1013
  el.title = all ? t('ds.connOn') : any ? t('ds.connIng') : t('ds.connOff')
842
- const cur = state.servers.find(s => s.url === state.server)
843
- const group = cur ? cur.group : state.activeGroup
844
- const label = cur ? (cur.note || cur.url) : (state.server || t('ds.origin'))
845
1014
  $('server-badge').textContent = t('ds.currentServer', { group, url: label })
846
1015
  }
847
1016
 
@@ -866,6 +1035,35 @@ function bindUi() {
866
1035
  })
867
1036
  $('btn-stats-top').addEventListener('click', toggleStatsDrawer)
868
1037
  $('stats-drawer-close').addEventListener('click', toggleStatsDrawer)
1038
+ // 反馈
1039
+ $('btn-feedback').addEventListener('click', (e) => { e.stopPropagation(); toggleFeedbackMenu() })
1040
+ $('feedback-menu').addEventListener('click', (e) => {
1041
+ if (e.target.closest('a[role="menuitem"]')) closeFeedbackMenu()
1042
+ })
1043
+ $('btn-copy-link').addEventListener('click', async () => {
1044
+ const ok = await copyText(FEEDBACK_LINKS.repo)
1045
+ toast(t(ok ? 'ds.feedbackCopied' : 'ds.feedbackCopyFailed'), ok ? 'ok' : 'err')
1046
+ closeFeedbackMenu()
1047
+ })
1048
+ $('btn-write-feedback').addEventListener('click', () => { closeFeedbackMenu(); openFeedbackModal() })
1049
+ $('fb-cancel').addEventListener('click', closeFeedbackModal)
1050
+ $('fb-submit').addEventListener('click', submitFeedback)
1051
+ document.querySelectorAll('#fb-chips .ds-fb-chip').forEach(btn =>
1052
+ btn.addEventListener('click', () => {
1053
+ document.querySelectorAll('#fb-chips .ds-fb-chip').forEach(b => b.classList.toggle('current', b === btn))
1054
+ }))
1055
+ document.addEventListener('click', (e) => {
1056
+ if (!e.target.closest('.ds-feedback')) closeFeedbackMenu()
1057
+ })
1058
+ document.addEventListener('keydown', (e) => {
1059
+ if (e.key === 'Escape' && !$('feedback-menu').classList.contains('hidden')) { closeFeedbackMenu(); $('btn-feedback').focus() }
1060
+ })
1061
+ // 统计柱状图悬停提示: 自定义 tooltip, 限制在视口内, 避免原生 title 溢出抽屉
1062
+ $('stats-chart').addEventListener('mouseover', (e) => {
1063
+ const bar = e.target.closest('.ds-stats-bar')
1064
+ if (bar && bar.dataset.tip) showTip(bar.dataset.tip, bar.getBoundingClientRect())
1065
+ })
1066
+ $('stats-chart').addEventListener('mouseleave', hideTip)
869
1067
 
870
1068
  $('btn-server-speed').addEventListener('click', () => selectFastestServer({ silent: false }))
871
1069
  $('btn-server-add').addEventListener('click', addServer)
package/public/index.html CHANGED
@@ -20,9 +20,9 @@
20
20
  <span class="brand-name">DSH Remote</span>
21
21
  </div>
22
22
  <div class="topbar-right">
23
- <button id="btn-admin" class="icon-btn" data-i18n-title="a11y.hostAdmin" data-i18n-aria="a11y.hostAdmin">🖥</button>
24
- <button id="btn-refresh" class="icon-btn" data-i18n-title="a11y.refresh" data-i18n-aria="a11y.refresh">⟳</button>
25
- <span id="conn-badge" class="conn-badge off" data-i18n="conn.off">未连接</span>
23
+ <button id="btn-feedback" class="topbar-btn icon-btn" data-i18n-title="feedback.title" data-i18n-aria="feedback.title" aria-haspopup="menu" aria-expanded="false">💬</button>
24
+ <button id="btn-refresh" class="topbar-btn icon-btn" data-i18n-title="a11y.refresh" data-i18n-aria="a11y.refresh">⟳</button>
25
+ <span id="conn-badge" class="topbar-btn conn-badge off" data-i18n="conn.off">未连接</span>
26
26
  </div>
27
27
  </header>
28
28
 
@@ -199,6 +199,25 @@
199
199
  <button id="btn-check-update" class="mini-btn" data-i18n="settings.check">检查</button>
200
200
  </div>
201
201
  </div>
202
+ <div class="settings-group feedback-card">
203
+ <div class="setting-row">
204
+ <div><div class="setting-name" data-i18n="settings.feedbackTitle">反馈渠道</div><div class="setting-desc" data-i18n="settings.feedbackDesc">GitHub / Gitee / B站:反馈 bug、提建议、唠嗑</div></div>
205
+ </div>
206
+ <div class="feedback-links">
207
+ <a class="feedback-btn primary" href="https://github.com/Blank-not-black/dsh-Remote" target="_blank" rel="noopener">
208
+ <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>
209
+ <span>GitHub</span>
210
+ </a>
211
+ <a class="feedback-btn" href="https://gitee.com/Blankneverfails/dsh-Remote" target="_blank" rel="noopener">
212
+ <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>
213
+ <span>Gitee</span>
214
+ </a>
215
+ <a class="feedback-btn" href="https://space.bilibili.com/419009275/dynamic" target="_blank" rel="noopener">
216
+ <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>
217
+ <span>B站</span>
218
+ </a>
219
+ </div>
220
+ </div>
202
221
  <div class="settings-group">
203
222
  <div class="setting-row">
204
223
  <div><div class="setting-name" data-i18n="settings.resetTitle">清空本地数据</div><div class="setting-desc" data-i18n="settings.resetDesc">令牌与缓存</div></div>
@@ -209,6 +228,33 @@
209
228
  </section>
210
229
  </main>
211
230
 
231
+ <!-- 反馈底部菜单(非模态) -->
232
+ <div id="feedback-backdrop" class="sheet-backdrop hidden"></div>
233
+ <div id="feedback-sheet" class="sheet hidden" role="menu" data-i18n-aria="feedback.title">
234
+ <div class="sheet-handle" aria-hidden="true"></div>
235
+ <div class="sheet-title" data-i18n="feedback.title">反馈</div>
236
+ <button class="sheet-item" id="btn-write-feedback" role="menuitem">
237
+ <span class="sheet-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>
238
+ <span class="sheet-item-body"><span class="sheet-item-name" data-i18n="feedback.write">写反馈</span><span class="sheet-item-desc" data-i18n="feedback.writeDesc">App 内直接提交</span></span>
239
+ </button>
240
+ <a class="sheet-item primary" href="https://github.com/Blank-not-black/dsh-Remote/issues" target="_blank" rel="noopener" role="menuitem">
241
+ <span class="sheet-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>
242
+ <span class="sheet-item-body"><span class="sheet-item-name">GitHub Issues</span><span class="sheet-item-desc" data-i18n="feedback.githubDesc">反馈 bug / 提建议</span></span>
243
+ </a>
244
+ <a class="sheet-item" href="https://gitee.com/Blankneverfails/dsh-Remote/issues" target="_blank" rel="noopener" role="menuitem">
245
+ <span class="sheet-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>
246
+ <span class="sheet-item-body"><span class="sheet-item-name">Gitee 反馈</span><span class="sheet-item-desc" data-i18n="feedback.giteeDesc">国内镜像,无需代理</span></span>
247
+ </a>
248
+ <a class="sheet-item" href="https://space.bilibili.com/419009275/dynamic" target="_blank" rel="noopener" role="menuitem">
249
+ <span class="sheet-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>
250
+ <span class="sheet-item-body"><span class="sheet-item-name">B站交流</span><span class="sheet-item-desc" data-i18n="feedback.biliDesc">UP 动态页交流</span></span>
251
+ </a>
252
+ <button class="sheet-item" id="btn-copy-link" role="menuitem">
253
+ <span class="sheet-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>
254
+ <span class="sheet-item-body"><span class="sheet-item-name" data-i18n="feedback.copyLink">复制项目链接</span><span class="sheet-item-desc" data-i18n="feedback.copyDesc">手动分享给朋友</span></span>
255
+ </button>
256
+ </div>
257
+
212
258
  <nav class="bottom-nav">
213
259
  <button data-view="view-home" class="nav-btn active"><span class="nav-ico">▤</span><span data-i18n="nav.sessions">会话</span></button>
214
260
  <button data-view="view-files" class="nav-btn"><span class="nav-ico">⇅</span><span data-i18n="nav.files">文件</span></button>
@@ -241,6 +287,26 @@
241
287
  </div>
242
288
  </div>
243
289
 
290
+ <!-- 写反馈模态 -->
291
+ <div id="modal-feedback" class="modal hidden">
292
+ <div class="modal-card">
293
+ <div class="modal-title" data-i18n="feedback.modalTitle">写反馈</div>
294
+ <div class="modal-body">
295
+ <div class="fb-chips" id="fb-chips">
296
+ <button class="fb-chip current" data-fb-type="bug" data-i18n="feedback.typeBug">Bug</button>
297
+ <button class="fb-chip" data-fb-type="suggestion" data-i18n="feedback.typeSuggestion">建议</button>
298
+ <button class="fb-chip" data-fb-type="other" data-i18n="feedback.typeOther">其他</button>
299
+ </div>
300
+ <textarea id="fb-msg" class="fb-textarea" rows="5" maxlength="2000" data-i18n-placeholder="feedback.messagePlaceholder" placeholder="请描述遇到的问题或建议(必填,≤2000 字)"></textarea>
301
+ <input id="fb-contact" class="fb-input" maxlength="200" data-i18n-placeholder="feedback.contactPlaceholder" placeholder="联系方式(可选):邮箱 / 微信 / B站 ID">
302
+ </div>
303
+ <div class="modal-actions">
304
+ <button id="fb-cancel" class="btn subtle" data-i18n="feedback.cancel">取消</button>
305
+ <button id="fb-submit" class="btn primary" data-i18n="feedback.submit">提交</button>
306
+ </div>
307
+ </div>
308
+ </div>
309
+
244
310
  <!-- goal 模态 -->
245
311
  <div id="modal-goal" class="modal hidden">
246
312
  <div class="modal-card">
@@ -282,6 +348,7 @@
282
348
  zh: {
283
349
  'a11y.hostAdmin': '主机管理', 'a11y.refresh': '刷新', 'a11y.more': '更多操作', 'a11y.moreTitle': '指令/权限/模型',
284
350
  'conn.on': '已连接', 'conn.off': '未连接', 'conn.reconnecting': '连接中断,正在重连…',
351
+ 'conn.poll': '轮询', 'conn.pollTitle': '当前网络不支持实时推送,已降级为轮询(延迟数秒)',
285
352
  'conn.titleGroup': '{group} · {url}({ms})',
286
353
  'common.refreshing': '刷新中…',
287
354
  'nav.sessions': '会话', 'nav.files': '文件', 'nav.pending': '待办', 'nav.stats': '统计', 'nav.settings': '设置',
@@ -384,6 +451,7 @@
384
451
  'update.latestV': '已是最新 v{version}', 'update.latestRemote': '最新版本 v{version}', 'update.latestToast': '已是最新版本',
385
452
  'update.checkFailedDesc': '检查失败:{msg}', 'update.checkFailed': '检查更新失败:{msg}',
386
453
  'update.downloadStarted': '开始下载,完成后会弹出安装页', 'update.downloadFailed': '无法启动下载:{msg}',
454
+ 'update.corrupted': '下载文件损坏,请重试', 'update.serverFileMissing': '服务器上还没有对应版本的文件,请稍后再试',
387
455
  'update.installUnsupported': '当前版本不支持 App 内安装,已转浏览器下载',
388
456
  'update.expand': '展开', 'update.collapse': '收起',
389
457
  'scan.imageLoadFailed': '图片加载失败', 'scan.decodeUnsupported': '当前设备不支持图片解码',
@@ -411,6 +479,18 @@
411
479
  'settings.hostTitle': 'DSH 状态', 'settings.hostProbing': '探测中…', 'settings.probe': '探测', 'settings.probeFailed': '探测失败',
412
480
  'settings.hostDesc': 'DSH {version} · {cwd} · 附加会话 {n}',
413
481
  'settings.updateTitle': '检查更新', 'settings.updateLoading': '加载中…', 'settings.downloadUpdate': '下载并安装更新', 'settings.check': '检查',
482
+ 'settings.feedbackTitle': '反馈渠道', 'settings.feedbackDesc': 'GitHub / Gitee / B站:反馈 bug、提建议、唠嗑',
483
+ 'feedback.title': '反馈', 'feedback.githubDesc': '反馈 bug / 提建议', 'feedback.giteeDesc': '国内镜像,无需代理',
484
+ 'feedback.biliDesc': 'UP 动态页交流', 'feedback.copyLink': '复制项目链接', 'feedback.copyDesc': '手动分享给朋友',
485
+ 'feedback.copied': '项目链接已复制', 'feedback.copyFailed': '复制失败,请手动复制',
486
+ 'feedback.write': '写反馈', 'feedback.writeDesc': 'App 内直接提交', 'feedback.modalTitle': '写反馈',
487
+ 'feedback.typeBug': 'Bug', 'feedback.typeSuggestion': '建议', 'feedback.typeOther': '其他',
488
+ 'feedback.messagePlaceholder': '请描述遇到的问题或建议(必填,≤2000 字)',
489
+ 'feedback.contactPlaceholder': '联系方式(可选):邮箱 / 微信 / B站 ID',
490
+ 'feedback.cancel': '取消', 'feedback.submit': '提交',
491
+ 'feedback.empty': '请填写描述内容', 'feedback.tooLong': '描述不能超过 2000 字',
492
+ 'feedback.submitted': '已提交,感谢反馈',
493
+ 'feedback.rateLimited': '提交太频繁,请稍后再试', 'feedback.rateLimitedAt': '提交太频繁,请 {n} 秒后再试', 'feedback.submitFailed': '提交失败:{msg}', 'feedback.networkError': '网络错误',
414
494
  'settings.resetTitle': '清空本地数据', 'settings.resetDesc': '令牌与缓存', 'settings.reset': '重置',
415
495
  'settings.confirmReset': '清除本地令牌、服务器与缓存?', 'settings.notifyDenied': '通知权限未开启',
416
496
  'settings.toolsShown': '已显示工具调用', 'settings.toolsHidden': '已隐藏工具调用',
@@ -424,6 +504,7 @@
424
504
  en: {
425
505
  'a11y.hostAdmin': 'Host admin', 'a11y.refresh': 'Refresh', 'a11y.more': 'More actions', 'a11y.moreTitle': 'Commands / Permissions / Models',
426
506
  'conn.on': 'Connected', 'conn.off': 'Offline', 'conn.reconnecting': 'Connection lost, reconnecting…',
507
+ 'conn.poll': 'Polling', 'conn.pollTitle': 'Realtime push is unavailable on this network; degraded to polling (a few seconds delay)',
427
508
  'conn.titleGroup': '{group} · {url} ({ms})',
428
509
  'common.refreshing': 'Refreshing…',
429
510
  'nav.sessions': 'Sessions', 'nav.files': 'Files', 'nav.pending': 'Inbox', 'nav.stats': 'Stats', 'nav.settings': 'Settings',
@@ -526,6 +607,7 @@
526
607
  'update.latestV': 'Up to date v{version}', 'update.latestRemote': 'Latest version v{version}', 'update.latestToast': 'Already up to date',
527
608
  'update.checkFailedDesc': 'Check failed: {msg}', 'update.checkFailed': 'Update check failed: {msg}',
528
609
  'update.downloadStarted': 'Downloading, the install page will open when ready', 'update.downloadFailed': 'Could not start download: {msg}',
610
+ 'update.corrupted': 'Downloaded file is corrupted, please retry', 'update.serverFileMissing': 'The file for this version is not on the server yet, please try again later',
529
611
  'update.installUnsupported': 'This version cannot install in-app, opening browser download',
530
612
  'update.expand': 'Expand', 'update.collapse': 'Collapse',
531
613
  'scan.imageLoadFailed': 'Image failed to load', 'scan.decodeUnsupported': 'This device cannot decode images',
@@ -553,6 +635,18 @@
553
635
  'settings.hostTitle': 'DSH status', 'settings.hostProbing': 'Probing…', 'settings.probe': 'Probe', 'settings.probeFailed': 'Probe failed',
554
636
  'settings.hostDesc': 'DSH {version} · {cwd} · {n} attached sessions',
555
637
  'settings.updateTitle': 'Check for updates', 'settings.updateLoading': 'Loading…', 'settings.downloadUpdate': 'Download & install update', 'settings.check': 'Check',
638
+ 'settings.feedbackTitle': 'Feedback', 'settings.feedbackDesc': 'GitHub / Gitee / Bilibili: report bugs, suggest features, or just chat',
639
+ 'feedback.title': 'Feedback', 'feedback.githubDesc': 'Report bugs · suggest features', 'feedback.giteeDesc': 'Mirror in China, no proxy needed',
640
+ 'feedback.biliDesc': 'Chat on the UP\'s Bilibili page', 'feedback.copyLink': 'Copy project link', 'feedback.copyDesc': 'Share it manually',
641
+ 'feedback.copied': 'Project link copied', 'feedback.copyFailed': 'Copy failed, copy manually',
642
+ 'feedback.write': 'Write feedback', 'feedback.writeDesc': 'Submit from the app', 'feedback.modalTitle': 'Write feedback',
643
+ 'feedback.typeBug': 'Bug', 'feedback.typeSuggestion': 'Suggestion', 'feedback.typeOther': 'Other',
644
+ 'feedback.messagePlaceholder': 'Describe the bug or suggestion (required, ≤2000 chars)',
645
+ 'feedback.contactPlaceholder': 'Contact (optional): email / WeChat / Bilibili ID',
646
+ 'feedback.cancel': 'Cancel', 'feedback.submit': 'Submit',
647
+ 'feedback.empty': 'Please fill in the description', 'feedback.tooLong': 'Description must be ≤2000 characters',
648
+ 'feedback.submitted': 'Submitted — thanks!',
649
+ 'feedback.rateLimited': 'Too frequent, try again later', 'feedback.rateLimitedAt': 'Too frequent, try again in {n}s', 'feedback.submitFailed': 'Submit failed: {msg}', 'feedback.networkError': 'Network error',
556
650
  'settings.resetTitle': 'Clear local data', 'settings.resetDesc': 'Token and caches', 'settings.reset': 'Reset',
557
651
  'settings.confirmReset': 'Clear local token, servers and caches?', 'settings.notifyDenied': 'Notification permission not granted',
558
652
  'settings.toolsShown': 'Tool calls shown', 'settings.toolsHidden': 'Tool calls hidden',
package/public/styles.css CHANGED
@@ -59,6 +59,14 @@ button {
59
59
  font-size: 17px; display: grid; place-items: center;
60
60
  }
61
61
  .icon-btn:active { background: var(--dsr-panel-2); }
62
+ /* 顶栏按钮统一: 反馈/刷新/连接徽章同高同基线 */
63
+ .topbar-btn {
64
+ height: 44px; box-sizing: border-box;
65
+ display: inline-flex; align-items: center; justify-content: center; gap: 6px;
66
+ line-height: 1; vertical-align: middle; flex-shrink: 0;
67
+ }
68
+ .topbar-right .topbar-btn.icon-btn { width: 44px; padding: 0; font-size: 19px; border-radius: 12px; }
69
+ .topbar-right .topbar-btn.conn-badge { padding: 0 14px; border-radius: 999px; font-size: 12.5px; }
62
70
  .mini-btn {
63
71
  padding: 5px 11px; border-radius: 9px; font-size: 13px;
64
72
  background: var(--dsr-panel); border: 1px solid var(--dsr-line); color: var(--dsr-accent-strong);
@@ -66,6 +74,19 @@ button {
66
74
  flex-shrink: 0;
67
75
  }
68
76
  .mini-btn:active { background: var(--dsr-panel-2); }
77
+ a.mini-btn { text-decoration: none; display: inline-flex; align-items: center; }
78
+ .feedback-links { display: flex; gap: 8px; flex-wrap: wrap; padding: 0 14px 14px; }
79
+ .feedback-card { background: var(--dsr-accent-soft); border-color: var(--dsr-accent-line); }
80
+ .feedback-btn {
81
+ flex: 1 1 0; min-width: 92px; min-height: 44px;
82
+ display: inline-flex; align-items: center; justify-content: center; gap: 7px;
83
+ padding: 9px 12px; border-radius: 12px; font-size: 13.5px; font-weight: 600;
84
+ background: var(--dsr-panel); border: 1px solid var(--dsr-line); color: var(--dsr-accent-strong);
85
+ text-decoration: none; cursor: pointer; white-space: nowrap;
86
+ }
87
+ .feedback-btn svg { width: 17px; height: 17px; fill: currentColor; flex-shrink: 0; }
88
+ .feedback-btn.primary { background: var(--dsr-accent-strong); border-color: var(--dsr-accent-strong); color: var(--dsr-on-accent); }
89
+ .feedback-btn:active { filter: brightness(.94); }
69
90
  .btn {
70
91
  flex: 1; padding: 11px 14px; border-radius: 12px; font-size: 15px; font-weight: 600;
71
92
  border: 1px solid var(--dsr-line);
@@ -521,6 +542,57 @@ body.in-session .main { padding-bottom: 84px; }
521
542
  font-size: 10px; font-weight: 800; padding: 0 6px; line-height: 16px;
522
543
  }
523
544
 
545
+ /* ---------- 反馈底部菜单 ---------- */
546
+ .sheet-backdrop {
547
+ position: fixed; inset: 0; z-index: 45;
548
+ background: var(--dsr-overlay);
549
+ animation: sheet-fade .18s ease;
550
+ }
551
+ .sheet {
552
+ position: fixed; left: 0; right: 0; bottom: 0; z-index: 46;
553
+ max-height: 72vh; overflow-y: auto; overscroll-behavior: contain;
554
+ background: var(--dsr-bg-2); border-top: 1px solid var(--dsr-line); border-radius: 20px 20px 0 0;
555
+ padding: 8px 12px calc(12px + env(safe-area-inset-bottom, 0px));
556
+ box-shadow: 0 -10px 34px var(--dsr-shadow);
557
+ animation: sheet-up .22s cubic-bezier(.2,.8,.3,1);
558
+ }
559
+ .sheet-handle { width: 38px; height: 4px; border-radius: 999px; background: var(--dsr-line); margin: 2px auto 10px; }
560
+ .sheet-title { font-size: 14px; font-weight: 700; padding: 0 6px 8px; }
561
+ .sheet-item {
562
+ width: 100%; min-height: 52px; display: flex; align-items: center; gap: 11px;
563
+ padding: 9px 10px; border-radius: 13px; border: none; background: transparent; color: var(--dsr-text);
564
+ font: inherit; text-align: left; cursor: pointer; text-decoration: none;
565
+ }
566
+ .sheet-item:hover, .sheet-item:focus-visible { background: var(--dsr-bg); outline: none; }
567
+ .sheet-item.primary { background: var(--dsr-accent-soft); border: 1px solid var(--dsr-accent-line); }
568
+ .sheet-item.primary:hover { background: var(--dsr-accent-soft); filter: brightness(1.03); }
569
+ .sheet-ico {
570
+ width: 38px; height: 38px; border-radius: 11px; flex-shrink: 0;
571
+ display: grid; place-items: center; background: var(--dsr-panel); border: 1px solid var(--dsr-line); color: var(--dsr-accent-strong);
572
+ }
573
+ .sheet-item.primary .sheet-ico { background: var(--dsr-accent-strong); border-color: var(--dsr-accent-strong); color: var(--dsr-on-accent); }
574
+ .sheet-ico svg { width: 18px; height: 18px; fill: currentColor; }
575
+ .sheet-item-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
576
+ .sheet-item-name { font-size: 14px; font-weight: 600; }
577
+ .sheet-item-desc { font-size: 12px; color: var(--dsr-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
578
+ @keyframes sheet-fade { from { opacity: 0 } to { opacity: 1 } }
579
+ @keyframes sheet-up { from { transform: translateY(24px); opacity: .6 } to { transform: translateY(0); opacity: 1 } }
580
+
581
+ /* 写反馈弹层 */
582
+ .fb-chips { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
583
+ .fb-chip {
584
+ min-height: 36px; padding: 7px 14px; border-radius: 999px; font-size: 13px;
585
+ background: var(--dsr-panel); border: 1px solid var(--dsr-line); color: var(--dsr-text); cursor: pointer;
586
+ }
587
+ .fb-chip.current { background: var(--dsr-accent-soft); border-color: var(--dsr-accent-line); color: var(--dsr-accent-strong); font-weight: 600; }
588
+ .fb-textarea, .fb-input {
589
+ width: 100%; box-sizing: border-box; margin-bottom: 10px;
590
+ background: var(--dsr-panel); color: var(--dsr-text); border: 1px solid var(--dsr-line); border-radius: 12px;
591
+ padding: 10px 12px; font: inherit; font-size: 14px; outline: none; resize: vertical;
592
+ }
593
+ .fb-textarea:focus, .fb-input:focus { border-color: var(--dsr-accent-line); }
594
+ .fb-input { min-height: 42px; }
595
+
524
596
  /* ---------- 模态 ---------- */
525
597
  .modal {
526
598
  position: fixed; inset: 0; z-index: 40; display: grid; place-items: center;
@@ -1,6 +1,7 @@
1
1
  {
2
- "version": "0.5.8",
2
+ "version": "0.6.0",
3
3
  "apkUrl": "dsh-remote.apk",
4
- "releasedAt": "2026-08-17T14:17:29.757Z",
5
- "notes": "新增:桌面端 WebUI(浏览器打开网关地址自动进入桌面布局,侧栏会话/文件/设置/统计抽屉/审批通知卡片栈);多服务端分组管理(备注/分组/组内自动或手动选择);峰谷计费提醒;Token 统计仅从 2026-08-17 定价生效日起算。"
4
+ "sha256": "ebab5d1ce9c669c50742a121f23d29ca4d8e38d60bee8fe95ba112db6761b788",
5
+ "releasedAt": "2026-08-18T13:08:00.384Z",
6
+ "notes": "新增事件轮询降级(公网隧道网络下自动切换,消息不丢、延迟数秒);更新下载完整性校验(SHA-256);网关稳定性加固(设备自动清理、发布流程修复);文档重构(能力对比表 / FAQ)"
6
7
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.5.8"
2
+ "version": "0.6.0"
3
3
  }