1659 lines
37 KiB
Markdown
1659 lines
37 KiB
Markdown
|
|
# 解班客前端开发文档
|
|||
|
|
|
|||
|
|
## 📋 概述
|
|||
|
|
|
|||
|
|
本文档详细介绍解班客项目前端开发的技术架构、组件设计、开发规范和最佳实践。前端采用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设计规范
|
|||
|
|
|
|||
|
|
### 设计系统
|
|||
|
|
|
|||
|
|
#### 色彩规范
|
|||
|
|
```scss
|
|||
|
|
// 主色调
|
|||
|
|
$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); // 遮罩背景
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 字体规范
|
|||
|
|
```scss
|
|||
|
|
// 字体大小
|
|||
|
|
$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; // 次要行高
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 间距规范
|
|||
|
|
```scss
|
|||
|
|
// 间距系统 (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
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 组件注册
|
|||
|
|
```typescript
|
|||
|
|
// ✅ 正确 - 组件名使用PascalCase
|
|||
|
|
export default defineComponent({
|
|||
|
|
name: 'AnimalCard',
|
|||
|
|
// ...
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 全局注册
|
|||
|
|
app.component('AnimalCard', AnimalCard)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 组件结构规范
|
|||
|
|
|
|||
|
|
#### 标准组件模板
|
|||
|
|
```vue
|
|||
|
|
<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
|
|||
|
|
// ✅ 使用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
|
|||
|
|
// ✅ 使用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结构
|
|||
|
|
```typescript
|
|||
|
|
// 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)
|
|||
|
|
|
|||
|
|
#### 认证相关
|
|||
|
|
```typescript
|
|||
|
|
// 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调用
|
|||
|
|
```typescript
|
|||
|
|
// 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
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 🛣️ 路由设计
|
|||
|
|
|
|||
|
|
### 路由配置
|
|||
|
|
```typescript
|
|||
|
|
// 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: '页面不存在'
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 路由守卫
|
|||
|
|
```typescript
|
|||
|
|
// 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) => {
|
|||
|
|
// 页面切换后的处理
|
|||
|
|
// 例如:埋点统计、页面加载完成事件等
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 🔧 工具函数
|
|||
|
|
|
|||
|
|
### 格式化工具
|
|||
|
|
```typescript
|
|||
|
|
// 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)}`
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 验证工具
|
|||
|
|
```typescript
|
|||
|
|
// 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'
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 🎯 性能优化
|
|||
|
|
|
|||
|
|
### 代码分割
|
|||
|
|
```typescript
|
|||
|
|
// 路由懒加载
|
|||
|
|
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
|
|||
|
|
})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 缓存策略
|
|||
|
|
```typescript
|
|||
|
|
// 组件缓存
|
|||
|
|
<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
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 虚拟滚动
|
|||
|
|
```vue
|
|||
|
|
<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>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 🧪 测试规范
|
|||
|
|
|
|||
|
|
### 单元测试
|
|||
|
|
```typescript
|
|||
|
|
// 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])
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 集成测试
|
|||
|
|
```typescript
|
|||
|
|
// 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
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 📱 响应式设计
|
|||
|
|
|
|||
|
|
### 断点系统
|
|||
|
|
```scss
|
|||
|
|
// 断点定义
|
|||
|
|
$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;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 移动端适配
|
|||
|
|
```vue
|
|||
|
|
<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配置
|
|||
|
|
```typescript
|
|||
|
|
// 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)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 开发环境配置
|
|||
|
|
```typescript
|
|||
|
|
// 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日
|
|||
|
|
**维护人员**: 前端开发团队
|