Initial orientator
This commit is contained in:
353
internal/server/web/app.js
Normal file
353
internal/server/web/app.js
Normal file
@@ -0,0 +1,353 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user