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,165 @@
<template>
<Dialog :modelValue="modelValue" @update:modelValue="handleClose" :zIndex="80">
<template #header>
<div class="border-b border-border px-6 py-4">
<div class="flex items-center gap-3">
<component :is="icon" class="h-5 w-5 flex-shrink-0" :class="iconColorClass" />
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-foreground leading-tight">{{ title }}</h3>
</div>
</div>
</div>
</template>
<template #default>
<!-- 描述 -->
<div class="space-y-3">
<p v-for="(line, index) in descriptionLines" :key="index" :class="getLineClass(index)">
{{ line }}
</p>
</div>
<!-- 自定义内容插槽 -->
<slot></slot>
</template>
<template #footer>
<!-- 取消按钮 -->
<Button
variant="outline"
@click="handleCancel"
:disabled="loading"
class="h-10 px-5"
>
{{ cancelText }}
</Button>
<!-- 确认按钮 -->
<Button
:variant="confirmVariant"
@click="handleConfirm"
:disabled="loading"
class="h-10 px-5"
>
<Loader2 v-if="loading" class="animate-spin h-4 w-4 mr-2" />
{{ confirmText }}
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Dialog } from '@/components/ui'
import Button from '@/components/ui/button.vue'
import { AlertTriangle, AlertCircle, Info, Trash2, HelpCircle, Loader2 } from 'lucide-vue-next'
export type AlertType = 'danger' | 'destructive' | 'warning' | 'info' | 'question'
interface Props {
modelValue: boolean
title: string
description: string
type?: AlertType
confirmText?: string
cancelText?: string
loading?: boolean
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'confirm'): void
(e: 'cancel'): void
}
const props = withDefaults(defineProps<Props>(), {
type: 'warning',
confirmText: '确认',
cancelText: '取消',
loading: false
})
const emit = defineEmits<Emits>()
// 解析描述文本为多行
const descriptionLines = computed(() => {
return props.description.split('\n').filter(line => line.trim())
})
// 根据行索引获取样式(中间行高亮)
function getLineClass(index: number): string {
const total = descriptionLines.value.length
if (total <= 1) {
return 'text-sm text-muted-foreground'
}
// 中间行(不是第一行也不是最后一行)使用高亮样式
if (index > 0 && index < total - 1) {
return 'text-sm font-mono font-medium text-foreground bg-muted/50 px-3 py-2 rounded-md'
}
return 'text-sm text-muted-foreground'
}
// 根据类型获取图标
const icon = computed(() => {
switch (props.type) {
case 'danger':
case 'destructive':
return Trash2
case 'warning':
return AlertTriangle
case 'info':
return Info
case 'question':
return HelpCircle
default:
return AlertCircle
}
})
// 根据类型获取图标颜色样式
const iconColorClass = computed(() => {
switch (props.type) {
case 'danger':
case 'destructive':
return 'text-rose-600 dark:text-rose-400'
case 'warning':
return 'text-amber-600 dark:text-amber-400'
case 'info':
return 'text-primary'
case 'question':
return 'text-gray-600 dark:text-muted-foreground'
default:
return 'text-primary'
}
})
// 根据类型获取确认按钮样式
const confirmVariant = computed(() => {
switch (props.type) {
case 'danger':
case 'destructive':
return 'destructive' as const
case 'warning':
case 'info':
case 'question':
default:
return 'default' as const
}
})
function handleConfirm() {
emit('confirm')
}
function handleCancel() {
emit('cancel')
emit('update:modelValue', false)
}
function handleClose(value: boolean) {
if (!value && !props.loading) {
emit('update:modelValue', value)
emit('cancel')
}
}
</script>

View File

@@ -0,0 +1,285 @@
<template>
<div :class="containerClasses">
<!-- 图标 -->
<div :class="iconContainerClasses">
<component
v-if="icon"
:is="icon"
:class="iconClasses"
/>
<component
v-else
:is="defaultIcon"
:class="iconClasses"
/>
</div>
<!-- 标题 -->
<h3 v-if="title" :class="titleClasses">
{{ title }}
</h3>
<!-- 描述 -->
<p v-if="description" :class="descriptionClasses">
{{ description }}
</p>
<!-- 自定义内容插槽 -->
<div v-if="$slots.default" class="mt-4">
<slot />
</div>
<!-- 操作按钮 -->
<div v-if="$slots.actions || actionText" class="mt-6 flex flex-wrap items-center justify-center gap-3">
<slot name="actions">
<Button
v-if="actionText"
@click="handleAction"
:variant="actionVariant"
:size="actionSize"
>
<component v-if="actionIcon" :is="actionIcon" class="mr-2 h-4 w-4" />
{{ actionText }}
</Button>
</slot>
</div>
<!-- 次要操作 -->
<div v-if="$slots.secondary" class="mt-3">
<slot name="secondary" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Button from '@/components/ui/button.vue'
import {
FileQuestion,
Search,
Inbox,
AlertCircle,
PackageOpen,
FolderOpen,
Database,
Filter
} from 'lucide-vue-next'
import type { Component } from 'vue'
type EmptyStateType = 'default' | 'search' | 'filter' | 'error' | 'empty' | 'notFound'
type ButtonVariant = 'default' | 'outline' | 'secondary' | 'ghost' | 'link' | 'destructive'
type ButtonSize = 'sm' | 'default' | 'lg' | 'icon'
interface Props {
/** 空状态类型 */
type?: EmptyStateType
/** 自定义图标组件 */
icon?: Component
/** 标题 */
title?: string
/** 描述文本 */
description?: string
/** 操作按钮文本 */
actionText?: string
/** 操作按钮图标 */
actionIcon?: Component
/** 操作按钮变体 */
actionVariant?: ButtonVariant
/** 操作按钮大小 */
actionSize?: ButtonSize
/** 大小 */
size?: 'sm' | 'md' | 'lg'
/** 对齐方式 */
align?: 'left' | 'center' | 'right'
}
interface Emits {
(e: 'action'): void
}
const props = withDefaults(defineProps<Props>(), {
type: 'default',
actionVariant: 'default',
actionSize: 'default',
size: 'md',
align: 'center'
})
const emit = defineEmits<Emits>()
// 根据类型获取默认配置
const typeConfig = computed(() => {
const configs = {
default: {
icon: Inbox,
title: '暂无数据',
description: '当前没有可显示的内容'
},
search: {
icon: Search,
title: '未找到结果',
description: '尝试使用不同的关键词搜索'
},
filter: {
icon: Filter,
title: '无匹配结果',
description: '没有符合当前筛选条件的数据'
},
error: {
icon: AlertCircle,
title: '加载失败',
description: '数据加载过程中出现错误'
},
empty: {
icon: PackageOpen,
title: '这里空空如也',
description: '还没有任何内容'
},
notFound: {
icon: FileQuestion,
title: '未找到',
description: '请求的资源不存在'
}
}
return configs[props.type]
})
// 默认图标
const defaultIcon = computed(() => typeConfig.value.icon)
// 容器样式
const containerClasses = computed(() => {
const classes = ['empty-state']
// 大小
if (props.size === 'sm') {
classes.push('empty-state-sm', 'py-6')
} else if (props.size === 'lg') {
classes.push('empty-state-lg', 'py-16')
} else {
classes.push('empty-state-md', 'py-12')
}
// 对齐
if (props.align === 'left') {
classes.push('text-left')
} else if (props.align === 'right') {
classes.push('text-right')
} else {
classes.push('text-center')
}
return classes.join(' ')
})
// 图标容器样式
const iconContainerClasses = computed(() => {
const classes = [
'empty-state-icon-container',
'rounded-full',
'inline-flex',
'items-center',
'justify-center',
'mb-4'
]
// 大小和颜色
if (props.type === 'error') {
classes.push('bg-red-100', 'dark:bg-red-900/30')
} else if (props.type === 'search' || props.type === 'filter') {
classes.push('bg-blue-100', 'dark:bg-blue-900/30')
} else {
classes.push('bg-muted')
}
// 尺寸
if (props.size === 'sm') {
classes.push('w-12', 'h-12')
} else if (props.size === 'lg') {
classes.push('w-20', 'h-20')
} else {
classes.push('w-16', 'h-16')
}
return classes.join(' ')
})
// 图标样式
const iconClasses = computed(() => {
const classes = []
// 颜色
if (props.type === 'error') {
classes.push('text-red-600', 'dark:text-red-400')
} else if (props.type === 'search' || props.type === 'filter') {
classes.push('text-blue-600', 'dark:text-blue-400')
} else {
classes.push('text-muted-foreground')
}
// 尺寸
if (props.size === 'sm') {
classes.push('w-6', 'h-6')
} else if (props.size === 'lg') {
classes.push('w-10', 'h-10')
} else {
classes.push('w-8', 'h-8')
}
return classes.join(' ')
})
// 标题样式
const titleClasses = computed(() => {
const classes = ['font-semibold', 'text-foreground', 'mb-2']
if (props.size === 'sm') {
classes.push('text-base')
} else if (props.size === 'lg') {
classes.push('text-2xl')
} else {
classes.push('text-lg')
}
return classes.join(' ')
})
// 描述样式
const descriptionClasses = computed(() => {
const classes = ['text-muted-foreground', 'max-w-md']
if (props.align === 'center') {
classes.push('mx-auto')
}
if (props.size === 'sm') {
classes.push('text-xs')
} else if (props.size === 'lg') {
classes.push('text-base')
} else {
classes.push('text-sm')
}
return classes.join(' ')
})
// 处理操作
function handleAction() {
emit('action')
}
</script>
<style scoped>
.empty-state {
@apply flex flex-col items-center justify-center;
}
.empty-state-icon-container {
@apply transition-transform duration-200;
}
.empty-state:hover .empty-state-icon-container {
@apply scale-105;
}
</style>

View File

@@ -0,0 +1,69 @@
<template>
<div :class="containerClasses">
<div class="flex flex-col items-center gap-4">
<Skeleton v-if="variant === 'skeleton'" :class="skeletonClasses" />
<div v-else-if="variant === 'spinner'" class="relative">
<div class="h-12 w-12 animate-spin rounded-full border-4 border-muted border-t-primary"></div>
</div>
<div v-else-if="variant === 'pulse'" class="flex gap-2">
<div
v-for="i in 3"
:key="i"
class="h-3 w-3 animate-pulse rounded-full bg-primary"
:style="{ animationDelay: `${i * 150}ms` }"
></div>
</div>
<div v-if="message" class="text-sm text-muted-foreground">
{{ message }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Skeleton from '@/components/ui/skeleton.vue'
interface Props {
variant?: 'skeleton' | 'spinner' | 'pulse'
message?: string
size?: 'sm' | 'md' | 'lg'
fullHeight?: boolean
}
const props = withDefaults(defineProps<Props>(), {
variant: 'spinner',
size: 'md',
fullHeight: false,
})
const containerClasses = computed(() => {
const classes = ['flex items-center justify-center']
if (props.fullHeight) {
classes.push('min-h-[400px]')
} else {
const sizeMap = {
sm: 'py-8',
md: 'py-12',
lg: 'py-16',
}
classes.push(sizeMap[props.size])
}
return classes.join(' ')
})
const skeletonClasses = computed(() => {
const sizeMap = {
sm: 'h-24 w-full',
md: 'h-48 w-full',
lg: 'h-64 w-full',
}
return sizeMap[props.size]
})
</script>

View File

@@ -0,0 +1,9 @@
/**
* Common Components
* 常用的自定义业务组件
*/
// 状态和反馈组件
export { default as EmptyState } from './EmptyState.vue'
export { default as AlertDialog } from './AlertDialog.vue'
export { default as LoadingState } from './LoadingState.vue'