fix: 修复动态表单保存校验失败等问题,优化演示页源码入口与注释

This commit is contained in:
Ray.Hao committed 2026-09-08 16:34:32 +08:00
1 parent 92a7046634
commit 443c34ed20
22 files changed
+186 -274

No files matched your search

+1 -4
View File
@@ -1,4 +1 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 4h6v6H4V4Zm10 0h6v6h-6V4ZM4 14h6v6H4v-6Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
<path d="M14 14h2.5v2.5H14V14Zm4 0h2v2m-6 2h2v2m2-2h2v2h-2v-2Z" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<svg t="1788842704919" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="24588" width="200" height="200"><path d="M928.016126 543.908618 95.983874 543.908618c-17.717453 0-31.994625-14.277171-31.994625-31.994625S78.26642 479.919368 95.983874 479.919368l832.032253 0c17.717453 0 31.994625 14.277171 31.994625 31.994625S945.73358 543.908618 928.016126 543.908618z" fill="#575B66" p-id="24589"></path><path d="M832.032253 928.016126 639.892491 928.016126c-17.717453 0-31.994625-14.277171-31.994625-31.994625s14.277171-31.994625 31.994625-31.994625l191.967747 0c17.717453 0 31.994625-14.277171 31.994625-31.994625l0-159.973123c0-17.717453 14.277171-31.994625 31.994625-31.994625s31.994625 14.277171 31.994625 31.994625l0 159.973123C928.016126 884.840585 884.840585 928.016126 832.032253 928.016126z" fill="#575B66" p-id="24590"></path><path d="M351.94087 928.016126l-159.973123 0c-52.980346 0-95.983874-43.003528-95.983874-95.983874l0-159.973123c0-17.717453 14.277171-31.994625 31.994625-31.994625S159.973123 654.341676 159.973123 672.05913l0 159.973123c0 17.717453 14.449185 31.994625 31.994625 31.994625l159.973123 0c17.717453 0 31.994625 14.277171 31.994625 31.994625C383.935495 913.738955 369.658324 928.016126 351.94087 928.016126z" fill="#575B66" p-id="24591"></path><path d="M127.978498 383.935495c-17.717453 0-31.994625-14.277171-31.994625-31.994625l0-159.973123c0-52.980346 43.003528-95.983874 95.983874-95.983874l159.973123 0c17.717453 0 31.994625 14.277171 31.994625 31.994625S369.658324 159.973123 351.94087 159.973123l-159.973123 0c-17.545439 0-31.994625 14.449185-31.994625 31.994625l0 159.973123C159.973123 369.658324 145.695952 383.935495 127.978498 383.935495z" fill="#575B66" p-id="24592"></path><path d="M896.021502 383.935495c-17.717453 0-31.994625-14.277171-31.994625-31.994625l0-159.973123c0-17.545439-14.277171-31.994625-31.994625-31.994625L639.892491 159.973123c-17.717453 0-31.994625-14.277171-31.994625-31.994625s14.277171-31.994625 31.994625-31.994625l191.967747 0c52.980346 0 95.983874 43.003528 95.983874 95.983874l0 159.973123C928.016126 369.658324 913.738955 383.935495 896.021502 383.935495z" fill="#575B66" p-id="24593"></path></svg>

Before

Width:  |  Height:  |  Size: 359 B

After

Width:  |  Height:  |  Size: 2.2 KiB

+10 -6
View File
@@ -1,14 +1,18 @@
<!-- 字典组件示例 -->
<template>
<div class="page-container">
<el-link
href="https://gitee.com/youlaiorg/vue3-element-admin/blob/master/src/views/demo/component/dictionary.vue"
type="primary"
target="_blank"
<el-button
class="mb-[20px]"
tag="a"
href="https://gitee.com/youlaiorg/vue3-element-admin/blob/master/src/views/demo/component/dictionary.vue"
target="_blank"
link
type="info"
size="small"
>
示例源码 请点击>>>
</el-link>
<el-icon class="mr-1"><Link /></el-icon>
示例源码
</el-button>
<el-form>
<el-form-item label="性别">
<DictSelect v-model="stringValue" code="gender" />
+10 -6
View File
@@ -1,14 +1,18 @@
<!-- 图标选择器示例 -->
<template>
<div class="page-container">
<el-link
href="https://gitee.com/youlaiorg/vue3-element-admin/blob/master/src/views/demo/component/icon-select.vue"
type="primary"
target="_blank"
<el-button
class="mb-10"
tag="a"
href="https://gitee.com/youlaiorg/vue3-element-admin/blob/master/src/views/demo/component/icon-select.vue"
target="_blank"
link
type="info"
size="small"
>
示例源码 请点击>>>
</el-link>
<el-icon class="mr-1"><Link /></el-icon>
示例源码
</el-button>
<IconSelect v-model="iconName" />
</div>
</template>
@@ -1,14 +1,18 @@
<!-- 列表选择器示例 -->
<template>
<div class="page-container">
<el-link
href="https://gitee.com/youlaiorg/vue3-element-admin/blob/master/src/views/demo/component/table-select/index.vue"
type="primary"
target="_blank"
<el-button
class="mb-10"
tag="a"
href="https://gitee.com/youlaiorg/vue3-element-admin/blob/master/src/views/demo/component/table-select/index.vue"
target="_blank"
link
type="info"
size="small"
>
示例源码 请点击>>>
</el-link>
<el-icon class="mr-1"><Link /></el-icon>
示例源码
</el-button>
<table-select :text="text" :select-config="selectConfig" @confirm-click="handleConfirm">
<template #status="scope">
<el-tag :type="scope.row[scope.prop as string] === 1 ? 'success' : 'info'">
+10 -6
View File
@@ -1,14 +1,18 @@
<!-- wangEditor富文本编辑器示例 -->
<template>
<div class="page-container">
<el-link
href="https://gitee.com/youlaiorg/vue3-element-admin/blob/master/src/views/demo/component/wang-editor.vue"
type="primary"
target="_blank"
<el-button
class="mb-[20px]"
tag="a"
href="https://gitee.com/youlaiorg/vue3-element-admin/blob/master/src/views/demo/component/wang-editor.vue"
target="_blank"
link
type="info"
size="small"
>
示例源码 请点击>>>
</el-link>
<el-icon class="mr-1"><Link /></el-icon>
示例源码
</el-button>
<WangEditor v-model="value" height="400px" />
<div style="margin-top: 10px">
+11 -7
View File
@@ -1,14 +1,18 @@
<!-- 文件上传组件示例 -->
<!-- 自适应表格操作栏组件示例 -->
<template>
<div class="page-container">
<el-link
href="https://gitee.com/youlaiorg/vue3-element-admin/blob/master/src/views/demo/table/auto-operation-column.vue"
type="primary"
target="_blank"
<el-button
class="mb-10"
tag="a"
href="https://gitee.com/youlaiorg/vue3-element-admin/blob/master/src/views/demo/table/auto-operation-column.vue"
target="_blank"
link
type="info"
size="small"
>
示例源码 请点击>>>
</el-link>
<el-icon class="mr-1"><Link /></el-icon>
示例源码
</el-button>
<div>
<h3>自适应表格操作栏</h3>
+8 -4
View File
@@ -1,13 +1,17 @@
<template>
<div class="page-container h-full flex flex-1 flex-col">
<div class="mb-10">
<el-link
<el-button
tag="a"
href="https://gitee.com/youlaiorg/vue3-element-admin/blob/master/src/views/demo/table/curd-single.vue"
type="primary"
target="_blank"
link
type="info"
size="small"
>
整合版示例源码 请点击 >>>
</el-link>
<el-icon class="mr-1"><Link /></el-icon>
示例源码
</el-button>
</div>
<!-- 搜索 -->
+8 -4
View File
@@ -1,13 +1,17 @@
<template>
<div class="page-container h-full flex flex-1 flex-col">
<div class="flex-x-between mb-10">
<el-link
<el-button
tag="a"
href="https://gitee.com/youlaiorg/vue3-element-admin/blob/master/src/views/demo/table/curd/index.vue"
type="primary"
target="_blank"
link
type="info"
size="small"
>
示例源码 请点击>>>
</el-link>
<el-icon class="mr-1"><Link /></el-icon>
示例源码
</el-button>
<el-button type="primary" plain round size="small" @click="isA = !isA">切换示例</el-button>
</div>
@@ -34,12 +34,7 @@ defineOptions({
inheritAttrs: false,
});
/**
* 表单渲染组件
*
* @description 填写页、公开填写页、设计器预览页共用,保证三处渲染效果一致;
* 规则加载与提交落库由各承载页实现,本组件只负责渲染、校验与数据抛出
*/
/** 表单渲染组件(填写/公开/预览三页共用;规则加载与提交由承载页实现) */
const props = defineProps<{
/** 表单标题(卡片头展示) */
title: string;
@@ -63,10 +58,7 @@ const emit = defineEmits<{
const formApi = ref();
const formData = ref<Record<string, unknown>>({});
/**
* 从成功态返回填写态时清空已提交数据:
* form-create 随 v-if 重新挂载会重新应用规则默认值,此处仅需清掉残留提交值
*/
// 返回填写态时清掉残留提交值(重新挂载会重新应用规则默认值)
watch(
() => props.submitted,
(submitted) => {
@@ -76,9 +68,7 @@ watch(
}
);
/**
* 校验通过后抛出表单数据(浅拷贝,避免承载页异步提交期间数据被继续编辑污染)
*/
// 抛出表单数据(浅拷贝,防异步提交期间被继续编辑污染)
function handleSubmit(): void {
emit("submit", { ...formData.value });
}
@@ -91,13 +91,7 @@ defineOptions({
name: "FormPublishDialog",
});
/**
* 表单发布/入口管理向导容器
*
* @description 三步向导(方式选择 → 入口配置 → 发布完成)的状态与编排容器:
* 数据加载、保存编排(发布 → 生成菜单 → 保存公开开关)在容器完成,
* 各步骤展示细节由 publish/ 目录下的步骤组件承担
*/
/** 发布/入口管理三步向导容器 */
const props = defineProps<{
/** 表单ID */
formId: string;
@@ -119,7 +113,7 @@ const visible = defineModel("modelValue", {
default: false,
});
/** 表单是否已发布(决定向导模式:发布流程 / 入口管理直达第②步) */
/** 表单是否已发布(已发布直达入口配置步骤) */
const isPublished = computed(() => props.status === FormStatus.PUBLISHED);
/** 当前向导步骤 */
@@ -149,13 +143,10 @@ const entryConfigRef = ref<InstanceType<typeof EntryConfigStep>>();
/** 目录类型标识(sys_menu.type:C 目录) */
const MENU_TYPE_CATALOG = "C";
/** 默认挂载目录名称:挂在"动态表单"目录下,管理菜单不授权给填写角色即不渲染 */
/** 默认挂载目录名称 */
const DEFAULT_CATALOG_NAME = "表单中心";
/**
* 是否展示菜单入口配置块:
* 已发布(入口管理)统一管理两种入口;未发布按第①步所选方式渲染
*/
/** 是否展示菜单入口配置块(已发布恒显示,未发布按第①步所选) */
const showMenuConfig = computed(() =>
isPublished.value ? true : selectedMethods.value.includes("menu")
);
@@ -176,7 +167,7 @@ const shareUrl = computed(
() => `${window.location.origin}${window.location.pathname}#/f/${props.formKey}`
);
/** 第③步分享汇总可见:本次配置了分享且公开开关开启 */
/** 第③步分享汇总可见(配置了分享且开关开启) */
const shareResultVisible = computed(() => showShareConfig.value && shareEnabled.value);
/** 第③步副标题 */
@@ -194,9 +185,7 @@ watch(visible, async (newVisible) => {
await loadWizardData();
});
/**
* 加载向导数据:目录树、角色选项、已生成菜单配置回显
*/
// 加载目录树、角色选项与已生成菜单配置
async function loadWizardData(): Promise<void> {
const [menuTree, roles, menu] = await Promise.all([
MenuAPI.getList({}),
@@ -206,20 +195,19 @@ async function loadWizardData(): Promise<void> {
catalogTree.value = buildCatalogTree(menuTree);
roleOptions.value = roles;
// 回显已生成的菜单配置;未生成过时菜单名默认表单名、父级默认"表单中心"
// 未生成过菜单时:名称默认表单名、父级默认"表单中心"
menuConfig.value = menu;
Object.assign(menuForm, {
menuName: menu?.menuName ?? props.formName,
parentId: menu?.parentId ?? findDefaultCatalogId(),
roleIds: menu?.roleIds ? [...menu.roleIds] : [],
});
// 回显公开开关(列表行传入,未开启过为 0)
// 回显公开开关
shareEnabled.value = props.isPublic === 1;
}
/**
* 切换发布方式选中态(可单选可全选)
*
* 切换发布方式选中态
* @param method 发布方式
*/
function toggleMethod(method: PublishMethod): void {
@@ -233,7 +221,6 @@ function toggleMethod(method: PublishMethod): void {
/**
* 构建目录树(仅目录类型可挂载表单菜单)
*
* @param menus 菜单树
*/
function buildCatalogTree(menus: MenuItem[]): CatalogNode[] {
@@ -247,9 +234,7 @@ function buildCatalogTree(menus: MenuItem[]): CatalogNode[] {
}));
}
/**
* 查找默认挂载目录ID:递归查找"表单中心"(已生成过则预选);不存在返回空串(留空=后端自动创建)
*/
// 递归查找"表单中心"目录ID;不存在返回空串(留空由后端自动创建)
function findDefaultCatalogId(): string {
const find = (nodes: CatalogNode[]): string => {
for (const node of nodes) {
@@ -266,14 +251,8 @@ function findDefaultCatalogId(): string {
return find(catalogTree.value);
}
/**
* 菜单路径面包屑:选中目录的名称链 + 菜单名称
* 留空(默认目录)时展示后端实际挂载位置:动态表单 / 表单中心
*/
/** 菜单位置面包屑(目录名称链 + 菜单名) */
const menuBreadcrumb = computed(() => {
if (!menuForm.parentId) {
return ["动态表单", DEFAULT_CATALOG_NAME, menuForm.menuName];
}
const names: string[] = [];
const find = (nodes: CatalogNode[], chain: string[]): boolean => {
for (const node of nodes) {
@@ -297,14 +276,8 @@ const grantedRoleNames = computed(() =>
.map((role) => role.label)
);
/**
* 生成菜单的完整路由路径:目录路径链 + 表单标识
* 留空(默认目录)时为后端标准挂载位置:/form/center/{formKey}
*/
/** 菜单完整路由路径(目录路径链 + 表单标识) */
const menuRoutePath = computed(() => {
if (!menuForm.parentId) {
return `/form/center/${props.formKey}`;
}
let routePath = "";
const find = (nodes: CatalogNode[], basePath: string): boolean => {
for (const node of nodes) {
@@ -324,9 +297,7 @@ const menuRoutePath = computed(() => {
return routePath;
});
/**
* 保存入口配置:未发布表单先发布(版本+1),再按所选方式生成菜单/保存公开开关
*/
// 保存入口配置:未发布先发布,再生成菜单/保存公开开关
async function handleSaveEntry(): Promise<void> {
if (showMenuConfig.value) {
const valid = (await entryConfigRef.value?.validate()) ?? true;
@@ -341,14 +312,18 @@ async function handleSaveEntry(): Promise<void> {
if (showMenuConfig.value) {
await FormAPI.saveFormMenu(props.formId, {
menuName: menuForm.menuName,
// 留空不传:后端自动使用/创建默认目录"表单中心"
parentId: menuForm.parentId || undefined,
parentId: menuForm.parentId,
roleIds: menuForm.roleIds,
});
}
if (showShareConfig.value) {
// isPublic 独立提交:仅配置分享入口时才写开关,避免菜单-only 保存意外重置公开状态
await FormAPI.update(props.formId, { isPublic: shareEnabled.value ? 1 : 0 });
// isPublic 独立提交,避免菜单-only 保存意外重置公开状态
// formKey/formName 原样回传满足后端非空校验(已发布态后端不更新元数据)
await FormAPI.update(props.formId, {
formKey: props.formKey,
formName: props.formName,
isPublic: shareEnabled.value ? 1 : 0,
});
}
step.value = 2;
emit("success");
@@ -357,9 +332,7 @@ async function handleSaveEntry(): Promise<void> {
}
}
/**
* 复制分享链接
*/
// 复制分享链接
function handleCopyLink(): void {
navigator.clipboard
?.writeText(shareUrl.value)
@@ -367,9 +340,7 @@ function handleCopyLink(): void {
.catch(() => ElMessage.warning("复制失败,请手动复制"));
}
/**
* 前往查看:重载动态路由(菜单为新建,当前路由表未包含)后跳转
*/
// 重载动态路由(新建菜单需重新拉取路由表)后跳转
async function handleGoView(): Promise<void> {
if (!menuRoutePath.value) {
ElMessage.warning("未能解析菜单路径,请刷新页面后从侧边栏进入");
@@ -381,9 +352,7 @@ async function handleGoView(): Promise<void> {
router.push(menuRoutePath.value);
}
/**
* 关闭向导并重置步骤
*/
// 关闭向导并重置步骤
function handleClose(): void {
step.value = 0;
}
@@ -6,7 +6,7 @@
ref="menuFormRef"
:model="menuForm"
:rules="rules"
label-width="80px"
label-width="92px"
class="entry-section__form"
>
<el-form-item label="菜单名称" prop="menuName">
@@ -22,10 +22,7 @@
<template #label>
<div class="flex-y-center">
上级菜单
<el-tooltip placement="bottom">
<template #content>
留空时后端自动创建"表单中心"目录(挂在"动态表单"下);仅目录类型可选
</template>
<el-tooltip content="表单入口挂载的目录位置,仅目录类型可选" placement="bottom">
<el-icon class="ml-1 cursor-pointer">
<QuestionFilled />
</el-icon>
@@ -34,10 +31,9 @@
</template>
<el-tree-select
v-model="menuForm.parentId"
placeholder="留空默认:表单中心"
placeholder="请选择上级菜单"
:data="catalogTree"
filterable
clearable
check-strictly
:render-after-expand="false"
class="!w-full"
@@ -86,7 +82,7 @@
<!-- 对外分享分区 -->
<EntrySection v-if="showShare" kind="share" title="对外分享" sub="匿名链接/二维码进入">
<el-form label-width="80px" class="entry-section__form">
<el-form label-width="92px" class="entry-section__form">
<el-form-item>
<template #label>
<div class="flex-y-center">
@@ -142,12 +138,7 @@ defineOptions({
name: "FormPublishEntryConfigStep",
});
/**
* 入口配置(向导第②步)
*
* @description 菜单入口(名称/目录/角色)与公开分享开关的表单分区;
* 表单数据与开关由容器持有(defineModel 双向绑定),校验能力通过 expose 提供给容器保存前调用
*/
/** 入口配置(向导第②步;数据由容器持有,校验经 expose 供容器保存前调用) */
const props = defineProps<{
/** 是否展示菜单入口配置块 */
showMenu: boolean;
@@ -171,11 +162,12 @@ const shareEnabled = defineModel<boolean>("shareEnabled", { required: true });
const menuFormRef = ref<FormInstance>();
/** 菜单表单校验规则:菜单名称必填;上级菜单留空 = 默认目录"表单中心"(后端自动创建) */
/** 菜单表单校验规则:菜单名称与上级菜单必填 */
const rules = computed<FormRules<FormMenuFormData>>(() =>
props.showMenu
? {
menuName: [{ required: true, message: "请输入菜单名称", trigger: "blur" }],
parentId: [{ required: true, message: "请选择上级菜单", trigger: "change" }],
}
: {}
);
@@ -187,8 +179,7 @@ function handleSelectAllRoles(): void {
/**
* 校验菜单表单(容器保存前调用)
*
* @return 校验是否通过;未配置菜单入口时恒通过
* @return 未配置菜单入口时恒通过
*/
function validate(): Promise<boolean> {
if (!props.showMenu || !menuFormRef.value) {
@@ -213,6 +204,11 @@ defineExpose({ validate });
.entry-section__form {
padding: 16px 14px 0;
// label 带提示图标,禁止折行
:deep(.el-form-item__label) {
white-space: nowrap;
}
}
/* 角色多选下拉 header:全选 + 计数 */
@@ -19,12 +19,7 @@ defineOptions({
name: "FormPublishEntrySection",
});
/**
* 入口分区卡片
*
* @description 发布向导第②步(入口配置)与第③步(发布完成)共用的分区容器,
* 左侧 3px 色条区分两种形态(蓝=系统内嵌 绿=对外分享)
*/
/** 入口分区卡片(发布向导②③步共用,左侧色条区分内嵌/分享形态) */
defineProps<{
/** 分区形态:menu 系统内嵌 / share 对外分享 */
kind: "menu" | "share";
@@ -47,11 +47,7 @@ defineOptions({
name: "FormPublishMethodStep",
});
/**
* 发布方式选择(向导第①步)
*
* @description 卡片可单选可全选;已生成过入口/已开启分享的形态带标签提示
*/
/** 发布方式选择(向导第①步;卡片单选/全选,已配置过的入口带标签提示) */
defineProps<{
/** 选中的发布方式 */
selected: PublishMethod[];
@@ -55,11 +55,7 @@ defineOptions({
name: "FormPublishResultStep",
});
/**
* 发布完成汇总(向导第③步)
*
* @description 展示保存后的最终入口配置:菜单位置面包屑、可见角色、分享二维码与链接
*/
/** 发布完成汇总(向导第③步:菜单位置、可见角色、分享二维码与链接) */
const props = defineProps<{
/** 是否展示菜单入口汇总 */
showMenu: boolean;
@@ -85,7 +81,7 @@ const emit = defineEmits<{
/** 分享二维码画布 */
const qrCanvasRef = ref<HTMLCanvasElement>();
// 本组件仅在向导第③步渲染(v-else-if 挂载),此时配置已定,挂载后绘制一次即可
// 仅在第③步渲染(v-else-if 挂载),配置已定,挂载后绘制一次即可
onMounted(async () => {
if (!props.shareVisible) return;
await nextTick();
@@ -1,9 +1,7 @@
/**
* 表单规则加载与渲染状态
*
* @description
* 填写页(render.vue)、公开填写页(share.vue)、设计器预览页(preview.vue)共用,
* 保证三处走同一条渲染管线:相同的规则解析、提交按钮策略与成功态切换
* 填写页(render)、公开页(share)、预览页(preview)共用同一渲染管线
*/
import { ref, shallowRef } from "vue";
@@ -25,17 +23,15 @@ export interface FormRuleSource {
export function useFormRenderer<T extends FormRuleSource = FormRuleSource>(
loader: () => Promise<T | null | undefined>
) {
// shallowRef:规则与配置整体加载整体替换,无需深层响应式;
// 深响应式代理会破坏 form-create Rule 内部 Creator 结构(模板 UnwrapRef 深映射导致类型失配)
// shallowRef:深响应式代理会破坏 form-create Rule 内部 Creator 结构
const rule = shallowRef<Rule[]>([]);
const option = shallowRef<Options>({ submitBtn: true });
const loading = ref(false);
const submitted = ref(false);
/**
* 加载并解析规则,返回接口原始数据
*
* 强制开启提交按钮:填写页必须有提交入口,设计器保存时可能关掉了它
* 加载并解析规则
* @returns 接口原始数据
*/
async function load(): Promise<T | undefined> {
loading.value = true;
@@ -43,6 +39,7 @@ export function useFormRenderer<T extends FormRuleSource = FormRuleSource>(
const data = await loader();
rule.value = data?.formJson ? JSON.parse(data.formJson) : [];
const parsedOption: Options = data?.optionsJson ? JSON.parse(data.optionsJson) : {};
// 强制开启提交按钮:填写页必须有提交入口,设计器保存时可能关掉了它
option.value = { ...parsedOption, submitBtn: true };
return data ?? undefined;
} finally {
@@ -50,7 +47,7 @@ export function useFormRenderer<T extends FormRuleSource = FormRuleSource>(
}
}
/** 回到填写态(已填数据清空由 FormRenderer 处理) */
// 回到填写态(已填数据清空由 FormRenderer 处理)
function refill(): void {
submitted.value = false;
}
+15 -31
View File
@@ -55,7 +55,8 @@
{{ formatCellValue(getFieldValue(scope.row as FormDataItem, field.field), field) }}
</template>
</el-table-column>
<el-table-column label="提交人" width="120" align="center" show-overflow-tooltip>
<!-- 弹性列:吸收剩余宽度,避免表格右侧留白 -->
<el-table-column label="提交人" min-width="120" align="center" show-overflow-tooltip>
<template #default="scope">
{{ (scope.row as FormDataItem).createByName || "匿名" }}
</template>
@@ -140,10 +141,10 @@ defineOptions({
const route = useRoute();
/** 表单唯一标识(列表"数据"按钮 query 携带,复刻 dict-item 模式) */
/** 表单唯一标识(列表"数据"按钮携带) */
const formKey = computed(() => String(route.query.formKey ?? ""));
/** 页面标题(列表页携带,如【入职信息采集】数据) */
/** 页面标题(列表页携带) */
const title = computed(() => String(route.query.title ?? "表单数据"));
const tableWrapperRef = ref<HTMLElement | null>(null);
@@ -152,10 +153,10 @@ const { toggle: toggleFullscreen } = useFullscreen(tableWrapperRef);
/** 表单名称(导出文件名用) */
const formName = ref("");
/** 导出中状态(防止重复触发全量拉取) */
/** 导出中状态(防重复触发) */
const exporting = ref(false);
/** 表单规则(详情抽屉只读回显复用;shallowRef 避免深代理破坏 Rule 内部的 Creator 结构) */
/** 表单规则(shallowRef 避免深代理破坏 Rule 内部 Creator 结构) */
const rule = shallowRef<Rule[]>([]);
/** 从表单规则提取的字段元数据(field + title + 选项映射) */
@@ -202,11 +203,7 @@ onMounted(async () => {
fetchData();
});
/**
* 行数据解析缓存(行ID -> field/value 映射)
*
* 避免模板渲染时每行每列各解析一次同一行 dataJson
*/
/** 行数据解析缓存(行ID -> 字段值映射,避免模板重复解析 dataJson) */
const rowDataMap = computed(() => {
const map = new Map<string, Record<string, unknown>>();
list.value.forEach((row) => map.set(row.id, parseDataJson(row.dataJson)));
@@ -215,7 +212,6 @@ const rowDataMap = computed(() => {
/**
* 取行数据的指定字段值
*
* @param row 数据行
* @param field 字段名
*/
@@ -223,33 +219,27 @@ function getFieldValue(row: FormDataItem, field: string): unknown {
return rowDataMap.value.get(row.id)?.[field];
}
/**
* 打开详情抽屉并只读回显
*
* 规则优先加载后端返回的提交时版本快照,防止表单改版(字段删除/改名)导致历史数据回显漂移;
* 无快照(快照表启用前提交的历史数据)回退当前定义
*/
// 打开详情抽屉只读回显(优先提交时版本快照,防止表单改版后历史数据漂移)
async function handleDetailClick(row: FormDataItem): Promise<void> {
const detail = await FormAPI.getFormDataDetail(formKey.value, row.id);
detailRow.value = detail;
detailData.value = detail.dataJson ? JSON.parse(detail.dataJson) : {};
// JSON.parse 产出的规则与页面级渲染规则隔离,可安全写入 disabled 只读态
// 独立 JSON.parse,与页面渲染规则隔离,可安全置为只读
detailRule.value = detail.formJson
? JSON.parse(detail.formJson)
: JSON.parse(JSON.stringify(rule.value));
// 全局配置随快照回显;强制隐藏提交/重置按钮(快照配置可能开启了它们)
// 强制隐藏提交/重置按钮(快照配置可能开启)
const parsedOption: Options = detail.optionsJson ? JSON.parse(detail.optionsJson) : {};
detailOption.value = { ...parsedOption, submitBtn: false, resetBtn: false };
detailState.visible = true;
// 抽屉内 form-create 挂载完成后再禁用(api 就绪时机在 nextTick 之后)
// form-create 挂载完成后再禁用
await nextTick();
detailApi.value?.disabled(true);
}
/**
* 删除单个或批量表单数据
*
* @param id 指定时删除单条数据;不指定时删除表格勾选项
* @param id 指定时删除单条,否则删除勾选项
*/
async function handleDelete(id?: string): Promise<void> {
const dataIds = id ?? selectedIds.value.join(",");
@@ -279,18 +269,13 @@ async function handleDelete(id?: string): Promise<void> {
}
}
/** 导出分页大小(单页拉取行数,与导出上限配合控制内存占用) */
/** 导出分页大小 */
const EXPORT_PAGE_SIZE = 500;
/** 导出页数上限(超出截断并提示,防止超大表单拖垮浏览器) */
/** 导出页数上限(超出截断提示) */
const EXPORT_MAX_PAGES = 100;
/**
* 导出全量表单数据为 Excel
*
* 动态列 = 表单全部字段(不受列表展示列数上限约束)+ 提交人/版本/提交时间;
* 数据按分页循环拉取全量,exceljs 动态导入按需加载
*/
// 导出全量数据为 Excel(全部字段 + 提交人/版本/时间;分页拉取,exceljs 按需加载)
async function handleExport(): Promise<void> {
if (exporting.value) return;
exporting.value = true;
@@ -333,7 +318,6 @@ async function handleExport(): Promise<void> {
const data = parseDataJson(row.dataJson);
const record: Record<string, unknown> = {};
fields.value.forEach((field) => {
// 与列表展示同规则:选项 value 翻译为 label,时间格式化
record[field.field] = formatCellValue(data[field.field], field);
});
record.createByName = row.createByName || "匿名";
+19 -18
View File
@@ -35,38 +35,45 @@ defineOptions({
const route = useRoute();
/** 表单 ID(设计器始终由列表页"设计"按钮带参进入) */
/** 表单 ID(列表页"设计"按钮携带) */
const formId = computed(() => String(route.query.id ?? ""));
/** 页面标题(列表页携带,如【入职信息采集】表单设计) */
/** 页面标题(列表页携带) */
const title = computed(() => String(route.query.title ?? "表单设计"));
const designerRef = ref();
/** 设计器配置:隐藏自带保存按钮,保存统一走工具栏 */
/** 隐藏设计器自带保存按钮,统一走工具栏 */
const designerConfig = { showSaveBtn: false };
/** 保存中状态 */
const saving = ref(false);
/** 回显已有规则(空规则为空白画布) */
/** 表单定义元数据(保存时回传以满足后端非空校验) */
const formMeta = ref<{ formKey?: string; formName?: string }>({});
// 回显已有规则(空规则为空白画布)
onMounted(async () => {
if (!formId.value) {
ElMessage.error("缺少表单ID参数");
return;
}
const data = await FormAPI.getFormData(formId.value);
formMeta.value = { formKey: data.formKey, formName: data.formName };
if (data.formJson) {
designerRef.value?.setRule(JSON.parse(data.formJson));
}
if (data.optionsJson) {
designerRef.value?.setOption(JSON.parse(data.optionsJson));
if (data.optionsJson || data.formName) {
const option = data.optionsJson ? JSON.parse(data.optionsJson) : {};
// 画布「表单名称」面板回显读 option.formName,而 getOption 保存产出的是面板字段原名
// formCreateFormName,键名不对称需转换;画布未填时用表单定义名称预填
option.formName = option.formName || option.formCreateFormName || data.formName || "";
delete option.formCreateFormName;
designerRef.value?.setOption(option);
}
});
/**
* 保存设计器产出的规则与全局配置
*/
// 保存设计器产出的规则与全局配置
async function handleSave(): Promise<void> {
if (!formId.value) {
ElMessage.error("缺少表单ID参数");
@@ -76,25 +83,19 @@ async function handleSave(): Promise<void> {
const optionsJson = JSON.stringify(designerRef.value?.getOption() ?? {});
saving.value = true;
try {
await FormAPI.update(formId.value, { formJson, optionsJson });
await FormAPI.update(formId.value, { ...formMeta.value, formJson, optionsJson });
ElMessage.success("保存成功");
} finally {
saving.value = false;
}
}
/**
* 返回表单管理列表
*/
// 返回表单列表
function handleBack(): void {
router.back();
}
/**
* 运行态真预览:跳预览页用与填写承载页相同的渲染管线查看效果
*
* 预览读取的是已保存规则,未保存的改动不会体现,需先保存再预览
*/
// 预览读取已保存规则,未保存改动不体现
function handlePreview(): void {
if (!formId.value) {
ElMessage.error("缺少表单ID参数");
+17 -32
View File
@@ -32,7 +32,7 @@
<el-card ref="tableWrapperRef" class="page-content" shadow="never">
<div class="page-toolbar">
<div class="page-toolbar__left">
<!-- 类型过滤视图:空串=全部;切换即查询,替代原"类型列+搜索区类型下拉"双冗余 -->
<!-- 类型过滤:空串=全部,切换即查询 -->
<el-radio-group v-model="params.category" @change="handleQuery">
<el-radio-button value="">全部</el-radio-button>
<el-radio-button v-for="(label, value) in categoryOptions" :key="value" :value="value">
@@ -110,6 +110,7 @@
设计
</el-button>
<el-button
v-if="scope.row.status === FormStatus.PUBLISHED"
type="primary"
link
size="small"
@@ -329,7 +330,7 @@ const dialogState = reactive({
visible: false,
});
/** 新增表单默认值:类型缺省普通表单(工作流表单需明确选择) */
/** 新增表单默认值:类型缺省普通表单 */
const initialFormData: FormDefinitionData = {
category: "normal",
};
@@ -348,9 +349,7 @@ const rules: FormRules<FormDefinitionData> = {
],
};
/**
* 重置表单数据和验证状态
*/
// 重置表单数据和校验状态
function resetForm(): void {
formDefinitionFormRef.value?.resetFields();
formDefinitionFormRef.value?.clearValidate();
@@ -364,17 +363,13 @@ function openDialog(): void {
dialogState.visible = true;
}
/**
* 关闭表单定义弹窗并清理临时状态
*/
// 关闭弹窗并重置
function closeDialog(): void {
dialogState.visible = false;
resetForm();
}
/**
* 打开新增表单定义弹窗:类型按当前过滤视图预选(仍可修改)
*/
// 打开新增弹窗(类型按当前过滤视图预选)
function handleCreateClick(): void {
resetForm();
editingStatus.value = null;
@@ -384,9 +379,8 @@ function handleCreateClick(): void {
}
/**
* 打开编辑表单定义弹窗并回填数据
*
* @param row 当前表单行(携带状态,用于判断类型可否修改)
* 打开编辑弹窗并回填数据
* @param row 当前表单行(携带状态,判断类型可否修改)
*/
async function handleEditClick(row: FormDefinitionItem): Promise<void> {
resetForm();
@@ -397,9 +391,7 @@ async function handleEditClick(row: FormDefinitionItem): Promise<void> {
openDialog();
}
/**
* 校验并提交表单定义
*/
// 校验并提交
async function handleSubmit(): Promise<void> {
const valid = await formDefinitionFormRef.value?.validate().then(
() => true,
@@ -411,7 +403,10 @@ async function handleSubmit(): Promise<void> {
try {
const id = formData.id;
if (id) {
await FormAPI.update(id, formData);
// 仅提交元数据:formData 由回显数据整体赋值,含 formJson/optionsJson,
// 整体回写会用打开弹窗时的旧规则覆盖期间设计器保存的新规则
const { formKey, formName, category, description } = formData;
await FormAPI.update(id, { formKey, formName, category, description });
ElMessage.success("修改成功");
} else {
await FormAPI.create(formData);
@@ -424,9 +419,7 @@ async function handleSubmit(): Promise<void> {
}
}
/**
* 发布向导状态("发布"与已发布表单的"入口管理"共用)
*/
/** 发布向导状态(发布/入口管理共用) */
const publishState = reactive({
visible: false,
formId: "",
@@ -438,8 +431,7 @@ const publishState = reactive({
});
/**
* 打开发布向导(未发布走发布流程,已发布直达入口配置)
*
* 打开发布向导(已发布表单直达入口配置)
* @param row 当前表单行
*/
function openPublishDialog(row: FormDefinitionItem): void {
@@ -453,16 +445,13 @@ function openPublishDialog(row: FormDefinitionItem): void {
});
}
/**
* 发布向导保存成功后刷新列表(表单状态与版本可能已变更)
*/
// 发布后刷新列表(状态与版本可能已变更)
function handlePublishSuccess(): void {
fetchData();
}
/**
* 跳转到指定表单页面并检查路由是否已注册
*
* @param name 路由名称(FormDesigner/FormData)
* @param query 路由参数
*/
@@ -482,7 +471,6 @@ function openFormPage(name: string, query: Record<string, string>): void {
/**
* 跳转到表单数据页
*
* @param row 当前表单行
*/
function openDataPage(row: FormDefinitionItem): void {
@@ -491,7 +479,6 @@ function openDataPage(row: FormDefinitionItem): void {
/**
* 停用表单(已发出去的访问入口立即失效)
*
* @param id 表单 ID
*/
async function handleDisable(id: string): Promise<void> {
@@ -512,8 +499,7 @@ async function handleDisable(id: string): Promise<void> {
/**
* 删除单个或批量表单定义
*
* @param id 指定时删除单个表单;不指定时删除表格勾选项
* @param id 指定时删除单个表单,否则删除勾选项
*/
async function handleDelete(id?: string): Promise<void> {
const formIds = id ?? selectedIds.value.join(",");
@@ -545,7 +531,6 @@ async function handleDelete(id?: string): Promise<void> {
/**
* 跳转到表单设计器页面
*
* @param row 当前表单行
*/
function openDesigner(row: FormDefinitionItem): void {
+4 -4
View File
@@ -37,10 +37,10 @@ defineOptions({
const route = useRoute();
/** 表单 ID(设计器"预览"按钮携参进入) */
/** 表单 ID(设计器"预览"按钮携带) */
const formId = computed(() => String(route.query.id ?? ""));
/** 页面标题(设计器携带,如【入职信息采集】表单预览) */
/** 页面标题(设计器携带) */
const title = computed(() => String(route.query.title ?? "表单预览"));
// 走表单定义接口拉草稿规则:render 接口仅返回已发布表单,预览恰恰要覆盖未发布态
@@ -56,12 +56,12 @@ onMounted(async () => {
await load();
});
/** 预览提交:校验通过即进入成功态,不落库 */
// 预览不落库,校验通过即进入成功态
function handlePreviewSubmit(): void {
submitted.value = true;
}
/** 返回设计器继续编辑 */
// 返回设计器
function handleBack(): void {
router.back();
}
+4 -14
View File
@@ -36,12 +36,7 @@ defineOptions({
const route = useRoute();
const router = useRouter();
/**
* 表单唯一标识
*
* 优先取菜单路由参数(meta.params 由后端菜单 params 转换而来,刷新不丢失);
* query 兜底:侧边栏点击跳转时 LayoutSidebarItem 会将 meta.params 透传为 query
*/
/** 表单唯一标识(优先菜单路由 meta.params,query 兜底) */
const formKey = computed(() => {
const metaParams = route.meta.params as Record<string, unknown> | undefined;
return String(metaParams?.formKey ?? route.query.formKey ?? "");
@@ -63,8 +58,7 @@ onMounted(async () => {
});
/**
* 提交表单数据(FormRenderer 校验通过后触发)
*
* 提交表单数据(校验通过后触发)
* @param data 表单数据(field -> value 映射)
*/
async function handleSubmit(data: Record<string, unknown>): Promise<void> {
@@ -79,14 +73,10 @@ async function handleSubmit(data: Record<string, unknown>): Promise<void> {
}
}
/**
* 是否有权查看收集数据(复用 form:data:list 数据查询权限)
*
* 无权限(匿名分享等)不显示"查看已提交数据"按钮,避免点击后无路由可跳
*/
/** 是否有权查看收集数据(无权限时不显示"查看已提交数据"按钮) */
const canViewData = computed(() => hasPerm(["form:data:list"]));
/** 跳转数据列表(带 formKey 定位表单,列表按提交时间倒序,本次提交在首位) */
// 跳转数据列表(按提交时间倒序,本次提交在首位)
function handleViewData(): void {
router.push({
name: "FormData",
+3 -4
View File
@@ -50,7 +50,7 @@ const formKey = computed(() => String(route.params.formKey ?? ""));
const renderData = ref<FormRenderData>();
const submitting = ref(false);
/** 加载失败提示(接口异常时 request 拦截器已弹错误消息,此处只负责兜底文案) */
/** 加载失败兜底文案(错误消息由拦截器统一弹出) */
const loadError = ref("");
const { rule, option, loading, submitted, load, refill } = useFormRenderer(() =>
@@ -70,8 +70,7 @@ onMounted(async () => {
});
/**
* 匿名提交表单数据(FormRenderer 校验通过后触发)
*
* 匿名提交表单数据(校验通过后触发)
* @param data 表单数据(field -> value 映射)
*/
async function handleSubmit(data: Record<string, unknown>): Promise<void> {
@@ -81,7 +80,7 @@ async function handleSubmit(data: Record<string, unknown>): Promise<void> {
await FormAPI.submitPublicFormData(formKey.value, data);
submitted.value = true;
} catch {
// 提交失败(限流/校验不通过)消息由 request 拦截器统一弹出,无需重复提示;停留填写态可重试
// 失败不重复提示(拦截器已弹),停留填写态可重试
} finally {
submitting.value = false;
}
+6 -17
View File
@@ -1,7 +1,5 @@
/**
* 表单字段解析
*
* @description 从 form-create 规则提取字段元数据、解析提交数据并格式化展示值
* 表单字段解析(规则字段提取、提交数据解析与展示格式化)
*/
/** 表单字段元数据 */
@@ -15,12 +13,8 @@ export interface FormFieldMeta {
}
/**
* 递归提取表单字段元数据
*
* 布局容器(row/col)自身无 field,其子节点递归收集;
* 参数为 JSON 解析产物,结构未经校验,按 unknown 逐层探测
*
* @param rules form-create 规则(JSON 解析值)
* 递归提取表单字段元数据(布局容器的子节点递归收集)
* @param rules form-create 规则(JSON 解析产物,结构未校验)
*/
export function extractFields(rules: unknown): FormFieldMeta[] {
const result: FormFieldMeta[] = [];
@@ -45,11 +39,8 @@ export function extractFields(rules: unknown): FormFieldMeta[] {
}
/**
* 提取字段选项映射(value -> label)
*
* 非对象或缺失 value/label 的脏项跳过
*
* @param options 规则里的 options 数组(JSON 解析值)
* 提取字段选项映射(value -> label,脏项跳过)
* @param options 规则 options 数组
*/
export function extractOptionMap(options: unknown): Map<string, string> {
const optionMap = new Map<string, string>();
@@ -66,7 +57,6 @@ export function extractOptionMap(options: unknown): Map<string, string> {
/**
* 解析提交数据(field -> value 映射,解析失败按空数据兜底)
*
* @param dataJson 数据 JSON 字符串
*/
export function parseDataJson(dataJson?: string): Record<string, unknown> {
@@ -79,9 +69,8 @@ export function parseDataJson(dataJson?: string): Record<string, unknown> {
/**
* 格式化单元格展示值(选项翻译、数组拼接、对象序列化、空值占位)
*
* @param value 字段值
* @param field 字段元数据(选项类字段 value 翻译为 label,未命中映射时原样展示)
* @param field 字段元数据(选项类字段翻译 label,未命中原样展示)
*/
export function formatCellValue(value: unknown, field?: FormFieldMeta): string {
if (value === null || value === undefined || value === "") return "-";