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

View File

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

View File

@@ -193,7 +193,7 @@ const fieldProps = computed(() => {
const rules = fieldRules.value;
return {
keepValue: true,
label,
label: isString(label) ? label : '',
...(rules ? { rules } : {}),
...(formFieldProps as Record<string, any>),
};
@@ -285,7 +285,7 @@ function autofocus() {
'pb-6': !compact,
'pb-2': compact,
}"
class="flex"
class="relative flex"
v-bind="$attrs"
>
<FormLabel
@@ -301,59 +301,64 @@ function autofocus() {
)
"
:help="help"
:colon="colon"
:label="label"
:required="shouldRequired && !hideRequiredMark"
:style="labelStyle"
>
<template v-if="label">
<span>{{ label }}</span>
<span v-if="colon" class="ml-[2px]">:</span>
<VbenRenderContent :content="label" />
</template>
</FormLabel>
<div :class="cn('relative flex w-full items-center', wrapperClass)">
<FormControl :class="cn(controlClass)">
<slot
v-bind="{
...slotProps,
...createComponentProps(slotProps),
disabled: shouldDisabled,
isInValid,
}"
>
<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)]':
<div class="w-full overflow-hidden">
<div :class="cn('relative flex w-full items-center', wrapperClass)">
<div class="flex-auto overflow-hidden p-[2px]">
<FormControl :class="cn(controlClass)">
<slot
v-bind="{
...slotProps,
...createComponentProps(slotProps),
disabled: shouldDisabled,
isInValid,
}"
v-bind="createComponentProps(slotProps)"
:disabled="shouldDisabled"
>
<template
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 v-if="suffix" class="ml-1">
<VbenRenderContent :content="suffix" />
<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,
}"
v-bind="createComponentProps(slotProps)"
:disabled="shouldDisabled"
>
<template
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>
<FormDescription v-if="description">
<VbenRenderContent :content="description" />
</FormDescription>
<Transition name="slide-up">
<FormMessage class="absolute -bottom-[22px]" />
<FormMessage class="absolute bottom-1" />
</Transition>
</div>
</FormItem>

View File

@@ -1,10 +1,18 @@
<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';
interface Props {
class?: string;
help?: string;
colon?: boolean;
help?: CustomRenderType;
label?: CustomRenderType;
required?: boolean;
}
@@ -20,6 +28,8 @@ const props = defineProps<Props>();
<span class="whitespace-pre-line">
{{ help }}
</span>
<VbenRenderContent :content="help" />
</VbenHelpTooltip>
<span v-if="colon && label" class="ml-[2px]">:</span>
</FormLabel>
</template>

View File

@@ -244,13 +244,13 @@ export interface FormSchema<
/** 依赖 */
dependencies?: FormItemDependencies;
/** 描述 */
description?: string;
description?: CustomRenderType;
/** 字段名 */
fieldName: string;
/** 帮助信息 */
help?: string;
help?: CustomRenderType;
/** 表单项 */
label?: string;
label?: CustomRenderType;
// 自定义组件内部渲染
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 { Component } from 'vue';
import type { ButtonVariants, ButtonVariantSize } from '../../ui';
@@ -21,3 +22,21 @@ export interface VbenButtonProps {
size?: ButtonVariantSize;
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 { default as VbenButtonGroup } from './button-group.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';

View File

@@ -37,7 +37,18 @@ const props = withDefaults(defineProps<Props>(), {
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 disabled = ref(false);
@@ -73,8 +84,14 @@ function run() {
outputValue = useTransition(source, {
disabled,
duration: props.duration,
onFinished: () => emit('onFinished'),
onStarted: () => emit('onStarted'),
onFinished: () => {
emit('finished');
emit('onFinished');
},
onStarted: () => {
emit('started');
emit('onStarted');
},
...(props.useEasing
? { transition: TransitionPresets[props.transition] }
: {}),

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>
<div v-if="text" class="text-primary 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

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

View File

@@ -7,6 +7,7 @@ export * from './ellipsis-text';
export * from './icon-picker';
export * from './json-preview';
export * from './json-viewer';
export * from './loading';
export * from './markdown';
export * from './page';
export * from './resize';
@@ -17,6 +18,8 @@ export * from '@vben-core/popup-ui';
// 给文档用
export {
VbenButton,
VbenButtonGroup,
VbenCheckButtonGroup,
VbenCountToAnimator,
VbenInputPassword,
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
v-if="formOptions"
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">
<Form>

View File

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