feat(apply): 办件详情「申请内容」按表单 schema 渲染——中文 label + 分组
build / build (push) Successful in 2m11s

- 新增 utils/formDataGroups.ts:groups 顺序展示、schema 外字段归「其他信息」、无 schema 回落按 key
- ApplyDetail 改用详情出参 formSchemaJson(删除再调 /api/apply/item 取 schema 的旁路);ApplyVo 类型补 formSchemaJson
- 顺带入库 hall 动态表单修复(就地写 reactive + 内联 validate,2026-09-17 会话)
This commit is contained in:
chenxing
2026-09-17 14:51:04 +08:00
parent f87523c524
commit 05b6c78a93
6 changed files with 273 additions and 36 deletions
+130 -5
View File
@@ -2,15 +2,19 @@
/**
* 动态表单渲染器(V2.0 Part B.3:schema → 控件,一期 11 类)。
* schemaJson = {"groups":[{title, fields:[{key,label,type,required,pattern,options,...}]}]}。
* v-model(formData) 为 {key:value} 对象;upload/image 字段值为 /api/file/upload 返回体数组
* [{id,fileName,fileUrl,fileSize,mimeType}],多选类(checkbox/multiselect)为 string[]。
* formData 为父组件传入的共享 reactive 对象,本组件**就地改写其属性**(不整体替换):
* 此前 emit 新对象 + 父组件 `v-model:form-data` 整体替换,会因 props 冒泡滞后出现
* 「后一次提交基于旧快照合并、把前一次刚填的值覆盖掉」,表现为必填项填了也报未填。
* upload/image 字段值为 /api/file/upload 返回体数组 [{id,fileName,fileUrl,...}],
* 多选类(checkbox/multiselect)为 string[]。
* validate() 暴露给发起页提交前调用:规则与后端 FormValidator.validateSubmit 同口径,
* 失败时字段下方内联红字(不再弹全局 Message)、并把第一个错误滚到视口内。
*/
import { computed } from "vue";
import { computed, reactive, watch } from "vue";
import { Message } from "view-ui-plus";
import { fileApi } from "@/api/module/svc";
const props = defineProps<{ schemaJson?: string; formData: Record<string, any> }>();
const emit = defineEmits<{ (e: "update:formData", v: Record<string, any>): void }>();
interface Field {
key: string;
@@ -40,8 +44,13 @@ const groups = computed<{ title: string; fields: Field[] }[]>(() => {
}
});
/** 字段内联校验错误(key → 提示),只在提交校验与后续修改时维护 */
const errors = reactive<Record<string, string>>({});
function setValue(field: Field, value: any) {
emit("update:formData", { ...props.formData, [field.key]: value });
// 就地改写共享 reactive 对象;替换引用会让父组件绑定与子组件 props 脱钩
props.formData[field.key] = value;
delete errors[field.key];
}
function ensureArray(field: Field): any[] {
@@ -72,6 +81,114 @@ async function uploadFile(field: Field, ev: Event) {
Message.error(e?.message || "上传失败");
}
}
/** 已填且非空(与后端 FormValidator 的空判定同口径) */
function isEmpty(v: any): boolean {
return (
v === undefined ||
v === null ||
(typeof v === "string" && v.trim().length === 0) ||
(Array.isArray(v) && v.length === 0)
);
}
/**
* 提交校验:与后端 FormValidator.validateSubmit 同口径(required / number min/max /
* select·radio·checkbox·multiselect 选项成员 / maxLength / pattern / upload maxFiles)。
* 通过返回 true;失败把错误写到 errors 并滚动到第一个错误,返回 false(不弹 Message)。
*/
function validate(): boolean {
Object.keys(errors).forEach(k => delete errors[k]);
let firstCoveredField: Field | null = null;
let firstMsg = "";
for (const group of groups.value) {
for (const field of group.fields) {
// defaultValue 只影响展示(渲染时兜底),后端不见它——为前后端一致,提交校验
// 认可 defaultValue 的字段视为已填(缺省值场景不发空跑后端)
const value = props.formData[field.key] ?? field.defaultValue;
const msg = checkField(field, value);
if (msg) {
errors[field.key] = msg;
if (!firstMsg) {
firstMsg = msg;
firstCoveredField = field;
}
}
}
}
if (firstMsg) {
const el = document.querySelector<HTMLElement>(".dyn-error");
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
}
return false;
}
return true;
}
function checkField(field: Field, value: any): string {
const label = field.label || field.key;
if (isEmpty(value)) {
return field.required ? `请填写${label}` : "";
}
switch (field.type) {
case "number": {
if (typeof value !== "number" || Number.isNaN(value)) return `${label}必须是数字`;
if (field.min !== undefined && value < field.min) return `${label}不得小于 ${field.min}`;
if (field.max !== undefined && value > field.max) return `${label}不得大于 ${field.max}`;
break;
}
case "select":
case "radio": {
const v = String(value);
if (!(field.options || []).includes(v)) return `${label}选项值不在候选列表内`;
break;
}
case "checkbox":
case "multiselect": {
const list: any[] = Array.isArray(value) ? value : [];
for (const v of list) {
if (!(field.options || []).includes(String(v))) return `${label}选项值不在候选列表内`;
}
break;
}
case "upload":
case "image":
case "phone":
case "text":
case "textarea":
case "date":
default: {
const v = String(value ?? "");
if (field.maxLength !== undefined && v.length > field.maxLength) {
return `${label}长度超过上限 ${field.maxLength}`;
}
if (field.pattern && !new RegExp(field.pattern).test(v)) {
return `${label}格式不正确`;
}
break;
}
}
return "";
}
defineExpose({ validate });
/** schema 变化时把 defaultValue 预填进 formData(仅一次、仅未填字段,保证提交与展示一致) */
watch(
() => props.schemaJson,
() => {
for (const group of groups.value) {
for (const field of group.fields) {
if (field.defaultValue !== undefined && field.defaultValue !== null
&& props.formData[field.key] === undefined) {
setValue(field, field.defaultValue);
}
}
}
},
{ immediate: true }
);
</script>
<template>
@@ -201,6 +318,9 @@ async function uploadFile(field: Field, ev: Event) {
</template>
<div v-if="field.tip" class="dyn-tip">{{ field.tip }}</div>
<div v-if="errors[field.key]" class="dyn-error">
<span class="dyn-required">*</span>{{ errors[field.key] }}
</div>
</div>
</div>
</div>
@@ -233,6 +353,11 @@ async function uploadFile(field: Field, ev: Event) {
color: #999;
margin-top: 4px;
}
.dyn-error {
font-size: 12px;
color: #ed4014;
margin-top: 4px;
}
.dyn-file {
display: flex;
gap: 8px;