1717 lines
43 KiB
Vue
1717 lines
43 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">详情资料</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>
|