- 集成HaveADrink SDK版本从1.0.4升级至1.0.8 - 实现酒友圈API接口,替换原有的mock数据 - 添加地理位置权限配置(requiredPrivateInfos) - 重构API调用方式,统一使用client实例调用真实接口 - 更新WebSocket协议与SDK保持一致,支持聊天、输入状态、已读回执等功能 - 实现游标分页获取动态信息流 - 添加文件上传API支持图片上传 - 优化API错误处理,支持业务级错误提示 - 调整酒局状态显示,新增upcoming待开始状态 - 优化自我感觉标签映射逻辑,支持更多状态类型 - 更新websocket连接认证方式,使用Authorization header传递token - 添加服务端连接确认和被踢下线事件处理
654 lines
16 KiB
Vue
654 lines
16 KiB
Vue
<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" :style="inputBarStyle">
|
||
<view class="chat-input-wrap">
|
||
<input
|
||
class="chat-input"
|
||
v-model="inputText"
|
||
placeholder="说点什么..."
|
||
placeholder-class="chat-input-ph"
|
||
confirm-type="send"
|
||
:adjust-position="false"
|
||
:focus="inputFocus"
|
||
:hold-keyboard="true"
|
||
@confirm="sendMessage"
|
||
@input="onInput"
|
||
@blur="onInputBlur"
|
||
/>
|
||
</view>
|
||
<view
|
||
class="chat-send-btn"
|
||
:class="{ 'chat-send-active': inputText.trim() }"
|
||
@touchstart="onSendTouch"
|
||
@click="sendMessage"
|
||
>
|
||
<text class="chat-send-icon">➤</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import { GetMessages, MarkRead, GetConversations } 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: '',
|
||
inputFocus: false,
|
||
keyboardHeight: 0,
|
||
scrollToId: '',
|
||
isOnline: false,
|
||
peerTyping: false,
|
||
typingTimer: null,
|
||
lastID: 0,
|
||
hasMore: true
|
||
}
|
||
},
|
||
onLoad(options) {
|
||
this.friendId = options.friendId || ''
|
||
this.nickname = decodeURIComponent(options.nickname || '')
|
||
this.conversationId = options.conversationId || ''
|
||
this.initChat()
|
||
// 监听键盘高度变化,动态调整输入栏位置(避免键盘顶起整个页面)
|
||
uni.onKeyboardHeightChange(res => {
|
||
this.keyboardHeight = res.height
|
||
if (res.height > 0) {
|
||
this.$nextTick(() => this.scrollToBottom())
|
||
}
|
||
})
|
||
},
|
||
onUnload() {
|
||
this.unbindWsEvents()
|
||
if (this.typingTimer) {
|
||
clearTimeout(this.typingTimer)
|
||
}
|
||
uni.offKeyboardHeightChange()
|
||
},
|
||
computed: {
|
||
inputBarStyle() {
|
||
if (this.keyboardHeight > 0) {
|
||
// +10px 缓冲,避免输入栏与键盘顶部贴合/遮挡
|
||
return { paddingBottom: (this.keyboardHeight + 10) + 'px' }
|
||
}
|
||
return {}
|
||
}
|
||
},
|
||
methods: {
|
||
// === 初始化聊天 ===
|
||
async initChat() {
|
||
// 如果没有传入 conversationId,通过会话列表查找
|
||
if (!this.conversationId && this.friendId) {
|
||
try {
|
||
const res = await GetConversations()
|
||
const conv = (res.data.list || []).find(c => String(c.friendId) === String(this.friendId))
|
||
if (conv) {
|
||
this.conversationId = conv.id
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
this.loadMessages()
|
||
this.markRead()
|
||
this.bindWsEvents()
|
||
},
|
||
// === 数据加载 ===
|
||
async loadMessages() {
|
||
try {
|
||
const res = await GetMessages({ conversationId: this.conversationId, lastID: this.lastID, pageSize: 20 })
|
||
this.messages = res.data.list || []
|
||
this.hasMore = res.data.hasMore
|
||
// 记录最早一条消息ID作为加载更多游标
|
||
if (this.messages.length) {
|
||
this.lastID = this.messages[0].id
|
||
}
|
||
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 (String(data.senderId) !== String(this.friendId)) return
|
||
const msg = {
|
||
id: data.msgId ? String(data.msgId) : 'msg_' + Date.now(),
|
||
conversationId: data.conversationId || this.conversationId,
|
||
senderId: data.senderId,
|
||
receiverId: data.receiverId,
|
||
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 (String(data.senderId) !== String(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 || m.id === String(data.clientMsgId))
|
||
if (msg) {
|
||
msg.id = data.msgId ? String(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: wsManager.userId || 'self',
|
||
receiverId: this.friendId,
|
||
type: 'text',
|
||
content: text,
|
||
timestamp: Date.now(),
|
||
status: 'sending'
|
||
}
|
||
|
||
// 乐观更新:立即显示
|
||
this.messages.push(msg)
|
||
this.inputText = ''
|
||
this.$nextTick(() => this.scrollToBottom())
|
||
|
||
// 键盘保持由 onInputBlur 处理,无需额外操作
|
||
|
||
// 通过 WebSocket 发送
|
||
wsManager.send('chat', {
|
||
receiverId: this.friendId,
|
||
content: text,
|
||
msgType: 'text',
|
||
clientMesgId: clientMsgId
|
||
})
|
||
},
|
||
|
||
/** 重发失败消息 */
|
||
resend(msg) {
|
||
msg.status = 'sending'
|
||
wsManager.send('chat', {
|
||
receiverId: this.friendId,
|
||
content: msg.content,
|
||
msgType: msg.type || 'text',
|
||
clientMesgId: msg.id
|
||
})
|
||
},
|
||
|
||
/** 点击发送按钮时记录时间戳 */
|
||
onSendTouch() {
|
||
this._sendTouchedAt = Date.now()
|
||
},
|
||
|
||
/** 输入框失焦:若因点击发送按钮导致,立即重聚焦保持键盘不收起 */
|
||
onInputBlur() {
|
||
if (this._sendTouchedAt && Date.now() - this._sendTouchedAt < 500) {
|
||
this._sendTouchedAt = 0
|
||
this.inputFocus = false
|
||
this.$nextTick(() => {
|
||
this.inputFocus = true
|
||
})
|
||
}
|
||
},
|
||
|
||
/** 输入时通知对方 */
|
||
onInput() {
|
||
wsManager.send('typing', { receiverId: this.friendId })
|
||
},
|
||
|
||
// === 辅助方法 ===
|
||
isSelf(msg) {
|
||
return String(msg.senderId) === String(wsManager.userId) || msg.senderId === 'self'
|
||
},
|
||
|
||
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));
|
||
transition: padding-bottom 0.15s ease-out;
|
||
}
|
||
|
||
.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>
|