Quellcode durchsuchen

添加 views/chat 模块

reghao vor 1 Woche
Ursprung
Commit
b642ccebd4

+ 23 - 0
src/api/chat.js

@@ -0,0 +1,23 @@
+import { get, post, delete0 } from '@/utils/request'
+
+const chatApi = {
+  contactApi: '/api/chat/contact',
+  chatRecordApi: '/api/chat/talk/records',
+  dialogueApi: '/api/chat/dialogue'
+}
+
+export function getContact(queryParams) {
+  return post(chatApi.translateApi, queryParams)
+}
+
+export function getChatRecords(queryParams) {
+  return get(chatApi.chatRecordApi, queryParams)
+}
+
+export function getChatDialogueCount() {
+  return get(chatApi.dialogueApi + '/total')
+}
+
+export function getChatDialogues() {
+  return get(chatApi.dialogueApi + '/list')
+}

+ 39 - 0
src/router/chat.js

@@ -0,0 +1,39 @@
+const Chat = () => import('views/chat/Chat')
+const ChatList = () => import('views/chat/ChatList')
+const ChatDialogue = () => import('views/chat/ChatDialogue')
+const ChatAddressBook = () => import('views/chat/ChatAddressBook')
+const ChatMe = () => import('views/chat/ChatMe')
+
+export default {
+  path: '/chat',
+  name: 'Chat',
+  component: Chat,
+  redirect: '/chat/list',
+  meta: { needAuth: false },
+  children: [
+    {
+      path: '/chat/list',
+      name: 'ChatList',
+      component: ChatList,
+      meta: { title: '聊天', needAuth: false }
+    },
+    {
+      path: '/chat/dialogue',
+      name: 'ChatDialogue',
+      component: ChatDialogue,
+      meta: { title: 'xxx', needAuth: false }
+    },
+    {
+      path: '/chat/contact',
+      name: 'ChatAddressBook',
+      component: ChatAddressBook,
+      meta: { title: '通讯录', needAuth: false }
+    },
+    {
+      path: '/chat/me',
+      name: 'ChatMe',
+      component: ChatMe,
+      meta: { title: '我', needAuth: false }
+    }
+  ]
+}

+ 2 - 0
src/router/index.js

@@ -3,6 +3,7 @@ import Vue from 'vue'
 
 import DiskRouter from './disk'
 import DiskMobileRouter from './diskm'
+import ChatRouter from './chat'
 import UserRouter from './user'
 import BlogRouter from './blog'
 import AiRouter from './ai'
@@ -47,6 +48,7 @@ const Dashboard = () => import('views/admin/Dashboard')
 Vue.use(VueRouter)
 export const constantRoutes = [
   DiskMobileRouter,
+  ChatRouter,
   UserRouter,
   BlogRouter,
   AiRouter,

+ 185 - 0
src/views/chat/Chat.vue

@@ -0,0 +1,185 @@
+<template>
+  <div class="app-container">
+    <header v-if="$route.path !== '/chat/dialogue'" class="app-header">
+      <span class="title">{{ currentTitle }}</span>
+      <div class="header-icons">
+        <i class="el-icon-search" />
+        <i class="el-icon-plus" style="margin-left: 15px;" />
+      </div>
+    </header>
+
+    <main class="app-content">
+      <keep-alive>
+        <router-view />
+      </keep-alive>
+    </main>
+
+    <footer v-if="$route.path !== '/chat/dialogue'" class="app-footer">
+      <div
+        :class="['nav-item', { active: $route.path === '/chat/list' }]"
+        @click="switchTab('/chat/list')"
+      >
+        <el-badge :value="dialogueCount" :max="99" class="nav-badge">
+          <i class="el-icon-chat-dot-square" />
+        </el-badge>
+        <span>微信</span>
+      </div>
+
+      <div
+        :class="['nav-item', { active: $route.path === '/chat/contact' }]"
+        @click="switchTab('/chat/contact')"
+      >
+        <i class="el-icon-notebook-2" />
+        <span>通讯录</span>
+      </div>
+
+      <div
+        :class="['nav-item', { active: $route.path === '/chat/me' }]"
+        @click="switchTab('/chat/me')"
+      >
+        <i class="el-icon-user" />
+        <span>我</span>
+      </div>
+    </footer>
+  </div>
+</template>
+
+<script>
+import { getChatDialogueCount } from '@/api/chat'
+
+export default {
+  name: 'Chat',
+  data() {
+    return {
+      dialogueCount: 0
+    }
+  },
+  computed: {
+    currentTitle() {
+      if (this.$route.path.includes('list')) return this.dialogueCount === 0 ? '微信' : '微信(' + this.dialogueCount + ')'
+      if (this.$route.path.includes('contact')) return '通讯录'
+      if (this.$route.path.includes('me')) return '我'
+      return '微信'
+    }
+  },
+  created() {
+    getChatDialogueCount().then(resp => {
+      if (resp.code === 0) {
+        this.dialogueCount = resp.data
+      }
+    })
+  },
+  methods: {
+    switchTab(path) {
+      if (this.$route.path !== path) {
+        this.$router.push(path)
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+/* 全局容器,模拟手机屏幕大小,也可以直接占满全屏 */
+.app-container {
+  display: flex;
+  flex-direction: column;
+  width: 100%;
+  max-width: 450px;
+  height: 100vh;
+  margin: 0 auto;
+  overflow: hidden;
+}
+
+/* 顶部标题栏 */
+.app-header {
+  height: 50px;
+  background-color: #ededed;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 0 16px;
+  border-bottom: 1px solid #e0e0e0;
+}
+
+.app-header .title {
+  font-size: 16px;
+  font-weight: bold;
+  color: #181818;
+}
+
+.app-header .header-icons {
+  font-size: 18px;
+  color: #181818;
+  cursor: pointer;
+}
+
+/* 中间主内容区:自动撑满,超长滚动 */
+.app-content {
+  flex: 1;
+  display: flex;         /* 加上这一行 */
+  flex-direction: column;/* 加上这一行 */
+  overflow: hidden;      /* 加上这一行,防止子组件溢出到大框架外面 */
+  background-color: #f7f7f7;
+}
+
+.page-mock {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  height: 80%;
+  color: #888;
+  font-size: 16px;
+}
+
+.sub-text {
+  font-size: 12px;
+  color: #b0b0b0;
+  margin-top: 10px;
+}
+
+/* 底部导航栏 */
+.app-footer {
+  height: 55px;
+  background-color: #f7f7f7;
+  border-top: 1px solid #e0e0e0;
+  display: flex;
+  justify-content: space-around;
+  align-items: center;
+  padding-bottom: env(safe-area-inset-bottom); /* 适配全面屏底部黑条 */
+}
+
+/* 单个导航项 */
+.nav-item {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  color: #575757;
+  cursor: pointer;
+  flex: 1;
+  height: 100%;
+  font-size: 10px; /* 微信风格的小字 */
+}
+
+.nav-item i {
+  font-size: 22px; /* 图标稍大 */
+  margin-bottom: 2px;
+  transition: color 0.2s;
+}
+
+/* 选中激活状态 */
+.nav-item.active {
+  color: #07c160; /* 微信经典绿 */
+}
+
+/* 微信未读消息红点微调 */
+.nav-badge ::v-deep .el-badge__content.is-fixed {
+  top: 5px;
+  right: 5px;
+  padding: 0 4px;
+  height: 16px;
+  line-height: 16px;
+}
+</style>

+ 96 - 0
src/views/chat/ChatAddressBook.vue

@@ -0,0 +1,96 @@
+<template>
+  <div class="address-book-container">
+    <div class="fixed-tools">
+      <div v-for="tool in tools" :key="tool.name" class="tool-item">
+        <div class="icon-wrapper" :style="{ backgroundColor: tool.bgColor }">
+          <i :class="tool.icon" />
+        </div>
+        <span class="tool-name">{{ tool.name }}</span>
+      </div>
+    </div>
+
+    <div v-for="group in contacts" :key="group.letter" class="contacts-group">
+      <div class="group-letter">{{ group.letter }}</div>
+      <div v-for="user in group.list" :key="user.name" class="contact-item">
+        <el-avatar :size="36" shape="square" :src="user.avatar" />
+        <span class="contact-name">{{ user.name }}</span>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'ChatAddressBook',
+  data() {
+    return {
+      tools: [
+        { name: '新的朋友', icon: 'el-icon-user-solid', bgColor: '#fa9d3b' },
+        { name: '群聊', icon: 'el-icon-phone', bgColor: '#46c11b' },
+        { name: '标签', icon: 'el-icon-collection-tag', bgColor: '#3cc51f' },
+        { name: '公众号', icon: 'el-icon-s-promotion', bgColor: '#0bb20c' }
+      ],
+      contacts: [
+        {
+          letter: 'C',
+          list: [{ name: '陈七', avatar: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png' }]
+        },
+        {
+          letter: 'L',
+          list: [
+            { name: '李四', avatar: 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png' },
+            { name: '刘八', avatar: 'https://cube.elemecdn.com/9/cbb/57175861119de33d35e44b5fed93bpng.png' }
+          ]
+        },
+        {
+          letter: 'Z',
+          list: [{ name: '张三', avatar: 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png' }]
+        }
+      ]
+    }
+  }
+}
+</script>
+
+<style scoped>
+.address-book-container {
+  background-color: #f7f7f7;
+}
+.fixed-tools {
+  background-color: #fff;
+  margin-bottom: 8px;
+}
+.tool-item, .contact-item {
+  display: flex;
+  align-items: center;
+  padding: 10px 16px;
+  border-bottom: 1px solid #f2f2f2;
+  background-color: #fff;
+  cursor: pointer;
+}
+.tool-item:active, .contact-item:active {
+  background-color: #f5f5f5;
+}
+.icon-wrapper {
+  width: 36px;
+  height: 36px;
+  border-radius: 4px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  color: #fff;
+  font-size: 20px;
+}
+.tool-name, .contact-name {
+  margin-left: 14px;
+  font-size: 16px;
+  color: #191919;
+}
+/* 字母分组标题 */
+.group-letter {
+  padding: 4px 16px;
+  font-size: 12px;
+  color: #888;
+  background-color: #f7f7f7;
+}
+</style>

+ 603 - 0
src/views/chat/ChatDialogue.vue

@@ -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>

+ 135 - 0
src/views/chat/ChatList.vue

@@ -0,0 +1,135 @@
+<template>
+  <div class="chat-list-container">
+    <div
+      v-for="item in chats"
+      :key="item.chatId"
+      class="chat-item"
+      @click="openChat(item)"
+    >
+      <el-badge :value="item.unread" :max="99" :hidden="item.unread === 0" class="avatar-badge">
+        <el-avatar :size="48" shape="square" :src="item.receiver.avatarUrl" />
+      </el-badge>
+
+      <div class="chat-info">
+        <div class="chat-top">
+          <span class="nickname">{{ item.receiver.screenName }}</span>
+          <span class="time">{{ item.lastMessageTime }}</span>
+        </div>
+        <div class="chat-bottom">
+          <span class="last-msg">{{ item.lastMessageContent }}</span>
+          <i v-if="item.muted" class="el-icon-bell-off mute-icon" />
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import { getChatDialogues } from '@/api/chat'
+
+export default {
+  name: 'ChatList',
+  data() {
+    return {
+      chats: [
+        {
+          id: 1,
+          nickname: '文件传输助手',
+          avatar: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png',
+          lastMessageId: 0,
+          lastMessageContent: '',
+          lastMessageTime: '',
+          lastMsg: '[文件] 2026工作总结.pdf',
+          time: '10:24',
+          unread: 0,
+          muted: false
+        },
+        { id: 2, nickname: '张三', avatar: 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png', lastMsg: '好勒,明天见!', time: '09:15', unread: 2, muted: false },
+        { id: 3, nickname: '微信官方团队', avatar: 'https://cube.elemecdn.com/9/cbb/57175861119de33d35e44b5fed93bpng.png', lastMsg: '欢迎使用微信安全中心。', time: '昨天', unread: 0, muted: true }
+      ]
+    }
+  },
+  created() {
+    this.getData()
+  },
+  methods: {
+    getData() {
+      getChatDialogues().then(resp => {
+        if (resp.code === 0) {
+          this.chats = resp.data
+        }
+      })
+    },
+    openChat(item) {
+      this.$router.push({
+        path: '/chat/dialogue',
+        query: {
+          chatId: item.chatId,
+          receiverId: item.receiver.userId,
+          lastMessageId: item.lastMessageId
+        }
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.chat-list-container {
+  background-color: #fff;
+}
+.chat-item {
+  display: flex;
+  padding: 12px 16px;
+  border-bottom: 1px solid #f2f2f2;
+  cursor: pointer;
+  background-color: #fff;
+}
+.chat-item:active {
+  background-color: #f5f5f5; /* 模拟点击态 */
+}
+.avatar-badge {
+  flex-shrink: 0;
+}
+.chat-info {
+  flex: 1;
+  margin-left: 12px;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  overflow: hidden;
+}
+.chat-top {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 4px;
+}
+.nickname {
+  font-size: 16px;
+  color: #191919;
+  font-weight: 500;
+}
+.time {
+  font-size: 12px;
+  color: #b2b2b2;
+}
+.chat-bottom {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+.last-msg {
+  font-size: 14px;
+  color: #999;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  flex: 1;
+}
+.mute-icon {
+  font-size: 14px;
+  color: #ccc;
+  margin-left: 5px;
+}
+</style>

+ 119 - 0
src/views/chat/ChatMe.vue

@@ -0,0 +1,119 @@
+<template>
+  <div class="me-container">
+    <div class="profile-card">
+      <el-avatar :size="60" shape="square" src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png" />
+      <div class="profile-info">
+        <div class="name">我的微信</div>
+        <div class="wechat-id">微信号: ai_collaborator_2026</div>
+      </div>
+      <i class="el-icon-arrow-right arrow" />
+    </div>
+
+    <div class="menu-group">
+      <div class="menu-item">
+        <i class="el-icon-wallet icon-service" />
+        <span class="menu-title">服务</span>
+        <i class="el-icon-arrow-right arrow" />
+      </div>
+    </div>
+
+    <div class="menu-group">
+      <div class="menu-item">
+        <i class="el-icon-star-off icon-collect" />
+        <span class="menu-title">收藏</span>
+        <i class="el-icon-arrow-right arrow" />
+      </div>
+      <div class="menu-item">
+        <i class="el-icon-picture-outline icon-album" />
+        <span class="menu-title">朋友圈</span>
+        <i class="el-icon-arrow-right arrow" />
+      </div>
+      <div class="menu-item">
+        <i class="el-icon-tickets icon-card" />
+        <span class="menu-title">卡包</span>
+        <i class="el-icon-arrow-right arrow" />
+      </div>
+    </div>
+
+    <div class="menu-group">
+      <div class="menu-item">
+        <i class="el-icon-setting icon-setting" />
+        <span class="menu-title">设置</span>
+        <i class="el-icon-arrow-right arrow" />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'ChatMe'
+}
+</script>
+
+<style scoped>
+.me-container {
+  background-color: #ededed;
+  height: 100%;
+}
+/* 个人卡片 */
+.profile-card {
+  display: flex;
+  align-items: center;
+  background-color: #fff;
+  padding: 24px 16px;
+  margin-bottom: 8px;
+  cursor: pointer;
+}
+.profile-info {
+  flex: 1;
+  margin-left: 16px;
+}
+.profile-info .name {
+  font-size: 20px;
+  font-weight: bold;
+  color: #191919;
+  margin-bottom: 6px;
+}
+.profile-info .wechat-id {
+  font-size: 14px;
+  color: #7f7f7f;
+}
+/* 菜单组 */
+.menu-group {
+  background-color: #fff;
+  margin-bottom: 8px;
+}
+.menu-item {
+  display: flex;
+  align-items: center;
+  padding: 14px 16px;
+  border-bottom: 1px solid #f2f2f2;
+  cursor: pointer;
+}
+.menu-item:last-child {
+  border-bottom: none;
+}
+.menu-item:active {
+  background-color: #f5f5f5;
+}
+.menu-item i:first-child {
+  font-size: 20px;
+  margin-right: 14px;
+}
+.menu-title {
+  flex: 1;
+  font-size: 16px;
+  color: #191919;
+}
+.arrow {
+  color: #c7c7cc;
+  font-size: 14px;
+}
+/* 图标颜色定制 */
+.icon-service { color: #07c160; }
+.icon-collect { color: #f4b03e; }
+.icon-album { color: #2d8cf0; }
+.icon-card { color: #51a0f2; }
+.icon-setting { color: #46c11b; }
+</style>