Initial commit

This commit is contained in:
fawney19
2025-12-10 20:52:44 +08:00
commit f784106826
485 changed files with 110993 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import { AvatarFallback as AvatarFallbackPrimitive } from 'radix-vue'
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
}
const props = defineProps<Props>()
const fallbackClass = computed(() =>
cn(
'flex h-full w-full items-center justify-center rounded-full bg-muted',
props.class
)
)
</script>
<template>
<AvatarFallbackPrimitive :class="fallbackClass">
<slot />
</AvatarFallbackPrimitive>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import { AvatarImage as AvatarImagePrimitive } from 'radix-vue'
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
src?: string
alt: string
}
const props = withDefaults(defineProps<Props>(), {
alt: ''
})
const imageClass = computed(() =>
cn('aspect-square h-full w-full', props.class)
)
</script>
<template>
<AvatarImagePrimitive :class="imageClass" :src="src || ''" :alt="alt" />
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import { AvatarRoot } from 'radix-vue'
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
}
const props = defineProps<Props>()
const avatarClass = computed(() =>
cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', props.class)
)
</script>
<template>
<AvatarRoot :class="avatarClass">
<slot />
</AvatarRoot>
</template>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import { cva } from 'class-variance-authority'
import { cn } from '@/lib/utils'
import { computed } from 'vue'
const badgeVariants = cva(
'inline-flex items-center rounded-full 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',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground border-border bg-card/50',
success:
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
warning:
'border-transparent bg-yellow-500 text-white hover:bg-yellow-600',
dark:
'border-transparent bg-foreground text-background hover:bg-foreground/80',
},
},
defaultVariants: {
variant: 'default',
},
}
)
interface Props {
variant?: 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'dark'
class?: string
}
const props = withDefaults(defineProps<Props>(), {
variant: 'default',
})
const badgeClass = computed(() =>
cn(badgeVariants({ variant: props.variant }), props.class)
)
</script>
<template>
<div :class="badgeClass">
<slot />
</div>
</template>

View File

@@ -0,0 +1,61 @@
<template>
<button
:type="props.type"
:class="buttonClass"
:disabled="disabled"
v-bind="$attrs"
>
<slot />
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'
size?: 'default' | 'sm' | 'lg' | 'icon'
disabled?: boolean
class?: string
type?: 'button' | 'submit' | 'reset'
}
const props = withDefaults(defineProps<Props>(), {
variant: 'default',
size: 'default',
disabled: false,
type: 'button'
})
const buttonClass = computed(() => {
const baseClass =
'inline-flex items-center justify-center rounded-xl text-sm font-semibold transition-all duration-200 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]'
const variantClasses = {
default:
'bg-primary text-white shadow-[0_20px_35px_rgba(204,120,92,0.35)] hover:bg-primary/90 hover:shadow-[0_25px_45px_rgba(204,120,92,0.45)]',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/85 shadow-sm',
outline:
'border border-border/60 bg-card/60 text-foreground hover:border-primary/60 hover:text-primary hover:bg-primary/10 shadow-sm backdrop-blur transition-all',
secondary:
'bg-secondary text-secondary-foreground shadow-inner hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
}
const sizeClasses = {
default: 'h-11 px-5',
sm: 'h-9 rounded-lg px-3',
lg: 'h-12 rounded-xl px-8 text-base',
icon: 'h-11 w-11 rounded-2xl',
}
return cn(
baseClass,
variantClasses[props.variant],
sizeClasses[props.size],
props.class
)
})
</script>

View File

@@ -0,0 +1,44 @@
<template>
<div :class="cardClass">
<slot />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
variant?: 'default' | 'glass' | 'elevated' | 'interactive' | 'subtle' | 'inset'
class?: string
}
const props = withDefaults(defineProps<Props>(), {
variant: 'default'
})
// 标准卡片变体定义
const variants = {
// 默认卡片 - 纯色背景,标准边框,用于主要内容容器
default: 'rounded-2xl border border-border bg-card text-card-foreground shadow-sm',
// 玻璃态卡片 - 半透明背景+模糊效果,用于嵌套内容/次要层级
glass: 'rounded-2xl border border-border bg-card/50 text-card-foreground shadow-sm backdrop-blur-sm',
// 提升卡片 - 更强阴影效果,用于模态对话框/强调内容
elevated: 'rounded-2xl border border-border bg-card text-card-foreground shadow-lg',
// 交互卡片 - 带hover效果,用于可点击列表项
interactive: 'rounded-2xl border border-border bg-card text-card-foreground shadow-sm transition-all duration-200 hover:shadow-md hover:border-primary/30 hover:-translate-y-0.5',
// 轻量卡片 - 更淡的边框,用于辅助信息区域
subtle: 'rounded-2xl border border-border/50 bg-card text-card-foreground shadow-sm',
// 嵌入式卡片 - 极淡背景,用于卡片内的子卡片(保持层级关系)
inset: 'rounded-xl border border-border/40 bg-card/40 text-card-foreground'
}
const cardClass = computed(() =>
cn(variants[props.variant], props.class)
)
</script>

View File

@@ -0,0 +1,47 @@
<template>
<input
type="checkbox"
:class="checkboxClass"
:checked="isChecked"
v-bind="$attrs"
@change="handleChange"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
modelValue?: boolean
checked?: boolean
class?: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'update:checked': [value: boolean]
}>()
const checkboxClass = computed(() =>
cn(
'h-4 w-4 rounded border-border/60 bg-card/80 text-primary shadow-sm focus:ring-2 focus:ring-primary/40 focus:ring-offset-1 accent-primary',
props.class
)
)
const isChecked = computed<boolean>(() => {
if (typeof props.checked === 'boolean') {
return props.checked
}
return props.modelValue ?? false
})
function handleChange(event: Event) {
const target = event.target as HTMLInputElement
const value = target.checked
emit('update:modelValue', value)
emit('update:checked', value)
}
</script>

View File

@@ -0,0 +1,139 @@
<template>
<Teleport to="body">
<div v-if="isOpen" class="fixed inset-0 overflow-y-auto" :style="{ zIndex: containerZIndex }">
<!-- 背景遮罩 -->
<Transition
enter-active-class="duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="isOpen"
class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity"
:style="{ zIndex: backdropZIndex }"
@click="handleClose"
/>
</Transition>
<div class="relative flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<!-- 对话框内容 -->
<Transition
enter-active-class="duration-300 ease-out"
enter-from-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to-class="opacity-100 translate-y-0 sm:scale-100"
leave-active-class="duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0 sm:scale-100"
leave-to-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div
v-if="isOpen"
@click.stop
class="relative transform rounded-lg bg-background text-left shadow-2xl transition-all sm:my-8 sm:w-full border border-border"
:style="{ zIndex: contentZIndex }"
:class="maxWidthClass"
>
<!-- Header 区域优先使用 slot否则使用 title prop -->
<slot name="header">
<div v-if="title" class="border-b border-border px-6 py-4">
<div class="flex items-center gap-3">
<div v-if="icon" class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0" :class="iconClass">
<component :is="icon" class="h-5 w-5 text-primary" />
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-foreground leading-tight">{{ title }}</h3>
<p v-if="description" class="text-xs text-muted-foreground">{{ description }}</p>
</div>
</div>
</div>
</slot>
<!-- 内容区域统一添加 padding -->
<div class="px-6 py-3">
<slot />
</div>
<!-- Footer 区域如果有 footer 插槽自动添加样式 -->
<div
v-if="slots.footer"
class="border-t border-border px-6 py-4 bg-muted/10 flex flex-row-reverse gap-3"
>
<slot name="footer" />
</div>
</div>
</Transition>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, useSlots, type Component } from 'vue'
// Props 定义
const props = defineProps<{
open?: boolean
modelValue?: boolean
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | '7xl'
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | '7xl'
title?: string
description?: string
icon?: Component // Lucide icon component
iconClass?: string // Custom icon color class
zIndex?: number // Custom z-index for nested dialogs (default: 60)
}>()
// Emits 定义
const emit = defineEmits<{
'update:open': [value: boolean]
'update:modelValue': [value: boolean]
}>()
// 获取 slots 以便在模板中使用
const slots = useSlots()
// 统一处理 open 状态
const isOpen = computed(() => {
if (props.modelValue === true) {
return true
}
if (props.open === true) {
return true
}
return false
})
// 统一处理关闭事件
function handleClose() {
if (props.open !== undefined) {
emit('update:open', false)
}
if (props.modelValue !== undefined) {
emit('update:modelValue', false)
}
}
const maxWidthClass = computed(() => {
const sizeValue = props.maxWidth || props.size || 'md'
const sizes = {
sm: 'sm:max-w-sm',
md: 'sm:max-w-md',
lg: 'sm:max-w-lg',
xl: 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl',
'3xl': 'sm:max-w-3xl',
'4xl': 'sm:max-w-4xl',
'5xl': 'sm:max-w-5xl',
'6xl': 'sm:max-w-6xl',
'7xl': 'sm:max-w-7xl'
}
return sizes[sizeValue]
})
// Z-index computed values for nested dialogs support
const containerZIndex = computed(() => props.zIndex || 60)
const backdropZIndex = computed(() => props.zIndex || 60)
const contentZIndex = computed(() => (props.zIndex || 60) + 10)
</script>

View File

@@ -0,0 +1,5 @@
<template>
<div class="px-6 py-5 bg-background">
<slot />
</div>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<div class="mt-2">
<p class="text-sm text-gray-500 dark:text-muted-foreground">
<slot />
</p>
</div>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<div class="border-t border-border px-6 py-4 bg-muted/10 flex flex-row-reverse gap-3">
<slot />
</div>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<div class="border-b border-border px-6 py-4">
<slot />
</div>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<h3 class="text-lg font-semibold text-foreground flex items-center gap-2">
<slot />
</h3>
</template>

View File

@@ -0,0 +1,67 @@
/**
* shadcn/ui Components
* 统一导出所有 shadcn UI 组件,简化导入
*
* 使用方式:
* import { Button, Input, Card } from '@/components/ui'
*/
// 布局组件
export { default as Card } from './card.vue'
export { default as Separator } from './separator.vue'
// Tabs 选项卡系列
export { default as Tabs } from './tabs.vue'
export { default as TabsContent } from './tabs-content.vue'
export { default as TabsList } from './tabs-list.vue'
export { default as TabsTrigger } from './tabs-trigger.vue'
// 表单组件
export { default as Button } from './button.vue'
export { default as Input } from './input.vue'
export { default as Textarea } from './textarea.vue'
export { default as Label } from './label.vue'
export { default as Checkbox } from './checkbox.vue'
export { default as Switch } from './switch.vue'
// Select 选择器系列
export { default as Select } from './select.vue'
export { default as SelectTrigger } from './select-trigger.vue'
export { default as SelectValue } from './select-value.vue'
export { default as SelectContent } from './select-content.vue'
export { default as SelectItem } from './select-item.vue'
// 反馈组件
export { default as Badge } from './badge.vue'
export { default as Skeleton } from './skeleton.vue'
// Dialog 对话框系列
export { default as Dialog } from './dialog/Dialog.vue'
export { default as DialogContent } from './dialog/DialogContent.vue'
export { default as DialogHeader } from './dialog/DialogHeader.vue'
export { default as DialogTitle } from './dialog/DialogTitle.vue'
export { default as DialogDescription } from './dialog/DialogDescription.vue'
export { default as DialogFooter } from './dialog/DialogFooter.vue'
// Table 表格系列
export { default as Table } from './table.vue'
export { default as TableBody } from './table-body.vue'
export { default as TableCell } from './table-cell.vue'
export { default as TableHead } from './table-head.vue'
export { default as TableHeader } from './table-header.vue'
export { default as TableRow } from './table-row.vue'
export { default as TableCard } from './table-card.vue'
// Avatar 头像系列
export { default as Avatar } from './avatar.vue'
export { default as AvatarFallback } from './avatar-fallback.vue'
export { default as AvatarImage } from './avatar-image.vue'
// 分页组件
export { default as Pagination } from './pagination.vue'
// 操作按钮
export { default as RefreshButton } from './refresh-button.vue'
// Tooltip 提示系列
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip'

View File

@@ -0,0 +1,39 @@
<template>
<input
:class="inputClass"
:value="modelValue"
:autocomplete="autocompleteAttr"
v-bind="$attrs"
@input="handleInput"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
modelValue?: string | number
class?: string
autocomplete?: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const autocompleteAttr = computed(() => props.autocomplete ?? 'off')
const inputClass = computed(() =>
cn(
'flex h-11 w-full rounded-2xl border border-border/60 bg-card/80 px-4 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:border-primary/60 text-foreground backdrop-blur transition-all',
props.class
)
)
function handleInput(event: Event) {
const target = event.target as HTMLInputElement
emit('update:modelValue', target.value)
}
</script>

View File

@@ -0,0 +1,23 @@
<template>
<label :class="labelClass">
<slot />
</label>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: string
}
const props = defineProps<Props>()
const labelClass = computed(() =>
cn(
'text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
props.class
)
)
</script>

View File

@@ -0,0 +1,172 @@
<template>
<div class="flex flex-col sm:flex-row gap-4 border-t border-border/60 px-6 py-4 bg-muted/20">
<!-- 左侧记录范围和每页数量 -->
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-3 text-sm text-muted-foreground">
<span class="font-medium">
显示 <span class="text-foreground font-semibold">{{ recordRange.start }}-{{ recordRange.end }}</span> <span class="text-foreground font-semibold">{{ total }}</span>
</span>
<Select
v-if="showPageSizeSelector"
v-model:open="pageSizeSelectOpen"
:model-value="String(pageSize)"
@update:model-value="handlePageSizeChange"
>
<SelectTrigger class="w-36 h-9 border-border/60">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="size in pageSizeOptions"
:key="size"
:value="String(size)"
>
{{ size }} /
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- 右侧分页按钮 -->
<div class="flex flex-wrap items-center gap-2 sm:ml-auto">
<Button
variant="outline"
size="sm"
class="h-9 px-3"
:disabled="current === 1"
@click="handlePageChange(1)"
>
首页
</Button>
<Button
variant="outline"
size="sm"
class="h-9 px-3"
:disabled="current === 1"
@click="handlePageChange(current - 1)"
>
上一页
</Button>
<!-- 页码按钮智能省略 -->
<template v-for="page in pageNumbers" :key="page">
<Button
v-if="typeof page === 'number'"
:variant="page === current ? 'default' : 'outline'"
size="sm"
class="h-9 min-w-[36px] px-2"
:class="page === current ? 'shadow-sm' : ''"
@click="handlePageChange(page)"
>
{{ page }}
</Button>
<span v-else class="px-2 text-muted-foreground select-none">{{ page }}</span>
</template>
<Button
variant="outline"
size="sm"
class="h-9 px-3"
:disabled="current === totalPages"
@click="handlePageChange(current + 1)"
>
下一页
</Button>
<Button
variant="outline"
size="sm"
class="h-9 px-3"
:disabled="current === totalPages"
@click="handlePageChange(totalPages)"
>
末页
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Button, Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui'
interface Props {
current: number
total: number
pageSize?: number
pageSizeOptions?: number[]
showPageSizeSelector?: boolean
}
interface Emits {
(e: 'update:current', value: number): void
(e: 'update:pageSize', value: number): void
}
const props = withDefaults(defineProps<Props>(), {
pageSize: 20,
pageSizeOptions: () => [10, 20, 50, 100],
showPageSizeSelector: true
})
const emit = defineEmits<Emits>()
const pageSizeSelectOpen = ref(false)
const totalPages = computed(() => Math.ceil(props.total / props.pageSize))
const recordRange = computed(() => {
const start = (props.current - 1) * props.pageSize + 1
const end = Math.min(props.current * props.pageSize, props.total)
return { start, end }
})
const pageNumbers = computed(() => {
const pages: (number | string)[] = []
const total = totalPages.value
const current = props.current
if (total <= 7) {
// 总页数 <= 7全部显示
for (let i = 1; i <= total; i++) {
pages.push(i)
}
} else {
// 总页数 > 7智能省略
if (current <= 3) {
// 当前页在前 3 页:[1, 2, 3, 4, 5, ..., total]
for (let i = 1; i <= 5; i++) pages.push(i)
pages.push('...')
pages.push(total)
} else if (current >= total - 2) {
// 当前页在后 3 页:[1, ..., total-4, total-3, total-2, total-1, total]
pages.push(1)
pages.push('...')
for (let i = total - 4; i <= total; i++) pages.push(i)
} else {
// 当前页在中间:[1, ..., current-1, current, current+1, ..., total]
pages.push(1)
pages.push('...')
for (let i = current - 1; i <= current + 1; i++) pages.push(i)
pages.push('...')
pages.push(total)
}
}
return pages
})
function handlePageChange(page: number) {
if (page < 1 || page > totalPages.value || page === props.current) {
return
}
emit('update:current', page)
}
function handlePageSizeChange(value: string) {
const newSize = parseInt(value)
if (newSize !== props.pageSize) {
emit('update:pageSize', newSize)
// 切换每页数量时,重置到第一页
emit('update:current', 1)
}
}
</script>

View File

@@ -0,0 +1,37 @@
<template>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:disabled="loading"
:title="title"
@click="handleClick"
>
<RefreshCcw class="w-3.5 h-3.5" :class="loading ? 'animate-spin' : ''" />
</Button>
</template>
<script setup lang="ts">
import { Button } from '@/components/ui'
import { RefreshCcw } from 'lucide-vue-next'
interface Props {
loading?: boolean
title?: string
}
interface Emits {
(e: 'click'): void
}
withDefaults(defineProps<Props>(), {
loading: false,
title: '刷新'
})
const emit = defineEmits<Emits>()
function handleClick() {
emit('click')
}
</script>

View File

@@ -0,0 +1,52 @@
<template>
<SelectPortal>
<SelectContentPrimitive
v-bind="$attrs"
:class="contentClass"
:position="position"
:side="side"
:side-offset="sideOffset"
:align="align"
:align-offset="alignOffset"
>
<SelectViewport :class="viewportClass">
<slot />
</SelectViewport>
</SelectContentPrimitive>
</SelectPortal>
</template>
<script setup lang="ts">
import {
SelectContent as SelectContentPrimitive,
SelectPortal,
SelectViewport,
} from 'radix-vue'
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
position?: 'item-aligned' | 'popper'
side?: 'top' | 'right' | 'bottom' | 'left'
sideOffset?: number
align?: 'start' | 'center' | 'end'
alignOffset?: number
}
const props = withDefaults(defineProps<Props>(), {
position: 'popper',
sideOffset: 4,
})
const contentClass = computed(() =>
cn(
'z-[100] max-h-96 min-w-[8rem] overflow-hidden rounded-2xl border border-border bg-card text-foreground shadow-2xl backdrop-blur-xl pointer-events-auto',
'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',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
props.class
)
)
const viewportClass = 'p-1 max-h-[var(--radix-select-content-available-height)]'
</script>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
import { SelectItem as SelectItemPrimitive, SelectItemIndicator, SelectItemText } from 'radix-vue'
import { Check } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
value: string
disabled?: boolean
}
const props = defineProps<Props>()
const itemClass = computed(() =>
cn(
'relative flex w-full cursor-pointer select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm outline-none hover:bg-primary/10 focus:bg-primary/15 text-foreground transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
props.class
)
)
</script>
<template>
<SelectItemPrimitive :class="itemClass" :value="value" :disabled="disabled">
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectItemIndicator>
<Check class="h-4 w-4" />
</SelectItemIndicator>
</span>
<SelectItemText>
<slot />
</SelectItemText>
</SelectItemPrimitive>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { SelectTrigger as SelectTriggerPrimitive } from 'radix-vue'
import { ChevronDown } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
disabled?: boolean
}
const props = defineProps<Props>()
const triggerClass = computed(() =>
cn(
'flex h-11 w-full items-center justify-between rounded-2xl border border-border/60 bg-card/80 px-4 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary/60 disabled:cursor-not-allowed disabled:opacity-50 text-foreground cursor-pointer backdrop-blur transition-all',
props.class
)
)
</script>
<template>
<SelectTriggerPrimitive
v-bind="$attrs"
:class="triggerClass"
:disabled="disabled"
>
<slot />
<ChevronDown class="h-4 w-4 opacity-50 pointer-events-none" />
</SelectTriggerPrimitive>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import { SelectValue as SelectValuePrimitive } from 'radix-vue'
interface Props {
placeholder?: string
}
const props = defineProps<Props>()
</script>
<template>
<SelectValuePrimitive :placeholder="placeholder">
<slot />
</SelectValuePrimitive>
</template>

View File

@@ -0,0 +1,94 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { SelectRoot as SelectRootPrimitive } from 'radix-vue'
interface Props {
defaultValue?: string
modelValue?: string
open?: boolean
defaultOpen?: boolean
dir?: 'ltr' | 'rtl'
name?: string
autocomplete?: string
disabled?: boolean
required?: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: string]
'update:open': [value: boolean]
}>()
const internalValue = ref<string | undefined>(
props.modelValue ?? props.defaultValue
)
const isModelControlled = computed(() => props.modelValue !== undefined)
watch(
() => props.modelValue,
value => {
if (isModelControlled.value) {
internalValue.value = value
}
}
)
const modelValueState = computed({
get: () => (isModelControlled.value ? props.modelValue : internalValue.value),
set: (value: string | undefined) => {
if (!isModelControlled.value) {
internalValue.value = value
}
// Cast to string for the emit signature when value exists
if (value !== undefined) {
emit('update:modelValue', value)
}
}
})
const internalOpen = ref<boolean>(
props.open ?? props.defaultOpen ?? false
)
const isOpenControlled = computed(() => props.open !== undefined)
watch(
() => props.open,
value => {
if (isOpenControlled.value && value !== undefined) {
internalOpen.value = value
}
}
)
const openState = computed({
get: () => (isOpenControlled.value ? props.open : internalOpen.value),
set: (value: boolean) => {
if (!isOpenControlled.value) {
internalOpen.value = value
}
emit('update:open', value)
}
})
</script>
<template>
<SelectRootPrimitive
:default-value="defaultValue"
:model-value="modelValueState"
:open="openState"
:default-open="defaultOpen"
:dir="dir"
:name="name"
:autocomplete="autocomplete"
:disabled="disabled"
:required="required"
@update:model-value="modelValueState = $event"
@update:open="openState = $event"
>
<slot />
</SelectRootPrimitive>
</template>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import { Separator as SeparatorPrimitive } from 'radix-vue'
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
orientation?: 'horizontal' | 'vertical'
decorative?: boolean
}
const props = withDefaults(defineProps<Props>(), {
orientation: 'horizontal',
decorative: true,
})
const separatorClass = computed(() =>
cn(
'shrink-0 bg-border',
props.orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
props.class
)
)
</script>
<template>
<SeparatorPrimitive
:class="separatorClass"
:orientation="orientation"
:decorative="decorative"
/>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
}
const props = defineProps<Props>()
const skeletonClass = computed(() =>
cn('animate-pulse rounded-md bg-muted', props.class)
)
</script>
<template>
<div :class="skeletonClass" />
</template>

View File

@@ -0,0 +1,29 @@
<template>
<button
type="button"
role="switch"
:aria-checked="modelValue"
:class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
modelValue ? 'bg-primary' : 'bg-muted'
]"
@click="$emit('update:modelValue', !modelValue)"
>
<span
:class="[
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform',
modelValue ? 'translate-x-6' : 'translate-x-1'
]"
/>
</button>
</template>
<script setup lang="ts">
defineProps<{
modelValue: boolean
}>()
defineEmits<{
'update:modelValue': [value: boolean]
}>()
</script>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
}
const props = defineProps<Props>()
const bodyClass = computed(() =>
cn('[&_tr:last-child]:border-0', props.class)
)
</script>
<template>
<tbody :class="bodyClass">
<slot />
</tbody>
</template>

View File

@@ -0,0 +1,34 @@
<template>
<Card class="overflow-hidden">
<!-- 标题和操作栏 -->
<div v-if="$slots.header || title" class="px-6 py-3.5 border-b border-border/60">
<slot name="header">
<div class="flex items-center justify-between gap-4">
<!-- 左侧标题 -->
<h3 class="text-base font-semibold">{{ title }}</h3>
<!-- 右侧操作区 -->
<div v-if="$slots.actions" class="flex items-center gap-2">
<slot name="actions" />
</div>
</div>
</slot>
</div>
<!-- 表格内容 -->
<slot />
<!-- 分页 -->
<slot name="pagination" />
</Card>
</template>
<script setup lang="ts">
import { Card } from '@/components/ui'
interface Props {
title?: string
}
defineProps<Props>()
</script>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
}
const props = defineProps<Props>()
const cellClass = computed(() =>
cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', props.class)
)
</script>
<template>
<td :class="cellClass">
<slot />
</td>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
}
const props = defineProps<Props>()
const headClass = computed(() =>
cn(
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
props.class
)
)
</script>
<template>
<th :class="headClass">
<slot />
</th>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
}
const props = defineProps<Props>()
const headerClass = computed(() =>
cn('[&_tr]:border-b', props.class)
)
</script>
<template>
<thead :class="headerClass">
<slot />
</thead>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
}
const props = defineProps<Props>()
const rowClass = computed(() =>
cn(
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
props.class
)
)
</script>
<template>
<tr :class="rowClass">
<slot />
</tr>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
}
const props = defineProps<Props>()
const tableClass = computed(() =>
cn('w-full caption-bottom text-sm', props.class)
)
</script>
<template>
<div class="relative w-full overflow-auto">
<table :class="tableClass">
<slot />
</table>
</div>
</template>

View File

@@ -0,0 +1,31 @@
<template>
<div
v-show="isActive"
:class="contentClass"
>
<slot />
</div>
</template>
<script setup lang="ts">
import { computed, inject, type Ref } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
value: string
class?: string
}
const props = defineProps<Props>()
const activeTab = inject<Ref<string>>('activeTab')
const isActive = computed(() => activeTab?.value === props.value)
const contentClass = computed(() => {
return cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
props.class
)
})
</script>

View File

@@ -0,0 +1,205 @@
<template>
<div :class="listClass" ref="listRef">
<!-- 滑动指示器 - 放在按钮前面 -->
<div
class="tabs-indicator"
:style="indicatorStyle"
/>
<slot />
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch, onMounted, onUnmounted, nextTick, inject, type Ref } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: string
}
const props = defineProps<Props>()
const listRef = ref<HTMLElement | null>(null)
const indicatorStyle = ref<Record<string, string>>({
transform: 'translateX(0)',
width: '0px',
opacity: '0',
transition: 'none'
})
// 标记是否已完成首次定位(首次无动画)
const hasInitialized = ref(false)
// 记录当前激活的 tab 索引,用于计算相对位置
const activeIndex = ref(-1)
const activeTab = inject<Ref<string>>('activeTab')
// 检查是否有 grid 类(由外部传入)
const hasGridClass = computed(() => {
return props.class?.includes('grid')
})
const listClass = computed(() => {
return cn(
'tabs-list',
// 如果外部传入了 grid 类,就不使用默认的 inline-flex
!hasGridClass.value && 'inline-flex',
props.class
)
})
// 更新指示器位置
const updateIndicator = () => {
if (!listRef.value) return
const buttons = Array.from(
listRef.value.querySelectorAll<HTMLButtonElement>('button[data-value]')
)
// 确保所有 button 都已渲染且有 data-value
if (buttons.length === 0) return
const newIndex = buttons.findIndex(
(button) => button.dataset.value === activeTab?.value
)
if (newIndex === -1) {
indicatorStyle.value = {
transform: 'translateX(0)',
width: '0px',
opacity: '0',
transition: 'none'
}
return
}
const activeButton = buttons[newIndex]
const buttonRect = activeButton.getBoundingClientRect()
// 确保按钮已渲染
if (buttonRect.width === 0) return
// 计算相对位置:累加前面所有按钮的宽度
let offsetLeft = 0
for (let i = 0; i < newIndex; i++) {
offsetLeft += buttons[i].getBoundingClientRect().width
}
// 判断是否需要动画:
// 1. 首次初始化不需要动画
// 2. 索引变化(用户切换 tab需要动画
const isTabChange = hasInitialized.value && activeIndex.value !== newIndex && activeIndex.value !== -1
// 更新状态
activeIndex.value = newIndex
if (!hasInitialized.value) {
hasInitialized.value = true
}
indicatorStyle.value = {
transform: `translateX(${offsetLeft}px)`,
width: `${buttonRect.width}px`,
opacity: '1',
transition: isTabChange
? 'transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), width 0.2s cubic-bezier(0.4, 0, 0.2, 1)'
: 'none'
}
}
let rafId: number | null = null
const scheduleIndicatorUpdate = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId)
}
rafId = requestAnimationFrame(() => {
updateIndicator()
rafId = null
})
}
// 监听 activeTab 变化
watch(
() => activeTab?.value,
() => {
nextTick(() => {
scheduleIndicatorUpdate()
})
},
{ immediate: true, flush: 'post' }
)
// DOM 初始化/重新挂载时重新计算
watch(
() => listRef.value,
(el) => {
if (el) {
nextTick(() => {
scheduleIndicatorUpdate()
})
}
}
)
onMounted(() => {
// 重置状态
hasInitialized.value = false
activeIndex.value = -1
// 立即尝试更新
nextTick(() => {
scheduleIndicatorUpdate()
})
window.addEventListener('resize', scheduleIndicatorUpdate)
})
onUnmounted(() => {
if (rafId !== null) {
cancelAnimationFrame(rafId)
}
window.removeEventListener('resize', scheduleIndicatorUpdate)
})
</script>
<style scoped>
.tabs-list {
position: relative;
height: 2.5rem;
align-items: center;
justify-content: center;
border-radius: 0.5rem;
background-color: hsl(var(--muted) / 0.3);
padding: 0.25rem;
color: hsl(var(--muted-foreground));
border: 1px solid hsl(var(--border) / 0.6);
}
.tabs-indicator {
position: absolute;
z-index: 0;
top: 0.25rem;
bottom: 0.25rem;
left: 0;
border-radius: 0.375rem;
background: linear-gradient(
180deg,
hsl(var(--background)),
hsl(var(--background) / 0.95)
);
border: 1px solid hsl(var(--border));
box-shadow:
0 1px 3px 0 rgb(0 0 0 / 0.1),
0 1px 2px -1px rgb(0 0 0 / 0.1);
pointer-events: none;
}
@media (prefers-color-scheme: dark) {
.tabs-indicator {
background: linear-gradient(
180deg,
hsl(var(--accent)),
hsl(var(--accent) / 0.95)
);
border-color: hsl(var(--border) / 0.8);
}
}
</style>

View File

@@ -0,0 +1,42 @@
<template>
<button
:class="triggerClass"
:data-state="isActive ? 'active' : 'inactive'"
:data-value="props.value"
@click="handleClick"
type="button"
>
<slot />
</button>
</template>
<script setup lang="ts">
import { computed, inject, type Ref } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
value: string
class?: string
}
const props = defineProps<Props>()
const activeTab = inject<Ref<string>>('activeTab')
const setActiveTab = inject<(value: string) => void>('setActiveTab')
const isActive = computed(() => activeTab?.value === props.value)
const handleClick = () => {
setActiveTab?.(props.value)
}
const triggerClass = computed(() => {
return cn(
'relative z-10 inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
isActive.value
? 'text-foreground font-semibold'
: 'text-muted-foreground hover:text-foreground',
props.class
)
})
</script>

View File

@@ -0,0 +1,35 @@
<template>
<div class="tabs-root">
<slot />
</div>
</template>
<script setup lang="ts">
import { provide, ref, watch } from 'vue'
interface Props {
defaultValue?: string
modelValue?: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const activeTab = ref(props.modelValue || props.defaultValue || '')
watch(() => props.modelValue, (newValue) => {
if (newValue !== undefined) {
activeTab.value = newValue
}
})
const setActiveTab = (value: string) => {
activeTab.value = value
emit('update:modelValue', value)
}
provide('activeTab', activeTab)
provide('setActiveTab', setActiveTab)
</script>

View File

@@ -0,0 +1,35 @@
<template>
<textarea
:class="textareaClass"
:value="modelValue"
v-bind="$attrs"
@input="handleInput"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
modelValue?: string
class?: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const textareaClass = computed(() =>
cn(
'flex min-h-[80px] w-full rounded-2xl border border-border/60 bg-card/80 px-4 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:border-primary/60 text-foreground backdrop-blur transition-all resize-none',
props.class
)
)
function handleInput(event: Event) {
const target = event.target as HTMLTextAreaElement
emit('update:modelValue', target.value)
}
</script>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import { TooltipRoot } from 'radix-vue'
interface Props {
defaultOpen?: boolean
open?: boolean
delayDuration?: number
disableHoverableContent?: boolean
}
const props = withDefaults(defineProps<Props>(), {
defaultOpen: false,
delayDuration: 200,
disableHoverableContent: false
})
const emit = defineEmits<{
'update:open': [value: boolean]
}>()
</script>
<template>
<TooltipRoot
:default-open="props.defaultOpen"
:open="props.open"
:delay-duration="props.delayDuration"
:disable-hoverable-content="props.disableHoverableContent"
@update:open="emit('update:open', $event)"
>
<slot />
</TooltipRoot>
</template>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed } from 'vue'
import { TooltipContent as TooltipContentPrimitive, TooltipPortal } from 'radix-vue'
import { cn } from '@/lib/utils'
interface Props {
class?: string
side?: 'top' | 'right' | 'bottom' | 'left'
sideOffset?: number
align?: 'start' | 'center' | 'end'
alignOffset?: number
avoidCollisions?: boolean
}
const props = withDefaults(defineProps<Props>(), {
side: 'top',
sideOffset: 4,
align: 'center',
alignOffset: 0,
avoidCollisions: true
})
const contentClass = computed(() =>
cn(
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
props.class
)
)
</script>
<template>
<TooltipPortal>
<TooltipContentPrimitive
:class="contentClass"
:side="side"
:side-offset="sideOffset"
:align="align"
:align-offset="alignOffset"
:avoid-collisions="avoidCollisions"
>
<slot />
</TooltipContentPrimitive>
</TooltipPortal>
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import { TooltipProvider as TooltipProviderPrimitive } from 'radix-vue'
interface Props {
delayDuration?: number
skipDelayDuration?: number
disableHoverableContent?: boolean
}
withDefaults(defineProps<Props>(), {
delayDuration: 200,
skipDelayDuration: 300,
disableHoverableContent: false
})
</script>
<template>
<TooltipProviderPrimitive
:delay-duration="delayDuration"
:skip-delay-duration="skipDelayDuration"
:disable-hoverable-content="disableHoverableContent"
>
<slot />
</TooltipProviderPrimitive>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import { TooltipTrigger as TooltipTriggerPrimitive } from 'radix-vue'
interface Props {
asChild?: boolean
}
withDefaults(defineProps<Props>(), {
asChild: false
})
</script>
<template>
<TooltipTriggerPrimitive :as-child="asChild">
<slot />
</TooltipTriggerPrimitive>
</template>

View File

@@ -0,0 +1,4 @@
export { default as Tooltip } from './Tooltip.vue'
export { default as TooltipContent } from './TooltipContent.vue'
export { default as TooltipProvider } from './TooltipProvider.vue'
export { default as TooltipTrigger } from './TooltipTrigger.vue'