2026-08-20 20:10:20 +08:00
|
|
|
<template>
|
2026-08-24 19:23:23 +08:00
|
|
|
<view v-if="user" class="page-shell portal-page receiving-page">
|
2026-08-26 16:50:25 +08:00
|
|
|
<PortalHeader :title="inboundMode ? $t('operation.title') : operationName" :user="user" :fallback-url="`/pages/module/module?key=${moduleKey}`" />
|
2026-08-24 19:23:23 +08:00
|
|
|
<view class="portal-content receiving-content">
|
2026-08-24 15:25:36 +08:00
|
|
|
<template v-if="inboundMode">
|
2026-08-26 15:59:08 +08:00
|
|
|
<view class="receiving-heading"><view><text class="receiving-title">{{ pageTitle }}</text><text class="receiving-subtitle">{{ pageSubtitle }}</text></view></view>
|
2026-08-24 19:23:23 +08:00
|
|
|
|
2026-08-26 16:50:25 +08:00
|
|
|
<view v-if="loading" class="state-card"><uni-load-more status="loading" /><text>{{ $t('operation.restoring') }}</text></view>
|
2026-08-24 19:23:23 +08:00
|
|
|
|
|
|
|
|
<template v-else-if="phase === 'candidates'">
|
|
|
|
|
<view class="scan-card">
|
2026-08-26 16:50:25 +08:00
|
|
|
<text class="section-label">{{ $t('operation.scanContainer') }}</text>
|
|
|
|
|
<ScanTargetInput name="container" v-model="containerInput" :label="$t('operation.container')" :placeholder="$t('operation.containerPlaceholder')" :active="activeTarget === 'container'" @activate="activeTarget = 'container'" @submit="openContainer" />
|
|
|
|
|
<button class="primary-button" :disabled="busy || !containerInput.trim()" @tap="openContainer(containerInput)">{{ $t('operation.queryContainer') }}</button>
|
2026-08-24 19:23:23 +08:00
|
|
|
</view>
|
2026-08-26 16:50:25 +08:00
|
|
|
<view class="list-toolbar"><text>{{ $t('operation.pendingContainers', { count: candidates.length }) }}</text><button :disabled="busy" @tap="loadCandidates">{{ $t('common.refresh') }}</button></view>
|
|
|
|
|
<view v-if="!candidates.length" class="empty-card"><uni-icons type="info" color="#8090a3" size="34" /><text>{{ $t('operation.emptyContainers') }}</text></view>
|
2026-08-24 19:23:23 +08:00
|
|
|
<view v-for="item in candidates" :key="item.containerNo" class="container-card">
|
2026-08-26 16:50:25 +08:00
|
|
|
<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="progress-row"><text>{{ $t('operation.receivingProgress') }}</text><text><strong>{{ item.receivedPackages }}</strong> / {{ item.totalPackages }} {{ $t('common.boxes') }}</text></view>
|
2026-08-24 19:23:23 +08:00
|
|
|
<view class="progress-track"><view class="progress-fill" :style="{ width: percent(item.receivedPackages, item.totalPackages) + '%' }"></view></view>
|
2026-08-26 16:50:25 +08:00
|
|
|
<button class="primary-button" :disabled="busy" @tap="openContainer(item.containerNo)">{{ item.currentAccount ? $t('operation.continueCurrent') : $t('operation.viewDetail') }}</button>
|
2026-08-24 19:23:23 +08:00
|
|
|
</view>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<template v-else-if="phase === 'detail'">
|
2026-08-26 16:50:25 +08:00
|
|
|
<ReceivingSessionSummary :session="selected" />
|
|
|
|
|
<view class="lock-notice">{{ $t('operation.lockedNotice') }}</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>
|
2026-08-24 19:23:23 +08:00
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<template v-else-if="phase === 'sign'">
|
2026-08-26 16:50:25 +08:00
|
|
|
<ReceivingSessionSummary :session="current" />
|
2026-08-24 19:23:23 +08:00
|
|
|
<view class="photo-card">
|
2026-08-26 16:50:25 +08:00
|
|
|
<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>
|
2026-08-24 19:23:23 +08:00
|
|
|
<view class="photo-grid">
|
2026-08-26 15:59:08 +08:00
|
|
|
<view v-for="(step, index) in photoSteps" :key="step.role" class="photo-role" :class="{ complete: photoSlots[index]?.fileId }">
|
2026-08-26 16:50:25 +08:00
|
|
|
<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" />
|
|
|
|
|
<view v-else class="photo-placeholder"><uni-icons type="camera" color="#8090a3" size="23" /><text>{{ $t('operation.notAdded') }}</text></view>
|
2026-08-26 15:59:08 +08:00
|
|
|
</view>
|
2026-08-26 16:50:25 +08:00
|
|
|
<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>
|
|
|
|
|
<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>
|
|
|
|
|
</view>
|
|
|
|
|
<view class="photo-actions"><button :disabled="busy" @tap="choosePhoto(index)">{{ photoSlots[index]?.fileId ? $t('operation.retake') : $t('operation.takePhoto') }}</button></view>
|
2026-08-26 15:59:08 +08:00
|
|
|
</view>
|
2026-08-24 19:23:23 +08:00
|
|
|
</view>
|
|
|
|
|
</view>
|
2026-08-26 16:50:25 +08:00
|
|
|
<view class="sticky-action one"><button class="primary-button" :disabled="busy || !photosReady" @tap="signContainer">{{ $t('operation.signAndReceive') }}</button></view>
|
2026-08-24 19:23:23 +08:00
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<template v-else-if="phase === 'receive'">
|
2026-08-26 16:50:25 +08:00
|
|
|
<ReceivingSessionSummary :session="current" />
|
|
|
|
|
<view class="scan-card">
|
|
|
|
|
<text class="section-label">{{ $t('operation.scanCartonStep') }}</text>
|
|
|
|
|
<ScanTargetInput name="carton" v-model="cartonInput" :label="$t('operation.carton')" 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>
|
2026-08-24 19:23:23 +08:00
|
|
|
|
2026-08-26 16:50:25 +08:00
|
|
|
<template v-if="activeInbound">
|
|
|
|
|
<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>
|
|
|
|
|
<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>
|
|
|
|
|
<text class="section-label">{{ $t('operation.scanPalletStep') }}</text>
|
|
|
|
|
<ScanTargetInput name="pallet" v-model="palletInput" :label="$t('operation.pallet')" :placeholder="$t('operation.palletPlaceholder')" :active="activeTarget === 'pallet'" @activate="activeTarget = 'pallet'" @submit="resolvePallet" />
|
|
|
|
|
<button class="secondary-button" :disabled="busy || !palletInput.trim()" @tap="resolvePallet(palletInput)">{{ $t('operation.verifyPallet') }}</button>
|
|
|
|
|
<view v-if="resolvedPallet" class="resolved-card"><text>{{ resolvedPallet.palletCode }}</text><text>{{ resolvedPallet.compatible ? $t('operation.palletMatched') : $t('operation.palletMismatch') }}</text></view>
|
|
|
|
|
<template v-if="activeInbound.destinationType === 'FBA'">
|
|
|
|
|
<text class="section-label">{{ $t('operation.inputCountStep') }}</text>
|
|
|
|
|
<input v-model.number="packageCount" class="quantity-input" type="number" inputmode="numeric" :placeholder="$t('operation.maxCount', { count: activeInbound.pendingPackages })" />
|
|
|
|
|
</template>
|
|
|
|
|
<button class="primary-button receipt-button" :disabled="!canSubmitReceipt || busy" @tap="submitReceipt">{{ activeInbound.destinationType === 'FBA' ? $t('operation.confirmBulk') : $t('operation.confirmCarton') }}</button>
|
2026-08-24 19:23:23 +08:00
|
|
|
</template>
|
|
|
|
|
</view>
|
2026-08-26 16:50:25 +08:00
|
|
|
<view v-if="lastReceipt" class="success-card">{{ $t('operation.receiptSuccess', { accepted: lastReceipt.acceptedPackages, inbound: lastReceipt.inboundCode, pending: 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>
|
2026-08-24 19:23:23 +08:00
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<template v-else-if="phase === 'completed'">
|
2026-08-26 16:50:25 +08:00
|
|
|
<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>
|
2026-08-24 19:23:23 +08:00
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<view v-if="error" class="error-card">
|
|
|
|
|
<text class="error-message">{{ error.message }}</text>
|
|
|
|
|
<text v-if="error.code">code={{ error.code }}</text><text v-if="error.requestId">requestId={{ error.requestId }}</text><text v-if="error.traceId">traceId={{ error.traceId }}</text>
|
|
|
|
|
</view>
|
2026-08-24 15:25:36 +08:00
|
|
|
</template>
|
2026-08-24 19:23:23 +08:00
|
|
|
|
2026-08-26 15:59:08 +08:00
|
|
|
<template v-else>
|
2026-08-26 16:50:25 +08:00
|
|
|
<view class="receiving-heading"><view><text class="receiving-title">{{ operationName }}</text><text class="receiving-subtitle">{{ $t('operation.unavailableSubtitle') }}</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>
|
2026-08-24 19:23:23 +08:00
|
|
|
</template>
|
|
|
|
|
</view>
|
|
|
|
|
<view v-if="toast.visible" class="app-toast" :class="toast.type" role="status">{{ toast.message }}</view>
|
2026-08-20 20:10:20 +08:00
|
|
|
</view>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
import PortalHeader from '@/components/PortalHeader.vue'
|
2026-08-26 16:50:25 +08:00
|
|
|
import ReceivingSessionSummary from '@/components/ReceivingSessionSummary.vue'
|
2026-08-24 19:23:23 +08:00
|
|
|
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
2026-08-24 19:29:41 +08:00
|
|
|
import { isNoCurrentReceiving, isReceivingConflict, RECEIVING_PHOTO_ROLES } from '@/api/receiving-adapter.js'
|
2026-08-20 20:10:20 +08:00
|
|
|
import { session, requireSession } from '@/stores/session.js'
|
2026-08-26 16:50:25 +08:00
|
|
|
import { idempotencyKey, receiving, receivingPhase, receivingProgress, releaseIdempotencyKey, resetReceivingDraft, setCurrentReceiving } from '@/stores/receiving.js'
|
2026-08-20 20:10:20 +08:00
|
|
|
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
2026-08-24 19:23:23 +08:00
|
|
|
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
2026-08-26 16:50:25 +08:00
|
|
|
import { completeCurrentReceiving, downloadCurrentUnloadPhoto, getCurrentReceiving, getReceivingContainer, listReceivingCandidates, prepareReceivingContainer, receiveCurrentBulk, receiveCurrentCarton, resolveCurrentCarton, resolveCurrentPallet, signCurrentReceiving, uploadCurrentUnloadPhoto } from '@/api/inbound.js'
|
2026-08-24 19:23:23 +08:00
|
|
|
|
2026-08-20 20:10:20 +08:00
|
|
|
export default {
|
2026-08-26 16:50:25 +08:00
|
|
|
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: '' } } },
|
2026-08-24 19:23:23 +08:00
|
|
|
computed: {
|
2026-08-25 10:39:50 +08:00
|
|
|
inboundMode() { return this.moduleKey === 'inbound' },
|
2026-08-26 16:50:25 +08:00
|
|
|
operationName() { return this.$t(`modules.${this.opKey}.name`) },
|
|
|
|
|
photoSteps() { return RECEIVING_PHOTO_ROLES.map((role) => ({ role, label: this.$t(({ LICENSE_PLATE: 'operation.photoLicense', CONTAINER_SEAL: 'operation.photoSeal', DOOR_OPEN: 'operation.photoDoor' })[role]) })) },
|
2026-08-24 19:23:23 +08:00
|
|
|
candidates() { return receiving.candidates }, selected() { return receiving.selected }, current() { return receiving.current }, photoSlots() { return receiving.photoSlots },
|
2026-08-26 16:50:25 +08:00
|
|
|
resolvedCarton() { return receiving.resolvedCarton }, resolvedPallet() { return receiving.resolvedPallet }, lastReceipt() { return receiving.lastReceipt },
|
2026-08-24 19:23:23 +08:00
|
|
|
activeTarget: { get() { return receiving.activeScanTarget }, set(value) { receiving.activeScanTarget = value } },
|
2026-08-26 16:50:25 +08:00
|
|
|
phase() { return this.detailOpen && !this.current ? 'detail' : receivingPhase(this.current) }, progress() { return receivingProgress(this.current) }, activeInbound() { return this.resolvedCarton },
|
2026-08-26 15:59:08 +08:00
|
|
|
uploadedPhotoCount() { return this.photoSlots.filter((item) => item?.fileId).length }, photosReady() { return this.uploadedPhotoCount === 3 && !this.photoSlots.some((item) => item?.status === 'uploading') },
|
2026-08-26 16:50:25 +08:00
|
|
|
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) },
|
2026-08-24 19:29:41 +08:00
|
|
|
allInboundsReconciled() { const values = this.current?.inbounds || []; return values.length > 0 && values.every((item) => Number(item.pendingPackages) === 0) },
|
2026-08-26 16:50:25 +08:00
|
|
|
pageTitle() { return this.$t(`operationPhase.${this.phase}.title`) },
|
|
|
|
|
pageSubtitle() { return this.$t(`operationPhase.${this.phase}.subtitle`) }
|
2026-08-24 19:23:23 +08:00
|
|
|
},
|
2026-08-26 16:50:25 +08:00
|
|
|
onLoad(options) { this.moduleKey = options.module || 'query'; this.opKey = options.op || (this.moduleKey === 'inbound' ? 'receiving' : 'inventoryQuery') },
|
|
|
|
|
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.startScanner(); if (this.inboundMode && !this.bootstrapped) this.bootstrap() },
|
2026-08-24 19:23:23 +08:00
|
|
|
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { clearTimeout(this._toastTimer); this.stopScanner() },
|
2026-08-20 20:10:20 +08:00
|
|
|
methods: {
|
2026-08-26 16:50:25 +08:00
|
|
|
statusLabel(status) { const key = ({ WAIT_SIGN: 'statusWait', SIGNED: 'statusSigned', RECEIVING: 'statusReceiving', COMPLETED: 'statusCompleted' })[status]; return key ? this.$t(`operation.${key}`) : status },
|
2026-08-24 19:23:23 +08:00
|
|
|
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 },
|
2026-08-26 16:50:25 +08:00
|
|
|
destinationLabel(item) { return item?.destinationType === 'FBA' ? (item.fbaWarehouseCode || 'FBA') : (item?.destinationWarehouseName || item?.destinationWarehouseCode || this.$t('modules.inbound.name')) },
|
|
|
|
|
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') },
|
2026-08-24 19:23:23 +08:00
|
|
|
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 },
|
2026-08-26 16:50:25 +08:00
|
|
|
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) },
|
|
|
|
|
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 } },
|
|
|
|
|
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 } })) },
|
2026-08-26 15:59:08 +08:00
|
|
|
async applyCurrentReceiving(value) { setCurrentReceiving(value); await this.restorePhotoPreviews(); this.setScanTargetForCurrent(value) },
|
2026-08-26 16:50:25 +08:00
|
|
|
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 loadCandidates(feedback = true) { try { const work = () => listReceivingCandidates(); receiving.candidates = feedback ? await this.run('refresh', 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) } },
|
2026-08-24 19:23:23 +08:00
|
|
|
cancelDetail() { this.detailOpen = false; receiving.selected = null },
|
2026-08-26 16:50:25 +08:00
|
|
|
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) } },
|
|
|
|
|
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 } },
|
2026-08-26 15:59:08 +08:00
|
|
|
previewPhoto(index) { const path = this.photoSlots[index]?.localPath; if (!path) return; uni.previewImage({ urls: [path], current: path }) },
|
2026-08-26 16:50:25 +08:00
|
|
|
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 (_) {} },
|
|
|
|
|
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 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 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 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 (_) {} },
|
2026-08-24 19:23:23 +08:00
|
|
|
async finishAndReturn() { resetReceivingDraft(); await this.loadCandidates(false) },
|
2026-08-24 15:25:36 +08:00
|
|
|
showToast(message, type) { clearTimeout(this._toastTimer); this.toast = { visible: true, message, type }; this._toastTimer = setTimeout(() => { this.toast.visible = false }, 2600) }
|
2026-08-20 20:10:20 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</script>
|
2026-08-24 15:25:36 +08:00
|
|
|
|
|
|
|
|
<style scoped>
|
2026-08-24 19:23:23 +08:00
|
|
|
.receiving-page{padding-bottom:calc(88px + env(safe-area-inset-bottom,0px))}.receiving-content{max-width:640px}.receiving-heading,.card-head,.progress-row,.list-toolbar{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.receiving-heading{margin-bottom:14px}.receiving-title,.receiving-subtitle,.container-no,.container-meta,.card-title,.section-label,.resolved-card text,.completed-card text,.error-card text,.prototype-tip{display:block}.receiving-title{font-size:21px;font-weight:800}.receiving-subtitle{margin-top:4px;color:#6f8194;font-size:12px;line-height:18px}.real-badge,.prototype-badge,.status-tag{flex-shrink:0;padding:5px 8px;border-radius:7px;font-size:11px;font-weight:700}.real-badge{color:#1668dc;background:#eaf2ff}.prototype-badge{color:#6f8194;background:#edf1f5}.scan-card,.container-card,.inbound-card,.inbound-list-card,.photo-card,.state-card,.empty-card,.completed-card{margin-bottom:12px;padding:14px;border:1px solid #dfe7ef;border-radius:14px;background:#fff;box-shadow:0 2px 10px rgba(16,24,40,.05)}.section-label,.card-title{margin-bottom:9px;color:#17324d;font-size:14px;font-weight:800}.primary-button,.secondary-button,.ghost-button{box-sizing:border-box;min-height:46px;border-radius:9px;font-size:13px;font-weight:700}.primary-button{width:100%;margin-top:10px;color:#fff;background:#1668dc}.secondary-button{width:100%;margin-top:8px;color:#1668dc;border:1px solid #9dbce9;background:#f4f8ff}.ghost-button{color:#496176;border:1px solid #d3dce6;background:#fff}.primary-button[disabled],.secondary-button[disabled]{color:#8b96a3;background:#e6eaf0;opacity:1}.list-toolbar{align-items:center;margin:8px 2px 12px;color:#6f8194;font-size:12px}.list-toolbar button{min-height:40px;padding:0 12px;color:#1668dc;background:transparent}.container-no{font-size:18px;font-weight:800}.container-meta{margin-top:4px;color:#6f8194;font-size:11px;line-height:17px}.status-wait{color:#a35b0c;background:#fff1dd}.status-current{color:#1668dc;background:#eaf2ff}.status-good{color:#10856f;background:#e2f6f0}.progress-row{margin-top:14px;color:#6f8194;font-size:12px}.progress-row strong{color:#10856f;font-size:16px}.progress-track{height:7px;margin-top:8px;overflow:hidden;border-radius:99px;background:#e7edf3}.progress-fill{height:100%;border-radius:inherit;background:#10856f}.empty-card,.state-card,.completed-card{display:flex;align-items:center;flex-direction:column;gap:10px;padding:34px 18px;color:#6f8194;text-align:center}.summary-card{border-color:#a9c8f3}.inbound-row{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:11px 0;border-top:1px solid #edf1f5}.inbound-code{display:block;font-size:13px;font-weight:750}.inbound-count{flex-shrink:0;color:#6f8194;font-size:11px}.inbound-count strong{color:#10856f;font-size:17px}.lock-notice,.success-card,.error-card{margin-bottom:12px;padding:11px 12px;border-radius:9px;font-size:12px;line-height:18px}.lock-notice{color:#86540c;background:#fff4df}.success-card{color:#0b6c5d;background:#e6f7f1}.error-card{color:#b42318;background:#fff1f0}.error-message{font-weight:700}.photo-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:14px}.photo-slot{display:flex;align-items:center;flex-direction:column;justify-content:center;min-height:112px;padding:8px;border:1px solid #d5dee8;border-radius:10px;color:#496176;font-size:11px;background:#f8fafc}.photo-slot.complete{border-color:#9cd0bf;color:#10856f;background:#effaf6}.photo-action{margin-top:6px;font-size:10px}.inbound-tabs{width:100%;white-space:nowrap}.inbound-tab{display:inline-flex;vertical-align:top;align-items:flex-start;flex-direction:column;min-width:190px;min-height:62px;margin-right:8px;padding:9px 11px;border:1px solid #d6e0eb;border-radius:9px;color:#496176;font-size:10px;text-align:left;background:#f8fafc}.inbound-tab text:first-child{margin-bottom:5px;color:#17324d;font-size:12px;font-weight:750}.inbound-tab.active{border:2px solid #1668dc;background:#f2f7ff}.inbound-tab.done{opacity:.65}.mode-banner{display:flex;justify-content:space-between;gap:8px;m
|
2026-08-25 10:39:50 +08:00
|
|
|
.blocked-card{display:flex;align-items:center;flex-direction:column;gap:10px;margin-bottom:12px;padding:34px 20px;border:1px solid #dfe7ef;border-radius:14px;color:#6f8194;text-align:center;line-height:20px;background:#fff;box-shadow:0 2px 10px rgba(16,24,40,.05)}.blocked-title{color:#17324d;font-size:17px;font-weight:800}.prototype-badge{color:#8b610e;background:#fff1dd}
|
2026-08-26 16:50:25 +08:00
|
|
|
.photo-grid{display:grid;grid-template-columns:1fr;gap:9px;margin-top:12px}
|
|
|
|
|
.photo-role{display:grid;min-width:0;min-height:94px;padding:10px;align-items:center;grid-template-columns:82px minmax(0,1fr) 78px;gap:10px;border:1px solid #d5dee8;border-radius:11px;background:#f8fafc}
|
|
|
|
|
.photo-role.complete{border-color:#9cd0bf;background:#f2fbf7}
|
|
|
|
|
.photo-preview{box-sizing:border-box;display:flex;width:82px;height:72px;overflow:hidden;align-items:center;justify-content:center;border:1px solid #dce4ec;border-radius:8px;background:#e9eef4}
|
|
|
|
|
.photo-preview.failed{border-color:#e8a09a;background:#fff7f6}
|
|
|
|
|
.photo-thumb{width:100%;height:100%}
|
|
|
|
|
.photo-placeholder{display:flex;width:100%;height:100%;align-items:center;flex-direction:column;justify-content:center;gap:3px;color:#8090a3;font-size:9px}
|
|
|
|
|
.photo-role-info{display:flex;min-width:0;flex-direction:column}
|
|
|
|
|
.photo-role-head{display:flex;align-items:center;justify-content:flex-start;gap:7px;color:#17324d;font-size:12px;font-weight:750}
|
|
|
|
|
.photo-required,.photo-valid{padding:2px 6px;border-radius:99px;font-size:9px;line-height:14px}
|
|
|
|
|
.photo-required{color:#a35b0c;background:#fff1dd}.photo-valid{color:#08705f;background:#dff5ed}
|
|
|
|
|
.photo-state{margin-top:6px;color:#6f8194;font-size:10px;line-height:15px;text-align:left}
|
|
|
|
|
.photo-error{margin-top:3px;overflow:hidden;color:#c9362b;font-size:9px;line-height:13px;text-overflow:ellipsis;white-space:nowrap}
|
|
|
|
|
.photo-actions{display:grid;grid-template-columns:1fr}
|
|
|
|
|
.photo-actions button{box-sizing:border-box;min-height:44px;padding:0 5px;border:1px solid #8fb8f5;border-radius:8px;color:#1668dc;font-size:11px;font-weight:700;line-height:44px;background:#fff}
|
|
|
|
|
.matched-inbound-card{margin:14px 0;padding:12px;border:1px solid #cfe0f2;border-radius:10px;background:#f8fbff}
|
|
|
|
|
.matched-inbound-card .mode-banner{margin-bottom:10px}
|
|
|
|
|
.matched-inbound-code{display:block;color:#17324d;font-size:15px;font-weight:800}
|
|
|
|
|
.matched-inbound-progress{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px;margin-top:8px;color:#5f7185;font-size:10px}
|
|
|
|
|
.matched-inbound-progress text{padding:6px 4px;border-radius:6px;text-align:center;background:#edf3f9}
|
|
|
|
|
@media(max-width:360px){.photo-role{grid-template-columns:68px minmax(0,1fr) 64px;gap:7px;padding:8px}.photo-preview{width:68px;height:64px}.photo-actions button{min-height:34px;padding:0 2px;line-height:34px}.photo-state{font-size:9px;line-height:13px}}
|
|
|
|
|
.receiving-page{padding-bottom:env(safe-area-inset-bottom,0px)}
|
|
|
|
|
.receiving-content{box-sizing:border-box;padding-bottom:calc(152px + env(safe-area-inset-bottom,0px))}
|
|
|
|
|
.primary-button,.secondary-button,.ghost-button{display:flex;box-sizing:border-box;align-items:center;justify-content:center;padding-top:0;padding-bottom:0;line-height:normal;text-align:center}
|
|
|
|
|
.sticky-action button{display:flex;align-items:center;justify-content:center;line-height:normal;text-align:center}
|
2026-08-24 15:25:36 +08:00
|
|
|
</style>
|