This commit is contained in:
dap
2025-11-30 01:57:18 +08:00
152 changed files with 632 additions and 273 deletions

View File

@@ -3,15 +3,31 @@
* 可用于 vben-form、vben-modal、vben-drawer 等组件使用,
*/
import type { Component } from 'vue';
import type {
UploadChangeParam,
UploadFile,
UploadProps,
} from 'ant-design-vue';
import type { Component, Ref } from 'vue';
import type { BaseFormComponentType } from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import { defineAsyncComponent, defineComponent, h, ref } from 'vue';
import {
defineAsyncComponent,
defineComponent,
h,
ref,
render,
unref,
watch,
} from 'vue';
import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { $t } from '@vben/locales';
import { isEmpty } from '@vben/utils';
import { notification } from 'ant-design-vue';
@@ -60,6 +76,10 @@ const TreeSelect = defineAsyncComponent(
() => import('ant-design-vue/es/tree-select'),
);
const Upload = defineAsyncComponent(() => import('ant-design-vue/es/upload'));
const Image = defineAsyncComponent(() => import('ant-design-vue/es/image'));
const PreviewGroup = defineAsyncComponent(() =>
import('ant-design-vue/es/image').then((res) => res.ImagePreviewGroup),
);
const withDefaultPlaceholder = <T extends Component>(
component: T,
@@ -104,6 +124,216 @@ const withDefaultPlaceholder = <T extends Component>(
});
};
const withPreviewUpload = () => {
return defineComponent({
name: Upload.name,
emits: ['change', 'update:modelValue'],
setup: (
props: any,
{ attrs, slots, emit }: { attrs: any; emit: any; slots: any },
) => {
const previewVisible = ref<boolean>(false);
const placeholder = attrs?.placeholder || $t(`ui.placeholder.upload`);
const listType = attrs?.listType || attrs?.['list-type'] || 'text';
const fileList = ref<UploadProps['fileList']>(
attrs?.fileList || attrs?.['file-list'] || [],
);
const handleChange = async (event: UploadChangeParam) => {
fileList.value = event.fileList;
emit('change', event);
emit(
'update:modelValue',
event.fileList?.length ? fileList.value : undefined,
);
};
const handlePreview = async (file: UploadFile) => {
previewVisible.value = true;
await previewImage(file, previewVisible, fileList);
};
const renderUploadButton = (): any => {
const isDisabled = attrs.disabled;
// 如果禁用,不渲染上传按钮
if (isDisabled) {
return null;
}
// 否则渲染默认上传按钮
return isEmpty(slots)
? createDefaultSlotsWithUpload(listType, placeholder)
: slots;
};
// 可以监听到表单API设置的值
watch(
() => attrs.modelValue,
(res) => {
fileList.value = res;
},
);
return () =>
h(
Upload,
{
...props,
...attrs,
fileList: fileList.value,
onChange: handleChange,
onPreview: handlePreview,
},
renderUploadButton(),
);
},
});
};
const createDefaultSlotsWithUpload = (
listType: string,
placeholder: string,
) => {
switch (listType) {
case 'picture-card': {
return {
default: () => placeholder,
};
}
default: {
return {
default: () =>
h(
Button,
{
icon: h(IconifyIcon, {
icon: 'ant-design:upload-outlined',
class: 'mb-1 size-4',
}),
},
() => placeholder,
),
};
}
}
};
const previewImage = async (
file: UploadFile,
visible: Ref<boolean>,
fileList: Ref<UploadProps['fileList']>,
) => {
// 检查是否为图片文件的辅助函数
const isImageFile = (file: UploadFile): boolean => {
const imageExtensions = new Set([
'bmp',
'gif',
'jpeg',
'jpg',
'png',
'webp',
]);
if (file.url) {
const ext = file.url?.split('.').pop()?.toLowerCase();
return ext ? imageExtensions.has(ext) : false;
}
if (!file.type) {
const ext = file.name?.split('.').pop()?.toLowerCase();
return ext ? imageExtensions.has(ext) : false;
}
return file.type.startsWith('image/');
};
// 如果当前文件不是图片,直接打开
if (!isImageFile(file)) {
if (file.url) {
window.open(file.url, '_blank');
} else if (file.preview) {
window.open(file.preview, '_blank');
} else {
console.warn('无法打开文件没有可用的URL或预览地址');
}
return;
}
// 对于图片文件,继续使用预览组
const [ImageComponent, PreviewGroupComponent] = await Promise.all([
Image,
PreviewGroup,
]);
const getBase64 = (file: File) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.addEventListener('load', () => resolve(reader.result));
reader.addEventListener('error', (error) => reject(error));
});
};
// 从fileList中过滤出所有图片文件
const imageFiles = (unref(fileList) || []).filter((element) =>
isImageFile(element),
);
// 为所有没有预览地址的图片生成预览
for (const imgFile of imageFiles) {
if (!imgFile.url && !imgFile.preview && imgFile.originFileObj) {
imgFile.preview = (await getBase64(imgFile.originFileObj)) as string;
}
}
const container: HTMLElement | null = document.createElement('div');
document.body.append(container);
// 用于追踪组件是否已卸载
let isUnmounted = false;
const PreviewWrapper = {
setup() {
return () => {
if (isUnmounted) return null;
return h(
PreviewGroupComponent,
{
class: 'hidden',
preview: {
visible: visible.value,
// 设置初始显示的图片索引
current: imageFiles.findIndex((f) => f.uid === file.uid),
onVisibleChange: (value: boolean) => {
visible.value = value;
if (!value) {
// 延迟清理,确保动画完成
setTimeout(() => {
if (!isUnmounted && container) {
isUnmounted = true;
render(null, container);
container.remove();
}
}, 300);
}
},
},
},
() =>
// 渲染所有图片文件
imageFiles.map((imgFile) =>
h(ImageComponent, {
key: imgFile.uid,
src: imgFile.url || imgFile.preview,
}),
),
);
};
},
};
render(h(PreviewWrapper), container);
};
// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
export type ComponentType =
| 'ApiSelect'
@@ -185,7 +415,7 @@ async function initComponentAdapter() {
Textarea: withDefaultPlaceholder(Textarea, 'input'),
TimePicker,
TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'),
Upload,
Upload: withPreviewUpload(),
};
// 将组件注册到全局共享状态中

View File

@@ -1,9 +1,11 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { onMounted, ref } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
@@ -23,7 +25,7 @@ onMounted(() => {
{ name: '定制', value: 310 },
{ name: '技术支持', value: 274 },
{ name: '远程', value: 400 },
].sort((a, b) => {
].toSorted((a, b) => {
return a.value - b.value;
}),
name: '商业占比',

View File

@@ -56,7 +56,7 @@ async function changeAccount(role: string) {
<Card class="mb-5">
<template #title>
<span class="font-semibold">当前角色:</span>
<span class="text-primary mx-4 text-lg">
<span class="mx-4 text-lg text-primary">
{{ userStore.userRoles?.[0] }}
</span>
</template>

View File

@@ -66,7 +66,7 @@ async function handleToggleAccessMode() {
>
<Card class="mb-5" title="权限模式">
<span class="font-semibold">当前权限模式:</span>
<span class="text-primary mx-4">{{
<span class="mx-4 text-primary">{{
accessMode === 'frontend' ? '前端权限控制' : '后端权限控制'
}}</span>
<Button type="primary" @click="handleToggleAccessMode">

View File

@@ -31,7 +31,7 @@ const inputComponent = h(Input);
<template>
<Page title="图标">
<template #description>
<div class="text-foreground/80 mt-2">
<div class="mt-2 text-foreground/80">
图标可在
<a
class="text-primary"

View File

@@ -20,7 +20,7 @@ async function handleClick(type: LoginExpiredModeType) {
<template>
<Page title="登录过期演示">
<template #description>
<div class="text-foreground/80 mt-2">
<div class="mt-2 text-foreground/80">
接口请求遇到401状态码时需要重新登录有两种方式
<p>1.转到登录页登录成功后跳转回原页面</p>
<p>

View File

@@ -41,7 +41,7 @@ function reset() {
<template>
<Page description="用于需要操作标签页的场景" title="标签页">
<Card class="mb-5" title="打开/关闭标签页">
<div class="text-foreground/80 mb-3">
<div class="mb-3 text-foreground/80">
如果标签页存在直接跳转切换如果标签页不存在则打开新的标签页
</div>
<div class="flex flex-wrap gap-3">
@@ -53,7 +53,7 @@ function reset() {
</Card>
<Card class="mb-5" title="标签页操作">
<div class="text-foreground/80 mb-3">用于动态控制标签页的各种操作</div>
<div class="mb-3 text-foreground/80">用于动态控制标签页的各种操作</div>
<div class="flex flex-wrap gap-3">
<Button type="primary" @click="closeCurrentTab()">
关闭当前标签页
@@ -73,7 +73,7 @@ function reset() {
</Card>
<Card class="mb-5" title="动态标题">
<div class="text-foreground/80 mb-3">
<div class="mb-3 text-foreground/80">
该操作不会影响页面标题仅修改Tab标题
</div>
<div class="flex flex-wrap items-center gap-3">
@@ -90,7 +90,7 @@ function reset() {
</Card>
<Card class="mb-5" title="最大打开数量">
<div class="text-foreground/80 mb-3">
<div class="mb-3 text-foreground/80">
限制带参数的tab打开的最大数量 `route.meta.maxNumOfOpenTab` 控制
</div>
<div class="flex flex-wrap items-center gap-3">

View File

@@ -48,7 +48,7 @@ async function createWaterMark() {
<template>
<Page title="水印">
<template #description>
<div class="text-foreground/80 mt-2">
<div class="mt-2 text-foreground/80">
水印使用了
<a
class="text-primary"

View File

@@ -35,7 +35,7 @@ function handleUpdate(len: number) {
<div
v-for="item in list"
:key="item"
class="even:bg-heavy bg-muted flex-center h-[220px] w-full"
class="flex-center h-[220px] w-full bg-muted even:bg-heavy"
>
{{ item }}
</div>

View File

@@ -56,7 +56,7 @@ const leftMaxWidth = ref(props.leftMaxWidth || 100);
<div
v-else
:style="{ minWidth: '200px' }"
class="border-border bg-card mr-2 rounded-[var(--radius)] border p-2"
class="mr-2 rounded-[var(--radius)] border border-border bg-card p-2"
>
<p>这里是左侧内容</p>
<p>这里是左侧内容</p>

View File

@@ -51,7 +51,7 @@ const loadingV = refAutoReset(false, 3000);
<template #icon>
<IconifyIcon
icon="svg-spinners:ring-resize"
class="text-primary size-10"
class="size-10 text-primary"
/>
</template>
</Loading>
@@ -65,7 +65,7 @@ const loadingV = refAutoReset(false, 3000);
<template #icon>
<IconifyIcon
icon="svg-spinners:bars-scale"
class="text-primary size-10"
class="size-10 text-primary"
/>
</template>
</Loading>

View File

@@ -38,7 +38,7 @@ function handleUpdate(len?: number) {
<div
v-for="item in list"
:key="item"
class="even:bg-heavy bg-muted flex-center h-[220px] w-full"
class="flex-center h-[220px] w-full bg-muted even:bg-heavy"
>
{{ item }}
</div>

View File

@@ -291,7 +291,7 @@ function goDoc() {
<Form class="mt-4" />
<template #actions>
<p
class="text-secondary-foreground hover:text-secondary-foreground cursor-default"
class="cursor-default text-secondary-foreground hover:text-secondary-foreground"
>
更多配置请
<Button type="link" size="small" @click="goDoc">查看文档</Button>

View File

@@ -107,7 +107,7 @@ const schema: VbenFormSchema[] = [
componentProps() {
// 不需要处理多语言时就无需这么做
return {
addonAfter: titleSuffix.value,
...(titleSuffix.value && { addonAfter: titleSuffix.value }),
onChange({ target: { value } }: ChangeEvent) {
titleSuffix.value = value && $te(value) ? $t(value) : undefined;
},
@@ -442,7 +442,6 @@ const [Form, formApi] = useVbenForm({
showDefaultActions: false,
wrapperClass: 'grid-cols-2 gap-x-4',
});
const [Drawer, drawerApi] = useVbenDrawer({
onConfirm: onSubmit,
onOpenChange(isOpen) {