# 流程详情页面自定义字段渲染整理 ## 📋 概述 本文档整理了 `/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`) ```javascript // 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`) ```364:385:cz-hjjc-oa/src/views/flow/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`) ```90:114:cz-hjjc-oa/src/utils/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` 组件 - **只读**: 显示文本值 ```116:132:cz-hjjc-oa/src/utils/formBuilder.js 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" - **只读**: 显示文本值 ```133:153:cz-hjjc-oa/src/utils/formBuilder.js 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日) ```154:180:cz-hjjc-oa/src/utils/formBuilder.js 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分) ```181:207:cz-hjjc-oa/src/utils/formBuilder.js 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` 组件,支持单选和多选 - **只读**: 显示选项名称(多个用逗号分隔) ```208:263:cz-hjjc-oa/src/utils/formBuilder.js 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":["子选项"]}` ```264:388:cz-hjjc-oa/src/utils/formBuilder.js 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` 字段存储显示文本 ```389:407:cz-hjjc-oa/src/utils/formBuilder.js 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` 字段的值 - **功能**: 支持合同签订操作 ```408:427:cz-hjjc-oa/src/utils/formBuilder.js 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` 多选,支持搜索和查看关联流程 - **只读**: 显示关联流程的标题链接列表 - **功能**: 支持加载所有相关流程、搜索流程、查看流程详情 ```648:857:cz-hjjc-oa/src/utils/formBuilder.js 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` 表格编辑 - **只读**: 显示子表单数据表格 - **功能**: 支持动态添加/删除子表单行 ```858:1016:cz-hjjc-oa/src/utils/formBuilder.js 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 (标签) - 仅用于展示文本,不可编辑 ```619:629:cz-hjjc-oa/src/utils/formBuilder.js case "label": formItem = h( "div", { props: { type: "primary", }, }, info.label ); break; ``` #### static (静态链接) - 显示为可点击的链接 ```630:644:cz-hjjc-oa/src/utils/formBuilder.js case "static": formItem = h( "el-link", { props: { type: "primary", }, attrs: { href: target[info.name], target: "_blank", }, }, info.label ); break; ``` #### hr (分隔线) - 显示为分隔线 ```645:647:cz-hjjc-oa/src/utils/formBuilder.js case "hr": formItem = h("el-divider", {}, info.label); break; ``` ## 🔐 字段权限控制 ### 权限类型 1. **可读字段** (`_readable`): 字段可见,但不可编辑 2. **可写字段** (`_writeable`): 字段可见且可编辑 ### 权限判断逻辑 ```364:370:cz-hjjc-oa/src/views/flow/DesktopForm.vue 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` 类型字段默认可读 - 其他字段根据节点配置的 `readable` 和 `writeable` 字段ID列表判断 ### 详情页权限设置 ```576:579:cz-hjjc-oa/src/views/flow/detailCommon.vue this.readableFields = /\/detail/.test(this.$route.path) ? this.fields?.map((i) => i.id) : this.config?.currentNode?.readable || []; this.writeableFields = this.config?.currentNode?.writeable || []; ``` - **详情页**: 所有字段可读,无可写字段 - **待办页**: 根据当前节点的 `readable` 和 `writeable` 配置 ## ✍️ 会签字段显示 ### 会签数据获取 ```346:362:cz-hjjc-oa/src/views/flow/DesktopForm.vue 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 } ``` ### 会签字段渲染 ```1435:1536:cz-hjjc-oa/src/utils/formBuilder.js 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 || "") : "", ]) ); } } }); }); ``` ### 签名显示逻辑 ```1587:1672:cz-hjjc-oa/src/utils/formBuilder.js (() => { // 检查 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`: 行跨度 ```1551:1562:cz-hjjc-oa/src/utils/formBuilder.js 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, }, ``` ## 🔧 自定义脚本支持 ### 脚本注入 ```321:338:cz-hjjc-oa/src/views/flow/DesktopForm.vue 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` 脚本 ### 安全防护 ```234:268:cz-hjjc-oa/src/views/flow/DesktopForm.vue 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. **扩展性好**: 支持自定义脚本扩展功能 该系统通过组件化和配置化的方式,实现了高度灵活的表单字段渲染机制。