Browse Source

[Feat 0000] 预警模型表达式表单开发及初步对接

houzekong 2 tháng trước cách đây
mục cha
commit
28d4813fdf

+ 770 - 0
src/views/system/algorithm/RelationBuilder.vue

@@ -0,0 +1,770 @@
+<template>
+  <div class="relation-builder">
+    <a-input-group v-if="compact" compact>
+      <a-textarea
+        :value="modelValue"
+        style="width: calc(100% - 100px)"
+        placeholder="例如: CO > 0 && CO2 > 0"
+        allow-clear
+        auto-size
+        @update:value="emit('update:modelValue', $event)"
+      />
+      <a-button class="w-100px" type="primary" @click="enterAdvancedMode"> 高级编辑 </a-button>
+    </a-input-group>
+    <a-row v-else :gutter="8">
+      <!-- 左侧:元素库 -->
+      <a-col :span="12">
+        <a-card title="元素库" size="small">
+          <a-collapse class="relation-card" v-model:activeKey="activeKey" :bordered="false">
+            <a-collapse-panel v-for="group in elementGroups" :key="group.key" :header="group.header">
+              <div class="chip-container">
+                <div
+                  v-for="item in group.items"
+                  :key="item.value"
+                  class="chip"
+                  :class="`chip-${group.type}`"
+                  draggable="true"
+                  @click="appendItem(item)"
+                  @dragstart="onDragStart($event, item)"
+                >
+                  {{ item.label }}
+                </div>
+              </div>
+              <div class="extra-container">
+                <div v-for="item in group.extra" :key="item.type" class="mt-8px">
+                  <a-input-number v-model:value="item.value" :defaultValue="0">
+                    <template #addonAfter>
+                      <span style="cursor: pointer" draggable="true" @click="appendItem(item)" @dragstart="onDragStart($event, item)">
+                        追加 | 插入
+                      </span>
+                    </template>
+                  </a-input-number>
+                </div>
+              </div>
+            </a-collapse-panel>
+          </a-collapse>
+          <template #actions>
+            <div class="relation-card-footer">
+              <a-typography-text class="pt-6px pb-6px" type="secondary"> <InfoCircleOutlined /> 点击追加 | 拖拽插入 </a-typography-text>
+            </div>
+          </template>
+        </a-card>
+      </a-col>
+
+      <!-- 中间:画布 -->
+      <a-col :span="12">
+        <a-card title="表达式画布" size="small">
+          <template #extra>
+            <a-tooltip title="清空">
+              <a-button size="small" danger @click="clearCanvas">
+                <DeleteOutlined />
+              </a-button>
+            </a-tooltip>
+          </template>
+
+          <div class="relation-card">
+            <div class="canvas-container" :class="{ 'drag-over': isDragOver }" @dragover.prevent="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
+              <a-empty v-if="!items.length" description="从左侧点击或拖拽元素" />
+              <div v-else class="expression-list">
+                <a-tag v-for="(item, index) in items" :key="index" :color="tagColors[item.type]" class="expr-tag" closable @close="removeItem(index)">
+                  <span class="mr-4px">{{ formatDisplay(item) }}</span>
+                </a-tag>
+              </div>
+            </div>
+
+            <a-divider style="margin: 12px 0" />
+            <a-space direction="vertical" style="width: 100%" :size="16">
+              <a-typography-text strong>当前表达式</a-typography-text>
+              <span>{{ expressionString }}</span>
+              <a-alert v-if="errorMessage" :message="errorMessage" type="error" show-icon closable @close="errorMessage = ''" />
+
+              <div>
+                <a-typography-text strong>快速模板</a-typography-text>
+                <a-input-group compact>
+                  <a-select v-model:value="selectedTemplate" :options="templateOptions" style="width: calc(100% - 100px)" />
+                  <a-button class="w-100px" type="primary" @click="applyTemplate">应用模板</a-button>
+                </a-input-group>
+              </div>
+
+              <div>
+                <a-typography-text strong>输入表达式</a-typography-text>
+                <a-input-group compact>
+                  <a-input v-model:value="inputString" style="width: calc(100% - 100px)" placeholder="例如: CO > 0 && CO2 > 0" allow-clear />
+                  <a-button class="w-100px" type="primary" @click="applyManualInput"> 应用到画布 </a-button>
+                </a-input-group>
+              </div>
+            </a-space>
+          </div>
+
+          <template #actions>
+            <div class="relation-card-footer" style="text-align: right">
+              <a-button class="mr-10px" @click="backToCompact">普通模式</a-button>
+              <a-button class="mr-10px" @click="reset">重置</a-button>
+              <a-button class="mr-12px" type="primary" @click="confirm">提交</a-button>
+            </div>
+          </template>
+        </a-card>
+      </a-col>
+    </a-row>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref, computed, watch, h } from 'vue';
+  import { cloneDeep } from 'lodash-es';
+  import { message, Modal } from 'ant-design-vue';
+
+  // 类型定义
+  interface Item {
+    type: 'operand' | 'operator' | 'logic' | 'number' | 'leftParen' | 'rightParen';
+    value: string;
+    label?: string;
+  }
+
+  interface ElementGroup {
+    key: string;
+    header: string;
+    type: string;
+    items: Item[];
+    extra: Item[];
+  }
+
+  const props = defineProps<{ modelValue: string }>();
+  const emit = defineEmits<{
+    (e: 'update:modelValue', value: string): void;
+    (e: 'change', value: string): void;
+    (e: 'confirm', value: string): void;
+  }>();
+
+  // 模式状态
+  const compact = ref<boolean>(true);
+  // 表达式字符串,配合画布显示
+  const inputString = ref<string>('');
+  // 高级模式画布数据
+  const items = ref<Item[]>([]);
+  // 手动输入区域绑定
+  const manualInput = ref<string>('');
+  // 错误信息
+  const errorMessage = ref<string>('');
+  // 模板选择值
+  const selectedTemplate = ref<string>('');
+
+  const activeKey = ref<string[]>(['gas', 'operator', 'logic', 'number', 'paren']);
+
+  // 模板配置
+  const templateOptions = ref([
+    { label: '压差预警1', value: '20<=sourcePressureChange && sourcePressureChange<=40' },
+    { label: '压差预警2', value: '40<sourcePressureChange && sourcePressureChange<=60' },
+    { label: '压差预警3', value: '60<sourcePressureChange && sourcePressureChange<=80' },
+    { label: '压差预警4', value: '80<sourcePressureChange' },
+    { label: '火灾预警1', value: '((coRzl<=20||coZf<=0.5*coAvg)&&coVal<=10)||o2Val<=5&&temperatureDifference<2&&co2Val<0.5' },
+    {
+      label: '火灾预警2',
+      value:
+        '((coRzl>20&&coRzl<=50)||(0.5*coAvg<coZf&&coZf<=coAvg)||(coVal>10&&coVal<=50)||(temperatureDifference>=2&&temperatureDifference<=4))&&co2Val<0.5',
+    },
+    {
+      label: '火灾预警3',
+      value:
+        '((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: '闭外火灾预警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: 'OPEN',
+      value:
+        '(temperature<30||-3<temperature-fireAirTemperature&&temperature-fireAirTemperature<3)&&o2Val<5&&c2h4Val<0.001&&c2h2Val<0.001&&coVal<0.001&&(temperature<25||-3<temperature-fireWaterTemperature&&temperature-fireWaterTemperature<3)&&stableDays>30',
+    },
+  ]);
+
+  // 元素库配置
+  const elementGroups: ElementGroup[] = [
+    {
+      key: 'gas',
+      header: '监测变量',
+      type: 'operand',
+      items: [
+        { type: 'operand', value: 'sourcePressureChange', label: '压差变化' },
+        { type: 'operand', value: 'coRzl', label: 'CO日增率' },
+        { type: 'operand', value: 'coZf', label: 'CO增幅' },
+        { type: 'operand', value: 'coAvg', label: 'CO平均值' },
+        { type: 'operand', value: 'coVal', label: 'CO值' },
+        { type: 'operand', value: 'co2Val', label: 'CO2值' },
+        { type: 'operand', value: 'co2Trend', label: 'CO2存在上升趋势' },
+        { type: 'operand', value: 'o2Val', label: 'O2值' },
+        { type: 'operand', value: 'ch4Val', label: 'CH4值' },
+        { type: 'operand', value: 'c2h4Val', label: 'C2H4值' },
+        { type: 'operand', value: 'c2h2Val', label: 'C2H2值' },
+        { type: 'operand', value: 'temperature', label: '温度' },
+        { type: 'operand', value: 'fireAirTemperature', label: '气温' },
+        { type: 'operand', value: 'fireWaterTemperature', label: '水温' },
+        { type: 'operand', value: 'temperatureDifference', label: '温差' },
+        { type: 'operand', value: 'stableDays', label: '稳定天数' },
+      ],
+      extra: [],
+    },
+    {
+      key: 'operator',
+      header: '计算符',
+      type: 'operator',
+      items: [
+        { type: 'operator', value: '>', label: '>' },
+        { type: 'operator', value: '>=', label: '≥' },
+        { type: 'operator', value: '<', label: '<' },
+        { type: 'operator', value: '<=', label: '≤' },
+        { type: 'operator', value: '==', label: '=' },
+        { type: 'operator', value: '+', label: '+' },
+        { type: 'operator', value: '-', label: '-' },
+        { type: 'operator', value: '*', label: '*' },
+        { type: 'operator', value: '/', label: '/' },
+      ],
+      extra: [],
+    },
+    {
+      key: 'logic',
+      header: '逻辑符',
+      type: 'logic',
+      items: [
+        { type: 'logic', value: '&&', label: '且' },
+        { type: 'logic', value: '||', label: '或' },
+      ],
+      extra: [],
+    },
+    {
+      key: 'paren',
+      header: '括号',
+      type: 'paren',
+      items: [
+        { type: 'leftParen', value: '(', label: '(' },
+        { type: 'rightParen', value: ')', label: ')' },
+      ],
+      extra: [],
+    },
+    {
+      key: 'number',
+      header: '数值',
+      type: 'number',
+      items: [
+        { type: 'number', value: '0', label: '0' },
+        { type: 'number', value: '10', label: '10' },
+        { type: 'number', value: '20', label: '20' },
+        { type: 'number', value: '30', label: '30' },
+        { type: 'number', value: '40', label: '40' },
+        { type: 'number', value: '50', label: '50' },
+        { type: 'number', value: '60', label: '60' },
+        { type: 'number', value: '70', label: '70' },
+        { type: 'number', value: '80', label: '80' },
+        { type: 'number', value: '90', label: '90' },
+        { type: 'number', value: '100', label: '100' },
+      ],
+      extra: [{ type: 'number', value: '0', label: '追加自定义值' }],
+    },
+  ];
+
+  // 标签颜色映射(供模板使用)
+  const tagColors: Record<string, string> = {
+    operand: 'blue',
+    operator: 'green',
+    logic: 'orange',
+    number: 'purple',
+    leftParen: 'default',
+    rightParen: 'default',
+  };
+  const displayMap: Record<string, string> = {
+    '&&': '且',
+    '||': '或',
+    '>=': '≥',
+    '<=': '≤',
+    '==': '=',
+    sourcePressureChange: '压差变化',
+    coRzl: 'CO日增率',
+    coZf: 'CO增幅',
+    coAvg: 'CO平均值',
+    coVal: 'CO值',
+    co2Val: 'CO2值',
+    co2Trend: 'CO2存在上升趋势',
+    o2Val: 'O2值',
+    ch4Val: 'CH4值',
+    c2h4Val: 'C2H4值',
+    c2h2Val: 'C2H2值',
+    temperature: '温度',
+    fireAirTemperature: '气温',
+    fireWaterTemperature: '水温',
+    temperatureDifference: '温差',
+    stableDays: '稳定天数',
+  };
+
+  // 当前画布表达式字符串
+  const expressionString = computed(() => items.value.map((i) => i.value).join(' '));
+
+  // 格式化显示
+  const formatDisplay = (item: Item): string => {
+    if (item.type === 'number') return item.value;
+    return displayMap[item.value] || item.value;
+  };
+
+  const buildOperandSet = (): Set<string> => {
+    const set = new Set<string>();
+    elementGroups.forEach((group) => {
+      if (group.type === 'operand') group.items.forEach((item) => set.add(item.value));
+    });
+    return set;
+  };
+
+  const parseExpression = (str: string): Item[] => {
+    if (!str?.trim()) return [];
+    const operandSet = buildOperandSet();
+    const operandPattern = [...operandSet].map((v) => v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
+    const regex = new RegExp(`(${operandPattern}|\\d+(?:\\.\\d+)?|==|>=|<=|[>+\\-*/<]|&&|\\|\\||[()])`, 'g');
+    const tokens = str.match(regex) || [];
+    return tokens
+      .map((t) => {
+        if (t === '(') return { type: 'leftParen', value: '(' } as Item;
+        if (t === ')') return { type: 'rightParen', value: ')' } as Item;
+        if (t === '&&' || t === '||') return { type: 'logic', value: t } as Item;
+        if (['>', '>=', '<', '<=', '==', '+', '-', '*', '/'].includes(t)) return { type: 'operator', value: t } as Item;
+        if (operandSet.has(t)) return { type: 'operand', value: t } as Item;
+        if (/^\d+(\.\d+)?$/.test(t)) return { type: 'number', value: t } as Item;
+        return null;
+      })
+      .filter((item): item is Item => item !== null);
+  };
+
+  // 初始化:将表达式字符串同步到画布与内部状态
+  const initializeFromExpression = (expr: string): void => {
+    const parsed = parseExpression(expr);
+    items.value = parsed.length ? parsed : [];
+    // 同步简易模式输入框(如果处于简易模式,显示最新表达式)
+    inputString.value = expr;
+    manualInput.value = ''; // 清空手动输入区
+    errorMessage.value = '';
+  };
+
+  // 画布操作(追加、删除、清空、插入括号)
+  const appendItem = (item: Item): void => {
+    items.value.push(cloneDeep(item));
+    syncAfterChange();
+  };
+
+  const removeItem = (index: number): void => {
+    items.value.splice(index, 1);
+    syncAfterChange();
+  };
+
+  const clearCanvas = (): void => {
+    items.value = [];
+    syncAfterChange();
+  };
+
+  // 画布变更后同步:更新简易模式输入框,并向上通知(但不提交)
+  const syncAfterChange = (): void => {
+    const expr = expressionString.value;
+    inputString.value = expr;
+  };
+
+  // 拖拽逻辑
+  const isDragOver = ref<boolean>(false);
+  const onDragStart = (e: DragEvent, item: Item): void => {
+    e.dataTransfer?.setData('text/plain', JSON.stringify(item));
+  };
+  const onDragOver = (e: DragEvent): void => {
+    e.preventDefault();
+    isDragOver.value = true;
+  };
+  const onDragLeave = (): void => {
+    isDragOver.value = false;
+  };
+  const onDrop = (e: DragEvent): void => {
+    e.preventDefault();
+    isDragOver.value = false;
+    try {
+      const data = e.dataTransfer?.getData('text/plain');
+      if (!data) return;
+      const item: Item = JSON.parse(data);
+      let droppedItem = cloneDeep(item);
+      const container = e.currentTarget as HTMLElement;
+      const tags = [...container.querySelectorAll('.expr-tag')] as HTMLElement[];
+      let index = tags.length;
+      const mouseX = e.clientX;
+      for (let i = 0; i < tags.length; i++) {
+        const rect = tags[i].getBoundingClientRect();
+        if (mouseX < rect.left + rect.width / 2) {
+          index = i;
+          break;
+        }
+      }
+      items.value.splice(index, 0, droppedItem);
+      syncAfterChange();
+    } catch {
+      message.error('拖拽失败');
+    }
+  };
+
+  // 校验与优先级处理
+  const validateExpression = (expr: string): { valid: boolean; message?: string } => {
+    if (!expr?.trim()) return { valid: false, message: '表达式不能为空' };
+    // 括号匹配
+    let count = 0;
+    for (const char of expr) {
+      if (char === '(') count++;
+      if (char === ')') count--;
+      if (count < 0) return { valid: false, message: '括号不匹配' };
+    }
+    if (count !== 0) return { valid: false, message: '括号不匹配' };
+
+    // 基本语法:检查操作符是否孤立(简单启发)
+    const tokens = parseExpression(expr);
+    if (tokens.length === 0) return { valid: false, message: '无法解析表达式' };
+    for (let i = 0; i < tokens.length - 1; i++) {
+      const cur = tokens[i];
+      const next = tokens[i + 1];
+      if (cur.type === 'operand' && (next.type === 'number' || next.type === 'operand')) {
+        return { valid: false, message: `监测变量 ${cur.value} 位置可能不正确` };
+      }
+      if (cur.type === 'operator' && (next.type === 'operator' || next.type === 'logic' || next.type === 'rightParen')) {
+        return { valid: false, message: `运算符 ${cur.value} 后缺少操作数` };
+      }
+      if (cur.type === 'logic' && (next.type === 'logic' || next.type === 'rightParen')) {
+        return { valid: false, message: `逻辑符 ${cur.value} 位置可能不正确` };
+      }
+      if (cur.type === 'number' && (next.type === 'operand' || next.type === 'number')) {
+        return { valid: false, message: `数值 ${cur.value} 位置可能不正确` };
+      }
+    }
+    return { valid: true };
+  };
+
+  /**
+   * 基于 items 为混合优先级表达式自动添加括号
+   * 仅在最外层 || 分隔的段中,对未加括号且包含 && 的段添加括号
+   */
+  const addParenthesesForAnd = (_expr: string): string => {
+    const tokens = items.value;
+    if (!tokens.length) return _expr;
+
+    // 1. 计算每个 token 在整体表达式中的嵌套深度
+    const depths: number[] = [];
+    let depth = 0;
+    for (const t of tokens) {
+      if (t.type === 'leftParen') {
+        depths.push(depth);
+        depth++;
+      } else if (t.type === 'rightParen') {
+        depth--;
+        depths.push(depth);
+      } else {
+        depths.push(depth);
+      }
+    }
+
+    // 2. 按最外层 (depth === 0) 的 || 切分成段
+    const segments: Item[][] = [];
+    let current: Item[] = [];
+    for (let i = 0; i < tokens.length; i++) {
+      if (tokens[i].type === 'logic' && tokens[i].value === '||' && depths[i] === 0) {
+        if (current.length) segments.push(current);
+        current = [];
+      } else {
+        current.push(tokens[i]);
+      }
+    }
+    if (current.length) segments.push(current);
+
+    // 3. 辅助:判断段是否已被一整对括号包裹
+    const isEnclosed = (seg: Item[]): boolean => {
+      if (seg.length < 2) return false;
+      if (seg[0].type !== 'leftParen' || seg[seg.length - 1].type !== 'rightParen') return false;
+      let bal = 0;
+      for (let i = 1; i < seg.length - 1; i++) {
+        if (seg[i].type === 'leftParen') bal++;
+        else if (seg[i].type === 'rightParen') bal--;
+        if (bal < 0) return false;
+      }
+      return bal === 0;
+    };
+
+    // 4. 辅助:判断段内是否存在最外层 (深度为0) 的 &&
+    const hasTopLevelAnd = (seg: Item[]): boolean => {
+      let d = 0;
+      for (const t of seg) {
+        if (t.type === 'leftParen') d++;
+        else if (t.type === 'rightParen') d--;
+        else if (t.type === 'logic' && t.value === '&&' && d === 0) return true;
+      }
+      return false;
+    };
+
+    // 5. 重建表达式,对需要的段添加括号
+    return segments
+      .map((seg) => {
+        const str = seg.map((i) => i.value).join(' ');
+        if (!isEnclosed(seg) && hasTopLevelAnd(seg)) {
+          return `( ${str} )`;
+        }
+        return str;
+      })
+      .join(' || ');
+  };
+
+  /**
+   * 判断 items 最外层(depth=0)是否同时存在 && 和 ||
+   */
+  const checkTopLevelMixed = (): boolean => {
+    let depth = 0;
+    let hasAnd = false;
+    let hasOr = false;
+    for (const item of items.value) {
+      if (item.type === 'leftParen') {
+        depth++;
+      } else if (item.type === 'rightParen') {
+        depth--;
+      } else if (item.type === 'logic' && depth === 0) {
+        if (item.value === '&&') hasAnd = true;
+        else if (item.value === '||') hasOr = true;
+      }
+    }
+    return hasAnd && hasOr;
+  };
+
+  // ---------- 提交逻辑 ----------
+  const confirm = async (): Promise<void> => {
+    const expr = expressionString.value;
+    const validation = validateExpression(expr);
+    if (!validation.valid) {
+      errorMessage.value = validation.message || '表达式不合法';
+      return;
+    }
+    errorMessage.value = '';
+
+    // 基于 items 判断最外层是否存在未加括号的 && 与 || 混合
+    const hasMixed = checkTopLevelMixed();
+    let finalExpr = expr;
+
+    if (hasMixed) {
+      await new Promise<void>((resolve) => {
+        const originalExpr = expr;
+        const suggestedExpr = addParenthesesForAnd(expr);
+
+        Modal.confirm({
+          title: '运算符优先级提醒',
+          content: h('div', [
+            h('p', '表达式最外层同时包含“且(&&)”和“或(||)”,程序将优先计算“且(&&)”。'),
+            h('p', { style: { margin: '8px 0' } }, [
+              h('strong', '原表达式:'),
+              h('code', { style: { backgroundColor: '#f5f5f5', padding: '2px 6px', borderRadius: '4px' } }, originalExpr),
+            ]),
+            h('p', { style: { margin: '8px 0' } }, [
+              h('strong', '建议表达式:'),
+              h('code', { style: { backgroundColor: '#f6ffed', padding: '2px 6px', borderRadius: '4px' } }, suggestedExpr),
+            ]),
+            h('p', '是否自动添加括号以明确优先级?'),
+          ]),
+          okText: '自动添加',
+          cancelText: '保持原样',
+          onOk: () => {
+            finalExpr = suggestedExpr;
+            // 同步到画布(仅当用户选择自动添加时才更新画布)
+            const parsed = parseExpression(finalExpr);
+            if (parsed.length) items.value = parsed;
+            resolve();
+          },
+          onCancel: () => resolve(),
+        });
+      });
+    }
+
+    // 统一提交
+    emit('update:modelValue', finalExpr);
+    emit('change', finalExpr);
+    emit('confirm', finalExpr);
+    inputString.value = finalExpr;
+    message.success('已提交');
+  };
+
+  // ---------- 重置 ----------
+  const reset = (): void => {
+    items.value = [];
+    inputString.value = '';
+    manualInput.value = '';
+    errorMessage.value = '';
+    emit('update:modelValue', '');
+    emit('change', '');
+  };
+
+  // ---------- 进入高级模式 ----------
+  const enterAdvancedMode = (): void => {
+    // 将当前 modelValue 初始化到画布
+    initializeFromExpression(props.modelValue);
+    compact.value = false;
+  };
+
+  // ---------- 返回简易模式 ----------
+  const backToCompact = async (): Promise<void> => {
+    const currentExpr = expressionString.value;
+    if (currentExpr === props.modelValue) {
+      compact.value = true;
+      return;
+    }
+    Modal.confirm({
+      title: '确认返回',
+      content: '画布内容已修改,是否应用变更?',
+      okText: '应用',
+      cancelText: '放弃',
+      onOk: async () => {
+        await confirm(); // 内部会校验、询问优先级、提交
+        compact.value = true;
+      },
+      onCancel: () => {
+        // 放弃变更:恢复为原始 modelValue
+        initializeFromExpression(props.modelValue);
+        compact.value = true;
+      },
+    });
+  };
+
+  // ---------- 模板应用 / 手动输入应用 ----------
+  const applyTemplate = (): void => {
+    if (!selectedTemplate.value) return;
+    initializeFromExpression(selectedTemplate.value);
+    selectedTemplate.value = '';
+    message.success('模板已应用');
+  };
+
+  const applyManualInput = (): void => {
+    const expr = manualInput.value.trim();
+    if (!expr) return;
+    const validation = validateExpression(expr);
+    if (!validation.valid) {
+      errorMessage.value = validation.message || '表达式不合法';
+      return;
+    }
+    initializeFromExpression(expr);
+    errorMessage.value = '';
+    message.success('已应用');
+  };
+
+  // ---------- 初始化:监听 modelValue 变化(用于外部控制) ----------
+  watch(
+    () => props.modelValue,
+    (newVal) => {
+      if (newVal !== undefined) {
+        inputString.value = newVal;
+        // 如果当前处于高级模式,也同步画布
+        if (!compact.value) {
+          const parsed = parseExpression(newVal);
+          items.value = parsed.length ? parsed : [];
+        }
+      }
+    },
+    { immediate: true }
+  );
+
+  // 暴露校验方法供表单 rules 使用
+  defineExpose({ validate: () => validateExpression(props.modelValue) });
+</script>
+
+<style scoped lang="less">
+  .relation-builder {
+    // :deep(.ant-card) {
+    //   height: 100%;
+    // }
+    // :deep(.ant-card-body) {
+    //   height: calc(100% - 57px);
+    //   overflow-y: auto;
+    // }
+    .relation-card {
+      height: 680px;
+      overflow-y: auto;
+    }
+    .relation-card-footer {
+      height: 30px;
+    }
+
+    .chip-container {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 4px;
+    }
+    .chip {
+      display: inline-block;
+      padding: 4px 12px;
+      border-radius: 4px;
+      font-size: 14px;
+      cursor: grab;
+      user-select: none;
+      border: 1px solid @border-color-base;
+      background: @white;
+      transition: all 0.2s;
+      &:hover {
+        // transform: translateY(-2px);
+        box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+      }
+      &:active {
+        cursor: grabbing;
+      }
+      &.chip-operand {
+        background: #e6f7ff;
+        border-color: #91d5ff;
+        color: #0050b3;
+      }
+      &.chip-operator {
+        background: #f6ffed;
+        border-color: #b7eb8f;
+        color: #135200;
+      }
+      &.chip-logic {
+        background: #fff7e6;
+        border-color: #ffd591;
+        color: #ad4e00;
+      }
+      &.chip-number {
+        background: #f9f0ff;
+        border-color: #d3adf7;
+        color: #531dab;
+      }
+      &.chip-paren {
+        background: #f0f0f0;
+        border-color: #bfbfbf;
+        font-weight: bold;
+      }
+    }
+
+    .canvas-container {
+      min-height: 200px;
+      max-height: 400px;
+      overflow-y: auto;
+      border: 2px dashed #d9d9d9;
+      border-radius: 8px;
+      padding: 16px;
+      background: #fafafa;
+      transition: all 0.2s;
+      &.drag-over {
+        border-color: #1890ff;
+        background: #e6f7ff;
+      }
+      .expression-list {
+        display: flex;
+        flex-wrap: wrap;
+        align-items: center;
+        gap: 8px;
+      }
+      .expr-tag {
+        font-size: 14px;
+        padding: 6px 12px;
+        margin: 0;
+        // :deep(.anticon-close) {
+        //   margin-left: 6px;
+        //   &:hover {
+        //     color: #ff4d4f;
+        //   }
+        // }
+      }
+    }
+  }
+</style>

+ 133 - 0
src/views/system/algorithm/algorithm.data.ts

@@ -704,3 +704,136 @@ export const schemasGoafLimit: FormSchema[] = [
     colProps: { span: 12 },
   },
 ];
+
+export const schemasCoalExpression: FormSchema[] = [
+  // "mineCode": null,
+  // "coalSeamId": null,
+  {
+    label: 'ID',
+    field: 'id',
+    show: false,
+    component: 'Input',
+  },
+  {
+    label: '规则名称:',
+    field: 'ruleName',
+    labelWidth: 118,
+    component: 'Input',
+
+    colProps: { span: 24 },
+  },
+  {
+    field: 'm1',
+    label: '密闭内外压差变化风险提示模型',
+    // labelWidth: 110,
+    component: 'Divider',
+  },
+  {
+    field: 'yc_warn1',
+    label: '预警等级(Ⅰ):',
+    labelWidth: 118,
+    component: 'Input',
+    slot: 'relation',
+    // required: true,
+  },
+  {
+    field: 'yc_warn2',
+    label: '预警等级(Ⅱ):',
+    labelWidth: 118,
+    component: 'Input',
+    slot: 'relation',
+  },
+  {
+    field: 'yc_warn3',
+    label: '预警等级(Ⅲ):',
+    labelWidth: 118,
+    component: 'Input',
+    slot: 'relation',
+  },
+  {
+    field: 'yc_warn4',
+    label: '预警等级(Ⅳ):',
+    labelWidth: 118,
+    component: 'Input',
+    slot: 'relation',
+  },
+  {
+    field: 'm2',
+    label: '闭内煤自然发火隐患分级预警模型',
+    // labelWidth: 110,
+    component: 'Divider',
+  },
+  {
+    field: 'fire_warn1',
+    label: '预警等级(Ⅰ):',
+    suffix: '',
+    labelWidth: 118,
+    component: 'Input',
+    slot: 'relation',
+  },
+  {
+    field: 'fire_warn2',
+    label: '预警等级(Ⅱ):',
+    suffix: '',
+    labelWidth: 118,
+    component: 'Input',
+    slot: 'relation',
+  },
+  {
+    field: 'fire_warn3',
+    label: '预警等级(Ⅲ):',
+    suffix: '',
+    labelWidth: 118,
+    component: 'Input',
+    slot: 'relation',
+  },
+  {
+    field: 'fire_warn4',
+    label: '预警等级(Ⅳ):',
+    suffix: '',
+    labelWidth: 118,
+    component: 'Input',
+    slot: 'relation',
+  },
+  {
+    field: 'm3',
+    label: '闭外煤自然发火隐患分级预警模型',
+    // labelWidth: 110,
+    component: 'Divider',
+  },
+  {
+    field: 'fire_out_warn1',
+    label: '预警等级(Ⅰ):',
+    labelWidth: 118,
+    component: 'Input',
+    slot: 'relation',
+  },
+  {
+    field: 'fire_out_warn2',
+    label: '预警等级(Ⅱ):',
+    labelWidth: 118,
+    component: 'Input',
+    slot: 'relation',
+  },
+  {
+    field: 'fire_out_warn3',
+    label: '预警等级(Ⅲ):',
+    labelWidth: 118,
+    component: 'Input',
+    slot: 'relation',
+  },
+  {
+    field: 'fire_out_warn4',
+    label: '预警等级(Ⅳ):',
+    labelWidth: 118,
+    component: 'Input',
+    slot: 'relation',
+  },
+  {
+    field: 'open_warn',
+    label: '火区密闭启封判定:',
+    labelWidth: 118,
+    component: 'Input',
+    slot: 'relation',
+  },
+];

+ 297 - 0
src/views/system/algorithm/index copy.vue

@@ -0,0 +1,297 @@
+<!-- eslint-disable vue/multi-word-component-names -->
+<template>
+  <Flex class="algorithm-model h-full">
+    <!-- <EnfMineTree class="flex-grow-1" @select="onTreeSelect" /> -->
+    <!--引用表格-->
+    <BasicTable class="flex-grow-1" :expandedRowKeys="expandedRowKeys" :ellipsis="true" @register="registerTable">
+      <!-- <template #formTitle>
+        <a-button type="primary" @click="handleEdit({}, 'coal')">
+          <PlusOutlined />
+          新增
+        </a-button>
+        矿区CODE级联选择器占位符
+      </template> -->
+      <template #expandedRowRender>
+        <BasicTable @register="registerInnerTable">
+          <template #action="{ record }">
+            <button @click="handleEdit(record, 'goaf')" class="action-btn" title="编辑">
+              <SvgIcon name="edit" />
+            </button>
+            <!-- <button @click="handleAdd({ goafId: record.id, mineCode: last(expandedRowKeys) }, 'goaf')" class="action-btn ml-1">
+              <PlusOutlined />
+            </button> -->
+            <a-popconfirm title="确认删除?" @confirm="handleDelete(record, 'coal')">
+              <button @click="handleDelete(record, 'goaf')" class="action-btn ml-1" title="删除">
+                <SvgIcon name="delete" />
+              </button>
+            </a-popconfirm>
+          </template>
+        </BasicTable>
+      </template>
+
+      <template #action="{ record }">
+        <button @click="handleEdit({ coalSeamId: record.id, mineCode: record.mineCode }, 'coal')" class="action-btn" title="编辑">
+          <SvgIcon name="edit" />
+        </button>
+        <!-- <button @click="handleAdd({ coalSeamId: record.id, mineCode: record.mineCode }, 'coal')" class="action-btn ml-1">
+          <PlusOutlined />
+        </button> -->
+        <a-popconfirm title="确认删除?" @confirm="handleDelete(record, 'coal')">
+          <button class="action-btn ml-1" title="删除">
+            <SvgIcon name="delete" />
+          </button>
+        </a-popconfirm>
+      </template>
+    </BasicTable>
+    {{ expression }}
+    <RelationBuilder v-model="expression" />
+  </Flex>
+  <BasicModal width="60%" :height="500" @register="registerModal" @ok="handleSubmit" title="预警参数设置">
+    <BasicForm @register="registerForm">
+      <template #InputRangeNumber="{ model, field, schema }">
+        <a-form-item>
+          <a-input-group>
+            <a-input-number v-model:value="model[`${field}Start`]" style="width: calc(50% - 100px)" placeholder="-" />
+            <a-input style="width: 200px; border-left: 0; pointer-events: none; color: inherit" :value="schema.groupName" disabled />
+            <a-input-number v-model:value="model[`${field}End`]" style="width: calc(50% - 100px); border-left: 0" placeholder="-" />
+          </a-input-group>
+        </a-form-item>
+      </template>
+      <template #InputGreaterNumber="{ model, field, schema }">
+        <a-form-item>
+          <a-input-group>
+            <a-input-number style="width: calc(50% - 100px)" placeholder="-" disabled />
+            <a-input style="width: 200px; border-left: 0; pointer-events: none; color: inherit" :value="schema.groupName" disabled />
+            <a-input-number v-model:value="model[field]" style="width: calc(50% - 100px); border-left: 0" placeholder="-" />
+          </a-input-group>
+        </a-form-item>
+      </template>
+      <template #InputLowerNumber="{ model, field, schema }">
+        <a-form-item>
+          <a-input-group>
+            <a-input-number v-model:value="model[field]" style="width: calc(50% - 100px)" placeholder="-" />
+            <a-input style="width: 200px; border-left: 0; pointer-events: none; color: inherit" :value="schema.groupName" disabled />
+            <a-input-number style="width: calc(50% - 100px); border-left: 0" placeholder="-" disabled />
+          </a-input-group>
+        </a-form-item>
+      </template>
+      <template #InputRangeGoaf="{ model, field, schema }">
+        <a-form-item v-if="model[field]">
+          <a-input-group>
+            <a-input-number v-model:value="model[field][`lowerLimit`]" style="width: calc(50% - 100px)" placeholder="-" />
+            <a-input style="width: 200px; border-left: 0; pointer-events: none; color: inherit" :value="schema.groupName" disabled />
+            <a-input-number v-model:value="model[field][`upperLimit`]" style="width: calc(50% - 100px); border-left: 0" placeholder="-" />
+          </a-input-group>
+        </a-form-item>
+      </template>
+    </BasicForm>
+  </BasicModal>
+</template>
+
+<script lang="ts" setup>
+  import { nextTick, provide, ref } from 'vue';
+  import { useDesign } from '/@/hooks/web/useDesign';
+  // import EnfMineTree from './components/EnfMineTree.vue';
+  import { useModal, BasicModal, ModalProps } from '/@/components/Modal';
+  import { useForm, BasicForm } from '/@/components/Form';
+  import { BasicTable, useTable } from '/@/components/Table';
+  import { useListPage } from '/@/hooks/system/useListPage';
+  import { columnsCoalAlarm, columnsGoafLimit, schemasCoalAlarm, schemasGoafLimit, searchFormSchema, goafAlarmModel } from './algorithm.data';
+  import {
+    addCoalSeamAlarmRule,
+    deleteCoalSeamAlarmRule,
+    deleteGoafDataLimit,
+    getCoalSeam,
+    getGoafList,
+    updateCoalSeamAlarmRule,
+    getCoalSeamAlarmRule,
+    getGoafDataLimit,
+    patchGoafDataLimit,
+  } from './algorithm.api';
+  import { Flex } from 'ant-design-vue';
+  // import { PlusOutlined } from '@ant-design/icons-vue';
+  import { forEach, last, isString } from 'lodash-es';
+  import { message } from 'ant-design-vue';
+  import { SvgIcon } from '/@/components/Icon';
+
+  const expression = ref('((CO > 0 && CO2 > 0) || C2H4 = 2)')
+
+  const { prefixCls } = useDesign('algorithm-list');
+  provide('prefixCls', prefixCls);
+
+  // 为了更好的控制展开逻辑
+  const expandedRowKeys = ref<string[]>([]);
+
+  // 列表页面公共参数、方法
+  const { tableContext } = useListPage({
+    tableProps: {
+      api: getCoalSeam,
+      columns: columnsCoalAlarm,
+      rowKey: 'id',
+      showIndexColumn: true,
+      formConfig: {
+        // showResetButton: false,
+        schemas: searchFormSchema,
+        schemaGroupNames: ['常规查询'],
+      },
+      onExpand(expanded, record) {
+        // 展开最多一行
+        if (expanded) {
+          expandedRowKeys.value = [record.id];
+          nextTick(reloadInner);
+        } else {
+          expandedRowKeys.value = [];
+        }
+      },
+    },
+  });
+  //注册table数据
+  const [registerTable] = tableContext;
+  const [registerInnerTable, { reload: reloadInner }] = useTable({
+    api: getGoafList,
+    columns: columnsGoafLimit,
+    rowKey: 'id',
+    pagination: false,
+    showActionColumn: true,
+    actionColumn: {
+      title: '操作',
+      dataIndex: 'action',
+      slots: { customRender: 'action' },
+    },
+    immediate: false,
+    beforeFetch(p) {
+      p.coalSeamId = expandedRowKeys.value[0];
+    },
+  });
+
+  const formPropsMap = new Map([
+    [
+      'coal',
+      {
+        schemas: schemasCoalAlarm,
+        submitFunc: (res) => (res.id ? updateCoalSeamAlarmRule(res) : addCoalSeamAlarmRule(res)),
+        fetchRecord: (params) => getCoalSeamAlarmRule(params).then((r) => last(r)),
+      },
+    ],
+    [
+      'goaf',
+      {
+        schemas: schemasGoafLimit,
+        submitFunc: (res) => patchGoafDataLimit(res),
+        fetchRecord: (params) =>
+          getGoafDataLimit({ goafId: params.id, mineCode: last(expandedRowKeys.value) }).then((r) => {
+            const result: any = params;
+            forEach(goafAlarmModel, (item, key) => {
+              result[key] = { goafId: params.id, mineCode: last(expandedRowKeys.value), ...item };
+            });
+            forEach(r, (item) => {
+              result[item.alarmField] = item;
+            });
+
+            return result;
+          }),
+      },
+    ],
+  ]);
+  const modalPropsMap = new Map<string, Partial<ModalProps>>([
+    ['coal', { title: '预警参数设置', visible: true, loading: true }],
+    ['goaf', { title: '超限预警设置', visible: true, loading: true }],
+  ]);
+  const submitResolver = ref<(res: any) => Promise<unknown>>();
+  // 点击编辑后,获取对应的表单和弹窗配置
+  async function handleEdit(record, sign: string) {
+    if (!modalPropsMap.has(sign)) return;
+    if (!formPropsMap.has(sign)) return;
+    setModalProps(modalPropsMap.get(sign) as ModalProps);
+    const { schemas, fetchRecord, submitFunc } = formPropsMap.get(sign)!;
+    const res = await fetchRecord(record);
+    await resetSchema(schemas);
+
+    await nextTick();
+
+    await setFieldsValue(res);
+
+    await nextTick();
+
+    // 不要使用setFormProps因为它会错误的触发submit方法
+    // await setFormProps({ submitFunc });
+    submitResolver.value = (res) => submitFunc(Object.assign(record, res));
+
+    setModalProps({ loading: false });
+  }
+  // async function handleAdd(record, sign: string) {
+  //   if (!modalPropsMap.has(sign)) return;
+  //   if (!formPropsMap.has(sign)) return;
+  //   setModalProps(modalPropsMap.get(sign) as ModalProps);
+  //   const { schemas, submitFunc } = formPropsMap.get(sign)!;
+  //   await nextTick();
+  //   await resetSchema(schemas);
+
+  //   await nextTick();
+
+  //   await resetFields();
+
+  //   await nextTick();
+
+  //   // 不要使用setFormProps因为它会错误的触发submit方法
+  //   // await setFormProps({ submitFunc });
+  //   submitResolver.value = (res) => submitFunc(Object.assign(res, record));
+
+  //   setModalProps({ loading: false });
+  // }
+
+  const deletionPropsMap = new Map<string, { api: (id: string) => Promise<void> }>([
+    ['coal', { api: (id) => deleteCoalSeamAlarmRule({ id }) }],
+    ['goaf', { api: (id) => deleteGoafDataLimit({ id }) }],
+  ]);
+
+  function handleDelete(record, sign: string) {
+    if (!deletionPropsMap.has(sign)) return;
+    deletionPropsMap.get(sign)?.api(record.id);
+  }
+
+  const [registerModal, { setModalProps }] = useModal();
+  const [registerForm, { setFieldsValue, validate, resetSchema }] = useForm({
+    model: {},
+    schemas: schemasCoalAlarm,
+    showResetButton: false,
+    showSubmitButton: false,
+    colon: false,
+    compact: true,
+  });
+
+  function handleSubmit() {
+    return validate().then((res) => {
+      submitResolver.value &&
+        submitResolver
+          .value(res)
+          .then(() => {
+            message.success('操作成功');
+            setModalProps({ visible: false });
+          })
+          .catch((e) => {
+            message.error(isString(e) ? e : '操作失败');
+          });
+    });
+  }
+</script>
+
+<style lang="less">
+  @import './index.less';
+
+  .ant-input-group {
+    display: flex;
+    text-align: center;
+    .ant-input-number {
+      min-width: 100px;
+      &:first-child {
+        border-top-right-radius: 0;
+        border-bottom-right-radius: 0;
+      }
+      &:last-child {
+        border-top-left-radius: 0;
+        border-bottom-left-radius: 0;
+      }
+    }
+  }
+</style>

+ 8 - 4
src/views/system/algorithm/index.vue

@@ -44,7 +44,7 @@
       </template>
     </BasicTable>
   </Flex>
-  <BasicModal width="60%" :height="500" @register="registerModal" @ok="handleSubmit" title="预警参数设置">
+  <BasicModal width="60%" :height="700" @register="registerModal" @ok="handleSubmit" title="预警参数设置">
     <BasicForm @register="registerForm">
       <template #InputRangeNumber="{ model, field, schema }">
         <a-form-item>
@@ -82,6 +82,9 @@
           </a-input-group>
         </a-form-item>
       </template>
+      <template #relation="{ model, field }">
+        <RelationBuilder class="h-full" v-model="model[field]" />
+      </template>
     </BasicForm>
   </BasicModal>
 </template>
@@ -94,7 +97,7 @@
   import { useForm, BasicForm } from '/@/components/Form';
   import { BasicTable, useTable } from '/@/components/Table';
   import { useListPage } from '/@/hooks/system/useListPage';
-  import { columnsCoalAlarm, columnsGoafLimit, schemasCoalAlarm, schemasGoafLimit, searchFormSchema, goafAlarmModel } from './algorithm.data';
+  import { columnsCoalAlarm, columnsGoafLimit, schemasCoalExpression, schemasGoafLimit, searchFormSchema, goafAlarmModel } from './algorithm.data';
   import {
     addCoalSeamAlarmRule,
     deleteCoalSeamAlarmRule,
@@ -111,6 +114,7 @@
   import { forEach, last, isString } from 'lodash-es';
   import { message } from 'ant-design-vue';
   import { SvgIcon } from '/@/components/Icon';
+  import RelationBuilder from './RelationBuilder.vue';
 
   const { prefixCls } = useDesign('algorithm-list');
   provide('prefixCls', prefixCls);
@@ -164,7 +168,7 @@
     [
       'coal',
       {
-        schemas: schemasCoalAlarm,
+        schemas: schemasCoalExpression,
         submitFunc: (res) => (res.id ? updateCoalSeamAlarmRule(res) : addCoalSeamAlarmRule(res)),
         fetchRecord: (params) => getCoalSeamAlarmRule(params).then((r) => last(r)),
       },
@@ -249,7 +253,7 @@
   const [registerModal, { setModalProps }] = useModal();
   const [registerForm, { setFieldsValue, validate, resetSchema }] = useForm({
     model: {},
-    schemas: schemasCoalAlarm,
+    schemas: schemasCoalExpression,
     showResetButton: false,
     showSubmitButton: false,
     colon: false,