gaoluyang
2026-08-03 75242bbba28ae8902ddf86d33febb497d79e5fc3
银川
1.首页修改
2.ai辅助功能
已修改13个文件
已添加7个文件
已删除1个文件
1279 ■■■■■ 文件已修改
.env 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/crm/saleQuotation/ai/index.ts 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/ai/index.ts 97 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/locales/langs/en-US/page.json 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/locales/langs/zh-CN/page.json 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/@core/ui-kit/layout-ui/src/components/layout-content.vue 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/routes/modules/dashboard.ts 43 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/components/select.vue 4 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/saleQuotation/index.vue 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/saleQuotation/modules/form.vue 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/saleQuotation/modules/item-form.vue 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/saleQuotation/modules/ocr-upload.vue 185 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/workspace/index.vue 260 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pd/archive/modules/detail.vue 164 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/feedback/index.vue 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/modules/ai/predict-delivery.vue 86 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/modules/ai/predict-duration.vue 91 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/modules/ai/predict-material.vue 85 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/modules/ai/predict-risk.vue 90 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/modules/form.vue 31 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
vite.config.ts 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
.env
@@ -1,5 +1,5 @@
# åº”用标题
VITE_APP_TITLE=工厂数字化MOM系统
VITE_APP_TITLE=工业AI智造平台
# åº”用命名空间,用于缓存、store等功能的前缀,确保隔离
VITE_APP_NAMESPACE=yudao-vben-antd
src/api/crm/saleQuotation/ai/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,32 @@
import { requestClient } from '#/api/request';
export namespace CrmSaleQuotationAiApi {
  /** OCR è¯†åˆ«åŽçš„æŠ¥ä»·ç‰©æ–™æ˜Žç»† */
  export interface OcrItemVO {
    itemName?: string;
    itemSpec?: string;
    count?: number;
    quotationPrice?: number;
  }
  /** AI OCR è¯†åˆ«æŠ¥ä»·æ–‡ä»¶å“åº” */
  export interface OcrResultVO {
    name?: string;
    customerName?: string;
    quotationTime?: string;
    validUntil?: string;
    taxRate?: number;
    discountPercent?: number;
    remark?: string;
    items?: OcrItemVO[];
    rawText?: string;
  }
}
/** AI OCR è¯†åˆ«æŠ¥ä»·æ–‡ä»¶ */
export function ocrSaleQuotation(blobId: number) {
  return requestClient.post<CrmSaleQuotationAiApi.OcrResultVO>(
    '/crm/sale-quotation/ai/ocr',
    { blobId },
  );
}
src/api/mes/pro/ai/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,97 @@
import { requestClient } from '#/api/request';
export namespace MesProAiApi {
  // ========== ç‰©æ–™é¢„测 ==========
  export interface MaterialRiskItem {
    itemCode: string;
    itemName: string;
    requiredQuantity: number;
    availableQuantity: number;
    shortageQuantity: number;
    riskLevel: string;
    suggestion: string;
  }
  export interface PredictMaterialRespVO {
    riskLevel: number;
    reasoning: string;
    riskItems: MaterialRiskItem[];
  }
  // ========== æ—¶é•¿é¢„测 ==========
  export interface ProcessTimeBreakdown {
    processName: string;
    sort: number;
    standardPrepareTime: number;
    standardWaitTime: number;
    standardProductionTime: number;
    predictedProductionTime: number;
  }
  export interface PredictDurationRespVO {
    predictedTotalHours: number;
    standardTotalHours: number;
    reasoning: string;
    keyPoints: string[];
    processBreakdown: ProcessTimeBreakdown[];
  }
  // ========== é£Žé™©é¢„测 ==========
  export interface RiskItem {
    category: string;
    description: string;
    severity: string;
    suggestion: string;
  }
  export interface PredictRiskRespVO {
    overallRiskLevel: number;
    reasoning: string;
    keyPoints: string[];
    risks: RiskItem[];
  }
  // ========== äº¤ä»˜é¢„测 ==========
  export interface PredictDeliveryRespVO {
    onTime: boolean;
    confidence: number;
    reasoning: string;
    delayFactors: string[];
    progressPercent: number;
    remainingDays: number;
  }
}
const BASE = '/mes/pro/ai';
/** AI ç‰©æ–™çŸ­ç¼ºé¢„测 */
export function predictMaterial(workOrderId: number) {
  return requestClient.post<MesProAiApi.PredictMaterialRespVO>(
    `${BASE}/predict-material`,
    { workOrderId },
  );
}
/** AI ç”Ÿäº§æ—¶é•¿é¢„测 */
export function predictDuration(workOrderId: number) {
  return requestClient.post<MesProAiApi.PredictDurationRespVO>(
    `${BASE}/predict-duration`,
    { workOrderId },
  );
}
/** AI ç”Ÿäº§é£Žé™©é¢„测 */
export function predictRisk(workOrderId: number) {
  return requestClient.post<MesProAiApi.PredictRiskRespVO>(
    `${BASE}/predict-risk`,
    { workOrderId },
  );
}
/** AI æŒ‰æ—¶äº¤ä»˜é¢„测 */
export function predictDelivery(workOrderId: number) {
  return requestClient.post<MesProAiApi.PredictDeliveryRespVO>(
    `${BASE}/predict-delivery`,
    { workOrderId },
  );
}
src/locales/langs/en-US/page.json
@@ -8,8 +8,8 @@
    "profile": "Profile"
  },
  "dashboard": {
    "title": "Dashboard",
    "analytics": "Analytics",
    "title": "Home",
    "analytics": "Home",
    "workspace": "Workspace"
  },
  "action": {
src/locales/langs/zh-CN/page.json
@@ -8,8 +8,8 @@
    "profile": "个人中心"
  },
  "dashboard": {
    "title": "概览",
    "analytics": "分析页",
    "title": "首页",
    "analytics": "首页",
    "workspace": "工作台"
  },
  "action": {
src/packages/@core/ui-kit/layout-ui/src/components/layout-content.vue
@@ -56,7 +56,7 @@
</script>
<template>
  <main ref="contentElement" :style="style" class="relative bg-background-deep">
  <main ref="contentElement" :style="style" class="relative overflow-auto bg-background-deep">
    <Slot :style="overlayStyle">
      <slot name="overlay"></slot>
    </Slot>
src/router/routes/modules/dashboard.ts
@@ -1,46 +1,15 @@
import type { RouteRecordRaw } from 'vue-router';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
  {
    name: 'Analytics',
    path: '/analytics',
    component: () => import('#/views/dashboard/analytics/index.vue'),
    meta: {
      icon: 'lucide:layout-dashboard',
      affixTab: true,
      icon: 'lucide:area-chart',
      order: -1,
      title: $t('page.dashboard.title'),
    },
    name: 'Dashboard',
    path: '/dashboard',
    children: [
      {
        name: 'Workspace',
        path: '/workspace',
        component: () => import('#/views/dashboard/workspace/index.vue'),
        meta: {
          icon: 'carbon:workspace',
          title: $t('page.dashboard.workspace'),
        },
      },
      {
        name: 'Analytics',
        path: '/analytics',
        component: () => import('#/views/dashboard/analytics/index.vue'),
        meta: {
          affixTab: true,
          icon: 'lucide:area-chart',
          title: $t('page.dashboard.analytics'),
        },
      },
    ],
  },
  {
    name: 'Profile',
    path: '/profile',
    component: () => import('#/views/_core/profile/index.vue'),
    meta: {
      icon: 'ant-design:profile-outlined',
      title: $t('ui.widgets.profile'),
      hideInMenu: true,
      title: '首页',
    },
  },
];
src/views/basicData/mdm/components/select.vue
@@ -19,12 +19,14 @@
    disabled?: boolean;
    modelValue?: number;
    placeholder?: string;
    label?: string;
  }>(),
  {
    allowClear: true,
    disabled: false,
    modelValue: undefined,
    placeholder: '请选择物料',
    label: undefined,
  },
);
const emit = defineEmits<{
@@ -36,7 +38,7 @@
const hovering = ref(false); // æ˜¯å¦æ‚¬åœ
const selectedItem = ref<MdmItemApi.Item>(); // å½“前选中物料
const displayLabel = computed(() => selectedItem.value?.name ?? ''); // é€‰æ‹©å™¨å±•示名称
const displayLabel = computed(() => selectedItem.value?.name ?? props.label ?? ''); // é€‰æ‹©å™¨å±•示名称
const showClear = computed(
  () =>
    props.allowClear &&
src/views/crm/saleQuotation/index.vue
@@ -16,8 +16,13 @@
} from '#/api/crm/saleQuotation';
import { $t } from '#/locales';
import type { CrmSaleQuotationAiApi } from '#/api/crm/saleQuotation/ai';
import { nextTick, ref } from 'vue';
import { QUOTATION_STATUS, useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import OcrUpload from './modules/ocr-upload.vue';
const { push } = useRouter();
@@ -25,6 +30,8 @@
  connectedComponent: Form,
  destroyOnClose: true,
});
const ocrUploadRef = ref<InstanceType<typeof OcrUpload>>();
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
@@ -34,6 +41,18 @@
/** åˆ›å»ºæŠ¥ä»·å• */
function handleCreate() {
  formModalApi.setData(null).open();
}
/** AI è¯†åˆ«å½•å…¥ */
function handleOcrCreate() {
  ocrUploadRef.value?.open();
}
/** OCR è¯†åˆ«ç»“果确认 */
function handleOcrSuccess(data: CrmSaleQuotationAiApi.OcrResultVO) {
  nextTick(() => {
    formModalApi.setData({ ocrData: data } as any).open();
  });
}
/** ç¼–辑报价单 */
@@ -127,6 +146,7 @@
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <OcrUpload ref="ocrUploadRef" @success="handleOcrSuccess" />
    <Grid>
      <template #toolbar-tools>
        <TableAction
@@ -138,6 +158,12 @@
              auth: ['crm:sale-quotation:create'],
              onClick: handleCreate,
            },
            {
              label: 'AI è¯†åˆ«å½•å…¥',
              type: 'default',
              auth: ['crm:sale-quotation:create'],
              onClick: handleOcrCreate,
            },
          ]"
        />
      </template>
src/views/crm/saleQuotation/modules/form.vue
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import type { CrmSaleQuotationAiApi } from '#/api/crm/saleQuotation/ai';
import type { CrmSaleQuotationApi } from '#/api/crm/saleQuotation';
import { computed, ref, watch } from 'vue';
@@ -6,7 +7,7 @@
import { useVbenModal } from '#/packages/effects/common-ui/src';
import { erpPriceMultiply } from '#/packages/utils/src';
import { message } from 'ant-design-vue';
import { message, Alert } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
@@ -22,6 +23,7 @@
const emit = defineEmits(['success']);
const formData = ref<CrmSaleQuotationApi.SaleQuotation>();
const itemFormRef = ref();
const ocrCustomerName = ref('');
const getTitle = computed(() => {
  return formData.value?.id
@@ -85,14 +87,52 @@
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      formData.value = undefined;
      ocrCustomerName.value = '';
      return;
    }
    const data = modalApi.getData<CrmSaleQuotationApi.SaleQuotation>();
    const data = modalApi.getData<CrmSaleQuotationApi.SaleQuotation & { ocrData?: CrmSaleQuotationAiApi.OcrResultVO }>();
    if (!data || !data.id) {
      formData.value = {} as CrmSaleQuotationApi.SaleQuotation;
      await formApi.resetForm();
      // æ–°å¢žæ—¶ï¼Œé‡ç½®å¹¶æ·»åŠ ä¸€è¡Œç©ºæ•°æ®
      itemFormRef.value?.resetData();
      // OCR è¯†åˆ«æ•°æ®é¢„å¡«
      if (data?.ocrData) {
        const ocr = data.ocrData;
        await formApi.setValues({
          name: ocr.name,
          quotationTime: ocr.quotationTime,
          validUntil: ocr.validUntil,
          taxRate: ocr.taxRate,
          discountPercent: ocr.discountPercent,
          remark: ocr.remark,
        });
        if (ocr.items?.length) {
          itemFormRef.value?.setData(ocr.items.map((item) => ({
            id: undefined as unknown as number,
            itemId: undefined as unknown as number,
            itemName: item.itemName || '',
            itemCode: '',
            itemBarCode: undefined,
            itemSpecification: item.itemSpec || '',
            itemUnitName: '',
            itemUnitName2: undefined,
            itemUnitName3: undefined,
            itemPrice: undefined as unknown as number,
            quotationPrice: item.quotationPrice || 0,
            count: item.count || 1,
            totalPrice: erpPriceMultiply(item.quotationPrice || 0, item.count || 1) ?? 0,
            taxPercent: undefined,
            taxPrice: undefined,
            remark: undefined,
          })));
        } else {
          itemFormRef.value?.resetData();
        }
        // å­˜å‚¨ OCR è¯†åˆ«çš„客户名称,用于界面提示
        ocrCustomerName.value = ocr.customerName || '';
      } else {
        // æ–°å¢žæ—¶ï¼Œé‡ç½®å¹¶æ·»åŠ ä¸€è¡Œç©ºæ•°æ®
        itemFormRef.value?.resetData();
      }
      return;
    }
    modalApi.lock();
@@ -110,6 +150,14 @@
<template>
  <Modal :title="getTitle" class="w-[80%]">
    <Alert
      v-if="ocrCustomerName"
      type="info"
      show-icon
      class="mx-4 mb-4"
      message="AI è¯†åˆ«åˆ°å®¢æˆ·åç§°ï¼Œè¯·åœ¨å®¢æˆ·é€‰æ‹©å™¨ä¸­æ‰‹åŠ¨åŒ¹é… CRM å®¢æˆ·"
      :description="`识别结果:${ocrCustomerName}`"
    />
    <Form class="mx-4">
      <template #items>
        <ItemForm
src/views/crm/saleQuotation/modules/item-form.vue
@@ -191,6 +191,7 @@
      <MdmItemSelect
        v-if="!disabled"
        v-model:model-value="row.itemId"
        :label="row.itemName"
        placeholder="请选择产品"
        @change="(item) => handleItemChange(item, row)"
      />
src/views/crm/saleQuotation/modules/ocr-upload.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,185 @@
<script lang="ts" setup>
import type { CrmSaleQuotationAiApi } from '#/api/crm/saleQuotation/ai';
import { ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Modal, Button, Upload, Descriptions, Table, Tag, message, Alert } from 'ant-design-vue';
import { ocrSaleQuotation } from '#/api/crm/saleQuotation/ai';
import { uploadFile } from '#/api/system/storage';
defineOptions({ name: 'CrmSaleQuotationOcrUpload' });
const emit = defineEmits<{
  success: [data: CrmSaleQuotationAiApi.OcrResultVO];
}>();
const open = ref(false);
const step = ref(0); // 0=upload, 1=processing, 2=preview
const fileList = ref<any[]>([]);
const ocrResult = ref<CrmSaleQuotationAiApi.OcrResultVO>();
const processingTip = ref('');
const itemColumns = [
  { title: '物料名称', dataIndex: 'itemName', key: 'itemName' },
  { title: '规格型号', dataIndex: 'itemSpec', key: 'itemSpec' },
  { title: '数量', dataIndex: 'count', key: 'count' },
  { title: '单价', dataIndex: 'quotationPrice', key: 'quotationPrice' },
];
function handleOpen() {
  open.value = true;
  step.value = 0;
  fileList.value = [];
  ocrResult.value = undefined;
}
/** è‡ªå®šä¹‰ä¸Šä¼  */
async function handleUpload(options: any) {
  const { file, onSuccess, onError } = options;
  try {
    const result = await uploadFile([file as File]);
    const blobId = result?.[0]?.id;
    if (!blobId) {
      onError(new Error('上传失败'));
      return;
    }
    onSuccess({ blobId }, file);
    // å¼€å§‹ OCR è¯†åˆ«
    step.value = 1;
    processingTip.value = '正在进行 AI OCR è¯†åˆ«ï¼Œè¯·ç¨å€™...';
    const ocrData = await ocrSaleQuotation(blobId);
    // æ£€æŸ¥é”™è¯¯
    if (ocrData.rawText && !ocrData.name && !ocrData.customerName && ocrData.items?.length === 0) {
      // æ‰€æœ‰å­—段为 null,rawText åŒ…含错误提示
      processingTip.value = '';
      step.value = 0;
      message.error(ocrData.rawText);
      return;
    }
    // æ£€æŸ¥æ˜¯å¦å…¨éƒ¨ä¸ºç©º
    const hasData = ocrData.name || ocrData.customerName || ocrData.quotationTime
      || ocrData.validUntil || ocrData.taxRate || ocrData.discountPercent
      || ocrData.remark || (ocrData.items && ocrData.items.length > 0);
    if (!hasData) {
      processingTip.value = '';
      step.value = 0;
      message.warning('未识别到报价数据,请手动录入或重新上传');
      return;
    }
    ocrResult.value = ocrData;
    step.value = 2;
  } catch {
    processingTip.value = '';
    step.value = 0;
    onError(new Error('上传失败'));
  }
}
function handleConfirm() {
  if (ocrResult.value) {
    emit('success', ocrResult.value);
  }
  handleClose();
}
function handleReset() {
  step.value = 0;
  fileList.value = [];
  ocrResult.value = undefined;
  processingTip.value = '';
}
function handleClose() {
  open.value = false;
  handleReset();
}
defineExpose({ open: handleOpen });
</script>
<template>
  <Modal v-model:open="open" title="AI è¯†åˆ«å½•入报价单" width="680px" :footer="null" @cancel="handleClose">
    <!-- Step 0: ä¸Šä¼  -->
    <template v-if="step === 0">
      <Upload.Dragger
        v-model:file-list="fileList"
        :max-count="1"
        :custom-request="handleUpload"
        accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.gif,.bmp,.txt,.csv"
        @remove="handleReset"
      >
        <p class="text-4xl text-gray-400">
          <IconifyIcon icon="ant-design:inbox-outlined" />
        </p>
        <p class="text-base text-gray-500">点击或拖拽报价文件到此区域上传</p>
        <p class="text-sm text-gray-400">
          æ”¯æŒ PDF、Word、Excel、图片、纯文本文件
        </p>
      </Upload.Dragger>
    </template>
    <!-- Step 1: è¯†åˆ«ä¸­ -->
    <template v-else-if="step === 1">
      <div class="flex flex-col items-center gap-4 py-12">
        <a-spin size="large" />
        <span class="text-base text-gray-500">{{ processingTip }}</span>
      </div>
    </template>
    <!-- Step 2: é¢„览结果 -->
    <template v-else-if="step === 2 && ocrResult">
      <div class="mb-4">
        <Descriptions :column="2" bordered size="small">
          <Descriptions.Item v-if="ocrResult.name" label="报价单名称">
            {{ ocrResult.name }}
          </Descriptions.Item>
          <Descriptions.Item v-if="ocrResult.customerName" label="客户名称(需手动匹配)">
            <Tag color="orange">{{ ocrResult.customerName }}</Tag>
          </Descriptions.Item>
          <Descriptions.Item v-if="ocrResult.quotationTime" label="报价日期">
            {{ ocrResult.quotationTime }}
          </Descriptions.Item>
          <Descriptions.Item v-if="ocrResult.validUntil" label="有效期至">
            {{ ocrResult.validUntil }}
          </Descriptions.Item>
          <Descriptions.Item v-if="ocrResult.taxRate != null" label="税率(%)">
            {{ ocrResult.taxRate }}
          </Descriptions.Item>
          <Descriptions.Item v-if="ocrResult.discountPercent != null" label="折扣率(%)">
            {{ ocrResult.discountPercent }}
          </Descriptions.Item>
          <Descriptions.Item v-if="ocrResult.remark" label="备注" :span="2">
            {{ ocrResult.remark }}
          </Descriptions.Item>
        </Descriptions>
      </div>
      <!-- ç‰©æ–™æ˜Žç»† -->
      <div v-if="ocrResult.items?.length" class="mb-4">
        <div class="mb-2 text-sm font-medium">物料明细(需手动匹配实际物料)</div>
        <Table
          :columns="itemColumns"
          :data-source="ocrResult.items"
          :pagination="false"
          bordered
          size="small"
        />
      </div>
      <Alert
        message="客户名称和物料名称仅为文本识别结果,需在表单中手动选择匹配的 CRM å®¢æˆ·å’Œå®žé™…物料。"
        type="info"
        show-icon
        class="mb-4"
      />
      <div class="flex justify-end gap-2">
        <Button @click="handleReset">重新上传</Button>
        <Button type="primary" @click="handleConfirm">确认并填入表单</Button>
      </div>
    </template>
  </Modal>
</template>
src/views/dashboard/workspace/index.vue
ÎļþÒÑɾ³ý
src/views/mes/pd/archive/modules/detail.vue
@@ -3,13 +3,11 @@
import { ref } from 'vue';
import { useVbenModal } from '#/packages/effects/common-ui/src';
import { DICT_TYPE } from '#/packages/constants/src';
import { Descriptions, Table, Tabs } from 'ant-design-vue';
import { Modal, Descriptions, Table, Tabs, message } from 'ant-design-vue';
import { getPdArchiveDetailByProduct } from '#/api/mes/pd/archive';
const open = ref(false);
const loading = ref(false);
const detailList = ref<MesPdArchiveApi.DetailItem[]>([]);
const activeProject = ref('0');
@@ -36,85 +34,93 @@
  },
];
const [Modal, modalApi] = useVbenModal({
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      detailList.value = [];
      activeProject.value = '0';
      return;
    }
    const data = modalApi.getData<{ productCode: number }>();
    loading.value = true;
    try {
      detailList.value = await getPdArchiveDetailByProduct(data.productCode);
      activeProject.value = '0';
    } finally {
      loading.value = false;
    }
  },
});
async function handleOpen(productCode?: string) {
  if (!productCode) {
    message.warning('产品编码为空,无法查看归档');
    return;
  }
  open.value = true;
  detailList.value = [];
  activeProject.value = '0';
  loading.value = true;
  try {
    detailList.value = await getPdArchiveDetailByProduct(productCode);
  } catch {
    message.error('获取归档详情失败');
  } finally {
    loading.value = false;
  }
}
defineExpose({
  open: (productCode: number) => modalApi.setData({ productCode }).open(),
});
function handleClose() {
  open.value = false;
  detailList.value = [];
}
defineExpose({ open: handleOpen });
</script>
<template>
  <Modal class="w-4/5" title="归档详情" :loading="loading">
    <Tabs v-if="detailList.length > 0" v-model:active-key="activeProject" type="card">
      <Tabs.TabPane
        v-for="(detail, index) in detailList"
        :key="String(index)"
        :tab="detail.project.taskName || '项目'"
      >
        <!-- é¡¹ç›®ä¿¡æ¯ -->
        <Descriptions :column="3" bordered size="small" class="mb-4">
          <Descriptions.Item label="任务编码">{{ detail.project.taskCode }}</Descriptions.Item>
          <Descriptions.Item label="任务名称">{{ detail.project.taskName }}</Descriptions.Item>
          <Descriptions.Item label="产品编码">{{ detail.project.productCode }}</Descriptions.Item>
          <Descriptions.Item label="产品名称">{{ detail.project.productName }}</Descriptions.Item>
          <Descriptions.Item label="版本号">{{ detail.project.version }}</Descriptions.Item>
          <Descriptions.Item label="主设计师">{{ detail.project.chiefDesignerNickname }}</Descriptions.Item>
          <Descriptions.Item label="审核人">{{ detail.project.reviewerNickname }}</Descriptions.Item>
          <Descriptions.Item label="计划完成">{{ detail.project.planFinishTime }}</Descriptions.Item>
        </Descriptions>
        <!-- å½’档记录 -->
        <div class="mb-4">
          <h4 class="mb-2 text-sm font-medium">归档记录</h4>
          <Table
            :columns="archiveColumns"
            :data-source="detail.archives"
            :pagination="false"
            bordered
            row-key="id"
            size="small"
          />
        </div>
        <!-- è®¾è®¡èµ„æ–™ -->
        <div>
          <h4 class="mb-2 text-sm font-medium">设计资料</h4>
          <Table
            :columns="documentColumns"
            :data-source="detail.documents"
            :pagination="false"
            bordered
            row-key="id"
            size="small"
          >
            <template #bodyCell="{ column, record }">
              <template v-if="column.key === 'action'">
                <a v-if="record.fileUrl" :href="record.fileUrl" target="_blank" class="text-primary">下载</a>
                <span v-else class="text-gray-400">-</span>
              </template>
            </template>
          </Table>
        </div>
      </Tabs.TabPane>
    </Tabs>
    <div v-else class="py-8 text-center text-gray-400">
      æš‚无归档记录
  <Modal v-model:open="open" title="归档详情" width="80%" :footer="null" @cancel="handleClose">
    <div v-if="loading" class="flex justify-center py-8">
      <a-spin />
    </div>
    <template v-else>
      <Tabs v-if="detailList.length > 0" v-model:active-key="activeProject" type="card">
        <Tabs.TabPane
          v-for="(detail, index) in detailList"
          :key="String(index)"
          :tab="detail.project.taskName || '项目'"
        >
          <!-- é¡¹ç›®ä¿¡æ¯ -->
          <Descriptions :column="3" bordered size="small" class="mb-4">
            <Descriptions.Item label="任务编码">{{ detail.project.taskCode }}</Descriptions.Item>
            <Descriptions.Item label="任务名称">{{ detail.project.taskName }}</Descriptions.Item>
            <Descriptions.Item label="产品编码">{{ detail.project.productCode }}</Descriptions.Item>
            <Descriptions.Item label="产品名称">{{ detail.project.productName }}</Descriptions.Item>
            <Descriptions.Item label="版本号">{{ detail.project.version }}</Descriptions.Item>
            <Descriptions.Item label="主设计师">{{ detail.project.chiefDesignerNickname }}</Descriptions.Item>
            <Descriptions.Item label="审核人">{{ detail.project.reviewerNickname }}</Descriptions.Item>
            <Descriptions.Item label="计划完成">{{ detail.project.planFinishTime }}</Descriptions.Item>
          </Descriptions>
          <!-- å½’档记录 -->
          <div class="mb-4">
            <h4 class="mb-2 text-sm font-medium">归档记录</h4>
            <Table
              :columns="archiveColumns"
              :data-source="detail.archives"
              :pagination="false"
              bordered
              row-key="id"
              size="small"
            />
          </div>
          <!-- è®¾è®¡èµ„æ–™ -->
          <div>
            <h4 class="mb-2 text-sm font-medium">设计资料</h4>
            <Table
              :columns="documentColumns"
              :data-source="detail.documents"
              :pagination="false"
              bordered
              row-key="id"
              size="small"
            >
              <template #bodyCell="{ column, record }">
                <template v-if="column.key === 'action'">
                  <a v-if="record.fileUrl" :href="record.fileUrl" target="_blank" class="text-primary">下载</a>
                  <span v-else class="text-gray-400">-</span>
                </template>
              </template>
            </Table>
          </div>
        </Tabs.TabPane>
      </Tabs>
      <div v-else class="py-8 text-center text-gray-400">
        æš‚无归档记录
      </div>
    </template>
  </Modal>
</template>
src/views/mes/pro/feedback/index.vue
@@ -15,6 +15,7 @@
  exportFeedback,
  getFeedbackPage,
} from '#/api/mes/pro/feedback';
import { getWorkOrder } from '#/api/mes/pro/workorder';
import { $t } from '#/locales';
import { ref } from 'vue';
@@ -94,6 +95,17 @@
async function handleExport() {
  const data = await exportFeedback(await gridApi.formApi.getValues());
  downloadFileFromBlobPart({ fileName: '生产报工.xls', source: data });
}
/** æŸ¥çœ‹å½’æ¡£ */
async function handleArchive(row: MesProFeedbackApi.Feedback) {
  if (!row.workOrderId) return;
  try {
    const workOrder = await getWorkOrder(row.workOrderId);
    archiveDetailRef.value?.open(workOrder.productCode);
  } catch {
    message.error('获取工单信息失败');
  }
}
/** å¿«é€ŸæŠ¥å·¥ */
@@ -213,7 +225,7 @@
            {
              label: '归档',
              type: 'link',
              onClick: () => archiveDetailRef.value?.open(row.itemCode),
              onClick: handleArchive.bind(null, row),
            },
          ]"
        />
src/views/mes/pro/workorder/modules/ai/predict-delivery.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,86 @@
<script lang="ts" setup>
import type { MesProAiApi } from '#/api/mes/pro/ai';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { Alert, Button, message, Progress, Tag } from 'ant-design-vue';
import { predictDelivery } from '#/api/mes/pro/ai';
defineOptions({ name: 'MesProAiPredictDelivery' });
const props = defineProps<{ workOrderId: number }>();
const loading = ref(false);
const result = ref<MesProAiApi.PredictDeliveryRespVO>();
async function handlePredict() {
  loading.value = true;
  result.value = undefined;
  try {
    result.value = await predictDelivery(props.workOrderId);
    modalApi.open();
  } catch {
    message.error('AI åˆ†æžæš‚时不可用,请稍后重试');
  } finally {
    loading.value = false;
  }
}
const [ResultModal, modalApi] = useVbenModal({ footer: false });
</script>
<template>
  <Button :loading="loading" @click="handlePredict">
    <template #icon><IconifyIcon icon="ant-design:check-circle-outlined" /></template>
    AI äº¤ä»˜è¯„ä¼°
  </Button>
  <ResultModal title="AI æŒ‰æ—¶äº¤ä»˜é¢„测" class="w-1/2">
    <template v-if="result">
      <div class="mb-4 flex items-center gap-4">
        <div class="flex items-center gap-2">
          <span class="text-base font-medium">能否按时交付:</span>
          <Tag :color="result.onTime ? 'green' : 'red'">
            {{ result.onTime ? '可按时交付' : '无法按时交付' }}
          </Tag>
        </div>
        <div class="flex items-center gap-2">
          <span class="text-base">置信度:</span>
          <span class="font-medium">{{ result.confidence }}%</span>
        </div>
      </div>
      <div class="mb-4 flex items-center gap-4">
        <div class="flex-1">
          <div class="mb-1 text-sm text-gray-500">
            ç”Ÿäº§è¿›åº¦ {{ result.progressPercent }}%
          </div>
          <Progress :percent="result.progressPercent" :stroke-color="{ '0%': '#108ee9', '100%': '#87d068' }" />
        </div>
        <div class="text-sm text-gray-500">
          å‰©ä½™ {{ result.remainingDays }} å¤©
        </div>
      </div>
      <div class="mb-4 rounded bg-gray-50 p-3 text-sm leading-relaxed">
        {{ result.reasoning }}
      </div>
      <div v-if="!result.onTime && result.delayFactors?.length">
        <div class="mb-2 text-base font-medium">延期因素:</div>
        <Alert
          v-for="(factor, index) in result.delayFactors"
          :key="index"
          type="error"
          show-icon
          class="mb-2"
          :message="factor"
        />
      </div>
    </template>
  </ResultModal>
</template>
src/views/mes/pro/workorder/modules/ai/predict-duration.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,91 @@
<script lang="ts" setup>
import type { MesProAiApi } from '#/api/mes/pro/ai';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { Alert, Button, message, Table, Tag } from 'ant-design-vue';
import { predictDuration } from '#/api/mes/pro/ai';
defineOptions({ name: 'MesProAiPredictDuration' });
const props = defineProps<{ workOrderId: number }>();
const loading = ref(false);
const result = ref<MesProAiApi.PredictDurationRespVO>();
const columns = [
  { title: '工序名称', dataIndex: 'processName', width: 150 },
  { title: '顺序', dataIndex: 'sort', width: 60 },
  { title: '标准准备时间(分)', dataIndex: 'standardPrepareTime', width: 140 },
  { title: '标准等待时间(分)', dataIndex: 'standardWaitTime', width: 140 },
  { title: '标准生产时间(时)', dataIndex: 'standardProductionTime', width: 140 },
  { title: '预测生产时间(时)', dataIndex: 'predictedProductionTime', width: 140 },
];
async function handlePredict() {
  loading.value = true;
  result.value = undefined;
  try {
    result.value = await predictDuration(props.workOrderId);
    modalApi.open();
  } catch {
    message.error('AI åˆ†æžæš‚时不可用,请稍后重试');
  } finally {
    loading.value = false;
  }
}
const [ResultModal, modalApi] = useVbenModal({ footer: false });
</script>
<template>
  <Button :loading="loading" @click="handlePredict">
    <template #icon><IconifyIcon icon="ant-design:clock-circle-outlined" /></template>
    AI æ—¶é•¿é¢„测
  </Button>
  <ResultModal title="AI ç”Ÿäº§æ—¶é•¿é¢„测" class="w-3/5">
    <template v-if="result">
      <div class="mb-4 flex items-center gap-4">
        <div class="flex items-center gap-2">
          <span class="text-base font-medium">预测总时长:</span>
          <Tag color="blue">{{ result.predictedTotalHours }} å°æ—¶</Tag>
        </div>
        <div class="flex items-center gap-2">
          <span class="text-base">标准总时长:</span>
          <span class="text-gray-600">{{ result.standardTotalHours }} å°æ—¶</span>
        </div>
      </div>
      <div class="mb-4 rounded bg-gray-50 p-3 text-sm leading-relaxed">
        {{ result.reasoning }}
      </div>
      <Table
        v-if="result.processBreakdown?.length"
        :columns="columns"
        :data-source="result.processBreakdown"
        :pagination="false"
        row-key="sort"
        size="small"
        bordered
      />
      <div v-if="result.keyPoints?.length" class="mt-4">
        <div class="mb-2 text-base font-medium">关注点:</div>
        <Alert
          v-for="(point, index) in result.keyPoints"
          :key="index"
          type="info"
          show-icon
          class="mb-2"
          :message="point"
        />
      </div>
    </template>
  </ResultModal>
</template>
src/views/mes/pro/workorder/modules/ai/predict-material.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,85 @@
<script lang="ts" setup>
import type { MesProAiApi } from '#/api/mes/pro/ai';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { Button, message, Table, Tag } from 'ant-design-vue';
import { predictMaterial } from '#/api/mes/pro/ai';
defineOptions({ name: 'MesProAiPredictMaterial' });
const props = defineProps<{ workOrderId: number }>();
const loading = ref(false);
const result = ref<MesProAiApi.PredictMaterialRespVO>();
const riskLevelColor: Record<number, string> = { 1: 'green', 2: 'orange', 3: 'red' };
const riskLevelText: Record<number, string> = { 1: '低风险', 2: '中风险', 3: '高风险' };
const itemRiskColor: Record<string, string> = { '充足': 'green', 'ç´§å¼ ': 'orange', '短缺': 'red' };
const columns = [
  { title: '物料编码', dataIndex: 'itemCode', width: 120 },
  { title: '物料名称', dataIndex: 'itemName', width: 150 },
  { title: '需求量', dataIndex: 'requiredQuantity', width: 100 },
  { title: '库存可用量', dataIndex: 'availableQuantity', width: 100 },
  { title: '短缺数量', dataIndex: 'shortageQuantity', width: 100 },
  { title: '风险等级', dataIndex: 'riskLevel', width: 100, key: 'riskLevel' },
  { title: '建议措施', dataIndex: 'suggestion' },
];
async function handlePredict() {
  loading.value = true;
  result.value = undefined;
  try {
    result.value = await predictMaterial(props.workOrderId);
    modalApi.open();
  } catch {
    message.error('AI åˆ†æžæš‚时不可用,请稍后重试');
  } finally {
    loading.value = false;
  }
}
const [ResultModal, modalApi] = useVbenModal({ footer: false });
</script>
<template>
  <Button :loading="loading" @click="handlePredict">
    <template #icon><IconifyIcon icon="ant-design:alert-outlined" /></template>
    AI ç‰©æ–™é¢„测
  </Button>
  <ResultModal title="AI ç‰©æ–™çŸ­ç¼ºé¢„测" class="w-3/5">
    <template v-if="result">
      <div class="mb-4 flex items-center gap-2">
        <span class="text-base font-medium">整体风险等级:</span>
        <Tag :color="riskLevelColor[result.riskLevel]">
          {{ riskLevelText[result.riskLevel] || '-' }}
        </Tag>
      </div>
      <div class="mb-4 rounded bg-gray-50 p-3 text-sm leading-relaxed">
        {{ result.reasoning }}
      </div>
      <Table
        v-if="result.riskItems?.length"
        :columns="columns"
        :data-source="result.riskItems"
        :pagination="false"
        row-key="itemCode"
        size="small"
        bordered
      >
        <template #bodyCell="{ column, text }">
          <template v-if="column.key === 'riskLevel'">
            <Tag :color="itemRiskColor[text] || 'default'">{{ text }}</Tag>
          </template>
        </template>
      </Table>
      <div v-else class="py-4 text-center text-gray-400">无物料风险项</div>
    </template>
  </ResultModal>
</template>
src/views/mes/pro/workorder/modules/ai/predict-risk.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,90 @@
<script lang="ts" setup>
import type { MesProAiApi } from '#/api/mes/pro/ai';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { Alert, Button, message, Tag } from 'ant-design-vue';
import { predictRisk } from '#/api/mes/pro/ai';
defineOptions({ name: 'MesProAiPredictRisk' });
const props = defineProps<{ workOrderId: number }>();
const loading = ref(false);
const result = ref<MesProAiApi.PredictRiskRespVO>();
const riskLevelColor: Record<number, string> = { 1: 'green', 2: 'orange', 3: 'red' };
const riskLevelText: Record<number, string> = { 1: '低风险', 2: '中风险', 3: '高风险' };
const severityColor: Record<string, string> = { '高': 'red', '中': 'orange', '低': 'green' };
async function handlePredict() {
  loading.value = true;
  result.value = undefined;
  try {
    result.value = await predictRisk(props.workOrderId);
    modalApi.open();
  } catch {
    message.error('AI åˆ†æžæš‚时不可用,请稍后重试');
  } finally {
    loading.value = false;
  }
}
const [ResultModal, modalApi] = useVbenModal({ footer: false });
</script>
<template>
  <Button :loading="loading" @click="handlePredict">
    <template #icon><IconifyIcon icon="ant-design:warning-outlined" /></template>
    AI é£Žé™©é¢„测
  </Button>
  <ResultModal title="AI ç”Ÿäº§é£Žé™©é¢„测" class="w-1/2">
    <template v-if="result">
      <div class="mb-4 flex items-center gap-2">
        <span class="text-base font-medium">整体风险等级:</span>
        <Tag :color="riskLevelColor[result.overallRiskLevel]">
          {{ riskLevelText[result.overallRiskLevel] || '-' }}
        </Tag>
      </div>
      <div class="mb-4 rounded bg-gray-50 p-3 text-sm leading-relaxed">
        {{ result.reasoning }}
      </div>
      <div v-if="result.keyPoints?.length" class="mb-4">
        <div class="mb-2 text-base font-medium">关键关注点:</div>
        <Alert
          v-for="(point, index) in result.keyPoints"
          :key="index"
          type="warning"
          show-icon
          class="mb-2"
          :message="point"
        />
      </div>
      <div v-if="result.risks?.length">
        <div class="mb-2 text-base font-medium">风险明细:</div>
        <div
          v-for="(risk, index) in result.risks"
          :key="index"
          class="mb-2 rounded border border-gray-200 p-3"
        >
          <div class="mb-1 flex items-center gap-2">
            <Tag>{{ risk.category }}</Tag>
            <Tag :color="severityColor[risk.severity] || 'default'">
              ä¸¥é‡ç¨‹åº¦ï¼š{{ risk.severity }}
            </Tag>
          </div>
          <div class="text-sm text-gray-700">{{ risk.description }}</div>
          <div class="mt-1 text-sm text-blue-600">{{ risk.suggestion }}</div>
        </div>
      </div>
    </template>
  </ResultModal>
</template>
src/views/mes/pro/workorder/modules/form.vue
@@ -27,6 +27,10 @@
  import { BarcodeDetail } from "#/views/wls/barcode/components";
  import { useFormSchema } from "../data";
  import PredictDelivery from "./ai/predict-delivery.vue";
  import PredictDuration from "./ai/predict-duration.vue";
  import PredictMaterial from "./ai/predict-material.vue";
  import PredictRisk from "./ai/predict-risk.vue";
  import ItemList from "./item-list.vue";
  import ProcessList from "./process-list.vue";
@@ -50,6 +54,12 @@
    () =>
      formType.value === "update" &&
      formData.value?.status === MesProWorkOrderStatusEnum.PREPARE
  );
  const canUseAi = computed(
    () =>
      formData.value?.id &&
      (formData.value?.status === MesProWorkOrderStatusEnum.PREPARE ||
        formData.value?.status === MesProWorkOrderStatusEnum.CONFIRMED)
  );
  const getTitle = computed(() => {
    const isChild = !!formData.value?.parentId;
@@ -351,12 +361,21 @@
      </Tabs>
    </template>
    <template #prepend-footer>
      <div class="flex flex-auto items-center">
        <Button v-if="showMainBtn"
                type="primary"
                @click="modalApi.onConfirm()">
          {{ mainActionText }}
        </Button>
      <div class="flex flex-auto flex-col gap-2">
        <div v-if="canUseAi" class="flex items-center gap-2">
          <PredictMaterial :work-order-id="formData!.id" />
          <PredictDuration :work-order-id="formData!.id" />
          <PredictRisk :work-order-id="formData!.id" />
          <PredictDelivery :work-order-id="formData!.id" />
        </div>
        <div v-if="canUseAi && showMainBtn" class="border-t border-gray-200" />
        <div class="flex items-center">
          <Button v-if="showMainBtn"
                  type="primary"
                  @click="modalApi.onConfirm()">
            {{ mainActionText }}
          </Button>
        </div>
      </div>
    </template>
    <template #append-footer>
vite.config.ts
@@ -55,7 +55,7 @@
          "/admin-api": {
            changeOrigin: true,
            rewrite: path => path.replace(/^\/admin-api/, ""),
            target: "http://192.168.0.226:48080/admin-api",
            target: "http://192.168.0.10:48080/admin-api",
            ws: true,
          },
        },