diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 9959a02..3e6f5f4 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,5 +1,4 @@ -import axios from 'axios' -import type { AxiosInstance } from 'axios' +import axios, { type AxiosInstance } from 'axios' const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000' @@ -18,30 +17,55 @@ api.interceptors.request.use((config) => { return config }) +// 防止并发刷新 +let refreshPromise: Promise | null = null + // 响应拦截器:处理 401 自动刷新 api.interceptors.response.use( (response) => response, async (error) => { const originalRequest = error.config + if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true + const refreshToken = localStorage.getItem('refresh_token') - if (refreshToken) { - try { - const { data } = await axios.post(`${API_BASE}/api/v1/auth/refresh`, { + if (!refreshToken) { + // 没有 refresh token,直接跳转登录 + localStorage.removeItem('access_token') + localStorage.removeItem('refresh_token') + window.location.href = '/login' + return Promise.reject(error) + } + + try { + // 如果已经有正在进行的刷新请求,复用它 + if (!refreshPromise) { + refreshPromise = axios.post(`${API_BASE}/api/v1/auth/refresh`, { refresh_token: refreshToken, }) - localStorage.setItem('access_token', data.access_token) - localStorage.setItem('refresh_token', data.refresh_token) - originalRequest.headers.Authorization = `Bearer ${data.access_token}` - return api(originalRequest) - } catch { - localStorage.removeItem('access_token') - localStorage.removeItem('refresh_token') - window.location.href = '/login' } + + const { data } = await refreshPromise + refreshPromise = null + + // 更新 token + localStorage.setItem('access_token', data.access_token) + localStorage.setItem('refresh_token', data.refresh_token) + + // 重试原请求 + originalRequest.headers.Authorization = `Bearer ${data.access_token}` + return api(originalRequest) + } catch (refreshError) { + refreshPromise = null + // 刷新失败,清除 token 并跳转登录 + localStorage.removeItem('access_token') + localStorage.removeItem('refresh_token') + window.location.href = '/login' + return Promise.reject(refreshError) } } + return Promise.reject(error) }, ) diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index a45f839..af71c82 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -135,7 +135,7 @@ const routes: RouteRecordRaw[] = [ { path: '/admin', component: () => import('@/layouts/AdminLayout.vue'), - meta: { requiresAuth: true }, + meta: { requiresAuth: true, requiresAdmin: true }, children: [ { path: '', redirect: '/admin/dashboard' }, { path: 'dashboard', name: 'AdminDashboard', component: () => import('@/views/admin/AdminDashboardView.vue') }, @@ -165,12 +165,18 @@ router.beforeEach(async (to, _from, next) => { } const needsAuth = to.matched.some((record) => record.meta.requiresAuth) + const needsAdmin = to.matched.some((record) => record.meta.requiresAdmin) if (needsAuth && !authStore.isAuthenticated) { next({ name: 'Login', query: { redirect: to.fullPath } }) return } + if (needsAdmin && !authStore.user?.is_admin && !localStorage.getItem('admin_token')) { + next({ name: 'ChatList' }) + return + } + next() }) diff --git a/frontend/src/stores/chat.ts b/frontend/src/stores/chat.ts index 146550f..acd3518 100644 --- a/frontend/src/stores/chat.ts +++ b/frontend/src/stores/chat.ts @@ -5,8 +5,11 @@ import { chatApi } from '@/api/chat' export const useChatStore = defineStore('chat', () => { const conversations = ref([]) const currentMessages = ref([]) + const pendingMessages = ref([]) // Optimistic messages const activeConversation = ref(null) const isLoading = ref(false) + const isLoadingMore = ref(false) + const hasMoreMessages = ref(true) async function fetchConversations() { isLoading.value = true @@ -19,19 +22,58 @@ export const useChatStore = defineStore('chat', () => { } async function fetchMessages(conversationId: string, before?: string) { - const { data } = await chatApi.getMessages(conversationId, before) if (before) { - currentMessages.value = [...data.messages, ...currentMessages.value] + // Loading more (pagination) + isLoadingMore.value = true + try { + const { data } = await chatApi.getMessages(conversationId, before) + if (data.messages.length === 0) { + hasMoreMessages.value = false + } else { + currentMessages.value = [...data.messages, ...currentMessages.value] + } + } finally { + isLoadingMore.value = false + } } else { + // Initial load + const { data } = await chatApi.getMessages(conversationId) currentMessages.value = data.messages + pendingMessages.value = [] + activeConversation.value = conversationId + hasMoreMessages.value = data.messages.length >= 50 + } + } + + function addPendingMessage(message: any) { + pendingMessages.value.push({ + ...message, + _pending: true, + _failed: false, + }) + } + + function markPendingFailed(content: string) { + const msg = pendingMessages.value.find( + (m: any) => m.content === content && m._pending + ) + if (msg) { + msg._failed = true + msg._pending = false } - activeConversation.value = conversationId } function addMessage(message: any) { currentMessages.value.push(message) - // 更新会话列表中的最后消息 - const conv = conversations.value.find((c) => c.id === message.conversation_id) + // Remove matching pending message + const idx = pendingMessages.value.findIndex( + (m: any) => m.content === message.content && m._pending + ) + if (idx !== -1) { + pendingMessages.value.splice(idx, 1) + } + // Update conversation list preview + const conv = conversations.value.find((c: any) => c.id === message.conversation_id) if (conv) { conv.last_message_preview = message.content?.substring(0, 50) conv.last_message_at = message.created_at @@ -39,11 +81,20 @@ export const useChatStore = defineStore('chat', () => { } async function markAsRead(conversationId: string, messageId: string) { - await chatApi.markAsRead(conversationId, messageId) + try { + await chatApi.markAsRead(conversationId, messageId) + } catch {} + } + + /** Get all messages for display (real + pending) */ + function allMessages() { + return [...currentMessages.value, ...pendingMessages.value] } return { - conversations, currentMessages, activeConversation, isLoading, - fetchConversations, fetchMessages, addMessage, markAsRead, + conversations, currentMessages, pendingMessages, + activeConversation, isLoading, isLoadingMore, hasMoreMessages, + fetchConversations, fetchMessages, addMessage, addPendingMessage, + markPendingFailed, markAsRead, allMessages, } }) diff --git a/frontend/src/views/chat/ChatRoomView.vue b/frontend/src/views/chat/ChatRoomView.vue index 536c9a3..1163443 100644 --- a/frontend/src/views/chat/ChatRoomView.vue +++ b/frontend/src/views/chat/ChatRoomView.vue @@ -4,9 +4,7 @@
- - {{ (conversationName || '群')[0] }} - + {{ (conversationName || '群')[0] }}
{{ conversationName }} @@ -17,14 +15,24 @@
-
-
+
+ +
+
+ 加载中... +
+
没有更多消息了
+ +
🌿

开始聊天吧

发送第一条消息

-
+
{{ formatTimeDivider(msg.created_at) }}