dsh-remote-plugin 0.6.10 → 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/public/app.js CHANGED
@@ -58,12 +58,14 @@ const state = {
58
58
  selectingServer: false, // 防重入: 测速/切换中
59
59
  sessions: [],
60
60
  sessionSort: LS.get('sessionSort', 'time') === 'workspace' ? 'workspace' : 'time',
61
+ workspaceFilter: LS.get('workspaceFilterV1', ''),
61
62
  byId: new Map(),
62
63
  current: null, // 当前打开的 sessionId
63
64
  hostInfo: null,
64
65
  localVersion: '',
65
66
  updateInfo: null,
66
67
  announcement: null,
68
+ announcements: [],
67
69
  approvals: [], // 待处理审批
68
70
  questions: [], // 待处理提问
69
71
  queues: {}, // sessionId -> queue items
@@ -77,7 +79,7 @@ const state = {
77
79
  streamMode: 'ws', // 'ws' | 'poll'
78
80
  pollSeq: { mux: 0, host: 0 },
79
81
  refreshTimer: null,
80
- fs: { path: null, initial: null, loaded: false, upload: null },
82
+ fs: { path: null, initial: null, loaded: false, upload: null, workspaceId: LS.get('fsWorkspaceIdV1', ''), preview: null },
81
83
  composerImages: [], // 当前草稿中的图片附件:只在发送成功后释放
82
84
  models: { loaded: false, loading: false, groups: [], current: null, failures: [] },
83
85
  wb: null,
@@ -101,6 +103,135 @@ function toast(text, kind = '') {
101
103
  toast._t = setTimeout(() => el.classList.add('hidden'), 3200)
102
104
  }
103
105
 
106
+ /* ---------------- 应用内选择抽屉 ---------------- */
107
+ const CUSTOM_SELECT_TITLES = {
108
+ 'session-workspace-filter': 'workspace.select',
109
+ 'session-sort': 'sessions.sortLabel',
110
+ 'fs-workspace': 'workspace.select',
111
+ 'mobile-enter-action': 'settings.mobileEnterTitle',
112
+ 'bg-interval': 'settings.bgIntervalTitle',
113
+ 'new-session-workspace': 'workspace.select'
114
+ }
115
+ let customSelectCurrent = null
116
+
117
+ function syncCustomSelect(select) {
118
+ const trigger = select?._customSelectTrigger
119
+ if (!trigger) return
120
+ const option = select.selectedOptions?.[0] || select.options?.[select.selectedIndex]
121
+ const label = trigger.querySelector('.custom-select-trigger-label')
122
+ if (label) label.textContent = option?.textContent?.trim() || t('select.choose')
123
+ trigger.disabled = !!select.disabled
124
+ trigger.setAttribute('aria-expanded', customSelectCurrent?.select === select ? 'true' : 'false')
125
+ }
126
+
127
+ function closeCustomSelect({ restoreFocus = true } = {}) {
128
+ if (!$('custom-select-sheet')) return
129
+ const previous = customSelectCurrent
130
+ customSelectCurrent = null
131
+ $('custom-select-backdrop').classList.add('hidden')
132
+ $('custom-select-sheet').classList.add('hidden')
133
+ if (previous?.select) syncCustomSelect(previous.select)
134
+ if (restoreFocus) previous?.trigger?.focus?.({ preventScroll: true })
135
+ }
136
+
137
+ function customSelectOptionCopy(option) {
138
+ const copy = document.createElement('span')
139
+ copy.className = 'custom-select-option-copy'
140
+ const raw = option.textContent?.trim() || ''
141
+ const separator = raw.indexOf(' — ')
142
+ const name = document.createElement('span')
143
+ name.className = 'custom-select-option-name'
144
+ name.textContent = separator >= 0 ? raw.slice(0, separator) : raw
145
+ copy.appendChild(name)
146
+ if (separator >= 0) {
147
+ const path = document.createElement('span')
148
+ path.className = 'custom-select-option-path'
149
+ path.textContent = raw.slice(separator + 3)
150
+ copy.appendChild(path)
151
+ }
152
+ return copy
153
+ }
154
+
155
+ function openCustomSelect(select) {
156
+ const trigger = select?._customSelectTrigger
157
+ if (!select || !trigger || select.disabled) return
158
+ closeFeedbackSheet()
159
+ syncCustomSelect(select)
160
+ customSelectCurrent = { select, trigger }
161
+ const titleKey = CUSTOM_SELECT_TITLES[select.id]
162
+ $('custom-select-title').textContent = t(titleKey || 'select.choose')
163
+ const options = $('custom-select-options')
164
+ options.replaceChildren()
165
+ let selectedButton = null
166
+ Array.from(select.options).forEach(option => {
167
+ const button = document.createElement('button')
168
+ button.type = 'button'
169
+ button.className = 'custom-select-option' + (option.selected ? ' current' : '')
170
+ button.setAttribute('role', 'option')
171
+ button.setAttribute('aria-selected', option.selected ? 'true' : 'false')
172
+ button.disabled = option.disabled
173
+ button.appendChild(customSelectOptionCopy(option))
174
+ const check = document.createElement('span')
175
+ check.className = 'custom-select-option-check'
176
+ check.setAttribute('aria-hidden', 'true')
177
+ check.textContent = option.selected ? '✓' : ''
178
+ button.appendChild(check)
179
+ button.addEventListener('click', () => {
180
+ if (option.disabled) return
181
+ select.value = option.value
182
+ select.dispatchEvent(new Event('input', { bubbles: true }))
183
+ select.dispatchEvent(new Event('change', { bubbles: true }))
184
+ syncCustomSelect(select)
185
+ closeCustomSelect()
186
+ })
187
+ options.appendChild(button)
188
+ if (option.selected) selectedButton = button
189
+ })
190
+ $('custom-select-backdrop').classList.remove('hidden')
191
+ $('custom-select-sheet').classList.remove('hidden')
192
+ syncCustomSelect(select)
193
+ setTimeout(() => (selectedButton || options.querySelector('button:not(:disabled)'))?.focus(), 30)
194
+ }
195
+
196
+ function enhanceCustomSelect(select) {
197
+ if (!select || select._customSelectTrigger || select.multiple || Number(select.size) > 1) return
198
+ select.classList.add('custom-select-native')
199
+ select.tabIndex = -1
200
+ select.setAttribute('aria-hidden', 'true')
201
+ const trigger = document.createElement('button')
202
+ trigger.type = 'button'
203
+ trigger.id = `${select.id}-trigger`
204
+ trigger.className = 'custom-select-trigger'
205
+ if (select.classList.contains('workspace-select')) trigger.classList.add('workspace-select-trigger')
206
+ if (select.classList.contains('full')) trigger.classList.add('full')
207
+ if (select.classList.contains('session-sort')) trigger.classList.add('session-sort-trigger')
208
+ if (select.classList.contains('setting-select')) trigger.classList.add('setting-select-trigger')
209
+ trigger.setAttribute('aria-haspopup', 'listbox')
210
+ trigger.setAttribute('aria-expanded', 'false')
211
+ const label = document.createElement('span')
212
+ label.className = 'custom-select-trigger-label'
213
+ trigger.appendChild(label)
214
+ trigger.addEventListener('click', () => openCustomSelect(select))
215
+ select.insertAdjacentElement('afterend', trigger)
216
+ select._customSelectTrigger = trigger
217
+ const associatedLabel = document.querySelector(`label[for="${CSS.escape(select.id)}"]`)
218
+ if (associatedLabel) associatedLabel.htmlFor = trigger.id
219
+ select.addEventListener('change', () => syncCustomSelect(select))
220
+ select._customSelectObserver = new MutationObserver(() => syncCustomSelect(select))
221
+ select._customSelectObserver.observe(select, { childList: true, subtree: true, attributes: true, attributeFilter: ['disabled', 'selected'] })
222
+ syncCustomSelect(select)
223
+ }
224
+
225
+ function initCustomSelects() {
226
+ document.querySelectorAll('select').forEach(enhanceCustomSelect)
227
+ $('custom-select-backdrop').addEventListener('click', () => closeCustomSelect())
228
+ $('custom-select-close').addEventListener('click', () => closeCustomSelect())
229
+ $('custom-select-cancel').addEventListener('click', () => closeCustomSelect())
230
+ document.addEventListener('keydown', (event) => {
231
+ if (event.key === 'Escape' && customSelectCurrent) closeCustomSelect()
232
+ })
233
+ }
234
+
104
235
  /* ---------------- 反馈 ---------------- */
105
236
  const FEEDBACK_LINKS = {
106
237
  githubIssues: 'https://github.com/Blank-not-black/dsh-Remote/issues',
@@ -142,6 +273,14 @@ function openFeedbackModal() {
142
273
  setTimeout(() => $('fb-msg').focus(), 50)
143
274
  }
144
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
+ }
145
284
  async function submitFeedback() {
146
285
  const type = state.feedbackType || 'bug'
147
286
  const message = $('fb-msg').value.trim()
@@ -151,7 +290,8 @@ async function submitFeedback() {
151
290
  const btn = $('fb-submit')
152
291
  btn.disabled = true
153
292
  try {
154
- const base = (state.server || '').replace(/\/+$/, '')
293
+ const base = updateBase()
294
+ if (!base) throw new Error(t('feedback.networkError'))
155
295
  const res = await fetch(base + '/feedback', {
156
296
  method: 'POST',
157
297
  headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token },
@@ -159,7 +299,7 @@ async function submitFeedback() {
159
299
  })
160
300
  let json = {}
161
301
  try { json = await res.json() } catch {}
162
- if (res.ok && json.ok) { toast(t('feedback.submitted'), 'ok'); closeFeedbackModal() }
302
+ if (res.ok && json.ok) { closeFeedbackModal(); openFeedbackSuccess() }
163
303
  else if (res.status === 429) { toast(json.retryAfter ? t('feedback.rateLimitedAt', { n: json.retryAfter }) : t('feedback.rateLimited'), 'err') }
164
304
  else { toast(t('feedback.submitFailed', { msg: json.error || res.status }), 'err') }
165
305
  } catch {
@@ -1244,6 +1384,69 @@ function wbJoin(root, name) {
1244
1384
  function workbenchRoot() {
1245
1385
  return state.wb?.bound && state.wb.path ? state.wb.path : ''
1246
1386
  }
1387
+ const WORKSPACE_UNGROUPED = '__ungrouped__'
1388
+ function workspaceItems() {
1389
+ return (state.wbProjects || []).filter(w => w && typeof w.workspaceId === 'string' && w.workspaceId && typeof w.path === 'string' && w.path)
1390
+ }
1391
+ function workspaceById(workspaceId) {
1392
+ return workspaceItems().find(w => w.workspaceId === workspaceId) || null
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
+ }
1402
+ function workspaceForSession(session) {
1403
+ if (!session) return null
1404
+ return workspaceItems().find(workspace => workspaceOwnsSession(workspace, session)) || null
1405
+ }
1406
+ function workspaceName(workspace) {
1407
+ return String(workspace?.title || wbBaseName(workspace?.path) || workspace?.path || '').trim()
1408
+ }
1409
+ function workspaceOptionLabel(workspace) {
1410
+ const name = workspaceName(workspace)
1411
+ return workspace.path && workspace.path !== name ? `${name} — ${workspace.path}` : name
1412
+ }
1413
+ function workspaceOptionsHtml({ all = false, ungrouped = false, root = false, selected = '' } = {}) {
1414
+ const rows = []
1415
+ if (all) rows.push({ id: '', label: t('workspace.all') })
1416
+ if (root) rows.push({ id: '', label: t('fs.root') })
1417
+ for (const workspace of workspaceItems()) rows.push({ id: workspace.workspaceId, label: workspaceOptionLabel(workspace) })
1418
+ if (ungrouped) rows.push({ id: WORKSPACE_UNGROUPED, label: t('workspace.ungrouped') })
1419
+ return rows.map(row => `<option value="${esc(row.id)}"${row.id === selected ? ' selected' : ''}>${esc(row.label)}</option>`).join('')
1420
+ }
1421
+ function renderWorkspaceNavigation() {
1422
+ const items = workspaceItems()
1423
+ if (state.workspaceFilter !== WORKSPACE_UNGROUPED && state.workspaceFilter && !workspaceById(state.workspaceFilter)) {
1424
+ state.workspaceFilter = ''
1425
+ LS.del('workspaceFilterV1')
1426
+ }
1427
+ const sessionSelect = $('session-workspace-filter')
1428
+ if (sessionSelect) {
1429
+ sessionSelect.innerHTML = workspaceOptionsHtml({ all: true, ungrouped: true, selected: state.workspaceFilter })
1430
+ syncCustomSelect(sessionSelect)
1431
+ }
1432
+ const selectedWorkspace = workspaceById(state.workspaceFilter)
1433
+ const pathBox = $('session-workspace-path')
1434
+ if (pathBox) pathBox.textContent = selectedWorkspace?.path || (state.workspaceFilter === WORKSPACE_UNGROUPED ? t('workspace.ungrouped') : t('workspace.allPath'))
1435
+ const filesButton = $('session-workspace-files')
1436
+ if (filesButton) filesButton.disabled = !selectedWorkspace
1437
+
1438
+ if (state.fs.workspaceId && !workspaceById(state.fs.workspaceId)) {
1439
+ state.fs.workspaceId = ''
1440
+ LS.del('fsWorkspaceIdV1')
1441
+ }
1442
+ const fsSelect = $('fs-workspace')
1443
+ if (fsSelect) {
1444
+ fsSelect.innerHTML = workspaceOptionsHtml({ root: true, selected: state.fs.workspaceId })
1445
+ syncCustomSelect(fsSelect)
1446
+ }
1447
+ if ($('modal-new-session') && !$('modal-new-session').classList.contains('hidden')) renderNewSessionWorkspace()
1448
+ return items
1449
+ }
1247
1450
  async function refreshWorkbench() {
1248
1451
  if (!state.token) return
1249
1452
  try {
@@ -1271,7 +1474,9 @@ async function refreshWorkbench() {
1271
1474
  const listData = await listRes.json().catch(() => ({}))
1272
1475
  if (Array.isArray(listData.entries)) {
1273
1476
  const diskDirs = new Set(listData.entries.filter(e => e.type === 'dir').map(e => wbPathKey(wbJoin(state.wb.path, e.name))))
1274
- state.wbProjects = state.wbProjects.filter(w => diskDirs.has(wbPathKey(w.path)))
1477
+ // 工作台只管理自己根目录下的项目;DSH 中位于其他路径的工作区必须保留,
1478
+ // 否则手机的工作区选择器会因绑定了工作台而丢失条目。
1479
+ state.wbProjects = state.wbProjects.filter(w => !wbStrictInside(w.path, state.wb.path) || diskDirs.has(wbPathKey(w.path)))
1275
1480
  const have = new Set(state.wbProjects.map(w => wbPathKey(w.path)))
1276
1481
  for (const entry of listData.entries) {
1277
1482
  if (entry.type !== 'dir') continue
@@ -1286,6 +1491,7 @@ async function refreshWorkbench() {
1286
1491
  }
1287
1492
  } catch {}
1288
1493
  }
1494
+ renderWorkspaceNavigation()
1289
1495
  renderWorkbench()
1290
1496
  renderSessions()
1291
1497
  }
@@ -1338,8 +1544,12 @@ function renderWorkbench() {
1338
1544
 
1339
1545
  function sessionCwd(s) { return typeof s?.cwd === 'string' ? s.cwd.trim() : '' }
1340
1546
  function sessionWorkspaceLabel(s) {
1341
- const cwd = sessionCwd(s)
1342
- return cwd || t('sessions.workspaceUnknown')
1547
+ const workspace = workspaceForSession(s)
1548
+ return workspace?.path || sessionCwd(s) || t('sessions.workspaceUnknown')
1549
+ }
1550
+ function sessionWorkspaceName(s) {
1551
+ const workspace = workspaceForSession(s)
1552
+ return workspace ? workspaceName(workspace) : workspaceDisplayName(sessionWorkspaceLabel(s))
1343
1553
  }
1344
1554
  function workspaceDisplayName(label) {
1345
1555
  const value = String(label || '').trim()
@@ -1363,14 +1573,11 @@ function sortedSessions() {
1363
1573
  function renderSessions() {
1364
1574
  const list = $('session-list')
1365
1575
  const allItems = sortedSessions()
1366
- const wbIds = new Set()
1367
- if (state.wb?.bound) for (const w of state.wbProjects) for (const id of (w.sessionIds || [])) wbIds.add(id)
1368
- const root = workbenchRoot()
1369
1576
  const archivedSet = new Set(state.wbArchived || [])
1370
1577
  const visible = allItems.filter(s => {
1371
- if (!state.wb?.bound) return true
1372
- if (archivedSet.has(s.sessionId)) return true
1373
- return !(wbIds.has(s.sessionId) || wbStrictInside(s.cwd, root))
1578
+ if (!state.workspaceFilter) return true
1579
+ if (state.workspaceFilter === WORKSPACE_UNGROUPED) return !workspaceForSession(s)
1580
+ return workspaceOwnsSession(workspaceById(state.workspaceFilter), s)
1374
1581
  })
1375
1582
  const archived = visible.filter(s => archivedSet.has(s.sessionId))
1376
1583
  const main = visible.filter(s => !archivedSet.has(s.sessionId))
@@ -1380,9 +1587,9 @@ function renderSessions() {
1380
1587
  const rows = []
1381
1588
  for (const s of items) {
1382
1589
  const workspace = sessionWorkspaceLabel(s)
1383
- const workspaceName = workspaceDisplayName(workspace)
1590
+ const workspaceTitle = sessionWorkspaceName(s)
1384
1591
  if (state.sessionSort === 'workspace' && workspace !== lastWorkspace) {
1385
- rows.push(`<div class="session-group-label" title="${esc(workspace)}"><span class="session-group-icon" aria-hidden="true">⌂</span><span class="session-group-name">${esc(workspaceName)}</span></div>`)
1592
+ rows.push(`<div class="session-group-label" title="${esc(workspace)}"><span class="session-group-icon" aria-hidden="true">⌂</span><span class="session-group-name">${esc(workspaceTitle)}</span></div>`)
1386
1593
  lastWorkspace = workspace
1387
1594
  }
1388
1595
  const title = titleOf(s)
@@ -1404,7 +1611,7 @@ function renderSessions() {
1404
1611
  ${s.running ? '<span>' + t('sessions.running') + '</span>' : ''}
1405
1612
  ${badge}${queueBadge}
1406
1613
  </div>
1407
- <div class="sc-workspace" title="${esc(workspace)}">⌂ ${esc(workspaceName)}</div>
1614
+ <div class="sc-workspace" title="${esc(workspace)}">⌂ ${esc(workspaceTitle)}</div>
1408
1615
  <span class="sc-arrow">›</span>
1409
1616
  </div>
1410
1617
  ${archiveButton}
@@ -1414,11 +1621,10 @@ function renderSessions() {
1414
1621
  }
1415
1622
  const divider = archived.length ? `<button class="archived-toggle" type="button" data-archived-toggle>${esc(showArchived ? t('wb.archivedShown') : t('wb.archivedHidden'))}</button>` : ''
1416
1623
  const rows = renderItems(main) + divider + (showArchived ? renderItems(archived) : '')
1417
- const hiddenByWorkbench = allItems.length - visible.length
1418
- list.innerHTML = rows || `<div class="empty">${esc(hiddenByWorkbench ? t('wb.flatHidden', { n: hiddenByWorkbench }) : t('home.empty'))}</div>`
1624
+ list.innerHTML = rows || `<div class="empty">${esc(t('home.empty'))}</div>`
1419
1625
  list.classList.toggle('workspace-sorted', state.sessionSort === 'workspace')
1420
1626
  const sort = $('session-sort')
1421
- if (sort) sort.value = state.sessionSort
1627
+ if (sort) { sort.value = state.sessionSort; syncCustomSelect(sort) }
1422
1628
  $('home-empty').classList.toggle('hidden', visible.length > 0)
1423
1629
  const running = state.sessions.filter(s => s.running).length
1424
1630
  const pending = state.approvals.length + state.questions.length
@@ -1460,6 +1666,7 @@ function bindNativeBack() {
1460
1666
  try {
1461
1667
  CAP.Plugins?.App?.addListener?.('backButton', () => {
1462
1668
  if ($('composer-wrap')?.classList.contains('fs')) { setComposerFullscreen(false); return }
1669
+ if (customSelectCurrent) { closeCustomSelect(); return }
1463
1670
  const openModal = [...document.querySelectorAll('.modal')].find(m => !m.classList.contains('hidden'))
1464
1671
  if (openModal) { if (openModal.id === 'modal-notes') closeNotesModal(); else if (openModal.id === 'modal-archive') closeArchiveConfirm(); else openModal.classList.add('hidden'); return } // 先关弹窗
1465
1672
  if (document.body.classList.contains('in-session')) { closeSession(); return } // 会话页 → 回主页
@@ -2232,19 +2439,43 @@ async function cancelSession() {
2232
2439
  }
2233
2440
 
2234
2441
  async function newSession() {
2235
- let payload = {}
2236
- // DSH host.describe 返回当前工作目录。每次创建前短暂刷新一次,
2237
- // 避免用户在桌面端切换工作区后,手机仍沿用启动时的旧 cwd。
2238
- try {
2239
- const host = await rpc('host.describe', {}, 5000)
2240
- const cwd = typeof host?.cwd === 'string' ? host.cwd.trim() : ''
2241
- if (cwd) {
2242
- state.hostInfo = host
2243
- payload = { cwd }
2244
- }
2245
- } catch {}
2246
- const v = await safeRpc('session.create', payload, t('home.createFailed'))
2442
+ if (!state.token) { showView('view-settings'); return }
2443
+ if (!workspaceItems().length) await refreshWorkbench()
2444
+ const preferred = workspaceById(state.workspaceFilter)?.workspaceId || LS.get('lastNewSessionWorkspaceV1', '')
2445
+ renderNewSessionWorkspace(preferred)
2446
+ $('modal-new-session').classList.remove('hidden')
2447
+ }
2448
+ function renderNewSessionWorkspace(preferredId = '') {
2449
+ const items = workspaceItems()
2450
+ const select = $('new-session-workspace')
2451
+ if (!select) return
2452
+ const current = workspaceById(preferredId || select.value)?.workspaceId || items[0]?.workspaceId || ''
2453
+ select.innerHTML = workspaceOptionsHtml({ selected: current })
2454
+ select.disabled = !items.length
2455
+ syncCustomSelect(select)
2456
+ const workspace = workspaceById(select.value)
2457
+ $('new-session-workspace-name').textContent = workspace ? workspaceName(workspace) : ''
2458
+ $('new-session-workspace-path').textContent = workspace?.path || ''
2459
+ $('new-session-empty').classList.toggle('hidden', !!items.length)
2460
+ $('new-session-create').disabled = !workspace
2461
+ }
2462
+ function closeNewSessionModal() {
2463
+ $('modal-new-session').classList.add('hidden')
2464
+ }
2465
+ async function createSessionInWorkspace() {
2466
+ const workspaceId = $('new-session-workspace').value
2467
+ if (!workspaceById(workspaceId)) return toast(t('newSession.chooseWorkspace'), 'err')
2468
+ const button = $('new-session-create')
2469
+ button.disabled = true
2470
+ const v = await safeRpc('session.create', { workspaceId: workspaceId }, t('home.createFailed'))
2471
+ button.disabled = false
2247
2472
  if (!v?.sessionId) return
2473
+ state.workspaceFilter = workspaceId
2474
+ state.fs.workspaceId = workspaceId
2475
+ LS.set('workspaceFilterV1', workspaceId)
2476
+ LS.set('fsWorkspaceIdV1', workspaceId)
2477
+ LS.set('lastNewSessionWorkspaceV1', workspaceId)
2478
+ closeNewSessionModal()
2248
2479
  toast(t('home.created'), 'ok')
2249
2480
  await refreshSessions()
2250
2481
  openSession(v.sessionId)
@@ -2554,6 +2785,32 @@ function fsParent(p) {
2554
2785
  return clean.slice(0, idx)
2555
2786
  }
2556
2787
 
2788
+ const FS_PREVIEW_EXTENSIONS = new Set([
2789
+ '.txt', '.md', '.markdown', '.log', '.json', '.jsonl', '.js', '.mjs', '.cjs', '.jsx',
2790
+ '.ts', '.tsx', '.py', '.css', '.html', '.htm', '.xml', '.yaml', '.yml', '.toml',
2791
+ '.ini', '.conf', '.env', '.sh', '.bash', '.zsh', '.fish', '.sql', '.java', '.kt',
2792
+ '.kts', '.go', '.rs', '.c', '.h', '.cpp', '.hpp', '.cs', '.php', '.rb', '.vue',
2793
+ '.svelte', '.gradle', '.properties', '.gitignore', '.dockerfile'
2794
+ ])
2795
+ function fsPreviewExtension(name) {
2796
+ const lower = String(name || '').toLowerCase()
2797
+ if (lower === 'dockerfile') return '.dockerfile'
2798
+ const dot = lower.lastIndexOf('.')
2799
+ return dot >= 0 ? lower.slice(dot) : ''
2800
+ }
2801
+ function fsCanPreview(name, size) {
2802
+ return Number(size) <= 1024 * 1024 && FS_PREVIEW_EXTENSIONS.has(fsPreviewExtension(name))
2803
+ }
2804
+ function openWorkspaceFiles(workspaceId) {
2805
+ const workspace = workspaceById(workspaceId)
2806
+ if (!workspace) return
2807
+ state.fs.workspaceId = workspace.workspaceId
2808
+ state.fs.loaded = false
2809
+ LS.set('fsWorkspaceIdV1', workspace.workspaceId)
2810
+ renderWorkspaceNavigation()
2811
+ showView('view-files')
2812
+ }
2813
+
2557
2814
  async function openWorkspaceModal() {
2558
2815
  if (!state.token) { toast(t('fs.noTokenToast'), 'err'); showView('view-settings'); return }
2559
2816
  if (!state.fs.path) await loadFs(null, { silent: true })
@@ -2581,7 +2838,19 @@ async function createWorkspace() {
2581
2838
  }
2582
2839
  closeWorkspaceModal()
2583
2840
  await loadFs(parent || null, { silent: true })
2584
- const v = await safeRpc('session.create', { cwd: data.path }, t('home.createFailed'))
2841
+ let workspace = null
2842
+ try {
2843
+ const created = await rpc('workspace.create', { path: data.path })
2844
+ workspace = created?.workspace || null
2845
+ } catch {}
2846
+ const sessionPayload = workspace?.workspaceId ? { workspaceId: workspace.workspaceId } : { cwd: data.path }
2847
+ const v = await safeRpc('session.create', sessionPayload, t('home.createFailed'))
2848
+ if (workspace?.workspaceId) {
2849
+ state.workspaceFilter = workspace.workspaceId
2850
+ state.fs.workspaceId = workspace.workspaceId
2851
+ LS.set('workspaceFilterV1', workspace.workspaceId)
2852
+ LS.set('fsWorkspaceIdV1', workspace.workspaceId)
2853
+ }
2585
2854
  await refreshSessions()
2586
2855
  if (v?.sessionId) {
2587
2856
  toast(t('workspace.created'), 'ok')
@@ -2648,17 +2917,18 @@ function renderFs(data) {
2648
2917
  }
2649
2918
  list.innerHTML = data.entries.map(e => {
2650
2919
  const isDir = e.type === 'dir'
2651
- return `<div class="fs-row" data-name="${esc(e.name)}" data-type="${esc(e.type)}">
2920
+ const preview = !isDir && fsCanPreview(e.name, e.size)
2921
+ return `<div class="fs-row" data-name="${esc(e.name)}" data-type="${esc(e.type)}" data-size="${Number(e.size) || 0}">
2652
2922
  <span class="fs-ico">${fsIconSvg(isDir)}</span>
2653
2923
  <span class="fs-meta">
2654
2924
  <span class="fs-name">${esc(e.name)}</span>
2655
2925
  <span class="fs-sub">${isDir ? t('fs.dir') : fmtSize(e.size)} · ${fmtFullTime(e.mtimeMs)}</span>
2656
2926
  </span>
2657
- <span class="fs-arrow">${isDir ? '›' : '↓'}</span>
2927
+ <span class="fs-arrow">${isDir || preview ? '›' : '↓'}</span>
2658
2928
  </div>`
2659
2929
  }).join('')
2660
2930
  list.querySelectorAll('.fs-row').forEach(row =>
2661
- row.addEventListener('click', () => fsOpenEntry(row.dataset.name, row.dataset.type)))
2931
+ row.addEventListener('click', () => fsOpenEntry(row.dataset.name, row.dataset.type, Number(row.dataset.size))))
2662
2932
  }
2663
2933
 
2664
2934
  function fsIconSvg(isDir) {
@@ -2667,15 +2937,20 @@ function fsIconSvg(isDir) {
2667
2937
  : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 3.5h8l4 4V20a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4.5a1 1 0 0 1 1-1Z"/><path d="M14 3.5v4h4M8 13h8M8 16h6"/></svg>'
2668
2938
  }
2669
2939
 
2670
- function fsOpenEntry(name, type) {
2940
+ function fsOpenEntry(name, type, size = 0) {
2671
2941
  if (!name) return
2672
2942
  const p = fsJoin(state.fs.path, name)
2673
2943
  if (type === 'dir') return loadFs(p)
2944
+ if (fsCanPreview(name, size)) return openFsPreview(p, name)
2674
2945
  downloadFsFile(name)
2675
2946
  }
2676
2947
 
2677
2948
  function downloadFsFile(name) {
2678
2949
  const p = fsJoin(state.fs.path, name)
2950
+ downloadFsPath(p, name)
2951
+ }
2952
+
2953
+ function downloadFsPath(p, name) {
2679
2954
  const url = fsApiUrl('/file', { path: p })
2680
2955
  if (CAP?.isNativePlatform?.()) {
2681
2956
  if (window.NativeFile?.downloadToDownloads) {
@@ -2701,6 +2976,54 @@ function downloadFsFile(name) {
2701
2976
  a.remove()
2702
2977
  }
2703
2978
 
2979
+ let fsPreviewGeneration = 0
2980
+ async function openFsPreview(pathValue, name) {
2981
+ const generation = ++fsPreviewGeneration
2982
+ state.fs.preview = { path: pathValue, name, extension: fsPreviewExtension(name), content: '' }
2983
+ $('file-preview-title').textContent = name
2984
+ $('file-preview-path').textContent = pathValue
2985
+ $('file-preview-loading').textContent = t('fs.previewLoading')
2986
+ $('file-preview-loading').classList.remove('hidden')
2987
+ $('file-preview-source').classList.add('hidden')
2988
+ $('file-preview-rendered').classList.add('hidden')
2989
+ $('file-preview-tabs').classList.add('hidden')
2990
+ $('modal-file-preview').classList.remove('hidden')
2991
+ try {
2992
+ const res = await fetch(fsApiUrl('/preview', { path: pathValue }), { headers: fsHeaders() })
2993
+ if (res.status === 401) { closeFsPreview(); fsAuthError(401); return }
2994
+ const data = await res.json().catch(() => ({}))
2995
+ if (generation !== fsPreviewGeneration) return
2996
+ if (!res.ok) {
2997
+ const message = data.error === 'preview-too-large' ? t('fs.previewTooLarge')
2998
+ : (data.error === 'preview-unsupported' || data.error === 'preview-binary') ? t('fs.previewUnsupported')
2999
+ : t('fs.previewFailed', { msg: data.error || ('HTTP ' + res.status) })
3000
+ throw new Error(message)
3001
+ }
3002
+ state.fs.preview = data
3003
+ $('file-preview-loading').classList.add('hidden')
3004
+ $('file-preview-source').textContent = data.content || ''
3005
+ const markdown = data.extension === '.md' || data.extension === '.markdown'
3006
+ $('file-preview-tabs').classList.toggle('hidden', !markdown)
3007
+ if (markdown) $('file-preview-rendered').innerHTML = window.mdToHtml(data.content || '')
3008
+ showFsPreviewMode(markdown ? 'rendered' : 'source')
3009
+ } catch (e) {
3010
+ if (generation !== fsPreviewGeneration) return
3011
+ $('file-preview-loading').textContent = e.message || t('fs.previewFailed', { msg: t('fs.networkError') })
3012
+ }
3013
+ }
3014
+ function showFsPreviewMode(mode) {
3015
+ const rendered = mode === 'rendered' && !($('file-preview-tabs').classList.contains('hidden'))
3016
+ $('file-preview-source').classList.toggle('hidden', rendered)
3017
+ $('file-preview-rendered').classList.toggle('hidden', !rendered)
3018
+ $('file-preview-source-tab').classList.toggle('current', !rendered)
3019
+ $('file-preview-rendered-tab').classList.toggle('current', rendered)
3020
+ }
3021
+ function closeFsPreview() {
3022
+ fsPreviewGeneration++
3023
+ state.fs.preview = null
3024
+ $('modal-file-preview').classList.add('hidden')
3025
+ }
3026
+
2704
3027
  function showFsProgress(pct, loaded, total) {
2705
3028
  $('fs-progress').classList.remove('hidden')
2706
3029
  $('fs-progress-bar').style.width = Math.max(2, Math.min(100, pct)) + '%'
@@ -3094,6 +3417,11 @@ async function loadLocalVersion() {
3094
3417
  */
3095
3418
  const ANNOUNCEMENTS_KEY = 'seenAnnouncementsV1'
3096
3419
  const ANNOUNCEMENT_HISTORY_KEY = 'announcementHistoryV1'
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
3097
3425
  function readSeenAnnouncements() {
3098
3426
  try {
3099
3427
  const value = JSON.parse(LS.get(ANNOUNCEMENTS_KEY, '{}'))
@@ -3110,6 +3438,34 @@ function markAnnouncementSeen(id) {
3110
3438
  for (const key of keys.slice(0, keys.length - 100)) delete seen[key]
3111
3439
  }
3112
3440
  LS.set(ANNOUNCEMENTS_KEY, JSON.stringify(seen))
3441
+ renderAnnouncementBoard()
3442
+ }
3443
+ function readAnnouncementVotes() {
3444
+ try {
3445
+ const value = JSON.parse(LS.get(ANNOUNCEMENT_VOTES_KEY, '{}'))
3446
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
3447
+ } catch { return {} }
3448
+ }
3449
+ function announcementVoteKey(announcementId, pollId) {
3450
+ return `${announcementId}\u0000${pollId}`
3451
+ }
3452
+ function storeAnnouncementVote(announcementId, pollId, optionId) {
3453
+ const votes = readAnnouncementVotes()
3454
+ votes[announcementVoteKey(announcementId, pollId)] = { optionId, votedAt: Date.now() }
3455
+ const keys = Object.keys(votes)
3456
+ if (keys.length > 100) {
3457
+ keys.sort((a, b) => Number(votes[a]?.votedAt || 0) - Number(votes[b]?.votedAt || 0))
3458
+ for (const key of keys.slice(0, keys.length - 100)) delete votes[key]
3459
+ }
3460
+ LS.set(ANNOUNCEMENT_VOTES_KEY, JSON.stringify(votes))
3461
+ renderAnnouncementBoard()
3462
+ }
3463
+ function announcementVote(item) {
3464
+ if (!item?.poll?.id) return null
3465
+ const saved = readAnnouncementVotes()[announcementVoteKey(item.id, item.poll.id)]
3466
+ if (!saved?.optionId) return null
3467
+ const option = item.poll.options.find(entry => entry.id === saved.optionId)
3468
+ return option ? { ...saved, option } : null
3113
3469
  }
3114
3470
  function readAnnouncementHistory() {
3115
3471
  try {
@@ -3124,6 +3480,36 @@ function storeAnnouncementHistory(items) {
3124
3480
  LS.set(ANNOUNCEMENT_HISTORY_KEY, JSON.stringify(list))
3125
3481
  return list
3126
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
+ }
3127
3513
  function renderAnnouncementHistory() {
3128
3514
  const box = $('announcement-history-list')
3129
3515
  if (!box) return
@@ -3135,7 +3521,11 @@ function renderAnnouncementHistory() {
3135
3521
  box.innerHTML = list.map(item => {
3136
3522
  const date = Number(item.publishedAt) > 0 ? fmtFullTime(item.publishedAt) : t('announcement.noDate')
3137
3523
  const action = item.actionUrl ? `<a class="announcement-action" href="${esc(item.actionUrl)}" target="_blank" rel="noopener">${esc(item.actionText || t('announcement.open'))}</a>` : ''
3138
- return `<details class="announcement-history-item"><summary><span>${esc(item.title)}</span><small>${esc(date)}</small></summary><div class="announcement-history-content">${esc(item.content).replace(/\r?\n/g, '<br>')}${action}</div></details>`
3524
+ const vote = announcementVote(item)
3525
+ const pollAction = item.poll ? (vote
3526
+ ? `<div class="announcement-poll-status">${esc(t('announcement.voteThanks', { option: vote.option.label }))}</div>`
3527
+ : `<button class="mini-btn" type="button" data-announcement-poll="${esc(item.id)}">${esc(t('announcement.voteFromHistory'))}</button>`) : ''
3528
+ return `<details class="announcement-history-item"><summary><span>${esc(item.title)}</span><small>${esc(date)}</small></summary><div class="announcement-history-content">${esc(item.content).replace(/\r?\n/g, '<br>')}${action}${pollAction}</div></details>`
3139
3529
  }).join('')
3140
3530
  }
3141
3531
  function openAnnouncementHistory() {
@@ -3153,6 +3543,22 @@ function announcementVersionMatch(item) {
3153
3543
  if (max && cmpVersion(state.localVersion, max) > 0) return false
3154
3544
  return true
3155
3545
  }
3546
+ function normalizeAnnouncementPoll(value, announcementId) {
3547
+ if (!value || typeof value !== 'object') return null
3548
+ const id = String(value.id || announcementId || '').trim().slice(0, 120)
3549
+ const question = String(value.question || '').trim().slice(0, 300)
3550
+ if (!id || !question || !Array.isArray(value.options)) return null
3551
+ const seen = new Set()
3552
+ const options = []
3553
+ for (const raw of value.options.slice(0, 8)) {
3554
+ const optionId = String(raw?.id || '').trim().slice(0, 120)
3555
+ const label = String(raw?.label || '').trim().slice(0, 200)
3556
+ if (!optionId || !label || seen.has(optionId)) continue
3557
+ seen.add(optionId)
3558
+ options.push({ id: optionId, label, description: String(raw?.description || '').trim().slice(0, 500) })
3559
+ }
3560
+ return options.length >= 2 ? { id, question, options } : null
3561
+ }
3156
3562
  function normalizeAnnouncement(item, base) {
3157
3563
  if (!item || typeof item !== 'object') return null
3158
3564
  const id = String(item.id || '').trim().slice(0, 120)
@@ -3176,13 +3582,34 @@ function normalizeAnnouncement(item, base) {
3176
3582
  id, title, content, actionUrl,
3177
3583
  actionText: String(item.actionText || '').trim().slice(0, 80),
3178
3584
  publishedAt: Number.isFinite(startsAt) ? startsAt : 0,
3179
- force: item.force === true
3180
- }
3585
+ force: item.force === true,
3586
+ poll: normalizeAnnouncementPoll(item.poll, id)
3587
+ }
3588
+ }
3589
+ function renderAnnouncementPoll(item) {
3590
+ const panel = $('announcement-poll')
3591
+ const poll = item?.poll
3592
+ panel.classList.toggle('hidden', !poll)
3593
+ if (!poll) return
3594
+ const vote = announcementVote(item)
3595
+ $('announcement-poll-question').textContent = poll.question
3596
+ $('announcement-poll-options').innerHTML = poll.options.map(option => `
3597
+ <label class="announcement-poll-option">
3598
+ <input type="radio" name="announcement-poll-option" value="${esc(option.id)}"${vote?.optionId === option.id ? ' checked' : ''}${vote ? ' disabled' : ''}>
3599
+ <span><strong>${esc(option.label)}</strong>${option.description ? `<small>${esc(option.description)}</small>` : ''}</span>
3600
+ </label>`).join('')
3601
+ const status = $('announcement-poll-status')
3602
+ status.textContent = vote ? t('announcement.voteThanks', { option: vote.option.label }) : ''
3603
+ status.classList.toggle('hidden', !vote)
3604
+ const submit = $('announcement-poll-submit')
3605
+ submit.classList.toggle('hidden', !!vote)
3606
+ submit.disabled = true
3181
3607
  }
3182
3608
  function openAnnouncementModal(item) {
3183
3609
  state.announcement = item
3184
3610
  $('announcement-title').textContent = item.title
3185
3611
  $('announcement-content').innerHTML = esc(item.content).replace(/\r?\n/g, '<br>')
3612
+ renderAnnouncementPoll(item)
3186
3613
  const action = $('announcement-action')
3187
3614
  if (item.actionUrl) {
3188
3615
  action.href = item.actionUrl
@@ -3196,12 +3623,54 @@ function openAnnouncementModal(item) {
3196
3623
  $('announcement-later').classList.toggle('hidden', item.force)
3197
3624
  $('modal-announcement').classList.remove('hidden')
3198
3625
  }
3626
+ async function submitAnnouncementVote() {
3627
+ const item = state.announcement
3628
+ const poll = item?.poll
3629
+ const optionId = document.querySelector('input[name="announcement-poll-option"]:checked')?.value || ''
3630
+ const option = poll?.options.find(entry => entry.id === optionId)
3631
+ if (!poll || !option) return toast(t('announcement.voteChoose'), 'err')
3632
+ const button = $('announcement-poll-submit')
3633
+ button.disabled = true
3634
+ try {
3635
+ const base = updateBase()
3636
+ if (!base) throw new Error(t('announcement.voteNetworkError'))
3637
+ const res = await fetch(base + '/feedback', {
3638
+ method: 'POST',
3639
+ headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token },
3640
+ body: JSON.stringify({
3641
+ type: 'poll',
3642
+ message: `Poll ${poll.id}: ${option.id}`,
3643
+ announcementId: item.id,
3644
+ pollId: poll.id,
3645
+ optionId: option.id,
3646
+ appVersion: state.localVersion
3647
+ })
3648
+ })
3649
+ const data = await res.json().catch(() => ({}))
3650
+ if (res.ok && data.ok) {
3651
+ storeAnnouncementVote(item.id, poll.id, option.id)
3652
+ markAnnouncementSeen(item.id)
3653
+ renderAnnouncementPoll(item)
3654
+ renderAnnouncementHistory()
3655
+ toast(t('announcement.voteThanks', { option: option.label }), 'ok')
3656
+ } else if (res.status === 429) {
3657
+ toast(t('announcement.voteAgainLater'), 'err')
3658
+ button.disabled = false
3659
+ } else {
3660
+ toast(t('announcement.voteFailed', { msg: data.error || res.status }), 'err')
3661
+ button.disabled = false
3662
+ }
3663
+ } catch (e) {
3664
+ toast(t('announcement.voteFailed', { msg: e.message || t('announcement.voteNetworkError') }), 'err')
3665
+ button.disabled = false
3666
+ }
3667
+ }
3199
3668
  function closeAnnouncement(markSeen) {
3200
3669
  if (markSeen && state.announcement) markAnnouncementSeen(state.announcement.id)
3201
3670
  state.announcement = null
3202
3671
  $('modal-announcement').classList.add('hidden')
3203
3672
  }
3204
- async function checkAnnouncements() {
3673
+ async function fetchAnnouncements() {
3205
3674
  const base = updateBase()
3206
3675
  if (!base || !state.localVersion) return false
3207
3676
  try {
@@ -3214,16 +3683,36 @@ async function checkAnnouncements() {
3214
3683
  const data = JSON.parse(raw)
3215
3684
  const source = Array.isArray(data) ? data : (Array.isArray(data?.items) ? data.items : [data])
3216
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))
3217
3687
  storeAnnouncementHistory(normalized)
3688
+ renderAnnouncementBoard()
3218
3689
  const seen = readSeenAnnouncements()
3219
3690
  const items = normalized.filter(item => !seen[item.id])
3220
3691
  .sort((a, b) => b.publishedAt - a.publishedAt)
3221
- if (!items.length) return false
3692
+ if (!items.length || state.announcement) return false
3222
3693
  openAnnouncementModal(items[0])
3223
3694
  return true
3224
3695
  } catch { return false }
3225
3696
  }
3226
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
+
3227
3716
  /* ---------------- 更新内容弹窗 ---------------- */
3228
3717
  const NOTES_KEY = 'seenNotesVersion'
3229
3718
  let notesVersion = ''
@@ -3502,6 +3991,7 @@ function applyBgConfigFromNative() {
3502
3991
  const v = String(cfg.intervalMin ?? 1)
3503
3992
  const opts = Array.from($('bg-interval')?.options || [])
3504
3993
  if (opts.some(o => o.value === v)) $('bg-interval').value = v
3994
+ syncCustomSelect($('bg-interval'))
3505
3995
  if ($('opt-task-done')) $('opt-task-done').checked = cfg.notifyTaskDone !== false
3506
3996
  $('bg-auth-status')?.classList.toggle('hidden', !cfg.loginExpired)
3507
3997
  } catch {}
@@ -3594,9 +4084,24 @@ function deletePreset(id) {
3594
4084
 
3595
4085
  /* ---------------- 峰谷计费提醒(前台服务进程内定时, 绕开 MIUI 后台限制) ---------------- */
3596
4086
  function peakRemindOn() { return LS.get('peakRemind', '0') === '1' }
4087
+ const LEGACY_PEAK_NOTIFICATION_IDS = [8801, 8802, 8803, 8804]
3597
4088
 
3598
- async function schedulePeakReminders() {
4089
+ async function cancelLegacyPeakNotifications() {
3599
4090
  if (!CAP?.isNativePlatform?.()) return false
4091
+ const notifications = CAP.Plugins?.LocalNotifications
4092
+ if (!notifications?.cancel) return true
4093
+ try {
4094
+ await notifications.cancel({ notifications: LEGACY_PEAK_NOTIFICATION_IDS.map(id => ({ id })) })
4095
+ return true
4096
+ } catch (error) {
4097
+ console.warn('Failed to cancel legacy peak reminders', error)
4098
+ return false
4099
+ }
4100
+ }
4101
+
4102
+ async function schedulePeakReminders({ legacyCleaned = false } = {}) {
4103
+ if (!CAP?.isNativePlatform?.()) return false
4104
+ if (!legacyCleaned && !await cancelLegacyPeakNotifications()) return false
3600
4105
  const b = bgBridge()
3601
4106
  if (!b?.startPeakReminder) return false
3602
4107
  try {
@@ -3609,10 +4114,19 @@ async function cancelPeakReminders() {
3609
4114
  const b = bgBridge()
3610
4115
  if (!b?.stopPeakReminder) return false
3611
4116
  try {
3612
- return b.stopPeakReminder() !== false
4117
+ const stopped = b.stopPeakReminder() !== false
4118
+ const legacyCleaned = await cancelLegacyPeakNotifications()
4119
+ return stopped && legacyCleaned
3613
4120
  } catch { return false }
3614
4121
  }
3615
4122
 
4123
+ async function restorePeakReminders() {
4124
+ if (!CAP?.isNativePlatform?.()) return
4125
+ // 旧版使用 LocalNotifications 每日调度;无论当前开关状态都先清理,防止与前台服务重复提醒。
4126
+ const legacyCleaned = await cancelLegacyPeakNotifications()
4127
+ if (peakRemindOn() && legacyCleaned) await schedulePeakReminders({ legacyCleaned: true })
4128
+ }
4129
+
3616
4130
  /* ---------------- 视图切换 ---------------- */
3617
4131
  function showView(id) {
3618
4132
  for (const v of ['view-home', 'view-files', 'view-session', 'view-activity', 'view-stats', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
@@ -3620,7 +4134,10 @@ function showView(id) {
3620
4134
  document.body.classList.toggle('in-session', id === 'view-session')
3621
4135
  document.querySelectorAll('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === id))
3622
4136
  window.scrollTo(0, 0)
3623
- if (id === 'view-files' && !state.fs.loaded) loadFs(null, { silent: true })
4137
+ if (id === 'view-files' && !state.fs.loaded) {
4138
+ const workspace = workspaceById(state.fs.workspaceId)
4139
+ loadFs(workspace?.path || null, { silent: true, resetRoot: true })
4140
+ }
3624
4141
  if (id === 'view-stats') loadStats()
3625
4142
  if (id === 'view-settings') showSettingsHome()
3626
4143
  }
@@ -3881,36 +4398,155 @@ function initToken() {
3881
4398
  $('server-desc').textContent = state.server || t('servers.defaultDesc')
3882
4399
  }
3883
4400
 
3884
- function renderDshControlStatus(v) {
3885
- const desc = $('dsh-control-desc')
3886
- if (!desc || !v) return
4401
+ let dshControlPollPromise = null
4402
+
4403
+ function dshControlButtonsBusy(busy, supported = true) {
3887
4404
  const buttons = [$('btn-dsh-start'), $('btn-dsh-restart')].filter(Boolean)
3888
- if (v.supported === false) {
3889
- desc.textContent = v.message || t('settings.dshUnsupported')
3890
- 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)
4508
+ return
4509
+ }
4510
+ if (value.ok === false) {
4511
+ desc.textContent = t('settings.dshStatusFailed', { msg: dshControlFailureText(value) })
4512
+ dshControlButtonsBusy(false, false)
3891
4513
  return
3892
4514
  }
3893
- buttons.forEach(b => { b.disabled = false; b.classList.remove('hidden') })
3894
- desc.textContent = `${t(v.running ? 'settings.dshRunning' : 'settings.dshStopped')} · ${v.service || 'dsh-web'}`
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 })
3895
4522
  }
3896
4523
 
3897
4524
  async function loadDshControl() {
3898
4525
  if (!state.token || !$('dsh-control-desc')) return
3899
4526
  try {
3900
- 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' })
3901
4528
  if (res.status === 401) return authFailure()
3902
- renderDshControlStatus(await res.json())
3903
- } catch {
3904
- $('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') })
3905
4542
  }
3906
4543
  }
3907
4544
 
3908
4545
  async function controlDsh(action) {
4546
+ if (dshControlPollPromise) return
3909
4547
  const label = action === 'start' ? t('settings.dshStart') : t('settings.dshRestart')
3910
- const buttons = [$('btn-dsh-start'), $('btn-dsh-restart')].filter(Boolean)
3911
- buttons.forEach(b => { b.disabled = true })
3912
- const desc = $('dsh-control-desc')
3913
- 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: [] })
3914
4550
  try {
3915
4551
  const res = await fetch(adminApiUrl('/admin/api/dsh'), {
3916
4552
  method: 'POST',
@@ -3918,15 +4554,33 @@ async function controlDsh(action) {
3918
4554
  body: JSON.stringify({ action }),
3919
4555
  })
3920
4556
  if (res.status === 401) return authFailure()
3921
- const v = await res.json().catch(() => ({}))
3922
- 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 })
3923
4572
  renderDshControlStatus(v)
3924
4573
  toast(t('settings.dshStarted', { action: label }), 'ok')
3925
- } catch (e) {
3926
- if (desc) desc.textContent = t('settings.dshFailed', { msg: e?.message || e })
3927
- 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')
3928
4581
  } finally {
3929
- buttons.forEach(b => { b.disabled = false })
4582
+ dshControlPollPromise = null
4583
+ dshControlButtonsBusy(false, true)
3930
4584
  }
3931
4585
  }
3932
4586
 
@@ -3987,6 +4641,7 @@ function bindUi() {
3987
4641
  renderServers()
3988
4642
  renderSessions()
3989
4643
  renderWorkbench()
4644
+ renderAnnouncementBoard()
3990
4645
  renderPending(); renderQueue(); renderJobs()
3991
4646
  updateConn()
3992
4647
  if (state.current) { renderSessionTitle(); renderSessionSub(); renderSessionCards(); renderHistory(true) }
@@ -4064,6 +4719,8 @@ function bindUi() {
4064
4719
  if (newButton) {
4065
4720
  safeRpc('session.create', { workspaceId: newButton.dataset.wbNew }, t('home.createFailed')).then(async v => {
4066
4721
  if (!v?.sessionId) return
4722
+ state.workspaceFilter = newButton.dataset.wbNew
4723
+ LS.set('workspaceFilterV1', state.workspaceFilter)
4067
4724
  toast(t('home.created'), 'ok')
4068
4725
  await refreshSessions()
4069
4726
  openSession(v.sessionId)
@@ -4084,6 +4741,11 @@ function bindUi() {
4084
4741
  $('btn-stats').addEventListener('click', () => { renderSessionCards(); $('modal-stats').classList.remove('hidden') })
4085
4742
  $('stats-close').addEventListener('click', () => $('modal-stats').classList.add('hidden'))
4086
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
+ })
4087
4749
  // 反馈
4088
4750
  $('btn-feedback').addEventListener('click', (e) => { e.stopPropagation(); toggleFeedbackSheet() })
4089
4751
  $('feedback-backdrop').addEventListener('click', closeFeedbackSheet)
@@ -4098,6 +4760,10 @@ function bindUi() {
4098
4760
  $('btn-write-feedback').addEventListener('click', () => { closeFeedbackSheet(); openFeedbackModal() })
4099
4761
  $('fb-cancel').addEventListener('click', closeFeedbackModal)
4100
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
+ })
4101
4767
  document.querySelectorAll('#fb-chips .fb-chip').forEach(btn =>
4102
4768
  btn.addEventListener('click', () => {
4103
4769
  state.feedbackType = btn.dataset.fbType
@@ -4108,14 +4774,44 @@ function bindUi() {
4108
4774
  })
4109
4775
  document.addEventListener('keydown', (e) => {
4110
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()
4111
4778
  })
4112
4779
  $('btn-new-session').addEventListener('click', newSession)
4780
+ $('session-workspace-filter').addEventListener('change', (e) => {
4781
+ state.workspaceFilter = e.target.value
4782
+ if (state.workspaceFilter) LS.set('workspaceFilterV1', state.workspaceFilter)
4783
+ else LS.del('workspaceFilterV1')
4784
+ renderWorkspaceNavigation()
4785
+ renderSessions()
4786
+ })
4787
+ $('session-workspace-files').addEventListener('click', () => openWorkspaceFiles(state.workspaceFilter))
4788
+ $('new-session-workspace').addEventListener('change', (e) => renderNewSessionWorkspace(e.target.value))
4789
+ $('new-session-cancel').addEventListener('click', closeNewSessionModal)
4790
+ $('new-session-create').addEventListener('click', createSessionInWorkspace)
4791
+ $('modal-new-session').addEventListener('click', (e) => { if (e.target === $('modal-new-session')) closeNewSessionModal() })
4113
4792
  $('btn-new-workspace').addEventListener('click', openWorkspaceModal)
4114
4793
  $('session-sort')?.addEventListener('change', (e) => {
4115
4794
  state.sessionSort = e.target.value === 'workspace' ? 'workspace' : 'time'
4116
4795
  LS.set('sessionSort', state.sessionSort)
4117
4796
  renderSessions()
4118
4797
  })
4798
+ $('fs-workspace').addEventListener('change', (e) => {
4799
+ state.fs.workspaceId = e.target.value
4800
+ if (state.fs.workspaceId) LS.set('fsWorkspaceIdV1', state.fs.workspaceId)
4801
+ else LS.del('fsWorkspaceIdV1')
4802
+ state.fs.loaded = false
4803
+ const workspace = workspaceById(state.fs.workspaceId)
4804
+ loadFs(workspace?.path || null, { resetRoot: true })
4805
+ })
4806
+ $('file-preview-close').addEventListener('click', closeFsPreview)
4807
+ $('file-preview-done').addEventListener('click', closeFsPreview)
4808
+ $('file-preview-source-tab').addEventListener('click', () => showFsPreviewMode('source'))
4809
+ $('file-preview-rendered-tab').addEventListener('click', () => showFsPreviewMode('rendered'))
4810
+ $('file-preview-download').addEventListener('click', () => {
4811
+ const preview = state.fs.preview
4812
+ if (preview?.path && preview?.name) downloadFsPath(preview.path, preview.name)
4813
+ })
4814
+ $('modal-file-preview').addEventListener('click', (e) => { if (e.target === $('modal-file-preview')) closeFsPreview() })
4119
4815
  $('btn-cancel').addEventListener('click', cancelSession)
4120
4816
  $('btn-send').addEventListener('click', sendMessage)
4121
4817
  $('btn-fs-send').addEventListener('click', sendMessage)
@@ -4213,13 +4909,28 @@ function bindUi() {
4213
4909
  $('modal-archive').addEventListener('click', (e) => { if (e.target === $('modal-archive')) closeArchiveConfirm() })
4214
4910
  $('announcement-later').addEventListener('click', () => closeAnnouncement(false))
4215
4911
  $('announcement-confirm').addEventListener('click', () => closeAnnouncement(true))
4912
+ $('announcement-poll-options').addEventListener('change', () => { $('announcement-poll-submit').disabled = false })
4913
+ $('announcement-poll-submit').addEventListener('click', submitAnnouncementVote)
4216
4914
  $('modal-announcement').addEventListener('click', (e) => {
4217
4915
  if (e.target === $('modal-announcement') && !state.announcement?.force) closeAnnouncement(false)
4218
4916
  })
4219
4917
  $('announcement-history-close').addEventListener('click', closeAnnouncementHistory)
4918
+ $('announcement-history-list').addEventListener('click', (e) => {
4919
+ const button = e.target.closest('[data-announcement-poll]')
4920
+ if (!button) return
4921
+ const item = readAnnouncementHistory().find(entry => entry.id === button.dataset.announcementPoll)
4922
+ if (item?.poll) { closeAnnouncementHistory(); openAnnouncementModal(item) }
4923
+ })
4220
4924
  $('modal-announcement-history').addEventListener('click', (e) => {
4221
4925
  if (e.target === $('modal-announcement-history')) closeAnnouncementHistory()
4222
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
+ })
4223
4934
  // 设置
4224
4935
  $('view-settings').addEventListener('click', (e) => {
4225
4936
  const group = e.target.closest('[data-settings-group]')
@@ -4256,7 +4967,7 @@ function bindUi() {
4256
4967
  $('btn-update-expand').addEventListener('click', toggleUpdateExpand)
4257
4968
  $('btn-reset').addEventListener('click', () => {
4258
4969
  if (!confirm(t('settings.confirmReset'))) return
4259
- LS.del('token'); LS.del('notify'); LS.del('server'); LS.del('mobileEnterAction'); LS.del(ANNOUNCEMENTS_KEY); LS.del(ANNOUNCEMENT_HISTORY_KEY)
4970
+ LS.del('token'); LS.del('notify'); LS.del('server'); LS.del('mobileEnterAction'); LS.del(ANNOUNCEMENTS_KEY); LS.del(ANNOUNCEMENT_HISTORY_KEY); LS.del(ANNOUNCEMENT_VOTES_KEY)
4260
4971
  if (bgBridge()?.saveBackgroundConfig) saveBgConfig(false)
4261
4972
  location.reload()
4262
4973
  })
@@ -4301,8 +5012,8 @@ function bindUi() {
4301
5012
  })
4302
5013
  $('btn-test-notify').addEventListener('click', sendTestNotification)
4303
5014
  $('btn-announcement-history').addEventListener('click', openAnnouncementHistory)
4304
- // 已开启则启动时重新调度, 防止系统清理后丢失
4305
- if (peakRemindOn() && CAP?.isNativePlatform?.()) schedulePeakReminders()
5015
+ // 启动时先清理旧版 LocalNotifications,再按当前开关恢复前台提醒服务。
5016
+ restorePeakReminders()
4306
5017
  applyBgConfigFromNative()
4307
5018
  $('opt-bg-poll').addEventListener('change', async (e) => {
4308
5019
  if (e.target.checked) {
@@ -4355,6 +5066,8 @@ function bindUi() {
4355
5066
 
4356
5067
  bindRail()
4357
5068
 
5069
+ initCustomSelects()
5070
+
4358
5071
  // 向上翻历史 / 向下回最新
4359
5072
  $('history').addEventListener('scroll', () => {
4360
5073
  const box = $('history')
@@ -4392,6 +5105,7 @@ function applyNativeInsets() {
4392
5105
  async function boot() {
4393
5106
  initToken()
4394
5107
  bindUi()
5108
+ renderAnnouncementBoard()
4395
5109
  renderLangBtn()
4396
5110
  bindNativeBack()
4397
5111
  bindNativeLinks()
@@ -4411,7 +5125,9 @@ async function boot() {
4411
5125
  if (host) { state.hostInfo = host; $('host-desc').textContent = t('settings.hostDesc', { version: host.version, cwd: host.cwd, n: host.attachedSessions }) }
4412
5126
  loadDshControl()
4413
5127
  }
4414
- // 公告与更新共用当前服务器;公告优先,避免启动时两个弹窗重叠。
5128
+ // 网关从中央 HTTPS 公告源读取并在不可达时回退内置文件。前台每 30 秒检查,
5129
+ // 回到前台或网络恢复时立即补查;公告优先,避免启动时两个弹窗重叠。
5130
+ startAnnouncementPolling()
4415
5131
  setTimeout(async () => {
4416
5132
  const shown = await checkAnnouncements()
4417
5133
  if (!shown && state.token) checkUpdate(true)