feat: 办事大厅用户面前端(hall)首版入库——由 app/public 改名去 app 层;build.yml 修并发撞名容exit137
build / build (push) Successful in 3m6s

This commit is contained in:
chenxing
2026-09-17 09:12:03 +08:00
parent 6511923327
commit f87523c524
89 changed files with 16000 additions and 14 deletions
+255
View File
@@ -0,0 +1,255 @@
<script setup lang="ts">
/**
* 动态表单渲染器(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[]。
*/
import { computed } 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;
label: string;
type: string;
required?: boolean;
pattern?: string;
min?: number;
max?: number;
maxLength?: number;
tip?: string;
placeholder?: string;
options?: string[];
rows?: number;
defaultValue?: any;
accept?: string;
maxSize?: number;
maxFiles?: number;
}
const groups = computed<{ title: string; fields: Field[] }[]>(() => {
try {
const parsed = JSON.parse(props.schemaJson || "{}");
return (parsed.groups || []).map((g: any) => ({ title: g.title || "", fields: g.fields || [] }));
} catch {
return [];
}
});
function setValue(field: Field, value: any) {
emit("update:formData", { ...props.formData, [field.key]: value });
}
function ensureArray(field: Field): any[] {
const v = props.formData[field.key];
return Array.isArray(v) ? v : [];
}
/** upload/image 上传(maxSize MB、maxFiles 上限;maxFiles 缺省/0=不限;落库为 UploadVo 原样对象) */
async function uploadFile(field: Field, ev: Event) {
const input = ev.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (!file) return;
if (field.maxSize && file.size > field.maxSize * 1024 * 1024) {
Message.error(`单个文件不得超过 ${field.maxSize}MB`);
return;
}
const list = ensureArray(field);
if (field.maxFiles && field.maxFiles > 0 && list.length >= field.maxFiles) {
Message.error(`附件数超过上限 ${field.maxFiles}`);
return;
}
try {
// image 走 image 通道:后端按 type 分目录(/upload/image/** 与 /upload/file/**)
const res: any = await fileApi.upload(file, field.type === "image" ? "image" : "file").send();
setValue(field, [...list, res]);
} catch (e: any) {
Message.error(e?.message || "上传失败");
}
}
</script>
<template>
<div class="dyn-form">
<div v-for="(group, gi) in groups" :key="gi" class="dyn-group">
<div v-if="group.title" class="dyn-group-title">{{ group.title }}</div>
<div v-for="field in group.fields" :key="field.key" class="dyn-field">
<label class="dyn-label">
<span v-if="field.required" class="dyn-required">*</span>
{{ field.label }}
</label>
<template v-if="field.type === 'text' || field.type === 'phone'">
<Input
:model-value="formData[field.key] ?? field.defaultValue ?? ''"
:placeholder="field.placeholder || ''"
:maxlength="field.maxLength"
@update:model-value="(v: string) => setValue(field, v)"
/>
</template>
<template v-else-if="field.type === 'textarea'">
<Input
type="textarea"
:rows="field.rows || 3"
:model-value="formData[field.key] ?? field.defaultValue ?? ''"
:placeholder="field.placeholder || ''"
:maxlength="field.maxLength"
@update:model-value="(v: string) => setValue(field, v)"
/>
</template>
<template v-else-if="field.type === 'number'">
<InputNumber
:model-value="formData[field.key] ?? field.defaultValue ?? null"
:min="field.min"
:max="field.max"
:placeholder="field.placeholder || ''"
style="width: 220px"
@update:model-value="(v: number) => setValue(field, v)"
/>
</template>
<template v-else-if="field.type === 'date'">
<DatePicker
type="date"
:model-value="formData[field.key] ?? field.defaultValue ?? ''"
placeholder="选择日期"
style="width: 220px"
@update:model-value="(v: string) => setValue(field, v)"
/>
</template>
<template v-else-if="field.type === 'select'">
<Select
:model-value="formData[field.key] ?? field.defaultValue ?? ''"
:placeholder="field.placeholder || '请选择'"
style="width: 260px"
@update:model-value="(v: string) => setValue(field, v)"
>
<Option v-for="opt in field.options || []" :key="opt" :value="opt">{{ opt }}</Option>
</Select>
</template>
<template v-else-if="field.type === 'multiselect'">
<Select
:model-value="ensureArray(field)"
multiple
:placeholder="field.placeholder || '请选择'"
style="width: 260px"
@update:model-value="(v: any[]) => setValue(field, v)"
>
<Option v-for="opt in field.options || []" :key="opt" :value="opt">{{ opt }}</Option>
</Select>
</template>
<template v-else-if="field.type === 'radio'">
<RadioGroup
:model-value="formData[field.key] ?? field.defaultValue ?? ''"
@update:model-value="(v: string) => setValue(field, v)"
>
<Radio v-for="opt in field.options || []" :key="opt" :label="opt">{{ opt }}</Radio>
</RadioGroup>
</template>
<template v-else-if="field.type === 'checkbox'">
<CheckboxGroup
:model-value="ensureArray(field)"
@update:model-value="(v: any[]) => setValue(field, v)"
>
<Checkbox v-for="opt in field.options || []" :key="opt" :label="opt">{{ opt }}</Checkbox>
</CheckboxGroup>
</template>
<template v-else-if="field.type === 'upload' || field.type === 'image'">
<div class="dyn-upload">
<div v-for="(f, fi) in ensureArray(field)" :key="fi" class="dyn-file">
<img
v-if="field.type === 'image' && f.fileUrl"
:src="f.fileUrl"
class="dyn-thumb"
alt=""
/>
<a :href="f.fileUrl" target="_blank" rel="noopener">{{ f.fileName }}</a>
<a
class="dyn-file-remove"
@click.prevent="
setValue(
field,
ensureArray(field).filter((_, i) => i !== fi)
)
"
>移除</a>
</div>
<label class="dyn-file-btn">
<input
type="file"
style="display: none"
:accept="field.accept || (field.type === 'image' ? '.jpg,.jpeg,.png,.gif' : '')"
@change="uploadFile(field, $event)"
/>
<Button size="small" icon="ios-cloud-upload-outline">
{{ field.type === 'image' ? '上传图片' : '上传附件' }}
</Button>
</label>
</div>
</template>
<div v-if="field.tip" class="dyn-tip">{{ field.tip }}</div>
</div>
</div>
</div>
</template>
<style scoped>
.dyn-group {
margin-bottom: 8px;
}
.dyn-group-title {
font-weight: 600;
padding: 8px 0;
border-bottom: 1px solid #eee;
margin-bottom: 12px;
}
.dyn-field {
margin-bottom: 14px;
}
.dyn-label {
display: block;
margin-bottom: 6px;
color: #515a6e;
}
.dyn-required {
color: #ed4014;
margin-right: 2px;
}
.dyn-tip {
font-size: 12px;
color: #999;
margin-top: 4px;
}
.dyn-file {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 4px;
}
.dyn-file-remove {
color: #ed4014;
cursor: pointer;
font-size: 12px;
}
.dyn-thumb {
width: 64px;
height: 64px;
object-fit: cover;
vertical-align: middle;
margin-right: 8px;
border-radius: 4px;
}
</style>