Files
pda-overseas-uniapp/src/pages/operation/operation.vue
T

62 lines
15 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">
<PortalHeader :title="operationName" :user="user" :fallback-url="`/pages/module/module?key=${moduleKey}`" />
<view class="portal-content operation-page">
<view class="operation-page-title-row"><view><text class="operation-page-title">{{ operationName }}</text><text class="operation-page-subtitle">{{ inboundMode ? '海外仓真实入仓作业' : prototype.subtitle }}</text></view><text class="prototype-badge">{{ inboundMode ? '真实接口' : '静态原型' }}</text></view>
<template v-if="inboundMode">
<view class="work-card"><text class="card-title">任务范围</text><input v-model="form.customerId" class="work-input" placeholder="客户ID(平台作业必填)" inputmode="numeric" /><view class="operation-scan-row"><input v-model="form.planNo" class="operation-scan-input" placeholder="扫描或输入入仓计划号" confirm-type="search" @confirm="loadTask" /><button class="operation-scan-button" :disabled="busy" @tap="loadTask">查询</button></view><view class="button-row"><button class="secondary-button" :disabled="busy" @tap="pushTask">推送/到仓登记</button><button class="secondary-button" :disabled="busy || !task" @tap="loadTask">刷新任务</button></view></view>
<view v-if="task" class="work-card task-summary"><text class="card-title">{{ task.taskNo }}</text><text>计划:{{ task.planNo }}</text><text>状态:{{ task.status }}</text><text>卸柜照片:{{ task.unloadPhotoFileIds?.length || 0 }} 张</text><text v-if="task.estimatedEmptyReturnAt">预计报空:{{ task.estimatedEmptyReturnAt }}</text><button v-for="item in task.pallets || []" :key="item.planPalletId" class="instruction-row" @tap="selectPallet(item)">托盘 {{ item.palletNo }} · {{ item.assignmentStatus }} · 实收 {{ item.actualPackageCount }} 箱</button><button v-for="item in task.allocations || []" :key="item.allocationId" class="instruction-row" @tap="selectAllocation(item)">入仓 {{ item.inboundCode }} · 计划 {{ item.plannedPackageCount }} · 已收 {{ item.pickedPackageCount }}</button><button v-for="item in task.expectedCartons || []" :key="item.cartonMark" class="instruction-row" @tap="selectCarton(item)">箱唛 {{ item.cartonMark }} · {{ item.skuCode || '无SKU' }} · {{ item.status }}</button></view>
<view class="work-card"><text class="card-title">托盘资源分配</text><input v-model="form.planPalletId" class="work-input" placeholder="计划托盘ID" inputmode="numeric" /><input v-model="form.warehouseId" class="work-input" placeholder="海外仓ID" inputmode="numeric" /><input v-model="form.palletId" class="work-input" placeholder="实物托盘ID" inputmode="numeric" /><input v-model="form.locationId" class="work-input" placeholder="目标库位ID" inputmode="numeric" /><button class="primary-action" :disabled="busy" @tap="assignPallet">确认分配</button></view>
<view class="work-card"><text class="card-title">卸柜实收</text><input v-model="form.allocationId" class="work-input" placeholder="分配明细ID" inputmode="numeric" /><input v-model.number="form.packageCount" class="work-input" placeholder="本次箱数" inputmode="numeric" type="number" /><view class="button-row"><button class="secondary-button" :disabled="busy" @tap="countPackages">确认点数</button><button class="secondary-button" :disabled="busy" @tap="pickPackages">确认实收</button></view><input v-model="form.cartonMark" class="work-input" placeholder="扫描国内冻结箱唛" @confirm="scanCarton" /><view class="button-row"><button class="secondary-button" :disabled="busy" @tap="scanCarton">绑定托盘</button><button class="danger-button" :disabled="busy" @tap="removeCarton">移除箱唛</button></view></view>
<view class="work-card"><text class="card-title">卸柜照片与完成</text><button class="secondary-button full-button" :disabled="busy" @tap="chooseUnloadPhotos">选择并上传卸柜照片</button><text v-if="photoFileIds.length" class="hint-text">已上传文件ID:{{ photoFileIds.join('、') }}</text><button class="secondary-button full-button" :disabled="busy || !photoFileIds.length" @tap="saveUnloadPhotos">保存照片凭证</button><button class="primary-action complete-button" :disabled="busy || !task" @tap="completeTask">完成入仓并入库存</button></view>
<view v-if="lastResult" class="result-card"><text class="card-title">最近结果</text><text>{{ lastResult }}</text></view><view v-if="error" class="error-card"><text>{{ 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="operation-scan-card"><text class="operation-scan-label">{{ prototype.scanLabel }}</text><view class="operation-scan-row"><input v-model="scanValue" class="operation-scan-input" :placeholder="prototype.scanPlaceholder" @confirm="prototypeScan" /><button class="operation-scan-button" @tap="prototypeScan">确认</button></view><text class="operation-scan-tip">该库内模块仍待后续真实接口接入</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 { getOperationPrototype } from '@/data/overseas-workbench.js'
import { session, requireSession } from '@/stores/session.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { playSuccessSound } from '@/utils/audio-feedback.js'
import { assignInboundPallet, bindUnloadPhotos, completeInboundTask, countInboundPackages, getInboundTask, pickInboundPackages, pushInboundTask, removeInboundCarton, scanInboundCarton, uploadUnloadPhotoFile } from '@/api/inbound.js'
const positive = (value) => /^\d+$/.test(String(value || '')) && Number(value) > 0
export default {
components: { PortalHeader },
data() { return { user: session.currentUser, moduleKey: 'query', operationName: '库存查询', scanValue: '', busy: false, task: null, error: null, lastResult: '', photoFileIds: [], keys: {}, form: { customerId: '', planNo: '', planPalletId: '', warehouseId: '', palletId: '', locationId: '', allocationId: '', packageCount: 1, cartonMark: '' }, toast: { visible: false, message: '', type: '' } } },
computed: { prototype() { return getOperationPrototype(this.operationName) }, inboundMode() { return this.moduleKey === 'inbound' } },
onLoad(options) { this.moduleKey = options.module || 'query'; this.operationName = decodeURIComponent(options.name || '库存查询'); if (session.tenantId && session.tenantId !== '0') this.form.customerId = session.tenantId },
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.startScanner() }, onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { clearTimeout(this._toastTimer); this.stopScanner() },
methods: {
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => { if (this.inboundMode) { if (!this.form.planNo) this.form.planNo = value; else this.form.cartonMark = value } else this.scanValue = value; playSuccessSound() } }) }, stopScanner() { if (typeof this._stopScanner === 'function') this._stopScanner(); this._stopScanner = null },
validateScope() { if (!this.form.customerId.trim()) throw new Error('请输入目标客户ID'); if (!this.form.planNo.trim()) throw new Error('请扫描或输入入仓计划号') },
key(action, payload) { const signature = `${action}:${JSON.stringify(payload)}`; if (!this.keys[signature]) this.keys[signature] = `pda-${Date.now()}-${Math.random().toString(16).slice(2)}`; return { signature, value: this.keys[signature] } },
async execute(action, work) { this.busy = true; this.error = null; try { const value = await work(); this.lastResult = action; this.showToast(`${action}成功`, 'success'); return value } catch (error) { this.error = error; this.showToast(error.message || `${action}失败`, 'warning'); throw error } finally { this.busy = false } },
operator() { return { operatorId: String(this.user?.id || ''), operatorName: this.user?.name || 'PDA作业员' } },
selectPallet(item) { this.form.planPalletId = String(item.planPalletId); if (item.warehousePalletId) this.form.palletId = String(item.warehousePalletId); if (item.warehouseLocationId) this.form.locationId = String(item.warehouseLocationId) },
selectAllocation(item) { this.form.allocationId = String(item.allocationId); this.form.planPalletId = String(item.planPalletId); this.form.packageCount = Math.max(1, Number(item.plannedPackageCount || 0) - Number(item.pickedPackageCount || 0)) },
selectCarton(item) { this.form.cartonMark = item.cartonMark || ''; if (positive(item.planPalletId)) this.form.planPalletId = String(item.planPalletId) },
async loadTask() { try { this.validateScope(); this.task = await this.execute('查询入仓任务', () => getInboundTask(this.form.planNo.trim(), this.form.customerId.trim())) } catch (_) {} },
async pushTask() { try { this.validateScope(); const payload = this.operator(); const key = this.key('push', payload); this.task = await this.execute('推送入仓任务', () => pushInboundTask(this.form.planNo.trim(), this.form.customerId.trim(), { ...payload, idempotencyKey: key.value })); delete this.keys[key.signature] } catch (_) {} },
async assignPallet() { try { this.validateScope(); for (const field of ['planPalletId','warehouseId','palletId','locationId']) if (!positive(this.form[field])) throw new Error('计划托盘、仓库、实物托盘和库位ID均必须填写'); const payload = { warehouseId: this.form.warehouseId, palletId: this.form.palletId, locationId: this.form.locationId }; const key = this.key('assign', payload); await this.execute('分配托盘资源', () => assignInboundPallet(this.form.planNo.trim(), this.form.customerId.trim(), this.form.planPalletId, { ...payload, idempotencyKey: key.value })); delete this.keys[key.signature] } catch (error) { if (!this.error) this.error = error } },
packagePayload() { if (!positive(this.form.allocationId) || !positive(this.form.packageCount)) throw new Error('分配明细ID和箱数必须大于0'); return { packageCount: Number(this.form.packageCount), ...this.operator() } },
async countPackages() { try { this.validateScope(); const payload = this.packagePayload(); const key = this.key('count', payload); await this.execute('确认点数', () => countInboundPackages(this.form.planNo.trim(), this.form.customerId.trim(), this.form.allocationId, { ...payload, idempotencyKey: key.value })); delete this.keys[key.signature] } catch (error) { if (!this.error) this.error = error } },
async pickPackages() { try { this.validateScope(); const payload = this.packagePayload(); const key = this.key('pick', payload); await this.execute('确认实收', () => pickInboundPackages(this.form.planNo.trim(), this.form.customerId.trim(), this.form.allocationId, { ...payload, idempotencyKey: key.value })); delete this.keys[key.signature] } catch (error) { if (!this.error) this.error = error } },
async scanCarton() { try { this.validateScope(); if (!this.form.cartonMark.trim() || !positive(this.form.planPalletId)) throw new Error('请扫描箱唛并填写计划托盘ID'); const payload = { cartonMark: this.form.cartonMark.trim(), planPalletId: this.form.planPalletId, ...this.operator() }; const key = this.key('scan', payload); await this.execute('箱唛绑定托盘', () => scanInboundCarton(this.form.planNo.trim(), this.form.customerId.trim(), { ...payload, idempotencyKey: key.value })); delete this.keys[key.signature]; this.form.cartonMark = '' } catch (error) { if (!this.error) this.error = error } },
async removeCarton() { try { this.validateScope(); if (!this.form.cartonMark.trim()) throw new Error('请扫描需要移除的箱唛'); const payload = { cartonMark: this.form.cartonMark.trim(), ...this.operator() }; const key = this.key('remove', payload); await this.execute('移除箱唛', () => removeInboundCarton(this.form.planNo.trim(), this.form.customerId.trim(), { ...payload, idempotencyKey: key.value })); delete this.keys[key.signature] } catch (error) { if (!this.error) this.error = error } },
chooseUnloadPhotos() { try { this.validateScope(); uni.chooseImage({ count: 9, sourceType: ['camera','album'], success: async ({ tempFilePaths }) => { try { for (const path of tempFilePaths) { const file = await this.execute('上传卸柜照片', () => uploadUnloadPhotoFile(this.form.planNo.trim(), this.form.customerId.trim(), path)); if (file?.fileId && !this.photoFileIds.includes(String(file.fileId))) this.photoFileIds.push(String(file.fileId)) } } catch (_) {} } }) } catch (error) { this.error = error } },
async saveUnloadPhotos() { try { this.validateScope(); if (!this.photoFileIds.length) throw new Error('请先上传卸柜照片'); const payload = { fileIds: this.photoFileIds, ...this.operator() }; const key = this.key('photos', payload); this.task = await this.execute('保存卸柜照片', () => bindUnloadPhotos(this.form.planNo.trim(), this.form.customerId.trim(), { ...payload, idempotencyKey: key.value })); delete this.keys[key.signature] } catch (error) { if (!this.error) this.error = error } },
async completeTask() { try { this.validateScope(); this.task = await this.execute('完成入仓', () => completeInboundTask(this.form.planNo.trim(), this.form.customerId.trim())) } catch (_) {} },
prototypeScan() { const value = String(this.scanValue || '').trim(); if (!value) return this.showToast('请先扫描或输入编码', 'warning'); this.showToast('该模块尚未接入真实业务接口', 'warning') },
showToast(message, type) { clearTimeout(this._toastTimer); this.toast = { visible: true, message, type }; this._toastTimer = setTimeout(() => { this.toast.visible = false }, 2600) }
}
}
</script>
<style scoped>
.work-card,.result-card,.error-card{margin-bottom:12px;padding:14px;border-radius:12px;background:#fff;box-shadow:0 4px 15px rgba(32,67,111,.06)}.card-title,.task-summary text,.hint-text,.error-card text{display:block;margin-bottom:8px}.card-title{font-weight:700}.instruction-row{width:100%;min-height:38px;margin-top:6px;padding:7px 9px;color:#23456f;font-size:11px;text-align:left;border:1px solid #d7e4f5;background:#f5f9ff}.work-input{box-sizing:border-box;width:100%;height:44px;margin-bottom:9px;padding:0 11px;border:1px solid #cfd8e3;border-radius:8px;background:#fbfdff}.button-row{display:flex;gap:8px}.secondary-button,.danger-button,.primary-action{min-height:42px;flex:1;font-size:13px}.secondary-button{color:#135ee8;border:1px solid #8cb5f5;background:#edf4ff}.danger-button{color:#b42318;border:1px solid #f2a29b;background:#fff1f0}.primary-action{width:100%;color:#fff;background:#135ee8}.full-button,.complete-button{width:100%;margin-top:8px}.hint-text{margin-top:8px;color:#52657c;font-size:11px;word-break:break-all}.error-card{color:#b42318;background:#fff1f0}.result-card{color:#176b3a;background:#eefbf3}
</style>