本章概述

本章将通过构建一个完整的现代化 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;
    }
}

项目总结

技术亮点

  1. UnoCSS 深度应用

    • 自定义预设和规则
    • 响应式设计系统
    • 主题切换机制
    • 性能优化策略
  2. 现代化架构

    • Vue 3 Composition API
    • TypeScript 类型安全
    • Pinia 状态管理
    • 组件化设计
  3. 用户体验优化

    • 流畅的动画效果
    • 响应式布局
    • 无障碍访问支持
    • 性能监控
  4. 开发体验

    • 热重载开发
    • 类型检查
    • 代码规范
    • 自动化测试

性能指标

  • 首屏加载时间: < 2s
  • CSS 文件大小: < 50KB
  • JavaScript 包大小: < 200KB
  • Lighthouse 评分: > 90

扩展建议

  1. 功能扩展

    • 实时协作
    • 文件上传
    • 通知系统
    • 数据导出
  2. 技术升级

    • PWA 支持
    • 离线功能
    • 推送通知
    • 微前端架构
  3. 性能优化

    • 虚拟滚动
    • 图片懒加载
    • 预加载策略
    • CDN 部署

练习题

基础练习

  1. 组件开发

    • 创建一个评分组件(星级评分)
    • 实现一个日期选择器组件
    • 开发一个文件上传组件
  2. 功能实现

    • 添加任务搜索功能
    • 实现任务拖拽排序
    • 创建任务模板系统

进阶练习

  1. 性能优化

    • 实现虚拟列表
    • 添加图片懒加载
    • 优化包体积大小
  2. 功能扩展

    • 添加实时协作功能
    • 实现数据可视化
    • 创建插件系统

挑战练习

  1. 架构设计

    • 设计微前端架构
    • 实现多租户系统
    • 创建设计系统
  2. 技术创新

    • 集成 AI 功能
    • 实现语音控制
    • 添加 AR/VR 支持

本章总结

通过本章的实战项目开发,我们:

  1. 综合应用了 UnoCSS 的各项特性,包括预设系统、自定义规则、响应式设计等

  2. 构建了完整的现代化 Web 应用,涵盖了组件设计、状态管理、路由配置等

  3. 实践了性能优化策略,包括代码分割、懒加载、图片优化等

  4. 建立了完整的开发流程,包括测试、部署、监控等

  5. 掌握了项目架构设计,为后续的大型项目开发奠定了基础

这个实战项目展示了 UnoCSS 在真实项目中的强大能力,为你的前端开发技能提升提供了宝贵的实践经验。