Files
hejiu/pages/chat/chat.vue
T
cg eaa0d7101f feat(chat): 添加私信功能和WebSocket实时通信
- 集成WebSocket管理器,支持心跳、自动重连、消息队列
- 实现私信相关API接口,包括会话列表、历史消息、已读标记等
- 添加聊天页面和会话列表页面,支持实时消息收发
- 配置地图权限和腾讯地图SDK用于酒局定位功能
- 修改应用名称为"喝酒了么"并更新应用描述
- 在App.vue中初始化WebSocket连接和私信功能
- 添加详细的私信模块接口文档和Mock数据
- 优化单图片动态高度计算和预加载逻辑
2026-07-21 21:27:01 +08:00

602 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>