|
|
@@ -0,0 +1,657 @@
|
|
|
+<template>
|
|
|
+ <div class="message-layout">
|
|
|
+ <!-- 左侧:会话列表 -->
|
|
|
+ <div class="sidebar">
|
|
|
+ <div class="search-bar">
|
|
|
+ <el-input
|
|
|
+ v-model="searchText"
|
|
|
+ placeholder="搜索联系人..."
|
|
|
+ prefix-icon="Search"
|
|
|
+ clearable
|
|
|
+ />
|
|
|
+ <el-button icon="Plus" circle class="add-btn" @click="showUserSelector = true" />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="conversation-list" v-loading="loadingConversations">
|
|
|
+ <div
|
|
|
+ v-for="chat in filteredConversations"
|
|
|
+ :key="chat.user_id"
|
|
|
+ class="chat-item"
|
|
|
+ :class="{ active: currentChatId === chat.user_id }"
|
|
|
+ @click="selectChat(chat)"
|
|
|
+ >
|
|
|
+ <UserAvatar
|
|
|
+ :name="chat.full_name || chat.username || '未知'"
|
|
|
+ :userId="chat.user_id"
|
|
|
+ :size="40"
|
|
|
+ />
|
|
|
+
|
|
|
+ <div class="chat-info">
|
|
|
+ <div class="chat-header">
|
|
|
+ <span class="chat-name">{{ chat.full_name || chat.username }}</span>
|
|
|
+ <span class="chat-time">{{ formatTime(chat.updated_at) }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="chat-preview">
|
|
|
+ {{ chat.last_message }}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 未读红点 -->
|
|
|
+ <div v-if="chat.unread_count > 0" class="unread-badge">
|
|
|
+ {{ chat.unread_count > 99 ? '99+' : chat.unread_count }}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <el-empty v-if="conversations.length === 0 && !loadingConversations" description="暂无消息" :image-size="60" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 右侧:聊天窗口 -->
|
|
|
+ <div class="chat-window">
|
|
|
+ <template v-if="currentChatId">
|
|
|
+ <!-- 顶部标题 -->
|
|
|
+ <header class="chat-header-bar">
|
|
|
+ <h3>{{ currentChatUser?.full_name || currentChatUser?.username }}</h3>
|
|
|
+ </header>
|
|
|
+
|
|
|
+ <!-- 消息流区域 -->
|
|
|
+ <main class="message-stream" ref="scrollContainer">
|
|
|
+ <div v-for="msg in messages" :key="msg.id" class="message-row" :class="{ 'is-me': msg.sender_id === currentUserId }">
|
|
|
+
|
|
|
+ <UserAvatar
|
|
|
+ v-if="msg.sender_id !== currentUserId"
|
|
|
+ :name="currentChatUser?.full_name || currentChatUser?.username || '?'"
|
|
|
+ :userId="msg.sender_id"
|
|
|
+ :size="36"
|
|
|
+ class="msg-avatar"
|
|
|
+ />
|
|
|
+
|
|
|
+ <div class="message-content-wrapper">
|
|
|
+ <div class="message-bubble">
|
|
|
+ <!-- TEXT -->
|
|
|
+ <span v-if="msg.content_type === 'TEXT'">{{ msg.content }}</span>
|
|
|
+
|
|
|
+ <!-- IMAGE -->
|
|
|
+ <el-image
|
|
|
+ v-else-if="msg.content_type === 'IMAGE'"
|
|
|
+ :src="msg.content"
|
|
|
+ :preview-src-list="[msg.content]"
|
|
|
+ class="msg-image"
|
|
|
+ />
|
|
|
+
|
|
|
+ <!-- FILE -->
|
|
|
+ <div v-else-if="msg.content_type === 'FILE'" class="msg-file">
|
|
|
+ <el-icon><Document /></el-icon>
|
|
|
+ <a :href="msg.content" target="_blank">下载文件</a>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- VIDEO -->
|
|
|
+ <video v-else-if="msg.content_type === 'VIDEO'" :src="msg.content" controls class="msg-video"></video>
|
|
|
+ </div>
|
|
|
+ <div class="message-time">{{ formatTime(msg.created_at) }}</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Removed UserAvatar for Me -->
|
|
|
+ </div>
|
|
|
+ </main>
|
|
|
+
|
|
|
+ <!-- 底部输入框 -->
|
|
|
+ <footer class="input-area">
|
|
|
+ <div class="toolbar">
|
|
|
+ <el-upload
|
|
|
+ class="upload-demo"
|
|
|
+ action="#"
|
|
|
+ :http-request="handleUpload"
|
|
|
+ :show-file-list="false"
|
|
|
+ accept="image/*"
|
|
|
+ >
|
|
|
+ <el-icon title="图片" class="tool-icon"><Picture /></el-icon>
|
|
|
+ </el-upload>
|
|
|
+ <!-- <el-icon title="文件" class="tool-icon"><Folder /></el-icon> -->
|
|
|
+ </div>
|
|
|
+ <textarea
|
|
|
+ v-model="inputMessage"
|
|
|
+ @keydown.enter.prevent="sendMessage"
|
|
|
+ placeholder="输入消息..."
|
|
|
+ class="input-textarea"
|
|
|
+ ></textarea>
|
|
|
+ <div class="send-actions">
|
|
|
+ <el-button type="primary" @click="sendMessage" :disabled="!inputMessage.trim()">发送</el-button>
|
|
|
+ </div>
|
|
|
+ </footer>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <div v-else class="empty-state">
|
|
|
+ <el-empty description="选择一个联系人开始聊天" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 用户选择器弹窗 -->
|
|
|
+ <el-dialog v-model="showUserSelector" title="发起聊天" width="500px">
|
|
|
+ <el-select
|
|
|
+ v-model="selectedUserId"
|
|
|
+ filterable
|
|
|
+ remote
|
|
|
+ reserve-keyword
|
|
|
+ placeholder="搜索用户(输入手机号或名字)"
|
|
|
+ :remote-method="searchUsersRemote"
|
|
|
+ :loading="searchingUsers"
|
|
|
+ style="width: 100%"
|
|
|
+ >
|
|
|
+ <el-option
|
|
|
+ v-for="item in userOptions"
|
|
|
+ :key="item.id"
|
|
|
+ :label="`${item.name || item.username || '未命名'} (${item.mobile})`"
|
|
|
+ :value="item.id"
|
|
|
+ />
|
|
|
+ </el-select>
|
|
|
+ <template #footer>
|
|
|
+ <span class="dialog-footer">
|
|
|
+ <el-button @click="showUserSelector = false">取消</el-button>
|
|
|
+ <el-button type="primary" @click="startNewChat">确定</el-button>
|
|
|
+ </span>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import { ref, onMounted, computed, watch, nextTick } from 'vue'
|
|
|
+import UserAvatar from '@/components/UserAvatar.vue'
|
|
|
+import { Search, Plus, Picture, Folder, Document } from '@element-plus/icons-vue'
|
|
|
+import api from '@/utils/request'
|
|
|
+import { useAuthStore } from '@/store/auth'
|
|
|
+import { ElMessage } from 'element-plus'
|
|
|
+
|
|
|
+const authStore = useAuthStore()
|
|
|
+const currentUser = computed(() => authStore.user)
|
|
|
+const currentUserId = computed(() => authStore.user?.id)
|
|
|
+
|
|
|
+// State
|
|
|
+const searchText = ref('')
|
|
|
+const loadingConversations = ref(false)
|
|
|
+const conversations = ref<any[]>([])
|
|
|
+const currentChatId = ref<number | null>(null)
|
|
|
+const currentChatUser = ref<any>(null)
|
|
|
+const messages = ref<any[]>([])
|
|
|
+const inputMessage = ref('')
|
|
|
+const scrollContainer = ref<HTMLElement | null>(null)
|
|
|
+
|
|
|
+// User Selector
|
|
|
+const showUserSelector = ref(false)
|
|
|
+const selectedUserId = ref<number | null>(null)
|
|
|
+const userOptions = ref<any[]>([])
|
|
|
+const searchingUsers = ref(false)
|
|
|
+
|
|
|
+// Computed
|
|
|
+const filteredConversations = computed(() => {
|
|
|
+ if (!searchText.value) return conversations.value
|
|
|
+ const lower = searchText.value.toLowerCase()
|
|
|
+ return conversations.value.filter(c =>
|
|
|
+ (c.full_name && c.full_name.toLowerCase().includes(lower)) ||
|
|
|
+ (c.username && c.username.toLowerCase().includes(lower))
|
|
|
+ )
|
|
|
+})
|
|
|
+
|
|
|
+// Lifecycle
|
|
|
+onMounted(() => {
|
|
|
+ fetchConversations()
|
|
|
+ if (!currentUser.value) authStore.fetchUser()
|
|
|
+})
|
|
|
+
|
|
|
+// Methods
|
|
|
+const initWebSocket = () => {
|
|
|
+ if (!currentUser.value) return
|
|
|
+
|
|
|
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
|
+ // Use relative path or configured base URL
|
|
|
+ const wsUrl = `${protocol}//${window.location.host}/api/v1/ws/messages?token=${localStorage.getItem('token')}`
|
|
|
+
|
|
|
+ const ws = new WebSocket(wsUrl)
|
|
|
+
|
|
|
+ ws.onmessage = (event) => {
|
|
|
+ if (event.data === 'pong') return
|
|
|
+ try {
|
|
|
+ const msg = JSON.parse(event.data)
|
|
|
+ if (msg.type === 'NEW_MESSAGE') {
|
|
|
+ const newMessage = msg.data
|
|
|
+ // If current chat is open, append message
|
|
|
+ if (currentChatId.value === newMessage.sender_id || (newMessage.sender_id === currentUserId.value && currentChatId.value === newMessage.receiver_id)) {
|
|
|
+ // Handle case where sender_id is myself (from another device) or currentChat is sender
|
|
|
+ messages.value.push(newMessage)
|
|
|
+ scrollToBottom()
|
|
|
+ // Mark read if window focused? (Skip for now)
|
|
|
+ } else if (newMessage.sender_id === currentUserId.value && currentChatId.value === newMessage.receiver_id) {
|
|
|
+ // Message sent by me from another tab/device
|
|
|
+ messages.value.push(newMessage)
|
|
|
+ scrollToBottom()
|
|
|
+ }
|
|
|
+
|
|
|
+ // Update sidebar list
|
|
|
+ updateConversationPreview(
|
|
|
+ newMessage.sender_id === currentUserId.value ? newMessage.receiver_id : newMessage.sender_id,
|
|
|
+ newMessage.content,
|
|
|
+ newMessage.content_type
|
|
|
+ )
|
|
|
+
|
|
|
+ // Update unread count if not current chat
|
|
|
+ if (newMessage.sender_id !== currentUserId.value && currentChatId.value !== newMessage.sender_id) {
|
|
|
+ const conv = conversations.value.find(c => c.user_id === newMessage.sender_id)
|
|
|
+ if (conv) conv.unread_count = (conv.unread_count || 0) + 1
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error('WS parse error', e)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ ws.onopen = () => {
|
|
|
+ // Start heartbeat
|
|
|
+ setInterval(() => {
|
|
|
+ if (ws.readyState === WebSocket.OPEN) ws.send('ping')
|
|
|
+ }, 30000)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const fetchConversations = async () => {
|
|
|
+ loadingConversations.value = true
|
|
|
+ try {
|
|
|
+ const res = await api.get('/messages/conversations')
|
|
|
+ conversations.value = res.data
|
|
|
+ initWebSocket() // Start WS after data loaded
|
|
|
+ } catch (e) {
|
|
|
+ console.error(e)
|
|
|
+ } finally {
|
|
|
+ loadingConversations.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const selectChat = async (chat: any) => {
|
|
|
+ currentChatId.value = chat.user_id
|
|
|
+ currentChatUser.value = chat
|
|
|
+
|
|
|
+ // Mark as read locally (API call typically happens here or on scroll)
|
|
|
+ // For simplicity, we just load history
|
|
|
+ await loadHistory(chat.user_id)
|
|
|
+
|
|
|
+ // Clear unread count locally
|
|
|
+ const conv = conversations.value.find(c => c.user_id === chat.user_id)
|
|
|
+ if (conv) conv.unread_count = 0
|
|
|
+}
|
|
|
+
|
|
|
+const loadHistory = async (userId: number) => {
|
|
|
+ try {
|
|
|
+ const res = await api.get(`/messages/history/${userId}`)
|
|
|
+ // API returns newest first, reverse for display
|
|
|
+ messages.value = res.data.reverse()
|
|
|
+ scrollToBottom()
|
|
|
+
|
|
|
+ // Mark messages as read
|
|
|
+ // Iterate and find unread... or just call a "read all from this user" endpoint?
|
|
|
+ const unreadIds = messages.value.filter(m => !m.is_read && m.receiver_id === currentUserId.value).map(m => m.id)
|
|
|
+ if (unreadIds.length > 0) {
|
|
|
+ // Implement batch read
|
|
|
+ // For efficiency, we mark one by one for now, or ideally backend supports batch
|
|
|
+ // Using Promise.all to parallelize
|
|
|
+ await Promise.all(unreadIds.map(id => api.put(`/messages/${id}/read`)))
|
|
|
+
|
|
|
+ // Update local state for unread count
|
|
|
+ const conv = conversations.value.find(c => c.user_id === userId)
|
|
|
+ if (conv) conv.unread_count = 0
|
|
|
+ }
|
|
|
+
|
|
|
+ } catch (e) {
|
|
|
+ console.error(e)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const sendMessage = async () => {
|
|
|
+ if (!inputMessage.value.trim() || !currentChatId.value) return
|
|
|
+
|
|
|
+ const payload = {
|
|
|
+ receiver_id: currentChatId.value,
|
|
|
+ content: inputMessage.value,
|
|
|
+ type: 'MESSAGE',
|
|
|
+ content_type: 'TEXT',
|
|
|
+ title: '私信'
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await api.post('/messages/', payload)
|
|
|
+ messages.value.push(res.data)
|
|
|
+ inputMessage.value = ''
|
|
|
+ scrollToBottom()
|
|
|
+
|
|
|
+ // Update conversation list preview
|
|
|
+ updateConversationPreview(currentChatId.value, res.data.content, 'TEXT')
|
|
|
+
|
|
|
+ } catch (e) {
|
|
|
+ ElMessage.error('发送失败')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const handleUpload = async (options: any) => {
|
|
|
+ const { file } = options
|
|
|
+ const formData = new FormData()
|
|
|
+ formData.append('file', file)
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 1. Upload
|
|
|
+ const uploadRes = await api.post('/messages/upload', formData, {
|
|
|
+ headers: { 'Content-Type': 'multipart/form-data' }
|
|
|
+ })
|
|
|
+
|
|
|
+ const url = uploadRes.data.url // assuming response { url: '...' }
|
|
|
+
|
|
|
+ // 2. Send Message
|
|
|
+ const payload = {
|
|
|
+ receiver_id: currentChatId.value,
|
|
|
+ content: url, // Or JSON
|
|
|
+ type: 'MESSAGE',
|
|
|
+ content_type: 'IMAGE',
|
|
|
+ title: '图片'
|
|
|
+ }
|
|
|
+
|
|
|
+ const res = await api.post('/messages/', payload)
|
|
|
+ messages.value.push(res.data)
|
|
|
+ scrollToBottom()
|
|
|
+ updateConversationPreview(currentChatId.value!, '[图片]', 'IMAGE')
|
|
|
+
|
|
|
+ } catch (e) {
|
|
|
+ ElMessage.error('上传失败')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const updateConversationPreview = (userId: number, content: string, type: string) => {
|
|
|
+ const conv = conversations.value.find(c => c.user_id === userId)
|
|
|
+ if (conv) {
|
|
|
+ conv.last_message = type === 'TEXT' ? content : `[${type}]`
|
|
|
+ conv.updated_at = new Date().toISOString()
|
|
|
+ // Move to top
|
|
|
+ conversations.value = [conv, ...conversations.value.filter(c => c.user_id !== userId)]
|
|
|
+ } else {
|
|
|
+ // New conversation? Fetch list again or manually construct
|
|
|
+ fetchConversations()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const scrollToBottom = () => {
|
|
|
+ nextTick(() => {
|
|
|
+ if (scrollContainer.value) {
|
|
|
+ scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight
|
|
|
+ }
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+const formatTime = (timeStr: string) => {
|
|
|
+ if (!timeStr) return ''
|
|
|
+ const date = new Date(timeStr)
|
|
|
+ const now = new Date()
|
|
|
+
|
|
|
+ if (date.toDateString() === now.toDateString()) {
|
|
|
+ // Today: HH:mm
|
|
|
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
|
+ }
|
|
|
+ // Other: MM-DD
|
|
|
+ return `${date.getMonth() + 1}-${date.getDate()}`
|
|
|
+}
|
|
|
+
|
|
|
+// User Search Logic
|
|
|
+const searchUsersRemote = async (query: string) => {
|
|
|
+ if (query) {
|
|
|
+ searchingUsers.value = true
|
|
|
+ try {
|
|
|
+ // Assuming existing user search API
|
|
|
+ const res = await api.get('/users/search', { params: { q: query, limit: 10 } })
|
|
|
+ userOptions.value = res.data.data || res.data // Adapt to actual response structure
|
|
|
+ } catch (e) {
|
|
|
+ console.error(e)
|
|
|
+ } finally {
|
|
|
+ searchingUsers.value = false
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ userOptions.value = []
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const startNewChat = () => {
|
|
|
+ if (!selectedUserId.value) return
|
|
|
+
|
|
|
+ const user = userOptions.value.find(u => u.id === selectedUserId.value)
|
|
|
+ if (user) {
|
|
|
+ // Check if exists
|
|
|
+ const existing = conversations.value.find(c => c.user_id === user.id)
|
|
|
+ if (existing) {
|
|
|
+ selectChat(existing)
|
|
|
+ } else {
|
|
|
+ // Add temporary conversation item
|
|
|
+ const newChat = {
|
|
|
+ user_id: user.id,
|
|
|
+ username: user.mobile, // Use mobile as username
|
|
|
+ full_name: user.name || user.username || user.mobile, // Better fallback
|
|
|
+ unread_count: 0,
|
|
|
+ last_message: '',
|
|
|
+ last_message_type: 'TEXT',
|
|
|
+ updated_at: new Date().toISOString()
|
|
|
+ }
|
|
|
+ conversations.value.unshift(newChat)
|
|
|
+ selectChat(newChat)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ showUserSelector.value = false
|
|
|
+ selectedUserId.value = null
|
|
|
+}
|
|
|
+
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.message-layout {
|
|
|
+ display: flex;
|
|
|
+ height: calc(100vh - 120px); /* Adjust based on header/padding */
|
|
|
+ background: #fff;
|
|
|
+ border-radius: 8px;
|
|
|
+ overflow: hidden;
|
|
|
+ box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1);
|
|
|
+ border: 1px solid #e6e6e6;
|
|
|
+}
|
|
|
+
|
|
|
+.sidebar {
|
|
|
+ width: 280px;
|
|
|
+ background: #f7f7f7;
|
|
|
+ border-right: 1px solid #e6e6e6;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+}
|
|
|
+
|
|
|
+.search-bar {
|
|
|
+ padding: 15px;
|
|
|
+ display: flex;
|
|
|
+ gap: 10px;
|
|
|
+ background: #f7f7f7;
|
|
|
+ border-bottom: 1px solid #e6e6e6;
|
|
|
+}
|
|
|
+
|
|
|
+.conversation-list {
|
|
|
+ flex: 1;
|
|
|
+ overflow-y: auto;
|
|
|
+}
|
|
|
+
|
|
|
+.chat-item {
|
|
|
+ display: flex;
|
|
|
+ padding: 12px 15px;
|
|
|
+ cursor: pointer;
|
|
|
+ position: relative;
|
|
|
+ transition: background 0.2s;
|
|
|
+}
|
|
|
+
|
|
|
+.chat-item:hover {
|
|
|
+ background: #e9e9e9;
|
|
|
+}
|
|
|
+
|
|
|
+.chat-item.active {
|
|
|
+ background: #c6c6c6;
|
|
|
+}
|
|
|
+
|
|
|
+.chat-info {
|
|
|
+ margin-left: 10px;
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ justify-content: center;
|
|
|
+}
|
|
|
+
|
|
|
+.chat-header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ margin-bottom: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.chat-name {
|
|
|
+ font-weight: 500;
|
|
|
+ color: #333;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+
|
|
|
+.chat-time {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #999;
|
|
|
+}
|
|
|
+
|
|
|
+.chat-preview {
|
|
|
+ font-size: 13px;
|
|
|
+ color: #888;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+
|
|
|
+.unread-badge {
|
|
|
+ position: absolute;
|
|
|
+ right: 10px;
|
|
|
+ bottom: 12px;
|
|
|
+ background: #ff4d4f;
|
|
|
+ color: white;
|
|
|
+ border-radius: 10px;
|
|
|
+ padding: 0 6px;
|
|
|
+ font-size: 12px;
|
|
|
+ height: 18px;
|
|
|
+ line-height: 18px;
|
|
|
+}
|
|
|
+
|
|
|
+/* Chat Window */
|
|
|
+.chat-window {
|
|
|
+ flex: 1;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ background: #f5f5f5;
|
|
|
+ min-width: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.chat-header-bar {
|
|
|
+ height: 60px;
|
|
|
+ border-bottom: 1px solid #e6e6e6;
|
|
|
+ padding: 0 20px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ background: #f5f5f5;
|
|
|
+}
|
|
|
+
|
|
|
+.chat-header-bar h3 {
|
|
|
+ margin: 0;
|
|
|
+ font-size: 16px;
|
|
|
+}
|
|
|
+
|
|
|
+.message-stream {
|
|
|
+ flex: 1;
|
|
|
+ overflow-y: auto;
|
|
|
+ padding: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+.message-row {
|
|
|
+ display: flex;
|
|
|
+ margin-bottom: 20px;
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.message-row.is-me {
|
|
|
+ flex-direction: row-reverse;
|
|
|
+}
|
|
|
+
|
|
|
+.message-content-wrapper {
|
|
|
+ max-width: 70%;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+}
|
|
|
+
|
|
|
+.message-row.is-me .message-content-wrapper {
|
|
|
+ align-items: flex-end;
|
|
|
+}
|
|
|
+
|
|
|
+.message-bubble {
|
|
|
+ background: #fff;
|
|
|
+ padding: 10px 14px;
|
|
|
+ border-radius: 4px;
|
|
|
+ position: relative;
|
|
|
+ font-size: 14px;
|
|
|
+ line-height: 1.5;
|
|
|
+ word-wrap: break-word;
|
|
|
+}
|
|
|
+
|
|
|
+.message-row.is-me .message-bubble {
|
|
|
+ background: #95ec69; /* WeChat green */
|
|
|
+}
|
|
|
+
|
|
|
+.message-time {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #b2b2b2;
|
|
|
+ margin-top: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.msg-image {
|
|
|
+ max-width: 200px;
|
|
|
+ border-radius: 4px;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+
|
|
|
+.input-area {
|
|
|
+ height: 160px;
|
|
|
+ border-top: 1px solid #e6e6e6;
|
|
|
+ background: #fff;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ padding: 0 20px 20px;
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar {
|
|
|
+ height: 40px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 15px;
|
|
|
+}
|
|
|
+
|
|
|
+.tool-icon {
|
|
|
+ font-size: 20px;
|
|
|
+ color: #666;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+.tool-icon:hover {
|
|
|
+ color: #333;
|
|
|
+}
|
|
|
+
|
|
|
+.input-textarea {
|
|
|
+ flex: 1;
|
|
|
+ border: none;
|
|
|
+ resize: none;
|
|
|
+ outline: none;
|
|
|
+ font-size: 14px;
|
|
|
+ font-family: inherit;
|
|
|
+}
|
|
|
+
|
|
|
+.send-actions {
|
|
|
+ display: flex;
|
|
|
+ justify-content: flex-end;
|
|
|
+}
|
|
|
+</style>
|