Quellcode durchsuchen

[Feat 0000]AI助手新增dxf格式支持

wangkeyi vor 2 Wochen
Ursprung
Commit
5f41a3beec

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

@@ -0,0 +1,319 @@
+<template>
+  <div class="cad-preview">
+    <!-- DXF:本地解析 + canvas 渲染,不依赖任何后端服务 -->
+    <div class="cad-dxf">
+      <div v-if="dxfError" class="cad-hint">{{ dxfError }}</div>
+      <div v-else class="cad-canvas-area">
+        <canvas ref="canvasRef" class="cad-canvas" @wheel.prevent="onWheel" @mousedown="onMouseDown" @dblclick="fitView"></canvas>
+        <div class="cad-toolbar">
+          <span class="cad-stat">{{ dxfStat }}</span>
+          <a-button size="small" type="link" @click="fitView">适应窗口</a-button>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
+  import { parseDxf } from '/@/views/ventAI/manageAssistent/lib/dxf';
+  import type { DxfEntity, DxfParseResult } from '/@/views/ventAI/manageAssistent/lib/dxf';
+  import type { AttachedFile } from '/@/views/ventAI/manageAssistent/types/index';
+
+  /**
+   * 右侧面板的 CAD 预览:本地解析 DXF 并在 canvas 上绘制(可滚轮缩放、拖拽平移),不依赖任何后端服务。
+   *
+   * 当前只处理 .dxf——面板仅在 isDxfFile 命中时才渲染本组件。
+   * .dwg(专有二进制,需服务端转换)与 .mxweb(依赖 mxcad 及其配套服务)暂不预览,
+   * 由 fetchPreviewFile 返回 null 走"回退为下载"的老路径。
+   */
+  const props = defineProps<{ file: AttachedFile }>();
+
+  // —— DXF ——
+  const canvasRef = ref<HTMLCanvasElement>();
+  const dxfError = ref('');
+  const parsed = ref<DxfParseResult | null>(null);
+  const view = ref({ scale: 1, tx: 0, ty: 0 });
+  const dxfStat = computed(() => {
+    const r = parsed.value;
+    if (!r) return '';
+    return r.skipped > 0 ? `${r.entities.length} 个对象(跳过 ${r.skipped} 个不支持的对象)` : `${r.entities.length} 个对象`;
+  });
+
+  const loadDxf = () => {
+    parsed.value = null;
+    dxfError.value = '';
+    const buf = props.file.arrayBuffer;
+    const text = props.file.content ?? (buf ? new TextDecoder().decode(buf) : '');
+    if (!text.trim()) {
+      dxfError.value = '未取到 DXF 内容,无法预览';
+      return;
+    }
+    const result = parseDxf(text);
+    if (!result.entities.length) {
+      dxfError.value = result.skipped ? `未解析到可显示的图形(跳过 ${result.skipped} 个不支持的实体)` : 'DXF 中没有可显示的实体';
+      return;
+    }
+    parsed.value = result;
+    fitView();
+  };
+
+  /** 按图形范围缩放到画布(留 10% 边距) */
+  const fitView = () => {
+    const canvas = canvasRef.value;
+    if (!canvas || !parsed.value) return;
+    const w = canvas.clientWidth || 1;
+    const h = canvas.clientHeight || 1;
+    const b = parsed.value.bounds;
+    if (!b) {
+      view.value = { scale: 1, tx: w / 2, ty: h / 2 };
+      draw();
+      return;
+    }
+    const spanX = Math.max(b.maxX - b.minX, 1e-6);
+    const spanY = Math.max(b.maxY - b.minY, 1e-6);
+    const scale = Math.min(w / spanX, h / spanY) * 0.9;
+    const cx = (b.minX + b.maxX) / 2;
+    const cy = (b.minY + b.maxY) / 2;
+    // 纵向取加号:绘制时 y 轴翻转(screenY = ty - y * scale)
+    view.value = { scale, tx: w / 2 - cx * scale, ty: h / 2 + cy * scale };
+    draw();
+  };
+
+  const pathEntity = (ctx: CanvasRenderingContext2D, e: DxfEntity) => {
+    ctx.beginPath();
+    switch (e.kind) {
+      case 'line':
+        ctx.moveTo(e.a.x, e.a.y);
+        ctx.lineTo(e.b.x, e.b.y);
+        break;
+      case 'polyline': {
+        if (!e.points.length) return;
+        ctx.moveTo(e.points[0].x, e.points[0].y);
+        for (let i = 1; i < e.points.length; i++) ctx.lineTo(e.points[i].x, e.points[i].y);
+        if (e.closed) ctx.closePath();
+        break;
+      }
+      case 'circle':
+        ctx.arc(e.center.x, e.center.y, Math.max(e.radius, 0), 0, Math.PI * 2);
+        break;
+      case 'arc':
+        // 绘制变换含 y 翻转,而 arc 的参数 t 恰好等于 DXF 角度(cos/sin 直接对应图纸坐标),
+        // 所以取 false(t 递增)才是 DXF 意义上的逆时针短弧;取 true 会画成 270° 的补弧(实测如此)。
+        ctx.arc(e.center.x, e.center.y, Math.max(e.radius, 0), (e.startDeg * Math.PI) / 180, (e.endDeg * Math.PI) / 180, false);
+        break;
+      case 'ellipse':
+        ctx.ellipse(
+          e.center.x,
+          e.center.y,
+          Math.max(e.rx, 0),
+          Math.max(e.ry, 0),
+          (e.rotationDeg * Math.PI) / 180,
+          (e.startDeg * Math.PI) / 180,
+          (e.endDeg * Math.PI) / 180,
+          false
+        );
+        break;
+      case 'point': {
+        // 单点必须画成小十字,否则 stroke 什么都看不到
+        const r = 1.5 / (view.value.scale || 1);
+        ctx.moveTo(e.at.x - r, e.at.y);
+        ctx.lineTo(e.at.x + r, e.at.y);
+        ctx.moveTo(e.at.x, e.at.y - r);
+        ctx.lineTo(e.at.x, e.at.y + r);
+        break;
+      }
+      default:
+        break;
+    }
+  };
+
+  const drawTextEntity = (ctx: CanvasRenderingContext2D, e: Extract<DxfEntity, { kind: 'text' }>) => {
+    const { scale, tx, ty } = view.value;
+    const px = e.height * scale;
+    // 太小看不清、太大(图纸被缩得极小或字号本就夸张)就不画,避免糊满整屏
+    if (!Number.isFinite(px) || px < 5 || px > 120) return;
+    ctx.save();
+    ctx.translate(tx + e.at.x * scale, ty - e.at.y * scale);
+    if (e.rotationDeg) ctx.rotate((-e.rotationDeg * Math.PI) / 180);
+    ctx.font = `${px}px sans-serif`;
+    ctx.fillStyle = '#9fb3c8';
+    ctx.fillText(e.text, 0, 0);
+    ctx.restore();
+  };
+
+  const draw = () => {
+    const canvas = canvasRef.value;
+    const data = parsed.value;
+    if (!canvas || !data) return;
+    const dpr = window.devicePixelRatio || 1;
+    const w = canvas.clientWidth || 1;
+    const h = canvas.clientHeight || 1;
+    canvas.width = Math.round(w * dpr);
+    canvas.height = Math.round(h * dpr);
+    const ctx = canvas.getContext('2d');
+    if (!ctx) return;
+    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+    ctx.clearRect(0, 0, w, h);
+
+    const { scale, tx, ty } = view.value;
+    ctx.save();
+    ctx.translate(tx, ty);
+    // DXF 的 y 轴向上,canvas 向下 → 纵向取负;线宽除以 scale 保持屏幕上恒为 1px
+    ctx.scale(scale, -scale);
+    ctx.strokeStyle = '#c9d6e4';
+    ctx.lineWidth = 1 / scale;
+    for (const e of data.entities) {
+      if (e.kind === 'text') continue;
+      pathEntity(ctx, e);
+      ctx.stroke();
+    }
+    ctx.restore();
+
+    // 文字在翻转后的坐标系里会被镜像,单独按屏幕坐标绘制
+    for (const e of data.entities) {
+      if (e.kind === 'text') drawTextEntity(ctx, e);
+    }
+  };
+
+  // 平移/缩放
+  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;
+    draw();
+  };
+
+  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 canvas = canvasRef.value;
+    if (!canvas) return;
+    const rect = canvas.getBoundingClientRect();
+    const px = ev.clientX - rect.left;
+    const py = ev.clientY - rect.top;
+    const v = view.value;
+    const scale = Math.min(Math.max(v.scale * (ev.deltaY < 0 ? 1.15 : 1 / 1.15), 1e-6), 1e6);
+    // 以光标为不动点:tx' = px - (px - tx) * k
+    const k = scale / v.scale;
+    view.value = { scale, tx: px - (px - v.tx) * k, ty: py - (py - v.ty) * k };
+    draw();
+  };
+
+  let resizeObserver: ResizeObserver | null = null;
+
+  // 每次换文件后都要把观察者绑到「当前这块」画布:DXF 分支的画布是随文件类型重新创建的,
+  // 继续观察被替换掉的旧画布,尺寸变化就不会再触发重绘。
+  const setupResizeObserver = () => {
+    resizeObserver?.disconnect();
+    resizeObserver = null;
+    const el = canvasRef.value;
+    if (!el) return;
+    resizeObserver = new ResizeObserver(() => {
+      if (parsed.value) fitView();
+    });
+    resizeObserver.observe(el);
+  };
+
+  // 面板实例会在多个文件标签之间复用(RightPanelTabs 没加 :key),换文件必须重新初始化。
+  // 必须先等一次 nextTick:面板实例在多个文件标签间复用,此刻模板还停在上一个文件的分支上
+  //(例如文本预览块),画布尚未挂载,canvasRef 为空会让 fitView 直接 return,绘制被静默跳过——
+  // 表现就是面板一片空白,得手动点一次「适应窗口」才出图。
+  const init = async () => {
+    onMouseUp();
+    parsed.value = null;
+    dxfError.value = '';
+    await nextTick();
+    loadDxf();
+    setupResizeObserver();
+  };
+
+  onMounted(() => {
+    void init();
+  });
+
+  watch(
+    () => props.file,
+    () => {
+      void init();
+    }
+  );
+
+  onBeforeUnmount(() => {
+    onMouseUp();
+    resizeObserver?.disconnect();
+    resizeObserver = null;
+  });
+</script>
+
+<style scoped lang="less">
+  .cad-preview {
+    display: flex;
+    flex-direction: column;
+    height: 100%;
+    min-height: 0;
+  }
+
+  .cad-dxf {
+    display: flex;
+    flex-direction: column;
+    flex: 1;
+    min-height: 0;
+  }
+
+  .cad-canvas-area {
+    position: relative;
+    flex: 1;
+    min-height: 320px;
+  }
+
+  .cad-canvas {
+    display: block;
+    width: 100%;
+    height: 100%;
+    cursor: grab;
+    background: rgb(13 21 32 / 55%);
+
+    &:active {
+      cursor: grabbing;
+    }
+  }
+
+  .cad-toolbar {
+    position: absolute;
+    right: 8px;
+    bottom: 8px;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+  }
+
+  .cad-stat {
+    font-size: 12px;
+    color: #8b949e;
+  }
+
+  .cad-hint {
+    padding: 12px 16px;
+    font-size: 13px;
+    color: #8b949e;
+  }
+</style>

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

@@ -87,6 +87,9 @@
         <div v-else-if="isMdFile(file.name)" class="markdown-preview">
           <div class="markdown-body" v-html="renderedMarkdown" @click="handleTableAction"></div>
         </div>
+        <div v-else-if="isDxfFile(file.name)" class="cad-preview-wrapper">
+          <CadPreview :file="file" />
+        </div>
         <div v-else-if="isTextFile(file.name)" class="text-preview">
           <pre>{{ file.content || '文件内容加载中...' }}</pre>
         </div>
@@ -113,6 +116,7 @@
     isCsvFile,
     isMdFile,
     isTextFile,
+    isDxfFile,
     getFileExtension,
     getFileIconVar,
     renderMarkdown,
@@ -123,6 +127,7 @@
     flashCopySuccess,
   } 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';
 
   // [perf-base-v1] 卡2c: VueOfficeDocx 改异步加载,仅打开 docx 预览时才拉取 office-preview chunk
   const VueOfficeDocx = defineAsyncComponent(() => import('@vue-office/docx/lib/v3/vue-office-docx.mjs'));
@@ -878,6 +883,16 @@
       }
     }
 
+    // 与 excel 分支同样撑满内容区(抵消外层 16px padding),让画布拿到真实高度
+    .cad-preview-wrapper {
+      display: flex;
+      flex-direction: column;
+      width: calc(100% + 32px);
+      height: calc(100% + 32px);
+      margin: -16px;
+      overflow: hidden;
+    }
+
     .edit-mode {
       display: flex;
       flex-direction: column;

+ 15 - 5
src/views/ventAI/manageAssistent/hooks/useChatFiles.ts

@@ -2,7 +2,7 @@ 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, isExcelFile, isTextFile, isVideoFile, isWordFile } from '/@/views/ventAI/manageAssistent/lib/index';
+import { isAudioFile, isCsvFile, isDxfFile, isExcelFile, isTextFile, isVideoFile, isWordFile } from '/@/views/ventAI/manageAssistent/lib/index';
 import type ChatInputArea from '/@/views/ventAI/manageAssistent/components/chat/ChatInputArea.vue';
 
 /** 输入区附件:选择文件 → 按类型读取(文本/二进制/预览)→ 待发送状态 */
@@ -44,20 +44,30 @@ export const useChatFiles = (getChatInput: () => InstanceType<typeof ChatInputAr
       const reader = new FileReader();
       activeFileReader.value = reader;
 
+      // 读取方式只判断一次:onload 与 readAs* 两处各写一遍条件容易走偏。
+      // .dxf 必须在此显式分流到文本:否则会落到 dataURL 分支,附件里只有 preview、拿不到 content,
+      // DXF 预览面板只能显示空白。
+      const readMode =
+        isTextFile(file.name) || isCsvFile(file.name) || isDxfFile(file.name)
+          ? 'text'
+          : isWordFile(file.name) || isExcelFile(file.name)
+            ? 'binary'
+            : 'dataurl';
+
       reader.onload = (e) => {
         activeFileReader.value = null;
-        if (isTextFile(file.name) || isCsvFile(file.name)) {
+        if (readMode === 'text') {
           newFile.content = e.target?.result as string;
-        } else if (isWordFile(file.name) || isExcelFile(file.name)) {
+        } else if (readMode === 'binary') {
           newFile.arrayBuffer = e.target?.result as ArrayBuffer;
         } else {
           newFile.preview = e.target?.result as string;
         }
       };
 
-      if (isTextFile(file.name) || isCsvFile(file.name)) {
+      if (readMode === 'text') {
         reader.readAsText(file);
-      } else if (isWordFile(file.name) || isExcelFile(file.name)) {
+      } else if (readMode === 'binary') {
         reader.readAsArrayBuffer(file);
       } else {
         reader.readAsDataURL(file);

+ 270 - 0
src/views/ventAI/manageAssistent/lib/dxf.ts

@@ -0,0 +1,270 @@
+// lib/dxf.ts:极简 DXF 解析(只取"画二维预览"需要的实体)。
+//
+// 为什么不引三方库:three 的 examples 里已无 DXFLoader,而 dxf-parser / three-dxf 需要新增依赖;
+// 这里只覆盖常见几何与文字实体(line/polyline/circle/arc/ellipse/text/point),
+// 不支持的实体按个数统计返回(skipped),由界面如实告知——避免"看着像空图"的误导。
+//
+// DXF 是"组码 + 值"交替两行一组的纯文本格式,且 y 轴向上(与 canvas 相反,渲染时翻转)。
+
+export interface DxfPoint {
+  x: number;
+  y: number;
+}
+
+export type DxfEntity =
+  | { kind: 'line'; a: DxfPoint; b: DxfPoint }
+  | { kind: 'polyline'; points: DxfPoint[]; closed: boolean }
+  | { kind: 'circle'; center: DxfPoint; radius: number }
+  | { kind: 'arc'; center: DxfPoint; radius: number; startDeg: number; endDeg: number }
+  | { kind: 'ellipse'; center: DxfPoint; rx: number; ry: number; rotationDeg: number; startDeg: number; endDeg: number }
+  | { kind: 'text'; at: DxfPoint; height: number; text: string; rotationDeg: number }
+  | { kind: 'point'; at: DxfPoint };
+
+export interface DxfBounds {
+  minX: number;
+  minY: number;
+  maxX: number;
+  maxY: number;
+}
+
+export interface DxfParseResult {
+  entities: DxfEntity[];
+  /** 未支持的实体个数(含 INSERT/SPLINE/HATCH/DIMENSION 等) */
+  skipped: number;
+  bounds: DxfBounds | null;
+}
+
+/** 一个实体在解析过程中的原始组码序列(保序,LWPOLYLINE 的顶点要靠顺序成对还原) */
+interface RawEntity {
+  type: string;
+  codes: Array<[number, string]>;
+}
+
+const num = (raw: RawEntity, code: number, fallback = 0): number => {
+  const hit = raw.codes.find((c) => c[0] === code);
+  const v = hit ? Number.parseFloat(hit[1]) : Number.NaN;
+  return Number.isFinite(v) ? v : fallback;
+};
+
+/** 位标志(code 70):第 1 位表示闭合 */
+const isClosed = (raw: RawEntity): boolean => (Math.trunc(num(raw, 70, 0)) & 1) === 1;
+
+const DEG = 180 / Math.PI;
+
+/** 把 LWPOLYLINE 里交替出现的 10/20 组码还原成顶点序列 */
+const polylinePoints = (raw: RawEntity): Array<{ x: number; y: number }> => {
+  const points: Array<{ x: number; y: number }> = [];
+  let pendingX: number | null = null;
+  for (const [code, value] of raw.codes) {
+    if (code === 10) {
+      pendingX = Number.parseFloat(value);
+    } else if (code === 20 && pendingX !== null) {
+      const y = Number.parseFloat(value);
+      if (Number.isFinite(pendingX) && Number.isFinite(y)) points.push({ x: pendingX, y });
+      pendingX = null;
+    }
+  }
+  return points;
+};
+
+/** 按"组码 值"两行一组切分;容错:空行/非数字组码行只跳过该行,不错位后续内容 */
+const toPairs = (dxfText: string): Array<[number, string]> => {
+  const lines = dxfText.split(/\r\n|\r|\n/);
+  const pairs: Array<[number, string]> = [];
+  let pendingCode: number | null = null;
+  for (const line of lines) {
+    if (pendingCode === null) {
+      const code = Number.parseInt(line.trim(), 10);
+      // 组码行必须是整数;空行等异常行忽略,继续等下一个组码
+      if (Number.isFinite(code)) pendingCode = code;
+      continue;
+    }
+    pairs.push([pendingCode, line]);
+    pendingCode = null;
+  }
+  return pairs;
+};
+
+/** 把原始实体转成可渲染实体;不支持的返回 null(由调用方计入 skipped) */
+const toEntity = (raw: RawEntity, verticesOfPolyline: Array<{ x: number; y: number }> | null): DxfEntity | null => {
+  switch (raw.type) {
+    case 'LINE':
+      return { kind: 'line', a: { x: num(raw, 10), y: num(raw, 20) }, b: { x: num(raw, 11), y: num(raw, 21) } };
+    case 'LWPOLYLINE': {
+      const points = polylinePoints(raw);
+      return points.length >= 2 ? { kind: 'polyline', points, closed: isClosed(raw) } : null;
+    }
+    case 'POLYLINE': {
+      // 顶点在后续的 VERTEX 实体里,由调用方收集后传入
+      const points = verticesOfPolyline ?? [];
+      return points.length >= 2 ? { kind: 'polyline', points, closed: isClosed(raw) } : null;
+    }
+    case 'CIRCLE':
+      return { kind: 'circle', center: { x: num(raw, 10), y: num(raw, 20) }, radius: num(raw, 40) };
+    case 'ARC':
+      return {
+        kind: 'arc',
+        center: { x: num(raw, 10), y: num(raw, 20) },
+        radius: num(raw, 40),
+        startDeg: num(raw, 50),
+        endDeg: num(raw, 51),
+      };
+    case 'ELLIPSE': {
+      // 11/21 是"相对圆心的长轴端点",40 是短长轴比,41/42 是起止参数(弧度,非角度)
+      const mx = num(raw, 11);
+      const my = num(raw, 21);
+      const rx = Math.hypot(mx, my);
+      const ratio = num(raw, 40, 1) || 1;
+      return {
+        kind: 'ellipse',
+        center: { x: num(raw, 10), y: num(raw, 20) },
+        rx,
+        ry: rx * Math.abs(ratio),
+        rotationDeg: Math.atan2(my, mx) * DEG,
+        startDeg: num(raw, 41, 0) * DEG,
+        endDeg: num(raw, 42, Math.PI * 2) * DEG,
+      };
+    }
+    case 'TEXT':
+    case 'MTEXT': {
+      // MTEXT 长文本会拆成 1/3 两个组码,这里拼接
+      const body = raw.codes
+        .filter((c) => c[0] === 1 || c[0] === 3)
+        .map((c) => c[1])
+        .join('');
+      return { kind: 'text', at: { x: num(raw, 10), y: num(raw, 20) }, height: num(raw, 40), text: body, rotationDeg: num(raw, 50) };
+    }
+    case 'POINT':
+      return { kind: 'point', at: { x: num(raw, 10), y: num(raw, 20) } };
+    case 'SOLID':
+    case '3DFACE': {
+      // 四个角点,按闭合折线画(预览足够)
+      const corners = [
+        { x: num(raw, 10), y: num(raw, 20) },
+        { x: num(raw, 11), y: num(raw, 21) },
+        { x: num(raw, 12), y: num(raw, 22) },
+        { x: num(raw, 13), y: num(raw, 23) },
+      ];
+      return { kind: 'polyline', points: corners, closed: true };
+    }
+    default:
+      return null;
+  }
+};
+
+const growBounds = (b: DxfBounds, x: number, y: number): void => {
+  if (!Number.isFinite(x) || !Number.isFinite(y)) return;
+  if (x < b.minX) b.minX = x;
+  if (y < b.minY) b.minY = y;
+  if (x > b.maxX) b.maxX = x;
+  if (y > b.maxY) b.maxY = y;
+};
+
+const entityBounds = (e: DxfEntity, b: DxfBounds): void => {
+  switch (e.kind) {
+    case 'line':
+      growBounds(b, e.a.x, e.a.y);
+      growBounds(b, e.b.x, e.b.y);
+      break;
+    case 'polyline':
+      e.points.forEach((p) => growBounds(b, p.x, p.y));
+      break;
+    case 'circle':
+    case 'arc':
+      growBounds(b, e.center.x - e.radius, e.center.y - e.radius);
+      growBounds(b, e.center.x + e.radius, e.center.y + e.radius);
+      break;
+    case 'ellipse': {
+      const r = Math.max(e.rx, e.ry);
+      growBounds(b, e.center.x - r, e.center.y - r);
+      growBounds(b, e.center.x + r, e.center.y + r);
+      break;
+    }
+    case 'text':
+      growBounds(b, e.at.x, e.at.y);
+      // 文字高度也算进范围,避免只有标注的图被"缩放到看不见"
+      growBounds(b, e.at.x + e.text.length * e.height * 0.6, e.at.y + e.height);
+      break;
+    case 'point':
+      growBounds(b, e.at.x, e.at.y);
+      break;
+  }
+};
+
+/**
+ * 解析 DXF 文本。只读 ENTITIES 段;解析失败/无实体时返回空列表,不抛异常。
+ */
+export const parseDxf = (dxfText: string): DxfParseResult => {
+  const entities: DxfEntity[] = [];
+  let skipped = 0;
+  const pairs = toPairs(dxfText);
+
+  let inEntities = false;
+  let raw: RawEntity | null = null;
+  // POLYLINE 的顶点来自后续 VERTEX 实体,需要跨实体累积
+  let polylineVertices: Array<{ x: number; y: number }> | null = null;
+
+  const flush = () => {
+    if (!raw) return;
+    const built = toEntity(raw, raw.type === 'POLYLINE' ? polylineVertices : null);
+    if (built) entities.push(built);
+    else skipped++;
+    raw = null;
+    polylineVertices = null;
+  };
+
+  for (let i = 0; i < pairs.length; i++) {
+    const [code, value] = pairs[i];
+    const token = value.trim();
+
+    if (code === 0) {
+      // 实体/段的边界
+      if (raw?.type === 'POLYLINE' && token === 'VERTEX') {
+        // 顶点属于父 POLYLINE:这里只收集它的组码,不 flush 父实体
+        const vertex: RawEntity = { type: 'VERTEX', codes: [] };
+        const vCodes: Array<[number, string]> = [];
+        let j = i + 1;
+        while (j < pairs.length && pairs[j][0] !== 0) {
+          vCodes.push(pairs[j]);
+          j++;
+        }
+        vertex.codes = vCodes;
+        const vx = num(vertex, 10);
+        const vy = num(vertex, 20);
+        if (Number.isFinite(vx) && Number.isFinite(vy)) polylineVertices!.push({ x: vx, y: vy });
+        i = j - 1;
+        continue;
+      }
+
+      flush();
+
+      if (token === 'SECTION') {
+        // 下一组是段名
+        const name = pairs[i + 1]?.[1]?.trim();
+        inEntities = name === 'ENTITIES';
+        if (pairs[i + 1]) i++;
+        continue;
+      }
+      if (token === 'ENDSEC') {
+        inEntities = false;
+        continue;
+      }
+      if (!inEntities) continue; // BLOCKS/HEADER 等段内的实体不参与预览
+      raw = { type: token, codes: [] };
+      if (token === 'POLYLINE') polylineVertices = [];
+      continue;
+    }
+
+    if (raw && inEntities) raw.codes.push([code, value]);
+  }
+  flush();
+
+  let bounds: DxfBounds | null = null;
+  if (entities.length) {
+    bounds = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
+    entities.forEach((e) => entityBounds(e, bounds!));
+    if (!Number.isFinite(bounds.minX) || bounds.minX > bounds.maxX) bounds = null;
+  }
+
+  return { entities, skipped, bounds };
+};

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

@@ -17,6 +17,11 @@ 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 回退为下载。
+export const isDxfFile = (filename: string): boolean => /\.dxf$/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

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

@@ -15,6 +15,8 @@ export const fetchPreviewFile = async (url: string, filename: string): Promise<A
     yaml: 'text/yaml',
     yml: 'text/yaml',
     csv: 'text/csv',
+    // .dxf 是明文,按文本取回后由预览面板本地解析渲染
+    dxf: 'text/plain',
   };
   const id = `report-${url}`;
   const uploadTime = new Date().toISOString();

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

@@ -7,3 +7,4 @@ export * from './theme';
 export * from './filePreview';
 export * from './messageKey';
 export * from './clipboard';
+export * from './dxf';