mi-assessment/uniapp/pages/chat/index.vue
2026-02-09 14:45:06 +08:00

729 lines
17 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="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>