|
@@ -0,0 +1,603 @@
|
|
|
|
|
+<template>
|
|
|
|
|
+ <div class="dialogue-container">
|
|
|
|
|
+ <header class="dialogue-header">
|
|
|
|
|
+ <div class="left-action" @click="goBack">
|
|
|
|
|
+ <i class="el-icon-arrow-left" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <span class="chat-title">{{ currentContact.nickname }}</span>
|
|
|
|
|
+ <i class="el-icon-more right-icon" />
|
|
|
|
|
+ </header>
|
|
|
|
|
+
|
|
|
|
|
+ <div ref="messageBox" class="message-container" @scroll="handleScroll">
|
|
|
|
|
+
|
|
|
|
|
+ <div v-if="hasOlder" class="loading-tips">
|
|
|
|
|
+ <i class="el-icon-loading" /> 正在加载历史消息...
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div
|
|
|
|
|
+ v-for="msg in messageList"
|
|
|
|
|
+ :id="'msg-' + msg.messageId"
|
|
|
|
|
+ :key="msg.messageId"
|
|
|
|
|
+ :class="['message-row', isMe(msg.sender.userId) ? 'msg-me' : 'msg-other']"
|
|
|
|
|
+ >
|
|
|
|
|
+ <el-avatar
|
|
|
|
|
+ :size="38"
|
|
|
|
|
+ shape="square"
|
|
|
|
|
+ :src="isMe(msg.sender.userId) ? myAvatar : (msg.sender.avatarUrl || currentContact.avatar)"
|
|
|
|
|
+ />
|
|
|
|
|
+
|
|
|
|
|
+ <div class="message-content">
|
|
|
|
|
+ <div v-if="msg.msgType === 1" class="msg-text">{{ msg.content }}</div>
|
|
|
|
|
+
|
|
|
|
|
+ <div v-else-if="msg.msgType === 2" class="msg-image" @click="onPreview(msg.objectUrl)">
|
|
|
|
|
+ <el-image :src="msg.objectUrl" fit="cover" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div
|
|
|
|
|
+ v-else-if="msg.msgType === 3"
|
|
|
|
|
+ class="msg-audio"
|
|
|
|
|
+ :style="{ width: 60 + (msg.duration || 5) * 4 + 'px' }"
|
|
|
|
|
+ @click="toggleAudio(msg, msg.messageId)"
|
|
|
|
|
+ >
|
|
|
|
|
+ <i :class="['audio-icon', currentPlayingId === msg.messageId ? 'el-icon-loading' : 'el-icon-phone-outline']" />
|
|
|
|
|
+ <span class="audio-duration">{{ msg.duration || 5 }}"</span>
|
|
|
|
|
+ <div v-if="msg.isUnread" class="audio-unread-dot" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div v-else-if="msg.msgType === 4" class="msg-video" @click="viewVideo(msg.objectUrl)">
|
|
|
|
|
+ <el-image :src="require('@/assets/img/video.jpg')" fit="cover" class="video-cover" />
|
|
|
|
|
+ <div class="video-play-mask">
|
|
|
|
|
+ <i class="el-icon-video-play play-icon" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div v-else-if="[5, 6, 7, 99].includes(msg.msgType)" class="msg-file">
|
|
|
|
|
+ <div class="file-info">
|
|
|
|
|
+ <div class="file-name">{{ msg.fileName || '未知文件' }}</div>
|
|
|
|
|
+ <div class="file-size">{{ msg.fileSize || '0 KB' }}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="file-icon-box">
|
|
|
|
|
+ <i :class="getFileIcon(msg.msgType)" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div v-else-if="msg.msgType === 8" class="msg-record">
|
|
|
|
|
+ <div class="record-title">聊天记录</div>
|
|
|
|
|
+ <div v-for="(line, idx) in msg.previewLines" :key="idx" class="record-preview">
|
|
|
|
|
+ {{ line }}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div v-if="hasNewer" class="loading-tips">
|
|
|
|
|
+ <i class="el-icon-loading" /> 正在加载新消息...
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <footer class="input-container">
|
|
|
|
|
+ <div class="tool-bar">
|
|
|
|
|
+ <i class="el-icon-microphone" />
|
|
|
|
|
+ <el-input v-model="inputText" type="text" size="small" placeholder="发送消息..." @keyup.enter.native="sendMessage" />
|
|
|
|
|
+ <i class="el-icon-aim" />
|
|
|
|
|
+ <el-button type="success" size="mini" class="send-btn" @click="sendMessage">发送</el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </footer>
|
|
|
|
|
+
|
|
|
|
|
+ <audio
|
|
|
|
|
+ v-if="currentAudioUrl"
|
|
|
|
|
+ ref="audioPlayer"
|
|
|
|
|
+ :src="currentAudioUrl"
|
|
|
|
|
+ style="display: none;"
|
|
|
|
|
+ @ended="onAudioEnded"
|
|
|
|
|
+ @error="onAudioError" />
|
|
|
|
|
+
|
|
|
|
|
+ <el-dialog
|
|
|
|
|
+ :visible.sync="videoDialogVisible"
|
|
|
|
|
+ width="90%"
|
|
|
|
|
+ max-width="400px"
|
|
|
|
|
+ custom-class="video-player-dialog"
|
|
|
|
|
+ :append-to-body="true"
|
|
|
|
|
+ @close="closeVideoPlayer"
|
|
|
|
|
+ >
|
|
|
|
|
+ <div class="dialog-video-wrapper">
|
|
|
|
|
+ <video
|
|
|
|
|
+ v-if="videoDialogVisible"
|
|
|
|
|
+ ref="previewVideo"
|
|
|
|
|
+ :src="currentVideoUrl"
|
|
|
|
|
+ controls
|
|
|
|
|
+ autoplay
|
|
|
|
|
+ class="preview-video-element"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </el-dialog>
|
|
|
|
|
+ </div>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<script>
|
|
|
|
|
+import { getChatRecords } from '@/api/chat'
|
|
|
|
|
+
|
|
|
|
|
+export default {
|
|
|
|
|
+ name: 'ChatDialogue',
|
|
|
|
|
+ data() {
|
|
|
|
|
+ return {
|
|
|
|
|
+ // 1. 基本配置与当前登入用户静态声明
|
|
|
|
|
+ myUserId: 10001, // 对应后端 receiver/sender 中的当前用户 ID,用于判断消息左右定位
|
|
|
|
|
+ myAvatar: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png',
|
|
|
|
|
+ inputText: '',
|
|
|
|
|
+ currentAudioUrl: '',
|
|
|
|
|
+ currentPlayingId: null,
|
|
|
|
|
+ currentContact: {
|
|
|
|
|
+ chatId: 65422888961, // 当前会话的唯一分布式 ID
|
|
|
|
|
+ nickname: '1000条分页压力测试',
|
|
|
|
|
+ avatar: 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png'
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ // 2. 状态控制与渲染层解耦变量
|
|
|
|
|
+ messageList: [], // 视图层真正循环渲染的数组
|
|
|
|
|
+ lastReadMessageId: 0, // 后端持久化记录的用户上次查看的 Anchor ID
|
|
|
|
|
+
|
|
|
|
|
+ // 双向分页开关
|
|
|
|
|
+ hasOlder: false, // 初始化默认为 true,若请求触顶且后端返回数据为空或判断到头则置为 false
|
|
|
|
|
+ hasNewer: false, // 初始化默认为 true,向下滚动加载更新的标记
|
|
|
|
|
+ isLoading: false, // 节流阀锁
|
|
|
|
|
+ videoDialogVisible: false,
|
|
|
|
|
+ currentVideoUrl: ''
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ created() {
|
|
|
|
|
+ // 页面创建时执行核心链条:先通过锚点初始化首屏切片
|
|
|
|
|
+ this.initFirstScreen()
|
|
|
|
|
+ },
|
|
|
|
|
+ methods: {
|
|
|
|
|
+ // 助手函数:比对 userId 判断是否是“我”发送的
|
|
|
|
|
+ isMe(senderId) {
|
|
|
|
|
+ return Number(senderId) === Number(this.myUserId)
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 核心生命周期方法A:首屏初始化定位
|
|
|
|
|
+ * 围绕上次阅读位置 lastReadMessageId 去查询它前后的邻近消息
|
|
|
|
|
+ */
|
|
|
|
|
+ initFirstScreen() {
|
|
|
|
|
+ this.isLoading = true
|
|
|
|
|
+
|
|
|
|
|
+ const queryParams = {
|
|
|
|
|
+ chatId: this.currentContact.chatId,
|
|
|
|
|
+ messageId: this.lastReadMessageId, // 传入锚点 ID
|
|
|
|
|
+ direction: 'init' // 自定义策略标识,告知后端需要加载此 ID 往上 5 条及往下 5 条
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ getChatRecords(queryParams).then(resp => {
|
|
|
|
|
+ if (resp.code === 0) {
|
|
|
|
|
+ resp.data.list.forEach(msg => {
|
|
|
|
|
+ if ([5, 6, 7].includes(msg.msgType) && msg.content) {
|
|
|
|
|
+ // 假设后端 content 规则为 "文件名|大小|下载地址"
|
|
|
|
|
+ const parts = msg.content.split('|')
|
|
|
|
|
+ msg.fileName = parts[0] || '未知文件.docx'
|
|
|
|
|
+ msg.fileSize = parts[1] || '0 KB'
|
|
|
|
|
+ msg.fileUrl = parts[2] || ''
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // 规范后端有可能因为无序返回,在前端按长整型 ID 重新做一次升序排列保证聊天时间线正常
|
|
|
|
|
+ this.messageList = resp.data.list.sort((a, b) => a.messageId - b.messageId)
|
|
|
|
|
+
|
|
|
|
|
+ // 根据后端给出的总链条状态决定是否还能继续拉取
|
|
|
|
|
+ this.hasOlder = resp.data.hasPrev // 或者是通过后端特定规则进行首屏判定
|
|
|
|
|
+ this.hasNewer = resp.data.hasNext
|
|
|
|
|
+
|
|
|
|
|
+ // 精准复位将该消息钉在可视区域正中央
|
|
|
|
|
+ this.scrollToMessage(this.lastReadMessageId)
|
|
|
|
|
+ }
|
|
|
|
|
+ }).finally(() => {
|
|
|
|
|
+ this.isLoading = false
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 核心监听:判定触顶/触底边界
|
|
|
|
|
+ */
|
|
|
|
|
+ handleScroll() {
|
|
|
|
|
+ if (this.isLoading) return
|
|
|
|
|
+ const box = this.$refs.messageBox
|
|
|
|
|
+ if (!box) return
|
|
|
|
|
+
|
|
|
|
|
+ const threshold = 15 // 触边缓冲像素
|
|
|
|
|
+
|
|
|
|
|
+ // 1. 向上滚动触顶 -> 抓取当前页面最顶端一条消息的 messageId 传向后端,拉取更旧记录
|
|
|
|
|
+ if (box.scrollTop <= threshold && this.hasOlder) {
|
|
|
|
|
+ this.loadMoreData('older')
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 2. 向下滚动触底 -> 抓取当前页面最底端一条消息的 messageId 传向后端,拉取更新消息
|
|
|
|
|
+ const scrollBottom = box.scrollHeight - box.clientHeight - box.scrollTop
|
|
|
|
|
+ if (scrollBottom <= threshold && this.hasNewer) {
|
|
|
|
|
+ this.loadMoreData('newer')
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 核心异步翻页:双向动态加载器
|
|
|
|
|
+ */
|
|
|
|
|
+ loadMoreData(direction) {
|
|
|
|
|
+ if (this.messageList.length === 0) return
|
|
|
|
|
+ this.isLoading = true
|
|
|
|
|
+
|
|
|
|
|
+ const box = this.$refs.messageBox
|
|
|
|
|
+ const queryParams = {
|
|
|
|
|
+ chatId: this.currentContact.chatId,
|
|
|
|
|
+ direction: direction // 'older' 或 'newer'
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (direction === 'older') {
|
|
|
|
|
+ // 向上:边界锚点为当前渲染队列的第一条
|
|
|
|
|
+ queryParams.messageId = this.messageList[0].messageId
|
|
|
|
|
+ const oldScrollHeight = box.scrollHeight
|
|
|
|
|
+
|
|
|
|
|
+ getChatRecords(queryParams).then(resp => {
|
|
|
|
|
+ if (resp.code === 0 && resp.data && resp.data.list && resp.data.list.length > 0) {
|
|
|
|
|
+ const fetchedList = resp.data.list.sort((a, b) => a.messageId - b.messageId)
|
|
|
|
|
+ // 向前合并数组
|
|
|
|
|
+ this.messageList = [...fetchedList, ...this.messageList]
|
|
|
|
|
+
|
|
|
|
|
+ // 体验守护:计算滚动差并悄悄复位,防画面向下闪跳
|
|
|
|
|
+ this.$nextTick(() => {
|
|
|
|
|
+ box.scrollTop = box.scrollHeight - oldScrollHeight
|
|
|
|
|
+ })
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 后端没有返回更多历史,说明触顶封顶了
|
|
|
|
|
+ this.hasOlder = false
|
|
|
|
|
+ }
|
|
|
|
|
+ }).finally(() => {
|
|
|
|
|
+ this.isLoading = false
|
|
|
|
|
+ })
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 向下:边界锚点为当前渲染队列的最后一条
|
|
|
|
|
+ queryParams.messageId = this.messageList[this.messageList.length - 1].messageId
|
|
|
|
|
+
|
|
|
|
|
+ getChatRecords(queryParams).then(resp => {
|
|
|
|
|
+ if (resp.code === 0 && resp.data && resp.data.list && resp.data.list.length > 0) {
|
|
|
|
|
+ const fetchedList = resp.data.list.sort((a, b) => a.messageId - b.messageId)
|
|
|
|
|
+ // 向后合并数组
|
|
|
|
|
+ this.messageList = [...this.messageList, ...fetchedList]
|
|
|
|
|
+ this.hasNewer = resp.data.hasNext
|
|
|
|
|
+ } else {
|
|
|
|
|
+ this.hasNewer = false
|
|
|
|
|
+ }
|
|
|
|
|
+ }).finally(() => {
|
|
|
|
|
+ this.isLoading = false
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ // 锚点精确定位 DOM 渲染高度
|
|
|
|
|
+ scrollToMessage(msgId) {
|
|
|
|
|
+ this.$nextTick(() => {
|
|
|
|
|
+ const targetEl = document.getElementById(`msg-${msgId}`)
|
|
|
|
|
+ if (targetEl) {
|
|
|
|
|
+ targetEl.scrollIntoView({ block: 'center', behavior: 'auto' })
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ // 返回列表页
|
|
|
|
|
+ goBack() {
|
|
|
|
|
+ this.$router.push('/chat/list')
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ // 发送消息
|
|
|
|
|
+ sendMessage() {
|
|
|
|
|
+ if (!this.inputText.trim()) return
|
|
|
|
|
+
|
|
|
|
|
+ // 真实场景下需要调用后端的 sendMessage 接口,这里先模拟单推追加视图
|
|
|
|
|
+ const mockNewMsgId = new Date().getTime() // 零时主键
|
|
|
|
|
+ const fakeNewMsg = {
|
|
|
|
|
+ messageId: mockNewMsgId,
|
|
|
|
|
+ chatId: this.currentContact.chatId,
|
|
|
|
|
+ createdAt: new Date().toISOString(),
|
|
|
|
|
+ msgType: 1,
|
|
|
|
|
+ content: this.inputText,
|
|
|
|
|
+ sender: { userId: this.myUserId },
|
|
|
|
|
+ receiver: { userId: 0 }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ this.messageList.push(fakeNewMsg)
|
|
|
|
|
+ this.inputText = ''
|
|
|
|
|
+ this.hasNewer = false // 用户发新消息,强制切回最新视窗状态
|
|
|
|
|
+
|
|
|
|
|
+ this.$nextTick(() => {
|
|
|
|
|
+ const box = this.$refs.messageBox
|
|
|
|
|
+ box.scrollTop = box.scrollHeight // 强制置底
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ getFileIcon(type) {
|
|
|
|
|
+ const iconMap = { 5: 'el-icon-document text-word', 6: 'el-icon-s-order text-excel', 7: 'el-icon-document-checked text-pdf', 99: 'el-icon-box text-zip' }
|
|
|
|
|
+ return iconMap[type] || 'el-icon-document'
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ toggleAudio(msg, id) {
|
|
|
|
|
+ const player = this.$refs.audioPlayer
|
|
|
|
|
+ if (!player) return
|
|
|
|
|
+ if (msg.isUnread) msg.isUnread = false
|
|
|
|
|
+
|
|
|
|
|
+ if (this.currentPlayingId === id) {
|
|
|
|
|
+ player.pause()
|
|
|
|
|
+ this.currentPlayingId = null
|
|
|
|
|
+ } else {
|
|
|
|
|
+ this.currentPlayingId = id
|
|
|
|
|
+ this.currentAudioUrl = msg.objectUrl
|
|
|
|
|
+ this.$nextTick(() => {
|
|
|
|
|
+ player.load()
|
|
|
|
|
+ player.play().catch(() => { this.currentPlayingId = null })
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ onAudioEnded() { this.currentPlayingId = null },
|
|
|
|
|
+ onAudioError() { this.$message.error('语音加载失败'); this.currentPlayingId = null },
|
|
|
|
|
+
|
|
|
|
|
+ viewVideo(url) {
|
|
|
|
|
+ this.currentVideoUrl = url
|
|
|
|
|
+ this.videoDialogVisible = true
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ closeVideoPlayer() {
|
|
|
|
|
+ const videoElement = this.$refs.previewVideo
|
|
|
|
|
+ if (videoElement) {
|
|
|
|
|
+ videoElement.pause()
|
|
|
|
|
+ }
|
|
|
|
|
+ this.currentVideoUrl = ''
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ onPreview(url) {
|
|
|
|
|
+ if (!url) return
|
|
|
|
|
+ this.$viewerApi({
|
|
|
|
|
+ options: {
|
|
|
|
|
+ toolbar: true,
|
|
|
|
|
+ navbar: false,
|
|
|
|
|
+ title: false,
|
|
|
|
|
+ movable: true,
|
|
|
|
|
+ zoomable: true,
|
|
|
|
|
+ transition: true
|
|
|
|
|
+ },
|
|
|
|
|
+ images: [url]
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<style scoped>
|
|
|
|
|
+/* 核心调整:外层容器加上 overflow: hidden; */
|
|
|
|
|
+.dialogue-container {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ max-width: 450px;
|
|
|
|
|
+ height: 100vh;
|
|
|
|
|
+ margin: 0 auto;
|
|
|
|
|
+ background-color: #f3f3f3;
|
|
|
|
|
+ box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
|
|
|
|
+ overflow: hidden; /* 关键:切断一切外层溢出,禁掉外层滚动条 */
|
|
|
|
|
+ box-sizing: border-box;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* 顶部导航 */
|
|
|
|
|
+.dialogue-header {
|
|
|
|
|
+ height: 50px;
|
|
|
|
|
+ background-color: #ededed;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ padding: 0 12px;
|
|
|
|
|
+ border-bottom: 1px solid #e0e0e0;
|
|
|
|
|
+ flex-shrink: 0; /* 关键:防止高度被挤压 */
|
|
|
|
|
+}
|
|
|
|
|
+.left-action { cursor: pointer; display: flex; align-items: center; font-size: 16px; }
|
|
|
|
|
+.chat-title { font-size: 16px; font-weight: bold; }
|
|
|
|
|
+.right-icon { font-size: 18px; }
|
|
|
|
|
+
|
|
|
|
|
+/* 消息流区域:高度自适应并允许滚动 */
|
|
|
|
|
+.message-container {
|
|
|
|
|
+ flex: 1; /* 关键:自动撑满 Header 和 Footer 之间的剩余高度 */
|
|
|
|
|
+ padding: 16px;
|
|
|
|
|
+ overflow-y: auto; /* 关键:只有这里允许出现垂直滚动条 */
|
|
|
|
|
+ background-color: #f3f3f3;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.message-row {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ margin-bottom: 18px;
|
|
|
|
|
+}
|
|
|
|
|
+.message-content {
|
|
|
|
|
+ max-width: 70%;
|
|
|
|
|
+ margin: 0 10px;
|
|
|
|
|
+}
|
|
|
|
|
+.msg-other { flex-direction: row; }
|
|
|
|
|
+.msg-me { flex-direction: row-reverse; }
|
|
|
|
|
+
|
|
|
|
|
+/* ==================== 各种消息类型的专属样式 ==================== */
|
|
|
|
|
+.msg-text {
|
|
|
|
|
+ padding: 10px 12px;
|
|
|
|
|
+ border-radius: 4px;
|
|
|
|
|
+ font-size: 15px;
|
|
|
|
|
+ line-height: 1.4;
|
|
|
|
|
+ word-break: break-all;
|
|
|
|
|
+}
|
|
|
|
|
+.msg-other .msg-text { background-color: #fff; color: #000; }
|
|
|
|
|
+.msg-me .msg-text { background-color: #95ec69; color: #000; }
|
|
|
|
|
+
|
|
|
|
|
+.msg-image {
|
|
|
|
|
+ cursor: pointer; /* 鼠标悬停时显示小手 */
|
|
|
|
|
+}
|
|
|
|
|
+.msg-image .el-image {
|
|
|
|
|
+ max-width: 150px;
|
|
|
|
|
+ max-height: 200px;
|
|
|
|
|
+ border-radius: 4px;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ display: block;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.msg-audio {
|
|
|
|
|
+ padding: 10px 14px;
|
|
|
|
|
+ border-radius: 4px;
|
|
|
|
|
+ font-size: 15px;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ min-width: 60px;
|
|
|
|
|
+ box-sizing: border-box;
|
|
|
|
|
+}
|
|
|
|
|
+.msg-other .msg-audio { background-color: #fff; }
|
|
|
|
|
+.msg-me .msg-audio { background-color: #95ec69; flex-direction: row-reverse; }
|
|
|
|
|
+.audio-icon { font-size: 16px; }
|
|
|
|
|
+.audio-duration { margin: 0 6px; color: #666; font-size: 13px; }
|
|
|
|
|
+.audio-unread-dot {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ top: 50%;
|
|
|
|
|
+ right: -15px;
|
|
|
|
|
+ transform: translateY(-50%);
|
|
|
|
|
+ width: 8px;
|
|
|
|
|
+ height: 8px;
|
|
|
|
|
+ background-color: #fa5151;
|
|
|
|
|
+ border-radius: 50%;
|
|
|
|
|
+}
|
|
|
|
|
+.msg-me .audio-unread-dot { display: none; }
|
|
|
|
|
+
|
|
|
|
|
+.msg-video video {
|
|
|
|
|
+ max-width: 200px;
|
|
|
|
|
+ border-radius: 4px;
|
|
|
|
|
+ background-color: #000;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.msg-file {
|
|
|
|
|
+ background-color: #fff;
|
|
|
|
|
+ padding: 12px;
|
|
|
|
|
+ border-radius: 4px;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ width: 220px;
|
|
|
|
|
+ border: 1px solid #e4e4e4;
|
|
|
|
|
+ box-sizing: border-box;
|
|
|
|
|
+}
|
|
|
|
|
+.file-info { flex: 1; margin-right: 10px; overflow: hidden; }
|
|
|
|
|
+.file-name { font-size: 14px; color: #333; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 4px; }
|
|
|
|
|
+.file-size { font-size: 12px; color: #999; }
|
|
|
|
|
+.file-icon-box { font-size: 32px; }
|
|
|
|
|
+.text-word { color: #2b579a; }
|
|
|
|
|
+.text-excel { color: #217346; }
|
|
|
|
|
+.text-pdf { color: #d9383a; }
|
|
|
|
|
+.text-zip { color: #f39c12; }
|
|
|
|
|
+
|
|
|
|
|
+.msg-record {
|
|
|
|
|
+ background-color: #fff;
|
|
|
|
|
+ padding: 10px 12px;
|
|
|
|
|
+ border-radius: 4px;
|
|
|
|
|
+ border: 1px solid #e4e4e4;
|
|
|
|
|
+ width: 200px;
|
|
|
|
|
+ box-sizing: border-box;
|
|
|
|
|
+}
|
|
|
|
|
+.record-title { font-size: 14px; color: #333; font-weight: 500; padding-bottom: 6px; border-bottom: 1px solid #f2f2f2; margin-bottom: 6px; }
|
|
|
|
|
+.record-preview { font-size: 12px; color: #777; margin-bottom: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
|
|
|
+
|
|
|
|
|
+/* 底部输入框 */
|
|
|
|
|
+.input-container {
|
|
|
|
|
+ background-color: #f7f7f7;
|
|
|
|
|
+ border-top: 1px solid #e0e0e0;
|
|
|
|
|
+ padding: 8px 12px;
|
|
|
|
|
+ padding-bottom: calc(8px + env(safe-area-inset-bottom));
|
|
|
|
|
+ flex-shrink: 0; /* 关键:防止高度被挤压 */
|
|
|
|
|
+}
|
|
|
|
|
+.tool-bar { display: flex; align-items: center; }
|
|
|
|
|
+.tool-bar i { font-size: 24px; margin: 0 8px; color: #2c3e50; }
|
|
|
|
|
+.send-btn { margin-left: 8px; }
|
|
|
|
|
+
|
|
|
|
|
+.loading-tips {
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+ color: #a0a0a0;
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+ padding: 10px 0;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* 4. 视频消息气泡(封面及图标控制) */
|
|
|
|
|
+.msg-video {
|
|
|
|
|
+ position: relative;
|
|
|
|
|
+ max-width: 150px;
|
|
|
|
|
+ max-height: 200px;
|
|
|
|
|
+ border-radius: 4px;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ display: block;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.video-cover {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ height: 100%;
|
|
|
|
|
+ display: block;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* 覆盖在封面上层的播放按钮遮罩 */
|
|
|
|
|
+.video-play-mask {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ top: 0;
|
|
|
|
|
+ left: 0;
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ height: 100%;
|
|
|
|
|
+ background-color: rgba(0, 0, 0, 0.15); /* 淡淡的黑色滤镜,突出白色图标 */
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+ transition: background-color 0.2s;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.video-play-mask:hover {
|
|
|
|
|
+ background-color: rgba(0, 0, 0, 0.3); /* 鼠标悬停加深 */
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.play-icon {
|
|
|
|
|
+ font-size: 40px;
|
|
|
|
|
+ color: rgba(255, 255, 255, 0.85); /* 微信经典的半透明白 */
|
|
|
|
|
+ text-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ==================== 弹窗播放器专属样式 ==================== */
|
|
|
|
|
+/* 如果要修改 Element UI Dialog 的内部边距,可以使用深度作用选择器(如有必要) */
|
|
|
|
|
+::v-deep .video-player-dialog {
|
|
|
|
|
+ background: transparent !important; /* 微信风格:纯黑或全透明背景 */
|
|
|
|
|
+ box-shadow: none !important;
|
|
|
|
|
+}
|
|
|
|
|
+::v-deep .video-player-dialog .el-dialog__header {
|
|
|
|
|
+ padding: 0; /* 隐藏弹窗头部 */
|
|
|
|
|
+}
|
|
|
|
|
+::v-deep .video-player-dialog .el-dialog__body {
|
|
|
|
|
+ padding: 0; /* 让视频填满弹窗 */
|
|
|
|
|
+}
|
|
|
|
|
+::v-deep .video-player-dialog .el-dialog__headerbtn {
|
|
|
|
|
+ top: -30px; /* 把右上角关闭按钮提上去,不遮挡视频 */
|
|
|
|
|
+ font-size: 24px;
|
|
|
|
|
+}
|
|
|
|
|
+::v-deep .video-player-dialog .el-dialog__headerbtn .el-dialog__close {
|
|
|
|
|
+ color: #fff; /* 关闭按钮设为白色 */
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.dialog-video-wrapper {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ background-color: #000;
|
|
|
|
|
+ border-radius: 4px;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.preview-video-element {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ max-height: 80vh; /* 限制视频最大高度不要超过屏幕的 80% */
|
|
|
|
|
+ display: block;
|
|
|
|
|
+ object-fit: contain; /* 保持视频原本的宽高比 */
|
|
|
|
|
+}
|
|
|
|
|
+</style>
|