(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 `
${escapeHtml(f.name)}
${size}
` }).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() })()