HiHuo
首页
博客
手册
工具
首页
博客
手册
工具
  • Vue & CSS 进阶

    • 第0章:工具链与开发体验
    • 第1章:TypeScript 核心
    • 第2章:CSS 现代能力
    • 第3章:Vue3 核心与原理
    • 第4章:路由与状态管理
    • 第5章:工程化与性能
    • 第6章:测试与质量
    • 第7章:CSS 进阶专题
    • 第8章:项目实战A - SaaS仪表盘
    • 第9章:项目实战B - 内容社区
    • 第10章:Vue 内核深入
    • 第11章:微前端与部署
    • CSS 变量体系与主题系统深度指南
    • 前端安全与防护专题
    • CSS 专题:动效优化与微交互
    • CSS 专题:图片与图形处理
    • 实时通信与WebSocket专题
    • 开发工具与调试技巧专题
    • 新兴技术与未来趋势专题
    • 移动端开发与PWA专题
    • 附录A:代码片段库
    • 附录B:练习题集

第8章:项目实战A - SaaS仪表盘

从零到一构建一个完整的 SaaS 仪表盘应用,掌握企业级前端开发技能

📚 项目概览

项目目标

构建一个功能完整的企业级 SaaS 仪表盘,包含用户管理、数据分析、系统设置等核心功能。

技术栈

  • 前端框架:Vue 3 + TypeScript
  • 状态管理:Pinia
  • 路由管理:Vue Router
  • 构建工具:Vite
  • UI 组件库:Element Plus
  • 图表库:ECharts
  • 样式方案:SCSS + CSS Variables
  • 测试框架:Vitest + Playwright

项目结构

saas-dashboard/
├── src/
│   ├── components/          # 通用组件
│   ├── views/              # 页面组件
│   ├── stores/             # 状态管理
│   ├── router/             # 路由配置
│   ├── utils/              # 工具函数
│   ├── api/                # API 接口
│   ├── types/              # TypeScript 类型
│   └── styles/             # 样式文件
├── tests/                  # 测试文件
├── public/                 # 静态资源
└── docs/                   # 项目文档

🚀 8.1 项目初始化

8.1.1 创建项目

# 创建 Vue3 + TypeScript 项目
pnpm create vue@latest saas-dashboard
cd saas-dashboard

# 安装依赖
pnpm install

# 安装额外依赖
pnpm add element-plus @element-plus/icons-vue
pnpm add echarts vue-echarts
pnpm add pinia
pnpm add axios
pnpm add dayjs
pnpm add lodash-es
pnpm add @types/lodash-es

# 开发依赖
pnpm add -D sass
pnpm add -D @types/node
pnpm add -D unplugin-auto-import
pnpm add -D unplugin-vue-components

8.1.2 项目配置

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import { resolve } from 'path'

export default defineConfig({
  plugins: [
    vue(),
    AutoImport({
      resolvers: [ElementPlusResolver()],
      imports: ['vue', 'vue-router', 'pinia']
    }),
    Components({
      resolvers: [ElementPlusResolver()]
    })
  ],
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src')
    }
  },
  server: {
    port: 3000,
    open: true
  }
})

8.1.3 环境配置

// src/config/env.ts
interface EnvConfig {
  apiBaseUrl: string
  appTitle: string
  version: string
}

const config: EnvConfig = {
  apiBaseUrl: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api',
  appTitle: import.meta.env.VITE_APP_TITLE || 'SaaS Dashboard',
  version: import.meta.env.VITE_APP_VERSION || '1.0.0'
}

export default config
# .env.development
VITE_API_BASE_URL=http://localhost:8080/api
VITE_APP_TITLE=SaaS Dashboard
VITE_APP_VERSION=1.0.0

# .env.production
VITE_API_BASE_URL=https://api.example.com
VITE_APP_TITLE=SaaS Dashboard
VITE_APP_VERSION=1.0.0

🎨 8.2 设计系统与主题

8.2.1 设计令牌

// src/styles/tokens.scss
:root {
  // 颜色系统
  --color-primary: #409eff;
  --color-success: #67c23a;
  --color-warning: #e6a23c;
  --color-danger: #f56c6c;
  --color-info: #909399;
  
  // 中性色
  --color-text-primary: #303133;
  --color-text-regular: #606266;
  --color-text-secondary: #909399;
  --color-text-placeholder: #c0c4cc;
  
  // 背景色
  --color-bg-primary: #ffffff;
  --color-bg-secondary: #f5f7fa;
  --color-bg-tertiary: #fafafa;
  
  // 边框色
  --color-border-light: #ebeef5;
  --color-border-base: #dcdfe6;
  --color-border-dark: #d4d7de;
  
  // 间距系统
  --spacing-xs: 4px;
  --spacing-sm: 8px;
  --spacing-md: 16px;
  --spacing-lg: 24px;
  --spacing-xl: 32px;
  
  // 圆角系统
  --border-radius-sm: 2px;
  --border-radius-base: 4px;
  --border-radius-lg: 8px;
  --border-radius-xl: 12px;
  
  // 阴影系统
  --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.12);
  --shadow-base: 0 2px 4px rgba(0, 0, 0, 0.12);
  --shadow-lg: 0 4px 8px rgba(0, 0, 0, 0.12);
  --shadow-xl: 0 8px 16px rgba(0, 0, 0, 0.12);
  
  // 字体系统
  --font-size-xs: 12px;
  --font-size-sm: 14px;
  --font-size-base: 16px;
  --font-size-lg: 18px;
  --font-size-xl: 20px;
  --font-size-2xl: 24px;
  
  // 行高
  --line-height-tight: 1.25;
  --line-height-base: 1.5;
  --line-height-relaxed: 1.75;
  
  // 字重
  --font-weight-light: 300;
  --font-weight-normal: 400;
  --font-weight-medium: 500;
  --font-weight-semibold: 600;
  --font-weight-bold: 700;
}

// 深色主题
[data-theme="dark"] {
  --color-text-primary: #e5eaf3;
  --color-text-regular: #cfd3dc;
  --color-text-secondary: #a3a6ad;
  --color-text-placeholder: #8d9095;
  
  --color-bg-primary: #141414;
  --color-bg-secondary: #1f1f1f;
  --color-bg-tertiary: #262727;
  
  --color-border-light: #414243;
  --color-border-base: #4c4d4f;
  --color-border-dark: #58595b;
}

8.2.2 主题切换组件

<!-- src/components/ThemeToggle.vue -->
<template>
  <el-dropdown @command="handleCommand">
    <el-button type="text" :icon="themeIcon">
      {{ themeText }}
    </el-button>
    <template #dropdown>
      <el-dropdown-menu>
        <el-dropdown-item command="light">
          <el-icon><Sunny /></el-icon>
          浅色主题
        </el-dropdown-item>
        <el-dropdown-item command="dark">
          <el-icon><Moon /></el-icon>
          深色主题
        </el-dropdown-item>
        <el-dropdown-item command="auto">
          <el-icon><Monitor /></el-icon>
          跟随系统
        </el-dropdown-item>
      </el-dropdown-menu>
    </template>
  </el-dropdown>
</template>

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Sunny, Moon, Monitor } from '@element-plus/icons-vue'

type Theme = 'light' | 'dark' | 'auto'

const theme = ref<Theme>('light')

const themeIcon = computed(() => {
  switch (theme.value) {
    case 'light': return Sunny
    case 'dark': return Moon
    case 'auto': return Monitor
    default: return Sunny
  }
})

const themeText = computed(() => {
  switch (theme.value) {
    case 'light': return '浅色'
    case 'dark': return '深色'
    case 'auto': return '自动'
    default: return '浅色'
  }
})

const handleCommand = (command: Theme) => {
  theme.value = command
  applyTheme(command)
  localStorage.setItem('theme', command)
}

const applyTheme = (newTheme: Theme) => {
  const root = document.documentElement
  
  if (newTheme === 'auto') {
    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
    root.setAttribute('data-theme', prefersDark ? 'dark' : 'light')
  } else {
    root.setAttribute('data-theme', newTheme)
  }
}

onMounted(() => {
  const savedTheme = localStorage.getItem('theme') as Theme || 'light'
  theme.value = savedTheme
  applyTheme(savedTheme)
  
  // 监听系统主题变化
  const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
  mediaQuery.addEventListener('change', () => {
    if (theme.value === 'auto') {
      applyTheme('auto')
    }
  })
})
</script>

🏗️ 8.3 项目架构搭建

8.3.1 路由配置

// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'

const routes: RouteRecordRaw[] = [
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/Login.vue'),
    meta: { requiresAuth: false }
  },
  {
    path: '/',
    component: () => import('@/layout/DefaultLayout.vue'),
    meta: { requiresAuth: true },
    children: [
      {
        path: '',
        name: 'Dashboard',
        component: () => import('@/views/Dashboard.vue'),
        meta: { title: '仪表盘', icon: 'Dashboard' }
      },
      {
        path: '/users',
        name: 'Users',
        component: () => import('@/views/Users.vue'),
        meta: { title: '用户管理', icon: 'User' }
      },
      {
        path: '/analytics',
        name: 'Analytics',
        component: () => import('@/views/Analytics.vue'),
        meta: { title: '数据分析', icon: 'TrendCharts' }
      },
      {
        path: '/settings',
        name: 'Settings',
        component: () => import('@/views/Settings.vue'),
        meta: { title: '系统设置', icon: 'Setting' }
      }
    ]
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

// 路由守卫
router.beforeEach((to, from, next) => {
  const token = localStorage.getItem('token')
  
  if (to.meta.requiresAuth && !token) {
    next('/login')
  } else if (to.path === '/login' && token) {
    next('/')
  } else {
    next()
  }
})

export default router

8.3.2 状态管理

// src/stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { User, LoginForm } from '@/types/user'
import { loginApi, getUserInfoApi } from '@/api/user'

export const useUserStore = defineStore('user', () => {
  const token = ref<string>(localStorage.getItem('token') || '')
  const userInfo = ref<User | null>(null)
  const permissions = ref<string[]>([])

  const isLoggedIn = computed(() => !!token.value)
  const isAdmin = computed(() => permissions.value.includes('admin'))

  const login = async (loginForm: LoginForm) => {
    try {
      const response = await loginApi(loginForm)
      token.value = response.token
      localStorage.setItem('token', response.token)
      
      await getUserInfo()
      return response
    } catch (error) {
      throw error
    }
  }

  const logout = () => {
    token.value = ''
    userInfo.value = null
    permissions.value = []
    localStorage.removeItem('token')
  }

  const getUserInfo = async () => {
    try {
      const response = await getUserInfoApi()
      userInfo.value = response.user
      permissions.value = response.permissions
      return response
    } catch (error) {
      logout()
      throw error
    }
  }

  return {
    token,
    userInfo,
    permissions,
    isLoggedIn,
    isAdmin,
    login,
    logout,
    getUserInfo
  }
})
// src/stores/app.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'

export const useAppStore = defineStore('app', () => {
  const sidebarCollapsed = ref(false)
  const theme = ref<'light' | 'dark' | 'auto'>('light')
  const loading = ref(false)

  const toggleSidebar = () => {
    sidebarCollapsed.value = !sidebarCollapsed.value
  }

  const setTheme = (newTheme: 'light' | 'dark' | 'auto') => {
    theme.value = newTheme
  }

  const setLoading = (value: boolean) => {
    loading.value = value
  }

  return {
    sidebarCollapsed,
    theme,
    loading,
    toggleSidebar,
    setTheme,
    setLoading
  }
})

8.3.3 API 封装

// src/api/request.ts
import axios from 'axios'
import { ElMessage } from 'element-plus'
import type { AxiosRequestConfig, AxiosResponse } from 'axios'

const request = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 10000
})

// 请求拦截器
request.interceptors.request.use(
  (config) => {
    const token = localStorage.getItem('token')
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  },
  (error) => {
    return Promise.reject(error)
  }
)

// 响应拦截器
request.interceptors.response.use(
  (response: AxiosResponse) => {
    const { code, message, data } = response.data
    
    if (code === 200) {
      return data
    } else {
      ElMessage.error(message || '请求失败')
      return Promise.reject(new Error(message || '请求失败'))
    }
  },
  (error) => {
    if (error.response?.status === 401) {
      localStorage.removeItem('token')
      window.location.href = '/login'
    }
    
    ElMessage.error(error.message || '网络错误')
    return Promise.reject(error)
  }
)

export default request
// src/api/user.ts
import request from './request'
import type { User, LoginForm, LoginResponse } from '@/types/user'

export const loginApi = (data: LoginForm): Promise<LoginResponse> => {
  return request.post('/auth/login', data)
}

export const getUserInfoApi = (): Promise<{ user: User; permissions: string[] }> => {
  return request.get('/user/info')
}

export const updateUserApi = (data: Partial<User>): Promise<User> => {
  return request.put('/user/update', data)
}

🎨 8.4 核心页面开发

8.4.1 登录页面

<!-- src/views/Login.vue -->
<template>
  <div class="login-container">
    <div class="login-form">
      <div class="login-header">
        <h1>SaaS Dashboard</h1>
        <p>欢迎登录管理系统</p>
      </div>
      
      <el-form
        ref="loginFormRef"
        :model="loginForm"
        :rules="loginRules"
        @submit.prevent="handleLogin"
      >
        <el-form-item prop="username">
          <el-input
            v-model="loginForm.username"
            placeholder="请输入用户名"
            :prefix-icon="User"
            size="large"
          />
        </el-form-item>
        
        <el-form-item prop="password">
          <el-input
            v-model="loginForm.password"
            type="password"
            placeholder="请输入密码"
            :prefix-icon="Lock"
            size="large"
            show-password
          />
        </el-form-item>
        
        <el-form-item>
          <el-checkbox v-model="loginForm.remember">
            记住我
          </el-checkbox>
        </el-form-item>
        
        <el-form-item>
          <el-button
            type="primary"
            size="large"
            :loading="loading"
            @click="handleLogin"
            style="width: 100%"
          >
            登录
          </el-button>
        </el-form-item>
      </el-form>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, Lock } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
import type { LoginForm } from '@/types/user'

const router = useRouter()
const userStore = useUserStore()

const loginFormRef = ref()
const loading = ref(false)

const loginForm = reactive<LoginForm>({
  username: '',
  password: '',
  remember: false
})

const loginRules = {
  username: [
    { required: true, message: '请输入用户名', trigger: 'blur' }
  ],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    { min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
  ]
}

const handleLogin = async () => {
  if (!loginFormRef.value) return
  
  await loginFormRef.value.validate(async (valid: boolean) => {
    if (valid) {
      loading.value = true
      try {
        await userStore.login(loginForm)
        ElMessage.success('登录成功')
        router.push('/')
      } catch (error) {
        ElMessage.error('登录失败')
      } finally {
        loading.value = false
      }
    }
  })
}
</script>

<style scoped>
.login-container {
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}

.login-form {
  width: 400px;
  padding: 40px;
  background: white;
  border-radius: 8px;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}

.login-header {
  text-align: center;
  margin-bottom: 30px;
}

.login-header h1 {
  color: #303133;
  margin-bottom: 10px;
}

.login-header p {
  color: #909399;
  margin: 0;
}
</style>

8.4.2 仪表盘页面

<!-- src/views/Dashboard.vue -->
<template>
  <div class="dashboard">
    <div class="dashboard-header">
      <h1>仪表盘</h1>
      <div class="dashboard-actions">
        <el-date-picker
          v-model="dateRange"
          type="daterange"
          range-separator="至"
          start-placeholder="开始日期"
          end-placeholder="结束日期"
          @change="handleDateChange"
        />
      </div>
    </div>

    <!-- 统计卡片 -->
    <div class="stats-grid">
      <StatCard
        v-for="stat in stats"
        :key="stat.title"
        :title="stat.title"
        :value="stat.value"
        :change="stat.change"
        :icon="stat.icon"
        :color="stat.color"
      />
    </div>

    <!-- 图表区域 -->
    <div class="charts-grid">
      <div class="chart-card">
        <h3>用户增长趋势</h3>
        <div ref="userTrendChart" class="chart-container"></div>
      </div>
      
      <div class="chart-card">
        <h3>收入分析</h3>
        <div ref="revenueChart" class="chart-container"></div>
      </div>
    </div>

    <!-- 数据表格 -->
    <div class="table-card">
      <h3>最近活动</h3>
      <el-table :data="recentActivities" stripe>
        <el-table-column prop="user" label="用户" />
        <el-table-column prop="action" label="操作" />
        <el-table-column prop="time" label="时间" />
        <el-table-column prop="status" label="状态">
          <template #default="{ row }">
            <el-tag :type="getStatusType(row.status)">
              {{ row.status }}
            </el-tag>
          </template>
        </el-table-column>
      </el-table>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import * as echarts from 'echarts'
import StatCard from '@/components/StatCard.vue'
import { getDashboardData } from '@/api/dashboard'

const dateRange = ref<[Date, Date]>([
  new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
  new Date()
])

const stats = ref([
  {
    title: '总用户数',
    value: '12,345',
    change: '+12.5%',
    icon: 'User',
    color: '#409eff'
  },
  {
    title: '总收入',
    value: '¥123,456',
    change: '+8.2%',
    icon: 'Money',
    color: '#67c23a'
  },
  {
    title: '订单数',
    value: '1,234',
    change: '+15.3%',
    icon: 'ShoppingCart',
    color: '#e6a23c'
  },
  {
    title: '转化率',
    value: '3.2%',
    change: '-2.1%',
    icon: 'TrendCharts',
    color: '#f56c6c'
  }
])

const recentActivities = ref([
  { user: '张三', action: '登录系统', time: '2024-01-15 10:30', status: '成功' },
  { user: '李四', action: '创建订单', time: '2024-01-15 10:25', status: '成功' },
  { user: '王五', action: '更新资料', time: '2024-01-15 10:20', status: '成功' }
])

const userTrendChart = ref<HTMLElement>()
const revenueChart = ref<HTMLElement>()

const handleDateChange = async () => {
  await loadDashboardData()
}

const loadDashboardData = async () => {
  try {
    const data = await getDashboardData({
      startDate: dateRange.value[0],
      endDate: dateRange.value[1]
    })
    
    // 更新统计数据
    stats.value = data.stats
    
    // 更新图表
    await nextTick()
    initCharts(data.charts)
  } catch (error) {
    console.error('加载仪表盘数据失败:', error)
  }
}

const initCharts = (chartData: any) => {
  // 用户增长趋势图
  const userTrendInstance = echarts.init(userTrendChart.value)
  userTrendInstance.setOption({
    title: { text: '用户增长趋势' },
    tooltip: { trigger: 'axis' },
    xAxis: {
      type: 'category',
      data: chartData.userTrend.labels
    },
    yAxis: { type: 'value' },
    series: [{
      data: chartData.userTrend.data,
      type: 'line',
      smooth: true,
      areaStyle: {}
    }]
  })

  // 收入分析图
  const revenueInstance = echarts.init(revenueChart.value)
  revenueInstance.setOption({
    title: { text: '收入分析' },
    tooltip: { trigger: 'item' },
    series: [{
      type: 'pie',
      data: chartData.revenue.data,
      radius: '50%'
    }]
  })
}

const getStatusType = (status: string) => {
  const statusMap: Record<string, string> = {
    '成功': 'success',
    '失败': 'danger',
    '进行中': 'warning'
  }
  return statusMap[status] || 'info'
}

onMounted(() => {
  loadDashboardData()
})
</script>

<style scoped>
.dashboard {
  padding: 24px;
}

.dashboard-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 24px;
}

.stats-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
  gap: 24px;
  margin-bottom: 24px;
}

.charts-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 24px;
  margin-bottom: 24px;
}

.chart-card {
  background: white;
  padding: 24px;
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.chart-container {
  height: 300px;
  margin-top: 16px;
}

.table-card {
  background: white;
  padding: 24px;
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
</style>

8.4.3 用户管理页面

<!-- src/views/Users.vue -->
<template>
  <div class="users">
    <div class="users-header">
      <h1>用户管理</h1>
      <el-button type="primary" @click="handleAdd">
        <el-icon><Plus /></el-icon>
        添加用户
      </el-button>
    </div>

    <!-- 搜索区域 -->
    <div class="search-section">
      <el-form :model="searchForm" inline>
        <el-form-item label="用户名">
          <el-input
            v-model="searchForm.username"
            placeholder="请输入用户名"
            clearable
          />
        </el-form-item>
        <el-form-item label="状态">
          <el-select v-model="searchForm.status" placeholder="请选择状态" clearable>
            <el-option label="全部" value="" />
            <el-option label="启用" value="active" />
            <el-option label="禁用" value="inactive" />
          </el-select>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="handleSearch">搜索</el-button>
          <el-button @click="handleReset">重置</el-button>
        </el-form-item>
      </el-form>
    </div>

    <!-- 用户表格 -->
    <div class="table-card">
      <el-table
        v-loading="loading"
        :data="users"
        stripe
        @selection-change="handleSelectionChange"
      >
        <el-table-column type="selection" width="55" />
        <el-table-column prop="id" label="ID" width="80" />
        <el-table-column prop="username" label="用户名" />
        <el-table-column prop="email" label="邮箱" />
        <el-table-column prop="role" label="角色">
          <template #default="{ row }">
            <el-tag :type="getRoleType(row.role)">
              {{ row.role }}
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="status" label="状态">
          <template #default="{ row }">
            <el-switch
              v-model="row.status"
              :active-value="'active'"
              :inactive-value="'inactive'"
              @change="handleStatusChange(row)"
            />
          </template>
        </el-table-column>
        <el-table-column prop="createdAt" label="创建时间" />
        <el-table-column label="操作" width="200">
          <template #default="{ row }">
            <el-button size="small" @click="handleEdit(row)">编辑</el-button>
            <el-button size="small" type="danger" @click="handleDelete(row)">
              删除
            </el-button>
          </template>
        </el-table-column>
      </el-table>

      <!-- 分页 -->
      <div class="pagination">
        <el-pagination
          v-model:current-page="pagination.currentPage"
          v-model:page-size="pagination.pageSize"
          :total="pagination.total"
          :page-sizes="[10, 20, 50, 100]"
          layout="total, sizes, prev, pager, next, jumper"
          @size-change="handleSizeChange"
          @current-change="handleCurrentChange"
        />
      </div>
    </div>

    <!-- 用户表单对话框 -->
    <UserForm
      v-model:visible="formVisible"
      :user="currentUser"
      @success="handleFormSuccess"
    />
  </div>
</template>

<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import UserForm from '@/components/UserForm.vue'
import { getUsersApi, deleteUserApi, updateUserStatusApi } from '@/api/user'
import type { User, SearchForm } from '@/types/user'

const loading = ref(false)
const users = ref<User[]>([])
const selectedUsers = ref<User[]>([])
const formVisible = ref(false)
const currentUser = ref<User | null>(null)

const searchForm = reactive<SearchForm>({
  username: '',
  status: ''
})

const pagination = reactive({
  currentPage: 1,
  pageSize: 10,
  total: 0
})

const loadUsers = async () => {
  loading.value = true
  try {
    const response = await getUsersApi({
      ...searchForm,
      page: pagination.currentPage,
      size: pagination.pageSize
    })
    
    users.value = response.data
    pagination.total = response.total
  } catch (error) {
    ElMessage.error('加载用户列表失败')
  } finally {
    loading.value = false
  }
}

const handleSearch = () => {
  pagination.currentPage = 1
  loadUsers()
}

const handleReset = () => {
  Object.assign(searchForm, {
    username: '',
    status: ''
  })
  handleSearch()
}

const handleAdd = () => {
  currentUser.value = null
  formVisible.value = true
}

const handleEdit = (user: User) => {
  currentUser.value = user
  formVisible.value = true
}

const handleDelete = async (user: User) => {
  try {
    await ElMessageBox.confirm('确定要删除该用户吗?', '提示', {
      type: 'warning'
    })
    
    await deleteUserApi(user.id)
    ElMessage.success('删除成功')
    loadUsers()
  } catch (error) {
    if (error !== 'cancel') {
      ElMessage.error('删除失败')
    }
  }
}

const handleStatusChange = async (user: User) => {
  try {
    await updateUserStatusApi(user.id, user.status)
    ElMessage.success('状态更新成功')
  } catch (error) {
    ElMessage.error('状态更新失败')
    // 恢复原状态
    user.status = user.status === 'active' ? 'inactive' : 'active'
  }
}

const handleSelectionChange = (selection: User[]) => {
  selectedUsers.value = selection
}

const handleSizeChange = (size: number) => {
  pagination.pageSize = size
  loadUsers()
}

const handleCurrentChange = (page: number) => {
  pagination.currentPage = page
  loadUsers()
}

const handleFormSuccess = () => {
  formVisible.value = false
  loadUsers()
}

const getRoleType = (role: string) => {
  const roleMap: Record<string, string> = {
    'admin': 'danger',
    'user': 'primary',
    'guest': 'info'
  }
  return roleMap[role] || 'info'
}

onMounted(() => {
  loadUsers()
})
</script>

<style scoped>
.users {
  padding: 24px;
}

.users-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 24px;
}

.search-section {
  background: white;
  padding: 24px;
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  margin-bottom: 24px;
}

.table-card {
  background: white;
  padding: 24px;
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.pagination {
  margin-top: 24px;
  text-align: right;
}
</style>

🧩 8.5 组件开发

8.5.1 统计卡片组件

<!-- src/components/StatCard.vue -->
<template>
  <div class="stat-card" :class="[`stat-card--${color}`]">
    <div class="stat-card__icon">
      <el-icon :size="24">
        <component :is="icon" />
      </el-icon>
    </div>
    <div class="stat-card__content">
      <div class="stat-card__value">{{ value }}</div>
      <div class="stat-card__title">{{ title }}</div>
      <div class="stat-card__change" :class="[`stat-card__change--${changeType}`]">
        {{ change }}
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'

interface Props {
  title: string
  value: string | number
  change: string
  icon: string
  color: string
}

const props = defineProps<Props>()

const changeType = computed(() => {
  if (props.change.startsWith('+')) return 'positive'
  if (props.change.startsWith('-')) return 'negative'
  return 'neutral'
})
</script>

<style scoped>
.stat-card {
  background: white;
  padding: 24px;
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  display: flex;
  align-items: center;
  gap: 16px;
}

.stat-card__icon {
  width: 48px;
  height: 48px;
  border-radius: 8px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: white;
}

.stat-card--primary .stat-card__icon {
  background: #409eff;
}

.stat-card--success .stat-card__icon {
  background: #67c23a;
}

.stat-card--warning .stat-card__icon {
  background: #e6a23c;
}

.stat-card--danger .stat-card__icon {
  background: #f56c6c;
}

.stat-card__content {
  flex: 1;
}

.stat-card__value {
  font-size: 24px;
  font-weight: 600;
  color: #303133;
  margin-bottom: 4px;
}

.stat-card__title {
  font-size: 14px;
  color: #909399;
  margin-bottom: 8px;
}

.stat-card__change {
  font-size: 12px;
  font-weight: 500;
}

.stat-card__change--positive {
  color: #67c23a;
}

.stat-card__change--negative {
  color: #f56c6c;
}

.stat-card__change--neutral {
  color: #909399;
}
</style>

8.5.2 用户表单组件

<!-- src/components/UserForm.vue -->
<template>
  <el-dialog
    v-model="visible"
    :title="isEdit ? '编辑用户' : '添加用户'"
    width="600px"
    @close="handleClose"
  >
    <el-form
      ref="formRef"
      :model="form"
      :rules="rules"
      label-width="80px"
    >
      <el-form-item label="用户名" prop="username">
        <el-input v-model="form.username" placeholder="请输入用户名" />
      </el-form-item>
      
      <el-form-item label="邮箱" prop="email">
        <el-input v-model="form.email" placeholder="请输入邮箱" />
      </el-form-item>
      
      <el-form-item label="密码" prop="password" v-if="!isEdit">
        <el-input
          v-model="form.password"
          type="password"
          placeholder="请输入密码"
          show-password
        />
      </el-form-item>
      
      <el-form-item label="角色" prop="role">
        <el-select v-model="form.role" placeholder="请选择角色">
          <el-option label="管理员" value="admin" />
          <el-option label="普通用户" value="user" />
          <el-option label="访客" value="guest" />
        </el-select>
      </el-form-item>
      
      <el-form-item label="状态" prop="status">
        <el-radio-group v-model="form.status">
          <el-radio label="active">启用</el-radio>
          <el-radio label="inactive">禁用</el-radio>
        </el-radio-group>
      </el-form-item>
    </el-form>
    
    <template #footer>
      <el-button @click="handleClose">取消</el-button>
      <el-button type="primary" :loading="loading" @click="handleSubmit">
        确定
      </el-button>
    </template>
  </el-dialog>
</template>

<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { createUserApi, updateUserApi } from '@/api/user'
import type { User } from '@/types/user'

interface Props {
  visible: boolean
  user?: User | null
}

const props = defineProps<Props>()
const emit = defineEmits<{
  'update:visible': [value: boolean]
  success: []
}>()

const formRef = ref()
const loading = ref(false)

const form = reactive({
  username: '',
  email: '',
  password: '',
  role: 'user',
  status: 'active'
})

const rules = {
  username: [
    { required: true, message: '请输入用户名', trigger: 'blur' }
  ],
  email: [
    { required: true, message: '请输入邮箱', trigger: 'blur' },
    { type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
  ],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    { min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
  ],
  role: [
    { required: true, message: '请选择角色', trigger: 'change' }
  ]
}

const isEdit = computed(() => !!props.user)

watch(() => props.visible, (visible) => {
  if (visible && props.user) {
    Object.assign(form, props.user)
  } else if (visible && !props.user) {
    resetForm()
  }
})

const resetForm = () => {
  Object.assign(form, {
    username: '',
    email: '',
    password: '',
    role: 'user',
    status: 'active'
  })
  formRef.value?.clearValidate()
}

const handleClose = () => {
  emit('update:visible', false)
  resetForm()
}

const handleSubmit = async () => {
  if (!formRef.value) return
  
  await formRef.value.validate(async (valid: boolean) => {
    if (valid) {
      loading.value = true
      try {
        if (isEdit.value) {
          await updateUserApi(props.user!.id, form)
          ElMessage.success('更新成功')
        } else {
          await createUserApi(form)
          ElMessage.success('创建成功')
        }
        emit('success')
        handleClose()
      } catch (error) {
        ElMessage.error(isEdit.value ? '更新失败' : '创建失败')
      } finally {
        loading.value = false
      }
    }
  })
}
</script>

🧪 8.6 测试实现

8.6.1 单元测试

// tests/components/StatCard.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import StatCard from '@/components/StatCard.vue'

describe('StatCard', () => {
  it('应该正确渲染统计卡片', () => {
    const wrapper = mount(StatCard, {
      props: {
        title: '总用户数',
        value: '12,345',
        change: '+12.5%',
        icon: 'User',
        color: 'primary'
      }
    })
    
    expect(wrapper.find('.stat-card__title').text()).toBe('总用户数')
    expect(wrapper.find('.stat-card__value').text()).toBe('12,345')
    expect(wrapper.find('.stat-card__change').text()).toBe('+12.5%')
  })

  it('应该根据变化类型显示不同颜色', () => {
    const wrapper = mount(StatCard, {
      props: {
        title: '测试',
        value: '100',
        change: '+10%',
        icon: 'User',
        color: 'primary'
      }
    })
    
    expect(wrapper.find('.stat-card__change--positive').exists()).toBe(true)
  })
})

8.6.2 E2E 测试

// tests/e2e/dashboard.spec.ts
import { test, expect } from '@playwright/test'

test.describe('仪表盘页面', () => {
  test('应该正确加载仪表盘', async ({ page }) => {
    await page.goto('/')
    
    // 检查页面标题
    await expect(page.locator('h1')).toContainText('仪表盘')
    
    // 检查统计卡片
    await expect(page.locator('.stat-card')).toHaveCount(4)
    
    // 检查图表
    await expect(page.locator('.chart-container')).toHaveCount(2)
  })

  test('应该能够切换日期范围', async ({ page }) => {
    await page.goto('/')
    
    // 点击日期选择器
    await page.click('.el-date-editor')
    
    // 选择新的日期范围
    await page.click('[data-date="2024-01-01"]')
    await page.click('[data-date="2024-01-31"]')
    
    // 验证数据更新
    await expect(page.locator('.stat-card__value')).toBeVisible()
  })
})

🚀 8.7 部署与优化

8.7.1 构建优化

// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          vendor: ['vue', 'vue-router', 'pinia'],
          element: ['element-plus', '@element-plus/icons-vue'],
          charts: ['echarts'],
          utils: ['axios', 'dayjs', 'lodash-es']
        }
      }
    },
    chunkSizeWarningLimit: 1000
  }
})

8.7.2 性能监控

// src/utils/performance.ts
export const initPerformanceMonitoring = () => {
  // Web Vitals 监控
  import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
    getCLS(console.log)
    getFID(console.log)
    getFCP(console.log)
    getLCP(console.log)
    getTTFB(console.log)
  })
}

🎯 本章总结

通过本章学习,你完成了:

  1. 项目初始化:Vue3 + TypeScript + Vite 项目搭建
  2. 设计系统:主题切换、设计令牌、组件库
  3. 架构设计:路由配置、状态管理、API 封装
  4. 页面开发:登录、仪表盘、用户管理页面
  5. 组件开发:可复用组件、表单组件
  6. 测试实现:单元测试、E2E 测试
  7. 部署优化:构建优化、性能监控

📝 练习题

  1. 完善用户管理功能,添加批量操作
  2. 实现数据分析页面的更多图表类型
  3. 添加系统设置页面的配置功能
  4. 实现消息通知系统
  5. 添加数据导出功能

🔗 相关资源

  • Vue 3 官方文档
  • Element Plus 文档
  • ECharts 文档
  • Pinia 文档
  • Vite 文档
Prev
第7章:CSS 进阶专题
Next
第9章:项目实战B - 内容社区