Files
hejiu/pages/chat/chat.vue
T
cg 9052a6f4da refactor(components): 统一组件点击事件命名规范
- 将 EventCard、FeedCard、FriendItem 组件的点击事件从 'tap' 改为 'item-click'
- 更新相关页面中的事件监听器以匹配新的事件名称
- 在聊天页面优化键盘处理逻辑,添加输入框失焦时的键盘保持功能
- 为圆圈页面添加导航防抖锁,防止页面重复打开
- 调整聊天输入框样式,增加10px缓冲避免与键盘贴合
2026-07-21 22:48:35 +08:00

644 lines
15 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" :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 } 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,
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()
// 监听键盘高度变化,动态调整输入栏位置(避免键盘顶起整个页面)
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 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())
// 键盘保持由 onInputBlur 处理,无需额外操作
// 通过 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)
},
/** 点击发送按钮时记录时间戳 */
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 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));
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>