feat(form): 新增动态表单设计与数据收集功能

- src/api/form: 新增表单定义、渲染、数据收集、菜单发布接口与类型
- src/views/dynamic-form: 新增表单列表、设计器、预览、内嵌填写、公开填写、数据页
- src/plugins/form-create: 注册 form-create 渲染器、设计器与业务组件拖拽规则
- src/router: 新增公开表单分享路由 /f/:formKey,并加入登录守卫白名单
- src/enums/business: 新增 FormStatus 表单状态枚举
- src/layouts/components/LayoutSidebarItem: 支持渲染菜单角标,取自 sys_menu.params.badge
- src/main.ts: 应用启动时调用 setupFormCreate
- package.json: 新增 @form-create/element-ui 与 @form-create/designer 依赖
This commit is contained in:
Ray.Hao committed 2026-09-01 00:17:11 +08:00
1 parent 17ca4c1b7b
commit d36ccf54d5
22 files changed
+7991 -3822

No files matched your search

@@ -0,0 +1,118 @@
<template>
<div class="form-renderer">
<el-card v-loading="loading" class="form-renderer__card" shadow="never">
<template #header>
<div class="form-renderer__header">
<span class="form-renderer__title">{{ title }}</span>
<div class="form-renderer__extra">
<el-tag v-if="version" type="info" effect="plain">v{{ version }}</el-tag>
<slot name="header-extra" />
</div>
</div>
</template>
<!-- 提交成功态:由承载页通过 success 插槽定制后续动作(继续填写/返回设计器等),替代表单区避免重复编辑 -->
<slot v-if="submitted" name="success" />
<form-create
v-else
v-model="formData"
v-model:api="formApi"
:rule="rule"
:option="option"
@submit="handleSubmit"
/>
</el-card>
</div>
</template>
<script setup lang="ts">
import type { Options, Rule } from "@form-create/element-ui";
defineOptions({
name: "FormRenderer",
inheritAttrs: false,
});
/**
* 表单渲染组件
*
* @description 填写页、公开填写页、设计器预览页共用,保证三处渲染效果一致;
* 规则加载与提交落库由各承载页实现,本组件只负责渲染、校验与数据抛出
*/
const props = defineProps<{
/** 表单标题(卡片头展示) */
title: string;
/** 表单版本号(不传则不展示版本标签,如草稿态预览) */
version?: number;
/** form-create 渲染规则 */
rule: Rule[];
/** form-create 全局配置 */
option: Options;
/** 规则加载中 */
loading?: boolean;
/** 已提交成功(true 时以 success 插槽替代表单区) */
submitted?: boolean;
}>();
const emit = defineEmits<{
/** 表单校验通过后的提交(是否落库由承载页决定) */
submit: [data: Record<string, unknown>];
}>();
const formApi = ref();
const formData = ref<Record<string, unknown>>({});
/**
* 从成功态返回填写态时清空已提交数据:
* form-create 随 v-if 重新挂载会重新应用规则默认值,此处仅需清掉残留提交值
*/
watch(
() => props.submitted,
(submitted) => {
if (!submitted) {
formData.value = {};
}
}
);
/**
* 校验通过后抛出表单数据(浅拷贝,避免承载页异步提交期间数据被继续编辑污染)
*/
function handleSubmit(): void {
emit("submit", { ...formData.value });
}
</script>
<style lang="scss" scoped>
.form-renderer {
display: flex;
justify-content: center;
padding: 16px;
&__card {
align-self: flex-start;
width: 100%;
max-width: 900px;
}
&__header {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
}
&__title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
&__extra {
display: flex;
gap: 8px;
align-items: center;
}
}
</style>
@@ -0,0 +1,831 @@
<template>
<el-dialog
v-model="visible"
:title="isPublished ? '入口管理' : '发布表单'"
width="680px"
@close="handleClose"
>
<el-steps :active="step" align-center finish-status="success">
<el-step title="发布方式" />
<el-step title="入口配置" />
<el-step title="发布完成" />
</el-steps>
<!-- 第①步:发布方式选择(可单选可全选,已发布走"入口管理"跳过此步) -->
<div v-if="step === 0" class="publish-method">
<div
class="method-card"
:class="{ 'method-card--active': selectedMethods.includes('menu') }"
@click="toggleMethod('menu')"
>
<div class="method-card__header">
<el-icon :size="18" class="method-card__icon method-card__icon--menu">
<Monitor />
</el-icon>
<div class="method-card__titles">
<span class="method-card__name">系统内嵌</span>
<span class="method-card__name-en">MENU</span>
</div>
<el-tag v-if="menuConfig?.menuId" size="small" type="success">已生成入口</el-tag>
</div>
<p class="method-card__desc">员工登录系统从侧边栏进入填写,需配置可见角色</p>
</div>
<div
class="method-card"
:class="{ 'method-card--active': selectedMethods.includes('share') }"
@click="toggleMethod('share')"
>
<div class="method-card__header">
<el-icon :size="18" class="method-card__icon method-card__icon--share"><Link /></el-icon>
<div class="method-card__titles">
<span class="method-card__name">对外分享</span>
<span class="method-card__name-en">SHARE</span>
</div>
<el-tag v-if="isPublic === 1" size="small" type="success">已开启</el-tag>
</div>
<p class="method-card__desc">微信发链接或扫码即可填写,无需账号</p>
</div>
<div class="publish-method__tip">不确定?两种方式可同时开启</div>
</div>
<!-- 第②步:入口配置(内嵌/分享各自分区卡片,强化两种形态的视觉区分) -->
<div v-else-if="step === 1" class="entry-config">
<!-- 系统内嵌分区:左侧色条 + 图标标题 -->
<section v-if="showMenuConfig" class="entry-section entry-section--menu">
<header class="entry-section__header">
<el-icon :size="16" class="entry-section__icon"><Monitor /></el-icon>
<span class="entry-section__title">系统内嵌</span>
<span class="entry-section__sub">登录后侧边栏菜单进入</span>
</header>
<el-form
ref="menuFormRef"
:model="menuForm"
:rules="rules"
label-width="80px"
class="entry-section__form"
>
<el-form-item label="菜单名称" prop="menuName">
<el-input
v-model="menuForm.menuName"
placeholder="侧边栏展示名称"
maxlength="50"
show-word-limit
/>
</el-form-item>
<el-form-item prop="parentId">
<template #label>
<div class="flex-y-center">
上级菜单
<el-tooltip placement="bottom">
<template #content>
留空时后端自动创建"表单中心"目录(挂在"动态表单"下);仅目录类型可选
</template>
<el-icon class="ml-1 cursor-pointer">
<QuestionFilled />
</el-icon>
</el-tooltip>
</div>
</template>
<el-tree-select
v-model="menuForm.parentId"
placeholder="留空默认:表单中心"
:data="catalogTree"
filterable
clearable
check-strictly
:render-after-expand="false"
class="!w-full"
/>
</el-form-item>
<el-form-item prop="roleIds">
<template #label>
<div class="flex-y-center">
可见角色
<el-tooltip content="授权后无需再去角色管理分配菜单" placement="bottom">
<el-icon class="ml-1 cursor-pointer">
<QuestionFilled />
</el-icon>
</el-tooltip>
</div>
</template>
<el-select
v-model="menuForm.roleIds"
multiple
collapse-tags
collapse-tags-tooltip
clearable
filterable
placeholder="不选时仅超级管理员可见"
class="!w-full"
>
<el-option
v-for="role in roleOptions"
:key="role.value"
:label="role.label"
:value="role.value"
/>
<template #header>
<div class="role-select__header">
<el-button link size="small" @click="handleSelectAllRoles">全选</el-button>
<span class="role-select__count">
{{ menuForm.roleIds?.length ?? 0 }}/{{ roleOptions.length }}
</span>
</div>
</template>
</el-select>
</el-form-item>
</el-form>
</section>
<!-- 对外分享分区 -->
<section v-if="showShareConfig" class="entry-section entry-section--share">
<header class="entry-section__header">
<el-icon :size="16" class="entry-section__icon"><Link /></el-icon>
<span class="entry-section__title">对外分享</span>
<span class="entry-section__sub">匿名链接/二维码进入</span>
</header>
<el-form label-width="80px" class="entry-section__form">
<el-form-item>
<template #label>
<div class="flex-y-center">
公开访问
<el-tooltip
content="开启后可通过链接匿名填写;关闭后已发出去的链接立即失效"
placement="bottom"
>
<el-icon class="ml-1 cursor-pointer">
<QuestionFilled />
</el-icon>
</el-tooltip>
</div>
</template>
<el-switch v-model="shareEnabled" />
</el-form-item>
<el-form-item v-if="shareEnabled">
<template #label>
<div class="flex-y-center">
分享链接
<el-tooltip
content="公开链接无需登录即可提交,请勿在表单中收集敏感信息(如密码)"
placement="bottom"
>
<el-icon class="ml-1 cursor-pointer">
<QuestionFilled />
</el-icon>
</el-tooltip>
</div>
</template>
<el-input :model-value="shareUrl" readonly size="default">
<template #append>
<el-button @click="handleCopyLink">复制</el-button>
</template>
</el-input>
</el-form-item>
</el-form>
</section>
</div>
<!-- 第③步:发布完成汇总(与第②步同构的分区卡片) -->
<div v-else-if="step === 2" class="entry-config">
<section v-if="showMenuConfig" class="entry-section entry-section--menu">
<header class="entry-section__header">
<el-icon :size="16" class="entry-section__icon"><Monitor /></el-icon>
<span class="entry-section__title">系统内嵌</span>
<span class="entry-section__sub">已生效</span>
</header>
<div class="entry-summary">
<div class="entry-summary__row">
<span class="entry-summary__label">菜单位置</span>
<el-breadcrumb separator="/">
<el-breadcrumb-item v-for="name in menuBreadcrumb" :key="name">
{{ name }}
</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="entry-summary__row">
<span class="entry-summary__label">可见角色</span>
<template v-if="grantedRoleNames.length">
<el-tag
v-for="name in grantedRoleNames"
:key="name"
size="small"
type="info"
effect="plain"
>
{{ name }}
</el-tag>
</template>
<span v-else class="entry-summary__empty">未授权,仅超级管理员可见</span>
</div>
<el-button type="primary" class="entry-summary__action" @click="handleGoView">
前往查看
</el-button>
</div>
</section>
<section v-if="shareResultVisible" class="entry-section entry-section--share">
<header class="entry-section__header">
<el-icon :size="16" class="entry-section__icon"><Link /></el-icon>
<span class="entry-section__title">对外分享</span>
<span class="entry-section__sub">已开启</span>
</header>
<div class="entry-summary entry-summary--share">
<canvas ref="qrCanvasRef" class="entry-summary__qrcode" />
<div class="entry-summary__link">
<el-link type="primary" :href="shareUrl" target="_blank">{{ shareUrl }}</el-link>
<el-button size="small" @click="handleCopyLink">复制链接</el-button>
</div>
<div class="entry-summary__tip">扫码或复制链接分享,微信打开即可填写</div>
</div>
</section>
<div class="publish-result__subtitle">{{ resultSubtitle }}</div>
</div>
<template #footer>
<div class="dialog-footer">
<template v-if="step === 0">
<el-button type="primary" :disabled="!selectedMethods.length" @click="step = 1">
下一步
</el-button>
<el-button @click="visible = false">取 消</el-button>
</template>
<template v-else-if="step === 1">
<el-button v-if="!isPublished" @click="step = 0">上一步</el-button>
<el-button type="primary" :loading="saving" @click="handleSaveEntry">
{{ saveButtonLabel }}
</el-button>
<el-button @click="visible = false">取 消</el-button>
</template>
<template v-else>
<el-button type="primary" @click="visible = false">完 成</el-button>
</template>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
import { Link, Monitor, QuestionFilled } from "@element-plus/icons-vue";
import QRCode from "qrcode";
import FormAPI from "@/api/form";
import type { FormMenuConfig, FormMenuFormData } from "@/api/form";
import MenuAPI from "@/api/system/menu";
import type { MenuItem } from "@/api/system/menu";
import RoleAPI from "@/api/system/role";
import type { OptionItem } from "@/api/common";
import router from "@/router";
import { usePermissionStore } from "@/stores";
import { FormStatus } from "@/enums";
defineOptions({
name: "FormPublishDialog",
});
const props = defineProps<{
/** 表单ID */
formId: string;
/** 表单唯一标识 */
formKey: string;
/** 表单名称(菜单名称默认值) */
formName: string;
/** 表单状态(0草稿 1已发布 -1已停用):已发布走"入口管理"语义,跳过方式选择 */
status: number;
/** 是否允许匿名公开访问(0否 1是):分享配置回显 */
isPublic?: number;
}>();
const emit = defineEmits(["success"]);
const visible = defineModel("modelValue", {
type: Boolean,
required: true,
default: false,
});
/** 表单是否已发布(决定向导模式:发布流程 / 入口管理直达第②步) */
const isPublished = computed(() => props.status === FormStatus.PUBLISHED);
/** 发布方式 */
type PublishMethod = "menu" | "share";
/** 选中的发布方式(第①步卡片切换,可单选可全选) */
const selectedMethods = ref<PublishMethod[]>(["menu"]);
/** 目录类型标识(sys_menu.type:C 目录) */
const MENU_TYPE_CATALOG = "C";
/** 默认挂载目录名称:挂在"动态表单"目录下,管理菜单不授权给填写角色即不渲染 */
const DEFAULT_CATALOG_NAME = "表单中心";
/** 目录树节点(el-tree-select 数据源) */
interface CatalogNode {
value: string;
label: string;
routePath?: string;
children?: CatalogNode[];
}
const menuFormRef = ref<FormInstance>();
const step = ref(0);
const saving = ref(false);
/** 已生成的菜单配置(回显,未生成过为 null) */
const menuConfig = ref<FormMenuConfig | null>(null);
/** 公开访问开关(分享配置) */
const shareEnabled = ref(false);
/** 分享二维码画布 */
const qrCanvasRef = ref<HTMLCanvasElement>();
const catalogTree = ref<CatalogNode[]>([]);
const roleOptions = ref<OptionItem[]>([]);
const initialMenuForm: FormMenuFormData = {
menuName: "",
parentId: "",
roleIds: [],
};
const menuForm = reactive<FormMenuFormData>({ ...initialMenuForm, roleIds: [] });
/**
* 是否展示菜单入口配置块:
* 已发布(入口管理)统一管理两种入口;未发布按第①步所选方式渲染
*/
const showMenuConfig = computed(() =>
isPublished.value ? true : selectedMethods.value.includes("menu")
);
/** 是否展示分享入口配置块 */
const showShareConfig = computed(() =>
isPublished.value ? true : selectedMethods.value.includes("share")
);
/** 菜单表单校验规则:菜单名称必填;上级菜单留空 = 默认目录"表单中心"(后端自动创建) */
const rules = computed<FormRules<FormMenuFormData>>(() =>
showMenuConfig.value
? {
menuName: [{ required: true, message: "请输入菜单名称", trigger: "blur" }],
}
: {}
);
/** 保存按钮文案(按配置的入口组合动态变化) */
const saveButtonLabel = computed(() => {
if (isPublished.value) return "保存配置";
return showMenuConfig.value ? "发布并生成入口" : "发布并开启分享";
});
/** 分享链接(hash 路由:origin + pathname + #/f/formKey) */
const shareUrl = computed(
() => `${window.location.origin}${window.location.pathname}#/f/${props.formKey}`
);
/** 第③步分享汇总可见:本次配置了分享且公开开关开启 */
const shareResultVisible = computed(() => showShareConfig.value && shareEnabled.value);
/** 第③步副标题 */
const resultSubtitle = computed(() => {
if (showMenuConfig.value && shareResultVisible.value)
return "员工可从侧边栏进入,也可通过分享链接填写";
if (showMenuConfig.value) return "员工刷新页面后即可从侧边栏进入填写";
return "通过分享链接或扫码即可填写";
});
watch(visible, async (newVisible) => {
if (!newVisible) return;
// 已发布表单的"入口管理":直达入口配置步骤;未发布走三步向导
step.value = isPublished.value ? 1 : 0;
await loadWizardData();
});
/**
* 加载向导数据:目录树、角色选项、已生成菜单配置回显
*/
async function loadWizardData(): Promise<void> {
const [menuTree, roles, menu] = await Promise.all([
MenuAPI.getList({}),
RoleAPI.getOptions(),
FormAPI.getFormMenu(props.formId),
]);
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 {
const index = selectedMethods.value.indexOf(method);
if (index > -1) {
selectedMethods.value.splice(index, 1);
} else {
selectedMethods.value.push(method);
}
}
/** 角色全选(多选下拉 header 快捷操作) */
function handleSelectAllRoles(): void {
menuForm.roleIds = roleOptions.value.map((role) => String(role.value));
}
/**
* 构建目录树(仅目录类型可挂载表单菜单)
*
* @param menus 菜单树
*/
function buildCatalogTree(menus: MenuItem[]): CatalogNode[] {
return menus
.filter((menu) => menu.type === MENU_TYPE_CATALOG)
.map((menu) => ({
value: String(menu.id ?? ""),
label: String(menu.name ?? ""),
routePath: menu.routePath,
children: menu.children?.length ? buildCatalogTree(menu.children) : undefined,
}));
}
/**
* 查找默认挂载目录ID:递归查找"表单中心"(已生成过则预选);不存在返回空串(留空=后端自动创建)
*/
function findDefaultCatalogId(): string {
const find = (nodes: CatalogNode[]): string => {
for (const node of nodes) {
if (node.label === DEFAULT_CATALOG_NAME) {
return node.value;
}
if (node.children?.length) {
const hit = find(node.children);
if (hit) return hit;
}
}
return "";
};
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) {
const nextChain = [...chain, node.label];
if (node.value === menuForm.parentId) {
names.push(...nextChain);
return true;
}
if (node.children?.length && find(node.children, nextChain)) return true;
}
return false;
};
find(catalogTree.value, []);
return [...names, menuForm.menuName];
});
/**
* 已授权角色名称列表
*/
const grantedRoleNames = computed(() =>
roleOptions.value
.filter((role) => menuForm.roleIds?.includes(String(role.value)))
.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) {
// 顶级目录 routePath 以 / 开头,子级为相对段
const fullPath = node.routePath?.startsWith("/")
? node.routePath
: `${basePath}/${node.routePath ?? ""}`;
if (node.value === menuForm.parentId) {
routePath = `${fullPath}/${props.formKey}`;
return true;
}
if (node.children?.length && find(node.children, fullPath)) return true;
}
return false;
};
find(catalogTree.value, "");
return routePath;
});
/**
* 保存入口配置:未发布表单先发布(版本+1),再按所选方式生成菜单/保存公开开关
*/
async function handleSaveEntry(): Promise<void> {
if (showMenuConfig.value) {
const valid = await menuFormRef.value?.validate().then(
() => true,
() => false
);
if (!valid) return;
}
saving.value = true;
try {
if (!isPublished.value) {
await FormAPI.publish(props.formId);
}
if (showMenuConfig.value) {
await FormAPI.saveFormMenu(props.formId, {
menuName: menuForm.menuName,
// 留空不传:后端自动使用/创建默认目录"表单中心"
parentId: menuForm.parentId || undefined,
roleIds: menuForm.roleIds,
});
}
if (showShareConfig.value) {
// isPublic 独立提交:仅配置分享入口时才写开关,避免菜单-only 保存意外重置公开状态
await FormAPI.update(props.formId, { isPublic: shareEnabled.value ? 1 : 0 });
}
step.value = 2;
emit("success");
} finally {
saving.value = false;
}
}
/**
* 第③步分享汇总渲染后绘制二维码(canvas 随 v-if 挂载,需等 DOM 就绪)
*/
watch(step, async (newStep) => {
if (newStep !== 2 || !shareResultVisible.value) return;
await nextTick();
if (qrCanvasRef.value) {
await QRCode.toCanvas(qrCanvasRef.value, shareUrl.value, { width: 140, margin: 1 });
}
});
/**
* 复制分享链接
*/
function handleCopyLink(): void {
navigator.clipboard
?.writeText(shareUrl.value)
.then(() => ElMessage.success("链接已复制"))
.catch(() => ElMessage.warning("复制失败,请手动复制"));
}
/**
* 前往查看:重载动态路由(菜单为新建,当前路由表未包含)后跳转
*/
async function handleGoView(): Promise<void> {
if (!menuRoutePath.value) {
ElMessage.warning("未能解析菜单路径,请刷新页面后从侧边栏进入");
return;
}
const permissionStore = usePermissionStore();
await permissionStore.reloadRoutes();
visible.value = false;
router.push(menuRoutePath.value);
}
/**
* 关闭向导并重置步骤
*/
function handleClose(): void {
step.value = 0;
}
</script>
<style lang="scss" scoped>
/* ============ 第①步:发布方式卡片 ============ */
.publish-method {
display: flex;
flex-direction: column;
gap: 12px;
padding: 24px 16px 0;
}
.method-card {
padding: 16px;
cursor: pointer;
border: 1px solid var(--el-border-color);
border-radius: 6px;
transition: border-color 0.2s;
&:hover {
border-color: var(--el-color-primary-light-5);
}
&--active {
background-color: var(--el-color-primary-light-9);
border-color: var(--el-color-primary);
}
&__header {
display: flex;
gap: 10px;
align-items: center;
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
&__titles {
display: flex;
gap: 6px;
align-items: baseline;
}
&__name-en {
font-size: 11px;
font-weight: 400;
color: var(--el-text-color-placeholder);
letter-spacing: 1px;
}
&__icon {
&--menu {
color: var(--el-color-primary);
}
&--share {
color: var(--el-color-success);
}
}
&__desc {
margin: 8px 0 0;
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
.publish-method__tip {
padding: 8px 0;
font-size: 12px;
color: var(--el-text-color-secondary);
text-align: center;
}
/* ============ 第②/③步:入口分区卡片 ============ */
.entry-config {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px 16px 0;
}
/* 分区卡片:左侧 3px 色条区分两种形态(蓝=内嵌 绿=分享) */
.entry-section {
padding: 0 0 4px;
overflow: hidden;
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
&--menu {
border-left: 3px solid var(--el-color-primary);
}
&--share {
border-left: 3px solid var(--el-color-success);
}
&__header {
display: flex;
gap: 8px;
align-items: center;
padding: 10px 14px;
background-color: var(--el-fill-color-light);
border-bottom: 1px solid var(--el-border-color-lighter);
}
&__icon {
color: var(--el-text-color-secondary);
}
&--menu &__icon {
color: var(--el-color-primary);
}
&--share &__icon {
color: var(--el-color-success);
}
&__title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
&__sub {
font-size: 12px;
color: var(--el-text-color-secondary);
}
&__form {
padding: 16px 14px 0;
}
}
/* 角色多选下拉 header:全选 + 计数 */
.role-select__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.role-select__count {
font-size: 12px;
color: var(--el-text-color-secondary);
}
/* ============ 第③步:分区汇总 ============ */
.entry-summary {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 14px 12px;
&__row {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
font-size: 13px;
}
&__label {
width: 60px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
&__empty {
font-size: 13px;
color: var(--el-text-color-placeholder);
}
&__action {
align-self: flex-start;
margin-left: 60px;
}
&--share {
align-items: center;
}
&__qrcode {
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
}
&__link {
display: flex;
gap: 8px;
align-items: center;
max-width: 100%;
}
&__tip {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.publish-result__subtitle {
padding: 8px 0 0;
font-size: 12px;
color: var(--el-text-color-secondary);
text-align: center;
}
</style>
@@ -0,0 +1,59 @@
/**
* 表单规则加载与渲染状态
*
* @description
* 填写页(render.vue)、公开填写页(share.vue)、设计器预览页(preview.vue)共用,
* 保证三处走同一条渲染管线:相同的规则解析、提交按钮策略与成功态切换
*/
import { ref, shallowRef } from "vue";
import type { Options, Rule } from "@form-create/element-ui";
/** 规则来源(表单定义接口与渲染接口返回的 JSON 字段) */
export interface FormRuleSource {
/** 表单规则(form-create rule 数组 JSON 字符串) */
formJson?: string;
/** 表单全局配置 JSON 字符串 */
optionsJson?: string;
}
/**
* 加载表单规则并管理渲染状态
*
* @param loader 规则加载函数(各承载页的规则来源不同:草稿定义 / 已发布规则 / 公开规则)
*/
export function useFormRenderer<T extends FormRuleSource = FormRuleSource>(
loader: () => Promise<T | null | undefined>
) {
// shallowRef:规则与配置整体加载整体替换,无需深层响应式;
// 深响应式代理会破坏 form-create Rule 内部 Creator 结构(模板 UnwrapRef 深映射导致类型失配)
const rule = shallowRef<Rule[]>([]);
const option = shallowRef<Options>({ submitBtn: true });
const loading = ref(false);
const submitted = ref(false);
/**
* 加载并解析规则,返回接口原始数据
*
* 强制开启提交按钮:填写页必须有提交入口,设计器保存时可能关掉了它
*/
async function load(): Promise<T | undefined> {
loading.value = true;
try {
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 {
loading.value = false;
}
}
/** 回到填写态(已填数据清空由 FormRenderer 处理) */
function refill(): void {
submitted.value = false;
}
return { rule, option, loading, submitted, load, refill };
}
+365
View File
@@ -0,0 +1,365 @@
<template>
<div class="page-container">
<el-card ref="tableWrapperRef" class="page-content" shadow="never">
<div class="page-toolbar">
<div class="page-toolbar__left">
<span class="data-toolbar__title">{{ title }}</span>
<el-tag v-if="formKey" type="info" effect="plain">{{ formKey }}</el-tag>
<el-button
v-hasPerm="['form:data:delete']"
type="danger"
:disabled="!hasSelection"
@click="handleDelete()"
>
删除
</el-button>
<el-button v-hasPerm="['form:data:list']" :loading="exporting" @click="handleExport">
导出
</el-button>
</div>
<div class="page-toolbar__right">
<el-tooltip content="刷新" placement="top">
<el-button class="page-icon-btn" @click="fetchData">
<el-icon><Refresh /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="全屏" placement="top">
<el-button class="page-icon-btn" @click="toggleFullscreen">
<el-icon><FullScreen /></el-icon>
</el-button>
</el-tooltip>
</div>
</div>
<div class="page-table-wrapper">
<el-table
v-loading="loading"
highlight-current-row
:data="list"
class="page-table"
border
height="100%"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" align="center" />
<!-- 动态字段列:由表单规则提取,超出上限的字段在详情抽屉查看 -->
<el-table-column
v-for="field in displayFields"
:key="field.field"
:label="field.title"
:prop="field.field"
min-width="140"
show-overflow-tooltip
>
<template #default="scope">
{{ formatCellValue(getFieldValue(scope.row as FormDataItem, field.field), field) }}
</template>
</el-table-column>
<el-table-column label="提交人" width="120" align="center" show-overflow-tooltip>
<template #default="scope">
{{ (scope.row as FormDataItem).createByName || "匿名" }}
</template>
</el-table-column>
<el-table-column label="版本" prop="formVersion" width="70" align="center" />
<el-table-column label="提交时间" prop="createTime" width="170" align="center" />
<el-table-column fixed="right" label="操作" align="center" width="140">
<template #default="scope">
<el-button
type="primary"
link
size="small"
@click.stop="handleDetailClick(scope.row as FormDataItem)"
>
详情
</el-button>
<el-button
v-hasPerm="['form:data:delete']"
type="danger"
link
size="small"
@click.stop="handleDelete((scope.row as FormDataItem).id)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<pagination
v-if="total > 0"
v-model:total="total"
v-model:page="params.pageNum"
v-model:limit="params.pageSize"
@pagination="fetchData"
/>
</el-card>
<!-- 数据详情抽屉:form-create 只读回显提交时的表单结构 -->
<el-drawer v-model="detailState.visible" title="数据详情" size="560px">
<el-descriptions v-if="detailRow" :column="2" border class="data-detail__desc">
<el-descriptions-item label="提交人">
{{ detailRow.createByName || "匿名" }}
</el-descriptions-item>
<el-descriptions-item label="提交时间">{{ detailRow.createTime }}</el-descriptions-item>
<el-descriptions-item label="表单版本">v{{ detailRow.formVersion }}</el-descriptions-item>
</el-descriptions>
<form-create
v-model="detailData"
v-model:api="detailApi"
:rule="detailRule"
:option="detailOption"
/>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { useFullscreen } from "@vueuse/core";
import { ElMessage, ElMessageBox } from "element-plus";
import { Refresh } from "@element-plus/icons-vue";
import type { Options, Rule } from "@form-create/element-ui";
import type { Column } from "exceljs";
import FormAPI from "@/api/form";
import type { FormDataItem, FormDataQueryParams } from "@/api/form";
import { usePageTable, useTableSelection } from "@/composables";
import { downloadFile } from "@/utils";
import {
extractFields,
formatCellValue,
parseDataJson,
type FormFieldMeta,
} from "./utils/form-field";
defineOptions({
name: "FormData",
inheritAttrs: false,
});
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);
const { toggle: toggleFullscreen } = useFullscreen(tableWrapperRef);
/** 表单名称(导出文件名用) */
const formName = ref("");
/** 导出中状态(防止重复触发全量拉取) */
const exporting = ref(false);
/** 表单规则(详情抽屉只读回显复用;shallowRef 避免深代理破坏 Rule 内部的 Creator 结构) */
const rule = shallowRef<Rule[]>([]);
/** 从表单规则提取的字段元数据(field + title + 选项映射) */
const fields = ref<FormFieldMeta[]>([]);
/** 动态列上限:超出部分在详情抽屉查看,避免表格横向溢出 */
const MAX_FIELD_COLUMNS = 6;
const displayFields = computed(() => fields.value.slice(0, MAX_FIELD_COLUMNS));
/** 分页表格数据管理 */
const { loading, list, total, params, fetchData } = usePageTable<FormDataItem, FormDataQueryParams>(
{
initialParams: { pageNum: 1, pageSize: 10 },
request: (queryParams) => FormAPI.getFormDataPage(formKey.value, queryParams),
}
);
const { selectedIds, hasSelection, handleSelectionChange } = useTableSelection<FormDataItem>();
/** 详情抽屉状态 */
const detailState = reactive({
visible: false,
});
/** 详情行(descriptions 展示提交人/时间) */
const detailRow = ref<FormDataItem>();
const detailApi = ref();
const detailData = ref<Record<string, unknown>>({});
const detailRule = shallowRef<Rule[]>([]);
const detailOption = ref<Options>({ submitBtn: false, resetBtn: false });
onMounted(async () => {
if (!formKey.value) {
ElMessage.error("缺少表单标识参数");
return;
}
// 先拉表单规则提取动态列,再查数据(表格列依赖字段元数据)
const renderData = await FormAPI.getRender(formKey.value);
formName.value = renderData.formName;
rule.value = JSON.parse(renderData.formJson);
fields.value = extractFields(rule.value);
fetchData();
});
/**
* 行数据解析缓存(行ID -> field/value 映射)
*
* 避免模板渲染时每行每列各解析一次同一行 dataJson
*/
const rowDataMap = computed(() => {
const map = new Map<string, Record<string, unknown>>();
list.value.forEach((row) => map.set(row.id, parseDataJson(row.dataJson)));
return map;
});
/**
* 取行数据的指定字段值
*
* @param row 数据行
* @param field 字段名
*/
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 只读态
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 之后)
await nextTick();
detailApi.value?.disabled(true);
}
/**
* 删除单个或批量表单数据
*
* @param id 指定时删除单条数据;不指定时删除表格勾选项
*/
async function handleDelete(id?: string): Promise<void> {
const dataIds = id ?? selectedIds.value.join(",");
if (!dataIds) {
ElMessage.warning("请勾选删除项");
return;
}
try {
await ElMessageBox.confirm("删除后不可恢复,确认删除已选中的数据项?", "警告", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
} catch {
ElMessage.info("已取消删除");
return;
}
loading.value = true;
try {
await FormAPI.deleteFormData(formKey.value, dataIds);
ElMessage.success("删除成功");
fetchData();
} finally {
loading.value = false;
}
}
/** 导出分页大小(单页拉取行数,与导出上限配合控制内存占用) */
const EXPORT_PAGE_SIZE = 500;
/** 导出页数上限(超出截断并提示,防止超大表单拖垮浏览器) */
const EXPORT_MAX_PAGES = 100;
/**
* 导出全量表单数据为 Excel
*
* 动态列 = 表单全部字段(不受列表展示列数上限约束)+ 提交人/版本/提交时间;
* 数据按分页循环拉取全量,exceljs 动态导入按需加载
*/
async function handleExport(): Promise<void> {
if (exporting.value) return;
exporting.value = true;
try {
const rows: FormDataItem[] = [];
let total = 0;
for (let pageNum = 1; pageNum <= EXPORT_MAX_PAGES; pageNum++) {
const page = await FormAPI.getFormDataPage(formKey.value, {
pageNum,
pageSize: EXPORT_PAGE_SIZE,
});
total = page.total;
rows.push(...page.list);
if (rows.length >= total) break;
}
if (rows.length < total) {
ElMessage.warning(`数据量超出导出上限,仅导出前 ${rows.length} 条`);
}
if (rows.length === 0) {
ElMessage.warning("暂无数据可导出");
return;
}
const ExcelJS = await import("exceljs");
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet("表单数据");
const columns: Partial<Column>[] = [
...fields.value.map((field) => ({ header: field.title, key: field.field })),
{ header: "提交人", key: "createByName" },
{ header: "版本", key: "formVersion" },
{ header: "提交时间", key: "createTime" },
];
worksheet.columns = columns;
// 列宽按表头长度自适应并设下限,避免中文长标题列被压缩不可读
worksheet.columns.forEach((column) => {
column.width = Math.max((column.header?.length ?? 4) * 2.5 + 4, 12);
});
worksheet.addRows(
rows.map((row) => {
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 || "匿名";
record.formVersion = row.formVersion;
record.createTime = row.createTime;
return record;
})
);
const buffer = await workbook.xlsx.writeBuffer();
downloadFile({ data: buffer }, `${formName.value || formKey.value}-数据.xlsx`);
ElMessage.success(`成功导出 ${rows.length} 条数据`);
} finally {
exporting.value = false;
}
}
</script>
<style lang="scss" scoped>
.data-toolbar__title {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.data-detail__desc {
margin-bottom: 16px;
}
</style>
+144
View File
@@ -0,0 +1,144 @@
<template>
<div class="designer-container">
<div class="designer-toolbar">
<div class="designer-toolbar__left">
<el-button circle @click="handleBack">
<el-icon><ArrowLeft /></el-icon>
</el-button>
<span class="designer-toolbar__title">{{ title }}</span>
</div>
<div class="designer-toolbar__right">
<el-button @click="handlePreview">预 览</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保 存</el-button>
</div>
</div>
<div class="designer-wrapper">
<FcDesigner ref="designerRef" :config="designerConfig" height="100%" />
</div>
</div>
</template>
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { ArrowLeft } from "@element-plus/icons-vue";
import FcDesigner from "@form-create/designer";
import FormAPI from "@/api/form";
import router from "@/router";
defineOptions({
name: "FormDesigner",
inheritAttrs: false,
});
const route = useRoute();
/** 表单 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);
/** 回显已有规则(空规则为空白画布) */
onMounted(async () => {
if (!formId.value) {
ElMessage.error("缺少表单ID参数");
return;
}
const data = await FormAPI.getFormData(formId.value);
if (data.formJson) {
designerRef.value?.setRule(JSON.parse(data.formJson));
}
if (data.optionsJson) {
designerRef.value?.setOption(JSON.parse(data.optionsJson));
}
});
/**
* 保存设计器产出的规则与全局配置
*/
async function handleSave(): Promise<void> {
if (!formId.value) {
ElMessage.error("缺少表单ID参数");
return;
}
const formJson = JSON.stringify(designerRef.value?.getRule() ?? []);
const optionsJson = JSON.stringify(designerRef.value?.getOption() ?? {});
saving.value = true;
try {
await FormAPI.update(formId.value, { formJson, optionsJson });
ElMessage.success("保存成功");
} finally {
saving.value = false;
}
}
/**
* 返回表单管理列表
*/
function handleBack(): void {
router.back();
}
/**
* 运行态真预览:跳预览页用与填写承载页相同的渲染管线查看效果
*
* 预览读取的是已保存规则,未保存的改动不会体现,需先保存再预览
*/
function handlePreview(): void {
if (!formId.value) {
ElMessage.error("缺少表单ID参数");
return;
}
router.push({
name: "FormPreview",
query: { id: formId.value, title: title.value },
});
}
</script>
<style lang="scss" scoped>
.designer-container {
display: flex;
flex-direction: column;
// 视口高度减去导航栏、标签栏及容器内边距
height: calc(100vh - 120px);
overflow: hidden;
background-color: var(--el-bg-color);
}
.designer-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
border-bottom: 1px solid var(--el-border-color-light);
&__left {
display: flex;
gap: 12px;
align-items: center;
}
&__title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.designer-wrapper {
flex: 1;
overflow: hidden;
}
</style>
+492
View File
@@ -0,0 +1,492 @@
<template>
<div class="page-container">
<el-card class="page-search" shadow="never">
<el-form ref="queryFormRef" :model="params" :inline="true">
<el-form-item label="关键字" prop="keywords">
<el-input
v-model="params.keywords"
placeholder="表单名称/标识"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="params.status" placeholder="全部" clearable class="!w-[160px]">
<el-option
v-for="(label, value) in statusOptions"
:key="value"
:label="label"
:value="Number(value)"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery">搜索</el-button>
<el-button @click="handleResetQuery">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card ref="tableWrapperRef" class="page-content" shadow="never">
<div class="page-toolbar">
<div class="page-toolbar__left">
<el-button type="primary" @click="handleCreateClick()">新增</el-button>
<el-button type="danger" :disabled="!hasSelection" @click="handleDelete()">
删除
</el-button>
</div>
<div class="page-toolbar__right">
<el-tooltip content="刷新" placement="top">
<el-button class="page-icon-btn" @click="fetchData">
<el-icon><Refresh /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="全屏" placement="top">
<el-button class="page-icon-btn" @click="toggleFullscreen">
<el-icon><FullScreen /></el-icon>
</el-button>
</el-tooltip>
</div>
</div>
<div class="page-table-wrapper">
<el-table
v-loading="loading"
highlight-current-row
:data="list"
class="page-table"
border
height="100%"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="表单名称" prop="formName" min-width="140" show-overflow-tooltip>
<template #default="scope">
<span>{{ scope.row.formName }}</span>
<el-tag v-if="scope.row.isPublic === 1" size="small" type="warning" effect="plain">
公开
</el-tag>
</template>
</el-table-column>
<el-table-column label="表单标识" prop="formKey" min-width="160" show-overflow-tooltip />
<el-table-column label="描述" prop="description" min-width="160" show-overflow-tooltip />
<el-table-column label="状态" width="90" align="center">
<template #default="scope">
<el-tag :type="statusTagType(scope.row.status)">
{{ statusOptions[scope.row.status] ?? "未知" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="版本" prop="version" width="70" align="center" />
<el-table-column label="创建时间" prop="createTime" width="170" align="center" />
<el-table-column fixed="right" label="操作" align="center" width="330">
<template #default="scope">
<el-button
type="primary"
link
size="small"
@click.stop="openDesigner(scope.row as FormDefinitionItem)"
>
设计
</el-button>
<el-button
type="primary"
link
size="small"
@click.stop="openDataPage(scope.row as FormDefinitionItem)"
>
数据
</el-button>
<el-button
type="success"
link
size="small"
@click.stop="openPublishDialog(scope.row as FormDefinitionItem)"
>
{{ scope.row.status === FormStatus.PUBLISHED ? "入口管理" : "发布" }}
</el-button>
<el-button
v-if="scope.row.status === FormStatus.PUBLISHED"
type="warning"
link
size="small"
@click.stop="handleDisable(scope.row.id)"
>
停用
</el-button>
<el-button
type="primary"
link
size="small"
@click.stop="handleEditClick(scope.row.id)"
>
编辑
</el-button>
<el-button type="danger" link size="small" @click.stop="handleDelete(scope.row.id)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<pagination
v-if="total > 0"
v-model:total="total"
v-model:page="params.pageNum"
v-model:limit="params.pageSize"
@pagination="fetchData"
/>
</el-card>
<el-dialog
v-model="dialogState.visible"
:title="dialogState.title"
width="560px"
@close="closeDialog"
>
<el-form ref="formDefinitionFormRef" :model="formData" :rules="rules" label-width="90px">
<el-form-item label="表单名称" prop="formName">
<el-input v-model="formData.formName" placeholder="请输入表单名称" />
</el-form-item>
<el-form-item prop="formKey">
<template #label>
<div class="flex-y-center">
表单标识
<el-tooltip content="创建后不可修改,建议用有业务含义的英文编码" placement="bottom">
<el-icon class="ml-1 cursor-pointer">
<QuestionFilled />
</el-icon>
</el-tooltip>
</div>
</template>
<el-input
v-model="formData.formKey"
:disabled="!!formData.id"
placeholder="如 employee_onboarding"
/>
</el-form-item>
<el-form-item label="表单描述" prop="description">
<el-input
v-model="formData.description"
type="textarea"
:rows="3"
placeholder="请输入表单描述"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="handleSubmit">确 定</el-button>
<el-button @click="closeDialog">取 消</el-button>
</div>
</template>
</el-dialog>
<!-- 发布向导:发布方式 → 入口配置(菜单+角色/公开开关) → 完成汇总 -->
<FormPublishDialog
v-model="publishState.visible"
:form-id="publishState.formId"
:form-key="publishState.formKey"
:form-name="publishState.formName"
:status="publishState.status"
:is-public="publishState.isPublic"
@success="handlePublishSuccess"
/>
</div>
</template>
<script setup lang="ts">
import { useFullscreen } from "@vueuse/core";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { QuestionFilled, Refresh } from "@element-plus/icons-vue";
import FormAPI from "@/api/form";
import type { FormDefinitionData, FormDefinitionItem, FormDefinitionQueryParams } from "@/api/form";
import router from "@/router";
import { usePageTable, useTableSelection } from "@/composables";
import { FormStatus } from "@/enums";
import FormPublishDialog from "./components/PublishDialog.vue";
defineOptions({
name: "FormDefinition",
inheritAttrs: false,
});
const tableWrapperRef = ref<HTMLElement | null>(null);
const { toggle: toggleFullscreen } = useFullscreen(tableWrapperRef);
const queryFormRef = ref<FormInstance>();
const formDefinitionFormRef = ref<FormInstance>();
/** 状态下拉/标签展示映射 */
const statusOptions: Record<number, string> = {
[FormStatus.DRAFT]: "草稿",
[FormStatus.PUBLISHED]: "已发布",
[FormStatus.DISABLED]: "已停用",
};
/**
* 状态标签样式
*
* @param status 状态值
*/
function statusTagType(status: number): "info" | "success" | "danger" {
switch (status) {
case FormStatus.PUBLISHED:
return "success";
case FormStatus.DISABLED:
return "danger";
default:
return "info";
}
}
/** 分页表格数据管理 */
const { loading, list, total, params, fetchData, handleQuery, handleResetQuery } = usePageTable<
FormDefinitionItem,
FormDefinitionQueryParams
>({
initialParams: {
pageNum: 1,
pageSize: 10,
keywords: "",
},
request: FormAPI.getPage,
onBeforeReset: () => queryFormRef.value?.resetFields(),
});
const { selectedIds, hasSelection, handleSelectionChange } =
useTableSelection<FormDefinitionItem>();
const dialogState = reactive({
title: "",
visible: false,
});
const initialFormData: FormDefinitionData = {};
const formData = reactive<FormDefinitionData>({ ...initialFormData });
const rules: FormRules<FormDefinitionData> = {
formName: [{ required: true, message: "请输入表单名称", trigger: "blur" }],
formKey: [
{ required: true, message: "请输入表单标识", trigger: "blur" },
{
pattern: /^[a-z][a-z0-9_]*$/,
message: "须以小写字母开头,仅含小写字母、数字、下划线",
trigger: "blur",
},
],
};
/**
* 重置表单数据和验证状态
*/
function resetForm(): void {
formDefinitionFormRef.value?.resetFields();
formDefinitionFormRef.value?.clearValidate();
Object.keys(formData).forEach((key) => {
delete (formData as Record<string, unknown>)[key];
});
Object.assign(formData, initialFormData);
}
function openDialog(): void {
dialogState.visible = true;
}
/**
* 关闭表单定义弹窗并清理临时状态
*/
function closeDialog(): void {
dialogState.visible = false;
resetForm();
}
/**
* 打开新增表单定义弹窗
*/
function handleCreateClick(): void {
resetForm();
dialogState.title = "新增表单";
openDialog();
}
/**
* 打开编辑表单定义弹窗并回填数据
*
* @param id 表单 ID
*/
async function handleEditClick(id: string): Promise<void> {
resetForm();
dialogState.title = "修改表单";
const data = await FormAPI.getFormData(id);
Object.assign(formData, data);
openDialog();
}
/**
* 校验并提交表单定义
*/
async function handleSubmit(): Promise<void> {
const valid = await formDefinitionFormRef.value?.validate().then(
() => true,
() => false
);
if (!valid) return;
loading.value = true;
try {
const id = formData.id;
if (id) {
await FormAPI.update(id, formData);
ElMessage.success("修改成功");
} else {
await FormAPI.create(formData);
ElMessage.success("新增成功");
}
closeDialog();
handleQuery();
} finally {
loading.value = false;
}
}
/**
* 发布向导状态("发布"与已发布表单的"入口管理"共用)
*/
const publishState = reactive({
visible: false,
formId: "",
formKey: "",
formName: "",
status: 0,
/** 公开开关回显(分享配置步骤依赖) */
isPublic: 0,
});
/**
* 打开发布向导(未发布走发布流程,已发布直达入口配置)
*
* @param row 当前表单行
*/
function openPublishDialog(row: FormDefinitionItem): void {
Object.assign(publishState, {
visible: true,
formId: row.id,
formKey: row.formKey,
formName: row.formName,
status: row.status,
isPublic: row.isPublic ?? 0,
});
}
/**
* 发布向导保存成功后刷新列表(表单状态与版本可能已变更)
*/
function handlePublishSuccess(): void {
fetchData();
}
/**
* 跳转到指定表单页面并检查路由是否已注册
*
* @param name 路由名称(FormDesigner/FormData)
* @param query 路由参数
*/
function openFormPage(name: string, query: Record<string, string>): void {
try {
const route = router.resolve({ name, query });
if (route.matched.length === 0) {
ElMessage.error("路由未注册,请刷新页面后重试");
return;
}
router.push(route);
} catch (error) {
console.error("路由跳转失败:", error);
ElMessage.error("页面跳转失败,请刷新页面后重试");
}
}
/**
* 跳转到表单数据页
*
* @param row 当前表单行
*/
function openDataPage(row: FormDefinitionItem): void {
openFormPage("FormData", { formKey: row.formKey, title: `【${row.formName}】数据` });
}
/**
* 停用表单(已发出去的访问入口立即失效)
*
* @param id 表单 ID
*/
async function handleDisable(id: string): Promise<void> {
try {
await ElMessageBox.confirm("停用后表单立即不可访问,已提交数据保留,确定停用?", "警告", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
} catch {
ElMessage.info("已取消停用");
return;
}
await FormAPI.disable(id);
ElMessage.success("已停用");
fetchData();
}
/**
* 删除单个或批量表单定义
*
* @param id 指定时删除单个表单;不指定时删除表格勾选项
*/
async function handleDelete(id?: string): Promise<void> {
const formIds = id ?? selectedIds.value.join(",");
if (!formIds) {
ElMessage.warning("请勾选删除项");
return;
}
try {
await ElMessageBox.confirm("确认删除已选中的数据项?", "警告", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
} catch {
ElMessage.info("已取消删除");
return;
}
loading.value = true;
try {
await FormAPI.deleteByIds(formIds);
ElMessage.success("删除成功");
handleResetQuery();
} finally {
loading.value = false;
}
}
/**
* 跳转到表单设计器页面
*
* @param row 当前表单行
*/
function openDesigner(row: FormDefinitionItem): void {
openFormPage("FormDesigner", { id: row.id, title: `【${row.formName}】表单设计` });
}
onMounted(() => {
handleQuery();
});
</script>
+68
View File
@@ -0,0 +1,68 @@
<template>
<FormRenderer
:title="title"
:rule="rule"
:option="option"
:loading="loading"
:submitted="submitted"
@submit="handlePreviewSubmit"
>
<template #header-extra>
<el-button type="primary" size="small" @click="handleBack">返回设计器</el-button>
</template>
<template #success>
<el-result icon="success" title="预览提交成功" sub-title="预览模式下数据不会保存">
<template #extra>
<el-button @click="refill">继续填写</el-button>
<el-button type="primary" @click="handleBack">返回设计器</el-button>
</template>
</el-result>
</template>
</FormRenderer>
</template>
<script setup lang="ts">
import { ElMessage } from "element-plus";
import FormAPI from "@/api/form";
import router from "@/router";
import FormRenderer from "./components/FormRenderer.vue";
import { useFormRenderer } from "./composables/useFormRenderer";
defineOptions({
name: "FormPreview",
inheritAttrs: false,
});
const route = useRoute();
/** 表单 ID(设计器"预览"按钮携参进入) */
const formId = computed(() => String(route.query.id ?? ""));
/** 页面标题(设计器携带,如【入职信息采集】表单预览) */
const title = computed(() => String(route.query.title ?? "表单预览"));
// 走表单定义接口拉草稿规则:render 接口仅返回已发布表单,预览恰恰要覆盖未发布态
const { rule, option, loading, submitted, load, refill } = useFormRenderer(() =>
FormAPI.getFormData(formId.value)
);
onMounted(async () => {
if (!formId.value) {
ElMessage.error("缺少表单ID参数");
return;
}
await load();
});
/** 预览提交:校验通过即进入成功态,不落库 */
function handlePreviewSubmit(): void {
submitted.value = true;
}
/** 返回设计器继续编辑 */
function handleBack(): void {
router.back();
}
</script>
+96
View File
@@ -0,0 +1,96 @@
<template>
<FormRenderer
:title="renderData?.formName ?? '表单填写'"
:version="renderData?.version"
:rule="rule"
:option="option"
:loading="loading"
:submitted="submitted"
@submit="handleSubmit"
>
<template #success>
<el-result icon="success" title="提交成功" sub-title="感谢您的填写,信息已成功提交">
<template #extra>
<el-button type="primary" @click="refill">继续填写</el-button>
<el-button v-if="canViewData" @click="handleViewData">查看已提交数据</el-button>
</template>
</el-result>
</template>
</FormRenderer>
</template>
<script setup lang="ts">
import { ElMessage } from "element-plus";
import FormAPI from "@/api/form";
import type { FormRenderData } from "@/api/form";
import { hasPerm } from "@/utils/auth";
import FormRenderer from "./components/FormRenderer.vue";
import { useFormRenderer } from "./composables/useFormRenderer";
defineOptions({
name: "FormRender",
inheritAttrs: false,
});
const route = useRoute();
const router = useRouter();
/**
* 表单唯一标识
*
* 优先取菜单路由参数(meta.params 由后端菜单 params 转换而来,刷新不丢失);
* query 兜底:侧边栏点击跳转时 LayoutSidebarItem 会将 meta.params 透传为 query
*/
const formKey = computed(() => {
const metaParams = route.meta.params as Record<string, unknown> | undefined;
return String(metaParams?.formKey ?? route.query.formKey ?? "");
});
const renderData = ref<FormRenderData>();
const submitting = ref(false);
const { rule, option, loading, submitted, load, refill } = useFormRenderer(() =>
FormAPI.getRender(formKey.value)
);
onMounted(async () => {
if (!formKey.value) {
ElMessage.error("缺少表单标识参数,请检查菜单路由参数配置");
return;
}
renderData.value = await load();
});
/**
* 提交表单数据(FormRenderer 校验通过后触发)
*
* @param data 表单数据(field -> value 映射)
*/
async function handleSubmit(data: Record<string, unknown>): Promise<void> {
if (submitting.value) return;
submitting.value = true;
try {
await FormAPI.submitFormData(formKey.value, data);
ElMessage.success("提交成功");
submitted.value = true;
} finally {
submitting.value = false;
}
}
/**
* 是否有权查看收集数据(复用 form:data:list 数据查询权限)
*
* 无权限(匿名分享等)不显示"查看已提交数据"按钮,避免点击后无路由可跳
*/
const canViewData = computed(() => hasPerm(["form:data:list"]));
/** 跳转数据列表(带 formKey 定位表单,列表按提交时间倒序,本次提交在首位) */
function handleViewData(): void {
router.push({
name: "FormData",
query: { formKey: formKey.value, title: renderData.value?.formName ?? "表单数据" },
});
}
</script>
+113
View File
@@ -0,0 +1,113 @@
<template>
<!-- 公开表单 H5 承载页:匿名访问,不套管理端 Layout -->
<div class="share-page">
<!-- 加载失败态:链接失效/未开放公开访问/表单不存在 -->
<el-card v-if="loadError" class="share-page__card" shadow="never">
<el-result icon="warning" title="表单暂不可用" :sub-title="loadError">
<template #extra>
<div class="share-page__hint">请联系表单发布者确认链接是否有效</div>
</template>
</el-result>
</el-card>
<FormRenderer
v-else
:title="renderData?.formName ?? '表单填写'"
:version="renderData?.version"
:rule="rule"
:option="option"
:loading="loading"
:submitted="submitted"
@submit="handleSubmit"
>
<template #success>
<el-result icon="success" title="提交成功" sub-title="感谢您的填写,信息已成功提交">
<template #extra>
<el-button type="primary" @click="refill">继续填写</el-button>
</template>
</el-result>
</template>
</FormRenderer>
</div>
</template>
<script setup lang="ts">
import FormAPI from "@/api/form";
import type { FormRenderData } from "@/api/form";
import FormRenderer from "./components/FormRenderer.vue";
import { useFormRenderer } from "./composables/useFormRenderer";
defineOptions({
name: "FormShare",
inheritAttrs: false,
});
const route = useRoute();
/** 表单唯一标识(路由路径段 /f/:formKey) */
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(() =>
FormAPI.getPublicRender(formKey.value)
);
onMounted(async () => {
if (!formKey.value) {
loadError.value = "缺少表单标识参数";
return;
}
try {
renderData.value = await load();
} catch {
loadError.value = "表单不存在或未开放公开访问";
}
});
/**
* 匿名提交表单数据(FormRenderer 校验通过后触发)
*
* @param data 表单数据(field -> value 映射)
*/
async function handleSubmit(data: Record<string, unknown>): Promise<void> {
if (submitting.value) return;
submitting.value = true;
try {
await FormAPI.submitPublicFormData(formKey.value, data);
submitted.value = true;
} catch {
// 提交失败(限流/校验不通过)消息由 request 拦截器统一弹出,无需重复提示;停留填写态可重试
} finally {
submitting.value = false;
}
}
</script>
<style lang="scss" scoped>
.share-page {
min-height: 100vh;
padding: 12px 0;
background-color: var(--el-fill-color-light);
}
.share-page__card {
width: 100%;
max-width: 640px;
margin: 0 auto;
}
.share-page__hint {
font-size: 13px;
color: var(--el-text-color-secondary);
}
// 移动端优先:渲染卡片宽度收敛,与桌面端预览宽度区分开
:deep(.form-renderer__card) {
max-width: 640px;
}
</style>
@@ -0,0 +1,92 @@
/**
* 表单字段解析
*
* @description 从 form-create 规则提取字段元数据、解析提交数据并格式化展示值
*/
/** 表单字段元数据 */
export interface FormFieldMeta {
/** 字段名 */
field: string;
/** 字段标题 */
title: string;
/** 选项映射(value -> label):选项类字段提交的是 value,列表/导出需翻译为 label */
optionMap: Map<string, string>;
}
/**
* 递归提取表单字段元数据
*
* 布局容器(row/col)自身无 field,其子节点递归收集;
* 参数为 JSON 解析产物,结构未经校验,按 unknown 逐层探测
*
* @param rules form-create 规则(JSON 解析值)
*/
export function extractFields(rules: unknown): FormFieldMeta[] {
const result: FormFieldMeta[] = [];
const walk = (nodes: unknown[]): void => {
nodes.forEach((node) => {
if (!node || typeof node !== "object") return;
const item = node as Record<string, unknown>;
if (typeof item.field === "string" && item.field) {
result.push({
field: item.field,
title: String(item.title ?? item.field),
optionMap: extractOptionMap(item.options),
});
}
if (Array.isArray(item.children)) {
walk(item.children);
}
});
};
walk(Array.isArray(rules) ? rules : []);
return result;
}
/**
* 提取字段选项映射(value -> label)
*
* 非对象或缺失 value/label 的脏项跳过
*
* @param options 规则里的 options 数组(JSON 解析值)
*/
export function extractOptionMap(options: unknown): Map<string, string> {
const optionMap = new Map<string, string>();
if (!Array.isArray(options)) return optionMap;
options.forEach((option) => {
if (!option || typeof option !== "object") return;
const { value, label } = option as Record<string, unknown>;
if (value !== undefined && label !== undefined) {
optionMap.set(String(value), String(label));
}
});
return optionMap;
}
/**
* 解析提交数据(field -> value 映射,解析失败按空数据兜底)
*
* @param dataJson 数据 JSON 字符串
*/
export function parseDataJson(dataJson?: string): Record<string, unknown> {
try {
return dataJson ? (JSON.parse(dataJson) as Record<string, unknown>) : {};
} catch {
return {};
}
}
/**
* 格式化单元格展示值(选项翻译、数组拼接、对象序列化、空值占位)
*
* @param value 字段值
* @param field 字段元数据(选项类字段 value 翻译为 label,未命中映射时原样展示)
*/
export function formatCellValue(value: unknown, field?: FormFieldMeta): string {
if (value === null || value === undefined || value === "") return "-";
if (Array.isArray(value)) return value.map((item) => formatCellValue(item, field)).join(", ");
if (typeof value === "object") return JSON.stringify(value);
const raw = String(value);
return field?.optionMap.get(raw) ?? raw;
}