729 lines
17 KiB
Vue
729 lines
17 KiB
Vue
<template>
|
||
<view class="chat-page">
|
||
<!-- 加载状态 -->
|
||
<Loading type="page" :loading="loading" />
|
||
|
||
<!-- 自定义导航栏 -->
|
||
<view class="custom-navbar" :style="{ paddingTop: statusBarHeight + 'px' }">
|
||
<view class="navbar-content">
|
||
<view class="navbar-back" @click="handleBack">
|
||
<text class="back-icon">‹</text>
|
||
</view>
|
||
<text class="navbar-title">{{ targetNickname || '聊天' }}</text>
|
||
<view class="navbar-actions"></view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 消息列表 -->
|
||
<scroll-view
|
||
class="content-scroll"
|
||
scroll-y
|
||
scroll-with-animation
|
||
:scroll-into-view="scrollToId"
|
||
:style="{ paddingTop: (statusBarHeight + 44) + 'px' }"
|
||
@scrolltoupper="loadMoreMessages"
|
||
>
|
||
<!-- 加载更多提示 -->
|
||
<view v-if="hasMore" class="load-more">
|
||
<text>上拉加载更多</text>
|
||
</view>
|
||
|
||
<!-- 消息列表 -->
|
||
<view class="message-list">
|
||
<view
|
||
v-for="(message, index) in messages"
|
||
:key="message.id"
|
||
:id="'msg-' + message.id"
|
||
class="message-item"
|
||
:class="{ 'mine': message.isMine }"
|
||
>
|
||
<!-- 时间分隔 -->
|
||
<view v-if="shouldShowTime(message, index)" class="time-divider">
|
||
<text>{{ formatMessageTime(message.createTime) }}</text>
|
||
</view>
|
||
|
||
<!-- 消息内容 -->
|
||
<view class="message-content">
|
||
<!-- 对方头像 -->
|
||
<image
|
||
v-if="!message.isMine"
|
||
class="avatar"
|
||
:src="targetAvatar || '/static/logo.png'"
|
||
mode="aspectFill"
|
||
/>
|
||
|
||
<!-- 消息气泡 -->
|
||
<view class="bubble-wrapper">
|
||
<!-- 文本消息 -->
|
||
<view
|
||
v-if="message.messageType === MessageType.TEXT"
|
||
class="bubble text-bubble"
|
||
>
|
||
<text>{{ message.content }}</text>
|
||
</view>
|
||
|
||
<!-- 图片消息 -->
|
||
<view
|
||
v-else-if="message.messageType === MessageType.IMAGE"
|
||
class="bubble image-bubble"
|
||
>
|
||
<image
|
||
:src="message.content"
|
||
mode="widthFix"
|
||
@click="previewImage(message.content)"
|
||
/>
|
||
</view>
|
||
|
||
<!-- 语音消息 -->
|
||
<view
|
||
v-else-if="message.messageType === MessageType.VOICE"
|
||
class="bubble voice-bubble"
|
||
@click="handlePlayVoice(message)"
|
||
>
|
||
<view class="voice-content">
|
||
<text class="voice-icon">🎤</text>
|
||
<text class="voice-duration">{{ message.voiceDuration }}"</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 消息状态 -->
|
||
<view v-if="message.isMine" class="message-status">
|
||
<text v-if="message.status === MessageStatus.SENDING" class="status sending">发送中</text>
|
||
<text v-else-if="message.status === MessageStatus.FAILED" class="status failed" @click="handleRetryMessage(message)">发送失败,点击重试</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 自己头像 -->
|
||
<image
|
||
v-if="message.isMine"
|
||
class="avatar"
|
||
:src="myAvatar || '/static/logo.png'"
|
||
mode="aspectFill"
|
||
/>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 底部占位 -->
|
||
<view class="bottom-placeholder" id="bottom-anchor"></view>
|
||
</scroll-view>
|
||
|
||
<!-- 底部输入区域 -->
|
||
<view class="bottom-action-bar">
|
||
<view class="input-area">
|
||
<!-- 切换语音/文本按钮 -->
|
||
<view class="mode-switch-btn" @click="handleSwitchInputMode">
|
||
<text class="icon">{{ inputMode === 'text' ? '🎤' : '⌨️' }}</text>
|
||
</view>
|
||
|
||
<!-- 文本输入模式 -->
|
||
<view v-if="inputMode === 'text'" class="text-input-wrapper">
|
||
<input
|
||
v-model="inputText"
|
||
class="message-input"
|
||
type="text"
|
||
placeholder="请输入..."
|
||
:adjust-position="true"
|
||
confirm-type="send"
|
||
@confirm="handleSendMessage"
|
||
/>
|
||
|
||
<!-- 表情按钮 -->
|
||
<view class="emoji-btn" @click="handleToggleEmoji">
|
||
<text class="icon">😊</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 语音输入模式 -->
|
||
<VoiceRecorder
|
||
v-else
|
||
class="voice-input-wrapper"
|
||
@send="handleSendVoice"
|
||
/>
|
||
|
||
<!-- 发送按钮 -->
|
||
<button
|
||
v-if="inputMode === 'text'"
|
||
class="send-btn"
|
||
:class="{ active: inputText.trim() }"
|
||
@click="handleSendMessage"
|
||
>
|
||
发送
|
||
</button>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 表情选择器 -->
|
||
<EmojiPicker
|
||
:visible="showEmojiPicker"
|
||
@close="showEmojiPicker = false"
|
||
@select="handleEmojiSelect"
|
||
/>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||
import { useUserStore } from '@/store/user.js'
|
||
import { useChatStore, MessageType, MessageStatus } from '@/store/chat.js'
|
||
import { getMessages, sendMessage, uploadVoice } from '@/api/chat.js'
|
||
import Loading from '@/components/Loading/index.vue'
|
||
import EmojiPicker from '@/components/EmojiPicker/index.vue'
|
||
import VoiceRecorder from '@/components/VoiceRecorder/index.vue'
|
||
import { formatTimestamp } from '@/utils/format.js'
|
||
import signalR from '@/utils/signalr.js'
|
||
|
||
const userStore = useUserStore()
|
||
const chatStore = useChatStore()
|
||
|
||
// 状态栏高度
|
||
const statusBarHeight = ref(20)
|
||
|
||
// 页面参数
|
||
const targetUserId = ref(0)
|
||
const sessionId = ref(0)
|
||
const targetNickname = ref('')
|
||
const targetAvatar = ref('')
|
||
|
||
// 状态
|
||
const loading = ref(true)
|
||
const messages = ref([])
|
||
const inputText = ref('')
|
||
const scrollToId = ref('')
|
||
const hasMore = ref(true)
|
||
const pageIndex = ref(1)
|
||
const inputMode = ref('text')
|
||
const showEmojiPicker = ref(false)
|
||
|
||
// 用户信息
|
||
const myAvatar = computed(() => userStore.avatar)
|
||
const myUserId = computed(() => userStore.userId)
|
||
|
||
// 获取系统信息
|
||
const getSystemInfo = () => {
|
||
uni.getSystemInfo({
|
||
success: (res) => {
|
||
statusBarHeight.value = res.statusBarHeight || 20
|
||
}
|
||
})
|
||
}
|
||
|
||
// 加载消息列表
|
||
const loadMessages = async (isLoadMore = false) => {
|
||
if (!isLoadMore) {
|
||
loading.value = true
|
||
}
|
||
|
||
try {
|
||
const res = await getMessages(sessionId.value, pageIndex.value, 20)
|
||
if (res && res.code === 0 && res.data) {
|
||
const serverMessages = res.data.items || []
|
||
const newMessages = serverMessages.map((msg) => ({
|
||
...msg,
|
||
id: msg.messageId ?? msg.id ?? Date.now(),
|
||
isMine: typeof msg.isSelf === 'boolean' ? msg.isSelf : msg.senderId === myUserId.value
|
||
}))
|
||
|
||
if (isLoadMore) {
|
||
messages.value = [...newMessages.reverse(), ...messages.value]
|
||
} else {
|
||
messages.value = newMessages.reverse()
|
||
await nextTick()
|
||
scrollToBottom()
|
||
}
|
||
|
||
hasMore.value = newMessages.length >= 20
|
||
chatStore.markSessionAsRead(sessionId.value)
|
||
}
|
||
} catch (error) {
|
||
console.error('加载消息失败:', error)
|
||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
const loadMoreMessages = () => {
|
||
if (!hasMore.value || loading.value) return
|
||
pageIndex.value++
|
||
loadMessages(true)
|
||
}
|
||
|
||
// 发送文本消息
|
||
const handleSendMessage = async () => {
|
||
const content = inputText.value.trim()
|
||
if (!content) return
|
||
|
||
inputText.value = ''
|
||
|
||
const localId = Date.now()
|
||
const localMessage = {
|
||
id: localId,
|
||
sessionId: sessionId.value,
|
||
senderId: myUserId.value,
|
||
receiverId: targetUserId.value,
|
||
messageType: MessageType.TEXT,
|
||
content,
|
||
status: MessageStatus.SENDING,
|
||
createTime: new Date().toISOString(),
|
||
isMine: true
|
||
}
|
||
|
||
messages.value.push(localMessage)
|
||
await nextTick()
|
||
scrollToBottom()
|
||
|
||
try {
|
||
const res = await sendMessage({
|
||
sessionId: sessionId.value,
|
||
receiverId: targetUserId.value,
|
||
messageType: MessageType.TEXT,
|
||
content
|
||
})
|
||
|
||
const msgIndex = messages.value.findIndex(m => m.id === localId)
|
||
if (msgIndex !== -1) {
|
||
if (res && res.code === 0) {
|
||
messages.value[msgIndex].status = MessageStatus.SENT
|
||
if (res.data?.messageId) {
|
||
messages.value[msgIndex].id = res.data.messageId
|
||
}
|
||
} else {
|
||
messages.value[msgIndex].status = MessageStatus.FAILED
|
||
}
|
||
}
|
||
} catch (error) {
|
||
const msgIndex = messages.value.findIndex(m => m.id === localId)
|
||
if (msgIndex !== -1) {
|
||
messages.value[msgIndex].status = MessageStatus.FAILED
|
||
}
|
||
uni.showToast({ title: '发送失败', icon: 'none' })
|
||
}
|
||
}
|
||
|
||
// 重试发送
|
||
const handleRetryMessage = async (message) => {
|
||
if (message.status !== MessageStatus.FAILED) return
|
||
|
||
const msgIndex = messages.value.findIndex(m => m.id === message.id)
|
||
if (msgIndex === -1) return
|
||
|
||
messages.value[msgIndex].status = MessageStatus.SENDING
|
||
|
||
try {
|
||
const res = await sendMessage({
|
||
sessionId: sessionId.value,
|
||
receiverId: targetUserId.value,
|
||
messageType: message.messageType,
|
||
content: message.content
|
||
})
|
||
|
||
if (res && res.code === 0) {
|
||
messages.value[msgIndex].status = MessageStatus.SENT
|
||
} else {
|
||
messages.value[msgIndex].status = MessageStatus.FAILED
|
||
}
|
||
} catch (error) {
|
||
messages.value[msgIndex].status = MessageStatus.FAILED
|
||
}
|
||
}
|
||
|
||
// 切换输入模式
|
||
const handleSwitchInputMode = () => {
|
||
inputMode.value = inputMode.value === 'text' ? 'voice' : 'text'
|
||
showEmojiPicker.value = false
|
||
}
|
||
|
||
// 切换表情选择器
|
||
const handleToggleEmoji = () => {
|
||
showEmojiPicker.value = !showEmojiPicker.value
|
||
}
|
||
|
||
// 选择表情
|
||
const handleEmojiSelect = (emoji) => {
|
||
inputText.value += emoji
|
||
showEmojiPicker.value = false
|
||
}
|
||
|
||
// 发送语音消息
|
||
const handleSendVoice = async (voiceData) => {
|
||
try {
|
||
uni.showLoading({ title: '发送中...' })
|
||
|
||
const uploadRes = await uploadVoice(voiceData.tempFilePath)
|
||
|
||
if (uploadRes && uploadRes.code === 0) {
|
||
const voiceUrl = uploadRes.data.url
|
||
|
||
const localId = Date.now()
|
||
const localMessage = {
|
||
id: localId,
|
||
sessionId: sessionId.value,
|
||
senderId: myUserId.value,
|
||
receiverId: targetUserId.value,
|
||
messageType: MessageType.VOICE,
|
||
voiceUrl: voiceUrl,
|
||
voiceDuration: voiceData.duration,
|
||
status: MessageStatus.SENDING,
|
||
createTime: new Date().toISOString(),
|
||
isMine: true
|
||
}
|
||
|
||
messages.value.push(localMessage)
|
||
await nextTick()
|
||
scrollToBottom()
|
||
|
||
const res = await sendMessage({
|
||
sessionId: sessionId.value,
|
||
receiverId: targetUserId.value,
|
||
messageType: MessageType.VOICE,
|
||
voiceUrl: voiceUrl,
|
||
voiceDuration: voiceData.duration
|
||
})
|
||
|
||
uni.hideLoading()
|
||
|
||
const msgIndex = messages.value.findIndex(m => m.id === localId)
|
||
if (msgIndex !== -1) {
|
||
messages.value[msgIndex].status = res?.code === 0 ? MessageStatus.SENT : MessageStatus.FAILED
|
||
}
|
||
} else {
|
||
uni.hideLoading()
|
||
uni.showToast({ title: '上传失败', icon: 'none' })
|
||
}
|
||
} catch (error) {
|
||
uni.hideLoading()
|
||
uni.showToast({ title: '发送失败', icon: 'none' })
|
||
}
|
||
}
|
||
|
||
// 播放语音
|
||
const handlePlayVoice = (message) => {
|
||
if (!message.voiceUrl) return
|
||
|
||
const audio = uni.createInnerAudioContext()
|
||
audio.src = message.voiceUrl
|
||
audio.play()
|
||
}
|
||
|
||
// 预览图片
|
||
const previewImage = (url) => {
|
||
uni.previewImage({ urls: [url] })
|
||
}
|
||
|
||
// 滚动到底部
|
||
const scrollToBottom = () => {
|
||
scrollToId.value = 'bottom-anchor'
|
||
}
|
||
|
||
// 是否显示时间
|
||
const shouldShowTime = (message, index) => {
|
||
if (index === 0) return true
|
||
const prevMessage = messages.value[index - 1]
|
||
const prevTime = new Date(prevMessage.createTime).getTime()
|
||
const currTime = new Date(message.createTime).getTime()
|
||
return currTime - prevTime > 5 * 60 * 1000 // 5分钟间隔
|
||
}
|
||
|
||
// 格式化消息时间
|
||
const formatMessageTime = (time) => {
|
||
return formatTimestamp(time)
|
||
}
|
||
|
||
// 返回
|
||
const handleBack = () => {
|
||
uni.navigateBack()
|
||
}
|
||
|
||
// SignalR 消息处理
|
||
const handleReceiveMessage = (msg) => {
|
||
if (msg.sessionId === sessionId.value) {
|
||
messages.value.push({
|
||
...msg,
|
||
id: msg.messageId || Date.now(),
|
||
isMine: false
|
||
})
|
||
nextTick(() => scrollToBottom())
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
getSystemInfo()
|
||
|
||
// 获取页面参数
|
||
const pages = getCurrentPages()
|
||
const currentPage = pages[pages.length - 1]
|
||
const options = currentPage.options || {}
|
||
|
||
sessionId.value = Number(options.sessionId) || 0
|
||
targetUserId.value = Number(options.targetUserId) || 0
|
||
targetNickname.value = options.nickname || ''
|
||
targetAvatar.value = options.avatar || ''
|
||
|
||
if (sessionId.value) {
|
||
loadMessages()
|
||
chatStore.setCurrentSession(sessionId.value)
|
||
|
||
// 连接 SignalR
|
||
signalR.connect().then(() => {
|
||
signalR.joinSession(sessionId.value)
|
||
signalR.on('ReceiveMessage', handleReceiveMessage)
|
||
}).catch(err => {
|
||
console.error('SignalR 连接失败:', err)
|
||
})
|
||
}
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
if (sessionId.value) {
|
||
signalR.leaveSession(sessionId.value)
|
||
signalR.off('ReceiveMessage', handleReceiveMessage)
|
||
}
|
||
chatStore.clearCurrentSession()
|
||
})
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.chat-page {
|
||
min-height: 100vh;
|
||
background-color: #f5f5f5;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.custom-navbar {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
background-color: #fff;
|
||
z-index: 100;
|
||
|
||
.navbar-content {
|
||
height: 44px;
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0 16px;
|
||
|
||
.navbar-back {
|
||
width: 40px;
|
||
.back-icon {
|
||
font-size: 28px;
|
||
color: #333;
|
||
}
|
||
}
|
||
|
||
.navbar-title {
|
||
flex: 1;
|
||
text-align: center;
|
||
font-size: 17px;
|
||
font-weight: 500;
|
||
color: #333;
|
||
}
|
||
|
||
.navbar-actions {
|
||
width: 40px;
|
||
}
|
||
}
|
||
}
|
||
|
||
.content-scroll {
|
||
flex: 1;
|
||
padding-bottom: 120px;
|
||
}
|
||
|
||
.load-more {
|
||
text-align: center;
|
||
padding: 16px;
|
||
color: #999;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.message-list {
|
||
padding: 16px;
|
||
}
|
||
|
||
.message-item {
|
||
margin-bottom: 16px;
|
||
|
||
&.mine .message-content {
|
||
flex-direction: row-reverse;
|
||
|
||
.bubble {
|
||
background-color: #95ec69;
|
||
|
||
&::before {
|
||
right: auto;
|
||
left: -6px;
|
||
border-right-color: #95ec69;
|
||
border-left-color: transparent;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.time-divider {
|
||
text-align: center;
|
||
margin: 16px 0;
|
||
|
||
text {
|
||
font-size: 12px;
|
||
color: #999;
|
||
background-color: rgba(0, 0, 0, 0.05);
|
||
padding: 4px 12px;
|
||
border-radius: 4px;
|
||
}
|
||
}
|
||
|
||
.message-content {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
|
||
.avatar {
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 4px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.bubble-wrapper {
|
||
margin: 0 12px;
|
||
max-width: 70%;
|
||
}
|
||
|
||
.bubble {
|
||
padding: 10px 14px;
|
||
border-radius: 8px;
|
||
background-color: #fff;
|
||
position: relative;
|
||
word-break: break-all;
|
||
|
||
text {
|
||
font-size: 15px;
|
||
line-height: 1.5;
|
||
color: #333;
|
||
}
|
||
}
|
||
|
||
.image-bubble {
|
||
padding: 4px;
|
||
|
||
image {
|
||
max-width: 200px;
|
||
border-radius: 4px;
|
||
}
|
||
}
|
||
|
||
.voice-bubble {
|
||
min-width: 80px;
|
||
|
||
.voice-content {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
|
||
.voice-icon {
|
||
font-size: 20px;
|
||
}
|
||
|
||
.voice-duration {
|
||
font-size: 14px;
|
||
color: #666;
|
||
}
|
||
}
|
||
}
|
||
|
||
.message-status {
|
||
margin-top: 4px;
|
||
|
||
.status {
|
||
font-size: 12px;
|
||
|
||
&.sending {
|
||
color: #999;
|
||
}
|
||
|
||
&.failed {
|
||
color: #ff4d4f;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.bottom-placeholder {
|
||
height: 20px;
|
||
}
|
||
|
||
.bottom-action-bar {
|
||
position: fixed;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
background-color: #f7f7f7;
|
||
padding: 8px 12px;
|
||
padding-bottom: calc(8px + env(safe-area-inset-bottom));
|
||
border-top: 1px solid #e5e5e5;
|
||
|
||
.input-area {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
|
||
.mode-switch-btn {
|
||
width: 36px;
|
||
height: 36px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
|
||
.icon {
|
||
font-size: 24px;
|
||
}
|
||
}
|
||
|
||
.text-input-wrapper {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
background-color: #fff;
|
||
border-radius: 8px;
|
||
padding: 0 12px;
|
||
|
||
.message-input {
|
||
flex: 1;
|
||
height: 36px;
|
||
font-size: 15px;
|
||
}
|
||
|
||
.emoji-btn {
|
||
padding: 4px;
|
||
|
||
.icon {
|
||
font-size: 24px;
|
||
}
|
||
}
|
||
}
|
||
|
||
.voice-input-wrapper {
|
||
flex: 1;
|
||
}
|
||
|
||
.send-btn {
|
||
width: 60px;
|
||
height: 36px;
|
||
font-size: 14px;
|
||
background-color: #07c160;
|
||
color: #fff;
|
||
border-radius: 6px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
|
||
&:not(.active) {
|
||
background-color: #ccc;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</style>
|