feat(chat): 添加私信功能和WebSocket实时通信

- 集成WebSocket管理器,支持心跳、自动重连、消息队列
- 实现私信相关API接口,包括会话列表、历史消息、已读标记等
- 添加聊天页面和会话列表页面,支持实时消息收发
- 配置地图权限和腾讯地图SDK用于酒局定位功能
- 修改应用名称为"喝酒了么"并更新应用描述
- 在App.vue中初始化WebSocket连接和私信功能
- 添加详细的私信模块接口文档和Mock数据
- 优化单图片动态高度计算和预加载逻辑
This commit is contained in:
cg
2026-07-21 21:27:01 +08:00
parent 0332aabba0
commit eaa0d7101f
14 changed files with 2112 additions and 38 deletions
+601
View File
@@ -0,0 +1,601 @@
<template>
<view :class="themeClass" class="page-container chat-page">
<!-- 自定义导航栏 -->
<view class="chat-nav" :style="{ paddingTop: headerPaddingTop }">
<view class="chat-nav-inner">
<view class="nav-back" @click="goBack">
<text class="nav-back-icon"></text>
</view>
<view class="nav-center">
<text class="nav-title">{{ nickname }}</text>
<text class="nav-status">{{ peerTyping ? '正在输入...' : (isOnline ? '在线' : '离线') }}</text>
</view>
<view class="nav-placeholder"></view>
</view>
</view>
<!-- 消息区域 -->
<scroll-view
class="chat-scroll"
scroll-y
:scroll-into-view="scrollToId"
:scroll-with-animation="true"
>
<view class="chat-messages">
<template v-for="(msg, idx) in messages" :key="msg.id">
<!-- 时间分隔线 -->
<view v-if="showTimeDivider(idx)" class="time-divider">
<text class="time-divider-text">{{ formatTime(msg.timestamp) }}</text>
</view>
<!-- 消息气泡 -->
<view
:id="'msg-' + msg.id"
class="msg-row"
:class="{ 'msg-row-self': isSelf(msg) }"
>
<!-- 对方头像 -->
<view v-if="!isSelf(msg)" class="msg-avatar-wrap">
<view class="msg-avatar msg-avatar-ph">
<text class="msg-avatar-text">{{ nickname ? nickname[0] : '酒' }}</text>
</view>
</view>
<!-- 气泡 -->
<view class="msg-bubble" :class="isSelf(msg) ? 'msg-bubble-self' : 'msg-bubble-peer'">
<image
v-if="msg.type === 'image'"
class="msg-image"
:src="msg.content"
mode="widthFix"
@click="previewImage(msg.content)"
></image>
<text v-else class="msg-text">{{ msg.content }}</text>
</view>
<!-- 自己头像 -->
<view v-if="isSelf(msg)" class="msg-avatar-wrap">
<view class="msg-avatar msg-avatar-ph msg-avatar-self">
<text class="msg-avatar-text"></text>
</view>
</view>
</view>
<!-- 消息状态仅自己发送的 -->
<view v-if="isSelf(msg) && msg.status === 'sending'" class="msg-status">
<text class="msg-status-text">发送中...</text>
</view>
<view v-if="isSelf(msg) && msg.status === 'failed'" class="msg-status msg-status-fail" @click="resend(msg)">
<text class="msg-status-text">发送失败点击重试</text>
</view>
</template>
<!-- 对方正在输入 -->
<view v-if="peerTyping" class="msg-row">
<view class="msg-avatar-wrap">
<view class="msg-avatar msg-avatar-ph">
<text class="msg-avatar-text">{{ nickname ? nickname[0] : '酒' }}</text>
</view>
</view>
<view class="msg-bubble msg-bubble-peer msg-typing">
<text class="msg-typing-dot">·</text>
<text class="msg-typing-dot">·</text>
<text class="msg-typing-dot">·</text>
</view>
</view>
<!-- 底部锚点 -->
<view id="msg-bottom" style="height: 20rpx;"></view>
</view>
</scroll-view>
<!-- 底部输入栏 -->
<view class="chat-input-bar">
<view class="chat-input-wrap">
<input
class="chat-input"
v-model="inputText"
placeholder="说点什么..."
placeholder-class="chat-input-ph"
confirm-type="send"
:adjust-position="true"
@confirm="sendMessage"
@input="onInput"
/>
</view>
<view
class="chat-send-btn"
:class="{ 'chat-send-active': inputText.trim() }"
@click="sendMessage"
>
<text class="chat-send-icon"></text>
</view>
</view>
</view>
</template>
<script>
import { GetMessages, MarkRead } from '../../common/api'
import wsManager from '../../common/websocket'
import themeMixin from '../../common/theme-mixin'
export default {
mixins: [themeMixin],
data() {
const menuBtn = uni.getMenuButtonBoundingClientRect()
return {
headerPaddingTop: (menuBtn.top + 8) + 'px',
friendId: '',
nickname: '',
conversationId: '',
messages: [],
inputText: '',
scrollToId: '',
isOnline: false,
peerTyping: false,
typingTimer: null,
page: 1,
hasMore: true
}
},
onLoad(options) {
this.friendId = options.friendId || ''
this.nickname = decodeURIComponent(options.nickname || '')
this.conversationId = options.conversationId || ''
this.loadMessages()
this.markRead()
this.bindWsEvents()
},
onUnload() {
this.unbindWsEvents()
if (this.typingTimer) {
clearTimeout(this.typingTimer)
}
},
methods: {
// === 数据加载 ===
async loadMessages() {
try {
const res = await GetMessages({ conversationId: this.conversationId, page: this.page })
this.messages = res.data.list
this.hasMore = res.data.hasMore
this.$nextTick(() => {
this.scrollToBottom()
})
} catch (e) {
console.warn('加载消息失败', e)
}
},
async markRead() {
try {
await MarkRead({ conversationId: this.conversationId })
// 通过 WebSocket 发送已读回执
if (this.messages.length) {
const lastMsg = this.messages[this.messages.length - 1]
wsManager.send('read', {
conversationId: this.conversationId,
lastMsgId: lastMsg.id
})
}
} catch (e) { /* ignore */ }
},
// === WebSocket 事件 ===
bindWsEvents() {
wsManager.on('message', this.onWsMessage)
wsManager.on('typing', this.onWsTyping)
wsManager.on('ack', this.onWsAck)
},
unbindWsEvents() {
wsManager.off('message', this.onWsMessage)
wsManager.off('typing', this.onWsTyping)
wsManager.off('ack', this.onWsAck)
},
/** 收到新消息 */
onWsMessage(data) {
// 只处理当前会话的消息
if (data.senderId !== this.friendId) return
const msg = {
id: data.msgId || 'msg_' + Date.now(),
conversationId: this.conversationId,
senderId: data.senderId,
receiverId: 'user_001',
type: data.msgType || 'text',
content: data.content,
timestamp: data.timestamp || Date.now(),
status: 'sent'
}
this.messages.push(msg)
this.peerTyping = false
this.$nextTick(() => this.scrollToBottom())
// 发送已读回执
wsManager.send('read', {
conversationId: this.conversationId,
lastMsgId: msg.id
})
},
/** 对方正在输入 */
onWsTyping(data) {
if (data.senderId !== this.friendId) return
this.peerTyping = true
if (this.typingTimer) clearTimeout(this.typingTimer)
this.typingTimer = setTimeout(() => {
this.peerTyping = false
}, 3000)
},
/** 消息送达确认 */
onWsAck(data) {
const msg = this.messages.find(m => m.id === data.clientMsgId)
if (msg) {
msg.id = data.msgId || msg.id
msg.status = data.status || 'sent'
}
},
// === 发送消息 ===
sendMessage() {
const text = this.inputText.trim()
if (!text) return
const clientMsgId = 'msg_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6)
const msg = {
id: clientMsgId,
conversationId: this.conversationId,
senderId: 'user_001',
receiverId: this.friendId,
type: 'text',
content: text,
timestamp: Date.now(),
status: 'sending'
}
// 乐观更新:立即显示
this.messages.push(msg)
this.inputText = ''
this.$nextTick(() => this.scrollToBottom())
// 通过 WebSocket 发送
wsManager.send('chat', {
receiverId: this.friendId,
content: text,
msgType: 'text',
clientMsgId
})
// 模拟确认(Mock模式下直接标记为已发送)
setTimeout(() => {
msg.status = 'sent'
}, 500)
},
/** 重发失败消息 */
resend(msg) {
msg.status = 'sending'
wsManager.send('chat', {
receiverId: this.friendId,
content: msg.content,
msgType: msg.type,
clientMsgId: msg.id
})
setTimeout(() => {
msg.status = 'sent'
}, 500)
},
/** 输入时通知对方 */
onInput() {
wsManager.send('typing', { receiverId: this.friendId })
},
// === 辅助方法 ===
isSelf(msg) {
return msg.senderId === 'user_001'
},
showTimeDivider(idx) {
if (idx === 0) return true
const prev = this.messages[idx - 1]
const curr = this.messages[idx]
return (curr.timestamp - prev.timestamp) > 5 * 60 * 1000
},
formatTime(ts) {
const d = new Date(ts)
const now = new Date()
const isToday = d.toDateString() === now.toDateString()
const h = String(d.getHours()).padStart(2, '0')
const m = String(d.getMinutes()).padStart(2, '0')
if (isToday) return `${h}:${m}`
const month = d.getMonth() + 1
const day = d.getDate()
return `${month}${day}${h}:${m}`
},
scrollToBottom() {
this.scrollToId = ''
this.$nextTick(() => {
this.scrollToId = 'msg-bottom'
})
},
previewImage(url) {
uni.previewImage({ urls: [url] })
},
goBack() {
uni.navigateBack()
}
}
}
</script>
<style lang="scss" scoped>
.chat-page {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
/* 导航栏 */
.chat-nav {
background: $bg-base;
padding-left: $sp-lg;
padding-right: $sp-lg;
padding-bottom: $sp-sm;
position: relative;
z-index: 10;
border-bottom: 1rpx solid var(--border-faint, rgba(255,255,255,0.08));
}
.chat-nav-inner {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
}
.nav-back {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: $bg-card;
&:active {
background: $bg-card-alt;
}
}
.nav-back-icon {
font-size: 44rpx;
color: $text-primary;
font-weight: $fw-bold;
margin-top: -4rpx;
}
.nav-center {
display: flex;
flex-direction: column;
align-items: center;
gap: 2rpx;
}
.nav-title {
font-size: $fs-base;
font-weight: $fw-bold;
color: $text-primary;
}
.nav-status {
font-size: $fs-xs;
color: $text-tertiary;
}
.nav-placeholder {
width: 64rpx;
}
/* 消息区域 */
.chat-scroll {
flex: 1;
height: 0;
}
.chat-messages {
padding: $sp-lg;
padding-bottom: $sp-md;
}
/* 时间分隔 */
.time-divider {
text-align: center;
padding: $sp-md 0;
}
.time-divider-text {
font-size: $fs-xs;
color: $text-tertiary;
background: $bg-card;
padding: 6rpx 20rpx;
border-radius: $radius-full;
}
/* 消息行 */
.msg-row {
display: flex;
align-items: flex-start;
gap: $sp-sm;
margin-bottom: $sp-md;
}
.msg-row-self {
flex-direction: row-reverse;
}
/* 头像 */
.msg-avatar-wrap {
flex-shrink: 0;
}
.msg-avatar {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
}
.msg-avatar-ph {
background: linear-gradient(135deg, rgba(232,168,56,0.15), rgba(139,133,184,0.15));
display: flex;
align-items: center;
justify-content: center;
}
.msg-avatar-self {
background: linear-gradient(135deg, rgba(232,168,56,0.25), rgba(232,168,56,0.1));
}
.msg-avatar-text {
font-size: $fs-sm;
font-weight: $fw-bold;
color: $amber;
}
/* 气泡 */
.msg-bubble {
max-width: 65%;
padding: $sp-md $sp-lg;
border-radius: $radius-lg;
word-break: break-all;
}
.msg-bubble-peer {
background: $bg-card-alt;
border-top-left-radius: 8rpx;
}
.msg-bubble-self {
background: $amber-glow;
border: 1rpx solid rgba(232,168,56,0.2);
border-top-right-radius: 8rpx;
}
.msg-text {
font-size: $fs-base;
color: $text-primary;
line-height: $lh-normal;
}
.msg-image {
max-width: 360rpx;
border-radius: $radius-md;
}
/* 消息状态 */
.msg-status {
text-align: right;
padding-right: 100rpx;
margin-top: -8rpx;
margin-bottom: $sp-sm;
}
.msg-status-text {
font-size: $fs-xs;
color: $text-tertiary;
}
.msg-status-fail .msg-status-text {
color: $coral;
}
/* 正在输入动画 */
.msg-typing {
display: flex;
align-items: center;
gap: 6rpx;
padding: $sp-md $sp-lg;
}
.msg-typing-dot {
font-size: $fs-xl;
color: $text-tertiary;
animation: typingBlink 1.4s infinite;
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
@keyframes typingBlink {
0%, 60%, 100% { opacity: 0.3; }
30% { opacity: 1; }
}
/* 底部输入栏 */
.chat-input-bar {
display: flex;
align-items: center;
gap: $sp-md;
padding: $sp-md $sp-lg;
padding-bottom: calc(env(safe-area-inset-bottom) + #{$sp-md});
background: $bg-base;
border-top: 1rpx solid var(--border-faint, rgba(255,255,255,0.08));
}
.chat-input-wrap {
flex: 1;
background: $bg-card;
border-radius: $radius-full;
border: 1rpx solid var(--border-faint, rgba(255,255,255,0.08));
padding: 0 $sp-lg;
height: 80rpx;
display: flex;
align-items: center;
}
.chat-input {
width: 100%;
height: 80rpx;
font-size: $fs-base;
color: $text-primary;
}
.chat-input-ph {
color: $text-tertiary;
}
.chat-send-btn {
flex-shrink: 0;
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: $bg-card;
display: flex;
align-items: center;
justify-content: center;
transition: all $duration-fast $ease-out;
}
.chat-send-active {
background: linear-gradient(135deg, $amber, $amber-deep);
box-shadow: $shadow-amber;
}
.chat-send-icon {
font-size: 32rpx;
color: $text-tertiary;
.chat-send-active & {
color: $text-on-amber;
}
}
</style>