dsh-remote-plugin 0.6.3 → 0.6.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/gateway.cjs CHANGED
@@ -43,6 +43,7 @@ const ROOT = __dirname
43
43
  const PUBLIC_DIR = path.join(ROOT, 'public')
44
44
  const PORT = Number(process.env.PORT) || 8787
45
45
  const HOST = process.env.HOST || '0.0.0.0'
46
+ const WS_IDLE_MS = Number(process.env.GATEWAY_WS_IDLE_MS) || 60000
46
47
  const UPSTREAM = new URL(process.env.DSH_UPSTREAM || 'http://127.0.0.1:3080')
47
48
  const TOKEN_FILE = process.env.TOKEN_FILE || path.join(os.homedir(), '.dsh-remote', 'token')
48
49
  const NOTES_FILE = process.env.DSH_REMOTE_NOTES || path.join(os.homedir(), '.dsh-remote', 'device-notes.json')
@@ -1778,11 +1779,43 @@ server.on('upgrade', (req, socket, head) => {
1778
1779
  upSocket.setNoDelay(true)
1779
1780
  upSocket.pipe(socket)
1780
1781
  socket.pipe(upSocket)
1781
- const close = () => { upSocket.destroy(); socket.destroy() }
1782
+ // 双向 idle 检测: 任一侧 60s 无数据即视为死连接, 同时销毁两侧
1783
+ let upIdle = null
1784
+ let clientIdle = null
1785
+ const clearIdle = () => {
1786
+ clearTimeout(upIdle)
1787
+ clearTimeout(clientIdle)
1788
+ upIdle = null
1789
+ clientIdle = null
1790
+ }
1791
+ const destroyBoth = () => {
1792
+ clearIdle()
1793
+ upSocket.destroy()
1794
+ socket.destroy()
1795
+ }
1796
+ const close = () => {
1797
+ clearIdle()
1798
+ upSocket.destroy()
1799
+ socket.destroy()
1800
+ }
1801
+ const touchUp = () => {
1802
+ clearTimeout(upIdle)
1803
+ upIdle = setTimeout(destroyBoth, WS_IDLE_MS)
1804
+ upIdle?.unref?.()
1805
+ }
1806
+ const touchClient = () => {
1807
+ clearTimeout(clientIdle)
1808
+ clientIdle = setTimeout(destroyBoth, WS_IDLE_MS)
1809
+ clientIdle?.unref?.()
1810
+ }
1811
+ upSocket.on('data', touchUp)
1812
+ socket.on('data', touchClient)
1813
+ touchUp()
1814
+ touchClient()
1782
1815
  upSocket.on('error', close)
1783
1816
  socket.on('error', close)
1784
- upSocket.on('close', () => socket.end())
1785
- socket.on('close', () => upSocket.end())
1817
+ upSocket.on('close', () => { clearIdle(); if (!socket.destroyed) socket.end() })
1818
+ socket.on('close', () => { clearIdle(); if (!upSocket.destroyed) upSocket.end() })
1786
1819
  })
1787
1820
 
1788
1821
  upstreamReq.on('error', () => {
package/index.mjs CHANGED
@@ -8,6 +8,7 @@
8
8
  import { execFileSync, spawn } from 'node:child_process'
9
9
  import { appendFileSync, createReadStream, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs'
10
10
  import { stat } from 'node:fs/promises'
11
+ import net from 'node:net'
11
12
  import { homedir, hostname, networkInterfaces } from 'node:os'
12
13
  import { dirname, extname, normalize, resolve } from 'node:path'
13
14
  import { fileURLToPath } from 'node:url'
@@ -20,8 +21,24 @@ const PUBLIC_DIR = fileURLToPath(new URL('./public/', import.meta.url))
20
21
  const INDEX_FILE = 'index.html'
21
22
  const GATEWAY_SCRIPT = fileURLToPath(new URL('./gateway.cjs', import.meta.url))
22
23
  const gatewayInstalled = existsSync(GATEWAY_SCRIPT)
23
- // 本地网关管理 API 代理: 让插件抽屉显示与 8787 网关管理页完全一致的数据。
24
- const GATEWAY_BASE = (process.env.DSH_REMOTE_GATEWAY || 'http://127.0.0.1:8787').replace(/\/+$/, '')
24
+ // 本地网关管理 API 代理: 让插件抽屉显示与网关管理页完全一致的数据。
25
+ // 端口读取优先级: DSH_REMOTE_GATEWAY_PORT > ~/.dsh-remote/gateway-port > 8787
26
+ function gatewayPortFile() { return `${homedir()}/.dsh-remote/gateway-port` }
27
+
28
+ function readGatewayPort() {
29
+ const valid = (v) => /^\d+$/.test(String(v)) && Number(v) >= 1 && Number(v) <= 65535
30
+ const envPort = process.env.DSH_REMOTE_GATEWAY_PORT
31
+ if (valid(envPort)) return String(Number(envPort))
32
+ try {
33
+ const filePort = readFileSync(gatewayPortFile(), 'utf8').trim()
34
+ if (valid(filePort)) return String(Number(filePort))
35
+ } catch {}
36
+ return '8787'
37
+ }
38
+
39
+ function gatewayBase() {
40
+ return (process.env.DSH_REMOTE_GATEWAY || `http://127.0.0.1:${readGatewayPort()}`).replace(/\/+$/, '')
41
+ }
25
42
 
26
43
  function gatewayToken() {
27
44
  if (process.env.DSH_REMOTE_TOKEN) return process.env.DSH_REMOTE_TOKEN
@@ -95,7 +112,7 @@ async function proxyGateway(path, method, body) {
95
112
  const token = gatewayToken()
96
113
  if (!token) return null
97
114
  try {
98
- const res = await fetch(`${GATEWAY_BASE}${path}`, {
115
+ const res = await fetch(`${gatewayBase()}${path}`, {
99
116
  method,
100
117
  headers: {
101
118
  authorization: `Bearer ${token}`,
@@ -126,9 +143,26 @@ function runExit(cmd, args) {
126
143
  })
127
144
  }
128
145
 
146
+ /** 127.0.0.1 端口占用预检: 能连上=被占用, 连接被拒/超时=可用。 */
147
+ function portInUse(port) {
148
+ return new Promise((resolvePromise) => {
149
+ const sock = net.connect({ host: '127.0.0.1', port: Number(port) })
150
+ let done = false
151
+ const finish = (used) => {
152
+ if (done) return
153
+ done = true
154
+ sock.destroy()
155
+ resolvePromise(used)
156
+ }
157
+ sock.once('connect', () => finish(true))
158
+ sock.once('error', () => finish(false))
159
+ sock.setTimeout(800, () => finish(false))
160
+ })
161
+ }
162
+
129
163
  async function gatewayRunning() {
130
164
  try {
131
- const res = await fetch(`${GATEWAY_BASE}/health`, { signal: AbortSignal.timeout(3000) })
165
+ const res = await fetch(`${gatewayBase()}/health`, { signal: AbortSignal.timeout(3000) })
132
166
  if (!res.ok) return { running: false }
133
167
  const data = await res.json().catch(() => ({}))
134
168
  return {
@@ -217,8 +251,12 @@ async function startGateway() {
217
251
  if (!existsSync(script)) {
218
252
  return { ok: false, running: false, error: '插件包缺少 gateway.cjs, 请升级插件' }
219
253
  }
220
- const port = process.env.DSH_REMOTE_GATEWAY_PORT || '8787'
221
- logGateway('启动网关, 上游: ' + upstream)
254
+ const port = readGatewayPort()
255
+ if (await portInUse(port)) {
256
+ logGateway(`端口 ${port} 已被占用, 拒绝启动`)
257
+ return { ok: false, running: false, error: `端口 ${port} 已被占用,请在插件页修改网关端口后重试` }
258
+ }
259
+ logGateway('启动网关, 端口: ' + port + ', 上游: ' + upstream)
222
260
 
223
261
  // 首选 systemd-run: 网关成为独立 user 单元, DSH 重启/升级不会连带杀掉它
224
262
  let sysd = false
@@ -302,7 +340,7 @@ async function stopGateway() {
302
340
  const token = gatewayToken()
303
341
  if (!token) return { ok: false, running: false, error: '找不到 ~/.dsh-remote/token, 无法认证网关' }
304
342
  try {
305
- const res = await fetch(`${GATEWAY_BASE}/admin/api/shutdown`, {
343
+ const res = await fetch(`${gatewayBase()}/admin/api/shutdown`, {
306
344
  method: 'POST',
307
345
  headers: { authorization: `Bearer ${token}`, 'x-dsh-remote-client': 'admin' },
308
346
  signal: AbortSignal.timeout(2000),
@@ -348,7 +386,7 @@ function statsSend(session, event) {
348
386
  const prev = statsQueues.get(session.id) || Promise.resolve()
349
387
  const next = prev.then(async () => {
350
388
  try {
351
- await fetch(`${GATEWAY_BASE}/stats/ingest`, {
389
+ await fetch(`${gatewayBase()}/stats/ingest`, {
352
390
  method: 'POST',
353
391
  headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
354
392
  body: JSON.stringify(payload),
@@ -434,7 +472,7 @@ async function serveStatic(req, res, ctx) {
434
472
  if (proxied !== null) {
435
473
  sendJson(res, proxied.status, proxied.json)
436
474
  } else {
437
- sendJson(res, 502, { ok: false, error: '本地网关不可用, Token 统计需要 8787 网关运行' })
475
+ sendJson(res, 502, { ok: false, error: '本地网关不可用, Token 统计需要网关运行' })
438
476
  }
439
477
  return
440
478
  }
@@ -486,11 +524,65 @@ async function serveStatic(req, res, ctx) {
486
524
  if (proxied !== null) {
487
525
  sendJson(res, proxied.status, proxied.json)
488
526
  } else {
489
- sendJson(res, 502, { ok: false, error: '本地网关不可用, 设备管理需在 8787 网关模式操作' })
527
+ sendJson(res, 502, { ok: false, error: '本地网关不可用, 设备管理需在网关模式操作' })
490
528
  }
491
529
  return
492
530
  }
493
531
 
532
+ // 网关端口配置(仅插件内嵌页使用): GET 当前生效端口 / PUT 修改端口
533
+ if (pathname === `${MOUNT}/admin/api/config`) {
534
+ if (req.method === 'GET') {
535
+ const h = await gatewayRunning()
536
+ sendJson(res, 200, {
537
+ ok: true,
538
+ port: Number(readGatewayPort()),
539
+ running: h.running,
540
+ source: process.env.DSH_REMOTE_GATEWAY_PORT ? 'env' : existsSync(gatewayPortFile()) ? 'file' : 'default',
541
+ })
542
+ return
543
+ }
544
+ if (req.method === 'PUT') {
545
+ let body = {}
546
+ try {
547
+ body = JSON.parse((await readBody(req, 4096)) || '{}')
548
+ } catch {}
549
+ const port = Number(body.port)
550
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
551
+ sendJson(res, 400, { ok: false, error: '端口必须是 1-65535 的整数' })
552
+ return
553
+ }
554
+ const oldPort = Number(readGatewayPort())
555
+ try {
556
+ mkdirSync(`${homedir()}/.dsh-remote`, { recursive: true })
557
+ writeFileSync(gatewayPortFile(), String(port) + '\n')
558
+ } catch (e) {
559
+ sendJson(res, 500, { ok: false, error: '写入端口配置失败: ' + (e?.message || String(e)) })
560
+ return
561
+ }
562
+ const effectivePort = Number(readGatewayPort())
563
+ if (effectivePort !== oldPort) {
564
+ const h = await gatewayRunning()
565
+ if (h.running) {
566
+ await killGateway(h)
567
+ for (let i = 0; i < 10; i++) {
568
+ if (!(await gatewayRunning()).running) break
569
+ await sleep(200)
570
+ }
571
+ }
572
+ }
573
+ let running = (await gatewayRunning()).running
574
+ if (gatewayAutostart()) {
575
+ const startOut = await startGateway()
576
+ running = !!startOut.running || (await gatewayRunning()).running
577
+ }
578
+ sendJson(res, 200, { ok: true, port: Number(port), effectivePort, running })
579
+ return
580
+ }
581
+ res.writeHead(405, { allow: 'GET, PUT' })
582
+ res.end()
583
+ return
584
+ }
585
+
494
586
  // 本地网关开关(仅插件内嵌页使用): GET 状态 / POST {action:'start'|'stop'}
495
587
  if (pathname === `${MOUNT}/admin/api/gateway`) {
496
588
  if (req.method === 'GET') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-plugin",
3
- "version": "0.6.3",
3
+ "version": "0.6.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
@@ -79,6 +79,11 @@
79
79
  .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; }
80
80
  .token-actions { display: flex; gap: 8px; flex-wrap: wrap; }
81
81
  .token-actions .mini-btn { flex: 1 1 auto; text-align: center; padding: 6px 8px; font-size: 12.5px; }
82
+ .gateway-port-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 0 0 14px; padding: 10px 12px; background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: var(--dsr-radius); }
83
+ .gateway-port-row label { font-size: 12px; color: var(--dsr-muted); flex: none; }
84
+ .gateway-port-row input { width: 92px; background: var(--dsr-bg); color: var(--dsr-text); border: 1px solid var(--dsr-line); border-radius: 8px; padding: 6px 8px; font: inherit; font-size: 13px; outline: none; }
85
+ .gateway-port-row .muted { font-size: 12px; }
86
+ .gateway-port-row .mini-btn { margin-left: auto; }
82
87
  .conn-badge { white-space: nowrap; }
83
88
  #btn-close-drawer { white-space: nowrap; }
84
89
  @media (max-width: 720px) {
@@ -229,6 +234,13 @@
229
234
  </div>
230
235
  </div>
231
236
 
237
+ <div id="gateway-port-row" class="gateway-port-row hidden">
238
+ <label data-i18n="gatewayPort">网关端口</label>
239
+ <input id="gateway-port-input" type="number" min="1" max="65535" inputmode="numeric" placeholder="8787">
240
+ <span id="gateway-port-current" class="muted">—</span>
241
+ <button id="btn-save-port" class="mini-btn" data-i18n="savePort">保存</button>
242
+ </div>
243
+
232
244
  <div id="pair-box" class="pair-box hidden">
233
245
  <div class="pair-title" data-i18n="pairTitle">手机 App 扫码配对</div>
234
246
  <div class="pair-qr" id="pair-qr"></div>
@@ -307,6 +319,14 @@
307
319
  'stopGateway': '停止网关',
308
320
  'starting': '启动中…',
309
321
  'stopping': '停止中…',
322
+ 'gatewayPort': '网关端口',
323
+ 'gatewayPort.current': '当前 {port}',
324
+ 'savePort': '保存',
325
+ 'toast.portInvalid': '端口必须是 1-65535 的整数',
326
+ 'toast.portSaved': '端口已更新,网关已切换至 {port}',
327
+ 'toast.portSavedIdle': '端口已保存,网关未运行,下次启动生效',
328
+ 'toast.portEnv': '环境变量优先,当前仍使用 {port}',
329
+ 'toast.portFailedMsg': '端口保存失败:{msg}',
310
330
  'qrCode': '二维码',
311
331
  'rotateToken': '轮换令牌',
312
332
  'copyToken': '复制令牌',
@@ -324,7 +344,7 @@
324
344
  'stat.updateAvailable': '{version} 可用', 'stat.currentV': '当前 v{version}',
325
345
  'stat.download': '去下载', 'stat.embedded': 'DSH 内嵌 · 免网关',
326
346
  'stat.updateCheck': '更新检查: {error}', 'stat.latest': '已是最新(来源检查)', 'stat.notChecked': '未检查更新',
327
- 'stat.hostIP': '主机 IP · {hostname}', 'stat.ipSep': '、', 'stat.phoneGateway': ' (手机连 8787 网关)', 'stat.phoneThis': ' (手机连这个地址)',
347
+ 'stat.hostIP': '主机 IP · {hostname}', 'stat.ipSep': '、', 'stat.phoneGateway': ' (手机连 {port} 网关)', 'stat.phoneThis': ' (手机连这个地址)',
328
348
  'stat.reachable': '可达', 'stat.unreachable': '不可达', 'stat.dshUpstream': 'DSH 上游 {url}',
329
349
  'stat.devicesOnline': '设备在线 / 累计', 'stat.totalRequests': '总请求数', 'stat.authFailures': '认证失败',
330
350
  'stat.uptime': '运行时长 · {host}:{port}',
@@ -338,7 +358,7 @@
338
358
  'stats.output': '输出',
339
359
  'stats.peak': '高峰', 'stats.off': '空闲',
340
360
  'stats.days': '近 {n} 天',
341
- 'stats.gatewayDown': '统计需要 8787 网关运行', 'stats.empty': '暂无统计,产生会话后自动聚合',
361
+ 'stats.gatewayDown': '统计需要网关运行', 'stats.empty': '暂无统计,产生会话后自动聚合',
342
362
  'stats.note': '注:本数据仅在使用 DeepSeek 官方 API 时估算;基于 token 计算,与官网账单可能有出入,一切以官网为准。统计自 2026-08-17 定价生效日起。',
343
363
  'unit.sec': ' 秒', 'unit.min': ' 分钟', 'unit.hour': ' 小时 ', 'unit.minShort': ' 分', 'unit.day': ' 天 ',
344
364
  'device.installedNotRunning': '网关已安装 · 当前未运行', 'device.noGatewayBinary': '未检测到网关程序',
@@ -382,6 +402,14 @@
382
402
  'stopGateway': 'Stop gateway',
383
403
  'starting': 'Starting…',
384
404
  'stopping': 'Stopping…',
405
+ 'gatewayPort': 'Gateway port',
406
+ 'gatewayPort.current': 'Current {port}',
407
+ 'savePort': 'Save',
408
+ 'toast.portInvalid': 'Port must be an integer from 1 to 65535',
409
+ 'toast.portSaved': 'Port updated, gateway switched to {port}',
410
+ 'toast.portSavedIdle': 'Port saved; gateway not running, takes effect on next start',
411
+ 'toast.portEnv': 'Environment variable takes priority, current port remains {port}',
412
+ 'toast.portFailedMsg': 'Failed to save port: {msg}',
385
413
  'qrCode': 'QR code',
386
414
  'rotateToken': 'Rotate token',
387
415
  'copyToken': 'Copy token',
@@ -399,7 +427,7 @@
399
427
  'stat.updateAvailable': 'v{version} available', 'stat.currentV': 'Current v{version}',
400
428
  'stat.download': 'Download', 'stat.embedded': 'Embedded in DSH · no gateway',
401
429
  'stat.updateCheck': 'Update check: {error}', 'stat.latest': 'Up to date (source check)', 'stat.notChecked': 'Not checked',
402
- 'stat.hostIP': 'Host IP · {hostname}', 'stat.ipSep': ', ', 'stat.phoneGateway': ' (phone connects to gateway 8787)', 'stat.phoneThis': ' (phone connects to this address)',
430
+ 'stat.hostIP': 'Host IP · {hostname}', 'stat.ipSep': ', ', 'stat.phoneGateway': ' (phone connects to gateway {port})', 'stat.phoneThis': ' (phone connects to this address)',
403
431
  'stat.reachable': 'Reachable', 'stat.unreachable': 'Unreachable', 'stat.dshUpstream': 'DSH upstream {url}',
404
432
  'stat.devicesOnline': 'Devices online / total', 'stat.totalRequests': 'Total requests', 'stat.authFailures': 'Auth failures',
405
433
  'stat.uptime': 'Uptime · {host}:{port}',
@@ -413,7 +441,7 @@
413
441
  'stats.output': 'Output',
414
442
  'stats.peak': 'Peak', 'stats.off': 'Off-peak',
415
443
  'stats.days': 'Last {n} days',
416
- 'stats.gatewayDown': 'Stats require the gateway on 8787', 'stats.empty': 'No stats yet — they aggregate as sessions happen',
444
+ 'stats.gatewayDown': 'Stats require the gateway to be running', 'stats.empty': 'No stats yet — they aggregate as sessions happen',
417
445
  'stats.note': 'Note: estimates assume the official DeepSeek API. Token-based calculation may differ from the official bill; always defer to deepseek.com. Stats start from the 2026-08-17 pricing date.',
418
446
  'unit.sec': 's', 'unit.min': 'min', 'unit.hour': 'h ', 'unit.minShort': 'm', 'unit.day': 'd ',
419
447
  'device.installedNotRunning': 'Gateway installed · not running', 'device.noGatewayBinary': 'Gateway binary not found',
package/public/admin.js CHANGED
@@ -24,6 +24,8 @@ let gatewayBusy = false
24
24
  let shownToken = token
25
25
  let lastState = null
26
26
  let qrShown = false
27
+ let gatewayPort = 8787
28
+ let gatewayPortLoaded = false
27
29
 
28
30
  const STATS_API = pluginMode ? API + '/stats' : '/stats'
29
31
  let statsTimer = null
@@ -64,6 +66,26 @@ async function loadStats() {
64
66
  }
65
67
  }
66
68
 
69
+ async function loadGatewayConfig() {
70
+ if (!pluginMode) return
71
+ try {
72
+ const res = await fetch(`${API}/config`, {
73
+ headers: { authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' }
74
+ })
75
+ const out = await res.json().catch(() => ({}))
76
+ if (out.ok) {
77
+ gatewayPort = Number(out.port) || 8787
78
+ gatewayPortLoaded = true
79
+ const row = $('gateway-port-row')
80
+ const input = $('gateway-port-input')
81
+ if (row) row.classList.toggle('hidden', !pluginMode)
82
+ if (input && document.activeElement !== input) input.value = gatewayPort
83
+ const cur = $('gateway-port-current')
84
+ if (cur) cur.textContent = t('gatewayPort.current', { port: gatewayPort })
85
+ }
86
+ } catch {}
87
+ }
88
+
67
89
  function renderStats(days) {
68
90
  if (!days.length) {
69
91
  $('stats-cards').innerHTML = ''
@@ -191,6 +213,12 @@ function render(st) {
191
213
  ? t(gatewayRunning ? 'stopping' : 'starting')
192
214
  : t(gatewayRunning ? 'stopGateway' : 'startGateway')
193
215
  $('btn-gateway').disabled = gatewayBusy
216
+ // 网关端口配置: 仅插件内嵌页提供
217
+ $('gateway-port-row').classList.toggle('hidden', !pluginMode || !gatewayPortLoaded)
218
+ if (pluginMode) {
219
+ const cur = $('gateway-port-current')
220
+ if (cur) cur.textContent = t('gatewayPort.current', { port: gatewayPort })
221
+ }
194
222
  const upOk = st.upstream.reachable
195
223
  const hostIPs = (st.lanIPs || []).join(t('stat.ipSep')) || '127.0.0.1'
196
224
  const latestHtml = st.latest?.newer
@@ -199,7 +227,7 @@ function render(st) {
199
227
  $('stats').innerHTML = `
200
228
  <div class="stat-card"><div class="v">v${st.version}</div><div class="k">${t(isPlugin ? 'stat.pluginVersion' : 'stat.gatewayVersion')}</div></div>
201
229
  <div class="stat-card ${st.latest?.newer ? 'warn' : 'ok'}">${latestHtml}</div>
202
- <div class="stat-card ok"><div class="v" style="font-size:13px">${hostIPs}</div><div class="k">${t('stat.hostIP', { hostname: st.hostname })}${isPlugin ? t('stat.phoneGateway') : t('stat.phoneThis')}</div></div>
230
+ <div class="stat-card ok"><div class="v" style="font-size:13px">${hostIPs}</div><div class="k">${t('stat.hostIP', { hostname: st.hostname })}${isPlugin ? t('stat.phoneGateway', { port: gatewayPort }) : t('stat.phoneThis')}</div></div>
203
231
  <div class="stat-card ${upOk ? 'ok' : 'warn'}"><div class="v">${t(upOk ? 'stat.reachable' : 'stat.unreachable')}</div><div class="k">${t('stat.dshUpstream', { url: st.upstream.url })}</div></div>
204
232
  <div class="stat-card"><div class="v">${st.onlineCount}/${st.deviceCount}</div><div class="k">${t('stat.devicesOnline')}</div></div>
205
233
  <div class="stat-card"><div class="v">${st.totalRequests}</div><div class="k">${t('stat.totalRequests')}</div></div>
@@ -434,6 +462,45 @@ $('btn-gateway').addEventListener('click', async () => {
434
462
  setTimeout(loadState, 700)
435
463
  })
436
464
 
465
+ $('btn-save-port').addEventListener('click', async () => {
466
+ const input = $('gateway-port-input')
467
+ const raw = input.value.trim()
468
+ const port = Number(raw)
469
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
470
+ toast(t('toast.portInvalid'), 'err')
471
+ return
472
+ }
473
+ const btn = $('btn-save-port')
474
+ const wasRunning = gatewayRunning
475
+ btn.disabled = true
476
+ try {
477
+ const res = await fetch(`${API}/config`, {
478
+ method: 'PUT',
479
+ headers: { 'content-type': 'application/json', authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' },
480
+ body: JSON.stringify({ port })
481
+ })
482
+ const out = await res.json().catch(() => ({}))
483
+ if (out.ok) {
484
+ const saved = Number(out.port) || port
485
+ const effective = Number(out.effectivePort || out.port) || saved
486
+ if (out.effectivePort && effective !== saved) {
487
+ toast(t('toast.portEnv', { port: effective }), 'ok')
488
+ } else if (wasRunning) {
489
+ toast(t('toast.portSaved', { port: effective }), 'ok')
490
+ } else {
491
+ toast(t('toast.portSavedIdle', { port: effective }), 'ok')
492
+ }
493
+ loadGatewayConfig()
494
+ setTimeout(loadState, 800)
495
+ } else {
496
+ toast(out.error || t('toast.portFailedMsg', { msg: res.status }), 'err')
497
+ }
498
+ } catch (e) {
499
+ toast(t('toast.portFailedMsg', { msg: e.message || e }), 'err')
500
+ }
501
+ btn.disabled = false
502
+ })
503
+
437
504
  function renderLangBtn() {
438
505
  const btn = $('btn-lang')
439
506
  if (btn) btn.textContent = I18N.lang === 'zh' ? 'EN' : '中文'
@@ -521,6 +588,7 @@ function start(showLogin) {
521
588
  }
522
589
  showMain()
523
590
  loadState()
591
+ loadGatewayConfig()
524
592
  loadStats()
525
593
  timer = setInterval(loadState, 5000)
526
594
  if (statsTimer) clearInterval(statsTimer)