UI 组件
约 3327 字大约 11 分钟
2025-08-16
概述
Evoliant 前端采用基于 shadcn/ui 的组件系统,提供了完整的设计系统和可复用的 UI 组件。组件库支持主题定制、无障碍访问,并与 Tailwind CSS 深度集成。
组件架构
基础架构
components/
├── ui/ # 基础 UI 组件库
│ ├── button/ # 按钮组件
│ ├── input/ # 输入框组件
│ ├── card/ # 卡片组件
│ ├── dialog/ # 对话框组件
│ ├── dropdown-menu/ # 下拉菜单组件
│ ├── select/ # 选择器组件
│ └── ... # 其他基础组件
├── layout/ # 布局组件
│ ├── Navbar.vue # 导航栏
│ ├── Footer.vue # 页脚
│ └── ColorModeToggle.vue # 主题切换
├── KnowledgeGraph/ # 知识图谱组件
└── business/ # 业务组件(根据需要创建)设计原则
- 一致性: 统一的视觉语言和交互模式
- 可复用性: 组件高度模块化,易于复用
- 可访问性: 遵循 WCAG 标准,支持键盘导航和屏幕阅读器
- 响应式: 支持多种屏幕尺寸,移动优先设计
- 主题化: 支持明暗主题切换
基础组件
Button 组件
<!-- components/ui/button/Button.vue -->
<template>
<button :class="buttonClass" v-bind="$attrs">
<slot />
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'underline-offset-4 hover:underline text-primary'
},
size: {
default: 'h-10 py-2 px-4',
sm: 'h-9 px-3 rounded-md',
lg: 'h-11 px-8 rounded-md',
icon: 'h-10 w-10'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
)
interface ButtonProps extends VariantProps<typeof buttonVariants> {
class?: string
}
const props = withDefaults(defineProps<ButtonProps>(), {
variant: 'default',
size: 'default'
})
const buttonClass = computed(() => {
return cn(buttonVariants({ variant: props.variant, size: props.size }), props.class)
})
</script>使用示例:
<template>
<div class="space-x-2">
<Button>默认按钮</Button>
<Button variant="outline">边框按钮</Button>
<Button variant="destructive" size="sm">删除</Button>
<Button variant="ghost" size="icon">
<Search class="h-4 w-4" />
</Button>
</div>
</template>Input 组件
<!-- components/ui/input/Input.vue -->
<template>
<input
:class="cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
$attrs.class
)"
v-bind="$attrs"
/>
</template>
<script setup lang="ts">
import { cn } from '@/lib/utils'
</script>使用示例:
<template>
<div class="space-y-4">
<Input placeholder="请输入用户名" />
<Input type="password" placeholder="请输入密码" />
<Input type="email" placeholder="请输入邮箱" disabled />
</div>
</template>Card 组件
<!-- components/ui/card/Card.vue -->
<template>
<div :class="cn('rounded-lg border bg-card text-card-foreground shadow-sm', $attrs.class)">
<slot />
</div>
</template>
<script setup lang="ts">
import { cn } from '@/lib/utils'
</script><!-- components/ui/card/CardHeader.vue -->
<template>
<div :class="cn('flex flex-col space-y-1.5 p-6', $attrs.class)">
<slot />
</div>
</template>
<script setup lang="ts">
import { cn } from '@/lib/utils'
</script>完整卡片示例:
<template>
<Card class="w-[350px]">
<CardHeader>
<CardTitle>课程信息</CardTitle>
<CardDescription>查看课程详细信息和统计数据</CardDescription>
</CardHeader>
<CardContent>
<p>这里是课程的主要内容...</p>
</CardContent>
<CardFooter class="flex justify-between">
<Button variant="outline">取消</Button>
<Button>确认</Button>
</CardFooter>
</Card>
</template>Dialog 组件
<!-- components/ui/dialog/Dialog.vue -->
<template>
<DialogPrimitive.Root v-model:open="open">
<slot />
</DialogPrimitive.Root>
</template>
<script setup lang="ts">
import * as DialogPrimitive from 'radix-vue/dialog'
interface DialogProps {
open?: boolean
}
const props = defineProps<DialogProps>()
const emit = defineEmits<{
'update:open': [open: boolean]
}>()
const open = computed({
get: () => props.open,
set: (value) => emit('update:open', value)
})
</script>对话框使用示例:
<template>
<div>
<Button @click="showDialog = true">打开对话框</Button>
<Dialog v-model:open="showDialog">
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>编辑个人资料</DialogTitle>
<DialogDescription>
在这里更改您的个人资料信息。点击保存以确认更改。
</DialogDescription>
</DialogHeader>
<div class="grid gap-4 py-4">
<div class="grid grid-cols-4 items-center gap-4">
<Label for="name" class="text-right">姓名</Label>
<Input id="name" value="张三" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="email" class="text-right">邮箱</Label>
<Input id="email" value="zhangsan@example.com" class="col-span-3" />
</div>
</div>
<DialogFooter>
<Button type="submit">保存更改</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>
<script setup lang="ts">
const showDialog = ref(false)
</script>布局组件
Navbar 组件
<!-- components/layout/Navbar.vue -->
<template>
<nav class="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div class="container flex h-14 items-center">
<!-- Logo -->
<div class="mr-4 flex">
<NuxtLink to="/" class="mr-6 flex items-center space-x-2">
<LogoIcon class="h-6 w-6" />
<span class="hidden font-bold sm:inline-block">Evoliant</span>
</NuxtLink>
</div>
<!-- 主导航 -->
<div class="flex flex-1 items-center justify-between space-x-2 md:justify-end">
<div class="w-full flex-1 md:w-auto md:flex-none">
<!-- 搜索框 -->
<div class="relative">
<Search class="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索..."
class="pl-8 sm:w-[300px] md:w-[200px] lg:w-[300px]"
/>
</div>
</div>
<!-- 右侧菜单 -->
<nav class="flex items-center space-x-2">
<!-- 主题切换 -->
<ColorModeToggle />
<!-- 用户菜单 -->
<DropdownMenu v-if="isAuthenticated">
<DropdownMenuTrigger as-child>
<Button variant="ghost" class="relative h-8 w-8 rounded-full">
<Avatar class="h-8 w-8">
<AvatarImage :src="avatarUrl" :alt="displayName" />
<AvatarFallback>{{ displayName.charAt(0) }}</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-56" align="end">
<DropdownMenuLabel class="font-normal">
<div class="flex flex-col space-y-1">
<p class="text-sm font-medium leading-none">{{ displayName }}</p>
<p class="text-xs leading-none text-muted-foreground">{{ userEmail }}</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem @click="navigateTo('/profile')">
<User class="mr-2 h-4 w-4" />
个人资料
</DropdownMenuItem>
<DropdownMenuItem @click="navigateTo('/settings')">
<Settings class="mr-2 h-4 w-4" />
设置
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem @click="handleSignOut">
<LogOut class="mr-2 h-4 w-4" />
登出
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<!-- 未登录状态 -->
<div v-else class="flex items-center space-x-2">
<Button variant="ghost" @click="navigateTo('/login')">登录</Button>
<Button @click="navigateTo('/register')">注册</Button>
</div>
</nav>
</div>
</div>
</nav>
</template>
<script setup lang="ts">
import { Search, User, Settings, LogOut } from 'lucide-vue-next'
const { isAuthenticated, displayName, userEmail, avatarUrl, signOut } = useAuth()
const handleSignOut = async () => {
await signOut()
await navigateTo('/login')
}
</script>主题切换组件
<!-- components/layout/ColorModeToggle.vue -->
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon">
<Sun class="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon class="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span class="sr-only">切换主题</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="setColorMode('light')">
<Sun class="mr-2 h-4 w-4" />
浅色
</DropdownMenuItem>
<DropdownMenuItem @click="setColorMode('dark')">
<Moon class="mr-2 h-4 w-4" />
深色
</DropdownMenuItem>
<DropdownMenuItem @click="setColorMode('system')">
<Laptop class="mr-2 h-4 w-4" />
系统
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
<script setup lang="ts">
import { Sun, Moon, Laptop } from 'lucide-vue-next'
const colorMode = useColorMode()
const setColorMode = (mode: 'light' | 'dark' | 'system') => {
colorMode.preference = mode
}
</script>主题系统
Tailwind CSS 配置
// tailwind.config.js
module.exports = {
darkMode: 'class',
content: ['./app/**/*.{js,ts,jsx,tsx,vue}'],
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
}
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
}
}
},
plugins: [require('tailwindcss-animate')]
}CSS 变量定义
/* assets/css/main.css */
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96%;
--secondary-foreground: 222.2 84% 4.9%;
--muted: 210 40% 96%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96%;
--accent-foreground: 222.2 84% 4.9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 84% 4.9%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 94.1%;
}
}表单组件
Form 表单封装
<!-- components/ui/form/FormField.vue -->
<template>
<div class="space-y-2">
<Label v-if="label" :for="inputId" :class="labelClass">
{{ label }}
<span v-if="required" class="text-destructive">*</span>
</Label>
<div class="relative">
<slot :inputId="inputId" :hasError="hasError" />
<!-- 错误图标 -->
<AlertCircle v-if="hasError" class="absolute right-3 top-3 h-4 w-4 text-destructive" />
</div>
<!-- 帮助文本 -->
<p v-if="description && !hasError" :class="descriptionClass">
{{ description }}
</p>
<!-- 错误信息 -->
<p v-if="hasError" :class="errorClass">
{{ errorMessage }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { AlertCircle } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
interface FormFieldProps {
label?: string
description?: string
errorMessage?: string
required?: boolean
class?: string
}
const props = defineProps<FormFieldProps>()
const inputId = computed(() => `field-${Math.random().toString(36).substr(2, 9)}`)
const hasError = computed(() => !!props.errorMessage)
const labelClass = computed(() =>
cn('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70')
)
const descriptionClass = computed(() =>
cn('text-sm text-muted-foreground')
)
const errorClass = computed(() =>
cn('text-sm font-medium text-destructive')
)
</script>表单使用示例:
<template>
<form @submit.prevent="handleSubmit" class="space-y-6">
<FormField
label="用户名"
:error-message="errors.username"
description="用户名将用于登录系统"
required
>
<template #default="{ inputId, hasError }">
<Input
:id="inputId"
v-model="form.username"
:class="cn(hasError && 'border-destructive')"
placeholder="请输入用户名"
/>
</template>
</FormField>
<FormField
label="邮箱地址"
:error-message="errors.email"
required
>
<template #default="{ inputId, hasError }">
<Input
:id="inputId"
v-model="form.email"
type="email"
:class="cn(hasError && 'border-destructive')"
placeholder="请输入邮箱地址"
/>
</template>
</FormField>
<FormField
label="角色"
:error-message="errors.role"
required
>
<template #default="{ inputId }">
<Select v-model="form.role">
<SelectTrigger :id="inputId">
<SelectValue placeholder="请选择角色" />
</SelectTrigger>
<SelectContent>
<SelectItem value="student">学生</SelectItem>
<SelectItem value="teacher">教师</SelectItem>
<SelectItem value="admin">管理员</SelectItem>
</SelectContent>
</Select>
</template>
</FormField>
<Button type="submit" :disabled="loading" class="w-full">
<Loader2 v-if="loading" class="mr-2 h-4 w-4 animate-spin" />
{{ loading ? '提交中...' : '提交' }}
</Button>
</form>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { Loader2 } from 'lucide-vue-next'
const form = reactive({
username: '',
email: '',
role: ''
})
const errors = reactive({
username: '',
email: '',
role: ''
})
const loading = ref(false)
const validateForm = () => {
// 重置错误
Object.keys(errors).forEach(key => {
errors[key] = ''
})
let isValid = true
if (!form.username.trim()) {
errors.username = '用户名不能为空'
isValid = false
}
if (!form.email.trim()) {
errors.email = '邮箱不能为空'
isValid = false
} else if (!/\S+@\S+\.\S+/.test(form.email)) {
errors.email = '邮箱格式不正确'
isValid = false
}
if (!form.role) {
errors.role = '请选择角色'
isValid = false
}
return isValid
}
const handleSubmit = async () => {
if (!validateForm()) return
try {
loading.value = true
// 提交逻辑
await submitForm(form)
// 成功处理
} catch (error) {
// 错误处理
} finally {
loading.value = false
}
}
</script>数据展示组件
Table 组件
<!-- components/ui/table/Table.vue -->
<template>
<div class="relative w-full overflow-auto">
<table :class="cn('w-full caption-bottom text-sm', $attrs.class)">
<slot />
</table>
</div>
</template>
<script setup lang="ts">
import { cn } from '@/lib/utils'
</script>数据表格示例:
<template>
<div class="space-y-4">
<!-- 表格工具栏 -->
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold">用户列表</h2>
<div class="flex items-center space-x-2">
<Input placeholder="搜索用户..." class="w-64" />
<Button>添加用户</Button>
</div>
</div>
<!-- 数据表格 -->
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-[100px]">ID</TableHead>
<TableHead>姓名</TableHead>
<TableHead>邮箱</TableHead>
<TableHead>角色</TableHead>
<TableHead>状态</TableHead>
<TableHead class="text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="user in users" :key="user.id">
<TableCell class="font-medium">{{ user.id }}</TableCell>
<TableCell>{{ user.full_name }}</TableCell>
<TableCell>{{ user.email }}</TableCell>
<TableCell>
<Badge :variant="getRoleBadgeVariant(user.role)">
{{ getRoleLabel(user.role) }}
</Badge>
</TableCell>
<TableCell>
<Badge :variant="user.is_active ? 'default' : 'secondary'">
{{ user.is_active ? '活跃' : '未激活' }}
</Badge>
</TableCell>
<TableCell class="text-right">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon">
<MoreHorizontal class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="editUser(user)">
<Edit class="mr-2 h-4 w-4" />
编辑
</DropdownMenuItem>
<DropdownMenuItem @click="toggleUserStatus(user)">
<ToggleLeft class="mr-2 h-4 w-4" />
{{ user.is_active ? '禁用' : '启用' }}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem @click="deleteUser(user)" class="text-destructive">
<Trash class="mr-2 h-4 w-4" />
删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
</TableBody>
</Table>
</Card>
<!-- 分页 -->
<div class="flex items-center justify-between">
<p class="text-sm text-muted-foreground">
显示 {{ startIndex }} - {{ endIndex }} 项,共 {{ totalItems }} 项
</p>
<div class="flex items-center space-x-2">
<Button variant="outline" size="sm" :disabled="currentPage === 1">
上一页
</Button>
<Button variant="outline" size="sm" :disabled="currentPage === totalPages">
下一页
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { MoreHorizontal, Edit, ToggleLeft, Trash } from 'lucide-vue-next'
// 用户数据和分页逻辑
const users = ref([])
const currentPage = ref(1)
const pageSize = ref(10)
const totalItems = ref(0)
const totalPages = computed(() => Math.ceil(totalItems.value / pageSize.value))
const startIndex = computed(() => (currentPage.value - 1) * pageSize.value + 1)
const endIndex = computed(() => Math.min(currentPage.value * pageSize.value, totalItems.value))
const getRoleBadgeVariant = (role: string) => {
const variants = {
admin: 'destructive',
teacher: 'default',
student: 'secondary'
}
return variants[role] || 'secondary'
}
const getRoleLabel = (role: string) => {
const labels = {
admin: '管理员',
teacher: '教师',
student: '学生'
}
return labels[role] || role
}
const editUser = (user: any) => {
// 编辑用户逻辑
}
const toggleUserStatus = (user: any) => {
// 切换用户状态逻辑
}
const deleteUser = (user: any) => {
// 删除用户逻辑
}
</script>反馈组件
Toast 通知
<!-- 使用 vue-sonner -->
<script setup lang="ts">
import { toast } from 'vue-sonner'
const showSuccess = () => {
toast.success('操作成功!', {
description: '您的更改已成功保存。'
})
}
const showError = () => {
toast.error('操作失败!', {
description: '请检查您的网络连接并重试。'
})
}
const showInfo = () => {
toast.info('系统提示', {
description: '这是一条信息通知。'
})
}
const showWarning = () => {
toast.warning('警告信息', {
description: '请注意这个重要提示。'
})
}
const showPromise = () => {
const promise = new Promise((resolve) => {
setTimeout(() => resolve({ name: 'Sonner' }), 2000)
})
toast.promise(promise, {
loading: '正在处理...',
success: (data) => `${data.name} 处理完成!`,
error: '处理失败!'
})
}
</script>
<template>
<div class="space-x-2">
<Button @click="showSuccess">成功</Button>
<Button @click="showError" variant="destructive">错误</Button>
<Button @click="showInfo" variant="outline">信息</Button>
<Button @click="showWarning" variant="secondary">警告</Button>
<Button @click="showPromise" variant="outline">Promise</Button>
</div>
</template>最佳实践
1. 组件设计原则
- 单一职责: 每个组件只负责一个功能
- 组合优于继承: 使用插槽和组合模式
- 可预测性: 相同的输入应该产生相同的输出
- 可测试性: 组件应该易于单元测试
2. 样式约定
<template>
<!-- 使用语义化的类名 -->
<div class="user-card">
<div class="user-card__header">
<h3 class="user-card__title">{{ user.name }}</h3>
</div>
<div class="user-card__content">
<p class="user-card__description">{{ user.description }}</p>
</div>
</div>
</template>
<style scoped>
/* 使用 BEM 命名约定 */
.user-card {
@apply rounded-lg border bg-card text-card-foreground shadow-sm;
}
.user-card__header {
@apply flex flex-col space-y-1.5 p-6;
}
.user-card__title {
@apply text-lg font-semibold leading-none tracking-tight;
}
.user-card__content {
@apply p-6 pt-0;
}
.user-card__description {
@apply text-sm text-muted-foreground;
}
</style>3. 可访问性
<template>
<!-- 使用适当的 ARIA 属性 -->
<button
class="btn"
:aria-pressed="isPressed"
:aria-label="ariaLabel"
:disabled="disabled"
@click="handleClick"
@keydown.enter="handleClick"
@keydown.space.prevent="handleClick"
>
<slot />
</button>
</template>
<script setup lang="ts">
interface ButtonProps {
disabled?: boolean
ariaLabel?: string
isPressed?: boolean
}
const props = defineProps<ButtonProps>()
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
const handleClick = (event: MouseEvent | KeyboardEvent) => {
if (props.disabled) return
emit('click', event as MouseEvent)
}
</script>4. 性能优化
<script setup lang="ts">
// 使用 computed 缓存昂贵的计算
const expensiveValue = computed(() => {
return someExpensiveCalculation(props.data)
})
// 使用 watchEffect 优化副作用
watchEffect(() => {
if (props.visible) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
})
// 清理副作用
onUnmounted(() => {
document.body.style.overflow = ''
})
</script>5. 错误处理
<template>
<div>
<!-- 错误边界 -->
<ErrorBoundary @error="handleError">
<AsyncComponent :data="data" />
</ErrorBoundary>
</div>
</template>
<script setup lang="ts">
const handleError = (error: Error) => {
console.error('组件错误:', error)
toast.error('组件加载失败,请刷新页面重试')
}
</script>通过遵循这些设计原则和最佳实践,Evoliant 的组件系统能够提供一致、可靠、易于维护的用户界面,为用户提供优秀的交互体验。
版权所有
版权归属:Evoliant
许可证:MIT