移动端:增加测试批次模式隔离
This commit is contained in:
@@ -0,0 +1,486 @@
|
||||
<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 class="flex items-center justify-between">
|
||||
<view>
|
||||
<text class="block text-xl font-bold text-gray-800">仓库入库</text>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
扫描尚未首次入库的空托盘,保存草稿后确认
|
||||
</text>
|
||||
</view>
|
||||
<button
|
||||
v-if="canDraft"
|
||||
class="rounded-lg bg-blue-500 px-3 py-2 text-xs text-white active:opacity-80"
|
||||
size="mini"
|
||||
:disabled="operationPending"
|
||||
@click="startNewDraft"
|
||||
>
|
||||
新建草稿
|
||||
</button>
|
||||
</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 v-if="editorVisible" class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<view class="mb-4 flex items-center justify-between">
|
||||
<text class="font-bold text-gray-800">
|
||||
{{ editing ? "编辑入库草稿" : "新建入库草稿" }}
|
||||
</text>
|
||||
<text class="text-xs text-gray-400">托盘数量 {{ scannedPallets.length }}</text>
|
||||
</view>
|
||||
|
||||
<view class="mb-3">
|
||||
<text class="mb-2 block text-xs text-gray-500">入库仓库</text>
|
||||
<picker
|
||||
:range="warehouseOptions"
|
||||
range-key="name"
|
||||
:value="warehouseIndex"
|
||||
:disabled="optionLoading || detailLoading || operationPending"
|
||||
@change="handleWarehouseChange"
|
||||
>
|
||||
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||
{{ selectedWarehouseName || "请选择授权仓库" }}
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="mb-3">
|
||||
<text class="mb-2 block text-xs text-gray-500">连续扫码</text>
|
||||
<PalletScanInput
|
||||
:disabled="!form.warehouseId || detailLoading || operationPending"
|
||||
:loading="scanLoading"
|
||||
@scan="handleInboundScan"
|
||||
@invalid="handleScanInvalid"
|
||||
@duplicate="handleScanDuplicate"
|
||||
/>
|
||||
<text class="mt-2 block text-xs text-gray-400">
|
||||
仅接受 UNSTOCKED 托盘;同一托盘不会重复加入。
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<text v-if="scanError" class="mb-3 block text-xs text-red-500">{{ scanError }}</text>
|
||||
<text v-if="formError" class="mb-3 block text-xs text-red-500">{{ formError }}</text>
|
||||
|
||||
<view v-if="scannedPallets.length" class="mb-4 space-y-2">
|
||||
<view
|
||||
v-for="pallet in scannedPallets"
|
||||
:key="pallet.palletId"
|
||||
class="flex items-center justify-between rounded-xl bg-blue-50 px-3 py-2"
|
||||
>
|
||||
<view>
|
||||
<text class="block text-sm font-medium text-gray-700">
|
||||
{{ pallet.palletCode || `#${pallet.palletId}` }}
|
||||
</text>
|
||||
<text class="block text-xs text-gray-500">{{ pallet.flowState }}</text>
|
||||
</view>
|
||||
<button
|
||||
class="text-xs text-red-500"
|
||||
size="mini"
|
||||
:disabled="operationPending || detailLoading"
|
||||
@click="removeScannedPallet(pallet.palletId)"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="flex gap-3">
|
||||
<button
|
||||
class="flex-1 rounded-xl bg-blue-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
|
||||
:disabled="!canSaveDraft || operationPending || scanLoading || detailLoading"
|
||||
@click="saveDraft"
|
||||
>
|
||||
{{ submitLoading ? "保存中" : editing ? "保存修改" : "保存草稿" }}
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 rounded-xl bg-gray-100 py-3 text-sm text-gray-600 active:opacity-80"
|
||||
:disabled="operationPending"
|
||||
@click="cancelEditor"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</view>
|
||||
</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 || operationPending"
|
||||
@click="loadInboundList"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</view>
|
||||
<text v-if="listError" class="mb-3 block text-xs text-red-500">{{ listError }}</text>
|
||||
<text v-if="confirmError" class="mb-3 block text-xs text-red-500">{{ confirmError }}</text>
|
||||
<view v-if="loading" class="py-6 text-center text-xs text-gray-400">加载中...</view>
|
||||
<view v-else-if="!inboundList.length" class="py-6 text-center text-xs text-gray-400">
|
||||
暂无入库单
|
||||
</view>
|
||||
<view
|
||||
v-for="row in inboundList"
|
||||
v-else
|
||||
:key="row.inboundId"
|
||||
class="mb-3 rounded-xl border border-gray-100 p-3 last:mb-0"
|
||||
>
|
||||
<view class="flex items-center justify-between">
|
||||
<text class="font-medium text-gray-700">{{ row.inboundNo || `#${row.inboundId}` }}</text>
|
||||
<text :class="row.status === 'DRAFT' ? 'text-orange-500' : 'text-green-500'" class="text-xs">
|
||||
{{ row.status === "DRAFT" ? "草稿" : "已确认" }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
仓库:{{ warehouseName(row.warehouseId) }} · 托盘:{{ row.items?.length || 0 }} 个
|
||||
</text>
|
||||
<view v-if="row.status === 'DRAFT'" class="mt-3 flex gap-2">
|
||||
<button
|
||||
v-if="canEdit"
|
||||
class="flex-1 rounded-lg bg-blue-50 py-2 text-xs text-blue-600"
|
||||
size="mini"
|
||||
:disabled="operationPending"
|
||||
@click="startEditDraft(row)"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
v-if="canConfirm"
|
||||
class="flex-1 rounded-lg bg-green-50 py-2 text-xs text-green-600"
|
||||
size="mini"
|
||||
:disabled="operationPending"
|
||||
@click="confirmDraft(row)"
|
||||
>
|
||||
{{ confirmLoading ? "确认中" : "确认" }}
|
||||
</button>
|
||||
</view>
|
||||
</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 {
|
||||
addInbound,
|
||||
confirmInbound,
|
||||
getInbound,
|
||||
getInboundWarehouseOptions,
|
||||
listInbound,
|
||||
scanInboundPallet,
|
||||
updateInbound,
|
||||
} from "@/api/business/inbound";
|
||||
import { createIdempotencyKey } from "@/utils/palletScan";
|
||||
|
||||
const { proxy } = getCurrentInstance();
|
||||
const inboundList = ref([]);
|
||||
const warehouseOptions = ref([]);
|
||||
const scannedPallets = ref([]);
|
||||
const editorVisible = ref(false);
|
||||
const editing = ref(false);
|
||||
const loading = ref(false);
|
||||
const optionLoading = ref(false);
|
||||
const detailLoading = ref(false);
|
||||
const scanLoading = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const confirmLoading = ref(false);
|
||||
const listError = ref("");
|
||||
const formError = ref("");
|
||||
const scanError = ref("");
|
||||
const confirmError = ref("");
|
||||
const form = reactive({ inboundId: null, warehouseId: null });
|
||||
const confirmState = reactive({ inboundId: null, key: "" });
|
||||
let listGeneration = 0;
|
||||
let scanGeneration = 0;
|
||||
|
||||
const canPage = computed(() =>
|
||||
proxy.$auth.hasPermiOr([
|
||||
"business:inbound:list",
|
||||
"business:inbound:query",
|
||||
"business:inbound:add",
|
||||
"business:inbound:edit",
|
||||
"business:inbound:confirm",
|
||||
]),
|
||||
);
|
||||
const canList = computed(() => proxy.$auth.hasPermi("business:inbound:list"));
|
||||
const canDraft = computed(() =>
|
||||
proxy.$auth.hasPermiAnd(["business:inbound:query", "business:inbound:add"]),
|
||||
);
|
||||
const canEdit = computed(() =>
|
||||
proxy.$auth.hasPermiAnd(["business:inbound:query", "business:inbound:edit"]),
|
||||
);
|
||||
const canConfirm = computed(() =>
|
||||
proxy.$auth.hasPermi("business:inbound:confirm"),
|
||||
);
|
||||
const canSaveDraft = computed(() => (editing.value ? canEdit.value : canDraft.value));
|
||||
const operationPending = computed(
|
||||
() =>
|
||||
submitLoading.value ||
|
||||
confirmLoading.value ||
|
||||
detailLoading.value,
|
||||
);
|
||||
const warehouseIndex = computed(() => {
|
||||
const index = warehouseOptions.value.findIndex(
|
||||
(item) => item.id === form.warehouseId,
|
||||
);
|
||||
return index >= 0 ? index : 0;
|
||||
});
|
||||
const selectedWarehouseName = computed(
|
||||
() => warehouseOptions.value.find((item) => item.id === form.warehouseId)?.name || "",
|
||||
);
|
||||
|
||||
function errorMessage(error, fallback) {
|
||||
return error?.response?.data?.msg || error?.message || fallback;
|
||||
}
|
||||
|
||||
function warehouseName(id) {
|
||||
return warehouseOptions.value.find((item) => item.id === id)?.name || `#${id || "-"}`;
|
||||
}
|
||||
|
||||
function resetEditor() {
|
||||
scanGeneration += 1;
|
||||
editorVisible.value = false;
|
||||
editing.value = false;
|
||||
form.inboundId = null;
|
||||
form.warehouseId = null;
|
||||
scannedPallets.value = [];
|
||||
detailLoading.value = false;
|
||||
scanLoading.value = false;
|
||||
formError.value = "";
|
||||
scanError.value = "";
|
||||
}
|
||||
|
||||
function loadInboundList() {
|
||||
if (!canList.value) return;
|
||||
const generation = ++listGeneration;
|
||||
loading.value = true;
|
||||
listError.value = "";
|
||||
listInbound({ pageNum: 1, pageSize: 20 })
|
||||
.then((response) => {
|
||||
if (generation !== listGeneration) return;
|
||||
inboundList.value = Array.isArray(response.rows) ? response.rows : [];
|
||||
})
|
||||
.catch((error) => {
|
||||
if (generation !== listGeneration) return;
|
||||
inboundList.value = [];
|
||||
listError.value = errorMessage(error, "入库单列表加载失败,请稍后重试");
|
||||
})
|
||||
.finally(() => {
|
||||
if (generation === listGeneration) loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function loadWarehouseOptions() {
|
||||
if (!proxy.$auth.hasPermi("business:inbound:query")) return;
|
||||
optionLoading.value = true;
|
||||
getInboundWarehouseOptions()
|
||||
.then((response) => {
|
||||
warehouseOptions.value = Array.isArray(response.data) ? response.data : [];
|
||||
})
|
||||
.catch((error) => {
|
||||
warehouseOptions.value = [];
|
||||
formError.value = errorMessage(error, "仓库选项加载失败,请稍后重试");
|
||||
})
|
||||
.finally(() => {
|
||||
optionLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function startNewDraft() {
|
||||
if (!canDraft.value || operationPending.value) return;
|
||||
resetEditor();
|
||||
editorVisible.value = true;
|
||||
loadWarehouseOptions();
|
||||
}
|
||||
|
||||
function startEditDraft(row) {
|
||||
if (!canEdit.value || row.status !== "DRAFT" || operationPending.value) return;
|
||||
resetEditor();
|
||||
editorVisible.value = true;
|
||||
editing.value = true;
|
||||
form.inboundId = row.inboundId;
|
||||
loadWarehouseOptions();
|
||||
detailLoading.value = true;
|
||||
getInbound(row.inboundId)
|
||||
.then((response) => {
|
||||
const detail = response.data;
|
||||
if (!detail || detail.status !== "DRAFT") {
|
||||
formError.value = "该入库单已不是草稿状态,不能编辑";
|
||||
return;
|
||||
}
|
||||
form.warehouseId = detail.warehouseId;
|
||||
scannedPallets.value = (Array.isArray(detail.items) ? detail.items : []).map(
|
||||
(item) => ({
|
||||
palletId: item.palletId,
|
||||
palletCode: item.palletCode || `#${item.palletId}`,
|
||||
flowState: item.flowState || "UNSTOCKED",
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
formError.value = errorMessage(error, "入库单详情加载失败,请稍后重试");
|
||||
})
|
||||
.finally(() => {
|
||||
detailLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function cancelEditor() {
|
||||
if (operationPending.value) return;
|
||||
resetEditor();
|
||||
}
|
||||
|
||||
function handleWarehouseChange(event) {
|
||||
const option = warehouseOptions.value[Number(event.detail.value)];
|
||||
if (!option || option.id === form.warehouseId) return;
|
||||
form.warehouseId = option.id;
|
||||
scannedPallets.value = [];
|
||||
scanError.value = "";
|
||||
scanGeneration += 1;
|
||||
}
|
||||
|
||||
function handleInboundScan(qrToken) {
|
||||
if (!form.warehouseId || operationPending.value || scanLoading.value) {
|
||||
scanError.value = "请先选择入库仓库";
|
||||
return;
|
||||
}
|
||||
const generation = ++scanGeneration;
|
||||
scanLoading.value = true;
|
||||
scanError.value = "";
|
||||
scanInboundPallet(qrToken, { warehouseId: form.warehouseId })
|
||||
.then((response) => {
|
||||
if (generation !== scanGeneration) return;
|
||||
const pallet = response.data;
|
||||
if (pallet?.flowState !== "UNSTOCKED") {
|
||||
scanError.value = "该托盘不是未入库状态,不能加入入库草稿";
|
||||
return;
|
||||
}
|
||||
if (scannedPallets.value.some((item) => item.palletId === pallet.palletId)) {
|
||||
proxy.$modal.msg("该托盘已在当前草稿中");
|
||||
return;
|
||||
}
|
||||
scannedPallets.value.push({
|
||||
palletId: pallet.palletId,
|
||||
palletCode: pallet.palletCode || `#${pallet.palletId}`,
|
||||
flowState: pallet.flowState,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
if (generation === scanGeneration) {
|
||||
scanError.value = errorMessage(error, "托盘扫码校验失败,请检查后重试");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (generation === scanGeneration) scanLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function handleScanInvalid(message) {
|
||||
scanError.value = message;
|
||||
}
|
||||
|
||||
function handleScanDuplicate() {
|
||||
proxy.$modal.msg("扫码过快,请勿重复扫描");
|
||||
}
|
||||
|
||||
function removeScannedPallet(palletId) {
|
||||
if (operationPending.value) return;
|
||||
scannedPallets.value = scannedPallets.value.filter(
|
||||
(item) => item.palletId !== palletId,
|
||||
);
|
||||
}
|
||||
|
||||
function saveDraft() {
|
||||
if (!canSaveDraft.value || operationPending.value || scanLoading.value) return;
|
||||
if (!form.warehouseId) {
|
||||
formError.value = "请选择入库仓库";
|
||||
return;
|
||||
}
|
||||
if (!scannedPallets.value.length) {
|
||||
formError.value = "请至少扫描一个托盘";
|
||||
return;
|
||||
}
|
||||
submitLoading.value = true;
|
||||
formError.value = "";
|
||||
const payload = {
|
||||
warehouseId: form.warehouseId,
|
||||
palletIds: scannedPallets.value.map((item) => item.palletId),
|
||||
};
|
||||
const submitRequest = editing.value
|
||||
? updateInbound({ ...payload, inboundId: form.inboundId })
|
||||
: addInbound(payload);
|
||||
submitRequest
|
||||
.then((response) => {
|
||||
proxy.$modal.msgSuccess(response.msg || "入库草稿保存成功");
|
||||
resetEditor();
|
||||
loadInboundList();
|
||||
})
|
||||
.catch((error) => {
|
||||
formError.value = errorMessage(error, "入库草稿保存失败,请根据提示修正后重试");
|
||||
})
|
||||
.finally(() => {
|
||||
submitLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function askConfirm(content) {
|
||||
return new Promise((resolve) => {
|
||||
uni.showModal({
|
||||
title: "系统提示",
|
||||
content,
|
||||
cancelText: "取消",
|
||||
confirmText: "确定",
|
||||
success: (result) => resolve(Boolean(result.confirm)),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function ensureConfirmIdempotencyKey(inboundId) {
|
||||
if (confirmState.inboundId !== inboundId) {
|
||||
confirmState.inboundId = inboundId;
|
||||
confirmState.key = "";
|
||||
}
|
||||
if (!confirmState.key) confirmState.key = createIdempotencyKey();
|
||||
return confirmState.key;
|
||||
}
|
||||
|
||||
function clearConfirmIdempotency() {
|
||||
confirmState.inboundId = null;
|
||||
confirmState.key = "";
|
||||
}
|
||||
|
||||
async function confirmDraft(row) {
|
||||
if (!canConfirm.value || row.status !== "DRAFT" || operationPending.value) return;
|
||||
if (!(await askConfirm(`确认入库单“${row.inboundNo || `#${row.inboundId}`}”?`))) return;
|
||||
confirmLoading.value = true;
|
||||
confirmError.value = "";
|
||||
try {
|
||||
await confirmInbound(row.inboundId, {
|
||||
idempotencyKey: ensureConfirmIdempotencyKey(row.inboundId),
|
||||
});
|
||||
clearConfirmIdempotency();
|
||||
proxy.$modal.msgSuccess("入库确认成功");
|
||||
loadInboundList();
|
||||
} catch (error) {
|
||||
confirmError.value = `${errorMessage(error, "入库确认失败")};再次确认将复用本次幂等键`;
|
||||
} finally {
|
||||
confirmLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
loadInboundList();
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user