云
昨天 12250a63a8f65a37451fd49392a3cc90facbceaa
feat(bagCode): 添加袋码批量生成后台任务支持

- 实现幂等键机制防止重复提交
- 集成后台任务轮询和进度展示功能
- 添加任务取消和状态管理逻辑
- 重构表单组件支持异步生成流程
- 新增任务状态枚举和API接口定义
- 优化弹窗交互体验和错误处理机制
已添加1个文件
已修改3个文件
389 ■■■■■ 文件已修改
src/api/mes/pro/bagCode/index.ts 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/bagCode/modules/generate-form.vue 111 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/bagCode/modules/raw-generate-form.vue 101 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/bagCode/modules/use-generate-task.ts 134 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/bagCode/index.ts
@@ -47,6 +47,7 @@
    palletCount?: number; // æ‰˜ç›˜æ•°ï¼ˆ48袋/100袋 å“ç§å¿…填)
    bagCount?: number; // è¢‹ç æ•°ï¼ˆå¨å“ç§å¿…填)
    remark?: string; // å¤‡æ³¨
    requestId?: string; // å¹‚等键:网络重试复用同一个值
  }
  export interface GenerateResult {
@@ -54,7 +55,10 @@
    type?: string; // å“ç§ä¸»è®¡é‡å•位类型(48袋/100袋/吨)
    perPallet?: number; // æ¯æ‰˜è¢‹æ•°ï¼ˆå¨å“ç§ä¸º 0)
    totalCount?: number; // ç”Ÿæˆè¢‹ç æ€»æ•°
    palletIds?: number[]; // è‡ªåŠ¨ç”Ÿæˆçš„æ‰˜ç›˜ç¼–å·åˆ—è¡¨ï¼ˆå¨å“ç§ä¸ºç©ºï¼‰
    palletCount?: number; // æ‰˜ç›˜æ€»æ•°ï¼ˆåŽŸå§‹æ•°æ®ä¸º 0)
    palletIds?: number[]; // è‡ªåŠ¨ç”Ÿæˆçš„æ‰˜ç›˜ç¼–å·åˆ—è¡¨ï¼ˆä»…åŒæ­¥å®Œæˆè¿”å›žï¼Œå¨å“ç§ä¸ºç©ºï¼‰
    async?: boolean; // true=已转后台任务
    taskId?: number; // åŽå°ä»»åŠ¡ç¼–å·ï¼ˆå¼‚æ­¥æ¨¡å¼è¿”å›žï¼‰
  }
  /** ç”ŸæˆåŽŸå§‹è¢‹ç è¯·æ±‚ï¼ˆä»…å“ç§+袋码数量,不关联计量单位类型、不生成托盘) */
@@ -62,6 +66,28 @@
    itemId: number; // å“ç§ï¼ˆç‰©æ–™ï¼‰ç¼–号
    count: number; // è¢‹ç æ•°é‡
    remark?: string; // å¤‡æ³¨
    requestId?: string; // å¹‚等键:网络重试复用同一个值
  }
  /** è¢‹ç ç”Ÿæˆä»»åŠ¡ï¼ˆå¤§æ‰¹é‡ç”Ÿæˆè½¬åŽå°åŽçš„è¿›åº¦ï¼‰ */
  export interface GenerateTask {
    id: number; // ä»»åŠ¡ç¼–å·
    bizType?: string; // PACKAGE(数据包)/ RAW(原始数据)
    status?: number; // 10 æŽ’队中 / 20 ç”Ÿæˆä¸­ / 25 å–消中 / 30 å·²å®Œæˆ / 40 å·²å–消 / 50 å·²å¤±è´¥
    statusName?: string; // çŠ¶æ€åç§°
    progress?: number; // è¿›åº¦ç™¾åˆ†æ¯” 0~100
    uuid?: string; // ç”Ÿæˆç»„ UUID
    itemId?: number; // å“ç§ç¼–号
    itemCode?: string; // å“ç§ç¼–码
    itemName?: string; // å“ç§åç§°
    unitType?: string; // è®¡é‡å•位类型
    perPallet?: number; // æ¯æ‰˜è¢‹æ•°
    palletCount?: number; // æ‰˜ç›˜æ•°
    totalCount?: number; // è¢‹ç æ€»æ•°
    finishedCount?: number; // å·²ç”Ÿæˆè¢‹ç æ•°
    errorMsg?: string; // å¤±è´¥åŽŸå› 
    createTime?: string; // åˆ›å»ºæ—¶é—´
    finishTime?: string; // å®Œæˆ/终止时间
  }
  export interface BindBatchParams {
@@ -155,6 +181,21 @@
  );
}
/** æŸ¥è¯¢è¢‹ç ç”Ÿæˆä»»åŠ¡ï¼ˆå¤§æ‰¹é‡ç”Ÿæˆçš„è¿›åº¦è½®è¯¢ï¼‰ */
export function getBagCodeGenerateTask(id: number) {
  return requestClient.get<MesProBagCodeApi.GenerateTask>(
    '/mes/pro/bag-code/generate-task/get',
    { params: { id } },
  );
}
/** å–消袋码生成任务(停止生成并清理半成品) */
export function cancelBagCodeGenerateTask(id: number) {
  return requestClient.post<boolean>(
    `/mes/pro/bag-code/generate-task/cancel?id=${id}`,
  );
}
/** å½•入生产批次 */
export function bindBagCodeBatch(data: MesProBagCodeApi.BindBatchParams) {
  return requestClient.put('/mes/pro/bag-code/bind-batch', data);
src/views/mes/pro/bagCode/modules/generate-form.vue
@@ -7,12 +7,14 @@
  import { useVbenModal } from "@vben/common-ui";
  import { message } from "ant-design-vue";
  import { Button, message, Progress } from "ant-design-vue";
  import { useVbenForm } from "#/adapter/form";
  import { generateBagCodes } from "#/api/mes/pro/bagCode";
  import { getUnit } from "#/api/mdm/unit";
  import { MdmItemSelect } from "#/views/basicData/mdm/components";
  import { isFinalTaskStatus, useGenerateTask } from "./use-generate-task";
  defineOptions({ name: "MesProBagCodeGenerateForm" });
@@ -25,6 +27,15 @@
  const unitTypeError = ref(false);
  const palletCountVal = ref<number | null>(null);
  const bagCountVal = ref<number | null>(null);
  /** å¹‚等键:打开弹窗时生成,网络重试复用;失败后重新生成 */
  const requestId = ref("");
  function newRequestId() {
    return `${Date.now()}-${Math.random().toString(36).slice(2)}-${Math.random()
      .toString(36)
      .slice(2)}`;
  }
  /** æ¢ç®—/配置提示文案 */
  const tipText = computed(() => {
@@ -155,17 +166,20 @@
    async onConfirm() {
      const { valid } = await formApi.validate();
      if (!valid) return;
      if (task.value && !isFinalTaskStatus(task.value.status)) return;
      if (unitTypeError.value) {
        message.warning(tipText.value);
        return;
      }
      modalApi.lock();
      let asyncStarted = false;
      try {
        const values =
          (await formApi.getValues()) as MesProBagCodeApi.GenerateParams;
        const data: MesProBagCodeApi.GenerateParams = {
          itemId: values.itemId,
          remark: values.remark,
          requestId: requestId.value,
        };
        if (isTon.value) {
          data.bagCount = values.bagCount;
@@ -173,22 +187,43 @@
          data.palletCount = values.palletCount;
        }
        const result = await generateBagCodes(data);
        if (result.async && result.taskId) {
          asyncStarted = true;
          modalApi.unlock();
          // å¤§æ‰¹é‡å·²è½¬åŽå°ï¼šä¿æŒå¼¹çª—打开,展示进度并轮询
          startGenerateTask({
            id: result.taskId,
            status: 10,
            statusName: "排队中",
            progress: 0,
            totalCount: result.totalCount,
            finishedCount: 0,
            palletCount: result.palletCount,
            uuid: result.uuid,
          });
          return;
        }
        message.success(
          `已成功生成 ${result.totalCount} æ¡è¢‹ç ` +
            (result.palletIds?.length
              ? `,并自动生成 ${result.palletIds.length} ä¸ªæ‰˜ç›˜`
            (result.palletCount
              ? `,并自动生成 ${result.palletCount} ä¸ªæ‰˜ç›˜`
              : ""),
        );
        emit("success");
        await modalApi.close();
      } finally {
        modalApi.unlock();
        if (!asyncStarted) modalApi.unlock();
      }
    },
    async onOpenChange(isOpen: boolean) {
      if (!isOpen) return;
      if (!isOpen) {
        stopGenerateTaskPolling();
        return;
      }
      modalApi.lock();
      try {
        resetGenerateTask();
        requestId.value = newRequestId();
        unitType.value = "";
        bagsPerPallet.value = null;
        isTon.value = false;
@@ -207,17 +242,67 @@
      }
    },
  });
  const {
    task,
    canceling: generateTaskCanceling,
    cancel: cancelGenerateTask,
    reset: resetGenerateTask,
    stopPolling: stopGenerateTaskPolling,
    start: startGenerateTask,
  } = useGenerateTask(modalApi, {
    onSuccess: () => emit("success"),
    onFailed: () => {
      // å¤±è´¥åŽæ¢æ–°çš„幂等键,允许用户直接重新提交
      requestId.value = newRequestId();
    },
  });
</script>
<template>
  <Modal title="生成袋码数据包" class="w-2/5">
    <Form class="mx-4" />
    <div
      v-if="tipText"
      class="mx-4 mb-2 rounded bg-gray-50 px-3 py-2 text-sm"
      :class="unitTypeError ? 'text-red-500' : 'text-gray-500'"
    >
      {{ tipText }}
    </div>
    <template v-if="task">
      <div class="mx-4 mb-2">
        <p class="mb-1 text-sm">
          ä»»åŠ¡ #{{ task.id }} Â· {{ task.statusName }}
        </p>
        <p class="mb-2 text-sm text-gray-500">
          å·²ç”Ÿæˆ {{ task.finishedCount ?? 0 }} / {{ task.totalCount ?? 0 }} æ¡
        </p>
        <Progress
          :percent="task.progress ?? 0"
          :status="
            task.status === 50
              ? 'exception'
              : task.status === 30
                ? 'success'
                : 'active'
          "
        />
        <p v-if="task.errorMsg" class="mt-2 text-sm text-red-500">
          {{ task.errorMsg }}
        </p>
        <div class="mt-4 flex justify-end">
          <Button
            danger
            size="small"
            :loading="generateTaskCanceling"
            @click="cancelGenerateTask"
          >
            å–消生成
          </Button>
        </div>
      </div>
    </template>
    <template v-else>
      <Form class="mx-4" />
      <div
        v-if="tipText"
        class="mx-4 mb-2 rounded bg-gray-50 px-3 py-2 text-sm"
        :class="unitTypeError ? 'text-red-500' : 'text-gray-500'"
      >
        {{ tipText }}
      </div>
    </template>
  </Modal>
</template>
src/views/mes/pro/bagCode/modules/raw-generate-form.vue
@@ -1,19 +1,30 @@
<script lang="ts" setup>
  import type { VbenFormSchema } from "#/adapter/form";
  import { markRaw } from "vue";
  import { markRaw, ref } from "vue";
  import { useVbenModal } from "@vben/common-ui";
  import { message } from "ant-design-vue";
  import { Button, message, Progress } from "ant-design-vue";
  import { useVbenForm } from "#/adapter/form";
  import { generateRawBagCodes } from "#/api/mes/pro/bagCode";
  import { MdmItemSelect } from "#/views/basicData/mdm/components";
  import { isFinalTaskStatus, useGenerateTask } from "./use-generate-task";
  defineOptions({ name: "MesProBagCodeRawGenerateForm" });
  const emit = defineEmits(["success"]);
  /** å¹‚等键:打开弹窗时生成,网络重试复用;失败后重新生成 */
  const requestId = ref("");
  function newRequestId() {
    return `${Date.now()}-${Math.random().toString(36).slice(2)}-${Math.random()
      .toString(36)
      .slice(2)}`;
  }
  const schema: VbenFormSchema[] = [
    {
@@ -64,23 +75,47 @@
    async onConfirm() {
      const { valid } = await formApi.validate();
      if (!valid) return;
      if (task.value && !isFinalTaskStatus(task.value.status)) return;
      modalApi.lock();
      let asyncStarted = false;
      try {
        const values = await formApi.getValues();
        const result = await generateRawBagCodes({
          itemId: values.itemId,
          count: values.count,
          remark: values.remark,
          requestId: requestId.value,
        });
        if (result.async && result.taskId) {
          asyncStarted = true;
          modalApi.unlock();
          // å¤§æ‰¹é‡å·²è½¬åŽå°ï¼šä¿æŒå¼¹çª—打开,展示进度并轮询
          startGenerateTask({
            id: result.taskId,
            status: 10,
            statusName: "排队中",
            progress: 0,
            totalCount: result.totalCount,
            finishedCount: 0,
            palletCount: result.palletCount,
            uuid: result.uuid,
          });
          return;
        }
        message.success(`已成功生成 ${result.totalCount} æ¡åŽŸå§‹è¢‹ç `);
        emit("success");
        await modalApi.close();
      } finally {
        modalApi.unlock();
        if (!asyncStarted) modalApi.unlock();
      }
    },
    async onOpenChange(isOpen: boolean) {
      if (!isOpen) return;
      if (!isOpen) {
        stopGenerateTaskPolling();
        return;
      }
      resetGenerateTask();
      requestId.value = newRequestId();
      await formApi.setValues({
        itemId: undefined,
        count: undefined,
@@ -88,13 +123,63 @@
      });
    },
  });
  const {
    task,
    canceling: generateTaskCanceling,
    cancel: cancelGenerateTask,
    reset: resetGenerateTask,
    stopPolling: stopGenerateTaskPolling,
    start: startGenerateTask,
  } = useGenerateTask(modalApi, {
    onSuccess: () => emit("success"),
    onFailed: () => {
      // å¤±è´¥åŽæ¢æ–°çš„幂等键,允许用户直接重新提交
      requestId.value = newRequestId();
    },
  });
</script>
<template>
  <Modal title="生成原始袋码" class="w-2/5">
    <Form class="mx-4" />
    <div class="mx-4 mb-2 rounded bg-gray-50 px-3 py-2 text-sm text-gray-500">
      åŽŸå§‹è¢‹ç ä»…éœ€é€‰æ‹©å“ç§å¹¶è¾“å…¥æ•°é‡ï¼Œä¸æ¢ç®—è®¡é‡å•ä½ç±»åž‹ã€ä¸ç”Ÿæˆæ‰˜ç›˜ï¼›åŽç»­å¯æ­£å¸¸å½•å…¥æ‰¹æ¬¡æˆ–ç»‘å®šæ‰˜ç›˜
    </div>
    <template v-if="task">
      <div class="mx-4 mb-2">
        <p class="mb-1 text-sm">
          ä»»åŠ¡ #{{ task.id }} Â· {{ task.statusName }}
        </p>
        <p class="mb-2 text-sm text-gray-500">
          å·²ç”Ÿæˆ {{ task.finishedCount ?? 0 }} / {{ task.totalCount ?? 0 }} æ¡
        </p>
        <Progress
          :percent="task.progress ?? 0"
          :status="
            task.status === 50
              ? 'exception'
              : task.status === 30
                ? 'success'
                : 'active'
          "
        />
        <p v-if="task.errorMsg" class="mt-2 text-sm text-red-500">
          {{ task.errorMsg }}
        </p>
        <div class="mt-4 flex justify-end">
          <Button
            danger
            size="small"
            :loading="generateTaskCanceling"
            @click="cancelGenerateTask"
          >
            å–消生成
          </Button>
        </div>
      </div>
    </template>
    <template v-else>
      <Form class="mx-4" />
      <div class="mx-4 mb-2 rounded bg-gray-50 px-3 py-2 text-sm text-gray-500">
        åŽŸå§‹è¢‹ç ä»…éœ€é€‰æ‹©å“ç§å¹¶è¾“å…¥æ•°é‡ï¼Œä¸æ¢ç®—è®¡é‡å•ä½ç±»åž‹ã€ä¸ç”Ÿæˆæ‰˜ç›˜ï¼›åŽç»­å¯æ­£å¸¸å½•å…¥æ‰¹æ¬¡æˆ–ç»‘å®šæ‰˜ç›˜
      </div>
    </template>
  </Modal>
</template>
src/views/mes/pro/bagCode/modules/use-generate-task.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,134 @@
import type { MesProBagCodeApi } from '#/api/mes/pro/bagCode';
import { onBeforeUnmount, ref } from 'vue';
import { message } from 'ant-design-vue';
import {
  cancelBagCodeGenerateTask,
  getBagCodeGenerateTask,
} from '#/api/mes/pro/bagCode';
/** å¼¹çª—最小接口,避免依赖内部 ModalApi ç±»åž‹ */
interface GenerateModalApi {
  close: () => Promise<void>;
  setState: (state: {
    showCancelButton?: boolean;
    showConfirmButton?: boolean;
  }) => unknown;
}
interface GenerateTaskCallbacks {
  /** ç”ŸæˆæˆåŠŸï¼ˆçŠ¶æ€ 30),调用方刷新列表 */
  onSuccess: () => void;
  /** ç”Ÿæˆå¤±è´¥ï¼ˆçŠ¶æ€ 50),调用方更换幂等键后允许重新提交 */
  onFailed?: () => void;
}
/** ç»ˆæ€ï¼šå·²å®Œæˆ / å·²å–消 / å·²å¤±è´¥ */
const FINAL_STATUSES = new Set([30, 40, 50]);
export function isFinalTaskStatus(status?: number) {
  return status !== undefined && FINAL_STATUSES.has(status);
}
/**
 * å¤§æ‰¹é‡è¢‹ç åŽå°ç”Ÿæˆï¼šè½®è¯¢è¿›åº¦ã€å–消任务,并把弹窗按钮切换为进度展示态。
 */
export function useGenerateTask(
  modalApi: GenerateModalApi,
  callbacks: GenerateTaskCallbacks,
) {
  const task = ref<MesProBagCodeApi.GenerateTask>();
  const canceling = ref(false);
  let timer: ReturnType<typeof setTimeout> | undefined;
  function stopPolling() {
    if (timer) clearTimeout(timer);
    timer = undefined;
  }
  function restoreButtons() {
    modalApi.setState({ showConfirmButton: true, showCancelButton: true });
  }
  /** é‡ç½®åˆ°å¯ç¼–辑态:关闭弹窗、重新打开、失败重试前调用 */
  function reset() {
    stopPolling();
    task.value = undefined;
    canceling.value = false;
    restoreButtons();
  }
  /** è¿›å…¥è¿›åº¦å±•示态:隐藏提交/取消按钮,进度区自行提供取消操作 */
  function start(initial: MesProBagCodeApi.GenerateTask) {
    stopPolling();
    task.value = initial;
    modalApi.setState({ showConfirmButton: false, showCancelButton: false });
    void refresh();
  }
  async function refresh() {
    if (!task.value) return;
    stopPolling();
    try {
      const current = await getBagCodeGenerateTask(task.value.id);
      task.value = current;
      if (isFinalTaskStatus(current.status)) {
        finish(current);
        return;
      }
    } catch {
      // ä¸€æ¬¡æŸ¥è¯¢å¤±è´¥ä¸ç»ˆæ­¢è½®è¯¢ï¼Œé¿å…ç½‘络抖动把进度卡死
    }
    timer = setTimeout(refresh, 2000);
  }
  function finish(current: MesProBagCodeApi.GenerateTask) {
    if (current.status === 30) {
      message.success(
        `生成完成:共 ${current.totalCount ?? 0} æ¡è¢‹ç ` +
          (current.palletCount
            ? `,并自动生成 ${current.palletCount} ä¸ªæ‰˜ç›˜`
            : ''),
      );
      callbacks.onSuccess();
      void modalApi.close();
      return;
    }
    if (current.status === 40) {
      message.info('生成任务已取消,已生成的数据已清理');
      void modalApi.close();
      return;
    }
    // å¤±è´¥ï¼šå›žåˆ°å¯ç¼–辑态,由调用方更换 requestId åŽé‡æ–°æäº¤
    message.error(current.errorMsg ?? '生成失败,请重新提交');
    task.value = undefined;
    restoreButtons();
    callbacks.onFailed?.();
  }
  async function cancel() {
    if (!task.value || canceling.value) return;
    canceling.value = true;
    try {
      await cancelBagCodeGenerateTask(task.value.id);
      message.info('已申请取消,正在停止并清理已生成数据');
    } catch {
      message.error('取消生成失败,请稍后重试');
    } finally {
      canceling.value = false;
    }
  }
  onBeforeUnmount(stopPolling);
  return {
    task,
    canceling,
    cancel,
    reset,
    stopPolling,
    start,
  };
}