Files
2026-05-13 19:54:18 +03:00

354 lines
11 KiB
JavaScript
Raw Permalink 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.
const drop = document.getElementById('drop');
const fileInput = document.getElementById('file');
const pickBtn = document.getElementById('pick');
const queue = document.getElementById('queue');
const processAllBtn = document.getElementById('process-all');
const clearBtn = document.getElementById('clear');
const summaryTitle = document.getElementById('summary-title');
const summaryMeta = document.getElementById('summary-meta');
const jobs = [];
let nextId = 1;
let activeXhr = null;
['dragenter', 'dragover'].forEach(ev =>
drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.add('drag'); })
);
['dragleave', 'drop'].forEach(ev =>
drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.remove('drag'); })
);
drop.addEventListener('drop', e => addFiles(e.dataTransfer.files));
drop.addEventListener('click', () => fileInput.click());
drop.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
fileInput.click();
}
});
pickBtn.addEventListener('click', e => { e.stopPropagation(); fileInput.click(); });
fileInput.addEventListener('change', () => {
addFiles(fileInput.files);
fileInput.value = '';
});
processAllBtn.addEventListener('click', processPending);
clearBtn.addEventListener('click', clearQueue);
function addFiles(files) {
for (const file of files) {
jobs.unshift({
id: nextId++,
file,
state: 'ready',
status: 'в очереди',
progress: 0,
reports: [],
error: '',
download: null,
outputName: ''
});
}
render();
}
function clearQueue() {
if (activeXhr) activeXhr.abort();
activeXhr = null;
for (const job of jobs) {
if (job.download) URL.revokeObjectURL(job.download);
}
jobs.splice(0, jobs.length);
render();
}
function processPending() {
const selected = jobs.filter(j => j.state === 'ready' || j.state === 'error');
if (!selected.length || activeXhr) return;
processJobs(selected);
}
function retryJob(id) {
const job = jobs.find(j => j.id === id);
if (!job || activeXhr) return;
processJobs([job]);
}
function removeJob(id) {
const idx = jobs.findIndex(j => j.id === id);
if (idx < 0 || activeXhr) return;
const [job] = jobs.splice(idx, 1);
if (job.download) URL.revokeObjectURL(job.download);
render();
}
function processJobs(selected) {
selected.forEach(job => {
job.state = 'run';
job.status = 'загрузка';
job.progress = 4;
job.error = '';
job.reports = [];
if (job.download) URL.revokeObjectURL(job.download);
job.download = null;
job.outputName = '';
});
render();
const fd = new FormData();
selected.forEach(job => fd.append('files', job.file));
const xhr = new XMLHttpRequest();
activeXhr = xhr;
xhr.open('POST', '/api/process');
xhr.responseType = 'blob';
xhr.upload.onprogress = e => {
const pct = e.lengthComputable ? Math.min(70, Math.round(e.loaded / e.total * 70)) : 35;
selected.forEach(job => {
job.progress = pct;
job.status = pct >= 70 ? 'обработка' : 'загрузка';
});
render();
};
xhr.onprogress = () => {
selected.forEach(job => {
job.progress = Math.max(job.progress, 85);
job.status = 'получение';
});
render();
};
xhr.onload = () => {
activeXhr = null;
if (xhr.status >= 200 && xhr.status < 300) {
handleSuccess(xhr, selected);
} else {
handleFailure(xhr, selected);
}
render();
};
xhr.onerror = () => {
activeXhr = null;
selected.forEach(job => markError(job, 'Сеть или сервер оборвали запрос'));
render();
};
xhr.onabort = () => {
selected.forEach(job => markError(job, 'Обработка отменена'));
render();
};
xhr.send(fd);
}
function handleSuccess(xhr, selected) {
const blob = xhr.response;
const cd = xhr.getResponseHeader('Content-Disposition') || '';
const outputName = filenameFromContentDisposition(cd) || (selected.length > 1 ? 'orientator-output.zip' : 'output');
const reports = parseReports(xhr.getResponseHeader('X-Orientator-Reports'));
if (selected.length === 1) {
const job = selected[0];
job.state = 'done';
job.status = 'готово';
job.progress = 100;
job.outputName = outputName;
job.download = URL.createObjectURL(blob);
job.reports = reports;
return;
}
const batchJob = {
id: nextId++,
file: new File([blob], outputName),
kind: 'batch',
sourceCount: selected.length,
state: 'done',
status: 'готов к скачиванию',
progress: 100,
reports,
error: '',
download: URL.createObjectURL(blob),
outputName
};
jobs.unshift(batchJob);
selected.forEach(job => {
const jobReports = reports.filter(r => r.file === job.file.name);
job.state = 'done';
job.status = jobReports.length ? 'добавлено в архив' : 'см. архив';
job.progress = 100;
job.reports = jobReports;
job.error = jobReports.length ? '' : 'По этому файлу нет отчета. Если формат не поддерживается, в итоговом zip будет файл с ошибкой .error.txt.';
});
}
function handleFailure(xhr, selected) {
if (!xhr.response) {
selected.forEach(job => markError(job, 'Сервер вернул ошибку без текста'));
return;
}
const reader = new FileReader();
reader.onload = () => {
let msg = reader.result || 'Ошибка обработки';
try {
msg = JSON.parse(reader.result).error || msg;
} catch {}
selected.forEach(job => markError(job, msg));
render();
};
reader.readAsText(xhr.response);
}
function markError(job, msg) {
job.state = 'error';
job.status = 'ошибка';
job.progress = 100;
job.error = msg;
}
function render() {
updateSummary();
queue.innerHTML = '';
if (!jobs.length) {
queue.innerHTML = '<div class="empty">Добавьте документы для обработки.</div>';
return;
}
for (const job of jobs) {
queue.appendChild(renderJob(job));
}
}
function renderJob(job) {
const item = document.createElement('article');
item.className = `item ${job.state}`;
const main = document.createElement('div');
const name = document.createElement('div');
name.className = 'name';
name.textContent = job.kind === 'batch'
? `Итоговый архив: ${job.outputName || job.file.name}`
: (job.outputName || job.file.name);
const meta = document.createElement('div');
meta.className = 'meta';
meta.textContent = job.kind === 'batch'
? `Скачать один zip с результатами по ${job.sourceCount || 0} файл(ам) · ${fmtBytes(job.file.size)}`
: [fmtBytes(job.file.size), fileKind(job.file.name)].filter(Boolean).join(' · ');
main.append(name, meta);
const right = document.createElement('div');
right.className = 'right';
if (job.download) {
const a = document.createElement('a');
a.href = job.download;
a.textContent = 'Скачать';
a.download = job.outputName || job.file.name;
a.className = 'btn';
right.appendChild(a);
}
if (job.kind !== 'batch' && (job.state === 'ready' || job.state === 'error' || job.state === 'done')) {
right.appendChild(button('Обработать заново', 'btn ghost', () => retryJob(job.id), Boolean(activeXhr)));
}
if (job.state !== 'run') {
right.appendChild(button('Убрать', 'icon-btn', () => removeJob(job.id), Boolean(activeXhr), 'x'));
}
const status = document.createElement('span');
status.className = 'status';
status.textContent = job.status;
right.appendChild(status);
const bar = document.createElement('div');
bar.className = 'bar';
const fill = document.createElement('i');
fill.style.width = `${job.progress}%`;
bar.appendChild(fill);
item.append(main, right, bar);
if (job.error) {
const err = document.createElement('div');
err.className = 'report error-text';
err.textContent = job.error;
item.appendChild(err);
} else if (job.reports.length) {
item.appendChild(renderReport(job.reports));
}
return item;
}
function renderReport(reports) {
const box = document.createElement('div');
box.className = 'report';
const changed = reports.filter(r => r.angle && r.angle !== 0).length;
const avg = reports.reduce((sum, r) => sum + (r.confidence || 0), 0) / reports.length;
const files = new Set(reports.map(r => r.file));
const head = document.createElement('div');
head.className = 'report-head';
head.textContent = `${reports.length} стр. · повернуто ${changed} · средняя уверенность ${(avg * 100).toFixed(1)}%`;
const list = document.createElement('pre');
list.textContent = reports.map(r => {
const page = r.page ? `стр. ${r.page}` : 'файл';
return `${page}: ${r.angle}° · ${(r.confidence * 100).toFixed(1)}%`;
}).map((line, i) => {
const r = reports[i];
return files.size > 1 ? `${r.file} · ${line}` : line;
}).join('\n');
box.append(head, list);
return box;
}
function updateSummary() {
const total = jobs.length;
const ready = jobs.filter(j => j.state === 'ready' || j.state === 'error').length;
const done = jobs.filter(j => j.state === 'done').length;
const running = jobs.some(j => j.state === 'run');
const bytes = jobs.reduce((sum, j) => sum + j.file.size, 0);
summaryTitle.textContent = total ? `${total} файл(ов) · ${fmtBytes(bytes)}` : 'Файлов нет';
summaryMeta.textContent = total ? `${ready} ожидает · ${done} готово` : 'pdf, png, jpg, tiff, webp, bmp, zip, 7z, rar, tar.gz';
processAllBtn.disabled = !ready || running;
clearBtn.disabled = !total;
processAllBtn.textContent = running ? 'Идет обработка' : (ready > 1 ? 'Обработать все в zip' : 'Обработать');
}
function button(text, className, onClick, disabled, title) {
const b = document.createElement('button');
b.type = 'button';
b.className = className;
b.textContent = title || text;
b.title = text;
b.disabled = disabled;
b.addEventListener('click', onClick);
return b;
}
function fmtBytes(n) {
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB';
return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB';
}
function fileKind(name) {
const lower = name.toLowerCase();
if (lower.endsWith('.tar.gz')) return 'tar.gz';
const i = lower.lastIndexOf('.');
return i >= 0 ? lower.slice(i + 1) : '';
}
function parseReports(header) {
if (!header) return [];
try {
return JSON.parse(decodeURIComponent(header.replace(/\+/g, ' ')));
} catch {
return [];
}
}
function filenameFromContentDisposition(cd) {
const encoded = cd.match(/filename\*=UTF-8''([^;]+)/i);
if (encoded) {
try {
return decodeURIComponent(encoded[1]);
} catch {}
}
const plain = cd.match(/filename="([^"]+)"|filename=([^;]+)/i);
return plain ? (plain[1] || plain[2]).trim() : '';
}
render();