feat: loading and spinner component with directive (#5587)

* 添加loading和spinner组件,以及对应的vue指令
This commit is contained in:
Netfan 2025-02-23 12:41:54 +08:00 committed by GitHub
parent eba372062e
commit 579b1b486c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 321 additions and 14 deletions

View File

@ -1,7 +1,7 @@
import { createApp, watchEffect } from 'vue';
import { registerAccessDirective } from '@vben/access';
import { initTippy } from '@vben/common-ui';
import { initTippy, registerLoadingDirective } from '@vben/common-ui';
import { MotionPlugin } from '@vben/plugins/motion';
import { preferences } from '@vben/preferences';
import { initStores } from '@vben/stores';
@ -31,6 +31,12 @@ async function bootstrap(namespace: string) {
const app = createApp(App);
// 注册v-loading指令
registerLoadingDirective(app, {
loading: 'loading', // 在这里可以自定义指令名称也可以明确提供false表示不注册这个指令
spinning: 'spinning',
});
// 国际化 i18n 配置
await setupI18n(app);

View File

@ -1,7 +1,7 @@
import { createApp, watchEffect } from 'vue';
import { registerAccessDirective } from '@vben/access';
import { initTippy } from '@vben/common-ui';
import { initTippy, registerLoadingDirective } from '@vben/common-ui';
import { MotionPlugin } from '@vben/plugins/motion';
import { preferences } from '@vben/preferences';
import { initStores } from '@vben/stores';
@ -33,6 +33,12 @@ async function bootstrap(namespace: string) {
// 注册Element Plus提供的v-loading指令
app.directive('loading', ElLoading.directive);
// 注册Vben提供的v-loading和v-spinning指令
registerLoadingDirective(app, {
loading: false, // Vben提供的v-loading指令和Element Plus提供的v-loading指令二选一即可此处false表示不注册Vben提供的v-loading指令
spinning: 'spinning',
});
// 国际化 i18n 配置
await setupI18n(app);

View File

@ -1,7 +1,7 @@
import { createApp, watchEffect } from 'vue';
import { registerAccessDirective } from '@vben/access';
import { initTippy } from '@vben/common-ui';
import { initTippy, registerLoadingDirective } from '@vben/common-ui';
import { MotionPlugin } from '@vben/plugins/motion';
import { preferences } from '@vben/preferences';
import { initStores } from '@vben/stores';
@ -31,6 +31,12 @@ async function bootstrap(namespace: string) {
const app = createApp(App);
// 注册v-loading指令
registerLoadingDirective(app, {
loading: 'loading', // 在这里可以自定义指令名称,也可以明确提供false表示不注册这个指令
spinning: 'spinning',
});
// 国际化 i18n 配置
await setupI18n(app);

View File

@ -31,7 +31,7 @@ const props = withDefaults(defineProps<Props>(), {
});
// const startTime = ref(0);
const showSpinner = ref(false);
const renderSpinner = ref(true);
const renderSpinner = ref(false);
const timer = ref<ReturnType<typeof setTimeout>>();
watch(
@ -69,7 +69,7 @@ function onTransitionEnd() {
<div
:class="
cn(
'z-100 dark:bg-overlay bg-overlay-content pointer-events-none absolute left-0 top-0 flex size-full flex-col items-center justify-center transition-all duration-500',
'z-100 dark:bg-overlay bg-overlay-content absolute left-0 top-0 flex size-full flex-col items-center justify-center transition-all duration-500',
{
'invisible opacity-0': !showSpinner,
},
@ -78,15 +78,18 @@ function onTransitionEnd() {
"
@transitionend="onTransitionEnd"
>
<span class="dot relative inline-block size-9 text-3xl">
<i
v-for="index in 4"
:key="index"
class="bg-primary absolute block size-4 origin-[50%_50%] scale-75 rounded-full opacity-30"
></i>
</span>
<slot name="icon" v-if="renderSpinner">
<span class="dot relative inline-block size-9 text-3xl">
<i
v-for="index in 4"
:key="index"
class="bg-primary absolute block size-4 origin-[50%_50%] scale-75 rounded-full opacity-30"
></i>
</span>
</slot>
<div v-if="text" class="mt-4 text-xs">{{ text }}</div>
<slot></slot>
</div>
</template>

View File

@ -25,7 +25,7 @@ const props = withDefaults(defineProps<Props>(), {
});
// const startTime = ref(0);
const showSpinner = ref(false);
const renderSpinner = ref(true);
const renderSpinner = ref(false);
const timer = ref<ReturnType<typeof setTimeout>>();
watch(
@ -74,6 +74,7 @@ function onTransitionEnd() {
>
<div
:class="{ paused: !renderSpinner }"
v-if="renderSpinner"
class="loader before:bg-primary/50 after:bg-primary relative size-12 before:absolute before:left-0 before:top-[60px] before:h-[5px] before:w-12 before:rounded-[50%] before:content-[''] after:absolute after:left-0 after:top-0 after:h-full after:w-full after:rounded after:content-['']"
></div>
</div>

View File

@ -5,6 +5,7 @@ export * from './count-to';
export * from './ellipsis-text';
export * from './icon-picker';
export * from './json-viewer';
export * from './loading';
export * from './page';
export * from './resize';
export * from './tippy';

View File

@ -0,0 +1,132 @@
import type { App, Directive, DirectiveBinding } from 'vue';
import { h, render } from 'vue';
import { VbenLoading, VbenSpinner } from '@vben-core/shadcn-ui';
import { isString } from '@vben-core/shared/utils';
const LOADING_INSTANCE_KEY = Symbol('loading');
const SPINNER_INSTANCE_KEY = Symbol('spinner');
const CLASS_NAME_RELATIVE = 'spinner-parent--relative';
const loadingDirective: Directive = {
mounted(el, binding) {
const instance = h(VbenLoading, getOptions(binding));
render(instance, el);
el.classList.add(CLASS_NAME_RELATIVE);
el[LOADING_INSTANCE_KEY] = instance;
},
unmounted(el) {
const instance = el[LOADING_INSTANCE_KEY];
el.classList.remove(CLASS_NAME_RELATIVE);
render(null, el);
instance.el.remove();
el[LOADING_INSTANCE_KEY] = null;
},
updated(el, binding) {
const instance = el[LOADING_INSTANCE_KEY];
const options = getOptions(binding);
if (options && instance?.component) {
try {
Object.keys(options).forEach((key) => {
instance.component.props[key] = options[key];
});
instance.component.update();
} catch (error) {
console.error(
'Failed to update loading component in directive:',
error,
);
}
}
},
};
function getOptions(binding: DirectiveBinding) {
if (binding.value === undefined) {
return { spinning: true };
} else if (typeof binding.value === 'boolean') {
return { spinning: binding.value };
} else {
return { ...binding.value };
}
}
const spinningDirective: Directive = {
mounted(el, binding) {
const instance = h(VbenSpinner, getOptions(binding));
render(instance, el);
el.classList.add(CLASS_NAME_RELATIVE);
el[SPINNER_INSTANCE_KEY] = instance;
},
unmounted(el) {
const instance = el[SPINNER_INSTANCE_KEY];
el.classList.remove(CLASS_NAME_RELATIVE);
render(null, el);
instance.el.remove();
el[SPINNER_INSTANCE_KEY] = null;
},
updated(el, binding) {
const instance = el[SPINNER_INSTANCE_KEY];
const options = getOptions(binding);
if (options && instance?.component) {
try {
Object.keys(options).forEach((key) => {
instance.component.props[key] = options[key];
});
instance.component.update();
} catch (error) {
console.error(
'Failed to update spinner component in directive:',
error,
);
}
}
},
};
type loadingDirectiveParams = {
/** 是否注册loading指令。如果提供一个string则将指令注册为指定的名称 */
loading?: boolean | string;
/** 是否注册spinning指令。如果提供一个string则将指令注册为指定的名称 */
spinning?: boolean | string;
};
/**
* loading指令
* @param app
* @param params
*/
export function registerLoadingDirective(
app: App,
params?: loadingDirectiveParams,
) {
// 注入一个样式供指令使用,确保容器是相对定位
const style = document.createElement('style');
style.id = CLASS_NAME_RELATIVE;
style.innerHTML = `
.${CLASS_NAME_RELATIVE} {
position: relative !important;
}
`;
document.head.append(style);
if (params?.loading !== false) {
app.directive(
isString(params?.loading) ? params.loading : 'loading',
loadingDirective,
);
}
if (params?.spinning !== false) {
app.directive(
isString(params?.spinning) ? params.spinning : 'spinning',
spinningDirective,
);
}
}

View File

@ -0,0 +1,3 @@
export * from './directive';
export { default as Loading } from './loading.vue';
export { default as Spinner } from './spinner.vue';

View File

@ -0,0 +1,19 @@
<script lang="ts" setup>
import { VbenLoading } from '@vben-core/shadcn-ui';
defineOptions({ name: 'Loading' });
defineProps<{
spinning: boolean;
text?: string;
}>();
</script>
<template>
<div class="relative min-h-20">
<slot></slot>
<VbenLoading :spinning="spinning" :text="text">
<template v-if="$slots.icon" #icon>
<slot name="icon"></slot>
</template>
</VbenLoading>
</div>
</template>

View File

@ -0,0 +1,14 @@
<script lang="ts" setup>
import { VbenSpinner } from '@vben-core/shadcn-ui';
defineOptions({ name: 'Spinner' });
defineProps({
spinning: Boolean,
});
</script>
<template>
<div class="relative min-h-20">
<slot></slot>
<VbenSpinner :spinning="spinning" />
</div>
</template>

View File

@ -1,7 +1,7 @@
import { createApp, watchEffect } from 'vue';
import { registerAccessDirective } from '@vben/access';
import { initTippy } from '@vben/common-ui';
import { initTippy, registerLoadingDirective } from '@vben/common-ui';
import { MotionPlugin } from '@vben/plugins/motion';
import { preferences } from '@vben/preferences';
import { initStores } from '@vben/stores';
@ -32,6 +32,12 @@ async function bootstrap(namespace: string) {
const app = createApp(App);
// 注册v-loading指令
registerLoadingDirective(app, {
loading: 'loading', // 在这里可以自定义指令名称也可以明确提供false表示不注册这个指令
spinning: 'spinning',
});
// 国际化 i18n 配置
await setupI18n(app);

View File

@ -290,6 +290,15 @@ const routes: RouteRecordRaw[] = [
title: 'CountTo',
},
},
{
name: 'Loading',
path: '/examples/loading',
component: () => import('#/views/examples/loading/index.vue'),
meta: {
icon: 'mdi:circle-double',
title: 'Loading',
},
},
],
},
];

View File

@ -0,0 +1,101 @@
<script lang="ts" setup>
import { Loading, Page, Spinner } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { refAutoReset } from '@vueuse/core';
import { Button, Card, Spin } from 'ant-design-vue';
const spinning = refAutoReset(false, 3000);
const loading = refAutoReset(false, 3000);
const spinningV = refAutoReset(false, 3000);
const loadingV = refAutoReset(false, 3000);
</script>
<template>
<Page
title="Vben Loading"
description="加载中状态组件。这个组件可以为其它作为容器的组件添加一个加载中的遮罩层。使用它们时容器需要relative定位。"
>
<Card title="Antd Spin">
<template #actions>这是Antd 组件库自带的Spin组件演示</template>
<Spin :spinning="spinning" tip="加载中...">
<Button type="primary" @click="spinning = true">显示Spin</Button>
</Spin>
</Card>
<Card title="Vben Loading" v-loading="loadingV" class="mt-4">
<template #extra>
<Button type="primary" @click="loadingV = true">
v-loading 指令
</Button>
</template>
<template #actions>
Loading组件可以设置文字并且也提供了icon插槽用于替换加载图标
</template>
<div class="flex gap-4">
<div class="size-40">
<Loading
:spinning="loading"
text="正在加载..."
class="flex h-full w-full items-center justify-center"
>
<Button type="primary" @click="loading = true">默认动画</Button>
</Loading>
</div>
<div class="size-40">
<Loading
:spinning="loading"
class="flex h-full w-full items-center justify-center"
>
<Button type="primary" @click="loading = true">自定义动画1</Button>
<template #icon>
<IconifyIcon
icon="svg-spinners:ring-resize"
class="text-primary size-10"
/>
</template>
</Loading>
</div>
<div class="size-40">
<Loading
:spinning="loading"
class="flex h-full w-full items-center justify-center"
>
<Button type="primary" @click="loading = true">自定义动画2</Button>
<template #icon>
<IconifyIcon
icon="svg-spinners:bars-scale"
class="text-primary size-10"
/>
</template>
</Loading>
</div>
</div>
</Card>
<Card
title="Vben Spinner"
v-spinning="spinningV"
class="mt-4 overflow-hidden"
:body-style="{
position: 'relative',
overflow: 'hidden',
}"
>
<template #extra>
<Button type="primary" @click="spinningV = true">
v-spinning 指令
</Button>
</template>
<template #actions>
Spinner组件是Loading组件的一个特例只有一个固定的统一样式
</template>
<Spinner
:spinning="spinning"
class="flex size-40 items-center justify-center"
>
<Button type="primary" @click="spinning = true">显示Spinner</Button>
</Spinner>
</Card>
</Page>
</template>