118 lines
4.9 KiB
Vue
118 lines
4.9 KiB
Vue
<script setup lang="ts">
|
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
import type { KbxBarcodeEvent } from '@kbx/contracts'
|
|
import { isKbxBarcodeDuplicate, normalizeKbxBarcode } from './barcodeGuard'
|
|
|
|
const props = withDefaults(defineProps<{
|
|
enabled?: boolean
|
|
minLength?: number
|
|
maxLength?: number
|
|
interKeyTimeoutMs?: number
|
|
duplicateDebounceMs?: number
|
|
label?: string
|
|
}>(), {
|
|
enabled: true,
|
|
minLength: 3,
|
|
maxLength: 256,
|
|
interKeyTimeoutMs: 70,
|
|
duplicateDebounceMs: 180,
|
|
label: 'SCAN READY',
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
scan: [KbxBarcodeEvent]
|
|
duplicateIgnored: [KbxBarcodeEvent]
|
|
}>()
|
|
|
|
const buffer = ref('')
|
|
const lastKeyAt = ref(0)
|
|
const manualValue = ref('')
|
|
let lastSubmitted:KbxBarcodeEvent|null=null
|
|
|
|
function resetBuffer(){buffer.value='';lastKeyAt.value=0}
|
|
function isEditableTarget(target: EventTarget | null) {
|
|
if (!(target instanceof HTMLElement)) return false
|
|
if (target.dataset.kbxBarcodeCapture === 'true') return false
|
|
return target.matches('input, textarea, select, [contenteditable="true"]')
|
|
}
|
|
|
|
function submit(value: string, source: KbxBarcodeEvent['source']) {
|
|
if(!props.enabled)return
|
|
const scan=normalizeKbxBarcode(value,source,props.minLength,props.maxLength)
|
|
if(!scan)return
|
|
if(isKbxBarcodeDuplicate(scan,lastSubmitted,props.duplicateDebounceMs)){
|
|
emit('duplicateIgnored',scan)
|
|
return
|
|
}
|
|
lastSubmitted=scan
|
|
emit('scan', scan)
|
|
}
|
|
|
|
function onKeydown(event: KeyboardEvent) {
|
|
if (!props.enabled || event.ctrlKey || event.altKey || event.metaKey || isEditableTarget(event.target)) return
|
|
|
|
const now = performance.now()
|
|
if (lastKeyAt.value && now - lastKeyAt.value > props.interKeyTimeoutMs) resetBuffer()
|
|
lastKeyAt.value = now
|
|
|
|
if (event.key === 'Escape') { resetBuffer(); return }
|
|
if (event.key === 'Enter') {
|
|
const value = buffer.value
|
|
resetBuffer()
|
|
if (value.length >= props.minLength) {
|
|
event.preventDefault()
|
|
submit(value, 'keyboard-wedge')
|
|
}
|
|
return
|
|
}
|
|
|
|
if (event.key.length === 1) {
|
|
if(buffer.value.length>=props.maxLength){resetBuffer();return}
|
|
buffer.value += event.key
|
|
}
|
|
}
|
|
|
|
function submitManual() {
|
|
submit(manualValue.value, 'manual')
|
|
manualValue.value = ''
|
|
}
|
|
function submitCamera(value:string){submit(value,'camera')}
|
|
function reset(){resetBuffer();manualValue.value='';lastSubmitted=null}
|
|
function onVisibilityChange(){if(document.visibilityState!=='visible')resetBuffer()}
|
|
watch(()=>props.enabled,value=>{if(!value)resetBuffer()})
|
|
|
|
onMounted(() => {document.addEventListener('keydown', onKeydown, true);document.addEventListener('visibilitychange',onVisibilityChange)})
|
|
onBeforeUnmount(() => {document.removeEventListener('keydown', onKeydown, true);document.removeEventListener('visibilitychange',onVisibilityChange)})
|
|
defineExpose({submitCamera,reset})
|
|
</script>
|
|
|
|
<template>
|
|
<section class="capture" :class="{ disabled: !enabled }" aria-label="바코드 입력" :data-enabled="enabled">
|
|
<div class="scan-box" aria-live="polite">
|
|
<strong>{{ enabled ? label : 'SCAN PAUSED' }}</strong>
|
|
<small>스캐너 입력은 Enter 종결자를 기준으로 인식하며 매우 짧은 중복 신호는 한 번만 처리합니다.</small>
|
|
</div>
|
|
|
|
<details class="manual">
|
|
<summary>바코드 직접 입력</summary>
|
|
<div class="manual-row">
|
|
<input
|
|
v-model="manualValue"
|
|
data-kbx-barcode-capture="true"
|
|
inputmode="text"
|
|
autocomplete="off"
|
|
:maxlength="maxLength"
|
|
:disabled="!enabled"
|
|
aria-label="바코드 직접 입력"
|
|
@keyup.enter="submitManual"
|
|
>
|
|
<button type="button" :disabled="!enabled || manualValue.trim().length < minLength" @click="submitManual">입력</button>
|
|
</div>
|
|
</details>
|
|
</section>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.capture{margin:var(--kbx-space-4) 0}.scan-box{min-height:var(--kbx-wms-scan-box-min-height);border:calc(var(--kbx-border-width) * 2) solid var(--kbx-color-border-strong);border-radius:var(--kbx-radius-lg);display:flex;flex-direction:column;justify-content:center;align-items:center;gap:var(--kbx-space-1);background:var(--kbx-color-surface-muted)}.scan-box strong{font-size:var(--kbx-font-xl);letter-spacing:.06em}.scan-box small{color:var(--kbx-color-text-muted);font-size:var(--kbx-font-xs);text-align:center;padding:0 var(--kbx-space-2)}.disabled{opacity:.6}.manual{margin-top:var(--kbx-space-2);font-size:var(--kbx-font-sm)}.manual-row{display:grid;grid-template-columns:1fr var(--kbx-wms-manual-button-width);gap:var(--kbx-space-2);margin-top:var(--kbx-space-2)}.manual-row input,.manual-row button{min-height:var(--kbx-control-touch);font:inherit}.manual-row input{border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-md);padding:0 var(--kbx-space-3)}.manual-row button{border:var(--kbx-border-width) solid var(--kbx-color-border-strong);background:var(--kbx-color-surface);border-radius:var(--kbx-radius-md);font-weight:600}
|
|
</style>
|