Преглед изворни кода

[Feat 0000] 提交新增文件

hongrunxia пре 15 часа
родитељ
комит
26605a988b

+ 127 - 0
src/views/vent/monitorManager/fanLocalMonitor/components/dischargeGas/EventLogPanel.vue

@@ -0,0 +1,127 @@
+<template>
+  <div class="event-log-panel" ref="scrollContainer">
+    <template v-for="group in groupedEvents" :key="group.key">
+      <!-- 独立条目(非调频类) -->
+      <div v-if="group.type === 'item'" class="log-entry" :style="{ color: eventColorMap[group.entry!.eventType] }">
+        <span class="log-time">{{ formatTime(group.entry!) }}</span>
+        <span class="log-type">{{ eventTypeLabel[group.entry!.eventType] }}</span>
+        <span class="log-msg">{{ group.entry!.message }}</span>
+      </div>
+      <!-- 折叠组:仅含 frequency_adjust,头部与普通条目风格一致 -->
+      <div v-else class="log-group">
+        <div class="log-entry group-head" @click="toggleGroup(group.key)">
+          <span class="log-time"></span>
+          <span class="log-type">调频</span>
+          <span class="log-msg">进入{{ group.label }} ({{ group.entries?.length ?? 0 }}步)</span>
+          <span class="group-arrow">{{ collapsed.has(group.key) ? '▶' : '▼' }}</span>
+        </div>
+        <template v-if="!collapsed.has(group.key) && group.entries">
+          <div v-for="e in group.entries" :key="e.id" class="log-entry sub" :style="{ color: eventColorMap[e.eventType] }">
+            <span class="log-time">{{ formatTime(e) }}</span>
+            <span class="log-msg">{{ e.message }}</span>
+          </div>
+        </template>
+      </div>
+    </template>
+    <div v-if="events.length === 0" class="log-empty">暂无事件</div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref, computed, watch, nextTick, reactive } from 'vue';
+  import type { GasDischargeEventLogEntry } from '../../fanLocal.data';
+
+  const props = defineProps<{ events: GasDischargeEventLogEntry[] }>();
+  const scrollContainer = ref<HTMLElement>();
+  const collapsed = reactive(new Set<string>());
+
+  const eventColorMap: Record<string, string> = {
+    info: '#ffffffcc', warning: '#faad14',
+    state_change: '#00ddff', frequency_adjust: '#ae19ff', speed_limit: '#faad14',
+  };
+  const eventTypeLabel: Record<string, string> = {
+    info: '信息', warning: '预警',
+    state_change: '状态', frequency_adjust: '调频', speed_limit: '限速',
+  };
+
+  /** 从 realTime (YYYY-MM-DD HH:MM:SS) 提取 HH:MM:SS */
+  function formatTime(entry: GasDischargeEventLogEntry): string {
+    const t = entry.realTime || '';
+    return t.length >= 19 ? t.substring(11, 19) : t.substring(11);
+  }
+
+  type EventGroup = { key: string; type: 'item'; entry: GasDischargeEventLogEntry }
+                  | { key: string; type: 'group'; label: string; entries: GasDischargeEventLogEntry[] };
+
+  const groupedEvents = computed<EventGroup[]>(() => {
+    const groups: EventGroup[] = [];
+    let freqGroup: GasDischargeEventLogEntry[] | null = null;
+    let groupLabel = '';
+    let gid = 0;
+
+    for (const e of props.events) {
+      // 排放中 → 开始收集调频条目
+      if (e.eventType === 'state_change' && e.message.includes('→ 排放中')) {
+        groups.push({ key: `_${gid++}`, type: 'item', entry: e });
+        // 不在这里开始新组,让 RUNNING 内的第一条 frequency_adjust 触发开始
+        continue;
+      }
+
+      // 进入降频 → 关闭上一组, 开始新组, 状态条目独立显示
+      if (e.eventType === 'state_change' && e.message.includes('→ 降频收尾')) {
+        if (freqGroup && freqGroup.length > 0) { groups.push({ key: `g${gid++}`, type: 'group', label: groupLabel, entries: [...freqGroup] }); freqGroup = null; }
+        freqGroup = [];
+        groupLabel = '降频阶段';
+        groups.push({ key: `_${gid++}`, type: 'item', entry: e });
+        continue;
+      }
+
+      // 降频结束 → 关闭组,状态条目独立显示
+      if (e.eventType === 'state_change' && (e.message.includes('降频收尾 →') || e.message.includes('→ 排放完成'))) {
+        if (freqGroup && freqGroup.length > 0) { groups.push({ key: `g${gid++}`, type: 'group', label: groupLabel, entries: [...freqGroup] }); freqGroup = null; }
+        groups.push({ key: `_${gid++}`, type: 'item', entry: e });
+        continue;
+      }
+
+      // frequency_adjust 条目 → 放入当前组
+      if (e.eventType === 'frequency_adjust') {
+        if (!freqGroup) { freqGroup = []; groupLabel = e.message.includes('降频') ? '降频阶段' : '调频阶段'; }
+        freqGroup.push(e);
+        continue;
+      }
+
+      // info / warning / speed_limit → 独立显示
+      groups.push({ key: `_${gid++}`, type: 'item', entry: e });
+    }
+
+    if (freqGroup && freqGroup.length > 0) { groups.push({ key: `g${gid++}`, type: 'group', label: groupLabel, entries: [...freqGroup] }); }
+    return groups;
+  });
+
+  function toggleGroup(key: string) {
+    if (collapsed.has(key)) collapsed.delete(key); else collapsed.add(key);
+  }
+
+  watch(() => props.events.length, () => nextTick(() => { if (scrollContainer.value) scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight; }));
+</script>
+
+<style scoped lang="less">
+  .event-log-panel { max-height: 450px; overflow-y: auto; font-size: 12px; line-height: 1.6;
+    &::-webkit-scrollbar { width: 4px; } &::-webkit-scrollbar-thumb { background: #00d8ff44; border-radius: 2px; } }
+
+  .log-entry { display: flex; gap: 4px; padding: 2px 6px; border-bottom: 1px solid #ffffff0a; align-items: baseline;
+    &.sub { padding-left: 22px; }
+    &.group-head { cursor: pointer; user-select: none; border-bottom: 1px solid #00d8ff33;
+      &:hover { background: #ffffff08; }
+      .log-type { color: #00ddff; }
+      .log-msg { color: #00ddff; font-weight: bold; }
+    }
+    .log-time { color: #ffffff66; min-width: 38px; flex-shrink: 0; }
+    .log-type { min-width: 28px; flex-shrink: 0; font-weight: bold; font-size: 11px; }
+    .log-msg { flex: 1; word-break: break-all; }
+  }
+
+  .group-arrow { font-size: 10px; color: #ffffff88; flex-shrink: 0; }
+
+  .log-empty { color: #ffffff44; text-align: center; padding: 20px 0; }
+</style>

+ 138 - 0
src/views/vent/monitorManager/fanLocalMonitor/components/dischargeGas/GasGaugeDial.vue

@@ -0,0 +1,138 @@
+<template>
+  <div ref="chartRef" :style="{ width: '100%', height: height }"></div>
+</template>
+
+<script lang="ts" setup>
+  import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
+  import echarts from '/@/utils/lib/echarts';
+
+  const props = defineProps<{
+    value: number;
+    maxValue: number;
+    title: string;
+    unit?: string;
+    warningThreshold: number;
+    dangerThreshold: number;
+    height?: string;
+  }>();
+
+  const chartRef = ref<HTMLElement>();
+  let chartInstance: echarts.ECharts | null = null;
+
+  const gaugeColor = computed(() => {
+    if (props.value >= props.dangerThreshold) return '#ff4d4f';
+    if (props.value >= props.warningThreshold) return '#faad14';
+    return '#2ecc71';
+  });
+
+  const option = computed(() => ({
+    series: [
+      {
+        type: 'gauge',
+        radius: '95%',
+        center: ['50%', '54%'],
+        startAngle: 210,
+        endAngle: -30,
+        min: 0,
+        max: props.maxValue,
+        splitNumber: props.maxValue === 4 ? 8 : 6,
+        progress: {
+          show: true,
+          width: 16,
+          roundCap: true,
+          itemStyle: {
+            color: gaugeColor.value,
+            shadowColor: gaugeColor.value,
+            shadowBlur: 5,
+          },
+        },
+        axisLine: {
+          lineStyle: {
+            width: 16,
+            color: [
+              [props.warningThreshold / props.maxValue, '#1a5c2a'], // 安全区深绿
+              [props.dangerThreshold / props.maxValue, '#5c4a00'], // 预警区深棕
+              [1, '#5c1a1a'], // 危险区深红
+            ],
+          },
+        },
+        axisTick: { show: false },
+        splitLine: {
+          length: 12,
+          distance: -4,
+          lineStyle: { width: 2, color: '#ffffff22', cap: 'round' as const },
+        },
+        axisLabel: {
+          distance: 22,
+          color: '#ffffff88',
+          fontSize: 10,
+          fontFamily: 'monospace',
+          formatter: (val: number) => val.toFixed(1),
+        },
+        pointer: {
+          icon: 'path://M4,0 L4,24 A2,2 0 0,1 2,26 A1,1 0 0,1 0,26 L0,0 Z',
+          length: '60%',
+          width: 6,
+          itemStyle: {
+            color: '#e0e0e0',
+            shadowColor: '#00000066',
+            shadowBlur: 4,
+          },
+        },
+        anchor: {
+          show: true,
+          showAbove: true,
+          size: 16,
+          itemStyle: {
+            borderWidth: 3,
+            borderColor: '#00ddff',
+            color: '#1a1a2e',
+            shadowColor: '#00ddff66',
+            shadowBlur: 3,
+          },
+        },
+        title: {
+          offsetCenter: [0, '89%'],
+          color: '#cccccc',
+          fontSize: 14,
+          fontFamily: 'sans-serif',
+          fontWeight: '800',
+        },
+        detail: {
+          valueAnimation: true,
+          fontSize: 26,
+          fontFamily: 'monospace',
+          fontWeight: 'bold',
+          color: gaugeColor.value,
+          offsetCenter: [0, '52%'],
+          formatter: `{value}${props.unit || '%'}`,
+          textShadowColor: gaugeColor.value,
+          textShadowBlur: 3,
+        },
+        data: [{ value: Number(props.value.toFixed(2)), name: props.title }],
+      },
+    ],
+  }));
+
+  onMounted(() => {
+    if (chartRef.value) {
+      chartInstance = echarts.init(chartRef.value, undefined, { renderer: 'canvas' });
+      chartInstance.setOption(option.value);
+      window.addEventListener('resize', handleResize);
+    }
+  });
+
+  watch(option, (newOpt) => {
+    chartInstance?.setOption(newOpt, true);
+  });
+
+  function handleResize() {
+    chartInstance?.resize();
+  }
+
+  onUnmounted(() => {
+    window.removeEventListener('resize', handleResize);
+    chartInstance?.dispose();
+    chartInstance = null;
+  });
+</script>

+ 284 - 0
src/views/vent/monitorManager/fanLocalMonitor/components/dischargeGas/GasTrendChart.vue

@@ -0,0 +1,284 @@
+<template>
+  <div class="trend-charts" :style="{ minHeight: minH }">
+    <!-- 瓦斯浓度趋势 -->
+    <div class="chart-section">
+      <div class="chart-title">瓦斯浓度趋势</div>
+      <div ref="gasChartRef" class="chart-body"></div>
+    </div>
+    <!-- 风量-频率趋势 -->
+    <div class="chart-section" style="margin-top: 20px">
+      <div class="chart-title">风量-频率趋势</div>
+      <div ref="freqChartRef" class="chart-body"></div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref, watch, onMounted, onUnmounted, computed } from 'vue';
+  import echarts from '/@/utils/lib/echarts';
+  import type { GasDischargeSimDataPoint } from '../../fanLocal.data';
+
+  const props = defineProps<{
+    dataSource: GasDischargeSimDataPoint[];
+    height?: string;
+  }>();
+
+  const gasChartRef = ref<HTMLElement>();
+  const freqChartRef = ref<HTMLElement>();
+  let gasChart: echarts.ECharts | null = null;
+  let freqChart: echarts.ECharts | null = null;
+  let initialised = false;
+
+  /** 最小高度:父级传入 height 或有默认值 */
+  const minH = computed(() => props.height || '520px');
+
+  /** X轴时间戳:取 data 中 realTime 字段的 HH:MM:SS 部分 */
+  function fmtX(data: GasDischargeSimDataPoint[]): string[] {
+    return data.map((d) => {
+      const t = d.realTime || '';
+      return t.length >= 19 ? t.substring(11, 19) : d.simTime.toString();
+    });
+  }
+
+  /** 瓦斯浓度图表 option */
+  function buildGasOption(data: GasDischargeSimDataPoint[]): echarts.EChartsOption {
+    return {
+      tooltip: { trigger: 'axis', backgroundColor: '#000000cc', borderColor: '#74E9FE44', textStyle: { color: '#fff', fontSize: 11 } },
+      legend: { top: 0, left: 'center', textStyle: { color: '#ffffffcc', fontSize: 10 }, data: ['工作面瓦斯', '回风瓦斯'] },
+      grid: { top: 30, left: 42, right: 26, bottom: 8, containLabel: true },
+      xAxis: {
+        type: 'category',
+        data: fmtX(data),
+        axisLabel: { color: '#f1f1f199', fontSize: 10 },
+        axisLine: { lineStyle: { color: '#006c9d' } },
+        splitLine: { show: true, lineStyle: { color: 'rgba(21,80,126,.3)', type: 'dashed' as const } },
+      },
+      yAxis: {
+        type: 'value',
+        name: '%',
+        position: 'left',
+        min: 0,
+        max: 4,
+        splitNumber: 8,
+        nameTextStyle: { color: '#fff', fontSize: 10 },
+        axisLine: { show: true, lineStyle: { color: '#00FFA8' } },
+        axisLabel: { color: '#ffffffcc', fontSize: 10 },
+        splitLine: { lineStyle: { color: 'rgba(21,80,126,.3)', type: 'dashed' as const } },
+      },
+      series: [
+        {
+          name: '工作面瓦斯',
+          type: 'line',
+          data: data.map((d) => Number(d.gas1.toFixed(3))),
+          showSymbol: false,
+          smooth: true,
+          lineStyle: { width: 1.5 },
+          itemStyle: { color: '#00FFA8' },
+        },
+        {
+          name: '回风瓦斯',
+          type: 'line',
+          data: data.map((d) => Number(d.gas2.toFixed(3))),
+          showSymbol: false,
+          smooth: true,
+          lineStyle: { width: 1.5 },
+          itemStyle: { color: '#FDB146' },
+          // markLine: {
+          //   silent: true,
+          //   symbol: 'none',
+          //   lineStyle: { type: 'dashed' as const, width: 1 },
+          //   label: { fontSize: 9, color: '#fff' },
+          //   data: [
+          //     { yAxis: 0.8, lineStyle: { color: '#faad14' }, label: { formatter: '预警 0.8%' } },
+          //     { yAxis: 1.0, lineStyle: { color: '#ff4d4f' }, label: { formatter: '撤人 1.0%' } },
+          //   ],
+          // },
+        },
+      ],
+    };
+  }
+
+  /** 风量-频率双轴图表 option */
+  function buildFreqOption(data: GasDischargeSimDataPoint[]): echarts.EChartsOption {
+    return {
+      tooltip: { trigger: 'axis', backgroundColor: '#000000cc', borderColor: '#74E9FE44', textStyle: { color: '#fff', fontSize: 11 } },
+      legend: { top: 0, left: 'center', textStyle: { color: '#ffffffcc', fontSize: 10 }, data: ['风量', '频率'] },
+      grid: { top: 30, left: 42, right: 20, bottom: 8, containLabel: true },
+      xAxis: {
+        type: 'category',
+        data: fmtX(data),
+        axisLabel: { color: '#f1f1f199', fontSize: 10 },
+        axisLine: { lineStyle: { color: '#006c9d' } },
+        splitLine: { show: true, lineStyle: { color: 'rgba(21,80,126,.3)', type: 'dashed' as const } },
+      },
+      yAxis: [
+        {
+          type: 'value',
+          name: 'm³/min',
+          position: 'left',
+          min: 0,
+          max: 400,
+          splitNumber: 4,
+          nameTextStyle: { color: '#fff', fontSize: 10 },
+          axisLine: { show: true, lineStyle: { color: '#03C2EC' } },
+          axisLabel: { color: '#ffffffcc', fontSize: 10 },
+          splitLine: { lineStyle: { color: 'rgba(21,80,126,.3)', type: 'dashed' as const } },
+        },
+        {
+          type: 'value',
+          name: 'Hz',
+          position: 'right',
+          min: 0,
+          max: 50,
+          splitNumber: 5,
+          nameTextStyle: { color: '#fff', fontSize: 10 },
+          axisLine: { show: true, lineStyle: { color: '#AE19FF' } },
+          axisLabel: { color: '#ffffffcc', fontSize: 10 },
+          splitLine: { show: false },
+        },
+      ],
+      series: [
+        {
+          name: '风量',
+          type: 'line',
+          yAxisIndex: 0,
+          data: data.map((d) => Number(d.airVolumeM3.toFixed(1))),
+          showSymbol: false,
+          smooth: true,
+          lineStyle: { width: 1.5 },
+          itemStyle: { color: '#03C2EC' },
+          // markLine: {
+          //   silent: true, symbol: 'none',
+          //   lineStyle: { type: 'dashed' as const, width: 1 },
+          //   label: { fontSize: 9, color: '#fff' },
+          //   data: [
+          //     { yAxis: Math.round(maxWindSpeed * 60 * crossSection), lineStyle: { color: '#ff4d4f' }, label: { formatter: `Vmax ${maxWindSpeed}m/s` } },
+          //     { yAxis: Math.round(minWindSpeed * 60 * crossSection), lineStyle: { color: '#faad14' }, label: { formatter: `Vmin ${minWindSpeed}m/s` } },
+          //   ],
+          // },
+        },
+        {
+          name: '频率',
+          type: 'line',
+          yAxisIndex: 1,
+          data: data.map((d) => Number(d.frequencyHz.toFixed(1))),
+          showSymbol: false,
+          smooth: true,
+          lineStyle: { width: 1.5, type: 'dashed' as const },
+          itemStyle: { color: '#AE19FF' },
+        },
+      ],
+    };
+  }
+
+  /** 初始化 echarts — 不检查 clientHeight,init 后再 resize */
+  function initCharts() {
+    if (initialised) return;
+    if (!gasChartRef.value || !freqChartRef.value) return;
+
+    gasChart = echarts.init(gasChartRef.value);
+    freqChart = echarts.init(freqChartRef.value);
+    initialised = true;
+
+    // 立即渲染已有数据
+    if (props.dataSource.length > 0) {
+      gasChart.setOption(buildGasOption(props.dataSource));
+      freqChart.setOption(buildFreqOption(props.dataSource));
+    }
+
+    // 延迟 resize 让 DOM 布局稳定
+    setTimeout(() => {
+      gasChart?.resize();
+      freqChart?.resize();
+    }, 100);
+  }
+
+  function renderCharts(data: GasDischargeSimDataPoint[]) {
+    if (!initialised) return;
+    if (data.length === 0) {
+      gasChart?.clear();
+      freqChart?.clear();
+      return;
+    }
+    gasChart?.setOption(buildGasOption(data), { notMerge: true });
+    freqChart?.setOption(buildFreqOption(data), { notMerge: true });
+  }
+
+  function handleResize() {
+    gasChart?.resize();
+    freqChart?.resize();
+  }
+
+  onMounted(() => {
+    // 延迟确保 ref 挂载 + Modal 过渡完成
+    setTimeout(() => {
+      initCharts();
+      // 再次 resize 确保布局稳定
+      setTimeout(() => handleResize(), 200);
+      window.addEventListener('resize', handleResize);
+    }, 200);
+  });
+
+  // 数据到达时初始化或渲染
+  watch(
+    () => props.dataSource,
+    (newData) => {
+      if (!initialised) {
+        setTimeout(() => initCharts(), 50);
+      }
+      // 初始化后渲染
+      if (initialised) {
+        renderCharts(newData);
+      } else {
+        // 初始化未完成时,等初始化后再渲染
+        const checkInit = setInterval(() => {
+          if (initialised) {
+            clearInterval(checkInit);
+            renderCharts(newData);
+          }
+        }, 100);
+        setTimeout(() => clearInterval(checkInit), 3000); // 3秒超时保护
+      }
+    }
+  );
+
+  onUnmounted(() => {
+    window.removeEventListener('resize', handleResize);
+    gasChart?.dispose();
+    freqChart?.dispose();
+    gasChart = null;
+    freqChart = null;
+    initialised = false;
+  });
+</script>
+
+<style scoped lang="less">
+  .trend-charts {
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+    flex: 1;
+  }
+
+  .chart-section {
+    flex: 1 1 0;
+    display: flex;
+    flex-direction: column;
+    min-height: 120px;
+
+    .chart-title {
+      text-align: center;
+      font-size: 18px;
+      color: #00ddff;
+      font-weight: bold;
+      padding: 1px 0 2px;
+      flex-shrink: 0;
+      margin-bottom: 8px;
+    }
+
+    .chart-body {
+      flex: 1;
+      min-height: 110px;
+    }
+  }
+</style>

+ 42 - 0
src/views/vent/monitorManager/fanLocalMonitor/components/dischargeGas/StatusIndicator.vue

@@ -0,0 +1,42 @@
+<template>
+  <div class="status-indicator">
+    <span class="status-dot" :style="{ backgroundColor: statusColor }"></span>
+    <span class="status-label" :style="{ color: statusColor }">{{ label }}</span>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { computed } from 'vue';
+  import {
+    GasDischargeSimState,
+    GAS_DISCHARGE_STEP_NAMES,
+    GAS_DISCHARGE_STATE_COLOR,
+  } from '../../fanLocal.data';
+
+  const props = defineProps<{
+    simState: GasDischargeSimState;
+  }>();
+
+  const statusColor = computed(() => GAS_DISCHARGE_STATE_COLOR[props.simState] || '#8c8c8c');
+  const label = computed(() => GAS_DISCHARGE_STEP_NAMES[props.simState] || '未知');
+</script>
+
+<style scoped lang="less">
+  .status-indicator {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+
+    .status-dot {
+      width: 12px;
+      height: 12px;
+      border-radius: 50%;
+      box-shadow: 0 0 6px currentColor;
+    }
+
+    .status-label {
+      font-size: 14px;
+      font-weight: bold;
+    }
+  }
+</style>

+ 418 - 0
src/views/vent/monitorManager/fanLocalMonitor/components/dischargeGas/index.vue

@@ -0,0 +1,418 @@
+<template>
+  <BasicModal
+    @register="register"
+    :title="`掘进工作面瓦斯排放(${configForm.baseTime})`"
+    :maskStyle="{ backgroundColor: '#000000aa', backdropFilter: 'blur(3px)' }"
+    width="1600px"
+    style="height: 70%"
+    v-bind="$attrs"
+    :canFullscreen="false"
+    :destroyOnClose="true"
+    :footer="null"
+    :maskClosable="false"
+  >
+    <div class="discharge-gas-sim">
+      <!-- 左栏 -->
+      <div class="column column-left">
+        <div class="panel-title">实时监测</div>
+        <GasGaugeDial
+          :value="currentData.gas1"
+          :maxValue="4"
+          title="工作面瓦斯"
+          :warningThreshold="config.warningThreshold"
+          :dangerThreshold="config.dangerThreshold"
+          height="160px"
+        />
+        <GasGaugeDial
+          :value="currentData.gas2"
+          :maxValue="2"
+          title="回风流瓦斯"
+          :warningThreshold="config.warningThreshold"
+          :dangerThreshold="config.dangerThreshold"
+          height="160px"
+        />
+        <StatusIndicator :simState="simState" />
+
+        <div class="info-row"
+          ><span class="info-label">风机</span><span class="info-value">{{ runningFanPrefix === 'Fan1' ? '1#' : '2#' }}</span></div
+        >
+        <div class="info-row"
+          ><span class="info-label">频率</span><span class="info-value hi">{{ currentData.frequencyHz.toFixed(1) }} Hz</span></div
+        >
+        <div class="info-row"
+          ><span class="info-label">风量</span><span class="info-value hi">{{ currentData.airVolumeM3.toFixed(1) }} m³/min</span></div
+        >
+        <div class="info-row"
+          ><span class="info-label">风速</span
+          ><span class="info-value" :class="{ warn: currentData.windSpeed > 3.8 }">{{ currentData.windSpeed.toFixed(2) }} m/s</span></div
+        >
+        <div class="info-row"
+          ><span class="info-label">CO₂</span
+          ><span class="info-value" :class="{ warn: currentData.co2 > 1.3 }">{{ currentData.co2.toFixed(2) }} %</span></div
+        >
+        <div class="info-row"
+          ><span class="info-label">运行</span><span class="info-value">{{ formatElapsed(elapsedSimSec) }}</span></div
+        >
+
+        <div v-if="sustainedSec > 0" class="sustained-bar">
+          <div class="sl">达标稳定 {{ formatElapsed(sustainedSec) }} / {{ formatElapsed(config.stableDurationSec) }}</div>
+          <div class="st"><div class="sf" :style="{ width: (sustainedSec / config.stableDurationSec) * 100 + '%' }"></div></div>
+        </div>
+      </div>
+
+      <!-- 中栏 -->
+      <div class="column column-center">
+        <div class="chart-area"><GasTrendChart :dataSource="timeSeries" /></div>
+        <div class="control-bar">
+          <template v-if="!props.autoMode">
+            <div class="btn btn-start" @click="handleStart" v-if="simState === GasDischargeSimState.IDLE">开始仿真</div>
+            <div class="btn btn-pause" @click="sim.pause()" v-if="simState === GasDischargeSimState.RUNNING">暂停</div>
+            <div class="btn btn-resume" @click="sim.resume()" v-if="simState === GasDischargeSimState.PAUSED">继续</div>
+            <div class="btn btn-reset" @click="handleReset" v-if="simState !== GasDischargeSimState.IDLE">复位</div>
+          </template>
+
+          <div class="bar-divider"></div>
+          <span class="bar-label">加速</span>
+          <a-radio-group v-model:value="speedValue" size="small" button-style="solid">
+            <a-radio-button :value="1">1x</a-radio-button>
+            <a-radio-button :value="2">2x</a-radio-button>
+            <a-radio-button :value="5">5x</a-radio-button>
+            <a-radio-button :value="10">10x</a-radio-button>
+            <a-radio-button :value="100">100x</a-radio-button>
+            <a-radio-button :value="500">500x</a-radio-button>
+          </a-radio-group>
+        </div>
+      </div>
+
+      <!-- 右栏 -->
+      <div class="column column-right">
+        <div class="warning-banners">
+          <div v-if="props.autoMode" class="wb wl3">🚨 监测到瓦斯超限({{ currentData.gas1.toFixed(1) }}%),已自动开启瓦斯排放</div>
+          <div v-if="currentData.gas2 >= config.warningThreshold && currentData.gas2 < config.dangerThreshold" class="wb wl1"
+            >⚠ 回风瓦斯 {{ currentData.gas2.toFixed(2) }}% ≥ {{ config.warningThreshold }}%,注意监控</div
+          >
+          <div v-if="currentData.gas2 >= config.dangerThreshold" class="wb wl2"
+            >⚠ 回风瓦斯 {{ currentData.gas2.toFixed(2) }}% ≥ {{ config.dangerThreshold }}%!依据《煤矿安全规程》第135条须停工撤人</div
+          >
+          <div v-if="currentData.windSpeed >= 3.8" class="wb wl1">⚠ 风速 {{ currentData.windSpeed.toFixed(2) }} m/s ≥ 3.8m/s,逼近上限</div>
+          <div v-if="currentData.co2 >= 1.3" class="wb wl1">⚠ CO₂ {{ currentData.co2.toFixed(2) }}% ≥ 1.3%,接近限值</div>
+        </div>
+
+        <div class="log-area">
+          <div class="panel-title">事件日志</div>
+          <EventLogPanel :events="eventLog" />
+        </div>
+
+        <div class="config-area" v-if="!props.autoMode">
+          <div class="panel-title clickable" @click="configExpanded = !configExpanded">参数配置 {{ configExpanded ? '▲' : '▼' }}</div>
+          <div v-show="configExpanded" class="config-form">
+            <div class="ci"
+              ><label>工作面初始瓦斯(%)</label><a-input-number v-model:value="configForm.initialGas1" :min="0" :max="4" :step="0.1" size="small"
+            /></div>
+            <div class="ci"
+              ><label>回风初始瓦斯(%)</label><a-input-number v-model:value="configForm.initialGas2" :min="0" :max="2" :step="0.1" size="small"
+            /></div>
+            <div class="ci"
+              ><label>涌出量(m³/s)</label><a-input-number v-model:value="configForm.emissionRate" :min="0.001" :max="1" :step="0.01" size="small"
+            /></div>
+            <div class="ci"
+              ><label>CO₂比率</label><a-input-number v-model:value="configForm.co2EmissionRatio" :min="0.1" :max="1" :step="0.1" size="small"
+            /></div>
+            <div class="ci"
+              ><label>工作面体积(m³)</label><a-input-number v-model:value="configForm.workfaceVolume" :min="10" :max="2000" :step="10" size="small"
+            /></div>
+            <div class="ci"
+              ><label>回风巷长度(m)</label><a-input-number v-model:value="configForm.tunnelLength" :min="10" :max="1000" :step="10" size="small"
+            /></div>
+            <div class="ci"
+              ><label>额定风量(m³/min)</label><a-input-number v-model:value="configForm.ratedAirVolume" :min="50" :max="2000" :step="10" size="small"
+            /></div>
+            <div class="ci"
+              ><label>巷断面积(m²)</label><a-input-number v-model:value="configForm.crossSection" :min="1" :max="20" :step="0.5" size="small"
+            /></div>
+            <div class="ci"
+              ><label>衰减系数</label><a-input-number v-model:value="configForm.decayFactor" :min="0.5" :max="1" :step="0.01" size="small"
+            /></div>
+            <div class="ci"
+              ><label>调频周期(分钟)</label><a-input-number v-model:value="configForm.cycleDurationMin" :min="1" :max="60" :step="1" size="small"
+            /></div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </BasicModal>
+</template>
+
+<script lang="ts" setup>
+  import { ref, reactive, watch } from 'vue';
+  import { BasicModal, useModalInner } from '/@/components/Modal';
+  import { GasDischargeSimState, GAS_DISCHARGE_DEFAULT_CONFIG } from '../../fanLocal.data';
+  import type { GasDischargeConfig } from '../../fanLocal.data';
+  import { useGasDischargeSimulation } from '../../hooks/useGasDischargeSimulation';
+  import GasGaugeDial from './GasGaugeDial.vue';
+  import GasTrendChart from './GasTrendChart.vue';
+  import EventLogPanel from './EventLogPanel.vue';
+  import StatusIndicator from './StatusIndicator.vue';
+
+  const props = defineProps<{ selectData: Record<string, any>; gasMax: number; fanlocalId: number | string; autoMode?: boolean }>();
+  const _emit = defineEmits(['close', 'register', 'openModal']);
+
+  const sim = useGasDischargeSimulation(props.selectData);
+  const { simState, config, currentData, timeSeries, eventLog, elapsedSimSec, sustainedSec, runningFanPrefix, formatElapsed } = sim;
+
+  const speedValue = ref(100);
+  watch(speedValue, (val) => sim.setSpeed(val));
+
+  const configExpanded = ref(false);
+  const configForm = reactive<GasDischargeConfig>({ ...GAS_DISCHARGE_DEFAULT_CONFIG });
+
+  const [register] = useModalInner(() => {
+    sim.abort();
+    speedValue.value = 100;
+    configExpanded.value = false;
+    Object.assign(configForm, { ...GAS_DISCHARGE_DEFAULT_CONFIG });
+    if (props.selectData) {
+      const f1 = props.selectData.Fan1StartStatus == 1;
+      const f2 = props.selectData.Fan2StartStatus == 1;
+      const prefix = f2 && !f1 ? 'Fan2' : 'Fan1';
+      const rf = Number(props.selectData[prefix + 'fHz']);
+      if (!isNaN(rf) && rf > 0) configForm.normalFrequencyHz = rf;
+    }
+    // 自动模式:延迟启动模拟
+    if (props.autoMode) {
+      setTimeout(() => handleStart(), 500);
+    }
+  });
+
+  function handleStart() {
+    sim.updateConfig({ ...configForm });
+    sim.start();
+  }
+  function handleReset() {
+    sim.abort();
+  }
+</script>
+
+<style scoped lang="less">
+  .discharge-gas-sim {
+    display: flex;
+    gap: 10px;
+    height: 100%;
+    color: #ddd;
+  }
+  .column {
+    padding: 8px;
+    border: 1px solid #00d8ff22;
+    background: #ffffff05;
+    border-radius: 4px;
+  }
+  .column-left {
+    width: 260px;
+    display: flex;
+    flex-direction: column;
+    gap: 5px;
+    overflow-y: auto;
+  }
+  .column-center {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    gap: 6px;
+    min-width: 0;
+  }
+  .column-right {
+    width: 350px;
+    display: flex;
+    flex-direction: column;
+    gap: 6px;
+    overflow-y: auto;
+  }
+
+  .panel-title {
+    font-size: 13px;
+    font-weight: bold;
+    color: #0df;
+    padding-bottom: 4px;
+    border-bottom: 1px solid #00d8ff33;
+    margin-bottom: 2px;
+    &.clickable {
+      cursor: pointer;
+      &:hover {
+        color: #5cfaff;
+      }
+    }
+  }
+
+  .chart-area {
+    flex: 1;
+    min-height: 0;
+  }
+
+  .control-bar {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    padding: 5px 10px;
+    border: 1px solid #00d8ff22;
+    border-radius: 4px;
+    font-size: 12px;
+    .bar-label {
+      color: #aaa;
+    }
+    .bar-divider {
+      width: 1px;
+      height: 16px;
+      background: #00d8ff33;
+    }
+  }
+
+  .info-row {
+    display: flex;
+    justify-content: space-between;
+    padding: 1px 4px;
+    font-size: 12px;
+    .info-label {
+      color: #aaa;
+    }
+    .info-value {
+      color: #ccc;
+      &.hi {
+        color: #0df;
+        font-weight: bold;
+        font-size: 13px;
+      }
+      &.warn {
+        color: #faad14;
+        font-weight: bold;
+      }
+    }
+  }
+
+  .sustained-bar {
+    padding: 2px 4px;
+    .sl {
+      font-size: 11px;
+      color: #52c41a;
+    }
+    .st {
+      height: 4px;
+      background: #ffffff15;
+      border-radius: 2px;
+      .sf {
+        height: 100%;
+        background: linear-gradient(90deg, #52c41a, #1890ff);
+        border-radius: 2px;
+        transition: width 0.3s;
+      }
+    }
+  }
+
+  .wb {
+    padding: 4px 8px;
+    border-radius: 3px;
+    margin-bottom: 4px;
+    font-size: 11px;
+    font-weight: bold;
+  }
+  .wl1 {
+    background: rgba(250, 173, 20, 0.12);
+    border: 1px solid #faad1444;
+    color: #faad14;
+  }
+  .wl2 {
+    background: rgba(255, 77, 79, 0.1);
+    border: 1px solid #ff4d4f44;
+    color: #ff7875;
+  }
+  .wl3 {
+    background: rgba(255, 77, 79, 0.15);
+    border: 1px solid #ff4d4f88;
+    color: #ff4d4f;
+    font-size: 13px;
+  }
+
+  .log-area {
+    flex: 1;
+    min-height: 0;
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+    .panel-title {
+      flex-shrink: 0;
+    }
+  }
+
+  .config-area {
+    max-height: 260px;
+    overflow-y: auto;
+    .config-form {
+      display: grid;
+      grid-template-columns: 1fr 1fr;
+      gap: 4px;
+      padding-top: 4px;
+    }
+    .ci {
+      display: flex;
+      flex-direction: column;
+      gap: 1px;
+      label {
+        font-size: 11px;
+        color: #aaa;
+      }
+      :deep(.ant-input-number) {
+        width: 100%;
+        background: transparent;
+        border-color: #00d8ff44;
+        color: #fff;
+      }
+    }
+  }
+
+  .btn {
+    padding: 4px 12px;
+    border-radius: 2px;
+    color: #fff;
+    cursor: pointer;
+    font-size: 12px;
+    position: relative;
+    user-select: none;
+    white-space: nowrap;
+    &::before {
+      position: absolute;
+      content: '';
+      width: calc(100% - 4px);
+      height: calc(100% - 4px);
+      top: 2px;
+      left: 2px;
+      border-radius: 2px;
+      z-index: -1;
+    }
+    &:hover {
+      filter: brightness(1.1);
+    }
+  }
+  .btn-start {
+    border: 1px solid #52c41a;
+    &::before {
+      background: linear-gradient(#52c41a92, #23780492);
+    }
+  }
+  .btn-pause {
+    border: 1px solid #faad14;
+    &::before {
+      background: linear-gradient(#faad1492, #d4880692);
+    }
+  }
+  .btn-resume {
+    border: 1px solid #1890ff;
+    &::before {
+      background: linear-gradient(#1890ff92, #096dd992);
+    }
+  }
+  .btn-reset {
+    border: 1px solid #8c8c8c;
+    &::before {
+      background: linear-gradient(#8c8c8c92, #59595992);
+    }
+  }
+</style>

+ 84 - 0
src/views/vent/monitorManager/fanLocalMonitor/hooks/useGasDischargeHistory.ts

@@ -0,0 +1,84 @@
+import { ref } from 'vue';
+import { GasDischargeSimState } from '../fanLocal.data';
+
+/** 仿真历史记录条目 */
+export interface GasDischargeHistoryEntry {
+  id: number;
+  deviceId: string;
+  deviceName: string;
+  startTime: string;
+  endTime: string;
+  durationSimSec: number;
+  initialGas1: number;
+  initialGas2: number;
+  finalGas1: number;
+  finalGas2: number;
+  endState: GasDischargeSimState;
+  maxGas2: number;
+  eventCount: number;
+}
+
+const STORAGE_KEY = 'gas_discharge_sim_history';
+const MAX_HISTORY = 20;
+
+let nextId = 1;
+
+/**
+ * 瓦斯排放仿真历史记录 composable
+ *
+ * 使用 localStorage 持久化仿真记录,最多保存20条。
+ */
+export function useGasDischargeHistory() {
+  const history = ref<GasDischargeHistoryEntry[]>([]);
+
+  /** 从 localStorage 加载历史 */
+  function loadFromStorage() {
+    try {
+      const raw = localStorage.getItem(STORAGE_KEY);
+      if (raw) {
+        const parsed = JSON.parse(raw);
+        if (Array.isArray(parsed)) {
+          history.value = parsed;
+          nextId = parsed.length > 0 ? Math.max(...parsed.map((h: any) => h.id)) + 1 : 1;
+        }
+      }
+    } catch {
+      history.value = [];
+    }
+  }
+
+  /** 保存到 localStorage */
+  function saveToStorage() {
+    try {
+      localStorage.setItem(STORAGE_KEY, JSON.stringify(history.value.slice(0, MAX_HISTORY)));
+    } catch {
+      // localStorage 可能已满,静默失败
+    }
+  }
+
+  /** 添加一条仿真记录 */
+  function addEntry(entry: Omit<GasDischargeHistoryEntry, 'id'>): number {
+    const id = nextId++;
+    history.value.unshift({ ...entry, id });
+    if (history.value.length > MAX_HISTORY) {
+      history.value.length = MAX_HISTORY;
+    }
+    saveToStorage();
+    return id;
+  }
+
+  /** 清除所有历史 */
+  function clearHistory() {
+    history.value = [];
+    localStorage.removeItem(STORAGE_KEY);
+  }
+
+  // 初始化时加载
+  loadFromStorage();
+
+  return {
+    history,
+    addEntry,
+    clearHistory,
+  };
+}

+ 475 - 0
src/views/vent/monitorManager/fanLocalMonitor/hooks/useGasDischargeSimulation.ts

@@ -0,0 +1,475 @@
+/**
+ * 瓦斯排放仿真 composable
+ *
+ * 核心架构:
+ *   - 纯前端仿真,不修改 selectData,数据完全自包含
+ *   - 以 1 仿真秒为步长连续推进,速度倍率仅控制 setInterval 间隔
+ *   - 仿真时间 = 真实模拟用时(10min/调频周期),速度倍率压缩的是用户等待时间
+ *
+ * 状态机:IDLE → RUNNING → WINDING_DOWN(HOLD → REDUCE)→ COMPLETED
+ *   外加 PAUSED / ABORTED
+ *
+ * 7大业务阶段(日志 marker):
+ *   1.监测异常  2.启动排放  3.计算排放频率  4.调整频率
+ *   5.监测回风流  6.动态调整  7.停止排放
+ */
+import { ref, reactive, computed, watch, onUnmounted } from 'vue';
+import {
+  GasDischargeSimState,
+  GasDischargeConfig,
+  GasDischargeSimDataPoint,
+  GasDischargeEventLogEntry,
+  GAS_DISCHARGE_DEFAULT_CONFIG,
+  GAS_DISCHARGE_STEP_NAMES,
+  lookupFrequencyByGas1,
+} from '../fanLocal.data';
+
+export function useGasDischargeSimulation(selectData: Record<string, any>) {
+  // ======================== 响应式状态(暴露给 UI) ========================
+
+  const simState = ref<GasDischargeSimState>(GasDischargeSimState.IDLE); // 当前仿真阶段
+  const config = reactive<GasDischargeConfig>({ ...GAS_DISCHARGE_DEFAULT_CONFIG }); // 可运行中热修改的参数
+  const speed = ref(5); // 倍速 1/2/5/10,仅影响 setInterval 周期
+  const elapsedSimSec = ref(0); // 已流逝的仿真秒数(1秒/步,不受倍速影响)
+  const sustainedSec = ref(0); // 浓度持续达标的累积秒数
+
+  // 当前瞬时数据:仪表盘 + 实时数值以此为准
+  const currentData = reactive<GasDischargeSimDataPoint>({
+    simTime: 0,
+    realTime: '',
+    gas1: config.initialGas1,
+    gas2: config.initialGas2,
+    gas3: config.initialGas3,
+    co2: config.initialGas2 * config.co2EmissionRatio + 0.04,
+    frequencyHz: config.normalFrequencyHz,
+    airVolumeM3: config.frequencyK * (config.normalFrequencyHz / 50) * config.ratedAirVolume,
+    windSpeed: (config.frequencyK * (config.normalFrequencyHz / 50) * config.ratedAirVolume) / 60 / config.crossSection,
+    state: GasDischargeSimState.IDLE,
+  });
+
+  const timeSeries = ref<GasDischargeSimDataPoint[]>([]); // 趋势图用,最多 300 点
+  const eventLog = ref<GasDischargeEventLogEntry[]>([]); // 事件日志,最多 200 条
+  const runningFanPrefix = ref<'Fan1' | 'Fan2'>('Fan1'); // 识别当前运行的风机
+
+  // ======================== 内部变量(不暴露) ========================
+
+  let tickTimer: ReturnType<typeof setInterval> | null = null; // 主循环定时器
+  let eventIdCounter = 0; // 事件递增ID
+  let savedInitialFreq = 35; // 启动时保存的真实频率(作为降频终点)
+  let gas1History: number[] = []; // 工作面瓦斯历史,用于回风延迟模型
+  let lastGas2Warning = false; // 防止重复预警日志
+  let prevGas1 = 0; // 上周期工作面瓦斯(趋势判断用)
+  let prevGas2 = 0; // 上周期回风瓦斯
+  let gas2Peak = 0; // HOLD 阶段追踪的 gas2 峰值
+  let holdPhase = false; // 是否处于锁频等待阶段
+  let phase3Logged = false; // "计算排放频率" marker 是否已打
+
+  // ======================== 计算属性 ========================
+
+  const isRunning = computed(() => [GasDischargeSimState.RUNNING, GasDischargeSimState.WINDING_DOWN].includes(simState.value));
+  const stateLabel = computed(() => GAS_DISCHARGE_STEP_NAMES[simState.value]);
+
+  // ======================== 辅助函数 ========================
+
+  /** 秒数 → HH:MM:SS 显示 */
+  function formatElapsed(sec: number): string {
+    const h = Math.floor(sec / 3600),
+      m = Math.floor((sec % 3600) / 60),
+      s = Math.floor(sec % 60);
+    return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
+  }
+
+  /** 日志时间戳 = baseTime + 仿真秒(本地时间,避免 UTC 时差) */
+  function realTimeStr(): string {
+    const d = new Date(config.baseTime);
+    d.setSeconds(d.getSeconds() + elapsedSimSec.value);
+    const p = (n: number) => String(n).padStart(2, '0');
+    return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
+  }
+
+  /** 添加事件日志,自动截断保持 200 条以内 */
+  function addEvent(type: GasDischargeEventLogEntry['eventType'], message: string) {
+    const entry: GasDischargeEventLogEntry = {
+      id: ++eventIdCounter,
+      simTime: elapsedSimSec.value,
+      realTime: realTimeStr(),
+      eventType: type,
+      message,
+      gas1: currentData.gas1,
+      gas2: currentData.gas2,
+      co2: currentData.co2,
+      frequencyHz: currentData.frequencyHz,
+      airVolumeM3: currentData.airVolumeM3,
+      windSpeed: currentData.windSpeed,
+    };
+    eventLog.value = eventLog.value.length >= 200 ? [...eventLog.value.slice(1), entry] : [...eventLog.value, entry];
+  }
+
+  /** 状态切换并记录日志 */
+  function transitionTo(newState: GasDischargeSimState) {
+    const oldLabel = GAS_DISCHARGE_STEP_NAMES[simState.value];
+    simState.value = newState;
+    currentData.state = newState;
+    addEvent('state_change', `${oldLabel} → ${GAS_DISCHARGE_STEP_NAMES[newState]}`);
+  }
+
+  /**
+   * 回风延迟模型:取 delaySec 秒前的 gas1 值
+   * gas2(t) = gas1(t - delaySec) × decayFactor
+   */
+  function getDelayedGas1(delaySec: number): number {
+    const idx = Math.floor(delaySec);
+    if (idx >= gas1History.length) return gas1History[0] ?? config.initialGas1;
+    return gas1History[gas1History.length - 1 - idx];
+  }
+
+  // ======================== 仿真核心:每步 1 仿真秒 ========================
+
+  function simulationTick() {
+    const cfg = config; // 局部别名,减少模板字符串长度
+    const f = currentData.frequencyHz;
+
+    // -------- 1. 风量 --------
+    // 一阶惯性滞后模拟空气柱惯性 + 微小湍流噪声
+    const Q_target = cfg.frequencyK * (f / 50) * cfg.ratedAirVolume; // m³/min
+    const Q_prev = currentData.airVolumeM3 > 0 ? currentData.airVolumeM3 : Q_target;
+    const Q_actual = Q_prev + (Q_target - Q_prev) * 0.35; // 每步逼近35%
+    const noise = (Math.random() - 0.5) * Q_target * 0.02; // ±1% 湍流
+    currentData.airVolumeM3 = Math.round((Q_actual + noise) * 100) / 100;
+
+    // -------- 2. 风速 --------
+    // V = Q / 60 / A,安全约束 [minWindSpeed, maxWindSpeed]
+    const V = Q_actual / 60 / cfg.crossSection;
+    currentData.windSpeed = Math.round(V * 100) / 100;
+
+    // -------- 3. 工作面瓦斯稀释 --------
+    // dC/dt = (emissionRate - Q_s·(C - C_inlet)) / workfaceVolume
+    // 变化幅度 ×0.2 延长时间匹配 10min 调频周期
+    const Q_s = Q_actual / 60; // m³/s
+    const deltaC = ((cfg.emissionRate - Q_s * (currentData.gas1 - currentData.gas3)) / cfg.workfaceVolume) * 0.05;
+    currentData.gas1 = Math.max(0, currentData.gas1 + deltaC);
+
+    // -------- 4. CO₂ 独立稀释 --------
+    // CO₂ 涌出率 = 瓦斯涌出率 × 比率,与瓦斯独立但共享 Q 和 V
+    const co2Emission = cfg.emissionRate * cfg.co2EmissionRatio;
+    const deltaCo2 = (co2Emission - Q_s * (currentData.co2 - 0.04)) / cfg.workfaceVolume;
+    currentData.co2 = Math.max(0, currentData.co2 + deltaCo2);
+
+    // -------- 5. gas1 历史 --------
+    // 用于回风延迟模型的滑动窗口,保留最近 600 秒
+    gas1History.push(currentData.gas1);
+    if (gas1History.length > 600) gas1History.shift();
+
+    // -------- 6. 回风流瓦斯 --------
+    // gas2(t) = gas1(t - delaySec) × decayFactor
+    // delaySec = tunnelLength / windSpeed (风速越高延迟越短)
+    // 迫近 1.0% 时随机波动避免触碰红线;达标稳定后阻尼平滑
+    const delaySec = Math.floor(cfg.tunnelLength / (currentData.windSpeed || 1));
+    const rawGas2 = getDelayedGas1(delaySec) * cfg.decayFactor;
+    if (rawGas2 >= 0.9) {
+      currentData.gas2 = 0.85 + Math.random() * 0.12;
+    } else {
+      const newGas2 = Math.max(0, rawGas2);
+      const isStable = simState.value === GasDischargeSimState.WINDING_DOWN || holdPhase;
+      const damp = isStable && newGas2 < currentData.gas2 && newGas2 > 0.87 ? 0.13 : 0.7;
+      currentData.gas2 = currentData.gas2 + (newGas2 - currentData.gas2) * damp;
+    }
+
+    // -------- 7. 入口瓦斯 --------
+    currentData.gas3 = Math.max(0, cfg.initialGas3 + (Math.random() - 0.5) * 0.005);
+
+    // -------- 8. 预警(仅视觉)--------
+    if (currentData.gas2 >= 1.0 && !lastGas2Warning) {
+      addEvent('warning', `T2: ${currentData.gas2.toFixed(2)}%≥1.0%,注意监控`);
+      lastGas2Warning = true;
+    } else if (currentData.gas2 < 0.9) {
+      lastGas2Warning = false;
+    }
+
+    // ======== 9. RUNNING 阶段:渐进分级调频 ========
+    // 每隔 cycleDurationMin 分钟检查一次
+    if (simState.value === GasDischargeSimState.RUNNING) {
+      if (elapsedSimSec.value % (cfg.cycleDurationMin * 60) === 0) {
+        const lookup = lookupFrequencyByGas1(currentData.gas1); // 查分段表
+        const targetFreq = lookup.frequency; // 本段目标频率
+        const step = lookup.stepHz; // 本段步进
+        const oldFreq = currentData.frequencyHz;
+
+        // 7大阶段-3: 首次打印"计算排放频率"
+        if (!phase3Logged) {
+          addEvent('info', `计算排放频率 T1=${currentData.gas1.toFixed(2)}% → 目标${targetFreq}Hz (${lookup.label}) 步进+${step}Hz`);
+          phase3Logged = true;
+        }
+
+        // gas2 迫近 0.9% 时加速逼近(effStep = 55% 差距),否则正常渐进
+        const effStep = currentData.gas2 >= 0.9 ? (targetFreq - f) * 0.55 : step;
+        // 风速上下限约束
+        const maxFreqByWind = f + (currentData.windSpeed >= cfg.maxWindSpeed ? 0 : effStep);
+        const minFreqByWind = f - (currentData.windSpeed <= cfg.minWindSpeed ? 0 : step);
+
+        if (f < targetFreq) {
+          currentData.frequencyHz = Math.min(targetFreq, maxFreqByWind);
+        } else if (f > targetFreq) {
+          currentData.frequencyHz = Math.max(targetFreq, minFreqByWind);
+        }
+        currentData.frequencyHz = Math.max(20, Math.min(50, currentData.frequencyHz));
+        currentData.frequencyHz = Math.round(currentData.frequencyHz * 100) / 100;
+
+        // 7大阶段-4/5/6: 实际频率变化时打日志
+        if (Math.abs(currentData.frequencyHz - oldFreq) > 0.05) {
+          addEvent('info', `监测回风流瓦斯 T2=${currentData.gas2.toFixed(2)}% < 1.0% 正常,动态调整频率`);
+          addEvent(
+            'frequency_adjust',
+            `${oldFreq.toFixed(1)}→${currentData.frequencyHz.toFixed(1)}Hz (${lookup.label}) T1:${currentData.gas1.toFixed(2)}% T2:${currentData.gas2.toFixed(2)}%`
+          );
+        }
+        if (currentData.windSpeed >= cfg.maxWindSpeed) {
+          addEvent('speed_limit', `风速${currentData.windSpeed.toFixed(1)}m/s≥${cfg.maxWindSpeed}m/s,限制增频`);
+        }
+      }
+    }
+
+    // ======== 10. WINDING_DOWN 阶段 ========
+    //   子阶段 HOLD:  频率锁死,跟踪 gas2 峰值,等回落 0.2% 后进入 REDUCE
+    //   子阶段 REDUCE: 趋势驱动 -0.1Hz,完成条件后 COMPLETED
+    if (simState.value === GasDischargeSimState.WINDING_DOWN) {
+      // 首次进入:初始化 HOLD
+      if (!holdPhase && gas2Peak === 0) {
+        holdPhase = true;
+        gas2Peak = currentData.gas2;
+        prevGas1 = currentData.gas1;
+        prevGas2 = currentData.gas2;
+        addEvent('info', `进入保持阶段,频率${currentData.frequencyHz.toFixed(1)}Hz不变,等待T2峰值回落(T2Peak=${gas2Peak.toFixed(2)}%)`);
+      }
+
+      // 每帧更新 gas2 峰值
+      if (holdPhase && currentData.gas2 > gas2Peak) {
+        gas2Peak = currentData.gas2;
+      }
+
+      if (elapsedSimSec.value % (cfg.cycleDurationMin * 60) === 0) {
+        const oldFreq = currentData.frequencyHz;
+        // 最低频率 = max(20, 风速下限对应的频率)
+        const minFreq = Math.max(20, (cfg.minWindSpeed * 60 * cfg.crossSection) / ((cfg.frequencyK * cfg.ratedAirVolume) / 50));
+
+        if (holdPhase) {
+          // HOLD: 频率不变,等待 gas2 从峰值回落 ≥0.2%
+          const drop = gas2Peak - currentData.gas2;
+          if (drop >= 0.2 && currentData.gas2 < 0.9) {
+            holdPhase = false;
+            addEvent(
+              'info',
+              `T2峰值${gas2Peak.toFixed(2)}%回落${drop.toFixed(2)}≥0.2,降频中…(当前${currentData.frequencyHz.toFixed(1)}Hz→目标${savedInitialFreq}Hz) T1:${currentData.gas1.toFixed(2)}%`
+            );
+          }
+        } else {
+          // REDUCE: gas1 < 0.3% 且趋势健康 → -0.1Hz
+          if (currentData.gas1 < 0.3) {
+            const delta1 = currentData.gas1 - prevGas1; // T1 本周期变化
+            const delta2 = currentData.gas2 - prevGas2; // T2 本周期变化
+            if (delta1 < 0 && delta2 < delta1) {
+              // T1↓ + T2↓↓(更快) → 安全降频
+              currentData.frequencyHz = Math.max(minFreq, savedInitialFreq, currentData.frequencyHz - 0.1);
+              currentData.frequencyHz = Math.round(currentData.frequencyHz * 100) / 100;
+              if (currentData.frequencyHz < oldFreq) {
+                addEvent(
+                  'frequency_adjust',
+                  `降频(-0.1): ${oldFreq.toFixed(1)}→${currentData.frequencyHz.toFixed(1)}Hz [T1↓${Math.abs(delta1).toFixed(2)} T2↓${Math.abs(delta2).toFixed(2)}] T1:${currentData.gas1.toFixed(2)}% T2:${currentData.gas2.toFixed(2)}%`
+                );
+              }
+            } else if (delta1 >= 0) {
+              addEvent('info', `暂停降频: T1↑${delta1.toFixed(2)} T1:${currentData.gas1.toFixed(2)}% T2:${currentData.gas2.toFixed(2)}%`);
+            }
+          }
+        }
+
+        prevGas1 = currentData.gas1;
+        prevGas2 = currentData.gas2;
+
+        // COMPLETED 五条件:freq≤初始 && T1<0.3% && T2<0.2% && T2<T1 && CO2<1.5%
+        if (
+          !holdPhase &&
+          currentData.frequencyHz <= savedInitialFreq + 0.3 &&
+          currentData.gas1 < 0.3 &&
+          currentData.gas2 < 0.2 &&
+          currentData.gas2 < currentData.gas1 &&
+          currentData.co2 < 1.5
+        ) {
+          currentData.frequencyHz = savedInitialFreq;
+          transitionTo(GasDischargeSimState.COMPLETED);
+          // 7大阶段-7: 停止排放
+          addEvent('info', `停止排放 T1=${currentData.gas1.toFixed(2)}% T2=${currentData.gas2.toFixed(2)}% freq=${savedInitialFreq}Hz`);
+          stopTick();
+          return;
+        }
+      }
+    }
+
+    // ======== 11. 达标判断(RUNNING → WINDING_DOWN) ========
+    // T1<1.0% && T2<1.0% && T2≤T1 && CO2<1.5% 持续 stableDurationSec → 进入降频
+    if (simState.value === GasDischargeSimState.RUNNING) {
+      if (
+        currentData.gas1 < cfg.stopGasThreshold &&
+        currentData.gas2 < cfg.stopGasThreshold &&
+        currentData.gas2 <= currentData.gas1 &&
+        currentData.co2 < 1.5
+      ) {
+        sustainedSec.value++;
+        if (sustainedSec.value >= cfg.stableDurationSec) {
+          transitionTo(GasDischargeSimState.WINDING_DOWN);
+          addEvent(
+            'info',
+            `达标: T1=${currentData.gas1.toFixed(2)}% T2=${currentData.gas2.toFixed(2)}% CO2=${currentData.co2.toFixed(2)}% 持续${cfg.stableDurationSec}s,进入保持阶段`
+          );
+          sustainedSec.value = 0;
+        }
+      } else {
+        sustainedSec.value = 0;
+      }
+    }
+
+    // ======== 12. 推进 ========
+    const point: GasDischargeSimDataPoint = { ...currentData };
+    timeSeries.value = timeSeries.value.length >= 300 ? [...timeSeries.value.slice(1), point] : [...timeSeries.value, point];
+    elapsedSimSec.value++;
+    currentData.simTime = elapsedSimSec.value;
+    currentData.realTime = realTimeStr();
+  }
+
+  // ======================== 定时器 ========================
+  function startTick() {
+    stopTick();
+    tickTimer = setInterval(simulationTick, Math.max(50, 1000 / speed.value));
+  }
+  function stopTick() {
+    if (tickTimer) {
+      clearInterval(tickTimer);
+      tickTimer = null;
+    }
+  }
+  watch(speed, () => {
+    if (isRunning.value) startTick();
+  });
+
+  // ======================== 对外方法 ========================
+
+  /** 启动仿真:读取真实频率作为起点,递进式预填充历史,开始渐进排放 */
+  function start() {
+    resetInternal();
+
+    // 识别当前运行的风机
+    const f1 = selectData.Fan1StartStatus == 1;
+    const f2 = selectData.Fan2StartStatus == 1;
+    runningFanPrefix.value = f2 && !f1 ? 'Fan2' : 'Fan1';
+
+    // 读取真实频率(Fan1fHz/Fan2fHz),兜底为配置默认值
+    const realFreq = Number(selectData[runningFanPrefix.value + 'fHz']);
+    savedInitialFreq = !isNaN(realFreq) && realFreq > 0 ? realFreq : config.normalFrequencyHz;
+
+    // 读取真实风量 windQuantity1,兜底公式计算
+    const realAirVolume = selectData['windQuantity1'];
+    const initAirVolume =
+      typeof realAirVolume === 'number' && realAirVolume > 0 ? realAirVolume : config.frequencyK * (savedInitialFreq / 50) * config.ratedAirVolume;
+
+    // 初始化当前数据
+    currentData.gas1 = config.initialGas1;
+    currentData.gas2 = config.initialGas2;
+    currentData.gas3 = config.initialGas3;
+    currentData.co2 = config.initialGas2 * config.co2EmissionRatio + 0.04;
+    currentData.frequencyHz = savedInitialFreq;
+    currentData.airVolumeM3 = initAirVolume;
+    currentData.windSpeed = currentData.airVolumeM3 / 60 / config.crossSection;
+    currentData.simTime = 0;
+    currentData.realTime = realTimeStr();
+
+    // 递进式预填充 gas1History:前半段正常浓度 → 后半段逐步累积到超限
+    // 上限约束:确保 gas2 = gas1 × decayFactor ≤ 1.0%
+    const delaySec = Math.floor(config.tunnelLength / (currentData.windSpeed || 1));
+    const steadyGas1 = config.initialGas2 / Math.max(0.01, config.decayFactor); // 正常状态对应瓦斯
+    const maxSafeGas1 = 1.0 / Math.max(0.01, config.decayFactor); // gas2≤1.0% 对应的 gas1 上限
+    const cappedInitialGas1 = Math.min(config.initialGas1, maxSafeGas1);
+    const halfDelay = Math.floor(delaySec / 2);
+    const rampSteps = delaySec - halfDelay || 1;
+    gas1History = [];
+    for (let i = 0; i <= delaySec; i++) {
+      if (i < halfDelay) {
+        gas1History.push(steadyGas1);
+      } else {
+        const rampVal = steadyGas1 + (cappedInitialGas1 - steadyGas1) * ((i - halfDelay) / rampSteps);
+        gas1History.push(Math.min(rampVal, maxSafeGas1));
+      }
+    }
+    lastGas2Warning = false;
+
+    // 7大阶段-1/2
+    addEvent('info', `监测到瓦斯浓度异常 T1=${config.initialGas1}% > ${config.stopGasThreshold}%`);
+    addEvent('info', `启动瓦斯排放 当前频率=${savedInitialFreq}Hz 风机${runningFanPrefix.value === 'Fan1' ? '1#' : '2#'}`);
+    phase3Logged = false;
+    transitionTo(GasDischargeSimState.RUNNING);
+    startTick();
+  }
+
+  function pause() {
+    if (simState.value !== GasDischargeSimState.RUNNING) return;
+    stopTick();
+    transitionTo(GasDischargeSimState.PAUSED);
+  }
+  function resume() {
+    if (simState.value !== GasDischargeSimState.PAUSED) return;
+    transitionTo(GasDischargeSimState.RUNNING);
+    startTick();
+  }
+  function abort() {
+    stopTick();
+    if (simState.value !== GasDischargeSimState.IDLE) transitionTo(GasDischargeSimState.ABORTED);
+    simState.value = GasDischargeSimState.IDLE;
+  }
+  function updateConfig(partial: Partial<GasDischargeConfig>) {
+    Object.assign(config, partial);
+  }
+
+  /** 重置全部内部状态(不清配置) */
+  function resetInternal() {
+    stopTick();
+    elapsedSimSec.value = 0;
+    sustainedSec.value = 0;
+    eventIdCounter = 0;
+    gas1History = [];
+    lastGas2Warning = false;
+    prevGas1 = 0;
+    prevGas2 = 0;
+    gas2Peak = 0;
+    holdPhase = false;
+    phase3Logged = false;
+    timeSeries.value = [];
+    eventLog.value = [];
+    simState.value = GasDischargeSimState.IDLE;
+    currentData.state = GasDischargeSimState.IDLE;
+  }
+
+  onUnmounted(() => stopTick());
+
+  // ======================== 导出 ========================
+  return {
+    simState,
+    config,
+    speed,
+    currentData,
+    timeSeries,
+    eventLog,
+    elapsedSimSec,
+    sustainedSec,
+    runningFanPrefix,
+    isRunning,
+    stateLabel,
+    formatElapsed,
+    start,
+    pause,
+    resume,
+    abort,
+    updateConfig,
+    setSpeed: (s: number) => {
+      speed.value = s;
+    },
+  };
+}

+ 315 - 0
src/views/vent/monitorManager/mainFanMonitor/components/FanSwitchoverSimPanel.vue

@@ -0,0 +1,315 @@
+<template>
+  <div class="elementContent elementContent-r" style="width: 300px" id="ljhMonitor">
+    <div class="element-item" style="line-height: 22px" v-if="simPanelVisible">
+      <span class="data-title" style="color: aqua">倒机已运行:</span>
+      <span>{{ formatTime(selectData.simElapsed) }}</span>
+    </div>
+    <!-- 风机监测数据 -->
+    <div class="element-item" style="line-height: 22px"
+      ><span class="data-title">风机负压:</span><span>{{ selectData.Fan1Negative || selectData.Fan2Negative || '-' }}(Pa)</span></div
+    >
+    <div class="element-item" style="line-height: 22px"
+      ><span class="data-title">风机风量:</span><span>{{ selectData.Fan1m3 || selectData.Fan2m3 || '-' }}(m³/min)</span></div
+    >
+
+    <div style="color: aqua">----------------1#风机-----------------</div>
+    <div class="element-item" style="line-height: 22px"
+      ><span class="data-title">天窗面积:</span
+      ><span> {{ Number.isFinite(selectData.Fan1WindowArea) ? selectData.Fan1WindowArea.toFixed(2) : '-' }}(㎡) </span></div
+    >
+    <div class="element-item" style="line-height: 22px"
+      ><span class="data-title">闸板门面积:</span
+      ><span> {{ Number.isFinite(selectData.Fan1GateArea) ? selectData.Fan1GateArea.toFixed(2) : '-' }}(㎡) </span></div
+    >
+
+    <div style="color: aqua">----------------2#风机-----------------</div>
+    <div class="element-item" style="line-height: 22px"
+      ><span class="data-title">天窗面积:</span
+      ><span> {{ Number.isFinite(selectData.Fan2WindowArea) ? selectData.Fan2WindowArea.toFixed(2) : '-' }}(㎡) </span></div
+    >
+    <div class="element-item" style="line-height: 22px"
+      ><span class="data-title">闸板门面积:</span
+      ><span> {{ Number.isFinite(selectData.Fan2GateArea) ? selectData.Fan2GateArea.toFixed(2) : '-' }}(㎡) </span></div
+    >
+  </div>
+  <!-- 不停风倒机模拟进度 -->
+  <div v-if="simPanelVisible" class="sim-panel">
+    <div class="sim-header">
+      <span class="sim-title">不停风倒机执行</span>
+      <!-- <span class="sim-close" @click="$emit('simAbort')">✕</span> -->
+    </div>
+    <div class="sim-row">
+      <span class="data-title">方向</span>
+      <span>{{ directionLabel }}</span>
+    </div>
+    <div class="sim-row">
+      <span class="data-title">已运行</span>
+      <span>{{ formatTime(selectData.simElapsed) }}</span>
+    </div>
+    <div class="sim-row">
+      <span class="data-title">风量波动</span>
+      <span :class="{ 'sim-delta-warn': selectData.simAirflowDelta > 20 }">{{ selectData.simAirflowDelta || 0 }}%</span>
+    </div>
+    <div class="sim-progress">
+      <div class="sim-progress-bar" :style="{ width: progressPercent + '%' }"></div>
+    </div>
+    <!-- 步骤列表 -->
+    <div class="sim-steps">
+      <div v-for="(step, i) in selectData.simStepLog" :key="i">
+        <div class="sim-step-item" :class="{ 'sim-step-active': !step.completed, 'sim-step-done': step.completed }">
+          <span class="sim-step-marker">{{ step.completed ? '✓' : '→' }}</span>
+          <span class="sim-step-name">{{ step.name }}</span>
+          <span class="sim-step-time"
+            ><span class="sim-step-start">{{ step.startTime || '' }}启动</span
+            ><span class="step-use-time"> {{ step.completed ? '' + step.elapsed + 's' : '进行中' }}</span></span
+          >
+        </div>
+        <div v-if="step.detail" class="sim-step-detail">{{ step.detail }}</div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { computed, watch, nextTick, ref } from 'vue';
+
+  const props = defineProps<{
+    selectData: Record<string, any>;
+  }>();
+
+  defineEmits<{
+    simSpeedChange: [speed: number];
+    simAbort: [];
+  }>();
+
+  const speeds = [1, 2, 5, 10];
+
+  const simRunningStates = ['INIT', 'OPEN_SKYLIGHT', 'STABILIZE', 'TRANSFER', 'REVERSE', 'END'];
+  const simPanelVisible = computed(() => simRunningStates.includes(props.selectData?.simState));
+  const directionLabel = computed(() =>
+    props.selectData?.simDirection === '1to2' ? '1# → 2#' : props.selectData?.simDirection === '2to1' ? '2# → 1#' : '-'
+  );
+  const progressPercent = computed(() => {
+    const total = props.selectData?.simTotalSteps;
+    const step = props.selectData?.simStep;
+    if (!total || total <= 0) return 0;
+    return Math.round((step / total) * 100);
+  });
+  function formatTime(seconds: number): string {
+    if (!Number.isFinite(seconds)) return '0s';
+    return `${Math.floor(seconds)}s`;
+  }
+
+  // 逐步切换详情自动滚底
+  watch(
+    () => props.selectData?.simStepLog,
+    () => {
+      nextTick(() => {
+        const details = document.querySelectorAll('.sim-step-detail');
+        details.forEach((el) => {
+          el.scrollTop = el.scrollHeight;
+        });
+      });
+    },
+    { deep: true }
+  );
+</script>
+
+<style scoped lang="less">
+  .elementContent {
+    backdrop-filter: blur(2px);
+    width: 220px;
+    background-color: #00000099;
+    box-shadow: 0px 0px 40px #005a838c inset;
+    border: 2px solid #99b0c38c;
+    padding: 15px 20px 15px 20px;
+    font-size: 15px;
+
+    .element-item {
+      line-height: 38px;
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+    }
+
+    span {
+      color: #f2a500;
+    }
+
+    .data-title {
+      max-width: 190px;
+      display: inline-block;
+      color: var(--vent-font-color);
+    }
+  }
+
+  .elementContent-r {
+    &::before {
+      right: 120px;
+    }
+
+    &::after {
+      right: 270px;
+      border: 4px solid #00ddff;
+      background: #a7a7a766;
+    }
+  }
+  .element-item {
+    line-height: 22px;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    span {
+      color: #f2a500;
+    }
+    .data-title {
+      color: var(--vent-font-color);
+    }
+  }
+
+  .sim-panel {
+    width: 400px;
+    margin-top: 8px;
+    padding: 8px 10px;
+    border: 1px solid #00ddff66;
+    background: #001c2e88;
+    backdrop-filter: blur(5px);
+    border-radius: 4px;
+    font-size: 13px;
+    position: absolute;
+    left: 375px;
+    top: 145px;
+    z-index: 999;
+    pointer-events: auto;
+
+    .sim-header {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      margin-bottom: 6px;
+      .sim-title {
+        color: #00ddff;
+        font-weight: 600;
+      }
+    }
+
+    .sim-row {
+      display: flex;
+      justify-content: space-between;
+      line-height: 22px;
+      span {
+        color: #f2a500;
+      }
+      .data-title {
+        color: var(--vent-font-color);
+      }
+    }
+
+    .sim-delta-warn {
+      color: #ff4444 !important;
+      font-weight: 600;
+    }
+
+    .sim-progress {
+      height: 6px;
+      background: #ffffff22;
+      border-radius: 3px;
+      margin: 6px 0;
+      overflow: hidden;
+      .sim-progress-bar {
+        height: 100%;
+        background: linear-gradient(to right, #00ddff, #00aaff);
+        border-radius: 3px;
+        transition: width 0.3s;
+      }
+    }
+
+    .sim-speed {
+      display: flex;
+      align-items: center;
+      gap: 4px;
+      margin: 6px 0;
+      .data-title {
+        color: var(--vent-font-color);
+      }
+      .sim-speed-btn {
+        padding: 1px 6px;
+        border: 1px solid #00ddff66;
+        border-radius: 3px;
+        cursor: pointer;
+        color: #00ddff;
+        font-size: 12px;
+        &:hover {
+          background: #00ddff33;
+        }
+      }
+      .sim-speed-active {
+        background: #00ddff55;
+        color: #fff;
+      }
+    }
+
+    .sim-steps {
+      margin-top: 8px;
+      .sim-step-item {
+        display: flex;
+        align-items: center;
+        gap: 6px;
+        line-height: 20px;
+        font-size: 12px;
+        padding: 2px 0;
+      }
+      .sim-step-marker {
+        width: 16px;
+        text-align: center;
+        font-size: 11px;
+      }
+      .sim-step-name {
+        flex: 1;
+        color: var(--vent-font-color);
+      }
+      .sim-step-time {
+        font-size: 11px;
+        min-width: 45px;
+        text-align: right;
+
+        .sim-step-start {
+          color: #ffffffc4;
+          margin-right: 4px;
+          display: inline-block;
+        }
+        .step-use-time {
+          display: inline-block;
+          width: 40px;
+          color: #f2a500;
+        }
+      }
+      .sim-step-active {
+        .sim-step-marker {
+          color: #00ddff;
+        }
+        .sim-step-name {
+          color: #fff;
+        }
+      }
+      .sim-step-done {
+        .sim-step-marker {
+          color: #46c66f;
+        }
+        .sim-step-name {
+          color: #ffffff88;
+        }
+      }
+      .sim-step-detail {
+        width: 100%;
+        max-height: 200px;
+        padding-left: 22px;
+        font-size: 12px;
+        color: #ffffff7a;
+        line-height: 15px;
+        white-space: pre-line;
+        overflow-y: auto;
+      }
+    }
+  }
+</style>

+ 66 - 0
src/views/vent/monitorManager/mainFanMonitor/components/SimHistoryTable.vue

@@ -0,0 +1,66 @@
+<template>
+  <div class="sim-history-table">
+    <BasicTable @register="registerTable" :data-source="dataSource" />
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref, watch } from 'vue';
+  import { BasicTable, useTable } from '/@/components/Table';
+  import { simHistory } from '../hooks/useSimHistory';
+
+  const columns = [
+    { title: '用户', dataIndex: 'user' },
+    { title: '操作设备', dataIndex: 'device' },
+    { title: '操作风机', dataIndex: 'fan' },
+    { title: '操作记录', dataIndex: 'description', width: 500 },
+    { title: '时间', dataIndex: 'time' },
+  ];
+
+  const dataSource = ref<any[]>([]);
+
+  const [registerTable] = useTable({
+    columns,
+    showTableSetting: false,
+    showActionColumn: false,
+    bordered: false,
+    size: 'small',
+    pagination: { pageSize: 10 },
+    canResize: true,
+  });
+
+  watch(
+    simHistory,
+    (val) => {
+      dataSource.value = val.map((r) => ({ ...r, key: r.id }));
+    },
+    { immediate: true, deep: true }
+  );
+</script>
+
+<style scoped lang="less">
+  .sim-history-table {
+    :deep(.ant-table) {
+      background: transparent;
+      font-size: 12px;
+    }
+    :deep(.ant-table-thead > tr > th) {
+      background: #001529;
+      color: var(--vent-font-color);
+      border-color: #ffffff22;
+    }
+    :deep(.ant-table-tbody > tr > td) {
+      border-color: #ffffff11;
+      color: #ffffffcc;
+    }
+    :deep(.ant-table-tbody > tr:hover > td) {
+      background: #ffffff11 !important;
+    }
+    :deep(.ant-pagination-item-active) {
+      border-color: #00ddff;
+      a {
+        color: #00ddff;
+      }
+    }
+  }
+</style>

+ 116 - 0
src/views/vent/monitorManager/mainFanMonitor/hooks/useAreaSetSimulation.ts

@@ -0,0 +1,116 @@
+import { SIM_CONFIG } from '../main.data';
+
+/**
+ * 天窗/闸板门独立设定过程模拟 composable
+ * 与不停风倒机流程完全独立,共享 selectData 和 SIM_CONFIG
+ */
+export function useAreaSetSimulation(selectData: Record<string, any>) {
+  // ============ 共享工具函数 ============
+
+  /** 安全 clamp:兜底 NaN/Infinity */
+  const clampArea = (val: number, max: number) => {
+    if (!Number.isFinite(val)) return 0;
+    const clamped = Math.max(0, Math.min(max, val));
+    return Math.round(clamped * 100) / 100;
+  };
+
+  /** easeInOutCubic 缓动 */
+  const easeInOutCubic = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
+
+  /** 生成模拟到位值(全量程 ±0.8%FS 误差,关到位无误差) */
+  const generateMonitorValue = (setValue: number, maxRange: number) => {
+    if (setValue <= 0) return 0;
+    const errorFS = (Math.random() - 0.5) * 2 * SIM_CONFIG.areaMonitorMaxError;
+    const result = setValue + maxRange * errorFS;
+    return Math.round(Math.max(0, result) * 100) / 100;
+  };
+
+  /** 默认过渡时长:基于试验数据 6s/m² 速率 */
+  const calcDefaultDuration = (setVal: number, curVal: number, maxRange: number) => {
+    const raw = (Math.abs(setVal - curVal) / maxRange) * 30;
+    return Math.max(5, Math.min(60, Math.round(raw)));
+  };
+
+  // ============ 过程管理 ============
+
+  /** 活跃过程 Map,key = "fan_type",支持 1#/2# 并行 */
+  const activeProcesses = new Map<string, ReturnType<typeof setInterval>>();
+
+  /**
+   * 天窗/闸板门面积设定过程模拟
+   * - 根据设定前面积、设定面积、最大面积决定是否过渡
+   * - 设定面积 > 设定前面积 → 上行过渡(目标 > 起始)
+   * - 设定面积 < 设定前面积 → 下行过渡(目标 < 起始)
+   * - 设定面积 = 设定前面积 → 跳过过渡,直接生成到位值
+   */
+  function setArea(
+    type: 'window' | 'gate',
+    fan: '1' | '2',
+    setValue: number,
+    onComplete?: (info: { fan: string; startValue: number; setValue: number; finalMonitor: number; type: 'window' | 'gate' }) => void
+  ) {
+    const maxRange = type === 'window' ? SIM_CONFIG.targetSkylightMax : SIM_CONFIG.gateMax;
+    const areaKey = `Fan${fan}${type === 'window' ? 'WindowArea' : 'GateArea'}`;
+    const setKey = `Fan${fan}${type === 'window' ? 'WindowSet' : 'GateSet'}`;
+    const processKey = `${fan}_${type}`;
+
+    // 中断同风机同类型的上一个过程
+    const prevTimer = activeProcesses.get(processKey);
+    if (prevTimer) clearInterval(prevTimer);
+
+    // 安全兜底
+    const safeSetVal = Number.isFinite(setValue) ? setValue : 0;
+    const clampedSet = clampArea(safeSetVal, maxRange);
+    const rawStart = selectData[areaKey];
+    const startValue = Number.isFinite(rawStart) ? clampArea(rawStart as number, maxRange) : 0;
+
+    // 立即写入设定值
+    selectData[setKey] = clampedSet;
+
+    // 无变化时直接写入到位值,不启动过渡
+    if (clampedSet === startValue) {
+      const fm = generateMonitorValue(clampedSet, maxRange);
+      selectData[areaKey] = fm;
+      onComplete?.({ fan, startValue, setValue: clampedSet, finalMonitor: fm, type });
+      return;
+    }
+
+    // 计算过渡时长、预生成最终到位值
+    const duration = calcDefaultDuration(clampedSet, startValue, maxRange);
+    const startTime = Date.now();
+    const finalMonitor = generateMonitorValue(clampedSet, maxRange);
+
+    activeProcesses.set(
+      processKey,
+      setInterval(() => {
+        const elapsed = (Date.now() - startTime) / 1000;
+        const progress = Math.min(elapsed / duration, 1);
+        const eased = easeInOutCubic(progress);
+
+        // 插值:上行 (clampedSet > startValue) 时递增,下行时递减
+        const interpolated = startValue + (clampedSet - startValue) * eased;
+
+        // 运动中叠加微小噪声
+        let noise = 0;
+        if (progress < 1) {
+          noise = (Math.random() - 0.5) * 0.06;
+        }
+
+        let current = progress >= 1 ? finalMonitor : interpolated + noise;
+        current = clampArea(current, maxRange);
+        selectData[areaKey] = current;
+
+        if (progress >= 1) {
+          clearInterval(activeProcesses.get(processKey));
+          activeProcesses.delete(processKey);
+          onComplete?.({ fan, startValue, setValue: clampedSet, finalMonitor, type });
+        }
+      }, SIM_CONFIG.tickInterval)
+    );
+  }
+
+  return {
+    setArea,
+    activeProcesses,
+  };
+}

+ 426 - 0
src/views/vent/monitorManager/mainFanMonitor/hooks/useFanSwitchoverSimulation.ts

@@ -0,0 +1,426 @@
+import { ref, watch, onUnmounted } from 'vue';
+import { SimState, SIM_CONFIG, SIM_STEP_NAMES } from '../main.data';
+import { addSimRecord, updateSimRecord } from './useSimHistory';
+
+/**
+ * 不停风倒机模拟 composable
+ *
+ * 使用方式:
+ *   const sim = useFanSwitchoverSimulation(selectData);
+ *   // 触发模拟:sim.run()
+ *   // 终止模拟:sim.abort()
+ *   // UI 绑定:sim.speed, sim.running, sim.state 等
+ */
+export function useFanSwitchoverSimulation(selectData: Record<string, any>) {
+  // ============ 暴露给 UI 的响应式状态 ============
+  const running = ref(false);
+  const speed = ref(1); // 倍速 1/2/5/10
+  const state = ref<SimState>(SimState.IDLE);
+  const stepIndex = ref(0);
+  const stepName = ref('');
+  const elapsed = ref(0); // 已运行秒数
+  const direction = ref(''); // '1to2' | '2to1'
+  const airflowDelta = ref(0); // 风量波动百分比
+  const changeIsRunning = ref(false);
+
+  // ============ 内部状态 ============
+  let abortFlag = false;
+  let sourceFan: '1' | '2' = '1';
+  let targetFan: '1' | '2' = '2';
+  let sourceGate = 0;
+  let sourceWindow = 0;
+  let targetGate = 0;
+  let targetWindow = 0;
+  let tickTimer: ReturnType<typeof setInterval> | null = null;
+
+  // ============ 辅助函数 ============
+
+  /** 根据倍速调整延迟 */
+  const adjDelay = (seconds: number) => (seconds * 1000) / speed.value;
+
+  /** 异步等待(可中断) */
+  const sleep = (ms: number) =>
+    new Promise<void>((resolve) => {
+      const t = setTimeout(resolve, ms);
+      // 不在此处检查 abortFlag,由上层控制
+    });
+
+  /** 按风机编号写面积 */
+  const setGate = (fan: '1' | '2', val: number) => {
+    const v = clampArea(val, SIM_CONFIG.gateMax);
+    if (fan === '1') selectData.Fan1GateArea = v;
+    else selectData.Fan2GateArea = v;
+  };
+  const setWindow = (fan: '1' | '2', val: number) => {
+    const v = clampArea(val, SIM_CONFIG.targetSkylightMax);
+    if (fan === '1') selectData.Fan1WindowArea = v;
+    else selectData.Fan2WindowArea = v;
+  };
+
+  /** 安全 clamp:兜底 NaN/Infinity,确保返回值始终为有效非负数 */
+  const clampArea = (val: number, max: number) => {
+    if (!Number.isFinite(val)) return 0;
+    const clamped = Math.max(0, Math.min(max, val));
+    return Math.round(clamped * 100) / 100;
+  };
+
+  // ============ 默认值初始化 ============
+
+  /** 根据风机启停状态设置天窗/闸板默认值 */
+  function initAreaDefaults() {
+    // Fan1StartStatus == 1 运行 → 天窗关(0)、闸板开(最大)
+    if (selectData.Fan1StartStatus == 1) {
+      selectData.Fan1WindowArea = 0;
+      selectData.Fan1GateArea = SIM_CONFIG.gateMax;
+    } else {
+      selectData.Fan1WindowArea = 0;
+      selectData.Fan1GateArea = 0;
+    }
+    // Fan2 同理
+    if (selectData.Fan2StartStatus == 1) {
+      selectData.Fan2WindowArea = 0;
+      selectData.Fan2GateArea = SIM_CONFIG.gateMax;
+    } else {
+      selectData.Fan2WindowArea = 0;
+      selectData.Fan2GateArea = 0;
+    }
+  }
+
+  // ============ 计时器 ============
+
+  function startTick(startTime: number) {
+    stopTick();
+    tickTimer = setInterval(() => {
+      if (!running.value) return;
+      const realElapsed = (Date.now() - startTime) / 1000;
+      elapsed.value = Math.round(realElapsed * speed.value);
+      selectData.simElapsed = elapsed.value;
+    }, SIM_CONFIG.tickInterval);
+  }
+
+  function stopTick() {
+    if (tickTimer) {
+      clearInterval(tickTimer);
+      tickTimer = null;
+    }
+  }
+
+  // ============ 状态机 ============
+
+  let stepStartTime = 0;
+
+  function setSimState(s: SimState) {
+    const now = Date.now();
+    // 记录上一个步骤的耗时
+    if (stepStartTime > 0 && state.value !== SimState.IDLE) {
+      const stepElapsed = ((now - stepStartTime) / 1000).toFixed(1);
+      const log = selectData.simStepLog as Array<{ name: string; elapsed: string; completed: boolean }>;
+      // 更新最后一条(当前正在执行的步骤)为已完成
+      if (log.length > 0) {
+        log[log.length - 1].elapsed = stepElapsed;
+        log[log.length - 1].completed = true;
+      }
+    }
+    // 新步骤开始
+    stepStartTime = now;
+    state.value = s;
+    selectData.simState = s;
+    stepName.value = SIM_STEP_NAMES[s];
+    selectData.simStepName = SIM_STEP_NAMES[s];
+    if (s !== SimState.IDLE && s !== SimState.COMPLETE && s !== SimState.ABORTED) {
+      const log = selectData.simStepLog as Array<{ name: string; elapsed: string; completed: boolean; startTime: string }>;
+      log.push({ name: SIM_STEP_NAMES[s], elapsed: '0', completed: false, startTime: new Date().toLocaleTimeString() });
+    }
+  }
+
+  /** 频率联动:目标风机 0→40Hz,源风机 40→0Hz(跟随闸板面积) */
+  function initFreqs() {
+    selectData[`Fan${targetFan}FreqHz`] = 0;
+    selectData[`Fan${sourceFan}FreqHz`] = 40;
+  }
+
+  /** 频率联动:目标风机 0→40Hz,源风机 40→0Hz(跟随闸板面积) */
+  function updateFreqs() {
+    const targetFreq = 0 + (targetGate / SIM_CONFIG.gateMax) * 40;
+    const sourceFreq = 40 * (sourceGate / SIM_CONFIG.gateMax);
+    selectData[`Fan${targetFan}FreqHz`] = Math.round(targetFreq * 10) / 10;
+    selectData[`Fan${sourceFan}FreqHz`] = Math.round(sourceFreq * 10) / 10;
+  }
+
+  async function runOpenSkylight() {
+    setSimState(SimState.OPEN_SKYLIGHT);
+    const totalWait = SIM_CONFIG.skylightOpenWait;
+    const steps = 10;
+    for (let i = 1; i <= steps; i++) {
+      if (abortFlag) return;
+      const area = (SIM_CONFIG.targetSkylightMax / steps) * i;
+      setWindow(targetFan, area);
+      setGate(targetFan, 0);
+      updateFreqs();
+      await sleep(adjDelay(totalWait / steps));
+    }
+  }
+
+  async function runTransfer() {
+    setSimState(SimState.TRANSFER);
+    selectData.simTotalSteps = SIM_CONFIG.totalSteps;
+
+    const stepRatio = 1 / SIM_CONFIG.totalSteps;
+    const gateDS = SIM_CONFIG.gateMax * stepRatio;
+    const windowDS = SIM_CONFIG.targetSkylightMax * stepRatio;
+
+    for (let i = 1; i <= SIM_CONFIG.totalSteps; i++) {
+      if (abortFlag) return;
+
+      stepIndex.value = i;
+      selectData.simStep = i;
+
+      // dS 小范围波动 ±5%
+      const jitter = 1 + (Math.random() - 0.5) * 0.1;
+      const actualGateDS = gateDS * jitter;
+      const actualWindowDS = windowDS * jitter;
+
+      // 目标风机:闸板 +dS,天窗 -dS
+      let rawTargetGate = targetGate + actualGateDS;
+      let rawTargetWindow = targetWindow - actualWindowDS;
+      // 源风机:闸板 -dS,天窗 +dS
+      let rawSourceGate = sourceGate - actualGateDS;
+      let rawSourceWindow = sourceWindow + actualWindowDS;
+
+      // 同比例基础上叠加面积噪声 ±0.02
+      rawTargetGate += (Math.random() - 0.5) * 0.04;
+      rawTargetWindow += (Math.random() - 0.5) * 0.04;
+      rawSourceGate += (Math.random() - 0.5) * 0.04;
+      rawSourceWindow += (Math.random() - 0.5) * 0.04;
+
+      let newTargetGate = clampArea(rawTargetGate, SIM_CONFIG.gateMax);
+      let newTargetWindow = clampArea(rawTargetWindow, SIM_CONFIG.targetSkylightMax);
+      let newSourceGate = clampArea(rawSourceGate, SIM_CONFIG.gateMax);
+      let newSourceWindow = clampArea(rawSourceWindow, SIM_CONFIG.targetSkylightMax);
+
+      // 应用
+      setGate(targetFan, newTargetGate);
+      setWindow(targetFan, newTargetWindow);
+      setGate(sourceFan, newSourceGate);
+      setWindow(sourceFan, newSourceWindow);
+
+      // 更新内部跟踪
+      targetGate = newTargetGate;
+      targetWindow = newTargetWindow;
+      sourceGate = newSourceGate;
+      sourceWindow = newSourceWindow;
+
+      // 频率联动
+      updateFreqs();
+
+      // 风量变化幅度 5-18% 随机
+      const delta = Math.floor(Math.random() * 14) + 5 + Math.round(Math.random() * 10) / 10;
+      airflowDelta.value = delta;
+      selectData.simAirflowDelta = delta;
+
+      // 步骤详情:逐行追加到"逐步切换"条目下
+      const tg = actualGateDS.toFixed(2);
+      const tw = actualWindowDS.toFixed(2);
+      const line = `${i}. ${targetFan}#闸板+${tg}㎡ ${targetFan}#天窗-${tw}㎡  ${sourceFan}#闸板-${tg}㎡ ${sourceFan}#天窗+${tw}㎡`;
+      const log = selectData.simStepLog as Array<{ name: string; elapsed: string; completed: boolean; detail?: string }>;
+      if (log.length > 0) {
+        log[log.length - 1].detail = log[log.length - 1].detail ? log[log.length - 1].detail + '\n' + line : line;
+      }
+
+      // 超阈值回退(不改变 UI 状态,仅数据回调)
+      if (delta > SIM_CONFIG.maxDelta) {
+        const rGateDS = actualGateDS / 2;
+        const rWindowDS = actualWindowDS / 2;
+        newTargetGate = clampArea(targetGate - rGateDS, SIM_CONFIG.gateMax);
+        newTargetWindow = clampArea(targetWindow + rWindowDS, SIM_CONFIG.targetSkylightMax);
+        newSourceGate = clampArea(sourceGate + rGateDS, SIM_CONFIG.gateMax);
+        newSourceWindow = clampArea(sourceWindow - rWindowDS, SIM_CONFIG.targetSkylightMax);
+        setGate(targetFan, newTargetGate);
+        setWindow(targetFan, newTargetWindow);
+        setGate(sourceFan, newSourceGate);
+        setWindow(sourceFan, newSourceWindow);
+        targetGate = newTargetGate;
+        targetWindow = newTargetWindow;
+        sourceGate = newSourceGate;
+        sourceWindow = newSourceWindow;
+
+        const newDelta = Math.floor(Math.random() * 14) + 5 + Math.round(Math.random() * 10) / 10;
+        airflowDelta.value = newDelta;
+        selectData.simAirflowDelta = newDelta;
+      }
+
+      await sleep(adjDelay(SIM_CONFIG.stepDuration));
+    }
+  }
+
+  // ============ 公开方法 ============
+
+  async function run() {
+    if (running.value) return;
+    running.value = true;
+    abortFlag = false;
+
+    // 先确定方向(必须在强制双机运行之前)
+    if (selectData.Fan1StartStatus == 1 && selectData.Fan2StartStatus != 1) {
+      sourceFan = '1';
+      targetFan = '2';
+    } else if (selectData.Fan2StartStatus == 1 && selectData.Fan1StartStatus != 1) {
+      sourceFan = '2';
+      targetFan = '1';
+    } else {
+      // 默认:1# 为源
+      sourceFan = '1';
+      targetFan = '2';
+    }
+
+    // 重置步骤日志
+    selectData.simStepLog = [];
+    stepStartTime = 0;
+
+    // 倒机期间强制双机运行(完成后设为 undefined,下轮 API 轮询恢复真实值)
+    selectData.Fan1StartStatus = '1';
+    selectData.Fan2StartStatus = '1';
+    direction.value = `${sourceFan}to${targetFan}`;
+    selectData.simDirection = direction.value;
+
+    // 指令下发时先生成记录
+    const switchRecId = addSimRecord({
+      device: selectData.strname || '-',
+      fan: '',
+      description: `${sourceFan}#风机倒${targetFan}#风机,正在执行...`,
+      type: 'switchover',
+    });
+
+    // 不停风倒机专属初始状态(与 initAreaDefaults 独立):
+    // 源风机(运行中)→ 闸板全开、天窗全关;目标风机(停机)→ 全部关闭
+    setGate(sourceFan, SIM_CONFIG.gateMax);
+    setWindow(sourceFan, 0);
+    setGate(targetFan, 0);
+    setWindow(targetFan, 0);
+    sourceGate = SIM_CONFIG.gateMax;
+    sourceWindow = 0;
+    targetGate = 0;
+    targetWindow = 0;
+
+    const startTime = Date.now();
+    startTick(startTime);
+    changeIsRunning.value = true;
+    try {
+      // 初始化风机频率
+      initFreqs();
+
+      // Step 1: INIT
+      setSimState(SimState.INIT);
+      selectData.simTotalSteps = SIM_CONFIG.totalSteps;
+      selectData.simStep = 0;
+      await sleep(adjDelay(0.5));
+      if (abortFlag) return;
+
+      // Step 2: OPEN_SKYLIGHT — 目标风机天窗 0→5.00
+      await runOpenSkylight();
+      if (abortFlag) return;
+      // 同步本地变量:runOpenSkylight 已通过 setWindow/setGate 写入 selectData
+      targetWindow = selectData[`Fan${targetFan}WindowArea`] ?? 0;
+      targetGate = selectData[`Fan${targetFan}GateArea`] ?? 0;
+      sourceWindow = selectData[`Fan${sourceFan}WindowArea`] ?? 0;
+      sourceGate = selectData[`Fan${sourceFan}GateArea`] ?? 0;
+
+      // Step 3: STABILIZE — 等待稳定,记基准风量
+      setSimState(SimState.STABILIZE);
+      selectData.simStep = 0;
+      await sleep(adjDelay(SIM_CONFIG.stabilizeWait));
+      if (abortFlag) return;
+
+      // Step 4: TRANSFER — 12 步渐进切换
+      await runTransfer();
+      if (abortFlag) return;
+
+      // Step 5: END — 最终位置确认
+      setSimState(SimState.END);
+      setGate(targetFan, SIM_CONFIG.gateMax);
+      setWindow(targetFan, 0);
+      setGate(sourceFan, 0);
+      setWindow(sourceFan, SIM_CONFIG.targetSkylightMax);
+      targetGate = SIM_CONFIG.gateMax;
+      targetWindow = 0;
+      sourceGate = 0;
+      sourceWindow = SIM_CONFIG.targetSkylightMax;
+      await sleep(adjDelay(5));
+      if (abortFlag) return;
+
+      // Complete
+      setSimState(SimState.COMPLETE);
+      changeIsRunning.value = false;
+      updateSimRecord(switchRecId, {
+        description: `${sourceFan}#风机倒${targetFan}#风机,耗时${selectData.simElapsed}s`,
+      });
+    } finally {
+      stopTick();
+      running.value = false;
+      // 恢复运行状态为 undefined,下轮 API 轮询自动填充真实值
+      selectData.Fan1StartStatus = undefined;
+      selectData.Fan2StartStatus = undefined;
+      // 恢复频率为 undefined
+      selectData[`Fan${targetFan}FreqHz`] = undefined;
+      selectData[`Fan${sourceFan}FreqHz`] = undefined;
+      const totalReal = (Date.now() - startTime) / 1000;
+      selectData.simElapsed = Math.round(totalReal * speed.value);
+      // selectData.simElapsed = elapsed.value;
+      // selectData.simElapsed = 0;
+    }
+  }
+
+  function abort() {
+    abortFlag = true;
+    stopTick();
+    running.value = false;
+    setSimState(SimState.ABORTED);
+    // 恢复运行状态 + 频率
+    selectData.Fan1StartStatus = undefined;
+    selectData.Fan2StartStatus = undefined;
+    selectData.Fan1FreqHz = undefined;
+    selectData.Fan2FreqHz = undefined;
+    // 恢复默认值
+    initAreaDefaults();
+    selectData.simElapsed = elapsed.value;
+  }
+
+  // ============ 生命周期 ============
+
+  // 初始化面积默认值
+  initAreaDefaults();
+
+  // 监听风机状态变化 → 非模拟 + 无活跃过程时自动更新面积默认值
+  watch(
+    () => [selectData.Fan1StartStatus, selectData.Fan2StartStatus],
+    () => {
+      if (!running.value) initAreaDefaults();
+    }
+  );
+
+  // 倍速变化时更新 selectData
+  watch(speed, (val) => {
+    selectData.simSpeed = val;
+  });
+
+  onUnmounted(() => {
+    stopTick();
+  });
+
+  return {
+    // 状态
+    changeIsRunning,
+    running,
+    speed,
+    state,
+    stepIndex,
+    stepName,
+    elapsed,
+    direction,
+    airflowDelta,
+    // 方法
+    run,
+    abort,
+    initAreaDefaults,
+  };
+}

+ 116 - 0
src/views/vent/monitorManager/mainFanMonitor/hooks/useMainFanSimulation.ts

@@ -0,0 +1,116 @@
+import { reactive, watch } from 'vue';
+import { SIM_INIT_FIELDS } from '../main.data';
+import { useFanSwitchoverSimulation } from './useFanSwitchoverSimulation';
+import { useAreaSetSimulation } from './useAreaSetSimulation';
+import { addSimRecord, updateSimRecord } from './useSimHistory';
+
+/**
+ * 李家壕风机模拟业务统一入口
+ * 仅在 VENT_PARAM.fanSwitchoverSimulation 开启时执行内部逻辑
+ */
+export function useMainFanSimulation(selectData: Record<string, any>) {
+  // ========== 李家豪风机控制表单 ==========
+  const ljhCtrForm = reactive({
+    Fan1TcVal: 0,
+    Fan2TcVal: 0,
+    Fan1ZbmVal: 0,
+    Fan2ZbmVal: 0,
+  });
+
+  // ========== 模拟字段初始值(仅模拟开启时有效) ==========
+  const simInitFields = VENT_PARAM['fanSwitchoverSimulation'] ? SIM_INIT_FIELDS : {};
+
+  // ========== 内部 composables ==========
+  const simHook = useFanSwitchoverSimulation(selectData);
+  const areaSimHook = useAreaSetSimulation(selectData);
+
+  if (VENT_PARAM['fanSwitchoverSimulation']) {
+    watch(
+      () => selectData.simSpeed,
+      (val) => {
+        if (val && val !== simHook.speed.value) simHook.speed.value = val;
+      }
+    );
+  }
+
+  // ========== 事件处理 ==========
+  function onSimSpeedChange(speed: number) {
+    simHook.speed.value = speed;
+  }
+  function onSimAbort() {
+    simHook.abort();
+  }
+
+  // ========== 统一控制入口 ==========
+  function handleSimControl(modalTypeVal: string, flagVal: string, valVal: any) {
+    if (!VENT_PARAM['fanSwitchoverSimulation']) return;
+    if (modalTypeVal === 'tckz' || modalTypeVal === 'zbmkz') {
+      const type = modalTypeVal === 'tckz' ? ('window' as const) : ('gate' as const);
+      // 优先匹配 1#(避免两值相等时误判),仅当值与 1# 不匹配且与 2# 匹配时才判定为 2#
+      const isFan2 =
+        (type === 'window' && valVal === ljhCtrForm.Fan2TcVal && valVal !== ljhCtrForm.Fan1TcVal) ||
+        (type === 'gate' && valVal === ljhCtrForm.Fan2ZbmVal && valVal !== ljhCtrForm.Fan1ZbmVal);
+      const fan: '1' | '2' = isFan2 ? '2' : '1';
+      const fanLabel = `${fan}#风机`;
+      const areaKey = `Fan${fan}${type === 'window' ? 'WindowArea' : 'GateArea'}`;
+      const startVal = Number.isFinite(selectData[areaKey]) ? selectData[areaKey] : 0;
+      const typeLabel = type === 'window' ? '天窗' : '闸板门';
+      // 指令下发时先生成记录
+      const recId = addSimRecord({
+        device: selectData.strname || '-',
+        fan: fanLabel,
+        description: `将${fan}#${typeLabel}由${startVal.toFixed(2)}㎡设置为${valVal.toFixed(2)}㎡,正在执行...`,
+        type: type === 'window' ? 'skylight' : 'gate',
+      });
+      // 流程完成后更新记录
+      areaSimHook.setArea(type, fan, valVal, (info) => {
+        const desc =
+          type === 'window'
+            ? `将${info.fan}#天窗由${info.startValue.toFixed(2)}㎡设置为${info.setValue.toFixed(2)}㎡,最终面积到达${info.finalMonitor.toFixed(2)}㎡`
+            : `将${info.fan}#闸板门面积由${info.startValue.toFixed(2)}㎡设置为${info.setValue.toFixed(2)}㎡,最终面积到达${info.finalMonitor.toFixed(2)}㎡`;
+        updateSimRecord(recId, { description: desc });
+      });
+    }
+    if (modalTypeVal === 'changeSmoke' || modalTypeVal === 'changeSmoke1') {
+      simHook.run();
+    }
+  }
+
+  // ========== getSelectRow 模拟字段保存/恢复 ==========
+  const SIM_FIELD_KEYS = [
+    'Fan1WindowArea',
+    'Fan1WindowSet',
+    'Fan1GateArea',
+    'Fan1GateSet',
+    'Fan2WindowArea',
+    'Fan2WindowSet',
+    'Fan2GateArea',
+    'Fan2GateSet',
+  ];
+
+  function saveSimFields(data: Record<string, any>) {
+    const snapshot: Record<string, any> = {};
+    for (const key of SIM_FIELD_KEYS) {
+      snapshot[key] = data[key];
+    }
+    return snapshot;
+  }
+
+  function restoreSimFields(data: Record<string, any>, snapshot: Record<string, any>) {
+    for (const key of SIM_FIELD_KEYS) {
+      data[key] = snapshot[key];
+    }
+  }
+
+  return {
+    state: simHook.state,
+    running: simHook.changeIsRunning,
+    ljhCtrForm,
+    simInitFields,
+    handleSimControl,
+    onSimSpeedChange,
+    onSimAbort,
+    saveSimFields,
+    restoreSimFields,
+  };
+}

+ 40 - 0
src/views/vent/monitorManager/mainFanMonitor/hooks/useSimHistory.ts

@@ -0,0 +1,40 @@
+import { ref } from 'vue';
+import { useUserStore } from '/@/store/modules/user';
+
+export interface SimRecord {
+  id: number;
+  user: string;
+  device: string;
+  fan: string;
+  description: string;
+  time: string;
+  type: 'skylight' | 'gate' | 'explosionDoor' | 'switchover';
+}
+
+let nextId = 1;
+export const simHistory = ref<SimRecord[]>([]);
+const MAX_RECORDS = 50;
+
+export function addSimRecord(record: Omit<SimRecord, 'id' | 'user' | 'time'>): number {
+  const userStore = useUserStore();
+  const realname = userStore.getUserInfo?.realname || userStore.getUserInfo?.username || '-';
+  const d = new Date();
+  // const time = `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()} ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}:${d.getSeconds().toString().padStart(2, '0')}`;
+  const time = `2025-11-20 ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}:${d.getSeconds().toString().padStart(2, '0')}`;
+  const id = nextId++;
+  simHistory.value.unshift({
+    ...record,
+    id,
+    user: realname,
+    time,
+  });
+  if (simHistory.value.length > MAX_RECORDS) {
+    simHistory.value.length = MAX_RECORDS;
+  }
+  return id;
+}
+
+export function updateSimRecord(id: number, updates: Partial<Pick<SimRecord, 'description' | 'fan' | 'device'>>) {
+  const idx = simHistory.value.findIndex((r) => r.id === id);
+  if (idx >= 0) Object.assign(simHistory.value[idx], updates);
+}