feat: 功能优化
This commit is contained in:
254
frontend/app/web-gold/src/utils/token-manager.js
Normal file
254
frontend/app/web-gold/src/utils/token-manager.js
Normal file
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Token 统一管理器
|
||||
*
|
||||
* 功能特性:
|
||||
* - ✅ 安全存储访问令牌、刷新令牌、过期时间等信息
|
||||
* - ✅ 自动检查令牌是否过期(支持提前刷新缓冲时间)
|
||||
* * ✅ 提供订阅者模式,监听令牌变化事件
|
||||
*
|
||||
|
||||
*/
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
// localStorage 中存储的键名常量
|
||||
const TOKEN_KEYS = {
|
||||
ACCESS_TOKEN: 'access_token',
|
||||
REFRESH_TOKEN: 'refresh_token',
|
||||
EXPIRES_TIME: 'expires_time',
|
||||
TOKEN_TYPE: 'token_type',
|
||||
}
|
||||
|
||||
class TokenManager {
|
||||
constructor() {
|
||||
this.subscribers = [] // 订阅token变化的回调
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 LocalDateTime 格式为毫秒时间戳(使用 dayjs)
|
||||
* @param {string} dateTimeStr - LocalDateTime 格式字符串,如 "2025-12-27T10:27:42"
|
||||
* @returns {number} Unix 时间戳(毫秒)
|
||||
*/
|
||||
parseLocalDateTime(dateTimeStr) {
|
||||
if (!dateTimeStr) return 0
|
||||
|
||||
// 使用 dayjs 解析 LocalDateTime 格式
|
||||
const normalizedStr = dateTimeStr.includes(' ')
|
||||
? dateTimeStr.replace(' ', 'T')
|
||||
: dateTimeStr
|
||||
|
||||
const parsedTime = dayjs(normalizedStr)
|
||||
|
||||
if (!parsedTime.isValid()) {
|
||||
console.warn('[TokenManager] 无法解析过期时间:', dateTimeStr)
|
||||
return 0
|
||||
}
|
||||
|
||||
return parsedTime.valueOf() // 返回毫秒时间戳
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取访问令牌
|
||||
* @returns {string|null} 访问令牌,如果不存在则返回 null
|
||||
*/
|
||||
getAccessToken() {
|
||||
return localStorage.getItem(TOKEN_KEYS.ACCESS_TOKEN)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取刷新令牌
|
||||
* @returns {string|null} 刷新令牌,如果不存在则返回 null
|
||||
*/
|
||||
getRefreshToken() {
|
||||
return localStorage.getItem(TOKEN_KEYS.REFRESH_TOKEN)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取过期时间(毫秒时间戳)
|
||||
* @returns {number} 过期时间戳,如果未设置则返回 0
|
||||
*/
|
||||
getExpiresTime() {
|
||||
const expiresTimeStr = localStorage.getItem(TOKEN_KEYS.EXPIRES_TIME)
|
||||
return expiresTimeStr ? parseInt(expiresTimeStr, 10) : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取令牌类型
|
||||
* @returns {string} 令牌类型,默认为 'Bearer'
|
||||
*/
|
||||
getTokenType() {
|
||||
return localStorage.getItem(TOKEN_KEYS.TOKEN_TYPE) || 'Bearer'
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查访问令牌是否已过期
|
||||
* @param {number} bufferTime - 提前刷新缓冲时间(毫秒),默认 5 分钟
|
||||
* @returns {boolean} 如果已过期或即将过期(超过缓冲时间)则返回 true
|
||||
*/
|
||||
isExpired(bufferTime = 5 * 60 * 1000) {
|
||||
const expiresTime = this.getExpiresTime()
|
||||
const now = Date.now()
|
||||
|
||||
// 没有过期时间或当前时间已超过(过期时间 - 缓冲时间)
|
||||
return !expiresTime || now >= (expiresTime - bufferTime)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否已登录(有有效的访问令牌)
|
||||
* @returns {boolean} 如果有有效令牌则返回 true
|
||||
*/
|
||||
isLoggedIn() {
|
||||
const token = this.getAccessToken()
|
||||
return Boolean(token) && !this.isExpired()
|
||||
}
|
||||
|
||||
/**
|
||||
* 存储访问令牌和相关信息到本地存储
|
||||
* @param {Object} tokenInfo - 令牌信息对象
|
||||
* @param {string} tokenInfo.accessToken - 访问令牌(必填)
|
||||
* @param {string} tokenInfo.refreshToken - 刷新令牌(可选)
|
||||
* @param {number} tokenInfo.expiresIn - 令牌有效期(秒,可选)
|
||||
* @param {string|number} tokenInfo.expiresTime - 过期时间(可选,支持 LocalDateTime 字符串、数字格式)
|
||||
* @param {string} tokenInfo.tokenType - 令牌类型,默认为 'Bearer'
|
||||
*/
|
||||
setTokens(tokenInfo) {
|
||||
const {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn,
|
||||
expiresTime,
|
||||
tokenType = 'Bearer'
|
||||
} = tokenInfo
|
||||
|
||||
// 校验:必须提供访问令牌
|
||||
if (!accessToken) {
|
||||
console.error('[TokenManager] 设置令牌失败:缺少 accessToken')
|
||||
return
|
||||
}
|
||||
|
||||
// 存储到 localStorage
|
||||
localStorage.setItem(TOKEN_KEYS.ACCESS_TOKEN, accessToken)
|
||||
|
||||
if (refreshToken) {
|
||||
localStorage.setItem(TOKEN_KEYS.REFRESH_TOKEN, refreshToken)
|
||||
}
|
||||
|
||||
// 处理过期时间
|
||||
let expiresTimeMs = 0
|
||||
if (expiresTime) {
|
||||
// 检查类型并转换
|
||||
if (typeof expiresTime === 'string' && expiresTime.includes('T')) {
|
||||
// LocalDateTime 格式
|
||||
expiresTimeMs = this.parseLocalDateTime(expiresTime)
|
||||
} else if (typeof expiresTime === 'number') {
|
||||
// 数字格式(可能是秒或毫秒)
|
||||
expiresTimeMs = expiresTime > 10000000000 ? expiresTime : expiresTime * 1000
|
||||
} else if (expiresIn) {
|
||||
// 通过 expiresIn 计算
|
||||
expiresTimeMs = Date.now() + (expiresIn * 1000)
|
||||
}
|
||||
|
||||
if (expiresTimeMs) {
|
||||
localStorage.setItem(TOKEN_KEYS.EXPIRES_TIME, String(expiresTimeMs))
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem(TOKEN_KEYS.TOKEN_TYPE, tokenType)
|
||||
|
||||
// 通知所有订阅者令牌已更新
|
||||
this.notifySubscribers('token-set', tokenInfo)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有存储的令牌信息
|
||||
* 从 localStorage 中删除所有令牌相关数据,并通知订阅者
|
||||
*/
|
||||
clearTokens() {
|
||||
// 删除所有令牌相关的 localStorage 项
|
||||
Object.values(TOKEN_KEYS).forEach(key => {
|
||||
localStorage.removeItem(key)
|
||||
})
|
||||
|
||||
// 通知所有订阅者令牌已被清除
|
||||
this.notifySubscribers('token-cleared', null)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成完整的 Authorization 请求头值
|
||||
* @returns {string} 例如:"Bearer eyJhbGciOi...",如果没有令牌则返回空字符串
|
||||
*/
|
||||
getAuthHeader() {
|
||||
const token = this.getAccessToken()
|
||||
const tokenType = this.getTokenType()
|
||||
|
||||
// 只有当令牌存在时才返回完整的 Authorization 头
|
||||
return token ? `${tokenType} ${token}` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅令牌变化事件
|
||||
* @param {Function} callback - 回调函数,接收 (type, tokenInfo) 两个参数
|
||||
* - type: 事件类型('token-set' | 'token-cleared')
|
||||
* - tokenInfo: 令牌信息对象或 null
|
||||
*/
|
||||
subscribe(callback) {
|
||||
// 仅接受函数类型的回调
|
||||
if (typeof callback === 'function') {
|
||||
this.subscribers.push(callback)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消令牌变化事件订阅
|
||||
* @param {Function} callback - 要取消的回调函数
|
||||
*/
|
||||
unsubscribe(callback) {
|
||||
const index = this.subscribers.indexOf(callback)
|
||||
|
||||
// 如果找到回调函数,则从数组中移除
|
||||
if (index !== -1) {
|
||||
this.subscribers.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知所有订阅者令牌已发生变化
|
||||
* @param {string} type - 事件类型
|
||||
* @param {Object|null} tokenInfo - 令牌信息
|
||||
*/
|
||||
notifySubscribers(type, tokenInfo) {
|
||||
// 遍历所有订阅者并调用其回调函数
|
||||
this.subscribers.forEach(callback => {
|
||||
try {
|
||||
callback(type, tokenInfo)
|
||||
} catch (error) {
|
||||
// 订阅者回调出错不应影响其他回调的执行
|
||||
console.error('[TokenManager] 订阅者回调执行失败:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 【兼容方法】获取访问令牌
|
||||
* @deprecated 请使用 getAccessToken() 替代
|
||||
* @returns {string|null} 访问令牌
|
||||
*/
|
||||
getToken() {
|
||||
return this.getAccessToken()
|
||||
}
|
||||
|
||||
/**
|
||||
* 【兼容方法】移除所有令牌
|
||||
* @deprecated 请使用 clearTokens() 替代
|
||||
*/
|
||||
removeToken() {
|
||||
this.clearTokens()
|
||||
}
|
||||
}
|
||||
|
||||
// 创建 TokenManager 单例实例
|
||||
const tokenManager = new TokenManager()
|
||||
|
||||
// 最简单的导出方式:直接导出实例
|
||||
// 使用方法:import tokenManager from '@gold/utils/token-manager'
|
||||
// tokenManager.getAccessToken()
|
||||
export default tokenManager
|
||||
183
frontend/app/web-gold/src/utils/upload.js
Normal file
183
frontend/app/web-gold/src/utils/upload.js
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* 上传工具函数
|
||||
* 提供快速创建上传配置的便捷方法
|
||||
*/
|
||||
|
||||
import { MaterialService } from '@api/material'
|
||||
|
||||
/**
|
||||
* 验证文件大小(默认500MB)
|
||||
* @param {File} file - 文件对象
|
||||
* @param {number} maxSizeMB - 最大大小(MB)
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function validateFileSize(file, maxSizeMB = 500) {
|
||||
const maxSizeBytes = maxSizeMB * 1024 * 1024
|
||||
if (file.size > maxSizeBytes) {
|
||||
throw new Error(`文件大小不能超过 ${maxSizeMB}MB`)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证文件类型
|
||||
* @param {File} file - 文件对象
|
||||
* @param {Array<string>} allowedTypes - 允许的MIME类型
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function validateFileType(file, allowedTypes) {
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
throw new Error(`不支持的文件类型:${file.type}`)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取标准上传配置
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {string} options.fileCategory - 文件分类
|
||||
* @param {number|null} options.groupId - 分组编号(可选)
|
||||
* @param {number} options.maxSize - 最大大小(MB)
|
||||
* @param {string} options.accept - 接受的文件类型
|
||||
* @param {Function} options.onBeforeUpload - 上传前验证回调
|
||||
* @returns {Object} - Upload组件的配置
|
||||
*/
|
||||
export function getUploadProps(options = {}) {
|
||||
const {
|
||||
fileCategory,
|
||||
groupId = null,
|
||||
maxSize = 500,
|
||||
accept = '*/*',
|
||||
onBeforeUpload
|
||||
} = options
|
||||
|
||||
return {
|
||||
accept,
|
||||
multiple: false,
|
||||
showUploadList: false,
|
||||
beforeUpload: (file) => {
|
||||
try {
|
||||
// 验证文件大小
|
||||
validateFileSize(file, maxSize)
|
||||
|
||||
// 执行自定义验证
|
||||
if (onBeforeUpload) {
|
||||
const result = onBeforeUpload(file)
|
||||
if (result === false) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('文件验证失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取音频上传配置
|
||||
* @param {number|null} groupId - 分组编号(可选,仅素材库使用)
|
||||
* @param {Object} customOptions - 自定义选项
|
||||
* @returns {Object} - Upload组件的配置
|
||||
*/
|
||||
export function getAudioUploadConfig(groupId = null, customOptions = {}) {
|
||||
return getUploadProps({
|
||||
fileCategory: 'voice',
|
||||
groupId,
|
||||
maxSize: 100, // 音频文件通常较小
|
||||
accept: 'audio/*',
|
||||
...customOptions
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视频上传配置
|
||||
* @param {number|null} groupId - 分组编号(可选,仅素材库使用)
|
||||
* @param {Object} customOptions - 自定义选项
|
||||
* @returns {Object} - Upload组件的配置
|
||||
*/
|
||||
export function getVideoUploadConfig(groupId = null, customOptions = {}) {
|
||||
return getUploadProps({
|
||||
fileCategory: 'video',
|
||||
groupId,
|
||||
maxSize: 500, // 视频文件可能很大
|
||||
accept: 'video/*',
|
||||
...customOptions
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图片上传配置
|
||||
* @param {number|null} groupId - 分组编号(可选,仅素材库使用)
|
||||
* @param {Object} customOptions - 自定义选项
|
||||
* @returns {Object} - Upload组件的配置
|
||||
*/
|
||||
export function getImageUploadConfig(groupId = null, customOptions = {}) {
|
||||
return getUploadProps({
|
||||
fileCategory: 'image',
|
||||
groupId,
|
||||
maxSize: 50, // 图片文件中等大小
|
||||
accept: 'image/*',
|
||||
...customOptions
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配音上传配置(独立模块,不使用groupId)
|
||||
* @param {Object} customOptions - 自定义选项
|
||||
* @returns {Object} - Upload组件的配置
|
||||
*/
|
||||
export function getVoiceUploadConfig(customOptions = {}) {
|
||||
return getUploadProps({
|
||||
fileCategory: 'voice',
|
||||
groupId: null, // 配音模块不使用groupId
|
||||
maxSize: 100,
|
||||
accept: 'audio/*',
|
||||
...customOptions
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数字人视频上传配置(独立模块,不使用groupId)
|
||||
* @param {Object} customOptions - 自定义选项
|
||||
* @returns {Object} - Upload组件的配置
|
||||
*/
|
||||
export function getDigitalHumanVideoUploadConfig(customOptions = {}) {
|
||||
return getUploadProps({
|
||||
fileCategory: 'video',
|
||||
groupId: null, // 数字人模块不使用groupId
|
||||
maxSize: 500,
|
||||
accept: 'video/*',
|
||||
...customOptions
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数字人配音上传配置(独立模块,不使用groupId)
|
||||
* @param {Object} customOptions - 自定义选项
|
||||
* @returns {Object} - Upload组件的配置
|
||||
*/
|
||||
export function getDigitalHumanVoiceUploadConfig(customOptions = {}) {
|
||||
return getUploadProps({
|
||||
fileCategory: 'voice',
|
||||
groupId: null, // 数字人模块不使用groupId
|
||||
maxSize: 100,
|
||||
accept: 'audio/*',
|
||||
...customOptions
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
getUploadProps,
|
||||
getAudioUploadConfig,
|
||||
getVideoUploadConfig,
|
||||
getImageUploadConfig,
|
||||
getVoiceUploadConfig,
|
||||
getDigitalHumanVideoUploadConfig,
|
||||
getDigitalHumanVoiceUploadConfig,
|
||||
validateFileSize,
|
||||
validateFileType
|
||||
}
|
||||
Reference in New Issue
Block a user