dsh-remote-plugin 0.6.10 → 0.6.11

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,6 +58,7 @@ 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,
@@ -77,7 +78,7 @@ const state = {
77
78
  streamMode: 'ws', // 'ws' | 'poll'
78
79
  pollSeq: { mux: 0, host: 0 },
79
80
  refreshTimer: null,
80
- fs: { path: null, initial: null, loaded: false, upload: null },
81
+ fs: { path: null, initial: null, loaded: false, upload: null, workspaceId: LS.get('fsWorkspaceIdV1', ''), preview: null },
81
82
  composerImages: [], // 当前草稿中的图片附件:只在发送成功后释放
82
83
  models: { loaded: false, loading: false, groups: [], current: null, failures: [] },
83
84
  wb: null,
@@ -101,6 +102,135 @@ function toast(text, kind = '') {
101
102
  toast._t = setTimeout(() => el.classList.add('hidden'), 3200)
102
103
  }
103
104
 
105
+ /* ---------------- 应用内选择抽屉 ---------------- */
106
+ const CUSTOM_SELECT_TITLES = {
107
+ 'session-workspace-filter': 'workspace.select',
108
+ 'session-sort': 'sessions.sortLabel',
109
+ 'fs-workspace': 'workspace.select',
110
+ 'mobile-enter-action': 'settings.mobileEnterTitle',
111
+ 'bg-interval': 'settings.bgIntervalTitle',
112
+ 'new-session-workspace': 'workspace.select'
113
+ }
114
+ let customSelectCurrent = null
115
+
116
+ function syncCustomSelect(select) {
117
+ const trigger = select?._customSelectTrigger
118
+ if (!trigger) return
119
+ const option = select.selectedOptions?.[0] || select.options?.[select.selectedIndex]
120
+ const label = trigger.querySelector('.custom-select-trigger-label')
121
+ if (label) label.textContent = option?.textContent?.trim() || t('select.choose')
122
+ trigger.disabled = !!select.disabled
123
+ trigger.setAttribute('aria-expanded', customSelectCurrent?.select === select ? 'true' : 'false')
124
+ }
125
+
126
+ function closeCustomSelect({ restoreFocus = true } = {}) {
127
+ if (!$('custom-select-sheet')) return
128
+ const previous = customSelectCurrent
129
+ customSelectCurrent = null
130
+ $('custom-select-backdrop').classList.add('hidden')
131
+ $('custom-select-sheet').classList.add('hidden')
132
+ if (previous?.select) syncCustomSelect(previous.select)
133
+ if (restoreFocus) previous?.trigger?.focus?.({ preventScroll: true })
134
+ }
135
+
136
+ function customSelectOptionCopy(option) {
137
+ const copy = document.createElement('span')
138
+ copy.className = 'custom-select-option-copy'
139
+ const raw = option.textContent?.trim() || ''
140
+ const separator = raw.indexOf(' — ')
141
+ const name = document.createElement('span')
142
+ name.className = 'custom-select-option-name'
143
+ name.textContent = separator >= 0 ? raw.slice(0, separator) : raw
144
+ copy.appendChild(name)
145
+ if (separator >= 0) {
146
+ const path = document.createElement('span')
147
+ path.className = 'custom-select-option-path'
148
+ path.textContent = raw.slice(separator + 3)
149
+ copy.appendChild(path)
150
+ }
151
+ return copy
152
+ }
153
+
154
+ function openCustomSelect(select) {
155
+ const trigger = select?._customSelectTrigger
156
+ if (!select || !trigger || select.disabled) return
157
+ closeFeedbackSheet()
158
+ syncCustomSelect(select)
159
+ customSelectCurrent = { select, trigger }
160
+ const titleKey = CUSTOM_SELECT_TITLES[select.id]
161
+ $('custom-select-title').textContent = t(titleKey || 'select.choose')
162
+ const options = $('custom-select-options')
163
+ options.replaceChildren()
164
+ let selectedButton = null
165
+ Array.from(select.options).forEach(option => {
166
+ const button = document.createElement('button')
167
+ button.type = 'button'
168
+ button.className = 'custom-select-option' + (option.selected ? ' current' : '')
169
+ button.setAttribute('role', 'option')
170
+ button.setAttribute('aria-selected', option.selected ? 'true' : 'false')
171
+ button.disabled = option.disabled
172
+ button.appendChild(customSelectOptionCopy(option))
173
+ const check = document.createElement('span')
174
+ check.className = 'custom-select-option-check'
175
+ check.setAttribute('aria-hidden', 'true')
176
+ check.textContent = option.selected ? '✓' : ''
177
+ button.appendChild(check)
178
+ button.addEventListener('click', () => {
179
+ if (option.disabled) return
180
+ select.value = option.value
181
+ select.dispatchEvent(new Event('input', { bubbles: true }))
182
+ select.dispatchEvent(new Event('change', { bubbles: true }))
183
+ syncCustomSelect(select)
184
+ closeCustomSelect()
185
+ })
186
+ options.appendChild(button)
187
+ if (option.selected) selectedButton = button
188
+ })
189
+ $('custom-select-backdrop').classList.remove('hidden')
190
+ $('custom-select-sheet').classList.remove('hidden')
191
+ syncCustomSelect(select)
192
+ setTimeout(() => (selectedButton || options.querySelector('button:not(:disabled)'))?.focus(), 30)
193
+ }
194
+
195
+ function enhanceCustomSelect(select) {
196
+ if (!select || select._customSelectTrigger || select.multiple || Number(select.size) > 1) return
197
+ select.classList.add('custom-select-native')
198
+ select.tabIndex = -1
199
+ select.setAttribute('aria-hidden', 'true')
200
+ const trigger = document.createElement('button')
201
+ trigger.type = 'button'
202
+ trigger.id = `${select.id}-trigger`
203
+ trigger.className = 'custom-select-trigger'
204
+ if (select.classList.contains('workspace-select')) trigger.classList.add('workspace-select-trigger')
205
+ if (select.classList.contains('full')) trigger.classList.add('full')
206
+ if (select.classList.contains('session-sort')) trigger.classList.add('session-sort-trigger')
207
+ if (select.classList.contains('setting-select')) trigger.classList.add('setting-select-trigger')
208
+ trigger.setAttribute('aria-haspopup', 'listbox')
209
+ trigger.setAttribute('aria-expanded', 'false')
210
+ const label = document.createElement('span')
211
+ label.className = 'custom-select-trigger-label'
212
+ trigger.appendChild(label)
213
+ trigger.addEventListener('click', () => openCustomSelect(select))
214
+ select.insertAdjacentElement('afterend', trigger)
215
+ select._customSelectTrigger = trigger
216
+ const associatedLabel = document.querySelector(`label[for="${CSS.escape(select.id)}"]`)
217
+ if (associatedLabel) associatedLabel.htmlFor = trigger.id
218
+ select.addEventListener('change', () => syncCustomSelect(select))
219
+ select._customSelectObserver = new MutationObserver(() => syncCustomSelect(select))
220
+ select._customSelectObserver.observe(select, { childList: true, subtree: true, attributes: true, attributeFilter: ['disabled', 'selected'] })
221
+ syncCustomSelect(select)
222
+ }
223
+
224
+ function initCustomSelects() {
225
+ document.querySelectorAll('select').forEach(enhanceCustomSelect)
226
+ $('custom-select-backdrop').addEventListener('click', () => closeCustomSelect())
227
+ $('custom-select-close').addEventListener('click', () => closeCustomSelect())
228
+ $('custom-select-cancel').addEventListener('click', () => closeCustomSelect())
229
+ document.addEventListener('keydown', (event) => {
230
+ if (event.key === 'Escape' && customSelectCurrent) closeCustomSelect()
231
+ })
232
+ }
233
+
104
234
  /* ---------------- 反馈 ---------------- */
105
235
  const FEEDBACK_LINKS = {
106
236
  githubIssues: 'https://github.com/Blank-not-black/dsh-Remote/issues',
@@ -151,7 +281,8 @@ async function submitFeedback() {
151
281
  const btn = $('fb-submit')
152
282
  btn.disabled = true
153
283
  try {
154
- const base = (state.server || '').replace(/\/+$/, '')
284
+ const base = updateBase()
285
+ if (!base) throw new Error(t('feedback.networkError'))
155
286
  const res = await fetch(base + '/feedback', {
156
287
  method: 'POST',
157
288
  headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token },
@@ -1244,6 +1375,58 @@ function wbJoin(root, name) {
1244
1375
  function workbenchRoot() {
1245
1376
  return state.wb?.bound && state.wb.path ? state.wb.path : ''
1246
1377
  }
1378
+ const WORKSPACE_UNGROUPED = '__ungrouped__'
1379
+ function workspaceItems() {
1380
+ return (state.wbProjects || []).filter(w => w && typeof w.workspaceId === 'string' && w.workspaceId && typeof w.path === 'string' && w.path)
1381
+ }
1382
+ function workspaceById(workspaceId) {
1383
+ return workspaceItems().find(w => w.workspaceId === workspaceId) || null
1384
+ }
1385
+ function workspaceForSession(session) {
1386
+ 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
1391
+ }
1392
+ function workspaceName(workspace) {
1393
+ return String(workspace?.title || wbBaseName(workspace?.path) || workspace?.path || '').trim()
1394
+ }
1395
+ function workspaceOptionLabel(workspace) {
1396
+ const name = workspaceName(workspace)
1397
+ return workspace.path && workspace.path !== name ? `${name} — ${workspace.path}` : name
1398
+ }
1399
+ function workspaceOptionsHtml({ all = false, ungrouped = false, root = false, selected = '' } = {}) {
1400
+ const rows = []
1401
+ if (all) rows.push({ id: '', label: t('workspace.all') })
1402
+ if (root) rows.push({ id: '', label: t('fs.root') })
1403
+ for (const workspace of workspaceItems()) rows.push({ id: workspace.workspaceId, label: workspaceOptionLabel(workspace) })
1404
+ if (ungrouped) rows.push({ id: WORKSPACE_UNGROUPED, label: t('workspace.ungrouped') })
1405
+ return rows.map(row => `<option value="${esc(row.id)}"${row.id === selected ? ' selected' : ''}>${esc(row.label)}</option>`).join('')
1406
+ }
1407
+ function renderWorkspaceNavigation() {
1408
+ const items = workspaceItems()
1409
+ if (state.workspaceFilter !== WORKSPACE_UNGROUPED && state.workspaceFilter && !workspaceById(state.workspaceFilter)) {
1410
+ state.workspaceFilter = ''
1411
+ LS.del('workspaceFilterV1')
1412
+ }
1413
+ const sessionSelect = $('session-workspace-filter')
1414
+ if (sessionSelect) sessionSelect.innerHTML = workspaceOptionsHtml({ all: true, ungrouped: true, selected: state.workspaceFilter })
1415
+ const selectedWorkspace = workspaceById(state.workspaceFilter)
1416
+ const pathBox = $('session-workspace-path')
1417
+ if (pathBox) pathBox.textContent = selectedWorkspace?.path || (state.workspaceFilter === WORKSPACE_UNGROUPED ? t('workspace.ungrouped') : t('workspace.allPath'))
1418
+ const filesButton = $('session-workspace-files')
1419
+ if (filesButton) filesButton.disabled = !selectedWorkspace
1420
+
1421
+ if (state.fs.workspaceId && !workspaceById(state.fs.workspaceId)) {
1422
+ state.fs.workspaceId = ''
1423
+ LS.del('fsWorkspaceIdV1')
1424
+ }
1425
+ const fsSelect = $('fs-workspace')
1426
+ if (fsSelect) fsSelect.innerHTML = workspaceOptionsHtml({ root: true, selected: state.fs.workspaceId })
1427
+ if ($('modal-new-session') && !$('modal-new-session').classList.contains('hidden')) renderNewSessionWorkspace()
1428
+ return items
1429
+ }
1247
1430
  async function refreshWorkbench() {
1248
1431
  if (!state.token) return
1249
1432
  try {
@@ -1271,7 +1454,9 @@ async function refreshWorkbench() {
1271
1454
  const listData = await listRes.json().catch(() => ({}))
1272
1455
  if (Array.isArray(listData.entries)) {
1273
1456
  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)))
1457
+ // 工作台只管理自己根目录下的项目;DSH 中位于其他路径的工作区必须保留,
1458
+ // 否则手机的工作区选择器会因绑定了工作台而丢失条目。
1459
+ state.wbProjects = state.wbProjects.filter(w => !wbStrictInside(w.path, state.wb.path) || diskDirs.has(wbPathKey(w.path)))
1275
1460
  const have = new Set(state.wbProjects.map(w => wbPathKey(w.path)))
1276
1461
  for (const entry of listData.entries) {
1277
1462
  if (entry.type !== 'dir') continue
@@ -1286,6 +1471,7 @@ async function refreshWorkbench() {
1286
1471
  }
1287
1472
  } catch {}
1288
1473
  }
1474
+ renderWorkspaceNavigation()
1289
1475
  renderWorkbench()
1290
1476
  renderSessions()
1291
1477
  }
@@ -1338,8 +1524,12 @@ function renderWorkbench() {
1338
1524
 
1339
1525
  function sessionCwd(s) { return typeof s?.cwd === 'string' ? s.cwd.trim() : '' }
1340
1526
  function sessionWorkspaceLabel(s) {
1341
- const cwd = sessionCwd(s)
1342
- return cwd || t('sessions.workspaceUnknown')
1527
+ const workspace = workspaceForSession(s)
1528
+ return workspace?.path || sessionCwd(s) || t('sessions.workspaceUnknown')
1529
+ }
1530
+ function sessionWorkspaceName(s) {
1531
+ const workspace = workspaceForSession(s)
1532
+ return workspace ? workspaceName(workspace) : workspaceDisplayName(sessionWorkspaceLabel(s))
1343
1533
  }
1344
1534
  function workspaceDisplayName(label) {
1345
1535
  const value = String(label || '').trim()
@@ -1363,14 +1553,12 @@ function sortedSessions() {
1363
1553
  function renderSessions() {
1364
1554
  const list = $('session-list')
1365
1555
  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
1556
  const archivedSet = new Set(state.wbArchived || [])
1370
1557
  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))
1558
+ if (!state.workspaceFilter) return true
1559
+ const workspace = workspaceForSession(s)
1560
+ if (state.workspaceFilter === WORKSPACE_UNGROUPED) return !workspace
1561
+ return workspace?.workspaceId === state.workspaceFilter
1374
1562
  })
1375
1563
  const archived = visible.filter(s => archivedSet.has(s.sessionId))
1376
1564
  const main = visible.filter(s => !archivedSet.has(s.sessionId))
@@ -1380,9 +1568,9 @@ function renderSessions() {
1380
1568
  const rows = []
1381
1569
  for (const s of items) {
1382
1570
  const workspace = sessionWorkspaceLabel(s)
1383
- const workspaceName = workspaceDisplayName(workspace)
1571
+ const workspaceTitle = sessionWorkspaceName(s)
1384
1572
  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>`)
1573
+ 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
1574
  lastWorkspace = workspace
1387
1575
  }
1388
1576
  const title = titleOf(s)
@@ -1404,7 +1592,7 @@ function renderSessions() {
1404
1592
  ${s.running ? '<span>' + t('sessions.running') + '</span>' : ''}
1405
1593
  ${badge}${queueBadge}
1406
1594
  </div>
1407
- <div class="sc-workspace" title="${esc(workspace)}">⌂ ${esc(workspaceName)}</div>
1595
+ <div class="sc-workspace" title="${esc(workspace)}">⌂ ${esc(workspaceTitle)}</div>
1408
1596
  <span class="sc-arrow">›</span>
1409
1597
  </div>
1410
1598
  ${archiveButton}
@@ -1414,11 +1602,10 @@ function renderSessions() {
1414
1602
  }
1415
1603
  const divider = archived.length ? `<button class="archived-toggle" type="button" data-archived-toggle>${esc(showArchived ? t('wb.archivedShown') : t('wb.archivedHidden'))}</button>` : ''
1416
1604
  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>`
1605
+ list.innerHTML = rows || `<div class="empty">${esc(t('home.empty'))}</div>`
1419
1606
  list.classList.toggle('workspace-sorted', state.sessionSort === 'workspace')
1420
1607
  const sort = $('session-sort')
1421
- if (sort) sort.value = state.sessionSort
1608
+ if (sort) { sort.value = state.sessionSort; syncCustomSelect(sort) }
1422
1609
  $('home-empty').classList.toggle('hidden', visible.length > 0)
1423
1610
  const running = state.sessions.filter(s => s.running).length
1424
1611
  const pending = state.approvals.length + state.questions.length
@@ -1460,6 +1647,7 @@ function bindNativeBack() {
1460
1647
  try {
1461
1648
  CAP.Plugins?.App?.addListener?.('backButton', () => {
1462
1649
  if ($('composer-wrap')?.classList.contains('fs')) { setComposerFullscreen(false); return }
1650
+ if (customSelectCurrent) { closeCustomSelect(); return }
1463
1651
  const openModal = [...document.querySelectorAll('.modal')].find(m => !m.classList.contains('hidden'))
1464
1652
  if (openModal) { if (openModal.id === 'modal-notes') closeNotesModal(); else if (openModal.id === 'modal-archive') closeArchiveConfirm(); else openModal.classList.add('hidden'); return } // 先关弹窗
1465
1653
  if (document.body.classList.contains('in-session')) { closeSession(); return } // 会话页 → 回主页
@@ -2232,19 +2420,41 @@ async function cancelSession() {
2232
2420
  }
2233
2421
 
2234
2422
  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'))
2423
+ if (!state.token) { showView('view-settings'); return }
2424
+ if (!workspaceItems().length) await refreshWorkbench()
2425
+ const preferred = workspaceById(state.workspaceFilter)?.workspaceId || LS.get('lastNewSessionWorkspaceV1', '')
2426
+ renderNewSessionWorkspace(preferred)
2427
+ $('modal-new-session').classList.remove('hidden')
2428
+ }
2429
+ function renderNewSessionWorkspace(preferredId = '') {
2430
+ const items = workspaceItems()
2431
+ const select = $('new-session-workspace')
2432
+ if (!select) return
2433
+ const current = workspaceById(preferredId || select.value)?.workspaceId || items[0]?.workspaceId || ''
2434
+ select.innerHTML = workspaceOptionsHtml({ selected: current })
2435
+ select.disabled = !items.length
2436
+ const workspace = workspaceById(select.value)
2437
+ $('new-session-workspace-path').textContent = workspace?.path || ''
2438
+ $('new-session-empty').classList.toggle('hidden', !!items.length)
2439
+ $('new-session-create').disabled = !workspace
2440
+ }
2441
+ function closeNewSessionModal() {
2442
+ $('modal-new-session').classList.add('hidden')
2443
+ }
2444
+ async function createSessionInWorkspace() {
2445
+ const workspaceId = $('new-session-workspace').value
2446
+ if (!workspaceById(workspaceId)) return toast(t('newSession.chooseWorkspace'), 'err')
2447
+ const button = $('new-session-create')
2448
+ button.disabled = true
2449
+ const v = await safeRpc('session.create', { workspaceId: workspaceId }, t('home.createFailed'))
2450
+ button.disabled = false
2247
2451
  if (!v?.sessionId) return
2452
+ state.workspaceFilter = workspaceId
2453
+ state.fs.workspaceId = workspaceId
2454
+ LS.set('workspaceFilterV1', workspaceId)
2455
+ LS.set('fsWorkspaceIdV1', workspaceId)
2456
+ LS.set('lastNewSessionWorkspaceV1', workspaceId)
2457
+ closeNewSessionModal()
2248
2458
  toast(t('home.created'), 'ok')
2249
2459
  await refreshSessions()
2250
2460
  openSession(v.sessionId)
@@ -2554,6 +2764,32 @@ function fsParent(p) {
2554
2764
  return clean.slice(0, idx)
2555
2765
  }
2556
2766
 
2767
+ const FS_PREVIEW_EXTENSIONS = new Set([
2768
+ '.txt', '.md', '.markdown', '.log', '.json', '.jsonl', '.js', '.mjs', '.cjs', '.jsx',
2769
+ '.ts', '.tsx', '.py', '.css', '.html', '.htm', '.xml', '.yaml', '.yml', '.toml',
2770
+ '.ini', '.conf', '.env', '.sh', '.bash', '.zsh', '.fish', '.sql', '.java', '.kt',
2771
+ '.kts', '.go', '.rs', '.c', '.h', '.cpp', '.hpp', '.cs', '.php', '.rb', '.vue',
2772
+ '.svelte', '.gradle', '.properties', '.gitignore', '.dockerfile'
2773
+ ])
2774
+ function fsPreviewExtension(name) {
2775
+ const lower = String(name || '').toLowerCase()
2776
+ if (lower === 'dockerfile') return '.dockerfile'
2777
+ const dot = lower.lastIndexOf('.')
2778
+ return dot >= 0 ? lower.slice(dot) : ''
2779
+ }
2780
+ function fsCanPreview(name, size) {
2781
+ return Number(size) <= 1024 * 1024 && FS_PREVIEW_EXTENSIONS.has(fsPreviewExtension(name))
2782
+ }
2783
+ function openWorkspaceFiles(workspaceId) {
2784
+ const workspace = workspaceById(workspaceId)
2785
+ if (!workspace) return
2786
+ state.fs.workspaceId = workspace.workspaceId
2787
+ state.fs.loaded = false
2788
+ LS.set('fsWorkspaceIdV1', workspace.workspaceId)
2789
+ renderWorkspaceNavigation()
2790
+ showView('view-files')
2791
+ }
2792
+
2557
2793
  async function openWorkspaceModal() {
2558
2794
  if (!state.token) { toast(t('fs.noTokenToast'), 'err'); showView('view-settings'); return }
2559
2795
  if (!state.fs.path) await loadFs(null, { silent: true })
@@ -2581,7 +2817,19 @@ async function createWorkspace() {
2581
2817
  }
2582
2818
  closeWorkspaceModal()
2583
2819
  await loadFs(parent || null, { silent: true })
2584
- const v = await safeRpc('session.create', { cwd: data.path }, t('home.createFailed'))
2820
+ let workspace = null
2821
+ try {
2822
+ const created = await rpc('workspace.create', { path: data.path })
2823
+ workspace = created?.workspace || null
2824
+ } catch {}
2825
+ const sessionPayload = workspace?.workspaceId ? { workspaceId: workspace.workspaceId } : { cwd: data.path }
2826
+ const v = await safeRpc('session.create', sessionPayload, t('home.createFailed'))
2827
+ if (workspace?.workspaceId) {
2828
+ state.workspaceFilter = workspace.workspaceId
2829
+ state.fs.workspaceId = workspace.workspaceId
2830
+ LS.set('workspaceFilterV1', workspace.workspaceId)
2831
+ LS.set('fsWorkspaceIdV1', workspace.workspaceId)
2832
+ }
2585
2833
  await refreshSessions()
2586
2834
  if (v?.sessionId) {
2587
2835
  toast(t('workspace.created'), 'ok')
@@ -2648,17 +2896,18 @@ function renderFs(data) {
2648
2896
  }
2649
2897
  list.innerHTML = data.entries.map(e => {
2650
2898
  const isDir = e.type === 'dir'
2651
- return `<div class="fs-row" data-name="${esc(e.name)}" data-type="${esc(e.type)}">
2899
+ const preview = !isDir && fsCanPreview(e.name, e.size)
2900
+ return `<div class="fs-row" data-name="${esc(e.name)}" data-type="${esc(e.type)}" data-size="${Number(e.size) || 0}">
2652
2901
  <span class="fs-ico">${fsIconSvg(isDir)}</span>
2653
2902
  <span class="fs-meta">
2654
2903
  <span class="fs-name">${esc(e.name)}</span>
2655
2904
  <span class="fs-sub">${isDir ? t('fs.dir') : fmtSize(e.size)} · ${fmtFullTime(e.mtimeMs)}</span>
2656
2905
  </span>
2657
- <span class="fs-arrow">${isDir ? '›' : '↓'}</span>
2906
+ <span class="fs-arrow">${isDir || preview ? '›' : '↓'}</span>
2658
2907
  </div>`
2659
2908
  }).join('')
2660
2909
  list.querySelectorAll('.fs-row').forEach(row =>
2661
- row.addEventListener('click', () => fsOpenEntry(row.dataset.name, row.dataset.type)))
2910
+ row.addEventListener('click', () => fsOpenEntry(row.dataset.name, row.dataset.type, Number(row.dataset.size))))
2662
2911
  }
2663
2912
 
2664
2913
  function fsIconSvg(isDir) {
@@ -2667,15 +2916,20 @@ function fsIconSvg(isDir) {
2667
2916
  : '<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
2917
  }
2669
2918
 
2670
- function fsOpenEntry(name, type) {
2919
+ function fsOpenEntry(name, type, size = 0) {
2671
2920
  if (!name) return
2672
2921
  const p = fsJoin(state.fs.path, name)
2673
2922
  if (type === 'dir') return loadFs(p)
2923
+ if (fsCanPreview(name, size)) return openFsPreview(p, name)
2674
2924
  downloadFsFile(name)
2675
2925
  }
2676
2926
 
2677
2927
  function downloadFsFile(name) {
2678
2928
  const p = fsJoin(state.fs.path, name)
2929
+ downloadFsPath(p, name)
2930
+ }
2931
+
2932
+ function downloadFsPath(p, name) {
2679
2933
  const url = fsApiUrl('/file', { path: p })
2680
2934
  if (CAP?.isNativePlatform?.()) {
2681
2935
  if (window.NativeFile?.downloadToDownloads) {
@@ -2701,6 +2955,54 @@ function downloadFsFile(name) {
2701
2955
  a.remove()
2702
2956
  }
2703
2957
 
2958
+ let fsPreviewGeneration = 0
2959
+ async function openFsPreview(pathValue, name) {
2960
+ const generation = ++fsPreviewGeneration
2961
+ state.fs.preview = { path: pathValue, name, extension: fsPreviewExtension(name), content: '' }
2962
+ $('file-preview-title').textContent = name
2963
+ $('file-preview-path').textContent = pathValue
2964
+ $('file-preview-loading').textContent = t('fs.previewLoading')
2965
+ $('file-preview-loading').classList.remove('hidden')
2966
+ $('file-preview-source').classList.add('hidden')
2967
+ $('file-preview-rendered').classList.add('hidden')
2968
+ $('file-preview-tabs').classList.add('hidden')
2969
+ $('modal-file-preview').classList.remove('hidden')
2970
+ try {
2971
+ const res = await fetch(fsApiUrl('/preview', { path: pathValue }), { headers: fsHeaders() })
2972
+ if (res.status === 401) { closeFsPreview(); fsAuthError(401); return }
2973
+ const data = await res.json().catch(() => ({}))
2974
+ if (generation !== fsPreviewGeneration) return
2975
+ if (!res.ok) {
2976
+ const message = data.error === 'preview-too-large' ? t('fs.previewTooLarge')
2977
+ : (data.error === 'preview-unsupported' || data.error === 'preview-binary') ? t('fs.previewUnsupported')
2978
+ : t('fs.previewFailed', { msg: data.error || ('HTTP ' + res.status) })
2979
+ throw new Error(message)
2980
+ }
2981
+ state.fs.preview = data
2982
+ $('file-preview-loading').classList.add('hidden')
2983
+ $('file-preview-source').textContent = data.content || ''
2984
+ const markdown = data.extension === '.md' || data.extension === '.markdown'
2985
+ $('file-preview-tabs').classList.toggle('hidden', !markdown)
2986
+ if (markdown) $('file-preview-rendered').innerHTML = window.mdToHtml(data.content || '')
2987
+ showFsPreviewMode(markdown ? 'rendered' : 'source')
2988
+ } catch (e) {
2989
+ if (generation !== fsPreviewGeneration) return
2990
+ $('file-preview-loading').textContent = e.message || t('fs.previewFailed', { msg: t('fs.networkError') })
2991
+ }
2992
+ }
2993
+ function showFsPreviewMode(mode) {
2994
+ const rendered = mode === 'rendered' && !($('file-preview-tabs').classList.contains('hidden'))
2995
+ $('file-preview-source').classList.toggle('hidden', rendered)
2996
+ $('file-preview-rendered').classList.toggle('hidden', !rendered)
2997
+ $('file-preview-source-tab').classList.toggle('current', !rendered)
2998
+ $('file-preview-rendered-tab').classList.toggle('current', rendered)
2999
+ }
3000
+ function closeFsPreview() {
3001
+ fsPreviewGeneration++
3002
+ state.fs.preview = null
3003
+ $('modal-file-preview').classList.add('hidden')
3004
+ }
3005
+
2704
3006
  function showFsProgress(pct, loaded, total) {
2705
3007
  $('fs-progress').classList.remove('hidden')
2706
3008
  $('fs-progress-bar').style.width = Math.max(2, Math.min(100, pct)) + '%'
@@ -3094,6 +3396,7 @@ async function loadLocalVersion() {
3094
3396
  */
3095
3397
  const ANNOUNCEMENTS_KEY = 'seenAnnouncementsV1'
3096
3398
  const ANNOUNCEMENT_HISTORY_KEY = 'announcementHistoryV1'
3399
+ const ANNOUNCEMENT_VOTES_KEY = 'announcementVotesV1'
3097
3400
  function readSeenAnnouncements() {
3098
3401
  try {
3099
3402
  const value = JSON.parse(LS.get(ANNOUNCEMENTS_KEY, '{}'))
@@ -3111,6 +3414,32 @@ function markAnnouncementSeen(id) {
3111
3414
  }
3112
3415
  LS.set(ANNOUNCEMENTS_KEY, JSON.stringify(seen))
3113
3416
  }
3417
+ function readAnnouncementVotes() {
3418
+ try {
3419
+ const value = JSON.parse(LS.get(ANNOUNCEMENT_VOTES_KEY, '{}'))
3420
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
3421
+ } catch { return {} }
3422
+ }
3423
+ function announcementVoteKey(announcementId, pollId) {
3424
+ return `${announcementId}\u0000${pollId}`
3425
+ }
3426
+ function storeAnnouncementVote(announcementId, pollId, optionId) {
3427
+ const votes = readAnnouncementVotes()
3428
+ votes[announcementVoteKey(announcementId, pollId)] = { optionId, votedAt: Date.now() }
3429
+ const keys = Object.keys(votes)
3430
+ if (keys.length > 100) {
3431
+ keys.sort((a, b) => Number(votes[a]?.votedAt || 0) - Number(votes[b]?.votedAt || 0))
3432
+ for (const key of keys.slice(0, keys.length - 100)) delete votes[key]
3433
+ }
3434
+ LS.set(ANNOUNCEMENT_VOTES_KEY, JSON.stringify(votes))
3435
+ }
3436
+ function announcementVote(item) {
3437
+ if (!item?.poll?.id) return null
3438
+ const saved = readAnnouncementVotes()[announcementVoteKey(item.id, item.poll.id)]
3439
+ if (!saved?.optionId) return null
3440
+ const option = item.poll.options.find(entry => entry.id === saved.optionId)
3441
+ return option ? { ...saved, option } : null
3442
+ }
3114
3443
  function readAnnouncementHistory() {
3115
3444
  try {
3116
3445
  const value = JSON.parse(LS.get(ANNOUNCEMENT_HISTORY_KEY, '[]'))
@@ -3135,7 +3464,11 @@ function renderAnnouncementHistory() {
3135
3464
  box.innerHTML = list.map(item => {
3136
3465
  const date = Number(item.publishedAt) > 0 ? fmtFullTime(item.publishedAt) : t('announcement.noDate')
3137
3466
  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>`
3467
+ const vote = announcementVote(item)
3468
+ const pollAction = item.poll ? (vote
3469
+ ? `<div class="announcement-poll-status">${esc(t('announcement.voteThanks', { option: vote.option.label }))}</div>`
3470
+ : `<button class="mini-btn" type="button" data-announcement-poll="${esc(item.id)}">${esc(t('announcement.voteFromHistory'))}</button>`) : ''
3471
+ 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
3472
  }).join('')
3140
3473
  }
3141
3474
  function openAnnouncementHistory() {
@@ -3153,6 +3486,22 @@ function announcementVersionMatch(item) {
3153
3486
  if (max && cmpVersion(state.localVersion, max) > 0) return false
3154
3487
  return true
3155
3488
  }
3489
+ function normalizeAnnouncementPoll(value, announcementId) {
3490
+ if (!value || typeof value !== 'object') return null
3491
+ const id = String(value.id || announcementId || '').trim().slice(0, 120)
3492
+ const question = String(value.question || '').trim().slice(0, 300)
3493
+ if (!id || !question || !Array.isArray(value.options)) return null
3494
+ const seen = new Set()
3495
+ const options = []
3496
+ for (const raw of value.options.slice(0, 8)) {
3497
+ const optionId = String(raw?.id || '').trim().slice(0, 120)
3498
+ const label = String(raw?.label || '').trim().slice(0, 200)
3499
+ if (!optionId || !label || seen.has(optionId)) continue
3500
+ seen.add(optionId)
3501
+ options.push({ id: optionId, label, description: String(raw?.description || '').trim().slice(0, 500) })
3502
+ }
3503
+ return options.length >= 2 ? { id, question, options } : null
3504
+ }
3156
3505
  function normalizeAnnouncement(item, base) {
3157
3506
  if (!item || typeof item !== 'object') return null
3158
3507
  const id = String(item.id || '').trim().slice(0, 120)
@@ -3176,13 +3525,34 @@ function normalizeAnnouncement(item, base) {
3176
3525
  id, title, content, actionUrl,
3177
3526
  actionText: String(item.actionText || '').trim().slice(0, 80),
3178
3527
  publishedAt: Number.isFinite(startsAt) ? startsAt : 0,
3179
- force: item.force === true
3180
- }
3528
+ force: item.force === true,
3529
+ poll: normalizeAnnouncementPoll(item.poll, id)
3530
+ }
3531
+ }
3532
+ function renderAnnouncementPoll(item) {
3533
+ const panel = $('announcement-poll')
3534
+ const poll = item?.poll
3535
+ panel.classList.toggle('hidden', !poll)
3536
+ if (!poll) return
3537
+ const vote = announcementVote(item)
3538
+ $('announcement-poll-question').textContent = poll.question
3539
+ $('announcement-poll-options').innerHTML = poll.options.map(option => `
3540
+ <label class="announcement-poll-option">
3541
+ <input type="radio" name="announcement-poll-option" value="${esc(option.id)}"${vote?.optionId === option.id ? ' checked' : ''}${vote ? ' disabled' : ''}>
3542
+ <span><strong>${esc(option.label)}</strong>${option.description ? `<small>${esc(option.description)}</small>` : ''}</span>
3543
+ </label>`).join('')
3544
+ const status = $('announcement-poll-status')
3545
+ status.textContent = vote ? t('announcement.voteThanks', { option: vote.option.label }) : ''
3546
+ status.classList.toggle('hidden', !vote)
3547
+ const submit = $('announcement-poll-submit')
3548
+ submit.classList.toggle('hidden', !!vote)
3549
+ submit.disabled = true
3181
3550
  }
3182
3551
  function openAnnouncementModal(item) {
3183
3552
  state.announcement = item
3184
3553
  $('announcement-title').textContent = item.title
3185
3554
  $('announcement-content').innerHTML = esc(item.content).replace(/\r?\n/g, '<br>')
3555
+ renderAnnouncementPoll(item)
3186
3556
  const action = $('announcement-action')
3187
3557
  if (item.actionUrl) {
3188
3558
  action.href = item.actionUrl
@@ -3196,6 +3566,48 @@ function openAnnouncementModal(item) {
3196
3566
  $('announcement-later').classList.toggle('hidden', item.force)
3197
3567
  $('modal-announcement').classList.remove('hidden')
3198
3568
  }
3569
+ async function submitAnnouncementVote() {
3570
+ const item = state.announcement
3571
+ const poll = item?.poll
3572
+ const optionId = document.querySelector('input[name="announcement-poll-option"]:checked')?.value || ''
3573
+ const option = poll?.options.find(entry => entry.id === optionId)
3574
+ if (!poll || !option) return toast(t('announcement.voteChoose'), 'err')
3575
+ const button = $('announcement-poll-submit')
3576
+ button.disabled = true
3577
+ try {
3578
+ const base = updateBase()
3579
+ if (!base) throw new Error(t('announcement.voteNetworkError'))
3580
+ const res = await fetch(base + '/feedback', {
3581
+ method: 'POST',
3582
+ headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token },
3583
+ body: JSON.stringify({
3584
+ type: 'poll',
3585
+ message: `Poll ${poll.id}: ${option.id}`,
3586
+ announcementId: item.id,
3587
+ pollId: poll.id,
3588
+ optionId: option.id,
3589
+ appVersion: state.localVersion
3590
+ })
3591
+ })
3592
+ const data = await res.json().catch(() => ({}))
3593
+ if (res.ok && data.ok) {
3594
+ storeAnnouncementVote(item.id, poll.id, option.id)
3595
+ markAnnouncementSeen(item.id)
3596
+ renderAnnouncementPoll(item)
3597
+ renderAnnouncementHistory()
3598
+ toast(t('announcement.voteThanks', { option: option.label }), 'ok')
3599
+ } else if (res.status === 429) {
3600
+ toast(t('announcement.voteAgainLater'), 'err')
3601
+ button.disabled = false
3602
+ } else {
3603
+ toast(t('announcement.voteFailed', { msg: data.error || res.status }), 'err')
3604
+ button.disabled = false
3605
+ }
3606
+ } catch (e) {
3607
+ toast(t('announcement.voteFailed', { msg: e.message || t('announcement.voteNetworkError') }), 'err')
3608
+ button.disabled = false
3609
+ }
3610
+ }
3199
3611
  function closeAnnouncement(markSeen) {
3200
3612
  if (markSeen && state.announcement) markAnnouncementSeen(state.announcement.id)
3201
3613
  state.announcement = null
@@ -3502,6 +3914,7 @@ function applyBgConfigFromNative() {
3502
3914
  const v = String(cfg.intervalMin ?? 1)
3503
3915
  const opts = Array.from($('bg-interval')?.options || [])
3504
3916
  if (opts.some(o => o.value === v)) $('bg-interval').value = v
3917
+ syncCustomSelect($('bg-interval'))
3505
3918
  if ($('opt-task-done')) $('opt-task-done').checked = cfg.notifyTaskDone !== false
3506
3919
  $('bg-auth-status')?.classList.toggle('hidden', !cfg.loginExpired)
3507
3920
  } catch {}
@@ -3594,9 +4007,24 @@ function deletePreset(id) {
3594
4007
 
3595
4008
  /* ---------------- 峰谷计费提醒(前台服务进程内定时, 绕开 MIUI 后台限制) ---------------- */
3596
4009
  function peakRemindOn() { return LS.get('peakRemind', '0') === '1' }
4010
+ const LEGACY_PEAK_NOTIFICATION_IDS = [8801, 8802, 8803, 8804]
3597
4011
 
3598
- async function schedulePeakReminders() {
4012
+ async function cancelLegacyPeakNotifications() {
3599
4013
  if (!CAP?.isNativePlatform?.()) return false
4014
+ const notifications = CAP.Plugins?.LocalNotifications
4015
+ if (!notifications?.cancel) return true
4016
+ try {
4017
+ await notifications.cancel({ notifications: LEGACY_PEAK_NOTIFICATION_IDS.map(id => ({ id })) })
4018
+ return true
4019
+ } catch (error) {
4020
+ console.warn('Failed to cancel legacy peak reminders', error)
4021
+ return false
4022
+ }
4023
+ }
4024
+
4025
+ async function schedulePeakReminders({ legacyCleaned = false } = {}) {
4026
+ if (!CAP?.isNativePlatform?.()) return false
4027
+ if (!legacyCleaned && !await cancelLegacyPeakNotifications()) return false
3600
4028
  const b = bgBridge()
3601
4029
  if (!b?.startPeakReminder) return false
3602
4030
  try {
@@ -3609,10 +4037,19 @@ async function cancelPeakReminders() {
3609
4037
  const b = bgBridge()
3610
4038
  if (!b?.stopPeakReminder) return false
3611
4039
  try {
3612
- return b.stopPeakReminder() !== false
4040
+ const stopped = b.stopPeakReminder() !== false
4041
+ const legacyCleaned = await cancelLegacyPeakNotifications()
4042
+ return stopped && legacyCleaned
3613
4043
  } catch { return false }
3614
4044
  }
3615
4045
 
4046
+ async function restorePeakReminders() {
4047
+ if (!CAP?.isNativePlatform?.()) return
4048
+ // 旧版使用 LocalNotifications 每日调度;无论当前开关状态都先清理,防止与前台服务重复提醒。
4049
+ const legacyCleaned = await cancelLegacyPeakNotifications()
4050
+ if (peakRemindOn() && legacyCleaned) await schedulePeakReminders({ legacyCleaned: true })
4051
+ }
4052
+
3616
4053
  /* ---------------- 视图切换 ---------------- */
3617
4054
  function showView(id) {
3618
4055
  for (const v of ['view-home', 'view-files', 'view-session', 'view-activity', 'view-stats', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
@@ -3620,7 +4057,10 @@ function showView(id) {
3620
4057
  document.body.classList.toggle('in-session', id === 'view-session')
3621
4058
  document.querySelectorAll('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === id))
3622
4059
  window.scrollTo(0, 0)
3623
- if (id === 'view-files' && !state.fs.loaded) loadFs(null, { silent: true })
4060
+ if (id === 'view-files' && !state.fs.loaded) {
4061
+ const workspace = workspaceById(state.fs.workspaceId)
4062
+ loadFs(workspace?.path || null, { silent: true, resetRoot: true })
4063
+ }
3624
4064
  if (id === 'view-stats') loadStats()
3625
4065
  if (id === 'view-settings') showSettingsHome()
3626
4066
  }
@@ -4064,6 +4504,8 @@ function bindUi() {
4064
4504
  if (newButton) {
4065
4505
  safeRpc('session.create', { workspaceId: newButton.dataset.wbNew }, t('home.createFailed')).then(async v => {
4066
4506
  if (!v?.sessionId) return
4507
+ state.workspaceFilter = newButton.dataset.wbNew
4508
+ LS.set('workspaceFilterV1', state.workspaceFilter)
4067
4509
  toast(t('home.created'), 'ok')
4068
4510
  await refreshSessions()
4069
4511
  openSession(v.sessionId)
@@ -4110,12 +4552,41 @@ function bindUi() {
4110
4552
  if (e.key === 'Escape' && !$('feedback-sheet').classList.contains('hidden')) { closeFeedbackSheet(); $('btn-feedback').focus() }
4111
4553
  })
4112
4554
  $('btn-new-session').addEventListener('click', newSession)
4555
+ $('session-workspace-filter').addEventListener('change', (e) => {
4556
+ state.workspaceFilter = e.target.value
4557
+ if (state.workspaceFilter) LS.set('workspaceFilterV1', state.workspaceFilter)
4558
+ else LS.del('workspaceFilterV1')
4559
+ renderWorkspaceNavigation()
4560
+ renderSessions()
4561
+ })
4562
+ $('session-workspace-files').addEventListener('click', () => openWorkspaceFiles(state.workspaceFilter))
4563
+ $('new-session-workspace').addEventListener('change', (e) => renderNewSessionWorkspace(e.target.value))
4564
+ $('new-session-cancel').addEventListener('click', closeNewSessionModal)
4565
+ $('new-session-create').addEventListener('click', createSessionInWorkspace)
4566
+ $('modal-new-session').addEventListener('click', (e) => { if (e.target === $('modal-new-session')) closeNewSessionModal() })
4113
4567
  $('btn-new-workspace').addEventListener('click', openWorkspaceModal)
4114
4568
  $('session-sort')?.addEventListener('change', (e) => {
4115
4569
  state.sessionSort = e.target.value === 'workspace' ? 'workspace' : 'time'
4116
4570
  LS.set('sessionSort', state.sessionSort)
4117
4571
  renderSessions()
4118
4572
  })
4573
+ $('fs-workspace').addEventListener('change', (e) => {
4574
+ state.fs.workspaceId = e.target.value
4575
+ if (state.fs.workspaceId) LS.set('fsWorkspaceIdV1', state.fs.workspaceId)
4576
+ else LS.del('fsWorkspaceIdV1')
4577
+ state.fs.loaded = false
4578
+ const workspace = workspaceById(state.fs.workspaceId)
4579
+ loadFs(workspace?.path || null, { resetRoot: true })
4580
+ })
4581
+ $('file-preview-close').addEventListener('click', closeFsPreview)
4582
+ $('file-preview-done').addEventListener('click', closeFsPreview)
4583
+ $('file-preview-source-tab').addEventListener('click', () => showFsPreviewMode('source'))
4584
+ $('file-preview-rendered-tab').addEventListener('click', () => showFsPreviewMode('rendered'))
4585
+ $('file-preview-download').addEventListener('click', () => {
4586
+ const preview = state.fs.preview
4587
+ if (preview?.path && preview?.name) downloadFsPath(preview.path, preview.name)
4588
+ })
4589
+ $('modal-file-preview').addEventListener('click', (e) => { if (e.target === $('modal-file-preview')) closeFsPreview() })
4119
4590
  $('btn-cancel').addEventListener('click', cancelSession)
4120
4591
  $('btn-send').addEventListener('click', sendMessage)
4121
4592
  $('btn-fs-send').addEventListener('click', sendMessage)
@@ -4213,10 +4684,18 @@ function bindUi() {
4213
4684
  $('modal-archive').addEventListener('click', (e) => { if (e.target === $('modal-archive')) closeArchiveConfirm() })
4214
4685
  $('announcement-later').addEventListener('click', () => closeAnnouncement(false))
4215
4686
  $('announcement-confirm').addEventListener('click', () => closeAnnouncement(true))
4687
+ $('announcement-poll-options').addEventListener('change', () => { $('announcement-poll-submit').disabled = false })
4688
+ $('announcement-poll-submit').addEventListener('click', submitAnnouncementVote)
4216
4689
  $('modal-announcement').addEventListener('click', (e) => {
4217
4690
  if (e.target === $('modal-announcement') && !state.announcement?.force) closeAnnouncement(false)
4218
4691
  })
4219
4692
  $('announcement-history-close').addEventListener('click', closeAnnouncementHistory)
4693
+ $('announcement-history-list').addEventListener('click', (e) => {
4694
+ const button = e.target.closest('[data-announcement-poll]')
4695
+ if (!button) return
4696
+ const item = readAnnouncementHistory().find(entry => entry.id === button.dataset.announcementPoll)
4697
+ if (item?.poll) { closeAnnouncementHistory(); openAnnouncementModal(item) }
4698
+ })
4220
4699
  $('modal-announcement-history').addEventListener('click', (e) => {
4221
4700
  if (e.target === $('modal-announcement-history')) closeAnnouncementHistory()
4222
4701
  })
@@ -4256,7 +4735,7 @@ function bindUi() {
4256
4735
  $('btn-update-expand').addEventListener('click', toggleUpdateExpand)
4257
4736
  $('btn-reset').addEventListener('click', () => {
4258
4737
  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)
4738
+ 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
4739
  if (bgBridge()?.saveBackgroundConfig) saveBgConfig(false)
4261
4740
  location.reload()
4262
4741
  })
@@ -4301,8 +4780,8 @@ function bindUi() {
4301
4780
  })
4302
4781
  $('btn-test-notify').addEventListener('click', sendTestNotification)
4303
4782
  $('btn-announcement-history').addEventListener('click', openAnnouncementHistory)
4304
- // 已开启则启动时重新调度, 防止系统清理后丢失
4305
- if (peakRemindOn() && CAP?.isNativePlatform?.()) schedulePeakReminders()
4783
+ // 启动时先清理旧版 LocalNotifications,再按当前开关恢复前台提醒服务。
4784
+ restorePeakReminders()
4306
4785
  applyBgConfigFromNative()
4307
4786
  $('opt-bg-poll').addEventListener('change', async (e) => {
4308
4787
  if (e.target.checked) {
@@ -4355,6 +4834,8 @@ function bindUi() {
4355
4834
 
4356
4835
  bindRail()
4357
4836
 
4837
+ initCustomSelects()
4838
+
4358
4839
  // 向上翻历史 / 向下回最新
4359
4840
  $('history').addEventListener('scroll', () => {
4360
4841
  const box = $('history')