This commit is contained in:
2026-08-28 13:54:38 +08:00
parent a617386f9d
commit 4810b44452
6 changed files with 81 additions and 17 deletions
+10 -1
View File
@@ -15,14 +15,23 @@ export function normalizeReceivingInbound(value = {}) {
export function normalizeReceivingCandidate(value = {}) {
return {
...value,
sourceContainerBookingId: typeof value.sourceContainerBookingId === 'string' ? value.sourceContainerBookingId.trim() : '',
containerNo: String(value.containerNo || '').trim(),
plannedArrivalAt: typeof value.plannedArrivalAt === 'string' && value.plannedArrivalAt.trim() ? value.plannedArrivalAt.trim() : null,
totalOrderCount: number(value.totalOrderCount),
planCount: number(value.planCount),
inboundCodeCount: number(value.inboundCodeCount),
missingInboundCodeCount: number(value.missingInboundCodeCount),
plannedInboundCodeCount: number(value.plannedInboundCodeCount),
pendingPlanCount: number(value.pendingPlanCount),
destinationCodes: Array.isArray(value.destinationCodes) ? value.destinationCodes.filter(Boolean) : [],
plannedPalletCount: number(value.plannedPalletCount),
totalPackages: number(value.totalPackages),
receivedPackages: number(value.receivedPackages),
currentAccount: value.currentAccount === true
currentAccount: value.currentAccount === true,
allPlansReady: value.allPlansReady === true,
operable: value.operable === true,
blockReason: typeof value.blockReason === 'string' ? value.blockReason.trim() : ''
}
}
+24 -9
View File
@@ -12,17 +12,30 @@
<view v-if="candidateError" class="error-card candidate-error-card"><text class="error-message">{{ candidateError.message || '待卸货集装箱加载失败' }}</text><text v-if="candidateError.code">code={{ candidateError.code }}</text><text v-if="candidateError.requestId">requestId={{ candidateError.requestId }}</text><text v-if="candidateError.traceId">traceId={{ candidateError.traceId }}</text><button class="secondary-button" :disabled="candidateLoading" @tap="loadCandidates">重新加载</button></view>
<view v-if="candidateLoading && !candidates.length" class="state-card"><uni-load-more status="loading" /><text>正在刷新待卸货集装箱…</text></view>
<view v-else-if="!candidateError && !candidates.length" class="empty-card"><uni-icons type="info" color="#8090a3" size="34" /><text>暂无待卸货集装箱</text><button class="secondary-button" :disabled="candidateLoading" @tap="loadCandidates">重新加载</button></view>
<view v-for="item in candidates" :key="item.containerNo" class="container-card container-card-compact" role="button" :aria-label="`${item.containerNo},${item.currentAccount ? '继续当前柜' : '查看详情'}`" hover-class="container-card-pressed" @tap="openContainer(item.containerNo)">
<view class="card-head"><text class="container-no">{{ item.containerNo }}</text><view class="container-card-actions"><text class="status-tag" :class="statusTone(item.status)">{{ statusLabel(item.status) }}</text><text class="container-detail-link">{{ item.currentAccount ? '继续' : '详情' }} ›</text></view></view>
<view class="candidate-facts"><view><strong>{{ item.inboundCodeCount }}</strong><text>入仓码</text></view><view><strong>{{ item.totalPackages }}</strong><text>箱</text></view><view><strong>{{ item.plannedPalletCount }}</strong><text>计划托盘</text></view><view><strong>{{ item.destinationCodes?.length || 0 }}</strong><text>目的仓</text></view></view>
<view v-for="item in candidates" :key="item.containerNo" class="container-card container-card-compact" :class="{ 'container-card-disabled': !item.operable }" role="button" :aria-label="`${item.containerNo},${item.operable ? '查看可操作柜详情' : '查看未齐全柜摘要'}`" hover-class="container-card-pressed" @tap="openContainer(item)">
<view class="card-head"><view><text class="container-no">{{ item.containerNo }}</text><text class="candidate-arrival">计划到仓:{{ formatPlannedArrival(item.plannedArrivalAt) }}</text></view><view class="container-card-actions"><text class="status-tag" :class="item.operable ? statusTone(item.status) : 'status-disabled'">{{ item.operable ? statusLabel(item.status) : '待齐全' }}</text><text class="container-detail-link">{{ item.operable ? (item.currentAccount ? '继续' : '详情') : '只读' }} ›</text></view></view>
<view class="candidate-facts"><view><strong>{{ item.totalOrderCount }}</strong><text>总订单</text></view><view><strong>{{ item.inboundCodeCount }}</strong><text>入仓码</text></view><view><strong>{{ item.planCount }}</strong><text>已生成计划</text></view><view><strong>{{ item.pendingPlanCount }}</strong><text>未操作</text></view></view>
<text class="candidate-progress-copy">已生成 {{ item.planCount }} 个入库计划,{{ item.pendingPlanCount }} 个未操作<template v-if="item.missingInboundCodeCount">,其中 {{ item.missingInboundCodeCount }} 个缺入仓码</template></text>
<text v-if="!item.operable" class="candidate-block-reason">{{ item.blockReason || '当前柜入库计划尚未齐全' }}</text>
<text v-if="item.destinationCodes?.length" class="candidate-destinations">仓码:{{ item.destinationCodes.join(' / ') }}</text>
</view>
</template>
<template v-else-if="phase === 'detail'">
<ReceivingSessionSummary :session="selected" />
<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 v-if="selected?.operable">
<ReceivingSessionSummary :session="selected" />
<view class="lock-notice">选择后账号将锁定当前柜,完成前不能切换其他柜号。</view>
<view class="sticky-action"><button class="ghost-button" :disabled="busy" @tap="cancelDetail">返回列表</button><button class="primary-button" :disabled="busy || !selected.allPlansReady || !selected.operable" @tap="prepareContainer">选择此柜并开始拍照</button></view>
</template>
<template v-else>
<view class="readonly-candidate-card">
<view class="card-head"><view><text class="container-no">{{ selected?.containerNo }}</text><text class="candidate-arrival">计划到仓:{{ formatPlannedArrival(selected?.plannedArrivalAt) }}</text></view><text class="status-tag status-disabled">暂不可操作</text></view>
<view class="candidate-facts"><view><strong>{{ selected?.totalOrderCount }}</strong><text>总订单</text></view><view><strong>{{ selected?.inboundCodeCount }}</strong><text>入仓码</text></view><view><strong>{{ selected?.planCount }}</strong><text>已生成计划</text></view><view><strong>{{ selected?.pendingPlanCount }}</strong><text>未操作</text></view></view>
<text class="candidate-progress-copy">已生成 {{ selected?.planCount }} 个入库计划,{{ selected?.pendingPlanCount }} 个未操作<template v-if="selected?.missingInboundCodeCount">,其中 {{ selected.missingInboundCodeCount }} 个缺入仓码</template></text>
<text class="candidate-block-reason">{{ selected?.blockReason || '当前柜入库计划尚未齐全' }}</text>
</view>
<view class="sticky-action one"><button class="ghost-button" :disabled="busy" @tap="cancelDetail">返回列表</button></view>
</template>
</template>
<template v-else-if="phase === 'sign'">
@@ -128,6 +141,7 @@
methods: {
statusLabel(status) { return ({ WAIT_SIGN: '待签收', SIGNED: '已签收', RECEIVING: '收货中', COMPLETED: '已完成' })[status] || status },
statusTone(status) { return status === 'RECEIVING' ? 'status-current' : status === 'COMPLETED' ? 'status-good' : 'status-wait' },
formatPlannedArrival(value) { const text = String(value || '').trim(); return text ? text.replace('T', ' ').slice(0, 16) : '待确认' },
destinationLabel(item) { return item?.destinationType === 'FBA' ? (item.fbaWarehouseCode || 'FBA') : (item?.destinationWarehouseName || item?.destinationWarehouseCode || '海外仓') },
photoStateLabel(photo) { if (photo?.status === 'uploading') return '正在上传…'; if (photo?.status === 'error') return photo.errorMessage || (photo.fileId ? '重拍失败,已保留原图' : '上传失败,请重试'); return photo?.fileId ? '点击左侧照片可查看大图' : '请拍摄现场照片' },
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleHardwareScan(value), onError: (error) => { this.error = error; this.showToast('硬件扫码接收器异常,请检查扫描服务', 'warning') } }) }, stopScanner() { if (typeof this._stopScanner === 'function') this._stopScanner(); this._stopScanner = null },
@@ -137,11 +151,11 @@
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 bootstrap() { if (this.loading) return; this.loading = true; this.error = null; this.detailOpen = false; try { const current = await getCurrentReceiving(); await this.applyCurrentReceiving(current); this.candidateError = null; this.bootstrapped = true } catch (error) { if (isNoCurrentReceiving(error)) { resetReceivingDraft(); await this.loadCandidates(false); this.bootstrapped = true } else this.candidateError = 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(`账号已有未完成柜 ${current.containerNo},已返回当前柜`, 'warning'); return true } catch (recoveryError) { if (isNoCurrentReceiving(recoveryError)) { this.detailOpen = false; receiving.selected = null; await this.loadCandidates(false); this.error = null; this.showToast('该柜已被其他账号选择,列表已刷新', 'warning'); return true } this.error = error; return false } },
async recoverCurrentAfterConflict(error, preserveEvidence = false) { if (!isReceivingConflict(error)) return false; try { const current = await getCurrentReceiving(); await this.applyCurrentReceiving(current); this.detailOpen = false; this.error = preserveEvidence ? error : null; this.showToast(`账号已有未完成柜 ${current.containerNo},已返回当前柜`, 'warning'); return true } catch (recoveryError) { if (isNoCurrentReceiving(recoveryError)) { this.detailOpen = false; receiving.selected = null; await this.loadCandidates(false); this.candidateError = preserveEvidence ? error : null; this.error = preserveEvidence ? error : null; this.showToast('柜状态已变化,列表已刷新', 'warning'); return true } this.error = error; return false } },
async loadCandidates(feedback = true) { if (this.candidateLoading) return; this.candidateLoading = true; this.candidateError = null; this.error = null; try { receiving.candidates = await listReceivingCandidates(); if (feedback) this.showToast('刷新待卸柜成功', 'success') } catch (error) { this.candidateError = error; if (feedback) { playErrorSound(); this.showToast(error.message || '刷新待卸柜失败', 'error') } } finally { this.candidateLoading = false } },
async openContainer(raw) { const containerNo = String(raw?.detail?.value ?? raw ?? '').trim(); if (!containerNo || this.busy) return; try { receiving.selected = await this.run('查询柜号', () => getReceivingContainer(containerNo)); this.detailOpen = true; playSuccessSound() } catch (error) { await this.recoverCurrentAfterConflict(error) } },
async openContainer(candidate) { const containerNo = String(candidate?.containerNo || '').trim(); if (!containerNo || this.busy) return; this.error = null; if (!candidate.operable) { receiving.selected = candidate; this.detailOpen = true; return } try { const detail = await this.run('查询柜号', () => getReceivingContainer(containerNo)); receiving.selected = { ...candidate, ...detail, allPlansReady: candidate.allPlansReady, operable: candidate.operable, blockReason: candidate.blockReason }; this.detailOpen = true; playSuccessSound() } catch (error) { await this.recoverCurrentAfterConflict(error) } },
cancelDetail() { this.detailOpen = false; receiving.selected = null },
async prepareContainer() { if (!this.selected?.containerNo || this.busy) 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) } },
async prepareContainer() { if (!this.selected?.containerNo || !this.selected?.allPlansReady || !this.selected?.operable || this.busy) 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, true) } },
choosePhoto(index) { if (this.busy) return; uni.chooseImage({ count: 1, sourceType: ['camera'], success: ({ tempFilePaths, tempFiles }) => { const path = tempFilePaths?.[0]; const size = Number(tempFiles?.[0]?.size || 0); if (size > MAX_UNLOAD_PHOTO_BYTES) { const message = '照片不能超过10MB'; this.error = Object.assign(new Error(message), { code: 'A0400' }); this.showToast(message, 'error'); playErrorSound(); return } 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.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; this.showToast(error.message || '照片上传失败', 'error'); playErrorSound() } finally { this.busy = false } },
previewPhoto(index) { const path = this.photoSlots[index]?.localPath; if (!path) return; uni.previewImage({ urls: [path], current: path }) },
@@ -187,4 +201,5 @@
.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}
.list-toolbar{height:28px;margin:0 2px 7px;align-items:center}.list-toolbar button{box-sizing:border-box;width:auto;min-width:48px;height:28px;min-height:28px;margin:0;padding:0 8px;border:1px solid #bdd1eb;border-radius:7px;color:#1668dc;font-size:11px;line-height:26px;background:#f7faff}.container-card-compact{padding:11px 13px;cursor:pointer;transition:background-color .12s ease,border-color .12s ease}.container-card-pressed{border-color:#93baf0;background:#f4f8ff}.container-card-actions{display:flex;flex-shrink:0;align-items:flex-end;flex-direction:column;gap:5px}.container-detail-link{box-sizing:border-box;min-height:24px;padding:0 4px;color:#1668dc;font-size:11px;font-weight:700;line-height:24px}.candidate-facts{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin-top:9px;padding:8px 0;border-top:1px solid #edf1f5;border-bottom:1px solid #edf1f5}.candidate-facts view{display:flex;align-items:center;flex-direction:column;border-right:1px solid #edf1f5}.candidate-facts view:last-child{border-right:0}.candidate-facts strong{color:#17324d;font-size:17px;line-height:21px}.candidate-facts text{margin-top:2px;color:#6f8194;font-size:10px}.candidate-destinations{display:block;margin-top:7px;overflow:hidden;color:#6f8194;font-size:11px;line-height:17px;text-overflow:ellipsis;white-space:nowrap}
.candidate-arrival{display:block;margin-top:3px;color:#718095;font-size:10px;line-height:15px}.candidate-progress-copy{display:block;margin-top:7px;color:#435a72;font-size:10px;line-height:16px}.candidate-block-reason{display:block;margin-top:6px;padding:6px 8px;border-radius:7px;color:#73551b;font-size:10px;line-height:16px;background:#f0e9dc}.container-card-disabled{border-color:#d9dee5;background:#f1f3f5;box-shadow:none}.container-card-disabled .container-no,.container-card-disabled .candidate-facts strong{color:#778391}.container-card-disabled .container-detail-link{color:#788797}.container-card-disabled .candidate-progress-copy,.container-card-disabled .candidate-arrival,.container-card-disabled .candidate-destinations{color:#87919d}.status-disabled{color:#788491;background:#e1e5e9}.readonly-candidate-card{padding:13px;border:1px solid #d9dee5;border-radius:13px;background:#f1f3f5}.readonly-candidate-card .candidate-block-reason{margin-top:9px}
</style>