整合前端仓库并保留提交历史
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
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 }
|
||||
|
||||
|
||||
/**
|
||||
* 获取token
|
||||
* @param type token类型,默认为Bearer
|
||||
* @returns 完整的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
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资源完整地址
|
||||
* @param relativeUrl 资源相对地址
|
||||
* @param domain 指定域名
|
||||
*/
|
||||
export const fullUrl = (relativeUrl: string, domain = '') => {
|
||||
const configStore = useConfigStore()
|
||||
if (!domain) {
|
||||
domain = configStore?.cdn_url ? configStore?.cdn_url : import.meta.env.VITE_API_BASE_URL
|
||||
}
|
||||
if (!relativeUrl) return domain
|
||||
|
||||
const regUrl = new RegExp(/^http(s)?:\/\//)
|
||||
const regexImg = new RegExp(/^((?:[a-z]+:)?\/\/|data:image\/)(.*)/i)
|
||||
if (!domain || regUrl.test(relativeUrl) || regexImg.test(relativeUrl)) {
|
||||
return relativeUrl
|
||||
}
|
||||
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 资源相对地址
|
||||
* @param domain 指定域名
|
||||
*/
|
||||
export const arrayFullUrl = (relativeUrls: string | string[], domain = '') => {
|
||||
if (typeof relativeUrls === 'string') {
|
||||
relativeUrls = relativeUrls == '' ? [] : relativeUrls.split(',')
|
||||
}
|
||||
for (const key in relativeUrls) {
|
||||
relativeUrls[key] = fullUrl(relativeUrls[key], domain)
|
||||
}
|
||||
return relativeUrls
|
||||
}
|
||||
|
||||
/**
|
||||
* 从一个文件路径中获取文件名
|
||||
* @param path 文件路径
|
||||
*/
|
||||
export const getFileNameFromPath = (path: string) => {
|
||||
const paths = path.split('/')
|
||||
return paths[paths.length - 1]
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否是外部链接
|
||||
* @param path
|
||||
*/
|
||||
export function isExternal(path: string): boolean {
|
||||
return /^(https?|ftp|mailto|tel):/.test(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为手机设备
|
||||
*/
|
||||
export const isMobile = () => {
|
||||
const event = useRequestEvent()
|
||||
const userAgent = import.meta.client ? navigator.userAgent : event?.node.req.headers['user-agent']
|
||||
if (!userAgent) return false
|
||||
return !!userAgent.match(
|
||||
/android|webos|ip(hone|ad|od)|opera (mini|mobi|tablet)|iemobile|windows.+(phone|touch)|mobile|fennec|kindle (Fire)|Silk|maemo|blackberry|playbook|bb10\; (touch|kbd)|Symbian(OS)|Ubuntu Touch/i
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 防抖
|
||||
* @param fn 执行函数
|
||||
* @param ms 间隔毫秒数
|
||||
*/
|
||||
export const debounce = (fn: Function, ms: number) => {
|
||||
return (...args: any[]) => {
|
||||
if (globals.lazy) {
|
||||
clearTimeout(globals.lazy)
|
||||
}
|
||||
globals.lazy = setTimeout(() => {
|
||||
fn(...args)
|
||||
}, ms)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单重置
|
||||
* @param formEl
|
||||
*/
|
||||
export function onResetForm(formEl?: FormInstance | null) {
|
||||
typeof formEl?.resetFields == 'function' && formEl?.resetFields()
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据pk字段的值从数组中获取key
|
||||
* @param arr
|
||||
* @param pk
|
||||
* @param value
|
||||
*/
|
||||
export const getArrayKey = (arr: any, pk: string, value: any): any => {
|
||||
for (const key in arr) {
|
||||
if (arr[key][pk] == value) {
|
||||
return key
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取简洁的路由 path
|
||||
*/
|
||||
export const getCurrentRoutePath = () => {
|
||||
const router = useRouter()
|
||||
let path = router.currentRoute.value.path
|
||||
if (path.indexOf('?') !== -1) path = path.replace(/\?.*/, '')
|
||||
return path
|
||||
}
|
||||
|
||||
export function auth(node: string): boolean
|
||||
export function auth(node: { name: string; subNodeName?: string }): boolean
|
||||
|
||||
/**
|
||||
* 权限检查函数
|
||||
* @param node 权限节点,可以是字符串或对象
|
||||
* @returns 是否有权限
|
||||
*/
|
||||
export function auth(node: string | { name: string; subNodeName?: string }): boolean {
|
||||
// TODO: 实现权限检查逻辑
|
||||
// 目前先返回 true,表示所有用户都有权限
|
||||
return true
|
||||
}
|
||||
|
||||
/*
|
||||
* 格式化时间戳
|
||||
*/
|
||||
export const timeFormat = (dateTime: string | number | null = null, fmt = 'yyyy-mm-dd hh:MM:ss') => {
|
||||
if (dateTime == 'none') {
|
||||
return i18n.global.t('None')
|
||||
}
|
||||
|
||||
if (isNull(dateTime)) {
|
||||
dateTime = Number(new Date())
|
||||
}
|
||||
|
||||
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 ''
|
||||
}
|
||||
|
||||
let ret
|
||||
const opt: anyObj = {
|
||||
'y+': date.getFullYear().toString(), // 年
|
||||
'm+': (date.getMonth() + 1).toString(), // 月
|
||||
'd+': date.getDate().toString(), // 日
|
||||
'h+': date.getHours().toString(), // 时
|
||||
'M+': date.getMinutes().toString(), // 分
|
||||
's+': date.getSeconds().toString(), // 秒
|
||||
}
|
||||
for (const k in opt) {
|
||||
ret = new RegExp('(' + k + ')').exec(fmt)
|
||||
if (ret) {
|
||||
fmt = fmt.replace(ret[1], ret[1].length == 1 ? opt[k] : padStart(opt[k], ret[1].length, '0'))
|
||||
}
|
||||
}
|
||||
return fmt
|
||||
}
|
||||
|
||||
/*
|
||||
* 字符串补位
|
||||
*/
|
||||
const padStart = (str: string, maxLength: number, fillString = ' ') => {
|
||||
if (str.length >= maxLength) return str
|
||||
|
||||
const fillLength = maxLength - str.length
|
||||
let times = Math.ceil(fillLength / fillString.length)
|
||||
while ((times >>= 1)) {
|
||||
fillString += fillString
|
||||
if (times === 1) {
|
||||
fillString += fillString
|
||||
}
|
||||
}
|
||||
return fillString.slice(0, fillLength) + str
|
||||
}
|
||||
|
||||
/**
|
||||
* calc(100vh - minusHeight)
|
||||
* @param minusHeight 需要减去的高度
|
||||
* @returns CSSProperties
|
||||
*/
|
||||
export function calcHeight(minusHeight = 60): CSSProperties {
|
||||
return {
|
||||
height: 'calc(100vh - ' + minusHeight.toString() + 'px)',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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
|
||||
try {
|
||||
const htmlEl = document.getElementsByTagName('html')[0]
|
||||
// 使用 classList.toggle 避免覆盖其他 class
|
||||
if (val) {
|
||||
htmlEl.classList.add('dark')
|
||||
} else {
|
||||
htmlEl.classList.remove('dark')
|
||||
}
|
||||
} catch (error) {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
export function initDark() {
|
||||
try {
|
||||
const isDark = getDark()
|
||||
updateHtmlDarkClass(isDark)
|
||||
return isDark
|
||||
} catch (error) {
|
||||
// 在 SSR 环境中可能会出错,返回默认值 false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
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'
|
||||
import { decryptEncryptedResponse, prepareEncryptedRequest } from '~/utils/security/api-encrypt'
|
||||
|
||||
// 响应数据类型定义
|
||||
interface ResponseData<T = any> {
|
||||
code: number
|
||||
msg: string
|
||||
data: T
|
||||
type?: string
|
||||
}
|
||||
|
||||
interface ConfigOption {
|
||||
showErrorMessage?: boolean
|
||||
showSuccessMessage?: boolean,
|
||||
headers?: HeadersInit
|
||||
// 控制是否返回完整响应对象(默认 false 返回 data)
|
||||
returnFullResponse?: boolean
|
||||
// 响应类型
|
||||
responseType?: 'json' | 'blob' | 'text'
|
||||
}
|
||||
|
||||
interface FetchOptions {
|
||||
baseURL: string
|
||||
headers: Record<string, any>
|
||||
onRequest?: (data: any) => void
|
||||
onResponse?: (data: any) => void
|
||||
onResponseError?: (data: any) => void
|
||||
showErrorMessage?: boolean
|
||||
showSuccessMessage?: boolean
|
||||
watch: boolean
|
||||
}
|
||||
|
||||
class Http {
|
||||
private options: FetchOptions = {
|
||||
baseURL: '',
|
||||
headers: {},
|
||||
watch: false
|
||||
}
|
||||
private isRefreshing = false
|
||||
private refreshSubscribers: ((token: string) => void)[] = []
|
||||
|
||||
public constructor() {
|
||||
/**
|
||||
* 全局请求拦截器
|
||||
*/
|
||||
this.options.onRequest = (data) => {
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
|
||||
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 && data.code != undefined) {
|
||||
if (data.code == 0) {
|
||||
if (options.showSuccessMessage) ElMessage({ message: data.msg, type: 'success' })
|
||||
} else {
|
||||
// 移除这里的错误提示,由 request 方法统一处理,避免重复提示
|
||||
this.handleAuthError(data.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public get<T = any>(url: string, query = {}, config: ConfigOption = {}): Promise<T> {
|
||||
url += '?' + qs.stringify(query)
|
||||
return this.request<T>(url, 'GET', {}, config)
|
||||
}
|
||||
|
||||
public post<T = any>(url: string, body = {}, config: ConfigOption = {}): Promise<T> {
|
||||
return this.request<T>(url, 'POST', { 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 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送请求
|
||||
* @param url
|
||||
* @param method
|
||||
* @param param
|
||||
* @param config
|
||||
*/
|
||||
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 })
|
||||
}
|
||||
|
||||
// 构建请求选项
|
||||
const requestHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(this.options.headers as Record<string, string>),
|
||||
...((config.headers || {}) as Record<string, string>),
|
||||
...((param.headers || {}) as Record<string, string>)
|
||||
}
|
||||
|
||||
const requestOptions: RequestInit = {
|
||||
method,
|
||||
headers: requestHeaders
|
||||
}
|
||||
|
||||
let encryptSession: any = null
|
||||
|
||||
// 添加请求体
|
||||
const encryptedRequest = await prepareEncryptedRequest(baseURL, `/${normalizedUrl}`, method, param.body)
|
||||
encryptSession = encryptedRequest.session
|
||||
|
||||
Object.assign(requestHeaders, encryptedRequest.headers)
|
||||
|
||||
if (encryptedRequest.body) {
|
||||
requestOptions.body = encryptedRequest.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()
|
||||
data = await decryptEncryptedResponse(data, encryptSession)
|
||||
} else if (responseType === 'blob') {
|
||||
// 对于blob类型,直接读取blob并返回
|
||||
try {
|
||||
const blob = await response.blob()
|
||||
resolve(blob as T)
|
||||
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}`
|
||||
requestHeaders['Authorization'] = `Bearer ${newToken}`
|
||||
return fetch(fullURL, requestOptions)
|
||||
}).then(async (newResponse) => {
|
||||
let newData = await newResponse.json()
|
||||
newData = await decryptEncryptedResponse(newData, encryptSession)
|
||||
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 {
|
||||
// 非 JSON 响应直接返回
|
||||
resolve(data as T)
|
||||
}
|
||||
} 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:
|
||||
// 401 错误由 handle401Error 处理
|
||||
break;
|
||||
case 402:
|
||||
navigateTo('/site/close', { replace: true })
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private handleNetworkError(err: any) {
|
||||
if (err.status && err.status != 200) {
|
||||
let errMessage = ''
|
||||
switch (err.status) {
|
||||
case 400:
|
||||
errMessage = t('common.request.400')
|
||||
break
|
||||
case 401:
|
||||
errMessage = t('common.request.401')
|
||||
// 401 错误由 handle401Error 处理
|
||||
break
|
||||
case 403:
|
||||
errMessage = t('common.request.403')
|
||||
break
|
||||
case 404:
|
||||
errMessage = err.url + t('common.request.404')
|
||||
break
|
||||
case 405:
|
||||
errMessage = t('common.request.405')
|
||||
break
|
||||
case 408:
|
||||
errMessage = t('common.request.408')
|
||||
break
|
||||
case 409:
|
||||
errMessage = t('common.request.409')
|
||||
break
|
||||
case 500:
|
||||
errMessage = t('common.request.500')
|
||||
break
|
||||
case 501:
|
||||
errMessage = t('common.request.501')
|
||||
break
|
||||
case 502:
|
||||
errMessage = t('common.request.502')
|
||||
break
|
||||
case 503:
|
||||
errMessage = t('common.request.503')
|
||||
break
|
||||
case 504:
|
||||
errMessage = t('common.request.504')
|
||||
break
|
||||
case 505:
|
||||
errMessage = t('common.request.505')
|
||||
break
|
||||
}
|
||||
if (errMessage) {
|
||||
ElMessage({ message: errMessage, type: 'error' })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const request = new Http()
|
||||
export default request
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import type { Menus } from '~/stores/interface'
|
||||
|
||||
/**
|
||||
* 获取第一个菜单
|
||||
*/
|
||||
export const getFirstRoute = (routes: RouteRecordRaw[]): false | RouteRecordRaw => {
|
||||
const routerPaths: string[] = []
|
||||
const router = useRouter()
|
||||
const routers = router.getRoutes()
|
||||
routers.forEach((item) => {
|
||||
if (item.path) routerPaths.push(item.path)
|
||||
})
|
||||
let find: boolean | RouteRecordRaw = false
|
||||
for (const key in routes) {
|
||||
if (routes[key].meta?.type != 'menu_dir' && routerPaths.indexOf(routes[key].path) !== -1) {
|
||||
return routes[key]
|
||||
} else if (routes[key].children && routes[key].children?.length) {
|
||||
find = getFirstRoute(routes[key].children!)
|
||||
if (find) return find
|
||||
}
|
||||
}
|
||||
return find
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开菜单
|
||||
* @param menu 菜单数据
|
||||
*/
|
||||
export const onClickMenu = (menu: RouteRecordRaw) => {
|
||||
switch (menu.meta?.menu_type) {
|
||||
case 'iframe':
|
||||
case 'tab':
|
||||
navigateTo(menu.path)
|
||||
break
|
||||
case 'link':
|
||||
window.open(menu.path, '_blank')
|
||||
break
|
||||
|
||||
default:
|
||||
alert('菜单类型未识别')
|
||||
// ElNotification({
|
||||
// message: $t('utils.Navigation failed, the menu type is unrecognized!'),
|
||||
// type: 'error',
|
||||
// })
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理权限节点
|
||||
* @param routes 路由数据
|
||||
* @param prefix 节点前缀
|
||||
* @returns 组装好的权限节点
|
||||
*/
|
||||
export const handleAuthNode = (routes: any, prefix = '/') => {
|
||||
const authNode: Map<string, string[]> = new Map([])
|
||||
assembleAuthNode(routes, authNode, prefix, prefix)
|
||||
return authNode
|
||||
}
|
||||
const assembleAuthNode = (routes: any, authNode: Map<string, string[]>, prefix = '/', parent = '/') => {
|
||||
const authNodeTemp = []
|
||||
for (const key in routes) {
|
||||
if (routes[key].type == 'button') authNodeTemp.push(prefix + routes[key].name)
|
||||
if (routes[key].children && routes[key].children.length > 0) {
|
||||
assembleAuthNode(routes[key].children, authNode, prefix, prefix + routes[key].name)
|
||||
}
|
||||
}
|
||||
if (authNodeTemp && authNodeTemp.length > 0) {
|
||||
authNode.set(parent, authNodeTemp)
|
||||
}
|
||||
}
|
||||
|
||||
export const registerMenus = (rules: any, menus: any) => {
|
||||
const systemConfig = useSystemStore()
|
||||
// if (rules.length) {
|
||||
// personalCenter.mergeAuthNode(handleAuthNode(rules, '/'))
|
||||
// systemConfig.setHeadNav(handleMenus(rules, '/', ['nav']))
|
||||
// personalCenter.mergeNavUserMenus(handleMenus(rules, '/', ['nav_user_menu']))
|
||||
// }
|
||||
// if (menus.length) {
|
||||
// const menupersonalCenterBaseRoute = '/user/'
|
||||
// personalCenter.mergeAuthNode(handleAuthNode(menus, menupersonalCenterBaseRoute))
|
||||
|
||||
// personalCenter.mergeNavUserMenus(handleMenus(menus, '/', ['nav_user_menu']))
|
||||
// personalCenter.setShowHeadline(menus.length > 1)
|
||||
// personalCenter.setUserMenus(handleMenus(menus, menupersonalCenterBaseRoute, ['menu', 'menu_dir']))
|
||||
// }
|
||||
}
|
||||
|
||||
export const handleMenus = (rules: any, prefix = '/', type = ['nav']) => {
|
||||
const menus: Menus[] = []
|
||||
for (const key in rules) {
|
||||
if (rules[key].extend == 'add_rules_only') {
|
||||
continue
|
||||
}
|
||||
if (rules[key].type == 'menu_dir' && rules[key].children && !rules[key].children.length) {
|
||||
continue
|
||||
}
|
||||
let children: Menus[] = []
|
||||
if (rules[key].children && rules[key].children.length > 0) {
|
||||
children = handleMenus(rules[key].children, prefix, type)
|
||||
}
|
||||
|
||||
if (type.includes(rules[key].type)) {
|
||||
let path = ''
|
||||
if (['link', 'iframe'].includes(rules[key].menu_type)) {
|
||||
path = rules[key].url
|
||||
} else {
|
||||
path = prefix + rules[key].path
|
||||
}
|
||||
menus.push({
|
||||
...rules[key],
|
||||
meta: {
|
||||
/**
|
||||
* 在 meta 中存储多个可能用到的属性
|
||||
* 兼容 RouteRecordRaw 和 RouteLocationNormalizedLoaded 类型
|
||||
*/
|
||||
id: rules[key].id,
|
||||
type: rules[key].type,
|
||||
menu_type: rules[key].menu_type,
|
||||
},
|
||||
path: path,
|
||||
children: children,
|
||||
})
|
||||
}
|
||||
}
|
||||
return menus
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
interface PublicKeyResponse {
|
||||
enabled: boolean
|
||||
scope: string
|
||||
algorithm: string
|
||||
key_version: string
|
||||
public_key: string
|
||||
encrypted_paths?: string[]
|
||||
}
|
||||
|
||||
interface EncryptSession {
|
||||
aesKey: Uint8Array
|
||||
iv: Uint8Array
|
||||
keyVersion: string
|
||||
encryptedKey: string
|
||||
encryptedIv: string
|
||||
}
|
||||
|
||||
interface EncryptRequestResult {
|
||||
body: string
|
||||
headers: Record<string, string>
|
||||
session: EncryptSession | null
|
||||
}
|
||||
|
||||
const PUBLIC_KEY_PATH = '/system/security/api-encrypt/public-key'
|
||||
let publicKeyCache: PublicKeyResponse | null = null
|
||||
let publicKeyFailureAt = 0
|
||||
const PUBLIC_KEY_RETRY_INTERVAL = 30000
|
||||
|
||||
const textEncoder = new TextEncoder()
|
||||
const textDecoder = new TextDecoder()
|
||||
|
||||
function normalizeBaseUrl(baseURL: string): string {
|
||||
return baseURL.endsWith('/') ? baseURL.slice(0, -1) : baseURL
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return path.startsWith('/') ? path : `/${path}`
|
||||
}
|
||||
|
||||
function resolveRequestPath(baseURL: string, path: string): string {
|
||||
const normalizedBaseUrl = normalizeBaseUrl(baseURL)
|
||||
const normalizedPath = normalizePath(path)
|
||||
|
||||
if (/^https?:\/\//i.test(normalizedPath)) {
|
||||
const url = new URL(normalizedPath)
|
||||
return url.pathname
|
||||
}
|
||||
|
||||
if (normalizedPath.startsWith('/api/') || normalizedPath.startsWith('/adminapi/')) {
|
||||
return normalizedPath
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(`${normalizedBaseUrl}${normalizedPath}`, window.location.origin)
|
||||
return url.pathname
|
||||
} catch {
|
||||
return normalizedPath
|
||||
}
|
||||
}
|
||||
|
||||
function shouldSkipEncrypt(path: string): boolean {
|
||||
return path === '/api/auth/refresh'
|
||||
|| path === '/adminapi/auth/refresh'
|
||||
|| path === '/api/system/security/api-encrypt/public-key'
|
||||
|| path === '/adminapi/system/security/api-encrypt/public-key'
|
||||
}
|
||||
|
||||
function matchesEncryptedPath(path: string, encryptedPaths: string[] = []): boolean {
|
||||
const normalizedPath = normalizePath(path)
|
||||
return encryptedPaths.some((encryptedPath) => {
|
||||
const normalizedEncryptedPath = normalizePath(encryptedPath)
|
||||
return normalizedEncryptedPath === normalizedPath || normalizedPath.startsWith(`${normalizedEncryptedPath}/`)
|
||||
})
|
||||
}
|
||||
|
||||
function toBase64(buffer: ArrayBuffer | Uint8Array): string {
|
||||
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer)
|
||||
let binary = ''
|
||||
bytes.forEach((byte) => {
|
||||
binary += String.fromCharCode(byte)
|
||||
})
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
|
||||
}
|
||||
|
||||
function fromPem(pem: string): ArrayBuffer {
|
||||
const clean = pem.replace(/-----BEGIN PUBLIC KEY-----/g, '')
|
||||
.replace(/-----END PUBLIC KEY-----/g, '')
|
||||
.replace(/\s+/g, '')
|
||||
const binary = atob(clean)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i)
|
||||
}
|
||||
return bytes.buffer
|
||||
}
|
||||
|
||||
async function importPublicKey(publicKeyPem: string): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey(
|
||||
'spki',
|
||||
fromPem(publicKeyPem),
|
||||
{
|
||||
name: 'RSA-OAEP',
|
||||
hash: 'SHA-256'
|
||||
},
|
||||
true,
|
||||
['encrypt']
|
||||
)
|
||||
}
|
||||
|
||||
async function rsaEncrypt(publicKeyPem: string, data: Uint8Array): Promise<string> {
|
||||
const publicKey = await importPublicKey(publicKeyPem)
|
||||
const encrypted = await crypto.subtle.encrypt({ name: 'RSA-OAEP' }, publicKey, data)
|
||||
return toBase64(encrypted)
|
||||
}
|
||||
|
||||
async function aesEncrypt(plainText: string, aesKey: Uint8Array, iv: Uint8Array): Promise<string> {
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
toArrayBuffer(aesKey),
|
||||
{ name: 'AES-CBC' },
|
||||
false,
|
||||
['encrypt']
|
||||
)
|
||||
const encrypted = await crypto.subtle.encrypt({ name: 'AES-CBC', iv: toArrayBuffer(iv) }, cryptoKey, textEncoder.encode(plainText))
|
||||
return toBase64(encrypted)
|
||||
}
|
||||
|
||||
async function aesDecrypt(cipherText: string, aesKey: Uint8Array, iv: Uint8Array): Promise<string> {
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
toArrayBuffer(aesKey),
|
||||
{ name: 'AES-CBC' },
|
||||
false,
|
||||
['decrypt']
|
||||
)
|
||||
const binary = atob(cipherText)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i)
|
||||
}
|
||||
const decrypted = await crypto.subtle.decrypt({ name: 'AES-CBC', iv: toArrayBuffer(iv) }, cryptoKey, toArrayBuffer(bytes))
|
||||
return textDecoder.decode(decrypted)
|
||||
}
|
||||
|
||||
async function fetchPublicKey(baseURL: string): Promise<PublicKeyResponse | null> {
|
||||
const requestUrl = `${normalizeBaseUrl(baseURL)}${PUBLIC_KEY_PATH}`
|
||||
const response = await fetch(requestUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
if (!result || result.code !== 0 || !result.data) {
|
||||
return null
|
||||
}
|
||||
|
||||
return result.data as PublicKeyResponse
|
||||
}
|
||||
|
||||
async function ensurePublicKey(baseURL: string): Promise<PublicKeyResponse | null> {
|
||||
if (publicKeyCache) {
|
||||
return publicKeyCache
|
||||
}
|
||||
|
||||
if (publicKeyFailureAt > 0 && Date.now() - publicKeyFailureAt < PUBLIC_KEY_RETRY_INTERVAL) {
|
||||
return null
|
||||
}
|
||||
|
||||
let data: PublicKeyResponse | null = null
|
||||
try {
|
||||
data = await fetchPublicKey(baseURL)
|
||||
} catch {
|
||||
publicKeyFailureAt = Date.now()
|
||||
return null
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
publicKeyFailureAt = Date.now()
|
||||
return null
|
||||
}
|
||||
|
||||
publicKeyFailureAt = 0
|
||||
publicKeyCache = data
|
||||
return publicKeyCache
|
||||
}
|
||||
|
||||
export function clearApiEncryptPublicKeyCache() {
|
||||
publicKeyCache = null
|
||||
publicKeyFailureAt = 0
|
||||
}
|
||||
|
||||
export async function prepareEncryptedRequest(baseURL: string, path: string, method: string, body?: unknown): Promise<EncryptRequestResult> {
|
||||
const resolvedPath = resolveRequestPath(baseURL, path)
|
||||
|
||||
if (!resolvedPath.startsWith('/api/') && !resolvedPath.startsWith('/adminapi/')) {
|
||||
return {
|
||||
body: body ? JSON.stringify(body) : '',
|
||||
headers: {},
|
||||
session: null
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldSkipEncrypt(resolvedPath)) {
|
||||
return {
|
||||
body: body ? JSON.stringify(body) : '',
|
||||
headers: {},
|
||||
session: null
|
||||
}
|
||||
}
|
||||
|
||||
const publicKeyData = await ensurePublicKey(baseURL)
|
||||
if (!publicKeyData || !publicKeyData.enabled || publicKeyData.scope === 'off') {
|
||||
return {
|
||||
body: body ? JSON.stringify(body) : '',
|
||||
headers: {},
|
||||
session: null
|
||||
}
|
||||
}
|
||||
|
||||
if (publicKeyData.scope === 'partial' && !matchesEncryptedPath(resolvedPath, publicKeyData.encrypted_paths || [])) {
|
||||
return {
|
||||
body: body ? JSON.stringify(body) : '',
|
||||
headers: {},
|
||||
session: null
|
||||
}
|
||||
}
|
||||
|
||||
const aesKey = crypto.getRandomValues(new Uint8Array(32))
|
||||
const iv = crypto.getRandomValues(new Uint8Array(16))
|
||||
const encryptedKey = await rsaEncrypt(publicKeyData.public_key, aesKey)
|
||||
const encryptedIv = await rsaEncrypt(publicKeyData.public_key, iv)
|
||||
|
||||
let encryptedBody = ''
|
||||
if (body !== undefined && !['GET', 'HEAD'].includes(method.toUpperCase())) {
|
||||
const payload = JSON.stringify(body)
|
||||
encryptedBody = await aesEncrypt(payload, aesKey, iv)
|
||||
}
|
||||
|
||||
return {
|
||||
body: encryptedBody === '' ? '' : JSON.stringify({
|
||||
encrypted: true,
|
||||
algorithm: publicKeyData.algorithm,
|
||||
key_version: publicKeyData.key_version,
|
||||
data: encryptedBody,
|
||||
method
|
||||
}),
|
||||
headers: {
|
||||
'X-Api-Encrypt': '1',
|
||||
'X-Api-Encrypt-Version': publicKeyData.key_version,
|
||||
'X-Api-Encrypt-Key': encryptedKey,
|
||||
'X-Api-Encrypt-Iv': encryptedIv
|
||||
},
|
||||
session: {
|
||||
aesKey,
|
||||
iv,
|
||||
keyVersion: publicKeyData.key_version,
|
||||
encryptedKey,
|
||||
encryptedIv
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function decryptEncryptedResponse(data: any, session: EncryptSession | null): Promise<any> {
|
||||
if (!data || data.encrypted !== true || !session) {
|
||||
return data
|
||||
}
|
||||
|
||||
const plainText = await aesDecrypt(String(data.data || ''), session.aesKey, session.iv)
|
||||
return JSON.parse(plainText)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
interface setParam {
|
||||
key: string,
|
||||
data: any,
|
||||
success?: () => {},
|
||||
fail?: (err: any) => {}
|
||||
}
|
||||
|
||||
class Storage {
|
||||
|
||||
/**
|
||||
* 设置缓存
|
||||
* @param param
|
||||
*/
|
||||
public set(param: setParam) {
|
||||
try {
|
||||
window.localStorage.setItem(param.key, JSON.stringify(param.data))
|
||||
typeof param.success == 'function' && param.success()
|
||||
} catch (error) {
|
||||
typeof param.fail == 'function' && param.fail(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存
|
||||
* @param key
|
||||
* @returns
|
||||
*/
|
||||
public get(key: string) {
|
||||
try {
|
||||
const json: any = window.localStorage.getItem(key)
|
||||
return JSON.parse(json)
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除指定缓存
|
||||
* @param key
|
||||
*/
|
||||
public remove(key: string | string[]) {
|
||||
if (typeof key == 'string') window.localStorage.removeItem(key)
|
||||
else key.forEach(item => { window.localStorage.removeItem(item) })
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理缓存
|
||||
*/
|
||||
public clear() {
|
||||
window.localStorage.clear()
|
||||
}
|
||||
}
|
||||
|
||||
const storage = new Storage()
|
||||
export default storage
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
const validate = {
|
||||
/**
|
||||
* 验证电子邮箱格式
|
||||
*/
|
||||
email(value: string) {
|
||||
return /^\w+((-\w+)|(\.\w+))*\@[A-Za-z0-9]+((\.|-)[A-Za-z0-9]+)*\.[A-Za-z0-9]+$/.test(value)
|
||||
},
|
||||
/**
|
||||
* 验证手机格式
|
||||
*/
|
||||
mobile(value: string) {
|
||||
return /^1[23456789]\d{9}$/.test(value)
|
||||
},
|
||||
/**
|
||||
* 验证URL格式
|
||||
*/
|
||||
url(value: string) {
|
||||
return /^((https|http|ftp|rtsp|mms):\/\/)(([0-9a-zA-Z_!~*'().&=+$%-]+: )?[0-9a-zA-Z_!~*'().&=+$%-]+@)?(([0-9]{1,3}.){3}[0-9]{1,3}|([0-9a-zA-Z_!~*'()-]+.)*([0-9a-zA-Z][0-9a-zA-Z-]{0,61})?[0-9a-zA-Z].[a-zA-Z]{2,6})(:[0-9]{1,4})?((\/?)|(\/[0-9a-zA-Z_!~*'().;?:@&=+$,%#-]+)+\/?)$/
|
||||
.test(value)
|
||||
},
|
||||
/**
|
||||
* 验证日期格式
|
||||
*/
|
||||
date(value: string) {
|
||||
if (!value) return false
|
||||
// 判断是否数值或者字符串数值(意味着为时间戳),转为数值,否则new Date无法识别字符串时间戳
|
||||
if (this.number(value)) value = +value
|
||||
return !/Invalid|NaN/.test(new Date(value).toString())
|
||||
},
|
||||
/**
|
||||
* 验证ISO类型的日期格式
|
||||
*/
|
||||
dateISO(value: string) {
|
||||
return /^\d{4}[\/\-](0?[1-9]|1[012])[\/\-](0?[1-9]|[12][0-9]|3[01])$/.test(value)
|
||||
},
|
||||
/**
|
||||
* 验证十进制数字
|
||||
*/
|
||||
number(value: string) {
|
||||
return /^[\+-]?(\d+\.?\d*|\.\d+|\d\.\d+e\+\d+)$/.test(value)
|
||||
},
|
||||
/**
|
||||
* 验证字符串
|
||||
*/
|
||||
string(value: string) {
|
||||
return typeof value === 'string'
|
||||
},
|
||||
/**
|
||||
* 验证整数
|
||||
*/
|
||||
digits(value: string) {
|
||||
return /^\d+$/.test(value)
|
||||
},
|
||||
/**
|
||||
* 验证身份证号码
|
||||
*/
|
||||
idCard(value: string) {
|
||||
return /^[1-9]\d{5}[1-9]\d{3}((0\d)|(1[0-2]))(([0|1|2]\d)|3[0-1])\d{3}([0-9]|X)$/.test(
|
||||
value
|
||||
)
|
||||
},
|
||||
/**
|
||||
* 是否车牌号
|
||||
*/
|
||||
carNo(value: string) {
|
||||
// 新能源车牌
|
||||
const xreg = /^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领A-Z]{1}[A-Z]{1}(([0-9]{5}[DF]$)|([DF][A-HJ-NP-Z0-9][0-9]{4}$))/
|
||||
// 旧车牌
|
||||
const creg = /^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领A-Z]{1}[A-Z]{1}[A-HJ-NP-Z0-9]{4}[A-HJ-NP-Z0-9挂学警港澳]{1}$/
|
||||
if (value.length === 7) {
|
||||
return creg.test(value)
|
||||
} if (value.length === 8) {
|
||||
return xreg.test(value)
|
||||
}
|
||||
return false
|
||||
},
|
||||
/**
|
||||
* 金额,只允许2位小数
|
||||
*/
|
||||
amount(value: string) {
|
||||
// 金额,只允许保留两位小数
|
||||
return /^[1-9]\d*(,\d{3})*(\.\d{1,2})?$|^0\.\d{1,2}$/.test(value)
|
||||
},
|
||||
/**
|
||||
* 中文
|
||||
*/
|
||||
chinese(value: string) {
|
||||
const reg = /^[\u4e00-\u9fa5]+$/gi
|
||||
return reg.test(value)
|
||||
},
|
||||
/**
|
||||
* 只能输入字母
|
||||
*/
|
||||
letter(value: string) {
|
||||
return /^[a-zA-Z]*$/.test(value)
|
||||
},
|
||||
/**
|
||||
* 只能是字母或者数字
|
||||
*/
|
||||
enOrNum(value: string) {
|
||||
// 英文或者数字
|
||||
const reg = /^[0-9a-zA-Z]*$/g
|
||||
return reg.test(value)
|
||||
},
|
||||
/**
|
||||
* 验证是否包含某个值
|
||||
*/
|
||||
contains(value: string, param: string) {
|
||||
return value.indexOf(param) >= 0
|
||||
},
|
||||
/**
|
||||
* 验证一个值范围[min, max]
|
||||
*/
|
||||
range(value: number, param: number[]) {
|
||||
return value >= param[0] && value <= param[1]
|
||||
},
|
||||
/**
|
||||
* 验证一个长度范围[min, max]
|
||||
*/
|
||||
rangeLength(value: string, param: number[]) {
|
||||
return value.length >= param[0] && value.length <= param[1]
|
||||
},
|
||||
/**
|
||||
* 是否固定电话
|
||||
*/
|
||||
landline(value: string) {
|
||||
const reg = /^\d{3,4}-\d{7,8}(-\d{3,4})?$/
|
||||
return reg.test(value)
|
||||
},
|
||||
/**
|
||||
* 判断是否为空
|
||||
*/
|
||||
empty(value: any) {
|
||||
switch (typeof value) {
|
||||
case 'undefined':
|
||||
return true
|
||||
case 'string':
|
||||
if (value.replace(/(^[ \t\n\r]*)|([ \t\n\r]*$)/g, '').length == 0) return true
|
||||
break
|
||||
case 'boolean':
|
||||
if (!value) return true
|
||||
break
|
||||
case 'number':
|
||||
if (value === 0 || isNaN(value)) return true
|
||||
break
|
||||
case 'object':
|
||||
if (value === null || value.length === 0) return true
|
||||
for (const i in value) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
/**
|
||||
* 是否json字符串
|
||||
*/
|
||||
jsonString(value: object) {
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const obj = JSON.parse(value)
|
||||
if (typeof obj === 'object' && obj) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
/**
|
||||
* 是否数组
|
||||
*/
|
||||
array(value: []) {
|
||||
if (typeof Array.isArray === 'function') {
|
||||
return Array.isArray(value)
|
||||
}
|
||||
return Object.prototype.toString.call(value) === '[object Array]'
|
||||
},
|
||||
/**
|
||||
* 是否对象
|
||||
*/
|
||||
object(value: object) {
|
||||
return Object.prototype.toString.call(value) === '[object Object]'
|
||||
},
|
||||
/**
|
||||
* 是否短信验证码
|
||||
*/
|
||||
code(value: string, len = 6) {
|
||||
return new RegExp(`^\\d{${len}}$`).test(value)
|
||||
},
|
||||
/**
|
||||
* 是否函数方法
|
||||
* @param {Object} value
|
||||
*/
|
||||
func(value: string) {
|
||||
return typeof value === 'function'
|
||||
},
|
||||
/**
|
||||
* 是否promise对象
|
||||
* @param {Object} value
|
||||
*/
|
||||
promise(value: Promise<any>) {
|
||||
return this.object(value) && this.func(value.then) && this.func(value.catch)
|
||||
},
|
||||
/** 是否图片格式
|
||||
* @param {Object} value
|
||||
*/
|
||||
image(value: string) {
|
||||
const newValue = value.split('?')[0]
|
||||
const IMAGE_REGEXP = /\.(jpeg|jpg|gif|png|svg|webp|jfif|bmp|dpg)/i
|
||||
return IMAGE_REGEXP.test(newValue)
|
||||
},
|
||||
/**
|
||||
* 是否视频格式
|
||||
* @param {Object} value
|
||||
*/
|
||||
video(value: string) {
|
||||
const VIDEO_REGEXP = /\.(mp4|mpg|mpeg|dat|asf|avi|rm|rmvb|mov|wmv|flv|mkv|m3u8)/i
|
||||
return VIDEO_REGEXP.test(value)
|
||||
},
|
||||
/**
|
||||
* 是否为正则对象
|
||||
* @param o
|
||||
*/
|
||||
regExp(o) {
|
||||
return o && Object.prototype.toString.call(o) === '[object RegExp]'
|
||||
}
|
||||
}
|
||||
|
||||
export default validate
|
||||
Reference in New Issue
Block a user