This commit is contained in:
dap 2025-02-25 09:22:24 +08:00
commit 4da1bb9896
34 changed files with 1105 additions and 170 deletions

View File

@ -131,7 +131,13 @@ async function initComponentAdapter() {
IconPicker: (props, { attrs, slots }) => { IconPicker: (props, { attrs, slots }) => {
return h( return h(
IconPicker, IconPicker,
{ iconSlot: 'addonAfter', inputComponent: Input, ...props, ...attrs }, {
iconSlot: 'addonAfter',
inputComponent: Input,
modelValueProp: 'value',
...props,
...attrs,
},
slots, slots,
); );
}, },

View File

@ -1,7 +1,7 @@
import { createApp, watchEffect } from 'vue'; import { createApp, watchEffect } from 'vue';
import { registerAccessDirective } from '@vben/access'; 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 { MotionPlugin } from '@vben/plugins/motion';
import { preferences } from '@vben/preferences'; import { preferences } from '@vben/preferences';
import { initStores } from '@vben/stores'; import { initStores } from '@vben/stores';
@ -34,6 +34,11 @@ async function bootstrap(namespace: string) {
// 全局组件 // 全局组件
setupGlobalComponent(app); setupGlobalComponent(app);
// 注册v-loading指令
registerLoadingDirective(app, {
loading: 'loading', // 在这里可以自定义指令名称也可以明确提供false表示不注册这个指令
spinning: 'spinning',
});
// 国际化 i18n 配置 // 国际化 i18n 配置
await setupI18n(app); await setupI18n(app);

View File

@ -42,11 +42,18 @@ outline: deep
| transition | 动画效果 | `string` | `linear` | | transition | 动画效果 | `string` | `linear` |
| decimals | 保留小数点位数 | `number` | `0` | | decimals | 保留小数点位数 | `number` | `0` |
### Events
| 事件名 | 描述 | 类型 |
| -------------- | -------------- | -------------- |
| started | 动画已开始 | `()=>void` |
| finished | 动画已结束 | `()=>void` |
| ~~onStarted~~ | ~~动画已开始~~ | ~~`()=>void`~~ |
| ~~onFinished~~ | ~~动画已结束~~ | ~~`()=>void`~~ |
### Methods ### Methods
以下事件,只有在 `useVbenModal({onCancel:()=>{}})` 中传入才会生效。 | 方法名 | 描述 | 类型 |
| 事件名 | 描述 | 类型 |
| ------ | ------------ | ---------- | | ------ | ------------ | ---------- |
| start | 开始执行动画 | `()=>void` | | start | 开始执行动画 | `()=>void` |
| reset | 重置 | `()=>void` | | reset | 重置 | `()=>void` |

View File

@ -445,9 +445,9 @@ export interface FormSchema<
/** 字段名,也作为自定义插槽的名称 */ /** 字段名,也作为自定义插槽的名称 */
fieldName: string; fieldName: string;
/** 帮助信息 */ /** 帮助信息 */
help?: string; help?: CustomRenderType;
/** 表单 */ /** 表单的标签如果是一个string会用于默认必选规则的消息提示 */
label?: string; label?: CustomRenderType;
/** 自定义组件内部渲染 */ /** 自定义组件内部渲染 */
renderComponentContent?: RenderComponentContentType; renderComponentContent?: RenderComponentContentType;
/** 字段规则 */ /** 字段规则 */

View File

@ -538,4 +538,6 @@ interface Preferences {
- `overridesPreferences`方法只需要覆盖项目中的一部分配置,不需要的配置不用覆盖,会自动使用默认配置。 - `overridesPreferences`方法只需要覆盖项目中的一部分配置,不需要的配置不用覆盖,会自动使用默认配置。
- 任何配置项都可以覆盖,只需要在`overridesPreferences`方法内覆盖即可,不要修改默认配置文件。 - 任何配置项都可以覆盖,只需要在`overridesPreferences`方法内覆盖即可,不要修改默认配置文件。
- 更改配置后请清空缓存,否则可能不生效。::: - 更改配置后请清空缓存,否则可能不生效。
:::

View File

@ -1,9 +1,7 @@
export { export {
ArrowDown, ArrowDown,
ArrowLeft, ArrowLeft,
ArrowLeftFromLine as MdiMenuOpen,
ArrowLeftToLine, ArrowLeftToLine,
ArrowRightFromLine as MdiMenuClose,
ArrowRightLeft, ArrowRightLeft,
ArrowRightToLine, ArrowRightToLine,
ArrowUp, ArrowUp,
@ -16,6 +14,8 @@ export {
ChevronRight, ChevronRight,
ChevronsLeft, ChevronsLeft,
ChevronsRight, ChevronsRight,
Circle,
CircleCheckBig,
CircleHelp, CircleHelp,
Copy, Copy,
CornerDownLeft, CornerDownLeft,
@ -29,6 +29,7 @@ export {
Github, Github,
Grip, Grip,
GripVertical, GripVertical,
Menu as IconDefault,
Info, Info,
InspectionPanel, InspectionPanel,
Languages, Languages,
@ -37,7 +38,8 @@ export {
LogOut, LogOut,
MailCheck, MailCheck,
Maximize, Maximize,
Menu as IconDefault, ArrowRightFromLine as MdiMenuClose,
ArrowLeftFromLine as MdiMenuOpen,
Menu, Menu,
Minimize, Minimize,
Minimize2, Minimize2,

View File

@ -193,7 +193,7 @@ const fieldProps = computed(() => {
const rules = fieldRules.value; const rules = fieldRules.value;
return { return {
keepValue: true, keepValue: true,
label, label: isString(label) ? label : '',
...(rules ? { rules } : {}), ...(rules ? { rules } : {}),
...(formFieldProps as Record<string, any>), ...(formFieldProps as Record<string, any>),
}; };
@ -285,7 +285,7 @@ function autofocus() {
'pb-6': !compact, 'pb-6': !compact,
'pb-2': compact, 'pb-2': compact,
}" }"
class="flex" class="relative flex"
v-bind="$attrs" v-bind="$attrs"
> >
<FormLabel <FormLabel
@ -301,59 +301,64 @@ function autofocus() {
) )
" "
:help="help" :help="help"
:colon="colon"
:label="label"
:required="shouldRequired && !hideRequiredMark" :required="shouldRequired && !hideRequiredMark"
:style="labelStyle" :style="labelStyle"
> >
<template v-if="label"> <template v-if="label">
<span>{{ label }}</span> <VbenRenderContent :content="label" />
<span v-if="colon" class="ml-[2px]">:</span>
</template> </template>
</FormLabel> </FormLabel>
<div :class="cn('relative flex w-full items-center', wrapperClass)"> <div class="w-full overflow-hidden">
<FormControl :class="cn(controlClass)"> <div :class="cn('relative flex w-full items-center', wrapperClass)">
<slot <div class="flex-auto overflow-hidden p-[2px]">
v-bind="{ <FormControl :class="cn(controlClass)">
...slotProps, <slot
...createComponentProps(slotProps), v-bind="{
disabled: shouldDisabled, ...slotProps,
isInValid, ...createComponentProps(slotProps),
}" disabled: shouldDisabled,
>
<component
:is="FieldComponent"
ref="fieldComponentRef"
:class="{
'border-destructive focus:border-destructive hover:border-destructive/80 focus:shadow-[0_0_0_2px_rgba(255,38,5,0.06)]':
isInValid, isInValid,
}" }"
v-bind="createComponentProps(slotProps)"
:disabled="shouldDisabled"
>
<template
v-for="name in renderContentKey"
:key="name"
#[name]="renderSlotProps"
> >
<VbenRenderContent <component
:content="customContentRender[name]" :is="FieldComponent"
v-bind="{ ...renderSlotProps, formContext: slotProps }" ref="fieldComponentRef"
/> :class="{
</template> 'border-destructive focus:border-destructive hover:border-destructive/80 focus:shadow-[0_0_0_2px_rgba(255,38,5,0.06)]':
<!-- <slot></slot> --> isInValid,
</component> }"
</slot> v-bind="createComponentProps(slotProps)"
</FormControl> :disabled="shouldDisabled"
<!-- 自定义后缀 --> >
<div v-if="suffix" class="ml-1"> <template
<VbenRenderContent :content="suffix" /> v-for="name in renderContentKey"
:key="name"
#[name]="renderSlotProps"
>
<VbenRenderContent
:content="customContentRender[name]"
v-bind="{ ...renderSlotProps, formContext: slotProps }"
/>
</template>
<!-- <slot></slot> -->
</component>
</slot>
</FormControl>
</div>
<!-- 自定义后缀 -->
<div v-if="suffix" class="ml-1">
<VbenRenderContent :content="suffix" />
</div>
<FormDescription v-if="description" class="ml-1">
<VbenRenderContent :content="description" />
</FormDescription>
</div> </div>
<FormDescription v-if="description">
<VbenRenderContent :content="description" />
</FormDescription>
<Transition name="slide-up"> <Transition name="slide-up">
<FormMessage class="absolute -bottom-[22px]" /> <FormMessage class="absolute bottom-1" />
</Transition> </Transition>
</div> </div>
</FormItem> </FormItem>

View File

@ -1,10 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { FormLabel, VbenHelpTooltip } from '@vben-core/shadcn-ui'; import type { CustomRenderType } from '../types';
import {
FormLabel,
VbenHelpTooltip,
VbenRenderContent,
} from '@vben-core/shadcn-ui';
import { cn } from '@vben-core/shared/utils'; import { cn } from '@vben-core/shared/utils';
interface Props { interface Props {
class?: string; class?: string;
help?: string; colon?: boolean;
help?: CustomRenderType;
label?: CustomRenderType;
required?: boolean; required?: boolean;
} }
@ -20,6 +28,8 @@ const props = defineProps<Props>();
<span class="whitespace-pre-line"> <span class="whitespace-pre-line">
{{ help }} {{ help }}
</span> </span>
<VbenRenderContent :content="help" />
</VbenHelpTooltip> </VbenHelpTooltip>
<span v-if="colon && label" class="ml-[2px]">:</span>
</FormLabel> </FormLabel>
</template> </template>

View File

@ -244,13 +244,13 @@ export interface FormSchema<
/** 依赖 */ /** 依赖 */
dependencies?: FormItemDependencies; dependencies?: FormItemDependencies;
/** 描述 */ /** 描述 */
description?: string; description?: CustomRenderType;
/** 字段名 */ /** 字段名 */
fieldName: string; fieldName: string;
/** 帮助信息 */ /** 帮助信息 */
help?: string; help?: CustomRenderType;
/** 表单项 */ /** 表单项 */
label?: string; label?: CustomRenderType;
// 自定义组件内部渲染 // 自定义组件内部渲染
renderComponentContent?: RenderComponentContentType; renderComponentContent?: RenderComponentContentType;
/** 字段规则 */ /** 字段规则 */

View File

@ -0,0 +1,98 @@
<script lang="ts" setup>
import { cn } from '@vben-core/shared/utils';
defineOptions({ name: 'VbenButtonGroup' });
withDefaults(
defineProps<{
border?: boolean;
gap?: number;
size?: 'large' | 'middle' | 'small';
}>(),
{ border: false, gap: 0, size: 'middle' },
);
</script>
<template>
<div
:class="
cn(
'vben-button-group rounded-md',
`size-${size}`,
gap ? 'with-gap' : 'no-gap',
$attrs.class as string,
)
"
:style="{ gap: gap ? `${gap}px` : '0px' }"
>
<slot></slot>
</div>
</template>
<style lang="scss" scoped>
.vben-button-group {
display: inline-flex;
&.size-large :deep(button) {
height: 2.25rem;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
line-height: 1.25rem;
.icon-wrapper {
margin-right: 0.4rem;
svg {
width: 1rem;
height: 1rem;
}
}
}
&.size-middle :deep(button) {
height: 2rem;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
line-height: 1rem;
.icon-wrapper {
margin-right: 0.2rem;
svg {
width: 0.75rem;
height: 0.75rem;
}
}
}
&.size-small :deep(button) {
height: 1.75rem;
padding: 0.2rem 0.4rem;
font-size: 0.65rem;
line-height: 0.75rem;
.icon-wrapper {
margin-right: 0.1rem;
svg {
width: 0.65rem;
height: 0.65rem;
}
}
}
&.no-gap > :deep(button):nth-of-type(1) {
border-radius: calc(var(--radius) - 2px) 0 0 calc(var(--radius) - 2px);
}
&.no-gap > :deep(button):last-of-type {
border-radius: 0 calc(var(--radius) - 2px) calc(var(--radius) - 2px) 0;
}
&.no-gap {
:deep(button + button) {
border-left-width: 0;
border-radius: 0;
}
}
}
</style>

View File

@ -1,4 +1,5 @@
import type { AsTag } from 'radix-vue'; import type { AsTag } from 'radix-vue';
import type { Component } from 'vue'; import type { Component } from 'vue';
import type { ButtonVariants, ButtonVariantSize } from '../../ui'; import type { ButtonVariants, ButtonVariantSize } from '../../ui';
@ -21,3 +22,21 @@ export interface VbenButtonProps {
size?: ButtonVariantSize; size?: ButtonVariantSize;
variant?: ButtonVariants; variant?: ButtonVariants;
} }
export type CustomRenderType = (() => Component | string) | string;
export type ValueType = boolean | number | string;
export interface VbenButtonGroupProps
extends Pick<VbenButtonProps, 'disabled'> {
beforeChange?: (
value: ValueType,
isChecked: boolean,
) => boolean | PromiseLike<boolean | undefined> | undefined;
btnClass?: any;
gap?: number;
multiple?: boolean;
options?: { label: CustomRenderType; value: ValueType }[];
showIcon?: boolean;
size?: 'large' | 'middle' | 'small';
}

View File

@ -0,0 +1,163 @@
<script lang="ts" setup>
import type { Arrayable } from '@vueuse/core';
import type { ValueType, VbenButtonGroupProps } from './button';
import { computed, ref, watch } from 'vue';
import { Circle, CircleCheckBig, LoaderCircle } from '@vben-core/icons';
import { VbenRenderContent } from '@vben-core/shadcn-ui';
import { cn, isFunction } from '@vben-core/shared/utils';
import { objectOmit } from '@vueuse/core';
import VbenButtonGroup from './button-group.vue';
import Button from './button.vue';
const props = withDefaults(defineProps<VbenButtonGroupProps>(), {
gap: 0,
multiple: false,
showIcon: true,
size: 'middle',
});
const btnDefaultProps = computed(() => {
return {
...objectOmit(props, ['options', 'btnClass', 'size', 'disabled']),
class: cn(props.btnClass),
};
});
const modelValue = defineModel<Arrayable<ValueType> | undefined>();
const innerValue = ref<Array<ValueType>>([]);
const loadingValues = ref<Array<ValueType>>([]);
watch(
() => props.multiple,
(val) => {
if (val) {
modelValue.value = innerValue.value;
} else {
modelValue.value =
innerValue.value.length > 0 ? innerValue.value[0] : undefined;
}
},
{ immediate: true },
);
watch(
() => modelValue.value,
(val) => {
if (Array.isArray(val)) {
const arrVal = val.filter((v) => v !== undefined);
if (arrVal.length > 0) {
innerValue.value = props.multiple
? [...arrVal]
: [arrVal[0] as ValueType];
} else {
innerValue.value = [];
}
} else {
innerValue.value = val === undefined ? [] : [val as ValueType];
}
},
{ deep: true },
);
async function onBtnClick(value: ValueType) {
if (props.beforeChange && isFunction(props.beforeChange)) {
try {
loadingValues.value.push(value);
const canChange = await props.beforeChange(
value,
!innerValue.value.includes(value),
);
if (canChange === false) {
return;
}
} finally {
loadingValues.value.splice(loadingValues.value.indexOf(value), 1);
}
}
if (props.multiple) {
if (innerValue.value.includes(value)) {
innerValue.value = innerValue.value.filter((item) => item !== value);
} else {
innerValue.value.push(value);
}
modelValue.value = innerValue.value;
} else {
innerValue.value = [value];
modelValue.value = value;
}
}
</script>
<template>
<VbenButtonGroup
:size="props.size"
:gap="props.gap"
class="vben-check-button-group"
>
<Button
v-for="(btn, index) in props.options"
:key="index"
:class="cn('border', props.btnClass)"
:disabled="
props.disabled ||
loadingValues.includes(btn.value) ||
(!props.multiple && loadingValues.length > 0)
"
v-bind="btnDefaultProps"
:variant="innerValue.includes(btn.value) ? 'default' : 'outline'"
@click="onBtnClick(btn.value)"
>
<div class="icon-wrapper" v-if="props.showIcon">
<LoaderCircle
class="animate-spin"
v-if="loadingValues.includes(btn.value)"
/>
<CircleCheckBig v-else-if="innerValue.includes(btn.value)" />
<Circle v-else />
</div>
<slot name="option" :label="btn.label" :value="btn.value">
<VbenRenderContent :content="btn.label" />
</slot>
</Button>
</VbenButtonGroup>
</template>
<style lang="scss" scoped>
.vben-check-button-group {
&:deep(.size-large) button {
.icon-wrapper {
margin-right: 0.3rem;
svg {
width: 1rem;
height: 1rem;
}
}
}
&:deep(.size-middle) button {
.icon-wrapper {
margin-right: 0.2rem;
svg {
width: 0.75rem;
height: 0.75rem;
}
}
}
&:deep(.size-small) button {
.icon-wrapper {
margin-right: 0.1rem;
svg {
width: 0.65rem;
height: 0.65rem;
}
}
}
}
</style>

View File

@ -1,3 +1,5 @@
export type * from './button'; export type * from './button';
export { default as VbenButtonGroup } from './button-group.vue';
export { default as VbenButton } from './button.vue'; export { default as VbenButton } from './button.vue';
export { default as VbenCheckButtonGroup } from './check-button-group.vue';
export { default as VbenIconButton } from './icon-button.vue'; export { default as VbenIconButton } from './icon-button.vue';

View File

@ -37,7 +37,18 @@ const props = withDefaults(defineProps<Props>(), {
useEasing: true, useEasing: true,
}); });
const emit = defineEmits(['onStarted', 'onFinished']); const emit = defineEmits<{
finished: [];
/**
* @deprecated 请使用{@link finished}事件
*/
onFinished: [];
/**
* @deprecated 请使用{@link started}事件
*/
onStarted: [];
started: [];
}>();
const source = ref(props.startVal); const source = ref(props.startVal);
const disabled = ref(false); const disabled = ref(false);
@ -73,8 +84,14 @@ function run() {
outputValue = useTransition(source, { outputValue = useTransition(source, {
disabled, disabled,
duration: props.duration, duration: props.duration,
onFinished: () => emit('onFinished'), onFinished: () => {
onStarted: () => emit('onStarted'), emit('finished');
emit('onFinished');
},
onStarted: () => {
emit('started');
emit('onStarted');
},
...(props.useEasing ...(props.useEasing
? { transition: TransitionPresets[props.transition] } ? { transition: TransitionPresets[props.transition] }
: {}), : {}),

View File

@ -31,7 +31,7 @@ const props = withDefaults(defineProps<Props>(), {
}); });
// const startTime = ref(0); // const startTime = ref(0);
const showSpinner = ref(false); const showSpinner = ref(false);
const renderSpinner = ref(true); const renderSpinner = ref(false);
const timer = ref<ReturnType<typeof setTimeout>>(); const timer = ref<ReturnType<typeof setTimeout>>();
watch( watch(
@ -69,7 +69,7 @@ function onTransitionEnd() {
<div <div
:class=" :class="
cn( 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, 'invisible opacity-0': !showSpinner,
}, },
@ -78,15 +78,18 @@ function onTransitionEnd() {
" "
@transitionend="onTransitionEnd" @transitionend="onTransitionEnd"
> >
<span class="dot relative inline-block size-9 text-3xl"> <slot name="icon" v-if="renderSpinner">
<i <span class="dot relative inline-block size-9 text-3xl">
v-for="index in 4" <i
:key="index" v-for="index in 4"
class="bg-primary absolute block size-4 origin-[50%_50%] scale-75 rounded-full opacity-30" :key="index"
></i> class="bg-primary absolute block size-4 origin-[50%_50%] scale-75 rounded-full opacity-30"
</span> ></i>
</span>
</slot>
<div v-if="text" class="mt-4 text-xs">{{ text }}</div> <div v-if="text" class="text-primary mt-4 text-xs">{{ text }}</div>
<slot></slot>
</div> </div>
</template> </template>

View File

@ -25,7 +25,7 @@ const props = withDefaults(defineProps<Props>(), {
}); });
// const startTime = ref(0); // const startTime = ref(0);
const showSpinner = ref(false); const showSpinner = ref(false);
const renderSpinner = ref(true); const renderSpinner = ref(false);
const timer = ref<ReturnType<typeof setTimeout>>(); const timer = ref<ReturnType<typeof setTimeout>>();
watch( watch(
@ -74,6 +74,7 @@ function onTransitionEnd() {
> >
<div <div
:class="{ paused: !renderSpinner }" :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-['']" 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>
</div> </div>

View File

@ -53,7 +53,7 @@ const numMain = computed(() => {
const result = currentValue.value const result = currentValue.value
.toFixed(props.decimals) .toFixed(props.decimals)
.split('.')[0] .split('.')[0]
?.replaceAll(/\B(?=(\d{3})+(?!\d))/g, ','); ?.replaceAll(/\B(?=(\d{3})+(?!\d))/g, props.separator);
return result; return result;
}); });

View File

@ -7,6 +7,7 @@ export * from './ellipsis-text';
export * from './icon-picker'; export * from './icon-picker';
export * from './json-preview'; export * from './json-preview';
export * from './json-viewer'; export * from './json-viewer';
export * from './loading';
export * from './markdown'; export * from './markdown';
export * from './page'; export * from './page';
export * from './resize'; export * from './resize';
@ -17,6 +18,8 @@ export * from '@vben-core/popup-ui';
// 给文档用 // 给文档用
export { export {
VbenButton, VbenButton,
VbenButtonGroup,
VbenCheckButtonGroup,
VbenCountToAnimator, VbenCountToAnimator,
VbenInputPassword, VbenInputPassword,
VbenLoading, VbenLoading,

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,39 @@
<script lang="ts" setup>
import { VbenLoading } from '@vben-core/shadcn-ui';
import { cn } from '@vben-core/shared/utils';
interface LoadingProps {
class?: string;
/**
* @zh_CN 最小加载时间
* @en_US Minimum loading time
*/
minLoadingTime?: number;
/**
* @zh_CN loading状态开启
*/
spinning?: boolean;
/**
* @zh_CN 文字
*/
text?: string;
}
defineOptions({ name: 'Loading' });
const props = defineProps<LoadingProps>();
</script>
<template>
<div :class="cn('relative min-h-20', props.class)">
<slot></slot>
<VbenLoading
:min-loading-time="props.minLoadingTime"
:spinning="props.spinning"
:text="props.text"
>
<template v-if="$slots.icon" #icon>
<slot name="icon"></slot>
</template>
</VbenLoading>
</div>
</template>

View File

@ -0,0 +1,28 @@
<script lang="ts" setup>
import { VbenSpinner } from '@vben-core/shadcn-ui';
import { cn } from '@vben-core/shared/utils';
interface SpinnerProps {
class?: string;
/**
* @zh_CN 最小加载时间
* @en_US Minimum loading time
*/
minLoadingTime?: number;
/**
* @zh_CN loading状态开启
*/
spinning?: boolean;
}
defineOptions({ name: 'Spinner' });
const props = defineProps<SpinnerProps>();
</script>
<template>
<div :class="cn('relative min-h-20', props.class)">
<slot></slot>
<VbenSpinner
:min-loading-time="props.minLoadingTime"
:spinning="props.spinning"
/>
</div>
</template>

View File

@ -355,7 +355,7 @@ onUnmounted(() => {
<div <div
v-if="formOptions" v-if="formOptions"
v-show="showSearchForm !== false" v-show="showSearchForm !== false"
:class="cn('relative rounded py-3', isCompactForm ? 'pb-6' : 'pb-4')" :class="cn('relative rounded py-3', isCompactForm ? 'pb-8' : 'pb-4')"
> >
<slot name="form"> <slot name="form">
<Form> <Form>

View File

@ -1,9 +1,10 @@
import type { RouteRecordRaw } from 'vue-router';
import type { import type {
ComponentRecordType, ComponentRecordType,
GenerateMenuAndRoutesOptions, GenerateMenuAndRoutesOptions,
RouteRecordStringComponent, RouteRecordStringComponent,
} from '@vben-core/typings'; } from '@vben-core/typings';
import type { RouteRecordRaw } from 'vue-router';
import { mapTree } from '@vben-core/shared/utils'; import { mapTree } from '@vben-core/shared/utils';

View File

@ -3,11 +3,15 @@
* vben-formvben-modalvben-drawer 使, * vben-formvben-modalvben-drawer 使,
*/ */
import type { BaseFormComponentType } from '@vben/common-ui';
import type { Component, SetupContext } from 'vue'; import type { Component, SetupContext } from 'vue';
import type { BaseFormComponentType } from '@vben/common-ui';
import { h } from 'vue';
import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui'; import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui';
import { $t } from '@vben/locales'; import { $t } from '@vben/locales';
import { import {
AutoComplete, AutoComplete,
Button, Button,
@ -32,7 +36,6 @@ import {
TreeSelect, TreeSelect,
Upload, Upload,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { h } from 'vue';
const withDefaultPlaceholder = <T extends Component>( const withDefaultPlaceholder = <T extends Component>(
component: T, component: T,
@ -123,7 +126,13 @@ async function initComponentAdapter() {
IconPicker: (props, { attrs, slots }) => { IconPicker: (props, { attrs, slots }) => {
return h( return h(
IconPicker, IconPicker,
{ iconSlot: 'addonAfter', inputComponent: Input, ...props, ...attrs }, {
iconSlot: 'addonAfter',
inputComponent: Input,
modelValueProp: 'value',
...props,
...attrs,
},
slots, slots,
); );
}, },

View File

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

View File

@ -12,6 +12,7 @@
"form": { "form": {
"title": "Form", "title": "Form",
"basic": "Basic Form", "basic": "Basic Form",
"layout": "Custom Layout",
"query": "Query Form", "query": "Query Form",
"rules": "Form Rules", "rules": "Form Rules",
"dynamic": "Dynamic Form", "dynamic": "Dynamic Form",
@ -62,5 +63,8 @@
}, },
"layout": { "layout": {
"col-page": "ColPage Layout" "col-page": "ColPage Layout"
},
"button-group": {
"title": "Button Group"
} }
} }

View File

@ -15,6 +15,7 @@
"form": { "form": {
"title": "表单", "title": "表单",
"basic": "基础表单", "basic": "基础表单",
"layout": "自定义布局",
"query": "查询表单", "query": "查询表单",
"rules": "表单校验", "rules": "表单校验",
"dynamic": "动态表单", "dynamic": "动态表单",
@ -62,5 +63,8 @@
}, },
"layout": { "layout": {
"col-page": "双列布局" "col-page": "双列布局"
},
"button-group": {
"title": "按钮组"
} }
} }

View File

@ -53,6 +53,14 @@ const routes: RouteRecordRaw[] = [
title: $t('examples.form.dynamic'), title: $t('examples.form.dynamic'),
}, },
}, },
{
name: 'FormLayoutExample',
path: '/examples/form/custom-layout',
component: () => import('#/views/examples/form/custom-layout.vue'),
meta: {
title: $t('examples.form.layout'),
},
},
{ {
name: 'FormCustomExample', name: 'FormCustomExample',
path: '/examples/form/custom', path: '/examples/form/custom',
@ -282,6 +290,24 @@ const routes: RouteRecordRaw[] = [
title: 'CountTo', title: 'CountTo',
}, },
}, },
{
name: 'Loading',
path: '/examples/loading',
component: () => import('#/views/examples/loading/index.vue'),
meta: {
icon: 'mdi:circle-double',
title: 'Loading',
},
},
{
name: 'ButtonGroup',
path: '/examples/button-group',
component: () => import('#/views/examples/button-group/index.vue'),
meta: {
icon: 'mdi:check-circle',
title: $t('examples.button-group.title'),
},
},
], ],
}, },
]; ];

View File

@ -0,0 +1,194 @@
<script lang="ts" setup>
import type { Recordable } from '@vben/types';
import { reactive, ref } from 'vue';
import {
Page,
VbenButton,
VbenButtonGroup,
VbenCheckButtonGroup,
} from '@vben/common-ui';
import { Button, Card, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
const radioValue = ref<string | undefined>('a');
const checkValue = ref(['a', 'b']);
const options = [
{ label: '选项1', value: 'a' },
{ label: '选项2', value: 'b' },
{ label: '选项3', value: 'c' },
{ label: '选项4', value: 'd' },
{ label: '选项5', value: 'e' },
{ label: '选项6', value: 'f' },
];
function resetValues() {
radioValue.value = undefined;
checkValue.value = [];
}
function beforeChange(v: any, isChecked: boolean) {
return new Promise((resolve) => {
message.loading({
content: `正在设置${v}${isChecked ? '选中' : '未选中'}...`,
duration: 0,
key: 'beforeChange',
});
setTimeout(() => {
message.success({ content: `${v} 已设置成功`, key: 'beforeChange' });
resolve(true);
}, 2000);
});
}
const compProps = reactive({
beforeChange: undefined,
disabled: false,
gap: 0,
showIcon: true,
size: 'middle',
} as Recordable<any>);
const [Form] = useVbenForm({
handleValuesChange(values) {
Object.keys(values).forEach((k) => {
if (k === 'beforeChange') {
compProps[k] = values[k] ? beforeChange : undefined;
} else {
compProps[k] = values[k];
}
});
},
schema: [
{
component: 'RadioGroup',
componentProps: {
options: [
{ label: '大', value: 'large' },
{ label: '中', value: 'middle' },
{ label: '小', value: 'small' },
],
},
defaultValue: compProps.size,
fieldName: 'size',
label: '尺寸',
},
{
component: 'RadioGroup',
componentProps: {
options: [
{ label: '无', value: 0 },
{ label: '小', value: 5 },
{ label: '中', value: 15 },
{ label: '大', value: 30 },
],
},
defaultValue: compProps.gap,
fieldName: 'gap',
label: '间距',
},
{
component: 'Switch',
defaultValue: compProps.showIcon,
fieldName: 'showIcon',
label: '显示图标',
},
{
component: 'Switch',
defaultValue: compProps.disabled,
fieldName: 'disabled',
label: '禁用',
},
{
component: 'Switch',
defaultValue: false,
fieldName: 'beforeChange',
label: '前置回调',
},
],
showDefaultActions: false,
submitOnChange: true,
});
function onBtnClick(value: any) {
const opt = options.find((o) => o.value === value);
if (opt) {
message.success(`点击了按钮${opt.label}value = ${value}`);
}
}
</script>
<template>
<Page
title="VbenButtonGroup 按钮组"
description="VbenButtonGroup是一个按钮容器用于包裹一组按钮协调整体样式。VbenCheckButtonGroup则可以作为一个表单组件提供单选或多选功能"
>
<Card title="基本用法">
<template #extra>
<Button type="primary" @click="resetValues">清空值</Button>
</template>
<p class="mt-4">按钮组</p>
<div class="mt-2 flex flex-col gap-2">
<VbenButtonGroup v-bind="compProps" border>
<VbenButton
v-for="btn in options"
:key="btn.value"
variant="link"
@click="onBtnClick(btn.value)"
>
{{ btn.label }}
</VbenButton>
</VbenButtonGroup>
<VbenButtonGroup v-bind="compProps" border>
<VbenButton
v-for="btn in options"
:key="btn.value"
variant="outline"
@click="onBtnClick(btn.value)"
>
{{ btn.label }}
</VbenButton>
</VbenButtonGroup>
</div>
<p class="mt-4">单选{{ radioValue }}</p>
<div class="mt-2 flex flex-col gap-2">
<VbenCheckButtonGroup
v-model="radioValue"
:options="options"
v-bind="compProps"
/>
</div>
<p class="mt-4">单选插槽{{ radioValue }}</p>
<div class="mt-2 flex flex-col gap-2">
<VbenCheckButtonGroup
v-model="radioValue"
:options="options"
v-bind="compProps"
>
<template #option="{ label, value }">
<div class="flex items-center">
<span>{{ label }}</span>
<span class="ml-2 text-gray-400">{{ value }}</span>
</div>
</template>
</VbenCheckButtonGroup>
</div>
<p class="mt-4">多选{{ checkValue }}</p>
<div class="mt-2 flex flex-col gap-2">
<VbenCheckButtonGroup
v-model="checkValue"
multiple
:options="options"
v-bind="compProps"
/>
</div>
</Card>
<Card title="设置" class="mt-4">
<Form />
</Card>
</Page>
</template>

View File

@ -1,15 +1,17 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useVbenForm } from '#/adapter/form';
import { getAllMenusApi } from '#/api';
import { Page } from '@vben/common-ui';
import { useDebounceFn } from '@vueuse/core';
import { Button, Card, message, Spin, TabPane, Tabs } from 'ant-design-vue';
import dayjs from 'dayjs';
import { h, ref } from 'vue'; import { h, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { useDebounceFn } from '@vueuse/core';
import { Button, Card, message, Spin, Tag } from 'ant-design-vue';
import dayjs from 'dayjs';
import { useVbenForm, z } from '#/adapter/form';
import { getAllMenusApi } from '#/api';
import DocButton from '../doc-button.vue'; import DocButton from '../doc-button.vue';
const activeTab = ref('basic');
const keyword = ref(''); const keyword = ref('');
const fetching = ref(false); const fetching = ref(false);
// //
@ -108,6 +110,7 @@ const [BaseForm, baseFormApi] = useVbenForm({
notFoundContent: fetching.value ? h(Spin) : undefined, notFoundContent: fetching.value ? h(Spin) : undefined,
}; };
}, },
rules: 'selectRequired',
}, },
{ {
component: 'ApiTreeSelect', component: 'ApiTreeSelect',
@ -115,10 +118,10 @@ const [BaseForm, baseFormApi] = useVbenForm({
componentProps: { componentProps: {
// //
api: getAllMenusApi, api: getAllMenusApi,
childrenField: 'children',
// options // options
labelField: 'name', labelField: 'name',
valueField: 'path', valueField: 'path',
childrenField: 'children',
}, },
// //
fieldName: 'apiTree', fieldName: 'apiTree',
@ -148,6 +151,7 @@ const [BaseForm, baseFormApi] = useVbenForm({
label: '图标', label: '图标',
}, },
{ {
colon: false,
component: 'Select', component: 'Select',
componentProps: { componentProps: {
allowClear: true, allowClear: true,
@ -166,7 +170,7 @@ const [BaseForm, baseFormApi] = useVbenForm({
showSearch: true, showSearch: true,
}, },
fieldName: 'options', fieldName: 'options',
label: '下拉选', label: () => h(Tag, { color: 'warning' }, () => '😎自定义:'),
}, },
{ {
component: 'RadioGroup', component: 'RadioGroup',
@ -222,6 +226,9 @@ const [BaseForm, baseFormApi] = useVbenForm({
default: () => ['我已阅读并同意'], default: () => ['我已阅读并同意'],
}; };
}, },
rules: z
.boolean()
.refine((v) => v, { message: '为什么不同意?勾上它!' }),
}, },
{ {
component: 'Mentions', component: 'Mentions',
@ -252,6 +259,8 @@ const [BaseForm, baseFormApi] = useVbenForm({
class: 'w-auto', class: 'w-auto',
}, },
fieldName: 'switch', fieldName: 'switch',
help: () =>
['这是一个多行帮助信息', '第二行', '第三行'].map((v) => h('p', v)),
label: '开关', label: '开关',
}, },
{ {
@ -321,75 +330,6 @@ const [BaseForm, baseFormApi] = useVbenForm({
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3', wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
}); });
const [CustomLayoutForm] = useVbenForm({
//
commonConfig: {
//
componentProps: {
class: 'w-full',
},
},
layout: 'horizontal',
schema: [
{
component: 'Select',
fieldName: 'field1',
label: '字符串',
},
{
component: 'TreeSelect',
fieldName: 'field2',
label: '字符串',
},
{
component: 'Mentions',
fieldName: 'field3',
label: '字符串',
},
{
component: 'Input',
fieldName: 'field4',
label: '字符串',
},
{
component: 'InputNumber',
fieldName: 'field5',
//
formItemClass: 'col-start-3',
label: '前面空了一列',
},
{
component: 'Textarea',
fieldName: 'field6',
// 线
formItemClass: 'col-span-3 items-baseline',
label: '占满三列',
},
{
component: 'Input',
fieldName: 'field7',
// 2
formItemClass: 'col-span-2 col-start-2',
label: '占满2列',
},
{
component: 'Input',
fieldName: 'field8',
//
formItemClass: 'col-start-2',
label: '左右留空',
},
{
component: 'InputPassword',
fieldName: 'field9',
formItemClass: 'col-start-1',
label: '字符串',
},
],
//
wrapperClass: 'grid-cols-3',
});
function onSubmit(values: Record<string, any>) { function onSubmit(values: Record<string, any>) {
message.success({ message.success({
content: `form values: ${JSON.stringify(values)}`, content: `form values: ${JSON.stringify(values)}`,
@ -425,7 +365,6 @@ function handleSetFormValue() {
<Page <Page
content-class="flex flex-col gap-4" content-class="flex flex-col gap-4"
description="表单组件基础示例,请注意,该页面用到的参数代码会添加一些简单注释,方便理解,请仔细查看。" description="表单组件基础示例,请注意,该页面用到的参数代码会添加一些简单注释,方便理解,请仔细查看。"
header-class="pb-0"
title="表单组件" title="表单组件"
> >
<template #description> <template #description>
@ -434,22 +373,15 @@ function handleSetFormValue() {
表单组件基础示例请注意该页面用到的参数代码会添加一些简单注释方便理解请仔细查看 表单组件基础示例请注意该页面用到的参数代码会添加一些简单注释方便理解请仔细查看
</p> </p>
</div> </div>
<Tabs v-model:active-key="activeTab" :tab-bar-style="{ marginBottom: 0 }">
<TabPane key="basic" tab="基础示例" />
<TabPane key="layout" tab="自定义布局" />
</Tabs>
</template> </template>
<template #extra> <template #extra>
<DocButton class="mb-2" path="/components/common-ui/vben-form" /> <DocButton class="mb-2" path="/components/common-ui/vben-form" />
</template> </template>
<Card v-show="activeTab === 'basic'" title="基础示例"> <Card title="基础示例">
<template #extra> <template #extra>
<Button type="primary" @click="handleSetFormValue">设置表单值</Button> <Button type="primary" @click="handleSetFormValue">设置表单值</Button>
</template> </template>
<BaseForm /> <BaseForm />
</Card> </Card>
<Card v-show="activeTab === 'layout'" title="使用tailwind自定义布局">
<CustomLayoutForm />
</Card>
</Page> </Page>
</template> </template>

View File

@ -0,0 +1,111 @@
<script lang="ts" setup>
import { h } from 'vue';
import { Page } from '@vben/common-ui';
import { Card } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import DocButton from '../doc-button.vue';
const [CustomLayoutForm] = useVbenForm({
//
commonConfig: {
//
componentProps: {
class: 'w-full',
},
},
layout: 'horizontal',
schema: [
{
component: 'Select',
fieldName: 'field1',
label: '字符串',
},
{
component: 'TreeSelect',
fieldName: 'field2',
label: '字符串',
},
{
component: 'Mentions',
fieldName: 'field3',
label: '字符串',
},
{
component: 'Input',
fieldName: 'field4',
label: '字符串',
},
{
component: 'InputNumber',
fieldName: 'field5',
//
formItemClass: 'col-start-3',
label: '前面空了一列',
},
{
component: 'Divider',
fieldName: '_divider',
formItemClass: 'col-span-3',
hideLabel: true,
renderComponentContent: () => {
return {
default: () => h('div', '分割线'),
};
},
},
{
component: 'Textarea',
fieldName: 'field6',
// 线
formItemClass: 'col-span-3 items-baseline',
label: '占满三列',
},
{
component: 'Input',
fieldName: 'field7',
// 2
formItemClass: 'col-span-2 col-start-2',
label: '占满2列',
},
{
component: 'Input',
fieldName: 'field8',
//
formItemClass: 'col-start-2',
label: '左右留空',
},
{
component: 'InputPassword',
fieldName: 'field9',
formItemClass: 'col-start-1',
label: '字符串',
},
],
//
wrapperClass: 'grid-cols-3',
});
</script>
<template>
<Page
content-class="flex flex-col gap-4"
description="使用tailwind自定义表单项的布局"
title="表单自定义布局"
>
<template #description>
<div class="text-muted-foreground">
<p>使用tailwind自定义表单项的布局使用Divider分割表单</p>
</div>
</template>
<template #extra>
<DocButton class="mb-2" path="/components/common-ui/vben-form" />
</template>
<Card title="使用tailwind自定义布局">
<CustomLayoutForm />
</Card>
</Page>
</template>

View File

@ -150,7 +150,9 @@ const [Form, formApi] = useVbenForm({
default: () => ['我已阅读并同意'], default: () => ['我已阅读并同意'],
}; };
}, },
rules: 'selectRequired', rules: z.boolean().refine((value) => value, {
message: '请勾选',
}),
}, },
{ {
component: 'DatePicker', component: 'DatePicker',

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>