feat: 表单拖拽设计器(/svc/form/designer)+ 渲染器补 checkbox/multiselect/image
build / build (push) Successful in 3m30s
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:
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<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,...}]}]}。
|
* 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 { computed } from "vue";
|
||||||
import { Message } from "view-ui-plus";
|
import { Message } from "view-ui-plus";
|
||||||
@@ -48,7 +49,7 @@ function ensureArray(field: Field): any[] {
|
|||||||
return Array.isArray(v) ? v : [];
|
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) {
|
async function uploadFile(field: Field, ev: Event) {
|
||||||
const input = ev.target as HTMLInputElement;
|
const input = ev.target as HTMLInputElement;
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
@@ -59,12 +60,13 @@ async function uploadFile(field: Field, ev: Event) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const list = ensureArray(field);
|
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}`);
|
Message.error(`附件数超过上限 ${field.maxFiles}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
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]);
|
setValue(field, [...list, res]);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
Message.error(e?.message || "上传失败");
|
Message.error(e?.message || "上传失败");
|
||||||
@@ -134,6 +136,18 @@ async function uploadFile(field: Field, ev: Event) {
|
|||||||
</Select>
|
</Select>
|
||||||
</template>
|
</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'">
|
<template v-else-if="field.type === 'radio'">
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
:model-value="formData[field.key] ?? field.defaultValue ?? ''"
|
:model-value="formData[field.key] ?? field.defaultValue ?? ''"
|
||||||
@@ -143,20 +157,45 @@ async function uploadFile(field: Field, ev: Event) {
|
|||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</template>
|
</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'">
|
<template v-else-if="field.type === 'upload' || field.type === 'image'">
|
||||||
<div class="dyn-upload">
|
<div class="dyn-upload">
|
||||||
<div v-for="(f, fi) in ensureArray(field)" :key="fi" class="dyn-file">
|
<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 :href="f.fileUrl" target="_blank" rel="noopener">{{ f.fileName }}</a>
|
||||||
<a class="dyn-file-remove" @click.prevent="
|
<a
|
||||||
setValue(
|
class="dyn-file-remove"
|
||||||
field,
|
@click.prevent="
|
||||||
ensureArray(field).filter((_, i) => i !== fi)
|
setValue(
|
||||||
)
|
field,
|
||||||
">移除</a>
|
ensureArray(field).filter((_, i) => i !== fi)
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>移除</a>
|
||||||
</div>
|
</div>
|
||||||
<label class="dyn-file-btn">
|
<label class="dyn-file-btn">
|
||||||
<input type="file" style="display: none" :accept="field.accept || ''" @change="uploadFile(field, $event)" />
|
<input
|
||||||
<Button size="small" icon="ios-cloud-upload-outline">上传附件</Button>
|
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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -205,4 +244,12 @@ async function uploadFile(field: Field, ev: Event) {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
.dyn-thumb {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
object-fit: cover;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
</style>
|
</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>
|
||||||
@@ -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>
|
||||||
@@ -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 选项2 选项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>
|
||||||
@@ -36,6 +36,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import("@/views/svc/ServiceFormManage.vue"),
|
component: () => import("@/views/svc/ServiceFormManage.vue"),
|
||||||
meta: { title: "表单管理", modId: 25 },
|
meta: { title: "表单管理", modId: 25 },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/svc/form/designer",
|
||||||
|
name: "svc_form_designer",
|
||||||
|
component: () => import("@/views/svc/FormDesigner.vue"),
|
||||||
|
meta: { title: "表单设计器", modId: 25, parentId: 25 },
|
||||||
|
},
|
||||||
// 管理面:办件记录
|
// 管理面:办件记录
|
||||||
{
|
{
|
||||||
path: "/svc/apply",
|
path: "/svc/apply",
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* 表单设计器类型与控件字典(迁移自旧 FormManage,Vue3 版)。
|
||||||
|
* 契约对齐后端 FormValidator:不迁移 address/user/users 三类。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 设计器组件库控件字典(左栏渲染源) */
|
||||||
|
export interface DesignerTypeItem {
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
ftype: "base" | "enhance";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DESIGNER_TYPES: DesignerTypeItem[] = [
|
||||||
|
{ type: "text", name: "单行文本", ftype: "base" },
|
||||||
|
{ type: "textarea", name: "多行文本", ftype: "base" },
|
||||||
|
{ type: "number", name: "数字", ftype: "base" },
|
||||||
|
{ type: "date", name: "日期", ftype: "base" },
|
||||||
|
{ type: "select", name: "下拉", ftype: "base" },
|
||||||
|
{ type: "radio", name: "单选", ftype: "base" },
|
||||||
|
{ type: "checkbox", name: "多选", ftype: "base" },
|
||||||
|
{ type: "multiselect", name: "下拉多选", ftype: "base" },
|
||||||
|
{ type: "phone", name: "手机", ftype: "enhance" },
|
||||||
|
{ type: "upload", name: "附件", ftype: "enhance" },
|
||||||
|
{ type: "image", name: "图片", ftype: "enhance" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 需要 options 的控件类型(属性面板也要展示选项编辑) */
|
||||||
|
export const OPTION_TYPES = ["select", "radio", "checkbox", "multiselect"];
|
||||||
|
|
||||||
|
/** 输入提示类控件(属性面板展示 placeholder) */
|
||||||
|
export const PLACEHOLDER_TYPES = ["text", "textarea", "number", "date", "select", "radio", "checkbox", "multiselect", "phone"];
|
||||||
|
|
||||||
|
/** 上传类控件(图片/附件) */
|
||||||
|
export const FILE_TYPES = ["upload", "image"];
|
||||||
|
|
||||||
|
/** 编辑态字段(_uid 为内部拖拽/渲染 key,序列化时剔除) */
|
||||||
|
export interface DesignField {
|
||||||
|
_uid: string;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DesignGroup {
|
||||||
|
_uid: string;
|
||||||
|
title: string;
|
||||||
|
fields: DesignField[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 后端契约属性白名单(与 FormValidator.ALLOWED_PROPS 同步),序列化只能输出这些键 */
|
||||||
|
export const ALLOWED_FIELD_PROPS = [
|
||||||
|
"key", "label", "type", "required", "pattern", "min", "max", "maxLength",
|
||||||
|
"tip", "placeholder", "options", "rows", "defaultValue", "accept", "maxSize", "maxFiles",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 契约 key 规则(与后端 KEY_PATTERN 同步) */
|
||||||
|
export const KEY_PATTERN = /^[A-Za-z0-9_]{1,64}$/;
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* 设计器 schema 工具:字段默认值生成、key 自动去重、白名单序列化、编辑态还原。
|
||||||
|
* 纪律:产出对象只含契约白名单属性(ALLOWED_FIELD_PROPS),不会有 precision 等非法键。
|
||||||
|
*/
|
||||||
|
import type { DesignField, DesignGroup } from "@/types/designer";
|
||||||
|
import { ALLOWED_FIELD_PROPS } from "@/types/designer";
|
||||||
|
|
||||||
|
let uidSeq = 0;
|
||||||
|
|
||||||
|
/** 内部唯一 id(拖拽/渲染 key,非契约字段) */
|
||||||
|
export function genUid(): string {
|
||||||
|
uidSeq += 1;
|
||||||
|
return `uid_${Date.now().toString(36)}_${uidSeq}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 控件类型 → key 前缀 */
|
||||||
|
const TYPE_KEY_BASE: Record<string, string> = {
|
||||||
|
text: "text",
|
||||||
|
textarea: "textarea",
|
||||||
|
number: "number",
|
||||||
|
date: "date",
|
||||||
|
select: "select",
|
||||||
|
radio: "radio",
|
||||||
|
checkbox: "check",
|
||||||
|
multiselect: "multi",
|
||||||
|
phone: "phone",
|
||||||
|
upload: "file",
|
||||||
|
image: "img",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** key 自动生成器(全表单唯一;用于拖入/复制时分配新 key) */
|
||||||
|
export function makeNextKey(used: Set<string>): (type: string) => string {
|
||||||
|
return (type: string) => {
|
||||||
|
const base = TYPE_KEY_BASE[type] ?? "field";
|
||||||
|
let n = 1;
|
||||||
|
let k = `${base}_${n}`;
|
||||||
|
while (used.has(k)) {
|
||||||
|
n += 1;
|
||||||
|
k = `${base}_${n}`;
|
||||||
|
}
|
||||||
|
used.add(k);
|
||||||
|
return k;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IMAGE_ACCEPT = ".jpg,.jpeg,.png,.gif";
|
||||||
|
export const FILE_ACCEPT = ".doc,.docx,.xls,.xlsx,.pdf,.ppt,.pptx,.txt,.zip,.rar,.jpg,.jpeg,.png,.gif";
|
||||||
|
|
||||||
|
/** 控件默认显示名(点击添加时作为 label) */
|
||||||
|
function defaultLabel(type: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
text: "单行文本",
|
||||||
|
textarea: "多行文本",
|
||||||
|
number: "数字",
|
||||||
|
date: "日期",
|
||||||
|
select: "下拉",
|
||||||
|
radio: "单选",
|
||||||
|
checkbox: "多选",
|
||||||
|
multiselect: "下拉多选",
|
||||||
|
phone: "手机",
|
||||||
|
upload: "附件",
|
||||||
|
image: "图片",
|
||||||
|
};
|
||||||
|
return map[type] ?? type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成字段默认配置。maxSize 单位为 MB(与 DynamicForm 渲染器一致)。 */
|
||||||
|
export function defaultField(type: string, nextKey: (t: string) => string, label?: string): DesignField {
|
||||||
|
const f: DesignField = { _uid: genUid(), key: nextKey(type), label: label || defaultLabel(type), type };
|
||||||
|
switch (type) {
|
||||||
|
case "textarea":
|
||||||
|
f.rows = 5;
|
||||||
|
f.maxLength = 1000;
|
||||||
|
f.placeholder = "请输入";
|
||||||
|
break;
|
||||||
|
case "select":
|
||||||
|
case "multiselect":
|
||||||
|
f.options = ["选项1", "选项2", "选项3"];
|
||||||
|
f.placeholder = "请选择";
|
||||||
|
break;
|
||||||
|
case "radio":
|
||||||
|
case "checkbox":
|
||||||
|
f.options = ["选项1", "选项2", "选项3"];
|
||||||
|
break;
|
||||||
|
case "phone":
|
||||||
|
f.placeholder = "请输入手机号";
|
||||||
|
f.tip = "请输入 1 开头的 11 位手机号";
|
||||||
|
f.pattern = "^1[3-9]\\d{9}$";
|
||||||
|
f.maxLength = 11;
|
||||||
|
break;
|
||||||
|
case "upload":
|
||||||
|
f.accept = FILE_ACCEPT;
|
||||||
|
f.maxSize = 20;
|
||||||
|
break;
|
||||||
|
case "image":
|
||||||
|
f.accept = IMAGE_ACCEPT;
|
||||||
|
f.maxSize = 10;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 序列化字段:仅输出契约白名单属性,剔除内部 _uid 与空值 */
|
||||||
|
export function pickAllowed(f: DesignField): Record<string, any> {
|
||||||
|
const o: Record<string, any> = {};
|
||||||
|
for (const k of ALLOWED_FIELD_PROPS) {
|
||||||
|
const v = (f as Record<string, any>)[k];
|
||||||
|
if (v === undefined || v === null) continue;
|
||||||
|
// 空串必须剔除:`pattern:""` 到后端是「能编译的空正则」,会把所有非空值判成格式不正确
|
||||||
|
if (typeof v === "string" && v.trim() === "") continue;
|
||||||
|
o[k] = v;
|
||||||
|
}
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从后端 schema 的 groups 还原为编辑态(补 _uid) */
|
||||||
|
export function restoreGroups(groups: Record<string, any>[]): DesignGroup[] {
|
||||||
|
return (groups || []).map((g) => ({
|
||||||
|
_uid: genUid(),
|
||||||
|
title: g.title || "",
|
||||||
|
fields: ((g.fields as Record<string, any>[]) || []).map((raw) => ({
|
||||||
|
...(raw as DesignField),
|
||||||
|
_uid: genUid(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 收集全表单已占用的 key(校验 + key 自动生成) */
|
||||||
|
export function collectUsedKeys(groups: DesignGroup[]): Set<string> {
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const g of groups) {
|
||||||
|
for (const f of g.fields || []) set.add(f.key);
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全表单字段总数 */
|
||||||
|
export function countFields(groups: DesignGroup[]): number {
|
||||||
|
return (groups || []).reduce((n, g) => n + (g.fields || []).length, 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 表单设计器(独立全屏页,/svc/form/designer):三栏布局迁移自旧 FormManage,
|
||||||
|
* 左组件库 / 中多分组画布 / 右字段属性;预览复用 DynamicForm 渲染器。
|
||||||
|
* 产出 schema 严格对齐后端 FormValidator 契约(groups/fields + 白名单属性)。
|
||||||
|
*/
|
||||||
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
import { cloneDeep } from "lodash";
|
||||||
|
import { Message, Modal } from "view-ui-plus";
|
||||||
|
import { svcFormApi } from "@/api/module/svc";
|
||||||
|
import { useSite } from "@/utils/site";
|
||||||
|
import ComponentLibrary from "@/components/designer/ComponentLibrary.vue";
|
||||||
|
import DesignerCanvas from "@/components/designer/DesignerCanvas.vue";
|
||||||
|
import FieldPropPanel from "@/components/designer/FieldPropPanel.vue";
|
||||||
|
import DynamicForm from "@/components/DynamicForm.vue";
|
||||||
|
import type { DesignField, DesignGroup } from "@/types/designer";
|
||||||
|
import { KEY_PATTERN } from "@/types/designer";
|
||||||
|
import {
|
||||||
|
collectUsedKeys,
|
||||||
|
countFields,
|
||||||
|
defaultField,
|
||||||
|
genUid,
|
||||||
|
makeNextKey,
|
||||||
|
pickAllowed,
|
||||||
|
restoreGroups,
|
||||||
|
} from "@/utils/designer/schema";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const { currentSiteCode, loadSites } = useSite();
|
||||||
|
|
||||||
|
const formId = ref<number | null>(null);
|
||||||
|
const formStatus = ref(0);
|
||||||
|
const formVersion = ref(1);
|
||||||
|
const code = ref("");
|
||||||
|
const name = ref("");
|
||||||
|
const groups = ref<DesignGroup[]>([]);
|
||||||
|
const selected = ref<{ gi: number; fi: number } | null>(null);
|
||||||
|
const saving = ref(false);
|
||||||
|
const previewModal = ref(false);
|
||||||
|
const previewData = ref<Record<string, any>>({});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 装载的是已发布/停用版本:行不可变,保存必须不传 id,由后端按 code 克隆出 version+1 草稿
|
||||||
|
* (传 id 会被 ServiceFormService.save 直接拒:「该版本已发布不可修改」)。
|
||||||
|
*/
|
||||||
|
const loadedPublished = computed(() => formId.value !== null && formStatus.value !== 0);
|
||||||
|
|
||||||
|
/** key 自动生成器(基于当前占用集合每次重建,保证新增/复制去重) */
|
||||||
|
const nextKey = computed(() => makeNextKey(collectUsedKeys(groups.value)));
|
||||||
|
/** 全表单重复的 key */
|
||||||
|
const duplicateKeys = computed(() => {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const g of groups.value) {
|
||||||
|
for (const f of g.fields) counts.set(f.key, (counts.get(f.key) || 0) + 1);
|
||||||
|
}
|
||||||
|
return new Set([...counts.entries()].filter(([, c]) => c > 1).map(([k]) => k));
|
||||||
|
});
|
||||||
|
const selectedField = computed<DesignField | null>(() => {
|
||||||
|
const s = selected.value;
|
||||||
|
if (!s) return null;
|
||||||
|
const g = groups.value[s.gi];
|
||||||
|
return g?.fields[s.fi] ?? null;
|
||||||
|
});
|
||||||
|
const keyFormatOk = computed(() => {
|
||||||
|
for (const g of groups.value) {
|
||||||
|
for (const f of g.fields) {
|
||||||
|
if (!f.key || !KEY_PATTERN.test(f.key)) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildSchemaJson(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
groups: groups.value.map((g) => ({
|
||||||
|
title: g.title || undefined,
|
||||||
|
fields: g.fields.map((f) => pickAllowed(f)),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预览用的 schema(与保存一致) */
|
||||||
|
const previewSchemaJson = computed(() => buildSchemaJson());
|
||||||
|
|
||||||
|
function select(gi: number, fi: number) {
|
||||||
|
const g = groups.value[gi];
|
||||||
|
if (!g || !g.fields[fi]) {
|
||||||
|
selected.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selected.value = { gi, fi };
|
||||||
|
}
|
||||||
|
|
||||||
|
function addField(seed: { type: string; name: string }) {
|
||||||
|
if (!groups.value.length) {
|
||||||
|
groups.value.push({ _uid: genUid(), title: "基本信息", fields: [] });
|
||||||
|
}
|
||||||
|
const gi = selected.value?.gi ?? groups.value.length - 1;
|
||||||
|
const f = defaultField(seed.type, nextKey.value, seed.name);
|
||||||
|
groups.value[gi].fields.push(f);
|
||||||
|
select(gi, groups.value[gi].fields.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyField(gi: number, fi: number) {
|
||||||
|
const src = groups.value[gi]?.fields[fi];
|
||||||
|
if (!src) return;
|
||||||
|
const copy: DesignField = cloneDeep(src);
|
||||||
|
copy._uid = genUid();
|
||||||
|
copy.key = nextKey.value(src.type);
|
||||||
|
groups.value[gi].fields.splice(fi + 1, 0, copy);
|
||||||
|
select(gi, fi + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeField(gi: number, fi: number) {
|
||||||
|
groups.value[gi].fields.splice(fi, 1);
|
||||||
|
const s = selected.value;
|
||||||
|
if (s && s.gi === gi) {
|
||||||
|
if (groups.value[gi].fields.length) select(gi, Math.min(s.fi, groups.value[gi].fields.length - 1));
|
||||||
|
else selected.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addGroup() {
|
||||||
|
groups.value.push({ _uid: genUid(), title: `分组${groups.value.length + 1}`, fields: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeGroup(gi: number) {
|
||||||
|
const g = groups.value[gi];
|
||||||
|
if (!g) return;
|
||||||
|
const content = (g.fields || []).length
|
||||||
|
? `分组「${g.title || "未命名"}」内还有 ${g.fields.length} 个字段,删除后字段将一并移除。`
|
||||||
|
: "确定删除该分组吗?";
|
||||||
|
Modal.confirm({
|
||||||
|
title: "删除分组确认",
|
||||||
|
content,
|
||||||
|
onOk: () => {
|
||||||
|
groups.value.splice(gi, 1);
|
||||||
|
const s = selected.value;
|
||||||
|
if (!s) return;
|
||||||
|
if (s.gi === gi) selected.value = null;
|
||||||
|
else if (s.gi > gi) selected.value = { ...s, gi: s.gi - 1 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPreview() {
|
||||||
|
if (!countFields(groups.value)) {
|
||||||
|
Message.info("请先添加字段");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!keyFormatOk.value || duplicateKeys.value.size) {
|
||||||
|
Message.error("字段 key 非法或重复,请检查右侧属性面板");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
previewData.value = {};
|
||||||
|
previewModal.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateBeforeSave(): string | null {
|
||||||
|
const c = code.value.trim();
|
||||||
|
if (!c) return "请输入表单编码";
|
||||||
|
if (!/^[A-Za-z0-9_-]{2,64}$/.test(c)) return "表单编码仅限字母/数字/下划线/中划线,2~64 位";
|
||||||
|
const n = name.value.trim();
|
||||||
|
if (!n) return "请输入表单名称";
|
||||||
|
if (n.length > 128) return "表单名称过长(≤128 字)";
|
||||||
|
if (!countFields(groups.value)) return "请至少添加一个字段";
|
||||||
|
const noLabel = groups.value.flatMap((g) => g.fields).find((f) => !f.label || !f.label.trim());
|
||||||
|
if (noLabel) return `字段「${noLabel.key}」缺少标题(服务端同样必填)`;
|
||||||
|
for (const g of groups.value) {
|
||||||
|
if ((g.title || "").length > 64) return `分组标题过长(≤64):${g.title}`;
|
||||||
|
}
|
||||||
|
if (duplicateKeys.value.size) return "存在重复的字段 key,请修改";
|
||||||
|
if (!keyFormatOk.value) return "存在非法字段 key(仅限字母/数字/下划线,1~64 位)";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!currentSiteCode.value) {
|
||||||
|
Message.error("站点上下文缺失,请从表单列表进入设计器");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const err = validateBeforeSave();
|
||||||
|
if (err) {
|
||||||
|
Message.error(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
await svcFormApi
|
||||||
|
.save(currentSiteCode.value, {
|
||||||
|
id: loadedPublished.value ? undefined : formId.value ?? undefined,
|
||||||
|
code: code.value.trim(),
|
||||||
|
name: name.value.trim(),
|
||||||
|
schemaJson: buildSchemaJson(),
|
||||||
|
})
|
||||||
|
.send();
|
||||||
|
Message.success(
|
||||||
|
loadedPublished.value
|
||||||
|
? `已保存为草稿 v${formVersion.value + 1}。新结构需发布后才对发起页生效`
|
||||||
|
: "已保存为草稿。下一步:在表单列表发布,并在「办事事项」中绑定使用"
|
||||||
|
);
|
||||||
|
router.push("/svc/form");
|
||||||
|
} catch (e: any) {
|
||||||
|
Message.error(e?.message || e || "保存失败");
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!formId.value) return;
|
||||||
|
try {
|
||||||
|
const vo: any = await svcFormApi.get(currentSiteCode.value, formId.value).send();
|
||||||
|
code.value = vo.code;
|
||||||
|
name.value = vo.name;
|
||||||
|
formStatus.value = vo.status ?? 0;
|
||||||
|
formVersion.value = vo.version ?? 1;
|
||||||
|
let parsed: any = {};
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(vo.schemaJson);
|
||||||
|
} catch {
|
||||||
|
parsed = {};
|
||||||
|
}
|
||||||
|
groups.value = restoreGroups(parsed.groups || []);
|
||||||
|
if (!groups.value.length) {
|
||||||
|
groups.value.push({ _uid: genUid(), title: "基本信息", fields: [] });
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
Message.error(e?.message || e || "表单加载失败");
|
||||||
|
router.push("/svc/form");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadSites();
|
||||||
|
const idRaw = route.query.id;
|
||||||
|
formId.value = idRaw !== undefined && idRaw !== null ? Number(idRaw) : null;
|
||||||
|
if (formId.value && !Number.isNaN(formId.value)) {
|
||||||
|
await load();
|
||||||
|
} else {
|
||||||
|
formId.value = null;
|
||||||
|
groups.value = [{ _uid: genUid(), title: "基本信息", fields: [] }];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="designer-page">
|
||||||
|
<div class="designer-head">
|
||||||
|
<div class="head-left">
|
||||||
|
<Button icon="ios-arrow-back" @click="router.push('/svc/form')">返回列表</Button>
|
||||||
|
<Input v-model="name" placeholder="请输入表单名称" style="width: 240px" />
|
||||||
|
<Input
|
||||||
|
v-model="code"
|
||||||
|
:disabled="!!formId"
|
||||||
|
placeholder="表单编码(字母/数字/下划线/中划线)"
|
||||||
|
style="width: 220px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="head-right">
|
||||||
|
<Tag v-if="loadedPublished" color="success">
|
||||||
|
已发布 v{{ formVersion }}(保存将生成新草稿版本)
|
||||||
|
</Tag>
|
||||||
|
<Button @click="openPreview">预览</Button>
|
||||||
|
<Button type="primary" :loading="saving" @click="save">保存</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="designer-body">
|
||||||
|
<ComponentLibrary :next-key="nextKey" @add="addField" />
|
||||||
|
<DesignerCanvas
|
||||||
|
:groups="groups"
|
||||||
|
:selected="selected"
|
||||||
|
:next-key="nextKey"
|
||||||
|
@select="select"
|
||||||
|
@copy="copyField"
|
||||||
|
@remove="removeField"
|
||||||
|
@add="addField"
|
||||||
|
@add-group="addGroup"
|
||||||
|
@remove-group="removeGroup"
|
||||||
|
/>
|
||||||
|
<FieldPropPanel :field="selectedField" :duplicate-keys="duplicateKeys" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal v-model="previewModal" width="720" title="表单预览(发起页渲染效果)">
|
||||||
|
<DynamicForm v-if="previewModal" :schema-json="previewSchemaJson" v-model:form-data="previewData" />
|
||||||
|
<template #footer>
|
||||||
|
<Button @click="previewModal = false">关闭</Button>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.designer-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: #f2f4f8;
|
||||||
|
}
|
||||||
|
.designer-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #e8eaec;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.head-left,
|
||||||
|
.head-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.designer-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,27 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/** 表单管理(modId 25):列表/JSON 编辑(发布版保存=自动升版本)/发布/删除/版本轨迹/绑定明细/schema 预览。 */
|
/** 表单管理(modId 25):列表/发布/删除/版本轨迹/绑定明细。编辑走独立全屏设计器(/svc/form/designer)。 */
|
||||||
import { onMounted, ref } from "vue";
|
import { onMounted, ref } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { Message, Modal } from "view-ui-plus";
|
import { Message, Modal } from "view-ui-plus";
|
||||||
import { svcFormApi } from "@/api/module/svc";
|
import { svcFormApi } from "@/api/module/svc";
|
||||||
import type { SvcFormItemBrief, SvcFormVo } from "@/types/svc";
|
import type { SvcFormItemBrief, SvcFormVo } from "@/types/svc";
|
||||||
import { useSite } from "@/utils/site";
|
import { useSite } from "@/utils/site";
|
||||||
import DynamicForm from "@/components/DynamicForm.vue";
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { currentSiteCode, sites, loadSites, setSite } = useSite();
|
const { currentSiteCode, sites, loadSites, setSite } = useSite();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const list = ref<SvcFormVo[]>([]);
|
const list = ref<SvcFormVo[]>([]);
|
||||||
const modal = ref(false);
|
|
||||||
const versionsModal = ref(false);
|
const versionsModal = ref(false);
|
||||||
const saving = ref(false);
|
|
||||||
const form = ref<any>({ schemaJson: '{\n "groups": [\n { "title": "基本信息", "fields": [\n { "key": "reason", "label": "申请事由", "type": "textarea", "required": true }\n ] }\n ]\n}' });
|
|
||||||
const versionRows = ref<SvcFormVo[]>([]);
|
const versionRows = ref<SvcFormVo[]>([]);
|
||||||
const refsModal = ref(false);
|
const refsModal = ref(false);
|
||||||
const refRows = ref<SvcFormItemBrief[]>([]);
|
const refRows = ref<SvcFormItemBrief[]>([]);
|
||||||
const refFormName = ref("");
|
const refFormName = ref("");
|
||||||
const previewModal = ref(false);
|
|
||||||
const previewData = ref<Record<string, any>>({});
|
|
||||||
|
|
||||||
const statusName = (s: number) => (s === 1 ? "已发布" : s === 2 ? "停用" : "草稿");
|
const statusName = (s: number) => (s === 1 ? "已发布" : s === 2 ? "停用" : "草稿");
|
||||||
const statusColor = (s: number) => (s === 1 ? "success" : "default");
|
const statusColor = (s: number) => (s === 1 ? "success" : "default");
|
||||||
@@ -37,32 +31,12 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function open(row?: SvcFormVo) {
|
/** 新建/编辑均进入独立设计器;已发布版本保存时不传 id,由后端按 code 自动克隆出新草稿版本 */
|
||||||
if (row) {
|
function createForm() {
|
||||||
form.value = { id: row.id, code: row.code, name: row.name, schemaJson: row.schemaJson };
|
router.push("/svc/form/designer");
|
||||||
} else {
|
|
||||||
form.value = { code: "", name: "", schemaJson: form.value.schemaJson };
|
|
||||||
}
|
|
||||||
modal.value = true;
|
|
||||||
}
|
}
|
||||||
|
function editForm(row: SvcFormVo) {
|
||||||
async function save() {
|
router.push(`/svc/form/designer?id=${row.id}`);
|
||||||
// 客户端先过一遍 JSON 合法性(服务端还有契约静态校验)
|
|
||||||
try {
|
|
||||||
JSON.parse(form.value.schemaJson);
|
|
||||||
} catch {
|
|
||||||
Message.error("表单结构不是合法 JSON");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
saving.value = true;
|
|
||||||
try {
|
|
||||||
await svcFormApi.save(currentSiteCode.value, form.value).send();
|
|
||||||
Message.success("已保存为草稿。下一步:发布表单,并在「办事事项」中绑定使用");
|
|
||||||
modal.value = false;
|
|
||||||
load();
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function publish(row: SvcFormVo) {
|
async function publish(row: SvcFormVo) {
|
||||||
@@ -111,18 +85,6 @@ function editRefItem(item: SvcFormItemBrief) {
|
|||||||
router.push(`/svc/item/edit?id=${item.id}`);
|
router.push(`/svc/item/edit?id=${item.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** schema 预览:发布前即可看到表单长相(复用发起页 DynamicForm 渲染器) */
|
|
||||||
function openPreview() {
|
|
||||||
try {
|
|
||||||
JSON.parse(form.value.schemaJson);
|
|
||||||
} catch {
|
|
||||||
Message.error("表单结构不是合法 JSON,无法预览");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
previewData.value = {};
|
|
||||||
previewModal.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadSites();
|
await loadSites();
|
||||||
load();
|
load();
|
||||||
@@ -146,7 +108,8 @@ const cols = [
|
|||||||
<Select :model-value="currentSiteCode" style="width: 240px" @on-change="(c: string) => { setSite(c); load(); }">
|
<Select :model-value="currentSiteCode" style="width: 240px" @on-change="(c: string) => { setSite(c); load(); }">
|
||||||
<Option v-for="s in sites" :key="s.siteCode" :value="s.siteCode">{{ s.name }}({{ s.siteCode }})</Option>
|
<Option v-for="s in sites" :key="s.siteCode" :value="s.siteCode">{{ s.name }}({{ s.siteCode }})</Option>
|
||||||
</Select>
|
</Select>
|
||||||
<Button type="primary" @click="open()">新建表单</Button>
|
<Button type="primary" @click="createForm">新建表单</Button>
|
||||||
|
<div class="bar-tip">拖拽式设计器编辑表单结构,保存为草稿后发布即可在事项发起页使用</div>
|
||||||
</div>
|
</div>
|
||||||
<Table :loading="loading" :columns="cols" :data="list">
|
<Table :loading="loading" :columns="cols" :data="list">
|
||||||
<template #status="{ row }">
|
<template #status="{ row }">
|
||||||
@@ -157,7 +120,7 @@ const cols = [
|
|||||||
<span v-else style="color: #999">未绑定</span>
|
<span v-else style="color: #999">未绑定</span>
|
||||||
</template>
|
</template>
|
||||||
<template #action="{ row }">
|
<template #action="{ row }">
|
||||||
<Button size="small" type="text" @click="open(row)">编辑</Button>
|
<Button size="small" type="text" @click="editForm(row)">编辑</Button>
|
||||||
<Button v-if="row.status === 0" size="small" type="text" @click="publish(row)">发布</Button>
|
<Button v-if="row.status === 0" size="small" type="text" @click="publish(row)">发布</Button>
|
||||||
<Button v-if="!row.referenced" size="small" type="text" @click="router.push('/svc/item')">去绑定</Button>
|
<Button v-if="!row.referenced" size="small" type="text" @click="router.push('/svc/item')">去绑定</Button>
|
||||||
<Button size="small" type="text" @click="showVersions(row)">版本</Button>
|
<Button size="small" type="text" @click="showVersions(row)">版本</Button>
|
||||||
@@ -166,36 +129,6 @@ const cols = [
|
|||||||
</Table>
|
</Table>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Modal v-model="modal" width="720" :title="form.id ? `编辑表单(已发布版本将自动升版)` : '新建表单'">
|
|
||||||
<Form :label-width="90">
|
|
||||||
<FormItem label="表单编码" required>
|
|
||||||
<Input v-model="form.code" :disabled="!!form.id" placeholder="字母/数字/下划线/中划线" />
|
|
||||||
</FormItem>
|
|
||||||
<FormItem label="表单名称" required>
|
|
||||||
<Input v-model="form.name" :maxlength="128" />
|
|
||||||
</FormItem>
|
|
||||||
<FormItem label="表单结构" required>
|
|
||||||
<Input v-model="form.schemaJson" type="textarea" :rows="14" />
|
|
||||||
<div class="tip">
|
|
||||||
契约:{"groups":[{title, fields:[{key,label,type,required,pattern,min,max,maxLength,options,accept,maxSize,maxFiles,…}]}]}
|
|
||||||
;一期控件:text/textarea/number/date/select/radio/upload
|
|
||||||
</div>
|
|
||||||
<Button size="small" type="text" @click="openPreview">预览效果</Button>
|
|
||||||
</FormItem>
|
|
||||||
</Form>
|
|
||||||
<template #footer>
|
|
||||||
<Button @click="modal = false">取消</Button>
|
|
||||||
<Button type="primary" :loading="saving" @click="save">保存</Button>
|
|
||||||
</template>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal v-model="previewModal" width="640" title="表单预览(发起页渲染效果)">
|
|
||||||
<DynamicForm :schema-json="form.schemaJson" v-model:form-data="previewData" />
|
|
||||||
<template #footer>
|
|
||||||
<Button @click="previewModal = false">关闭</Button>
|
|
||||||
</template>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal v-model="refsModal" width="720" :title="`「${refFormName}」被以下事项使用`">
|
<Modal v-model="refsModal" width="720" :title="`「${refFormName}」被以下事项使用`">
|
||||||
<Table size="small" :columns="[
|
<Table size="small" :columns="[
|
||||||
{ title: '站点', key: 'siteCode', width: 100 },
|
{ title: '站点', key: 'siteCode', width: 100 },
|
||||||
@@ -238,6 +171,11 @@ const cols = [
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.bar-tip {
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.tip {
|
.tip {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
Reference in New Issue
Block a user