This commit is contained in:
2026-08-26 16:58:41 +08:00
parent 083299602d
commit 76077369c2
14 changed files with 136 additions and 195 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
<view class="popup-header"> <view class="popup-header">
<view class="popup-header-spacer" aria-hidden="true"></view> <view class="popup-header-spacer" aria-hidden="true"></view>
<text class="popup-header-title" :class="{ 'popup-header-title-danger': tone === 'danger' }">{{ title }}</text> <text class="popup-header-title" :class="{ 'popup-header-title-danger': tone === 'danger' }">{{ title }}</text>
<button class="popup-header-close" :aria-label="`${$t('common.close')} ${title}`" @tap="$emit('close')"> <button class="popup-header-close" :aria-label="`关闭${title}`" @tap="$emit('close')">
<uni-icons type="closeempty" color="#52657c" size="24" /> <uni-icons type="closeempty" color="#52657c" size="24" />
</button> </button>
</view> </view>
+7 -7
View File
@@ -2,20 +2,20 @@
<view class="portal-header-shell"> <view class="portal-header-shell">
<view class="portal-header"> <view class="portal-header">
<view class="portal-header-row"> <view class="portal-header-row">
<button v-if="menu" class="portal-header-button" :aria-label="$t('nav.openMenu')" @tap="openMenu"><uni-icons type="bars" color="#ffffff" size="26" /></button> <button v-if="menu" class="portal-header-button" aria-label="打开功能菜单" @tap="openMenu"><uni-icons type="bars" color="#ffffff" size="26" /></button>
<button v-else class="portal-header-button" :aria-label="$t('nav.back')" @tap="goBack"><uni-icons type="back" color="#ffffff" size="26" /></button> <button v-else class="portal-header-button" aria-label="返回上一页" @tap="goBack"><uni-icons type="back" color="#ffffff" size="26" /></button>
<text class="portal-title">{{ title }}</text> <text class="portal-title">{{ title }}</text>
<button v-if="showProfile" class="avatar-button" :aria-label="$t('nav.profile')" @tap="goProfile"><uni-icons type="person" color="#ffffff" size="27" /></button> <button v-if="showProfile" class="avatar-button" aria-label="打开个人中心" @tap="goProfile"><uni-icons type="person" color="#ffffff" size="27" /></button>
<view v-else class="portal-header-spacer" aria-hidden="true"></view> <view v-else class="portal-header-spacer" aria-hidden="true"></view>
</view> </view>
</view> </view>
<uni-popup ref="menuPopup" type="bottom" background-color="#ffffff" border-radius="12px 12px 0 0" :is-mask-click="true" @change="handleMenuChange"> <uni-popup ref="menuPopup" type="bottom" background-color="#ffffff" border-radius="12px 12px 0 0" :is-mask-click="true" @change="handleMenuChange">
<view class="header-menu-sheet"> <view class="header-menu-sheet">
<view class="header-menu-content"> <view class="header-menu-content">
<PopupHeader :title="$t('nav.menu')" @close="closeMenu" /> <PopupHeader title="功能菜单" @close="closeMenu" />
<button class="header-menu-item" @tap="goHistory"><uni-icons type="list" color="#334155" size="22" /><text>{{ $t('nav.history') }}</text><uni-icons type="right" color="#94a3b8" size="18" /></button> <button class="header-menu-item" @tap="goHistory"><uni-icons type="list" color="#334155" size="22" /><text>作业记录</text><uni-icons type="right" color="#94a3b8" size="18" /></button>
<button class="header-menu-item" @tap="goProfile"><uni-icons type="person" color="#334155" size="22" /><text>{{ $t('nav.profile') }}</text><uni-icons type="right" color="#94a3b8" size="18" /></button> <button class="header-menu-item" @tap="goProfile"><uni-icons type="person" color="#334155" size="22" /><text>个人中心</text><uni-icons type="right" color="#94a3b8" size="18" /></button>
<button class="header-menu-close" @tap="closeMenu">{{ $t('common.close') }}</button> <button class="header-menu-close" @tap="closeMenu">关闭</button>
</view> </view>
</view> </view>
</uni-popup> </uni-popup>
+9 -9
View File
@@ -1,20 +1,20 @@
<template> <template>
<view class="receiving-summary"> <view class="receiving-summary">
<view class="receiving-summary-head"> <view class="receiving-summary-head">
<view><text class="receiving-summary-label">{{ $t('receivingSummary.container') }}</text><text class="receiving-summary-container">{{ session?.containerNo || '—' }}</text></view> <view><text class="receiving-summary-label">当前柜号</text><text class="receiving-summary-container">{{ session?.containerNo || '—' }}</text></view>
<text class="receiving-summary-status">{{ statusLabel }}</text> <text class="receiving-summary-status">{{ statusLabel }}</text>
</view> </view>
<view class="receiving-summary-context"> <view class="receiving-summary-context">
<text>{{ $t('receivingSummary.warehouse') }}:{{ session?.overseasWarehouseName || '—' }}</text> <text>收货仓:{{ session?.overseasWarehouseName || '—' }}</text>
<text>{{ $t('receivingSummary.destinations') }}:{{ destinationSummary }}</text> <text>目的仓:{{ destinationSummary }}</text>
</view> </view>
<view class="receiving-summary-metrics"> <view class="receiving-summary-metrics">
<view><text class="metric-value">{{ inboundCodeCount }}</text><text class="metric-label">{{ $t('receivingSummary.inboundCodes') }}</text></view> <view><text class="metric-value">{{ inboundCodeCount }}</text><text class="metric-label">入仓码</text></view>
<view><text class="metric-value">{{ plannedPalletCount }}</text><text class="metric-label">{{ $t('receivingSummary.plannedPallets') }}</text></view> <view><text class="metric-value">{{ plannedPalletCount }}</text><text class="metric-label">计划托数</text></view>
<view><text class="metric-value">{{ totalPackages }}</text><text class="metric-label">{{ $t('receivingSummary.plannedBoxes') }}</text></view> <view><text class="metric-value">{{ totalPackages }}</text><text class="metric-label">计划箱数</text></view>
<view><text class="metric-value metric-pending">{{ pendingPackages }}</text><text class="metric-label">{{ $t('receivingSummary.pendingBoxes') }}</text></view> <view><text class="metric-value metric-pending">{{ pendingPackages }}</text><text class="metric-label">待收箱数</text></view>
</view> </view>
<view class="receiving-summary-progress"><view><text>{{ $t('operation.receivingProgress') }}</text><text>{{ receivedPackages }} / {{ totalPackages }} {{ $t('common.boxes') }}</text></view><view class="progress-track"><view class="progress-fill" :style="{ width: percent + '%' }"></view></view></view> <view class="receiving-summary-progress"><view><text>收货进度</text><text>{{ receivedPackages }} / {{ totalPackages }} 箱</text></view><view class="progress-track"><view class="progress-fill" :style="{ width: percent + '%' }"></view></view></view>
</view> </view>
</template> </template>
@@ -29,7 +29,7 @@
receivedPackages() { return Math.max(0, Number(this.session?.receivedPackages || 0)) }, receivedPackages() { return Math.max(0, Number(this.session?.receivedPackages || 0)) },
pendingPackages() { return Math.max(0, Number(this.session?.pendingPackages ?? this.totalPackages - this.receivedPackages)) }, pendingPackages() { return Math.max(0, Number(this.session?.pendingPackages ?? this.totalPackages - this.receivedPackages)) },
percent() { return this.totalPackages ? Math.min(100, Math.round(this.receivedPackages / this.totalPackages * 100)) : 0 }, percent() { return this.totalPackages ? Math.min(100, Math.round(this.receivedPackages / this.totalPackages * 100)) : 0 },
statusLabel() { return this.$t(({ WAIT_SIGN: 'operation.statusWait', SIGNED: 'operation.statusSigned', RECEIVING: 'operation.statusReceiving', COMPLETED: 'operation.statusCompleted' })[this.session?.status] || 'operation.statusWait') } statusLabel() { return ({ WAIT_SIGN: '待签收', SIGNED: '已签收', RECEIVING: '收货中', COMPLETED: '已完成' })[this.session?.status] || '待签收' }
} }
} }
</script> </script>
+3 -3
View File
@@ -21,7 +21,7 @@
@input="handleInput" @input="handleInput"
@confirm="handleConfirm" @confirm="handleConfirm"
/> />
<text v-if="active" class="scan-target-badge">{{ $t('scanInput.active') }}</text> <text v-if="active" class="scan-target-badge">当前扫码</text>
</view> </view>
</template> </template>
@@ -31,8 +31,8 @@
props: { props: {
name: { type: String, required: true }, name: { type: String, required: true },
modelValue: { type: String, default: '' }, modelValue: { type: String, default: '' },
label: { type: String, default: '' }, label: { type: String, default: '扫码输入框' },
placeholder: { type: String, default: '' }, placeholder: { type: String, default: '扫描或人工输入' },
active: Boolean active: Boolean
}, },
methods: { methods: {
+2 -18
View File
@@ -1,18 +1,2 @@
export default { // Translation resources will be added after the overseas PDA copy and workflows are stable.
receivingSummary: { container: 'Current container', warehouse: 'Warehouse', destinations: 'Destinations', inboundCodes: 'Inbound codes', plannedPallets: 'Planned pallets', plannedBoxes: 'Planned cartons', pendingBoxes: 'Pending cartons' }, export default {}
scanInput: { active: 'Scan target', label: 'Scan input', placeholder: 'Scan or enter manually' },
operationPhase: { candidates: { title: 'Containers awaiting unload', subtitle: 'Select a container and take 3 photos to sign it in' }, detail: { title: 'Container details', subtitle: 'Verify the container and total planned cartons' }, sign: { title: 'Photo sign-off', subtitle: 'Capture on-site proof before receiving' }, receive: { title: 'Current container', subtitle: 'Scan a carton to identify its inbound code' }, completed: { title: 'Inbound completed', subtitle: 'Inventory and container totals are reconciled by the server' } },
common: { close: 'Close', cancel: 'Cancel', confirm: 'Confirm', refresh: 'Refresh', back: 'Back', loading: 'Loading…', boxes: 'ctns', pallets: 'pallets', plans: 'plans', inboundCodes: 'inbound codes', available: 'Ready', unavailable: 'Unavailable', required: 'Required', uploaded: 'Uploaded', retry: 'Try again', currentWarehouse: 'Current warehouse', warehouseFallback: 'Assigned automatically to this account', warehouseOperator: 'Warehouse operator' },
app: { title: 'Baiyue Logistics Overseas', workbench: 'Overseas Warehouse', subtitle: 'Warehouse Operations System' },
login: { username: 'Employee ID / Username', usernamePlaceholder: 'Enter your employee ID', password: 'Password', passwordPlaceholder: 'Enter your password', submit: 'Sign in', submitting: 'Signing in...', usernameRequired: 'Enter your employee ID', passwordRequired: 'Enter your password', offline: 'No network connection. Connect and try again.', failed: 'Sign-in failed' },
nav: { menu: 'Menu', history: 'Work history', profile: 'Profile', openMenu: 'Open menu', back: 'Go back' },
home: { eyebrow: 'OVERSEAS WAREHOUSE', actionable: 'ready', night: 'Stay safe on the night shift', morning: 'Good morning', afternoon: 'Good afternoon', evening: 'Good evening' },
module: { title: 'Operations', functions: 'Functions', hint: 'Tap an available function to begin', availability: '{available}/{total} ready', onlyReceiving: 'Only container receiving is available. Other operations are not yet open.' },
modules: { inbound: { name: 'Inbound', description: 'Lock one container per account and complete receiving' }, inventory: { name: 'Warehouse', description: 'Stocktake, moves, and pallet operations' }, relabel: { name: 'Relabeling', description: 'Receive, execute, and review relabel work' }, query: { name: 'Lookup', description: 'Inventory, location, and pallet lookup' }, receiving: { name: 'Container receiving', description: 'Capture three photos; receive FBA by count and overseas cartons individually' }, stocktake: { name: 'Stocktake', description: 'Scan locations and stock by task' }, move: { name: 'Stock move', description: 'Scan source and destination locations' }, pallet: { name: 'Pallet management', description: 'Build, split, and verify pallets' }, relabelReceive: { name: 'Relabel receiving', description: 'Scan a relabel order and confirm stock' }, relabelExecute: { name: 'Relabel execution', description: 'Scan old and new labels' }, relabelReview: { name: 'Relabel review', description: 'Verify labels and product links' }, inventoryQuery: { name: 'Inventory lookup', description: 'Scan an item or carton to view stock' }, locationQuery: { name: 'Location lookup', description: 'Scan a location to view capacity and stock' }, palletQuery: { name: 'Pallet lookup', description: 'Scan a pallet to view linked stock' } },
profile: { title: 'Profile', warehouse: 'Work warehouse', warehousePending: 'Loading warehouse profile', shift: 'Current shift', shiftValue: 'Based on the warehouse roster', version: 'App version', language: 'Language', settings: 'Settings', settingsLater: 'Settings will be available in a future version', logout: 'Sign out', logoutTitle: 'Sign out?', logoutDescription: 'Signing out clears the local overseas PDA session. Unsubmitted work cannot be retained across accounts.' },
language: { zh: '中文', en: 'English', es: 'Español' },
receivingSummary: { container: 'Current container', inboundCodes: 'Inbound codes', plannedBoxes: 'Planned cartons', pendingBoxes: 'Pending cartons' },
history: { title: 'Work history', completedToday: 'Completed today', inProgress: 'In progress', recent: 'Recent work', emptyTitle: 'No overseas warehouse records', emptyDescription: 'Completed work will appear here when live task history is connected.' },
scanner: { title: 'Scanner broadcast test', waitingTitle: 'Receiver started', waitingDetail: 'Waiting for a hardware scan', receivedTitle: 'Scan broadcast received', receivedDetail: 'Scan again to verify continuous reception', errorTitle: 'Receiver failed to start', errorDetail: 'Check the PDA scanner settings and reopen this page', unsupportedTitle: 'Broadcast not supported here', unsupportedDetail: 'Test on an Android PDA app', latest: 'Latest value', waiting: 'Waiting for scan', hardwareHint: 'Press the PDA hardware scan key', count: 'Scan {count} on this page', extra: 'Extra key', records: 'Received scans', clear: 'Clear', empty: 'No broadcast data' },
operation: { title: 'Container receiving', restoring: 'Restoring the current container…', scanContainer: 'Scan container number', container: 'Container number', containerPlaceholder: 'Scan or enter a container number', queryContainer: 'Find container', pendingContainers: '{count} containers awaiting unload', emptyContainers: 'No containers awaiting unload', destination: 'Destination: {value}', receivingProgress: 'Receiving progress', continueCurrent: 'Continue current container', viewDetail: 'View details', lockedNotice: 'After selection, this account is locked to the container until completion.', backList: 'Back to list', selectContainer: 'Select container and take photos', unloadPhotos: 'Unload proof photos', photoHint: 'Take one photo for each of the three required slots', notAdded: 'Not added', uploading: 'Uploading…', retakeFailedKept: 'Retake failed; original kept', uploadFailed: 'Upload failed. Try again.', previewPhoto: 'Tap the photo to preview', takePhotoHint: 'Take an on-site photo', retake: 'Retake', takePhoto: 'Take photo', signAndReceive: 'Confirm arrival and start receiving', scanCartonStep: '1. Scan a carton to identify the inbound code', carton: 'Carton mark', parseCarton: 'Resolve carton', fbaMode: 'FBA receiving by carton count', overseasMode: 'Overseas receiving by carton', totalBoxes: '{count} cartons total', receivedBoxes: '{count} received', pendingBoxes: '{count} pending', scanPalletStep: '2. Scan the physical pallet', pallet: 'Pallet code', palletPlaceholder: 'Scan or enter a pallet code', verifyPallet: 'Verify pallet', palletMatched: 'Destination matches; ready to receive', palletMismatch: 'Pallet does not match', inputCountStep: '3. Enter carton count', maxCount: 'Up to {count} cartons', confirmBulk: 'Confirm carton-count receipt', confirmCarton: 'Confirm this carton', receiptSuccess: '{accepted} received; {inbound} has {pending} remaining', completeReady: 'All inbound codes reconciled — complete container', incomplete: '{count} cartons still pending', completedTitle: 'Container receiving completed', actualReceipt: '{received} / {total} cartons received', returnPending: 'Return to pending containers', unavailableSubtitle: 'This function is not available yet', unavailableTitle: 'Function unavailable', unavailableDescription: 'Warehouse operators cannot call platform, customer, or internal APIs. Scans are not submitted and no simulated success is produced.', lockedContainer: 'Container locked to this account', inbounds: 'Inbound codes in container', plannedPallets: '{count} planned pallets', photoLicense: 'License plate', photoSeal: 'Container seal', photoDoor: 'Open-door view', statusWait: 'Awaiting sign-off', statusSigned: 'Signed', statusReceiving: 'Receiving', statusCompleted: 'Completed', actionSuccess: '{action} succeeded', actionFailed: '{action} failed', photoLoadFailed: 'Photo failed to load', scanUnavailable: 'This function has no dedicated overseas PDA API; scan not submitted', currentConflict: 'Account already has unfinished container {container}; returned to current work', actions: { refresh: 'Refresh pending containers', query: 'Find container', select: 'Select container', sign: 'Sign container', carton: 'Resolve carton', sku: 'Resolve SKU', pallet: 'Verify pallet', receipt: 'Submit receipt', complete: 'Complete receiving' } }
}
+2 -18
View File
@@ -1,18 +1,2 @@
export default { // Translation resources will be added after the overseas PDA copy and workflows are stable.
receivingSummary: { container: 'Contenedor actual', inboundCodes: 'Códigos de entrada', plannedBoxes: 'Cajas planificadas', pendingBoxes: 'Cajas pendientes' }, export default {}
scanInput: { active: 'Destino del escáner', label: 'Entrada de escaneo', placeholder: 'Escanee o ingrese manualmente' },
operationPhase: { candidates: { title: 'Contenedores por descargar', subtitle: 'Seleccione un contenedor y tome 3 fotos para firmar' }, detail: { title: 'Detalles del contenedor', subtitle: 'Verifique el contenedor y el total de cajas planificadas' }, sign: { title: 'Firma con fotos', subtitle: 'Capture la evidencia antes de recibir' }, receive: { title: 'Contenedor actual', subtitle: 'Escanee una caja para identificar su entrada' }, completed: { title: 'Entrada completada', subtitle: 'El servidor ha conciliado el inventario y el contenedor' } },
common: { close: 'Cerrar', cancel: 'Cancelar', confirm: 'Confirmar', refresh: 'Actualizar', back: 'Volver', loading: 'Cargando…', boxes: 'cajas', pallets: 'palés', plans: 'planes', inboundCodes: 'códigos de entrada', available: 'Disponible', unavailable: 'No disponible', required: 'Obligatoria', uploaded: 'Cargada', retry: 'Reintentar', currentWarehouse: 'Almacén actual', warehouseFallback: 'Asignado automáticamente a esta cuenta', warehouseOperator: 'Operario de almacén' },
app: { title: 'Baiyue Logistics Internacional', workbench: 'Almacén internacional', subtitle: 'Sistema de operaciones de almacén' },
login: { username: 'ID de empleado / Usuario', usernamePlaceholder: 'Ingrese su ID de empleado', password: 'Contraseña', passwordPlaceholder: 'Ingrese su contraseña', submit: 'Iniciar sesión', submitting: 'Iniciando sesión...', usernameRequired: 'Ingrese su ID de empleado', passwordRequired: 'Ingrese su contraseña', offline: 'Sin conexión. Conéctese e inténtelo de nuevo.', failed: 'Error al iniciar sesión' },
nav: { menu: 'Menú', history: 'Historial de trabajo', profile: 'Perfil', openMenu: 'Abrir menú', back: 'Volver' },
home: { eyebrow: 'ALMACÉN INTERNACIONAL', actionable: 'disponible', night: 'Trabaje con seguridad en el turno nocturno', morning: 'Buenos días', afternoon: 'Buenas tardes', evening: 'Buenas noches' },
module: { title: 'Operaciones', functions: 'Funciones', hint: 'Toque una función disponible para comenzar', availability: '{available}/{total} disponibles', onlyReceiving: 'Solo está disponible la recepción de contenedores. Las demás operaciones aún no están abiertas.' },
modules: { inbound: { name: 'Entrada', description: 'Bloquee un contenedor por cuenta y complete la recepción' }, inventory: { name: 'Operaciones internas', description: 'Inventario, movimientos y operaciones de palés' }, relabel: { name: 'Reetiquetado', description: 'Recepción, ejecución y revisión de reetiquetado' }, query: { name: 'Consultas', description: 'Consulta de inventario, ubicaciones y palés' }, receiving: { name: 'Recepción de contenedor', description: 'Tres fotos; FBA por cantidad y cajas internacionales una a una' }, stocktake: { name: 'Conteo de inventario', description: 'Escanee ubicaciones e inventario por tarea' }, move: { name: 'Mover mercancía', description: 'Escanee ubicaciones de origen y destino' }, pallet: { name: 'Gestión de palés', description: 'Agrupe, divida y verifique palés' }, relabelReceive: { name: 'Recepción para reetiquetar', description: 'Escanee la orden y confirme el inventario' }, relabelExecute: { name: 'Ejecutar reetiquetado', description: 'Escanee etiquetas antiguas y nuevas' }, relabelReview: { name: 'Revisar reetiquetado', description: 'Verifique etiquetas y productos' }, inventoryQuery: { name: 'Consultar inventario', description: 'Escanee un artículo o una caja' }, locationQuery: { name: 'Consultar ubicación', description: 'Escanee una ubicación para ver capacidad e inventario' }, palletQuery: { name: 'Consultar palé', description: 'Escanee un palé para ver el inventario vinculado' } },
profile: { title: 'Perfil', warehouse: 'Almacén de trabajo', warehousePending: 'Cargando datos del almacén', shift: 'Turno actual', shiftValue: 'Según el horario del almacén', version: 'Versión de la aplicación', language: 'Idioma', settings: 'Configuración', settingsLater: 'La configuración estará disponible en una versión futura', logout: 'Cerrar sesión', logoutTitle: '¿Cerrar sesión?', logoutDescription: 'Al salir se borra la sesión local del PDA internacional. El trabajo sin enviar no puede conservarse entre cuentas.' },
language: { zh: '中文', en: 'English', es: 'Español' },
receivingSummary: { container: 'Contenedor actual', warehouse: 'Almacén', destinations: 'Destinos', inboundCodes: 'Códigos de entrada', plannedPallets: 'Palés previstos', plannedBoxes: 'Cajas previstas', pendingBoxes: 'Cajas pendientes' },
history: { title: 'Historial de trabajo', completedToday: 'Completado hoy', inProgress: 'En curso', recent: 'Trabajo reciente', emptyTitle: 'Sin registros de almacén', emptyDescription: 'El trabajo completado aparecerá aquí cuando se conecte el historial real.' },
scanner: { title: 'Prueba de difusión del escáner', waitingTitle: 'Receptor iniciado', waitingDetail: 'Esperando un escaneo físico', receivedTitle: 'Escaneo recibido', receivedDetail: 'Escanee de nuevo para verificar la recepción continua', errorTitle: 'No se pudo iniciar el receptor', errorDetail: 'Revise la configuración del escáner PDA y vuelva a abrir la página', unsupportedTitle: 'Difusión no compatible aquí', unsupportedDetail: 'Pruebe en la aplicación Android del PDA', latest: 'Último valor', waiting: 'Esperando escaneo', hardwareHint: 'Pulse la tecla física de escaneo', count: 'Escaneo {count} en esta página', extra: 'Clave Extra', records: 'Escaneos recibidos', clear: 'Borrar', empty: 'Sin datos de difusión' },
operation: { title: 'Recepción de contenedor', restoring: 'Restaurando el contenedor actual…', scanContainer: 'Escanear número de contenedor', container: 'Número de contenedor', containerPlaceholder: 'Escanee o ingrese el número', queryContainer: 'Buscar contenedor', pendingContainers: '{count} contenedores por descargar', emptyContainers: 'No hay contenedores por descargar', destination: 'Destino: {value}', receivingProgress: 'Progreso de recepción', continueCurrent: 'Continuar contenedor actual', viewDetail: 'Ver detalles', lockedNotice: 'Al seleccionarlo, la cuenta queda bloqueada al contenedor hasta finalizar.', backList: 'Volver a la lista', selectContainer: 'Seleccionar y tomar fotos', unloadPhotos: 'Fotos de recepción', photoHint: 'Tome una foto en cada una de las tres posiciones obligatorias', notAdded: 'Sin foto', uploading: 'Cargando…', retakeFailedKept: 'Falló la nueva foto; se conserva la anterior', uploadFailed: 'Error de carga. Reintente.', previewPhoto: 'Toque la foto para ampliarla', takePhotoHint: 'Tome una foto en el lugar', retake: 'Repetir', takePhoto: 'Tomar foto', signAndReceive: 'Confirmar llegada e iniciar recepción', scanCartonStep: '1. Escanee una caja para identificar la entrada', carton: 'Marca de caja', parseCarton: 'Resolver caja', fbaMode: 'Recepción FBA por cantidad', overseasMode: 'Recepción internacional por caja', totalBoxes: '{count} cajas en total', receivedBoxes: '{count} recibidas', pendingBoxes: '{count} pendientes', scanPalletStep: '2. Escanee el palé físico', pallet: 'Código de palé', palletPlaceholder: 'Escanee o ingrese el código', verifyPallet: 'Verificar palé', palletMatched: 'El destino coincide; listo para recibir', palletMismatch: 'El palé no coincide', inputCountStep: '3. Ingrese la cantidad de cajas', maxCount: 'Máximo {count} cajas', confirmBulk: 'Confirmar recepción por cantidad', confirmCarton: 'Confirmar esta caja', receiptSuccess: '{accepted} recibidas; quedan {pending} en {inbound}', completeReady: 'Todas las entradas conciliadas — completar contenedor', incomplete: 'Quedan {count} cajas', completedTitle: 'Recepción del contenedor completada', actualReceipt: '{received} / {total} cajas recibidas', returnPending: 'Volver a contenedores pendientes', unavailableSubtitle: 'Esta función aún no está disponible', unavailableTitle: 'Función no disponible', unavailableDescription: 'Los operarios no pueden llamar API de plataforma, cliente o internas. Los escaneos no se envían ni generan resultados simulados.', lockedContainer: 'Contenedor bloqueado para esta cuenta', inbounds: 'Códigos de entrada del contenedor', plannedPallets: '{count} palés planificados', photoLicense: 'Matrícula del vehículo', photoSeal: 'Sello del contenedor', photoDoor: 'Puertas abiertas', statusWait: 'Pendiente de firma', statusSigned: 'Firmado', statusReceiving: 'Recibiendo', statusCompleted: 'Completado', actionSuccess: '{action} completado', actionFailed: 'Error: {action}', photoLoadFailed: 'No se pudo cargar la foto', scanUnavailable: 'Esta función no tiene API PDA internacional; escaneo no enviado', currentConflict: 'La cuenta ya tiene el contenedor {container} sin terminar; se volvió al trabajo actual', actions: { refresh: 'Actualizar pendientes', query: 'Buscar contenedor', select: 'Seleccionar contenedor', sign: 'Firmar contenedor', carton: 'Resolver caja', sku: 'Resolver SKU', pallet: 'Verificar palé', receipt: 'Enviar recepción', complete: 'Completar recepción' } }
}
+2 -23
View File
@@ -1,23 +1,2 @@
export default { // Translation resources will be added after the overseas PDA copy and workflows are stable.
receivingSummary: { container: '当前柜号', warehouse: '作业仓', destinations: '目的仓', inboundCodes: '入仓码', plannedPallets: '计划托盘', plannedBoxes: '计划箱数', pendingBoxes: '待收箱数' }, export default {}
scanInput: { active: '当前扫码', label: '扫码输入框', placeholder: '扫描或人工输入' },
operationPhase: { candidates: { title: '待卸货集装箱', subtitle: '选择集装箱后拍摄 3 张照片完成签收' }, detail: { title: '柜号详情', subtitle: '核对柜号与整柜计划箱数' }, sign: { title: '拆柜拍照签收', subtitle: '完成现场取证后进入收货' }, receive: { title: '当前柜收货', subtitle: '扫描箱码定位入仓码后开始收货' }, completed: { title: '入库完成', subtitle: '库存与柜级进度已由服务端核平' } },
common: { close: '关闭', cancel: '取消', confirm: '确认', refresh: '刷新', back: '返回', loading: '加载中…', boxes: '箱', pallets: '托', plans: '个计划', inboundCodes: '个入仓码', available: '可处理', unavailable: '暂未开放', required: '必传', uploaded: '已上传', retry: '请重试', currentWarehouse: '当前作业仓', warehouseFallback: '仓库由当前账号自动绑定', warehouseOperator: '仓库作业员' },
app: { title: '柏悦物流海外版', workbench: '海外仓工作台', subtitle: '海外仓现场作业系统' },
login: { username: '工号 / 用户名', usernamePlaceholder: '请输入工号', password: '密码', passwordPlaceholder: '请输入密码', submit: '登录', submitting: '登录中...', usernameRequired: '请输入工号', passwordRequired: '请输入密码', offline: '当前网络不可用,请联网后重试', failed: '登录失败' },
nav: { menu: '功能菜单', history: '作业记录', profile: '个人中心', openMenu: '打开功能菜单', back: '返回上一页' },
home: { eyebrow: 'OVERSEAS WAREHOUSE', actionable: '项可处理', night: '夜间作业请注意安全', morning: '早上好', afternoon: '下午好', evening: '晚上好' },
module: { title: '业务模块', functions: '业务功能', hint: '点击可用功能开始作业', availability: '{available}/{total} 可用', onlyReceiving: '当前仅开放拆柜收货,其他作业暂未开放。' },
modules: {
inbound: { name: '入仓作业', description: '单账号锁定一柜,完成柜号级入库闭环' }, inventory: { name: '库内作业', description: '盘点、移库与托盘管理' }, relabel: { name: '换标作业', description: '换标收货、执行与复核' }, query: { name: '现场查询', description: '库存、库位与托盘查询' },
receiving: { name: '拆柜收货', description: '三类照片签收,FBA 按 SKU 箱数、海外仓逐箱收货' }, stocktake: { name: '库存盘点', description: '按任务扫描库位和库存' }, move: { name: '商品移库', description: '扫描来源与目标库位完成移库' }, pallet: { name: '托盘管理', description: '组托、拆托及托盘状态核对' }, relabelReceive: { name: '换标收货', description: '扫描换标单并确认待处理库存' }, relabelExecute: { name: '换标执行', description: '扫描旧标和新标记录换标结果' }, relabelReview: { name: '换标复核', description: '复核标签和商品关联关系' }, inventoryQuery: { name: '库存查询', description: '扫描商品或箱码查询可用库存' }, locationQuery: { name: '库位查询', description: '扫描库位查看容量和在库明细' }, palletQuery: { name: '托盘查询', description: '扫描托盘码查看绑定库存' }
},
profile: { title: '个人中心', warehouse: '作业仓库', warehousePending: '正在读取仓库主档', shift: '当前班次', shiftValue: '以海外仓排班为准', version: '应用版本', language: '语言', settings: '系统设置', settingsLater: '设置功能将在后续版本开放', logout: '退出登录', logoutTitle: '确认退出登录?', logoutDescription: '退出后会清空海外 PDA 的本地登录会话;未提交作业数据不得跨账号保留。' },
language: { zh: '中文', en: 'English', es: 'Español' },
receivingSummary: { container: '当前柜号', inboundCodes: '入仓码', plannedBoxes: '计划箱数', pendingBoxes: '待收箱数' },
history: { title: '作业记录', completedToday: '今日完成', inProgress: '进行中', recent: '最近作业', emptyTitle: '暂无海外仓作业记录', emptyDescription: '接入海外仓真实任务后,完成记录会显示在这里' },
scanner: { title: '广播扫码测试', waitingTitle: '接收器已启动', waitingDetail: '正在等待硬件扫描结果', receivedTitle: '已收到扫描广播', receivedDetail: '继续扫描可验证连续接收', errorTitle: '接收器启动失败', errorDetail: '请检查 PDA 扫描服务设置后重新进入页面', unsupportedTitle: '当前环境不支持广播', unsupportedDetail: '请在 Android App 真机中测试', latest: '最近收到', waiting: '等待扫描', hardwareHint: '按下 PDA 硬件扫描键', count: '本页第 {count} 次', extra: 'Extra 标签', records: '接收记录', clear: '清空', empty: '暂无广播数据' },
operation: {
title: '拆柜收货', restoring: '正在恢复当前柜作业…', scanContainer: '扫描柜号', container: '柜号', containerPlaceholder: '扫描或人工输入柜号', queryContainer: '查询柜号', pendingContainers: '{count} 个待卸货集装箱', emptyContainers: '暂无待卸货集装箱', destination: '目的仓:{value}', receivingProgress: '收货进度', continueCurrent: '继续当前柜', viewDetail: '查看详情', lockedNotice: '选择后账号将锁定当前柜,完成前不能切换其他柜号。', backList: '返回列表', selectContainer: '选择此柜并开始拍照', unloadPhotos: '卸货签收照片', photoHint: '三个固定槽位各拍摄 1 张现场照片', notAdded: '未添加', uploading: '正在上传…', retakeFailedKept: '重拍失败,已保留原图', uploadFailed: '上传失败,请重试', previewPhoto: '点击左侧照片可查看大图', takePhotoHint: '请拍摄现场照片', retake: '重拍', takePhoto: '拍照', signAndReceive: '确认签收并开始收货', scanCartonStep: '1. 扫描箱码定位入仓码', carton: '箱码', parseCarton: '解析箱码', fbaMode: 'FBA 按箱数收货', overseasMode: '海外仓逐箱收货', totalBoxes: '总计 {count} 箱', receivedBoxes: '已收 {count} 箱', pendingBoxes: '待收 {count} 箱', scanPalletStep: '2. 扫描实物托盘码', pallet: '托盘码', palletPlaceholder: '扫描或人工输入托盘码', verifyPallet: '校验托盘', palletMatched: '目的仓匹配,可收货', palletMismatch: '托盘不匹配', inputCountStep: '3. 输入本次箱数', maxCount: '最多可收 {count} 箱', confirmBulk: '确认按箱数收货', confirmCarton: '确认本箱收货', receiptSuccess: '本次已收 {accepted} 箱,{inbound} 剩余 {pending} 箱', completeReady: '每个入仓码已核平,完成整柜入库', incomplete: '尚有 {count} 箱未收', completedTitle: '当前柜已完成入库', actualReceipt: '实收 {received} / {total} 箱', returnPending: '返回待卸货列表', unavailableSubtitle: '当前功能暂未开放', unavailableTitle: '功能暂未开放', unavailableDescription: '现有平台、客户或内部接口不能由仓库作业员直接调用。扫码不会提交,也不会产生模拟成功结果。', lockedContainer: '当前账号锁定柜号', inbounds: '柜内入仓码', plannedPallets: '计划 {count} 托', photoLicense: '车牌照片', photoSeal: '集装箱封签', photoDoor: '开柜现场', statusWait: '待签收', statusSigned: '已签收', statusReceiving: '收货中', statusCompleted: '已完成', actionSuccess: '{action}成功', actionFailed: '{action}失败', photoLoadFailed: '照片加载失败', scanUnavailable: '该功能缺少海外 PDA 专用接口,扫码未提交', currentConflict: '账号已有未完成柜 {container},已返回当前柜', actions: { refresh: '刷新待卸柜', query: '查询柜号', select: '选择当前柜', sign: '签收当前柜', carton: '解析箱唛', sku: '解析 SKU', pallet: '校验托盘', receipt: '提交实收', complete: '完成入库' }
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
<template><view v-if="user" class="page-shell portal-page"><PortalHeader :title="$t('history.title')" :user="user" /><view class="portal-content"><view class="summary-card"><view><text class="summary-number">0</text><text class="summary-label">{{ $t('history.completedToday') }}</text></view><view><text class="summary-number pending-color">0</text><text class="summary-label">{{ $t('history.inProgress') }}</text></view></view><text class="group-title">{{ $t('history.recent') }}</text><view class="history-empty"><text class="history-empty-icon">▤</text><text class="history-empty-title">{{ $t('history.emptyTitle') }}</text><text class="history-empty-desc">{{ $t('history.emptyDescription') }}</text></view></view></view></template> <template><view v-if="user" class="page-shell portal-page"><PortalHeader title="作业记录" :user="user" /><view class="portal-content"><view class="summary-card"><view><text class="summary-number">0</text><text class="summary-label">今日完成</text></view><view><text class="summary-number pending-color">0</text><text class="summary-label">进行中</text></view></view><text class="group-title">最近作业</text><view class="history-empty"><text class="history-empty-icon">▤</text><text class="history-empty-title">暂无海外仓作业记录</text><text class="history-empty-desc">接入海外仓真实任务后,完成记录会显示在这里</text></view></view></view></template>
<script> <script>
import PortalHeader from '@/components/PortalHeader.vue'; import { session, requireSession } from '@/stores/session.js' import PortalHeader from '@/components/PortalHeader.vue'; import { session, requireSession } from '@/stores/session.js'
export default { components: { PortalHeader }, data() { return { user: session.currentUser } }, onShow() { if (!requireSession()) return; this.user = session.currentUser } } export default { components: { PortalHeader }, data() { return { user: session.currentUser } }, onShow() { if (!requireSession()) return; this.user = session.currentUser } }
+14 -14
View File
@@ -1,23 +1,23 @@
<template> <template>
<view v-if="user" class="page-shell portal-page home-page"> <view v-if="user" class="page-shell portal-page home-page">
<PortalHeader ref="portalHeader" :title="$t('app.workbench')" :user="user" menu @menu-change="menuOpen = $event" /> <PortalHeader ref="portalHeader" title="海外仓工作台" :user="user" menu @menu-change="menuOpen = $event" />
<view class="home-menu"> <view class="home-menu">
<view class="home-overview"> <view class="home-overview">
<view class="home-overview-copy"> <view class="home-overview-copy">
<text class="home-overview-eyebrow">{{ $t('home.eyebrow') }}</text> <text class="home-overview-eyebrow">OVERSEAS WAREHOUSE</text>
<text class="home-overview-title">{{ greeting }},{{ displayName }}</text> <text class="home-overview-title">{{ greeting }},{{ displayName }}</text>
<text class="home-overview-warehouse">{{ warehouseLabel }}</text> <text class="home-overview-warehouse">{{ warehouseLabel }}</text>
</view> </view>
<view class="home-overview-status"><text class="home-overview-status-value">{{ availableCount }}</text><text class="home-overview-status-label">{{ $t('home.actionable') }}</text></view> <view class="home-overview-status"><text class="home-overview-status-value">{{ availableCount }}</text><text class="home-overview-status-label">项可处理</text></view>
</view> </view>
<view v-for="group in groups" :key="group.key" class="home-group"> <view v-for="group in groups" :key="group.key" class="home-group">
<view class="home-group-title"><view class="home-group-mark" :class="`home-group-mark-${group.key}`"></view><text>{{ $t(group.nameKey) }}</text><text class="home-group-count">{{ group.operations.length }}</text></view> <view class="home-group-title"><view class="home-group-mark" :class="`home-group-mark-${group.key}`"></view><text>{{ group.name }}</text><text class="home-group-count">{{ group.operations.length }}</text></view>
<view class="home-operation-grid"> <view class="home-operation-grid">
<button v-for="operation in group.operations" :key="operation.name" class="home-operation" :class="{ 'home-operation-primary': operation.available, 'home-operation-disabled': !operation.available }" :disabled="!operation.available" @tap="openOperation(operation)"> <button v-for="operation in group.operations" :key="operation.name" class="home-operation" :class="{ 'home-operation-primary': operation.available, 'home-operation-disabled': !operation.available }" :disabled="!operation.available" @tap="openOperation(operation)">
<view class="home-operation-icon" aria-hidden="true"><uni-icons :type="operation.iconType" :color="operation.available ? '#135ee8' : '#9aa6b2'" size="28" /></view> <view class="home-operation-icon" aria-hidden="true"><uni-icons :type="operation.iconType" :color="operation.available ? '#135ee8' : '#9aa6b2'" size="28" /></view>
<text class="home-operation-name">{{ $t(operation.nameKey) }}</text> <text class="home-operation-name">{{ operation.name }}</text>
<text v-if="operation.available" class="home-operation-pending">{{ $t('common.available') }}</text> <text v-if="operation.available" class="home-operation-pending">可处理</text>
<text v-else class="home-operation-unavailable">{{ $t('common.unavailable') }}</text> <text v-else class="home-operation-unavailable">暂未开放</text>
</button> </button>
</view> </view>
</view> </view>
@@ -44,20 +44,20 @@
availableCount() { return this.groups.reduce((total, group) => total + group.operations.filter((operation) => operation.available).length, 0) }, availableCount() { return this.groups.reduce((total, group) => total + group.operations.filter((operation) => operation.available).length, 0) },
displayName() { displayName() {
const name = String(this.user?.name || '').trim() const name = String(this.user?.name || '').trim()
return name && !/^[a-z0-9._-]+$/i.test(name) ? name : this.$t('common.warehouseOperator') return name && !/^[a-z0-9._-]+$/i.test(name) ? name : '仓库作业员'
}, },
warehouseLabel() { warehouseLabel() {
if (this.user?.warehouseName) return this.user.warehouseCode ? `${this.user.warehouseName} · ${this.user.warehouseCode}` : this.user.warehouseName if (this.user?.warehouseName) return this.user.warehouseCode ? `${this.user.warehouseName} · ${this.user.warehouseCode}` : this.user.warehouseName
const warehouseId = String(this.user?.warehouseId || '') const warehouseId = String(this.user?.warehouseId || '')
if (warehouseId) return `${this.$t('common.currentWarehouse')} · ${warehouseId.slice(-6)}` if (warehouseId) return `当前作业仓 · ${warehouseId.slice(-6)}`
return this.user?.warehouse || this.$t('common.warehouseFallback') return this.user?.warehouse || '仓库由当前账号自动绑定'
}, },
greeting() { greeting() {
const hour = new Date().getHours() const hour = new Date().getHours()
if (hour < 6) return this.$t('home.night') if (hour < 6) return '夜间作业请注意安全'
if (hour < 12) return this.$t('home.morning') if (hour < 12) return '早上好'
if (hour < 18) return this.$t('home.afternoon') if (hour < 18) return '下午好'
return this.$t('home.evening') return '晚上好'
} }
}, },
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.loadWarehouseProfile() }, onShow() { if (!requireSession()) return; this.user = session.currentUser; this.loadWarehouseProfile() },
+8 -8
View File
@@ -2,13 +2,13 @@
<view class="page-shell login-page"> <view class="page-shell login-page">
<view class="login-brand"> <view class="login-brand">
<image class="login-logo-icon" src="/static/brand-mark-white.png" mode="aspectFit" aria-hidden="true" /> <image class="login-logo-icon" src="/static/brand-mark-white.png" mode="aspectFit" aria-hidden="true" />
<text class="login-logo-title">{{ $t('app.title') }}</text><text class="login-logo-sub">{{ $t('app.subtitle') }}</text> <text class="login-logo-title">柏悦物流海外版</text><text class="login-logo-sub">海外仓现场作业系统</text>
</view> </view>
<view class="login-card"> <view class="login-card">
<view class="login-field"><text class="login-field-label">{{ $t('login.username') }}</text><input v-model="form.userId" class="login-field-input" :aria-label="$t('login.username')" :placeholder="$t('login.usernamePlaceholder')" confirm-type="next" @focus="error = ''" /></view> <view class="login-field"><text class="login-field-label">工号 / 用户名</text><input v-model="form.userId" class="login-field-input" aria-label="工号 / 用户名" placeholder="请输入工号" confirm-type="next" @focus="error = ''" /></view>
<view class="login-field"><text class="login-field-label">{{ $t('login.password') }}</text><input v-model="form.password" class="login-field-input" :aria-label="$t('login.password')" type="password" :placeholder="$t('login.passwordPlaceholder')" confirm-type="done" @focus="error = ''" @confirm="login" /></view> <view class="login-field"><text class="login-field-label">密码</text><input v-model="form.password" class="login-field-input" aria-label="密码" type="password" placeholder="请输入密码" confirm-type="done" @focus="error = ''" @confirm="login" /></view>
<text v-if="error" class="login-error" role="alert">{{ error }}</text> <text v-if="error" class="login-error" role="alert">{{ error }}</text>
<button class="login-btn" :disabled="submitting" @tap="login">{{ submitting ? $t('login.submitting') : $t('login.submit') }}</button> <button class="login-btn" :disabled="submitting" @tap="login">{{ submitting ? '登录中...' : '登录' }}</button>
</view> </view>
</view> </view>
</template> </template>
@@ -24,19 +24,19 @@
methods: { methods: {
async login() { async login() {
const userId = this.form.userId.trim() const userId = this.form.userId.trim()
if (!userId) return void (this.error = this.$t('login.usernameRequired')) if (!userId) return void (this.error = '请输入工号')
if (!this.form.password) return void (this.error = this.$t('login.passwordRequired')) if (!this.form.password) return void (this.error = '请输入密码')
this.submitting = true this.submitting = true
this.error = '' this.error = ''
try { try {
requireOnline(this.$t('login.offline')) requireOnline('当前网络不可用,无法登录')
const tokens = await loginRequest(userId, this.form.password) const tokens = await loginRequest(userId, this.form.password)
signInWithTokens(tokens) signInWithTokens(tokens)
uni.reLaunch({ url: '/pages/home/home' }) uni.reLaunch({ url: '/pages/home/home' })
} catch (error) { } catch (error) {
console.error('[Overseas PDA login] failed', { message: error.message, network: error.network === true, console.error('[Overseas PDA login] failed', { message: error.message, network: error.network === true,
code: error.code, requestId: error.requestId, traceId: error.traceId }) code: error.code, requestId: error.requestId, traceId: error.traceId })
this.error = error.network ? this.$t('login.offline') : (error.message || this.$t('login.failed')) this.error = error.network ? '当前网络不可用,请联网后重试' : (error.message || '登录失败')
} finally { } finally {
this.submitting = false this.submitting = false
} }
+5 -5
View File
@@ -1,11 +1,11 @@
<template> <template>
<view v-if="user && currentModule" class="page-shell portal-page"> <view v-if="user && currentModule" class="page-shell portal-page">
<PortalHeader :title="$t('module.title')" :user="user" /> <PortalHeader title="业务模块" :user="user" />
<view class="portal-content"> <view class="portal-content">
<view class="module-hero" :class="`module-hero-${currentModule.key}`"><view class="module-hero-icon">{{ currentModule.icon }}</view><view class="module-hero-copy"><text class="module-hero-title">{{ $t(currentModule.nameKey) }}</text><text class="module-hero-desc">{{ $t(currentModule.descriptionKey) }}</text></view><text class="module-hero-count">{{ $t('module.availability', { available: availableCount, total: currentModule.operations.length }) }}</text></view> <view class="module-hero" :class="`module-hero-${currentModule.key}`"><view class="module-hero-icon">{{ currentModule.icon }}</view><view class="module-hero-copy"><text class="module-hero-title">{{ currentModule.name }}</text><text class="module-hero-desc">{{ currentModule.description }}</text></view><text class="module-hero-count">{{ availableCount }}/{{ currentModule.operations.length }} 可用</text></view>
<view class="section-heading"><text class="group-title">{{ $t('module.functions') }}</text><text class="section-heading-hint">{{ $t('module.hint') }}</text></view> <view class="section-heading"><text class="group-title">业务功能</text><text class="section-heading-hint">点击可用功能开始作业</text></view>
<view class="operation-list"><button v-for="operation in currentModule.operations" :key="operation.name" class="operation-item" :class="{ 'operation-item-disabled': !operation.available }" :disabled="!operation.available" @tap="openOperation(operation)"><view class="operation-icon">{{ operation.icon }}</view><view class="operation-info"><text class="operation-name">{{ $t(operation.nameKey) }}</text><text class="operation-desc">{{ $t(operation.descriptionKey) }}</text></view><text v-if="operation.available" class="operation-ready">{{ $t('common.available') }}</text><text v-else class="operation-locked">{{ $t('common.unavailable') }}</text><text v-if="operation.available" class="operation-arrow">›</text></button></view> <view class="operation-list"><button v-for="operation in currentModule.operations" :key="operation.name" class="operation-item" :class="{ 'operation-item-disabled': !operation.available }" :disabled="!operation.available" @tap="openOperation(operation)"><view class="operation-icon">{{ operation.icon }}</view><view class="operation-info"><text class="operation-name">{{ operation.name }}</text><text class="operation-desc">{{ operation.description }}</text></view><text v-if="operation.available" class="operation-ready">可处理</text><text v-else class="operation-locked">未开放</text><text v-if="operation.available" class="operation-arrow">›</text></button></view>
<view class="module-empty-note">{{ $t('module.onlyReceiving') }}</view> <view class="module-empty-note">当前仅开放拆柜收货,其他作业暂未开放。</view>
</view> </view>
</view> </view>
</template> </template>
+56 -57
View File
@@ -1,86 +1,86 @@
<template> <template>
<view v-if="user" class="page-shell portal-page receiving-page"> <view v-if="user" class="page-shell portal-page receiving-page">
<PortalHeader :title="inboundMode ? $t('operation.title') : operationName" :user="user" :fallback-url="`/pages/module/module?key=${moduleKey}`" /> <PortalHeader :title="inboundMode ? '拆柜收货' : operationName" :user="user" :fallback-url="`/pages/module/module?key=${moduleKey}`" />
<view class="portal-content receiving-content"> <view class="portal-content receiving-content">
<template v-if="inboundMode"> <template v-if="inboundMode">
<view class="receiving-heading"><view><text class="receiving-title">{{ pageTitle }}</text><text class="receiving-subtitle">{{ pageSubtitle }}</text></view></view> <view class="receiving-heading"><view><text class="receiving-title">{{ pageTitle }}</text><text class="receiving-subtitle">{{ pageSubtitle }}</text></view></view>
<view v-if="loading" class="state-card"><uni-load-more status="loading" /><text>{{ $t('operation.restoring') }}</text></view> <view v-if="loading" class="state-card"><uni-load-more status="loading" /><text>正在恢复当前柜作业…</text></view>
<template v-else-if="phase === 'candidates'"> <template v-else-if="phase === 'candidates'">
<view class="scan-card"> <view class="scan-card">
<text class="section-label">{{ $t('operation.scanContainer') }}</text> <text class="section-label">扫描柜号</text>
<ScanTargetInput name="container" v-model="containerInput" :label="$t('operation.container')" :placeholder="$t('operation.containerPlaceholder')" :active="activeTarget === 'container'" @activate="activeTarget = 'container'" @submit="openContainer" /> <ScanTargetInput name="container" v-model="containerInput" label="柜号" placeholder="扫描或人工输入柜号" :active="activeTarget === 'container'" @activate="activeTarget = 'container'" @submit="openContainer" />
<button class="primary-button" :disabled="busy || !containerInput.trim()" @tap="openContainer(containerInput)">{{ $t('operation.queryContainer') }}</button> <button class="primary-button" :disabled="busy || !containerInput.trim()" @tap="openContainer(containerInput)">查询柜号</button>
</view> </view>
<view class="list-toolbar"><text>{{ $t('operation.pendingContainers', { count: candidates.length }) }}</text><button :disabled="busy" @tap="loadCandidates">{{ $t('common.refresh') }}</button></view> <view class="list-toolbar"><text>{{ candidates.length }} 个待卸货集装箱</text><button :disabled="busy" @tap="loadCandidates">刷新</button></view>
<view v-if="!candidates.length" class="empty-card"><uni-icons type="info" color="#8090a3" size="34" /><text>{{ $t('operation.emptyContainers') }}</text></view> <view v-if="!candidates.length" class="empty-card"><uni-icons type="info" color="#8090a3" size="34" /><text>暂无待卸货集装箱</text></view>
<view v-for="item in candidates" :key="item.containerNo" class="container-card"> <view v-for="item in candidates" :key="item.containerNo" class="container-card">
<view class="card-head"><view><text class="container-no">{{ item.containerNo }}</text><text class="container-meta">{{ item.inboundCodeCount }} {{ $t('common.inboundCodes') }} · {{ item.planCount }} {{ $t('common.plans') }}<template v-if="item.plannedPalletCount != null"> · {{ item.plannedPalletCount }} {{ $t('common.pallets') }}</template></text><text v-if="item.destinationCodes?.length" class="container-meta">{{ $t('operation.destination', { value: item.destinationCodes.join(' / ') }) }}</text></view><text class="status-tag" :class="statusTone(item.status)">{{ statusLabel(item.status) }}</text></view> <view class="card-head"><view><text class="container-no">{{ item.containerNo }}</text><text class="container-meta">{{ item.inboundCodeCount }} 个入仓码 · {{ item.planCount }} 个计划<template v-if="item.plannedPalletCount != null"> · {{ item.plannedPalletCount }} 托</template></text><text v-if="item.destinationCodes?.length" class="container-meta">目的仓:{{ item.destinationCodes.join(' / ') }}</text></view><text class="status-tag" :class="statusTone(item.status)">{{ statusLabel(item.status) }}</text></view>
<view class="progress-row"><text>{{ $t('operation.receivingProgress') }}</text><text><strong>{{ item.receivedPackages }}</strong> / {{ item.totalPackages }} {{ $t('common.boxes') }}</text></view> <view class="progress-row"><text>收货进度</text><text><strong>{{ item.receivedPackages }}</strong> / {{ item.totalPackages }} 箱</text></view>
<view class="progress-track"><view class="progress-fill" :style="{ width: percent(item.receivedPackages, item.totalPackages) + '%' }"></view></view> <view class="progress-track"><view class="progress-fill" :style="{ width: percent(item.receivedPackages, item.totalPackages) + '%' }"></view></view>
<button class="primary-button" :disabled="busy" @tap="openContainer(item.containerNo)">{{ item.currentAccount ? $t('operation.continueCurrent') : $t('operation.viewDetail') }}</button> <button class="primary-button" :disabled="busy" @tap="openContainer(item.containerNo)">{{ item.currentAccount ? '继续当前柜' : '查看详情' }}</button>
</view> </view>
</template> </template>
<template v-else-if="phase === 'detail'"> <template v-else-if="phase === 'detail'">
<ReceivingSessionSummary :session="selected" /> <ReceivingSessionSummary :session="selected" />
<view class="lock-notice">{{ $t('operation.lockedNotice') }}</view> <view class="lock-notice">选择后账号将锁定当前柜,完成前不能切换其他柜号。</view>
<view class="sticky-action"><button class="ghost-button" :disabled="busy" @tap="cancelDetail">{{ $t('operation.backList') }}</button><button class="primary-button" :disabled="busy" @tap="prepareContainer">{{ $t('operation.selectContainer') }}</button></view> <view class="sticky-action"><button class="ghost-button" :disabled="busy" @tap="cancelDetail">返回列表</button><button class="primary-button" :disabled="busy" @tap="prepareContainer">选择此柜并开始拍照</button></view>
</template> </template>
<template v-else-if="phase === 'sign'"> <template v-else-if="phase === 'sign'">
<ReceivingSessionSummary :session="current" /> <ReceivingSessionSummary :session="current" />
<view class="photo-card"> <view class="photo-card">
<view class="card-head"><view><text class="card-title">{{ $t('operation.unloadPhotos') }}</text><text class="container-meta">{{ $t('operation.photoHint') }}</text></view><text class="status-tag" :class="photosReady ? 'status-good' : 'status-wait'">{{ uploadedPhotoCount }} / 3</text></view> <view class="card-head"><view><text class="card-title">卸货签收照片</text><text class="container-meta">三个固定槽位各拍摄 1 张现场照片</text></view><text class="status-tag" :class="photosReady ? 'status-good' : 'status-wait'">{{ uploadedPhotoCount }} / 3</text></view>
<view class="photo-grid"> <view class="photo-grid">
<view v-for="(step, index) in photoSteps" :key="step.role" class="photo-role" :class="{ complete: photoSlots[index]?.fileId }"> <view v-for="(step, index) in photoSteps" :key="step.role" class="photo-role" :class="{ complete: photoSlots[index]?.fileId }">
<view class="photo-preview" :class="{ failed: photoSlots[index]?.status === 'error' }" @tap="previewPhoto(index)"> <view class="photo-preview" :class="{ failed: photoSlots[index]?.status === 'error' }" @tap="previewPhoto(index)">
<image v-if="photoSlots[index]?.localPath" class="photo-thumb" :src="photoSlots[index].localPath" mode="aspectFill" /> <image v-if="photoSlots[index]?.localPath" class="photo-thumb" :src="photoSlots[index].localPath" mode="aspectFill" />
<view v-else class="photo-placeholder"><uni-icons type="camera" color="#8090a3" size="23" /><text>{{ $t('operation.notAdded') }}</text></view> <view v-else class="photo-placeholder"><uni-icons type="camera" color="#8090a3" size="23" /><text>未添加</text></view>
</view> </view>
<view class="photo-role-info"> <view class="photo-role-info">
<view class="photo-role-head"><text>{{ step.label }}</text><text :class="photoSlots[index]?.fileId ? 'photo-valid' : 'photo-required'">{{ photoSlots[index]?.fileId ? $t('common.uploaded') : $t('common.required') }}</text></view> <view class="photo-role-head"><text>{{ step.label }}</text><text :class="photoSlots[index]?.fileId ? 'photo-valid' : 'photo-required'">{{ photoSlots[index]?.fileId ? '已上传' : '必传' }}</text></view>
<text class="photo-state">{{ photoStateLabel(photoSlots[index]) }}</text> <text class="photo-state">{{ photoStateLabel(photoSlots[index]) }}</text>
<text v-if="photoSlots[index]?.status === 'error'" class="photo-error">{{ photoSlots[index].errorMessage || $t('common.retry') }}</text> <text v-if="photoSlots[index]?.status === 'error'" class="photo-error">{{ photoSlots[index].errorMessage || '请重试' }}</text>
</view> </view>
<view class="photo-actions"><button :disabled="busy" @tap="choosePhoto(index)">{{ photoSlots[index]?.fileId ? $t('operation.retake') : $t('operation.takePhoto') }}</button></view> <view class="photo-actions"><button :disabled="busy" @tap="choosePhoto(index)">{{ photoSlots[index]?.fileId ? '重拍' : '拍照' }}</button></view>
</view> </view>
</view> </view>
</view> </view>
<view class="sticky-action one"><button class="primary-button" :disabled="busy || !photosReady" @tap="signContainer">{{ $t('operation.signAndReceive') }}</button></view> <view class="sticky-action one"><button class="primary-button" :disabled="busy || !photosReady" @tap="signContainer">确认签收并开始收货</button></view>
</template> </template>
<template v-else-if="phase === 'receive'"> <template v-else-if="phase === 'receive'">
<ReceivingSessionSummary :session="current" /> <ReceivingSessionSummary :session="current" />
<view class="scan-card"> <view class="scan-card">
<text class="section-label">{{ $t('operation.scanCartonStep') }}</text> <text class="section-label">1. 扫描箱码定位入仓码</text>
<ScanTargetInput name="carton" v-model="cartonInput" :label="$t('operation.carton')" placeholder="NYEXX2608260001#4-1" :active="activeTarget === 'carton'" @activate="activeTarget = 'carton'" @submit="resolveCarton" /> <ScanTargetInput name="carton" v-model="cartonInput" label="箱码" placeholder="NYEXX2608260001#4-1" :active="activeTarget === 'carton'" @activate="activeTarget = 'carton'" @submit="resolveCarton" />
<button class="secondary-button" :disabled="busy || !cartonInput.trim()" @tap="resolveCarton(cartonInput)">{{ $t('operation.parseCarton') }}</button> <button class="secondary-button" :disabled="busy || !cartonInput.trim()" @tap="resolveCarton(cartonInput)">解析箱码</button>
<template v-if="activeInbound"> <template v-if="activeInbound">
<view class="matched-inbound-card"> <view class="matched-inbound-card">
<view class="mode-banner" :class="activeInbound.destinationType === 'FBA' ? 'fba' : 'overseas'"><text>{{ activeInbound.destinationType === 'FBA' ? $t('operation.fbaMode') : $t('operation.overseasMode') }}</text><text>{{ destinationLabel(activeInbound) }}</text></view> <view class="mode-banner" :class="activeInbound.destinationType === 'FBA' ? 'fba' : 'overseas'"><text>{{ activeInbound.destinationType === 'FBA' ? 'FBA 按箱数收货' : '海外仓逐箱收货' }}</text><text>{{ destinationLabel(activeInbound) }}</text></view>
<text class="matched-inbound-code">{{ activeInbound.inboundCode }}</text> <text class="matched-inbound-code">{{ activeInbound.inboundCode }}</text>
<view class="matched-inbound-progress"><text>{{ $t('operation.totalBoxes', { count: activeInbound.totalPackages }) }}</text><text>{{ $t('operation.receivedBoxes', { count: activeInbound.receivedPackages }) }}</text><text>{{ $t('operation.pendingBoxes', { count: activeInbound.pendingPackages }) }}</text></view> <view class="matched-inbound-progress"><text>总计 {{ activeInbound.totalPackages }} 箱</text><text>已收 {{ activeInbound.receivedPackages }} 箱</text><text>待收 {{ activeInbound.pendingPackages }} 箱</text></view>
</view> </view>
<text class="section-label">{{ $t('operation.scanPalletStep') }}</text> <text class="section-label">2. 扫描实物托盘码</text>
<ScanTargetInput name="pallet" v-model="palletInput" :label="$t('operation.pallet')" :placeholder="$t('operation.palletPlaceholder')" :active="activeTarget === 'pallet'" @activate="activeTarget = 'pallet'" @submit="resolvePallet" /> <ScanTargetInput name="pallet" v-model="palletInput" label="托盘码" placeholder="扫描或人工输入托盘码" :active="activeTarget === 'pallet'" @activate="activeTarget = 'pallet'" @submit="resolvePallet" />
<button class="secondary-button" :disabled="busy || !palletInput.trim()" @tap="resolvePallet(palletInput)">{{ $t('operation.verifyPallet') }}</button> <button class="secondary-button" :disabled="busy || !palletInput.trim()" @tap="resolvePallet(palletInput)">校验托盘</button>
<view v-if="resolvedPallet" class="resolved-card"><text>{{ resolvedPallet.palletCode }}</text><text>{{ resolvedPallet.compatible ? $t('operation.palletMatched') : $t('operation.palletMismatch') }}</text></view> <view v-if="resolvedPallet" class="resolved-card"><text>{{ resolvedPallet.palletCode }}</text><text>{{ resolvedPallet.compatible ? '目的仓匹配,可收货' : '托盘不匹配' }}</text></view>
<template v-if="activeInbound.destinationType === 'FBA'"> <template v-if="activeInbound.destinationType === 'FBA'">
<text class="section-label">{{ $t('operation.inputCountStep') }}</text> <text class="section-label">3. 输入本次箱数</text>
<input v-model.number="packageCount" class="quantity-input" type="number" inputmode="numeric" :placeholder="$t('operation.maxCount', { count: activeInbound.pendingPackages })" /> <input v-model.number="packageCount" class="quantity-input" type="number" inputmode="numeric" :placeholder="`最多可收 ${activeInbound.pendingPackages} 箱`" />
</template> </template>
<button class="primary-button receipt-button" :disabled="!canSubmitReceipt || busy" @tap="submitReceipt">{{ activeInbound.destinationType === 'FBA' ? $t('operation.confirmBulk') : $t('operation.confirmCarton') }}</button> <button class="primary-button receipt-button" :disabled="!canSubmitReceipt || busy" @tap="submitReceipt">{{ activeInbound.destinationType === 'FBA' ? '确认按箱数收货' : '确认本箱收货' }}</button>
</template> </template>
</view> </view>
<view v-if="lastReceipt" class="success-card">{{ $t('operation.receiptSuccess', { accepted: lastReceipt.acceptedPackages, inbound: lastReceipt.inboundCode, pending: lastReceipt.pendingPackages }) }}</view> <view v-if="lastReceipt" class="success-card">本次已收 {{ lastReceipt.acceptedPackages }} 箱,{{ lastReceipt.inboundCode }} 剩余 {{ lastReceipt.pendingPackages }} 箱</view>
<view class="sticky-action one"><button class="primary-button complete-button" :disabled="busy || !allInboundsReconciled" @tap="completeContainer">{{ allInboundsReconciled ? $t('operation.completeReady') : $t('operation.incomplete', { count: progress.pending }) }}</button></view> <view class="sticky-action one"><button class="primary-button complete-button" :disabled="busy || !allInboundsReconciled" @tap="completeContainer">{{ allInboundsReconciled ? '每个入仓码已核平,完成整柜入库' : `尚有 ${progress.pending} 箱未收` }}</button></view>
</template> </template>
<template v-else-if="phase === 'completed'"> <template v-else-if="phase === 'completed'">
<view class="completed-card"><view class="completed-icon">✓</view><text class="completed-title">{{ $t('operation.completedTitle') }}</text><text>{{ current?.containerNo }}</text><text>{{ $t('operation.actualReceipt', { received: current?.receivedPackages, total: current?.totalPackages }) }}</text><button class="primary-button" @tap="finishAndReturn">{{ $t('operation.returnPending') }}</button></view> <view class="completed-card"><view class="completed-icon">✓</view><text class="completed-title">当前柜已完成入库</text><text>{{ current?.containerNo }}</text><text>实收 {{ current?.receivedPackages }} / {{ current?.totalPackages }} 箱</text><button class="primary-button" @tap="finishAndReturn">返回待卸货列表</button></view>
</template> </template>
<view v-if="error" class="error-card"> <view v-if="error" class="error-card">
@@ -90,8 +90,8 @@
</template> </template>
<template v-else> <template v-else>
<view class="receiving-heading"><view><text class="receiving-title">{{ operationName }}</text><text class="receiving-subtitle">{{ $t('operation.unavailableSubtitle') }}</text></view></view> <view class="receiving-heading"><view><text class="receiving-title">{{ operationName }}</text><text class="receiving-subtitle">当前功能暂未开放</text></view></view>
<view class="blocked-card"><uni-icons type="locked" color="#8090a3" size="36" /><text class="blocked-title">{{ $t('operation.unavailableTitle') }}</text><text>{{ $t('operation.unavailableDescription') }}</text></view> <view class="blocked-card"><uni-icons type="locked" color="#8090a3" size="36" /><text class="blocked-title">功能暂未开放</text><text>现有平台、客户或内部接口不能由仓库作业员直接调用。扫码不会提交,也不会产生模拟成功结果。</text></view>
</template> </template>
</view> </view>
<view v-if="toast.visible" class="app-toast" :class="toast.type" role="status">{{ toast.message }}</view> <view v-if="toast.visible" class="app-toast" :class="toast.type" role="status">{{ toast.message }}</view>
@@ -111,11 +111,10 @@
export default { export default {
components: { PortalHeader, ReceivingSessionSummary, ScanTargetInput }, components: { PortalHeader, ReceivingSessionSummary, ScanTargetInput },
data() { return { user: session.currentUser, moduleKey: 'query', opKey: 'inventoryQuery', loading: false, busy: false, bootstrapped: false, detailOpen: false, containerInput: '', cartonInput: '', palletInput: '', packageCount: 1, error: null, toast: { visible: false, message: '', type: '' } } }, data() { return { user: session.currentUser, moduleKey: 'query', operationName: '库存查询', loading: false, busy: false, bootstrapped: false, detailOpen: false, containerInput: '', cartonInput: '', palletInput: '', packageCount: 1, error: null, toast: { visible: false, message: '', type: '' } } },
computed: { computed: {
inboundMode() { return this.moduleKey === 'inbound' }, inboundMode() { return this.moduleKey === 'inbound' },
operationName() { return this.$t(`modules.${this.opKey}.name`) }, photoSteps() { return RECEIVING_PHOTO_ROLES.map((role) => ({ role, label: ({ LICENSE_PLATE: '车牌照片', CONTAINER_SEAL: '集装箱封签', DOOR_OPEN: '开柜现场' })[role] })) },
photoSteps() { return RECEIVING_PHOTO_ROLES.map((role) => ({ role, label: this.$t(({ LICENSE_PLATE: 'operation.photoLicense', CONTAINER_SEAL: 'operation.photoSeal', DOOR_OPEN: 'operation.photoDoor' })[role]) })) },
candidates() { return receiving.candidates }, selected() { return receiving.selected }, current() { return receiving.current }, photoSlots() { return receiving.photoSlots }, candidates() { return receiving.candidates }, selected() { return receiving.selected }, current() { return receiving.current }, photoSlots() { return receiving.photoSlots },
resolvedCarton() { return receiving.resolvedCarton }, resolvedPallet() { return receiving.resolvedPallet }, lastReceipt() { return receiving.lastReceipt }, resolvedCarton() { return receiving.resolvedCarton }, resolvedPallet() { return receiving.resolvedPallet }, lastReceipt() { return receiving.lastReceipt },
activeTarget: { get() { return receiving.activeScanTarget }, set(value) { receiving.activeScanTarget = value } }, activeTarget: { get() { return receiving.activeScanTarget }, set(value) { receiving.activeScanTarget = value } },
@@ -123,38 +122,38 @@
uploadedPhotoCount() { return this.photoSlots.filter((item) => item?.fileId).length }, photosReady() { return this.uploadedPhotoCount === 3 && !this.photoSlots.some((item) => item?.status === 'uploading') }, uploadedPhotoCount() { return this.photoSlots.filter((item) => item?.fileId).length }, photosReady() { return this.uploadedPhotoCount === 3 && !this.photoSlots.some((item) => item?.status === 'uploading') },
canSubmitReceipt() { if (!this.activeInbound || !this.resolvedPallet?.compatible) return false; if (this.activeInbound.destinationType === 'FBA') { const count = Number(this.packageCount); return Number.isInteger(count) && count > 0 && count <= Number(this.activeInbound.pendingPackages) } return Boolean(this.activeInbound.cartonMark) }, canSubmitReceipt() { if (!this.activeInbound || !this.resolvedPallet?.compatible) return false; if (this.activeInbound.destinationType === 'FBA') { const count = Number(this.packageCount); return Number.isInteger(count) && count > 0 && count <= Number(this.activeInbound.pendingPackages) } return Boolean(this.activeInbound.cartonMark) },
allInboundsReconciled() { const values = this.current?.inbounds || []; return values.length > 0 && values.every((item) => Number(item.pendingPackages) === 0) }, allInboundsReconciled() { const values = this.current?.inbounds || []; return values.length > 0 && values.every((item) => Number(item.pendingPackages) === 0) },
pageTitle() { return this.$t(`operationPhase.${this.phase}.title`) }, pageTitle() { return { candidates: '待卸货集装箱', detail: '柜号详情', sign: '拆柜拍照签收', receive: '当前柜收货', completed: '入库完成' }[this.phase] },
pageSubtitle() { return this.$t(`operationPhase.${this.phase}.subtitle`) } pageSubtitle() { return { candidates: '选择集装箱后拍摄 3 张照片完成签收', detail: '核对柜号与整柜计划箱数', sign: '完成现场取证后进入收货', receive: '扫描箱码定位入仓码后开始收货', completed: '库存与柜级进度已由服务端核平' }[this.phase] }
}, },
onLoad(options) { this.moduleKey = options.module || 'query'; this.opKey = options.op || (this.moduleKey === 'inbound' ? 'receiving' : 'inventoryQuery') }, onLoad(options) { this.moduleKey = options.module || 'query'; const key = options.op || (this.moduleKey === 'inbound' ? 'receiving' : 'inventoryQuery'); this.operationName = ({ receiving: '拆柜收货', stocktake: '库存盘点', move: '商品移库', pallet: '托盘管理', relabelReceive: '换标收货', relabelExecute: '换标执行', relabelReview: '换标复核', inventoryQuery: '库存查询', locationQuery: '库位查询', palletQuery: '托盘查询' })[key] || '库存查询' },
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.startScanner(); if (this.inboundMode && !this.bootstrapped) this.bootstrap() }, onShow() { if (!requireSession()) return; this.user = session.currentUser; this.startScanner(); if (this.inboundMode && !this.bootstrapped) this.bootstrap() },
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { clearTimeout(this._toastTimer); this.stopScanner() }, onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { clearTimeout(this._toastTimer); this.stopScanner() },
methods: { methods: {
statusLabel(status) { const key = ({ WAIT_SIGN: 'statusWait', SIGNED: 'statusSigned', RECEIVING: 'statusReceiving', COMPLETED: 'statusCompleted' })[status]; return key ? this.$t(`operation.${key}`) : status }, statusLabel(status) { return ({ WAIT_SIGN: '待签收', SIGNED: '已签收', RECEIVING: '收货中', COMPLETED: '已完成' })[status] || status },
statusTone(status) { return status === 'RECEIVING' ? 'status-current' : status === 'COMPLETED' ? 'status-good' : 'status-wait' }, percent(received, total) { return total ? Math.min(100, Math.round(Number(received || 0) / Number(total) * 100)) : 0 }, statusTone(status) { return status === 'RECEIVING' ? 'status-current' : status === 'COMPLETED' ? 'status-good' : 'status-wait' }, percent(received, total) { return total ? Math.min(100, Math.round(Number(received || 0) / Number(total) * 100)) : 0 },
destinationLabel(item) { return item?.destinationType === 'FBA' ? (item.fbaWarehouseCode || 'FBA') : (item?.destinationWarehouseName || item?.destinationWarehouseCode || this.$t('modules.inbound.name')) }, destinationLabel(item) { return item?.destinationType === 'FBA' ? (item.fbaWarehouseCode || 'FBA') : (item?.destinationWarehouseName || item?.destinationWarehouseCode || '海外仓') },
photoStateLabel(photo) { if (photo?.status === 'uploading') return this.$t('operation.uploading'); if (photo?.status === 'error') return this.$t(photo.fileId ? 'operation.retakeFailedKept' : 'operation.uploadFailed'); return this.$t(photo?.fileId ? 'operation.previewPhoto' : 'operation.takePhotoHint') }, photoStateLabel(photo) { if (photo?.status === 'uploading') return '正在上传…'; if (photo?.status === 'error') return photo.fileId ? '重拍失败,已保留原图' : '上传失败,请重试'; return photo?.fileId ? '点击左侧照片可查看大图' : '请拍摄现场照片' },
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleHardwareScan(value), onError: (error) => { this.error = error } }) }, stopScanner() { if (typeof this._stopScanner === 'function') this._stopScanner(); this._stopScanner = null }, startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleHardwareScan(value), onError: (error) => { this.error = error } }) }, stopScanner() { if (typeof this._stopScanner === 'function') this._stopScanner(); this._stopScanner = null },
handleHardwareScan(value) { if (!this.inboundMode) return this.showToast(this.$t('operation.scanUnavailable'), 'warning'); if (this.phase === 'candidates') { this.containerInput = value; return this.openContainer(value) } if (this.phase !== 'receive') return; if (this.activeTarget === 'pallet' && this.activeInbound) { this.palletInput = value; return this.resolvePallet(value) } this.cartonInput = value; return this.resolveCarton(value) }, handleHardwareScan(value) { if (!this.inboundMode) return this.showToast('该功能缺少海外 PDA 专用接口,扫码未提交', 'warning'); if (this.phase === 'candidates') { this.containerInput = value; return this.openContainer(value) } if (this.phase !== 'receive') return; if (this.activeTarget === 'pallet' && this.activeInbound) { this.palletInput = value; return this.resolvePallet(value) } this.cartonInput = value; return this.resolveCarton(value) },
async run(actionKey, work) { const action = this.$t(`operation.actions.${actionKey}`); this.busy = true; this.error = null; try { const result = await work(); this.showToast(this.$t('operation.actionSuccess', { action }), 'success'); return result } catch (error) { this.error = error; playErrorSound(); this.showToast(error.message || this.$t('operation.actionFailed', { action }), 'error'); throw error } finally { this.busy = false } }, async run(action, work) { this.busy = true; this.error = null; try { const result = await work(); this.showToast(`${action}成功`, 'success'); return result } catch (error) { this.error = error; playErrorSound(); this.showToast(error.message || `${action}失败`, 'error'); throw error } finally { this.busy = false } },
setScanTargetForCurrent(value) { receiving.activeScanTarget = value?.status === 'WAIT_SIGN' ? 'container' : 'carton' }, setScanTargetForCurrent(value) { receiving.activeScanTarget = value?.status === 'WAIT_SIGN' ? 'container' : 'carton' },
async restorePhotoPreviews() { await Promise.all(receiving.photoSlots.filter((photo) => photo?.downloadPath && !photo.localPath).map(async (photo) => { photo.status = 'restoring'; try { photo.localPath = await downloadCurrentUnloadPhoto(photo.downloadPath); photo.status = 'success' } catch (error) { photo.status = 'error'; photo.errorMessage = error.message || this.$t('operation.photoLoadFailed'); this.error = error } })) }, async restorePhotoPreviews() { await Promise.all(receiving.photoSlots.filter((photo) => photo?.downloadPath && !photo.localPath).map(async (photo) => { photo.status = 'restoring'; try { photo.localPath = await downloadCurrentUnloadPhoto(photo.downloadPath); photo.status = 'success' } catch (error) { photo.status = 'error'; photo.errorMessage = error.message || '照片加载失败'; this.error = error } })) },
async applyCurrentReceiving(value) { setCurrentReceiving(value); await this.restorePhotoPreviews(); this.setScanTargetForCurrent(value) }, async applyCurrentReceiving(value) { setCurrentReceiving(value); await this.restorePhotoPreviews(); this.setScanTargetForCurrent(value) },
async bootstrap() { this.loading = true; this.error = null; this.detailOpen = false; try { const current = await getCurrentReceiving(); await this.applyCurrentReceiving(current); this.bootstrapped = true } catch (error) { if (isNoCurrentReceiving(error)) { resetReceivingDraft(); await this.loadCandidates(false); this.bootstrapped = true } else this.error = error } finally { this.loading = false } }, async bootstrap() { this.loading = true; this.error = null; this.detailOpen = false; try { const current = await getCurrentReceiving(); await this.applyCurrentReceiving(current); this.bootstrapped = true } catch (error) { if (isNoCurrentReceiving(error)) { resetReceivingDraft(); await this.loadCandidates(false); this.bootstrapped = true } else this.error = error } finally { this.loading = false } },
async recoverCurrentAfterConflict(error) { if (!isReceivingConflict(error)) return false; try { const current = await getCurrentReceiving(); await this.applyCurrentReceiving(current); this.detailOpen = false; this.error = null; this.showToast(this.$t('operation.currentConflict', { container: current.containerNo }), 'warning'); return true } catch (recoveryError) { this.error = recoveryError; return false } }, async recoverCurrentAfterConflict(error) { if (!isReceivingConflict(error)) return false; try { const current = await getCurrentReceiving(); await this.applyCurrentReceiving(current); this.detailOpen = false; this.error = null; this.showToast(`账号已有未完成柜 ${current.containerNo},已返回当前柜`, 'warning'); return true } catch (recoveryError) { this.error = recoveryError; return false } },
async loadCandidates(feedback = true) { try { const work = () => listReceivingCandidates(); receiving.candidates = feedback ? await this.run('refresh', work) : await work() } catch (_) {} }, async loadCandidates(feedback = true) { try { const work = () => listReceivingCandidates(); receiving.candidates = feedback ? await this.run('刷新待卸柜', work) : await work() } catch (_) {} },
async openContainer(raw) { const containerNo = String(raw?.detail?.value ?? raw ?? this.containerInput).trim(); if (!containerNo || this.busy) return; try { receiving.selected = await this.run('query', () => getReceivingContainer(containerNo)); this.containerInput = containerNo; this.detailOpen = true; playSuccessSound() } catch (error) { await this.recoverCurrentAfterConflict(error) } }, async openContainer(raw) { const containerNo = String(raw?.detail?.value ?? raw ?? this.containerInput).trim(); if (!containerNo || this.busy) return; try { receiving.selected = await this.run('查询柜号', () => getReceivingContainer(containerNo)); this.containerInput = containerNo; this.detailOpen = true; playSuccessSound() } catch (error) { await this.recoverCurrentAfterConflict(error) } },
cancelDetail() { this.detailOpen = false; receiving.selected = null }, cancelDetail() { this.detailOpen = false; receiving.selected = null },
async prepareContainer() { if (!this.selected?.containerNo) return; const key = idempotencyKey('prepare', { containerNo: this.selected.containerNo }); try { const value = await this.run('select', () => prepareReceivingContainer(this.selected.containerNo, { idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); setCurrentReceiving(value); this.setScanTargetForCurrent(value); this.detailOpen = false } catch (error) { await this.recoverCurrentAfterConflict(error) } }, async prepareContainer() { if (!this.selected?.containerNo) return; const key = idempotencyKey('prepare', { containerNo: this.selected.containerNo }); try { const value = await this.run('选择当前柜', () => prepareReceivingContainer(this.selected.containerNo, { idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); setCurrentReceiving(value); this.setScanTargetForCurrent(value); this.detailOpen = false } catch (error) { await this.recoverCurrentAfterConflict(error) } },
choosePhoto(index) { if (this.busy) return; uni.chooseImage({ count: 1, sourceType: ['camera'], success: ({ tempFilePaths }) => { const path = tempFilePaths?.[0]; if (path) this.replacePhoto(index, path) } }) }, choosePhoto(index) { if (this.busy) return; uni.chooseImage({ count: 1, sourceType: ['camera'], success: ({ tempFilePaths }) => { const path = tempFilePaths?.[0]; if (path) this.replacePhoto(index, path) } }) },
async replacePhoto(index, path) { const previous = receiving.photoSlots[index]; receiving.photoSlots.splice(index, 1, { ...(previous || {}), role: this.photoSteps[index].role, status: 'uploading', errorMessage: '' }); this.busy = true; this.error = null; try { const file = await uploadCurrentUnloadPhoto(path); receiving.photoSlots.splice(index, 1, { ...file, fileId: String(file.fileId), role: this.photoSteps[index].role, localPath: path, uploaded: true, status: 'success' }); this.showToast(this.$t('operation.actionSuccess', { action: this.photoSteps[index].label }), 'success') } catch (error) { receiving.photoSlots.splice(index, 1, previous?.fileId ? { ...previous, status: 'error', errorMessage: error.message || this.$t('operation.retakeFailedKept') } : { role: this.photoSteps[index].role, localPath: path, uploaded: false, status: 'error', errorMessage: error.message || this.$t('operation.uploadFailed') }); this.error = error; playErrorSound() } finally { this.busy = false } }, async replacePhoto(index, path) { const previous = receiving.photoSlots[index]; receiving.photoSlots.splice(index, 1, { ...(previous || {}), role: this.photoSteps[index].role, status: 'uploading', errorMessage: '' }); this.busy = true; this.error = null; try { const file = await uploadCurrentUnloadPhoto(path); receiving.photoSlots.splice(index, 1, { ...file, fileId: String(file.fileId), role: this.photoSteps[index].role, localPath: path, uploaded: true, status: 'success' }); this.showToast(`${this.photoSteps[index].label}上传成功`, 'success') } catch (error) { receiving.photoSlots.splice(index, 1, previous?.fileId ? { ...previous, status: 'error', errorMessage: error.message || '重拍失败,已保留原图' } : { role: this.photoSteps[index].role, localPath: path, uploaded: false, status: 'error', errorMessage: error.message || '上传失败,请重试' }); this.error = error; playErrorSound() } finally { this.busy = false } },
previewPhoto(index) { const path = this.photoSlots[index]?.localPath; if (!path) return; uni.previewImage({ urls: [path], current: path }) }, previewPhoto(index) { const path = this.photoSlots[index]?.localPath; if (!path) return; uni.previewImage({ urls: [path], current: path }) },
async signContainer() { if (!this.photosReady) return; const photos = this.photoSlots.map((item, index) => ({ role: this.photoSteps[index].role, fileId: String(item.fileId) })); const key = idempotencyKey('sign', { containerNo: this.current.containerNo, photos }); try { const value = await this.run('sign', () => signCurrentReceiving({ photos, idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); setCurrentReceiving(value); this.clearReceiptContext() } catch (_) {} }, async signContainer() { if (!this.photosReady) return; const photos = this.photoSlots.map((item, index) => ({ role: this.photoSteps[index].role, fileId: String(item.fileId) })); const key = idempotencyKey('sign', { containerNo: this.current.containerNo, photos }); try { const value = await this.run('签收当前柜', () => signCurrentReceiving({ photos, idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); setCurrentReceiving(value); this.clearReceiptContext() } catch (_) {} },
clearReceiptContext() { receiving.resolvedCarton = null; receiving.resolvedPallet = null; this.cartonInput = ''; this.palletInput = ''; this.packageCount = 1; receiving.activeScanTarget = 'carton' }, clearReceiptContext() { receiving.resolvedCarton = null; receiving.resolvedPallet = null; this.cartonInput = ''; this.palletInput = ''; this.packageCount = 1; receiving.activeScanTarget = 'carton' },
async resolveCarton(raw) { const cartonMark = String(raw?.detail?.value ?? raw ?? this.cartonInput).trim(); if (!cartonMark || this.busy) return; try { const value = await this.run('carton', () => resolveCurrentCarton(cartonMark)); receiving.resolvedCarton = value; receiving.resolvedPallet = null; this.cartonInput = value.cartonMark; this.palletInput = ''; this.packageCount = 1; receiving.activeScanTarget = 'pallet'; playSuccessSound() } catch (_) {} }, async resolveCarton(raw) { const cartonMark = String(raw?.detail?.value ?? raw ?? this.cartonInput).trim(); if (!cartonMark || this.busy) return; try { const value = await this.run('解析箱码', () => resolveCurrentCarton(cartonMark)); receiving.resolvedCarton = value; receiving.resolvedPallet = null; this.cartonInput = value.cartonMark; this.palletInput = ''; this.packageCount = 1; receiving.activeScanTarget = 'pallet'; playSuccessSound() } catch (_) {} },
async resolvePallet(raw) { const palletCode = String(raw?.detail?.value ?? raw ?? this.palletInput).trim(); if (!palletCode || !this.activeInbound || this.busy) return; try { const value = await this.run('pallet', () => resolveCurrentPallet(palletCode, this.activeInbound.inboundCode)); receiving.resolvedPallet = value; this.palletInput = value.palletCode; if (!value.compatible) throw Object.assign(new Error(this.$t('operation.palletMismatch')), { code: 'PALLET_INCOMPATIBLE' }); playSuccessSound() } catch (error) { if (!this.error) this.error = error } }, async resolvePallet(raw) { const palletCode = String(raw?.detail?.value ?? raw ?? this.palletInput).trim(); if (!palletCode || !this.activeInbound || this.busy) return; try { const value = await this.run('校验托盘', () => resolveCurrentPallet(palletCode, this.activeInbound.inboundCode)); receiving.resolvedPallet = value; this.palletInput = value.palletCode; if (!value.compatible) throw Object.assign(new Error('托盘不匹配'), { code: 'PALLET_INCOMPATIBLE' }); playSuccessSound() } catch (error) { if (!this.error) this.error = error } },
async submitReceipt() { if (!this.canSubmitReceipt) return; const isFba = this.activeInbound.destinationType === 'FBA'; const payload = isFba ? { inboundCode: this.activeInbound.inboundCode, palletCode: this.resolvedPallet.palletCode, packageCount: Number(this.packageCount) } : { cartonMark: this.activeInbound.cartonMark, palletCode: this.resolvedPallet.palletCode }; const key = idempotencyKey(isFba ? 'bulk-receipt' : 'carton-receipt', payload); try { const result = await this.run('receipt', () => isFba ? receiveCurrentBulk({ ...payload, idempotencyKey: key.value }) : receiveCurrentCarton({ ...payload, idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); receiving.lastReceipt = result; setCurrentReceiving(await getCurrentReceiving()); this.clearReceiptContext(); playSuccessSound() } catch (_) {} }, async submitReceipt() { if (!this.canSubmitReceipt) return; const isFba = this.activeInbound.destinationType === 'FBA'; const payload = isFba ? { inboundCode: this.activeInbound.inboundCode, palletCode: this.resolvedPallet.palletCode, packageCount: Number(this.packageCount) } : { cartonMark: this.activeInbound.cartonMark, palletCode: this.resolvedPallet.palletCode }; const key = idempotencyKey(isFba ? 'bulk-receipt' : 'carton-receipt', payload); try { const result = await this.run('提交实收', () => isFba ? receiveCurrentBulk({ ...payload, idempotencyKey: key.value }) : receiveCurrentCarton({ ...payload, idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); receiving.lastReceipt = result; setCurrentReceiving(await getCurrentReceiving()); this.clearReceiptContext(); playSuccessSound() } catch (_) {} },
async completeContainer() { if (!this.allInboundsReconciled) return; const key = idempotencyKey('complete', { containerNo: this.current.containerNo }); try { const value = await this.run('complete', () => completeCurrentReceiving({ idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); setCurrentReceiving(value) } catch (_) {} }, async completeContainer() { if (!this.allInboundsReconciled) return; const key = idempotencyKey('complete', { containerNo: this.current.containerNo }); try { const value = await this.run('完成入库', () => completeCurrentReceiving({ idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); setCurrentReceiving(value) } catch (_) {} },
async finishAndReturn() { resetReceivingDraft(); await this.loadCandidates(false) }, async finishAndReturn() { resetReceivingDraft(); await this.loadCandidates(false) },
showToast(message, type) { clearTimeout(this._toastTimer); this.toast = { visible: true, message, type }; this._toastTimer = setTimeout(() => { this.toast.visible = false }, 2600) } showToast(message, type) { clearTimeout(this._toastTimer); this.toast = { visible: true, message, type }; this._toastTimer = setTimeout(() => { this.toast.visible = false }, 2600) }
} }
+12 -17
View File
@@ -1,43 +1,38 @@
<template> <template>
<view v-if="user" class="page-shell portal-page"> <view v-if="user" class="page-shell portal-page">
<PortalHeader :title="$t('profile.title')" :user="user" :show-profile="false" /> <PortalHeader title="个人中心" :user="user" :show-profile="false" />
<view class="portal-content profile-content"> <view class="portal-content profile-content">
<view class="profile-card"><view class="profile-avatar">{{ avatar }}</view><view><text class="profile-name">{{ displayName }}</text><text class="profile-role">{{ user.role }} · {{ user.id }}</text></view></view> <view class="profile-card"><view class="profile-avatar">{{ avatar }}</view><view><text class="profile-name">{{ displayName }}</text><text class="profile-role">{{ user.role }} · {{ user.id }}</text></view></view>
<view class="profile-list"> <view class="profile-list">
<view class="profile-row"><text>{{ $t('profile.warehouse') }}</text><text class="profile-value">{{ warehouseDisplay }}</text></view> <view class="profile-row"><text>作业仓库</text><text class="profile-value">{{ warehouseDisplay }}</text></view>
<view class="profile-row"><text>{{ $t('profile.shift') }}</text><text class="profile-value">{{ $t('profile.shiftValue') }}</text></view> <view class="profile-row"><text>当前班次</text><text class="profile-value">以海外仓排班为准</text></view>
<view class="profile-row"><text>{{ $t('profile.version') }}</text><text class="profile-value">0.1.0</text></view> <view class="profile-row"><text>应用版本</text><text class="profile-value">0.1.0</text></view>
<picker mode="selector" :range="localeLabels" :value="localeIndex" @change="changeLocale"><view class="profile-row language-row"><text>{{ $t('profile.language') }}</text><text class="profile-value">{{ localeLabels[localeIndex] }} ›</text></view></picker>
</view> </view>
<button class="profile-action" @tap="showSettings">{{ $t('profile.settings') }} <text>›</text></button> <button class="profile-action" @tap="showSettings">系统设置 <text>›</text></button>
<button class="logout-action" @tap="openLogout">{{ $t('profile.logout') }}</button> <button class="logout-action" @tap="openLogout">退出登录</button>
</view> </view>
<uni-popup ref="logoutPopup" type="center" :is-mask-click="true" @change="handleLogoutChange"><view class="logout-dialog-card" role="dialog" aria-modal="true" :aria-label="$t('profile.logoutTitle')"><PopupHeader :title="$t('profile.logoutTitle')" tone="danger" @close="closeLogout" /><text class="logout-dialog-content">{{ $t('profile.logoutDescription') }}</text><view class="logout-dialog-actions"><button class="logout-dialog-cancel" @tap="closeLogout">{{ $t('common.cancel') }}</button><button class="logout-dialog-confirm" @tap="logout">{{ $t('common.confirm') }}</button></view></view></uni-popup> <uni-popup ref="logoutPopup" type="center" :is-mask-click="true" @change="handleLogoutChange"><view class="logout-dialog-card" role="dialog" aria-modal="true" aria-label="确认退出登录"><PopupHeader title="确认退出登录?" tone="danger" @close="closeLogout" /><text class="logout-dialog-content">退出后会清空海外 PDA 的本地登录会话;未提交作业数据不得跨账号保留。</text><view class="logout-dialog-actions"><button class="logout-dialog-cancel" @tap="closeLogout">取消</button><button class="logout-dialog-confirm" @tap="logout">确认退出</button></view></view></uni-popup>
<view v-if="toast" class="app-toast warning" role="status">{{ $t('profile.settingsLater') }}</view> <view v-if="toast" class="app-toast warning" role="status">设置功能将在后续版本开放</view>
</view> </view>
</template> </template>
<script> <script>
import PortalHeader from '@/components/PortalHeader.vue' import PortalHeader from '@/components/PortalHeader.vue'
import PopupHeader from '@/components/PopupHeader.vue' import PopupHeader from '@/components/PopupHeader.vue'
import { getCurrentWarehouseProfile } from '@/api/inbound.js' import { getCurrentWarehouseProfile } from '@/api/inbound.js'
import { currentLocale, LOCALE_OPTIONS, setLocale } from '@/i18n/index.js'
import { applyWarehouseProfile, session, requireSession, signOut } from '@/stores/session.js' import { applyWarehouseProfile, session, requireSession, signOut } from '@/stores/session.js'
export default { export default {
components: { PopupHeader, PortalHeader }, components: { PopupHeader, PortalHeader },
data() { return { user: session.currentUser, logoutOpen: false, toast: false, locale: currentLocale() } }, data() { return { user: session.currentUser, logoutOpen: false, toast: false } },
computed: { computed: {
localeLabels() { return LOCALE_OPTIONS.map((item) => this.$t(item.labelKey)) }, displayName() { const name = String(this.user?.name || ''); return name && !/^[a-z0-9._-]+$/i.test(name) ? name : '仓库作业员' },
localeIndex() { const index = LOCALE_OPTIONS.findIndex((item) => item.value === this.locale); return Math.max(0, index) },
displayName() { const name = String(this.user?.name || ''); return name && !/^[a-z0-9._-]+$/i.test(name) ? name : this.$t('common.warehouseOperator') },
avatar() { return this.displayName.slice(0, 1).toUpperCase() }, avatar() { return this.displayName.slice(0, 1).toUpperCase() },
warehouseDisplay() { return this.user?.warehouseName ? (this.user.warehouseCode ? `${this.user.warehouseName} · ${this.user.warehouseCode}` : this.user.warehouseName) : (this.user?.warehouse || this.$t('profile.warehousePending')) } warehouseDisplay() { return this.user?.warehouseName ? (this.user.warehouseCode ? `${this.user.warehouseName} · ${this.user.warehouseCode}` : this.user.warehouseName) : (this.user?.warehouse || '正在读取仓库主档') }
}, },
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.locale = currentLocale(); this.loadWarehouseProfile() }, onShow() { if (!requireSession()) return; this.user = session.currentUser; this.loadWarehouseProfile() },
beforeUnmount() { clearTimeout(this._toastTimer) }, beforeUnmount() { clearTimeout(this._toastTimer) },
onBackPress() { if (!this.logoutOpen) return false; this.closeLogout(); return true }, onBackPress() { if (!this.logoutOpen) return false; this.closeLogout(); return true },
methods: { methods: {
async loadWarehouseProfile() { try { this.user = applyWarehouseProfile(await getCurrentWarehouseProfile()) } catch (_) { this.user = session.currentUser } }, async loadWarehouseProfile() { try { this.user = applyWarehouseProfile(await getCurrentWarehouseProfile()) } catch (_) { this.user = session.currentUser } },
changeLocale(event) { this.locale = setLocale(LOCALE_OPTIONS[Number(event.detail.value)]?.value || 'en') },
openLogout() { this.logoutOpen = true; this.$refs.logoutPopup.open() }, closeLogout() { this.logoutOpen = false; this.$refs.logoutPopup.close() }, handleLogoutChange(event) { this.logoutOpen = Boolean(event.show) }, openLogout() { this.logoutOpen = true; this.$refs.logoutPopup.open() }, closeLogout() { this.logoutOpen = false; this.$refs.logoutPopup.close() }, handleLogoutChange(event) { this.logoutOpen = Boolean(event.show) },
showSettings() { clearTimeout(this._toastTimer); this.toast = true; this._toastTimer = setTimeout(() => { this.toast = false }, 2200) }, showSettings() { clearTimeout(this._toastTimer); this.toast = true; this._toastTimer = setTimeout(() => { this.toast = false }, 2200) },
logout() { signOut(); uni.reLaunch({ url: '/pages/login/login' }) } logout() { signOut(); uni.reLaunch({ url: '/pages/login/login' }) }
+14 -14
View File
@@ -1,6 +1,6 @@
<template> <template>
<view v-if="user" class="page-shell portal-page scanner-test-page"> <view v-if="user" class="page-shell portal-page scanner-test-page">
<PortalHeader :title="$t('scanner.title')" :user="user" /> <PortalHeader title="广播扫码测试" :user="user" />
<view class="scanner-test-content"> <view class="scanner-test-content">
<view class="scanner-status" :class="`scanner-status-${status}`" role="status"> <view class="scanner-status" :class="`scanner-status-${status}`" role="status">
<view class="scanner-status-dot"></view> <view class="scanner-status-dot"></view>
@@ -11,19 +11,19 @@
</view> </view>
<view class="scanner-result" :class="{ 'scanner-result-received': lastValue }"> <view class="scanner-result" :class="{ 'scanner-result-received': lastValue }">
<text class="scanner-result-label">{{ $t('scanner.latest') }}</text> <text class="scanner-result-label">最近收到</text>
<text class="scanner-result-value">{{ lastValue || $t('scanner.waiting') }}</text> <text class="scanner-result-value">{{ lastValue || '等待扫描' }}</text>
<text class="scanner-result-meta">{{ lastValue ? `${lastTime} · ${$t('scanner.count', { count: scanCount })}` : $t('scanner.hardwareHint') }}</text> <text class="scanner-result-meta">{{ lastValue ? `${lastTime} · 本页第 ${scanCount} 次` : '按下 PDA 硬件扫描键' }}</text>
</view> </view>
<view class="scanner-config"> <view class="scanner-config">
<view class="scanner-config-row"><text class="scanner-config-label">Action</text><text class="scanner-config-value">{{ action }}</text></view> <view class="scanner-config-row"><text class="scanner-config-label">Action</text><text class="scanner-config-value">{{ action }}</text></view>
<view class="scanner-config-row"><text class="scanner-config-label">{{ $t('scanner.extra') }}</text><text class="scanner-config-value">{{ extra }}</text></view> <view class="scanner-config-row"><text class="scanner-config-label">Extra 标签</text><text class="scanner-config-value">{{ extra }}</text></view>
</view> </view>
<view class="scanner-history-head"> <view class="scanner-history-head">
<text class="scanner-history-title">{{ $t('scanner.records') }}</text> <text class="scanner-history-title">接收记录</text>
<button class="scanner-clear" :disabled="!history.length" @tap="clearHistory">{{ $t('scanner.clear') }}</button> <button class="scanner-clear" :disabled="!history.length" @tap="clearHistory">清空</button>
</view> </view>
<view v-if="history.length" class="scanner-history"> <view v-if="history.length" class="scanner-history">
<view v-for="item in history" :key="item.id" class="scanner-history-item"> <view v-for="item in history" :key="item.id" class="scanner-history-item">
@@ -31,7 +31,7 @@
<view class="scanner-history-main"><text class="scanner-history-value">{{ item.value }}</text><text class="scanner-history-time">{{ item.time }}</text></view> <view class="scanner-history-main"><text class="scanner-history-value">{{ item.value }}</text><text class="scanner-history-time">{{ item.time }}</text></view>
</view> </view>
</view> </view>
<view v-else class="scanner-empty"><text>{{ $t('scanner.empty') }}</text></view> <view v-else class="scanner-empty"><text>暂无广播数据</text></view>
</view> </view>
</view> </view>
</template> </template>
@@ -57,14 +57,14 @@
}, },
computed: { computed: {
statusTitle() { statusTitle() {
if (this.status === 'error') return this.$t('scanner.errorTitle') if (this.status === 'error') return '接收器启动失败'
if (this.status === 'unsupported') return this.$t('scanner.unsupportedTitle') if (this.status === 'unsupported') return '当前环境不支持广播'
return this.$t(this.lastValue ? 'scanner.receivedTitle' : 'scanner.waitingTitle') return this.lastValue ? '已收到扫描广播' : '接收器已启动'
}, },
statusDetail() { statusDetail() {
if (this.status === 'error') return this.$t('scanner.errorDetail') if (this.status === 'error') return '请检查 PDA 扫描服务设置后重新进入页面'
if (this.status === 'unsupported') return this.$t('scanner.unsupportedDetail') if (this.status === 'unsupported') return '请在 Android App 真机中测试'
return this.$t(this.lastValue ? 'scanner.receivedDetail' : 'scanner.waitingDetail') return this.lastValue ? '继续扫描可验证连续接收' : '正在等待硬件扫描结果'
} }
}, },
onShow() { onShow() {