移动端:增加测试批次模式隔离
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
<template>
|
||||
<view class="min-h-screen bg-gray-50">
|
||||
<scroll-view scroll-y class="h-screen">
|
||||
<view class="space-y-4 p-4 pb-24">
|
||||
<view>
|
||||
<text class="block text-xl font-bold text-gray-800">客户现场使用</text>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
扫描现场托盘,首次使用绑定产线并记录本次消耗包数
|
||||
</text>
|
||||
</view>
|
||||
<TestBatchBanner />
|
||||
|
||||
<view v-if="!canPage" class="rounded-2xl bg-white p-6 text-center shadow-sm">
|
||||
<text class="text-sm text-gray-500">当前账号没有现场使用权限</text>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<view 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 class="text-xs text-gray-500" size="mini" :disabled="operationPending" @click="resetUsageOperation">
|
||||
重新扫描
|
||||
</button>
|
||||
</view>
|
||||
<PalletScanInput
|
||||
:disabled="operationPending || optionLoading"
|
||||
:loading="scanLoading"
|
||||
@scan="handleUsageScan"
|
||||
@invalid="handleScanInvalid"
|
||||
@duplicate="handleScanDuplicate"
|
||||
/>
|
||||
<text class="mt-2 block text-xs text-gray-400">
|
||||
仅接受 AT_SITE_UNUSED 或 AT_SITE_PARTIAL 且仍有余量的托盘。
|
||||
</text>
|
||||
<text v-if="operationError" class="mt-2 block text-xs text-red-500">{{ operationError }}</text>
|
||||
|
||||
<view v-if="scannedPallet" class="mt-4 rounded-xl bg-purple-50 p-3">
|
||||
<view class="flex items-center justify-between">
|
||||
<text class="font-medium text-gray-700">
|
||||
{{ scannedPallet.palletCode || `#${scannedPallet.palletId}` }}
|
||||
</text>
|
||||
<text class="text-xs text-purple-600">{{ scannedPallet.flowState }}</text>
|
||||
</view>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
{{ scannedPallet.strainName || scannedPallet.strainCode || "未提供菌种" }} · 批次 {{ scannedPallet.batchNo || "—" }}
|
||||
</text>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
当前剩余:{{ scannedPallet.remainingPackages }} 包 · 现场 #{{ scannedPallet.siteId }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<text class="mb-3 block font-bold text-gray-800">使用信息</text>
|
||||
<view class="mb-3">
|
||||
<text class="mb-2 block text-xs text-gray-500">使用产线</text>
|
||||
<view v-if="lineLocked" class="rounded-xl bg-gray-100 px-3 py-3 text-sm text-gray-600">
|
||||
{{ selectedLineName }}(已由托盘绑定)
|
||||
</view>
|
||||
<picker
|
||||
v-else
|
||||
:range="lineOptions"
|
||||
range-key="name"
|
||||
:value="lineIndex"
|
||||
:disabled="operationPending || optionLoading || !scannedPallet"
|
||||
@change="handleLineChange"
|
||||
>
|
||||
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||
{{ selectedLineName || "请选择现场产线" }}
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view>
|
||||
<text class="mb-2 block text-xs text-gray-500">本次使用包数</text>
|
||||
<input
|
||||
v-model="form.usedPackages"
|
||||
class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700"
|
||||
type="number"
|
||||
placeholder="请输入正整数"
|
||||
:disabled="operationPending || !scannedPallet"
|
||||
@input="markPayloadChanged"
|
||||
/>
|
||||
<text v-if="scannedPallet" class="mt-1 block text-xs text-gray-400">
|
||||
不能超过当前剩余 {{ scannedPallet.remainingPackages }} 包,最终以服务端校验为准
|
||||
</text>
|
||||
</view>
|
||||
<text v-if="submitError" class="mt-3 block text-xs text-red-500">{{ submitError }}</text>
|
||||
<view v-if="usageResult" class="mt-3 rounded-xl bg-green-50 p-3">
|
||||
<text class="block text-sm font-medium text-green-700">使用记录已提交</text>
|
||||
<text class="mt-1 block text-xs text-gray-600">
|
||||
使用后剩余 {{ usageResult.remainingPackages }} 包 · 状态 {{ usageResult.flowState }}
|
||||
</text>
|
||||
</view>
|
||||
<button
|
||||
class="mt-4 w-full rounded-xl bg-purple-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
|
||||
:disabled="!canOperate || operationPending || scanLoading || optionLoading || !scannedPallet || !!usageResult"
|
||||
@click="submitUsage"
|
||||
>
|
||||
{{ submitLoading ? "提交中" : "确认使用" }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view 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 class="text-xs text-blue-500" size="mini" :disabled="loading" @click="loadUsageList">
|
||||
刷新
|
||||
</button>
|
||||
</view>
|
||||
<text v-if="listError" class="mb-3 block text-xs text-red-500">{{ listError }}</text>
|
||||
<view v-if="loading" class="py-6 text-center text-xs text-gray-400">加载中...</view>
|
||||
<view v-else-if="!usageList.length" class="py-6 text-center text-xs text-gray-400">
|
||||
暂无使用记录
|
||||
</view>
|
||||
<view
|
||||
v-for="row in usageList"
|
||||
v-else
|
||||
:key="row.usageId"
|
||||
class="mb-3 rounded-xl border border-gray-100 p-3 last:mb-0"
|
||||
>
|
||||
<text class="block font-medium text-gray-700">{{ row.usageNo || `#${row.usageId}` }}</text>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
托盘 #{{ row.palletId }} · 产线 #{{ row.lineId }} · 使用 {{ row.usedPackages }} 包
|
||||
</text>
|
||||
<text class="mt-1 block text-xs text-gray-400">
|
||||
剩余 {{ row.remainingPackagesAfter }} 包 · {{ row.occurredAt || "—" }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, getCurrentInstance, reactive, ref } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import PalletScanInput from "@/components/business/PalletScanInput.vue";
|
||||
import TestBatchBanner from "@/components/business/TestBatchBanner.vue";
|
||||
import {
|
||||
addUsage,
|
||||
getUsageLineOptions,
|
||||
listUsage,
|
||||
scanUsagePallet,
|
||||
} from "@/api/business/usage";
|
||||
import { createIdempotencyKey } from "@/utils/palletScan";
|
||||
|
||||
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 listError = ref("");
|
||||
const operationError = ref("");
|
||||
const submitError = ref("");
|
||||
const idempotencyKey = ref("");
|
||||
const form = reactive({ qrToken: "", lineId: null, usedPackages: "" });
|
||||
let listGeneration = 0;
|
||||
let scanGeneration = 0;
|
||||
let optionGeneration = 0;
|
||||
let operationSession = 0;
|
||||
|
||||
const canPage = computed(() =>
|
||||
proxy.$auth.hasPermiOr([
|
||||
"business:usage:list",
|
||||
"business:usage:query",
|
||||
"business:usage:add",
|
||||
]),
|
||||
);
|
||||
const canList = computed(() => proxy.$auth.hasPermi("business:usage:list"));
|
||||
const canOperate = computed(() =>
|
||||
proxy.$auth.hasPermiAnd(["business:usage:query", "business:usage:add"]),
|
||||
);
|
||||
const operationPending = computed(() => submitLoading.value);
|
||||
const lineIndex = computed(() => {
|
||||
const index = lineOptions.value.findIndex((item) => item.id === form.lineId);
|
||||
return index >= 0 ? index : 0;
|
||||
});
|
||||
const selectedLineName = computed(
|
||||
() => lineOptions.value.find((item) => item.id === form.lineId)?.name || "",
|
||||
);
|
||||
|
||||
function errorMessage(error, fallback) {
|
||||
return error?.response?.data?.msg || error?.message || fallback;
|
||||
}
|
||||
|
||||
function loadUsageList() {
|
||||
if (!canList.value) return;
|
||||
const generation = ++listGeneration;
|
||||
loading.value = true;
|
||||
listError.value = "";
|
||||
listUsage({ pageNum: 1, pageSize: 20 })
|
||||
.then((response) => {
|
||||
if (generation !== listGeneration) return;
|
||||
usageList.value = Array.isArray(response.rows) ? response.rows : [];
|
||||
})
|
||||
.catch((error) => {
|
||||
if (generation !== listGeneration) return;
|
||||
usageList.value = [];
|
||||
listError.value = errorMessage(error, "使用记录加载失败,请稍后重试");
|
||||
})
|
||||
.finally(() => {
|
||||
if (generation === listGeneration) loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function resetUsageOperation() {
|
||||
operationSession += 1;
|
||||
scanGeneration += 1;
|
||||
optionGeneration += 1;
|
||||
scannedPallet.value = null;
|
||||
usageResult.value = null;
|
||||
lineOptions.value = [];
|
||||
lineLocked.value = false;
|
||||
idempotencyKey.value = "";
|
||||
form.qrToken = "";
|
||||
form.lineId = null;
|
||||
form.usedPackages = "";
|
||||
operationError.value = "";
|
||||
submitError.value = "";
|
||||
scanLoading.value = false;
|
||||
optionLoading.value = false;
|
||||
}
|
||||
|
||||
function loadLineOptions(siteId, session, scanRequestGeneration) {
|
||||
const generation = ++optionGeneration;
|
||||
optionLoading.value = true;
|
||||
getUsageLineOptions(siteId)
|
||||
.then((response) => {
|
||||
if (
|
||||
generation !== optionGeneration ||
|
||||
session !== operationSession ||
|
||||
scanRequestGeneration !== scanGeneration
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lineOptions.value = Array.isArray(response.data) ? response.data : [];
|
||||
if (!lineOptions.value.length) operationError.value = "该现场暂无可用产线";
|
||||
})
|
||||
.catch((error) => {
|
||||
if (generation !== optionGeneration || session !== operationSession) return;
|
||||
lineOptions.value = [];
|
||||
operationError.value = errorMessage(error, "产线选项加载失败,请稍后重新扫码");
|
||||
})
|
||||
.finally(() => {
|
||||
if (generation === optionGeneration && session === operationSession) {
|
||||
optionLoading.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleUsageScan(qrToken) {
|
||||
if (operationPending.value || scanLoading.value || optionLoading.value) return;
|
||||
resetUsageOperation();
|
||||
const session = operationSession;
|
||||
const generation = ++scanGeneration;
|
||||
scanLoading.value = true;
|
||||
scanUsagePallet(qrToken)
|
||||
.then((response) => {
|
||||
if (generation !== scanGeneration || session !== operationSession) 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,
|
||||
strainCode: pallet.strainCode,
|
||||
strainName: pallet.strainName,
|
||||
batchNo: pallet.batchNo,
|
||||
remainingPackages: pallet.remainingPackages,
|
||||
flowState: pallet.flowState,
|
||||
};
|
||||
form.qrToken = qrToken;
|
||||
if (pallet.currentLineId != null) {
|
||||
lineLocked.value = true;
|
||||
form.lineId = pallet.currentLineId;
|
||||
lineOptions.value = [
|
||||
{ id: pallet.currentLineId, code: `#${pallet.currentLineId}`, name: `产线 #${pallet.currentLineId}` },
|
||||
];
|
||||
} else {
|
||||
lineLocked.value = false;
|
||||
form.lineId = null;
|
||||
loadLineOptions(pallet.siteId, session, generation);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (generation === scanGeneration && session === operationSession) {
|
||||
operationError.value = errorMessage(error, "托盘扫码校验失败,请检查后重试");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (generation === scanGeneration && session === operationSession) {
|
||||
scanLoading.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleScanInvalid(message) {
|
||||
resetUsageOperation();
|
||||
operationError.value = message;
|
||||
}
|
||||
|
||||
function handleScanDuplicate() {
|
||||
proxy.$modal.msg("扫码过快,请勿重复扫描");
|
||||
}
|
||||
|
||||
function handleLineChange(event) {
|
||||
if (lineLocked.value) return;
|
||||
const option = lineOptions.value[Number(event.detail.value)];
|
||||
form.lineId = option?.id || null;
|
||||
markPayloadChanged();
|
||||
}
|
||||
|
||||
function markPayloadChanged() {
|
||||
if (!submitLoading.value) idempotencyKey.value = "";
|
||||
}
|
||||
|
||||
function ensureIdempotencyKey() {
|
||||
if (!idempotencyKey.value) idempotencyKey.value = createIdempotencyKey();
|
||||
return idempotencyKey.value;
|
||||
}
|
||||
|
||||
function submitUsage() {
|
||||
if (!canOperate.value || operationPending.value || scanLoading.value || optionLoading.value) return;
|
||||
const usedPackages = Number(form.usedPackages);
|
||||
if (!scannedPallet.value || !form.qrToken) {
|
||||
submitError.value = "请先扫描待使用托盘";
|
||||
return;
|
||||
}
|
||||
if (!form.lineId) {
|
||||
submitError.value = "请选择使用产线";
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(usedPackages) || usedPackages <= 0) {
|
||||
submitError.value = "使用包数必须为正整数";
|
||||
return;
|
||||
}
|
||||
if (usedPackages > scannedPallet.value.remainingPackages) {
|
||||
submitError.value = "使用包数不能超过当前剩余包数";
|
||||
return;
|
||||
}
|
||||
submitLoading.value = true;
|
||||
submitError.value = "";
|
||||
addUsage({
|
||||
qrToken: form.qrToken,
|
||||
lineId: form.lineId,
|
||||
usedPackages,
|
||||
idempotencyKey: ensureIdempotencyKey(),
|
||||
})
|
||||
.then((response) => {
|
||||
usageResult.value = response.data;
|
||||
idempotencyKey.value = "";
|
||||
proxy.$modal.msgSuccess(response.msg || "现场使用记录成功");
|
||||
loadUsageList();
|
||||
})
|
||||
.catch((error) => {
|
||||
submitError.value = `${errorMessage(error, "现场使用提交失败")};直接重试将复用本次幂等键,修改输入后会生成新键`;
|
||||
})
|
||||
.finally(() => {
|
||||
submitLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
loadUsageList();
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user