diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 3679acc..2c75068 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -6,11 +6,11 @@
diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts
index 8bec31d..ff6cbb7 100644
--- a/frontend/src/api/auth.ts
+++ b/frontend/src/api/auth.ts
@@ -6,9 +6,19 @@ export function login(username: string, password: string): Promise r.data)
}
-/** 刷新 Token */
-export function refreshToken(refreshToken: string): Promise> {
- return request.post('/auth/refresh', { refreshToken }).then(r => r.data)
+/** 刷新 Token(refresh token 通过 httpOnly Cookie 自动携带) */
+export function refreshToken(): Promise> {
+ return request.post('/auth/refresh').then(r => r.data)
+}
+
+/** 退出登录(清除服务端 refresh token cookie) */
+export function logout(): Promise> {
+ return request.post('/auth/logout').then(r => r.data)
+}
+
+/** 当前登录用户自助修改密码 */
+export function changePassword(oldPassword: string, newPassword: string): Promise> {
+ return request.put('/auth/password', { oldPassword, newPassword }).then(r => r.data)
}
/** 获取当前登录用户信息 */
diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts
index d38101d..49d938e 100644
--- a/frontend/src/api/request.ts
+++ b/frontend/src/api/request.ts
@@ -3,13 +3,15 @@
* 替代旧 api.js 中的 fetch 封装
*/
import axios from 'axios'
-import { getToken } from '@/utils/token'
+import { getToken, setToken, clearTokens } from '@/utils/token'
/** 同源部署,API 基址为空 */
const request = axios.create({
baseURL: '',
timeout: 30000,
headers: { 'Content-Type': 'application/json' },
+ // 允许跨域携带 Cookie(refresh token 使用 httpOnly Cookie)
+ withCredentials: true,
})
/** 请求拦截器:自动附加 Bearer Token */
@@ -22,18 +24,64 @@ request.interceptors.request.use((config) => {
})
/**
- * 响应拦截器:401 触发 auth:unauthorized 事件
- * 与旧版 app.js 中的 Token 刷新逻辑一致
+ * Token 刷新锁与等待队列:
+ * - 多个请求并发 401 时,只有第一个发起 /auth/refresh,其余挂起等待新 token 后重试。
+ * - 刷新成功后重试原请求,调用方无感知。
+ */
+let isRefreshing = false
+let pendingQueue: Array<(token: string) => void> = []
+
+/**
+ * 响应拦截器:401 自动刷新 access token 并重试原请求
+ * 注意:/auth/login 与 /auth/refresh 自身的 401 不做刷新处理,由调用方自行处理,避免递归。
*/
request.interceptors.response.use(
(response) => response,
- (error) => {
- if (error.response?.status === 401) {
- localStorage.removeItem('sb_token')
- localStorage.removeItem('sb_user')
- window.dispatchEvent(new Event('auth:unauthorized'))
+ async (error) => {
+ const { config, response } = error
+ const url: string = config?.url || ''
+
+ if (response?.status === 401 && !url.includes('/auth/login') && !url.includes('/auth/refresh')) {
+ // 已有刷新在进行中,挂起当前请求,等待新 token 后重试
+ if (isRefreshing) {
+ return new Promise((resolve, reject) => {
+ pendingQueue.push((token: string) => {
+ if (token) {
+ resolve(request(config))
+ } else {
+ reject(new Error(response?.data?.message || error.message || '网络错误'))
+ }
+ })
+ })
+ }
+
+ // 发起刷新(request 的 url 为 /auth/refresh,其 401 会被上方判断跳过,不会递归)
+ isRefreshing = true
+ try {
+ const res = await request.post('/auth/refresh')
+ const newToken = res.data?.data?.accessToken
+ if (newToken) {
+ setToken(newToken)
+ // 放行所有挂起请求(请求拦截器会自动附加新 token)
+ pendingQueue.forEach((cb) => cb(newToken))
+ pendingQueue = []
+ // 重试当前请求
+ return request(config)
+ }
+ throw new Error('刷新令牌失败')
+ } catch (refreshErr) {
+ // 刷新失败:清除本地凭证并触发全局登出
+ clearTokens()
+ pendingQueue.forEach((cb) => cb(''))
+ pendingQueue = []
+ window.dispatchEvent(new Event('auth:unauthorized'))
+ return Promise.reject(new Error(response?.data?.message || error.message || '网络错误'))
+ } finally {
+ isRefreshing = false
+ }
}
- const message = error.response?.data?.message || error.message || '网络错误'
+
+ const message = response?.data?.message || error.message || '网络错误'
return Promise.reject(new Error(message))
}
)
diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue
index 73e60f2..b7e250d 100644
--- a/frontend/src/layouts/MainLayout.vue
+++ b/frontend/src/layouts/MainLayout.vue
@@ -50,8 +50,8 @@ const mobileMenuOpen = ref(false)
// 提供给子组件全局弹窗能力
provide('openDocDetail', (docId: string) => dialog.openDetail(docId))
-function handleLogout() {
- auth.logout()
+async function handleLogout() {
+ await auth.doLogout()
toast('已退出登录', 'info')
router.push('/chat')
}
diff --git a/frontend/src/layouts/Topbar.vue b/frontend/src/layouts/Topbar.vue
index bfea6e0..ea173e8 100644
--- a/frontend/src/layouts/Topbar.vue
+++ b/frontend/src/layouts/Topbar.vue
@@ -13,21 +13,108 @@
{{ displayName }}
{{ r }}
+ 改密
退出
+
+
+
+
+
+
+
+
+
+
+
+
+
+