更新登录页及优化相关功能

This commit is contained in:
焦龙言
2026-10-08 14:13:05 +08:00
parent 9a14b692e3
commit 56e646bcc1
139 changed files with 77587 additions and 811 deletions
@@ -9,7 +9,7 @@
maxlength="128"
:focus="focusRequested"
:disabled="disabled || loading"
placeholder="请扫描或输入托盘二维码后按回车"
placeholder="扫描二维码或输入托盘号后按回车"
@confirm="handleSubmit"
@input="clearError"
/>
@@ -0,0 +1,302 @@
<template>
<view v-if="canReceive" class="space-y-4">
<view class="rounded-2xl bg-white p-4 shadow-sm">
<text class="mb-3 block font-bold text-gray-800">收货单</text>
<picker :range="receiptStateOptions" range-key="label" :value="receiptStateIndex" @change="changeReceiptState">
<view class="mb-3 rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">{{ receiptStateOptions[receiptStateIndex].label }}</view>
</picker>
<input v-model="queryParams.shipmentNo" class="mb-3 rounded-xl bg-gray-50 px-3 py-3 text-sm" maxlength="64" placeholder="输入原发货单号" confirm-type="search" @confirm="handleQuery" />
<button size="mini" class="rounded-lg bg-blue-50 text-blue-600" :disabled="loading" @click="handleQuery">查询收货</button>
<text v-if="listError" class="mt-3 block text-xs text-red-500">{{ listError }}</text>
<text v-if="loading" class="mt-3 block text-xs text-gray-400">加载中...</text>
<text v-else-if="!receiptList.length" class="mt-3 block text-xs text-gray-400">暂无收货单</text>
<view v-for="row in receiptList" :key="row.shipmentId" class="mt-3 rounded-xl border border-gray-100 p-3">
<view class="flex items-center justify-between gap-2">
<text class="text-sm font-medium text-gray-700">{{ row.shipmentNo }}</text>
<text class="text-xs text-orange-600">{{ receiptStateLabel(row.receiptState) }}</text>
</view>
<text class="mt-2 block text-xs text-gray-500">仓库 #{{ row.sourceWarehouseId }} → 现场 #{{ row.targetSiteId }}</text>
<text v-if="row.receiptState === 'RECEIVED'" class="mt-1 block text-xs text-gray-500">收货时间:{{ row.receivedAt || '未登记' }}</text>
<button size="mini" class="mt-3 rounded-lg bg-orange-50 text-orange-600" @click="openReceipt(row)">{{ row.receiptState === 'PENDING' ? '核对收货' : '查看明细' }}</button>
</view>
<view class="mt-3 flex items-center justify-between gap-2">
<button size="mini" :disabled="loading || queryParams.pageNum <= 1" @click="changePage(-1)">上一页</button>
<text class="text-xs text-gray-500">第 {{ queryParams.pageNum }} 页 · 共 {{ total }} 单</text>
<button size="mini" :disabled="loading || queryParams.pageNum * queryParams.pageSize >= total" @click="changePage(1)">下一页</button>
</view>
</view>
<view v-if="activeShipmentId !== null" class="rounded-2xl bg-white p-4 shadow-sm">
<view class="mb-3 flex items-center justify-between">
<text class="font-bold text-gray-800">收货核对</text>
<button size="mini" class="text-xs text-gray-500" @click="closeReceipt">关闭</button>
</view>
<text v-if="detailLoading" class="block text-xs text-gray-400">加载原单明细...</text>
<text v-if="detailError" class="mb-3 block text-xs text-red-500">{{ detailError }}</text>
<button v-if="detailError" size="mini" :disabled="detailLoading || confirmLoading" @click="retryDetail">重新读取详情</button>
<template v-if="receiptDetail">
<text class="block text-sm font-medium text-gray-700">{{ receiptDetail.shipmentNo }} · {{ receiptStateLabel(receiptDetail.receiptState) }}</text>
<text class="mt-1 block text-xs text-gray-500">目标现场 #{{ receiptDetail.targetSiteId }}</text>
<text v-if="receiptDetail.receiptState === 'RECEIVED'" class="mt-1 block text-xs text-gray-500">收货时间:{{ receiptDetail.receivedAt || '未登记' }}</text>
<text v-if="receiptDetail.receiptState === 'LEGACY'" class="mt-3 block text-xs text-gray-500">历史未登记收货:仅供查询原单明细,不补造收货时间。</text>
<view v-if="receiptDetail.receiptState === 'PENDING'" class="mt-3">
<PalletScanInput :key="activeShipmentId" :disabled="!canScan" :loading="scanLoading" @scan="handleReceiptScan" @invalid="message => scanError = message" @duplicate="scanError = '扫码过快,请勿重复扫描'" />
<text class="mt-2 block text-xs text-gray-500">逐托核对原标签,已核对 {{ scannedPalletIds.length }} / {{ receiptItems.length }} 托,全部核对后才可确认。</text>
</view>
<text v-if="scanError" class="mt-3 block text-xs text-red-500">{{ scanError }}</text>
<text v-if="!receiptItems.length" class="mt-3 block text-xs text-red-500">原单无托盘明细,无法确认收货</text>
<view v-for="item in receiptItems" :key="item.palletId" class="mt-3 rounded-xl bg-gray-50 p-3">
<view class="flex items-center justify-between gap-2">
<text class="text-sm font-medium text-gray-700">{{ item.palletCode }}</text>
<text v-if="receiptDetail.receiptState === 'PENDING'" class="text-xs text-orange-600">{{ scannedPalletIds.includes(item.palletId) ? '已核对' : '待扫描' }}</text>
</view>
<text class="mt-2 block text-xs text-gray-600">品种:{{ item.strainName || '未登记' }} · 原发货 {{ item.packageQty }} 包</text>
<text class="mt-1 block text-xs text-gray-500">批号:{{ item.batchNo || '未登记' }}</text>
<text class="mt-1 block text-xs text-gray-500">生产日期:{{ item.productionDate || '未登记' }}</text>
<text class="mt-1 block text-xs text-gray-500">失效日期:{{ item.expiryDate || '未登记' }}</text>
<button v-if="receiptDetail.receiptState === 'PENDING' && scannedPalletIds.includes(item.palletId)" size="mini" class="mt-2 text-xs text-red-500" :disabled="!canScan || scanLoading" @click="removeScannedPallet(item.palletId)">撤销核对</button>
</view>
<text v-if="confirmError" class="mt-3 block text-xs text-red-500">{{ confirmError }}</text>
<button v-if="receiptDetail.receiptState === 'PENDING'" class="mt-4 rounded-xl bg-orange-500 py-3 text-sm text-white disabled:bg-gray-300" :disabled="!canConfirmReceipt" :loading="confirmLoading" @click="confirmReceipt">确认收货</button>
</template>
</view>
</view>
</template>
<script setup>
import { computed, getCurrentInstance, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { onHide, onShow, onUnload } from '@dcloudio/uni-app'
import PalletScanInput from '@/components/business/PalletScanInput.vue'
import { listShipmentReceipts, getShipmentReceipt, scanShipmentReceipt, confirmShipmentReceipt } from '@/api/business/shipmentReceipt'
import { createIdempotencyKey } from '@/utils/palletScan'
const { proxy } = getCurrentInstance()
const emit = defineEmits(['received'])
const canReceive = computed(() => proxy.$auth.hasPermi('business:shipment:receive'))
const queryParams = reactive({ pageNum: 1, pageSize: 10, receiptState: 'PENDING', shipmentNo: undefined })
const receiptList = ref([])
const total = ref(0)
const loading = ref(false)
const listError = ref('')
const activeShipmentId = ref(null)
const receiptDetail = ref(null)
const detailLoading = ref(false)
const detailError = ref('')
const scannedPalletIds = ref([])
const scanLoading = ref(false)
const scanError = ref('')
const confirmLoading = ref(false)
const confirmError = ref('')
const receiptAccepted = ref(false)
let session = 0
let listGeneration = 0
let suspended = false
let idempotencyKey = ''
const receiptItems = computed(() => receiptDetail.value?.items || [])
const canScan = computed(() => canReceive.value && activeShipmentId.value !== null &&
receiptDetail.value?.status === 'CONFIRMED' && receiptDetail.value?.receiptState === 'PENDING' &&
!detailLoading.value && !detailError.value && !confirmLoading.value && !receiptAccepted.value)
const canConfirmReceipt = computed(() => canScan.value && !scanLoading.value &&
receiptItems.value.length > 0 && receiptItems.value.length === scannedPalletIds.value.length &&
receiptItems.value.every(item => scannedPalletIds.value.includes(item.palletId)))
function receiptStateLabel(state) {
return { PENDING: '待收货', RECEIVED: '已收货', LEGACY: '历史未登记收货' }[state] || '未登记收货'
}
function errorMessage(error, fallback) {
return error?.response?.data?.msg || error?.message || fallback
}
function isCurrent(currentSession) {
return currentSession === session && !suspended && canReceive.value
}
async function loadReceipts() {
if (!canReceive.value || suspended) return
const generation = ++listGeneration
loading.value = true
listError.value = ''
try {
const response = await listShipmentReceipts({ ...queryParams })
if (generation !== listGeneration || suspended || !canReceive.value) return
receiptList.value = Array.isArray(response.rows) ? response.rows : []
total.value = response.total || 0
} catch (error) {
if (generation !== listGeneration || suspended || !canReceive.value) return
receiptList.value = []
total.value = 0
listError.value = errorMessage(error, '收货列表加载失败,请重试')
} finally {
if (generation === listGeneration) loading.value = false
}
}
function handleQuery() {
queryParams.pageNum = 1
return loadReceipts()
}
function closeReceipt() {
// 会话版本同时隔离详情、扫码、确认响应,旧请求不能写入新单据。
session += 1
activeShipmentId.value = null
receiptDetail.value = null
scannedPalletIds.value = []
detailLoading.value = false
scanLoading.value = false
confirmLoading.value = false
receiptAccepted.value = false
detailError.value = ''
scanError.value = ''
confirmError.value = ''
idempotencyKey = ''
}
async function loadReceiptDetail(shipmentId, currentSession) {
detailLoading.value = true
detailError.value = ''
try {
const response = await getShipmentReceipt(shipmentId)
if (!isCurrent(currentSession)) return
if (!response.data || String(response.data.shipmentId) !== String(shipmentId)) {
throw new Error('收货详情与当前单据不一致,请重新打开')
}
receiptDetail.value = response.data
} catch (error) {
if (!isCurrent(currentSession)) return
detailError.value = errorMessage(error, '收货详情加载失败,请重试')
} finally {
if (isCurrent(currentSession)) detailLoading.value = false
}
}
async function openReceipt(row) {
if (!canReceive.value || suspended || row.shipmentId == null) return
closeReceipt()
activeShipmentId.value = row.shipmentId
await loadReceiptDetail(row.shipmentId, session)
}
function retryDetail() {
if (activeShipmentId.value === null || detailLoading.value || confirmLoading.value) return
return loadReceiptDetail(activeShipmentId.value, session)
}
async function handleReceiptScan(identifier) {
if (!canScan.value || scanLoading.value) return
const value = String(identifier ?? '').trim()
if (!value) {
scanError.value = '请扫描二维码或输入托盘号'
return
}
const currentSession = session
scanLoading.value = true
scanError.value = ''
try {
const response = await scanShipmentReceipt(activeShipmentId.value, value)
if (!isCurrent(currentSession)) return
const scanned = response.data
const original = receiptItems.value.find(item => String(item.palletId) === String(scanned?.palletId))
if (!original || String(original.activeFillingId) !== String(scanned?.activeFillingId)) {
scanError.value = '该托盘或装填记录不属于当前发货单'
return
}
if (scannedPalletIds.value.includes(original.palletId)) {
scanError.value = '该托盘已核对,请勿重复扫描'
return
}
// 仅登记实际扫码通过的 ID;品种、批号、日期与包数始终展示原单快照。
scannedPalletIds.value.push(original.palletId)
idempotencyKey = ''
confirmError.value = ''
} catch (error) {
if (isCurrent(currentSession)) scanError.value = errorMessage(error, '托盘核对失败,请重试')
} finally {
if (isCurrent(currentSession)) scanLoading.value = false
}
}
function removeScannedPallet(palletId) {
if (!canScan.value || scanLoading.value) return
if (!scannedPalletIds.value.includes(palletId)) return
scannedPalletIds.value = scannedPalletIds.value.filter(id => id !== palletId)
idempotencyKey = ''
confirmError.value = ''
}
async function confirmReceipt() {
if (!canReceive.value || confirmLoading.value || receiptAccepted.value) return
if (!canConfirmReceipt.value) {
confirmError.value = '请逐托扫码核对全部明细后再确认收货'
return
}
const currentSession = session
const shipmentId = activeShipmentId.value
confirmLoading.value = true
confirmError.value = ''
try {
// 失败保留同一键;增删核对集合时作废,下一次提交生成新键。
if (!idempotencyKey) idempotencyKey = createIdempotencyKey()
const response = await confirmShipmentReceipt(shipmentId, {
scannedPalletIds: [...scannedPalletIds.value], idempotencyKey
})
if (!isCurrent(currentSession)) return
receiptAccepted.value = true
proxy.$modal.msgSuccess(response.msg || '收货成功')
emit('received')
// 不推算余量,也不伪造已收状态/时间。刷新失败仍锁定本次已接受的确认。
await Promise.all([loadReceiptDetail(shipmentId, currentSession), loadReceipts()])
} catch (error) {
if (isCurrent(currentSession)) {
confirmError.value = errorMessage(error, '收货确认失败') + ';未修改核对集合时重试使用同一幂等键'
}
} finally {
if (isCurrent(currentSession)) confirmLoading.value = false
}
}
function suspendPanel() {
suspended = true
listGeneration += 1
loading.value = false
closeReceipt()
}
function resumePanel() {
if (!suspended) return
suspended = false
loadReceipts()
}
onMounted(loadReceipts)
onBeforeUnmount(suspendPanel)
const receiptStateOptions = [
{ label: '待收货', value: 'PENDING' },
{ label: '已收货', value: 'RECEIVED' },
{ label: '历史未登记收货', value: 'LEGACY' },
{ label: '全部', value: undefined }
]
const receiptStateIndex = computed(() => receiptStateOptions.findIndex(item => item.value === queryParams.receiptState))
function changeReceiptState(event) {
const option = receiptStateOptions[Number(event.detail.value)]
if (!option) return
queryParams.receiptState = option.value
handleQuery()
}
function changePage(offset) {
const page = queryParams.pageNum + offset
if (loading.value || page < 1 || (page > 1 && (page - 1) * queryParams.pageSize >= total.value)) return
queryParams.pageNum = page
loadReceipts()
}
onShow(resumePanel)
onHide(suspendPanel)
onUnload(suspendPanel)
</script>