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:
@@ -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">
|
||||
/** 表单管理(modId 25):列表/JSON 编辑(发布版保存=自动升版本)/发布/删除/版本轨迹/绑定明细/schema 预览。 */
|
||||
/** 表单管理(modId 25):列表/发布/删除/版本轨迹/绑定明细。编辑走独立全屏设计器(/svc/form/designer)。 */
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Message, Modal } from "view-ui-plus";
|
||||
import { svcFormApi } from "@/api/module/svc";
|
||||
import type { SvcFormItemBrief, SvcFormVo } from "@/types/svc";
|
||||
import { useSite } from "@/utils/site";
|
||||
import DynamicForm from "@/components/DynamicForm.vue";
|
||||
|
||||
const router = useRouter();
|
||||
const { currentSiteCode, sites, loadSites, setSite } = useSite();
|
||||
const loading = ref(false);
|
||||
const list = ref<SvcFormVo[]>([]);
|
||||
const modal = 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 refsModal = ref(false);
|
||||
const refRows = ref<SvcFormItemBrief[]>([]);
|
||||
const refFormName = ref("");
|
||||
const previewModal = ref(false);
|
||||
const previewData = ref<Record<string, any>>({});
|
||||
|
||||
const statusName = (s: number) => (s === 1 ? "已发布" : s === 2 ? "停用" : "草稿");
|
||||
const statusColor = (s: number) => (s === 1 ? "success" : "default");
|
||||
@@ -37,32 +31,12 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
function open(row?: SvcFormVo) {
|
||||
if (row) {
|
||||
form.value = { id: row.id, code: row.code, name: row.name, schemaJson: row.schemaJson };
|
||||
} else {
|
||||
form.value = { code: "", name: "", schemaJson: form.value.schemaJson };
|
||||
}
|
||||
modal.value = true;
|
||||
/** 新建/编辑均进入独立设计器;已发布版本保存时不传 id,由后端按 code 自动克隆出新草稿版本 */
|
||||
function createForm() {
|
||||
router.push("/svc/form/designer");
|
||||
}
|
||||
|
||||
async function save() {
|
||||
// 客户端先过一遍 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;
|
||||
}
|
||||
function editForm(row: SvcFormVo) {
|
||||
router.push(`/svc/form/designer?id=${row.id}`);
|
||||
}
|
||||
|
||||
async function publish(row: SvcFormVo) {
|
||||
@@ -111,18 +85,6 @@ function editRefItem(item: SvcFormItemBrief) {
|
||||
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 () => {
|
||||
await loadSites();
|
||||
load();
|
||||
@@ -146,7 +108,8 @@ const cols = [
|
||||
<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>
|
||||
</Select>
|
||||
<Button type="primary" @click="open()">新建表单</Button>
|
||||
<Button type="primary" @click="createForm">新建表单</Button>
|
||||
<div class="bar-tip">拖拽式设计器编辑表单结构,保存为草稿后发布即可在事项发起页使用</div>
|
||||
</div>
|
||||
<Table :loading="loading" :columns="cols" :data="list">
|
||||
<template #status="{ row }">
|
||||
@@ -157,7 +120,7 @@ const cols = [
|
||||
<span v-else style="color: #999">未绑定</span>
|
||||
</template>
|
||||
<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.referenced" size="small" type="text" @click="router.push('/svc/item')">去绑定</Button>
|
||||
<Button size="small" type="text" @click="showVersions(row)">版本</Button>
|
||||
@@ -166,36 +129,6 @@ const cols = [
|
||||
</Table>
|
||||
</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}」被以下事项使用`">
|
||||
<Table size="small" :columns="[
|
||||
{ title: '站点', key: 'siteCode', width: 100 },
|
||||
@@ -238,10 +171,15 @@ const cols = [
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.bar-tip {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
.tip {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
Reference in New Issue
Block a user