Prechádzať zdrojové kódy

feat:PC端和桌面端新增问数中md表格的导出,以及md格式表格样式

韩洋 10 mesiacov pred
rodič
commit
e7ee3c1362

+ 96 - 1
package-lock.json

@@ -35,7 +35,8 @@
         "vue": "3.5.13",
         "vue-i18n": "10.0.5",
         "vue-router": "4.5.0",
-        "wujie-vue3": "1.0.23"
+        "wujie-vue3": "1.0.23",
+        "xlsx": "0.18.5"
       },
       "devDependencies": {
         "@vitejs/plugin-vue": "5.2.1",
@@ -1427,6 +1428,14 @@
         "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
       }
     },
+    "node_modules/adler-32": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz",
+      "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/ajv": {
       "version": "6.12.6",
       "dev": true,
@@ -2382,6 +2391,18 @@
         "url": "https://github.com/sponsors/wooorm"
       }
     },
+    "node_modules/cfb": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmmirror.com/cfb/-/cfb-1.2.2.tgz",
+      "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
+      "dependencies": {
+        "adler-32": "~1.3.0",
+        "crc-32": "~1.2.0"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/chalk": {
       "version": "4.1.2",
       "dev": true,
@@ -2453,6 +2474,14 @@
         "tiny-emitter": "^2.0.0"
       }
     },
+    "node_modules/codepage": {
+      "version": "1.15.0",
+      "resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz",
+      "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/color": {
       "version": "3.2.1",
       "license": "MIT",
@@ -2625,6 +2654,17 @@
       "version": "1.0.3",
       "license": "MIT"
     },
+    "node_modules/crc-32": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz",
+      "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
+      "bin": {
+        "crc32": "bin/crc32.njs"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/cross-env": {
       "version": "7.0.3",
       "dev": true,
@@ -3868,6 +3908,14 @@
         "node": ">= 6"
       }
     },
+    "node_modules/frac": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/frac/-/frac-1.1.2.tgz",
+      "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/fs.realpath": {
       "version": "1.0.0",
       "dev": true,
@@ -5497,6 +5545,17 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/ssf": {
+      "version": "0.11.2",
+      "resolved": "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz",
+      "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
+      "dependencies": {
+        "frac": "~1.1.2"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/ssr-window": {
       "version": "3.0.0",
       "resolved": "https://registry.npmmirror.com/ssr-window/-/ssr-window-3.0.0.tgz",
@@ -6033,6 +6092,22 @@
       "resolved": "https://registry.npmmirror.com/wildcard/-/wildcard-1.1.2.tgz",
       "integrity": "sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng=="
     },
+    "node_modules/wmf": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/wmf/-/wmf-1.0.2.tgz",
+      "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/word": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmmirror.com/word/-/word-0.3.0.tgz",
+      "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/word-wrap": {
       "version": "1.2.5",
       "dev": true,
@@ -6079,6 +6154,26 @@
         "vue": "^3.0.0"
       }
     },
+    "node_modules/xlsx": {
+      "version": "0.18.5",
+      "resolved": "https://registry.npmmirror.com/xlsx/-/xlsx-0.18.5.tgz",
+      "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
+      "dependencies": {
+        "adler-32": "~1.3.0",
+        "cfb": "~1.2.1",
+        "codepage": "~1.15.0",
+        "crc-32": "~1.2.1",
+        "ssf": "~0.11.2",
+        "wmf": "~1.0.1",
+        "word": "~0.3.0"
+      },
+      "bin": {
+        "xlsx": "bin/xlsx.njs"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/xml-name-validator": {
       "version": "4.0.0",
       "dev": true,

+ 2 - 1
package.json

@@ -53,7 +53,8 @@
     "vue": "3.5.13",
     "vue-i18n": "10.0.5",
     "vue-router": "4.5.0",
-    "wujie-vue3": "1.0.23"
+    "wujie-vue3": "1.0.23",
+    "xlsx": "0.18.5"
   },
   "build": {
     "appId": "com.example.damoxing_desk",

+ 6 - 1
src/modules/chat-item/Answer.vue

@@ -72,7 +72,12 @@
       </div>
       <div class="answer-item-content" v-if="chat_type === 'znws'">
         <!-- 智能问数的组件渲染和报告生成的一致,先使用同一个,后续有变动再单独拆分 -->
-        <DialogReportContent :is_loading="is_content_loading" :type="type" :result_list="report_content">
+        <DialogReportContent
+          :is_loading="is_content_loading"
+          :type="type"
+          :answer_status="answer_status"
+          :result_list="report_content"
+        >
           <DialogActions v-if="answer_status == 2" class="answer-actions-btns" :type="type" @copy="handleReportCopy" />
         </DialogReportContent>
       </div>

+ 31 - 15
src/modules/chat-item/DialogBgclContent.vue

@@ -18,7 +18,7 @@ const props = defineProps({
   /**
    * 文本内容
    */
-  content: String,
+  content: String
 });
 
 const content_style = computed(() => {
@@ -30,10 +30,8 @@ const content_style = computed(() => {
     };
   }
 });
-const content_list = computed(() => {
-
-})
-onMounted(() => { });
+const content_list = computed(() => {});
+onMounted(() => {});
 </script>
 
 <style lang="css" scoped>
@@ -57,7 +55,6 @@ onMounted(() => { });
   line-height: 24px;
 }
 
-
 .loading {
   display: flex;
   align-items: center;
@@ -65,23 +62,42 @@ onMounted(() => { });
 }
 </style>
 <style>
+/* 表格基础样式 */
 .answer-content-text table {
-  border-collapse: collapse;
+  width: 100%;
+  border-collapse: collapse; /* 合并边框 */
+  border-spacing: 0;
+  font-size: 14px;
+  line-height: 1.5;
 }
 
-.answer-content-text table,
-.answer-content-text th,
-.answer-content-text td {
-  border: 1px solid rgb(184, 179, 179);
+/* 表头样式 */
+.answer-content-text th {
+  padding: 12px 16px;
+  background-color: #f5f5f5; /* 浅灰色背景 */
+  border: 1px solid #e8e8e8; /* 边框颜色 */
+  font-weight: 500;
+  text-align: center; /* 表头文字居中 */
 }
 
-.answer-content-text th,
+/* 单元格样式 */
 .answer-content-text td {
-  padding: 8px;
-  text-align: left;
+  padding: 12px 16px;
+  border: 1px solid #e8e8e8;
+  text-align: left; /* 默认左对齐,会被Markdown的对齐方式覆盖 */
+}
+
+/* 隔行变色(可选) */
+.answer-content-text tr:nth-child(even) {
+  background-color: #fafafa;
+}
+
+/* 鼠标悬停效果(可选) */
+.answer-content-text tr:hover {
+  background-color: #f0f7ff;
 }
 
-.answer-content-text .katex-display>.katex {
+.answer-content-text .katex-display > .katex {
   white-space: pre-wrap !important;
 }
 

+ 27 - 8
src/modules/chat-item/DialogContent.vue

@@ -81,20 +81,39 @@ onMounted(() => {});
 }
 </style>
 <style>
+/* 表格基础样式 */
 .answer-content-text table {
-  border-collapse: collapse;
+  width: 100%;
+  border-collapse: collapse; /* 合并边框 */
+  border-spacing: 0;
+  font-size: 14px;
+  line-height: 1.5;
 }
 
-.answer-content-text table,
-.answer-content-text th,
-.answer-content-text td {
-  border: 1px solid rgb(184, 179, 179);
+/* 表头样式 */
+.answer-content-text th {
+  padding: 12px 16px;
+  background-color: #f5f5f5; /* 浅灰色背景 */
+  border: 1px solid #e8e8e8; /* 边框颜色 */
+  font-weight: 500;
+  text-align: center; /* 表头文字居中 */
 }
 
-.answer-content-text th,
+/* 单元格样式 */
 .answer-content-text td {
-  padding: 8px;
-  text-align: left;
+  padding: 12px 16px;
+  border: 1px solid #e8e8e8;
+  text-align: left; /* 默认左对齐,会被Markdown的对齐方式覆盖 */
+}
+
+/* 隔行变色(可选) */
+.answer-content-text tr:nth-child(even) {
+  background-color: #fafafa;
+}
+
+/* 鼠标悬停效果(可选) */
+.answer-content-text tr:hover {
+  background-color: #f0f7ff;
 }
 
 .answer-content-text .katex-display > .katex {

+ 152 - 20
src/modules/chat-item/DialogReportContent.vue

@@ -3,8 +3,8 @@
     <div v-if="is_loading && type == 'bgsc'" class="loading"><a-spin></a-spin> <span>报告生成中...</span></div>
     <div v-if="is_loading && type == 'znws'" class="loading"><a-spin></a-spin> <span>正在思考中...</span></div>
     <div class="answer-content-text answer-content-report" v-for="(item, index) in result_list" :key="index">
-      <div class="answer-content" v-if="typeof item === 'string'" v-html="renderHtml(item)"></div>
-      <HomeChart v-if="typeof item === 'object'" :origin="'home'" :data="item"></HomeChart>
+      <div ref="answerRef" class="answer-content" v-if="typeof item === 'string'" v-html="renderHtml(item)"></div>
+      <HomeChart v-if="typeof item === 'object' && item.type != 'excel'" :origin="'home'" :data="item"></HomeChart>
     </div>
     <slot></slot>
     <div class="report-loading" v-if="answer_status === 1">
@@ -16,9 +16,13 @@
 </template>
 
 <script setup>
-import { computed, onMounted } from 'vue';
+import { ref, computed, watch, onMounted, render, h } from 'vue';
 import HomeChart from '../chat_echart/HomeChart.vue';
 import marked from '../../3rd-libs/marked';
+import { Tooltip } from '@arco-design/web-vue';
+import { IconDownload } from '@arco-design/web-vue/es/icon';
+import * as XLSX from 'xlsx';
+// import TableHasDownload from '../chat_files/TableHasDownload.vue';
 // 接收markdown数据,进行一些转换
 const props = defineProps({
   /**
@@ -38,6 +42,8 @@ const props = defineProps({
    * */
   answer_status: Boolean
 });
+const answerRef = ref(null);
+const exportButtonList = ref([]);
 
 const content_style = computed(() => {
   if (props.type === 'q') {
@@ -71,7 +77,103 @@ const renderHtml = (item) => {
     .replace(/<\/code><\/pre>/gs, '</p>');
   return renderContent;
 };
-onMounted(() => {});
+
+const createSingTableExportBtn = (table, index) => {
+  // 创建按钮容器
+  const btnContainer = document.createElement('div');
+  btnContainer.className = 'arco-export-btn';
+  btnContainer.dataset.tableIndex = index; //标记表格索引
+  console.log(btnContainer, 112121);
+  // 渲染组件
+  render(
+    h(
+      Tooltip,
+      { content: `导出表格${index + 1}` },
+      {
+        default: () =>
+          h(IconDownload, {
+            size: 18,
+            style: { cursor: 'pointer', color: 'var(--color-primary)' },
+            onClick: () => handleSingleTableExport(table, index)
+          })
+      }
+    ),
+    btnContainer
+  );
+  // setTimeout(() => {
+  // console.log(table.parentNode, 99);
+  if (table.nextSibling) {
+    table.parentNode.insertBefore(btnContainer, table.nextSibling);
+  } else {
+    table.parentNode.appendChild(btnContainer);
+  }
+
+  // }, 30);
+
+  exportButtonList.value.push(btnContainer);
+};
+const handleSingleTableExport = (table, tableIndex) => {
+  const workbook = XLSX.utils.book_new();
+  const worksheet = XLSX.utils.table_to_sheet(table);
+  // 添加工作表到工作簿
+  XLSX.utils.book_append_sheet(workbook, worksheet, `表格${tableIndex + 1}数据`);
+
+  // 生成并下载Excel文件
+  XLSX.writeFile(workbook, `表格${tableIndex + 1}_数据导出.xlsx`);
+};
+watch(
+  () => props.answer_status,
+  (new_value) => {
+    // console.log('变化', props.answer_status);
+    if (props.type == 'znws' && new_value / 1 == 2) {
+      setTimeout(() => {
+        const tableContainers = document.querySelectorAll('.answer-content');
+        console.log(tableContainers);
+        const tableContainers_filter = Array.from(tableContainers).filter((item) => !item.getAttribute('data-table'));
+        const allTables = [];
+        tableContainers_filter.forEach((item) => {
+          item.setAttribute('data-table', '1');
+          if (item.querySelectorAll('table') && item.querySelectorAll('table').length) {
+            allTables.push(...Array.from(item.querySelectorAll('table')));
+          }
+        });
+        if (!allTables || !allTables.length) return;
+        Array.from(allTables).forEach((table, index) => {
+          createSingTableExportBtn(table, index);
+        });
+        // const tableContainer = answerRef.value;
+        // tableContainer.setAttribute('data-table', '1');
+        // const allTables = tableContainer?.querySelectorAll('table');
+        // console.log(allTables, 111);
+        // if (!allTables || !allTables.length) return;
+        // Array.from(allTables).forEach((table, index) => {
+        //   createSingTableExportBtn(table, index);
+        // });
+      }, 1000);
+    }
+  }
+);
+onMounted(() => {
+  if (props.result_list && props.result_list.length) {
+    if (props.type == 'znws') {
+      const tableContainers = document.querySelectorAll('.answer-content');
+      console.log(tableContainers);
+      const tableContainers_filter = Array.from(tableContainers).filter((item) => !item.getAttribute('data-table'));
+      const allTables = [];
+      tableContainers_filter.forEach((item) => {
+        item.setAttribute('data-table', '1');
+        if (item.querySelectorAll('table') && item.querySelectorAll('table').length) {
+          allTables.push(...Array.from(item.querySelectorAll('table')));
+        }
+      });
+      // const allTables = tableContainer?.querySelectorAll('table');
+      if (!allTables || !allTables.length) return;
+      Array.from(allTables).forEach((table, index) => {
+        createSingTableExportBtn(table, index);
+      });
+    }
+  }
+});
 </script>
 
 <style lang="css" scoped>
@@ -114,22 +216,6 @@ p {
 }
 </style>
 <style>
-.answer-content-text table {
-  border-collapse: collapse;
-}
-
-.answer-content-text table,
-.answer-content-text th,
-.answer-content-text td {
-  border: 1px solid rgb(184, 179, 179);
-}
-
-.answer-content-text th,
-.answer-content-text td {
-  padding: 8px;
-  text-align: left;
-}
-
 .refferences {
   background: rgb(238, 243, 254);
   border: 1px solid rgb(255, 255, 255);
@@ -155,3 +241,49 @@ p {
   width: 32px;
 }
 </style>
+<style>
+/* 表格基础样式 */
+.answer-content-text table {
+  width: 100%;
+  border-collapse: collapse; /* 合并边框 */
+  border-spacing: 0;
+  font-size: 14px;
+  line-height: 1.5;
+}
+
+/* 表头样式 */
+.answer-content-text th {
+  padding: 12px 16px;
+  background-color: #f5f5f5; /* 浅灰色背景 */
+  border: 1px solid #e8e8e8; /* 边框颜色 */
+  font-weight: 500;
+  text-align: center; /* 表头文字居中 */
+}
+
+/* 单元格样式 */
+.answer-content-text td {
+  padding: 12px 16px;
+  border: 1px solid #e8e8e8;
+  text-align: left; /* 默认左对齐,会被Markdown的对齐方式覆盖 */
+}
+
+/* 隔行变色(可选) */
+.answer-content-text tr:nth-child(even) {
+  background-color: #fafafa;
+}
+
+/* 鼠标悬停效果(可选) */
+.answer-content-text tr:hover {
+  background-color: #f0f7ff;
+}
+.arco-export-btn {
+  margin: 8px 0 24px 0;
+  display: inline-block;
+}
+.arco-export-btn :deep(.arco-icon) {
+  transition: color 0.2s ease;
+}
+.arco-export-btn :deep(.arco-icon:hover) {
+  color: var(--primary-default);
+}
+</style>

+ 276 - 0
src/modules/chat_files/TableHasDownload copy.vue

@@ -0,0 +1,276 @@
+<template>
+  <div class="table-has-download">
+    <!-- <a-table :columns="columns" :data="tableData" bordered /> -->
+    <!-- <table></table> -->
+    <div v-html="table_html"></div>
+    <a-tooltip content="导出">
+      <icon-arrow-down class="download-icon" @click="toDownLoad" />
+    </a-tooltip>
+    <!-- <a-button @click="toDownLoad" style="margin-top: 16px">下载(列宽自适应)</a-button> -->
+  </div>
+</template>
+
+<script setup>
+import { reactive, ref } from 'vue';
+import marked from '../../3rd-libs/marked';
+// import * as XLSX from 'xlsx';
+
+// 确保所有叶子列都有 dataIndex(修复核心)
+const columns = [
+  {
+    title: 'Name',
+    dataIndex: 'name'
+  },
+  {
+    title: 'Salary',
+    dataIndex: 'salary'
+  },
+  {
+    title: 'Address',
+    dataIndex: 'address'
+  },
+  {
+    title: 'Email',
+    dataIndex: 'email'
+  }
+];
+const tableData = reactive([
+  {
+    key: '1',
+    name: 'Jane Doe',
+    salary: 23000,
+    address: '32 Park Road, London',
+    email: 'jane.doe@example.com'
+  },
+  {
+    key: '2',
+    name: 'Alisa Ross',
+    salary: 25000,
+    address: '35 Park Road, London',
+    email: 'alisa.ross@example.com'
+  },
+  {
+    key: '3',
+    name: 'Kevin Sandra',
+    salary: 22000,
+    address: '31 Park Road, London',
+    email: 'kevin.sandra@example.com'
+  },
+  {
+    key: '4',
+    name: 'Ed Hellen',
+    salary: 17000,
+    address: '42 Park Road, London',
+    email: 'ed.hellen@example.com'
+  },
+  {
+    key: '5',
+    name: 'William Smith',
+    salary: 27000,
+    address: '62 Park Road, London',
+    email: 'william.smith@example.com'
+  },
+  {
+    key: '5',
+    name: 'William Smith',
+    salary: 27000,
+    address: '62 Park Road, London',
+    email: 'william.smith@example.com'
+  },
+  {
+    key: '5',
+    name: 'William Smith',
+    salary: 27000,
+    address: '62 Park Road, London',
+    email: 'william.smith@example.com'
+  },
+  {
+    key: '5',
+    name: 'William Smith',
+    salary: 27000,
+    address: '62 Park Road, London',
+    email: 'william.smith@example.com'
+  },
+  {
+    key: '5',
+    name: 'William Smith',
+    salary: 27000,
+    address: '62 Park Road, London',
+    email: 'william.smith@example.com'
+  },
+  {
+    key: '5',
+    name: 'William Smith',
+    salary: 27000,
+    address: '62 Park Road, London',
+    email: 'william.smith@example.com'
+  }
+]);
+
+const md_table = ref(`| 学科   | 满分 | 及格线 | 难度系数 |
+| :----- | ---: | :----: | :------- |
+| 数学   | 150  |   90   | 高       |
+| 语文   | 150  |   90   | 中       |
+| 英语   | 120  |   72   | 中       |
+| 物理   | 100  |   60   | 高       |`);
+
+const table_html = marked.parse(md_table.value);
+const toDownLoad = () => {
+  const { headerMatrix, headerMerges, maxLevel, flatCols } = parseMultiLevelHeaders(columns);
+  const { dataMatrix } = parseData(tableData, flatCols);
+  const dataMerges = parseDataMerges(tableData, maxLevel);
+
+  const allData = [...headerMatrix, ...dataMatrix];
+  const worksheet = XLSX.utils.aoa_to_sheet(allData);
+  worksheet['!merges'] = [...headerMerges, ...dataMerges];
+
+  adjustColumnWidths(worksheet, allData);
+
+  const workbook = XLSX.utils.book_new();
+  XLSX.utils.book_append_sheet(workbook, worksheet, '表格数据');
+  const fileName = `列宽自适应表格_${new Date().toLocaleDateString().replace(/\//g, '-')}.xlsx`;
+  XLSX.writeFile(workbook, fileName);
+};
+
+// 自动调整列宽
+function adjustColumnWidths(worksheet, allData) {
+  if (!allData.length) return;
+
+  const colCount = allData[0].length;
+  const maxLengths = new Array(colCount).fill(0);
+
+  allData.forEach((row) => {
+    row.forEach((cell, colIndex) => {
+      if (cell === undefined || cell === null) cell = '';
+      const cellStr = String(cell);
+      const length = cellStr.split('').reduce((len, char) => {
+        return len + (char.charCodeAt(0) > 127 ? 2 : 1);
+      }, 0);
+      if (length > maxLengths[colIndex]) {
+        maxLengths[colIndex] = length;
+      }
+    });
+  });
+
+  worksheet['!cols'] = maxLengths.map((length) => ({
+    wch: Math.max(length + 2, 8)
+  }));
+}
+
+// 解析多级表头
+function parseMultiLevelHeaders(columns) {
+  const flatCols = [];
+  let maxLevel = 1;
+
+  function traverse(cols, currentLevel, parentPath = []) {
+    cols.forEach((col) => {
+      const path = [...parentPath, col];
+      if (col.children && col.children.length > 0) {
+        const nextLevel = currentLevel + 1;
+        if (nextLevel > maxLevel) maxLevel = nextLevel;
+        traverse(col.children, nextLevel, path);
+      } else {
+        // 过滤掉没有 dataIndex 的列(避免后续报错)
+        if (col.dataIndex) {
+          flatCols.push({ path, depth: path.length, dataIndex: col.dataIndex });
+        }
+      }
+    });
+  }
+  traverse(columns, 1);
+
+  const headerMatrix = Array.from({ length: maxLevel }, () => []);
+  flatCols.forEach(({ path }) => {
+    path.forEach((col, levelIndex) => {
+      headerMatrix[levelIndex].push(col.title || '');
+    });
+  });
+
+  const headerMerges = [];
+  for (let level = 0; level < maxLevel; level++) {
+    let currentCol = 0;
+    while (currentCol < flatCols.length) {
+      const currentPath = flatCols[currentCol].path;
+      const currentTitle = currentPath[level]?.title;
+      if (!currentTitle) {
+        currentCol++;
+        continue;
+      }
+
+      let mergeCount = 1;
+      while (currentCol + mergeCount < flatCols.length) {
+        const nextPath = flatCols[currentCol + mergeCount].path;
+        const nextTitle = nextPath[level]?.title;
+        if (nextTitle === currentTitle) mergeCount++;
+        else break;
+      }
+
+      if (mergeCount > 1) {
+        headerMerges.push({
+          s: { r: level, c: currentCol },
+          e: { r: level, c: currentCol + mergeCount - 1 }
+        });
+      }
+      currentCol += mergeCount;
+    }
+  }
+
+  return { headerMatrix, headerMerges, maxLevel, flatCols };
+}
+
+// 处理数据行
+function parseData(tableData, flatCols) {
+  const dataMatrix = tableData.map((row) => {
+    return flatCols.map((col) => {
+      // 传入明确的 dataIndex(来自 flatCols 过滤后的数据)
+      return getValueByDataIndex(row, col.dataIndex);
+    });
+  });
+  return { dataMatrix };
+}
+
+// 处理数据合并
+function parseDataMerges(tableData, headerRowCount) {
+  const merges = [];
+  tableData.forEach((row, rowIndex) => {
+    const excelRow = headerRowCount + rowIndex;
+    if (!row.merges) return;
+
+    row.merges.forEach((merge) => {
+      merges.push({
+        s: { r: excelRow, c: merge.col },
+        e: {
+          r: excelRow + (merge.rowspan || 1) - 1,
+          c: merge.col + (merge.colspan || 1) - 1
+        }
+      });
+    });
+  });
+  return merges;
+}
+
+// 修复:增加 dataIndex 校验,避免 undefined
+const getValueByDataIndex = (row, dataIndex) => {
+  // 如果 dataIndex 不存在,直接返回空
+  if (!dataIndex) return '';
+  return dataIndex.split('.').reduce((obj, key) => {
+    return obj ? obj[key] : '';
+  }, row);
+};
+</script>
+
+<style scoped>
+.table-has-download {
+  position: relative;
+}
+.table-has-download {
+  padding: 20px;
+}
+.download-icon {
+  position: relative;
+  top: -28px;
+  left: 10px;
+  font-size: 24px;
+  cursor: pointer;
+}
+</style>

+ 121 - 0
src/modules/chat_files/TableHasDownload.vue

@@ -0,0 +1,121 @@
+<template>
+  <div class="table-has-download">
+    <div v-html="table_html" class="markdown-table-container"></div>
+    <a-tooltip content="导出">
+      <icon-arrow-down class="download-icon" @click="toDownLoad" />
+    </a-tooltip>
+  </div>
+</template>
+
+<script setup>
+import { reactive, ref } from 'vue';
+import marked from '../../3rd-libs/marked';
+import * as XLSX from 'xlsx';
+
+const md_table = ref(`## 表格测试 \n| 学科   | 满分 | 及格线 | 难度系数 |
+| :----- | ---: | :----: | :------- |
+| 数学   | 150  |   90   | 高       |
+| 语文   | 150  |   90   | 中       |
+| 英语   | 120  |   72   | 中       |
+| 物理   | 100  |   60   | 高       | \n
+- asdasdasdaldas;jaf
+- asdasdasdaldas;jaf
+- asdasdasdaldas;jaf
+- asdasdasdaldas;jaf
+`);
+
+const table_html = marked.parse(md_table.value);
+const toDownLoad = () => {
+  // 从DOM中获取表格元素
+  const tableContainer = document.querySelector('.markdown-table-container');
+  const table = tableContainer.querySelector('table');
+
+  if (!table) {
+    console.error('未找到表格元素');
+    return;
+  }
+
+  // 解析表格数据
+  const workbook = XLSX.utils.book_new();
+  const worksheet = XLSX.utils.table_to_sheet(table);
+
+  // 添加工作表到工作簿
+  XLSX.utils.book_append_sheet(workbook, worksheet, '表格数据');
+
+  // 生成并下载Excel文件
+  XLSX.(workbook, '表格数据导出.xlsx');
+};
+
+// 自动调整列宽
+</script>
+
+<style scoped>
+.table-has-download {
+  position: relative;
+}
+.table-has-download {
+  padding: 20px;
+}
+.download-icon {
+  position: relative;
+  top: -28px;
+  left: 10px;
+  font-size: 24px;
+  cursor: pointer;
+}
+.table-has-download {
+  position: relative;
+  padding: 20px;
+}
+
+.download-icon {
+  /* position: absolute;
+  top: 20px;
+  right: 20px; */
+  font-size: 24px;
+  cursor: pointer;
+  color: #1890ff; /* 蓝色图标,更显眼 */
+  margin-top: 30px;
+}
+
+/* Markdown表格容器样式 */
+.markdown-table-container {
+  margin-top: 10px; /* 给图标留出空间 */
+}
+</style>
+<style>
+/* 表格基础样式 */
+.markdown-table-container table {
+  width: 100%;
+  border-collapse: collapse; /* 合并边框 */
+  border-spacing: 0;
+  font-size: 14px;
+  line-height: 1.5;
+}
+
+/* 表头样式 */
+.markdown-table-container th {
+  padding: 12px 16px;
+  background-color: #f5f5f5; /* 浅灰色背景 */
+  border: 1px solid #e8e8e8; /* 边框颜色 */
+  font-weight: 500;
+  text-align: center; /* 表头文字居中 */
+}
+
+/* 单元格样式 */
+.markdown-table-container td {
+  padding: 12px 16px;
+  border: 1px solid #e8e8e8;
+  text-align: left; /* 默认左对齐,会被Markdown的对齐方式覆盖 */
+}
+
+/* 隔行变色(可选) */
+.markdown-table-container tr:nth-child(even) {
+  background-color: #fafafa;
+}
+
+/* 鼠标悬停效果(可选) */
+.markdown-table-container tr:hover {
+  background-color: #f0f7ff;
+}
+</style>

+ 14 - 0
src/modules/conversation-dialog/format-reg-string.js

@@ -47,3 +47,17 @@ export function splitStringByDelimiter(input, delimiter, replacementObject = { t
     afterDelimiter
   ];
 }
+
+/**
+ * 提取 </excel> 和 <excel/> 标签之间的内容,若无则返回原始内容
+ * @param {string} str - 待处理的字符串
+ * @returns {string} 标签内内容或原始字符串
+ */
+export function getContentBetweenExcelTags(str) {
+  // 正则规则:匹配以 </excel> 开头、<excel/> 结尾的字符串,捕获中间内容
+  // [\s\S]*? 匹配任意字符(包括换行),非贪婪模式避免多标签时过度匹配
+  const regex = /^<\/excel>([\s\S]*?)<excel\/>$/;
+
+  const match = str.match(regex);
+  return match ? match[1] : str; // 匹配成功返回中间内容,否则返回原字符串
+}

+ 3 - 1
src/views/Conversation.vue

@@ -22,7 +22,7 @@
       <!-- <Test></Test> -->
 
       <router-view ref="conversationContainer" :style="conversationChatStyle" @updateHistory="updateHistory" />
-
+      <!-- <TableHasDownload></TableHasDownload> -->
       <!-- <iframe src="../assets/1000548/index.html" frameborder="0"></iframe> -->
       <!-- <iframe src="http://localhost:5174/" frameborder="0"></iframe> -->
     </div>
@@ -42,9 +42,11 @@ import marked from '../3rd-libs/marked.js';
 
 import HomeChart from '../modules/chat_echart/HomeChart_cs.vue';
 import Test from './conversation/Text.vue';
+// import TableHasDownload from '../modules/chat_files/TableHasDownload.vue';
 
 import { isElectron } from '../utils/env';
 import { useRouter, useRoute } from 'vue-router';
+import { Table } from '@arco-design/web-vue';
 
 const router = useRouter();
 const route = useRoute();

+ 44 - 0
src/views/conversation/22.js

@@ -54,3 +54,47 @@ const data = reactive([{
   phone: '12345678',
   email: 'alisa.ross@example.com'
 }]);
+
+
+
+
+
+
+{
+  "
+  "column": [
+    {
+      title: 'Name',
+      dataIndex: 'name',
+    },
+    {
+      title: 'User Info',
+      children: [
+        {
+          title: 'Birthday',
+          dataIndex: 'birthday'
+        },
+        {
+          title: 'Address',
+          children: [{
+            title: 'City',
+            dataIndex: 'city'
+          }, {
+            title: 'Road',
+            dataIndex: 'road'
+          }, {
+            title: 'No.',
+            dataIndex: 'no'
+          }]
+        }
+      ]
+    },
+    {
+      title: 'salary',
+      dataIndex: 'salary',
+    },
+    {
+      title: 'Salary',
+      dataIndex: 'salary',
+    }]"
+}