Files
jiebanke/docs/前端开发文档.md

37 KiB
Raw Blame History

解班客前端开发文档

📋 概述

本文档详细介绍解班客项目前端开发的技术架构、组件设计、开发规范和最佳实践。前端采用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']
        }
      }
    }
  }
})

📚 总结

本文档详细介绍了解班客项目前端开发的各个方面,包括技术架构、组件设计、状态管理、路由配置、性能优化等。遵循这些规范和最佳实践,可以确保代码质量、提高开发效率、增强项目的可维护性。

关键要点

  1. 技术选型: Vue 3 + TypeScript + Element Plus提供现代化开发体验
  2. 组件化: 采用组合式API和单文件组件提高代码复用性
  3. 状态管理: 使用Pinia进行状态管理支持TypeScript
  4. 路由设计: 基于角色的权限控制和懒加载优化
  5. 性能优化: 代码分割、缓存策略、虚拟滚动等技术
  6. 响应式设计: 移动端优先,多断点适配
  7. 测试覆盖: 单元测试和集成测试保证代码质量

后续计划

  • 完善组件库和设计系统
  • 增加更多性能优化策略
  • 完善测试用例覆盖
  • 添加国际化支持
  • 集成更多开发工具

文档版本: v1.0.0
最后更新: 2024年1月15日
维护人员: 前端开发团队