From 1810c6a56008f502ca4e85cc1b9ee6fbc443290a Mon Sep 17 00:00:00 2001 From: chenxing Date: Wed, 16 Sep 2026 20:21:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A1=A8=E5=8D=95=E6=8B=96=E6=8B=BD?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E5=99=A8=EF=BC=88/svc/form/designer=EF=BC=89?= =?UTF-8?q?+=20=E6=B8=B2=E6=9F=93=E5=99=A8=E8=A1=A5=20checkbox/multiselect?= =?UTF-8?q?/image?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增独立全屏设计器:左组件库(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 绿。 --- src/components/DynamicForm.vue | 73 ++++- src/components/designer/ComponentLibrary.vue | 96 ++++++ src/components/designer/DesignerCanvas.vue | 230 +++++++++++++ src/components/designer/FieldPropPanel.vue | 237 ++++++++++++++ src/router/routes.ts | 6 + src/types/designer.ts | 70 ++++ src/utils/designer/schema.ts | 142 ++++++++ src/views/svc/FormDesigner.vue | 324 +++++++++++++++++++ src/views/svc/ServiceFormManage.vue | 92 +----- 9 files changed, 1180 insertions(+), 90 deletions(-) create mode 100644 src/components/designer/ComponentLibrary.vue create mode 100644 src/components/designer/DesignerCanvas.vue create mode 100644 src/components/designer/FieldPropPanel.vue create mode 100644 src/types/designer.ts create mode 100644 src/utils/designer/schema.ts create mode 100644 src/views/svc/FormDesigner.vue diff --git a/src/components/DynamicForm.vue b/src/components/DynamicForm.vue index 94a41cd..586e4e1 100644 --- a/src/components/DynamicForm.vue +++ b/src/components/DynamicForm.vue @@ -1,8 +1,9 @@ + + + + \ No newline at end of file diff --git a/src/components/designer/DesignerCanvas.vue b/src/components/designer/DesignerCanvas.vue new file mode 100644 index 0000000..c2a5eed --- /dev/null +++ b/src/components/designer/DesignerCanvas.vue @@ -0,0 +1,230 @@ + + + + + \ No newline at end of file diff --git a/src/components/designer/FieldPropPanel.vue b/src/components/designer/FieldPropPanel.vue new file mode 100644 index 0000000..c07429f --- /dev/null +++ b/src/components/designer/FieldPropPanel.vue @@ -0,0 +1,237 @@ + + + + + \ No newline at end of file diff --git a/src/router/routes.ts b/src/router/routes.ts index 3880cb3..cd9ad56 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -36,6 +36,12 @@ const routes: RouteRecordRaw[] = [ component: () => import("@/views/svc/ServiceFormManage.vue"), 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", diff --git a/src/types/designer.ts b/src/types/designer.ts new file mode 100644 index 0000000..1311c5e --- /dev/null +++ b/src/types/designer.ts @@ -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}$/; \ No newline at end of file diff --git a/src/utils/designer/schema.ts b/src/utils/designer/schema.ts new file mode 100644 index 0000000..56513ee --- /dev/null +++ b/src/utils/designer/schema.ts @@ -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 = { + 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): (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 = { + 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 { + const o: Record = {}; + for (const k of ALLOWED_FIELD_PROPS) { + const v = (f as Record)[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[]): DesignGroup[] { + return (groups || []).map((g) => ({ + _uid: genUid(), + title: g.title || "", + fields: ((g.fields as Record[]) || []).map((raw) => ({ + ...(raw as DesignField), + _uid: genUid(), + })), + })); +} + +/** 收集全表单已占用的 key(校验 + key 自动生成) */ +export function collectUsedKeys(groups: DesignGroup[]): Set { + const set = new Set(); + 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); +} \ No newline at end of file diff --git a/src/views/svc/FormDesigner.vue b/src/views/svc/FormDesigner.vue new file mode 100644 index 0000000..5a5e0e4 --- /dev/null +++ b/src/views/svc/FormDesigner.vue @@ -0,0 +1,324 @@ + + + + + \ No newline at end of file diff --git a/src/views/svc/ServiceFormManage.vue b/src/views/svc/ServiceFormManage.vue index 6b5386c..54804b9 100644 --- a/src/views/svc/ServiceFormManage.vue +++ b/src/views/svc/ServiceFormManage.vue @@ -1,27 +1,21 @@