mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
Initial commit
This commit is contained in:
165
frontend/src/components/common/AlertDialog.vue
Normal file
165
frontend/src/components/common/AlertDialog.vue
Normal 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>
|
||||
285
frontend/src/components/common/EmptyState.vue
Normal file
285
frontend/src/components/common/EmptyState.vue
Normal 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>
|
||||
69
frontend/src/components/common/LoadingState.vue
Normal file
69
frontend/src/components/common/LoadingState.vue
Normal 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>
|
||||
9
frontend/src/components/common/index.ts
Normal file
9
frontend/src/components/common/index.ts
Normal 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'
|
||||
Reference in New Issue
Block a user