2564 lines
56 KiB
Markdown
2564 lines
56 KiB
Markdown
|
|
# 解班客管理后台架构文档
|
|||
|
|
|
|||
|
|
## 1. 项目概述
|
|||
|
|
|
|||
|
|
### 1.1 项目简介
|
|||
|
|
解班客管理后台是一个基于Vue.js 3.x + Element Plus的现代化管理系统,为运营人员提供用户管理、内容管理、数据分析等功能。采用前后端分离架构,支持多角色权限管理和实时数据监控。
|
|||
|
|
|
|||
|
|
### 1.2 业务目标
|
|||
|
|
- **运营管理**:提供完整的运营管理功能
|
|||
|
|
- **数据分析**:实时数据监控和分析报表
|
|||
|
|
- **权限控制**:细粒度的角色权限管理
|
|||
|
|
- **系统监控**:系统状态和性能监控
|
|||
|
|
|
|||
|
|
### 1.3 技术目标
|
|||
|
|
- **现代化技术栈**:Vue 3 + TypeScript + Vite
|
|||
|
|
- **组件化开发**:高复用性的组件设计
|
|||
|
|
- **响应式设计**:适配不同屏幕尺寸
|
|||
|
|
- **高性能**:快速加载和流畅交互
|
|||
|
|
|
|||
|
|
## 2. 技术选型
|
|||
|
|
|
|||
|
|
### 2.1 核心框架
|
|||
|
|
|
|||
|
|
#### 2.1.1 Vue.js 3.x
|
|||
|
|
```javascript
|
|||
|
|
// 选型理由
|
|||
|
|
{
|
|||
|
|
"框架": "Vue.js 3.x",
|
|||
|
|
"版本": "^3.3.0",
|
|||
|
|
"优势": [
|
|||
|
|
"Composition API,逻辑复用性强",
|
|||
|
|
"TypeScript支持完善",
|
|||
|
|
"性能优化,体积更小",
|
|||
|
|
"生态系统成熟"
|
|||
|
|
],
|
|||
|
|
"特性": [
|
|||
|
|
"响应式系统重构",
|
|||
|
|
"Tree-shaking支持",
|
|||
|
|
"Fragment支持",
|
|||
|
|
"Teleport组件"
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 2.1.2 构建工具 - Vite
|
|||
|
|
```javascript
|
|||
|
|
{
|
|||
|
|
"构建工具": "Vite",
|
|||
|
|
"版本": "^4.4.0",
|
|||
|
|
"优势": [
|
|||
|
|
"极快的冷启动",
|
|||
|
|
"热更新速度快",
|
|||
|
|
"原生ES模块支持",
|
|||
|
|
"插件生态丰富"
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2.2 UI组件库
|
|||
|
|
|
|||
|
|
#### 2.2.1 Element Plus
|
|||
|
|
```javascript
|
|||
|
|
{
|
|||
|
|
"组件库": "Element Plus",
|
|||
|
|
"版本": "^2.3.0",
|
|||
|
|
"优势": [
|
|||
|
|
"组件丰富完整",
|
|||
|
|
"设计规范统一",
|
|||
|
|
"Vue 3原生支持",
|
|||
|
|
"TypeScript支持"
|
|||
|
|
],
|
|||
|
|
"核心组件": [
|
|||
|
|
"Table", "Form", "Dialog",
|
|||
|
|
"Menu", "Breadcrumb", "Pagination",
|
|||
|
|
"DatePicker", "Select", "Upload"
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2.3 状态管理
|
|||
|
|
|
|||
|
|
#### 2.3.1 Pinia
|
|||
|
|
```javascript
|
|||
|
|
{
|
|||
|
|
"状态管理": "Pinia",
|
|||
|
|
"版本": "^2.1.0",
|
|||
|
|
"优势": [
|
|||
|
|
"Vue 3官方推荐",
|
|||
|
|
"TypeScript支持完善",
|
|||
|
|
"DevTools支持",
|
|||
|
|
"模块化设计"
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2.4 路由管理
|
|||
|
|
|
|||
|
|
#### 2.4.1 Vue Router 4
|
|||
|
|
```javascript
|
|||
|
|
{
|
|||
|
|
"路由": "Vue Router 4",
|
|||
|
|
"版本": "^4.2.0",
|
|||
|
|
"特性": [
|
|||
|
|
"Composition API支持",
|
|||
|
|
"动态路由匹配",
|
|||
|
|
"路由守卫",
|
|||
|
|
"懒加载支持"
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2.5 开发工具
|
|||
|
|
|
|||
|
|
#### 2.5.1 TypeScript
|
|||
|
|
```javascript
|
|||
|
|
{
|
|||
|
|
"类型系统": "TypeScript",
|
|||
|
|
"版本": "^5.0.0",
|
|||
|
|
"优势": [
|
|||
|
|
"静态类型检查",
|
|||
|
|
"IDE支持完善",
|
|||
|
|
"代码可维护性高",
|
|||
|
|
"重构安全"
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 2.5.2 ESLint + Prettier
|
|||
|
|
```javascript
|
|||
|
|
{
|
|||
|
|
"代码规范": {
|
|||
|
|
"ESLint": "^8.45.0",
|
|||
|
|
"Prettier": "^3.0.0",
|
|||
|
|
"配置": "@vue/eslint-config-typescript"
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 3. 架构设计
|
|||
|
|
|
|||
|
|
### 3.1 整体架构
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
graph TB
|
|||
|
|
subgraph "管理后台架构"
|
|||
|
|
A[表现层 Presentation Layer]
|
|||
|
|
B[业务逻辑层 Business Layer]
|
|||
|
|
C[数据管理层 Data Layer]
|
|||
|
|
D[服务层 Service Layer]
|
|||
|
|
E[工具层 Utils Layer]
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
subgraph "外部服务"
|
|||
|
|
F[后端API]
|
|||
|
|
G[文件存储]
|
|||
|
|
H[第三方服务]
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
A --> B
|
|||
|
|
B --> C
|
|||
|
|
B --> D
|
|||
|
|
D --> F
|
|||
|
|
D --> G
|
|||
|
|
D --> H
|
|||
|
|
B --> E
|
|||
|
|
C --> E
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 3.2 目录结构
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
src/
|
|||
|
|
├── assets/ # 静态资源
|
|||
|
|
│ ├── images/ # 图片资源
|
|||
|
|
│ ├── icons/ # 图标资源
|
|||
|
|
│ └── styles/ # 样式文件
|
|||
|
|
├── components/ # 公共组件
|
|||
|
|
│ ├── common/ # 通用组件
|
|||
|
|
│ ├── business/ # 业务组件
|
|||
|
|
│ └── layout/ # 布局组件
|
|||
|
|
├── views/ # 页面组件
|
|||
|
|
│ ├── dashboard/ # 仪表板
|
|||
|
|
│ ├── user/ # 用户管理
|
|||
|
|
│ ├── travel/ # 旅行管理
|
|||
|
|
│ ├── animal/ # 动物管理
|
|||
|
|
│ └── system/ # 系统管理
|
|||
|
|
├── stores/ # 状态管理
|
|||
|
|
│ ├── modules/ # 状态模块
|
|||
|
|
│ └── index.ts # Store入口
|
|||
|
|
├── services/ # API服务
|
|||
|
|
│ ├── api/ # API接口
|
|||
|
|
│ ├── http/ # HTTP客户端
|
|||
|
|
│ └── types/ # 类型定义
|
|||
|
|
├── utils/ # 工具函数
|
|||
|
|
│ ├── common.ts # 通用工具
|
|||
|
|
│ ├── date.ts # 日期工具
|
|||
|
|
│ ├── format.ts # 格式化工具
|
|||
|
|
│ └── validate.ts # 验证工具
|
|||
|
|
├── router/ # 路由配置
|
|||
|
|
│ ├── index.ts # 路由入口
|
|||
|
|
│ ├── modules/ # 路由模块
|
|||
|
|
│ └── guards.ts # 路由守卫
|
|||
|
|
├── hooks/ # 组合式函数
|
|||
|
|
│ ├── useAuth.ts # 认证Hook
|
|||
|
|
│ ├── useTable.ts # 表格Hook
|
|||
|
|
│ └── useForm.ts # 表单Hook
|
|||
|
|
└── types/ # 全局类型定义
|
|||
|
|
├── api.ts # API类型
|
|||
|
|
├── common.ts # 通用类型
|
|||
|
|
└── store.ts # Store类型
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 3.3 分层架构详解
|
|||
|
|
|
|||
|
|
#### 3.3.1 表现层 (Presentation Layer)
|
|||
|
|
```typescript
|
|||
|
|
// 页面组件示例
|
|||
|
|
<template>
|
|||
|
|
<div class="user-management">
|
|||
|
|
<div class="header">
|
|||
|
|
<el-breadcrumb>
|
|||
|
|
<el-breadcrumb-item>用户管理</el-breadcrumb-item>
|
|||
|
|
<el-breadcrumb-item>用户列表</el-breadcrumb-item>
|
|||
|
|
</el-breadcrumb>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="content">
|
|||
|
|
<SearchForm @search="handleSearch" />
|
|||
|
|
<DataTable
|
|||
|
|
:data="userList"
|
|||
|
|
:loading="loading"
|
|||
|
|
@edit="handleEdit"
|
|||
|
|
@delete="handleDelete"
|
|||
|
|
/>
|
|||
|
|
<Pagination
|
|||
|
|
:total="total"
|
|||
|
|
@change="handlePageChange"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import { ref, onMounted } from 'vue'
|
|||
|
|
import { useUserStore } from '@/stores/modules/user'
|
|||
|
|
import type { User, SearchParams } from '@/types/api'
|
|||
|
|
|
|||
|
|
const userStore = useUserStore()
|
|||
|
|
const userList = ref<User[]>([])
|
|||
|
|
const loading = ref(false)
|
|||
|
|
const total = ref(0)
|
|||
|
|
|
|||
|
|
onMounted(() => {
|
|||
|
|
loadUserList()
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const loadUserList = async (params?: SearchParams) => {
|
|||
|
|
loading.value = true
|
|||
|
|
try {
|
|||
|
|
const result = await userStore.getUserList(params)
|
|||
|
|
userList.value = result.list
|
|||
|
|
total.value = result.total
|
|||
|
|
} finally {
|
|||
|
|
loading.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 3.3.2 业务逻辑层 (Business Layer)
|
|||
|
|
```typescript
|
|||
|
|
// 业务逻辑Hook
|
|||
|
|
export function useUserManagement() {
|
|||
|
|
const userStore = useUserStore()
|
|||
|
|
const { message, messageBox } = useMessage()
|
|||
|
|
|
|||
|
|
// 用户列表状态
|
|||
|
|
const state = reactive({
|
|||
|
|
userList: [] as User[],
|
|||
|
|
loading: false,
|
|||
|
|
total: 0,
|
|||
|
|
currentPage: 1,
|
|||
|
|
pageSize: 20,
|
|||
|
|
searchParams: {} as SearchParams
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 加载用户列表
|
|||
|
|
const loadUserList = async (refresh = false) => {
|
|||
|
|
if (refresh) {
|
|||
|
|
state.currentPage = 1
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
state.loading = true
|
|||
|
|
try {
|
|||
|
|
const params = {
|
|||
|
|
page: state.currentPage,
|
|||
|
|
pageSize: state.pageSize,
|
|||
|
|
...state.searchParams
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = await userStore.getUserList(params)
|
|||
|
|
state.userList = result.list
|
|||
|
|
state.total = result.total
|
|||
|
|
} catch (error) {
|
|||
|
|
message.error('加载用户列表失败')
|
|||
|
|
} finally {
|
|||
|
|
state.loading = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 删除用户
|
|||
|
|
const deleteUser = async (userId: string) => {
|
|||
|
|
try {
|
|||
|
|
await messageBox.confirm('确定要删除该用户吗?')
|
|||
|
|
await userStore.deleteUser(userId)
|
|||
|
|
message.success('删除成功')
|
|||
|
|
await loadUserList()
|
|||
|
|
} catch (error) {
|
|||
|
|
if (error !== 'cancel') {
|
|||
|
|
message.error('删除失败')
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 搜索用户
|
|||
|
|
const searchUsers = (params: SearchParams) => {
|
|||
|
|
state.searchParams = params
|
|||
|
|
loadUserList(true)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
state: readonly(state),
|
|||
|
|
loadUserList,
|
|||
|
|
deleteUser,
|
|||
|
|
searchUsers
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 3.3.3 数据管理层 (Data Layer)
|
|||
|
|
```typescript
|
|||
|
|
// Pinia Store
|
|||
|
|
import { defineStore } from 'pinia'
|
|||
|
|
import type { User, UserListParams, UserListResponse } from '@/types/api'
|
|||
|
|
import { userApi } from '@/services/api/user'
|
|||
|
|
|
|||
|
|
export const useUserStore = defineStore('user', () => {
|
|||
|
|
// 状态
|
|||
|
|
const userList = ref<User[]>([])
|
|||
|
|
const currentUser = ref<User | null>(null)
|
|||
|
|
const loading = ref(false)
|
|||
|
|
|
|||
|
|
// Getters
|
|||
|
|
const activeUsers = computed(() =>
|
|||
|
|
userList.value.filter(user => user.status === 'active')
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const userCount = computed(() => userList.value.length)
|
|||
|
|
|
|||
|
|
// Actions
|
|||
|
|
const getUserList = async (params: UserListParams): Promise<UserListResponse> => {
|
|||
|
|
loading.value = true
|
|||
|
|
try {
|
|||
|
|
const response = await userApi.getList(params)
|
|||
|
|
userList.value = response.list
|
|||
|
|
return response
|
|||
|
|
} finally {
|
|||
|
|
loading.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const getUserDetail = async (userId: string): Promise<User> => {
|
|||
|
|
const response = await userApi.getDetail(userId)
|
|||
|
|
currentUser.value = response
|
|||
|
|
return response
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const createUser = async (userData: Partial<User>): Promise<User> => {
|
|||
|
|
const response = await userApi.create(userData)
|
|||
|
|
userList.value.unshift(response)
|
|||
|
|
return response
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const updateUser = async (userId: string, userData: Partial<User>): Promise<User> => {
|
|||
|
|
const response = await userApi.update(userId, userData)
|
|||
|
|
const index = userList.value.findIndex(user => user.id === userId)
|
|||
|
|
if (index !== -1) {
|
|||
|
|
userList.value[index] = response
|
|||
|
|
}
|
|||
|
|
return response
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const deleteUser = async (userId: string): Promise<void> => {
|
|||
|
|
await userApi.delete(userId)
|
|||
|
|
const index = userList.value.findIndex(user => user.id === userId)
|
|||
|
|
if (index !== -1) {
|
|||
|
|
userList.value.splice(index, 1)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
// State
|
|||
|
|
userList: readonly(userList),
|
|||
|
|
currentUser: readonly(currentUser),
|
|||
|
|
loading: readonly(loading),
|
|||
|
|
|
|||
|
|
// Getters
|
|||
|
|
activeUsers,
|
|||
|
|
userCount,
|
|||
|
|
|
|||
|
|
// Actions
|
|||
|
|
getUserList,
|
|||
|
|
getUserDetail,
|
|||
|
|
createUser,
|
|||
|
|
updateUser,
|
|||
|
|
deleteUser
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 3.3.4 服务层 (Service Layer)
|
|||
|
|
```typescript
|
|||
|
|
// HTTP客户端
|
|||
|
|
import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios'
|
|||
|
|
import { useAuthStore } from '@/stores/modules/auth'
|
|||
|
|
import { ElMessage } from 'element-plus'
|
|||
|
|
|
|||
|
|
class HttpClient {
|
|||
|
|
private instance: AxiosInstance
|
|||
|
|
|
|||
|
|
constructor() {
|
|||
|
|
this.instance = axios.create({
|
|||
|
|
baseURL: import.meta.env.VITE_API_BASE_URL,
|
|||
|
|
timeout: 10000,
|
|||
|
|
headers: {
|
|||
|
|
'Content-Type': 'application/json'
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
this.setupInterceptors()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private setupInterceptors() {
|
|||
|
|
// 请求拦截器
|
|||
|
|
this.instance.interceptors.request.use(
|
|||
|
|
(config) => {
|
|||
|
|
const authStore = useAuthStore()
|
|||
|
|
const token = authStore.token
|
|||
|
|
|
|||
|
|
if (token) {
|
|||
|
|
config.headers.Authorization = `Bearer ${token}`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return config
|
|||
|
|
},
|
|||
|
|
(error) => {
|
|||
|
|
return Promise.reject(error)
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// 响应拦截器
|
|||
|
|
this.instance.interceptors.response.use(
|
|||
|
|
(response) => {
|
|||
|
|
const { code, data, message } = response.data
|
|||
|
|
|
|||
|
|
if (code === 200) {
|
|||
|
|
return data
|
|||
|
|
} else {
|
|||
|
|
ElMessage.error(message || '请求失败')
|
|||
|
|
return Promise.reject(new Error(message))
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
(error) => {
|
|||
|
|
this.handleError(error)
|
|||
|
|
return Promise.reject(error)
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private handleError(error: any) {
|
|||
|
|
if (error.response) {
|
|||
|
|
const { status, data } = error.response
|
|||
|
|
|
|||
|
|
switch (status) {
|
|||
|
|
case 401:
|
|||
|
|
// 未授权,跳转登录
|
|||
|
|
const authStore = useAuthStore()
|
|||
|
|
authStore.logout()
|
|||
|
|
break
|
|||
|
|
case 403:
|
|||
|
|
ElMessage.error('权限不足')
|
|||
|
|
break
|
|||
|
|
case 404:
|
|||
|
|
ElMessage.error('请求的资源不存在')
|
|||
|
|
break
|
|||
|
|
case 500:
|
|||
|
|
ElMessage.error('服务器内部错误')
|
|||
|
|
break
|
|||
|
|
default:
|
|||
|
|
ElMessage.error(data?.message || '请求失败')
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
ElMessage.error('网络错误')
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// HTTP方法封装
|
|||
|
|
get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
|||
|
|
return this.instance.get(url, config)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
|||
|
|
return this.instance.post(url, data, config)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
|||
|
|
return this.instance.put(url, data, config)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
|||
|
|
return this.instance.delete(url, config)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export const http = new HttpClient()
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 3.3.5 API服务
|
|||
|
|
```typescript
|
|||
|
|
// 用户API服务
|
|||
|
|
import { http } from '@/services/http'
|
|||
|
|
import type { User, UserListParams, UserListResponse } from '@/types/api'
|
|||
|
|
|
|||
|
|
export const userApi = {
|
|||
|
|
// 获取用户列表
|
|||
|
|
getList(params: UserListParams): Promise<UserListResponse> {
|
|||
|
|
return http.get('/admin/users', { params })
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 获取用户详情
|
|||
|
|
getDetail(userId: string): Promise<User> {
|
|||
|
|
return http.get(`/admin/users/${userId}`)
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 创建用户
|
|||
|
|
create(userData: Partial<User>): Promise<User> {
|
|||
|
|
return http.post('/admin/users', userData)
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 更新用户
|
|||
|
|
update(userId: string, userData: Partial<User>): Promise<User> {
|
|||
|
|
return http.put(`/admin/users/${userId}`, userData)
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 删除用户
|
|||
|
|
delete(userId: string): Promise<void> {
|
|||
|
|
return http.delete(`/admin/users/${userId}`)
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 批量操作
|
|||
|
|
batchUpdate(userIds: string[], data: Partial<User>): Promise<void> {
|
|||
|
|
return http.post('/admin/users/batch', { userIds, data })
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 导出用户数据
|
|||
|
|
export(params: UserListParams): Promise<Blob> {
|
|||
|
|
return http.get('/admin/users/export', {
|
|||
|
|
params,
|
|||
|
|
responseType: 'blob'
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 4. 核心模块设计
|
|||
|
|
|
|||
|
|
### 4.1 认证模块
|
|||
|
|
|
|||
|
|
#### 4.1.1 认证Store
|
|||
|
|
```typescript
|
|||
|
|
// 认证状态管理
|
|||
|
|
export const useAuthStore = defineStore('auth', () => {
|
|||
|
|
// 状态
|
|||
|
|
const token = ref<string>('')
|
|||
|
|
const userInfo = ref<AdminUser | null>(null)
|
|||
|
|
const permissions = ref<string[]>([])
|
|||
|
|
const roles = ref<string[]>([])
|
|||
|
|
|
|||
|
|
// Getters
|
|||
|
|
const isLogin = computed(() => !!token.value)
|
|||
|
|
const hasPermission = computed(() => (permission: string) =>
|
|||
|
|
permissions.value.includes(permission)
|
|||
|
|
)
|
|||
|
|
const hasRole = computed(() => (role: string) =>
|
|||
|
|
roles.value.includes(role)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// Actions
|
|||
|
|
const login = async (credentials: LoginCredentials) => {
|
|||
|
|
try {
|
|||
|
|
const response = await authApi.login(credentials)
|
|||
|
|
|
|||
|
|
token.value = response.token
|
|||
|
|
userInfo.value = response.userInfo
|
|||
|
|
permissions.value = response.permissions
|
|||
|
|
roles.value = response.roles
|
|||
|
|
|
|||
|
|
// 保存到本地存储
|
|||
|
|
localStorage.setItem('admin_token', response.token)
|
|||
|
|
localStorage.setItem('admin_user', JSON.stringify(response.userInfo))
|
|||
|
|
|
|||
|
|
return response
|
|||
|
|
} catch (error) {
|
|||
|
|
throw error
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const logout = async () => {
|
|||
|
|
try {
|
|||
|
|
await authApi.logout()
|
|||
|
|
} finally {
|
|||
|
|
// 清除状态
|
|||
|
|
token.value = ''
|
|||
|
|
userInfo.value = null
|
|||
|
|
permissions.value = []
|
|||
|
|
roles.value = []
|
|||
|
|
|
|||
|
|
// 清除本地存储
|
|||
|
|
localStorage.removeItem('admin_token')
|
|||
|
|
localStorage.removeItem('admin_user')
|
|||
|
|
|
|||
|
|
// 跳转到登录页
|
|||
|
|
router.push('/login')
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const refreshToken = async () => {
|
|||
|
|
try {
|
|||
|
|
const response = await authApi.refreshToken()
|
|||
|
|
token.value = response.token
|
|||
|
|
localStorage.setItem('admin_token', response.token)
|
|||
|
|
return response.token
|
|||
|
|
} catch (error) {
|
|||
|
|
await logout()
|
|||
|
|
throw error
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const initAuth = () => {
|
|||
|
|
const savedToken = localStorage.getItem('admin_token')
|
|||
|
|
const savedUser = localStorage.getItem('admin_user')
|
|||
|
|
|
|||
|
|
if (savedToken && savedUser) {
|
|||
|
|
token.value = savedToken
|
|||
|
|
userInfo.value = JSON.parse(savedUser)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
// State
|
|||
|
|
token: readonly(token),
|
|||
|
|
userInfo: readonly(userInfo),
|
|||
|
|
permissions: readonly(permissions),
|
|||
|
|
roles: readonly(roles),
|
|||
|
|
|
|||
|
|
// Getters
|
|||
|
|
isLogin,
|
|||
|
|
hasPermission,
|
|||
|
|
hasRole,
|
|||
|
|
|
|||
|
|
// Actions
|
|||
|
|
login,
|
|||
|
|
logout,
|
|||
|
|
refreshToken,
|
|||
|
|
initAuth
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 4.1.2 路由守卫
|
|||
|
|
```typescript
|
|||
|
|
// 路由守卫
|
|||
|
|
import { useAuthStore } from '@/stores/modules/auth'
|
|||
|
|
|
|||
|
|
export function setupRouterGuards(router: Router) {
|
|||
|
|
// 全局前置守卫
|
|||
|
|
router.beforeEach(async (to, from, next) => {
|
|||
|
|
const authStore = useAuthStore()
|
|||
|
|
|
|||
|
|
// 白名单路由
|
|||
|
|
const whiteList = ['/login', '/404', '/403']
|
|||
|
|
|
|||
|
|
if (whiteList.includes(to.path)) {
|
|||
|
|
next()
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检查登录状态
|
|||
|
|
if (!authStore.isLogin) {
|
|||
|
|
next('/login')
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检查权限
|
|||
|
|
if (to.meta.permission && !authStore.hasPermission(to.meta.permission)) {
|
|||
|
|
next('/403')
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检查角色
|
|||
|
|
if (to.meta.roles && !to.meta.roles.some(role => authStore.hasRole(role))) {
|
|||
|
|
next('/403')
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
next()
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 全局后置守卫
|
|||
|
|
router.afterEach((to) => {
|
|||
|
|
// 设置页面标题
|
|||
|
|
document.title = `${to.meta.title || '管理后台'} - 解班客`
|
|||
|
|
|
|||
|
|
// 页面访问统计
|
|||
|
|
// analytics.trackPageView(to.path)
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4.2 表格组件
|
|||
|
|
|
|||
|
|
#### 4.2.1 通用表格组件
|
|||
|
|
```vue
|
|||
|
|
<!-- DataTable.vue -->
|
|||
|
|
<template>
|
|||
|
|
<div class="data-table">
|
|||
|
|
<el-table
|
|||
|
|
:data="data"
|
|||
|
|
:loading="loading"
|
|||
|
|
v-bind="$attrs"
|
|||
|
|
@selection-change="handleSelectionChange"
|
|||
|
|
@sort-change="handleSortChange"
|
|||
|
|
>
|
|||
|
|
<el-table-column
|
|||
|
|
v-if="showSelection"
|
|||
|
|
type="selection"
|
|||
|
|
width="55"
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<el-table-column
|
|||
|
|
v-for="column in columns"
|
|||
|
|
:key="column.prop"
|
|||
|
|
v-bind="column"
|
|||
|
|
>
|
|||
|
|
<template #default="scope" v-if="column.slot">
|
|||
|
|
<slot :name="column.slot" :row="scope.row" :index="scope.$index" />
|
|||
|
|
</template>
|
|||
|
|
</el-table-column>
|
|||
|
|
|
|||
|
|
<el-table-column
|
|||
|
|
v-if="showActions"
|
|||
|
|
label="操作"
|
|||
|
|
:width="actionWidth"
|
|||
|
|
fixed="right"
|
|||
|
|
>
|
|||
|
|
<template #default="scope">
|
|||
|
|
<slot name="actions" :row="scope.row" :index="scope.$index">
|
|||
|
|
<el-button
|
|||
|
|
v-for="action in actions"
|
|||
|
|
:key="action.key"
|
|||
|
|
:type="action.type"
|
|||
|
|
:size="action.size || 'small'"
|
|||
|
|
@click="handleAction(action.key, scope.row)"
|
|||
|
|
>
|
|||
|
|
{{ action.label }}
|
|||
|
|
</el-button>
|
|||
|
|
</slot>
|
|||
|
|
</template>
|
|||
|
|
</el-table-column>
|
|||
|
|
</el-table>
|
|||
|
|
|
|||
|
|
<div class="table-footer" v-if="showPagination">
|
|||
|
|
<el-pagination
|
|||
|
|
v-model:current-page="currentPage"
|
|||
|
|
v-model:page-size="pageSize"
|
|||
|
|
:total="total"
|
|||
|
|
:page-sizes="pageSizes"
|
|||
|
|
layout="total, sizes, prev, pager, next, jumper"
|
|||
|
|
@current-change="handlePageChange"
|
|||
|
|
@size-change="handleSizeChange"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
interface Column {
|
|||
|
|
prop: string
|
|||
|
|
label: string
|
|||
|
|
width?: number | string
|
|||
|
|
minWidth?: number | string
|
|||
|
|
sortable?: boolean
|
|||
|
|
slot?: string
|
|||
|
|
formatter?: (row: any, column: any, cellValue: any) => string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface Action {
|
|||
|
|
key: string
|
|||
|
|
label: string
|
|||
|
|
type?: 'primary' | 'success' | 'warning' | 'danger'
|
|||
|
|
size?: 'large' | 'default' | 'small'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface Props {
|
|||
|
|
data: any[]
|
|||
|
|
columns: Column[]
|
|||
|
|
loading?: boolean
|
|||
|
|
showSelection?: boolean
|
|||
|
|
showActions?: boolean
|
|||
|
|
actions?: Action[]
|
|||
|
|
actionWidth?: number
|
|||
|
|
showPagination?: boolean
|
|||
|
|
total?: number
|
|||
|
|
pageSizes?: number[]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const props = withDefaults(defineProps<Props>(), {
|
|||
|
|
loading: false,
|
|||
|
|
showSelection: false,
|
|||
|
|
showActions: true,
|
|||
|
|
actionWidth: 200,
|
|||
|
|
showPagination: true,
|
|||
|
|
pageSizes: () => [10, 20, 50, 100]
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const emit = defineEmits<{
|
|||
|
|
selectionChange: [selection: any[]]
|
|||
|
|
sortChange: [sort: { prop: string; order: string }]
|
|||
|
|
action: [key: string, row: any]
|
|||
|
|
pageChange: [page: number]
|
|||
|
|
sizeChange: [size: number]
|
|||
|
|
}>()
|
|||
|
|
|
|||
|
|
const currentPage = ref(1)
|
|||
|
|
const pageSize = ref(20)
|
|||
|
|
|
|||
|
|
const handleSelectionChange = (selection: any[]) => {
|
|||
|
|
emit('selectionChange', selection)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleSortChange = (sort: { prop: string; order: string }) => {
|
|||
|
|
emit('sortChange', sort)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleAction = (key: string, row: any) => {
|
|||
|
|
emit('action', key, row)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handlePageChange = (page: number) => {
|
|||
|
|
emit('pageChange', page)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleSizeChange = (size: number) => {
|
|||
|
|
emit('sizeChange', size)
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 4.2.2 表格Hook
|
|||
|
|
```typescript
|
|||
|
|
// useTable Hook
|
|||
|
|
export function useTable<T = any>(
|
|||
|
|
api: (params: any) => Promise<{ list: T[]; total: number }>,
|
|||
|
|
options: {
|
|||
|
|
immediate?: boolean
|
|||
|
|
defaultParams?: Record<string, any>
|
|||
|
|
defaultPageSize?: number
|
|||
|
|
} = {}
|
|||
|
|
) {
|
|||
|
|
const { immediate = true, defaultParams = {}, defaultPageSize = 20 } = options
|
|||
|
|
|
|||
|
|
// 状态
|
|||
|
|
const state = reactive({
|
|||
|
|
data: [] as T[],
|
|||
|
|
loading: false,
|
|||
|
|
total: 0,
|
|||
|
|
currentPage: 1,
|
|||
|
|
pageSize: defaultPageSize,
|
|||
|
|
searchParams: { ...defaultParams },
|
|||
|
|
selectedRows: [] as T[]
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 加载数据
|
|||
|
|
const loadData = async (resetPage = false) => {
|
|||
|
|
if (resetPage) {
|
|||
|
|
state.currentPage = 1
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
state.loading = true
|
|||
|
|
try {
|
|||
|
|
const params = {
|
|||
|
|
page: state.currentPage,
|
|||
|
|
pageSize: state.pageSize,
|
|||
|
|
...state.searchParams
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = await api(params)
|
|||
|
|
state.data = result.list
|
|||
|
|
state.total = result.total
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('加载数据失败:', error)
|
|||
|
|
} finally {
|
|||
|
|
state.loading = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 搜索
|
|||
|
|
const search = (params: Record<string, any>) => {
|
|||
|
|
state.searchParams = { ...defaultParams, ...params }
|
|||
|
|
loadData(true)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 重置搜索
|
|||
|
|
const resetSearch = () => {
|
|||
|
|
state.searchParams = { ...defaultParams }
|
|||
|
|
loadData(true)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 刷新
|
|||
|
|
const refresh = () => {
|
|||
|
|
loadData()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 分页变化
|
|||
|
|
const handlePageChange = (page: number) => {
|
|||
|
|
state.currentPage = page
|
|||
|
|
loadData()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 页面大小变化
|
|||
|
|
const handleSizeChange = (size: number) => {
|
|||
|
|
state.pageSize = size
|
|||
|
|
loadData(true)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 选择变化
|
|||
|
|
const handleSelectionChange = (selection: T[]) => {
|
|||
|
|
state.selectedRows = selection
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 初始化
|
|||
|
|
if (immediate) {
|
|||
|
|
onMounted(() => {
|
|||
|
|
loadData()
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
state: readonly(state),
|
|||
|
|
loadData,
|
|||
|
|
search,
|
|||
|
|
resetSearch,
|
|||
|
|
refresh,
|
|||
|
|
handlePageChange,
|
|||
|
|
handleSizeChange,
|
|||
|
|
handleSelectionChange
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4.3 表单组件
|
|||
|
|
|
|||
|
|
#### 4.3.1 动态表单组件
|
|||
|
|
```vue
|
|||
|
|
<!-- DynamicForm.vue -->
|
|||
|
|
<template>
|
|||
|
|
<el-form
|
|||
|
|
ref="formRef"
|
|||
|
|
:model="formData"
|
|||
|
|
:rules="formRules"
|
|||
|
|
v-bind="$attrs"
|
|||
|
|
>
|
|||
|
|
<el-form-item
|
|||
|
|
v-for="field in fields"
|
|||
|
|
:key="field.prop"
|
|||
|
|
:label="field.label"
|
|||
|
|
:prop="field.prop"
|
|||
|
|
:required="field.required"
|
|||
|
|
>
|
|||
|
|
<!-- 输入框 -->
|
|||
|
|
<el-input
|
|||
|
|
v-if="field.type === 'input'"
|
|||
|
|
v-model="formData[field.prop]"
|
|||
|
|
v-bind="field.attrs"
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<!-- 数字输入框 -->
|
|||
|
|
<el-input-number
|
|||
|
|
v-else-if="field.type === 'number'"
|
|||
|
|
v-model="formData[field.prop]"
|
|||
|
|
v-bind="field.attrs"
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<!-- 选择器 -->
|
|||
|
|
<el-select
|
|||
|
|
v-else-if="field.type === 'select'"
|
|||
|
|
v-model="formData[field.prop]"
|
|||
|
|
v-bind="field.attrs"
|
|||
|
|
>
|
|||
|
|
<el-option
|
|||
|
|
v-for="option in field.options"
|
|||
|
|
:key="option.value"
|
|||
|
|
:label="option.label"
|
|||
|
|
:value="option.value"
|
|||
|
|
/>
|
|||
|
|
</el-select>
|
|||
|
|
|
|||
|
|
<!-- 日期选择器 -->
|
|||
|
|
<el-date-picker
|
|||
|
|
v-else-if="field.type === 'date'"
|
|||
|
|
v-model="formData[field.prop]"
|
|||
|
|
v-bind="field.attrs"
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<!-- 开关 -->
|
|||
|
|
<el-switch
|
|||
|
|
v-else-if="field.type === 'switch'"
|
|||
|
|
v-model="formData[field.prop]"
|
|||
|
|
v-bind="field.attrs"
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<!-- 文本域 -->
|
|||
|
|
<el-input
|
|||
|
|
v-else-if="field.type === 'textarea'"
|
|||
|
|
v-model="formData[field.prop]"
|
|||
|
|
type="textarea"
|
|||
|
|
v-bind="field.attrs"
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<!-- 上传组件 -->
|
|||
|
|
<el-upload
|
|||
|
|
v-else-if="field.type === 'upload'"
|
|||
|
|
v-bind="field.attrs"
|
|||
|
|
@success="(response) => handleUploadSuccess(response, field.prop)"
|
|||
|
|
>
|
|||
|
|
<el-button type="primary">点击上传</el-button>
|
|||
|
|
</el-upload>
|
|||
|
|
|
|||
|
|
<!-- 自定义插槽 -->
|
|||
|
|
<slot
|
|||
|
|
v-else-if="field.type === 'slot'"
|
|||
|
|
:name="field.slot"
|
|||
|
|
:field="field"
|
|||
|
|
:value="formData[field.prop]"
|
|||
|
|
@update:value="(value) => formData[field.prop] = value"
|
|||
|
|
/>
|
|||
|
|
</el-form-item>
|
|||
|
|
|
|||
|
|
<el-form-item v-if="showActions">
|
|||
|
|
<slot name="actions" :form-data="formData" :validate="validate">
|
|||
|
|
<el-button type="primary" @click="handleSubmit">
|
|||
|
|
{{ submitText }}
|
|||
|
|
</el-button>
|
|||
|
|
<el-button @click="handleReset">
|
|||
|
|
{{ resetText }}
|
|||
|
|
</el-button>
|
|||
|
|
</slot>
|
|||
|
|
</el-form-item>
|
|||
|
|
</el-form>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
interface FormField {
|
|||
|
|
prop: string
|
|||
|
|
label: string
|
|||
|
|
type: 'input' | 'number' | 'select' | 'date' | 'switch' | 'textarea' | 'upload' | 'slot'
|
|||
|
|
required?: boolean
|
|||
|
|
attrs?: Record<string, any>
|
|||
|
|
options?: { label: string; value: any }[]
|
|||
|
|
slot?: string
|
|||
|
|
rules?: any[]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface Props {
|
|||
|
|
fields: FormField[]
|
|||
|
|
modelValue: Record<string, any>
|
|||
|
|
showActions?: boolean
|
|||
|
|
submitText?: string
|
|||
|
|
resetText?: string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const props = withDefaults(defineProps<Props>(), {
|
|||
|
|
showActions: true,
|
|||
|
|
submitText: '提交',
|
|||
|
|
resetText: '重置'
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const emit = defineEmits<{
|
|||
|
|
'update:modelValue': [value: Record<string, any>]
|
|||
|
|
submit: [data: Record<string, any>]
|
|||
|
|
reset: []
|
|||
|
|
}>()
|
|||
|
|
|
|||
|
|
const formRef = ref<FormInstance>()
|
|||
|
|
const formData = ref({ ...props.modelValue })
|
|||
|
|
|
|||
|
|
// 监听外部数据变化
|
|||
|
|
watch(() => props.modelValue, (newValue) => {
|
|||
|
|
formData.value = { ...newValue }
|
|||
|
|
}, { deep: true })
|
|||
|
|
|
|||
|
|
// 监听内部数据变化
|
|||
|
|
watch(formData, (newValue) => {
|
|||
|
|
emit('update:modelValue', newValue)
|
|||
|
|
}, { deep: true })
|
|||
|
|
|
|||
|
|
// 生成表单规则
|
|||
|
|
const formRules = computed(() => {
|
|||
|
|
const rules: Record<string, any[]> = {}
|
|||
|
|
|
|||
|
|
props.fields.forEach(field => {
|
|||
|
|
if (field.required || field.rules) {
|
|||
|
|
rules[field.prop] = [
|
|||
|
|
...(field.required ? [{ required: true, message: `请输入${field.label}` }] : []),
|
|||
|
|
...(field.rules || [])
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
return rules
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 验证表单
|
|||
|
|
const validate = async (): Promise<boolean> => {
|
|||
|
|
if (!formRef.value) return false
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await formRef.value.validate()
|
|||
|
|
return true
|
|||
|
|
} catch {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 提交表单
|
|||
|
|
const handleSubmit = async () => {
|
|||
|
|
const isValid = await validate()
|
|||
|
|
if (isValid) {
|
|||
|
|
emit('submit', formData.value)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 重置表单
|
|||
|
|
const handleReset = () => {
|
|||
|
|
formRef.value?.resetFields()
|
|||
|
|
emit('reset')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 上传成功处理
|
|||
|
|
const handleUploadSuccess = (response: any, prop: string) => {
|
|||
|
|
formData.value[prop] = response.url
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 暴露方法
|
|||
|
|
defineExpose({
|
|||
|
|
validate,
|
|||
|
|
resetFields: () => formRef.value?.resetFields()
|
|||
|
|
})
|
|||
|
|
</script>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 4.3.2 表单Hook
|
|||
|
|
```typescript
|
|||
|
|
// useForm Hook
|
|||
|
|
export function useForm<T extends Record<string, any>>(
|
|||
|
|
initialData: T,
|
|||
|
|
options: {
|
|||
|
|
resetAfterSubmit?: boolean
|
|||
|
|
validateOnSubmit?: boolean
|
|||
|
|
} = {}
|
|||
|
|
) {
|
|||
|
|
const { resetAfterSubmit = false, validateOnSubmit = true } = options
|
|||
|
|
|
|||
|
|
// 表单数据
|
|||
|
|
const formData = ref<T>({ ...initialData })
|
|||
|
|
const formRef = ref<FormInstance>()
|
|||
|
|
|
|||
|
|
// 表单状态
|
|||
|
|
const state = reactive({
|
|||
|
|
loading: false,
|
|||
|
|
errors: {} as Record<string, string>
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 重置表单
|
|||
|
|
const resetForm = () => {
|
|||
|
|
formData.value = { ...initialData }
|
|||
|
|
formRef.value?.resetFields()
|
|||
|
|
state.errors = {}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 验证表单
|
|||
|
|
const validateForm = async (): Promise<boolean> => {
|
|||
|
|
if (!formRef.value) return false
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await formRef.value.validate()
|
|||
|
|
state.errors = {}
|
|||
|
|
return true
|
|||
|
|
} catch (errors) {
|
|||
|
|
state.errors = errors as Record<string, string>
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 提交表单
|
|||
|
|
const submitForm = async (
|
|||
|
|
submitFn: (data: T) => Promise<any>
|
|||
|
|
): Promise<any> => {
|
|||
|
|
if (validateOnSubmit) {
|
|||
|
|
const isValid = await validateForm()
|
|||
|
|
if (!isValid) return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
state.loading = true
|
|||
|
|
try {
|
|||
|
|
const result = await submitFn(formData.value)
|
|||
|
|
|
|||
|
|
if (resetAfterSubmit) {
|
|||
|
|
resetForm()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return result
|
|||
|
|
} finally {
|
|||
|
|
state.loading = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 设置字段值
|
|||
|
|
const setFieldValue = <K extends keyof T>(field: K, value: T[K]) => {
|
|||
|
|
formData.value[field] = value
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 设置字段错误
|
|||
|
|
const setFieldError = (field: string, error: string) => {
|
|||
|
|
state.errors[field] = error
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 清除字段错误
|
|||
|
|
const clearFieldError = (field: string) => {
|
|||
|
|
delete state.errors[field]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
formData,
|
|||
|
|
formRef,
|
|||
|
|
state: readonly(state),
|
|||
|
|
resetForm,
|
|||
|
|
validateForm,
|
|||
|
|
submitForm,
|
|||
|
|
setFieldValue,
|
|||
|
|
setFieldError,
|
|||
|
|
clearFieldError
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 5. 权限管理
|
|||
|
|
|
|||
|
|
### 5.1 权限设计
|
|||
|
|
|
|||
|
|
#### 5.1.1 权限模型
|
|||
|
|
```typescript
|
|||
|
|
// 权限类型定义
|
|||
|
|
interface Permission {
|
|||
|
|
id: string
|
|||
|
|
name: string
|
|||
|
|
code: string
|
|||
|
|
type: 'menu' | 'button' | 'api'
|
|||
|
|
resource: string
|
|||
|
|
action: string
|
|||
|
|
description?: string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface Role {
|
|||
|
|
id: string
|
|||
|
|
name: string
|
|||
|
|
code: string
|
|||
|
|
permissions: Permission[]
|
|||
|
|
description?: string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface AdminUser {
|
|||
|
|
id: string
|
|||
|
|
username: string
|
|||
|
|
email: string
|
|||
|
|
roles: Role[]
|
|||
|
|
permissions: Permission[]
|
|||
|
|
status: 'active' | 'inactive'
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 5.1.2 权限指令
|
|||
|
|
```typescript
|
|||
|
|
// 权限指令
|
|||
|
|
import type { App, DirectiveBinding } from 'vue'
|
|||
|
|
import { useAuthStore } from '@/stores/modules/auth'
|
|||
|
|
|
|||
|
|
export function setupPermissionDirective(app: App) {
|
|||
|
|
// v-permission 指令
|
|||
|
|
app.directive('permission', {
|
|||
|
|
mounted(el: HTMLElement, binding: DirectiveBinding) {
|
|||
|
|
const { value } = binding
|
|||
|
|
const authStore = useAuthStore()
|
|||
|
|
|
|||
|
|
if (value && !authStore.hasPermission(value)) {
|
|||
|
|
el.style.display = 'none'
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
updated(el: HTMLElement, binding: DirectiveBinding) {
|
|||
|
|
const { value } = binding
|
|||
|
|
const authStore = useAuthStore()
|
|||
|
|
|
|||
|
|
if (value && !authStore.hasPermission(value)) {
|
|||
|
|
el.style.display = 'none'
|
|||
|
|
} else {
|
|||
|
|
el.style.display = ''
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// v-role 指令
|
|||
|
|
app.directive('role', {
|
|||
|
|
mounted(el: HTMLElement, binding: DirectiveBinding) {
|
|||
|
|
const { value } = binding
|
|||
|
|
const authStore = useAuthStore()
|
|||
|
|
|
|||
|
|
const hasRole = Array.isArray(value)
|
|||
|
|
? value.some(role => authStore.hasRole(role))
|
|||
|
|
: authStore.hasRole(value)
|
|||
|
|
|
|||
|
|
if (!hasRole) {
|
|||
|
|
el.style.display = 'none'
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 5.1.3 权限组件
|
|||
|
|
```vue
|
|||
|
|
<!-- PermissionWrapper.vue -->
|
|||
|
|
<template>
|
|||
|
|
<div v-if="hasAccess">
|
|||
|
|
<slot />
|
|||
|
|
</div>
|
|||
|
|
<div v-else-if="showFallback">
|
|||
|
|
<slot name="fallback">
|
|||
|
|
<div class="no-permission">
|
|||
|
|
<el-empty description="暂无权限访问" />
|
|||
|
|
</div>
|
|||
|
|
</slot>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
interface Props {
|
|||
|
|
permission?: string | string[]
|
|||
|
|
role?: string | string[]
|
|||
|
|
showFallback?: boolean
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const props = withDefaults(defineProps<Props>(), {
|
|||
|
|
showFallback: false
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const authStore = useAuthStore()
|
|||
|
|
|
|||
|
|
const hasAccess = computed(() => {
|
|||
|
|
// 检查权限
|
|||
|
|
if (props.permission) {
|
|||
|
|
const permissions = Array.isArray(props.permission)
|
|||
|
|
? props.permission
|
|||
|
|
: [props.permission]
|
|||
|
|
|
|||
|
|
return permissions.some(p => authStore.hasPermission(p))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检查角色
|
|||
|
|
if (props.role) {
|
|||
|
|
const roles = Array.isArray(props.role)
|
|||
|
|
? props.role
|
|||
|
|
: [props.role]
|
|||
|
|
|
|||
|
|
return roles.some(r => authStore.hasRole(r))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return true
|
|||
|
|
})
|
|||
|
|
</script>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 5.2 菜单权限
|
|||
|
|
|
|||
|
|
#### 5.2.1 动态菜单
|
|||
|
|
```typescript
|
|||
|
|
// 菜单配置
|
|||
|
|
export interface MenuItem {
|
|||
|
|
id: string
|
|||
|
|
title: string
|
|||
|
|
icon?: string
|
|||
|
|
path?: string
|
|||
|
|
permission?: string
|
|||
|
|
roles?: string[]
|
|||
|
|
children?: MenuItem[]
|
|||
|
|
hidden?: boolean
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 菜单数据
|
|||
|
|
export const menuConfig: MenuItem[] = [
|
|||
|
|
{
|
|||
|
|
id: 'dashboard',
|
|||
|
|
title: '仪表板',
|
|||
|
|
icon: 'Dashboard',
|
|||
|
|
path: '/dashboard',
|
|||
|
|
permission: 'dashboard:view'
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'user',
|
|||
|
|
title: '用户管理',
|
|||
|
|
icon: 'User',
|
|||
|
|
permission: 'user:view',
|
|||
|
|
children: [
|
|||
|
|
{
|
|||
|
|
id: 'user-list',
|
|||
|
|
title: '用户列表',
|
|||
|
|
path: '/user/list',
|
|||
|
|
permission: 'user:list'
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'user-role',
|
|||
|
|
title: '角色管理',
|
|||
|
|
path: '/user/role',
|
|||
|
|
permission: 'user:role'
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'travel',
|
|||
|
|
title: '旅行管理',
|
|||
|
|
icon: 'Location',
|
|||
|
|
permission: 'travel:view',
|
|||
|
|
children: [
|
|||
|
|
{
|
|||
|
|
id: 'travel-list',
|
|||
|
|
title: '旅行列表',
|
|||
|
|
path: '/travel/list',
|
|||
|
|
permission: 'travel:list'
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'travel-category',
|
|||
|
|
title: '分类管理',
|
|||
|
|
path: '/travel/category',
|
|||
|
|
permission: 'travel:category'
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
// 菜单过滤
|
|||
|
|
export function filterMenuByPermission(
|
|||
|
|
menus: MenuItem[],
|
|||
|
|
hasPermission: (permission: string) => boolean,
|
|||
|
|
hasRole: (role: string) => boolean
|
|||
|
|
): MenuItem[] {
|
|||
|
|
return menus.filter(menu => {
|
|||
|
|
// 检查权限
|
|||
|
|
if (menu.permission && !hasPermission(menu.permission)) {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检查角色
|
|||
|
|
if (menu.roles && !menu.roles.some(role => hasRole(role))) {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 递归过滤子菜单
|
|||
|
|
if (menu.children) {
|
|||
|
|
menu.children = filterMenuByPermission(menu.children, hasPermission, hasRole)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return true
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 5.2.2 菜单组件
|
|||
|
|
```vue
|
|||
|
|
<!-- SideMenu.vue -->
|
|||
|
|
<template>
|
|||
|
|
<el-menu
|
|||
|
|
:default-active="activeMenu"
|
|||
|
|
:collapse="isCollapse"
|
|||
|
|
:unique-opened="true"
|
|||
|
|
router
|
|||
|
|
>
|
|||
|
|
<menu-item
|
|||
|
|
v-for="menu in filteredMenus"
|
|||
|
|
:key="menu.id"
|
|||
|
|
:menu="menu"
|
|||
|
|
/>
|
|||
|
|
</el-menu>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import MenuItem from './MenuItem.vue'
|
|||
|
|
import { menuConfig, filterMenuByPermission } from '@/config/menu'
|
|||
|
|
|
|||
|
|
interface Props {
|
|||
|
|
isCollapse?: boolean
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
defineProps<Props>()
|
|||
|
|
|
|||
|
|
const route = useRoute()
|
|||
|
|
const authStore = useAuthStore()
|
|||
|
|
|
|||
|
|
// 当前激活菜单
|
|||
|
|
const activeMenu = computed(() => route.path)
|
|||
|
|
|
|||
|
|
// 过滤后的菜单
|
|||
|
|
const filteredMenus = computed(() => {
|
|||
|
|
return filterMenuByPermission(
|
|||
|
|
menuConfig,
|
|||
|
|
authStore.hasPermission,
|
|||
|
|
authStore.hasRole
|
|||
|
|
)
|
|||
|
|
})
|
|||
|
|
</script>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 6. 性能优化
|
|||
|
|
|
|||
|
|
### 6.1 代码分割
|
|||
|
|
|
|||
|
|
#### 6.1.1 路由懒加载
|
|||
|
|
```typescript
|
|||
|
|
// 路由配置
|
|||
|
|
const routes: RouteRecordRaw[] = [
|
|||
|
|
{
|
|||
|
|
path: '/dashboard',
|
|||
|
|
name: 'Dashboard',
|
|||
|
|
component: () => import('@/views/dashboard/index.vue'),
|
|||
|
|
meta: {
|
|||
|
|
title: '仪表板',
|
|||
|
|
permission: 'dashboard:view'
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
path: '/user',
|
|||
|
|
name: 'User',
|
|||
|
|
component: () => import('@/views/user/index.vue'),
|
|||
|
|
meta: {
|
|||
|
|
title: '用户管理',
|
|||
|
|
permission: 'user:view'
|
|||
|
|
},
|
|||
|
|
children: [
|
|||
|
|
{
|
|||
|
|
path: 'list',
|
|||
|
|
name: 'UserList',
|
|||
|
|
component: () => import('@/views/user/list.vue'),
|
|||
|
|
meta: {
|
|||
|
|
title: '用户列表',
|
|||
|
|
permission: 'user:list'
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 6.1.2 组件懒加载
|
|||
|
|
```typescript
|
|||
|
|
// 异步组件
|
|||
|
|
import { defineAsyncComponent } from 'vue'
|
|||
|
|
|
|||
|
|
export const AsyncDataTable = defineAsyncComponent({
|
|||
|
|
loader: () => import('@/components/DataTable.vue'),
|
|||
|
|
loadingComponent: () => h('div', '加载中...'),
|
|||
|
|
errorComponent: () => h('div', '加载失败'),
|
|||
|
|
delay: 200,
|
|||
|
|
timeout: 3000
|
|||
|
|
})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 6.2 缓存优化
|
|||
|
|
|
|||
|
|
#### 6.2.1 组件缓存
|
|||
|
|
```vue
|
|||
|
|
<!-- 页面缓存 -->
|
|||
|
|
<template>
|
|||
|
|
<router-view v-slot="{ Component, route }">
|
|||
|
|
<keep-alive :include="cachedViews">
|
|||
|
|
<component :is="Component" :key="route.path" />
|
|||
|
|
</keep-alive>
|
|||
|
|
</router-view>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
// 缓存的页面组件
|
|||
|
|
const cachedViews = ref(['UserList', 'TravelList'])
|
|||
|
|
</script>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 6.2.2 数据缓存
|
|||
|
|
```typescript
|
|||
|
|
// 数据缓存Hook
|
|||
|
|
export function useCache<T>(
|
|||
|
|
key: string,
|
|||
|
|
fetcher: () => Promise<T>,
|
|||
|
|
options: {
|
|||
|
|
ttl?: number // 缓存时间(毫秒)
|
|||
|
|
staleWhileRevalidate?: boolean // 后台更新
|
|||
|
|
} = {}
|
|||
|
|
) {
|
|||
|
|
const { ttl = 5 * 60 * 1000, staleWhileRevalidate = true } = options
|
|||
|
|
|
|||
|
|
const data = ref<T>()
|
|||
|
|
const loading = ref(false)
|
|||
|
|
const error = ref<Error>()
|
|||
|
|
|
|||
|
|
const cacheKey = `cache_${key}`
|
|||
|
|
|
|||
|
|
// 从缓存获取数据
|
|||
|
|
const getFromCache = (): { data: T; timestamp: number } | null => {
|
|||
|
|
try {
|
|||
|
|
const cached = localStorage.getItem(cacheKey)
|
|||
|
|
return cached ? JSON.parse(cached) : null
|
|||
|
|
} catch {
|
|||
|
|
return null
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 保存到缓存
|
|||
|
|
const saveToCache = (value: T) => {
|
|||
|
|
try {
|
|||
|
|
localStorage.setItem(cacheKey, JSON.stringify({
|
|||
|
|
data: value,
|
|||
|
|
timestamp: Date.now()
|
|||
|
|
}))
|
|||
|
|
} catch {
|
|||
|
|
// 忽略存储错误
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检查缓存是否过期
|
|||
|
|
const isCacheExpired = (timestamp: number): boolean => {
|
|||
|
|
return Date.now() - timestamp > ttl
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 获取数据
|
|||
|
|
const fetchData = async (useCache = true): Promise<T> => {
|
|||
|
|
// 检查缓存
|
|||
|
|
if (useCache) {
|
|||
|
|
const cached = getFromCache()
|
|||
|
|
if (cached && !isCacheExpired(cached.timestamp)) {
|
|||
|
|
data.value = cached.data
|
|||
|
|
|
|||
|
|
// 后台更新
|
|||
|
|
if (staleWhileRevalidate) {
|
|||
|
|
fetchData(false).catch(() => {})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return cached.data
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 获取新数据
|
|||
|
|
loading.value = true
|
|||
|
|
error.value = undefined
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const result = await fetcher()
|
|||
|
|
data.value = result
|
|||
|
|
saveToCache(result)
|
|||
|
|
return result
|
|||
|
|
} catch (err) {
|
|||
|
|
error.value = err as Error
|
|||
|
|
throw err
|
|||
|
|
} finally {
|
|||
|
|
loading.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 清除缓存
|
|||
|
|
const clearCache = () => {
|
|||
|
|
localStorage.removeItem(cacheKey)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
data: readonly(data),
|
|||
|
|
loading: readonly(loading),
|
|||
|
|
error: readonly(error),
|
|||
|
|
fetchData,
|
|||
|
|
clearCache
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 6.3 虚拟滚动
|
|||
|
|
|
|||
|
|
#### 6.3.1 虚拟列表组件
|
|||
|
|
```vue
|
|||
|
|
<!-- VirtualList.vue -->
|
|||
|
|
<template>
|
|||
|
|
<div
|
|||
|
|
ref="containerRef"
|
|||
|
|
class="virtual-list"
|
|||
|
|
:style="{ height: containerHeight + 'px' }"
|
|||
|
|
@scroll="handleScroll"
|
|||
|
|
>
|
|||
|
|
<div
|
|||
|
|
class="virtual-list-phantom"
|
|||
|
|
:style="{ height: totalHeight + 'px' }"
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<div
|
|||
|
|
class="virtual-list-content"
|
|||
|
|
:style="{ transform: `translateY(${offsetY}px)` }"
|
|||
|
|
>
|
|||
|
|
<div
|
|||
|
|
v-for="item in visibleItems"
|
|||
|
|
:key="item.index"
|
|||
|
|
class="virtual-list-item"
|
|||
|
|
:style="{ height: itemHeight + 'px' }"
|
|||
|
|
>
|
|||
|
|
<slot :item="item.data" :index="item.index" />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
interface Props {
|
|||
|
|
items: any[]
|
|||
|
|
itemHeight: number
|
|||
|
|
containerHeight: number
|
|||
|
|
buffer?: number
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const props = withDefaults(defineProps<Props>(), {
|
|||
|
|
buffer: 5
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const containerRef = ref<HTMLElement>()
|
|||
|
|
const scrollTop = ref(0)
|
|||
|
|
|
|||
|
|
// 计算属性
|
|||
|
|
const totalHeight = computed(() => props.items.length * props.itemHeight)
|
|||
|
|
|
|||
|
|
const visibleCount = computed(() =>
|
|||
|
|
Math.ceil(props.containerHeight / props.itemHeight)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const startIndex = computed(() =>
|
|||
|
|
Math.max(0, Math.floor(scrollTop.value / props.itemHeight) - props.buffer)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const endIndex = computed(() =>
|
|||
|
|
Math.min(
|
|||
|
|
props.items.length,
|
|||
|
|
startIndex.value + visibleCount.value + props.buffer * 2
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const visibleItems = computed(() => {
|
|||
|
|
const items = []
|
|||
|
|
for (let i = startIndex.value; i < endIndex.value; i++) {
|
|||
|
|
items.push({
|
|||
|
|
index: i,
|
|||
|
|
data: props.items[i]
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
return items
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const offsetY = computed(() => startIndex.value * props.itemHeight)
|
|||
|
|
|
|||
|
|
// 滚动处理
|
|||
|
|
const handleScroll = (e: Event) => {
|
|||
|
|
const target = e.target as HTMLElement
|
|||
|
|
scrollTop.value = target.scrollTop
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 7. 测试策略
|
|||
|
|
|
|||
|
|
### 7.1 单元测试
|
|||
|
|
|
|||
|
|
#### 7.1.1 组件测试
|
|||
|
|
```typescript
|
|||
|
|
// DataTable.test.ts
|
|||
|
|
import { mount } from '@vue/test-utils'
|
|||
|
|
import { describe, it, expect } from 'vitest'
|
|||
|
|
import DataTable from '@/components/DataTable.vue'
|
|||
|
|
|
|||
|
|
describe('DataTable', () => {
|
|||
|
|
const mockData = [
|
|||
|
|
{ id: 1, name: 'John', age: 25 },
|
|||
|
|
{ id: 2, name: 'Jane', age: 30 }
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
const mockColumns = [
|
|||
|
|
{ prop: 'name', label: '姓名' },
|
|||
|
|
{ prop: 'age', label: '年龄' }
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
it('renders table with data', () => {
|
|||
|
|
const wrapper = mount(DataTable, {
|
|||
|
|
props: {
|
|||
|
|
data: mockData,
|
|||
|
|
columns: mockColumns
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
expect(wrapper.find('.el-table').exists()).toBe(true)
|
|||
|
|
expect(wrapper.findAll('.el-table__row')).toHaveLength(2)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
it('emits selection-change event', async () => {
|
|||
|
|
const wrapper = mount(DataTable, {
|
|||
|
|
props: {
|
|||
|
|
data: mockData,
|
|||
|
|
columns: mockColumns,
|
|||
|
|
showSelection: true
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const checkbox = wrapper.find('.el-checkbox')
|
|||
|
|
await checkbox.trigger('click')
|
|||
|
|
|
|||
|
|
expect(wrapper.emitted('selectionChange')).toBeTruthy()
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 7.1.2 Store测试
|
|||
|
|
```typescript
|
|||
|
|
// userStore.test.ts
|
|||
|
|
import { setActivePinia, createPinia } from 'pinia'
|
|||
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|||
|
|
import { useUserStore } from '@/stores/modules/user'
|
|||
|
|
import { userApi } from '@/services/api/user'
|
|||
|
|
|
|||
|
|
// Mock API
|
|||
|
|
vi.mock('@/services/api/user')
|
|||
|
|
|
|||
|
|
describe('User Store', () => {
|
|||
|
|
beforeEach(() => {
|
|||
|
|
setActivePinia(createPinia())
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
it('loads user list', async () => {
|
|||
|
|
const mockResponse = {
|
|||
|
|
list: [{ id: 1, name: 'John' }],
|
|||
|
|
total: 1
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
vi.mocked(userApi.getList).mockResolvedValue(mockResponse)
|
|||
|
|
|
|||
|
|
const store = useUserStore()
|
|||
|
|
const result = await store.getUserList({ page: 1 })
|
|||
|
|
|
|||
|
|
expect(result).toEqual(mockResponse)
|
|||
|
|
expect(store.userList).toEqual(mockResponse.list)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
it('handles API error', async () => {
|
|||
|
|
vi.mocked(userApi.getList).mockRejectedValue(new Error('API Error'))
|
|||
|
|
|
|||
|
|
const store = useUserStore()
|
|||
|
|
|
|||
|
|
await expect(store.getUserList({ page: 1 })).rejects.toThrow('API Error')
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 7.2 集成测试
|
|||
|
|
|
|||
|
|
#### 7.2.1 页面测试
|
|||
|
|
```typescript
|
|||
|
|
// UserList.test.ts
|
|||
|
|
import { mount } from '@vue/test-utils'
|
|||
|
|
import { createTestingPinia } from '@pinia/testing'
|
|||
|
|
import { describe, it, expect, vi } from 'vitest'
|
|||
|
|
import UserList from '@/views/user/list.vue'
|
|||
|
|
|
|||
|
|
describe('UserList Page', () => {
|
|||
|
|
it('loads and displays user list', async () => {
|
|||
|
|
const wrapper = mount(UserList, {
|
|||
|
|
global: {
|
|||
|
|
plugins: [
|
|||
|
|
createTestingPinia({
|
|||
|
|
createSpy: vi.fn
|
|||
|
|
})
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 等待数据加载
|
|||
|
|
await wrapper.vm.$nextTick()
|
|||
|
|
|
|||
|
|
expect(wrapper.find('.user-list').exists()).toBe(true)
|
|||
|
|
expect(wrapper.find('.data-table').exists()).toBe(true)
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 7.3 E2E测试
|
|||
|
|
|
|||
|
|
#### 7.3.1 用户流程测试
|
|||
|
|
```typescript
|
|||
|
|
// user-management.e2e.ts
|
|||
|
|
import { test, expect } from '@playwright/test'
|
|||
|
|
|
|||
|
|
test.describe('User Management', () => {
|
|||
|
|
test.beforeEach(async ({ page }) => {
|
|||
|
|
// 登录
|
|||
|
|
await page.goto('/login')
|
|||
|
|
await page.fill('[data-testid="username"]', 'admin')
|
|||
|
|
await page.fill('[data-testid="password"]', 'password')
|
|||
|
|
await page.click('[data-testid="login-btn"]')
|
|||
|
|
await page.waitForURL('/dashboard')
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
test('should create new user', async ({ page }) => {
|
|||
|
|
// 导航到用户管理
|
|||
|
|
await page.click('[data-testid="user-menu"]')
|
|||
|
|
await page.click('[data-testid="user-list"]')
|
|||
|
|
|
|||
|
|
// 点击新建用户
|
|||
|
|
await page.click('[data-testid="create-user-btn"]')
|
|||
|
|
|
|||
|
|
// 填写表单
|
|||
|
|
await page.fill('[data-testid="username"]', 'testuser')
|
|||
|
|
await page.fill('[data-testid="email"]', 'test@example.com')
|
|||
|
|
await page.selectOption('[data-testid="role"]', 'user')
|
|||
|
|
|
|||
|
|
// 提交表单
|
|||
|
|
await page.click('[data-testid="submit-btn"]')
|
|||
|
|
|
|||
|
|
// 验证结果
|
|||
|
|
await expect(page.locator('.el-message--success')).toBeVisible()
|
|||
|
|
await expect(page.locator('text=testuser')).toBeVisible()
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 8. 部署配置
|
|||
|
|
|
|||
|
|
### 8.1 构建配置
|
|||
|
|
|
|||
|
|
#### 8.1.1 Vite配置
|
|||
|
|
```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')
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
build: {
|
|||
|
|
target: 'es2015',
|
|||
|
|
outDir: 'dist',
|
|||
|
|
assetsDir: 'assets',
|
|||
|
|
sourcemap: false,
|
|||
|
|
|
|||
|
|
rollupOptions: {
|
|||
|
|
output: {
|
|||
|
|
chunkFileNames: 'js/[name]-[hash].js',
|
|||
|
|
entryFileNames: 'js/[name]-[hash].js',
|
|||
|
|
assetFileNames: '[ext]/[name]-[hash].[ext]',
|
|||
|
|
|
|||
|
|
manualChunks: {
|
|||
|
|
vue: ['vue', 'vue-router', 'pinia'],
|
|||
|
|
element: ['element-plus'],
|
|||
|
|
utils: ['axios', 'dayjs']
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
terserOptions: {
|
|||
|
|
compress: {
|
|||
|
|
drop_console: true,
|
|||
|
|
drop_debugger: true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
server: {
|
|||
|
|
port: 3000,
|
|||
|
|
proxy: {
|
|||
|
|
'/api': {
|
|||
|
|
target: 'http://localhost:8080',
|
|||
|
|
changeOrigin: true,
|
|||
|
|
rewrite: (path) => path.replace(/^\/api/, '')
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 8.1.2 环境配置
|
|||
|
|
```typescript
|
|||
|
|
// .env.development
|
|||
|
|
VITE_APP_TITLE=解班客管理后台
|
|||
|
|
VITE_API_BASE_URL=http://localhost:8080/api
|
|||
|
|
VITE_UPLOAD_URL=http://localhost:8080/upload
|
|||
|
|
|
|||
|
|
// .env.production
|
|||
|
|
VITE_APP_TITLE=解班客管理后台
|
|||
|
|
VITE_API_BASE_URL=https://api.jiebanke.com
|
|||
|
|
VITE_UPLOAD_URL=https://cdn.jiebanke.com/upload
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 8.2 Docker部署
|
|||
|
|
|
|||
|
|
#### 8.2.1 Dockerfile
|
|||
|
|
```dockerfile
|
|||
|
|
# 构建阶段
|
|||
|
|
FROM node:18-alpine as builder
|
|||
|
|
|
|||
|
|
WORKDIR /app
|
|||
|
|
|
|||
|
|
# 复制依赖文件
|
|||
|
|
COPY package*.json ./
|
|||
|
|
RUN npm ci --only=production
|
|||
|
|
|
|||
|
|
# 复制源码
|
|||
|
|
COPY . .
|
|||
|
|
|
|||
|
|
# 构建应用
|
|||
|
|
RUN npm run build
|
|||
|
|
|
|||
|
|
# 生产阶段
|
|||
|
|
FROM nginx:alpine
|
|||
|
|
|
|||
|
|
# 复制构建产物
|
|||
|
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
|||
|
|
|
|||
|
|
# 复制nginx配置
|
|||
|
|
COPY nginx.conf /etc/nginx/nginx.conf
|
|||
|
|
|
|||
|
|
EXPOSE 80
|
|||
|
|
|
|||
|
|
CMD ["nginx", "-g", "daemon off;"]
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 8.2.2 Nginx配置
|
|||
|
|
```nginx
|
|||
|
|
# nginx.conf
|
|||
|
|
server {
|
|||
|
|
listen 80;
|
|||
|
|
server_name localhost;
|
|||
|
|
|
|||
|
|
root /usr/share/nginx/html;
|
|||
|
|
index index.html;
|
|||
|
|
|
|||
|
|
# 静态资源缓存
|
|||
|
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
|||
|
|
expires 1y;
|
|||
|
|
add_header Cache-Control "public, immutable";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# SPA路由支持
|
|||
|
|
location / {
|
|||
|
|
try_files $uri $uri/ /index.html;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# API代理
|
|||
|
|
location /api/ {
|
|||
|
|
proxy_pass http://backend:8080/;
|
|||
|
|
proxy_set_header Host $host;
|
|||
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|||
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|||
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 安全头
|
|||
|
|
add_header X-Frame-Options DENY;
|
|||
|
|
add_header X-Content-Type-Options nosniff;
|
|||
|
|
add_header X-XSS-Protection "1; mode=block";
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 8.3 CI/CD流程
|
|||
|
|
|
|||
|
|
#### 8.3.1 GitHub Actions
|
|||
|
|
```yaml
|
|||
|
|
# .github/workflows/deploy.yml
|
|||
|
|
name: Deploy Admin System
|
|||
|
|
|
|||
|
|
on:
|
|||
|
|
push:
|
|||
|
|
branches: [main]
|
|||
|
|
paths: ['admin-system/**']
|
|||
|
|
|
|||
|
|
jobs:
|
|||
|
|
build-and-deploy:
|
|||
|
|
runs-on: ubuntu-latest
|
|||
|
|
|
|||
|
|
steps:
|
|||
|
|
- name: Checkout code
|
|||
|
|
uses: actions/checkout@v3
|
|||
|
|
|
|||
|
|
- name: Setup Node.js
|
|||
|
|
uses: actions/setup-node@v3
|
|||
|
|
with:
|
|||
|
|
node-version: '18'
|
|||
|
|
cache: 'npm'
|
|||
|
|
cache-dependency-path: admin-system/package-lock.json
|
|||
|
|
|
|||
|
|
- name: Install dependencies
|
|||
|
|
working-directory: admin-system
|
|||
|
|
run: npm ci
|
|||
|
|
|
|||
|
|
- name: Run tests
|
|||
|
|
working-directory: admin-system
|
|||
|
|
run: npm run test
|
|||
|
|
|
|||
|
|
- name: Build application
|
|||
|
|
working-directory: admin-system
|
|||
|
|
run: npm run build
|
|||
|
|
|
|||
|
|
- name: Build Docker image
|
|||
|
|
run: |
|
|||
|
|
docker build -t jiebanke/admin-system:${{ github.sha }} ./admin-system
|
|||
|
|
docker tag jiebanke/admin-system:${{ github.sha }} jiebanke/admin-system:latest
|
|||
|
|
|
|||
|
|
- name: Login to Docker Hub
|
|||
|
|
uses: docker/login-action@v2
|
|||
|
|
with:
|
|||
|
|
username: ${{ secrets.DOCKER_USERNAME }}
|
|||
|
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
|||
|
|
|
|||
|
|
- name: Push Docker image
|
|||
|
|
run: |
|
|||
|
|
docker push jiebanke/admin-system:${{ github.sha }}
|
|||
|
|
docker push jiebanke/admin-system:latest
|
|||
|
|
|
|||
|
|
- name: Deploy to production
|
|||
|
|
uses: appleboy/ssh-action@v0.1.5
|
|||
|
|
with:
|
|||
|
|
host: ${{ secrets.PROD_HOST }}
|
|||
|
|
username: ${{ secrets.PROD_USER }}
|
|||
|
|
key: ${{ secrets.PROD_SSH_KEY }}
|
|||
|
|
script: |
|
|||
|
|
cd /opt/jiebanke
|
|||
|
|
docker-compose pull admin-system
|
|||
|
|
docker-compose up -d admin-system
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 8.3.2 Docker Compose
|
|||
|
|
```yaml
|
|||
|
|
# docker-compose.yml
|
|||
|
|
version: '3.8'
|
|||
|
|
|
|||
|
|
services:
|
|||
|
|
admin-system:
|
|||
|
|
image: jiebanke/admin-system:latest
|
|||
|
|
container_name: jiebanke-admin
|
|||
|
|
ports:
|
|||
|
|
- "3000:80"
|
|||
|
|
environment:
|
|||
|
|
- NODE_ENV=production
|
|||
|
|
depends_on:
|
|||
|
|
- backend
|
|||
|
|
networks:
|
|||
|
|
- jiebanke-network
|
|||
|
|
restart: unless-stopped
|
|||
|
|
|
|||
|
|
backend:
|
|||
|
|
image: jiebanke/backend:latest
|
|||
|
|
container_name: jiebanke-backend
|
|||
|
|
ports:
|
|||
|
|
- "8080:8080"
|
|||
|
|
environment:
|
|||
|
|
- NODE_ENV=production
|
|||
|
|
- DB_HOST=mysql
|
|||
|
|
- REDIS_HOST=redis
|
|||
|
|
depends_on:
|
|||
|
|
- mysql
|
|||
|
|
- redis
|
|||
|
|
networks:
|
|||
|
|
- jiebanke-network
|
|||
|
|
restart: unless-stopped
|
|||
|
|
|
|||
|
|
networks:
|
|||
|
|
jiebanke-network:
|
|||
|
|
driver: bridge
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 9. 监控与分析
|
|||
|
|
|
|||
|
|
### 9.1 性能监控
|
|||
|
|
|
|||
|
|
#### 9.1.1 性能指标收集
|
|||
|
|
```typescript
|
|||
|
|
// 性能监控
|
|||
|
|
export class PerformanceMonitor {
|
|||
|
|
private static instance: PerformanceMonitor
|
|||
|
|
|
|||
|
|
static getInstance(): PerformanceMonitor {
|
|||
|
|
if (!this.instance) {
|
|||
|
|
this.instance = new PerformanceMonitor()
|
|||
|
|
}
|
|||
|
|
return this.instance
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 页面加载性能
|
|||
|
|
measurePageLoad() {
|
|||
|
|
if (typeof window !== 'undefined' && 'performance' in window) {
|
|||
|
|
window.addEventListener('load', () => {
|
|||
|
|
setTimeout(() => {
|
|||
|
|
const perfData = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming
|
|||
|
|
|
|||
|
|
const metrics = {
|
|||
|
|
// 页面加载时间
|
|||
|
|
loadTime: perfData.loadEventEnd - perfData.navigationStart,
|
|||
|
|
// DOM解析时间
|
|||
|
|
domParseTime: perfData.domContentLoadedEventEnd - perfData.navigationStart,
|
|||
|
|
// 首次内容绘制
|
|||
|
|
firstContentfulPaint: this.getFCP(),
|
|||
|
|
// 最大内容绘制
|
|||
|
|
largestContentfulPaint: this.getLCP(),
|
|||
|
|
// 累积布局偏移
|
|||
|
|
cumulativeLayoutShift: this.getCLS()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
this.sendMetrics('page_load', metrics)
|
|||
|
|
}, 0)
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 获取FCP
|
|||
|
|
private getFCP(): number {
|
|||
|
|
const entries = performance.getEntriesByType('paint')
|
|||
|
|
const fcpEntry = entries.find(entry => entry.name === 'first-contentful-paint')
|
|||
|
|
return fcpEntry ? fcpEntry.startTime : 0
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 获取LCP
|
|||
|
|
private getLCP(): number {
|
|||
|
|
return new Promise((resolve) => {
|
|||
|
|
new PerformanceObserver((list) => {
|
|||
|
|
const entries = list.getEntries()
|
|||
|
|
const lastEntry = entries[entries.length - 1]
|
|||
|
|
resolve(lastEntry.startTime)
|
|||
|
|
}).observe({ entryTypes: ['largest-contentful-paint'] })
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 获取CLS
|
|||
|
|
private getCLS(): number {
|
|||
|
|
let clsValue = 0
|
|||
|
|
new PerformanceObserver((list) => {
|
|||
|
|
for (const entry of list.getEntries()) {
|
|||
|
|
if (!entry.hadRecentInput) {
|
|||
|
|
clsValue += entry.value
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}).observe({ entryTypes: ['layout-shift'] })
|
|||
|
|
return clsValue
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 发送指标数据
|
|||
|
|
private sendMetrics(type: string, data: any) {
|
|||
|
|
// 发送到监控服务
|
|||
|
|
fetch('/api/metrics', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {
|
|||
|
|
'Content-Type': 'application/json'
|
|||
|
|
},
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
type,
|
|||
|
|
data,
|
|||
|
|
timestamp: Date.now(),
|
|||
|
|
userAgent: navigator.userAgent,
|
|||
|
|
url: location.href
|
|||
|
|
})
|
|||
|
|
}).catch(console.error)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 9.1.2 错误监控
|
|||
|
|
```typescript
|
|||
|
|
// 错误监控
|
|||
|
|
export class ErrorMonitor {
|
|||
|
|
private static instance: ErrorMonitor
|
|||
|
|
|
|||
|
|
static getInstance(): ErrorMonitor {
|
|||
|
|
if (!this.instance) {
|
|||
|
|
this.instance = new ErrorMonitor()
|
|||
|
|
}
|
|||
|
|
return this.instance
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
init() {
|
|||
|
|
// JavaScript错误
|
|||
|
|
window.addEventListener('error', (event) => {
|
|||
|
|
this.handleError({
|
|||
|
|
type: 'javascript',
|
|||
|
|
message: event.message,
|
|||
|
|
filename: event.filename,
|
|||
|
|
lineno: event.lineno,
|
|||
|
|
colno: event.colno,
|
|||
|
|
stack: event.error?.stack
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Promise错误
|
|||
|
|
window.addEventListener('unhandledrejection', (event) => {
|
|||
|
|
this.handleError({
|
|||
|
|
type: 'promise',
|
|||
|
|
message: event.reason?.message || 'Unhandled Promise Rejection',
|
|||
|
|
stack: event.reason?.stack
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Vue错误处理
|
|||
|
|
app.config.errorHandler = (err, instance, info) => {
|
|||
|
|
this.handleError({
|
|||
|
|
type: 'vue',
|
|||
|
|
message: err.message,
|
|||
|
|
stack: err.stack,
|
|||
|
|
componentName: instance?.$options.name,
|
|||
|
|
info
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private handleError(error: any) {
|
|||
|
|
console.error('Error caught:', error)
|
|||
|
|
|
|||
|
|
// 发送错误报告
|
|||
|
|
fetch('/api/errors', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {
|
|||
|
|
'Content-Type': 'application/json'
|
|||
|
|
},
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
...error,
|
|||
|
|
timestamp: Date.now(),
|
|||
|
|
userAgent: navigator.userAgent,
|
|||
|
|
url: location.href,
|
|||
|
|
userId: this.getCurrentUserId()
|
|||
|
|
})
|
|||
|
|
}).catch(console.error)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private getCurrentUserId(): string | null {
|
|||
|
|
const authStore = useAuthStore()
|
|||
|
|
return authStore.userInfo?.id || null
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 9.2 用户行为分析
|
|||
|
|
|
|||
|
|
#### 9.2.1 埋点系统
|
|||
|
|
```typescript
|
|||
|
|
// 埋点系统
|
|||
|
|
export class Analytics {
|
|||
|
|
private static instance: Analytics
|
|||
|
|
private queue: any[] = []
|
|||
|
|
private isInitialized = false
|
|||
|
|
|
|||
|
|
static getInstance(): Analytics {
|
|||
|
|
if (!this.instance) {
|
|||
|
|
this.instance = new Analytics()
|
|||
|
|
}
|
|||
|
|
return this.instance
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
init(config: { apiUrl: string; appId: string }) {
|
|||
|
|
this.isInitialized = true
|
|||
|
|
|
|||
|
|
// 发送队列中的事件
|
|||
|
|
this.queue.forEach(event => this.sendEvent(event))
|
|||
|
|
this.queue = []
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 页面访问
|
|||
|
|
trackPageView(path: string, title?: string) {
|
|||
|
|
this.track('page_view', {
|
|||
|
|
path,
|
|||
|
|
title,
|
|||
|
|
referrer: document.referrer,
|
|||
|
|
timestamp: Date.now()
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 用户行为
|
|||
|
|
trackEvent(action: string, category: string, label?: string, value?: number) {
|
|||
|
|
this.track('user_action', {
|
|||
|
|
action,
|
|||
|
|
category,
|
|||
|
|
label,
|
|||
|
|
value,
|
|||
|
|
timestamp: Date.now()
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 业务事件
|
|||
|
|
trackBusiness(event: string, properties: Record<string, any>) {
|
|||
|
|
this.track('business_event', {
|
|||
|
|
event,
|
|||
|
|
properties,
|
|||
|
|
timestamp: Date.now()
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private track(type: string, data: any) {
|
|||
|
|
const event = {
|
|||
|
|
type,
|
|||
|
|
data,
|
|||
|
|
sessionId: this.getSessionId(),
|
|||
|
|
userId: this.getUserId(),
|
|||
|
|
deviceInfo: this.getDeviceInfo()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (this.isInitialized) {
|
|||
|
|
this.sendEvent(event)
|
|||
|
|
} else {
|
|||
|
|
this.queue.push(event)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private sendEvent(event: any) {
|
|||
|
|
fetch('/api/analytics', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {
|
|||
|
|
'Content-Type': 'application/json'
|
|||
|
|
},
|
|||
|
|
body: JSON.stringify(event)
|
|||
|
|
}).catch(console.error)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private getSessionId(): string {
|
|||
|
|
let sessionId = sessionStorage.getItem('analytics_session_id')
|
|||
|
|
if (!sessionId) {
|
|||
|
|
sessionId = this.generateId()
|
|||
|
|
sessionStorage.setItem('analytics_session_id', sessionId)
|
|||
|
|
}
|
|||
|
|
return sessionId
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private getUserId(): string | null {
|
|||
|
|
const authStore = useAuthStore()
|
|||
|
|
return authStore.userInfo?.id || null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private getDeviceInfo() {
|
|||
|
|
return {
|
|||
|
|
userAgent: navigator.userAgent,
|
|||
|
|
language: navigator.language,
|
|||
|
|
platform: navigator.platform,
|
|||
|
|
screenResolution: `${screen.width}x${screen.height}`,
|
|||
|
|
viewportSize: `${window.innerWidth}x${window.innerHeight}`
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private generateId(): string {
|
|||
|
|
return Math.random().toString(36).substr(2, 9)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 10. 总结
|
|||
|
|
|
|||
|
|
### 10.1 架构优势
|
|||
|
|
|
|||
|
|
1. **现代化技术栈**
|
|||
|
|
- Vue 3 + TypeScript提供类型安全和开发体验
|
|||
|
|
- Vite构建工具提供极快的开发和构建速度
|
|||
|
|
- Element Plus提供丰富的UI组件
|
|||
|
|
|
|||
|
|
2. **组件化设计**
|
|||
|
|
- 高度复用的组件库
|
|||
|
|
- 清晰的组件层次结构
|
|||
|
|
- 统一的设计规范
|
|||
|
|
|
|||
|
|
3. **状态管理**
|
|||
|
|
- Pinia提供现代化的状态管理
|
|||
|
|
- 模块化的Store设计
|
|||
|
|
- TypeScript完美支持
|
|||
|
|
|
|||
|
|
4. **权限控制**
|
|||
|
|
- 细粒度的权限管理
|
|||
|
|
- 动态菜单和路由
|
|||
|
|
- 多角色支持
|
|||
|
|
|
|||
|
|
### 10.2 扩展性设计
|
|||
|
|
|
|||
|
|
1. **模块化架构**
|
|||
|
|
- 清晰的模块边界
|
|||
|
|
- 松耦合的组件设计
|
|||
|
|
- 易于扩展新功能
|
|||
|
|
|
|||
|
|
2. **插件化支持**
|
|||
|
|
- 支持第三方插件
|
|||
|
|
- 可配置的功能模块
|
|||
|
|
- 灵活的扩展机制
|
|||
|
|
|
|||
|
|
3. **国际化支持**
|
|||
|
|
- 多语言切换
|
|||
|
|
- 本地化配置
|
|||
|
|
- 文化适配
|
|||
|
|
|
|||
|
|
### 10.3 运维保障
|
|||
|
|
|
|||
|
|
1. **监控体系**
|
|||
|
|
- 性能监控
|
|||
|
|
- 错误监控
|
|||
|
|
- 用户行为分析
|
|||
|
|
|
|||
|
|
2. **部署自动化**
|
|||
|
|
- CI/CD流程
|
|||
|
|
- Docker容器化
|
|||
|
|
- 蓝绿部署
|
|||
|
|
|
|||
|
|
3. **安全保障**
|
|||
|
|
- 权限控制
|
|||
|
|
- 数据加密
|
|||
|
|
- 安全头配置
|
|||
|
|
|
|||
|
|
### 10.4 持续改进
|
|||
|
|
|
|||
|
|
1. **性能优化**
|
|||
|
|
- 代码分割
|
|||
|
|
- 懒加载
|
|||
|
|
- 缓存策略
|
|||
|
|
|
|||
|
|
2. **用户体验**
|
|||
|
|
- 响应式设计
|
|||
|
|
- 交互优化
|
|||
|
|
- 无障碍支持
|
|||
|
|
|
|||
|
|
3. **开发效率**
|
|||
|
|
- 代码规范
|
|||
|
|
- 自动化测试
|
|||
|
|
- 开发工具链
|
|||
|
|
|
|||
|
|
通过以上架构设计,解班客管理后台将具备高性能、高可用、易维护的特点,为运营团队提供强大的管理工具,支撑业务的快速发展。
|