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
+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);