dsh-remote-plugin 0.6.10-rc.1 → 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/README.en.md +20 -1
- package/README.md +20 -1
- package/apk/dsh-remote.apk +0 -0
- package/client.js +56 -4
- package/gateway-stats.cjs +40 -11
- package/gateway.cjs +149 -34
- package/index.mjs +3 -1
- package/package.json +1 -1
- package/public/app.js +541 -47
- package/public/desktop/desktop.js +10 -3
- package/public/index.html +84 -6
- package/public/plugin-icon.svg +5 -0
- package/public/styles.css +86 -0
- package/public/theme.js +9 -2
- package/public/update.json +12 -4
- package/public/version.json +1 -1
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 = (
|
|
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
|
-
|
|
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
|
|
1342
|
-
return
|
|
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.
|
|
1372
|
-
|
|
1373
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 } // 会话页 → 回主页
|
|
@@ -1641,7 +1829,10 @@ function insertLiveEvent(event) {
|
|
|
1641
1829
|
h.renderStart = Math.max(0, h.renderEnd - 200)
|
|
1642
1830
|
renderHistory(false, 'bottom')
|
|
1643
1831
|
} else {
|
|
1644
|
-
|
|
1832
|
+
// 图片异步撑高历史区后,用户可能瞬间不再满足 nearBottom。新回复仍须
|
|
1833
|
+
// 扩展可见窗口,但保持当前阅读位置,不能等到重新进入会话才出现。
|
|
1834
|
+
h.renderEnd = h.visible.length
|
|
1835
|
+
renderHistory(false, 'fixed')
|
|
1645
1836
|
}
|
|
1646
1837
|
scheduleHistoryCacheSave()
|
|
1647
1838
|
}
|
|
@@ -1684,6 +1875,7 @@ function renderHistory(reset, mode = 'bottom') {
|
|
|
1684
1875
|
box.innerHTML = filtered.slice(start, end).map(e => eventHtml(e, { toolNames })).join('')
|
|
1685
1876
|
if (reset || mode === 'bottom') box.scrollTop = box.scrollHeight
|
|
1686
1877
|
else if (mode === 'keep') box.scrollTop = Math.max(0, oldTop + (box.scrollHeight - oldH))
|
|
1878
|
+
else if (mode === 'fixed') box.scrollTop = oldTop
|
|
1687
1879
|
updateRail()
|
|
1688
1880
|
}
|
|
1689
1881
|
|
|
@@ -1856,8 +2048,11 @@ function statsHtml(s) {
|
|
|
1856
2048
|
return html || '<div class="empty">' + t('stats.empty') + '</div>'
|
|
1857
2049
|
}
|
|
1858
2050
|
|
|
2051
|
+
let sessionCardsRenderGeneration = 0
|
|
1859
2052
|
async function renderSessionCards() {
|
|
1860
|
-
const
|
|
2053
|
+
const renderGeneration = ++sessionCardsRenderGeneration
|
|
2054
|
+
const sessionId = state.current
|
|
2055
|
+
const s = state.byId.get(sessionId)
|
|
1861
2056
|
const box = $('session-cards')
|
|
1862
2057
|
const statsBox = $('stats-body')
|
|
1863
2058
|
if (!s) { box.innerHTML = ''; if (statsBox) statsBox.innerHTML = ''; return }
|
|
@@ -1888,7 +2083,8 @@ async function renderSessionCards() {
|
|
|
1888
2083
|
btn.addEventListener('click', () => goalAction(btn.dataset.goal)))
|
|
1889
2084
|
|
|
1890
2085
|
// 子代理
|
|
1891
|
-
const sub = await safeRpc('subagent.list', { parentSessionId:
|
|
2086
|
+
const sub = await safeRpc('subagent.list', { parentSessionId: sessionId })
|
|
2087
|
+
if (renderGeneration !== sessionCardsRenderGeneration || state.current !== sessionId) return
|
|
1892
2088
|
if (sub?.entries?.length) {
|
|
1893
2089
|
const rows = sub.entries.map(e => {
|
|
1894
2090
|
if (e.kind === 'diagnostic') return `<div class="card-row"><span class="k">${t('subagent.diagnostic')}</span><span class="v">${esc(e.reason)}</span></div>`
|
|
@@ -2224,19 +2420,41 @@ async function cancelSession() {
|
|
|
2224
2420
|
}
|
|
2225
2421
|
|
|
2226
2422
|
async function newSession() {
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
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
|
|
2239
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()
|
|
2240
2458
|
toast(t('home.created'), 'ok')
|
|
2241
2459
|
await refreshSessions()
|
|
2242
2460
|
openSession(v.sessionId)
|
|
@@ -2326,7 +2544,9 @@ function renderOverview() {
|
|
|
2326
2544
|
const ring = $('overview-pulse-ring')
|
|
2327
2545
|
if (!ring) return
|
|
2328
2546
|
const checks = {
|
|
2329
|
-
|
|
2547
|
+
// 独立网关页面默认走同源,此时 state.server 合法地为空;不能因此把
|
|
2548
|
+
// 已连接网关误报为离线。Capacitor 等非 HTTP 页面仍要求显式服务器。
|
|
2549
|
+
gateway: !!state.token && (!!state.server || /^https?:$/.test(location.protocol)),
|
|
2330
2550
|
dsh: !!state.hostInfo,
|
|
2331
2551
|
mux: !!state.streamsOk?.mux,
|
|
2332
2552
|
host: !!state.streamsOk?.host
|
|
@@ -2544,6 +2764,32 @@ function fsParent(p) {
|
|
|
2544
2764
|
return clean.slice(0, idx)
|
|
2545
2765
|
}
|
|
2546
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
|
+
|
|
2547
2793
|
async function openWorkspaceModal() {
|
|
2548
2794
|
if (!state.token) { toast(t('fs.noTokenToast'), 'err'); showView('view-settings'); return }
|
|
2549
2795
|
if (!state.fs.path) await loadFs(null, { silent: true })
|
|
@@ -2571,7 +2817,19 @@ async function createWorkspace() {
|
|
|
2571
2817
|
}
|
|
2572
2818
|
closeWorkspaceModal()
|
|
2573
2819
|
await loadFs(parent || null, { silent: true })
|
|
2574
|
-
|
|
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
|
+
}
|
|
2575
2833
|
await refreshSessions()
|
|
2576
2834
|
if (v?.sessionId) {
|
|
2577
2835
|
toast(t('workspace.created'), 'ok')
|
|
@@ -2638,17 +2896,18 @@ function renderFs(data) {
|
|
|
2638
2896
|
}
|
|
2639
2897
|
list.innerHTML = data.entries.map(e => {
|
|
2640
2898
|
const isDir = e.type === 'dir'
|
|
2641
|
-
|
|
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}">
|
|
2642
2901
|
<span class="fs-ico">${fsIconSvg(isDir)}</span>
|
|
2643
2902
|
<span class="fs-meta">
|
|
2644
2903
|
<span class="fs-name">${esc(e.name)}</span>
|
|
2645
2904
|
<span class="fs-sub">${isDir ? t('fs.dir') : fmtSize(e.size)} · ${fmtFullTime(e.mtimeMs)}</span>
|
|
2646
2905
|
</span>
|
|
2647
|
-
<span class="fs-arrow">${isDir ? '›' : '↓'}</span>
|
|
2906
|
+
<span class="fs-arrow">${isDir || preview ? '›' : '↓'}</span>
|
|
2648
2907
|
</div>`
|
|
2649
2908
|
}).join('')
|
|
2650
2909
|
list.querySelectorAll('.fs-row').forEach(row =>
|
|
2651
|
-
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))))
|
|
2652
2911
|
}
|
|
2653
2912
|
|
|
2654
2913
|
function fsIconSvg(isDir) {
|
|
@@ -2657,15 +2916,20 @@ function fsIconSvg(isDir) {
|
|
|
2657
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>'
|
|
2658
2917
|
}
|
|
2659
2918
|
|
|
2660
|
-
function fsOpenEntry(name, type) {
|
|
2919
|
+
function fsOpenEntry(name, type, size = 0) {
|
|
2661
2920
|
if (!name) return
|
|
2662
2921
|
const p = fsJoin(state.fs.path, name)
|
|
2663
2922
|
if (type === 'dir') return loadFs(p)
|
|
2923
|
+
if (fsCanPreview(name, size)) return openFsPreview(p, name)
|
|
2664
2924
|
downloadFsFile(name)
|
|
2665
2925
|
}
|
|
2666
2926
|
|
|
2667
2927
|
function downloadFsFile(name) {
|
|
2668
2928
|
const p = fsJoin(state.fs.path, name)
|
|
2929
|
+
downloadFsPath(p, name)
|
|
2930
|
+
}
|
|
2931
|
+
|
|
2932
|
+
function downloadFsPath(p, name) {
|
|
2669
2933
|
const url = fsApiUrl('/file', { path: p })
|
|
2670
2934
|
if (CAP?.isNativePlatform?.()) {
|
|
2671
2935
|
if (window.NativeFile?.downloadToDownloads) {
|
|
@@ -2691,6 +2955,54 @@ function downloadFsFile(name) {
|
|
|
2691
2955
|
a.remove()
|
|
2692
2956
|
}
|
|
2693
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
|
+
|
|
2694
3006
|
function showFsProgress(pct, loaded, total) {
|
|
2695
3007
|
$('fs-progress').classList.remove('hidden')
|
|
2696
3008
|
$('fs-progress-bar').style.width = Math.max(2, Math.min(100, pct)) + '%'
|
|
@@ -3084,6 +3396,7 @@ async function loadLocalVersion() {
|
|
|
3084
3396
|
*/
|
|
3085
3397
|
const ANNOUNCEMENTS_KEY = 'seenAnnouncementsV1'
|
|
3086
3398
|
const ANNOUNCEMENT_HISTORY_KEY = 'announcementHistoryV1'
|
|
3399
|
+
const ANNOUNCEMENT_VOTES_KEY = 'announcementVotesV1'
|
|
3087
3400
|
function readSeenAnnouncements() {
|
|
3088
3401
|
try {
|
|
3089
3402
|
const value = JSON.parse(LS.get(ANNOUNCEMENTS_KEY, '{}'))
|
|
@@ -3101,6 +3414,32 @@ function markAnnouncementSeen(id) {
|
|
|
3101
3414
|
}
|
|
3102
3415
|
LS.set(ANNOUNCEMENTS_KEY, JSON.stringify(seen))
|
|
3103
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
|
+
}
|
|
3104
3443
|
function readAnnouncementHistory() {
|
|
3105
3444
|
try {
|
|
3106
3445
|
const value = JSON.parse(LS.get(ANNOUNCEMENT_HISTORY_KEY, '[]'))
|
|
@@ -3125,7 +3464,11 @@ function renderAnnouncementHistory() {
|
|
|
3125
3464
|
box.innerHTML = list.map(item => {
|
|
3126
3465
|
const date = Number(item.publishedAt) > 0 ? fmtFullTime(item.publishedAt) : t('announcement.noDate')
|
|
3127
3466
|
const action = item.actionUrl ? `<a class="announcement-action" href="${esc(item.actionUrl)}" target="_blank" rel="noopener">${esc(item.actionText || t('announcement.open'))}</a>` : ''
|
|
3128
|
-
|
|
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>`
|
|
3129
3472
|
}).join('')
|
|
3130
3473
|
}
|
|
3131
3474
|
function openAnnouncementHistory() {
|
|
@@ -3143,6 +3486,22 @@ function announcementVersionMatch(item) {
|
|
|
3143
3486
|
if (max && cmpVersion(state.localVersion, max) > 0) return false
|
|
3144
3487
|
return true
|
|
3145
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
|
+
}
|
|
3146
3505
|
function normalizeAnnouncement(item, base) {
|
|
3147
3506
|
if (!item || typeof item !== 'object') return null
|
|
3148
3507
|
const id = String(item.id || '').trim().slice(0, 120)
|
|
@@ -3166,13 +3525,34 @@ function normalizeAnnouncement(item, base) {
|
|
|
3166
3525
|
id, title, content, actionUrl,
|
|
3167
3526
|
actionText: String(item.actionText || '').trim().slice(0, 80),
|
|
3168
3527
|
publishedAt: Number.isFinite(startsAt) ? startsAt : 0,
|
|
3169
|
-
force: item.force === true
|
|
3170
|
-
|
|
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
|
|
3171
3550
|
}
|
|
3172
3551
|
function openAnnouncementModal(item) {
|
|
3173
3552
|
state.announcement = item
|
|
3174
3553
|
$('announcement-title').textContent = item.title
|
|
3175
3554
|
$('announcement-content').innerHTML = esc(item.content).replace(/\r?\n/g, '<br>')
|
|
3555
|
+
renderAnnouncementPoll(item)
|
|
3176
3556
|
const action = $('announcement-action')
|
|
3177
3557
|
if (item.actionUrl) {
|
|
3178
3558
|
action.href = item.actionUrl
|
|
@@ -3186,6 +3566,48 @@ function openAnnouncementModal(item) {
|
|
|
3186
3566
|
$('announcement-later').classList.toggle('hidden', item.force)
|
|
3187
3567
|
$('modal-announcement').classList.remove('hidden')
|
|
3188
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
|
+
}
|
|
3189
3611
|
function closeAnnouncement(markSeen) {
|
|
3190
3612
|
if (markSeen && state.announcement) markAnnouncementSeen(state.announcement.id)
|
|
3191
3613
|
state.announcement = null
|
|
@@ -3492,6 +3914,7 @@ function applyBgConfigFromNative() {
|
|
|
3492
3914
|
const v = String(cfg.intervalMin ?? 1)
|
|
3493
3915
|
const opts = Array.from($('bg-interval')?.options || [])
|
|
3494
3916
|
if (opts.some(o => o.value === v)) $('bg-interval').value = v
|
|
3917
|
+
syncCustomSelect($('bg-interval'))
|
|
3495
3918
|
if ($('opt-task-done')) $('opt-task-done').checked = cfg.notifyTaskDone !== false
|
|
3496
3919
|
$('bg-auth-status')?.classList.toggle('hidden', !cfg.loginExpired)
|
|
3497
3920
|
} catch {}
|
|
@@ -3584,9 +4007,24 @@ function deletePreset(id) {
|
|
|
3584
4007
|
|
|
3585
4008
|
/* ---------------- 峰谷计费提醒(前台服务进程内定时, 绕开 MIUI 后台限制) ---------------- */
|
|
3586
4009
|
function peakRemindOn() { return LS.get('peakRemind', '0') === '1' }
|
|
4010
|
+
const LEGACY_PEAK_NOTIFICATION_IDS = [8801, 8802, 8803, 8804]
|
|
4011
|
+
|
|
4012
|
+
async function cancelLegacyPeakNotifications() {
|
|
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
|
+
}
|
|
3587
4024
|
|
|
3588
|
-
async function schedulePeakReminders() {
|
|
4025
|
+
async function schedulePeakReminders({ legacyCleaned = false } = {}) {
|
|
3589
4026
|
if (!CAP?.isNativePlatform?.()) return false
|
|
4027
|
+
if (!legacyCleaned && !await cancelLegacyPeakNotifications()) return false
|
|
3590
4028
|
const b = bgBridge()
|
|
3591
4029
|
if (!b?.startPeakReminder) return false
|
|
3592
4030
|
try {
|
|
@@ -3599,10 +4037,19 @@ async function cancelPeakReminders() {
|
|
|
3599
4037
|
const b = bgBridge()
|
|
3600
4038
|
if (!b?.stopPeakReminder) return false
|
|
3601
4039
|
try {
|
|
3602
|
-
|
|
4040
|
+
const stopped = b.stopPeakReminder() !== false
|
|
4041
|
+
const legacyCleaned = await cancelLegacyPeakNotifications()
|
|
4042
|
+
return stopped && legacyCleaned
|
|
3603
4043
|
} catch { return false }
|
|
3604
4044
|
}
|
|
3605
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
|
+
|
|
3606
4053
|
/* ---------------- 视图切换 ---------------- */
|
|
3607
4054
|
function showView(id) {
|
|
3608
4055
|
for (const v of ['view-home', 'view-files', 'view-session', 'view-activity', 'view-stats', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
|
|
@@ -3610,7 +4057,10 @@ function showView(id) {
|
|
|
3610
4057
|
document.body.classList.toggle('in-session', id === 'view-session')
|
|
3611
4058
|
document.querySelectorAll('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === id))
|
|
3612
4059
|
window.scrollTo(0, 0)
|
|
3613
|
-
if (id === 'view-files' && !state.fs.loaded)
|
|
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
|
+
}
|
|
3614
4064
|
if (id === 'view-stats') loadStats()
|
|
3615
4065
|
if (id === 'view-settings') showSettingsHome()
|
|
3616
4066
|
}
|
|
@@ -3633,6 +4083,9 @@ function showSettingsPage(name) {
|
|
|
3633
4083
|
|
|
3634
4084
|
function updateConn() {
|
|
3635
4085
|
const el = $('conn-badge')
|
|
4086
|
+
// mux/host 的打开顺序不稳定;连接状态改变时同步重绘总览,避免后打开的
|
|
4087
|
+
// 通道只更新顶栏、总览却永久停留在 3/4。
|
|
4088
|
+
renderOverview()
|
|
3636
4089
|
const cur = state.servers.find(s => s.url === state.server)
|
|
3637
4090
|
const ms = state.serverLatency[state.server]
|
|
3638
4091
|
const curGroup = cur ? cur.group : state.activeGroup
|
|
@@ -4051,6 +4504,8 @@ function bindUi() {
|
|
|
4051
4504
|
if (newButton) {
|
|
4052
4505
|
safeRpc('session.create', { workspaceId: newButton.dataset.wbNew }, t('home.createFailed')).then(async v => {
|
|
4053
4506
|
if (!v?.sessionId) return
|
|
4507
|
+
state.workspaceFilter = newButton.dataset.wbNew
|
|
4508
|
+
LS.set('workspaceFilterV1', state.workspaceFilter)
|
|
4054
4509
|
toast(t('home.created'), 'ok')
|
|
4055
4510
|
await refreshSessions()
|
|
4056
4511
|
openSession(v.sessionId)
|
|
@@ -4097,12 +4552,41 @@ function bindUi() {
|
|
|
4097
4552
|
if (e.key === 'Escape' && !$('feedback-sheet').classList.contains('hidden')) { closeFeedbackSheet(); $('btn-feedback').focus() }
|
|
4098
4553
|
})
|
|
4099
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() })
|
|
4100
4567
|
$('btn-new-workspace').addEventListener('click', openWorkspaceModal)
|
|
4101
4568
|
$('session-sort')?.addEventListener('change', (e) => {
|
|
4102
4569
|
state.sessionSort = e.target.value === 'workspace' ? 'workspace' : 'time'
|
|
4103
4570
|
LS.set('sessionSort', state.sessionSort)
|
|
4104
4571
|
renderSessions()
|
|
4105
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() })
|
|
4106
4590
|
$('btn-cancel').addEventListener('click', cancelSession)
|
|
4107
4591
|
$('btn-send').addEventListener('click', sendMessage)
|
|
4108
4592
|
$('btn-fs-send').addEventListener('click', sendMessage)
|
|
@@ -4200,10 +4684,18 @@ function bindUi() {
|
|
|
4200
4684
|
$('modal-archive').addEventListener('click', (e) => { if (e.target === $('modal-archive')) closeArchiveConfirm() })
|
|
4201
4685
|
$('announcement-later').addEventListener('click', () => closeAnnouncement(false))
|
|
4202
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)
|
|
4203
4689
|
$('modal-announcement').addEventListener('click', (e) => {
|
|
4204
4690
|
if (e.target === $('modal-announcement') && !state.announcement?.force) closeAnnouncement(false)
|
|
4205
4691
|
})
|
|
4206
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
|
+
})
|
|
4207
4699
|
$('modal-announcement-history').addEventListener('click', (e) => {
|
|
4208
4700
|
if (e.target === $('modal-announcement-history')) closeAnnouncementHistory()
|
|
4209
4701
|
})
|
|
@@ -4243,7 +4735,7 @@ function bindUi() {
|
|
|
4243
4735
|
$('btn-update-expand').addEventListener('click', toggleUpdateExpand)
|
|
4244
4736
|
$('btn-reset').addEventListener('click', () => {
|
|
4245
4737
|
if (!confirm(t('settings.confirmReset'))) return
|
|
4246
|
-
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)
|
|
4247
4739
|
if (bgBridge()?.saveBackgroundConfig) saveBgConfig(false)
|
|
4248
4740
|
location.reload()
|
|
4249
4741
|
})
|
|
@@ -4288,8 +4780,8 @@ function bindUi() {
|
|
|
4288
4780
|
})
|
|
4289
4781
|
$('btn-test-notify').addEventListener('click', sendTestNotification)
|
|
4290
4782
|
$('btn-announcement-history').addEventListener('click', openAnnouncementHistory)
|
|
4291
|
-
//
|
|
4292
|
-
|
|
4783
|
+
// 启动时先清理旧版 LocalNotifications,再按当前开关恢复前台提醒服务。
|
|
4784
|
+
restorePeakReminders()
|
|
4293
4785
|
applyBgConfigFromNative()
|
|
4294
4786
|
$('opt-bg-poll').addEventListener('change', async (e) => {
|
|
4295
4787
|
if (e.target.checked) {
|
|
@@ -4342,6 +4834,8 @@ function bindUi() {
|
|
|
4342
4834
|
|
|
4343
4835
|
bindRail()
|
|
4344
4836
|
|
|
4837
|
+
initCustomSelects()
|
|
4838
|
+
|
|
4345
4839
|
// 向上翻历史 / 向下回最新
|
|
4346
4840
|
$('history').addEventListener('scroll', () => {
|
|
4347
4841
|
const box = $('history')
|