37 KiB
37 KiB
解班客前端开发文档
📋 概述
本文档详细介绍解班客项目前端开发的技术架构、组件设计、开发规范和最佳实践。前端采用Vue.js 3 + TypeScript + Element Plus技术栈,提供现代化的用户界面和良好的用户体验。
🏗️ 技术架构
核心技术栈
基础框架
- Vue.js 3.4+ - 渐进式JavaScript框架
- TypeScript 5.0+ - 类型安全的JavaScript超集
- Vite 5.0+ - 现代化构建工具
- Vue Router 4 - 官方路由管理器
- Pinia - 状态管理库
UI组件库
- Element Plus - 基于Vue 3的组件库
- @element-plus/icons-vue - Element Plus图标库
- Tailwind CSS - 原子化CSS框架
- SCSS - CSS预处理器
工具库
- Axios - HTTP客户端
- Day.js - 轻量级日期处理库
- VueUse - Vue组合式API工具集
- Lodash-es - JavaScript工具库
- @vueuse/core - Vue组合式函数集合
开发工具
- ESLint - 代码检查工具
- Prettier - 代码格式化工具
- Husky - Git钩子工具
- Lint-staged - 暂存文件检查
- Commitizen - 规范化提交工具
项目结构
frontend/
├── public/ # 静态资源
│ ├── favicon.ico
│ └── index.html
├── src/
│ ├── api/ # API接口
│ │ ├── modules/ # 按模块分类的API
│ │ │ ├── auth.ts # 认证相关API
│ │ │ ├── user.ts # 用户相关API
│ │ │ ├── animal.ts # 动物相关API
│ │ │ └── adoption.ts # 认领相关API
│ │ ├── request.ts # 请求拦截器
│ │ └── types.ts # API类型定义
│ ├── assets/ # 静态资源
│ │ ├── images/ # 图片资源
│ │ ├── icons/ # 图标资源
│ │ └── styles/ # 全局样式
│ │ ├── index.scss # 主样式文件
│ │ ├── variables.scss # SCSS变量
│ │ └── mixins.scss # SCSS混入
│ ├── components/ # 公共组件
│ │ ├── common/ # 通用组件
│ │ │ ├── AppHeader.vue # 应用头部
│ │ │ ├── AppFooter.vue # 应用底部
│ │ │ ├── Loading.vue # 加载组件
│ │ │ └── Pagination.vue # 分页组件
│ │ └── business/ # 业务组件
│ │ ├── AnimalCard.vue # 动物卡片
│ │ ├── UserAvatar.vue # 用户头像
│ │ └── MapView.vue # 地图组件
│ ├── composables/ # 组合式函数
│ │ ├── useAuth.ts # 认证相关
│ │ ├── useApi.ts # API调用
│ │ ├── useForm.ts # 表单处理
│ │ └── useMap.ts # 地图功能
│ ├── layouts/ # 布局组件
│ │ ├── DefaultLayout.vue # 默认布局
│ │ ├── AuthLayout.vue # 认证布局
│ │ └── AdminLayout.vue # 管理布局
│ ├── pages/ # 页面组件
│ │ ├── home/ # 首页
│ │ ├── auth/ # 认证页面
│ │ ├── animal/ # 动物相关页面
│ │ ├── user/ # 用户相关页面
│ │ └── adoption/ # 认领相关页面
│ ├── router/ # 路由配置
│ │ ├── index.ts # 主路由文件
│ │ ├── guards.ts # 路由守卫
│ │ └── routes.ts # 路由定义
│ ├── stores/ # 状态管理
│ │ ├── modules/ # 按模块分类的store
│ │ │ ├── auth.ts # 认证状态
│ │ │ ├── user.ts # 用户状态
│ │ │ └── animal.ts # 动物状态
│ │ └── index.ts # Store入口
│ ├── types/ # 类型定义
│ │ ├── api.ts # API类型
│ │ ├── user.ts # 用户类型
│ │ ├── animal.ts # 动物类型
│ │ └── common.ts # 通用类型
│ ├── utils/ # 工具函数
│ │ ├── auth.ts # 认证工具
│ │ ├── format.ts # 格式化工具
│ │ ├── validate.ts # 验证工具
│ │ └── constants.ts # 常量定义
│ ├── App.vue # 根组件
│ └── main.ts # 应用入口
├── .env.development # 开发环境变量
├── .env.production # 生产环境变量
├── .eslintrc.js # ESLint配置
├── .prettierrc # Prettier配置
├── index.html # HTML模板
├── package.json # 项目配置
├── tsconfig.json # TypeScript配置
└── vite.config.ts # Vite配置
🎨 UI设计规范
设计系统
色彩规范
// 主色调
$primary-color: #409EFF; // 主要品牌色
$success-color: #67C23A; // 成功色
$warning-color: #E6A23C; // 警告色
$danger-color: #F56C6C; // 危险色
$info-color: #909399; // 信息色
// 中性色
$text-primary: #303133; // 主要文字
$text-regular: #606266; // 常规文字
$text-secondary: #909399; // 次要文字
$text-placeholder: #C0C4CC; // 占位文字
// 边框色
$border-base: #DCDFE6; // 基础边框
$border-light: #E4E7ED; // 浅色边框
$border-lighter: #EBEEF5; // 更浅边框
$border-extra-light: #F2F6FC; // 极浅边框
// 背景色
$bg-color: #FFFFFF; // 基础背景
$bg-page: #F2F3F5; // 页面背景
$bg-overlay: rgba(0,0,0,0.8); // 遮罩背景
字体规范
// 字体大小
$font-size-extra-large: 20px; // 超大字体
$font-size-large: 18px; // 大字体
$font-size-medium: 16px; // 中等字体
$font-size-base: 14px; // 基础字体
$font-size-small: 13px; // 小字体
$font-size-extra-small: 12px; // 超小字体
// 字体粗细
$font-weight-primary: 500; // 主要字重
$font-weight-secondary: 400; // 次要字重
// 行高
$line-height-primary: 24px; // 主要行高
$line-height-secondary: 16px; // 次要行高
间距规范
// 间距系统 (8px基准)
$spacing-xs: 4px; // 超小间距
$spacing-sm: 8px; // 小间距
$spacing-md: 16px; // 中等间距
$spacing-lg: 24px; // 大间距
$spacing-xl: 32px; // 超大间距
$spacing-xxl: 48px; // 极大间距
组件设计原则
1. 一致性原则
- 保持视觉风格统一
- 交互行为一致
- 命名规范统一
2. 可访问性原则
- 支持键盘导航
- 提供语义化标签
- 考虑屏幕阅读器
3. 响应式原则
- 移动端优先设计
- 断点适配
- 弹性布局
🧩 组件开发规范
组件命名规范
文件命名
// ✅ 正确 - 使用PascalCase
AnimalCard.vue
UserProfile.vue
SearchForm.vue
// ❌ 错误
animalCard.vue
user-profile.vue
searchform.vue
组件注册
// ✅ 正确 - 组件名使用PascalCase
export default defineComponent({
name: 'AnimalCard',
// ...
})
// 全局注册
app.component('AnimalCard', AnimalCard)
组件结构规范
标准组件模板
<template>
<div class="animal-card">
<!-- 组件内容 -->
<div class="animal-card__header">
<h3 class="animal-card__title">{{ animal.name }}</h3>
<span class="animal-card__status" :class="`animal-card__status--${animal.status}`">
{{ getStatusText(animal.status) }}
</span>
</div>
<div class="animal-card__content">
<img
:src="animal.avatar"
:alt="animal.name"
class="animal-card__image"
@error="handleImageError"
>
<p class="animal-card__description">{{ animal.description }}</p>
</div>
<div class="animal-card__actions">
<el-button
type="primary"
@click="handleAdopt"
:loading="adopting"
>
申请认领
</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { Animal } from '@/types/animal'
// Props定义
interface Props {
animal: Animal
showActions?: boolean
}
const props = withDefaults(defineProps<Props>(), {
showActions: true
})
// Emits定义
interface Emits {
adopt: [animalId: number]
imageError: [animal: Animal]
}
const emit = defineEmits<Emits>()
// 响应式数据
const adopting = ref(false)
// 计算属性
const getStatusText = computed(() => (status: string) => {
const statusMap = {
available: '可认领',
pending: '审核中',
adopted: '已认领'
}
return statusMap[status] || '未知'
})
// 方法
const handleAdopt = async () => {
adopting.value = true
try {
emit('adopt', props.animal.id)
} finally {
adopting.value = false
}
}
const handleImageError = () => {
emit('imageError', props.animal)
}
</script>
<style lang="scss" scoped>
.animal-card {
border: 1px solid $border-base;
border-radius: 8px;
padding: $spacing-md;
background: $bg-color;
transition: box-shadow 0.3s ease;
&:hover {
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-sm;
}
&__title {
font-size: $font-size-large;
font-weight: $font-weight-primary;
color: $text-primary;
margin: 0;
}
&__status {
padding: 4px 8px;
border-radius: 4px;
font-size: $font-size-small;
&--available {
background-color: rgba($success-color, 0.1);
color: $success-color;
}
&--pending {
background-color: rgba($warning-color, 0.1);
color: $warning-color;
}
&--adopted {
background-color: rgba($info-color, 0.1);
color: $info-color;
}
}
&__content {
margin-bottom: $spacing-md;
}
&__image {
width: 100%;
height: 200px;
object-fit: cover;
border-radius: 4px;
margin-bottom: $spacing-sm;
}
&__description {
color: $text-regular;
line-height: $line-height-primary;
margin: 0;
}
&__actions {
display: flex;
gap: $spacing-sm;
}
}
// 响应式设计
@media (max-width: 768px) {
.animal-card {
padding: $spacing-sm;
&__header {
flex-direction: column;
align-items: flex-start;
gap: $spacing-xs;
}
&__actions {
flex-direction: column;
}
}
}
</style>
Props和Emits规范
Props定义
// ✅ 使用TypeScript接口定义Props
interface Props {
// 必需属性
userId: number
// 可选属性
showAvatar?: boolean
// 带默认值的属性
size?: 'small' | 'medium' | 'large'
// 复杂类型
user?: User | null
// 数组类型
tags?: string[]
// 函数类型
onUpdate?: (value: string) => void
}
// 设置默认值
const props = withDefaults(defineProps<Props>(), {
showAvatar: true,
size: 'medium',
user: null,
tags: () => [],
onUpdate: undefined
})
Emits定义
// ✅ 使用TypeScript接口定义Emits
interface Emits {
// 简单事件
close: []
// 带参数的事件
update: [value: string]
// 多参数事件
change: [id: number, value: string, meta?: any]
// 对象参数事件
submit: [data: { name: string; email: string }]
}
const emit = defineEmits<Emits>()
// 触发事件
const handleSubmit = () => {
emit('submit', { name: 'John', email: 'john@example.com' })
}
🔄 状态管理
Pinia Store设计
Store结构
// stores/modules/auth.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { User, LoginForm, RegisterForm } from '@/types/user'
import { authApi } from '@/api/modules/auth'
export const useAuthStore = defineStore('auth', () => {
// State
const user = ref<User | null>(null)
const token = ref<string | null>(localStorage.getItem('token'))
const loading = ref(false)
// Getters
const isAuthenticated = computed(() => !!token.value && !!user.value)
const userRole = computed(() => user.value?.role || 'guest')
const permissions = computed(() => user.value?.permissions || [])
// Actions
const login = async (form: LoginForm) => {
loading.value = true
try {
const response = await authApi.login(form)
token.value = response.token
user.value = response.user
// 保存到localStorage
localStorage.setItem('token', response.token)
localStorage.setItem('user', JSON.stringify(response.user))
return response
} catch (error) {
console.error('Login failed:', error)
throw error
} finally {
loading.value = false
}
}
const register = async (form: RegisterForm) => {
loading.value = true
try {
const response = await authApi.register(form)
return response
} catch (error) {
console.error('Register failed:', error)
throw error
} finally {
loading.value = false
}
}
const logout = async () => {
try {
await authApi.logout()
} catch (error) {
console.error('Logout failed:', error)
} finally {
// 清除本地数据
token.value = null
user.value = null
localStorage.removeItem('token')
localStorage.removeItem('user')
}
}
const fetchUserInfo = async () => {
if (!token.value) return
try {
const response = await authApi.getUserInfo()
user.value = response.user
localStorage.setItem('user', JSON.stringify(response.user))
} catch (error) {
console.error('Fetch user info failed:', error)
// 如果获取用户信息失败,可能token已过期
logout()
}
}
const updateProfile = async (data: Partial<User>) => {
try {
const response = await authApi.updateProfile(data)
user.value = { ...user.value, ...response.user }
localStorage.setItem('user', JSON.stringify(user.value))
return response
} catch (error) {
console.error('Update profile failed:', error)
throw error
}
}
// 初始化
const init = () => {
const savedUser = localStorage.getItem('user')
if (savedUser && token.value) {
try {
user.value = JSON.parse(savedUser)
// 验证token有效性
fetchUserInfo()
} catch (error) {
console.error('Parse saved user failed:', error)
logout()
}
}
}
return {
// State
user,
token,
loading,
// Getters
isAuthenticated,
userRole,
permissions,
// Actions
login,
register,
logout,
fetchUserInfo,
updateProfile,
init
}
})
组合式函数 (Composables)
认证相关
// composables/useAuth.ts
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/modules/auth'
import { ElMessage } from 'element-plus'
export function useAuth() {
const authStore = useAuthStore()
const router = useRouter()
// 计算属性
const isLoggedIn = computed(() => authStore.isAuthenticated)
const currentUser = computed(() => authStore.user)
const userRole = computed(() => authStore.userRole)
// 登录方法
const login = async (form: LoginForm) => {
try {
await authStore.login(form)
ElMessage.success('登录成功')
// 重定向到之前的页面或首页
const redirect = router.currentRoute.value.query.redirect as string
router.push(redirect || '/')
} catch (error) {
ElMessage.error('登录失败,请检查用户名和密码')
throw error
}
}
// 登出方法
const logout = async () => {
try {
await authStore.logout()
ElMessage.success('已退出登录')
router.push('/login')
} catch (error) {
ElMessage.error('退出登录失败')
}
}
// 权限检查
const hasPermission = (permission: string) => {
return authStore.permissions.includes(permission)
}
const hasRole = (role: string) => {
return authStore.userRole === role
}
// 需要登录的操作
const requireAuth = (callback: () => void) => {
if (isLoggedIn.value) {
callback()
} else {
ElMessage.warning('请先登录')
router.push('/login')
}
}
return {
isLoggedIn,
currentUser,
userRole,
login,
logout,
hasPermission,
hasRole,
requireAuth
}
}
API调用
// composables/useApi.ts
import { ref, unref } from 'vue'
import type { Ref } from 'vue'
import { ElMessage } from 'element-plus'
interface UseApiOptions {
immediate?: boolean
showError?: boolean
showSuccess?: boolean
successMessage?: string
}
export function useApi<T = any, P = any>(
apiFunction: (params?: P) => Promise<T>,
options: UseApiOptions = {}
) {
const {
immediate = false,
showError = true,
showSuccess = false,
successMessage = '操作成功'
} = options
const data = ref<T | null>(null)
const loading = ref(false)
const error = ref<Error | null>(null)
const execute = async (params?: P) => {
loading.value = true
error.value = null
try {
const result = await apiFunction(params)
data.value = result
if (showSuccess) {
ElMessage.success(successMessage)
}
return result
} catch (err) {
error.value = err as Error
if (showError) {
ElMessage.error(err.message || '操作失败')
}
throw err
} finally {
loading.value = false
}
}
// 立即执行
if (immediate) {
execute()
}
return {
data,
loading,
error,
execute
}
}
// 使用示例
export function useAnimalList() {
const { data: animals, loading, execute: fetchAnimals } = useApi(
animalApi.getList,
{ immediate: true, showError: true }
)
const { execute: deleteAnimal } = useApi(
animalApi.delete,
{ showSuccess: true, successMessage: '删除成功' }
)
return {
animals,
loading,
fetchAnimals,
deleteAnimal
}
}
🛣️ 路由设计
路由配置
// router/routes.ts
import type { RouteRecordRaw } from 'vue-router'
export const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'Home',
component: () => import('@/layouts/DefaultLayout.vue'),
children: [
{
path: '',
name: 'HomePage',
component: () => import('@/pages/home/HomePage.vue'),
meta: {
title: '首页',
requiresAuth: false
}
},
{
path: '/animals',
name: 'AnimalList',
component: () => import('@/pages/animal/AnimalList.vue'),
meta: {
title: '动物列表',
requiresAuth: false
}
},
{
path: '/animals/:id',
name: 'AnimalDetail',
component: () => import('@/pages/animal/AnimalDetail.vue'),
meta: {
title: '动物详情',
requiresAuth: false
}
}
]
},
{
path: '/auth',
component: () => import('@/layouts/AuthLayout.vue'),
children: [
{
path: 'login',
name: 'Login',
component: () => import('@/pages/auth/Login.vue'),
meta: {
title: '登录',
requiresAuth: false,
hideForAuth: true
}
},
{
path: 'register',
name: 'Register',
component: () => import('@/pages/auth/Register.vue'),
meta: {
title: '注册',
requiresAuth: false,
hideForAuth: true
}
}
]
},
{
path: '/user',
component: () => import('@/layouts/DefaultLayout.vue'),
meta: {
requiresAuth: true
},
children: [
{
path: 'profile',
name: 'UserProfile',
component: () => import('@/pages/user/Profile.vue'),
meta: {
title: '个人资料'
}
},
{
path: 'animals',
name: 'UserAnimals',
component: () => import('@/pages/user/Animals.vue'),
meta: {
title: '我的动物'
}
},
{
path: 'adoptions',
name: 'UserAdoptions',
component: () => import('@/pages/user/Adoptions.vue'),
meta: {
title: '我的认领'
}
}
]
},
{
path: '/admin',
component: () => import('@/layouts/AdminLayout.vue'),
meta: {
requiresAuth: true,
requiresRole: 'admin'
},
children: [
{
path: '',
name: 'AdminDashboard',
component: () => import('@/pages/admin/Dashboard.vue'),
meta: {
title: '管理后台'
}
}
]
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/pages/error/NotFound.vue'),
meta: {
title: '页面不存在'
}
}
]
路由守卫
// router/guards.ts
import type { Router } from 'vue-router'
import { useAuthStore } from '@/stores/modules/auth'
import { ElMessage } from 'element-plus'
export function setupRouterGuards(router: Router) {
// 全局前置守卫
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore()
// 设置页面标题
if (to.meta.title) {
document.title = `${to.meta.title} - 解班客`
}
// 检查是否需要认证
if (to.meta.requiresAuth) {
if (!authStore.isAuthenticated) {
ElMessage.warning('请先登录')
next({
name: 'Login',
query: { redirect: to.fullPath }
})
return
}
// 检查角色权限
if (to.meta.requiresRole) {
if (authStore.userRole !== to.meta.requiresRole) {
ElMessage.error('权限不足')
next({ name: 'Home' })
return
}
}
// 检查具体权限
if (to.meta.requiresPermission) {
if (!authStore.permissions.includes(to.meta.requiresPermission)) {
ElMessage.error('权限不足')
next({ name: 'Home' })
return
}
}
}
// 已登录用户访问登录/注册页面时重定向
if (to.meta.hideForAuth && authStore.isAuthenticated) {
next({ name: 'Home' })
return
}
next()
})
// 全局后置钩子
router.afterEach((to, from) => {
// 页面切换后的处理
// 例如:埋点统计、页面加载完成事件等
})
}
🔧 工具函数
格式化工具
// utils/format.ts
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import relativeTime from 'dayjs/plugin/relativeTime'
dayjs.locale('zh-cn')
dayjs.extend(relativeTime)
/**
* 格式化日期
*/
export const formatDate = (
date: string | number | Date,
format = 'YYYY-MM-DD HH:mm:ss'
): string => {
return dayjs(date).format(format)
}
/**
* 格式化相对时间
*/
export const formatRelativeTime = (date: string | number | Date): string => {
return dayjs(date).fromNow()
}
/**
* 格式化文件大小
*/
export const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
/**
* 格式化数字
*/
export const formatNumber = (num: number): string => {
return num.toLocaleString('zh-CN')
}
/**
* 格式化手机号
*/
export const formatPhone = (phone: string): string => {
return phone.replace(/(\d{3})(\d{4})(\d{4})/, '$1****$3')
}
/**
* 格式化金额
*/
export const formatMoney = (amount: number): string => {
return `¥${amount.toFixed(2)}`
}
验证工具
// utils/validate.ts
/**
* 验证邮箱
*/
export const isEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
/**
* 验证手机号
*/
export const isPhone = (phone: string): boolean => {
const phoneRegex = /^1[3-9]\d{9}$/
return phoneRegex.test(phone)
}
/**
* 验证身份证号
*/
export const isIdCard = (idCard: string): boolean => {
const idCardRegex = /(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)/
return idCardRegex.test(idCard)
}
/**
* 验证密码强度
*/
export const validatePassword = (password: string): {
isValid: boolean
strength: 'weak' | 'medium' | 'strong'
message: string
} => {
if (password.length < 8) {
return {
isValid: false,
strength: 'weak',
message: '密码长度至少8位'
}
}
let score = 0
// 包含小写字母
if (/[a-z]/.test(password)) score++
// 包含大写字母
if (/[A-Z]/.test(password)) score++
// 包含数字
if (/\d/.test(password)) score++
// 包含特殊字符
if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) score++
if (score < 2) {
return {
isValid: false,
strength: 'weak',
message: '密码强度太弱,请包含字母和数字'
}
} else if (score < 3) {
return {
isValid: true,
strength: 'medium',
message: '密码强度中等'
}
} else {
return {
isValid: true,
strength: 'strong',
message: '密码强度很强'
}
}
}
/**
* 表单验证规则
*/
export const validationRules = {
required: {
required: true,
message: '此字段为必填项',
trigger: 'blur'
},
email: {
validator: (rule: any, value: string, callback: Function) => {
if (value && !isEmail(value)) {
callback(new Error('请输入正确的邮箱地址'))
} else {
callback()
}
},
trigger: 'blur'
},
phone: {
validator: (rule: any, value: string, callback: Function) => {
if (value && !isPhone(value)) {
callback(new Error('请输入正确的手机号'))
} else {
callback()
}
},
trigger: 'blur'
},
password: {
validator: (rule: any, value: string, callback: Function) => {
const result = validatePassword(value)
if (!result.isValid) {
callback(new Error(result.message))
} else {
callback()
}
},
trigger: 'blur'
}
}
🎯 性能优化
代码分割
// 路由懒加载
const routes = [
{
path: '/animals',
component: () => import('@/pages/animal/AnimalList.vue')
}
]
// 组件懒加载
const LazyComponent = defineAsyncComponent(() => import('@/components/HeavyComponent.vue'))
// 条件加载
const ConditionalComponent = defineAsyncComponent({
loader: () => import('@/components/ConditionalComponent.vue'),
loadingComponent: Loading,
errorComponent: Error,
delay: 200,
timeout: 3000
})
缓存策略
// 组件缓存
<template>
<router-view v-slot="{ Component }">
<keep-alive :include="cachedViews">
<component :is="Component" />
</keep-alive>
</router-view>
</template>
// API缓存
const cache = new Map()
export const cachedApi = {
async get(url: string, ttl = 5 * 60 * 1000) {
const cached = cache.get(url)
if (cached && Date.now() - cached.timestamp < ttl) {
return cached.data
}
const data = await api.get(url)
cache.set(url, {
data,
timestamp: Date.now()
})
return data
}
}
虚拟滚动
<template>
<div class="virtual-list" ref="containerRef">
<div
class="virtual-list__phantom"
:style="{ height: phantomHeight + 'px' }"
></div>
<div
class="virtual-list__content"
:style="{ transform: `translateY(${startOffset}px)` }"
>
<div
v-for="item in visibleData"
:key="item.id"
class="virtual-list__item"
:style="{ height: itemHeight + 'px' }"
>
<slot :item="item" :index="item.index"></slot>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
interface Props {
items: any[]
itemHeight: number
containerHeight: number
}
const props = defineProps<Props>()
const containerRef = ref<HTMLElement>()
const scrollTop = ref(0)
// 计算可见区域
const visibleCount = computed(() => Math.ceil(props.containerHeight / props.itemHeight))
const startIndex = computed(() => Math.floor(scrollTop.value / props.itemHeight))
const endIndex = computed(() => Math.min(startIndex.value + visibleCount.value, props.items.length))
// 可见数据
const visibleData = computed(() => {
return props.items.slice(startIndex.value, endIndex.value).map((item, index) => ({
...item,
index: startIndex.value + index
}))
})
// 偏移量
const startOffset = computed(() => startIndex.value * props.itemHeight)
const phantomHeight = computed(() => props.items.length * props.itemHeight)
// 滚动处理
const handleScroll = (e: Event) => {
scrollTop.value = (e.target as HTMLElement).scrollTop
}
onMounted(() => {
containerRef.value?.addEventListener('scroll', handleScroll)
})
onUnmounted(() => {
containerRef.value?.removeEventListener('scroll', handleScroll)
})
</script>
🧪 测试规范
单元测试
// tests/components/AnimalCard.test.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import AnimalCard from '@/components/business/AnimalCard.vue'
import type { Animal } from '@/types/animal'
const mockAnimal: Animal = {
id: 1,
name: '小白',
type: 'dog',
status: 'available',
description: '一只可爱的小狗',
avatar: 'https://example.com/avatar.jpg'
}
describe('AnimalCard', () => {
it('renders animal information correctly', () => {
const wrapper = mount(AnimalCard, {
props: {
animal: mockAnimal
}
})
expect(wrapper.find('.animal-card__title').text()).toBe('小白')
expect(wrapper.find('.animal-card__description').text()).toBe('一只可爱的小狗')
expect(wrapper.find('.animal-card__image').attributes('src')).toBe(mockAnimal.avatar)
})
it('emits adopt event when adopt button is clicked', async () => {
const wrapper = mount(AnimalCard, {
props: {
animal: mockAnimal
}
})
await wrapper.find('.el-button').trigger('click')
expect(wrapper.emitted('adopt')).toBeTruthy()
expect(wrapper.emitted('adopt')[0]).toEqual([mockAnimal.id])
})
it('shows correct status', () => {
const wrapper = mount(AnimalCard, {
props: {
animal: { ...mockAnimal, status: 'adopted' }
}
})
const statusElement = wrapper.find('.animal-card__status--adopted')
expect(statusElement.exists()).toBe(true)
expect(statusElement.text()).toBe('已认领')
})
it('handles image error', async () => {
const wrapper = mount(AnimalCard, {
props: {
animal: mockAnimal
}
})
await wrapper.find('.animal-card__image').trigger('error')
expect(wrapper.emitted('imageError')).toBeTruthy()
expect(wrapper.emitted('imageError')[0]).toEqual([mockAnimal])
})
})
集成测试
// tests/pages/AnimalList.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import AnimalList from '@/pages/animal/AnimalList.vue'
import { animalApi } from '@/api/modules/animal'
// Mock API
vi.mock('@/api/modules/animal', () => ({
animalApi: {
getList: vi.fn()
}
}))
describe('AnimalList', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('loads and displays animals on mount', async () => {
const mockAnimals = [
{ id: 1, name: '小白', type: 'dog', status: 'available' },
{ id: 2, name: '小黑', type: 'cat', status: 'available' }
]
vi.mocked(animalApi.getList).mockResolvedValue({
data: mockAnimals,
total: 2
})
const wrapper = mount(AnimalList)
// 等待异步操作完成
await wrapper.vm.$nextTick()
expect(animalApi.getList).toHaveBeenCalled()
expect(wrapper.findAll('.animal-card')).toHaveLength(2)
})
it('handles search functionality', async () => {
const wrapper = mount(AnimalList)
const searchInput = wrapper.find('input[placeholder="搜索动物"]')
await searchInput.setValue('小白')
await searchInput.trigger('input')
// 验证搜索参数
expect(animalApi.getList).toHaveBeenCalledWith({
keyword: '小白',
page: 1,
limit: 20
})
})
})
📱 响应式设计
断点系统
// 断点定义
$breakpoints: (
xs: 0,
sm: 576px,
md: 768px,
lg: 992px,
xl: 1200px,
xxl: 1400px
);
// 媒体查询混入
@mixin respond-to($breakpoint) {
@if map-has-key($breakpoints, $breakpoint) {
@media (min-width: map-get($breakpoints, $breakpoint)) {
@content;
}
}
}
// 使用示例
.container {
padding: 16px;
@include respond-to(md) {
padding: 24px;
}
@include respond-to(lg) {
padding: 32px;
}
}
移动端适配
<template>
<div class="mobile-layout">
<!-- 移动端头部 -->
<header class="mobile-header">
<el-button
class="mobile-header__back"
@click="goBack"
v-if="showBackButton"
>
<el-icon><ArrowLeft /></el-icon>
</el-button>
<h1 class="mobile-header__title">{{ title }}</h1>
<div class="mobile-header__actions">
<slot name="actions"></slot>
</div>
</header>
<!-- 内容区域 -->
<main class="mobile-content">
<slot></slot>
</main>
<!-- 底部导航 -->
<nav class="mobile-nav" v-if="showBottomNav">
<router-link
v-for="item in navItems"
:key="item.name"
:to="item.path"
class="mobile-nav__item"
:class="{ 'mobile-nav__item--active': $route.name === item.name }"
>
<el-icon>
<component :is="item.icon" />
</el-icon>
<span>{{ item.label }}</span>
</router-link>
</nav>
</div>
</template>
<style lang="scss" scoped>
.mobile-layout {
display: flex;
flex-direction: column;
height: 100vh;
@include respond-to(md) {
display: none; // 桌面端隐藏
}
}
.mobile-header {
display: flex;
align-items: center;
padding: 12px 16px;
background: $bg-color;
border-bottom: 1px solid $border-base;
position: sticky;
top: 0;
z-index: 100;
&__back {
margin-right: 12px;
}
&__title {
flex: 1;
font-size: 18px;
font-weight: 600;
text-align: center;
margin: 0;
}
&__actions {
min-width: 40px;
}
}
.mobile-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.mobile-nav {
display: flex;
background: $bg-color;
border-top: 1px solid $border-base;
padding: 8px 0;
&__item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 8px;
color: $text-secondary;
text-decoration: none;
font-size: 12px;
&--active {
color: $primary-color;
}
.el-icon {
font-size: 20px;
margin-bottom: 4px;
}
}
}
</style>
🔍 调试和开发工具
Vue DevTools配置
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
// 开发环境配置
if (import.meta.env.DEV) {
// 启用Vue DevTools
app.config.devtools = true
// 全局错误处理
app.config.errorHandler = (err, vm, info) => {
console.error('Vue Error:', err)
console.error('Component:', vm)
console.error('Info:', info)
}
// 全局警告处理
app.config.warnHandler = (msg, vm, trace) => {
console.warn('Vue Warning:', msg)
console.warn('Component:', vm)
console.warn('Trace:', trace)
}
}
开发环境配置
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
server: {
port: 3000,
open: true,
cors: true,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
},
build: {
sourcemap: true,
rollupOptions: {
output: {
manualChunks: {
vendor: ['vue', 'vue-router', 'pinia'],
element: ['element-plus'],
utils: ['axios', 'dayjs', 'lodash-es']
}
}
}
}
})
📚 总结
本文档详细介绍了解班客项目前端开发的各个方面,包括技术架构、组件设计、状态管理、路由配置、性能优化等。遵循这些规范和最佳实践,可以确保代码质量、提高开发效率、增强项目的可维护性。
关键要点
- 技术选型: Vue 3 + TypeScript + Element Plus提供现代化开发体验
- 组件化: 采用组合式API和单文件组件,提高代码复用性
- 状态管理: 使用Pinia进行状态管理,支持TypeScript
- 路由设计: 基于角色的权限控制和懒加载优化
- 性能优化: 代码分割、缓存策略、虚拟滚动等技术
- 响应式设计: 移动端优先,多断点适配
- 测试覆盖: 单元测试和集成测试保证代码质量
后续计划
- 完善组件库和设计系统
- 增加更多性能优化策略
- 完善测试用例覆盖
- 添加国际化支持
- 集成更多开发工具
文档版本: v1.0.0
最后更新: 2024年1月15日
维护人员: 前端开发团队