dsh-remote-plugin 0.6.11 → 0.6.12

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/index.mjs CHANGED
@@ -151,8 +151,9 @@ function runExit(cmd, args) {
151
151
  }
152
152
 
153
153
  const GATEWAY_ENV_KEYS = [
154
- 'TOKEN', 'TOKEN_FILE', 'DSH_REMOTE_TOKEN', 'DSH_REMOTE_FS_ROOT', 'DSH_REMOTE_FS_MAX_UPLOAD',
155
- 'DSH_REMOTE_NOTES', 'DSH_REMOTE_WORKBENCH', 'DSH_REMOTE_DSH_SERVICE', 'DSH_REMOTE_FEEDBACK_URL',
154
+ 'TOKEN', 'TOKEN_FILE', 'DSH_REMOTE_TOKEN', 'DSH_REMOTE_FS_ROOT', 'DSH_REMOTE_FS_WORKSPACE_CACHE_MS', 'DSH_REMOTE_FS_MAX_UPLOAD',
155
+ 'DSH_REMOTE_NOTES', 'DSH_REMOTE_WORKBENCH', 'DSH_REMOTE_DSH_SERVICE', 'DSH_REMOTE_SYSTEMCTL',
156
+ 'DSH_REMOTE_DSH_CONTROL_TIMEOUT_MS', 'DSH_REMOTE_DSH_CONTROL_POLL_MS', 'DSH_REMOTE_FEEDBACK_URL',
156
157
  'UPDATE_CHECK_URL', 'UPDATE_INTERVAL_MS', 'UPDATE_PROXY', 'DSH_HEALTH_PATH',
157
158
  'GATEWAY_WS_IDLE_MS', 'GATEWAY_WS_PING_MS', 'GATEWAY_WS_PONG_TIMEOUT_MS',
158
159
  'GATEWAY_WS_UPGRADE_TIMEOUT_MS', 'GATEWAY_UPSTREAM_TIMEOUT_MS', 'GATEWAY_EVENT_BUFFER_MAX',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-plugin",
3
- "version": "0.6.11",
3
+ "version": "0.6.12",
4
4
  "description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
@@ -15,6 +15,14 @@
15
15
  "minVersion": "0.6.9",
16
16
  "maxVersion": "",
17
17
  "publishedAt": "2026-08-22T12:00:00+08:00"
18
+ },
19
+ {
20
+ "id": "2026-08-23-feedback-polls",
21
+ "title": "反馈系统更新与投票公告上线",
22
+ "content": "大家好,DSH Remote 的反馈系统已完成修复,并新增了投票类公告功能。后续我们会通过投票收集大家对新功能、优化方向和更新优先级的意见,让版本规划更贴近实际使用需求。\n\n欢迎各位用户和开发者提交更多反馈,包括功能建议、异常问题、兼容性情况以及使用体验等。\n\n目前应用内的“直接反馈”功能仍处于测试阶段,稳定性还在持续改进。如果是重要问题或需要持续跟进的 Bug,建议优先通过 GitHub Issues 或 B 站相关渠道反馈,这两种方式目前比直接反馈更加稳定。\n\n提交日志或截图前,请注意隐藏 Token、服务器地址等敏感信息。感谢大家的支持与参与!",
23
+ "minVersion": "",
24
+ "maxVersion": "",
25
+ "publishedAt": "2026-08-23T19:04:26+08:00"
18
26
  }
19
27
  ]
20
28
  }
package/public/app.js CHANGED
@@ -65,6 +65,7 @@ const state = {
65
65
  localVersion: '',
66
66
  updateInfo: null,
67
67
  announcement: null,
68
+ announcements: [],
68
69
  approvals: [], // 待处理审批
69
70
  questions: [], // 待处理提问
70
71
  queues: {}, // sessionId -> queue items
@@ -272,6 +273,14 @@ function openFeedbackModal() {
272
273
  setTimeout(() => $('fb-msg').focus(), 50)
273
274
  }
274
275
  function closeFeedbackModal() { $('modal-feedback').classList.add('hidden') }
276
+ function openFeedbackSuccess() {
277
+ $('modal-feedback-success').classList.remove('hidden')
278
+ setTimeout(() => $('feedback-success-confirm').focus(), 50)
279
+ }
280
+ function closeFeedbackSuccess() {
281
+ $('modal-feedback-success').classList.add('hidden')
282
+ $('btn-feedback')?.focus()
283
+ }
275
284
  async function submitFeedback() {
276
285
  const type = state.feedbackType || 'bug'
277
286
  const message = $('fb-msg').value.trim()
@@ -290,7 +299,7 @@ async function submitFeedback() {
290
299
  })
291
300
  let json = {}
292
301
  try { json = await res.json() } catch {}
293
- if (res.ok && json.ok) { toast(t('feedback.submitted'), 'ok'); closeFeedbackModal() }
302
+ if (res.ok && json.ok) { closeFeedbackModal(); openFeedbackSuccess() }
294
303
  else if (res.status === 429) { toast(json.retryAfter ? t('feedback.rateLimitedAt', { n: json.retryAfter }) : t('feedback.rateLimited'), 'err') }
295
304
  else { toast(t('feedback.submitFailed', { msg: json.error || res.status }), 'err') }
296
305
  } catch {
@@ -1382,12 +1391,17 @@ function workspaceItems() {
1382
1391
  function workspaceById(workspaceId) {
1383
1392
  return workspaceItems().find(w => w.workspaceId === workspaceId) || null
1384
1393
  }
1394
+ function workspaceOwnsSession(workspace, session) {
1395
+ if (!workspace || !session?.sessionId) return false
1396
+ if (String(session.workspaceId || '') === workspace.workspaceId) return true
1397
+ if (Array.isArray(workspace.sessionIds) && workspace.sessionIds.some(id => String(id) === String(session.sessionId))) return true
1398
+ const cwdKey = wbPathKey(sessionCwd(session))
1399
+ const workspaceKey = wbPathKey(workspace.path)
1400
+ return !!cwdKey && !!workspaceKey && (cwdKey === workspaceKey || cwdKey.startsWith(workspaceKey.endsWith('/') ? workspaceKey : workspaceKey + '/'))
1401
+ }
1385
1402
  function workspaceForSession(session) {
1386
1403
  if (!session) return null
1387
- const byMembership = workspaceItems().find(w => Array.isArray(w.sessionIds) && w.sessionIds.includes(session.sessionId))
1388
- if (byMembership) return byMembership
1389
- const cwdKey = wbPathKey(sessionCwd(session))
1390
- return cwdKey ? workspaceItems().find(w => wbPathKey(w.path) === cwdKey) || null : null
1404
+ return workspaceItems().find(workspace => workspaceOwnsSession(workspace, session)) || null
1391
1405
  }
1392
1406
  function workspaceName(workspace) {
1393
1407
  return String(workspace?.title || wbBaseName(workspace?.path) || workspace?.path || '').trim()
@@ -1411,7 +1425,10 @@ function renderWorkspaceNavigation() {
1411
1425
  LS.del('workspaceFilterV1')
1412
1426
  }
1413
1427
  const sessionSelect = $('session-workspace-filter')
1414
- if (sessionSelect) sessionSelect.innerHTML = workspaceOptionsHtml({ all: true, ungrouped: true, selected: state.workspaceFilter })
1428
+ if (sessionSelect) {
1429
+ sessionSelect.innerHTML = workspaceOptionsHtml({ all: true, ungrouped: true, selected: state.workspaceFilter })
1430
+ syncCustomSelect(sessionSelect)
1431
+ }
1415
1432
  const selectedWorkspace = workspaceById(state.workspaceFilter)
1416
1433
  const pathBox = $('session-workspace-path')
1417
1434
  if (pathBox) pathBox.textContent = selectedWorkspace?.path || (state.workspaceFilter === WORKSPACE_UNGROUPED ? t('workspace.ungrouped') : t('workspace.allPath'))
@@ -1423,7 +1440,10 @@ function renderWorkspaceNavigation() {
1423
1440
  LS.del('fsWorkspaceIdV1')
1424
1441
  }
1425
1442
  const fsSelect = $('fs-workspace')
1426
- if (fsSelect) fsSelect.innerHTML = workspaceOptionsHtml({ root: true, selected: state.fs.workspaceId })
1443
+ if (fsSelect) {
1444
+ fsSelect.innerHTML = workspaceOptionsHtml({ root: true, selected: state.fs.workspaceId })
1445
+ syncCustomSelect(fsSelect)
1446
+ }
1427
1447
  if ($('modal-new-session') && !$('modal-new-session').classList.contains('hidden')) renderNewSessionWorkspace()
1428
1448
  return items
1429
1449
  }
@@ -1556,9 +1576,8 @@ function renderSessions() {
1556
1576
  const archivedSet = new Set(state.wbArchived || [])
1557
1577
  const visible = allItems.filter(s => {
1558
1578
  if (!state.workspaceFilter) return true
1559
- const workspace = workspaceForSession(s)
1560
- if (state.workspaceFilter === WORKSPACE_UNGROUPED) return !workspace
1561
- return workspace?.workspaceId === state.workspaceFilter
1579
+ if (state.workspaceFilter === WORKSPACE_UNGROUPED) return !workspaceForSession(s)
1580
+ return workspaceOwnsSession(workspaceById(state.workspaceFilter), s)
1562
1581
  })
1563
1582
  const archived = visible.filter(s => archivedSet.has(s.sessionId))
1564
1583
  const main = visible.filter(s => !archivedSet.has(s.sessionId))
@@ -2433,7 +2452,9 @@ function renderNewSessionWorkspace(preferredId = '') {
2433
2452
  const current = workspaceById(preferredId || select.value)?.workspaceId || items[0]?.workspaceId || ''
2434
2453
  select.innerHTML = workspaceOptionsHtml({ selected: current })
2435
2454
  select.disabled = !items.length
2455
+ syncCustomSelect(select)
2436
2456
  const workspace = workspaceById(select.value)
2457
+ $('new-session-workspace-name').textContent = workspace ? workspaceName(workspace) : ''
2437
2458
  $('new-session-workspace-path').textContent = workspace?.path || ''
2438
2459
  $('new-session-empty').classList.toggle('hidden', !!items.length)
2439
2460
  $('new-session-create').disabled = !workspace
@@ -3397,6 +3418,10 @@ async function loadLocalVersion() {
3397
3418
  const ANNOUNCEMENTS_KEY = 'seenAnnouncementsV1'
3398
3419
  const ANNOUNCEMENT_HISTORY_KEY = 'announcementHistoryV1'
3399
3420
  const ANNOUNCEMENT_VOTES_KEY = 'announcementVotesV1'
3421
+ const ANNOUNCEMENTS_POLL_MS = 30 * 1000
3422
+ let announcementCheckPromise = null
3423
+ let announcementPollTimer = null
3424
+ let announcementPollingStarted = false
3400
3425
  function readSeenAnnouncements() {
3401
3426
  try {
3402
3427
  const value = JSON.parse(LS.get(ANNOUNCEMENTS_KEY, '{}'))
@@ -3413,6 +3438,7 @@ function markAnnouncementSeen(id) {
3413
3438
  for (const key of keys.slice(0, keys.length - 100)) delete seen[key]
3414
3439
  }
3415
3440
  LS.set(ANNOUNCEMENTS_KEY, JSON.stringify(seen))
3441
+ renderAnnouncementBoard()
3416
3442
  }
3417
3443
  function readAnnouncementVotes() {
3418
3444
  try {
@@ -3432,6 +3458,7 @@ function storeAnnouncementVote(announcementId, pollId, optionId) {
3432
3458
  for (const key of keys.slice(0, keys.length - 100)) delete votes[key]
3433
3459
  }
3434
3460
  LS.set(ANNOUNCEMENT_VOTES_KEY, JSON.stringify(votes))
3461
+ renderAnnouncementBoard()
3435
3462
  }
3436
3463
  function announcementVote(item) {
3437
3464
  if (!item?.poll?.id) return null
@@ -3453,6 +3480,36 @@ function storeAnnouncementHistory(items) {
3453
3480
  LS.set(ANNOUNCEMENT_HISTORY_KEY, JSON.stringify(list))
3454
3481
  return list
3455
3482
  }
3483
+ function announcementBoardItems() {
3484
+ const seen = readSeenAnnouncements()
3485
+ const merged = new Map(readAnnouncementHistory().map(item => [item.id, item]))
3486
+ for (const item of state.announcements) if (item?.id) merged.set(item.id, item)
3487
+ return [...merged.values()]
3488
+ .filter(item => !seen[item.id])
3489
+ .sort((a, b) => Number(b.publishedAt || 0) - Number(a.publishedAt || 0))
3490
+ .slice(0, 1)
3491
+ }
3492
+ function renderAnnouncementBoard() {
3493
+ const box = $('overview-announcement-list')
3494
+ if (!box) return
3495
+ const items = announcementBoardItems()
3496
+ if (!items.length) {
3497
+ box.innerHTML = `<div class="overview-empty">${esc(t('overview.communityEmpty'))}</div>`
3498
+ return
3499
+ }
3500
+ box.innerHTML = items.map(item => {
3501
+ const poll = !!item.poll
3502
+ const vote = announcementVote(item)
3503
+ const date = Number(item.publishedAt) > 0 ? fmtFullTime(item.publishedAt) : t('announcement.noDate')
3504
+ const action = poll ? (vote ? t('overview.communityVoted') : t('overview.communityVote')) : t('overview.communityView')
3505
+ return `<button class="overview-announcement-card ${poll ? 'poll' : 'notice'}" type="button" data-home-announcement="${esc(item.id)}">
3506
+ <span class="overview-announcement-meta"><span class="overview-announcement-badge">${esc(t(poll ? 'overview.communityPoll' : 'overview.communityNotice'))}</span><span class="overview-announcement-new">${esc(t('overview.communityNew'))}</span><span class="overview-announcement-date">${esc(date)}</span></span>
3507
+ <span class="overview-announcement-title">${esc(item.title)}</span>
3508
+ <span class="overview-announcement-copy">${esc(item.content)}</span>
3509
+ <span class="overview-announcement-footer"><span>${esc(action)}</span><span aria-hidden="true">›</span></span>
3510
+ </button>`
3511
+ }).join('')
3512
+ }
3456
3513
  function renderAnnouncementHistory() {
3457
3514
  const box = $('announcement-history-list')
3458
3515
  if (!box) return
@@ -3613,7 +3670,7 @@ function closeAnnouncement(markSeen) {
3613
3670
  state.announcement = null
3614
3671
  $('modal-announcement').classList.add('hidden')
3615
3672
  }
3616
- async function checkAnnouncements() {
3673
+ async function fetchAnnouncements() {
3617
3674
  const base = updateBase()
3618
3675
  if (!base || !state.localVersion) return false
3619
3676
  try {
@@ -3626,16 +3683,36 @@ async function checkAnnouncements() {
3626
3683
  const data = JSON.parse(raw)
3627
3684
  const source = Array.isArray(data) ? data : (Array.isArray(data?.items) ? data.items : [data])
3628
3685
  const normalized = source.map(item => normalizeAnnouncement(item, base)).filter(Boolean)
3686
+ state.announcements = normalized.sort((a, b) => Number(b.publishedAt || 0) - Number(a.publishedAt || 0))
3629
3687
  storeAnnouncementHistory(normalized)
3688
+ renderAnnouncementBoard()
3630
3689
  const seen = readSeenAnnouncements()
3631
3690
  const items = normalized.filter(item => !seen[item.id])
3632
3691
  .sort((a, b) => b.publishedAt - a.publishedAt)
3633
- if (!items.length) return false
3692
+ if (!items.length || state.announcement) return false
3634
3693
  openAnnouncementModal(items[0])
3635
3694
  return true
3636
3695
  } catch { return false }
3637
3696
  }
3638
3697
 
3698
+ function checkAnnouncements() {
3699
+ if (announcementCheckPromise) return announcementCheckPromise
3700
+ announcementCheckPromise = fetchAnnouncements().finally(() => { announcementCheckPromise = null })
3701
+ return announcementCheckPromise
3702
+ }
3703
+
3704
+ function startAnnouncementPolling() {
3705
+ if (announcementPollingStarted) return
3706
+ announcementPollingStarted = true
3707
+ announcementPollTimer = setInterval(() => {
3708
+ if (!document.hidden) void checkAnnouncements()
3709
+ }, ANNOUNCEMENTS_POLL_MS)
3710
+ document.addEventListener('visibilitychange', () => {
3711
+ if (!document.hidden) void checkAnnouncements()
3712
+ })
3713
+ window.addEventListener('online', () => { void checkAnnouncements() })
3714
+ }
3715
+
3639
3716
  /* ---------------- 更新内容弹窗 ---------------- */
3640
3717
  const NOTES_KEY = 'seenNotesVersion'
3641
3718
  let notesVersion = ''
@@ -4321,36 +4398,155 @@ function initToken() {
4321
4398
  $('server-desc').textContent = state.server || t('servers.defaultDesc')
4322
4399
  }
4323
4400
 
4324
- function renderDshControlStatus(v) {
4325
- const desc = $('dsh-control-desc')
4326
- if (!desc || !v) return
4401
+ let dshControlPollPromise = null
4402
+
4403
+ function dshControlButtonsBusy(busy, supported = true) {
4327
4404
  const buttons = [$('btn-dsh-start'), $('btn-dsh-restart')].filter(Boolean)
4328
- if (v.supported === false) {
4329
- desc.textContent = v.message || t('settings.dshUnsupported')
4330
- buttons.forEach(b => { b.disabled = true; b.classList.add('hidden') })
4405
+ buttons.forEach(button => {
4406
+ button.disabled = busy || !supported
4407
+ button.classList.toggle('hidden', !supported)
4408
+ })
4409
+ }
4410
+
4411
+ function dshControlFailureText(value) {
4412
+ const key = {
4413
+ SERVICE_NOT_FOUND: 'settings.dshErrorServiceNotFound',
4414
+ INVALID_SERVICE: 'settings.dshErrorInvalidService',
4415
+ SYSTEMCTL_NOT_FOUND: 'settings.dshErrorSystemctlNotFound',
4416
+ SYSTEMD_UNAVAILABLE: 'settings.dshErrorSystemdUnavailable',
4417
+ PERMISSION_DENIED: 'settings.dshErrorPermissionDenied',
4418
+ COMMAND_TIMEOUT: 'settings.dshErrorCommandTimeout',
4419
+ COMMAND_FAILED: 'settings.dshErrorCommandFailed',
4420
+ SERVICE_FAILED: 'settings.dshErrorServiceFailed',
4421
+ SERVICE_TIMEOUT: 'settings.dshErrorServiceTimeout',
4422
+ UPSTREAM_TIMEOUT: 'settings.dshErrorUpstreamTimeout',
4423
+ EVENTS_TIMEOUT: 'settings.dshErrorEventsTimeout',
4424
+ OPERATION_NOT_FOUND: 'settings.dshErrorOperationNotFound',
4425
+ OPERATION_IN_PROGRESS: 'settings.dshErrorOperationProgress',
4426
+ }[value?.code]
4427
+ return key
4428
+ ? t(key, { service: value?.service || value?.status?.service || 'dsh-web' })
4429
+ : t('settings.dshErrorGeneric', { msg: value?.error || value?.message || value?.code || t('conn.off') })
4430
+ }
4431
+
4432
+ function dshControlStageText(operation, step = operation) {
4433
+ const seconds = Math.max(0, Math.round(Number(step?.elapsedMs ?? operation?.elapsedMs ?? 0) / 1000))
4434
+ const action = operation?.action === 'start' ? t('settings.dshStart') : t('settings.dshRestart')
4435
+ const service = operation?.service || operation?.status?.service || 'dsh-web'
4436
+ if (step?.stage === 'queued') return t('settings.dshStageQueued')
4437
+ if (step?.stage === 'checking') return t('settings.dshStageChecking', { service })
4438
+ if (step?.stage === 'command') return t('settings.dshStageCommand', { action })
4439
+ if (step?.stage === 'waiting-service') return t('settings.dshStageWaitingService', { seconds })
4440
+ if (step?.stage === 'waiting-upstream') return t('settings.dshStageWaitingUpstream', { seconds })
4441
+ if (step?.stage === 'waiting-events') return t('settings.dshStageWaitingEvents', { seconds })
4442
+ if (step?.stage === 'complete') {
4443
+ if (operation?.code === 'ALREADY_RUNNING') return t('settings.dshAlreadyRunning', { pid: operation?.status?.mainPid || '—' })
4444
+ return t('settings.dshSuccessDetail', {
4445
+ action,
4446
+ pid: operation?.status?.mainPid || '—',
4447
+ status: operation?.upstream?.status || '—',
4448
+ seconds: Math.max(0, Math.round(Number(operation?.elapsedMs || step?.elapsedMs || 0) / 1000)),
4449
+ })
4450
+ }
4451
+ if (step?.stage === 'failed') return dshControlFailureText(operation)
4452
+ return step?.message || operation?.message || '—'
4453
+ }
4454
+
4455
+ function renderDshControlOperation(operation) {
4456
+ const desc = $('dsh-control-desc')
4457
+ const box = $('dsh-control-steps')
4458
+ if (!desc || !box || !operation) return
4459
+ const failed = operation.stage === 'failed' || operation.done && operation.ok === false
4460
+ desc.textContent = failed ? t('settings.dshFailed', { msg: dshControlFailureText(operation) }) : dshControlStageText(operation)
4461
+ const steps = Array.isArray(operation.steps) && operation.steps.length
4462
+ ? operation.steps
4463
+ : [{ stage: operation.stage || 'queued', elapsedMs: operation.elapsedMs || 0, message: operation.message || '' }]
4464
+ box.classList.remove('hidden')
4465
+ box.innerHTML = steps.map((step, index) => {
4466
+ const current = !operation.done && index === steps.length - 1
4467
+ const isFailed = step.stage === 'failed'
4468
+ const success = step.stage === 'complete'
4469
+ const mark = isFailed ? '×' : success ? '✓' : current ? '…' : '✓'
4470
+ const cls = isFailed ? 'failed' : success ? 'success' : current ? 'current' : ''
4471
+ const detail = isFailed && operation.detail
4472
+ ? `<span class="dsh-control-step-detail">${esc(t('settings.dshErrorDetail', { detail: operation.detail }))}</span>`
4473
+ : ''
4474
+ return `<div class="dsh-control-step ${cls}"><span class="dsh-control-step-mark" aria-hidden="true">${mark}</span><span>${esc(dshControlStageText(operation, step))}</span>${detail}</div>`
4475
+ }).join('')
4476
+ dshControlButtonsBusy(!operation.done, true)
4477
+ }
4478
+
4479
+ async function readDshControlResponse(res) {
4480
+ const text = await res.text()
4481
+ if (!text) return {}
4482
+ try { return JSON.parse(text) } catch { return { error: `HTTP ${res.status}`, detail: text.slice(0, 500) } }
4483
+ }
4484
+
4485
+ async function pollDshControlOperation(operationId, initial) {
4486
+ let value = initial
4487
+ while (true) {
4488
+ renderDshControlOperation(value)
4489
+ if (value.done) return value
4490
+ await new Promise(resolvePromise => setTimeout(resolvePromise, 700))
4491
+ const res = await fetch(adminApiUrl(`/admin/api/dsh?operation=${encodeURIComponent(operationId)}`), {
4492
+ headers: { authorization: 'Bearer ' + state.token }, cache: 'no-store'
4493
+ })
4494
+ if (res.status === 401) { authFailure(); throw Object.assign(new Error('unauthorized'), { auth: true }) }
4495
+ const next = await readDshControlResponse(res)
4496
+ if (!res.ok) throw Object.assign(new Error(next.error || next.message || `HTTP ${res.status}`), { dshPayload: { ...next, httpStatus: res.status } })
4497
+ value = next
4498
+ }
4499
+ }
4500
+
4501
+ function renderDshControlStatus(value) {
4502
+ const desc = $('dsh-control-desc')
4503
+ const box = $('dsh-control-steps')
4504
+ if (!desc || !value) return
4505
+ if (value.supported === false) {
4506
+ desc.textContent = value.code ? dshControlFailureText(value) : value.message || t('settings.dshUnsupported')
4507
+ dshControlButtonsBusy(false, false)
4331
4508
  return
4332
4509
  }
4333
- buttons.forEach(b => { b.disabled = false; b.classList.remove('hidden') })
4334
- desc.textContent = `${t(v.running ? 'settings.dshRunning' : 'settings.dshStopped')} · ${v.service || 'dsh-web'}`
4510
+ if (value.ok === false) {
4511
+ desc.textContent = t('settings.dshStatusFailed', { msg: dshControlFailureText(value) })
4512
+ dshControlButtonsBusy(false, false)
4513
+ return
4514
+ }
4515
+ dshControlButtonsBusy(false, true)
4516
+ if (box && !dshControlPollPromise) box.classList.add('hidden')
4517
+ const service = value.service || 'dsh-web'
4518
+ const serviceState = `${value.activeState || value.state || 'unknown'}/${value.subState || 'unknown'}`
4519
+ desc.textContent = value.running
4520
+ ? t('settings.dshRunningDetail', { service, pid: value.mainPid || '—', state: serviceState })
4521
+ : t('settings.dshStoppedDetail', { service, state: serviceState })
4335
4522
  }
4336
4523
 
4337
4524
  async function loadDshControl() {
4338
4525
  if (!state.token || !$('dsh-control-desc')) return
4339
4526
  try {
4340
- const res = await fetch(adminApiUrl('/admin/api/dsh'), { headers: { authorization: 'Bearer ' + state.token } })
4527
+ const res = await fetch(adminApiUrl('/admin/api/dsh'), { headers: { authorization: 'Bearer ' + state.token }, cache: 'no-store' })
4341
4528
  if (res.status === 401) return authFailure()
4342
- renderDshControlStatus(await res.json())
4343
- } catch {
4344
- $('dsh-control-desc').textContent = t('settings.dshFailed', { msg: t('conn.off') })
4529
+ const value = await readDshControlResponse(res)
4530
+ if (!res.ok) throw Object.assign(new Error(value.error || value.message || `HTTP ${res.status}`), { dshPayload: value })
4531
+ renderDshControlStatus(value)
4532
+ if (value.operation?.operationId && !value.operation.done && !dshControlPollPromise) {
4533
+ dshControlPollPromise = pollDshControlOperation(value.operation.operationId, value.operation)
4534
+ .then(renderDshControlOperation)
4535
+ .catch(error => {
4536
+ if (!error?.auth) $('dsh-control-desc').textContent = t('settings.dshFailed', { msg: error?.dshPayload ? dshControlFailureText(error.dshPayload) : t('settings.dshResultUnknown') })
4537
+ })
4538
+ .finally(() => { dshControlPollPromise = null; dshControlButtonsBusy(false, true) })
4539
+ }
4540
+ } catch (error) {
4541
+ $('dsh-control-desc').textContent = t('settings.dshStatusFailed', { msg: error?.dshPayload ? dshControlFailureText(error.dshPayload) : t('conn.off') })
4345
4542
  }
4346
4543
  }
4347
4544
 
4348
4545
  async function controlDsh(action) {
4546
+ if (dshControlPollPromise) return
4349
4547
  const label = action === 'start' ? t('settings.dshStart') : t('settings.dshRestart')
4350
- const buttons = [$('btn-dsh-start'), $('btn-dsh-restart')].filter(Boolean)
4351
- buttons.forEach(b => { b.disabled = true })
4352
- const desc = $('dsh-control-desc')
4353
- if (desc) desc.textContent = t('settings.dshStarting', { action: label })
4548
+ dshControlButtonsBusy(true, true)
4549
+ renderDshControlOperation({ action, service: 'dsh-web', accepted: true, done: false, stage: 'queued', elapsedMs: 0, steps: [] })
4354
4550
  try {
4355
4551
  const res = await fetch(adminApiUrl('/admin/api/dsh'), {
4356
4552
  method: 'POST',
@@ -4358,15 +4554,33 @@ async function controlDsh(action) {
4358
4554
  body: JSON.stringify({ action }),
4359
4555
  })
4360
4556
  if (res.status === 401) return authFailure()
4361
- const v = await res.json().catch(() => ({}))
4362
- if (!res.ok || v.ok === false) throw new Error(v.error || v.message || `HTTP ${res.status}`)
4557
+ let v = await readDshControlResponse(res)
4558
+ if (res.status === 409 && v.operation?.operationId) v = v.operation
4559
+ else if (!res.ok) throw Object.assign(new Error(v.error || v.message || `HTTP ${res.status}`), { dshPayload: { ...v, httpStatus: res.status } })
4560
+
4561
+ if (v.accepted && v.operationId) {
4562
+ dshControlPollPromise = pollDshControlOperation(v.operationId, v)
4563
+ v = await dshControlPollPromise
4564
+ if (!v.ok) throw Object.assign(new Error(dshControlFailureText(v)), { dshPayload: v })
4565
+ renderDshControlOperation(v)
4566
+ toast(dshControlStageText(v), 'ok')
4567
+ return
4568
+ }
4569
+
4570
+ // 兼容旧网关:它会在单个 POST 中直接返回最终状态。
4571
+ if (v.ok === false) throw Object.assign(new Error(v.error || v.message || `HTTP ${res.status}`), { dshPayload: v })
4363
4572
  renderDshControlStatus(v)
4364
4573
  toast(t('settings.dshStarted', { action: label }), 'ok')
4365
- } catch (e) {
4366
- if (desc) desc.textContent = t('settings.dshFailed', { msg: e?.message || e })
4367
- toast(t('settings.dshFailed', { msg: e?.message || e }), 'err')
4574
+ } catch (error) {
4575
+ if (error?.auth) return
4576
+ const payload = error?.dshPayload
4577
+ const message = payload ? dshControlFailureText(payload) : t('settings.dshResultUnknown')
4578
+ if (payload) renderDshControlOperation({ action, service: payload.service || 'dsh-web', done: true, ok: false, stage: 'failed', steps: payload.steps || [], ...payload })
4579
+ else $('dsh-control-desc').textContent = t('settings.dshFailed', { msg: message })
4580
+ toast(t('settings.dshFailed', { msg: message }), 'err')
4368
4581
  } finally {
4369
- buttons.forEach(b => { b.disabled = false })
4582
+ dshControlPollPromise = null
4583
+ dshControlButtonsBusy(false, true)
4370
4584
  }
4371
4585
  }
4372
4586
 
@@ -4427,6 +4641,7 @@ function bindUi() {
4427
4641
  renderServers()
4428
4642
  renderSessions()
4429
4643
  renderWorkbench()
4644
+ renderAnnouncementBoard()
4430
4645
  renderPending(); renderQueue(); renderJobs()
4431
4646
  updateConn()
4432
4647
  if (state.current) { renderSessionTitle(); renderSessionSub(); renderSessionCards(); renderHistory(true) }
@@ -4526,6 +4741,11 @@ function bindUi() {
4526
4741
  $('btn-stats').addEventListener('click', () => { renderSessionCards(); $('modal-stats').classList.remove('hidden') })
4527
4742
  $('stats-close').addEventListener('click', () => $('modal-stats').classList.add('hidden'))
4528
4743
  $('btn-refresh').addEventListener('click', () => { toast(t('common.refreshing')); openStreams(); refreshAll() })
4744
+ $('overview-refresh').addEventListener('click', () => {
4745
+ toast(t('common.refreshing'))
4746
+ openStreams()
4747
+ void Promise.all([refreshAll(), checkAnnouncements()])
4748
+ })
4529
4749
  // 反馈
4530
4750
  $('btn-feedback').addEventListener('click', (e) => { e.stopPropagation(); toggleFeedbackSheet() })
4531
4751
  $('feedback-backdrop').addEventListener('click', closeFeedbackSheet)
@@ -4540,6 +4760,10 @@ function bindUi() {
4540
4760
  $('btn-write-feedback').addEventListener('click', () => { closeFeedbackSheet(); openFeedbackModal() })
4541
4761
  $('fb-cancel').addEventListener('click', closeFeedbackModal)
4542
4762
  $('fb-submit').addEventListener('click', submitFeedback)
4763
+ $('feedback-success-confirm').addEventListener('click', closeFeedbackSuccess)
4764
+ $('modal-feedback-success').addEventListener('click', (e) => {
4765
+ if (e.target === $('modal-feedback-success')) closeFeedbackSuccess()
4766
+ })
4543
4767
  document.querySelectorAll('#fb-chips .fb-chip').forEach(btn =>
4544
4768
  btn.addEventListener('click', () => {
4545
4769
  state.feedbackType = btn.dataset.fbType
@@ -4550,6 +4774,7 @@ function bindUi() {
4550
4774
  })
4551
4775
  document.addEventListener('keydown', (e) => {
4552
4776
  if (e.key === 'Escape' && !$('feedback-sheet').classList.contains('hidden')) { closeFeedbackSheet(); $('btn-feedback').focus() }
4777
+ else if (e.key === 'Escape' && !$('modal-feedback-success').classList.contains('hidden')) closeFeedbackSuccess()
4553
4778
  })
4554
4779
  $('btn-new-session').addEventListener('click', newSession)
4555
4780
  $('session-workspace-filter').addEventListener('change', (e) => {
@@ -4699,6 +4924,13 @@ function bindUi() {
4699
4924
  $('modal-announcement-history').addEventListener('click', (e) => {
4700
4925
  if (e.target === $('modal-announcement-history')) closeAnnouncementHistory()
4701
4926
  })
4927
+ $('overview-announcement-history').addEventListener('click', openAnnouncementHistory)
4928
+ $('overview-announcement-list').addEventListener('click', (e) => {
4929
+ const button = e.target.closest('[data-home-announcement]')
4930
+ if (!button) return
4931
+ const item = announcementBoardItems().find(entry => entry.id === button.dataset.homeAnnouncement)
4932
+ if (item) openAnnouncementModal(item)
4933
+ })
4702
4934
  // 设置
4703
4935
  $('view-settings').addEventListener('click', (e) => {
4704
4936
  const group = e.target.closest('[data-settings-group]')
@@ -4873,6 +5105,7 @@ function applyNativeInsets() {
4873
5105
  async function boot() {
4874
5106
  initToken()
4875
5107
  bindUi()
5108
+ renderAnnouncementBoard()
4876
5109
  renderLangBtn()
4877
5110
  bindNativeBack()
4878
5111
  bindNativeLinks()
@@ -4892,7 +5125,9 @@ async function boot() {
4892
5125
  if (host) { state.hostInfo = host; $('host-desc').textContent = t('settings.hostDesc', { version: host.version, cwd: host.cwd, n: host.attachedSessions }) }
4893
5126
  loadDshControl()
4894
5127
  }
4895
- // 公告与更新共用当前服务器;公告优先,避免启动时两个弹窗重叠。
5128
+ // 网关从中央 HTTPS 公告源读取并在不可达时回退内置文件。前台每 30 秒检查,
5129
+ // 回到前台或网络恢复时立即补查;公告优先,避免启动时两个弹窗重叠。
5130
+ startAnnouncementPolling()
4896
5131
  setTimeout(async () => {
4897
5132
  const shown = await checkAnnouncements()
4898
5133
  if (!shown && state.token) checkUpdate(true)