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