Files
pda-overseas-uniapp/src/pages/operation/operation.vue
T
2026-08-26 16:58:41 +08:00

192 lines
31 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<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">
<template v-if="inboundMode">
<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>正在恢复当前柜作业…</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'">
<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>
<template v-else-if="phase === 'sign'">
<ReceivingSessionSummary :session="current" />
<view class="photo-card">
<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 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)">
<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>未添加</text></view>
</view>
<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 ? '已上传' : '必传' }}</text></view>
<text class="photo-state">{{ photoStateLabel(photoSlots[index]) }}</text>
<text v-if="photoSlots[index]?.status === 'error'" class="photo-error">{{ photoSlots[index].errorMessage || '请重试' }}</text>
</view>
<view class="photo-actions"><button :disabled="busy" @tap="choosePhoto(index)">{{ photoSlots[index]?.fileId ? '重拍' : '拍照' }}</button></view>
</view>
</view>
</view>
<view class="sticky-action one"><button class="primary-button" :disabled="busy || !photosReady" @tap="signContainer">确认签收并开始收货</button></view>
</template>
<template v-else-if="phase === 'receive'">
<ReceivingSessionSummary :session="current" />
<view class="scan-card">
<text class="section-label">1. 扫描箱码定位入仓码</text>
<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)">解析箱码</button>
<template v-if="activeInbound">
<view class="matched-inbound-card">
<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>
<view class="matched-inbound-progress"><text>总计 {{ activeInbound.totalPackages }} 箱</text><text>已收 {{ activeInbound.receivedPackages }} 箱</text><text>待收 {{ activeInbound.pendingPackages }} 箱</text></view>
</view>
<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>
</template>
</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 ? '每个入仓码已核平,完成整柜入库' : `尚有 ${progress.pending} 箱未收` }}</button></view>
</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>
</template>
<template v-else>
<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">功能暂未开放</text><text>现有平台、客户或内部接口不能由仓库作业员直接调用。扫码不会提交,也不会产生模拟成功结果。</text></view>
</template>
</view>
<view v-if="toast.visible" class="app-toast" :class="toast.type" role="status">{{ toast.message }}</view>
</view>
</template>
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import ReceivingSessionSummary from '@/components/ReceivingSessionSummary.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { isNoCurrentReceiving, isReceivingConflict, RECEIVING_PHOTO_ROLES } from '@/api/receiving-adapter.js'
import { session, requireSession } from '@/stores/session.js'
import { idempotencyKey, receiving, receivingPhase, receivingProgress, releaseIdempotencyKey, resetReceivingDraft, setCurrentReceiving } from '@/stores/receiving.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
import { completeCurrentReceiving, downloadCurrentUnloadPhoto, getCurrentReceiving, getReceivingContainer, listReceivingCandidates, prepareReceivingContainer, receiveCurrentBulk, receiveCurrentCarton, resolveCurrentCarton, resolveCurrentPallet, signCurrentReceiving, uploadCurrentUnloadPhoto } from '@/api/inbound.js'
export default {
components: { PortalHeader, ReceivingSessionSummary, ScanTargetInput },
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: {
inboundMode() { return this.moduleKey === 'inbound' },
photoSteps() { return RECEIVING_PHOTO_ROLES.map((role) => ({ role, label: ({ LICENSE_PLATE: '车牌照片', CONTAINER_SEAL: '集装箱封签', DOOR_OPEN: '开柜现场' })[role] })) },
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 },
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 this.resolvedCarton },
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) },
allInboundsReconciled() { const values = this.current?.inbounds || []; return values.length > 0 && values.every((item) => Number(item.pendingPackages) === 0) },
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'; 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() },
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { clearTimeout(this._toastTimer); this.stopScanner() },
methods: {
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 || '海外仓') },
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 },
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(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' },
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() { 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(`账号已有未完成柜 ${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('刷新待卸柜', 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('查询柜号', () => getReceivingContainer(containerNo)); this.containerInput = containerNo; this.detailOpen = true; playSuccessSound() } catch (error) { await this.recoverCurrentAfterConflict(error) } },
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('选择当前柜', () => 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.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 }) },
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' },
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('校验托盘', () => 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('提交实收', () => 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('完成入库', () => completeCurrentReceiving({ idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); setCurrentReceiving(value) } catch (_) {} },
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) }
}
}
</script>
<style scoped>
.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;margin-bottom:13px;padding:10px;border-radius:9px;font-size:12px;font-weight:700}.mode-banner.fba{color:#8b5508;background:#fff1dd}.mode-banner.overseas{color:#08705f;background:#e2f6f0}.scan-card .section-label:not(:first-child){margin-top:14px}.resolved-card{margin-top:8px;padding:9px 11px;border-left:3px solid #10856f;color:#0b6c5d;font-size:11px;background:#eaf8f3}.resolved-card text:first-child{font-size:13px;font-weight:750}.quantity-input{box-sizing:border-box;width:100%;height:48px;padding:0 12px;border:2px solid #1668dc;border-radius:8px;font-size:18px;font-weight:750;background:#fff}.receipt-button{margin-top:14px}.sticky-action{position:fixed;z-index:8;right:10px;bottom:8px;left:10px;display:grid;grid-template-columns:1fr 2fr;gap:9px;max-width:620px;margin:auto;padding:10px;border:1px solid #dbe2ea;border-radius:14px;background:#fff;box-shadow:0 6px 20px rgba(31,41,55,.16)}.sticky-action.one{grid-template-columns:1fr}.sticky-action .primary-button{margin-top:0}.completed-icon{display:grid;place-items:center;width:56px;height:56px;border-radius:50%;color:#fff;font-size:30px;background:#10856f}.completed-title{color:#17324d;font-size:20px;font-weight:800}.prototype-tip{margin-top:10px;color:#8b610e;font-size:11px}
.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}
.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}
</style>