dsh-remote-plugin 0.5.2 → 0.5.4

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/public/app.js CHANGED
@@ -1,6 +1,10 @@
1
1
  /* DSH Remote 移动控制台 · 零依赖 */
2
2
  'use strict'
3
3
 
4
+ const I18N = window.I18N
5
+ const t = (k, v) => I18N.t(k, v)
6
+ I18N.init(window.APP_STR)
7
+
4
8
  /* ---------------- 状态 ---------------- */
5
9
  const LS = {
6
10
  get(k, d) { try { return localStorage.getItem(k) ?? d } catch { return d } },
@@ -71,9 +75,9 @@ function toast(text, kind = '') {
71
75
  function fmtTime(ts) {
72
76
  if (!ts) return ''
73
77
  const diff = Date.now() - ts
74
- if (diff < 60e3) return '刚刚'
75
- if (diff < 3600e3) return Math.floor(diff / 60e3) + ' 分钟前'
76
- if (diff < 86400e3) return Math.floor(diff / 3600e3) + ' 小时前'
78
+ if (diff < 60e3) return t('time.justNow')
79
+ if (diff < 3600e3) return Math.floor(diff / 60e3) + t('time.minAgo')
80
+ if (diff < 86400e3) return Math.floor(diff / 3600e3) + t('time.hourAgo')
77
81
  const d = new Date(ts)
78
82
  return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
79
83
  }
@@ -113,10 +117,10 @@ async function rpc(method, payload = {}) {
113
117
  if (res.status === 401) throw new Error('AUTH')
114
118
  if (!res.ok) throw new Error('HTTP ' + res.status)
115
119
  const full = await res.json()
116
- if (!full?.result) throw new Error('坏响应')
120
+ if (!full?.result) throw new Error(t('err.badResponse'))
117
121
  if (!full.result.ok) {
118
122
  const err = full.result.error || {}
119
- throw new Error(err.message || 'DSH 返回错误')
123
+ throw new Error(err.message || t('err.dshError'))
120
124
  }
121
125
  return full.result.value
122
126
  }
@@ -142,9 +146,9 @@ async function safeRpc(method, payload, errText) {
142
146
  }
143
147
 
144
148
  function authFailure() {
145
- toast('访问被拒绝:请检查令牌', 'err')
149
+ toast(t('err.accessDenied'), 'err')
146
150
  showView('view-settings')
147
- $('token-desc').textContent = '令牌无效,点「更换」重新设置'
151
+ $('token-desc').textContent = t('token.invalid')
148
152
  }
149
153
 
150
154
  /* ---------------- 多服务器 + 自动选优 ---------------- */
@@ -195,7 +199,7 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
195
199
  if (state.selectingServer) return null
196
200
  state.selectingServer = true
197
201
  try {
198
- if (!silent) toast('正在测速各服务器…')
202
+ if (!silent) toast(t('speed.testing'))
199
203
  const candidates = serverCandidates()
200
204
  for (const u of candidates) state.serverLatency[u] = await pingServer(u)
201
205
  const best = candidates
@@ -208,11 +212,11 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
208
212
  if (chosen !== state.server) {
209
213
  state.server = chosen
210
214
  saveServers()
211
- if (!silent) toast(`已切换到最快服务器:${chosen || '当前页面'}(${state.serverLatency[best]}ms)`, 'ok')
215
+ if (!silent) toast(t('speed.switched', { url: chosen || t('speed.origin'), ms: state.serverLatency[best] }), 'ok')
212
216
  if (reconnect && state.token) { openStreams(); refreshAll() }
213
217
  } else if (!silent) {
214
- if (best) toast(`当前已是最快:${chosen || '当前页面'}(${state.serverLatency[best]}ms)`, 'ok')
215
- else toast('全部服务器不可达', 'err')
218
+ if (best) toast(t('speed.alreadyBest', { url: chosen || t('speed.origin'), ms: state.serverLatency[best] }), 'ok')
219
+ else toast(t('speed.allDown'), 'err')
216
220
  }
217
221
  return chosen
218
222
  } finally {
@@ -224,41 +228,41 @@ function renderServers() {
224
228
  const box = $('server-list')
225
229
  if (!box) return
226
230
  if (!state.servers.length) {
227
- box.innerHTML = '<div class="server-empty">未添加备用服务器 · 默认使用当前页面地址</div>'
231
+ box.innerHTML = '<div class="server-empty">' + t('servers.empty') + '</div>'
228
232
  } else {
229
233
  box.innerHTML = state.servers.map(u => {
230
234
  const ms = state.serverLatency[u]
231
- let badge = '<span class="server-badge">未测速</span>'
232
- if (Number.isFinite(ms)) badge = `<span class="server-badge ${u === state.server ? 'good' : ''}">${ms}ms${u === state.server ? ' · 当前' : ''}</span>`
233
- else if (ms !== undefined) badge = '<span class="server-badge bad">不可达</span>'
235
+ let badge = '<span class="server-badge">' + t('servers.untested') + '</span>'
236
+ if (Number.isFinite(ms)) badge = `<span class="server-badge ${u === state.server ? 'good' : ''}">${ms}ms${u === state.server ? t('servers.current') : ''}</span>`
237
+ else if (ms !== undefined) badge = '<span class="server-badge bad">' + t('servers.unreachable') + '</span>'
234
238
  return `<div class="server-row ${u === state.server ? 'active' : ''}">
235
239
  <span class="server-url">${esc(u)}</span>${badge}
236
- <button class="server-del" data-del="${esc(u)}" aria-label="删除服务器">✕</button>
240
+ <button class="server-del" data-del="${esc(u)}" aria-label="${t('servers.delete')}">✕</button>
237
241
  </div>`
238
242
  }).join('')
239
243
  box.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', () => removeServer(b.dataset.del)))
240
244
  }
241
245
  $('server-desc').textContent = state.server
242
- ? `当前: ${state.server}`
243
- : (CAP?.isNativePlatform?.() ? '未设置服务器' : '默认 = 当前页面地址')
246
+ ? t('servers.currentDesc', { url: state.server })
247
+ : (CAP?.isNativePlatform?.() ? t('servers.notSet') : t('servers.defaultDesc'))
244
248
  }
245
249
 
246
250
  async function addServer() {
247
251
  const input = $('server-input')
248
252
  let raw = (input?.value || '').trim().replace(/\/+$/, '')
249
- if (!raw) return toast('请输入服务器地址', 'err')
253
+ if (!raw) return toast(t('servers.needAddress'), 'err')
250
254
  try {
251
255
  const u = new URL(raw)
252
256
  if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('bad')
253
257
  } catch {
254
- return toast('地址需以 http:// 或 https:// 开头', 'err')
258
+ return toast(t('servers.badProtocol'), 'err')
255
259
  }
256
- if (state.servers.includes(raw)) return toast('该地址已在列表中')
260
+ if (state.servers.includes(raw)) return toast(t('servers.duplicate'))
257
261
  state.servers.push(raw)
258
262
  saveServers()
259
263
  if (input) input.value = ''
260
264
  renderServers()
261
- toast('已添加服务器', 'ok')
265
+ toast(t('servers.added'), 'ok')
262
266
  if (state.token) selectFastestServer({ silent: false })
263
267
  }
264
268
 
@@ -269,7 +273,7 @@ function removeServer(url) {
269
273
  saveServers()
270
274
  renderServers()
271
275
  if (wasActive) {
272
- toast('已删除当前服务器,重新测速…')
276
+ toast(t('servers.removedActive'))
273
277
  selectFastestServer({ silent: true })
274
278
  }
275
279
  }
@@ -279,11 +283,13 @@ const streams = {}
279
283
  state.streamsOk = { mux: false, host: false }
280
284
 
281
285
  function openStreams() {
286
+ if (!state.token) return
282
287
  openStream('mux', onMuxFrame, true)
283
288
  openStream('host', onHostFrame, false)
284
289
  }
285
290
 
286
291
  function openStream(kind, handler, refreshOnOpen) {
292
+ if (!state.token) return
287
293
  let base
288
294
  if (state.server) {
289
295
  base = state.server.replace(/^http/, 'ws')
@@ -321,7 +327,7 @@ function openStream(kind, handler, refreshOnOpen) {
321
327
  state.streamsOk[kind] = false
322
328
  state.errCount++
323
329
  updateConn()
324
- if (state.errCount === 3) toast('连接中断,正在重连…', 'err')
330
+ if (state.errCount === 3) toast(t('conn.reconnecting'), 'err')
325
331
  // 多服务器: 连续掉线若干次就重测速, 自动换到当前可达的最快地址
326
332
  if (state.servers.length && state.errCount % 5 === 0) setTimeout(() => selectFastestServer({ silent: true }), 300)
327
333
  // 无条件重连; 页面被挂起时定时器暂停, visibilitychange 会再触发一次
@@ -351,12 +357,12 @@ document.addEventListener('visibilitychange', () => {
351
357
  if (document.visibilityState === 'visible') {
352
358
  onResume()
353
359
  if (state.servers.length) selectFastestServer({ silent: true })
354
- else if (streams.mux?.readyState !== WebSocket.OPEN || streams.host?.readyState !== WebSocket.OPEN) openStreams()
360
+ else if (state.token && (streams.mux?.readyState !== WebSocket.OPEN || streams.host?.readyState !== WebSocket.OPEN)) openStreams()
355
361
  }
356
362
  })
357
363
  window.addEventListener('pageshow', onResume)
358
364
  setInterval(() => {
359
- if (document.visibilityState === 'visible') {
365
+ if (document.visibilityState === 'visible' && state.token) {
360
366
  if (streams.mux?.readyState !== WebSocket.OPEN || streams.host?.readyState !== WebSocket.OPEN) openStreams()
361
367
  }
362
368
  }, 15000)
@@ -372,21 +378,21 @@ function onMuxFrame(full) {
372
378
  if (f.type === 'approval/requested') {
373
379
  state.approvals = state.approvals.filter(a => a.approvalId !== f.approvalId)
374
380
  state.approvals.push({ ...f, rpcId: full.rpcId })
375
- notify('工具审批', `${f.toolName || '未知工具'} 需要批准`)
381
+ notify(t('notify.approvalTitle'), t('notify.approvalBody', { tool: f.toolName || t('tool.unknown') }))
376
382
  renderPending(); return
377
383
  }
378
384
  if (f.type === 'approval/resolved') { state.approvals = state.approvals.filter(a => a.approvalId !== f.approvalId); renderPending(); return }
379
385
  if (f.type === 'question/requested') {
380
386
  state.questions = state.questions.filter(q => q.rpcId !== full.rpcId)
381
387
  state.questions.push({ ...f, rpcId: full.rpcId })
382
- notify('DSH 提问', f.questions?.map(q => q.question).join(' / ') || '需要你回答')
388
+ notify(t('notify.questionTitle'), f.questions?.map(q => q.question).join(' / ') || t('notify.questionBody'))
383
389
  renderPending(); return
384
390
  }
385
391
  if (f.type === 'question/resolved') { state.questions = state.questions.filter(q => q.rpcId !== f.questionRpcId); renderPending(); return }
386
392
  if (f.type === 'session/queue') { state.queues[f.sessionId] = f.items || []; renderQueue(); return }
387
393
  if (f.type === 'session/jobs') { state.jobs[f.sessionId] = f.jobs || []; renderJobs(); return }
388
394
  if (f.type === 'session/projection') { applyProjection(f.sessionId, f.key, f.value, f.seq); return }
389
- if (f.type === 'stream/error') { toast('事件流错误:' + (f.error?.message || ''), 'err') }
395
+ if (f.type === 'stream/error') { toast(t('stream.error', { msg: f.error?.message || '' }), 'err') }
390
396
  }
391
397
  function onHostFrame(full) {
392
398
  const f = full.payload
@@ -401,7 +407,7 @@ function onHostFrame(full) {
401
407
  const s = state.byId.get(f.sessionId)
402
408
  if (s) { s.error = true; s.running = false }
403
409
  if (state.current === f.sessionId) { renderSessionSub(); updateSessionStatus() }
404
- return toast(`会话出错:${f.message}`, 'err')
410
+ return toast(t('session.errorMsg', { msg: f.message }), 'err')
405
411
  }
406
412
  if (f.type === 'host/remote-event') return scheduleRefresh()
407
413
  }
@@ -435,7 +441,7 @@ async function refreshAll() {
435
441
  }
436
442
 
437
443
  async function refreshSessions() {
438
- const v = await safeRpc('session.list', {}, '拉取会话列表失败')
444
+ const v = await safeRpc('session.list', {}, t('err.sessionList'))
439
445
  if (!v) {
440
446
  // 网关不可达: 用上次成功的会话列表兜底, 用户仍能打开历史缓存
441
447
  if (!state.sessions.length) {
@@ -444,7 +450,7 @@ async function refreshSessions() {
444
450
  state.sessions = cached
445
451
  state.byId = new Map(cached.map(s => [s.sessionId, s]))
446
452
  renderSessions()
447
- toast('网络不可用:显示本地缓存的会话列表', 'ok')
453
+ toast(t('sessions.cacheFallback'), 'ok')
448
454
  }
449
455
  }
450
456
  return
@@ -492,14 +498,14 @@ function renderSessions() {
492
498
  const dots = []
493
499
  if (s.running) dots.push('running')
494
500
  if (pending) dots.push('pending')
495
- const badge = goal ? `<span class="sc-badge ${goal.phase === 'active' ? 'goal-active' : ''}">目标·${esc(goal.phase || '?')}</span>` : ''
496
- const queueBadge = queueN ? `<span class="sc-badge">队列 ${queueN}</span>` : ''
501
+ const badge = goal ? `<span class="sc-badge ${goal.phase === 'active' ? 'goal-active' : ''}">${esc(t('sessions.goalBadge', { phase: goal.phase || '?' }))}</span>` : ''
502
+ const queueBadge = queueN ? `<span class="sc-badge">${esc(t('sessions.queueBadge', { n: queueN }))}</span>` : ''
497
503
  return `<div class="session-card ${state.current === s.sessionId ? 'current' : ''}" data-id="${esc(s.sessionId)}">
498
504
  <div class="sc-title">${esc(title)}</div>
499
505
  <div class="sc-meta">
500
506
  <span class="sc-dot ${dots.join(' ')}"></span>
501
507
  <span>${fmtTime(s.updatedAt)}</span>
502
- ${s.running ? '<span>运行中</span>' : ''}
508
+ ${s.running ? '<span>' + t('sessions.running') + '</span>' : ''}
503
509
  ${badge}${queueBadge}
504
510
  </div>
505
511
  <span class="sc-arrow">›</span>
@@ -509,9 +515,9 @@ function renderSessions() {
509
515
  const running = state.sessions.filter(s => s.running).length
510
516
  const pending = state.approvals.length + state.questions.length
511
517
  $('stat-strip').innerHTML = `
512
- <div class="stat running"><div class="v">${running}</div><div class="k">运行中</div></div>
513
- <div class="stat pending"><div class="v">${pending}</div><div class="k">待处理</div></div>
514
- <div class="stat ctx"><div class="v">${items.length}</div><div class="k">会话总数</div></div>`
518
+ <div class="stat running"><div class="v">${running}</div><div class="k">${t('sessions.statRunning')}</div></div>
519
+ <div class="stat pending"><div class="v">${pending}</div><div class="k">${t('sessions.statPending')}</div></div>
520
+ <div class="stat ctx"><div class="v">${items.length}</div><div class="k">${t('sessions.statTotal')}</div></div>`
515
521
  updatePendingBadge()
516
522
  }
517
523
 
@@ -522,7 +528,7 @@ async function openSession(id) {
522
528
  document.body.classList.add('in-session')
523
529
  showView('view-session')
524
530
  renderSessionTitle(); renderSessionSub(); updateCancelBtn(); updateSessionStatus()
525
- $('history').innerHTML = '<div class="empty">加载历史…</div>'
531
+ $('history').innerHTML = '<div class="empty">' + t('history.loading') + '</div>'
526
532
  await loadHistory(true)
527
533
  renderSessionCards()
528
534
  refreshSessions()
@@ -555,7 +561,7 @@ function bindNativeBack() {
555
561
 
556
562
  function renderSessionTitle() {
557
563
  const s = state.byId.get(state.current)
558
- $('session-title').textContent = s ? titleOf(s) : '未知会话'
564
+ $('session-title').textContent = s ? titleOf(s) : t('session.unknown')
559
565
  }
560
566
 
561
567
  function renderSessionSub() {
@@ -563,8 +569,8 @@ function renderSessionSub() {
563
569
  if (!s) { $('session-sub').textContent = ''; return }
564
570
  const parts = [short(s.sessionId)]
565
571
  if (s.cwd) parts.push(s.cwd)
566
- if (s.running) parts.push('运行中')
567
- else if (s.error) parts.push('已中断')
572
+ if (s.running) parts.push(t('session.running'))
573
+ else if (s.error) parts.push(t('session.interrupted'))
568
574
  $('session-sub').textContent = parts.join(' · ')
569
575
  }
570
576
 
@@ -642,7 +648,7 @@ function restoreCachedHistory() {
642
648
  }
643
649
  h.visible.sort((a, b) => a.seq - b.seq)
644
650
  state.history = h
645
- $('history-hint').textContent = `离线缓存 ${h.visible.length} 条`
651
+ $('history-hint').textContent = t('history.offlineCache', { n: h.visible.length })
646
652
  renderHistory(true)
647
653
  return true
648
654
  }
@@ -662,8 +668,8 @@ async function loadHistory(reset) {
662
668
  } catch (e) {
663
669
  state.history.loading = false
664
670
  if (e.message === 'AUTH') { authFailure(); return }
665
- if (restoreCachedHistory()) toast('网络不可用:显示本地缓存的历史', 'ok')
666
- else toast('加载历史失败:' + e.message, 'err')
671
+ if (restoreCachedHistory()) toast(t('history.cacheFallback'), 'ok')
672
+ else toast(t('history.loadFailed', { msg: e.message }), 'err')
667
673
  return
668
674
  }
669
675
 
@@ -688,7 +694,7 @@ async function loadHistory(reset) {
688
694
  if (reset) renderHistory(true)
689
695
  else if (added) renderHistory(false, 'keep')
690
696
  if (moreBtn) moreBtn.classList.toggle('hidden', !state.history.hasMore)
691
- $('history-hint').textContent = state.history.visible.length ? `${state.history.visible.length} 条` : ''
697
+ $('history-hint').textContent = state.history.visible.length ? t('history.count', { n: state.history.visible.length }) : ''
692
698
  scheduleHistoryCacheSave()
693
699
  }
694
700
 
@@ -727,7 +733,7 @@ function renderHistory(reset, mode = 'bottom') {
727
733
  const filtered = filteredEntries()
728
734
  const len = filtered.length
729
735
  if (!len) {
730
- box.innerHTML = '<div class="empty">还没有消息</div>'
736
+ box.innerHTML = '<div class="empty">' + t('history.empty') + '</div>'
731
737
  h.renderStart = 0; h.renderEnd = 0
732
738
  updateRail()
733
739
  return
@@ -843,19 +849,19 @@ function eventHtml(entry, ctx = {}) {
843
849
  const msg = data.message || {}
844
850
  const role = data.role || msg.role || (type.startsWith('user') ? 'user' : 'assistant')
845
851
  const blocks = msg.content || data.content || []
846
- inner = `<div class="msg ${esc(role)}" data-seq="${seq}"><div class="role">${esc(role === 'user' ? '' : 'DSH')}</div>${blocks.map(blockHtml).join('')}</div>`
852
+ inner = `<div class="msg ${esc(role)}" data-seq="${seq}"><div class="role">${esc(role === 'user' ? t('role.me') : t('role.dsh'))}</div>${blocks.map(blockHtml).join('')}</div>`
847
853
  } else if (type === 'tool/call') {
848
- const name = data.name || data.toolName || '工具'
854
+ const name = data.name || data.toolName || t('tool.default')
849
855
  const step = (data.turn != null ? ` · turn ${data.turn}` : '') + (data.step != null ? `.${data.step}` : '')
850
856
  inner = `<details class="tool" data-seq="${seq}"><summary>🔧 ${esc(name)}<span class="tool-meta-inline">${esc(step)}</span></summary><pre>${esc(safeJson(data.arguments ?? data.args ?? data.input ?? data))}</pre></details>`
851
857
  } else if (type === 'tool/result') {
852
858
  const callId = data.callId || data.message?.source?.callId
853
- const name = (callId && ctx.toolNames?.get(callId)) || '结果'
859
+ const name = (callId && ctx.toolNames?.get(callId)) || t('tool.result')
854
860
  const err = data.error || data.ok === false
855
- inner = `<details class="tool result ${err ? 'error' : ''}" data-seq="${seq}"><summary>📦 ${esc(name)}<span class="tool-meta-inline">结果</span></summary><pre>${esc(truncate(safeJson(data.result ?? data.output ?? data.message ?? data), 4000))}</pre></details>`
861
+ inner = `<details class="tool result ${err ? 'error' : ''}" data-seq="${seq}"><summary>📦 ${esc(name)}<span class="tool-meta-inline">${t('tool.result')}</span></summary><pre>${esc(truncate(safeJson(data.result ?? data.output ?? data.message ?? data), 4000))}</pre></details>`
856
862
  } else if (type === 'agent/status') {
857
863
  const running = !!data.running
858
- inner = `<div class="event" data-seq="${seq}">${running ? '▶ 任务开始' : '■ 任务结束'}</div>`
864
+ inner = `<div class="event" data-seq="${seq}">${running ? t('event.taskStart') : t('event.taskEnd')}</div>`
859
865
  } else if (type === 'llm/usage') {
860
866
  inner = `<div class="event" data-seq="${seq}">tokens ${fmtTokens(data.inputTokens)} → ${fmtTokens(data.outputTokens)}</div>`
861
867
  } else if (type === 'checkpoint/created' || type === 'compaction/complete' || type === 'compaction/summary') {
@@ -871,16 +877,16 @@ function blockHtml(b) {
871
877
  if ((b.type === 'tool-call' || b.type === 'tool-result') && LS.get('showTools', '1') === '0') return ''
872
878
  switch (b.type) {
873
879
  case 'text': return `<div>${renderMarkdown(b.text ?? '')}</div>`
874
- case 'image': return `<img alt="图片" src="data:${esc(b.mediaType || 'image/png')};base64,${esc(b.data || '')}">`
880
+ case 'image': return `<img alt="${t('block.image')}" src="data:${esc(b.mediaType || 'image/png')};base64,${esc(b.data || '')}">`
875
881
  case 'thinking':
876
882
  case 'reasoning':
877
- return `<details class="tool"><summary>🧠 思考过程</summary><div class="tool-text">${esc(truncate(String(b.text ?? b.content ?? safeJson(b)), 6000))}</div></details>`
883
+ return `<details class="tool"><summary>${t('block.thinking')}</summary><div class="tool-text">${esc(truncate(String(b.text ?? b.content ?? safeJson(b)), 6000))}</div></details>`
878
884
  case 'code': return `<pre>${esc(b.content ?? b.code ?? '')}</pre>`
879
885
  case 'tool-call':
880
- return `<details class="tool"><summary>🔧 ${esc(b.name || b.toolName || '工具调用')}</summary><pre>${esc(truncate(safeJson(b.arguments ?? b), 4000))}</pre></details>`
886
+ return `<details class="tool"><summary>🔧 ${esc(b.name || b.toolName || t('block.toolCall'))}</summary><pre>${esc(truncate(safeJson(b.arguments ?? b), 4000))}</pre></details>`
881
887
  case 'tool-result':
882
- return `<details class="tool result"><summary>📦 ${esc(b.name || b.toolName || '工具结果')}</summary><pre>${esc(truncate(safeJson(b.content ?? b), 4000))}</pre></details>`
883
- default: return `<details class="tool"><summary>块 · ${esc(b.type || '?')}</summary><pre>${esc(truncate(safeJson(b), 2000))}</pre></details>`
888
+ return `<details class="tool result"><summary>📦 ${esc(b.name || b.toolName || t('block.toolResult'))}</summary><pre>${esc(truncate(safeJson(b.content ?? b), 4000))}</pre></details>`
889
+ default: return `<details class="tool"><summary>${esc(t('block.unknown', { type: b.type || '?' }))}</summary><pre>${esc(truncate(safeJson(b), 2000))}</pre></details>`
884
890
  }
885
891
  }
886
892
 
@@ -902,7 +908,7 @@ function safeJson(v) {
902
908
  try { return typeof v === 'string' ? v : JSON.stringify(v, null, 2) }
903
909
  catch { return String(v) }
904
910
  }
905
- function truncate(s, n) { return String(s).length > n ? String(s).slice(0, n) + '…(截断)' : s }
911
+ function truncate(s, n) { return String(s).length > n ? String(s).slice(0, n) + t('truncated') : s }
906
912
 
907
913
  /* 会话卡片(goal/todo/subagents); 统计进顶栏 📊 弹窗 */
908
914
  function statsHtml(s) {
@@ -913,15 +919,15 @@ function statsHtml(s) {
913
919
  let html = ''
914
920
  if (stats) {
915
921
  const llmMin = stats.llmMs ? (stats.llmMs / 60000).toFixed(1) : null
916
- html += `<div class="card"><div class="card-title">本轮统计</div>
917
- <div class="card-row"><span class="k">轮次 / 步骤</span><span class="v">${stats.turns ?? '—'} / ${stats.steps ?? '—'}</span></div>
918
- <div class="card-row"><span class="k">模型耗时</span><span class="v">${llmMin ? llmMin + ' 分钟' : '—'}</span></div>
919
- ${usage ? `<div class="card-row"><span class="k">输出 / 缓存读</span><span class="v">${fmtTokens(usage.outputTokens)} / ${fmtTokens(usage.cacheReadTokens)}</span></div>` : ''}
920
- ${ctx ? `<div class="card-row"><span class="k">上下文压力</span><span class="v">${fmtTokens(ctx.pressureTokens)} / ${fmtTokens(ctx.contextWindow)}</span></div>` : ''}
921
- ${perms?.currentValue ? `<div class="card-row"><span class="k">权限</span><span class="v">${esc(perms.currentValue)}</span></div>` : ''}
922
+ html += `<div class="card"><div class="card-title">${t('stats.roundTitle')}</div>
923
+ <div class="card-row"><span class="k">${t('stats.turnsSteps')}</span><span class="v">${stats.turns ?? '—'} / ${stats.steps ?? '—'}</span></div>
924
+ <div class="card-row"><span class="k">${t('stats.llmTime')}</span><span class="v">${llmMin ? llmMin + t('stats.minutes') : '—'}</span></div>
925
+ ${usage ? `<div class="card-row"><span class="k">${t('stats.outputCache')}</span><span class="v">${fmtTokens(usage.outputTokens)} / ${fmtTokens(usage.cacheReadTokens)}</span></div>` : ''}
926
+ ${ctx ? `<div class="card-row"><span class="k">${t('stats.ctxPressure')}</span><span class="v">${fmtTokens(ctx.pressureTokens)} / ${fmtTokens(ctx.contextWindow)}</span></div>` : ''}
927
+ ${perms?.currentValue ? `<div class="card-row"><span class="k">${t('stats.permission')}</span><span class="v">${esc(perms.currentValue)}</span></div>` : ''}
922
928
  </div>`
923
929
  }
924
- return html || '<div class="empty">暂无统计数据</div>'
930
+ return html || '<div class="empty">' + t('stats.empty') + '</div>'
925
931
  }
926
932
 
927
933
  async function renderSessionCards() {
@@ -935,18 +941,18 @@ async function renderSessionCards() {
935
941
  let html = ''
936
942
 
937
943
  if (goal) {
938
- html += `<div class="card"><div class="card-title">目标</div>
944
+ html += `<div class="card"><div class="card-title">${t('goal.title')}</div>
939
945
  <div class="goal-obj">${esc(goal.objective || '')}</div>
940
946
  <div class="goal-phase">phase: ${esc(goal.phase || '?')} · revision ${goal.revision ?? '?'}</div>
941
947
  <div class="goal-actions">
942
- ${goal.phase === 'active' ? '<button class="mini-btn" data-goal="pause">暂停</button>' : '<button class="mini-btn" data-goal="resume">继续</button>'}
943
- <button class="mini-btn" data-goal="complete">完成</button>
944
- <button class="mini-btn" data-goal="edit">改目标</button>
945
- <button class="mini-btn" data-goal="clear">清除</button>
948
+ ${goal.phase === 'active' ? '<button class="mini-btn" data-goal="pause">' + t('goal.pause') + '</button>' : '<button class="mini-btn" data-goal="resume">' + t('goal.resume') + '</button>'}
949
+ <button class="mini-btn" data-goal="complete">${t('goal.complete')}</button>
950
+ <button class="mini-btn" data-goal="edit">${t('goal.edit')}</button>
951
+ <button class="mini-btn" data-goal="clear">${t('goal.clear')}</button>
946
952
  </div></div>`
947
953
  }
948
954
  if (todos?.items?.length) {
949
- html += `<div class="card"><div class="card-title">任务清单</div>${todos.items.map(t =>
955
+ html += `<div class="card"><div class="card-title">${t('todos.title')}</div>${todos.items.map(t =>
950
956
  `<div><span class="pill ${t.status === 'completed' ? 'done' : t.status === 'in_progress' ? 'active' : ''}">${esc(t.status || 'pending')}</span>${esc(t.content || '')}</div>`
951
957
  ).join('')}</div>`
952
958
  }
@@ -958,12 +964,12 @@ async function renderSessionCards() {
958
964
  const sub = await safeRpc('subagent.list', { parentSessionId: state.current })
959
965
  if (sub?.entries?.length) {
960
966
  const rows = sub.entries.map(e => {
961
- if (e.kind === 'diagnostic') return `<div class="card-row"><span class="k">诊断项</span><span class="v">${esc(e.reason)}</span></div>`
967
+ if (e.kind === 'diagnostic') return `<div class="card-row"><span class="k">${t('subagent.diagnostic')}</span><span class="v">${esc(e.reason)}</span></div>`
962
968
  const label = e.label || short(e.id)
963
969
  const running = e.activity === 'running'
964
- return `<div class="card-row"><span class="k">${running ? '▶ ' : ''}${esc(label)}</span><span class="v">${esc(e.mode)} ${running ? '· 运行中' : ''}${e.mode === 'continuable' && running ? ` <button class="mini-btn" data-sub-interrupt="${esc(e.id)}">中断</button>` : ''}</span></div>`
970
+ return `<div class="card-row"><span class="k">${running ? '▶ ' : ''}${esc(label)}</span><span class="v">${esc(e.mode)} ${running ? t('subagent.running') : ''}${e.mode === 'continuable' && running ? ` <button class="mini-btn" data-sub-interrupt="${esc(e.id)}">${t('subagent.interrupt')}</button>` : ''}</span></div>`
965
971
  }).join('')
966
- box.insertAdjacentHTML('beforeend', `<div class="card"><div class="card-title">子代理</div>${rows}</div>`)
972
+ box.insertAdjacentHTML('beforeend', `<div class="card"><div class="card-title">${t('subagent.title')}</div>${rows}</div>`)
967
973
  box.querySelectorAll('[data-sub-interrupt]').forEach(btn =>
968
974
  btn.addEventListener('click', () => interruptSubagent(btn.dataset.subInterrupt)))
969
975
  }
@@ -972,23 +978,23 @@ async function renderSessionCards() {
972
978
  async function goalAction(kind) {
973
979
  const s = state.byId.get(state.current)
974
980
  const goal = goalOf(s)
975
- if (!goal) return toast('当前会话没有目标')
981
+ if (!goal) return toast(t('goal.none'))
976
982
  const ref = { id: goal.id, revision: goal.revision }
977
983
  if (kind === 'edit') return openGoalModal(goal)
978
984
  const map = { pause: 'goal.pause', resume: 'goal.resume', complete: 'goal.complete', clear: 'goal.clear' }
979
985
  const method = map[kind]
980
986
  if (!method) return
981
- if (kind === 'clear' && !confirm('清除当前目标?(不会删除会话)')) return
982
- if (kind === 'complete' && !confirm('将目标标记为完成?')) return
983
- await safeRpc(method, { sessionId: state.current, ref }, '目标操作失败')
984
- toast('目标操作已提交', 'ok')
987
+ if (kind === 'clear' && !confirm(t('goal.confirmClear'))) return
988
+ if (kind === 'complete' && !confirm(t('goal.confirmComplete'))) return
989
+ await safeRpc(method, { sessionId: state.current, ref }, t('goal.actionFailed'))
990
+ toast(t('goal.actionSubmitted'), 'ok')
985
991
  scheduleRefresh()
986
992
  }
987
993
 
988
994
  async function interruptSubagent(childId) {
989
- if (!confirm('中断这个子代理当前回合?')) return
990
- await safeRpc('subagent.interrupt', { parentSessionId: state.current, childSessionId: childId, mode: 'continuable' }, '中断失败')
991
- toast('中断请求已提交', 'ok')
995
+ if (!confirm(t('subagent.confirmInterrupt'))) return
996
+ await safeRpc('subagent.interrupt', { parentSessionId: state.current, childSessionId: childId, mode: 'continuable' }, t('subagent.interruptFailed'))
997
+ toast(t('subagent.interruptSubmitted'), 'ok')
992
998
  setTimeout(renderSessionCards, 600)
993
999
  }
994
1000
 
@@ -1001,10 +1007,10 @@ async function sendSessionText(text) {
1001
1007
  sessionId: state.current,
1002
1008
  mode: 'queue',
1003
1009
  content: [{ type: 'text', text: clean }]
1004
- }, '发送失败')
1010
+ }, t('send.failed'))
1005
1011
  $('btn-send').disabled = false
1006
- if (v?.accepted) { toast(clean.startsWith('/') ? '指令已发送' : '已发送', 'ok'); return true }
1007
- if (v?.command?.text) { toast('命令已执行', 'ok'); return true }
1012
+ if (v?.accepted) { toast(clean.startsWith('/') ? t('send.commandSent') : t('send.sent'), 'ok'); return true }
1013
+ if (v?.command?.text) { toast(t('send.commandExecuted'), 'ok'); return true }
1008
1014
  return false
1009
1015
  }
1010
1016
 
@@ -1040,7 +1046,7 @@ async function loadSessionModels() {
1040
1046
  state.models.loaded = true
1041
1047
  } catch (e) {
1042
1048
  if (e.message === 'AUTH') { authFailure(); return }
1043
- toast('模型列表加载失败:' + e.message, 'err')
1049
+ toast(t('models.loadFailed', { msg: e.message }), 'err')
1044
1050
  }
1045
1051
  state.models.loading = false
1046
1052
  renderModelMenu()
@@ -1049,10 +1055,10 @@ async function loadSessionModels() {
1049
1055
  function renderModelMenu() {
1050
1056
  const box = $('menu-models')
1051
1057
  if (!box) return
1052
- if (state.models.loading) { box.innerHTML = '<span>模型加载中…</span>'; return }
1058
+ if (state.models.loading) { box.innerHTML = '<span>' + t('models.loading') + '</span>'; return }
1053
1059
  const groups = state.models.groups || []
1054
1060
  if (!groups.length) {
1055
- box.innerHTML = '<span>' + ((state.models.failures || []).map(f => f.name + '不可用').join(';') || '没有可用模型') + '</span>'
1061
+ box.innerHTML = '<span>' + ((state.models.failures || []).map(f => f.name + ' ' + t('models.unavailable')).join(';') || t('models.none')) + '</span>'
1056
1062
  const effortGroup = $('menu-effort-group')
1057
1063
  if (effortGroup) effortGroup.classList.add('hidden')
1058
1064
  return
@@ -1093,11 +1099,11 @@ async function selectSessionEffort(effortId) {
1093
1099
  if (!state.current || !cur) return
1094
1100
  const v = await safeRpc('session.selectModel', {
1095
1101
  sessionId: state.current, provider: cur.provider, model: cur.model, reasoningEffort: effortId
1096
- }, '切换思考深度失败')
1102
+ }, t('models.effortFailed'))
1097
1103
  if (v?.selected) {
1098
1104
  state.models.current = v.selected
1099
1105
  renderEffortMenu()
1100
- toast(`思考深度:${effortId}`, 'ok')
1106
+ toast(t('models.effortSwitched', { effort: effortId }), 'ok')
1101
1107
  }
1102
1108
  }
1103
1109
 
@@ -1108,25 +1114,25 @@ async function selectSessionModel(provider, modelId) {
1108
1114
  const payload = { sessionId: state.current, provider, model: modelId }
1109
1115
  const effort = model?.reasoning?.defaultEffort || model?.reasoning?.efforts?.[0]?.id
1110
1116
  if (effort) payload.reasoningEffort = effort
1111
- const v = await safeRpc('session.selectModel', payload, '切换模型失败')
1117
+ const v = await safeRpc('session.selectModel', payload, t('models.switchFailed'))
1112
1118
  if (v?.selected) {
1113
1119
  state.models.current = v.selected
1114
1120
  renderModelMenu()
1115
- toast(`已切换模型:${v.selected.model}`, 'ok')
1121
+ toast(t('models.switched', { model: v.selected.model }), 'ok')
1116
1122
  }
1117
1123
  }
1118
1124
 
1119
1125
  async function cancelSession() {
1120
1126
  if (!state.current) return
1121
- if (!confirm('停止当前会话正在运行的任务?')) return
1122
- const v = await safeRpc('session.cancel', { sessionId: state.current }, '停止失败')
1123
- if (v?.accepted) toast('已请求停止', 'ok')
1127
+ if (!confirm(t('session.confirmStop'))) return
1128
+ const v = await safeRpc('session.cancel', { sessionId: state.current }, t('session.stopFailed'))
1129
+ if (v?.accepted) toast(t('session.stopRequested'), 'ok')
1124
1130
  }
1125
1131
 
1126
1132
  async function newSession() {
1127
- const v = await safeRpc('session.create', {}, '新建会话失败')
1133
+ const v = await safeRpc('session.create', {}, t('home.createFailed'))
1128
1134
  if (!v?.sessionId) return
1129
- toast('会话已创建', 'ok')
1135
+ toast(t('home.created'), 'ok')
1130
1136
  await refreshSessions()
1131
1137
  openSession(v.sessionId)
1132
1138
  }
@@ -1138,27 +1144,27 @@ function renderPending() {
1138
1144
  ...state.approvals.map(a => ({ kind: 'approval', a })),
1139
1145
  ...state.questions.map(q => ({ kind: 'question', q }))
1140
1146
  ]
1141
- $('pending-count').textContent = items.length ? `${items.length} 项` : ''
1147
+ $('pending-count').textContent = items.length ? t('pending.count', { n: items.length }) : ''
1142
1148
  list.innerHTML = items.length ? items.map(it => {
1143
1149
  if (it.kind === 'approval') {
1144
1150
  const a = it.a
1145
1151
  const title = titleOf(state.byId.get(a.sessionId))
1146
1152
  return `<div class="pending-card approval" data-approval="${esc(a.approvalId)}">
1147
- <div class="pc-title">🔧 ${esc(a.toolName || '工具')} 请求批准</div>
1148
- <div class="pc-desc">${esc(a.reason || '无说明')}</div>
1153
+ <div class="pc-title">${esc(t('pending.approvalTitle', { tool: a.toolName || t('tool.default') }))}</div>
1154
+ <div class="pc-desc">${esc(a.reason || t('pending.noReason'))}</div>
1149
1155
  <div class="pc-session">${esc(title)}</div>
1150
- <div class="goal-actions"><button class="mini-btn" data-approve="1">允许</button><button class="mini-btn" data-approve="0">拒绝</button></div>
1156
+ <div class="goal-actions"><button class="mini-btn" data-approve="1">${t('pending.allow')}</button><button class="mini-btn" data-approve="0">${t('pending.reject')}</button></div>
1151
1157
  </div>`
1152
1158
  }
1153
1159
  const q = it.q
1154
1160
  const title = titleOf(state.byId.get(q.sessionId))
1155
1161
  return `<div class="pending-card question" data-question="${esc(q.rpcId)}">
1156
- <div class="pc-title">❓ ${esc(q.questions?.[0]?.question || 'DSH 提问')}</div>
1157
- <div class="pc-desc">${q.questions?.length > 1 ? `共 ${q.questions.length} 个问题` : ''}</div>
1162
+ <div class="pc-title">❓ ${esc(q.questions?.[0]?.question || t('notify.questionTitle'))}</div>
1163
+ <div class="pc-desc">${q.questions?.length > 1 ? t('pending.questionCount', { n: q.questions.length }) : ''}</div>
1158
1164
  <div class="pc-session">${esc(title)}</div>
1159
- <div class="goal-actions"><button class="mini-btn" data-answer="1">去回答</button></div>
1165
+ <div class="goal-actions"><button class="mini-btn" data-answer="1">${t('pending.answer')}</button></div>
1160
1166
  </div>`
1161
- }).join('') : '<div class="empty">暂无待处理事项</div>'
1167
+ }).join('') : '<div class="empty">' + t('pending.empty') + '</div>'
1162
1168
  list.querySelectorAll('[data-approve]').forEach(btn => {
1163
1169
  const card = btn.closest('[data-approval]')
1164
1170
  btn.addEventListener('click', () => approveApproval(card?.dataset.approval || '', btn.dataset.approve === '1'))
@@ -1172,7 +1178,7 @@ async function approveApproval(id, allow) {
1172
1178
  const a = state.approvals.find(x => x.approvalId === id)
1173
1179
  if (!a) return
1174
1180
  const ok = await respond(a.rpcId, { sessionId: a.sessionId, approvalId: a.approvalId, outcome: allow ? 'allowed-once' : 'rejected' })
1175
- toast(ok ? (allow ? '已允许' : '已拒绝') : '审批已不在待处理状态', ok ? 'ok' : 'err')
1181
+ toast(ok ? (allow ? t('pending.allowed') : t('pending.rejected')) : t('pending.stale'), ok ? 'ok' : 'err')
1176
1182
  state.approvals = state.approvals.filter(x => x.approvalId !== id)
1177
1183
  renderPending()
1178
1184
  }
@@ -1185,7 +1191,7 @@ function openQuestionModal(q) {
1185
1191
  <div class="q-text">${esc(item.header ? item.header + ':' : '')}${esc(item.question)}</div>
1186
1192
  ${(item.options || []).map((o, j) => `
1187
1193
  <label class="q-option"><input type="${item.multiSelect ? 'checkbox' : 'radio'}" name="q${i}" value="${esc(o.label)}" data-q="${i}"><span>${esc(o.label)}${o.description ? `<div class="muted">${esc(o.description)}</div>` : ''}</span></label>`).join('')}
1188
- <textarea rows="2" placeholder="其他 / 自定义回答(可选)" data-qcustom="${i}"></textarea>
1194
+ <textarea rows="2" placeholder="${t('question.customPlaceholder')}" data-qcustom="${i}"></textarea>
1189
1195
  </div>`).join('')
1190
1196
  $('modal-question').classList.remove('hidden')
1191
1197
  }
@@ -1201,10 +1207,10 @@ async function submitQuestion() {
1201
1207
  if (!sel.length && !custom) return null
1202
1208
  return ans
1203
1209
  }).filter(Boolean)
1204
- if (!answers.length) return toast('请先选择或填写回答', 'err')
1210
+ if (!answers.length) return toast(t('question.needAnswer'), 'err')
1205
1211
  const ok = await respond(q.rpcId, { sessionId: q.sessionId, answer: { answers } })
1206
- if (ok) { toast('已提交回答', 'ok'); $('modal-question').classList.add('hidden'); state.questions = state.questions.filter(x => x.rpcId !== q.rpcId); renderPending() }
1207
- else toast('提问已不在待处理状态', 'err')
1212
+ if (ok) { toast(t('question.submitted'), 'ok'); $('modal-question').classList.add('hidden'); state.questions = state.questions.filter(x => x.rpcId !== q.rpcId); renderPending() }
1213
+ else toast(t('question.stale'), 'err')
1208
1214
  }
1209
1215
 
1210
1216
  /* ---------------- 后台任务 ---------------- */
@@ -1214,14 +1220,14 @@ function renderQueue() {
1214
1220
  const items = state.queues[state.current] || []
1215
1221
  updateCancelBtn()
1216
1222
  // 队列数量在会话列表已显示; 详情页不重复大 UI
1217
- $('history-hint').textContent = items.length ? `队列 ${items.length} · 历史 ${state.history.visible.length}` : `历史 ${state.history.visible.length}`
1223
+ $('history-hint').textContent = items.length ? t('history.queueAndCount', { q: items.length, n: state.history.visible.length }) : t('history.countOnly', { n: state.history.visible.length })
1218
1224
  renderSessions()
1219
1225
  }
1220
1226
 
1221
1227
  function renderJobs() {
1222
1228
  const box = $('jobs-list')
1223
1229
  const all = Object.entries(state.jobs).filter(([, jobs]) => jobs?.length)
1224
- if (!all.length) { box.innerHTML = '<div class="empty">暂无后台任务</div>'; return }
1230
+ if (!all.length) { box.innerHTML = '<div class="empty">' + t('jobs.empty') + '</div>'; return }
1225
1231
  box.innerHTML = all.flatMap(([sid, jobs]) => jobs.map(j => {
1226
1232
  const title = titleOf(state.byId.get(sid))
1227
1233
  return `<div class="job-card">
@@ -1264,21 +1270,21 @@ function fsAuthError(status) {
1264
1270
 
1265
1271
  async function loadFs(dir, { silent = false, resetRoot = false } = {}) {
1266
1272
  if (!state.token) {
1267
- $('fs-path').textContent = '未设置令牌'
1268
- $('fs-list').innerHTML = '<div class="empty">请先到「设置」页粘贴网关令牌</div>'
1273
+ $('fs-path').textContent = t('fs.noToken')
1274
+ $('fs-list').innerHTML = '<div class="empty">' + t('fs.goSettings') + '</div>'
1269
1275
  return
1270
1276
  }
1271
1277
  if (resetRoot) { state.fs.initial = null; state.fs.path = null }
1272
1278
  const target = dir ?? state.fs.path ?? ''
1273
1279
  if (!silent) {
1274
- $('fs-list').innerHTML = '<div class="empty">加载中…</div>'
1275
- $('fs-path').textContent = target ? '…' + target.slice(-40) : '加载中…'
1280
+ $('fs-list').innerHTML = '<div class="empty">' + t('fs.loading') + '</div>'
1281
+ $('fs-path').textContent = target ? '…' + target.slice(-40) : t('fs.loading')
1276
1282
  }
1277
1283
  try {
1278
1284
  const res = await fetch(fsApiUrl('/list', target ? { path: target } : {}), { headers: fsHeaders() })
1279
1285
  if (res.status === 401) { fsAuthError(401); return }
1280
1286
  const data = await res.json().catch(() => ({}))
1281
- if (!res.ok || !Array.isArray(data.entries)) throw new Error(data.error === 'not-found' ? '目录不存在' : data.error === 'forbidden' ? '路径不在允许范围内' : data.error || ('HTTP ' + res.status))
1287
+ if (!res.ok || !Array.isArray(data.entries)) throw new Error(data.error === 'not-found' ? t('fs.notFound') : data.error === 'forbidden' ? t('fs.forbidden') : data.error || ('HTTP ' + res.status))
1282
1288
  state.fs.path = data.path
1283
1289
  if (!state.fs.initial) state.fs.initial = data.path
1284
1290
  state.fs.loaded = true
@@ -1286,8 +1292,8 @@ async function loadFs(dir, { silent = false, resetRoot = false } = {}) {
1286
1292
  } catch (e) {
1287
1293
  if (e.message === 'AUTH') return
1288
1294
  $('fs-path').textContent = target || '~'
1289
- $('fs-list').innerHTML = `<div class="empty">加载失败:${esc(e.message || '网络错误')}</div>`
1290
- if (!silent) toast('文件列表加载失败:' + e.message, 'err')
1295
+ $('fs-list').innerHTML = `<div class="empty">${esc(t('fs.loadFailed', { msg: e.message || t('fs.networkError') }))}</div>`
1296
+ if (!silent) toast(t('fs.loadFailedToast', { msg: e.message }), 'err')
1291
1297
  }
1292
1298
  }
1293
1299
 
@@ -1295,7 +1301,7 @@ function renderFs(data) {
1295
1301
  $('fs-path').textContent = data.path || '~'
1296
1302
  const list = $('fs-list')
1297
1303
  if (!data.entries.length) {
1298
- list.innerHTML = '<div class="empty">空目录</div>'
1304
+ list.innerHTML = '<div class="empty">' + t('fs.emptyDir') + '</div>'
1299
1305
  return
1300
1306
  }
1301
1307
  list.innerHTML = data.entries.map(e => {
@@ -1304,7 +1310,7 @@ function renderFs(data) {
1304
1310
  <span class="fs-ico">${isDir ? '📁' : '📄'}</span>
1305
1311
  <span class="fs-meta">
1306
1312
  <span class="fs-name">${esc(e.name)}</span>
1307
- <span class="fs-sub">${isDir ? '目录' : fmtSize(e.size)} · ${fmtFullTime(e.mtimeMs)}</span>
1313
+ <span class="fs-sub">${isDir ? t('fs.dir') : fmtSize(e.size)} · ${fmtFullTime(e.mtimeMs)}</span>
1308
1314
  </span>
1309
1315
  <span class="fs-arrow">${isDir ? '›' : '↓'}</span>
1310
1316
  </div>`
@@ -1327,13 +1333,13 @@ function downloadFsFile(name) {
1327
1333
  if (window.NativeFile?.downloadToDownloads) {
1328
1334
  try {
1329
1335
  window.NativeFile.downloadToDownloads(url, name, state.token)
1330
- toast('开始下载到「下载/dsh-remote」目录', 'ok')
1336
+ toast(t('fs.downloadStarted'), 'ok')
1331
1337
  } catch (e) {
1332
- toast('无法启动下载:' + (e?.message || ''), 'err')
1338
+ toast(t('fs.downloadFailed', { msg: e?.message || '' }), 'err')
1333
1339
  }
1334
1340
  return
1335
1341
  }
1336
- toast('当前 App 版本不支持系统下载,请先更新 App', 'err')
1342
+ toast(t('fs.downloadUnsupported'), 'err')
1337
1343
  return
1338
1344
  }
1339
1345
  // 浏览器控制台: <a download> + ?token= 兜底(主通道仍是 Bearer 头)
@@ -1368,7 +1374,7 @@ function setFsButtons(show, paused = false) {
1368
1374
  if (!pauseBtn || !cancelBtn) return
1369
1375
  pauseBtn.classList.toggle('hidden', !show)
1370
1376
  cancelBtn.classList.toggle('hidden', !show)
1371
- if (show) pauseBtn.textContent = paused ? '继续' : '暂停'
1377
+ if (show) pauseBtn.textContent = paused ? t('fs.resume') : t('fs.pause')
1372
1378
  }
1373
1379
 
1374
1380
  function pauseFsUpload() {
@@ -1402,7 +1408,7 @@ async function cancelFsUpload() {
1402
1408
  state.fs.upload = null
1403
1409
  hideFsProgress()
1404
1410
  setFsButtons(false)
1405
- toast('已取消上传')
1411
+ toast(t('fs.uploadCancelled'))
1406
1412
  }
1407
1413
 
1408
1414
  const FS_CHUNK_SIZE = 4 * 1024 * 1024 // 4MB/块, 断线后重选同一文件自动续传
@@ -1413,7 +1419,7 @@ async function hashFsRange(file, hasher, start, end, up) {
1413
1419
  for (let off = start; off < end; off += step) {
1414
1420
  const buf = await file.slice(off, Math.min(off + step, end)).arrayBuffer()
1415
1421
  if (up?.pauseRequested) {
1416
- const err = new Error('已暂停'); err.code = 'PAUSED'; throw err
1422
+ const err = new Error(t('fs.pausedErr')); err.code = 'PAUSED'; throw err
1417
1423
  }
1418
1424
  hasher.update(new Uint8Array(buf))
1419
1425
  }
@@ -1421,9 +1427,9 @@ async function hashFsRange(file, hasher, start, end, up) {
1421
1427
 
1422
1428
  function uploadFsFile(file) {
1423
1429
  if (!file) return
1424
- if (!state.token) { toast('请先到「设置」页设置令牌', 'err'); showView('view-settings'); return }
1425
- if (file.size > 2 * 1024 * 1024 * 1024) { toast('单文件超过 2GB 上限', 'err'); return }
1426
- if (state.fs.upload?.active) { toast('已有上传任务,先暂停或取消', 'err'); return }
1430
+ if (!state.token) { toast(t('fs.noTokenToast'), 'err'); showView('view-settings'); return }
1431
+ if (file.size > 2 * 1024 * 1024 * 1024) { toast(t('fs.tooLarge'), 'err'); return }
1432
+ if (state.fs.upload?.active) { toast(t('fs.uploadBusy'), 'err'); return }
1427
1433
 
1428
1434
  const prev = state.fs.upload
1429
1435
  if (prev && prev.path === state.fs.path && prev.name === file.name && prev.size === file.size) {
@@ -1475,11 +1481,11 @@ async function runFsUpload(up) {
1475
1481
  try { json = JSON.parse(xhr.responseText || '{}') } catch {}
1476
1482
  resolve({ status: xhr.status, json })
1477
1483
  }
1478
- xhr.onerror = () => { if (up.xhr === xhr) up.xhr = null; reject(new Error('网络错误')) }
1479
- xhr.upload.onerror = () => { if (up.xhr === xhr) up.xhr = null; reject(new Error('网络中断')) }
1484
+ xhr.onerror = () => { if (up.xhr === xhr) up.xhr = null; reject(new Error(t('fs.networkError'))) }
1485
+ xhr.upload.onerror = () => { if (up.xhr === xhr) up.xhr = null; reject(new Error(t('fs.networkInterrupt'))) }
1480
1486
  xhr.onabort = () => {
1481
1487
  if (up.xhr === xhr) up.xhr = null
1482
- const err = new Error(up.cancelled ? '已取消' : '已暂停')
1488
+ const err = new Error(t(up.cancelled ? 'fs.cancelledErr' : 'fs.pausedErr'))
1483
1489
  err.code = up.cancelled ? 'CANCELLED' : 'PAUSED'
1484
1490
  reject(err)
1485
1491
  }
@@ -1502,7 +1508,7 @@ async function runFsUpload(up) {
1502
1508
  if (info === null || up.cancelled) return
1503
1509
  if (info.partialSize > 0) wasResumed = true
1504
1510
  if (info.targetExists && info.targetSize === up.size && !overwrite) {
1505
- if (!confirm('文件已存在(大小相同),覆盖它?')) { state.fs.upload = null; hideFsProgress(); setFsButtons(false); return }
1511
+ if (!confirm(t('fs.confirmOverwrite'))) { state.fs.upload = null; hideFsProgress(); setFsButtons(false); return }
1506
1512
  overwrite = true
1507
1513
  }
1508
1514
  if (up.offset > 0) await hashFsRange(up.file, hasher, 0, up.offset, up)
@@ -1515,7 +1521,7 @@ async function runFsUpload(up) {
1515
1521
  const before = hasher.clone()
1516
1522
  const chunkBytes = new Uint8Array(await blob.arrayBuffer())
1517
1523
  if (up.pauseRequested) {
1518
- const err = new Error('已暂停'); err.code = 'PAUSED'; throw err
1524
+ const err = new Error(t('fs.pausedErr')); err.code = 'PAUSED'; throw err
1519
1525
  }
1520
1526
  hasher.update(chunkBytes)
1521
1527
  const isLast = end >= up.size
@@ -1528,19 +1534,19 @@ async function runFsUpload(up) {
1528
1534
  if (r.status === 201) {
1529
1535
  const expected = params.sha256 || hasher.hex()
1530
1536
  if (r.json.sha256 && r.json.sha256 !== expected) {
1531
- const err = new Error('文件校验不一致(SHA-256)'); err.checksum = true
1537
+ const err = new Error(t('fs.checksumMismatch')); err.checksum = true
1532
1538
  throw err
1533
1539
  }
1534
1540
  hideFsProgress()
1535
1541
  setFsButtons(false)
1536
1542
  state.fs.upload = null
1537
- toast(`已上传 ${up.name}(SHA-256 已校验${wasResumed ? ' · 断点续传' : ''})`, 'ok')
1543
+ toast(t('fs.uploadDone', { name: up.name, resumed: wasResumed ? t('fs.resumedSuffix') : '' }), 'ok')
1538
1544
  loadFs()
1539
1545
  return
1540
1546
  }
1541
1547
  if (r.status === 409 && r.json.error === 'conflict') {
1542
1548
  hasher = before // 这段数据没被写入, 回退哈希状态后带 overwrite=1 重发
1543
- if (!confirm('文件已存在,覆盖它?')) { state.fs.upload = null; hideFsProgress(); setFsButtons(false); return }
1549
+ if (!confirm(t('fs.confirmOverwrite2'))) { state.fs.upload = null; hideFsProgress(); setFsButtons(false); return }
1544
1550
  overwrite = true
1545
1551
  continue
1546
1552
  }
@@ -1550,7 +1556,7 @@ async function runFsUpload(up) {
1550
1556
  continue
1551
1557
  }
1552
1558
  if (r.status === 422 && r.json.error === 'checksum-mismatch') {
1553
- const err = new Error('文件校验不一致(SHA-256),已清除坏分片')
1559
+ const err = new Error(t('fs.checksumCleared'))
1554
1560
  err.checksum = true
1555
1561
  throw err
1556
1562
  }
@@ -1566,24 +1572,24 @@ async function runFsUpload(up) {
1566
1572
  const r = await uploadChunk(params, new Blob([]))
1567
1573
  if (r.status === 401) { fsAuthError(401); return }
1568
1574
  if (r.status === 409 && r.json.error === 'conflict') {
1569
- if (!confirm('文件已存在,覆盖它?')) { state.fs.upload = null; hideFsProgress(); setFsButtons(false); return }
1575
+ if (!confirm(t('fs.confirmOverwrite2'))) { state.fs.upload = null; hideFsProgress(); setFsButtons(false); return }
1570
1576
  params.overwrite = '1'
1571
1577
  return runFsUpload(up) // 目标冲突未写入, 重新走 probe + 空 finish
1572
1578
  }
1573
1579
  if (r.status === 422 && r.json.error === 'checksum-mismatch') {
1574
- const err = new Error('文件校验不一致(SHA-256),已清除坏分片')
1580
+ const err = new Error(t('fs.checksumCleared'))
1575
1581
  err.checksum = true
1576
1582
  throw err
1577
1583
  }
1578
1584
  if (r.status !== 201) throw new Error(r.json.error || ('HTTP ' + r.status))
1579
1585
  if (r.json.sha256 && r.json.sha256 !== expected) {
1580
- const err = new Error('文件校验不一致(SHA-256)'); err.checksum = true
1586
+ const err = new Error(t('fs.checksumMismatch')); err.checksum = true
1581
1587
  throw err
1582
1588
  }
1583
1589
  hideFsProgress()
1584
1590
  setFsButtons(false)
1585
1591
  state.fs.upload = null
1586
- toast(`已上传 ${up.name}(SHA-256 已校验${wasResumed ? ' · 断点续传' : ''})`, 'ok')
1592
+ toast(t('fs.uploadDone', { name: up.name, resumed: wasResumed ? t('fs.resumedSuffix') : '' }), 'ok')
1587
1593
  loadFs()
1588
1594
  return
1589
1595
  }
@@ -1596,8 +1602,8 @@ async function runFsUpload(up) {
1596
1602
  if (e?.code === 'PAUSED') {
1597
1603
  up.paused = true
1598
1604
  setFsButtons(true, true)
1599
- setFsProgressText(`已暂停 · ${Math.round(up.offset / Math.max(1, up.size) * 100)}%`)
1600
- toast('已暂停,点「继续」接着传', 'ok')
1605
+ setFsProgressText(t('fs.pausedPct', { pct: Math.round(up.offset / Math.max(1, up.size) * 100) }))
1606
+ toast(t('fs.pausedToast'), 'ok')
1601
1607
  return
1602
1608
  }
1603
1609
  if (e?.checksum) {
@@ -1610,20 +1616,20 @@ async function runFsUpload(up) {
1610
1616
  })
1611
1617
  } catch {}
1612
1618
  setFsButtons(true, true)
1613
- setFsProgressText('校验失败 · 点「继续」重新上传')
1619
+ setFsProgressText(t('fs.checksumFailed'))
1614
1620
  toast(e.message, 'err')
1615
1621
  return
1616
1622
  }
1617
1623
  hideFsProgress()
1618
1624
  setFsButtons(false)
1619
- toast(`上传中断:${e.message}(重选同一文件可断点续传)`, 'err')
1625
+ toast(t('fs.uploadInterrupted', { msg: e.message }), 'err')
1620
1626
  }
1621
1627
  }
1622
1628
 
1623
1629
  function fsUp() {
1624
1630
  if (!state.fs.path || !state.fs.initial) return
1625
1631
  if (state.fs.path === state.fs.initial) {
1626
- toast('已在允许的根目录')
1632
+ toast(t('fs.alreadyRoot'))
1627
1633
  return
1628
1634
  }
1629
1635
  loadFs(fsParent(state.fs.path))
@@ -1642,7 +1648,7 @@ function bindFsPullRefresh() {
1642
1648
  const dy = e.touches[0].clientY - startY
1643
1649
  if (dy > 4 && window.scrollY <= 0) {
1644
1650
  pull.style.height = Math.min(64, dy / 2) + 'px'
1645
- pull.textContent = dy > 80 ? '松开刷新' : '下拉刷新'
1651
+ pull.textContent = dy > 80 ? t('fs.pullRelease') : t('fs.pullDown')
1646
1652
  }
1647
1653
  }, { passive: true })
1648
1654
  view.addEventListener('touchend', () => {
@@ -1650,7 +1656,7 @@ function bindFsPullRefresh() {
1650
1656
  const h = parseFloat(pull.style.height || '0')
1651
1657
  startY = null
1652
1658
  if (h >= 40) {
1653
- pull.textContent = '刷新中…'
1659
+ pull.textContent = t('fs.refreshing')
1654
1660
  loadFs(null, { silent: true })
1655
1661
  }
1656
1662
  pull.style.height = '0px'
@@ -1672,10 +1678,10 @@ async function submitGoalEdit() {
1672
1678
  const goal = state.goalEdit
1673
1679
  if (!goal) return
1674
1680
  const objective = $('goal-edit-text')?.value?.trim()
1675
- if (!objective) return toast('目标不能为空', 'err')
1676
- await safeRpc('goal.edit', { sessionId: state.current, ref: { id: goal.id, revision: goal.revision }, objective }, '更新失败')
1681
+ if (!objective) return toast(t('goal.cannotEmpty'), 'err')
1682
+ await safeRpc('goal.edit', { sessionId: state.current, ref: { id: goal.id, revision: goal.revision }, objective }, t('goal.updateFailed'))
1677
1683
  $('modal-goal').classList.add('hidden')
1678
- toast('目标已更新', 'ok')
1684
+ toast(t('goal.updated'), 'ok')
1679
1685
  scheduleRefresh()
1680
1686
  }
1681
1687
 
@@ -1707,41 +1713,66 @@ function cmpVersion(a, b) {
1707
1713
  return 0
1708
1714
  }
1709
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
+
1710
1734
  async function loadLocalVersion() {
1711
1735
  try {
1712
1736
  const res = await fetch('version.json?t=' + Date.now())
1713
1737
  if (res.ok) state.localVersion = (await res.json())?.version || ''
1714
1738
  } catch {}
1715
- $('update-desc').textContent = state.localVersion ? `当前版本 v${state.localVersion}` : '未获取到版本'
1739
+ $('update-desc').textContent = state.localVersion ? t('update.currentV', { version: state.localVersion }) : t('update.noVersion')
1716
1740
  }
1717
1741
 
1718
1742
  async function checkUpdate(silent) {
1719
1743
  const base = state.server
1720
1744
  if (!base) {
1721
- if (!silent) toast('请先设置服务器地址', 'err')
1722
- $('update-desc').textContent = state.localVersion ? `当前版本 v${state.localVersion} · 未设置服务器` : '请先设置服务器地址'
1745
+ if (!silent) toast(t('update.needServer'), 'err')
1746
+ $('update-desc').textContent = state.localVersion ? `${t('update.currentV', { version: state.localVersion })} · ${t('update.needServer')}` : t('update.needServer')
1747
+ resetUpdateExpand()
1723
1748
  return
1724
1749
  }
1725
- if (!silent) toast('正在检查更新…')
1750
+ if (!silent) toast(t('update.checking'))
1726
1751
  try {
1727
1752
  const res = await fetch(base + '/update.json?t=' + Date.now() + '&local=' + encodeURIComponent(state.localVersion))
1728
1753
  if (!res.ok) throw new Error('HTTP ' + res.status)
1729
1754
  const info = await res.json()
1730
1755
  if (info.version && cmpVersion(info.version, state.localVersion) > 0) {
1731
1756
  state.updateInfo = info
1732
- $('update-desc').textContent = `发现新版本 v${info.version}${info.notes ? ':' + info.notes : ''}`
1757
+ const hasNotes = !!(info.notes && String(info.notes).trim())
1758
+ $('update-desc').textContent = t('update.found', { version: info.version, notes: hasNotes ? ':' + info.notes : '' })
1759
+ $('update-desc').classList.remove('expanded')
1760
+ $('btn-update-expand').classList.toggle('hidden', !hasNotes)
1761
+ renderUpdateExpandBtn()
1733
1762
  $('btn-download-update').classList.remove('hidden')
1734
- if (!silent) toast(`发现新版本 v${info.version}`, 'ok')
1735
- else notify('发现新版本', 'v' + info.version + ' 可更新')
1763
+ if (!silent) toast(t('update.found', { version: info.version }), 'ok')
1764
+ else notify(t('update.foundTitle'), t('update.foundBody', { version: info.version }))
1736
1765
  } else {
1737
1766
  state.updateInfo = null
1738
- $('update-desc').textContent = state.localVersion ? `已是最新 v${state.localVersion}` : `最新版本 v${info.version || '?'}`
1767
+ $('update-desc').textContent = state.localVersion ? t('update.latestV', { version: state.localVersion }) : t('update.latestRemote', { version: info.version || '?' })
1739
1768
  $('btn-download-update').classList.add('hidden')
1740
- if (!silent) toast('已是最新版本', 'ok')
1769
+ resetUpdateExpand()
1770
+ if (!silent) toast(t('update.latestToast'), 'ok')
1741
1771
  }
1742
1772
  } catch (e) {
1743
- $('update-desc').textContent = '检查失败:' + (e.message || '网络错误')
1744
- if (!silent) toast('检查更新失败:' + e.message, 'err')
1773
+ $('update-desc').textContent = t('update.checkFailedDesc', { msg: e.message || t('fs.networkError') })
1774
+ resetUpdateExpand()
1775
+ if (!silent) toast(t('update.checkFailed', { msg: e.message }), 'err')
1745
1776
  }
1746
1777
  }
1747
1778
 
@@ -1757,14 +1788,14 @@ function downloadUpdate() {
1757
1788
  if (window.NativeUpdate?.downloadAndInstall) {
1758
1789
  try {
1759
1790
  window.NativeUpdate.downloadAndInstall(url)
1760
- toast('开始下载,完成后会弹出安装页', 'ok')
1791
+ toast(t('update.downloadStarted'), 'ok')
1761
1792
  } catch (e) {
1762
- toast('无法启动下载:' + (e?.message || ''), 'err')
1793
+ toast(t('update.downloadFailed', { msg: e?.message || '' }), 'err')
1763
1794
  }
1764
1795
  return
1765
1796
  }
1766
1797
  // 兜底: 旧版 App 没有原生桥时用浏览器下载
1767
- toast('当前版本不支持 App 内安装,已转浏览器下载', 'err')
1798
+ toast(t('update.installUnsupported'), 'err')
1768
1799
  }
1769
1800
  // 浏览器: 直接触发下载
1770
1801
  location.href = url
@@ -1781,7 +1812,7 @@ async function ensureNotify() {
1781
1812
  const p = await L.requestPermissions()
1782
1813
  return p?.display === 'granted'
1783
1814
  } catch (e) {
1784
- toast('通知权限申请失败:' + (e?.message || ''), 'err')
1815
+ toast(t('notify.permissionFailed', { msg: e?.message || '' }), 'err')
1785
1816
  return false
1786
1817
  }
1787
1818
  }
@@ -1825,7 +1856,7 @@ function showView(id) {
1825
1856
  function updateConn() {
1826
1857
  const ok = !!state.streamsOk?.mux
1827
1858
  const el = $('conn-badge')
1828
- el.textContent = ok ? '已连接' : '未连接'
1859
+ el.textContent = ok ? t('conn.on') : t('conn.off')
1829
1860
  el.className = 'conn-badge ' + (ok ? 'on' : 'off')
1830
1861
  }
1831
1862
 
@@ -1840,16 +1871,16 @@ function applyPairUrl(url) {
1840
1871
  try {
1841
1872
  const u = new URL(String(url).trim())
1842
1873
  if (u.protocol !== 'dshremote:' || u.hostname !== 'pair') return false
1843
- const t = (u.searchParams.get('token') || '').trim()
1874
+ const tok = (u.searchParams.get('token') || '').trim()
1844
1875
  const server = (u.searchParams.get('server') || '').trim().replace(/\/+$/, '')
1845
- if (!t || !/^https?:\/\//i.test(server)) return false
1846
- state.token = t
1847
- LS.set('token', t)
1876
+ if (!tok || !/^https?:\/\//i.test(server)) return false
1877
+ state.token = tok
1878
+ LS.set('token', tok)
1848
1879
  state.server = server
1849
1880
  if (!state.servers.includes(server)) state.servers.unshift(server)
1850
1881
  saveServers()
1851
1882
  renderServers()
1852
- $('token-desc').textContent = '已保存(扫码)'
1883
+ $('token-desc').textContent = t('token.savedScan')
1853
1884
  return true
1854
1885
  } catch {
1855
1886
  return false
@@ -1861,7 +1892,7 @@ async function decodeQrDataUrl(dataUrl) {
1861
1892
  const img = new Image()
1862
1893
  await new Promise((resolve, reject) => {
1863
1894
  img.onload = resolve
1864
- img.onerror = () => reject(new Error('图片加载失败'))
1895
+ img.onerror = () => reject(new Error(t('scan.imageLoadFailed')))
1865
1896
  img.src = dataUrl
1866
1897
  })
1867
1898
  const maxSide = 1600
@@ -1872,7 +1903,7 @@ async function decodeQrDataUrl(dataUrl) {
1872
1903
  canvas.width = w
1873
1904
  canvas.height = h
1874
1905
  const ctx = canvas.getContext('2d', { willReadFrequently: true })
1875
- if (!ctx) throw new Error('当前设备不支持图片解码')
1906
+ if (!ctx) throw new Error(t('scan.decodeUnsupported'))
1876
1907
  ctx.drawImage(img, 0, 0, w, h)
1877
1908
  const imageData = ctx.getImageData(0, 0, w, h)
1878
1909
  const code = window.jsQR?.(imageData.data, w, h, { inversionAttempts: 'attemptBoth' })
@@ -1884,37 +1915,37 @@ async function decodeQrDataUrl(dataUrl) {
1884
1915
  * 冗余路径 2: 设置页手动粘贴令牌。 */
1885
1916
  async function scanPair() {
1886
1917
  if (!CAP?.isNativePlatform?.()) {
1887
- toast('浏览器请打开主机管理页,用手机相机扫码', 'err')
1918
+ toast(t('scan.browserHint'), 'err')
1888
1919
  return
1889
1920
  }
1890
1921
  const camera = CAP.Plugins?.Camera
1891
- if (!camera?.getPhoto) { toast('当前 App 版本不支持拍照扫码,请先更新 App', 'err'); return }
1922
+ if (!camera?.getPhoto) { toast(t('scan.unsupported'), 'err'); return }
1892
1923
  try {
1893
1924
  const perm = await camera.requestPermissions?.({ permissions: ['camera'] })
1894
- if (perm && perm.camera !== 'granted') { toast('未授予相机权限', 'err'); return }
1925
+ if (perm && perm.camera !== 'granted') { toast(t('scan.permissionDenied'), 'err'); return }
1895
1926
  const photo = await camera.getPhoto({
1896
1927
  resultType: 'dataUrl',
1897
1928
  source: 'PROMPT', // 拍照 / 从相册选择都行, 相册可扫截图
1898
1929
  quality: 85,
1899
1930
  correctOrientation: true,
1900
1931
  saveToGallery: false,
1901
- promptLabelHeader: '扫描 DSH Remote 配对二维码',
1902
- promptLabelPhoto: '拍照扫描',
1903
- promptLabelPicture: '从相册选择',
1932
+ promptLabelHeader: t('scan.promptHeader'),
1933
+ promptLabelPhoto: t('scan.promptPhoto'),
1934
+ promptLabelPicture: t('scan.promptGallery'),
1904
1935
  })
1905
- if (!photo?.dataUrl) { toast('没有拿到图片,请重试', 'err'); return }
1936
+ if (!photo?.dataUrl) { toast(t('scan.noPhoto'), 'err'); return }
1906
1937
  const raw = await decodeQrDataUrl(photo.dataUrl)
1907
- if (!raw) { toast('未识别到二维码,请对准后重试', 'err'); return }
1938
+ if (!raw) { toast(t('scan.noQr'), 'err'); return }
1908
1939
  if (applyPairUrl(raw)) {
1909
- toast('配对成功,正在连接', 'ok')
1940
+ toast(t('scan.paired'), 'ok')
1910
1941
  openStreams()
1911
1942
  refreshAll()
1912
1943
  } else {
1913
- toast('这不是 DSH Remote 的配对二维码', 'err')
1944
+ toast(t('scan.notPair'), 'err')
1914
1945
  }
1915
1946
  } catch (e) {
1916
1947
  const msg = String(e?.message || e || '')
1917
- toast(/cancel/i.test(msg) ? '已取消扫码' : '扫码失败:' + msg, 'err')
1948
+ toast(/cancel/i.test(msg) ? t('scan.cancelled') : t('scan.failed', { msg }), 'err')
1918
1949
  }
1919
1950
  }
1920
1951
 
@@ -1924,7 +1955,7 @@ function bindNativeLinks() {
1924
1955
  try {
1925
1956
  CAP.Plugins?.App?.addListener?.('appUrlOpen', (data) => {
1926
1957
  if (data?.url && applyPairUrl(data.url)) {
1927
- toast('已通过二维码配对', 'ok')
1958
+ toast(t('scan.pairedLink'), 'ok')
1928
1959
  openStreams()
1929
1960
  refreshAll()
1930
1961
  }
@@ -1945,11 +1976,71 @@ function initToken() {
1945
1976
  state.token = LS.get('token', '')
1946
1977
  }
1947
1978
  loadServers()
1948
- $('token-desc').textContent = state.token ? '已保存(本机)' : '未设置'
1949
- $('server-desc').textContent = state.server || '默认 = 当前页面地址'
1979
+ $('token-desc').textContent = state.token ? t('token.savedLocal') : t('token.notSet')
1980
+ $('server-desc').textContent = state.server || t('servers.defaultDesc')
1981
+ }
1982
+
1983
+ function renderLangBtn() {
1984
+ const btn = $('btn-lang')
1985
+ if (btn) btn.textContent = I18N.lang === 'zh' ? 'EN' : '中文'
1986
+ }
1987
+
1988
+ const THEME_META = [
1989
+ { id: 'default', sw: ['#0B0E1A', '#151B33', '#5B8CFF'] },
1990
+ { id: 'dark', sw: ['#05348B', '#0D438F', '#F9A647'] },
1991
+ { id: 'light', sw: ['#EFEEEC', '#FAF8F5', '#E6BC7B'] },
1992
+ { id: 'neutral', sw: ['#DDD4B8', '#585818', '#832D15'] }
1993
+ ]
1994
+
1995
+ function renderThemeBtn() {
1996
+ const btn = $('btn-theme')
1997
+ if (btn) btn.textContent = t('theme.' + window.DSHTheme.get())
1998
+ }
1999
+
2000
+ function renderThemeOptions() {
2001
+ const box = $('theme-options')
2002
+ if (!box) return
2003
+ const cur = window.DSHTheme.get()
2004
+ box.innerHTML = THEME_META.map(m => `
2005
+ <button class="theme-option ${m.id === cur ? 'current' : ''}" data-theme="${m.id}" title="${t('theme.' + m.id)}">
2006
+ <span class="theme-swatches">${m.sw.map(c => `<i style="background:${c}"></i>`).join('')}</span>
2007
+ <span class="theme-name">${t('theme.' + m.id)}</span>
2008
+ <span class="theme-check">${m.id === cur ? '✓' : ''}</span>
2009
+ </button>`).join('')
2010
+ box.querySelectorAll('.theme-option').forEach(btn =>
2011
+ btn.addEventListener('click', () => {
2012
+ window.DSHTheme.set(btn.dataset.theme)
2013
+ renderThemeBtn()
2014
+ renderThemeOptions()
2015
+ $('modal-theme').classList.add('hidden')
2016
+ }))
2017
+ }
2018
+
2019
+ function openThemePanel() {
2020
+ renderThemeOptions()
2021
+ $('modal-theme').classList.remove('hidden')
1950
2022
  }
1951
2023
 
1952
2024
  function bindUi() {
2025
+ renderLangBtn()
2026
+ renderThemeBtn()
2027
+ $('btn-lang').addEventListener('click', () => {
2028
+ I18N.setLang(I18N.lang === 'zh' ? 'en' : 'zh')
2029
+ renderLangBtn()
2030
+ renderThemeBtn()
2031
+ renderUpdateExpandBtn()
2032
+ renderServers()
2033
+ renderSessions()
2034
+ renderPending(); renderQueue(); renderJobs()
2035
+ updateConn()
2036
+ if (state.current) { renderSessionTitle(); renderSessionSub(); renderSessionCards(); renderHistory(true) }
2037
+ else renderModelMenu()
2038
+ loadLocalVersion()
2039
+ if (state.hostInfo) $('host-desc').textContent = t('settings.hostDesc', { version: state.hostInfo.version, cwd: state.hostInfo.cwd, n: state.hostInfo.attachedSessions })
2040
+ $('token-desc').textContent = state.token ? t('token.savedLocal') : t('token.notSet')
2041
+ })
2042
+ $('btn-theme').addEventListener('click', openThemePanel)
2043
+ $('theme-close').addEventListener('click', () => $('modal-theme').classList.add('hidden'))
1953
2044
  renderServers()
1954
2045
  // 底部导航
1955
2046
  document.querySelectorAll('.nav-btn').forEach(b =>
@@ -1962,7 +2053,7 @@ function bindUi() {
1962
2053
  $('btn-back').addEventListener('click', closeSession)
1963
2054
  $('btn-stats').addEventListener('click', () => { renderSessionCards(); $('modal-stats').classList.remove('hidden') })
1964
2055
  $('stats-close').addEventListener('click', () => $('modal-stats').classList.add('hidden'))
1965
- $('btn-refresh').addEventListener('click', () => { toast('刷新中…'); openStreams(); refreshAll() })
2056
+ $('btn-refresh').addEventListener('click', () => { toast(t('common.refreshing')); openStreams(); refreshAll() })
1966
2057
  $('btn-admin').addEventListener('click', () => {
1967
2058
  location.href = state.server ? state.server.replace(/\/+$/, '') + '/admin' : 'admin'
1968
2059
  })
@@ -1999,8 +2090,8 @@ function bindUi() {
1999
2090
  // 设置
2000
2091
  $('btn-scan-pair').addEventListener('click', scanPair)
2001
2092
  $('btn-change-token').addEventListener('click', () => {
2002
- const t = prompt('输入访问令牌(网关启动时打印的 token):', state.token)
2003
- if (t && t.trim()) { state.token = t.trim(); LS.set('token', t.trim()); $('token-desc').textContent = '已保存'; toast('已保存,正在重连', 'ok'); openStreams(); refreshAll() }
2093
+ const input = prompt(t('token.prompt'), state.token)
2094
+ if (input && input.trim()) { state.token = input.trim(); LS.set('token', input.trim()); $('token-desc').textContent = t('token.saved'); toast(t('token.savedReconnect'), 'ok'); openStreams(); refreshAll() }
2004
2095
  })
2005
2096
  $('btn-server-speed').addEventListener('click', () => selectFastestServer({ silent: false }))
2006
2097
  $('btn-server-add').addEventListener('click', addServer)
@@ -2008,16 +2099,17 @@ function bindUi() {
2008
2099
  if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); addServer() }
2009
2100
  })
2010
2101
  $('btn-host-describe').addEventListener('click', async () => {
2011
- const v = await safeRpc('host.describe', {}, '探测失败')
2102
+ const v = await safeRpc('host.describe', {}, t('settings.probeFailed'))
2012
2103
  if (v) {
2013
2104
  state.hostInfo = v
2014
- $('host-desc').textContent = `DSH ${v.version} · ${v.cwd} · 附加会话 ${v.attachedSessions}`
2105
+ $('host-desc').textContent = t('settings.hostDesc', { version: v.version, cwd: v.cwd, n: v.attachedSessions })
2015
2106
  }
2016
2107
  })
2017
2108
  $('btn-check-update').addEventListener('click', () => checkUpdate(false))
2018
2109
  $('btn-download-update').addEventListener('click', downloadUpdate)
2110
+ $('btn-update-expand').addEventListener('click', toggleUpdateExpand)
2019
2111
  $('btn-reset').addEventListener('click', () => {
2020
- if (!confirm('清除本地令牌、服务器与缓存?')) return
2112
+ if (!confirm(t('settings.confirmReset'))) return
2021
2113
  LS.del('token'); LS.del('notify'); LS.del('server')
2022
2114
  location.reload()
2023
2115
  })
@@ -2025,7 +2117,7 @@ function bindUi() {
2025
2117
  $('opt-notify').addEventListener('change', async (e) => {
2026
2118
  if (e.target.checked) {
2027
2119
  const ok = await ensureNotify()
2028
- if (!ok) { e.target.checked = false; return toast('通知权限未开启') }
2120
+ if (!ok) { e.target.checked = false; return toast(t('settings.notifyDenied')) }
2029
2121
  }
2030
2122
  LS.set('notify', e.target.checked ? '1' : '0')
2031
2123
  })
@@ -2033,12 +2125,12 @@ function bindUi() {
2033
2125
  $('opt-tools').addEventListener('change', (e) => {
2034
2126
  LS.set('showTools', e.target.checked ? '1' : '0')
2035
2127
  if (state.current) renderHistory(true)
2036
- toast(e.target.checked ? '已显示工具调用' : '已隐藏工具调用', 'ok')
2128
+ toast(e.target.checked ? t('settings.toolsShown') : t('settings.toolsHidden'), 'ok')
2037
2129
  })
2038
2130
 
2039
2131
  // 文件页
2040
2132
  $('fs-up').addEventListener('click', fsUp)
2041
- $('fs-refresh').addEventListener('click', () => { toast('刷新中…'); loadFs() })
2133
+ $('fs-refresh').addEventListener('click', () => { toast(t('common.refreshing')); loadFs() })
2042
2134
  $('fs-upload-btn').addEventListener('click', () => $('fs-file-input').click())
2043
2135
  $('fs-file-input').addEventListener('change', (e) => {
2044
2136
  const f = e.target.files?.[0]
@@ -2088,6 +2180,7 @@ function applyNativeInsets() {
2088
2180
  async function boot() {
2089
2181
  initToken()
2090
2182
  bindUi()
2183
+ renderLangBtn()
2091
2184
  bindNativeBack()
2092
2185
  bindNativeLinks()
2093
2186
  applyNativeInsets()
@@ -2095,14 +2188,14 @@ async function boot() {
2095
2188
  loadLocalVersion()
2096
2189
  if (!state.token) {
2097
2190
  showView('view-settings')
2098
- $('token-desc').textContent = '未设置——点「更换」粘贴网关启动时打印的 token'
2191
+ $('token-desc').textContent = t('token.notSetHint')
2099
2192
  } else {
2100
2193
  // 多服务器: 启动时静默测速一次, 选最快的连接(同源页面也参与比较)
2101
2194
  await selectFastestServer({ silent: true, reconnect: false })
2102
2195
  openStreams()
2103
2196
  await refreshAll()
2104
2197
  const host = await safeRpc('host.describe', {}, '')
2105
- if (host) { state.hostInfo = host; $('host-desc').textContent = `DSH ${host.version} · ${host.cwd} · 附加会话 ${host.attachedSessions}` }
2198
+ if (host) { state.hostInfo = host; $('host-desc').textContent = t('settings.hostDesc', { version: host.version, cwd: host.cwd, n: host.attachedSessions }) }
2106
2199
  // 启动后自动检查一次更新(静默)
2107
2200
  setTimeout(() => checkUpdate(true), 4000)
2108
2201
  }