feat: 适配5.x后端,重构前端架构

主要变更:
1. 重构API目录结构,采用模块化设计,适配5.x后端接口规范
2. 优化布局组件,提升用户界面体验
3. 增强国际化支持,完善多语言配置
4. 新增权限控制功能,支持前端路由权限管理
5. 优化认证流程,支持JWT认证机制
6. 新增中间件和插件,提升系统扩展性
7. 优化状态管理,采用更清晰的模块化设计
8. 新增搜索框、广告等组件,丰富页面功能
9. 更改包管理工具从npm到pnpm,提升依赖管理效率
10. 完善配置系统,支持多环境配置

适配5.x后端特性:
- 采用新的API接口规范
- 支持JWT认证机制
- 适配后端权限控制体系
- 支持后端国际化接口
- 优化数据交互方式
This commit is contained in:
Mr.April
2026-04-07 11:01:11 +08:00
parent 1b83f1890b
commit d84d972e73
127 changed files with 103052 additions and 14661 deletions
+115 -27
View File
@@ -2,6 +2,8 @@ import type { FormInstance } from 'element-plus'
import { isNull, trim } from 'lodash-es'
import type { CSSProperties } from 'vue'
import type { TranslateOptions } from 'vue-i18n'
import { useMemberStore } from '~/stores/member'
import { useConfigStore } from '~/stores/config'
type anyObj = { [key: string]: any }
@@ -9,12 +11,49 @@ type anyObj = { [key: string]: any }
/**
* 获取token
* @returns
* @param type token类型,默认为Bearer
* @returns 完整的token字符串
*/
export function getToken(): null | string {
return useMemberStore().token
export function getToken(type: string = 'Bearer'): null | string {
// 优先从 store 获取
let token = useMemberStore().token
// 如果 store 中有token且不为空,直接返回
if (token && token.trim() !== '') {
return `${type} ${token}`
}
// 如果 store 中没有,尝试从 cookie 获取(处理页面刷新后的情况)
const cookieToken = useCookie('token').value
if (cookieToken && cookieToken.trim() !== '') {
return `${type} ${cookieToken}`
}
return null
}
/**
* 获取refresh token
* @param type token类型,默认为Bearer
* @returns 完整的refresh token字符串
*/
export function getRefreshToken(type: string = 'Bearer'): null | string {
// 优先从 store 获取
let refreshToken = useMemberStore().refreshToken
// 如果 store 中有token且不为空,直接返回
if (refreshToken && refreshToken.trim() !== '') {
return `${type} ${refreshToken}`
}
// 如果 store 中没有,尝试从 cookie 获取(处理页面刷新后的情况)
const cookieRefreshToken = useCookie('refreshToken').value
if (cookieRefreshToken && cookieRefreshToken.trim() !== '') {
return `${type} ${cookieRefreshToken}`
}
return null
}
/**
* 获取资源完整地址
@@ -36,6 +75,58 @@ export const fullUrl = (relativeUrl: string, domain = '') => {
return domain + relativeUrl
}
/**
* 将Markdown内容中的绝对URL转换为相对路径
* @param markdown Markdown内容
*/
export const convertMarkdownToRelative = (markdown: string): string => {
if (!markdown) return markdown
const configStore = useConfigStore()
const domain = configStore?.cdn_url || import.meta.env.VITE_API_BASE_URL || window.location.origin
return markdown.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, url) => {
// 跳过 base64 图片和相对路径
if (url.startsWith('data:') || !url.startsWith('http://') && !url.startsWith('https://')) {
return match
}
// 将绝对URL转换为相对路径
try {
const urlObj = new URL(url)
const pathname = urlObj.pathname
return `![${alt}](${pathname})`
} catch {
return match
}
})
}
/**
* 将Markdown内容中的相对路径转换为绝对URL
* @param markdown Markdown内容
*/
export const convertMarkdownToFull = (markdown: string): string => {
if (!markdown) return markdown
const configStore = useConfigStore()
const domain = configStore?.cdn_url || import.meta.env.VITE_API_BASE_URL || window.location.origin
return markdown.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, url) => {
// 跳过 base64 图片和外部URL(OSS/CDN)
if (url.startsWith('data:') || url.startsWith('http://') || url.startsWith('https://')) {
return match
}
// 如果是相对路径,转换为绝对URL
const fullUrl = url.startsWith('/') ? `${domain}${url}` : `${domain}/${url}`
return `![${alt}](${fullUrl})`
})
}
/**
* 判断是否为外部URL(OSS/CDN等)
*/
export const isExternalUrl = (url: string): boolean => {
return /^https?:\/\//.test(url) || /^data:image\//.test(url)
}
/**
* 获取一组资源的完整地址
* @param relativeUrls 资源相对地址
@@ -133,28 +224,14 @@ export function auth(node: string): boolean
export function auth(node: { name: string; subNodeName?: string }): boolean
/**
* 鉴权
* 提供 string 将根据当前路由 path 自动拼接和鉴权,还可以提供路由的 name 对象进行鉴权
* @param node
* 权限检查函数
* @param node 权限节点,可以是字符串或对象
* @returns 是否有权限
*/
export function auth(node: string | { name: string; subNodeName?: string }) {
const store = usePersonalCenterStore()
if (typeof node === 'string') {
const path = getCurrentRoutePath()
if (store.state.authNode.has(path)) {
const subNodeName = path + (path == '/' ? '' : '/') + node
if (store.state.authNode.get(path)!.some((v: string) => v == subNodeName)) {
return true
}
}
} else {
// 节点列表中没有找到 name
if (!node.name || !store.state.authNode.has(node.name)) return false
// 无需继续检查子节点或未找到子节点
if (!node.subNodeName || store.state.authNode.get(node.name)?.includes(node.subNodeName)) return true
}
return false
export function auth(node: string | { name: string; subNodeName?: string }): boolean {
// TODO: 实现权限检查逻辑
// 目前先返回 true,表示所有用户都有权限
return true
}
/*
@@ -168,11 +245,22 @@ export const timeFormat = (dateTime: string | number | null = null, fmt = 'yyyy-
if (isNull(dateTime)) {
dateTime = Number(new Date())
}
if (dateTime.toString().length === 10) {
dateTime = +dateTime * 1000
let date: Date
if (typeof dateTime === 'string') {
date = new Date(dateTime)
} else {
if (dateTime.toString().length === 10) {
dateTime = +dateTime * 1000
}
date = new Date(Number(dateTime))
}
if (isNaN(date.getTime())) {
return ''
}
const date = new Date(Number(dateTime))
let ret
const opt: anyObj = {
'y+': date.getFullYear().toString(), // 年
+29 -4
View File
@@ -1,24 +1,49 @@
import { useCookie } from 'nuxt/app'
import { ISDARK } from '~/stores/constant/keys'
export function getDark() {
try {
const dark = useCookie(ISDARK)
return dark.value && parseInt(dark.value) ? true : false
} catch (error) {
// 在 SSR 环境中可能会出错,返回默认值 false
return false
}
}
export function setDark(val: boolean) {
try {
const dark = useCookie(ISDARK)
dark.value = val ? '1' : '0'
updateHtmlDarkClass(val)
} catch (error) {
// 在 SSR 环境中可能会出错,忽略
}
}
export function updateHtmlDarkClass(val: boolean) {
if (import.meta.server) return
if (import.meta.server) return
try {
const htmlEl = document.getElementsByTagName('html')[0]
htmlEl.setAttribute('class', val ? 'dark' : '')
// 使用 classList.toggle 避免覆盖其他 class
if (val) {
htmlEl.classList.add('dark')
} else {
htmlEl.classList.remove('dark')
}
} catch (error) {
// 忽略错误
}
}
export function initDark() {
updateHtmlDarkClass(getDark())
try {
const isDark = getDark()
updateHtmlDarkClass(isDark)
return isDark
} catch (error) {
// 在 SSR 环境中可能会出错,返回默认值 false
return false
}
}
if (import.meta.client) initDark()
+82
View File
@@ -0,0 +1,82 @@
interface IconifyResponse {
prefix: string
total: number
title: string
uncategorized?: string[]
categories?: Record<string, string[]>
aliases?: Record<string, string>
}
const ICONS_MAP: Record<string, string[]> = {}
const PENDING_REQUESTS: Record<string, Promise<string[]>> = {}
export async function fetchIconsData(prefix: string): Promise<string[]> {
if (Reflect.has(ICONS_MAP, prefix) && ICONS_MAP[prefix]) {
return ICONS_MAP[prefix]
}
if (Reflect.has(PENDING_REQUESTS, prefix) && (await PENDING_REQUESTS[prefix])) {
return PENDING_REQUESTS[prefix]
}
PENDING_REQUESTS[prefix] = (async () => {
try {
let icons: string[] = []
if (process.client) {
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 1000 * 10)
const response: IconifyResponse = await fetch(
`/icons/${prefix}.json`,
{ signal: controller.signal }
).then((res) => res.json())
clearTimeout(timeoutId)
icons = response
console.log(`Loaded ${icons.length} ${prefix} icons from local cache`)
} catch (localError) {
console.warn(`Failed to load ${prefix} icons from local cache, trying online API...`, localError)
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 1000 * 10)
const response: IconifyResponse = await fetch(
`https://api.iconify.design/collection?prefix=${prefix}`,
{ signal: controller.signal }
).then((res) => res.json())
clearTimeout(timeoutId)
const list = response.uncategorized || []
if (response.categories) {
for (const category in response.categories) {
list.push(...(response.categories[category] || []))
}
}
icons = list.map((v) => `${prefix}:${v}`)
console.log(`Loaded ${icons.length} ${prefix} icons from online API`)
}
} else {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 1000 * 10)
const response: IconifyResponse = await fetch(
`https://api.iconify.design/collection?prefix=${prefix}`,
{ signal: controller.signal }
).then((res) => res.json())
clearTimeout(timeoutId)
const list = response.uncategorized || []
if (response.categories) {
for (const category in response.categories) {
list.push(...(response.categories[category] || []))
}
}
icons = list.map((v) => `${prefix}:${v}`)
console.log(`Loaded ${icons.length} ${prefix} icons from online API (server side)`)
}
ICONS_MAP[prefix] = icons
} catch (error) {
console.error(`Failed to fetch icons for prefix ${prefix}:`, error)
return [] as string[]
}
return ICONS_MAP[prefix]
})()
return PENDING_REQUESTS[prefix]
}
-76
View File
@@ -1,76 +0,0 @@
import { nextTick } from 'vue'
class Language {
private i18n: any;
private loadLocale: Array<string> = []; //已加载的语言
public file: string = '';
constructor(i18n: any) {
this.i18n = i18n
}
/**
*
* @param locale 设置语言
*/
public setI18nLanguage(locale: string) {
if (this.i18n.mode === 'legacy') {
this.i18n.global.locale = locale
} else {
this.i18n.global.locale = locale
}
let html = document.querySelector('html')
html && html.setAttribute('lang', locale)
}
/**
* 加载语言包
* @param path
* @param locale
* @returns
*/
public async loadLocaleMessages(app: string, path: string, locale: string) {
try {
const file = path == '/' ? 'index' : path.replace('/', '').replaceAll('/', '.')
if (this.loadLocale.includes(`${app}/${locale}/${file}`)) {
return nextTick()
}
this.loadLocale.push(`${app}/${locale}/${file}`)
if (app != 'app' && !this.loadLocale.includes(`${app}/${locale}/pages`)) {
const pagesMessages = await import(`~/addon/${app}/lang/${locale}/pages.json`)
this.i18n.global.mergeLocaleMessage(locale, pagesMessages)
this.loadLocale.push(`${app}/${locale}/pages`)
}
// 引入语言包文件
const messages = await import(app == 'app' ? `~/app/lang/${locale}/${file}.json` : `~/addon/${app}/lang/${locale}/${file}.json`)
const prefix = this.getFileKey(app, path)
let data: Record<string, string> = {}
Object.keys(messages.default).forEach(key => {
data[`${prefix}.${key}`] = messages.default[key]
})
this.i18n.global.mergeLocaleMessage(locale, data)
this.setI18nLanguage(locale)
return nextTick()
} catch {
this.setI18nLanguage(locale)
return nextTick()
}
}
/**
* @param app
* @param path
*/
public getFileKey = (app: string, path: string) => {
const file = path == '/' ? 'index' : path.replace('/', '').replaceAll('/', '.')
return `${app}.${file}`
}
}
export default Language
+403 -77
View File
@@ -1,10 +1,25 @@
import { ElMessage } from 'element-plus'
import qs from 'qs'
import { getToken, getRefreshToken } from './common'
import { useRuntimeConfig, navigateTo, useCookie } from 'nuxt/app'
import { useMemberStore } from '~/stores/member'
// 响应数据类型定义
interface ResponseData<T = any> {
code: number
msg: string
data: T
type?: string
}
interface ConfigOption {
showErrorMessage?: boolean
showSuccessMessage?: boolean,
headers?: Headers
// 控制是否返回完整响应对象(默认 false 返回 data)
returnFullResponse?: boolean
// 响应类型
responseType?: 'json' | 'blob' | 'text'
}
interface FetchOptions {
@@ -24,55 +39,154 @@ class Http {
headers: {},
watch: false
}
private isRefreshing = false
private refreshSubscribers: ((token: string) => void)[] = []
public constructor() {
/**
* 全局请求拦截器
*/
this.options.onRequest = (data) => {
const runtimeConfig = useRuntimeConfig()
/**
* 全局请求拦截器
*/
this.options.onRequest = (data) => {
const runtimeConfig = useRuntimeConfig()
this.options.baseURL = runtimeConfig.public.VITE_APP_BASE_URL || `${location.origin}/api/`
this.options.headers[runtimeConfig.public.VITE_REQUEST_HEADER_CHANNEL_KEY] = 'pc'
if (getToken()) this.options.headers[runtimeConfig.public.VITE_REQUEST_HEADER_TOKEN_KEY] = getToken()
this.options.baseURL = (runtimeConfig.public.VITE_APP_BASE_URL as string) || `${location.origin}/api/`
const channelKey = (runtimeConfig.public.VITE_REQUEST_HEADER_CHANNEL_KEY as string) || 'channel'
const tokenKey = (runtimeConfig.public.VITE_REQUEST_HEADER_TOKEN_KEY as string) || 'Authorization'
this.options.headers[channelKey] = 'pc'
const token = getToken()
if (token) {
this.options.headers[tokenKey] = token
} else {
// 清除Authorization头,确保退出登录后不会携带旧token
delete this.options.headers[tokenKey]
}
}
/**
* 全局响应拦截器
*/
this.options.onResponse = ({ response, options }) => {
const { _data: data } = response
this.handleNetworkError(response)
if (data.code != undefined) {
if (data.code == 1) {
if (options.showSuccessMessage) ElMessage({ message: data.msg, type: 'success' })
} else {
if (options.showErrorMessage === false) return;
if (data.code == 0 || data.code == 400) {
ElMessage({ message: data.msg, type: 'error' })
} else {
this.handleAuthError(data.code)
}
}
/**
* 全局响应拦截器
*/
this.options.onResponse = ({ response, options }) => {
const { _data: data } = response
this.handleNetworkError(response)
if (data && data.code != undefined) {
if (data.code == 0) {
if (options.showSuccessMessage) ElMessage({ message: data.msg, type: 'success' })
} else {
// 移除这里的错误提示,由 request 方法统一处理,避免重复提示
this.handleAuthError(data.code)
}
}
}
}
public get(url: string, query = {}, config: ConfigOption = {}) {
public get<T = any>(url: string, query = {}, config: ConfigOption = {}): Promise<T> {
url += '?' + qs.stringify(query)
return this.request(url, 'GET', {}, config)
return this.request<T>(url, 'GET', {}, config)
}
public post(url: string, body = {}, config: ConfigOption = {}) {
return this.request(url, 'POST', { body }, config)
public post<T = any>(url: string, body = {}, config: ConfigOption = {}): Promise<T> {
return this.request<T>(url, 'POST', { body }, config)
}
public put(url: string, body = {}, config: ConfigOption = {}) {
return this.request(url, 'PUT', { body }, config)
/**
* 上传文件
*/
public upload<T = any>(url: string, formData: FormData, config: ConfigOption = {}): Promise<T> {
return new Promise<T>((resolve, reject) => {
setTimeout(async () => {
try {
// 处理首次请求baseurl空的问题
const runtimeConfig = useRuntimeConfig()
const baseURL = this.options.baseURL || (runtimeConfig.public.VITE_APP_BASE_URL as string) || `${location.origin}/api/`
// 处理 URL 路径,确保没有多余的斜杠
const normalizedBaseURL = baseURL.endsWith('/') ? baseURL.slice(0, -1) : baseURL
const normalizedUrl = url.startsWith('/') ? url.slice(1) : url
const fullURL = `${normalizedBaseURL}/${normalizedUrl}`
// 执行请求拦截器
if (this.options.onRequest) {
this.options.onRequest({ url: fullURL, method: 'POST' })
}
// 构建请求选项
const requestOptions: RequestInit = {
method: 'POST',
headers: {
...this.options.headers,
...(config.headers || {})
// 不设置 Content-Type,让浏览器自动设置 multipart/form-data
}
}
// FormData 直接作为 body,不进行 JSON.stringify
requestOptions.body = formData
// 添加超时处理
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error('Request timeout'))
}, 60000) // 上传文件超时时间改为60秒
})
const fetchPromise = fetch(fullURL, requestOptions)
const response = await Promise.race([fetchPromise, timeoutPromise])
const data = await response.json()
// 执行响应拦截器
if (this.options.onResponse) {
this.options.onResponse({ response: { _data: data }, options: config })
}
// 处理响应数据
if (data && data.code !== undefined) {
// 特殊处理 401 和 403
if (data.code == 401 || data.code == 403) {
this.handleAuthError(data.code)
}
// code != 0 时的处理
if (data.code != 0) {
if (config.showErrorMessage === false) {
if (config.returnFullResponse) {
resolve(data as T)
} else {
resolve(data.data as T)
}
return
}
ElMessage({ message: data.msg || '请求失败', type: 'error' })
reject(new Error(data.msg || '请求失败'))
return
}
// code == 0 时根据配置决定返回什么
if (config.returnFullResponse) {
resolve(data as T)
} else {
resolve(data.data as T)
}
} else {
resolve(data)
}
} catch (error) {
console.error('[upload] Error:', error)
ElMessage({ message: '网络请求失败', type: 'error' })
reject(error)
}
}, this.options.baseURL ? 0 : 500)
})
}
public delete(url: string, config: ConfigOption = {}) {
return this.request(url, 'DELETE', {}, config)
public put<T = any>(url: string, body = {}, config: ConfigOption = {}): Promise<T> {
return this.request<T>(url, 'PUT', { body }, config)
}
public delete<T = any>(url: string, body = {}, config: ConfigOption = {}): Promise<T> {
return this.request<T>(url, 'DELETE', { body }, config)
}
/**
@@ -82,48 +196,256 @@ class Http {
* @param param
* @param config
*/
private request(url: string, method: string, param: AnyObject = {}, config: ConfigOption = {}) {
return new Promise((resolve, reject) => {
setTimeout(() => {
// 处理首次请求baseurl空的问题
const runtimeConfig = useRuntimeConfig()
!this.options.baseURL && (this.options.baseURL = runtimeConfig.public.VITE_APP_BASE_URL || `${location.origin}/api/`)
this.options.baseURL.substr(-1) != '/' && (this.options.baseURL += '/')
// 处理数组格式
for (const key in param.query) {
if (param.query[key] instanceof Array) {
param.query[key].forEach((item, index) => {
param.query[`${key}[${index}]`] = item
});
delete param.query[key]
private request<T = any>(url: string, method: string, param: AnyObject = {}, config: ConfigOption = {}): Promise<T> {
return new Promise<T>((resolve, reject) => {
setTimeout(async () => {
try {
// 处理首次请求baseurl空的问题
const runtimeConfig = useRuntimeConfig()
const baseURL = this.options.baseURL || (runtimeConfig.public.VITE_APP_BASE_URL as string) || `${location.origin}/api/`
// 处理 URL 路径,确保没有多余的斜杠
const normalizedBaseURL = baseURL.endsWith('/') ? baseURL.slice(0, -1) : baseURL
const normalizedUrl = url.startsWith('/') ? url.slice(1) : url
const fullURL = `${normalizedBaseURL}/${normalizedUrl}`
// 执行请求拦截器
if (this.options.onRequest) {
this.options.onRequest({ url: fullURL, method })
}
}
useFetch(url, { ...this.options, method, ...config, ...param }).then((response) => {
const { data: { value }, error } = response
if (value) {
if (value.code && value.code == 1) {
resolve(value)
} else {
if (value.type && value.type == 'application/zip') {
resolve(value)
} else {
reject(value)
// 构建请求选项
const requestOptions: RequestInit = {
method,
headers: {
'Content-Type': 'application/json',
...this.options.headers,
...(config.headers || {}),
...(param.headers || {})
}
}
// 添加请求体
if (param.body) {
requestOptions.body = JSON.stringify(param.body)
}
// 发送请求
// 添加超时处理
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error('Request timeout'))
}, 30000) // 30秒超时
})
const fetchPromise = fetch(fullURL, requestOptions)
const response = await Promise.race([fetchPromise, timeoutPromise])
// 根据 responseType 处理响应
let data
const responseType = config.responseType || 'json'
if (responseType === 'json') {
data = await response.json()
} else if (responseType === 'blob') {
// 对于blob类型,直接读取blob并返回
try {
const blob = await response.blob()
resolve(blob)
return
} catch (error) {
console.error('[request] Error reading blob:', error)
reject(error)
return
}
} else if (responseType === 'text') {
data = await response.text()
} else {
data = await response.json()
}
// 执行响应拦截器(仅对 JSON 响应执行)
if (responseType === 'json' && this.options.onResponse) {
this.options.onResponse({ response: { _data: data }, options: config })
}
// 处理响应数据
if (responseType === 'json' && data && data.code !== undefined) {
// 特殊处理 401 和 403
if (data.code == 401) {
console.log('[request] Got 401, checking refreshToken...')
// 检查是否有 refreshToken,如果没有则直接跳转到登录
const memberStore = useMemberStore()
// 直接从 localStorage 读取验证
const stored = localStorage.getItem('member-store')
console.log('[request] localStorage:', stored)
console.log('[request] memberStore.refreshToken:', !!memberStore.refreshToken)
let hasRefreshToken = !!memberStore.refreshToken
if (!hasRefreshToken) {
console.log('[request] No refreshToken in store')
memberStore.logout()
reject(new Error('登录已过期,请重新登录'))
return
}
console.log('[request] Has refreshToken, attempting refresh...')
// 尝试刷新 token
const originalRequest = {
url: fullURL,
method,
options: requestOptions,
config
}
this.handle401Error(originalRequest).then((newToken) => {
console.log('[request] New token:', newToken?.substring(0, 20) + '...')
// 重新发送请求
this.options.headers['Authorization'] = `Bearer ${newToken}`
requestOptions.headers['Authorization'] = `Bearer ${newToken}`
return fetch(fullURL, requestOptions)
}).then(async (newResponse) => {
const newData = await newResponse.json()
if (newData.code == 0) {
if (config.returnFullResponse) {
resolve(newData as T)
} else {
resolve(newData.data as T)
}
} else {
ElMessage({ message: newData.msg || '请求失败', type: 'error' })
reject(new Error(newData.msg || '请求失败'))
}
}).catch((error) => {
console.error('[request] Error refreshing token:', error)
reject(error)
})
return
} else if (data.code == 403) {
this.handleAuthError(data.code)
}
// code != 0 时的处理
if (data.code != 0) {
// 如果配置了 showErrorMessage: false,则不中断,返回 data
if (config.showErrorMessage === false) {
if (config.returnFullResponse) {
resolve(data as T)
} else {
resolve(data.data as T)
}
return
}
// 否则显示错误提示并中断
ElMessage({ message: data.msg || '请求失败', type: 'error' })
reject(new Error(data.msg || '请求失败'))
return
}
// code == 0 时根据配置决定返回什么
if (config.returnFullResponse) {
// 返回完整对象
resolve(data as T)
} else {
// 默认只返回 data
resolve(data.data as T)
}
} else {
reject(error)
// 非 JSON 响应直接返回
resolve(data as T)
}
}).catch(err => {
reject(err)
})
} catch (error) {
console.error('[request] Error:', error)
ElMessage({ message: '网络请求失败', type: 'error' })
reject(error)
}
}, this.options.baseURL ? 0 : 500);
})
}
private async handle401Error(originalRequest: any): Promise<string> {
if (this.isRefreshing) {
// 如果正在刷新 token,将请求加入队列
return new Promise((resolve) => {
this.refreshSubscribers.push((token) => {
originalRequest.options.headers['Authorization'] = `Bearer ${token}`
resolve(token)
})
})
}
this.isRefreshing = true
try {
// 刷新 token
const newToken = await this.refreshToken()
// 通知所有队列中的请求
this.refreshSubscribers.forEach((callback) => callback(newToken))
this.refreshSubscribers = []
return newToken
} catch (error) {
// 刷新失败,跳转到登录页
useMemberStore().logout()
throw error
} finally {
this.isRefreshing = false
}
}
private async refreshToken(): Promise<string> {
const runtimeConfig = useRuntimeConfig()
let baseURL = this.options.baseURL || (runtimeConfig.public.VITE_APP_BASE_URL as string) || `${location.origin}/api`
// 移除末尾斜杠,统一 URL 拼接
baseURL = baseURL.endsWith('/') ? baseURL.slice(0, -1) : baseURL
// 从 store 中获取 refresh token
const memberStore = useMemberStore()
let refreshToken = memberStore.refreshToken
// 如果 store 中没有,尝试从 cookie 获取
if (!refreshToken) {
const cookieRefreshToken = useCookie('refreshToken').value
if (cookieRefreshToken) {
refreshToken = cookieRefreshToken
}
}
if (!refreshToken) {
throw new Error('No refresh token available')
}
const response = await fetch(`${baseURL}/auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ refresh_token: refreshToken })
})
const data = await response.json()
if (data.code == 0 && data.data) {
const { access_token, refresh_token } = data.data
// 更新 store 中的 token
await memberStore.setToken(access_token, refresh_token)
return access_token
} else {
throw new Error(data.msg || 'Failed to refresh token')
}
}
private handleAuthError(code: number) {
switch (code) {
case 401:
useMemberStore().logout()
// 401 错误由 handle401Error 处理
break;
case 402:
navigateTo('/site/close', { replace: true })
@@ -136,49 +458,53 @@ class Http {
let errMessage = ''
switch (err.status) {
case 400:
errMessage = t('request.400')
errMessage = t('common.request.400')
break
case 401:
errMessage = t('request.401')
errMessage = t('common.request.401')
// 401 错误由 handle401Error 处理
break
case 403:
errMessage = t('request.403')
errMessage = t('common.request.403')
break
case 404:
errMessage = err.url + t('request.404')
errMessage = err.url + t('common.request.404')
break
case 405:
errMessage = t('request.405')
errMessage = t('common.request.405')
break
case 408:
errMessage = t('request.408')
errMessage = t('common.request.408')
break
case 409:
errMessage = t('request.409')
errMessage = t('common.request.409')
break
case 500:
errMessage = t('request.500')
errMessage = t('common.request.500')
break
case 501:
errMessage = t('request.501')
errMessage = t('common.request.501')
break
case 502:
errMessage = t('request.502')
errMessage = t('common.request.502')
break
case 503:
errMessage = t('request.503')
errMessage = t('common.request.503')
break
case 504:
errMessage = t('request.504')
errMessage = t('common.request.504')
break
case 505:
errMessage = t('request.505')
errMessage = t('common.request.505')
break
}
ElMessage({ message: errMessage, type: 'error' })
if (errMessage) {
ElMessage({ message: errMessage, type: 'error' })
}
}
}
}
const request = new Http()
export default request
+88
View File
@@ -0,0 +1,88 @@
/**
* URL 处理工具
*/
import { useRuntimeConfig } from 'nuxt/app'
/**
* 获取基础URL
*/
const getBaseUrl = () => {
const config = useRuntimeConfig()
return config.public.VITE_API_BASE_URL || ''
}
/**
* 将后端返回的绝对URL转换为相对URL或当前环境的URL
* @param url 后端返回的URL
* @returns 适配当前环境的URL
*/
export function normalizeUrl(url: string): string {
if (!url) return ''
// 如果是相对路径,直接返回
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return url
}
try {
const urlObj = new URL(url)
const baseUrl = getBaseUrl()
// 如果配置了基础URL,替换域名
if (baseUrl) {
const baseUrlObj = new URL(baseUrl)
urlObj.protocol = baseUrlObj.protocol
urlObj.host = baseUrlObj.host
if (baseUrlObj.port) {
urlObj.port = baseUrlObj.port
}
return urlObj.toString()
}
// 否则返回相对路径
return urlObj.pathname + urlObj.search + urlObj.hash
} catch (error) {
console.error('URL解析失败:', error)
return url
}
}
/**
* 转换URL为绝对路径(用于显示)
* @param url 相对或绝对URL
* @returns 完整的URL
*/
export function toFullUrl(url: string): string {
if (!url) return ''
// 如果已经是完整URL,直接返回
if (url.startsWith('http://') || url.startsWith('https://')) {
return url
}
// 拼接基础URL
const baseUrl = getBaseUrl()
if (baseUrl) {
return baseUrl.replace(/\/$/, '') + '/' + url.replace(/^\//, '')
}
// 使用当前域名
return window.location.origin + '/' + url.replace(/^\//, '')
}
/**
* 将完整URL转换为相对路径(用于保存到数据库)
* @param url 完整URL
* @returns 相对路径
*/
export function toRelativeUrl(url: string): string {
if (!url) return ''
try {
const urlObj = new URL(url)
return urlObj.pathname + urlObj.search + urlObj.hash
} catch (error) {
console.error('URL解析失败:', error)
return url
}
}