dsh-remote-plugin 0.5.3 → 0.5.5

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.
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-plugin",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
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
@@ -3,68 +3,99 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
- <meta name="theme-color" content="#0b0e1a">
6
+ <meta name="theme-color" content="#05348B">
7
7
  <title>DSH Remote · 管理</title>
8
+ <script>/* 首帧前应用皮肤 */ (function(){try{var t=localStorage.getItem('dshTheme');if(t!=='default'&&t!=='dark'&&t!=='light'&&t!=='neutral'){t=window.matchMedia('(prefers-color-scheme: light)').matches?'light':'default'}document.documentElement.setAttribute('data-theme',t)}catch(e){}})()</script>
8
9
  <link rel="stylesheet" href="../styles.css">
9
10
  <style>
10
- .admin-wrap { max-width: 860px; margin: 0 auto; padding: calc(env(safe-area-inset-top, 0px) + 14px) 14px 40px; }
11
+ .admin-wrap { max-width: 1180px; margin: 0 auto; padding: calc(env(safe-area-inset-top, 0px) + 14px) 14px 40px; }
11
12
  .admin-title { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 14px; }
12
13
  .admin-title h1 { font-size: 20px; margin: 0; white-space: nowrap; }
13
- .login-card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px; display: flex; gap: 8px; }
14
- .login-card input { flex: 1; background: var(--bg); color: var(--text); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; font: inherit; outline: none; }
15
- .stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 8px; margin-bottom: 14px; }
16
- .stat-card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 12px 14px; }
17
- .stat-card .v { font-size: 20px; font-weight: 700; overflow-wrap: anywhere; line-height: 1.3; }
18
- .stat-card .k { font-size: 11px; color: var(--muted); margin-top: 2px; }
19
- .stat-card.ok .v { color: var(--cyan); } .stat-card.warn .v { color: var(--orange); }
20
- .table-wrap { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); overflow-x: auto; }
21
- table { width: 100%; border-collapse: collapse; font-size: 13px; min-width: 640px; }
22
- th, td { text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
23
- th { font-size: 11px; color: var(--muted); letter-spacing: .5px; white-space: nowrap; }
14
+ .login-card { background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: var(--dsr-radius); padding: 16px; display: flex; gap: 8px; }
15
+ .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; }
16
+ .stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 8px; margin-bottom: 14px; }
17
+ .stat-card { background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: var(--dsr-radius); padding: 12px 14px; }
18
+ .stat-card .v { font-size: 20px; font-weight: 700; line-height: 1.3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
19
+ .stat-card .k { font-size: 11px; color: var(--dsr-muted); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
20
+ .stat-card.ok .v { color: var(--dsr-success); } .stat-card.warn .v { color: var(--dsr-warning); }
21
+ .table-wrap { background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: var(--dsr-radius); overflow-x: auto; }
22
+ table { width: 100%; border-collapse: collapse; font-size: 13px; min-width: 0; }
23
+ th, td { text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--dsr-line); vertical-align: top; }
24
+ th { font-size: 11px; color: var(--dsr-muted); letter-spacing: .5px; white-space: nowrap; }
24
25
  tr:last-child td { border-bottom: none; }
25
- .badge { display: inline-block; font-size: 11px; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); white-space: nowrap; }
26
- .badge.app { color: var(--blue); border-color: rgba(91,140,255,.4); background: rgba(91,140,255,.08); }
27
- .badge.admin { color: var(--purple); border-color: rgba(180,140,255,.4); background: rgba(180,140,255,.08); }
28
- .dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--line); margin-right: 5px; }
29
- .dot.on { background: var(--cyan); box-shadow: 0 0 6px rgba(125,207,255,.9); }
30
- .dot.off { background: var(--orange); }
26
+ .badge { display: inline-block; font-size: 11px; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--dsr-line); color: var(--dsr-muted); white-space: nowrap; }
27
+ .badge.app { color: var(--dsr-accent-strong); border-color: var(--dsr-accent-line); background: var(--dsr-accent-soft); }
28
+ .badge.admin { color: var(--dsr-accent-2); border-color: var(--dsr-accent-2-line); background: var(--dsr-accent-2-soft); }
29
+ .dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--dsr-line); margin-right: 5px; }
30
+ .dot.on { background: var(--dsr-success); box-shadow: 0 0 6px var(--dsr-success-line); }
31
+ .dot.off { background: var(--dsr-warning); }
31
32
  .mono { font-family: ui-monospace, monospace; font-size: 12px; word-break: break-all; }
32
33
  .nowrap { white-space: nowrap; }
33
- .ua { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); }
34
+ .ua { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsr-muted); }
35
+ /* 宽屏: 固定列宽让表格铺满容器, 名称列吸收剩余宽度, 消除临界横向滚动条 */
36
+ @media (min-width: 1000px) {
37
+ table { table-layout: fixed; }
38
+ th:nth-child(1) { width: 80px; }
39
+ th:nth-child(2) { width: 180px; }
40
+ th:nth-child(3) { width: 88px; }
41
+ th:nth-child(4) { width: 120px; }
42
+ th:nth-child(5) { width: 84px; }
43
+ th:nth-child(6) { width: 56px; }
44
+ th:nth-child(7) { width: 110px; }
45
+ th:nth-child(8) { width: 280px; }
46
+ th:nth-child(9) { width: 72px; }
47
+ td:nth-child(2) { white-space: nowrap; }
48
+ td:nth-child(2) b { display: inline-block; max-width: 130px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle; }
49
+ tbody tr:hover { background: var(--dsr-accent-soft); }
50
+ }
34
51
  .token-row { display: flex; flex-direction: column; align-items: stretch; gap: 8px; margin: 10px 0 14px; }
35
- .token-row code { flex: none; background: var(--bg); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; font-size: 12px; white-space: nowrap; overflow-x: auto; }
52
+ .token-row code { flex: none; width: 100%; background: var(--dsr-bg); border: 1px solid var(--dsr-line); border-radius: 8px; padding: 8px 10px; font-size: 12px; white-space: nowrap; overflow-x: auto; }
36
53
  .token-actions { display: flex; gap: 8px; flex-wrap: wrap; }
54
+ .token-actions .mini-btn { flex: 1 1 auto; text-align: center; padding: 6px 8px; font-size: 12.5px; }
37
55
  .conn-badge { white-space: nowrap; }
38
- @media (max-width: 620px) {
56
+ #btn-close-drawer { white-space: nowrap; }
57
+ @media (max-width: 720px) {
39
58
  .admin-title h1 { font-size: 16px; }
40
59
  .admin-title .right { gap: 6px; }
41
- .gh-btn span { display: none; }
60
+ .gh-btn { display: none; }
61
+ #btn-close-drawer span { display: none; }
62
+ #btn-close-drawer { padding: 5px 9px; }
63
+ #btn-console span { display: none; }
64
+ #btn-console { padding: 5px 9px; }
65
+ .conn-badge { padding: 3px 7px; }
42
66
  }
43
- @media (max-width: 560px) {
67
+ @media (max-width: 760px) {
44
68
  .table-wrap { overflow: visible; background: transparent; border: none; }
45
69
  .table-wrap table { min-width: 0; }
46
70
  .table-wrap thead { display: none; }
47
71
  .table-wrap tbody { display: flex; flex-direction: column; gap: 8px; }
48
- .table-wrap tr { display: block; background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 10px 12px; }
72
+ .table-wrap tr { display: block; background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: 12px; padding: 10px 12px; }
49
73
  .table-wrap tr:last-child td { border-bottom: none; }
50
- .table-wrap td { display: flex; align-items: center; gap: 8px; padding: 4px 0; border-bottom: 1px dashed var(--line); }
74
+ .table-wrap td { display: flex; align-items: center; gap: 8px; padding: 4px 0; border-bottom: 1px dashed var(--dsr-divider); }
51
75
  .table-wrap td:last-child { border-bottom: none; }
52
- .table-wrap td[data-label]::before { content: attr(data-label); min-width: 64px; font-size: 11px; color: var(--muted); flex: none; }
76
+ .table-wrap td[data-label]::before { content: attr(data-label); min-width: 64px; font-size: 11px; color: var(--dsr-muted); flex: none; }
53
77
  .table-wrap td.ua { display: none; }
54
78
  .table-wrap td.act { justify-content: flex-end; }
55
79
  .table-wrap td .mini-btn { margin-left: auto; }
56
80
  }
57
- .pair-box { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 14px; margin-bottom: 14px; text-align: center; }
81
+ .pair-box { background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: var(--dsr-radius); padding: 14px; margin-bottom: 14px; text-align: center; }
58
82
  .pair-box .pair-title { font-weight: 600; margin-bottom: 8px; }
59
83
  .pair-qr { display: inline-block; background: #fff; border-radius: 8px; padding: 6px; line-height: 0; }
60
84
  .pair-qr svg { display: block; width: 180px; height: 180px; }
61
85
  .pair-box .muted { margin-top: 8px; font-size: 12px; }
62
- .empty { text-align: center; color: var(--muted); padding: 30px 0; }
86
+ .empty { text-align: center; color: var(--dsr-muted); padding: 30px 0; }
63
87
  .empty-actions { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; margin-top: 12px; }
64
88
  a.mini-btn { text-decoration: none; display: inline-flex; align-items: center; }
65
- .gh-btn { display: inline-flex; align-items: center; gap: 6px; text-decoration: none; color: var(--text); }
89
+ .gh-btn { display: inline-flex; align-items: center; gap: 6px; text-decoration: none; color: var(--dsr-text); }
66
90
  .gh-btn svg { width: 16px; height: 16px; fill: currentColor; }
67
91
  .admin-title .right { display: flex; align-items: center; gap: 10px; }
92
+ /* 放在 .gh-btn 基础规则之后, 窄屏才能覆盖 display */
93
+ @media (max-width: 720px) {
94
+ a.gh-btn { display: none; }
95
+ }
96
+ @media (max-width: 400px) {
97
+ .admin-title h1 span { display: none; }
98
+ }
68
99
  </style>
69
100
  </head>
70
101
  <body>
@@ -80,6 +111,9 @@
80
111
  <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>
81
112
  <span data-i18n="repo">仓库</span>
82
113
  </a>
114
+ <button id="btn-theme" class="mini-btn" title="">
115
+ <span id="theme-swatch" class="theme-swatch-dot"></span><span id="theme-label" class="t-label">默认深空</span>
116
+ </button>
83
117
  <button id="btn-lang" class="mini-btn" title="Language / 语言">EN</button>
84
118
  <button id="conn-badge" class="conn-badge off" title="网关面板"><span data-i18n="unauth">未认证</span></button>
85
119
  </div>
@@ -125,6 +159,17 @@
125
159
  </div>
126
160
  </div>
127
161
 
162
+ <!-- 皮肤选择模态 -->
163
+ <div id="modal-theme" class="modal hidden">
164
+ <div class="modal-card">
165
+ <div class="modal-title" data-i18n="theme.panelTitle">选择配色</div>
166
+ <div id="theme-options" class="theme-panel-list"></div>
167
+ <div class="modal-actions">
168
+ <button id="theme-close" class="btn subtle" data-i18n="theme.close">关闭</button>
169
+ </div>
170
+ </div>
171
+ </div>
172
+
128
173
  <div id="toast" class="toast hidden"></div>
129
174
  <script>
130
175
  window.ADMIN_STR = {
@@ -133,6 +178,8 @@
133
178
  'consoleTitle': '返回控制台',
134
179
  'collapse': '收起面板',
135
180
  'admin': '管理',
181
+ 'theme.default': '默认深空', 'theme.dark': '落日', 'theme.light': '易北爱乐厅', 'theme.neutral': '草原孤塔',
182
+ 'theme.panelTitle': '选择配色', 'theme.close': '关闭',
136
183
  'repo': '仓库',
137
184
  'unauth': '未认证',
138
185
  'tokenPlaceholder': '输入网关访问令牌',
@@ -191,6 +238,8 @@
191
238
  'consoleTitle': 'Back to console',
192
239
  'collapse': 'Collapse panel',
193
240
  'admin': 'Admin',
241
+ 'theme.default': 'Default', 'theme.dark': 'Sunset', 'theme.light': 'Elbphilharmonie', 'theme.neutral': 'Prairie Tower',
242
+ 'theme.panelTitle': 'Choose theme', 'theme.close': 'Close',
194
243
  'repo': 'Repo',
195
244
  'unauth': 'Not connected',
196
245
  'tokenPlaceholder': 'Gateway access token',
@@ -248,6 +297,7 @@
248
297
  </script>
249
298
  <script src="../qrcode.min.js"></script>
250
299
  <script src="../i18n.js"></script>
300
+ <script src="../theme.js"></script>
251
301
  <script src="../admin.js"></script>
252
302
  </body>
253
303
  </html>
package/public/admin.js CHANGED
@@ -92,7 +92,7 @@ function render(st) {
92
92
  const upOk = st.upstream.reachable
93
93
  const hostIPs = (st.lanIPs || []).join(t('stat.ipSep')) || '127.0.0.1'
94
94
  const latestHtml = st.latest?.newer
95
- ? `<div class="v">${t('stat.updateAvailable', { version: st.latest.version })}</div><div class="k">${t('stat.currentV', { version: st.version })} · <a href="${st.latest.url || '#'}" target="_blank" rel="noopener" style="color:var(--orange)">${t('stat.download')}</a></div>`
95
+ ? `<div class="v">${t('stat.updateAvailable', { version: st.latest.version })}</div><div class="k">${t('stat.currentV', { version: st.version })} · <a href="${st.latest.url || '#'}" target="_blank" rel="noopener" style="color:var(--dsr-accent-strong)">${t('stat.download')}</a></div>`
96
96
  : `<div class="v">v${st.version}</div><div class="k">${isPlugin ? t('stat.embedded') : st.latest?.error ? t('stat.updateCheck', { error: st.latest.error }) : st.latest?.version ? t('stat.latest') : t('stat.notChecked')}</div>`
97
97
  $('stats').innerHTML = `
98
98
  <div class="stat-card"><div class="v">v${st.version}</div><div class="k">${t(isPlugin ? 'stat.pluginVersion' : 'stat.gatewayVersion')}</div></div>
@@ -332,13 +332,60 @@ function renderLangBtn() {
332
332
  if (btn) btn.textContent = I18N.lang === 'zh' ? 'EN' : '中文'
333
333
  document.title = t('login.title')
334
334
  }
335
+
336
+ const THEME_META = [
337
+ { id: 'default', sw: ['#0B0E1A', '#151B33', '#5B8CFF'] },
338
+ { id: 'dark', sw: ['#05348B', '#0D438F', '#F9A647'] },
339
+ { id: 'light', sw: ['#EFEEEC', '#FAF8F5', '#E6BC7B'] },
340
+ { id: 'neutral', sw: ['#DDD4B8', '#585818', '#832D15'] }
341
+ ]
342
+
343
+ function renderThemeBtn() {
344
+ const cur = window.DSHTheme.get()
345
+ const meta = THEME_META.find(m => m.id === cur)
346
+ const label = $('theme-label')
347
+ const swatch = $('theme-swatch')
348
+ if (label) label.textContent = t('theme.' + cur)
349
+ if (swatch && meta) swatch.style.background = meta.sw[0]
350
+ const btn = $('btn-theme')
351
+ if (btn) btn.title = t('theme.' + cur)
352
+ }
353
+
354
+ function renderThemeOptions() {
355
+ const box = $('theme-options')
356
+ if (!box) return
357
+ const cur = window.DSHTheme.get()
358
+ box.innerHTML = THEME_META.map(m => `
359
+ <button class="theme-option ${m.id === cur ? 'current' : ''}" data-theme="${m.id}" title="${t('theme.' + m.id)}">
360
+ <span class="theme-swatches">${m.sw.map(c => `<i style="background:${c}"></i>`).join('')}</span>
361
+ <span class="theme-name">${t('theme.' + m.id)}</span>
362
+ <span class="theme-check">${m.id === cur ? '✓' : ''}</span>
363
+ </button>`).join('')
364
+ box.querySelectorAll('.theme-option').forEach(btn =>
365
+ btn.addEventListener('click', () => {
366
+ window.DSHTheme.set(btn.dataset.theme)
367
+ renderThemeBtn()
368
+ renderThemeOptions()
369
+ $('modal-theme').classList.add('hidden')
370
+ }))
371
+ }
372
+
373
+ function openThemePanel() {
374
+ renderThemeOptions()
375
+ $('modal-theme').classList.remove('hidden')
376
+ }
377
+
335
378
  $('btn-lang').addEventListener('click', () => {
336
379
  I18N.setLang(I18N.lang === 'zh' ? 'en' : 'zh')
337
380
  renderLangBtn()
381
+ renderThemeBtn()
338
382
  if (lastState) render(lastState)
339
383
  else if (!token && !pluginMode) $('conn-badge').textContent = t('unauth')
340
384
  })
341
385
 
386
+ $('btn-theme').addEventListener('click', openThemePanel)
387
+ $('theme-close').addEventListener('click', () => $('modal-theme').classList.add('hidden'))
388
+
342
389
  function start(showLogin) {
343
390
  if (!showLogin) {
344
391
  $('login-view').classList.add('hidden')
@@ -363,3 +410,4 @@ if (pluginMode) {
363
410
  $('login-view').classList.remove('hidden')
364
411
  }
365
412
  renderLangBtn()
413
+ renderThemeBtn()
package/public/app.js CHANGED
@@ -1713,6 +1713,24 @@ function cmpVersion(a, b) {
1713
1713
  return 0
1714
1714
  }
1715
1715
 
1716
+ function resetUpdateExpand() {
1717
+ const desc = $('update-desc')
1718
+ if (desc) desc.classList.remove('expanded')
1719
+ const btn = $('btn-update-expand')
1720
+ if (btn) btn.classList.add('hidden')
1721
+ }
1722
+
1723
+ function renderUpdateExpandBtn() {
1724
+ const btn = $('btn-update-expand')
1725
+ if (!btn || btn.classList.contains('hidden')) return
1726
+ btn.textContent = t($('update-desc').classList.contains('expanded') ? 'update.collapse' : 'update.expand')
1727
+ }
1728
+
1729
+ function toggleUpdateExpand() {
1730
+ $('update-desc').classList.toggle('expanded')
1731
+ renderUpdateExpandBtn()
1732
+ }
1733
+
1716
1734
  async function loadLocalVersion() {
1717
1735
  try {
1718
1736
  const res = await fetch('version.json?t=' + Date.now())
@@ -1722,10 +1740,17 @@ async function loadLocalVersion() {
1722
1740
  }
1723
1741
 
1724
1742
  async function checkUpdate(silent) {
1743
+ if (!state.localVersion) {
1744
+ $('update-desc').textContent = t('update.noVersion')
1745
+ resetUpdateExpand()
1746
+ if (!silent) toast(t('update.noVersion'), 'err')
1747
+ return
1748
+ }
1725
1749
  const base = state.server
1726
1750
  if (!base) {
1727
1751
  if (!silent) toast(t('update.needServer'), 'err')
1728
1752
  $('update-desc').textContent = state.localVersion ? `${t('update.currentV', { version: state.localVersion })} · ${t('update.needServer')}` : t('update.needServer')
1753
+ resetUpdateExpand()
1729
1754
  return
1730
1755
  }
1731
1756
  if (!silent) toast(t('update.checking'))
@@ -1735,7 +1760,11 @@ async function checkUpdate(silent) {
1735
1760
  const info = await res.json()
1736
1761
  if (info.version && cmpVersion(info.version, state.localVersion) > 0) {
1737
1762
  state.updateInfo = info
1738
- $('update-desc').textContent = t('update.found', { version: info.version, notes: info.notes ? ':' + info.notes : '' })
1763
+ const hasNotes = !!(info.notes && String(info.notes).trim())
1764
+ $('update-desc').textContent = t('update.found', { version: info.version, notes: hasNotes ? ':' + info.notes : '' })
1765
+ $('update-desc').classList.remove('expanded')
1766
+ $('btn-update-expand').classList.toggle('hidden', !hasNotes)
1767
+ renderUpdateExpandBtn()
1739
1768
  $('btn-download-update').classList.remove('hidden')
1740
1769
  if (!silent) toast(t('update.found', { version: info.version }), 'ok')
1741
1770
  else notify(t('update.foundTitle'), t('update.foundBody', { version: info.version }))
@@ -1743,10 +1772,12 @@ async function checkUpdate(silent) {
1743
1772
  state.updateInfo = null
1744
1773
  $('update-desc').textContent = state.localVersion ? t('update.latestV', { version: state.localVersion }) : t('update.latestRemote', { version: info.version || '?' })
1745
1774
  $('btn-download-update').classList.add('hidden')
1775
+ resetUpdateExpand()
1746
1776
  if (!silent) toast(t('update.latestToast'), 'ok')
1747
1777
  }
1748
1778
  } catch (e) {
1749
1779
  $('update-desc').textContent = t('update.checkFailedDesc', { msg: e.message || t('fs.networkError') })
1780
+ resetUpdateExpand()
1750
1781
  if (!silent) toast(t('update.checkFailed', { msg: e.message }), 'err')
1751
1782
  }
1752
1783
  }
@@ -1960,11 +1991,50 @@ function renderLangBtn() {
1960
1991
  if (btn) btn.textContent = I18N.lang === 'zh' ? 'EN' : '中文'
1961
1992
  }
1962
1993
 
1994
+ const THEME_META = [
1995
+ { id: 'default', sw: ['#0B0E1A', '#151B33', '#5B8CFF'] },
1996
+ { id: 'dark', sw: ['#05348B', '#0D438F', '#F9A647'] },
1997
+ { id: 'light', sw: ['#EFEEEC', '#FAF8F5', '#E6BC7B'] },
1998
+ { id: 'neutral', sw: ['#DDD4B8', '#585818', '#832D15'] }
1999
+ ]
2000
+
2001
+ function renderThemeBtn() {
2002
+ const btn = $('btn-theme')
2003
+ if (btn) btn.textContent = t('theme.' + window.DSHTheme.get())
2004
+ }
2005
+
2006
+ function renderThemeOptions() {
2007
+ const box = $('theme-options')
2008
+ if (!box) return
2009
+ const cur = window.DSHTheme.get()
2010
+ box.innerHTML = THEME_META.map(m => `
2011
+ <button class="theme-option ${m.id === cur ? 'current' : ''}" data-theme="${m.id}" title="${t('theme.' + m.id)}">
2012
+ <span class="theme-swatches">${m.sw.map(c => `<i style="background:${c}"></i>`).join('')}</span>
2013
+ <span class="theme-name">${t('theme.' + m.id)}</span>
2014
+ <span class="theme-check">${m.id === cur ? '✓' : ''}</span>
2015
+ </button>`).join('')
2016
+ box.querySelectorAll('.theme-option').forEach(btn =>
2017
+ btn.addEventListener('click', () => {
2018
+ window.DSHTheme.set(btn.dataset.theme)
2019
+ renderThemeBtn()
2020
+ renderThemeOptions()
2021
+ $('modal-theme').classList.add('hidden')
2022
+ }))
2023
+ }
2024
+
2025
+ function openThemePanel() {
2026
+ renderThemeOptions()
2027
+ $('modal-theme').classList.remove('hidden')
2028
+ }
2029
+
1963
2030
  function bindUi() {
1964
2031
  renderLangBtn()
2032
+ renderThemeBtn()
1965
2033
  $('btn-lang').addEventListener('click', () => {
1966
2034
  I18N.setLang(I18N.lang === 'zh' ? 'en' : 'zh')
1967
2035
  renderLangBtn()
2036
+ renderThemeBtn()
2037
+ renderUpdateExpandBtn()
1968
2038
  renderServers()
1969
2039
  renderSessions()
1970
2040
  renderPending(); renderQueue(); renderJobs()
@@ -1975,6 +2045,8 @@ function bindUi() {
1975
2045
  if (state.hostInfo) $('host-desc').textContent = t('settings.hostDesc', { version: state.hostInfo.version, cwd: state.hostInfo.cwd, n: state.hostInfo.attachedSessions })
1976
2046
  $('token-desc').textContent = state.token ? t('token.savedLocal') : t('token.notSet')
1977
2047
  })
2048
+ $('btn-theme').addEventListener('click', openThemePanel)
2049
+ $('theme-close').addEventListener('click', () => $('modal-theme').classList.add('hidden'))
1978
2050
  renderServers()
1979
2051
  // 底部导航
1980
2052
  document.querySelectorAll('.nav-btn').forEach(b =>
@@ -2041,6 +2113,7 @@ function bindUi() {
2041
2113
  })
2042
2114
  $('btn-check-update').addEventListener('click', () => checkUpdate(false))
2043
2115
  $('btn-download-update').addEventListener('click', downloadUpdate)
2116
+ $('btn-update-expand').addEventListener('click', toggleUpdateExpand)
2044
2117
  $('btn-reset').addEventListener('click', () => {
2045
2118
  if (!confirm(t('settings.confirmReset'))) return
2046
2119
  LS.del('token'); LS.del('notify'); LS.del('server')
package/public/i18n.js CHANGED
@@ -22,7 +22,12 @@
22
22
  let s = table[key]
23
23
  if (s == null) s = (dict && dict.zh && dict.zh[key]) != null ? dict.zh[key] : key
24
24
  s = String(s)
25
- if (vars) for (const [k, v] of Object.entries(vars)) s = s.replaceAll('{' + k + '}', String(v ?? ''))
25
+ if (vars) {
26
+ const keys = new Set(Object.keys(vars))
27
+ s = s.replace(/\{([A-Za-z0-9_]+)\}/g, (m, k) => keys.has(k) ? String(vars[k] ?? '') : '')
28
+ } else {
29
+ s = s.replace(/\{[A-Za-z0-9_]+\}/g, '')
30
+ }
26
31
  return s
27
32
  }
28
33
 
package/public/index.html CHANGED
@@ -3,10 +3,11 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
6
- <meta name="theme-color" content="#0b0e1a">
6
+ <meta name="theme-color" content="#05348B">
7
7
  <meta name="mobile-web-app-capable" content="yes">
8
8
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
9
9
  <title>DSH Remote</title>
10
+ <script>/* 首帧前应用皮肤, 避免闪白/闪黑 */ (function(){try{var t=localStorage.getItem('dshTheme');if(t!=='default'&&t!=='dark'&&t!=='light'&&t!=='neutral'){t=window.matchMedia('(prefers-color-scheme: light)').matches?'light':'default'}document.documentElement.setAttribute('data-theme',t)}catch(e){}})()</script>
10
11
  <link rel="manifest" href="manifest.webmanifest">
11
12
  <link rel="icon" href="icon.svg" type="image/svg+xml">
12
13
  <link rel="stylesheet" href="styles.css">
@@ -155,6 +156,10 @@
155
156
  <div><div class="setting-name" data-i18n="settings.langTitle">语言 / Language</div><div class="setting-desc" data-i18n="settings.langDesc">界面语言,跟随系统或手动切换</div></div>
156
157
  <button id="btn-lang" class="mini-btn">EN</button>
157
158
  </div>
159
+ <div class="setting-row">
160
+ <div><div class="setting-name" data-i18n="settings.themeTitle">皮肤</div><div class="setting-desc" data-i18n="settings.themeDesc">默认深空 / 落日 / 易北爱乐厅 / 草原孤塔</div></div>
161
+ <button id="btn-theme" class="mini-btn" style="min-width:104px">默认深空</button>
162
+ </div>
158
163
  <div class="setting-row">
159
164
  <div><div class="setting-name" data-i18n="settings.tokenTitle">访问令牌</div><div class="setting-desc" id="token-desc" data-i18n="token.notSet">未设置</div></div>
160
165
  <button id="btn-change-token" class="mini-btn" data-i18n="settings.change">更换</button>
@@ -165,7 +170,8 @@
165
170
  </div>
166
171
  <div class="setting-row">
167
172
  <div><div class="setting-name" data-i18n="settings.updateTitle">检查更新</div><div class="setting-desc" id="update-desc" data-i18n="settings.updateLoading">加载中…</div>
168
- <button id="btn-download-update" class="mini-btn hidden" style="margin-top:6px" data-i18n="settings.downloadUpdate">下载并安装更新</button></div>
173
+ <button id="btn-download-update" class="mini-btn hidden" style="margin-top:6px" data-i18n="settings.downloadUpdate">下载并安装更新</button>
174
+ <button id="btn-update-expand" class="mini-btn hidden" style="margin-top:6px" data-i18n="update.expand">展开</button></div>
169
175
  <button id="btn-check-update" class="mini-btn" data-i18n="settings.check">检查</button>
170
176
  </div>
171
177
  </div>
@@ -233,6 +239,17 @@
233
239
  </div>
234
240
  </div>
235
241
 
242
+ <!-- 皮肤选择模态 -->
243
+ <div id="modal-theme" class="modal hidden">
244
+ <div class="modal-card">
245
+ <div class="modal-title" data-i18n="theme.panelTitle">选择配色</div>
246
+ <div id="theme-options" class="theme-panel-list"></div>
247
+ <div class="modal-actions">
248
+ <button id="theme-close" class="btn subtle" data-i18n="theme.close">关闭</button>
249
+ </div>
250
+ </div>
251
+ </div>
252
+
236
253
  <div id="toast" class="toast hidden"></div>
237
254
 
238
255
  <script>
@@ -324,6 +341,7 @@
324
341
  'update.checkFailedDesc': '检查失败:{msg}', 'update.checkFailed': '检查更新失败:{msg}',
325
342
  'update.downloadStarted': '开始下载,完成后会弹出安装页', 'update.downloadFailed': '无法启动下载:{msg}',
326
343
  'update.installUnsupported': '当前版本不支持 App 内安装,已转浏览器下载',
344
+ 'update.expand': '展开', 'update.collapse': '收起',
327
345
  'scan.imageLoadFailed': '图片加载失败', 'scan.decodeUnsupported': '当前设备不支持图片解码',
328
346
  'scan.browserHint': '浏览器请打开主机管理页,用手机相机扫码', 'scan.unsupported': '当前 App 版本不支持拍照扫码,请先更新 App',
329
347
  'scan.permissionDenied': '未授予相机权限',
@@ -336,6 +354,9 @@
336
354
  'settings.notifyTitle': '通知', 'settings.notifyDesc': '收到审批/提问时推送',
337
355
  'settings.toolsTitle': '显示工具调用', 'settings.toolsDesc': '关闭后隐藏 🔧/📦 工具调用与结果',
338
356
  'settings.langTitle': '语言 / Language', 'settings.langDesc': '界面语言,跟随系统或手动切换',
357
+ 'settings.themeTitle': '皮肤', 'settings.themeDesc': '默认深空 / 落日 / 易北爱乐厅 / 草原孤塔',
358
+ 'theme.default': '默认深空', 'theme.dark': '落日', 'theme.light': '易北爱乐厅', 'theme.neutral': '草原孤塔',
359
+ 'theme.panelTitle': '选择配色', 'theme.close': '关闭',
339
360
  'settings.tokenTitle': '访问令牌', 'settings.change': '更换',
340
361
  'settings.hostTitle': 'DSH 状态', 'settings.hostProbing': '探测中…', 'settings.probe': '探测', 'settings.probeFailed': '探测失败',
341
362
  'settings.hostDesc': 'DSH {version} · {cwd} · 附加会话 {n}',
@@ -437,6 +458,7 @@
437
458
  'update.checkFailedDesc': 'Check failed: {msg}', 'update.checkFailed': 'Update check failed: {msg}',
438
459
  'update.downloadStarted': 'Downloading, the install page will open when ready', 'update.downloadFailed': 'Could not start download: {msg}',
439
460
  'update.installUnsupported': 'This version cannot install in-app, opening browser download',
461
+ 'update.expand': 'Expand', 'update.collapse': 'Collapse',
440
462
  'scan.imageLoadFailed': 'Image failed to load', 'scan.decodeUnsupported': 'This device cannot decode images',
441
463
  'scan.browserHint': 'In a browser, open the host admin page and scan with your phone camera', 'scan.unsupported': 'This app version cannot scan, please update first',
442
464
  'scan.permissionDenied': 'Camera permission not granted',
@@ -449,6 +471,9 @@
449
471
  'settings.notifyTitle': 'Notifications', 'settings.notifyDesc': 'Push on approvals / questions',
450
472
  'settings.toolsTitle': 'Show tool calls', 'settings.toolsDesc': 'Hide 🔧/📦 tool calls and results when off',
451
473
  'settings.langTitle': 'Language / 语言', 'settings.langDesc': 'UI language, follows system or switch manually',
474
+ 'settings.themeTitle': 'Theme', 'settings.themeDesc': 'Default deep space / Sunset / Elbphilharmonie / Prairie Tower',
475
+ 'theme.default': 'Default', 'theme.dark': 'Sunset', 'theme.light': 'Elbphilharmonie', 'theme.neutral': 'Prairie Tower',
476
+ 'theme.panelTitle': 'Choose theme', 'theme.close': 'Close',
452
477
  'settings.tokenTitle': 'Access token', 'settings.change': 'Change',
453
478
  'settings.hostTitle': 'DSH status', 'settings.hostProbing': 'Probing…', 'settings.probe': 'Probe', 'settings.probeFailed': 'Probe failed',
454
479
  'settings.hostDesc': 'DSH {version} · {cwd} · {n} attached sessions',
@@ -468,6 +493,7 @@
468
493
  <script src="sha256.js"></script>
469
494
  <script src="jsqr.min.js"></script>
470
495
  <script src="i18n.js"></script>
496
+ <script src="theme.js"></script>
471
497
  <script src="app.js"></script>
472
498
  </body>
473
499
  </html>