第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)
})
}
🎯 本章总结
通过本章学习,你完成了:
- 项目初始化:Vue3 + TypeScript + Vite 项目搭建
- 设计系统:主题切换、设计令牌、组件库
- 架构设计:路由配置、状态管理、API 封装
- 页面开发:登录、仪表盘、用户管理页面
- 组件开发:可复用组件、表单组件
- 测试实现:单元测试、E2E 测试
- 部署优化:构建优化、性能监控
📝 练习题
- 完善用户管理功能,添加批量操作
- 实现数据分析页面的更多图表类型
- 添加系统设置页面的配置功能
- 实现消息通知系统
- 添加数据导出功能