多规则实现

0 阅读7分钟

image.png

// ruleAdapter
import { v4 as uuidv4 } from "uuid";
import type {
  ConditionGroup,
  ConditionRule,
  ConditionNode
} from "./ConditionBuilder/types";
import type {
  BackendRuleItem,
  BackendRuleDetail,
  BackendRulePayload
} from "./types";

// ================= Map 配置 =================

const OP_MAP_TO_API: Record<string, string> = {
  ">": "gt",
  "<": "lt",
  "=": "eq",
  ">=": "ge",
  "<=": "le"
};
const OP_MAP_TO_UI: Record<string, string> = {
  gt: ">",
  lt: "<",
  eq: "=",
  ge: ">=",
  le: "<="
};

// ================= 1. 后端 -> 前端 (回显) =================

export function transformToUI(data: BackendRulePayload): ConditionGroup {
  // 默认根节点
  const rootGroup: ConditionGroup = {
    id: uuidv4(),
    type: "group",
    logic: "AND",
    children: []
  };

  if (!data.ruleItemList || data.ruleItemList.length === 0) {
    return rootGroup;
  }

  // 智能识别根结构:
  // 情况 A (mockData[1]): ruleItemList 只有一个元素,且它是 formula 类型
  // 这意味着这个 formula 就是我们的 Root Group
  if (
    data.ruleItemList.length === 1 &&
    data.ruleItemList[0].ruleItemType === "formula"
  ) {
    const rootFormula = data.ruleItemList[0];
    rootGroup.logic =
      (rootFormula.ruleFormula?.toUpperCase() as "AND" | "OR") || "AND";
    rootGroup.children = (rootFormula.children || []).map(processApiToNode);
  }
  // 情况 B (mockData[0]): ruleItemList 是一个列表(通常隐含 AND 关系),直接作为 Root Group 的子级
  else {
    rootGroup.logic = "AND"; // 列表默认视为 AND
    rootGroup.children = data.ruleItemList.map(processApiToNode);
  }

  return rootGroup;
}

// 递归处理函数
function processApiToNode(item: BackendRuleItem): ConditionNode {
  // 1. 处理组 (Formula)
  if (item.ruleItemType === "formula") {
    return {
      id: uuidv4(),
      type: "group",
      logic: (item.ruleFormula?.toUpperCase() as "AND" | "OR") || "AND",
      children: (item.children || []).map(processApiToNode)
    } as ConditionGroup;
  }

  // 2. 处理规则 (Metric)
  else {
    const left = item.ruleDetailList?.[0] || {};
    const right = item.ruleDetailList?.[1] || {};

    return {
      id: uuidv4(),
      type: "rule",
      metric: left.indicatorCode || "",
      dimension: left.objectField || "",
      operator: OP_MAP_TO_UI[item.operation || ""] || item.operation || "",
      // 如果右值是 fixed 则是 'value',否则认为是字段 'field'
      valueType: right.valType === "fixed" ? "value" : "field",
      value: String(right.fixedValue !== undefined ? right.fixedValue : "")
    } as ConditionRule;
  }
}

// ================= 2. 前端 -> 后端 (提交) =================

export function transformToApi(
  node: ConditionGroup,
  originalData?: Partial<BackendRulePayload>
): BackendRulePayload {
  let ruleItemList: BackendRuleItem[] = [];

  // 策略:
  // 如果根节点是 OR 逻辑,或者我们需要严格保持树形结构,
  // 我们将整个 Root Group 包装成一个 formula 对象放入 list 中 (对应 mockData[1])。
  // 如果根节点是 AND 逻辑,我们可以直接将其 children 放入 list 中 (对应 mockData[0] 的扁平化风格)。

  if (node.logic === "OR") {
    // 根逻辑是 OR,必须包裹一层 formula,因为 ruleItemList 顶层通常隐含 AND
    ruleItemList = [processNodeToApi(node)];
  } else {
    // 根逻辑是 AND,直接展开 children,这样生成的 JSON 更简洁 (类似 mockData[0])
    ruleItemList = node.children.map(processNodeToApi);
  }

  return {
    id: originalData?.id, // 保持原有 ID
    name: originalData?.name, // 保持原有 Name
    ruleType: "metric",
    ruleItemList: ruleItemList
  };
}

// 递归处理函数
function processNodeToApi(node: ConditionNode): BackendRuleItem {
  // 1. 前端 Group -> 后端 Formula
  if (node.type === "group") {
    return {
      ruleItemType: "formula",
      ruleFormula: node.logic.toLowerCase() as "and" | "or",
      children: node.children.map(processNodeToApi)
    };
  }

  // 2. 前端 Rule -> 后端 Metric
  else {
    const rule = node as ConditionRule;

    // 构造左值
    const leftSide: BackendRuleDetail = {
      name: "指标名称占位", // 后端可能需要,或者根据 code 自动填充
      valType: "metric",
      compareType: "parameter",
      indicatorCode: rule.metric,
      objectField: rule.dimension
    };

    // 构造右值
    const rightSide: BackendRuleDetail = {
      compareType:
        rule.valueType === "value" ? "parameter" : "compareParameter", // 根据 mockData[1] 调整
      valType: rule.valueType === "value" ? "fixed" : "metric", // 注意:如果选的是对象,valType 可能是 metric 或 parameter
      fixedValue: rule.valueType === "value" ? Number(rule.value) : undefined
      // 如果是对象对比,可能需要传 indicatorCode 等,这里暂按 fixedValue 处理
    };

    return {
      ruleItemType: "metric",
      operation: (OP_MAP_TO_API[rule.operator] as any) || "eq",
      ruleDetailList: [leftSide, rightSide]
    };
  }
}

// 1. 规则详情(左值/右值)
export interface BackendRuleDetail {
  name?: string;
  valType: "metric" | "fixed" | "parameter";
  compareType?: "parameter" | "compareParameter"; // 新增
  indicatorCode?: string; // 指标编码
  objectField?: string; // 维度字段
  fixedValue?: string | number; // 具体数值
}

// 2. 规则项 (核心递归结构)
export interface BackendRuleItem {
  ruleItemType: "metric" | "formula"; // 变更:group -> formula

  // --- metric 类型特有 ---
  operation?: "gt" | "lt" | "eq" | "ge" | "le";
  ruleDetailList?: BackendRuleDetail[];

  // --- formula 类型特有 ---
  ruleFormula?: "and" | "or"; // 变更:逻辑字段名
  children?: BackendRuleItem[]; // 变更:嵌套字段名
}

// 3. 完整报文
export interface BackendRulePayload {
  id?: number | string;
  name?: string;
  ruleType: string;
  ruleItemList: BackendRuleItem[];
}

// IndicatorDialog
<script setup lang="ts">
import { ref } from "vue";
import { Refresh, Plus, Delete } from "@element-plus/icons-vue";
import ConditionGroupComp from "../ConditionBuilder/ConditionGroup.vue";
import type { ConditionGroup } from "../ConditionBuilder/types";
import { v4 as uuidv4 } from "uuid";
import type { BackendRulePayload } from "../types";
import { transformToApi, transformToUI } from "../ruleAdapter";
import { ElMessage } from "element-plus";

const visible = ref(false);

// 1. 定义空状态/默认状态 (用于新增或重置)
const initialData: ConditionGroup = {
  id: "root",
  type: "group",
  logic: "AND",
  children: [
    {
      id: uuidv4(),
      type: "rule",
      metric: "loss_ratio",
      dimension: "yearly",
      operator: ">",
      valueType: "field",
      value: "parent_org"
    },
    // 模拟图中的嵌套组
    {
      id: uuidv4(),
      type: "group",
      logic: "OR",
      children: [
        {
          id: uuidv4(),
          type: "rule",
          metric: "loss_ratio",
          dimension: "monthly_ratio",
          operator: ">",
          valueType: "field",
          value: "parent_org"
        },
        {
          id: uuidv4(),
          type: "rule",
          metric: "loss_ratio",
          dimension: "monthly_yoy",
          operator: "value",
          valueType: "value",
          value: "0"
        }
      ]
    },
    {
      id: uuidv4(),
      type: "rule",
      metric: "loss_ratio",
      dimension: "yearly",
      operator: ">",
      valueType: "field",
      value: "parent_org"
    }
  ]
};

// 规则列表项接口
interface RuleItem {
  id: number | string;
  name: string;
  ruleType: string;
  rawData?: BackendRulePayload;
  uiData: ConditionGroup;
}

// 规则列表
const rulesList = ref<RuleItem[]>([]);

// 2. 打开弹窗的方法
const open = (data?: BackendRulePayload | BackendRulePayload[]) => {
  visible.value = true;

  if (Array.isArray(data)) {
    // === 编辑多个规则 ===
    console.log("正在编辑多个规则:", data);
    rulesList.value = data.map((rule, index) => ({
      id: rule.id || uuidv4(),
      name: rule.name || `规则 ${index + 1}`,
      ruleType: rule.ruleType || "metric",
      rawData: rule,
      uiData: transformToUI(rule)
    }));
  } else if (data) {
    // === 编辑单个规则 ===
    console.log("正在编辑单个规则:", data);
    rulesList.value = [
      {
        id: data.id || uuidv4(),
        name: data.name || "规则 1",
        ruleType: data.ruleType || "metric",
        rawData: data,
        uiData: transformToUI(data)
      }
    ];
  } else {
    // === 新增模式 - 默认创建一个空规则 ===
    rulesList.value = [
      {
        id: uuidv4(),
        name: "规则 1",
        ruleType: "metric",
        uiData: JSON.parse(JSON.stringify(initialData))
      }
    ];
  }
};

// 新增规则
const addRule = () => {
  const newRule: RuleItem = {
    id: uuidv4(),
    name: `规则 ${rulesList.value.length + 1}`,
    ruleType: "metric",
    uiData: JSON.parse(JSON.stringify(initialData))
  };
  rulesList.value.push(newRule);
};

// 删除规则
const deleteRule = (index: number) => {
  if (rulesList.value.length <= 1) {
    ElMessage.warning("至少需要保留一个规则");
    return;
  }
  rulesList.value.splice(index, 1);
  // 重新编号
  rulesList.value.forEach((rule, i) => {
    if (!rule.rawData?.name) {
      rule.name = `规则 ${i + 1}`;
    }
  });
};

// 恢复当前规则为默认
const handleReset = (index: number) => {
  rulesList.value[index].uiData = JSON.parse(JSON.stringify(initialData));
};

// 提交数据
const handleConfirm = () => {
  // 转换所有规则为后端格式
  const payload = rulesList.value.map(rule => {
    const apiData = transformToApi(rule.uiData, rule.rawData);
    return {
      ...apiData,
      name: rule.name
    };
  });

  console.log("最终提交的规则列表:", JSON.stringify(payload, null, 2));

  // 这里调用后端保存接口
  // await api.saveRules(payload);

  visible.value = false;
};

defineExpose({ open });
</script>

<template>
  <el-dialog
    v-model="visible"
    title="指标预警"
    width="900px"
    destroy-on-close
    class="indicator-dialog"
  >
    <div class="p-4 min-h-[300px]">
      <div
        v-for="(rule, index) in rulesList"
        :key="rule.id"
        class="mb-6 last:mb-0"
      >
        <!-- 规则标题和操作 -->
        <div class="flex items-center justify-between mb-3">
          <span class="font-medium text-gray-700">{{ rule.name }}</span>
          <div class="flex gap-2">
            <el-button
              link
              type="primary"
              :icon="Refresh"
              size="small"
              @click="handleReset(index)"
            >
              恢复默认设置
            </el-button>
            <el-button
              link
              type="danger"
              :icon="Delete"
              size="small"
              @click="deleteRule(index)"
            >
              删除
            </el-button>
          </div>
        </div>

        <!-- 规则编辑器 -->
        <ConditionGroupComp :node="rule.uiData" :depth="0" />

        <!-- 规则之间的分隔线 -->
        <div
          v-if="index < rulesList.length - 1"
          class="my-6 border-t border-gray-200"
        />
      </div>

      <!-- 新增规则按钮 -->
      <div class="flex justify-center mt-6">
        <el-button type="primary" :icon="Plus" plain @click="addRule">
          新增规则
        </el-button>
      </div>
    </div>

    <template #footer>
      <div class="flex justify-between items-center w-full">
        <div />
        <div>
          <el-button @click="visible = false">取消</el-button>
          <el-button type="primary" @click="handleConfirm">确认</el-button>
        </div>
      </div>
    </template>
  </el-dialog>
</template>

<style lang="scss">
// 全局样式覆盖,如果是 scoped 可能会无法覆盖 el-dialog body
.indicator-dialog {
  .el-dialog__body {
    padding-top: 10px;
    padding-bottom: 10px;
  }
}
</style>

// src/views/example/ConditionBuilder/types.ts
export type LogicType = "AND" | "OR";
export type NodeType = "group" | "rule";

export interface ConditionRule {
  id: string;
  type: "rule";
  metric: string; // 指标,如 'loss_ratio'
  dimension: string; // 维度,如 'yearly'
  operator: string; // 操作符,如 '>'
  valueType: string; // 值类型,如 'value' | 'field'
  value: string; // 具体值
}

export interface ConditionGroup {
  id: string;
  type: "group";
  logic: LogicType; // 'AND' | 'OR'
  children: (ConditionGroup | ConditionRule)[];
}

// 联合类型
export type ConditionNode = ConditionGroup | ConditionRule;

//src/views/example/ConditionBuilder/ConditionRule.vue
<script setup lang="ts">
import { Delete, Plus } from "@element-plus/icons-vue";
import type { ConditionRule } from "./types";
import { computed } from "vue";

// 接收 props
const props = defineProps<{
  item: ConditionRule;
  index: number; // 当前在列表中的索引,用于判断是否显示新增按钮
  depth: number; // 当前层级深度,用于判断按钮文案
}>();

// 定义事件
const emit = defineEmits(["delete", "add"]);

// 模拟下拉数据(保持不变)
const metricOptions = [{ label: "车险已报告赔付率", value: "loss_ratio" }];
const dimOptions = [
  { label: "年累计", value: "yearly" },
  { label: "月环比", value: "monthly_ratio" },
  { label: "月同比", value: "monthly_yoy" }
];
const opOptions = [
  { label: ">", value: ">" },
  { label: "<", value: "<" },
  { label: "=", value: "=" },
  { label: "数值", value: "value" }
];
</script>

<template>
  <div class="flex items-center gap-2 w-full">
    <!-- 1. 指标选择 -->
    <el-select
      v-model="props.item.metric"
      placeholder="请选择指标"
      class="!w-[200px]"
    >
      <el-option
        v-for="opt in metricOptions"
        :key="opt.value"
        :label="opt.label"
        :value="opt.value"
      />
    </el-select>

    <!-- 2. 维度选择 -->
    <el-select v-model="item.dimension" placeholder="维度" class="!w-[120px]">
      <el-option
        v-for="opt in dimOptions"
        :key="opt.value"
        :label="opt.label"
        :value="opt.value"
      />
    </el-select>

    <!-- 3. 操作符 -->
    <el-select v-model="item.operator" placeholder="操作符" class="!w-[120px]">
      <el-option
        v-for="opt in opOptions"
        :key="opt.value"
        :label="opt.label"
        :value="opt.value"
      />
    </el-select>

    <!-- 4. 值/对象 -->
    <template v-if="item.operator === 'value'">
      <el-input
        v-model="item.value"
        placeholder="输入数值"
        class="!w-[150px]"
      />
    </template>
    <template v-else>
      <el-select v-model="item.value" placeholder="选择对象" class="!w-[150px]">
        <el-option label="上级机构" value="parent_org" />
        <el-option label="0" value="0" />
      </el-select>
    </template>

    <!-- 5. 删除按钮 -->
    <el-button type="danger" link :icon="Delete" @click="emit('delete')" />

    <!-- 6. 新增按钮 (核心逻辑) -->
    <!-- 只有每一组的第一个元素显示添加按钮 -->
    <el-button
      v-if="index === 0"
      type="primary"
      link
      :icon="Plus"
      class="ml-2"
      @click="emit('add')"
    >
      <!-- 第一层显示“筛选”,子层显示“并且满足” -->
      {{ depth === 0 ? "筛选" : "并且满足" }}
    </el-button>
  </div>
</template>

<style scoped>
:deep(.el-input__wrapper) {
  box-shadow: 0 0 0 1px #dcdfe6 inset;
}
</style>

// src/views/example/ConditionBuilder/ConditionGroup.vue
<script setup lang="ts">
import ConditionRuleComp from "./ConditionRule.vue";
import type { ConditionGroup, ConditionRule } from "./types";
import { v4 as uuidv4 } from "uuid";

const props = defineProps<{
  node: ConditionGroup;
  depth: number;
}>();

const emit = defineEmits(["delete-node"]);

// 切换 AND / OR
const toggleLogic = (val: "AND" | "OR") => {
  props.node.logic = val;
};

// 核心:添加规则
// 这个方法是在当前 Group 的 children 数组末尾追加一个 Rule
const addRule = () => {
  const newRule: ConditionRule = {
    id: uuidv4(),
    type: "rule",
    metric: "",
    dimension: "",
    operator: "",
    valueType: "value",
    value: ""
  };
  props.node.children.push(newRule);
};

// 删除子节点
const removeChild = (index: number) => {
  props.node.children.splice(index, 1);
  // 如果子节点删光了,且不是最顶层根节点,建议把当前空组也删掉,避免出现空的工字架
  if (props.node.children.length === 0 && props.depth > 0) {
    emit("delete-node");
  }
};
</script>

<template>
  <div class="flex w-full">
    <!-- ================= 左侧工字型连线区域 ================= -->
    <!-- 宽度固定,右边距固定,确保不会挤压右侧内容 -->
    <div class="relative w-14 flex-shrink-0 mr-2 select-none">
      <!-- 只有当子元素数量 > 1 时,才显示“且/或”开关和连线 -->
      <template v-if="node.children.length > 1">
        <!-- 
           连线容器:
           top-4 (16px) 对应 Element输入框(32px)的中心点
           bottom-4 (16px) 同理
           这样能保证连线始终垂直居中于内容区域
        -->
        <div
          class="absolute left-1/2 top-4 bottom-4 -translate-x-1/2 w-full pointer-events-none"
        >
          <!-- 1. 垂直主线 -->
          <div
            class="absolute left-1/2 top-0 bottom-0 w-[1px] bg-gray-300 -translate-x-1/2"
          />
          <!-- 2. 顶部短横线 -->
          <div
            class="absolute left-1/2 top-0 w-3 h-[1px] bg-gray-300 -translate-x-1/2"
          />
          <!-- 3. 底部短横线 -->
          <div
            class="absolute left-1/2 bottom-0 w-3 h-[1px] bg-gray-300 -translate-x-1/2"
          />
        </div>

        <!-- 逻辑开关 (绝对定位居中) -->
        <div
          class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10"
        >
          <div
            class="flex flex-col items-center bg-gray-100 p-1 rounded-lg shadow-sm border border-gray-200"
          >
            <!-- 且 -->
            <div
              class="w-6 h-6 flex items-center justify-center text-xs rounded cursor-pointer transition-all mb-1"
              :class="
                node.logic === 'AND'
                  ? 'bg-blue-500 text-white shadow-sm'
                  : 'text-gray-500 hover:text-gray-700'
              "
              @click="toggleLogic('AND')"
            ></div>
            <!-- 或 -->
            <div
              class="w-6 h-6 flex items-center justify-center text-xs rounded cursor-pointer transition-all"
              :class="
                node.logic === 'OR'
                  ? 'bg-blue-500 text-white shadow-sm'
                  : 'text-gray-500 hover:text-gray-700'
              "
              @click="toggleLogic('OR')"
            ></div>
          </div>
        </div>
      </template>
    </div>

    <!-- ================= 右侧内容区域 ================= -->
    <!-- flex-col gap-4: 这里的 gap-4 (16px) 是保证计算正确的关键 -->
    <div class="flex-1 flex flex-col gap-4">
      <div
        v-for="(child, index) in node.children"
        :key="child.id"
        class="relative"
      >
        <!-- 递归渲染:如果子节点是 Group -->
        <template v-if="child.type === 'group'">
          <ConditionGroup
            :node="(child as ConditionGroup)"
            :depth="depth + 1"
            @delete-node="removeChild(index)"
          />
        </template>

        <!-- 渲染:如果子节点是 Rule -->
        <template v-else>
          <!-- 
            注意:这里监听了 @add 事件。
            当 Rule 组件内的按钮被点击时,触发的是当前组件(ConditionGroup)的 addRule 方法,
            从而在当前层级添加一条新规则。
          -->
          <ConditionRuleComp
            :item="(child as ConditionRule)"
            :index="index"
            :depth="depth"
            @delete="removeChild(index)"
            @add="addRule"
          />
        </template>
      </div>
    </div>
  </div>
</template>