Initial orientator

This commit is contained in:
2026-05-13 19:52:51 +03:00
commit 6dc5080ee9
23 changed files with 1920 additions and 0 deletions

353
internal/server/web/app.js Normal file
View 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();

View File

@@ -0,0 +1,51 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Orientator</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="dot"></span>
<span class="name">orientator</span>
</div>
<span class="version">v1</span>
</header>
<main class="container">
<section class="workspace">
<div class="intro">
<h1>Автоповорот документов</h1>
<p>PDF, изображения и архивы обрабатываются моделью постранично.</p>
</div>
<section id="drop" class="dropzone" tabindex="0">
<input id="file" type="file" multiple hidden />
<div class="dz-inner">
<div class="dz-icon"></div>
<div class="dz-title">Перетащите файлы сюда</div>
<div class="dz-sub">или <button type="button" id="pick">выберите</button></div>
</div>
</section>
<section class="controls" aria-label="Очередь обработки">
<div class="summary">
<strong id="summary-title">Файлов нет</strong>
<span id="summary-meta">pdf, png, jpg, tiff, webp, bmp, zip, 7z, rar, tar.gz</span>
</div>
<div class="actions">
<button type="button" id="process-all" class="btn" disabled>Обработать все</button>
<button type="button" id="clear" class="btn ghost" disabled>Очистить</button>
</div>
</section>
<section id="queue" class="queue" aria-live="polite"></section>
</section>
</main>
<script src="/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,309 @@
:root {
--bg: #f5f7fa;
--panel: #ffffff;
--ink: #18202a;
--muted: #667085;
--line: #d8dee8;
--soft: #eef2f7;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--accent-ink: #ffffff;
--ok: #15803d;
--warn: #b45309;
--err: #dc2626;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--ink);
-webkit-font-smoothing: antialiased;
font-size: 15px;
line-height: 1.45;
}
button, input { font: inherit; }
.topbar {
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 28px;
border-bottom: 1px solid var(--line);
background: var(--panel);
position: sticky;
top: 0;
z-index: 10;
}
.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; }
.brand .dot {
width: 12px;
height: 12px;
border-radius: 3px;
background: linear-gradient(135deg, var(--accent), #0f766e);
}
.brand .name { letter-spacing: 0; }
.version { color: var(--muted); font-size: 12px; }
.container {
width: min(1080px, 100%);
margin: 0 auto;
padding: 28px 24px 64px;
}
.workspace {
display: grid;
grid-template-columns: minmax(260px, 340px) 1fr;
grid-template-areas:
"intro controls"
"drop queue";
gap: 18px;
align-items: start;
}
.intro { grid-area: intro; }
.intro h1 {
font-size: 26px;
letter-spacing: 0;
margin: 0 0 6px;
font-weight: 700;
}
.intro p { color: var(--muted); margin: 0; max-width: 560px; }
.dropzone {
grid-area: drop;
min-height: 240px;
background: var(--panel);
border: 1.5px dashed #aab4c3;
border-radius: 8px;
padding: 28px 22px;
display: grid;
place-items: center;
text-align: center;
cursor: pointer;
transition: border-color .15s, background .15s, box-shadow .15s;
}
.dropzone:hover,
.dropzone:focus {
border-color: var(--accent);
outline: none;
box-shadow: 0 0 0 3px rgba(37, 99, 235, .12);
}
.dropzone.drag {
border-color: var(--accent);
background: #eef5ff;
}
.dz-icon {
width: 42px;
height: 42px;
margin: 0 auto 14px;
border-radius: 8px;
background: var(--accent);
position: relative;
}
.dz-icon::before,
.dz-icon::after {
content: "";
position: absolute;
background: var(--accent-ink);
border-radius: 2px;
}
.dz-icon::before { left: 19px; top: 10px; width: 4px; height: 18px; }
.dz-icon::after { left: 12px; top: 17px; width: 18px; height: 4px; }
.dz-title { font-weight: 700; font-size: 16px; margin-bottom: 4px; }
.dz-sub { color: var(--muted); font-size: 13px; }
.dz-sub button {
background: none;
border: none;
color: var(--accent);
cursor: pointer;
padding: 0;
text-decoration: underline;
text-underline-offset: 2px;
}
.controls {
grid-area: controls;
min-height: 72px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.summary { min-width: 0; display: grid; gap: 2px; }
.summary strong { font-size: 15px; }
.summary span {
color: var(--muted);
font-size: 12px;
overflow-wrap: anywhere;
}
.actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
.queue {
grid-area: queue;
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.empty {
border: 1px dashed var(--line);
border-radius: 8px;
color: var(--muted);
min-height: 150px;
display: grid;
place-items: center;
background: rgba(255, 255, 255, .55);
}
.item {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 14px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
}
.item .name {
font-weight: 650;
overflow-wrap: anywhere;
}
.item .meta {
color: var(--muted);
font-size: 12px;
margin-top: 2px;
}
.item .right {
display: flex;
gap: 8px;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
}
.item .status {
min-width: 82px;
text-align: center;
font-size: 12px;
padding: 4px 8px;
border-radius: 999px;
background: var(--soft);
color: var(--muted);
}
.item.done .status { background: #e8f7ed; color: var(--ok); }
.item.error .status { background: #fdecec; color: var(--err); }
.item.run .status { background: #fff7e6; color: var(--warn); }
.bar {
grid-column: 1 / -1;
height: 5px;
background: var(--soft);
border-radius: 999px;
overflow: hidden;
}
.bar > i {
display: block;
height: 100%;
width: 0;
background: linear-gradient(90deg, var(--accent), #0f766e);
transition: width .2s ease;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 34px;
background: var(--accent);
color: var(--accent-ink);
border: 1px solid var(--accent);
padding: 7px 12px;
border-radius: 7px;
font-size: 13px;
font-weight: 650;
cursor: pointer;
text-decoration: none;
white-space: nowrap;
}
.btn:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn.ghost {
background: transparent;
color: var(--ink);
border-color: var(--line);
}
.btn.ghost:hover { background: var(--soft); border-color: #c7d0dd; }
.btn:disabled {
opacity: .55;
cursor: not-allowed;
}
.icon-btn {
width: 34px;
height: 34px;
border: 1px solid var(--line);
border-radius: 7px;
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: 18px;
line-height: 1;
}
.icon-btn:hover { background: var(--soft); color: var(--ink); }
.report {
grid-column: 1 / -1;
font-size: 12px;
color: var(--muted);
background: #f8fafc;
border: 1px solid #e6ebf2;
padding: 10px 12px;
border-radius: 7px;
max-height: 180px;
overflow: auto;
}
.report-head {
color: var(--ink);
font-weight: 650;
margin-bottom: 6px;
}
.report pre {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
}
.error-text { color: var(--err); background: #fff7f7; border-color: #ffd9d9; }
@media (max-width: 820px) {
.topbar { padding: 0 16px; }
.container { padding: 20px 14px 48px; }
.workspace {
grid-template-columns: 1fr;
grid-template-areas:
"intro"
"drop"
"controls"
"queue";
}
.dropzone { min-height: 190px; }
.controls {
align-items: stretch;
flex-direction: column;
}
.actions { justify-content: stretch; }
.actions .btn { flex: 1; }
.item { grid-template-columns: 1fr; }
.item .right { justify-content: flex-start; }
}