Parcourir la source

[Feat 0000]AI助手增加对dwg文件的支持

wangkeyi il y a 3 jours
Parent
commit
53d5bd59cf

+ 91 - 0
src/views/ventAI/manageAssistent/api/cad.ts

@@ -0,0 +1,91 @@
+// api/cad.ts:DWG 图纸预览所需的「服务端转 SVG」接口。
+//
+// DWG 是专有二进制格式,前端没法本地解析(对比 .dxf 走 lib/dxf.ts 本地渲染),只能把原始文件传给后端转成
+// SVG 再展示。后端给了两个接口,差别只在返回什么:
+//   dwgFileToSVG     —— 只返回生成的 svg 路径,前端自己去静态目录取;
+//   dwgFileToSVGInfo —— 直接返回 svg 正文,不依赖静态目录能否被前端访问。
+import { defHttp } from '/@/utils/http/axios/index';
+import { useGlobSetting } from '/@/hooks/setting';
+
+// 接口地址按后端给的文档写(/Vmodel/ventanalyModel/...);实测「加 /ventanaly-model 服务前缀」和「不加」
+// 两种写法都能打到同一个 handler(见 views/vent/monitorManager/mainFanMonitor/main.api.ts 也是不加前缀的写法)。
+// 请求最终由 uploadFile 拼上网关前缀 /modelreq 发出去,dev 下由 Vite proxy 转发。
+enum Api {
+  dwgToSvg = '/Vmodel/ventanalyModel/dwgFileToSVG',
+  dwgToSvgInfo = '/Vmodel/ventanalyModel/dwgFileToSVGInfo',
+}
+
+/** 转换业务目录:后端据此存放上传的 dwg 与生成的 svg(对应返回里的 temp/dwg、/temp/svg/*.svg) */
+const DWG_BIZ = 'temp/dwg';
+
+export interface DwgToSvgResult {
+  /** 生成的 svg 相对路径,如 /temp/svg/test300K.svg */
+  svgFile: string;
+  /** 上传的 dwg 相对路径,如 temp/dwg/test300K.dwg */
+  dwgFile: string;
+}
+
+export interface DwgToSvgInfoResult extends DwgToSvgResult {
+  /** svg 正文(完整 xml 字符串) */
+  svgInfo: string;
+}
+
+/**
+ * 上传 dwg,返回转好的 svg 路径。
+ * @param file - dwg 文件(文件名会带到后端,用于命名生成物,务必传带名字的 File 而不是裸 Blob)
+ */
+export const dwgToSvg = (file: File): Promise<DwgToSvgResult> => postDwg<DwgToSvgResult>(Api.dwgToSvg, file);
+
+/**
+ * 上传 dwg,返回 svg 正文。
+ * @param file - dwg 文件
+ */
+export const dwgToSvgInfo = (file: File): Promise<DwgToSvgInfoResult> => postDwg<DwgToSvgInfoResult>(Api.dwgToSvgInfo, file);
+
+function postDwg<T>(url: string, file: File): Promise<T> {
+  // 走项目自带的上传方法,**不要**写成 defHttp.post({ data: formData }):
+  // beforeRequestHook 是用 `Object.keys(config.data).length > 0` 判断「有没有 body」的,而 FormData 的
+  // 自有可枚举属性为空,于是它把 FormData 整个换成 params(默认 {})——实测发出去的 body 就是字符串 "{}",
+  // 服务端一个 part 都收不到("Current request is not a multipart request" /
+  // "Required request part 'file' is not present"),只看到 getFile("file") 为 null 后抛出的 NPE。
+  // uploadFile 直接走 axios 实例、不过这个 hook,file 与附加字段(biz)都能正常送达。
+  return defHttp
+    .uploadFile<T>(
+      // uploadFile 内部会把 baseURL 设成网关地址(/modelreq),所以这里传服务前缀之后的完整路径
+      { url, timeout: 120000 }, // 转换是 CPU 密集操作,比普通接口慢得多,单独放宽超时(全局默认 10s)
+      { file, name: 'file', filename: file.name, data: { biz: DWG_BIZ } },
+      { isReturnResponse: true }
+    )
+    .then((res) => unwrapEnvelope<T>(res));
+}
+
+/**
+ * 兜住「业务失败但 HTTP 200」的信封。
+ *
+ * 后端业务失败时返回的是 `{success:false, code:500, result:null}`:此时响应拦截器不会走 reject
+ * (它是按 `data.result || data.success` 进分支的,两者都为假),而是把整个信封当普通数据返回。
+ * 不在这里判一次,调用方就只能看到「返回结果里没有 svgFile」,把「转换失败」误报成「缺字段」。
+ * 成功时拦截器已经把 result 拆出来了(普通对象,没有 success 字段),这里原样放行。
+ */
+function unwrapEnvelope<T>(res: unknown): T {
+  if (!res || typeof res !== 'object' || !('success' in res)) return res as T;
+  const envelope = res as { success?: boolean; code?: number; message?: string; result?: T };
+  if (envelope.success === false || (typeof envelope.code === 'number' && envelope.code !== 200)) {
+    throw new Error(envelope.message || `DWG 转换接口返回 code=${envelope.code}`);
+  }
+  return envelope.result as T;
+}
+
+/**
+ * 把接口返回的 svg 相对路径拼成浏览器可直接访问的地址:`<网关>/sys/common/static/<路径>`。
+ *
+ * 后端返回的路径带前导斜杠(示例 `/temp/svg/test300K.svg`),直接拼接会拼出 `static//temp/...`,
+ * 部分网关会把它当成非法路径,这里统一去掉再拼。
+ * 静态目录不校验 token,所以可以直接交给 <img> 加载(图片标签没法带 X-Access-Token 头)。
+ */
+export const buildStaticFileUrl = (path: string): string => {
+  const base = useGlobSetting().domainUrl || '';
+  return `${base}/sys/common/static/${String(path || '')
+    .trim()
+    .replace(/^\/+/, '')}`;
+};

+ 1 - 0
src/views/ventAI/manageAssistent/api/index.ts

@@ -12,3 +12,4 @@ export * from './skill';
 export * from './schedule';
 export * from './subagent';
 export * from './report';
+export * from './cad';

+ 2 - 2
src/views/ventAI/manageAssistent/components/resource/CadPreview.vue

@@ -24,8 +24,8 @@
    * 右侧面板的 CAD 预览:本地解析 DXF 并在 canvas 上绘制(可滚轮缩放、拖拽平移),不依赖任何后端服务。
    *
    * 当前只处理 .dxf——面板仅在 isDxfFile 命中时才渲染本组件。
-   * .dwg(专有二进制,需服务端转换)与 .mxweb(依赖 mxcad 及其配套服务)暂不预览,
-   * 由 fetchPreviewFile 返回 null 走"回退为下载"的老路径。
+   * .dwg(专有二进制,需上传后端转 SVG)见 components/resource/DwgPreview.vue;
+   * .mxweb(依赖 mxcad 及其配套服务)暂不预览,由 fetchPreviewFile 返回 null 走"回退为下载"的老路径。
    */
   const props = defineProps<{ file: AttachedFile }>();
 

+ 315 - 0
src/views/ventAI/manageAssistent/components/resource/DwgPreview.vue

@@ -0,0 +1,315 @@
+<template>
+  <div class="dwg-preview">
+    <!-- 转换中:上传原始 dwg + 后端转 svg 期间没有可展示的内容 -->
+    <div v-if="loading" class="dwg-hint">{{ loadingText }}</div>
+    <!-- 转换/加载失败:给一次重试入口(重试绕开缓存重新上传转换) -->
+    <div v-else-if="error" class="dwg-hint dwg-hint-error">
+      <span>{{ error }}</span>
+      <a-button size="small" type="link" @click="load(true)">重试</a-button>
+    </div>
+    <div v-else ref="stageRef" class="dwg-stage" @wheel.prevent="onWheel" @mousedown="onMouseDown" @dblclick="resetView">
+      <img class="dwg-svg" :src="svgSrc" :style="svgStyle" alt="DWG 图纸预览" draggable="false" @error="onSvgError" />
+      <!-- 工具栏不吃 mousedown:否则从按钮上按下会连带触发舞台的拖拽平移 -->
+      <div class="dwg-toolbar" @mousedown.stop>
+        <span class="dwg-stat">{{ zoomText }}</span>
+        <a-button size="small" type="link" @click="resetView">适应窗口</a-button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
+  import { getToken } from '/@/utils/auth/index';
+  import { buildStaticFileUrl, dwgToSvg, dwgToSvgInfo } from '/@/views/ventAI/manageAssistent/api';
+  import type { AttachedFile } from '/@/views/ventAI/manageAssistent/types/index';
+
+  /**
+   * 右侧面板的 DWG 预览。
+   *
+   * DWG 是专有二进制格式,前端解析不了(对比 .dxf:明文,见 CadPreview 本地解析渲染),
+   * 只能「取出原始字节 → 上传后端转 SVG → 当作图片展示」。两个接口分工:
+   *   1) dwgFileToSVG:上传 + 转换,拿到生成的 svg 路径(转换结果落在服务端,重开面板不用重转);
+   *   2) dwgFileToSVGInfo:转换 + 直接返回 svg 正文,仅在 svg 内容拿不到/不可用时兜底。
+   *
+   * svg 必须自己取回来造 blob 地址再交给 <img>,不能把静态地址直接塞进 img src:
+   * 静态目录的响应头是 `Content-Type: application/force-download` + `X-Content-Type-Options: nosniff`,
+   * 浏览器据此判定"不是图片"而拒绝渲染(实测 <img> 直接 error,同样的字节换成 blob 地址就能显示)。
+   *
+   * 转换发生在预览面板而不是附件/卡片点击时:附件加进来、AI 给了下载链接,都不代表用户要打开图纸,
+   * 万一不看就白传一次几百 KB 到几 MB 的文件。
+   */
+  const props = defineProps<{ file: AttachedFile }>();
+
+  const stageRef = ref<HTMLElement | null>(null);
+  const loading = ref(false);
+  const loadingText = ref('');
+  const error = ref('');
+  const svgSrc = ref('');
+  const view = ref({ k: 1, tx: 0, ty: 0 });
+
+  const zoomText = computed(() => `${Math.round(view.value.k * 100)}%`);
+  const svgStyle = computed(() => {
+    const { k, tx, ty } = view.value;
+    return { transform: `translate(${tx}px, ${ty}px) scale(${k})` };
+  });
+
+  // 已转换出的 svg 静态地址按文件缓存:面板实例会随标签切换重建(RightPanelTabs 没加 :key),
+  // 同一份图纸关掉再打开很常见,每次重开都重新上传转换一次没有意义——本次会话内转换结果是稳定的。
+  const svgUrlCache = new Map<string, string>();
+
+  // 当前文件的上传体:转 SVG(首次与兜底)都要用,留着避免重复构造
+  let uploadSource: File | null = null;
+  // 交给 <img> 的 blob 地址(svg 正文所在),换文件/卸载时必须释放
+  let svgBlobUrl = '';
+  // 请求序号:换文件或重试后,在途请求的响应回来时状态已经属于另一个文件,必须丢弃
+  let loadToken = 0;
+  // svg 兜底只做一次,否则失败会在两个接口之间来回触发
+  let fallbackTried = false;
+
+  const releaseSvgBlob = () => {
+    if (svgBlobUrl) {
+      URL.revokeObjectURL(svgBlobUrl);
+      svgBlobUrl = '';
+    }
+  };
+
+  const resetView = () => {
+    view.value = { k: 1, tx: 0, ty: 0 };
+  };
+
+  /** 把 svg 正文渲染出来:必须走 blob 地址(原因见文件头注释),空内容按失败处理 */
+  const applySvgText = (raw: string) => {
+    const svg = (raw || '').trim();
+    if (!svg) throw new Error('svg 内容为空');
+    releaseSvgBlob();
+    svgBlobUrl = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }));
+    svgSrc.value = svgBlobUrl;
+    resetView();
+  };
+
+  /** 上传用的 File:本地上传的附件直接用原始文件(字节最保真),远端预览对象只有字节时按原文件名重建(后端用它命名生成物) */
+  const toUploadFile = (file: AttachedFile): File | null => {
+    if (file.originalFile) return file.originalFile;
+    if (file.arrayBuffer) return new File([file.arrayBuffer], file.name || 'preview.dwg', { type: 'application/octet-stream' });
+    return null;
+  };
+
+  const load = async (force = false) => {
+    const token = ++loadToken;
+    const file = props.file;
+    releaseSvgBlob();
+    fallbackTried = false;
+    error.value = '';
+    // 上一次转换可能还停在 loading(请求在途时被换文件/重试抢占),这里统一复位:
+    // 命中缓存的分支不会再把它置回 false,漏了这一行就会永远卡在「正在转换图纸」。
+    loading.value = false;
+    resetView();
+
+    // 缓存的是转换产物地址而非 svg 正文:正文 1MB 上下,多份图纸攒在内存里不值当,
+    // 省下的只该是「重新上传 + 重新转换」这笔真正昂贵的开销。
+    const cachedUrl = force ? '' : svgUrlCache.get(file.id);
+    if (cachedUrl) {
+      svgSrc.value = '';
+      await loadSvgFromUrl(cachedUrl, token);
+      return;
+    }
+
+    svgSrc.value = '';
+    uploadSource = toUploadFile(file);
+    if (!uploadSource) {
+      error.value = '未取到图纸内容,无法预览';
+      return;
+    }
+
+    loading.value = true;
+    loadingText.value = '正在转换图纸,请稍候…';
+    try {
+      const result = await dwgToSvg(uploadSource);
+      if (token !== loadToken) return;
+      const url = result?.svgFile ? buildStaticFileUrl(result.svgFile) : '';
+      if (!url) throw new Error('转换结果缺少 svg 地址');
+      svgUrlCache.set(file.id, url);
+      loading.value = false;
+      await loadSvgFromUrl(url, token);
+    } catch (err) {
+      if (token !== loadToken) return;
+      console.error('DWG 转 SVG 失败:', err);
+      error.value = '图纸转换失败,请稍后重试';
+      loading.value = false;
+    }
+  };
+
+  /** 按转换产物地址取回 svg 正文再渲染(取不回来就换接口兜底) */
+  const loadSvgFromUrl = async (url: string, token: number) => {
+    loading.value = true;
+    loadingText.value = '正在加载图纸,请稍候…';
+    try {
+      const resp = await fetch(url, { headers: { 'X-Access-Token': getToken() } });
+      if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+      const svg = await resp.text();
+      if (token !== loadToken) return;
+      applySvgText(svg);
+    } catch (err) {
+      if (token !== loadToken) return;
+      console.error('取回 DWG 转换结果失败:', err);
+      const ok = await loadSvgFromInfo(token);
+      if (token !== loadToken) return;
+      if (!ok) error.value = '图纸加载失败,请稍后重试';
+    } finally {
+      if (token === loadToken) loading.value = false;
+    }
+  };
+
+  /** 兜底:换「转换并返回 svg 正文」的接口再拿一次;成功返回 true */
+  const loadSvgFromInfo = async (token: number) => {
+    if (!uploadSource) return false;
+    try {
+      const result = await dwgToSvgInfo(uploadSource);
+      if (token !== loadToken) return false;
+      applySvgText(result?.svgInfo || '');
+      // 已经用过内容接口了,后面 <img> 再失败就别再回来重试同一条路
+      fallbackTried = true;
+      return true;
+    } catch (err) {
+      if (token === loadToken) console.error('DWG 转 SVG 内容失败:', err);
+      return false;
+    }
+  };
+
+  /** <img> 渲染失败(取回的字节其实不是有效 svg)时再兜一次 */
+  const onSvgError = async () => {
+    if (fallbackTried) {
+      error.value = '图纸加载失败,请稍后重试';
+      return;
+    }
+    const token = loadToken;
+    loading.value = true;
+    loadingText.value = '正在重新获取图纸内容,请稍候…';
+    const ok = await loadSvgFromInfo(token);
+    if (token !== loadToken) return;
+    if (!ok) error.value = '图纸加载失败,请稍后重试';
+    loading.value = false;
+  };
+
+  // 平移/缩放:与 DXF 预览(CadPreview)保持同一套操作——滚轮缩放、按住拖拽、双击复位
+  let dragging = false;
+  let lastX = 0;
+  let lastY = 0;
+
+  const onMouseMove = (ev: MouseEvent) => {
+    if (!dragging) return;
+    const v = view.value;
+    view.value = { ...v, tx: v.tx + (ev.clientX - lastX), ty: v.ty + (ev.clientY - lastY) };
+    lastX = ev.clientX;
+    lastY = ev.clientY;
+  };
+
+  const onMouseUp = () => {
+    dragging = false;
+    window.removeEventListener('mousemove', onMouseMove);
+    window.removeEventListener('mouseup', onMouseUp);
+  };
+
+  const onMouseDown = (ev: MouseEvent) => {
+    dragging = true;
+    lastX = ev.clientX;
+    lastY = ev.clientY;
+    window.addEventListener('mousemove', onMouseMove);
+    window.addEventListener('mouseup', onMouseUp);
+  };
+
+  const onWheel = (ev: WheelEvent) => {
+    const stage = stageRef.value;
+    if (!stage) return;
+    const rect = stage.getBoundingClientRect();
+    // 变换原点在图片中心(即舞台中心),所以光标位置先换算成「相对中心」的坐标
+    const px = ev.clientX - rect.left - rect.width / 2;
+    const py = ev.clientY - rect.top - rect.height / 2;
+    const v = view.value;
+    const k = Math.min(Math.max(v.k * (ev.deltaY < 0 ? 1.15 : 1 / 1.15), 0.05), 200);
+    // 以光标为不动点:q = c·k + t → t' = q - (q - t)·k'/k
+    const ratio = k / v.k;
+    view.value = { k, tx: px - (px - v.tx) * ratio, ty: py - (py - v.ty) * ratio };
+  };
+
+  onMounted(() => {
+    void load();
+  });
+
+  // 面板实例会在多个文件标签之间复用,换文件必须重新走一遍转换
+  watch(
+    () => props.file,
+    () => {
+      void load();
+    }
+  );
+
+  onBeforeUnmount(() => {
+    loadToken++; // 在途响应作废,避免卸载后还去改已销毁实例的状态
+    onMouseUp();
+    releaseSvgBlob();
+  });
+</script>
+
+<style scoped lang="less">
+  .dwg-preview {
+    display: flex;
+    flex-direction: column;
+    height: 100%;
+    min-height: 0;
+  }
+
+  .dwg-stage {
+    position: relative;
+    flex: 1;
+    min-height: 320px;
+    overflow: hidden;
+    // 后端转出的 svg 是透明底,图元里绝大多数是黑线(#000000,AutoCAD 默认色,实测 1MB 图纸里占六成以上),
+    // 所以必须配白底——不像 DXF 预览那样画在深色画布上,否则主体线条几乎看不见。
+    background: #fff;
+    cursor: grab;
+    user-select: none;
+
+    &:active {
+      cursor: grabbing;
+    }
+  }
+
+  // 后端生成的 svg 根节点带宽高 100% 和 viewBox(preserveAspectRatio 默认 xMidYMid meet),
+  // 图片框铺满舞台时图形本身就已是「适应窗口」,缩放/平移只在这个基础上叠加 transform
+  .dwg-svg {
+    position: absolute;
+    inset: 0;
+    width: 100%;
+    height: 100%;
+    transform-origin: center center;
+  }
+
+  .dwg-toolbar {
+    position: absolute;
+    right: 8px;
+    bottom: 8px;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+  }
+
+  .dwg-stat {
+    font-size: 12px;
+    // 面板其余部分是深色,这里的文字压在白底舞台上,取更深的灰
+    color: #606a76;
+  }
+
+  .dwg-hint {
+    padding: 12px 16px;
+    font-size: 13px;
+    color: #8b949e;
+  }
+
+  .dwg-hint-error {
+    display: flex;
+    align-items: center;
+    gap: 4px;
+  }
+</style>

+ 5 - 0
src/views/ventAI/manageAssistent/components/resource/FilePreviewPanel.vue

@@ -90,6 +90,9 @@
         <div v-else-if="isDxfFile(file.name)" class="cad-preview-wrapper">
           <CadPreview :file="file" />
         </div>
+        <div v-else-if="isDwgFile(file.name)" class="cad-preview-wrapper">
+          <DwgPreview :file="file" />
+        </div>
         <div v-else-if="isTextFile(file.name)" class="text-preview">
           <pre>{{ file.content || '文件内容加载中...' }}</pre>
         </div>
@@ -117,6 +120,7 @@
     isMdFile,
     isTextFile,
     isDxfFile,
+    isDwgFile,
     getFileExtension,
     getFileIconVar,
     renderMarkdown,
@@ -128,6 +132,7 @@
   } from '/@/views/ventAI/manageAssistent/lib/index';
   import type { AttachedFile } from '/@/views/ventAI/manageAssistent/types/index';
   import CadPreview from '/@/views/ventAI/manageAssistent/components/resource/CadPreview.vue';
+  import DwgPreview from '/@/views/ventAI/manageAssistent/components/resource/DwgPreview.vue';
 
   // [perf-base-v1] 卡2c: VueOfficeDocx 改异步加载,仅打开 docx 预览时才拉取 office-preview chunk
   const VueOfficeDocx = defineAsyncComponent(() => import('@vue-office/docx/lib/v3/vue-office-docx.mjs'));

+ 17 - 2
src/views/ventAI/manageAssistent/hooks/useChatFiles.ts

@@ -2,7 +2,16 @@ import { ref } from 'vue';
 import dayjs from 'dayjs';
 import { message } from 'ant-design-vue';
 import type { AttachedFile } from '/@/views/ventAI/manageAssistent/types/index';
-import { isAudioFile, isCsvFile, isDxfFile, isExcelFile, isTextFile, isVideoFile, isWordFile } from '/@/views/ventAI/manageAssistent/lib/index';
+import {
+  isAudioFile,
+  isCsvFile,
+  isDwgFile,
+  isDxfFile,
+  isExcelFile,
+  isTextFile,
+  isVideoFile,
+  isWordFile,
+} from '/@/views/ventAI/manageAssistent/lib/index';
 import type ChatInputArea from '/@/views/ventAI/manageAssistent/components/chat/ChatInputArea.vue';
 
 /**
@@ -52,7 +61,13 @@ export const useChatFiles = (getChatInput: () => InstanceType<typeof ChatInputAr
       // 读取方式只判断一次:onload 与 readAs* 两处各写一遍条件容易走偏。
       // .dxf 必须在此显式分流到文本:否则会落到 dataURL 分支,附件里只有 preview、拿不到 content,
       // DXF 预览面板只能显示空白。
-      const readMode = isTextFile(name) || isCsvFile(name) || isDxfFile(name) ? 'text' : isWordFile(name) || isExcelFile(name) ? 'binary' : 'dataurl';
+      // .dwg 同样要显式分流到二进制:预览面板得拿原始字节上传后端转 SVG,存成 base64 的 dataURL 既白占内存又没法直接用。
+      const readMode =
+        isTextFile(name) || isCsvFile(name) || isDxfFile(name)
+          ? 'text'
+          : isWordFile(name) || isExcelFile(name) || isDwgFile(name)
+            ? 'binary'
+            : 'dataurl';
 
       reader.onload = (e) => {
         activeFileReader.value = null;

+ 5 - 2
src/views/ventAI/manageAssistent/lib/fileIcon.ts

@@ -18,10 +18,13 @@ export const isExcelFile = (filename: string): boolean => /\.(xls|xlsx)$/i.test(
 export const isCsvFile = (filename: string): boolean => /\.csv$/i.test(filename);
 
 // —— CAD ——
-// 当前只支持 .dxf(明文交换格式,可本地解析渲染);
-// .dwg(专有二进制,需服务端转换)与 .mxweb(依赖 mxcad 及其配套服务)暂不预览,由 fetchPreviewFile 返回 null 回退为下载。
+// .dxf:明文交换格式,前端本地解析渲染(components/resource/CadPreview.vue);
+// .dwg:专有二进制,前端解析不了,预览时上传后端转成 SVG 再展示(components/resource/DwgPreview.vue);
+// .mxweb:依赖 mxcad 及其配套服务,暂不预览,由 fetchPreviewFile 返回 null 回退为下载。
 export const isDxfFile = (filename: string): boolean => /\.dxf$/i.test(filename);
 
+export const isDwgFile = (filename: string): boolean => /\.dwg$/i.test(filename);
+
 export const isTextFile = (filename: string): boolean =>
   /\.(txt|md|json|csv|xml|yaml|yml|log|ini|toml|cfg|conf|env|gitignore|dockerignore|editorconfig|prettierrc|eslintrc|babelrc|properties|sh|bat|ps1|cmd|bash|zsh|fish|sql|graphql|proto|js|ts|jsx|tsx|vue|css|scss|sass|less|html|htm|svelte|py|java|kt|kts|c|cpp|h|hpp|cc|cxx|cs|go|rs|rb|php|swift|m|mm|r|R|lua|pl|pm|hs|ex|exs|erl|clj|scala|dart|zig|nim|v|sol|tf|hcl|gradle|cmake|makefile|mk)$/i.test(
     filename

+ 4 - 2
src/views/ventAI/manageAssistent/lib/filePreview.ts

@@ -1,7 +1,7 @@
 // lib/filePreview.ts:由 lib/utils.ts 按职责拆分(实现未改)
 import { getToken } from '/@/utils/auth/index';
 import type { AttachedFile } from '/@/views/ventAI/manageAssistent/types/resource';
-import { isImageFile, isPdfFile, isWordFile, isExcelFile } from './fileIcon';
+import { isImageFile, isPdfFile, isWordFile, isExcelFile, isDwgFile } from './fileIcon';
 
 export const fetchPreviewFile = async (url: string, filename: string): Promise<AttachedFile | null> => {
   const resp = await fetch(url, { headers: { 'X-Access-Token': getToken() } });
@@ -25,7 +25,9 @@ export const fetchPreviewFile = async (url: string, filename: string): Promise<A
     const content = await resp.text();
     return { id, name: filename, size: content.length, type: mimeMap[ext], content, uploadTime };
   }
-  if (isWordFile(filename) || isExcelFile(filename)) {
+  // 二进制文件都按字节取回:word/excel 由预览面板本地渲染,.dwg 是二进制图纸前端解析不了、
+  // 要由预览面板上传后端转 SVG(components/resource/DwgPreview.vue),这里只负责把原始字节取回来。
+  if (isWordFile(filename) || isExcelFile(filename) || isDwgFile(filename)) {
     const arrayBuffer = await resp.arrayBuffer();
     return { id, name: filename, size: arrayBuffer.byteLength, type: 'application/octet-stream', arrayBuffer, uploadTime };
   }