mirror of
https://gitee.com/mirrors/AllinSSL.git
synced 2026-03-29 00:33:25 +08:00
【修复】条件节点前fromNodeId传值问题
【修复】部署参数默认错误问题 【测设】部分项目代码结构 【同步】前端项目代码
This commit is contained in:
BIN
frontend/packages/vue/hooks/.DS_Store
vendored
Normal file
BIN
frontend/packages/vue/hooks/.DS_Store
vendored
Normal file
Binary file not shown.
284
frontend/packages/vue/hooks/docs/useAxios.md
Normal file
284
frontend/packages/vue/hooks/docs/useAxios.md
Normal file
@@ -0,0 +1,284 @@
|
||||
# useAxios 使用文档
|
||||
|
||||
## 基本介绍
|
||||
|
||||
`useAxios` 是一个基于 Vue 3 的 Axios 封装钩子函数,它提供了一种简洁的方式来管理 HTTP 请求。通过这个钩子函数,你可以轻松地处理请求状态、加载状态、错误处理、请求取消等功能。
|
||||
|
||||
## 核心函数
|
||||
|
||||
### useAxios
|
||||
|
||||
```typescript
|
||||
function useAxios<T = unknown, Z = Record<string, unknown>>(instance: HttpClient): useAxiosReturn<T, Z>
|
||||
```
|
||||
|
||||
**参数**:
|
||||
|
||||
- `instance`: HttpClient 实例,用于发送 HTTP 请求
|
||||
|
||||
**返回值**:
|
||||
包含以下属性和方法的对象:
|
||||
|
||||
**状态属性**:
|
||||
|
||||
- `loadingMask`: 加载遮罩配置,类型为 `Ref<{ status: boolean } & LoadingMaskOptions>`
|
||||
- `message`: 是否显示响应消息,类型为 `Ref<boolean>`
|
||||
- `dialog`: 确认框配置,类型为 `Ref<{ status: boolean } & CustomDialogOptions>`
|
||||
- `loading`: 是否正在加载,类型为 `Ref<boolean>`
|
||||
- `error`: 错误信息,类型为 `ShallowRef<Error | null | string>`
|
||||
- `response`: 原始响应对象,类型为 `ShallowRef<AxiosResponse<T> | null>`
|
||||
- `data`: 响应数据,类型为 `Ref<T>`
|
||||
- `defaultData`: 默认数据,类型为 `Ref<T>`
|
||||
- `statusCode`: HTTP 状态码,类型为 `ComputedRef<HttpStatusCode | null>`
|
||||
- `aborted`: 是否被中断,类型为 `Ref<boolean>`
|
||||
- `urlRef`: 请求 URL,类型为 `Ref<string>`
|
||||
- `paramsRef`: 请求参数,类型为 `Ref<Z>`
|
||||
|
||||
**方法**:
|
||||
|
||||
- `execute(url: string, params?: Z)`: 执行请求,返回 `Promise<T>`
|
||||
- `setParams(params: Z)`: 设置请求参数并执行请求,返回 `Promise<T>`
|
||||
- `setUrl(url: string, params?: Z)`: 设置请求 URL 和参数并执行请求,返回 `Promise<T>`
|
||||
- `cancel(url: string)`: 取消特定请求
|
||||
- `cancelAll()`: 取消所有请求
|
||||
- `start(params?: Z)`: 使用当前 URL 和参数重新发起请求,返回 `Promise<T>`
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 基本用法
|
||||
|
||||
```typescript
|
||||
import { useAxios, HttpClient } from '@baota/hooks/axios'
|
||||
|
||||
// 创建 HTTP 客户端实例
|
||||
const httpClient = new HttpClient({
|
||||
baseURL: 'https://api.example.com',
|
||||
})
|
||||
|
||||
// 创建请求钩子
|
||||
const api = useAxios<UserData, UserParams>(httpClient)
|
||||
|
||||
// 发送请求
|
||||
const fetchUser = async (userId: string) => {
|
||||
try {
|
||||
const result = await api.execute('/user/info', { userId })
|
||||
console.log('用户数据:', result)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('获取用户数据失败:', error)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 配置加载遮罩
|
||||
|
||||
```typescript
|
||||
import { useAxios, HttpClient } from '@baota/hooks/axios'
|
||||
|
||||
const httpClient = new HttpClient({
|
||||
baseURL: 'https://api.example.com',
|
||||
})
|
||||
|
||||
const api = useAxios(httpClient)
|
||||
|
||||
// 配置加载遮罩
|
||||
api.loadingMask.value = {
|
||||
status: true, // 启用加载遮罩
|
||||
text: '正在加载数据,请稍候...', // 自定义加载文本
|
||||
}
|
||||
|
||||
// 发送请求时会自动显示加载遮罩
|
||||
api.execute('/data/list', { page: 1 })
|
||||
```
|
||||
|
||||
### 配置确认对话框
|
||||
|
||||
```typescript
|
||||
import { useAxios, HttpClient } from '@baota/hooks/axios'
|
||||
|
||||
const httpClient = new HttpClient({
|
||||
baseURL: 'https://api.example.com',
|
||||
})
|
||||
|
||||
const api = useAxios(httpClient)
|
||||
|
||||
// 配置确认对话框
|
||||
api.dialog.value = {
|
||||
status: true, // 启用确认对话框
|
||||
title: '确认操作',
|
||||
content: '确定要执行此操作吗?',
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
}
|
||||
|
||||
// 发送请求前会先显示确认对话框
|
||||
api.execute('/user/delete', { userId: '123' })
|
||||
```
|
||||
|
||||
### 启用响应消息提示
|
||||
|
||||
```typescript
|
||||
import { useAxios, HttpClient } from '@baota/hooks/axios'
|
||||
|
||||
const httpClient = new HttpClient({
|
||||
baseURL: 'https://api.example.com',
|
||||
})
|
||||
|
||||
const api = useAxios(httpClient)
|
||||
|
||||
// 启用响应消息提示
|
||||
api.message.value = true
|
||||
|
||||
// 发送请求后会自动显示响应中的消息
|
||||
// 假设响应格式为 { status: boolean, message: string, data: any }
|
||||
api.execute('/user/update', { userId: '123', name: '张三' })
|
||||
```
|
||||
|
||||
### 取消请求
|
||||
|
||||
```typescript
|
||||
import { useAxios, HttpClient } from '@baota/hooks/axios'
|
||||
|
||||
const httpClient = new HttpClient({
|
||||
baseURL: 'https://api.example.com',
|
||||
})
|
||||
|
||||
const api = useAxios(httpClient)
|
||||
|
||||
// 发送请求
|
||||
const fetchData = () => {
|
||||
api.execute('/data/large-file')
|
||||
}
|
||||
|
||||
// 取消特定请求
|
||||
const cancelFetch = () => {
|
||||
api.cancel('/data/large-file')
|
||||
}
|
||||
|
||||
// 取消所有请求
|
||||
const cancelAllRequests = () => {
|
||||
api.cancelAll()
|
||||
}
|
||||
```
|
||||
|
||||
### 重新发送请求
|
||||
|
||||
```typescript
|
||||
import { useAxios, HttpClient } from '@baota/hooks/axios'
|
||||
|
||||
const httpClient = new HttpClient({
|
||||
baseURL: 'https://api.example.com',
|
||||
})
|
||||
|
||||
const api = useAxios(httpClient)
|
||||
|
||||
// 首次发送请求
|
||||
api.execute('/data/list', { page: 1 })
|
||||
|
||||
// 更新参数并重新发送请求
|
||||
const nextPage = () => {
|
||||
api.setParams({ page: api.paramsRef.value.page + 1 })
|
||||
}
|
||||
|
||||
// 使用当前参数重新发送请求
|
||||
const refresh = () => {
|
||||
api.start()
|
||||
}
|
||||
```
|
||||
|
||||
### 监听请求状态
|
||||
|
||||
```typescript
|
||||
import { useAxios, HttpClient } from '@baota/hooks/axios'
|
||||
import { watch } from 'vue'
|
||||
|
||||
const httpClient = new HttpClient({
|
||||
baseURL: 'https://api.example.com',
|
||||
})
|
||||
|
||||
const api = useAxios(httpClient)
|
||||
|
||||
// 监听加载状态
|
||||
watch(api.loading, (isLoading) => {
|
||||
console.log('加载状态:', isLoading)
|
||||
})
|
||||
|
||||
// 监听错误状态
|
||||
watch(api.error, (error) => {
|
||||
if (error) {
|
||||
console.error('请求错误:', error)
|
||||
}
|
||||
})
|
||||
|
||||
// 监听响应数据
|
||||
watch(api.data, (data) => {
|
||||
console.log('响应数据:', data)
|
||||
})
|
||||
```
|
||||
|
||||
## 高级用法
|
||||
|
||||
### 设置默认数据
|
||||
|
||||
```typescript
|
||||
import { useAxios, HttpClient } from '@baota/hooks/axios'
|
||||
|
||||
const httpClient = new HttpClient({
|
||||
baseURL: 'https://api.example.com',
|
||||
})
|
||||
|
||||
const api = useAxios<{ list: any[]; total: number }>(httpClient)
|
||||
|
||||
// 设置默认数据
|
||||
api.defaultData.value = { list: [], total: 0 }
|
||||
|
||||
// 当请求失败时,data 会被重置为默认数据
|
||||
api.execute('/data/list', { page: 1 })
|
||||
```
|
||||
|
||||
### 类型化请求和响应
|
||||
|
||||
```typescript
|
||||
import { useAxios, HttpClient } from '@baota/hooks/axios'
|
||||
|
||||
// 定义响应数据类型
|
||||
interface UserData {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
role: string
|
||||
}
|
||||
|
||||
// 定义请求参数类型
|
||||
interface UserParams {
|
||||
userId: string
|
||||
}
|
||||
|
||||
const httpClient = new HttpClient({
|
||||
baseURL: 'https://api.example.com',
|
||||
})
|
||||
|
||||
// 创建类型化的请求钩子
|
||||
const api = useAxios<UserData, UserParams>(httpClient)
|
||||
|
||||
// 发送类型化的请求
|
||||
const fetchUser = async (userId: string) => {
|
||||
const user = await api.execute('/user/info', { userId })
|
||||
// user 的类型为 UserData
|
||||
console.log(`用户名: ${user.name}, 角色: ${user.role}`)
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. `useAxios` 默认使用 POST 方法发送请求,如需使用其他 HTTP 方法,需要在 HttpClient 实例中配置。
|
||||
|
||||
2. 响应消息提示功能要求响应数据格式包含 `status` 和 `message` 字段。
|
||||
|
||||
3. 加载遮罩、确认对话框和响应消息提示功能依赖于 `@baota/naive-ui/hooks` 提供的相关组件。
|
||||
|
||||
4. 取消请求功能基于 Axios 的 AbortController 实现,可以通过 URL 来标识和取消特定请求。
|
||||
|
||||
5. 当请求被取消时,`aborted` 状态会被设置为 `true`,可以通过监听此状态来处理请求取消的情况。
|
||||
|
||||
6. 错误处理会自动显示错误消息,并将错误信息存储在 `error` 状态中。
|
||||
332
frontend/packages/vue/hooks/docs/useSocket.md
Normal file
332
frontend/packages/vue/hooks/docs/useSocket.md
Normal file
@@ -0,0 +1,332 @@
|
||||
# useSocket 使用文档
|
||||
|
||||
## 基本介绍
|
||||
|
||||
`useSocket` 是一个基于 Vue 3 的 WebSocket 封装钩子函数,它提供了一种简洁的方式来管理 WebSocket 连接。通过这个钩子函数,你可以轻松地处理连接状态、消息收发、自动重连、心跳机制等功能。
|
||||
|
||||
## 核心函数
|
||||
|
||||
### useSocket
|
||||
|
||||
```typescript
|
||||
function useSocket(url: string, options?: SocketOptions): UseSocketReturn
|
||||
```
|
||||
|
||||
**参数**:
|
||||
|
||||
- `url`: 字符串类型,WebSocket 服务器的 URL
|
||||
- `options`: 可选的 SocketOptions 配置对象
|
||||
|
||||
**SocketOptions 选项**:
|
||||
|
||||
```typescript
|
||||
interface SocketOptions {
|
||||
autoReconnect?: boolean; // 是否自动重连, 默认为 true
|
||||
middleware?: (data: any) => any; // 数据中间件函数,支持原始数据处理,默认为直接返回
|
||||
maxReconnectAttempts?: number; // 最大重连次数, 默认无限制
|
||||
reconnectDelay?: number; // 重连延迟, 默认为3000ms
|
||||
heartbeatInterval?: number; // 心跳间隔, 单位毫秒, 默认为5000ms
|
||||
heartbeatMessage?: any; // 心跳包消息, 默认为 'ping'
|
||||
}
|
||||
```
|
||||
|
||||
**返回值**:
|
||||
包含以下属性和方法的对象:
|
||||
|
||||
**状态属性**:
|
||||
|
||||
- `socket`: WebSocket 实例,类型为 `Ref<WebSocket | null>`
|
||||
- `connected`: 连接状态,类型为 `Ref<boolean>`
|
||||
- `message`: 接收到的消息,类型为 `Ref<any>`
|
||||
|
||||
**方法**:
|
||||
|
||||
- `connect()`: 建立 WebSocket 连接
|
||||
- `disconnect()`: 主动断开连接,禁止自动重连
|
||||
- `send(data: any)`: 发送数据,仅在连接状态时执行
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 基本用法
|
||||
|
||||
```typescript
|
||||
import { useSocket } from '@baota/hooks/socket'
|
||||
|
||||
// 创建 WebSocket 钩子
|
||||
const { socket, connect, disconnect, send, message, connected } = useSocket('wss://example.com/ws')
|
||||
|
||||
// 建立连接
|
||||
connect()
|
||||
|
||||
// 监听连接状态
|
||||
watch(connected, (isConnected) => {
|
||||
if (isConnected) {
|
||||
console.log('WebSocket已连接')
|
||||
} else {
|
||||
console.log('WebSocket已断开')
|
||||
}
|
||||
})
|
||||
|
||||
// 监听接收到的消息
|
||||
watch(message, (newMessage) => {
|
||||
if (newMessage) {
|
||||
console.log('收到消息:', newMessage)
|
||||
}
|
||||
})
|
||||
|
||||
// 发送消息
|
||||
const sendMessage = () => {
|
||||
send(JSON.stringify({ type: 'greeting', content: '你好,服务器!' }))
|
||||
}
|
||||
|
||||
// 断开连接
|
||||
const closeConnection = () => {
|
||||
disconnect()
|
||||
}
|
||||
```
|
||||
|
||||
### 配置自动重连
|
||||
|
||||
```typescript
|
||||
import { useSocket } from '@baota/hooks/socket'
|
||||
|
||||
// 配置自动重连
|
||||
const { connect, disconnect } = useSocket('wss://example.com/ws', {
|
||||
autoReconnect: true, // 启用自动重连
|
||||
maxReconnectAttempts: 5, // 最多重连5次
|
||||
reconnectDelay: 2000 // 2秒后重连
|
||||
})
|
||||
|
||||
// 建立连接
|
||||
connect()
|
||||
```
|
||||
|
||||
### 使用数据中间件
|
||||
|
||||
```typescript
|
||||
import { useSocket } from '@baota/hooks/socket'
|
||||
|
||||
// 配置数据中间件,自动解析JSON
|
||||
const { connect, message } = useSocket('wss://example.com/ws', {
|
||||
middleware: (data) => {
|
||||
try {
|
||||
return JSON.parse(data)
|
||||
} catch (e) {
|
||||
return data
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 建立连接
|
||||
connect()
|
||||
|
||||
// 此时接收到的消息已经被解析为JSON对象
|
||||
watch(message, (newMessage) => {
|
||||
if (newMessage) {
|
||||
console.log('消息类型:', newMessage.type)
|
||||
console.log('消息内容:', newMessage.content)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 配置心跳机制
|
||||
|
||||
```typescript
|
||||
import { useSocket } from '@baota/hooks/socket'
|
||||
|
||||
// 配置心跳机制
|
||||
const { connect } = useSocket('wss://example.com/ws', {
|
||||
heartbeatInterval: 10000, // 10秒发送一次心跳
|
||||
heartbeatMessage: JSON.stringify({ type: 'heartbeat' }) // 自定义心跳消息
|
||||
})
|
||||
|
||||
// 建立连接,会自动启动心跳
|
||||
connect()
|
||||
```
|
||||
|
||||
### 与后端服务集成
|
||||
|
||||
```typescript
|
||||
import { useSocket } from '@baota/hooks/socket'
|
||||
import { ref } from 'vue'
|
||||
|
||||
// 创建聊天应用示例
|
||||
const chatMessages = ref<{ sender: string; content: string }[]>([])
|
||||
const newMessage = ref('')
|
||||
|
||||
const { connect, send, message, connected } = useSocket('wss://chat.example.com/ws', {
|
||||
middleware: (data) => JSON.parse(data)
|
||||
})
|
||||
|
||||
// 连接到聊天服务器
|
||||
connect()
|
||||
|
||||
// 监听新消息
|
||||
watch(message, (msg) => {
|
||||
if (msg && msg.type === 'chat') {
|
||||
chatMessages.value.push({
|
||||
sender: msg.sender,
|
||||
content: msg.content
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 发送新消息
|
||||
const sendChatMessage = () => {
|
||||
if (newMessage.value.trim() && connected.value) {
|
||||
send(JSON.stringify({
|
||||
type: 'chat',
|
||||
content: newMessage.value
|
||||
}))
|
||||
newMessage.value = ''
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 在组件卸载时断开连接
|
||||
|
||||
```typescript
|
||||
import { useSocket } from '@baota/hooks/socket'
|
||||
import { onUnmounted } from 'vue'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const { connect, disconnect, send, message, connected } = useSocket('wss://example.com/ws')
|
||||
|
||||
// 建立连接
|
||||
connect()
|
||||
|
||||
// 在组件卸载时断开连接
|
||||
onUnmounted(() => {
|
||||
disconnect()
|
||||
})
|
||||
|
||||
return { send, message, connected }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 高级用法
|
||||
|
||||
### 结合 TypeScript 类型定义
|
||||
|
||||
```typescript
|
||||
import { useSocket } from '@baota/hooks/socket'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
// 定义消息类型
|
||||
interface ServerMessage {
|
||||
type: 'chat' | 'notification' | 'system';
|
||||
timestamp: number;
|
||||
content: string;
|
||||
sender?: string;
|
||||
}
|
||||
|
||||
// 创建带类型的消息引用
|
||||
const typedMessage = ref<ServerMessage | null>(null)
|
||||
|
||||
const { connect, message } = useSocket('wss://example.com/ws', {
|
||||
middleware: (data) => {
|
||||
try {
|
||||
return JSON.parse(data) as ServerMessage
|
||||
} catch (e) {
|
||||
console.error('消息解析失败:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
connect()
|
||||
|
||||
// 监听并处理不同类型的消息
|
||||
watch(message, (newMessage) => {
|
||||
if (newMessage) {
|
||||
typedMessage.value = newMessage
|
||||
|
||||
switch (newMessage.type) {
|
||||
case 'chat':
|
||||
console.log(`[${newMessage.sender}]: ${newMessage.content}`)
|
||||
break
|
||||
case 'notification':
|
||||
console.log(`通知: ${newMessage.content}`)
|
||||
break
|
||||
case 'system':
|
||||
console.log(`系统消息: ${newMessage.content}`)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 处理多个 WebSocket 连接
|
||||
|
||||
```typescript
|
||||
import { useSocket } from '@baota/hooks/socket'
|
||||
|
||||
// 创建多个 WebSocket 连接
|
||||
const chatSocket = useSocket('wss://chat.example.com/ws')
|
||||
const notificationSocket = useSocket('wss://notification.example.com/ws')
|
||||
const dataStreamSocket = useSocket('wss://datastream.example.com/ws')
|
||||
|
||||
// 分别连接
|
||||
chatSocket.connect()
|
||||
notificationSocket.connect()
|
||||
dataStreamSocket.connect()
|
||||
|
||||
// 监听不同连接的消息
|
||||
watch(chatSocket.message, (msg) => {
|
||||
console.log('聊天消息:', msg)
|
||||
})
|
||||
|
||||
watch(notificationSocket.message, (msg) => {
|
||||
console.log('通知消息:', msg)
|
||||
})
|
||||
|
||||
watch(dataStreamSocket.message, (msg) => {
|
||||
console.log('数据流消息:', msg)
|
||||
})
|
||||
|
||||
// 发送不同类型的消息
|
||||
const sendChatMessage = (content) => {
|
||||
chatSocket.send(JSON.stringify({ type: 'chat', content }))
|
||||
}
|
||||
|
||||
const sendCommand = (command) => {
|
||||
dataStreamSocket.send(JSON.stringify({ type: 'command', command }))
|
||||
}
|
||||
```
|
||||
|
||||
### 实现可视化的连接状态
|
||||
|
||||
```typescript
|
||||
import { useSocket } from '@baota/hooks/socket'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { connect, disconnect, connected } = useSocket('wss://example.com/ws')
|
||||
|
||||
// 计算连接状态的显示文本和样式
|
||||
const connectionStatus = computed(() => {
|
||||
return connected.value ? '已连接' : '未连接'
|
||||
})
|
||||
|
||||
const statusClass = computed(() => {
|
||||
return {
|
||||
'status-connected': connected.value,
|
||||
'status-disconnected': !connected.value
|
||||
}
|
||||
})
|
||||
|
||||
// 连接按钮的状态
|
||||
const connectButtonText = computed(() => {
|
||||
return connected.value ? '断开连接' : '建立连接'
|
||||
})
|
||||
|
||||
// 切换连接状态的方法
|
||||
const toggleConnection = () => {
|
||||
if (connected.value) {
|
||||
disconnect()
|
||||
} else {
|
||||
connect()
|
||||
}
|
||||
}
|
||||
```
|
||||
21
frontend/packages/vue/hooks/eslint.config.js
Normal file
21
frontend/packages/vue/hooks/eslint.config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import vueConfig from '@baota/eslint/vue'
|
||||
import baseConfig from '@baota/eslint'
|
||||
|
||||
/** @type {import("eslint").Linter.Config[]} */
|
||||
const config = [
|
||||
// Vue 相关配置,包含 TypeScript 支持
|
||||
...vueConfig,
|
||||
|
||||
// 基础配置,用于通用的 JavaScript/TypeScript 规则
|
||||
...baseConfig,
|
||||
|
||||
// 项目特定的配置覆盖
|
||||
{
|
||||
files: ['**/*.{js,ts,vue}'],
|
||||
rules: {
|
||||
// 在此处添加项目特定的规则覆盖
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export default config
|
||||
40
frontend/packages/vue/hooks/package.json
Normal file
40
frontend/packages/vue/hooks/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@baota/hooks",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "eslint --ext .ts,.js src --fix"
|
||||
},
|
||||
"files": [
|
||||
"dist/**",
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
"./axios": "./src/axios/index.ts",
|
||||
"./axios/model": "./src/axios/model/index.ts",
|
||||
"./notification": "./src/notification/index.ts",
|
||||
"./retry": "./src/retry/index.ts",
|
||||
"./socket": "./src/socket/index.ts",
|
||||
"./task-queue": "./src/task-queue/index.ts",
|
||||
"./error": "./src/error/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@baota/utils": "workspace:*",
|
||||
"@baota/naive-ui": "workspace:*",
|
||||
"axios": "^1.7.9",
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@baota/eslint": "workspace:*",
|
||||
"@baota/typescript": "workspace:*",
|
||||
"@baota/prettier": "workspace:*",
|
||||
"fake-indexeddb": "^6.0.0"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC"
|
||||
}
|
||||
3
frontend/packages/vue/hooks/prettier.config.js
Normal file
3
frontend/packages/vue/hooks/prettier.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import prettierConfig from '@baota/prettier'
|
||||
|
||||
export default prettierConfig
|
||||
290
frontend/packages/vue/hooks/src/axios/index.ts
Normal file
290
frontend/packages/vue/hooks/src/axios/index.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import { AxiosError, AxiosResponse } from 'axios'
|
||||
import { ref, shallowRef, computed, watch, effectScope, onScopeDispose } from 'vue'
|
||||
import type { Ref, ShallowRef, ComputedRef } from 'vue'
|
||||
import { useLoadingMask, useDialog, useMessage } from '@baota/naive-ui/hooks'
|
||||
import { HttpClient, type Middleware } from './model'
|
||||
import { useError } from '../error'
|
||||
import { cancelRequest, removeAllAbortController } from './model/axios-cancel'
|
||||
|
||||
import type { CustomDialogOptions } from '@baota/naive-ui/types/dialog'
|
||||
import type { LoadingMaskOptions } from '@baota/naive-ui/types/loadingMask'
|
||||
|
||||
export type HttpStatusCode = 200 | 201 | 204 | 400 | 401 | 403 | 404 | 500 | 502 | 504
|
||||
|
||||
/**
|
||||
* @description API响应类型
|
||||
*/
|
||||
export interface ApiResponse<T = unknown> {
|
||||
status: boolean
|
||||
message: string
|
||||
code: HttpStatusCode
|
||||
data: T
|
||||
}
|
||||
|
||||
export interface useAxiosReturn<T, Z> {
|
||||
/** 加载遮罩 */
|
||||
loadingMask: Ref<{ status: boolean } & LoadingMaskOptions>
|
||||
/** 消息提示 */
|
||||
message: Ref<boolean>
|
||||
/** 确认框 */
|
||||
dialog: Ref<{ status: boolean } & CustomDialogOptions>
|
||||
/** 响应式状态 */
|
||||
loading: Ref<boolean>
|
||||
/** 错误 */
|
||||
error: ShallowRef<Error | null | string>
|
||||
/** 响应 */
|
||||
response: ShallowRef<AxiosResponse<T> | null>
|
||||
/** 响应数据 */
|
||||
data: Ref<T>
|
||||
/** 默认数据 */
|
||||
defaultData: Ref<T>
|
||||
/** HTTP状态码 */
|
||||
statusCode: ComputedRef<HttpStatusCode | null>
|
||||
/** 是否被中断 */
|
||||
aborted: Ref<boolean>
|
||||
/** URL和参数 */
|
||||
urlRef: Ref<string>
|
||||
/** 请求参数 */
|
||||
paramsRef: Ref<Z>
|
||||
/** 执行请求 */
|
||||
execute: (url: string, params?: Z) => Promise<T>
|
||||
/** 设置参数 */
|
||||
setParams: (params: Z) => Promise<T>
|
||||
/** 设置URL */
|
||||
setUrl: (url: string, params?: Z) => Promise<T>
|
||||
/** 取消请求 */
|
||||
cancel: (url: string) => void
|
||||
/** 取消所有请求 */
|
||||
cancelAll: () => void
|
||||
/** 发起请求 */
|
||||
fetch: (params?: Z) => Promise<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* @description axios hooks
|
||||
* @param instance HTTP客户端实例
|
||||
* @param config 配置项
|
||||
* @returns 响应式对象和方法
|
||||
*/
|
||||
const useAxios = <T = unknown, Z = Record<string, unknown>>(instance: HttpClient): useAxiosReturn<T, Z> => {
|
||||
const { open, close, update } = useLoadingMask() // 加载遮罩
|
||||
|
||||
// 请求状态
|
||||
const loadingMaskRefs = ref<{ status: boolean } & LoadingMaskOptions>({
|
||||
status: false, // 是否启用遮罩过渡
|
||||
text: '正在处理,请稍后...', // 加载文本
|
||||
})
|
||||
|
||||
// 响应数据
|
||||
const dialogRefs = ref<{ status: boolean } & CustomDialogOptions>({
|
||||
status: false, // 是否启动确认框
|
||||
}) // 消息提示
|
||||
|
||||
const loadingRef = ref(false) // 是否正在加载
|
||||
const messageRef = ref(false) // 消息提示
|
||||
const loadingInstance = shallowRef<unknown>(null) // 加载实例
|
||||
|
||||
// 响应数据
|
||||
const errorRef = shallowRef<Error | null | string>(null) // 错误
|
||||
const response = shallowRef<AxiosResponse<T> | null>(null) // 原始响应
|
||||
const statusCode = computed<HttpStatusCode | null>(() => (response.value?.status as HttpStatusCode) || null) // HTTP状态码
|
||||
const dataRef = ref<T>({} as T) // 处理后的数据
|
||||
const defaultData = ref<T>({} as T) // 默认数据
|
||||
|
||||
// 请求参数
|
||||
const urlRef = ref('') // url
|
||||
const paramsRef = ref<Z>({} as Z) // 参数
|
||||
// const replayRef = ref({ url: '', params: {} as Z }) // 重放请求
|
||||
const aborted = ref(false) // 是否被中断
|
||||
|
||||
// 控制加载遮罩
|
||||
const showLoadingMask = () => {
|
||||
if (loadingMaskRefs.value.status && !loadingInstance.value) {
|
||||
update({ ...loadingMaskRefs.value }) // 更新加载文本
|
||||
open() // 打开加载遮罩
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭加载遮罩
|
||||
const closeLoadingMask = () => {
|
||||
if (loadingInstance.value) {
|
||||
close() // 关闭加载遮罩
|
||||
loadingInstance.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 显示响应消息
|
||||
const showResponseMessage = () => {
|
||||
if (!messageRef.value || !dataRef.value) return
|
||||
if (dataRef.value && typeof dataRef.value === 'object') {
|
||||
if ('status' in dataRef.value && 'message' in dataRef.value) {
|
||||
const { request } = useMessage() // 消息提示
|
||||
console.log(dataRef.value, '+++++++')
|
||||
const { status, message } = dataRef.value
|
||||
if (message) request({ status, message })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理请求错误
|
||||
const handleApiError = (err: AxiosError) => {
|
||||
const { handleError } = useError()
|
||||
if (typeof err === 'boolean') return
|
||||
aborted.value = (err as Error)?.name === 'AbortError' || false // 是否被中断
|
||||
// 检查是否为服务器错误
|
||||
if (err.status != 200 && err.status != 404 && err?.response) {
|
||||
const { message } = err.response?.data as { status: number; message: string }
|
||||
return handleError(new Error(message))
|
||||
} else {
|
||||
handleError(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行请求
|
||||
* @param {string} url 请求地址
|
||||
* @param params 请求参数
|
||||
* @returns 响应数据
|
||||
*/
|
||||
const execute = async (url: string, params?: Z) => {
|
||||
// 避免空URL请求
|
||||
if (!url.trim()) return
|
||||
|
||||
try {
|
||||
// 重置状态
|
||||
errorRef.value = null
|
||||
aborted.value = false
|
||||
loadingRef.value = true
|
||||
|
||||
// 保留请求信息
|
||||
urlRef.value = url
|
||||
paramsRef.value = params || {}
|
||||
|
||||
// 是否显示提示框
|
||||
if (dialogRefs.value.status) {
|
||||
const { create } = useDialog()
|
||||
await create({
|
||||
type: 'info',
|
||||
...dialogRefs.value,
|
||||
})
|
||||
}
|
||||
|
||||
// 显示加载遮罩
|
||||
if (loadingMaskRefs.value.status) showLoadingMask()
|
||||
// 执行请求
|
||||
const res = await instance.post<T>(url, params as Record<string, unknown>)
|
||||
// 保存响应
|
||||
response.value = res
|
||||
// 处理响应数据
|
||||
if (res.data) dataRef.value = { ...defaultData.value, ...res.data }
|
||||
// 显示响应消息
|
||||
if (messageRef.value) showResponseMessage()
|
||||
return res.data
|
||||
} catch (err: unknown) {
|
||||
handleApiError(err as AxiosError)
|
||||
} finally {
|
||||
// 关闭加载状态
|
||||
loadingRef.value = false
|
||||
// 关闭加载遮罩
|
||||
if (loadingMaskRefs.value.text) closeLoadingMask()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置请求参数,并执行请求
|
||||
* @param params 请求参数
|
||||
*/
|
||||
const setParams = (params: Z) => {
|
||||
paramsRef.value = params
|
||||
return execute(urlRef.value, params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置请求地址,并执行请求
|
||||
* @param url 请求地址
|
||||
* @param params 请求参数
|
||||
*/
|
||||
const setUrl = (url: string, params: Z) => {
|
||||
urlRef.value = url
|
||||
paramsRef.value = params || {}
|
||||
return execute(url, paramsRef.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消特定请求
|
||||
* @param url 请求地址
|
||||
*/
|
||||
const cancel = (url: string) => {
|
||||
aborted.value = true
|
||||
return cancelRequest(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消所有请求
|
||||
*/
|
||||
const cancelAll = () => {
|
||||
aborted.value = true
|
||||
return removeAllAbortController()
|
||||
}
|
||||
|
||||
/**
|
||||
* 重放上一次请求
|
||||
*/
|
||||
const fetch = (params?: Z) => {
|
||||
if (!urlRef.value) return
|
||||
return execute(urlRef.value, params || paramsRef.value)
|
||||
}
|
||||
|
||||
const scope = effectScope()
|
||||
scope.run(() => {
|
||||
// 监听 loadingMask 变化
|
||||
watch(loadingMaskRefs, (newVal) => {
|
||||
if (newVal && loadingRef.value) {
|
||||
showLoadingMask()
|
||||
} else if (!newVal) {
|
||||
closeLoadingMask()
|
||||
}
|
||||
})
|
||||
onScopeDispose(() => {
|
||||
scope.stop()
|
||||
})
|
||||
})
|
||||
|
||||
// 封装响应式状态
|
||||
const state = {
|
||||
// 集成组件状态
|
||||
loadingMask: loadingMaskRefs,
|
||||
dialog: dialogRefs,
|
||||
message: messageRef,
|
||||
|
||||
// 响应式状态
|
||||
|
||||
loading: loadingRef,
|
||||
error: errorRef,
|
||||
response,
|
||||
data: dataRef,
|
||||
defaultData,
|
||||
statusCode,
|
||||
aborted,
|
||||
urlRef,
|
||||
paramsRef,
|
||||
}
|
||||
|
||||
// 封装方法
|
||||
const methods = {
|
||||
execute,
|
||||
setParams,
|
||||
setUrl,
|
||||
cancel,
|
||||
cancelAll,
|
||||
fetch,
|
||||
}
|
||||
|
||||
return <useAxiosReturn<T, Z>>{
|
||||
...state,
|
||||
...methods,
|
||||
}
|
||||
}
|
||||
|
||||
export { HttpClient, useAxios, type Middleware }
|
||||
45
frontend/packages/vue/hooks/src/axios/model/axios-cancel.ts
Normal file
45
frontend/packages/vue/hooks/src/axios/model/axios-cancel.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { AxiosRequestConfig } from 'axios'
|
||||
import { requestMiddleware } from './other'
|
||||
|
||||
// * 声明一个 Map 用于存储每个请求的标识 和 取消函数
|
||||
export const pendingMap = new Map<string, AbortController>()
|
||||
|
||||
// 获取请求的唯一标识
|
||||
// const getAbortUrl = (config: AxiosRequestConfig) => config.url
|
||||
|
||||
/**
|
||||
* 添加取消请求中间件
|
||||
* @param {AxiosRequestConfig} config 请求配置
|
||||
* @param {AbortController} controller 取消请求控制器
|
||||
* @returns {AbortController} 返回取消请求控制器
|
||||
*/
|
||||
export const addAbortMiddles = requestMiddleware((config: AxiosRequestConfig) => {
|
||||
const controller = new AbortController() // 创建取消请求控制器
|
||||
pendingMap.set(config.url as string, controller) // 设置取消请求控制器
|
||||
config.signal = controller.signal // 设置请求的信号,当调用 abort 时,会触发信号
|
||||
return config // 返回配置
|
||||
})
|
||||
|
||||
// /**
|
||||
// * 删除取消请求中间件
|
||||
// * @param {AxiosRequestConfig} config 请求配置
|
||||
// */
|
||||
// export const removeAbortMiddles = responseMiddleware((response: AxiosResponse) => {
|
||||
// pendingMap.delete(response.config.url as string)
|
||||
// return response
|
||||
// })
|
||||
|
||||
/**
|
||||
* 取消请求
|
||||
* @param {AxiosRequestConfig} config 请求配置
|
||||
*/
|
||||
export const cancelRequest = (url: string) => {
|
||||
pendingMap.get(url)?.abort()
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除所有取消请求控制器
|
||||
*/
|
||||
export const removeAllAbortController = () => {
|
||||
pendingMap.clear() // 清空取消请求控制器列表
|
||||
}
|
||||
35
frontend/packages/vue/hooks/src/axios/model/axios-options.ts
Normal file
35
frontend/packages/vue/hooks/src/axios/model/axios-options.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
import { getCookie, isDev } from '@baota/utils/browser'
|
||||
import { objectToQueryString } from '@baota/utils/data'
|
||||
import { requestMiddleware } from './other'
|
||||
|
||||
/**
|
||||
* @description 请求头处理-基础
|
||||
* @param {AxiosRequestConfig} options 请求头参数
|
||||
* @param {boolean} isDev 是否为开发环境
|
||||
*/
|
||||
export const requestDefalutOptionsMiddles = requestMiddleware((options: AxiosRequestConfig, dev: boolean = isDev()) => {
|
||||
const defaultOpt: AxiosRequestConfig = {
|
||||
baseURL: dev ? '/api' : '', // 请求基础路径,相对路径用于追加到 baseURL
|
||||
timeout: 250000, // 请求超时时间: 250s
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
transformRequest: [objectToQueryString],
|
||||
}
|
||||
return { ...options, ...defaultOpt }
|
||||
})
|
||||
|
||||
/**
|
||||
* @description 默认配置-面板配置
|
||||
* @param options
|
||||
*/
|
||||
export const requestPanelOptionsMiddle = requestMiddleware((options: AxiosRequestConfig, dev: boolean = isDev()) => {
|
||||
if (!dev) {
|
||||
const cookies = getCookie('request_token') // 获取请求头token
|
||||
options.headers = {
|
||||
...options.headers,
|
||||
...{ 'x-http-token': window.request_token },
|
||||
...(cookies ? { 'x-cookie-token': cookies } : {}),
|
||||
}
|
||||
}
|
||||
return options
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { AxiosError } from 'axios'
|
||||
|
||||
export const handleResponseError = (error: AxiosError): never => {
|
||||
// 自定义错误处理逻辑通知等
|
||||
console.error('Handled Error:', error)
|
||||
throw error
|
||||
}
|
||||
188
frontend/packages/vue/hooks/src/axios/model/index.ts
Normal file
188
frontend/packages/vue/hooks/src/axios/model/index.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
|
||||
import { requestMiddleware, responseMiddleware, errorMiddleware } from './other'
|
||||
|
||||
/**
|
||||
* 中间件类型定义
|
||||
* @property request - 请求拦截器,用于处理请求配置
|
||||
* @property response - 响应拦截器,用于处理响应数据
|
||||
* @property error - 错误处理器,用于处理请求过程中的错误
|
||||
*/
|
||||
export type Middleware = {
|
||||
request?: (config: AxiosRequestConfig) => AxiosRequestConfig | Promise<AxiosRequestConfig>
|
||||
response?: (response: AxiosResponse) => AxiosResponse | Promise<AxiosResponse>
|
||||
error?: (error: unknown) => unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP客户端配置接口
|
||||
*/
|
||||
export interface HttpClientConfig extends AxiosRequestConfig {
|
||||
/** 全局中间件 */
|
||||
middlewares?: Middleware[]
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP客户端类
|
||||
* 封装axios实例,提供中间件机制和常用的HTTP方法
|
||||
*/
|
||||
class HttpClient {
|
||||
// axios实例
|
||||
private instance: AxiosInstance
|
||||
// 全局中间件数组
|
||||
private middlewares: Middleware[] = []
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
* @param config - HTTP客户端配置
|
||||
*/
|
||||
constructor(config: HttpClientConfig = {}) {
|
||||
const { middlewares = [], ...axiosConfig } = config
|
||||
|
||||
// 创建axios实例
|
||||
this.instance = axios.create(axiosConfig)
|
||||
|
||||
// 初始化全局中间件
|
||||
this.middlewares = [...middlewares]
|
||||
|
||||
// 设置拦截器
|
||||
this.setupInterceptors()
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行中间件链
|
||||
* @param handler - 处理函数名称
|
||||
* @param context - 上下文数据
|
||||
* @returns 处理后的上下文数据
|
||||
*/
|
||||
private async executeMiddlewareChain<T>(handler: keyof Middleware, context: T): Promise<T> {
|
||||
const currentContext = { ...context }
|
||||
let Context = currentContext as T
|
||||
// 执行中间件链
|
||||
for (const middleware of this.middlewares) {
|
||||
const handlerFn = middleware[handler]
|
||||
if (handlerFn) Context = (await handlerFn(Context as any)) as T
|
||||
}
|
||||
return Context
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置请求和响应拦截器
|
||||
* 用于执行中间件链
|
||||
*/
|
||||
private setupInterceptors() {
|
||||
// 请求拦截器
|
||||
this.instance.interceptors.request.use(
|
||||
async (config) => {
|
||||
// 复制配置对象,避免直接修改原始配置
|
||||
let currentConfig = { ...config } as AxiosRequestConfig
|
||||
// 执行请求中间件链
|
||||
currentConfig = await this.executeMiddlewareChain('request', currentConfig)
|
||||
return currentConfig as InternalAxiosRequestConfig
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
this.instance.interceptors.response.use(async (response) => {
|
||||
// 复制响应对象,避免直接修改原始响应
|
||||
let currentResponse = { ...response }
|
||||
// 执行响应中间件链
|
||||
currentResponse = await this.executeMiddlewareChain('response', currentResponse)
|
||||
return currentResponse
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加全局中间件
|
||||
* @param middleware - 中间件对象
|
||||
* @returns this - 返回实例本身,支持链式调用
|
||||
*/
|
||||
public use(middleware: Middleware) {
|
||||
this.middlewares.push(middleware)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取axios实例
|
||||
* @returns AxiosInstance - 返回当前的axios实例
|
||||
*/
|
||||
public getAxiosInstance() {
|
||||
return this.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送请求
|
||||
* @param config - 请求配置
|
||||
* @returns Promise<AxiosResponse<T>> - 返回请求响应
|
||||
*/
|
||||
public async request<T = unknown>(config: HttpClientConfig): Promise<AxiosResponse<T> | void> {
|
||||
try {
|
||||
const processedConfig = await this.executeMiddlewareChain('request', config) // 执行请求中间件链
|
||||
const response = await this.instance.request(processedConfig) // 发送请求
|
||||
return this.executeMiddlewareChain('response', response) // 执行响应中间件链
|
||||
} catch (error) {
|
||||
// 执行错误处理中间件链
|
||||
const middleError = await this.executeMiddlewareChain('error', error) // 执行错误处理中间件链,返回错误信息
|
||||
return Promise.reject(middleError)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送GET请求
|
||||
* @param url - 请求地址
|
||||
* @param config - 请求配置
|
||||
* @returns Promise<AxiosResponse<T>> - 返回请求响应
|
||||
*/
|
||||
public async get<T = unknown>(url: string, config: AxiosRequestConfig = {}): Promise<AxiosResponse<T>> {
|
||||
return this.request<T>({ ...config, url, method: 'get' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送POST请求
|
||||
* @param url - 请求地址
|
||||
* @param data - 请求数据
|
||||
* @param config - 请求配置
|
||||
* @returns Promise<AxiosResponse<T>> - 返回请求响应
|
||||
*/
|
||||
public async post<T = unknown>(
|
||||
url: string,
|
||||
data?: Record<string, unknown>,
|
||||
config: AxiosRequestConfig = {},
|
||||
): Promise<AxiosResponse<T>> {
|
||||
return this.request<T>({ ...config, url, data, method: 'post' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送PUT请求
|
||||
* @param url - 请求地址
|
||||
* @param data - 请求数据
|
||||
* @param config - 请求配置
|
||||
* @returns Promise<AxiosResponse<T>> - 返回请求响应
|
||||
*/
|
||||
public async put<T = unknown>(
|
||||
url: string,
|
||||
data?: Record<string, unknown>,
|
||||
config: AxiosRequestConfig = {},
|
||||
): Promise<AxiosResponse<T>> {
|
||||
return this.request<T>({ ...config, url, data, method: 'put' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送DELETE请求
|
||||
* @param url - 请求地址
|
||||
* @param config - 请求配置
|
||||
* @returns Promise<AxiosResponse<T>> - 返回请求响应
|
||||
*/
|
||||
public async delete<T = unknown>(url: string, config: AxiosRequestConfig = {}): Promise<AxiosResponse<T>> {
|
||||
return this.request<T>({ ...config, url, method: 'delete' })
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
HttpClient,
|
||||
requestMiddleware, // 请求中间件
|
||||
responseMiddleware, // 响应中间件
|
||||
errorMiddleware, // 错误中间件
|
||||
}
|
||||
36
frontend/packages/vue/hooks/src/axios/model/other.ts
Normal file
36
frontend/packages/vue/hooks/src/axios/model/other.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'
|
||||
|
||||
type RequestMiddlewareProps = (fn: (config: AxiosRequestConfig) => AxiosRequestConfig) => {
|
||||
request: (config: AxiosRequestConfig) => AxiosRequestConfig
|
||||
}
|
||||
|
||||
type ResponseMiddlewareProps = (fn: (response: AxiosResponse) => AxiosResponse) => {
|
||||
response: (response: AxiosResponse) => AxiosResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建Request 请求中间件
|
||||
* @param fn 请求中间件函数
|
||||
* @returns 请求中间件
|
||||
*/
|
||||
export const requestMiddleware: RequestMiddlewareProps = (fn: (config: AxiosRequestConfig) => AxiosRequestConfig) => ({
|
||||
request: fn,
|
||||
})
|
||||
|
||||
/**
|
||||
* 构建Response 响应中间件
|
||||
* @param fn 响应中间件函数
|
||||
* @returns 响应中间件
|
||||
*/
|
||||
export const responseMiddleware: ResponseMiddlewareProps = (fn: (response: AxiosResponse) => AxiosResponse) => ({
|
||||
response: fn,
|
||||
})
|
||||
|
||||
/**
|
||||
* 构建Error 错误中间件
|
||||
* @param fn 错误中间件函数
|
||||
* @returns 错误中间件
|
||||
*/
|
||||
export const errorMiddleware = (fn: (error: AxiosError) => AxiosError) => ({
|
||||
error: fn,
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
|
||||
import { getProxyConfig, type ProxyConfig } from '@baota/utils/business'
|
||||
import { isDev } from '@baota/utils/browser'
|
||||
import { requestMiddleware } from './other'
|
||||
|
||||
/**
|
||||
* 代理请求中间件
|
||||
* @param {InternalAxiosRequestConfig<any>} config 请求配置
|
||||
* @param {boolean} dev 是否为开发环境
|
||||
* @returns {InternalAxiosRequestConfig<any>} 返回请求配置
|
||||
*/
|
||||
export const proxyRequestMiddle = requestMiddleware((config: AxiosRequestConfig, dev: boolean = isDev()) => {
|
||||
if (dev) {
|
||||
const { requestTime, requestToken } = getProxyConfig('request_token') as unknown as ProxyConfig
|
||||
config.params = {
|
||||
...config.params,
|
||||
request_time: requestTime,
|
||||
request_token: requestToken,
|
||||
}
|
||||
}
|
||||
return config
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { AxiosResponse } from 'axios'
|
||||
import { responseMiddleware } from './other'
|
||||
import { isObject, isString } from '@baota/utils/type'
|
||||
import { hasRequiredKeys } from '@baota/utils/data'
|
||||
|
||||
/*
|
||||
* 预处理响应数据中间件,该组件运行在
|
||||
*/
|
||||
export const processPanelDataMiddle = responseMiddleware((response: AxiosResponse) => {
|
||||
const defaultOption = {
|
||||
data: {}, // 请求数据
|
||||
code: 0, // 状态码,200为成功,其他为失败
|
||||
msg: 'success', // 提示信息
|
||||
status: true, // 接口状态
|
||||
default: true, // 默认状态,用于判断当前数据是否为默认数据,没有经过处理
|
||||
cache: false, // 是否缓存,基于前端缓存
|
||||
oldData: null, // 旧数据,用于保存原始shuj
|
||||
timestamp: 0, // 时间戳
|
||||
}
|
||||
const { data } = response
|
||||
const { custom } = response.config
|
||||
const result = { ...defaultOption } // 拷贝一份数据
|
||||
// 监测字段是否存在
|
||||
if (isObject(data)) {
|
||||
const hasRequiredKeysCurry = hasRequiredKeys(data)
|
||||
const hasStatus = hasRequiredKeysCurry(['status']) // 是否存在status字段
|
||||
const hasMsg = hasRequiredKeysCurry(['msg']) // 是否存在msg字段
|
||||
const hasData = hasRequiredKeysCurry(['data']) // 是否存在data字段
|
||||
if (hasStatus) result.status = (data as { status: boolean }).status
|
||||
if (hasMsg) result.msg = (data as { msg: string }).msg
|
||||
if (hasData) result.data = (data as { data: any }).data
|
||||
result.default = false
|
||||
} else {
|
||||
result.data = data
|
||||
result.default = true // 原数据,仅移动至data
|
||||
}
|
||||
result.oldData = data
|
||||
if (isString(data)) return response
|
||||
return response
|
||||
})
|
||||
310
frontend/packages/vue/hooks/src/error/index.ts
Normal file
310
frontend/packages/vue/hooks/src/error/index.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* 错误处理 Hook
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 基础使用
|
||||
* const { handleError } = useError()
|
||||
*
|
||||
* // 处理运行时错误
|
||||
* try {
|
||||
* throw new Error('运行时错误')
|
||||
* } catch (error) {
|
||||
* handleError(error)
|
||||
* }
|
||||
*
|
||||
* // 处理网络错误(自动显示弹窗)
|
||||
* try {
|
||||
* await fetch('invalid-url')
|
||||
* } catch (error) {
|
||||
* handleError(error, {
|
||||
* title: '网络错误',
|
||||
* content: '请检查网络连接',
|
||||
* showCancel: true
|
||||
* })
|
||||
* }
|
||||
*
|
||||
* // 处理业务错误
|
||||
* const businessError = {
|
||||
* code: 'E001',
|
||||
* message: '余额不足'
|
||||
* }
|
||||
* handleError(businessError)
|
||||
*
|
||||
* // 高级配置
|
||||
* const { handleError, collector } = useError({
|
||||
* showMessage: true, // 显示错误消息
|
||||
* showDialog: true, // 显示错误弹窗
|
||||
* reportError: true, // 启用错误上报
|
||||
* autoAnalyze: true, // 自动分析错误
|
||||
* reportHandler: (errors) => {
|
||||
* // 自定义错误上报逻辑
|
||||
* console.log('上报错误:', errors)
|
||||
* },
|
||||
* customHandler: (error) => {
|
||||
* // 自定义错误处理逻辑
|
||||
* console.error('自定义处理:', error)
|
||||
* }
|
||||
* })
|
||||
*
|
||||
* // 错误收集和上报
|
||||
* collector.collect({
|
||||
* message: '收集错误',
|
||||
* type: 'business'
|
||||
* })
|
||||
*
|
||||
* // 上报所有错误
|
||||
* collector.report()
|
||||
*
|
||||
* // 清空错误队列
|
||||
* collector.clear()
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { useMessage } from '@baota/naive-ui/hooks'
|
||||
import { ref } from 'vue'
|
||||
import type { ErrorInfo, ErrorHandlerOptions, ErrorCollector, ErrorAnalysis, ErrorDialogConfig } from './type'
|
||||
import { AxiosError } from 'axios'
|
||||
import { isArray } from '@baota/utils/type'
|
||||
|
||||
/** 错误队列 */
|
||||
const errorQueue = ref<ErrorInfo[]>([])
|
||||
|
||||
/** 默认错误处理选项 */
|
||||
const DEFAULT_OPTIONS: ErrorHandlerOptions = {
|
||||
showMessage: true, // 显示错误消息
|
||||
reportError: true, // 启用错误上报
|
||||
autoAnalyze: true, // 自动分析错误
|
||||
showDialog: false, // 显示错误弹窗
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认错误弹窗配置
|
||||
*/
|
||||
const DEFAULT_DIALOG_CONFIG: ErrorDialogConfig = {
|
||||
title: '错误提示',
|
||||
confirmText: '确定',
|
||||
cancelText: '取消',
|
||||
showCancel: false,
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析错误类型和级别
|
||||
* @param error 错误对象
|
||||
* @returns 错误分析结果
|
||||
*/
|
||||
const analyzeErrorType = (error: Error | unknown): ErrorAnalysis => {
|
||||
// 如果是 AxiosError,则直接返回错误消息
|
||||
if ((error as AxiosError).name === 'AxiosError') {
|
||||
return {
|
||||
type: 'network',
|
||||
level: 'error',
|
||||
summary: (error as AxiosError).message,
|
||||
details: { message: (error as AxiosError).message },
|
||||
}
|
||||
}
|
||||
|
||||
// 网络错误
|
||||
if (error instanceof TypeError && error.message.includes('network')) {
|
||||
return {
|
||||
type: 'network',
|
||||
level: 'error',
|
||||
summary: '网络请求错误',
|
||||
details: { message: error.message },
|
||||
}
|
||||
}
|
||||
|
||||
// 运行时错误
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
type: 'runtime',
|
||||
level: 'error',
|
||||
summary: error.message,
|
||||
details: {
|
||||
stack: error.stack,
|
||||
name: error.name,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 业务错误
|
||||
if (typeof error === 'object' && error !== null && 'code' in error) {
|
||||
return {
|
||||
type: 'business',
|
||||
level: 'warning',
|
||||
summary: '业务处理错误,请联系管理员',
|
||||
details: error,
|
||||
}
|
||||
}
|
||||
|
||||
// 验证错误
|
||||
if (typeof error === 'object' && error !== null && Array.isArray(error)) {
|
||||
return {
|
||||
type: 'validation',
|
||||
level: 'warning',
|
||||
summary: '数据验证错误',
|
||||
details: { message: '数据验证错误,请检查输入内容' },
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof error === 'string') {
|
||||
return {
|
||||
type: 'runtime',
|
||||
level: 'error',
|
||||
summary: error,
|
||||
details: { message: error },
|
||||
}
|
||||
}
|
||||
// 未知错误
|
||||
return {
|
||||
type: 'runtime',
|
||||
level: 'error',
|
||||
summary: '未知错误',
|
||||
details: { message: error?.message || '未知错误' },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示错误弹窗
|
||||
* @param error 错误信息
|
||||
* @param config 弹窗配置
|
||||
*/
|
||||
const showErrorDialog = (error: ErrorInfo, config: ErrorDialogConfig = {}) => {
|
||||
const dialogConfig = { ...DEFAULT_DIALOG_CONFIG, ...config }
|
||||
// TODO: 实现错误弹窗显示逻辑
|
||||
console.log('Show error dialog:', error, dialogConfig)
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误处理 Hook
|
||||
* @param options 错误处理选项
|
||||
* @returns 错误处理函数和收集器
|
||||
*/
|
||||
export const useError = (options: ErrorHandlerOptions = {}) => {
|
||||
const mergedOptions = { ...DEFAULT_OPTIONS, ...options }
|
||||
|
||||
// 判断是否为 ErrorInfo
|
||||
const isErrorInfo = (error: unknown): error is ErrorInfo =>
|
||||
typeof error === 'object' && error !== null && 'message' in error
|
||||
|
||||
// 默认错误处理函数
|
||||
const defaultFn = (error: ErrorInfo | Error | AxiosError | unknown, msg: string) =>
|
||||
typeof error !== 'boolean' && isErrorInfo(error) ? error.message : msg
|
||||
|
||||
/**
|
||||
* 处理错误
|
||||
* @param error 错误信息或原始错误对象
|
||||
* @param dialogConfig 弹窗配置(可选)
|
||||
* @returns 处理后的错误信息
|
||||
*/
|
||||
const handleError = (error: ErrorInfo | Error | AxiosError | unknown | string, dialogConfig?: ErrorDialogConfig) => {
|
||||
const message = useMessage()
|
||||
|
||||
let errorInfo: ErrorInfo
|
||||
|
||||
// 如果传入的错误为布尔值,则直接返回
|
||||
if (typeof error === 'boolean') return { default: (msg: string) => defaultFn(error, msg) }
|
||||
|
||||
// 如果启用了自动分析,且传入的是原始错误对象
|
||||
if (mergedOptions.autoAnalyze && typeof error === 'object' && error !== null && 'message' in error) {
|
||||
errorInfo = collector.analyze(error)
|
||||
} else {
|
||||
errorInfo = error as ErrorInfo
|
||||
}
|
||||
// 添加时间戳
|
||||
errorInfo.timestamp = Date.now()
|
||||
|
||||
// 收集错误
|
||||
errorQueue.value.push(errorInfo)
|
||||
|
||||
// 根据错误级别显示不同类型的消息
|
||||
if (mergedOptions.showMessage) {
|
||||
// 如果是 ErrorInfo,则根据错误级别显示不同类型的消息
|
||||
const analysis = analyzeErrorType(error)
|
||||
console.log('handleError', typeof error, analysis)
|
||||
|
||||
switch (analysis.level) {
|
||||
case 'error':
|
||||
message.error(analysis.details.message || analysis.summary)
|
||||
break
|
||||
case 'warning':
|
||||
message.warning(analysis.details.message || analysis.summary)
|
||||
break
|
||||
case 'info':
|
||||
message.info(errorInfo.message || analysis.summary)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 显示错误弹窗
|
||||
if (mergedOptions.showDialog) {
|
||||
showErrorDialog(errorInfo, dialogConfig)
|
||||
}
|
||||
|
||||
// 自定义处理
|
||||
if (mergedOptions.customHandler) {
|
||||
mergedOptions.customHandler(errorInfo)
|
||||
}
|
||||
|
||||
return { errorInfo, ...message, default: (msg: string) => defaultFn(error, msg) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误收集器
|
||||
*/
|
||||
const collector: ErrorCollector = {
|
||||
/**
|
||||
* 收集错误
|
||||
* @param error 错误信息
|
||||
*/
|
||||
collect: (error: ErrorInfo) => {
|
||||
errorQueue.value.push({
|
||||
...error,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 上报错误
|
||||
* @param errors 错误列表
|
||||
*/
|
||||
report: (errors: ErrorInfo[] = errorQueue.value) => {
|
||||
if (mergedOptions.reportError) {
|
||||
if (mergedOptions.reportHandler) {
|
||||
mergedOptions.reportHandler(errors)
|
||||
} else {
|
||||
// 默认上报逻辑
|
||||
console.log('Reporting errors:', errors)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 清空错误队列
|
||||
*/
|
||||
clear: () => {
|
||||
errorQueue.value = []
|
||||
},
|
||||
|
||||
/**
|
||||
* 分析错误
|
||||
* @param error 错误对象
|
||||
* @returns 标准化的错误信息
|
||||
*/
|
||||
analyze: (error: Error | unknown): ErrorInfo => {
|
||||
const analysis = analyzeErrorType(error)
|
||||
return {
|
||||
message: analysis.summary,
|
||||
type: analysis.type,
|
||||
metadata: analysis.details,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
handleError,
|
||||
collector,
|
||||
errorQueue,
|
||||
}
|
||||
}
|
||||
89
frontend/packages/vue/hooks/src/error/type.d.ts
vendored
Normal file
89
frontend/packages/vue/hooks/src/error/type.d.ts
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 错误信息接口
|
||||
*/
|
||||
export interface ErrorInfo {
|
||||
/** 错误消息 */
|
||||
message: string
|
||||
/** 错误代码 */
|
||||
code?: string | number
|
||||
/** 错误堆栈 */
|
||||
stack?: string
|
||||
/** 错误类型 */
|
||||
type?: ErrorType
|
||||
/** 时间戳 */
|
||||
timestamp?: number
|
||||
/** 元数据 */
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误类型
|
||||
*/
|
||||
export type ErrorType = 'runtime' | 'network' | 'business' | 'validation'
|
||||
|
||||
/**
|
||||
* 错误级别
|
||||
*/
|
||||
export type ErrorLevel = 'error' | 'warning' | 'info'
|
||||
|
||||
/**
|
||||
* 错误分析结果
|
||||
*/
|
||||
export interface ErrorAnalysis {
|
||||
/** 错误类型 */
|
||||
type: ErrorType
|
||||
/** 错误级别 */
|
||||
level: ErrorLevel
|
||||
/** 错误摘要 */
|
||||
summary: string
|
||||
/** 详细信息 */
|
||||
details: Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误处理选项
|
||||
*/
|
||||
export interface ErrorHandlerOptions {
|
||||
/** 是否显示错误消息 */
|
||||
showMessage?: boolean
|
||||
/** 是否上报错误 */
|
||||
reportError?: boolean
|
||||
/** 是否自动分析错误 */
|
||||
autoAnalyze?: boolean
|
||||
/** 自定义错误处理函数 */
|
||||
customHandler?: (error: ErrorInfo) => void
|
||||
/** 错误上报函数 */
|
||||
reportHandler?: (errors: ErrorInfo[]) => void
|
||||
/** 是否显示错误弹窗 */
|
||||
showDialog?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误收集器接口
|
||||
*/
|
||||
export interface ErrorCollector {
|
||||
/** 收集错误 */
|
||||
collect: (error: ErrorInfo) => void
|
||||
/** 上报错误 */
|
||||
report: (errors: ErrorInfo[]) => void
|
||||
/** 清空错误队列 */
|
||||
clear: () => void
|
||||
/** 分析错误 */
|
||||
analyze: (error: Error | unknown) => ErrorInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误弹窗配置
|
||||
*/
|
||||
export interface ErrorDialogConfig {
|
||||
/** 标题 */
|
||||
title?: string
|
||||
/** 内容 */
|
||||
content?: string
|
||||
/** 确认按钮文本 */
|
||||
confirmText?: string
|
||||
/** 取消按钮文本 */
|
||||
cancelText?: string
|
||||
/** 是否显示取消按钮 */
|
||||
showCancel?: boolean
|
||||
}
|
||||
0
frontend/packages/vue/hooks/src/negotiate/index.ts
Normal file
0
frontend/packages/vue/hooks/src/negotiate/index.ts
Normal file
52
frontend/packages/vue/hooks/src/retry/index.ts
Normal file
52
frontend/packages/vue/hooks/src/retry/index.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface RetryOptions {
|
||||
retries?: number // 重试次数
|
||||
delay?: number // 重试延迟,单位毫秒,默认为1000ms
|
||||
}
|
||||
|
||||
// 等待函数,用于暂停指定的毫秒数
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 重试hook,用于尝试执行异步操作并提供重试逻辑
|
||||
* @param {() => Promise<T>} fn 待执行的异步函数
|
||||
* @param {RetryOptions} options 配置重试策略
|
||||
* @returns { run, loading, error } 包含执行函数、加载状态和错误信息
|
||||
*/
|
||||
export default function useRetry<T>(fn: () => Promise<T>, options?: RetryOptions) {
|
||||
const { retries = 3, delay = 1000 } = options || {}
|
||||
const loading = ref(false)
|
||||
const error = ref<Error | null>(null)
|
||||
|
||||
// run方法封装了重试逻辑
|
||||
const run = async () => {
|
||||
loading.value = true // 标记开始执行
|
||||
error.value = null // 清除之前的错误状态
|
||||
let attempt = 0
|
||||
let lastError: Error | null = null
|
||||
// 循环尝试执行异步函数
|
||||
while (attempt < retries) {
|
||||
try {
|
||||
// 尝试调用传入的异步函数
|
||||
const result = await fn()
|
||||
loading.value = false // 成功后取消加载状态
|
||||
return result
|
||||
} catch (err: any) {
|
||||
lastError = err
|
||||
error.value = err // 记录错误
|
||||
attempt++
|
||||
// 若未达到最大重试次数,则等待后重试
|
||||
if (attempt < retries) {
|
||||
await sleep(delay)
|
||||
}
|
||||
}
|
||||
}
|
||||
loading.value = false // 重试完毕后取消加载状态
|
||||
throw lastError || new Error('重试失败')
|
||||
}
|
||||
|
||||
return { run, loading, error }
|
||||
}
|
||||
143
frontend/packages/vue/hooks/src/socket/index.ts
Normal file
143
frontend/packages/vue/hooks/src/socket/index.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface SocketOptions {
|
||||
autoReconnect?: boolean // 是否自动重连, 默认为 true
|
||||
middleware?: (data: any) => any // 数据中间件函数,支持原始数据处理,默认为直接返回,支持多个中间件
|
||||
maxReconnectAttempts?: number // 最大重连次数, 默认无限制
|
||||
reconnectDelay?: number // 重连延迟, 默认为3000ms
|
||||
heartbeatInterval?: number // 心跳间隔, 单位毫秒
|
||||
heartbeatMessage?: any // 心跳包消息
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 将对象参数拼接到 URL 上,返回新的 URL 字符串
|
||||
* @param url - 基础 URL(如 'https://example.com')
|
||||
* @param obj - 要拼接到 URL 上的对象参数(键值对)
|
||||
* @returns 拼接参数后的完整 URL 字符串
|
||||
*/
|
||||
export function setObjToUrlParams(url: string, obj: Record<string, any>): string {
|
||||
// 将对象的每一个键值对转换成 'key=value' 形式,并且对 value 做 URL 编码
|
||||
const paramsArray: string[] = Object.entries(obj).map(([key, value]) => {
|
||||
return `${String(key)}=${encodeURIComponent(String(value))}`
|
||||
})
|
||||
|
||||
// 将所有 'key=value' 形式的字符串用 '&' 符号连接起来,形成 URL 查询字符串
|
||||
const parameters: string = paramsArray.join('&')
|
||||
|
||||
// 判断原 URL 是否以 '?' 结尾
|
||||
const hasQuestionMarkAtEnd: boolean = /\?$/.test(url)
|
||||
|
||||
// 根据 URL 是否已经有 '?' 来决定如何拼接参数
|
||||
if (hasQuestionMarkAtEnd) {
|
||||
// 如果 URL 已经以 '?' 结尾,直接加参数
|
||||
return url + parameters
|
||||
} else {
|
||||
// 如果 URL 没有以 '?' 结尾,需要先去除可能的结尾 '/',然后加上 '?'
|
||||
const cleanUrl = url.replace(/\/?$/, '')
|
||||
return cleanUrl + '?' + parameters
|
||||
}
|
||||
}
|
||||
|
||||
export default function useSocket(url: string, options?: SocketOptions) {
|
||||
const {
|
||||
autoReconnect = true,
|
||||
reconnectDelay = 3000,
|
||||
middleware = (data: any) => data,
|
||||
maxReconnectAttempts,
|
||||
heartbeatInterval = 5000,
|
||||
heartbeatMessage = 'ping',
|
||||
} = options || {}
|
||||
|
||||
const socket = ref<WebSocket | null>(null)
|
||||
const connected = ref(false)
|
||||
const message = ref<any>(null)
|
||||
let manuallyDisconnected = false // 标记是否主动断开
|
||||
|
||||
// 新增重连计数与心跳定时器变量
|
||||
let reconnectAttempts = 0
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// 建立WebSocket连接
|
||||
const connect = () => {
|
||||
// 兼容检测:若当前环境不支持 WebSocket,则打印错误并退出
|
||||
if (typeof WebSocket === 'undefined') {
|
||||
console.error('WebSocket is not supported in this environment.')
|
||||
return
|
||||
}
|
||||
manuallyDisconnected = false // 重置主动断开标记
|
||||
// 清除之前的心跳定时器
|
||||
if (heartbeatTimer) {
|
||||
clearInterval(heartbeatTimer)
|
||||
heartbeatTimer = null
|
||||
}
|
||||
socket.value = new WebSocket(url)
|
||||
|
||||
// 连接成功的回调
|
||||
socket.value.onopen = () => {
|
||||
connected.value = true
|
||||
reconnectAttempts = 0 // 重置重连计数
|
||||
// 如果配置了心跳包,启动心跳定时器
|
||||
if (heartbeatInterval && heartbeatMessage !== undefined) {
|
||||
heartbeatTimer = setInterval(() => {
|
||||
if (socket.value && connected.value) {
|
||||
socket.value.send(heartbeatMessage)
|
||||
}
|
||||
}, heartbeatInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// 收到消息后,使用中间件处理数据
|
||||
socket.value.onmessage = (event: MessageEvent) => {
|
||||
message.value = middleware(event.data)
|
||||
}
|
||||
|
||||
// 出现错误时打印日志
|
||||
socket.value.onerror = (error) => {
|
||||
console.error('WebSocket error:', error)
|
||||
}
|
||||
|
||||
// 关闭连接的回调,判断是否需要自动重连
|
||||
socket.value.onclose = () => {
|
||||
connected.value = false // 更新连接状态
|
||||
socket.value = null
|
||||
// 清除心跳定时器
|
||||
if (heartbeatTimer) {
|
||||
clearInterval(heartbeatTimer)
|
||||
heartbeatTimer = null
|
||||
}
|
||||
// 如果自动重连且未主动断开,则在延迟后重连
|
||||
if (autoReconnect && !manuallyDisconnected) {
|
||||
if (maxReconnectAttempts !== undefined) {
|
||||
if (reconnectAttempts < maxReconnectAttempts) {
|
||||
reconnectAttempts++
|
||||
setTimeout(() => connect(), reconnectDelay)
|
||||
}
|
||||
} else {
|
||||
setTimeout(() => connect(), reconnectDelay)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 主动断开连接,禁止自动重连
|
||||
const disconnect = () => {
|
||||
manuallyDisconnected = true // 标记为主动断开
|
||||
if (socket.value) {
|
||||
socket.value.close()
|
||||
}
|
||||
// 清除心跳定时器
|
||||
if (heartbeatTimer) {
|
||||
clearInterval(heartbeatTimer)
|
||||
heartbeatTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// 发送数据方法,仅在连接状态时执行
|
||||
const send = (data: any) => {
|
||||
if (socket.value && connected.value) {
|
||||
socket.value.send(data)
|
||||
}
|
||||
}
|
||||
|
||||
return { socket, connect, disconnect, send, message, connected }
|
||||
}
|
||||
157
frontend/packages/vue/hooks/src/task-queue/index.tsx
Normal file
157
frontend/packages/vue/hooks/src/task-queue/index.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
/* eslint-disable guard-for-in */
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
/* eslint-disable no-unused-expressions */
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import { ref, reactive, watch, type Ref, defineComponent } from 'vue'
|
||||
|
||||
// 定义任务状态类型
|
||||
interface TaskStatus<T = any> {
|
||||
result: T | null
|
||||
status: boolean
|
||||
error?: Error | null
|
||||
}
|
||||
|
||||
// 定义任务接口
|
||||
interface Task<T = any> {
|
||||
fn: () => Promise<T>
|
||||
name: string
|
||||
status: boolean
|
||||
}
|
||||
|
||||
// 定义任务队列接口
|
||||
export interface TaskQueuePromise<T> {
|
||||
addTask: <T>(taskName: string, taskFn: () => Promise<T>) => Promise<T>
|
||||
getTaskStatus: <T>(taskName: string) => Ref<TaskStatus<T>>
|
||||
getTaskResult: <T>(taskName: string) => Promise<T>
|
||||
processQueue: () => Promise<void>
|
||||
clearAllTasks: () => void
|
||||
isProcessing: Ref<boolean>
|
||||
TaskQueueLoader: ReturnType<typeof defineComponent>
|
||||
}
|
||||
|
||||
// 使用 hooks 改写任务队列
|
||||
export default function useTaskQueue<T>(): TaskQueuePromise<T> {
|
||||
const taskList = ref<Task[]>([])
|
||||
const taskResults = reactive<Record<string, TaskStatus>>({})
|
||||
const isProcessing = ref(false)
|
||||
|
||||
/**
|
||||
* 添加任务到队列
|
||||
*/
|
||||
const addTask = <T,>(taskName: string, taskFn: () => Promise<T>): Promise<T> => {
|
||||
if (!taskName || !taskFn) {
|
||||
return Promise.reject(new Error('任务名称和函数不能为空'))
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
// 初始化任务状态
|
||||
taskResults[taskName] = { result: null, status: false, error: null }
|
||||
taskList.value.push({
|
||||
name: taskName,
|
||||
fn: async () => {
|
||||
try {
|
||||
const result = await taskFn()
|
||||
taskResults[taskName] = { result, status: true, error: null }
|
||||
resolve(result)
|
||||
return result
|
||||
} catch (error) {
|
||||
const taskError = error instanceof Error ? error : new Error(String(error))
|
||||
taskResults[taskName] = { result: null, status: true, error: taskError }
|
||||
reject(taskError)
|
||||
throw taskError
|
||||
}
|
||||
},
|
||||
status: false,
|
||||
})
|
||||
startProcessing()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务状态
|
||||
*/
|
||||
const getTaskStatus = <T,>(taskName: string): Ref<TaskStatus<T>> => {
|
||||
if (!taskResults[taskName]) {
|
||||
throw new Error(`任务 "${taskName}" 不存在`)
|
||||
}
|
||||
return ref(taskResults[taskName]) as Ref<TaskStatus<T>>
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务结果
|
||||
*/
|
||||
const getTaskResult = <T,>(taskName: string): Promise<T> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const status = getTaskStatus<T>(taskName)
|
||||
if (status.value.status) {
|
||||
status.value.error ? reject(status.value.error) : resolve(status.value.result as T)
|
||||
return
|
||||
}
|
||||
watch(
|
||||
() => status.value,
|
||||
(newStatus) => {
|
||||
if (newStatus.status) {
|
||||
newStatus.error ? reject(newStatus.error) : resolve(newStatus.result as T)
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理任务队列中的任务
|
||||
*/
|
||||
const processQueue = async (): Promise<void> => {
|
||||
if (taskList.value.length === 0) {
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
const taskIndex = taskList.value.findIndex((task) => !task.status)
|
||||
if (taskIndex === -1) return
|
||||
const task = taskList.value[taskIndex] || { status: false, name: '', fn: () => {} } // 获取未处理的任务
|
||||
try {
|
||||
task.status = true
|
||||
await task.fn()
|
||||
taskList.value.splice(taskIndex, 1)
|
||||
} catch (error) {
|
||||
console.error(`任务 "${task.name}" 执行失败:`, error)
|
||||
task.status = false
|
||||
}
|
||||
await processQueue()
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有任务和任务状态
|
||||
*/
|
||||
const clearAllTasks = (): void => {
|
||||
taskList.value = []
|
||||
for (const key in taskResults) delete taskResults[key]
|
||||
isProcessing.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部方法:启动任务处理
|
||||
*/
|
||||
const startProcessing = (): void => {
|
||||
if (!isProcessing.value) {
|
||||
isProcessing.value = true
|
||||
processQueue()
|
||||
}
|
||||
}
|
||||
|
||||
// 合并加载组件:根据任务队列处理状态展示加载提示
|
||||
const TaskQueueLoader = defineComponent({
|
||||
name: 'TaskQueueLoader',
|
||||
setup(_, { slots }) {
|
||||
const { isProcessing } = useTaskQueue()
|
||||
return () => (
|
||||
<div>
|
||||
{isProcessing.value && <div class="loading-indicator">加载中...</div>}
|
||||
<div>{slots.default ? slots.default() : null}</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
return { addTask, getTaskStatus, getTaskResult, processQueue, clearAllTasks, isProcessing, TaskQueueLoader }
|
||||
}
|
||||
BIN
frontend/packages/vue/hooks/test/.DS_Store
vendored
Normal file
BIN
frontend/packages/vue/hooks/test/.DS_Store
vendored
Normal file
Binary file not shown.
429
frontend/packages/vue/hooks/test/axios.spec.js
Normal file
429
frontend/packages/vue/hooks/test/axios.spec.js
Normal file
@@ -0,0 +1,429 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { useAxios, MiddlewareStage } from '../src/axios'
|
||||
import axios from 'axios'
|
||||
// 模拟axios
|
||||
vi.mock('axios', () => {
|
||||
return {
|
||||
default: {
|
||||
create: vi.fn(() => ({
|
||||
interceptors: {
|
||||
request: {
|
||||
use: vi.fn(),
|
||||
eject: vi.fn(),
|
||||
},
|
||||
response: {
|
||||
use: vi.fn(),
|
||||
eject: vi.fn(),
|
||||
},
|
||||
},
|
||||
request: vi.fn(),
|
||||
})),
|
||||
isCancel: vi.fn((error) => error && error.__CANCEL__),
|
||||
CancelToken: {
|
||||
source: vi.fn(() => ({
|
||||
token: 'mock-token',
|
||||
cancel: vi.fn(),
|
||||
})),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
describe('useAxios', () => {
|
||||
let mockResponse
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockResponse = {
|
||||
data: { message: 'success' },
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {},
|
||||
config: {},
|
||||
}
|
||||
// 设置axios.request的模拟实现
|
||||
axios.create().request.mockImplementation(() => Promise.resolve(mockResponse))
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
it('应该返回正确的响应数据', async () => {
|
||||
const { data, error, loading, request } = useAxios()
|
||||
expect(loading.value).toBe(false)
|
||||
expect(data.value).toBe(null)
|
||||
expect(error.value).toBe(null)
|
||||
const promise = request({ url: '/test' })
|
||||
expect(loading.value).toBe(true)
|
||||
await promise
|
||||
expect(loading.value).toBe(false)
|
||||
expect(data.value).toEqual({ message: 'success' })
|
||||
expect(error.value).toBe(null)
|
||||
expect(axios.create().request).toHaveBeenCalledWith(expect.objectContaining({ url: '/test' }))
|
||||
})
|
||||
it('当请求失败时应该设置错误信息', async () => {
|
||||
const mockError = new Error('Request failed')
|
||||
axios.create().request.mockImplementation(() => Promise.reject(mockError))
|
||||
const { data, error, loading, request } = useAxios()
|
||||
try {
|
||||
await request({ url: '/test' })
|
||||
} catch (e) {
|
||||
// 预期抛出错误
|
||||
}
|
||||
expect(loading.value).toBe(false)
|
||||
expect(data.value).toBe(null)
|
||||
expect(error.value).toBe(mockError)
|
||||
})
|
||||
it('应该支持请求重试', async () => {
|
||||
// 前两次请求失败,第三次成功
|
||||
axios
|
||||
.create()
|
||||
.request.mockImplementationOnce(() => Promise.reject(new Error('Retry 1')))
|
||||
.mockImplementationOnce(() => Promise.reject(new Error('Retry 2')))
|
||||
.mockImplementationOnce(() => Promise.resolve(mockResponse))
|
||||
const { data, request } = useAxios()
|
||||
await request({
|
||||
url: '/test',
|
||||
retry: true,
|
||||
retryTimes: 3,
|
||||
})
|
||||
expect(axios.create().request).toHaveBeenCalledTimes(3)
|
||||
expect(data.value).toEqual({ message: 'success' })
|
||||
})
|
||||
it('应该能够取消请求', async () => {
|
||||
// 模拟取消功能
|
||||
const cancelError = new Error('Request canceled')
|
||||
cancelError.__CANCEL__ = true
|
||||
const sourceCancel = axios.CancelToken.source().cancel
|
||||
sourceCancel.mockImplementation(() => {
|
||||
axios.create().request.mockImplementation(() => Promise.reject(cancelError))
|
||||
})
|
||||
const { loading, request, cancel } = useAxios()
|
||||
const requestPromise = request({ url: '/test', requestId: 'test-request' })
|
||||
cancel('test-request')
|
||||
try {
|
||||
await requestPromise
|
||||
} catch (e) {
|
||||
// 预期抛出错误
|
||||
}
|
||||
expect(loading.value).toBe(false)
|
||||
expect(sourceCancel).toHaveBeenCalled()
|
||||
})
|
||||
it('应该支持中间件机制', async () => {
|
||||
const requestMiddleware = vi.fn()
|
||||
const responseMiddleware = vi.fn()
|
||||
const { request, use } = useAxios()
|
||||
// 添加请求中间件
|
||||
use({
|
||||
id: 'request-middleware',
|
||||
stage: MiddlewareStage.REQUEST,
|
||||
handler: requestMiddleware,
|
||||
})
|
||||
// 添加响应中间件
|
||||
use({
|
||||
id: 'response-middleware',
|
||||
stage: MiddlewareStage.RESPONSE,
|
||||
handler: responseMiddleware,
|
||||
})
|
||||
await request({ url: '/test' })
|
||||
expect(requestMiddleware).toHaveBeenCalled()
|
||||
expect(responseMiddleware).toHaveBeenCalled()
|
||||
})
|
||||
it('应该缓存请求结果', async () => {
|
||||
const { request, clearCache } = useAxios()
|
||||
await request({ url: '/cached', cache: true })
|
||||
await request({ url: '/cached', cache: true })
|
||||
// 由于缓存,实际axios请求应该只执行一次
|
||||
expect(axios.create().request).toHaveBeenCalledTimes(1)
|
||||
// 清除缓存后再次请求应该执行新的请求
|
||||
clearCache()
|
||||
await request({ url: '/cached', cache: true })
|
||||
expect(axios.create().request).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
it('应该支持自定义实例配置', async () => {
|
||||
const customConfig = {
|
||||
baseURL: 'https://api.example.com',
|
||||
timeout: 5000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Custom-Header': 'Custom-Value',
|
||||
},
|
||||
}
|
||||
const { request } = useAxios({
|
||||
options: customConfig,
|
||||
})
|
||||
await request({ url: '/test' })
|
||||
// 验证创建实例时使用了自定义配置
|
||||
expect(axios.create).toHaveBeenCalledWith(customConfig)
|
||||
})
|
||||
it('应该支持请求级别的配置覆盖实例配置', async () => {
|
||||
const { request } = useAxios({
|
||||
options: {
|
||||
baseURL: 'https://api.example.com',
|
||||
timeout: 5000,
|
||||
},
|
||||
})
|
||||
await request({
|
||||
url: '/test',
|
||||
timeout: 10000, // 覆盖实例的timeout
|
||||
headers: {
|
||||
'X-Request-Header': 'Request-Value',
|
||||
},
|
||||
})
|
||||
// 验证请求参数包含覆盖的配置
|
||||
expect(axios.create().request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: '/test',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'X-Request-Header': 'Request-Value',
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
it('应该支持删除中间件', async () => {
|
||||
const requestMiddleware = vi.fn()
|
||||
const { request, use, eject } = useAxios()
|
||||
// 添加中间件
|
||||
use({
|
||||
id: 'test-middleware',
|
||||
stage: MiddlewareStage.REQUEST,
|
||||
handler: requestMiddleware,
|
||||
})
|
||||
// 删除中间件
|
||||
eject('test-middleware')
|
||||
await request({ url: '/test' })
|
||||
// 由于中间件已被删除,不应该被调用
|
||||
expect(requestMiddleware).not.toHaveBeenCalled()
|
||||
})
|
||||
it('应该支持请求前的数据转换', async () => {
|
||||
const { request, use } = useAxios()
|
||||
// 添加请求转换中间件
|
||||
use({
|
||||
id: 'transform-request',
|
||||
stage: MiddlewareStage.REQUEST,
|
||||
handler: (config) => {
|
||||
if (config.data) {
|
||||
config.data = { ...config.data, transformed: true }
|
||||
}
|
||||
return config
|
||||
},
|
||||
})
|
||||
await request({
|
||||
url: '/test',
|
||||
method: 'post',
|
||||
data: { original: true },
|
||||
})
|
||||
// 验证请求数据被转换
|
||||
expect(axios.create().request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: { original: true, transformed: true },
|
||||
}),
|
||||
)
|
||||
})
|
||||
it('应该支持响应数据转换', async () => {
|
||||
const { data, request, use } = useAxios()
|
||||
// 添加响应转换中间件
|
||||
use({
|
||||
id: 'transform-response',
|
||||
stage: MiddlewareStage.RESPONSE,
|
||||
handler: (response) => {
|
||||
response.data = { ...response.data, transformed: true }
|
||||
return response
|
||||
},
|
||||
})
|
||||
await request({ url: '/test' })
|
||||
// 验证响应数据被转换
|
||||
expect(data.value).toEqual({
|
||||
message: 'success',
|
||||
transformed: true,
|
||||
})
|
||||
})
|
||||
it('应该支持响应错误处理中间件', async () => {
|
||||
const mockError = new Error('Request failed')
|
||||
axios.create().request.mockImplementation(() => Promise.reject(mockError))
|
||||
const errorHandler = vi.fn().mockImplementation(() => {
|
||||
// 将错误转换为成功响应
|
||||
return {
|
||||
data: { recovered: true },
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {},
|
||||
config: {},
|
||||
}
|
||||
})
|
||||
const { data, error, request, use } = useAxios()
|
||||
// 添加错误处理中间件
|
||||
use({
|
||||
id: 'error-handler',
|
||||
stage: MiddlewareStage.ERROR,
|
||||
handler: errorHandler,
|
||||
})
|
||||
await request({ url: '/test' })
|
||||
// 验证错误被处理且转换为成功响应
|
||||
expect(errorHandler).toHaveBeenCalled()
|
||||
expect(data.value).toEqual({ recovered: true })
|
||||
expect(error.value).toBe(null)
|
||||
})
|
||||
it('应该支持请求防抖', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { request } = useAxios()
|
||||
// 短时间内发起多个相同请求
|
||||
request({ url: '/debounced', debounce: true, debounceTime: 100 })
|
||||
request({ url: '/debounced', debounce: true, debounceTime: 100 })
|
||||
request({ url: '/debounced', debounce: true, debounceTime: 100 })
|
||||
// 前进100ms,触发防抖后的请求
|
||||
vi.advanceTimersByTime(100)
|
||||
await Promise.resolve() // 等待微任务队列
|
||||
// 仅发送一次请求
|
||||
expect(axios.create().request).toHaveBeenCalledTimes(1)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
it('应该支持请求节流', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { request } = useAxios()
|
||||
// 立即执行第一个请求
|
||||
request({ url: '/throttled', throttle: true, throttleTime: 100 })
|
||||
// 这些请求应该被忽略
|
||||
request({ url: '/throttled', throttle: true, throttleTime: 100 })
|
||||
request({ url: '/throttled', throttle: true, throttleTime: 100 })
|
||||
// 验证立即执行了第一个请求
|
||||
expect(axios.create().request).toHaveBeenCalledTimes(1)
|
||||
// 重置模拟
|
||||
axios.create().request.mockClear()
|
||||
// 前进100ms,节流时间结束
|
||||
vi.advanceTimersByTime(100)
|
||||
// 再次发送请求
|
||||
request({ url: '/throttled', throttle: true, throttleTime: 100 })
|
||||
// 验证发送了新的请求
|
||||
expect(axios.create().request).toHaveBeenCalledTimes(1)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
it('应该支持同时处理多个并发请求', async () => {
|
||||
const { loading, request } = useAxios()
|
||||
// 发起多个请求
|
||||
const promise1 = request({ url: '/request1' })
|
||||
const promise2 = request({ url: '/request2' })
|
||||
const promise3 = request({ url: '/request3' })
|
||||
expect(loading.value).toBe(true)
|
||||
// 等待所有请求完成
|
||||
await Promise.all([promise1, promise2, promise3])
|
||||
expect(loading.value).toBe(false)
|
||||
expect(axios.create().request).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
it('应该支持通过配置禁用全局loading状态', async () => {
|
||||
const { loading, request } = useAxios({
|
||||
options: {
|
||||
useGlobalLoading: false,
|
||||
},
|
||||
})
|
||||
// 初始状态
|
||||
expect(loading.value).toBe(false)
|
||||
// 发起请求
|
||||
const promise = request({ url: '/test' })
|
||||
// loading状态应该仍为false
|
||||
expect(loading.value).toBe(false)
|
||||
await promise
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
it('应该支持通过URL参数进行请求', async () => {
|
||||
const { request } = useAxios()
|
||||
await request({
|
||||
url: '/test',
|
||||
params: {
|
||||
id: 123,
|
||||
filter: 'active',
|
||||
sort: 'desc',
|
||||
},
|
||||
})
|
||||
// 验证请求包含参数
|
||||
expect(axios.create().request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: '/test',
|
||||
params: { id: 123, filter: 'active', sort: 'desc' },
|
||||
}),
|
||||
)
|
||||
})
|
||||
it('应该支持自定义请求头', async () => {
|
||||
const { request } = useAxios()
|
||||
await request({
|
||||
url: '/test',
|
||||
headers: {
|
||||
Authorization: 'Bearer token123',
|
||||
'Accept-Language': 'zh-CN',
|
||||
},
|
||||
})
|
||||
// 验证请求包含自定义头
|
||||
expect(axios.create().request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
Authorization: 'Bearer token123',
|
||||
'Accept-Language': 'zh-CN',
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
it('应该支持不同的响应类型', async () => {
|
||||
mockResponse.data = 'blob-data'
|
||||
const { data, request } = useAxios()
|
||||
await request({
|
||||
url: '/download',
|
||||
responseType: 'blob',
|
||||
})
|
||||
// 验证请求参数
|
||||
expect(axios.create().request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
responseType: 'blob',
|
||||
}),
|
||||
)
|
||||
// 验证获取了正确的响应
|
||||
expect(data.value).toBe('blob-data')
|
||||
})
|
||||
it('应该支持请求超时配置', async () => {
|
||||
const mockTimeoutError = new Error('timeout')
|
||||
mockTimeoutError.code = 'ECONNABORTED'
|
||||
axios.create().request.mockImplementation(() => Promise.reject(mockTimeoutError))
|
||||
const { error, request } = useAxios()
|
||||
try {
|
||||
await request({
|
||||
url: '/test',
|
||||
timeout: 1000,
|
||||
})
|
||||
} catch (e) {
|
||||
// 预期抛出错误
|
||||
}
|
||||
// 验证请求参数
|
||||
expect(axios.create().request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
timeout: 1000,
|
||||
}),
|
||||
)
|
||||
// 验证错误状态
|
||||
expect(error.value).toBe(mockTimeoutError)
|
||||
})
|
||||
it('应该支持动态更新请求选项', async () => {
|
||||
const defaultOptions = ref({
|
||||
baseURL: 'https://api.example.com',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
const { request } = useAxios({
|
||||
options: defaultOptions,
|
||||
})
|
||||
// 发起第一个请求
|
||||
await request({ url: '/test1' })
|
||||
// 验证使用了初始配置
|
||||
expect(axios.create).toHaveBeenCalledWith(defaultOptions.value)
|
||||
// 更新配置
|
||||
defaultOptions.value = {
|
||||
...defaultOptions.value,
|
||||
baseURL: 'https://api2.example.com',
|
||||
timeout: 3000,
|
||||
}
|
||||
await nextTick()
|
||||
// 发起第二个请求
|
||||
await request({ url: '/test2' })
|
||||
// 验证使用了更新后的配置
|
||||
expect(axios.create).toHaveBeenCalledWith(defaultOptions.value)
|
||||
})
|
||||
})
|
||||
//# sourceMappingURL=axios.spec.js.map
|
||||
135
frontend/packages/vue/hooks/test/cookie.spec.js
Normal file
135
frontend/packages/vue/hooks/test/cookie.spec.js
Normal file
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import useCookie from '../src/cookie'
|
||||
import { nextTick } from 'vue'
|
||||
describe('useCookie', () => {
|
||||
// 保存原始Document.cookie
|
||||
const originalCookie = Object.getOwnPropertyDescriptor(Document.prototype, 'cookie')
|
||||
beforeEach(() => {
|
||||
// 模拟cookie存储
|
||||
let cookies = ''
|
||||
// 重写document.cookie的getter和setter
|
||||
Object.defineProperty(document, 'cookie', {
|
||||
get: vi.fn(() => cookies),
|
||||
set: vi.fn((value) => {
|
||||
// 简单的cookie设置模拟
|
||||
const [cookieStr] = value.split(';')
|
||||
const [key, val] = cookieStr.split('=')
|
||||
// 如果值为空字符串,表示删除cookie
|
||||
if (!val || val === '') {
|
||||
const cookieList = cookies.split('; ')
|
||||
cookies = cookieList.filter((cookie) => !cookie.startsWith(`${key}=`)).join('; ')
|
||||
} else {
|
||||
// 添加或更新cookie
|
||||
if (!cookies) {
|
||||
cookies = `${cookieStr}`
|
||||
} else {
|
||||
const cookieList = cookies.split('; ')
|
||||
const exists = cookieList.some((cookie) => cookie.startsWith(`${key}=`))
|
||||
if (exists) {
|
||||
cookies = cookieList.map((cookie) => (cookie.startsWith(`${key}=`) ? cookieStr : cookie)).join('; ')
|
||||
} else {
|
||||
cookies = cookies ? `${cookies}; ${cookieStr}` : cookieStr
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}),
|
||||
configurable: true,
|
||||
})
|
||||
// 模拟window.location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { protocol: 'http:' },
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
afterEach(() => {
|
||||
// 恢复原始的document.cookie
|
||||
if (originalCookie) {
|
||||
Object.defineProperty(Document.prototype, 'cookie', originalCookie)
|
||||
}
|
||||
})
|
||||
it('应该初始化带默认值的Cookie', async () => {
|
||||
const { cookie } = useCookie('testCookie', 'defaultValue')
|
||||
await nextTick()
|
||||
expect(cookie.value).toBe('defaultValue')
|
||||
expect(document.cookie).toContain('testCookie=defaultValue')
|
||||
})
|
||||
it('应该读取现有Cookie', async () => {
|
||||
// 预先设置cookie
|
||||
document.cookie = 'existingCookie=existingValue'
|
||||
const { cookie } = useCookie('existingCookie', 'defaultValue')
|
||||
await nextTick()
|
||||
expect(cookie.value).toBe('existingValue')
|
||||
})
|
||||
it('如果无指定键则应该返回所有Cookie', async () => {
|
||||
// 预先设置多个cookie
|
||||
document.cookie = 'cookie1=value1'
|
||||
document.cookie = 'cookie2=value2'
|
||||
const { cookies } = useCookie()
|
||||
await nextTick()
|
||||
expect(cookies.value).toHaveProperty('cookie1', 'value1')
|
||||
expect(cookies.value).toHaveProperty('cookie2', 'value2')
|
||||
})
|
||||
it('应该支持响应式更新Cookie', async () => {
|
||||
const { cookie } = useCookie('reactiveCookie', 'initialValue')
|
||||
await nextTick()
|
||||
expect(document.cookie).toContain('reactiveCookie=initialValue')
|
||||
cookie.value = 'updatedValue'
|
||||
await nextTick()
|
||||
expect(document.cookie).toContain('reactiveCookie=updatedValue')
|
||||
expect(document.cookie).not.toContain('reactiveCookie=initialValue')
|
||||
})
|
||||
it('设置为null或空字符串应该删除Cookie', async () => {
|
||||
// 先设置一个cookie
|
||||
const { cookie } = useCookie('toBeDeleted', 'deleteMe')
|
||||
await nextTick()
|
||||
expect(document.cookie).toContain('toBeDeleted=deleteMe')
|
||||
// 设置为null应该删除
|
||||
// cookie.value = null;
|
||||
await nextTick()
|
||||
expect(document.cookie).not.toContain('toBeDeleted')
|
||||
// 再次设置
|
||||
cookie.value = 'newValue'
|
||||
await nextTick()
|
||||
// 设置为空字符串也应该删除
|
||||
cookie.value = ''
|
||||
await nextTick()
|
||||
expect(document.cookie).not.toContain('toBeDeleted')
|
||||
})
|
||||
it('应该支持Cookie选项', async () => {
|
||||
// 在HTTPS环境下模拟
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { protocol: 'https:' },
|
||||
configurable: true,
|
||||
})
|
||||
// 使用选项创建cookie
|
||||
const cookieOptions = {
|
||||
path: '/test',
|
||||
domain: 'example.com',
|
||||
secure: true,
|
||||
expires: 7, // 7天过期
|
||||
}
|
||||
const cookie = useCookie('optionsCookie', 'optionsValue', cookieOptions)
|
||||
await nextTick()
|
||||
// 验证cookie选项是否被应用
|
||||
const cookieString = document.cookie.mock.calls[0][0]
|
||||
expect(cookieString).toContain('optionsCookie=optionsValue')
|
||||
expect(cookieString).toContain('path=/test')
|
||||
expect(cookieString).toContain('domain=example.com')
|
||||
expect(cookieString).toContain('secure')
|
||||
// 验证过期时间(粗略检查)
|
||||
expect(cookieString).toContain('expires=')
|
||||
})
|
||||
it('在HTTPS环境下应该使用https前缀', async () => {
|
||||
// 设置HTTPS环境
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { protocol: 'https:' },
|
||||
configurable: true,
|
||||
})
|
||||
const cookie = useCookie('secureCookie', 'secureValue')
|
||||
await nextTick()
|
||||
// 验证cookie名称是否添加了https前缀
|
||||
expect(document.cookie).toContain('https_secureCookie=secureValue')
|
||||
})
|
||||
})
|
||||
//# sourceMappingURL=cookie.spec.js.map
|
||||
237
frontend/packages/vue/hooks/test/debounce-fn.spec.js
Normal file
237
frontend/packages/vue/hooks/test/debounce-fn.spec.js
Normal file
@@ -0,0 +1,237 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import useDebounceFn from '../src/debounce-fn'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { ref, nextTick } from 'vue'
|
||||
describe('useDebounceFn', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
it('应该在指定延迟后执行函数', async () => {
|
||||
const mockFn = vi.fn().mockReturnValue('测试结果')
|
||||
const debounced = useDebounceFn(mockFn, 100)
|
||||
const promise = debounced('参数1', '参数2')
|
||||
expect(mockFn).not.toHaveBeenCalled()
|
||||
// 前进100ms
|
||||
vi.advanceTimersByTime(100)
|
||||
const result = await promise
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
expect(mockFn).toHaveBeenCalledWith('参数1', '参数2')
|
||||
expect(result).toBe('测试结果')
|
||||
})
|
||||
it('应该在多次调用时只执行最后一次', async () => {
|
||||
const mockFn = vi.fn()
|
||||
const debounced = useDebounceFn(mockFn, 100)
|
||||
debounced('调用1')
|
||||
debounced('调用2')
|
||||
debounced('调用3')
|
||||
expect(mockFn).not.toHaveBeenCalled()
|
||||
// 前进100ms
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
expect(mockFn).toHaveBeenCalledWith('调用3')
|
||||
})
|
||||
it('应该支持函数形式的延迟参数', async () => {
|
||||
const mockFn = vi.fn()
|
||||
const getDelay = () => 200
|
||||
const debounced = useDebounceFn(mockFn, getDelay)
|
||||
debounced()
|
||||
expect(mockFn).not.toHaveBeenCalled()
|
||||
// 前进100ms
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(mockFn).not.toHaveBeenCalled()
|
||||
// 再前进100ms
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('应该支持executeDelay选项', async () => {
|
||||
const mockFn = vi.fn()
|
||||
const debounced = useDebounceFn(mockFn, 100, { executeDelay: 50 })
|
||||
debounced()
|
||||
expect(mockFn).not.toHaveBeenCalled()
|
||||
// 前进100ms - 防抖结束但还没执行
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(mockFn).not.toHaveBeenCalled()
|
||||
// 再前进50ms - executeDelay后应该执行
|
||||
vi.advanceTimersByTime(50)
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('如果在等待期间再次调用应该重置定时器', async () => {
|
||||
const mockFn = vi.fn()
|
||||
const debounced = useDebounceFn(mockFn, 100)
|
||||
debounced()
|
||||
// 前进50ms
|
||||
vi.advanceTimersByTime(50)
|
||||
expect(mockFn).not.toHaveBeenCalled()
|
||||
// 再次调用
|
||||
debounced()
|
||||
// 再前进50ms - 原来的定时器时间到了,但由于重置应该还没执行
|
||||
vi.advanceTimersByTime(50)
|
||||
expect(mockFn).not.toHaveBeenCalled()
|
||||
// 再前进50ms - 新定时器时间到了,应该执行
|
||||
vi.advanceTimersByTime(50)
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('应该支持立即执行选项', async () => {
|
||||
const mockFn = vi.fn()
|
||||
const debounced = useDebounceFn(mockFn, 100, { immediate: true })
|
||||
// 第一次调用立即执行
|
||||
debounced('立即参数')
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
expect(mockFn).toHaveBeenCalledWith('立即参数')
|
||||
// 延迟期间再次调用不会立即执行
|
||||
debounced('第二次参数')
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
// 前进100ms后,应该执行最后一次调用
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(mockFn).toHaveBeenCalledTimes(2)
|
||||
expect(mockFn).toHaveBeenLastCalledWith('第二次参数')
|
||||
})
|
||||
it('应该支持防抖函数的取消功能', async () => {
|
||||
const mockFn = vi.fn()
|
||||
const { run, cancel } = useDebounceFn(mockFn, 100)
|
||||
run()
|
||||
expect(mockFn).not.toHaveBeenCalled()
|
||||
// 取消防抖
|
||||
cancel()
|
||||
// 前进100ms
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(mockFn).not.toHaveBeenCalled()
|
||||
})
|
||||
it('应该支持防抖函数的立即执行功能', async () => {
|
||||
const mockFn = vi.fn()
|
||||
const { run, flush } = useDebounceFn(mockFn, 100)
|
||||
run('待执行参数')
|
||||
expect(mockFn).not.toHaveBeenCalled()
|
||||
// 立即执行
|
||||
flush()
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
expect(mockFn).toHaveBeenCalledWith('待执行参数')
|
||||
// 前进100ms, 由于已经执行过,不会再次执行
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('在组件卸载时应该清除定时器', () => {
|
||||
const mockFn = vi.fn()
|
||||
// 模拟组件中使用hook
|
||||
const wrapper = mount({
|
||||
template: '<div></div>',
|
||||
setup() {
|
||||
const debounced = useDebounceFn(mockFn, 100)
|
||||
debounced()
|
||||
return { debounced }
|
||||
},
|
||||
})
|
||||
expect(mockFn).not.toHaveBeenCalled()
|
||||
// 卸载组件,此时应该清除定时器
|
||||
wrapper.unmount()
|
||||
// 前进100ms,由于组件已卸载,定时器应该被清除
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(mockFn).not.toHaveBeenCalled()
|
||||
})
|
||||
it('应该保留函数的上下文', async () => {
|
||||
const context = {
|
||||
value: '上下文值',
|
||||
fn() {
|
||||
return this.value
|
||||
},
|
||||
}
|
||||
const spy = vi.spyOn(context, 'fn')
|
||||
const debounced = useDebounceFn(context.fn.bind(context), 100)
|
||||
debounced()
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
expect(spy.mock.results[0].value).toBe('上下文值')
|
||||
})
|
||||
it('应该支持动态修改延迟时间', async () => {
|
||||
const mockFn = vi.fn()
|
||||
const delay = ref(100)
|
||||
const debounced = useDebounceFn(mockFn, delay)
|
||||
debounced()
|
||||
expect(mockFn).not.toHaveBeenCalled()
|
||||
// 修改延迟为50ms
|
||||
delay.value = 50
|
||||
await nextTick()
|
||||
// 前进50ms
|
||||
vi.advanceTimersByTime(50)
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('应该支持Promise返回值', async () => {
|
||||
const mockFn = vi.fn().mockResolvedValue('Promise结果')
|
||||
const debounced = useDebounceFn(mockFn, 100)
|
||||
const promise = debounced()
|
||||
vi.advanceTimersByTime(100)
|
||||
const result = await promise
|
||||
expect(result).toBe('Promise结果')
|
||||
})
|
||||
it('应该处理函数执行期间的错误', async () => {
|
||||
const error = new Error('测试错误')
|
||||
const mockFn = vi.fn().mockRejectedValue(error)
|
||||
const debounced = useDebounceFn(mockFn, 100)
|
||||
const promise = debounced()
|
||||
vi.advanceTimersByTime(100)
|
||||
await expect(promise).rejects.toThrow('测试错误')
|
||||
})
|
||||
it('在多次调用时应该只保留最后一个Promise', async () => {
|
||||
let callIndex = 0
|
||||
const mockFn = vi.fn().mockImplementation(() => {
|
||||
callIndex++
|
||||
return Promise.resolve(`结果${callIndex}`)
|
||||
})
|
||||
const debounced = useDebounceFn(mockFn, 100)
|
||||
const promise1 = debounced()
|
||||
const promise2 = debounced()
|
||||
const promise3 = debounced()
|
||||
vi.advanceTimersByTime(100)
|
||||
// 所有promise应该解析为最后一次调用的结果
|
||||
expect(await promise1).toBe('结果1')
|
||||
expect(await promise2).toBe('结果1')
|
||||
expect(await promise3).toBe('结果1')
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('应该支持maxWait选项,确保函数在指定时间内至少执行一次', async () => {
|
||||
const mockFn = vi.fn()
|
||||
const debounced = useDebounceFn(mockFn, 100, { maxWait: 300 })
|
||||
debounced()
|
||||
// 前进50ms
|
||||
vi.advanceTimersByTime(50)
|
||||
// 再次调用重置防抖
|
||||
debounced()
|
||||
// 再前进50ms
|
||||
vi.advanceTimersByTime(50)
|
||||
// 再次调用重置防抖
|
||||
debounced()
|
||||
// 再前进50ms
|
||||
vi.advanceTimersByTime(50)
|
||||
// 再次调用重置防抖
|
||||
debounced()
|
||||
// 再前进50ms
|
||||
vi.advanceTimersByTime(50)
|
||||
// 再次调用重置防抖
|
||||
debounced()
|
||||
// 再前进100ms,此时总共过去了300ms,应该由于maxWait触发函数执行
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('应该支持组合使用immediate和trailing选项', async () => {
|
||||
const mockFn = vi.fn()
|
||||
const debounced = useDebounceFn(mockFn, 100, {
|
||||
immediate: true,
|
||||
trailing: false,
|
||||
})
|
||||
// 第一次调用立即执行
|
||||
debounced('第一次')
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
expect(mockFn).toHaveBeenCalledWith('第一次')
|
||||
// 延迟期间再次调用
|
||||
debounced('第二次')
|
||||
// 前进100ms
|
||||
vi.advanceTimersByTime(100)
|
||||
// 由于trailing为false,不会执行最后一次调用
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
//# sourceMappingURL=debounce-fn.spec.js.map
|
||||
108
frontend/packages/vue/hooks/test/event-listener.spec.js
Normal file
108
frontend/packages/vue/hooks/test/event-listener.spec.js
Normal file
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import useEventListener from '../src/event-listener'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
describe('useEventListener', () => {
|
||||
let element
|
||||
let eventHandler
|
||||
let cleanup
|
||||
beforeEach(() => {
|
||||
// 创建测试DOM元素
|
||||
element = document.createElement('div')
|
||||
document.body.appendChild(element)
|
||||
// 创建事件处理函数的模拟函数
|
||||
eventHandler = vi.fn()
|
||||
// 清除beforeEach中创建的资源
|
||||
cleanup = () => {
|
||||
document.body.removeChild(element)
|
||||
}
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
cleanup()
|
||||
})
|
||||
it('为元素添加事件监听', async () => {
|
||||
// 创建包含组件hook的测试组件
|
||||
const wrapper = mount({
|
||||
template: '<div></div>',
|
||||
setup() {
|
||||
useEventListener(element, 'click', eventHandler)
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
// 触发事件
|
||||
element.click()
|
||||
// 验证事件处理函数被调用
|
||||
expect(eventHandler).toHaveBeenCalledTimes(1)
|
||||
// 销毁组件
|
||||
wrapper.unmount()
|
||||
// 再次触发事件
|
||||
element.click()
|
||||
// 验证事件处理函数没有被再次调用(已经被移除)
|
||||
expect(eventHandler).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('支持Ref包装的元素目标', async () => {
|
||||
// 创建另一个测试元素
|
||||
const anotherElement = document.createElement('button')
|
||||
document.body.appendChild(anotherElement)
|
||||
// 创建Ref包装的元素引用
|
||||
const targetRef = ref(element)
|
||||
const wrapper = mount({
|
||||
template: '<div></div>',
|
||||
setup() {
|
||||
useEventListener(targetRef, 'click', eventHandler)
|
||||
return { targetRef }
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
// 触发原始元素事件
|
||||
element.click()
|
||||
expect(eventHandler).toHaveBeenCalledTimes(1)
|
||||
// 改变引用的元素
|
||||
targetRef.value = anotherElement
|
||||
await nextTick()
|
||||
// 原始元素事件应该不再被监听
|
||||
element.click()
|
||||
expect(eventHandler).toHaveBeenCalledTimes(1)
|
||||
// 新元素事件应该被监听
|
||||
anotherElement.click()
|
||||
expect(eventHandler).toHaveBeenCalledTimes(2)
|
||||
// 清理
|
||||
document.body.removeChild(anotherElement)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('支持手动注销事件监听', async () => {
|
||||
const { unregister } = useEventListener(element, 'click', eventHandler)
|
||||
// 触发事件
|
||||
element.click()
|
||||
expect(eventHandler).toHaveBeenCalledTimes(1)
|
||||
// 手动注销监听
|
||||
unregister()
|
||||
// 再次触发事件
|
||||
element.click()
|
||||
// 验证事件处理函数没有被再次调用
|
||||
expect(eventHandler).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('支持传递事件选项', async () => {
|
||||
const captureHandler = vi.fn()
|
||||
const bubbleHandler = vi.fn()
|
||||
// 创建父子元素结构
|
||||
const parent = document.createElement('div')
|
||||
const child = document.createElement('div')
|
||||
parent.appendChild(child)
|
||||
document.body.appendChild(parent)
|
||||
// 使用捕获模式监听父元素
|
||||
useEventListener(parent, 'click', captureHandler, { capture: true })
|
||||
// 使用冒泡模式监听子元素
|
||||
useEventListener(child, 'click', bubbleHandler)
|
||||
// 触发子元素事件
|
||||
child.click()
|
||||
// 验证捕获和冒泡顺序(捕获先于冒泡)
|
||||
expect(captureHandler).toHaveBeenCalledTimes(1)
|
||||
expect(bubbleHandler).toHaveBeenCalledTimes(1)
|
||||
// 由于Jest无法验证实际调用顺序,我们只能确认两个处理函数都被调用
|
||||
// 清理
|
||||
document.body.removeChild(parent)
|
||||
})
|
||||
})
|
||||
//# sourceMappingURL=event-listener.spec.js.map
|
||||
81
frontend/packages/vue/hooks/test/local-storage.spec.js
Normal file
81
frontend/packages/vue/hooks/test/local-storage.spec.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import useLocalStorage from '../src/local-storage'
|
||||
describe('useLocalStorage', () => {
|
||||
// 保存原始的localStorage
|
||||
const originalLocalStorage = window.localStorage
|
||||
beforeEach(() => {
|
||||
// 创建模拟的localStorage
|
||||
const localStorageMock = {
|
||||
store: {},
|
||||
getItem: vi.fn((key) => localStorageMock.store[key] || null),
|
||||
setItem: vi.fn((key, value) => {
|
||||
localStorageMock.store[key] = value
|
||||
}),
|
||||
removeItem: vi.fn((key) => {
|
||||
delete localStorageMock.store[key]
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
localStorageMock.store = {}
|
||||
}),
|
||||
key: vi.fn((index) => Object.keys(localStorageMock.store)[index] || null),
|
||||
length: 0,
|
||||
}
|
||||
// 替换全局的localStorage
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: localStorageMock,
|
||||
writable: true,
|
||||
})
|
||||
// 监视console.error
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
afterEach(() => {
|
||||
// 恢复原始的localStorage
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: originalLocalStorage,
|
||||
writable: true,
|
||||
})
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
it('应该使用localStorage存储数据', async () => {
|
||||
const value = useLocalStorage('testKey', { name: 'test' })
|
||||
// 验证初始值已存储到localStorage
|
||||
expect(window.localStorage.setItem).toHaveBeenCalled()
|
||||
// 修改值
|
||||
value.value = { name: 'updated' }
|
||||
await nextTick()
|
||||
// 验证更新的值已存储到localStorage
|
||||
const lastCall = window.localStorage.setItem.mock.calls.pop()
|
||||
expect(lastCall[0]).toBe('testKey')
|
||||
expect(JSON.parse(lastCall[1]).value).toEqual({ name: 'updated' })
|
||||
})
|
||||
it('应该从localStorage加载存储的数据', () => {
|
||||
// 预先设置localStorage的值
|
||||
const storedValue = { value: { name: 'stored' } }
|
||||
window.localStorage.getItem.mockReturnValueOnce(JSON.stringify(storedValue))
|
||||
const value = useLocalStorage('testKey', { name: 'default' })
|
||||
// 验证加载了存储的值而不是默认值
|
||||
expect(value.value).toEqual({ name: 'stored' })
|
||||
expect(window.localStorage.getItem).toHaveBeenCalledWith('testKey')
|
||||
})
|
||||
it('当设置为null时应该从localStorage移除数据', async () => {
|
||||
const value = useLocalStorage('testKey', { name: 'test' })
|
||||
// 设置为null
|
||||
value.value = null
|
||||
await nextTick()
|
||||
// 验证数据已从localStorage中移除
|
||||
expect(window.localStorage.removeItem).toHaveBeenCalledWith('testKey')
|
||||
})
|
||||
it('应该支持过期时间选项', () => {
|
||||
vi.useFakeTimers()
|
||||
const now = Date.now()
|
||||
vi.setSystemTime(now)
|
||||
useLocalStorage('testKey', 'test', { expires: 1000 })
|
||||
// 验证设置了过期时间
|
||||
const lastCall = window.localStorage.setItem.mock.calls.pop()
|
||||
const storedData = JSON.parse(lastCall[1])
|
||||
expect(storedData.expires).toBe(now + 1000)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
//# sourceMappingURL=local-storage.spec.js.map
|
||||
212
frontend/packages/vue/hooks/test/resize-observer.spec.js
Normal file
212
frontend/packages/vue/hooks/test/resize-observer.spec.js
Normal file
@@ -0,0 +1,212 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { ref, nextTick } from 'vue'
|
||||
import useResizeObserver from '../src/resize-observer'
|
||||
import { mount } from '@vue/test-utils'
|
||||
describe('useResizeObserver', () => {
|
||||
// 保存原始的ResizeObserver
|
||||
const originalResizeObserver = global.ResizeObserver
|
||||
// 模拟ResizeObserver
|
||||
class MockResizeObserver {
|
||||
callback
|
||||
observedElements
|
||||
constructor(callback) {
|
||||
this.callback = callback
|
||||
this.observedElements = new Set()
|
||||
}
|
||||
observe = vi.fn((el) => {
|
||||
this.observedElements.add(el)
|
||||
// 模拟元素大小变化
|
||||
setTimeout(() => {
|
||||
this.callback([
|
||||
{
|
||||
target: el,
|
||||
contentRect: { width: 100, height: 100 },
|
||||
},
|
||||
])
|
||||
}, 0)
|
||||
})
|
||||
unobserve = vi.fn((el) => {
|
||||
this.observedElements.delete(el)
|
||||
})
|
||||
disconnect = vi.fn(() => {
|
||||
this.observedElements.clear()
|
||||
})
|
||||
// 模拟大小变化
|
||||
simulateResize(el, width, height) {
|
||||
if (this.observedElements.has(el)) {
|
||||
this.callback([
|
||||
{
|
||||
target: el,
|
||||
contentRect: { width, height },
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
// 全局mock对象
|
||||
let mockObserver
|
||||
beforeEach(() => {
|
||||
// 安装模拟的ResizeObserver
|
||||
global.ResizeObserver = vi.fn((callback) => {
|
||||
mockObserver = new MockResizeObserver(callback)
|
||||
return mockObserver
|
||||
})
|
||||
// 监视console.warn
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
afterEach(() => {
|
||||
// 恢复原始的ResizeObserver
|
||||
global.ResizeObserver = originalResizeObserver
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
it('应该返回元素的宽高', async () => {
|
||||
const div = document.createElement('div')
|
||||
const { width, height } = useResizeObserver(div)
|
||||
await nextTick()
|
||||
expect(width.value).toBe(100)
|
||||
expect(height.value).toBe(100)
|
||||
})
|
||||
it('应该支持使用Ref包装的元素', async () => {
|
||||
const div = document.createElement('div')
|
||||
const elementRef = ref(div)
|
||||
const { width, height } = useResizeObserver(elementRef)
|
||||
await nextTick()
|
||||
expect(width.value).toBe(100)
|
||||
expect(height.value).toBe(100)
|
||||
// 改变引用
|
||||
const newDiv = document.createElement('div')
|
||||
elementRef.value = newDiv
|
||||
await nextTick()
|
||||
// 验证新元素被监听,旧元素被取消监听
|
||||
expect(mockObserver.observe).toHaveBeenCalledWith(newDiv)
|
||||
expect(mockObserver.unobserve).toHaveBeenCalledWith(div)
|
||||
})
|
||||
it('应该在元素变化时调用回调函数', async () => {
|
||||
const div = document.createElement('div')
|
||||
const callback = vi.fn()
|
||||
useResizeObserver(div, callback)
|
||||
await nextTick()
|
||||
// 验证回调被调用
|
||||
expect(callback).toHaveBeenCalledTimes(1)
|
||||
expect(callback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
target: div,
|
||||
contentRect: { width: 100, height: 100 },
|
||||
}),
|
||||
)
|
||||
// 模拟元素大小变化
|
||||
mockObserver.simulateResize(div, 200, 150)
|
||||
// 验证回调再次被调用,且参数正确
|
||||
expect(callback).toHaveBeenCalledTimes(2)
|
||||
expect(callback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
target: div,
|
||||
contentRect: { width: 200, height: 150 },
|
||||
}),
|
||||
)
|
||||
})
|
||||
it('应该在组件卸载时断开观察', async () => {
|
||||
// 挂载组件
|
||||
const wrapper = mount({
|
||||
template: '<div></div>',
|
||||
setup() {
|
||||
const el = ref(null)
|
||||
return {
|
||||
el,
|
||||
...useResizeObserver(el),
|
||||
}
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
// 卸载组件
|
||||
wrapper.unmount()
|
||||
// 验证断开连接
|
||||
expect(mockObserver.disconnect).toHaveBeenCalled()
|
||||
})
|
||||
it('在ResizeObserver不存在时应该使用fallback', async () => {
|
||||
// 临时删除ResizeObserver
|
||||
global.ResizeObserver = undefined
|
||||
const div = document.createElement('div')
|
||||
// 模拟元素的getBoundingClientRect方法
|
||||
div.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
width: 200,
|
||||
height: 150,
|
||||
})
|
||||
const { width, height } = useResizeObserver(div)
|
||||
await nextTick()
|
||||
// 验证fallback获取了正确的尺寸
|
||||
expect(width.value).toBe(200)
|
||||
expect(height.value).toBe(150)
|
||||
// 验证警告信息
|
||||
expect(console.warn).toHaveBeenCalledWith('ResizeObserver is not supported, using window resize fallback.')
|
||||
// 触发window resize事件
|
||||
div.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
width: 300,
|
||||
height: 250,
|
||||
})
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
await nextTick()
|
||||
// 验证尺寸更新
|
||||
expect(width.value).toBe(300)
|
||||
expect(height.value).toBe(250)
|
||||
})
|
||||
it('fallback应该在元素引用变化时更新尺寸', async () => {
|
||||
// 临时删除ResizeObserver
|
||||
global.ResizeObserver = undefined
|
||||
const div = document.createElement('div')
|
||||
div.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
width: 200,
|
||||
height: 150,
|
||||
})
|
||||
const elementRef = ref(div)
|
||||
const { width, height } = useResizeObserver(elementRef)
|
||||
await nextTick()
|
||||
expect(width.value).toBe(200)
|
||||
expect(height.value).toBe(150)
|
||||
// 改变引用
|
||||
const newDiv = document.createElement('div')
|
||||
newDiv.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
width: 400,
|
||||
height: 300,
|
||||
})
|
||||
elementRef.value = newDiv
|
||||
await nextTick()
|
||||
// 验证尺寸更新
|
||||
expect(width.value).toBe(400)
|
||||
expect(height.value).toBe(300)
|
||||
})
|
||||
it('当Ref元素为null时应该正确处理', async () => {
|
||||
const elementRef = ref(null)
|
||||
const { width, height } = useResizeObserver(elementRef)
|
||||
// 初始值应为0
|
||||
expect(width.value).toBe(0)
|
||||
expect(height.value).toBe(0)
|
||||
// 设置有效元素
|
||||
const div = document.createElement('div')
|
||||
elementRef.value = div
|
||||
await nextTick()
|
||||
// 验证元素被观察
|
||||
expect(mockObserver.observe).toHaveBeenCalledWith(div)
|
||||
// 再次设为null
|
||||
elementRef.value = null
|
||||
await nextTick()
|
||||
// 验证元素被取消观察
|
||||
expect(mockObserver.unobserve).toHaveBeenCalledWith(div)
|
||||
})
|
||||
it('当多个尺寸变化同时发生时应正确处理', async () => {
|
||||
const div1 = document.createElement('div')
|
||||
const div2 = document.createElement('div')
|
||||
const { width: width1, height: height1 } = useResizeObserver(div1)
|
||||
const { width: width2, height: height2 } = useResizeObserver(div2)
|
||||
await nextTick()
|
||||
// 模拟多个元素同时变化
|
||||
mockObserver.simulateResize(div1, 150, 120)
|
||||
mockObserver.simulateResize(div2, 250, 220)
|
||||
// 验证每个元素的尺寸正确更新
|
||||
expect(width1.value).toBe(150)
|
||||
expect(height1.value).toBe(120)
|
||||
expect(width2.value).toBe(250)
|
||||
expect(height2.value).toBe(220)
|
||||
})
|
||||
})
|
||||
//# sourceMappingURL=resize-observer.spec.js.map
|
||||
139
frontend/packages/vue/hooks/test/retry.spec.js
Normal file
139
frontend/packages/vue/hooks/test/retry.spec.js
Normal file
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import useRetry from '../src/retry'
|
||||
describe('useRetry', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers() // 使用假定时器
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers() // 清除所有定时器
|
||||
vi.useRealTimers() // 恢复真实定时器
|
||||
})
|
||||
it('should execute successfully on first try', async () => {
|
||||
const mockFn = vi.fn().mockResolvedValue('success') // 模拟成功
|
||||
const { run, loading, error } = useRetry(mockFn)
|
||||
const promise = run()
|
||||
expect(loading.value).toBe(true)
|
||||
expect(error.value).toBe(null)
|
||||
const result = await promise
|
||||
expect(result).toBe('success')
|
||||
expect(loading.value).toBe(false)
|
||||
expect(error.value).toBe(null)
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('should retry on failure and succeed eventually', async () => {
|
||||
const mockError = new Error('test error')
|
||||
const mockFn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(mockError)
|
||||
.mockRejectedValueOnce(mockError)
|
||||
.mockResolvedValue('success')
|
||||
const { run, loading, error } = useRetry(mockFn, {
|
||||
retries: 3,
|
||||
delay: 1000,
|
||||
})
|
||||
const promise = run()
|
||||
expect(loading.value).toBe(true)
|
||||
expect(error.value).toBe(null)
|
||||
// First failure
|
||||
await vi.advanceTimersByTime(0)
|
||||
expect(error.value).toBe(mockError)
|
||||
expect(loading.value).toBe(true)
|
||||
// Second failure
|
||||
await vi.advanceTimersByTime(1000)
|
||||
expect(error.value).toBe(mockError)
|
||||
expect(loading.value).toBe(true)
|
||||
// Success on third try
|
||||
await vi.advanceTimersByTime(1000)
|
||||
const result = await promise
|
||||
expect(result).toBe('success')
|
||||
expect(loading.value).toBe(false)
|
||||
expect(error.value).toBe(null)
|
||||
expect(mockFn).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
it('should throw after max retries', async () => {
|
||||
const mockError = new Error('persistent error')
|
||||
const mockFn = vi.fn().mockRejectedValue(mockError)
|
||||
const { run, loading, error } = useRetry(mockFn, {
|
||||
retries: 2,
|
||||
delay: 500,
|
||||
})
|
||||
const promise = run()
|
||||
expect(loading.value).toBe(true)
|
||||
expect(error.value).toBe(null)
|
||||
// First attempt
|
||||
await vi.advanceTimersByTime(0)
|
||||
expect(error.value).toBe(mockError)
|
||||
expect(loading.value).toBe(true)
|
||||
// First retry
|
||||
await vi.advanceTimersByTime(500)
|
||||
expect(error.value).toBe(mockError)
|
||||
expect(loading.value).toBe(true)
|
||||
// Second retry
|
||||
await vi.advanceTimersByTime(500)
|
||||
await expect(promise).rejects.toThrow(mockError)
|
||||
expect(loading.value).toBe(false)
|
||||
expect(error.value).toBe(mockError)
|
||||
expect(mockFn).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
it('should use default options when not provided', async () => {
|
||||
const mockError = new Error('temporary error')
|
||||
const mockFn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(mockError)
|
||||
.mockRejectedValueOnce(mockError)
|
||||
.mockRejectedValueOnce(mockError)
|
||||
.mockResolvedValue('success')
|
||||
const { run, loading, error } = useRetry(mockFn)
|
||||
const promise = run()
|
||||
expect(loading.value).toBe(true)
|
||||
// First attempt
|
||||
await vi.advanceTimersByTime(0)
|
||||
expect(error.value).toBe(mockError)
|
||||
// First retry
|
||||
await vi.advanceTimersByTime(1000)
|
||||
expect(error.value).toBe(mockError)
|
||||
// Second retry
|
||||
await vi.advanceTimersByTime(1000)
|
||||
expect(error.value).toBe(mockError)
|
||||
// Third retry
|
||||
await vi.advanceTimersByTime(1000)
|
||||
const result = await promise
|
||||
expect(result).toBe('success')
|
||||
expect(loading.value).toBe(false)
|
||||
expect(error.value).toBe(null)
|
||||
expect(mockFn).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
it('should reset error state on each run', async () => {
|
||||
const mockError = new Error('first run error')
|
||||
const mockFn = vi.fn().mockRejectedValueOnce(mockError).mockResolvedValueOnce('second run success')
|
||||
const { run, error } = useRetry(mockFn, { retries: 0 })
|
||||
// First run fails
|
||||
await expect(run()).rejects.toThrow(mockError)
|
||||
expect(error.value).toBe(mockError)
|
||||
// Second run succeeds
|
||||
const result = await run()
|
||||
expect(result).toBe('second run success')
|
||||
expect(error.value).toBe(null)
|
||||
})
|
||||
it('should prevent concurrent executions', async () => {
|
||||
const mockFn = vi.fn(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
return 'success'
|
||||
})
|
||||
const { run, loading } = useRetry(mockFn)
|
||||
// First execution
|
||||
const promise1 = run()
|
||||
expect(loading.value).toBe(true)
|
||||
// Second execution during first one
|
||||
const promise2 = run()
|
||||
// Should reuse the first execution
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTime(1000)
|
||||
const [result1, result2] = await Promise.all([promise1, promise2])
|
||||
expect(result1).toBe('success')
|
||||
expect(result2).toBe('success')
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
})
|
||||
//# sourceMappingURL=retry.spec.js.map
|
||||
295
frontend/packages/vue/hooks/test/session-storage.spec.js
Normal file
295
frontend/packages/vue/hooks/test/session-storage.spec.js
Normal file
@@ -0,0 +1,295 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import useSessionStorage from '../src/session-storage'
|
||||
describe('useSessionStorage', () => {
|
||||
// 保存原始的sessionStorage
|
||||
const originalSessionStorage = window.sessionStorage
|
||||
let sessionStorageMockEvents = []
|
||||
beforeEach(() => {
|
||||
// 重置事件数组
|
||||
sessionStorageMockEvents = []
|
||||
// 创建模拟的sessionStorage
|
||||
const sessionStorageMock = {
|
||||
store: {},
|
||||
getItem: vi.fn((key) => sessionStorageMock.store[key] || null),
|
||||
setItem: vi.fn((key, value) => {
|
||||
const oldValue = sessionStorageMock.store[key]
|
||||
sessionStorageMock.store[key] = value
|
||||
// 触发storage事件
|
||||
const event = new CustomEvent('storage', {
|
||||
detail: {
|
||||
key,
|
||||
oldValue,
|
||||
newValue: value,
|
||||
storageArea: sessionStorageMock,
|
||||
url: window.location.href,
|
||||
},
|
||||
})
|
||||
sessionStorageMockEvents.push(event)
|
||||
window.dispatchEvent(event)
|
||||
}),
|
||||
removeItem: vi.fn((key) => {
|
||||
const oldValue = sessionStorageMock.store[key]
|
||||
delete sessionStorageMock.store[key]
|
||||
// 触发storage事件
|
||||
const event = new CustomEvent('storage', {
|
||||
detail: {
|
||||
key,
|
||||
oldValue,
|
||||
newValue: null,
|
||||
storageArea: sessionStorageMock,
|
||||
url: window.location.href,
|
||||
},
|
||||
})
|
||||
sessionStorageMockEvents.push(event)
|
||||
window.dispatchEvent(event)
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
sessionStorageMock.store = {}
|
||||
// 触发storage事件
|
||||
const event = new CustomEvent('storage', {
|
||||
detail: {
|
||||
key: null,
|
||||
oldValue: null,
|
||||
newValue: null,
|
||||
storageArea: sessionStorageMock,
|
||||
url: window.location.href,
|
||||
},
|
||||
})
|
||||
sessionStorageMockEvents.push(event)
|
||||
window.dispatchEvent(event)
|
||||
}),
|
||||
key: vi.fn((index) => Object.keys(sessionStorageMock.store)[index] || null),
|
||||
get length() {
|
||||
return Object.keys(sessionStorageMock.store).length
|
||||
},
|
||||
}
|
||||
// 替换全局的sessionStorage
|
||||
Object.defineProperty(window, 'sessionStorage', {
|
||||
value: sessionStorageMock,
|
||||
writable: true,
|
||||
})
|
||||
// 监视console.error
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
// 添加事件监听器模拟
|
||||
vi.spyOn(window, 'addEventListener')
|
||||
vi.spyOn(window, 'removeEventListener')
|
||||
})
|
||||
afterEach(() => {
|
||||
// 恢复原始的sessionStorage
|
||||
Object.defineProperty(window, 'sessionStorage', {
|
||||
value: originalSessionStorage,
|
||||
writable: true,
|
||||
})
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
it('应该使用sessionStorage存储数据', async () => {
|
||||
const value = useSessionStorage('testKey', { name: 'test' })
|
||||
// 验证初始值已存储到sessionStorage
|
||||
expect(window.sessionStorage.setItem).toHaveBeenCalled()
|
||||
// 修改值
|
||||
value.value = { name: 'updated' }
|
||||
await nextTick()
|
||||
// 验证更新的值已存储到sessionStorage
|
||||
const lastCall = window.sessionStorage.setItem.mock.calls.pop()
|
||||
expect(lastCall[0]).toBe('testKey')
|
||||
expect(JSON.parse(lastCall[1]).value).toEqual({ name: 'updated' })
|
||||
})
|
||||
it('应该从sessionStorage加载存储的数据', () => {
|
||||
// 预先设置sessionStorage的值
|
||||
const storedValue = { value: { name: 'stored' } }
|
||||
window.sessionStorage.getItem.mockReturnValueOnce(JSON.stringify(storedValue))
|
||||
const value = useSessionStorage('testKey', { name: 'default' })
|
||||
// 验证加载了存储的值而不是默认值
|
||||
expect(value.value).toEqual({ name: 'stored' })
|
||||
expect(window.sessionStorage.getItem).toHaveBeenCalledWith('testKey')
|
||||
})
|
||||
it('当设置为null时应该从sessionStorage移除数据', async () => {
|
||||
const value = useSessionStorage('testKey', { name: 'test' })
|
||||
// 设置为null
|
||||
value.value = null
|
||||
await nextTick()
|
||||
// 验证数据已从sessionStorage中移除
|
||||
expect(window.sessionStorage.removeItem).toHaveBeenCalledWith('testKey')
|
||||
})
|
||||
it('应该支持过期时间选项', () => {
|
||||
vi.useFakeTimers()
|
||||
const now = Date.now()
|
||||
vi.setSystemTime(now)
|
||||
useSessionStorage('testKey', 'test', { expires: 1000 })
|
||||
// 验证设置了过期时间
|
||||
const lastCall = window.sessionStorage.setItem.mock.calls.pop()
|
||||
const storedData = JSON.parse(lastCall[1])
|
||||
expect(storedData.expires).toBe(now + 1000)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
it('应该和localStorage使用不同的存储空间', async () => {
|
||||
// 创建模拟的localStorage
|
||||
const localStorageMock = {
|
||||
store: {},
|
||||
getItem: vi.fn((key) => localStorageMock.store[key] || null),
|
||||
setItem: vi.fn((key, value) => {
|
||||
localStorageMock.store[key] = value
|
||||
}),
|
||||
removeItem: vi.fn((key) => {
|
||||
delete localStorageMock.store[key]
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
localStorageMock.store = {}
|
||||
}),
|
||||
key: vi.fn((index) => Object.keys(localStorageMock.store)[index] || null),
|
||||
length: 0,
|
||||
}
|
||||
// 替换全局的localStorage
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: localStorageMock,
|
||||
writable: true,
|
||||
})
|
||||
// 在sessionStorage中保存数据
|
||||
const sessionValue = useSessionStorage('sameKey', 'sessionValue')
|
||||
// 确认数据保存在sessionStorage而不是localStorage
|
||||
expect(window.sessionStorage.setItem).toHaveBeenCalled()
|
||||
expect(window.localStorage.setItem).not.toHaveBeenCalled()
|
||||
// 修改sessionStorage中的值
|
||||
sessionValue.value = 'updatedSessionValue'
|
||||
await nextTick()
|
||||
// 验证值被更新到了sessionStorage而不是localStorage
|
||||
expect(window.sessionStorage.setItem).toHaveBeenCalledTimes(2)
|
||||
expect(window.localStorage.setItem).not.toHaveBeenCalled()
|
||||
})
|
||||
it('应该处理存储的数据格式不正确的情况', () => {
|
||||
// 模拟sessionStorage中存在无效的JSON数据
|
||||
window.sessionStorage.getItem.mockReturnValueOnce('invalid json')
|
||||
// 使用带有默认值的hook
|
||||
const value = useSessionStorage('testKey', 'default')
|
||||
// 验证使用了默认值
|
||||
expect(value.value).toBe('default')
|
||||
// 验证错误被记录
|
||||
expect(console.error).toHaveBeenCalledWith(expect.stringContaining('Error parsing'), expect.any(Error))
|
||||
})
|
||||
it('应该在sessionStorage不可用时使用内存存储', () => {
|
||||
// 模拟sessionStorage不可用的情况
|
||||
window.sessionStorage.setItem.mockImplementationOnce(() => {
|
||||
throw new Error('QuotaExceededError')
|
||||
})
|
||||
const value = useSessionStorage('testKey', 'default')
|
||||
// 验证值仍然可用
|
||||
expect(value.value).toBe('default')
|
||||
// 修改值
|
||||
value.value = 'updated'
|
||||
// 验证值被更新(内存中),即使无法保存到sessionStorage
|
||||
expect(value.value).toBe('updated')
|
||||
// 验证警告被记录
|
||||
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('sessionStorage error'), expect.any(Error))
|
||||
})
|
||||
it('应该在达到过期时间时使用默认值', () => {
|
||||
vi.useFakeTimers()
|
||||
const now = Date.now()
|
||||
vi.setSystemTime(now)
|
||||
// 模拟在sessionStorage中有一个过期的值
|
||||
const expiredData = {
|
||||
value: 'expired',
|
||||
expires: now - 1000, // 已过期1秒
|
||||
}
|
||||
window.sessionStorage.getItem.mockReturnValueOnce(JSON.stringify(expiredData))
|
||||
// 使用hook
|
||||
const value = useSessionStorage('testKey', 'default')
|
||||
// 验证使用了默认值而不是过期的值
|
||||
expect(value.value).toBe('default')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
it('应该监听storage事件同步其他标签页的变化', async () => {
|
||||
// 创建两个使用相同键的hooks实例
|
||||
const value1 = useSessionStorage('syncKey', 'initial')
|
||||
const value2 = useSessionStorage('syncKey', 'other')
|
||||
// 验证它们有相同的初始值
|
||||
expect(value1.value).toBe('initial')
|
||||
expect(value2.value).toBe('initial')
|
||||
// 修改第一个实例的值
|
||||
value1.value = 'updated'
|
||||
await nextTick()
|
||||
// 验证第二个实例的值也更新了
|
||||
expect(value2.value).toBe('updated')
|
||||
// 模拟从另一个标签页修改同一个键
|
||||
const event = new StorageEvent('storage', {
|
||||
key: 'syncKey',
|
||||
newValue: JSON.stringify({ value: 'from another tab' }),
|
||||
storageArea: window.sessionStorage,
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
await nextTick()
|
||||
// 验证两个实例都更新了值
|
||||
expect(value1.value).toBe('from another tab')
|
||||
expect(value2.value).toBe('from another tab')
|
||||
})
|
||||
it('应该支持自定义序列化和反序列化', () => {
|
||||
// 创建自定义的序列化和反序列化函数
|
||||
const serializer = {
|
||||
serialize: vi.fn((value) => `custom:${JSON.stringify(value)}`),
|
||||
deserialize: vi.fn((value) => {
|
||||
if (value && value.startsWith('custom:')) {
|
||||
return JSON.parse(value.slice(7))
|
||||
}
|
||||
return null
|
||||
}),
|
||||
}
|
||||
// 使用自定义序列化器
|
||||
const value = useSessionStorage(
|
||||
'customKey',
|
||||
{ test: true },
|
||||
{
|
||||
serializer,
|
||||
},
|
||||
)
|
||||
// 验证自定义序列化器被使用
|
||||
expect(serializer.serialize).toHaveBeenCalled()
|
||||
// 检查存储的数据格式
|
||||
const lastCall = window.sessionStorage.setItem.mock.calls.pop()
|
||||
expect(lastCall[1]).toContain('custom:')
|
||||
// 模拟从存储加载数据
|
||||
window.sessionStorage.getItem.mockReturnValueOnce('custom:{"result":"loaded"}')
|
||||
// 创建一个新的hook实例使用相同的键和序列化器
|
||||
const loadedValue = useSessionStorage('customKey', null, { serializer })
|
||||
// 验证反序列化器被使用
|
||||
expect(serializer.deserialize).toHaveBeenCalled()
|
||||
expect(loadedValue.value).toEqual({ result: 'loaded' })
|
||||
})
|
||||
it('应该支持使用ref作为默认值', async () => {
|
||||
const defaultValueRef = ref('initialRef')
|
||||
// 使用ref作为默认值
|
||||
const value = useSessionStorage('refKey', defaultValueRef)
|
||||
// 验证初始值
|
||||
expect(value.value).toBe('initialRef')
|
||||
// 修改ref的值
|
||||
defaultValueRef.value = 'updatedRef'
|
||||
await nextTick()
|
||||
// 验证存储的值没有变化,只是默认值变化了
|
||||
expect(value.value).toBe('initialRef') // 已经初始化的值不会随着默认值ref变化
|
||||
// 创建一个新的带有相同键的实例但没有初始值
|
||||
window.sessionStorage.getItem.mockReturnValueOnce(null)
|
||||
const newValue = useSessionStorage('newRefKey', defaultValueRef)
|
||||
// 验证新实例使用了当前的ref值
|
||||
expect(newValue.value).toBe('updatedRef')
|
||||
})
|
||||
it('应该在组件卸载时取消事件监听', () => {
|
||||
// 使用onUnmounted来模拟组件卸载
|
||||
const unmountHandlers = []
|
||||
vi.mock('vue', async () => {
|
||||
const actual = await vi.importActual('vue')
|
||||
return {
|
||||
...actual,
|
||||
onUnmounted: (fn) => unmountHandlers.push(fn),
|
||||
}
|
||||
})
|
||||
// 创建hook实例
|
||||
useSessionStorage('testKey', 'value')
|
||||
// 验证添加了事件监听器
|
||||
expect(window.addEventListener).toHaveBeenCalledWith('storage', expect.any(Function))
|
||||
// 模拟组件卸载
|
||||
unmountHandlers.forEach((handler) => handler())
|
||||
// 验证移除了事件监听器
|
||||
expect(window.removeEventListener).toHaveBeenCalledWith('storage', expect.any(Function))
|
||||
})
|
||||
})
|
||||
//# sourceMappingURL=session-storage.spec.js.map
|
||||
36
frontend/packages/vue/hooks/test/setup.js
Normal file
36
frontend/packages/vue/hooks/test/setup.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'fake-indexeddb/auto'
|
||||
import { vi } from 'vitest'
|
||||
import { indexedDB, IDBKeyRange } from 'fake-indexeddb'
|
||||
// 设置全局变量
|
||||
globalThis.indexedDB = indexedDB
|
||||
globalThis.IDBKeyRange = IDBKeyRange
|
||||
// 清理 IndexedDB 数据库的辅助函数
|
||||
async function clearIndexedDB() {
|
||||
const databases = indexedDB._databases
|
||||
if (databases && databases instanceof Map) {
|
||||
const databaseNames = Array.from(databases.keys())
|
||||
await Promise.all(
|
||||
databaseNames.map(
|
||||
(name) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const request = indexedDB.deleteDatabase(name)
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => resolve()
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
// 清理函数
|
||||
beforeEach(async () => {
|
||||
// 重置所有模拟
|
||||
vi.resetModules()
|
||||
// 清理 indexedDB
|
||||
await clearIndexedDB()
|
||||
})
|
||||
// 测试完成后清理
|
||||
afterEach(async () => {
|
||||
// 清理 indexedDB
|
||||
await clearIndexedDB()
|
||||
})
|
||||
//# sourceMappingURL=setup.js.map
|
||||
305
frontend/packages/vue/hooks/test/socket.spec.js
Normal file
305
frontend/packages/vue/hooks/test/socket.spec.js
Normal file
@@ -0,0 +1,305 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import useSocket from '../src/socket'
|
||||
// 模拟WebSocket
|
||||
class MockWebSocket {
|
||||
url
|
||||
onopen = null
|
||||
onclose = null
|
||||
onmessage = null
|
||||
onerror = null
|
||||
readyState = 0 // CONNECTING
|
||||
constructor(url) {
|
||||
this.url = url
|
||||
// 模拟异步连接
|
||||
setTimeout(() => {
|
||||
this.readyState = 1 // OPEN
|
||||
this.onopen?.()
|
||||
}, 50)
|
||||
}
|
||||
send = vi.fn((data) => {
|
||||
// 模拟数据发送成功
|
||||
return true
|
||||
})
|
||||
close = vi.fn(() => {
|
||||
this.readyState = 3 // CLOSED
|
||||
this.onclose?.({ code: 1000 })
|
||||
})
|
||||
// 模拟触发错误
|
||||
triggerError(error) {
|
||||
this.onerror?.(error)
|
||||
}
|
||||
// 模拟接收消息
|
||||
simulateMessage(data) {
|
||||
this.onmessage?.({ data: typeof data === 'object' ? JSON.stringify(data) : data })
|
||||
}
|
||||
// 模拟连接关闭
|
||||
simulateClose(code = 1000, reason = '') {
|
||||
this.readyState = 3 // CLOSED
|
||||
this.onclose?.({ code, reason })
|
||||
}
|
||||
}
|
||||
describe('useSocket', () => {
|
||||
const originalWebSocket = global.WebSocket
|
||||
let mockSocket
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
// 安装模拟的WebSocket
|
||||
global.WebSocket = vi.fn((url) => {
|
||||
mockSocket = new MockWebSocket(url)
|
||||
return mockSocket
|
||||
})
|
||||
// 监视console.error和warn
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
afterEach(() => {
|
||||
global.WebSocket = originalWebSocket
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
it('应该连接到WebSocket', async () => {
|
||||
const url = 'ws://example.com'
|
||||
const { socket, connected } = useSocket(url)
|
||||
expect(global.WebSocket).toHaveBeenCalledWith(url)
|
||||
expect(socket.value).toBeTruthy()
|
||||
expect(connected.value).toBe(false)
|
||||
// 触发连接
|
||||
vi.advanceTimersByTime(50)
|
||||
await nextTick()
|
||||
expect(connected.value).toBe(true)
|
||||
})
|
||||
it('应该发送消息', async () => {
|
||||
const { socket, send, connected } = useSocket('ws://example.com')
|
||||
// 连接WebSocket
|
||||
vi.advanceTimersByTime(50)
|
||||
await nextTick()
|
||||
expect(connected.value).toBe(true)
|
||||
// 发送消息
|
||||
send({ type: 'test', data: '测试数据' })
|
||||
expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify({ type: 'test', data: '测试数据' }))
|
||||
// 测试发送字符串
|
||||
send('纯文本消息')
|
||||
expect(mockSocket.send).toHaveBeenCalledWith('纯文本消息')
|
||||
})
|
||||
it('应该接收消息', async () => {
|
||||
const { message, connected } = useSocket('ws://example.com')
|
||||
// 连接WebSocket
|
||||
vi.advanceTimersByTime(50)
|
||||
await nextTick()
|
||||
expect(connected.value).toBe(true)
|
||||
// 模拟接收JSON消息
|
||||
const testMessage = { type: 'update', data: '新数据' }
|
||||
mockSocket.onmessage?.({ data: JSON.stringify(testMessage) })
|
||||
await nextTick()
|
||||
expect(message.value).toEqual(testMessage)
|
||||
// 模拟接收非JSON消息
|
||||
mockSocket.onmessage?.({ data: 'plain text message' })
|
||||
await nextTick()
|
||||
expect(message.value).toEqual('plain text message')
|
||||
})
|
||||
it('应该支持数据中间件', async () => {
|
||||
// 创建中间件函数
|
||||
const middleware = (data) => {
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
return { ...parsed, processed: true }
|
||||
} catch (e) {
|
||||
return `处理后: ${data}`
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
const { message } = useSocket('ws://example.com', { middleware })
|
||||
// 连接WebSocket
|
||||
vi.advanceTimersByTime(50)
|
||||
await nextTick()
|
||||
// 模拟接收JSON消息
|
||||
const testMessage = { type: 'update', data: '新数据' }
|
||||
mockSocket.simulateMessage(testMessage)
|
||||
await nextTick()
|
||||
// 验证中间件处理了数据
|
||||
expect(message.value).toEqual({ ...testMessage, processed: true })
|
||||
// 测试非JSON消息
|
||||
mockSocket.simulateMessage('plain text')
|
||||
await nextTick()
|
||||
expect(message.value).toBe('处理后: plain text')
|
||||
})
|
||||
it('应该支持多个中间件函数', async () => {
|
||||
// 创建多个中间件函数
|
||||
const middleware1 = (data) => {
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
return JSON.parse(data)
|
||||
} catch (e) {
|
||||
return data
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
const middleware2 = (data) => {
|
||||
if (typeof data === 'object') {
|
||||
return { ...data, step2: true }
|
||||
}
|
||||
return `Step2: ${data}`
|
||||
}
|
||||
const { message } = useSocket('ws://example.com', {
|
||||
middleware: [middleware1, middleware2],
|
||||
})
|
||||
// 连接WebSocket
|
||||
vi.advanceTimersByTime(50)
|
||||
await nextTick()
|
||||
// 模拟接收JSON消息
|
||||
mockSocket.simulateMessage({ type: 'test' })
|
||||
await nextTick()
|
||||
// 验证多个中间件都处理了数据
|
||||
expect(message.value).toEqual({ type: 'test', step2: true })
|
||||
// 测试字符串消息
|
||||
mockSocket.simulateMessage('text')
|
||||
await nextTick()
|
||||
expect(message.value).toBe('Step2: text')
|
||||
})
|
||||
it('应该断开连接', async () => {
|
||||
const { disconnect, connected } = useSocket('ws://example.com')
|
||||
// 连接WebSocket
|
||||
vi.advanceTimersByTime(50)
|
||||
await nextTick()
|
||||
expect(connected.value).toBe(true)
|
||||
// 断开连接
|
||||
disconnect()
|
||||
await nextTick()
|
||||
expect(mockSocket.close).toHaveBeenCalled()
|
||||
expect(connected.value).toBe(false)
|
||||
})
|
||||
it('应该发送心跳包', async () => {
|
||||
// 使用较短的心跳间隔
|
||||
useSocket('ws://example.com', { heartbeatInterval: 100, heartbeatMessage: 'PING' })
|
||||
// 连接WebSocket
|
||||
vi.advanceTimersByTime(50)
|
||||
await nextTick()
|
||||
// 清除之前的send调用
|
||||
mockSocket.send.mockClear()
|
||||
// 等待心跳间隔
|
||||
vi.advanceTimersByTime(100)
|
||||
// 验证发送了心跳包
|
||||
expect(mockSocket.send).toHaveBeenCalledWith('PING')
|
||||
// 再等待一个间隔,应该再次发送
|
||||
mockSocket.send.mockClear()
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(mockSocket.send).toHaveBeenCalledWith('PING')
|
||||
})
|
||||
it('应该在连接关闭后自动重连', async () => {
|
||||
useSocket('ws://example.com', { autoReconnect: true, reconnectDelay: 200 })
|
||||
// 连接WebSocket
|
||||
vi.advanceTimersByTime(50)
|
||||
await nextTick()
|
||||
// 模拟连接关闭
|
||||
mockSocket.onclose?.({ code: 1006 }) // 非正常关闭
|
||||
await nextTick()
|
||||
// 清除之前的WebSocket调用
|
||||
global.WebSocket.mockClear()
|
||||
// 等待重连延迟
|
||||
vi.advanceTimersByTime(200)
|
||||
// 验证尝试重连
|
||||
expect(global.WebSocket).toHaveBeenCalledWith('ws://example.com')
|
||||
})
|
||||
it('主动断开连接不应该触发自动重连', async () => {
|
||||
const { disconnect } = useSocket('ws://example.com', { autoReconnect: true, reconnectDelay: 200 })
|
||||
// 连接WebSocket
|
||||
vi.advanceTimersByTime(50)
|
||||
await nextTick()
|
||||
// 主动断开连接
|
||||
disconnect()
|
||||
await nextTick()
|
||||
// 清除之前的WebSocket调用
|
||||
global.WebSocket.mockClear()
|
||||
// 等待重连延迟
|
||||
vi.advanceTimersByTime(200)
|
||||
// 验证没有尝试重连
|
||||
expect(global.WebSocket).not.toHaveBeenCalled()
|
||||
})
|
||||
it('应该在环境不支持WebSocket时给出警告', async () => {
|
||||
// 临时删除WebSocket
|
||||
global.WebSocket = undefined
|
||||
useSocket('ws://example.com')
|
||||
// 验证警告信息
|
||||
expect(console.error).toHaveBeenCalledWith('WebSocket is not supported in this environment.')
|
||||
})
|
||||
it('应该在到达最大重连次数后停止尝试', async () => {
|
||||
useSocket('ws://example.com', {
|
||||
autoReconnect: true,
|
||||
reconnectDelay: 100,
|
||||
maxReconnectAttempts: 2,
|
||||
})
|
||||
// 连接WebSocket
|
||||
vi.advanceTimersByTime(50)
|
||||
await nextTick()
|
||||
// 第一次断开连接
|
||||
mockSocket.simulateClose(1006)
|
||||
await nextTick()
|
||||
// 第一次重连
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(global.WebSocket).toHaveBeenCalledTimes(2)
|
||||
// 再次断开
|
||||
mockSocket.simulateClose(1006)
|
||||
await nextTick()
|
||||
// 第二次重连
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(global.WebSocket).toHaveBeenCalledTimes(3)
|
||||
// 再次断开
|
||||
mockSocket.simulateClose(1006)
|
||||
await nextTick()
|
||||
// 清除WebSocket调用记录
|
||||
global.WebSocket.mockClear()
|
||||
// 等待可能的第三次重连
|
||||
vi.advanceTimersByTime(100)
|
||||
// 验证没有第三次重连尝试
|
||||
expect(global.WebSocket).not.toHaveBeenCalled()
|
||||
})
|
||||
it('应该在连接错误时记录日志', async () => {
|
||||
useSocket('ws://example.com')
|
||||
// 连接WebSocket
|
||||
vi.advanceTimersByTime(50)
|
||||
await nextTick()
|
||||
// 模拟连接错误
|
||||
const errorEvent = new Event('error')
|
||||
mockSocket.triggerError(errorEvent)
|
||||
// 验证错误被记录
|
||||
expect(console.error).toHaveBeenCalledWith('WebSocket error:', errorEvent)
|
||||
})
|
||||
it('应该支持初始化后手动连接', async () => {
|
||||
const { socket, connect, connected } = useSocket('ws://example.com', {
|
||||
autoConnect: false, // 不自动连接
|
||||
})
|
||||
// 验证初始化时没有连接
|
||||
expect(global.WebSocket).not.toHaveBeenCalled()
|
||||
expect(socket.value).toBe(null)
|
||||
// 手动连接
|
||||
connect()
|
||||
// 验证连接建立
|
||||
expect(global.WebSocket).toHaveBeenCalledWith('ws://example.com')
|
||||
// 触发连接完成
|
||||
vi.advanceTimersByTime(50)
|
||||
await nextTick()
|
||||
expect(connected.value).toBe(true)
|
||||
})
|
||||
it('应该支持动态修改URL', async () => {
|
||||
const dynamicUrl = ref('ws://example.com')
|
||||
const { connected } = useSocket(dynamicUrl)
|
||||
// 初始连接
|
||||
vi.advanceTimersByTime(50)
|
||||
await nextTick()
|
||||
expect(connected.value).toBe(true)
|
||||
expect(global.WebSocket).toHaveBeenCalledWith('ws://example.com')
|
||||
// 断开现有连接
|
||||
mockSocket.simulateClose()
|
||||
await nextTick()
|
||||
// 修改URL
|
||||
dynamicUrl.value = 'ws://new-server.com'
|
||||
await nextTick()
|
||||
// 验证使用新URL重连
|
||||
expect(global.WebSocket).toHaveBeenCalledWith('ws://new-server.com')
|
||||
})
|
||||
})
|
||||
//# sourceMappingURL=socket.spec.js.map
|
||||
88
frontend/packages/vue/hooks/test/storage.spec.js
Normal file
88
frontend/packages/vue/hooks/test/storage.spec.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import useStorage from '../src/storage'
|
||||
// 创建模拟的 Storage
|
||||
class MockStorage {
|
||||
store = {}
|
||||
length = 0
|
||||
clear() {
|
||||
this.store = {}
|
||||
this.length = 0
|
||||
}
|
||||
getItem(key) {
|
||||
return this.store[key] || null
|
||||
}
|
||||
key(index) {
|
||||
return Object.keys(this.store)[index] || null
|
||||
}
|
||||
removeItem(key) {
|
||||
delete this.store[key]
|
||||
this.length = Object.keys(this.store).length
|
||||
}
|
||||
setItem(key, value) {
|
||||
this.store[key] = value
|
||||
this.length = Object.keys(this.store).length
|
||||
}
|
||||
}
|
||||
describe('useStorage', () => {
|
||||
let storage
|
||||
beforeEach(() => {
|
||||
storage = new MockStorage()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
it('初始化时返回默认值', () => {
|
||||
const initialValue = { name: 'test' }
|
||||
const value = useStorage('testKey', initialValue, {}, storage)
|
||||
expect(value.value).toEqual(initialValue)
|
||||
expect(storage.getItem('testKey')).not.toBeNull()
|
||||
})
|
||||
it('可以读取已存储的值', () => {
|
||||
const storedValue = { name: 'stored' }
|
||||
const serialized = JSON.stringify({ value: storedValue })
|
||||
storage.setItem('testKey', serialized)
|
||||
const value = useStorage('testKey', { name: 'default' }, {}, storage)
|
||||
expect(value.value).toEqual(storedValue)
|
||||
})
|
||||
it('当值发生变化时更新存储', async () => {
|
||||
const value = useStorage('testKey', { name: 'test' }, {}, storage)
|
||||
value.value = { name: 'updated' }
|
||||
await nextTick()
|
||||
const stored = JSON.parse(storage.getItem('testKey') || '{}')
|
||||
expect(stored.value).toEqual({ name: 'updated' })
|
||||
})
|
||||
it('当值为null或undefined时从存储中移除', async () => {
|
||||
const value = useStorage('testKey', { name: 'test' }, {}, storage)
|
||||
value.value = null
|
||||
await nextTick()
|
||||
expect(storage.getItem('testKey')).toBeNull()
|
||||
})
|
||||
it('支持配置过期时间', () => {
|
||||
vi.useFakeTimers()
|
||||
const now = Date.now()
|
||||
vi.setSystemTime(now)
|
||||
const value = useStorage('testKey', 'test', { expires: 1000 }, storage)
|
||||
const stored = JSON.parse(storage.getItem('testKey') || '{}')
|
||||
expect(stored.expires).toEqual(now + 1000)
|
||||
// 设置时间为刚好过期
|
||||
vi.setSystemTime(now + 1001)
|
||||
// 读取过期的值应该返回默认值
|
||||
const expiredValue = useStorage('testKey', 'default', {}, storage)
|
||||
expect(expiredValue.value).toBe('default')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
it('支持自定义合并策略', () => {
|
||||
const storedValue = { name: 'stored', age: 20 }
|
||||
storage.setItem('testKey', JSON.stringify({ value: storedValue }))
|
||||
// 使用自定义合并函数
|
||||
const mergeDefaults = (stored, defaults) => ({
|
||||
...defaults,
|
||||
name: stored.name,
|
||||
})
|
||||
const value = useStorage('testKey', { name: 'default', role: 'admin' }, { mergeDefaults }, storage)
|
||||
expect(value.value).toEqual({ name: 'stored', role: 'admin' })
|
||||
})
|
||||
})
|
||||
//# sourceMappingURL=storage.spec.js.map
|
||||
276
frontend/packages/vue/hooks/test/task-queue.spec.js
Normal file
276
frontend/packages/vue/hooks/test/task-queue.spec.js
Normal file
@@ -0,0 +1,276 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { nextTick, reactive, computed } from 'vue'
|
||||
import useTaskQueue from '../src/task-queue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
describe('useTaskQueue', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
it('应该添加任务到队列', async () => {
|
||||
const { addTask, taskList } = useTaskQueue()
|
||||
const task1 = vi.fn().mockResolvedValue('任务1结果')
|
||||
const task2 = vi.fn().mockResolvedValue('任务2结果')
|
||||
addTask('task1', task1)
|
||||
addTask('task2', task2)
|
||||
// 验证任务列表
|
||||
expect(taskList.value.length).toBe(2)
|
||||
expect(taskList.value[0].name).toBe('task1')
|
||||
expect(taskList.value[1].name).toBe('task2')
|
||||
})
|
||||
it('应该按顺序处理任务队列', async () => {
|
||||
const { addTask, processQueue, isProcessing } = useTaskQueue()
|
||||
const order = []
|
||||
const task1 = vi.fn().mockImplementation(async () => {
|
||||
order.push('task1')
|
||||
return '任务1结果'
|
||||
})
|
||||
const task2 = vi.fn().mockImplementation(async () => {
|
||||
order.push('task2')
|
||||
return '任务2结果'
|
||||
})
|
||||
const task3 = vi.fn().mockImplementation(async () => {
|
||||
order.push('task3')
|
||||
return '任务3结果'
|
||||
})
|
||||
// 添加任务
|
||||
addTask('task1', task1)
|
||||
addTask('task2', task2)
|
||||
addTask('task3', task3)
|
||||
// 开始处理队列
|
||||
const processPromise = processQueue()
|
||||
// 验证正在处理状态
|
||||
expect(isProcessing.value).toBe(true)
|
||||
// 解析所有Promise
|
||||
await processPromise
|
||||
// 验证所有任务都已执行
|
||||
expect(task1).toHaveBeenCalledTimes(1)
|
||||
expect(task2).toHaveBeenCalledTimes(1)
|
||||
expect(task3).toHaveBeenCalledTimes(1)
|
||||
// 验证执行顺序
|
||||
expect(order).toEqual(['task1', 'task2', 'task3'])
|
||||
// 验证处理完成后状态更新
|
||||
expect(isProcessing.value).toBe(false)
|
||||
})
|
||||
it('应该能够获取任务状态', async () => {
|
||||
const { addTask, processQueue, getTaskStatus } = useTaskQueue()
|
||||
const task = vi.fn().mockResolvedValue('任务结果')
|
||||
// 添加任务
|
||||
addTask('testTask', task)
|
||||
// 检查初始状态
|
||||
const status = getTaskStatus('testTask')
|
||||
expect(status.value.status).toBe(false)
|
||||
expect(status.value.result).toBe(null)
|
||||
expect(status.value.error).toBe(null)
|
||||
// 处理队列
|
||||
await processQueue()
|
||||
// 验证状态更新
|
||||
expect(status.value.status).toBe(true)
|
||||
expect(status.value.result).toBe('任务结果')
|
||||
expect(status.value.error).toBe(null)
|
||||
})
|
||||
it('应该能够获取任务结果', async () => {
|
||||
const { addTask, processQueue, getTaskResult } = useTaskQueue()
|
||||
const task = vi.fn().mockResolvedValue('任务结果')
|
||||
// 添加任务
|
||||
addTask('testTask', task)
|
||||
// 处理队列
|
||||
await processQueue()
|
||||
// 获取结果
|
||||
const result = await getTaskResult('testTask')
|
||||
expect(result).toBe('任务结果')
|
||||
})
|
||||
it('当任务失败时应该记录错误', async () => {
|
||||
const { addTask, processQueue, getTaskStatus } = useTaskQueue()
|
||||
const error = new Error('任务失败')
|
||||
const task = vi.fn().mockRejectedValue(error)
|
||||
// 添加任务
|
||||
addTask('failingTask', task)
|
||||
// 处理队列
|
||||
await processQueue()
|
||||
// 验证错误被记录
|
||||
const status = getTaskStatus('failingTask')
|
||||
expect(status.value.status).toBe(false)
|
||||
expect(status.value.error).toBe(error)
|
||||
})
|
||||
it('应该能够清除所有任务', async () => {
|
||||
const { addTask, clearAllTasks, taskList } = useTaskQueue()
|
||||
// 添加任务
|
||||
addTask('task1', vi.fn())
|
||||
addTask('task2', vi.fn())
|
||||
expect(taskList.value.length).toBe(2)
|
||||
// 清除任务
|
||||
clearAllTasks()
|
||||
// 验证任务已清除
|
||||
expect(taskList.value.length).toBe(0)
|
||||
})
|
||||
it('应该渲染加载组件', async () => {
|
||||
const { addTask, TaskQueueLoader, processQueue } = useTaskQueue()
|
||||
// 添加一个长时间运行的任务
|
||||
const longTask = () => new Promise((resolve) => setTimeout(() => resolve('完成'), 1000))
|
||||
addTask('longTask', longTask)
|
||||
// 挂载加载组件
|
||||
const wrapper = mount(TaskQueueLoader, {
|
||||
slots: {
|
||||
default: '<div>加载中...</div>',
|
||||
},
|
||||
})
|
||||
// 初始时不显示内容(没有开始处理)
|
||||
expect(wrapper.html()).not.toContain('加载中...')
|
||||
// 开始处理队列
|
||||
const processPromise = processQueue()
|
||||
await nextTick()
|
||||
// 加载中时显示内容
|
||||
expect(wrapper.html()).toContain('加载中...')
|
||||
// 完成任务
|
||||
vi.advanceTimersByTime(1000)
|
||||
await processPromise
|
||||
await nextTick()
|
||||
// 验证加载组件消失
|
||||
expect(wrapper.html()).not.toContain('加载中...')
|
||||
})
|
||||
it('任务应该返回Promise', async () => {
|
||||
const { addTask, processQueue } = useTaskQueue()
|
||||
const taskFn = vi.fn().mockResolvedValue('任务结果')
|
||||
// 添加任务并获取Promise
|
||||
const taskPromise = addTask('promiseTask', taskFn)
|
||||
// 验证返回的是Promise
|
||||
expect(taskPromise).toBeInstanceOf(Promise)
|
||||
// 等待处理队列
|
||||
processQueue()
|
||||
// 解析Promise
|
||||
const result = await taskPromise
|
||||
expect(result).toBe('任务结果')
|
||||
})
|
||||
it('应该拒绝无效的任务', async () => {
|
||||
const { addTask } = useTaskQueue()
|
||||
// 没有任务名称
|
||||
await expect(addTask('', vi.fn())).rejects.toThrow('任务名称和函数不能为空')
|
||||
// 没有任务函数
|
||||
// @ts-ignore - 测试不合法的输入
|
||||
await expect(addTask('emptyTask', null)).rejects.toThrow('任务名称和函数不能为空')
|
||||
})
|
||||
it('应该处理任务依赖关系', async () => {
|
||||
const { addTask, processQueue } = useTaskQueue()
|
||||
const order = []
|
||||
const results = {}
|
||||
// 创建依赖任务
|
||||
const task1 = vi.fn().mockImplementation(async () => {
|
||||
order.push('task1')
|
||||
return 'task1结果'
|
||||
})
|
||||
// 依赖task1结果的任务
|
||||
const task2 = vi.fn().mockImplementation(async (task1Result) => {
|
||||
order.push('task2')
|
||||
return `${task1Result} -> task2结果`
|
||||
})
|
||||
// 依赖task2结果的任务
|
||||
const task3 = vi.fn().mockImplementation(async (task2Result) => {
|
||||
order.push('task3')
|
||||
return `${task2Result} -> task3结果`
|
||||
})
|
||||
// 添加任务并建立依赖关系
|
||||
const task1Promise = addTask('task1', task1)
|
||||
task1Promise.then((result) => {
|
||||
results.task1 = result
|
||||
addTask('task2', () => task2(result))
|
||||
})
|
||||
// 开始处理
|
||||
await processQueue()
|
||||
// 添加第三个任务,依赖第二个任务
|
||||
await addTask('task2', () => task2(results.task1))
|
||||
// 运行第二个任务
|
||||
await processQueue()
|
||||
// 等待task2结果
|
||||
const task2Result = await getTaskResult('task2')
|
||||
results.task2 = task2Result
|
||||
// 添加第三个任务
|
||||
await addTask('task3', () => task3(results.task2))
|
||||
// 运行第三个任务
|
||||
await processQueue()
|
||||
// 验证执行顺序
|
||||
expect(order).toEqual(['task1', 'task2', 'task3'])
|
||||
// 验证任务结果正确传递
|
||||
expect(results.task1).toBe('task1结果')
|
||||
expect(results.task2).toBe('task1结果 -> task2结果')
|
||||
expect(await getTaskResult('task3')).toBe('task1结果 -> task2结果 -> task3结果')
|
||||
})
|
||||
it('应该支持查询特定任务', async () => {
|
||||
const { addTask, processQueue, findTask } = useTaskQueue()
|
||||
addTask('task1', vi.fn().mockResolvedValue('任务1结果'))
|
||||
addTask('task2', vi.fn().mockResolvedValue('任务2结果'))
|
||||
const foundTask = findTask('task2')
|
||||
expect(foundTask).toBeTruthy()
|
||||
expect(foundTask?.name).toBe('task2')
|
||||
// 查询不存在的任务
|
||||
const nonExistingTask = findTask('non-existing')
|
||||
expect(nonExistingTask).toBeUndefined()
|
||||
})
|
||||
it('应该支持任务优先级', async () => {
|
||||
const { addTask, processQueue } = useTaskQueue()
|
||||
const executionOrder = []
|
||||
// 添加普通任务
|
||||
addTask('normalTask', async () => {
|
||||
executionOrder.push('normalTask')
|
||||
return 'normal'
|
||||
})
|
||||
// 添加高优先级任务
|
||||
addTask(
|
||||
'highPriorityTask',
|
||||
async () => {
|
||||
executionOrder.push('highPriorityTask')
|
||||
return 'high'
|
||||
},
|
||||
{ priority: 10 },
|
||||
)
|
||||
// 添加低优先级任务
|
||||
addTask(
|
||||
'lowPriorityTask',
|
||||
async () => {
|
||||
executionOrder.push('lowPriorityTask')
|
||||
return 'low'
|
||||
},
|
||||
{ priority: -5 },
|
||||
)
|
||||
// 处理队列
|
||||
await processQueue()
|
||||
// 验证执行顺序按优先级
|
||||
expect(executionOrder).toEqual(['highPriorityTask', 'normalTask', 'lowPriorityTask'])
|
||||
})
|
||||
it('应该支持外部管理的响应式任务列表', async () => {
|
||||
// 创建外部的响应式任务列表
|
||||
const externalTasks = reactive([
|
||||
{ name: 'externalTask1', fn: vi.fn().mockResolvedValue('外部任务1结果') },
|
||||
{ name: 'externalTask2', fn: vi.fn().mockResolvedValue('外部任务2结果') },
|
||||
])
|
||||
// 创建任务队列,使用外部的任务列表
|
||||
const { processQueue, getTaskResult } = useTaskQueue({
|
||||
tasks: computed(() => externalTasks),
|
||||
})
|
||||
// 处理队列
|
||||
await processQueue()
|
||||
// 验证任务执行
|
||||
expect(externalTasks[0].fn).toHaveBeenCalled()
|
||||
expect(externalTasks[1].fn).toHaveBeenCalled()
|
||||
// 验证结果
|
||||
const result1 = await getTaskResult('externalTask1')
|
||||
const result2 = await getTaskResult('externalTask2')
|
||||
expect(result1).toBe('外部任务1结果')
|
||||
expect(result2).toBe('外部任务2结果')
|
||||
// 添加新任务到外部列表
|
||||
externalTasks.push({
|
||||
name: 'externalTask3',
|
||||
fn: vi.fn().mockResolvedValue('外部任务3结果'),
|
||||
})
|
||||
// 再次处理队列
|
||||
await processQueue()
|
||||
// 验证新任务被执行
|
||||
expect(externalTasks[2].fn).toHaveBeenCalled()
|
||||
const result3 = await getTaskResult('externalTask3')
|
||||
expect(result3).toBe('外部任务3结果')
|
||||
})
|
||||
})
|
||||
//# sourceMappingURL=task-queue.spec.js.map
|
||||
225
frontend/packages/vue/hooks/test/throttle-fn.spec.js
Normal file
225
frontend/packages/vue/hooks/test/throttle-fn.spec.js
Normal file
@@ -0,0 +1,225 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { useThrottleFn } from '../src/throttle-fn'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { ref, nextTick } from 'vue'
|
||||
describe('useThrottleFn', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
it('应该立即执行第一次调用', () => {
|
||||
const mockFn = vi.fn()
|
||||
const throttled = useThrottleFn(mockFn, 100)
|
||||
throttled('参数1', '参数2')
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
expect(mockFn).toHaveBeenCalledWith('参数1', '参数2')
|
||||
})
|
||||
it('在规定延迟内多次调用应该只执行一次', () => {
|
||||
const mockFn = vi.fn()
|
||||
const throttled = useThrottleFn(mockFn, 100)
|
||||
throttled()
|
||||
throttled()
|
||||
throttled()
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('延迟时间过后应该能再次执行', () => {
|
||||
const mockFn = vi.fn()
|
||||
const throttled = useThrottleFn(mockFn, 100)
|
||||
// 第一次调用立即执行
|
||||
throttled()
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
// 延迟内的调用应该被忽略
|
||||
throttled()
|
||||
throttled()
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
// 前进100ms
|
||||
vi.advanceTimersByTime(100)
|
||||
// 延迟后再次调用应该能执行
|
||||
throttled()
|
||||
expect(mockFn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
it('应该在节流期间保留最后一次调用,并在延迟后执行', () => {
|
||||
const mockFn = vi.fn()
|
||||
const throttled = useThrottleFn(mockFn, 100)
|
||||
// 第一次调用立即执行
|
||||
throttled('第一次')
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
expect(mockFn).toHaveBeenLastCalledWith('第一次')
|
||||
// 前进50ms,此时仍在节流期间
|
||||
vi.advanceTimersByTime(50)
|
||||
// 节流期间的调用应该被延迟
|
||||
throttled('第二次')
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
// 前进剩余的50ms
|
||||
vi.advanceTimersByTime(50)
|
||||
// 延迟后执行最后一次调用
|
||||
expect(mockFn).toHaveBeenCalledTimes(2)
|
||||
expect(mockFn).toHaveBeenLastCalledWith('第二次')
|
||||
})
|
||||
it('在组件卸载时应该清除定时器', () => {
|
||||
const mockFn = vi.fn()
|
||||
// 模拟组件中使用hook
|
||||
const wrapper = mount({
|
||||
template: '<div></div>',
|
||||
setup() {
|
||||
const throttled = useThrottleFn(mockFn, 100)
|
||||
// 触发第一次调用
|
||||
throttled()
|
||||
// 50ms后再次调用,此时在节流期间
|
||||
setTimeout(() => {
|
||||
throttled()
|
||||
}, 50)
|
||||
return { throttled }
|
||||
},
|
||||
})
|
||||
// 前进50ms,触发第二次调用
|
||||
vi.advanceTimersByTime(50)
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
// 卸载组件,此时应该清除定时器
|
||||
wrapper.unmount()
|
||||
// 前进剩余的50ms,由于组件已卸载,定时器应该被清除,不会执行第二次调用
|
||||
vi.advanceTimersByTime(50)
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('应该使用默认延迟时间', () => {
|
||||
const mockFn = vi.fn()
|
||||
const throttled = useThrottleFn(mockFn) // 使用默认延迟200ms
|
||||
throttled()
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
throttled()
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
// 前进100ms,应该还不会再次调用
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
// 再前进100ms,达到默认的200ms
|
||||
vi.advanceTimersByTime(100)
|
||||
// 此时已经可以再次调用
|
||||
throttled()
|
||||
expect(mockFn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
it('支持leading选项为false,首次调用不立即执行', () => {
|
||||
const mockFn = vi.fn()
|
||||
const throttled = useThrottleFn(mockFn, 100, { leading: false })
|
||||
throttled()
|
||||
expect(mockFn).not.toHaveBeenCalled()
|
||||
// 前进100ms,首次调用应该被延迟执行
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('支持trailing选项为false,延迟期间最后一次调用不会被保留执行', () => {
|
||||
const mockFn = vi.fn()
|
||||
const throttled = useThrottleFn(mockFn, 100, { trailing: false })
|
||||
// 第一次调用立即执行
|
||||
throttled()
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
// 前进50ms,此时仍在节流期间
|
||||
vi.advanceTimersByTime(50)
|
||||
// 节流期间的调用不会被保留
|
||||
throttled()
|
||||
// 前进剩余的50ms
|
||||
vi.advanceTimersByTime(50)
|
||||
// 由于trailing为false,最后一次调用不会延迟执行
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('支持在执行期间获取this上下文', () => {
|
||||
const obj = {
|
||||
value: '测试值',
|
||||
method() {
|
||||
return this.value
|
||||
},
|
||||
}
|
||||
// 监控method方法
|
||||
const spy = vi.spyOn(obj, 'method')
|
||||
// 创建节流函数
|
||||
const throttled = useThrottleFn(obj.method.bind(obj), 100)
|
||||
// 调用并检查返回值
|
||||
const result = throttled()
|
||||
expect(result).toBe('测试值')
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('支持取消功能,取消后待执行的调用不会被执行', () => {
|
||||
const mockFn = vi.fn()
|
||||
const { run, cancel } = useThrottleFn(mockFn, 100)
|
||||
// 第一次调用立即执行
|
||||
run()
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
// 前进50ms,此时仍在节流期间
|
||||
vi.advanceTimersByTime(50)
|
||||
// 节流期间的调用应该被延迟
|
||||
run()
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
// 取消待执行的调用
|
||||
cancel()
|
||||
// 前进剩余的50ms
|
||||
vi.advanceTimersByTime(50)
|
||||
// 由于已取消,最后一次调用不会被执行
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('支持flush功能,立即执行待执行的调用', () => {
|
||||
const mockFn = vi.fn()
|
||||
const { run, flush } = useThrottleFn(mockFn, 100)
|
||||
// 第一次调用立即执行
|
||||
run('初始参数')
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
expect(mockFn).toHaveBeenLastCalledWith('初始参数')
|
||||
// 前进50ms,此时仍在节流期间
|
||||
vi.advanceTimersByTime(50)
|
||||
// 节流期间的调用应该被延迟
|
||||
run('待执行参数')
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
// 立即执行待执行的调用
|
||||
flush()
|
||||
// 待执行的调用应该立即被执行
|
||||
expect(mockFn).toHaveBeenCalledTimes(2)
|
||||
expect(mockFn).toHaveBeenLastCalledWith('待执行参数')
|
||||
})
|
||||
it('支持动态修改延迟时间', async () => {
|
||||
const mockFn = vi.fn()
|
||||
const delay = ref(100)
|
||||
// 使用响应式延迟时间
|
||||
const throttled = useThrottleFn(mockFn, delay)
|
||||
// 第一次调用立即执行
|
||||
throttled()
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
// 修改延迟时间为200ms
|
||||
delay.value = 200
|
||||
await nextTick()
|
||||
// 前进100ms,由于延迟已变为200ms,还不能执行下一次调用
|
||||
vi.advanceTimersByTime(100)
|
||||
throttled()
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
// 再前进100ms,总共200ms,此时应该可以执行下一次调用
|
||||
vi.advanceTimersByTime(100)
|
||||
throttled()
|
||||
expect(mockFn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
it('支持节流函数返回Promise', async () => {
|
||||
const mockFn = vi.fn().mockResolvedValue('结果')
|
||||
const throttled = useThrottleFn(mockFn, 100)
|
||||
// 调用并等待Promise解析
|
||||
const promise = throttled()
|
||||
expect(mockFn).toHaveBeenCalledTimes(1)
|
||||
// 验证Promise解析结果
|
||||
const result = await promise
|
||||
expect(result).toBe('结果')
|
||||
})
|
||||
it('在微任务队列中执行,保持事件顺序', async () => {
|
||||
// 记录事件顺序
|
||||
const events = []
|
||||
const mockFn = vi.fn(() => {
|
||||
events.push('函数执行')
|
||||
})
|
||||
const throttled = useThrottleFn(mockFn, 0)
|
||||
events.push('调用前')
|
||||
throttled()
|
||||
events.push('调用后')
|
||||
// 等待微任务队列完成
|
||||
await Promise.resolve()
|
||||
// 验证事件顺序,即使延迟为0,也应该在当前事件循环结束后执行
|
||||
expect(events).toEqual(['调用前', '函数执行', '调用后'])
|
||||
})
|
||||
})
|
||||
//# sourceMappingURL=throttle-fn.spec.js.map
|
||||
57
frontend/packages/vue/hooks/test/title.spec.js
Normal file
57
frontend/packages/vue/hooks/test/title.spec.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { useTitle } from '../src/title'
|
||||
import { nextTick } from 'vue'
|
||||
describe('useTitle', () => {
|
||||
const originalTitle = document.title
|
||||
beforeEach(() => {
|
||||
// 每个测试前重置标题
|
||||
document.title = originalTitle
|
||||
})
|
||||
afterEach(() => {
|
||||
// 每个测试后恢复原始标题
|
||||
document.title = originalTitle
|
||||
})
|
||||
it('应该设置初始标题', async () => {
|
||||
useTitle('新标题')
|
||||
await nextTick()
|
||||
expect(document.title).toBe('新标题')
|
||||
})
|
||||
it('应该响应式更新标题', async () => {
|
||||
const title = useTitle('初始标题')
|
||||
await nextTick()
|
||||
expect(document.title).toBe('初始标题')
|
||||
title.value = '更新的标题'
|
||||
await nextTick()
|
||||
expect(document.title).toBe('更新的标题')
|
||||
})
|
||||
it('应该能处理空标题', async () => {
|
||||
useTitle('')
|
||||
await nextTick()
|
||||
expect(document.title).toBe('')
|
||||
})
|
||||
it('应该能处理特殊字符', async () => {
|
||||
const specialTitle = '特殊 & 字符 < > " \''
|
||||
useTitle(specialTitle)
|
||||
await nextTick()
|
||||
expect(document.title).toBe(specialTitle)
|
||||
})
|
||||
it('应该在不同组件之间共享标题', async () => {
|
||||
// 模拟第一个组件
|
||||
const title1 = useTitle('组件1标题')
|
||||
await nextTick()
|
||||
expect(document.title).toBe('组件1标题')
|
||||
// 模拟第二个组件
|
||||
const title2 = useTitle('组件2标题')
|
||||
await nextTick()
|
||||
expect(document.title).toBe('组件2标题')
|
||||
// 第一个组件更新标题
|
||||
title1.value = '组件1更新标题'
|
||||
await nextTick()
|
||||
expect(document.title).toBe('组件1更新标题')
|
||||
// 第二个组件更新标题
|
||||
title2.value = '组件2更新标题'
|
||||
await nextTick()
|
||||
expect(document.title).toBe('组件2更新标题')
|
||||
})
|
||||
})
|
||||
//# sourceMappingURL=title.spec.js.map
|
||||
9
frontend/packages/vue/hooks/tsconfig.json
Normal file
9
frontend/packages/vue/hooks/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@baota/typescript/vue.json",
|
||||
"include": ["**/*.{ts,tsx,vue}", "eslint.config.js", "types.d.ts"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist", // 输出目录
|
||||
"baseUrl": "./",
|
||||
"sourceMap": true
|
||||
}
|
||||
}
|
||||
3
frontend/packages/vue/hooks/types.d.ts
vendored
Normal file
3
frontend/packages/vue/hooks/types.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
declare interface Window {
|
||||
request_token: string
|
||||
}
|
||||
0
frontend/packages/vue/hooks/vite.config.ts
Normal file
0
frontend/packages/vue/hooks/vite.config.ts
Normal file
Reference in New Issue
Block a user