reghao 1 тиждень тому
батько
коміт
10e215579f

+ 18 - 5
src/api/chat.js

@@ -3,21 +3,34 @@ import { get, post, delete0 } from '@/utils/request'
 const chatApi = {
   contactApi: '/api/chat/contact',
   chatRecordApi: '/api/chat/talk/records',
-  dialogueApi: '/api/chat/dialogue'
+  dialogueApi: '/api/chat/dialogue',
+  userApi: '/api/chat/user'
 }
 
-export function getContact(queryParams) {
-  return post(chatApi.translateApi, queryParams)
+export function getContacts() {
+  return get(chatApi.contactApi)
 }
 
 export function getChatRecords(queryParams) {
   return get(chatApi.chatRecordApi, queryParams)
 }
 
-export function getChatDialogueCount() {
-  return get(chatApi.dialogueApi + '/total')
+export function getChatUnreadCount() {
+  return get(chatApi.dialogueApi + '/unread_total')
 }
 
 export function getChatDialogues() {
   return get(chatApi.dialogueApi + '/list')
 }
+
+export function getChatDialogue(queryParams) {
+  return get(chatApi.dialogueApi, queryParams)
+}
+
+export function updateChatDialogue(payload) {
+  return post(chatApi.dialogueApi + '/update', payload)
+}
+
+export function getChatUser(queryParams) {
+  return get(chatApi.userApi, queryParams)
+}

+ 6 - 6
src/views/chat/Chat.vue

@@ -19,7 +19,7 @@
         :class="['nav-item', { active: $route.path === '/chat/list' }]"
         @click="switchTab('/chat/list')"
       >
-        <el-badge :value="dialogueCount" :max="99" class="nav-badge">
+        <el-badge :value="unreadCount" :max="99" :hidden="unreadCount <= 0" class="nav-badge">
           <i class="el-icon-chat-dot-square" />
         </el-badge>
         <span>微信</span>
@@ -45,27 +45,27 @@
 </template>
 
 <script>
-import { getChatDialogueCount } from '@/api/chat'
+import { getChatUnreadCount } from '@/api/chat'
 
 export default {
   name: 'Chat',
   data() {
     return {
-      dialogueCount: 0
+      unreadCount: 0
     }
   },
   computed: {
     currentTitle() {
-      if (this.$route.path.includes('list')) return this.dialogueCount === 0 ? '微信' : '微信(' + this.dialogueCount + ')'
+      if (this.$route.path.includes('list')) return this.unreadCount === 0 ? '微信' : '微信(' + this.unreadCount + ')'
       if (this.$route.path.includes('contact')) return '通讯录'
       if (this.$route.path.includes('me')) return '我'
       return '微信'
     }
   },
   created() {
-    getChatDialogueCount().then(resp => {
+    getChatUnreadCount().then(resp => {
       if (resp.code === 0) {
-        this.dialogueCount = resp.data
+        this.unreadCount = resp.data
       }
     })
   },

+ 260 - 16
src/views/chat/ChatAddressBook.vue

@@ -1,29 +1,91 @@
 <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 class="address-book-wrapper">
+    <div v-if="viewMode === 'list'" 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.userId"
+          class="contact-item"
+          @click="enterUserDetail(user)"
+        >
+          <el-avatar :size="36" shape="square" :src="user.avatarUrl" />
+          <span class="contact-name">{{ user.screenName }}</span>
         </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 v-else-if="viewMode === 'detail' && selectedUser" class="wechat-detail-page">
+      <header class="detail-header">
+        <div class="back-btn" @click="backToList">
+          <i class="el-icon-arrow-left" />
+        </div>
+        <i class="el-icon-more right-icon" />
+      </header>
+
+      <div class="user-detail-card">
+        <div class="profile-header">
+          <div class="info-left">
+            <h2 class="user-nickname">{{ selectedUser.screenName }}</h2>
+            <p class="wechat-id">微信号:{{ selectedUser.username }}</p>
+            <p class="region-text">地区:广东 深圳</p>
+          </div>
+          <el-avatar :size="64" shape="square" :src="selectedUser.avatarUrl" class="profile-avatar" />
+        </div>
+
+        <hr class="divider">
+
+        <div class="detail-rows">
+          <div class="detail-row">
+            <span class="row-label">标签</span>
+            <span class="row-value placeholder">无</span>
+          </div>
+          <div class="detail-row">
+            <span class="row-label">朋友圈</span>
+            <div class="pyq-previews">
+              <div class="pyq-box" />
+              <div class="pyq-box" />
+            </div>
+          </div>
+          <div class="detail-row">
+            <span class="row-label">更多信息</span>
+            <span class="row-value placeholder">个性签名或出处</span>
+          </div>
+        </div>
+
+        <div class="action-buttons">
+          <el-button type="default" icon="el-icon-chat-dot-round" class="btn-wechat" @click="goToChat">
+            发消息
+          </el-button>
+          <el-button type="default" icon="el-icon-video-camera" class="btn-wechat" @click="goToDial">
+            音视频通话
+          </el-button>
+        </div>
       </div>
     </div>
   </div>
 </template>
 
 <script>
+import { getContacts } from '@/api/chat'
+
 export default {
   name: 'ChatAddressBook',
   data() {
     return {
+      // 核心控制变量:'list' 代表通讯录列表,'detail' 代表全屏用户详情
+      viewMode: 'list',
+      selectedUser: null,
+
       tools: [
         { name: '新的朋友', icon: 'el-icon-user-solid', bgColor: '#fa9d3b' },
         { name: '群聊', icon: 'el-icon-phone', bgColor: '#46c11b' },
@@ -33,26 +95,88 @@ export default {
       contacts: [
         {
           letter: 'C',
-          list: [{ name: '陈七', avatar: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png' }]
+          list: [
+            {
+              userId: 10007,
+              screenName: '陈七',
+              username: 'chenqi_99',
+              avatarUrl: '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' }
+            { userId: 10002, screenName: '李四', username: 'lisi_004', avatarUrl: 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png' },
+            { userId: 10008, screenName: '刘八', username: 'liuba_888', avatarUrl: 'https://cube.elemecdn.com/9/cbb/57175861119de33d35e44b5fed93bpng.png' }
           ]
         },
         {
           letter: 'Z',
-          list: [{ name: '张三', avatar: 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png' }]
+          list: [{ userId: 10003, screenName: '张三', username: 'zhangsan_33', avatarUrl: 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png' }]
         }
       ]
     }
+  },
+  created() {
+    this.getData()
+  },
+  methods: {
+    getData() {
+      getContacts().then(resp => {
+        if (resp.code === 0) {
+          this.contacts = resp.data
+        }
+      })
+    },
+    /**
+     * 点击联系人:保存当前用户并原地切换到详情全屏视图
+     */
+    enterUserDetail(user) {
+      this.selectedUser = user
+      this.viewMode = 'detail'
+    },
+
+    /**
+     * 点击左上角返回:切回列表视图
+     */
+    backToList() {
+      this.viewMode = 'list'
+      this.selectedUser = null
+    },
+
+    /**
+     * 联动:点击发消息跳转至实际聊天会话页面
+     */
+    goToChat() {
+      this.$router.push({
+        path: '/chat/dialogue',
+        query: {
+          chatId: this.selectedUser.chatId,
+          receiverId: this.selectedUser.userId
+        }
+      })
+      /* this.viewMode = 'list'
+      this.selectedUser = null*/
+    },
+    goToDial() {
+      /* this.viewMode = 'list'
+      this.selectedUser = null*/
+      this.$message.info('尚未实现')
+    }
   }
 }
 </script>
 
 <style scoped>
+/* 统筹外层包裹器 */
+.address-book-wrapper {
+  width: 100%;
+  min-height: 100vh;
+  background-color: #f7f7f7;
+}
+
+/* ==================== 1. 通讯录列表样式 ==================== */
 .address-book-container {
   background-color: #f7f7f7;
 }
@@ -86,11 +210,131 @@ export default {
   font-size: 16px;
   color: #191919;
 }
-/* 字母分组标题 */
 .group-letter {
   padding: 4px 16px;
   font-size: 12px;
   color: #888;
   background-color: #f7f7f7;
 }
+
+/* ==================== 2. 全屏用户详情独立视图样式 ==================== */
+.wechat-detail-page {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  min-height: 100vh;
+  background-color: #ffffff; /* 微信详情卡片底色为纯白 */
+  box-sizing: border-box;
+  padding: 0 24px 40px 24px;
+  z-index: 10; /* 确保覆盖层级 */
+}
+
+/* 详情页顶部导航栏 */
+.detail-header {
+  height: 50px;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  font-size: 20px;
+  color: #191919;
+  margin-bottom: 20px;
+}
+.back-btn {
+  cursor: pointer;
+  padding: 4px;
+}
+.right-icon {
+  cursor: pointer;
+}
+
+/* 资料卡头部主体 */
+.profile-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  margin-top: 10px;
+}
+.info-left {
+  flex: 1;
+  padding-right: 16px;
+}
+.user-nickname {
+  font-size: 22px;
+  font-weight: 600;
+  color: #191919;
+  margin: 0 0 8px 0;
+}
+.wechat-id, .region-text {
+  font-size: 13px;
+  color: #7f7f7f;
+  margin: 4px 0;
+}
+.profile-avatar {
+  border-radius: 6px;
+  flex-shrink: 0;
+}
+
+/* 模块分割线 */
+.divider {
+  border: none;
+  border-top: 1px solid #f0f0f0;
+  margin: 24px 0 16px 0;
+}
+
+/* 中部扩展功能项 */
+.detail-rows {
+  margin-bottom: 44px;
+}
+.detail-row {
+  display: flex;
+  align-items: center;
+  padding: 14px 0;
+  font-size: 14px;
+  border-bottom: 1px solid #fafafa;
+}
+.row-label {
+  width: 80px;
+  color: #191919;
+  font-weight: 500;
+}
+.row-value {
+  color: #191919;
+}
+.row-value.placeholder {
+  color: #b3b3b3;
+}
+
+/* 朋友圈微缩图缩略盒 */
+.pyq-previews {
+  display: flex;
+  gap: 6px;
+}
+.pyq-box {
+  width: 42px;
+  height: 42px;
+  background-color: #f2f2f2;
+  border-radius: 4px;
+}
+
+/* 底部功能按钮组 */
+.action-buttons {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+.btn-wechat {
+  width: 100%;
+  height: 44px;
+  margin: 0 !important;
+  font-size: 15px;
+  font-weight: 500;
+  border: none;
+  background-color: #f2f2f2;
+  color: #576b95; /* 微信经典的深蓝链接色 */
+  transition: background-color 0.1s;
+}
+.btn-wechat:active {
+  background-color: #e5e5e5;
+}
 </style>

+ 281 - 62
src/views/chat/ChatDialogue.vue

@@ -4,73 +4,80 @@
       <div class="left-action" @click="goBack">
         <i class="el-icon-arrow-left" />
       </div>
-      <span class="chat-title">{{ currentContact.nickname }}</span>
+      <span class="chat-title">{{ chatReceiver.screenName }}</span>
       <i class="el-icon-more right-icon" />
     </header>
 
     <div ref="messageBox" class="message-container" @scroll="handleScroll">
 
-      <div v-if="hasOlder" class="loading-tips">
+      <div v-if="hasOlder && isLoading && isPageLoadingOlder" class="loading-tips">
         <i class="el-icon-loading" /> 正在加载历史消息...
       </div>
 
       <div
-        v-for="msg in messageList"
-        :id="'msg-' + msg.messageId"
+        v-for="(msg, index) in formattedMessageList"
         :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-if="msg._showTimeText" class="chat-time-node">
+          <span>{{ msg._showTimeText }}</span>
+        </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
+          :id="'msg-' + msg.messageId"
+          :class="['message-row', isMe(msg.sender.userId) ? 'msg-me' : 'msg-other']"
+        >
+          <el-avatar
+            :size="38"
+            shape="square"
+            :src="isMe(msg.sender.userId) ? my.avatarUrl : (msg.sender.avatarUrl || chatReceiver.avatarUrl)"
+          />
+
+          <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 === 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
+              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>
 
-          <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 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 class="file-icon-box">
-              <i :class="getFileIcon(msg.msgType)" />
+
+            <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>
 
-          <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 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>
 
-      <div v-if="hasNewer" class="loading-tips">
+      <div v-if="hasNewer && isLoading && isPageLoadingNewer" class="loading-tips">
         <i class="el-icon-loading" /> 正在加载新消息...
       </div>
     </div>
@@ -90,7 +97,8 @@
       :src="currentAudioUrl"
       style="display: none;"
       @ended="onAudioEnded"
-      @error="onAudioError" />
+      @error="onAudioError"
+    />
 
     <el-dialog
       :visible.sync="videoDialogVisible"
@@ -115,24 +123,26 @@
 </template>
 
 <script>
-import { getChatRecords } from '@/api/chat'
+import { getChatDialogue, getChatRecords, updateChatDialogue, getChatUser } from '@/api/chat'
+import { getAuthedUser } from '@/utils/auth'
 
 export default {
   name: 'ChatDialogue',
   data() {
     return {
-      // 1. 基本配置与当前登入用户静态声明
-      myUserId: 10001, // 对应后端 receiver/sender 中的当前用户 ID,用于判断消息左右定位
-      myAvatar: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png',
+      my: null,
       inputText: '',
       currentAudioUrl: '',
       currentPlayingId: null,
       currentContact: {
-        chatId: 65422888961, // 当前会话的唯一分布式 ID
-        nickname: '1000条分页压力测试',
-        avatar: 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png'
+        chatId: 0,
+        receiverId: 0
+      },
+      chatReceiver: {
+        userId: 0,
+        screenName: '',
+        avatarUrl: ''
       },
-
       // 2. 状态控制与渲染层解耦变量
       messageList: [], // 视图层真正循环渲染的数组
       lastReadMessageId: 0, // 后端持久化记录的用户上次查看的 Anchor ID
@@ -141,18 +151,132 @@ export default {
       hasOlder: false, // 初始化默认为 true,若请求触顶且后端返回数据为空或判断到头则置为 false
       hasNewer: false, // 初始化默认为 true,向下滚动加载更新的标记
       isLoading: false, // 节流阀锁
+      isPageLoadingOlder: false, // 是否是“主动向上拉取历史”引发的加载
+      isPageLoadingNewer: false, // 是否是“主动向下拉取新消息”引发的加载
       videoDialogVisible: false,
       currentVideoUrl: ''
     }
   },
+  computed: {
+    /**
+     * 带有时间聚合标记的渲染消息列表
+     */
+    formattedMessageList() {
+      // const TIME_THRESHOLD = 5 * 60 * 1000 // 时间差阈值:5分钟(毫秒)
+      const TIME_THRESHOLD = 3 * 1000 // 时间差阈值:5分钟(毫秒)
+
+      return this.messageList.map((msg, index) => {
+        // 深拷贝或浅拷贝出一个用于渲染的新对象,避免直接污染原数组
+        const rMsg = { ...msg }
+
+        // 1. 第一条消息无条件显示时间
+        if (index === 0) {
+          rMsg._showTimeText = this.formatChatTime(rMsg.createdAt)
+          return rMsg
+        }
+
+        // 2. 计算当前消息与上一条消息的时间差
+        const prevMsg = this.messageList[index - 1]
+        const prevTime = new Date(prevMsg.createdAt).getTime()
+        const currTime = new Date(rMsg.createdAt).getTime()
+
+        if (currTime - prevTime > TIME_THRESHOLD) {
+          rMsg._showTimeText = this.formatChatTime(rMsg.createdAt)
+        } else {
+          rMsg._showTimeText = null
+        }
+
+        return rMsg
+      })
+    }
+  },
   created() {
-    // 页面创建时执行核心链条:先通过锚点初始化首屏切片
-    this.initFirstScreen()
+    this.my = getAuthedUser()
+    this.parseRouteParams()
   },
   methods: {
+    /**
+     * 仿微信聊天时间精细化格式化引擎
+     * @param {String|Date} timeStr 后端返回的 ISO 或标准时间字符串 (例如 msg.createdAt)
+     */
+    formatChatTime(timeStr) {
+      if (!timeStr) return ''
+      const date = new Date(timeStr)
+      const now = new Date()
+
+      const diffMs = now.getTime() - date.getTime()
+      const diffDays = Math.floor(diffMs / (24 * 3600 * 1000))
+
+      // 提取时分补零
+      const hours = String(date.getHours()).padStart(2, '0')
+      const minutes = String(date.getMinutes()).padStart(2, '0')
+      const timePart = `${hours}:${minutes}`
+
+      // 1. 同一天
+      if (date.toDateString() === now.toDateString()) {
+        return timePart
+      }
+
+      // 2. 昨天
+      const yesterday = new Date(now)
+      yesterday.setDate(now.getDate() - 1)
+      if (date.toDateString() === yesterday.toDateString()) {
+        return `昨天 ${timePart}`
+      }
+
+      // 3. 同一年内但超过昨天
+      if (date.getFullYear() === now.getFullYear()) {
+        return `${date.getMonth() + 1}月${date.getDate()}日 ${timePart}`
+      }
+
+      // 4. 跨年
+      return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日 ${timePart}`
+    },
+    /**
+     * 精准解析并转换路由传入的 Long 型参数
+     */
+    parseRouteParams() {
+      const query = this.$route.query
+
+      // 1. 防御性拦截:如果关键参数缺失,进行报错提示或重定向
+      if (!query.chatId || !query.receiverId) {
+        this.$message.error('会话参数缺失,正在返回列表...')
+        setTimeout(() => {
+          this.goBack()
+        }, 1500)
+        return
+      }
+
+      // 2. 类型转换 (将 String 转为 JavaScript 的标准 Number,对应 Java 的 Long)
+      // 注意:如果你的分布式雪花 ID 超过了 16 位(大于 9007199254740991),
+      // 请不要转成 Number,保持字符串进行传输,否则 JS 会丢失后三位精度!
+      this.currentContact.chatId = Number(query.chatId)
+      this.currentContact.receiverId = Number(query.receiverId)
+
+      getChatUser({ 'userId': this.currentContact.receiverId }).then(resp => {
+        if (resp.code === 0) {
+          this.chatReceiver = resp.data
+        }
+      })
+
+      // 3. 联动逻辑:如果路由中没有传 lastMessageId,则强制补 0
+      // this.lastReadMessageId = query.lastMessageId ? Number(query.lastMessageId) : 0
+      this.lastReadMessageId = 0
+      const queryParams = {}
+      queryParams.chatId = this.currentContact.chatId
+      getChatDialogue(queryParams).then(resp => {
+        if (resp.code === 0) {
+          this.lastReadMessageId = resp.data.lastMessageId
+        }
+      }).finally(() => {
+        // 4. 参数解析完成后,正式向后端发起切片请求
+        // 页面创建时执行核心链条:先通过锚点初始化首屏切片
+        this.initFirstScreen()
+      })
+    },
     // 助手函数:比对 userId 判断是否是“我”发送的
     isMe(senderId) {
-      return Number(senderId) === Number(this.myUserId)
+      return Number(senderId) === Number(this.my.userId)
     },
 
     /**
@@ -188,13 +312,44 @@ export default {
           this.hasNewer = resp.data.hasNext
 
           // 精准复位将该消息钉在可视区域正中央
-          this.scrollToMessage(this.lastReadMessageId)
+          // this.scrollToMessage(this.lastReadMessageId)
+          this.$nextTick(() => {
+            if (Number(this.lastReadMessageId) === 0) {
+              this.scrollToBottom()
+
+              if (this.messageList.length > 0) {
+                this.lastReadMessageId = this.messageList[this.messageList.length - 1].messageId
+
+                // 可选:在这里调用后端接口,异步上报已读回执/更新数据库中的 last_link_id
+                // this.reportLastReadId(this.lastReadMessageId);
+                const payload = {}
+                payload.chatId = this.currentContact.chatId
+                payload.lastMessageId = this.lastReadMessageId
+                updateChatDialogue(payload).then(resp => {
+                  if (resp.code === 0) {
+                  }
+                })
+              }
+            } else {
+              // 精准定位到特定的某条历史消息
+              this.scrollToMessage(this.lastReadMessageId)
+            }
+          })
         }
       }).finally(() => {
         this.isLoading = false
       })
     },
-
+    scrollToBottom() {
+      this.$nextTick(() => {
+        const box = this.$refs.messageBox
+        if (box) {
+          // 将滚动条拉到最底部:
+          // 盒子的总高度(包含溢出隐藏的部分)直接赋值给滚动偏移量
+          box.scrollTop = box.scrollHeight
+        }
+      })
+    },
     /**
      * 核心监听:判定触顶/触底边界
      */
@@ -215,14 +370,55 @@ export default {
       if (scrollBottom <= threshold && this.hasNewer) {
         this.loadMoreData('newer')
       }
+
+      // ==== 2. 【新增】视窗滚动时,动态捕捉当前屏幕顶部的消息作为新锚点 ====
+      // 采用防抖(Debounce)优化性能,避免滚动时频繁计算
+      clearTimeout(this.scrollTimer)
+      this.scrollTimer = setTimeout(() => {
+        this.updateCurrentAnchor()
+      }, 200)
     },
+    updateCurrentAnchor() {
+      if (this.messageList.length === 0) return
 
+      const box = this.$refs.messageBox
+      const containerTop = box.getBoundingClientRect().top
+
+      // 遍历当前渲染的所有消息 DOM 节点
+      for (const msg of this.messageList) {
+        const el = document.getElementById(`msg-${msg.messageId}`)
+        if (el) {
+          const rect = el.getBoundingClientRect()
+          // 当某个消息气泡的底部穿过了容器顶部,且其头部在可见区附近时
+          if (rect.bottom > containerTop + 10) {
+            // 这条消息就是用户当前正在看的第一条消息
+            if (this.lastReadMessageId !== msg.messageId) {
+              this.lastReadMessageId = msg.messageId
+
+              const payload = {}
+              payload.chatId = this.currentContact.chatId
+              payload.lastMessageId = this.lastReadMessageId
+              updateChatDialogue(payload).then(resp => {
+                if (resp.code === 0) {
+                }
+              })
+            }
+            break
+          }
+        }
+      }
+    },
     /**
      * 核心异步翻页:双向动态加载器
      */
     loadMoreData(direction) {
       if (this.messageList.length === 0) return
       this.isLoading = true
+      if (direction === 'older') {
+        this.isPageLoadingOlder = true // 此时顶部的“正在加载历史消息...”才会真正显示出来!
+      } else {
+        this.isPageLoadingNewer = true // 此时底部的“正在加载新消息...”才会真正显示出来!
+      }
 
       const box = this.$refs.messageBox
       const queryParams = {
@@ -251,6 +447,7 @@ export default {
           }
         }).finally(() => {
           this.isLoading = false
+          this.isPageLoadingOlder = false
         })
       } else {
         // 向下:边界锚点为当前渲染队列的最后一条
@@ -267,6 +464,7 @@ export default {
           }
         }).finally(() => {
           this.isLoading = false
+          this.isPageLoadingNewer = false
         })
       }
     },
@@ -283,6 +481,7 @@ export default {
 
     // 返回列表页
     goBack() {
+      // this.messageList = []
       this.$router.push('/chat/list')
     },
 
@@ -298,7 +497,7 @@ export default {
         createdAt: new Date().toISOString(),
         msgType: 1,
         content: this.inputText,
-        sender: { userId: this.myUserId },
+        sender: { userId: this.my.userId },
         receiver: { userId: 0 }
       }
 
@@ -311,12 +510,15 @@ export default {
         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' }
+      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
@@ -600,4 +802,21 @@ export default {
   display: block;
   object-fit: contain; /* 保持视频原本的宽高比 */
 }
+
+/* 聊天会话精细化时间节点样式 */
+.chat-time-node {
+  text-align: center;
+  margin: 14px 0 10px 0;
+  user-select: none;
+}
+
+.chat-time-node span {
+  display: inline-block;
+  background-color: rgba(0, 0, 0, 0.05); /* 淡淡的灰色胶囊背景 */
+  color: #b0b0b0;                        /* 柔和的文字颜色 */
+  font-size: 12px;
+  padding: 2px 8px;
+  border-radius: 4px;
+  letter-spacing: 0.3px;
+}
 </style>

+ 2 - 18
src/views/chat/ChatList.vue

@@ -31,22 +31,7 @@ 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 }
-      ]
+      chats: []
     }
   },
   created() {
@@ -65,8 +50,7 @@ export default {
         path: '/chat/dialogue',
         query: {
           chatId: item.chatId,
-          receiverId: item.receiver.userId,
-          lastMessageId: item.lastMessageId
+          receiverId: item.receiver.userId
         }
       })
     }