gengx
This commit is contained in:
@@ -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,
|
||||
};
|
||||
};
|
||||
+111
@@ -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>
|
||||
+39
@@ -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>
|
||||
+196
@@ -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';
|
||||
+123
@@ -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>
|
||||
+264
@@ -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;
|
||||
}
|
||||
+128
@@ -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 };
|
||||
+128
@@ -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';
|
||||
+129
@@ -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>
|
||||
+57
@@ -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>
|
||||
+112
@@ -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>
|
||||
+91
@@ -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;
|
||||
}
|
||||
+216
@@ -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;
|
||||
});
|
||||
}
|
||||
+49
@@ -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>
|
||||
+52
@@ -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 };
|
||||
+31
@@ -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';
|
||||
+57
@@ -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>
|
||||
+66
@@ -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';
|
||||
+59
@@ -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>
|
||||
+116
@@ -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-child(DropdownMenuItem + 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';
|
||||
+216
@@ -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>
|
||||
+28
@@ -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
Reference in New Issue
Block a user