feat: 重构HTTP客户端架构和认证系统
核心改进: - HTTP客户端:工厂函数模式,支持自定义拦截器和401/403处理 - 认证服务:函数式实现,消除this绑定问题,支持业务码+HTTP状态码双通道 - Token管理:简化为直接实例导出,移除bind()和箭头函数包装 - 路由守卫:优化逻辑,移除冗余代码,更简洁易维护 技术亮点: - 统一401/403错误处理(业务code和HTTP status双检查) - 自动刷新token并重试请求,保留自定义拦截器 - 分层清晰:clientAxios (Mono) -> http (应用) -> AuthService - 支持扩展:业务代码可创建自定义HTTP实例并添加拦截器 文件变更: - 新增 AuthService.js (函数式) 和 Login.vue - 重构 http.js、token-manager.js、router/index.js - 删除 TokenInput.vue、utils/auth.js 等冗余文件 - 更新所有API调用点使用直接实例导入 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,8 @@
|
||||
*/
|
||||
|
||||
import axios from 'axios'
|
||||
import { getAuthHeader, clearAllTokens } from '@gold/utils/token-manager'
|
||||
// 直接使用实例(最简单、最可靠)
|
||||
import tokenManager from '@gold/utils/token-manager'
|
||||
|
||||
/**
|
||||
* 不需要 token 的接口白名单
|
||||
@@ -37,7 +38,7 @@ function handle401Error() {
|
||||
isHandling401 = true
|
||||
|
||||
try {
|
||||
clearAllTokens()
|
||||
tokenManager.clearTokens()
|
||||
} catch (e) {
|
||||
console.error('清空 token 失败:', e)
|
||||
}
|
||||
@@ -85,7 +86,7 @@ export function createClientAxios(options = {}) {
|
||||
// 添加 Authorization header
|
||||
const needToken = config.headers?.isToken !== false && !isInWhiteList(config.url || '')
|
||||
if (needToken) {
|
||||
const authHeader = getAuthHeader()
|
||||
const authHeader = tokenManager.getAuthHeader()
|
||||
if (authHeader) {
|
||||
config.headers.Authorization = authHeader
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import TopNav from './components/TopNav.vue'
|
||||
import { theme } from 'ant-design-vue'
|
||||
import SvgSprite from '@/components/icons/SvgSprite.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { getToken } from '@gold/utils/token-manager'
|
||||
import tokenManager from '@gold/utils/token-manager'
|
||||
|
||||
function readCssVar(name) {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || undefined
|
||||
@@ -40,15 +40,15 @@ onMounted(async () => {
|
||||
|
||||
// 检查登录状态:如果有token但store中未标记为登录,则恢复登录状态
|
||||
const userStore = useUserStore()
|
||||
|
||||
|
||||
// 等待store从本地存储恢复完成(最多等待500ms)
|
||||
let waitCount = 0
|
||||
while (!userStore.isHydrated && waitCount < 50) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
waitCount++
|
||||
}
|
||||
|
||||
const token = getToken()
|
||||
|
||||
const token = tokenManager.getAccessToken()
|
||||
if (token) {
|
||||
// 如果有token但未登录,可能是刷新页面,需要恢复登录状态
|
||||
if (!userStore.isLoggedIn) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
|
||||
import api from '@/api/http'
|
||||
import { setToken, getRefreshToken } from '@/utils/auth'
|
||||
// 直接使用实例(最简单、最可靠)
|
||||
import tokenManager from '@gold/utils/token-manager'
|
||||
// 使用公共配置
|
||||
import { API_BASE } from '@gold/config/api'
|
||||
|
||||
@@ -12,7 +13,7 @@ const SERVER_BASE = API_BASE.APP_MEMBER
|
||||
*/
|
||||
function saveTokens(info) {
|
||||
if (info?.accessToken || info?.refreshToken) {
|
||||
setToken({
|
||||
tokenManager.setTokens({
|
||||
accessToken: info.accessToken || '',
|
||||
refreshToken: info.refreshToken || '',
|
||||
})
|
||||
@@ -118,7 +119,7 @@ export async function loginBySms(mobile, code) {
|
||||
* @returns {Promise<Object>} data.data: { accessToken, refreshToken, expiresTime, userInfo }
|
||||
*/
|
||||
export async function refreshToken() {
|
||||
const rt = getRefreshToken();
|
||||
const rt = tokenManager.getRefreshToken();
|
||||
if (!rt) throw new Error('缺少 refresh_token');
|
||||
// refreshToken 作为 URL 查询参数传递
|
||||
const { data } = await api.post(`${SERVER_BASE}/auth/refresh-token`, null, { params: { refreshToken: rt } });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import request from '@/api/http'
|
||||
import { fetchEventSource } from '@microsoft/fetch-event-source'
|
||||
import { getAccessToken } from '@/utils/auth'
|
||||
// 直接使用 tokenManager 实例(最简单、最可靠)
|
||||
import tokenManager from '@gold/utils/token-manager'
|
||||
// 使用公共配置
|
||||
import { API_BASE } from '@gold/config/api'
|
||||
|
||||
@@ -32,7 +33,7 @@ export const ChatMessageApi = {
|
||||
onClose,
|
||||
attachmentUrls = []
|
||||
} = options || {}
|
||||
const token = getAccessToken()
|
||||
const token = tokenManager.getAccessToken()
|
||||
|
||||
let retryCount = 0
|
||||
const maxRetries = 0 // 禁用自动重试
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
*/
|
||||
|
||||
import { fetchEventSource } from '@microsoft/fetch-event-source'
|
||||
import { getAuthHeader } from '@gold/utils/token-manager'
|
||||
// 直接使用实例(最简单、最可靠)
|
||||
import tokenManager from '@gold/utils/token-manager'
|
||||
import { TikHubService } from '@gold/api/services'
|
||||
import { API_BASE } from '@gold/config/api'
|
||||
|
||||
@@ -43,9 +44,9 @@ export const CommonService = {
|
||||
onError,
|
||||
onClose
|
||||
} = options || {}
|
||||
|
||||
const authHeader = getAuthHeader()
|
||||
|
||||
|
||||
const authHeader = tokenManager.getAuthHeader()
|
||||
|
||||
let retryCount = 0
|
||||
const maxRetries = 0
|
||||
|
||||
|
||||
@@ -1,168 +1,110 @@
|
||||
/**
|
||||
* 应用层 HTTP 客户端
|
||||
* 使用 mono 级别的 axios 实例,添加应用特定的 401 处理
|
||||
* 支持自定义拦截器的可扩展版本
|
||||
* - 提供 createHttpClient 工厂函数
|
||||
* - 业务代码可以创建自己的 http 实例并添加拦截器
|
||||
*/
|
||||
|
||||
import { message } from 'ant-design-vue'
|
||||
import { clearAllTokens, getRefreshToken } from '@gold/utils/token-manager'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { createClientAxios } from '@gold/api/axios/client'
|
||||
import { refreshToken } from '@/api/auth'
|
||||
|
||||
// 刷新 token 的状态管理
|
||||
let isRefreshing = false
|
||||
let refreshPromise = null
|
||||
import authService from '@/services/AuthService'
|
||||
|
||||
/**
|
||||
* 处理 403 禁止访问错误(应用层特定逻辑)
|
||||
* 先尝试刷新 token,如果失败或没有 refresh token 才提示用户
|
||||
* 创建应用层 HTTP 客户端实例
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {Function} options.on401 - 401 错误处理回调
|
||||
* @param {Function} options.on403 - 403 错误处理回调
|
||||
* @returns {AxiosInstance} HTTP 客户端实例
|
||||
*/
|
||||
async function handleApp403Error() {
|
||||
// 避免重复处理
|
||||
if (handleApp403Error.processed) {
|
||||
return
|
||||
}
|
||||
|
||||
handleApp403Error.processed = true
|
||||
|
||||
try {
|
||||
// 检查是否有 refresh token
|
||||
const refreshTokenValue = getRefreshToken()
|
||||
|
||||
if (refreshTokenValue) {
|
||||
// 如果有 refresh token,尝试刷新
|
||||
try {
|
||||
// 如果正在刷新,等待刷新完成
|
||||
if (isRefreshing && refreshPromise) {
|
||||
await refreshPromise
|
||||
handleApp403Error.processed = false
|
||||
return
|
||||
}
|
||||
|
||||
// 开始刷新 token
|
||||
isRefreshing = true
|
||||
refreshPromise = refreshToken()
|
||||
|
||||
await refreshPromise
|
||||
|
||||
// 刷新成功,重置状态
|
||||
isRefreshing = false
|
||||
refreshPromise = null
|
||||
handleApp403Error.processed = false
|
||||
|
||||
// 刷新成功,不提示用户(静默刷新)
|
||||
return
|
||||
} catch (refreshError) {
|
||||
// 刷新失败,清除状态
|
||||
isRefreshing = false
|
||||
refreshPromise = null
|
||||
console.error('刷新 token 失败:', refreshError)
|
||||
|
||||
// 刷新失败才提示用户
|
||||
message.warning('登录状态已过期,请重新登录', 3)
|
||||
handleApp403Error.processed = false
|
||||
return
|
||||
export function createHttpClient(options = {}) {
|
||||
const { on401, on403 } = options
|
||||
|
||||
const httpClient = createClientAxios({
|
||||
baseURL: '/',
|
||||
timeout: 180000,
|
||||
on401: (error) => {
|
||||
if (on401) {
|
||||
on401(error)
|
||||
} else {
|
||||
// 默认使用 AuthService 处理,并传入 httpClient 确保拦截器生效
|
||||
authService.handleAuthError(
|
||||
error,
|
||||
() => window.location.href = '/login',
|
||||
{ httpClient } // 关键:传入 HTTP 实例
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// 没有 refresh token,提示用户
|
||||
message.warning('登录状态已过期,请重新登录', 3)
|
||||
handleApp403Error.processed = false
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('处理 403 错误失败:', e)
|
||||
handleApp403Error.processed = false
|
||||
}
|
||||
},
|
||||
on403: (error) => {
|
||||
if (on403) {
|
||||
on403(error)
|
||||
} else {
|
||||
// 默认使用 AuthService 处理,并传入 httpClient 确保拦截器生效
|
||||
authService.handleAuthError(
|
||||
error,
|
||||
() => window.location.href = '/login',
|
||||
{ httpClient } // 关键:传入 HTTP 实例
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return httpClient
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 401 未授权错误(应用层特定逻辑)
|
||||
* 先尝试刷新 token,如果失败或没有 refresh token 才退出登录
|
||||
* 默认的 HTTP 客户端实例
|
||||
* 业务代码可以:
|
||||
* 1. 直接使用 http 实例
|
||||
* 2. 调用 createHttpClient() 创建自定义实例
|
||||
* 3. 在自定义实例上添加拦截器
|
||||
*/
|
||||
async function handleApp401Error() {
|
||||
// 避免重复处理
|
||||
if (handleApp401Error.processed) {
|
||||
return
|
||||
}
|
||||
|
||||
handleApp401Error.processed = true
|
||||
|
||||
try {
|
||||
// 检查是否有 refresh token
|
||||
const refreshTokenValue = getRefreshToken()
|
||||
|
||||
if (refreshTokenValue) {
|
||||
// 如果有 refresh token,尝试刷新
|
||||
try {
|
||||
// 如果正在刷新,等待刷新完成
|
||||
if (isRefreshing && refreshPromise) {
|
||||
await refreshPromise
|
||||
handleApp401Error.processed = false
|
||||
return
|
||||
}
|
||||
|
||||
// 开始刷新 token
|
||||
isRefreshing = true
|
||||
refreshPromise = refreshToken()
|
||||
|
||||
await refreshPromise
|
||||
|
||||
// 刷新成功,重置状态
|
||||
isRefreshing = false
|
||||
refreshPromise = null
|
||||
handleApp401Error.processed = false
|
||||
|
||||
// 刷新成功,不提示用户(静默刷新)
|
||||
return
|
||||
} catch (refreshError) {
|
||||
// 刷新失败,清除状态
|
||||
isRefreshing = false
|
||||
refreshPromise = null
|
||||
console.error('刷新 token 失败:', refreshError)
|
||||
|
||||
// 继续执行退出登录逻辑
|
||||
}
|
||||
}
|
||||
|
||||
// 没有 refresh token 或刷新失败,执行退出登录
|
||||
try {
|
||||
clearAllTokens()
|
||||
} catch (e) {
|
||||
console.error('清空 token 失败:', e)
|
||||
}
|
||||
|
||||
try {
|
||||
const userStore = useUserStore()
|
||||
userStore.logout()
|
||||
} catch (e) {
|
||||
console.error('退出登录失败:', e)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
message.warning('登录已过期,请重新登录', 3)
|
||||
}, 100)
|
||||
|
||||
} catch (e) {
|
||||
console.error('处理 401 错误失败:', e)
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
handleApp401Error.processed = false
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建应用层 HTTP 客户端
|
||||
* 基于 mono 级别的 axios 实例,添加应用特定的错误处理
|
||||
*/
|
||||
const http = createClientAxios({
|
||||
baseURL: '/',
|
||||
timeout: 180000,
|
||||
on401: handleApp401Error,
|
||||
on403: handleApp403Error,
|
||||
})
|
||||
|
||||
// 注意:403 处理已在 createClientAxios 的响应拦截器中通过 on403 回调处理
|
||||
const http = createHttpClient()
|
||||
|
||||
export default http
|
||||
|
||||
/**
|
||||
* 使用示例:
|
||||
*
|
||||
* 1. 直接使用默认实例
|
||||
* import http from '@/api/http'
|
||||
* await http.post('/api/data')
|
||||
*
|
||||
* 2. 创建自定义实例并添加拦截器
|
||||
* import { createHttpClient } from '@/api/http'
|
||||
*
|
||||
* const myHttp = createHttpClient()
|
||||
* myHttp.interceptors.request.use((config) => {
|
||||
* // 添加自定义请求头
|
||||
* config.headers['X-Custom-Header'] = 'value'
|
||||
* return config
|
||||
* })
|
||||
* await myHttp.post('/api/data')
|
||||
*
|
||||
* 3. 自定义 401 处理
|
||||
* import { createHttpClient } from '@/api/http'
|
||||
*
|
||||
* const myHttp = createHttpClient({
|
||||
* on401: (error) => {
|
||||
* // 自定义 401 处理逻辑
|
||||
* console.log('自定义 401 处理')
|
||||
* }
|
||||
* })
|
||||
*
|
||||
* 4. 高级用法:自动刷新 token 并重试请求
|
||||
* import { createHttpClient } from '@/api/http'
|
||||
*
|
||||
* const myHttp = createHttpClient()
|
||||
*
|
||||
* try {
|
||||
* const response = await myHttp.post('/api/data')
|
||||
* console.log(response.data)
|
||||
* } catch (error) {
|
||||
* // 如果是 401 且刷新失败,会自动调用 onAuthFailed 回调
|
||||
* console.error('请求失败:', error.message)
|
||||
* }
|
||||
*
|
||||
* 注意:当 401 发生时,AuthService 会自动尝试刷新 token。
|
||||
* 如果刷新成功,会使用相同的 http 实例重新发起请求,
|
||||
* 确保自定义拦截器被正确应用。
|
||||
*/
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import { ref, computed, onUnmounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import authApi, { SMS_SCENE, SMS_TEMPLATE_CODE } from '@/api/auth'
|
||||
import { setToken } from '@/utils/auth'
|
||||
// 直接使用实例(最简单、最可靠)
|
||||
import tokenManager from '@gold/utils/token-manager'
|
||||
|
||||
defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
@@ -213,7 +214,7 @@ async function handleSubmit() {
|
||||
|
||||
async function handleLoginSuccess(info) {
|
||||
if (info?.accessToken) {
|
||||
setToken({ accessToken: info.accessToken, refreshToken: info.refreshToken })
|
||||
tokenManager.setTokens({ accessToken: info.accessToken, refreshToken: info.refreshToken })
|
||||
}
|
||||
const userInfo = info?.userInfo || {}
|
||||
await userStore.loginWithPhone({
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { setDevToken, getDevToken } from '@gold/utils/token-manager'
|
||||
|
||||
const token = ref('')
|
||||
const isVisible = ref(true)
|
||||
|
||||
// 从 sessionStorage 恢复 token
|
||||
onMounted(() => {
|
||||
const saved = getDevToken()
|
||||
if (saved) {
|
||||
token.value = saved
|
||||
}
|
||||
})
|
||||
|
||||
// 保存 token
|
||||
const handleSave = () => {
|
||||
if (token.value.trim()) {
|
||||
setDevToken(token.value)
|
||||
message.success('Token 已保存')
|
||||
} else {
|
||||
setDevToken('')
|
||||
message.success('Token 已清除')
|
||||
}
|
||||
}
|
||||
|
||||
// 监听输入变化,自动保存(防抖)
|
||||
let saveTimer = null
|
||||
watch(token, () => {
|
||||
clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(() => {
|
||||
if (token.value.trim()) {
|
||||
setDevToken(token.value)
|
||||
}
|
||||
}, 500)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isVisible" class="token-input-wrapper">
|
||||
<div class="token-input-label">Dev Token</div>
|
||||
<input
|
||||
v-model="token"
|
||||
type="password"
|
||||
placeholder="输入测试 token"
|
||||
class="token-input"
|
||||
@keyup.enter="handleSave"
|
||||
/>
|
||||
<button class="token-btn" @click="handleSave">保存</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.token-input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.token-input-label {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.token-input {
|
||||
width: 200px;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.token-input:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
|
||||
.token-btn {
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.token-btn:hover {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { getToken } from '@gold/utils/token-manager'
|
||||
import tokenManager from '@gold/utils/token-manager'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/content-style/benchmark'
|
||||
},
|
||||
// 登录页面
|
||||
{
|
||||
path: '/login',
|
||||
name: '登录',
|
||||
component: () => import('../views/auth/Login.vue'),
|
||||
meta: {
|
||||
requiresAuth: false
|
||||
}
|
||||
},
|
||||
// { path: '/home', name: '首页', component: () => import('../views/home/Home.vue') },
|
||||
{
|
||||
path: '/content-style',
|
||||
@@ -53,7 +62,10 @@ const routes = [
|
||||
children: [
|
||||
{ path: '', redirect: '/system/style-settings' },
|
||||
{ path: 'style-settings', name: '风格设置', component: () => import('../views/system/StyleSettings.vue') },
|
||||
]
|
||||
],
|
||||
meta: {
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{ path: '/realtime-hot', name: '实时热点推送', component: () => import('../views/realtime/RealtimeHot.vue') },
|
||||
{ path: '/capcut-import', name: '剪映导入', component: () => import('../views/capcut/CapcutImport.vue') },
|
||||
@@ -67,57 +79,46 @@ const router = createRouter({
|
||||
routes,
|
||||
})
|
||||
|
||||
// 用户信息初始化标志(确保只初始化一次)
|
||||
let userInfoInitialized = false
|
||||
|
||||
/**
|
||||
* 路由导航守卫:初始化用户信息 + 登录验证
|
||||
* 在首次路由跳转时,如果已登录(有 token),则获取用户信息
|
||||
* 如果未登录访问系统相关路由,则重定向到首页
|
||||
*/
|
||||
// 路由守卫
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 只在首次路由跳转时初始化用户信息
|
||||
if (!userInfoInitialized) {
|
||||
userInfoInitialized = true
|
||||
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
try {
|
||||
// 如果 store 中已标记为登录,则获取用户信息
|
||||
if (userStore.isLoggedIn) {
|
||||
userStore.fetchUserInfo()
|
||||
} else {
|
||||
// 如果有 token 但 store 中未标记为登录,可能是刷新页面
|
||||
// 先标记为已登录,然后获取用户信息
|
||||
userStore.isLoggedIn = true
|
||||
userStore.fetchUserInfo()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('初始化用户信息失败:', error)
|
||||
// 不阻止路由跳转,继续执行
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查访问系统相关路由时是否已登录
|
||||
if (to.path.startsWith('/system')) {
|
||||
// 等待 store 从本地存储恢复完成(最多等待500ms)
|
||||
|
||||
// 等待 Pinia store 恢复完成(最多等待 500ms)
|
||||
if (to.meta.requiresAuth) {
|
||||
let waitCount = 0
|
||||
while (!userStore.isHydrated && waitCount < 50) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
waitCount++
|
||||
}
|
||||
|
||||
// 如果未登录,重定向到首页
|
||||
if (!userStore.isLoggedIn) {
|
||||
next({ path: '/content-style/benchmark', replace: true })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 继续路由跳转
|
||||
|
||||
// 检查是否已登录(通过 token 是否有效)
|
||||
const isAuthenticated = tokenManager.isLoggedIn()
|
||||
|
||||
// 路由访问控制
|
||||
if (to.meta.requiresAuth && !isAuthenticated) {
|
||||
// 需要认证但未登录,跳转到登录页并记录当前路径
|
||||
next({
|
||||
path: '/login',
|
||||
query: { redirect: to.fullPath }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 已登录用户访问登录页,重定向到首页
|
||||
if (to.path === '/login' && isAuthenticated) {
|
||||
next({ path: '/content-style/benchmark', replace: true })
|
||||
return
|
||||
}
|
||||
|
||||
// 首次访问且已登录时,同步用户信息到 store
|
||||
if (isAuthenticated && !userStore.isLoggedIn) {
|
||||
userStore.isLoggedIn = true
|
||||
userStore.fetchUserInfo().catch(error => {
|
||||
console.error('初始化用户信息失败:', error)
|
||||
})
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
|
||||
232
frontend/app/web-gold/src/services/AuthService.js
Normal file
232
frontend/app/web-gold/src/services/AuthService.js
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* 认证服务 - 封装所有认证相关业务逻辑
|
||||
* 提供登录、登出、刷新token等方法
|
||||
* 函数式实现,无 this 绑定问题
|
||||
*/
|
||||
|
||||
import { clientAxios } from '@gold/api/axios/client'
|
||||
import tokenManager from '@gold/utils/token-manager'
|
||||
|
||||
// 刷新token的锁,避免并发刷新
|
||||
let isRefreshing = false
|
||||
let refreshPromise = null
|
||||
|
||||
/**
|
||||
* 手机号+验证码登录
|
||||
*/
|
||||
async function loginBySms(mobile, code) {
|
||||
try {
|
||||
const { data } = await clientAxios.post('/api/member/auth/sms-login', { mobile, code })
|
||||
const tokenInfo = data || {}
|
||||
|
||||
// 保存token
|
||||
tokenManager.setTokens({
|
||||
accessToken: tokenInfo.accessToken || '',
|
||||
refreshToken: tokenInfo.refreshToken || '',
|
||||
expiresIn: tokenInfo.expiresTime ? Math.floor(tokenInfo.expiresTime / 1000) : 7200,
|
||||
tokenType: 'Bearer'
|
||||
})
|
||||
|
||||
return tokenInfo
|
||||
} catch (error) {
|
||||
console.error('短信登录失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号密码登录
|
||||
*/
|
||||
async function loginByPassword(mobile, password) {
|
||||
try {
|
||||
const { data } = await clientAxios.post('/api/member/auth/login', { mobile, password })
|
||||
const tokenInfo = data || {}
|
||||
|
||||
tokenManager.setTokens({
|
||||
accessToken: tokenInfo.accessToken || '',
|
||||
refreshToken: tokenInfo.refreshToken || '',
|
||||
expiresIn: tokenInfo.expiresTime ? Math.floor(tokenInfo.expiresTime / 1000) : 7200,
|
||||
tokenType: 'Bearer'
|
||||
})
|
||||
|
||||
return tokenInfo
|
||||
} catch (error) {
|
||||
console.error('密码登录失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行实际刷新token的逻辑
|
||||
*/
|
||||
async function doRefreshToken(refreshToken) {
|
||||
const { data } = await clientAxios.post('/api/member/auth/refresh-token', null, {
|
||||
params: { refreshToken }
|
||||
})
|
||||
const tokenInfo = data || {}
|
||||
|
||||
tokenManager.setTokens({
|
||||
accessToken: tokenInfo.accessToken || '',
|
||||
refreshToken: tokenInfo.refreshToken || '',
|
||||
expiresIn: tokenInfo.expiresTime ? Math.floor(tokenInfo.expiresTime / 1000) : 7200,
|
||||
tokenType: 'Bearer'
|
||||
})
|
||||
|
||||
return tokenInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新token
|
||||
* 使用锁机制防止并发刷新
|
||||
*/
|
||||
async function refreshToken() {
|
||||
// 如果正在刷新,返回已有的刷新Promise
|
||||
if (isRefreshing && refreshPromise) {
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
const refreshTokenValue = tokenManager.getRefreshToken()
|
||||
if (!refreshTokenValue) {
|
||||
throw new Error('缺少refreshToken,无法刷新')
|
||||
}
|
||||
|
||||
try {
|
||||
isRefreshing = true
|
||||
refreshPromise = doRefreshToken(refreshTokenValue)
|
||||
const result = await refreshPromise
|
||||
return result
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
refreshPromise = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送短信验证码
|
||||
*/
|
||||
async function sendSmsCode(mobile, scene = 1) {
|
||||
const { data } = await clientAxios.post('/api/member/auth/send-sms-code', {
|
||||
mobile,
|
||||
scene
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出
|
||||
*/
|
||||
async function logout() {
|
||||
try {
|
||||
// 清除token
|
||||
tokenManager.clearTokens()
|
||||
|
||||
// 跳转到登录页
|
||||
window.location.href = '/login'
|
||||
} catch (error) {
|
||||
console.error('登出失败:', error)
|
||||
// 即使出错也要清除token
|
||||
tokenManager.clearTokens()
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已登录
|
||||
*/
|
||||
function isAuthenticated() {
|
||||
return tokenManager.isLoggedIn()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
*/
|
||||
async function getCurrentUser() {
|
||||
if (!isAuthenticated()) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await clientAxios.get('/api/member/user/get')
|
||||
return data || null
|
||||
} catch (error) {
|
||||
console.error('获取用户信息失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理401/403错误
|
||||
* @param {Error} error - axios错误对象
|
||||
* @param {Function} onAuthFailed - 认证失败回调
|
||||
* @param {Object} options - 额外选项
|
||||
* @param {AxiosInstance} options.httpClient - 可选的HTTP客户端实例,用于重新发起请求
|
||||
*/
|
||||
async function handleAuthError(error, onAuthFailed, options = {}) {
|
||||
const status = error?.response?.status
|
||||
const data = error?.response?.data
|
||||
const code = data?.code
|
||||
const originalRequest = error.config
|
||||
const { httpClient } = options
|
||||
|
||||
// 统一检查是否为 401 (token无效或过期)
|
||||
const is401 = (code === 401) || (status === 401)
|
||||
|
||||
// 统一检查是否为 403 (没有权限)
|
||||
const is403 = (code === 403) || (status === 403)
|
||||
|
||||
// 处理 401 错误:尝试刷新 token
|
||||
if (is401) {
|
||||
try {
|
||||
// 刷新 token
|
||||
await refreshToken()
|
||||
|
||||
// 刷新成功,重新发起原请求(如果提供了 httpClient)
|
||||
if (originalRequest && httpClient) {
|
||||
originalRequest.headers['Authorization'] = tokenManager.getAuthHeader()
|
||||
return httpClient(originalRequest)
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (refreshError) {
|
||||
console.error('刷新token失败:', refreshError)
|
||||
if (typeof onAuthFailed === 'function') {
|
||||
onAuthFailed()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 403 错误:直接调用回调
|
||||
if (is403) {
|
||||
if (typeof onAuthFailed === 'function') {
|
||||
onAuthFailed()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
// 其他错误直接抛出
|
||||
throw error
|
||||
}
|
||||
|
||||
export default {
|
||||
loginBySms,
|
||||
loginByPassword,
|
||||
refreshToken,
|
||||
sendSmsCode,
|
||||
logout,
|
||||
isAuthenticated,
|
||||
getCurrentUser,
|
||||
handleAuthError
|
||||
}
|
||||
|
||||
// 导出便捷方法(解构赋值,不会有 this 问题)
|
||||
export {
|
||||
loginBySms,
|
||||
loginByPassword,
|
||||
refreshToken,
|
||||
sendSmsCode,
|
||||
logout,
|
||||
isAuthenticated,
|
||||
getCurrentUser,
|
||||
handleAuthError
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { getJSON, setJSON, remove } from '@/utils/storage'
|
||||
import { clearAllTokens } from '@gold/utils/token-manager'
|
||||
// 直接使用实例(最简单、最可靠)
|
||||
import tokenManager from '@gold/utils/token-manager'
|
||||
|
||||
// 本地持久化的 key
|
||||
const STORAGE_KEY = 'user_store_v1'
|
||||
@@ -115,12 +116,13 @@ export const useUserStore = defineStore('user', () => {
|
||||
try {
|
||||
// 使用公共 hook 获取用户信息
|
||||
const { getUserInfo } = await import('@gold/hooks/web/useUserInfo')
|
||||
const { getToken } = await import('@gold/utils/token-manager')
|
||||
|
||||
// 导入 tokenManager 获取 token
|
||||
const tokenManager = (await import('@gold/utils/token-manager')).default
|
||||
|
||||
const userInfo = await getUserInfo({
|
||||
getToken,
|
||||
getToken: () => tokenManager.getAccessToken()
|
||||
})
|
||||
|
||||
|
||||
if (userInfo) {
|
||||
// 更新用户信息
|
||||
userId.value = String(userInfo.id || userInfo.userId || userId.value)
|
||||
@@ -142,11 +144,11 @@ export const useUserStore = defineStore('user', () => {
|
||||
async function logout() {
|
||||
// 1. 清空所有 token
|
||||
try {
|
||||
clearAllTokens()
|
||||
tokenManager.clearTokens()
|
||||
} catch (e) {
|
||||
console.error('清空 token 失败:', e)
|
||||
}
|
||||
|
||||
|
||||
// 2. 清空用户信息
|
||||
isLoggedIn.value = false
|
||||
userId.value = ''
|
||||
@@ -159,7 +161,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
balance.value = 0
|
||||
vipLevel.value = 0
|
||||
credits.value = 0
|
||||
|
||||
|
||||
// 3. 删除本地存储的用户数据
|
||||
await remove(STORAGE_KEY)
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* Token 管理兼容层
|
||||
*
|
||||
* @deprecated 此文件已废弃,请直接使用 @gold/utils/token-manager
|
||||
*
|
||||
* 为了保持向后兼容,此文件作为兼容层,所有函数都转发到 token-manager.js
|
||||
* 新代码请直接使用 @gold/utils/token-manager 中的函数
|
||||
*/
|
||||
|
||||
import {
|
||||
getToken as getTokenFromManager,
|
||||
setToken as setTokenFromManager,
|
||||
getRefreshToken as getRefreshTokenFromManager,
|
||||
getAccessToken as getAccessTokenFromManager,
|
||||
removeToken as removeTokenFromManager,
|
||||
clearAllTokens,
|
||||
getAuthHeader
|
||||
} from '@gold/utils/token-manager'
|
||||
|
||||
/**
|
||||
* 获取访问令牌
|
||||
* @deprecated 使用 @gold/utils/token-manager 中的 getToken() 或 getAccessToken()
|
||||
*/
|
||||
export const getAccessToken = getAccessTokenFromManager
|
||||
|
||||
/**
|
||||
* 获取刷新令牌
|
||||
* @deprecated 使用 @gold/utils/token-manager 中的 getRefreshToken()
|
||||
*/
|
||||
export const getRefreshToken = getRefreshTokenFromManager
|
||||
|
||||
/**
|
||||
* 设置令牌
|
||||
* @deprecated 使用 @gold/utils/token-manager 中的 setToken()
|
||||
*/
|
||||
export const setToken = setTokenFromManager
|
||||
|
||||
/**
|
||||
* 删除令牌
|
||||
* @deprecated 使用 @gold/utils/token-manager 中的 clearAllTokens() 或 removeToken()
|
||||
*/
|
||||
export const removeToken = removeTokenFromManager
|
||||
|
||||
/**
|
||||
* 格式化token(jwt格式)
|
||||
* @param {string} token - token 字符串
|
||||
* @returns {string} Bearer token
|
||||
*/
|
||||
export const formatToken = (token) => {
|
||||
return 'Bearer ' + token
|
||||
}
|
||||
|
||||
// 导出 token-manager 的其他函数,方便迁移
|
||||
export { clearAllTokens, getAuthHeader }
|
||||
@@ -252,13 +252,13 @@ export async function streamChat(options = {}) {
|
||||
if (typewriter) typewriter.flush()
|
||||
const errorMsg = err?.message || '网络请求失败'
|
||||
console.error('SSE请求错误:', err)
|
||||
|
||||
|
||||
if (typeof onError === 'function') {
|
||||
onError(err)
|
||||
} else {
|
||||
message.error(errorMsg)
|
||||
}
|
||||
|
||||
|
||||
reject(new Error(errorMsg))
|
||||
}
|
||||
},
|
||||
|
||||
488
frontend/app/web-gold/src/views/auth/Login.vue
Normal file
488
frontend/app/web-gold/src/views/auth/Login.vue
Normal file
@@ -0,0 +1,488 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<!-- 背景动画 -->
|
||||
<div class="bg-animation">
|
||||
<div class="floating-shapes">
|
||||
<div class="shape shape-1"></div>
|
||||
<div class="shape shape-2"></div>
|
||||
<div class="shape shape-3"></div>
|
||||
<div class="shape shape-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 登录表单卡片 -->
|
||||
<div class="login-card">
|
||||
<div class="card-header">
|
||||
<h1 class="title">欢迎回来</h1>
|
||||
<p class="subtitle">登录您的账户以继续</p>
|
||||
</div>
|
||||
|
||||
<a-tabs v-model:activeKey="activeTab" centered class="login-tabs">
|
||||
<a-tab-pane key="sms" tab="短信登录">
|
||||
<a-form
|
||||
:model="smsForm"
|
||||
:rules="smsRules"
|
||||
ref="smsFormRef"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item name="mobile" label="手机号">
|
||||
<a-input
|
||||
v-model:value="smsForm.mobile"
|
||||
size="large"
|
||||
placeholder="请输入手机号"
|
||||
:maxlength="11"
|
||||
>
|
||||
<template #prefix>
|
||||
<PhoneOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="code" label="验证码">
|
||||
<div class="code-input">
|
||||
<a-input
|
||||
v-model:value="smsForm.code"
|
||||
size="large"
|
||||
placeholder="请输入验证码"
|
||||
:maxlength="6"
|
||||
>
|
||||
<template #prefix>
|
||||
<SafetyOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
<a-button
|
||||
type="primary"
|
||||
:disabled="codeCountdown > 0"
|
||||
:loading="sendingCode"
|
||||
@click="sendSmsCode"
|
||||
class="send-code-btn"
|
||||
>
|
||||
{{ codeCountdown > 0 ? `${codeCountdown}s后重发` : '发送验证码' }}
|
||||
</a-button>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
size="large"
|
||||
block
|
||||
:loading="loggingIn"
|
||||
@click="handleSmsLogin"
|
||||
class="login-btn"
|
||||
>
|
||||
登录
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="password" tab="密码登录">
|
||||
<a-form
|
||||
:model="passwordForm"
|
||||
:rules="passwordRules"
|
||||
ref="passwordFormRef"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item name="mobile" label="手机号">
|
||||
<a-input
|
||||
v-model:value="passwordForm.mobile"
|
||||
size="large"
|
||||
placeholder="请输入手机号"
|
||||
:maxlength="11"
|
||||
>
|
||||
<template #prefix>
|
||||
<PhoneOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="password" label="密码">
|
||||
<a-input-password
|
||||
v-model:value="passwordForm.password"
|
||||
size="large"
|
||||
placeholder="请输入密码"
|
||||
@pressEnter="handlePasswordLogin"
|
||||
>
|
||||
<template #prefix>
|
||||
<LockOutlined />
|
||||
</template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
size="large"
|
||||
block
|
||||
:loading="loggingIn"
|
||||
@click="handlePasswordLogin"
|
||||
class="login-btn"
|
||||
>
|
||||
登录
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
|
||||
<div class="card-footer">
|
||||
<p class="tip">登录即表示您同意我们的服务条款和隐私政策</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
PhoneOutlined,
|
||||
SafetyOutlined,
|
||||
LockOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import authService from '@/services/AuthService'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 标签页
|
||||
const activeTab = ref('sms')
|
||||
|
||||
// 短信登录表单
|
||||
const smsForm = reactive({
|
||||
mobile: '',
|
||||
code: ''
|
||||
})
|
||||
const smsFormRef = ref()
|
||||
|
||||
// 密码登录表单
|
||||
const passwordForm = reactive({
|
||||
mobile: '',
|
||||
password: ''
|
||||
})
|
||||
const passwordFormRef = ref()
|
||||
|
||||
// 状态
|
||||
const sendingCode = ref(false)
|
||||
const loggingIn = ref(false)
|
||||
const codeCountdown = ref(0)
|
||||
|
||||
// 验证码倒计时
|
||||
let countdownTimer = null
|
||||
|
||||
// 表单验证规则
|
||||
const smsRules = {
|
||||
mobile: [
|
||||
{ required: true, message: '请输入手机号', trigger: 'blur' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
|
||||
],
|
||||
code: [
|
||||
{ required: true, message: '请输入验证码', trigger: 'blur' },
|
||||
{ len: 6, message: '验证码为6位数字', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
const passwordRules = {
|
||||
mobile: [
|
||||
{ required: true, message: '请输入手机号', trigger: 'blur' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 4, max: 16, message: '密码长度为4-16位', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 发送验证码
|
||||
async function sendSmsCode() {
|
||||
try {
|
||||
await smsFormRef.value.validateFields(['mobile'])
|
||||
sendingCode.value = true
|
||||
|
||||
await authService.sendSmsCode(smsForm.mobile, 1)
|
||||
|
||||
message.success('验证码已发送')
|
||||
|
||||
// 开始倒计时
|
||||
codeCountdown.value = 60
|
||||
countdownTimer = setInterval(() => {
|
||||
codeCountdown.value--
|
||||
if (codeCountdown.value <= 0) {
|
||||
clearInterval(countdownTimer)
|
||||
}
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
if (error?.response?.data?.message) {
|
||||
message.error(error.response.data.message)
|
||||
} else if (error.message) {
|
||||
message.error(error.message)
|
||||
} else {
|
||||
message.error('发送验证码失败')
|
||||
}
|
||||
} finally {
|
||||
sendingCode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 短信登录
|
||||
async function handleSmsLogin() {
|
||||
try {
|
||||
await smsFormRef.value.validateFields()
|
||||
loggingIn.value = true
|
||||
|
||||
const result = await authService.loginBySms(smsForm.mobile, smsForm.code)
|
||||
|
||||
message.success('登录成功')
|
||||
|
||||
// 获取redirect参数,跳转到之前的页面或默认首页
|
||||
const redirect = router.currentRoute.value.query.redirect
|
||||
setTimeout(() => {
|
||||
router.push(redirect || '/content-style/benchmark')
|
||||
}, 500)
|
||||
} catch (error) {
|
||||
if (error?.response?.data?.message) {
|
||||
message.error(error.response.data.message)
|
||||
} else {
|
||||
message.error('登录失败,请检查输入信息')
|
||||
}
|
||||
} finally {
|
||||
loggingIn.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 密码登录
|
||||
async function handlePasswordLogin() {
|
||||
try {
|
||||
await passwordFormRef.value.validateFields()
|
||||
loggingIn.value = true
|
||||
|
||||
await authService.loginByPassword(passwordForm.mobile, passwordForm.password)
|
||||
|
||||
message.success('登录成功')
|
||||
|
||||
// 获取redirect参数,跳转到之前的页面或默认首页
|
||||
const redirect = router.currentRoute.value.query.redirect
|
||||
setTimeout(() => {
|
||||
router.push(redirect || '/content-style/benchmark')
|
||||
}, 500)
|
||||
} catch (error) {
|
||||
if (error?.response?.data?.message) {
|
||||
message.error(error.response.data.message)
|
||||
} else {
|
||||
message.error('登录失败,请检查输入信息')
|
||||
}
|
||||
} finally {
|
||||
loggingIn.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 背景动画 */
|
||||
.bg-animation {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.floating-shapes {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.shape {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(40px);
|
||||
opacity: 0.3;
|
||||
animation: float 20s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.shape-1 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: #ffffff;
|
||||
top: -100px;
|
||||
left: -100px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.shape-2 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: #ffd700;
|
||||
top: 50%;
|
||||
right: -150px;
|
||||
animation-delay: -5s;
|
||||
}
|
||||
|
||||
.shape-3 {
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
background: #ff6b9d;
|
||||
bottom: -80px;
|
||||
left: 30%;
|
||||
animation-delay: -10s;
|
||||
}
|
||||
|
||||
.shape-4 {
|
||||
width: 350px;
|
||||
height: 350px;
|
||||
background: #00f2fe;
|
||||
top: 20%;
|
||||
left: 10%;
|
||||
animation-delay: -15s;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translate(0, 0) rotate(0deg);
|
||||
}
|
||||
33% {
|
||||
transform: translate(100px, -100px) rotate(120deg);
|
||||
}
|
||||
66% {
|
||||
transform: translate(-80px, 80px) rotate(240deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 登录卡片 */
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 20px;
|
||||
padding: 50px 40px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 标签页样式 */
|
||||
.login-tabs {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.login-tabs :deep(.ant-tabs-nav) {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.login-tabs :deep(.ant-tabs-tab) {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
padding: 12px 20px;
|
||||
}
|
||||
|
||||
.login-tabs :deep(.ant-tabs-tab-active) {
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.login-tabs :deep(.ant-tabs-ink-bar) {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
/* 表单样式 */
|
||||
:deep(.ant-form-item-label > label) {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
:deep(.ant-input-affix-wrapper) {
|
||||
border-radius: 10px;
|
||||
border: 2px solid #e8e8e8;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
:deep(.ant-input-affix-wrapper:hover),
|
||||
:deep(.ant-input-affix-wrapper-focused) {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
:deep(.ant-input) {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* 验证码输入组 */
|
||||
.code-input {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.send-code-btn {
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 登录按钮 */
|
||||
.login-btn {
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
|
||||
}
|
||||
|
||||
.login-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 底部提示 */
|
||||
.card-footer {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.tip {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -10,6 +10,7 @@ import { UserPromptApi } from '@/api/userPrompt'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import GradientButton from '@/components/GradientButton.vue'
|
||||
import { setJSON, getJSON } from '@/utils/storage'
|
||||
import authService from '@/services/AuthService'
|
||||
|
||||
const promptStore = usePromptStore()
|
||||
const userStore = useUserStore()
|
||||
@@ -379,6 +380,20 @@ async function generateCopywriting() {
|
||||
if (!isResolved) {
|
||||
errorOccurred = true
|
||||
ctrl.abort()
|
||||
|
||||
// 尝试解析错误中的状态码和业务码
|
||||
const status = err?.response?.status
|
||||
const data = err?.response?.data
|
||||
|
||||
// 处理 401/403 认证错误
|
||||
if (status === 401 || status === 403 ||
|
||||
(data && typeof data.code === 'number' && (data.code === 401 || data.code === 403))) {
|
||||
authService.handleAuthError(err, () => {
|
||||
window.location.href = '/login'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const errorMsg = err?.message || '网络请求失败'
|
||||
console.error('SSE请求错误:', err)
|
||||
message.error(errorMsg)
|
||||
|
||||
@@ -7,6 +7,7 @@ import { UserPromptApi } from '@/api/userPrompt'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import GradientButton from '@/components/GradientButton.vue'
|
||||
import { getVoiceText } from '@gold/hooks/web/useVoiceText'
|
||||
import authService from '@/services/AuthService'
|
||||
|
||||
defineOptions({ name: 'ForecastView' })
|
||||
|
||||
@@ -272,6 +273,20 @@ async function handleGenerate() {
|
||||
errorOccurred = true
|
||||
isResolved = true
|
||||
ctrl.abort()
|
||||
|
||||
// 尝试解析错误中的状态码和业务码
|
||||
const status = err?.response?.status
|
||||
const data = err?.response?.data
|
||||
|
||||
// 处理 401/403 认证错误
|
||||
if (status === 401 || status === 403 ||
|
||||
(data && typeof data.code === 'number' && (data.code === 401 || data.code === 403))) {
|
||||
authService.handleAuthError(err, () => {
|
||||
window.location.href = '/login'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const errorMsg = err?.message || '网络请求失败'
|
||||
console.error('SSE请求错误:', err)
|
||||
message.error(errorMsg)
|
||||
|
||||
@@ -31,7 +31,7 @@ export const API_BASE = {
|
||||
// 具体模块路径
|
||||
APP_AI: `${BASE_URL}/api/ai`,
|
||||
APP_MEMBER: `${BASE_URL}/app-api/member`,
|
||||
|
||||
|
||||
// 特殊路径
|
||||
TIKHUB_APP: `${BASE_URL}/api/tikHup`,
|
||||
AI_APP: `${BASE_URL}/api/ai`,
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
# 公共 Hooks 模块
|
||||
|
||||
此目录包含可在 monorepo 各个应用中复用的 Vue Composition API Hooks。
|
||||
|
||||
## 📁 目录结构
|
||||
|
||||
```
|
||||
hooks/web/
|
||||
├── useCache.js # 缓存管理 Hook
|
||||
├── useUserInfo.js # 用户信息获取 Hook
|
||||
└── useVoiceText.ts # 语音文本处理 Hook(已抽离为公共模块)
|
||||
```
|
||||
|
||||
## 🚀 使用方式
|
||||
|
||||
### useUserInfo Hook
|
||||
|
||||
获取用户信息的公共 Hook,可在各个应用中复用。
|
||||
|
||||
#### 基础用法
|
||||
|
||||
```javascript
|
||||
import { useUserInfo } from '@gold/hooks/web/useUserInfo'
|
||||
import { getToken } from '@/utils/token-manager'
|
||||
|
||||
// 在组件中使用
|
||||
const { fetchUserInfo, loading, error, userInfo } = useUserInfo({
|
||||
getToken, // 传入获取 token 的函数
|
||||
})
|
||||
|
||||
// 获取用户信息
|
||||
await fetchUserInfo()
|
||||
```
|
||||
|
||||
#### 便捷函数
|
||||
|
||||
如果只需要获取一次用户信息,可以使用便捷函数:
|
||||
|
||||
```javascript
|
||||
import { getUserInfo } from '@gold/hooks/web/useUserInfo'
|
||||
import { getToken } from '@/utils/token-manager'
|
||||
|
||||
// 直接获取用户信息
|
||||
const userInfo = await getUserInfo({
|
||||
getToken,
|
||||
})
|
||||
```
|
||||
|
||||
#### 自定义配置
|
||||
|
||||
```javascript
|
||||
const { fetchUserInfo } = useUserInfo({
|
||||
baseUrl: '/custom-api/member', // 自定义 API 基础 URL
|
||||
getToken: () => {
|
||||
// 自定义获取 token 的逻辑
|
||||
return localStorage.getItem('token')
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## 📝 API 说明
|
||||
|
||||
### useUserInfo(options)
|
||||
|
||||
**参数:**
|
||||
- `options.baseUrl` (string, 可选): API 基础 URL,默认使用 `API_BASE.APP_MEMBER`
|
||||
- `options.getToken` (Function, 可选): 获取 token 的函数
|
||||
|
||||
**返回值:**
|
||||
- `fetchUserInfo()` (Function): 获取用户信息的异步函数
|
||||
- `loading` (Ref<boolean>): 加载状态
|
||||
- `error` (Ref<Error | null>): 错误信息
|
||||
- `userInfo` (Ref<Object | null>): 用户信息数据
|
||||
|
||||
### getUserInfo(options)
|
||||
|
||||
便捷函数,直接返回用户信息对象。
|
||||
|
||||
**参数:** 同 `useUserInfo`
|
||||
|
||||
**返回值:** `Promise<Object>` 用户信息对象
|
||||
|
||||
## 🔄 在 Store 中使用
|
||||
|
||||
```javascript
|
||||
import { defineStore } from 'pinia'
|
||||
import { getUserInfo } from '@gold/hooks/web/useUserInfo'
|
||||
import { getToken } from '@/utils/token-manager'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const userInfo = ref(null)
|
||||
|
||||
async function fetchUserInfo() {
|
||||
try {
|
||||
const data = await getUserInfo({ getToken })
|
||||
userInfo.value = data
|
||||
} catch (error) {
|
||||
console.error('获取用户信息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return { userInfo, fetchUserInfo }
|
||||
})
|
||||
```
|
||||
|
||||
### useVoiceText Hook
|
||||
|
||||
语音文本转换 Hook,将音频文件转换为文本转录。
|
||||
|
||||
#### 使用方式
|
||||
|
||||
```javascript
|
||||
import useVoiceText from '@gold/hooks/web/useVoiceText'
|
||||
import type { AudioItem } from '@gold/config/types'
|
||||
|
||||
// 在组件中使用(无需任何初始化)
|
||||
const { getVoiceText } = useVoiceText()
|
||||
|
||||
const audioList: AudioItem[] = [
|
||||
{ audio_url: 'https://example.com/audio.mp3' }
|
||||
]
|
||||
|
||||
const transcriptions = await getVoiceText(audioList)
|
||||
// transcriptions: [{ key: 'url', value: 'transcribed text', audio_url: '...' }]
|
||||
```
|
||||
|
||||
#### 说明
|
||||
|
||||
`useVoiceText` Hook 直接使用 mono 级别的 `TikHubService`,无需任何初始化或配置。
|
||||
所有 API 服务都在 `@gold/api/services` 中统一管理,开箱即用。
|
||||
|
||||
#### 类型定义
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
AudioItem,
|
||||
TranscriptionResult
|
||||
} from '@gold/config/types'
|
||||
```
|
||||
|
||||
## 📦 依赖
|
||||
|
||||
- `vue`: Vue 3 Composition API
|
||||
- `axios`: HTTP 请求库(用于 useUserInfo)
|
||||
- `@gold/config/api`: 公共 API 配置
|
||||
- `@gold/api/services`: Mono 级别的 API 服务
|
||||
- `@gold/api/axios/client`: Mono 级别的 Axios 客户端
|
||||
- `@gold/config/types`: 公共类型定义
|
||||
|
||||
## 🔧 配置要求
|
||||
|
||||
确保在应用的 `vite.config.js` 中配置了 `@gold` 别名:
|
||||
|
||||
```javascript
|
||||
resolve: {
|
||||
alias: {
|
||||
'@gold': fileURLToPath(new URL('../../', import.meta.url))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -20,7 +20,7 @@ let getTokenFn = null
|
||||
* 设置获取 token 的函数
|
||||
* @param {Function} fn - 获取 token 的函数
|
||||
*/
|
||||
export function setTokenGetter(fn) {
|
||||
export function setTokensGetter(fn) {
|
||||
getTokenFn = fn
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export function useUserInfo(options = {}) {
|
||||
|
||||
// 如果传入了 getToken 函数,设置它
|
||||
if (options.getToken) {
|
||||
setTokenGetter(options.getToken)
|
||||
setTokensGetter(options.getToken)
|
||||
}
|
||||
|
||||
// 确定 API 基础路径
|
||||
@@ -140,6 +140,10 @@ export function useUserInfo(options = {}) {
|
||||
* @returns {Promise<Object>} 用户信息对象
|
||||
*/
|
||||
export async function getUserInfo(options = {}) {
|
||||
// 如果传入了 getToken 函数,设置到全局
|
||||
if (options.getToken) {
|
||||
setTokensGetter(options.getToken)
|
||||
}
|
||||
const { fetchUserInfo } = useUserInfo(options)
|
||||
return await fetchUserInfo()
|
||||
}
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
# 全局工具模块 (Monorepo Utils)
|
||||
|
||||
此目录包含可在 monorepo 各个应用中复用的全局工具函数。
|
||||
|
||||
## 📁 目录结构
|
||||
|
||||
```
|
||||
utils/
|
||||
├── token-manager.js # Token 统一管理工具
|
||||
└── README.md # 本文件
|
||||
```
|
||||
|
||||
## 🚀 使用方式
|
||||
|
||||
### Token 管理 (`token-manager.js`)
|
||||
|
||||
在应用中使用全局 token 管理工具:
|
||||
|
||||
```javascript
|
||||
// 导入 token 管理工具
|
||||
import {
|
||||
getToken,
|
||||
setToken,
|
||||
clearAllTokens,
|
||||
getAuthHeader
|
||||
} from '@gold/utils/token-manager'
|
||||
|
||||
// 获取 token
|
||||
const token = getToken()
|
||||
|
||||
// 设置 token
|
||||
setToken({
|
||||
accessToken: 'xxx',
|
||||
refreshToken: 'yyy'
|
||||
})
|
||||
|
||||
// 获取 Authorization Header
|
||||
const header = getAuthHeader() // Bearer xxx
|
||||
|
||||
// 清空所有 token
|
||||
clearAllTokens()
|
||||
```
|
||||
|
||||
## 📝 API 说明
|
||||
|
||||
### Token 管理函数
|
||||
|
||||
#### `getToken()`
|
||||
获取访问令牌(按优先级读取)
|
||||
|
||||
**返回值:** `string` - token 字符串或空字符串
|
||||
|
||||
#### `getAccessToken()`
|
||||
获取访问令牌(别名,兼容旧代码)
|
||||
|
||||
**返回值:** `string` - token 字符串或空字符串
|
||||
|
||||
#### `getRefreshToken()`
|
||||
获取刷新令牌
|
||||
|
||||
**返回值:** `string | null` - 刷新令牌或 null
|
||||
|
||||
#### `setToken(tokens)`
|
||||
设置访问令牌和刷新令牌
|
||||
|
||||
**参数:**
|
||||
- `tokens.accessToken` (string, 可选): 访问令牌
|
||||
- `tokens.refreshToken` (string, 可选): 刷新令牌
|
||||
|
||||
#### `clearAllTokens()`
|
||||
清除所有 token(包括 dev token、access token、refresh token)
|
||||
|
||||
#### `removeToken()`
|
||||
删除 token(别名,调用 `clearAllTokens()`)
|
||||
|
||||
#### `getAuthHeader()`
|
||||
获取完整的 Authorization Header 值
|
||||
|
||||
**返回值:** `string` - `Bearer token` 或空字符串
|
||||
|
||||
#### `setDevToken(token)`
|
||||
设置手动输入的 dev token(用于开发测试)
|
||||
|
||||
**参数:** `token` (string) - dev token
|
||||
|
||||
#### `getDevToken()`
|
||||
获取手动输入的 dev token
|
||||
|
||||
**返回值:** `string` - dev token 或空字符串
|
||||
|
||||
## 🔄 Token 存储优先级
|
||||
|
||||
1. **手动输入的 dev token** (sessionStorage) - 最高优先级
|
||||
2. **正式登录的 token** (wsCache/localStorage)
|
||||
3. **环境变量 VITE_DEV_TOKEN** - 兜底
|
||||
|
||||
## 📦 在 Vite 配置中使用
|
||||
|
||||
确保在 `vite.config.js` 中配置了 `@gold` 别名:
|
||||
|
||||
```javascript
|
||||
resolve: {
|
||||
alias: {
|
||||
'@gold': fileURLToPath(new URL('../../', import.meta.url))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,215 +1,232 @@
|
||||
import { useCache, CACHE_KEY, deleteTokenCache } from '../hooks/web/useCache'
|
||||
|
||||
/**
|
||||
* Token 统一管理模块(Monorepo 全局工具)
|
||||
*
|
||||
* 这是项目中唯一的 token 管理入口,所有 token 操作都应该通过此模块进行。
|
||||
*
|
||||
* Token 存储优先级(读取顺序):
|
||||
* 1. 手动输入的 dev token (sessionStorage)
|
||||
* 2. 正式登录的 token (wsCache/localStorage)
|
||||
* 3. 环境变量 VITE_DEV_TOKEN
|
||||
*
|
||||
* Token 存储位置:
|
||||
* - ACCESS_TOKEN/access_token: 访问令牌(wsCache/localStorage)
|
||||
* - REFRESH_TOKEN/refresh_token: 刷新令牌(wsCache/localStorage)
|
||||
* - DEV_MANUAL_TOKEN: 开发手动输入的token(sessionStorage)
|
||||
* Token 统一管理器
|
||||
*
|
||||
* 功能特性:
|
||||
* - ✅ 安全存储访问令牌、刷新令牌、过期时间等信息
|
||||
* - ✅ 自动检查令牌是否过期(支持提前刷新缓冲时间)
|
||||
* * ✅ 提供订阅者模式,监听令牌变化事件
|
||||
* ✅ 兼容旧版本 API,提供 getToken()、removeToken() 等方法
|
||||
*
|
||||
* 使用示例:
|
||||
* ```javascript
|
||||
* import { getAccessToken, setTokens, isLoggedIn } from '@gold/utils/token-manager'
|
||||
*
|
||||
* // 检查登录状态
|
||||
* if (isLoggedIn()) {
|
||||
* const token = getAccessToken()
|
||||
* }
|
||||
*
|
||||
* // 设置令牌
|
||||
* setTokens({
|
||||
* accessToken: 'eyJ...',
|
||||
* refreshToken: 'eyJ...',
|
||||
* expiresIn: 7200,
|
||||
* tokenType: 'Bearer'
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
|
||||
// ==================== 常量定义 ====================
|
||||
const DEV_MANUAL_TOKEN_KEY = 'DEV_MANUAL_TOKEN'
|
||||
|
||||
// Token 键名变体(支持大小写不同)
|
||||
// localStorage 中存储的键名常量
|
||||
const TOKEN_KEYS = {
|
||||
ACCESS: [CACHE_KEY.ACCESS_TOKEN, 'access_token'],
|
||||
REFRESH: [CACHE_KEY.REFRESH_TOKEN, 'refresh_token']
|
||||
ACCESS_TOKEN: 'access_token',
|
||||
REFRESH_TOKEN: 'refresh_token',
|
||||
EXPIRES_TIME: 'expires_time',
|
||||
TOKEN_TYPE: 'token_type',
|
||||
}
|
||||
|
||||
// ==================== 缓存实例管理 ====================
|
||||
let wsCache = null
|
||||
class TokenManager {
|
||||
constructor() {
|
||||
this.subscribers = [] // 订阅token变化的回调
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 wsCache 实例(延迟初始化,避免模块加载时出错)
|
||||
* @returns {Object} wsCache 实例
|
||||
*/
|
||||
function getCache() {
|
||||
if (!wsCache) {
|
||||
try {
|
||||
wsCache = useCache().wsCache
|
||||
} catch (e) {
|
||||
console.warn('初始化 wsCache 失败:', e)
|
||||
// 返回一个安全的空对象,避免后续调用出错
|
||||
wsCache = {
|
||||
get: () => null,
|
||||
set: () => {},
|
||||
delete: () => {}
|
||||
}
|
||||
/**
|
||||
* 获取访问令牌
|
||||
* @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} tokenInfo.tokenType - 令牌类型,默认为 'Bearer'
|
||||
*/
|
||||
setTokens(tokenInfo) {
|
||||
const {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn,
|
||||
tokenType = 'Bearer'
|
||||
} = tokenInfo
|
||||
|
||||
// 校验:必须提供访问令牌
|
||||
if (!accessToken) {
|
||||
console.error('[TokenManager] 设置令牌失败:缺少 accessToken')
|
||||
return
|
||||
}
|
||||
|
||||
// 将过期时间从秒转换为毫秒时间戳
|
||||
const expiresTime = expiresIn ? Date.now() + (expiresIn * 1000) : 0
|
||||
|
||||
// 存储到 localStorage
|
||||
localStorage.setItem(TOKEN_KEYS.ACCESS_TOKEN, accessToken)
|
||||
|
||||
if (refreshToken) {
|
||||
localStorage.setItem(TOKEN_KEYS.REFRESH_TOKEN, refreshToken)
|
||||
}
|
||||
|
||||
if (expiresTime) {
|
||||
localStorage.setItem(TOKEN_KEYS.EXPIRES_TIME, String(expiresTime))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
return wsCache
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取完整的 Authorization Header 值
|
||||
* @returns {string} Bearer token 或空字符串
|
||||
*/
|
||||
export function getAuthHeader() {
|
||||
const token = getToken()
|
||||
return token ? `Bearer ${token}` : ''
|
||||
}
|
||||
/**
|
||||
* 取消令牌变化事件订阅
|
||||
* @param {Function} callback - 要取消的回调函数
|
||||
*/
|
||||
unsubscribe(callback) {
|
||||
const index = this.subscribers.indexOf(callback)
|
||||
|
||||
/**
|
||||
* 获取 token
|
||||
* @returns {string} token 字符串
|
||||
*/
|
||||
export function getToken() {
|
||||
// 1. 优先使用手动输入的 dev token
|
||||
const manualToken = sessionStorage.getItem(DEV_MANUAL_TOKEN_KEY)
|
||||
if (manualToken) {
|
||||
return manualToken
|
||||
}
|
||||
|
||||
// 2. 使用正式登录的 token(从 wsCache 读取)
|
||||
try {
|
||||
const cache = getCache()
|
||||
// 尝试所有可能的键名变体
|
||||
for (const key of TOKEN_KEYS.ACCESS) {
|
||||
const accessToken = cache.get(key)
|
||||
if (accessToken) {
|
||||
return accessToken
|
||||
}
|
||||
// 如果找到回调函数,则从数组中移除
|
||||
if (index !== -1) {
|
||||
this.subscribers.splice(index, 1)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('获取 wsCache 失败:', e)
|
||||
}
|
||||
|
||||
// 3. 兜底:环境变量中的 token
|
||||
const envToken = import.meta?.env?.VITE_DEV_TOKEN
|
||||
if (envToken) {
|
||||
return envToken
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置手动输入的 dev token
|
||||
* @param {string} token
|
||||
*/
|
||||
export function setDevToken(token) {
|
||||
if (token) {
|
||||
sessionStorage.setItem(DEV_MANUAL_TOKEN_KEY, token)
|
||||
} else {
|
||||
sessionStorage.removeItem(DEV_MANUAL_TOKEN_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取手动输入的 dev token(用于显示)
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getDevToken() {
|
||||
return sessionStorage.getItem(DEV_MANUAL_TOKEN_KEY) || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置访问令牌和刷新令牌
|
||||
* @param {Object} tokens - token 对象
|
||||
* @param {string} tokens.accessToken - 访问令牌
|
||||
* @param {string} tokens.refreshToken - 刷新令牌
|
||||
*/
|
||||
export function setToken(tokens) {
|
||||
if (!tokens || (!tokens.accessToken && !tokens.refreshToken)) {
|
||||
console.warn('setToken: token 参数无效', tokens)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const cache = getCache()
|
||||
if (tokens.accessToken) {
|
||||
cache.set(CACHE_KEY.ACCESS_TOKEN, tokens.accessToken)
|
||||
}
|
||||
if (tokens.refreshToken) {
|
||||
cache.set(CACHE_KEY.REFRESH_TOKEN, tokens.refreshToken)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('设置 token 失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取刷新令牌
|
||||
* @returns {string|null} 刷新令牌或 null
|
||||
*/
|
||||
export function getRefreshToken() {
|
||||
try {
|
||||
const cache = getCache()
|
||||
// 尝试所有可能的键名变体
|
||||
for (const key of TOKEN_KEYS.REFRESH) {
|
||||
const token = cache.get(key)
|
||||
if (token) {
|
||||
return token
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('获取 refresh token 失败:', e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有 token
|
||||
* 清空所有可能的 token 存储位置,确保完全清除
|
||||
*/
|
||||
export function clearAllTokens() {
|
||||
try {
|
||||
// 1. 清空 sessionStorage 中的 dev token
|
||||
sessionStorage.removeItem(DEV_MANUAL_TOKEN_KEY)
|
||||
} catch (e) {
|
||||
console.warn('清除 sessionStorage token 失败:', e)
|
||||
}
|
||||
|
||||
try {
|
||||
// 2. 清空 wsCache (localStorage) 中的所有可能的 token 键名
|
||||
// 使用统一的 deleteTokenCache 函数,保持与 useCache 的一致性
|
||||
deleteTokenCache()
|
||||
} catch (e) {
|
||||
console.warn('清除 wsCache token 失败:', e)
|
||||
}
|
||||
|
||||
try {
|
||||
// 3. 清空 localStorage 中可能的 token(兜底处理)
|
||||
// 注意:wsCache 默认使用 localStorage,但为了确保清除,也直接操作 localStorage
|
||||
const localStorageKeys = [
|
||||
...TOKEN_KEYS.ACCESS,
|
||||
...TOKEN_KEYS.REFRESH,
|
||||
DEV_MANUAL_TOKEN_KEY
|
||||
]
|
||||
localStorageKeys.forEach(key => {
|
||||
/**
|
||||
* 通知所有订阅者令牌已发生变化
|
||||
* @param {string} type - 事件类型
|
||||
* @param {Object|null} tokenInfo - 令牌信息
|
||||
*/
|
||||
notifySubscribers(type, tokenInfo) {
|
||||
// 遍历所有订阅者并调用其回调函数
|
||||
this.subscribers.forEach(callback => {
|
||||
try {
|
||||
localStorage.removeItem(key)
|
||||
} catch (e) {
|
||||
// 忽略单个键删除失败
|
||||
callback(type, tokenInfo)
|
||||
} catch (error) {
|
||||
// 订阅者回调出错不应影响其他回调的执行
|
||||
console.error('[TokenManager] 订阅者回调执行失败:', error)
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('清除 localStorage token 失败:', e)
|
||||
}
|
||||
|
||||
/**
|
||||
* 【兼容方法】获取访问令牌
|
||||
* @deprecated 请使用 getAccessToken() 替代
|
||||
* @returns {string|null} 访问令牌
|
||||
*/
|
||||
getToken() {
|
||||
return this.getAccessToken()
|
||||
}
|
||||
|
||||
/**
|
||||
* 【兼容方法】移除所有令牌
|
||||
* @deprecated 请使用 clearTokens() 替代
|
||||
*/
|
||||
removeToken() {
|
||||
this.clearTokens()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 token(别名,兼容旧代码)
|
||||
* @deprecated 使用 clearAllTokens() 代替
|
||||
*/
|
||||
export function removeToken() {
|
||||
clearAllTokens()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取访问令牌(别名,兼容旧代码)
|
||||
* @returns {string} token 字符串
|
||||
*/
|
||||
export function getAccessToken() {
|
||||
return getToken()
|
||||
}
|
||||
// 创建 TokenManager 单例实例
|
||||
const tokenManager = new TokenManager()
|
||||
|
||||
// 最简单的导出方式:直接导出实例
|
||||
// 使用方法:import tokenManager from '@gold/utils/token-manager'
|
||||
// tokenManager.getAccessToken()
|
||||
export default tokenManager
|
||||
|
||||
Reference in New Issue
Block a user