!330 feat(form-create): 【ele/antd】新增 iframe 和省市区选择器组件

Merge pull request !330 from puhui999/master-fix
This commit is contained in:
xingyu
2026-02-10 01:32:01 +00:00
committed by Gitee
14 changed files with 847 additions and 0 deletions

View File

@@ -0,0 +1,154 @@
<!-- 省市区选择器 (Ant Design Vue 版本) -->
<script lang="ts" setup>
import { onMounted, ref, watch } from 'vue';
import { handleTree } from '@vben/utils';
import { Cascader } from 'ant-design-vue';
import { getAreaTree } from '#/api/system/area';
defineOptions({ name: 'AreaSelect' });
const props = withDefaults(defineProps<Props>(), {
modelValue: undefined,
value: undefined,
level: 3,
disabled: false,
placeholder: '请选择省市区',
clearable: true,
showAllLevels: true,
separator: '/',
});
const emit = defineEmits<{
(e: 'update:modelValue', value: number[] | string[] | undefined): void;
(e: 'update:value', value: number[] | string[] | undefined): void;
}>();
// 地区数据接口
interface AreaVO {
id: number;
name: string;
code: string;
parentId?: number;
sort?: number;
status?: number;
children?: AreaVO[];
}
// 接受父组件参数
interface Props {
modelValue?: number[] | string[];
value?: number[] | string[];
level?: 1 | 2 | 3; // 1-省 2-市 3-区
disabled?: boolean;
placeholder?: string;
clearable?: boolean;
showAllLevels?: boolean;
separator?: string;
formCreateInject?: any;
}
// Ant Design Vue Cascader 的 fieldNames 配置
const fieldNames = {
label: 'name',
value: 'id',
children: 'children',
};
// 地区树形数据
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);
emit('update:value', undefined);
return;
}
emit('update:modelValue', value);
emit('update:value', value);
}
// 同步 modelValue 或 value 到内部选中值
function syncSelectedValue(): void {
const newValue = props.modelValue || props.value;
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 和 value 变化
watch(() => props.modelValue || props.value, syncSelectedValue, { immediate: true });
// 组件挂载时加载数据
onMounted(async () => {
await loadAreaTree();
});
</script>
<template>
<Cascader
v-model:value="selectedValue"
class="w-full"
:options="areaTree"
:field-names="fieldNames"
:disabled="disabled"
:placeholder="placeholder"
:allow-clear="clearable"
:show-search="true"
:change-on-select="true"
:loading="loading"
@change="handleChange"
/>
</template>

View File

@@ -0,0 +1,107 @@
<!-- 网页 iframe 组件 (Ant Design Vue 版本) -->
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
defineOptions({ name: 'IframeComponent' });
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
value: '',
url: '',
height: '500px',
width: '100%',
frameborder: '0',
allowfullscreen: true,
loading: 'lazy',
sandbox: '',
});
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
(e: 'update:value', value: string): void;
}>();
// 接受父组件参数
interface Props {
modelValue?: string;
value?: string;
url?: string;
height?: string;
width?: string;
frameborder?: string;
allowfullscreen?: boolean;
loading?: 'eager' | 'lazy';
sandbox?: string;
formCreateInject?: any;
}
// 显示的 URL优先使用 url prop其次使用 value 或 modelValue
const displayUrl = computed(() => props.url || props.value || 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>
<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">
<a-empty description="请在右侧属性面板配置 URL 地址" />
</div>
</div>
</template>
<style scoped>
.iframe-component {
width: 100%;
}
.iframe-preview {
border: 1px solid #d9d9d9;
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 #d9d9d9;
border-radius: 4px;
background-color: #fafafa;
}
</style>

View File

@@ -17,6 +17,8 @@ import {
useUploadFileRule,
useUploadImageRule,
useUploadImagesRule,
useIframeRule,
useAreaSelectRule,
} from './rules';
/** 编码表单 Conf */
@@ -160,6 +162,8 @@ export async function useFormCreateDesigner(designer: Ref) {
const uploadFileRule = useUploadFileRule();
const uploadImageRule = useUploadImageRule();
const uploadImagesRule = useUploadImagesRule();
const iframeRule = useIframeRule();
const areaSelectRule = useAreaSelectRule();
/** 构建表单组件 */
function buildFormComponents() {
@@ -172,6 +176,8 @@ export async function useFormCreateDesigner(designer: Ref) {
uploadFileRule,
uploadImageRule,
uploadImagesRule,
iframeRule,
areaSelectRule,
];
components.forEach((component) => {
// 插入组件规则

View File

@@ -1,5 +1,7 @@
export { useAreaSelectRule } from './use-area-select-rule';
export { useDictSelectRule } from './use-dict-select';
export { useEditorRule } from './use-editor-rule';
export { useIframeRule } from './use-iframe-rule';
export { useSelectRule } from './use-select-rule';
export { useUploadFileRule } from './use-upload-file-rule';
export { useUploadImageRule } from './use-upload-image-rule';

View File

@@ -0,0 +1,77 @@
import { cloneDeep } from '@vben/utils';
import {
localeProps,
makeRequiredRule,
} from '#/components/form-create/helpers';
/** 省市区选择器规则 */
export function useAreaSelectRule() {
const label = '省市区选择器';
const name = 'AreaSelect';
return {
icon: 'icon-location',
label,
name,
rule() {
return {
type: name,
field: `area_${Date.now()}`,
title: label,
info: '',
$required: false,
modelField: 'value', // 特殊ele 里是 model-valueantd 里是 value
};
},
props(_: any, { t }: any) {
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,77 @@
import { buildUUID } from '@vben/utils';
import {
localeProps,
makeRequiredRule,
} from '#/components/form-create/helpers';
/** iframe 组件规则 */
export function useIframeRule() {
const label = '网页 iframe';
const name = 'IframeComponent';
return {
icon: 'icon-link',
label,
name,
rule() {
return {
type: name,
field: buildUUID(),
title: label,
info: '',
$required: false,
modelField: 'value', // 特殊ele 里是 model-valueantd 里是 value
};
},
props(_: any, { t }: any) {
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

@@ -34,8 +34,10 @@ import {
// ======================= 自定义组件 =======================
import { useApiSelect } from '#/components/form-create';
import AreaSelect from '#/components/form-create/components/area-select.vue';
import DeptSelect from '#/components/form-create/components/dept-select.vue';
import DictSelect from '#/components/form-create/components/dict-select.vue';
import IframeComponent from '#/components/form-create/components/iframe.vue';
import { useImagesUpload } from '#/components/form-create/components/use-images-upload';
import { Tinymce } from '#/components/tinymce';
import { FileUpload, ImageUpload } from '#/components/upload';
@@ -84,6 +86,8 @@ const components = [
Tinymce,
ImageUpload,
FileUpload,
IframeComponent,
AreaSelect,
];
// 参考 https://www.form-create.com/v3/ant-design-vue/auto-import 文档

View File

@@ -0,0 +1,151 @@
<!-- 省市区选择器 (Element Plus 版本) -->
<script lang="ts" setup>
import { onMounted, ref, watch } from 'vue';
import { handleTree } from '@vben/utils';
import { ElCascader } from 'element-plus';
import { getAreaTree } from '#/api/system/area';
defineOptions({ name: 'AreaSelect' });
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;
}>();
// 地区数据接口
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;
}
// 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>
<template>
<ElCascader
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>

View File

@@ -0,0 +1,103 @@
<!-- 网页 iframe 组件 (Element Plus 版本) -->
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
defineOptions({ name: 'IframeComponent' });
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;
}>();
// 接受父组件参数
interface Props {
modelValue?: string;
url?: string;
height?: string;
width?: string;
frameborder?: string;
allowfullscreen?: boolean;
loading?: 'eager' | 'lazy';
sandbox?: string;
formCreateInject?: any;
}
// 显示的 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>
<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>
<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

@@ -17,6 +17,8 @@ import {
useUploadFileRule,
useUploadImageRule,
useUploadImagesRule,
useIframeRule,
useAreaSelectRule,
} from './rules';
/** 编码表单 Conf */
@@ -160,6 +162,8 @@ export async function useFormCreateDesigner(designer: Ref) {
const uploadFileRule = useUploadFileRule();
const uploadImageRule = useUploadImageRule();
const uploadImagesRule = useUploadImagesRule();
const iframeRule = useIframeRule();
const areaSelectRule = useAreaSelectRule();
/** 构建表单组件 */
function buildFormComponents() {
@@ -172,6 +176,8 @@ export async function useFormCreateDesigner(designer: Ref) {
uploadFileRule,
uploadImageRule,
uploadImagesRule,
iframeRule,
areaSelectRule,
];
components.forEach((component) => {
// 插入组件规则

View File

@@ -1,5 +1,7 @@
export { useAreaSelectRule } from './use-area-select-rule';
export { useDictSelectRule } from './use-dict-select';
export { useEditorRule } from './use-editor-rule';
export { useIframeRule } from './use-iframe-rule';
export { useSelectRule } from './use-select-rule';
export { useUploadFileRule } from './use-upload-file-rule';
export { useUploadImageRule } from './use-upload-image-rule';

View File

@@ -0,0 +1,77 @@
import { cloneDeep } from '@vben/utils';
import {
localeProps,
makeRequiredRule,
} from '#/components/form-create/helpers';
/** 省市区选择器规则 */
export function useAreaSelectRule() {
const label = '省市区选择器';
const name = 'AreaSelect';
return {
icon: 'icon-location',
label,
name,
rule() {
return {
type: name,
field: `area_${Date.now()}`,
title: label,
info: '',
$required: false,
modelField: 'model-value', // 特殊ele 里是 model-valueantd 里是 value
};
},
props(_: any, { t }: any) {
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,77 @@
import { buildUUID } from '@vben/utils';
import {
localeProps,
makeRequiredRule,
} from '#/components/form-create/helpers';
/** iframe 组件规则 */
export function useIframeRule() {
const label = '网页 iframe';
const name = 'IframeComponent';
return {
icon: 'icon-link',
label,
name,
rule() {
return {
type: name,
field: buildUUID(),
title: label,
info: '',
$required: false,
modelField: 'model-value', // 特殊ele 里是 model-valueantd 里是 value
};
},
props(_: any, { t }: any) {
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

@@ -34,8 +34,10 @@ import {
// ======================= 自定义组件 =======================
import { useApiSelect } from '#/components/form-create';
import AreaSelect from '#/components/form-create/components/area-select.vue';
import DeptSelect from '#/components/form-create/components/dept-select.vue';
import DictSelect from '#/components/form-create/components/dict-select.vue';
import IframeComponent from '#/components/form-create/components/iframe.vue';
import { useImagesUpload } from '#/components/form-create/components/use-images-upload';
import { Tinymce } from '#/components/tinymce';
import { FileUpload, ImageUpload } from '#/components/upload';
@@ -60,6 +62,8 @@ const components = [
UserSelect,
DeptSelect,
ApiSelect,
IframeComponent,
AreaSelect,
ElAlert,
ElTransfer,
ElAside,