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">
|
|
|
|
|
|
<PortalHeader :title="inboundMode ? '拆柜收货' : operationName" :user="user" :fallback-url="`/pages/module/module?key=${moduleKey}`" />
|
|
|
|
|
|
<view class="portal-content receiving-content">
|
2026-08-24 15:25:36 +08:00
|
|
|
|
<template v-if="inboundMode">
|
2026-08-24 19:23:23 +08:00
|
|
|
|
<view class="receiving-heading">
|
|
|
|
|
|
<view><text class="receiving-title">{{ pageTitle }}</text><text class="receiving-subtitle">{{ pageSubtitle }}</text></view>
|
|
|
|
|
|
<text class="real-badge">真实接口</text>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
|
|
|
|
|
|
<view v-if="loading" class="state-card"><uni-load-more status="loading" /><text>正在恢复当前柜作业…</text></view>
|
|
|
|
|
|
|
|
|
|
|
|
<template v-else-if="phase === 'candidates'">
|
|
|
|
|
|
<view class="scan-card">
|
|
|
|
|
|
<text class="section-label">扫描柜号</text>
|
|
|
|
|
|
<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)">查询柜号</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>暂无待卸货集装箱</text></view>
|
|
|
|
|
|
<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 }} 个入仓码 · {{ 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>收货进度</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>
|
|
|
|
|
|
<button class="primary-button" :disabled="busy" @tap="openContainer(item.containerNo)">{{ item.currentAccount ? '继续当前柜' : '查看详情' }}</button>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<template v-else-if="phase === 'detail'">
|
|
|
|
|
|
<SessionSummary :session="selected" />
|
|
|
|
|
|
<InboundList :items="selected?.inbounds || []" />
|
|
|
|
|
|
<view class="lock-notice">选择后账号将锁定当前柜,完成前不能切换其他柜号。</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 v-else-if="phase === 'sign'">
|
|
|
|
|
|
<SessionSummary :session="current" />
|
|
|
|
|
|
<view class="photo-card">
|
|
|
|
|
|
<view class="card-head"><view><text class="card-title">卸货签收照片</text><text class="container-meta">需完成 3 个现场照片槽位</text></view><text class="status-tag" :class="photosReady ? 'status-good' : 'status-wait'">{{ uploadedPhotoCount }} / 3</text></view>
|
|
|
|
|
|
<view class="photo-grid">
|
|
|
|
|
|
<button v-for="(step, index) in photoSteps" :key="step.role" class="photo-slot" :class="{ complete: photoSlots[index]?.fileId }" :disabled="busy" @tap="choosePhoto(index)">
|
|
|
|
|
|
<uni-icons :type="photoSlots[index]?.fileId ? 'checkmarkempty' : 'camera'" :color="photoSlots[index]?.fileId ? '#10856f' : '#6f8194'" size="28" />
|
|
|
|
|
|
<text>{{ step.label }}</text><text class="photo-action">{{ photoSlots[index]?.fileId ? '重拍' : '拍照' }}</text>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
<InboundList :items="current?.inbounds || []" />
|
|
|
|
|
|
<view class="sticky-action one"><button class="primary-button" :disabled="busy || !photosReady" @tap="signContainer">确认签收并开始收货</button></view>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<template v-else-if="phase === 'receive'">
|
|
|
|
|
|
<SessionSummary :session="current" />
|
|
|
|
|
|
<view class="inbound-card">
|
|
|
|
|
|
<text class="card-title">选择入仓码</text>
|
|
|
|
|
|
<scroll-view class="inbound-tabs" scroll-x>
|
|
|
|
|
|
<button v-for="item in current?.inbounds || []" :key="item.inboundCode" class="inbound-tab" :class="{ active: selectedInboundCode === item.inboundCode, done: item.pendingPackages === 0 }" @tap="chooseInbound(item)">
|
|
|
|
|
|
<text>{{ item.inboundCode }}</text><text>{{ destinationLabel(item) }} · 待收 {{ item.pendingPackages }} 箱</text>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</scroll-view>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
|
|
|
|
|
|
<view v-if="activeInbound" class="scan-card">
|
|
|
|
|
|
<view class="mode-banner" :class="activeInbound.destinationType === 'FBA' ? 'fba' : 'overseas'">
|
|
|
|
|
|
<text>{{ activeInbound.destinationType === 'FBA' ? 'FBA 按箱数收货' : '海外仓逐箱扫码收货' }}</text>
|
|
|
|
|
|
<text>{{ destinationLabel(activeInbound) }}</text>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
<template v-if="activeInbound.destinationType === 'OVERSEAS'">
|
|
|
|
|
|
<text class="section-label">1. 扫描箱唛</text>
|
|
|
|
|
|
<ScanTargetInput name="carton" v-model="cartonInput" label="箱唛" placeholder="扫描或人工输入箱唛" :active="activeTarget === 'carton'" @activate="activeTarget = 'carton'" @submit="resolveCarton" />
|
|
|
|
|
|
<button class="secondary-button" :disabled="busy || !cartonInput.trim()" @tap="resolveCarton(cartonInput)">解析箱唛</button>
|
|
|
|
|
|
<view v-if="resolvedCarton" class="resolved-card"><text>{{ resolvedCarton.cartonMark }}</text><text>{{ resolvedCarton.skuCode || '无 SKU' }} · {{ resolvedCarton.inboundCode }}</text></view>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
|
<text class="section-label">1. 扫描 SKU</text>
|
|
|
|
|
|
<ScanTargetInput name="sku" v-model="skuInput" label="SKU" placeholder="扫描或人工输入 SKU" :active="activeTarget === 'sku'" @activate="activeTarget = 'sku'" @submit="resolveSku" />
|
|
|
|
|
|
<button class="secondary-button" :disabled="busy || !skuInput.trim()" @tap="resolveSku(skuInput)">解析 SKU</button>
|
|
|
|
|
|
<view v-if="resolvedSku" class="resolved-card"><text>{{ resolvedSku.skuCode }}</text><text>{{ resolvedSku.inboundCode }} · 待收 {{ resolvedSku.pendingPackages ?? resolvedSku.totalPackages ?? '—' }} 箱</text></view>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<text class="section-label">2. 扫描实物托盘码</text>
|
|
|
|
|
|
<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)">校验托盘</button>
|
|
|
|
|
|
<view v-if="resolvedPallet" class="resolved-card"><text>{{ resolvedPallet.palletCode }}</text><text>{{ resolvedPallet.compatible ? '目的仓匹配,可收货' : '托盘不匹配' }}</text></view>
|
|
|
|
|
|
<template v-if="activeInbound.destinationType === 'FBA'">
|
|
|
|
|
|
<text class="section-label">3. 输入本次箱数</text>
|
|
|
|
|
|
<input v-model.number="packageCount" class="quantity-input" type="number" inputmode="numeric" :placeholder="`最多可收 ${activeInbound.pendingPackages} 箱`" />
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<button class="primary-button receipt-button" :disabled="!canSubmitReceipt || busy" @tap="submitReceipt">{{ activeInbound.destinationType === 'FBA' ? '确认按箱数收货' : '确认本箱收货' }}</button>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
<InboundList :items="current?.inbounds || []" />
|
|
|
|
|
|
<view v-if="lastReceipt" class="success-card">本次已收 {{ lastReceipt.acceptedPackages }} 箱,{{ lastReceipt.inboundCode }} 剩余 {{ lastReceipt.pendingPackages }} 箱</view>
|
2026-08-24 19:29:41 +08:00
|
|
|
|
<view class="sticky-action one"><button class="primary-button complete-button" :disabled="busy || !allInboundsReconciled" @tap="completeContainer">{{ allInboundsReconciled ? '每个入仓码已核平,完成整柜入库' : `尚有 ${progress.pending} 箱未收` }}</button></view>
|
2026-08-24 19:23:23 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<template v-else-if="phase === 'completed'">
|
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
|
|
<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
|
|
|
|
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
|
<view class="receiving-heading"><view><text class="receiving-title">{{ operationName }}</text><text class="receiving-subtitle">{{ prototype.subtitle }}</text></view><text class="prototype-badge">静态原型</text></view>
|
|
|
|
|
|
<view class="scan-card"><ScanTargetInput name="prototype" v-model="prototypeInput" :label="prototype.scanLabel" :placeholder="prototype.scanPlaceholder" active @submit="prototypeScan" /><button class="secondary-button" @tap="prototypeScan">确认</button><text class="prototype-tip">该库内模块尚未接入真实业务接口,不会模拟提交成功。</text></view>
|
|
|
|
|
|
</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-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-24 15:25:36 +08:00
|
|
|
|
import { getOperationPrototype } from '@/data/overseas-workbench.js'
|
2026-08-20 20:10:20 +08:00
|
|
|
|
import { session, requireSession } from '@/stores/session.js'
|
2026-08-24 19:23:23 +08:00
|
|
|
|
import { idempotencyKey, receiving, receivingPhase, receivingProgress, releaseIdempotencyKey, resetReceivingDraft, selectedInbound, 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'
|
|
|
|
|
|
import { completeCurrentReceiving, getCurrentReceiving, getReceivingContainer, listReceivingCandidates, prepareReceivingContainer, receiveCurrentBulk, receiveCurrentCarton, resolveCurrentCarton, resolveCurrentPallet, resolveCurrentSku, signCurrentReceiving, uploadCurrentUnloadPhoto } from '@/api/inbound.js'
|
|
|
|
|
|
|
|
|
|
|
|
const SessionSummary = {
|
|
|
|
|
|
props: { session: Object },
|
|
|
|
|
|
computed: { pct() { return this.session?.totalPackages ? Math.min(100, Math.round(Number(this.session.receivedPackages || 0) / Number(this.session.totalPackages) * 100)) : 0 } },
|
|
|
|
|
|
template: `<view class="container-card summary-card"><view class="card-head"><view><text class="container-no">{{ session?.containerNo }}</text><text class="container-meta">当前账号锁定柜号</text></view><text class="status-tag status-current">{{ session?.status }}</text></view><view class="progress-row"><text>收货进度</text><text><strong>{{ session?.receivedPackages }}</strong> / {{ session?.totalPackages }} 箱</text></view><view class="progress-track"><view class="progress-fill" :style="{ width: pct + '%' }"></view></view></view>`
|
|
|
|
|
|
}
|
|
|
|
|
|
const InboundList = {
|
|
|
|
|
|
props: { items: { type: Array, default: () => [] } },
|
|
|
|
|
|
methods: { destination(item) { return item.destinationType === 'FBA' ? (item.fbaWarehouseCode || 'FBA') : (item.destinationWarehouseName || item.destinationWarehouseCode || '海外仓') } },
|
|
|
|
|
|
template: `<view class="inbound-list-card"><text class="card-title">柜内入仓码</text><view v-for="item in items" :key="item.inboundCode" class="inbound-row"><view><text class="inbound-code">{{ item.inboundCode }}</text><text class="container-meta">{{ item.destinationType }} · {{ destination(item) }} · 计划 {{ item.plannedPalletCount }} 托</text></view><view class="inbound-count"><strong>{{ item.receivedPackages }}</strong> / {{ item.totalPackages }} 箱</view></view></view>`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-20 20:10:20 +08:00
|
|
|
|
export default {
|
2026-08-24 19:23:23 +08:00
|
|
|
|
components: { PortalHeader, ScanTargetInput, SessionSummary, InboundList },
|
2026-08-24 19:29:41 +08:00
|
|
|
|
data() { return { user: session.currentUser, moduleKey: 'query', operationName: '库存查询', loading: false, busy: false, detailOpen: false, containerInput: '', cartonInput: '', skuInput: '', palletInput: '', packageCount: 1, prototypeInput: '', error: null, toast: { visible: false, message: '', type: '' }, photoSteps: RECEIVING_PHOTO_ROLES.map((role) => ({ role, label: ({ LICENSE_PLATE: '车牌照片', CONTAINER_SEAL: '集装箱封签', DOOR_OPEN: '开柜现场' })[role] })) } },
|
2026-08-24 19:23:23 +08:00
|
|
|
|
computed: {
|
|
|
|
|
|
prototype() { return getOperationPrototype(this.operationName) }, inboundMode() { return this.moduleKey === 'inbound' },
|
|
|
|
|
|
candidates() { return receiving.candidates }, selected() { return receiving.selected }, current() { return receiving.current }, photoSlots() { return receiving.photoSlots },
|
|
|
|
|
|
selectedInboundCode: { get() { return receiving.selectedInboundCode }, set(value) { receiving.selectedInboundCode = value } },
|
|
|
|
|
|
resolvedCarton() { return receiving.resolvedCarton }, resolvedSku() { return receiving.resolvedSku }, resolvedPallet() { return receiving.resolvedPallet }, lastReceipt() { return receiving.lastReceipt },
|
|
|
|
|
|
activeTarget: { get() { return receiving.activeScanTarget }, set(value) { receiving.activeScanTarget = value } },
|
|
|
|
|
|
phase() { return this.detailOpen && !this.current ? 'detail' : receivingPhase(this.current) }, progress() { return receivingProgress(this.current) }, activeInbound() { return selectedInbound(this.current) },
|
|
|
|
|
|
uploadedPhotoCount() { return this.photoSlots.filter((item) => item?.fileId).length }, photosReady() { return this.uploadedPhotoCount === 3 },
|
2026-08-24 19:29:41 +08:00
|
|
|
|
canSubmitReceipt() { if (!this.activeInbound || !this.resolvedPallet?.compatible) return false; if (this.activeInbound.destinationType === 'FBA') { const count = Number(this.packageCount); return this.resolvedSku?.inboundCode === this.activeInbound.inboundCode && Boolean(this.resolvedSku?.skuCode) && Number.isInteger(count) && count > 0 && count <= Number(this.activeInbound.pendingPackages) } return this.resolvedCarton?.inboundCode === this.activeInbound.inboundCode && Boolean(this.resolvedCarton?.cartonMark) },
|
|
|
|
|
|
allInboundsReconciled() { const values = this.current?.inbounds || []; return values.length > 0 && values.every((item) => Number(item.pendingPackages) === 0) },
|
2026-08-24 19:23:23 +08:00
|
|
|
|
pageTitle() { return { candidates: '待卸货集装箱', detail: '柜号详情', sign: '拆柜拍照签收', receive: '当前柜收货', completed: '入库完成' }[this.phase] },
|
|
|
|
|
|
pageSubtitle() { return { candidates: '选择集装箱后拍摄 3 张照片完成签收', detail: '核对全部入仓码、目的仓与箱数', sign: '完成现场取证后进入收货', receive: '扫码箱唛与托盘,按目的类型收货', completed: '库存与柜级进度已由服务端核平' }[this.phase] }
|
|
|
|
|
|
},
|
|
|
|
|
|
onLoad(options) { this.moduleKey = options.module || 'query'; this.operationName = decodeURIComponent(options.name || '库存查询') },
|
|
|
|
|
|
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.startScanner(); if (this.inboundMode) this.bootstrap() },
|
|
|
|
|
|
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { clearTimeout(this._toastTimer); this.stopScanner() },
|
2026-08-20 20:10:20 +08:00
|
|
|
|
methods: {
|
2026-08-24 19:23:23 +08:00
|
|
|
|
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 },
|
|
|
|
|
|
destinationLabel(item) { return item?.destinationType === 'FBA' ? (item.fbaWarehouseCode || 'FBA') : (item?.destinationWarehouseName || item?.destinationWarehouseCode || '海外仓') },
|
|
|
|
|
|
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) { this.prototypeInput = value; return this.prototypeScan() } if (this.phase === 'candidates') { this.containerInput = value; return this.openContainer(value) } if (this.phase !== 'receive') return; if (this.activeTarget === 'carton' && this.activeInbound?.destinationType === 'OVERSEAS') { this.cartonInput = value; return this.resolveCarton(value) } if (this.activeTarget === 'sku' && this.activeInbound?.destinationType === 'FBA') { this.skuInput = value; return this.resolveSku(value) } this.palletInput = value; return this.resolvePallet(value) },
|
|
|
|
|
|
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 } },
|
2026-08-24 19:29:41 +08:00
|
|
|
|
setScanTargetForCurrent(value) { receiving.activeScanTarget = value?.status === 'WAIT_SIGN' ? 'container' : (selectedInbound(value)?.destinationType === 'OVERSEAS' ? 'carton' : 'sku') },
|
|
|
|
|
|
async bootstrap() { this.loading = true; this.error = null; this.detailOpen = false; try { const current = await getCurrentReceiving(); setCurrentReceiving(current); this.setScanTargetForCurrent(current) } catch (error) { if (isNoCurrentReceiving(error)) { resetReceivingDraft(); await this.loadCandidates(false) } else this.error = error } finally { this.loading = false } },
|
|
|
|
|
|
async recoverCurrentAfterConflict(error) { if (!isReceivingConflict(error)) return false; try { const current = await getCurrentReceiving(); setCurrentReceiving(current); this.setScanTargetForCurrent(current); this.detailOpen = false; this.error = null; this.showToast(`账号已有未完成柜 ${current.containerNo},已返回当前柜`, 'warning'); return true } catch (recoveryError) { this.error = recoveryError; return false } },
|
2026-08-24 19:23:23 +08:00
|
|
|
|
async loadCandidates(feedback = true) { try { const work = () => listReceivingCandidates(); receiving.candidates = feedback ? await this.run('刷新待卸柜', work) : await work() } catch (_) {} },
|
2026-08-24 19:29:41 +08:00
|
|
|
|
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) } },
|
2026-08-24 19:23:23 +08:00
|
|
|
|
cancelDetail() { this.detailOpen = false; receiving.selected = null },
|
2026-08-24 19:29:41 +08:00
|
|
|
|
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) } },
|
2026-08-24 19:23:23 +08:00
|
|
|
|
choosePhoto(index) { if (this.busy) return; uni.chooseImage({ count: 1, sourceType: ['camera', 'album'], success: async ({ tempFilePaths }) => { const path = tempFilePaths?.[0]; if (!path) return; try { const file = await this.run('上传签收照片', () => uploadCurrentUnloadPhoto(path)); receiving.photoSlots.splice(index, 1, { ...file, fileId: String(file.fileId), role: this.photoSteps[index].role, localPath: path, uploaded: true }) } catch (_) {} } }) },
|
|
|
|
|
|
async signContainer() { if (!this.photosReady) return; const photos = this.photoSlots.map((item, index) => ({ role: this.photoSteps[index].role, fileId: Number(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); receiving.activeScanTarget = selectedInbound(value)?.destinationType === 'OVERSEAS' ? 'carton' : 'sku' } catch (_) {} },
|
|
|
|
|
|
chooseInbound(item) { receiving.selectedInboundCode = item.inboundCode; receiving.resolvedCarton = null; receiving.resolvedSku = null; receiving.resolvedPallet = null; this.cartonInput = ''; this.skuInput = ''; this.palletInput = ''; this.packageCount = Math.max(1, Number(item.pendingPackages || 1)); receiving.activeScanTarget = item.destinationType === 'OVERSEAS' ? 'carton' : 'sku' },
|
|
|
|
|
|
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.selectedInboundCode = value.inboundCode; receiving.resolvedPallet = null; this.cartonInput = value.cartonMark; this.palletInput = ''; receiving.activeScanTarget = 'pallet'; playSuccessSound() } catch (_) {} },
|
|
|
|
|
|
async resolveSku(raw) { const skuCode = String(raw?.detail?.value ?? raw ?? this.skuInput).trim(); if (!skuCode || this.busy) return; try { const value = await this.run('解析 SKU', () => resolveCurrentSku(skuCode)); receiving.resolvedSku = value; receiving.selectedInboundCode = value.inboundCode; receiving.resolvedPallet = null; this.skuInput = value.skuCode; this.palletInput = ''; 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('校验托盘', () => 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, skuCode: this.resolvedSku.skuCode, palletCode: this.resolvedPallet.palletCode, packageCount: Number(this.packageCount) } : { cartonMark: this.resolvedCarton.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()); receiving.resolvedCarton = null; receiving.resolvedSku = null; receiving.resolvedPallet = null; this.cartonInput = ''; this.skuInput = ''; this.palletInput = ''; const inbound = selectedInbound(); this.packageCount = Math.max(1, Number(inbound?.pendingPackages || 1)); receiving.activeScanTarget = inbound?.destinationType === 'OVERSEAS' ? 'carton' : 'sku'; playSuccessSound() } catch (_) {} },
|
2026-08-24 19:29:41 +08:00
|
|
|
|
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 (_) {} },
|
2026-08-24 19:23:23 +08:00
|
|
|
|
async finishAndReturn() { resetReceivingDraft(); await this.loadCandidates(false) },
|
|
|
|
|
|
prototypeScan() { if (!String(this.prototypeInput || '').trim()) return this.showToast('请先扫描或输入编码', 'warning'); this.showToast('该模块尚未接入真实业务接口', 'warning') },
|
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
|
|
|
|
|
|
@media(max-width:340px){.photo-grid{grid-template-columns:1fr}.photo-slot{min-height:82px}.sticky-action{right:6px;left:6px}}
|
2026-08-24 15:25:36 +08:00
|
|
|
|
</style>
|