dsh-remote-plugin 0.5.8 → 0.5.9

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 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
 
Binary file
package/gateway.cjs CHANGED
@@ -493,6 +493,124 @@ function serveStats(req, res, url) {
493
493
  res.end(JSON.stringify({ error: 'not found' }))
494
494
  }
495
495
 
496
+ // ---------- 反馈提交 ----------
497
+ const feedbackThrottle = new Map() // ip -> 上次受理时间戳
498
+ const FEEDBACK_WINDOW_MS = 60 * 1000
499
+ // 反馈收集器: 环境变量可覆盖, 默认 Tailscale 内网地址
500
+ const FEEDBACK_URL = process.env.DSH_REMOTE_FEEDBACK_URL || 'http://100.84.128.29/submit'
501
+
502
+ function maskIp(ip) {
503
+ if (!ip) return 'unknown'
504
+ const s = String(ip).replace(/^::ffff:/, '')
505
+ if (s.includes(':')) {
506
+ const groups = s.split(':').filter(Boolean)
507
+ return (groups.slice(0, 2).join(':') || '::') + '::x'
508
+ }
509
+ const parts = s.split('.')
510
+ if (parts.length === 4) return parts.slice(0, 3).join('.') + '.x'
511
+ return s
512
+ }
513
+
514
+ function serveFeedback(req, res, url) {
515
+ cors(res)
516
+ if (req.method === 'OPTIONS') {
517
+ res.writeHead(204)
518
+ res.end()
519
+ return
520
+ }
521
+ if (req.method !== 'POST') {
522
+ res.writeHead(405, { 'content-type': 'application/json; charset=utf-8' })
523
+ res.end(JSON.stringify({ error: 'method not allowed' }))
524
+ return
525
+ }
526
+ if (!authorized(req, url)) {
527
+ authFailures++
528
+ touchDevice(req, { failedAuth: true })
529
+ res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
530
+ res.end(JSON.stringify({ error: 'unauthorized' }))
531
+ return
532
+ }
533
+ touchDevice(req)
534
+
535
+ let body = ''
536
+ req.on('data', c => { body += c; if (body.length > 16 * 1024) req.destroy() })
537
+ req.on('end', () => {
538
+ let payload
539
+ try {
540
+ payload = JSON.parse(body || '{}')
541
+ } catch {
542
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
543
+ res.end(JSON.stringify({ error: 'invalid json' }))
544
+ return
545
+ }
546
+ const type = payload.type
547
+ const message = String(payload.message || '').trim()
548
+ const contact = String(payload.contact || '').trim()
549
+ const appVersion = String(payload.appVersion || '').trim()
550
+ if (!['bug', 'suggestion', 'other'].includes(type)) {
551
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
552
+ res.end(JSON.stringify({ error: 'invalid type', expect: 'bug|suggestion|other' }))
553
+ return
554
+ }
555
+ if (!message) {
556
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
557
+ res.end(JSON.stringify({ error: 'message required' }))
558
+ return
559
+ }
560
+ if (message.length > 2000) {
561
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
562
+ res.end(JSON.stringify({ error: 'message too long', max: 2000 }))
563
+ return
564
+ }
565
+ if (contact.length > 200) {
566
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
567
+ res.end(JSON.stringify({ error: 'contact too long', max: 200 }))
568
+ return
569
+ }
570
+
571
+ const ip = ipOf(req)
572
+ const now = Date.now()
573
+ const last = feedbackThrottle.get(ip) || 0
574
+ if (now - last < FEEDBACK_WINDOW_MS) {
575
+ res.writeHead(429, { 'content-type': 'application/json; charset=utf-8', 'retry-after': String(Math.ceil((FEEDBACK_WINDOW_MS - (now - last)) / 1000)) })
576
+ res.end(JSON.stringify({ error: 'rate_limited', retryAfter: Math.ceil((FEEDBACK_WINDOW_MS - (now - last)) / 1000) }))
577
+ return
578
+ }
579
+
580
+ // 转发收集器(收集器服务端已做校验/节流/落盘)。节流只在收集器确认成功后占位,
581
+ // 失败(429/502/网络错误)不占位, 用户可立即重试。
582
+ fetch(FEEDBACK_URL, {
583
+ method: 'POST',
584
+ headers: { 'content-type': 'application/json' },
585
+ body: JSON.stringify({
586
+ type,
587
+ message,
588
+ contact: contact || undefined,
589
+ appVersion: appVersion || 'unknown',
590
+ gatewayVersion: VERSION,
591
+ clientIp: maskIp(ip)
592
+ }),
593
+ signal: AbortSignal.timeout(8000)
594
+ }).then(async (r) => {
595
+ const data = await r.json().catch(() => ({}))
596
+ if (r.status === 429) {
597
+ res.writeHead(429, { 'content-type': 'application/json; charset=utf-8' })
598
+ res.end(JSON.stringify({ error: 'rate_limited' }))
599
+ } else if (r.ok && data.ok) {
600
+ feedbackThrottle.set(ip, now)
601
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
602
+ res.end(JSON.stringify({ ok: true }))
603
+ } else {
604
+ res.writeHead(502, { 'content-type': 'application/json; charset=utf-8' })
605
+ res.end(JSON.stringify({ error: 'upstream_error' }))
606
+ }
607
+ }).catch(() => {
608
+ res.writeHead(502, { 'content-type': 'application/json; charset=utf-8' })
609
+ res.end(JSON.stringify({ error: 'feedback_service_unavailable' }))
610
+ })
611
+ })
612
+ }
613
+
496
614
  // ---------- 静态文件 ----------
497
615
  function serveStatic(req, res, url) {
498
616
  if (req.method !== 'GET' && req.method !== 'HEAD') {
@@ -1427,6 +1545,7 @@ const server = http.createServer((req, res) => {
1427
1545
  try {
1428
1546
  const url = new URL(req.url, 'http://dsh-remote.local')
1429
1547
  if (url.pathname === '/fs' || url.pathname.startsWith('/fs/')) return serveFs(req, res, url)
1548
+ if (url.pathname === '/feedback') return serveFeedback(req, res, url)
1430
1549
  if (url.pathname.startsWith('/admin/api')) return serveAdminApi(req, res, url)
1431
1550
  if (url.pathname.startsWith('/stats')) return serveStats(req, res, url)
1432
1551
  if (url.pathname.startsWith('/api/')) return proxyApi(req, res, url)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-plugin",
3
- "version": "0.5.8",
3
+ "version": "0.5.9",
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 { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 14px; }
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 .right { display: flex; align-items: center; gap: 10px; }
113
- /* 放在 .gh-btn 基础规则之后, 窄屏才能覆盖 display */
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
- a.gh-btn { display: none; }
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: 400px) {
118
- .admin-title h1 span { display: none; }
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 style="display:flex;align-items:center;gap:10px">
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
- <button id="btn-theme" class="mini-btn" title="">
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) {
package/public/app.js CHANGED
@@ -76,6 +76,74 @@ function toast(text, kind = '') {
76
76
  toast._t = setTimeout(() => el.classList.add('hidden'), 3200)
77
77
  }
78
78
 
79
+ /* ---------------- 反馈 ---------------- */
80
+ const FEEDBACK_LINKS = {
81
+ githubIssues: 'https://github.com/Blank-not-black/dsh-Remote/issues',
82
+ giteeIssues: 'https://gitee.com/Blankneverfails/dsh-Remote/issues',
83
+ bili: 'https://space.bilibili.com/419009275/dynamic',
84
+ repo: 'https://github.com/Blank-not-black/dsh-Remote'
85
+ }
86
+ async function copyText(text) {
87
+ try { await navigator.clipboard.writeText(text); return true } catch {}
88
+ try {
89
+ const ta = document.createElement('textarea')
90
+ ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'
91
+ document.body.appendChild(ta); ta.focus(); ta.select()
92
+ const ok = document.execCommand('copy')
93
+ ta.remove(); return ok
94
+ } catch { return false }
95
+ }
96
+ function openFeedbackSheet() {
97
+ $('feedback-backdrop').classList.remove('hidden')
98
+ $('feedback-sheet').classList.remove('hidden')
99
+ $('btn-feedback').setAttribute('aria-expanded', 'true')
100
+ const first = $('feedback-sheet').querySelector('[role="menuitem"]')
101
+ if (first) first.focus()
102
+ }
103
+ function closeFeedbackSheet() {
104
+ $('feedback-backdrop').classList.add('hidden')
105
+ $('feedback-sheet').classList.add('hidden')
106
+ $('btn-feedback').setAttribute('aria-expanded', 'false')
107
+ }
108
+ function toggleFeedbackSheet() {
109
+ $('feedback-sheet').classList.contains('hidden') ? openFeedbackSheet() : closeFeedbackSheet()
110
+ }
111
+ function openFeedbackModal() {
112
+ state.feedbackType = 'bug'
113
+ document.querySelectorAll('#fb-chips .fb-chip').forEach(b => b.classList.toggle('current', b.dataset.fbType === 'bug'))
114
+ $('fb-msg').value = ''
115
+ $('fb-contact').value = ''
116
+ $('modal-feedback').classList.remove('hidden')
117
+ setTimeout(() => $('fb-msg').focus(), 50)
118
+ }
119
+ function closeFeedbackModal() { $('modal-feedback').classList.add('hidden') }
120
+ async function submitFeedback() {
121
+ const type = state.feedbackType || 'bug'
122
+ const message = $('fb-msg').value.trim()
123
+ const contact = $('fb-contact').value.trim()
124
+ if (!message) { toast(t('feedback.empty'), 'err'); return }
125
+ if (message.length > 2000) { toast(t('feedback.tooLong'), 'err'); return }
126
+ const btn = $('fb-submit')
127
+ btn.disabled = true
128
+ try {
129
+ const base = (state.server || '').replace(/\/+$/, '')
130
+ const res = await fetch(base + '/feedback', {
131
+ method: 'POST',
132
+ headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token },
133
+ body: JSON.stringify({ type, message, contact, appVersion: state.localVersion })
134
+ })
135
+ let json = {}
136
+ try { json = await res.json() } catch {}
137
+ if (res.ok && json.ok) { toast(t('feedback.submitted'), 'ok'); closeFeedbackModal() }
138
+ else if (res.status === 429) { toast(json.retryAfter ? t('feedback.rateLimitedAt', { n: json.retryAfter }) : t('feedback.rateLimited'), 'err') }
139
+ else { toast(t('feedback.submitFailed', { msg: json.error || res.status }), 'err') }
140
+ } catch {
141
+ toast(t('feedback.submitFailed', { msg: t('feedback.networkError') }), 'err')
142
+ } finally {
143
+ btn.disabled = false
144
+ }
145
+ }
146
+
79
147
  function fmtTime(ts) {
80
148
  if (!ts) return ''
81
149
  const diff = Date.now() - ts
@@ -2251,7 +2319,7 @@ function updateConn() {
2251
2319
  const ok = !!state.streamsOk?.mux
2252
2320
  const el = $('conn-badge')
2253
2321
  el.textContent = ok ? t('conn.on') : t('conn.off')
2254
- el.className = 'conn-badge ' + (ok ? 'on' : 'off')
2322
+ el.className = 'topbar-btn conn-badge ' + (ok ? 'on' : 'off')
2255
2323
  const cur = state.servers.find(s => s.url === state.server)
2256
2324
  const ms = state.serverLatency[state.server]
2257
2325
  const curGroup = cur ? cur.group : state.activeGroup
@@ -2455,8 +2523,30 @@ function bindUi() {
2455
2523
  $('btn-stats').addEventListener('click', () => { renderSessionCards(); $('modal-stats').classList.remove('hidden') })
2456
2524
  $('stats-close').addEventListener('click', () => $('modal-stats').classList.add('hidden'))
2457
2525
  $('btn-refresh').addEventListener('click', () => { toast(t('common.refreshing')); openStreams(); refreshAll() })
2458
- $('btn-admin').addEventListener('click', () => {
2459
- location.href = state.server ? state.server.replace(/\/+$/, '') + '/admin' : 'admin'
2526
+ // 反馈
2527
+ $('btn-feedback').addEventListener('click', (e) => { e.stopPropagation(); toggleFeedbackSheet() })
2528
+ $('feedback-backdrop').addEventListener('click', closeFeedbackSheet)
2529
+ $('feedback-sheet').addEventListener('click', (e) => {
2530
+ if (e.target.closest('a[role="menuitem"]')) closeFeedbackSheet()
2531
+ })
2532
+ $('btn-copy-link').addEventListener('click', async () => {
2533
+ const ok = await copyText(FEEDBACK_LINKS.repo)
2534
+ toast(t(ok ? 'feedback.copied' : 'feedback.copyFailed'), ok ? 'ok' : 'err')
2535
+ closeFeedbackSheet()
2536
+ })
2537
+ $('btn-write-feedback').addEventListener('click', () => { closeFeedbackSheet(); openFeedbackModal() })
2538
+ $('fb-cancel').addEventListener('click', closeFeedbackModal)
2539
+ $('fb-submit').addEventListener('click', submitFeedback)
2540
+ document.querySelectorAll('#fb-chips .fb-chip').forEach(btn =>
2541
+ btn.addEventListener('click', () => {
2542
+ state.feedbackType = btn.dataset.fbType
2543
+ document.querySelectorAll('#fb-chips .fb-chip').forEach(b => b.classList.toggle('current', b === btn))
2544
+ }))
2545
+ document.addEventListener('click', (e) => {
2546
+ if (!e.target.closest('#feedback-sheet') && !e.target.closest('#btn-feedback')) closeFeedbackSheet()
2547
+ })
2548
+ document.addEventListener('keydown', (e) => {
2549
+ if (e.key === 'Escape' && !$('feedback-sheet').classList.contains('hidden')) { closeFeedbackSheet(); $('btn-feedback').focus() }
2460
2550
  })
2461
2551
  $('btn-new-session').addEventListener('click', newSession)
2462
2552
  $('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,6 +204,18 @@
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': '连接中',
152
221
  'ds.currentServer': '{group} · {url}', 'ds.origin': '当前页面',
@@ -197,6 +266,18 @@
197
266
  'ds.serversTitle': 'Servers (groups)', 'ds.speedTest': 'Test', 'ds.currentGroup': 'Current group',
198
267
  'ds.addGroup': '+ Group', 'ds.addServer': 'Add', 'ds.tokenTitle': 'Access token', 'ds.copyToken': 'Copy',
199
268
  'ds.themeTitle': 'Theme', 'ds.themeDesc': 'Deep Space / Sunset / Elbphilharmonie / Prairie Tower', 'ds.langTitle': 'Language',
269
+ 'ds.feedbackTitle': 'Feedback', 'ds.feedbackDesc': 'GitHub / Gitee / Bilibili: report bugs, suggest features, or just chat',
270
+ 'ds.feedback': 'Feedback', 'ds.feedbackGithubDesc': 'Report bugs · suggest features', 'ds.feedbackGiteeDesc': 'Mirror in China, no proxy needed',
271
+ 'ds.feedbackBiliDesc': 'Chat on the UP\'s Bilibili page', 'ds.feedbackCopyLink': 'Copy project link', 'ds.feedbackCopyDesc': 'Share it manually',
272
+ 'ds.feedbackCopied': 'Project link copied', 'ds.feedbackCopyFailed': 'Copy failed, copy manually',
273
+ 'ds.feedbackWrite': 'Write feedback', 'ds.feedbackWriteDesc': 'Submit from the app', 'ds.feedbackModalTitle': 'Write feedback',
274
+ 'ds.feedbackTypeBug': 'Bug', 'ds.feedbackTypeSuggestion': 'Suggestion', 'ds.feedbackTypeOther': 'Other',
275
+ 'ds.feedbackMessagePlaceholder': 'Describe the bug or suggestion (required, ≤2000 chars)',
276
+ 'ds.feedbackContactPlaceholder': 'Contact (optional): email / WeChat / Bilibili ID',
277
+ 'ds.feedbackCancel': 'Cancel', 'ds.feedbackSubmit': 'Submit',
278
+ 'ds.feedbackEmpty': 'Please fill in the description', 'ds.feedbackTooLong': 'Description must be ≤2000 characters',
279
+ 'ds.feedbackSubmitted': 'Submitted — thanks!',
280
+ 'ds.feedbackRateLimited': 'Too frequent, try again in 5 minutes', 'ds.feedbackSubmitFailed': 'Submit failed: {msg}', 'ds.feedbackNetworkError': 'Network error',
200
281
  'ds.questionTitle': 'Answer question', 'ds.ignore': 'Ignore', 'ds.submit': 'Submit',
201
282
  'ds.connOn': 'Connected', 'ds.connOff': 'Offline', 'ds.connIng': 'Connecting',
202
283
  'ds.currentServer': '{group} · {url}', 'ds.origin': 'this page',
@@ -92,6 +92,86 @@ function toast(text, kind = '') {
92
92
  toast._t = setTimeout(() => el.classList.add('hidden'), 2600)
93
93
  }
94
94
 
95
+ /* ---------------- 反馈 ---------------- */
96
+ const FEEDBACK_LINKS = {
97
+ githubIssues: 'https://github.com/Blank-not-black/dsh-Remote/issues',
98
+ giteeIssues: 'https://gitee.com/Blankneverfails/dsh-Remote/issues',
99
+ bili: 'https://space.bilibili.com/419009275/dynamic',
100
+ repo: 'https://github.com/Blank-not-black/dsh-Remote'
101
+ }
102
+ async function copyText(text) {
103
+ try { await navigator.clipboard.writeText(text); return true } catch {}
104
+ try {
105
+ const ta = document.createElement('textarea')
106
+ ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'
107
+ document.body.appendChild(ta); ta.focus(); ta.select()
108
+ const ok = document.execCommand('copy')
109
+ ta.remove(); return ok
110
+ } catch { return false }
111
+ }
112
+ function openFeedbackMenu() {
113
+ $('feedback-menu').classList.remove('hidden')
114
+ $('btn-feedback').setAttribute('aria-expanded', 'true')
115
+ const first = $('feedback-menu').querySelector('[role="menuitem"]')
116
+ if (first) first.focus()
117
+ }
118
+ function closeFeedbackMenu() {
119
+ $('feedback-menu').classList.add('hidden')
120
+ $('btn-feedback').setAttribute('aria-expanded', 'false')
121
+ }
122
+ function toggleFeedbackMenu() {
123
+ $('feedback-menu').classList.contains('hidden') ? openFeedbackMenu() : closeFeedbackMenu()
124
+ }
125
+ function openFeedbackModal() {
126
+ document.querySelectorAll('#fb-chips .ds-fb-chip').forEach(b => b.classList.toggle('current', b.dataset.fbType === 'bug'))
127
+ $('fb-msg').value = ''
128
+ $('fb-contact').value = ''
129
+ $('modal-feedback').classList.remove('hidden')
130
+ setTimeout(() => $('fb-msg').focus(), 50)
131
+ }
132
+ function closeFeedbackModal() { $('modal-feedback').classList.add('hidden') }
133
+ async function submitFeedback() {
134
+ const type = document.querySelector('#fb-chips .ds-fb-chip.current')?.dataset.fbType || 'bug'
135
+ const message = $('fb-msg').value.trim()
136
+ const contact = $('fb-contact').value.trim()
137
+ if (!message) { toast(t('ds.feedbackEmpty'), 'err'); return }
138
+ if (message.length > 2000) { toast(t('ds.feedbackTooLong'), 'err'); return }
139
+ const btn = $('fb-submit')
140
+ btn.disabled = true
141
+ try {
142
+ const res = await fetch(apiUrl('/feedback'), {
143
+ method: 'POST',
144
+ headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token },
145
+ body: JSON.stringify({ type, message, contact, appVersion: '' })
146
+ })
147
+ let json = {}
148
+ try { json = await res.json() } catch {}
149
+ if (res.ok && json.ok) { toast(t('ds.feedbackSubmitted'), 'ok'); closeFeedbackModal() }
150
+ else if (res.status === 429) { toast(t('ds.feedbackRateLimited'), 'err') }
151
+ else { toast(t('ds.feedbackSubmitFailed', { msg: json.error || res.status }), 'err') }
152
+ } catch {
153
+ toast(t('ds.feedbackSubmitFailed', { msg: t('ds.feedbackNetworkError') }), 'err')
154
+ } finally {
155
+ btn.disabled = false
156
+ }
157
+ }
158
+ function showTip(text, anchorRect) {
159
+ const tip = $('ds-tip')
160
+ if (!tip) return
161
+ tip.textContent = text
162
+ tip.classList.remove('hidden')
163
+ const margin = 8
164
+ const tw = tip.offsetWidth
165
+ const th = tip.offsetHeight
166
+ let left = anchorRect.left + anchorRect.width / 2 - tw / 2
167
+ left = Math.max(margin, Math.min(left, window.innerWidth - tw - margin))
168
+ let top = anchorRect.top - th - 10
169
+ if (top < margin) top = anchorRect.bottom + 10
170
+ tip.style.left = left + 'px'
171
+ tip.style.top = top + 'px'
172
+ }
173
+ function hideTip() { const tip = $('ds-tip'); if (tip) tip.classList.add('hidden') }
174
+
95
175
  /* ---------------- API ---------------- */
96
176
  function apiUrl(path) { return (state.server || '') + path }
97
177
  async function rpc(method, payload = {}) {
@@ -814,7 +894,8 @@ function renderStats(days) {
814
894
  const peakH = cost > 0 ? Math.round((d.peak.cost || 0) / cost * 100) : 0
815
895
  const offH = cost > 0 ? Math.max(0, 100 - peakH) : 0
816
896
  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)}">
897
+ const tip = `${d.date}\n${t('ds.statsPeak')} ${fmtCost(d.peak.cost)}\n${t('ds.statsOff')} ${fmtCost(d.off.cost)}`
898
+ return `<div class="ds-stats-bar" data-tip="${esc(tip)}">
818
899
  <div class="bars" style="height:${totalH}%"><div class="seg peak" style="height:${peakH}%"></div><div class="seg off" style="height:${offH}%"></div></div>
819
900
  <div class="val">${cost > 0 ? fmtCost(cost) : ''}</div>
820
901
  <div class="lbl">${d.date.slice(5)}</div>
@@ -866,6 +947,35 @@ function bindUi() {
866
947
  })
867
948
  $('btn-stats-top').addEventListener('click', toggleStatsDrawer)
868
949
  $('stats-drawer-close').addEventListener('click', toggleStatsDrawer)
950
+ // 反馈
951
+ $('btn-feedback').addEventListener('click', (e) => { e.stopPropagation(); toggleFeedbackMenu() })
952
+ $('feedback-menu').addEventListener('click', (e) => {
953
+ if (e.target.closest('a[role="menuitem"]')) closeFeedbackMenu()
954
+ })
955
+ $('btn-copy-link').addEventListener('click', async () => {
956
+ const ok = await copyText(FEEDBACK_LINKS.repo)
957
+ toast(t(ok ? 'ds.feedbackCopied' : 'ds.feedbackCopyFailed'), ok ? 'ok' : 'err')
958
+ closeFeedbackMenu()
959
+ })
960
+ $('btn-write-feedback').addEventListener('click', () => { closeFeedbackMenu(); openFeedbackModal() })
961
+ $('fb-cancel').addEventListener('click', closeFeedbackModal)
962
+ $('fb-submit').addEventListener('click', submitFeedback)
963
+ document.querySelectorAll('#fb-chips .ds-fb-chip').forEach(btn =>
964
+ btn.addEventListener('click', () => {
965
+ document.querySelectorAll('#fb-chips .ds-fb-chip').forEach(b => b.classList.toggle('current', b === btn))
966
+ }))
967
+ document.addEventListener('click', (e) => {
968
+ if (!e.target.closest('.ds-feedback')) closeFeedbackMenu()
969
+ })
970
+ document.addEventListener('keydown', (e) => {
971
+ if (e.key === 'Escape' && !$('feedback-menu').classList.contains('hidden')) { closeFeedbackMenu(); $('btn-feedback').focus() }
972
+ })
973
+ // 统计柱状图悬停提示: 自定义 tooltip, 限制在视口内, 避免原生 title 溢出抽屉
974
+ $('stats-chart').addEventListener('mouseover', (e) => {
975
+ const bar = e.target.closest('.ds-stats-bar')
976
+ if (bar && bar.dataset.tip) showTip(bar.dataset.tip, bar.getBoundingClientRect())
977
+ })
978
+ $('stats-chart').addEventListener('mouseleave', hideTip)
869
979
 
870
980
  $('btn-server-speed').addEventListener('click', () => selectFastestServer({ silent: false }))
871
981
  $('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">
@@ -411,6 +477,18 @@
411
477
  'settings.hostTitle': 'DSH 状态', 'settings.hostProbing': '探测中…', 'settings.probe': '探测', 'settings.probeFailed': '探测失败',
412
478
  'settings.hostDesc': 'DSH {version} · {cwd} · 附加会话 {n}',
413
479
  'settings.updateTitle': '检查更新', 'settings.updateLoading': '加载中…', 'settings.downloadUpdate': '下载并安装更新', 'settings.check': '检查',
480
+ 'settings.feedbackTitle': '反馈渠道', 'settings.feedbackDesc': 'GitHub / Gitee / B站:反馈 bug、提建议、唠嗑',
481
+ 'feedback.title': '反馈', 'feedback.githubDesc': '反馈 bug / 提建议', 'feedback.giteeDesc': '国内镜像,无需代理',
482
+ 'feedback.biliDesc': 'UP 动态页交流', 'feedback.copyLink': '复制项目链接', 'feedback.copyDesc': '手动分享给朋友',
483
+ 'feedback.copied': '项目链接已复制', 'feedback.copyFailed': '复制失败,请手动复制',
484
+ 'feedback.write': '写反馈', 'feedback.writeDesc': 'App 内直接提交', 'feedback.modalTitle': '写反馈',
485
+ 'feedback.typeBug': 'Bug', 'feedback.typeSuggestion': '建议', 'feedback.typeOther': '其他',
486
+ 'feedback.messagePlaceholder': '请描述遇到的问题或建议(必填,≤2000 字)',
487
+ 'feedback.contactPlaceholder': '联系方式(可选):邮箱 / 微信 / B站 ID',
488
+ 'feedback.cancel': '取消', 'feedback.submit': '提交',
489
+ 'feedback.empty': '请填写描述内容', 'feedback.tooLong': '描述不能超过 2000 字',
490
+ 'feedback.submitted': '已提交,感谢反馈',
491
+ 'feedback.rateLimited': '提交太频繁,请稍后再试', 'feedback.rateLimitedAt': '提交太频繁,请 {n} 秒后再试', 'feedback.submitFailed': '提交失败:{msg}', 'feedback.networkError': '网络错误',
414
492
  'settings.resetTitle': '清空本地数据', 'settings.resetDesc': '令牌与缓存', 'settings.reset': '重置',
415
493
  'settings.confirmReset': '清除本地令牌、服务器与缓存?', 'settings.notifyDenied': '通知权限未开启',
416
494
  'settings.toolsShown': '已显示工具调用', 'settings.toolsHidden': '已隐藏工具调用',
@@ -553,6 +631,18 @@
553
631
  'settings.hostTitle': 'DSH status', 'settings.hostProbing': 'Probing…', 'settings.probe': 'Probe', 'settings.probeFailed': 'Probe failed',
554
632
  'settings.hostDesc': 'DSH {version} · {cwd} · {n} attached sessions',
555
633
  'settings.updateTitle': 'Check for updates', 'settings.updateLoading': 'Loading…', 'settings.downloadUpdate': 'Download & install update', 'settings.check': 'Check',
634
+ 'settings.feedbackTitle': 'Feedback', 'settings.feedbackDesc': 'GitHub / Gitee / Bilibili: report bugs, suggest features, or just chat',
635
+ 'feedback.title': 'Feedback', 'feedback.githubDesc': 'Report bugs · suggest features', 'feedback.giteeDesc': 'Mirror in China, no proxy needed',
636
+ 'feedback.biliDesc': 'Chat on the UP\'s Bilibili page', 'feedback.copyLink': 'Copy project link', 'feedback.copyDesc': 'Share it manually',
637
+ 'feedback.copied': 'Project link copied', 'feedback.copyFailed': 'Copy failed, copy manually',
638
+ 'feedback.write': 'Write feedback', 'feedback.writeDesc': 'Submit from the app', 'feedback.modalTitle': 'Write feedback',
639
+ 'feedback.typeBug': 'Bug', 'feedback.typeSuggestion': 'Suggestion', 'feedback.typeOther': 'Other',
640
+ 'feedback.messagePlaceholder': 'Describe the bug or suggestion (required, ≤2000 chars)',
641
+ 'feedback.contactPlaceholder': 'Contact (optional): email / WeChat / Bilibili ID',
642
+ 'feedback.cancel': 'Cancel', 'feedback.submit': 'Submit',
643
+ 'feedback.empty': 'Please fill in the description', 'feedback.tooLong': 'Description must be ≤2000 characters',
644
+ 'feedback.submitted': 'Submitted — thanks!',
645
+ '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
646
  'settings.resetTitle': 'Clear local data', 'settings.resetDesc': 'Token and caches', 'settings.reset': 'Reset',
557
647
  'settings.confirmReset': 'Clear local token, servers and caches?', 'settings.notifyDenied': 'Notification permission not granted',
558
648
  '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,6 @@
1
1
  {
2
- "version": "0.5.8",
2
+ "version": "0.5.9",
3
3
  "apkUrl": "dsh-remote.apk",
4
- "releasedAt": "2026-08-17T14:17:29.757Z",
5
- "notes": "新增:桌面端 WebUI(浏览器打开网关地址自动进入桌面布局,侧栏会话/文件/设置/统计抽屉/审批通知卡片栈);多服务端分组管理(备注/分组/组内自动或手动选择);峰谷计费提醒;Token 统计仅从 2026-08-17 定价生效日起算。"
4
+ "releasedAt": "2026-08-18T07:05:40.678Z",
5
+ "notes": "反馈渠道升级:App 顶栏 / 桌面端侧边栏 / 管理页右上角三端入口;App 与桌面端「写反馈」弹层直接提交,网关转发到反馈收集器(DSH_REMOTE_FEEDBACK_URL 可覆盖,无 token 配置,成功后 1 分钟节流);管理页顶栏滚动置顶并适配窄屏;桌面端反馈按钮与导航项同层级。"
6
6
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.5.8"
2
+ "version": "0.5.9"
3
3
  }