1.3
This commit is contained in:
+37
-13
@@ -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)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user