Files
chat/frontend/src/views/chat/ChatRoomView.vue
T
2026-06-14 10:01:47 +08:00

777 lines
35 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="chat-room">
<!-- 聊天头部 -->
<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>
</div>
<div class="header-info">
<span class="room-name">{{ conversationName }}</span>
<span v-if="typingText" class="typing-indicator">{{ typingText }}</span>
<span v-else-if="convDetail?.type === 'group'" class="member-count">{{ convDetail.members?.length || 0 }} 位成员</span>
<span v-else class="member-count" :class="{ online: isOtherOnline }">{{ isOtherOnline ? '在线' : '离线' }}</span>
</div>
<span v-if="climate" class="climate-badge" @click="showCalendar = !showCalendar" :title="climateDesc">
{{ climate.emoji }} {{ climate.temperature }}°
</span>
<n-button v-if="convDetail?.type === 'group'" quaternary size="small" @click="showGroupInfo = !showGroupInfo">群信息 ▾</n-button>
<n-button quaternary size="small" @click="toggleSearch" title="搜索">🔍</n-button>
</div>
<!-- 搜索栏 -->
<div v-if="showSearch" class="search-bar">
<n-input v-model:value="searchQuery" placeholder="搜索消息..." size="small" clearable
@keydown.enter.prevent="doSearch" style="flex: 1" />
<n-button size="tiny" type="primary" :loading="isSearching" :disabled="!searchQuery.trim()" @click="doSearch">搜索</n-button>
<n-button size="tiny" quaternary @click="showSearch = false; searchResults = []">✕</n-button>
</div>
<!-- 气候日历 -->
<div v-if="showCalendar" class="calendar-bar">
<div class="calendar-header">
<span>{{ climateDesc }}</span>
<span class="close-btn" @click="showCalendar = false"></span>
</div>
<div class="calendar-grid">
<div v-for="(day, i) in calendar" :key="i" class="cal-cell" :title="day.date">
<span class="cal-emoji">{{ day.emoji }}</span>
</div>
<div v-if="calendar.length === 0" class="cal-empty">还没有气候记录多聊聊就有了</div>
</div>
</div>
<!-- 搜索结果 -->
<div v-if="searchResults.length > 0" class="search-results">
<div class="search-results-header">
<span>找到 {{ searchResults.length }} 条结果</span>
</div>
<div v-for="r in searchResults" :key="r.id" class="search-result-item" @click="scrollToMsg(r.id)">
<span class="result-sender">{{ r.sender_name }}</span>
<span class="result-content">{{ r.content?.slice(0, 80) }}{{ (r.content?.length || 0) > 80 ? '...' : '' }}</span>
<span class="result-time">{{ formatTime(r.created_at) }}</span>
</div>
</div>
<div class="chat-body">
<!-- 关系花园背景私聊时你们的树在背景里轻轻呼吸 -->
<div v-if="convDetail?.type === 'private' && gardenBg" class="garden-bg">
<svg class="bg-tree" viewBox="0 0 200 220" preserveAspectRatio="xMidYMax meet">
<template v-if="gardenBg.stage_index > 0">
<rect :x="100 - bgStyle.trunkWidth/2" y="140" :width="bgStyle.trunkWidth" height="65" :fill="treeTrunkColor(bgStyle)" rx="2" opacity="0.18" />
<g v-for="(p, i) in canopyPositions(bgStyle, 100, 100)" :key="i">
<circle :cx="p.cx" :cy="p.cy" :r="p.r" :fill="treeCanopyColor(bgStyle, i%2?5:-3)" opacity="0.14" />
</g>
</template>
<ellipse v-else cx="100" cy="200" rx="10" ry="7" :fill="treeTrunkColor(bgStyle)" opacity="0.18" />
</svg>
<div class="bg-glow" :style="{ animationDuration: bgBreathMs + 'ms' }"></div>
</div>
<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 allDisplayMessages" :key="msg.id || 'pending-' + idx"
:data-msg-id="msg.id"
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>
</template>
<template v-else>
<div v-if="msg.sender_id !== auth.user?.id" class="avatar-wrap">
<n-avatar :size="36" round :style="{ background: avatarColor(msg.sender_name) }">{{ (msg.sender_name || '?')[0] }}</n-avatar>
</div>
<div class="bubble-area">
<div v-if="msg.sender_id !== auth.user?.id && convDetail?.type === 'group'" class="sender-name">{{ msg.sender_name }}</div>
<div class="bubble" :class="{ 'bubble-self': msg.sender_id === auth.user?.id, 'bubble-other': msg.sender_id !== auth.user?.id }"
@contextmenu.prevent="onMsgContext($event, msg)">
<!-- 引用消息块 -->
<div v-if="msg.reply_to_id" class="reply-quote" @click.stop="scrollToMsg(msg.reply_to_id)">
<span class="reply-quote-author">{{ msg.reply_to_sender_name || '用户' }}</span>
<span class="reply-quote-text">{{ msg.reply_to_content || '消息不可用' }}</span>
</div>
<n-image v-if="msg.type === 'image'" :src="msg.content"
:img-props="{ style: 'max-width:240px;border-radius:10px;display:block;cursor:pointer' }" />
<span v-else>{{ msg.content }}</span>
</div>
<div class="msg-meta">
<span class="msg-time">{{ formatTime(msg.created_at) }}</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">
<n-avatar :size="36" round :style="{ background: 'var(--color-primary-dark)' }">{{ (auth.user?.nickname || auth.user?.username || '我')[0] }}</n-avatar>
</div>
</template>
</div>
</div>
<!-- 右键菜单 -->
<div v-if="ctxMenu.show" class="ctx-menu" :style="{ top: ctxMenu.y + 'px', left: ctxMenu.x + 'px' }">
<div class="ctx-item" @click="replyToMsg"> 回复</div>
<div class="ctx-item" @click="forwardMsg"> 转发</div>
<div class="ctx-item" @click="copyMsg">📋 复制</div>
<div v-if="ctxMenu.msg?.sender_id === auth.user?.id && !ctxMenu.msg?._pending" class="ctx-item danger" @click="deleteMsg">🗑 删除</div>
</div>
<!-- 转发选择器 -->
<div v-if="showForwardModal" class="modal-overlay" @click.self="showForwardModal = false">
<div class="forward-modal">
<div class="modal-header">
<h3>转发消息</h3>
<span class="close-btn" @click="showForwardModal = false"></span>
</div>
<div class="forward-body">
<div class="forward-preview">
<span class="forward-label">转发内容</span>
<span class="forward-text">{{ forwardContent?.slice(0, 100) }}{{ (forwardContent?.length || 0) > 100 ? '...' : '' }}</span>
</div>
<div class="forward-section-label">选择会话</div>
<div v-for="conv in chatStore.conversations" :key="conv.id" class="forward-conv-item"
:class="{ selected: forwardTarget === conv.id }" @click="forwardTarget = conv.id">
<n-avatar :size="32" round :style="{ background: 'var(--color-primary)' }">
{{ (conv.name || '?')[0] }}
</n-avatar>
<span class="forward-conv-name">{{ conv.name }}</span>
</div>
</div>
<div class="modal-footer">
<n-button size="small" @click="showForwardModal = false">取消</n-button>
<n-button size="small" type="primary" :disabled="!forwardTarget" @click="doForward">发送</n-button>
</div>
</div>
</div>
<GroupInfoPanel v-if="showGroupInfo && convDetail?.type === 'group'"
:conversation-id="String(route.params.id)" :detail="convDetail"
@close="showGroupInfo = false" @updated="loadDetail" />
</div>
<!-- 输入框 -->
<div class="input-bar">
<!-- 回复预览条 -->
<div v-if="replyingTo" class="reply-bar">
<div class="reply-bar-content">
<span class="reply-bar-label">回复 {{ replyingTo.sender_name || '用户' }}</span>
<span class="reply-bar-text">{{ (replyingTo.content || '').slice(0, 60) }}{{ (replyingTo.content || '').length > 60 ? '...' : '' }}</span>
</div>
<span class="reply-bar-close" @click="cancelReply"></span>
</div>
<div class="input-actions">
<span class="action-icon" @click="showEmoji = !showEmoji">😊</span>
<span class="action-icon" @click="triggerImageUpload">🖼</span>
<input ref="imageInput" type="file" accept="image/*" style="display:none" @change="handleImageUpload" />
</div>
<div v-if="showEmoji" class="emoji-panel">
<div v-for="e in emojis" :key="e" class="emoji-item" @click="insertEmoji(e)">{{ e }}</div>
</div>
<n-input v-model:value="inputText" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }"
placeholder="输入消息..." @keydown.enter.exact.prevent="sendMessage" @focus="showEmoji = false" />
<n-button type="primary" :disabled="!inputText.trim()" @click="sendMessage" round size="small">发送</n-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, nextTick, watch, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { useMessage } from 'naive-ui'
import { useChatStore } from '@/stores/chat'
import { useAuthStore } from '@/stores/auth'
import { useUiStore } from '@/stores/ui'
import { useWebSocket } from '@/composables/useWebSocket'
import { chatApi } from '@/api/chat'
import { climatesApi } from '@/api/climates'
import { treesApi } from '@/api/trees'
import {
generateTreeStyle, treeTrunkColor, treeCanopyColor, canopyPositions,
} from '@/utils/treeGenerator'
import api from '@/api/client'
import GroupInfoPanel from './GroupInfoPanel.vue'
import dayjs from 'dayjs'
const EMOJIS = ['😀','😃','😄','😁','😆','😅','🤣','😂','🙂','😊','😇','🥰','😍','🤩','😘','😗','😋','😛','😜','🤪','😝','🤑','🤗','🤭','🤫','🤔','😐','😑','😶','😏','😒','🙄','😬','😮','😯','😲','😳','🥺','😦','😧','😨','😰','😥','😢','😭','😱','😖','😣','😞','😓','😩','😫','🥱','😤','😡','🤬','😈','👿','💀','☠️','💩','🤡','👻','👽','🤖','👋','🤚','🖐️','✋','🖖','👌','🤌','🤏','✌️','🤞','🤟','🤘','🤙','👍','👎','✊','👊','🤛','🤜','👏','🙌','🤝','🙏','💪','❤️','🧡','💛','💚','💙','💜','🖤','💔','❣️','💕','💞','💓','💗','💖','💘','💝','💟','🔥','✨','🌟','⭐','💫','🎉','🎊','🎈','🎁','🎀','🏆','🥇','🎯','🎮','🎵','🎶','🎸','🎵','✈️','🚀','🌈','☀️','🌙','⛅','❄️','🌸','🌺','🌻','🌹','🍀','🌿','🌳','🌴','🐱','🐶','🐼','🦊','🦁','🐻','🐰','🐨','🐸','🐳','🦋','🌺','🍳','☕','🍺','🍕','🍔','🍟','🍦','🍰','🎂','🍫','🍬','☕','🍷','🍸','🏢','🏠','🏡','⛪','🕌','🏰','🗼','🗽','⛲','⛰️','🌋','🏖️','🏜️','🏝️','🏟️','🎯','🎨','🎬','🎤','🎧','🎼','🎹','🥁','🎺','🎸','🎻','🎲','♟️','🎮','🕹️','🧩','🎰','🚗','🚕','🚙','🚌','🚎','🏎️','🚓','🚑','🚒','🚐','🛻','🚚','🚛','🚜','🛵','🏍️','🚲','🛴','🛹','🚏','🛣️','🛤️','🛢️','⛽','🚨','🚥','🚦','🛑','🚧','⚓','⛵','🛶','🚤','🛳️','⛽','✈️','🛫','🛬','🪂','💺','🚁','🚟','🚠','🚡','🛩️','🚀','🛸','🎆','🎇','🎈','🎉','🎊','🎋','🎍','🎎','🎏','🎐','🎑','🧧','🎀','🎁','🎗️','🎟️','🎫','🎖️','🏆','🏅','🥇','🥈','🥉','⚽','⚾','🥎','🏀','排球','🏈','🏉','🎾','🥏','🎳','🏏','🏑','🏒','🥍','🏓','🏸','🥊','🥋','🥅','⛳','⛸️','🎣','🤿','🎽','🎿','🛷','🥌','🎯','🪀','🪁','🎱','🔮','🪄','🧿','🎮','🕹️','🎰','🎲','♟️','🧩','🧸','🪅','🪆','🖼️','🎨','🧵','🪡','🧶','🪢']
const route = useRoute()
const message = useMessage()
const chatStore = useChatStore()
const auth = useAuthStore()
const uiStore = useUiStore()
const { send, connected, typingUsers } = useWebSocket()
const inputText = ref('')
const messageListRef = ref<HTMLElement>()
const imageInput = ref<HTMLInputElement>()
const conversationName = ref('')
const convDetail = ref<any>(null)
const showGroupInfo = ref(false)
const showEmoji = ref(false)
const isOtherOnline = ref(false)
const replyingTo = ref<any>(null)
const lastTypingSent = ref(0)
const showSearch = ref(false)
const searchQuery = ref('')
const searchResults = ref<any[]>([])
const isSearching = ref(false)
const showForwardModal = ref(false)
const forwardTarget = ref<string | null>(null)
const forwardContent = ref('')
const forwardSender = ref('')
const climate = ref<any>(null)
const calendar = ref<any[]>([])
const showCalendar = ref(false)
const gardenBg = ref<any>(null) // 关系花园背景:好友之树数据
const emojis = EMOJIS
const bgStyle = computed(() => gardenBg.value
? generateTreeStyle(gardenBg.value.stage_index, gardenBg.value.seed, gardenBg.value.water_count)
: generateTreeStyle(0, '0000', 0))
// 呼吸周期:BPM 越高(越亲密)呼吸越快,60-90s
const bgBreathMs = computed(() => {
const score = gardenBg.value?.total_score || 0
const bpm = Math.min(75, 50 + score / 10)
return Math.round(60000 / bpm)
})
const SEASON_LABEL: Record<string, string> = {
spring: '春', summer: '夏', autumn: '秋', winter: '冬',
}
const WEATHER_LABEL: Record<string, string> = {
sunny: '晴朗', cloudy: '多云', rainy: '绵绵细雨', windy: '微风', snowy: '飘雪',
}
const climateDesc = computed(() => {
if (!climate.value) return ''
const s = SEASON_LABEL[climate.value.season] || ''
const w = WEATHER_LABEL[climate.value.weather] || ''
return `你们的对话正处于「${s}天 · ${w}」,${climate.value.temperature}°C`
})
// Typing indicator: get users typing in current conversation
const currentTypingUsers = computed(() => {
const convId = route.params.id as string
if (!convId) return []
const users: string[] = []
typingUsers.value.forEach((val, key) => {
if (key.startsWith(convId + ':') && val.username) {
users.push(val.username)
}
})
return users
})
const typingText = computed(() => {
const users = currentTypingUsers.value
if (users.length === 0) return ''
if (users.length === 1) return `${users[0]} 正在输入...`
if (users.length === 2) return `${users[0]}${users[1]} 正在输入...`
return `${users[0]}${users.length} 人正在输入...`
})
// 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 () => {
const id = route.params.id as string
if (id) {
await chatStore.fetchMessages(id)
await loadDetail()
await nextTick()
scrollToBottom()
markRead()
loadClimate(id)
loadGardenBg()
}
document.addEventListener('click', closeCtxMenu)
})
onUnmounted(() => {
document.removeEventListener('click', closeCtxMenu)
})
async function loadDetail() {
const id = route.params.id as string
if (!id) return
try {
const { data } = await chatApi.getConversationDetail(id)
convDetail.value = data
// 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 {}
}
async function loadClimate(id: string) {
try {
const { data } = await climatesApi.get(id)
climate.value = data
const { data: cal } = await climatesApi.getCalendar(id)
calendar.value = cal
} catch {}
}
async function loadGardenBg() {
// 私聊时加载关系之树作为背景
if (convDetail.value?.type !== 'private') return
const other = convDetail.value.members?.find((m: any) => m.user_id !== auth.user?.id)
if (!other) return
try {
const { data } = await treesApi.getTree(other.user_id)
gardenBg.value = data
} catch {}
}
function markRead() {
const msgs = chatStore.currentMessages
if (msgs.length > 0 && chatStore.activeConversation) {
const lastMsg = msgs[msgs.length - 1]
chatStore.markAsRead(chatStore.activeConversation, lastMsg.id)
}
}
// 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)
})
// Send typing event when user types (throttled to once per 2 seconds)
watch(inputText, () => {
const convId = chatStore.activeConversation
if (!convId || !inputText.value.trim()) return
const now = Date.now()
if (now - lastTypingSent.value > 2000) {
lastTypingSent.value = now
send('chat.typing', { conversation_id: convId })
}
})
function scrollToBottom() {
if (messageListRef.value) {
messageListRef.value.scrollTop = messageListRef.value.scrollHeight
}
}
// Optimistic message sending
function sendMessage() {
const text = inputText.value.trim()
if (!text || !chatStore.activeConversation) return
inputText.value = ''
showEmoji.value = false
const replyToId = replyingTo.value?.id || null
// 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',
reply_to_id: replyToId,
reply_to_content: replyingTo.value?.content?.slice(0, 200) || null,
reply_to_sender_name: replyingTo.value?.sender_name || null,
created_at: new Date().toISOString(),
})
// Send via WebSocket
send('chat.send', {
conversation_id: chatStore.activeConversation,
content: text,
type: 'text',
reply_to_id: replyToId,
})
// Clear reply state
replyingTo.value = null
}
function replyToMsg() {
if (ctxMenu.msg) {
replyingTo.value = { ...ctxMenu.msg }
}
ctxMenu.show = false
}
function cancelReply() {
replyingTo.value = null
}
function toggleSearch() {
showSearch.value = !showSearch.value
if (!showSearch.value) {
searchResults.value = []
searchQuery.value = ''
}
}
async function doSearch() {
if (!searchQuery.value.trim() || !chatStore.activeConversation) return
isSearching.value = true
try {
const { data } = await chatApi.searchMessages(chatStore.activeConversation, searchQuery.value.trim())
searchResults.value = data.results || []
} catch {
searchResults.value = []
} finally {
isSearching.value = false
}
}
function forwardMsg() {
if (!ctxMenu.msg) return
forwardContent.value = ctxMenu.msg.content || ''
forwardSender.value = ctxMenu.msg.sender_name || '用户'
forwardTarget.value = null
showForwardModal.value = true
ctxMenu.show = false
}
async function doForward() {
if (!forwardTarget.value || !forwardContent.value) return
const text = `[转发自 ${forwardSender.value}]: ${forwardContent.value}`
send('chat.send', {
conversation_id: forwardTarget.value,
content: text,
type: 'text',
})
message.success('已转发')
showForwardModal.value = false
forwardTarget.value = null
}
function scrollToMsg(msgId: string) {
const el = messageListRef.value
if (!el) return
const target = el.querySelector(`[data-msg-id="${msgId}"]`)
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
target.classList.add('highlight')
setTimeout(() => target.classList.remove('highlight'), 1500)
}
}
function insertEmoji(e: string) {
inputText.value += e
}
function triggerImageUpload() {
imageInput.value?.click()
}
async function handleImageUpload(event: Event) {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file || !chatStore.activeConversation) return
try {
const formData = new FormData()
formData.append('file', file)
const { data } = await api.post('/uploads/file', formData, { headers: { 'Content-Type': 'multipart/form-data' } })
send('chat.send', { conversation_id: chatStore.activeConversation, content: data.url, type: 'image' })
} catch {}
target.value = ''
}
function previewImage(url: string) { window.open(url, '_blank') }
// Context menu
function onMsgContext(e: MouseEvent, msg: any) {
e.preventDefault()
ctxMenu.show = true
ctxMenu.x = Math.min(e.clientX, window.innerWidth - 160)
ctxMenu.y = Math.min(e.clientY, window.innerHeight - 100)
ctxMenu.msg = msg
}
function closeCtxMenu() { ctxMenu.show = false }
function copyMsg() {
if (ctxMenu.msg?.content) navigator.clipboard.writeText(ctxMenu.msg.content)
ctxMenu.show = false
}
async function deleteMsg() {
if (!ctxMenu.msg || !chatStore.activeConversation) return
try {
await chatApi.deleteMessage(chatStore.activeConversation, ctxMenu.msg.id)
chatStore.currentMessages = chatStore.currentMessages.filter((m: any) => m.id !== ctxMenu.msg.id)
} catch {}
ctxMenu.show = false
}
function avatarColor(name: string) {
const colors = ['#009688', '#26A69A', '#00796B', '#00897B', '#4DB6AC', '#00838F', '#00695C']
return { background: colors[(name || '').charCodeAt(0) % colors.length] }
}
function shouldShowTime(msg: any, idx: number) {
if (idx === 0) return true
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
}
function formatTime(time: string) { return dayjs(time).format('HH:mm') }
function formatTimeDivider(time: string) {
const d = dayjs(time)
const now = dayjs()
if (d.isSame(now, 'day')) return d.format('HH:mm')
if (d.isSame(now.subtract(1, 'day'), 'day')) return '昨天 ' + d.format('HH:mm')
return d.format('MM月DD日 HH:mm')
}
</script>
<style scoped>
.chat-room { display: flex; flex-direction: column; height: 100%; background: var(--color-bg); }
.chat-header { display: flex; align-items: center; gap: 12px; padding: 12px 20px; border-bottom: 1px solid var(--color-border); background: var(--color-surface); }
.header-info { flex: 1; display: flex; flex-direction: column; gap: 1px; }
.room-name { font-weight: 600; font-size: 16px; }
.member-count { font-size: 12px; color: var(--color-text-hint); }
.member-count.online { color: var(--color-success); }
.member-count.online::before { content: ''; display: inline-block; width: 6px; height: 6px; background: var(--color-success); border-radius: 50%; margin-right: 4px; }
.typing-indicator { font-size: 12px; color: var(--color-primary); font-style: italic; animation: typing-fade 1.5s ease infinite; }
@keyframes typing-fade { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
/* 气候徽章 */
.climate-badge {
font-size: 13px; padding: 3px 10px; border-radius: 12px;
background: var(--color-primary-lightest); color: var(--color-primary);
cursor: pointer; transition: all 0.2s; white-space: nowrap;
}
.climate-badge:hover { background: var(--color-primary-lighter); color: white; }
/* 气候日历 */
.calendar-bar {
background: var(--color-surface); border-bottom: 1px solid var(--color-border); padding: 12px 20px;
}
.calendar-header {
display: flex; justify-content: space-between; align-items: center;
font-size: 13px; color: var(--color-text-secondary); margin-bottom: 10px;
}
.calendar-header .close-btn { cursor: pointer; color: var(--color-text-hint); }
.calendar-grid {
display: grid; grid-template-columns: repeat(15, 1fr); gap: 4px;
}
.cal-cell { display: flex; align-items: center; justify-content: center; font-size: 16px; padding: 2px; }
.cal-empty { grid-column: 1 / -1; text-align: center; font-size: 12px; color: var(--color-text-hint); padding: 12px; }
.chat-body { flex: 1; display: flex; overflow: hidden; position: relative; }
.message-list { flex: 1; overflow-y: auto; padding: 16px 20px; position: relative; z-index: 1; }
/* 关系花园背景 */
.garden-bg {
position: absolute; inset: 0; pointer-events: none; overflow: hidden; z-index: 0;
}
.bg-tree {
position: absolute; bottom: -20px; right: -30px; width: 320px; height: 352px; opacity: 0.5;
animation: bg-sway 6s ease-in-out infinite; transform-origin: 50% 100%;
}
@keyframes bg-sway { 0%,100% { transform: rotate(-1deg); } 50% { transform: rotate(1deg); } }
.bg-glow {
position: absolute; bottom: 10%; right: 5%; width: 280px; height: 280px; border-radius: 50%;
background: radial-gradient(circle, rgba(0,200,150,0.12) 0%, transparent 65%);
animation: bg-breathe ease-in-out infinite;
}
@keyframes bg-breathe { 0%,100% { transform: scale(0.85); opacity: 0.6; } 50% { transform: scale(1.05); opacity: 1; } }
/* 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; }
.no-msg-hint { color: var(--color-text-hint); font-size: 13px; margin-top: 4px; }
.time-divider { text-align: center; margin: 16px 0 8px; font-size: 11px; color: var(--color-text-hint); }
.time-divider::before, .time-divider::after { content: ''; display: inline-block; width: 40px; border-top: 1px solid var(--color-border); vertical-align: middle; margin: 0 8px; }
.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%; }
.sender-name { font-size: 12px; color: var(--color-text-secondary); margin-bottom: 3px; margin-left: 4px; font-weight: 500; }
.bubble { padding: 10px 14px; font-size: 15px; line-height: 1.6; word-break: break-word; }
.bubble-self { background: var(--color-bubble-self); color: var(--color-bubble-self-text); border-radius: 16px 16px 4px 16px; box-shadow: 0 1px 2px rgba(0,150,136,0.15); }
.bubble-other { background: var(--color-bubble-other); color: var(--color-bubble-other-text); border-radius: 16px 16px 16px 4px; }
.msg-meta { margin-top: 2px; display: flex; align-items: center; gap: 4px; }
.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; }
/* Reply quote inside bubble */
.reply-quote {
padding: 6px 10px; margin-bottom: 6px; border-radius: 6px;
background: rgba(0,0,0,0.06); border-left: 3px solid var(--color-primary);
cursor: pointer; font-size: 12px; line-height: 1.4;
}
.bubble-self .reply-quote { background: rgba(255,255,255,0.2); border-left-color: rgba(255,255,255,0.6); }
.reply-quote-author { font-weight: 600; color: var(--color-primary); display: block; margin-bottom: 2px; }
.bubble-self .reply-quote-author { color: rgba(255,255,255,0.9); }
.reply-quote-text { color: var(--color-text-secondary); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.bubble-self .reply-quote-text { color: rgba(255,255,255,0.7); }
/* Highlight animation for scrollToMsg */
.message-row.highlight .bubble { animation: highlight-pulse 1.5s ease; }
@keyframes highlight-pulse {
0%, 100% { box-shadow: none; }
30% { box-shadow: 0 0 0 3px var(--color-primary-lighter); }
}
/* Reply bar above input */
.reply-bar {
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
background: var(--color-primary-lightest); border-radius: 8px; margin-bottom: 6px;
border-left: 3px solid var(--color-primary);
}
.reply-bar-content { flex: 1; min-width: 0; }
.reply-bar-label { display: block; font-size: 12px; font-weight: 600; color: var(--color-primary); }
.reply-bar-text { display: block; font-size: 12px; color: var(--color-text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.reply-bar-close { cursor: pointer; color: var(--color-text-hint); font-size: 14px; padding: 2px; }
.reply-bar-close:hover { color: var(--color-text-primary); }
/* Search bar */
.search-bar {
display: flex; align-items: center; gap: 8px; padding: 8px 20px;
border-bottom: 1px solid var(--color-border); background: var(--color-surface);
}
.search-results {
max-height: 240px; overflow-y: auto; border-bottom: 1px solid var(--color-border);
background: var(--color-surface-elevated);
}
.search-results-header {
padding: 6px 16px; font-size: 12px; color: var(--color-text-hint);
border-bottom: 1px solid var(--color-border); background: var(--color-surface);
}
.search-result-item {
display: flex; align-items: center; gap: 8px; padding: 8px 16px;
cursor: pointer; transition: background 0.15s; border-bottom: 1px solid var(--color-border);
}
.search-result-item:hover { background: var(--color-primary-lightest); }
.result-sender { font-size: 12px; color: var(--color-primary); font-weight: 500; flex-shrink: 0; }
.result-content { flex: 1; font-size: 13px; color: var(--color-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.result-time { font-size: 11px; color: var(--color-text-hint); flex-shrink: 0; }
/* 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; }
/* Forward modal */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.4);
display: flex; align-items: center; justify-content: center; z-index: 1000;
}
.forward-modal {
width: 400px; max-height: 500px; background: var(--color-surface); border-radius: 16px;
box-shadow: 0 12px 40px rgba(0,0,0,0.15); display: flex; flex-direction: column;
}
.forward-modal .modal-header {
display: flex; justify-content: space-between; align-items: center;
padding: 20px 24px 16px; border-bottom: 1px solid var(--color-border);
}
.forward-modal .modal-header h3 { margin: 0; font-size: 18px; }
.forward-modal .close-btn { cursor: pointer; font-size: 18px; color: var(--color-text-hint); }
.forward-modal .close-btn:hover { color: var(--color-text-primary); }
.forward-body { flex: 1; overflow-y: auto; padding: 16px 24px; }
.forward-preview {
padding: 10px 12px; background: var(--color-primary-lightest); border-radius: 8px;
margin-bottom: 12px; border-left: 3px solid var(--color-primary);
}
.forward-label { font-size: 12px; color: var(--color-text-hint); display: block; margin-bottom: 4px; }
.forward-text { font-size: 13px; color: var(--color-text-primary); line-height: 1.5; }
.forward-section-label { font-size: 13px; color: var(--color-text-secondary); margin-bottom: 8px; font-weight: 500; }
.forward-conv-item {
display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 8px;
cursor: pointer; transition: background 0.15s;
}
.forward-conv-item:hover { background: var(--color-primary-lightest); }
.forward-conv-item.selected { background: var(--color-primary-lightest); border: 1px solid var(--color-primary); }
.forward-conv-name { font-size: 14px; }
.forward-modal .modal-footer {
display: flex; justify-content: flex-end; gap: 8px;
padding: 12px 24px; border-top: 1px solid var(--color-border);
}
/* 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 panel */
.emoji-panel {
position: absolute; bottom: 60px; left: 12px; width: 340px; max-height: 200px;
background: var(--color-surface); border: 1px solid var(--color-border);
border-radius: 12px; box-shadow: 0 4px 16px rgba(0,0,0,0.12);
overflow-y: auto; padding: 8px; display: flex; flex-wrap: wrap; gap: 2px; z-index: 100;
}
.emoji-item { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; font-size: 18px; cursor: pointer; border-radius: 4px; transition: background 0.15s; }
.emoji-item:hover { background: var(--color-primary-lightest); }
</style>