139 lines
13 KiB
Vue
139 lines
13 KiB
Vue
<template>
|
||
<view v-if="user" class="page-shell palletization-page">
|
||
<PortalHeader title="合托作业" :user="user" :fallback-url="'/pages/dropship-tasks/dropship-tasks'" />
|
||
<view class="content">
|
||
<view v-if="error" class="message error" role="alert">{{ error }}</view>
|
||
<view v-if="notice" class="message success" role="status">{{ notice }}</view>
|
||
<view v-if="loading && !task" class="state">正在加载合托任务…</view>
|
||
<template v-if="task">
|
||
<view class="summary-card">
|
||
<view class="summary-head"><view><text class="eyebrow">入仓码</text><text class="inbound-code">{{ task.inboundCode || '—' }}</text></view><text class="status">{{ statusLabel }}</text></view>
|
||
<text class="customer">{{ task.customerName || '客户未关联' }} · {{ task.warehouseName || '仓库未关联' }}</text>
|
||
<view class="metrics">
|
||
<view><strong>{{ task.totalCartons || 0 }}</strong><text>总箱</text></view>
|
||
<view><strong>{{ task.countedCartons || 0 }}</strong><text>已清点</text></view>
|
||
<view><strong>{{ task.boundCartons || 0 }}</strong><text>已归托</text></view>
|
||
<view><strong>{{ task.unboundCartons || 0 }}</strong><text>待归托</text></view>
|
||
</view>
|
||
<button v-if="task.taskStatus === 'PENDING'" class="primary-button" :disabled="submitting" @tap="claimTask">领取并开始合托</button>
|
||
</view>
|
||
|
||
<view v-if="task.taskStatus === 'CLAIMED'" class="section-card">
|
||
<text class="section-title">1. 扫箱清点</text>
|
||
<text class="section-desc">扫描箱唛,核对 SKU 与本箱实际件数。</text>
|
||
<ScanTargetInput v-model="boxCode" name="boxCode" label="箱码" placeholder="扫描或输入箱唛箱码" :active="activeScan === 'boxCode'" @activate="activeScan = 'boxCode'" @submit="resolveCarton" />
|
||
<view v-if="carton" class="carton-card">
|
||
<view class="carton-head"><text>{{ carton.boxCode }}</text><text>{{ carton.countStatus === 'UNCOUNTED' ? '待清点' : '已处理' }}</text></view>
|
||
<text class="carton-line">{{ carton.skuCode || 'SKU —' }} · {{ carton.cargoName || '品名 —' }}</text>
|
||
<text class="carton-line">计划 {{ carton.expectedSkuQuantity || 0 }} 件</text>
|
||
<view class="field"><text>实际件数</text><input v-model="actualQuantity" type="number" aria-label="本箱实际件数" /></view>
|
||
<view v-if="quantityDiffers" class="field stacked"><text>差异说明</text><textarea v-model="discrepancyRemark" maxlength="500" placeholder="数量不一致时必填" /></view>
|
||
<button class="primary-button" :disabled="!canCount || submitting" @tap="countCarton">确认本箱并进入待归托区</button>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="task.taskStatus === 'CLAIMED'" class="section-card">
|
||
<text class="section-title">2. 归托</text>
|
||
<text class="section-desc">把已清点待归托箱绑定到现有 DP 托盘,或由系统生成新托盘。</text>
|
||
<ScanTargetInput v-model="palletNo" name="palletNo" label="托盘码" placeholder="扫描 DP-YYMMDD-NNNN" :active="activeScan === 'palletNo'" @activate="activeScan = 'palletNo'" @submit="bindPending" />
|
||
<button class="secondary-button" :disabled="!validPalletNo || !task.unboundCartons || submitting" @tap="bindPending">绑定到扫描托盘</button>
|
||
<button class="primary-button" :disabled="!task.unboundCartons || submitting" @tap="createAndBind">生成 DP 托盘并绑定</button>
|
||
</view>
|
||
|
||
<view class="section-card">
|
||
<view class="section-heading"><view><text class="section-title">3. 托盘与封托</text><text class="section-desc">所有箱清点并归托后,逐托确认封托。</text></view><button class="text-button" :disabled="loading" @tap="loadTask">刷新</button></view>
|
||
<view v-if="!task.pallets || !task.pallets.length" class="empty">暂无托盘</view>
|
||
<view v-for="pallet in task.pallets" :key="pallet.palletId" class="pallet-card">
|
||
<view class="pallet-head"><text class="pallet-code">{{ pallet.palletNo }}</text><text class="pallet-status" :class="pallet.status === 'SEALED' ? 'sealed' : ''">{{ pallet.status === 'SEALED' ? '已封托' : '待封托' }}</text></view>
|
||
<text class="pallet-meta">{{ pallet.cartonCount || 0 }} 箱 · {{ pallet.skuCount || 0 }} SKU · {{ pallet.weightKg || 0 }} kg · {{ pallet.volumeCbm || 0 }} m³</text>
|
||
<button v-if="pallet.status === 'OPEN'" class="primary-button" :disabled="submitting || !canSeal" @tap="sealPallet(pallet)">确认封托</button>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import PortalHeader from '@/components/PortalHeader.vue'
|
||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||
import { bindDropshipPendingCartons, claimDropshipPalletizationTask, countDropshipCarton, createAndBindDropshipPallet, getDropshipPalletizationTask, resolveDropshipCarton, sealDropshipPallet } from '@/api/pda-dropship.js'
|
||
import { session, requireSession } from '@/stores/session.js'
|
||
import { formatApiError } from '@/utils/user-facing.js'
|
||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||
|
||
export default {
|
||
components: { PortalHeader, ScanTargetInput },
|
||
data() { return { user: session.currentUser, taskId: '', warehouseId: '', task: null, boxCode: '', palletNo: '', carton: null, actualQuantity: '', discrepancyRemark: '', activeScan: 'boxCode', loading: false, submitting: false, error: '', notice: '' } },
|
||
computed: {
|
||
statusLabel() { return this.task?.taskStatus === 'COMPLETED' ? '已完成' : (this.task?.taskStatus === 'CLAIMED' ? '作业中' : '待领取') },
|
||
quantityDiffers() { return this.carton && Number(this.actualQuantity) !== Number(this.carton.expectedSkuQuantity) },
|
||
canCount() { return Boolean(this.carton?.canCount && Number(this.actualQuantity) > 0 && (!this.quantityDiffers || this.discrepancyRemark.trim())) },
|
||
validPalletNo() { return /^DP-\d{6}-\d{4}$/.test(this.palletNo.trim().toUpperCase()) },
|
||
canSeal() { return Number(this.task?.countedCartons || 0) === Number(this.task?.totalCartons || 0) && Number(this.task?.unboundCartons || 0) === 0 }
|
||
},
|
||
onLoad(options) { this.taskId = String(options.taskId || ''); this.warehouseId = String(options.warehouseId || '') },
|
||
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.startScanner(); this.loadTask() },
|
||
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
|
||
methods: {
|
||
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleScan(value), onError: () => { this.error = '扫码头连接失败,可使用输入框继续作业' } }) },
|
||
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
|
||
handleScan(value) { if (this.activeScan === 'palletNo') { this.palletNo = String(value).trim().toUpperCase(); this.bindPending() } else { this.boxCode = String(value).trim().toUpperCase(); this.resolveCarton() } },
|
||
resetMessages() { this.error = ''; this.notice = '' },
|
||
async loadTask() {
|
||
if (!this.taskId || !this.warehouseId) { this.error = '缺少合托任务或仓库信息,请返回任务列表'; return }
|
||
this.loading = true; this.resetMessages()
|
||
try { this.task = await getDropshipPalletizationTask(this.taskId, this.warehouseId) }
|
||
catch (error) { this.error = formatApiError(error, '合托任务加载失败') }
|
||
finally { this.loading = false }
|
||
},
|
||
async claimTask() { await this.runMutation(() => claimDropshipPalletizationTask(this.taskId, Number(this.warehouseId)), '任务领取成功') },
|
||
async resolveCarton(value) {
|
||
const code = String(value || this.boxCode || '').trim().toUpperCase(); if (!code || this.loading) return
|
||
this.boxCode = code; this.loading = true; this.resetMessages(); this.carton = null
|
||
try { this.carton = await resolveDropshipCarton(this.taskId, this.warehouseId, code); this.actualQuantity = String(this.carton.expectedSkuQuantity || '') }
|
||
catch (error) { this.error = formatApiError(error, '箱码解析失败') }
|
||
finally { this.loading = false }
|
||
},
|
||
async countCarton() {
|
||
const result = await this.runMutation(() => countDropshipCarton(this.taskId, { warehouseId: Number(this.warehouseId), boxCode: this.carton.boxCode, actualSkuQuantity: Number(this.actualQuantity), discrepancyAction: this.quantityDiffers ? 'ACCEPT_VARIANCE' : 'MATCH', discrepancyRemark: this.quantityDiffers ? this.discrepancyRemark.trim() : null, palletNo: null }), '本箱已清点,等待归托')
|
||
if (result) { this.boxCode = ''; this.carton = null; this.actualQuantity = ''; this.discrepancyRemark = '' }
|
||
},
|
||
async bindPending(value) { const code = String(value || this.palletNo || '').trim().toUpperCase(); if (!/^DP-\d{6}-\d{4}$/.test(code)) { this.error = '请输入正确的 DP 托盘码'; return }; this.palletNo = code; await this.runMutation(() => bindDropshipPendingCartons(this.taskId, Number(this.warehouseId), code), '待归托箱已绑定') },
|
||
async createAndBind() { await this.runMutation(() => createAndBindDropshipPallet(this.taskId, Number(this.warehouseId)), '已生成 DP 托盘并绑定') },
|
||
async sealPallet(pallet) { await this.runMutation(() => sealDropshipPallet(this.taskId, Number(this.warehouseId), pallet), `${pallet.palletNo} 已封托`) },
|
||
async runMutation(action, message) {
|
||
if (this.submitting) return null
|
||
this.submitting = true; this.resetMessages()
|
||
try {
|
||
const result = await action()
|
||
this.task = result?.taskId && Array.isArray(result?.pallets)
|
||
? result
|
||
: await getDropshipPalletizationTask(this.taskId, this.warehouseId)
|
||
this.notice = message
|
||
return result
|
||
}
|
||
catch (error) { this.error = formatApiError(error, '操作失败'); return null }
|
||
finally { this.submitting = false }
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.content { padding: 12px 12px 42px; }
|
||
.state, .message { margin-bottom: 10px; padding: 12px; border-radius: 9px; color: #667085; font-size: 12px; line-height: 18px; text-align: center; background: #fff; }
|
||
.message.error { color: #b42318; background: #fff1f0; }.message.success { color: #067647; background: #ecfdf3; }
|
||
.summary-card, .section-card { margin-bottom: 12px; padding: 13px; border: 1px solid #dce6f1; border-radius: 12px; background: #fff; box-shadow: 0 2px 8px rgba(23,55,93,.05); }
|
||
.summary-head, .section-heading, .pallet-head, .carton-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
|
||
.summary-head > view { display: flex; min-width: 0; flex-direction: column; }.eyebrow { color: #667085; font-size: 10px; }.inbound-code { margin-top: 2px; color: #12233f; font-size: 19px; font-weight: 700; overflow-wrap: anywhere; }
|
||
.status, .pallet-status { flex-shrink: 0; padding: 4px 8px; border-radius: 99px; color: #155eef; font-size: 10px; background: #eff4ff; }.pallet-status.sealed { color: #067647; background: #ecfdf3; }
|
||
.customer { display: block; margin-top: 6px; color: #667085; font-size: 11px; }
|
||
.metrics { display: grid; grid-template-columns: repeat(4,1fr); margin: 12px 0; padding: 10px 0; border-top: 1px solid #edf1f5; border-bottom: 1px solid #edf1f5; }.metrics view { display: flex; flex-direction: column; align-items: center; border-right: 1px solid #edf1f5; }.metrics view:last-child { border: 0; }.metrics strong { font-size: 17px; }.metrics text { margin-top: 2px; color: #667085; font-size: 10px; }
|
||
.section-title { display: block; color: #12233f; font-size: 16px; font-weight: 700; }.section-desc { display: block; margin: 3px 0 11px; color: #667085; font-size: 11px; line-height: 17px; }.section-heading > view { min-width: 0; }
|
||
.primary-button, .secondary-button { box-sizing: border-box; width: 100%; height: 46px; margin-top: 10px; border-radius: 9px; font-size: 14px; font-weight: 700; line-height: 44px; }.primary-button { border: 0; color: #fff; background: #1a73e8; }.secondary-button { border: 1px solid #8ab8ef; color: #155eef; background: #f6f9ff; }.primary-button[disabled],.secondary-button[disabled] { opacity: .45; }
|
||
.carton-card, .pallet-card { margin-top: 11px; padding: 11px; border: 1px solid #e3eaf2; border-radius: 9px; background: #f8fbff; }.carton-head { color: #12233f; font-size: 13px; font-weight: 700; }.carton-line, .pallet-meta { display: block; margin-top: 5px; color: #667085; font-size: 11px; line-height: 17px; }
|
||
.field { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 10px; color: #344054; font-size: 12px; }.field input { box-sizing: border-box; width: 120px; height: 42px; padding: 0 10px; border: 1px solid #cfd8e3; border-radius: 8px; background: #fff; }.field.stacked { align-items: stretch; flex-direction: column; gap: 6px; }.field textarea { box-sizing: border-box; width: 100%; height: 70px; padding: 9px; border: 1px solid #cfd8e3; border-radius: 8px; background: #fff; }
|
||
.pallet-card { background: #fff; }.pallet-code { color: #12233f; font-size: 15px; font-weight: 700; overflow-wrap: anywhere; }.text-button { min-width: 58px; height: 40px; border: 0; color: #155eef; font-size: 12px; line-height: 40px; background: transparent; }.empty { padding: 18px 0 8px; color: #98a2b3; font-size: 12px; text-align: center; }
|
||
</style>
|