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.
37 KiB
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-inputtype="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-pickertype="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-pickertype="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;
🔐 字段权限控制
权限类型
- 可读字段 (
_readable): 字段可见,但不可编辑 - 可写字段 (
_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类型字段默认可读- 其他字段根据节点配置的
readable和writeable字段ID列表判断
详情页权限设置
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配置
✍️ 会签字段显示
会签数据获取
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配置
🎯 总结
流程详情页面的自定义字段渲染系统具有以下特点:
- 类型丰富: 支持15+种字段类型,覆盖常用表单需求
- 权限精细: 支持字段级别的读写权限控制
- 布局灵活: 基于Grid布局,支持任意字段位置和大小
- 功能强大: 支持会签、签名、关联等复杂场景
- 扩展性好: 支持自定义脚本扩展功能
该系统通过组件化和配置化的方式,实现了高度灵活的表单字段渲染机制。