feat: Scan-Ansicht und Treffer-Rueckmeldung
This commit is contained in:
+15
-26
@@ -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
@@ -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; }
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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">↶</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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user