777 lines
35 KiB
Vue
777 lines
35 KiB
Vue
<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>
|