(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 `
`
}).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 `
| ${idx + 1} |
${escapeHtml(it.file_path || '')} |
${escapeHtml(it.error_message || '')} |
`
}).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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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()
})()