xiangyixiangqin/miniapp/pages/chat/index.vue
2026-03-22 17:30:44 +08:00

2179 lines
59 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="phone-popup-mask" v-if="showPhonePopup" @click="showPhonePopup = false">
<view class="phone-popup" @click.stop>
<view class="phone-popup-header">
<text class="phone-popup-title">{{ targetNickname }}的联系电话</text>
<text class="phone-popup-close" @click="showPhonePopup = false">×</text>
</view>
<view class="phone-popup-content">
<text class="phone-number">{{ targetUserDetail?.phone || '暂无电话' }}</text>
</view>
<button class="phone-popup-btn" @click="handleMakeCall" :disabled="!targetUserDetail?.phone">
拨打电话
</button>
</view>
</view>
<!-- 自定义导航栏 -->
<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-with-animation
:scroll-into-view="scrollToId"
:style="{ paddingTop: (statusBarHeight + 44) + 'px', paddingBottom: (140 + keyboardHeight) + 'px' }"
@scrolltoupper="loadMoreMessages"
>
<!-- 用户信息卡片 -->
<view class="user-info-card" @click="handleViewProfile">
<view class="user-avatar">
<image :src="targetAvatar" mode="aspectFill" />
</view>
<view class="user-details">
<view class="user-name-row">
<text class="nickname">{{ targetNickname }}</text>
<text class="relationship" v-if="targetRelationship && !targetNickname.includes('')">({{ targetRelationship }})</text>
<view class="user-tags">
<image v-if="targetIsMember && memberIconUrl" class="member-icon" :src="memberIconUrl" mode="heightFix" />
<text v-else-if="targetIsMember" class="tag tag-member">会员</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, 'exchange-item': message.messageType === MessageType.EXCHANGE_WECHAT || message.messageType === MessageType.EXCHANGE_PHOTO }"
>
<!-- 时间分隔 -->
<view v-if="shouldShowTime(message, index)" class="time-divider">
<text>{{ formatMessageTime(message.createTime) }}</text>
</view>
<!-- 交换微信请求卡片 - 独立全宽显示 -->
<view
v-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>{{ getExchangeRejectText(message, 'wechat') }}</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>{{ getExchangeRejectText(message, 'photo') }}</text>
</view>
<!-- 等待中 -->
<view v-else class="exchange-card-result pending">
<text>等待对方回应...</text>
</view>
</view>
<!-- 普通消息内容 -->
<view v-else class="message-content">
<!-- 对方头像 -->
<image
v-if="!message.isMine"
class="avatar"
:src="targetAvatar"
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-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"
mode="aspectFill"
/>
</view>
</view>
</view>
<!-- 底部占位 -->
<view class="bottom-placeholder" id="bottom-anchor"></view>
</scroll-view>
<!-- 底部操作栏 -->
<view class="bottom-action-bar" :style="{ bottom: keyboardHeight + 'px' }">
<!-- 三个操作按钮 - 键盘弹起时隐藏 -->
<view class="action-buttons" v-show="!isInputFocused && inputMode === 'text'">
<button class="action-btn" @click="handleExchangeWeChat">
{{ hasExchangedWeChat ? '展示微信' : '交换微信' }}
</button>
<button class="action-btn" @click="handleCall">拨打电话</button>
<button class="action-btn" @click="handleExchangePhoto">
{{ hasExchangedPhoto ? '展示照片' : '交换照片' }}
</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; font-size: 32rpx;"
:adjust-position="false"
:hold-keyboard="true"
confirm-type="send"
confirm-hold
@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"
/>
<!-- 发送按钮 - 使用view避免抢夺input焦点 -->
<view
v-if="inputMode === 'text'"
class="send-btn"
:class="{ active: inputText.trim() }"
@click="handleSendMessage"
>
发送
</view>
</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 { useConfigStore } from '@/store/config.js'
import { useChatStore, MessageType, MessageStatus } from '@/store/chat.js'
import { getMessages, sendMessage, exchangeWeChat, exchangePhoto, respondExchange, uploadVoice, getOrCreateSession } 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 { getFullImageUrl } from '@/utils/image.js'
import signalR from '@/utils/signalr.js'
const userStore = useUserStore()
const configStore = useConfigStore()
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 targetMemberLevel = ref(0)
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)
// 键盘高度
const keyboardHeight = ref(0)
// 交换状态 - 记录是否已成功交换过微信/照片
const hasExchangedWeChat = ref(false)
const hasExchangedPhoto = ref(false)
const exchangedWeChat = ref('') // 已交换的微信号
const exchangedPhotos = ref([]) // 已交换的照片列表
// 输入模式text | voice
const inputMode = ref('text')
// 表情选择器显示状态
const showEmojiPicker = ref(false)
// 电话弹窗显示状态
const showPhonePopup = ref(false)
// 当前播放的语音ID
const playingVoiceId = ref(null)
const innerAudioContext = ref(null)
// 用户信息
const myAvatar = computed(() => userStore.avatar)
const myUserId = computed(() => userStore.userId)
// 会员图标URL
const memberIconUrl = computed(() => {
if (!targetIsMember.value || !targetMemberLevel.value) return ''
const iconUrl = configStore.getMemberIcon(targetMemberLevel.value)
return iconUrl ? getFullImageUrl(iconUrl) : ''
})
// 选项映射
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
targetMemberLevel.value = res.data.memberLevel || 0
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
// 过滤掉结果类型的消息5=交换微信结果7=交换照片结果),这些结果已在原请求消息中更新
.filter((msg) => msg.messageType !== MessageType.EXCHANGE_WECHAT_RESULT && msg.messageType !== MessageType.EXCHANGE_PHOTO_RESULT)
.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
}
// 解析交换请求的状态和数据
if (msg.messageType >= 4) {
// 默认设置为待处理状态
mapped.status = ExchangeStatus.PENDING
if (msg.extraData) {
try {
const extraData = JSON.parse(msg.extraData)
// 兼容 Status 和 status 两种字段名
const status = extraData.Status ?? extraData.status ?? ExchangeStatus.PENDING
mapped.status = status
// 如果是交换微信请求且已同意,设置微信号
if (msg.messageType === MessageType.EXCHANGE_WECHAT && status === ExchangeStatus.ACCEPTED) {
// 根据是否是自己发送的,获取对方的微信号
// 兼容 Pascal Case 和 camelCase
const senderWeChat = extraData.SenderWeChat ?? extraData.senderWeChat
const receiverWeChat = extraData.ReceiverWeChat ?? extraData.receiverWeChat
mapped.exchangedContent = mapped.isMine ? receiverWeChat : senderWeChat
}
// 如果是交换照片请求且已同意,设置照片列表
if (msg.messageType === MessageType.EXCHANGE_PHOTO && status === ExchangeStatus.ACCEPTED) {
// 根据是否是自己发送的,获取对方的照片
// 兼容 Pascal Case 和 camelCase
const senderPhotos = extraData.SenderPhotos ?? extraData.senderPhotos
const receiverPhotos = extraData.ReceiverPhotos ?? extraData.receiverPhotos
mapped.photos = mapped.isMine ? receiverPhotos : senderPhotos
mapped.photoCount = mapped.photos?.length || 0
}
} catch (e) {
console.error('解析消息extraData失败:', e)
}
}
}
return mapped
})
if (isLoadMore) {
messages.value = [...newMessages.reverse(), ...messages.value]
} else {
messages.value = newMessages.reverse()
await nextTick()
scrollToBottom()
}
// 检查是否有已成功交换的微信/照片
checkExchangeStatus()
hasMore.value = newMessages.length >= 20
console.log('[Chat] 消息加载完成,标记会话已读:', sessionId.value)
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 checkExchangeStatus = () => {
// 遍历所有消息,查找已接受的交换请求
for (const msg of messages.value) {
// 检查微信交换
if (msg.messageType === MessageType.EXCHANGE_WECHAT && msg.status === ExchangeStatus.ACCEPTED) {
hasExchangedWeChat.value = true
if (msg.exchangedContent) {
exchangedWeChat.value = msg.exchangedContent
}
}
// 检查照片交换
if (msg.messageType === MessageType.EXCHANGE_PHOTO && msg.status === ExchangeStatus.ACCEPTED) {
hasExchangedPhoto.value = true
if (msg.photos && msg.photos.length > 0) {
exchangedPhotos.value = msg.photos
}
}
}
}
// 发送文本消息 (Requirements 7.2)
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 && res.data.id) {
messages.value[msgIndex].id = res.data.id
} else if (res.data && 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
if (res.data?.messageId) {
messages.value[msgIndex].id = res.data.messageId
}
uni.showToast({ title: '发送成功', icon: 'success' })
} else {
messages.value[msgIndex].status = MessageStatus.FAILED
uni.showToast({ title: '发送失败', icon: 'none' })
}
} catch (error) {
messages.value[msgIndex].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 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) {
if (res && res.code === 0) {
messages.value[msgIndex].status = MessageStatus.SENT
if (res.data && res.data.messageId) {
messages.value[msgIndex].id = res.data.messageId
}
} else {
messages.value[msgIndex].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 () => {
// 如果已交换过,直接显示微信号
if (hasExchangedWeChat.value && exchangedWeChat.value) {
uni.showModal({
title: `${targetNickname.value}的微信号`,
content: exchangedWeChat.value,
confirmText: '复制',
success: (res) => {
if (res.confirm) {
uni.setClipboardData({
data: exchangedWeChat.value,
success: () => {
uni.showToast({ title: '已复制微信号', icon: 'success' })
}
})
}
}
})
return
}
// 未交换过,发送交换请求
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 () => {
// 如果已交换过,直接预览照片
if (hasExchangedPhoto.value && exchangedPhotos.value.length > 0) {
uni.previewImage({
urls: exchangedPhotos.value,
current: exchangedPhotos.value[0]
})
return
}
// 未交换过,发送交换请求
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) {
try {
const exchangedData = JSON.parse(res.data.exchangedData)
// 交换结果数据:微信号或照片列表
if (message.messageType === MessageType.EXCHANGE_WECHAT) {
// 我是接收者,所以获取发送者的微信号
message.exchangedContent = exchangedData.SenderWeChat || exchangedData.senderWeChat
// 更新交换状态
hasExchangedWeChat.value = true
exchangedWeChat.value = message.exchangedContent
} else if (message.messageType === MessageType.EXCHANGE_PHOTO) {
// 我是接收者,所以获取发送者的照片
const photos = exchangedData.SenderPhotos || exchangedData.senderPhotos || []
if (Array.isArray(photos)) {
message.photos = photos
message.photoCount = photos.length
// 更新交换状态
hasExchangedPhoto.value = true
exchangedPhotos.value = photos
}
}
} 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) => {
if (message.isMine) {
return '您发起了交换微信请求'
}
// 如果昵称已包含关系信息(括号),则不再添加
const name = targetNickname.value
if (name && name.includes('')) {
return `${name}希望与您交换微信号`
}
return `${name}${targetRelationship.value || '本人'})希望与您交换微信号`
}
const getExchangePhotoTitle = (message) => {
if (message.isMine) {
return '您发起了交换照片请求'
}
const name = targetNickname.value
if (name && name.includes('')) {
return `${name}希望与您交换孩子照片`
}
return `${name}${targetRelationship.value || '本人'})希望与您交换孩子照片`
}
const getExchangeRejectText = (message, type) => {
let name = targetNickname.value
if (name && !name.includes('')) {
name = `${name}${targetRelationship.value || '本人'}`
}
if (type === 'wechat') {
return message.isMine ? `${name}拒绝了交换微信号` : '您拒绝了交换微信号'
}
return message.isMine ? `${name}拒绝了交换孩子照片` : '您拒绝了交换孩子照片'
}
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 = () => {
showPhonePopup.value = true
}
// 实际拨打电话
const handleMakeCall = () => {
const phone = targetUserDetail.value?.phone
if (!phone) {
uni.showToast({ title: '暂无电话号码', icon: 'none' })
return
}
uni.makePhoneCall({
phoneNumber: phone,
fail: (err) => {
console.error('拨打电话失败:', err)
}
})
}
// 查看用户资料
const handleViewProfile = () => {
uni.navigateTo({ url: `/pages/profile/detail?userId=${targetUserId.value}` })
}
// 输入框焦点处理
const handleInputFocus = () => {
isInputFocused.value = true
}
const handleInputBlur = () => {
isInputFocused.value = false
}
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 = ''
nextTick(() => {
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()
// 监听键盘高度变化
uni.onKeyboardHeightChange((res) => {
keyboardHeight.value = res.height || 0
if (res.height > 0) {
nextTick(() => scrollToBottom())
}
})
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()
// 如果没有sessionId但有targetUserId先获取或创建会话
if (!sessionId.value && targetUserId.value) {
try {
uni.showLoading({ title: '加载中...' })
const res = await getOrCreateSession(targetUserId.value)
uni.hideLoading()
if (res && res.code === 0 && res.data) {
sessionId.value = res.data
} else {
uni.showToast({ title: res?.message || '创建会话失败', icon: 'none' })
setTimeout(() => {
uni.navigateBack()
}, 1500)
return
}
} catch (error) {
uni.hideLoading()
console.error('获取会话失败:', error)
uni.showToast({ title: '网络错误', icon: 'none' })
setTimeout(() => {
uni.navigateBack()
}, 1500)
return
}
}
if (sessionId.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
}
// 处理交换结果消息5=交换微信结果7=交换照片结果)
if (message.messageType === MessageType.EXCHANGE_WECHAT_RESULT || message.messageType === MessageType.EXCHANGE_PHOTO_RESULT) {
console.log('[Chat] 收到交换结果消息:', message)
// 更新原始请求消息的状态
if (message.extraData) {
try {
const extraData = typeof message.extraData === 'string' ? JSON.parse(message.extraData) : message.extraData
console.log('[Chat] 交换结果extraData:', extraData)
// 根据结果消息类型找到对应的请求消息类型
const requestType = message.messageType === MessageType.EXCHANGE_WECHAT_RESULT
? MessageType.EXCHANGE_WECHAT
: MessageType.EXCHANGE_PHOTO
// 优先使用 RequestMessageId 精确匹配,兼容大小写
const requestMessageId = extraData.RequestMessageId || extraData.requestMessageId
let requestMsg = null
if (requestMessageId) {
// 通过 ID 精确匹配
requestMsg = messages.value.find(m => m.id === requestMessageId)
console.log('[Chat] 通过ID匹配请求消息:', requestMessageId, requestMsg)
}
// 如果没找到,回退到类型+状态匹配(我发起的,状态为待处理)
if (!requestMsg) {
requestMsg = messages.value.find(m =>
m.messageType === requestType &&
m.isMine &&
m.status === ExchangeStatus.PENDING
)
console.log('[Chat] 通过类型匹配请求消息:', requestMsg)
}
if (requestMsg) {
// 根据响应内容判断是同意还是拒绝
const isAgreed = message.content === '已同意交换'
requestMsg.status = isAgreed ? ExchangeStatus.ACCEPTED : ExchangeStatus.REJECTED
console.log('[Chat] 更新请求消息状态:', isAgreed ? 'ACCEPTED' : 'REJECTED')
if (isAgreed) {
// 更新交换的数据
if (requestMsg.messageType === MessageType.EXCHANGE_WECHAT) {
// 我是发起方,获取对方(接收者)的微信号
requestMsg.exchangedContent = extraData.ReceiverWeChat || extraData.receiverWeChat
// 更新交换状态
hasExchangedWeChat.value = true
exchangedWeChat.value = requestMsg.exchangedContent
} else if (requestMsg.messageType === MessageType.EXCHANGE_PHOTO) {
// 我是发起方,获取对方(接收者)的照片
const photos = extraData.ReceiverPhotos || extraData.receiverPhotos || []
requestMsg.photos = photos
requestMsg.photoCount = photos.length
// 更新交换状态
hasExchangedPhoto.value = true
exchangedPhotos.value = photos
}
}
console.log('[Chat] 已更新交换请求状态:', requestMsg)
} else {
console.warn('[Chat] 未找到对应的请求消息')
}
} catch (e) {
console.error('[Chat] 解析交换结果数据失败:', e)
}
} else {
console.warn('[Chat] 交换结果消息没有extraData')
}
return
}
// 如果是自己发送的消息,忽略(因为发送时已经添加到列表了)
if (message.senderId === myUserId.value) {
// 但需要更新本地消息的ID如果SignalR比API响应更快
const pendingMsg = messages.value.find(m =>
m.isMine &&
m.content === message.content &&
m.status === MessageStatus.SENDING
)
if (pendingMsg) {
pendingMsg.id = message.messageId
pendingMsg.status = MessageStatus.SENT
}
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: false
}
messages.value.push(newMessage)
nextTick(() => {
scrollToBottom()
})
// 用户正在查看聊天页面,立即标记消息为已读
signalR.markSessionAsRead(sessionId.value)
// 播放提示音(可选)
// uni.vibrateShort()
}
// 处理交换响应
const handleExchangeResponse = (message) => {
console.log('[Chat] 收到交换响应:', message)
// 更新原始请求消息的状态
if (message.extraData) {
try {
const extraData = typeof message.extraData === 'string' ? JSON.parse(message.extraData) : message.extraData
// 兼容大小写
const requestMessageId = extraData.RequestMessageId || extraData.requestMessageId
const status = extraData.Status ?? extraData.status
if (requestMessageId) {
const requestMsg = messages.value.find(m => m.id === requestMessageId)
if (requestMsg) {
requestMsg.status = status
console.log('[Chat] handleExchangeResponse 更新消息状态:', requestMessageId, status)
if (status === ExchangeStatus.ACCEPTED) {
// 更新交换的数据
if (requestMsg.messageType === MessageType.EXCHANGE_WECHAT) {
// 我是发起方,获取对方(接收者)的微信号
requestMsg.exchangedContent = extraData.ReceiverWeChat || extraData.receiverWeChat
hasExchangedWeChat.value = true
exchangedWeChat.value = requestMsg.exchangedContent
} else if (requestMsg.messageType === MessageType.EXCHANGE_PHOTO) {
// 我是发起方,获取对方(接收者)的照片
const photos = extraData.ReceiverPhotos || extraData.receiverPhotos || []
requestMsg.photos = photos
requestMsg.photoCount = photos.length
hasExchangedPhoto.value = true
exchangedPhotos.value = photos
}
}
}
}
} 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()
// 移除键盘高度监听
// #ifdef MP-WEIXIN
uni.offKeyboardHeightChange()
// #endif
// 停止语音播放
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: #f8f8f8;
overflow: hidden;
}
// 自定义导航栏
.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: 56rpx;
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;
height: 0;
background-color: #f8f8f8;
}
// 用户信息卡片
.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: 36rpx;
font-weight: 600;
color: #333;
}
.relationship {
font-size: 30rpx;
color: #666;
}
.user-tags {
display: flex;
align-items: center;
gap: 8rpx;
.member-icon {
width: auto;
height: 42rpx;
vertical-align: middle;
}
.tag {
display: inline-block;
font-size: 22rpx;
padding: 6rpx 16rpx;
border-radius: 8rpx;
height: 36rpx;
line-height: 24rpx;
box-sizing: border-box;
vertical-align: middle;
&.tag-member {
background: linear-gradient(135deg, #fff3e0 0%, #ffe0b2 100%);
color: #ff9800;
}
&.tag-realname {
background: #3d4a4a;
color: #fff;
}
}
}
}
.xiangqin-no {
font-size: 26rpx;
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: 28rpx;
color: #999;
width: 70rpx;
flex-shrink: 0;
}
.value {
font-size: 28rpx;
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: 32rpx;
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-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 0 40rpx;
.time-divider {
width: 100%;
margin-bottom: 20rpx;
}
}
// 交换卡片样式
.exchange-card {
background: #FFFFFF;
border-radius: 16rpx;
overflow: hidden;
width: 100%;
max-width: 600rpx;
.exchange-card-header {
padding: 30rpx 24rpx;
text-align: center;
text {
font-size: 28rpx;
color: #333;
font-weight: 500;
}
}
.exchange-card-actions {
display: flex;
padding: 20rpx 60rpx 30rpx;
gap: 40rpx;
justify-content: center;
.action-btn {
width: 180rpx;
height: 64rpx;
line-height: 64rpx;
font-size: 28rpx;
border-radius: 32rpx;
padding: 0;
&::after {
border: none;
}
&.reject-btn {
background: #fff;
color: #666;
border: 2rpx solid #CCCCCC;
}
&.accept-btn {
background: linear-gradient(135deg, #FFB5B5 0%, #FF9A9A 100%);
color: #fff;
border: none;
}
}
}
.exchange-card-result {
padding: 20rpx 24rpx 30rpx;
text-align: center;
.result-label {
display: block;
font-size: 28rpx;
color: #333;
margin-bottom: 20rpx;
line-height: 1.5;
}
.wechat-no {
display: block;
font-size: 32rpx;
font-weight: 500;
color: #333;
margin-bottom: 24rpx;
}
.copy-btn {
width: 280rpx;
height: 64rpx;
line-height: 64rpx;
background: #fff;
color: #666;
font-size: 26rpx;
border-radius: 32rpx;
border: 2rpx solid #CCCCCC;
margin: 0 auto;
&::after {
border: none;
}
}
.photo-preview {
display: flex;
gap: 16rpx;
margin-top: 20rpx;
justify-content: center;
image {
width: 180rpx;
height: 180rpx;
border-radius: 12rpx;
}
}
&.rejected {
text {
color: #333;
font-size: 28rpx;
}
}
&.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);
transition: bottom 0.15s ease-out;
z-index: 100;
.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: 32rpx;
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;
text-align: center;
&:active {
opacity: 0.8;
}
&.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);
}
}
// 拨打电话弹窗
.phone-popup-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
display: flex;
align-items: flex-end;
justify-content: center;
}
.phone-popup {
width: 100%;
background: #fff;
border-radius: 24rpx 24rpx 0 0;
padding: 40rpx;
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
.phone-popup-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 40rpx;
.phone-popup-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.phone-popup-close {
font-size: 48rpx;
color: #999;
line-height: 1;
}
}
.phone-popup-content {
text-align: center;
padding: 40rpx 0;
.phone-number {
font-size: 56rpx;
font-weight: 600;
color: #333;
letter-spacing: 4rpx;
}
}
.phone-popup-btn {
width: 100%;
height: 96rpx;
line-height: 96rpx;
background: #4cd964;
color: #fff;
font-size: 34rpx;
border-radius: 48rpx;
border: none;
&::after {
border: none;
}
&[disabled] {
opacity: 0.5;
}
}
}
</style>