Browse Source

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

* 添加loading和spinner组件,以及对应的vue指令
Netfan 1 month ago
parent
commit
579b1b486c

+ 7 - 1
apps/web-antd/src/bootstrap.ts

@@ -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);
 

+ 7 - 1
apps/web-ele/src/bootstrap.ts

@@ -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);
 

+ 7 - 1
apps/web-naive/src/bootstrap.ts

@@ -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);
 

+ 12 - 9
packages/@core/ui-kit/shadcn-ui/src/components/spinner/loading.vue

@@ -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>
 

+ 2 - 1
packages/@core/ui-kit/shadcn-ui/src/components/spinner/spinner.vue

@@ -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>

+ 1 - 0
packages/effects/common-ui/src/components/index.ts

@@ -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';

+ 132 - 0
packages/effects/common-ui/src/components/loading/directive.ts

@@ -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,
+    );
+  }
+}

+ 3 - 0
packages/effects/common-ui/src/components/loading/index.ts

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

+ 19 - 0
packages/effects/common-ui/src/components/loading/loading.vue

@@ -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>

+ 14 - 0
packages/effects/common-ui/src/components/loading/spinner.vue

@@ -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>

+ 7 - 1
playground/src/bootstrap.ts

@@ -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);
 

+ 9 - 0
playground/src/router/routes/modules/examples.ts

@@ -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',
+        },
+      },
     ],
   },
 ];

+ 101 - 0
playground/src/views/examples/loading/index.vue

@@ -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>