feat: Sitzungsliste und Verdrahtung zur vollstaendigen App
Ersetzt das Sichtpruefungs-Geruest in main.js durch die vollstaendige Verdrahtung aus Kamera, Barcode/OCR-Erkennung, Sitzungsverwaltung, Speicherung und Oberflaeche. Neu: session-list.js fuer die Sitzungsliste zum Umsortieren/Entfernen/Beenden. Gegenueber dem urspruenglichen Plan an den seither geaenderten Schnittstellen ausgerichtet: commitAssignment erhaelt die tatsaechliche Erkennungsquelle (source) statt der Konfidenz als drittes Argument, und die OCR-Verfuegbarkeitsanzeige ueberschreibt keine noch zu lesende Fehlermeldung mehr. Bezeichner in beiden Dateien auf Englisch umgestellt, damit sie der bereits etablierten Projektkonvention entsprechen.
This commit is contained in:
+167
-12
@@ -1,19 +1,174 @@
|
||||
// Vollstaendige Verdrahtung der App: Kamera, Barcode/OCR-Erkennung, Sitzung
|
||||
// und Oberflaeche. Ersetzt das fruehere Sichtpruefungs-Geruest vollstaendig.
|
||||
import { startCamera, grabFrame, imageDataFromFile } from './camera.js';
|
||||
import { decodeBarcodes } from './barcode.js';
|
||||
import { runOcr, isOcrAvailable } from './ocr.js';
|
||||
import { recognize } from './pipeline.js';
|
||||
import {
|
||||
createSession, proposeAssignment, commitAssignment,
|
||||
undoLast, moveEntry, removeEntry,
|
||||
} from './session.js';
|
||||
import { saveSession, loadSession, clearSession } from './storage.js';
|
||||
import { renderScanView } from './ui/scan-view.js';
|
||||
import { showResult } from './ui/result-overlay.js';
|
||||
import { askForStack } from './ui/ambiguous-dialog.js';
|
||||
import { startCamera } from './camera.js';
|
||||
import { renderSessionList } from './ui/session-list.js';
|
||||
|
||||
const app = document.querySelector('#app');
|
||||
const store = window.localStorage;
|
||||
|
||||
// loadSession liefert entweder eine in sich stimmige Sitzung oder null - nie
|
||||
// einen halb brauchbaren Zustand (siehe storage.js). Eine gefundene Sitzung
|
||||
// nach einem Neuladen hat Vorrang vor einer neuen.
|
||||
let session = loadSession(store) ?? createSession();
|
||||
|
||||
// Sperrt die Erfassung, waehrend eine Erkennung laeuft. Wird in processCapture()
|
||||
// im finally-Block in jedem Ausgang wieder aufgehoben - auch wenn der Nutzer
|
||||
// im Rot-Dialog "nochmal scannen" waehlt und die Funktion damit vorzeitig
|
||||
// verlassen wird (der fruehe return liegt innerhalb des try-Blocks).
|
||||
let busy = false;
|
||||
|
||||
const view = renderScanView(app, {
|
||||
onCapture: async () => {
|
||||
const answer = await askForStack(app, {
|
||||
spec: { capacityGb: 64, rank: null, speed: null, formFactor: null, partNumber: null },
|
||||
candidates: ['A', 'B'],
|
||||
});
|
||||
view.setLast(`Antwort: ${JSON.stringify(answer)}`);
|
||||
onCapture: () => processCapture(() => grabFrame(view.video)),
|
||||
onUndo: () => {
|
||||
const entry = undoLast(session);
|
||||
view.setLast(entry ? `zurueckgenommen: Stapel ${entry.stackId}` : 'nichts zurueckzunehmen');
|
||||
syncView();
|
||||
},
|
||||
onUndo: () => view.setLast('zurueckgenommen'),
|
||||
onOpenList: () => {},
|
||||
onPickFile: () => {},
|
||||
onOpenList: openSessionList,
|
||||
onPickFile: (file) => processCapture(() => imageDataFromFile(file)),
|
||||
});
|
||||
view.setStacks([{ id: 'A', count: 12 }, { id: 'B', count: 4 }]);
|
||||
startCamera(view.video).catch(() => view.setStatus('Kamera nicht verfuegbar', true));
|
||||
|
||||
/** Spiegelt den Sitzungsstand in die Oberflaeche und sichert ihn gegen Neuladen ab. */
|
||||
function syncView() {
|
||||
view.setStacks(session.stacks);
|
||||
saveSession(session, store);
|
||||
}
|
||||
|
||||
/** Kurzbeschreibung fuer die "Zuletzt"-Zeile. */
|
||||
function describeEntry(spec, stackId) {
|
||||
const parts = [
|
||||
spec.capacityGb ? `${spec.capacityGb}GB` : '?',
|
||||
spec.speed ?? '?',
|
||||
spec.formFactor ?? '',
|
||||
].filter(Boolean);
|
||||
return `${parts.join(' ')} → Stapel ${stackId}`;
|
||||
}
|
||||
|
||||
/** Naechster noch nicht vergebener Stapel-Buchstabe - fuer den Fall, dass der Nutzer im Rot-Dialog ausdruecklich einen neuen Stapel waehlt. */
|
||||
function nextFreeStackId() {
|
||||
const used = new Set(session.stacks.map((stack) => stack.id));
|
||||
for (const letter of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') {
|
||||
if (!used.has(letter)) return letter;
|
||||
}
|
||||
// Nach Z weiter mit A2, B2, ... - in der Praxis nie erreicht (siehe session.js).
|
||||
return `A${session.stacks.length}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Scan-Durchlauf: Bild beschaffen, erkennen, bei unsicherer Erkennung
|
||||
* nachfragen, buchen, kurz rueckmelden. Laeuft ohne Bestaetigung durch,
|
||||
* solange die Erkennung eindeutig ist - haelt nur bei roter Konfidenz oder
|
||||
* mehrdeutiger Stapelzuordnung an.
|
||||
*/
|
||||
async function processCapture(getFrame) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
view.setStatus('erkenne …');
|
||||
let failed = false;
|
||||
|
||||
try {
|
||||
const frame = await getFrame();
|
||||
const { spec, source, confidence } = await recognize(frame, { decodeBarcodes, runOcr });
|
||||
|
||||
const plan = proposeAssignment(session, spec);
|
||||
let stackId = plan.stackId;
|
||||
|
||||
if (confidence === 'red' || plan.kind === 'ambiguous') {
|
||||
const answer = await askForStack(app, {
|
||||
spec,
|
||||
candidates: plan.kind === 'ambiguous'
|
||||
? plan.candidates
|
||||
: session.stacks.map((stack) => stack.id),
|
||||
});
|
||||
|
||||
// "nochmal scannen": abbrechen, ohne zu buchen. Der fruehe return liegt
|
||||
// innerhalb des try - der finally-Block hebt die Sperre trotzdem auf.
|
||||
if (answer.action === 'retry') return;
|
||||
|
||||
if (answer.action === 'stack') {
|
||||
// Nutzer hat einen bestehenden Stapel gewaehlt: genau dieser wird genommen.
|
||||
stackId = answer.stackId;
|
||||
} else {
|
||||
// "neuer Stapel": unabhaengig von jeder Vermutung der naechste freie Buchstabe.
|
||||
stackId = nextFreeStackId();
|
||||
}
|
||||
}
|
||||
|
||||
const entry = commitAssignment(session, spec, source, stackId);
|
||||
view.setLast(describeEntry(spec, entry.stackId));
|
||||
syncView();
|
||||
|
||||
// Nach einer manuellen Entscheidung im Rot-Dialog gibt es nichts mehr
|
||||
// rueckzumelden - der Dialog selbst war die Rueckmeldung.
|
||||
if (confidence !== 'red') {
|
||||
await showResult(app, { stackId: entry.stackId, spec, confidence });
|
||||
}
|
||||
} catch (error) {
|
||||
failed = true;
|
||||
view.setStatus(`Fehler: ${error.message}`, true);
|
||||
} finally {
|
||||
busy = false;
|
||||
// Die OCR-Verfuegbarkeitsanzeige darf eine Fehlermeldung, die der Nutzer
|
||||
// noch lesen muss, nicht ueberschreiben.
|
||||
if (!failed) {
|
||||
const available = isOcrAvailable();
|
||||
view.setStatus(
|
||||
available ? '' : 'Texterkennung nicht verfuegbar — nur Barcodes werden gelesen',
|
||||
!available,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Entfernt alle offenen Vollbild-Overlays innerhalb der App. */
|
||||
function closeOverlays() {
|
||||
app.querySelectorAll('.overlay').forEach((element) => element.remove());
|
||||
}
|
||||
|
||||
/**
|
||||
* Oeffnet die Sitzungsliste. Nach einer Aenderung (Umsortieren, Entfernen)
|
||||
* wird die aktuelle Liste zuerst geschlossen und dann frisch neu aufgebaut,
|
||||
* damit nach dem Entfernen eines Eintrags nie zwei Listen uebereinanderliegen
|
||||
* und Stapel-Kopfzeilen sowie Zaehler stets aktuell bleiben.
|
||||
*/
|
||||
function openSessionList() {
|
||||
renderSessionList(app, session, {
|
||||
onMove: (entryId, stackId) => {
|
||||
moveEntry(session, entryId, stackId);
|
||||
syncView();
|
||||
closeOverlays();
|
||||
openSessionList();
|
||||
},
|
||||
onRemove: (entryId) => {
|
||||
removeEntry(session, entryId);
|
||||
syncView();
|
||||
closeOverlays();
|
||||
openSessionList();
|
||||
},
|
||||
onEndSession: () => {
|
||||
session = createSession();
|
||||
clearSession(store);
|
||||
view.setLast('Sitzung beendet');
|
||||
syncView();
|
||||
},
|
||||
onClose: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
startCamera(view.video).catch((error) => {
|
||||
view.setStatus(`Kamera nicht verfuegbar (${error.message}) — Bild auswaehlen`, true);
|
||||
view.openFilePicker();
|
||||
});
|
||||
|
||||
syncView();
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Rein darstellend: Vollbild-Liste aller Stapel der laufenden Sitzung, zum
|
||||
// Umsortieren und Entfernen einzelner Eintraege sowie zum Beenden der
|
||||
// Sitzung. Kennt weder Kamera noch Erkennung - alles kommt ueber die
|
||||
// Rueckrufe, genau wie bei scan-view.js und ambiguous-dialog.js.
|
||||
|
||||
/** Kurzbeschreibung eines Specs fuer Stapel-Kopfzeilen und Eintrags-Zeilen ohne Teilenummer. */
|
||||
function describeSpec(spec) {
|
||||
const parts = [
|
||||
spec.capacityGb ? `${spec.capacityGb}GB` : '?',
|
||||
spec.rank ?? '?',
|
||||
spec.speed ?? '?',
|
||||
spec.formFactor ?? '?',
|
||||
];
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Vollbild-Liste aller Stapel der laufenden Sitzung.
|
||||
* @param {HTMLElement} root
|
||||
* @param {{stacks: Array<{id: string, count: number, spec: object}>, entries: Array<{entryId: number, spec: object, stackId: string}>}} session
|
||||
* @param {{onMove: (entryId: number, stackId: string) => void, onRemove: (entryId: number) => void, onEndSession: () => void, onClose: () => void}} callbacks
|
||||
*/
|
||||
export function renderSessionList(root, session, { onMove, onRemove, onEndSession, onClose }) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'overlay';
|
||||
overlay.style.background = 'var(--bg)';
|
||||
overlay.style.justifyContent = 'flex-start';
|
||||
overlay.style.overflowY = 'auto';
|
||||
overlay.innerHTML = `
|
||||
<div class="stack-id" style="font-size:26px">Sitzung</div>
|
||||
<div class="choices" id="body" style="max-width:520px"></div>
|
||||
<div class="choices" style="max-width:520px">
|
||||
<button class="action" id="close">zurueck zum Scannen</button>
|
||||
<button class="action secondary" id="end">Sitzung beenden</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const body = overlay.querySelector('#body');
|
||||
|
||||
for (const stack of session.stacks) {
|
||||
const header = document.createElement('div');
|
||||
header.className = 'detail';
|
||||
header.style.textAlign = 'left';
|
||||
header.style.marginTop = '12px';
|
||||
header.textContent = `Stapel ${stack.id} — ${stack.count}x ${describeSpec(stack.spec)}`;
|
||||
body.appendChild(header);
|
||||
|
||||
for (const entry of session.entries.filter((candidate) => candidate.stackId === stack.id)) {
|
||||
const row = document.createElement('div');
|
||||
row.style.display = 'flex';
|
||||
row.style.gap = '8px';
|
||||
row.style.alignItems = 'center';
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.style.flex = '1';
|
||||
label.style.fontSize = '14px';
|
||||
label.style.textAlign = 'left';
|
||||
label.textContent = entry.spec.partNumber ?? describeSpec(entry.spec);
|
||||
row.appendChild(label);
|
||||
|
||||
// Bedienflaechen muessen mindestens 56px hoch sein - auch die Auswahl,
|
||||
// nicht nur die Schaltflaechen.
|
||||
const select = document.createElement('select');
|
||||
select.style.minHeight = '56px';
|
||||
select.setAttribute('aria-label', `Stapel fuer ${label.textContent}`);
|
||||
for (const target of session.stacks) {
|
||||
const option = document.createElement('option');
|
||||
option.value = target.id;
|
||||
option.textContent = target.id;
|
||||
option.selected = target.id === entry.stackId;
|
||||
select.appendChild(option);
|
||||
}
|
||||
select.addEventListener('change', () => onMove(entry.entryId, select.value));
|
||||
row.appendChild(select);
|
||||
|
||||
const removeButton = document.createElement('button');
|
||||
removeButton.className = 'action secondary';
|
||||
removeButton.style.minWidth = '56px';
|
||||
removeButton.textContent = '✕';
|
||||
removeButton.setAttribute('aria-label', `${label.textContent} entfernen`);
|
||||
removeButton.addEventListener('click', () => onRemove(entry.entryId));
|
||||
row.appendChild(removeButton);
|
||||
|
||||
body.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
if (session.stacks.length === 0) {
|
||||
body.textContent = 'Noch nichts erfasst.';
|
||||
}
|
||||
|
||||
overlay.querySelector('#close').addEventListener('click', () => {
|
||||
overlay.remove();
|
||||
onClose();
|
||||
});
|
||||
overlay.querySelector('#end').addEventListener('click', () => {
|
||||
overlay.remove();
|
||||
onEndSession();
|
||||
});
|
||||
|
||||
root.appendChild(overlay);
|
||||
}
|
||||
Reference in New Issue
Block a user