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