feat: 初始化前端项目基础结构

- 添加项目配置文件(tsconfig, eslint, prettier等)
- 实现基础路由和页面布局
- 添加全局状态管理和API请求封装
- 集成UI组件库和主题系统
- 添加文档网站和示例页面
- 配置CI/CD和工作流
This commit is contained in:
zhangtao
2026-05-01 00:47:48 +08:00
parent ff738007cd
commit 5c29d22aa5
256 changed files with 45257 additions and 2 deletions
+114
View File
@@ -0,0 +1,114 @@
import type { uniappRequestAdapter } from '@alova/adapter-uniapp'
import type { IResponse } from '../types'
import AdapterUniapp from '@alova/adapter-uniapp'
import { createAlova } from 'alova'
import { createServerTokenAuthentication } from 'alova/client'
import VueHook from 'alova/vue'
import { useUserStore } from '@/store/userStore'
// 移除了 @dcloudio/types 导入,该模块不存在
import { toLoginPage } from '@/utils/toLoginPage'
import { ContentTypeEnum, ResultEnum, ShowMessage } from '../tools/enum'
// 配置动态Tag
export const API_DOMAINS = {
DEFAULT: import.meta.env.VITE_API_BASE_URL || '',
SECONDARY: import.meta.env.VITE_SERVER_BASEURL_SECONDARY || '',
}
/**
* 创建请求实例
*/
const { onAuthRequired, onResponseRefreshToken }
= createServerTokenAuthentication<typeof VueHook, typeof uniappRequestAdapter>({
// 如果下面拦截不到,请使用 refreshTokenOnSuccess by 群友@琛
refreshTokenOnError: {
isExpired: (error) => {
return error.response?.status === ResultEnum.Unauthorized
},
handler: async () => {
try {
// await authLogin();
}
catch (error) {
// 切换到登录页
toLoginPage({ mode: 'reLaunch' })
throw error
}
},
},
})
/**
* alova 请求实例
*/
const alovaInstance = createAlova({
baseURL: `${API_DOMAINS.DEFAULT}${import.meta.env.VITE_APP_BASE_API || ''}`,
...AdapterUniapp(),
timeout: 10000,
statesHook: VueHook,
beforeRequest: onAuthRequired((method) => {
// 设置默认 Content-Type
method.config.headers = {
ContentType: ContentTypeEnum.JSON,
Accept: 'application/json, text/plain, */*',
...method.config.headers,
}
const { config } = method
// 修复认证逻辑:如果 ignoreAuth 为 true,则忽略认证
// 特殊处理验证码和登录请求,总是跳过认证
const isCaptchaRequest = method.url.includes('/captcha/get')
const isLoginRequest = method.url.includes('/system/auth/login')
const shouldAuth
= !isCaptchaRequest && !isLoginRequest && !config.meta?.ignoreAuth
// 处理认证信息 自行处理认证问题
if (shouldAuth) {
const userStore = useUserStore()
const token = userStore.getAccessToken()
// 只在需要认证时检查 token,验证码请求跳过
if (!token && !isCaptchaRequest) {
throw new Error('[请求错误]:未登录')
}
// 设置认证头
if (token) {
method.config.headers.Authorization = `Bearer ${token}`
}
}
// 处理动态域名
if (config.meta?.domain) {
method.baseURL = config.meta.domain
}
}),
responded: onResponseRefreshToken((response, method) => {
const { config } = method
const { requestType } = config
const { statusCode, data: rawData } = response as any
// 处理特殊请求类型(上传/下载)
if (requestType === 'upload' || requestType === 'download') {
return response
}
// 处理 HTTP 状态码错误
if (statusCode !== 200) {
const errorMessage
= rawData?.msg || rawData?.message || (rawData as any)?.error || ShowMessage(statusCode) || `HTTP请求错误[${statusCode}]`
throw new Error(errorMessage)
}
// 处理业务逻辑错误
const { code, message, msg, data } = rawData as IResponse
// 0和200当做成功都很普遍,这里直接兼容两者,见 ResultEnum
if (code !== ResultEnum.Success0 && code !== ResultEnum.Success200) {
const errorMessage = msg || message || '请求错误'
throw new Error(errorMessage)
}
// 处理成功响应,返回业务数据
return data
}),
})
export const http = alovaInstance
+171
View File
@@ -0,0 +1,171 @@
import type { CustomRequestOptions, IResponse } from '../types'
import { useUserStore } from '@/store/userStore'
import { toLoginPage } from '@/utils/toLoginPage'
import { ResultEnum } from '../tools/enum'
// 配置 baseURL
const BASE_URL = import.meta.env.VITE_API_BASE_URL || ''
const API_PREFIX = import.meta.env.VITE_APP_BASE_API || ''
export function http<T>(options: CustomRequestOptions) {
// 1. 返回 Promise 对象
return new Promise<T>((resolve, reject) => {
// 如果 url 不包含 http:// 或 https://,则添加 baseURL 和 API_PREFIX
let url = options.url
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = `${BASE_URL}${API_PREFIX}${url}`
}
uni.request({
...options,
url,
dataType: 'json',
// #ifndef MP-WEIXIN
responseType: 'json',
// #endif
// 响应成功
success: async (res) => {
const userStore = useUserStore()
const responseData = res.data as IResponse<T>
const { code } = responseData
// 检查是否是401错误(包括HTTP状态码401或业务码401)
const isTokenExpired = res.statusCode === 401 || code === 401
if (isTokenExpired) {
// 清理用户信息,跳转到登录页
userStore.clearAll()
userStore.logout()
toLoginPage()
return reject(res)
}
// 处理其他成功状态(HTTP状态码200-299)
if (res.statusCode >= 200 && res.statusCode < 300) {
// 处理业务逻辑错误
if (code !== ResultEnum.Success0) {
uni.showToast({
icon: 'error',
title: responseData.msg || responseData.message || '请求错误',
})
return reject(responseData)
}
return resolve(responseData.data)
}
// 处理其他错误
if (!options.hideErrorToast) {
uni.showToast({
icon: 'error',
title: (res.data as any).msg || '请求错误',
})
}
reject(res)
},
// 响应失败
fail(err) {
uni.showToast({
icon: 'none',
title: '网络错误,换个网络试试',
})
reject(err)
},
})
})
}
/**
* GET 请求
* @param url 后台地址
* @param query 请求query参数
* @param header 请求头,默认为json格式
* @returns 返回包含响应数据的 Promise
*/
export function httpGet<T>(
url: string,
query?: Record<string, any>,
header?: Record<string, any>,
options?: Partial<CustomRequestOptions>,
) {
return http<T>({
url,
query,
method: 'GET',
header,
...options,
})
}
/**
* POST 请求
* @param url 后台地址
* @param data 请求body参数
* @param query 请求query参数,post请求也支持query,很多微信接口都需要
* @param header 请求头,默认为json格式
* @returns 返回包含响应数据的 Promise
*/
export function httpPost<T>(
url: string,
data?: Record<string, any>,
query?: Record<string, any>,
header?: Record<string, any>,
options?: Partial<CustomRequestOptions>,
) {
return http<T>({
url,
query,
data,
method: 'POST',
header,
...options,
})
}
/**
* PUT 请求
*/
export function httpPut<T>(
url: string,
data?: Record<string, any>,
query?: Record<string, any>,
header?: Record<string, any>,
options?: Partial<CustomRequestOptions>,
) {
return http<T>({
url,
data,
query,
method: 'PUT',
header,
...options,
})
}
/**
* DELETE 请求(无请求体,仅 query)
*/
export function httpDelete<T>(
url: string,
query?: Record<string, any>,
header?: Record<string, any>,
options?: Partial<CustomRequestOptions>,
) {
return http<T>({
url,
query,
method: 'DELETE',
header,
...options,
})
}
// 支持与 axios 类似的API调用
http.get = httpGet
http.post = httpPost
http.put = httpPut
http.delete = httpDelete
// 支持与 AlovaJS 类似的API调用
http.Get = httpGet
http.Post = httpPost
http.Put = httpPut
http.Delete = httpDelete
+15
View File
@@ -0,0 +1,15 @@
// 导出类型
import type { CustomRequestOptions, IResponse } from './types'
// 导出请求适配器
import { http as alovaHttp } from './adapters/alova'
import { http as nativeHttp } from './adapters/http'
// 导出默认请求实例(默认使用 alova 请求实例)
export const http = alovaHttp
// 导出所有类型
export type { CustomRequestOptions, IResponse }
// 导出请求适配器,允许手动选择使用哪种请求方式
export { alovaHttp, nativeHttp }
+69
View File
@@ -0,0 +1,69 @@
import type { CustomRequestOptions } from '@/http/types'
import { useUserStore } from '@/store/userStore'
import { stringifyQuery } from './tools/queryString'
const userStore = useUserStore()
// 请求基准地址
const baseUrl = import.meta.env.VITE_API_BASE_URL || ''
// 拦截器配置
const httpInterceptor = {
// 拦截前触发
invoke(options: CustomRequestOptions) {
// 如果您使用了alova,则请把下面的代码放开注释
// alova 执行流程:alova beforeRequest --> 本拦截器 --> alova responded
// return options
// 非 alova 请求,正常执行
// 接口请求支持通过 query 参数配置 queryString
if (options.query) {
const queryStr = stringifyQuery(options.query)
if (options.url.includes('?')) {
options.url += `&${queryStr}`
}
else {
options.url += `?${queryStr}`
}
}
// 非 http 开头需拼接地址
if (!options.url.startsWith('http')) {
// #ifdef H5
if (JSON.parse(import.meta.env.VITE_APP_PROXY_ENABLE)) {
// 自动拼接代理前缀
options.url = import.meta.env.VITE_APP_PROXY_PREFIX + options.url
}
else {
options.url = baseUrl + options.url
}
// #endif
// 非H5正常拼接
// #ifndef H5
options.url = baseUrl + options.url
// #endif
// TIPS: 如果需要对接多个后端服务,也可以在这里处理,拼接成所需要的地址
}
// 1. 请求超时
options.timeout = 60000 // 60s
// 2. (可选)添加小程序端请求头标识
options.header = {
...options.header,
}
// 3. 添加 token 请求头标识
const token = userStore.getAccessToken()
if (token) {
options.header.Authorization = `Bearer ${token}`
}
return options
},
}
export const requestInterceptor = {
install() {
// 拦截 request 请求
uni.addInterceptor('request', httpInterceptor)
// 拦截 uploadFile 文件上传
uni.addInterceptor('uploadFile', httpInterceptor)
},
}
+68
View File
@@ -0,0 +1,68 @@
export enum ResultEnum {
// 0和200当做成功都很普遍,这里直接兼容两者(PS:0和200通常都不会当做错误码,但是有的接口会返回0,有的接口会返回200)
Success0 = 0, // 成功
Success200 = 200, // 成功
Error = -1, // 错误
Unauthorized = 401, // 未授权
Forbidden = 403, // 禁止访问(原为forbidden)
NotFound = 404, // 未找到(原为notFound)
MethodNotAllowed = 405, // 方法不允许(原为methodNotAllowed)
RequestTimeout = 408, // 请求超时(原为requestTimeout)
InternalServerError = 500, // 服务器错误(原为internalServerError)
NotImplemented = 501, // 未实现(原为notImplemented)
BadGateway = 502, // 网关错误(原为badGateway)
ServiceUnavailable = 503, // 服务不可用(原为serviceUnavailable)
GatewayTimeout = 504, // 网关超时(原为gatewayTimeout)
HttpVersionNotSupported = 505, // HTTP版本不支持(原为httpVersionNotSupported)
}
export enum ContentTypeEnum {
JSON = 'application/json;charset=UTF-8',
FORM_URLENCODED = 'application/x-www-form-urlencoded;charset=UTF-8',
FORM_DATA = 'multipart/form-data;charset=UTF-8',
}
/**
* 根据状态码,生成对应的错误信息
* @param {number|string} status 状态码
* @returns {string} 错误信息
*/
export function ShowMessage(status: number | string): string {
let message: string
switch (status) {
case 400:
message = '请求错误(400)'
break
case 401:
message = '未授权,请重新登录(401)'
break
case 403:
message = '拒绝访问(403)'
break
case 404:
message = '请求出错(404)'
break
case 408:
message = '请求超时(408)'
break
case 500:
message = '服务器错误(500)'
break
case 501:
message = '服务未实现(501)'
break
case 502:
message = '网络错误(502)'
break
case 503:
message = '服务不可用(503)'
break
case 504:
message = '网络超时(504)'
break
case 505:
message = 'HTTP版本不受支持(505)'
break
default:
message = `连接出错(${status})!`
}
return `${message},请检查网络或联系管理员!`
}
@@ -0,0 +1,29 @@
/**
* 将对象序列化为URL查询字符串,用于替代第三方的 qs 库,节省宝贵的体积
* 支持基本类型值和数组,不支持嵌套对象
* @param obj 要序列化的对象
* @returns 序列化后的查询字符串
*/
export function stringifyQuery(obj: Record<string, any>): string {
if (!obj || typeof obj !== 'object' || Array.isArray(obj))
return ''
return Object.entries(obj)
.filter(([, value]) => value !== undefined && value !== null)
.map(([key, value]) => {
// 对键进行编码
const encodedKey = encodeURIComponent(key)
// 处理数组类型
if (Array.isArray(value)) {
return value
.filter(item => item !== undefined && item !== null)
.map(item => `${encodedKey}=${encodeURIComponent(item)}`)
.join('&')
}
// 处理基本类型
return `${encodedKey}=${encodeURIComponent(value)}`
})
.join('&')
}
+50
View File
@@ -0,0 +1,50 @@
/**
* 在 uniapp 的 RequestOptions 和 UploadFileOption 基础上,添加自定义参数
*/
export type CustomRequestOptions = UniApp.RequestOptions & {
query?: Record<string, any>
/** 出错时是否隐藏错误提示 */
hideErrorToast?: boolean
} & UniApp.UploadFileOption // 添加uni.uploadFile参数类型
/** 主要提供给 openapi-ts-request 生成的代码使用 */
export type CustomRequestOptions_ = Omit<CustomRequestOptions, 'url'>
export interface HttpRequestResult<T> {
promise: Promise<T>
requestTask: UniApp.RequestTask
}
// 通用响应格式(兼容 msg + message 字段)
export type IResponse<T = any>
= | {
code: number
data: T
msg: string
status_code: number
success: boolean
[key: string]: any // 允许额外属性
}
| {
code: number
data: T
msg: string
status_code: number
success: boolean
[key: string]: any // 允许额外属性
}
// 分页请求参数
export interface PageParams {
page: number
pageSize: number
[key: string]: any
}
// 分页响应数据
export interface PageResult<T> {
list: T[]
total: number
page: number
pageSize: number
}