feat: 网络请求+发布订阅模块

This commit is contained in:
2026-06-09 10:12:09 +08:00
parent 391def3c92
commit ed86444fe6
10 changed files with 636 additions and 2 deletions

View File

@@ -0,0 +1,17 @@
import { createDiscreteApi } from 'naive-ui'
import { eventBus, AppEvents } from '@/utils/eventBus'
import type { RequestErrorEvent } from './types'
/** 离散式 API可在组件外使用 */
const { message } = createDiscreteApi(['message'])
/**
* 注册请求错误 UI 展示
*
* 订阅应用级 REQUEST_ERROR 事件,统一展示错误提示,
* 实现网络模块与 UI 的解耦。
*/
eventBus.on(AppEvents.REQUEST_ERROR, (payload: unknown) => {
const { message: errorMessage } = payload as RequestErrorEvent
message.error(errorMessage)
})

5
src/api/request/index.ts Normal file
View File

@@ -0,0 +1,5 @@
export { requestClient } from './requestClient'
export type { ApiResponse, RequestErrorEvent, RequestOptions } from './types'
/** 注册错误 UI 展示(副作用导入,应用启动时执行) */
import './errorUI'

View File

@@ -0,0 +1,243 @@
import axios, { type AxiosInstance, type AxiosRequestConfig, type CreateAxiosDefaults } from 'axios'
import { useAccountStore } from '@/stores'
import { eventBus, AppEvents } from '@/utils/eventBus'
import type { ApiResponse, InternalRequestConfig, RequestErrorEvent } from './types'
/**
* 替换 URL 中的路径参数占位符
*
* @example
* resolvePathParams('/user/:id/posts', { id: 1 }) // → '/user/1/posts'
*/
function resolvePathParams(url: string, pathParams?: Record<string, string | number>): string {
if (!pathParams) return url
return url.replace(/:(\w+)/g, (_, key) => {
const value = pathParams[key]
if (value === undefined) {
throw new Error(`路径参数 "${key}" 缺失`)
}
return String(value)
})
}
/**
* 基于 axios 封装的网络请求客户端
*
* 不依赖任何 UI 库,错误通过事件总线发布,由外部订阅处理。
*
* @example
* ```ts
* // GET 请求
* const res = await requestClient.get<UserListResult>('/users', { page: 1 })
*
* // POST 请求
* const res = await requestClient.post<LoginResult>('/auth/login', { username, password })
* ```
*/
class RequestClient {
/** axios 实例 */
private instance: AxiosInstance
constructor(config?: CreateAxiosDefaults) {
this.instance = axios.create({
baseURL: '/api',
timeout: 15000,
headers: {
'Content-Type': 'application/json'
},
...config
})
this.setupInterceptors()
}
/** 注册请求和响应拦截器 */
private setupInterceptors(): void {
// === 请求拦截器 ===
this.instance.interceptors.request.use(
(config) => {
const reqConfig = config as InternalRequestConfig
// 替换路径参数 :param → 实际值
if (reqConfig.pathParams && config.url) {
config.url = resolvePathParams(config.url, reqConfig.pathParams)
}
// 跳过鉴权时不注入 token
if (!reqConfig?.skipAuth) {
const accountStore = useAccountStore()
if (accountStore.token) {
config.headers.Authorization = `Bearer ${accountStore.token}`
}
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// === 响应拦截器 ===
// 拦截器内部将 axiosResponse 解包为 ApiResponse
// 返回类型与 axios 类型签名不匹配,但公共方法已声明正确返回类型。
this.instance.interceptors.response.use(
// @ts-expect-error 拦截器解包 axiosResponse → ApiResponse
(response) => {
const data = response.data as ApiResponse
// 业务成功
if (data.code === 0) {
return data
}
// 业务错误事件载荷
const errorEvent: RequestErrorEvent = {
type: 'business',
code: data.code,
message: data.message || '请求失败'
}
// 401 → 额外发布未授权事件,解耦路由
if (data.code === 401) {
eventBus.emit(AppEvents.UNAUTHORIZED)
}
// 发布请求错误事件,由 errorUI 模块订阅展示
eventBus.emit(AppEvents.REQUEST_ERROR, errorEvent)
return Promise.reject(data)
},
(error) => {
let errorEvent: RequestErrorEvent
if (error.response?.status === 401) {
eventBus.emit(AppEvents.UNAUTHORIZED)
errorEvent = {
type: 'http',
code: 401,
message: '登录已过期,请重新登录'
}
} else if (!error.response) {
errorEvent = {
type: 'network',
message: '网络异常,请检查网络连接'
}
} else {
errorEvent = {
type: 'http',
code: error.response.status,
message: `请求错误 (${error.response.status})`
}
}
eventBus.emit(AppEvents.REQUEST_ERROR, errorEvent)
return Promise.reject(error)
}
)
}
/**
* 通用请求方法
*
* 适用于路径参数、混合参数等复杂场景。
*
* @example
* ```ts
* // 路径参数 + 查询参数
* requestClient.request<PostList>({
* method: 'GET',
* url: '/user/:id/posts',
* params: { page: 1 },
* pathParams: { id: 1 }
* })
*
* // 路径参数 + 请求体
* requestClient.request<Result>({
* method: 'POST',
* url: '/user/:id/post',
* data: body,
* pathParams: { id: 1 }
* })
*
* // 自定义请求头 + 跳过鉴权
* requestClient.request<Result>({
* method: 'POST',
* url: '/public/upload',
* data: formData,
* headers: { 'Content-Type': 'multipart/form-data' },
* requestOptions: { skipAuth: true }
* })
* ```
*/
request<T = unknown>(config: InternalRequestConfig): Promise<ApiResponse<T>> {
return this.instance.request(config)
}
/**
* GET 请求
* @param url - 请求地址
* @param params - 查询参数
* @param config - 额外配置pathParams、requestOptions 等)
*/
get<T = unknown>(
url: string,
params?: Record<string, unknown>,
config?: InternalRequestConfig
): Promise<ApiResponse<T>> {
return this.instance.get(url, { params, ...config })
}
/**
* POST 请求
* @param url - 请求地址
* @param data - 请求体
* @param config - 额外配置pathParams、requestOptions 等)
*/
post<T = unknown>(
url: string,
data?: unknown,
config?: InternalRequestConfig
): Promise<ApiResponse<T>> {
return this.instance.post(url, data, config)
}
/**
* PUT 请求
* @param url - 请求地址
* @param data - 请求体
* @param config - 额外配置pathParams、requestOptions 等)
*/
put<T = unknown>(
url: string,
data?: unknown,
config?: InternalRequestConfig
): Promise<ApiResponse<T>> {
return this.instance.put(url, data, config)
}
/**
* DELETE 请求
* @param url - 请求地址
* @param config - 额外配置pathParams、requestOptions 等)
*/
delete<T = unknown>(url: string, config?: InternalRequestConfig): Promise<ApiResponse<T>> {
return this.instance.delete(url, config)
}
/**
* PATCH 请求
* @param url - 请求地址
* @param data - 请求体
* @param config - 额外配置pathParams、requestOptions 等)
*/
patch<T = unknown>(
url: string,
data?: unknown,
config?: InternalRequestConfig
): Promise<ApiResponse<T>> {
return this.instance.patch(url, data, config)
}
}
/** 请求客户端单例 */
export const requestClient = new RequestClient()

29
src/api/request/types.ts Normal file
View File

@@ -0,0 +1,29 @@
import type { AxiosRequestConfig } from 'axios'
/** 后端统一响应格式 */
export interface ApiResponse<T = unknown> {
/** 业务状态码0 表示成功 */
code: number
/** 响应消息 */
message: string
/** 响应数据 */
data: T
}
/** 扩展的请求配置,包含自定义选项 */
export interface InternalRequestConfig extends AxiosRequestConfig {
/** 路径参数,替换 URL 中的 :param 占位符(如 /user/:id → /user/1 */
pathParams?: Record<string, string | number>
/** 跳过鉴权注入(不携带 Authorization 头) */
skipAuth?: boolean
}
/** 请求错误事件载荷 */
export interface RequestErrorEvent {
/** 错误类型 */
type: 'business' | 'http' | 'network'
/** 业务状态码business 类型时有效) */
code?: number
/** 错误消息 */
message: string
}

View File

@@ -1,4 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router'
import { eventBus, AppEvents } from '@/utils/eventBus'
import { useAccountStore } from '@/stores'
import { createRouterGuard } from './guard.ts'
import routes from './routes.ts'
@@ -13,4 +15,11 @@ const router = createRouter({
// 创建路由守卫
createRouterGuard(router)
/** 订阅 401 未授权事件:清除 token 并跳转登录页 */
eventBus.on(AppEvents.UNAUTHORIZED, () => {
const accountStore = useAccountStore()
accountStore.token = ''
router.push('/auth/login')
})
export default router

91
src/utils/eventBus.ts Normal file
View File

@@ -0,0 +1,91 @@
/** 事件处理器类型 */
type EventHandler = (...args: unknown[]) => void
/** 应用级事件常量 */
export const AppEvents = {
/** 401 未授权 */
UNAUTHORIZED: 'app:unauthorized',
/** 请求错误(业务错误 / HTTP 错误 / 网络错误) */
REQUEST_ERROR: 'app:request:error'
} as const
/**
* 发布订阅事件总线
*
* @example
* ```ts
* // 注册事件
* eventBus.on(AppEvents.UNAUTHORIZED, () => {
* router.push('/auth/login')
* })
*
* // 发布事件
* eventBus.emit(AppEvents.UNAUTHORIZED)
* ```
*/
class EventBus {
/** 事件名 → 处理器集合 */
private handlers = new Map<string, Set<EventHandler>>()
/**
* 注册事件处理器
* @param event - 事件名
* @param handler - 处理器函数
*/
on(event: string, handler: EventHandler): void {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set())
}
this.handlers.get(event)!.add(handler)
}
/**
* 注册一次性事件处理器(触发后自动取消注册)
* @param event - 事件名
* @param handler - 处理器函数
*/
once(event: string, handler: EventHandler): void {
const wrapper: EventHandler = (...args: unknown[]) => {
handler(...args)
this.off(event, wrapper)
}
this.on(event, wrapper)
}
/**
* 取消注册事件处理器
* @param event - 事件名
* @param handler - 要取消的处理器函数
*/
off(event: string, handler: EventHandler): void {
this.handlers.get(event)?.delete(handler)
}
/**
* 发布事件
* @param event - 事件名
* @param args - 传递给处理器的参数
*/
emit(event: string, ...args: unknown[]): void {
const handlers = this.handlers.get(event)
if (!handlers) return
handlers.forEach((handler) => {
handler(...args)
})
}
/**
* 清除指定事件的所有处理器,或清除所有事件
* @param event - 可选,不传则清除全部
*/
clear(event?: string): void {
if (event) {
this.handlers.delete(event)
} else {
this.handlers.clear()
}
}
}
/** 事件总线单例 */
export const eventBus = new EventBus()

View File

@@ -64,14 +64,34 @@ const rules: FormRules = {
{
required: true,
message: '请输入登陆账号',
trigger: ['blur']
trigger: ['blur'],
validator: (rule: FormItemRule, value: string) => {
if (value.length < 2) {
return new Error('账号长度不能小于2位')
} else if (value.length > 20) {
return new Error('账号长度不能大于20位')
} else if (!/^[A-Za-z][A-Za-z0-9]*$/.test(value)) {
return new Error('账号以字母开头,只能包含字母和数字')
}
return true
}
}
],
password: [
{
required: true,
message: '请输入登陆密码',
trigger: ['blur']
trigger: ['blur'],
validator: (rule: FormItemRule, value: string) => {
if (value.length < 6) {
return new Error('密码长度不能小于6位')
} else if (value.length > 20) {
return new Error('密码长度不能大于20位')
} else if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/.test(value)) {
return new Error('密码必须包含大小写字母和数字')
}
return true
}
}
]
}