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

1161 lines
37 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 流程详情页面自定义字段渲染整理
## 📋 概述
本文档整理了 `/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. **扩展性好**: 支持自定义脚本扩展功能
该系统通过组件化和配置化的方式,实现了高度灵活的表单字段渲染机制。