|
@@ -0,0 +1,396 @@
|
|
|
+<template>
|
|
|
+ <div class="formula-container">
|
|
|
+ <el-row class="formula-editor">
|
|
|
+ <!-- 左侧编辑面板 -->
|
|
|
+ <el-col :xs="24" :span="16" class="left-panel">
|
|
|
+ <div class="editor-area">
|
|
|
+ <div class="operator-toolbar">
|
|
|
+ <el-button
|
|
|
+ v-for="op in operatorMap"
|
|
|
+ :key="op.value"
|
|
|
+ v-wave
|
|
|
+ class="operator-btn"
|
|
|
+ :title="op.name"
|
|
|
+ @click="insertOperator(op.value)"
|
|
|
+ >
|
|
|
+ <span class="symbol">{{ op.symbol }}</span>
|
|
|
+ </el-button>
|
|
|
+ <el-dropdown @command="insertFunction" trigger="click">
|
|
|
+ <el-button type="primary" class="func-btn">
|
|
|
+ <span>函数列表</span>
|
|
|
+ </el-button>
|
|
|
+ <template #dropdown>
|
|
|
+ <el-dropdown-menu class="func-menu">
|
|
|
+ <el-dropdown-item v-for="func in functions" :key="func" :command="func" class="func-item">
|
|
|
+ <span class="func-name">{{ func }}</span>
|
|
|
+ </el-dropdown-item>
|
|
|
+ </el-dropdown-menu>
|
|
|
+ </template>
|
|
|
+ </el-dropdown>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <el-input
|
|
|
+ ref="editorRef"
|
|
|
+ v-model="formulaValue"
|
|
|
+ type="textarea"
|
|
|
+ :rows="8"
|
|
|
+ placeholder="请输入公式,例如:SUM(【基础工资】*1.2 + 【绩效奖金】)"
|
|
|
+ resize="none"
|
|
|
+ class="editor-input"
|
|
|
+ spellcheck="false"
|
|
|
+ >
|
|
|
+ </el-input>
|
|
|
+
|
|
|
+ <!-- <div class="action-buttons">
|
|
|
+ <el-button type="default" @click="handleReset">清空</el-button>
|
|
|
+ <el-button type="warning" @click="handleCheck">检查公式</el-button>
|
|
|
+ <el-button type="primary" @click="handleConfirm">确认</el-button>
|
|
|
+ </div> -->
|
|
|
+ </div>
|
|
|
+ </el-col>
|
|
|
+
|
|
|
+ <!-- 右侧参数面板 -->
|
|
|
+ <el-col :xs="24" :span="8" class="right-panel">
|
|
|
+ <div class="param-tree">
|
|
|
+ <!-- <div class="header">
|
|
|
+ <span>参数选择</span>
|
|
|
+ </div> -->
|
|
|
+
|
|
|
+ <el-input
|
|
|
+ v-model="filterText"
|
|
|
+ placeholder="搜索参数..."
|
|
|
+ clearable
|
|
|
+ class="search-box"
|
|
|
+ @input="handleSearchInput"
|
|
|
+ @keyup.enter="handleSearchEnter"
|
|
|
+ @clear="handleSearchClear"
|
|
|
+ />
|
|
|
+
|
|
|
+ <div class="tree-container">
|
|
|
+ <el-tree
|
|
|
+ ref="treeRef"
|
|
|
+ :data="props.mockParameters"
|
|
|
+ :props="defaultProps"
|
|
|
+ :filter-node-method="filterNode"
|
|
|
+ @node-click="handleNodeClick"
|
|
|
+ default-expand-all
|
|
|
+ node-key="text"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-col>
|
|
|
+ </el-row>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import { ref, computed, watch, nextTick } from "vue";
|
|
|
+import { ElMessage, ElTree } from "element-plus";
|
|
|
+import { debounce, cloneDeepWith, isObject } from "lodash-es";
|
|
|
+import { pinyin } from "pinyin-pro";
|
|
|
+import { FormulaCheck } from "@/api/modules/basicinfo";
|
|
|
+
|
|
|
+interface TreeNode {
|
|
|
+ text: string;
|
|
|
+ value?: number;
|
|
|
+ children?: TreeNode[];
|
|
|
+}
|
|
|
+interface FormulaEditorProps {
|
|
|
+ /** 初始公式值 */
|
|
|
+ modelValue: string;
|
|
|
+ /** 参数树数据 */
|
|
|
+ mockParameters: TreeNode[];
|
|
|
+}
|
|
|
+
|
|
|
+// Props定义
|
|
|
+const props = withDefaults(defineProps<FormulaEditorProps>(), {
|
|
|
+ modelValue: "",
|
|
|
+ mockParameters: () => [] as TreeNode[]
|
|
|
+});
|
|
|
+
|
|
|
+// Emits定义
|
|
|
+const emits = defineEmits<{
|
|
|
+ "update:modelValue": [value: string]; // v-model标准事件
|
|
|
+}>();
|
|
|
+
|
|
|
+// 响应式状态
|
|
|
+const formulaValue = ref<string>(props.modelValue);
|
|
|
+const filterText = ref("");
|
|
|
+const treeRef = ref<InstanceType<typeof ElTree>>();
|
|
|
+// 调整默认props配置
|
|
|
+const defaultProps = {
|
|
|
+ children: "children",
|
|
|
+ label: "text" // 与ElementUI Tree组件默认字段对齐
|
|
|
+};
|
|
|
+// 监听本地修改
|
|
|
+watch(formulaValue, newVal => {
|
|
|
+ emits("update:modelValue", newVal);
|
|
|
+});
|
|
|
+
|
|
|
+// 常量
|
|
|
+const operatorMap = ref([
|
|
|
+ {
|
|
|
+ value: "+",
|
|
|
+ symbol: "+",
|
|
|
+ name: "加"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ value: "-",
|
|
|
+ symbol: "-",
|
|
|
+ name: "减"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ value: "*",
|
|
|
+ symbol: "×",
|
|
|
+ name: "乘"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ value: "/",
|
|
|
+ symbol: "÷",
|
|
|
+ name: "除"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ value: "(",
|
|
|
+ symbol: "(",
|
|
|
+ name: "左括号"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ value: ")",
|
|
|
+ symbol: ")",
|
|
|
+ name: "右括号"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ value: "**",
|
|
|
+ symbol: "^",
|
|
|
+ name: "幂"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ value: "%",
|
|
|
+ symbol: "%",
|
|
|
+ name: "模"
|
|
|
+ }
|
|
|
+]);
|
|
|
+const functions = ["SUM", "AVG", "IF", "MAX", "MIN"];
|
|
|
+
|
|
|
+const filterNode = (value: string, data: TreeNode, node: any): boolean => {
|
|
|
+ // 空搜索时保留有效结构
|
|
|
+ if (!value) {
|
|
|
+ // 空搜索时:保留所有有效节点及其祖先路径
|
|
|
+ const selfValid = !!data.value || data.children !== undefined;
|
|
|
+ const keepForStructure = node.parent?.expanded || selfValid;
|
|
|
+ return keepForStructure;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 核心匹配逻辑(不依赖pinyinFilterTree的树裁剪)
|
|
|
+ const isSelfMatch = [data.text].some(
|
|
|
+ text =>
|
|
|
+ text &&
|
|
|
+ (text.includes(value) ||
|
|
|
+ pinyin(text, { toneType: "none", type: "array" }).join("").includes(value.toLowerCase()) ||
|
|
|
+ pinyin(text, { pattern: "first", type: "array", toneType: "none" }).join("").includes(value.toLowerCase()))
|
|
|
+ );
|
|
|
+
|
|
|
+ // 子节点匹配状态(需要展开父节点)
|
|
|
+ const hasChildMatch = node.childNodes?.some(child => child.data && filterNode(value, child.data, child));
|
|
|
+
|
|
|
+ // 保留逻辑:自身匹配或子节点匹配
|
|
|
+ const shouldShow = isSelfMatch || hasChildMatch;
|
|
|
+
|
|
|
+ return shouldShow;
|
|
|
+};
|
|
|
+
|
|
|
+// 优化有效性判断
|
|
|
+const isNodeValid = (node: TreeNode): boolean => {
|
|
|
+ // 有效条件:有code 或 有有效子节点
|
|
|
+ return !!node.value || (node.children?.some(isNodeValid) ?? false);
|
|
|
+};
|
|
|
+
|
|
|
+// 搜索处理逻辑
|
|
|
+const handleSearchInput = debounce((value: string) => {
|
|
|
+ treeRef.value?.filter(value);
|
|
|
+}, 300);
|
|
|
+
|
|
|
+// 回车键立即搜索
|
|
|
+const handleSearchEnter = () => {
|
|
|
+ treeRef.value?.filter(filterText.value);
|
|
|
+};
|
|
|
+
|
|
|
+// 清空时立即刷新
|
|
|
+const handleSearchClear = () => {
|
|
|
+ treeRef.value?.filter("");
|
|
|
+};
|
|
|
+
|
|
|
+const insertOperator = (op: string) => {
|
|
|
+ handleInsertFormula(op);
|
|
|
+};
|
|
|
+
|
|
|
+const insertFunction = (func: string) => {
|
|
|
+ handleInsertFormula(`${func}()`, -1);
|
|
|
+};
|
|
|
+
|
|
|
+const handleNodeClick = (data: TreeNode) => {
|
|
|
+ if (!data.value) return;
|
|
|
+ handleInsertFormula(`【${data.text}】`);
|
|
|
+};
|
|
|
+
|
|
|
+const handleInsertFormula = (data: string, pm: number = 0) => {
|
|
|
+ if (!data) return;
|
|
|
+ const textarea = document.querySelector(".editor-input textarea") as HTMLTextAreaElement;
|
|
|
+ if (!textarea) return;
|
|
|
+ const start = textarea.selectionStart;
|
|
|
+ const end = textarea.selectionEnd;
|
|
|
+ // 更新公式值
|
|
|
+ formulaValue.value = formulaValue.value.slice(0, start) + data + formulaValue.value.slice(end);
|
|
|
+ // 更新光标位置
|
|
|
+ nextTick(() => {
|
|
|
+ // 计算新光标位置(插入内容末尾)
|
|
|
+ const newPos = start + data.length;
|
|
|
+ textarea.setSelectionRange(newPos + pm, newPos + pm);
|
|
|
+ textarea.focus();
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+const handleReset = () => {
|
|
|
+ formulaValue.value = "";
|
|
|
+};
|
|
|
+
|
|
|
+const handleCheck = async (): Promise<boolean> => {
|
|
|
+ if (!formulaValue.value) {
|
|
|
+ ElMessage.warning("请先输入公式");
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ const result = await FormulaCheck({
|
|
|
+ formula: formulaValue.value
|
|
|
+ });
|
|
|
+
|
|
|
+ if (result) {
|
|
|
+ ElMessage.success("公式检查通过");
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ } catch (error) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 暴露方法供父组件调用
|
|
|
+defineExpose({
|
|
|
+ reset: handleReset,
|
|
|
+ check: handleCheck
|
|
|
+});
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped lang="scss">
|
|
|
+.formula-container {
|
|
|
+ height: 100%;
|
|
|
+ width: 100%;
|
|
|
+ min-height: 510px;
|
|
|
+ --editor-border: #dcdfe6;
|
|
|
+ --panel-bg: #f8f9fa;
|
|
|
+
|
|
|
+ .formula-editor {
|
|
|
+ height: calc(100% - 20px);
|
|
|
+ margin-top: 12px;
|
|
|
+
|
|
|
+ .left-panel {
|
|
|
+ .editor-area {
|
|
|
+ // border-color: var(--editor-border);
|
|
|
+ // box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
|
|
+ }
|
|
|
+
|
|
|
+ .operator-toolbar {
|
|
|
+ gap: 12px;
|
|
|
+ .operator-btn {
|
|
|
+ min-width: 36px;
|
|
|
+ padding: 8px 12px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .right-panel {
|
|
|
+ .param-tree {
|
|
|
+ border-color: var(--editor-border);
|
|
|
+ padding: 8px;
|
|
|
+
|
|
|
+ .header {
|
|
|
+ padding: 12px 16px;
|
|
|
+ font-size: 14px;
|
|
|
+ background: white;
|
|
|
+ }
|
|
|
+
|
|
|
+ .el-tree-node__content:hover {
|
|
|
+ background: rgba(64, 158, 255, 0.08);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .right-panel {
|
|
|
+ .search-box {
|
|
|
+ padding: 0 12px;
|
|
|
+ flex-shrink: 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ .tree-container {
|
|
|
+ .el-tree {
|
|
|
+ padding: 0px 12px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .editor-area {
|
|
|
+ flex: 1;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ // border: 1px solid var(--el-border-color);
|
|
|
+ // border-radius: 4px;
|
|
|
+ // background: var(--el-bg-color);
|
|
|
+ padding: 8px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .param-tree {
|
|
|
+ flex: 1;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ // border: 1px solid var(--el-border-color);
|
|
|
+ // border-radius: 4px;
|
|
|
+ // background: var(--el-bg-color);
|
|
|
+ height: 100%;
|
|
|
+
|
|
|
+ .header {
|
|
|
+ padding: 16px;
|
|
|
+ font-weight: 500;
|
|
|
+ }
|
|
|
+
|
|
|
+ .tree-container {
|
|
|
+ flex: 1;
|
|
|
+ min-height: 0; /* 关键修复 */
|
|
|
+ overflow: hidden;
|
|
|
+
|
|
|
+ .el-tree {
|
|
|
+ height: 100%;
|
|
|
+ :deep(.el-tree-node__content) {
|
|
|
+ padding: 6px 0;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .operator-toolbar {
|
|
|
+ display: flex;
|
|
|
+ gap: 8px;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ padding-bottom: 16px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .action-buttons {
|
|
|
+ margin-top: auto;
|
|
|
+ padding-top: 16px;
|
|
|
+ display: flex;
|
|
|
+ gap: 12px;
|
|
|
+ justify-content: flex-end;
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|