feat:修复聊天界面消息位置展示错误问题

This commit is contained in:
bujinyuan
2026-03-03 13:46:03 +08:00
parent 35788f6d2f
commit 7b1d061955
3 changed files with 144 additions and 205 deletions

View File

@@ -1,7 +1,7 @@
# EchoChat 项目开发进度 # EchoChat 项目开发进度
> **最后更新**2026-03-03Phase 2b 即时通讯核心开发完成) > **最后更新**2026-03-03Phase 2b 即时通讯核心 + ui-ux-pro-max 规范改造完成)
> **当前阶段**Phase 2b 开发中 > **当前阶段**Phase 2b 已完成
> **当前分支**`feature/phase2b-instant-messaging` > **当前分支**`feature/phase2b-instant-messaging`
> **实施计划**`docs/plans/2026-03-03-phase2b-implementation.plan.md` > **实施计划**`docs/plans/2026-03-03-phase2b-implementation.plan.md`
> **设计文档**`docs/plans/2026-03-03-phase2b-design.md` > **设计文档**`docs/plans/2026-03-03-phase2b-design.md`
@@ -22,6 +22,7 @@
| Task 7 | 会话列表页 + 聊天对话页 | ✅ 完成 | 2 个核心页面 | | Task 7 | 会话列表页 + 聊天对话页 | ✅ 完成 | 2 个核心页面 |
| Task 8 | 设置页 + 搜索页 + 联系人改造 | ✅ 完成 | 2 个辅助页面 + 发消息跳转 | | Task 8 | 设置页 + 搜索页 + 联系人改造 | ✅ 完成 | 2 个辅助页面 + 发消息跳转 |
| Task 9 | 文档更新 + 代码审查 | ✅ 完成 | 进度/架构文档同步 | | Task 9 | 文档更新 + 代码审查 | ✅ 完成 | 进度/架构文档同步 |
| UI 改造 | ui-ux-pro-max 规范改造 | ✅ 完成 | uni-icons 替换 emoji + 设计规范文件 |
--- ---
@@ -112,7 +113,9 @@
2. **状态管理**Pinia 2.1.7 + pinia-plugin-persistedstate@3 2. **状态管理**Pinia 2.1.7 + pinia-plugin-persistedstate@3
3. **WebSocket**`uni.connectSocket`(小程序)/ `WebSocket`H5 3. **WebSocket**`uni.connectSocket`(小程序)/ `WebSocket`H5
4. **IM Store**chat.js会话列表 + 消息缓存 + 三态确认 + 全局未读) 4. **IM Store**chat.js会话列表 + 消息缓存 + 三态确认 + 全局未读)
5. **设计系统**ui-ux-pro-max 规范 5. **设计系统**ui-ux-pro-max 规范MASTER.md + 页面覆盖规范
6. **图标方案**@dcloudio/uni-ui uni-iconseasycom 自动引入,跨平台兼容)
7. **预处理器**sassuni-icons SCSS 依赖)
### 后台管理端admin/ ### 后台管理端admin/
1. **框架**Vue 3.5+ + Vite 7.x + Element Plus 1. **框架**Vue 3.5+ + Vite 7.x + Element Plus

View File

@@ -1,21 +1,14 @@
<!-- <!--
聊天对话页 聊天对话页
设计系统design-system/echochat/MASTER.md 布局规则
页面覆盖design-system/echochat/pages/chat-conversation.md - 自己的消息靠右对齐顺序 [状态图标] [蓝色气泡] [自己头像]
图标方案@dcloudio/uni-ui uni-icons - 对方的消息靠左对齐顺序 [对方头像] [白色气泡]
色板Primary #2563EB / BG #F1F5F9 / Text #1E293B - 导航栏返回 / 对方昵称 / 更多
功能
- 消息气泡左侧对方 / 右侧自己
- 游标分页加载历史消息
- 消息发送三态sending sent failed
- 正在输入提示
- 消息撤回长按自己的消息
--> -->
<template> <template>
<view class="page-wrapper"> <view class="page-wrapper">
<!-- 自定义导航栏 --> <!-- 导航栏 -->
<view class="nav-bar"> <view class="nav-bar">
<view class="nav-left" @tap="goBack"> <view class="nav-left" @tap="goBack">
<uni-icons type="back" size="20" color="#1E293B" /> <uni-icons type="back" size="20" color="#1E293B" />
@@ -37,54 +30,64 @@
:scroll-with-animation="true" :scroll-with-animation="true"
@scrolltoupper="onLoadMore" @scrolltoupper="onLoadMore"
> >
<!-- 加载更多提示 -->
<view v-if="hasMore" class="load-more" @tap="onLoadMore"> <view v-if="hasMore" class="load-more" @tap="onLoadMore">
<text class="load-more-text">{{ loadingMore ? '加载中...' : '上拉加载更多' }}</text> <text class="load-more-text">{{ loadingMore ? '加载中...' : '加载更多' }}</text>
</view> </view>
<!-- 消息气泡 -->
<view <view
v-for="msg in messages" v-for="msg in messages"
:key="msg.client_msg_id || msg.id" :key="msg.client_msg_id || msg.id"
:id="'msg-' + (msg.id || msg.client_msg_id)" :id="'msg-' + (msg.id || msg.client_msg_id)"
class="msg-row" class="msg-row"
:class="{ 'msg-row-self': isSelf(msg), 'msg-row-other': !isSelf(msg) }" :class="isSelf(msg) ? 'msg-row-self' : 'msg-row-other'"
> >
<!-- 对方头像 --> <!-- ====== 对方消息左侧[头像] [气泡] ====== -->
<view v-if="!isSelf(msg)" class="msg-avatar-wrap"> <template v-if="!isSelf(msg)">
<image v-if="peerAvatar" class="msg-avatar" :src="peerAvatar" mode="aspectFill" /> <view class="avatar-wrap">
<view v-else class="msg-avatar msg-avatar-placeholder"> <image v-if="peerAvatar" class="avatar-img" :src="peerAvatar" mode="aspectFill" />
<text class="msg-avatar-text">{{ (peerName || '?')[0] }}</text> <view v-else class="avatar-img avatar-placeholder avatar-peer">
<text class="avatar-char">{{ (peerName || '?')[0] }}</text>
</view> </view>
</view> </view>
<!-- 消息内容 -->
<view <view
class="msg-bubble" class="bubble bubble-other"
:class="{ :class="{ 'bubble-recalled': msg.status === 2 }"
'bubble-self': isSelf(msg),
'bubble-other': !isSelf(msg),
'bubble-recalled': msg.status === 2
}"
@longpress="onMsgLongPress(msg)" @longpress="onMsgLongPress(msg)"
> >
<text v-if="msg.status === 2" class="msg-recalled">消息已撤回</text> <text v-if="msg.status === 2" class="recalled-text">消息已撤回</text>
<text v-else class="msg-text">{{ msg.content }}</text> <text v-else class="msg-text">{{ msg.content }}</text>
</view> </view>
</template>
<!-- 发送状态 --> <!-- ====== 自己消息右侧[状态] [气泡] [头像] ====== -->
<view v-if="isSelf(msg) && msg._sending" class="msg-status"> <template v-else>
<view v-if="msg._sending" class="msg-status">
<uni-icons type="loop" size="16" color="#94A3B8" /> <uni-icons type="loop" size="16" color="#94A3B8" />
</view> </view>
<view v-if="isSelf(msg) && msg._failed" class="msg-status msg-status-tap" @tap="onResend(msg)"> <view v-if="msg._failed" class="msg-status msg-status-tap" @tap="onResend(msg)">
<uni-icons type="info-filled" size="18" color="#EF4444" /> <uni-icons type="info-filled" size="18" color="#EF4444" />
</view> </view>
<view
class="bubble bubble-self"
:class="{ 'bubble-recalled': msg.status === 2 }"
@longpress="onMsgLongPress(msg)"
>
<text v-if="msg.status === 2" class="recalled-text">消息已撤回</text>
<text v-else class="msg-text msg-text-self">{{ msg.content }}</text>
</view>
<view class="avatar-wrap">
<image v-if="selfAvatar" class="avatar-img" :src="selfAvatar" mode="aspectFill" />
<view v-else class="avatar-img avatar-placeholder avatar-self">
<text class="avatar-char">{{ (selfName || '我')[0] }}</text>
</view>
</view>
</template>
</view> </view>
<view id="msg-bottom" style="height: 2rpx;" /> <view id="msg-bottom" style="height: 2rpx;" />
</scroll-view> </scroll-view>
<!-- 底部输入 --> <!-- 输入 -->
<view class="input-bar"> <view class="input-bar">
<view class="input-wrap"> <view class="input-wrap">
<input <input
@@ -107,7 +110,7 @@
<script> <script>
import { onLoad, onUnload } from '@dcloudio/uni-app' import { onLoad, onUnload } from '@dcloudio/uni-app'
import { ref, computed, nextTick } from 'vue' import { ref, computed, nextTick, watch } from 'vue'
import { useChatStore } from '@/store/chat' import { useChatStore } from '@/store/chat'
import { useUserStore } from '@/store/user' import { useUserStore } from '@/store/user'
@@ -128,9 +131,26 @@ export default {
const messages = computed(() => chatStore.currentMessages) const messages = computed(() => chatStore.currentMessages)
const hasMore = computed(() => chatStore.hasMoreMap[conversationId.value] !== false) const hasMore = computed(() => chatStore.hasMoreMap[conversationId.value] !== false)
const isTyping = computed(() => chatStore.typingMap[conversationId.value] || false) const isTyping = computed(() => chatStore.typingMap[conversationId.value] || false)
const selfAvatar = computed(() => userStore.userInfo?.avatar || '')
const selfName = computed(() => userStore.userInfo?.nickname || userStore.userInfo?.username || '我')
const isSelf = (msg) => { const isSelf = (msg) => {
return msg.sender_id === (userStore.userInfo?.id || 0) || msg._sending const myId = Number(userStore.userInfo?.id) || 0
return msg.sender_id === myId || msg._sending === true || msg._failed === true
}
const tryFindExistingConversation = async () => {
try {
await chatStore.fetchConversations()
const existingConv = chatStore.conversationList.find(c => c.peer_user_id === peerId.value)
if (existingConv) {
conversationId.value = existingConv.id
chatStore.setCurrentConversation(existingConv.id)
await loadInitialMessages()
}
} catch (e) {
console.warn('[Chat] 查找已有会话失败', e)
}
} }
onLoad((query) => { onLoad((query) => {
@@ -144,6 +164,8 @@ export default {
if (conversationId.value) { if (conversationId.value) {
chatStore.setCurrentConversation(conversationId.value) chatStore.setCurrentConversation(conversationId.value)
loadInitialMessages() loadInitialMessages()
} else if (peerId.value) {
tryFindExistingConversation()
} }
}) })
@@ -151,6 +173,12 @@ export default {
chatStore.setCurrentConversation(null) chatStore.setCurrentConversation(null)
}) })
watch(() => chatStore.currentConversationId, (newId) => {
if (newId && newId !== conversationId.value) {
conversationId.value = newId
}
})
const loadInitialMessages = async () => { const loadInitialMessages = async () => {
if (!chatStore.messagesMap[conversationId.value]?.length) { if (!chatStore.messagesMap[conversationId.value]?.length) {
await chatStore.loadHistoryMessages(conversationId.value) await chatStore.loadHistoryMessages(conversationId.value)
@@ -161,23 +189,19 @@ export default {
const scrollToBottom = () => { const scrollToBottom = () => {
nextTick(() => { nextTick(() => {
scrollToId.value = '' scrollToId.value = ''
nextTick(() => { nextTick(() => { scrollToId.value = 'msg-bottom' })
scrollToId.value = 'msg-bottom'
})
}) })
} }
const onSend = () => { const onSend = () => {
const content = inputText.value.trim() const content = inputText.value.trim()
if (!content) return if (!content) return
chatStore.sendMessage({ chatStore.sendMessage({
conversationId: conversationId.value || 0, conversationId: conversationId.value || 0,
targetUserId: conversationId.value ? 0 : peerId.value, targetUserId: conversationId.value ? 0 : peerId.value,
content, content,
type: 1 type: 1
}) })
inputText.value = '' inputText.value = ''
scrollToBottom() scrollToBottom()
} }
@@ -188,19 +212,14 @@ export default {
if (conversationId.value) { if (conversationId.value) {
chatStore.sendTyping(conversationId.value) chatStore.sendTyping(conversationId.value)
} }
typingTimer = setTimeout(() => { typingTimer = setTimeout(() => { typingTimer = null }, 3000)
typingTimer = null
}, 3000)
} }
const onLoadMore = async () => { const onLoadMore = async () => {
if (loadingMore.value || !hasMore.value) return if (loadingMore.value || !hasMore.value) return
loadingMore.value = true loadingMore.value = true
try { try { await chatStore.loadHistoryMessages(conversationId.value) }
await chatStore.loadHistoryMessages(conversationId.value) finally { loadingMore.value = false }
} finally {
loadingMore.value = false
}
} }
const onMsgLongPress = (msg) => { const onMsgLongPress = (msg) => {
@@ -208,9 +227,7 @@ export default {
uni.showActionSheet({ uni.showActionSheet({
itemList: ['撤回'], itemList: ['撤回'],
success: (res) => { success: (res) => {
if (res.tapIndex === 0 && msg.id) { if (res.tapIndex === 0 && msg.id) chatStore.recallMessage(msg.id)
chatStore.recallMessage(msg.id)
}
} }
}) })
} }
@@ -221,18 +238,18 @@ export default {
content: '是否重新发送?', content: '是否重新发送?',
success: (res) => { success: (res) => {
if (res.confirm) { if (res.confirm) {
chatStore.sendMessage({ chatStore.sendMessage({ conversationId: conversationId.value, content: msg.content, type: msg.type || 1 })
conversationId: conversationId.value,
content: msg.content,
type: msg.type || 1
})
} }
} }
}) })
} }
const goBack = () => { const goBack = () => {
if (getCurrentPages().length > 1) {
uni.navigateBack() uni.navigateBack()
} else {
uni.switchTab({ url: '/pages/chat/index' })
}
} }
const goToSettings = () => { const goToSettings = () => {
@@ -242,22 +259,11 @@ export default {
} }
return { return {
peerName, peerName, peerAvatar, selfAvatar, selfName,
peerAvatar, inputText, scrollToId, loadingMore,
inputText, messages, hasMore, isTyping,
scrollToId, isSelf, onSend, onInputChange, onLoadMore,
loadingMore, onMsgLongPress, onResend, goBack, goToSettings
messages,
hasMore,
isTyping,
isSelf,
onSend,
onInputChange,
onLoadMore,
onMsgLongPress,
onResend,
goBack,
goToSettings
} }
} }
} }
@@ -269,9 +275,10 @@ export default {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-color: #F1F5F9; background-color: #F1F5F9;
overflow: hidden;
} }
/* 导航栏 */ /* ===== 导航栏 ===== */
.nav-bar { .nav-bar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -281,8 +288,7 @@ export default {
background-color: #FFFFFF; background-color: #FFFFFF;
border-bottom: 1rpx solid #E2E8F0; border-bottom: 1rpx solid #E2E8F0;
} }
.nav-left, .nav-right {
.nav-left {
min-width: 88rpx; min-width: 88rpx;
min-height: 88rpx; min-height: 88rpx;
display: flex; display: flex;
@@ -290,149 +296,85 @@ export default {
justify-content: center; justify-content: center;
transition: opacity 150ms ease; transition: opacity 150ms ease;
} }
.nav-left:active, .nav-right:active { opacity: 0.6; }
.nav-center { flex: 1; text-align: center; }
.nav-title { font-size: 32rpx; font-weight: 600; color: #1E293B; }
.nav-typing { display: block; font-size: 22rpx; color: #2563EB; margin-top: 2rpx; }
.nav-left:active { /* ===== 消息列表 ===== */
opacity: 0.6;
}
.nav-center {
flex: 1;
text-align: center;
}
.nav-title {
font-size: 32rpx;
font-weight: 600;
color: #1E293B;
}
.nav-typing {
display: block;
font-size: 22rpx;
color: #2563EB;
margin-top: 2rpx;
}
.nav-right {
min-width: 88rpx;
min-height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 150ms ease;
}
.nav-right:active {
opacity: 0.6;
}
/* 消息列表 */
.msg-list { .msg-list {
flex: 1; flex: 1;
padding: 16rpx 24rpx; padding: 16rpx 24rpx;
box-sizing: border-box;
width: 100%;
} }
.load-more { text-align: center; padding: 16rpx 0; }
.load-more-text { font-size: 24rpx; color: #94A3B8; }
.load-more { /* ===== 消息行 ===== */
text-align: center;
padding: 16rpx 0;
}
.load-more-text {
font-size: 24rpx;
color: #94A3B8;
}
/* 消息行 */
.msg-row { .msg-row {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
margin-bottom: 24rpx; margin-bottom: 24rpx;
} }
.msg-row-self {
flex-direction: row-reverse;
}
.msg-row-other { .msg-row-other {
flex-direction: row; justify-content: flex-start;
}
.msg-row-self {
justify-content: flex-end;
} }
/* 头像 */ /* ===== 头像 ===== */
.msg-avatar-wrap { .avatar-wrap { flex-shrink: 0; }
flex-shrink: 0; .msg-row-other .avatar-wrap { margin-right: 16rpx; }
margin-right: 16rpx; .msg-row-self .avatar-wrap { margin-left: 16rpx; }
}
.msg-avatar { .avatar-img {
width: 72rpx; width: 72rpx;
height: 72rpx; height: 72rpx;
border-radius: 18rpx; border-radius: 18rpx;
} }
.avatar-placeholder {
.msg-avatar-placeholder {
background-color: #2563EB;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.avatar-peer { background-color: #2563EB; }
.avatar-self { background-color: #64748B; }
.avatar-char { color: #FFFFFF; font-size: 28rpx; font-weight: 600; }
.msg-avatar-text { /* ===== 气泡 ===== */
color: #FFFFFF; .bubble {
font-size: 28rpx; max-width: 65vw;
font-weight: 600;
}
/* 气泡 */
.msg-bubble {
max-width: 560rpx;
padding: 20rpx 24rpx; padding: 20rpx 24rpx;
border-radius: 24rpx; border-radius: 24rpx;
word-break: break-all; word-break: break-word;
overflow-wrap: break-word;
} }
.bubble-self { .bubble-self {
background-color: #2563EB; background-color: #2563EB;
border-bottom-right-radius: 8rpx; border-bottom-right-radius: 8rpx;
margin-left: 16rpx;
} }
.bubble-other { .bubble-other {
background-color: #FFFFFF; background-color: #FFFFFF;
border-bottom-left-radius: 8rpx; border-bottom-left-radius: 8rpx;
} }
.bubble-recalled { .bubble-recalled {
background-color: transparent !important; background-color: transparent !important;
padding: 8rpx 16rpx; padding: 8rpx 16rpx;
} }
.msg-text { .msg-text { font-size: 30rpx; line-height: 42rpx; color: #1E293B; }
font-size: 30rpx; .msg-text-self { color: #FFFFFF; }
line-height: 42rpx; .recalled-text { font-size: 24rpx; color: #94A3B8; font-style: italic; }
}
.bubble-self .msg-text { /* ===== 发送状态 ===== */
color: #FFFFFF;
}
.bubble-other .msg-text {
color: #1E293B;
}
.msg-recalled {
font-size: 24rpx;
color: #94A3B8;
font-style: italic;
}
/* 发送状态 */
.msg-status { .msg-status {
display: flex; display: flex;
align-items: center; align-items: center;
margin: 0 8rpx;
align-self: center; align-self: center;
margin: 0 8rpx;
} }
.msg-status-tap { .msg-status-tap {
min-width: 44rpx; min-width: 44rpx;
min-height: 44rpx; min-height: 44rpx;
@@ -441,7 +383,7 @@ export default {
justify-content: center; justify-content: center;
} }
/* 输入栏 */ /* ===== 输入栏 ===== */
.input-bar { .input-bar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -450,7 +392,6 @@ export default {
background-color: #FFFFFF; background-color: #FFFFFF;
border-top: 1rpx solid #E2E8F0; border-top: 1rpx solid #E2E8F0;
} }
.input-wrap { .input-wrap {
flex: 1; flex: 1;
background-color: #F1F5F9; background-color: #F1F5F9;
@@ -460,13 +401,7 @@ export default {
display: flex; display: flex;
align-items: center; align-items: center;
} }
.msg-input { flex: 1; font-size: 28rpx; color: #1E293B; }
.msg-input {
flex: 1;
font-size: 28rpx;
color: #1E293B;
}
.send-btn { .send-btn {
min-width: 72rpx; min-width: 72rpx;
min-height: 72rpx; min-height: 72rpx;
@@ -478,12 +413,6 @@ export default {
justify-content: center; justify-content: center;
transition: background-color 200ms ease; transition: background-color 200ms ease;
} }
.send-btn-active { background-color: #2563EB; }
.send-btn-active { .send-btn:active { opacity: 0.85; }
background-color: #2563EB;
}
.send-btn:active {
opacity: 0.85;
}
</style> </style>

View File

@@ -16,6 +16,7 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import imApi from '@/api/im' import imApi from '@/api/im'
import wsService from '@/services/websocket' import wsService from '@/services/websocket'
import { useUserStore } from '@/store/user'
export const useChatStore = defineStore('chat', () => { export const useChatStore = defineStore('chat', () => {
/** 会话列表 */ /** 会话列表 */
@@ -83,11 +84,12 @@ export const useChatStore = defineStore('chat', () => {
const sendMessage = (params) => { const sendMessage = (params) => {
const clientMsgId = _generateClientMsgId() const clientMsgId = _generateClientMsgId()
const { conversationId, targetUserId, content, type = 1 } = params const { conversationId, targetUserId, content, type = 1 } = params
const userStore = useUserStore()
const tempMsg = { const tempMsg = {
id: 0, id: 0,
conversation_id: conversationId || 0, conversation_id: conversationId || 0,
sender_id: 0, sender_id: Number(userStore.userInfo?.id) || 0,
type, type,
content, content,
status: 1, status: 1,
@@ -263,6 +265,11 @@ export const useChatStore = defineStore('chat', () => {
if (code === 0) { if (code === 0) {
pending.status = 'sent' pending.status = 'sent'
const convId = data.conversation_id const convId = data.conversation_id
if (pending.tempMsg && pending.tempMsg.conversation_id === 0 && convId) {
currentConversationId.value = convId
}
const messages = messagesMap.value[convId] const messages = messagesMap.value[convId]
if (messages) { if (messages) {
const idx = messages.findIndex(m => m.client_msg_id === data.client_msg_id) const idx = messages.findIndex(m => m.client_msg_id === data.client_msg_id)