Преглед на файлове

[Mod 0000]AI助手修改dwg文件的处理逻辑,历史dwg文件直接获取服务器端内容

wangkeyi преди 1 ден
родител
ревизия
242753f1e8

+ 25 - 33
src/views/ventAI/manageAssistent/api/cad.ts

@@ -1,46 +1,52 @@
 // api/cad.ts:DWG 图纸预览所需的「服务端转 SVG」接口。
 //
 // DWG 是专有二进制格式,前端没法本地解析(对比 .dxf 走 lib/dxf.ts 本地渲染),只能把原始文件传给后端转成
-// SVG 再展示。后端给了两个接口,差别只在返回什么:
-//   dwgFileToSVG     —— 只返回生成的 svg 路径,前端自己去静态目录取;
-//   dwgFileToSVGInfo —— 直接返回 svg 正文,不依赖静态目录能否被前端访问。
+// SVG 再展示。三种取图方式,这里只留两种:
+//   1) dwgFileToSVGInfo:上传文件 + 转换,回 svg 正文。本地附件(用户刚拖进来的)用这个;
+//   2) dwgServerFileToSVGInfo:按服务端已有的附件 file_id 直接取转换结果,回 svg 正文。
+//      历史消息里的附件后端早就转过一次了,用它就不必重新上传+转换;
+//   3) dwgFileToSVG 只回生成的 svg 路径,而且那条路径是服务端绝对路径(实测 /data/...),
+//      前端取不到文件还得再调一次正文接口,等于每次都转换两遍,所以不用。
 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',
+  dwgServerFileToSvgInfo = '/Vmodel/ventanalyModel/dwgServerFileToSVGInfo',
 }
 
 /** 转换业务目录:后端据此存放上传的 dwg 与生成的 svg(对应返回里的 temp/dwg、/temp/svg/*.svg) */
 const DWG_BIZ = 'temp/dwg';
 
-export interface DwgToSvgResult {
-  /** 生成的 svg 相对路径,如 /temp/svg/test300K.svg */
+export interface DwgToSvgInfoResult {
+  /** svg 正文(完整 xml 字符串),预览直接用这个 */
+  svgInfo: string;
+  /** 生成的 svg 路径:后端会给,但不保证前端可达,仅作参考 */
   svgFile: string;
-  /** 上传的 dwg 相对路径,如 temp/dwg/test300K.dwg */
+  /** 上传的 dwg 相对路径 */
   dwgFile: string;
 }
 
-export interface DwgToSvgInfoResult extends DwgToSvgResult {
-  /** svg 正文(完整 xml 字符串) */
-  svgInfo: string;
-}
-
 /**
- * 上传 dwg,返回转好的 svg 路径。
+ * 上传 dwg,转换并返回 svg 正文。
  * @param file - dwg 文件(文件名会带到后端,用于命名生成物,务必传带名字的 File 而不是裸 Blob)
  */
-export const dwgToSvg = (file: File): Promise<DwgToSvgResult> => postDwg<DwgToSvgResult>(Api.dwgToSvg, file);
+export const dwgToSvgInfo = (file: File): Promise<DwgToSvgInfoResult> => postDwg<DwgToSvgInfoResult>(Api.dwgToSvgInfo, file);
 
 /**
- * 上传 dwg,返回 svg 正文。
- * @param file - dwg 文件
+ * 按服务端已有附件的 file_id 直接取 dwg 转换结果(历史消息附件专用),不再上传文件。
+ * @param fileId - 会话附件接口里的 file_id
  */
-export const dwgToSvgInfo = (file: File): Promise<DwgToSvgInfoResult> => postDwg<DwgToSvgInfoResult>(Api.dwgToSvgInfo, file);
+export const dwgServerFileToSvgInfo = (fileId: string): Promise<DwgToSvgInfoResult> =>
+  defHttp
+    .post<DwgToSvgInfoResult>(
+      // JSON body,不是 multipart:这个接口是按文件 id 查,不接收文件
+      { url: Api.dwgServerFileToSvgInfo, data: { file_id: fileId }, timeout: 120000 },
+      { isTransformResponse: false }
+    )
+    .then((res) => unwrapEnvelope<DwgToSvgInfoResult>(res));
 
 function postDwg<T>(url: string, file: File): Promise<T> {
   // 走项目自带的上传方法,**不要**写成 defHttp.post({ data: formData }):
@@ -64,7 +70,7 @@ function postDwg<T>(url: string, file: File): Promise<T> {
  *
  * 后端业务失败时返回的是 `{success:false, code:500, result:null}`:此时响应拦截器不会走 reject
  * (它是按 `data.result || data.success` 进分支的,两者都为假),而是把整个信封当普通数据返回。
- * 不在这里判一次,调用方就只能看到「返回结果里没有 svgFile」,把「转换失败」误报成「缺字段」。
+ * 不在这里判一次,调用方就只能看到「返回结果里没有 svgInfo」,把「转换失败」误报成「缺字段」。
  * 成功时拦截器已经把 result 拆出来了(普通对象,没有 success 字段),这里原样放行。
  */
 function unwrapEnvelope<T>(res: unknown): T {
@@ -75,17 +81,3 @@ function unwrapEnvelope<T>(res: unknown): T {
   }
   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(/^\/+/, '')}`;
-};

+ 65 - 82
src/views/ventAI/manageAssistent/components/resource/DwgPreview.vue

@@ -7,8 +7,10 @@
       <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" />
+    <!-- 没有 svg 地址时不渲染舞台:空 src 的 <img> 会先报一次 error,把刚渲染好的内容盖成失败态 -->
+    <div v-else-if="svgSrc" ref="stageRef" class="dwg-stage" @wheel.prevent="onWheel" @mousedown="onMouseDown" @dblclick="resetView">
+      <!-- 地址一变就换一个新的 img:旧元素被移除后,它那次加载失败(比如旧地址已被回收)不会再回调到新图上来 -->
+      <img :key="svgSrc" 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>
@@ -18,22 +20,32 @@
   </div>
 </template>
 
+<script lang="ts">
+  // 转换结果(svg 正文)按文件缓存,整个页面共享一份。
+  // 注意必须放在普通 script 块里:<script setup> 的顶层代码是每个组件实例执行一遍,
+  // 写在那里等于每次打开预览都是空缓存,缓存形同虚设(实测关掉标签再打开会重新转换一次)。
+  // 代价是每份图纸在内存里留约 1MB 文本,会话内预览的图纸数量有限,可以接受。
+  const svgTextCache = new Map<string, string>();
+</script>
+
 <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 { dwgServerFileToSvgInfo, 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 再当作图片展示。两种取图方式:
+   *   - 历史消息里的附件(带 fileId):后端早就转过一次,按 fileId 直接取转换结果,不再上传;
+   *   - 本地附件(有原始字节、没有 fileId):上传文件再转换。
+   *
+   * 不用「转换后返回路径、前端再去取文件」那条路:后端给的是服务端绝对路径(实测 /data/...),
+   * 前端拼成静态地址取不到(404),结果为了一次预览把转换和约 1MB 的内容各跑两遍。
    *
-   * svg 必须自己取回来造 blob 地址再交给 <img>,不能把静态地址直接塞进 img src:
-   * 静态目录的响应头是 `Content-Type: application/force-download` + `X-Content-Type-Options: nosniff`,
+   * svg 正文自己造成 blob 地址再交给 <img>:静态目录的响应头是
+   * `Content-Type: application/force-download` + `X-Content-Type-Options: nosniff`,
    * 浏览器据此判定"不是图片"而拒绝渲染(实测 <img> 直接 error,同样的字节换成 blob 地址就能显示)。
    *
    * 转换发生在预览面板而不是附件/卡片点击时:附件加进来、AI 给了下载链接,都不代表用户要打开图纸,
@@ -54,18 +66,12 @@
     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) {
@@ -82,10 +88,14 @@
   const applySvgText = (raw: string) => {
     const svg = (raw || '').trim();
     if (!svg) throw new Error('svg 内容为空');
-    releaseSvgBlob();
+    const previous = svgBlobUrl;
     svgBlobUrl = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }));
     svgSrc.value = svgBlobUrl;
+    // 渲染成功就清掉之前的失败态:换文件/重试后不能让旧的 error 继续盖住新内容
+    error.value = '';
     resetView();
+    // 先切到新地址(模板按地址换了新 img)再回收旧的,避免旧地址被回收时触发一次失败回调
+    if (previous) URL.revokeObjectURL(previous);
   };
 
   /** 上传用的 File:本地上传的附件直接用原始文件(字节最保真),远端预览对象只有字节时按原文件名重建(后端用它命名生成物) */
@@ -95,29 +105,47 @@
     return null;
   };
 
+  /**
+   * 取 svg 正文:历史消息的附件(带 fileId)按服务端文件 id 直接取转换结果——它后端已经转过一次,
+   * 没必要重新上传转换;本地附件(有原始字节)才上传。服务端文件取不到且手上有字节时,退回上传转换。
+   */
+  const requestSvgText = async (file: AttachedFile): Promise<string> => {
+    if (file.fileId) {
+      try {
+        const result = await dwgServerFileToSvgInfo(file.fileId);
+        return result?.svgInfo || '';
+      } catch (err) {
+        // 服务端文件被清理/不可用时,手上有原始字节就退回上传转换,别让预览挂掉
+        if (!uploadSource) throw err;
+        console.warn('按服务端文件取 DWG 转换结果失败,退回上传转换:', err);
+      }
+    }
+    if (!uploadSource) throw new Error('未取到图纸内容,也无法按服务端文件获取');
+    const result = await dwgToSvgInfo(uploadSource);
+    return result?.svgInfo || '';
+  };
+
   const load = async (force = false) => {
     const token = ++loadToken;
     const file = props.file;
-    releaseSvgBlob();
-    fallbackTried = false;
+    // 这里不回收旧的 blob 地址:它此刻可能还挂在 <img> 上,提前回收会触发一次加载失败,
+    // 把失败的提示盖在正在渲染的内容上。旧地址由 applySvgText(换新图时)和卸载时统一释放。
     error.value = '';
     // 上一次转换可能还停在 loading(请求在途时被换文件/重试抢占),这里统一复位:
     // 命中缓存的分支不会再把它置回 false,漏了这一行就会永远卡在「正在转换图纸」。
     loading.value = false;
     resetView();
+    svgSrc.value = '';
 
-    // 缓存的是转换产物地址而非 svg 正文:正文 1MB 上下,多份图纸攒在内存里不值当,
-    // 省下的只该是「重新上传 + 重新转换」这笔真正昂贵的开销。
-    const cachedUrl = force ? '' : svgUrlCache.get(file.id);
-    if (cachedUrl) {
-      svgSrc.value = '';
-      await loadSvgFromUrl(cachedUrl, token);
+    // 重试(force)时绕开缓存重新取一遍
+    const cachedSvg = force ? '' : svgTextCache.get(file.id);
+    if (cachedSvg) {
+      applySvgText(cachedSvg);
       return;
     }
 
-    svgSrc.value = '';
     uploadSource = toUploadFile(file);
-    if (!uploadSource) {
+    if (!file.fileId && !uploadSource) {
       error.value = '未取到图纸内容,无法预览';
       return;
     }
@@ -125,71 +153,26 @@
     loading.value = true;
     loadingText.value = '正在转换图纸,请稍候…';
     try {
-      const result = await dwgToSvg(uploadSource);
+      const svg = await requestSvgText(file);
       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);
+      if (!svg.trim()) throw new Error('转换结果缺少 svg 内容');
+      svgTextCache.set(file.id, svg);
+      applySvgText(svg);
     } 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;
+  /** <img> 渲染失败:返回的内容不是有效 svg,提示重试即可(接口只有这一个,没有别的来源可换) */
+  const onSvgError = (e: Event) => {
+    const img = e.target as HTMLImageElement | null;
+    // 只认「当前这个地址」的失败:空地址初始渲染、以及已被换掉的旧地址,报错都不算数
+    if (!img || img.getAttribute('src') !== svgSrc.value) return;
+    error.value = '图纸加载失败,请稍后重试';
   };
 
   // 平移/缩放:与 DXF 预览(CadPreview)保持同一套操作——滚轮缩放、按住拖拽、双击复位

+ 4 - 0
src/views/ventAI/manageAssistent/hooks/useChatAttachments.ts

@@ -51,6 +51,10 @@ const ensureAttachmentContent = async (file: AttachedFile): Promise<AttachedFile
   if (hasContent(file)) return file;
   if (!file.fileId) throw new Error('附件缺少下载标识');
 
+  // dwg 历史附件不用把原始文件下载回来:预览面板会按 fileId 直接取后端已转换好的 svg
+  //(见 DwgPreview 的 requestSvgText),下载几百 KB 只为了再上传转换一遍属于纯浪费。
+  if (isDwgFile(file.name)) return file;
+
   const blob = await fetchAttachmentBlob(file.fileId, file.downloadUrl);
   if (isTextFile(file.name) || isCsvFile(file.name) || isDxfFile(file.name)) {
     file.content = await blob.text();