Ver código fonte

[Feat 0000] 添加预警原因计算功能

houzekong 2 dias atrás
pai
commit
7a8a8e5edc

+ 23 - 27
src/components/Configurable/preset/BoardTable.vue

@@ -69,14 +69,24 @@
 
         <!-- 2. 单个 Board 内容展示区 -->
         <div class="board-list" :class="{ 'overflow-y-auto': boardOverflow }" v-if="currentBoardConfig">
-          <MiniBoard
-            v-for="(item, itemIndex) in getCurrentBoardItems()"
-            :key="`board-item-${itemIndex}`"
-            :label="item.label"
-            :value="item.value as string"
-            :layout="currentBoardConfig.layout || 'val-top'"
-            :type="currentBoardConfig.type || 'D'"
-          />
+          <template v-for="(item, itemIndex) in getCurrentBoardItems()" :key="`board-item-${itemIndex}`">
+            <Tooltip v-if="item.tooltip">
+              <template #title>{{ item!.tooltip }}</template>
+              <MiniBoard
+                :label="item.label"
+                :value="item.value as string"
+                :layout="currentBoardConfig.layout || 'val-top'"
+                :type="currentBoardConfig.type || 'D'"
+              />
+            </Tooltip>
+            <MiniBoard
+              v-else
+              :label="item.label"
+              :value="item.value as string"
+              :layout="currentBoardConfig.layout || 'val-top'"
+              :type="currentBoardConfig.type || 'D'"
+            />
+          </template>
         </div>
       </div>
     </div>
@@ -88,10 +98,13 @@
   import { get, isNil } from 'lodash-es';
   import MiniBoard from '../detail/MiniBoard.vue';
   import { StatusColorEnum } from '/@/enums/jeecgEnum';
+  import { Tooltip } from 'ant-design-vue';
+  import { getFormattedText } from '../hooks/helper.js';
 
   interface BoardItem {
     label: string;
     value: string | number;
+    tooltip?: string;
   }
 
   interface TableColumn {
@@ -198,27 +211,10 @@
       if (!boardCfg?.items) return [];
 
       return boardCfg.items.map((item) => {
-        const valueStr = String(item.value || '');
-        // 匹配 ${...} 格式
-        const match = valueStr.match(/\$\{(.+?)\}/);
-
-        let newValue = item.value;
-        if (match && match[1]) {
-          const fieldKey = match[1]; // 例如: "fireAlarm.alarmName"
-          const resolvedValue = get(rowData, fieldKey);
-
-          // 如果为 null/undefined,则使用默认值
-          if (!isNil(resolvedValue)) {
-            newValue = resolvedValue;
-          } else {
-            // 当值为 null/undefined 时,使用默认值 '-'
-            newValue = props.defaultValue;
-          }
-        }
-
         return {
           label: item.label,
-          value: newValue,
+          value: getFormattedText(rowData, String(item.value)),
+          tooltip: getFormattedText(rowData, item.tooltip, {}, ''),
         };
       });
     });

+ 31 - 7
src/views/dashboard/Overhaul/components/RootPage.vue

@@ -85,7 +85,7 @@
         <div class="details-header">
           <div class="title">预警数据详情</div>
           <div class="btn-group">
-            <button class="btn" @click="handleShowDetail({ deptId: mineStore.getRootId })">展开详情</button>
+            <button class="btn" @click="handleShowDetail({ deptId: mineStore.getDepartId })">展开详情</button>
           </div>
         </div>
         <div class="details-content">
@@ -169,8 +169,19 @@
 
     <!-- 实时数据表格 -->
     <BasicModal v-model:open="isDetailModalVisible" title="实时数据详情" width="70%" centered :footer="null" @cancel="handleModalClose">
-      <BasicTable @register="registerRealtimeTable" />
+      <BasicTable @register="registerRealtimeTable">
+        <template #action="{ record }">
+          <div class="action-buttons">
+            <button @click="openModal(record)" class="action-btn" title="详情">
+              <SvgIcon name="details" />
+            </button>
+          </div>
+        </template>
+      </BasicTable>
     </BasicModal>
+
+    <!-- 密闭监测详情弹框 -->
+    <HistoricalDetailsModal :modal-details-data="modalDetailsData" @register="registerDetailModal" @close="resume" />
   </div>
 </template>
 <script setup lang="ts">
@@ -191,6 +202,11 @@
   import IMG1 from '/@/assets/images/overHaul/rootPage/pie-chart-center.png';
   import IMG2 from '/@/assets/images/overHaul/rootPage/alarm-icon-9.svg';
   import { get } from 'lodash-es';
+  import HistoricalDetailsModal from '../../../monitor/sealedMonitor/components/HistoricalDetailsModal.vue';
+  import { modalDetailsData } from '../../../monitor/sealedMonitor/monitor.data';
+  import { useModal } from '/@/components/Modal';
+  import { SvgIcon } from '/@/components/Icon';
+  import { useIntervalFn } from '@vueuse/core';
 
   // 处理矿名选择器相关的逻辑
   const isDetailModalVisible = ref(false);
@@ -202,7 +218,7 @@
       params.deptId = currentDeptId.value;
     } else {
       // 默认使用 rootId,防止初始加载时无数据
-      params.deptId = mineStore.getRootId;
+      params.deptId = mineStore.getDepartId;
     }
     return getGoafData(params);
   };
@@ -226,7 +242,7 @@
       // },
     },
   });
-  const [registerRealtimeTable] = ctxRealtime;
+  const [registerRealtimeTable, realtimeTable] = ctxRealtime;
 
   const handleShowDetail = (item: any) => {
     // 设置当前选中的 deptId
@@ -236,9 +252,9 @@
 
     // 关键:手动触发表格刷新,因为 api 依赖的 ref 变化可能不会自动触发 BasicTable 的内部重载
     // 使用 nextTick 确保 DOM 更新后再重载,或者直接在 modal 的 open 事件中处理
-    // setTimeout(() => {
-    //   realtimeTable?.reload();
-    // }, 100);
+    setTimeout(() => {
+      realtimeTable?.reload();
+    }, 100);
   };
 
   const handleModalClose = () => {
@@ -246,6 +262,14 @@
     currentDeptId.value = ''; // 可选:清空状态
   };
 
+  // 详情弹窗(复用 HistoricalDetailsModal)
+  const [registerDetailModal, { openModal: openDetailModal }] = useModal();
+  const { resume } = useIntervalFn(() => {}, 60000, { immediate: false });
+
+  const openModal = (record: any) => {
+    openDetailModal(true, record);
+  };
+
   // 路由实例
   const router = useRouter();
   const mineStore = useMineDepartmentStore();

+ 3 - 3
src/views/dashboard/SealedGoaf/configurable.data.sealedGoaf.ts

@@ -1088,10 +1088,10 @@ export const testConfigSealedMine: Config[] = [
               layout: 'val-top',
               title: '预警',
               items: [
-                { label: '密闭墙内自燃发火隐患等级', value: '${fireAlarm.alarmName}' },
-                { label: '密闭墙外自燃发火隐患等级', value: '${fireAlarmOut.alarmName}' },
+                { label: '密闭墙内自燃发火隐患等级', value: '${fireAlarm.alarmName}', tooltip: '${fireAlarmReason}' },
+                { label: '密闭墙外自燃发火隐患等级', value: '${fireAlarmOut.alarmName}', tooltip: '${fireAlarmOutReason}' },
                 { label: '爆炸预警等级', value: '${explosionAlarm.alarmName}' },
-                { label: '压差隐患等级', value: '${sourcePressureAlarm.alarmName}' },
+                { label: '压差隐患等级', value: '${sourcePressureAlarm.alarmName}', tooltip: '${sourcePressureReason}' },
                 { label: '是否漏风', value: '${leakageAlarm.alarmName}' },
                 { label: '密闭启封判定', value: '${unsealAlarm.alarmName}' },
               ],

+ 27 - 0
src/views/dashboard/SealedGoaf/index.vue

@@ -52,6 +52,7 @@
   import { useAppStore } from '/@/store/modules/app';
   import { useMineDepartmentStore } from '/@/store/modules/mine';
   import SystemSelect from '/@/layouts/default/feature/SystemSelect.vue';
+  import { DEFAULT_DISPLAY_MAP, DEFAULT_OPERAND_SET, evaluateExpressionLite } from '../../system/algorithm/utils';
 
   const { title = '老空区永久密闭监测与分析系统' } = useGlobSetting();
   const { data, updateData } = useInitPage(title);
@@ -211,6 +212,32 @@
         getProvinceAlarm(appStore.simpleMapParams), // 预警信息列表
       ]);
 
+      if (monitorData?.records) {
+        monitorData.records.forEach((record) => {
+          const { coalSeamAlarmRule, sourcePressureAlarm, fireAlarm, fireAlarmReson, fireAlarmOut, fireAlarmOutReson } = record;
+          record.sourcePressureReason = evaluateExpressionLite(
+            coalSeamAlarmRule[`ycWarn${sourcePressureAlarm.alarmLevel}`],
+            ['sourcePressureChange'],
+            DEFAULT_OPERAND_SET,
+            DEFAULT_DISPLAY_MAP
+          ).join(';');
+
+          record.fireAlarmReason = evaluateExpressionLite(
+            coalSeamAlarmRule[`fireWarn${fireAlarm.alarmLevel}`],
+            fireAlarmReson,
+            DEFAULT_OPERAND_SET,
+            DEFAULT_DISPLAY_MAP
+          ).join(';');
+
+          record.fireAlarmOutReason = evaluateExpressionLite(
+            coalSeamAlarmRule[`fireOutWarn${fireAlarmOut.alarmLevel}`],
+            fireAlarmOutReson,
+            DEFAULT_OPERAND_SET,
+            DEFAULT_DISPLAY_MAP
+          ).join(';');
+        });
+      }
+
       if (provinceAlarm?.records) {
         const alarmTypeMap = {
           leakageAlarm: '老空区永久密闭漏风状态报警',

+ 2 - 1
src/views/monitor/sealedMonitor/components/HistoricalDetailsModal.vue

@@ -10,10 +10,11 @@
     @close="okHandler"
     @ok="okHandler"
     @cancel="okHandler"
+    style="top: 10px"
   >
     <!-- 基础信息栏(修改布局:每行4列,共两行) -->
     <div class="base-info">
-      <div v-for="(item, index) in modalDetailsData.basicInfo" :key="index" class="info-item">
+      <div v-for="(item, index) in modalDetailsData.basicInfo" :key="index" class="info-item" :style="item.style">
         <span class="label">{{ item.label }}:</span>
         <component :is="contextRender(item)"></component>
       </div>

+ 63 - 0
src/views/monitor/sealedMonitor/monitor.data.ts

@@ -6,6 +6,8 @@ import { ModuleDataChart } from '/@/components/Configurable/types';
 import { h } from 'vue';
 import { StatusColorEnum } from '/@/enums/jeecgEnum';
 import { map } from 'lodash-es';
+import { DEFAULT_DISPLAY_MAP, DEFAULT_OPERAND_SET, evaluateExpressionLite } from '../../system/algorithm/utils';
+import { Tag } from 'ant-design-vue';
 // import { getDictItemsByCode } from '/@/utils/dict';
 // import { get } from 'lodash-es';
 
@@ -420,6 +422,67 @@ export const modalDetailsData: ModalDetailsData = {
         return alarmCellRender(record.unsealAlarm);
       },
     },
+    {
+      label: '内外压差变化预警原因',
+      value: 'sourcePressure',
+      style: 'grid-column: span 3',
+      customRender({ record }) {
+        const { coalSeamAlarmRule, sourcePressureAlarm } = record;
+        if (!coalSeamAlarmRule || !sourcePressureAlarm) return h('span', '-');
+        const result = evaluateExpressionLite(
+          coalSeamAlarmRule[`ycWarn${sourcePressureAlarm.alarmLevel}`],
+          ['sourcePressureChange'],
+          DEFAULT_OPERAND_SET,
+          DEFAULT_DISPLAY_MAP
+        );
+
+        return h(
+          'span',
+          result.map((r) => h(Tag, { color: 'warning' }, r))
+        );
+      },
+    },
+    {
+      label: '密闭墙内自燃发火预警原因',
+      value: 'sourcePressure',
+      style: 'grid-column: span 3',
+      customRender({ record }) {
+        const { coalSeamAlarmRule, fireAlarm, fireAlarmReson } = record;
+        if (!coalSeamAlarmRule || !fireAlarm) return h('span', '-');
+        const result = evaluateExpressionLite(
+          coalSeamAlarmRule[`fireWarn${fireAlarm.alarmLevel}`],
+          fireAlarmReson,
+          DEFAULT_OPERAND_SET,
+          DEFAULT_DISPLAY_MAP
+        );
+
+        return h(
+          'span',
+          result.map((r) => h(Tag, { color: 'warning' }, r))
+        );
+      },
+    },
+    {
+      label: '密闭墙外自燃发火预警原因',
+      value: 'sourcePressure',
+      style: 'grid-column: span 3',
+      customRender({ record }) {
+        const { coalSeamAlarmRule, fireAlarmOut, fireAlarmOutReson } = record;
+        if (!coalSeamAlarmRule || !fireAlarmOut) return h('span', '-');
+
+        const result = evaluateExpressionLite(
+          coalSeamAlarmRule[`fireOutWarn${fireAlarmOut.alarmLevel}`],
+          fireAlarmOutReson,
+          DEFAULT_OPERAND_SET,
+          DEFAULT_DISPLAY_MAP
+        );
+
+        return h(
+          'span',
+          result.map((r) => h(Tag, { color: 'warning' }, r))
+        );
+      },
+    },
   ],
   board: [
     {

+ 36 - 4
src/views/system/algorithm/utils/config.ts

@@ -19,13 +19,13 @@ export const DEFAULT_TEMPLATE_OPTIONS: { label: string; value: string }[] = [
       '((coRzl>50&&coRzl<=100)||(coAvg<coZf&&coZf<=2*coAvg)||(coVal>=50&&coVal<=100)||(temperatureDifference>4&&temperatureDifference<=10))&&co2Val<0.5',
   },
   { label: '火灾预警4', value: 'coRzl>100||2*coAvg<coZf||coVal>100||temperatureDifference>10||(co2Val>=0.5&&co2Trend)' },
-  { label: '闭外火灾预警1', value: 'temperatureDifference<2||ch4Val<0.5||coVal<5||o2Val<20' },
-  { label: '闭外火灾预警2', value: 'temperatureDifference>=2&&temperatureDifference<=4||ch4Val>=0.5&&ch4Val<=1||coVal>=5&&coVal<10' },
+  { label: '外火灾预警1', value: 'temperatureDifference<2||ch4Val<0.5||coVal<5||o2Val<20' },
+  { label: '外火灾预警2', value: 'temperatureDifference>=2&&temperatureDifference<=4||ch4Val>=0.5&&ch4Val<=1||coVal>=5&&coVal<10' },
   {
-    label: '闭外火灾预警3',
+    label: '外火灾预警3',
     value: 'temperatureDifference>4&&temperatureDifference<=10&&temperature>=26||ch4Val>=1&&ch4Val<1.5||coVal>=10&&coVal<24||c2h4Val>0&&c2h2Val==0',
   },
-  { label: '闭外火灾预警4', value: 'temperatureDifference>10&&temperature>=30||ch4Val>=1.5&&ch4Val<2||coVal>=24||c2h2Val>0' },
+  { label: '外火灾预警4', value: 'temperatureDifference>10&&temperature>=30||ch4Val>=1.5&&ch4Val<2||coVal>=24||c2h2Val>0' },
   {
     label: 'OPEN',
     value:
@@ -56,6 +56,22 @@ export const DEFAULT_ELEMENT_GROUPS: ElementGroup[] = [
       { type: 'operand', value: 'fireWaterTemperature', label: '水温' },
       { type: 'operand', value: 'temperatureDifference', label: '温差' },
       { type: 'operand', value: 'stableDays', label: '稳定天数' },
+      { type: 'operand', value: 'sourcePressureChangeOut', label: '密闭墙外压差变化' },
+      { type: 'operand', value: 'coRzlOut', label: '密闭墙外CO日增率' },
+      { type: 'operand', value: 'coZfOut', label: '密闭墙外CO增幅' },
+      { type: 'operand', value: 'coAvgOut', label: '密闭墙外CO平均值' },
+      { type: 'operand', value: 'coValOut', label: '密闭墙外CO值' },
+      { type: 'operand', value: 'co2ValOut', label: '密闭墙外CO2值' },
+      { type: 'operand', value: 'co2TrendOut', label: '密闭墙外CO2存在上升趋势' },
+      { type: 'operand', value: 'o2ValOut', label: '密闭墙外O2值' },
+      { type: 'operand', value: 'ch4ValOut', label: '密闭墙外CH4值' },
+      { type: 'operand', value: 'c2h4ValOut', label: '密闭墙外C2H4值' },
+      { type: 'operand', value: 'c2h2ValOut', label: '密闭墙外C2H2值' },
+      { type: 'operand', value: 'temperatureOut', label: '密闭墙外温度' },
+      { type: 'operand', value: 'fireAirTemperatureOut', label: '密闭墙外气温' },
+      { type: 'operand', value: 'fireWaterTemperatureOut', label: '密闭墙外水温' },
+      { type: 'operand', value: 'temperatureDifferenceOut', label: '密闭墙外温差' },
+      { type: 'operand', value: 'stableDaysOut', label: '密闭墙外稳定天数' },
     ],
     extra: [],
   },
@@ -140,6 +156,22 @@ export const DEFAULT_DISPLAY_MAP: Record<string, string> = {
   fireWaterTemperature: '水温',
   temperatureDifference: '温差',
   stableDays: '稳定天数',
+  sourcePressureChangeOut: '密闭墙外压差变化',
+  coRzlOut: '密闭墙外CO日增率',
+  coZfOut: '密闭墙外CO增幅',
+  coAvgOut: '密闭墙外CO平均值',
+  coValOut: '密闭墙外CO值',
+  co2ValOut: '密闭墙外CO2值',
+  co2TrendOut: '密闭墙外CO2存在上升趋势',
+  o2ValOut: '密闭墙外O2值',
+  ch4ValOut: '密闭墙外CH4值',
+  c2h4ValOut: '密闭墙外C2H4值',
+  c2h2ValOut: '密闭墙外C2H2值',
+  temperatureOut: '密闭墙外温度',
+  fireAirTemperatureOut: '密闭墙外气温',
+  fireWaterTemperatureOut: '密闭墙外水温',
+  temperatureDifferenceOut: '密闭墙外温差',
+  stableDaysOut: '密闭墙外稳定天数',
 };
 
 /** 基于 DEFAULT_ELEMENT_GROUPS 预计算的操作数集合 */

+ 131 - 268
src/views/system/algorithm/utils/evaluator.ts

@@ -1,285 +1,110 @@
 import type { Item, EvaluateDetail, EvaluateResult } from './types';
 import { parseExpression } from './parser';
 
-/** 运算符 → 中文描述 */
-const OPERATOR_DISPLAY: Record<string, string> = {
-  '>': '大于',
-  '>=': '大于等于',
-  '<': '小于',
-  '<=': '小于等于',
-  '==': '等于',
-  '!=': '不等于',
-};
-
-/**
- * 将表达式中的变量名替换为实际数值,返回新的 token 列表。
- * 若提供了 displayMap,会用中文标签标记替换后的数字 token。
- */
-function substituteValues(
-  tokens: Item[],
-  variableValues: Record<string, number>,
-  displayMap?: Record<string, string>,
-): Item[] {
-  return tokens.map((t) => {
-    if (t.type === 'operand' && t.value in variableValues) {
-      const label = displayMap?.[t.value] || t.label || t.value;
-      return { type: 'number', value: String(variableValues[t.value]), label };
-    }
-    return { ...t };
-  });
+// ─── token 渲染 ─────────────────────────────────────────────
+
+function renderTokens(tokens: Item[], displayMap: Record<string, string>): string {
+  return tokens
+    .map((t) => {
+      if (t.type === 'number' && t.label) return t.label;
+      if (t.type === 'operator') return displayMap[t.value] || t.value;
+      if (t.type === 'logic') return displayMap[t.value] || t.value;
+      if (t.type === 'operand') return displayMap[t.value] || t.label || t.value;
+      return t.value;
+    })
+    .join(' ');
 }
 
-/**
- * 简易算术求值 — 支持 + - * / 和括号(不含函数调用)
- */
-class ArithmeticEvaluator {
-  private pos: number;
-  private tokens: Item[];
-
-  constructor(tokens: Item[]) {
-    this.tokens = tokens;
-    this.pos = 0;
-  }
-
-  private peek(): Item | null {
-    return this.pos < this.tokens.length ? this.tokens[this.pos] : null;
-  }
-
-  private consume(): Item {
-    return this.tokens[this.pos++];
-  }
-
-  parseAddSub(): number {
-    let left = this.parseMulDiv();
-    while (this.peek() && (this.peek()!.value === '+' || this.peek()!.value === '-')) {
-      const op = this.consume().value;
-      const right = this.parseMulDiv();
-      if (op === '+') left += right;
-      else left -= right;
-    }
-    return left;
-  }
-
-  private parseMulDiv(): number {
-    let left = this.parseAtom();
-    while (this.peek() && (this.peek()!.value === '*' || this.peek()!.value === '/')) {
-      const op = this.consume().value;
-      const right = this.parseAtom();
-      if (op === '*') left *= right;
-      else left /= right;
-    }
-    return left;
-  }
-
-  private parseAtom(): number {
-    const token = this.peek();
-    if (!token) throw new Error('意外的表达式结尾');
+// ─── OR 分支拆分 ────────────────────────────────────────────
 
-    if (token.type === 'number') {
-      this.consume();
-      return parseFloat(token.value);
-    }
+function splitOrBranches(tokens: Item[]): Item[][] {
+  const branches: Item[][] = [];
+  let current: Item[] = [];
+  let depth = 0;
 
-    if (token.type === 'leftParen') {
-      this.consume();
-      const val = this.parseAddSub();
-      if (this.peek()?.type !== 'rightParen') throw new Error('缺少右括号');
-      this.consume();
-      return val;
-    }
+  for (const t of tokens) {
+    if (t.type === 'leftParen') depth++;
+    else if (t.type === 'rightParen') depth--;
 
-    if (token.type === 'operator' && token.value === '-') {
-      this.consume();
-      return -this.parseAtom();
+    if (depth === 0 && t.type === 'logic' && t.value === '||') {
+      branches.push(current);
+      current = [];
+    } else {
+      current.push(t);
     }
-
-    throw new Error(`无法识别的操作数: ${token.value}`);
-  }
-
-  evaluate(): number {
-    return this.parseAddSub();
   }
+  if (current.length > 0) branches.push(current);
+  return branches;
 }
 
-function evaluateComparison(left: number, operator: string, right: number): boolean {
-  switch (operator) {
-    case '>':  return left > right;
-    case '>=': return left >= right;
-    case '<':  return left < right;
-    case '<=': return left <= right;
-    case '==': return left === right;
-    case '!=': return left !== right;
-    default:   throw new Error(`不支持的比较运算符: ${operator}`);
-  }
+function branchContains(branch: Item[], vars: Set<string>): boolean {
+  return branch.some((t) => t.type === 'operand' && vars.has(t.value));
 }
 
+// ─── 原子条件提取 ──────────────────────────────────────────
+
 /**
- * 比较运算求值器 — 同时记录每个原子条件的求值详情(含可读原因)。
+ * 将 token 列表递归拆解为原子比较条件的 token 组。
+ *
+ * 步骤:
+ *  1. 递归去掉最外层匹配的括号
+ *  2. 在顶层 && / || 处拆分
+ *  3. 对仍含括号的片段递归处理
+ *
+ * 例如 `(coVal>10 && coVal<=50) || co2Val<0.5` →
+ *  [[coVal,>,10], [coVal,<=,50], [co2Val,<,0.5]]
  */
-class ComparisonExtractor {
-  private tokens: Item[];
-  private pos: number;
-  private details: EvaluateDetail[];
-  private displayMap: Record<string, string>;
-
-  constructor(tokens: Item[], displayMap: Record<string, string>) {
-    this.tokens = tokens;
-    this.pos = 0;
-    this.details = [];
-    this.displayMap = displayMap;
-  }
-
-  private peek(): Item | null {
-    return this.pos < this.tokens.length ? this.tokens[this.pos] : null;
-  }
-
-  private consume(): Item {
-    return this.tokens[this.pos++];
-  }
-
-  private hasMore(): boolean {
-    return this.pos < this.tokens.length;
-  }
-
-  parseOr(): boolean {
-    let left = this.parseAnd();
-    while (this.peek() && this.peek()!.type === 'logic' && this.peek()!.value === '||') {
-      this.consume();
-      const right = this.parseAnd();
-      left = left || right;
+function extractAtomicSegments(tokens: Item[]): Item[][] {
+  // 递归去掉最外层匹配的括号
+  while (tokens.length >= 2 && tokens[0].type === 'leftParen') {
+    let d = 0;
+    let matched = true;
+    for (let i = 0; i < tokens.length; i++) {
+      if (tokens[i].type === 'leftParen') d++;
+      if (tokens[i].type === 'rightParen') d--;
+      if (d === 0 && i < tokens.length - 1) { matched = false; break; }
     }
-    return left;
+    if (matched) tokens = tokens.slice(1, -1);
+    else break;
   }
 
-  private parseAnd(): boolean {
-    let left = this.parseRelation();
-    while (this.peek() && this.peek()!.type === 'logic' && this.peek()!.value === '&&') {
-      this.consume();
-      const right = this.parseRelation();
-      left = left && right;
-    }
-    return left;
-  }
-
-  private parseRelation(): boolean {
-    const token = this.peek();
-    if (!token) throw new Error('意外的表达式结尾');
-
-    if (token.type === 'leftParen') {
-      this.consume();
-      const val = this.parseOr();
-      if (this.peek()?.type !== 'rightParen') throw new Error('缺少右括号');
-      this.consume();
-      return val;
-    }
-
-    return this.parseAtomicComparison();
-  }
-
-  /**
-   * 将 token 列表渲染为人类可读的文本
-   * - 数字 token 优先使用其 label(替换变量时设的中文名,如"压差")
-   * - 运算符、逻辑符、操作数均尝试通过 displayMap 转为中文(如 >= → ≥, && → 且)
-   * - 无映射的 token 回退显示原文
-   */
-  private renderTokens(tokens: Item[]): string {
-    return tokens
-      .map((t) => {
-        // 数字 token 优先使用 label(替换变量时设的中文名)
-        if (t.type === 'number' && t.label) return t.label;
-        // 运算符尝试走 displayMap(>= → ≥)
-        if (t.type === 'operator') return this.displayMap[t.value] || t.value;
-        // 逻辑符
-        if (t.type === 'logic') return this.displayMap[t.value] || t.value;
-        // 操作数
-        if (t.type === 'operand') return this.displayMap[t.value] || t.label || t.value;
-        return t.value;
-      })
-      .join(' ');
-  }
-
-  /** 解析并求值一个原子比较条件,同时记录可读的 condition 和失败原因 */
-  private parseAtomicComparison(): boolean {
-    const comparisonOps = new Set(['>', '>=', '<', '<=', '==', '!=']);
+  // 在顶层 && / || 处拆分
+  const segments: Item[][] = [];
+  let current: Item[] = [];
+  let depth = 0;
 
-    // 读取左侧算术表达式
-    const leftTokens: Item[] = [];
-    while (this.hasMore()) {
-      const t = this.peek()!;
-      if (t.type === 'logic' && (t.value === '&&' || t.value === '||')) break;
-      if (t.type === 'leftParen' || t.type === 'rightParen') break;
-      if (t.type === 'operator' && comparisonOps.has(t.value)) break;
-      leftTokens.push(this.consume());
-    }
-
-    if (!this.peek() || !(this.peek()!.type === 'operator' && comparisonOps.has(this.peek()!.value))) {
-      const leftVal = new ArithmeticEvaluator(leftTokens).evaluate();
-      return leftVal !== 0;
-    }
-
-    const opToken = this.consume();
-    const operator = opToken.value;
-
-    // 读取右侧算术表达式
-    const rightTokens: Item[] = [];
-    while (this.hasMore()) {
-      const t = this.peek()!;
-      if (t.type === 'logic' && (t.value === '&&' || t.value === '||')) break;
-      if (t.type === 'leftParen' || t.type === 'rightParen') break;
-      rightTokens.push(this.consume());
-    }
-
-    // 渲染为可读文本
-    const leftStr = this.renderTokens(leftTokens);
-    const rightStr = this.renderTokens(rightTokens);
-    const operatorStr = OPERATOR_DISPLAY[operator] || operator;
-
-    // 求值
-    const leftVal = new ArithmeticEvaluator(leftTokens).evaluate();
-    const rightVal = new ArithmeticEvaluator(rightTokens).evaluate();
-    const passes = evaluateComparison(leftVal, operator, rightVal);
+  for (const t of tokens) {
+    if (t.type === 'leftParen') depth++;
+    else if (t.type === 'rightParen') depth--;
 
-    const condition = `${leftStr} ${this.displayMap[operator] || operator} ${rightStr}`;
-
-    if (passes) {
-      this.details.push({ condition, passes: true });
+    if (depth === 0 && t.type === 'logic') {
+      if (current.length > 0) { segments.push(current); current = []; }
     } else {
-      this.details.push({
-        condition,
-        passes: false,
-        reason: `${leftStr}当前为 ${leftVal},不满足${operatorStr}${rightStr}的条件`,
-      });
+      current.push(t);
     }
-
-    return passes;
   }
+  if (current.length > 0) segments.push(current);
 
-  getDetails(): EvaluateDetail[] {
-    return this.details;
-  }
+  // 对仍含括号的片段递归
+  return segments.flatMap((s) =>
+    s.some((t) => t.type === 'leftParen') ? extractAtomicSegments(s) : [s],
+  );
 }
 
+// ─── 对外接口 ───────────────────────────────────────────────
+
 /**
- * 根据传入的变量值,判断表达式是否能通过,返回每个条件的详细原因。
- *
- * @param expr          - 表达式字符串,例如 "sourcePressure > 500 && sourcePressure < 2000"
- * @param variableValues - 变量名到数值的映射,例如 { sourcePressure: 1000, coVal: 5 }
- * @param operandSet    - 已知操作数集合(由 buildOperandSet 生成)
- * @param displayMap    - 可选的变量名/运算符 → 中文标签映射,用于生成可读原因
- * @returns 求值结果,含每个条件的通过状态和可读原因
+ * 从表达式中提取涉及指定关键字的条件,返回可读解释。
  *
- * @example
- * // displayMap 传入 DEFAULT_DISPLAY_MAP 可使原因使用中文描述
- * evaluateExpression("sourcePressure > 500", { sourcePressure: 100 }, operandSet, displayMap)
- * // => {
- * //   passes: false,
- * //   details: [{ condition: "压差 > 500", passes: false, reason: "压差当前为 100,不满足大于 500 的条件" }],
- * //   summary: "条件不满足(0/1): 压差当前为 100,不满足大于 500 的条件"
- * // }
+ * @param expr       - 表达式,如 "coValOut>=0 && coValOut<5"
+ * @param reasons    - 关键字列表,如 ['coValOut', 'o2ValOut']
+ * @param operandSet - 操作数集合
+ * @param displayMap - 变量名/运算符 → 中文标签映射
+ * @returns { passes, details, summary }
  */
 export function evaluateExpression(
   expr: string,
-  variableValues: Record<string, number>,
+  reasons: string[],
   operandSet: Set<string>,
   displayMap?: Record<string, string>,
 ): EvaluateResult {
@@ -288,27 +113,65 @@ export function evaluateExpression(
     return { passes: false, details: [], summary: '表达式为空或无法解析' };
   }
 
-  const substituted = substituteValues(tokens, variableValues, displayMap);
+  const relevantVars = new Set(reasons);
 
-  try {
-    const extractor = new ComparisonExtractor(substituted, displayMap || {});
-    const passes = extractor.parseOr();
-    const details = extractor.getDetails();
+  // 按顶层 || 拆分,只保留涉及传入关键字的 OR 分支
+  const branches = splitOrBranches(tokens).filter((b) => branchContains(b, relevantVars));
 
-    const passedCount = details.filter((d) => d.passes).length;
-    const failedDetails = details.filter((d) => !d.passes);
+  if (branches.length === 0) {
+    return {
+      passes: false,
+      details: [],
+      summary: `表达式中未找到涉及 ${reasons.join('、')} 的条件`,
+    };
+  }
 
-    let summary: string;
-    if (passes) {
-      summary = `全部 ${details.length} 个条件满足`;
-    } else {
-      const reasons = failedDetails.map((d) => d.reason || d.condition).join(';');
-      summary = `条件不满足(${passedCount}/${details.length}): ${reasons}`;
-    }
+  // 逐分支拆解为原子条件,只保留涉及 reasons 的
+  const allDetails: EvaluateDetail[] = branches.flatMap((b) => {
+    const atoms = extractAtomicSegments(b);
+    return atoms
+      .filter((atom) => atom.some((t) => t.type === 'operand' && relevantVars.has(t.value)))
+      .map((atom) => ({
+        condition: renderTokens(atom, displayMap || {}),
+        passes: true,
+      }));
+  });
 
-    return { passes, details, summary };
-  } catch (e: unknown) {
-    const message = e instanceof Error ? e.message : String(e);
-    return { passes: false, details: [], summary: `表达式求值出错: ${message}` };
+  if (allDetails.length === 0) {
+    return {
+      passes: false,
+      details: [],
+      summary: `表达式中未找到涉及 ${reasons.join('、')} 的条件`,
+    };
   }
+
+  const summary = allDetails.map((d) => d.condition).join(',');
+  return { passes: true, details: allDetails, summary };
+}
+
+/**
+ * evaluateExpression 的简化版,直接返回匹配条件的文本,用 ; 连接。
+ *
+ * @param expr       - 表达式
+ * @param reasons    - 关键字列表
+ * @param operandSet - 操作数集合
+ * @param displayMap - 中文标签映射
+ * @returns 匹配条件的可读文本,如 "闭外氧气 < 20;闭外一氧化碳 ≥ 0 且 闭外一氧化碳 < 5"
+ */
+export function evaluateExpressionLite(
+  expr: string,
+  reasons: string[],
+  operandSet: Set<string>,
+  displayMap?: Record<string, string>,
+): string[] {
+  const tokens = parseExpression(expr, operandSet);
+  if (tokens.length === 0) return [];
+
+  const relevantVars = new Set(reasons);
+  const branches = splitOrBranches(tokens).filter((b) => branchContains(b, relevantVars));
+
+  return branches
+    .flatMap((b) => extractAtomicSegments(b))
+    .filter((atom) => atom.some((t) => t.type === 'operand' && relevantVars.has(t.value)))
+    .map((atom) => renderTokens(atom, displayMap || {}));
 }

+ 1 - 1
src/views/system/algorithm/utils/index.ts

@@ -2,7 +2,7 @@ export type { Item, ItemType, ElementGroup, ValidationResult, EvaluateDetail, Ev
 
 export { buildOperandSet, parseExpression, formatDisplay } from './parser';
 export { validateExpression, checkTopLevelMixed, addParenthesesForAnd } from './validator';
-export { evaluateExpression } from './evaluator';
+export { evaluateExpression, evaluateExpressionLite } from './evaluator';
 export {
   DEFAULT_TEMPLATE_OPTIONS,
   DEFAULT_ELEMENT_GROUPS,

+ 4 - 1
src/views/system/algorithm/utils/parser.ts

@@ -19,7 +19,10 @@ export function buildOperandSet(elementGroups: ElementGroup[]): Set<string> {
 export function parseExpression(str: string, operandSet: Set<string>): Item[] {
   if (!str?.trim()) return [];
 
-  const operandPattern = [...operandSet].map((v) => v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
+  const operandPattern = [...operandSet]
+    .sort((a, b) => b.length - a.length) // 长名优先,避免 "temperature" 吃掉 "temperatureDifferenceOut"
+    .map((v) => v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
+    .join('|');
   const regex = new RegExp(`(${operandPattern}|==|>=|<=|&&|\\|\\||-?\\d+(?:\\.\\d+)?|[>+\\-*/<()])`, 'g');
   const rawTokens = str.match(regex);
 

+ 136 - 0
tests/eva.test.ts

@@ -0,0 +1,136 @@
+/**
+ * evaluateExpressionLite 测试 — 基于 RAW 数据集
+ *
+ * 使用方式:
+ *   npx jest tests/eva.test.ts
+ */
+import { evaluateExpressionLite } from '../src/views/system/algorithm/utils/evaluator';
+import { DEFAULT_DISPLAY_MAP, DEFAULT_OPERAND_SET } from '../src/views/system/algorithm/utils/config';
+
+const RAW = [
+  {
+    name: '压差测试陕西益东矿业有限责任公司',
+    expr: 'sourcePressureChange>80',
+    reasons: ['sourcePressureChange'],
+  },
+  {
+    name: '闭内火灾测试陕西益东矿业有限责任公司',
+    expr: '((coRzl>20 && coRzl<=50) ||  (0.5*coAvg<coZf && coZf<=coAvg)  || (coVal>10 && coVal<=50) || (temperatureDifference>=2 && temperatureDifference<=4)) && co2Val<0.5',
+    reasons: ['coVal', 'co2Val'],
+  },
+  {
+    name: '闭外火灾测试陕西益东矿业有限责任公司',
+    expr: '(temperatureDifferenceOut>10 && temperatureOut>=30) || (ch4ValOut>=1.5 && ch4ValOut<2) || coValOut>=24 || c2h2ValOut>0',
+    reasons: ['coValOut'],
+  },
+  {
+    name: '压差测试陕西益东矿业有限责任公司',
+    expr: 'sourcePressureChange>80',
+    reasons: ['sourcePressureChange'],
+  },
+  {
+    name: '闭内火灾测试陕西益东矿业有限责任公司',
+    expr: '((coRzl>50 && coRzl<=100) ||  (coAvg<coZf && coZf<=2*coAvg)  || (coVal>=50 && coVal<=100) || (temperatureDifference>4 && temperatureDifference<=10)) && co2Val<0.5  || (c2h4Val>0 && c2h2Val==0)',
+    reasons: ['c2h2Val', 'co2Val', 'coRzl'],
+  },
+  {
+    name: '闭外火灾测试陕西益东矿业有限责任公司',
+    expr: 'temperatureDifferenceOut<2 || (ch4ValOut>0 && ch4ValOut<0.5) || (coValOut>=0 && coValOut<5) || o2ValOut < 20',
+    reasons: ['o2ValOut', 'coValOut'],
+  },
+  {
+    name: '压差测试陕西益东矿业有限责任公司',
+    expr: 'sourcePressureChange>80',
+    reasons: ['sourcePressureChange'],
+  },
+  {
+    name: '闭内火灾测试陕西益东矿业有限责任公司',
+    expr: '(((coRzl<=20 || coZf<=0.5*coAvg) && coVal<=10)||o2Val<=5 ) && temperatureDifference<2 && co2Val<0.5',
+    reasons: ['coVal', 'coZf', 'coRzl'],
+  },
+  {
+    name: '闭外火灾测试陕西益东矿业有限责任公司',
+    expr: '(temperatureDifferenceOut>10 && temperatureOut>=30) || (ch4ValOut>=1.5 && ch4ValOut<2) || coValOut>=24 || c2h2ValOut>0',
+    reasons: ['coValOut'],
+  },
+  {
+    name: '压差测试陕西益东矿业有限责任公司',
+    expr: 'sourcePressureChange>80',
+    reasons: ['sourcePressureChange'],
+  },
+  {
+    name: '闭内火灾测试陕西益东矿业有限责任公司',
+    expr: '((coRzl>20 && coRzl<=50) ||  (0.5*coAvg<coZf && coZf<=coAvg)  || (coVal>10 && coVal<=50) || (temperatureDifference>=2 && temperatureDifference<=4)) && co2Val<0.5',
+    reasons: ['coVal', 'co2Val'],
+  },
+  {
+    name: '闭外火灾测试陕西益东矿业有限责任公司',
+    expr: '(temperatureDifferenceOut>4 && temperatureDifferenceOut<=10 && temperatureOut>=26) || (ch4ValOut>=1 && ch4ValOut<1.5) || (coValOut>=10 && coValOut<24)  || (c2h4ValOut>0 && c2h2ValOut==0)',
+    reasons: ['c2h2ValOut', 'coValOut'],
+  },
+  {
+    name: '压差测试陕西益东矿业有限责任公司',
+    expr: 'sourcePressureChange>80',
+    reasons: ['sourcePressureChange'],
+  },
+  {
+    name: '闭内火灾测试陕西益东矿业有限责任公司',
+    expr: '((coRzl>20 && coRzl<=50) ||  (0.5*coAvg<coZf && coZf<=coAvg)  || (coVal>10 && coVal<=50) || (temperatureDifference>=2 && temperatureDifference<=4)) && co2Val<0.5',
+    reasons: ['coVal', 'co2Val', 'coRzl'],
+  },
+  {
+    name: '闭外火灾测试陕西益东矿业有限责任公司',
+    expr: '(temperatureDifferenceOut>=2 && temperatureDifferenceOut<=4) || (ch4ValOut>=0.5 && ch4ValOut <=1) || (coValOut>=5 && coValOut<10)',
+    reasons: ['coValOut'],
+  },
+  {
+    name: '闭外火灾测试红柳林煤矿',
+    expr: 'temperatureDifferenceOut<2 || (ch4ValOut>0 && ch4ValOut<0.5) || (coValOut>=0 && coValOut<5) || o2ValOut < 20',
+    reasons: ['coValOut'],
+  },
+  {
+    name: '闭外火灾测试红柳林煤矿',
+    expr: 'temperatureDifferenceOut<2 || (ch4ValOut>0 && ch4ValOut<0.5) || (coValOut>=0 && coValOut<5) || o2ValOut < 20',
+    reasons: ['coValOut'],
+  },
+  {
+    name: '闭外火灾测试红柳林煤矿',
+    expr: 'temperatureDifferenceOut<2 || (ch4ValOut>0 && ch4ValOut<0.5) || (coValOut>=0 && coValOut<5) || o2ValOut < 20',
+    reasons: ['coValOut'],
+  },
+  {
+    name: '闭外火灾测试红柳林煤矿',
+    expr: 'temperatureDifferenceOut<2 || (ch4ValOut>0 && ch4ValOut<0.5) || (coValOut>=0 && coValOut<5) || o2ValOut < 20',
+    reasons: ['coValOut'],
+  },
+  {
+    name: '闭外火灾测试红柳林煤矿',
+    expr: 'temperatureDifferenceOut<2 || (ch4ValOut>0 && ch4ValOut<0.5) || (coValOut>=0 && coValOut<5) || o2ValOut < 20',
+    reasons: ['coValOut'],
+  },
+  {
+    name: '闭外火灾测试红柳林煤矿',
+    expr: 'temperatureDifferenceOut<2 || (ch4ValOut>0 && ch4ValOut<0.5) || (coValOut>=0 && coValOut<5) || o2ValOut < 20',
+    reasons: ['coValOut'],
+  },
+  {
+    name: '闭外火灾测试红柳林煤矿',
+    expr: 'temperatureDifferenceOut<2 || (ch4ValOut>0 && ch4ValOut<0.5) || (coValOut>=0 && coValOut<5) || o2ValOut < 20',
+    reasons: ['coValOut'],
+  },
+];
+
+describe('evaluateExpressionLite', () => {
+  RAW.forEach(({ name, expr, reasons }) => {
+    const result = evaluateExpressionLite(expr, reasons, DEFAULT_OPERAND_SET, DEFAULT_DISPLAY_MAP);
+
+    // 验证每个 reason 关键字都在 summary 中被提及
+    it(name, () => {
+      console.log(`[${name}]预警原因:${reasons}`);
+      console.log(result);
+      expect(result.length).toBeGreaterThanOrEqual(0);
+
+      // DEFAULT_OPERAND_SET.
+    });
+  });
+});