本章概述
本章将通过构建一个完整的现代化 Web 应用来综合运用 UnoCSS 的各项特性。我们将开发一个任务管理应用,涵盖响应式设计、主题切换、组件系统、性能优化等方面。
项目规划
项目概述
我们将构建一个名为 “TaskFlow” 的任务管理应用,具备以下功能:
- 用户界面:现代化、响应式设计
- 主题系统:支持浅色/深色模式切换
- 任务管理:创建、编辑、删除、分类任务
- 数据可视化:任务统计图表
- 用户体验:流畅的动画和交互
技术栈
// 技术栈选择
const techStack = {
// 前端框架
framework: 'Vue 3',
// 构建工具
buildTool: 'Vite',
// CSS 框架
css: 'UnoCSS',
// 状态管理
stateManagement: 'Pinia',
// 路由
router: 'Vue Router',
// 图标
icons: 'Iconify',
// 图表
charts: 'Chart.js',
// 工具库
utils: ['VueUse', 'date-fns'],
// 开发工具
devTools: ['TypeScript', 'ESLint', 'Prettier'],
}
项目结构
taskflow/
├── public/
│ ├── favicon.ico
│ └── index.html
├── src/
│ ├── components/ # 组件
│ │ ├── ui/ # UI 基础组件
│ │ ├── layout/ # 布局组件
│ │ └── features/ # 功能组件
│ ├── views/ # 页面视图
│ ├── stores/ # 状态管理
│ ├── composables/ # 组合式函数
│ ├── utils/ # 工具函数
│ ├── types/ # 类型定义
│ ├── styles/ # 样式文件
│ ├── assets/ # 静态资源
│ ├── router/ # 路由配置
│ ├── App.vue # 根组件
│ └── main.ts # 入口文件
├── uno.config.ts # UnoCSS 配置
├── vite.config.ts # Vite 配置
├── package.json # 依赖配置
├── tsconfig.json # TypeScript 配置
└── README.md # 项目说明
环境搭建
项目初始化
# 创建项目
npm create vue@latest taskflow
cd taskflow
# 安装依赖
npm install
# 安装 UnoCSS
npm install -D unocss @unocss/preset-uno @unocss/preset-attributify @unocss/preset-icons
# 安装其他依赖
npm install pinia vue-router @vueuse/core date-fns
npm install -D @iconify-json/carbon @iconify-json/mdi
UnoCSS 配置
// uno.config.ts
import {
defineConfig,
presetUno,
presetAttributify,
presetIcons,
transformerDirectives,
transformerVariantGroup,
} from 'unocss'
export default defineConfig({
// 预设
presets: [
presetUno(),
presetAttributify(),
presetIcons({
collections: {
carbon: () => import('@iconify-json/carbon/icons.json').then(i => i.default),
mdi: () => import('@iconify-json/mdi/icons.json').then(i => i.default),
},
extraProperties: {
'display': 'inline-block',
'vertical-align': 'middle',
},
}),
],
// 转换器
transformers: [
transformerDirectives(),
transformerVariantGroup(),
],
// 主题配置
theme: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
gray: {
50: '#f9fafb',
100: '#f3f4f6',
200: '#e5e7eb',
300: '#d1d5db',
400: '#9ca3af',
500: '#6b7280',
600: '#4b5563',
700: '#374151',
800: '#1f2937',
900: '#111827',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
animation: {
'fade-in': 'fadeIn 0.3s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
'slide-down': 'slideDown 0.3s ease-out',
'scale-in': 'scaleIn 0.2s ease-out',
},
},
// 快捷方式
shortcuts: {
// 布局
'flex-center': 'flex items-center justify-center',
'flex-between': 'flex items-center justify-between',
'flex-col-center': 'flex flex-col items-center justify-center',
// 按钮
'btn-base': 'inline-flex items-center justify-center px-4 py-2 text-sm font-medium rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2',
'btn-primary': 'btn-base bg-primary-500 text-white hover:bg-primary-600 focus:ring-primary-500',
'btn-secondary': 'btn-base bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-gray-500 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600',
'btn-ghost': 'btn-base bg-transparent text-gray-600 hover:bg-gray-100 focus:ring-gray-500 dark:text-gray-400 dark:hover:bg-gray-800',
// 输入框
'input-base': 'block w-full px-3 py-2 border border-gray-300 rounded-lg text-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent dark:bg-gray-800 dark:border-gray-600 dark:text-white',
// 卡片
'card-base': 'bg-white rounded-xl shadow-sm border border-gray-200 dark:bg-gray-800 dark:border-gray-700',
'card-hover': 'card-base hover:shadow-md transition-shadow duration-200',
// 文本
'text-primary': 'text-gray-900 dark:text-white',
'text-secondary': 'text-gray-600 dark:text-gray-400',
'text-muted': 'text-gray-500 dark:text-gray-500',
},
// 自定义规则
rules: [
// 安全区域
['safe-top', { 'padding-top': 'env(safe-area-inset-top)' }],
['safe-bottom', { 'padding-bottom': 'env(safe-area-inset-bottom)' }],
// 滚动条样式
['scrollbar-thin', {
'scrollbar-width': 'thin',
'scrollbar-color': '#cbd5e1 transparent',
}],
['scrollbar-none', {
'scrollbar-width': 'none',
'-ms-overflow-style': 'none',
'&::-webkit-scrollbar': {
'display': 'none',
},
}],
],
// 预检样式
preflights: [
{
getCSS: () => `
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { transform: translateY(10px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes slideDown {
from { transform: translateY(-10px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes scaleIn {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
font-family: 'Inter', system-ui, sans-serif;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
`,
},
],
})
Vite 配置
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import UnoCSS from 'unocss/vite'
import { resolve } from 'path'
export default defineConfig({
plugins: [
vue(),
UnoCSS(),
],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
css: {
devSourcemap: true,
},
build: {
cssCodeSplit: true,
rollupOptions: {
output: {
manualChunks: {
vendor: ['vue', 'vue-router', 'pinia'],
utils: ['@vueuse/core', 'date-fns'],
},
},
},
},
server: {
port: 3000,
open: true,
},
})
核心组件开发
基础 UI 组件
1. 按钮组件
<!-- src/components/ui/Button.vue -->
<template>
<button
:class="buttonClass"
:disabled="disabled || loading"
@click="handleClick"
>
<div v-if="loading" class="i-carbon-loading animate-spin mr-2" />
<div v-else-if="icon" :class="icon" class="mr-2" />
<slot />
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
loading?: boolean
icon?: string
block?: boolean
}
interface Emits {
click: [event: MouseEvent]
}
const props = withDefaults(defineProps<Props>(), {
variant: 'primary',
size: 'md',
disabled: false,
loading: false,
block: false,
})
const emit = defineEmits<Emits>()
const buttonClass = computed(() => {
const baseClass = 'btn-base'
const variantClasses = {
primary: 'btn-primary',
secondary: 'btn-secondary',
ghost: 'btn-ghost',
danger: 'bg-red-500 text-white hover:bg-red-600 focus:ring-red-500',
}
const sizeClasses = {
sm: 'px-3 py-1.5 text-xs',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base',
}
const classes = [
baseClass,
variantClasses[props.variant],
sizeClasses[props.size],
]
if (props.block) {
classes.push('w-full')
}
if (props.disabled || props.loading) {
classes.push('opacity-50 cursor-not-allowed')
}
return classes.join(' ')
})
const handleClick = (event: MouseEvent) => {
if (!props.disabled && !props.loading) {
emit('click', event)
}
}
</script>
2. 输入框组件
<!-- src/components/ui/Input.vue -->
<template>
<div class="space-y-1">
<label v-if="label" :for="inputId" class="block text-sm font-medium text-primary">
{{ label }}
<span v-if="required" class="text-red-500 ml-1">*</span>
</label>
<div class="relative">
<div v-if="prefixIcon" class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<div :class="prefixIcon" class="text-gray-400" />
</div>
<input
:id="inputId"
v-model="inputValue"
:type="type"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:class="inputClass"
@focus="handleFocus"
@blur="handleBlur"
@input="handleInput"
>
<div v-if="suffixIcon" class="absolute inset-y-0 right-0 pr-3 flex items-center">
<div :class="suffixIcon" class="text-gray-400" />
</div>
</div>
<p v-if="error" class="text-sm text-red-500">{{ error }}</p>
<p v-else-if="hint" class="text-sm text-secondary">{{ hint }}</p>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { generateId } from '@/utils/helpers'
interface Props {
modelValue?: string | number
type?: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url'
label?: string
placeholder?: string
disabled?: boolean
readonly?: boolean
required?: boolean
error?: string
hint?: string
prefixIcon?: string
suffixIcon?: string
}
interface Emits {
'update:modelValue': [value: string | number]
focus: [event: FocusEvent]
blur: [event: FocusEvent]
}
const props = withDefaults(defineProps<Props>(), {
type: 'text',
disabled: false,
readonly: false,
required: false,
})
const emit = defineEmits<Emits>()
const inputId = ref(generateId('input'))
const isFocused = ref(false)
const inputValue = computed({
get: () => props.modelValue ?? '',
set: (value) => emit('update:modelValue', value),
})
const inputClass = computed(() => {
const baseClass = 'input-base'
const classes = [baseClass]
if (props.prefixIcon) {
classes.push('pl-10')
}
if (props.suffixIcon) {
classes.push('pr-10')
}
if (props.error) {
classes.push('border-red-500 focus:ring-red-500')
}
if (props.disabled) {
classes.push('bg-gray-100 cursor-not-allowed dark:bg-gray-700')
}
return classes.join(' ')
})
const handleFocus = (event: FocusEvent) => {
isFocused.value = true
emit('focus', event)
}
const handleBlur = (event: FocusEvent) => {
isFocused.value = false
emit('blur', event)
}
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement
emit('update:modelValue', target.value)
}
</script>
3. 模态框组件
<!-- src/components/ui/Modal.vue -->
<template>
<Teleport to="body">
<Transition
enter-active-class="transition-opacity duration-300"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-300"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="visible"
class="fixed inset-0 z-50 overflow-y-auto"
@click="handleBackdropClick"
>
<!-- 背景遮罩 -->
<div class="fixed inset-0 bg-black bg-opacity-50" />
<!-- 模态框容器 -->
<div class="flex min-h-full items-center justify-center p-4">
<Transition
enter-active-class="transition-all duration-300"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition-all duration-300"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="visible"
:class="modalClass"
@click.stop
>
<!-- 头部 -->
<div v-if="$slots.header || title" class="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<slot name="header">
<h3 class="text-lg font-semibold text-primary">{{ title }}</h3>
</slot>
<button
v-if="closable"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
@click="handleClose"
>
<div class="i-carbon-close w-5 h-5" />
</button>
</div>
<!-- 内容 -->
<div class="p-6">
<slot />
</div>
<!-- 底部 -->
<div v-if="$slots.footer" class="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-700">
<slot name="footer" />
</div>
</div>
</Transition>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
import { onKeyStroke } from '@vueuse/core'
interface Props {
visible: boolean
title?: string
size?: 'sm' | 'md' | 'lg' | 'xl'
closable?: boolean
closeOnEscape?: boolean
closeOnBackdrop?: boolean
}
interface Emits {
'update:visible': [visible: boolean]
close: []
}
const props = withDefaults(defineProps<Props>(), {
size: 'md',
closable: true,
closeOnEscape: true,
closeOnBackdrop: true,
})
const emit = defineEmits<Emits>()
const modalClass = computed(() => {
const baseClass = 'card-base relative w-full max-h-[90vh] overflow-hidden'
const sizeClasses = {
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
}
return `${baseClass} ${sizeClasses[props.size]}`
})
const handleClose = () => {
emit('update:visible', false)
emit('close')
}
const handleBackdropClick = () => {
if (props.closeOnBackdrop) {
handleClose()
}
}
// ESC 键关闭
watch(() => props.visible, (visible) => {
if (visible && props.closeOnEscape) {
onKeyStroke('Escape', handleClose)
}
})
// 防止背景滚动
watch(() => props.visible, (visible) => {
if (visible) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
})
</script>
布局组件
1. 应用布局
<!-- src/components/layout/AppLayout.vue -->
<template>
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
<!-- 侧边栏 -->
<AppSidebar
:collapsed="sidebarCollapsed"
@toggle="toggleSidebar"
/>
<!-- 主内容区 -->
<div :class="mainContentClass">
<!-- 顶部导航 -->
<AppHeader
@toggle-sidebar="toggleSidebar"
@toggle-theme="toggleTheme"
/>
<!-- 页面内容 -->
<main class="flex-1 p-6">
<router-view v-slot="{ Component }">
<Transition
name="page"
mode="out-in"
enter-active-class="animate-fade-in"
leave-active-class="animate-fade-out"
>
<component :is="Component" />
</Transition>
</router-view>
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { useSidebar } from '@/composables/useSidebar'
import AppSidebar from './AppSidebar.vue'
import AppHeader from './AppHeader.vue'
const { toggleTheme } = useTheme()
const { sidebarCollapsed, toggleSidebar } = useSidebar()
const mainContentClass = computed(() => {
const baseClass = 'flex flex-col min-h-screen transition-all duration-300'
const marginClass = sidebarCollapsed.value ? 'ml-16' : 'ml-64'
return `${baseClass} ${marginClass}`
})
</script>
<style>
.animate-fade-out {
animation: fadeOut 0.3s ease-in-out;
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
</style>
2. 侧边栏组件
<!-- src/components/layout/AppSidebar.vue -->
<template>
<aside :class="sidebarClass">
<!-- Logo -->
<div class="flex items-center px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="i-carbon-task w-8 h-8 text-primary-500" />
<Transition name="logo">
<span v-if="!collapsed" class="ml-3 text-xl font-bold text-primary">TaskFlow</span>
</Transition>
</div>
<!-- 导航菜单 -->
<nav class="flex-1 px-4 py-6 space-y-2">
<SidebarItem
v-for="item in menuItems"
:key="item.path"
:item="item"
:collapsed="collapsed"
/>
</nav>
<!-- 底部用户信息 -->
<div class="border-t border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center">
<img
src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=32&h=32&fit=crop&crop=face"
alt="User"
class="w-8 h-8 rounded-full"
>
<Transition name="user-info">
<div v-if="!collapsed" class="ml-3">
<p class="text-sm font-medium text-primary">John Doe</p>
<p class="text-xs text-secondary">john@example.com</p>
</div>
</Transition>
</div>
</div>
</aside>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import SidebarItem from './SidebarItem.vue'
interface Props {
collapsed: boolean
}
const props = defineProps<Props>()
const sidebarClass = computed(() => {
const baseClass = 'fixed left-0 top-0 h-full bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 transition-all duration-300 z-40 flex flex-col'
const widthClass = props.collapsed ? 'w-16' : 'w-64'
return `${baseClass} ${widthClass}`
})
const menuItems = [
{
path: '/dashboard',
name: 'Dashboard',
icon: 'i-carbon-dashboard',
},
{
path: '/tasks',
name: 'Tasks',
icon: 'i-carbon-task',
},
{
path: '/projects',
name: 'Projects',
icon: 'i-carbon-folder',
},
{
path: '/calendar',
name: 'Calendar',
icon: 'i-carbon-calendar',
},
{
path: '/analytics',
name: 'Analytics',
icon: 'i-carbon-analytics',
},
{
path: '/settings',
name: 'Settings',
icon: 'i-carbon-settings',
},
]
</script>
<style>
.logo-enter-active,
.logo-leave-active {
transition: all 0.3s ease;
}
.logo-enter-from,
.logo-leave-to {
opacity: 0;
transform: translateX(-10px);
}
.user-info-enter-active,
.user-info-leave-active {
transition: all 0.3s ease;
}
.user-info-enter-from,
.user-info-leave-to {
opacity: 0;
transform: translateX(-10px);
}
</style>
功能组件
1. 任务卡片组件
<!-- src/components/features/TaskCard.vue -->
<template>
<div :class="cardClass" @click="handleClick">
<!-- 任务头部 -->
<div class="flex items-start justify-between mb-3">
<div class="flex items-center space-x-2">
<button
class="w-5 h-5 rounded border-2 transition-colors"
:class="checkboxClass"
@click.stop="toggleComplete"
>
<div v-if="task.completed" class="i-carbon-checkmark w-3 h-3 text-white" />
</button>
<span :class="priorityClass" class="px-2 py-1 text-xs font-medium rounded-full">
{{ task.priority }}
</span>
</div>
<div class="flex items-center space-x-1">
<button
class="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
@click.stop="handleEdit"
>
<div class="i-carbon-edit w-4 h-4" />
</button>
<button
class="p-1 text-gray-400 hover:text-red-500 transition-colors"
@click.stop="handleDelete"
>
<div class="i-carbon-trash-can w-4 h-4" />
</button>
</div>
</div>
<!-- 任务标题 -->
<h3 :class="titleClass" class="font-medium mb-2">
{{ task.title }}
</h3>
<!-- 任务描述 -->
<p v-if="task.description" class="text-sm text-secondary mb-3 line-clamp-2">
{{ task.description }}
</p>
<!-- 任务标签 -->
<div v-if="task.tags?.length" class="flex flex-wrap gap-1 mb-3">
<span
v-for="tag in task.tags"
:key="tag"
class="px-2 py-1 text-xs bg-gray-100 text-gray-600 rounded dark:bg-gray-700 dark:text-gray-300"
>
{{ tag }}
</span>
</div>
<!-- 任务底部信息 -->
<div class="flex items-center justify-between text-xs text-secondary">
<div class="flex items-center space-x-2">
<div class="i-carbon-calendar w-4 h-4" />
<span>{{ formatDate(task.dueDate) }}</span>
</div>
<div v-if="task.assignee" class="flex items-center space-x-1">
<img
:src="task.assignee.avatar"
:alt="task.assignee.name"
class="w-5 h-5 rounded-full"
>
<span>{{ task.assignee.name }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { format } from 'date-fns'
import type { Task } from '@/types/task'
interface Props {
task: Task
}
interface Emits {
click: [task: Task]
edit: [task: Task]
delete: [task: Task]
'toggle-complete': [task: Task]
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const cardClass = computed(() => {
const baseClass = 'card-hover p-4 cursor-pointer transition-all duration-200'
const completedClass = props.task.completed ? 'opacity-75' : ''
return `${baseClass} ${completedClass}`
})
const checkboxClass = computed(() => {
if (props.task.completed) {
return 'bg-primary-500 border-primary-500'
}
return 'border-gray-300 hover:border-primary-500 dark:border-gray-600'
})
const titleClass = computed(() => {
const baseClass = 'text-primary'
const completedClass = props.task.completed ? 'line-through' : ''
return `${baseClass} ${completedClass}`
})
const priorityClass = computed(() => {
const classes = {
high: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
medium: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
low: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
}
return classes[props.task.priority] || classes.medium
})
const formatDate = (date: Date) => {
return format(date, 'MMM dd')
}
const handleClick = () => {
emit('click', props.task)
}
const handleEdit = () => {
emit('edit', props.task)
}
const handleDelete = () => {
emit('delete', props.task)
}
const toggleComplete = () => {
emit('toggle-complete', props.task)
}
</script>
<style>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
2. 任务表单组件
<!-- src/components/features/TaskForm.vue -->
<template>
<form @submit.prevent="handleSubmit">
<div class="space-y-4">
<!-- 任务标题 -->
<Input
v-model="form.title"
label="Task Title"
placeholder="Enter task title"
required
:error="errors.title"
/>
<!-- 任务描述 -->
<div>
<label class="block text-sm font-medium text-primary mb-1">
Description
</label>
<textarea
v-model="form.description"
rows="3"
class="input-base resize-none"
placeholder="Enter task description"
/>
</div>
<!-- 优先级和截止日期 -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-primary mb-1">
Priority
</label>
<select v-model="form.priority" class="input-base">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<Input
v-model="form.dueDate"
type="date"
label="Due Date"
:error="errors.dueDate"
/>
</div>
<!-- 标签 -->
<div>
<label class="block text-sm font-medium text-primary mb-1">
Tags
</label>
<div class="flex flex-wrap gap-2 mb-2">
<span
v-for="tag in form.tags"
:key="tag"
class="inline-flex items-center px-2 py-1 text-xs bg-primary-100 text-primary-800 rounded dark:bg-primary-900 dark:text-primary-200"
>
{{ tag }}
<button
type="button"
class="ml-1 text-primary-600 hover:text-primary-800"
@click="removeTag(tag)"
>
<div class="i-carbon-close w-3 h-3" />
</button>
</span>
</div>
<div class="flex space-x-2">
<input
v-model="newTag"
type="text"
class="input-base flex-1"
placeholder="Add a tag"
@keydown.enter.prevent="addTag"
>
<Button type="button" variant="secondary" @click="addTag">
Add
</Button>
</div>
</div>
<!-- 分配给 -->
<div>
<label class="block text-sm font-medium text-primary mb-1">
Assign to
</label>
<select v-model="form.assigneeId" class="input-base">
<option value="">Select assignee</option>
<option
v-for="user in users"
:key="user.id"
:value="user.id"
>
{{ user.name }}
</option>
</select>
</div>
</div>
<!-- 表单按钮 -->
<div class="flex justify-end space-x-3 mt-6">
<Button variant="secondary" @click="handleCancel">
Cancel
</Button>
<Button type="submit" :loading="loading">
{{ isEdit ? 'Update' : 'Create' }} Task
</Button>
</div>
</form>
</template>
<script setup lang="ts">
import { reactive, ref, computed } from 'vue'
import { useTaskStore } from '@/stores/task'
import { useUserStore } from '@/stores/user'
import type { Task, CreateTaskData } from '@/types/task'
import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue'
interface Props {
task?: Task
}
interface Emits {
success: []
cancel: []
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const taskStore = useTaskStore()
const userStore = useUserStore()
const loading = ref(false)
const newTag = ref('')
const errors = reactive({})
const isEdit = computed(() => !!props.task)
const users = computed(() => userStore.users)
const form = reactive<CreateTaskData>({
title: props.task?.title || '',
description: props.task?.description || '',
priority: props.task?.priority || 'medium',
dueDate: props.task?.dueDate ? format(props.task.dueDate, 'yyyy-MM-dd') : '',
tags: props.task?.tags || [],
assigneeId: props.task?.assignee?.id || '',
})
const addTag = () => {
const tag = newTag.value.trim()
if (tag && !form.tags.includes(tag)) {
form.tags.push(tag)
newTag.value = ''
}
}
const removeTag = (tag: string) => {
const index = form.tags.indexOf(tag)
if (index > -1) {
form.tags.splice(index, 1)
}
}
const validateForm = () => {
Object.keys(errors).forEach(key => delete errors[key])
if (!form.title.trim()) {
errors.title = 'Title is required'
}
if (!form.dueDate) {
errors.dueDate = 'Due date is required'
}
return Object.keys(errors).length === 0
}
const handleSubmit = async () => {
if (!validateForm()) return
loading.value = true
try {
if (isEdit.value) {
await taskStore.updateTask(props.task!.id, form)
} else {
await taskStore.createTask(form)
}
emit('success')
} catch (error) {
console.error('Failed to save task:', error)
} finally {
loading.value = false
}
}
const handleCancel = () => {
emit('cancel')
}
</script>
状态管理
任务状态管理
// src/stores/task.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Task, CreateTaskData, TaskFilter } from '@/types/task'
import { taskApi } from '@/api/task'
export const useTaskStore = defineStore('task', () => {
// 状态
const tasks = ref<Task[]>([])
const loading = ref(false)
const filter = ref<TaskFilter>({
status: 'all',
priority: 'all',
assignee: 'all',
search: '',
})
// 计算属性
const filteredTasks = computed(() => {
let result = tasks.value
// 状态过滤
if (filter.value.status !== 'all') {
result = result.filter(task => {
if (filter.value.status === 'completed') {
return task.completed
}
if (filter.value.status === 'pending') {
return !task.completed
}
return true
})
}
// 优先级过滤
if (filter.value.priority !== 'all') {
result = result.filter(task => task.priority === filter.value.priority)
}
// 分配人过滤
if (filter.value.assignee !== 'all') {
result = result.filter(task => task.assignee?.id === filter.value.assignee)
}
// 搜索过滤
if (filter.value.search) {
const search = filter.value.search.toLowerCase()
result = result.filter(task =>
task.title.toLowerCase().includes(search) ||
task.description?.toLowerCase().includes(search)
)
}
return result
})
const taskStats = computed(() => {
const total = tasks.value.length
const completed = tasks.value.filter(task => task.completed).length
const pending = total - completed
const overdue = tasks.value.filter(task =>
!task.completed && new Date(task.dueDate) < new Date()
).length
return {
total,
completed,
pending,
overdue,
completionRate: total > 0 ? Math.round((completed / total) * 100) : 0,
}
})
// 操作
const fetchTasks = async () => {
loading.value = true
try {
tasks.value = await taskApi.getTasks()
} catch (error) {
console.error('Failed to fetch tasks:', error)
} finally {
loading.value = false
}
}
const createTask = async (data: CreateTaskData) => {
const task = await taskApi.createTask(data)
tasks.value.push(task)
return task
}
const updateTask = async (id: string, data: Partial<CreateTaskData>) => {
const updatedTask = await taskApi.updateTask(id, data)
const index = tasks.value.findIndex(task => task.id === id)
if (index > -1) {
tasks.value[index] = updatedTask
}
return updatedTask
}
const deleteTask = async (id: string) => {
await taskApi.deleteTask(id)
const index = tasks.value.findIndex(task => task.id === id)
if (index > -1) {
tasks.value.splice(index, 1)
}
}
const toggleTaskComplete = async (id: string) => {
const task = tasks.value.find(task => task.id === id)
if (task) {
await updateTask(id, { completed: !task.completed })
}
}
const updateFilter = (newFilter: Partial<TaskFilter>) => {
filter.value = { ...filter.value, ...newFilter }
}
const clearFilter = () => {
filter.value = {
status: 'all',
priority: 'all',
assignee: 'all',
search: '',
}
}
return {
// 状态
tasks,
loading,
filter,
// 计算属性
filteredTasks,
taskStats,
// 操作
fetchTasks,
createTask,
updateTask,
deleteTask,
toggleTaskComplete,
updateFilter,
clearFilter,
}
})
主题状态管理
// src/composables/useTheme.ts
import { ref, watch } from 'vue'
import { useLocalStorage } from '@vueuse/core'
type Theme = 'light' | 'dark' | 'system'
const theme = useLocalStorage<Theme>('theme', 'system')
const isDark = ref(false)
export function useTheme() {
const updateTheme = () => {
if (theme.value === 'system') {
isDark.value = window.matchMedia('(prefers-color-scheme: dark)').matches
} else {
isDark.value = theme.value === 'dark'
}
// 更新 HTML 类名
if (isDark.value) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}
const setTheme = (newTheme: Theme) => {
theme.value = newTheme
updateTheme()
}
const toggleTheme = () => {
if (theme.value === 'light') {
setTheme('dark')
} else if (theme.value === 'dark') {
setTheme('system')
} else {
setTheme('light')
}
}
// 监听系统主题变化
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQuery.addEventListener('change', updateTheme)
// 监听主题变化
watch(theme, updateTheme, { immediate: true })
return {
theme,
isDark,
setTheme,
toggleTheme,
}
}
页面开发
仪表板页面
<!-- src/views/Dashboard.vue -->
<template>
<div class="space-y-6">
<!-- 页面标题 -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-primary">Dashboard</h1>
<Button @click="showCreateModal = true">
<div class="i-carbon-add mr-2" />
New Task
</Button>
</div>
<!-- 统计卡片 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatsCard
title="Total Tasks"
:value="taskStats.total"
icon="i-carbon-task"
color="blue"
/>
<StatsCard
title="Completed"
:value="taskStats.completed"
icon="i-carbon-checkmark"
color="green"
/>
<StatsCard
title="Pending"
:value="taskStats.pending"
icon="i-carbon-time"
color="yellow"
/>
<StatsCard
title="Overdue"
:value="taskStats.overdue"
icon="i-carbon-warning"
color="red"
/>
</div>
<!-- 图表区域 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 任务完成趋势 -->
<div class="card-base p-6">
<h3 class="text-lg font-semibold text-primary mb-4">Task Completion Trend</h3>
<TaskTrendChart :data="trendData" />
</div>
<!-- 优先级分布 -->
<div class="card-base p-6">
<h3 class="text-lg font-semibold text-primary mb-4">Priority Distribution</h3>
<PriorityChart :data="priorityData" />
</div>
</div>
<!-- 最近任务 -->
<div class="card-base p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-primary">Recent Tasks</h3>
<router-link
to="/tasks"
class="text-primary-500 hover:text-primary-600 text-sm font-medium"
>
View All
</router-link>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<TaskCard
v-for="task in recentTasks"
:key="task.id"
:task="task"
@edit="handleEditTask"
@delete="handleDeleteTask"
@toggle-complete="handleToggleComplete"
/>
</div>
</div>
<!-- 创建任务模态框 -->
<Modal
v-model:visible="showCreateModal"
title="Create New Task"
size="lg"
>
<TaskForm
@success="handleTaskCreated"
@cancel="showCreateModal = false"
/>
</Modal>
<!-- 编辑任务模态框 -->
<Modal
v-model:visible="showEditModal"
title="Edit Task"
size="lg"
>
<TaskForm
:task="selectedTask"
@success="handleTaskUpdated"
@cancel="showEditModal = false"
/>
</Modal>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useTaskStore } from '@/stores/task'
import type { Task } from '@/types/task'
import Button from '@/components/ui/Button.vue'
import Modal from '@/components/ui/Modal.vue'
import TaskCard from '@/components/features/TaskCard.vue'
import TaskForm from '@/components/features/TaskForm.vue'
import StatsCard from '@/components/features/StatsCard.vue'
import TaskTrendChart from '@/components/features/TaskTrendChart.vue'
import PriorityChart from '@/components/features/PriorityChart.vue'
const taskStore = useTaskStore()
const showCreateModal = ref(false)
const showEditModal = ref(false)
const selectedTask = ref<Task | null>(null)
const taskStats = computed(() => taskStore.taskStats)
const recentTasks = computed(() =>
taskStore.tasks
.slice()
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 6)
)
const trendData = computed(() => {
// 生成最近7天的任务完成趋势数据
const days = 7
const data = []
const today = new Date()
for (let i = days - 1; i >= 0; i--) {
const date = new Date(today)
date.setDate(date.getDate() - i)
const completed = taskStore.tasks.filter(task => {
const completedDate = new Date(task.completedAt || '')
return task.completed &&
completedDate.toDateString() === date.toDateString()
}).length
data.push({
date: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
completed,
})
}
return data
})
const priorityData = computed(() => {
const priorities = { high: 0, medium: 0, low: 0 }
taskStore.tasks.forEach(task => {
priorities[task.priority]++
})
return [
{ label: 'High', value: priorities.high, color: '#ef4444' },
{ label: 'Medium', value: priorities.medium, color: '#f59e0b' },
{ label: 'Low', value: priorities.low, color: '#10b981' },
]
})
const handleEditTask = (task: Task) => {
selectedTask.value = task
showEditModal.value = true
}
const handleDeleteTask = async (task: Task) => {
if (confirm('Are you sure you want to delete this task?')) {
await taskStore.deleteTask(task.id)
}
}
const handleToggleComplete = async (task: Task) => {
await taskStore.toggleTaskComplete(task.id)
}
const handleTaskCreated = () => {
showCreateModal.value = false
}
const handleTaskUpdated = () => {
showEditModal.value = false
selectedTask.value = null
}
onMounted(() => {
taskStore.fetchTasks()
})
</script>
性能优化
构建优化
// vite.config.ts 优化配置
export default defineConfig({
plugins: [
vue(),
UnoCSS(),
],
build: {
// 代码分割
rollupOptions: {
output: {
manualChunks: {
// 框架代码
vue: ['vue'],
router: ['vue-router'],
pinia: ['pinia'],
// 工具库
utils: ['@vueuse/core', 'date-fns'],
// UI 组件
ui: [
'./src/components/ui/Button.vue',
'./src/components/ui/Input.vue',
'./src/components/ui/Modal.vue',
],
// 图表库
charts: ['chart.js'],
},
},
},
// 压缩配置
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true,
},
},
// 资源内联阈值
assetsInlineLimit: 4096,
},
// CSS 优化
css: {
postcss: {
plugins: [
// 自动添加浏览器前缀
require('autoprefixer'),
// 生产环境压缩
process.env.NODE_ENV === 'production' && require('cssnano')({
preset: 'default',
}),
].filter(Boolean),
},
},
})
组件懒加载
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
// 懒加载页面组件
const Dashboard = () => import('@/views/Dashboard.vue')
const Tasks = () => import('@/views/Tasks.vue')
const Projects = () => import('@/views/Projects.vue')
const Calendar = () => import('@/views/Calendar.vue')
const Analytics = () => import('@/views/Analytics.vue')
const Settings = () => import('@/views/Settings.vue')
const routes = [
{
path: '/',
redirect: '/dashboard',
},
{
path: '/dashboard',
name: 'Dashboard',
component: Dashboard,
},
{
path: '/tasks',
name: 'Tasks',
component: Tasks,
},
{
path: '/projects',
name: 'Projects',
component: Projects,
},
{
path: '/calendar',
name: 'Calendar',
component: Calendar,
},
{
path: '/analytics',
name: 'Analytics',
component: Analytics,
},
{
path: '/settings',
name: 'Settings',
component: Settings,
},
]
export const router = createRouter({
history: createWebHistory(),
routes,
})
图片优化
// src/utils/imageOptimization.ts
export const optimizeImage = (file: File, maxWidth = 800, quality = 0.8): Promise<Blob> => {
return new Promise((resolve) => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')!
const img = new Image()
img.onload = () => {
// 计算新尺寸
const ratio = Math.min(maxWidth / img.width, maxWidth / img.height)
canvas.width = img.width * ratio
canvas.height = img.height * ratio
// 绘制压缩后的图片
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
// 转换为 Blob
canvas.toBlob(resolve, 'image/jpeg', quality)
}
img.src = URL.createObjectURL(file)
})
}
测试策略
单元测试
// tests/components/Button.test.ts
import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import Button from '@/components/ui/Button.vue'
describe('Button', () => {
it('renders correctly', () => {
const wrapper = mount(Button, {
slots: {
default: 'Click me',
},
})
expect(wrapper.text()).toBe('Click me')
expect(wrapper.classes()).toContain('btn-base')
expect(wrapper.classes()).toContain('btn-primary')
})
it('emits click event', async () => {
const wrapper = mount(Button)
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toHaveLength(1)
})
it('shows loading state', () => {
const wrapper = mount(Button, {
props: {
loading: true,
},
})
expect(wrapper.find('.i-carbon-loading').exists()).toBe(true)
expect(wrapper.classes()).toContain('cursor-not-allowed')
})
})
集成测试
// tests/stores/task.test.ts
import { setActivePinia, createPinia } from 'pinia'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useTaskStore } from '@/stores/task'
import { taskApi } from '@/api/task'
// Mock API
vi.mock('@/api/task')
describe('Task Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('fetches tasks correctly', async () => {
const mockTasks = [
{ id: '1', title: 'Test Task', completed: false },
]
vi.mocked(taskApi.getTasks).mockResolvedValue(mockTasks)
const store = useTaskStore()
await store.fetchTasks()
expect(store.tasks).toEqual(mockTasks)
expect(store.loading).toBe(false)
})
it('creates task correctly', async () => {
const newTask = { id: '2', title: 'New Task', completed: false }
vi.mocked(taskApi.createTask).mockResolvedValue(newTask)
const store = useTaskStore()
await store.createTask({ title: 'New Task' })
expect(store.tasks).toContain(newTask)
})
})
部署配置
Docker 配置
# Dockerfile
FROM node:18-alpine as build
WORKDIR /app
# 复制依赖文件
COPY package*.json ./
RUN npm ci --only=production
# 复制源代码
COPY . .
# 构建应用
RUN npm run build
# 生产环境
FROM nginx:alpine
# 复制构建结果
COPY --from=build /app/dist /usr/share/nginx/html
# 复制 Nginx 配置
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Nginx 配置
# nginx.conf
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Gzip 压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# 静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA 路由支持
location / {
try_files $uri $uri/ /index.html;
}
# 安全头
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
}
}
项目总结
技术亮点
UnoCSS 深度应用
- 自定义预设和规则
- 响应式设计系统
- 主题切换机制
- 性能优化策略
现代化架构
- Vue 3 Composition API
- TypeScript 类型安全
- Pinia 状态管理
- 组件化设计
用户体验优化
- 流畅的动画效果
- 响应式布局
- 无障碍访问支持
- 性能监控
开发体验
- 热重载开发
- 类型检查
- 代码规范
- 自动化测试
性能指标
- 首屏加载时间: < 2s
- CSS 文件大小: < 50KB
- JavaScript 包大小: < 200KB
- Lighthouse 评分: > 90
扩展建议
功能扩展
- 实时协作
- 文件上传
- 通知系统
- 数据导出
技术升级
- PWA 支持
- 离线功能
- 推送通知
- 微前端架构
性能优化
- 虚拟滚动
- 图片懒加载
- 预加载策略
- CDN 部署
练习题
基础练习
组件开发
- 创建一个评分组件(星级评分)
- 实现一个日期选择器组件
- 开发一个文件上传组件
功能实现
- 添加任务搜索功能
- 实现任务拖拽排序
- 创建任务模板系统
进阶练习
性能优化
- 实现虚拟列表
- 添加图片懒加载
- 优化包体积大小
功能扩展
- 添加实时协作功能
- 实现数据可视化
- 创建插件系统
挑战练习
架构设计
- 设计微前端架构
- 实现多租户系统
- 创建设计系统
技术创新
- 集成 AI 功能
- 实现语音控制
- 添加 AR/VR 支持
本章总结
通过本章的实战项目开发,我们:
综合应用了 UnoCSS 的各项特性,包括预设系统、自定义规则、响应式设计等
构建了完整的现代化 Web 应用,涵盖了组件设计、状态管理、路由配置等
实践了性能优化策略,包括代码分割、懒加载、图片优化等
建立了完整的开发流程,包括测试、部署、监控等
掌握了项目架构设计,为后续的大型项目开发奠定了基础
这个实战项目展示了 UnoCSS 在真实项目中的强大能力,为你的前端开发技能提升提供了宝贵的实践经验。