You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
cz-hjjc-oa/自定义字段渲染整理.md

37 KiB

流程详情页面自定义字段渲染整理

📋 概述

本文档整理了 /oa/#/flow/detail?module_id=72&flow_id=13469 页面上关于自定义字段的渲染机制。

🏗️ 组件架构

1. 页面组件结构

detailCommon.vue (主页面组件)
  └── DesktopForm.vue (桌面端表单组件)
       └── formBuilder.js (字段渲染构建器)

2. 核心文件位置

  • 主页面: cz-hjjc-oa/src/views/flow/detailCommon.vue
  • 表单组件: cz-hjjc-oa/src/views/flow/DesktopForm.vue
  • 字段构建器: cz-hjjc-oa/src/utils/formBuilder.js

🔄 渲染流程

1. 数据获取 (detailCommon.vue)

// getConfig() 方法获取流程配置
async getConfig() {
  // 详情页面:获取流程详情
  if (/\/detail/.test(this.$route.path) && this.$route.query.flow_id) {
    const res = await view(this.$route.query.flow_id);
    // 获取字段配置
    const { fields } = res?.customModel;
    // 获取子表单配置
    fields.forEach((field) => {
      if (field.sub_custom_model_id) {
        getSubForm(field.sub_custom_model_id);
      }
    });
    // 设置字段权限
    this.readableFields = this.fields?.map((i) => i.id);
    this.writeableFields = this.config?.currentNode?.writeable || [];
  }
}

2. 字段权限处理 (DesktopForm.vue)

  render(h) {
    const authFields = this.fields.map((field) => ({
      ...field,
      _readable:
        this.readable.indexOf(field.id) !== -1 || field.type === "label",
      _writeable: this.writeable.indexOf(field.id) !== -1,
    }));
    console.log("authFields",authFields)
    if (this.needFlowTitle) {
      authFields.unshift({
        name: "flow_title",
        label: "工作名称",
        type: "text",
        gs_x: 0,
        gs_y: 0,
        gs_width: 12,
        gs_height: 1,
        label_show: 1,
        _readable: !this.isFirstNode,
        _writeable: this.isFirstNode,
      });
    }

3. 字段渲染 (formBuilder.js)

export default function formBuilder(
  device,
  info,
  h,
  row,
  pWrite = false,
  pReadable = false,
  pname
) {
  let target = row ? row : this.form;
  let formItem;
  //下拉选项
  let options = [];
  if (info?.selection_model) {
    options = info.selection_model_items || [];
  } else if (info?.stub) {
    options = info?.stub?.split(/\r\n/) || [];
  }
  if (device === "desktop") {
    //  可写并且不为查看和子表单下
    if (
      info._writeable ||
      (info.type === "relation" && info._readable) ||
      pWrite
    ) {

📝 支持的字段类型

1. 基础字段类型

text (文本输入)

  • 可写: el-input 组件
  • 只读: 显示文本值
        case "text":
          formItem = h("el-input", {
            props: {
              value: target[info.name],
              clearable: true,
              placeholder: info.help_text,
            },
            attrs: {
              placeholder: info.help_text,
            },
            on: {
              input: (e) => {
                this.$set(target, info.name, e);
              },
            },
          });
          break;

textarea (多行文本)

  • 可写: el-input type="textarea"
  • 只读: 显示文本值
        case "textarea":
          formItem = h("el-input", {
            props: {
              type: "textarea",
              autosize: {
                minRows: 2,
              },
              value: target[info.name],
              clearable: true,
              placeholder: info.help_text,
            },
            attrs: {
              placeholder: info.help_text,
            },
            on: {
              input: (e) => {
                this.$set(target, info.name, e);
              },
            },
          });
          break;

date (日期选择)

  • 可写: el-date-picker type="date"
  • 只读: 格式化显示日期 (YYYY年MM月DD日)
        case "date":
          formItem = h("el-date-picker", {
            props: {
              type: "date",
              "value-format": "yyyy-MM-dd",
              format: "yyyy年MM月dd日",
              value: target[info.name],
              clearable: true,
              editable:false,
              placeholder: info.help_text,
              "picker-options": {
                shortcuts: this.shortcuts,
              },
            },
            attrs: {
              placeholder: info.help_text,
            },
            style: {
              width: "100%",
            },
            on: {
              input: (e) => {
                this.$set(target, info.name, e);
              },
            },
          });
          break;

datetime (日期时间选择)

  • 可写: el-date-picker type="datetime"
  • 只读: 格式化显示日期时间 (YYYY年MM月DD日 HH时mm分)
        case "datetime":
          formItem = h("el-date-picker", {
            props: {
              type: "datetime",
              "value-format": "yyyy-MM-dd HH:mm:ss",
              format: "yyyy-MM-dd HH:mm",
              value: target[info.name],
              clearable: true,
               editable:false,
              placeholder: info.help_text,
              "picker-options": {
                shortcuts: this.shortcuts,
              },
            },
            style: {
              width: "100%",
            },
            attrs: {
              placeholder: info.help_text,
            },
            on: {
              input: (e) => {
                this.$set(target, info.name, e);
              },
            },
          });
          break;

2. 选择类型字段

select/choice (下拉选择)

  • 可写: el-select 组件,支持单选和多选
  • 只读: 显示选项名称(多个用逗号分隔)
        case "choice":
        case "select":
          const getSelectValue = () => {
            if (!!info.multiple) {
              return target[info.name]
                ? target[info.name]
                    .toString()
                    ?.split(/,|\|/)
                    .map((i) => {
                      return ((isNaN(Number(i)) || !i) ? i : Number(i))
                    })
                : [];
            } else {
              return (isNaN(Number(target[info.name])) || !target[info.name])
                ? target[info.name]
                : Number(target[info.name]);
            }
          };
          formItem = h(
            "el-select",
            {
              props: {
                value: getSelectValue(),
                clearable: true,
                placeholder: info.help_text,
                multiple: !!info.multiple,
                // "multiple-limit": info.multiple,
                filterable: true,
                "value-key": "id",
                "reserve-keyword": true,
                "allow-create": !!info.is_select2_tag,
                "default-first-option": true,
              },
              style: {
                width: "100%",
              },
              attrs: {
                placeholder: info.help_text,
              },
              on: {
                input: (e) => {
                  this.$set(target, info.name, e.toString());
                },
              },
            },
            options.map((option) =>
              h("el-option", {
                key: typeof option === "object" ? option.id : option,
                props: {
                  label: typeof option === "object" ? option.name : option,
                  value: typeof option === "object" ? option.id : option,
                },
              })
            )
          );
          break;

radio (单选)

  • 可写: el-radio-group 组件,支持父子级联选择
  • 只读: 用方框+勾号展示所有选项,高亮已选项
  • 存储格式: JSON字符串 {"parent":"父选项","children":["子选项"]}
        case "radio":
          // 支持父 radio + 子 radio单选子项存储为 JSON方案 c
          const { parent: radioParent, child: radioChild } = parseNestedRadioStoredValue(target[info.name]);

          const nestedOptions = options.map((option) => {
            const parsed = parseNestedRadioOption(option);
            const label = typeof option === "object" ? option.name : parsed.label;
            const value = normalizeRadioValue(label);
            const children = typeof option === "string" ? parsed.children : [];
            return { option, label, value, children };
          });

          const onParentChange = (parentValue) => {
            const nextParent = normalizeRadioValue(parentValue);
            const hit = nestedOptions.find((i) => i.value === nextParent);
            if (hit && hit.children && hit.children.length > 0) {
              const keepChild = hit.children.includes(radioChild) ? radioChild : "";
              this.$set(target, info.name, buildNestedRadioStoredValue(nextParent, keepChild));
            } else {
              this.$set(target, info.name, nextParent);
            }
          };

          const onChildChange = (parentValue, childValue) => {
            this.$set(
              target,
              info.name,
              buildNestedRadioStoredValue(parentValue, normalizeRadioValue(childValue))
            );
          };

          formItem = h(
            "el-radio-group",
            {
              props: {
                value: radioParent,
              },
              attrs: {
                placeholder: info.help_text,
              },
              on: {
                input: onParentChange,
              },
            },
            nestedOptions.map((item) => {
              const checked = item.value === radioParent;
              const showChildren = checked && item.children && item.children.length > 0;
              return h(
                "el-radio",
                {
                  key: item.value,
                  props: {
                    label: item.value,
                  },
                },
                [
                  h(
                    "span",
                    {
                      style: {
                        color: "rgb(51, 51, 51)",
                      },
                    },
                    item.label
                  ),
                  showChildren
                    ? h(
                        "span",
                        {
                          style: {
                            marginLeft: "6px",
                            color: "rgb(51, 51, 51)",
                          },
                        },
                        ""
                      )
                    : null,
                  showChildren
                    ? h(
                        "el-radio-group",
                        {
                          style: {
                            display: "inline-flex",
                            alignItems: "center",
                            gap: "10px",
                            marginLeft: "2px",
                            marginRight: "2px",
                          },
                          props: {
                            value: radioChild,
                          },
                          on: {
                            input: (v) => onChildChange(item.value, v),
                          },
                        },
                        item.children.map((c) =>
                          h(
                            "el-radio",
                            {
                              key: `${item.value}__${c}`,
                              props: {
                                label: normalizeRadioValue(c),
                              },
                            },
                            c
                          )
                        )
                      )
                    : null,
                  showChildren
                    ? h(
                        "span",
                        {
                          style: {
                            color: "rgb(51, 51, 51)",
                          },
                        },
                        ""
                      )
                    : null,
                ]
              );
            })
          );
          break;

3. 特殊字段类型

budget-source (预算来源)

  • 可写: 使用 BudgetSourcePickerField 组件
  • 只读: 显示 _display 字段的值
  • 存储: 主字段存储ID_display 字段存储显示文本
        case "budget-source":
          // 可写hidden input + "选取"按钮 + 弹窗(在独立组件内实现)
          formItem = h(BudgetSourcePickerField, {
            props: {
              fieldName: info.name,
              value: target[info.name],
              display: target[`${info.name}_display`],
            },
            on: {
              input: (val) => {
                this.$set(target, info.name, val);
              },
              "update:display": (txt) => {
                // 用于回显(只读/展示)
                this.$set(target, `${info.name}_display`, txt);
              },
            },
          });
          break;

contract-sign (合同签订)

  • 可写: 使用 ContractSignField 组件
  • 只读: 显示 _display 字段的值
  • 功能: 支持合同签订操作
        case "contract-sign":
          // 合同签订字段hidden input + "合同签订"按钮 + 弹窗(在独立组件内实现)
          formItem = h(ContractSignField, {
            props: {
              fieldName: info.name,
              value: target[info.name],
              display: target[`${info.name}_display`],
              flowId: this.$route?.query?.flow_id || "",
            },
            on: {
              input: (val) => {
                this.$set(target, info.name, val);
              },
              "update:display": (txt) => {
                // 用于回显(只读/展示)
                this.$set(target, `${info.name}_display`, txt);
              },
            },
          });
          break;

file (文件上传)

  • 可写: el-upload 组件(主表单)或 vxe-upload(子表单)
  • 只读: 显示文件列表,可点击查看
  • 支持: 单文件/多文件上传

relation-flow (关联流程)

  • 可写: el-select 多选,支持搜索和查看关联流程
  • 只读: 显示关联流程的标题链接列表
  • 功能: 支持加载所有相关流程、搜索流程、查看流程详情
        case "relation-flow":
          if (!this.flows[info.name]) {
            let extraParam = {}
            if (isJSON(info.stub)) {
              extraParam = JSON.parse(info.stub)
            }
            flowList("all", {
              page: 1,
              page_size: 9999,
              is_simple: 1,
              custom_model_id: isJSON(info.stub) ? '' : info.stub,
              // is_auth: 1,
              ids:target[info.name]?target[info.name]:'',
              ...extraParam
            }).then((res) => {
              this.$set(this.flows, info.name, res.data.data);
            });
          }

relation (关联表单/子表单)

  • 可写: vxe-table 表格编辑
  • 只读: 显示子表单数据表格
  • 功能: 支持动态添加/删除子表单行
        case "relation":
        console.log("123",this.form[info.name])
          formItem = h(
            "vxe-table",
            {
              ref: `subForm-${info.name}`,
              style: {
                "margin-top": "10px",
              },
              props: {
                "min-height": "200px",
                border: true,
                stripe: true,
                data:
                  this.form[info.name] &&
                  typeof this.form[info.name] !== "string"
                    ? this.form[info.name]
                    : [],
                "keep-source": true,
                "column-config": { resizable: true },
                "show-overflow": true,
                "edit-rules": info._writeable
                  ? this.subRules[`${info.name}_rules`]
                  : {},
                "edit-config": info._writeable
                  ? {
                      trigger: "click",
                      mode: "row",
                      showStatus: false,
                      isHover: true,
                      autoClear: false,
                    }
                  : {},
              },
              on: {
                "edit-closed": ({ row, column }) => {
                  const $table = this.$refs[`subForm-${info.name}`];
                  if ($table) {
                    this.$set(
                      this.form,
                      info.name,
                      this.$refs[`subForm-${info.name}`].tableData
                    );
                  }
                },
              },
            },
            [
              info._writeable
                ? h(
                    "vxe-column",
                    {
                      props: {
                        width: 56,
                        align: "center",
                      },
                      scopedSlots: {
                        default: ({ row }) => {
                          return h("el-button", {
                            slot: "default",
                            style: {
                              padding: "9px",
                            },
                            props: {
                              type: "danger",
                              size: "small",
                              icon: "el-icon-minus",
                            },
                            on: {
                              click: async (_) => {
                                const $table =
                                  this.$refs[`subForm-${info.name}`];
                                if ($table) {
                                  await $table.remove(row);
                                  this.form[info.name] =
                                    $table.getTableData()?.tableData;
                                }
                              },
                            },
                          });
                        },
                      },
                    },
                    [
                      h("el-button", {
                        slot: "header",
                        style: {
                          padding: "9px",
                        },
                        props: {
                          type: "primary",
                          size: "small",
                          icon: "el-icon-plus",
                        },
                        on: {
                          click: async (_) => {
                            const $table = this.$refs[`subForm-${info.name}`];
                            if ($table) {
                              const record = {};
                              // 确保子表数据为数组,避免空值时调用 unshift 报错
                              if (!Array.isArray(this.form[info.name])) {
                                this.$set(this.form, info.name, []);
                              }
                              this.form[info.name].unshift(record);
                              // 临时数据不好验证长度
                              // const { row: newRow } = await $table.insert(
                              //   record
                              // );
                              await this.$nextTick();
                              await $table.setEditRow(record);
                            }
                          },
                        },
                      }),
                    ]
                  )
                : "",
              ...this.subForm
                .get(info.sub_custom_model_id)
                ?.customModel?.fields?.map((subField, subIndex) =>
                  h("vxe-column", {
                    props: {
                      field: subField.name,
                      title: subField.label,
                      align: "center",
                      "min-width": "180",
                      "edit-render": {},
                    },
                    scopedSlots: {
                      edit: ({ row: myrow }) => {
                        return formBuilder.bind(this)(
                          device,
                          subField,
                          h,
                          myrow,
                          info._writeable,
                          false
                        );
                      },
                      [["file", "choices", "choice", "select", "radio"].indexOf(
                        subField.type
                      ) !== -1
                        ? "default"
                        : false]: ({ row: myrow }) => {
                        return formBuilder.bind(this)(
                          device,
                          subField,
                          h,
                          myrow,
                          false,
                          true
                        );
                      },
                    },
                  })
                ),
            ]
          );
          break;

4. 展示类型字段

label (标签)

  • 仅用于展示文本,不可编辑
        case "label":
          formItem = h(
            "div",
            {
              props: {
                type: "primary",
              },
            },
            info.label
          );
          break;

static (静态链接)

  • 显示为可点击的链接
        case "static":
          formItem = h(
            "el-link",
            {
              props: {
                type: "primary",
              },
              attrs: {
                href: target[info.name],
                target: "_blank",
              },
            },
            info.label
          );
          break;

hr (分隔线)

  • 显示为分隔线
        case "hr":
          formItem = h("el-divider", {}, info.label);
          break;

🔐 字段权限控制

权限类型

  1. 可读字段 (_readable): 字段可见,但不可编辑
  2. 可写字段 (_writeable): 字段可见且可编辑

权限判断逻辑

    const authFields = this.fields.map((field) => ({
      ...field,
      _readable:
        this.readable.indexOf(field.id) !== -1 || field.type === "label",
      _writeable: this.writeable.indexOf(field.id) !== -1,
    }));
  • label 类型字段默认可读
  • 其他字段根据节点配置的 readablewriteable 字段ID列表判断

详情页权限设置

          this.readableFields = /\/detail/.test(this.$route.path)
              ? this.fields?.map((i) => i.id)
              : this.config?.currentNode?.readable || [];
          this.writeableFields = this.config?.currentNode?.writeable || [];
  • 详情页: 所有字段可读,无可写字段
  • 待办页: 根据当前节点的 readablewriteable 配置

✍️ 会签字段显示

会签数据获取

    logs: {
      handler: function (newVal) {
        if (newVal && newVal instanceof Array && newVal.length > 0) {
          this.jointlySignLog = newVal.filter(log => {
            try {
              JSON.parse(log.data)
              return log.is_jointly_sign && /custom_field_id/g.test(log.data)
            } catch (e) {
              return false
            }
          })
        } else {
          this.jointlySignLog = []
        }
      },
      immediate: true
    }

会签字段渲染

    if (formItem) {
      let jointlySignContent = [];
      let isJointly = false;
      this.jointlySignLog.forEach((log) => {
        const data = JSON.parse(log.data);
        Object.entries(data)?.forEach(([key, value]) => {
          if (
            value.hasOwnProperty("custom_field_id") &&
            value["custom_field_id"] === info.id
          ) {
            isJointly = !!log.is_jointly_sign;
            if (log.status > 0 && log.user) {
              // 对于 budget-source 类型字段,优先显示后端返回的 _display 值
              let displayValue = value.value;
              if (info.type === 'budget-source') {
                // 优先使用主表字段的 _display与主表字段展示一致
                const displayFieldName = info.name + '_display';
                const mainDisplayValue = target[displayFieldName] || '';
                if (mainDisplayValue) {
                  displayValue = mainDisplayValue;
                } else if (displayValue) {
                  // 如果没有 _display尝试识别并屏蔽 JSON包括双重编码
                  const strValue = String(displayValue);
                  // 尝试解析 JSON可能是一层或两层编码
                  let isJson = false;
                  try {
                    const parsed1 = JSON.parse(strValue);
                    if (typeof parsed1 === 'object' || Array.isArray(parsed1)) {
                      isJson = true;
                    } else if (typeof parsed1 === 'string') {
                      // 双重编码:第一层解析出来是字符串,再试一次
                      try {
                        const parsed2 = JSON.parse(parsed1);
                        if (typeof parsed2 === 'object' || Array.isArray(parsed2)) {
                          isJson = true;
                        }
                      } catch (e2) {
                        // 不是双重编码的 JSON
                      }
                    }
                  } catch (e) {
                    // 不是 JSON 字符串
                  }
                  // 如果是 JSON包括双重编码不显示
                  if (isJson || strValue.trim().startsWith('{') || strValue.trim().startsWith('[')) {
                    displayValue = ''; // 不显示原始 JSON
                  } else if (typeof displayValue === 'object' || Array.isArray(displayValue)) {
                    displayValue = ''; // 不显示对象或数组
                  }
                }
              }
              jointlySignContent.push(
                h("div", [
                  h("span", displayValue),
                  h("br"),
                  info.is_sign
                    ? log.user?.sign_file?.url
                      ? h("div", {
                          style: {
                            display: "flex",
                            "align-items": "center",
                            gap: "8px",
                          }
                        }, [
                          h("el-image", {
                            style: {
                              "max-height": "80px",
                              "max-width": "100px",
                              display: "block",
                            },
                            props: {
                              src: log.user.sign_file.url,
                              fit: "contain",
                              alt: log.user?.name || "",
                              "preview-src-list": [log.user.sign_file.url],
                              lazy: false,
                            },
                            attrs: {
                              src: log.user.sign_file.url,
                            },
                          }),
                          h(
                            "span",
                            {
                              style: {
                                "font-size": "16px",
                                color: "#000",
                              }
                            },
                            log.updated_at
                              ? this.$moment(log.updated_at).format("YYYY年MM月DD日")
                              : ""
                          ),
                        ])
                      : h("span", log.user?.name || "")
                    : "",
                ])
              );
            }
          }
        });
      });

签名显示逻辑

                  (() => {
                    // 检查 is_sign 是否为真值(支持 1, true, "1" 等)
                    if (info.is_sign && (info.is_sign === 1 || info.is_sign === true || info.is_sign === "1")) {
                      let log = null;
                      
                      // 方法1: 从 jointlySignLog 中查找(检查所有字段)
                      if (this.jointlySignLog && this.jointlySignLog.length > 0) {
                        log = this.jointlySignLog.find((log) => {
                          if (log.status > 0 && log.user && log.user.sign_file && log.user.sign_file.url) {
                            try {
                              const data = JSON.parse(log.data);
                              // 检查 data 中是否有匹配当前字段的条目
                              return Object.values(data).some(
                                (value) =>
                                  value && 
                                  typeof value === 'object' &&
                                  value.custom_field_id === info.id
                              );
                            } catch (e) {
                              return false;
                            }
                          }
                          return false;
                        });
                      }
                      
                      // 方法2: 如果没找到,从 this.logs 中查找必须匹配字段ID和类型
                      if (!log && this.logs && this.logs.length > 0) {
                        log = this.logs.find(
                          (log) =>
                            log.status > 0 &&
                            log.user &&
                            log.user.sign_file &&
                            log.user.sign_file.url &&
                            log.node?.fields?.findIndex(
                              (field) =>
                                field?.field_id === info.id &&
                                field.type === "write"
                            ) !== -1
                        );
                      }
                      
                      // 只有当找到匹配当前字段的log时才显示
                      if (log && log.status > 0 && log.user && log.user.sign_file && log.user.sign_file.url) {
                        return h("div", {
                          style: {
                            "margin-top": "8px",
                            display: "flex",
                            "align-items": "center",
                            gap: "8px",
                          }
                        }, [
                          h("el-image", {
                            style: {
                              "max-height": "80px",
                              "max-width": "100px",
                              display: "block",
                            },
                            props: {
                              src: log.user.sign_file.url,
                              fit: "contain",
                              alt: log.user?.name || "",
                              "preview-src-list": [log.user.sign_file.url],
                              lazy: false,
                            },
                            attrs: {
                              src: log.user.sign_file.url,
                            },
                          }),
                          h(
                            "div",
                            {
                              style: {
                                "font-size": "16px",
                                color: "#000",
                              }
                            },
                            log.updated_at
                              ? this.$moment(log.updated_at).format("YYYY年MM月DD日")
                              : ""
                          ),
                        ]);
                      }
                    }
                    return null;
                  })(),

📐 字段布局

Grid布局系统

字段使用 CSS Grid 布局,通过以下属性控制位置:

  • gs_x: 列起始位置
  • gs_y: 行起始位置
  • gs_width: 列跨度
  • gs_height: 行跨度
              style: {
                // +1为了工作标题往下顺延
                "grid-column-start": info.gs_x + 1,
                "grid-column-end": info.gs_x + 1 + info.gs_width,
                "grid-row-start":
                  info.gs_y + 1 + (info.name === "flow_title" ? 0 : 1),
                "grid-row-end":
                  info.gs_y +
                  1 +
                  (info.name === "flow_title" ? 0 : 1) +
                  info.gs_height,
              },

🔧 自定义脚本支持

脚本注入

    scriptContent(newVal) {
      if (newVal) {
        try {
          // 使用 $nextTick 确保 DOM 已经渲染完成
          this.$nextTick(() => {
            try {
              this.installSafeEventListenerGuard();
              new Function(newVal).bind(this)();
            } catch (err) {
              console.error('脚本执行错误:', err);
              // 不阻止页面正常使用,只记录错误
            }
          });
        } catch (err) {
          console.error('脚本编译错误:', err);
        }
      }
    },
  • 新建/编辑: 使用 js 脚本
  • 详情查看: 使用 view_js 脚本

安全防护

    installSafeEventListenerGuard() {
      if (window.__oa_safe_listener_guard_installed) return;
      window.__oa_safe_listener_guard_installed = true;

      const map = new WeakMap();
      const origAdd = EventTarget && EventTarget.prototype && EventTarget.prototype.addEventListener;
      const origRemove = EventTarget && EventTarget.prototype && EventTarget.prototype.removeEventListener;
      if (!origAdd || !origRemove) return;

      EventTarget.prototype.addEventListener = function (type, listener, options) {
        if (typeof listener === "function") {
          let wrapped = map.get(listener);
          if (!wrapped) {
            wrapped = function (...args) {
              try {
                return listener.apply(this, args);
              } catch (e) {
                console.error("[OA Custom Script] event listener error:", e);
              }
            };
            map.set(listener, wrapped);
          }
          return origAdd.call(this, type, wrapped, options);
        }
        return origAdd.call(this, type, listener, options);
      };

      EventTarget.prototype.removeEventListener = function (type, listener, options) {
        if (typeof listener === "function") {
          const wrapped = map.get(listener);
          return origRemove.call(this, type, wrapped || listener, options);
        }
        return origRemove.call(this, type, listener, options);
      };
    },

📊 数据流

1. 字段配置获取

API调用 (view/preConfig/preDeal)
  ↓
获取 customModel.fields
  ↓
获取子表单配置 (subForm)
  ↓
设置字段权限 (readableFields/writeableFields)

2. 字段渲染

DesktopForm 接收 fields 和权限
  ↓
遍历 fields添加 _readable/_writeable
  ↓
调用 formBuilder 渲染每个字段
  ↓
根据字段类型和权限选择组件

3. 数据绑定

form 对象存储字段值
  ↓
通过 this.$set 实现响应式更新
  ↓
字段值同步到 form[field.name]

📝 特殊处理

1. budget-source 字段

  • 存储主值和显示值分离
  • 主值存储在 field.name
  • 显示值存储在 field.name + '_display'
  • 会签时优先使用 _display 字段

2. radio 父子级联

  • 支持格式:父选项[子选项1,子选项2]
  • 存储格式:{"parent":"父选项","children":["子选项"]}
  • 只读模式下用方框+勾号展示

3. 关联流程字段

  • 支持延迟加载所有流程
  • 支持搜索和过滤
  • 每个选项可跳转到详情页查看

4. 文件上传

  • 主表单使用 el-upload
  • 子表单使用 vxe-upload
  • 支持多文件上传最多20个
  • 文件大小限制由 uploadSize 配置

🎯 总结

流程详情页面的自定义字段渲染系统具有以下特点:

  1. 类型丰富: 支持15+种字段类型,覆盖常用表单需求
  2. 权限精细: 支持字段级别的读写权限控制
  3. 布局灵活: 基于Grid布局支持任意字段位置和大小
  4. 功能强大: 支持会签、签名、关联等复杂场景
  5. 扩展性好: 支持自定义脚本扩展功能

该系统通过组件化和配置化的方式,实现了高度灵活的表单字段渲染机制。