feat: 表单拖拽设计器(/svc/form/designer)+ 渲染器补 checkbox/multiselect/image
build / build (push) Successful in 3m30s

- 新增独立全屏设计器:左组件库(11 类,克隆拖入+点击添加)/中多分组画布(组内排序、
  跨组移动、复制删除)/右属性面板(label/key/必填/placeholder/options 批量编辑/
  textarea 行数字数/number min-max/upload·image accept·maxSize·maxFiles/tip)
- schema 产出严格对齐后端 FormValidator 契约:白名单 16 属性、key ^[A-Za-z0-9_]{1,64}$
  全表单唯一、_uid 与空串剔除;预览复用发起页 DynamicForm(WYSIWYG)
- DynamicForm 补 checkbox(CheckboxGroup→string[])/multiselect(Select multiple)/
  image(附件通道 + .dyn-thumb 缩略图)渲染
- 列表页「新建/编辑」改跳设计器,移除裸 JSON 编辑弹窗与旧预览入口;新增路由 /svc/form/designer

审计修复(本次独立复核):
1. P1 已发布表单进设计器保存**必被后端拒**(ServiceFormService 行不可变:带已发布版本
   id 一律拒)→ 装载到 status≠0 时保存不传 id,由后端按 code 升版出 vN+1 草稿,页头加 Tag 提示
2. P2 字段跨组拖拽后右侧选中态被清空 → 删掉 removed 分支的 clearSelect(Sortable 先 add
   后 remove 且经 nextTick 排队,清空会反手抹掉刚设的选中;字段本拖不出画布)
3. P2 maxFiles 三层语义不一致:面板写「0 不限」、渲染器视为不限、后端按上限比对 →
   后端改 maxFiles>0 判定(缺省/0=不限),面板 min=1 且文案「留空不限」
4. P2 pickAllowed 保留空串:pattern:"" 到后端是可编译空正则,会把所有非空值判成格式不正确
5. P3 保存前补 label 非空、分组标题 ≤64 的前置校验(少一次后端 400)
6. P3 image 上传走 type=image(落 /upload/image/,与后端分目录一致);修正 upload 值结构注释
   (UploadVo 是 {id,fileName,fileUrl,fileSize,mimeType},无 attachmentId)

验证:npm run build 通过(FormDesigner chunk 正常产出);后端 mvn test 102/102 绿。
This commit is contained in:
chenxing
2026-09-16 20:21:42 +08:00
parent 276ea03e69
commit 1810c6a560
9 changed files with 1180 additions and 90 deletions
+60 -13
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
/**
* 动态表单渲染器(V2.0 Part B.3:schema → 控件,一期 7 类)。
* 动态表单渲染器(V2.0 Part B.3:schema → 控件,一期 11 类)。
* schemaJson = {"groups":[{title, fields:[{key,label,type,required,pattern,options,...}]}]}。
* v-model(formData) 为 {key:value} 对象;upload 字段值为 [{attachmentId,fileUrl,fileName}]。
* 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";
@@ -48,7 +49,7 @@ function ensureArray(field: Field): any[] {
return Array.isArray(v) ? v : [];
}
/** upload 字段上传(maxSize MB/maxFiles 上限;返回 {attachmentId,fileUrl,fileName}) */
/** 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];
@@ -59,12 +60,13 @@ async function uploadFile(field: Field, ev: Event) {
return;
}
const list = ensureArray(field);
if (field.maxFiles && list.length >= field.maxFiles) {
if (field.maxFiles && field.maxFiles > 0 && list.length >= field.maxFiles) {
Message.error(`附件数超过上限 ${field.maxFiles}`);
return;
}
try {
const res: any = await fileApi.upload(file, "file").send();
// 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 || "上传失败");
@@ -134,6 +136,18 @@ async function uploadFile(field: Field, ev: Event) {
</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 ?? ''"
@@ -143,20 +157,45 @@ async function uploadFile(field: Field, ev: Event) {
</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>
<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 || ''" @change="uploadFile(field, $event)" />
<Button size="small" icon="ios-cloud-upload-outline">上传附件</Button>
<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>
@@ -205,4 +244,12 @@ async function uploadFile(field: Field, ev: Event) {
cursor: pointer;
font-size: 12px;
}
.dyn-thumb {
width: 64px;
height: 64px;
object-fit: cover;
vertical-align: middle;
margin-right: 8px;
border-radius: 4px;
}
</style>
@@ -0,0 +1,96 @@
<script setup lang="ts">
/** 设计器-左组件库:基础/增强两组,draggable 克隆源 + 点击添加 */
import { computed } from "vue";
import draggable from "vuedraggable";
import type { DesignField, DesignerTypeItem } from "@/types/designer";
import { DESIGNER_TYPES } from "@/types/designer";
import { defaultField } from "@/utils/designer/schema";
const props = defineProps<{ nextKey: (type: string) => string }>();
const emit = defineEmits<{ (e: "add", seed: DesignerTypeItem): void }>();
const baseItems = computed(() => DESIGNER_TYPES.filter((t) => t.ftype === "base"));
const enhanceItems = computed(() => DESIGNER_TYPES.filter((t) => t.ftype === "enhance"));
/** 拖拽克隆时立即生成完整字段(自带 _uid 与唯一 key),画布 @change.added 直接可用 */
function cloneItem(seed: DesignerTypeItem): DesignField {
return defaultField(seed.type, props.nextKey, seed.name);
}
</script>
<template>
<aside class="d-lib">
<div class="d-lib-title">表单控件</div>
<div class="d-lib-scroll">
<template v-for="(section, si) in [
{ name: '基础字段', items: baseItems },
{ name: '增强字段', items: enhanceItems },
]" :key="si">
<div class="d-lib-group-title">{{ section.name }}</div>
<draggable
:list="section.items as any[]"
:sort="false"
:group="{ name: 'designer', pull: 'clone', put: false }"
:clone="cloneItem"
item-key="type"
:force-fallback="true"
class="d-lib-grid"
>
<template #item="{ element }">
<div class="d-lib-item" @click="emit('add', element)">{{ element.name }}</div>
</template>
</draggable>
</template>
</div>
</aside>
</template>
<style scoped>
.d-lib {
width: 220px;
flex-shrink: 0;
border-right: 1px solid #e8eaec;
background: #fff;
display: flex;
flex-direction: column;
height: 100%;
}
.d-lib-title {
padding: 14px 16px 10px;
font-weight: 600;
border-bottom: 1px solid #f0f0f0;
}
.d-lib-scroll {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.d-lib-group-title {
color: #808695;
font-size: 12px;
margin: 8px 0 8px;
}
.d-lib-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.d-lib-item {
border: 1px solid #dcdee2;
border-radius: 4px;
padding: 8px 4px;
text-align: center;
font-size: 12px;
color: #515a6e;
cursor: grab;
user-select: none;
background:
linear-gradient(135deg, rgba(87, 122, 251, 0.06) 25%, transparent 25%) 0 0 / 8px 8px,
linear-gradient(315deg, rgba(87, 122, 251, 0.06) 25%, transparent 25%) 0 0 / 8px 8px,
#f8f9fb;
}
.d-lib-item:hover {
border-color: #577afb;
color: #2d4ed8;
}
</style>
+230
View File
@@ -0,0 +1,230 @@
<script setup lang="ts">
/** 设计器-中间画布:多分组、组内 draggable 排序 + 跨组移动、字段卡片选中/复制/删除 */
import draggable from "vuedraggable";
import type { DesignField, DesignGroup } from "@/types/designer";
import { DESIGNER_TYPES, OPTION_TYPES } from "@/types/designer";
import { defaultField } from "@/utils/designer/schema";
const props = defineProps<{
groups: DesignGroup[];
selected: { gi: number; fi: number } | null;
nextKey: (type: string) => string;
}>();
const emit = defineEmits<{
(e: "select", gi: number, fi: number): void;
(e: "copy", gi: number, fi: number): void;
(e: "remove", gi: number, fi: number): void;
(e: "add", seed: { type: string; name: string }): void;
(e: "addGroup"): void;
(e: "removeGroup", gi: number): void;
}>();
function typeName(type: string): string {
return DESIGNER_TYPES.find((t) => t.type === type)?.name ?? type;
}
function isActive(gi: number, fi: number): boolean {
return !!props.selected && props.selected.gi === gi && props.selected.fi === fi;
}
function isOptionType(type: string): boolean {
return OPTION_TYPES.includes(type);
}
/**
* vuedraggable 变更统一处理:克隆入组 / 组内排序 / 跨组移动。
* `removed` 不重置选中态:字段拖不出画布(左库 put:false,落到空白处 Sortable 自动回退),
* 因此 removed 只可能是跨组移动;而 Sortable 是「先 add 后 remove」派发(两者都经 nextTick 排队),
* 若在此清空选中,随后到达的 add 选中态会被反手抹掉,右侧属性面板就白了。
*/
function onChange(gi: number, ev: any) {
if (ev.added) {
const el = ev.added.element as any;
// 兼容:左库 clone 已生成完整字段(含 _uid/key);缺失时用种子重建并替换占位
if (!el || !el._uid || !el.key) {
const f = defaultField(el.type, props.nextKey, el.name);
props.groups[gi].fields.splice(ev.added.newIndex, 1, f);
}
emit("select", gi, ev.added.newIndex);
} else if (ev.moved) {
emit("select", gi, ev.moved.newIndex);
}
}
</script>
<template>
<section class="d-canvas">
<div class="d-canvas-inner">
<div v-for="(group, gi) in groups" :key="group._uid" class="d-group">
<div class="d-group-head">
<Input v-model="group.title" placeholder="分组标题" style="width: 220px" />
<span class="d-group-count">{{ (group.fields || []).length }} 个字段</span>
<Button v-if="groups.length > 1" size="small" type="text" style="color: #ed4014" @click="emit('removeGroup', gi)">删除分组</Button>
</div>
<div class="d-group-body">
<draggable
v-model="group.fields"
:group="{ name: 'designer', pull: true, put: true }"
item-key="_uid"
handle=".d-field-handle"
animation="150"
ghost-class="d-ghost"
:force-fallback="true"
@change="onChange(gi, $event)"
>
<template #item="{ element, index }: { element: DesignField; index: number }">
<div
class="d-field"
:class="{ active: isActive(gi, index) }"
@click="emit('select', gi, index)"
>
<span class="d-field-handle" title="拖拽排序">⠿</span>
<div class="d-field-main">
<div class="d-field-label">
<span v-if="element.required" class="d-required">*</span>
{{ element.label || "未命名字段" }}
</div>
<div class="d-field-meta">
{{ typeName(element.type) }}
<span v-if="isOptionType(element.type)" class="d-field-opts">
({{ (element.options || []).length }} 项)
</span>
</div>
</div>
<span class="d-field-tools">
<Icon type="md-copy" title="复制字段" @click.stop="emit('copy', gi, index)" />
<Icon type="ios-trash" title="删除字段" @click.stop="emit('remove', gi, index)" />
</span>
</div>
</template>
</draggable>
<div v-if="!(group.fields || []).length" class="d-quick-add">
<span class="d-empty-tip">从左侧拖拽或点击来添加字段</span>
<div class="d-quick-more">
<Button
v-for="t in DESIGNER_TYPES"
:key="t.type"
size="small"
@click="emit('add', t)"
>
{{ t.name }}
</Button>
</div>
</div>
</div>
</div>
<Button type="dashed" long icon="md-add" style="margin-top: 16px" @click="emit('addGroup')">添加分组</Button>
</div>
</section>
</template>
<style scoped>
.d-canvas {
flex: 1;
overflow-y: auto;
padding: 16px;
background: #f2f4f8;
height: 100%;
}
.d-canvas-inner {
min-height: 100%;
padding-bottom: 24px;
}
.d-group {
background: #fff;
border: 1px solid #dcdee2;
border-radius: 6px;
padding: 12px;
margin-bottom: 12px;
}
.d-group-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.d-group-count {
color: #808695;
font-size: 12px;
}
.d-group-body {
min-height: 60px;
}
.d-field {
display: flex;
align-items: center;
gap: 8px;
border: 1px solid #dcdee2;
border-radius: 4px;
padding: 8px 10px;
margin-bottom: 8px;
background: #fafbfc;
cursor: pointer;
}
.d-field.active {
border-color: #577afb;
box-shadow: 0 0 0 2px rgba(87, 122, 251, 0.15);
background: #fff;
}
.d-field-handle {
cursor: grab;
color: #c5c8ce;
font-size: 16px;
user-select: none;
}
.d-field-main {
flex: 1;
min-width: 0;
}
.d-field-label {
color: #34373e;
font-size: 14px;
}
.d-required {
color: #ed4014;
margin-right: 2px;
}
.d-field-meta {
color: #808695;
font-size: 12px;
margin-top: 2px;
}
.d-field-opts {
margin-left: 4px;
}
.d-field-tools {
display: none;
gap: 6px;
color: #808695;
font-size: 16px;
}
.d-field:hover .d-field-tools,
.d-field.active .d-field-tools {
display: flex;
}
.d-field-tools :deep(.ivu-icon:hover) {
color: #577afb;
}
.d-ghost {
opacity: 0.4;
border: 1px dashed #577afb;
}
.d-empty-tip {
display: block;
color: #c5c8ce;
text-align: center;
padding: 18px 0 10px;
border: 1px dashed #dcdee2;
border-radius: 4px;
font-size: 13px;
}
.d-quick-add {
margin-top: 4px;
}
.d-quick-more {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
</style>
+237
View File
@@ -0,0 +1,237 @@
<script setup lang="ts">
/** 设计器-右侧字段属性面板:字段对象直接浅层修改(props 透传 parent 的响应式对象) */
import { computed, ref } from "vue";
import type { DesignField } from "@/types/designer";
import { DESIGNER_TYPES, FILE_TYPES, KEY_PATTERN, OPTION_TYPES, PLACEHOLDER_TYPES } from "@/types/designer";
const props = defineProps<{
field: DesignField | null;
duplicateKeys: Set<string>;
}>();
const batchModal = ref(false);
const batchText = ref("");
const typeName = computed(() => {
if (!props.field) return "";
return DESIGNER_TYPES.find((t) => t.type === props.field!.type)?.name ?? props.field!.type;
});
const showPlaceholder = computed(() => !!props.field && PLACEHOLDER_TYPES.includes(props.field.type));
const showOptions = computed(() => !!props.field && OPTION_TYPES.includes(props.field.type));
const showTextarea = computed(() => !!props.field && props.field.type === "textarea");
const showNumber = computed(() => !!props.field && props.field.type === "number");
const showFile = computed(() => !!props.field && FILE_TYPES.includes(props.field.type));
const keyError = computed(() => {
if (!props.field) return "";
if (!props.field.key) return "字段 key 不得为空(字母/数字/下划线)";
if (!KEY_PATTERN.test(props.field.key)) return "仅限字母/数字/下划线,1~64 位";
if (props.duplicateKeys.has(props.field.key)) return "字段 key 重复,全表单唯一";
return "";
});
function addOption() {
const f = props.field;
if (!f) return;
f.options = [...(f.options || []), `选项${(f.options || []).length + 1}`];
}
function removeOption(index: number) {
const f = props.field;
if (!f) return;
f.options = (f.options || []).filter((_, i) => i !== index);
}
function moveOption(index: number, dir: -1 | 1) {
const f = props.field;
if (!f || !f.options) return;
const to = index + dir;
if (to < 0 || to >= f.options.length) return;
const arr = [...f.options];
[arr[index], arr[to]] = [arr[to], arr[index]];
f.options = arr;
}
function openBatch() {
batchText.value = (props.field?.options || []).join("\n");
batchModal.value = true;
}
function saveBatch() {
if (props.field) {
props.field.options = batchText.value.split("\n").filter((x) => x.trim() !== "");
}
batchModal.value = false;
}
</script>
<template>
<aside class="d-prop">
<div class="d-prop-title">字段属性</div>
<div class="d-prop-body">
<div v-if="!field" class="d-prop-empty">
点击画布中的字段<br />以编辑属性
</div>
<template v-else>
<div class="d-prop-item">
<label class="d-prop-label">字段标题</label>
<Input v-model="field.label" placeholder="请输入字段标题" />
</div>
<div class="d-prop-item">
<label class="d-prop-label">
字段标识 key
<Tooltip content="表单结构中的唯一标识,引用数据时使用" placement="top">
<Icon type="ios-help-circle" size="14" color="#c5c8ce" />
</Tooltip>
</label>
<Input v-model="field.key" :class="keyError ? 'd-err-input' : ''" placeholder="字母/数字/下划线" />
<div v-if="keyError" class="d-err-msg">{{ keyError }}</div>
</div>
<div class="d-prop-item">
<label class="d-prop-label">控件类型</label>
<Tag size="medium" color="default" style="width: 100%">{{ typeName }}</Tag>
</div>
<div class="d-prop-item">
<Checkbox v-model="field.required">必填</Checkbox>
</div>
<template v-if="showPlaceholder">
<div class="d-prop-item">
<label class="d-prop-label">输入提示</label>
<Input v-model="field.placeholder" placeholder="占位提示文本" />
</div>
</template>
<template v-if="showOptions">
<div class="d-prop-item">
<label class="d-prop-label">选项</label>
<div v-for="(opt, i) in field.options || []" :key="i" class="d-opt-row">
<Input v-model="field.options![i]" placeholder="选项内容" />
<Button size="small" icon="md-arrow-up" @click="moveOption(i, -1)" />
<Button size="small" icon="md-arrow-down" @click="moveOption(i, 1)" />
<Button size="small" type="text" icon="ios-trash" @click="removeOption(i)" />
</div>
<div class="d-opt-tools">
<a href="javascript:void(0)" @click="addOption">+ 添加选项</a>
<a href="javascript:void(0)" @click="openBatch">批量编辑</a>
</div>
</div>
</template>
<template v-if="showTextarea">
<div class="d-prop-item">
<label class="d-prop-label">行数</label>
<InputNumber v-model="field.rows" :min="1" :max="10" style="width: 100%" />
</div>
<div class="d-prop-item">
<label class="d-prop-label">最大字数</label>
<InputNumber v-model="field.maxLength" :min="1" :max="2000" style="width: 100%" />
</div>
</template>
<template v-if="showNumber">
<div class="d-prop-item">
<label class="d-prop-label">数值范围</label>
<div class="d-row2">
<InputNumber v-model="field.min" placeholder="最小值" style="width: 100%" />
<span>~</span>
<InputNumber v-model="field.max" placeholder="最大值" style="width: 100%" />
</div>
</div>
</template>
<template v-if="showFile">
<div class="d-prop-item">
<label class="d-prop-label">限定文件类型</label>
<Input v-model="field.accept" placeholder=".jpg,.jpeg,.png,留空不限" />
</div>
<div class="d-prop-item">
<label class="d-prop-label">单个大小上限(MB,留空不限)</label>
<InputNumber v-model="field.maxSize" :min="1" :max="1000" style="width: 100%" />
</div>
<div class="d-prop-item">
<label class="d-prop-label">最多数量(留空不限)</label>
<InputNumber v-model="field.maxFiles" :min="1" :max="99" style="width: 100%" />
</div>
</template>
<div class="d-prop-item">
<label class="d-prop-label">提示信息</label>
<Input v-model="field.tip" placeholder="字段下方辅助说明(可选)" />
</div>
</template>
</div>
<Modal v-model="batchModal" width="460" title="批量编辑选项">
<div style="color: #808695; margin-bottom: 8px">每行对应一个选项</div>
<Input v-model="batchText" type="textarea" :rows="10" placeholder="选项1&#10;选项2&#10;选项3" />
<template #footer>
<Button @click="batchModal = false">取消</Button>
<Button type="primary" @click="saveBatch">确定</Button>
</template>
</Modal>
</aside>
</template>
<style scoped>
.d-prop {
width: 300px;
flex-shrink: 0;
border-left: 1px solid #e8eaec;
background: #fff;
display: flex;
flex-direction: column;
height: 100%;
}
.d-prop-title {
padding: 14px 16px 10px;
font-weight: 600;
border-bottom: 1px solid #f0f0f0;
}
.d-prop-body {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
}
.d-prop-empty {
color: #c5c8ce;
text-align: center;
padding-top: 60px;
line-height: 1.8;
}
.d-prop-item {
margin-bottom: 14px;
}
.d-prop-label {
display: block;
color: #515a6e;
font-size: 13px;
margin-bottom: 6px;
}
.d-opt-row {
display: flex;
gap: 4px;
align-items: center;
margin-bottom: 6px;
}
.d-opt-row :deep(.ivu-input-wrapper) {
flex: 1;
}
.d-opt-tools {
display: flex;
gap: 12px;
font-size: 12px;
}
.d-opt-tools a {
color: #577afb;
}
.d-row2 {
display: flex;
gap: 8px;
align-items: center;
}
.d-err-input :deep(input) {
border-color: #ed4014;
}
.d-err-msg {
color: #ed4014;
font-size: 12px;
margin-top: 4px;
}
</style>