xiangyixiangqin/miniapp/pages/chat/index.vue
2026-01-18 18:13:01 +08:00

1717 lines
43 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">详情资料</text>
<view class="navbar-actions">
<text class="action-icon" @click="handleMore">···</text>
<text class="action-icon" @click="handleViewProfile"></text>
</view>
</view>
</view>
<!-- 内容区域 -->
<scroll-view
class="content-scroll"
scroll-y
:scroll-into-view="scrollToId"
:style="{ paddingTop: (statusBarHeight + 44) + 'px' }"
@scrolltoupper="loadMoreMessages"
>
<!-- 用户信息卡片 -->
<view class="user-info-card" @click="handleViewProfile">
<view class="user-avatar">
<image :src="targetAvatar || '/static/default-avatar.png'" mode="aspectFill" />
</view>
<view class="user-details">
<view class="user-name-row">
<text class="nickname">{{ targetNickname }}</text>
<text class="relationship" v-if="targetRelationship">({{ targetRelationship }})</text>
<view class="user-tags">
<text v-if="targetIsMember" class="tag tag-vip">VIP会员</text>
<text v-if="targetIsRealName" class="tag tag-realname">已实名</text>
</view>
</view>
<text class="xiangqin-no">相亲ID: {{ targetXiangQinNo }}</text>
</view>
</view>
<!-- 基本资料区域 -->
<view class="basic-info-section" v-if="targetUserDetail">
<view class="gender-year">
<text :class="{ male: targetUserDetail.childGender === 1 }">
{{ targetUserDetail.childGender === 1 ? '男' : '女' }} · {{ targetUserDetail.birthYear }}年
</text>
</view>
<view class="info-grid">
<view class="info-item">
<text class="label">年龄</text>
<text class="value">{{ targetUserDetail.age }}岁</text>
</view>
<view class="info-item">
<text class="label">身高</text>
<text class="value">{{ targetUserDetail.height }}cm</text>
</view>
<view class="info-item">
<text class="label">学历</text>
<text class="value">{{ getEducationText(targetUserDetail.education) }}</text>
</view>
<view class="info-item">
<text class="label">体重</text>
<text class="value">{{ targetUserDetail.weight ? targetUserDetail.weight + 'kg' : '未填写' }}</text>
</view>
<view class="info-item">
<text class="label">收入</text>
<text class="value">{{ getIncomeText(targetUserDetail.monthlyIncome) }}</text>
</view>
<view class="info-item">
<text class="label">职业</text>
<text class="value">{{ targetUserDetail.occupation || '未填写' }}</text>
</view>
<view class="info-item">
<text class="label">现居</text>
<text class="value">{{ targetUserDetail.workCity || '未填写' }}</text>
</view>
<view class="info-item">
<text class="label">家乡</text>
<text class="value">{{ targetUserDetail.homeCity || '未填写' }}</text>
</view>
</view>
</view>
<!-- 聊天安全提示 -->
<view class="safety-tip">
<text>聊天安全提示:请友好和谐交流,请友好和谐交流,</text>
<text>请友好和谐交流,请友好和谐交流哦。</text>
</view>
<!-- 加载更多提示 -->
<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/default-avatar.png'"
mode="aspectFill"
@click="handleViewProfile"
/>
<!-- 消息气泡 -->
<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 class="voice-wave" :class="{ playing: playingVoiceId === message.id }">
<view class="wave-bar"></view>
<view class="wave-bar"></view>
<view class="wave-bar"></view>
</view>
</view>
</view>
<!-- 交换微信请求卡片 -->
<view
v-else-if="message.messageType === MessageType.EXCHANGE_WECHAT"
class="exchange-card"
>
<view class="exchange-card-header">
<text>{{ getExchangeTitle(message) }}</text>
</view>
<!-- 待处理状态 - 显示操作按钮 -->
<view v-if="showExchangeActions(message)" class="exchange-card-actions">
<button class="action-btn reject-btn" @click="handleRespondExchange(message.id, false)">拒绝</button>
<button class="action-btn accept-btn" @click="handleRespondExchange(message.id, true)">同意</button>
</view>
<!-- 已接受 - 显示微信号 -->
<view v-else-if="message.status === ExchangeStatus.ACCEPTED" class="exchange-card-result accepted">
<text class="result-label">{{ targetNickname }}{{ targetRelationship || '本人' }})的微信号</text>
<text class="wechat-no">{{ message.exchangedContent || 'abcv123123' }}</text>
<button class="copy-btn" @click="handleCopyWeChat(message.exchangedContent)">点击复制微信号</button>
</view>
<!-- 已拒绝 -->
<view v-else-if="message.status === ExchangeStatus.REJECTED" class="exchange-card-result rejected">
<text>{{ message.isMine ? targetNickname : '您' }}拒绝了交换微信号</text>
</view>
<!-- 等待中 -->
<view v-else class="exchange-card-result pending">
<text>等待对方回应...</text>
</view>
</view>
<!-- 交换照片请求卡片 -->
<view
v-else-if="message.messageType === MessageType.EXCHANGE_PHOTO"
class="exchange-card"
>
<view class="exchange-card-header">
<text>{{ getExchangePhotoTitle(message) }}</text>
</view>
<!-- 待处理状态 - 显示操作按钮 -->
<view v-if="showExchangeActions(message)" class="exchange-card-actions">
<button class="action-btn reject-btn" @click="handleRespondExchange(message.id, false)">拒绝</button>
<button class="action-btn accept-btn" @click="handleRespondExchange(message.id, true)">同意</button>
</view>
<!-- 已接受 - 显示照片 -->
<view v-else-if="message.status === ExchangeStatus.ACCEPTED" class="exchange-card-result accepted">
<text class="result-label">{{ targetNickname }}{{ targetRelationship || '本人' }})的照片共{{ message.photoCount || 5 }}张</text>
<view class="photo-preview" v-if="message.photos && message.photos.length > 0">
<image
v-for="(photo, idx) in message.photos.slice(0, 3)"
:key="idx"
:src="photo"
mode="aspectFill"
@click="previewPhotos(message.photos, idx)"
/>
</view>
</view>
<!-- 已拒绝 -->
<view v-else-if="message.status === ExchangeStatus.REJECTED" class="exchange-card-result rejected">
<text>{{ message.isMine ? targetNickname : '您' }}拒绝了交换孩子照片</text>
</view>
<!-- 等待中 -->
<view v-else class="exchange-card-result pending">
<text>等待对方回应...</text>
</view>
</view>
<!-- 消息状态 -->
<view v-if="message.isMine && message.messageType === MessageType.TEXT" class="message-status">
<text v-if="message.status === MessageStatus.SENDING" class="status sending">发送中</text>
<view v-else-if="message.status === MessageStatus.FAILED" class="status-failed-wrapper">
<text class="status failed">发送失败</text>
<text class="retry-btn" @click="handleRetryMessage(message)">重试</text>
</view>
</view>
</view>
<!-- 自己头像 -->
<image
v-if="message.isMine"
class="avatar"
:src="myAvatar || '/static/default-avatar.png'"
mode="aspectFill"
/>
</view>
</view>
</view>
<!-- 底部占位 -->
<view class="bottom-placeholder" id="bottom-anchor"></view>
</scroll-view>
<!-- 底部操作栏 -->
<view class="bottom-action-bar">
<!-- 三个操作按钮 - 键盘弹起时隐藏 -->
<view class="action-buttons" v-show="!isInputFocused && inputMode === 'text'">
<button class="action-btn" @click="handleExchangeWeChat">交换微信</button>
<button class="action-btn" @click="handleCall">拨打电话</button>
<button class="action-btn" @click="handleExchangePhoto">交换照片</button>
</view>
<!-- 输入区域 -->
<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="请输入..."
placeholder-style="color: #999;"
:adjust-position="true"
confirm-type="send"
@focus="handleInputFocus"
@blur="handleInputBlur"
@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, exchangeWeChat, exchangePhoto, respondExchange, uploadVoice } from '@/api/chat.js'
import { getUserDetail } from '@/api/user.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 ExchangeStatus = {
PENDING: 0,
ACCEPTED: 1,
REJECTED: 2
}
// 状态栏高度
const statusBarHeight = ref(20)
// 页面参数
const targetUserId = ref(0)
const sessionId = ref(0)
const targetNickname = ref('')
const targetAvatar = ref('')
const targetRelationship = ref('')
const targetIsMember = ref(false)
const targetIsRealName = ref(false)
const targetXiangQinNo = ref('')
const targetUserDetail = ref(null)
// 状态
const loading = ref(true)
const messages = ref([])
const inputText = ref('')
const scrollToId = ref('')
const hasMore = ref(true)
const pageIndex = ref(1)
const isInputFocused = ref(false)
// 输入模式text | voice
const inputMode = ref('text')
// 表情选择器显示状态
const showEmojiPicker = ref(false)
// 当前播放的语音ID
const playingVoiceId = ref(null)
const innerAudioContext = ref(null)
// 用户信息
const myAvatar = computed(() => userStore.avatar)
const myUserId = computed(() => userStore.userId)
// 选项映射
const educationMap = {
1: '高中',
2: '中专',
3: '大专',
4: '本科',
5: '研究生',
6: '博士及以上'
}
const incomeMap = {
1: '5千以下',
2: '5千-8千/月',
3: '8千-1万/月',
4: '1万-2万/月',
5: '2万以上'
}
const relationshipMap = {
1: '父亲',
2: '母亲',
3: '本人'
}
// 获取学历文本
const getEducationText = (value) => {
return educationMap[value] || '未填写'
}
// 获取收入文本
const getIncomeText = (value) => {
return incomeMap[value] || '未填写'
}
// 获取系统信息
const getSystemInfo = () => {
uni.getSystemInfo({
success: (res) => {
statusBarHeight.value = res.statusBarHeight || 20
}
})
}
// 加载目标用户详情
const loadTargetUserDetail = async () => {
if (!targetUserId.value) return
try {
const res = await getUserDetail(targetUserId.value)
if (res && res.code === 0 && res.data) {
targetUserDetail.value = res.data
targetNickname.value = res.data.nickname || targetNickname.value
targetAvatar.value = res.data.avatar || targetAvatar.value
targetIsMember.value = res.data.isMember || false
targetIsRealName.value = res.data.isRealName || false
targetXiangQinNo.value = res.data.xiangQinNo || ''
targetRelationship.value = relationshipMap[res.data.relationship] || ''
}
} catch (error) {
console.error('加载用户详情失败:', error)
}
}
// 加载消息列表 (Requirements 7.1)
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) => {
// 后端字段 MessageId / IsSelf 转为前端使用的 id / isMine
const mapped = {
...msg,
id: msg.messageId ?? msg.id ?? Date.now(),
isMine: typeof msg.isSelf === 'boolean' ? msg.isSelf : msg.senderId === myUserId.value
}
return mapped
})
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)
}
// 发送文本消息 (Requirements 7.2)
const handleSendMessage = async () => {
const content = inputText.value.trim()
if (!content) return
inputText.value = ''
const localMessage = {
id: Date.now(),
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
})
if (res && res.code === 0) {
localMessage.status = MessageStatus.SENT
if (res.data && res.data.id) {
localMessage.id = res.data.id
} else if (res.data && res.data.messageId) {
// 后端返回 MessageId 字段
localMessage.id = res.data.messageId
}
} else {
localMessage.status = MessageStatus.FAILED
}
} catch (error) {
localMessage.status = MessageStatus.FAILED
uni.showToast({ title: '发送失败', icon: 'none' })
}
}
// 重试发送失败的消息
const handleRetryMessage = async (message) => {
if (message.status !== MessageStatus.FAILED) return
message.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) {
message.status = MessageStatus.SENT
if (res.data?.messageId) {
message.id = res.data.messageId
}
uni.showToast({ title: '发送成功', icon: 'success' })
} else {
message.status = MessageStatus.FAILED
uni.showToast({ title: '发送失败', icon: 'none' })
}
} catch (error) {
message.status = MessageStatus.FAILED
uni.showToast({ title: '发送失败', icon: 'none' })
}
}
// 切换输入模式
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 localMessage = {
id: Date.now(),
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()
if (res && res.code === 0) {
localMessage.status = MessageStatus.SENT
if (res.data && res.data.messageId) {
localMessage.id = res.data.messageId
}
} else {
localMessage.status = MessageStatus.FAILED
}
} else {
uni.hideLoading()
uni.showToast({ title: '上传失败', icon: 'none' })
}
} catch (error) {
uni.hideLoading()
console.error('发送语音失败:', error)
uni.showToast({ title: '发送失败', icon: 'none' })
}
}
// 播放语音
const handlePlayVoice = (message) => {
if (!message.voiceUrl) return
// 如果正在播放同一条语音,则停止
if (playingVoiceId.value === message.id) {
stopVoice()
return
}
// 停止之前的播放
stopVoice()
// 创建音频上下文
if (!innerAudioContext.value) {
innerAudioContext.value = uni.createInnerAudioContext()
innerAudioContext.value.onEnded(() => {
playingVoiceId.value = null
})
innerAudioContext.value.onError((err) => {
console.error('语音播放失败:', err)
playingVoiceId.value = null
uni.showToast({ title: '播放失败', icon: 'none' })
})
}
// 播放语音
innerAudioContext.value.src = message.voiceUrl
innerAudioContext.value.play()
playingVoiceId.value = message.id
}
// 停止播放
const stopVoice = () => {
if (innerAudioContext.value) {
innerAudioContext.value.stop()
}
playingVoiceId.value = null
}
// 交换微信 (Requirements 7.3)
const handleExchangeWeChat = async () => {
try {
const res = await exchangeWeChat(sessionId.value, targetUserId.value)
if (res && res.code === 0) {
const exchangeMessage = {
// 后端返回的是 RequestMessageId
id: res.data?.requestMessageId || Date.now(),
sessionId: sessionId.value,
senderId: myUserId.value,
receiverId: targetUserId.value,
messageType: MessageType.EXCHANGE_WECHAT,
content: '',
status: ExchangeStatus.PENDING,
createTime: new Date().toISOString(),
isMine: true
}
messages.value.push(exchangeMessage)
await nextTick()
scrollToBottom()
uni.showToast({ title: '已发送交换请求', icon: 'success' })
}
} catch (error) {
uni.showToast({ title: '发送失败', icon: 'none' })
}
}
const handleExchangePhoto = async () => {
try {
const res = await exchangePhoto(sessionId.value, targetUserId.value)
if (res && res.code === 0) {
const exchangeMessage = {
// 后端返回的是 RequestMessageId
id: res.data?.requestMessageId || Date.now(),
sessionId: sessionId.value,
senderId: myUserId.value,
receiverId: targetUserId.value,
messageType: MessageType.EXCHANGE_PHOTO,
content: '',
status: ExchangeStatus.PENDING,
createTime: new Date().toISOString(),
isMine: true
}
messages.value.push(exchangeMessage)
await nextTick()
scrollToBottom()
uni.showToast({ title: '已发送交换请求', icon: 'success' })
}
} catch (error) {
uni.showToast({ title: '发送失败', icon: 'none' })
}
}
// 响应交换请求 (Requirements 7.4, 7.5)
const handleRespondExchange = async (messageId, accept) => {
try {
const res = await respondExchange(messageId, accept)
if (res && res.code === 0) {
const message = messages.value.find(m => m.id === messageId)
if (message) {
message.status = accept ? ExchangeStatus.ACCEPTED : ExchangeStatus.REJECTED
if (accept && res.data?.exchangedData) {
// 交换结果数据微信号或照片列表JSON
if (message.messageType === MessageType.EXCHANGE_WECHAT) {
message.exchangedContent = res.data.exchangedData
} else if (message.messageType === MessageType.EXCHANGE_PHOTO) {
try {
const photos = JSON.parse(res.data.exchangedData || '[]')
if (Array.isArray(photos)) {
message.photos = photos
message.photoCount = photos.length
}
} catch (e) {
console.error('解析交换照片数据失败:', e)
}
}
}
}
uni.showToast({
title: accept ? '已接受' : '已拒绝',
icon: 'success'
})
}
} catch (error) {
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
// Property 15: Exchange Request UI helpers
const showExchangeActions = (message) => {
return !message.isMine && message.status === ExchangeStatus.PENDING
}
const getExchangeTitle = (message) => {
return message.isMine ? '您发起了交换微信请求' : '对方想和您交换微信'
}
const getExchangePhotoTitle = (message) => {
return message.isMine ? '您发起了交换照片请求' : '对方想和您交换照片'
}
const getExchangeStatusClass = (message) => {
if (message.status === ExchangeStatus.ACCEPTED) return 'accepted'
if (message.status === ExchangeStatus.REJECTED) return 'rejected'
return 'pending'
}
const getExchangeStatusText = (message) => {
if (message.status === ExchangeStatus.ACCEPTED) return '已接受'
if (message.status === ExchangeStatus.REJECTED) return '已拒绝'
if (message.isMine) return '等待对方回应'
return ''
}
// 复制微信号
const handleCopyWeChat = (wechatNo) => {
if (!wechatNo) {
uni.showToast({ title: '微信号为空', icon: 'none' })
return
}
uni.setClipboardData({
data: wechatNo,
success: () => {
uni.showToast({ title: '已复制微信号', icon: 'success' })
}
})
}
// 预览多张照片
const previewPhotos = (photos, index) => {
uni.previewImage({
urls: photos,
current: photos[index] || photos[0]
})
}
// 拨打电话
const handleCall = () => {
uni.showToast({ title: '请通过微信联系对方', icon: 'none' })
}
// 查看用户资料
const handleViewProfile = () => {
uni.navigateTo({ url: `/pages/profile/detail?userId=${targetUserId.value}` })
}
// 输入框焦点处理
const handleInputFocus = () => {
isInputFocused.value = true
}
const handleInputBlur = () => {
// 延迟隐藏,避免点击发送按钮时按钮消失
setTimeout(() => {
isInputFocused.value = false
}, 100)
}
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
}
const formatMessageTime = (timeStr) => {
return formatTimestamp(timeStr)
}
const previewImage = (url) => {
uni.previewImage({
urls: [url],
current: url
})
}
const scrollToBottom = () => {
scrollToId.value = 'bottom-anchor'
}
const handleBack = () => {
uni.navigateBack()
}
const handleMore = () => {
uni.showActionSheet({
itemList: ['查看资料', '举报'],
success: (res) => {
if (res.tapIndex === 0) {
uni.navigateTo({ url: `/pages/profile/detail?userId=${targetUserId.value}` })
} else if (res.tapIndex === 1) {
uni.showToast({ title: '举报功能开发中', icon: 'none' })
}
}
})
}
onMounted(async () => {
getSystemInfo()
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const options = currentPage?.options || {}
if (options.targetUserId) {
targetUserId.value = parseInt(options.targetUserId)
}
if (options.sessionId) {
sessionId.value = parseInt(options.sessionId)
}
if (options.nickname) {
targetNickname.value = decodeURIComponent(options.nickname)
}
if (options.avatar) {
targetAvatar.value = decodeURIComponent(options.avatar)
}
// 加载目标用户详情
loadTargetUserDetail()
if (sessionId.value) {
chatStore.setCurrentSession(sessionId.value)
loadMessages()
} else if (targetUserId.value) {
sessionId.value = targetUserId.value
chatStore.setCurrentSession(sessionId.value)
loadMessages()
} else {
uni.showToast({ title: '参数错误', icon: 'none' })
setTimeout(() => {
uni.navigateBack()
}, 1500)
return
}
// 连接 SignalR 并监听消息
try {
await signalR.connect()
console.log('[Chat] SignalR 连接成功')
// 加入会话组
if (sessionId.value) {
await signalR.joinSession(sessionId.value)
}
// 监听新消息
signalR.on('ReceiveMessage', handleReceiveMessage)
// 监听交换请求
signalR.on('ExchangeRequest', handleReceiveMessage)
// 监听交换响应
signalR.on('ExchangeResponse', handleExchangeResponse)
// 监听消息已读
signalR.on('MessagesRead', handleMessagesRead)
} catch (err) {
console.error('[Chat] SignalR 连接失败:', err)
// 连接失败不影响基本功能,只是没有实时推送
}
})
// 处理接收到的新消息
const handleReceiveMessage = (message) => {
console.log('[Chat] 收到新消息:', message)
// 检查是否是当前会话的消息
if (message.sessionId !== sessionId.value) {
return
}
// 检查消息是否已存在(避免重复)
const exists = messages.value.some(m => m.id === message.messageId)
if (exists) {
return
}
// 添加消息到列表
const newMessage = {
id: message.messageId,
sessionId: message.sessionId,
senderId: message.senderId,
receiverId: message.receiverId,
messageType: message.messageType,
content: message.content,
voiceUrl: message.voiceUrl,
voiceDuration: message.voiceDuration,
extraData: message.extraData,
status: message.messageType >= 4 ? ExchangeStatus.PENDING : MessageStatus.SENT,
createTime: message.createTime,
isMine: message.isSelf || message.senderId === myUserId.value
}
messages.value.push(newMessage)
nextTick(() => {
scrollToBottom()
})
// 播放提示音(可选)
// uni.vibrateShort()
}
// 处理交换响应
const handleExchangeResponse = (message) => {
console.log('[Chat] 收到交换响应:', message)
// 更新原始请求消息的状态
if (message.extraData) {
try {
const extraData = JSON.parse(message.extraData)
if (extraData.requestMessageId) {
const requestMsg = messages.value.find(m => m.id === extraData.requestMessageId)
if (requestMsg) {
requestMsg.status = extraData.status
if (extraData.status === ExchangeStatus.ACCEPTED) {
// 更新交换的数据
if (requestMsg.messageType === MessageType.EXCHANGE_WECHAT) {
requestMsg.exchangedContent = extraData.senderWeChat || extraData.receiverWeChat
} else if (requestMsg.messageType === MessageType.EXCHANGE_PHOTO) {
requestMsg.photos = extraData.senderPhotos || extraData.receiverPhotos
requestMsg.photoCount = requestMsg.photos?.length || 0
}
}
}
}
} catch (e) {
console.error('[Chat] 解析交换响应数据失败:', e)
}
}
}
// 处理消息已读通知
const handleMessagesRead = (data) => {
console.log('[Chat] 消息已读:', data)
if (data.sessionId === sessionId.value) {
// 更新消息状态为已读
messages.value.forEach(msg => {
if (msg.isMine && !msg.isRead) {
msg.isRead = true
}
})
}
}
onUnmounted(() => {
chatStore.clearCurrentSession()
// 停止语音播放
stopVoice()
if (innerAudioContext.value) {
innerAudioContext.value.destroy()
}
// 离开会话组
if (sessionId.value) {
signalR.leaveSession(sessionId.value)
}
// 移除事件监听
signalR.off('ReceiveMessage', handleReceiveMessage)
signalR.off('ExchangeRequest', handleReceiveMessage)
signalR.off('ExchangeResponse', handleExchangeResponse)
signalR.off('MessagesRead', handleMessagesRead)
})
</script>
<style lang="scss" scoped>
.chat-page {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f5f6fa;
}
// 自定义导航栏
.custom-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: linear-gradient(135deg, #ffb5b5 0%, #ff9a9a 100%);
.navbar-content {
height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24rpx;
.navbar-back {
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
.back-icon {
font-size: 64rpx;
color: #fff;
font-weight: 400;
}
}
.navbar-title {
font-size: 34rpx;
font-weight: 600;
color: #fff;
}
.navbar-actions {
display: flex;
align-items: center;
gap: 24rpx;
.action-icon {
font-size: 40rpx;
color: #fff;
}
}
}
}
// 内容滚动区域
.content-scroll {
flex: 1;
padding-bottom: 280rpx;
}
// 用户信息卡片
.user-info-card {
display: flex;
align-items: center;
padding: 24rpx 32rpx;
background: #fff;
margin: 24rpx;
border-radius: 24rpx;
.user-avatar {
width: 100rpx;
height: 100rpx;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
image {
width: 100%;
height: 100%;
}
}
.user-details {
margin-left: 24rpx;
flex: 1;
.user-name-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8rpx;
margin-bottom: 8rpx;
.nickname {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.relationship {
font-size: 28rpx;
color: #666;
}
.user-tags {
display: flex;
gap: 8rpx;
.tag {
font-size: 20rpx;
padding: 4rpx 12rpx;
border-radius: 16rpx;
&.tag-vip {
background: linear-gradient(135deg, #fff3e0 0%, #ffe0b2 100%);
color: #ff9800;
}
&.tag-realname {
background: #e8f5e9;
color: #4caf50;
}
}
}
}
.xiangqin-no {
font-size: 24rpx;
color: #999;
}
}
}
// 基本资料区域
.basic-info-section {
background: #fff;
margin: 0 24rpx 24rpx;
border-radius: 24rpx;
padding: 28rpx;
.gender-year {
margin-bottom: 24rpx;
text {
font-size: 40rpx;
font-weight: 600;
color: #ff6b6b;
&.male {
color: #4a90d9;
}
}
}
.info-grid {
display: flex;
flex-wrap: wrap;
.info-item {
width: 50%;
display: flex;
align-items: center;
margin-bottom: 16rpx;
.label {
font-size: 26rpx;
color: #999;
width: 70rpx;
flex-shrink: 0;
}
.value {
font-size: 26rpx;
color: #333;
margin-left: 12rpx;
}
}
}
}
// 聊天安全提示
.safety-tip {
text-align: center;
padding: 20rpx 32rpx;
margin-bottom: 20rpx;
text {
display: block;
font-size: 22rpx;
color: #999;
line-height: 1.6;
}
}
// 加载更多
.load-more {
text-align: center;
padding: 20rpx;
text {
font-size: 24rpx;
color: #999;
}
}
// 消息列表
.message-list {
padding: 0 24rpx;
}
.message-item {
margin-bottom: 30rpx;
.time-divider {
text-align: center;
margin-bottom: 20rpx;
text {
font-size: 24rpx;
color: #999;
background-color: rgba(0, 0, 0, 0.05);
padding: 8rpx 20rpx;
border-radius: 20rpx;
}
}
.message-content {
display: flex;
align-items: flex-start;
}
.avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
flex-shrink: 0;
}
.bubble-wrapper {
max-width: 70%;
margin: 0 16rpx;
}
.bubble {
padding: 20rpx 24rpx;
border-radius: 16rpx;
word-break: break-all;
&.text-bubble {
background-color: #fff;
text {
font-size: 28rpx;
color: #333;
line-height: 1.5;
}
}
&.image-bubble {
padding: 8rpx;
background-color: #fff;
image {
max-width: 300rpx;
border-radius: 8rpx;
}
}
}
// 自己发送的消息 - 整体靠右,头像在最右边
&.mine {
.message-content {
justify-content: flex-end;
}
.bubble-wrapper {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.bubble {
&.text-bubble {
background: linear-gradient(135deg, #a8e6cf 0%, #88d8b0 100%);
text {
color: #333;
}
}
}
.message-status {
text-align: right;
}
}
// 交换卡片样式
.exchange-card {
background: #fff;
border-radius: 16rpx;
overflow: hidden;
min-width: 400rpx;
.exchange-card-header {
padding: 24rpx;
background: #f8f8f8;
border-bottom: 1rpx solid #f0f0f0;
text {
font-size: 26rpx;
color: #666;
}
}
.exchange-card-actions {
display: flex;
padding: 24rpx;
gap: 24rpx;
.action-btn {
flex: 1;
height: 72rpx;
line-height: 72rpx;
font-size: 28rpx;
border-radius: 36rpx;
border: none;
&::after {
border: none;
}
&.reject-btn {
background: #f5f5f5;
color: #666;
}
&.accept-btn {
background: linear-gradient(135deg, #ff9a9a 0%, #ff6b6b 100%);
color: #fff;
}
}
}
.exchange-card-result {
padding: 24rpx;
.result-label {
display: block;
font-size: 26rpx;
color: #666;
margin-bottom: 16rpx;
}
.wechat-no {
display: block;
font-size: 32rpx;
font-weight: 600;
color: #333;
margin-bottom: 20rpx;
}
.copy-btn {
width: 100%;
height: 72rpx;
line-height: 72rpx;
background: linear-gradient(135deg, #4cd964 0%, #34c759 100%);
color: #fff;
font-size: 28rpx;
border-radius: 36rpx;
border: none;
&::after {
border: none;
}
}
.photo-preview {
display: flex;
gap: 12rpx;
margin-top: 16rpx;
image {
width: 120rpx;
height: 120rpx;
border-radius: 8rpx;
}
}
&.rejected {
text {
color: #999;
font-size: 26rpx;
}
}
&.pending {
text {
color: #ff9500;
font-size: 26rpx;
}
}
}
}
.message-status {
margin-top: 8rpx;
.status {
font-size: 22rpx;
&.sending {
color: #999;
}
&.failed {
color: #ff5252;
}
}
.status-failed-wrapper {
display: flex;
align-items: center;
gap: 12rpx;
}
.retry-btn {
font-size: 22rpx;
color: #1890ff;
text-decoration: underline;
}
}
}
.bottom-placeholder {
height: 40rpx;
}
// 底部操作栏
.bottom-action-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #f5f6fa;
padding-bottom: env(safe-area-inset-bottom);
.action-buttons {
display: flex;
justify-content: space-between;
padding: 20rpx 24rpx;
gap: 20rpx;
.action-btn {
flex: 1;
height: 80rpx;
line-height: 80rpx;
background: #fff;
border-radius: 40rpx;
font-size: 28rpx;
color: #333;
text-align: center;
border: none;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05);
&::after {
border: none;
}
&:active {
background: #f5f5f5;
}
}
}
.input-area {
display: flex;
align-items: center;
padding: 16rpx 24rpx;
gap: 16rpx;
background: #fff;
.mode-switch-btn {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
background-color: #f5f5f5;
border-radius: 50%;
flex-shrink: 0;
.icon {
font-size: 40rpx;
}
&:active {
background-color: #e8e8e8;
}
}
.text-input-wrapper {
flex: 1;
display: flex;
align-items: center;
gap: 16rpx;
background-color: #f5f5f5;
border-radius: 32rpx;
padding: 0 24rpx;
.message-input {
flex: 1;
height: 64rpx;
font-size: 28rpx;
background: transparent;
border: none;
}
.emoji-btn {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
.icon {
font-size: 36rpx;
}
&:active {
opacity: 0.7;
}
}
}
.voice-input-wrapper {
flex: 1;
}
.input-wrapper {
flex: 1;
min-width: 0;
background: #f5f5f5;
border-radius: 36rpx;
overflow: hidden;
box-sizing: border-box;
padding: 0 24rpx;
.message-input {
width: 100%;
height: 72rpx;
padding: 0;
margin: 0;
font-size: 28rpx;
text-align: left;
box-sizing: border-box;
background: transparent;
border: none;
outline: none;
}
}
.send-btn {
padding: 0 32rpx;
height: 72rpx;
line-height: 72rpx;
background: linear-gradient(135deg, #ff9a9a 0%, #ff6b6b 100%);
border-radius: 36rpx;
font-size: 28rpx;
color: #fff;
border: none;
flex-shrink: 0;
&::after {
border: none;
}
&.active {
background: linear-gradient(135deg, #ff9a9a 0%, #ff6b6b 100%);
color: #fff;
}
}
}
}
// 语音消息气泡样式
.voice-bubble {
min-width: 200rpx;
padding: 24rpx 32rpx;
cursor: pointer;
.voice-content {
display: flex;
align-items: center;
gap: 16rpx;
.voice-icon {
font-size: 32rpx;
}
.voice-duration {
font-size: 28rpx;
color: #333;
}
.voice-wave {
display: flex;
align-items: center;
gap: 6rpx;
.wave-bar {
width: 4rpx;
height: 20rpx;
background-color: #999;
border-radius: 2rpx;
&:nth-child(2) {
height: 30rpx;
}
&:nth-child(3) {
height: 24rpx;
}
}
&.playing {
.wave-bar {
animation: wave 0.8s ease-in-out infinite;
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
}
}
&:active {
opacity: 0.8;
}
}
@keyframes wave {
0%, 100% {
transform: scaleY(1);
}
50% {
transform: scaleY(1.5);
}
}
</style>