370 lines
11 KiB
JavaScript
370 lines
11 KiB
JavaScript
(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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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()
|
||
})()
|
||
|