第一基础版

This commit is contained in:
2025-06-08 20:16:51 +08:00
commit 08e79c60e7
1469 changed files with 127477 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
clean: true,
declaration: true,
entries: [
{
builder: 'mkdist',
input: './src',
pattern: ['**/*'],
},
{
builder: 'mkdist',
input: './src',
loaders: ['vue'],
pattern: ['**/*.vue'],
},
{
builder: 'mkdist',
format: 'esm',
input: './src',
loaders: ['js'],
pattern: ['**/*.ts'],
},
],
});

View File

@@ -0,0 +1,16 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"typescript": true,
"tailwind": {
"config": "tailwind.config.mjs",
"css": "src/assets/index.css",
"baseColor": "slate",
"cssVariables": true
},
"framework": "vite",
"aliases": {
"components": "@vben-core/shadcn-ui/components",
"utils": "@vben-core/shared/utils"
}
}

View File

@@ -0,0 +1,56 @@
{
"name": "@vben-core/shadcn-ui",
"version": "5.5.4",
"#main": "./dist/index.mjs",
"#module": "./dist/index.mjs",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {
"type": "git",
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
"directory": "packages/@vben-core/uikit/shadcn-ui"
},
"license": "MIT",
"type": "module",
"scripts": {
"#build": "pnpm unbuild",
"#prepublishOnly": "npm run build"
},
"files": [
"dist"
],
"sideEffects": [
"**/*.css"
],
"main": "./src/index.ts",
"module": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"development": "./src/index.ts",
"default": "./src/index.ts",
"//default": "./dist/index.mjs"
}
},
"publishConfig": {
"exports": {
".": {
"default": "./src/index.ts"
}
}
},
"dependencies": {
"@vben-core/composables": "workspace:*",
"@vben-core/icons": "workspace:*",
"@vben-core/shared": "workspace:*",
"@vben-core/typings": "workspace:*",
"@vueuse/core": "catalog:",
"@wangeditor/editor": "catalog:",
"@wangeditor/editor-for-vue": "catalog:",
"class-variance-authority": "catalog:",
"lucide-vue-next": "catalog:",
"radix-vue": "catalog:",
"vee-validate": "catalog:",
"vue": "catalog:"
}
}

View File

@@ -0,0 +1 @@
export { default } from '@vben/tailwind-config/postcss';

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
import type {
AvatarFallbackProps,
AvatarImageProps,
AvatarRootProps,
} from 'radix-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;
size?: number;
}
defineOptions({
inheritAttrs: false,
});
const props = withDefaults(defineProps<Props>(), {
alt: 'avatar',
as: 'button',
dot: false,
dotClass: 'bg-green-500',
});
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 flex-shrink-0 items-center"
>
<Avatar :class="props.class" class="size-full">
<AvatarImage :alt="alt" :src="src" />
<AvatarFallback>{{ text }}</AvatarFallback>
</Avatar>
<span
v-if="dot"
:class="dotClass"
class="border-background absolute bottom-0 right-0 size-3 rounded-full border-2"
>
</span>
</div>
</template>

View File

@@ -0,0 +1 @@
export { default as VbenAvatar } from './avatar.vue';

View File

@@ -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="dark:bg-accent dark:hover:bg-heavy bg-background hover:bg-heavy data shadow-float z-popup fixed bottom-10 size-10 rounded-full duration-500"
size="icon"
variant="icon"
@click="handleClick"
>
<ArrowUpToLine class="size-4" />
</VbenButton>
</transition>
</template>

View File

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

View File

@@ -0,0 +1 @@
export { default as VbenBackTop } from './back-top.vue';

View File

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

View File

@@ -0,0 +1,109 @@
<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 flex-shrink-0"
/>
<span
:class="{
'text-foreground font-normal':
index === breadcrumbs.length - 1,
}"
>{{ item.title }}
</span>
</span>
</a>
</li>
</template>
</TransitionGroup>
</ul>
</template>
<style scoped>
li {
@apply h-7;
}
li a {
@apply text-muted-foreground bg-accent relative mr-9 flex h-7 items-center py-0 pl-[5px] pr-2 text-[13px];
}
li a > span {
@apply -ml-3;
}
li:first-child a > span {
@apply -ml-1;
}
li:first-child a {
@apply rounded-[4px_0_0_4px] pl-[15px];
}
li:first-child a::before {
@apply border-none;
}
li:last-child a {
@apply rounded-[0_4px_4px_0] pr-[15px];
}
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-[.875rem] 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>

View File

@@ -0,0 +1,39 @@
<script lang="ts" setup>
import type { BreadcrumbProps } from './types';
import { useForwardPropsEmits } from 'radix-vue';
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>

View File

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

View File

@@ -0,0 +1,3 @@
export { default as VbenBreadcrumbView } from './breadcrumb-view.vue';
export type * from './types';

View File

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

View File

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

View File

@@ -0,0 +1,42 @@
import type { AsTag } from 'radix-vue';
import type { Component } from 'vue';
import type { ButtonVariants, ButtonVariantSize } 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.radix-vue.com/guides/composition.html) guide for more details.
*/
asChild?: boolean;
class?: any;
disabled?: boolean;
loading?: boolean;
size?: ButtonVariantSize;
variant?: ButtonVariants;
}
export type CustomRenderType = (() => Component | string) | string;
export type ValueType = boolean | number | string;
export interface VbenButtonGroupProps
extends Pick<VbenButtonProps, 'disabled'> {
beforeChange?: (
value: ValueType,
isChecked: boolean,
) => boolean | PromiseLike<boolean | undefined> | undefined;
btnClass?: any;
gap?: number;
multiple?: boolean;
options?: { label: CustomRenderType; value: ValueType }[];
showIcon?: boolean;
size?: 'large' | 'middle' | 'small';
}

View File

@@ -0,0 +1,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 'radix-vue';
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 flex-shrink-0 animate-spin"
/>
<slot></slot>
</Primitive>
</template>

View File

@@ -0,0 +1,163 @@
<script lang="ts" setup>
import type { Arrayable } from '@vueuse/core';
import type { ValueType, VbenButtonGroupProps } from './button';
import { computed, ref, watch } from 'vue';
import { Circle, CircleCheckBig, LoaderCircle } from '@vben-core/icons';
import { 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',
});
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 {
innerValue.value.push(value);
}
modelValue.value = innerValue.value;
} 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)"
>
<div class="icon-wrapper" v-if="props.showIcon">
<LoaderCircle
class="animate-spin"
v-if="loadingValues.includes(btn.value)"
/>
<CircleCheckBig v-else-if="innerValue.includes(btn.value)" />
<Circle v-else />
</div>
<slot name="option" :label="btn.label" :value="btn.value">
<VbenRenderContent :content="btn.label" />
</slot>
</Button>
</VbenButtonGroup>
</template>
<style lang="scss" scoped>
.vben-check-button-group {
&:deep(.size-large) button {
.icon-wrapper {
margin-right: 0.3rem;
svg {
width: 1rem;
height: 1rem;
}
}
}
&:deep(.size-middle) button {
.icon-wrapper {
margin-right: 0.2rem;
svg {
width: 0.75rem;
height: 0.75rem;
}
}
}
&:deep(.size-small) button {
.icon-wrapper {
margin-right: 0.1rem;
svg {
width: 0.65rem;
height: 0.65rem;
}
}
}
}
</style>

View File

@@ -0,0 +1,68 @@
<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?: () => void;
tooltip?: string;
tooltipDelayDuration?: number;
tooltipSide?: 'bottom' | 'left' | 'right' | 'top';
variant?: ButtonVariants;
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
onClick: () => {},
tooltipDelayDuration: 200,
tooltipSide: 'bottom',
variant: 'icon',
});
const slots = useSlots();
const showTooltip = computed(() => !!slots.tooltip || !!props.tooltip);
</script>
<template>
<VbenButton
v-if="!showTooltip"
:class="cn('rounded-full', props.class)"
:disabled="disabled"
:variant="variant"
size="icon"
@click="onClick"
>
<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="onClick"
>
<slot></slot>
</VbenButton>
</template>
<slot v-if="slots.tooltip" name="tooltip"> </slot>
<template v-else>
{{ tooltip }}
</template>
</VbenTooltip>
</template>

View File

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

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { CheckboxRootEmits, CheckboxRootProps } from 'radix-vue';
import { useId } from 'vue';
import { useForwardPropsEmits } from 'radix-vue';
import { Checkbox } from '../../ui/checkbox';
const props = defineProps<CheckboxRootProps & { indeterminate?: boolean }>();
const emits = defineEmits<CheckboxRootEmits>();
const checked = defineModel<boolean>('checked');
const forwarded = useForwardPropsEmits(props, emits);
const id = useId();
</script>
<template>
<div class="flex items-center">
<Checkbox v-bind="forwarded" :id="id" v-model:checked="checked" />
<label :for="id" class="ml-2 cursor-pointer text-sm"> <slot></slot> </label>
</div>
</template>

View File

@@ -0,0 +1 @@
export { default as VbenCheckbox } from './checkbox.vue';

View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
import type {
ContextMenuContentProps,
ContextMenuRootEmits,
ContextMenuRootProps,
} from 'radix-vue';
import type { ClassType } from '@vben-core/typings';
import type { IContextMenuItem } from './interface';
import { computed } from 'vue';
import { useForwardPropsEmits } from 'radix-vue';
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 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);
}
</script>
<template>
<ContextMenu v-bind="forwarded">
<ContextMenuTrigger as-child>
<slot></slot>
</ContextMenuTrigger>
<ContextMenuContent
:class="contentClass"
v-bind="contentProps"
class="side-content z-popup"
>
<template v-for="menu in menusView" :key="menu.key">
<ContextMenuItem
: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>

View File

@@ -0,0 +1,3 @@
export { default as VbenContextMenu } from './context-menu.vue';
export type * from './interface';

View File

@@ -0,0 +1,38 @@
import type { Component } from 'vue';
interface IContextMenuItem {
/**
* @zh_CN 是否禁用
*/
disabled?: boolean;
/**
* @zh_CN 点击事件处理
* @param data
*/
handler?: (data: any) => void;
/**
* @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 };

View File

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

View File

@@ -0,0 +1 @@
export { default as VbenCountToAnimator } from './count-to-animator.vue';

View File

@@ -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="data-[state=checked]:bg-accent data-[state=checked]:text-accent-foreground text-foreground/80 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>

View File

@@ -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.key">
<DropdownMenuItem
:class="
menu.value === modelValue
? 'bg-accent text-accent-foreground'
: ''
"
class="data-[state=checked]:bg-accent data-[state=checked]:text-accent-foreground text-foreground/80 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>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { default as VbenExpandableArrow } from './expandable-arrow.vue';

View File

@@ -0,0 +1,28 @@
<script lang="ts" setup>
import { Maximize, Minimize } from '@vben-core/icons';
import { useFullscreen } from '@vueuse/core';
import { VbenIconButton } from '../button';
defineOptions({ name: 'FullScreen' });
const { isFullscreen, toggle } = useFullscreen();
// 重新检查全屏状态
isFullscreen.value = !!(
document.fullscreenElement ||
// @ts-ignore
document.webkitFullscreenElement ||
// @ts-ignore
document.mozFullScreenElement ||
// @ts-ignore
document.msFullscreenElement
);
</script>
<template>
<VbenIconButton @click="toggle">
<Minimize v-if="isFullscreen" class="text-foreground size-4" />
<Maximize v-else class="text-foreground size-4" />
</VbenIconButton>
</template>

View File

@@ -0,0 +1 @@
export { default as VbenFullScreen } from './full-screen.vue';

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import type {
HoverCardContentProps,
HoverCardRootEmits,
HoverCardRootProps,
} from 'radix-vue';
import type { ClassType } from '@vben-core/typings';
import { computed } from 'vue';
import { useForwardPropsEmits } from 'radix-vue';
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>

View File

@@ -0,0 +1,2 @@
export { default as VbenHoverCard } from './hover-card.vue';
export type { HoverCardContentProps } from 'radix-vue';

View File

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

View File

@@ -0,0 +1 @@
export { default as VbenIcon } from './icon.vue';

View File

@@ -0,0 +1,24 @@
export * from './avatar';
export * from './back-top';
export * from './breadcrumb';
export * from './button';
export * from './checkbox';
export * from './context-menu';
export * from './count-to-animator';
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 './richText';
export * from './scrollbar';
export * from './segmented';
export * from './select';
export * from './spine-text';
export * from './spinner';
export * from './tooltip';

View File

@@ -0,0 +1 @@
export { default as VbenInputPassword } from './input-password.vue';

View File

@@ -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="hover:text-foreground text-foreground/60 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>

View File

@@ -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="dark:bg-input-background bg-heavy 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>

View File

@@ -0,0 +1 @@
export { default as VbenLogo } from './logo.vue';

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import { VbenAvatar } from '../avatar';
interface Props {
/**
* @zh_CN 是否收起文本
*/
collapsed?: boolean;
/**
* @zh_CN Logo 跳转地址
*/
href?: string;
/**
* @zh_CN Logo 图片大小
*/
logoSize?: number;
/**
* @zh_CN Logo 图标
*/
src?: string;
/**
* @zh_CN Logo 文本
*/
text: string;
/**
* @zh_CN Logo 主题
*/
theme?: string;
}
defineOptions({
name: 'VbenLogo',
});
withDefaults(defineProps<Props>(), {
collapsed: false,
href: 'javascript:void 0',
logoSize: 32,
src: '',
theme: 'light',
});
</script>
<template>
<div :class="theme" class="flex h-full items-center text-lg">
<a
:class="$attrs.class"
:href="href"
class="flex h-full items-center gap-2 overflow-hidden px-3 text-lg leading-normal transition-all duration-500"
>
<VbenAvatar
v-if="src"
:alt="text"
:src="src"
:size="logoSize"
class="relative rounded-none bg-transparent"
/>
<template v-if="!collapsed">
<slot name="text">
<span class="text-foreground truncate text-nowrap font-semibold">
{{ text }}
</span>
</slot>
</template>
</a>
</div>
</template>

View File

@@ -0,0 +1,3 @@
export { default as VbenPinInput } from './input.vue';
export type * from './types';

View File

@@ -0,0 +1,120 @@
<script setup lang="ts">
import type { PinInputProps } from './types';
import { computed, onBeforeUnmount, ref, useId, watch } from 'vue';
import { PinInput, PinInputGroup, PinInputInput } 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();
</script>
<template>
<PinInput
:id="id"
v-model="inputValue"
:disabled="disabled"
class="flex w-full justify-between"
otp
placeholder="○"
type="number"
@complete="handleComplete"
>
<div class="relative flex w-full">
<PinInputGroup class="mr-2">
<PinInputInput
v-for="(item, index) in codeLength"
:key="item"
:index="index"
/>
</PinInputGroup>
<VbenButton
:disabled="disabled"
:loading="btnLoading"
class="flex-grow"
size="lg"
variant="outline"
@click="handleSend"
>
{{ btnText }}
</VbenButton>
</div>
</PinInput>
</template>

View File

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

View File

@@ -0,0 +1 @@
export { default as VbenPopover } from './popover.vue';

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import type {
PopoverContentProps,
PopoverRootEmits,
PopoverRootProps,
} from 'radix-vue';
import type { ClassType } from '@vben-core/typings';
import { computed } from 'vue';
import { useForwardPropsEmits } from 'radix-vue';
import {
PopoverContent,
Popover as PopoverRoot,
PopoverTrigger,
} from '../../ui';
interface Props extends PopoverRootProps {
class?: ClassType;
contentClass?: ClassType;
contentProps?: PopoverContentProps;
}
const props = withDefaults(defineProps<Props>(), {});
const emits = defineEmits<PopoverRootEmits>();
const delegatedProps = computed(() => {
const {
class: _cls,
contentClass: _,
contentProps: _cProps,
...delegated
} = props;
return delegated;
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<PopoverRoot v-bind="forwarded">
<PopoverTrigger>
<slot name="trigger"></slot>
<PopoverContent
:class="contentClass"
class="side-content z-popup"
v-bind="contentProps"
>
<slot></slot>
</PopoverContent>
</PopoverTrigger>
</PopoverRoot>
</template>

View File

@@ -0,0 +1 @@
export { default as VbenRenderContent } from './render-content.vue';

View File

@@ -0,0 +1,56 @@
<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>

View File

@@ -0,0 +1 @@
declare module '@wangeditor/editor-for-vue';

View File

@@ -0,0 +1 @@
export { default as RichText } from './richText.vue';

View File

@@ -0,0 +1,169 @@
<script setup lang="ts">
import type {
IDomEditor,
IEditorConfig,
IToolbarConfig,
} from '@wangeditor/editor';
import { ref, watch } from 'vue';
import { Editor, Toolbar } from '@wangeditor/editor-for-vue';
import '@wangeditor/editor/dist/css/style.css';
interface Props {
height?: number | string;
mode?: 'default' | 'simple';
modelValue?: string;
placeholder?: string;
readonly?: boolean;
toolbarKeys?: string[];
uploadImageAccept?: string[];
uploadImageMaxSize?: number;
uploadImageServer?: string;
}
const props = withDefaults(defineProps<Props>(), {
height: 300,
mode: 'default',
modelValue: '',
placeholder: '请输入内容',
readonly: false,
toolbarKeys: () => [
'bold',
'italic',
'underline',
'color',
'bgColor',
'justifyLeft',
'justifyCenter',
'justifyRight',
'undo',
'redo',
],
uploadImageAccept: () => ['.jpg', '.jpeg', '.png', '.gif', '.webp'],
uploadImageMaxSize: 5 * 1024 * 1024, // 5MB
uploadImageServer: '',
});
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
(e: 'change', editor: IDomEditor): void;
(e: 'uploadImageSuccess', url: string): void;
(e: 'uploadImageError', error: Error): void;
(e: 'focus', editor: IDomEditor): void;
(e: 'blur', editor: IDomEditor): void;
}>();
const html = ref(props.modelValue);
const editorRef = ref<IDomEditor | null>(null);
// 监听 modelValue 变化
watch(
() => props.modelValue,
(newVal) => {
if (newVal !== html.value) {
html.value = newVal;
}
},
);
// 监听 html 变化
watch(
() => html.value,
(newVal) => {
emit('update:modelValue', newVal);
},
);
// 编辑器创建完成时的回调
const handleCreated = (editor: IDomEditor) => {
editorRef.value = editor;
emit('change', editor);
};
// 组件销毁时销毁编辑器
const handleDestroyed = () => {
editorRef.value?.destroy();
};
// 工具栏配置
const toolbarConfig: Partial<IToolbarConfig> = {
toolbarKeys: props.toolbarKeys,
};
// 编辑器配置
const editorConfig: Partial<IEditorConfig> = {
MENU_CONF: {
uploadImage: {
allowedFileTypes: props.uploadImageAccept,
maxFileSize: props.uploadImageMaxSize,
onError(file: File, err: Error) {
emit('uploadImageError', err);
},
onSuccess(file: File, res: any) {
emit('uploadImageSuccess', res.url);
},
server: props.uploadImageServer,
},
},
placeholder: props.placeholder,
readOnly: props.readonly,
};
// 编辑器事件处理
const handleFocus = (editor: IDomEditor) => {
emit('focus', editor);
};
const handleBlur = (editor: IDomEditor) => {
emit('blur', editor);
};
</script>
<template>
<div class="rich-text-editor">
<Toolbar
:editor="editorRef"
:default-config="toolbarConfig"
:mode="mode"
class="editor-toolbar"
/>
<Editor
v-model="html"
:default-config="editorConfig"
:mode="mode"
:style="{ height: typeof height === 'number' ? `${height}px` : height }"
class="editor-content"
@on-created="handleCreated"
@on-destroyed="handleDestroyed"
@on-focus="handleFocus"
@on-blur="handleBlur"
/>
</div>
</template>
<style>
.rich-text-editor {
width: 100%;
border: 1px solid #d9d9d9;
border-radius: 2px;
}
.editor-toolbar {
border-bottom: 1px solid #d9d9d9;
}
.editor-content {
overflow-y: hidden;
}
.rich-text-editor:hover {
border-color: #40a9ff;
}
.rich-text-editor:focus-within {
border-color: #40a9ff;
box-shadow: 0 0 0 2px rgb(24 144 255 / 20%);
}
</style>

View File

@@ -0,0 +1 @@
export { default as VbenScrollbar } from './scrollbar.vue';

View File

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

View File

@@ -0,0 +1,3 @@
export { default as VbenSegmented } from './segmented.vue';
export type * from './types';

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import type { SegmentedItem } from './types';
import { computed } from 'vue';
import { TabsTrigger } from 'radix-vue';
import { Tabs, TabsContent, TabsList } from '../../ui';
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)}%`,
};
});
</script>
<template>
<Tabs v-model="activeTab" :default-value="getDefaultValue">
<TabsList :style="tabsStyle" class="bg-accent relative grid w-full">
<TabsIndicator :style="tabsIndicatorStyle" />
<template v-for="tab in tabs" :key="tab.value">
<TabsTrigger
:value="tab.value"
class="z-20 inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium disabled:pointer-events-none disabled:opacity-50"
>
{{ tab.label }}
</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>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import type { TabsIndicatorProps } from 'radix-vue';
import { computed } from 'vue';
import { cn } from '@vben-core/shared/utils';
import { TabsIndicator, useForwardProps } from 'radix-vue';
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 h-full w-1/2 translate-x-[--radix-tabs-indicator-position] rounded-full px-0 py-1 pr-1 transition-[width,transform] duration-300',
props.class,
)
"
>
<div
class="bg-background text-foreground inline-flex h-full w-full items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
>
<slot></slot>
</div>
</TabsIndicator>
</template>

View File

@@ -0,0 +1,6 @@
interface SegmentedItem {
label: string;
value: string;
}
export type { SegmentedItem };

View File

@@ -0,0 +1 @@
export { default as VbenSelect } from './select.vue';

View File

@@ -0,0 +1,39 @@
<script lang="ts" setup>
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../../ui';
interface Props {
class?: any;
options?: Array<{ label: string; value: string }>;
placeholder?: string;
}
const props = defineProps<Props>();
</script>
<template>
<Select>
<SelectTrigger :class="props.class">
<SelectValue :placeholder="placeholder" />
</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>

View File

@@ -0,0 +1 @@
export { default as VbenSpineText } from './spine-text.vue';

View File

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

View File

@@ -0,0 +1,2 @@
export { default as VbenLoading } from './loading.vue';
export { default as VbenSpinner } from './spinner.vue';

View File

@@ -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);
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(
'z-100 dark:bg-overlay bg-overlay-content absolute left-0 top-0 flex size-full flex-col items-center justify-center transition-all duration-500',
{
'invisible opacity-0': !showSpinner,
},
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>

View File

@@ -0,0 +1,137 @@
<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 z-100 bg-overlay-content absolute left-0 top-0 size-full backdrop-blur-sm transition-all duration-500',
{
'invisible opacity-0': !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:left-0 before:top-[60px] before:h-[5px] before:w-12 before:rounded-[50%] before:content-[''] after:absolute after:left-0 after:top-0 after:h-full after:w-full after:rounded after:content-['']"
></div>
</div>
</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>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { cn } from '@vben-core/shared/utils';
import { CircleHelp } from 'lucide-vue-next';
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>

View File

@@ -0,0 +1,2 @@
export { default as VbenHelpTooltip } from './help-tooltip.vue';
export { default as VbenTooltip } from './tooltip.vue';

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { TooltipContentProps } from 'radix-vue';
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 text-popover-foreground bg-accent rounded-md"
>
<slot></slot>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</template>

View File

@@ -0,0 +1,3 @@
export * from './components';
export * from './ui';
export { createContext, Slot, VisuallyHidden } from 'radix-vue';

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { AccordionRootEmits, AccordionRootProps } from 'radix-vue';
import { AccordionRoot, useForwardPropsEmits } from 'radix-vue';
const props = defineProps<AccordionRootProps>();
const emits = defineEmits<AccordionRootEmits>();
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<AccordionRoot v-bind="forwarded">
<slot></slot>
</AccordionRoot>
</template>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { AccordionContentProps } from 'radix-vue';
import { computed } from 'vue';
import { cn } from '@vben-core/shared/utils';
import { AccordionContent } from 'radix-vue';
const props = defineProps<AccordionContentProps & { class?: any }>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<AccordionContent
v-bind="delegatedProps"
class="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
>
<div :class="cn('pb-4 pt-0', props.class)">
<slot></slot>
</div>
</AccordionContent>
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { AccordionItemProps } from 'radix-vue';
import { computed } from 'vue';
import { cn } from '@vben-core/shared/utils';
import { AccordionItem, useForwardProps } from 'radix-vue';
const props = defineProps<AccordionItemProps & { class?: any }>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<AccordionItem v-bind="forwardedProps" :class="cn('border-b', props.class)">
<slot></slot>
</AccordionItem>
</template>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import type { AccordionTriggerProps } from 'radix-vue';
import { computed } from 'vue';
import { cn } from '@vben-core/shared/utils';
import { ChevronDown } from 'lucide-vue-next';
import { AccordionHeader, AccordionTrigger } from 'radix-vue';
const props = defineProps<AccordionTriggerProps & { class?: any }>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<AccordionHeader class="flex">
<AccordionTrigger
v-bind="delegatedProps"
:class="
cn(
'flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
props.class,
)
"
>
<slot></slot>
<slot name="icon">
<ChevronDown
class="text-muted-foreground h-4 w-4 shrink-0 transition-transform duration-200"
/>
</slot>
</AccordionTrigger>
</AccordionHeader>
</template>

View File

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

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { AlertDialogEmits, AlertDialogProps } from 'radix-vue';
import { AlertDialogRoot, useForwardPropsEmits } from 'radix-vue';
const props = defineProps<AlertDialogProps>();
const emits = defineEmits<AlertDialogEmits>();
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<AlertDialogRoot v-bind="forwarded">
<slot></slot>
</AlertDialogRoot>
</template>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import type { AlertDialogActionProps } from 'radix-vue';
import { AlertDialogAction } from 'radix-vue';
const props = defineProps<AlertDialogActionProps>();
</script>
<template>
<AlertDialogAction v-bind="props">
<slot></slot>
</AlertDialogAction>
</template>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import type { AlertDialogCancelProps } from 'radix-vue';
import { AlertDialogCancel } from 'radix-vue';
const props = defineProps<AlertDialogCancelProps>();
</script>
<template>
<AlertDialogCancel v-bind="props">
<slot></slot>
</AlertDialogCancel>
</template>

View File

@@ -0,0 +1,101 @@
<script setup lang="ts">
import type {
AlertDialogContentEmits,
AlertDialogContentProps,
} from 'radix-vue';
import type { ClassType } from '@vben-core/typings';
import { computed, ref } from 'vue';
import { cn } from '@vben-core/shared/utils';
import {
AlertDialogContent,
AlertDialogPortal,
useForwardPropsEmits,
} from 'radix-vue';
import AlertDialogOverlay from './AlertDialogOverlay.vue';
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: [] }
>();
const delegatedProps = computed(() => {
const { class: _, modal: _modal, open: _open, ...delegated } = props;
return delegated;
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
const contentRef = ref<InstanceType<typeof AlertDialogContent> | null>(null);
function onAnimationEnd(event: AnimationEvent) {
// 只有在 contentRef 的动画结束时才触发 opened/closed 事件
if (event.target === contentRef.value?.$el) {
if (props.open) {
emits('opened');
} else {
emits('closed');
}
}
}
defineExpose({
getContentRef: () => contentRef.value,
});
</script>
<template>
<AlertDialogPortal>
<Transition name="fade" appear>
<AlertDialogOverlay
v-if="open && modal"
:style="{
...(zIndex ? { zIndex } : {}),
position: 'fixed',
backdropFilter:
overlayBlur && overlayBlur > 0 ? `blur(${overlayBlur}px)` : 'none',
}"
@click="() => emits('close')"
/>
</Transition>
<AlertDialogContent
ref="contentRef"
:style="{ ...(zIndex ? { zIndex } : {}), position: 'fixed' }"
@animationend="onAnimationEnd"
v-bind="forwarded"
:class="
cn(
'z-popup bg-background w-full p-6 shadow-lg outline-none sm:rounded-xl',
'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
{
'data-[state=open]:slide-in-from-top-[48%] data-[state=closed]:slide-out-to-top-[48%]':
!centered,
'data-[state=open]:slide-in-from-top-[98%] data-[state=closed]:slide-out-to-top-[148%]':
centered,
'top-[10vh]': !centered,
'top-1/2 -translate-y-1/2': centered,
},
props.class,
)
"
>
<slot></slot>
</AlertDialogContent>
</AlertDialogPortal>
</template>

View File

@@ -0,0 +1,28 @@
<script lang="ts" setup>
import type { AlertDialogDescriptionProps } from 'radix-vue';
import { computed } from 'vue';
import { cn } from '@vben-core/shared/utils';
import { AlertDialogDescription, useForwardProps } from 'radix-vue';
const props = defineProps<AlertDialogDescriptionProps & { class?: any }>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<AlertDialogDescription
v-bind="forwardedProps"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot></slot>
</AlertDialogDescription>
</template>

View File

@@ -0,0 +1,8 @@
<script setup lang="ts">
import { useScrollLock } from '@vben-core/composables';
useScrollLock();
</script>
<template>
<div class="bg-overlay z-popup inset-0"></div>
</template>

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import type { AlertDialogTitleProps } from 'radix-vue';
import { computed } from 'vue';
import { cn } from '@vben-core/shared/utils';
import { AlertDialogTitle, useForwardProps } from 'radix-vue';
const props = defineProps<AlertDialogTitleProps & { class?: any }>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<AlertDialogTitle
v-bind="forwardedProps"
:class="
cn('text-lg font-semibold leading-none tracking-tight', props.class)
"
>
<slot></slot>
</AlertDialogTitle>
</template>

View File

@@ -0,0 +1,6 @@
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 AlertDialogTitle } from './AlertDialogTitle.vue';

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { AvatarVariants } from './avatar';
import { cn } from '@vben-core/shared/utils';
import { AvatarRoot } from 'radix-vue';
import { avatarVariant } from './avatar';
const props = withDefaults(
defineProps<{
class?: any;
shape?: AvatarVariants['shape'];
size?: AvatarVariants['size'];
}>(),
{
shape: 'circle',
size: 'sm',
},
);
</script>
<template>
<AvatarRoot :class="cn(avatarVariant({ size, shape }), props.class)">
<slot></slot>
</AvatarRoot>
</template>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import type { AvatarFallbackProps } from 'radix-vue';
import { AvatarFallback } from 'radix-vue';
const props = defineProps<AvatarFallbackProps>();
</script>
<template>
<AvatarFallback v-bind="props">
<slot></slot>
</AvatarFallback>
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import type { AvatarImageProps } from 'radix-vue';
import { AvatarImage } from 'radix-vue';
const props = defineProps<AvatarImageProps>();
</script>
<template>
<AvatarImage v-bind="props" class="h-full w-full object-cover" />
</template>

View File

@@ -0,0 +1,22 @@
import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
export const avatarVariant = cva(
'inline-flex items-center justify-center font-normal text-foreground select-none shrink-0 bg-secondary overflow-hidden',
{
variants: {
shape: {
circle: 'rounded-full',
square: 'rounded-md',
},
size: {
base: 'h-16 w-16 text-2xl',
lg: 'h-32 w-32 text-5xl',
sm: 'h-10 w-10 text-xs',
},
},
},
);
export type AvatarVariants = VariantProps<typeof avatarVariant>;

View File

@@ -0,0 +1,4 @@
export * from './avatar';
export { default as Avatar } from './Avatar.vue';
export { default as AvatarFallback } from './AvatarFallback.vue';
export { default as AvatarImage } from './AvatarImage.vue';

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { BadgeVariants } from './badge';
import { cn } from '@vben-core/shared/utils';
import { badgeVariants } from './badge';
const props = defineProps<{
class?: any;
variant?: BadgeVariants['variant'];
}>();
</script>
<template>
<div :class="cn(badgeVariants({ variant }), props.class)">
<slot></slot>
</div>
</template>

View File

@@ -0,0 +1,25 @@
import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
export const badgeVariants = cva(
'inline-flex items-center rounded-md border border-border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
defaultVariants: {
variant: 'default',
},
variants: {
variant: {
default:
'border-transparent bg-accent hover:bg-accent text-primary-foreground shadow',
destructive:
'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive-hover',
outline: 'text-foreground',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
},
},
},
);
export type BadgeVariants = VariantProps<typeof badgeVariants>;

View File

@@ -0,0 +1,3 @@
export * from './badge';
export { default as Badge } from './Badge.vue';

View File

@@ -0,0 +1,11 @@
<script lang="ts" setup>
const props = defineProps<{
class?: any;
}>();
</script>
<template>
<nav :class="props.class" aria-label="breadcrumb" role="navigation">
<slot></slot>
</nav>
</template>

View File

@@ -0,0 +1,22 @@
<script lang="ts" setup>
import { cn } from '@vben-core/shared/utils';
import { MoreHorizontal } from 'lucide-vue-next';
const props = defineProps<{
class?: any;
}>();
</script>
<template>
<span
:class="cn('flex h-9 w-9 items-center justify-center', props.class)"
aria-hidden="true"
role="presentation"
>
<slot>
<MoreHorizontal class="h-4 w-4" />
</slot>
<span class="sr-only">More</span>
</span>
</template>

View File

@@ -0,0 +1,17 @@
<script lang="ts" setup>
import { cn } from '@vben-core/shared/utils';
const props = defineProps<{
class?: any;
}>();
</script>
<template>
<li
:class="
cn('hover:text-foreground inline-flex items-center gap-1.5', props.class)
"
>
<slot></slot>
</li>
</template>

View File

@@ -0,0 +1,21 @@
<script lang="ts" setup>
import type { PrimitiveProps } from 'radix-vue';
import { cn } from '@vben-core/shared/utils';
import { Primitive } from 'radix-vue';
const props = withDefaults(defineProps<PrimitiveProps & { class?: any }>(), {
as: 'a',
});
</script>
<template>
<Primitive
:as="as"
:as-child="asChild"
:class="cn('hover:text-foreground transition-colors', props.class)"
>
<slot></slot>
</Primitive>
</template>

View File

@@ -0,0 +1,20 @@
<script lang="ts" setup>
import { cn } from '@vben-core/shared/utils';
const props = defineProps<{
class?: any;
}>();
</script>
<template>
<ol
:class="
cn(
'text-muted-foreground flex flex-wrap items-center gap-1.5 break-words text-sm sm:gap-2.5',
props.class,
)
"
>
<slot></slot>
</ol>
</template>

View File

@@ -0,0 +1,18 @@
<script lang="ts" setup>
import { cn } from '@vben-core/shared/utils';
const props = defineProps<{
class?: any;
}>();
</script>
<template>
<span
:class="cn('text-foreground font-normal', props.class)"
aria-current="page"
aria-disabled="true"
role="link"
>
<slot></slot>
</span>
</template>

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