!861 feat(form-create): 新增 iframe 和省市区选择器组件

Merge pull request !861 from puhui999/master-dev
This commit is contained in:
芋道源码
2026-02-08 06:32:21 +00:00
committed by Gitee
7 changed files with 410 additions and 3 deletions

View File

@@ -0,0 +1,145 @@
<!-- 省市区选择器 (Element Plus 版本 - Vue3) -->
<template>
<el-cascader
v-model="selectedValue"
class="w-full"
:options="areaTree"
:props="cascaderProps"
:disabled="disabled"
:placeholder="placeholder"
:clearable="clearable"
:show-all-levels="showAllLevels"
:separator="separator"
:loading="loading"
@change="handleChange"
/>
</template>
<script lang="ts" setup>
import { onMounted, ref, watch } from 'vue'
import { getAreaTree } from '@/api/system/area'
import { handleTree } from '@/utils/tree'
defineOptions({ name: 'AreaSelect' })
interface AreaVO {
id: number
name: string
code: string
parentId?: number
sort?: number
status?: number
children?: AreaVO[]
}
interface Props {
modelValue?: number[] | string[]
level?: 1 | 2 | 3 // 1-省 2-市 3-区
disabled?: boolean
placeholder?: string
clearable?: boolean
showAllLevels?: boolean
separator?: string
formCreateInject?: any
}
const props = withDefaults(defineProps<Props>(), {
modelValue: undefined,
level: 3,
disabled: false,
placeholder: '请选择省市区',
clearable: true,
showAllLevels: true,
separator: '/'
})
const emit = defineEmits<{
(e: 'update:modelValue', value: number[] | string[] | undefined): void
}>()
// Element Plus Cascader 的 props 配置
const cascaderProps = {
label: 'name',
value: 'id',
children: 'children',
checkStrictly: true, // 允许选择任意级别
emitPath: true // 返回完整路径
}
// 地区树形数据
const areaTree = ref<AreaVO[]>([])
// 当前选中值
const selectedValue = ref<number[] | undefined>()
// 加载状态
const loading = ref(false)
// 加载地区树形数据
async function loadAreaTree(): Promise<void> {
try {
loading.value = true
const data = await getAreaTree()
// 根据 level 限制层级
areaTree.value = filterTreeByLevel(data || [], props.level)
} catch (error) {
console.warn('[AreaSelect] 加载地区数据失败:', error)
areaTree.value = []
} finally {
loading.value = false
}
}
// 根据层级过滤树形数据
function filterTreeByLevel(tree: AreaVO[], maxLevel: number): AreaVO[] {
if (maxLevel <= 0) return []
return tree.map((node) => {
const newNode = { ...node }
// 如果当前是最后一层,移除 children
if (maxLevel === 1) {
delete newNode.children
} else if (node.children && node.children.length > 0) {
// 递归处理子节点
newNode.children = filterTreeByLevel(node.children, maxLevel - 1)
}
return newNode
})
}
// 处理选中值变化
function handleChange(value: number[] | undefined): void {
if (value === undefined || value === null) {
emit('update:modelValue', undefined)
return
}
emit('update:modelValue', value)
}
// 同步 modelValue 到内部选中值
function syncSelectedValue(): void {
const newValue = props.modelValue
if (newValue === undefined || newValue === null) {
selectedValue.value = undefined
return
}
// 确保是数组格式
if (Array.isArray(newValue)) {
selectedValue.value = newValue as number[]
} else {
selectedValue.value = [newValue as number]
}
}
// 监听 modelValue 变化
watch(() => props.modelValue, syncSelectedValue, { immediate: true })
// 组件挂载时加载数据
onMounted(async () => {
await loadAreaTree()
})
</script>

View File

@@ -0,0 +1,102 @@
<!-- 网页 iframe 组件 (Element Plus 版本 - Vue3) -->
<template>
<div class="iframe-component">
<!-- iframe 预览 -->
<div v-if="showPreview" class="iframe-preview">
<iframe
:src="displayUrl"
:width="width"
:height="height"
:frameborder="frameborder"
:allowfullscreen="allowfullscreen"
:loading="loading"
:sandbox="sandbox || undefined"
class="iframe-content"
></iframe>
</div>
<!-- URL 或无效 URL 提示 -->
<div v-else class="iframe-placeholder">
<el-empty description="请在右侧属性面板配置 URL 地址" />
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
defineOptions({ name: 'IframeComponent' })
interface Props {
modelValue?: string
url?: string
height?: string
width?: string
frameborder?: string
allowfullscreen?: boolean
loading?: 'eager' | 'lazy'
sandbox?: string
formCreateInject?: any
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
url: '',
height: '500px',
width: '100%',
frameborder: '0',
allowfullscreen: true,
loading: 'lazy',
sandbox: ''
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
// 显示的 URL优先使用 url prop其次使用 modelValue
const displayUrl = computed(() => props.url || props.modelValue || '')
// 是否显示预览
const showPreview = computed(() => {
return displayUrl.value && isValidUrl(displayUrl.value)
})
// URL 验证
function isValidUrl(url: string): boolean {
if (!url || url.trim() === '') return false
try {
const urlObj = new URL(url)
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:'
} catch {
return false
}
}
</script>
<style scoped>
.iframe-component {
width: 100%;
}
.iframe-preview {
border: 1px solid #dcdfe6;
border-radius: 4px;
overflow: hidden;
}
.iframe-content {
display: block;
border: none;
}
.iframe-placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
border: 1px dashed #dcdfe6;
border-radius: 4px;
background-color: #fafafa;
}
</style>

View File

@@ -4,6 +4,8 @@ import { useUploadImgsRule } from './useUploadImgsRule'
import { useDictSelectRule } from './useDictSelectRule'
import { useEditorRule } from './useEditorRule'
import { useSelectRule } from './useSelectRule'
import { useIframeRule } from './useIframeRule'
import { useAreaSelectRule } from './useAreaSelectRule'
export {
useUploadFileRule,
@@ -11,5 +13,7 @@ export {
useUploadImgsRule,
useDictSelectRule,
useEditorRule,
useSelectRule
useSelectRule,
useIframeRule,
useAreaSelectRule
}

View File

@@ -0,0 +1,74 @@
import { generateUUID } from '@/utils'
import { localeProps, makeRequiredRule } from '@/components/FormCreate/src/utils'
/**
* 省市区选择器规则
*/
export const useAreaSelectRule = () => {
const label = '省市区选择器'
const name = 'AreaSelect'
return {
icon: 'icon-location',
label,
name,
rule() {
return {
type: name,
field: generateUUID(),
title: label,
info: '',
$required: false
}
},
props(_, { t }) {
return localeProps(t, name + '.props', [
makeRequiredRule(),
{
type: 'select',
field: 'level',
title: '选择层级',
value: 3,
options: [
{ label: '省', value: 1 },
{ label: '省/市', value: 2 },
{ label: '省/市/区', value: 3 }
],
info: '限制可选择的地区层级'
},
{
type: 'input',
field: 'placeholder',
title: '占位符',
value: '请选择省市区'
},
{
type: 'switch',
field: 'clearable',
title: '是否可清空',
value: true
},
{
type: 'switch',
field: 'showAllLevels',
title: '显示完整路径',
value: true,
info: '输入框中是否显示选中值的完整路径'
},
{
type: 'input',
field: 'separator',
title: '分隔符',
value: '/',
info: '选项分隔符'
},
{
type: 'switch',
field: 'disabled',
title: '是否禁用',
value: false
}
])
}
}
}

View File

@@ -0,0 +1,74 @@
import { generateUUID } from '@/utils'
import { localeProps, makeRequiredRule } from '@/components/FormCreate/src/utils'
/**
* iframe 组件规则
*/
export const useIframeRule = () => {
const label = '网页 iframe'
const name = 'IframeComponent'
return {
icon: 'icon-link',
label,
name,
rule() {
return {
type: name,
field: generateUUID(),
title: label,
info: '',
$required: false
}
},
props(_, { t }) {
return localeProps(t, name + '.props', [
makeRequiredRule(),
{
type: 'input',
field: 'url',
title: 'URL 地址',
value: '',
info: '请输入完整的 HTTP 或 HTTPS 地址'
},
{
type: 'input',
field: 'height',
title: 'iframe 高度',
value: '500px',
info: '支持 px、%、vh 等单位'
},
{
type: 'input',
field: 'width',
title: 'iframe 宽度',
value: '100%',
info: '支持 px、%、vw 等单位'
},
{
type: 'select',
field: 'loading',
title: '加载方式',
value: 'lazy',
options: [
{ label: '懒加载', value: 'lazy' },
{ label: '立即加载', value: 'eager' }
]
},
{
type: 'switch',
field: 'allowfullscreen',
title: '允许全屏',
value: true
},
{
type: 'input',
field: 'sandbox',
title: 'sandbox 属性',
value: '',
info: '安全沙箱限制allow-scripts allow-same-origin'
}
])
}
}
}

View File

@@ -4,7 +4,9 @@ import {
useSelectRule,
useUploadFileRule,
useUploadImgRule,
useUploadImgsRule
useUploadImgsRule,
useIframeRule,
useAreaSelectRule
} from './config'
import { Ref } from 'vue'
import { Menu } from '@/components/FormCreate/src/type'
@@ -36,7 +38,9 @@ export const useFormCreateDesigner = async (designer: Ref) => {
designer.value?.removeMenuItem('upload')
// 移除自带的富文本组件规则,使用 editorRule 替代
designer.value?.removeMenuItem('fcEditor')
const components = [editorRule, uploadFileRule, uploadImgRule, uploadImgsRule]
const iframeRule = useIframeRule()
const areaSelectRule = useAreaSelectRule()
const components = [editorRule, uploadFileRule, uploadImgRule, uploadImgsRule, iframeRule, areaSelectRule]
components.forEach((component) => {
// 插入组件规则
designer.value?.addComponent(component)

View File

@@ -69,6 +69,8 @@ import { useApiSelect } from '@/components/FormCreate'
import { Editor } from '@/components/Editor'
import DictSelect from '@/components/FormCreate/src/components/DictSelect.vue'
import DeptSelect from '@/components/FormCreate/src/components/DeptSelect.vue'
import IframeComponent from '@/components/FormCreate/src/components/IframeComponent.vue'
import AreaSelect from '@/components/FormCreate/src/components/AreaSelect.vue'
const UserSelect = useApiSelect({
name: 'UserSelect',
@@ -114,6 +116,8 @@ const components = [
DeptSelect,
ApiSelect,
Editor,
IframeComponent,
AreaSelect,
ElCollapse,
ElCollapseItem,
ElCard