Vue3 + Iframe 实战:打造企业级流程配置中心(附完整代码)

Vue3 + Iframe 实战:打造企业级流程配置中心(附完整代码)

在 SaaS 化合同管理系统中,如何让用户自定义每个业务模块的审批流程?本文将带你从零开始,基于 Vue3 + TypeScript + Ant Design Vue,实现一个支持"字段配置 + 流程设计"的一体化配置中心,并解决 Iframe 嵌入、状态回传、多来源返回等核心难题。


🎯 项目背景

我们的合同管理系统需要支持高度定制化:

  • 不同客户可启用/禁用不同功能模块(如"合同用印"、"合同变更")
  • 每个模块的表单字段可自由配置显示/编辑/必填
  • 每个模块可独立配置审批流程图

为此,我们设计了「合同业务设置」页面,包含三个核心子功能:

  1. 功能模块开关 → 控制哪些模块可用
  2. 字段配置面板 → 控制每个字段的展示规则
  3. 流程设计器嵌入 → 通过 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 平台、低代码系统、企业级后台管理系统

如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、转发!有任何问题也欢迎在评论区交流~