移动端:增加测试批次模式隔离

This commit is contained in:
焦龙言
2026-09-24 09:45:08 +08:00
parent 886a4479c4
commit d15b8e2f6c
29 changed files with 3561 additions and 9 deletions
+12
View File
@@ -23,6 +23,18 @@
>
> 另外谨慎升级 `package.json` 中锁定的 `pinia`/`vue`/`@vue/*` 相关包的版本,新版本可能 `uni-app` 没有兼容,造成一些奇怪的 bug
### 配置 PDA 后端地址
PDA 默认请求本机 `http://localhost:9099`,仅适用于后端运行在同一设备的本地开发。部署到独立 PDA 或局域网服务器时,在构建环境注入 `VITE_APP_BASE_API`:
```bash
VITE_APP_BASE_API=http://<服务器地址>:9099 pnpm run build:h5
```
不要把包含真实凭据的环境文件或地址配置提交到仓库。
PDA 测试模式、批次授权和失效回退规则请阅读[测试数据批次操作说明](../docs/test-data-batch-operations.md)。PDA 只允许选择后端返回的 ACTIVE 授权批次,退出登录会清除本地批次上下文。
### vscode
使用 `vscode` 的开发者,请先安装 [Tailwind CSS IntelliSense](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss) 智能提示与感应插件
+1
View File
@@ -3,6 +3,7 @@
"version": "1.9.0",
"packageManager": "pnpm@10.28.1",
"scripts": {
"test:business": "node --test tests/business-contract.test.mjs",
"dev": "uni -p mp-weixin",
"build": "npm run build:mp-weixin",
"dev:app": "uni -p app",
@@ -0,0 +1,38 @@
import request from "@/utils/request";
export function listFilling(query) {
return request({
url: "/business/filling/list",
method: "get",
params: query,
});
}
export function getFillingStrainOptions() {
return request({
url: "/business/filling/strain-options",
method: "get",
});
}
export function scanFillingPallet(qrToken) {
return request({
url: "/business/filling/scan/" + qrToken,
method: "get",
});
}
export function getFilling(fillingId) {
return request({
url: "/business/filling/" + fillingId,
method: "get",
});
}
export function addFilling(data) {
return request({
url: "/business/filling",
method: "post",
data,
});
}
@@ -0,0 +1,56 @@
import request from "@/utils/request";
export function listInbound(query) {
return request({
url: "/business/inbound/list",
method: "get",
params: query,
});
}
export function getInboundWarehouseOptions() {
return request({
url: "/business/inbound/warehouse-options",
method: "get",
});
}
export function scanInboundPallet(qrToken, query) {
return request({
url: "/business/inbound/scan/" + qrToken,
method: "get",
params: query,
});
}
export function getInbound(inboundId) {
return request({
url: "/business/inbound/" + inboundId,
method: "get",
});
}
export function addInbound(data) {
return request({
url: "/business/inbound",
method: "post",
data,
});
}
export function updateInbound(data) {
return request({
url: "/business/inbound",
method: "put",
data,
});
}
export function confirmInbound(inboundId, data) {
return request({
url: "/business/inbound/" + inboundId + "/confirm",
method: "put",
data,
headers: { repeatSubmit: false },
});
}
@@ -0,0 +1,47 @@
import request from "@/utils/request";
export function listReturn(query) {
return request({
url: "/business/return/list",
method: "get",
params: query,
});
}
export function getReturnWarehouseOptions() {
return request({
url: "/business/return/warehouse-options",
method: "get",
});
}
export function scanReturnPallet(qrToken, query) {
return request({
url: "/business/return/scan/" + qrToken,
method: "get",
params: query,
});
}
export function requestReturn(data) {
return request({
url: "/business/return/request",
method: "post",
data,
});
}
export function getReturn(returnId) {
return request({
url: "/business/return/" + returnId,
method: "get",
});
}
export function confirmReturn(returnId, data) {
return request({
url: "/business/return/" + returnId + "/confirm",
method: "put",
data,
});
}
@@ -0,0 +1,62 @@
import request from "@/utils/request";
export function listShipment(query) {
return request({
url: "/business/shipment/list",
method: "get",
params: query,
});
}
export function getShipmentWarehouseOptions() {
return request({
url: "/business/shipment/warehouse-options",
method: "get",
});
}
export function getShipmentSiteOptions() {
return request({
url: "/business/shipment/site-options",
method: "get",
});
}
export function scanShipmentPallet(qrToken, query) {
return request({
url: "/business/shipment/scan/" + qrToken,
method: "get",
params: query,
});
}
export function getShipment(shipmentId) {
return request({
url: "/business/shipment/" + shipmentId,
method: "get",
});
}
export function addShipment(data) {
return request({
url: "/business/shipment",
method: "post",
data,
});
}
export function updateShipment(data) {
return request({
url: "/business/shipment",
method: "put",
data,
});
}
export function confirmShipment(shipmentId, data) {
return request({
url: "/business/shipment/" + shipmentId + "/confirm",
method: "put",
data,
});
}
@@ -0,0 +1,39 @@
import request from "@/utils/request";
export function listUsage(query) {
return request({
url: "/business/usage/list",
method: "get",
params: query,
});
}
export function getUsageLineOptions(siteId) {
return request({
url: "/business/usage/line-options",
method: "get",
params: { siteId },
});
}
export function scanUsagePallet(qrToken) {
return request({
url: "/business/usage/scan/" + qrToken,
method: "get",
});
}
export function getUsage(usageId) {
return request({
url: "/business/usage/" + usageId,
method: "get",
});
}
export function addUsage(data) {
return request({
url: "/business/usage",
method: "post",
data,
});
}
@@ -0,0 +1,9 @@
import request from "@/utils/request";
// PDA 只读取当前登录用户被授权且仍处于 ACTIVE 状态的批次。
export function listAvailableTestBatches() {
return request({
url: "/system/test-batch/options",
method: "get",
});
}
@@ -0,0 +1,115 @@
<template>
<view class="w-full">
<view class="flex items-center rounded-xl bg-white p-2 shadow-sm">
<input
v-model="scanValue"
class="h-11 min-w-0 flex-1 px-3 text-sm text-gray-700"
type="text"
confirm-type="done"
maxlength="128"
:focus="focusRequested"
:disabled="disabled || loading"
placeholder="请扫描或输入托盘二维码后按回车"
@confirm="handleSubmit"
@input="clearError"
/>
<button
class="ml-2 h-10 rounded-lg bg-blue-500 px-3 text-xs text-white active:opacity-80 disabled:bg-gray-300"
:disabled="disabled || loading"
@click="handleSubmit"
>
{{ loading ? "校验中" : "提交扫描" }}
</button>
</view>
<text v-if="errorMessage" class="mt-2 block text-xs text-red-500">
{{ errorMessage }}
</text>
</view>
</template>
<script setup>
import { nextTick, onMounted, ref, watch } from "vue";
import { isDuplicateScan, normalizeScanValue } from "@/utils/palletScan";
const props = defineProps({
disabled: {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,
},
duplicateWindowMs: {
type: Number,
default: 500,
},
});
const emit = defineEmits(["scan", "invalid", "duplicate"]);
const scanValue = ref("");
const errorMessage = ref("");
const focusRequested = ref(false);
let lastScanValue = "";
let lastScanAt = 0;
function focusInput() {
if (props.disabled || props.loading) return;
focusRequested.value = false;
nextTick(() => {
if (!props.disabled && !props.loading) {
focusRequested.value = true;
}
});
}
function clearError() {
errorMessage.value = "";
}
function handleSubmit() {
if (props.disabled || props.loading) return;
let normalizedValue;
try {
normalizedValue = normalizeScanValue(scanValue.value);
} catch (error) {
errorMessage.value = error.message;
emit("invalid", error.message);
focusInput();
return;
}
const scannedAt = Date.now();
if (
isDuplicateScan(
normalizedValue,
lastScanValue,
scannedAt,
lastScanAt,
props.duplicateWindowMs,
)
) {
scanValue.value = "";
emit("duplicate", normalizedValue);
focusInput();
return;
}
lastScanValue = normalizedValue;
lastScanAt = scannedAt;
scanValue.value = "";
errorMessage.value = "";
emit("scan", normalizedValue);
focusInput();
}
onMounted(focusInput);
watch(
() => [props.disabled, props.loading],
([disabled, loading]) => {
if (!disabled && !loading) focusInput();
},
);
</script>
@@ -0,0 +1,22 @@
<template>
<view
v-if="testBatchStore.isTestMode"
class="flex items-start gap-3 rounded-xl border border-red-300 bg-red-50 px-3 py-3"
>
<view class="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-red-500 text-white">
<view class="i-mdi-flask-outline text-sm"></view>
</view>
<view class="min-w-0">
<text class="block text-sm font-bold text-red-700">测试数据</text>
<text class="mt-0.5 block break-all text-xs leading-5 text-red-600">
当前批次:{{ testBatchStore.currentBatchName || "已选测试批次" }}
</text>
</view>
</view>
</template>
<script setup>
import { useTestBatchStore } from "@/store";
const testBatchStore = useTestBatchStore();
</script>
@@ -0,0 +1,153 @@
<template>
<view class="space-y-3 rounded-2xl bg-white p-4 shadow-sm">
<view class="flex items-center justify-between gap-3">
<view class="min-w-0">
<text class="block text-sm font-bold text-gray-800">数据模式</text>
<text class="mt-1 block text-xs text-gray-500">
{{ testBatchStore.isTestMode ? "当前操作隔离在测试批次" : "当前使用正式数据" }}
</text>
</view>
<text
:class="testBatchStore.isTestMode ? 'border-red-300 bg-red-50 text-red-700' : 'border-gray-200 bg-gray-50 text-gray-600'"
class="shrink-0 rounded-full border px-2.5 py-1 text-xs font-semibold"
>
{{ testBatchStore.isTestMode ? "测试数据" : "正式数据" }}
</text>
</view>
<view class="flex rounded-xl bg-gray-100 p-1">
<button
class="flex-1 rounded-lg py-2 text-xs transition-colors"
:class="!testBatchStore.isTestMode ? 'bg-white font-semibold text-gray-800 shadow-sm' : 'text-gray-500'"
:disabled="loading"
size="mini"
@click="handleModeClick('formal')"
>
正式
</button>
<button
class="flex-1 rounded-lg py-2 text-xs transition-colors"
:class="testBatchStore.isTestMode ? 'bg-red-500 font-semibold text-white shadow-sm' : 'text-gray-500'"
:disabled="loading || !activeOptions.length"
size="mini"
@click="handleModeClick('test')"
>
测试
</button>
</view>
<view v-if="testBatchStore.isTestMode || activeOptions.length" class="space-y-1">
<text class="block text-xs text-gray-500">测试批次</text>
<picker
:range="activeOptions"
range-key="batchName"
:value="selectedBatchIndex"
:disabled="loading || !activeOptions.length"
@change="handleBatchChange"
>
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
{{ selectedBatch?.batchName || (loading ? "加载中..." : "暂无可用测试批次") }}
</view>
</picker>
</view>
<view v-if="loadError" class="flex items-center justify-between gap-2 rounded-lg bg-orange-50 px-3 py-2">
<text class="min-w-0 text-xs leading-5 text-orange-700">{{ loadError }}</text>
<button class="shrink-0 text-xs text-orange-700" size="mini" :disabled="loading" @click="loadOptions">
重试
</button>
</view>
</view>
</template>
<script setup>
import { computed, onMounted, ref } from "vue";
import { useTestBatchStore } from "@/store";
const testBatchStore = useTestBatchStore();
const loading = ref(false);
const loadError = ref("");
const activeOptions = computed(() =>
testBatchStore.options.filter((item) => item.status === "ACTIVE"),
);
const selectedBatch = computed(() =>
activeOptions.value.find(
(item) => String(item.batchId) === String(testBatchStore.currentBatchId),
),
);
const selectedBatchIndex = computed(() => {
const index = activeOptions.value.findIndex(
(item) => String(item.batchId) === String(testBatchStore.currentBatchId),
);
return index >= 0 ? index : 0;
});
function errorMessage(error) {
return error?.response?.data?.msg || error?.message || "测试批次加载失败,请稍后重试";
}
async function loadOptions() {
loading.value = true;
loadError.value = "";
try {
await testBatchStore.refreshOptions();
} catch (error) {
if (error?.reason !== "TEST_BATCH_UNAVAILABLE") {
loadError.value = errorMessage(error);
}
} finally {
loading.value = false;
}
}
function confirmModeSwitch() {
return new Promise((resolve) => {
uni.showModal({
title: "切换数据模式",
content: "切换后会清空当前页面未提交的扫码和表单内容,确定继续吗?",
cancelText: "取消",
confirmText: "确定",
success: (result) => resolve(result.confirm === true),
fail: () => resolve(false),
});
});
}
async function handleModeClick(mode) {
if (loading.value) return;
if (mode === "formal") {
if (!testBatchStore.isTestMode || !(await confirmModeSwitch())) return;
testBatchStore.leaveTestMode();
uni.reLaunch({ url: "/pages/work/index" });
return;
}
if (testBatchStore.isTestMode) return;
const batch = activeOptions.value[0];
if (!batch) {
uni.showToast({ title: "暂无可用测试批次", icon: "none" });
return;
}
if (!(await confirmModeSwitch())) return;
try {
testBatchStore.selectBatch(batch);
uni.reLaunch({ url: "/pages/work/index" });
} catch (error) {
uni.showToast({ title: error.message || "测试批次不可用", icon: "none" });
}
}
async function handleBatchChange(event) {
const batch = activeOptions.value[Number(event.detail.value)];
if (!batch || String(batch.batchId) === String(testBatchStore.currentBatchId)) return;
if (!(await confirmModeSwitch())) return;
try {
testBatchStore.selectBatch(batch);
uni.reLaunch({ url: "/pages/work/index" });
} catch (error) {
uni.showToast({ title: error.message || "测试批次不可用", icon: "none" });
}
}
onMounted(loadOptions);
</script>
+2 -1
View File
@@ -1,6 +1,7 @@
// 应用全局配置
export default {
baseUrl: "http://localhost:9099",
// PDA 部署到独立设备时由构建环境注入服务器地址;本地开发保留回环地址。
baseUrl: import.meta.env.VITE_APP_BASE_API || "http://localhost:9099",
// 应用信息
appInfo: {
// 应用名称
+14
View File
@@ -5,10 +5,24 @@ import store from "./store"; // store
import { install } from "./plugins"; // plugins
import "./permission"; // permission
import { useDict } from "@/utils/dict";
import { useTestBatchStore } from "./store";
let testBatchUnavailableListenerRegistered = false;
function registerTestBatchUnavailableListener() {
if (testBatchUnavailableListenerRegistered || typeof uni.$on !== "function") {
return;
}
testBatchUnavailableListenerRegistered = true;
uni.$on("test-batch-unavailable", () => {
useTestBatchStore().leaveTestMode();
});
}
export function createApp() {
const app = createSSRApp(App);
app.use(store);
registerTestBatchUnavailableListener();
app.config.globalProperties.useDict = useDict;
install(app);
return {
+30
View File
@@ -25,6 +25,36 @@
"navigationBarTitleText": "工作台"
}
},
{
"path": "pages/business/inbound/index",
"style": {
"navigationBarTitleText": "仓库入库"
}
},
{
"path": "pages/business/filling/index",
"style": {
"navigationBarTitleText": "托盘装填"
}
},
{
"path": "pages/business/shipment/index",
"style": {
"navigationBarTitleText": "发货出库"
}
},
{
"path": "pages/business/usage/index",
"style": {
"navigationBarTitleText": "现场使用"
}
},
{
"path": "pages/business/return/index",
"style": {
"navigationBarTitleText": "退回仓库"
}
},
{
"path": "pages/mine/index",
"style": {
@@ -0,0 +1,364 @@
<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="resetOperation"
>
清空
</button>
</view>
<PalletScanInput
:disabled="operationPending || optionLoading"
:loading="scanLoading"
@scan="handleFillingScan"
@invalid="handleScanInvalid"
@duplicate="handleScanDuplicate"
/>
<text class="mt-2 block text-xs text-gray-400">
仅接受 EMPTY_IN_WAREHOUSE 状态托盘。
</text>
<text v-if="scanError" class="mt-2 block text-xs text-red-500">{{ scanError }}</text>
<view v-if="scannedPallet" class="mt-4 rounded-xl bg-green-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-green-600">{{ scannedPallet.flowState }}</text>
</view>
<text class="mt-1 block text-xs text-gray-500">
仓库:{{ scannedPallet.warehouseName || `#${scannedPallet.warehouseId}` }} · 容量:{{ scannedPallet.capacityPackages }} 包
</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="space-y-3">
<view>
<text class="mb-2 block text-xs text-gray-500">菌种</text>
<picker
:range="strainOptions"
range-key="name"
:value="strainIndex"
:disabled="operationPending || optionLoading || !scannedPallet"
@change="handleStrainChange"
>
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
{{ selectedStrainName || "请选择启用菌种" }}
</view>
</picker>
</view>
<view>
<text class="mb-2 block text-xs text-gray-500">生产批次</text>
<input
v-model="form.batchNo"
class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700"
maxlength="64"
placeholder="请输入生产批次"
:disabled="operationPending || !scannedPallet"
@input="markPayloadChanged"
/>
</view>
<view>
<text class="mb-2 block text-xs text-gray-500">包装规格</text>
<input
v-model="form.packageSpec"
class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700"
maxlength="64"
placeholder="例如:500g/包"
:disabled="operationPending || !scannedPallet"
@input="markPayloadChanged"
/>
</view>
<view>
<text class="mb-2 block text-xs text-gray-500">初始包数</text>
<input
v-model="form.initialPackages"
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.capacityPackages }} 包,最终容量由服务端校验
</text>
</view>
</view>
<text v-if="formError" class="mt-3 block text-xs text-red-500">{{ formError }}</text>
<button
class="mt-4 w-full rounded-xl bg-green-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
:disabled="!canOperate || operationPending || scanLoading || !scannedPallet"
@click="submitFilling"
>
{{ 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="loadFillingList">
刷新
</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="!fillingList.length" class="py-6 text-center text-xs text-gray-400">
暂无装填记录
</view>
<view
v-for="row in fillingList"
v-else
:key="row.fillingId"
class="mb-3 rounded-xl border border-gray-100 p-3 last:mb-0"
>
<text class="block font-medium text-gray-700">{{ row.fillingNo || `#${row.fillingId}` }}</text>
<text class="mt-1 block text-xs text-gray-500">
{{ row.strainNameSnapshot || row.strainCodeSnapshot }} · {{ row.batchNo }} · {{ row.initialPackages }} 包
</text>
<text class="mt-1 block text-xs text-gray-400">托盘 #{{ row.palletId }} · {{ row.operatedAt || row.createTime || "—" }}</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 {
addFilling,
getFillingStrainOptions,
listFilling,
scanFillingPallet,
} from "@/api/business/filling";
import { createIdempotencyKey } from "@/utils/palletScan";
const { proxy } = getCurrentInstance();
const fillingList = ref([]);
const strainOptions = ref([]);
const scannedPallet = ref(null);
const loading = ref(false);
const optionLoading = ref(false);
const scanLoading = ref(false);
const submitLoading = ref(false);
const listError = ref("");
const scanError = ref("");
const formError = ref("");
const idempotencyKey = ref("");
const form = reactive({
qrToken: "",
warehouseId: null,
strainId: null,
batchNo: "",
packageSpec: "",
initialPackages: "",
});
let listGeneration = 0;
let scanGeneration = 0;
const canPage = computed(() =>
proxy.$auth.hasPermiOr([
"business:filling:list",
"business:filling:query",
"business:filling:add",
]),
);
const canList = computed(() => proxy.$auth.hasPermi("business:filling:list"));
const canOperate = computed(() =>
proxy.$auth.hasPermiAnd(["business:filling:query", "business:filling:add"]),
);
const operationPending = computed(() => submitLoading.value);
const strainIndex = computed(() => {
const index = strainOptions.value.findIndex((item) => item.id === form.strainId);
return index >= 0 ? index : 0;
});
const selectedStrainName = computed(
() => strainOptions.value.find((item) => item.id === form.strainId)?.name || "",
);
function errorMessage(error, fallback) {
return error?.response?.data?.msg || error?.message || fallback;
}
function loadFillingList() {
if (!canList.value) return;
const generation = ++listGeneration;
loading.value = true;
listError.value = "";
listFilling({ pageNum: 1, pageSize: 20 })
.then((response) => {
if (generation !== listGeneration) return;
fillingList.value = Array.isArray(response.rows) ? response.rows : [];
})
.catch((error) => {
if (generation !== listGeneration) return;
fillingList.value = [];
listError.value = errorMessage(error, "装填记录加载失败,请稍后重试");
})
.finally(() => {
if (generation === listGeneration) loading.value = false;
});
}
function loadStrainOptions() {
if (!proxy.$auth.hasPermi("business:filling:query")) return;
optionLoading.value = true;
getFillingStrainOptions()
.then((response) => {
strainOptions.value = Array.isArray(response.data) ? response.data : [];
})
.catch((error) => {
strainOptions.value = [];
formError.value = errorMessage(error, "菌种选项加载失败,请稍后重试");
})
.finally(() => {
optionLoading.value = false;
});
}
function resetOperation() {
scanGeneration += 1;
scannedPallet.value = null;
idempotencyKey.value = "";
form.qrToken = "";
form.warehouseId = null;
form.strainId = null;
form.batchNo = "";
form.packageSpec = "";
form.initialPackages = "";
scanError.value = "";
formError.value = "";
scanLoading.value = false;
}
function handleFillingScan(qrToken) {
if (operationPending.value || scanLoading.value || optionLoading.value) return;
resetOperation();
const generation = ++scanGeneration;
scanLoading.value = true;
scanError.value = "";
scanFillingPallet(qrToken)
.then((response) => {
if (generation !== scanGeneration) return;
const pallet = response.data;
if (pallet?.flowState !== "EMPTY_IN_WAREHOUSE") {
scanError.value = "该托盘当前不可装填";
return;
}
scannedPallet.value = {
palletId: pallet.palletId,
palletCode: pallet.palletCode || `#${pallet.palletId}`,
warehouseId: pallet.warehouseId,
warehouseName: pallet.warehouseName,
flowState: pallet.flowState,
capacityPackages: pallet.capacityPackages,
};
form.qrToken = qrToken;
form.warehouseId = pallet.warehouseId;
loadStrainOptions();
})
.catch((error) => {
if (generation === scanGeneration) {
scanError.value = errorMessage(error, "托盘扫码校验失败,请检查后重试");
}
})
.finally(() => {
if (generation === scanGeneration) scanLoading.value = false;
});
}
function handleScanInvalid(message) {
resetOperation();
scanError.value = message;
}
function handleScanDuplicate() {
proxy.$modal.msg("扫码过快,请勿重复扫描");
}
function handleStrainChange(event) {
const option = strainOptions.value[Number(event.detail.value)];
form.strainId = option?.id || null;
markPayloadChanged();
}
function markPayloadChanged() {
if (!submitLoading.value) idempotencyKey.value = "";
}
function ensureIdempotencyKey() {
if (!idempotencyKey.value) idempotencyKey.value = createIdempotencyKey();
return idempotencyKey.value;
}
function submitFilling() {
if (!canOperate.value || operationPending.value || scanLoading.value || optionLoading.value) return;
const initialPackages = Number(form.initialPackages);
if (!scannedPallet.value || !form.qrToken || !form.warehouseId) {
formError.value = "请先扫描待装填托盘";
return;
}
if (!form.strainId || !form.batchNo.trim() || !form.packageSpec.trim()) {
formError.value = "请完整填写菌种、批次和包装规格";
return;
}
if (!Number.isInteger(initialPackages) || initialPackages <= 0) {
formError.value = "初始包数必须为正整数";
return;
}
submitLoading.value = true;
formError.value = "";
addFilling({
qrToken: form.qrToken,
warehouseId: form.warehouseId,
strainId: form.strainId,
batchNo: form.batchNo.trim(),
packageSpec: form.packageSpec.trim(),
initialPackages,
idempotencyKey: ensureIdempotencyKey(),
})
.then((response) => {
idempotencyKey.value = "";
proxy.$modal.msgSuccess(response.msg || "托盘装填成功");
resetOperation();
loadFillingList();
})
.catch((error) => {
formError.value = `${errorMessage(error, "装填提交失败")};直接重试将复用本次幂等键,修改输入后会生成新键`;
})
.finally(() => {
submitLoading.value = false;
});
}
onShow(() => {
loadFillingList();
});
</script>
@@ -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>
@@ -0,0 +1,560 @@
<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 v-if="canRequest" 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="requestPending" @click="resetRequestOperation">
清空
</button>
</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 || requestPending"
@change="handleWarehouseChange"
>
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
{{ selectedWarehouseName || "请选择目标仓库" }}
</view>
</picker>
</view>
<PalletScanInput
:disabled="!requestForm.targetWarehouseId || requestPending"
:loading="scanLoading.request"
@scan="handleRequestScan"
@invalid="handleRequestScanInvalid"
@duplicate="handleScanDuplicate"
/>
<text class="mt-2 block text-xs text-gray-400">
同一申请只能包含同一客户现场的托盘;扫码成功后托盘由服务端冻结。
</text>
<text v-if="requestError" class="mt-2 block text-xs text-red-500">{{ requestError }}</text>
<view v-if="requestScannedPallets.length" class="mt-3 space-y-2">
<view
v-for="pallet in requestScannedPallets"
:key="pallet.palletId"
class="flex items-center justify-between rounded-xl bg-blue-50 px-3 py-2"
>
<view>
<text class="block text-sm text-gray-700">{{ pallet.palletCode || `#${pallet.palletId}` }}</text>
<text class="block text-xs text-gray-500">现场 #{{ pallet.siteId }} · 剩余 {{ pallet.remainingPackages }} 包</text>
</view>
<button class="text-xs text-red-500" size="mini" :disabled="requestPending" @click="removeRequestPallet(pallet.palletId)">
移除
</button>
</view>
</view>
<text v-if="requestForm.sourceSiteId" class="mt-3 block text-xs text-gray-500">
来源现场:#{{ requestForm.sourceSiteId }} · {{ requestScannedPallets.length }} 个托盘
</text>
<button
class="mt-4 w-full rounded-xl bg-blue-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
:disabled="!requestScannedPallets.length || requestPending || scanLoading.request"
@click="submitReturnRequest"
>
{{ requestSubmitLoading ? "申请中" : "提交退回申请" }}
</button>
</view>
<view v-if="canConfirm" 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>
<text class="text-xs text-gray-400">必须完整扫描单据托盘</text>
</view>
<picker
:range="requestedReturns"
range-key="returnNo"
:value="requestedReturnIndex"
:disabled="confirmPending || detailLoading"
@change="handleConfirmSelection"
>
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
{{ selectedReturnLabel || "请选择待确认退回单" }}
</view>
</picker>
<text v-if="confirmError" class="mt-2 block text-xs text-red-500">{{ confirmError }}</text>
<view v-if="confirmExpectedRows.length" class="mt-3 rounded-xl bg-orange-50 p-3">
<text class="block text-xs text-gray-500">
预期托盘 {{ confirmExpectedRows.length }} 个,已扫描 {{ confirmScannedPallets.length }} 个
</text>
<view class="mt-2 space-y-1">
<text v-for="row in confirmExpectedRows" :key="row.palletId" class="block text-xs text-gray-600">
{{ row.palletCode || `#${row.palletId}` }}
<text v-if="isConfirmPalletScanned(row.palletId)" class="text-green-600"> · 已扫描</text>
</text>
</view>
</view>
<PalletScanInput
class="mt-3"
:disabled="!confirmForm.returnId || !confirmExpectedRows.length || confirmPending || detailLoading"
:loading="scanLoading.confirm"
@scan="handleConfirmScan"
@invalid="handleConfirmScanInvalid"
@duplicate="handleScanDuplicate"
/>
<button
class="mt-4 w-full rounded-xl bg-orange-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
:disabled="!confirmForm.returnId || !confirmExpectedRows.length || confirmPending || scanLoading.confirm || detailLoading"
@click="submitReturnConfirm"
>
{{ confirmSubmitLoading ? "确认中" : "确认退回收货" }}
</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="loadReturnList">
刷新
</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="!returnList.length" class="py-6 text-center text-xs text-gray-400">
暂无退回单
</view>
<view
v-for="row in returnList"
v-else
:key="row.returnId"
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.returnNo || `#${row.returnId}` }}</text>
<text :class="row.status === 'REQUESTED' ? 'text-orange-500' : 'text-green-500'" class="text-xs">
{{ row.status === "REQUESTED" ? "待确认" : "已确认" }}
</text>
</view>
<text class="mt-1 block text-xs text-gray-500">
现场 #{{ row.sourceSiteId }} → {{ warehouseName(row.targetWarehouseId) }} · 托盘 {{ row.items?.length || 0 }} 个
</text>
<button
v-if="row.status === 'REQUESTED' && canConfirm"
class="mt-3 w-full rounded-lg bg-orange-50 py-2 text-xs text-orange-600"
size="mini"
:disabled="confirmPending"
@click="selectReturnRow(row)"
>
选择并确认
</button>
</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 {
confirmReturn,
getReturn,
getReturnWarehouseOptions,
listReturn,
requestReturn,
scanReturnPallet,
} from "@/api/business/return";
import { createIdempotencyKey } from "@/utils/palletScan";
const { proxy } = getCurrentInstance();
const returnList = ref([]);
const warehouseOptions = ref([]);
const requestScannedPallets = ref([]);
const confirmExpectedRows = ref([]);
const confirmExpectedPalletIds = ref([]);
const confirmScannedPallets = ref([]);
const loading = ref(false);
const optionLoading = ref(false);
const detailLoading = ref(false);
const listError = ref("");
const requestError = ref("");
const confirmError = ref("");
const scanLoading = reactive({ request: false, confirm: false });
const requestSubmitLoading = ref(false);
const confirmSubmitLoading = ref(false);
const requestIdempotencyKey = ref("");
const confirmIdempotencyKey = ref("");
const requestForm = reactive({ sourceSiteId: null, targetWarehouseId: null });
const confirmForm = reactive({ returnId: null });
let listGeneration = 0;
let requestScanGeneration = 0;
let confirmScanGeneration = 0;
let detailGeneration = 0;
let requestSession = 0;
let confirmSession = 0;
const canPage = computed(() =>
proxy.$auth.hasPermiOr([
"business:return:list",
"business:return:query",
"business:return:request",
"business:return:confirm",
]),
);
const canList = computed(() => proxy.$auth.hasPermi("business:return:list"));
const canRequest = computed(() =>
proxy.$auth.hasPermiAnd(["business:return:query", "business:return:request"]),
);
const canConfirm = computed(() =>
proxy.$auth.hasPermiAnd(["business:return:query", "business:return:confirm"]),
);
const requestPending = computed(() => requestSubmitLoading.value);
const confirmPending = computed(() => confirmSubmitLoading.value);
const warehouseIndex = computed(() => {
const index = warehouseOptions.value.findIndex(
(item) => item.id === requestForm.targetWarehouseId,
);
return index >= 0 ? index : 0;
});
const selectedWarehouseName = computed(
() => warehouseOptions.value.find((item) => item.id === requestForm.targetWarehouseId)?.name || "",
);
const requestedReturns = computed(() =>
returnList.value.filter((item) => item.status === "REQUESTED"),
);
const requestedReturnIndex = computed(() => {
const index = requestedReturns.value.findIndex(
(item) => item.returnId === confirmForm.returnId,
);
return index >= 0 ? index : 0;
});
const selectedReturnLabel = computed(() => {
const item = requestedReturns.value.find((row) => row.returnId === confirmForm.returnId);
return item ? `${item.returnNo || `#${item.returnId}`} · 现场 #${item.sourceSiteId}` : "";
});
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 loadReturnList() {
if (!canList.value) return;
const generation = ++listGeneration;
loading.value = true;
listError.value = "";
listReturn({ pageNum: 1, pageSize: 30 })
.then((response) => {
if (generation !== listGeneration) return;
returnList.value = Array.isArray(response.rows) ? response.rows : [];
})
.catch((error) => {
if (generation !== listGeneration) return;
returnList.value = [];
listError.value = errorMessage(error, "退回单列表加载失败,请稍后重试");
})
.finally(() => {
if (generation === listGeneration) loading.value = false;
});
}
function loadWarehouseOptions() {
if (!proxy.$auth.hasPermi("business:return:query")) return;
optionLoading.value = true;
getReturnWarehouseOptions()
.then((response) => {
warehouseOptions.value = Array.isArray(response.data) ? response.data : [];
})
.catch((error) => {
warehouseOptions.value = [];
requestError.value = errorMessage(error, "目标仓库选项加载失败,请稍后重试");
})
.finally(() => {
optionLoading.value = false;
});
}
function resetRequestOperation() {
requestSession += 1;
requestScanGeneration += 1;
requestForm.sourceSiteId = null;
requestForm.targetWarehouseId = null;
requestScannedPallets.value = [];
requestIdempotencyKey.value = "";
requestError.value = "";
scanLoading.request = false;
}
function handleWarehouseChange(event) {
const option = warehouseOptions.value[Number(event.detail.value)];
if (!option || option.id === requestForm.targetWarehouseId) return;
requestForm.targetWarehouseId = option.id;
requestForm.sourceSiteId = null;
requestScannedPallets.value = [];
requestIdempotencyKey.value = "";
requestError.value = "";
requestScanGeneration += 1;
}
function handleRequestScan(qrToken) {
if (!requestForm.targetWarehouseId || requestPending.value || scanLoading.request) {
requestError.value = "请先选择目标仓库";
return;
}
const session = requestSession;
const generation = ++requestScanGeneration;
scanLoading.request = true;
requestError.value = "";
scanReturnPallet(qrToken, { mode: "request" })
.then((response) => {
if (session !== requestSession || generation !== requestScanGeneration) return;
const pallet = response.data;
if (!pallet?.siteId) {
requestError.value = "该托盘不在客户现场,不能发起退回";
return;
}
if (requestForm.sourceSiteId && pallet.siteId !== requestForm.sourceSiteId) {
requestError.value = "同一退回申请只能包含同一客户现场的托盘";
return;
}
if (requestScannedPallets.value.some((item) => item.palletId === pallet.palletId)) {
proxy.$modal.msg("该托盘已在退回申请中");
return;
}
requestForm.sourceSiteId = requestForm.sourceSiteId || pallet.siteId;
requestScannedPallets.value.push({
palletId: pallet.palletId,
palletCode: pallet.palletCode || `#${pallet.palletId}`,
siteId: pallet.siteId,
remainingPackages: pallet.remainingPackages,
});
requestIdempotencyKey.value = "";
})
.catch((error) => {
if (session === requestSession && generation === requestScanGeneration) {
requestError.value = errorMessage(error, "退回托盘扫码校验失败,请检查后重试");
}
})
.finally(() => {
if (session === requestSession && generation === requestScanGeneration) {
scanLoading.request = false;
}
});
}
function handleRequestScanInvalid(message) {
requestError.value = message;
}
function removeRequestPallet(palletId) {
if (requestPending.value) return;
requestScannedPallets.value = requestScannedPallets.value.filter(
(item) => item.palletId !== palletId,
);
if (!requestScannedPallets.value.length) requestForm.sourceSiteId = null;
requestIdempotencyKey.value = "";
}
function ensureRequestIdempotencyKey() {
if (!requestIdempotencyKey.value) requestIdempotencyKey.value = createIdempotencyKey();
return requestIdempotencyKey.value;
}
async function submitReturnRequest() {
if (requestPending.value || scanLoading.request || !requestScannedPallets.value.length) return;
if (!requestForm.sourceSiteId || !requestForm.targetWarehouseId) {
requestError.value = "请选择目标仓库并扫描退回托盘";
return;
}
requestSubmitLoading.value = true;
requestError.value = "";
try {
await requestReturn({
sourceSiteId: requestForm.sourceSiteId,
targetWarehouseId: requestForm.targetWarehouseId,
palletIds: requestScannedPallets.value.map((item) => item.palletId),
idempotencyKey: ensureRequestIdempotencyKey(),
});
requestIdempotencyKey.value = "";
proxy.$modal.msgSuccess("退回申请已提交");
resetRequestOperation();
loadReturnList();
} catch (error) {
requestError.value = `${errorMessage(error, "退回申请提交失败")};直接重试将复用本次幂等键`;
} finally {
requestSubmitLoading.value = false;
}
}
function resetConfirmOperation() {
confirmSession += 1;
confirmScanGeneration += 1;
detailGeneration += 1;
confirmForm.returnId = null;
confirmExpectedRows.value = [];
confirmExpectedPalletIds.value = [];
confirmScannedPallets.value = [];
confirmIdempotencyKey.value = "";
confirmError.value = "";
detailLoading.value = false;
scanLoading.confirm = false;
}
function handleConfirmSelection(event) {
const row = requestedReturns.value[Number(event.detail.value)];
selectReturnRow(row);
}
function selectReturnRow(row) {
if (!row || row.status !== "REQUESTED" || confirmPending.value) return;
resetConfirmOperation();
const session = confirmSession;
const generation = ++detailGeneration;
confirmForm.returnId = row.returnId;
detailLoading.value = true;
getReturn(row.returnId)
.then((response) => {
if (session !== confirmSession || generation !== detailGeneration) return;
const detail = response.data;
if (!detail || detail.status !== "REQUESTED") {
confirmError.value = "该退回单已不是待确认状态";
return;
}
confirmExpectedRows.value = (Array.isArray(detail.items) ? detail.items : []).map(
(item) => ({
palletId: item.palletId,
palletCode: item.palletCode || `#${item.palletId}`,
}),
);
confirmExpectedPalletIds.value = confirmExpectedRows.value.map((item) => item.palletId);
if (!confirmExpectedPalletIds.value.length) confirmError.value = "该退回单没有托盘明细";
})
.catch((error) => {
if (session === confirmSession && generation === detailGeneration) {
confirmError.value = errorMessage(error, "退回单详情加载失败,请稍后重试");
}
})
.finally(() => {
if (session === confirmSession && generation === detailGeneration) {
detailLoading.value = false;
}
});
}
function handleConfirmScan(qrToken) {
if (!confirmForm.returnId || !confirmExpectedPalletIds.value.length || confirmPending.value || scanLoading.confirm) return;
const session = confirmSession;
const generation = ++confirmScanGeneration;
scanLoading.confirm = true;
confirmError.value = "";
scanReturnPallet(qrToken, { mode: "confirm", returnId: confirmForm.returnId })
.then((response) => {
if (session !== confirmSession || generation !== confirmScanGeneration) return;
const pallet = response.data;
if (!confirmExpectedPalletIds.value.includes(pallet.palletId)) {
confirmError.value = "该托盘不属于所选退回单";
return;
}
if (confirmScannedPallets.value.some((item) => item.palletId === pallet.palletId)) {
proxy.$modal.msg("该托盘已完成扫码");
return;
}
confirmScannedPallets.value.push({
palletId: pallet.palletId,
palletCode: pallet.palletCode || `#${pallet.palletId}`,
});
confirmIdempotencyKey.value = "";
})
.catch((error) => {
if (session === confirmSession && generation === confirmScanGeneration) {
confirmError.value = errorMessage(error, "到仓托盘扫码校验失败,请检查后重试");
}
})
.finally(() => {
if (session === confirmSession && generation === confirmScanGeneration) {
scanLoading.confirm = false;
}
});
}
function handleConfirmScanInvalid(message) {
confirmError.value = message;
}
function handleScanDuplicate() {
proxy.$modal.msg("扫码过快,请勿重复扫描");
}
function isConfirmPalletScanned(palletId) {
return confirmScannedPallets.value.some((item) => item.palletId === palletId);
}
function sameIdSet(left, right) {
if (left.length !== right.length) return false;
const leftSet = new Set(left);
const rightSet = new Set(right);
return (
leftSet.size === left.length &&
rightSet.size === right.length &&
right.every((id) => leftSet.has(id))
);
}
function ensureConfirmIdempotencyKey(returnId) {
if (!confirmIdempotencyKey.value) confirmIdempotencyKey.value = createIdempotencyKey();
return confirmIdempotencyKey.value;
}
async function submitReturnConfirm() {
if (confirmPending.value || detailLoading.value || scanLoading.confirm) return;
const returnId = confirmForm.returnId;
const expectedIds = [...confirmExpectedPalletIds.value];
const scannedIds = confirmScannedPallets.value.map((item) => item.palletId);
if (!returnId || !sameIdSet(expectedIds, scannedIds)) {
confirmError.value = "必须逐个扫描并完整匹配退回单全部托盘";
return;
}
confirmSubmitLoading.value = true;
confirmError.value = "";
try {
const latest = (await getReturn(returnId)).data;
const latestIds = (Array.isArray(latest.items) ? latest.items : []).map((item) => item.palletId);
if (latest.status !== "REQUESTED" || !sameIdSet(latestIds, scannedIds)) {
confirmError.value = "退回单状态或托盘明细已变化,请重新选择并扫码";
return;
}
await confirmReturn(returnId, {
scannedPalletIds: scannedIds,
idempotencyKey: ensureConfirmIdempotencyKey(returnId),
});
confirmIdempotencyKey.value = "";
proxy.$modal.msgSuccess("退回确认成功");
resetConfirmOperation();
loadReturnList();
} catch (error) {
confirmError.value = `${errorMessage(error, "退回确认失败")};直接重试将复用本次幂等键`;
} finally {
confirmSubmitLoading.value = false;
}
}
onShow(() => {
loadReturnList();
loadWarehouseOptions();
});
</script>
@@ -0,0 +1,549 @@
<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-orange-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>
<picker
:range="siteOptions"
range-key="name"
:value="siteIndex"
:disabled="optionLoading || detailLoading || operationPending"
@change="handleSiteChange"
>
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
{{ selectedSiteName || "请选择目标现场" }}
</view>
</picker>
</view>
<view class="mb-3">
<text class="mb-2 block text-xs text-gray-500">连续扫码</text>
<PalletScanInput
:disabled="!form.sourceWarehouseId || !form.targetSiteId || detailLoading || operationPending"
:loading="scanLoading"
@scan="handleShipmentScan"
@invalid="handleScanInvalid"
@duplicate="handleScanDuplicate"
/>
<text class="mt-2 block text-xs text-gray-400">
仅接受来源仓库内有剩余包数的 FILLED_IN_WAREHOUSE 托盘。
</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="rounded-xl bg-orange-50 px-3 py-2"
>
<view class="flex items-center justify-between">
<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.strainName || pallet.strainCode || "未提供菌种" }} · 剩余 {{ pallet.remainingPackages }} 包
</text>
</view>
<button
class="text-xs text-red-500"
size="mini"
:disabled="operationPending || detailLoading"
@click="removeScannedPallet(pallet.palletId)"
>
移除
</button>
</view>
<text class="mt-1 block text-xs text-gray-400">
批次:{{ pallet.batchNo || "—" }}
</text>
</view>
</view>
<view class="flex gap-3">
<button
class="flex-1 rounded-xl bg-orange-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="loadShipmentList"
>
刷新
</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="!shipmentList.length" class="py-6 text-center text-xs text-gray-400">
暂无发货单
</view>
<view
v-for="row in shipmentList"
v-else
:key="row.shipmentId"
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.shipmentNo || `#${row.shipmentId}` }}</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.sourceWarehouseId) }} → {{ siteName(row.targetSiteId) }} · 托盘 {{ 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 {
addShipment,
confirmShipment,
getShipment,
getShipmentSiteOptions,
getShipmentWarehouseOptions,
listShipment,
scanShipmentPallet,
updateShipment,
} from "@/api/business/shipment";
import { createIdempotencyKey } from "@/utils/palletScan";
const { proxy } = getCurrentInstance();
const shipmentList = ref([]);
const warehouseOptions = ref([]);
const siteOptions = 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({ shipmentId: null, sourceWarehouseId: null, targetSiteId: null });
const confirmState = reactive({ shipmentId: null, key: "" });
let listGeneration = 0;
let scanGeneration = 0;
const canPage = computed(() =>
proxy.$auth.hasPermiOr([
"business:shipment:list",
"business:shipment:query",
"business:shipment:add",
"business:shipment:edit",
"business:shipment:confirm",
]),
);
const canList = computed(() => proxy.$auth.hasPermi("business:shipment:list"));
const canDraft = computed(() =>
proxy.$auth.hasPermiAnd(["business:shipment:query", "business:shipment:add"]),
);
const canEdit = computed(() =>
proxy.$auth.hasPermiAnd(["business:shipment:query", "business:shipment:edit"]),
);
const canConfirm = computed(() => proxy.$auth.hasPermi("business:shipment: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.sourceWarehouseId,
);
return index >= 0 ? index : 0;
});
const siteIndex = computed(() => {
const index = siteOptions.value.findIndex((item) => item.id === form.targetSiteId);
return index >= 0 ? index : 0;
});
const selectedWarehouseName = computed(
() => warehouseOptions.value.find((item) => item.id === form.sourceWarehouseId)?.name || "",
);
const selectedSiteName = computed(
() => siteOptions.value.find((item) => item.id === form.targetSiteId)?.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 siteName(id) {
return siteOptions.value.find((item) => item.id === id)?.name || `现场 #${id || "-"}`;
}
function resetEditor() {
scanGeneration += 1;
editorVisible.value = false;
editing.value = false;
form.shipmentId = null;
form.sourceWarehouseId = null;
form.targetSiteId = null;
scannedPallets.value = [];
detailLoading.value = false;
scanLoading.value = false;
formError.value = "";
scanError.value = "";
}
function loadShipmentList() {
if (!canList.value) return;
const generation = ++listGeneration;
loading.value = true;
listError.value = "";
listShipment({ pageNum: 1, pageSize: 20 })
.then((response) => {
if (generation !== listGeneration) return;
shipmentList.value = Array.isArray(response.rows) ? response.rows : [];
})
.catch((error) => {
if (generation !== listGeneration) return;
shipmentList.value = [];
listError.value = errorMessage(error, "发货单列表加载失败,请稍后重试");
})
.finally(() => {
if (generation === listGeneration) loading.value = false;
});
}
function loadOptions() {
if (!proxy.$auth.hasPermi("business:shipment:query")) return;
optionLoading.value = true;
Promise.all([getShipmentWarehouseOptions(), getShipmentSiteOptions()])
.then(([warehouseResponse, siteResponse]) => {
warehouseOptions.value = Array.isArray(warehouseResponse.data)
? warehouseResponse.data
: [];
siteOptions.value = Array.isArray(siteResponse.data) ? siteResponse.data : [];
})
.catch((error) => {
warehouseOptions.value = [];
siteOptions.value = [];
formError.value = errorMessage(error, "仓库或现场选项加载失败,请稍后重试");
})
.finally(() => {
optionLoading.value = false;
});
}
function startNewDraft() {
if (!canDraft.value || operationPending.value) return;
resetEditor();
editorVisible.value = true;
loadOptions();
}
function startEditDraft(row) {
if (!canEdit.value || row.status !== "DRAFT" || operationPending.value) return;
resetEditor();
editorVisible.value = true;
editing.value = true;
form.shipmentId = row.shipmentId;
loadOptions();
detailLoading.value = true;
getShipment(row.shipmentId)
.then((response) => {
const detail = response.data;
if (!detail || detail.status !== "DRAFT") {
formError.value = "该发货单已不是草稿状态,不能编辑";
return;
}
form.sourceWarehouseId = detail.sourceWarehouseId;
form.targetSiteId = detail.targetSiteId;
scannedPallets.value = (Array.isArray(detail.items) ? detail.items : []).map(
(item) => ({
palletId: item.palletId,
palletCode: item.palletCode || `#${item.palletId}`,
strainName: item.strainName || item.strainCode,
batchNo: item.batchNo,
remainingPackages: item.remainingPackages ?? item.packageQty ?? 0,
}),
);
})
.catch((error) => {
formError.value = errorMessage(error, "发货单详情加载失败,请稍后重试");
})
.finally(() => {
detailLoading.value = false;
});
}
function cancelEditor() {
if (operationPending.value) return;
resetEditor();
}
function clearScannedPallets() {
scannedPallets.value = [];
scanError.value = "";
scanGeneration += 1;
}
function handleWarehouseChange(event) {
const option = warehouseOptions.value[Number(event.detail.value)];
if (!option || option.id === form.sourceWarehouseId) return;
form.sourceWarehouseId = option.id;
clearScannedPallets();
}
function handleSiteChange(event) {
const option = siteOptions.value[Number(event.detail.value)];
if (!option || option.id === form.targetSiteId) return;
form.targetSiteId = option.id;
clearScannedPallets();
}
function handleShipmentScan(qrToken) {
if (
!form.sourceWarehouseId ||
!form.targetSiteId ||
operationPending.value ||
scanLoading.value
) {
scanError.value = "请先选择来源仓库和目标现场";
return;
}
const generation = ++scanGeneration;
scanLoading.value = true;
scanError.value = "";
scanShipmentPallet(qrToken, {
sourceWarehouseId: form.sourceWarehouseId,
targetSiteId: form.targetSiteId,
})
.then((response) => {
if (generation !== scanGeneration) return;
const pallet = response.data;
if (pallet?.flowState !== "FILLED_IN_WAREHOUSE" || pallet.remainingPackages <= 0) {
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}`,
strainName: pallet.strainName || pallet.strainCode,
batchNo: pallet.batchNo,
remainingPackages: pallet.remainingPackages,
});
})
.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.sourceWarehouseId || !form.targetSiteId) {
formError.value = "请选择来源仓库和目标现场";
return;
}
if (!scannedPallets.value.length) {
formError.value = "请至少扫描一个有料托盘";
return;
}
submitLoading.value = true;
formError.value = "";
const payload = {
sourceWarehouseId: form.sourceWarehouseId,
targetSiteId: form.targetSiteId,
palletIds: scannedPallets.value.map((item) => item.palletId),
};
const submitRequest = editing.value
? updateShipment({ ...payload, shipmentId: form.shipmentId })
: addShipment(payload);
submitRequest
.then((response) => {
proxy.$modal.msgSuccess(response.msg || "发货草稿保存成功");
resetEditor();
loadShipmentList();
})
.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(shipmentId) {
if (confirmState.shipmentId !== shipmentId) {
confirmState.shipmentId = shipmentId;
confirmState.key = "";
}
if (!confirmState.key) confirmState.key = createIdempotencyKey();
return confirmState.key;
}
function clearConfirmIdempotency() {
confirmState.shipmentId = null;
confirmState.key = "";
}
async function confirmDraft(row) {
if (!canConfirm.value || row.status !== "DRAFT" || operationPending.value) return;
if (!(await askConfirm(`确认发货单“${row.shipmentNo || `#${row.shipmentId}`}”?`))) return;
confirmLoading.value = true;
confirmError.value = "";
try {
await confirmShipment(row.shipmentId, {
idempotencyKey: ensureConfirmIdempotencyKey(row.shipmentId),
});
clearConfirmIdempotency();
proxy.$modal.msgSuccess("发货确认成功");
loadShipmentList();
} catch (error) {
confirmError.value = `${errorMessage(error, "发货确认失败")};再次确认将复用本次幂等键`;
} finally {
confirmLoading.value = false;
}
}
onShow(() => {
loadShipmentList();
});
</script>
@@ -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>
+1 -1
View File
@@ -135,7 +135,7 @@ const captchaEnabled = ref(true);
const register = ref(false);
const loginForm = ref({
username: "admin",
password: "admin123",
password: "",
code: "",
uuid: "",
});
+112 -1
View File
@@ -2,6 +2,8 @@
<view class="flex h-full flex-col bg-gray-50 overflow-hidden">
<scroll-view scroll-y class="flex-1" :show-scrollbar="false">
<view class="p-4 pb-24 space-y-6">
<TestBatchModeSwitch />
<!-- Banner -->
<view
class="relative h-40 w-full overflow-hidden rounded-2xl shadow-lg shadow-indigo-100"
@@ -82,6 +84,36 @@
</view>
</view>
<!-- PDA Business Section -->
<view v-if="visibleBusinessEntries.length">
<view class="mb-4 flex items-center space-x-2">
<view class="h-4 w-1 rounded-full bg-emerald-500"></view>
<text class="text-base font-bold text-gray-800">现场业务</text>
</view>
<view class="rounded-2xl bg-white p-5 shadow-sm">
<view class="grid grid-cols-3 gap-4">
<view
v-for="entry in visibleBusinessEntries"
:key="entry.path"
class="flex flex-col items-center space-y-1.5 active:opacity-60"
@click="handleBusinessEntry(entry)"
>
<view
class="flex size-10 items-center justify-center rounded-xl bg-emerald-50 text-emerald-600"
>
<view :class="entry.icon" class="text-xl"></view>
</view>
<text class="text-center text-[11px] font-medium text-gray-600">
{{ entry.title }}
</text>
<text class="text-center text-[10px] text-gray-400">
{{ entry.description }}
</text>
</view>
</view>
</view>
</view>
<!-- System Management Section -->
<view>
<view class="mb-4 flex items-center space-x-2">
@@ -202,7 +234,8 @@
</template>
<script setup>
import { ref, getCurrentInstance } from "vue";
import { computed, ref, getCurrentInstance } from "vue";
import TestBatchModeSwitch from "@/components/business/TestBatchModeSwitch.vue";
const { proxy } = getCurrentInstance();
const current = ref(0);
@@ -212,6 +245,76 @@ const data = ref([
{ image: "/static/images/banner/banner02.jpg" },
{ image: "/static/images/banner/banner03.jpg" },
]);
const businessEntries = [
{
path: "/pages/business/inbound/index",
title: "仓库入库",
description: "扫描托盘登记入库",
icon: "i-mdi-package-down",
color: "blue",
permissions: [
"business:inbound:list",
"business:inbound:query",
"business:inbound:add",
"business:inbound:edit",
"business:inbound:confirm",
],
},
{
path: "/pages/business/filling/index",
title: "托盘装填",
description: "录入菌种和包数",
icon: "i-mdi-package-variant-closed-plus",
color: "green",
permissions: [
"business:filling:list",
"business:filling:query",
"business:filling:add",
],
},
{
path: "/pages/business/shipment/index",
title: "发货出库",
description: "扫码创建发货单",
icon: "i-mdi-truck-delivery",
color: "orange",
permissions: [
"business:shipment:list",
"business:shipment:query",
"business:shipment:add",
"business:shipment:edit",
"business:shipment:confirm",
],
},
{
path: "/pages/business/usage/index",
title: "现场使用",
description: "绑定产线记录消耗",
icon: "i-mdi-factory",
color: "purple",
permissions: [
"business:usage:list",
"business:usage:query",
"business:usage:add",
],
},
{
path: "/pages/business/return/index",
title: "退回仓库",
description: "申请和确认退回",
icon: "i-mdi-undo-variant",
color: "red",
permissions: [
"business:return:list",
"business:return:query",
"business:return:request",
"business:return:confirm",
],
},
];
const visibleBusinessEntries = computed(() =>
businessEntries.filter((entry) => proxy.$auth.hasPermiOr(entry.permissions)),
);
function clickBannerItem(item) {
console.info(item);
@@ -229,6 +332,14 @@ function handleToUserList() {
handleBuilding();
}
function handleBusinessEntry(entry) {
if (!proxy.$auth.hasPermiOr(entry.permissions)) {
proxy.$modal.msgError("当前账号没有该业务权限");
return;
}
proxy.$tab.navigateTo(entry.path);
}
function handleBuilding() {
proxy.$modal.msg("模块建设中~");
}
+2 -1
View File
@@ -1,9 +1,10 @@
import { createPinia } from "pinia";
import { useUserStore } from "./modules/user";
import { useConfigStore } from "./modules/config";
import { useTestBatchStore } from "./modules/testBatch";
const pinia = createPinia();
export default pinia;
export { useUserStore, useConfigStore };
export { useUserStore, useConfigStore, useTestBatchStore };
@@ -0,0 +1,83 @@
import { computed, ref } from "vue";
import { defineStore } from "pinia";
import { listAvailableTestBatches } from "@/api/system/testBatch";
import storage from "@/utils/storage";
import constant from "@/utils/constant";
export const useTestBatchStore = defineStore("testBatch", () => {
const options = ref([]);
const currentBatchId = ref(String(storage.get(constant.testBatchId) || ""));
const currentBatchName = ref(storage.get(constant.testBatchName) || "");
const scopeVersion = ref(0);
const isTestMode = computed(() => Boolean(currentBatchId.value));
function leaveTestMode() {
currentBatchId.value = "";
currentBatchName.value = "";
storage.remove(constant.testBatchId);
storage.remove(constant.testBatchName);
scopeVersion.value += 1;
}
async function refreshOptions() {
try {
const response = await listAvailableTestBatches();
const available = (Array.isArray(response.data) ? response.data : []).filter(
(item) => item && item.status === "ACTIVE" && item.batchId != null,
);
options.value = available;
const current = available.find(
(item) => String(item.batchId) === String(currentBatchId.value),
);
if (current) {
currentBatchId.value = String(current.batchId);
currentBatchName.value = current.batchName || "";
storage.set(constant.testBatchId, currentBatchId.value);
storage.set(constant.testBatchName, currentBatchName.value);
} else if (currentBatchId.value) {
// 本地缓存不能代替后端授权;选项接口未返回当前批次时立即回到正式模式。
leaveTestMode();
}
return available;
} catch (error) {
options.value = [];
leaveTestMode();
throw error;
}
}
function selectBatch(batch) {
const allowed = options.value.find(
(item) =>
String(item.batchId) === String(batch?.batchId) && item.status === "ACTIVE",
);
if (!allowed) {
throw new Error("所选测试批次当前不可用");
}
currentBatchId.value = String(allowed.batchId);
currentBatchName.value = allowed.batchName || "";
storage.set(constant.testBatchId, currentBatchId.value);
storage.set(constant.testBatchName, currentBatchName.value);
scopeVersion.value += 1;
}
function reset() {
options.value = [];
leaveTestMode();
}
return {
options,
currentBatchId,
currentBatchName,
scopeVersion,
isTestMode,
refreshOptions,
selectBatch,
leaveTestMode,
reset,
};
});
export default useTestBatchStore;
@@ -15,6 +15,7 @@ export const useUserStore = defineStore("user", () => {
const id = ref(storage.get(constant.id));
const name = ref(storage.get(constant.name));
const avatar = ref(storage.get(constant.avatar));
const admin = ref(storage.get(constant.admin) === true || storage.get(constant.admin) === "true");
const roles = ref(storage.get(constant.roles));
const permissions = ref(storage.get(constant.permissions));
@@ -33,6 +34,10 @@ export const useUserStore = defineStore("user", () => {
avatar.value = val;
storage.set(constant.avatar, val);
};
const SET_ADMIN = (val) => {
admin.value = val === true;
storage.set(constant.admin, admin.value);
};
const SET_ROLES = (val) => {
roles.value = val;
storage.set(constant.roles, val);
@@ -81,6 +86,7 @@ export const useUserStore = defineStore("user", () => {
} else {
SET_ROLES(["ROLE_DEFAULT"]);
}
SET_ADMIN(user.admin === true);
SET_ID(userid);
SET_NAME(username);
SET_AVATAR(avatar);
@@ -115,6 +121,7 @@ export const useUserStore = defineStore("user", () => {
id,
name,
avatar,
admin,
roles,
permissions,
SET_AVATAR,
@@ -2,8 +2,11 @@ const constant = {
avatar: "user_avatar",
id: "user_id",
name: "user_name",
admin: "user_admin",
roles: "user_roles",
permissions: "user_permissions",
testBatchId: "test_batch_id",
testBatchName: "test_batch_name",
};
export default constant;
@@ -0,0 +1,45 @@
export function normalizeScanValue(value) {
const normalizedValue = String(value ?? "").trim();
if (!normalizedValue) {
throw new Error("二维码不能为空");
}
if (normalizedValue.length > 128) {
throw new Error("二维码长度不能超过 128 个字符");
}
return normalizedValue;
}
export function isDuplicateScan(
value,
lastValue,
scannedAt,
lastScannedAt,
duplicateWindowMs,
) {
const elapsed = scannedAt - lastScannedAt;
return (
value === lastValue &&
elapsed >= 0 &&
elapsed <= duplicateWindowMs
);
}
export function createIdempotencyKey(
cryptoProvider =
typeof globalThis === "undefined" ? undefined : globalThis.crypto,
) {
if (typeof cryptoProvider?.randomUUID === "function") {
return cryptoProvider.randomUUID();
}
if (typeof cryptoProvider?.getRandomValues !== "function") {
throw new Error("当前环境不支持安全随机数,请使用 HTTPS 或 localhost 访问系统");
}
const bytes = cryptoProvider.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (byte) =>
byte.toString(16).padStart(2, "0"),
).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
+56 -5
View File
@@ -1,6 +1,8 @@
import config from "@/config";
import { getToken } from "@/utils/auth";
import errorCode from "@/utils/errorCode";
import constant from "@/utils/constant";
import storage from "@/utils/storage";
import { useUserStore } from "@/store/modules/user";
import { toast, showConfirm, tansParams } from "@/utils/common";
import {
@@ -14,6 +16,37 @@ import {
let timeout = 10000;
const baseUrl = config.baseUrl;
let testBatchRecoveryStarted = false;
function getResponseBody(payload) {
return payload?.data && typeof payload.data === "object" ? payload.data : payload || {};
}
function getResponseData(payload) {
const body = getResponseBody(payload);
return body.data && typeof body.data === "object" ? body.data : body;
}
function isTestBatchUnavailable(payload) {
return getResponseData(payload)?.reason === "TEST_BATCH_UNAVAILABLE";
}
function recoverUnavailableTestBatch(payload) {
if (testBatchRecoveryStarted) return;
testBatchRecoveryStarted = true;
const body = getResponseBody(payload);
const message = body.msg || "当前测试批次不可用,已切回正式数据模式";
storage.remove(constant.testBatchId);
storage.remove(constant.testBatchName);
if (typeof uni.$emit === "function") {
uni.$emit("test-batch-unavailable", message);
}
uni.showToast({ title: message, icon: "none", duration: 3500 });
uni.reLaunch({ url: "/pages/work/index" });
setTimeout(() => {
testBatchRecoveryStarted = false;
}, 1000);
}
const request = async (config) => {
// 是否需要设置 token
@@ -23,6 +56,10 @@ const request = async (config) => {
if (getToken() && !isToken) {
config.header["Authorization"] = "Bearer " + getToken();
}
const testBatchId = storage.get(constant.testBatchId);
if (getToken() && testBatchId) {
config.header["X-Test-Batch-Id"] = String(testBatchId);
}
try {
config = await encryptTransportRequest(config);
@@ -55,8 +92,17 @@ const request = async (config) => {
return;
}
const code = res.data.code || 200;
const msg = errorCode[code] || res.data.msg || errorCode["default"];
if (isTestBatchUnavailable(res)) {
recoverUnavailableTestBatch(res);
const error = new Error(getResponseBody(res).msg || "当前测试批次不可用");
error.response = res;
reject(error);
return;
}
const body = getResponseBody(res);
const code = body.code || 200;
const msg = errorCode[code] || body.msg || errorCode["default"];
if (code === 401) {
showConfirm(
"登录状态已过期,您可以继续留在该页面,或者重新登录?",
@@ -81,7 +127,7 @@ const request = async (config) => {
error.response = res;
reject(error);
} else {
resolve(res.data);
resolve(body);
}
} catch (error) {
reject(error);
@@ -100,10 +146,15 @@ const request = async (config) => {
return request(config);
}
if (isTestBatchUnavailable(error?.response)) {
recoverUnavailableTestBatch(error.response);
}
const response = error.response;
const responseStatus = response?.status ?? response?.statusCode;
const responseCode = response?.data?.code;
const responseMsg = response?.data?.msg;
const responseBody = getResponseBody(response);
const responseCode = responseBody?.code;
const responseMsg = responseBody?.msg;
if (responseMsg) {
uni.showToast({
title: responseMsg,
@@ -8,8 +8,11 @@ let storageNodeKeys = [
constant.avatar,
constant.id,
constant.name,
constant.admin,
constant.roles,
constant.permissions,
constant.testBatchId,
constant.testBatchName,
];
const storage = {
@@ -0,0 +1,310 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
const appRoot = fileURLToPath(new URL("..", import.meta.url));
const apiContracts = {
"src/api/business/inbound.js": [
["listInbound", "'/business/inbound/list'", "get"],
["getInboundWarehouseOptions", "'/business/inbound/warehouse-options'", "get"],
["scanInboundPallet", "'/business/inbound/scan/' + qrToken", "get"],
["getInbound", "'/business/inbound/' + inboundId", "get"],
["addInbound", "'/business/inbound'", "post"],
["updateInbound", "'/business/inbound'", "put"],
["confirmInbound", "'/business/inbound/' + inboundId + '/confirm'", "put"],
],
"src/api/business/filling.js": [
["listFilling", "'/business/filling/list'", "get"],
["getFillingStrainOptions", "'/business/filling/strain-options'", "get"],
["scanFillingPallet", "'/business/filling/scan/' + qrToken", "get"],
["getFilling", "'/business/filling/' + fillingId", "get"],
["addFilling", "'/business/filling'", "post"],
],
"src/api/business/shipment.js": [
["listShipment", "'/business/shipment/list'", "get"],
["getShipmentWarehouseOptions", "'/business/shipment/warehouse-options'", "get"],
["getShipmentSiteOptions", "'/business/shipment/site-options'", "get"],
["scanShipmentPallet", "'/business/shipment/scan/' + qrToken", "get"],
["getShipment", "'/business/shipment/' + shipmentId", "get"],
["addShipment", "'/business/shipment'", "post"],
["updateShipment", "'/business/shipment'", "put"],
["confirmShipment", "'/business/shipment/' + shipmentId + '/confirm'", "put"],
],
"src/api/business/usage.js": [
["listUsage", "'/business/usage/list'", "get"],
["getUsageLineOptions", "'/business/usage/line-options'", "get"],
["scanUsagePallet", "'/business/usage/scan/' + qrToken", "get"],
["getUsage", "'/business/usage/' + usageId", "get"],
["addUsage", "'/business/usage'", "post"],
],
"src/api/business/return.js": [
["listReturn", "'/business/return/list'", "get"],
["getReturnWarehouseOptions", "'/business/return/warehouse-options'", "get"],
["scanReturnPallet", "'/business/return/scan/' + qrToken", "get"],
["requestReturn", "'/business/return/request'", "post"],
["getReturn", "'/business/return/' + returnId", "get"],
["confirmReturn", "'/business/return/' + returnId + '/confirm'", "put"],
],
};
function sourceForFunction(source, functionName) {
const match = source.match(
new RegExp(`export function ${escapeRegExp(functionName)}\\s*\\(`),
);
const start = match?.index ?? -1;
assert.notEqual(start, -1, `${functionName} 导出不存在`);
const end = source.indexOf("\n}", start);
return source.slice(start, end === -1 ? source.length : end + 2);
}
test("五个 PDA API 模块使用现有业务路径和 HTTP 方法", async (t) => {
for (const [relativePath, contracts] of Object.entries(apiContracts)) {
await t.test(relativePath, async () => {
const source = await readFile(path.join(appRoot, relativePath), "utf8");
for (const [functionName, url, method] of contracts) {
const functionSource = sourceForFunction(source, functionName);
const normalizedFunctionSource = functionSource.replaceAll("'", '"');
const normalizedUrl = url.replaceAll("'", '"');
assert.match(
normalizedFunctionSource,
new RegExp(`url:\\s*${escapeRegExp(normalizedUrl)}`),
);
assert.match(functionSource, new RegExp(`method:\\s*["']${method}["']`));
}
});
}
});
test("扫码值只去除首尾空白并拒绝空值", async () => {
const { normalizeScanValue } = await importSource("src/utils/palletScan.js");
assert.equal(normalizeScanValue(" QR-001\n"), "QR-001");
assert.throws(() => normalizeScanValue(" \n\t "), /二维码不能为空/);
});
test("同一扫码值只在短时窗口内被识别为重复", async () => {
const { isDuplicateScan } = await importSource("src/utils/palletScan.js");
assert.equal(isDuplicateScan("QR-001", "QR-001", 1200, 1000, 500), true);
assert.equal(isDuplicateScan("QR-001", "QR-002", 1200, 1000, 500), false);
assert.equal(isDuplicateScan("QR-001", "QR-001", 1801, 1000, 500), false);
});
test("幂等键优先使用 randomUUID,回退时使用 UUID v4 安全随机字节", async () => {
const { createIdempotencyKey } = await importSource("src/utils/palletScan.js");
const expected = "12345678-1234-4123-8123-123456789abc";
assert.equal(createIdempotencyKey({ randomUUID: () => expected }), expected);
assert.equal(
createIdempotencyKey({
getRandomValues(bytes) {
bytes.fill(0);
return bytes;
},
}),
"00000000-0000-4000-8000-000000000000",
);
});
test("PalletScanInput 使用 HID 回车提交并由 loading 锁定输入", async () => {
const source = await readFile(
path.join(appRoot, "src/components/business/PalletScanInput.vue"),
"utf8",
);
assert.match(source, /@confirm=["']handleSubmit["']/);
assert.match(source, /normalizeScanValue/);
assert.match(source, /isDuplicateScan/);
assert.match(source, /emit\(["']scan["']/);
assert.match(source, /emit\(["']invalid["']/);
assert.match(source, /emit\(["']duplicate["']/);
assert.match(source, /loading/);
assert.doesNotMatch(source, /uni\.scanCode|camera|摄像头/);
});
test("PDA 路由和工作台入口覆盖五类现场业务", async () => {
const pages = JSON.parse(
await readFile(path.join(appRoot, "src/pages.json"), "utf8"),
);
const pagePaths = new Set(pages.pages.map((page) => page.path));
for (const pagePath of [
"pages/business/inbound/index",
"pages/business/filling/index",
"pages/business/shipment/index",
"pages/business/usage/index",
"pages/business/return/index",
]) {
assert.equal(pagePaths.has(pagePath), true, `${pagePath} 路由缺失`);
}
const workSource = await readFile(
path.join(appRoot, "src/pages/work/index.vue"),
"utf8",
);
assert.match(workSource, /hasPermiOr/);
assert.match(workSource, /business:inbound/);
assert.match(workSource, /business:filling/);
assert.match(workSource, /business:shipment/);
assert.match(workSource, /business:usage/);
assert.match(workSource, /business:return/);
assert.match(workSource, /pages\/business\/inbound\/index/);
assert.match(workSource, /pages\/business\/filling\/index/);
assert.match(workSource, /pages\/business\/shipment\/index/);
assert.match(workSource, /pages\/business\/usage\/index/);
assert.match(workSource, /pages\/business\/return\/index/);
});
test("PDA 测试模式附加批次请求头并在五个业务页持续提示", async () => {
const requestSource = await readFile(
path.join(appRoot, "src/utils/request.js"),
"utf8",
);
assert.match(requestSource, /X-Test-Batch-Id/);
assert.match(requestSource, /TEST_BATCH_UNAVAILABLE/);
assert.match(requestSource, /uni\.\$emit\(["']test-batch-unavailable["']/);
assert.match(requestSource, /reLaunch\(\{\s*url:\s*["']\/pages\/work\/index["']/);
for (const page of ["inbound", "filling", "shipment", "usage", "return"]) {
const source = await readFile(
path.join(appRoot, `src/pages/business/${page}/index.vue`),
"utf8",
);
assert.match(source, /TestBatchBanner/);
}
});
test("PDA 不再暴露入库和发货草稿物理删除", async () => {
for (const file of ["src/api/business/inbound.js", "src/api/business/shipment.js"]) {
const source = await readFile(path.join(appRoot, file), "utf8");
assert.doesNotMatch(source, /delInbound|delShipment|method:\s*["']delete["']/);
}
for (const page of ["inbound", "shipment"]) {
const source = await readFile(
path.join(appRoot, `src/pages/business/${page}/index.vue`),
"utf8",
);
assert.doesNotMatch(source, /deleteDraft|deleteLoading|business:(?:inbound|shipment):remove/);
}
});
test("登录页只预填账号,不把管理员密码写入源码", async () => {
const source = await readFile(path.join(appRoot, "src/pages/login.vue"), "utf8");
assert.match(source, /username:\s*["']admin["']/);
assert.match(source, /password:\s*["']["']/);
assert.doesNotMatch(source, /password:\s*["'][^"']+["']/);
});
test("PDA 后端地址支持 VITE_APP_BASE_API 并保留本地默认值", async () => {
const source = await readFile(path.join(appRoot, "src/config.js"), "utf8");
assert.match(source, /import\.meta\.env\.VITE_APP_BASE_API/);
assert.match(source, /VITE_APP_BASE_API\s*\|\|\s*["']http:\/\/localhost:9099["']/);
});
test("入库页面遵循 UNSTOCKED 扫码、草稿和幂等确认契约", async () => {
const source = await readFile(
path.join(appRoot, "src/pages/business/inbound/index.vue"),
"utf8",
);
assert.match(source, /PalletScanInput/);
assert.match(source, /scanInboundPallet/);
assert.match(source, /UNSTOCKED/);
assert.match(source, /warehouseId/);
assert.match(source, /palletIds/);
assert.match(source, /getInbound\(/);
assert.match(source, /updateInbound/);
assert.match(source, /confirmInbound/);
assert.match(source, /idempotencyKey/);
assert.match(source, /business:inbound:(?:query|add|edit|confirm)/);
});
test("发货页面遵循来源仓库、目标现场和整单确认契约", async () => {
const source = await readFile(
path.join(appRoot, "src/pages/business/shipment/index.vue"),
"utf8",
);
assert.match(source, /PalletScanInput/);
assert.match(source, /scanShipmentPallet/);
assert.match(source, /FILLED_IN_WAREHOUSE/);
assert.match(source, /remainingPackages/);
assert.match(source, /sourceWarehouseId/);
assert.match(source, /targetSiteId/);
assert.match(source, /palletIds/);
assert.match(source, /getShipment\(/);
assert.match(source, /updateShipment/);
assert.match(source, /confirmShipment/);
assert.match(source, /idempotencyKey/);
assert.match(source, /business:shipment:(?:query|add|edit|confirm)/);
});
test("装填页面遵循空托盘状态和 FillingCreateModel 字段契约", async () => {
const source = await readFile(
path.join(appRoot, "src/pages/business/filling/index.vue"),
"utf8",
);
assert.match(source, /PalletScanInput/);
assert.match(source, /scanFillingPallet/);
assert.match(source, /EMPTY_IN_WAREHOUSE/);
for (const field of [
"qrToken",
"warehouseId",
"strainId",
"batchNo",
"packageSpec",
"initialPackages",
"idempotencyKey",
]) {
assert.match(source, new RegExp(field));
}
assert.match(source, /addFilling/);
assert.match(source, /business:filling:(?:query|add)/);
});
test("现场使用页面遵循产线绑定、余量和使用结果契约", async () => {
const source = await readFile(
path.join(appRoot, "src/pages/business/usage/index.vue"),
"utf8",
);
assert.match(source, /PalletScanInput/);
assert.match(source, /scanUsagePallet/);
assert.match(source, /AT_SITE_UNUSED/);
assert.match(source, /AT_SITE_PARTIAL/);
assert.match(source, /currentLineId/);
assert.match(source, /getUsageLineOptions/);
assert.match(source, /usedPackages/);
assert.match(source, /remainingPackages/);
assert.match(source, /flowState/);
assert.match(source, /addUsage/);
assert.match(source, /idempotencyKey/);
assert.match(source, /business:usage:(?:query|add)/);
});
test("退回页面实现申请与确认两阶段的集合和幂等契约", async () => {
const source = await readFile(
path.join(appRoot, "src/pages/business/return/index.vue"),
"utf8",
);
assert.match(source, /PalletScanInput/);
assert.match(source, /scanReturnPallet/);
assert.match(source, /mode:\s*["']request["']/);
assert.match(source, /mode:\s*["']confirm["']/);
assert.match(source, /sourceSiteId/);
assert.match(source, /targetWarehouseId/);
assert.match(source, /palletIds/);
assert.match(source, /scannedPalletIds/);
assert.match(source, /requestReturn/);
assert.match(source, /getReturn\(/);
assert.match(source, /confirmReturn/);
assert.match(source, /sameIdSet/);
assert.match(source, /REQUESTED/);
assert.match(source, /requestIdempotencyKey/);
assert.match(source, /confirmIdempotencyKey/);
assert.match(source, /business:return:(?:query|request|confirm)/);
});
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
async function importSource(relativePath) {
const source = await readFile(path.join(appRoot, relativePath), "utf8");
return import(`data:text/javascript;charset=utf-8,${encodeURIComponent(source)}`);
}