V13-FE-011: finalize search list layout slice
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
<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>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
online: boolean
|
||||
pendingCommands?: number
|
||||
syncing?: boolean
|
||||
}>()
|
||||
|
||||
const label = computed(() => {
|
||||
if (!props.online) return props.pendingCommands ? `오프라인 · ${props.pendingCommands}건 대기` : '오프라인'
|
||||
if (props.syncing) return `동기화 중${props.pendingCommands ? ` · ${props.pendingCommands}건` : ''}`
|
||||
if (props.pendingCommands) return `온라인 · ${props.pendingCommands}건 전송 대기`
|
||||
return '온라인'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="network" :class="{ offline: !online, syncing }" role="status" aria-live="polite">
|
||||
<span class="dot" aria-hidden="true" />
|
||||
{{ label }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.network { display:inline-flex; gap:6px; align-items:center; font-size:12px; white-space:nowrap; color:#344054; }
|
||||
.dot { width:8px; height:8px; border-radius:50%; background:var(--kbx-color-success); }
|
||||
.offline .dot { background:#d92d20; }
|
||||
.syncing .dot { background:var(--kbx-color-warning); }
|
||||
</style>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
label: string
|
||||
variant?: 'primary' | 'secondary' | 'danger'
|
||||
disabled?: boolean
|
||||
busy?: boolean
|
||||
}>(), { variant: 'primary' })
|
||||
|
||||
defineEmits<{ click: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="action"
|
||||
:class="variant"
|
||||
type="button"
|
||||
:disabled="disabled || busy"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
{{ busy ? '처리 중...' : label }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.action { width:100%; min-height:var(--kbx-control-touch); border-radius:var(--kbx-radius-md); border:var(--kbx-border-width) solid transparent; font-size:var(--kbx-font-lg); font-weight:600; padding:var(--kbx-space-2) var(--kbx-space-4); }
|
||||
.primary { color:var(--kbx-color-surface); background:var(--kbx-color-primary); }
|
||||
.secondary { color:var(--kbx-color-text); background:var(--kbx-color-surface); border-color:var(--kbx-color-border); }
|
||||
.danger { color:var(--kbx-color-surface); background:var(--kbx-color-danger); }
|
||||
.action:disabled { opacity:.55; cursor:not-allowed; }
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject } from 'vue'
|
||||
import type { KbxAsyncState, KbxScreenDefinition } from '@kbx/contracts'
|
||||
import KbxNetworkIndicator from './KbxNetworkIndicator.vue'
|
||||
import KbxDataState from '../components/KbxDataState.vue'
|
||||
import KbxTemplateStateBoundary from '../components/KbxTemplateStateBoundary.vue'
|
||||
import { KbxScreenUtilityHostKey } from '../utility/host'
|
||||
import { KbxPermissionHostKey } from '../permission/host'
|
||||
|
||||
const props=defineProps<{
|
||||
screen: KbxScreenDefinition
|
||||
progress?: string
|
||||
taskContext?: string
|
||||
instruction?: string
|
||||
online: boolean
|
||||
pendingCommands?: number
|
||||
syncing?: boolean
|
||||
contentState?: KbxAsyncState
|
||||
refreshing?: boolean
|
||||
can?: (permission:string)=>boolean
|
||||
}>()
|
||||
const utilityHost=inject(KbxScreenUtilityHostKey,null)
|
||||
const permissionHost=inject(KbxPermissionHostKey,null)
|
||||
const helpAvailable=computed(()=>utilityHost?.available(props.screen).includes('help')??false)
|
||||
const templateMismatch=computed(()=>props.screen.type!=='wms-mobile'||props.screen.templateCode!=='T09')
|
||||
function canPermission(permission:string){return props.can?props.can(permission):(permissionHost?.has(permission)??true)}
|
||||
const permissionDenied=computed(()=>Boolean(props.screen.permissions?.some(permission=>!canPermission(permission))))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="kbx-wms-page" :data-screen-id="screen.id" data-screen-type="wms-mobile" :data-access="permissionDenied?'denied':'allowed'">
|
||||
<header class="kbx-wms-header" data-kbx-surface="mobile-header">
|
||||
<div><h1>{{screen.title}}</h1><strong v-if="progress" class="kbx-wms-progress">{{progress}}</strong></div>
|
||||
<div class="kbx-wms-header__tools" data-kbx-surface="network-state"><button v-if="helpAvailable" type="button" class="kbx-wms-help" @click="utilityHost?.open(screen,'help')">도움말</button><KbxNetworkIndicator :online="online" :pending-commands="pendingCommands ?? 0" :syncing="syncing ?? false" /></div>
|
||||
</header>
|
||||
<KbxDataState v-if="templateMismatch" class="kbx-wms-contract-error" state="error" title="현장 화면 구성이 올바르지 않습니다." detail="WMS Mobile 전용 화면 정의가 아니므로 작업 입력을 차단했습니다." />
|
||||
<KbxDataState v-else-if="permissionDenied" class="kbx-wms-contract-error" state="error" title="이 현장 업무를 사용할 권한이 없습니다." detail="현재 권한으로 작업 입력을 할 수 없습니다. 관리자에게 권한을 확인하세요." />
|
||||
<template v-else>
|
||||
<section v-if="taskContext || $slots.context" class="kbx-wms-context" aria-label="현재 작업" data-kbx-surface="task-context"><slot name="context">{{taskContext}}</slot></section>
|
||||
<section v-if="$slots.notice" class="kbx-wms-notice" aria-live="polite" data-kbx-surface="notice"><slot name="notice" /></section>
|
||||
<section class="kbx-wms-content" data-kbx-surface="instruction/content">
|
||||
<h2 v-if="instruction" class="kbx-wms-instruction">{{instruction}}</h2>
|
||||
<KbxTemplateStateBoundary :state="contentState" :refreshing="refreshing"><slot /></KbxTemplateStateBoundary>
|
||||
</section>
|
||||
<footer v-if="$slots.actions" class="kbx-wms-actions" aria-label="현재 작업 행동" data-kbx-surface="sticky-actions"><slot name="actions" /></footer>
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kbx-wms-page{min-height:100dvh;max-width:var(--kbx-wms-mobile-max-width);margin:0 auto;background:var(--kbx-color-surface);display:flex;flex-direction:column;color:var(--kbx-color-text)}.kbx-wms-header{min-height:var(--kbx-wms-header-min-height);padding:var(--kbx-space-3) var(--kbx-space-4);border-bottom:var(--kbx-border-width) solid var(--kbx-color-border);display:flex;align-items:center;justify-content:space-between;gap:var(--kbx-space-3);position:sticky;top:0;background:var(--kbx-color-surface);z-index:5}.kbx-wms-header h1{margin:0;font-size:var(--kbx-font-2xl);line-height:1.3}.kbx-wms-progress{display:block;margin-top:var(--kbx-space-1);font-size:var(--kbx-font-md);font-weight:600}.kbx-wms-context{min-height:var(--kbx-control-height);display:flex;align-items:center;padding:0 var(--kbx-space-4);border-bottom:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface-muted);font-size:var(--kbx-font-sm);font-weight:500}.kbx-wms-notice{padding:var(--kbx-space-2) var(--kbx-space-4) 0}.kbx-wms-content{flex:1;padding:var(--kbx-space-4)}.kbx-wms-instruction{margin:0 0 var(--kbx-space-3);font-size:var(--kbx-font-lg);font-weight:600}.kbx-wms-actions{position:sticky;bottom:0;padding:var(--kbx-space-3) var(--kbx-space-4) max(var(--kbx-space-3),env(safe-area-inset-bottom));border-top:var(--kbx-border-width) solid var(--kbx-color-border);background:var(--kbx-color-surface)}
|
||||
.kbx-wms-contract-error{margin:var(--kbx-space-4)}.kbx-wms-header__tools{display:flex;align-items:center;gap:var(--kbx-space-2)}.kbx-wms-help{min-height:var(--kbx-control-sm);padding:0 var(--kbx-space-2);border:var(--kbx-border-width) solid var(--kbx-color-border);border-radius:var(--kbx-radius-sm);background:var(--kbx-color-surface);font-size:var(--kbx-font-sm)}
|
||||
</style>
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { KbxBarcodeEvent, KbxBarcodeSource } from '@kbx/contracts'
|
||||
|
||||
export function normalizeKbxBarcode(rawValue:string,source:KbxBarcodeSource,minLength=3,maxLength=256,occurredAt=Date.now()):KbxBarcodeEvent|null{
|
||||
const normalizedValue=rawValue.trim()
|
||||
if(normalizedValue.length<minLength||normalizedValue.length>maxLength)return null
|
||||
return {rawValue,normalizedValue,source,occurredAt}
|
||||
}
|
||||
|
||||
export function isKbxBarcodeDuplicate(current:KbxBarcodeEvent,last:Pick<KbxBarcodeEvent,'normalizedValue'|'occurredAt'>|null,debounceMs:number){
|
||||
return Boolean(last&¤t.normalizedValue===last.normalizedValue&¤t.occurredAt-last.occurredAt<debounceMs)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
export type KbxWmsFeedbackKind = 'success' | 'warning' | 'error' | 'neutral'
|
||||
|
||||
function vibrate(pattern: number | number[]) {
|
||||
// Vibration is progressive enhancement: unsupported browsers simply skip it.
|
||||
if (typeof navigator !== 'undefined' && 'vibrate' in navigator) {
|
||||
navigator.vibrate(pattern)
|
||||
}
|
||||
}
|
||||
|
||||
function beep(frequency: number, durationMs: number) {
|
||||
if (typeof window === 'undefined') return
|
||||
const AudioContextCtor = window.AudioContext ?? (window as any).webkitAudioContext
|
||||
if (!AudioContextCtor) return
|
||||
|
||||
try {
|
||||
const context = new AudioContextCtor()
|
||||
const oscillator = context.createOscillator()
|
||||
const gain = context.createGain()
|
||||
oscillator.frequency.value = frequency
|
||||
gain.gain.value = 0.035
|
||||
oscillator.connect(gain)
|
||||
gain.connect(context.destination)
|
||||
oscillator.start()
|
||||
oscillator.stop(context.currentTime + durationMs / 1000)
|
||||
oscillator.addEventListener('ended', () => void context.close())
|
||||
} catch {
|
||||
// Sound is non-authoritative feedback. Never block WMS work because audio failed.
|
||||
}
|
||||
}
|
||||
|
||||
export function playKbxWmsFeedback(kind: KbxWmsFeedbackKind) {
|
||||
switch (kind) {
|
||||
case 'success':
|
||||
beep(880, 80)
|
||||
vibrate(50)
|
||||
break
|
||||
case 'warning':
|
||||
beep(520, 120)
|
||||
vibrate([70, 40, 70])
|
||||
break
|
||||
case 'error':
|
||||
beep(220, 180)
|
||||
vibrate([120, 60, 120])
|
||||
break
|
||||
default:
|
||||
beep(660, 60)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
export function useKbxNetworkState() {
|
||||
const online = ref(typeof navigator === 'undefined' ? true : navigator.onLine)
|
||||
|
||||
const refresh = () => {
|
||||
online.value = navigator.onLine
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('online', refresh)
|
||||
window.addEventListener('offline', refresh)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('online', refresh)
|
||||
window.removeEventListener('offline', refresh)
|
||||
})
|
||||
|
||||
return {
|
||||
online: computed(() => online.value),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user