云
2026-10-22 d39232a91f7b96d89876ed999b6e223a9bee9b82
feat(ai): 添加AI功能总开关及智能体导入功能

- 在环境变量中添加 VITE_APP_AI_ENABLE 开关配置
- 实现 isAiEnable() 钩子函数用于判断AI功能是否启用
- 添加AI模块路由总开关,关闭时整组路由不注册
- 在质检助手、AI建议等组件中添加AI开关条件渲染
- 实现过滤AI菜单功能,AI关闭时隐藏AI模块菜单
- 在工作订单表单中添加AI功能可用性计算
- 添加智能体导入物料功能模块及API接口
- 实现智能体导入的完整流程:解析、校验、审核、提交
- 添加代理API转发配置到智能体服务端口
- 在基础数据、销售报价、采购发票等页面添加AI相关功能开关控制
已添加2个文件
已修改13个文件
1131 ■■■■■ 文件已修改
.env 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mdm/item/agent.ts 264 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/hooks/src/use-app-config.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/routes/modules/ai.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/store/auth.ts 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/index.vue 94 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/modules/agent-import.vue 610 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/saleQuotation/index.vue 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/index.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/invoice/index.vue 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/modules/form.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/components/ai-assistant.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/components/ai-suggest.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/report/template/designer/index.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
vite.config.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.env
@@ -19,6 +19,9 @@
# æ–‡æ¡£åœ°å€çš„开关
VITE_APP_DOCALERT_ENABLE=true
# AI åŠŸèƒ½æ€»å¼€å…³ï¼šfalse æ—¶éšè—æ‰€æœ‰ AI å…¥å£ï¼ˆAI æ¨¡å—菜单、OCR、预测、智能判定、智能体导入)
VITE_APP_AI_ENABLE=true
# ç™¾åº¦ç»Ÿè®¡
VITE_APP_BAIDU_CODE = e98f2eab6ceb8688bc6d8fc5332ff093
src/api/mdm/item/agent.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,264 @@
import type { InternalAxiosRequestConfig } from '@vben/request';
import { RequestClient } from '@vben/request';
import { useAccessStore } from '@vben/stores';
import { requestClient } from '#/api/request';
/**
 * Python æ™ºèƒ½ä½“服务不在 /admin-api ä¸‹ï¼Œå¼€å‘期由 Vite ä»£ç† /agent-api è½¬å‘到 127.0.0.1:8091。
 * è¯¥æœåŠ¡ç›´æŽ¥è¿”å›žä¸šåŠ¡ JSON,不使用 CommonResult åŒ…装,因此单独建客户端。
 */
const agentRequestClient = new RequestClient({
  baseURL: '/agent-api',
  responseReturn: 'body',
  timeout: 120_000,
});
agentRequestClient.addRequestInterceptor({
  fulfilled: (config: InternalAxiosRequestConfig) => {
    const token = useAccessStore().accessToken;
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
});
export namespace MdmAgentImportApi {
  export type RowAction = 'CREATE' | 'SKIP' | 'BLOCKED';
  export type RowStatus = 'SUCCESS' | 'FAILED' | 'SKIPPED' | 'UNKNOWN';
  export interface Evidence {
    sheet: string;
    row: number;
    column: string;
    raw: string;
  }
  export interface Candidate {
    candidate_id: string;
    source_sha256: string;
    values: Record<string, string>;
    evidence: Record<string, Evidence>;
    issues: string[];
  }
  export interface Issue {
    code?: string;
    field?: string;
    message: string;
    retryable?: boolean;
  }
  export interface Resolved {
    category_id?: number;
    category_name?: string;
    unit_measure_id?: number;
    unit_measure_name?: string;
  }
  export interface PreflightRow {
    candidate_id: string;
    row_no?: number;
    code?: string;
    name?: string;
    action: RowAction;
    resolved?: Resolved;
    blockers?: Issue[];
    warnings?: Issue[];
    target_item_id?: number;
  }
  export interface Preflight {
    preflight_hash: string;
    source: {
      attachment_id: number;
      session_id?: number;
      source_sha256: string;
      proposal_revision?: string;
    };
    policy: {
      update_allowed: boolean;
      mes_sync_allowed: boolean;
      max_rows: number;
    };
    summary: {
      total: number;
      create: number;
      skip: number;
      blocked: number;
    };
    can_commit: boolean;
    side_effects: string[];
    rows: PreflightRow[];
    blocking_items: Issue[];
  }
  export interface Correction {
    candidate_id: string;
    field: 'categoryCode' | 'code' | 'name' | 'specification' | 'unitMeasureCode';
    value: string;
    reason: string;
  }
  export interface ReceiptRow {
    candidate_id: string;
    row_no?: number;
    code?: string;
    name?: string;
    action: 'CREATE' | 'SKIP';
    status: RowStatus;
    target_item_id?: number;
    target_version?: string;
    error_code?: string;
    error_message?: string;
    retryable?: boolean;
  }
  export interface Receipt {
    command_id: string;
    idempotency_key: string;
    status: 'FAILED' | 'PARTIAL_FAILED' | 'RUNNING' | 'SUCCEEDED' | 'UNKNOWN';
    attachment_id: number;
    session_id?: number;
    source_sha256: string;
    preflight_hash: string;
    proposal_hash?: string;
    summary: {
      total: number;
      success: number;
      failed: number;
      skipped: number;
      unknown: number;
    };
    error_code?: string;
    error_message?: string;
    rows: ReceiptRow[];
    create_time?: string;
    completed_time?: string;
  }
  export interface ImportState {
    task_id: string;
    attachment_id: string;
    filename: string;
    source_sha256: string;
    candidates: Candidate[];
    issues: Array<Issue & { candidate_id?: string; candidate_ids?: string[] }>;
    revision: number;
    status: string;
    preflight?: Preflight;
    preflight_hash?: string;
    proposal?: Record<string, unknown>;
    proposal_hash?: string;
    command_id?: string;
    receipt?: Receipt;
    last_error?: string;
  }
  export interface ReviewInterrupt {
    type: string;
    revision: number;
    issues: Array<Issue & { candidate_id?: string; candidate_ids?: string[] }>;
    candidates: Candidate[];
    preflight: {
      hash?: string;
      can_commit: boolean;
      summary?: Preflight['summary'];
      blocking_items: Issue[];
      needs_attention: PreflightRow[];
    };
    last_error?: string;
    notice?: string;
  }
  export interface PublicState {
    state: ImportState;
    interrupts: ReviewInterrupt[];
  }
  export interface ReviewReq {
    expected_revision: number;
    action: 'cancel' | 'confirm' | 'revise';
    corrections?: Correction[];
    expected_preflight_hash?: string;
    expected_proposal_hash?: string;
  }
}
/** åˆ›å»ºè¡¥å½•会话(Java) */
export function createImportSession() {
  return requestClient.post<number>('/agent/bridge/import-sessions');
}
/** ç»‘定附件到补录会话,返回 attachmentId(Java) */
export function bindImportAttachment(sessionId: number, blobId: number) {
  return requestClient.post<number>(
    `/agent/bridge/import-sessions/${sessionId}/attachments`,
    { blobId },
  );
}
/** åˆ›å»ºæ™ºèƒ½ä½“导入任务并触发解析与预检(Python) */
export function startMaterialImport(attachmentId: number) {
  return agentRequestClient.post<MdmAgentImportApi.PublicState>(
    '/v1/material-imports',
    { attachment_id: String(attachmentId) },
  );
}
/** è¯»å–任务当前状态(Python) */
export function getMaterialImportTask(taskId: string) {
  return agentRequestClient.get<MdmAgentImportApi.PublicState>(
    `/v1/material-imports/${taskId}`,
  );
}
/** ä¿®è®¢æˆ–确认(Python) */
export function reviewMaterialImport(
  taskId: string,
  data: MdmAgentImportApi.ReviewReq,
) {
  return agentRequestClient.post<MdmAgentImportApi.PublicState>(
    `/v1/material-imports/${taskId}/review`,
    data,
  );
}
/** æŸ¥è¯¢æäº¤å›žæ‰§ï¼ˆPython) */
export function getMaterialImportReceipt(taskId: string) {
  return agentRequestClient.get<MdmAgentImportApi.Receipt>(
    `/v1/material-imports/${taskId}/receipt`,
  );
}
interface AgentErrorDetail {
  error_code?: string;
  message?: string;
}
/** ä»Ž Python é”™è¯¯å“åº”中提取可读文案 */
export function agentImportErrorMessage(error: unknown): string {
  const err = error as
    | { data?: unknown; message?: string; response?: { data?: unknown } }
    | undefined;
  const body = (err?.response?.data ?? err?.data) as
    | AgentErrorDetail
    | string
    | undefined;
  const detail =
    typeof body === 'string'
      ? body
      : ((body as { detail?: unknown } | undefined)?.detail ?? body);
  if (typeof detail === 'string') {
    return detail;
  }
  const info = detail as AgentErrorDetail | undefined;
  if (info?.message) {
    return info.error_code
      ? `${info.message}(${info.error_code})`
      : info.message;
  }
  return err?.message || '智能体服务请求失败';
}
src/packages/effects/hooks/src/use-app-config.ts
@@ -46,3 +46,8 @@
export function isDocAlertEnable(): boolean {
  return import.meta.env.VITE_APP_DOCALERT_ENABLE !== 'false';
}
/** AI åŠŸèƒ½æ€»å¼€å…³ï¼šå…³é—­åŽéšè—æ‰€æœ‰ AI å…¥å£ï¼ˆæ¨¡å—菜单、OCR、预测、智能判定、智能体导入等) */
export function isAiEnable(): boolean {
  return import.meta.env.VITE_APP_AI_ENABLE !== 'false';
}
src/router/routes/modules/ai.ts
@@ -1,6 +1,9 @@
import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
import { isAiEnable } from '@vben/hooks';
/** AI æ€»å¼€å…³å…³é—­æ—¶æ•´ç»„路由不注册,避免通过地址栏直接访问 */
const routes: RouteRecordRaw[] = isAiEnable() ? [
  {
    path: '/ai',
    name: 'Ai',
@@ -108,6 +111,6 @@
      },
    ],
  },
];
] : [];
export default routes;
src/store/auth.ts
@@ -6,6 +6,7 @@
import { useRouter } from 'vue-router';
import { LOGIN_PATH } from '@vben/constants';
import { isAiEnable } from '@vben/hooks';
import { preferences } from '@vben/preferences';
import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores';
@@ -22,6 +23,22 @@
} from '#/api';
import { $t } from '#/locales';
import { automaticApproval, loadAuditPolicy } from '#/store/audit-policy';
/** AI æ€»å¼€å…³å…³é—­æ—¶ï¼Œè¿žåŒ /ai æ¨¡å—菜单一并隐藏 */
function filterAiMenus<T extends { children?: T[]; path?: string }>(
  menus: T[],
): T[] {
  return menus
    .filter((menu) => {
      const path = menu.path?.replace(/^\//, '');
      return !(path === 'ai' || path?.startsWith('ai/'));
    })
    .map((menu) =>
      menu.children?.length
        ? { ...menu, children: filterAiMenus(menu.children) }
        : menu,
    );
}
export const useAuthStore = defineStore('auth', () => {
  const accessStore = useAccessStore();
@@ -168,7 +185,9 @@
    userStore.setUserInfo(authPermissionInfo.user);
    userStore.setUserRoles(authPermissionInfo.roles);
    // accessStore
    accessStore.setAccessMenus(authPermissionInfo.menus);
    accessStore.setAccessMenus(
      isAiEnable() ? authPermissionInfo.menus : filterAiMenus(authPermissionInfo.menus),
    );
    accessStore.setAccessCodes(authPermissionInfo.permissions);
    return authPermissionInfo;
  }
src/views/basicData/mdm/index.vue
@@ -5,6 +5,8 @@
import { computed, ref } from 'vue';
import { isAiEnable } from '@vben/hooks';
import { confirm, Page, useVbenModal } from '#/packages/effects/common-ui/src';
import { downloadFileFromBlobPart, isEmpty } from '#/packages/utils/src';
@@ -23,6 +25,7 @@
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import AgentImport from './modules/agent-import.vue';
import Form from './modules/form.vue';
import ImportForm from './modules/import-form.vue';
@@ -35,6 +38,11 @@
const [ImportModal, importModalApi] = useVbenModal({
  connectedComponent: ImportForm,
  destroyOnClose: true,
});
const [AgentImportModal, agentImportModalApi] = useVbenModal({
  connectedComponent: AgentImport,
  destroyOnClose: true,
});
@@ -96,6 +104,56 @@
function handleImport() {
  importModalApi.open();
}
/** æ™ºèƒ½ä½“导入物料 */
function handleAgentImport() {
  agentImportModalApi.open();
}
/** å·¥å…·æ æŒ‰é’®ï¼šæ™ºèƒ½ä½“导入受 AI æ€»å¼€å…³æŽ§åˆ¶ */
const toolbarActions = computed(() => [
  {
    label: $t('ui.actionTitle.create', ['物料']),
    type: 'primary' as const,
    icon: ACTION_ICON.ADD,
    auth: ['mdm:item:create'],
    onClick: handleCreate,
  },
  {
    label: $t('ui.actionTitle.import', ['物料']),
    type: 'primary' as const,
    icon: ACTION_ICON.UPLOAD,
    auth: ['mdm:item:import'],
    onClick: handleImport,
  },
  ...(isAiEnable()
    ? [
        {
          label: '智能体导入',
          type: 'primary' as const,
          icon: ACTION_ICON.UPLOAD,
          auth: ['mdm:item:import'],
          onClick: handleAgentImport,
        },
      ]
    : []),
  {
    label: $t('ui.actionTitle.export'),
    type: 'primary' as const,
    icon: ACTION_ICON.DOWNLOAD,
    auth: ['mdm:item:export'],
    onClick: handleExport,
  },
  {
    label: $t('ui.actionTitle.deleteBatch'),
    type: 'primary' as const,
    danger: true,
    icon: ACTION_ICON.DELETE,
    auth: ['mdm:item:delete'],
    disabled: isEmpty(checkedIds),
    onClick: handleDeleteBatch,
  },
]);
/** ç¼–辑物料 */
function handleEdit(row: MdmItemApi.Item) {
@@ -223,42 +281,10 @@
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <ImportModal @success="handleRefresh" />
    <AgentImportModal @success="handleRefresh" />
    <Grid table-title="物料列表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.create', ['物料']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['mdm:item:create'],
              onClick: handleCreate,
            },
            {
              label: $t('ui.actionTitle.import', ['物料']),
              type: 'primary',
              icon: ACTION_ICON.UPLOAD,
              auth: ['mdm:item:import'],
              onClick: handleImport,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['mdm:item:export'],
              onClick: handleExport,
            },
            {
              label: $t('ui.actionTitle.deleteBatch'),
              type: 'primary',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['mdm:item:delete'],
              disabled: isEmpty(checkedIds),
              onClick: handleDeleteBatch,
            },
          ]"
        />
        <TableAction :actions="toolbarActions" />
      </template>
      <template #categoryName="{ row }">
        <Tag v-if="getCategoryColor(row.categoryId)" :color="getCategoryColor(row.categoryId)">{{ getCategoryName(row.categoryId) }}</Tag>
src/views/basicData/mdm/modules/agent-import.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,610 @@
<script lang="ts" setup>
import type { FileType } from 'ant-design-vue/es/upload/interface';
import type { MdmAgentImportApi } from '#/api/mdm/item/agent';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import {
  Alert,
  Button,
  Input,
  message,
  Steps,
  Table,
  Tag,
  Upload,
} from 'ant-design-vue';
import {
  agentImportErrorMessage,
  bindImportAttachment,
  createImportSession,
  getMaterialImportReceipt,
  reviewMaterialImport,
  startMaterialImport,
} from '#/api/mdm/item/agent';
import { uploadFile } from '#/api/system/storage';
defineOptions({ name: 'MdmAgentImport' });
const emit = defineEmits<{ success: [] }>();
const MAX_BYTES = 8 * 1024 * 1024;
const FIELD_KEYS = [
  'code',
  'name',
  'specification',
  'categoryCode',
  'unitMeasureCode',
] as const;
type FieldKey = (typeof FIELD_KEYS)[number];
interface EditableRow {
  candidate_id: string;
  row_no?: number;
  code: string;
  name: string;
  specification: string;
  categoryCode: string;
  unitMeasureCode: string;
  original: Record<string, string>;
  preflight?: MdmAgentImportApi.PreflightRow;
  issues: string[];
}
interface RowMessage {
  type: 'error' | 'warning';
  text: string;
}
const step = ref(1);
const file = ref<File>();
const fileName = ref('');
const processing = ref(false);
const taskId = ref('');
const state = ref<MdmAgentImportApi.ImportState>();
const receipt = ref<MdmAgentImportApi.Receipt>();
const rows = ref<EditableRow[]>([]);
const lastError = ref('');
let sessionId: number | undefined;
let attachmentId: number | undefined;
const preflight = computed(() => state.value?.preflight);
const issues = computed(() => state.value?.issues ?? []);
const canCommit = computed(
  () => !!preflight.value?.can_commit && issues.value.length === 0,
);
const hasChanges = computed(() => corrections.value.length > 0);
const corrections = computed<MdmAgentImportApi.Correction[]>(() => {
  const result: MdmAgentImportApi.Correction[] = [];
  for (const row of rows.value) {
    for (const field of FIELD_KEYS) {
      const before = (row.original[field] ?? '').trim();
      const after = (row[field] ?? '').trim();
      if (before !== after) {
        result.push({
          candidate_id: row.candidate_id,
          field,
          value: after,
          reason: '人工修订',
        });
      }
    }
  }
  return result;
});
const ACTION_LABEL: Record<MdmAgentImportApi.RowAction, string> = {
  BLOCKED: '阻断',
  CREATE: '新建',
  SKIP: '已存在',
};
const ACTION_COLOR: Record<MdmAgentImportApi.RowAction, string> = {
  BLOCKED: 'red',
  CREATE: 'green',
  SKIP: 'orange',
};
const STATUS_LABEL: Record<MdmAgentImportApi.RowStatus, string> = {
  FAILED: '失败',
  SKIPPED: '已跳过',
  SUCCESS: '成功',
  UNKNOWN: '未知',
};
const STATUS_COLOR: Record<MdmAgentImportApi.RowStatus, string> = {
  FAILED: 'red',
  SKIPPED: 'orange',
  SUCCESS: 'green',
  UNKNOWN: 'default',
};
const reviewColumns = [
  { dataIndex: 'row_no', key: 'row_no', title: '行号', width: 64 },
  { dataIndex: 'code', key: 'code', title: '物料编码', width: 150 },
  { dataIndex: 'name', key: 'name', title: '物料名称', width: 180 },
  { dataIndex: 'specification', key: 'specification', title: '规格型号', width: 150 },
  { dataIndex: 'categoryCode', key: 'categoryCode', title: '分类编码', width: 140 },
  {
    dataIndex: 'unitMeasureCode',
    key: 'unitMeasureCode',
    title: '计量单位',
    width: 130,
  },
  { key: 'action', title: '预检动作', width: 100 },
  { key: 'message', title: '校验信息', width: 260 },
];
const receiptColumns = [
  { dataIndex: 'row_no', key: 'row_no', title: '行号', width: 64 },
  { dataIndex: 'code', key: 'code', title: '物料编码', width: 150 },
  { dataIndex: 'name', key: 'name', title: '物料名称', width: 180 },
  { key: 'action', title: '动作', width: 90 },
  { key: 'status', title: '结果', width: 90 },
  { dataIndex: 'target_item_id', key: 'target_item_id', title: '物料 ID', width: 100 },
  { key: 'error', title: '失败原因', width: 220 },
];
function isEditableColumn(key: unknown): key is FieldKey {
  return typeof key === 'string' && (FIELD_KEYS as readonly string[]).includes(key);
}
function rowMessages(row: EditableRow): RowMessage[] {
  return [
    ...(row.preflight?.blockers ?? []).map((item) => ({
      type: 'error' as const,
      text: item.message,
    })),
    ...(row.preflight?.warnings ?? []).map((item) => ({
      type: 'warning' as const,
      text: item.message,
    })),
    ...row.issues.map((text) => ({ type: 'warning' as const, text })),
  ];
}
function buildRows(data: MdmAgentImportApi.ImportState): EditableRow[] {
  const plannedRows = data.preflight?.rows ?? [];
  return (data.candidates ?? [])
    .map((candidate) => {
      const planned = plannedRows.find(
        (item) => item.candidate_id === candidate.candidate_id,
      );
      const values = candidate.values ?? {};
      return {
        candidate_id: candidate.candidate_id,
        row_no: planned?.row_no ?? candidate.evidence?.code?.row,
        code: values.code ?? '',
        name: values.name ?? '',
        specification: values.specification ?? '',
        categoryCode: values.categoryCode ?? '',
        unitMeasureCode: values.unitMeasureCode ?? '',
        original: { ...values },
        preflight: planned,
        issues: candidate.issues ?? [],
      };
    })
    .sort((a, b) => (a.row_no ?? 0) - (b.row_no ?? 0));
}
function applyState(payload: MdmAgentImportApi.PublicState) {
  state.value = payload.state;
  taskId.value = payload.state.task_id;
  lastError.value =
    payload.state.last_error || payload.interrupts?.[0]?.last_error || '';
  receipt.value = payload.state.receipt;
  rows.value = buildRows(payload.state);
  step.value = receipt.value ? 3 : 2;
  syncFooter();
}
function syncFooter() {
  void modalApi.setState({
    cancelText: step.value === 2 ? '取消任务' : '取消',
    confirmText:
      step.value === 1 ? '开始解析' : step.value === 2 ? '确认提交' : '完成',
    showCancelButton: step.value !== 3,
  });
}
function reset() {
  step.value = 1;
  file.value = undefined;
  fileName.value = '';
  processing.value = false;
  taskId.value = '';
  state.value = undefined;
  receipt.value = undefined;
  rows.value = [];
  lastError.value = '';
  sessionId = undefined;
  attachmentId = undefined;
}
function beforeUpload(fileInfo: FileType) {
  const raw = fileInfo as unknown as File;
  const name = raw.name.toLowerCase();
  if (!name.endsWith('.csv') && !name.endsWith('.xlsx')) {
    message.error('仅支持 CSV æˆ– XLSX æ–‡ä»¶');
    return false;
  }
  if (raw.size > MAX_BYTES) {
    message.error('文件不能超过 8 MiB');
    return false;
  }
  file.value = raw;
  fileName.value = raw.name;
  return false;
}
async function startImport() {
  if (!file.value) {
    message.warning('请先选择 CSV/XLSX æ–‡ä»¶');
    return;
  }
  processing.value = true;
  modalApi.lock();
  try {
    sessionId = await createImportSession();
    const blobs = await uploadFile([file.value]);
    const blobId = blobs?.[0]?.id;
    if (!blobId) {
      throw new Error('文件上传失败,未取得 blobId');
    }
    attachmentId = await bindImportAttachment(sessionId, blobId);
    const payload = await startMaterialImport(attachmentId);
    applyState(payload);
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  } finally {
    processing.value = false;
    modalApi.unlock();
  }
}
async function revise() {
  if (!state.value) return;
  if (!hasChanges.value) {
    message.info('未检测到修改');
    return;
  }
  processing.value = true;
  modalApi.lock();
  try {
    const payload = await reviewMaterialImport(taskId.value, {
      action: 'revise',
      corrections: corrections.value,
      expected_revision: state.value.revision,
    });
    applyState(payload);
    message.success('已按修改重新预检,请核对结果');
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  } finally {
    processing.value = false;
    modalApi.unlock();
  }
}
async function confirmSubmit() {
  if (!state.value) return;
  if (hasChanges.value) {
    message.warning('存在未提交的修改,请先点「重新检查」并按新的预检结果确认');
    return;
  }
  if (!canCommit.value) {
    message.warning(lastError.value || '预检仍有阻断项,请修订后重新检查');
    return;
  }
  processing.value = true;
  modalApi.lock();
  try {
    const payload = await reviewMaterialImport(taskId.value, {
      action: 'confirm',
      expected_preflight_hash: state.value.preflight_hash,
      expected_revision: state.value.revision,
    });
    applyState(payload);
    if (receipt.value) {
      message.success('提交完成,请查看回执');
    } else if (lastError.value) {
      message.error(lastError.value);
    }
  } catch (error) {
    const text = agentImportErrorMessage(error);
    message.error(text);
    // æäº¤è¶…æ—¶/网关中断时命令可能已执行,提示用户查回执,避免重复提交
    lastError.value = text;
  } finally {
    processing.value = false;
    modalApi.unlock();
  }
}
async function fetchReceipt() {
  if (!taskId.value) return;
  processing.value = true;
  modalApi.lock();
  try {
    receipt.value = await getMaterialImportReceipt(taskId.value);
    step.value = 3;
    syncFooter();
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  } finally {
    processing.value = false;
    modalApi.unlock();
  }
}
async function handleCancel() {
  if (step.value === 2 && taskId.value) {
    try {
      await reviewMaterialImport(taskId.value, {
        action: 'cancel',
        expected_revision: state.value?.revision ?? 0,
      });
    } catch {
      // å–消失败不影响关闭窗口
    }
  }
  modalApi.close();
}
function finish() {
  if ((receipt.value?.summary.success ?? 0) > 0) {
    emit('success');
  }
  modalApi.close();
}
async function handlePrimary() {
  if (processing.value) return;
  if (step.value === 1) {
    await startImport();
    return;
  }
  if (step.value === 2) {
    await confirmSubmit();
    return;
  }
  finish();
}
const [Modal, modalApi] = useVbenModal({
  onCancel() {
    void handleCancel();
  },
  onClosed() {
    reset();
  },
  onConfirm() {
    void handlePrimary();
  },
  onOpenChange(isOpen) {
    if (isOpen) {
      reset();
      syncFooter();
    }
  },
});
</script>
<template>
  <Modal title="智能体导入物料" class="w-[1080px]">
    <Steps
      :current="step - 1"
      class="mx-4 mb-4"
      size="small"
      :items="[
        { title: '上传文件' },
        { title: '审核确认' },
        { title: '提交回执' },
      ]"
    />
    <div v-if="step === 1" class="mx-4">
      <Upload
        accept=".csv,.xlsx"
        :max-count="1"
        :show-upload-list="false"
        :before-upload="beforeUpload"
      >
        <Button type="primary">选择 CSV / XLSX æ–‡ä»¶</Button>
      </Upload>
      <div v-if="fileName" class="mt-2 text-sm text-gray-600">
        å·²é€‰æ‹©ï¼š{{ fileName }}
      </div>
      <div class="mt-3 text-xs text-gray-500 leading-relaxed">
        <div>文件上限 8 MiB,仅支持 CSV / XLSX。</div>
        <div>
          æµç¨‹ï¼šè§£æžæ¸…单 â†’ Java æ ¡éªŒåˆ†ç±»/单位/编码 â†’ äººå·¥æ ¸å¯¹ â†’ ç¡®è®¤æäº¤ã€‚
        </div>
        <div>
          æœ¬é˜¶æ®µåªæ–°å»ºç‰©æ–™ï¼šç¼–码已存在会跳过,不更新原物料,也不同步 MES。
        </div>
      </div>
    </div>
    <div v-else-if="step === 2" class="mx-4">
      <div class="mb-3 flex flex-wrap items-center gap-x-5 gap-y-1 text-sm">
        <span>总计:{{ preflight?.summary.total ?? rows.length }}</span>
        <span class="text-green-600">
          æ–°å»ºï¼š{{ preflight?.summary.create ?? 0 }}
        </span>
        <span class="text-orange-500">
          è·³è¿‡ï¼š{{ preflight?.summary.skip ?? 0 }}
        </span>
        <span class="text-red-500">
          é˜»æ–­ï¼š{{ preflight?.summary.blocked ?? 0 }}
        </span>
        <span class="text-gray-500">方案版本:{{ state?.revision ?? 0 }}</span>
      </div>
      <Alert
        v-if="lastError"
        type="error"
        :message="lastError"
        show-icon
        class="mb-3"
      />
      <Alert
        v-if="issues.length > 0"
        type="warning"
        show-icon
        class="mb-3"
        message="存在需要处理的问题"
      >
        <template #description>
          <div v-for="(issue, index) in issues" :key="index">
            {{ issue.message }}
          </div>
        </template>
      </Alert>
      <Alert
        v-else-if="preflight && !preflight.can_commit"
        type="warning"
        show-icon
        class="mb-3"
        message="预检仍有阻断项,修正后请点「重新检查」,通过后才能确认提交"
      />
      <Table
        bordered
        size="small"
        :columns="reviewColumns"
        :data-source="rows"
        :pagination="false"
        :row-key="(record: EditableRow) => record.candidate_id"
        :scroll="{ y: 360 }"
      >
        <template #bodyCell="{ column, record }">
          <template v-if="isEditableColumn(column.key)">
            <Input
              v-model:value="record[column.key]"
              size="small"
              :status="
                record.preflight?.blockers?.some(
                  (item: MdmAgentImportApi.Issue) => item.field === column.key,
                )
                  ? 'error'
                  : undefined
              "
            />
            <div
              v-if="
                column.key === 'categoryCode' &&
                record.preflight?.resolved?.category_name
              "
              class="mt-1 text-xs text-gray-400"
            >
              è§£æžä¸ºï¼š{{ record.preflight.resolved.category_name }}
            </div>
            <div
              v-if="
                column.key === 'unitMeasureCode' &&
                record.preflight?.resolved?.unit_measure_name
              "
              class="mt-1 text-xs text-gray-400"
            >
              è§£æžä¸ºï¼š{{ record.preflight.resolved.unit_measure_name }}
            </div>
          </template>
          <template v-else-if="column.key === 'action'">
            <Tag
              v-if="record.preflight"
              :color="
                ACTION_COLOR[record.preflight.action as MdmAgentImportApi.RowAction]
              "
            >
              {{ ACTION_LABEL[record.preflight.action as MdmAgentImportApi.RowAction] }}
            </Tag>
          </template>
          <template v-else-if="column.key === 'message'">
            <div
              v-for="(item, index) in rowMessages(record as EditableRow)"
              :key="index"
              class="text-xs leading-relaxed"
              :class="item.type === 'error' ? 'text-red-500' : 'text-orange-500'"
            >
              {{ item.text }}
            </div>
          </template>
        </template>
      </Table>
      <div class="mt-3 flex flex-wrap items-center gap-2">
        <Button :disabled="processing || !hasChanges" @click="revise">
          é‡æ–°æ£€æŸ¥
        </Button>
        <Button :disabled="processing || !taskId" @click="fetchReceipt">
          æŸ¥è¯¢å›žæ‰§
        </Button>
        <span class="text-xs text-gray-400">
          {{ hasChanges ? '已修改,需重新检查后才能确认' : '确认后提交,已存在编码会跳过' }}
        </span>
      </div>
    </div>
    <div v-else class="mx-4">
      <div class="mb-3 flex flex-wrap items-center gap-x-5 gap-y-1 text-sm">
        <Tag :color="receipt?.status === 'SUCCEEDED' ? 'green' : 'orange'">
          {{ receipt?.status ?? 'UNKNOWN' }}
        </Tag>
        <span>总计:{{ receipt?.summary.total ?? 0 }}</span>
        <span class="text-green-600">
          æˆåŠŸï¼š{{ receipt?.summary.success ?? 0 }}
        </span>
        <span class="text-orange-500">
          è·³è¿‡ï¼š{{ receipt?.summary.skipped ?? 0 }}
        </span>
        <span class="text-red-500">
          å¤±è´¥ï¼š{{ receipt?.summary.failed ?? 0 }}
        </span>
        <span class="text-gray-500">
          æœªçŸ¥ï¼š{{ receipt?.summary.unknown ?? 0 }}
        </span>
      </div>
      <Alert
        v-if="receipt?.error_message"
        type="error"
        :message="receipt.error_message"
        show-icon
        class="mb-3"
      />
      <Table
        bordered
        size="small"
        :columns="receiptColumns"
        :data-source="receipt?.rows ?? []"
        :pagination="false"
        :row-key="(record: MdmAgentImportApi.ReceiptRow) => record.candidate_id"
        :scroll="{ y: 360 }"
      >
        <template #bodyCell="{ column, record }">
          <template v-if="column.key === 'action'">
            <Tag :color="record.action === 'CREATE' ? 'green' : 'orange'">
              {{ record.action === 'CREATE' ? '新建' : '跳过' }}
            </Tag>
          </template>
          <template v-else-if="column.key === 'status'">
            <Tag :color="STATUS_COLOR[record.status as MdmAgentImportApi.RowStatus]">
              {{ STATUS_LABEL[record.status as MdmAgentImportApi.RowStatus] }}
            </Tag>
          </template>
          <template v-else-if="column.key === 'error'">
            <span class="text-xs text-red-500">
              {{ record.error_message ?? '' }}
            </span>
          </template>
        </template>
      </Table>
    </div>
  </Modal>
</template>
src/views/crm/saleQuotation/index.vue
@@ -19,7 +19,9 @@
import type { CrmSaleQuotationAiApi } from '#/api/crm/saleQuotation/ai';
import { nextTick, ref } from 'vue';
import { computed, nextTick, ref } from 'vue';
import { isAiEnable } from '@vben/hooks';
import { QUOTATION_STATUS, useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
@@ -119,6 +121,27 @@
  });
}
/** å·¥å…·æ æŒ‰é’®ï¼šè¯†åˆ«å½•入受 AI æ€»å¼€å…³æŽ§åˆ¶ */
const toolbarActions = computed(() => [
  {
    label: $t('ui.actionTitle.create', ['报价单']),
    type: 'primary' as const,
    icon: ACTION_ICON.ADD,
    auth: ['crm:sale-quotation:create'],
    onClick: handleCreate,
  },
  ...(isAiEnable()
    ? [
        {
          label: '识别录入',
          type: 'default' as const,
          auth: ['crm:sale-quotation:create'],
          onClick: handleOcrCreate,
        },
      ]
    : []),
]);
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
@@ -156,23 +179,7 @@
    <OcrUpload ref="ocrUploadRef" @success="handleOcrSuccess" />
    <Grid>
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.create', ['报价单']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['crm:sale-quotation:create'],
              onClick: handleCreate,
            },
            {
              label: '识别录入',
              type: 'default',
              auth: ['crm:sale-quotation:create'],
              onClick: handleOcrCreate,
            },
          ]"
        />
        <TableAction :actions="toolbarActions" />
      </template>
      <template #name="{ row }">
        <Button type="link" @click="handleDetail(row)">
src/views/dashboard/analytics/index.vue
@@ -3,6 +3,7 @@
import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { isAiEnable } from '@vben/hooks';
import { Col, Row } from 'ant-design-vue';
@@ -83,7 +84,7 @@
    <!-- AI æ™ºèƒ½åˆ†æž -->
    <AiInsights
      v-if="canViewHome && canNavigate('MesProWorkOrder')"
      v-if="canViewHome && canNavigate('MesProWorkOrder') && isAiEnable()"
      :active-order-count="summary.workOrderActiveCount"
      @navigate="handleNavigate"
    />
src/views/erp/purchase/invoice/index.vue
@@ -4,10 +4,11 @@
import type { ErpPurchaseInvoiceAiApi } from '#/api/erp/purchase/invoice/ai';
import { Page, useVbenModal } from '@vben/common-ui';
import { isAiEnable } from '@vben/hooks';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message, Tag } from 'ant-design-vue';
import { nextTick, ref } from 'vue';
import { computed, nextTick, ref } from 'vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
@@ -84,6 +85,34 @@
  }
}
/** å·¥å…·æ æŒ‰é’®ï¼šè¯†åˆ«å½•入受 AI æ€»å¼€å…³æŽ§åˆ¶ */
const toolbarActions = computed(() => [
  {
    label: $t('ui.actionTitle.create', ['来票']),
    type: 'primary' as const,
    icon: ACTION_ICON.ADD,
    auth: ['erp:purchase-invoice:create'],
    onClick: handleCreate,
  },
  ...(isAiEnable()
    ? [
        {
          label: '识别录入',
          type: 'primary' as const,
          auth: ['erp:purchase-invoice:create'],
          onClick: handleOcrCreate,
        },
      ]
    : []),
  {
    label: $t('ui.actionTitle.export'),
    type: 'primary' as const,
    icon: ACTION_ICON.DOWNLOAD,
    auth: ['erp:purchase-invoice:export'],
    onClick: handleExport,
  },
]);
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
@@ -121,30 +150,7 @@
    <OcrUpload ref="ocrUploadRef" @success="handleOcrSuccess" />
    <Grid table-title="来票列表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.create', ['来票']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['erp:purchase-invoice:create'],
              onClick: handleCreate,
            },
            {
              label: '识别录入',
              type: 'primary',
              auth: ['erp:purchase-invoice:create'],
              onClick: handleOcrCreate,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['erp:purchase-invoice:export'],
              onClick: handleExport,
            },
          ]"
        />
        <TableAction :actions="toolbarActions" />
      </template>
      <template #no="{ row }">
        <Button type="link" @click="handleDetail(row)">
src/views/mes/pro/workorder/modules/form.vue
@@ -10,6 +10,7 @@
  import {
    MesProWorkOrderStatusEnum,
  } from "@vben/constants";
  import { isAiEnable } from "@vben/hooks";
  import { Button, message, Tabs } from "ant-design-vue";
@@ -56,6 +57,7 @@
  );
  const canUseAi = computed(
    () =>
      isAiEnable() &&
      formData.value?.id &&
      (formData.value?.status === MesProWorkOrderStatusEnum.PREPARE ||
        formData.value?.status === MesProWorkOrderStatusEnum.CONFIRMED)
src/views/mes/qc/components/ai-assistant.vue
@@ -2,6 +2,7 @@
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { isAiEnable } from '@vben/hooks';
import { Empty, Input, Spin } from 'ant-design-vue';
@@ -43,7 +44,7 @@
</script>
<template>
  <a-button type="default" @click="modalApi.open()">
  <a-button v-if="isAiEnable()" type="default" @click="modalApi.open()">
    è´¨æ£€åŠ©æ‰‹
  </a-button>
src/views/mes/qc/components/ai-suggest.vue
@@ -4,6 +4,7 @@
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { isAiEnable } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
import { Alert, Button, message, Tag } from 'ant-design-vue';
@@ -51,7 +52,12 @@
</script>
<template>
  <Button type="default" :loading="loading" @click="handleSuggest">
  <Button
    v-if="isAiEnable()"
    type="default"
    :loading="loading"
    @click="handleSuggest"
  >
    <template #icon>
      <IconifyIcon icon="ant-design:robot-outlined" />
    </template>
src/views/mes/qc/report/template/designer/index.vue
@@ -7,7 +7,7 @@
import { useAccess } from '@vben/access';
import { Page } from '@vben/common-ui';
import { useTabs } from '@vben/hooks';
import { isAiEnable, useTabs } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
import {
@@ -48,8 +48,8 @@
const { hasAccessByCodes } = useAccess();
/** AI å¯¼å…¥æ˜¯ç‹¬ç«‹æƒé™ç ï¼šèƒ½æ”¹æ¨¡æ¿ä¸ç­‰äºŽèƒ½ç”¨å¤§æ¨¡åž‹è¯†åˆ«æ–‡ä»¶ï¼ˆæ¯æ¬¡è°ƒç”¨éƒ½è¦èŠ±é’±ï¼‰ */
const canAiImport = computed(() =>
  hasAccessByCodes(['qc-report:template:ai-import']),
const canAiImport = computed(
  () => isAiEnable() && hasAccessByCodes(['qc-report:template:ai-import']),
);
const loading = ref(true);
vite.config.ts
@@ -85,6 +85,12 @@
            target: "http://localhost:48080",
            ws: true,
          },
          "/agent-api": {
            changeOrigin: true,
            target: "http://127.0.0.1:8091",
            rewrite: (path) => path.replace(/^\/agent-api/, ""),
            ws: true,
          },
        },
      },
    },