功能:新增托盘业务操作页面
This commit is contained in:
@@ -0,0 +1,496 @@
|
||||
<template>
|
||||
<div class="app-container usage-page">
|
||||
<el-card v-if="canAddUsage" class="usage-operation" shadow="never">
|
||||
<template #header>
|
||||
<div class="usage-operation__header">
|
||||
<span>现场使用 PDA</span>
|
||||
<el-tag type="info">扫码枪 / 手工输入</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-alert v-if="operationError" :title="operationError" type="error" :closable="false" show-icon class="mb8" />
|
||||
<el-alert
|
||||
v-if="usageResult"
|
||||
:title="`使用成功:后端返回余量 ${usageResult.remainingPackages} 包,状态 ${usageResult.flowState}`"
|
||||
type="success"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb8"
|
||||
/>
|
||||
|
||||
<div class="usage-grid">
|
||||
<section class="usage-panel">
|
||||
<div class="usage-label">扫描托盘</div>
|
||||
<pallet-scan-input
|
||||
:disabled="operationPending || optionLoading"
|
||||
:loading="scanLoading"
|
||||
@scan="handleUsageScan"
|
||||
@invalid="handleScanInvalid"
|
||||
@duplicate="handleScanDuplicate"
|
||||
/>
|
||||
<div class="usage-tip">支持 Android 浏览器连接扫码枪后直接回车提交,不调用摄像头。</div>
|
||||
</section>
|
||||
|
||||
<section class="usage-panel usage-panel--summary">
|
||||
<template v-if="scannedPallet">
|
||||
<div class="summary-item"><span>托盘</span><strong>{{ scannedPallet.palletCode || `#${scannedPallet.palletId}` }}</strong></div>
|
||||
<div class="summary-item"><span>菌种</span><strong>{{ scannedPallet.strainName || scannedPallet.strainCode || (scannedPallet.strainId ? `#${scannedPallet.strainId}` : '—') }}</strong></div>
|
||||
<div class="summary-item"><span>批次</span><strong>{{ scannedPallet.batchNo || '—' }}</strong></div>
|
||||
<div class="summary-item"><span>当前余量</span><strong>{{ scannedPallet.remainingPackages }} 包</strong></div>
|
||||
</template>
|
||||
<el-empty v-else description="等待扫描托盘" :image-size="72" />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<el-form ref="usageRef" :model="form" :rules="rules" label-position="top" class="usage-form">
|
||||
<el-row :gutter="18">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item label="使用产线" prop="lineId">
|
||||
<el-select
|
||||
v-model="form.lineId"
|
||||
class="business-control"
|
||||
placeholder="请先扫描托盘"
|
||||
filterable
|
||||
style="width: 100%"
|
||||
:loading="optionLoading"
|
||||
:disabled="operationPending || lineLocked || optionLoading || !scannedPallet"
|
||||
>
|
||||
<el-option v-for="option in lineOptions" :key="option.id" :label="option.name || `#${option.id}`" :value="option.id" />
|
||||
</el-select>
|
||||
<div v-if="lineLocked" class="usage-tip">托盘已绑定产线,当前操作不可切换。</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item label="本次使用包数" prop="usedPackages">
|
||||
<el-input-number
|
||||
v-model="form.usedPackages"
|
||||
class="business-control"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
:disabled="operationPending || !scannedPallet"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
|
||||
<div class="usage-actions">
|
||||
<el-button
|
||||
class="business-action"
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="submitLoading"
|
||||
:disabled="operationPending || scanLoading || optionLoading || !scannedPallet || Boolean(usageResult)"
|
||||
@click="submitUsage"
|
||||
v-hasPermi="['business:usage:add']"
|
||||
>提交使用</el-button>
|
||||
<el-button class="business-action" size="large" :disabled="operationPending" @click="resetUsageOperation">清空重扫</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-divider v-if="canAddUsage" />
|
||||
|
||||
<el-form ref="queryRef" :model="queryParams" :inline="true">
|
||||
<el-form-item label="使用记录号" prop="usageNo">
|
||||
<el-input v-model="queryParams.usageNo" placeholder="请输入使用记录号" clearable maxlength="64" @keyup.enter="handleQuery" />
|
||||
</el-form-item>
|
||||
<el-form-item label="托盘编号" prop="palletCode">
|
||||
<el-input v-model="queryParams.palletCode" placeholder="请输入托盘编号" clearable maxlength="64" @keyup.enter="handleQuery" />
|
||||
</el-form-item>
|
||||
<el-form-item label="现场ID" prop="siteId">
|
||||
<el-input-number v-model="queryParams.siteId" :min="1" :controls="false" placeholder="请输入现场ID" />
|
||||
</el-form-item>
|
||||
<el-form-item label="产线ID" prop="lineId">
|
||||
<el-input-number v-model="queryParams.lineId" :min="1" :controls="false" placeholder="请输入产线ID" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" icon="Search" :disabled="operationPending" @click="handleQuery" v-hasPermi="['business:usage:list']">搜索</el-button>
|
||||
<el-button icon="Refresh" :disabled="operationPending" @click="resetQuery" v-hasPermi="['business:usage:list']">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon class="mb8" />
|
||||
<el-table v-loading="loading" empty-text="暂无数据" :data="usageList">
|
||||
<el-table-column label="使用记录号" prop="usageNo" min-width="170" :show-overflow-tooltip="true" />
|
||||
<el-table-column label="托盘" min-width="120">
|
||||
<template #default="scope">{{ scope.row.palletCode || `#${scope.row.palletId}` }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="现场ID" prop="siteId" width="100" />
|
||||
<el-table-column label="产线ID" prop="lineId" width="100" />
|
||||
<el-table-column label="使用包数" prop="usedPackages" width="105" align="right" />
|
||||
<el-table-column label="使用后余量" prop="remainingPackagesAfter" width="115" align="right" />
|
||||
<el-table-column label="发生时间" prop="occurredAt" width="180">
|
||||
<template #default="scope">{{ parseTime(scope.row.occurredAt) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination
|
||||
v-show="total > 0"
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNum"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="BusinessUsage">
|
||||
import PalletScanInput from '@/components/PalletScanInput'
|
||||
import { createIdempotencyKey } from '@/utils/palletScan'
|
||||
import { addUsage, getUsageLineOptions, listUsage, scanUsagePallet } from '@/api/business/usage'
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
const usageList = ref([])
|
||||
const lineOptions = ref([])
|
||||
const scannedPallet = ref(null)
|
||||
const usageResult = ref(null)
|
||||
const lineLocked = ref(false)
|
||||
const loading = ref(false)
|
||||
const optionLoading = ref(false)
|
||||
const scanLoading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const loadError = ref('')
|
||||
const operationError = ref('')
|
||||
const total = ref(0)
|
||||
const usageIdempotencyKey = ref('')
|
||||
const canAddUsage = computed(() => proxy.$auth.hasPermiAnd(['business:usage:query', 'business:usage:add']))
|
||||
const operationPending = computed(() => submitLoading.value)
|
||||
let listGeneration = 0
|
||||
let optionGeneration = 0
|
||||
let scanGeneration = 0
|
||||
let operationSession = 0
|
||||
|
||||
function validatePositiveInteger(rule, value, callback) {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
callback(new Error('使用包数必须为正整数'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
}
|
||||
|
||||
const data = reactive({
|
||||
form: { qrToken: '', lineId: undefined, usedPackages: undefined },
|
||||
queryParams: { pageNum: 1, pageSize: 10, usageNo: undefined, palletCode: undefined, siteId: undefined, lineId: undefined },
|
||||
rules: {
|
||||
lineId: [{ required: true, message: '请选择使用产线', trigger: 'change' }],
|
||||
usedPackages: [{ required: true, validator: validatePositiveInteger, trigger: 'blur' }]
|
||||
}
|
||||
})
|
||||
const { form, queryParams, rules } = toRefs(data)
|
||||
|
||||
function isCurrentListRequest(listContext) {
|
||||
return listContext.generation === listGeneration
|
||||
}
|
||||
|
||||
function getList() {
|
||||
const listContext = {
|
||||
generation: ++listGeneration,
|
||||
query: { ...queryParams.value }
|
||||
};
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
listUsage(listContext.query).then((response) => {
|
||||
if (!isCurrentListRequest(listContext)) {
|
||||
return;
|
||||
}
|
||||
usageList.value = response.rows
|
||||
total.value = response.total
|
||||
}).catch(() => {
|
||||
if (!isCurrentListRequest(listContext)) {
|
||||
return;
|
||||
}
|
||||
usageList.value = []
|
||||
total.value = 0
|
||||
loadError.value = '使用记录加载失败,请稍后重试'
|
||||
}).finally(() => {
|
||||
if (!isCurrentListRequest(listContext)) {
|
||||
return;
|
||||
}
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function invalidateOptionRequest() {
|
||||
optionGeneration += 1
|
||||
optionLoading.value = false
|
||||
}
|
||||
|
||||
function isCurrentOptionRequest(optionContext) {
|
||||
return optionContext.generation === optionGeneration &&
|
||||
optionContext.operationSession === operationSession &&
|
||||
optionContext.scanGeneration === scanGeneration &&
|
||||
optionContext.palletId === scannedPallet.value?.palletId
|
||||
}
|
||||
|
||||
function invalidateScanRequest() {
|
||||
scanGeneration += 1
|
||||
scanLoading.value = false
|
||||
}
|
||||
|
||||
function isCurrentScanRequest(scanContext) {
|
||||
return scanContext.generation === scanGeneration && scanContext.operationSession === operationSession
|
||||
}
|
||||
|
||||
function loadLineOptions(pallet, scanContext) {
|
||||
const optionContext = {
|
||||
generation: ++optionGeneration,
|
||||
operationSession,
|
||||
scanGeneration: scanContext.generation,
|
||||
palletId: pallet.palletId,
|
||||
siteId: pallet.siteId
|
||||
}
|
||||
optionLoading.value = true
|
||||
getUsageLineOptions(pallet.siteId).then((response) => {
|
||||
if (!isCurrentOptionRequest(optionContext)) return
|
||||
lineOptions.value = Array.isArray(response.data) ? response.data : []
|
||||
if (!lineOptions.value.length) operationError.value = '该现场暂无可用产线'
|
||||
}).catch(() => {
|
||||
if (!isCurrentOptionRequest(optionContext)) return
|
||||
lineOptions.value = []
|
||||
operationError.value = '产线选项加载失败,请稍后重新扫码'
|
||||
}).finally(() => {
|
||||
if (!isCurrentOptionRequest(optionContext)) return
|
||||
optionLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function clearUsageIdempotencyKey() {
|
||||
usageIdempotencyKey.value = ''
|
||||
}
|
||||
|
||||
function ensureUsageIdempotencyKey() {
|
||||
if (!usageIdempotencyKey.value) usageIdempotencyKey.value = createIdempotencyKey()
|
||||
return usageIdempotencyKey.value
|
||||
}
|
||||
|
||||
function resetUsageOperation() {
|
||||
if (operationPending.value) return
|
||||
operationSession += 1
|
||||
invalidateOptionRequest()
|
||||
invalidateScanRequest()
|
||||
clearUsageIdempotencyKey()
|
||||
scannedPallet.value = null
|
||||
usageResult.value = null
|
||||
lineOptions.value = []
|
||||
lineLocked.value = false
|
||||
form.value = { qrToken: '', lineId: undefined, usedPackages: undefined }
|
||||
operationError.value = ''
|
||||
proxy.resetForm('usageRef')
|
||||
}
|
||||
|
||||
function handleQuery() {
|
||||
queryParams.value.pageNum = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
function resetQuery() {
|
||||
proxy.resetForm('queryRef')
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
function handleUsageScan(qrToken) {
|
||||
if (operationPending.value || scanLoading.value || optionLoading.value) return
|
||||
invalidateOptionRequest()
|
||||
clearUsageIdempotencyKey()
|
||||
scannedPallet.value = null
|
||||
usageResult.value = null
|
||||
lineOptions.value = []
|
||||
lineLocked.value = false
|
||||
form.value = { qrToken: '', lineId: undefined, usedPackages: undefined }
|
||||
const scanContext = { generation: ++scanGeneration, operationSession, qrToken }
|
||||
scanLoading.value = true
|
||||
operationError.value = ''
|
||||
scanUsagePallet(qrToken).then((response) => {
|
||||
if (!isCurrentScanRequest(scanContext)) return
|
||||
const pallet = response.data
|
||||
if (!['AT_SITE_UNUSED', 'AT_SITE_PARTIAL'].includes(pallet.flowState) || pallet.remainingPackages <= 0) {
|
||||
operationError.value = '该托盘当前不可记录使用'
|
||||
return
|
||||
}
|
||||
scannedPallet.value = {
|
||||
palletId: pallet.palletId,
|
||||
palletCode: pallet.palletCode || `#${pallet.palletId}`,
|
||||
siteId: pallet.siteId,
|
||||
currentLineId: pallet.currentLineId,
|
||||
strainId: pallet.strainId,
|
||||
strainCode: pallet.strainCode,
|
||||
strainName: pallet.strainName,
|
||||
batchNo: pallet.batchNo,
|
||||
remainingPackages: pallet.remainingPackages,
|
||||
flowState: pallet.flowState
|
||||
}
|
||||
form.value.qrToken = qrToken
|
||||
form.value.usedPackages = undefined
|
||||
if (pallet.currentLineId == null) {
|
||||
lineLocked.value = false
|
||||
form.value.lineId = undefined
|
||||
lineOptions.value = []
|
||||
loadLineOptions(pallet, scanContext)
|
||||
} else {
|
||||
lineLocked.value = true
|
||||
form.value.lineId = pallet.currentLineId
|
||||
lineOptions.value = [{ id: pallet.currentLineId, code: `#${pallet.currentLineId}`, name: `#${pallet.currentLineId}` }]
|
||||
}
|
||||
}).catch(() => {
|
||||
if (!isCurrentScanRequest(scanContext)) return
|
||||
operationError.value = '托盘扫码校验失败,请检查后重试'
|
||||
}).finally(() => {
|
||||
if (!isCurrentScanRequest(scanContext)) return
|
||||
scanLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function handleScanInvalid(message) {
|
||||
invalidateOptionRequest()
|
||||
clearUsageIdempotencyKey()
|
||||
scannedPallet.value = null
|
||||
usageResult.value = null
|
||||
lineOptions.value = []
|
||||
lineLocked.value = false
|
||||
form.value = { qrToken: '', lineId: undefined, usedPackages: undefined }
|
||||
operationError.value = message
|
||||
}
|
||||
|
||||
function handleScanDuplicate() {
|
||||
proxy.$modal.msgWarning('扫码过快,请勿重复扫描')
|
||||
}
|
||||
|
||||
function submitUsage() {
|
||||
if (operationPending.value || scanLoading.value || optionLoading.value || usageResult.value) return
|
||||
submitLoading.value = true
|
||||
proxy.$refs.usageRef.validate((valid) => {
|
||||
if (!valid) {
|
||||
submitLoading.value = false
|
||||
return
|
||||
}
|
||||
if (!scannedPallet.value || !form.value.qrToken) {
|
||||
operationError.value = '请先扫描待使用托盘'
|
||||
submitLoading.value = false
|
||||
return
|
||||
}
|
||||
const idempotencyKey = ensureUsageIdempotencyKey()
|
||||
operationError.value = ''
|
||||
addUsage({
|
||||
qrToken: form.value.qrToken,
|
||||
lineId: form.value.lineId,
|
||||
usedPackages: form.value.usedPackages,
|
||||
idempotencyKey: idempotencyKey
|
||||
}).then((response) => {
|
||||
const result = response.data;
|
||||
usageResult.value = {
|
||||
remainingPackages: result.remainingPackages,
|
||||
flowState: result.flowState
|
||||
}
|
||||
clearUsageIdempotencyKey()
|
||||
proxy.$modal.msgSuccess(response.msg || '使用记录提交成功')
|
||||
getList()
|
||||
}).catch(() => {
|
||||
operationError.value = '使用提交失败;直接重试将复用本次幂等键'
|
||||
}).finally(() => {
|
||||
submitLoading.value = false
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [form.value.qrToken, form.value.lineId, form.value.usedPackages],
|
||||
() => {
|
||||
if (!submitLoading.value) clearUsageIdempotencyKey()
|
||||
}
|
||||
)
|
||||
|
||||
getList();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.usage-operation {
|
||||
border-color: var(--el-color-primary-light-7);
|
||||
}
|
||||
|
||||
.usage-operation__header,
|
||||
.usage-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.usage-operation__header {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.usage-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(300px, 0.8fr);
|
||||
gap: 18px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.usage-panel {
|
||||
padding: 18px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-blank);
|
||||
}
|
||||
|
||||
.usage-label {
|
||||
margin-bottom: 10px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.usage-tip {
|
||||
margin-top: 7px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--el-border-color-extra-light);
|
||||
}
|
||||
|
||||
.summary-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.summary-item span {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.usage-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.usage-page {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.usage-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.usage-panel {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.usage-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
:deep(.business-control .el-input__wrapper),
|
||||
:deep(.business-action) {
|
||||
min-height: 56px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user