/**
|
* BPM 动态表单解析器
|
*
|
* web 端用 @form-create/ant-design-vue 渲染流程表单,其结构为:
|
* formConf : JSON 字符串(表单全局配置,移动端忽略)
|
* formFields : string[](**数组,每个元素是一条表单组件规则的 JSON 字符串**)
|
* 单条规则形如 { type, field, title, $required?, props?, children? }
|
*
|
* 小程序无法运行 form-create(依赖 antd + DOM),因此这里把 formFields
|
* 解析为扁平的字段描述,交给 app 端自绘渲染。
|
*
|
* 关键约束:**只影响渲染方式,不影响接口传参**。
|
* 提交时 variables 的键名与取值完全来自 field,与 web 端逐字一致。
|
*/
|
|
/** 解析后的字段类型(app 端可渲染的归一化类型) */
|
export type FormFieldKind =
|
| "input"
|
| "textarea"
|
| "number"
|
| "select"
|
| "radio"
|
| "checkbox"
|
| "switch"
|
| "date"
|
| "datetime"
|
| "time"
|
| "rate"
|
| "slider"
|
| "user"
|
| "dept"
|
| "dict"
|
| "area"
|
| "image"
|
| "file"
|
| "unsupported";
|
|
export interface FormFieldOption {
|
label: string;
|
value: number | string;
|
}
|
|
export interface FormField {
|
/** 归一化类型 */
|
kind: FormFieldKind;
|
/** 原始 form-create type,保留用于调试 */
|
rawType: string;
|
/** 表单字段名(提交时 variables 的 key) */
|
field: string;
|
/** 中文标题 */
|
title: string;
|
/** 是否必填 */
|
required: boolean;
|
/** 占位提示 */
|
placeholder: string;
|
/** 静态选项(select/radio/checkbox 用) */
|
options: FormFieldOption[];
|
/** 字典 type(kind === 'dict') */
|
dictType?: string;
|
/** 接口地址(kind === 'user' | 'dept' | 'dict' 且由 api 驱动时) */
|
api?: string;
|
/** 数字约束 */
|
min?: number;
|
max?: number;
|
precision?: number;
|
/** 多选 */
|
multiple: boolean;
|
/** 默认值 */
|
defaultValue?: any;
|
/** 备注说明 */
|
info?: string;
|
}
|
|
/**
|
* 选择类字段的兜底占位文案。
|
* 注意:web 端设计器给的 placeholder 常形如「请输入部门选择器」(因为它把
|
* 组件标题当成了文案),移动端直接照搬会很奇怪。这里在「无 placeholder,
|
* 或 placeholder 等于 `请输入${title}` / `请选择${title}`」时统一改写为
|
* 「请选择${label}」,其中 label 已由 normalizeTitle 去掉「选择器」后缀。
|
*/
|
function resolvePlaceholder(
|
kind: FormFieldKind,
|
label: string,
|
raw: string | undefined,
|
rawTitle: string,
|
) {
|
const text = typeof raw === "string" ? raw.trim() : "";
|
// web 设计器自动生成的兜底文案(各种形态都算)
|
const isFallbackLike =
|
!text || text === `请输入${rawTitle}` || text === `请选择${rawTitle}`;
|
if (kind === "input" || kind === "textarea" || kind === "number") {
|
return text || `请输入${label}`;
|
}
|
if (isSelectLike(kind)) {
|
// 兜底文案,或文案里仍带「选择器」后缀(如「请选择部门选择器」)时统一改写
|
if (isFallbackLike || /选择器$/.test(text)) return `请选择${label}`;
|
return text;
|
}
|
return text || `请输入${label}`;
|
}
|
|
/**
|
* 清洗字段标题。
|
*
|
* web 端设计器里,选择类组件的 title 常被写成「部门选择器」「用户选择器」
|
* 这类带组件后缀的名字。移动端展示时把后缀去掉更像业务字段名
|
* (「部门选择器」→「部门」)。仅在选择类字段上生效,且只在标题确实以
|
* 「选择器」结尾时才裁剪,避免误伤「设备选择器编号」这种真实字段名。
|
*/
|
function normalizeTitle(kind: FormFieldKind, rawTitle: string) {
|
const title = String(rawTitle ?? "").trim();
|
if (!title) return title;
|
if (!isSelectLike(kind)) return title;
|
// 仅当「选择器」在结尾,且去掉后仍有内容时才裁剪
|
if (title.endsWith("选择器")) {
|
const trimmed = title.slice(0, -3).trim();
|
if (trimmed) return trimmed;
|
}
|
return title;
|
}
|
|
/**
|
* 是否为「选择类」字段(需要下拉/弹层选择,右侧展示箭头,占位用「请选择」)。
|
* 与 web 端表单里这些组件的交互一致。
|
*/
|
function isSelectLike(kind: FormFieldKind) {
|
return (
|
kind === "select" ||
|
kind === "radio" ||
|
kind === "checkbox" ||
|
kind === "date" ||
|
kind === "datetime" ||
|
kind === "time" ||
|
kind === "user" ||
|
kind === "dept" ||
|
kind === "dict" ||
|
kind === "area"
|
);
|
}
|
|
/** form-create 单条规则的最小结构 */
|
interface RawRule {
|
type?: string;
|
field?: string;
|
title?: string;
|
value?: any;
|
info?: string;
|
$required?: boolean | string;
|
options?: Array<{ label?: string; value?: any }>;
|
props?: Record<string, any>;
|
children?: RawRule[];
|
}
|
|
/**
|
* 安全解析 JSON(web 端用 formCreate.parseJson,此处等价实现)
|
* 容错:字符串可能带函数体(如 parseFunc),无法 JSON.parse 时返回 undefined
|
*/
|
function parseJsonSafe<T = any>(text?: string | null): T | undefined {
|
if (!text) return undefined;
|
try {
|
return JSON.parse(text) as T;
|
} catch {
|
return undefined;
|
}
|
}
|
|
/** 把 formFields 字符串数组解析为规则对象数组 */
|
export function decodeFields(formFields?: string[] | null): RawRule[] {
|
if (!Array.isArray(formFields)) return [];
|
const rules: RawRule[] = [];
|
formFields.forEach(item => {
|
const parsed = parseJsonSafe<RawRule>(item);
|
if (parsed && typeof parsed === "object") rules.push(parsed);
|
});
|
return rules;
|
}
|
|
/**
|
* 原始 type -> 归一化类型
|
*
|
* 注意:扩展组件在 web 端注册的是**大驼峰**名(见 components/form-create/rules/*.ts
|
* 的 `const name = 'UserSelect'` 与 components/dept-select.vue 的 defineOptions)。
|
* 曾误按小驼峰(userSelect)匹配,导致字段全部落入 unsupported 被过滤掉,
|
* 表现为发起流程页“暂无表单字段”。
|
*/
|
function normalizeKind(rawType: string): FormFieldKind {
|
switch (rawType) {
|
case "input":
|
return "input";
|
case "textarea":
|
return "textarea";
|
// 富文本:注册名是 Tinymce,小程序无对应组件,降级为多行文本
|
case "Tinymce":
|
case "editor":
|
return "textarea";
|
case "inputNumber":
|
case "number":
|
return "number";
|
case "select":
|
return "select";
|
// 接口选择器(web helpers.ts 里由 useSelectRule 注册,name = 'ApiSelect')
|
case "ApiSelect":
|
case "apiSelect":
|
return "select";
|
case "radio":
|
return "radio";
|
case "checkbox":
|
return "checkbox";
|
case "switch":
|
return "switch";
|
case "date":
|
case "datePicker":
|
return "date";
|
case "datetime":
|
case "dateTimePicker":
|
return "datetime";
|
case "time":
|
case "timePicker":
|
return "time";
|
case "rate":
|
return "rate";
|
case "slider":
|
return "slider";
|
case "UserSelect":
|
return "user";
|
case "DeptSelect":
|
return "dept";
|
case "DictSelect":
|
return "dict";
|
case "AreaSelect":
|
return "area";
|
case "ImageUpload":
|
case "ImagesUpload":
|
return "image";
|
case "FileUpload":
|
return "file";
|
// 内嵌页在移动端无意义,按不支持过滤
|
case "IframeComponent":
|
return "unsupported";
|
default:
|
return "unsupported";
|
}
|
}
|
|
/**
|
* 从 props 里提取静态选项。
|
* form-create 的选项可能放在 options、props.options 或 props.data 里。
|
*/
|
function extractOptions(rule: RawRule): FormFieldOption[] {
|
const candidates = [rule.options, rule.props?.options, rule.props?.data];
|
for (const list of candidates) {
|
if (Array.isArray(list) && list.length) {
|
return list
|
.map(item => {
|
if (item === null || item === undefined) return undefined;
|
if (typeof item === "object") {
|
const label = item.label ?? item.name ?? item.text ?? String(item.value ?? "");
|
const value = item.value ?? item.id ?? item.key;
|
if (value === undefined) return undefined;
|
return { label: String(label), value };
|
}
|
return { label: String(item), value: item as any };
|
})
|
.filter((item): item is FormFieldOption => Boolean(item));
|
}
|
}
|
return [];
|
}
|
|
/** 从 rule 或 props 里推导字典 type */
|
function extractDictType(rule: RawRule): string | undefined {
|
return (
|
rule.props?.dictType ??
|
rule.props?.type ??
|
rule.props?.dict ??
|
// 部分设计器把字典 type 放在 props.props 里
|
rule.props?.props?.dictType
|
);
|
}
|
|
/**
|
* 解析 formFields 为一维字段列表。
|
* 子表单(children)暂平铺到同一层,保证 field 键名不丢。
|
*/
|
export function parseFormFields(formFields?: string[] | null): FormField[] {
|
const rules = decodeFields(formFields);
|
const fields: FormField[] = [];
|
|
const walk = (list: RawRule[]) => {
|
list.forEach(rule => {
|
// 纯布局容器(无 field)直接下沉其 children
|
if (!rule.field) {
|
if (Array.isArray(rule.children) && rule.children.length) walk(rule.children);
|
return;
|
}
|
const rawType = String(rule.type ?? "input");
|
const kind = normalizeKind(rawType);
|
// 开关、隐藏域等不参与提交流程的字段类型过滤
|
if (kind === "unsupported") return;
|
|
const rawTitle = rule.title ?? rule.field;
|
// 展示用标题(选择类字段会去掉「选择器」后缀)
|
const title = normalizeTitle(kind, rawTitle);
|
|
fields.push({
|
kind,
|
rawType,
|
field: rule.field,
|
title,
|
required: Boolean(rule.$required),
|
placeholder: resolvePlaceholder(kind, title, rule.props?.placeholder, rawTitle),
|
options: extractOptions(rule),
|
dictType: extractDictType(rule),
|
api: rule.props?.url ?? rule.props?.api,
|
min: rule.props?.min,
|
max: rule.props?.max,
|
precision: rule.props?.precision,
|
multiple: Boolean(rule.props?.multiple),
|
defaultValue: rule.value,
|
info: rule.info,
|
});
|
|
if (Array.isArray(rule.children) && rule.children.length) walk(rule.children);
|
});
|
};
|
|
walk(rules);
|
return fields;
|
}
|
|
/**
|
* 构造表单初始值:以字段默认值打底,再用流程实例已有的 formVariables 覆盖。
|
* 对应 web 端 `detailForm.value = processInstance.formVariables`
|
*/
|
export function buildFormValue(fields: FormField[], variables?: Record<string, any>) {
|
const value: Record<string, any> = {};
|
fields.forEach(f => {
|
if (f.defaultValue !== undefined) value[f.field] = f.defaultValue;
|
else if (f.kind === "checkbox") value[f.field] = [];
|
else if (f.kind === "switch") value[f.field] = false;
|
else value[f.field] = "";
|
});
|
if (variables) {
|
Object.keys(variables).forEach(key => {
|
// 只接收表单声明的字段,避免把流程内部变量混进来
|
if (fields.some(f => f.field === key)) value[key] = variables[key];
|
});
|
}
|
return value;
|
}
|
|
/** 单字段校验,返回错误文案(无错返回 "") */
|
export function validateField(field: FormField, raw: any): string {
|
const value = raw;
|
const empty =
|
value === undefined ||
|
value === null ||
|
value === "" ||
|
(Array.isArray(value) && value.length === 0);
|
|
if (field.required && empty) {
|
return field.kind === "image" || field.kind === "file"
|
? `请上传${field.title}`
|
: `请填写${field.title}`;
|
}
|
if (empty) return "";
|
|
if (field.kind === "number") {
|
const num = Number(value);
|
if (Number.isNaN(num)) return `${field.title}必须是数字`;
|
if (field.min !== undefined && num < field.min) return `${field.title}不能小于${field.min}`;
|
if (field.max !== undefined && num > field.max) return `${field.title}不能大于${field.max}`;
|
}
|
return "";
|
}
|
|
/** 校验整个表单,返回首个错误文案 */
|
export function validateForm(fields: FormField[], value: Record<string, any>): string {
|
for (const field of fields) {
|
const error = validateField(field, value[field.field]);
|
if (error) return error;
|
}
|
return "";
|
}
|
|
/**
|
* 从表单值中裁出可写字段(对应 web 端 getUpdatedProcessInstanceVariables 的语义)。
|
* 审批通过时只提交 writableFields,避免覆盖不该改的流程变量。
|
*/
|
export function pickWritableVariables(
|
fields: FormField[],
|
value: Record<string, any>,
|
formFieldsPermission?: Record<string, string>,
|
) {
|
const variables: Record<string, any> = {};
|
fields.forEach(field => {
|
const permission = formFieldsPermission?.[field.field];
|
// 无权限信息时按可写处理;READ('1') 与 NONE('3') 不提交
|
if (permission === "1" || permission === "3") return;
|
if (value[field.field] !== undefined) variables[field.field] = value[field.field];
|
});
|
return variables;
|
}
|