- 新增 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;
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface ApplyVo {
|
||||
itemSnapshotJson?: string;
|
||||
formVersion?: number;
|
||||
formDataJson?: string;
|
||||
/** 办理表单 schema(仅详情出参;「申请内容」按 groups/fields 显示中文 label 与分组的唯一事实源) */
|
||||
formSchemaJson?: string;
|
||||
attachmentsJson?: string;
|
||||
submitterId: number;
|
||||
submitterName: string;
|
||||
@@ -41,6 +43,22 @@ export interface ApplyVo {
|
||||
}
|
||||
|
||||
|
||||
/** 表单附件字段行(对齐后端 ApplyAttachmentItem;attachmentId=上传接口返回的附件行 id) */
|
||||
export interface ApplyAttachmentItem {
|
||||
fieldKey?: string;
|
||||
attachmentId: number;
|
||||
}
|
||||
|
||||
/** 发起提交入参(对齐后端 ApplySubmitRequest) */
|
||||
export interface ApplySubmitRequest {
|
||||
siteCode: string;
|
||||
itemCode: string;
|
||||
/** 表单数据 JSON 字符串({key:value};无表单事项传空) */
|
||||
formData?: string;
|
||||
/** 附件字段上传的附件行(可空;缺失会导致后端 bindApply 不执行、办件材料无效) */
|
||||
attachments?: ApplyAttachmentItem[];
|
||||
}
|
||||
|
||||
/** 事项大厅列表行(后端复用 oapi ItemRow,见 HallController) */
|
||||
export interface HallItem {
|
||||
id: number;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 「申请内容」展示整形(2026-09-17):办件的 formDataJson 以英文 key 存储,
|
||||
* 展示层以办理表单 schemaJson(详情出参 formSchemaJson,版本=提交时 formVersion)为
|
||||
* key→中文 label 与分组的唯一事实源:
|
||||
* groups[{title, fields[{key,label}]}] 顺序即展示顺序,只保留有填报值的字段;
|
||||
* schema 里没有而数据里有的 key(历史数据/字段下架)归入「其他信息」兜底组;
|
||||
* 无 schema 时整体回落按 key 展示(老数据不报错)。
|
||||
*/
|
||||
export interface FormDataRow {
|
||||
key: string;
|
||||
label: string;
|
||||
value: any;
|
||||
/** 字段类型(附件/图片数组值渲染为可点链接,与发起页上传返回体同构 {fileName,fileUrl}) */
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface FormDataGroup {
|
||||
title: string;
|
||||
rows: FormDataRow[];
|
||||
}
|
||||
|
||||
function safeParse(json?: string): any {
|
||||
try {
|
||||
return JSON.parse(json || "{}") ?? {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function buildFormDataGroups(schemaJson?: string, formDataJson?: string): FormDataGroup[] {
|
||||
const data = safeParse(formDataJson);
|
||||
if (typeof data !== "object" || Array.isArray(data) || data === null) return [];
|
||||
const schema = safeParse(schemaJson);
|
||||
const groups: any[] = Array.isArray(schema.groups) ? schema.groups : [];
|
||||
const used = new Set<string>();
|
||||
const out: FormDataGroup[] = [];
|
||||
groups.forEach((g, i) => {
|
||||
const rows: FormDataRow[] = [];
|
||||
for (const f of Array.isArray(g.fields) ? g.fields : []) {
|
||||
const key = f?.key;
|
||||
if (!key || !(key in data)) continue;
|
||||
used.add(key);
|
||||
rows.push({ key, label: f.label || key, value: data[key], type: f.type });
|
||||
}
|
||||
if (rows.length) {
|
||||
out.push({ title: g.title || (groups.length > 1 ? `第 ${i + 1} 部分` : "申请内容"), rows });
|
||||
}
|
||||
});
|
||||
const rest = Object.keys(data)
|
||||
.filter((k) => !used.has(k))
|
||||
.map((key) => ({ key, label: key, value: data[key] }));
|
||||
if (rest.length) out.push({ title: "其他信息", rows: rest });
|
||||
if (!out.length) out.push({ title: "申请内容", rows: Object.keys(data).map((key) => ({ key, label: key, value: data[key] })) });
|
||||
return out;
|
||||
}
|
||||
+31
-2
@@ -14,9 +14,9 @@ import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { applyApi } from "@/api/module/svc";
|
||||
import type { ApplyAttachmentItem } from "@/types/svc";
|
||||
import { useSite } from "@/utils/site";
|
||||
import DynamicForm from "@/components/DynamicForm.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { resolveSite, rememberItem } = useSite();
|
||||
@@ -31,6 +31,7 @@ const item = ref<Record<string, any> | null>(null);
|
||||
const loadError = ref("");
|
||||
const siteCode = ref("");
|
||||
const formData = reactive<Record<string, any>>({});
|
||||
const dynFormRef = ref<InstanceType<typeof DynamicForm> | null>(null); // DynamicForm.validate()
|
||||
|
||||
const schemaJson = computed(() => (item.value?.schemaJson as string) || "");
|
||||
|
||||
@@ -56,6 +57,30 @@ async function load() {
|
||||
|
||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* 从 formData 提取附件行(upload/image 字段值为 /api/file/upload 返回体数组,
|
||||
* 每项含附件行 id):后端 submit 依据 attachments 做办件材料绑定(bindApply),
|
||||
* 不传则附件行永远未落 topic,表现为「附件传了但提交后无效」。
|
||||
*/
|
||||
function collectAttachments(): ApplyAttachmentItem[] {
|
||||
const out: ApplyAttachmentItem[] = [];
|
||||
try {
|
||||
const groups = JSON.parse(schemaJson.value || "{}").groups || [];
|
||||
for (const g of groups) {
|
||||
for (const f of g.fields || []) {
|
||||
if (f.type !== "upload" && f.type !== "image") continue;
|
||||
const list = formData[f.key];
|
||||
for (const it of Array.isArray(list) ? list : []) {
|
||||
if (it && it.id != null) out.push({ fieldKey: f.key, attachmentId: it.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// schema 解析失败时只丢附件绑定,不阻断提交
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 轮询办件状态直到离开 PENDING;超时返回最后一次看到的办件(可能仍是待提交) */
|
||||
async function awaitFlowStart(applyId: number, first: any) {
|
||||
let latest = first;
|
||||
@@ -75,6 +100,9 @@ async function awaitFlowStart(applyId: number, first: any) {
|
||||
|
||||
async function submit() {
|
||||
if (!item.value) return;
|
||||
// 前端先按 schema 校验(字段下方内联红字 + 滚到第一个错误),不再把"必填项未填写"
|
||||
// 这类校验留给后端弹 Message——后端强校验仍保留作兜底
|
||||
if (dynFormRef.value && !dynFormRef.value.validate()) return;
|
||||
submitting.value = true;
|
||||
try {
|
||||
const apply: any = await applyApi
|
||||
@@ -82,6 +110,7 @@ async function submit() {
|
||||
siteCode: siteCode.value,
|
||||
itemCode: String(route.query.itemCode),
|
||||
formData: Object.keys(formData).length ? JSON.stringify(formData) : undefined,
|
||||
attachments: collectAttachments(),
|
||||
})
|
||||
.send();
|
||||
|
||||
@@ -121,7 +150,7 @@ onMounted(load);
|
||||
</div>
|
||||
</div>
|
||||
<Divider v-if="schemaJson" orientation="left">申请表单</Divider>
|
||||
<DynamicForm v-if="schemaJson" :schema-json="schemaJson" v-model:form-data="formData" />
|
||||
<DynamicForm v-if="schemaJson" ref="dynFormRef" :schema-json="schemaJson" :form-data="formData" />
|
||||
<div v-else class="no-form">该事项无需填写表单,提交后由办理部门联系确认。</div>
|
||||
<div class="actions">
|
||||
<Button class="sh-btn-primary" type="primary" :loading="submitting" @click="submit">
|
||||
|
||||
+38
-28
@@ -9,6 +9,7 @@ import { useRoute, useRouter } from "vue-router";
|
||||
import { Message, Modal } from "view-ui-plus";
|
||||
import { applyApi } from "@/api/module/svc";
|
||||
import type { ApplyVo, FlowProgressVo } from "@/types/svc";
|
||||
import { buildFormDataGroups, FormDataGroup } from "@/utils/formDataGroups";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -35,7 +36,6 @@ const SNAPSHOT_LABEL: Record<string, string> = {
|
||||
};
|
||||
|
||||
const snapshot = computed<Record<string, any>>(() => parse(apply.value?.itemSnapshotJson));
|
||||
const formData = computed<Record<string, any>>(() => parse(apply.value?.formDataJson));
|
||||
const result = computed<Record<string, any>>(() => parse(apply.value?.flowResultJson));
|
||||
|
||||
function parse(json?: string): Record<string, any> {
|
||||
@@ -46,14 +46,24 @@ function parse(json?: string): Record<string, any> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 字段 key → 表单里的中文标签(schema 里的 label 优先,缺失回落 key 名) */
|
||||
const fieldLabels = ref<Record<string, string>>({});
|
||||
/** 附件/图片字段值 = /api/file/upload 返回体数组({fileName,fileUrl}),渲染为可点链接 */
|
||||
function fileList(v: any): { fileName: string; fileUrl: string }[] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.filter((it: any) => it && typeof it === "object" && it.fileUrl);
|
||||
}
|
||||
|
||||
function isUploadRow(row: { type?: string }): boolean {
|
||||
return row.type === "upload" || row.type === "image";
|
||||
}
|
||||
|
||||
/** 申请内容:按办理表单 schema 显示中文 label 与分组(详情出参 formSchemaJson,版本=提交时快照) */
|
||||
const contentGroups = ref<FormDataGroup[]>([]);
|
||||
|
||||
async function load() {
|
||||
const id = Number(route.query.id || 0);
|
||||
if (!id) return;
|
||||
apply.value = (await applyApi.detail(id).send()) as any;
|
||||
loadSchemaLabels();
|
||||
contentGroups.value = buildFormDataGroups(apply.value?.formSchemaJson, apply.value?.formDataJson);
|
||||
try {
|
||||
progress.value = (await applyApi.progress(id).send()) as any;
|
||||
} catch (e: any) {
|
||||
@@ -62,26 +72,6 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 事项表单 schema(发起页同源接口)只用于把 key 显示成中文,失败则显示 key 本身 */
|
||||
async function loadSchemaLabels() {
|
||||
const siteCode = String(route.query.siteCode || apply.value?.siteCode || "");
|
||||
const itemCode = apply.value?.itemCode;
|
||||
if (!siteCode || !itemCode) return;
|
||||
try {
|
||||
const item: any = await applyApi.item(siteCode, itemCode).send();
|
||||
const schema = JSON.parse(item?.schemaJson || "{}");
|
||||
const labels: Record<string, string> = {};
|
||||
(schema.groups || []).forEach((g: any) => {
|
||||
(g.fields || []).forEach((f: any) => {
|
||||
if (f.key) labels[f.key] = f.label || f.key;
|
||||
});
|
||||
});
|
||||
fieldLabels.value = labels;
|
||||
} catch {
|
||||
// 忽略:标签只是展示优化
|
||||
}
|
||||
}
|
||||
|
||||
function displayValue(v: any): string {
|
||||
if (v === null || v === undefined || v === "") return "—";
|
||||
if (Array.isArray(v)) {
|
||||
@@ -196,10 +186,21 @@ onMounted(load);
|
||||
|
||||
<div class="sh-card" v-if="apply.formDataJson">
|
||||
<div class="section-title">申请内容</div>
|
||||
<div class="kv" v-for="(v, k) in formData" :key="String(k)">
|
||||
<span class="k">{{ fieldLabels[String(k)] || k }}</span>
|
||||
<span class="v">{{ displayValue(v) }}</span>
|
||||
</div>
|
||||
<template v-for="g in contentGroups" :key="g.title">
|
||||
<p v-if="g.title !== '申请内容'" class="group-title">{{ g.title }}</p>
|
||||
<div class="kv" v-for="row in g.rows" :key="row.key">
|
||||
<span class="k">{{ row.label }}</span>
|
||||
<span class="v" v-if="isUploadRow(row)">
|
||||
<template v-if="fileList(row.value).length">
|
||||
<div v-for="(f, fi) in fileList(row.value)" :key="fi" class="file-item">
|
||||
<a :href="f.fileUrl" target="_blank" rel="noopener">{{ f.fileName || f.fileUrl }}</a>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>—</template>
|
||||
</span>
|
||||
<span class="v" v-else>{{ displayValue(row.value) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="sh-card" v-if="result.result || result.opinion">
|
||||
@@ -271,6 +272,12 @@ onMounted(load);
|
||||
padding-left: 8px;
|
||||
border-left: 3px solid var(--sh-primary);
|
||||
}
|
||||
.group-title {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--sh-text-weak);
|
||||
margin: 12px 0 2px;
|
||||
}
|
||||
.kv {
|
||||
display: flex;
|
||||
padding: 6px 0;
|
||||
@@ -286,6 +293,9 @@ onMounted(load);
|
||||
flex: 1;
|
||||
word-break: break-all;
|
||||
}
|
||||
.file-item {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.timeline {
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user