feat: Add .env.example configuration file and integrate Redis service into Docker Compose for improved task management. Update desktop application to support document indexing and extraction workflows, enhancing user experience with a new extraction wizard and stepper components. Revise README and documentation for clarity on setup and usage.
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
const MAX_SYNC_FILES = 50;␍
|
||||
const MAX_ASYNC_FILES = 500;␍
|
||||
const JOB_CHUNK_SIZE = 500;␍
|
||||
const LONG_POLL_MS = 30000;␍
|
||||
const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled"]);␍
|
||||
␍
|
||||
function $(id) {␍
|
||||
return document.getElementById(id);␍
|
||||
}␍
|
||||
␍
|
||||
function getBaseUrl() {␍
|
||||
const input = $("baseUrl").value.trim();␍
|
||||
return (input || window.location.origin).replace(/\/$/, "");␍
|
||||
}␍
|
||||
␍
|
||||
function getIncludeRejected() {␍
|
||||
return $("includeRejected").checked;␍
|
||||
}␍
|
||||
␍
|
||||
function getSelectedFiles(inputId) {␍
|
||||
const input = $(inputId);␍
|
||||
return Array.from(input.files || []);␍
|
||||
}␍
|
||||
␍
|
||||
function validateFiles(files, maxFiles) {␍
|
||||
if (files.length === 0) {␍
|
||||
throw new Error("Selecciona al menos un archivo PDF.");␍
|
||||
}␍
|
||||
if (files.length > maxFiles) {␍
|
||||
throw new Error(`Máximo ${maxFiles} archivos por petición.`);␍
|
||||
}␍
|
||||
for (const file of files) {␍
|
||||
if (!file.name.toLowerCase().endsWith(".pdf")) {␍
|
||||
throw new Error(`"${file.name}" no es un PDF.`);␍
|
||||
}␍
|
||||
}␍
|
||||
}␍
|
||||
␍
|
||||
async function parseError(res) {␍
|
||||
try {␍
|
||||
const data = await res.json();␍
|
||||
if (data && data.error) return data.error;␍
|
||||
} catch (_) {␍
|
||||
/* ignore */␍
|
||||
}␍
|
||||
return `Error HTTP ${res.status}`;␍
|
||||
}␍
|
||||
␍
|
||||
function buildMultipart(files) {␍
|
||||
const form = new FormData();␍
|
||||
for (const file of files) {␍
|
||||
form.append("files", file, file.name);␍
|
||||
}␍
|
||||
return form;␍
|
||||
}␍
|
||||
␍
|
||||
function setLoading(button, loading, label) {␍
|
||||
button.disabled = loading;␍
|
||||
button.textContent = loading ? "Procesando…" : label;␍
|
||||
}␍
|
||||
␍
|
||||
function showError(containerId, message) {␍
|
||||
const el = $(containerId);␍
|
||||
el.hidden = false;␍
|
||||
el.textContent = message;␍
|
||||
}␍
|
||||
␍
|
||||
function hideError(containerId) {␍
|
||||
const el = $(containerId);␍
|
||||
el.hidden = true;␍
|
||||
el.textContent = "";␍
|
||||
}␍
|
||||
␍
|
||||
function formatProbability(value) {␍
|
||||
if (typeof value !== "number") return "—";␍
|
||||
return (value * 100).toFixed(1) + "%";␍
|
||||
}␍
|
||||
␍
|
||||
function renderResults(container, results) {␍
|
||||
container.innerHTML = "";␍
|
||||
␍
|
||||
if (!results || results.length === 0) {␍
|
||||
container.innerHTML = '<p class="empty">Sin resultados.</p>';␍
|
||||
return;␍
|
||||
}␍
|
||||
␍
|
||||
for (const item of results) {␍
|
||||
const card = document.createElement("article");␍
|
||||
card.className = "result-card";␍
|
||||
␍
|
||||
const header = document.createElement("div");␍
|
||||
header.className = "result-header";␍
|
||||
header.innerHTML = `␍
|
||||
<h3>${escapeHtml(item.filename)}</h3>␍
|
||||
<span class="badge badge-${item.status}">${escapeHtml(item.status)}</span>␍
|
||||
`;␍
|
||||
card.appendChild(header);␍
|
||||
␍
|
||||
if (item.status === "error" && item.error) {␍
|
||||
const err = document.createElement("p");␍
|
||||
err.className = "result-error";␍
|
||||
err.textContent = item.error;␍
|
||||
card.appendChild(err);␍
|
||||
} else {␍
|
||||
const meta = document.createElement("p");␍
|
||||
meta.className = "result-meta";␍
|
||||
meta.textContent = `Servicios detectados: ${item.service_count ?? 0}`;␍
|
||||
card.appendChild(meta);␍
|
||||
␍
|
||||
if (item.services && item.services.length > 0) {␍
|
||||
const table = document.createElement("table");␍
|
||||
table.innerHTML = `␍
|
||||
<thead>␍
|
||||
<tr>␍
|
||||
<th>Texto</th>␍
|
||||
<th>Probabilidad</th>␍
|
||||
<th>Es servicio</th>␍
|
||||
</tr>␍
|
||||
</thead>␍
|
||||
<tbody></tbody>␍
|
||||
`;␍
|
||||
const tbody = table.querySelector("tbody");␍
|
||||
for (const row of item.services) {␍
|
||||
const tr = document.createElement("tr");␍
|
||||
const isService =␍
|
||||
row.is_service === undefined || row.is_service === null␍
|
||||
? "—"␍
|
||||
: row.is_service␍
|
||||
? "Sí"␍
|
||||
: "No";␍
|
||||
tr.innerHTML = `␍
|
||||
<td>${escapeHtml(row.text)}</td>␍
|
||||
<td>${formatProbability(row.probability)}</td>␍
|
||||
<td>${isService}</td>␍
|
||||
`;␍
|
||||
tbody.appendChild(tr);␍
|
||||
}␍
|
||||
card.appendChild(table);␍
|
||||
}␍
|
||||
}␍
|
||||
␍
|
||||
container.appendChild(card);␍
|
||||
}␍
|
||||
}␍
|
||||
␍
|
||||
function escapeHtml(text) {␍
|
||||
const div = document.createElement("div");␍
|
||||
div.textContent = text ?? "";␍
|
||||
return div.innerHTML;␍
|
||||
}␍
|
||||
␍
|
||||
async function countSync(baseUrl, files, includeRejected) {␍
|
||||
const url = new URL(`${baseUrl}/v1/count`);␍
|
||||
if (includeRejected) url.searchParams.set("include_rejected", "true");␍
|
||||
␍
|
||||
const res = await fetch(url, {␍
|
||||
method: "POST",␍
|
||||
body: buildMultipart(files),␍
|
||||
});␍
|
||||
␍
|
||||
if (!res.ok) throw new Error(await parseError(res));␍
|
||||
const data = await res.json();␍
|
||||
return data.results || [];␍
|
||||
}␍
|
||||
␍
|
||||
async function createEmptyJob(baseUrl, includeRejected) {␍
|
||||
const res = await fetch(`${baseUrl}/v1/jobs`, {␍
|
||||
method: "POST",␍
|
||||
headers: { "Content-Type": "application/json" },␍
|
||||
body: JSON.stringify({ include_rejected: includeRejected }),␍
|
||||
});␍
|
||||
if (!res.ok) throw new Error(await parseError(res));␍
|
||||
const data = await res.json();␍
|
||||
return data.job_id;␍
|
||||
}␍
|
||||
␍
|
||||
async function uploadChunk(baseUrl, jobId, files, includeRejected) {␍
|
||||
const url = new URL(`${baseUrl}/v1/jobs/${jobId}/upload`);␍
|
||||
if (includeRejected) url.searchParams.set("include_rejected", "true");␍
|
||||
␍
|
||||
const res = await fetch(url, {␍
|
||||
method: "POST",␍
|
||||
body: buildMultipart(files),␍
|
||||
});␍
|
||||
if (!res.ok) throw new Error(await parseError(res));␍
|
||||
return res.json();␍
|
||||
}␍
|
||||
␍
|
||||
async function getJobStatus(baseUrl, jobId, waitMs = 0) {␍
|
||||
const url = new URL(`${baseUrl}/v1/jobs/${jobId}`);␍
|
||||
if (waitMs > 0) url.searchParams.set("wait_ms", String(waitMs));␍
|
||||
␍
|
||||
const res = await fetch(url);␍
|
||||
if (!res.ok) throw new Error(await parseError(res));␍
|
||||
return res.json();␍
|
||||
}␍
|
||||
␍
|
||||
async function fetchAllResults(baseUrl, jobId) {␍
|
||||
const results = [];␍
|
||||
let cursor = "";␍
|
||||
let hasMore = true;␍
|
||||
␍
|
||||
while (hasMore) {␍
|
||||
const url = new URL(`${baseUrl}/v1/jobs/${jobId}/results`);␍
|
||||
url.searchParams.set("limit", "500");␍
|
||||
if (cursor) url.searchParams.set("cursor", cursor);␍
|
||||
␍
|
||||
const res = await fetch(url);␍
|
||||
if (!res.ok) throw new Error(await parseError(res));␍
|
||||
const page = await res.json();␍
|
||||
results.push(...(page.results || []));␍
|
||||
cursor = page.next_cursor || "";␍
|
||||
hasMore = Boolean(page.has_more);␍
|
||||
}␍
|
||||
␍
|
||||
return results;␍
|
||||
}␍
|
||||
␍
|
||||
function chunkArray(items, size) {␍
|
||||
const chunks = [];␍
|
||||
for (let i = 0; i < items.length; i += size) {␍
|
||||
chunks.push(items.slice(i, i + size));␍
|
||||
}␍
|
||||
return chunks;␍
|
||||
}␍
|
||||
␍
|
||||
async function runAsyncJob(baseUrl, files, includeRejected, onProgress) {␍
|
||||
const jobId = await createEmptyJob(baseUrl, includeRejected);␍
|
||||
const chunks = chunkArray(files, JOB_CHUNK_SIZE);␍
|
||||
␍
|
||||
for (let i = 0; i < chunks.length; i++) {␍
|
||||
onProgress({␍
|
||||
status: "uploading",␍
|
||||
total: files.length,␍
|
||||
processed: 0,␍
|
||||
failed: 0,␍
|
||||
uploadChunk: i + 1,␍
|
||||
uploadChunks: chunks.length,␍
|
||||
});␍
|
||||
await uploadChunk(baseUrl, jobId, chunks[i], includeRejected);␍
|
||||
}␍
|
||||
␍
|
||||
while (true) {␍
|
||||
const status = await getJobStatus(baseUrl, jobId, LONG_POLL_MS);␍
|
||||
onProgress(status);␍
|
||||
if (TERMINAL_STATUSES.has(status.status)) {␍
|
||||
if (status.status === "cancelled") {␍
|
||||
throw new Error("El job fue cancelado.");␍
|
||||
}␍
|
||||
break;␍
|
||||
}␍
|
||||
}␍
|
||||
␍
|
||||
return fetchAllResults(baseUrl, jobId);␍
|
||||
}␍
|
||||
␍
|
||||
function updateProgress(status) {␍
|
||||
const bar = $("asyncProgress");␍
|
||||
const label = $("asyncProgressLabel");␍
|
||||
const total = status.total || 0;␍
|
||||
const done = (status.processed || 0) + (status.failed || 0);␍
|
||||
const pct = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0;␍
|
||||
␍
|
||||
bar.hidden = false;␍
|
||||
label.hidden = false;␍
|
||||
␍
|
||||
if (status.status === "uploading") {␍
|
||||
const chunkPct =␍
|
||||
status.uploadChunks > 0␍
|
||||
? Math.round((status.uploadChunk / status.uploadChunks) * 100)␍
|
||||
: 0;␍
|
||||
bar.value = chunkPct;␍
|
||||
label.textContent = `Subiendo lote ${status.uploadChunk}/${status.uploadChunks} (${total} archivos)…`;␍
|
||||
return;␍
|
||||
}␍
|
||||
␍
|
||||
bar.value = pct;␍
|
||||
label.textContent = `Estado: ${status.status} — ${done}/${total} procesados (${status.failed || 0} fallidos)`;␍
|
||||
}␍
|
||||
␍
|
||||
function initTabs() {␍
|
||||
const tabs = document.querySelectorAll(".tab");␍
|
||||
const panels = document.querySelectorAll(".panel");␍
|
||||
␍
|
||||
tabs.forEach((tab) => {␍
|
||||
tab.addEventListener("click", () => {␍
|
||||
tabs.forEach((t) => t.classList.remove("active"));␍
|
||||
panels.forEach((p) => p.classList.remove("active"));␍
|
||||
tab.classList.add("active");␍
|
||||
document.getElementById(tab.dataset.panel).classList.add("active");␍
|
||||
});␍
|
||||
});␍
|
||||
}␍
|
||||
␍
|
||||
function init() {␍
|
||||
$("baseUrl").value = window.location.origin;␍
|
||||
␍
|
||||
initTabs();␍
|
||||
␍
|
||||
$("syncForm").addEventListener("submit", async (e) => {␍
|
||||
e.preventDefault();␍
|
||||
const btn = $("syncSubmit");␍
|
||||
hideError("syncError");␍
|
||||
$("syncResults").innerHTML = "";␍
|
||||
␍
|
||||
try {␍
|
||||
const files = getSelectedFiles("syncFiles");␍
|
||||
validateFiles(files, MAX_SYNC_FILES);␍
|
||||
setLoading(btn, true, "Contar servicios");␍
|
||||
const results = await countSync(getBaseUrl(), files, getIncludeRejected());␍
|
||||
renderResults($("syncResults"), results);␍
|
||||
} catch (err) {␍
|
||||
showError("syncError", err.message);␍
|
||||
} finally {␍
|
||||
setLoading(btn, false, "Contar servicios");␍
|
||||
}␍
|
||||
});␍
|
||||
␍
|
||||
$("asyncForm").addEventListener("submit", async (e) => {␍
|
||||
e.preventDefault();␍
|
||||
const btn = $("asyncSubmit");␍
|
||||
hideError("asyncError");␍
|
||||
$("asyncResults").innerHTML = "";␍
|
||||
$("asyncProgress").hidden = true;␍
|
||||
$("asyncProgressLabel").hidden = true;␍
|
||||
␍
|
||||
try {␍
|
||||
const files = getSelectedFiles("asyncFiles");␍
|
||||
validateFiles(files, MAX_ASYNC_FILES);␍
|
||||
setLoading(btn, true, "Iniciar job");␍
|
||||
const results = await runAsyncJob(␍
|
||||
getBaseUrl(),␍
|
||||
files,␍
|
||||
getIncludeRejected(),␍
|
||||
updateProgress,␍
|
||||
);␍
|
||||
renderResults($("asyncResults"), results);␍
|
||||
} catch (err) {␍
|
||||
showError("asyncError", err.message);␍
|
||||
} finally {␍
|
||||
setLoading(btn, false, "Iniciar job");␍
|
||||
}␍
|
||||
});␍
|
||||
}␍
|
||||
␍
|
||||
document.addEventListener("DOMContentLoaded", init);␍
|
||||
␍
|
||||
@@ -0,0 +1,6 @@
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed index.html app.js
|
||||
var FS embed.FS
|
||||
@@ -0,0 +1,307 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Services Count — Demo</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f4f6f8;
|
||||
--surface: #ffffff;
|
||||
--text: #1a2332;
|
||||
--muted: #5c6b7a;
|
||||
--border: #d8dee6;
|
||||
--primary: #2563eb;
|
||||
--primary-hover: #1d4ed8;
|
||||
--error-bg: #fef2f2;
|
||||
--error-text: #b91c1c;
|
||||
--ok: #15803d;
|
||||
--warn: #b45309;
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.25rem 3rem;
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
header p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
header a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
header a:hover { text-decoration: underline; }
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.35rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="file"] {
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.checkbox input { width: auto; }
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 0.65rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.panel { display: none; }
|
||||
.panel.active { display: block; }
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.6rem 1.25rem;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) { background: var(--primary-hover); }
|
||||
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
.error {
|
||||
background: var(--error-bg);
|
||||
color: var(--error-text);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
progress {
|
||||
width: 100%;
|
||||
height: 0.65rem;
|
||||
margin: 0.75rem 0 0.25rem;
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.results { margin-top: 1.25rem; }
|
||||
|
||||
.result-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.result-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.result-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
background: #e2e8f0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badge-ok { background: #dcfce7; color: var(--ok); }
|
||||
.badge-error { background: #fee2e2; color: var(--error-text); }
|
||||
.badge-pending { background: #fef3c7; color: var(--warn); }
|
||||
|
||||
.result-meta {
|
||||
margin: 0 0 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.result-error {
|
||||
margin: 0;
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 0.45rem 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>Services Count — Demo</h1>
|
||||
<p>
|
||||
Cliente de ejemplo para la API de conteo de servicios en facturas médicas.
|
||||
<a href="/openapi.json" target="_blank" rel="noopener">OpenAPI</a>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="card">
|
||||
<div class="field">
|
||||
<label for="baseUrl">URL base de la API</label>
|
||||
<input type="text" id="baseUrl" placeholder="http://localhost:8080">
|
||||
<p class="hint">Por defecto usa el mismo origen desde el que se sirve esta página.</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" id="includeRejected">
|
||||
Incluir filas rechazadas (<code>include_rejected</code>)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button type="button" class="tab active" data-panel="syncPanel">Síncrono</button>
|
||||
<button type="button" class="tab" data-panel="asyncPanel">Asíncrono</button>
|
||||
</div>
|
||||
|
||||
<div id="syncPanel" class="panel active">
|
||||
<div class="card">
|
||||
<form id="syncForm">
|
||||
<div class="field">
|
||||
<label for="syncFiles">Archivos PDF (máx. 50)</label>
|
||||
<input type="file" id="syncFiles" accept=".pdf,application/pdf" multiple required>
|
||||
<p class="hint">Envía una petición <code>POST /v1/count</code> y muestra la respuesta al instante.</p>
|
||||
</div>
|
||||
<div id="syncError" class="error" hidden></div>
|
||||
<button type="submit" class="btn" id="syncSubmit">Contar servicios</button>
|
||||
</form>
|
||||
<div id="syncResults" class="results"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="asyncPanel" class="panel">
|
||||
<div class="card">
|
||||
<form id="asyncForm">
|
||||
<div class="field">
|
||||
<label for="asyncFiles">Archivos PDF (hasta 500)</label>
|
||||
<input type="file" id="asyncFiles" accept=".pdf,application/pdf" multiple required>
|
||||
<p class="hint">
|
||||
Flujo producción: MinIO → cola NATS → workers (autoscaler).
|
||||
Sube por lotes de hasta 500; el progreso usa long-polling.
|
||||
</p>
|
||||
</div>
|
||||
<div id="asyncError" class="error" hidden></div>
|
||||
<progress id="asyncProgress" value="0" max="100" hidden></progress>
|
||||
<p id="asyncProgressLabel" class="progress-label" hidden></p>
|
||||
<button type="submit" class="btn" id="asyncSubmit">Iniciar job</button>
|
||||
</form>
|
||||
<div id="asyncResults" class="results"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user