dsh-remote-plugin 0.4.9 → 0.5.2
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.md +4 -4
- package/apk/dsh-remote.apk +0 -0
- package/client.js +0 -12
- package/gateway.cjs +173 -11
- package/index.mjs +4 -2
- package/package.json +1 -1
- package/public/admin.html +15 -1
- package/public/admin.js +81 -2
- package/public/app.js +500 -81
- package/public/index.html +43 -4
- package/public/jsqr.min.js +10104 -0
- package/public/qrcode.min.js +2297 -0
- package/public/sha256.js +119 -0
- package/public/styles.css +60 -6
- package/public/update.json +3 -3
- package/public/version.json +1 -1
package/public/app.js
CHANGED
|
@@ -50,7 +50,8 @@ const state = {
|
|
|
50
50
|
history: emptyHistory(),
|
|
51
51
|
errCount: 0,
|
|
52
52
|
refreshTimer: null,
|
|
53
|
-
fs: { path: null, initial: null, loaded: false, upload: null }
|
|
53
|
+
fs: { path: null, initial: null, loaded: false, upload: null },
|
|
54
|
+
models: { loaded: false, loading: false, groups: [], current: null, failures: [] }
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
const $ = (id) => document.getElementById(id)
|
|
@@ -298,6 +299,13 @@ function openStream(kind, handler, refreshOnOpen) {
|
|
|
298
299
|
state.streamsOk[kind] = true
|
|
299
300
|
state.errCount = 0
|
|
300
301
|
updateConn()
|
|
302
|
+
// mux 每次(重)连接都会重放“仍待处理”的审批/提问基线:
|
|
303
|
+
// 先清空旧列表, 避免“桌面已自定义回答, 手机漏收 question/resolved”后永久残留。
|
|
304
|
+
if (kind === 'mux') {
|
|
305
|
+
state.approvals = []
|
|
306
|
+
state.questions = []
|
|
307
|
+
renderPending()
|
|
308
|
+
}
|
|
301
309
|
if (refreshOnOpen) refreshAll()
|
|
302
310
|
}
|
|
303
311
|
ws.onmessage = (msg) => {
|
|
@@ -386,10 +394,15 @@ function onHostFrame(full) {
|
|
|
386
394
|
if (['host/session-added', 'host/session-removed', 'host/workspace-changed', 'host/workspace-removed', 'host/workspace-order-changed', 'host/archived-sessions-changed'].includes(f.type)) return scheduleRefresh()
|
|
387
395
|
if (f.type === 'host/session-status') {
|
|
388
396
|
const s = state.byId.get(f.sessionId)
|
|
389
|
-
if (s) { s.running = f.running; if (state.current === f.sessionId) { renderSessionCards(); updateCancelBtn() } }
|
|
397
|
+
if (s) { s.running = f.running; if (state.current === f.sessionId) { renderSessionCards(); updateCancelBtn(); renderSessionSub(); updateSessionStatus() } }
|
|
390
398
|
return
|
|
391
399
|
}
|
|
392
|
-
if (f.type === 'host/agent-error')
|
|
400
|
+
if (f.type === 'host/agent-error') {
|
|
401
|
+
const s = state.byId.get(f.sessionId)
|
|
402
|
+
if (s) { s.error = true; s.running = false }
|
|
403
|
+
if (state.current === f.sessionId) { renderSessionSub(); updateSessionStatus() }
|
|
404
|
+
return toast(`会话出错:${f.message}`, 'err')
|
|
405
|
+
}
|
|
393
406
|
if (f.type === 'host/remote-event') return scheduleRefresh()
|
|
394
407
|
}
|
|
395
408
|
|
|
@@ -398,8 +411,8 @@ function onSessionEvent(sessionId, event) {
|
|
|
398
411
|
const s = state.byId.get(sessionId)
|
|
399
412
|
if (s) s.updatedAt = Date.now()
|
|
400
413
|
if (event.type === 'agent/status') {
|
|
401
|
-
if (s) { s.running = !!event.data?.running; s.blank = false }
|
|
402
|
-
if (state.current === sessionId) { updateCancelBtn(); renderSessionSub() }
|
|
414
|
+
if (s) { s.running = !!event.data?.running; s.blank = false; if (s.running) s.error = false }
|
|
415
|
+
if (state.current === sessionId) { updateCancelBtn(); renderSessionSub(); updateSessionStatus() }
|
|
403
416
|
}
|
|
404
417
|
if (event.type === 'session/title' || event.type === 'title') {
|
|
405
418
|
if (event.data?.title && s) s.projections.values.title = event.data.title
|
|
@@ -417,7 +430,7 @@ function scheduleRefresh() {
|
|
|
417
430
|
|
|
418
431
|
async function refreshAll() {
|
|
419
432
|
await refreshSessions()
|
|
420
|
-
if (state.current) { renderSessionCards(); renderSessionSub(); updateCancelBtn() }
|
|
433
|
+
if (state.current) { renderSessionCards(); renderSessionSub(); updateCancelBtn(); updateSessionStatus() }
|
|
421
434
|
renderPending(); renderQueue(); renderJobs(); updateConn()
|
|
422
435
|
}
|
|
423
436
|
|
|
@@ -508,7 +521,7 @@ async function openSession(id) {
|
|
|
508
521
|
state.history = emptyHistory()
|
|
509
522
|
document.body.classList.add('in-session')
|
|
510
523
|
showView('view-session')
|
|
511
|
-
renderSessionTitle(); renderSessionSub(); updateCancelBtn()
|
|
524
|
+
renderSessionTitle(); renderSessionSub(); updateCancelBtn(); updateSessionStatus()
|
|
512
525
|
$('history').innerHTML = '<div class="empty">加载历史…</div>'
|
|
513
526
|
await loadHistory(true)
|
|
514
527
|
renderSessionCards()
|
|
@@ -519,6 +532,7 @@ function closeSession() {
|
|
|
519
532
|
state.current = null
|
|
520
533
|
state.history = emptyHistory()
|
|
521
534
|
document.body.classList.remove('in-session')
|
|
535
|
+
hideComposerMenu()
|
|
522
536
|
showView('view-home')
|
|
523
537
|
}
|
|
524
538
|
|
|
@@ -550,9 +564,21 @@ function renderSessionSub() {
|
|
|
550
564
|
const parts = [short(s.sessionId)]
|
|
551
565
|
if (s.cwd) parts.push(s.cwd)
|
|
552
566
|
if (s.running) parts.push('运行中')
|
|
567
|
+
else if (s.error) parts.push('已中断')
|
|
553
568
|
$('session-sub').textContent = parts.join(' · ')
|
|
554
569
|
}
|
|
555
570
|
|
|
571
|
+
/** 顶栏状态: 运行中=蓝色流动渐变, 中断/出错=橙红渐变, 空闲=原样式 */
|
|
572
|
+
function updateSessionStatus() {
|
|
573
|
+
const s = state.byId.get(state.current)
|
|
574
|
+
const head = $('session-head')
|
|
575
|
+
if (!head) return
|
|
576
|
+
head.classList.remove('running', 'interrupted')
|
|
577
|
+
const queued = (state.queues[state.current] || []).some(i => i.placement !== 'context')
|
|
578
|
+
if (s?.running || queued) head.classList.add('running')
|
|
579
|
+
else if (s?.error) head.classList.add('interrupted')
|
|
580
|
+
}
|
|
581
|
+
|
|
556
582
|
function updateCancelBtn() {
|
|
557
583
|
const s = state.byId.get(state.current)
|
|
558
584
|
const running = s?.running || (state.queues[state.current] || []).some(i => i.placement !== 'context')
|
|
@@ -966,20 +992,128 @@ async function interruptSubagent(childId) {
|
|
|
966
992
|
setTimeout(renderSessionCards, 600)
|
|
967
993
|
}
|
|
968
994
|
|
|
969
|
-
/* ---------------- 发送 / 取消 ---------------- */
|
|
970
|
-
async function
|
|
971
|
-
const
|
|
972
|
-
|
|
973
|
-
if (!text || !state.current) return
|
|
995
|
+
/* ---------------- 发送 / 取消 / 快捷菜单 ---------------- */
|
|
996
|
+
async function sendSessionText(text) {
|
|
997
|
+
const clean = String(text || '').trim()
|
|
998
|
+
if (!clean || !state.current) return false
|
|
974
999
|
$('btn-send').disabled = true
|
|
975
1000
|
const v = await safeRpc('session.prompt', {
|
|
976
1001
|
sessionId: state.current,
|
|
977
1002
|
mode: 'queue',
|
|
978
|
-
content: [{ type: 'text', text }]
|
|
1003
|
+
content: [{ type: 'text', text: clean }]
|
|
979
1004
|
}, '发送失败')
|
|
980
1005
|
$('btn-send').disabled = false
|
|
981
|
-
if (v?.accepted) {
|
|
982
|
-
|
|
1006
|
+
if (v?.accepted) { toast(clean.startsWith('/') ? '指令已发送' : '已发送', 'ok'); return true }
|
|
1007
|
+
if (v?.command?.text) { toast('命令已执行', 'ok'); return true }
|
|
1008
|
+
return false
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
async function sendMessage() {
|
|
1012
|
+
const input = $('composer-input')
|
|
1013
|
+
const text = input.value.trim()
|
|
1014
|
+
if (!text || !state.current) return
|
|
1015
|
+
if (await sendSessionText(text)) { input.value = ''; autosize(input) }
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function hideComposerMenu() {
|
|
1019
|
+
$('composer-menu').classList.add('hidden')
|
|
1020
|
+
$('btn-plus').classList.remove('active')
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function toggleComposerMenu() {
|
|
1024
|
+
const menu = $('composer-menu')
|
|
1025
|
+
const show = menu.classList.contains('hidden')
|
|
1026
|
+
menu.classList.toggle('hidden', !show)
|
|
1027
|
+
$('btn-plus').classList.toggle('active', show)
|
|
1028
|
+
if (show && !state.models.loaded && !state.models.loading) loadSessionModels()
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
async function loadSessionModels() {
|
|
1032
|
+
if (!state.current || state.models.loading) return
|
|
1033
|
+
state.models.loading = true
|
|
1034
|
+
renderModelMenu()
|
|
1035
|
+
try {
|
|
1036
|
+
const v = await rpc('session.models', { sessionId: state.current })
|
|
1037
|
+
state.models.groups = v.groups || []
|
|
1038
|
+
state.models.current = v.current || null
|
|
1039
|
+
state.models.failures = v.failures || []
|
|
1040
|
+
state.models.loaded = true
|
|
1041
|
+
} catch (e) {
|
|
1042
|
+
if (e.message === 'AUTH') { authFailure(); return }
|
|
1043
|
+
toast('模型列表加载失败:' + e.message, 'err')
|
|
1044
|
+
}
|
|
1045
|
+
state.models.loading = false
|
|
1046
|
+
renderModelMenu()
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function renderModelMenu() {
|
|
1050
|
+
const box = $('menu-models')
|
|
1051
|
+
if (!box) return
|
|
1052
|
+
if (state.models.loading) { box.innerHTML = '<span>模型加载中…</span>'; return }
|
|
1053
|
+
const groups = state.models.groups || []
|
|
1054
|
+
if (!groups.length) {
|
|
1055
|
+
box.innerHTML = '<span>' + ((state.models.failures || []).map(f => f.name + '不可用').join(';') || '没有可用模型') + '</span>'
|
|
1056
|
+
const effortGroup = $('menu-effort-group')
|
|
1057
|
+
if (effortGroup) effortGroup.classList.add('hidden')
|
|
1058
|
+
return
|
|
1059
|
+
}
|
|
1060
|
+
const cur = state.models.current
|
|
1061
|
+
box.innerHTML = groups.map(g => `
|
|
1062
|
+
<div style="width:100%">
|
|
1063
|
+
<div class="model-provider">${esc(g.name || g.id)}</div>
|
|
1064
|
+
<div class="menu-chips">${(g.models || []).map(m => {
|
|
1065
|
+
const isCur = cur && cur.provider === g.id && cur.model === m.id
|
|
1066
|
+
return `<button class="model-chip ${isCur ? 'current' : ''}" data-model="${esc(m.id)}" data-provider="${esc(g.id)}">${esc(m.name || m.id)}</button>`
|
|
1067
|
+
}).join('')}</div>
|
|
1068
|
+
</div>`).join('')
|
|
1069
|
+
box.querySelectorAll('[data-model]').forEach(btn =>
|
|
1070
|
+
btn.addEventListener('click', () => selectSessionModel(btn.dataset.provider, btn.dataset.model)))
|
|
1071
|
+
renderEffortMenu()
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function renderEffortMenu() {
|
|
1075
|
+
const group = $('menu-effort-group')
|
|
1076
|
+
const box = $('menu-efforts')
|
|
1077
|
+
if (!group || !box) return
|
|
1078
|
+
const cur = state.models.current
|
|
1079
|
+
const provider = (state.models.groups || []).find(g => g.id === cur?.provider)
|
|
1080
|
+
const model = (provider?.models || []).find(m => m.id === cur?.model)
|
|
1081
|
+
const efforts = model?.reasoning?.efforts || []
|
|
1082
|
+
group.classList.toggle('hidden', !efforts.length)
|
|
1083
|
+
box.innerHTML = efforts.map(e => {
|
|
1084
|
+
const isCur = cur?.reasoningEffort === e.id || (!cur?.reasoningEffort && e.id === model.reasoning.defaultEffort)
|
|
1085
|
+
return `<button class="menu-chip ${isCur ? 'current' : ''}" data-effort="${esc(e.id)}" title="${esc(e.description || '')}">${esc(e.name || e.id)}</button>`
|
|
1086
|
+
}).join('')
|
|
1087
|
+
box.querySelectorAll('[data-effort]').forEach(btn =>
|
|
1088
|
+
btn.addEventListener('click', () => selectSessionEffort(btn.dataset.effort)))
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
async function selectSessionEffort(effortId) {
|
|
1092
|
+
const cur = state.models.current
|
|
1093
|
+
if (!state.current || !cur) return
|
|
1094
|
+
const v = await safeRpc('session.selectModel', {
|
|
1095
|
+
sessionId: state.current, provider: cur.provider, model: cur.model, reasoningEffort: effortId
|
|
1096
|
+
}, '切换思考深度失败')
|
|
1097
|
+
if (v?.selected) {
|
|
1098
|
+
state.models.current = v.selected
|
|
1099
|
+
renderEffortMenu()
|
|
1100
|
+
toast(`思考深度:${effortId}`, 'ok')
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
async function selectSessionModel(provider, modelId) {
|
|
1105
|
+
if (!state.current) return
|
|
1106
|
+
const group = (state.models.groups || []).find(g => g.id === provider)
|
|
1107
|
+
const model = (group?.models || []).find(m => m.id === modelId)
|
|
1108
|
+
const payload = { sessionId: state.current, provider, model: modelId }
|
|
1109
|
+
const effort = model?.reasoning?.defaultEffort || model?.reasoning?.efforts?.[0]?.id
|
|
1110
|
+
if (effort) payload.reasoningEffort = effort
|
|
1111
|
+
const v = await safeRpc('session.selectModel', payload, '切换模型失败')
|
|
1112
|
+
if (v?.selected) {
|
|
1113
|
+
state.models.current = v.selected
|
|
1114
|
+
renderModelMenu()
|
|
1115
|
+
toast(`已切换模型:${v.selected.model}`, 'ok')
|
|
1116
|
+
}
|
|
983
1117
|
}
|
|
984
1118
|
|
|
985
1119
|
async function cancelSession() {
|
|
@@ -1219,20 +1353,113 @@ function showFsProgress(pct, loaded, total) {
|
|
|
1219
1353
|
$('fs-progress-text').textContent = `${pct}% · ${fmtSize(loaded)} / ${fmtSize(total)}`
|
|
1220
1354
|
}
|
|
1221
1355
|
|
|
1356
|
+
function setFsProgressText(text) {
|
|
1357
|
+
$('fs-progress-text').textContent = text
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1222
1360
|
function hideFsProgress() {
|
|
1223
1361
|
$('fs-progress').classList.add('hidden')
|
|
1224
1362
|
$('fs-progress-bar').style.width = '0%'
|
|
1225
1363
|
}
|
|
1226
1364
|
|
|
1365
|
+
function setFsButtons(show, paused = false) {
|
|
1366
|
+
const pauseBtn = $('fs-pause-btn')
|
|
1367
|
+
const cancelBtn = $('fs-cancel-btn')
|
|
1368
|
+
if (!pauseBtn || !cancelBtn) return
|
|
1369
|
+
pauseBtn.classList.toggle('hidden', !show)
|
|
1370
|
+
cancelBtn.classList.toggle('hidden', !show)
|
|
1371
|
+
if (show) pauseBtn.textContent = paused ? '继续' : '暂停'
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function pauseFsUpload() {
|
|
1375
|
+
const up = state.fs.upload
|
|
1376
|
+
if (!up) return
|
|
1377
|
+
if (!up.active) {
|
|
1378
|
+
if (up.paused) resumeFsUpload()
|
|
1379
|
+
return
|
|
1380
|
+
}
|
|
1381
|
+
up.pauseRequested = true
|
|
1382
|
+
try { up.xhr?.abort() } catch {}
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
function resumeFsUpload() {
|
|
1386
|
+
const up = state.fs.upload
|
|
1387
|
+
if (!up || !up.file) return
|
|
1388
|
+
up.paused = false
|
|
1389
|
+
runFsUpload(up)
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
async function cancelFsUpload() {
|
|
1393
|
+
const up = state.fs.upload
|
|
1394
|
+
if (!up) return
|
|
1395
|
+
up.cancelled = true
|
|
1396
|
+
try { up.xhr?.abort() } catch {}
|
|
1397
|
+
try {
|
|
1398
|
+
await fetch(fsApiUrl('/upload-control', { path: up.path, name: up.name, session: up.session, action: 'cancel' }), {
|
|
1399
|
+
method: 'POST', headers: fsHeaders()
|
|
1400
|
+
})
|
|
1401
|
+
} catch {}
|
|
1402
|
+
state.fs.upload = null
|
|
1403
|
+
hideFsProgress()
|
|
1404
|
+
setFsButtons(false)
|
|
1405
|
+
toast('已取消上传')
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
const FS_CHUNK_SIZE = 4 * 1024 * 1024 // 4MB/块, 断线后重选同一文件自动续传
|
|
1409
|
+
|
|
1410
|
+
/** 把文件 [start,end) 逐块喂给 hasher(续传时补齐已传前缀); 期间可被暂停打断 */
|
|
1411
|
+
async function hashFsRange(file, hasher, start, end, up) {
|
|
1412
|
+
const step = FS_CHUNK_SIZE
|
|
1413
|
+
for (let off = start; off < end; off += step) {
|
|
1414
|
+
const buf = await file.slice(off, Math.min(off + step, end)).arrayBuffer()
|
|
1415
|
+
if (up?.pauseRequested) {
|
|
1416
|
+
const err = new Error('已暂停'); err.code = 'PAUSED'; throw err
|
|
1417
|
+
}
|
|
1418
|
+
hasher.update(new Uint8Array(buf))
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1227
1422
|
function uploadFsFile(file) {
|
|
1228
1423
|
if (!file) return
|
|
1229
1424
|
if (!state.token) { toast('请先到「设置」页设置令牌', 'err'); showView('view-settings'); return }
|
|
1230
1425
|
if (file.size > 2 * 1024 * 1024 * 1024) { toast('单文件超过 2GB 上限', 'err'); return }
|
|
1426
|
+
if (state.fs.upload?.active) { toast('已有上传任务,先暂停或取消', 'err'); return }
|
|
1427
|
+
|
|
1428
|
+
const prev = state.fs.upload
|
|
1429
|
+
if (prev && prev.path === state.fs.path && prev.name === file.name && prev.size === file.size) {
|
|
1430
|
+
prev.file = file
|
|
1431
|
+
runFsUpload(prev)
|
|
1432
|
+
return
|
|
1433
|
+
}
|
|
1434
|
+
// 换传别的文件: 顺手清掉旧任务的服务端分片, 不残留隐藏 .part
|
|
1435
|
+
if (prev) {
|
|
1436
|
+
prev.cancelled = true
|
|
1437
|
+
try { prev.xhr?.abort() } catch {}
|
|
1438
|
+
try {
|
|
1439
|
+
fetch(fsApiUrl('/upload-control', { path: prev.path, name: prev.name, session: prev.session, action: 'cancel' }), {
|
|
1440
|
+
method: 'POST', headers: fsHeaders()
|
|
1441
|
+
})
|
|
1442
|
+
} catch {}
|
|
1443
|
+
}
|
|
1444
|
+
const up = {
|
|
1445
|
+
session: uuid(), path: state.fs.path, name: file.name, size: file.size,
|
|
1446
|
+
offset: 0, file, xhr: null, active: false, paused: false, cancelled: false
|
|
1447
|
+
}
|
|
1448
|
+
state.fs.upload = up
|
|
1449
|
+
runFsUpload(up)
|
|
1450
|
+
}
|
|
1231
1451
|
|
|
1232
|
-
|
|
1452
|
+
async function runFsUpload(up) {
|
|
1453
|
+
if (!up || !up.file) return
|
|
1454
|
+
up.active = true
|
|
1455
|
+
up.cancelled = false
|
|
1456
|
+
up.paused = false
|
|
1457
|
+
up.pauseRequested = false
|
|
1458
|
+
setFsButtons(true, false)
|
|
1233
1459
|
|
|
1234
|
-
const uploadChunk = (params, blob
|
|
1460
|
+
const uploadChunk = (params, blob) => new Promise((resolve, reject) => {
|
|
1235
1461
|
const xhr = new XMLHttpRequest()
|
|
1462
|
+
up.xhr = xhr
|
|
1236
1463
|
xhr.open('POST', fsApiUrl('/upload', params))
|
|
1237
1464
|
xhr.setRequestHeader('authorization', 'Bearer ' + state.token)
|
|
1238
1465
|
xhr.setRequestHeader('x-dsh-remote-client', CAP?.isNativePlatform?.() ? 'app' : 'web')
|
|
@@ -1243,17 +1470,23 @@ function uploadFsFile(file) {
|
|
|
1243
1470
|
}
|
|
1244
1471
|
}
|
|
1245
1472
|
xhr.onload = () => {
|
|
1473
|
+
if (up.xhr === xhr) up.xhr = null
|
|
1246
1474
|
let json = {}
|
|
1247
1475
|
try { json = JSON.parse(xhr.responseText || '{}') } catch {}
|
|
1248
1476
|
resolve({ status: xhr.status, json })
|
|
1249
1477
|
}
|
|
1250
|
-
xhr.onerror = () => reject(new Error('网络错误'))
|
|
1251
|
-
xhr.upload.onerror = () => reject(new Error('网络中断'))
|
|
1252
|
-
xhr.onabort = () =>
|
|
1478
|
+
xhr.onerror = () => { if (up.xhr === xhr) up.xhr = null; reject(new Error('网络错误')) }
|
|
1479
|
+
xhr.upload.onerror = () => { if (up.xhr === xhr) up.xhr = null; reject(new Error('网络中断')) }
|
|
1480
|
+
xhr.onabort = () => {
|
|
1481
|
+
if (up.xhr === xhr) up.xhr = null
|
|
1482
|
+
const err = new Error(up.cancelled ? '已取消' : '已暂停')
|
|
1483
|
+
err.code = up.cancelled ? 'CANCELLED' : 'PAUSED'
|
|
1484
|
+
reject(err)
|
|
1485
|
+
}
|
|
1253
1486
|
xhr.send(blob)
|
|
1254
1487
|
})
|
|
1255
1488
|
|
|
1256
|
-
const probe = async (
|
|
1489
|
+
const probe = async () => {
|
|
1257
1490
|
const res = await fetch(fsApiUrl('/upload-probe', { path: up.path, name: up.name, session: up.session }), { headers: fsHeaders() })
|
|
1258
1491
|
if (res.status === 401) { fsAuthError(401); return null }
|
|
1259
1492
|
const json = await res.json().catch(() => ({}))
|
|
@@ -1261,71 +1494,130 @@ function uploadFsFile(file) {
|
|
|
1261
1494
|
return json
|
|
1262
1495
|
}
|
|
1263
1496
|
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1497
|
+
let overwrite = false
|
|
1498
|
+
let wasResumed = false
|
|
1499
|
+
let hasher = new SHA256()
|
|
1500
|
+
try {
|
|
1501
|
+
const info = await probe()
|
|
1502
|
+
if (info === null || up.cancelled) return
|
|
1503
|
+
if (info.partialSize > 0) wasResumed = true
|
|
1504
|
+
if (info.targetExists && info.targetSize === up.size && !overwrite) {
|
|
1505
|
+
if (!confirm('文件已存在(大小相同),覆盖它?')) { state.fs.upload = null; hideFsProgress(); setFsButtons(false); return }
|
|
1506
|
+
overwrite = true
|
|
1269
1507
|
}
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
if (
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1508
|
+
if (up.offset > 0) await hashFsRange(up.file, hasher, 0, up.offset, up)
|
|
1509
|
+
showFsProgress(Math.round(up.offset / Math.max(1, up.size) * 100), up.offset, up.size)
|
|
1510
|
+
|
|
1511
|
+
while (up.offset < up.size) {
|
|
1512
|
+
if (up.cancelled) return
|
|
1513
|
+
const end = Math.min(up.offset + FS_CHUNK_SIZE, up.size)
|
|
1514
|
+
const blob = up.file.slice(up.offset, end)
|
|
1515
|
+
const before = hasher.clone()
|
|
1516
|
+
const chunkBytes = new Uint8Array(await blob.arrayBuffer())
|
|
1517
|
+
if (up.pauseRequested) {
|
|
1518
|
+
const err = new Error('已暂停'); err.code = 'PAUSED'; throw err
|
|
1279
1519
|
}
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
if (r.
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
toast(`已上传 ${file.name}${wasResumed ? '(断点续传完成)' : ''}`, 'ok')
|
|
1294
|
-
loadFs()
|
|
1295
|
-
return
|
|
1296
|
-
}
|
|
1297
|
-
if (r.status === 409 && r.json.error === 'conflict') {
|
|
1298
|
-
if (!confirm('文件已存在,覆盖它?')) { state.fs.upload = null; return }
|
|
1299
|
-
overwrite = true
|
|
1300
|
-
continue // 同一 offset 带 overwrite=1 重发
|
|
1520
|
+
hasher.update(chunkBytes)
|
|
1521
|
+
const isLast = end >= up.size
|
|
1522
|
+
const params = { path: up.path, name: up.name, session: up.session, offset: String(up.offset) }
|
|
1523
|
+
if (isLast) { params.finish = '1'; params.sha256 = hasher.hex() }
|
|
1524
|
+
if (overwrite) params.overwrite = '1'
|
|
1525
|
+
const r = await uploadChunk(params, blob)
|
|
1526
|
+
if (r.status === 401) { fsAuthError(401); return }
|
|
1527
|
+
if (r.status === 200 && r.json.offset != null) { up.offset = r.json.offset; continue }
|
|
1528
|
+
if (r.status === 201) {
|
|
1529
|
+
const expected = params.sha256 || hasher.hex()
|
|
1530
|
+
if (r.json.sha256 && r.json.sha256 !== expected) {
|
|
1531
|
+
const err = new Error('文件校验不一致(SHA-256)'); err.checksum = true
|
|
1532
|
+
throw err
|
|
1301
1533
|
}
|
|
1302
|
-
if (r.status === 409 && r.json.error === 'offset-mismatch') {
|
|
1303
|
-
await probe(up)
|
|
1304
|
-
continue
|
|
1305
|
-
}
|
|
1306
|
-
throw new Error(r.json.error || ('HTTP ' + r.status))
|
|
1307
|
-
}
|
|
1308
|
-
|
|
1309
|
-
// 0 字节文件: 循环不执行, 发一个空 finish 块
|
|
1310
|
-
if (up.size === 0 && up.offset === 0) {
|
|
1311
|
-
const params = { path: up.path, name: up.name, session: up.session, offset: '0', finish: '1' }
|
|
1312
|
-
if (overwrite) params.overwrite = '1'
|
|
1313
|
-
const r = await uploadChunk(params, new Blob([]), up)
|
|
1314
|
-
if (r.status === 401) { fsAuthError(401); return }
|
|
1315
|
-
if (r.status !== 201) throw new Error(r.json.error || ('HTTP ' + r.status))
|
|
1316
1534
|
hideFsProgress()
|
|
1535
|
+
setFsButtons(false)
|
|
1317
1536
|
state.fs.upload = null
|
|
1318
|
-
toast(`已上传 ${
|
|
1537
|
+
toast(`已上传 ${up.name}(SHA-256 已校验${wasResumed ? ' · 断点续传' : ''})`, 'ok')
|
|
1319
1538
|
loadFs()
|
|
1320
1539
|
return
|
|
1321
1540
|
}
|
|
1541
|
+
if (r.status === 409 && r.json.error === 'conflict') {
|
|
1542
|
+
hasher = before // 这段数据没被写入, 回退哈希状态后带 overwrite=1 重发
|
|
1543
|
+
if (!confirm('文件已存在,覆盖它?')) { state.fs.upload = null; hideFsProgress(); setFsButtons(false); return }
|
|
1544
|
+
overwrite = true
|
|
1545
|
+
continue
|
|
1546
|
+
}
|
|
1547
|
+
if (r.status === 409 && r.json.error === 'offset-mismatch') {
|
|
1548
|
+
hasher = before
|
|
1549
|
+
await probe()
|
|
1550
|
+
continue
|
|
1551
|
+
}
|
|
1552
|
+
if (r.status === 422 && r.json.error === 'checksum-mismatch') {
|
|
1553
|
+
const err = new Error('文件校验不一致(SHA-256),已清除坏分片')
|
|
1554
|
+
err.checksum = true
|
|
1555
|
+
throw err
|
|
1556
|
+
}
|
|
1557
|
+
throw new Error(r.json.error || ('HTTP ' + r.status))
|
|
1558
|
+
}
|
|
1322
1559
|
|
|
1560
|
+
// offset 已到文件末尾但还没落位(0 字节文件 / 续传时最后一块已完成而 rename 被中断):
|
|
1561
|
+
// 发一个空 finish 块完成收尾, 同时带上全量 SHA-256 校验
|
|
1562
|
+
if (up.offset >= up.size) {
|
|
1563
|
+
const expected = hasher.hex()
|
|
1564
|
+
const params = { path: up.path, name: up.name, session: up.session, offset: String(up.offset), finish: '1', sha256: expected }
|
|
1565
|
+
if (overwrite) params.overwrite = '1'
|
|
1566
|
+
const r = await uploadChunk(params, new Blob([]))
|
|
1567
|
+
if (r.status === 401) { fsAuthError(401); return }
|
|
1568
|
+
if (r.status === 409 && r.json.error === 'conflict') {
|
|
1569
|
+
if (!confirm('文件已存在,覆盖它?')) { state.fs.upload = null; hideFsProgress(); setFsButtons(false); return }
|
|
1570
|
+
params.overwrite = '1'
|
|
1571
|
+
return runFsUpload(up) // 目标冲突未写入, 重新走 probe + 空 finish
|
|
1572
|
+
}
|
|
1573
|
+
if (r.status === 422 && r.json.error === 'checksum-mismatch') {
|
|
1574
|
+
const err = new Error('文件校验不一致(SHA-256),已清除坏分片')
|
|
1575
|
+
err.checksum = true
|
|
1576
|
+
throw err
|
|
1577
|
+
}
|
|
1578
|
+
if (r.status !== 201) throw new Error(r.json.error || ('HTTP ' + r.status))
|
|
1579
|
+
if (r.json.sha256 && r.json.sha256 !== expected) {
|
|
1580
|
+
const err = new Error('文件校验不一致(SHA-256)'); err.checksum = true
|
|
1581
|
+
throw err
|
|
1582
|
+
}
|
|
1323
1583
|
hideFsProgress()
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
toast(
|
|
1584
|
+
setFsButtons(false)
|
|
1585
|
+
state.fs.upload = null
|
|
1586
|
+
toast(`已上传 ${up.name}(SHA-256 已校验${wasResumed ? ' · 断点续传' : ''})`, 'ok')
|
|
1587
|
+
loadFs()
|
|
1588
|
+
return
|
|
1327
1589
|
}
|
|
1328
|
-
|
|
1590
|
+
|
|
1591
|
+
hideFsProgress()
|
|
1592
|
+
setFsButtons(false)
|
|
1593
|
+
} catch (e) {
|
|
1594
|
+
up.active = false
|
|
1595
|
+
if (up.cancelled || e?.code === 'CANCELLED') return
|
|
1596
|
+
if (e?.code === 'PAUSED') {
|
|
1597
|
+
up.paused = true
|
|
1598
|
+
setFsButtons(true, true)
|
|
1599
|
+
setFsProgressText(`已暂停 · ${Math.round(up.offset / Math.max(1, up.size) * 100)}%`)
|
|
1600
|
+
toast('已暂停,点「继续」接着传', 'ok')
|
|
1601
|
+
return
|
|
1602
|
+
}
|
|
1603
|
+
if (e?.checksum) {
|
|
1604
|
+
// 坏分片保留只会反复校验失败: 服务端删掉, 下一次「继续」从 0 完整重传
|
|
1605
|
+
up.paused = true
|
|
1606
|
+
up.offset = 0
|
|
1607
|
+
try {
|
|
1608
|
+
await fetch(fsApiUrl('/upload-control', { path: up.path, name: up.name, session: up.session, action: 'cancel' }), {
|
|
1609
|
+
method: 'POST', headers: fsHeaders()
|
|
1610
|
+
})
|
|
1611
|
+
} catch {}
|
|
1612
|
+
setFsButtons(true, true)
|
|
1613
|
+
setFsProgressText('校验失败 · 点「继续」重新上传')
|
|
1614
|
+
toast(e.message, 'err')
|
|
1615
|
+
return
|
|
1616
|
+
}
|
|
1617
|
+
hideFsProgress()
|
|
1618
|
+
setFsButtons(false)
|
|
1619
|
+
toast(`上传中断:${e.message}(重选同一文件可断点续传)`, 'err')
|
|
1620
|
+
}
|
|
1329
1621
|
}
|
|
1330
1622
|
|
|
1331
1623
|
function fsUp() {
|
|
@@ -1388,13 +1680,30 @@ async function submitGoalEdit() {
|
|
|
1388
1680
|
}
|
|
1389
1681
|
|
|
1390
1682
|
/* ---------------- 检查更新 ---------------- */
|
|
1683
|
+
function parseVersion(v) {
|
|
1684
|
+
const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(v || '').trim())
|
|
1685
|
+
if (!m) return { core: [0, 0, 0], pre: null }
|
|
1686
|
+
return { core: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] || null }
|
|
1687
|
+
}
|
|
1391
1688
|
function cmpVersion(a, b) {
|
|
1392
|
-
const pa =
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
const d = (pa[i] || 0) - (pb[i] || 0)
|
|
1689
|
+
const pa = parseVersion(a), pb = parseVersion(b)
|
|
1690
|
+
for (let i = 0; i < 3; i++) {
|
|
1691
|
+
const d = pa.core[i] - pb.core[i]
|
|
1396
1692
|
if (d) return d
|
|
1397
1693
|
}
|
|
1694
|
+
// 无预发布后缀 = 正式版 > 任何 rc; 两个 rc 按段比较(数字段按数值, 字母段按字典序)
|
|
1695
|
+
if (!pa.pre && !pb.pre) return 0
|
|
1696
|
+
if (!pa.pre) return 1
|
|
1697
|
+
if (!pb.pre) return -1
|
|
1698
|
+
const sa = String(pa.pre).split('.'), sb = String(pb.pre).split('.')
|
|
1699
|
+
for (let i = 0; i < Math.max(sa.length, sb.length); i++) {
|
|
1700
|
+
const x = sa[i] ?? '', y = sb[i] ?? ''
|
|
1701
|
+
if (x === y) continue
|
|
1702
|
+
const nx = /^\d+$/.test(x), ny = /^\d+$/.test(y)
|
|
1703
|
+
if (nx && ny) { const d = Number(x) - Number(y); if (d) return d }
|
|
1704
|
+
else if (nx !== ny) return nx ? -1 : 1 // 数字段 < 字母段(semver 规则)
|
|
1705
|
+
else { const d = x.localeCompare(y); if (d) return d }
|
|
1706
|
+
}
|
|
1398
1707
|
return 0
|
|
1399
1708
|
}
|
|
1400
1709
|
|
|
@@ -1415,7 +1724,7 @@ async function checkUpdate(silent) {
|
|
|
1415
1724
|
}
|
|
1416
1725
|
if (!silent) toast('正在检查更新…')
|
|
1417
1726
|
try {
|
|
1418
|
-
const res = await fetch(base + '/update.json?t=' + Date.now())
|
|
1727
|
+
const res = await fetch(base + '/update.json?t=' + Date.now() + '&local=' + encodeURIComponent(state.localVersion))
|
|
1419
1728
|
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
1420
1729
|
const info = await res.json()
|
|
1421
1730
|
if (info.version && cmpVersion(info.version, state.localVersion) > 0) {
|
|
@@ -1526,6 +1835,106 @@ function autosize(el) {
|
|
|
1526
1835
|
}
|
|
1527
1836
|
|
|
1528
1837
|
/* ---------------- 初始化 ---------------- */
|
|
1838
|
+
/** 解析 dshremote://pair?token=..&server=.. 配对二维码 */
|
|
1839
|
+
function applyPairUrl(url) {
|
|
1840
|
+
try {
|
|
1841
|
+
const u = new URL(String(url).trim())
|
|
1842
|
+
if (u.protocol !== 'dshremote:' || u.hostname !== 'pair') return false
|
|
1843
|
+
const t = (u.searchParams.get('token') || '').trim()
|
|
1844
|
+
const server = (u.searchParams.get('server') || '').trim().replace(/\/+$/, '')
|
|
1845
|
+
if (!t || !/^https?:\/\//i.test(server)) return false
|
|
1846
|
+
state.token = t
|
|
1847
|
+
LS.set('token', t)
|
|
1848
|
+
state.server = server
|
|
1849
|
+
if (!state.servers.includes(server)) state.servers.unshift(server)
|
|
1850
|
+
saveServers()
|
|
1851
|
+
renderServers()
|
|
1852
|
+
$('token-desc').textContent = '已保存(扫码)'
|
|
1853
|
+
return true
|
|
1854
|
+
} catch {
|
|
1855
|
+
return false
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
/** 拍照/相册得到的 dataUrl → 画到 canvas → jsQR 纯本地解码(不依赖任何谷歌服务) */
|
|
1860
|
+
async function decodeQrDataUrl(dataUrl) {
|
|
1861
|
+
const img = new Image()
|
|
1862
|
+
await new Promise((resolve, reject) => {
|
|
1863
|
+
img.onload = resolve
|
|
1864
|
+
img.onerror = () => reject(new Error('图片加载失败'))
|
|
1865
|
+
img.src = dataUrl
|
|
1866
|
+
})
|
|
1867
|
+
const maxSide = 1600
|
|
1868
|
+
const scale = Math.min(1, maxSide / Math.max(img.naturalWidth || 1, img.naturalHeight || 1))
|
|
1869
|
+
const w = Math.max(1, Math.round((img.naturalWidth || 1) * scale))
|
|
1870
|
+
const h = Math.max(1, Math.round((img.naturalHeight || 1) * scale))
|
|
1871
|
+
const canvas = document.createElement('canvas')
|
|
1872
|
+
canvas.width = w
|
|
1873
|
+
canvas.height = h
|
|
1874
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true })
|
|
1875
|
+
if (!ctx) throw new Error('当前设备不支持图片解码')
|
|
1876
|
+
ctx.drawImage(img, 0, 0, w, h)
|
|
1877
|
+
const imageData = ctx.getImageData(0, 0, w, h)
|
|
1878
|
+
const code = window.jsQR?.(imageData.data, w, h, { inversionAttempts: 'attemptBoth' })
|
|
1879
|
+
return code?.data || ''
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
/** App 内扫码: 官方 Camera 拍照/相册 + jsQR 本地解码(无 Google ML Kit/GMS 依赖, 国内可用)。
|
|
1883
|
+
* 冗余路径 1: 系统相机扫 dshremote:// 二维码直接唤起 App(见 bindNativeLinks);
|
|
1884
|
+
* 冗余路径 2: 设置页手动粘贴令牌。 */
|
|
1885
|
+
async function scanPair() {
|
|
1886
|
+
if (!CAP?.isNativePlatform?.()) {
|
|
1887
|
+
toast('浏览器请打开主机管理页,用手机相机扫码', 'err')
|
|
1888
|
+
return
|
|
1889
|
+
}
|
|
1890
|
+
const camera = CAP.Plugins?.Camera
|
|
1891
|
+
if (!camera?.getPhoto) { toast('当前 App 版本不支持拍照扫码,请先更新 App', 'err'); return }
|
|
1892
|
+
try {
|
|
1893
|
+
const perm = await camera.requestPermissions?.({ permissions: ['camera'] })
|
|
1894
|
+
if (perm && perm.camera !== 'granted') { toast('未授予相机权限', 'err'); return }
|
|
1895
|
+
const photo = await camera.getPhoto({
|
|
1896
|
+
resultType: 'dataUrl',
|
|
1897
|
+
source: 'PROMPT', // 拍照 / 从相册选择都行, 相册可扫截图
|
|
1898
|
+
quality: 85,
|
|
1899
|
+
correctOrientation: true,
|
|
1900
|
+
saveToGallery: false,
|
|
1901
|
+
promptLabelHeader: '扫描 DSH Remote 配对二维码',
|
|
1902
|
+
promptLabelPhoto: '拍照扫描',
|
|
1903
|
+
promptLabelPicture: '从相册选择',
|
|
1904
|
+
})
|
|
1905
|
+
if (!photo?.dataUrl) { toast('没有拿到图片,请重试', 'err'); return }
|
|
1906
|
+
const raw = await decodeQrDataUrl(photo.dataUrl)
|
|
1907
|
+
if (!raw) { toast('未识别到二维码,请对准后重试', 'err'); return }
|
|
1908
|
+
if (applyPairUrl(raw)) {
|
|
1909
|
+
toast('配对成功,正在连接', 'ok')
|
|
1910
|
+
openStreams()
|
|
1911
|
+
refreshAll()
|
|
1912
|
+
} else {
|
|
1913
|
+
toast('这不是 DSH Remote 的配对二维码', 'err')
|
|
1914
|
+
}
|
|
1915
|
+
} catch (e) {
|
|
1916
|
+
const msg = String(e?.message || e || '')
|
|
1917
|
+
toast(/cancel/i.test(msg) ? '已取消扫码' : '扫码失败:' + msg, 'err')
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
/** 系统相机/浏览器扫码后通过 dshremote:// 链接唤起 App: 这里接住并配对 */
|
|
1922
|
+
function bindNativeLinks() {
|
|
1923
|
+
if (!CAP?.isNativePlatform?.()) return
|
|
1924
|
+
try {
|
|
1925
|
+
CAP.Plugins?.App?.addListener?.('appUrlOpen', (data) => {
|
|
1926
|
+
if (data?.url && applyPairUrl(data.url)) {
|
|
1927
|
+
toast('已通过二维码配对', 'ok')
|
|
1928
|
+
openStreams()
|
|
1929
|
+
refreshAll()
|
|
1930
|
+
}
|
|
1931
|
+
})
|
|
1932
|
+
CAP.Plugins?.App?.getLaunchUrl?.().then((data) => {
|
|
1933
|
+
if (data?.url) applyPairUrl(data.url)
|
|
1934
|
+
}).catch(() => {})
|
|
1935
|
+
} catch {}
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1529
1938
|
function initToken() {
|
|
1530
1939
|
const urlToken = new URLSearchParams(location.search).get('token')
|
|
1531
1940
|
if (urlToken) {
|
|
@@ -1553,13 +1962,19 @@ function bindUi() {
|
|
|
1553
1962
|
$('btn-back').addEventListener('click', closeSession)
|
|
1554
1963
|
$('btn-stats').addEventListener('click', () => { renderSessionCards(); $('modal-stats').classList.remove('hidden') })
|
|
1555
1964
|
$('stats-close').addEventListener('click', () => $('modal-stats').classList.add('hidden'))
|
|
1556
|
-
$('btn-refresh').addEventListener('click', () => { toast('刷新中…'); refreshAll() })
|
|
1965
|
+
$('btn-refresh').addEventListener('click', () => { toast('刷新中…'); openStreams(); refreshAll() })
|
|
1557
1966
|
$('btn-admin').addEventListener('click', () => {
|
|
1558
1967
|
location.href = state.server ? state.server.replace(/\/+$/, '') + '/admin' : 'admin'
|
|
1559
1968
|
})
|
|
1560
1969
|
$('btn-new-session').addEventListener('click', newSession)
|
|
1561
1970
|
$('btn-cancel').addEventListener('click', cancelSession)
|
|
1562
1971
|
$('btn-send').addEventListener('click', sendMessage)
|
|
1972
|
+
$('btn-plus').addEventListener('click', toggleComposerMenu)
|
|
1973
|
+
$('composer-menu').addEventListener('click', (e) => {
|
|
1974
|
+
const chip = e.target.closest('[data-cmd]')
|
|
1975
|
+
if (chip) { hideComposerMenu(); sendSessionText(chip.dataset.cmd) }
|
|
1976
|
+
})
|
|
1977
|
+
$('btn-model-refresh').addEventListener('click', loadSessionModels)
|
|
1563
1978
|
const input = $('composer-input')
|
|
1564
1979
|
input.addEventListener('input', () => autosize(input))
|
|
1565
1980
|
input.addEventListener('keydown', (e) => {
|
|
@@ -1582,6 +1997,7 @@ function bindUi() {
|
|
|
1582
1997
|
$('goal-close').addEventListener('click', () => $('modal-goal').classList.add('hidden'))
|
|
1583
1998
|
$('goal-edit').addEventListener('click', submitGoalEdit)
|
|
1584
1999
|
// 设置
|
|
2000
|
+
$('btn-scan-pair').addEventListener('click', scanPair)
|
|
1585
2001
|
$('btn-change-token').addEventListener('click', () => {
|
|
1586
2002
|
const t = prompt('输入访问令牌(网关启动时打印的 token):', state.token)
|
|
1587
2003
|
if (t && t.trim()) { state.token = t.trim(); LS.set('token', t.trim()); $('token-desc').textContent = '已保存'; toast('已保存,正在重连', 'ok'); openStreams(); refreshAll() }
|
|
@@ -1629,6 +2045,8 @@ function bindUi() {
|
|
|
1629
2045
|
if (f) uploadFsFile(f)
|
|
1630
2046
|
e.target.value = '' // 允许连续选同一个文件
|
|
1631
2047
|
})
|
|
2048
|
+
$('fs-pause-btn').addEventListener('click', pauseFsUpload)
|
|
2049
|
+
$('fs-cancel-btn').addEventListener('click', cancelFsUpload)
|
|
1632
2050
|
bindFsPullRefresh()
|
|
1633
2051
|
|
|
1634
2052
|
bindRail()
|
|
@@ -1671,6 +2089,7 @@ async function boot() {
|
|
|
1671
2089
|
initToken()
|
|
1672
2090
|
bindUi()
|
|
1673
2091
|
bindNativeBack()
|
|
2092
|
+
bindNativeLinks()
|
|
1674
2093
|
applyNativeInsets()
|
|
1675
2094
|
updateConn()
|
|
1676
2095
|
loadLocalVersion()
|