feat: Scan-Ansicht und Treffer-Rueckmeldung

This commit is contained in:
vchuser
2026-07-28 17:00:44 +02:00
parent 78592d03a1
commit 4aaff0b08d
4 changed files with 224 additions and 28 deletions
+15 -26
View File
@@ -1,29 +1,18 @@
import { startCamera, grabFrame } from './camera.js'; import { renderScanView } from './ui/scan-view.js';
import { decodeBarcodes } from './barcode.js'; import { showResult } from './ui/result-overlay.js';
import { startCamera } from './camera.js';
const app = document.querySelector('#app'); const app = document.querySelector('#app');
app.innerHTML = ` const view = renderScanView(app, {
<video id="v" style="width:100%"></video> onCapture: () =>
<button id="b" style="min-height:56px;width:100%">Bild aufnehmen</button> showResult(app, {
<pre id="out"></pre> stackId: 'B',
`; spec: { capacityGb: 64, rank: '4DRx4', speed: 'PC4-2400', formFactor: 'LRDIMM', partNumber: 'M386A8K40BM1-CRC4Y' },
confidence: 'green',
const video = document.querySelector('#v'); }),
const out = document.querySelector('#out'); onUndo: () => view.setLast('zurueckgenommen'),
onOpenList: () => {},
startCamera(video) onPickFile: () => {},
.then(() => { out.textContent = 'Kamera laeuft'; })
.catch((error) => { out.textContent = `Kamera nicht verfuegbar: ${error.message}`; });
document.querySelector('#b').addEventListener('click', async () => {
try {
// grabFrame wirft, wenn das Kamerabild noch nicht bereit ist (siehe
// camera.js) - dieser Fall wird hier abgefangen und sichtbar gemacht,
// statt unbehandelt zu verpuffen.
const frame = grabFrame(video);
const codes = await decodeBarcodes(frame);
out.textContent = codes.length ? codes.join('\n') : 'kein Barcode gefunden';
} catch (error) {
out.textContent = `Fehler: ${error.message}`;
}
}); });
view.setStacks([{ id: 'A', count: 12 }, { id: 'B', count: 4 }]);
startCamera(view.video).catch(() => view.setStatus('Kamera nicht verfuegbar', true));
+110 -2
View File
@@ -1,2 +1,110 @@
:root { color-scheme: dark; } :root {
body { margin: 0; font-family: system-ui, sans-serif; } color-scheme: dark;
--bg: #14161a;
--fg: #f2f4f7;
--muted: #9aa3af;
--green: #1f8a4c;
--yellow: #b8860b;
--red: #a02c2c;
--line: #2a2e36;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: system-ui, -apple-system, sans-serif;
overscroll-behavior: none;
}
#app { display: flex; flex-direction: column; height: 100dvh; }
.camera { position: relative; flex: 1; overflow: hidden; background: #000; }
.camera video { width: 100%; height: 100%; object-fit: cover; }
.frame {
position: absolute;
inset: 20% 8%;
border: 3px solid rgba(255, 255, 255, 0.7);
border-radius: 8px;
pointer-events: none;
}
.stacks {
display: flex;
gap: 8px;
padding: 10px 12px;
overflow-x: auto;
border-top: 1px solid var(--line);
min-height: 56px;
align-items: center;
}
.stacks button {
flex: none;
min-height: 44px;
padding: 0 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: #1c2027;
color: var(--fg);
font-size: 17px;
font-weight: 600;
}
.last {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-top: 1px solid var(--line);
min-height: 56px;
font-size: 15px;
color: var(--muted);
}
.last span { flex: 1; }
.action {
min-height: 56px;
border: none;
border-radius: 10px;
background: #2b6cb0;
color: #fff;
font-size: 18px;
font-weight: 600;
padding: 0 18px;
}
.action.secondary { background: #2a2e36; }
.capture { margin: 12px; }
.overlay {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 18px;
padding: 24px;
text-align: center;
z-index: 10;
}
.overlay.green { background: var(--green); }
.overlay.yellow { background: var(--yellow); }
.overlay.red { background: var(--red); }
.overlay .stack-id { font-size: 68px; font-weight: 800; letter-spacing: 2px; }
.overlay .detail { font-size: 20px; line-height: 1.5; }
.overlay .pn { font-size: 15px; opacity: 0.85; word-break: break-all; }
.choices { display: grid; gap: 12px; width: 100%; max-width: 420px; }
.choices button { min-height: 64px; font-size: 20px; }
.status { padding: 8px 12px; font-size: 14px; color: var(--muted); }
.status.warn { color: #f0b429; }
+33
View File
@@ -0,0 +1,33 @@
function beschreibung(spec) {
const teile = [
spec.capacityGb ? `${spec.capacityGb}GB` : null,
spec.rank,
spec.speed,
spec.formFactor,
].filter(Boolean);
return teile.join(' ') || 'ohne Angaben';
}
/**
* Blendet die Treffer-Rueckmeldung ein und nach kurzer Zeit wieder aus.
* Gruen bei Barcode, gelb bei OCR. Keine Eingabe noetig.
*/
export function showResult(root, { stackId, spec, confidence }, dauerMs = 1200) {
const overlay = document.createElement('div');
overlay.className = `overlay ${confidence}`;
overlay.innerHTML = `
<div class="stack-id">STAPEL ${stackId}</div>
<div class="detail"></div>
<div class="pn"></div>
`;
overlay.querySelector('.detail').textContent = beschreibung(spec);
overlay.querySelector('.pn').textContent = spec.partNumber ?? '';
root.appendChild(overlay);
return new Promise((resolve) => {
setTimeout(() => {
overlay.remove();
resolve();
}, dauerMs);
});
}
+66
View File
@@ -0,0 +1,66 @@
/**
* Baut die Hauptansicht auf und liefert Aktualisierungsfunktionen zurueck.
* Kennt weder Kamera noch Erkennung - alles kommt ueber die Rueckrufe.
*/
export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile }) {
root.innerHTML = `
<div class="camera">
<video id="preview" playsinline muted></video>
<div class="frame"></div>
</div>
<div class="status" id="status"></div>
<button class="action capture" id="capture">Modul scannen</button>
<div class="stacks" id="stacks"></div>
<div class="last">
<span id="last">Noch nichts erfasst</span>
<button class="action secondary" id="undo" style="min-width:64px">&#8630;</button>
</div>
<input type="file" id="file" accept="image/*" hidden />
`;
const stacksEl = root.querySelector('#stacks');
const lastEl = root.querySelector('#last');
const statusEl = root.querySelector('#status');
const fileEl = root.querySelector('#file');
root.querySelector('#capture').addEventListener('click', onCapture);
root.querySelector('#undo').addEventListener('click', onUndo);
fileEl.addEventListener('change', () => {
if (fileEl.files[0]) onPickFile(fileEl.files[0]);
fileEl.value = '';
});
return {
video: root.querySelector('#preview'),
/** @param {{id: string, count: number}[]} stacks */
setStacks(stacks) {
stacksEl.innerHTML = '';
if (stacks.length === 0) {
stacksEl.textContent = 'Noch keine Stapel';
return;
}
for (const stack of stacks) {
const button = document.createElement('button');
button.textContent = `${stack.id}: ${stack.count}`;
button.addEventListener('click', onOpenList);
stacksEl.appendChild(button);
}
},
setLast(text) {
lastEl.textContent = text;
},
/** @param {string} text @param {boolean} warn */
setStatus(text, warn = false) {
statusEl.textContent = text;
statusEl.classList.toggle('warn', warn);
},
openFilePicker() {
fileEl.hidden = false;
fileEl.click();
},
};
}