qr-scanner/static/app.js

370 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(function () {
const el = {
statusText: document.getElementById('statusText'),
viewUpload: document.getElementById('viewUpload'),
viewProgress: document.getElementById('viewProgress'),
viewResult: document.getElementById('viewResult'),
dropzone: document.getElementById('dropzone'),
fileInput: document.getElementById('fileInput'),
fileList: document.getElementById('fileList'),
btnClear: document.getElementById('btnClear'),
btnStart: document.getElementById('btnStart'),
concurrencyInput: document.getElementById('concurrencyInput'),
timeoutInput: document.getElementById('timeoutInput'),
progressPercent: document.getElementById('progressPercent'),
progressBar: document.getElementById('progressBar'),
statTotal: document.getElementById('statTotal'),
statProcessed: document.getElementById('statProcessed'),
statSuccess: document.getElementById('statSuccess'),
statFailed: document.getElementById('statFailed'),
currentFile: document.getElementById('currentFile'),
speed: document.getElementById('speed'),
remaining: document.getElementById('remaining'),
btnCancel: document.getElementById('btnCancel'),
resultTitle: document.getElementById('resultTitle'),
taskMeta: document.getElementById('taskMeta'),
resultSuccess: document.getElementById('resultSuccess'),
resultFailed: document.getElementById('resultFailed'),
btnDownloadExcel: document.getElementById('btnDownloadExcel'),
btnBack: document.getElementById('btnBack'),
failuresWrap: document.getElementById('failuresWrap'),
failuresBody: document.getElementById('failuresBody'),
errorAlert: document.getElementById('errorAlert'),
}
const state = {
files: [],
taskID: null,
sse: null,
lastProgress: null,
status: 'idle',
}
function setStatus(text, kind) {
el.statusText.textContent = text
if (!kind) return
}
function showError(msg) {
el.errorAlert.textContent = msg
el.errorAlert.classList.remove('d-none')
}
function clearError() {
el.errorAlert.classList.add('d-none')
el.errorAlert.textContent = ''
}
function setView(name) {
el.viewUpload.classList.toggle('d-none', name !== 'upload')
el.viewProgress.classList.toggle('d-none', name !== 'progress')
el.viewResult.classList.toggle('d-none', name !== 'result')
}
function renderFileList() {
if (!state.files.length) {
el.fileList.classList.add('text-muted')
el.fileList.textContent = '暂无文件'
el.btnStart.disabled = true
return
}
el.fileList.classList.remove('text-muted')
el.fileList.innerHTML = state.files.map((f, idx) => {
const size = formatBytes(f.size)
return `
<div class="file-item">
<div class="file-name">${escapeHtml(f.name)}</div>
<div class="d-flex align-items-center gap-2">
<div class="text-muted small">${size}</div>
<button class="btn btn-sm btn-outline-danger" data-remove="${idx}">移除</button>
</div>
</div>
`
}).join('')
el.btnStart.disabled = false
}
function bindFileListActions() {
el.fileList.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-remove]')
if (!btn) return
const idx = parseInt(btn.getAttribute('data-remove'), 10)
if (Number.isNaN(idx)) return
state.files.splice(idx, 1)
renderFileList()
})
}
function setProgress(p) {
const total = p.total || 0
const processed = p.processed || 0
const percent = total > 0 ? Math.min(100, Math.floor((processed / total) * 100)) : 0
el.progressPercent.textContent = percent + '%'
el.progressBar.style.width = percent + '%'
el.statTotal.textContent = String(total)
el.statProcessed.textContent = String(processed)
el.statSuccess.textContent = String(p.success || 0)
el.statFailed.textContent = String(p.failed || 0)
el.currentFile.textContent = p.current || '-'
el.speed.textContent = (typeof p.speed === 'number' ? p.speed.toFixed(1) : '-')
el.remaining.textContent = p.remaining || '-'
}
async function apiUpload(files) {
const fd = new FormData()
for (const f of files) fd.append('files', f)
const res = await fetch('/api/upload', { method: 'POST', body: fd })
const body = await safeJson(res)
if (!res.ok || !body || body.code !== 200) {
throw new Error((body && body.message) || '上传失败')
}
return body.data
}
async function apiScan(taskID, concurrency, timeout) {
const res = await fetch('/api/scan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ taskID, concurrency, timeout }),
})
const body = await safeJson(res)
if (!res.ok || !body || body.code !== 200) {
throw new Error((body && body.message) || '启动处理失败')
}
return body.data
}
async function apiResults(taskID) {
const res = await fetch('/api/results/' + encodeURIComponent(taskID))
const body = await safeJson(res)
if (!res.ok || !body || body.code !== 200) {
throw new Error((body && body.message) || '获取结果失败')
}
return body.data
}
async function apiCancel(taskID) {
const res = await fetch('/api/cancel/' + encodeURIComponent(taskID), { method: 'POST' })
const body = await safeJson(res)
if (!res.ok || !body || body.code !== 200) {
throw new Error((body && body.message) || '取消失败')
}
return body.data
}
function openSSE(taskID) {
closeSSE()
const url = '/api/progress/' + encodeURIComponent(taskID) + '/stream'
const es = new EventSource(url)
state.sse = es
es.onmessage = (ev) => {
try {
const p = JSON.parse(ev.data)
state.lastProgress = p
setProgress(p)
if (p.status === 'completed' || p.status === 'canceled' || p.status === 'failed') {
closeSSE()
void loadResultsAndShow()
}
} catch (_) {}
}
es.onerror = () => {
closeSSE()
showError('进度连接中断SSE')
setStatus('出错')
}
}
function closeSSE() {
if (state.sse) {
state.sse.close()
state.sse = null
}
}
async function loadResultsAndShow() {
try {
setStatus('整理结果')
const data = await apiResults(state.taskID)
renderResults(data)
setView('result')
clearError()
if (data.status === 'canceled') {
el.resultTitle.textContent = '已取消'
} else if (data.status === 'failed') {
el.resultTitle.textContent = '任务失败'
} else {
el.resultTitle.textContent = '处理完成!'
}
setStatus('就绪')
} catch (e) {
showError(e.message || '获取结果失败')
}
}
function renderResults(data) {
el.taskMeta.textContent = data.taskID || '-'
const items = Array.isArray(data.items) ? data.items : []
let success = 0
let failed = 0
const failures = []
for (const it of items) {
if (it.success) {
success++
} else {
failed++
failures.push(it)
}
}
el.resultSuccess.textContent = String(success)
el.resultFailed.textContent = String(failed)
if (!failures.length) {
el.failuresWrap.classList.add('d-none')
el.failuresBody.innerHTML = ''
return
}
el.failuresWrap.classList.remove('d-none')
el.failuresBody.innerHTML = failures.map((it, idx) => {
return `
<tr>
<td>${idx + 1}</td>
<td class="text-break">${escapeHtml(it.file_path || '')}</td>
<td class="text-break">${escapeHtml(it.error_message || '')}</td>
</tr>
`
}).join('')
}
function resetAll() {
closeSSE()
state.files = []
state.taskID = null
state.lastProgress = null
state.status = 'idle'
el.fileInput.value = ''
renderFileList()
clearError()
setProgress({ total: 0, processed: 0, success: 0, failed: 0, speed: 0, remaining: '' })
setView('upload')
setStatus('就绪')
}
function setupDropzone() {
el.dropzone.addEventListener('click', () => el.fileInput.click())
el.dropzone.addEventListener('dragover', (e) => {
e.preventDefault()
el.dropzone.classList.add('dragover')
})
el.dropzone.addEventListener('dragleave', () => el.dropzone.classList.remove('dragover'))
el.dropzone.addEventListener('drop', (e) => {
e.preventDefault()
el.dropzone.classList.remove('dragover')
const files = Array.from(e.dataTransfer.files || [])
if (!files.length) return
state.files.push(...files)
renderFileList()
})
}
el.fileInput.addEventListener('change', () => {
const files = Array.from(el.fileInput.files || [])
state.files.push(...files)
renderFileList()
})
el.btnClear.addEventListener('click', () => {
state.files = []
el.fileInput.value = ''
renderFileList()
})
el.btnStart.addEventListener('click', async () => {
clearError()
if (!state.files.length) return
try {
setStatus('上传中')
el.btnStart.disabled = true
const upload = await apiUpload(state.files)
state.taskID = upload.taskID
const concurrency = clampInt(parseInt(el.concurrencyInput.value, 10), 1, 32, 4)
const timeout = clampInt(parseInt(el.timeoutInput.value, 10), 1, 60, 30)
setStatus('启动处理中')
await apiScan(state.taskID, concurrency, timeout)
setView('progress')
setStatus('处理中')
openSSE(state.taskID)
} catch (e) {
showError(e.message || '处理失败')
setStatus('出错')
el.btnStart.disabled = false
}
})
el.btnCancel.addEventListener('click', async () => {
clearError()
if (!state.taskID) return
try {
setStatus('取消中')
await apiCancel(state.taskID)
closeSSE()
await loadResultsAndShow()
} catch (e) {
showError(e.message || '取消失败')
}
})
el.btnDownloadExcel.addEventListener('click', () => {
if (!state.taskID) return
window.location.href = '/api/export/' + encodeURIComponent(state.taskID)
})
el.btnBack.addEventListener('click', () => resetAll())
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
}
function formatBytes(bytes) {
if (!bytes || bytes < 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
let n = bytes
let i = 0
while (n >= 1024 && i < units.length - 1) {
n /= 1024
i++
}
return n.toFixed(i === 0 ? 0 : 1) + ' ' + units[i]
}
async function safeJson(res) {
try {
return await res.json()
} catch (_) {
return null
}
}
function clampInt(v, min, max, fallback) {
if (!Number.isFinite(v)) return fallback
if (v < min) return min
if (v > max) return max
return v
}
bindFileListActions()
setupDropzone()
resetAll()
})()