feat: complete overseas PDA warehouse operations

This commit is contained in:
2026-09-04 12:42:17 +08:00
parent e8f5d053ee
commit f960b9fe5f
18 changed files with 678 additions and 66 deletions
+65
View File
@@ -0,0 +1,65 @@
<template>
<view v-if="user" class="page-shell portal-page work-page">
<PortalHeader title="商品移库" :user="user" fallback-url="/pages/module/module?key=inventory" />
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
<view class="work-heading"><text class="work-title">整托移库</text><text class="work-subtitle">扫描来源托盘和同仓目标库位,按服务端资源版本一次移动整托库存</text></view>
<view v-if="restoring" class="empty-box"><uni-load-more status="loading" /><text class="empty-copy">正在恢复未完成的移库草稿…</text></view>
<view v-else-if="!source && !sourceCode" class="empty-box"><text class="empty-copy">暂无已读取托盘,请先扫描或输入来源托盘码</text></view>
<view class="work-card">
<text class="card-title">1. 来源托盘</text>
<ScanTargetInput name="source" v-model="sourceCode" label="托盘码" placeholder="扫描或输入来源托盘码" :active="activeTarget === 'source'" @activate="activeTarget = 'source'" @submit="resolveSource" />
<button class="secondary" :disabled="!sourceCode.trim() || busy" @tap="resolveSource(sourceCode)">{{ resolving === 'source' ? '查询中…' : '读取托盘状态' }}</button>
<view v-if="source" class="task-card section-gap"><view class="row-head"><view><text class="business-code">{{ source.palletCode }}</text><text class="meta-line">当前位置:{{ source.locationCode || '未绑定库位' }}</text></view><text class="status-pill">{{ statusLabel(source.status) }}</text></view><view class="metric-row"><view class="metric"><strong>{{ source.occupiedPackages }}</strong><text>在托箱数</text></view><view class="metric"><strong>{{ source.inventoryPositionCount }}</strong><text>库存位置</text></view></view></view>
</view>
<view class="work-card">
<text class="card-title">2. 目标库位</text>
<ScanTargetInput name="target" v-model="targetCode" label="库位码" placeholder="扫描或输入目标库位码" :active="activeTarget === 'target'" @activate="activeTarget = 'target'" @submit="resolveTarget" />
<button class="secondary" :disabled="!targetCode.trim() || busy" @tap="resolveTarget(targetCode)">{{ resolving === 'target' ? '校验中…' : '校验目标库位' }}</button>
<view v-if="target" class="task-card section-gap"><view class="row-head"><view><text class="business-code">{{ target.locationCode }}</text><text class="meta-line">{{ locationTypeLabel(target.locationType) }}</text></view><text class="status-pill">{{ statusLabel(target.status) }}</text></view><view class="metric-row"><view class="metric"><strong>{{ target.occupiedPackages }}</strong><text>已占箱数</text></view><view class="metric"><strong>{{ target.inventoryPositionCount }}</strong><text>库存位置</text></view></view></view>
</view>
<view class="work-card"><text class="card-title">3. 移库原因</text><textarea v-model="reason" class="reason-input" maxlength="500" placeholder="必填,例如:库位容量调整" /><button class="primary" :disabled="!canMove || busy" @tap="submitMove">{{ busyAction === 'move' ? '移库中…' : '确认整托移库' }}</button><text class="hint">来源和目标必须属于 Token 绑定的同一海外仓;冲突后必须重新扫描。</text></view>
<view v-if="result" class="success-box" role="status"><text class="success-title">移库完成</text><text>{{ result.moveNo || '移库单号待生成' }}</text><text>{{ result.palletCode }}:{{ result.sourceLocationCode || '未绑定库位' }} → {{ result.targetLocationCode || '—' }}</text><text>{{ result.packageCount }} 箱 · {{ result.replayed ? '幂等恢复结果' : '本次提交成功' }}</text><button class="secondary" @tap="resetPage">继续移库</button></view>
<view v-if="error" class="error-box" role="alert"><text class="error-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><button class="secondary" @tap="refreshFacts">重新读取托盘和库位</button></view>
</view></scroll-view>
</view>
</template>
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { moveInventory, resolveWarehouseLocation, resolveWarehousePallet } from '@/api/warehouse-operations.js'
import { session, requireSession } from '@/stores/session.js'
import { releaseWarehouseKey, resetWarehouseDomain, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
const isConflict = (error) => ['409', 'A0409', 'B0409'].includes(String(error?.code || ''))
export default {
components: { PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, source: null, target: null, activeTarget: 'source', resolving: '', restoring: false, busy: false, busyAction: '', error: null, result: null } },
computed: {
sourceCode: { get() { return warehouseWork.move.sourcePalletCode }, set(value) { updateWarehouseWork('move', { sourcePalletCode: value }); if (this.source?.palletCode !== String(value || '').trim()) this.source = null } },
targetCode: { get() { return warehouseWork.move.targetLocationCode }, set(value) { updateWarehouseWork('move', { targetLocationCode: value }); if (this.target?.locationCode !== String(value || '').trim()) this.target = null } },
reason: { get() { return warehouseWork.move.reason }, set(value) { updateWarehouseWork('move', { reason: value }) } },
canMove() { return Boolean(this.source && this.target && this.reason.trim() && this.source.locationCode !== this.target.locationCode) }
},
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.startScanner(); this.restoreDraftFacts() },
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
methods: {
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleScan(value), onError: (error) => this.setError(error, '扫码广播注册失败') }) },
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
handleScan(value) { if (this.activeTarget === 'target') { this.targetCode = value; this.resolveTarget(value) } else { this.sourceCode = value; this.resolveSource(value) } },
async restoreDraftFacts() { this.restoring = true; try { if (this.sourceCode) await this.resolveSource(this.sourceCode, false); if (this.targetCode) await this.resolveTarget(this.targetCode, false) } finally { this.restoring = false } },
async resolveSource(raw, sound = true) { const code = String(raw?.detail?.value ?? raw ?? this.sourceCode).trim(); if (!code || this.busy) return; this.sourceCode = code; this.resolving = 'source'; this.error = null; try { this.source = await resolveWarehousePallet(code); this.sourceCode = this.source.palletCode; if (sound) playSuccessSound(); this.activeTarget = 'target' } catch (error) { this.setError(error, '来源托盘查询失败') } finally { this.resolving = '' } },
async resolveTarget(raw, sound = true) { const code = String(raw?.detail?.value ?? raw ?? this.targetCode).trim(); if (!code || this.busy) return; this.targetCode = code; this.resolving = 'target'; this.error = null; try { this.target = await resolveWarehouseLocation(code); this.targetCode = this.target.locationCode; if (sound) playSuccessSound() } catch (error) { this.setError(error, '目标库位校验失败') } finally { this.resolving = '' } },
async submitMove() { if (!this.canMove || this.busy) return; const payload = { sourcePalletCode: this.source.palletCode, targetLocationCode: this.target.locationCode, expectedPalletVersion: Number(this.source.version || 0), reason: this.reason.trim() }; const key = warehouseIdempotencyKey('move', 'move', payload); this.busy = true; this.busyAction = 'move'; this.error = null; this.result = null; try { this.result = await moveInventory({ ...payload, idempotencyKey: key.value }); releaseWarehouseKey('move', key.signature); playSuccessSound(); updateWarehouseWork('move', { sourcePalletCode: '', targetLocationCode: '', reason: '' }); this.source = null; this.target = null } catch (error) { this.setError(error, '整托移库失败'); if (isConflict(error)) { releaseWarehouseKey('move', key.signature); await this.refreshFacts(false); if (this.error) this.error.message += ',已刷新最新资源版本,请重新确认' } } finally { this.busy = false; this.busyAction = '' } },
async refreshFacts(clearError = true) { const retainedError = clearError ? null : this.error; const sourceCode = this.sourceCode; const targetCode = this.targetCode; this.source = null; this.target = null; try { if (sourceCode) this.source = await resolveWarehousePallet(sourceCode); if (targetCode) this.target = await resolveWarehouseLocation(targetCode); this.error = retainedError } catch (error) { this.setError(error, '最新托盘或库位状态读取失败') } },
resetPage() { resetWarehouseDomain('move'); this.source = null; this.target = null; this.result = null; this.error = null; this.activeTarget = 'source' },
setError(error, fallback) { this.error = { message: error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId }; playErrorSound() },
statusLabel(value) { return ({ ACTIVE: '可用', IDLE: '空闲', OCCUPIED: '已占用', LOCKED: '已锁定' })[value] || value || '状态未知' },
locationTypeLabel(value) { return ({ STORAGE: '存储库位', STAGING: '暂存库位', RELABEL: '换标库位', RECEIVING: '收货库位' })[value] || value || '库位类型未标注' }
}
}
</script>
<style scoped lang="scss">@import '../warehouse/operations.scss';</style>
+55
View File
@@ -0,0 +1,55 @@
<template>
<view v-if="user" class="page-shell portal-page work-page">
<PortalHeader :title="title" :user="user" fallback-url="/pages/module/module?key=query" />
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
<view class="work-heading"><text class="work-title">{{ title }}</text><text class="work-subtitle">{{ subtitle }};查询范围固定为 Token 绑定的海外仓</text></view>
<view class="work-card"><ScanTargetInput name="query" v-model="code" :label="label" :placeholder="placeholder" :active="true" @submit="search" /><button class="primary" :disabled="!code.trim() || loading" @tap="search(code)">{{ loading ? '查询中…' : '查询' }}</button></view>
<view v-if="loading" class="empty-box"><uni-load-more status="loading" /><text class="empty-copy">正在读取最新库存事实…</text></view>
<template v-else-if="searched && mode === 'inventory'">
<view v-if="!inventoryItems.length" class="empty-box"><uni-icons type="info" color="#8090a3" size="32" /><text class="empty-copy">未找到与 {{ result?.queryCode || code }} 匹配的库存</text></view>
<view v-else class="item-list"><view v-for="(item,index) in inventoryItems" :key="`${item.palletCode}:${item.locationCode}:${item.skuCode}:${index}`" class="item-card"><view class="row-head"><view><text class="business-code">{{ item.skuCode || 'SKU 未关联' }}</text><text class="meta-line">{{ item.productName || '商品名称未关联' }}</text></view><text class="status-pill">{{ statusLabel(item.status) }}</text></view><text class="meta-line">入仓码:{{ item.inboundCode || '—' }}</text><text class="meta-line">箱码:{{ item.cartonMark || '—' }}</text><text class="meta-line">托盘:{{ item.palletCode || '未绑定' }} · 库位:{{ item.locationCode || '未绑定' }}</text><view class="metric-row"><view class="metric"><strong>{{ item.availablePackages }}</strong><text>可用</text></view><view class="metric"><strong>{{ item.holdPackages }}</strong><text>冻结</text></view><view class="metric"><strong>{{ item.receivingPackages }}</strong><text>收货中</text></view></view></view></view>
</template>
<view v-else-if="searched && mode === 'location' && result" class="work-card"><view class="row-head"><view><text class="business-code">{{ result.locationCode }}</text><text class="meta-line">{{ locationTypeLabel(result.locationType) }}</text></view><text class="status-pill">{{ statusLabel(result.status) }}</text></view><view class="metric-row"><view class="metric"><strong>{{ result.occupiedPackages }}</strong><text>在库箱数</text></view><view class="metric"><strong>{{ result.inventoryPositionCount }}</strong><text>库存位置</text></view><view class="metric"><strong>{{ result.maxLoadKg || '—' }}</strong><text>最大载重 kg</text></view></view></view>
<template v-else-if="searched && mode === 'pallet' && result"><view class="work-card"><view class="row-head"><view><text class="business-code">{{ result.palletCode }}</text><text class="meta-line">库位:{{ result.locationCode || '未绑定' }}</text></view><text class="status-pill">{{ statusLabel(result.status) }}</text></view><view class="metric-row"><view class="metric"><strong>{{ result.occupiedPackages }}</strong><text>在托箱数</text></view><view class="metric"><strong>{{ result.inventoryPositionCount }}</strong><text>库存位置</text></view></view></view><view v-if="!palletItems.length" class="empty-box"><text class="empty-copy">当前托盘未绑定库存</text></view><view v-else class="item-list"><view v-for="(item,index) in palletItems" :key="`${item.skuCode}:${item.inboundCode}:${index}`" class="item-card"><text class="business-code">{{ item.skuCode || 'SKU 未关联' }}</text><text class="meta-line">{{ item.productName || '商品名称未关联' }}</text><text class="meta-line">{{ item.inboundCode || '无入仓码' }} · {{ item.cartonMark || '无箱码' }}</text><view class="metric-row"><view class="metric"><strong>{{ item.availablePackages }}</strong><text>可用</text></view><view class="metric"><strong>{{ item.holdPackages }}</strong><text>冻结</text></view><view class="metric"><strong>{{ item.receivingPackages }}</strong><text>收货中</text></view></view></view></view></template>
<view v-if="error" class="error-box" role="alert"><text class="error-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><button class="secondary" @tap="search(code)">重新查询</button></view>
</view></scroll-view>
</view>
</template>
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { resolveInventory, resolveWarehouseLocation, resolveWarehousePallet } from '@/api/warehouse-operations.js'
import { session, requireSession } from '@/stores/session.js'
import { restoreWarehouseWork, updateWarehouseWork, warehouseWork } from '@/stores/warehouse-work.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
const MODES = {
inventoryQuery: { mode: 'inventory', title: '库存查询', label: '商品码或箱码', placeholder: '扫描或输入 SKU / 箱码', subtitle: '查看可用、冻结和收货中库存' },
locationQuery: { mode: 'location', title: '库位查询', label: '库位码', placeholder: '扫描或输入库位码', subtitle: '查看库位状态、容量和占用' },
palletQuery: { mode: 'pallet', title: '托盘查询', label: '托盘码', placeholder: '扫描或输入托盘码', subtitle: '查看托盘状态和绑定库存' }
}
export default {
components: { PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, operationKey: 'inventoryQuery', mode: 'inventory', title: '库存查询', label: '商品码或箱码', placeholder: '扫描或输入 SKU / 箱码', subtitle: '', loading: false, searched: false, result: null, error: null } },
computed: {
storeField() { return `${this.mode}Code` },
code: { get() { return warehouseWork.query[this.storeField] || '' }, set(value) { updateWarehouseWork('query', { [this.storeField]: value }) } },
inventoryItems() { return Array.isArray(this.result?.items) ? this.result.items : [] }, palletItems() { return Array.isArray(this.result?.inventory) ? this.result.inventory : [] }
},
onLoad(options) { this.operationKey = options.op || 'inventoryQuery'; Object.assign(this, MODES[this.operationKey] || MODES.inventoryQuery) },
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.startScanner() },
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
methods: {
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => { this.code = value; this.search(value) }, onError: (error) => this.setError(error, '扫码广播注册失败') }) },
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
async search(raw) { const code = String(raw?.detail?.value ?? raw ?? this.code).trim(); if (!code || this.loading) return; this.code = code; this.loading = true; this.error = null; this.result = null; this.searched = false; try { if (this.mode === 'location') this.result = await resolveWarehouseLocation(code); else if (this.mode === 'pallet') this.result = await resolveWarehousePallet(code); else this.result = await resolveInventory(code); this.searched = true; playSuccessSound() } catch (error) { this.searched = true; this.setError(error, `${this.title}失败`) } finally { this.loading = false } },
setError(error, fallback) { this.error = { message: error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId }; playErrorSound() },
statusLabel(value) { return ({ AVAILABLE: '可用', ACTIVE: '可用', IDLE: '空闲', OCCUPIED: '已占用', HOLD: '冻结', LOCKED: '已锁定' })[value] || value || '状态未知' },
locationTypeLabel(value) { return ({ STORAGE: '存储库位', STAGING: '暂存库位', RELABEL: '换标库位', RECEIVING: '收货库位' })[value] || value || '库位类型未标注' }
}
}
</script>
<style scoped lang="scss">@import '../warehouse/operations.scss';</style>
+105
View File
@@ -0,0 +1,105 @@
<template>
<view v-if="user" class="page-shell portal-page work-page">
<PortalHeader :title="title" :user="user" fallback-url="/pages/module/module?key=relabel" />
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
<view class="work-heading"><text class="work-title">{{ title }}</text><text class="work-subtitle">任务和仓库范围由服务端按当前 PDA Token 校验;扫描与人工输入使用同一正式接口</text></view>
<view class="work-card"><ScanTargetInput name="relabelTask" v-model="taskCode" label="换标单号" placeholder="扫描或输入换标单号" :active="activeTarget === 'task'" @activate="activeTarget = 'task'" @submit="openTask" /><button class="primary" :disabled="!taskCode.trim() || busy" @tap="openTask(taskCode)">{{ busyAction === 'detail' ? '查询中…' : '打开换标任务' }}</button></view>
<view class="row-head"><text class="card-title">{{ candidateHeading }}</text><button class="link-button" :disabled="loading" @tap="loadCandidates">{{ loading ? '刷新中…' : '刷新' }}</button></view>
<view v-if="loading && !visibleCandidates.length" class="empty-box"><uni-load-more status="loading" /><text class="empty-copy">正在加载换标任务…</text></view>
<view v-else-if="!visibleCandidates.length" class="empty-box"><uni-icons type="info" color="#8090a3" size="32" /><text class="empty-copy">{{ emptyText }}</text></view>
<view v-else class="task-list"><view v-for="item in visibleCandidates" :key="item.relabelNo" class="task-card" :class="{ current: current?.relabelNo === item.relabelNo }" @tap="openTask(item.relabelNo)"><view class="row-head"><view><text class="business-code">{{ item.relabelNo }}</text><text class="meta-line">客户订单:{{ item.customerOrderNo || '—' }}</text></view><text class="status-pill">{{ statusLabel(item.taskStatus || item.applicationStatus) }}</text></view><view class="metric-row"><view class="metric"><strong>{{ item.expectedPackages }}</strong><text>应处理</text></view><view class="metric"><strong>{{ item.relabelledPackages }}</strong><text>已换标</text></view><view class="metric"><strong>{{ item.skippedPackages + item.exceptionPackages }}</strong><text>跳过/异常</text></view><text class="link-button">详情 ›</text></view></view></view>
<view v-if="current" class="work-card">
<view class="row-head"><view><text class="business-code">{{ current.relabelNo }}</text><text class="meta-line">客户订单:{{ current.customerOrderNo || '—' }}</text><text class="meta-line">标签截止:{{ formatTime(current.labelDeadline) }}</text></view><text class="status-pill">{{ statusLabel(current.taskStatus || current.applicationStatus) }}</text></view>
<view class="metric-row"><view class="metric"><strong>{{ current.expectedPackages }}</strong><text>应处理</text></view><view class="metric"><strong>{{ current.relabelledPackages }}</strong><text>已换标</text></view><view class="metric"><strong>{{ current.skippedPackages }}</strong><text>跳过</text></view><view class="metric"><strong>{{ current.exceptionPackages }}</strong><text>异常</text></view></view>
<template v-if="stage === 'receive'"><text class="hint">认领后任务进入处理中,并锁定当前操作人;其他账号不可并发作业。</text><button class="primary" :disabled="busy || current.taskStatus === 'WAIT_REVIEW' || current.taskStatus === 'COMPLETED'" @tap="claimTask">{{ busyAction === 'claim' ? '认领中…' : current.claimedByCurrentOperator ? '恢复当前换标任务' : '确认收货并认领任务' }}</button></template>
<template v-else-if="stage === 'execute'">
<view v-if="current.taskStatus !== 'PROCESSING'" class="empty-box section-gap"><text class="empty-copy">当前任务不是处理中状态,请先在“换标收货”认领,或等待复核退回。</text></view>
<template v-else>
<text class="card-title section-gap">选择商品明细</text>
<view class="item-list"><view v-for="item in current.items" :key="item.itemId" class="item-card" :class="{ done: remaining(item) === 0, current: selectedItem?.itemId === item.itemId }" @tap="selectItem(item)"><view class="row-head"><view><text class="business-code">{{ item.skuCode || 'SKU 未关联' }}</text><text class="meta-line">{{ item.productNameZh || '商品名称未关联' }}</text><text class="meta-line">新 SKU:{{ item.newSkuCode || '—' }}</text></view><text class="status-pill" :class="{ good: remaining(item) === 0 }">待处理 {{ remaining(item) }}</text></view><text class="meta-line">{{ item.labelRequirement || '无额外标签要求' }}</text></view></view>
<template v-if="selectedItem && remaining(selectedItem) > 0">
<view class="mode-row section-gap"><button v-for="mode in actionModes" :key="mode.key" class="mode-button" :class="{ active: action === mode.key }" @tap="changeAction(mode.key)">{{ mode.name }}</button></view>
<ScanTargetInput name="oldLabel" v-model="oldLabel" label="旧标签" placeholder="扫描或输入旧标签" :active="activeTarget === 'old'" @activate="activeTarget = 'old'" @submit="handleOldLabel" />
<ScanTargetInput v-if="action === 'RELABEL'" name="newLabel" v-model="newLabel" label="新标签" :placeholder="`应匹配 ${selectedItem.newSkuCode || '审批新 SKU'}`" :active="activeTarget === 'new'" @activate="activeTarget = 'new'" @submit="handleNewLabel" />
<template v-if="action !== 'RELABEL'"><textarea v-model="reason" class="reason-input section-gap" maxlength="1000" placeholder="必填:跳过或异常原因" /><view v-if="action === 'EXCEPTION'" class="mode-row section-gap"><button v-for="item in exceptionCodes" :key="item.key" class="mode-button" :class="{ active: exceptionCode === item.key }" @tap="exceptionCode = item.key">{{ item.name }}</button></view></template>
<button class="primary" :disabled="busy || !canSubmitUnit" @tap="submitUnit">{{ busyAction === 'unit' ? '提交中…' : unitActionText }}</button>
</template>
<view class="work-card section-gap"><view class="row-head"><text class="card-title">完成照片</text><text class="status-pill">{{ photoFileIds.length }} 张</text></view><button class="secondary" :disabled="busy" @tap="choosePhoto">拍照或从相册选择</button><text v-for="(name,index) in photoNames" :key="`${name}:${index}`" class="meta-line">{{ index + 1 }}. {{ name || '换标现场照片' }}</text><textarea v-model="remark" class="reason-input section-gap" maxlength="1000" placeholder="完成备注(选填)" /><button class="primary" :disabled="busy || !allProcessed || !photoFileIds.length" @tap="completeTask">{{ busyAction === 'complete' ? '提交中…' : '完成现场换标并等待复核' }}</button><text class="hint">每件货物必须换标、跳过或登记异常,且至少上传一张现场照片。</text></view>
</template>
</template>
<template v-else><view v-if="current.taskStatus !== 'WAIT_REVIEW'" class="empty-box section-gap"><text class="empty-copy">当前任务尚未进入待复核状态。</text></view><template v-else><textarea v-model="remark" class="reason-input section-gap" maxlength="1000" placeholder="复核备注;退回重做时建议填写原因" /><view class="action-row"><button class="danger" :disabled="busy" @tap="review('REWORK')">退回重做</button><button class="primary" :disabled="busy" @tap="review('APPROVE')">复核通过</button></view></template></template>
</view>
<view v-if="error" class="error-box" role="alert"><text class="error-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><button class="secondary" @tap="retry">重新读取任务</button></view>
<view v-if="successMessage" class="success-box" role="status"><text class="success-title">{{ successMessage }}</text></view>
</view></scroll-view>
</view>
</template>
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { claimRelabelTask, completeRelabelTask, getRelabelTask, listRelabelCandidates, relabelUnit, reportRelabelException, reviewRelabelTask, skipRelabelUnit, uploadRelabelPhoto } from '@/api/relabel.js'
import { session, requireSession } from '@/stores/session.js'
import { releaseWarehouseKey, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
const STAGES = { relabelReceive: 'receive', relabelExecute: 'execute', relabelReview: 'review' }
const isConflict = (error) => ['409', 'A0409', 'B0409'].includes(String(error?.code || ''))
export default {
components: { PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, stage: 'receive', candidates: [], current: null, selectedItem: null, activeTarget: 'task', loading: false, busy: false, busyAction: '', error: null, successMessage: '', exceptionCode: 'OTHER', actionModes: [{ key: 'RELABEL', name: '正常换标' }, { key: 'SKIP', name: '跳过' }, { key: 'EXCEPTION', name: '异常' }], exceptionCodes: [{ key: 'DAMAGED', name: '破损' }, { key: 'MISSING_LABEL', name: '缺标签' }, { key: 'LABEL_MISMATCH', name: '标签不符' }, { key: 'UNREADABLE', name: '无法识别' }, { key: 'OTHER', name: '其他' }] } },
computed: {
title() { return ({ receive: '换标收货', execute: '换标执行', review: '换标复核' })[this.stage] },
candidateHeading() { return this.stage === 'review' ? '待复核任务' : this.stage === 'execute' ? '处理中任务' : '可认领任务' },
emptyText() { return `暂无${this.candidateHeading}` },
visibleCandidates() { if (this.stage === 'review') return this.candidates.filter((item) => item.taskStatus === 'WAIT_REVIEW'); if (this.stage === 'execute') return this.candidates.filter((item) => item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator); return this.candidates.filter((item) => !item.taskStatus || (item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator)) },
taskCode: { get() { return warehouseWork.relabel.currentNo }, set(value) { updateWarehouseWork('relabel', { currentNo: value }) } },
oldLabel: { get() { return warehouseWork.relabel.oldLabel }, set(value) { updateWarehouseWork('relabel', { oldLabel: value }) } },
newLabel: { get() { return warehouseWork.relabel.newLabel }, set(value) { updateWarehouseWork('relabel', { newLabel: value }) } },
action: { get() { return warehouseWork.relabel.action }, set(value) { updateWarehouseWork('relabel', { action: value }) } },
reason: { get() { return warehouseWork.relabel.reason }, set(value) { updateWarehouseWork('relabel', { reason: value }) } },
remark: { get() { return warehouseWork.relabel.remark }, set(value) { updateWarehouseWork('relabel', { remark: value }) } },
photoFileIds() { return warehouseWork.relabel.photoFileIds || [] }, photoNames() { return warehouseWork.relabel.photoNames || [] },
allProcessed() { return Boolean(this.current?.expectedPackages) && this.current.relabelledPackages + this.current.skippedPackages + this.current.exceptionPackages === this.current.expectedPackages },
canSubmitUnit() { if (!this.selectedItem || !this.oldLabel.trim()) return false; if (this.action === 'RELABEL') return Boolean(this.newLabel.trim()); return Boolean(this.reason.trim()) },
unitActionText() { return ({ RELABEL: '确认本件换标', SKIP: '确认跳过本件', EXCEPTION: '登记本件异常' })[this.action] }
},
onLoad(options) { this.stage = STAGES[options.op] || 'receive' },
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.startScanner(); this.bootstrap() },
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
methods: {
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleHardwareScan(value), onError: (error) => this.setError(error, '扫码广播注册失败') }) },
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
handleHardwareScan(value) { if (this.activeTarget === 'old') { this.oldLabel = value; this.activeTarget = this.action === 'RELABEL' ? 'new' : 'old'; return } if (this.activeTarget === 'new') { this.newLabel = value; return } this.taskCode = value; this.openTask(value) },
handleOldLabel(value) { this.oldLabel = String(value?.detail?.value ?? value ?? '').trim(); if (this.action === 'RELABEL') this.activeTarget = 'new' },
handleNewLabel(value) { this.newLabel = String(value?.detail?.value ?? value ?? '').trim() },
async bootstrap() { await this.loadCandidates(); if (this.taskCode) await this.openTask(this.taskCode, false) },
async loadCandidates() { if (this.loading) return; this.loading = true; this.error = null; try { this.candidates = await listRelabelCandidates() } catch (error) { this.setError(error, '换标任务加载失败') } finally { this.loading = false } },
async openTask(raw, sound = true) { const no = String(raw?.detail?.value ?? raw ?? this.taskCode).trim(); if (!no || this.busy) return; this.taskCode = no; await this.run('detail', async () => { this.applyCurrent(await getRelabelTask(no)); if (sound) playSuccessSound() }, false) },
applyCurrent(value) { this.current = value; this.taskCode = value.relabelNo; if (this.selectedItem) this.selectedItem = value.items.find((item) => item.itemId === this.selectedItem.itemId) || null },
selectItem(item) { if (this.remaining(item) <= 0) return; this.selectedItem = item; this.oldLabel = ''; this.newLabel = ''; this.reason = ''; this.activeTarget = 'old' },
remaining(item) { return Math.max(0, item.expectedPackages - item.relabelledPackages - item.skippedPackages - item.exceptionPackages) },
changeAction(value) { this.action = value; this.newLabel = ''; this.reason = ''; this.activeTarget = 'old' },
async claimTask() { if (!this.current) return; const payload = { relabelNo: this.current.relabelNo }; const key = warehouseIdempotencyKey('relabel', 'claim', payload); await this.run('claim', async () => { this.applyCurrent(await claimRelabelTask(this.current.relabelNo, { idempotencyKey: key.value })); releaseWarehouseKey('relabel', key.signature); this.successMessage = '换标任务已认领,可进入换标执行'; await this.loadCandidates() }, true, key) },
async submitUnit() { if (!this.canSubmitUnit || this.busy) return; const base = { itemId: this.selectedItem.itemId, oldLabel: this.oldLabel.trim(), expectedVersion: this.current.version }; const payload = { relabelNo: this.current.relabelNo, action: this.action, ...base, newLabel: this.newLabel.trim(), reason: this.reason.trim(), exceptionCode: this.exceptionCode }; const key = warehouseIdempotencyKey('relabel', 'unit', payload); await this.run('unit', async () => { let value; if (this.action === 'SKIP') value = await skipRelabelUnit(this.current.relabelNo, { ...base, reason: this.reason.trim(), idempotencyKey: key.value }); else if (this.action === 'EXCEPTION') value = await reportRelabelException(this.current.relabelNo, { ...base, exceptionCode: this.exceptionCode, reason: this.reason.trim(), idempotencyKey: key.value }); else value = await relabelUnit(this.current.relabelNo, { ...base, newLabel: this.newLabel.trim(), idempotencyKey: key.value }); releaseWarehouseKey('relabel', key.signature); this.applyCurrent(value); this.oldLabel = ''; this.newLabel = ''; this.reason = ''; this.activeTarget = 'old'; this.successMessage = '本件处理结果已保存' }, true, key) },
choosePhoto() { if (this.busy || !this.current) return; uni.chooseImage({ count: 1, sourceType: ['camera', 'album'], success: ({ tempFilePaths, tempFiles }) => { const path = tempFilePaths?.[0]; if (Number(tempFiles?.[0]?.size || 0) > 10 * 1024 * 1024) return this.setError(new Error('照片不能超过 10MB')); if (path) this.uploadPhoto(path) } }) },
async uploadPhoto(path) { await this.run('photo', async () => { const file = await uploadRelabelPhoto(this.current.relabelNo, path); updateWarehouseWork('relabel', { photoFileIds: [...this.photoFileIds, String(file.fileId)], photoNames: [...this.photoNames, file.fileName || '换标现场照片'] }); this.successMessage = '现场照片已上传' }, false) },
async completeTask() { if (!this.allProcessed || !this.photoFileIds.length) return; const payload = { relabelNo: this.current.relabelNo, photoFileIds: this.photoFileIds, expectedVersion: this.current.version, remark: this.remark.trim() }; const key = warehouseIdempotencyKey('relabel', 'complete', payload); await this.run('complete', async () => { this.applyCurrent(await completeRelabelTask(this.current.relabelNo, { photoFileIds: this.photoFileIds, expectedVersion: this.current.version, idempotencyKey: key.value, remark: this.remark.trim() || undefined })); releaseWarehouseKey('relabel', key.signature); updateWarehouseWork('relabel', { photoFileIds: [], photoNames: [], remark: '' }); this.successMessage = '现场换标已提交复核'; await this.loadCandidates() }, true, key) },
async review(decision) { if (!this.current || this.current.taskStatus !== 'WAIT_REVIEW') return; const payload = { relabelNo: this.current.relabelNo, decision, expectedVersion: this.current.version, remark: this.remark.trim() }; const key = warehouseIdempotencyKey('relabel', 'review', payload); await this.run('review', async () => { this.applyCurrent(await reviewRelabelTask(this.current.relabelNo, { decision, expectedVersion: this.current.version, idempotencyKey: key.value, remark: this.remark.trim() || undefined })); releaseWarehouseKey('relabel', key.signature); this.remark = ''; this.successMessage = decision === 'APPROVE' ? '换标复核已通过' : '换标任务已退回重做'; await this.loadCandidates() }, true, key) },
async retry() { if (this.current?.relabelNo) await this.openTask(this.current.relabelNo, false); else await this.loadCandidates() },
async reloadCurrent() { if (this.current?.relabelNo) this.applyCurrent(await getRelabelTask(this.current.relabelNo)) },
async run(action, work, refreshOnConflict = false, key = null) { if (this.busy) return; this.busy = true; this.busyAction = action; this.error = null; this.successMessage = ''; try { await work(); if (!['detail'].includes(action)) playSuccessSound() } catch (error) { this.setError(error, `${this.title}失败`); if (refreshOnConflict && isConflict(error)) { if (key) releaseWarehouseKey('relabel', key.signature); try { await this.reloadCurrent(); this.error.message += ',已刷新任务版本,请重新确认' } catch (_) {} } } finally { this.busy = false; this.busyAction = '' } },
setError(error, fallback = '换标作业失败') { this.error = { message: error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId }; playErrorSound() },
statusLabel(value) { return ({ PENDING_PROCESSING: '待认领', PROCESSING: '处理中', WAIT_REVIEW: '待复核', COMPLETED: '已完成' })[value] || value || '待认领' },
formatTime(value) { return value ? String(value).replace('T', ' ').slice(0, 16) : '—' }
}
}
</script>
<style scoped lang="scss">@import '../warehouse/operations.scss';.action-row>.danger,.action-row>.primary{width:50%}.item-card.current{border-color:#6ea3ee;background:#f3f8ff}</style>
+80
View File
@@ -0,0 +1,80 @@
<template>
<view v-if="user" class="page-shell portal-page work-page">
<PortalHeader title="库存盘点" :user="user" fallback-url="/pages/module/module?key=inventory" />
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
<view class="work-heading"><text class="work-title">库存盘点</text><text class="work-subtitle">按 Token 绑定的海外仓冻结库位或托盘库存,逐位置实盘后提交差异</text></view>
<view class="work-card">
<text class="card-title">新建盘点任务</text>
<view class="mode-row"><button class="mode-button" :class="{ active: scopeType === 'LOCATION' }" @tap="changeScope('LOCATION')">按库位</button><button class="mode-button" :class="{ active: scopeType === 'PALLET' }" @tap="changeScope('PALLET')">按托盘</button></view>
<ScanTargetInput name="scope" v-model="scopeCode" :label="scopeType === 'LOCATION' ? '库位码' : '托盘码'" :placeholder="scopeType === 'LOCATION' ? '扫描或输入库位码' : '扫描或输入托盘码'" :active="true" @submit="createTask" />
<button class="primary" :disabled="!scopeCode.trim() || busy" @tap="createTask(scopeCode)">{{ busyAction === 'create' ? '创建中…' : '创建并冻结盘点范围' }}</button>
</view>
<view class="row-head"><text class="card-title">进行中的任务</text><button class="link-button" :disabled="loading" @tap="loadTasks">{{ loading ? '刷新中…' : '刷新' }}</button></view>
<view v-if="loading && !tasks.length" class="empty-box"><uni-load-more status="loading" /><text class="empty-copy">正在加载盘点任务…</text></view>
<view v-else-if="!tasks.length" class="empty-box"><uni-icons type="info" color="#8090a3" size="32" /><text class="empty-copy">暂无进行中的盘点任务</text></view>
<view v-else class="task-list"><view v-for="task in tasks" :key="task.taskNo" class="task-card" :class="{ current: current?.task?.taskNo === task.taskNo }" @tap="openTask(task.taskNo)"><view class="row-head"><view><text class="business-code">{{ task.taskNo }}</text><text class="meta-line">{{ scopeLabel(task.scopeType) }} · {{ task.scopeCode || '—' }}</text></view><text class="status-pill">{{ statusLabel(task.status) }}</text></view><view class="metric-row"><view class="metric"><strong>{{ task.positionCount }}</strong><text>位置</text></view><view class="metric"><strong>{{ task.countedCount }}</strong><text>已盘</text></view><view class="metric"><strong>{{ task.differenceCount }}</strong><text>差异</text></view><text class="link-button">继续 ›</text></view></view></view>
<view v-if="current" class="work-card">
<view class="row-head"><view><text class="card-title">当前任务</text><text class="business-code">{{ current.task.taskNo }}</text><text class="meta-line">{{ scopeLabel(current.task.scopeType) }} · {{ current.task.scopeCode || '—' }}</text></view><text class="status-pill" :class="{ good: current.task.status === 'SUBMITTED' }">{{ statusLabel(current.task.status) }}</text></view>
<view v-if="!current.items.length" class="empty-box"><text class="empty-copy">该任务暂无库存位置</text></view>
<view v-else class="item-list section-gap"><view v-for="item in current.items" :key="item.itemId" class="item-card" :class="{ done: item.actualPackages != null }">
<view class="row-head"><view><text class="business-code">{{ item.skuCode || 'SKU 未关联' }}</text><text class="meta-line">{{ item.productName || '商品名称未关联' }}</text><text class="meta-line">{{ item.inboundCode || '无入仓码' }} · {{ item.palletCode || '未绑定托盘' }} · {{ item.locationCode || '未绑定库位' }}</text></view><text class="status-pill" :class="{ good: item.actualPackages != null }">{{ item.actualPackages == null ? '待盘' : '已盘' }}</text></view>
<view class="item-grid"><view><strong>{{ item.bookPackages }}</strong><text>账面箱数</text></view><view><strong>{{ item.actualPackages == null ? '—' : item.actualPackages }}</strong><text>实盘箱数</text></view><view><strong>{{ item.differencePackages == null ? '—' : item.differencePackages }}</strong><text>差异</text></view></view>
<input v-model.number="item.inputPackages" class="number-input section-gap" type="number" inputmode="numeric" placeholder="输入实盘箱数(可为 0)" />
<button class="secondary" :disabled="busy || !validCount(item) || current.task.status !== 'IN_PROGRESS'" @tap="saveCount(item)">{{ isSaving(item) ? '保存中…' : '保存本位置实盘数' }}</button>
</view></view>
<template v-if="current.task.status === 'IN_PROGRESS'"><text class="card-title section-gap">提交原因</text><textarea v-model="reason" class="reason-input" maxlength="500" placeholder="必填,例如:月度循环盘点" /><button class="primary" :disabled="busy || !allCounted || !reason.trim()" @tap="submitTask">{{ busyAction === 'submit' ? '提交中…' : '提交盘点并调整差异' }}</button><text class="hint">所有位置完成实盘后才能提交;版本变化时页面会自动刷新。</text></template>
</view>
<view v-if="error" class="error-box" role="alert"><text class="error-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><button class="secondary" @tap="retry">重新读取</button></view>
<view v-if="successMessage" class="success-box" role="status"><text class="success-title">{{ successMessage }}</text></view>
</view></scroll-view>
</view>
</template>
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { countStocktake, createStocktake, getStocktake, listStocktakes, submitStocktake } from '@/api/warehouse-operations.js'
import { session, requireSession } from '@/stores/session.js'
import { releaseWarehouseKey, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
const isConflict = (error) => ['409', 'A0409', 'B0409'].includes(String(error?.code || ''))
export default {
components: { PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, tasks: [], current: null, loading: false, busy: false, busyAction: '', busyItem: '', error: null, successMessage: '' } },
computed: {
scopeType: { get() { return warehouseWork.stocktake.scopeType }, set(value) { updateWarehouseWork('stocktake', { scopeType: value }) } },
scopeCode: { get() { return warehouseWork.stocktake.scopeCode }, set(value) { updateWarehouseWork('stocktake', { scopeCode: value }) } },
reason: { get() { return warehouseWork.stocktake.reason }, set(value) { updateWarehouseWork('stocktake', { reason: value }) } },
allCounted() { return Boolean(this.current?.items?.length) && this.current.items.every((item) => item.actualPackages != null) }
},
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.startScanner(); this.bootstrap() },
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
methods: {
isSaving(item) { return this.busyItem === item.positionId },
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => { this.scopeCode = value; this.createTask(value) }, onError: (error) => this.setError(error, '扫码广播注册失败') }) },
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
async bootstrap() { await this.loadTasks(); const saved = warehouseWork.stocktake.taskNo; if (saved) await this.openTask(saved, false) },
async loadTasks() { if (this.loading) return; this.loading = true; this.error = null; try { this.tasks = await listStocktakes('IN_PROGRESS') } catch (error) { this.setError(error, '盘点任务加载失败') } finally { this.loading = false } },
changeScope(value) { this.scopeType = value; this.scopeCode = '' },
async createTask(raw) { const code = String(raw?.detail?.value ?? raw ?? this.scopeCode).trim(); if (!code || this.busy) return; this.scopeCode = code; const payload = { scopeType: this.scopeType, scopeCode: code }; const key = warehouseIdempotencyKey('stocktake', 'create', payload); await this.run('create', async () => { const value = await createStocktake({ ...payload, idempotencyKey: key.value }); releaseWarehouseKey('stocktake', key.signature); this.applyCurrent(value); this.scopeCode = ''; await this.loadTasks(); this.successMessage = '盘点任务已创建' }) },
async openTask(taskNo, feedback = true) { if (!taskNo || this.busy) return; await this.run('detail', async () => { const value = await getStocktake(taskNo); this.applyCurrent(value); if (feedback) playSuccessSound() }, false) },
applyCurrent(value) { this.current = value; this.current.items = this.current.items.map((item) => ({ ...item, inputPackages: item.actualPackages })); updateWarehouseWork('stocktake', { taskNo: value.task.taskNo }) },
validCount(item) { const value = Number(item.inputPackages); return Number.isInteger(value) && value >= 0 },
async saveCount(item) { if (!this.validCount(item) || this.busy) return; const payload = { taskNo: this.current.task.taskNo, positionId: item.positionId, actualPackages: Number(item.inputPackages), expectedVersion: item.version }; const key = warehouseIdempotencyKey('stocktake', 'count', payload); this.busyItem = item.positionId; await this.run('count', async () => { await countStocktake(this.current.task.taskNo, { positionId: item.positionId, actualPackages: payload.actualPackages, expectedVersion: item.version, idempotencyKey: key.value }); releaseWarehouseKey('stocktake', key.signature); await this.reloadCurrent(); this.successMessage = '实盘数量已保存' }, true, key) },
async submitTask() { if (!this.allCounted || !this.reason.trim() || this.busy) return; const payload = { taskNo: this.current.task.taskNo, expectedVersion: this.current.task.version, reason: this.reason.trim() }; const key = warehouseIdempotencyKey('stocktake', 'submit', payload); await this.run('submit', async () => { const value = await submitStocktake(this.current.task.taskNo, { expectedVersion: payload.expectedVersion, reason: payload.reason, idempotencyKey: key.value }); releaseWarehouseKey('stocktake', key.signature); this.applyCurrent(value); this.reason = ''; this.successMessage = '盘点已提交'; await this.loadTasks() }, true, key) },
async reloadCurrent() { if (this.current?.task?.taskNo) this.applyCurrent(await getStocktake(this.current.task.taskNo)) },
async retry() { this.error = null; if (this.current?.task?.taskNo) await this.openTask(this.current.task.taskNo, false); else await this.loadTasks() },
async run(action, work, refreshOnConflict = false, key = null) { if (this.busy) return; this.busy = true; this.busyAction = action; this.error = null; this.successMessage = ''; try { await work(); playSuccessSound() } catch (error) { this.setError(error, '盘点作业失败'); if (refreshOnConflict && isConflict(error)) { if (key) releaseWarehouseKey('stocktake', key.signature); try { await this.reloadCurrent(); this.error.message += ',已刷新最新版本,请重新确认' } catch (_) {} } } finally { this.busy = false; this.busyAction = ''; this.busyItem = '' } },
setError(error, fallback) { this.error = { message: error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId }; playErrorSound() },
scopeLabel(value) { return value === 'PALLET' ? '托盘盘点' : '库位盘点' },
statusLabel(value) { return ({ IN_PROGRESS: '进行中', SUBMITTED: '已提交' })[value] || value || '状态未知' }
}
}
</script>
<style scoped lang="scss">@import '../warehouse/operations.scss';</style>
+1
View File
@@ -0,0 +1 @@
.work-page{height:100vh;overflow:hidden}.work-scroll{height:calc(100vh - 48px - var(--status-bar-height,0px))}.work-content{padding-bottom:42px}.work-heading{margin:4px 2px 12px}.work-title,.work-subtitle,.card-title,.business-code,.meta-line,.empty-copy,.error-box text,.success-box text{display:block}.work-title{color:#14263d;font-size:21px;font-weight:800}.work-subtitle{margin-top:4px;color:#708197;font-size:11px;line-height:17px}.work-card,.task-card,.empty-box,.error-box,.success-box{box-sizing:border-box;margin-bottom:10px;padding:14px;border:1px solid #dce5f0;border-radius:13px;background:#fff;box-shadow:0 4px 15px rgba(32,67,111,.06)}.card-title{margin-bottom:9px;color:#17314f;font-size:13px;font-weight:800}.business-code{color:#142c48;font-size:16px;font-weight:800}.meta-line{margin-top:4px;color:#718095;font-size:10px;line-height:16px}.row-head,.action-row,.metric-row,.mode-row{display:flex;align-items:center;justify-content:space-between;gap:9px}.mode-row{justify-content:flex-start;margin-bottom:10px}.mode-button{height:34px;margin:0;padding:0 14px;border-radius:17px;color:#617287;font-size:11px;line-height:34px;background:#e9eef4}.mode-button.active{color:#fff;font-weight:700;background:#135ee8}.primary,.secondary,.danger{box-sizing:border-box;min-height:42px;margin-top:9px;border-radius:8px;font-size:12px;font-weight:750}.primary{width:100%;color:#fff;background:#135ee8}.secondary{width:100%;color:#135ee8;border:1px solid #9dc0f4;background:#f8fbff}.danger{width:100%;color:#a43030;border:1px solid #e2a5a5;background:#fff7f7}.primary[disabled],.secondary[disabled],.danger[disabled]{color:#9aa7b5;background:#e8edf3;opacity:1}.compact-button{width:auto;min-width:82px;margin-top:0;padding:0 12px}.text-input,.number-input,.reason-input{box-sizing:border-box;width:100%;border:1px solid #cad6e3;border-radius:8px;color:#243951;background:#fbfcfe}.text-input,.number-input{height:44px;padding:0 11px}.number-input{font-size:17px;font-weight:750}.reason-input{height:74px;padding:9px 10px}.task-list{display:flex;flex-direction:column;gap:9px;margin-bottom:12px}.task-card{margin-bottom:0}.task-card.current{border-color:#8eb8f5;background:#f7faff}.metric-row{margin-top:10px;padding-top:9px;border-top:1px solid #edf1f5}.metric{display:flex;align-items:center;flex-direction:column;min-width:0;color:#708197;font-size:9px}.metric strong{color:#135ee8;font-size:15px}.item-list{display:flex;flex-direction:column;gap:9px}.item-card{padding:11px;border:1px solid #e0e7ef;border-radius:9px;background:#fbfcfe}.item-card.done{border-color:#9ed5bd;background:#f2fbf7}.item-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-top:9px;color:#708197;font-size:9px;text-align:center}.item-grid strong{display:block;color:#203750;font-size:14px}.empty-box{text-align:center;color:#738399}.empty-copy{line-height:20px}.error-box{color:#9d2323;background:#fff0f0}.error-title,.success-title{font-size:12px;font-weight:800}.success-box{color:#176b42;background:#eaf8f1}.status-pill{flex-shrink:0;padding:4px 7px;border-radius:6px;color:#8a570c;font-size:9px;background:#fff1dd}.status-pill.good{color:#08705f;background:#e2f6f0}.scan-gap{margin-top:10px}.section-gap{margin-top:13px}.hint{display:block;margin-top:7px;color:#718095;font-size:10px;line-height:16px}.link-button{height:30px;margin:0;padding:0 8px;color:#135ee8;font-size:10px;line-height:30px;background:transparent}