RelationBuilder.vue 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811
  1. <template>
  2. <div class="relation-builder">
  3. <div class="w-full flex justify-between mb-10px" v-if="compact">
  4. <a-space :style="{ width: `calc(100% - ${isOperator ? '100' : '0'}px)`, marginBottom: '10px' }" wrap>
  5. <template v-for="(item, index) in items" :key="index">
  6. <a-input-number v-if="item.type === 'number'" v-model:value="item.value" style="width: 30px" placeholder="-" @blur="confirm" />
  7. <a-tag v-if="item.type === 'operand' || item.type === 'operator'" :color="tagColors[item.type]" class="expr-tag">
  8. {{ formatDisplay(item) }}
  9. </a-tag>
  10. <a-tag v-else-if="item.type === 'logic'" class="expr-tag">|</a-tag>
  11. </template>
  12. </a-space>
  13. <!-- <div class="flex flex-wrap" :style="{ width: `calc(100% - ${isOperator ? '100' : '0'}px)` }" wrap>
  14. <div v-for="(group, id) in splitedItems" :key="`temp${id}`" class="w-50% flex justify-start items-center mb-10px" compact>
  15. <template v-for="(item, index) in group" :key="index">
  16. <a-input-number v-if="item.type === 'number'" v-model:value="item.value" style="width: 30px" placeholder="-" @blur="confirm" />
  17. <span v-else-if="item.type === 'operand'" class="w-90px text-center">{{ formatDisplay(item) }}</span>
  18. <span v-else class="p-5px">{{ formatDisplay(item) }}</span>
  19. </template>
  20. </div>
  21. </div> -->
  22. <a-button v-if="isOperator" class="w-100px" type="primary" @click="enterAdvancedMode"> 高级编辑 </a-button>
  23. </div>
  24. <a-row v-else :gutter="8">
  25. <!-- 左侧:元素库 -->
  26. <a-col :span="12">
  27. <a-card title="元素库" size="small">
  28. <a-collapse class="relation-card" v-model:activeKey="activeKey" :bordered="false">
  29. <a-collapse-panel v-for="group in elementGroups" :key="group.key" :header="group.header">
  30. <div class="chip-container">
  31. <!-- @dragstart="onDragStart($event, item)" -->
  32. <div
  33. v-for="item in group.items"
  34. :key="item.value"
  35. class="chip"
  36. :class="`chip-${group.type}`"
  37. draggable="true"
  38. @click="appendItem(item)"
  39. >
  40. {{ item.label }}
  41. </div>
  42. </div>
  43. <div class="extra-container">
  44. <div v-for="item in group.extra" :key="item.type" class="mt-8px">
  45. <a-input-number v-model:value="item.value" :defaultValue="0">
  46. <template #addonAfter>
  47. <!-- @dragstart="onDragStart($event, item)" -->
  48. <span style="cursor: pointer" draggable="true" @click="appendItem(item)"> 追加 </span>
  49. </template>
  50. </a-input-number>
  51. </div>
  52. </div>
  53. </a-collapse-panel>
  54. </a-collapse>
  55. <template #actions>
  56. <div class="relation-card-footer">
  57. <a-typography-text class="pt-6px pb-6px" type="secondary"> <InfoCircleOutlined /> 点击追加元素 </a-typography-text>
  58. </div>
  59. </template>
  60. </a-card>
  61. </a-col>
  62. <!-- 中间:画布 -->
  63. <a-col :span="12">
  64. <a-card title="表达式画布" size="small">
  65. <template #extra>
  66. <a-tooltip title="清空">
  67. <a-button size="small" danger @click="clearCanvas">
  68. <DeleteOutlined />
  69. </a-button>
  70. </a-tooltip>
  71. </template>
  72. <div class="relation-card">
  73. <!-- :class="{ 'drag-over': isDragOver }" @dragover.prevent="onDragOver" @dragleave="onDragLeave" @drop="onDrop"-->
  74. <div class="canvas-container">
  75. <a-empty v-if="!items.length" description="从左侧点击或拖拽元素" />
  76. <div v-else class="expression-list">
  77. <a-tag v-for="(item, index) in items" :key="index" :color="tagColors[item.type]" class="expr-tag" closable @close="removeItem(index)">
  78. <span class="mr-4px">{{ formatDisplay(item) }}</span>
  79. </a-tag>
  80. </div>
  81. </div>
  82. <a-divider style="margin: 12px 0" />
  83. <a-space direction="vertical" style="width: 100%" :size="16">
  84. <a-typography-text strong>当前表达式</a-typography-text>
  85. <span>{{ expressionString }}</span>
  86. <a-alert v-if="errorMessage" :message="errorMessage" type="error" show-icon closable @close="errorMessage = ''" />
  87. <div>
  88. <a-typography-text strong>快速模板</a-typography-text>
  89. <a-input-group compact>
  90. <a-select v-model:value="selectedTemplate" :options="templateOptions" style="width: calc(100% - 100px)" />
  91. <a-button class="w-100px" type="primary" @click="applyTemplate">应用模板</a-button>
  92. </a-input-group>
  93. </div>
  94. <div>
  95. <a-typography-text strong>输入表达式</a-typography-text>
  96. <a-input-group compact>
  97. <a-input v-model:value="inputString" style="width: calc(100% - 100px)" placeholder="例如: CO > 0 && CO2 > 0" allow-clear />
  98. <a-button class="w-100px" type="primary" @click="applyManualInput"> 应用到画布 </a-button>
  99. </a-input-group>
  100. </div>
  101. </a-space>
  102. </div>
  103. <template #actions>
  104. <div class="relation-card-footer" style="text-align: right">
  105. <a-button class="mr-10px" @click="backToCompact">普通模式</a-button>
  106. <a-button class="mr-10px" @click="reset">重置</a-button>
  107. <a-button class="mr-12px" type="primary" @click="confirm">提交</a-button>
  108. </div>
  109. </template>
  110. </a-card>
  111. </a-col>
  112. </a-row>
  113. </div>
  114. </template>
  115. <script lang="ts" setup>
  116. import { ref, computed, watch, h } from 'vue';
  117. import { cloneDeep } from 'lodash-es';
  118. import { message, Modal } from 'ant-design-vue';
  119. import { DeleteOutlined } from '@ant-design/icons-vue';
  120. // 类型定义
  121. interface Item {
  122. type: 'operand' | 'operator' | 'logic' | 'number' | 'leftParen' | 'rightParen';
  123. value: string;
  124. label?: string;
  125. }
  126. interface ElementGroup {
  127. key: string;
  128. header: string;
  129. type: string;
  130. items: Item[];
  131. extra: Item[];
  132. }
  133. // 常量配置
  134. const isOperator = ref(false);
  135. const activeKey = ref<string[]>(['gas', 'operator', 'logic', 'number', 'paren']);
  136. const templateOptions = ref([
  137. { label: '压差预警1', value: '20<=sourcePressureChange && sourcePressureChange<=40' },
  138. { label: '压差预警2', value: '40<sourcePressureChange && sourcePressureChange<=60' },
  139. { label: '压差预警3', value: '60<sourcePressureChange && sourcePressureChange<=80' },
  140. { label: '压差预警4', value: '80<sourcePressureChange' },
  141. { label: '火灾预警1', value: '((coRzl<=20||coZf<=0.5*coAvg)&&coVal<=10)||o2Val<=5&&temperatureDifference<2&&co2Val<0.5' },
  142. {
  143. label: '火灾预警2',
  144. value:
  145. '((coRzl>20&&coRzl<=50)||(0.5*coAvg<coZf&&coZf<=coAvg)||(coVal>10&&coVal<=50)||(temperatureDifference>=2&&temperatureDifference<=4))&&co2Val<0.5',
  146. },
  147. {
  148. label: '火灾预警3',
  149. value:
  150. '((coRzl>50&&coRzl<=100)||(coAvg<coZf&&coZf<=2*coAvg)||(coVal>=50&&coVal<=100)||(temperatureDifference>4&&temperatureDifference<=10))&&co2Val<0.5',
  151. },
  152. { label: '火灾预警4', value: 'coRzl>100||2*coAvg<coZf||coVal>100||temperatureDifference>10||(co2Val>=0.5&&co2Trend)' },
  153. { label: '闭外火灾预警1', value: 'temperatureDifference<2||ch4Val<0.5||coVal<5||o2Val<20' },
  154. { label: '闭外火灾预警2', value: 'temperatureDifference>=2&&temperatureDifference<=4||ch4Val>=0.5&&ch4Val<=1||coVal>=5&&coVal<10' },
  155. {
  156. label: '闭外火灾预警3',
  157. value: 'temperatureDifference>4&&temperatureDifference<=10&&temperature>=26||ch4Val>=1&&ch4Val<1.5||coVal>=10&&coVal<24||c2h4Val>0&&c2h2Val==0',
  158. },
  159. { label: '闭外火灾预警4', value: 'temperatureDifference>10&&temperature>=30||ch4Val>=1.5&&ch4Val<2||coVal>=24||c2h2Val>0' },
  160. {
  161. label: 'OPEN',
  162. value:
  163. '(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',
  164. },
  165. ]);
  166. const elementGroups: ElementGroup[] = [
  167. {
  168. key: 'gas',
  169. header: '监测变量',
  170. type: 'operand',
  171. items: [
  172. { type: 'operand', value: 'sourcePressureChange', label: '压差变化' },
  173. { type: 'operand', value: 'coRzl', label: 'CO日增率' },
  174. { type: 'operand', value: 'coZf', label: 'CO增幅' },
  175. { type: 'operand', value: 'coAvg', label: 'CO平均值' },
  176. { type: 'operand', value: 'coVal', label: 'CO值' },
  177. { type: 'operand', value: 'co2Val', label: 'CO2值' },
  178. { type: 'operand', value: 'co2Trend', label: 'CO2存在上升趋势' },
  179. { type: 'operand', value: 'o2Val', label: 'O2值' },
  180. { type: 'operand', value: 'ch4Val', label: 'CH4值' },
  181. { type: 'operand', value: 'c2h4Val', label: 'C2H4值' },
  182. { type: 'operand', value: 'c2h2Val', label: 'C2H2值' },
  183. { type: 'operand', value: 'temperature', label: '温度' },
  184. { type: 'operand', value: 'fireAirTemperature', label: '气温' },
  185. { type: 'operand', value: 'fireWaterTemperature', label: '水温' },
  186. { type: 'operand', value: 'temperatureDifference', label: '温差' },
  187. { type: 'operand', value: 'stableDays', label: '稳定天数' },
  188. ],
  189. extra: [],
  190. },
  191. {
  192. key: 'operator',
  193. header: '计算符',
  194. type: 'operator',
  195. items: [
  196. { type: 'operator', value: '>', label: '>' },
  197. { type: 'operator', value: '>=', label: '≥' },
  198. { type: 'operator', value: '<', label: '<' },
  199. { type: 'operator', value: '<=', label: '≤' },
  200. { type: 'operator', value: '==', label: '=' },
  201. { type: 'operator', value: '+', label: '+' },
  202. { type: 'operator', value: '-', label: '-' },
  203. { type: 'operator', value: '*', label: '*' },
  204. { type: 'operator', value: '/', label: '/' },
  205. ],
  206. extra: [],
  207. },
  208. {
  209. key: 'logic',
  210. header: '逻辑符',
  211. type: 'logic',
  212. items: [
  213. { type: 'logic', value: '&&', label: '且' },
  214. { type: 'logic', value: '||', label: '或' },
  215. ],
  216. extra: [],
  217. },
  218. {
  219. key: 'paren',
  220. header: '括号',
  221. type: 'paren',
  222. items: [
  223. { type: 'leftParen', value: '(', label: '(' },
  224. { type: 'rightParen', value: ')', label: ')' },
  225. ],
  226. extra: [],
  227. },
  228. {
  229. key: 'number',
  230. header: '数值',
  231. type: 'number',
  232. items: [
  233. { type: 'number', value: '0', label: '0' },
  234. { type: 'number', value: '10', label: '10' },
  235. { type: 'number', value: '20', label: '20' },
  236. { type: 'number', value: '30', label: '30' },
  237. { type: 'number', value: '40', label: '40' },
  238. { type: 'number', value: '50', label: '50' },
  239. { type: 'number', value: '60', label: '60' },
  240. { type: 'number', value: '70', label: '70' },
  241. { type: 'number', value: '80', label: '80' },
  242. { type: 'number', value: '90', label: '90' },
  243. { type: 'number', value: '100', label: '100' },
  244. ],
  245. extra: [{ type: 'number', value: '0', label: '追加自定义值' }],
  246. },
  247. ];
  248. const tagColors: Record<string, string> = {
  249. operand: 'blue',
  250. operator: 'green',
  251. logic: 'orange',
  252. number: 'purple',
  253. leftParen: 'default',
  254. rightParen: 'default',
  255. };
  256. const displayMap: Record<string, string> = {
  257. '&&': '且',
  258. '||': '或',
  259. '>=': '≥',
  260. '<=': '≤',
  261. '==': '=',
  262. sourcePressureChange: '压差变化',
  263. coRzl: 'CO日增率',
  264. coZf: 'CO增幅',
  265. coAvg: 'CO平均值',
  266. coVal: 'CO值',
  267. co2Val: 'CO2值',
  268. co2Trend: 'CO2存在上升趋势',
  269. o2Val: 'O2值',
  270. ch4Val: 'CH4值',
  271. c2h4Val: 'C2H4值',
  272. c2h2Val: 'C2H2值',
  273. temperature: '温度',
  274. fireAirTemperature: '气温',
  275. fireWaterTemperature: '水温',
  276. temperatureDifference: '温差',
  277. stableDays: '稳定天数',
  278. };
  279. // Props 和 Emits
  280. const props = defineProps<{ modelValue: string }>();
  281. const emit = defineEmits<{
  282. (e: 'update:modelValue', value: string): void;
  283. (e: 'change', value: string): void;
  284. (e: 'confirm', value: string): void;
  285. }>();
  286. // Reactive 状态
  287. const compact = ref<boolean>(true);
  288. const inputString = ref<string>('');
  289. const items = ref<Item[]>([]);
  290. const manualInput = ref<string>('');
  291. const errorMessage = ref<string>('');
  292. const selectedTemplate = ref<string>('');
  293. // Computed
  294. const expressionString = computed(() => items.value.map((i) => i.value).join(' '));
  295. // const splitedItems = computed(() => {
  296. // return items.value.reduce(
  297. // (arr: Item[][], ele) => {
  298. // console.log('debug rr', arr, ele);
  299. // if (ele.type === 'logic') {
  300. // arr.push([]);
  301. // }
  302. // if (['operand', 'operator', 'number'].includes(ele.type)) {
  303. // last(arr)!.push(ele);
  304. // }
  305. // return arr;
  306. // },
  307. // [[]]
  308. // );
  309. // });
  310. // 工具函数
  311. const buildOperandSet = (): Set<string> => {
  312. const set = new Set<string>();
  313. elementGroups.forEach((group) => {
  314. if (group.type === 'operand') group.items.forEach((item) => set.add(item.value));
  315. });
  316. return set;
  317. };
  318. const formatDisplay = (item: Item): string => {
  319. if (item.type === 'number') return item.value;
  320. return displayMap[item.value] || item.value;
  321. };
  322. const parseExpression = (str: string): Item[] => {
  323. if (!str?.trim()) return [];
  324. const operandSet = buildOperandSet();
  325. const operandPattern = [...operandSet].map((v) => v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
  326. const regex = new RegExp(`(${operandPattern}|==|>=|<=|&&|\\|\\||-?\\d+(?:\\.\\d+)?|[>+\\-*/<()])`, 'g');
  327. const rawTokens = str.match(regex) || [];
  328. const tokens = rawTokens.map((t) => t.trim()).filter((t) => t !== '');
  329. const items: Item[] = [];
  330. for (const token of tokens) {
  331. if (token === '(') {
  332. items.push({ type: 'leftParen', value: '(' });
  333. continue;
  334. }
  335. if (token === ')') {
  336. items.push({ type: 'rightParen', value: ')' });
  337. continue;
  338. }
  339. if (token === '&&' || token === '||') {
  340. items.push({ type: 'logic', value: token });
  341. continue;
  342. }
  343. if (['>', '>=', '<', '<=', '==', '+', '-', '*', '/'].includes(token)) {
  344. items.push({ type: 'operator', value: token });
  345. continue;
  346. }
  347. if (operandSet.has(token)) {
  348. items.push({ type: 'operand', value: token });
  349. continue;
  350. }
  351. if (/^-?\d+(?:\.\d+)?$/.test(token)) {
  352. items.push({ type: 'number', value: token });
  353. continue;
  354. }
  355. }
  356. return items;
  357. };
  358. // 初始化:将表达式字符串同步到画布与内部状态
  359. const initializeFromExpression = (expr: string): void => {
  360. const parsed = parseExpression(expr);
  361. items.value = parsed.length ? parsed : [];
  362. // 同步简易模式输入框(如果处于简易模式,显示最新表达式)
  363. inputString.value = expr;
  364. manualInput.value = ''; // 清空手动输入区
  365. errorMessage.value = '';
  366. };
  367. // 画布操作(追加、删除、清空、插入括号)
  368. const appendItem = (item: Item): void => {
  369. items.value.push(cloneDeep(item));
  370. syncAfterChange();
  371. };
  372. const removeItem = (index: number): void => {
  373. items.value.splice(index, 1);
  374. syncAfterChange();
  375. };
  376. const clearCanvas = (): void => {
  377. items.value = [];
  378. syncAfterChange();
  379. };
  380. // 画布变更后同步:更新简易模式输入框,并向上通知(但不提交)
  381. const syncAfterChange = (): void => {
  382. const expr = expressionString.value;
  383. inputString.value = expr;
  384. };
  385. // 拖拽逻辑
  386. // const isDragOver = ref<boolean>(false);
  387. // const onDragStart = (e: DragEvent, item: Item): void => {
  388. // e.dataTransfer?.setData('text/plain', JSON.stringify(item));
  389. // };
  390. // const onDragOver = (e: DragEvent): void => {
  391. // e.preventDefault();
  392. // isDragOver.value = true;
  393. // };
  394. // const onDragLeave = (): void => {
  395. // isDragOver.value = false;
  396. // };
  397. // const onDrop = (e: DragEvent): void => {
  398. // e.preventDefault();
  399. // isDragOver.value = false;
  400. // try {
  401. // const data = e.dataTransfer?.getData('text/plain');
  402. // if (!data) return;
  403. // const item: Item = JSON.parse(data);
  404. // let droppedItem = cloneDeep(item);
  405. // const container = e.currentTarget as HTMLElement;
  406. // const tags = [...container.querySelectorAll('.expr-tag')] as HTMLElement[];
  407. // let index = tags.length;
  408. // const mouseX = e.clientX;
  409. // for (let i = 0; i < tags.length; i++) {
  410. // const rect = tags[i].getBoundingClientRect();
  411. // if (mouseX < rect.left + rect.width / 2) {
  412. // index = i;
  413. // break;
  414. // }
  415. // }
  416. // items.value.splice(index, 0, droppedItem);
  417. // syncAfterChange();
  418. // } catch {
  419. // message.error('拖拽失败');
  420. // }
  421. // };
  422. // 校验与优先级处理
  423. const validateExpression = (expr: string): { valid: boolean; message?: string } => {
  424. if (!expr?.trim()) return { valid: false, message: '表达式不能为空' };
  425. // 括号匹配
  426. let count = 0;
  427. for (const char of expr) {
  428. if (char === '(') count++;
  429. if (char === ')') count--;
  430. if (count < 0) return { valid: false, message: '括号不匹配' };
  431. }
  432. if (count !== 0) return { valid: false, message: '括号不匹配' };
  433. // 基本语法:检查操作符是否孤立(简单启发)
  434. const tokens = parseExpression(expr);
  435. if (tokens.length === 0) return { valid: false, message: '无法解析表达式' };
  436. for (let i = 0; i < tokens.length - 1; i++) {
  437. const cur = tokens[i];
  438. const next = tokens[i + 1];
  439. if (cur.type === 'operand' && (next.type === 'number' || next.type === 'operand')) {
  440. return { valid: false, message: `监测变量 ${cur.value} 位置可能不正确` };
  441. }
  442. if (cur.type === 'operator' && (next.type === 'operator' || next.type === 'logic' || next.type === 'rightParen')) {
  443. return { valid: false, message: `运算符 ${cur.value} 后缺少操作数` };
  444. }
  445. if (cur.type === 'logic' && (next.type === 'logic' || next.type === 'rightParen')) {
  446. return { valid: false, message: `逻辑符 ${cur.value} 位置可能不正确` };
  447. }
  448. if (cur.type === 'number' && (next.type === 'operand' || next.type === 'number')) {
  449. return { valid: false, message: `数值 ${cur.value} 位置可能不正确` };
  450. }
  451. }
  452. return { valid: true };
  453. };
  454. /**
  455. * 基于 items 为混合优先级表达式自动添加括号
  456. * 仅在最外层 || 分隔的段中,对未加括号且包含 && 的段添加括号
  457. */
  458. const addParenthesesForAnd = (_expr: string): string => {
  459. const tokens = items.value;
  460. if (!tokens.length) return _expr;
  461. // 1. 计算每个 token 在整体表达式中的嵌套深度
  462. const depths: number[] = [];
  463. let depth = 0;
  464. for (const t of tokens) {
  465. if (t.type === 'leftParen') {
  466. depths.push(depth);
  467. depth++;
  468. } else if (t.type === 'rightParen') {
  469. depth--;
  470. depths.push(depth);
  471. } else {
  472. depths.push(depth);
  473. }
  474. }
  475. // 2. 按最外层 (depth === 0) 的 || 切分成段
  476. const segments: Item[][] = [];
  477. let current: Item[] = [];
  478. for (let i = 0; i < tokens.length; i++) {
  479. if (tokens[i].type === 'logic' && tokens[i].value === '||' && depths[i] === 0) {
  480. if (current.length) segments.push(current);
  481. current = [];
  482. } else {
  483. current.push(tokens[i]);
  484. }
  485. }
  486. if (current.length) segments.push(current);
  487. // 3. 辅助:判断段是否已被一整对括号包裹
  488. const isEnclosed = (seg: Item[]): boolean => {
  489. if (seg.length < 2) return false;
  490. if (seg[0].type !== 'leftParen' || seg[seg.length - 1].type !== 'rightParen') return false;
  491. let bal = 0;
  492. for (let i = 1; i < seg.length - 1; i++) {
  493. if (seg[i].type === 'leftParen') bal++;
  494. else if (seg[i].type === 'rightParen') bal--;
  495. if (bal < 0) return false;
  496. }
  497. return bal === 0;
  498. };
  499. // 4. 辅助:判断段内是否存在最外层 (深度为0) 的 &&
  500. const hasTopLevelAnd = (seg: Item[]): boolean => {
  501. let d = 0;
  502. for (const t of seg) {
  503. if (t.type === 'leftParen') d++;
  504. else if (t.type === 'rightParen') d--;
  505. else if (t.type === 'logic' && t.value === '&&' && d === 0) return true;
  506. }
  507. return false;
  508. };
  509. // 5. 重建表达式,对需要的段添加括号
  510. return segments
  511. .map((seg) => {
  512. const str = seg.map((i) => i.value).join(' ');
  513. if (!isEnclosed(seg) && hasTopLevelAnd(seg)) {
  514. return `( ${str} )`;
  515. }
  516. return str;
  517. })
  518. .join(' || ');
  519. };
  520. /**
  521. * 判断 items 最外层(depth=0)是否同时存在 && 和 ||
  522. */
  523. const checkTopLevelMixed = (): boolean => {
  524. let depth = 0;
  525. let hasAnd = false;
  526. let hasOr = false;
  527. for (const item of items.value) {
  528. if (item.type === 'leftParen') {
  529. depth++;
  530. } else if (item.type === 'rightParen') {
  531. depth--;
  532. } else if (item.type === 'logic' && depth === 0) {
  533. if (item.value === '&&') hasAnd = true;
  534. else if (item.value === '||') hasOr = true;
  535. }
  536. }
  537. return hasAnd && hasOr;
  538. };
  539. // 提交逻辑
  540. const confirm = async (): Promise<void> => {
  541. const expr = expressionString.value;
  542. const validation = validateExpression(expr);
  543. if (!validation.valid) {
  544. errorMessage.value = validation.message || '表达式不合法';
  545. return;
  546. }
  547. errorMessage.value = '';
  548. // 基于 items 判断最外层是否存在未加括号的 && 与 || 混合
  549. const hasMixed = checkTopLevelMixed();
  550. let finalExpr = expr;
  551. if (hasMixed) {
  552. await new Promise<void>((resolve) => {
  553. const originalExpr = expr;
  554. const suggestedExpr = addParenthesesForAnd(expr);
  555. Modal.confirm({
  556. title: '运算符优先级提醒',
  557. content: h('div', [
  558. h('p', '表达式最外层同时包含“且(&&)”和“或(||)”,程序将优先计算“且(&&)”。'),
  559. h('p', { style: { margin: '8px 0' } }, [
  560. h('strong', '原表达式:'),
  561. h('code', { style: { backgroundColor: '#f5f5f5', padding: '2px 6px', borderRadius: '4px' } }, originalExpr),
  562. ]),
  563. h('p', { style: { margin: '8px 0' } }, [
  564. h('strong', '建议表达式:'),
  565. h('code', { style: { backgroundColor: '#f6ffed', padding: '2px 6px', borderRadius: '4px' } }, suggestedExpr),
  566. ]),
  567. h('p', '是否自动添加括号以明确优先级?'),
  568. ]),
  569. okText: '自动添加',
  570. cancelText: '保持原样',
  571. onOk: () => {
  572. finalExpr = suggestedExpr;
  573. // 同步到画布(仅当用户选择自动添加时才更新画布)
  574. const parsed = parseExpression(finalExpr);
  575. if (parsed.length) items.value = parsed;
  576. resolve();
  577. },
  578. onCancel: () => resolve(),
  579. });
  580. });
  581. }
  582. // 统一提交
  583. emit('update:modelValue', finalExpr);
  584. emit('change', finalExpr);
  585. emit('confirm', finalExpr);
  586. inputString.value = finalExpr;
  587. message.success('已更新');
  588. };
  589. // 重置
  590. const reset = (): void => {
  591. items.value = [];
  592. inputString.value = '';
  593. manualInput.value = '';
  594. errorMessage.value = '';
  595. emit('update:modelValue', '');
  596. emit('change', '');
  597. };
  598. // 进入高级模式
  599. const enterAdvancedMode = (): void => {
  600. // 将当前 modelValue 初始化到画布
  601. initializeFromExpression(props.modelValue);
  602. compact.value = false;
  603. };
  604. // 返回简易模式
  605. const backToCompact = async (): Promise<void> => {
  606. const currentExpr = expressionString.value;
  607. if (currentExpr === props.modelValue) {
  608. compact.value = true;
  609. return;
  610. }
  611. Modal.confirm({
  612. title: '确认返回',
  613. content: '画布内容已修改,是否应用变更?',
  614. okText: '应用',
  615. cancelText: '放弃',
  616. onOk: async () => {
  617. await confirm(); // 内部会校验、询问优先级、提交
  618. compact.value = true;
  619. },
  620. onCancel: () => {
  621. // 放弃变更:恢复为原始 modelValue
  622. initializeFromExpression(props.modelValue);
  623. compact.value = true;
  624. },
  625. });
  626. };
  627. const applyTemplate = (): void => {
  628. if (!selectedTemplate.value) return;
  629. initializeFromExpression(selectedTemplate.value);
  630. selectedTemplate.value = '';
  631. message.success('模板已应用');
  632. };
  633. const applyManualInput = (): void => {
  634. const expr = manualInput.value.trim();
  635. if (!expr) return;
  636. const validation = validateExpression(expr);
  637. if (!validation.valid) {
  638. errorMessage.value = validation.message || '表达式不合法';
  639. return;
  640. }
  641. initializeFromExpression(expr);
  642. errorMessage.value = '';
  643. message.success('已应用');
  644. };
  645. watch(
  646. () => props.modelValue,
  647. (newVal) => {
  648. if (newVal !== undefined) {
  649. inputString.value = newVal;
  650. // 如果当前处于高级模式,也同步画布
  651. // if (!compact.value) {
  652. const parsed = parseExpression(newVal);
  653. items.value = parsed.length ? parsed : [];
  654. // }
  655. }
  656. },
  657. { immediate: true }
  658. );
  659. // 暴露校验方法供表单 rules 使用
  660. defineExpose({ validate: () => validateExpression(props.modelValue) });
  661. </script>
  662. <style scoped lang="less">
  663. .relation-builder {
  664. // :deep(.ant-card) {
  665. // height: 100%;
  666. // }
  667. // :deep(.ant-card-body) {
  668. // height: calc(100% - 57px);
  669. // overflow-y: auto;
  670. // }
  671. .relation-card {
  672. height: 680px;
  673. overflow-y: auto;
  674. }
  675. .relation-card-footer {
  676. height: 30px;
  677. }
  678. .chip-container {
  679. display: flex;
  680. flex-wrap: wrap;
  681. gap: 4px;
  682. }
  683. .chip {
  684. display: inline-block;
  685. padding: 4px 12px;
  686. border-radius: 4px;
  687. font-size: 14px;
  688. cursor: grab;
  689. user-select: none;
  690. border: 1px solid @border-color-base;
  691. background: @white;
  692. transition: all 0.2s;
  693. &:hover {
  694. // transform: translateY(-2px);
  695. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
  696. }
  697. &:active {
  698. cursor: grabbing;
  699. }
  700. &.chip-operand {
  701. background: #e6f7ff;
  702. border-color: #91d5ff;
  703. color: #0050b3;
  704. }
  705. &.chip-operator {
  706. background: #f6ffed;
  707. border-color: #b7eb8f;
  708. color: #135200;
  709. }
  710. &.chip-logic {
  711. background: #fff7e6;
  712. border-color: #ffd591;
  713. color: #ad4e00;
  714. }
  715. &.chip-number {
  716. background: #f9f0ff;
  717. border-color: #d3adf7;
  718. color: #531dab;
  719. }
  720. &.chip-paren {
  721. background: #f0f0f0;
  722. border-color: #bfbfbf;
  723. font-weight: bold;
  724. }
  725. }
  726. .canvas-container {
  727. min-height: 200px;
  728. max-height: 400px;
  729. overflow-y: auto;
  730. border: 2px dashed #d9d9d9;
  731. border-radius: 8px;
  732. padding: 16px;
  733. background: #fafafa;
  734. transition: all 0.2s;
  735. &.drag-over {
  736. border-color: #1890ff;
  737. background: #e6f7ff;
  738. }
  739. .expression-list {
  740. display: flex;
  741. flex-wrap: wrap;
  742. align-items: center;
  743. gap: 8px;
  744. }
  745. }
  746. .expr-tag {
  747. font-size: 14px;
  748. padding: 6px 12px;
  749. margin: 0;
  750. // :deep(.anticon-close) {
  751. // margin-left: 6px;
  752. // &:hover {
  753. // color: #ff4d4f;
  754. // }
  755. // }
  756. }
  757. }
  758. </style>