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:
2025-11-25 00:58:51 +08:00
parent cea43dd635
commit fb6d18b4f5
24 changed files with 1148 additions and 1198 deletions

View File

@@ -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) {

View File

@@ -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 } });

View File

@@ -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 // 禁用自动重试

View File

@@ -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

View File

@@ -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 实例重新发起请求,
* 确保自定义拦截器被正确应用。
*/

View File

@@ -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({

View File

@@ -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>

View File

@@ -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()
})

View 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
}

View File

@@ -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)
}

View File

@@ -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
/**
* 格式化tokenjwt格式
* @param {string} token - token 字符串
* @returns {string} Bearer token
*/
export const formatToken = (token) => {
return 'Bearer ' + token
}
// 导出 token-manager 的其他函数,方便迁移
export { clearAllTokens, getAuthHeader }

View File

@@ -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))
}
},

View 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>

View File

@@ -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)

View File

@@ -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)