Files
KArtSell.Aegis/docs/Design/kbx-foundation-v60-status-canonical-contract-hardening/packages/kbx-ui/src/wms/KbxBarcodeCapture.vue
T
kjh2064 3f293d8aa8
deploy / deploy (push) Successful in 1m52s
deploy / notify (push) Successful in 1s
V13-FE-006: consolidate approved UI and contract hardening
2026-08-13 02:41:00 +09:00

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>