Files
hejiu/pages/chat/chat.vue
T
cg 99723497fa rename(app): 将应用名称从"干杯日记"更改为"碰盏日记"
- 替换所有文件中的品牌名称引用,包括App.vue、pages.json、uni.scss等
- 更新全局样式、导航栏标题和组件中的文案显示
- 修改分享功能中的应用名称显示
- 调整聊天页面的输入栏键盘适配逻辑
- 在多个页面组件中添加事件发射声明以支持交互功能
2026-07-21 22:20:08 +08:00

630 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"
/>
</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: '',
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) {
return { paddingBottom: this.keyboardHeight + '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())
// 保持键盘不收起:先失焦再重新聚焦输入框(仿微信发送后键盘保留)
this.inputFocus = false
this.$nextTick(() => {
this.inputFocus = true
})
// 通过 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() {
this.inputFocus = true
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>