feat(frontend): Task 7 - 会话列表页 + 聊天对话页

pages/chat/index.vue(会话列表页):
- 排序展示会话列表(置顶优先 → 时间降序)
- 未读 badge + 时间格式化(今天/昨天/日期)
- 长按操作菜单(置顶/删除)
- 正在输入状态提示
- 空状态 UI

pages/chat/conversation.vue(聊天页):
- 消息气泡(自己蓝色/对方白色)
- 游标分页加载历史消息
- 发送消息 + 三态确认(sending/sent/failed)
- 长按撤回 + 重发失败消息
- 正在输入通知(发送 + 接收)
- 自定义导航栏 + 设置入口

pages.json:
- 新增 chat/conversation、chat/settings、chat/search 路由

Made-with: Cursor
This commit is contained in:
bujinyuan
2026-03-03 11:01:15 +08:00
parent 780dea3c31
commit 565afb3149
3 changed files with 834 additions and 21 deletions

View File

@@ -62,6 +62,25 @@
"navigationBarTitleText": "黑名单"
}
},
{
"path": "pages/chat/conversation",
"style": {
"navigationBarTitleText": "聊天",
"navigationStyle": "custom"
}
},
{
"path": "pages/chat/settings",
"style": {
"navigationBarTitleText": "聊天设置"
}
},
{
"path": "pages/chat/search",
"style": {
"navigationBarTitleText": "搜索消息"
}
},
{
"path": "pages/meeting/index",
"style": {

View File

@@ -0,0 +1,491 @@
<!--
聊天对话页
设计系统design-system/echochat/MASTER.md
色板Primary #2563EB / BG #F8FAFC / Text #1E293B
功能
- 消息气泡左侧对方 / 右侧自己
- 游标分页加载历史消息
- 消息发送三态sending sent failed
- 正在输入提示
- 消息撤回长按自己的消息
-->
<template>
<view class="page-wrapper">
<!-- 自定义导航栏 -->
<view class="nav-bar">
<view class="nav-left" @tap="goBack">
<text class="nav-back">&#10094;</text>
</view>
<view class="nav-center">
<text class="nav-title">{{ peerName || '聊天' }}</text>
<text v-if="isTyping" class="nav-typing">正在输入...</text>
</view>
<view class="nav-right" @tap="goToSettings">
<text class="nav-more">&#8943;</text>
</view>
</view>
<!-- 消息列表 -->
<scroll-view
scroll-y
class="msg-list"
:scroll-into-view="scrollToId"
:scroll-with-animation="true"
@scrolltoupper="onLoadMore"
>
<!-- 加载更多提示 -->
<view v-if="hasMore" class="load-more" @tap="onLoadMore">
<text class="load-more-text">{{ loadingMore ? '加载中...' : '上拉加载更多' }}</text>
</view>
<!-- 消息气泡 -->
<view
v-for="msg in messages"
:key="msg.client_msg_id || msg.id"
:id="'msg-' + (msg.id || msg.client_msg_id)"
class="msg-row"
:class="{ 'msg-row-self': isSelf(msg), 'msg-row-other': !isSelf(msg) }"
>
<!-- 对方头像 -->
<view v-if="!isSelf(msg)" class="msg-avatar-wrap">
<image v-if="peerAvatar" class="msg-avatar" :src="peerAvatar" mode="aspectFill" />
<view v-else class="msg-avatar msg-avatar-placeholder">
<text class="msg-avatar-text">{{ (peerName || '?')[0] }}</text>
</view>
</view>
<!-- 消息内容 -->
<view
class="msg-bubble"
:class="{
'bubble-self': isSelf(msg),
'bubble-other': !isSelf(msg),
'bubble-recalled': msg.status === 2
}"
@longpress="onMsgLongPress(msg)"
>
<text v-if="msg.status === 2" class="msg-recalled">消息已撤回</text>
<text v-else class="msg-text">{{ msg.content }}</text>
</view>
<!-- 发送状态 -->
<view v-if="isSelf(msg) && msg._sending" class="msg-status">
<text class="status-sending">&#8987;</text>
</view>
<view v-if="isSelf(msg) && msg._failed" class="msg-status" @tap="onResend(msg)">
<text class="status-failed">&#9888;</text>
</view>
</view>
<view id="msg-bottom" style="height: 2rpx;" />
</scroll-view>
<!-- 底部输入区 -->
<view class="input-bar">
<view class="input-wrap">
<input
class="msg-input"
v-model="inputText"
placeholder="输入消息..."
placeholder-style="color: #94A3B8"
confirm-type="send"
@confirm="onSend"
@input="onInputChange"
:adjust-position="true"
/>
</view>
<view class="send-btn" :class="{ 'send-btn-active': inputText.trim() }" @tap="onSend">
<text class="send-icon">&#10148;</text>
</view>
</view>
</view>
</template>
<script>
import { onLoad, onUnload } from '@dcloudio/uni-app'
import { ref, computed, nextTick } from 'vue'
import { useChatStore } from '@/store/chat'
import { useUserStore } from '@/store/user'
export default {
name: 'ChatConversation',
setup() {
const chatStore = useChatStore()
const userStore = useUserStore()
const conversationId = ref(0)
const peerId = ref(0)
const peerName = ref('')
const peerAvatar = ref('')
const inputText = ref('')
const scrollToId = ref('')
const loadingMore = ref(false)
const messages = computed(() => chatStore.currentMessages)
const hasMore = computed(() => chatStore.hasMoreMap[conversationId.value] !== false)
const isTyping = computed(() => chatStore.typingMap[conversationId.value] || false)
const isSelf = (msg) => {
return msg.sender_id === (userStore.userInfo?.id || 0) || msg._sending
}
onLoad((query) => {
conversationId.value = parseInt(query.conversationId) || 0
peerId.value = parseInt(query.peerId) || 0
peerName.value = decodeURIComponent(query.peerName || '')
peerAvatar.value = decodeURIComponent(query.peerAvatar || '')
chatStore.initWsListeners()
if (conversationId.value) {
chatStore.setCurrentConversation(conversationId.value)
loadInitialMessages()
}
})
onUnload(() => {
chatStore.setCurrentConversation(null)
})
const loadInitialMessages = async () => {
if (!chatStore.messagesMap[conversationId.value]?.length) {
await chatStore.loadHistoryMessages(conversationId.value)
}
scrollToBottom()
}
const scrollToBottom = () => {
nextTick(() => {
scrollToId.value = ''
nextTick(() => {
scrollToId.value = 'msg-bottom'
})
})
}
const onSend = () => {
const content = inputText.value.trim()
if (!content) return
chatStore.sendMessage({
conversationId: conversationId.value || 0,
targetUserId: conversationId.value ? 0 : peerId.value,
content,
type: 1
})
inputText.value = ''
scrollToBottom()
}
let typingTimer = null
const onInputChange = () => {
if (typingTimer) return
if (conversationId.value) {
chatStore.sendTyping(conversationId.value)
}
typingTimer = setTimeout(() => {
typingTimer = null
}, 3000)
}
const onLoadMore = async () => {
if (loadingMore.value || !hasMore.value) return
loadingMore.value = true
try {
await chatStore.loadHistoryMessages(conversationId.value)
} finally {
loadingMore.value = false
}
}
const onMsgLongPress = (msg) => {
if (!isSelf(msg) || msg.status === 2) return
uni.showActionSheet({
itemList: ['撤回'],
success: (res) => {
if (res.tapIndex === 0 && msg.id) {
chatStore.recallMessage(msg.id)
}
}
})
}
const onResend = (msg) => {
uni.showModal({
title: '提示',
content: '是否重新发送?',
success: (res) => {
if (res.confirm) {
chatStore.sendMessage({
conversationId: conversationId.value,
content: msg.content,
type: msg.type || 1
})
}
}
})
}
const goBack = () => {
uni.navigateBack()
}
const goToSettings = () => {
uni.navigateTo({
url: `/pages/chat/settings?conversationId=${conversationId.value}&peerId=${peerId.value}&peerName=${encodeURIComponent(peerName.value)}&peerAvatar=${encodeURIComponent(peerAvatar.value)}`
})
}
return {
peerName,
peerAvatar,
inputText,
scrollToId,
loadingMore,
messages,
hasMore,
isTyping,
isSelf,
onSend,
onInputChange,
onLoadMore,
onMsgLongPress,
onResend,
goBack,
goToSettings
}
}
}
</script>
<style scoped>
.page-wrapper {
height: 100vh;
display: flex;
flex-direction: column;
background-color: #F1F5F9;
}
/* 导航栏 */
.nav-bar {
display: flex;
align-items: center;
height: 88rpx;
padding: 0 24rpx;
padding-top: var(--status-bar-height, 44px);
background-color: #FFFFFF;
border-bottom: 1rpx solid #E2E8F0;
}
.nav-left {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
}
.nav-back {
font-size: 32rpx;
color: #1E293B;
font-weight: 600;
}
.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 {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
}
.nav-more {
font-size: 36rpx;
color: #475569;
}
/* 消息列表 */
.msg-list {
flex: 1;
padding: 16rpx 24rpx;
}
.load-more {
text-align: center;
padding: 16rpx 0;
}
.load-more-text {
font-size: 24rpx;
color: #94A3B8;
}
/* 消息行 */
.msg-row {
display: flex;
align-items: flex-start;
margin-bottom: 24rpx;
}
.msg-row-self {
flex-direction: row-reverse;
}
.msg-row-other {
flex-direction: row;
}
/* 头像 */
.msg-avatar-wrap {
flex-shrink: 0;
margin-right: 16rpx;
}
.msg-avatar {
width: 72rpx;
height: 72rpx;
border-radius: 18rpx;
}
.msg-avatar-placeholder {
background-color: #2563EB;
display: flex;
align-items: center;
justify-content: center;
}
.msg-avatar-text {
color: #FFFFFF;
font-size: 28rpx;
font-weight: 600;
}
/* 气泡 */
.msg-bubble {
max-width: 560rpx;
padding: 20rpx 24rpx;
border-radius: 24rpx;
word-break: break-all;
}
.bubble-self {
background-color: #2563EB;
border-bottom-right-radius: 8rpx;
margin-left: 16rpx;
}
.bubble-other {
background-color: #FFFFFF;
border-bottom-left-radius: 8rpx;
}
.bubble-recalled {
background-color: transparent !important;
padding: 8rpx 16rpx;
}
.msg-text {
font-size: 30rpx;
line-height: 42rpx;
}
.bubble-self .msg-text {
color: #FFFFFF;
}
.bubble-other .msg-text {
color: #1E293B;
}
.msg-recalled {
font-size: 24rpx;
color: #94A3B8;
font-style: italic;
}
/* 发送状态 */
.msg-status {
display: flex;
align-items: center;
margin: 0 8rpx;
align-self: center;
}
.status-sending {
font-size: 24rpx;
color: #94A3B8;
}
.status-failed {
font-size: 28rpx;
color: #EF4444;
}
/* 输入栏 */
.input-bar {
display: flex;
align-items: center;
padding: 16rpx 24rpx;
padding-bottom: calc(16rpx + env(safe-area-inset-bottom, 0));
background-color: #FFFFFF;
border-top: 1rpx solid #E2E8F0;
}
.input-wrap {
flex: 1;
background-color: #F1F5F9;
border-radius: 36rpx;
padding: 0 28rpx;
height: 72rpx;
display: flex;
align-items: center;
}
.msg-input {
flex: 1;
font-size: 28rpx;
color: #1E293B;
}
.send-btn {
width: 72rpx;
height: 72rpx;
margin-left: 16rpx;
border-radius: 50%;
background-color: #CBD5E1;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 200ms ease;
}
.send-btn-active {
background-color: #2563EB;
}
.send-icon {
font-size: 32rpx;
color: #FFFFFF;
}
</style>

View File

@@ -1,30 +1,172 @@
<!--
消息列表页占位
消息 - 会话列表页TabBar 页面
设计系统design-system/echochat/MASTER.md
TabBar 页面Phase 2IM 模块中实现具体功能
当前功能
- 显示模块名称和开发中提示
- 集成自定义 TabBar 组件
色板Primary #2563EB / BG #F8FAFC / Text #1E293B / Muted #94A3B8
-->
<template>
<view class="page-wrapper">
<view class="placeholder-content">
<text class="placeholder-icon">💬</text>
<text class="placeholder-title">消息</text>
<text class="placeholder-desc">即时通讯功能开发中</text>
<!-- 顶部栏 -->
<view class="header">
<text class="header-title">消息</text>
<view class="header-actions">
<view class="action-btn" @tap="goToSearch">
<text class="action-icon">&#128269;</text>
</view>
</view>
</view>
<!-- 会话列表 -->
<scroll-view scroll-y class="conv-list" @scrolltolower="onScrollToLower">
<!-- 空状态 -->
<view v-if="!loading && conversations.length === 0" class="empty-state">
<text class="empty-icon">💬</text>
<text class="empty-text">暂无消息</text>
<text class="empty-hint">找好友聊聊天吧</text>
</view>
<!-- 会话条目 -->
<view
v-for="conv in conversations"
:key="conv.id"
class="conv-item"
:class="{ 'conv-pinned': conv.is_pinned }"
@tap="openChat(conv)"
@longpress="onLongPress(conv)"
>
<!-- 头像 -->
<view class="conv-avatar-wrap">
<image
v-if="conv.peer_avatar"
class="conv-avatar"
:src="conv.peer_avatar"
mode="aspectFill"
/>
<view v-else class="conv-avatar conv-avatar-placeholder">
<text class="avatar-text">{{ (conv.peer_nickname || '?')[0] }}</text>
</view>
<view v-if="conv.unread_count > 0" class="conv-badge">
<text class="conv-badge-text">{{ conv.unread_count > 99 ? '99+' : conv.unread_count }}</text>
</view>
</view>
<!-- 信息区 -->
<view class="conv-info">
<view class="conv-top">
<text class="conv-name">{{ conv.peer_nickname || '未知用户' }}</text>
<text class="conv-time">{{ formatTime(conv.last_msg_time) }}</text>
</view>
<view class="conv-bottom">
<text v-if="isTyping(conv.id)" class="conv-typing">对方正在输入...</text>
<text v-else class="conv-preview" :class="{ 'conv-preview-unread': conv.unread_count > 0 }">
{{ conv.last_msg_content || ' ' }}
</text>
<view v-if="conv.is_pinned" class="conv-pin-tag">
<text class="pin-icon">📌</text>
</view>
</view>
</view>
</view>
</scroll-view>
<CustomTabBar :current="0" />
</view>
</template>
<script>
import { onShow } from '@dcloudio/uni-app'
import { ref, computed } from 'vue'
import { useChatStore } from '@/store/chat'
import CustomTabBar from '@/components/CustomTabBar.vue'
export default {
name: 'ChatIndex',
components: { CustomTabBar }
components: { CustomTabBar },
setup() {
const chatStore = useChatStore()
const loading = ref(false)
const conversations = computed(() => chatStore.sortedConversations)
const loadData = async () => {
loading.value = true
try {
await chatStore.fetchConversations()
} finally {
loading.value = false
}
}
onShow(() => {
chatStore.initWsListeners()
loadData()
})
const openChat = (conv) => {
uni.navigateTo({
url: `/pages/chat/conversation?conversationId=${conv.id}&peerId=${conv.peer_user_id}&peerName=${encodeURIComponent(conv.peer_nickname || '')}&peerAvatar=${encodeURIComponent(conv.peer_avatar || '')}`
})
}
const goToSearch = () => {
uni.navigateTo({ url: '/pages/chat/search' })
}
const isTyping = (convId) => {
return chatStore.typingMap[convId] || false
}
const formatTime = (timeStr) => {
if (!timeStr) return ''
const date = new Date(timeStr)
const now = new Date()
const isToday = date.toDateString() === now.toDateString()
if (isToday) {
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
const yesterday = new Date(now)
yesterday.setDate(yesterday.getDate() - 1)
if (date.toDateString() === yesterday.toDateString()) {
return '昨天'
}
return `${date.getMonth() + 1}/${date.getDate()}`
}
const onLongPress = (conv) => {
const items = [conv.is_pinned ? '取消置顶' : '置顶', '删除会话']
uni.showActionSheet({
itemList: items,
success: async (res) => {
if (res.tapIndex === 0) {
await chatStore.pinConversation(conv.id, !conv.is_pinned)
} else if (res.tapIndex === 1) {
uni.showModal({
title: '提示',
content: '确定删除该会话?',
success: async (r) => {
if (r.confirm) {
await chatStore.deleteConversation(conv.id)
}
}
})
}
}
})
}
const onScrollToLower = () => {}
return {
loading,
conversations,
openChat,
goToSearch,
isTyping,
formatTime,
onLongPress,
onScrollToLower
}
}
}
</script>
@@ -35,28 +177,189 @@ export default {
padding-bottom: 120rpx;
}
.placeholder-content {
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 32rpx;
height: 88rpx;
padding-top: var(--status-bar-height, 44px);
background-color: #FFFFFF;
}
.header-title {
font-size: 36rpx;
font-weight: 700;
color: #1E293B;
}
.header-actions {
display: flex;
gap: 16rpx;
}
.action-btn {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 16rpx;
background-color: #F1F5F9;
}
.action-icon {
font-size: 32rpx;
color: #475569;
}
/* 会话列表 */
.conv-list {
height: calc(100vh - 88rpx - var(--status-bar-height, 44px) - 120rpx);
}
.conv-item {
display: flex;
align-items: center;
padding: 24rpx 32rpx;
background-color: #FFFFFF;
border-bottom: 1rpx solid #F1F5F9;
}
.conv-pinned {
background-color: #F8FAFC;
}
/* 头像 */
.conv-avatar-wrap {
position: relative;
flex-shrink: 0;
margin-right: 24rpx;
}
.conv-avatar {
width: 96rpx;
height: 96rpx;
border-radius: 24rpx;
}
.conv-avatar-placeholder {
background-color: #2563EB;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-text {
color: #FFFFFF;
font-size: 36rpx;
font-weight: 600;
}
.conv-badge {
position: absolute;
top: -8rpx;
right: -8rpx;
min-width: 36rpx;
height: 36rpx;
padding: 0 8rpx;
background-color: #EF4444;
border-radius: 18rpx;
display: flex;
align-items: center;
justify-content: center;
}
.conv-badge-text {
color: #FFFFFF;
font-size: 20rpx;
line-height: 36rpx;
}
/* 信息区 */
.conv-info {
flex: 1;
overflow: hidden;
}
.conv-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8rpx;
}
.conv-name {
font-size: 30rpx;
font-weight: 500;
color: #1E293B;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 400rpx;
}
.conv-time {
font-size: 24rpx;
color: #94A3B8;
flex-shrink: 0;
}
.conv-bottom {
display: flex;
align-items: center;
justify-content: space-between;
}
.conv-preview {
font-size: 26rpx;
color: #94A3B8;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 480rpx;
}
.conv-preview-unread {
color: #64748B;
font-weight: 500;
}
.conv-typing {
font-size: 26rpx;
color: #2563EB;
}
.conv-pin-tag {
flex-shrink: 0;
}
.pin-icon {
font-size: 22rpx;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 300rpx;
padding-top: 200rpx;
}
.placeholder-icon {
.empty-icon {
font-size: 80rpx;
margin-bottom: 24rpx;
}
.placeholder-title {
font-size: 36rpx;
.empty-text {
font-size: 32rpx;
font-weight: 600;
color: #1E293B;
margin-bottom: 12rpx;
margin-bottom: 8rpx;
}
.placeholder-desc {
font-size: 28rpx;
.empty-hint {
font-size: 26rpx;
color: #94A3B8;
}
</style>