Browse Source

解决冲突

hongrunxia 1 day ago
parent
commit
5f49c13bf1

+ 52 - 6
src/router/index.ts

@@ -1,9 +1,9 @@
 import type { RouteRecordRaw } from 'vue-router';
 import type { App } from 'vue';
-
 import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router';
 import { basicRoutes } from './routes';
-
+import { defHttp } from '/@/utils/http/axios';
+// let userName = unref(userStore.getUserInfo).username;
 // 白名单应该包含基本静态路由
 const WHITE_NAME_LIST: string[] = [];
 const getRouteNames = (array: any[]) =>
@@ -22,11 +22,57 @@ export const router = createRouter({
 });
 
 // TODO 【QQYUN-4517】【表单设计器】记录分享路由守卫测试
+// 存储当前页面的browseId(用于关联离开/进入日志)
+let currentBrowseId = '';
 router.beforeEach(async (to, from, next) => {
-  //console.group('【QQYUN-4517】beforeEach');
-  //console.warn('from', from);
-  //console.warn('to', to);
-  //console.groupEnd();
+  const url = '/sys/log/addBrowseLog';
+  const currentPath = to.fullPath;
+  // 生成时间戳函数
+  const formatTimestamp = () => {
+    const date = new Date();
+    return [
+      date.getFullYear(),
+      String(date.getMonth() + 1).padStart(2, '0'),
+      String(date.getDate()).padStart(2, '0'),
+      String(date.getHours()).padStart(2, '0'),
+      String(date.getMinutes()).padStart(2, '0'),
+      String(date.getSeconds()).padStart(2, '0'),
+      String(date.getMilliseconds()).padStart(3, '0'),
+    ].join('');
+  };
+  // 1. 如果存在上一个页面的browseId,发送离开日志
+  if (currentBrowseId && from.fullPath !== '/') {
+    try {
+      await defHttp.post({
+        url,
+        params: {
+          browseId: currentBrowseId,
+          isEnd: true,
+          method: from.fullPath,
+        },
+      });
+      console.log('离开页面日志记录成功');
+    } catch (e) {
+      console.error('离开页面日志记录失败:', e);
+    }
+  }
+
+  // 2. 记录新页面进入日志
+  currentBrowseId = formatTimestamp();
+  try {
+    await defHttp.post({
+      url,
+      params: {
+        browseId: currentBrowseId,
+        isEnd: false,
+        method: to.fullPath,
+      },
+    });
+    console.log('进入页面日志记录成功');
+  } catch (e) {
+    console.error('进入页面日志记录失败:', e);
+  }
+
   next();
 });
 

+ 63 - 0
src/views/monitor/logRouter/index.vue

@@ -0,0 +1,63 @@
+<template>
+  <BasicTable @register="registerTable" :searchInfo="searchInfo" :columns="logColumns">
+    <template #tableTitle>
+      <a-tabs defaultActiveKey="1" @change="tabChange" size="small">
+        <a-tab-pane tab="浏览器日志" key="1"></a-tab-pane>
+      </a-tabs>
+    </template>
+  </BasicTable>
+</template>
+<script lang="ts" name="monitor-log" setup>
+import { ref } from 'vue';
+import { BasicTable, useTable, TableAction } from '/@/components/Table';
+import { getLogList } from './log.api';
+import { columns, searchFormSchema } from './log.data';
+import { useMessage } from '/@/hooks/web/useMessage';
+import { useListPage } from '/@/hooks/system/useListPage';
+const { createMessage } = useMessage();
+const checkedKeys = ref<Array<string | number>>([]);
+
+const logColumns = ref<any>(columns);
+const searchInfo = { logType: '3' };
+// 列表页面公共参数、方法
+const { prefixCls, tableContext } = useListPage({
+  designScope: 'user-list',
+  tableProps: {
+    title: '日志列表',
+    api: getLogList,
+    expandRowByClick: true,
+    showActionColumn: false,
+    rowSelection: {
+      columnWidth: 20,
+    },
+    formConfig: {
+      schemas: searchFormSchema,
+      fieldMapToTime: [['fieldTime', ['createTime_begin', 'createTime_end'], 'YYYY-MM-DD']],
+    },
+  },
+});
+
+const [registerTable, { reload }] = tableContext;
+
+// 日志类型
+function tabChange(key) {
+  searchInfo.logType = key;
+  if (key == '3') {
+    logColumns.value = columns;
+  }
+  reload();
+}
+
+/**
+ * 选择事件
+ */
+function onSelectChange(selectedRowKeys: (string | number)[]) {
+  checkedKeys.value = selectedRowKeys;
+}
+</script>
+<style lang="less" scoped>
+::v-deep .table-form {
+  padding: 0 !important;
+  margin: 0 !important;
+}
+</style>

+ 13 - 0
src/views/monitor/logRouter/log.api.ts

@@ -0,0 +1,13 @@
+import { defHttp } from '/@/utils/http/axios';
+
+enum Api {
+  list = '/sys/log/list',
+}
+
+/**
+ * 查询日志列表
+ * @param params
+ */
+export const getLogList = (params) => {
+  return defHttp.get({ url: Api.list, params });
+};

+ 56 - 0
src/views/monitor/logRouter/log.data.ts

@@ -0,0 +1,56 @@
+import { BasicColumn, FormSchema } from '/@/components/Table';
+
+export const columns: BasicColumn[] = [
+  {
+    title: '日志内容',
+    dataIndex: 'logContent',
+    width: 100,
+    align: 'left',
+  },
+  {
+    title: '操作人ID',
+    dataIndex: 'userid',
+    width: 80,
+  },
+  {
+    title: '操作人',
+    dataIndex: 'username',
+    width: 80,
+  },
+  {
+    title: 'IP',
+    dataIndex: 'ip',
+    width: 80,
+  },
+  {
+    title: '耗时(毫秒)',
+    dataIndex: 'costTime',
+    width: 80,
+  },
+  {
+    title: '创建时间',
+    dataIndex: 'createTime',
+    sorter: true,
+    width: 80,
+  },
+  {
+    title: '结束时间',
+    dataIndex: 'endTime',
+    sorter: true,
+    width: 80,
+  },
+];
+
+export const searchFormSchema: FormSchema[] = [
+  {
+    field: 'fieldTime',
+    component: 'RangePicker',
+    label: '创建时间',
+    componentProps: {
+      valueType: 'Date',
+    },
+    colProps: {
+      span: 4,
+    },
+  },
+];

+ 3 - 8
src/views/vent/home/configurable/components/ModuleMine.vue

@@ -104,15 +104,10 @@ function redirectTo() {
 watch(
   () => props.data,
   (d) => {
-    const currentSelectedDeviceID = selectedDeviceID.value; // 保存当前选中的设备ID
     init(d);
-    // 检查当前选中的设备ID是否还在选项中,如果在则保持选中状态
-    const optionExists = options.value.some(option => option.value === currentSelectedDeviceID);
-    if (optionExists) {
-      selectedDeviceID.value = currentSelectedDeviceID;
-    } else {
-      selectedDeviceID.value = options.value[0]?.value;
-    }
+      if (!selectedDeviceID.value) {
+        selectedDeviceID.value = options.value[0]?.value;
+      }
   },
   {
     immediate: true,

+ 0 - 327
src/views/vent/home/configurable/configurable.api.ts

@@ -1,327 +0,0 @@
-import { floor, isArray, random, slice } from 'lodash-es';
-import { defHttp } from '/@/utils/http/axios';
-import { get } from '../billboard/utils';
-
-enum Api {
-  list = '/safety/ventanalyDevice/homedata2',
-  getHomeData = '/safety/ventanalyDevice/homedata',
-  getDisHome = '/monitor/disaster/getDisHome',
-  getBDDustData = '/monitor/disaster/getDisDustHome',
-  getBDFireData = '/monitor/disaster/getDisFireHome',
-}
-
-// 搞这个缓存是由于:目前代码上的设计是多个模块发出多次请求,每个模块自己负责消费前者的响应。
-// 这会导致相同的请求被同时发送多次。
-const cache = new Map<string, Promise<any>>();
-
-/**
- * 列表接口,5.5专用,和6.0的getHomeData基本一致
- * @param params
- */
-export const list = (params) => {
-  const key = `${Api.list}?${JSON.stringify(params)}`;
-  if (!cache.has(key)) {
-    cache.set(
-      key,
-      defHttp.post({ url: Api.list, params }).finally(() => {
-        cache.delete(key);
-      })
-    );
-  }
-  return (cache.get(key) as Promise<any>).then((res) => {
-    if (res.fanmain) {
-      // 处理频率字段,为了兼容旧版保留,现配置项已支持一级动态字段
-      res.fanmain.forEach((e) => {
-        if (e.readData.Fan2StartStatus === '1') {
-          e.current = '二号';
-          e.readData.FanFreqHz = e.readData.Fan2FreqHz;
-        } else {
-          e.current = '一号';
-          e.readData.FanFreqHz = e.readData.Fan1FreqHz;
-        }
-      });
-    }
-    if (res.fanlocal) {
-      res.fanlocal.forEach((e) => {
-        e.chartData = [
-          {
-            x: '吸风量',
-            yRealtime: e.readData.windQuantity1,
-            yMock: floor(parseFloat(e.inletAirVolume_merge) * random(0.98, 1, false), 2),
-            y: e.inletAirVolume_merge,
-          },
-          {
-            x: '供风量',
-            yRealtime: e.readData.windQuantity2,
-            yMock: floor(parseFloat(e.ductOutletAirVolume_merge) * random(0.98, 1, false), 2),
-            y: e.ductOutletAirVolume_merge,
-          },
-        ];
-        if (e.readData.Fan2StartStatus === '1') {
-          e.current = '二号';
-          e.readData.FanfHz = e.readData.Fan2fHz;
-        } else {
-          e.current = '一号';
-          e.readData.FanfHz = e.readData.Fan1fHz;
-        }
-      });
-    }
-    if (res.sys_majorpath) {
-      res.sys_majorpath.forEach((e) => {
-        const { drag_1, drag_2, drag_3, drag_total } = e.majorpath;
-        const { fy_merge = { value: '1' } } = e.readData;
-        const drag_merge = parseInt(fy_merge.value);
-        // const m3_merge = parseInt(retM3_merge.value);
-
-        e.piechart = [
-          { val: drag_1, valMock: floor((drag_1 / drag_total) * drag_merge), label: '进风区(Pa)' },
-          { val: drag_2, valMock: floor((drag_2 / drag_total) * drag_merge), label: '用风区(Pa)' },
-          { val: drag_3, valMock: floor((drag_3 / drag_total) * drag_merge), label: '回风区(Pa)' },
-        ];
-        e.readData.dengjikong_merge = get(res, 'midinfo[0].sysinfo.equalarea');
-        e.readData.fy_merge_int = drag_merge;
-        // e.dengjikong_merge = floor((1.19 * (m3_merge / 60)) / Math.sqrt(drag_merge), 2);
-      });
-    }
-    if (res.sys_surface_caimei) {
-      res.sys_surface_caimei.forEach((e) => {
-        if (isArray(e.history)) {
-          e.history = slice(e.history, e.history.length - 30, e.history.length);
-        }
-        if (isArray(e.history_report)) {
-          e.history_report = slice(e.history_report, e.history_report.length - 30, e.history_report.length);
-        }
-      });
-    }
-    if (res.device_arr) {
-      res.device_arr = Object.values(res.device);
-    }
-    if (res.sys_wind) {
-      res.sys_wind.forEach((e) => {
-        if (e.readData.m3) {
-          e.readData.m3 = e.readData.m3.replace('-', '');
-        }
-        if (e.readData.va) {
-          e.readData.va = e.readData.va.replace('-', '');
-        }
-      });
-    }
-    if (res.windrect) {
-      res.windrect.forEach((e) => {
-        if (e.readData.m3) {
-          e.readData.m3 = e.readData.m3.replace('-', '');
-        }
-        if (e.readData.va) {
-          e.readData.va = e.readData.va.replace('-', '');
-        }
-      });
-    }
-
-    return res;
-  });
-};
-
-export const getHomeData = (params) => {
-  const key = `${Api.getHomeData}?${JSON.stringify(params)}`;
-  if (!cache.has(key)) {
-    cache.set(
-      key,
-      defHttp.post({ url: Api.getHomeData, params }).finally(() => {
-        cache.delete(key);
-      })
-    );
-  }
-  return (cache.get(key) as Promise<any>).then((res) => {
-    res.fanmain.forEach((e) => {
-      if (e.readData.Fan2StartStatus === '1') {
-        e.current = '二号';
-        e.readData.FanFreqHz = e.readData.Fan2FreqHz;
-      } else {
-        e.current = '一号';
-        e.readData.FanFreqHz = e.readData.Fan1FreqHz;
-      }
-    });
-    res.fanlocal.forEach((e) => {
-      e.chartData = [
-        {
-          x: '吸风量',
-          y: e.readData.windQuantity1,
-        },
-        {
-          x: '供风量',
-          y: e.readData.windQuantity2,
-        },
-      ];
-      if (e.readData.Fan2StartStatus === '1') {
-        e.current = '二号';
-        e.readData.FanfHz = e.readData.Fan2fHz;
-      } else {
-        e.current = '一号';
-        e.readData.FanfHz = e.readData.Fan1fHz;
-      }
-    });
-    res.sys_majorpath.forEach((e) => {
-      e.piechart = [
-        { val: e.majorpath.drag_1, label: '进风区' },
-        { val: e.majorpath.drag_2, label: '用风区' },
-        { val: e.majorpath.drag_3, label: '回风区' },
-      ];
-    });
-
-    return res;
-  });
-};
-
-export const getBDDustData = (params) => {
-  const key = `${Api.getBDDustData}?${JSON.stringify(params)}`;
-  if (!cache.has(key)) {
-    cache.set(
-      key,
-      defHttp.post({ url: Api.getBDDustData, params }).finally(() => {
-        cache.delete(key);
-      })
-    );
-  }
-  return cache.get(key) as Promise<any>;
-};
-
-export const getBDFireData = (params) => {
-  const key = `${Api.getBDFireData}?${JSON.stringify(params)}`;
-  if (!cache.has(key)) {
-    cache.set(
-      key,
-      defHttp.post({ url: Api.getBDFireData, params }).finally(() => {
-        cache.delete(key);
-      })
-    );
-  }
-  return (cache.get(key) as Promise<any>).then((res) => {
-    res.pdArray.forEach((e) => {
-      e.arrayFiber.forEach((j) => {
-        j.fibreTemperatureArr = JSON.parse(j.fibreTemperature);
-      });
-    });
-    res.sgGxObj.devGxcw.forEach((e) => {
-      e.fibreTemperatureArr = JSON.parse(e.fibreTemperature);
-    });
-    res.sgGxObj.devSgjc.forEach((e) => {
-      e.o2val = e.o2Val || 0;
-      e.coval = e.coVal || 0;
-      e.gasval = e.gasVal || 0;
-      e.ch2val = e.ch2Val || 0;
-      e.chval = e.chVal || 0;
-    });
-    return res;
-  });
-};
-
-export const getDisHome = (params) => {
-  const key = `${Api.getDisHome}?${JSON.stringify(params)}`;
-  if (!cache.has(key)) {
-    cache.set(
-      key,
-      defHttp.post({ url: Api.getDisHome, params }).finally(() => {
-        cache.delete(key);
-      })
-    );
-  }
-  return (cache.get(key) as Promise<any>).then((res) => {
-    if (res.pdArray) {
-      res.pdArray.forEach((e) => {
-        e.arrayFiber.forEach((j) => {
-          j.fibreTemperatureArr = JSON.parse(j.fibreTemperature);
-        });
-      });
-    }
-    if (res.sgGxObj) {
-      res.sgGxObj.devGxcw.forEach((e) => {
-        e.fibreTemperatureArr = JSON.parse(e.fibreTemperature);
-      });
-      res.sgGxObj.devSgjc.forEach((e) => {
-        e.o2val = e.o2Val || 0;
-        e.coval = e.coVal || 0;
-        e.gasval = e.gasVal || 0;
-        e.ch2val = e.ch2Val || 0;
-        e.chval = e.chVal || 0;
-      });
-    }
-    if (res.obfObj) {
-      res.obfObj.obfObjModded = [
-        {
-          objType: '氧气',
-          arrayDev: res.obfObj.arrayDev.map((e) => {
-            return {
-              strinstallpos: e.strinstallpos,
-              val: e.o2Val || 0,
-            };
-          }),
-        },
-        {
-          objType: '甲烷',
-          arrayDev: res.obfObj.arrayDev.map((e) => {
-            return {
-              strinstallpos: e.strinstallpos,
-              val: e.ch4Val || 0,
-            };
-          }),
-        },
-        {
-          objType: '一氧化碳',
-          arrayDev: res.obfObj.arrayDev.map((e) => {
-            return {
-              strinstallpos: e.strinstallpos,
-              val: e.coVal || 0,
-            };
-          }),
-        },
-        {
-          objType: '乙炔',
-          arrayDev: res.obfObj.arrayDev.map((e) => {
-            return {
-              strinstallpos: e.strinstallpos,
-              val: e.c2h2Val || 0,
-            };
-          }),
-        },
-        {
-          objType: '二氧化碳',
-          arrayDev: res.obfObj.arrayDev.map((e) => {
-            return {
-              strinstallpos: e.strinstallpos,
-              val: e.co2Val || 0,
-            };
-          }),
-        },
-
-        {
-          objType: '乙烯',
-          arrayDev: res.obfObj.arrayDev.map((e) => {
-            return {
-              strinstallpos: e.strinstallpos,
-              val: e.c2h4Val || 0,
-            };
-          }),
-        },
-        {
-          objType: '压差',
-          arrayDev: res.obfObj.arrayDev.map((e) => {
-            return {
-              strinstallpos: e.strinstallpos,
-              val: e.dpVal || 0,
-            };
-          }),
-        },
-        {
-          objType: '温度',
-          arrayDev: res.obfObj.arrayDev.map((e) => {
-            return {
-              strinstallpos: e.strinstallpos,
-              val: e.tempVal || 0,
-            };
-          }),
-        },
-      ];
-    }
-    return res;
-  });
-};

+ 2 - 8
src/views/vent/home/configurable/hooks/useInit.ts

@@ -294,7 +294,6 @@ export function useInitPage(title: string) {
   const mainTitle = ref(title);
   const enhancedConfigs = ref<EnhancedConfig[]>([]);
   const data = ref<any>({});
-  const oldData = ref<any>({}); // 用于存储旧数据
 
   const hiddenList = computed(() => {
     return enhancedConfigs.value.filter((e) => e.visible === false);
@@ -309,13 +308,8 @@ export function useInitPage(title: string) {
     });
   }
 
-  function updateData(newData: any) {
-    const isDataChanged = JSON.stringify(newData) !== JSON.stringify(oldData.value);
-    // 有数据更改时才进行数据更新
-    if (isDataChanged) {
-      data.value = newData;
-      oldData.value = newData;
-    }
+  function updateData(d: any) {
+    data.value = d;
   }
 
   return {

+ 181 - 0
src/views/vent/home/configurable/modelAirDoorCSS.vue

@@ -0,0 +1,181 @@
+<template>
+  <div class="air-door-container" @click="toggleDoor">
+    <div class="tunnel">
+      <!-- 巷道顶部 -->
+      <div class="tunnel-top"></div>
+      
+      <!-- 巷道底部 -->
+      <div class="tunnel-bottom"></div>
+      
+      <!-- 门框 -->
+      <div class="door-frame"></div>
+      
+      <!-- 左右两扇门 -->
+      <div class="doors-container">
+        <!-- 左门 (向内开) -->
+        <div 
+          class="door left-door"
+          :class="{ 'open': isOpen }"
+        ></div>
+        
+        <!-- 右门 (向外开) -->
+        <div 
+          class="door right-door"
+          :class="{ 'open': isOpen }"
+        ></div>
+      </div>
+    </div>
+    <div class="status">
+      {{ isOpen ? '风门开启' : '风门关闭' }}
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+
+const isOpen = ref(false);
+
+const toggleDoor = () => {
+  isOpen.value = !isOpen.value;
+};
+</script>
+
+<style scoped>
+.air-door-container {
+  position: relative;
+  width: 800px;
+  height: 400px;
+  margin: 0 auto;
+  perspective: 1000px;
+}
+
+.tunnel {
+  position: relative;
+  width: 100%;
+  height: 100%;
+  transform-style: preserve-3d;
+}
+
+.tunnel-top {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 120px;
+  background: linear-gradient(to right, #222, #444, #222);
+  border-radius: 400px 400px 0 0 / 60px 60px 0 0;
+}
+
+.tunnel-bottom {
+  position: absolute;
+  top: 120px;
+  left: 0;
+  width: 100%;
+  height: 300px;
+  /* background-image: linear-gradient(45deg, #333 25%, #444 25%, #444 50%, #333 50%, #333 75%, #444 75%, #444 100%); */
+  background-size: 40px 40px;
+}
+
+.door-frame {
+  position: absolute;
+  top: 120px;
+  left: 0;
+  width: 100%;
+  height: 300px;
+  border: 8px solid #8B4513;
+  border-radius: 0 0 20px 20px / 0 0 10px 10px;
+  box-sizing: border-box;
+  z-index: 10;
+}
+
+.doors-container {
+  position: absolute;
+  top: 120px;
+  left:0;
+  width: 100%;
+  height: 300px;
+  transform-style: preserve-3d;
+  z-index: 20;
+}
+
+.door {
+  position: absolute;
+  top: 8px;
+  width: calc(50% - 8px);
+  height: calc(100% - 16px);
+  background-color: #A9A9A9;
+  border: 2px solid #696969;
+  box-sizing: border-box;
+  transition: transform 2s ease;
+  transform-origin: left center;
+  transform-style: preserve-3d;
+}
+
+.left-door {
+  left: 8px;
+  /* border-radius: 0 0 0 10px / 0 0 0 5px; */
+}
+
+.right-door {
+  right: 8px;
+  /* border-radius: 0 0 10px 0 / 0 0 5px 0; */
+  transform-origin: right center;
+}
+
+.door.open.left-door {
+  transform: rotateY(-90deg);
+}
+
+.door.open.right-door {
+  transform: rotateY(-90deg);
+}
+
+/* 门的纹理 */
+.door::before,
+.door::after {
+  content: '';
+  position: absolute;
+  background-color: #696969;
+}
+
+.left-door::before {
+  top: 10px;
+  left: 37px;
+  width: 1px;
+  height: 100px;
+}
+
+.left-door::after {
+  top: 40px;
+  left: 10px;
+  width: 130px;
+  height: 1px;
+}
+
+.right-door::before {
+  top: 10px;
+  right: 37px;
+  width: 1px;
+  height: 100px;
+}
+
+.right-door::after {
+  top: 40px;
+  right: 10px;
+  width: 130px;
+  height: 1px;
+}
+
+.status {
+  position: absolute;
+  top: 10px;
+  left: 0;
+  width: 100%;
+  text-align: center;
+  font-size: 18px;
+  font-weight: bold;
+  color: #fff;
+  text-shadow: 1px 1px 2px rgba(0,0,0,0.5);
+}
+</style>    

+ 166 - 0
src/views/vent/home/configurable/modelAirDoorSVG.vue

@@ -0,0 +1,166 @@
+<template>
+  <div class="air-door-container" @click="toggleDoor">
+    <!-- 添加3D变换专用包装器 -->
+    <div class="svg-3d-wrapper">
+      <svg viewBox="0 0 800 400" width="800" height="400">
+        <!-- 定义渐变和图案 -->
+        <defs>
+          <linearGradient id="tunnelGradient" x1="0%" y1="0%" x2="100%" y2="0%">
+            <stop offset="0%" stop-color="#222" />
+            <stop offset="50%" stop-color="#444" />
+            <stop offset="100%" stop-color="#222" />
+          </linearGradient>
+          <pattern id="tunnelBottomPattern" width="40" height="40" patternUnits="userSpaceOnUse">
+            <!-- <rect width="40" height="40" fill="#333" fill-opacity="0.8" /> -->
+          </pattern>
+        </defs>
+        
+        <!-- 巷道顶部 - 只有左上和右上圆角 -->
+        <path d="M0,20 A20,20 0 0 1 20,0 H780 A20,20 0 0 1 800,20 V120 H0 Z" 
+              fill="url(#tunnelGradient)" />
+        
+        <!-- 巷道底部 -->
+        <rect x="0" y="120" width="800" height="300" fill="url(#tunnelBottomPattern)" />
+        
+        <!-- 门框 -->
+        <rect x="8" y="128" width="784" height="272" rx="20" ry="10" fill="none" stroke="#8B4513" stroke-width="8" />
+        
+        <!-- 左门 -->
+        <g 
+          :class="{ 'left-door-open': isOpen, 'left-door-closed': !isOpen }"
+          transform-origin="left center"
+        >
+          <rect 
+            x="16" 
+            y="136" 
+            width="384" 
+            height="256" 
+
+            fill="#A9A9A9" 
+            stroke="#696969" 
+            stroke-width="2"
+          />
+          <line x1="200" y1="146" x2="200" y2="392" stroke="#696969" stroke-width="1" />
+          <line x1="16" y1="264" x2="399" y2="264" stroke="#696969" stroke-width="1" />
+        </g>
+        
+        <!-- 右门 -->
+        <g 
+          :class="{ 'right-door-open': isOpen, 'right-door-closed': !isOpen }"
+          transform-origin="right center"
+        >
+          <rect 
+            x="400" 
+            y="136" 
+            width="384" 
+            height="256" 
+            fill="#A9A9A9" 
+            stroke="#696969" 
+            stroke-width="2"
+          />
+          <line x1="584" y1="146" x2="584" y2="392" stroke="#696969" stroke-width="1" />
+          <line x1="400" y1="264" x2="783" y2="264" stroke="#696969" stroke-width="1" />
+        </g>
+      </svg>
+    </div>
+    <div class="status">
+      {{ isOpen ? '风门开启' : '风门关闭' }}
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+
+const isOpen = ref(false);
+
+const toggleDoor = () => {
+  isOpen.value = !isOpen.value;
+};
+</script>
+
+<style scoped>
+.air-door-container {
+  position: relative;
+  width: 800px;
+  height: 400px;
+  margin: 0 auto;
+}
+
+.svg-3d-wrapper {
+  width: 100%;
+  height: 100%;
+  perspective: 800px;  /* 透视距离 */
+  transform-style: preserve-3d;
+}
+
+svg {
+  display: block;
+  overflow: visible;  /* 确保旋转内容不被裁剪 */
+  transform-style: preserve-3d;
+}
+
+/* 门元素3D优化 */
+g {
+  transform-box: fill-box;
+  backface-visibility: visible;
+  will-change: transform;  /* 性能优化 */
+}
+
+/* 左门打开动画 */
+@keyframes leftDoorOpen {
+  from { transform: translateZ(1px) rotateY(0deg); }
+  to { transform: translateZ(1px) rotateY(-75deg); }
+}
+
+/* 左门关闭动画 */
+@keyframes leftDoorClose {
+  from { transform: translateZ(1px) rotateY(-75deg); }
+  to { transform: translateZ(1px) rotateY(0deg); }
+}
+
+/* 右门打开动画 */
+@keyframes rightDoorOpen {
+  from { transform: translateZ(1px) rotateY(0deg); }
+  to { transform: translateZ(1px) rotateY(-75deg); }
+}
+
+/* 右门关闭动画 */
+@keyframes rightDoorClose {
+  from { transform: translateZ(1px) rotateY(-75deg); }
+  to { transform: translateZ(1px) rotateY(0deg); }
+}
+
+/* 左门打开状态 */
+.left-door-open {
+  animation: leftDoorOpen 2s cubic-bezier(0.22, 1, 0.36, 1) forwards;
+}
+
+/* 左门关闭状态 */
+.left-door-closed {
+  animation: leftDoorClose 2s cubic-bezier(0.22, 1, 0.36, 1) forwards;
+}
+
+/* 右门打开状态 */
+.right-door-open {
+  animation: rightDoorOpen 2s cubic-bezier(0.22, 1, 0.36, 1) forwards;
+}
+
+/* 右门关闭状态 */
+.right-door-closed {
+  animation: rightDoorClose 2s cubic-bezier(0.22, 1, 0.36, 1) forwards;
+}
+
+.status {
+  position: absolute;
+  top: 10px;
+  left: 0;
+  width: 100%;
+  text-align: center;
+  font-size: 18px;
+  font-weight: bold;
+  color: #fff;
+  text-shadow: 1px 1px 2px rgba(0,0,0,0.5);
+  pointer-events: none;  /* 防止遮挡点击事件 */
+}
+</style>