Sfoglia il codice sorgente

Merge branch 'master' of http://39.97.59.228:8013/hrx/mky-vent-base

hongrunxia 4 settimane fa
parent
commit
51ac8da601

BIN
src/assets/images/ventAI/chatModalBlue/2-7.png


+ 1 - 0
src/assets/images/ventAI/chatModalBlue/2-7.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1786585089912" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6775" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M63.65 512c-0.01 0 0 0 0 0 2.04-148.61 27.84-271.66 103.31-345.05C240.34 91.49 363.39 65.68 512 63.65c148.61 2.04 271.66 27.84 345.05 103.31 75.46 73.38 101.27 196.43 103.3 345.04-2.04 148.61-27.85 271.66-103.31 345.05-73.38 75.46-196.43 101.27-345.04 103.3-148.61-2.04-271.66-27.84-345.05-103.31C91.49 783.66 65.68 660.61 63.65 512z" p-id="6776" fill="#ffffff"></path><path d="M452.66 353.01v317.98c0 24.52-20.06 44.59-44.59 44.59-24.52 0-44.59-20.06-44.59-44.59V353.01c0-24.52 20.06-44.59 44.59-44.59 24.53 0 44.59 20.07 44.59 44.59zM660.51 353.01v317.98c0 24.52-20.06 44.59-44.59 44.59-24.52 0-44.59-20.06-44.59-44.59V353.01c0-24.52 20.06-44.59 44.59-44.59 24.52 0 44.59 20.07 44.59 44.59z" fill="#ffffff" p-id="6777"></path></svg>

+ 0 - 1
src/design/themify/deepblue.less

@@ -87,7 +87,6 @@ html[data-theme='deepblue'] {
   --img-chat-agent-icon: url('/src/assets/images/ventAI/chatModalBlue/2-4.svg');
   --img-chat-copy-success-icon: url('/src/assets/images/ventAI/chatModalBlue/2-5.svg');
   --img-chat-send-icon: url('/src/assets/images/ventAI/chatModalBlue/2-6.svg');
-  --img-chat-close-bg: url('/src/assets/images/ventAI/chatModalBlue/2-7.png');
   --img-chat-download-icon: url('/src/assets/images/ventAI/chatModalBlue/2-8.png');
   --img-chat-input-bg: url('/src/assets/images/ventAI/chatModalBlue/2-13.png');
   --img-chat-attach-icon: url('/src/assets/images/ventAI/chatModalBlue/3-7.svg');

+ 1 - 0
src/design/themify/default.less

@@ -94,6 +94,7 @@ html {
   --img-chat-agent-icon: url('/src/assets/images/ventAI/chatModalBlue/2-4.svg');
   --img-chat-copy-success-icon: url('/src/assets/images/ventAI/chatModalBlue/2-5.svg');
   --img-chat-send-icon: url('/src/assets/images/ventAI/chatModalBlue/2-6.svg');
+  --img-chat-stop-icon: url('/src/assets/images/ventAI/chatModalBlue/2-7.svg');
   --img-chat-download-icon: url('/src/assets/images/ventAI/chatModalBlue/2-8.png');
   --img-chat-input-bg: url('/src/assets/images/ventAI/chatModalBlue/1-7.svg');
   --img-chat-attach-icon: url('/src/assets/images/ventAI/chatModalBlue/3-7.svg');

+ 1 - 0
src/views/vent/monitorManager/camera/index.vue

@@ -96,6 +96,7 @@ async function onClick(node) {
   selected.pid = node.pid;
   selected.title = node.title;
   selected.isFolder = node.isFolder;
+  pagination.current = 1;
   if (node.pid != 'root') {
      nodeData = Object.assign({}, node)
     if (node.isFolder) {

+ 260 - 193
src/views/vent/monitorManager/comment/HandlerHistoryTable.vue

@@ -1,218 +1,285 @@
 <template>
   <div class="handler-history-table">
-    <BasicTable ref="handlerHistory" @register="registerTable" />
+    <BasicTable ref="handlerHistory" @register="registerTable">
+      <template #expandedRowRender="{ record }">
+        <a-table
+          size="small"
+          :columns="innerColumns"
+          :data-source="innerDataMap[record.groupcode] || []"
+          :pagination="false"
+          :loading="innerLoadingMap[record.groupcode]"
+          row-key="id"
+        >
+          <template #bodyCell="{ column }"> </template>
+        </a-table>
+      </template>
+    </BasicTable>
   </div>
 </template>
 
 <script lang="ts" name="system-user" setup>
-  //ts语法
-  import { watch, ref } from 'vue';
-  import { BasicTable } from '/@/components/Table';
-  import { useListPage } from '/@/hooks/system/useListPage';
-  import { getTableHeaderColumns } from '/@/hooks/web/useWebColumns';
-  import { defHttp } from '/@/utils/http/axios';
-  import dayjs from 'dayjs';
-  import { getAutoScrollContainer } from '/@/utils/common/compUtils';
+//ts语法
+import { watch, ref, reactive } from 'vue';
+import { BasicTable } from '/@/components/Table';
+import { useListPage } from '/@/hooks/system/useListPage';
+import { getTableHeaderColumns } from '/@/hooks/web/useWebColumns';
+import { defHttp } from '/@/utils/http/axios';
+import dayjs from 'dayjs';
+import { getAutoScrollContainer } from '/@/utils/common/compUtils';
 
-  const list = (params) => defHttp.get({ url: '/safety/ventanalyDevicesetLog/list', params });
+const list = (params) => defHttp.get({ url: '/safety/ventanalyDevicesetLog/list', params });
+const getDevicesetLog = (params) => defHttp.post({ url: '/ventanaly-device/safety/ventanalyDevicesetLog/queryByGroupCode', params });
+const props = defineProps({
+  columnsType: {
+    type: String,
+    required: true,
+  },
+  deviceType: {
+    type: String,
+    required: true,
+  },
+  deviceListApi: {
+    type: Function,
+    required: false,
+  },
+  designScope: {
+    type: String,
+  },
+  sysId: {
+    type: String,
+  },
+  deviceId: {
+    type: String,
+    default: '',
+  },
+  scroll: {
+    type: Object,
+    default: { y: 0 },
+  },
+});
 
-  const props = defineProps({
-    columnsType: {
-      type: String,
-      required: true,
-    },
-    deviceType: {
-      type: String,
-      required: true,
-    },
-    deviceListApi: {
-      type: Function,
-      required: false,
-    },
-    designScope: {
-      type: String,
-    },
-    sysId: {
-      type: String,
-    },
-    deviceId: {
-      type: String,
-      default: '',
-    },
-    scroll: {
-      type: Object,
-      default: { y: 0 },
-    },
-  });
+const handlerHistory = ref();
+const columns = ref([]);
+const tableScroll = props.scroll.y ? ref({ y: props.scroll.y - 100 }) : ref({});
+const innerColumns = ref([
+  {
+    title: '设备名称',
+    dataIndex: 'devicename',
+    key: 'devicename',
+  },
+  {
+    title: '用户',
+    dataIndex: 'realname',
+    key: 'realname',
+  },
+  {
+    title: '操作记录',
+    dataIndex: 'strremark',
+    key: 'strremark',
+  },
 
-  const handlerHistory = ref();
-  const columns = ref([]);
-  const tableScroll = props.scroll.y ? ref({ y: props.scroll.y - 100 }) : ref({});
+  {
+    title: 'IP',
+    dataIndex: 'ip',
+    key: 'ip',
+  },
+  {
+    title: '操作时间',
+    dataIndex: 'createTime',
+    key: 'createTime',
+  },
+]);
+// 展开行相关
+const expandedRowKeys = ref<string[]>([]);
+const innerDataMap = reactive<Record<string, any[]>>({});
+const innerLoadingMap = reactive<Record<string, boolean>>({});
 
-  watch(
-    () => {
-      return props.columnsType;
-    },
-    (newVal) => {
-      const column = getTableHeaderColumns(newVal);
-      if (column && column.length < 1) {
-        const arr = newVal.split('_');
-        const columnKey = arr.reduce((prev, cur, index) => {
-          if (index !== arr.length - 2) {
-            return prev + '_' + cur;
-          } else {
-            return prev;
-          }
-        });
-        columns.value = getTableHeaderColumns(arr[0] + '_history');
-      } else {
-        columns.value = column;
-      }
-      if (handlerHistory.value) reload();
-    },
-    {
-      immediate: true,
+// 展开/收起事件
+const onExpand = async (expanded: boolean, record: Recordable) => {
+  if (!expanded) return;
+  const key = record.groupcode;
+  if (innerDataMap[key]) return;
+  innerLoadingMap[key] = true;
+  try {
+    const res = await getDevicesetLog({ groupCode: key });
+    innerDataMap[key] = res.records || res || [];
+  } catch (e) {
+    console.error('获取子数据失败:', e);
+    innerDataMap[key] = [];
+  } finally {
+    innerLoadingMap[key] = false;
+  }
+};
+watch(
+  () => {
+    return props.columnsType;
+  },
+  (newVal) => {
+    const column = getTableHeaderColumns(newVal);
+    if (column && column.length < 1) {
+      const arr = newVal.split('_');
+      const columnKey = arr.reduce((prev, cur, index) => {
+        if (index !== arr.length - 2) {
+          return prev + '_' + cur;
+        } else {
+          return prev;
+        }
+      });
+      columns.value = getTableHeaderColumns(arr[0] + '_history');
+    } else {
+      columns.value = column;
     }
-  );
+    if (handlerHistory.value) reload();
+  },
+  {
+    immediate: true,
+  }
+);
 
-  watch(
-    () => props.scroll.y,
-    (newVal) => {
-      if (newVal) {
-        tableScroll.value = { y: newVal - 100 };
-      } else {
-        tableScroll.value = {};
-      }
+watch(
+  () => props.scroll.y,
+  (newVal) => {
+    if (newVal) {
+      tableScroll.value = { y: newVal - 100 };
+    } else {
+      tableScroll.value = {};
     }
-  );
-
-  // 列表页面公共参数、方法
-  const { tableContext } = useListPage({
-    tableProps: {
-      api: list,
-      columns: columns,
-      canResize: true,
-      showTableSetting: false,
-      showActionColumn: false,
-      showIndexColumn: true,
-      bordered: false,
-      size: 'small',
-      scroll: tableScroll,
-      formConfig: {
-        labelAlign: 'left',
-        showAdvancedButton: false,
-        baseColProps: {
-          // offset: 0.5,
-          xs: 24,
-          sm: 24,
-          md: 24,
-          lg: 9,
-          xl: 7,
-          xxl: 4,
+  }
+);
+const onExpandedRowsChange = (keys: string[]) => {
+  expandedRowKeys.value = keys;
+};
+// 列表页面公共参数、方法
+const { tableContext } = useListPage({
+  tableProps: {
+    api: list,
+    columns: columns,
+    canResize: true,
+    showTableSetting: false,
+    showActionColumn: false,
+    showIndexColumn: true,
+    bordered: false,
+    size: 'small',
+    scroll: tableScroll,
+    formConfig: {
+      labelAlign: 'left',
+      showAdvancedButton: false,
+      baseColProps: {
+        // offset: 0.5,
+        xs: 24,
+        sm: 24,
+        md: 24,
+        lg: 9,
+        xl: 7,
+        xxl: 4,
+      },
+      schemas: [
+        {
+          field: 'createTime_begin',
+          label: '开始时间',
+          component: 'DatePicker',
+          defaultValue: dayjs().add(-30, 'day').format('YYYY-MM-DD HH:mm:ss'),
+          required: true,
+          componentProps: {
+            showTime: true,
+            valueFormat: 'YYYY-MM-DD HH:mm:ss',
+            getPopupContainer: getAutoScrollContainer,
+          },
+          colProps: {
+            span: 4,
+          },
         },
-        schemas: [
-          {
-            field: 'createTime_begin',
-            label: '开始时间',
-            component: 'DatePicker',
-            defaultValue: dayjs().add(-30, 'day').format('YYYY-MM-DD HH:mm:ss'),
-            required: true,
-            componentProps: {
-              showTime: true,
-              valueFormat: 'YYYY-MM-DD HH:mm:ss',
-              getPopupContainer: getAutoScrollContainer,
-            },
-            colProps: {
-              span: 4,
-            },
+        {
+          field: 'createTime_end',
+          label: '结束时间',
+          component: 'DatePicker',
+          defaultValue: dayjs(),
+          required: true,
+          componentProps: {
+            showTime: true,
+            valueFormat: 'YYYY-MM-DD HH:mm:ss',
+            getPopupContainer: getAutoScrollContainer,
           },
-          {
-            field: 'createTime_end',
-            label: '结束时间',
-            component: 'DatePicker',
-            defaultValue: dayjs(),
-            required: true,
-            componentProps: {
-              showTime: true,
-              valueFormat: 'YYYY-MM-DD HH:mm:ss',
-              getPopupContainer: getAutoScrollContainer,
-            },
-            colProps: {
-              span: 4,
-            },
+          colProps: {
+            span: 4,
           },
-          // {
-          //   label: '查询设备',
-          //   field: 'gdeviceid',
-          //   component: 'ApiSelect',
-          //   componentProps: {
-          //     api: props.deviceListApi,
-          //     resultField: 'records',
-          //     labelField: 'strname',
-          //     valueField: 'id',
-          //   },
-          // },
-        ],
-      },
-      fetchSetting: {
-        listField: 'records',
-      },
-      pagination: {
-        current: 1,
-        pageSize: 10,
-        pageSizeOptions: ['10', '30', '50', '100'],
-      },
-      beforeFetch(params) {
-        params.devicetype = props.deviceType + '*';
-        if (props.sysId) {
-          params.sysId = props.sysId;
-        }
-        if (props.deviceId) {
-          params.deviceid = props.deviceId;
-        }
-      },
+        },
+        // {
+        //   label: '查询设备',
+        //   field: 'gdeviceid',
+        //   component: 'ApiSelect',
+        //   componentProps: {
+        //     api: props.deviceListApi,
+        //     resultField: 'records',
+        //     labelField: 'strname',
+        //     valueField: 'id',
+        //   },
+        // },
+      ],
+    },
+    fetchSetting: {
+      listField: 'records',
     },
-  });
-  //注册table数据
-  const [registerTable, { reload, setLoading }] = tableContext;
-  defineExpose({ setLoading });
+    pagination: {
+      current: 1,
+      pageSize: 10,
+      pageSizeOptions: ['10', '30', '50', '100'],
+    },
+    expandedRowKeys: expandedRowKeys,
+    onExpand: onExpand,
+    onExpandedRowsChange: onExpandedRowsChange,
+    beforeFetch(params) {
+      params.devicetype = props.deviceType + '*';
+      if (props.sysId) {
+        params.sysId = props.sysId;
+      }
+      if (props.deviceId) {
+        params.deviceid = props.deviceId;
+      }
+    },
+  },
+});
+
+//注册table数据
+const [registerTable, { reload, setLoading }] = tableContext;
+defineExpose({ setLoading });
 </script>
 
 <style scoped lang="less">
-  @ventSpace: zxm;
+@ventSpace: zxm;
 
-  // :deep(.@{ventSpace}-table-body) {
-  //   height: auto !important;
-  // }
-  :deep(.zxm-picker) {
-    height: 30px !important;
-  }
-  .handler-history-table {
-    width: 100%;
-    :deep(.jeecg-basic-table-form-container) {
-      .@{ventSpace}-form {
-        padding: 0 !important;
-        border: none !important;
-        margin-bottom: 0 !important;
-        .@{ventSpace}-picker,
-        .@{ventSpace}-select-selector {
-          width: 100% !important;
-          height: 100%;
-          background: #00000017;
-          border: 1px solid #b7b7b7;
-          input,
-          .@{ventSpace}-select-selection-item,
-          .@{ventSpace}-picker-suffix {
-            color: #fff;
-          }
-          .@{ventSpace}-select-selection-placeholder {
-            color: #ffffffaa;
-          }
+// :deep(.@{ventSpace}-table-body) {
+//   height: auto !important;
+// }
+:deep(.zxm-picker) {
+  height: 30px !important;
+}
+.handler-history-table {
+  width: 100%;
+  :deep(.jeecg-basic-table-form-container) {
+    .@{ventSpace}-form {
+      padding: 0 !important;
+      border: none !important;
+      margin-bottom: 0 !important;
+      .@{ventSpace}-picker,
+      .@{ventSpace}-select-selector {
+        width: 100% !important;
+        height: 100%;
+        background: #00000017;
+        border: 1px solid #b7b7b7;
+        input,
+        .@{ventSpace}-select-selection-item,
+        .@{ventSpace}-picker-suffix {
+          color: #fff;
+        }
+        .@{ventSpace}-select-selection-placeholder {
+          color: #ffffffaa;
         }
       }
-      .@{ventSpace}-table-title {
-        min-height: 0 !important;
-      }
+    }
+    .@{ventSpace}-table-title {
+      min-height: 0 !important;
     }
   }
-</style>
+}
+</style>

+ 5 - 3
src/views/vent/monitorManager/gateMonitor/components/GateShrinkSVG.vue

@@ -9,7 +9,7 @@
     y="0px"
     width="1920px"
     height="1080px"
-    viewBox="0 0 1920 1080"
+    viewBox="0 230 1920 1080"
   >
     <defs>
       <linearGradient id="Gradient_1" gradientUnits="userSpaceOnUse" x1="188.175" y1="25.7" x2="189.825" y2="25.7" spreadMethod="reflect">
@@ -857,9 +857,11 @@ onMounted(() => {
 });
 
 /** 根据SVG的使用场景播放动画 */
-function animate() {
+function animate(frontDoorIsOpen: boolean, middleDoorIsOpen: boolean, rearDoorIsOpen: boolean) {
   // 在SVG图片中,找到需要动起来的元素(类似<use xlink:href="#RE_L_0_Layer0_0_FILL"></use>),并填入id即可控制该元素的动画(如有)
-  // triggerAnimation(["___2_0_Layer0_0_FILL","___3_0_Layer0_0_FILL"], false);
+  // triggerAnimation(["___4_0_Layer0_0_FILL","___3_0_Layer0_0_FILL"], false);
+  triggerAnimation([`anim____2_0_Layer0_0_FILL_0${props.identify}`, `anim____2_0_Layer0_0_FILL_1${props.identify}`], !rearDoorIsOpen);
+  triggerAnimation([`anim____3_0_Layer0_0_FILL_0${props.identify}`, `anim____3_0_Layer0_0_FILL_1${props.identify}`], !frontDoorIsOpen);
 }
 
 // 导出方法以便外部调用

+ 5 - 3
src/views/vent/monitorManager/gateMonitor/components/GateYeYaSVG.vue

@@ -9,7 +9,7 @@
     y="0px"
     width="1920px"
     height="1080px"
-    viewBox="0 0 1920 1080"
+    viewBox="0 230 1920 1080"
   >
     <defs>
       <linearGradient id="Gradient_1" gradientUnits="userSpaceOnUse" x1="188.175" y1="25.7" x2="189.825" y2="25.7" spreadMethod="reflect">
@@ -1200,9 +1200,11 @@ onMounted(() => {
 });
 
 /** 根据SVG的使用场景播放动画 */
-function animate() {
+function animate(frontDoorIsOpen: boolean, middleDoorIsOpen: boolean, rearDoorIsOpen: boolean) {
   // 在SVG图片中,找到需要动起来的元素(类似<use xlink:href="#RE_L_0_Layer0_0_FILL"></use>),并填入id即可控制该元素的动画(如有)
-  // triggerAnimation(["___11_0_Layer0_0_FILL","___12_0_Layer0_0_FILL","___7_0_Layer0_0_FILL","___8_0_Layer0_0_FILL"], false);
+  // triggerAnimation(["___4_0_Layer0_0_FILL","___3_0_Layer0_0_FILL"], false);
+  triggerAnimation([`anim____11_0_Layer0_0_FILL_0${props.identify}`, `anim____12_0_Layer0_0_FILL_0${props.identify}`], !rearDoorIsOpen);
+  triggerAnimation([`anim____7_0_Layer0_0_FILL_1${props.identify}`, `anim____8_0_Layer0_0_FILL_1${props.identify}`], !frontDoorIsOpen);
 }
 
 // 导出方法以便外部调用

File diff suppressed because it is too large
+ 0 - 296
src/views/vent/monitorManager/gateMonitor/components/gateSanDaoShenSuoSVG.vue


File diff suppressed because it is too large
+ 0 - 368
src/views/vent/monitorManager/gateMonitor/components/gateXrSVG.vue


+ 5 - 1
src/views/vent/monitorManager/gateMonitor/gate.data.ts

@@ -432,7 +432,11 @@ export function getModelComponent(is2DModel: boolean = false, type?: string) {
         return getComponent('gateDualSVG');
       default:
         // return getComponent('gateTripleSVG');
-        return getComponent('gateTripleSVG');
+        // return getComponent('gateTripleSVG');
+        // return getComponent('gateSanDaoShenSuoSVG'); //三道伸缩风门
+        // return getComponent('gateXrSVG'); //行人风门
+        // return getComponent('GateYeYaSVG'); //液压风门
+        return getComponent('GateShrinkSVG'); //收缩风门
     }
   });
 }

+ 15 - 6
src/views/ventAI/manageAssistent/api.ts

@@ -108,7 +108,7 @@ export const uploadSkill = (file: File) => {
  * @returns Promise<{ session_id: string }>
  */
 export const unifiedStream = async (
-  params: { message: string; session_id?: string; file?: File; mode?: string },
+  params: { message: string; session_id?: string; file?: File; mode?: string; signal?: AbortSignal },
   onChunk: (chunk: string) => void
 ): Promise<{ session_id: string }> => {
   try {
@@ -128,6 +128,7 @@ export const unifiedStream = async (
       method: 'POST',
       body: formData,
       headers: { 'X-Access-Token': getToken() },
+      signal: params.signal,
     });
 
     if (!response.ok) {
@@ -178,7 +179,9 @@ export const unifiedStream = async (
 
     return { session_id: sessionId };
   } catch (error) {
-    console.error('统一对话流式请求失败:', error);
+    if ((error as Error)?.name !== 'AbortError') {
+      console.error('统一对话流式请求失败:', error);
+    }
     throw error;
   }
 };
@@ -192,7 +195,7 @@ export const unifiedStream = async (
  * @returns Promise<{ session_id: string }>
  */
 export const reviewPdfStream = async (
-  params: { file: File; session_id?: string; message?: string; mode?: string },
+  params: { file: File; session_id?: string; message?: string; mode?: string; signal?: AbortSignal },
   onChunk: (chunk: string) => void
 ): Promise<{ session_id: string }> => {
   try {
@@ -212,6 +215,7 @@ export const reviewPdfStream = async (
       method: 'POST',
       body: formData,
       headers: { 'X-Access-Token': getToken() },
+      signal: params.signal,
     });
 
     if (!response.ok) {
@@ -262,7 +266,9 @@ export const reviewPdfStream = async (
 
     return { session_id: sessionId };
   } catch (error) {
-    console.error('PDF审查流式请求失败:', error);
+    if ((error as Error)?.name !== 'AbortError') {
+      console.error('PDF审查流式请求失败:', error);
+    }
     throw error;
   }
 };
@@ -276,7 +282,7 @@ export const reviewPdfStream = async (
  * @returns Promise<{ session_id: string }>
  */
 export const chatResumeStream = async (
-  params: { session_id: string; thread_id?: string; action: string },
+  params: { session_id: string; thread_id?: string; action: string; signal?: AbortSignal },
   onChunk: (chunk: string) => void
 ): Promise<{ session_id: string }> => {
   try {
@@ -295,6 +301,7 @@ export const chatResumeStream = async (
         'Content-Type': 'application/json',
         'X-Access-Token': getToken(),
       },
+      signal: params.signal,
     });
 
     if (!response.ok) {
@@ -345,7 +352,9 @@ export const chatResumeStream = async (
 
     return { session_id: sessionId };
   } catch (error) {
-    console.error('恢复会话流式请求失败:', error);
+    if ((error as Error)?.name !== 'AbortError') {
+      console.error('恢复会话流式请求失败:', error);
+    }
     throw error;
   }
 };

+ 227 - 128
src/views/ventAI/manageAssistent/components/AiAssistantModal.vue

@@ -74,16 +74,15 @@
         />
 
         <!-- 下载确认条 -->
-        <div v-if="downloadConfirm" class="download-confirm-bar">
-          <span class="download-confirm-text">是否下载 {{ downloadConfirm.filename }} 到本地?</span>
-          <div class="download-confirm-actions">
-            <button class="download-confirm-btn download-btn" @click="doDownload">下载</button>
-            <button class="download-confirm-btn cancel-btn" @click="downloadConfirm = null">取消</button>
-          </div>
-        </div>
+        <DownloadConfirmBar
+          v-if="downloadConfirm && downloadConfirmTaskId === currentTaskId"
+          :filename="downloadConfirm.filename"
+          @confirm="doDownload"
+          @cancel="clearDownloadConfirm"
+        />
 
         <!-- 任务进度条 -->
-        <TodoListBar v-if="currentTodos.length > 0" :todos="currentTodos" />
+        <TodoListBar v-if="showTodoBar" :todos="currentTodos" />
 
         <!-- 输入区域 -->
         <ChatInputArea
@@ -91,6 +90,7 @@
           v-model="inputMessage"
           :pendingFile="pendingFile"
           :loading="loading || !!pendingApprovalSessionId"
+          :streaming="streaming"
           :contextUsed="contextUsed"
           :contextMax="contextMax"
           :contextPercent="contextPercent"
@@ -98,13 +98,16 @@
           :initialThinkLevel="thinkLevel"
           :initialEditMode="editMode"
           :taskList="taskList"
+          :currentTaskId="currentTaskId"
           @send="handleSendMessage"
+          @stop="stopStreaming"
           @file-upload="triggerFileUpload"
           @file-change="handleFileUpload"
           @remove-pending-file="removePendingFile"
           @think-level-change="handleThinkLevelChange"
           @edit-mode-change="handleEditModeChange"
-          @insert-session="handleInsertSession"
+          @update:selected-sessions="handleUpdateSelectedSessions"
+          @update:selected-skill="handleUpdateSelectedSkill"
         />
       </div>
 
@@ -141,7 +144,9 @@
   import ChatInputArea from './chatModal/ChatInputArea.vue';
   import FilePreviewPanel from './chatModal/FilePreviewPanel.vue';
   import TodoListBar from './chatModal/TodoListBar.vue';
+  import DownloadConfirmBar from './chatModal/DownloadConfirmBar.vue';
   import type { AttachedFile, Message, Task } from './chatModal/types';
+  import { isTextFile } from './chatModal/utils';
 
   interface Props {
     visible: boolean;
@@ -214,9 +219,28 @@
   const pendingApprovalSessionId = ref('');
   const pendingApprovalThreadId = ref('');
   const downloadConfirm = ref<{ url: string; filename: string } | null>(null);
+  const downloadConfirmTaskId = ref('');
+  let downloadConfirmTimer: ReturnType<typeof setTimeout> | null = null;
+
+  const clearDownloadConfirm = () => {
+    downloadConfirm.value = null;
+    downloadConfirmTaskId.value = '';
+    if (downloadConfirmTimer) {
+      clearTimeout(downloadConfirmTimer);
+      downloadConfirmTimer = null;
+    }
+  };
+
+  const startDownloadConfirmTimer = () => {
+    if (downloadConfirmTimer) clearTimeout(downloadConfirmTimer);
+    downloadConfirmTimer = setTimeout(() => {
+      clearDownloadConfirm();
+    }, 10000);
+  };
   const contextBreakdown = ref({ messages: 0, mcp: 0, skills: 0, system_prompt: 0, other: 0 });
 
   const currentTodos = ref<Array<{ content: string; status: string }>>([]);
+  const showTodoBar = ref(false);
 
   const syncTodoBarFromMessages = () => {
     for (let i = messages.value.length - 1; i >= 0; i--) {
@@ -226,11 +250,13 @@
           const step = msg.thinkingSteps[j];
           if (step.type === 'updated_todo_list' && step.todos) {
             currentTodos.value = [...step.todos];
+            showTodoBar.value = true;
             return;
           }
         }
       }
     }
+    showTodoBar.value = false;
     currentTodos.value = [];
   };
 
@@ -312,6 +338,14 @@
 
   const inputMessage = ref('');
   const loading = ref(false);
+  const streaming = ref(false);
+  const activeAbortController = ref<AbortController | null>(null);
+  const selectedSessions = ref<Array<{ id: string; name: string }>>([]);
+  const selectedSkill = ref<{ name: string; display_name: string } | null>(null);
+
+  const stopStreaming = () => {
+    activeAbortController.value?.abort();
+  };
 
   const getCurrentSessionId = (): string => {
     const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
@@ -423,8 +457,22 @@
     }
   };
 
-  const handleInsertSession = (_taskId: string, _taskName: string) => {
-    // 会话引用已由 ChatInputArea 内部以 chip 形式管理
+  const handleUpdateSelectedSessions = (sessions: Array<{ id: string; name: string }>) => {
+    selectedSessions.value = sessions;
+  };
+
+  const handleUpdateSelectedSkill = (skill: { name: string; display_name: string } | null) => {
+    selectedSkill.value = skill;
+  };
+
+  const getSkillRefIconUrl = () => {
+    const val = getComputedStyle(document.documentElement).getPropertyValue('--img-chat-options-skill-icon').trim();
+    return val.replace(/^url\(["']?/, '').replace(/["']?\)$/, '');
+  };
+
+  const getSessionRefIconUrl = () => {
+    const val = getComputedStyle(document.documentElement).getPropertyValue('--img-chat-popup-session-icon').trim();
+    return val.replace(/^url\(["']?/, '').replace(/["']?\)$/, '');
   };
 
   const handleEditModeChange = async (modeKey: string) => {
@@ -445,29 +493,18 @@
     const approveTask = taskList.value.find((t) => t.sessionId === sessionId);
     if (!approveTask) return;
 
-    // 清除中断消息的 pendingApproval 状态,获取中断前累计时长和思考内容
     const taskMessages = approveTask.messages;
     const lastMsg = taskMessages[taskMessages.length - 1];
-    const prevElapsedMs = lastMsg?.pendingApprovalData?.elapsedMs || 0;
-    const prevThinkingContent = lastMsg?.pendingApprovalData?.prevThinkingContent || lastMsg?.thinkingContent || '';
-    if (lastMsg && lastMsg.isPendingApproval) {
-      lastMsg.isPendingApproval = false;
-    }
+    if (!lastMsg || !lastMsg.isPendingApproval) return;
 
-    const now = dayjs();
-    const aiMsgIndex = taskMessages.length;
-    const aiMsg: Message = {
-      type: 'ai',
-      content: '',
-      thinkingContent: prevThinkingContent,
-      time: now.format('HH:mm'),
-      createdAt: now.toISOString(),
-      isLoading: true,
-      thinkingSteps: [],
-      generateStartTime: Date.now(),
-      baseDurationMs: prevElapsedMs,
-    };
-    taskMessages.push(aiMsg);
+    // 复用同一条消息继续渲染:清除审批状态,恢复 loading,重置前端计时起点
+    lastMsg.isPendingApproval = false;
+    lastMsg.pendingApprovalData = undefined;
+    lastMsg.isLoading = true;
+    lastMsg.generateStartTime = Date.now();
+    // thinkingSteps、thinkingContent、baseDurationMs 保持不变,后续继续累积
+
+    const aiMsgIndex = taskMessages.length - 1;
     approveTask.messages = [...taskMessages];
     if (currentTaskId.value === approveTask.id) {
       messages.value = approveTask.messages;
@@ -476,6 +513,12 @@
 
     loading.value = true;
     approveTask.isStreaming = true;
+    showTodoBar.value = false;
+    currentTodos.value = [];
+
+    const controller = new AbortController();
+    activeAbortController.value = controller;
+    streaming.value = true;
 
     try {
       const result = await chatResumeStream(
@@ -483,6 +526,7 @@
           session_id: sessionId,
           thread_id: pendingApprovalThreadId.value || undefined,
           action: 'approve',
+          signal: controller.signal,
         },
         (chunk: string) => {
           parseSSEData(chunk, aiMsgIndex, approveTask.id);
@@ -494,13 +538,27 @@
         setCurrentSessionId(result.session_id);
       }
     } catch (error) {
-      console.error('审批继续执行失败:', error);
-      message.error('审批失败,请重试');
-      if (aiMsg.isLoading) {
-        aiMsg.isLoading = false;
-        aiMsg.content = '抱歉,审批请求失败,请稍后重试。';
+      if ((error as Error)?.name === 'AbortError') {
+        // 用户主动停止生成
+        lastMsg.isLoading = false;
+        // 后端未返回耗时,冻结前端伪计时
+        lastMsg.durationMs = (lastMsg.baseDurationMs || 0) + (lastMsg.generateStartTime ? Date.now() - lastMsg.generateStartTime : 0);
+        if (!lastMsg.content) {
+          lastMsg.content = '已停止生成。';
+        }
+      } else {
+        console.error('审批继续执行失败:', error);
+        message.error('审批失败,请重试');
+        lastMsg.isLoading = false;
+        if (!lastMsg.content) {
+          lastMsg.content = '抱歉,审批请求失败,请稍后重试。';
+        }
       }
     } finally {
+      if (activeAbortController.value === controller) {
+        activeAbortController.value = null;
+      }
+      streaming.value = false;
       loading.value = false;
       approveTask.isStreaming = false;
       pendingApprovalSessionId.value = '';
@@ -518,18 +576,27 @@
     const rejectTask = taskList.value.find((t) => t.sessionId === sessionId);
     if (!rejectTask) return;
 
-    // 清除中断消息的 pendingApproval 状态
     const taskMessages = rejectTask.messages;
     const lastMsg = taskMessages[taskMessages.length - 1];
-    if (lastMsg && lastMsg.isPendingApproval) {
-      lastMsg.isPendingApproval = false;
-    }
+    if (!lastMsg || !lastMsg.isPendingApproval) return;
+
+    // 复用同一条消息:清除审批状态,标记为已拒绝
+    lastMsg.isPendingApproval = false;
+    lastMsg.pendingApprovalData = undefined;
+    lastMsg.isLoading = false;
+    lastMsg.content = (lastMsg.content ? lastMsg.content + '\n\n' : '') + '已拒绝执行计划。';
+
+    rejectTask.messages = [...taskMessages];
+
+    const threadId = pendingApprovalThreadId.value || undefined;
+    pendingApprovalSessionId.value = '';
+    pendingApprovalThreadId.value = '';
 
     try {
       await chatResumeStream(
         {
           session_id: sessionId,
-          thread_id: pendingApprovalThreadId.value || undefined,
+          thread_id: threadId,
           action: 'reject',
         },
         () => {}
@@ -538,19 +605,6 @@
       console.error('拒绝操作失败:', error);
     }
 
-    const now = dayjs();
-    const rejectMsg: Message = {
-      type: 'ai',
-      content: '已拒绝执行计划。',
-      time: now.format('HH:mm'),
-      createdAt: now.toISOString(),
-    };
-    taskMessages.push(rejectMsg);
-    rejectTask.messages = [...taskMessages];
-
-    pendingApprovalSessionId.value = '';
-    pendingApprovalThreadId.value = '';
-
     if (currentTaskId.value === rejectTask.id) {
       messages.value = rejectTask.messages;
       await scrollToBottom();
@@ -649,10 +703,17 @@
       };
 
       const reader = new FileReader();
-      reader.onload = (e) => {
-        newFile.preview = e.target?.result as string;
-      };
-      reader.readAsDataURL(file);
+      if (isTextFile(file.name)) {
+        reader.onload = (e) => {
+          newFile.content = e.target?.result as string;
+        };
+        reader.readAsText(file);
+      } else {
+        reader.onload = (e) => {
+          newFile.preview = e.target?.result as string;
+        };
+        reader.readAsDataURL(file);
+      }
 
       pendingFile.value = newFile;
       message.success(`文件 "${file.name}" 已添加,点击发送按钮提交`);
@@ -736,7 +797,7 @@
     document.body.appendChild(a);
     a.click();
     document.body.removeChild(a);
-    downloadConfirm.value = null;
+    clearDownloadConfirm();
   };
 
   const downloadWordFile = (url: string) => {
@@ -764,11 +825,27 @@
       return;
     }
 
-    const userInput = inputMessage.value.trim();
+    const sessionPrefix = selectedSessions.value.length > 0 ? selectedSessions.value.map((s) => `#session:${s.id}`).join(' ') + ' ' : '';
+    const skillPrefix = selectedSkill.value ? `#skill:${selectedSkill.value.name} ` : '';
+    const userInput = skillPrefix + sessionPrefix + inputMessage.value.trim();
+
+    let displayContent = inputMessage.value.trim();
+    if (selectedSkill.value) {
+      const skillIconUrl = getSkillRefIconUrl();
+      displayContent = `[<img src="${skillIconUrl}" class="session-ref-icon" /> 技能:${selectedSkill.value.display_name}] ${displayContent}`;
+    }
+    if (selectedSessions.value.length > 0) {
+      const names = selectedSessions.value.map((s) => s.name).join('、');
+      const iconUrl = getSessionRefIconUrl();
+      displayContent = `[<img src="${iconUrl}" class="session-ref-icon" /> 引用 ${selectedSessions.value.length} 个会话:${names}] ${displayContent}`;
+    }
+
     const fileToSend = pendingFile.value;
 
     inputMessage.value = '';
     pendingFile.value = null;
+    selectedSessions.value = [];
+    selectedSkill.value = null;
 
     try {
       loading.value = true;
@@ -776,9 +853,9 @@
       chatMessagesRef.value?.forceScrollToBottom();
 
       if (hasFile) {
-        await sendWithAttachment(fileToSend!, userInput);
+        await sendWithAttachment(fileToSend!, userInput, displayContent);
       } else {
-        await sendTextOnly(userInput);
+        await sendTextOnly(userInput, displayContent);
       }
     } catch (error) {
       console.error('发送消息失败:', error);
@@ -953,6 +1030,7 @@
           });
         }
         currentTodos.value = [...(data.todos || [])];
+        showTodoBar.value = true;
         break;
       }
 
@@ -981,6 +1059,8 @@
         });
         if (data.download_url) {
           downloadConfirm.value = { url: data.download_url, filename: data.filename || '文件' };
+          downloadConfirmTaskId.value = taskId;
+          startDownloadConfirmTimer();
         }
         break;
       }
@@ -993,9 +1073,10 @@
           message: `${(data.message || '审查完成').replace(/(\d+ms)/g, '')}${data.duration_ms ? `(${(data.duration_ms / 1000).toFixed(0)}s)` : ''}${data.progress ? ` [${data.progress}]` : ''}`,
           timestamp: Date.now(),
         });
-        // 用后端返回的时间替换前端计时,作为中断前的累计基准
+        // 累加后端返回的各段耗时,并重置前端计时起点,避免重复计算已验证的时间段
         if (data.duration_ms) {
-          aiMsg.baseDurationMs = data.duration_ms;
+          aiMsg.baseDurationMs = (aiMsg.baseDurationMs || 0) + data.duration_ms;
+          aiMsg.generateStartTime = Date.now();
         }
         if (data.preview) {
           aiMsg.content += data.preview;
@@ -1030,6 +1111,8 @@
           timestamp: Date.now(),
         });
         aiMsg.isLoading = false;
+        // 后端未返回耗时,冻结前端伪计时
+        aiMsg.durationMs = (aiMsg.baseDurationMs || 0) + (aiMsg.generateStartTime ? Date.now() - aiMsg.generateStartTime : 0);
         break;
 
       case 'word_download':
@@ -1044,9 +1127,9 @@
           aiMsg.sessionId = data.session_id;
           fetchContext(data.session_id);
         }
-        if (data.duration_ms) {
-          aiMsg.durationMs = (aiMsg.baseDurationMs || 0) + data.duration_ms;
-        }
+        aiMsg.durationMs = data.duration_ms
+          ? (aiMsg.baseDurationMs || 0) + data.duration_ms
+          : (aiMsg.baseDurationMs || 0) + (aiMsg.generateStartTime ? Date.now() - aiMsg.generateStartTime : 0);
         aiMsg.isLoading = false;
         break;
 
@@ -1057,8 +1140,9 @@
         break;
 
       case 'interrupt': {
-        // 优先使用 agent_done 已设置的后端时间,否则用前端计时
-        const elapsedMs = aiMsg.baseDurationMs || (aiMsg.generateStartTime ? Date.now() - aiMsg.generateStartTime : 0);
+        // 后端各段耗时 + 前端当前段计时,吸收进 baseDurationMs 保证审批继续后计时连续
+        const elapsedMs = (aiMsg.baseDurationMs || 0) + (aiMsg.generateStartTime ? Date.now() - aiMsg.generateStartTime : 0);
+        aiMsg.baseDurationMs = elapsedMs;
         aiMsg.isLoading = false;
         aiMsg.isPendingApproval = true;
         const interruptSessionId = data.session_id || streamingTask.sessionId;
@@ -1114,13 +1198,13 @@
     }
   };
 
-  const sendTextOnly = async (userInput: string) => {
+  const sendTextOnly = async (userInput: string, displayContent?: string) => {
     const originTaskId = currentTaskId.value;
 
     const now = dayjs();
     const userMsg: Message = {
       type: 'user',
-      content: userInput,
+      content: displayContent || userInput,
       time: now.format('HH:mm'),
       createdAt: now.toISOString(),
     };
@@ -1140,15 +1224,23 @@
     messages.value.push(aiMsg);
     await scrollToBottom();
 
+    showTodoBar.value = false;
+    currentTodos.value = [];
+
     const originTask = taskList.value.find((t) => t.id === originTaskId);
     if (originTask) originTask.isStreaming = true;
 
+    const controller = new AbortController();
+    activeAbortController.value = controller;
+    streaming.value = true;
+
     try {
       const result = await unifiedStream(
         {
           message: userInput,
           session_id: getCurrentSessionId() || undefined,
           mode: editMode.value,
+          signal: controller.signal,
         },
         (chunk: string) => {
           parseSSEData(chunk, aiMsgIndex, originTaskId);
@@ -1171,13 +1263,37 @@
           }
         }
       }
+    } catch (error) {
+      // 用户主动停止生成
+      if ((error as Error)?.name === 'AbortError') {
+        const originTask = taskList.value.find((t) => t.id === originTaskId);
+        const arr = currentTaskId.value === originTaskId ? messages.value : originTask?.messages;
+        const aiMsg = arr?.[aiMsgIndex];
+        if (aiMsg) {
+          aiMsg.isLoading = false;
+          // 后端未返回耗时,冻结前端伪计时
+          aiMsg.durationMs = (aiMsg.baseDurationMs || 0) + (aiMsg.generateStartTime ? Date.now() - aiMsg.generateStartTime : 0);
+          if (!aiMsg.content) {
+            aiMsg.content = '已停止生成。';
+          }
+        }
+        if (originTask && currentTaskId.value !== originTaskId) {
+          originTask.messages = [...(arr || [])];
+        }
+        return;
+      }
+      throw error;
     } finally {
+      if (activeAbortController.value === controller) {
+        activeAbortController.value = null;
+      }
+      streaming.value = false;
       const originTask = taskList.value.find((t) => t.id === originTaskId);
       if (originTask) originTask.isStreaming = false;
     }
   };
 
-  const sendWithAttachment = async (file: AttachedFile, userInput: string) => {
+  const sendWithAttachment = async (file: AttachedFile, userInput: string, displayContent?: string) => {
     const originTaskId = currentTaskId.value;
     let currentTask = taskList.value.find((t) => t.id === originTaskId);
     if (currentTask) {
@@ -1189,7 +1305,7 @@
     const now = dayjs();
     const userMsg: Message = {
       type: 'user',
-      content: userInput || `上传了文件:${file.name}`,
+      content: displayContent || userInput || `上传了文件:${file.name}`,
       time: now.format('HH:mm'),
       createdAt: now.toISOString(),
       attachedFile: file,
@@ -1210,6 +1326,9 @@
     messages.value.push(aiMsg);
     await scrollToBottom();
 
+    showTodoBar.value = false;
+    currentTodos.value = [];
+
     if (!file.originalFile) {
       throw new Error('缺少原始文件对象');
     }
@@ -1217,6 +1336,10 @@
     const originTask = taskList.value.find((t) => t.id === originTaskId);
     if (originTask) originTask.isStreaming = true;
 
+    const controller = new AbortController();
+    activeAbortController.value = controller;
+    streaming.value = true;
+
     try {
       const result = await reviewPdfStream(
         {
@@ -1224,6 +1347,7 @@
           session_id: getCurrentSessionId() || undefined,
           message: userInput || undefined,
           mode: editMode.value,
+          signal: controller.signal,
         },
         (chunk: string) => {
           parseSSEData(chunk, aiMsgIndex, originTaskId);
@@ -1247,7 +1371,31 @@
           }
         }
       }
+    } catch (error) {
+      // 用户主动停止生成
+      if ((error as Error)?.name === 'AbortError') {
+        const originTask = taskList.value.find((t) => t.id === originTaskId);
+        const arr = currentTaskId.value === originTaskId ? messages.value : originTask?.messages;
+        const aiMsg = arr?.[aiMsgIndex];
+        if (aiMsg) {
+          aiMsg.isLoading = false;
+          // 后端未返回耗时,冻结前端伪计时
+          aiMsg.durationMs = (aiMsg.baseDurationMs || 0) + (aiMsg.generateStartTime ? Date.now() - aiMsg.generateStartTime : 0);
+          if (!aiMsg.content) {
+            aiMsg.content = '已停止生成。';
+          }
+        }
+        if (originTask && currentTaskId.value !== originTaskId) {
+          originTask.messages = [...(arr || [])];
+        }
+        return;
+      }
+      throw error;
     } finally {
+      if (activeAbortController.value === controller) {
+        activeAbortController.value = null;
+      }
+      streaming.value = false;
       const originTask = taskList.value.find((t) => t.id === originTaskId);
       if (originTask) originTask.isStreaming = false;
     }
@@ -1423,6 +1571,12 @@
   onBeforeUnmount(() => {
     document.removeEventListener('mousemove', onDragMove);
     document.removeEventListener('mouseup', onDragEnd);
+    if (downloadConfirmTimer) {
+      clearTimeout(downloadConfirmTimer);
+      downloadConfirmTimer = null;
+    }
+    // 组件卸载时主动断开流式连接
+    activeAbortController.value?.abort();
   });
 </script>
 
@@ -1495,61 +1649,6 @@
     overflow: hidden;
     min-width: 0;
 
-    .download-confirm-bar {
-      display: flex;
-      align-items: center;
-      justify-content: space-between;
-      padding: 10px 16px;
-      margin: 0 16px;
-      background: #0a2a3f;
-      border: 1px solid #1a4a6f;
-      border-radius: 8px;
-      flex-shrink: 0;
-
-      .download-confirm-text {
-        font-size: 13px;
-        color: #e0e6ed;
-        overflow: hidden;
-        text-overflow: ellipsis;
-        white-space: nowrap;
-        margin-right: 12px;
-      }
-
-      .download-confirm-actions {
-        display: flex;
-        gap: 8px;
-        flex-shrink: 0;
-      }
-
-      .download-confirm-btn {
-        padding: 4px 14px;
-        border-radius: 4px;
-        font-size: 12px;
-        cursor: pointer;
-        border: none;
-        transition: all 0.2s;
-
-        &.download-btn {
-          background: #0a84ff;
-          color: #fff;
-
-          &:hover {
-            background: #3a9bfd;
-          }
-        }
-
-        &.cancel-btn {
-          background: rgba(255, 255, 255, 0.08);
-          color: #e0e6ed;
-          border: 1px solid rgba(63, 80, 106, 0.5);
-
-          &:hover {
-            background: rgba(255, 255, 255, 0.15);
-          }
-        }
-      }
-    }
-
     :deep(.chat-header) {
       display: flex;
       padding: 12px 20px;

+ 504 - 32
src/views/ventAI/manageAssistent/components/chatModal/ChatInputArea.vue

@@ -22,6 +22,15 @@
       </div>
     </div>
 
+    <!-- 已选技能引用标签 -->
+    <div v-if="selectedSkill" class="skill-chips">
+      <div class="skill-chip">
+        <div class="skill-chip-icon"></div>
+        <span class="skill-chip-name">{{ selectedSkill.display_name }}</span>
+        <CloseOutlined class="skill-chip-remove" @click.stop="removeSkill()" />
+      </div>
+    </div>
+
     <a-textarea
       ref="textareaRef"
       :value="modelValue"
@@ -56,6 +65,10 @@
               <div class="popup-item-icon session-icon"></div>
               <span class="popup-item-text">插入 # 会话</span>
             </div>
+            <div class="popup-item" @click="handleInsertSkill">
+              <div class="popup-item-icon skill-icon"></div>
+              <span class="popup-item-text">插入 $ 技能</span>
+            </div>
             <!-- <div class="popup-item" @click="handleInsertCommand">
               <div class="popup-item-icon command-icon"></div>
               <span class="popup-item-text">插入 / 命令</span>
@@ -192,36 +205,80 @@
           </div>
         </div>
         <div class="action-divider"></div>
-        <div class="btn-send" :class="{ disabled: !modelValue.trim() || loading }" @click="handleSend">
-          <div class="send-icon"></div>
+        <div class="btn-send" :class="{ disabled: !streaming && (!modelValue.trim() || loading) }" @click="handleSendClick">
+          <div class="send-icon" :class="{ 'stop-icon': streaming }"></div>
         </div>
       </div>
       <!-- 会话列表弹框 -->
       <div v-if="showSessionPopup" ref="sessionPopupRef" class="session-popup" @click.stop @pointerdown.stop>
+        <div class="session-popup-header">
+          <span>选择会话</span>
+          <span class="session-count">{{ selectedSessions.length }}/5</span>
+        </div>
         <div class="session-list">
           <div
             v-for="(task, index) in filteredSessionList"
             :key="task.id"
             class="session-item"
-            :class="{ active: index === activeSessionIndex }"
+            :class="{
+              active: index === activeSessionIndex,
+              selected: isSessionSelected(task.id),
+              disabled: (selectedSessions.length >= 5 && !isSessionSelected(task.id)) || task.id === props.currentTaskId,
+            }"
             @click="handleSelectSession(task)"
           >
+            <div class="session-checkbox" :class="{ checked: isSessionSelected(task.id) }">
+              <span v-if="isSessionSelected(task.id)" class="check-mark">✓</span>
+            </div>
             <div class="session-item-icon"></div>
             <span class="session-item-name">{{ task.name }}</span>
           </div>
           <div v-if="filteredSessionList.length === 0" class="session-empty">暂无匹配会话</div>
         </div>
-        <div class="session-popup-footer">输入内容以搜索对话</div>
+        <div class="session-popup-footer">
+          <span v-if="selectedSessions.length >= 5" class="max-hint">最多选择5个会话</span>
+          <span v-else>点击选择会话(最多5个)</span>
+        </div>
+      </div>
+      <!-- 技能列表弹框 -->
+      <div v-if="showSkillPopup" ref="skillPopupRef" class="skill-popup" @click.stop @pointerdown.stop>
+        <div class="skill-popup-header">
+          <span>选择技能</span>
+          <span class="skill-count">0/1</span>
+        </div>
+        <div class="skill-list">
+          <div
+            v-for="(skill, index) in filteredSkillList"
+            :key="skill.name"
+            class="skill-item"
+            :class="{
+              active: index === activeSkillIndex,
+              selected: isSkillSelected(skill.name),
+            }"
+            @click="handleSelectSkill(skill)"
+          >
+            <div class="skill-item-checkbox" :class="{ checked: isSkillSelected(skill.name) }">
+              <span v-if="isSkillSelected(skill.name)" class="check-mark">✓</span>
+            </div>
+            <div class="skill-item-icon"></div>
+            <span class="skill-item-name">{{ skill.display_name }}</span>
+          </div>
+          <div v-if="filteredSkillList.length === 0" class="skill-empty">暂无匹配技能</div>
+        </div>
+        <div class="skill-popup-footer">
+          <span>点击选择技能(仅可选1个)</span>
+        </div>
       </div>
     </div>
   </div>
 </template>
 
 <script setup lang="ts">
-  import { ref, computed, watch, nextTick } from 'vue';
+  import { ref, computed, watch, nextTick, onMounted } from 'vue';
   import { onClickOutside } from '@vueuse/core';
   import { CloseOutlined } from '@ant-design/icons-vue';
   import { formatFileSize } from './utils';
+  import { getSkills } from '../../api';
   import type { AttachedFile, Task } from './types';
 
   const props = withDefaults(
@@ -229,6 +286,7 @@
       modelValue: string;
       pendingFile: AttachedFile | null;
       loading: boolean;
+      streaming?: boolean;
       contextUsed?: number;
       contextMax?: number;
       contextPercent?: number;
@@ -236,28 +294,33 @@
       initialThinkLevel?: string;
       initialEditMode?: string;
       taskList?: Task[];
+      currentTaskId?: string;
     }>(),
     {
       contextUsed: 0,
       contextMax: 1000000,
       contextPercent: 0,
+      streaming: false,
       contextBreakdown: () => ({ messages: 0, mcp: 0, skills: 0, system_prompt: 0, other: 0 }),
       initialThinkLevel: 'off',
       initialEditMode: 'full',
       taskList: () => [],
+      currentTaskId: '',
     }
   );
 
   const emit = defineEmits<{
     (e: 'update:modelValue', value: string): void;
     (e: 'send'): void;
+    (e: 'stop'): void;
     (e: 'file-upload'): void;
     (e: 'file-change', event: Event): void;
     (e: 'remove-pending-file'): void;
     (e: 'model-change', modelKey: string): void;
     (e: 'think-level-change', levelKey: string): void;
     (e: 'edit-mode-change', modeKey: string): void;
-    (e: 'insert-session', taskId: string, taskName: string): void;
+    (e: 'update:selectedSessions', sessions: Array<{ id: string; name: string }>): void;
+    (e: 'update:selectedSkill', skill: { name: string; display_name: string } | null): void;
   }>();
 
   const handleSelectModel = (item: ModelOption) => {
@@ -444,9 +507,50 @@
   const showSessionPopup = ref(false);
   const activeSessionIndex = ref(0);
   const selectedSessions = ref<Array<{ id: string; name: string }>>([]);
+  const sessionPopupManual = ref(false);
   let isRemovingTag = false;
   let sessionJustSelected = false;
 
+  // 技能列表弹框
+  interface SkillItem {
+    name: string;
+    display_name: string;
+    path: string;
+    description: string;
+    enabled: boolean;
+    agents: string[];
+    has_skill_md: boolean;
+  }
+
+  const skillPopupRef = ref<HTMLElement>();
+  const showSkillPopup = ref(false);
+  const activeSkillIndex = ref(0);
+  const selectedSkill = ref<{ name: string; display_name: string } | null>(null);
+  const skillPopupManual = ref(false);
+  const skillList = ref<SkillItem[]>([]);
+  let skillJustSelected = false;
+
+  const fetchSkillList = async () => {
+    try {
+      const res = await getSkills();
+      skillList.value = res?.skills || [];
+    } catch {
+      skillList.value = [];
+    }
+  };
+
+  onMounted(() => {
+    fetchSkillList();
+  });
+
+  const isSkillSelected = (name: string) => {
+    return selectedSkill.value?.name === name;
+  };
+
+  const isSessionSelected = (sessionId: string) => {
+    return selectedSessions.value.some((s) => s.id === sessionId);
+  };
+
   const sessionSearchText = computed(() => {
     const val = props.modelValue || '';
     const hashIndex = val.lastIndexOf('#');
@@ -470,26 +574,69 @@
     if (val) activeSessionIndex.value = 0;
   });
 
+  const skillSearchText = computed(() => {
+    const val = props.modelValue || '';
+    const dollarIndex = val.lastIndexOf('$');
+    if (dollarIndex === -1) return '';
+    const afterDollar = val.substring(dollarIndex + 1);
+    if (afterDollar.includes(' ')) return '';
+    return afterDollar.toLowerCase();
+  });
+
+  const filteredSkillList = computed(() => {
+    if (!skillSearchText.value) return skillList.value;
+    return skillList.value.filter(
+      (s) => s.display_name.toLowerCase().includes(skillSearchText.value) || s.name.toLowerCase().includes(skillSearchText.value)
+    );
+  });
+
+  watch(filteredSkillList, () => {
+    activeSkillIndex.value = 0;
+  });
+
+  watch(showSkillPopup, (val) => {
+    if (val) activeSkillIndex.value = 0;
+  });
+
   const handleSessionKeydown = (e: KeyboardEvent) => {
-    if (!showSessionPopup.value || filteredSessionList.value.length === 0) return;
-    if (e.key === 'ArrowDown') {
-      e.preventDefault();
-      activeSessionIndex.value = (activeSessionIndex.value + 1) % filteredSessionList.value.length;
-    } else if (e.key === 'ArrowUp') {
-      e.preventDefault();
-      activeSessionIndex.value = (activeSessionIndex.value - 1 + filteredSessionList.value.length) % filteredSessionList.value.length;
-    } else if (e.key === 'Enter') {
-      e.preventDefault();
-      const task = filteredSessionList.value[activeSessionIndex.value];
-      if (task) {
-        sessionJustSelected = true;
-        handleSelectSession(task);
+    if (showSkillPopup.value && filteredSkillList.value.length > 0) {
+      if (e.key === 'ArrowDown') {
+        e.preventDefault();
+        activeSkillIndex.value = (activeSkillIndex.value + 1) % filteredSkillList.value.length;
+      } else if (e.key === 'ArrowUp') {
+        e.preventDefault();
+        activeSkillIndex.value = (activeSkillIndex.value - 1 + filteredSkillList.value.length) % filteredSkillList.value.length;
+      } else if (e.key === 'Enter') {
+        e.preventDefault();
+        const skill = filteredSkillList.value[activeSkillIndex.value];
+        if (skill) {
+          skillJustSelected = true;
+          handleSelectSkill(skill);
+        }
+      }
+      return;
+    }
+    if (showSessionPopup.value && filteredSessionList.value.length > 0) {
+      if (e.key === 'ArrowDown') {
+        e.preventDefault();
+        activeSessionIndex.value = (activeSessionIndex.value + 1) % filteredSessionList.value.length;
+      } else if (e.key === 'ArrowUp') {
+        e.preventDefault();
+        activeSessionIndex.value = (activeSessionIndex.value - 1 + filteredSessionList.value.length) % filteredSessionList.value.length;
+      } else if (e.key === 'Enter') {
+        e.preventDefault();
+        const task = filteredSessionList.value[activeSessionIndex.value];
+        if (task) {
+          sessionJustSelected = true;
+          handleSelectSession(task);
+        }
       }
     }
   };
 
   const handleInsertSession = () => {
     showPopup.value = false;
+    sessionPopupManual.value = true;
     showSessionPopup.value = true;
     const cur = props.modelValue || '';
     const needsSpace = cur && !cur.endsWith(' ') ? ' ' : '';
@@ -501,10 +648,57 @@
   };
 
   const handleSelectSession = (task: Task) => {
-    selectedSessions.value = [{ id: task.id, name: task.name }];
-    showSessionPopup.value = false;
+    if (task.id === props.currentTaskId) return;
+    const idx = selectedSessions.value.findIndex((s) => s.id === task.id);
+    if (idx >= 0) {
+      selectedSessions.value.splice(idx, 1);
+    } else {
+      if (selectedSessions.value.length >= 5) return;
+      selectedSessions.value.push({ id: task.id, name: task.name });
+    }
     removeSearchTag();
-    emit('insert-session', task.id, task.name);
+    emit('update:selectedSessions', [...selectedSessions.value]);
+  };
+
+  const handleInsertSkill = () => {
+    showPopup.value = false;
+    showSessionPopup.value = false;
+    skillPopupManual.value = true;
+    showSkillPopup.value = true;
+    const cur = props.modelValue || '';
+    const needsSpace = cur && !cur.endsWith(' ') ? ' ' : '';
+    emit('update:modelValue', `${cur}${needsSpace}$`);
+    nextTick(() => {
+      const el = textareaRef.value?.$el?.querySelector('textarea') || textareaRef.value?.$el;
+      if (el) el.focus();
+    });
+  };
+
+  const handleSelectSkill = (skill: SkillItem) => {
+    selectedSkill.value = { name: skill.name, display_name: skill.display_name };
+    removeSkillSearchTag();
+    showSkillPopup.value = false;
+    skillPopupManual.value = false;
+    emit('update:selectedSkill', { name: skill.name, display_name: skill.display_name });
+  };
+
+  const removeSkillSearchTag = () => {
+    isRemovingTag = true;
+    const val = props.modelValue || '';
+    const dollarIndex = val.lastIndexOf('$');
+    if (dollarIndex === -1) {
+      isRemovingTag = false;
+      return;
+    }
+    emit('update:modelValue', val.substring(0, dollarIndex).trimEnd());
+    nextTick(() => {
+      isRemovingTag = false;
+    });
+  };
+
+  const removeSkill = () => {
+    selectedSkill.value = null;
+    emit('update:selectedSkill', null);
   };
 
   const removeSearchTag = () => {
@@ -525,25 +719,50 @@
     () => props.modelValue,
     (newVal, oldVal) => {
       if (isRemovingTag) return;
-      if (oldVal && !newVal) selectedSessions.value = [];
+      if (oldVal && !newVal) {
+        selectedSessions.value = [];
+        selectedSkill.value = null;
+      }
     }
   );
 
   const removeSession = (sessionId: string) => {
     selectedSessions.value = selectedSessions.value.filter((s) => s.id !== sessionId);
+    emit('update:selectedSessions', [...selectedSessions.value]);
   };
 
   const handleTextareaInput = (val: string) => {
     emit('update:modelValue', val);
-    const hashIndex = val.lastIndexOf('#');
-    if (hashIndex === -1) {
-      showSessionPopup.value = false;
-    } else {
-      const afterHash = val.substring(hashIndex + 1);
-      if (afterHash.includes(' ')) {
+
+    // 技能弹框 $ 检测
+    if (!skillPopupManual.value) {
+      const dollarIndex = val.lastIndexOf('$');
+      if (dollarIndex === -1) {
+        showSkillPopup.value = false;
+      } else {
+        const afterDollar = val.substring(dollarIndex + 1);
+        if (afterDollar.includes(' ')) {
+          showSkillPopup.value = false;
+        } else {
+          showSkillPopup.value = true;
+          showSessionPopup.value = false;
+        }
+      }
+    }
+
+    // 会话弹框 # 检测
+    if (!sessionPopupManual.value) {
+      const hashIndex = val.lastIndexOf('#');
+      if (hashIndex === -1) {
         showSessionPopup.value = false;
       } else {
-        showSessionPopup.value = true;
+        const afterHash = val.substring(hashIndex + 1);
+        if (afterHash.includes(' ')) {
+          showSessionPopup.value = false;
+        } else {
+          showSessionPopup.value = true;
+          showSkillPopup.value = false;
+        }
       }
     }
   };
@@ -551,14 +770,25 @@
   const handleBackspace = (e: KeyboardEvent) => {
     const val = props.modelValue || '';
     if (val) return;
-    if (selectedSessions.value.length > 0) {
+    if (selectedSkill.value) {
+      e.preventDefault();
+      selectedSkill.value = null;
+      emit('update:selectedSkill', null);
+    } else if (selectedSessions.value.length > 0) {
       e.preventDefault();
       selectedSessions.value.pop();
+      emit('update:selectedSessions', [...selectedSessions.value]);
     }
   };
 
   onClickOutside(sessionPopupRef, () => {
     showSessionPopup.value = false;
+    sessionPopupManual.value = false;
+  });
+
+  onClickOutside(skillPopupRef, () => {
+    showSkillPopup.value = false;
+    skillPopupManual.value = false;
   });
 
   const handleInsertCommand = () => {
@@ -566,11 +796,15 @@
   };
 
   const handleEnterKey = (e: KeyboardEvent) => {
-    if (showSessionPopup.value) return;
+    if (showSessionPopup.value || showSkillPopup.value) return;
     if (sessionJustSelected) {
       sessionJustSelected = false;
       return;
     }
+    if (skillJustSelected) {
+      skillJustSelected = false;
+      return;
+    }
     if (!e.shiftKey && props.modelValue.trim() && !props.loading) {
       emit('send');
     }
@@ -582,6 +816,14 @@
     }
   };
 
+  const handleSendClick = () => {
+    if (props.streaming) {
+      emit('stop');
+      return;
+    }
+    handleSend();
+  };
+
   defineExpose({ fileInputRef });
 </script>
 
@@ -794,6 +1036,10 @@
               background-image: var(--img-chat-popup-session-icon);
             }
 
+            .skill-icon {
+              background-image: var(--img-chat-options-skill-icon);
+            }
+
             .command-icon {
               background-image: var(--img-chat-popup-command-icon);
             }
@@ -1240,6 +1486,10 @@
             background-image: var(--img-chat-send-icon);
             background-repeat: no-repeat;
             background-size: 100% 100%;
+
+            &.stop-icon {
+              background-image: var(--img-chat-stop-icon);
+            }
           }
         }
       }
@@ -1293,6 +1543,54 @@
       }
     }
 
+    .skill-chips {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 6px;
+      margin-bottom: 5px;
+
+      .skill-chip {
+        display: inline-flex;
+        align-items: center;
+        gap: 4px;
+        padding: 3px 6px 3px 4px;
+        background: rgba(10, 132, 255, 0.15);
+        border: 1px solid rgba(10, 132, 255, 0.3);
+        border-radius: 4px;
+        font-size: 12px;
+        color: #e0e6ed;
+        cursor: default;
+
+        .skill-chip-icon {
+          width: 16px;
+          height: 16px;
+          background-image: var(--img-chat-options-skill-icon);
+          background-repeat: no-repeat;
+          background-size: 100% 100%;
+          flex-shrink: 0;
+        }
+
+        .skill-chip-name {
+          max-width: 150px;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+
+        .skill-chip-remove {
+          font-size: 10px;
+          color: #8b949e;
+          cursor: pointer;
+          padding: 0 2px;
+          transition: color 0.2s;
+
+          &:hover {
+            color: #ff4d4f;
+          }
+        }
+      }
+    }
+
     .session-popup {
       position: absolute;
       bottom: 100%;
@@ -1308,6 +1606,23 @@
       z-index: 1000;
       box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
 
+      .session-popup-header {
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+        padding: 10px 14px 8px;
+        font-size: 13px;
+        color: #e0e6ed;
+        border-bottom: 1px solid rgba(63, 80, 106, 0.3);
+        flex-shrink: 0;
+
+        .session-count {
+          font-size: 12px;
+          color: #3a8fd4;
+          font-weight: 500;
+        }
+      }
+
       .session-list {
         flex: 1;
         overflow-y: auto;
@@ -1330,6 +1645,42 @@
             background: rgba(10, 132, 255, 0.2);
           }
 
+          &.selected {
+            background: rgba(10, 132, 255, 0.12);
+          }
+
+          &.disabled {
+            opacity: 0.4;
+            cursor: not-allowed;
+
+            &:hover {
+              background: transparent;
+            }
+          }
+
+          .session-checkbox {
+            width: 16px;
+            height: 16px;
+            border: 1.5px solid rgba(255, 255, 255, 0.25);
+            border-radius: 3px;
+            flex-shrink: 0;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            transition: all 0.2s;
+
+            &.checked {
+              background: #3a8fd4;
+              border-color: #3a8fd4;
+            }
+
+            .check-mark {
+              font-size: 11px;
+              color: #fff;
+              line-height: 1;
+            }
+          }
+
           .session-item-icon {
             width: 18px;
             height: 18px;
@@ -1363,6 +1714,127 @@
         text-align: center;
         border-top: 1px solid rgba(63, 80, 106, 0.3);
         flex-shrink: 0;
+
+        .max-hint {
+          color: #faad14;
+        }
+      }
+    }
+
+    .skill-popup {
+      position: absolute;
+      bottom: 100%;
+      left: 0;
+      right: 0;
+      margin-bottom: 8px;
+      background: #0a2a3f;
+      border: 1px solid #1a4a6f;
+      border-radius: 8px;
+      max-height: 300px;
+      display: flex;
+      flex-direction: column;
+      z-index: 1000;
+      box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
+
+      .skill-popup-header {
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+        padding: 10px 14px 8px;
+        font-size: 13px;
+        color: #e0e6ed;
+        border-bottom: 1px solid rgba(63, 80, 106, 0.3);
+        flex-shrink: 0;
+
+        .skill-count {
+          font-size: 12px;
+          color: #3a8fd4;
+          font-weight: 500;
+        }
+      }
+
+      .skill-list {
+        flex: 1;
+        overflow-y: auto;
+        padding: 6px;
+
+        .skill-item {
+          display: flex;
+          align-items: center;
+          gap: 10px;
+          padding: 8px 10px;
+          cursor: pointer;
+          border-radius: 6px;
+          transition: background 0.2s;
+
+          &:hover {
+            background: rgba(10, 132, 255, 0.15);
+          }
+
+          &.active {
+            background: rgba(10, 132, 255, 0.2);
+          }
+
+          &.selected {
+            background: rgba(10, 132, 255, 0.12);
+          }
+
+          .skill-item-checkbox {
+            width: 16px;
+            height: 16px;
+            border: 1.5px solid rgba(255, 255, 255, 0.25);
+            border-radius: 3px;
+            flex-shrink: 0;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            transition: all 0.2s;
+
+            &.checked {
+              background: #3a8fd4;
+              border-color: #3a8fd4;
+            }
+
+            .check-mark {
+              font-size: 11px;
+              color: #fff;
+              line-height: 1;
+            }
+          }
+
+          .skill-item-icon {
+            width: 18px;
+            height: 18px;
+            background-image: var(--img-chat-options-skill-icon);
+            background-repeat: no-repeat;
+            background-size: 100% 100%;
+            flex-shrink: 0;
+          }
+
+          .skill-item-name {
+            font-size: 13px;
+            color: #e0e6ed;
+            overflow: hidden;
+            text-overflow: ellipsis;
+            white-space: nowrap;
+          }
+        }
+
+        .skill-empty {
+          text-align: center;
+          color: #8b949e;
+          font-size: 13px;
+          padding: 20px 0;
+        }
+      }
+
+      .skill-popup-footer {
+        padding: 8px 14px;
+        font-size: 12px;
+        color: #8b949e;
+        text-align: center;
+        border-top: 1px solid rgba(63, 80, 106, 0.3);
+        flex-shrink: 0;
       }
     }
   }

+ 12 - 0
src/views/ventAI/manageAssistent/components/chatModal/ChatMessages.vue

@@ -741,6 +741,12 @@
             word-break: break-word;
             overflow-wrap: break-word;
 
+            :deep(.session-ref-icon) {
+              width: 14px;
+              height: 14px;
+              vertical-align: -2px;
+            }
+
             :deep(h1),
             :deep(h2),
             :deep(h3),
@@ -978,6 +984,12 @@
             word-break: break-word;
             overflow-wrap: break-word;
 
+            :deep(.session-ref-icon) {
+              width: 14px;
+              height: 14px;
+              vertical-align: -2px;
+            }
+
             :deep(h1, h2, h3, h4, h5, h6) {
               color: #fff;
             }

+ 78 - 0
src/views/ventAI/manageAssistent/components/chatModal/DownloadConfirmBar.vue

@@ -0,0 +1,78 @@
+<template>
+  <div class="download-confirm-bar">
+    <span class="download-confirm-text">是否下载 {{ filename }} 到本地?</span>
+    <div class="download-confirm-actions">
+      <button class="download-confirm-btn download-btn" @click="emit('confirm')">下载</button>
+      <button class="download-confirm-btn cancel-btn" @click="emit('cancel')">取消</button>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  interface Props {
+    filename: string;
+  }
+
+  defineProps<Props>();
+  const emit = defineEmits<{
+    (e: 'confirm'): void;
+    (e: 'cancel'): void;
+  }>();
+</script>
+
+<style scoped lang="less">
+  .download-confirm-bar {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 10px 16px;
+    margin: 0 16px;
+    background: #0a2a3f;
+    border: 1px solid #1a4a6f;
+    border-radius: 8px;
+    flex-shrink: 0;
+
+    .download-confirm-text {
+      font-size: 13px;
+      color: #e0e6ed;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+      margin-right: 12px;
+    }
+
+    .download-confirm-actions {
+      display: flex;
+      gap: 8px;
+      flex-shrink: 0;
+    }
+
+    .download-confirm-btn {
+      padding: 4px 14px;
+      border-radius: 4px;
+      font-size: 12px;
+      cursor: pointer;
+      border: none;
+      transition: all 0.2s;
+
+      &.download-btn {
+        background: #0a84ff;
+        color: #fff;
+
+        &:hover {
+          background: #3a9bfd;
+        }
+      }
+
+      &.cancel-btn {
+        background: rgba(255, 255, 255, 0.08);
+        color: #e0e6ed;
+        border: 1px solid rgba(63, 80, 106, 0.5);
+
+        &:hover {
+          background: rgba(255, 255, 255, 0.15);
+        }
+      }
+    }
+  }
+</style>

+ 96 - 8
src/views/ventAI/manageAssistent/components/chatModal/FilePreviewPanel.vue

@@ -12,17 +12,40 @@
       </div>
       <div v-else-if="isPdfFile(file.name)" class="pdf-preview">
         <iframe v-if="file.preview" :src="file.preview" frameborder="0"></iframe>
-        <div v-else class="pdf-no-preview">无法加载PDF预览</div>
+        <div v-else class="no-preview-text">无法加载PDF预览</div>
       </div>
-      <div v-else class="text-preview">
+      <div v-else-if="isVideoFile(file.name)" class="video-preview">
+        <video v-if="file.preview" controls>
+          <source :src="file.preview" />
+          您的浏览器不支持视频预览
+        </video>
+        <div v-else class="no-preview-text">无法加载视频预览</div>
+      </div>
+      <div v-else-if="isAudioFile(file.name)" class="audio-preview">
+        <div class="audio-info">
+          <div class="audio-icon"></div>
+          <span class="audio-name">{{ file.name }}</span>
+        </div>
+        <audio v-if="file.preview" controls>
+          <source :src="file.preview" />
+          您的浏览器不支持音频预览
+        </audio>
+        <div v-else class="no-preview-text">无法加载音频预览</div>
+      </div>
+      <div v-else-if="isTextFile(file.name)" class="text-preview">
         <pre>{{ file.content || '文件内容加载中...' }}</pre>
       </div>
+      <div v-else class="no-preview">
+        <div class="no-preview-icon"></div>
+        <div class="no-preview-text">不支持预览此文件格式</div>
+        <div class="no-preview-hint">{{ getFileExtension(file.name) }} 格式文件暂不支持在线预览</div>
+      </div>
     </div>
   </div>
 </template>
 
 <script setup lang="ts">
-  import { isImageFile, isPdfFile } from './utils';
+  import { isImageFile, isPdfFile, isVideoFile, isAudioFile, isTextFile, getFileExtension } from './utils';
   import type { AttachedFile } from './types';
 
   defineProps<{
@@ -117,14 +140,51 @@
         height: 100%;
         border: none;
       }
+    }
+
+    .video-preview {
+      width: 100%;
+      height: 100%;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+
+      video {
+        width: 100%;
+        max-height: 100%;
+        border-radius: 4px;
+      }
+    }
+
+    .audio-preview {
+      width: 100%;
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      gap: 16px;
+      padding: 20px 0;
 
-      .pdf-no-preview {
+      .audio-info {
         display: flex;
         align-items: center;
-        justify-content: center;
-        height: 100%;
-        color: #8b949e;
-        font-size: 14px;
+        gap: 10px;
+
+        .audio-icon {
+          width: 40px;
+          height: 40px;
+          background-image: var(--img-chat-attach-icon);
+          background-repeat: no-repeat;
+          background-size: 100% 100%;
+        }
+
+        .audio-name {
+          font-size: 14px;
+          color: #e0e6ed;
+        }
+      }
+
+      audio {
+        width: 100%;
       }
     }
 
@@ -137,5 +197,33 @@
         color: #e0e6ed;
       }
     }
+
+    .no-preview {
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      justify-content: center;
+      height: 100%;
+      gap: 12px;
+
+      .no-preview-icon {
+        width: 64px;
+        height: 64px;
+        background-image: var(--img-chat-attach-icon);
+        background-repeat: no-repeat;
+        background-size: 100% 100%;
+        opacity: 0.4;
+      }
+    }
+
+    .no-preview-text {
+      color: #8b949e;
+      font-size: 14px;
+    }
+
+    .no-preview-hint {
+      color: #6e7681;
+      font-size: 12px;
+    }
   }
 </style>

+ 15 - 2
src/views/ventAI/manageAssistent/components/chatModal/utils.ts

@@ -107,7 +107,20 @@ export const formatFileSize = (bytes: number): string => {
   return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
 };
 
-export const isImageFile = (filename: string): boolean =>
-  /\.(jpg|jpeg|png|gif|bmp|webp)$/i.test(filename);
+export const isImageFile = (filename: string): boolean => /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tiff|tif)$/i.test(filename);
 
 export const isPdfFile = (filename: string): boolean => /\.pdf$/i.test(filename);
+
+export const isVideoFile = (filename: string): boolean => /\.(mp4|webm|ogg|mov|avi|mkv|flv|wmv|m4v)$/i.test(filename);
+
+export const isAudioFile = (filename: string): boolean => /\.(mp3|wav|ogg|aac|flac|wma|m4a|opus)$/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
+  );
+
+export const getFileExtension = (filename: string): string => {
+  const match = filename.match(/\.([^.]+)$/);
+  return match ? `.${match[1]}`.toUpperCase() : '';
+};

Some files were not shown because too many files changed in this diff