Files
ShiErFeng/shierfeng-fastapi-app/src/pages/business/inbound/index.vue
T

487 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>