This commit is contained in:
2026-06-13 08:37:28 +08:00
parent ddc90e4b0d
commit ebcfb0c258
5 changed files with 204 additions and 51 deletions
+37 -13
View File
@@ -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<any> | 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)
},
)
+7 -1
View File
@@ -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()
})
+59 -8
View File
@@ -5,8 +5,11 @@ import { chatApi } from '@/api/chat'
export const useChatStore = defineStore('chat', () => {
const conversations = ref<any[]>([])
const currentMessages = ref<any[]>([])
const pendingMessages = ref<any[]>([]) // Optimistic messages
const activeConversation = ref<string | null>(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,
}
})
+92 -28
View File
@@ -4,9 +4,7 @@
<div class="chat-header">
<n-button v-if="uiStore.isMobile" quaternary circle @click="$router.push('/chat')" size="small"></n-button>
<div v-if="convDetail?.type === 'group'" class="header-avatar">
<n-avatar :size="36" round :style="{ background: 'var(--color-primary)' }">
{{ (conversationName || '群')[0] }}
</n-avatar>
<n-avatar :size="36" round :style="{ background: 'var(--color-primary)' }">{{ (conversationName || '群')[0] }}</n-avatar>
</div>
<div class="header-info">
<span class="room-name">{{ conversationName }}</span>
@@ -17,14 +15,24 @@
</div>
<div class="chat-body">
<div class="message-list" ref="messageListRef" @contextmenu.prevent>
<div v-if="chatStore.currentMessages.length === 0" class="no-messages">
<div class="message-list" ref="messageListRef" @contextmenu.prevent @scroll="onScroll">
<!-- 加载更多指示器 -->
<div v-if="chatStore.isLoadingMore" class="load-more-indicator">
<div class="loading-spinner-sm"></div>
<span>加载中...</span>
</div>
<div v-if="!chatStore.hasMoreMessages && chatStore.currentMessages.length > 0" class="no-more-msg">没有更多消息了</div>
<div v-if="allDisplayMessages.length === 0" class="no-messages">
<div class="no-msg-icon">🌿</div>
<p class="no-msg-title">开始聊天吧</p>
<p class="no-msg-hint">发送第一条消息</p>
</div>
<div v-for="(msg, idx) in chatStore.currentMessages" :key="msg.id"
class="message-row" :class="{ own: msg.sender_id === auth.user?.id, other: msg.sender_id !== auth.user?.id }">
<div v-for="(msg, idx) in allDisplayMessages" :key="msg.id || 'pending-' + idx"
class="message-row" :class="{
own: msg.sender_id === auth.user?.id, other: msg.sender_id !== auth.user?.id,
pending: msg._pending, failed: msg._failed,
}">
<div v-if="shouldShowTime(msg, idx)" class="time-divider">{{ formatTimeDivider(msg.created_at) }}</div>
<template v-if="msg.type === 'system'">
<div class="system-msg">{{ msg.content }}</div>
@@ -42,7 +50,9 @@
</div>
<div class="msg-meta">
<span class="msg-time">{{ formatTime(msg.created_at) }}</span>
<span v-if="msg.sender_id === auth.user?.id" class="msg-status">{{ msg._read ? '' : '' }}</span>
<span v-if="msg.sender_id === auth.user?.id && !msg._pending" class="msg-status"></span>
<span v-if="msg._pending && !msg._failed" class="msg-status sending">发送中</span>
<span v-if="msg._failed" class="msg-status failed"> 发送失败</span>
</div>
</div>
<div v-if="msg.sender_id === auth.user?.id" class="avatar-wrap">
@@ -55,7 +65,7 @@
<!-- 右键菜单 -->
<div v-if="ctxMenu.show" class="ctx-menu" :style="{ top: ctxMenu.y + 'px', left: ctxMenu.x + 'px' }">
<div class="ctx-item" @click="copyMsg">📋 复制</div>
<div v-if="ctxMenu.msg?.sender_id === auth.user?.id" class="ctx-item danger" @click="deleteMsg">🗑 删除</div>
<div v-if="ctxMenu.msg?.sender_id === auth.user?.id && !ctxMenu.msg?._pending" class="ctx-item danger" @click="deleteMsg">🗑 删除</div>
</div>
<GroupInfoPanel v-if="showGroupInfo && convDetail?.type === 'group'"
@@ -70,7 +80,6 @@
<span class="action-icon" @click="triggerImageUpload">🖼</span>
<input ref="imageInput" type="file" accept="image/*" style="display:none" @change="handleImageUpload" />
</div>
<!-- Emoji 面板 -->
<div v-if="showEmoji" class="emoji-panel">
<div v-for="e in emojis" :key="e" class="emoji-item" @click="insertEmoji(e)">{{ e }}</div>
</div>
@@ -82,7 +91,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick, watch, onUnmounted } from 'vue'
import { ref, reactive, computed, onMounted, nextTick, watch, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { useChatStore } from '@/stores/chat'
import { useAuthStore } from '@/stores/auth'
@@ -93,7 +102,7 @@ import api from '@/api/client'
import GroupInfoPanel from './GroupInfoPanel.vue'
import dayjs from 'dayjs'
const EMOJIS = ['😀','😃','😄','😁','😆','😅','🤣','😂','🙂','😊','😇','🥰','😍','🤩','😘','😗','😋','😛','😜','🤪','😝','🤑','🤗','🤭','🤫','🤔','😐','😑','😶','😏','😒','🙄','😬','😮','😯','😲','😳','🥺','😦','😧','😨','😰','😥','😢','😭','😱','😖','😣','😞','😓','😩','😫','🥱','😤','😡','🤬','😈','👿','💀','☠️','💩','🤡','👻','👽','🤖','😺','😸','😹','😻','😼','😽','🙀','😿','😾','👋','🤚','🖐️','','🖖','👌','🤌','🤏','✌️','🤞','🤟','🤘','🤙','👈','👉','👆','🖕','👇','☝️','👍','👎','','👊','🤛','🤜','👏','🙌','👐','🤲','🤝','🙏','💪','🦾','❤️','🧡','💛','💚','💙','💜','🖤','🤍','🤎','💔','❣️','💕','💞','💓','💗','💖','💘','💝','💟','','','☪️','🕉','☸️','✡️','🔯','🕎','☯️','🛐','','','','','','','','','','','','','','🆔','⚛️','🉑','☢️','☣️','📴','📳','🈶','🈚','🈸','🈺','🈷️','✴️','🆚','💮','🉐','㊙️','㊗️','🈴','🈵','🈹','🈲','🅰','🅱','🆎','🆑','🅾','🆘','','📛','🚫','💯','💢','♨️','🚷','🚯','🚳','🚱','🔞','📵','🚭','','','','','‼️','⁉️','🔅','🔆','','⚠️','🚸','🔱','⚜️','🔰','♻️','','🈯','💹','','✳️','','🌐','💠','','🌀','💤','🏧','🚾','','🅿️','🈳','🈂️','🛂','🛃','🛄','🛅','🚹','🚺','🚼','🚻','🚮','🎦','📶','🈁','🔣','','🔤','🔡','🔠','🆖','🆗','🆙','🆒','🆕','🆓','0️⃣','1️⃣','2️⃣','3️⃣','4️⃣','5️⃣','6️⃣','7️⃣','8️⃣','9️⃣','🔟','🔢','#️⃣','*️⃣','⏏️','▶️','⏸️','⏯️','⏹️','⏺️','⏭️','⏮️','','','','','◀️','🔼','🔽','➡️','⬅️','⬆️','⬇️','↗️','↘️','↙️','↖️','↕️','','↪️','↩️','⤴️','⤵️','🔀','🔁','🔂','🔄','🔃','🎵','🎶','','','','','♾️','💲','💱','™️','©️','®️','👁️‍🗨','🔚','🔙','🔛','🔝','🔜']
const EMOJIS = ['😀','😃','😄','😁','😆','😅','🤣','😂','🙂','😊','😇','🥰','😍','🤩','😘','😗','😋','😛','😜','🤪','😝','🤑','🤗','🤭','🤫','🤔','😐','😑','😶','😏','😒','🙄','😬','😮','😯','😲','😳','🥺','😦','😧','😨','😰','😥','😢','😭','😱','😖','😣','😞','😓','😩','😫','🥱','😤','😡','🤬','😈','👿','💀','☠️','💩','🤡','👻','👽','🤖','👋','🤚','🖐️','','🖖','👌','🤌','🤏','✌️','🤞','🤟','🤘','🤙','👍','👎','✊','👊','🤛','🤜','👏','🙌','🤝','🙏','💪','❤️','🧡','💛','💚','💙','💜','🖤','💔','❣️','💕','💞','💓','💗','💖','💘','💝','💟','🔥','','🌟','','💫','🎉','🎊','🎈','🎁','🎀','🏆','🥇','🎯','🎮','🎵','🎶','🎸','🎵','✈️','🚀','🌈','','🌙','⛅','❄','🌸','🌺','🌻','🌹','🍀','🌿','🌳','🌴','🐱','🐶','🐼','🦊','🦁','🐻','🐰','🐨','🐸','🐳','🦋','🌺','🍳','','🍺','🍕','🍔','🍟','🍦','🍰','🎂','🍫','🍬','','🍷','🍸','🏢','🏠','🏡','','🕌','🏰','🗼','🗽','','','🌋','🏖','🏜️','🏝️','🏟','🎯','🎨','🎬','🎤','🎧','🎼','🎹','🥁','🎺','🎸','🎻','🎲','♟️','🎮','🕹️','🧩','🎰','🚗','🚕','🚙','🚌','🚎','🏎','🚓','🚑','🚒','🚐','🛻','🚚','🚛','🚜','🛵','🏍','🚲','🛴','🛹','🚏','🛣','🛤️','🛢️','','🚨','🚥','🚦','🛑','🚧','','','🛶','🚤','🛳️','','✈️','🛫','🛬','🪂','💺','🚁','🚟','🚠','🚡','🛩️','🚀','🛸','🎆','🎇','🎈','🎉','🎊','🎋','🎍','🎎','🎏','🎐','🎑','🧧','🎀','🎁','🎗️','🎟️','🎫','🎖️','🏆','🏅','🥇','🥈','🥉','','','🥎','🏀','排球','🏈','🏉','🎾','🥏','🎳','🏏','🏑','🏒','🥍','🏓','🏸','🥊','🥋','🥅','','','🎣','🤿','🎽','🎿','🛷','🥌','🎯','🪀','🪁','🎱','🔮','🪄','🧿','🎮','🕹','🎰','🎲','♟️','🧩','🧸','🪅','🪆','🖼','🎨','🧵','🪡','🧶','🪢']
const route = useRoute()
const chatStore = useChatStore()
@@ -111,6 +120,9 @@ const showEmoji = ref(false)
const isOtherOnline = ref(false)
const emojis = EMOJIS
// All messages for display (real + optimistic pending)
const allDisplayMessages = computed(() => chatStore.allMessages())
const ctxMenu = reactive({ show: false, x: 0, y: 0, msg: null as any })
onMounted(async () => {
@@ -135,11 +147,13 @@ async function loadDetail() {
try {
const { data } = await chatApi.getConversationDetail(id)
convDetail.value = data
conversationName.value = data.name || '未命名'
// 私聊检查对方在线状态
// Fix: private chat display name from members
if (data.type === 'private') {
const other = data.members?.find((m: any) => m.user_id !== auth.user?.id)
conversationName.value = other?.nickname || other?.username || data.name || '未命名'
isOtherOnline.value = other?.status === 'online'
} else {
conversationName.value = data.name || '未命名'
}
} catch {}
}
@@ -152,7 +166,36 @@ function markRead() {
}
}
// Scroll-based pagination: load more when near top
function onScroll() {
const el = messageListRef.value
if (!el || !chatStore.activeConversation) return
if (el.scrollTop < 100 && chatStore.hasMoreMessages && !chatStore.isLoadingMore) {
const oldHeight = el.scrollHeight
const oldestMsg = chatStore.currentMessages[0]
if (oldestMsg) {
chatStore.fetchMessages(chatStore.activeConversation, oldestMsg.id).then(() => {
nextTick(() => {
if (el) {
el.scrollTop = el.scrollHeight - oldHeight
}
})
})
}
}
}
// Auto-scroll on new messages (only if already near bottom)
watch(() => chatStore.currentMessages.length, () => {
const el = messageListRef.value
if (!el) return
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 150
if (nearBottom) {
nextTick(scrollToBottom)
}
})
watch(() => chatStore.pendingMessages.length, () => {
nextTick(scrollToBottom)
})
@@ -162,11 +205,26 @@ function scrollToBottom() {
}
}
// Optimistic message sending
function sendMessage() {
const text = inputText.value.trim()
if (!text || !chatStore.activeConversation) return
inputText.value = ''
showEmoji.value = false
// Add optimistic pending message
const tempId = 'temp-' + Date.now()
chatStore.addPendingMessage({
id: tempId,
conversation_id: chatStore.activeConversation,
sender_id: auth.user?.id,
sender_name: auth.user?.nickname || auth.user?.username,
content: text,
type: 'text',
created_at: new Date().toISOString(),
})
// Send via WebSocket
send('chat.send', { conversation_id: chatStore.activeConversation, content: text, type: 'text' })
}
@@ -191,11 +249,9 @@ async function handleImageUpload(event: Event) {
target.value = ''
}
function previewImage(url: string) {
window.open(url, '_blank')
}
function previewImage(url: string) { window.open(url, '_blank') }
// 右键菜单
// Context menu
function onMsgContext(e: MouseEvent, msg: any) {
e.preventDefault()
ctxMenu.show = true
@@ -204,14 +260,10 @@ function onMsgContext(e: MouseEvent, msg: any) {
ctxMenu.msg = msg
}
function closeCtxMenu() {
ctxMenu.show = false
}
function closeCtxMenu() { ctxMenu.show = false }
function copyMsg() {
if (ctxMenu.msg?.content) {
navigator.clipboard.writeText(ctxMenu.msg.content)
}
if (ctxMenu.msg?.content) navigator.clipboard.writeText(ctxMenu.msg.content)
ctxMenu.show = false
}
@@ -231,7 +283,8 @@ function avatarColor(name: string) {
function shouldShowTime(msg: any, idx: number) {
if (idx === 0) return true
const prev = chatStore.currentMessages[idx - 1]
const msgs = allDisplayMessages.value
const prev = msgs[idx - 1]
if (!prev) return false
return dayjs(msg.created_at).diff(dayjs(prev.created_at), 'minute') > 5
}
@@ -258,6 +311,13 @@ function formatTimeDivider(time: string) {
.chat-body { flex: 1; display: flex; overflow: hidden; position: relative; }
.message-list { flex: 1; overflow-y: auto; padding: 16px 20px; }
/* Load more indicator */
.load-more-indicator { display: flex; align-items: center; justify-content: center; gap: 6px; padding: 8px 0; color: var(--color-text-hint); font-size: 13px; }
.loading-spinner-sm { width: 16px; height: 16px; border: 2px solid var(--color-border); border-top-color: var(--color-primary); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.no-more-msg { text-align: center; font-size: 12px; color: var(--color-text-hint); padding: 8px 0; }
.no-messages { text-align: center; padding-top: 100px; }
.no-msg-icon { font-size: 48px; margin-bottom: 8px; }
.no-msg-title { color: var(--color-text-secondary); font-size: 16px; margin: 0; }
@@ -269,6 +329,8 @@ function formatTimeDivider(time: string) {
.message-row { display: flex; align-items: flex-start; margin-bottom: 16px; gap: 8px; }
.message-row.own { justify-content: flex-end; }
.message-row.other { justify-content: flex-start; }
.message-row.pending .bubble { opacity: 0.7; }
.message-row.failed .bubble { border: 1px solid #EF5350; }
.avatar-wrap { flex-shrink: 0; }
.bubble-area { max-width: 60%; }
@@ -280,22 +342,24 @@ function formatTimeDivider(time: string) {
.own .msg-meta { justify-content: flex-end; margin-right: 4px; }
.msg-time { font-size: 10px; color: var(--color-text-hint); }
.msg-status { font-size: 10px; color: var(--color-primary); }
.msg-status.sending { color: var(--color-text-hint); }
.msg-status.failed { color: #EF5350; font-weight: 500; }
.msg-image { max-width: 240px; border-radius: 10px; display: block; cursor: pointer; }
.system-msg { text-align: center; font-size: 12px; color: var(--color-text-hint); margin: 12px 0; padding: 4px 16px; background: rgba(0,0,0,0.03); border-radius: 12px; display: inline-block; }
/* 右键菜单 */
/* Context menu */
.ctx-menu { position: fixed; background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 8px; box-shadow: 0 4px 16px rgba(0,0,0,0.12); z-index: 999; min-width: 120px; overflow: hidden; }
.ctx-item { padding: 8px 16px; font-size: 13px; cursor: pointer; transition: background 0.15s; }
.ctx-item:hover { background: var(--color-primary-lightest); }
.ctx-item.danger { color: #EF5350; }
/* 输入栏 */
/* Input bar */
.input-bar { position: relative; display: flex; align-items: flex-end; gap: 8px; padding: 12px 20px; border-top: 1px solid var(--color-border); background: var(--color-surface); }
.input-actions { display: flex; gap: 4px; padding-bottom: 4px; }
.action-icon { font-size: 20px; cursor: pointer; opacity: 0.6; transition: opacity 0.2s; user-select: none; }
.action-icon:hover { opacity: 1; }
/* Emoji 面板 */
/* Emoji panel */
.emoji-panel {
position: absolute; bottom: 60px; left: 12px; width: 340px; max-height: 200px;
background: var(--color-surface); border: 1px solid var(--color-border);
+9 -1
View File
@@ -14,4 +14,12 @@
不要列表卡片瀑布流,功能丰富一点,不要太单调,界面也丰富一点
要多功能,界面丰富不单调。
要多功能,界面丰富不单调。
还能如何改进
先做高优先
再做中优先
最后做低优先