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:
+115
-27
@@ -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 ``
|
||||
} 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 ``
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为外部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
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user