zhangwencui
9 天以前 dab35bff798cfce2ddf7e8a67e4e1cdb144f5b4c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
/**
 * 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;
}