This commit is contained in:
Your Name
2026-08-27 14:04:28 +08:00
parent f7720831be
commit 334890171e
3016 changed files with 263403 additions and 27971 deletions
@@ -0,0 +1 @@
@reference "@vben/tailwind-config/theme";
@@ -0,0 +1,76 @@
<script setup lang="ts">
import type {
AvatarFallbackProps,
AvatarImageProps,
AvatarRootProps,
} from 'reka-ui';
import type { CSSProperties } from 'vue';
import type { ClassType } from '@vben-core/typings';
import { computed } from 'vue';
import { Avatar, AvatarFallback, AvatarImage } from '../../ui';
interface Props extends AvatarFallbackProps, AvatarImageProps, AvatarRootProps {
alt?: string;
class?: ClassType;
dot?: boolean;
dotClass?: ClassType;
fit?: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down';
size?: number;
}
defineOptions({
inheritAttrs: false,
});
const props = withDefaults(defineProps<Props>(), {
alt: 'avatar',
as: 'button',
dot: false,
dotClass: 'bg-green-500',
fit: 'cover',
});
const imageStyle = computed<CSSProperties>(() => {
const { fit } = props;
if (fit) {
return { objectFit: fit };
}
return {};
});
const text = computed(() => {
return props.alt.slice(-2).toUpperCase();
});
const rootStyle = computed(() => {
return props.size !== undefined && props.size > 0
? {
height: `${props.size}px`,
width: `${props.size}px`,
}
: {};
});
</script>
<template>
<div
:class="props.class"
:style="rootStyle"
class="relative flex shrink-0 items-center"
>
<Avatar :class="props.class" class="size-full">
<AvatarImage :alt="alt" :src="src" :style="imageStyle" />
<AvatarFallback>{{ text }}</AvatarFallback>
</Avatar>
<span
v-if="dot"
:class="dotClass"
class="border-background absolute right-0 bottom-0 size-3 rounded-full border-2"
>
</span>
</div>
</template>
@@ -0,0 +1 @@
export { default as VbenAvatar } from './avatar.vue';
@@ -0,0 +1,43 @@
<script lang="ts" setup>
import type { BacktopProps } from './backtop';
import { computed } from 'vue';
import { ArrowUpToLine } from '@vben-core/icons';
import { VbenButton } from '../button';
import { useBackTop } from './use-backtop';
interface Props extends BacktopProps {}
defineOptions({ name: 'BackTop' });
const props = withDefaults(defineProps<Props>(), {
bottom: 20,
isGroup: false,
right: 24,
target: '',
visibilityHeight: 200,
});
const backTopStyle = computed(() => ({
bottom: `${props.bottom}px`,
right: `${props.right}px`,
}));
const { handleClick, visible } = useBackTop(props);
</script>
<template>
<transition name="fade-down">
<VbenButton
v-if="visible"
:style="backTopStyle"
class="data z-popup bg-background shadow-float hover:bg-heavy dark:bg-accent dark:hover:bg-heavy fixed bottom-10 size-10 rounded-full duration-500"
size="icon"
variant="ghost"
@click="handleClick"
>
<ArrowUpToLine class="size-4" />
</VbenButton>
</transition>
</template>
@@ -0,0 +1,38 @@
export const backtopProps = {
/**
* @zh_CN bottom distance.
*/
bottom: {
default: 40,
type: Number,
},
/**
* @zh_CN right distance.
*/
right: {
default: 40,
type: Number,
},
/**
* @zh_CN the target to trigger scroll.
*/
target: {
default: '',
type: String,
},
/**
* @zh_CN the button will not show until the scroll height reaches this value.
*/
visibilityHeight: {
default: 200,
type: Number,
},
} as const;
export interface BacktopProps {
bottom?: number;
isGroup?: boolean;
right?: number;
target?: string;
visibilityHeight?: number;
}
@@ -0,0 +1 @@
export { default as VbenBackTop } from './back-top.vue';
@@ -0,0 +1,45 @@
import type { BacktopProps } from './backtop';
import { onMounted, ref, shallowRef } from 'vue';
import { useEventListener, useThrottleFn } from '@vueuse/core';
export const useBackTop = (props: BacktopProps) => {
const el = shallowRef<HTMLElement>();
const container = shallowRef<Document | HTMLElement>();
const visible = ref(false);
const handleScroll = () => {
if (el.value) {
visible.value = el.value.scrollTop >= (props?.visibilityHeight ?? 0);
}
};
const handleClick = () => {
el.value?.scrollTo({ behavior: 'smooth', top: 0 });
};
const handleScrollThrottled = useThrottleFn(handleScroll, 300, true);
useEventListener(container, 'scroll', handleScrollThrottled);
onMounted(() => {
container.value = document;
el.value = document.documentElement;
if (props.target) {
el.value = document.querySelector<HTMLElement>(props.target) ?? undefined;
if (!el.value) {
throw new Error(`target does not exist: ${props.target}`);
}
container.value = el.value;
}
// Give visible an initial value, fix #13066
handleScroll();
});
return {
handleClick,
visible,
};
};
@@ -0,0 +1,111 @@
<script lang="ts" setup>
import type { BreadcrumbProps } from './types';
import { VbenIcon } from '../icon';
interface Props extends BreadcrumbProps {}
defineOptions({ name: 'Breadcrumb' });
const { breadcrumbs, showIcon } = defineProps<Props>();
const emit = defineEmits<{ select: [string] }>();
function handleClick(index: number, path?: string) {
if (!path || index === breadcrumbs.length - 1) {
return;
}
emit('select', path);
}
</script>
<template>
<ul class="flex">
<TransitionGroup name="breadcrumb-transition">
<template
v-for="(item, index) in breadcrumbs"
:key="`${item.path}-${item.title}-${index}`"
>
<li>
<a
href="javascript:void 0"
@click.stop="handleClick(index, item.path)"
>
<span class="flex-center z-10 h-full">
<VbenIcon
v-if="showIcon"
:icon="item.icon"
class="mr-1 size-4 shrink-0"
/>
<span
:class="{
'text-foreground font-normal':
index === breadcrumbs.length - 1,
}"
>{{ item.title }}
</span>
</span>
</a>
</li>
</template>
</TransitionGroup>
</ul>
</template>
<style scoped>
@reference "@vben/tailwind-config/theme";
li {
@apply h-7;
}
li a {
@apply bg-accent text-muted-foreground relative mr-9 flex h-7 items-center py-0 pr-2 pl-1.25 text-[13px];
}
li a > span {
@apply -ml-3;
}
li:first-child a > span {
@apply -ml-1;
}
li:first-child a {
@apply rounded-l-sm pl-3.75;
}
li:first-child a::before {
@apply border-none;
}
li:last-child a {
@apply rounded-r-sm pr-3.75;
}
li:last-child a::after {
@apply border-none;
}
li a::before,
li a::after {
@apply border-accent absolute top-0 h-0 w-0 border-14 border-solid content-[''];
}
li a::before {
@apply -left-7 z-10 border-l-transparent;
}
li a::after {
@apply border-l-accent left-full border-transparent;
}
li:not(:last-child) a:hover {
@apply bg-accent-hover;
}
li:not(:last-child) a:hover::before {
@apply border-accent-hover border-l-transparent;
}
li:not(:last-child) a:hover::after {
@apply border-l-accent-hover;
}
</style>
@@ -0,0 +1,39 @@
<script lang="ts" setup>
import type { BreadcrumbProps } from './types';
import { useForwardPropsEmits } from 'reka-ui';
import BreadcrumbBackground from './breadcrumb-background.vue';
import Breadcrumb from './breadcrumb.vue';
interface Props extends BreadcrumbProps {
class?: any;
}
const props = withDefaults(defineProps<Props>(), {});
const emit = defineEmits<{ select: [string] }>();
const forward = useForwardPropsEmits(props, emit);
</script>
<template>
<Breadcrumb
v-if="styleType === 'normal'"
v-bind="forward"
class="vben-breadcrumb"
/>
<BreadcrumbBackground
v-if="styleType === 'background'"
v-bind="forward"
class="vben-breadcrumb"
/>
</template>
<style lang="scss" scoped>
/** 修复全局引入Antd时,ol和ul的默认样式会被修改的问题 */
.vben-breadcrumb {
:deep(ol),
:deep(ul) {
margin-bottom: 0;
}
}
</style>
@@ -0,0 +1,98 @@
<script lang="ts" setup>
import type { BreadcrumbProps } from './types';
import { ChevronDown } from '@vben-core/icons';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../../ui';
import { VbenIcon } from '../icon';
interface Props extends BreadcrumbProps {}
defineOptions({ name: 'Breadcrumb' });
withDefaults(defineProps<Props>(), {
showIcon: false,
});
const emit = defineEmits<{ select: [string] }>();
function handleClick(path?: string) {
if (!path) {
return;
}
emit('select', path);
}
</script>
<template>
<Breadcrumb>
<BreadcrumbList>
<TransitionGroup name="breadcrumb-transition">
<template
v-for="(item, index) in breadcrumbs"
:key="`${item.path}-${item.title}-${index}`"
>
<BreadcrumbItem>
<div v-if="item.items?.length ?? 0 > 0">
<DropdownMenu>
<DropdownMenuTrigger class="flex items-center gap-1">
<VbenIcon v-if="showIcon" :icon="item.icon" class="size-5" />
{{ item.title }}
<ChevronDown class="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<template
v-for="menuItem in item.items"
:key="`sub-${menuItem.path}`"
>
<DropdownMenuItem @click.stop="handleClick(menuItem.path)">
{{ menuItem.title }}
</DropdownMenuItem>
</template>
</DropdownMenuContent>
</DropdownMenu>
</div>
<BreadcrumbLink
v-else-if="index !== breadcrumbs.length - 1"
href="javascript:void 0"
@click.stop="handleClick(item.path)"
>
<div class="flex-center">
<VbenIcon
v-if="showIcon"
:class="{ 'size-5': item.isHome }"
:icon="item.icon"
class="mr-1 size-4"
/>
{{ item.title }}
</div>
</BreadcrumbLink>
<BreadcrumbPage v-else>
<div class="flex-center">
<VbenIcon
v-if="showIcon"
:class="{ 'size-5': item.isHome }"
:icon="item.icon"
class="mr-1 size-4"
/>
{{ item.title }}
</div>
</BreadcrumbPage>
<BreadcrumbSeparator
v-if="index < breadcrumbs.length - 1 && !item.isHome"
/>
</BreadcrumbItem>
</template>
</TransitionGroup>
</BreadcrumbList>
</Breadcrumb>
</template>
@@ -0,0 +1,3 @@
export { default as VbenBreadcrumbView } from './breadcrumb-view.vue';
export type * from './types';
@@ -0,0 +1,17 @@
import type { Component } from 'vue';
import type { BreadcrumbStyleType } from '@vben-core/typings';
export interface IBreadcrumb {
icon?: Component | string;
isHome?: boolean;
items?: IBreadcrumb[];
path?: string;
title?: string;
}
export interface BreadcrumbProps {
breadcrumbs: IBreadcrumb[];
showIcon?: boolean;
styleType?: BreadcrumbStyleType;
}
@@ -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>
@@ -0,0 +1,55 @@
import type { AsTag } from 'reka-ui';
import type { Component } from 'vue';
import type { ButtonVariants } from '../../ui';
export interface VbenButtonProps {
/**
* The element or component this component should render as. Can be overwrite by `asChild`
* @defaultValue "div"
*/
as?: AsTag | Component;
/**
* Change the default rendered element for the one passed as a child, merging their props and behavior.
*
* Read our [Composition](https://www.reka-ui.com/docs/guides/composition) guide for more details.
*/
asChild?: boolean;
class?: any;
disabled?: boolean;
loading?: boolean;
size?: ButtonVariants['size'];
variant?: ButtonVariants['variant'];
}
export type CustomRenderType = (() => Component | string) | string;
export type ValueType = boolean | number | string;
export interface VbenButtonGroupProps extends Pick<
VbenButtonProps,
'disabled'
> {
/** 单选模式下允许清除选中 */
allowClear?: boolean;
/** 值改变前的回调 */
beforeChange?: (
value: ValueType,
isChecked: boolean,
) => boolean | PromiseLike<boolean | undefined> | undefined;
/** 按钮样式 */
btnClass?: any;
/** 按钮间隔距离 */
gap?: number;
/** 多选模式下限制最多选择的数量。0表示不限制 */
maxCount?: number;
/** 是否允许多选 */
multiple?: boolean;
/** 选项 */
options?: { [key: string]: any; label: CustomRenderType; value: ValueType }[];
/** 显示图标 */
showIcon?: boolean;
/** 尺寸 */
size?: 'large' | 'middle' | 'small';
}
@@ -0,0 +1,42 @@
<script setup lang="ts">
import type { VbenButtonProps } from './button';
import { computed } from 'vue';
import { LoaderCircle } from '@vben-core/icons';
import { cn } from '@vben-core/shared/utils';
import { Primitive } from 'reka-ui';
import { buttonVariants } from '../../ui';
interface Props extends VbenButtonProps {}
const props = withDefaults(defineProps<Props>(), {
as: 'button',
class: '',
disabled: false,
loading: false,
size: 'default',
variant: 'default',
});
const isDisabled = computed(() => {
return props.disabled || props.loading;
});
</script>
<template>
<Primitive
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
:disabled="isDisabled"
>
<LoaderCircle
v-if="loading"
class="text-md mr-2 size-4 shrink-0 animate-spin"
/>
<slot></slot>
</Primitive>
</template>
@@ -0,0 +1,196 @@
<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 { cn, isFunction } from '@vben-core/shared/utils';
import { objectOmit } from '@vueuse/core';
import { VbenRenderContent } from '../render-content';
import VbenButtonGroup from './button-group.vue';
import Button from './button.vue';
const props = withDefaults(defineProps<VbenButtonGroupProps>(), {
gap: 0,
multiple: false,
showIcon: true,
size: 'middle',
allowClear: false,
maxCount: 0,
});
const emit = defineEmits(['btnClick']);
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;
}
},
);
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, immediate: 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 {
if (props.maxCount > 0 && innerValue.value.length >= props.maxCount) {
innerValue.value = innerValue.value.slice(0, props.maxCount - 1);
}
innerValue.value.push(value);
}
modelValue.value = innerValue.value;
} else {
if (props.allowClear && innerValue.value.includes(value)) {
innerValue.value = [];
modelValue.value = undefined;
emit('btnClick', undefined);
return;
} else {
innerValue.value = [value];
modelValue.value = value;
}
}
emit('btnClick', 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)"
type="button"
>
<div class="icon-wrapper" v-if="props.showIcon">
<slot
name="icon"
:loading="loadingValues.includes(btn.value)"
:checked="innerValue.includes(btn.value)"
>
<LoaderCircle
class="animate-spin"
v-if="loadingValues.includes(btn.value)"
/>
<CircleCheckBig v-else-if="innerValue.includes(btn.value)" />
<Circle v-else />
</slot>
</div>
<slot name="option" :label="btn.label" :value="btn.value" :data="btn">
<VbenRenderContent :content="btn.label" />
</slot>
</Button>
</VbenButtonGroup>
</template>
<style lang="scss" scoped>
.vben-check-button-group {
display: flex;
flex-wrap: wrap;
&: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;
}
}
}
&.no-gap > :deep(button):nth-of-type(1) {
border-right-width: 0;
}
&.no-gap {
:deep(button + button) {
margin-right: -1px;
border-left-width: 1px;
}
}
}
</style>
@@ -0,0 +1,78 @@
<script setup lang="ts">
import type { ButtonVariants } from '../../ui';
import type { VbenButtonProps } from './button';
import { computed, useSlots } from 'vue';
import { cn } from '@vben-core/shared/utils';
import { VbenTooltip } from '../tooltip';
import VbenButton from './button.vue';
interface Props extends VbenButtonProps {
class?: any;
disabled?: boolean;
onClick?: ((e?: MouseEvent) => void)[] | ((e?: MouseEvent) => void);
tooltip?: string;
tooltipDelayDuration?: number;
tooltipSide?: 'bottom' | 'left' | 'right' | 'top';
variant?: ButtonVariants['variant'];
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
onClick: () => {},
tooltipDelayDuration: 200,
tooltipSide: 'bottom',
variant: 'ghost',
});
const slots = useSlots();
const showTooltip = computed(() => !!slots.tooltip || !!props.tooltip);
function handleClick(e: MouseEvent) {
if (Array.isArray(props.onClick)) {
for (const fn of props.onClick) {
fn?.(e);
}
} else {
props.onClick?.(e);
}
}
</script>
<template>
<VbenButton
v-if="!showTooltip"
:class="cn('rounded-full', props.class)"
:disabled="disabled"
:variant="variant"
size="icon"
@click="handleClick"
>
<slot></slot>
</VbenButton>
<VbenTooltip
v-else
:delay-duration="tooltipDelayDuration"
:side="tooltipSide"
>
<template #trigger>
<VbenButton
:class="cn('rounded-full', props.class)"
:disabled="disabled"
:variant="variant"
size="icon"
@click="handleClick"
>
<slot></slot>
</VbenButton>
</template>
<slot v-if="slots.tooltip" name="tooltip"> </slot>
<template v-else>
{{ tooltip }}
</template>
</VbenTooltip>
</template>
@@ -0,0 +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';
@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { CheckboxRootEmits, CheckboxRootProps } from 'reka-ui';
import { useId } from 'vue';
import { useForwardPropsEmits } from 'reka-ui';
import { Checkbox } from '../../ui/checkbox';
const props = defineProps<CheckboxRootProps & { indeterminate?: boolean }>();
const emits = defineEmits<CheckboxRootEmits>();
const checked = defineModel<boolean>();
const forwarded = useForwardPropsEmits(props, emits);
const id = useId();
</script>
<template>
<div class="flex items-center">
<Checkbox v-bind="forwarded" :id="id" v-model="checked" />
<label :for="id" class="ml-2 cursor-pointer text-sm"> <slot></slot> </label>
</div>
</template>
@@ -0,0 +1 @@
export { default as VbenCheckbox } from './checkbox.vue';
@@ -0,0 +1,123 @@
<script setup lang="ts">
import type { CollapsibleParamSchema } from './type';
import { computed } from 'vue';
import { globalShareState } from '@vben-core/shared/global-state';
interface Props {
data: CollapsibleParamSchema;
}
const props = defineProps<Props>();
const modelValue = defineModel('value');
const finalOption = computed(() => {
const { type, ...otherOption } = props.data.option;
if (type === 'number' || type === 'exponential') {
return {
step: props.data.option.step ?? 1,
min: props.data.option.min,
max: props.data.option.max,
precision: props.data.option.precision,
...otherOption,
};
}
return otherOption;
});
const components = globalShareState.getComponents();
const FieldComponent = computed(() => {
switch (props.data.option.type) {
case 'exponential':
case 'number': {
return components.InputNumber;
}
case 'select': {
return components.Select;
}
case 'string': {
return components.Input;
}
default: {
return components.InputNumber;
}
}
});
const limitDisplay = computed(() => {
if (
props.data.option.min !== null &&
props.data.option.min !== undefined &&
props.data.option.max !== null &&
props.data.option.max !== undefined
) {
return `[${props.data.option.min},${props.data.option.max}]`;
}
if (props.data.option.min !== null && props.data.option.min !== undefined) {
return `min:${props.data.option.min}`;
}
if (props.data.option.max !== null && props.data.option.max !== undefined) {
return `max:${props.data.option.max}`;
}
return '';
});
function reset() {
modelValue.value = props.data.defaultValue;
}
defineExpose({
reset,
});
</script>
<template>
<div
class="body-row flex items-center w-full flex-nowrap not-last-of-type:border-b"
>
<div
class="body-cell pt-2 pb-2 px-5 leading-[1.5rem] flex items-center flex-nowrap"
>
{{ data.key }}
</div>
<div
class="body-cell pt-2 pb-2 px-5 leading-[1.5rem] flex items-center flex-nowrap"
>
<div class="flex-auto w-full">
<component
:is="FieldComponent"
v-bind="finalOption"
v-model:value="modelValue"
/>
</div>
<div class="flex items-center flex-none text-muted-foreground pl-2 gap-2">
<span v-if="limitDisplay">
{{ limitDisplay }}
</span>
<span v-if="data.option.step && data.option.step !== 1">
step:{{ data.option.step }}
</span>
</div>
</div>
<div
class="body-cell pt-2 pb-2 px-5 leading-[1.5rem] flex items-center flex-nowrap w-full"
>
<p
class="line-clamp-2"
v-tippy="{
content: data.description,
}"
>
{{ data.description }}
</p>
</div>
</div>
</template>
@@ -0,0 +1,264 @@
<script setup lang="ts">
import type { Recordable } from '@vben-core/typings';
import type { CollapsibleParamSchema } from './type';
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
import { useNamespace } from '@vben-core/composables';
import { ChevronsDown } from '@lucide/vue';
import {
CollapsibleContent,
CollapsibleRoot,
CollapsibleTrigger,
} from 'reka-ui';
import CollapsibleParamsItem from './collapsible-params-item.vue';
interface Props {
defaultOpen?: boolean;
maxHeight?: number | string;
params: CollapsibleParamSchema[];
visibleCount?: number;
}
const props = withDefaults(defineProps<Props>(), {
visibleCount: 3,
defaultOpen: false,
maxHeight: undefined,
});
const emits = defineEmits<{ 'update:value': [any, string] }>();
const modelValue = defineModel<
Recordable<CollapsibleParamSchema['defaultValue']>
>('value', {
default: () => ({}),
});
const visibleRefs = useTemplateRef('visibleRefs');
const collapsibleRefs = useTemplateRef('collapsibleRefs');
const { b } = useNamespace('collapsible-params');
const open = ref(props.defaultOpen);
// 最小可见为1
const finalVisibleCount = computed(() =>
Math.max(1, Math.floor(props.visibleCount)),
);
const visibleRows = computed(() => {
return props.params.slice(0, finalVisibleCount.value);
});
const collapsibleRows = computed(() => {
return props.params.slice(finalVisibleCount.value);
});
const bodyStyle = computed(() => {
if (!open.value || props.maxHeight == null) {
return undefined;
}
return {
maxHeight:
typeof props.maxHeight === 'number'
? `${props.maxHeight}px`
: props.maxHeight,
};
});
function init(force = false) {
const nextValue: Recordable<CollapsibleParamSchema['defaultValue']> = {
...modelValue.value,
};
for (const param of props.params) {
if (force || nextValue[param.key] === undefined) {
nextValue[param.key] = param.defaultValue ?? undefined;
}
}
modelValue.value = nextValue;
}
function toggleCollapsed() {
open.value = !open.value;
}
async function onParamValueChange(_: any, key: string) {
await nextTick();
emits('update:value', modelValue.value, key);
}
function resetValues() {
if (visibleRefs.value)
for (const rowRef of visibleRefs.value) {
rowRef?.reset();
}
if (collapsibleRefs.value)
for (const rowRef of collapsibleRefs.value) {
rowRef?.reset();
}
init(true);
}
function updateValues(
values: Recordable<CollapsibleParamSchema['defaultValue']>,
) {
const allowedKeys = new Set(props.params.map((param) => param.key));
const patch = {} as Recordable<CollapsibleParamSchema['defaultValue']>;
for (const key in values) {
if (!Object.hasOwn(values, key)) continue;
if (!allowedKeys.has(key)) continue;
patch[key] = values[key];
}
modelValue.value = { ...modelValue.value, ...patch };
}
watch(
() => props.params,
() => init(),
{ immediate: true, deep: true },
);
defineExpose({
toggleCollapsed,
resetValues,
updateValues,
});
</script>
<template>
<CollapsibleRoot
v-model:open="open"
class="border rounded-[0.5rem] flex flex-col w-full overflow-hidden"
:class="[b()]"
:unmount-on-hide="false"
>
<div class="wrapper w-full relative flex flex-col overflow-x-auto">
<div class="w-full min-w-fit">
<div
class="header bg-accent w-full flex-none flex items-center rounded-t-[0.5rem] border-b"
>
<div
class="header-cell pt-2 pb-2 px-5 leading-[1.5rem] flex items-center flex-nowrap"
>
Name
</div>
<div
class="header-cell pt-2 pb-2 px-5 leading-[1.5rem] flex items-center flex-nowrap"
>
Value
</div>
<div
class="header-cell pt-2 pb-2 px-5 leading-[1.5rem] flex items-center flex-nowrap"
>
Description
</div>
</div>
<div
class="body w-full flex-none flex flex-col overflow-x-hidden"
:class="[
open && !!props.maxHeight ? 'overflow-y-auto' : 'overflow-y-hidden',
]"
:style="bodyStyle"
>
<CollapsibleParamsItem
:data="row"
v-for="row in visibleRows"
:key="row.key"
ref="visibleRefs"
v-model:value="modelValue[row.key]"
@update:value="(v) => onParamValueChange(v, row.key)"
/>
<CollapsibleContent
class="data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up"
>
<CollapsibleParamsItem
:data="row"
v-for="row in collapsibleRows"
:key="row.key"
ref="collapsibleRefs"
v-model:value="modelValue[row.key]"
@update:value="(v) => onParamValueChange(v, row.key)"
/>
</CollapsibleContent>
</div>
</div>
</div>
<div
class="gutter h-[1.5rem]"
v-if="!open && collapsibleRows.length > 0"
></div>
<div
class="trigger-bar flex min-h-[2rem] border-t px-5 pt-1 pb-1 rounded-b-[0.5rem] z-1"
:class="{
'collapsed absolute bottom-px left-px right-px border-t-0 pt-6': !open,
}"
v-if="collapsibleRows.length > 0"
>
<CollapsibleTrigger
class="cursor-pointer h-[2rem] flex items-center gap-2"
>
<ChevronsDown
class="transition-transform"
:size="16"
:class="{
'rotate-180': open,
}"
/>
{{ open ? 'Fold' : 'Unfold' }}
</CollapsibleTrigger>
</div>
</CollapsibleRoot>
</template>
<style>
.vben-collapsible-params {
.wrapper {
--column1: 11.25rem;
--column2: 18.25rem;
--column3: 27.5rem;
.header-cell,
.body-cell {
&:nth-of-type(1) {
flex: 0 0 var(--column1);
/* min-width: var(--column1); */
}
&:nth-of-type(2) {
flex: 0 0 var(--column2);
/* min-width: var(--column2); */
}
&:nth-of-type(3) {
flex: 1 1 var(--column3);
min-width: var(--column3);
}
}
}
.trigger-bar {
&.collapsed {
background-image: linear-gradient(
hsl(var(--foreground) / 0%) 0%,
hsl(var(--foreground) / 12%) 31.76%,
var(--color-border) 31.76%,
var(--color-border) 33.43%,
var(--color-background) 31.76%
);
}
}
}
</style>
@@ -0,0 +1,79 @@
<script setup lang="ts">
import type { CollapsibleRootEmits, CollapsibleRootProps } from 'reka-ui';
import type { ClassType } from '@vben-core/typings';
import { computed } from 'vue';
import { ChevronsDown } from '@lucide/vue';
import {
CollapsibleContent,
CollapsibleRoot,
CollapsibleTrigger,
useForwardPropsEmits,
} from 'reka-ui';
const props = defineProps<
CollapsibleRootProps & {
class?: ClassType;
showTrigger?: boolean;
}
>();
const emits = defineEmits<CollapsibleRootEmits>();
const delegatedProps = computed(() => {
const { class: _cls, ...delegated } = props;
return delegated;
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
const open = defineModel<boolean>('open', { default: true });
function toggle() {
open.value = !open.value;
}
defineExpose({
toggle,
});
</script>
<template>
<CollapsibleRoot
v-bind="forwarded"
v-model:open="open"
class="flex flex-col"
:unmount-on-hide="false"
>
<div
class="flex items-center justify-between"
v-if="$slots.label || showTrigger"
>
<slot name="label" v-if="$slots.label"> </slot>
<CollapsibleTrigger
v-if="showTrigger"
class="cursor-pointer rounded-full h-[25px] w-[25px] inline-flex items-center justify-center outline-none data-[state=closed]:bg-white data-[state=open]:bg-primary/20 hover:bg-primary/20 text-primary"
>
<slot name="trigger" :open>
<ChevronsDown
class="h-3.5 w-3.5 transition-transform"
:class="{
'rotate-180': open,
}"
/>
</slot>
</CollapsibleTrigger>
</div>
<slot name="visibleContent" :open></slot>
<CollapsibleContent
class="data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up overflow-hidden justify-start"
>
<slot name="collapsibleContent" :open></slot>
</CollapsibleContent>
</CollapsibleRoot>
</template>
@@ -0,0 +1,4 @@
export { default as VbenCollapsibleParams } from './collapsible-params.vue';
export { default as VbenCollapsible } from './collapsible.vue';
export * from './type';
@@ -0,0 +1,22 @@
export interface CollapsibleParamsProps {
defaultOpen?: boolean;
maxHeight?: number | string;
params: CollapsibleParamSchema[];
visibleCount?: number;
}
export interface CollapsibleParamOption {
[key: string]: any;
max?: number;
min?: number;
precision?: number;
step?: number;
type?: 'exponential' | 'number' | 'select' | 'string';
}
export interface CollapsibleParamSchema {
defaultValue?: number | number[] | string | string[];
description: string;
key: string;
option: CollapsibleParamOption;
}
@@ -0,0 +1,128 @@
<script setup lang="ts">
import type {
ContextMenuContentProps,
ContextMenuRootEmits,
ContextMenuRootProps,
} from 'reka-ui';
import type { ClassType } from '@vben-core/typings';
import type { IContextMenuItem } from './interface';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useForwardPropsEmits } from 'reka-ui';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuTrigger,
} from '../../ui/context-menu';
const props = defineProps<
ContextMenuRootProps & {
class?: ClassType;
contentClass?: ClassType;
contentProps?: ContextMenuContentProps;
handlerData?: Record<string, any>;
itemClass?: ClassType;
menus: (data: any) => IContextMenuItem[];
}
>();
const emits = defineEmits<ContextMenuRootEmits>();
const NATIVE_CONTEXT_SELECTORS = [
'input',
'textarea',
'select',
'[contenteditable]:not([contenteditable="false"])',
'.allow-native-context',
].join(', ');
const delegatedProps = computed(() => {
const {
class: _cls,
contentClass: _,
contentProps: _cProps,
itemClass: _iCls,
...delegated
} = props;
return delegated;
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
const menusView = computed(() => {
return props.menus?.(props.handlerData);
});
function handleClick(menu: IContextMenuItem) {
if (menu.disabled) {
return;
}
menu?.handler?.(props.handlerData);
}
const triggerRef = ref<HTMLElement | null>(null);
function onContextMenuCapture(e: MouseEvent) {
if ((e.target as HTMLElement).closest(NATIVE_CONTEXT_SELECTORS)) {
e.stopPropagation();
}
}
onMounted(() => {
triggerRef.value?.addEventListener('contextmenu', onContextMenuCapture, {
capture: true,
});
});
onUnmounted(() => {
triggerRef.value?.removeEventListener('contextmenu', onContextMenuCapture, {
capture: true,
});
});
</script>
<template>
<ContextMenu v-bind="forwarded">
<ContextMenuTrigger as-child>
<div ref="triggerRef" class="contents">
<slot></slot>
</div>
</ContextMenuTrigger>
<ContextMenuContent
:class="contentClass"
v-bind="contentProps"
class="side-content z-popup"
>
<template v-for="menu in menusView" :key="menu.key">
<ContextMenuItem
v-if="!menu.hidden"
:class="itemClass"
:disabled="menu.disabled"
:inset="menu.inset || !menu.icon"
class="cursor-pointer"
@click="handleClick(menu)"
>
<component
:is="menu.icon"
v-if="menu.icon"
class="mr-2 size-4 text-lg"
/>
{{ menu.text }}
<ContextMenuShortcut v-if="menu.shortcut">
{{ menu.shortcut }}
</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuSeparator v-if="menu.separator" />
</template>
</ContextMenuContent>
</ContextMenu>
</template>
@@ -0,0 +1,3 @@
export { default as VbenContextMenu } from './context-menu.vue';
export type * from './interface';
@@ -0,0 +1,42 @@
import type { Component } from 'vue';
interface IContextMenuItem {
/**
* @zh_CN 是否禁用
*/
disabled?: boolean;
/**
* @zh_CN 点击事件处理
* @param data
*/
handler?: (data: any) => void;
/**
* @zh_CN 是否隐藏
*/
hidden?: boolean;
/**
* @zh_CN 图标
*/
icon?: Component;
/**
* @zh_CN 是否显示图标
*/
inset?: boolean;
/**
* @zh_CN 唯一标识
*/
key: string;
/**
* @zh_CN 是否是分割线
*/
separator?: boolean;
/**
* @zh_CN 快捷键
*/
shortcut?: string;
/**
* @zh_CN 标题
*/
text: string;
}
export type { IContextMenuItem };
@@ -0,0 +1,128 @@
<script lang="ts" setup>
import { computed, onMounted, ref, unref, watch, watchEffect } from 'vue';
import { isNumber } from '@vben-core/shared/utils';
import { TransitionPresets, useTransition } from '@vueuse/core';
interface Props {
autoplay?: boolean;
color?: string;
decimal?: string;
decimals?: number;
duration?: number;
endVal?: number;
prefix?: string;
separator?: string;
startVal?: number;
suffix?: string;
transition?: keyof typeof TransitionPresets;
useEasing?: boolean;
}
defineOptions({ name: 'CountToAnimator' });
const props = withDefaults(defineProps<Props>(), {
autoplay: true,
color: '',
decimal: '.',
decimals: 0,
duration: 1500,
endVal: 2021,
prefix: '',
separator: ',',
startVal: 0,
suffix: '',
transition: 'linear',
useEasing: true,
});
const emit = defineEmits<{
finished: [];
/**
* @deprecated 请使用{@link finished}事件
*/
onFinished: [];
/**
* @deprecated 请使用{@link started}事件
*/
onStarted: [];
started: [];
}>();
const source = ref(props.startVal);
const disabled = ref(false);
let outputValue = useTransition(source);
const value = computed(() => formatNumber(unref(outputValue)));
watchEffect(() => {
source.value = props.startVal;
});
watch([() => props.startVal, () => props.endVal], () => {
if (props.autoplay) {
start();
}
});
onMounted(() => {
props.autoplay && start();
});
function start() {
run();
source.value = props.endVal;
}
function reset() {
source.value = props.startVal;
run();
}
function run() {
outputValue = useTransition(source, {
disabled,
duration: props.duration,
onFinished: () => {
emit('finished');
emit('onFinished');
},
onStarted: () => {
emit('started');
emit('onStarted');
},
...(props.useEasing
? { transition: TransitionPresets[props.transition] }
: {}),
});
}
function formatNumber(num: number | string) {
if (!num && num !== 0) {
return '';
}
const { decimal, decimals, prefix, separator, suffix } = props;
num = Number(num).toFixed(decimals);
num += '';
const x = num.split('.');
let x1 = x[0];
const x2 = x.length > 1 ? decimal + x[1] : '';
const rgx = /(\d+)(\d{3})/;
if (separator && !isNumber(separator) && x1) {
while (rgx.test(x1)) {
x1 = x1.replace(rgx, `$1${separator}$2`);
}
}
return prefix + x1 + x2 + suffix;
}
defineExpose({ reset });
</script>
<template>
<span :style="{ color }">
{{ value }}
</span>
</template>
@@ -0,0 +1 @@
export { default as VbenCountToAnimator } from './count-to-animator.vue';
@@ -0,0 +1,129 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue';
import type { DescriptionsRenderNode, DescriptionsSize } from './types';
import { computed } from 'vue';
import { cn } from '@vben-core/shared/utils';
import { VbenRenderContent } from '../render-content';
interface Props {
/** 是否边框模式 */
bordered?: boolean;
/** 是否显示冒号(仅非边框模式生效) */
colon?: boolean;
/** 内容 */
content?: DescriptionsRenderNode | null;
/** 内容样式 */
contentStyle?: CSSProperties;
/** 单项自定义类名 */
itemClass?: string;
/** 标签 */
label?: DescriptionsRenderNode | null;
/** 标签样式 */
labelStyle?: CSSProperties;
/** 尺寸 */
size?: DescriptionsSize;
/** 跨列数 */
span?: number;
/** 渲染标签 th 还是 td */
tag: 'td' | 'th';
/** 单元格类型 */
type: 'content' | 'item' | 'label';
}
const props = withDefaults(defineProps<Props>(), {
bordered: false,
colon: true,
content: null,
contentStyle: undefined,
itemClass: undefined,
label: null,
labelStyle: undefined,
size: 'middle',
span: 1,
});
const BORDERED_PADDING: Record<DescriptionsSize, string> = {
large: 'px-6 py-4',
middle: 'px-4 py-2.5',
small: 'px-3 py-2',
};
const PLAIN_PADDING: Record<DescriptionsSize, string> = {
large: 'pb-6',
middle: 'pb-4',
small: 'pb-2',
};
// 冒号通过伪元素追加,避免标签为渲染函数时无法拼接
const COLON_CLASS = "after:content-[':']";
const hasLabel = computed(
() => props.label !== null && props.label !== undefined,
);
const hasContent = computed(
() => props.content !== null && props.content !== undefined,
);
// 数字 0 会被 VbenRenderContent 当作 falsy 隐藏,这里转为字符串保证展示;
// 同时将 null 归一为 undefined,匹配 VbenRenderContent 的 content 类型
const displayLabel = computed(() => {
if (props.label === null || props.label === undefined) return undefined;
return typeof props.label === 'number' ? String(props.label) : props.label;
});
const displayContent = computed(() => {
if (props.content === null || props.content === undefined) return undefined;
return typeof props.content === 'number'
? String(props.content)
: props.content;
});
const cellClass = computed(() => {
if (props.bordered) {
return cn(
'border border-border align-top break-words',
BORDERED_PADDING[props.size],
props.type === 'label'
? 'bg-muted/50 text-start font-normal text-foreground'
: 'text-foreground',
props.itemClass,
);
}
return cn('align-top', PLAIN_PADDING[props.size], props.itemClass);
});
const labelClass = computed(() =>
cn('mr-2 shrink-0 text-muted-foreground', props.colon && COLON_CLASS),
);
</script>
<template>
<component :is="tag" :class="cellClass" :colspan="span">
<!-- 边框模式每个单元格仅承载 label content -->
<template v-if="bordered">
<span v-if="hasLabel" :style="labelStyle">
<VbenRenderContent :content="displayLabel" />
</span>
<span v-if="hasContent" :style="contentStyle">
<VbenRenderContent :content="displayContent" />
</span>
</template>
<!-- 非边框模式label + content 容器 -->
<div v-else class="flex">
<span v-if="hasLabel" :class="labelClass" :style="labelStyle">
<VbenRenderContent :content="displayLabel" />
</span>
<span
v-if="hasContent"
class="break-words text-foreground"
:style="contentStyle"
>
<VbenRenderContent :content="displayContent" />
</span>
</div>
</component>
</template>
@@ -0,0 +1,57 @@
<script lang="ts">
import type { PropType } from 'vue';
import type { DescriptionsItemSpan, DescriptionsRenderNode } from './types';
import { defineComponent } from 'vue';
import { DESCRIPTIONS_ITEM_NAME } from './use-descriptions';
/**
* 子节点用法的标记组件,本身不渲染任何内容。
* 其 props 与默认插槽会被父级 VbenDescriptions 收集为列表项。
*/
const VbenDescriptionsItem = defineComponent({
name: DESCRIPTIONS_ITEM_NAME,
props: {
content: {
default: undefined,
type: [
String,
Number,
Function,
Object,
] as PropType<DescriptionsRenderNode>,
},
contentStyle: {
default: undefined,
type: Object,
},
label: {
default: undefined,
type: [
String,
Number,
Function,
Object,
] as PropType<DescriptionsRenderNode>,
},
labelStyle: {
default: undefined,
type: Object,
},
span: {
default: undefined,
type: [Number, String, Object] as PropType<DescriptionsItemSpan>,
},
},
setup() {
return () => null;
},
});
// 额外标记,便于在 vnode 中稳健识别
(VbenDescriptionsItem as Record<string, any>).__isDescriptionsItem = true;
export default VbenDescriptionsItem;
</script>
@@ -0,0 +1,112 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue';
import type { DescriptionsSize, InternalDescriptionsItem } from './types';
import DescriptionsCell from './descriptions-cell.vue';
interface Props {
bordered?: boolean;
colon?: boolean;
contentStyle?: CSSProperties;
labelStyle?: CSSProperties;
row: InternalDescriptionsItem[];
size?: DescriptionsSize;
vertical?: boolean;
}
withDefaults(defineProps<Props>(), {
bordered: false,
colon: true,
contentStyle: undefined,
labelStyle: undefined,
size: 'middle',
vertical: false,
});
function mergeStyle(
base?: CSSProperties,
override?: CSSProperties,
): CSSProperties | undefined {
if (!base && !override) return undefined;
return { ...base, ...override };
}
</script>
<template>
<!-- 垂直布局标签独占一行内容独占一行 -->
<template v-if="vertical">
<tr>
<DescriptionsCell
v-for="(item, index) in row"
:key="`label-${item.key ?? index}`"
tag="th"
type="label"
:span="item.span ?? 1"
:bordered="bordered"
:colon="colon"
:size="size"
:label="item.label ?? null"
:item-class="item.class"
:label-style="mergeStyle(labelStyle, item.labelStyle)"
/>
</tr>
<tr>
<DescriptionsCell
v-for="(item, index) in row"
:key="`content-${item.key ?? index}`"
tag="td"
type="content"
:span="item.span ?? 1"
:bordered="bordered"
:size="size"
:content="item.content ?? null"
:item-class="item.class"
:content-style="mergeStyle(contentStyle, item.contentStyle)"
/>
</tr>
</template>
<!-- 水平 + 边框每项拆分为 label(th) content(td) -->
<tr v-else-if="bordered">
<template v-for="(item, index) in row" :key="item.key ?? index">
<DescriptionsCell
tag="th"
type="label"
:span="1"
:bordered="true"
:size="size"
:label="item.label ?? null"
:item-class="item.class"
:label-style="mergeStyle(labelStyle, item.labelStyle)"
/>
<DescriptionsCell
tag="td"
type="content"
:span="(item.span ?? 1) * 2 - 1"
:bordered="true"
:size="size"
:content="item.content ?? null"
:content-style="mergeStyle(contentStyle, item.contentStyle)"
/>
</template>
</tr>
<!-- 水平 + 非边框每项一个单元格label content 同列 -->
<tr v-else>
<DescriptionsCell
v-for="(item, index) in row"
:key="item.key ?? index"
tag="td"
type="item"
:span="item.span ?? 1"
:colon="colon"
:size="size"
:label="item.label ?? null"
:content="item.content ?? null"
:item-class="item.class"
:label-style="mergeStyle(labelStyle, item.labelStyle)"
:content-style="mergeStyle(contentStyle, item.contentStyle)"
/>
</tr>
</template>
@@ -0,0 +1,91 @@
<script setup lang="ts">
import type { VNode } from 'vue';
import type { DescriptionsItemType, DescriptionsProps } from './types';
import { computed, useSlots } from 'vue';
import { cn } from '@vben-core/shared/utils';
import DescriptionsRow from './descriptions-row.vue';
import {
calcRows,
normalizeItems,
parseItemsFromSlot,
resolveColumn,
useScreens,
} from './use-descriptions';
defineOptions({ name: 'VbenDescriptions' });
const props = withDefaults(defineProps<DescriptionsProps>(), {
bordered: false,
class: undefined,
colon: true,
column: undefined,
contentStyle: undefined,
extra: undefined,
items: undefined,
labelStyle: undefined,
layout: 'horizontal',
size: 'middle',
title: undefined,
});
const slots = useSlots();
const screens = useScreens();
// 优先使用 items;否则从默认插槽中解析 VbenDescriptionsItem
const resolvedItems = computed<DescriptionsItemType[]>(() => {
if (props.items && props.items.length > 0) return props.items;
const nodes = (slots.default?.() ?? []) as VNode[];
return parseItemsFromSlot(nodes);
});
const mergedColumn = computed(() => resolveColumn(props.column, screens.value));
const mergedItems = computed(() =>
normalizeItems(resolvedItems.value, screens.value),
);
const rows = computed(() => calcRows(mergedItems.value, mergedColumn.value));
const hasHeader = computed(
() => !!props.title || !!props.extra || !!slots.title || !!slots.extra,
);
const tableClass = computed(() =>
cn(
'w-full table-auto border-collapse text-sm',
// 非边框模式下,去掉最后一行的底部间距
!props.bordered && '[&>tbody>tr:last-child>td]:pb-0',
),
);
</script>
<template>
<div :class="cn('w-full', props.class)">
<div v-if="hasHeader" class="mb-5 flex items-center justify-between gap-4">
<div class="text-base font-semibold text-foreground">
<slot name="title">{{ title }}</slot>
</div>
<div class="text-foreground">
<slot name="extra">{{ extra }}</slot>
</div>
</div>
<table :class="tableClass">
<tbody>
<DescriptionsRow
v-for="(row, index) in rows"
:key="index"
:row="row"
:vertical="layout === 'vertical'"
:bordered="bordered"
:colon="colon"
:size="size"
:label-style="labelStyle"
:content-style="contentStyle"
/>
</tbody>
</table>
</div>
</template>
@@ -0,0 +1,4 @@
export { default as VbenDescriptionsItem } from './descriptions-item.vue';
export { default as VbenDescriptions } from './descriptions.vue';
export * from './types';
@@ -0,0 +1,93 @@
import type { Component, CSSProperties } from 'vue';
/** 响应式断点,与 antdv-next 保持一致 */
export type DescriptionsBreakpoint =
| 'lg'
| 'md'
| 'sm'
| 'xl'
| 'xs'
| 'xxl'
| 'xxxl';
/** 当前命中的断点集合 */
export type ScreenMap = Partial<Record<DescriptionsBreakpoint, boolean>>;
export type DescriptionsLayout = 'horizontal' | 'vertical';
export type DescriptionsSize = 'large' | 'middle' | 'small';
/** 列数,可为固定数字或按断点配置 */
export type DescriptionsColumn =
| number
| Partial<Record<DescriptionsBreakpoint, number>>;
/** 单项跨列,支持固定数字、'filled'(占满当前行剩余)或按断点配置 */
export type DescriptionsItemSpan =
| 'filled'
| number
| Partial<Record<DescriptionsBreakpoint, number>>;
/** 可渲染内容:字符串/数字/渲染函数/组件 */
export type DescriptionsRenderNode = (() => any) | Component | number | string;
export interface DescriptionsItemType {
/** 内容 */
content?: DescriptionsRenderNode;
/** 内容样式 */
contentStyle?: CSSProperties;
/** 唯一 key */
key?: number | string;
/** 标签 */
label?: DescriptionsRenderNode;
/** 标签样式 */
labelStyle?: CSSProperties;
/** 跨列 */
span?: DescriptionsItemSpan;
}
export interface DescriptionsProps {
/** 是否展示边框 */
bordered?: boolean;
class?: any;
/** 是否显示冒号(仅非 bordered 的水平布局生效) */
colon?: boolean;
/** 一行的列数 */
column?: DescriptionsColumn;
/** 统一的内容样式 */
contentStyle?: CSSProperties;
/** 操作区域,位于标题右侧 */
extra?: string;
/** 数据驱动的列表项;不传则读取默认插槽中的 VbenDescriptionsItem */
items?: DescriptionsItemType[];
/** 统一的标签样式 */
labelStyle?: CSSProperties;
/** 布局方式 */
layout?: DescriptionsLayout;
/** 尺寸 */
size?: DescriptionsSize;
/** 标题 */
title?: string;
}
export interface DescriptionsItemProps {
content?: DescriptionsRenderNode;
contentStyle?: CSSProperties;
label?: DescriptionsRenderNode;
labelStyle?: CSSProperties;
span?: DescriptionsItemSpan;
}
/** 归一化后的内部项,span 已解析为数字 */
export interface InternalDescriptionsItem {
_index?: number;
class?: string;
content?: DescriptionsRenderNode;
contentStyle?: CSSProperties;
filled?: boolean;
key?: number | string;
label?: DescriptionsRenderNode;
labelStyle?: CSSProperties;
span?: number;
style?: CSSProperties;
}
@@ -0,0 +1,216 @@
import type { VNode } from 'vue';
import type {
DescriptionsBreakpoint,
DescriptionsColumn,
DescriptionsItemType,
InternalDescriptionsItem,
ScreenMap,
} from './types';
import { Comment, computed, Fragment } from 'vue';
import { useBreakpoints } from '@vueuse/core';
/** 默认列数映射 */
export const DEFAULT_COLUMN_MAP: Record<DescriptionsBreakpoint, number> = {
lg: 3,
md: 3,
sm: 2,
xl: 3,
xs: 1,
xxl: 3,
xxxl: 4,
};
/** 由大到小的断点顺序,matchScreen 按此顺序取第一个命中的值 */
const RESPONSIVE_ARRAY: DescriptionsBreakpoint[] = [
'xxxl',
'xxl',
'xl',
'lg',
'md',
'sm',
'xs',
];
/** 断点像素值 */
const BREAKPOINT_PX = {
sm: 576,
md: 768,
lg: 992,
xl: 1200,
xxl: 1600,
xxxl: 2000,
};
/**
* 在给定的断点配置中,按由大到小的顺序取第一个命中的值
*/
export function matchScreen(
screens: ScreenMap,
screenSizes?: Partial<Record<DescriptionsBreakpoint, number>>,
): number | undefined {
if (!screenSizes) return undefined;
for (const breakpoint of RESPONSIVE_ARRAY) {
if (screens[breakpoint] && screenSizes[breakpoint] !== undefined) {
return screenSizes[breakpoint];
}
}
return undefined;
}
/**
* 监听视口宽度,返回当前命中的断点集合
*/
export function useScreens() {
const breakpoints = useBreakpoints(BREAKPOINT_PX);
return computed<ScreenMap>(() => ({
lg: breakpoints.lg.value,
md: breakpoints.md.value,
sm: breakpoints.sm.value,
xl: breakpoints.xl.value,
xs: !breakpoints.sm.value,
xxl: breakpoints.xxl.value,
xxxl: breakpoints.xxxl.value,
}));
}
/**
* 计算最终列数:固定数字直接返回,否则按断点解析
*/
export function resolveColumn(
column: DescriptionsColumn | undefined,
screens: ScreenMap,
): number {
if (typeof column === 'number') return column;
return matchScreen(screens, { ...DEFAULT_COLUMN_MAP, ...column }) ?? 3;
}
/**
* 归一化列表项:将 span 解析为数字,'filled' 标记为 filled
*/
export function normalizeItems(
items: DescriptionsItemType[],
screens: ScreenMap,
): InternalDescriptionsItem[] {
return items.map((item, index) => {
const { span, ...rest } = item;
if (span === 'filled') {
return { ...rest, _index: index, filled: true };
}
return {
...rest,
_index: index,
span: typeof span === 'number' ? span : matchScreen(screens, span),
};
});
}
/**
* 行装箱算法:根据列数与各项 span 将列表项拆分为多行,
* 并补齐每行最后一项以占满列数。移植自 antdv-next useRow。
*/
export function calcRows(
items: InternalDescriptionsItem[],
column: number,
): InternalDescriptionsItem[][] {
let rows: InternalDescriptionsItem[][] = [];
let tmpRow: InternalDescriptionsItem[] = [];
let count = 0;
items.filter(Boolean).forEach((item) => {
const { filled, ...rest } = item;
// filled:占满当前行剩余,并立即换行
if (filled) {
tmpRow.push(rest);
rows.push(tmpRow);
tmpRow = [];
count = 0;
return;
}
const restSpan = column - count;
count += item.span || 1;
if (count >= column) {
// 超出列数时,将当前项 span 收敛为剩余列数,避免溢出
tmpRow.push(count > column ? { ...rest, span: restSpan } : rest);
rows.push(tmpRow);
tmpRow = [];
count = 0;
} else {
tmpRow.push(rest);
}
});
if (tmpRow.length > 0) rows.push(tmpRow);
// 补齐:若一行总 span 不足列数,扩展最后一项
rows = rows.map((row) => {
const total = row.reduce((acc, item) => acc + (item.span || 1), 0);
if (total < column) {
const last = row[row.length - 1];
if (last) {
last.span = column - (total - (last.span || 1));
}
}
return row;
});
return rows;
}
/** 标记组件类型为 DescriptionsItem,便于从插槽 vnode 中识别 */
export const DESCRIPTIONS_ITEM_NAME = 'VbenDescriptionsItem';
function isItemVNode(node: VNode): boolean {
const type = node.type as any;
return (
!!type &&
(type.__isDescriptionsItem === true || type.name === DESCRIPTIONS_ITEM_NAME)
);
}
function flattenVNodes(nodes: VNode[]): VNode[] {
const result: VNode[] = [];
for (const node of nodes) {
if (node.type === Fragment && Array.isArray(node.children)) {
result.push(...flattenVNodes(node.children as VNode[]));
} else if (node.type !== Comment) {
result.push(node);
}
}
return result;
}
/**
* 从默认插槽的 vnode 中解析出列表项,支持
* <VbenDescriptionsItem label="..." :span="2">content</VbenDescriptionsItem> 写法
*/
export function parseItemsFromSlot(nodes: VNode[]): DescriptionsItemType[] {
return flattenVNodes(nodes)
.filter((node) => isItemVNode(node))
.map((node) => {
const props = (node.props ?? {}) as Record<string, any>;
const children = (node.children ?? {}) as Record<string, any>;
const labelSlot =
typeof children.label === 'function' ? children.label : undefined;
const contentDefaultSlot =
typeof children.default === 'function' ? children.default : undefined;
const contentSlot =
typeof children.content === 'function'
? children.content
: contentDefaultSlot;
return {
class: props.class,
content: contentSlot ?? props.content,
contentStyle: props.contentStyle ?? props['content-style'],
key: node.key ?? undefined,
label: labelSlot ?? props.label,
labelStyle: props.labelStyle ?? props['label-style'],
span: props.span,
style: props.style,
} as DescriptionsItemType;
});
}
@@ -0,0 +1,49 @@
<script lang="ts" setup>
import type {
DropdownMenuProps,
VbenDropdownMenuItem as IDropdownMenuItem,
} from './interface';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../../ui';
interface Props extends DropdownMenuProps {}
defineOptions({ name: 'DropdownMenu' });
const props = withDefaults(defineProps<Props>(), {});
function handleItemClick(menu: IDropdownMenuItem) {
if (menu.disabled) {
return;
}
menu?.handler?.(props);
}
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger class="flex h-full items-center gap-1">
<slot></slot>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuGroup>
<template v-for="menu in menus" :key="menu.value">
<DropdownMenuItem
:disabled="menu.disabled"
class="text-foreground/80 data-[state=checked]:bg-accent data-[state=checked]:text-accent-foreground mb-1 cursor-pointer"
@click="handleItemClick(menu)"
>
<component :is="menu.icon" v-if="menu.icon" class="mr-2 size-4" />
{{ menu.label }}
</DropdownMenuItem>
<DropdownMenuSeparator v-if="menu.separator" class="bg-border" />
</template>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</template>
@@ -0,0 +1,52 @@
<script lang="ts" setup>
import type { DropdownMenuProps } from './interface';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../../ui';
interface Props extends DropdownMenuProps {}
defineOptions({ name: 'DropdownRadioMenu' });
withDefaults(defineProps<Props>(), {});
const modelValue = defineModel<string>();
function handleItemClick(value: string) {
modelValue.value = value;
}
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child class="flex items-center gap-1">
<slot></slot>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuGroup>
<template v-for="menu in menus" :key="menu.value">
<DropdownMenuItem
:class="
menu.value === modelValue
? 'bg-accent text-accent-foreground'
: ''
"
class="text-foreground/80 data-[state=checked]:bg-accent data-[state=checked]:text-accent-foreground mb-1 cursor-pointer"
@click="handleItemClick(menu.value)"
>
<component :is="menu.icon" v-if="menu.icon" class="mr-2 size-4" />
<span
v-if="!menu.icon"
:class="menu.value === modelValue ? 'bg-foreground' : ''"
class="mr-2 size-1.5 rounded-full"
></span>
{{ menu.label }}
</DropdownMenuItem>
</template>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</template>
@@ -0,0 +1,4 @@
export { default as VbenDropdownMenu } from './dropdown-menu.vue';
export { default as VbenDropdownRadioMenu } from './dropdown-radio-menu.vue';
export type * from './interface';
@@ -0,0 +1,32 @@
import type { Component } from 'vue';
interface VbenDropdownMenuItem {
disabled?: boolean;
/**
* @zh_CN 点击事件处理
* @param data
*/
handler?: (data: any) => void;
/**
* @zh_CN 图标
*/
icon?: Component;
/**
* @zh_CN 标题
*/
label: string;
/**
* @zh_CN 是否是分割线
*/
separator?: boolean;
/**
* @zh_CN 唯一标识
*/
value: string;
}
interface DropdownMenuProps {
menus: VbenDropdownMenuItem[];
}
export type { DropdownMenuProps, VbenDropdownMenuItem };
@@ -0,0 +1,31 @@
<script lang="ts" setup>
import { ChevronDown } from '@vben-core/icons';
import { cn } from '@vben-core/shared/utils';
const props = defineProps<{
class?: string;
}>();
// 控制箭头展开/收起状态
const collapsed = defineModel<boolean>({ default: false });
</script>
<template>
<div
:class="cn('vben-link inline-flex items-center', props.class)"
@click="collapsed = !collapsed"
>
<slot :is-expanded="collapsed">
{{ collapsed }}
<!-- <span>{{ isExpanded ? '收起' : '展开' }}</span> -->
</slot>
<div
:class="{ 'rotate-180': !collapsed }"
class="transition-transform duration-300"
>
<slot name="icon">
<ChevronDown class="size-4" />
</slot>
</div>
</div>
</template>
@@ -0,0 +1 @@
export { default as VbenExpandableArrow } from './expandable-arrow.vue';
@@ -0,0 +1,41 @@
<script lang="ts" setup>
import { Maximize, Minimize } from '@vben-core/icons';
import { useFullscreen } from '@vueuse/core';
import { VbenIconButton } from '../button';
defineOptions({ name: 'FullScreen' });
withDefaults(
defineProps<{
tooltip?: string;
}>(),
{
tooltip: '',
},
);
const { isFullscreen, toggle } = useFullscreen();
// 重新检查全屏状态
isFullscreen.value = !!(
document.fullscreenElement ||
// @ts-expect-error - vendor fullscreen APIs are not included in the standard DOM typings
document.webkitFullscreenElement ||
// @ts-expect-error - vendor fullscreen APIs are not included in the standard DOM typings
document.mozFullScreenElement ||
// @ts-expect-error - vendor fullscreen APIs are not included in the standard DOM typings
document.msFullscreenElement
);
</script>
<template>
<VbenIconButton
:tooltip="tooltip || undefined"
class="hover:animate-[shrink_0.3s_ease-in-out]"
@click="toggle"
>
<Minimize v-if="isFullscreen" class="text-foreground size-4" />
<Maximize v-else class="text-foreground size-4" />
</VbenIconButton>
</template>
@@ -0,0 +1 @@
export { default as VbenFullScreen } from './full-screen.vue';
@@ -0,0 +1,55 @@
<script setup lang="ts">
import type {
HoverCardContentProps,
HoverCardRootEmits,
HoverCardRootProps,
} from 'reka-ui';
import type { ClassType } from '@vben-core/typings';
import { computed } from 'vue';
import { useForwardPropsEmits } from 'reka-ui';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '../../ui';
interface Props extends HoverCardRootProps {
class?: ClassType;
contentClass?: ClassType;
contentProps?: HoverCardContentProps;
}
const props = defineProps<Props>();
const emits = defineEmits<HoverCardRootEmits>();
const delegatedProps = computed(() => {
const {
class: _cls,
contentClass: _,
contentProps: _cProps,
...delegated
} = props;
return delegated;
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<HoverCard v-bind="forwarded">
<HoverCardTrigger as-child class="h-full">
<div class="h-full cursor-pointer">
<slot name="trigger"></slot>
</div>
</HoverCardTrigger>
<HoverCardContent
:class="contentClass"
v-bind="contentProps"
class="side-content z-popup"
>
<slot></slot>
</HoverCardContent>
</HoverCard>
</template>
@@ -0,0 +1,2 @@
export { default as VbenHoverCard } from './hover-card.vue';
export type { HoverCardContentProps } from 'reka-ui';
@@ -0,0 +1,35 @@
<script setup lang="ts">
import type { Component } from 'vue';
import { computed } from 'vue';
import { IconDefault, IconifyIcon } from '@vben-core/icons';
import {
isFunction,
isHttpUrl,
isObject,
isString,
} from '@vben-core/shared/utils';
const props = defineProps<{
// 没有是否显示默认图标
fallback?: boolean;
icon?: Component | Function | string;
}>();
const isRemoteIcon = computed(() => {
return isString(props.icon) && isHttpUrl(props.icon);
});
const isComponent = computed(() => {
const { icon } = props;
return !isString(icon) && (isObject(icon) || isFunction(icon));
});
</script>
<template>
<component :is="icon as Component" v-if="isComponent" v-bind="$attrs" />
<img v-else-if="isRemoteIcon" :src="icon as string" v-bind="$attrs" />
<IconifyIcon v-else-if="icon" v-bind="$attrs" :icon="icon as string" />
<IconDefault v-else-if="fallback" v-bind="$attrs" />
</template>
@@ -0,0 +1 @@
export { default as VbenIcon } from './icon.vue';
@@ -0,0 +1,26 @@
export * from './avatar';
export * from './back-top';
export * from './breadcrumb';
export * from './button';
export * from './checkbox';
export * from './collapsible';
export * from './context-menu';
export * from './count-to-animator';
export * from './descriptions';
export * from './dropdown-menu';
export * from './expandable-arrow';
export * from './full-screen';
export * from './hover-card';
export * from './icon';
export * from './input-password';
export * from './logo';
export * from './pin-input';
export * from './popover';
export * from './render-content';
export * from './scrollbar';
export * from './segmented';
export * from './select';
export * from './spine-text';
export * from './spinner';
export * from './table-action';
export * from './tooltip';
@@ -0,0 +1 @@
export { default as VbenInputPassword } from './input-password.vue';
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { ref, useSlots } from 'vue';
import { Eye, EyeOff } from '@vben-core/icons';
import { cn } from '@vben-core/shared/utils';
import { Input } from '../../ui';
import PasswordStrength from './password-strength.vue';
interface Props {
class?: any;
/**
* 是否显示密码强度
*/
passwordStrength?: boolean;
}
defineOptions({
inheritAttrs: false,
});
const props = defineProps<Props>();
const modelValue = defineModel<string>();
const slots = useSlots();
const show = ref(false);
</script>
<template>
<div class="relative w-full">
<Input
v-bind="$attrs"
v-model="modelValue"
:class="cn(props.class)"
:type="show ? 'text' : 'password'"
/>
<template v-if="passwordStrength">
<PasswordStrength :password="modelValue" />
<p v-if="slots.strengthText" class="text-muted-foreground mt-1.5 text-xs">
<slot name="strengthText"> </slot>
</p>
</template>
<div
:class="{
'top-3': !!passwordStrength,
'top-1/2 -translate-y-1/2 items-center': !passwordStrength,
}"
class="text-foreground/60 hover:text-foreground absolute inset-y-0 right-0 flex cursor-pointer pr-3 text-lg leading-5"
@click="show = !show"
>
<Eye v-if="show" class="size-4" />
<EyeOff v-else class="size-4" />
</div>
</div>
</template>
@@ -0,0 +1,66 @@
<script setup lang="ts">
import { computed } from 'vue';
const props = withDefaults(defineProps<{ password?: string }>(), {
password: '',
});
const strengthList: string[] = [
'',
'#e74242',
'#ED6F6F',
'#EFBD47',
'#55D18780',
'#55D187',
];
const currentStrength = computed(() => {
return checkPasswordStrength(props.password);
});
const currentColor = computed(() => {
return strengthList[currentStrength.value];
});
/**
* Check the strength of a password
*/
function checkPasswordStrength(password: string) {
let strength = 0;
// Check length
if (password.length >= 8) strength++;
// Check for lowercase letters
if (/[a-z]/.test(password)) strength++;
// Check for uppercase letters
if (/[A-Z]/.test(password)) strength++;
// Check for numbers
if (/\d/.test(password)) strength++;
// Check for special characters
if (/[^\da-z]/i.test(password)) strength++;
return strength;
}
</script>
<template>
<div class="relative mt-2 flex items-center justify-between">
<template v-for="index in 5" :key="index">
<div
class="bg-heavy dark:bg-input-background relative mr-1 h-1.5 w-1/5 rounded-sm last:mr-0"
>
<span
:style="{
backgroundColor: currentColor,
width: currentStrength >= index ? '100%' : '',
}"
class="absolute left-0 h-full w-0 rounded-sm transition-all duration-500"
></span>
</div>
</template>
</div>
</template>
@@ -0,0 +1 @@
export { default as VbenLogo } from './logo.vue';
@@ -0,0 +1,152 @@
<script setup lang="ts">
import { computed } from 'vue';
import { VbenAvatar } from '../avatar';
interface Props {
/**
* @zh_CN 是否收起文本;布局状态,侧边栏收起时隐藏文字。
*/
collapsed?: boolean;
/**
* @zh_CN Logo 图片适应方式
*/
fit?: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down';
/**
* @zh_CN logo高度, 只在 logoMode=full时生效
*/
fullLogoHeight?: number | string;
/**
* @zh_CN Logo 跳转地址
*/
href?: string;
/**
* @zh_CN logo 展示类型,icon 图标模式, full 铺满logo区域
*/
logoMode?: 'full' | 'icon';
/**
* @zh_CN Logo 图片大小
*/
logoSize?: number;
/**
* @zh_CN Logo 是否展示文本
*/
showText?: boolean;
/**
* @zh_CN Logo 图标
*/
src?: string;
/**
* @zh_CN 暗色主题 Logo 图标 (可选,若不设置则使用 src)
*/
srcDark?: string;
/**
* @zh_CN Logo 文本
*/
text: string;
/**
* @zh_CN Logo 主题
*/
theme?: string;
}
defineOptions({
name: 'VbenLogo',
});
const props = withDefaults(defineProps<Props>(), {
collapsed: false,
href: 'javascript:void 0',
logoMode: 'icon',
logoSize: 32,
fullLogoHeight: 42,
src: '',
srcDark: '',
theme: 'light',
fit: 'cover',
showText: true,
});
/**
* @zh_CN 根据主题选择合适的 logo 图标
*/
const logoSrc = computed(() => {
// 如果是暗色主题且提供了 srcDark,则使用暗色主题的 logo
if (props.theme === 'dark' && props.srcDark) {
return props.srcDark;
}
// 否则使用默认的 src
return props.src;
});
/**
* @zh_CN 是否铺满容器显示log
*/
const shouldUseFullLogo = computed(() => {
return props.logoMode === 'full' && !props.collapsed;
});
/**
* @zh_CN 根据配置展示 logo text
*/
const shouldShowText = computed(() => {
return (
props.showText &&
!props.collapsed &&
!shouldUseFullLogo.value &&
!!props.text
);
});
/**
* @zh_CN full 模式下logo的样式
*/
const fullLogoStyle = computed(() => ({
height:
typeof props.fullLogoHeight === 'number'
? `${props.fullLogoHeight}px`
: props.fullLogoHeight,
objectFit: props.fit,
}));
</script>
<template>
<div :class="theme" class="flex h-full items-center text-lg">
<a
:class="[
$attrs.class,
shouldShowText
? 'gap-2 px-3 justify-start'
: 'w-full p-0 justify-center',
]"
:href="href"
class="flex h-full items-center overflow-hidden text-lg leading-normal transition-all duration-500"
>
<img
v-if="logoSrc && shouldUseFullLogo"
:alt="text"
:src="logoSrc"
:style="fullLogoStyle"
class="w-full"
/>
<VbenAvatar
v-else-if="logoSrc"
:alt="text"
:src="logoSrc"
:size="logoSize"
:fit="fit"
class="relative rounded-none bg-transparent"
/>
<template v-if="shouldShowText">
<slot name="text">
<span class="text-foreground truncate font-semibold text-nowrap">
{{ text }}
</span>
</slot>
</template>
</a>
</div>
</template>
@@ -0,0 +1,3 @@
export { default as VbenPinInput } from './input.vue';
export type * from './types';
@@ -0,0 +1,122 @@
<script setup lang="ts">
import type { PinInputProps } from './types';
import { computed, onBeforeUnmount, ref, useId, watch } from 'vue';
import { PinInput, PinInputGroup, PinInputSlot } from '../../ui';
import { VbenButton } from '../button';
defineOptions({
inheritAttrs: false,
});
const {
codeLength = 6,
createText = async () => {},
disabled = false,
handleSendCode = async () => {},
loading = false,
maxTime = 60,
} = defineProps<PinInputProps>();
const emit = defineEmits<{
complete: [];
sendError: [error: any];
}>();
const timer = ref<ReturnType<typeof setTimeout>>();
const modelValue = defineModel<string>();
const inputValue = ref<string[]>([]);
const countdown = ref(0);
const btnText = computed(() => {
const countdownValue = countdown.value;
return createText?.(countdownValue);
});
const btnLoading = computed(() => {
return loading || countdown.value > 0;
});
watch(
() => modelValue.value,
() => {
inputValue.value = modelValue.value?.split('') ?? [];
},
);
watch(inputValue, (val) => {
modelValue.value = val.join('');
});
function handleComplete(e: string[]) {
modelValue.value = e.join('');
emit('complete');
}
async function handleSend(e: Event) {
try {
e?.preventDefault();
await handleSendCode();
countdown.value = maxTime;
startCountdown();
} catch (error) {
console.error('Failed to send code:', error);
// Consider emitting an error event or showing a notification
emit('sendError', error);
}
}
function startCountdown() {
if (countdown.value > 0) {
timer.value = setTimeout(() => {
countdown.value--;
startCountdown();
}, 1000);
}
}
onBeforeUnmount(() => {
countdown.value = 0;
clearTimeout(timer.value);
});
const id = useId();
const pinType = 'text' as const;
</script>
<template>
<PinInput
:id="id"
v-model="inputValue"
:disabled="disabled"
class="flex w-full justify-between"
otp
placeholder="○"
:type="pinType"
@complete="handleComplete"
>
<div class="relative flex w-full">
<PinInputGroup class="mr-2">
<PinInputSlot
v-for="(item, index) in codeLength"
:key="item"
:index="index"
/>
</PinInputGroup>
<VbenButton
:disabled="disabled"
:loading="btnLoading"
class="grow"
size="lg"
variant="outline"
@click="handleSend"
>
{{ btnText }}
</VbenButton>
</div>
</PinInput>
</template>
@@ -0,0 +1,30 @@
interface PinInputProps {
class?: any;
/**
* 验证码长度
*/
codeLength?: number;
/**
* 发送验证码按钮文本
*/
createText?: (countdown: number) => string;
/**
* 是否禁用
*/
disabled?: boolean;
/**
* 自定义验证码发送逻辑
* @returns
*/
handleSendCode?: () => Promise<void>;
/**
* 发送验证码按钮loading
*/
loading?: boolean;
/**
* 最大重试时间
*/
maxTime?: number;
}
export type { PinInputProps };
@@ -0,0 +1 @@
export { default as VbenPopover } from './popover.vue';
@@ -0,0 +1,60 @@
<script setup lang="ts">
import type {
PopoverContentProps,
PopoverRootEmits,
PopoverRootProps,
} from 'reka-ui';
import type { ClassType } from '@vben-core/typings';
import { computed } from 'vue';
import { useForwardPropsEmits } from 'reka-ui';
import {
PopoverContent,
Popover as PopoverRoot,
PopoverTrigger,
} from '../../ui';
interface Props extends PopoverRootProps {
class?: ClassType;
contentClass?: ClassType;
contentProps?: PopoverContentProps;
triggerClass?: ClassType;
}
const props = withDefaults(defineProps<Props>(), {});
const emits = defineEmits<PopoverRootEmits>();
const delegatedProps = computed(() => {
const {
class: _cls,
contentClass: _,
contentProps: _cProps,
triggerClass: _tClass,
...delegated
} = props;
return delegated;
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<PopoverRoot v-bind="forwarded">
<PopoverTrigger as="span" :class="triggerClass">
<slot name="trigger"></slot>
<PopoverContent
:class="contentClass"
class="side-content z-popup"
v-bind="contentProps"
>
<slot></slot>
</PopoverContent>
</PopoverTrigger>
</PopoverRoot>
</template>
@@ -0,0 +1 @@
export { default as VbenRenderContent } from './render-content.vue';
@@ -0,0 +1,59 @@
<script lang="ts">
import type { Component, PropType } from 'vue';
import { defineComponent, h } from 'vue';
import { isFunction, isObject, isString } from '@vben-core/shared/utils';
export default defineComponent({
name: 'RenderContent',
props: {
content: {
default: undefined as
| PropType<(() => any) | Component | string>
| undefined,
type: [Object, String, Function],
},
renderBr: {
default: false,
type: Boolean,
},
},
setup(props, { attrs, slots }) {
return () => {
if (!props.content) {
return null;
}
const isComponent =
(isObject(props.content) || isFunction(props.content)) &&
props.content !== null;
if (!isComponent) {
if (props.renderBr && isString(props.content)) {
const lines = props.content.split('\n');
const result = [];
for (const [i, line] of lines.entries()) {
result.push(h('p', { key: i }, line));
// if (i < lines.length - 1) {
// result.push(h('br'));
// }
}
return result;
} else {
return props.content;
}
}
return h(
props.content as never,
{
...attrs,
props: {
...props,
...attrs,
},
},
slots,
);
};
},
});
</script>
@@ -0,0 +1 @@
export { default as VbenScrollbar } from './scrollbar.vue';
@@ -0,0 +1,165 @@
<script setup lang="ts">
import type { ClassType } from '@vben-core/typings';
import { computed, ref } from 'vue';
import { cn } from '@vben-core/shared/utils';
import { ScrollArea, ScrollBar } from '../../ui';
interface Props {
class?: ClassType;
horizontal?: boolean;
scrollBarClass?: ClassType;
shadow?: boolean;
shadowBorder?: boolean;
shadowBottom?: boolean;
shadowLeft?: boolean;
shadowRight?: boolean;
shadowTop?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
class: '',
horizontal: false,
shadow: false,
shadowBorder: false,
shadowBottom: true,
shadowLeft: false,
shadowRight: false,
shadowTop: true,
});
const emit = defineEmits<{
scrollAt: [{ bottom: boolean; left: boolean; right: boolean; top: boolean }];
}>();
const isAtTop = ref(true);
const isAtRight = ref(false);
const isAtBottom = ref(false);
const isAtLeft = ref(true);
/**
* We have to check if the scroll amount is close enough to some threshold in order to
* more accurately calculate arrivedState. This is because scrollTop/scrollLeft are non-rounded
* numbers, while scrollHeight/scrollWidth and clientHeight/clientWidth are rounded.
* https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#determine_if_an_element_has_been_totally_scrolled
*/
const ARRIVED_STATE_THRESHOLD_PIXELS = 1;
const showShadowTop = computed(() => props.shadow && props.shadowTop);
const showShadowBottom = computed(() => props.shadow && props.shadowBottom);
const showShadowLeft = computed(() => props.shadow && props.shadowLeft);
const showShadowRight = computed(() => props.shadow && props.shadowRight);
const computedShadowClasses = computed(() => {
return {
'both-shadow':
!isAtLeft.value &&
!isAtRight.value &&
showShadowLeft.value &&
showShadowRight.value,
'left-shadow': !isAtLeft.value && showShadowLeft.value,
'right-shadow': !isAtRight.value && showShadowRight.value,
};
});
function handleScroll(event: Event) {
const target = event.target as HTMLElement;
const scrollTop = target?.scrollTop ?? 0;
const scrollLeft = target?.scrollLeft ?? 0;
const clientHeight = target?.clientHeight ?? 0;
const clientWidth = target?.clientWidth ?? 0;
const scrollHeight = target?.scrollHeight ?? 0;
const scrollWidth = target?.scrollWidth ?? 0;
isAtTop.value = scrollTop <= 0;
isAtLeft.value = scrollLeft <= 0;
isAtBottom.value =
Math.abs(scrollTop) + clientHeight >=
scrollHeight - ARRIVED_STATE_THRESHOLD_PIXELS;
isAtRight.value =
Math.abs(scrollLeft) + clientWidth >=
scrollWidth - ARRIVED_STATE_THRESHOLD_PIXELS;
emit('scrollAt', {
bottom: isAtBottom.value,
left: isAtLeft.value,
right: isAtRight.value,
top: isAtTop.value,
});
}
</script>
<template>
<ScrollArea
:class="[cn(props.class), computedShadowClasses]"
:on-scroll="handleScroll"
class="vben-scrollbar relative"
>
<div
v-if="showShadowTop"
:class="{
'opacity-100': !isAtTop,
'border-border border-t': shadowBorder && !isAtTop,
}"
class="scrollbar-top-shadow pointer-events-none absolute top-0 z-10 h-12 w-full opacity-0 transition-opacity duration-300 ease-in-out will-change-[opacity]"
></div>
<slot></slot>
<div
v-if="showShadowBottom"
:class="{
'opacity-100': !isAtTop && !isAtBottom,
'border-border border-b': shadowBorder && !isAtTop && !isAtBottom,
}"
class="scrollbar-bottom-shadow pointer-events-none absolute bottom-0 z-10 h-12 w-full opacity-0 transition-opacity duration-300 ease-in-out will-change-[opacity]"
></div>
<ScrollBar
v-if="horizontal"
:class="cn(scrollBarClass)"
orientation="horizontal"
/>
</ScrollArea>
</template>
<style scoped>
.vben-scrollbar {
&:not(.both-shadow).left-shadow {
mask-image: linear-gradient(90deg, transparent, #000 16px);
}
&:not(.both-shadow).right-shadow {
mask-image: linear-gradient(
90deg,
#000 0%,
#000 calc(100% - 16px),
transparent
);
}
&.both-shadow {
mask-image: linear-gradient(
90deg,
transparent,
#000 16px,
#000 calc(100% - 16px),
transparent 100%
);
}
}
.scrollbar-top-shadow {
background: linear-gradient(
to bottom,
hsl(var(--scroll-shadow, var(--background))),
transparent
);
}
.scrollbar-bottom-shadow {
background: linear-gradient(
to top,
hsl(var(--scroll-shadow, var(--background))),
transparent
);
}
</style>
@@ -0,0 +1,3 @@
export { default as VbenSegmented } from './segmented.vue';
export type * from './types';
@@ -0,0 +1,75 @@
<script setup lang="ts">
import type { SegmentedItem } from './types';
import { computed } from 'vue';
import { TabsTrigger } from 'reka-ui';
import { Tabs, TabsContent, TabsList } from '../../ui';
import { VbenTooltip } from '../tooltip';
import TabsIndicator from './tabs-indicator.vue';
interface Props {
defaultValue?: string;
tabs?: SegmentedItem[];
}
const props = withDefaults(defineProps<Props>(), {
defaultValue: '',
tabs: () => [],
});
const activeTab = defineModel<string>();
const getDefaultValue = computed(() => {
return props.defaultValue || props.tabs[0]?.value;
});
const tabsStyle = computed(() => {
return {
'grid-template-columns': `repeat(${props.tabs.length}, minmax(0, 1fr))`,
};
});
const tabsIndicatorStyle = computed(() => {
return {
width: `${(100 / props.tabs.length).toFixed(0)}%`,
};
});
function activeClass(tab: string): string[] {
return tab === activeTab.value ? ['font-bold!', 'text-primary'] : [];
}
</script>
<template>
<Tabs v-model="activeTab" :default-value="getDefaultValue">
<TabsList
:style="tabsStyle"
class="bg-accent outline-heavy! relative grid w-full outline-2!"
>
<TabsIndicator :style="tabsIndicatorStyle" />
<template v-for="tab in tabs" :key="tab.value">
<TabsTrigger
:value="tab.value"
:class="activeClass(tab.value)"
class="hover:text-primary z-20 size-full inline-flex items-center justify-center rounded-md text-sm font-medium whitespace-nowrap disabled:pointer-events-none disabled:opacity-50"
>
<VbenTooltip :delay-duration="300" side="bottom">
<template #trigger>
<div class="whitespace-nowrap overflow-hidden text-ellipsis px-1">
{{ tab.label }}
</div>
</template>
{{ tab.label }}
</VbenTooltip>
</TabsTrigger>
</template>
</TabsList>
<template v-for="tab in tabs" :key="tab.value">
<TabsContent :value="tab.value">
<slot :name="tab.value"></slot>
</TabsContent>
</template>
</Tabs>
</template>
@@ -0,0 +1,37 @@
<script setup lang="ts">
import type { TabsIndicatorProps } from 'reka-ui';
import { computed } from 'vue';
import { cn } from '@vben-core/shared/utils';
import { TabsIndicator, useForwardProps } from 'reka-ui';
const props = defineProps<TabsIndicatorProps & { class?: any }>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<TabsIndicator
v-bind="forwardedProps"
:class="
cn(
'absolute bottom-0 left-0 z-10 size-full translate-x-(--reka-tabs-indicator-position) rounded-full py-1 transition-[width,transform] duration-300',
props.class,
)
"
>
<div
class="bg-background text-foreground inline-flex size-full items-center justify-center rounded-md text-sm font-medium whitespace-nowrap focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
>
<slot></slot>
</div>
</TabsIndicator>
</template>
@@ -0,0 +1,6 @@
interface SegmentedItem {
label: string;
value: string;
}
export type { SegmentedItem };
@@ -0,0 +1 @@
export { default as VbenSelect } from './select.vue';
@@ -0,0 +1,57 @@
<script lang="ts" setup>
import { CircleX } from '@vben-core/icons';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../../ui';
interface Props {
allowClear?: boolean;
class?: any;
options?: Array<{ label: string; value: string }>;
placeholder?: string;
}
const props = withDefaults(defineProps<Props>(), {
allowClear: false,
});
const modelValue = defineModel<string>();
function handleClear() {
modelValue.value = undefined;
}
</script>
<template>
<Select v-model="modelValue">
<SelectTrigger :class="props.class" class="flex w-full items-center">
<SelectValue class="flex-auto text-left" :placeholder="placeholder" />
<CircleX
@pointerdown.stop
@click.stop.prevent="handleClear"
v-if="allowClear && modelValue"
data-clear-button
class="mr-1 size-4 cursor-pointer opacity-50 hover:opacity-100"
/>
</SelectTrigger>
<SelectContent>
<template v-for="item in options" :key="item.value">
<SelectItem :value="item.value"> {{ item.label }} </SelectItem>
</template>
</SelectContent>
</Select>
</template>
<style lang="scss" scoped>
button[role='combobox'][data-placeholder] {
color: hsl(var(--muted-foreground));
}
button {
--ring: var(--primary);
}
</style>
@@ -0,0 +1 @@
export { default as VbenSpineText } from './spine-text.vue';
@@ -0,0 +1,49 @@
<script lang="ts" setup>
import { computed } from 'vue';
const { animationDuration = 2, animationIterationCount = 'infinite' } =
defineProps<{
// 动画持续时间,单位秒
animationDuration?: number;
// 动画是否只执行一次
animationIterationCount?: 'infinite' | number;
}>();
const style = computed(() => {
return {
animation: `shine ${animationDuration}s linear ${animationIterationCount}`,
};
});
</script>
<template>
<div :style="style" class="vben-spine-text bg-clip-text! text-transparent">
<slot></slot>
</div>
</template>
<style>
.vben-spine-text {
background:
radial-gradient(circle at center, rgb(255 255 255 / 80%), #f000) -200% 50% /
200% 100% no-repeat,
#000;
/* animation: shine 3s linear infinite; */
}
.dark .vben-spine-text {
background:
radial-gradient(circle at center, rgb(24 24 26 / 80%), transparent) -200%
50% / 200% 100% no-repeat,
#f4f4f4;
}
@keyframes shine {
0% {
background-position: 200% 0%;
}
100% {
background-position: -200% 0%;
}
}
</style>
@@ -0,0 +1,2 @@
export { default as VbenLoading } from './loading.vue';
export { default as VbenSpinner } from './spinner.vue';
@@ -0,0 +1,140 @@
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { cn } from '@vben-core/shared/utils';
interface Props {
class?: string;
/**
* @zh_CN 最小加载时间
* @en_US Minimum loading time
*/
minLoadingTime?: number;
/**
* @zh_CN loading状态开启
*/
spinning?: boolean;
/**
* @zh_CN 文字
*/
text?: string;
}
defineOptions({
name: 'VbenLoading',
});
const props = withDefaults(defineProps<Props>(), {
minLoadingTime: 50,
text: '',
});
// const startTime = ref(0);
const showSpinner = ref(false);
const renderSpinner = ref(false);
let timer: ReturnType<typeof setTimeout> | undefined;
watch(
() => props.spinning,
(show) => {
if (!show) {
showSpinner.value = false;
timer && clearTimeout(timer);
return;
}
// startTime.value = performance.now();
timer = setTimeout(() => {
// const loadingTime = performance.now() - startTime.value;
showSpinner.value = true;
if (showSpinner.value) {
renderSpinner.value = true;
}
}, props.minLoadingTime);
},
{
immediate: true,
},
);
function onTransitionEnd() {
if (!showSpinner.value) {
renderSpinner.value = false;
}
}
</script>
<template>
<div
:class="
cn(
'bg-overlay-content dark:bg-overlay absolute top-0 left-0 z-100 flex size-full flex-col items-center justify-center transition-all duration-500',
{
'invisible opacity-0': !showSpinner,
},
props.class,
)
"
@transitionend="onTransitionEnd"
>
<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="text-primary mt-4 text-xs">{{ text }}</div>
<slot></slot>
</div>
</template>
<style scoped>
.dot {
transform: rotate(45deg);
animation: rotate-ani 1.2s infinite linear;
}
.dot i {
animation: spin-move-ani 1s infinite linear alternate;
}
.dot i:nth-child(1) {
top: 0;
left: 0;
}
.dot i:nth-child(2) {
top: 0;
right: 0;
animation-delay: 0.4s;
}
.dot i:nth-child(3) {
right: 0;
bottom: 0;
animation-delay: 0.8s;
}
.dot i:nth-child(4) {
bottom: 0;
left: 0;
animation-delay: 1.2s;
}
@keyframes rotate-ani {
to {
transform: rotate(405deg);
}
}
@keyframes spin-move-ani {
to {
opacity: 1;
}
}
</style>
@@ -0,0 +1,138 @@
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { cn } from '@vben-core/shared/utils';
interface Props {
class?: string;
/**
* @zh_CN 最小加载时间
* @en_US Minimum loading time
*/
minLoadingTime?: number;
/**
* @zh_CN loading状态开启
*/
spinning?: boolean;
}
defineOptions({
name: 'VbenSpinner',
});
const props = withDefaults(defineProps<Props>(), {
minLoadingTime: 50,
});
// const startTime = ref(0);
const showSpinner = ref(false);
const renderSpinner = ref(false);
const timer = ref<ReturnType<typeof setTimeout>>();
watch(
() => props.spinning,
(show) => {
if (!show) {
showSpinner.value = false;
clearTimeout(timer.value);
return;
}
// startTime.value = performance.now();
timer.value = setTimeout(() => {
// const loadingTime = performance.now() - startTime.value;
showSpinner.value = true;
if (showSpinner.value) {
renderSpinner.value = true;
}
}, props.minLoadingTime);
},
{
immediate: true,
},
);
function onTransitionEnd() {
if (!showSpinner.value) {
renderSpinner.value = false;
}
}
</script>
<template>
<div
:class="
cn(
'flex-center bg-overlay-content absolute top-0 left-0 z-100 size-full backdrop-blur-xs transition-all duration-500',
{
'invisible pointer-events-none opacity-0': !showSpinner,
'pointer-events-auto': showSpinner,
},
props.class,
)
"
@transitionend="onTransitionEnd"
>
<div
:class="{ paused: !renderSpinner }"
v-if="renderSpinner"
class="loader before:bg-primary/50 after:bg-primary relative size-12 before:absolute before:top-15 before:left-0 before:h-1.25 before:w-12 before:rounded-full before:content-[''] after:absolute after:top-0 after:left-0 after:h-full after:w-full after:rounded after:content-['']"
></div>
</div>
</template>
<style scoped>
.paused {
&::before {
animation-play-state: paused !important;
}
&::after {
animation-play-state: paused !important;
}
}
.loader {
&::before {
animation: loader-shadow-ani 0.5s linear infinite;
}
&::after {
animation: loader-jump-ani 0.5s linear infinite;
}
}
@keyframes loader-jump-ani {
15% {
border-bottom-right-radius: 3px;
}
25% {
transform: translateY(9px) rotate(22.5deg);
}
50% {
border-bottom-right-radius: 40px;
transform: translateY(18px) scale(1, 0.9) rotate(45deg);
}
75% {
transform: translateY(9px) rotate(67.5deg);
}
100% {
transform: translateY(0) rotate(90deg);
}
}
@keyframes loader-shadow-ani {
0%,
100% {
transform: scale(1, 1);
}
50% {
transform: scale(1.2, 1);
}
}
</style>
@@ -0,0 +1,116 @@
<script setup lang="ts">
import type { ActionItem } from './types';
import { computed, ref } from 'vue';
import { useSimpleLocale } from '@vben-core/composables';
import { cn } from '@vben-core/shared/utils';
import {
DropdownMenuItem,
Popover,
PopoverContent,
PopoverTrigger,
} from '../../ui';
import { VbenButton } from '../button';
import { VbenIcon } from '../icon';
const props = defineProps<{ action: ActionItem }>();
const emit = defineEmits<{ confirm: [] }>();
const { $t } = useSimpleLocale();
const open = ref(false);
const itemClass = computed(() =>
cn(
'cursor-pointer gap-2',
props.action.danger && 'text-destructive focus:text-destructive',
),
);
/**
* 阻止 reka-ui 事件的默认行为,用于:
* - @select:阻止点击菜单项后自动关闭菜单,以便弹出气泡确认框;
* - @open-auto-focus:阻止弹层抢占焦点(避免与菜单的焦点陷阱冲突);
* - @focus-outside:阻止因菜单夺回焦点而被误判为「焦点移出」从而关闭弹层。
*/
function preventDefault(event: Event) {
event.preventDefault();
}
function onClick() {
if (props.action.disabled) return;
props.action.onClick?.();
}
function onConfirm() {
open.value = false;
const pc = props.action.popConfirm;
if (pc?.confirm) {
pc.confirm();
} else {
props.action.onClick?.();
}
// 确认后关闭整个下拉菜单
emit('confirm');
}
function onCancel() {
open.value = false;
}
</script>
<template>
<!--
气泡确认菜单项同时作为 Popover 触发器
通过双重 as-childDropdownMenuItem + PopoverTrigger 均合并到同一个叶子元素
使该元素既是菜单项又是弹层触发器@select 阻止点击后菜单自动关闭
-->
<Popover v-if="action.popConfirm" v-model:open="open">
<PopoverTrigger as-child>
<DropdownMenuItem
as-child
:class="itemClass"
:disabled="action.disabled"
@select="preventDefault"
>
<div>
<VbenIcon v-if="action.icon" :icon="action.icon" class="size-4" />
{{ action.text }}
</div>
</DropdownMenuItem>
</PopoverTrigger>
<PopoverContent
class="z-popup w-60"
side="left"
@focus-outside="preventDefault"
@open-auto-focus="preventDefault"
>
<div class="text-foreground mb-3 text-sm">
{{ action.popConfirm.title ?? $t('confirmTitle') }}
</div>
<div class="flex justify-end gap-2">
<VbenButton size="sm" variant="outline" @click="onCancel">
{{ action.popConfirm.cancelText ?? $t('cancel') }}
</VbenButton>
<VbenButton
:variant="action.danger ? 'destructive' : 'default'"
size="sm"
@click="onConfirm"
>
{{ action.popConfirm.okText ?? $t('confirm') }}
</VbenButton>
</div>
</PopoverContent>
</Popover>
<!-- 普通下拉项 -->
<DropdownMenuItem
v-else
:class="itemClass"
:disabled="action.disabled"
@click="onClick"
>
<VbenIcon v-if="action.icon" :icon="action.icon" class="size-4" />
{{ action.text }}
</DropdownMenuItem>
</template>
@@ -0,0 +1,97 @@
<script setup lang="ts">
import type { ActionItem } from './types';
import { computed, ref } from 'vue';
import { cn } from '@vben-core/shared/utils';
import { Popover, PopoverContent, PopoverTrigger } from '../../ui';
import { VbenButton } from '../button';
import { VbenIcon } from '../icon';
const props = defineProps<{ action: ActionItem }>();
const open = ref(false);
const buttonClass = computed(() =>
cn(
'gap-1',
props.action.danger && 'text-destructive hover:text-destructive',
props.action.class,
),
);
const variant = computed(() => props.action.variant ?? 'link');
const size = computed(() => props.action.size ?? 'default');
function onClick() {
if (props.action.disabled || props.action.loading) return;
props.action.onClick?.();
}
function onConfirm() {
open.value = false;
const pc = props.action.popConfirm;
if (pc?.confirm) {
pc.confirm();
} else {
props.action.onClick?.();
}
}
function onCancel() {
open.value = false;
}
</script>
<template>
<!-- 气泡确认 -->
<Popover v-if="action.popConfirm" v-model:open="open">
<PopoverTrigger as-child>
<VbenButton
:class="buttonClass"
:disabled="action.disabled"
:loading="action.loading"
:size="size"
class="p-2"
:variant="variant"
>
<VbenIcon :icon="action.icon" v-if="action.icon" class="size-4" />
<span v-if="action.text">{{ action.text }}</span>
</VbenButton>
</PopoverTrigger>
<PopoverContent class="z-popup w-60" side="top">
<div class="text-foreground mb-3 text-sm">
{{ action.popConfirm.title ?? 'Are you sure?' }}
</div>
<div class="flex justify-end gap-2">
<VbenButton size="default" variant="outline" @click="onCancel">
{{ action.popConfirm.cancelText ?? 'Cancel' }}
</VbenButton>
<VbenButton
:variant="action.danger ? 'destructive' : 'default'"
size="default"
class="p-2"
@click="onConfirm"
>
{{ action.popConfirm.okText ?? 'OK' }}
</VbenButton>
</div>
</PopoverContent>
</Popover>
<!-- 普通按钮 -->
<VbenButton
v-else
:class="buttonClass"
:disabled="action.disabled"
:loading="action.loading"
:size="size"
class="p-2"
:variant="variant"
@click="onClick"
>
<VbenIcon :icon="action.icon" v-if="action.icon" class="size-4" />
<span v-if="action.text">{{ action.text }}</span>
</VbenButton>
</template>
@@ -0,0 +1,3 @@
export { default as VbenTableAction } from './table-action.vue';
export type * from './types';
@@ -0,0 +1,216 @@
<script setup lang="ts">
import type { ActionItem, TableActionProps } from './types';
import { computed, ref } from 'vue';
import { Ellipsis } from '@vben-core/icons';
import { cn } from '@vben-core/shared/utils';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuTrigger,
Separator,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '../../ui';
import { VbenButton } from '../button';
import { VbenIcon } from '../icon';
import ActionDropdownItemComp from './action-dropdown-item.vue';
import ActionItemComp from './action-item.vue';
defineOptions({ name: 'VbenTableAction' });
const props = withDefaults(defineProps<TableActionProps>(), {
actions: () => [],
align: 'end',
class: undefined,
divider: false,
dropdownActions: () => [],
hasPermission: undefined,
moreText: undefined,
});
function checkVisible(item: ActionItem): boolean {
// 权限
if (item.auth && props.hasPermission && !props.hasPermission(item.auth)) {
return false;
}
// ifShow
if (typeof item.ifShow === 'boolean') return item.ifShow;
if (typeof item.ifShow === 'function') return item.ifShow();
return true;
}
const visibleActions = computed(() =>
(props.actions ?? []).filter((item) => checkVisible(item)),
);
const visibleDropdownActions = computed(() =>
(props.dropdownActions ?? []).filter((item) => checkVisible(item)),
);
const alignClass = computed(
() =>
({ center: 'justify-center', end: 'justify-end', start: 'justify-start' })[
props.align
],
);
// 缓存根节点类名,避免每次渲染都执行 cn()(内部 tailwind-merge 解析开销较大)
const wrapperClass = computed(() =>
cn('flex items-center gap-1', alignClass.value, props.class),
);
function tooltipSide(action: ActionItem) {
return typeof action.tooltip === 'object'
? (action.tooltip.side ?? 'top')
: 'top';
}
function tooltipContent(action: ActionItem) {
return typeof action.tooltip === 'object'
? action.tooltip.content
: action.tooltip;
}
/**
* 预计算每个主操作的渲染视图模型:
* - 普通按钮在本组件内直接渲染,不再为每个操作多包一层子组件,
* 表格大量行时可显著减少组件实例数;
* - 仅 popConfirm 操作仍交由子组件维护独立弹层状态;
* - 类名等在此一次性计算并缓存,避免模板每次渲染都执行 cn()。
*/
const renderedActions = computed(() => {
const list = visibleActions.value;
return list.map((action, index) => {
const hasTooltip = !!action.tooltip && !action.popConfirm;
return {
action,
buttonClass: cn(
'gap-1 p-2',
action.danger && 'text-destructive hover:text-destructive',
action.class,
),
hasTooltip,
isConfirm: !!action.popConfirm,
key: action.key ?? index,
showDivider: props.divider && index < list.length - 1,
size: action.size ?? 'default',
tooltipContent: hasTooltip ? tooltipContent(action) : undefined,
tooltipSide: hasTooltip ? tooltipSide(action) : 'top',
variant: action.variant ?? 'link',
};
});
});
const dropdownOpen = ref(false);
function onActionClick(action: ActionItem) {
if (action.disabled || action.loading) return;
action.onClick?.();
}
/**
* 当与气泡确认(Popover)交互时,避免误关闭整个下拉菜单。
* Popover 内容被 Portal 渲染到菜单之外,默认会被判定为「点击外部」而关闭菜单。
*/
function onContentInteractOutside(event: Event) {
const target = (event as CustomEvent).detail?.originalEvent?.target as
| HTMLElement
| null
| undefined;
if (target?.closest('[data-slot="popover-content"]')) {
event.preventDefault();
}
}
</script>
<template>
<div :class="wrapperClass">
<!-- 所有主操作共享同一个 TooltipProvider避免每个 tooltip 各建一个 provider -->
<TooltipProvider v-if="renderedActions.length > 0" :delay-duration="0">
<template v-for="item in renderedActions" :key="item.key">
<!-- 气泡确认需独立弹层状态交由子组件维护 -->
<ActionItemComp v-if="item.isConfirm" :action="item.action" />
<!-- 带提示的普通按钮 -->
<Tooltip v-else-if="item.hasTooltip">
<TooltipTrigger as-child tabindex="-1">
<VbenButton
:class="item.buttonClass"
:disabled="item.action.disabled"
:loading="item.action.loading"
:size="item.size"
:variant="item.variant"
@click="onActionClick(item.action)"
>
<VbenIcon
v-if="item.action.icon"
:icon="item.action.icon"
class="size-4"
/>
<span v-if="item.action.text">{{ item.action.text }}</span>
</VbenButton>
</TooltipTrigger>
<TooltipContent
:side="item.tooltipSide"
class="side-content bg-accent text-popover-foreground rounded-md"
>
{{ item.tooltipContent }}
</TooltipContent>
</Tooltip>
<!-- 普通按钮 -->
<VbenButton
v-else
:class="item.buttonClass"
:disabled="item.action.disabled"
:loading="item.action.loading"
:size="item.size"
:variant="item.variant"
@click="onActionClick(item.action)"
>
<VbenIcon
v-if="item.action.icon"
:icon="item.action.icon"
class="size-4"
/>
<span v-if="item.action.text">{{ item.action.text }}</span>
</VbenButton>
<Separator v-if="item.showDivider" orientation="vertical" class="h-4" />
</template>
</TooltipProvider>
<DropdownMenu
v-if="visibleDropdownActions.length > 0"
v-model:open="dropdownOpen"
>
<DropdownMenuTrigger as-child>
<VbenButton class="gap-1 p-2" variant="link">
<Ellipsis class="size-4" />
<span v-if="moreText">{{ moreText }}</span>
</VbenButton>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
@interact-outside="onContentInteractOutside"
>
<template
v-for="(item, index) in visibleDropdownActions"
:key="item.key ?? index"
>
<ActionDropdownItemComp
:action="item"
@confirm="dropdownOpen = false"
/>
<DropdownMenuSeparator
v-if="divider && index < visibleDropdownActions.length - 1"
/>
</template>
</DropdownMenuContent>
</DropdownMenu>
</div>
</template>
@@ -0,0 +1,75 @@
import type { ButtonVariants } from '../../ui';
import { VbenIcon } from '../icon';
/** 权限码:单个或多个,配合注入的 hasPermission 判断 */
export type TableActionAuth = string | string[];
/** 操作按钮提示 */
export interface TableActionTooltip {
content: string;
side?: 'bottom' | 'left' | 'right' | 'top';
}
/** 气泡确认框配置 */
export interface TableActionPopConfirm {
/** 取消按钮文案 */
cancelText?: string;
/** 确认回调;未提供时回退到 action.onClick */
confirm?: () => void;
/** 确认按钮文案 */
okText?: string;
/** 提示标题 */
title?: string;
}
export interface ActionItem {
/** 权限码,配合注入的 hasPermission 过滤 */
auth?: TableActionAuth;
/** 自定义类名 */
class?: any;
/** 危险操作(红色文字) */
danger?: boolean;
/** 是否禁用 */
disabled?: boolean;
/** 图标组件 */
icon?: typeof VbenIcon.icon;
/** 是否显示:布尔或返回布尔的函数 */
ifShow?: (() => boolean) | boolean;
/** 唯一标识,点击回调可据此区分 */
key?: number | string;
/** 加载状态 */
loading?: boolean;
/** 点击回调 */
onClick?: () => void;
/** 气泡确认框 */
popConfirm?: TableActionPopConfirm;
/** 尺寸 */
size?: ButtonVariants['size'];
/** 文本 */
text?: string;
/** 提示:字符串或配置对象 */
tooltip?: string | TableActionTooltip;
/** 按钮样式变体 */
variant?: ButtonVariants['variant'];
}
export interface TableActionProps {
/** 主操作按钮 */
actions?: ActionItem[];
/** 对齐方式 */
align?: 'center' | 'end' | 'start';
/** 自定义类名 */
class?: any;
/** 按钮之间是否显示分割线 */
divider?: boolean;
/** “更多”下拉中的操作 */
dropdownActions?: ActionItem[];
/**
* 权限判断函数,返回 false 则隐藏对应 auth 的操作。
* 核心组件不依赖业务,由使用方注入(如 useAccess().hasAccessByCodes)。
*/
hasPermission?: (auth?: TableActionAuth) => boolean;
/** “更多”按钮文案(提供时显示在图标右侧) */
moreText?: string;
}
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { cn } from '@vben-core/shared/utils';
import { CircleHelp } from '@lucide/vue';
import Tooltip from './tooltip.vue';
defineOptions({
inheritAttrs: false,
});
defineProps<{ triggerClass?: string }>();
</script>
<template>
<Tooltip :delay-duration="300" side="right">
<template #trigger>
<slot name="trigger">
<CircleHelp
:class="
cn(
'text-foreground/80 hover:text-foreground inline-flex size-5 cursor-pointer',
triggerClass,
)
"
/>
</slot>
</template>
<slot></slot>
</Tooltip>
</template>
@@ -0,0 +1,2 @@
export { default as VbenHelpTooltip } from './help-tooltip.vue';
export { default as VbenTooltip } from './tooltip.vue';
@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { TooltipContentProps } from 'reka-ui';
import type { StyleValue } from 'vue';
import type { ClassType } from '@vben-core/typings';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '../../ui';
interface Props {
contentClass?: ClassType;
contentStyle?: StyleValue;
delayDuration?: number;
side?: TooltipContentProps['side'];
}
withDefaults(defineProps<Props>(), {
delayDuration: 0,
side: 'right',
});
</script>
<template>
<TooltipProvider :delay-duration="delayDuration">
<Tooltip>
<TooltipTrigger as-child tabindex="-1">
<slot name="trigger"></slot>
</TooltipTrigger>
<TooltipContent
:class="contentClass"
:side="side"
:style="contentStyle"
class="side-content bg-accent text-popover-foreground rounded-md"
>
<slot></slot>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</template>
@@ -0,0 +1,3 @@
export * from './components';
export * from './ui';
export { createContext, Slot, VisuallyHidden } from 'reka-ui';
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { AccordionRootEmits, AccordionRootProps } from 'reka-ui';
import { AccordionRoot, useForwardPropsEmits } from 'reka-ui';
const props = defineProps<AccordionRootProps>();
const emits = defineEmits<AccordionRootEmits>();
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<AccordionRoot v-slot="slotProps" data-slot="accordion" v-bind="forwarded">
<slot v-bind="slotProps"></slot>
</AccordionRoot>
</template>
@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { AccordionContentProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { cn } from '@vben-core/shared/utils';
import { reactiveOmit } from '@vueuse/core';
import { AccordionContent } from 'reka-ui';
const props = defineProps<
AccordionContentProps & { class?: HTMLAttributes['class'] }
>();
const delegatedProps = reactiveOmit(props, 'class');
</script>
<template>
<AccordionContent
data-slot="accordion-content"
v-bind="delegatedProps"
class="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
>
<div :class="cn('pt-0 pb-4', props.class)">
<slot></slot>
</div>
</AccordionContent>
</template>
@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { AccordionItemProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { cn } from '@vben-core/shared/utils';
import { reactiveOmit } from '@vueuse/core';
import { AccordionItem, useForwardProps } from 'reka-ui';
const props = defineProps<
AccordionItemProps & { class?: HTMLAttributes['class'] }
>();
const delegatedProps = reactiveOmit(props, 'class');
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<AccordionItem
v-slot="slotProps"
data-slot="accordion-item"
v-bind="forwardedProps"
:class="cn('border-b last:border-b-0', props.class)"
>
<slot v-bind="slotProps"></slot>
</AccordionItem>
</template>
@@ -0,0 +1,39 @@
<script setup lang="ts">
import type { AccordionTriggerProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { cn } from '@vben-core/shared/utils';
import { ChevronDown } from '@lucide/vue';
import { reactiveOmit } from '@vueuse/core';
import { AccordionHeader, AccordionTrigger } from 'reka-ui';
const props = defineProps<
AccordionTriggerProps & { class?: HTMLAttributes['class'] }
>();
const delegatedProps = reactiveOmit(props, 'class');
</script>
<template>
<AccordionHeader class="flex">
<AccordionTrigger
data-slot="accordion-trigger"
v-bind="delegatedProps"
:class="
cn(
'focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
props.class,
)
"
>
<slot></slot>
<slot name="icon">
<ChevronDown
class="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200"
/>
</slot>
</AccordionTrigger>
</AccordionHeader>
</template>
@@ -0,0 +1,4 @@
export { default as Accordion } from './Accordion.vue';
export { default as AccordionContent } from './AccordionContent.vue';
export { default as AccordionItem } from './AccordionItem.vue';
export { default as AccordionTrigger } from './AccordionTrigger.vue';
@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { AlertDialogEmits, AlertDialogProps } from 'reka-ui';
import { AlertDialogRoot, useForwardPropsEmits } from 'reka-ui';
const props = defineProps<AlertDialogProps>();
const emits = defineEmits<AlertDialogEmits>();
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<AlertDialogRoot
v-slot="slotProps"
data-slot="alert-dialog"
v-bind="forwarded"
>
<slot v-bind="slotProps"></slot>
</AlertDialogRoot>
</template>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import type { AlertDialogActionProps } from 'reka-ui';
import { AlertDialogAction } from 'reka-ui';
const props = defineProps<AlertDialogActionProps>();
</script>
<template>
<AlertDialogAction v-bind="props">
<slot></slot>
</AlertDialogAction>
</template>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import type { AlertDialogCancelProps } from 'reka-ui';
import { AlertDialogCancel } from 'reka-ui';
const props = defineProps<AlertDialogCancelProps>();
</script>
<template>
<AlertDialogCancel v-bind="props">
<slot></slot>
</AlertDialogCancel>
</template>
@@ -0,0 +1,101 @@
<script setup lang="ts">
import type { AlertDialogContentEmits, AlertDialogContentProps } from 'reka-ui';
import type { ClassType } from '@vben-core/typings';
import { ref } from 'vue';
import { cn } from '@vben-core/shared/utils';
import { reactiveOmit } from '@vueuse/core';
import {
AlertDialogContent,
AlertDialogPortal,
useForwardPropsEmits,
} from 'reka-ui';
import { useDialogStateEvents } from '../dialog/use-dialog-state-events';
import AlertDialogOverlay from './AlertDialogOverlay.vue';
defineOptions({
inheritAttrs: false,
});
const props = withDefaults(
defineProps<
AlertDialogContentProps & {
centered?: boolean;
class?: ClassType;
modal?: boolean;
open?: boolean;
overlayBlur?: number;
zIndex?: number;
}
>(),
{ modal: true },
);
const emits = defineEmits<
AlertDialogContentEmits & { close: []; closed: []; opened: [] }
>();
// reka-ui 的 AlertDialog 在 modal=true 时会将 body 设置 pointer-events:none
// 弹出层(如 Select 下拉框)会因此无法点击。这里通过在上层传入 :modal="false" 来
// 避免该问题,同时通过 AlertDialogOverlay 组件自行渲染遮罩并锁定滚动。
// AlertDialogOverlay 通过 v-if 控制挂载/卸载,其内部的 useScrollLock 会在组件
// 卸载时自动解锁滚动。
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardPropsEmits(delegatedProps, emits);
const contentRef = ref<InstanceType<typeof AlertDialogContent> | null>(null);
const { handleAnimationEvent } = useDialogStateEvents({
contentRef,
isOpen: () => props.open,
onClosed: () => emits('closed'),
onOpened: () => emits('opened'),
});
defineExpose({
getContentRef: () => contentRef.value,
});
</script>
<template>
<AlertDialogPortal>
<Transition name="fade" appear>
<AlertDialogOverlay
v-if="open && modal"
:overlay-blur="overlayBlur"
position="fixed"
:z-index="zIndex"
@click="() => emits('close')"
/>
</Transition>
<AlertDialogContent
data-slot="alert-dialog-content"
ref="contentRef"
:style="{ ...(zIndex ? { zIndex } : {}), position: 'fixed' }"
@animationend="handleAnimationEvent"
@animationcancel="handleAnimationEvent"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'z-popup bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
{
'data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-top-[48%]':
!centered,
'data-[state=closed]:slide-out-to-top-[148%] data-[state=open]:slide-in-from-top-[98%]':
centered,
'top-[10vh]': !centered,
'top-1/2 -translate-y-1/2': centered,
},
props.class,
)
"
>
<slot></slot>
</AlertDialogContent>
</AlertDialogPortal>
</template>
@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { AlertDialogDescriptionProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { cn } from '@vben-core/shared/utils';
import { reactiveOmit } from '@vueuse/core';
import { AlertDialogDescription, useForwardProps } from 'reka-ui';
const props = defineProps<
AlertDialogDescriptionProps & { class?: HTMLAttributes['class'] }
>();
const delegatedProps = reactiveOmit(props, 'class');
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<AlertDialogDescription
data-slot="alert-dialog-description"
v-bind="forwardedProps"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot></slot>
</AlertDialogDescription>
</template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue';
import { cn } from '@vben-core/shared/utils';
const props = defineProps<{
class?: HTMLAttributes['class'];
}>();
</script>
<template>
<div
data-slot="alert-dialog-footer"
:class="
cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)
"
>
<slot></slot>
</div>
</template>
@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue';
import { cn } from '@vben-core/shared/utils';
const props = defineProps<{
class?: HTMLAttributes['class'];
}>();
</script>
<template>
<div
data-slot="alert-dialog-header"
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
>
<slot></slot>
</div>
</template>
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { useScrollLock } from '@vben-core/composables';
import { cn } from '@vben-core/shared/utils';
const props = withDefaults(
defineProps<{
class?: any;
overlayBlur?: number;
position?: 'absolute' | 'fixed';
zIndex?: number;
}>(),
{
position: 'fixed',
},
);
// 通过 v-if 控制挂载/卸载,组件卸载时 useScrollLock 自动解锁滚动
useScrollLock();
</script>
<template>
<div
:style="{
...(zIndex ? { zIndex } : {}),
position,
backdropFilter:
overlayBlur && overlayBlur > 0 ? `blur(${overlayBlur}px)` : 'none',
}"
:class="cn('z-popup bg-overlay inset-0 fixed', props.class)"
></div>
</template>
@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { AlertDialogTitleProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { cn } from '@vben-core/shared/utils';
import { reactiveOmit } from '@vueuse/core';
import { AlertDialogTitle } from 'reka-ui';
const props = defineProps<
AlertDialogTitleProps & { class?: HTMLAttributes['class'] }
>();
const delegatedProps = reactiveOmit(props, 'class');
</script>
<template>
<AlertDialogTitle
data-slot="alert-dialog-title"
v-bind="delegatedProps"
:class="cn('text-lg font-semibold', props.class)"
>
<slot></slot>
</AlertDialogTitle>
</template>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import type { AlertDialogTriggerProps } from 'reka-ui';
import { AlertDialogTrigger } from 'reka-ui';
const props = defineProps<AlertDialogTriggerProps>();
</script>
<template>
<AlertDialogTrigger data-slot="alert-dialog-trigger" v-bind="props">
<slot></slot>
</AlertDialogTrigger>
</template>
@@ -0,0 +1,9 @@
export { default as AlertDialog } from './AlertDialog.vue';
export { default as AlertDialogAction } from './AlertDialogAction.vue';
export { default as AlertDialogCancel } from './AlertDialogCancel.vue';
export { default as AlertDialogContent } from './AlertDialogContent.vue';
export { default as AlertDialogDescription } from './AlertDialogDescription.vue';
export { default as AlertDialogFooter } from './AlertDialogFooter.vue';
export { default as AlertDialogHeader } from './AlertDialogHeader.vue';
export { default as AlertDialogTitle } from './AlertDialogTitle.vue';
export { default as AlertDialogTrigger } from './AlertDialogTrigger.vue';

Some files were not shown because too many files have changed in this diff Show More