- 新增 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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user