Kaynağa Gözat

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

bobo04052021@163.com 2 hafta önce
ebeveyn
işleme
22062afee9
35 değiştirilmiş dosya ile 3960 ekleme ve 1216 silme
  1. 3 3
      .env.development
  2. 8 0
      src/api/sys/menu.ts
  3. 2 1
      src/components/Application/src/AppLogo.vue
  4. 2 2
      src/components/Container/src/Adaptive.vue
  5. 1 2
      src/components/vent/micro/createSubApp.vue
  6. 3 1
      src/components/vent/micro/ventModal.vue
  7. 3 0
      src/enums/cacheEnum.ts
  8. 190 0
      src/layouts/default/header/components/user-dropdown/HomeStyleSelect.vue
  9. 15 1
      src/layouts/default/header/components/user-dropdown/index.vue
  10. 5 2
      src/layouts/default/sider/bottomSideder.vue
  11. 4 1
      src/layouts/default/sider/bottomSideder3.vue
  12. 1 0
      src/locales/lang/en/layout.ts
  13. 1 0
      src/locales/lang/zh-CN/layout.ts
  14. 2 1
      src/router/guard/index.ts
  15. 4 2
      src/router/guard/permissionGuard.ts
  16. 3 2
      src/store/modules/user.ts
  17. 45 0
      src/utils/homeStyle.ts
  18. 125 122
      src/views/vent/monitorManager/deviceMonitor/components/device/index.vue
  19. 83 94
      src/views/vent/monitorManager/deviceMonitor/index1.vue
  20. 3 3
      src/views/vent/monitorManager/fireDoorMonitor/fireDoor.threejs.ymh.ts
  21. 1 1
      src/views/vent/monitorManager/gateMonitor/gate.data.ts
  22. 801 703
      src/views/vent/monitorManager/mainFanMonitor/index.vue
  23. 5 0
      src/views/vent/monitorManager/mainFanMonitor/main.data.ts
  24. 613 0
      src/views/vent/monitorManager/windowMonitor/components/windowDaLiuTaSVG.vue
  25. 572 0
      src/views/vent/monitorManager/windowMonitor/components/windowDaiXiaoMenSVG.vue
  26. 559 0
      src/views/vent/monitorManager/windowMonitor/components/windowTripleSVG.vue
  27. 8 10
      src/views/vent/monitorManager/windowMonitor/window.data.ts
  28. 45 4
      src/views/ventAI/manageAssistent/api.ts
  29. 70 55
      src/views/ventAI/manageAssistent/components/AiAssistantModal.vue
  30. 1 1
      src/views/ventAI/manageAssistent/components/chatModal/ChatInputArea.vue
  31. 457 28
      src/views/ventAI/manageAssistent/components/chatModal/ChatMessages.vue
  32. 0 78
      src/views/ventAI/manageAssistent/components/chatModal/DownloadConfirmBar.vue
  33. 277 98
      src/views/ventAI/manageAssistent/components/chatModal/FilePreviewPanel.vue
  34. 27 0
      src/views/ventAI/manageAssistent/components/chatModal/types.ts
  35. 21 1
      src/views/ventAI/manageAssistent/components/chatModal/utils.ts

+ 3 - 3
.env.development

@@ -6,7 +6,7 @@ VITE_PUBLIC_PATH = /
 
 # 跨域代理,您可以配置多个 ,请注意,没有换行符
 #VITE_PROXY = [["/jeecgboot","http://localhost:8080/jeecg-boot"],["/upload","http://localhost:3300/upload"]]
-VITE_PROXY = [["/sw","http://182.92.126.35:6008"],["/jeecgsystem","http://182.92.126.35:9999"],["/upload","http://182.92.126.35:9999/upload"],["/documents", "http://182.92.126.35:9050"],["/modelreq", "http://182.92.126.35:9999"],["/webRtc", "http://182.92.126.35:8051"], ["/python", "http://127.0.0.1:8008"], ["/tun2D", "http://127.0.0.1:8088/micro-vent-2dModal/tun2D"],["/dataCenter", "http://39.97.59.228:10999"],["/ventAI", "http://39.97.59.228:8070"]]
+VITE_PROXY = [["/sw","http://182.92.126.35:6008"],["/jeecgsystem","http://182.92.126.35:9999"],["/upload","http://182.92.126.35:9999/upload"],["/documents", "http://182.92.126.35:9050"],["/modelreq", "http://182.92.126.35:9999"],["/webRtc", "http://182.92.126.35:8051"], ["/python", "http://127.0.0.1:8008"], ["/res", "http://182.92.126.35:9999/"],["/dataCenter", "http://39.97.59.228:10999"],["/ventAI", "http://39.97.59.228:8070"]]
 # VITE_PROXY = [["/jeecgsystem","http://192.168.183.88:9999"],["/upload","http://192.168.183.88:9999/upload"],["/documents", "http://192.168.183.88:9050"],["/modelreq", "http://192.168.183.88:9999"],["/webRtc", "http://192.168.183.88:8051"]]
 # VITE_PROXY = [["/jeecgsystem","http://10.10.150.72:9999"],["/upload","http://localhost:3300/upload"],["/documents", "http://10.10.150.72:9050"],["/modelreq", "http://10.10.150.72:9999"],["/webRtc", "http://192.168.183.216:8051"]]
 # VITE_PROXY = [["/jeecgsystem","http://192.168.1.8:9999"],["/upload","http://localhost:3300/upload"]]
@@ -31,6 +31,6 @@ VITE_GLOB_API_URL_PREFIX=
 #VITE_APP_SUB_APP = [["micro-need-air", "//10.10.150.72:8099/"], ["micro-vent-3dModal", "//localhost:8091/"], ["micro-fire-front", "//localhost:8090/"]]
 # VITE_APP_SUB_APP = [["micro-vent-3dModal", "//192.168.183.154:8091/", "micro-vent-3dModal"], ["micro-need-air", "//192.168.183.88:8093/", "micro-need-air"], ["micro-fire-front", "//localhost:8097/", "fire-Micro"]]
 # VITE_APP_SUB_APP = [["micro-vent-3dModal", "//192.168.1.16:8091/", "micro-vent-3dModal"], ["micro-need-air", "//192.168.183.88:8093/", "micro-need-air"], ["micro-fire-front", "//localhost:8097/", "fire-Micro"]]
-# VITE_APP_SUB_APP = [["micro-vent-3dModal", "//182.92.126.35:8091", "micro-vent-3dModal"],["micro-vent-2dModal", "//localhost:8088", "micro-vent-2dModal"],["micro-vent-doc", "//localhost:5173", "micro-vent-doc"],["micro-need-air", "//182.92.126.35:8093", "micro-need-air"], ["micro-fire-front", "//182.92.126.35:8097", "fire-Micro"]]
-VITE_APP_SUB_APP = [["micro-vent-3dModal", "//192.168.1.23:8091", "micro-vent-3dModal"],["micro-vent-2dModal", "//localhost:8088", "micro-vent-2dModal"],["micro-vent-doc", "//localhost:5173", "micro-vent-doc"],["micro-need-air", "//192.168.1.21:8099", "micro-need-air"], ["micro-fire-front", "//182.92.126.35:8097", "fire-Micro"]]
+VITE_APP_SUB_APP = [["micro-vent-3dModal", "//182.92.126.35:8091", "micro-vent-3dModal"],["micro-vent-2dModal", "//localhost:8088", "micro-vent-2dModal"],["micro-vent-doc", "//localhost:5173", "micro-vent-doc"],["micro-need-air", "//182.92.126.35:8093", "micro-need-air"], ["micro-fire-front", "//182.92.126.35:8097", "fire-Micro"]]
+# VITE_APP_SUB_APP = [["micro-vent-3dModal", "//192.168.1.11:8091/", "micro-vent-3dModal"],["micro-vent-2dModal", "//localhost:8088", "micro-vent-2dModal"],["micro-vent-doc", "//localhost:5173", "micro-vent-doc"],["micro-need-air", "//192.168.1.21:8099", "micro-need-air"], ["micro-fire-front", "//182.92.126.35:8097", "fire-Micro"]]
 # VITE_APP_SUB_APP = [["micro-vent-3dModal", "//localhost:8091/"], ["micro-need-air", "//localhost:8099/"], ["micro-fire-front", "//localhost:8090/"]]

+ 8 - 0
src/api/sys/menu.ts

@@ -3,9 +3,17 @@ import { getMenuListResultModel } from './model/menuModel';
 
 enum Api {
   GetMenuList = '/sys/permissionNew/getUserPermissionByToken',
+  List = '/sys/permissionNew/list',
   SwitchVue3Menu = '/sys/switchVue3Menu',
 }
 
+/**
+ * @description: 菜单/权限列表(首页风格选项由此接口动态获取)
+ */
+export const getPermissionList = (params?) => {
+  return defHttp.get({ url: Api.List, params });
+};
+
 /**
  * @description: Get user menu based on id
  */

+ 2 - 1
src/components/Application/src/AppLogo.vue

@@ -18,6 +18,7 @@
   import { useDesign } from '/@/hooks/web/useDesign';
   import { PageEnum } from '/@/enums/pageEnum';
   import { useUserStore } from '/@/store/modules/user';
+  import { getSavedHomeRoute } from '/@/utils/homeStyle';
 
   const props = defineProps({
     /**
@@ -51,7 +52,7 @@
 
   function goHome() {
     const glob = useGlobSetting();
-    go(glob.homePath || PageEnum.BASE_HOME);
+    go(getSavedHomeRoute() || glob.homePath || PageEnum.BASE_HOME);
   }
 </script>
 <style lang="less" scoped>

+ 2 - 2
src/components/Container/src/Adaptive.vue

@@ -88,7 +88,7 @@
         const heightScale = currentHeight / realHeight;
         appStore.setWidthScale(widthScale);
         appStore.setHeightScale(heightScale);
-        actions.setGlobalState({ widthScale, heightScale });
+        actions.setGlobalState({ widthScale, heightScale, currentRouter: null, locationId: '', locationObj: null, pageObj: null });
 
         //如果dom存在,就按照比例缩放
         dom && (dom.style.transform = `scale(${widthScale}, ${heightScale})`);
@@ -168,8 +168,8 @@
     position: relative;
     top: 0;
     left: 0;
+    z-index: 0;
     overflow: hidden;
     transform-origin: left top;
-    z-index: 0;
   }
 </style>

+ 1 - 2
src/components/vent/micro/createSubApp.vue

@@ -42,7 +42,6 @@
   watch(
     () => [route.query, route.path],
     async ([newQuery, newPath], [oldQuery, oldPath]) => {
-      debugger;
       // 这里判断
       if (newPath !== oldPath) {
         await resetComponent();
@@ -60,7 +59,7 @@
         }
         if (isRefresh) {
           const actions = getActions();
-          actions.setGlobalState({ currentRouter: route });
+          actions.setGlobalState({ currentRouter: route, locationId: '', locationObj: null, pageObj: null });
         }
       }
     }

+ 3 - 1
src/components/vent/micro/ventModal.vue

@@ -33,6 +33,7 @@
   .modal-bg {
     background: url('/@/assets/images/beltFire/baseMap.png') no-repeat center;
   }
+
   .loading-box {
     position: fixed;
     display: flex;
@@ -41,10 +42,11 @@
     width: 100%;
     height: 100%;
   }
+
   #micro-vent-3dModal {
     position: absolute;
-    left: 0;
     top: 0;
+    left: 0;
     width: 100%;
     height: 100%;
     pointer-events: auto;

+ 3 - 0
src/enums/cacheEnum.ts

@@ -22,6 +22,9 @@ export const MULTIPLE_TABS_KEY = 'MULTIPLE_TABS__KEY__';
 
 export const APP_DARK_MODE_KEY_ = '__APP__DARK__MODE__';
 
+// 首页风格选择 key
+export const HOME_STYLE_KEY = '__HOME__STYLE__';
+
 // base global local key
 export const APP_LOCAL_CACHE_KEY = 'COMMON__LOCAL__KEY__';
 

+ 190 - 0
src/layouts/default/header/components/user-dropdown/HomeStyleSelect.vue

@@ -0,0 +1,190 @@
+<template>
+  <BasicModal
+    v-model:visible="visible"
+    title="首页风格"
+    :ok-text="'确认'"
+    :cancel-text="'取消'"
+    :mask-closable="false"
+    :can-fullscreen="false"
+    width="480px"
+    @ok="handleConfirm"
+  >
+    <div class="home-style-select">
+      <p class="home-style-tip">选择后系统将应用对应的首页风格并切换主题。</p>
+      <a-select v-model:value="selectedId" :options="selectOptions" placeholder="请选择首页风格" :loading="loading" style="width: 100%" />
+      <p v-if="currentOption" class="home-style-detail">首页路由:{{ currentOption.route }} 主题:{{ currentOption.theme }}</p>
+    </div>
+    <!-- 默认 footer 顺序:取消 → centerFooter(恢复默认) → 确认 -->
+    <template #centerFooter>
+      <a-button @click="handleRestore">恢复默认</a-button>
+    </template>
+  </BasicModal>
+</template>
+<script lang="ts" setup>
+  // 首页风格切换弹窗:下拉框选择首页风格,右下角 取消 / 恢复默认 / 确认。
+  // 最终可选首页 = 前端配置数组 HOME_STYLE_CONFIG × 后端 /sys/permissionNew/list 数据(按 component 精确匹配):
+  // 前端配置提供候选与名称/主题,后端确认"已注册"并提供路由 url;本地存储见 /@/utils/homeStyle。
+  import { computed, ref } from 'vue';
+
+  import { BasicModal } from '/@/components/Modal';
+  import { ThemeEnum } from '/@/enums/appEnum';
+  import { PageEnum } from '/@/enums/pageEnum';
+  import { useGlobSetting } from '/@/hooks/setting';
+  import { useRootSetting } from '/@/hooks/setting/useRootSetting';
+  import { useMessage } from '/@/hooks/web/useMessage';
+  import { useGo } from '/@/hooks/web/usePage';
+  // import { ThemeModel } from '/@/layouts/default/layout.data';
+  import { updateDarkTheme } from '/@/logics/theme/dark';
+  import { updateHeaderBgColor, updateSidebarBgColor } from '/@/logics/theme/updateBackground';
+
+  import { getPermissionList } from '/@/api/sys/menu';
+  import { getSavedHomeStyle, saveHomeStyle } from '/@/utils/homeStyle';
+  import type { HomeStyleOption } from '/@/utils/homeStyle';
+
+  const visible = ref(false);
+  const selectedId = ref<string>('');
+  const loading = ref(false);
+  const options = ref<HomeStyleOption[]>([]);
+
+  const { createMessage } = useMessage();
+  const go = useGo();
+  const glob = useGlobSetting();
+  const { setDarkMode } = useRootSetting();
+
+  /** 递归展平菜单树(兼容 children 嵌套) */
+  function flattenMenu(nodes: any[], list: any[] = []): any[] {
+    nodes?.forEach((node) => {
+      list.push(node);
+      if (Array.isArray(node.children)) flattenMenu(node.children, list);
+    });
+    return list;
+  }
+
+  /** 前端配置的首页风格候选:与后端菜单按 component 精确匹配,匹配上才可选(component 需唯一) */
+  interface HomeStyleConfig {
+    /** 对应后端菜单的 component(匹配依据) */
+    component: string;
+    /** 首页风格名称(下拉框显示);缺省时取后端菜单 title */
+    name?: string;
+    /** 主题模式:ThemeEnum.VENT1 / DEEPBLUE / GREEN / LIGHT / DARK */
+    theme: ThemeEnum;
+    /** 可选:页面样式变量(如 ThemeModel.theme5_5) */
+    cssVars?: Record<string, string>;
+  }
+
+  const HOME_STYLE_CONFIG: HomeStyleConfig[] = [
+    { component: 'vent/home/configurable/vent182', name: '一通三防首页-默认', theme: ThemeEnum.VENT1 },
+    { component: 'vent/home/configurable/green/fusion-warn-green', name: '一通三防首页-绿主题', theme: ThemeEnum.GREEN },
+    { component: 'vent/home/configurable/blue/dustNew', name: '一通三防首页-蓝主题', theme: ThemeEnum.DEEPBLUE },
+  ];
+
+  // Promise 记忆化缓存:同一会话只请求一次,失败清空可重试
+  let homeStyleOptionsPromise: Promise<HomeStyleOption[]> | null = null;
+
+  /** 动态获取最终可选首页:前端配置 × 后端菜单(按 component 匹配) */
+  function getHomeStyleOptions(): Promise<HomeStyleOption[]> {
+    if (!homeStyleOptionsPromise) {
+      homeStyleOptionsPromise = getPermissionList()
+        .then((res: any) => {
+          // 后端菜单:component → 菜单项(同 component 多路由时优先 3D,2D 由路由守卫自动改写)
+          const menuByComponent = new Map<string, any>();
+          flattenMenu(Array.isArray(res) ? res : [])
+            .filter((item) => item && item.component && item.url)
+            .forEach((item) => {
+              const component = String(item.component);
+              const existed = menuByComponent.get(component);
+              if (!existed || (item.url.includes('micro-vent-3dModal') && !existed.url.includes('micro-vent-3dModal'))) {
+                menuByComponent.set(component, item);
+              }
+            });
+          // 配置数组 ∩ 后端已注册:路由取后端 url,名称/主题/样式变量取配置
+          return HOME_STYLE_CONFIG.filter((config) => menuByComponent.has(config.component)).map((config) => {
+            const item = menuByComponent.get(config.component);
+            return {
+              id: config.component, // 持久化 id 用配置的 component(稳定,不随后端菜单 id 变化)
+              name: config.name || item.title || item.name || config.component,
+              route: item.url,
+              theme: config.theme,
+              cssVars: config.cssVars,
+            };
+          });
+        })
+        .catch((e) => {
+          console.error('获取首页风格列表失败:', e);
+          homeStyleOptionsPromise = null;
+          return [];
+        });
+    }
+    return homeStyleOptionsPromise;
+  }
+
+  const selectOptions = computed(() => options.value.map((o) => ({ value: o.id, label: o.name })));
+  const currentOption = computed(() => options.value.find((o) => o.id === selectedId.value) || null);
+
+  /** 打开弹窗:获取选项后按 已保存 → 系统默认首页 → 第一项 预选 */
+  async function show() {
+    visible.value = true;
+    loading.value = true;
+    options.value = await getHomeStyleOptions();
+    loading.value = false;
+    // 本地选择已失效时清理
+    const saved = getSavedHomeStyle();
+    const savedValid = !!saved && options.value.some((o) => o.id === saved.id);
+    if (saved && !savedValid) saveHomeStyle(null);
+    const savedId = saved && savedValid ? saved.id : '';
+    selectedId.value = savedId || options.value.find((o) => o.route === glob.homePath)?.id || options.value[0]?.id || '';
+  }
+
+  /** 仅应用主题(模式 + 样式变量),不处理持久化 */
+  function applyTheme({ theme, cssVars }: Pick<HomeStyleOption, 'theme' | 'cssVars'>) {
+    setDarkMode(theme);
+    updateDarkTheme(theme);
+    updateHeaderBgColor();
+    updateSidebarBgColor();
+    if (cssVars) {
+      Object.entries(cssVars).forEach(([key, value]) => document.body.style.setProperty(`--${key}`, value));
+    }
+  }
+
+  /** 应用首页风格:主题 + 持久化 + 提示 + 跳转 */
+  function applyStyle(option: HomeStyleOption) {
+    applyTheme(option);
+    saveHomeStyle(option);
+    createMessage.success(`已切换首页风格为「${option.name}」,正在应用新首页…`);
+    visible.value = false;
+    go(option.route);
+  }
+
+  function handleConfirm() {
+    if (currentOption.value) applyStyle(currentOption.value);
+  }
+
+  /** 恢复默认:本地存储首页置为 null,应用系统默认首页主题并跳转 */
+  function handleRestore() {
+    const defaultOption = options.value.find((o) => o.route === glob.homePath);
+    saveHomeStyle(null);
+    selectedId.value = defaultOption?.id || '';
+    applyTheme(defaultOption ?? { theme: ThemeEnum.VENT1 });
+    createMessage.success('已恢复默认首页风格,正在应用系统默认首页…');
+    visible.value = false;
+    go(glob.homePath || PageEnum.BASE_HOME);
+  }
+
+  defineExpose({ show });
+</script>
+<style lang="less" scoped>
+  .home-style-select {
+    .home-style-tip {
+      margin-bottom: 12px;
+      color: #999;
+      font-size: 12px;
+    }
+
+    .home-style-detail {
+      margin-top: 12px;
+      color: #666;
+      font-size: 12px;
+      line-height: 1.6;
+    }
+  }
+</style>

+ 15 - 1
src/layouts/default/header/components/user-dropdown/index.vue

@@ -25,6 +25,7 @@
             :text="t('layout.header.tooltipLock')"
             icon="ion:lock-closed-outline"
         />-->
+        <MenuItem key="homeStyle" :text="t('layout.header.dropdownItemHomeStyle')" icon="ion:home-outline" />
         <MenuItem key="about" :text="t('layout.header.dropdownItemAbout')" icon="ion:information-outline" />
         <MenuItem key="logout" :text="t('layout.header.dropdownItemLoginOut')" icon="ion:power-outline" />
       </Menu>
@@ -33,6 +34,7 @@
   <LockAction v-if="lockActionVisible" ref="lockActionRef" @register="register" />
   <DepartSelect ref="loginSelectRef" />
   <ThemeSelect ref="themeSelectRef" />
+  <HomeStyleSelect ref="homeStyleSelectRef" />
   <UpdatePassword v-if="passwordVisible" ref="updatePasswordRef" />
 </template>
 <script lang="ts">
@@ -63,7 +65,7 @@
   import { getRefPromise } from '/@/utils/index';
   import { get } from 'lodash-es';
 
-  type MenuEvent = 'logout' | 'doc' | 'lock' | 'cache' | 'depart' | 'modalCache' | 'switchTheme' | 'info';
+  type MenuEvent = 'logout' | 'doc' | 'lock' | 'cache' | 'depart' | 'modalCache' | 'switchTheme' | 'info' | 'homeStyle';
   const { createMessage } = useMessage();
   export default defineComponent({
     name: 'UserDropdown',
@@ -75,6 +77,7 @@
       LockAction: createAsyncComponent(() => import('../lock/LockModal.vue')),
       DepartSelect: createAsyncComponent(() => import('./DepartSelect.vue')),
       ThemeSelect: createAsyncComponent(() => import('./ThemeSelect.vue')),
+      HomeStyleSelect: createAsyncComponent(() => import('./HomeStyleSelect.vue')),
       UpdatePassword: createAsyncComponent(() => import('./UpdatePassword.vue')),
     },
     props: {
@@ -175,6 +178,9 @@
       // update-end--author:liaozhiyang---date:20230901---for:【QQYUN-6333】空路由问题—首次访问资源太大
       function handleMenuClick(e: { key: MenuEvent }) {
         switch (e.key) {
+          case 'homeStyle':
+            updateHomeStyle();
+            break;
           case 'about':
             message.info(`当前版本:${get(window, '__LAST_PRODUCTION_TAG__.version', '开发模式')}`);
             break;
@@ -218,6 +224,12 @@
         themeSelectRef.value.show();
       }
 
+      // 首页风格弹窗
+      const homeStyleSelectRef = ref();
+      function updateHomeStyle() {
+        homeStyleSelectRef.value.show();
+      }
+
       return {
         prefixCls,
         t,
@@ -229,6 +241,8 @@
         getUseLockPage,
         loginSelectRef,
         themeSelectRef,
+        homeStyleSelectRef,
+        updateHomeStyle,
         updatePasswordRef,
         passwordVisible,
         lockActionVisible,

+ 5 - 2
src/layouts/default/sider/bottomSideder.vue

@@ -52,7 +52,7 @@
     </div>
   </div>
   <div v-else-if="isShowMenu == 0" class="menu-show-icon" :style="iconStyle" @mousedown="onDragStart">
-  <!-- <div v-else-if="isShowMenu == 0" class="menu-show-icon"> -->
+    <!-- <div v-else-if="isShowMenu == 0" class="menu-show-icon"> -->
     <div class="icon" :class="themeIcon == 'styleTwo' ? 'icon-2' : 'icon-1'" @click="openMenu"></div>
   </div>
 </template>
@@ -72,6 +72,7 @@
   import { useUserStoreWithOut } from '/@/store/modules/user';
   import { useAppStore } from '/@/store/modules/app';
   import { router } from '/@/router';
+  import { getSavedHomeRoute } from '/@/utils/homeStyle';
 
   export default defineComponent({
     name: 'BottomSider',
@@ -208,7 +209,9 @@
       }
 
       function geHome() {
-        if (userStore.getUserInfo.homePath) {
+        if (getSavedHomeRoute()) {
+          go(getSavedHomeRoute());
+        } else if (userStore.getUserInfo.homePath) {
           go(userStore.getUserInfo.homePath);
         } else if (currentRoute.value.path.startsWith('/micro-need-air')) {
           window.history.pushState({}, '', glob.homePath || PageEnum.BASE_HOME);

+ 4 - 1
src/layouts/default/sider/bottomSideder3.vue

@@ -67,6 +67,7 @@
   import { useAppStore } from '/@/store/modules/app';
   import { router } from '/@/router';
   import SiderBorderBg from '/@/components/vent/siderBorderBg.vue';
+  import { getSavedHomeRoute } from '/@/utils/homeStyle';
 
   export default defineComponent({
     name: 'BottomSider',
@@ -185,7 +186,9 @@
       }
 
       function geHome() {
-        if (userStore.getUserInfo.homePath) {
+        if (getSavedHomeRoute()) {
+          go(getSavedHomeRoute());
+        } else if (userStore.getUserInfo.homePath) {
           go(userStore.getUserInfo.homePath);
         } else if (currentRoute.value.path.startsWith('/micro-need-air')) {
           window.history.pushState({}, '', glob.homePath || PageEnum.BASE_HOME);

+ 1 - 0
src/locales/lang/en/layout.ts

@@ -4,6 +4,7 @@ export default {
     // user dropdown
     dropdownItemDoc: 'Document',
     dropdownItemAbout: 'About',
+    dropdownItemHomeStyle: 'Home Style',
     dropdownItemLoginOut: 'Login Out',
     dropdownItemSwitchPassword: 'Password Change',
     dropdownItemSwitchDepart: 'Switch Department',

+ 1 - 0
src/locales/lang/zh-CN/layout.ts

@@ -4,6 +4,7 @@ export default {
     // user dropdown
     // dropdownItemDoc: '官网',
     dropdownItemAbout: '关于',
+    dropdownItemHomeStyle: '首页风格',
     dropdownItemLoginOut: '退出系统',
     dropdownItemSwitchPassword: '密码修改',
     // dropdownItemSwitchHome: '切换风格',

+ 2 - 1
src/router/guard/index.ts

@@ -17,6 +17,7 @@ import { createParamMenuGuard } from './paramMenuGuard';
 import { RootRoute } from '/@/router/routes';
 import { useGlobSetting } from '/@/hooks/setting';
 import { PageEnum } from '/@/enums/pageEnum';
+import { getSavedHomeRoute } from '/@/utils/homeStyle';
 // import { unmountMicroApps } from '/@/qiankun';
 
 // Don't change the order of creation
@@ -33,7 +34,7 @@ export function setupRouterGuard(router: Router) {
 }
 const glob = useGlobSetting();
 
-RootRoute.redirect = glob.homePath || PageEnum.BASE_HOME;
+RootRoute.redirect = getSavedHomeRoute() || glob.homePath || PageEnum.BASE_HOME;
 /**
  * Hooks for handling page state
  */

+ 4 - 2
src/router/guard/permissionGuard.ts

@@ -14,6 +14,8 @@ import { OAUTH2_THIRD_LOGIN_TENANT_ID } from '/@/enums/cacheEnum';
 
 import { useGlobSetting } from '/@/hooks/setting';
 
+import { getSavedHomeRoute } from '/@/utils/homeStyle';
+
 import { isEmpty, assign } from 'lodash-es';
 import { MOCK_LOGIN_URL_QUERY, SKIP_SSO_URL_QUERY } from '../constant';
 import { useSso } from '/@/hooks/web/useSso';
@@ -44,7 +46,7 @@ export function createPermissionGuard(router: Router) {
   const permissionStore = usePermissionStoreWithOut();
   const { doAutoLogin, doTokenLogin, validateRoute, tokenValidateRoute } = useAutoLogin();
   router.beforeEach(async (to, from, next) => {
-    RootRoute.redirect = glob.homePath || PageEnum.BASE_HOME;
+    RootRoute.redirect = getSavedHomeRoute() || glob.homePath || PageEnum.BASE_HOME;
     if (to.query['isNoReverse'] != '1') {
       if (VENT_PARAM['is2DModel']) {
         for (const key in to) {
@@ -54,7 +56,7 @@ export function createPermissionGuard(router: Router) {
         }
       } else {
         for (const key in to) {
-          if (typeof to[key] == 'string' && to[key].includes('micro-vent-2dModal')) {
+          if (typeof to[key] == 'string' && !to[key].includes('/ltauto-view') && to[key].includes('micro-vent-2dModal')) {
             to[key] = to[key].replace('micro-vent-2dModal', 'micro-vent-3dModal');
           }
         }

+ 3 - 2
src/store/modules/user.ts

@@ -19,6 +19,7 @@ import { JDragConfigEnum } from '/@/enums/jeecgEnum';
 import { RoleEnum } from '/@/enums/roleEnum';
 import { useSso } from '/@/hooks/web/useSso';
 import { getActions } from '/@/qiankun/state';
+import { getSavedHomeRoute } from '/@/utils/homeStyle';
 import { MOCK_LOGIN_PASSWORD, MOCK_LOGIN_UESRNAME } from '../constant';
 import { AesEncryption } from '/@/utils/cipher';
 import { loginCipher } from '/@/settings/encryptionSetting';
@@ -195,7 +196,7 @@ export const useUserStore = defineStore({
         //update-begin-author:liusq date:2022-5-5 for:登录成功后缓存拖拽模块的接口前缀
         localStorage.setItem(JDragConfigEnum.DRAG_BASE_URL, useGlobSetting().domainUrl);
         //update-end-author:liusq date:2022-5-5 for: 登录成功后缓存拖拽模块的接口前缀
-        goHome && (await router.replace((userInfo && userInfo.homePath) || glob.homePath || PageEnum.BASE_HOME));
+        goHome && (await router.replace(getSavedHomeRoute() || (userInfo && userInfo.homePath) || glob.homePath || PageEnum.BASE_HOME));
         // update-begin-author:sunjianlei date:20230306 for: 修复登录成功后,没有正确重定向的问题
         const redirect = router.currentRoute.value?.query?.redirect as string;
         // 判断是否有 redirect 重定向地址
@@ -208,7 +209,7 @@ export const useUserStore = defineStore({
         }
         // update-end-author:sunjianlei date:20230306 for: 修复登录成功后,没有正确重定向的问题
 
-        goHome && (await router.replace((userInfo && userInfo.homePath) || glob.homePath || PageEnum.BASE_HOME));
+        goHome && (await router.replace(getSavedHomeRoute() || (userInfo && userInfo.homePath) || glob.homePath || PageEnum.BASE_HOME));
       }
       if (useGlobSetting().openQianKun) {
         const actions = getActions();

+ 45 - 0
src/utils/homeStyle.ts

@@ -0,0 +1,45 @@
+import { ThemeEnum } from '/@/enums/appEnum';
+import { HOME_STYLE_KEY } from '/@/enums/cacheEnum';
+
+/**
+ * 首页风格选择:localStorage 的存放与读取工具。
+ * key 定义见 /@/enums/cacheEnum 的 HOME_STYLE_KEY;
+ * 最终可选首页由 HomeStyleSelect.vue 的前端配置数组与 /sys/permissionNew/list 后端数据按 component 匹配得出。
+ */
+
+export interface HomeStyleOption {
+  /** 唯一标识(持久化用,取前端配置的 component,稳定) */
+  id: string;
+  /** 首页风格名称(下拉框显示) */
+  name: string;
+  /** 首页路由(对应后端菜单 url) */
+  route: string;
+  /** 主题模式:ThemeEnum.VENT1 / DEEPBLUE / GREEN / LIGHT / DARK */
+  theme: ThemeEnum;
+  /** 页面样式变量,可选(如 ThemeModel.theme5_5) */
+  cssVars?: Record<string, string>;
+}
+
+/** 读取已保存的首页风格(无则返回 null,损坏数据安全兜底) */
+export function getSavedHomeStyle(): HomeStyleOption | null {
+  try {
+    const raw = localStorage.getItem(HOME_STYLE_KEY);
+    return raw ? (JSON.parse(raw) as HomeStyleOption) : null;
+  } catch {
+    return null;
+  }
+}
+
+/** 保存首页风格选择;传入 null 表示清除(恢复系统默认) */
+export function saveHomeStyle(option: HomeStyleOption | null): void {
+  if (option) {
+    localStorage.setItem(HOME_STYLE_KEY, JSON.stringify({ id: option.id, route: option.route }));
+  } else {
+    localStorage.removeItem(HOME_STYLE_KEY);
+  }
+}
+
+/** 供路由守卫 / 登录流程读取已保存的首页路由(无则返回 '') */
+export function getSavedHomeRoute(): string {
+  return getSavedHomeStyle()?.route || '';
+}

+ 125 - 122
src/views/vent/monitorManager/deviceMonitor/components/device/index.vue

@@ -144,7 +144,7 @@
         <div
           v-if="(deviceType.startsWith('location') || deviceType.startsWith('vehicle')) && activeKey == 'monitor'"
           class="location-form"
-          style="position: absolute; z-index: 9999; top: 50px"
+          style="position: absolute; top: 50px; z-index: 9999"
         >
           <div class="location-form-item">
             <span class="location-form-label">{{ deviceType.startsWith('location') ? '人员名称:' : '车辆名称' }}</span>
@@ -173,13 +173,13 @@
               deviceType.startsWith('gasDay_normal')
                 ? '当日巡检监测'
                 : deviceType.startsWith('dustDayReport') ||
-                  deviceType.startsWith('bundleDayReport') ||
-                  deviceType.startsWith('bundleSpyDayReport') ||
-                  deviceType.startsWith('bundleReport') ||
-                  deviceType.startsWith('bundleSpyReport') ||
-                  deviceType.startsWith('dustReport')
-                ? '最新监测报表'
-                : '实时监测'
+                    deviceType.startsWith('bundleDayReport') ||
+                    deviceType.startsWith('bundleSpyDayReport') ||
+                    deviceType.startsWith('bundleReport') ||
+                    deviceType.startsWith('bundleSpyReport') ||
+                    deviceType.startsWith('dustReport')
+                  ? '最新监测报表'
+                  : '实时监测'
             "
           >
             <template v-if="(deviceType.startsWith('fanlocal') || deviceType.startsWith('fanmain')) && activeKey == 'monitor'">
@@ -633,7 +633,7 @@
     <mainPath
       v-if="deviceType == 'majorpath'"
       :dataSource="majorPathEchartsData"
-      style="width: 300px; height: 300px; position: absolute; left: 250px; top: 40px"
+      style="position: absolute; top: 40px; left: 250px; width: 300px; height: 300px"
     />
     <component v-if="modalVisible" :is="currentModal" v-model:visible="modalVisible" :dataSource="dataSource" :activeID="activeID" />
   </div>
@@ -684,8 +684,6 @@
   import { formConfig } from '../../../safetyMonitor/safety.data';
   import { getDictItemsByCode } from '/@/utils/dict';
   import BarAndLine from '/@/components/chart/BarAndLine.vue';
-  import MTreeSelect from '/@/components/Form/src/jeecg/components/MTreeSelect.vue';
-  // import ApiSelect from '/@/components/Form/src/components/ApiSelect.vue';
   import { nextTick } from 'vue';
   import { useMethods } from '/@/hooks/system/useMethods';
   import { useGo } from '/@/hooks/web/usePage';
@@ -1136,12 +1134,15 @@
       } else if (deviceType.value.startsWith('vehicle')) {
         actions.setGlobalState({ locationId: record.deviceID, locationObj: record, pageObj: null, type: 'car' });
       } else {
-        actions.setGlobalState({ locationId: record.deviceID, locationObj: record, pageObj: null });
+        actions.setGlobalState({ locationId: record.deviceID, locationObj: record, pageObj: null, type: deviceType.value });
       }
     }
   }
   function getSearch(param) {
-    (gasSearch.address = param.address), (gasSearch.userName = param.userName), (gasSearch.insType = param.insType), (gasSearch.class = param.class);
+    ((gasSearch.address = param.address),
+      (gasSearch.userName = param.userName),
+      (gasSearch.insType = param.insType),
+      (gasSearch.class = param.class));
   }
   // 详情跳转
   function goDetail(record?) {
@@ -1472,8 +1473,8 @@
 </script>
 
 <style lang="less" scoped>
-  @import '/@/design/theme.less';
-  @import '/@/design/vent/modal.less';
+  @import url('/@/design/theme.less');
+  @import url('/@/design/vent/modal.less');
   @ventSpace: zxm;
 
   @{theme-deepblue} {
@@ -1518,21 +1519,21 @@
     --location-bottom-bg: #00709955;
     --location-bottom-border: #aef3ff4d;
   }
+
   .scene-box {
     pointer-events: none;
   }
 
   .top-header {
     position: fixed;
+    z-index: 1;
     width: 100%;
     height: 56px;
     background: var(--image-modal-top);
+    color: #fffd;
     text-align: center;
     line-height: 56px;
-    font-size: 28px;
-    color: #ffffffdd;
     font-weight: 600;
-    z-index: 1;
     letter-spacing: 2px;
     font-size: 30px;
   }
@@ -1541,9 +1542,9 @@
     position: fixed;
     top: 100px;
     left: 10px;
-    color: var(--vent-font-color);
     display: flex;
     justify-content: center;
+    color: var(--vent-font-color);
     font-size: 22px;
 
     .title {
@@ -1552,11 +1553,11 @@
   }
 
   .expansion-icon {
-    background: var(--image-tree-icon-bg) no-repeat;
-    background-size: contain;
     position: absolute;
-    left: 190px;
     top: 25px;
+    left: 190px;
+    background: var(--image-tree-icon-bg) no-repeat;
+    background-size: contain;
 
     &:hover {
       background: var(--image-tree-icon-hover-bg) no-repeat;
@@ -1565,15 +1566,15 @@
   }
 
   .device-select {
-    width: 250px;
-    height: 500px;
-    background: var(--image-tree-bg) no-repeat;
     position: fixed;
     top: 100px;
     left: 10px;
+    width: 250px;
+    height: 500px;
+    padding: 20px 10px 30px;
+    background: var(--image-tree-bg) no-repeat;
     background-size: contain;
     pointer-events: auto;
-    padding: 20px 10px 30px 10px;
   }
 
   .inspect-info-xj {
@@ -1589,9 +1590,9 @@
   }
 
   .is-expansion-icon {
+    z-index: 999;
     padding: 5px;
     pointer-events: auto;
-    z-index: 999;
   }
 
   .device-select-show {
@@ -1611,18 +1612,18 @@
   }
 
   .node-select-show {
+    left: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: flex-start;
     width: 276px;
     height: 44px;
     background: var(--image-tree-expansion-bg) no-repeat;
-    left: 10px;
+    transition: all 1s cubic-bezier(0.165, 0.84, 0.44, 1) 0.5s;
     animation-name: treeShow;
     /* 持续时间 */
     animation-duration: 1s;
-    transition: all 1s cubic-bezier(0.165, 0.84, 0.44, 1) 0.5s;
-    display: flex;
-    align-items: center;
     margin-left: 0;
-    justify-content: flex-start;
     pointer-events: auto;
 
     &:hover {
@@ -1631,8 +1632,8 @@
 
     .put-away-icon {
       position: relative;
-      display: inline-block;
       left: 4px;
+      display: inline-block;
     }
   }
 
@@ -1667,36 +1668,36 @@
       }
 
       input {
-        height: 0px !important;
+        height: 0 !important;
       }
     }
 
     &::-webkit-scrollbar-track {
-      -webkit-box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
-      border-radius: 10px;
-      background: #ededed22;
       height: 100px;
+      background: #ededed22;
+      box-shadow: inset 0 0 5px rgb(0 0 0 / 20%);
+      border-radius: 10px;
     }
 
     &::-webkit-scrollbar-thumb {
-      -webkit-box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
+      box-shadow: inset 0 0 5px rgb(0 0 0 / 20%);
       background: #4288a444;
     }
   }
 
   .location-icon {
-    width: 46px;
-    height: 178px;
     position: absolute;
     top: 100px;
+    width: 46px;
+    height: 178px;
     background: var(--image-location-bg) no-repeat;
+    color: var(--vent-font-color);
+    cursor: pointer;
     background-size: contain;
     writing-mode: vertical-lr;
     line-height: 46px;
-    color: var(--vent-font-color);
     padding-top: 10px;
     pointer-events: auto;
-    cursor: pointer;
 
     &:hover {
       background: var(--image-location-hover-bg) no-repeat;
@@ -1715,36 +1716,36 @@
     pointer-events: auto;
 
     .location-select-box {
+      position: relative;
       width: 100%;
       height: 100%;
-      position: relative;
 
       &::before {
-        content: '';
         position: absolute;
-        width: 230px;
-        height: 500px;
         top: 0;
         left: 0;
+        z-index: -1;
+        width: 230px;
+        height: 500px;
         background: var(--image-tree-bg) no-repeat;
-        background-size: contain;
         transform: rotateY(180deg);
-        z-index: -1;
+        content: '';
+        background-size: contain;
       }
 
       .location-top-title {
-        color: var(--vent-font-color);
         position: absolute;
-        width: 225px;
-        height: 68px;
-        background: var(--image-turn-location-top-bg) no-repeat;
-        background-size: contain;
         top: 5px;
         left: 5px;
         display: flex;
         flex-direction: row;
-        justify-content: space-between;
         align-items: flex-end;
+        justify-content: space-between;
+        width: 225px;
+        height: 68px;
+        background: var(--image-turn-location-top-bg) no-repeat;
+        color: var(--vent-font-color);
+        background-size: contain;
 
         .title {
           font-size: 18px;
@@ -1755,12 +1756,12 @@
       }
 
       .location-expansion-icon {
-        background: var(--image-tree-icon-cover-bg) no-repeat;
-        background-size: contain;
         position: relative;
-        left: 10px;
         top: -15px;
+        left: 10px;
         padding: 5px;
+        background: var(--image-tree-icon-cover-bg) no-repeat;
+        background-size: contain;
 
         &:hover {
           background: var(--image-tree-icon-cover-hover-bg) no-repeat;
@@ -1770,47 +1771,47 @@
     }
 
     .location-container {
-      width: 200px;
-      height: 390px;
       position: absolute;
-      display: flex;
-      flex-direction: column;
       top: 80px;
       left: 18px;
+      display: flex;
+      flex-direction: column;
+      width: 200px;
+      height: 390px;
       overflow-y: auto;
 
       .location-item {
-        color: var(--vent-font-color);
-        line-height: 30px;
         display: flex;
         justify-content: space-between;
+        margin: 3px 0;
+        color: var(--vent-font-color);
+        line-height: 30px;
         // background-image: var(--vent-gas-list-item-bg-img);
         background-image: linear-gradient(to left, #39f5ff05, #39f5ff10);
-        margin: 3px 0;
 
         .item-title {
           width: 80px;
-          text-align: right;
           color: var(--vent-table-action-link);
+          text-align: right;
         }
       }
 
       .location-bottom-btn {
-        width: 100%;
-        color: var(--vent-font-color);
         display: flex;
         justify-content: flex-end;
+        width: 100%;
+        color: var(--vent-font-color);
         margin-top: 20px;
 
         span {
           display: inline-block;
           width: 100%;
-          background: var(--location-bottom-bg);
-          border-radius: 3px;
+          padding: 2px 0;
           border: 1px solid var(--location-bottom-border);
+          background: var(--location-bottom-bg);
           text-align: center;
-          padding: 2px 0;
           cursor: pointer;
+          border-radius: 3px;
 
           &:hover {
             background: #00557422;
@@ -1837,7 +1838,7 @@
   }
 
   .location-btn-show {
-    right: -0px;
+    right: -0;
     animation-name: locationBtnShow;
     /* 持续时间 */
     animation-duration: 1s;
@@ -1860,7 +1861,7 @@
     position: relative;
     background-color: var(--vent-configurable-module-border-bd);
     backdrop-filter: blur(18px);
-    border: 1px solid #ffffff55;
+    border: 1px solid #fff5;
     border-radius: 8px;
     // .tabs-box {
     //   width: calc(100% - 12px) !important;
@@ -1881,70 +1882,77 @@
         border-radius: 15%;
         padding: 8px;
         backdrop-filter: blur(10px);
-        background-color: rgba(30, 58, 117, 0.418);
+        background-color: rgb(30 58 117 / 41.8%);
         margin-right: 10px;
         cursor: pointer;
+
         &::after {
-          width: 34px;
-          height: 34px;
-          content: '';
           position: absolute;
           top: 8px;
           left: 8px;
+          width: 34px;
+          height: 34px;
           background: var(--image-tohome) no-repeat center;
+          content: '';
           background-repeat: no-repeat;
           background-size: 100% 100%;
         }
+
         &:hover {
-          background-color: rgba(79, 104, 134, 0.418);
+          background-color: rgb(79 104 134 / 41.8%);
         }
       }
+
       .to-2D {
         width: 50px;
         height: 50px;
         border-radius: 15%;
         padding: 8px;
         backdrop-filter: blur(10px);
-        background-color: rgba(30, 58, 117, 0.418);
-
+        background-color: rgb(30 58 117 / 41.8%);
         cursor: pointer;
+
         &::after {
-          width: 34px;
-          height: 34px;
-          content: '';
           position: absolute;
           top: 8px;
           left: 8px;
+          width: 34px;
+          height: 34px;
           background: var(--image-to2D) no-repeat center;
+          content: '';
           background-repeat: no-repeat;
           background-size: 100% 100%;
         }
+
         &:hover {
-          background-color: rgba(79, 104, 134, 0.418);
+          background-color: rgb(79 104 134 / 41.8%);
         }
       }
+
       .to-3D {
         width: 50px;
         height: 50px;
         border-radius: 15%;
         padding: 8px;
         backdrop-filter: blur(10px);
-        background-color: rgba(30, 58, 117, 0.418);
+        background-color: rgb(30 58 117 / 41.8%);
         margin-right: 10px;
         cursor: pointer;
+
         &::after {
-          width: 34px;
-          height: 34px;
-          content: '';
           position: absolute;
           top: 8px;
           left: 8px;
+          width: 34px;
+          height: 34px;
           background: var(--image-to3D) no-repeat center;
+          content: '';
           background-repeat: no-repeat;
           background-size: 100% 100%;
         }
+
         &:hover {
-          background-color: rgba(79, 104, 134, 0.418);
+          background-color: rgb(79 104 134 / 41.8%);
         }
       }
 
@@ -1974,57 +1982,52 @@
     }
 
     .table-hide-icon {
-      color: var(--vent-font-color);
-      cursor: pointer;
       position: absolute;
-      right: 20px;
       top: 10px;
+      right: 20px;
       z-index: 9999;
+      color: var(--vent-font-color);
+      cursor: pointer;
     }
 
     .enter-detail {
-      color: var(--vent-font-color);
-      cursor: pointer;
       position: absolute;
-      right: 35px;
       top: 35px;
-      padding: 5px;
-      border-radius: 5px;
-      margin-left: 8px;
-      margin-right: 8px;
-      width: auto;
-      height: 33px !important;
+      right: 35px;
+      z-index: 999;
       display: flex;
       align-items: center;
       justify-content: center;
+      width: auto;
+      height: 33px !important;
+      padding: 5px;
+      padding: 5px 15px;
       color: var(--vent-font-color);
-      padding: 5px 15px 5px 15px;
-      z-index: 999;
       cursor: pointer;
+      border-radius: 5px;
+      margin-left: 8px;
+      margin-right: 8px;
 
       &:hover {
         background: var(--vent-device-manager-control-btn-hover);
       }
 
       &::before {
+        position: absolute;
+        inset: 3px 0 0 3px;
+        z-index: -1;
         width: calc(100% - 6px);
         height: 27px;
+        /* important */
+        background: var(--vent-device-manager-control-btn);
         content: '';
-        position: absolute;
-        top: 3px;
-        right: 0;
-        left: 3px;
-        bottom: 0;
-        z-index: -1;
         border-radius: inherit;
-        /*important*/
-        background: var(--vent-device-manager-control-btn);
       }
     }
   }
 
   .table-hide {
-    height: 0px;
+    height: 0;
     animation-name: tableHide;
     /* 持续时间 */
     animation-duration: 1s;
@@ -2047,15 +2050,15 @@
       width: 400px;
 
       .location-form-label {
-        width: 100px;
         display: inline-block;
+        width: 100px;
         color: var(--vent-font-color);
       }
 
       input {
+        border: 1px solid var(--vent-form-item-border) !important;
         background: transparent !important;
         color: var(--vent-font-color);
-        border: 1px solid var(--vent-form-item-border) !important;
       }
     }
 
@@ -2066,7 +2069,7 @@
 
   @keyframes tableShow {
     0% {
-      height: 0px;
+      height: 0;
       opacity: 0;
     }
 
@@ -2082,7 +2085,7 @@
     }
 
     100% {
-      height: 0px;
+      height: 0;
       opacity: 0;
     }
   }
@@ -2113,7 +2116,7 @@
 
   @keyframes locationShow {
     0% {
-      right: 0px;
+      right: 0;
       opacity: 0;
     }
 
@@ -2171,7 +2174,7 @@
 
   :deep(.zxm-select-dropdown) {
     left: 0 !important;
-    color: #000000 !important;
+    color: #000 !important;
   }
 
   :deep(.zxm-select-selector) {
@@ -2189,15 +2192,15 @@
   }
 
   .device-button {
-    height: 26px;
+    position: relative;
     display: flex;
-    justify-content: center;
     align-items: center;
-    color: var(--vent-font-color);
-    position: relative;
-    cursor: pointer;
+    justify-content: center;
+    height: 26px;
     padding: 0 20px;
     background: linear-gradient(45deg, #04e6fb55, #0c5cab55);
+    color: var(--vent-font-color);
+    cursor: pointer;
     clip-path: polygon(10px 0, 0 50%, 10px 100%, 100% 100%, calc(100% - 10px) 50%, 100% 0);
 
     &:nth-child(1) {

+ 83 - 94
src/views/vent/monitorManager/deviceMonitor/index1.vue

@@ -2,7 +2,7 @@
   <div class="scene-box">
     <DeviceVue ref="DeviceRef" :pageData="pageData" />
   </div>
-  <VentModal style="width: 100%; height: 100%; position: absolute" />
+  <VentModal style="position: absolute; width: 100%; height: 100%" />
 </template>
 
 <script setup lang="ts">
@@ -27,12 +27,6 @@
 
   // actions.setGlobalState({ url: { path: '/micro-vent-3dModal/dashboard/analysis', query: { type, deviceType } } });
 
-  const changePageType = (pageType) => {
-    console.log('页面类型', pageType);
-    routerParam.value = pageType;
-    actions.setGlobalState({ pageObj: { pageType: pageType } });
-  };
-
   watch(
     () => route.fullPath,
     (fullPath) => {
@@ -79,29 +73,29 @@
 </script>
 
 <style lang="less" scoped>
-  @import '/@/design/vent/modal.less';
+  @import url('/@/design/vent/modal.less');
   @ventSpace: zxm;
 
   .device-header {
     position: fixed;
+    z-index: -1;
     width: 100%;
     height: 56px;
     background: url('/@/assets/images/vent/home/modal-top.png');
+    color: #fffd;
     text-align: center;
     line-height: 56px;
     font-size: 28px;
-    color: #ffffffdd;
     font-weight: 600;
-    z-index: -1;
   }
 
   .select-node {
     position: fixed;
     top: 60px;
     left: 10px;
-    color: #fff;
     display: flex;
     justify-content: center;
+    color: #fff;
     font-size: 22px;
 
     .title {
@@ -110,11 +104,11 @@
   }
 
   .expansion-icon {
-    background: url('/@/assets/images/vent/home/tree-icon-bg.png') no-repeat;
-    background-size: contain;
     position: absolute;
-    left: 190px;
     top: 25px;
+    left: 190px;
+    background: url('/@/assets/images/vent/home/tree-icon-bg.png') no-repeat;
+    background-size: contain;
 
     &:hover {
       background: url('/@/assets/images/vent/home/tree-icon-hover-bg.png') no-repeat;
@@ -123,21 +117,21 @@
   }
 
   .device-select {
-    width: 250px;
-    height: 500px;
-    background: url('/@/assets/images/vent/home/tree-bg.png') no-repeat;
     position: fixed;
     top: 60px;
     left: 10px;
+    width: 250px;
+    height: 500px;
+    padding: 20px 10px 30px;
+    background: url('/@/assets/images/vent/home/tree-bg.png') no-repeat;
     background-size: contain;
     pointer-events: auto;
-    padding: 20px 10px 30px 10px;
   }
 
   .is-expansion-icon {
+    z-index: 999;
     padding: 5px;
     pointer-events: auto;
-    z-index: 999;
   }
 
   .device-select-show {
@@ -157,18 +151,18 @@
   }
 
   .node-select-show {
+    left: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: flex-start;
     width: 276px;
     height: 44px;
     background: url('/@/assets/images/vent/home/tree-expansion-bg.png') no-repeat;
-    left: 10px;
+    transition: all 1s cubic-bezier(0.165, 0.84, 0.44, 1) 0.5s;
     animation-name: treeShow;
     /* 持续时间 */
     animation-duration: 1s;
-    transition: all 1s cubic-bezier(0.165, 0.84, 0.44, 1) 0.5s;
-    display: flex;
-    align-items: center;
     margin-left: 0;
-    justify-content: flex-start;
     pointer-events: auto;
 
     &:hover {
@@ -177,8 +171,8 @@
 
     .put-away-icon {
       position: relative;
-      display: inline-block;
       left: 4px;
+      display: inline-block;
     }
   }
 
@@ -213,37 +207,37 @@
       }
 
       input {
-        height: 0px !important;
+        height: 0 !important;
       }
     }
 
     &::-webkit-scrollbar-track {
-      -webkit-box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
-      border-radius: 10px;
-      background: #ededed22;
       height: 100px;
+      background: #ededed22;
+      box-shadow: inset 0 0 5px rgb(0 0 0 / 20%);
+      border-radius: 10px;
     }
 
     &::-webkit-scrollbar-thumb {
-      -webkit-box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
+      box-shadow: inset 0 0 5px rgb(0 0 0 / 20%);
       background: #4288a444;
     }
   }
 
   .location-icon {
-    width: 46px;
-    height: 178px;
     position: absolute;
     top: 60px;
+    width: 46px;
+    height: 178px;
     // right: 0;
     background: url('/@/assets/images/vent/home/location-bg.png') no-repeat;
+    color: #fff;
+    cursor: pointer;
     background-size: contain;
     writing-mode: vertical-lr;
     line-height: 46px;
-    color: #fff;
     padding-top: 10px;
     pointer-events: auto;
-    cursor: pointer;
 
     &:hover {
       background: url('/@/assets/images/vent/home/location-hover-bg.png') no-repeat;
@@ -263,21 +257,21 @@
     pointer-events: auto;
 
     .location-select-box {
+      position: relative;
       width: 100%;
       height: 100%;
-      position: relative;
 
       &::before {
-        content: '';
         position: absolute;
-        width: 230px;
-        height: 500px;
         top: 0;
         left: 0;
+        z-index: -1;
+        width: 230px;
+        height: 500px;
         background: url('/@/assets/images/vent/home/tree-bg.png') no-repeat;
-        background-size: contain;
         transform: rotateY(180deg);
-        z-index: -1;
+        content: '';
+        background-size: contain;
         // &:hover {
         //   background: url('/@/assets/images/vent/home/tree-icon-hover-bg.png') no-repeat;
         //   background-size: contain;
@@ -285,18 +279,18 @@
       }
 
       .location-top-title {
-        color: #fff;
         position: absolute;
-        width: 225px;
-        height: 68px;
-        background: url('/@/assets/images/vent/home/turn-location-top-bg.png') no-repeat;
-        background-size: contain;
         top: 5px;
         left: 5px;
         display: flex;
         flex-direction: row;
-        justify-content: space-between;
         align-items: flex-end;
+        justify-content: space-between;
+        width: 225px;
+        height: 68px;
+        background: url('/@/assets/images/vent/home/turn-location-top-bg.png') no-repeat;
+        color: #fff;
+        background-size: contain;
 
         .title {
           font-size: 18px;
@@ -307,12 +301,12 @@
       }
 
       .location-expansion-icon {
-        background: url('/@/assets/images/vent/home/tree-icon-cover-bg.png') no-repeat;
-        background-size: contain;
         position: relative;
-        left: 10px;
         top: -15px;
+        left: 10px;
         padding: 5px;
+        background: url('/@/assets/images/vent/home/tree-icon-cover-bg.png') no-repeat;
+        background-size: contain;
 
         &:hover {
           background: url('/@/assets/images/vent/home/tree-icon-cover-hover-bg.png') no-repeat;
@@ -322,46 +316,46 @@
     }
 
     .location-container {
-      width: 200px;
-      height: 390px;
       position: absolute;
-      display: flex;
-      flex-direction: column;
       top: 80px;
       left: 18px;
+      display: flex;
+      flex-direction: column;
+      width: 200px;
+      height: 390px;
       overflow-y: auto;
 
       .location-item {
-        color: #fff;
-        line-height: 30px;
         display: flex;
         justify-content: space-between;
-        background-image: linear-gradient(to left, #39f5ff05, #39f5ff10);
         margin: 3px 0;
+        color: #fff;
+        line-height: 30px;
+        background-image: linear-gradient(to left, #39f5ff05, #39f5ff10);
 
         .item-title {
           width: 80px;
-          text-align: right;
           color: #87f1ff;
+          text-align: right;
         }
       }
 
       .location-bottom-btn {
-        width: 100%;
-        color: #fff;
         display: flex;
         justify-content: flex-end;
+        width: 100%;
+        color: #fff;
         margin-top: 20px;
 
         span {
           display: inline-block;
           width: 100%;
+          padding: 2px 0;
+          border: 1px solid rgb(174 243 255 / 30%);
           background: #00709955;
-          border-radius: 3px;
-          border: 1px solid rgba(174, 243, 255, 0.3);
           text-align: center;
-          padding: 2px 0;
           cursor: pointer;
+          border-radius: 3px;
 
           &:hover {
             background: #00557422;
@@ -388,7 +382,7 @@
   }
 
   .location-btn-show {
-    right: -0px;
+    right: -0;
     animation-name: locationBtnShow;
     /* 持续时间 */
     animation-duration: 1s;
@@ -407,20 +401,20 @@
     position: relative;
 
     .to-small {
+      position: absolute;
+      top: -65px;
+      right: 36px;
       width: 60px;
       height: 60px;
+      padding: 8px;
       background: url('/@/assets/images/vent/home/tosmall.png') no-repeat center;
       background-size: auto;
-      position: absolute;
-      top: -65px;
-      right: 36px;
       border-radius: 10px;
-      padding: 8px;
       backdrop-filter: blur(10px);
-      background-color: rgba(45, 86, 137, 0.418);
+      background-color: rgb(45 86 137 / 41.8%);
 
       &:hover {
-        background-color: rgba(79, 104, 134, 0.418);
+        background-color: rgb(79 104 134 / 41.8%);
       }
     }
 
@@ -431,16 +425,16 @@
       width: 100%;
 
       .device-button {
+        position: relative;
+        display: flex;
+        align-items: center;
+        justify-content: center;
         height: 26px;
         padding: 0 20px;
         background: linear-gradient(45deg, #04e6fb55, #0c5cab55);
-        clip-path: polygon(10px 0, 0 50%, 10px 100%, 100% 100%, calc(100% - 10px) 50%, 100% 0);
-        display: flex;
-        justify-content: center;
-        align-items: center;
         color: #fff;
-        position: relative;
         cursor: pointer;
+        clip-path: polygon(10px 0, 0 50%, 10px 100%, 100% 100%, calc(100% - 10px) 50%, 100% 0);
 
         &:nth-child(1) {
           left: calc(-6px * 1);
@@ -518,41 +512,36 @@
     }
 
     .enter-detail {
-      color: #fff;
-      cursor: pointer;
       position: absolute;
-      right: 120px;
       top: -6px;
-      padding: 5px;
-      border-radius: 5px;
-      margin-left: 8px;
-      margin-right: 8px;
-      width: auto;
-      height: 33px !important;
+      right: 120px;
       display: flex;
       align-items: center;
       justify-content: center;
+      width: auto;
+      height: 33px !important;
+      padding: 5px;
+      padding: 5px 15px;
       color: #fff;
-      padding: 5px 15px 5px 15px;
       cursor: pointer;
+      border-radius: 5px;
+      margin-left: 8px;
+      margin-right: 8px;
 
       &:hover {
         background: linear-gradient(#2cd1ff55, #1eb0ff55);
       }
 
       &::before {
+        position: absolute;
+        inset: 3px 0 0 3px;
+        z-index: -1;
         width: calc(100% - 6px);
         height: 27px;
+        /* important */
+        background: linear-gradient(#1fa6cb, #127cb5);
         content: '';
-        position: absolute;
-        top: 3px;
-        right: 0;
-        left: 3px;
-        bottom: 0;
-        z-index: -1;
         border-radius: inherit;
-        /*important*/
-        background: linear-gradient(#1fa6cb, #127cb5);
       }
     }
   }
@@ -583,7 +572,7 @@
 
   @keyframes locationShow {
     0% {
-      right: 0px;
+      right: 0;
       opacity: 0;
     }
 
@@ -636,6 +625,6 @@
 
   :deep(.zxm-select-dropdown) {
     left: 0 !important;
-    color: #000000 !important;
+    color: #000 !important;
   }
 </style>

+ 3 - 3
src/views/vent/monitorManager/fireDoorMonitor/fireDoor.threejs.ymh.ts

@@ -1,9 +1,9 @@
 import * as THREE from 'three';
 import { useAppStore } from '/@/store/modules/app';
 
-import * as dat from 'dat.gui';
-const gui = new dat.GUI();
-gui.domElement.style = 'position:absolute;top:100px;left:10px;z-index:99999999999999';
+// import * as dat from 'dat.gui';
+// const gui = new dat.GUI();
+// gui.domElement.style = 'position:absolute;top:100px;left:10px;z-index:99999999999999';
 
 // 羊马河
 class FireDoorYmh {

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

@@ -428,7 +428,7 @@ export function getModelComponent(is2DModel: boolean = false, type?: string) {
         return getComponent('gateDualSVG');
       case 'fmSp1':
         return getComponent('gateSVG');
-      case 'ddFmLlbd':
+      case 'fm_fc_bd':
         return getComponent('gateLiangLieSVG');
       case 'fmSsl':
         return getComponent('gateDualSVG');

Dosya farkı çok büyük olduğundan ihmal edildi
+ 801 - 703
src/views/vent/monitorManager/mainFanMonitor/index.vue


+ 5 - 0
src/views/vent/monitorManager/mainFanMonitor/main.data.ts

@@ -398,6 +398,11 @@ export const modalTypeArr = {
       value: '不停风倒机',
       permission: 'fan:changeSmoke1',
     },
+    {
+      key: 'changeSmoke2',
+      value: '不停风倒机',
+      permission: 'fan:changeSmoke2',
+    },
     {
       key: 'changeDirection',
       value: '一键反风',

Dosya farkı çok büyük olduğundan ihmal edildi
+ 613 - 0
src/views/vent/monitorManager/windowMonitor/components/windowDaLiuTaSVG.vue


Dosya farkı çok büyük olduğundan ihmal edildi
+ 572 - 0
src/views/vent/monitorManager/windowMonitor/components/windowDaiXiaoMenSVG.vue


Dosya farkı çok büyük olduğundan ihmal edildi
+ 559 - 0
src/views/vent/monitorManager/windowMonitor/components/windowTripleSVG.vue


+ 8 - 10
src/views/vent/monitorManager/windowMonitor/window.data.ts

@@ -328,19 +328,18 @@ export function getModelComponent(is2DModel: boolean = false, type: string = '')
     // if (!componentsCaches.has('windowDualZDSVG')) componentsCaches.set('windowDualZDSVG', import('./components/windowDualZDSVG.vue')); //小窗风窗
     // if (!componentsCaches.has('windowLiangLieBDSVG')) componentsCaches.set('windowLiangLieBDSVG', import('./components/windowLiangLieBDSVG.vue')); //风窗两列扇叶
     // if (!componentsCaches.has('windowYiLieSVG')) componentsCaches.set('windowYiLieSVG', import('./components/windowYiLieSVG.vue')); //风窗大窗一列扇叶
+    // if (!componentsCaches.has('windowDaiXiaoMenSVG')) componentsCaches.set('windowDaiXiaoMenSVG', import('./components/windowDaiXiaoMenSVG.vue')); //风窗双道带小门
+    // if (!componentsCaches.has('windowDaLiuTaSVG')) componentsCaches.set('windowDaLiuTaSVG', import('./components/windowDaLiuTaSVG.vue')); //风窗单道中窗一列扇叶(大柳塔)
+    // if (!componentsCaches.has('windowTripleSVG')) componentsCaches.set('windowTripleSVG', import('./components/windowTripleSVG.vue')); //风门三道带窗
     switch (type) {
-      case 'ddFcLlsy':
-        return getComponent('windowLiangLieBDSVG');
-      case 'ddFcYlsy':
-        return getComponent('windowYiLieSVG');
       case 'sdFc1':
-        return getComponent('windowDualSVG');
+        return getComponent('windowDaiXiaoMenSVG');
       case 'ddFc5':
         return getComponent('windowSVG');
       case 'ddFc6':
-        return getComponent('windowSVG');
+        return getComponent('windowYiLieSVG');
       case 'ddFc7':
-        return getComponent('windowSVG');
+        return getComponent('windowLiangLieBDSVG');
       case 'ddFc8':
         return getComponent('windowSVG');
       case 'ddFc1':
@@ -348,7 +347,7 @@ export function getModelComponent(is2DModel: boolean = false, type: string = '')
       case 'ddFc2':
         return getComponent('windowSVG');
       case 'ddFc4':
-        return getComponent('windowSVG');
+        return getComponent('windowDaLiuTaSVG');
       case 'sdFc3':
         return getComponent('windowDualSVG');
       case 'sdFc4':
@@ -358,8 +357,7 @@ export function getModelComponent(is2DModel: boolean = false, type: string = '')
       case 'sdFc5':
         return getComponent('windowDualZDSVG');
       case 'threeFc8':
-        // 暂不支持,用单道的先
-        return getComponent('windowSVG');
+        return getComponent('windowTripleSVG');
       case 'singleXkWindow':
         return getComponent('windowSVG');
       case 'sdFcZhq':

+ 45 - 4
src/views/ventAI/manageAssistent/api.ts

@@ -11,6 +11,9 @@ enum Api {
   switchThinking = '/ventAI/api/model/thinking',
   getSkills = '/ventAI/api/skills',
   schedules = '/ventAI/api/schedules',
+  downloadReport = '/ventAI/api/download/report/',
+  saveReport = '/ventAI/api/report/save/',
+  editWord = '/ventAI/api/report/edit-word',
 }
 
 /**
@@ -378,8 +381,7 @@ export const createSchedule = (data: {
 export const getScheduleList = (include_disabled = true) =>
   defHttp.get({ url: Api.schedules, params: { include_disabled } }, { joinParamsToUrl: true, isTransformResponse: false });
 
-export const getScheduleDetail = (task_id: number) =>
-  defHttp.get({ url: `${Api.schedules}/${task_id}` }, { isTransformResponse: false });
+export const getScheduleDetail = (task_id: number) => defHttp.get({ url: `${Api.schedules}/${task_id}` }, { isTransformResponse: false });
 
 export const updateSchedule = (
   task_id: number,
@@ -395,8 +397,7 @@ export const deleteSchedule = (id: number) => defHttp.delete({ url: `${Api.sched
 export const getScheduleRuns = (task_id: number, limit = 50) =>
   defHttp.get({ url: `${Api.schedules}/${task_id}/runs`, params: { limit } }, { joinParamsToUrl: true, isTransformResponse: false });
 
-export const runScheduleNow = (task_id: number) =>
-  defHttp.post({ url: `${Api.schedules}/${task_id}/run` }, { isTransformResponse: false });
+export const runScheduleNow = (task_id: number) => defHttp.post({ url: `${Api.schedules}/${task_id}/run` }, { isTransformResponse: false });
 
 /**
  * 获取子智能体列表
@@ -442,3 +443,43 @@ export const deleteSubAgent = (name: string) => defHttp.delete({ url: `/ventAI/a
  * 启用/禁用子智能体
  */
 export const toggleSubAgent = (name: string) => defHttp.post({ url: `/ventAI/api/subagents/${name}/toggle` }, { isTransformResponse: false });
+
+/**
+ * 报告下载
+ * 从 URL 或文件名中提取纯文件名,构建 /api/download/report/{filename} 地址
+ */
+export const buildReportDownloadUrl = (urlOrFilename: string) => {
+  const filename = urlOrFilename.split('/').pop() || urlOrFilename;
+  return `${Api.downloadReport}${encodeURIComponent(filename)}`;
+};
+
+/**
+ * 覆盖保存报告(文本)
+ * @param filename - 文件名,如 report_20260810_150000.md
+ * @param content - 文本内容
+ */
+export const saveReportText = (filename: string, content: string) =>
+  defHttp.put({ url: `${Api.saveReport}${encodeURIComponent(filename)}`, data: { content } }, { isTransformResponse: false });
+
+/**
+ * 覆盖保存报告(二进制,如 xlsx)
+ * @param filename - 文件名
+ * @param data - ArrayBuffer / Blob / Uint8Array
+ */
+export const saveReportBinary = (filename: string, data: ArrayBuffer | Blob | Uint8Array) =>
+  defHttp.put(
+    {
+      url: `${Api.saveReport}${encodeURIComponent(filename)}`,
+      data,
+      headers: { 'Content-Type': 'application/octet-stream' },
+    },
+    { isTransformResponse: false }
+  );
+
+/**
+ * 对话式修改 Word 报告
+ * @param filename - docx 文件名
+ * @param instruction - 修改要求(自然语言)
+ */
+export const editWordReport = (filename: string, instruction: string) =>
+  defHttp.post({ url: Api.editWord, data: { filename, instruction } }, { isTransformResponse: false });

+ 70 - 55
src/views/ventAI/manageAssistent/components/AiAssistantModal.vue

@@ -74,18 +74,11 @@
             :messages="messages"
             @file-preview="openFilePreview"
             @download-word="downloadWordFile"
+            @open-file-preview="handleOpenFilePreview"
             @ask-user-submit="handleAskUserSubmit"
             @ask-user-cancel="handleAskUserCancel"
           />
 
-          <!-- 下载确认条 -->
-          <DownloadConfirmBar
-            v-if="downloadConfirm && downloadConfirmTaskId === currentTaskId"
-            :filename="downloadConfirm.filename"
-            @confirm="doDownload"
-            @cancel="clearDownloadConfirm"
-          />
-
           <!-- 任务进度条 -->
           <TodoListBar v-if="showTodoBar" :todos="currentTodos" />
 
@@ -119,7 +112,13 @@
 
         <!-- 文件预览面板(与 chat-panel 并列) -->
         <div v-if="previewFile" class="right-panel">
-          <FilePreviewPanel :file="previewFile" @close="removePreviewFile" />
+          <FilePreviewPanel
+            :file="previewFile"
+            :editable="previewEditable"
+            :save-filename="previewFile?.name || ''"
+            @close="removePreviewFile"
+            @save-success="handleSaveSuccess"
+          />
         </div>
 
         <!-- 定时任务详情面板(与 chat-panel 并列) -->
@@ -182,7 +181,6 @@
   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 SkillPanel from './chatModal/SkillPanel.vue';
   import SubAgentPanel from './chatModal/SubAgentPanel.vue';
   import SchedulePanel from './chatModal/SchedulePanel.vue';
@@ -268,6 +266,7 @@
   const chatMessagesRef = ref<InstanceType<typeof ChatMessages>>();
 
   const previewFile = ref<AttachedFile | null>(null);
+  const previewEditable = ref(false);
   const showFileList = ref(false);
   const activePanel = ref<'chat' | 'skill' | 'sub-agents' | 'schedules'>('chat');
   const scheduleListForDetail = ref<any[]>([]);
@@ -297,25 +296,6 @@
   const contextPercent = ref(0);
   const thinkLevel = ref('off');
   const editMode = ref('full');
-  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 }>>([]);
@@ -763,6 +743,7 @@
         console.error('提交答案失败:', error);
         message.error('提交失败,请重试');
         lastMsg.isLoading = false;
+        lastMsg.durationMs = (lastMsg.baseDurationMs || 0) + (lastMsg.generateStartTime ? Date.now() - lastMsg.generateStartTime : 0);
         if (!lastMsg.content) {
           lastMsg.content = '抱歉,提交请求失败,请稍后重试。';
         }
@@ -792,6 +773,8 @@
     const lastMsg = taskMessages[taskMessages.length - 1];
     if (!lastMsg || !lastMsg.isPendingApproval) return;
 
+    // 冻结累计耗时到 durationMs,避免取消后计时消失
+    lastMsg.durationMs = (lastMsg.baseDurationMs || 0) + (lastMsg.generateStartTime ? Date.now() - lastMsg.generateStartTime : 0);
     lastMsg.isPendingApproval = false;
     lastMsg.pendingApprovalData = undefined;
     lastMsg.isLoading = false;
@@ -862,7 +845,17 @@
     return str.replace(/\\n/g, '\n').replace(/\r\n/g, '\n');
   };
 
-  const transformHistoryToMessages = (data: Array<{ role?: string; type?: string; content: string; created_at?: string }>): Message[] => {
+  const transformHistoryToMessages = (
+    data: Array<{
+      role?: string;
+      type?: string;
+      content: string;
+      created_at?: string;
+      report_url?: string;
+      report_filename?: string;
+      citations?: any;
+    }>
+  ): Message[] => {
     const messages: Message[] = [];
 
     for (const item of data) {
@@ -874,7 +867,20 @@
       if (item.role === 'user') {
         messages.push({ type: 'user', content: normalizedContent, time, createdAt });
       } else if (item.role === 'assistant') {
-        messages.push({ type: 'ai', content: normalizedContent, time, createdAt });
+        const msg: Message = { type: 'ai', content: normalizedContent, time, createdAt };
+        if (item.report_url && item.report_filename) {
+          msg.downloadFiles = [{ url: item.report_url, filename: item.report_filename }];
+        }
+        if (item.citations) {
+          // 历史接口返回 citations 为扁平数组,SSE 事件返回 { items, model_only },统一归一化
+          const raw = item.citations;
+          if (Array.isArray(raw)) {
+            msg.citations = { items: raw, model_only: raw.length === 0 };
+          } else {
+            msg.citations = raw;
+          }
+        }
+        messages.push(msg);
       }
       // 兼容旧格式:type 字段
       else if (item.type === 'user_message') {
@@ -952,6 +958,7 @@
 
   const removePreviewFile = () => {
     previewFile.value = null;
+    previewEditable.value = false;
     // 关闭文件预览后,恢复定时任务详情面板
     const sessionId = currentTask.value?.sessionId;
     if (sessionId && scheduleListForDetail.value.some((s: any) => s.feedback_session_id === sessionId)) {
@@ -961,6 +968,7 @@
 
   const openFilePreview = (file: AttachedFile) => {
     previewFile.value = file;
+    previewEditable.value = false;
     showScheduleDetail.value = false;
 
     const msgIndex = messages.value.findIndex((m) => m.attachedFile?.id === file.id);
@@ -1016,17 +1024,6 @@
     document.body.removeChild(a);
   };
 
-  const doDownload = () => {
-    if (!downloadConfirm.value) return;
-    const a = document.createElement('a');
-    a.href = downloadConfirm.value.url;
-    a.download = downloadConfirm.value.filename;
-    document.body.appendChild(a);
-    a.click();
-    document.body.removeChild(a);
-    clearDownloadConfirm();
-  };
-
   const downloadWordFile = (url: string) => {
     if (!url) return;
     const a = document.createElement('a');
@@ -1038,6 +1035,18 @@
     document.body.removeChild(a);
   };
 
+  const handleOpenFilePreview = (file: AttachedFile) => {
+    previewFile.value = file;
+    previewEditable.value = true;
+    showScheduleDetail.value = false;
+  };
+
+  const handleSaveSuccess = (content: string) => {
+    if (previewFile.value) {
+      previewFile.value = { ...previewFile.value, content };
+    }
+  };
+
   const handleSendMessage = async () => {
     const hasText = inputMessage.value.trim();
     const hasFile = pendingFile.value;
@@ -1104,7 +1113,7 @@
         currentTask.messages = [...messages.value];
       }
     } finally {
-      await scrollToBottom();
+      await scrollToBottom(true);
     }
   };
 
@@ -1279,9 +1288,8 @@
           timestamp: Date.now(),
         });
         if (data.download_url) {
-          downloadConfirm.value = { url: data.download_url, filename: data.filename || '文件' };
-          downloadConfirmTaskId.value = taskId;
-          startDownloadConfirmTimer();
+          if (!aiMsg.downloadFiles) aiMsg.downloadFiles = [];
+          aiMsg.downloadFiles.push({ url: data.download_url, filename: data.filename || '文件' });
         }
         break;
       }
@@ -1337,6 +1345,15 @@
         aiMsg.durationMs = (aiMsg.baseDurationMs || 0) + (aiMsg.generateStartTime ? Date.now() - aiMsg.generateStartTime : 0);
         break;
 
+      case 'citations': {
+        aiMsg.citations = {
+          items: data.items || [],
+          model_only: data.model_only || false,
+          message: data.message,
+        };
+        break;
+      }
+
       case 'word_download':
         if (normalizedContent) {
           aiMsg.wordDownloadUrl = normalizedContent;
@@ -1436,7 +1453,8 @@
       createdAt: now.toISOString(),
     };
     messages.value.push(userMsg);
-    await scrollToBottom();
+    await nextTick();
+    chatMessagesRef.value?.forceScrollToBottom();
 
     const aiMsgIndex = messages.value.length;
     const aiMsg: Message = {
@@ -1544,7 +1562,8 @@
       attachedFile: file,
     };
     messages.value.push(userMsg);
-    await scrollToBottom();
+    await nextTick();
+    chatMessagesRef.value?.forceScrollToBottom();
 
     const aiMsgIndex = messages.value.length;
     const aiMsg: Message = {
@@ -1639,12 +1658,12 @@
     }
   };
 
-  const scrollToBottom = async () => {
+  const scrollToBottom = async (force = false) => {
     await nextTick();
-    if (!chatMessagesRef.value?.isAtBottom) return;
+    if (!force && !chatMessagesRef.value?.isAtBottom) return;
     const el = chatMessagesRef.value?.messagesRef;
     if (el) {
-      el.scrollTop = el.scrollHeight;
+      el.scrollTop = el.scrollHeight - el.clientHeight;
     }
   };
 
@@ -1810,10 +1829,6 @@
   onBeforeUnmount(() => {
     document.removeEventListener('mousemove', onDragMove);
     document.removeEventListener('mouseup', onDragEnd);
-    if (downloadConfirmTimer) {
-      clearTimeout(downloadConfirmTimer);
-      downloadConfirmTimer = null;
-    }
     // 组件卸载时主动断开所有任务的流式连接
     taskAbortControllers.value.forEach((controller) => controller.abort());
   });

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

@@ -419,7 +419,7 @@
     { key: 'highest', label: '最高', desc: '极致思考,质量最高但较慢' },
   ];
 
-  const selectedThinkLevel = ref<ThinkLevelOption>(thinkLevelOptions[0]);
+  const selectedThinkLevel = ref<ThinkLevelOption>(thinkLevelOptions[1]);
 
   watch(
     () => props.initialThinkLevel,

+ 457 - 28
src/views/ventAI/manageAssistent/components/chatModal/ChatMessages.vue

@@ -101,11 +101,79 @@
               </div>
             </div>
 
-            <div v-if="message.content" class="message-text" v-html="renderMarkdown(message.content, tableIcons)"></div>
+            <div
+              v-if="message.content"
+              class="message-text"
+              v-html="
+                renderMarkdown(
+                  message.content,
+                  tableIcons,
+                  message.citations?.items?.length ? Math.max(...message.citations.items.map((i) => i.id)) : undefined
+                )
+              "
+              @click="handleFootnoteClick($event, index)"
+            ></div>
+
+            <!-- 数据来源卡片 citations -->
+            <div v-if="message.citations" class="citations-card">
+              <div v-if="message.citations.model_only" class="citations-header citations-header-simple">
+                <span class="citations-title">本回答依据模型知识生成,仅供参考</span>
+              </div>
+              <template v-else-if="message.citations.items?.length">
+                <div class="citations-header" @click="toggleCitations(index)">
+                  <div class="citations-header-left">
+                    <span class="citations-title">数据来源</span>
+                    <span class="citations-count">{{ message.citations.items.length }}</span>
+                  </div>
+                  <div :class="['thinking-arrow', { expanded: !collapsedCitations[index] }]">
+                    <img :src="getCssUrl('--img-chat-arrow-icon')" width="10" height="10" />
+                  </div>
+                </div>
+                <div v-show="!collapsedCitations[index]" class="citations-body">
+                  <div v-for="item in message.citations.items" :key="item.id" :id="`citation-${index}-${item.id}`" class="citation-item">
+                    <div class="citation-item-row">
+                      <span :class="['citation-badge', `citation-badge-${item.type}`]">{{ item.type_cn }}</span>
+                      <span class="citation-title-text">{{ item.title }}</span>
+                      <span v-if="item.search_url" class="citation-link">知识库检索</span>
+                      <span v-if="item.url" class="citation-link">链接</span>
+                    </div>
+                    <div class="citation-item-attrs">
+                      <span v-if="item.source_system" class="citation-attr">{{ item.source_system }}</span>
+                      <span v-if="item.fetched_at" class="citation-attr">{{ item.fetched_at }}</span>
+                    </div>
+                    <div v-if="item.snippet" class="citation-snippet" :title="item.snippet">{{ item.snippet }}</div>
+                  </div>
+                </div>
+              </template>
+            </div>
+
             <div v-if="message.wordDownloadUrl" class="word-download-area">
               <a-button type="link" @click="emit('download-word', message.wordDownloadUrl)"> 下载报告文件 (.docx) </a-button>
             </div>
-            <div v-if="message.isLoading" class="loading-dots">
+
+            <!-- 文件操作卡片 -->
+            <div v-if="message.downloadFiles?.length" class="download-file-cards">
+              <div v-for="(file, fIdx) in message.downloadFiles" :key="fIdx" class="download-file-card">
+                <div class="file-card-info">
+                  <div class="file-card-icon"></div>
+                  <div class="file-card-text">
+                    <span class="file-card-name">{{ file.filename }}</span>
+                    <span class="file-card-hint">点击操作文件</span>
+                  </div>
+                </div>
+                <div class="file-card-actions">
+                  <button class="file-action-btn download-btn" @click="handleDownloadFile(file.url, file.filename)">
+                    <img :src="getCssUrl('--img-chat-download-icon-btn')" width="14" height="14" />
+                    <span>下载</span>
+                  </button>
+                  <button v-if="isEditableFile(file.filename)" class="file-action-btn edit-btn" @click="handleEditFile(file.url, file.filename)">
+                    <img :src="getCssUrl('--img-chat-edit-icon')" width="14" height="14" />
+                    <span>编辑</span>
+                  </button>
+                </div>
+              </div>
+            </div>
+            <div v-show="message.isLoading" class="loading-dots">
               <span class="dot"></span>
               <span class="dot"></span>
               <span class="dot"></span>
@@ -128,31 +196,47 @@
                   <div
                     v-for="(_, sIdx) in message.pendingApprovalData.ask_user.questions"
                     :key="sIdx"
-                    :class="['ask-user-step-dot', {
-                      active: sIdx === (askUserCurrentStep[index] ?? 0),
-                      completed: sIdx < (askUserCurrentStep[index] ?? 0) || !!(askUserAnswers[index]?.[sIdx] && askUserAnswers[index][sIdx].trim())
-                    }]"
+                    :class="[
+                      'ask-user-step-dot',
+                      {
+                        active: sIdx === (askUserCurrentStep[index] ?? 0),
+                        completed: sIdx < (askUserCurrentStep[index] ?? 0) || !!(askUserAnswers[index]?.[sIdx] && askUserAnswers[index][sIdx].trim()),
+                      },
+                    ]"
                     @click="askUserCurrentStep[index] = sIdx"
                   ></div>
-                  <span class="ask-user-step-text">{{ (askUserCurrentStep[index] ?? 0) + 1 }} / {{ message.pendingApprovalData.ask_user.questions.length }}</span>
+                  <span class="ask-user-step-text"
+                    >{{ (askUserCurrentStep[index] ?? 0) + 1 }} / {{ message.pendingApprovalData.ask_user.questions.length }}</span
+                  >
                 </div>
 
                 <!-- 当前问题 -->
-                <div
-                  v-if="message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0]"
-                  class="ask-user-question"
-                >
+                <div v-if="message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0]" class="ask-user-question">
                   <div class="ask-user-question-text">
                     {{ message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].question }}
-                    <span v-if="message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].required !== false" class="ask-user-required">*</span>
+                    <span
+                      v-if="message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].required !== false"
+                      class="ask-user-required"
+                      >*</span
+                    >
                   </div>
 
                   <!-- multiple_choice 类型 -->
-                  <div v-if="message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].type === 'multiple_choice'" class="ask-user-choices">
+                  <div
+                    v-if="message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].type === 'multiple_choice'"
+                    class="ask-user-choices"
+                  >
                     <div
                       v-for="(choice, cIdx) in message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].choices"
                       :key="cIdx"
-                      :class="['ask-user-choice', { selected: askUserAnswers[index]?.[askUserCurrentStep[index] ?? 0] === getChoiceValue(choice) && !askUserOtherSelected[index]?.[askUserCurrentStep[index] ?? 0] }]"
+                      :class="[
+                        'ask-user-choice',
+                        {
+                          selected:
+                            askUserAnswers[index]?.[askUserCurrentStep[index] ?? 0] === getChoiceValue(choice) &&
+                            !askUserOtherSelected[index]?.[askUserCurrentStep[index] ?? 0],
+                        },
+                      ]"
                       @click="selectChoice(index, askUserCurrentStep[index] ?? 0, choice, message.pendingApprovalData.ask_user.questions.length)"
                     >
                       <span>{{ getChoiceValue(choice) }}</span>
@@ -174,7 +258,10 @@
                   </div>
 
                   <!-- text 类型 -->
-                  <div v-else-if="message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].type === 'text'" class="ask-user-text">
+                  <div
+                    v-else-if="message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].type === 'text'"
+                    class="ask-user-text"
+                  >
                     <textarea
                       class="ask-user-textarea"
                       placeholder="请输入..."
@@ -186,23 +273,33 @@
 
                 <!-- 操作按钮 -->
                 <div class="approval-actions">
-                  <button
-                    v-if="(askUserCurrentStep[index] ?? 0) > 0"
-                    class="approval-btn nav-btn"
-                    @click="goPrevStep(index)"
-                  >上一步</button>
+                  <button v-if="(askUserCurrentStep[index] ?? 0) > 0" class="approval-btn nav-btn" @click="goPrevStep(index)">上一步</button>
                   <button
                     v-if="(askUserCurrentStep[index] ?? 0) < message.pendingApprovalData.ask_user.questions.length - 1"
                     class="approval-btn approve-btn"
-                    :disabled="!isCurrentStepAnswered(index, askUserCurrentStep[index] ?? 0, message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0])"
+                    :disabled="
+                      !isCurrentStepAnswered(
+                        index,
+                        askUserCurrentStep[index] ?? 0,
+                        message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0]
+                      )
+                    "
                     @click="goNextStep(index, message.pendingApprovalData.ask_user.questions.length)"
-                  >下一步</button>
+                    >下一步</button
+                  >
                   <button
                     v-if="(askUserCurrentStep[index] ?? 0) === message.pendingApprovalData.ask_user.questions.length - 1"
                     class="approval-btn approve-btn"
-                    :disabled="!isCurrentStepAnswered(index, askUserCurrentStep[index] ?? 0, message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0])"
+                    :disabled="
+                      !isCurrentStepAnswered(
+                        index,
+                        askUserCurrentStep[index] ?? 0,
+                        message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0]
+                      )
+                    "
                     @click="handleSubmitAskUser(index)"
-                  >提交</button>
+                    >提交</button
+                  >
                   <button class="approval-btn reject-btn" @click="handleCancelAskUser">取消</button>
                 </div>
               </div>
@@ -263,7 +360,9 @@
 <script setup lang="ts">
   import { ref, reactive, computed, watch, onMounted, onBeforeUnmount } from 'vue';
   import dayjs from 'dayjs';
-  import { renderMarkdown, formatFileSize, tableHtmlToMarkdown, tableHtmlToCsv } from './utils';
+  import { renderMarkdown, formatFileSize, tableHtmlToMarkdown, tableHtmlToCsv, isMdFile, isTextFile } from './utils';
+  import { getToken } from '/@/utils/auth';
+  import { message } from 'ant-design-vue';
   import type { Message, AttachedFile, ThinkingStep } from './types';
   // import katex from 'katex';
   // import 'katex/dist/katex.min.css';
@@ -336,10 +435,53 @@
   const emit = defineEmits<{
     (e: 'file-preview', file: AttachedFile): void;
     (e: 'download-word', url: string): void;
+    (e: 'open-file-preview', file: AttachedFile): void;
     (e: 'ask-user-submit', answers: string[]): void;
     (e: 'ask-user-cancel'): void;
   }>();
 
+  const isEditableFile = (filename: string) => isMdFile(filename) || isTextFile(filename);
+
+  const handleDownloadFile = (url: string, filename: string) => {
+    if (!url) return;
+    const a = document.createElement('a');
+    a.href = url;
+    a.download = filename;
+    document.body.appendChild(a);
+    a.click();
+    document.body.removeChild(a);
+  };
+
+  const handleEditFile = async (url: string, filename: string) => {
+    try {
+      const resp = await fetch(url, { headers: { 'X-Access-Token': getToken() } });
+      if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+      const content = await resp.text();
+      const ext = filename.split('.').pop()?.toLowerCase() || '';
+      const mimeMap: Record<string, string> = {
+        md: 'text/markdown',
+        txt: 'text/plain',
+        json: 'application/json',
+        xml: 'text/xml',
+        yaml: 'text/yaml',
+        yml: 'text/yaml',
+        csv: 'text/csv',
+      };
+      const file: AttachedFile = {
+        id: `report-${Date.now()}`,
+        name: filename,
+        size: content.length,
+        type: mimeMap[ext] || 'text/plain',
+        content,
+        uploadTime: new Date().toISOString(),
+      };
+      emit('open-file-preview', file);
+    } catch (err) {
+      console.error('获取文件内容失败:', err);
+      message.error('获取文件内容失败');
+    }
+  };
+
   // ask_user 答案状态(按消息索引存储)
   const askUserAnswers = reactive<Record<number, string[]>>({});
   const askUserOtherSelected = reactive<Record<number, boolean[]>>({});
@@ -408,6 +550,7 @@
   const collapsedThinking = reactive<Record<number, boolean>>({});
   const collapsedThinkingContent = reactive<Record<number, boolean>>({});
   const expandedAgents = reactive<Record<string, boolean>>({});
+  const collapsedCitations = reactive<Record<number, boolean>>({});
   const isAtBottom = ref(true);
   const copiedMessageIndex = ref(-1);
 
@@ -495,14 +638,18 @@
   const scrollToBottom = () => {
     const el = messagesRef.value;
     if (el) {
-      el.scrollTop = el.scrollHeight;
+      el.scrollTop = el.scrollHeight - el.clientHeight;
     }
   };
 
   const forceScrollToBottom = () => {
     const el = messagesRef.value;
     if (el) {
-      el.scrollTop = el.scrollHeight;
+      el.scrollTop = el.scrollHeight - el.clientHeight;
+      // 使用 requestAnimationFrame 确保 DOM 渲染完成后再滚动一次
+      requestAnimationFrame(() => {
+        el.scrollTop = el.scrollHeight - el.clientHeight;
+      });
     }
     isAtBottom.value = true;
   };
@@ -536,6 +683,23 @@
     expandedAgents[key] = !expandedAgents[key];
   };
 
+  const toggleCitations = (index: number) => {
+    collapsedCitations[index] = !collapsedCitations[index];
+  };
+
+  const handleFootnoteClick = (e: Event, msgIndex: number) => {
+    const target = (e.target as HTMLElement).closest('.footnote-ref') as HTMLElement;
+    if (!target) return;
+    const n = target.dataset.n;
+    if (!n) return;
+    const el = document.getElementById(`citation-${msgIndex}-${n}`);
+    if (el) {
+      el.scrollIntoView({ behavior: 'smooth', block: 'center' });
+      el.classList.add('citation-highlight');
+      setTimeout(() => el.classList.remove('citation-highlight'), 1500);
+    }
+  };
+
   const getLatestStepMessage = (group: AgentGroup): string => {
     if (group.steps.length === 0) return '';
     const last = group.steps[group.steps.length - 1];
@@ -645,7 +809,7 @@
         .message-body {
           padding: 12px 12px 4px 12px;
           border-radius: 6px;
-          width: calc(100% - 50px);
+          width: 100%;
           min-width: 0;
           overflow: hidden;
 
@@ -1011,6 +1175,7 @@
           align-items: center;
           gap: 6px;
           padding: 4px 0;
+          min-height: 16px;
 
           .dot {
             width: 8px;
@@ -1269,6 +1434,270 @@
             }
           }
         }
+
+        .citations-card {
+          margin-bottom: 12px;
+          overflow: hidden;
+
+          .citations-header {
+            display: flex;
+            align-items: center;
+            justify-content: space-between;
+            padding: 8px 14px;
+            border-bottom: 1px solid rgba(63, 80, 106, 0.3);
+            cursor: pointer;
+            user-select: none;
+            transition: background 0.2s;
+
+            &:hover {
+              background: rgba(10, 132, 255, 0.08);
+            }
+
+            &.citations-header-simple {
+              cursor: default;
+
+              &:hover {
+                background: transparent;
+              }
+            }
+
+            .citations-header-left {
+              display: flex;
+              align-items: center;
+              gap: 8px;
+            }
+
+            .citations-title {
+              font-size: 13px;
+              font-weight: 500;
+              color: #8b949e;
+            }
+
+            .citations-count {
+              font-size: 12px;
+              color: #8b949e;
+              background: rgba(10, 132, 255, 0.1);
+              padding: 1px 6px;
+              border-radius: 10px;
+            }
+
+            .thinking-arrow {
+              display: flex;
+              align-items: center;
+              transition: transform 0.2s;
+
+              &.expanded {
+                transform: rotate(180deg);
+              }
+            }
+          }
+
+          .citations-body {
+            padding: 4px 14px;
+
+            .citation-item {
+              padding: 6px 0;
+              border-bottom: 1px solid rgba(63, 80, 106, 0.15);
+
+              &:last-child {
+                border-bottom: none;
+              }
+
+              &.citation-highlight {
+                background: rgba(10, 132, 255, 0.08);
+                border-radius: 4px;
+                padding: 6px 8px;
+                margin: 0 -8px;
+              }
+
+              .citation-item-row {
+                display: flex;
+                align-items: center;
+                gap: 8px;
+                line-height: 1.5;
+
+                .citation-badge {
+                  font-size: 12px;
+                  color: #79c0ff;
+                  font-family: monospace;
+                  background: rgba(10, 132, 255, 0.1);
+                  padding: 1px 5px;
+                  border-radius: 3px;
+                  white-space: nowrap;
+                  flex-shrink: 0;
+                }
+
+                .citation-badge-knowledge_base {
+                  color: #3fb950;
+                  background: rgba(46, 160, 67, 0.1);
+                }
+
+                .citation-badge-web {
+                  color: #d29922;
+                  background: rgba(210, 153, 34, 0.1);
+                }
+
+                .citation-badge-calc {
+                  color: #a371f7;
+                  background: rgba(163, 113, 247, 0.1);
+                }
+
+                .citation-title-text {
+                  font-size: 13px;
+                  color: #b0b8c4;
+                  overflow: hidden;
+                  text-overflow: ellipsis;
+                  white-space: nowrap;
+                  min-width: 0;
+                }
+
+                .citation-link {
+                  font-size: 12px;
+                  color: #0a84ff;
+                  text-decoration: none;
+                  white-space: nowrap;
+                  flex-shrink: 0;
+
+                }
+              }
+
+              .citation-item-attrs {
+                display: flex;
+                align-items: center;
+                flex-wrap: wrap;
+                gap: 4px 10px;
+                padding-left: 0;
+                margin-top: 2px;
+
+                .citation-attr {
+                  font-size: 12px;
+                  color: #8b949e;
+                  white-space: nowrap;
+                }
+
+                .citation-low-score {
+                  color: #d29922;
+                }
+              }
+
+              .citation-snippet {
+                margin-top: 4px;
+                font-size: 12px;
+                color: #8b949e;
+                line-height: 1.5;
+                overflow: hidden;
+                text-overflow: ellipsis;
+                white-space: nowrap;
+              }
+            }
+          }
+        }
+
+        .download-file-cards {
+          display: flex;
+          flex-direction: column;
+          gap: 8px;
+          margin-top: 12px;
+
+          .download-file-card {
+            display: flex;
+            align-items: center;
+            justify-content: space-between;
+            padding: 12px 16px;
+            background: linear-gradient(135deg, rgba(0, 47, 74, 0.8) 0%, rgba(0, 60, 90, 0.6) 100%);
+            border: 1px solid rgba(0, 84, 130, 0.6);
+            border-radius: 10px;
+            transition: all 0.2s ease;
+
+            &:hover {
+              border-color: rgba(10, 132, 255, 0.4);
+              box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
+            }
+
+            .file-card-info {
+              display: flex;
+              align-items: center;
+              gap: 12px;
+              min-width: 0;
+              flex: 1;
+
+              .file-card-icon {
+                width: 36px;
+                height: 36px;
+                background-image: var(--img-chat-download-icon);
+                background-repeat: no-repeat;
+                background-size: 100% 100%;
+                flex-shrink: 0;
+                opacity: 0.9;
+              }
+
+              .file-card-text {
+                display: flex;
+                flex-direction: column;
+                gap: 2px;
+                min-width: 0;
+
+                .file-card-name {
+                  font-size: 13px;
+                  font-weight: 500;
+                  color: #e0e6ed;
+                  overflow: hidden;
+                  text-overflow: ellipsis;
+                  white-space: nowrap;
+                }
+
+                .file-card-hint {
+                  font-size: 11px;
+                  color: #6e7681;
+                }
+              }
+            }
+
+            .file-card-actions {
+              display: flex;
+              gap: 8px;
+              flex-shrink: 0;
+
+              .file-action-btn {
+                display: inline-flex;
+                align-items: center;
+                gap: 5px;
+                padding: 6px 14px;
+                border-radius: 7px;
+                font-size: 12px;
+                font-weight: 500;
+                cursor: pointer;
+                border: none;
+                transition: all 0.2s ease;
+                white-space: nowrap;
+
+                &.download-btn {
+                  background: rgba(255, 255, 255, 0.08);
+                  color: #c9d1d9;
+                  border: 1px solid rgba(63, 80, 106, 0.5);
+
+                  &:hover {
+                    background: rgba(255, 255, 255, 0.14);
+                    color: #e0e6ed;
+                    border-color: rgba(63, 80, 106, 0.8);
+                  }
+                }
+
+                &.edit-btn {
+                  background: linear-gradient(135deg, #0a84ff 0%, #0066cc 100%);
+                  color: #fff;
+                  border: 1px solid rgba(10, 132, 255, 0.5);
+                  box-shadow: 0 2px 6px rgba(10, 132, 255, 0.2);
+
+                  &:hover {
+                    background: linear-gradient(135deg, #3a9bfd 0%, #0a84ff 100%);
+                    box-shadow: 0 3px 10px rgba(10, 132, 255, 0.35);
+                  }
+                }
+              }
+            }
+          }
+        }
       }
 
       .user-message {

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

@@ -1,78 +0,0 @@
-<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>

+ 277 - 98
src/views/ventAI/manageAssistent/components/chatModal/FilePreviewPanel.vue

@@ -2,82 +2,106 @@
   <div class="file-preview-panel">
     <div class="panel-header">
       <span class="panel-title">{{ file.name }}</span>
+      <div class="panel-header-actions">
+        <button v-if="editable && !isEditing" class="header-action-btn edit-btn" @click="enterEdit">
+          <img :src="getCssUrl('--img-chat-edit-icon')" width="14" height="14" />
+          <span>编辑</span>
+        </button>
+        <button v-if="isEditing" class="header-action-btn cancel-btn" @click="cancelEdit">
+          <img :src="getCssUrl('--img-chat-close-icon-btn')" width="14" height="14" />
+          <span>取消</span>
+        </button>
+        <button v-if="isEditing" class="header-action-btn save-btn" :class="{ saving }" :disabled="saving" @click="saveEdit">
+          <img v-if="!saving" :src="getCssUrl('--img-chat-mode-confirm-icon')" width="14" height="14" />
+          <img v-else class="spin-icon" :src="getCssUrl('--img-chat-mode-confirm-icon')" width="14" height="14" />
+          <span>{{ saving ? '保存中...' : '保存' }}</span>
+        </button>
+      </div>
       <div class="close-btn" @click="emit('close')">
         <div class="btn-close-icon"></div>
       </div>
     </div>
     <div class="panel-content file-preview-content">
-      <div v-if="isImageFile(file.name)" class="image-preview">
-        <img :src="file.preview" alt="预览图片" />
-      </div>
-      <div v-else-if="isPdfFile(file.name)" class="pdf-preview">
-        <iframe v-if="file.preview" :src="pdfSrc" frameborder="0"></iframe>
-        <div v-else class="no-preview-text">无法加载PDF预览</div>
-      </div>
-      <div v-else-if="isOldDocFile(file.name)" class="no-preview">
-        <div class="no-preview-icon"></div>
-        <div class="no-preview-text">暂不支持 .doc 格式预览</div>
-        <div class="no-preview-hint">请将文件转换为 .docx 格式后重新上传</div>
-      </div>
-      <div v-else-if="isWordFile(file.name)" class="word-preview">
-        <VueOfficeDocx v-if="file.arrayBuffer" :src="file.arrayBuffer" @rendered="handleRendered" @error="handleError" />
-        <div v-else class="no-preview-text">无法加载Word文档</div>
+      <!-- 编辑模式 -->
+      <div v-if="isEditing" class="edit-mode">
+        <textarea v-model="editContent" class="edit-textarea" spellcheck="false"></textarea>
       </div>
-      <div v-else-if="isExcelFile(file.name)" class="excel-preview">
-        <div v-if="excelSheets.length > 1" class="excel-tabs">
-          <div v-for="(sheet, i) in excelSheets" :key="i" :class="['excel-tab', { active: activeSheet === i }]" @click="activeSheet = i">
-            {{ sheet.name }}
+      <!-- 预览模式 -->
+      <template v-else>
+        <div v-if="isImageFile(file.name)" class="image-preview">
+          <img :src="file.preview" alt="预览图片" />
+        </div>
+        <div v-else-if="isPdfFile(file.name)" class="pdf-preview">
+          <iframe v-if="file.preview" :src="pdfSrc" frameborder="0"></iframe>
+          <div v-else class="no-preview-text">无法加载PDF预览</div>
+        </div>
+        <div v-else-if="isOldDocFile(file.name)" class="no-preview">
+          <div class="no-preview-icon"></div>
+          <div class="no-preview-text">暂不支持 .doc 格式预览</div>
+          <div class="no-preview-hint">请将文件转换为 .docx 格式后重新上传</div>
+        </div>
+        <div v-else-if="isWordFile(file.name)" class="word-preview">
+          <VueOfficeDocx v-if="file.arrayBuffer" :src="file.arrayBuffer" @rendered="handleRendered" @error="handleError" />
+          <div v-else class="no-preview-text">无法加载Word文档</div>
+        </div>
+        <div v-else-if="isExcelFile(file.name)" class="excel-preview">
+          <div v-if="excelSheets.length > 1" class="excel-tabs">
+            <div v-for="(sheet, i) in excelSheets" :key="i" :class="['excel-tab', { active: activeSheet === i }]" @click="activeSheet = i">
+              {{ sheet.name }}
+            </div>
           </div>
+          <div v-if="excelLoading" class="no-preview-text">加载中...</div>
+          <div v-else-if="currentSheetData.length > 0" class="excel-table-wrapper">
+            <table class="excel-table">
+              <thead>
+                <tr>
+                  <th v-for="(cell, i) in currentSheetData[0]" :key="i">{{ cell }}</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr v-for="(row, ri) in currentSheetData.slice(1)" :key="ri">
+                  <td v-for="(cell, ci) in row" :key="ci">{{ cell }}</td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+          <div v-else class="no-preview-text">Excel文件内容为空</div>
         </div>
-        <div v-if="excelLoading" class="no-preview-text">加载中...</div>
-        <div v-else-if="currentSheetData.length > 0" class="excel-table-wrapper">
-          <table class="excel-table">
+        <div v-else-if="isCsvFile(file.name)" class="csv-preview">
+          <table v-if="csvRows.length > 0" class="csv-table">
             <thead>
               <tr>
-                <th v-for="(cell, i) in currentSheetData[0]" :key="i">{{ cell }}</th>
+                <th v-for="(cell, i) in csvRows[0]" :key="i">{{ cell }}</th>
               </tr>
             </thead>
             <tbody>
-              <tr v-for="(row, ri) in currentSheetData.slice(1)" :key="ri">
+              <tr v-for="(row, ri) in csvRows.slice(1)" :key="ri">
                 <td v-for="(cell, ci) in row" :key="ci">{{ cell }}</td>
               </tr>
             </tbody>
           </table>
+          <div v-else class="no-preview-text">CSV文件内容为空</div>
         </div>
-        <div v-else class="no-preview-text">Excel文件内容为空</div>
-      </div>
-      <div v-else-if="isCsvFile(file.name)" class="csv-preview">
-        <table v-if="csvRows.length > 0" class="csv-table">
-          <thead>
-            <tr>
-              <th v-for="(cell, i) in csvRows[0]" :key="i">{{ cell }}</th>
-            </tr>
-          </thead>
-          <tbody>
-            <tr v-for="(row, ri) in csvRows.slice(1)" :key="ri">
-              <td v-for="(cell, ci) in row" :key="ci">{{ cell }}</td>
-            </tr>
-          </tbody>
-        </table>
-        <div v-else class="no-preview-text">CSV文件内容为空</div>
-      </div>
-      <div v-else-if="isMdFile(file.name)" class="markdown-preview">
-        <div class="markdown-body" v-html="renderedMarkdown"></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 v-else-if="isMdFile(file.name)" class="markdown-preview">
+          <div class="markdown-body" v-html="renderedMarkdown"></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>
+      </template>
     </div>
   </div>
 </template>
 
 <script setup lang="ts">
   import { ref, computed, watch } from 'vue';
+  import { message } from 'ant-design-vue';
+  import { saveReportText } from '../../api';
   import VueOfficeDocx from '@vue-office/docx/lib/v3/vue-office-docx.mjs';
   import * as XLSX from 'xlsx';
   import {
@@ -94,14 +118,67 @@
   } from './utils';
   import type { AttachedFile } from './types';
 
-  const props = defineProps<{
-    file: AttachedFile;
-  }>();
+  const getCssUrl = (varName: string) => {
+    const val = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
+    return val.replace(/^url\(["']?/, '').replace(/["']?\)$/, '');
+  };
+
+  const props = withDefaults(
+    defineProps<{
+      file: AttachedFile;
+      editable?: boolean;
+      saveFilename?: string;
+    }>(),
+    { editable: false, saveFilename: '' }
+  );
 
   const emit = defineEmits<{
     (e: 'close'): void;
+    (e: 'save-success', content: string): void;
   }>();
 
+  // 编辑模式
+  const isEditing = ref(false);
+  const editContent = ref('');
+  const saving = ref(false);
+
+  const enterEdit = () => {
+    editContent.value = props.file.content || '';
+    isEditing.value = true;
+  };
+
+  const cancelEdit = () => {
+    isEditing.value = false;
+    editContent.value = '';
+  };
+
+  const saveEdit = async () => {
+    if (!props.saveFilename) return;
+    saving.value = true;
+    try {
+      await saveReportText(props.saveFilename, editContent.value);
+      message.success('保存成功');
+      emit('save-success', editContent.value);
+    } catch (err) {
+      console.error('保存文件失败:', err);
+      message.error('保存文件失败');
+    } finally {
+      saving.value = false;
+    }
+  };
+
+  // 文件内容更新后(保存成功),自动退出编辑模式
+  watch(
+    () => props.file.content,
+    () => {
+      if (isEditing.value) {
+        isEditing.value = false;
+        editContent.value = '';
+        saving.value = false;
+      }
+    }
+  );
+
   // Excel 解析
   const excelSheets = ref<{ name: string; data: string[][] }[]>([]);
   const activeSheet = ref(0);
@@ -196,40 +273,115 @@
 
 <style scoped lang="less">
   .file-preview-panel {
-    height: 100%;
     display: flex;
     flex-direction: column;
+    height: 100%;
   }
 
   .panel-header {
-    padding: 10px 10px 5px 10px;
     display: flex;
-    justify-content: space-between;
     align-items: center;
+    justify-content: space-between;
+    padding: 10px 10px 5px;
     flex-shrink: 0;
-    border-bottom: 1px solid rgba(63, 80, 106, 0.25);
+    border-bottom: 1px solid rgb(63 80 106 / 25%);
 
     .panel-title {
+      flex: 1;
+      color: #e0e6ed;
+      overflow: hidden;
       font-size: 15px;
       font-weight: 600;
       line-height: 1.4;
-      color: #e0e6ed;
-      overflow: hidden;
       text-overflow: ellipsis;
       white-space: nowrap;
-      flex: 1;
       margin-right: 10px;
     }
 
+    .panel-header-actions {
+      display: flex;
+      gap: 6px;
+      flex-shrink: 0;
+      margin-right: 6px;
+
+      .header-action-btn {
+        display: inline-flex;
+        align-items: center;
+        gap: 5px;
+        padding: 4px 12px;
+        border-radius: 6px;
+        font-size: 12px;
+        font-weight: 500;
+        cursor: pointer;
+        border: none;
+        transition: all 0.2s ease;
+        white-space: nowrap;
+
+        &.edit-btn {
+          background: linear-gradient(135deg, #0a84ff 0%, #0066cc 100%);
+          color: #fff;
+          border: 1px solid rgba(10, 132, 255, 0.5);
+          box-shadow: 0 2px 6px rgba(10, 132, 255, 0.2);
+
+          &:hover {
+            background: linear-gradient(135deg, #3a9bfd 0%, #0a84ff 100%);
+            box-shadow: 0 3px 10px rgba(10, 132, 255, 0.35);
+          }
+        }
+
+        &.cancel-btn {
+          background: rgba(255, 255, 255, 0.06);
+          color: #8b949e;
+          border: 1px solid rgba(63, 80, 106, 0.4);
+
+          &:hover {
+            background: rgba(255, 255, 255, 0.1);
+            color: #c9d1d9;
+            border-color: rgba(63, 80, 106, 0.6);
+          }
+        }
+
+        &.save-btn {
+          background: linear-gradient(135deg, #0a84ff 0%, #0066cc 100%);
+          color: #fff;
+          border: 1px solid rgba(10, 132, 255, 0.5);
+          box-shadow: 0 2px 6px rgba(10, 132, 255, 0.25);
+
+          &:hover:not(:disabled) {
+            background: linear-gradient(135deg, #3a9bfd 0%, #0a84ff 100%);
+            box-shadow: 0 3px 10px rgba(10, 132, 255, 0.35);
+          }
+
+          &:disabled {
+            opacity: 0.6;
+            cursor: not-allowed;
+          }
+
+          &.saving {
+            background: linear-gradient(135deg, #3a8fd4 0%, #2a6fa0 100%);
+          }
+        }
+
+        .spin-icon {
+          animation: spin 1s linear infinite;
+        }
+
+        @keyframes spin {
+          from { transform: rotate(0deg); }
+          to { transform: rotate(360deg); }
+        }
+      }
+    }
+
     .close-btn {
-      cursor: pointer;
-      width: 36px;
-      height: 36px;
       display: flex;
       align-items: center;
       justify-content: center;
-      flex-shrink: 0;
+      width: 36px;
+      height: 36px;
+      cursor: pointer;
       transition: all 0.3s;
+      flex-shrink: 0;
       margin-bottom: auto;
 
       .btn-close-icon {
@@ -252,11 +404,11 @@
     height: 100%;
 
     .image-preview {
-      width: 100%;
-      height: 100%;
       display: flex;
       align-items: center;
       justify-content: center;
+      width: 100%;
+      height: 100%;
 
       img {
         width: 100%;
@@ -281,29 +433,28 @@
       width: calc(100% + 32px);
       height: calc(100% + 32px);
       margin: -16px;
-      overflow-x: auto;
-      overflow-y: auto;
+      overflow: auto auto;
 
       :deep(.docx-wrapper) {
+        display: inline-block;
         padding: 0;
         background: transparent;
-        display: inline-block;
         min-width: 100%;
       }
     }
 
     .excel-preview {
+      display: flex;
+      flex-direction: column;
       width: calc(100% + 32px);
       height: calc(100% + 32px);
       margin: -16px;
-      display: flex;
-      flex-direction: column;
       overflow: hidden;
 
       .excel-tabs {
         display: flex;
         gap: 0;
-        border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+        border-bottom: 1px solid rgb(255 255 255 / 10%);
         flex-shrink: 0;
         padding: 0 16px;
 
@@ -328,8 +479,8 @@
 
       .excel-table-wrapper {
         flex: 1;
-        overflow: auto;
         padding: 0 16px;
+        overflow: auto;
 
         .excel-table {
           border-collapse: collapse;
@@ -339,18 +490,18 @@
 
           th,
           td {
-            border: 1px solid rgba(255, 255, 255, 0.15);
             padding: 6px 10px;
+            border: 1px solid rgb(255 255 255 / 15%);
             text-align: left;
             white-space: nowrap;
           }
 
           th {
-            background: rgba(10, 132, 255, 0.15);
-            color: #e0e6ed;
-            font-weight: 600;
             position: sticky;
             top: 0;
+            background: rgb(10 132 255 / 15%);
+            color: #e0e6ed;
+            font-weight: 600;
           }
 
           td {
@@ -358,7 +509,7 @@
           }
 
           tr:hover td {
-            background: rgba(10, 132, 255, 0.08);
+            background: rgb(10 132 255 / 8%);
           }
         }
       }
@@ -376,18 +527,18 @@
 
         th,
         td {
-          border: 1px solid rgba(255, 255, 255, 0.15);
           padding: 6px 10px;
+          border: 1px solid rgb(255 255 255 / 15%);
           text-align: left;
           white-space: nowrap;
         }
 
         th {
-          background: rgba(10, 132, 255, 0.15);
-          color: #e0e6ed;
-          font-weight: 600;
           position: sticky;
           top: 0;
+          background: rgb(10 132 255 / 15%);
+          color: #e0e6ed;
+          font-weight: 600;
         }
 
         td {
@@ -395,7 +546,7 @@
         }
 
         tr:hover td {
-          background: rgba(10, 132, 255, 0.08);
+          background: rgb(10 132 255 / 8%);
         }
       }
     }
@@ -417,13 +568,13 @@
 
           th,
           td {
-            border: 1px solid rgba(255, 255, 255, 0.15);
             padding: 8px 12px;
+            border: 1px solid rgb(255 255 255 / 15%);
             text-align: left;
           }
 
           th {
-            background: rgba(10, 132, 255, 0.1);
+            background: rgb(10 132 255 / 10%);
             font-weight: 600;
           }
         }
@@ -459,13 +610,13 @@
         :deep(h1) {
           font-size: 24px;
           padding-bottom: 8px;
-          border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+          border-bottom: 1px solid rgb(255 255 255 / 10%);
         }
 
         :deep(h2) {
           font-size: 20px;
           padding-bottom: 6px;
-          border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+          border-bottom: 1px solid rgb(255 255 255 / 10%);
         }
 
         :deep(h3) {
@@ -477,24 +628,24 @@
         }
 
         :deep(code) {
-          background: rgba(10, 132, 255, 0.15);
           padding: 2px 6px;
+          background: rgb(10 132 255 / 15%);
+          color: #79c0ff;
           border-radius: 4px;
           font-family: monospace;
           font-size: 13px;
-          color: #79c0ff;
         }
 
         :deep(pre) {
-          background: rgba(0, 0, 0, 0.3);
           padding: 12px 16px;
+          background: rgb(0 0 0 / 30%);
           border-radius: 6px;
           overflow-x: auto;
           margin-bottom: 12px;
 
           code {
-            background: none;
             padding: 0;
+            background: none;
             color: #e0e6ed;
           }
         }
@@ -523,18 +674,18 @@
 
           th,
           td {
-            border: 1px solid rgba(255, 255, 255, 0.15);
             padding: 8px 12px;
+            border: 1px solid rgb(255 255 255 / 15%);
             text-align: left;
           }
 
           th {
-            background: rgba(10, 132, 255, 0.1);
+            background: rgb(10 132 255 / 10%);
             font-weight: 600;
           }
 
           tr:hover td {
-            background: rgba(10, 132, 255, 0.05);
+            background: rgb(10 132 255 / 5%);
           }
         }
 
@@ -553,9 +704,9 @@
         }
 
         :deep(hr) {
-          border: none;
-          border-top: 1px solid rgba(255, 255, 255, 0.1);
           margin: 16px 0;
+          border: none;
+          border-top: 1px solid rgb(255 255 255 / 10%);
         }
       }
     }
@@ -570,6 +721,34 @@
       }
     }
 
+    .edit-mode {
+      height: 100%;
+      display: flex;
+      flex-direction: column;
+
+      .edit-textarea {
+        flex: 1;
+        width: 100%;
+        min-height: 100%;
+        padding: 12px;
+        font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
+        font-size: 13px;
+        line-height: 1.6;
+        color: #e0e6ed;
+        background: rgba(0, 0, 0, 0.2);
+        border: 1px solid rgba(63, 80, 106, 0.4);
+        border-radius: 6px;
+        outline: none;
+        resize: none;
+        tab-size: 4;
+
+        &:focus {
+          border-color: #0a84ff;
+          background: rgba(0, 0, 0, 0.3);
+        }
+      }
+    }
+
     .no-preview {
       display: flex;
       flex-direction: column;

+ 27 - 0
src/views/ventAI/manageAssistent/components/chatModal/types.ts

@@ -21,6 +21,31 @@ export interface AgentAnswer {
   timestamp: number;
 }
 
+export interface CitationItem {
+  id: number;
+  type: 'knowledge_base' | 'mcp' | 'web' | 'calc' | 'file' | 'history' | 'system' | 'model';
+  type_cn: string;
+  title: string;
+  tool: string;
+  tool_cn: string;
+  score?: number;
+  low_score?: boolean;
+  doc_path?: string;
+  search_url?: string;
+  snippet?: string;
+  url?: string;
+  fetched_at?: string;
+  source_system?: string;
+  param?: Record<string, any>;
+  meta?: any;
+}
+
+export interface CitationsData {
+  items: CitationItem[];
+  model_only: boolean;
+  message?: string;
+}
+
 export interface ThinkingStep {
   type: 'agent_start' | 'agent_done' | 'tool_call' | 'tool_result' | 'executing' | 'updated_todo_list' | 'token';
   agent?: string;
@@ -68,6 +93,8 @@ export interface Message {
   thinkingSteps?: ThinkingStep[];
   sessionId?: string;
   wordDownloadUrl?: string;
+  downloadFiles?: Array<{ url: string; filename: string }>;
+  citations?: CitationsData;
   durationMs?: number;
   baseDurationMs?: number;
   generateStartTime?: number;

+ 21 - 1
src/views/ventAI/manageAssistent/components/chatModal/utils.ts

@@ -22,10 +22,30 @@ const renderLatexInHtml = (html: string): string => {
   return html;
 };
 
-export const renderMarkdown = (text: string, icons?: { copy: string; download: string; preview: string; copySuccess: string }): string => {
+const processFootnotes = (html: string, maxId?: number): string => {
+  if (maxId === undefined) {
+    // 无 citations 上下文,剥离所有 [^n] 标记
+    return html.replace(/\[\^\d+\]/g, '');
+  }
+  // 有效编号范围 [1, maxId],范围外的剥离
+  return html.replace(/\[\^(\d+)\]/g, (_match, n) => {
+    const id = parseInt(n, 10);
+    if (id >= 1 && id <= maxId) {
+      return `<sup class="footnote-ref" data-n="${id}">[${n}]</sup>`;
+    }
+    return '';
+  });
+};
+
+export const renderMarkdown = (
+  text: string,
+  icons?: { copy: string; download: string; preview: string; copySuccess: string },
+  maxCitationId?: number
+): string => {
   if (!text) return '';
   const processed = text.replace(/<br\s*\/?>/gi, '  \n');
   let html = marked.parse(processed) as string;
+  html = processFootnotes(html, maxCitationId);
   html = renderLatexInHtml(html);
   return icons ? wrapTables(html, icons) : html;
 };

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor