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

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
@@ -5,7 +5,7 @@
<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 />
@@ -15,6 +15,25 @@
</view>
<template v-else>
<view v-if="pendingOperations.length || recoveryError || recoveryMessage" class="rounded-2xl bg-amber-50 p-4 shadow-sm">
<text class="block font-bold text-gray-800">提交结果核对</text>
<text v-if="pendingOperations.length" class="mt-2 block text-xs text-gray-600">
以下操作结果尚未确认。恢复重试会核对原操作,请先确认结果再录入下一次使用。
</text>
<view v-for="item in pendingOperations" :key="item.payload.idempotencyKey" class="mt-3">
<text class="block text-sm text-gray-700">
{{ item.palletCode }} · {{ item.tunnelName }} · {{ item.action === "usage" ? `使用 ${item.payload.usedPackages} 包` : "托盘入隧" }}
</text>
<button size="mini" class="mt-2 text-blue-600" :disabled="!canOperate || operationPending" @click="retryPendingOperation(item)">
恢复重试 / 核对
</button>
<button size="mini" class="mt-2 text-gray-600" :disabled="operationPending" @click="releasePendingOperation(item)">
已人工核对,解除待确认
</button>
</view>
<text v-if="recoveryError" class="mt-2 block text-xs text-red-500">{{ recoveryError }}</text>
<text v-if="recoveryMessage" class="mt-2 block text-xs text-gray-700">{{ recoveryMessage }}</text>
</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>
@@ -44,31 +63,81 @@
<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.productionDate || "未登记" }} · 有效期至:{{ scannedPallet.expiryDate || "未登记" }}
</text>
<text class="mt-1 block text-xs text-gray-500">
当前剩余:{{ scannedPallet.remainingPackages }} 包 · 现场 #{{ scannedPallet.siteId }}
</text>
</view>
<view v-if="scannedPallet" class="mt-3 rounded-xl bg-blue-50 p-3">
<view class="flex items-center justify-between">
<text class="text-sm text-gray-700">先到先用建议</text>
<button size="mini" class="text-xs text-blue-600" :disabled="fifoLoading || operationPending" @click="loadFifoRecommendation">
刷新建议
</button>
</view>
<text class="mt-2 block text-xs text-gray-600">{{ fifoLoading ? "建议加载中,提交时由服务端复核" : fifoMessage }}</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 v-if="tunnelLocked" class="rounded-xl bg-gray-100 px-3 py-3 text-sm text-gray-600">
{{ selectedTunnelName }}(已入隧,不可切换)
</view>
<picker
v-else
:range="tunnelPickerOptions"
range-key="label"
:value="tunnelIndex"
:disabled="!canOperate || operationPending || optionLoading || !scannedPallet"
@change="handleTunnelChange"
>
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
{{ selectedTunnelName || "请选择使用隧道" }}
</view>
</picker>
<text v-if="scannedPallet && !tunnelLocked" class="mt-2 block text-xs text-gray-500">
先确认入隧,再提交使用包数。
</text>
<view class="mt-3">
<text class="mb-2 block text-xs text-gray-500">跳过先到托盘原因({{ fifoRequiresSkipReason ? "必填" : "可选" }})</text>
<textarea v-model="form.fifoSkipReason" class="w-full rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700"
:maxlength="500" placeholder="跳过先到托盘时请填写原因,最多500字"
:disabled="operationPending || !scannedPallet" @input="markPayloadChanged" />
<text class="mt-1 block text-xs text-gray-400">最多500字;提交时由服务端复核先到先用要求。</text>
</view>
<button
v-if="!tunnelLocked"
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 || !form.tunnelId"
@click="enterTunnel"
>
{{ entryLoading ? "入隧中" : "确认入隧" }}
</button>
</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>
<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="linePickerOptions"
range-key="name"
:value="lineIndex"
:disabled="operationPending || optionLoading || !scannedPallet"
:disabled="operationPending || lineOptionLoading || !scannedPallet"
@change="handleLineChange"
>
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
{{ selectedLineName || "请选择现场产线" }}
{{ selectedLineName || "不选择产线" }}
</view>
</picker>
<text v-if="lineOptionError" class="mt-2 block text-xs text-amber-600">{{ lineOptionError }}</text>
</view>
<view>
<text class="mb-2 block text-xs text-gray-500">本次使用包数</text>
@@ -93,7 +162,7 @@
</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"
:disabled="!canOperate || operationPending || scanLoading || optionLoading || !scannedPallet || !tunnelLocked || !form.tunnelId || !!usageResult"
@click="submitUsage"
>
{{ submitLoading ? "提交中" : "确认使用" }}
@@ -120,11 +189,15 @@
>
<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 }} 包
托盘 #{{ row.palletId }} · {{ row.tunnelName || row.tunnelCode || (row.tunnelId ? `隧道 #${row.tunnelId}` : "未登记隧道") }} · 使用 {{ row.usedPackages }} 包
</text>
<text v-if="row.lineId" class="mt-1 block text-xs text-gray-500">
产线 #{{ row.lineId }}
</text>
<text class="mt-1 block text-xs text-gray-400">
剩余 {{ row.remainingPackagesAfter }} 包 · {{ row.occurredAt || "—" }}
</text>
<text class="mt-1 block text-xs text-gray-500">跳过先到托盘原因:{{ row.fifoSkipReason || "—" }}</text>
</view>
</view>
</template>
@@ -135,35 +208,54 @@
<script setup>
import { computed, getCurrentInstance, reactive, ref } from "vue";
import { onShow } from "@dcloudio/uni-app";
import { onHide, onShow, onUnload } from "@dcloudio/uni-app";
import PalletScanInput from "@/components/business/PalletScanInput.vue";
import TestBatchBanner from "@/components/business/TestBatchBanner.vue";
import {
addUsage,
enterUsageTunnel,
getUsageFifoRecommendation,
getUsageLineOptions,
getUsageTunnelOptions,
listUsage,
scanUsagePallet,
} from "@/api/business/usage";
import { createIdempotencyKey } from "@/utils/palletScan";
import config from "@/config";
import constant from "@/utils/constant";
import storage from "@/utils/storage";
const { proxy } = getCurrentInstance();
const usageList = ref([]);
const lineOptions = ref([]);
const tunnelOptions = ref([]);
const scannedPallet = ref(null);
const usageResult = ref(null);
const lineLocked = ref(false);
const tunnelLocked = ref(false);
const loading = ref(false);
const optionLoading = ref(false);
const lineOptionLoading = ref(false);
const lineOptionError = ref("");
const fifoLoading = ref(false);
const fifoMessage = ref("");
const fifoRequiresSkipReason = ref(false);
const scanLoading = ref(false);
const submitLoading = ref(false);
const entryLoading = ref(false);
const listError = ref("");
const operationError = ref("");
const submitError = ref("");
const idempotencyKey = ref("");
const form = reactive({ qrToken: "", lineId: null, usedPackages: "" });
const pendingOperations = ref([]);
const recoveryStorageKey = ref("");
const recoveryLoading = ref(false);
const recoveryError = ref("");
const recoveryMessage = ref("");
const form = reactive({ qrToken: "", tunnelId: null, lineId: null, usedPackages: "", fifoSkipReason: "" });
let listGeneration = 0;
let scanGeneration = 0;
let optionGeneration = 0;
let fifoGeneration = 0;
let operationSession = 0;
const canPage = computed(() =>
@@ -177,19 +269,225 @@ 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 operationPending = computed(() => submitLoading.value || entryLoading.value || recoveryLoading.value);
const linePickerOptions = computed(() => [
{ id: null, name: "不选择产线" },
...lineOptions.value,
]);
const lineIndex = computed(() => {
const index = lineOptions.value.findIndex((item) => item.id === form.lineId);
const index = linePickerOptions.value.findIndex((item) => item.id === form.lineId);
return index >= 0 ? index : 0;
});
const selectedLineName = computed(
() => lineOptions.value.find((item) => item.id === form.lineId)?.name || "",
);
const tunnelPickerOptions = computed(() => tunnelOptions.value.map((item) => ({
...item,
label: formatTunnelName(item),
})));
const tunnelIndex = computed(() => {
const index = tunnelOptions.value.findIndex((item) => item.id === form.tunnelId);
return index >= 0 ? index : 0;
});
const selectedTunnelName = computed(() => {
const option = tunnelOptions.value.find((item) => item.id === form.tunnelId);
if (option) return formatTunnelName(option);
if (!form.tunnelId) return "";
return formatTunnelName({
id: form.tunnelId,
name: scannedPallet.value?.tunnelName,
code: scannedPallet.value?.tunnelCode,
});
});
function formatTunnelName(option) {
return [option.name, option.code].filter(Boolean).join(" · ") || `隧道 #${option.id}`;
}
function errorMessage(error, fallback) {
return error?.response?.data?.msg || error?.message || fallback;
}
function currentRecoveryStorageKey() {
const userId = storage.get(constant.id);
if (!userId) throw new Error("账号信息未就绪,无法保存操作恢复记录");
return `business-usage-pending-v1:${JSON.stringify([config.baseUrl, userId, storage.get(constant.testBatchId) || "formal"])}`;
}
function loadPendingOperations() {
try {
const key = currentRecoveryStorageKey();
const records = uni.getStorageSync(key) || [];
if (!Array.isArray(records) || records.some((item) =>
!["entry", "usage"].includes(item?.action) || !item.payload?.qrToken || !item.payload?.idempotencyKey
)) throw new Error("本机操作恢复记录异常,请先联系管理员核对");
recoveryStorageKey.value = key;
pendingOperations.value = records;
recoveryError.value = "";
return true;
} catch (error) {
pendingOperations.value = [];
recoveryError.value = errorMessage(error, "本机操作恢复记录读取失败,请先核对");
return false;
}
}
function savePendingOperations(records) {
// 先持久化,再发送命令;页面隐藏、销毁或响应丢失都不能丢掉重试身份。
uni.setStorageSync(recoveryStorageKey.value, records);
pendingOperations.value = records;
}
function pendingForPallet() {
return pendingOperations.value.find((item) => item.palletId === scannedPallet.value?.palletId || item.payload.qrToken === form.qrToken);
}
function operationSignature(action, payload) {
return JSON.stringify([action, payload.qrToken, payload.tunnelId, payload.lineId ?? null, payload.usedPackages ?? null, payload.fifoSkipReason || ""]);
}
function prepareOperation(action, payload) {
if (!loadPendingOperations()) throw new Error(recoveryError.value);
const pending = pendingForPallet();
if (pending) {
if (operationSignature(pending.action, pending.payload) !== operationSignature(action, payload)) {
throw new Error("该托盘有结果未确认的操作,请先恢复重试并核对原操作");
}
return pending;
}
const operation = {
action,
palletId: scannedPallet.value.palletId,
palletCode: scannedPallet.value.palletCode,
tunnelName: selectedTunnelName.value,
payload: { ...payload, idempotencyKey: createIdempotencyKey() },
};
savePendingOperations([...pendingOperations.value, operation]);
return operation;
}
function completeOperation(operation, response) {
const result = response?.data;
// 先核实完整业务结果,再解除原凭据;空响应和不完整响应仍属于结果未知。
const confirmed = result && (operation.action === "usage"
? Number.isInteger(result.usageId) && result.usageId > 0 &&
Number.isInteger(result.remainingPackages) && result.remainingPackages >= 0 &&
typeof result.flowState === "string" && result.flowState.trim()
: result.palletId === operation.palletId && result.tunnelId === operation.payload.tunnelId &&
typeof result.traceNo === "string" && result.traceNo.trim());
if (!confirmed) throw new Error("服务端确认结果不完整,原操作凭据已保留");
if (!loadPendingOperations()) throw new Error(recoveryError.value);
savePendingOperations(pendingOperations.value.filter((item) => item.payload.idempotencyKey !== operation.payload.idempotencyKey));
return result;
}
function isCurrentOperation(session, scopeKey) {
try {
return session === operationSession && scopeKey === recoveryStorageKey.value && scopeKey === currentRecoveryStorageKey();
} catch {
return false;
}
}
function validatedFifoSkipReason() {
const reason = form.fifoSkipReason.trim();
if (Array.from(reason).length > 500) throw new Error("跳过先到托盘原因最多500字");
if (fifoRequiresSkipReason.value && !reason) throw new Error("请填写跳过先到托盘原因");
return reason;
}
function handleFifoRejection(error, operation) {
// 与 request 的 getResponseBody 一致:兼容 uni 响应外层及直接业务 body。
const response = error?.response;
const body = response?.data && typeof response.data === "object" ? response.data : response;
if (!operation || body?.code !== 500 || body.data !== "FIFO_SKIP_REASON_REQUIRED") return false;
try {
if (!loadPendingOperations()) return true;
const current = pendingOperations.value.find(item => item.payload.idempotencyKey === operation.payload.idempotencyKey);
if (!current || JSON.stringify(current) !== JSON.stringify(operation)) {
recoveryError.value = "本机待确认记录已变化,请重新核对后操作";
return true;
}
// 仅此明确业务错误保证事务回滚,可解除对应原凭据并允许修改原因。
savePendingOperations(pendingOperations.value.filter(item => item.payload.idempotencyKey !== operation.payload.idempotencyKey));
} catch (storageError) {
recoveryError.value = `${errorMessage(storageError, "本机待确认记录清理失败")};原凭据已保留,请恢复重试`;
return true;
}
if (scannedPallet.value?.palletId === operation.palletId && form.qrToken === operation.payload.qrToken) {
fifoRequiresSkipReason.value = true;
loadFifoRecommendation();
} else {
recoveryLoading.value = false;
handleUsageScan(operation.payload.qrToken, operation);
}
operationError.value = "请补充跳过先到托盘原因后重新提交,本次操作未生效";
submitError.value = operationError.value;
recoveryMessage.value = operationError.value;
return true;
}
async function releasePendingOperation(item) {
if (operationPending.value) return;
const session = operationSession;
const scopeKey = recoveryStorageKey.value;
if (!loadPendingOperations() || recoveryStorageKey.value !== scopeKey) return;
const operation = pendingOperations.value.find((record) => record.payload.idempotencyKey === item.payload.idempotencyKey);
if (!operation) return;
recoveryLoading.value = true;
try {
const confirmed = await new Promise((resolve, reject) => {
uni.showModal({
title: "解除本机待确认记录",
content: `请先核对使用记录和当前库存,确认 ${operation.palletCode} 的原操作结果。此操作仅移除对应本机待确认记录,不撤销或确认服务端业务。若原操作已成功,再次提交可能重复扣减库存。确认已人工核对并解除?`,
cancelText: "取消",
confirmText: "确认解除",
success: (result) => resolve(Boolean(result.confirm)),
fail: reject,
});
});
// 弹窗期间可能换账号、批次或离页;不能把旧确认应用到新的范围。
if (!confirmed || !isCurrentOperation(session, scopeKey) || currentRecoveryStorageKey() !== scopeKey) return;
if (!loadPendingOperations()) return;
savePendingOperations(pendingOperations.value.filter((record) => record.payload.idempotencyKey !== operation.payload.idempotencyKey));
if (scannedPallet.value?.palletId === operation.palletId || form.qrToken === operation.payload.qrToken) {
resetUsageOperation();
}
recoveryMessage.value = `${operation.palletCode} 仅已解除本机待确认记录,不代表原操作成功。请依据人工核对结果重新扫码后继续。`;
} catch (error) {
if (!isCurrentOperation(session, scopeKey)) return;
recoveryError.value = errorMessage(error, "解除本机待确认失败,原记录仍保留");
} finally {
if (isCurrentOperation(session, scopeKey)) recoveryLoading.value = false;
}
}
async function retryPendingOperation(item) {
if (!canOperate.value || operationPending.value || !loadPendingOperations()) return;
const operation = pendingOperations.value.find((record) => record.payload.idempotencyKey === item.payload.idempotencyKey);
if (!operation) return;
resetUsageOperation();
const session = operationSession;
const scopeKey = recoveryStorageKey.value;
recoveryLoading.value = true;
recoveryMessage.value = "";
try {
const response = await (operation.action === "usage" ? addUsage : enterUsageTunnel)({ ...operation.payload });
if (!isCurrentOperation(session, scopeKey)) return;
const result = completeOperation(operation, response);
recoveryMessage.value = operation.action === "usage"
? `${operation.palletCode} 原使用已确认,剩余 ${result.remainingPackages} 包。请重新扫码后继续。`
: `${operation.palletCode} 原入隧已确认。请重新扫码后继续。`;
loadUsageList();
} catch (error) {
if (!isCurrentOperation(session, scopeKey)) return;
if (handleFifoRejection(error, operation)) return;
recoveryError.value = `${errorMessage(error, "原操作核对失败")};原操作已保留,请重试或联系管理员核对`;
} finally {
if (isCurrentOperation(session, scopeKey)) recoveryLoading.value = false;
}
}
function loadUsageList() {
if (!canList.value) return;
const generation = ++listGeneration;
@@ -214,56 +512,104 @@ function resetUsageOperation() {
operationSession += 1;
scanGeneration += 1;
optionGeneration += 1;
fifoGeneration += 1;
scannedPallet.value = null;
usageResult.value = null;
lineOptions.value = [];
tunnelOptions.value = [];
lineLocked.value = false;
idempotencyKey.value = "";
tunnelLocked.value = false;
form.qrToken = "";
form.tunnelId = null;
form.lineId = null;
form.usedPackages = "";
form.fifoSkipReason = "";
operationError.value = "";
submitError.value = "";
scanLoading.value = false;
optionLoading.value = false;
lineOptionLoading.value = false;
lineOptionError.value = "";
fifoLoading.value = false;
fifoMessage.value = "";
fifoRequiresSkipReason.value = false;
submitLoading.value = false;
entryLoading.value = false;
recoveryLoading.value = false;
}
function loadLineOptions(siteId, session, scanRequestGeneration) {
function loadUsageOptions(pallet, session, scanRequestGeneration) {
const generation = ++optionGeneration;
const isCurrent = () => generation === optionGeneration &&
session === operationSession && scanRequestGeneration === scanGeneration;
optionLoading.value = true;
getUsageLineOptions(siteId)
.then((response) => {
if (
generation !== optionGeneration ||
session !== operationSession ||
scanRequestGeneration !== scanGeneration
) {
return;
lineOptionLoading.value = true;
const lineRequest = pallet.currentLineId == null
? getUsageLineOptions(pallet.siteId)
: Promise.resolve({ data: lineOptions.value });
getUsageTunnelOptions(pallet.siteId)
.then((tunnels) => {
if (!isCurrent()) return;
tunnelOptions.value = Array.isArray(tunnels.data) ? tunnels.data : [];
if (!tunnelLocked.value && !tunnelOptions.value.length) {
operationError.value = "该现场暂无可用隧道";
}
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, "产线选项加载失败,请稍后重新扫码");
if (!isCurrent()) return;
operationError.value = errorMessage(error, "隧道选项加载失败,请稍后重新扫码");
})
.finally(() => {
if (generation === optionGeneration && session === operationSession) {
optionLoading.value = false;
}
if (isCurrent()) optionLoading.value = false;
});
lineRequest
.then((lines) => {
if (isCurrent()) lineOptions.value = Array.isArray(lines.data) ? lines.data : [];
})
.catch((error) => {
if (!isCurrent()) return;
lineOptions.value = [];
lineOptionError.value = `${errorMessage(error, "产线选项加载失败")};可不选产线继续操作`;
})
.finally(() => {
if (isCurrent()) lineOptionLoading.value = false;
});
}
function handleUsageScan(qrToken) {
async function loadFifoRecommendation() {
if (!scannedPallet.value || !form.qrToken) return;
const generation = ++fifoGeneration;
const session = operationSession;
const scopeKey = recoveryStorageKey.value;
const { qrToken, tunnelId } = form;
const isCurrent = () => generation === fifoGeneration && isCurrentOperation(session, scopeKey) &&
qrToken === form.qrToken && tunnelId === form.tunnelId;
fifoLoading.value = true;
fifoMessage.value = "";
try {
const response = await getUsageFifoRecommendation(qrToken, tunnelId);
if (!isCurrent()) return;
fifoMessage.value = response.data?.message || "当前暂无先到先用建议";
fifoRequiresSkipReason.value = response.data?.requiresSkipReason === true;
} catch {
if (!isCurrent()) return;
fifoMessage.value = "先到先用建议暂不可用,请人工核对;提交时由服务端复核,跳过先到托盘可能需要填写原因";
} finally {
if (isCurrent()) fifoLoading.value = false;
}
}
function handleUsageScan(qrToken, rejectedOperation = null) {
if (operationPending.value || scanLoading.value || optionLoading.value) return;
resetUsageOperation();
if (!loadPendingOperations()) return;
const session = operationSession;
const scopeKey = recoveryStorageKey.value;
const generation = ++scanGeneration;
scanLoading.value = true;
scanUsagePallet(qrToken)
.then((response) => {
if (generation !== scanGeneration || session !== operationSession) return;
if (generation !== scanGeneration || !isCurrentOperation(session, scopeKey)) return;
const pallet = response.data;
if (
!["AT_SITE_UNUSED", "AT_SITE_PARTIAL"].includes(pallet?.flowState) ||
@@ -277,13 +623,26 @@ function handleUsageScan(qrToken) {
palletCode: pallet.palletCode || `#${pallet.palletId}`,
siteId: pallet.siteId,
currentLineId: pallet.currentLineId,
tunnelId: pallet.tunnelId,
tunnelName: pallet.tunnelName,
tunnelCode: pallet.tunnelCode,
strainCode: pallet.strainCode,
strainName: pallet.strainName,
batchNo: pallet.batchNo,
productionDate: pallet.productionDate,
expiryDate: pallet.expiryDate,
remainingPackages: pallet.remainingPackages,
flowState: pallet.flowState,
};
form.qrToken = qrToken;
form.qrToken = pallet.qrToken || qrToken;
form.tunnelId = pallet.tunnelId ?? null;
tunnelLocked.value = pallet.tunnelId != null;
if (rejectedOperation) {
form.tunnelId = pallet.tunnelId ?? rejectedOperation.payload.tunnelId;
form.usedPackages = rejectedOperation.payload.usedPackages ?? "";
form.fifoSkipReason = rejectedOperation.payload.fifoSkipReason || "";
fifoRequiresSkipReason.value = true;
}
if (pallet.currentLineId != null) {
lineLocked.value = true;
form.lineId = pallet.currentLineId;
@@ -292,17 +651,18 @@ function handleUsageScan(qrToken) {
];
} else {
lineLocked.value = false;
form.lineId = null;
loadLineOptions(pallet.siteId, session, generation);
form.lineId = rejectedOperation?.payload.lineId ?? null;
}
loadUsageOptions(pallet, session, generation);
loadFifoRecommendation();
})
.catch((error) => {
if (generation === scanGeneration && session === operationSession) {
if (generation === scanGeneration && isCurrentOperation(session, scopeKey)) {
operationError.value = errorMessage(error, "托盘扫码校验失败,请检查后重试");
}
})
.finally(() => {
if (generation === scanGeneration && session === operationSession) {
if (generation === scanGeneration && isCurrentOperation(session, scopeKey)) {
scanLoading.value = false;
}
});
@@ -318,63 +678,143 @@ function handleScanDuplicate() {
}
function handleLineChange(event) {
if (lineLocked.value) return;
const option = lineOptions.value[Number(event.detail.value)];
form.lineId = option?.id || null;
if (lineLocked.value || operationPending.value) return;
const option = linePickerOptions.value[Number(event.detail.value)];
form.lineId = option?.id ?? null;
markPayloadChanged();
}
function handleTunnelChange(event) {
if (tunnelLocked.value || operationPending.value) return;
const option = tunnelOptions.value[Number(event.detail.value)];
if (form.tunnelId === (option?.id ?? null)) return;
form.tunnelId = option?.id ?? null;
markPayloadChanged();
fifoRequiresSkipReason.value = false;
loadFifoRecommendation();
}
function markPayloadChanged() {
if (!submitLoading.value) idempotencyKey.value = "";
if (operationPending.value) return;
submitError.value = "";
}
function ensureIdempotencyKey() {
if (!idempotencyKey.value) idempotencyKey.value = createIdempotencyKey();
return idempotencyKey.value;
async function enterTunnel() {
if (!canOperate.value || operationPending.value || scanLoading.value || optionLoading.value || tunnelLocked.value) return;
if (!scannedPallet.value || !form.qrToken || !form.tunnelId) {
operationError.value = "请先扫描托盘并选择隧道";
return;
}
let fifoSkipReason;
try {
fifoSkipReason = validatedFifoSkipReason();
} catch (error) {
operationError.value = error.message;
return;
}
const session = operationSession;
entryLoading.value = true;
operationError.value = "";
let scopeKey = recoveryStorageKey.value;
let operation;
try {
operation = prepareOperation("entry", {
qrToken: form.qrToken,
tunnelId: form.tunnelId,
...(fifoSkipReason ? { fifoSkipReason } : {}),
});
scopeKey = recoveryStorageKey.value;
const response = await enterUsageTunnel({ ...operation.payload });
if (!isCurrentOperation(session, scopeKey)) return;
const result = completeOperation(operation, response);
form.tunnelId = result.tunnelId;
scannedPallet.value.tunnelId = result.tunnelId;
tunnelLocked.value = true;
submitError.value = "";
proxy.$modal.msgSuccess(response.msg || "托盘入隧成功");
loadFifoRecommendation();
} catch (error) {
if (!isCurrentOperation(session, scopeKey)) return;
if (handleFifoRejection(error, operation)) return;
operationError.value = `${errorMessage(error, "托盘入隧失败")};结果未确认时请恢复重试核对原操作`;
} finally {
if (isCurrentOperation(session, scopeKey)) entryLoading.value = false;
}
}
function submitUsage() {
if (!canOperate.value || operationPending.value || scanLoading.value || optionLoading.value) return;
async function submitUsage() {
if (!canOperate.value || operationPending.value || scanLoading.value || optionLoading.value || usageResult.value) return;
const usedPackages = Number(form.usedPackages);
if (!scannedPallet.value || !form.qrToken) {
submitError.value = "请先扫描待使用托盘";
return;
}
if (!form.lineId) {
submitError.value = "请选择使用产线";
if (!tunnelLocked.value || !form.tunnelId) {
submitError.value = "请先选择隧道并确认入隧";
return;
}
if (!Number.isInteger(usedPackages) || usedPackages <= 0) {
submitError.value = "使用包数必须为正整数";
return;
}
if (usedPackages > scannedPallet.value.remainingPackages) {
let fifoSkipReason;
try {
fifoSkipReason = validatedFifoSkipReason();
} catch (error) {
submitError.value = error.message;
return;
}
if (!loadPendingOperations()) {
submitError.value = recoveryError.value;
return;
}
// 服务端可能已扣减;已有原命令的重试由服务端幂等结果确认,不能用新余量拒绝。
if (usedPackages > scannedPallet.value.remainingPackages && !pendingForPallet()) {
submitError.value = "使用包数不能超过当前剩余包数";
return;
}
const session = operationSession;
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;
const scopeKey = recoveryStorageKey.value;
let operation;
try {
operation = prepareOperation("usage", {
qrToken: form.qrToken,
tunnelId: form.tunnelId,
lineId: form.lineId,
usedPackages,
...(fifoSkipReason ? { fifoSkipReason } : {}),
});
const response = await addUsage({ ...operation.payload });
if (!isCurrentOperation(session, scopeKey)) return;
usageResult.value = completeOperation(operation, response);
proxy.$modal.msgSuccess(response.msg || "现场使用记录成功");
loadUsageList();
} catch (error) {
if (!isCurrentOperation(session, scopeKey)) return;
if (handleFifoRejection(error, operation)) return;
submitError.value = `${errorMessage(error, "现场使用提交失败")};结果未确认时请恢复重试核对原操作`;
} finally {
if (isCurrentOperation(session, scopeKey)) submitLoading.value = false;
}
}
function leaveUsagePage() {
// uni-app 页面可能缓存;离开时使所有旧请求失效,不能回写下一次操作。
resetUsageOperation();
listGeneration += 1;
loading.value = false;
usageList.value = [];
listError.value = "";
recoveryMessage.value = "";
}
onHide(leaveUsagePage);
onUnload(leaveUsagePage);
onShow(() => {
loadPendingOperations();
loadUsageList();
});
loadPendingOperations();
</script>