mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-07 02:02:27 +08:00
Initial commit
This commit is contained in:
24
frontend/src/components/ui/avatar-fallback.vue
Normal file
24
frontend/src/components/ui/avatar-fallback.vue
Normal 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>
|
||||
23
frontend/src/components/ui/avatar-image.vue
Normal file
23
frontend/src/components/ui/avatar-image.vue
Normal 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>
|
||||
21
frontend/src/components/ui/avatar.vue
Normal file
21
frontend/src/components/ui/avatar.vue
Normal 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>
|
||||
50
frontend/src/components/ui/badge.vue
Normal file
50
frontend/src/components/ui/badge.vue
Normal 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>
|
||||
61
frontend/src/components/ui/button.vue
Normal file
61
frontend/src/components/ui/button.vue
Normal 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>
|
||||
44
frontend/src/components/ui/card.vue
Normal file
44
frontend/src/components/ui/card.vue
Normal 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>
|
||||
47
frontend/src/components/ui/checkbox.vue
Normal file
47
frontend/src/components/ui/checkbox.vue
Normal 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>
|
||||
139
frontend/src/components/ui/dialog/Dialog.vue
Normal file
139
frontend/src/components/ui/dialog/Dialog.vue
Normal 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>
|
||||
5
frontend/src/components/ui/dialog/DialogContent.vue
Normal file
5
frontend/src/components/ui/dialog/DialogContent.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="px-6 py-5 bg-background">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
7
frontend/src/components/ui/dialog/DialogDescription.vue
Normal file
7
frontend/src/components/ui/dialog/DialogDescription.vue
Normal 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>
|
||||
5
frontend/src/components/ui/dialog/DialogFooter.vue
Normal file
5
frontend/src/components/ui/dialog/DialogFooter.vue
Normal 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>
|
||||
5
frontend/src/components/ui/dialog/DialogHeader.vue
Normal file
5
frontend/src/components/ui/dialog/DialogHeader.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="border-b border-border px-6 py-4">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
5
frontend/src/components/ui/dialog/DialogTitle.vue
Normal file
5
frontend/src/components/ui/dialog/DialogTitle.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<h3 class="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<slot />
|
||||
</h3>
|
||||
</template>
|
||||
67
frontend/src/components/ui/index.ts
Normal file
67
frontend/src/components/ui/index.ts
Normal 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'
|
||||
39
frontend/src/components/ui/input.vue
Normal file
39
frontend/src/components/ui/input.vue
Normal 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>
|
||||
23
frontend/src/components/ui/label.vue
Normal file
23
frontend/src/components/ui/label.vue
Normal 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>
|
||||
172
frontend/src/components/ui/pagination.vue
Normal file
172
frontend/src/components/ui/pagination.vue
Normal 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>
|
||||
37
frontend/src/components/ui/refresh-button.vue
Normal file
37
frontend/src/components/ui/refresh-button.vue
Normal 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>
|
||||
52
frontend/src/components/ui/select-content.vue
Normal file
52
frontend/src/components/ui/select-content.vue
Normal 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>
|
||||
34
frontend/src/components/ui/select-item.vue
Normal file
34
frontend/src/components/ui/select-item.vue
Normal 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>
|
||||
31
frontend/src/components/ui/select-trigger.vue
Normal file
31
frontend/src/components/ui/select-trigger.vue
Normal 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>
|
||||
15
frontend/src/components/ui/select-value.vue
Normal file
15
frontend/src/components/ui/select-value.vue
Normal 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>
|
||||
94
frontend/src/components/ui/select.vue
Normal file
94
frontend/src/components/ui/select.vue
Normal 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>
|
||||
32
frontend/src/components/ui/separator.vue
Normal file
32
frontend/src/components/ui/separator.vue
Normal 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>
|
||||
18
frontend/src/components/ui/skeleton.vue
Normal file
18
frontend/src/components/ui/skeleton.vue
Normal 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>
|
||||
29
frontend/src/components/ui/switch.vue
Normal file
29
frontend/src/components/ui/switch.vue
Normal 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>
|
||||
20
frontend/src/components/ui/table-body.vue
Normal file
20
frontend/src/components/ui/table-body.vue
Normal 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>
|
||||
34
frontend/src/components/ui/table-card.vue
Normal file
34
frontend/src/components/ui/table-card.vue
Normal 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>
|
||||
20
frontend/src/components/ui/table-cell.vue
Normal file
20
frontend/src/components/ui/table-cell.vue
Normal 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>
|
||||
23
frontend/src/components/ui/table-head.vue
Normal file
23
frontend/src/components/ui/table-head.vue
Normal 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>
|
||||
20
frontend/src/components/ui/table-header.vue
Normal file
20
frontend/src/components/ui/table-header.vue
Normal 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>
|
||||
23
frontend/src/components/ui/table-row.vue
Normal file
23
frontend/src/components/ui/table-row.vue
Normal 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>
|
||||
22
frontend/src/components/ui/table.vue
Normal file
22
frontend/src/components/ui/table.vue
Normal 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>
|
||||
31
frontend/src/components/ui/tabs-content.vue
Normal file
31
frontend/src/components/ui/tabs-content.vue
Normal 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>
|
||||
205
frontend/src/components/ui/tabs-list.vue
Normal file
205
frontend/src/components/ui/tabs-list.vue
Normal 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>
|
||||
42
frontend/src/components/ui/tabs-trigger.vue
Normal file
42
frontend/src/components/ui/tabs-trigger.vue
Normal 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>
|
||||
35
frontend/src/components/ui/tabs.vue
Normal file
35
frontend/src/components/ui/tabs.vue
Normal 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>
|
||||
35
frontend/src/components/ui/textarea.vue
Normal file
35
frontend/src/components/ui/textarea.vue
Normal 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>
|
||||
32
frontend/src/components/ui/tooltip/Tooltip.vue
Normal file
32
frontend/src/components/ui/tooltip/Tooltip.vue
Normal 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>
|
||||
44
frontend/src/components/ui/tooltip/TooltipContent.vue
Normal file
44
frontend/src/components/ui/tooltip/TooltipContent.vue
Normal 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>
|
||||
25
frontend/src/components/ui/tooltip/TooltipProvider.vue
Normal file
25
frontend/src/components/ui/tooltip/TooltipProvider.vue
Normal 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>
|
||||
17
frontend/src/components/ui/tooltip/TooltipTrigger.vue
Normal file
17
frontend/src/components/ui/tooltip/TooltipTrigger.vue
Normal 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>
|
||||
4
frontend/src/components/ui/tooltip/index.ts
Normal file
4
frontend/src/components/ui/tooltip/index.ts
Normal 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'
|
||||
Reference in New Issue
Block a user