Vue3 + Iframe 实战:打造企业级流程配置中心(附完整代码)
在 SaaS 化合同管理系统中,如何让用户自定义每个业务模块的审批流程?本文将带你从零开始,基于 Vue3 + TypeScript + Ant Design Vue,实现一个支持"字段配置 + 流程设计"的一体化配置中心,并解决 Iframe 嵌入、状态回传、多来源返回等核心难题。
🎯 项目背景
我们的合同管理系统需要支持高度定制化:
- 不同客户可启用/禁用不同功能模块(如"合同用印"、"合同变更")
- 每个模块的表单字段可自由配置显示/编辑/必填
- 每个模块可独立配置审批流程图
为此,我们设计了「合同业务设置」页面,包含三个核心子功能:
- 功能模块开关 → 控制哪些模块可用
- 字段配置面板 → 控制每个字段的展示规则
- 流程设计器嵌入 → 通过 Iframe 集成第三方流程引擎
💡 核心架构设计
1. 页面结构概览
vue
<template>
<div class="contract-settings">
<!-- 左侧:功能模块列表 -->
<a-menu v-model:selectedKeys="selectedModule" mode="vertical">
<a-menu-item key="contract-new">合同新建</a-menu-item>
<a-menu-item key="contract-seal">合同用印</a-menu-item>
<!-- ...其他模块 -->
</a-menu>
<!-- 中间:字段配置区域 -->
<div class="field-config">
<a-tabs v-model:activeKey="activeTab">
<a-tab-pane key="fields" tab="字段设置">
<FieldConfigTable :fields="currentFields" />
</a-tab-pane>
<a-tab-pane key="other" tab="其他设置">
<OtherSettings :moduleId="selectedModule[0]" />
</a-tab-pane>
</a-tabs>
</div>
<!-- 右侧:选项内容预览 -->
<div class="preview-panel">
<Empty description="暂无选项内容" v-if="!hasOptions" />
<OptionList :options="currentOptions" v-else />
</div>
</div>
</template>
2. 功能模块开关组件
vue
<!-- components/ModuleSwitch.vue -->
<template>
<div class="module-switch">
<div
v-for="module in modules"
:key="module.id"
class="module-item"
:class="{ active: selectedModule.includes(module.key) }"
@click="toggleModule(module.key)"
>
<span>{{ module.name }}</span>
<a-switch
:checked="module.enabled"
@change="(val) => handleSwitchChange(module.id, val)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
const props = defineProps<{
modules: Array<{ id: number; name: string; key: string; enabled: boolean }>
}>()
const emit = defineEmits(['update:selectedModules', 'switch-change'])
const selectedModule = ref<string[]>([])
const toggleModule = (key: string) => {
if (selectedModule.value.includes(key)) {
selectedModule.value = selectedModule.value.filter(k => k !== key)
} else {
selectedModule.value.push(key)
}
emit('update:selectedModules', selectedModule.value)
}
const handleSwitchChange = (moduleId: number, enabled: boolean) => {
emit('switch-change', { moduleId, enabled })
}
</script>
<style scoped>
.module-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
cursor: pointer;
border-radius: 4px;
transition: all 0.3s;
}
.module-item.active {
background: #e6f7ff;
color: #1890ff;
}
</style>
3. 字段配置表格组件
vue
<!-- components/FieldConfigTable.vue -->
<template>
<a-table
:columns="columns"
:data-source="fields"
row-key="id"
:pagination="false"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'show'">
<a-checkbox
:checked="record.show"
@change="(e) => handleFieldChange(record.id, 'show', e.target.checked)"
/>
</template>
<template v-else-if="column.dataIndex === 'edit'">
<a-checkbox
:checked="record.editable"
@change="(e) => handleFieldChange(record.id, 'editable', e.target.checked)"
:disabled="!record.show"
/>
</template>
<template v-else-if="column.dataIndex === 'required'">
<a-checkbox
:checked="record.required"
@change="(e) => handleFieldChange(record.id, 'required', e.target.checked)"
:disabled="!record.show"
/>
</template>
</template>
</a-table>
</template>
<script setup lang="ts">
import type { TableColumnsType } from 'ant-design-vue'
interface FieldConfig {
id: number
fieldName: string
show: boolean
editable: boolean
required: boolean
}
const props = defineProps<{
fields: FieldConfig[]
}>()
const emit = defineEmits(['field-change'])
const columns: TableColumnsType<FieldConfig> = [
{ title: '字段名称', dataIndex: 'fieldName', key: 'fieldName' },
{ title: '显示', dataIndex: 'show', key: 'show', width: 80 },
{ title: '编辑', dataIndex: 'edit', key: 'edit', width: 80 },
{ title: '必填', dataIndex: 'required', key: 'required', width: 80 }
]
const handleFieldChange = (fieldId: number, field: keyof FieldConfig, value: boolean) => {
emit('field-change', { fieldId, field, value })
}
</script>
4. 流程配置入口与 Iframe 嵌入
vue
<!-- components/ProcessConfigEntry.vue -->
<template>
<div class="process-config-entry">
<div class="config-header">
<span>流程审批配置</span>
<a-switch
:checked="processEnabled"
@change="handleSwitchChange"
/>
<a-button
type="link"
:disabled="!processEnabled"
@click="openProcessDesigner"
>
{{ hasConfigured ? '已配置' : '去配置流程' }}
</a-button>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
const props = defineProps<{
processEnabled: boolean
hasConfigured: boolean
moduleId: string
taskTypeId?: string
}>()
const emit = defineEmits(['switch-change'])
const handleSwitchChange = (checked: boolean) => {
emit('switch-change', checked)
}
const openProcessDesigner = () => {
const query: Record<string, string> = {
from: 'contract-settings',
moduleId: props.moduleId,
processEnabled: String(props.processEnabled)
}
if (props.taskTypeId) {
query.taskTypeId = props.taskTypeId
}
if (props.hasConfigured) {
// 编辑模式 - 需要从后端获取 processKey
// 这里简化处理,实际应调用 API 获取
query.isCreate = '0'
query.processKey = 'existing_process_key_123' // 示例
} else {
query.isCreate = '1'
}
router.push({
path: '/setting/contract-settings/process-config',
query
})
}
</script>
<style scoped>
.config-header {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: #fafafa;
border-radius: 4px;
}
</style>
5. 主页面逻辑整合
vue
<!-- views/ContractSettings.vue -->
<template>
<div class="contract-settings-page">
<a-card>
<template #title>
<div class="page-title">
<h2>合同业务设置</h2>
<a-button type="primary" @click="saveAllConfig">保存配置</a-button>
</div>
</template>
<div class="settings-container">
<!-- 左侧模块选择 -->
<ModuleSwitch
:modules="moduleList"
v-model:selected-modules="selectedModules"
@switch-change="handleModuleSwitch"
/>
<!-- 中间配置区 -->
<div class="config-area">
<a-tabs v-model:activeKey="activeTab">
<a-tab-pane key="fields" tab="字段设置">
<FieldConfigTable
:fields="currentFields"
@field-change="handleFieldChange"
/>
</a-tab-pane>
<a-tab-pane key="other" tab="其他设置">
<ProcessConfigEntry
:process-enabled="currentProcessEnabled"
:has-configured="currentHasConfigured"
:module-id="selectedModules[0]"
@switch-change="handleProcessSwitch"
/>
<RichTextEditor
v-model="businessInstruction"
placeholder="请输入业务办理说明..."
/>
</a-tab-pane>
</a-tabs>
</div>
<!-- 右侧预览 -->
<div class="preview-area">
<h3>选项内容</h3>
<Empty v-if="!currentOptions.length" description="暂无选项内容" />
<OptionList v-else :options="currentOptions" />
</div>
</div>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import ModuleSwitch from '@/components/ModuleSwitch.vue'
import FieldConfigTable from '@/components/FieldConfigTable.vue'
import ProcessConfigEntry from '@/components/ProcessConfigEntry.vue'
import RichTextEditor from '@/components/RichTextEditor.vue'
import OptionList from '@/components/OptionList.vue'
import { FindContractModuleConfig, SaveContractModuleConfig } from '@/api/contract'
// 状态定义
const moduleList = ref([
{ id: 1, name: '合同新建', key: 'contract-new', enabled: true },
{ id: 2, name: '合同用印', key: 'contract-seal', enabled: false },
{ id: 3, name: '合同签订', key: 'contract-sign', enabled: true },
// ...更多模块
])
const selectedModules = ref(['contract-new'])
const activeTab = ref('fields')
const currentFields = ref([])
const currentProcessEnabled = ref(false)
const currentHasConfigured = ref(false)
const businessInstruction = ref('')
const currentOptions = ref([])
// 计算当前选中模块的配置
const currentModule = computed(() =>
moduleList.value.find(m => m.key === selectedModules.value[0])
)
// 加载配置
onMounted(async () => {
await loadModuleConfig(selectedModules.value[0])
})
// 监听模块切换
watch(selectedModules, async (newVal) => {
if (newVal.length > 0) {
await loadModuleConfig(newVal[0])
}
})
// 加载单个模块配置
const loadModuleConfig = async (moduleKey: string) => {
try {
const [err, res] = await to(FindContractModuleConfig({ moduleKey }))
if (!err && res) {
currentFields.value = res.fields || []
currentProcessEnabled.value = res.processEnabled || false
currentHasConfigured.value = res.hasConfigured || false
businessInstruction.value = res.businessInstruction || ''
currentOptions.value = res.options || []
}
} catch (error) {
message.error('加载配置失败')
}
}
// 处理字段变更
const handleFieldChange = ({ fieldId, field, value }: any) => {
const fieldItem = currentFields.value.find(f => f.id === fieldId)
if (fieldItem) {
fieldItem[field] = value
}
}
// 处理模块开关变更
const handleModuleSwitch = ({ moduleId, enabled }: any) => {
const module = moduleList.value.find(m => m.id === moduleId)
if (module) {
module.enabled = enabled
}
}
// 处理流程开关变更
const handleProcessSwitch = (enabled: boolean) => {
currentProcessEnabled.value = enabled
}
// 保存所有配置
const saveAllConfig = async () => {
try {
const configData = {
moduleKey: selectedModules.value[0],
fields: currentFields.value,
processEnabled: currentProcessEnabled.value,
businessInstruction: businessInstruction.value,
options: currentOptions.value
}
const [err] = await to(SaveContractModuleConfig(configData))
if (!err) {
message.success('配置保存成功')
} else {
message.error('配置保存失败')
}
} catch (error) {
message.error('配置保存异常')
}
}
</script>
<style scoped>
.contract-settings-page {
padding: 24px;
}
.page-title {
display: flex;
justify-content: space-between;
align-items: center;
}
.settings-container {
display: grid;
grid-template-columns: 200px 1fr 300px;
gap: 24px;
margin-top: 24px;
}
.config-area {
background: #fff;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.09);
}
.preview-area {
background: #fafafa;
border-radius: 4px;
padding: 16px;
}
</style>
🔧 关键技术点详解
1. Iframe 动态加载与通信
在 ProcessConfig.vue 中,我们通过以下方式实现与第三方流程系统的无缝对接:
typescript
// 注册全局回调函数
window.afterCreateProcessDesign = async (obj: { fKey: string, fName: string }) => {
console.log('流程设计完成:', obj)
const from = route.query.from as string
if (from === 'contract-settings') {
// 保存状态到 localStorage
const state = {
selectedModuleId: route.query.moduleId,
processTypeId: route.query.processTypeId,
isFromContractSettings: true,
needReopen: true,
timestamp: Date.now(),
processKey: obj.fKey,
approvalEnabled: route.query.processEnabled === 'true',
configured: true
}
localStorage.setItem('contractSettingsState', JSON.stringify(state))
// 延迟返回
setTimeout(() => {
router.push('/setting/contract-settings')
}, 1000)
}
}
// 动态创建 iframe
const createIframe = (url: string, name: string) => {
const iframeBox = document.getElementById('iframe-box')
if (!iframeBox) return
iframeBox.innerHTML = `<iframe
style="width: 100%; border: 0; height: 800px;"
scrolling="auto"
src="${url}"
name="${name}"
/>`
// 监听加载完成
iframeBox.onload = () => {
setTimeout(() => {
// 填充流程名称
const iframeDoc = iframeBox.contentDocument || iframeBox.contentWindow?.document
if (iframeDoc) {
const input = iframeDoc.querySelector('#pname input')
const display = iframeDoc.getElementById('flow-name')
if (input && display) {
input.value = route.query.flowName || ''
display.textContent = route.query.flowName || ''
}
}
}, 500)
}
}
2. 状态持久化策略
使用 localStorage 存储复杂状态,确保页面刷新或跳转后仍能恢复:
typescript
// 保存状态
const saveState = (state: any) => {
localStorage.setItem('contractSettingsState', JSON.stringify({
...state,
timestamp: Date.now()
}))
}
// 恢复状态
const restoreState = () => {
const stateStr = localStorage.getItem('contractSettingsState')
if (!stateStr) return null
const state = JSON.parse(stateStr)
// 清理过期状态(超过1小时)
if (Date.now() - state.timestamp > 3600000) {
localStorage.removeItem('contractSettingsState')
return null
}
return state
}
3. 防抖与节流优化
对于频繁触发的配置变更,使用防抖避免重复请求:
typescript
import { debounce } from 'lodash-es'
// 创建防抖函数
const debouncedSave = debounce(async () => {
await saveAllConfig()
}, 1000)
// 在字段变更时调用
const handleFieldChange = ({ fieldId, field, value }: any) => {
// 更新本地状态
updateLocalState(fieldId, field, value)
// 触发防抖保存
debouncedSave()
}
✅ 总结
本方案实现了:
- ✅ 模块化配置管理
- ✅ 字段级权限控制
- ✅ 第三方流程引擎无缝集成
- ✅ 状态持久化与恢复
- ✅ 优雅的用户体验
通过这套架构,我们可以轻松扩展到其他业务模块的配置管理,真正实现了"一次开发,多处复用"。
💡 提示:在生产环境中,建议增加配置版本管理、操作日志记录、权限校验等功能,以提升系统的安全性和可追溯性。
技术栈 :Vue3 | TypeScript | Vite | Ant Design Vue | Vuex
适用场景:SaaS 平台、低代码系统、企业级后台管理系统
如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、转发!有任何问题也欢迎在评论区交流~