| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439 |
- <template>
- <view class="message-page">
- <!-- 消息列表 -->
- <scroll-view class="message-list" scroll-y :scroll-top="scrollTop" :scroll-into-view="scrollIntoView"
- @touchstart="handleListTouchStart" @touchmove="handleListTouchMove" @touchend="handleListTouchEnd">
- <view class="message-item" :class="message.direction" v-for="(message, index) in messages" :key="index"
- :id="'msg-' + index">
- <!-- 头像+时间区域 -->
- <view class="avatar-section">
- <image class="avatar" src="/static/logo.png"></image>
- <text class="msg-time">{{ message.time }}</text>
- </view>
- <!-- 内容区域 -->
- <view class="content-section">
- <!-- 部门/班级 | 姓名 -->
- <view class="user-info">{{ message.department }} | {{ message.name }}</view>
- <!-- 消息内容 -->
- <view class="message-content">
- <!-- 文字消息 -->
- <view v-if="message.type === 'text'" class="text-message-wrapper">
- <!-- 接收的消息:内容在左,按钮在右 -->
- <template v-if="message.direction === 'left'">
- <view class="text-message">
- <text>{{ message.content }}</text>
- </view>
- <view v-if="message.needReceipt" class="inline-receipt-button"
- :class="message.receiptStatus">
- {{ message.receiptStatus === 'read' ? '已读' : '确认阅读' }}
- </view>
- </template>
- <!-- 发送的消息:按钮在左,内容在右 -->
- <template v-else>
- <!-- 带回执按钮(仅在1分钟内显示) -->
- <view v-if="message.showReceiptToggle" class="receipt-toggle-wrapper">
- <SsOnoffButton name="needReceipt" label="带回执" :value="1"
- :modelValue="message.needReceipt ? 1 : 0"
- @update:modelValue="toggleReceipt(index, $event)" />
- </view>
- <!-- 阅读情况按钮 -->
- <view v-if="message.needReceipt && !message.showReceiptToggle" class="inline-receipt-button"
- :class="message.receiptStatus">
- 阅读情况
- </view>
- <!-- 文字内容 -->
- <view class="text-message">
- <text>{{ message.content }}</text>
- </view>
- </template>
- </view>
- <!-- 通话记录 -->
- <view v-if="message.type === 'call'" class="call-message">
- <image src="/static/icon/tingtong.png"></image>
- <text>通话时长 {{ message.duration }}</text>
- </view>
- <!-- 语音消息 -->
- <view
- v-if="message.type === 'voice'"
- class="voice-message"
- :style="getVoiceBubbleStyle(message)"
- @click.stop="toggleVoicePlayback(message, index)"
- >
- <image
- :src="message.direction === 'left' ? '/static/icon/guangbo.png' : '/static/icon/yinbo.png'">
- </image>
- <text>{{ message.duration }}" {{ message.voicePreview }}</text>
- <view v-if="message.isRecording" class="recording-dot"></view>
- </view>
- <!-- 图片消息 -->
- <view v-if="message.type === 'image'" class="image-message" @click.stop="openImagePreview(message)">
- <image class="image-preview" :src="message.imageUrl || '/static/logo.png'" mode="aspectFill"></image>
- </view>
- <!-- 文件消息 -->
- <view v-if="message.type === 'file'" class="file-message" @click.stop="openFilePreview(message)">
- <Icon lib="base" name="icon-file" size="39" :color="iconColor" />
- <text class="file-name">{{ message.fileName }}</text>
- </view>
- <!-- 视频消息 -->
- <view v-if="message.type === 'video'" class="video-message" @click.stop="openVideoPreview(message, index)">
- <video
- class="video-cover"
- :src="message.videoUrl"
- muted
- :controls="false"
- :show-center-play-btn="false"
- :enable-progress-gesture="false"
- object-fit="cover"
- ></video>
- <view class="video-play">
- <Icon lib="base" name="icon-vid" size="40" color="#ffffff" />
- </view>
- </view>
- </view>
- <!-- 语音转文字区域(仅语音消息且有转文字内容时显示) -->
- <view v-if="message.type === 'voice' && message.voiceText" class="voice-text-section">
- <!-- 接收的消息:转写在左,按钮在右 -->
- <template v-if="message.direction === 'left'">
- <view class="voice-text-content">{{ message.voiceText }}</view>
- <view v-if="message.needReceipt" class="receipt-button" :class="message.receiptStatus">
- {{ message.receiptStatus === 'read' ? '已读' : '确认阅读' }}
- </view>
- </template>
- <!-- 发送的消息:按钮在左,转写在右 -->
- <template v-else>
- <view v-if="message.needReceipt" class="receipt-button" :class="message.receiptStatus">
- 阅读情况
- </view>
- <view class="voice-text-content">{{ message.voiceText }}</view>
- </template>
- </view>
- </view>
- </view>
- </scroll-view>
- <!-- 底部操作栏 -->
- <view class="footer" :class="{ active: showMorePanel || showEmojiPanel }" @click.stop>
- <!-- 左侧:语音/键盘切换 -->
- <view class="tool-btn" @click="toggleInputMode">
- <Icon lib="base" :name="inputMode === 'voice' ? 'icon-txt' : 'icon-spk'" size="36" :color="iconColor" />
- </view>
- <!-- 中间:输入框 / 按住说话 -->
- <view class="center-area">
- <view
- v-if="inputMode === 'voice'"
- class="press-to-talk"
- @longpress="handlePressToTalkStart"
- @touchmove.stop.prevent="handlePressToTalkMove"
- @touchend.stop.prevent="handlePressToTalkEnd"
- @touchcancel.stop.prevent="handlePressToTalkCancel"
- >
- <Icon lib="base" name="icon-aud-bold" size="36" :color="iconColor" />
- <text class="press-text">长按说话</text>
- </view>
- <textarea
- v-else
- class="text-input"
- :class="{ capped: textLineCount >= 5 }"
- v-model="draftText"
- :focus="inputFocus"
- auto-height
- confirm-type="send"
- @confirm="sendTextMessage"
- maxlength="-1"
- @linechange="handleTextLineChange"
- @blur="inputFocus = false"
- />
- </view>
- <!-- 右侧:表情 + 更多 -->
- <view class="tool-btn" @click="toggleEmojiPanel">
- <Icon lib="base" name="icon-emoji" size="36" :color="iconColor" />
- </view>
- <view class="tool-btn" @click="toggleMorePanel">
- <Icon lib="base" name="icon-add" size="36" :color="iconColor" />
- </view>
- </view>
- <!-- 底部扩展面板(更多/表情) -->
- <view class="bottom-panel" :class="{ open: showMorePanel || showEmojiPanel }" @click.stop>
- <view v-show="showMorePanel" class="more-panel">
- <view class="more-grid">
- <view class="more-item" @click="handleMoreAction('file')">
- <view class="more-icon">
- <Icon lib="base" name="icon-file" size="36" :color="iconColor" />
- </view>
- <text class="more-text">文件</text>
- </view>
- <view class="more-item" @click="handleMoreAction('image')">
- <view class="more-icon">
- <Icon lib="base" name="icon-img" size="36" :color="iconColor" />
- </view>
- <text class="more-text">照片</text>
- </view>
- <view class="more-item" @click="handleMoreAction('camera')">
- <view class="more-icon">
- <Icon lib="base" name="icon-vid-bold" size="36" :color="iconColor" />
- </view>
- <text class="more-text">拍照</text>
- </view>
- <view class="more-item" @click="handleMoreAction('video')">
- <view class="more-icon">
- <Icon lib="base" name="icon-vid" size="36" :color="iconColor" />
- </view>
- <text class="more-text">视频留言</text>
- </view>
- </view>
- </view>
- <view v-show="showEmojiPanel" class="emoji-panel">
- <view class="emoji-grid">
- <view class="emoji-item" v-for="(emoji, index) in emojiList" :key="index"
- @click="insertEmoji(emoji)">
- {{ emoji }}
- </view>
- </view>
- </view>
- </view>
- <view v-if="showMediaPreview" class="media-preview-mask" @click="closeMediaPreview">
- <view class="media-preview-content" :class="previewType === 'image' ? 'image-mode' : 'video-mode'" @click.stop="handlePreviewContentClick">
- <swiper
- v-if="previewType === 'image'"
- class="media-preview-swiper"
- :current="previewImageIndex"
- @change="handleImageSwiperChange"
- >
- <swiper-item class="media-preview-swiper-item" v-for="(img, idx) in previewImageList" :key="idx">
- <image class="media-preview-image fit-x" :src="img" mode="widthFix"></image>
- </swiper-item>
- </swiper>
- <video
- v-if="previewType === 'video'"
- class="media-preview-video"
- :src="previewSource"
- autoplay
- controls
- object-fit="contain"
- @loadedmetadata="handlePreviewVideoLoaded"
- @error="handlePreviewVideoError"
- ></video>
- </view>
- <view v-if="previewType === 'video'" class="media-preview-exit" @click.stop="closeMediaPreview">退出</view>
- </view>
- <view v-if="showFileDialog" class="file-dialog-mask" @click="closeFileDialog">
- <view class="file-dialog" @click.stop>
- <view class="file-dialog-header">
- <Icon lib="base" name="icon-file" size="44" :color="iconColor" />
- <text class="file-dialog-name">{{ activeFileName }}</text>
- </view>
- <view class="file-dialog-actions">
- <view class="file-dialog-btn ghost" @click="closeFileDialog">取消</view>
- <view class="file-dialog-btn" @click="downloadFileFromDialog">下载</view>
- </view>
- </view>
- </view>
- <view v-if="isRecording" class="record-mask">
- <view class="record-panel">
- <view class="record-title">正在录音</view>
- <view class="record-time">{{ recordSeconds }}s</view>
- <view class="record-tip" :class="{ danger: isRecordCancel }">
- {{ isRecordCancel ? '松开取消发送' : '手指上滑,取消发送' }}
- </view>
- </view>
- </view>
- </view>
- </template>
- <script>
- import SsOnoffButton from '@/components/SsOnoffButton/index.vue'
- import Icon from '@/components/icon/index.vue'
- import { EMOJI_LIST } from '@/constants/emoji'
- import { collectImageUrls, getInitialParentMessages, createImageMessage } from '@/utils/parent-message-factory'
- import {
- buildTextOutgoingMessage,
- buildVoiceOutgoingMessage,
- pickFileOutgoingMessage,
- pickImageOutgoingMessages,
- pickVideoOutgoingMessage,
- shootImageOutgoingMessage
- } from '@/utils/parent-message-send'
- const RECEIPT_TOGGLE_TTL_MS = 60 * 1000
- export default {
- components: {
- SsOnoffButton,
- Icon
- },
- computed: {},
- data() {
- return {
- scrollTop: 0,
- scrollIntoView: '',
- inputMode: 'voice', // voice | text
- inputFocus: false,
- draftText: '',
- iconColor: '#575d6d',
- showEmojiPanel: false,
- showMorePanel: false,
- emojiList: EMOJI_LIST,
- showMediaPreview: false,
- previewType: '',
- previewSource: '',
- activeVideoIndex: -1,
- previewImageList: [],
- previewImageIndex: 0,
- showFileDialog: false,
- activeFileName: '',
- activeFileUrl: '',
- receiptToggleTimers: {},
- currentUserMeta: {
- direction: 'right',
- department: '家长',
- name: '我'
- },
- textLineCount: 1,
- listTouchMoved: false,
- touchStartX: 0,
- touchStartY: 0,
- recorderManager: null,
- isRecording: false,
- isRecordCancel: false,
- recordSeconds: 0,
- recordStartY: 0,
- recordTickTimer: null,
- recordGuardTimer: null,
- audioPlayer: null,
- playingVoiceIndex: -1,
- // 消息列表数据
- messages: getInitialParentMessages()
- }
- },
- onLoad(options) {
- // 获取路由参数中的角色信息
- const role = options.role
- console.log(role)
- if (!role) {
- uni.showToast({
- title: '缺少角色信息',
- icon: 'none'
- })
- return
- }
- // 这里可以根据角色获取对应的消息列表
- // this.getMessageList(role)
- },
- onReady() {
- // 页面渲染完成后,滚动到最新消息
- this.$nextTick(() => {
- this.scrollToBottom()
- this.initReceiptToggleTimers()
- this.initRecorder()
- })
- },
- onUnload() {
- this.stopVoicePlayback()
- if (this.audioPlayer) {
- this.audioPlayer.destroy()
- this.audioPlayer = null
- }
- this.cleanupRecorder()
- this.clearReceiptToggleTimers()
- },
- methods: {
- getVoiceBubbleStyle(message) {
- if (!message || !message.isPlaying) return {}
- const progress = Math.max(0, Math.min(100, Number(message.playProgress || 0)))
- if (message.direction === 'right') {
- return {
- backgroundImage: `linear-gradient(to right, #7d89b1 ${progress}%, #eeeeee ${progress}%)`
- }
- }
- return {
- backgroundImage: `linear-gradient(to right, #eeeeee ${progress}%, #7d89b1 ${progress}%)`
- }
- },
- resetVoiceItemState(index) {
- if (index < 0 || !this.messages[index]) return
- this.$set(this.messages[index], 'isPlaying', false)
- this.$set(this.messages[index], 'playProgress', 0)
- },
- updateVoicePlayProgress() {
- const index = this.playingVoiceIndex
- if (index < 0 || !this.messages[index] || !this.audioPlayer) return
- const duration = Number(this.audioPlayer.duration || 0)
- const currentTime = Number(this.audioPlayer.currentTime || 0)
- if (duration <= 0) return
- const progress = Math.max(0, Math.min(100, (currentTime / duration) * 100))
- this.$set(this.messages[index], 'playProgress', progress)
- },
- initAudioPlayer() {
- if (this.audioPlayer || !uni.createInnerAudioContext) return
- this.audioPlayer = uni.createInnerAudioContext()
- this.audioPlayer.onTimeUpdate(() => {
- this.updateVoicePlayProgress()
- })
- this.audioPlayer.onEnded(() => {
- this.resetVoiceItemState(this.playingVoiceIndex)
- this.playingVoiceIndex = -1
- })
- this.audioPlayer.onStop(() => {
- this.resetVoiceItemState(this.playingVoiceIndex)
- this.playingVoiceIndex = -1
- })
- this.audioPlayer.onError(() => {
- this.resetVoiceItemState(this.playingVoiceIndex)
- this.playingVoiceIndex = -1
- uni.showToast({ title: '语音播放失败', icon: 'none' })
- })
- },
- stopVoicePlayback() {
- if (this.audioPlayer) {
- this.audioPlayer.stop()
- }
- this.resetVoiceItemState(this.playingVoiceIndex)
- this.playingVoiceIndex = -1
- },
- toggleVoicePlayback(message, index) {
- if (!message || message.type !== 'voice') return
- if (!message.audioUrl) {
- uni.showToast({ title: '该语音暂无音频文件', icon: 'none' })
- return
- }
- this.initAudioPlayer()
- if (!this.audioPlayer) {
- uni.showToast({ title: '当前环境不支持播放', icon: 'none' })
- return
- }
- if (this.playingVoiceIndex === index) {
- this.stopVoicePlayback()
- return
- }
- if (this.playingVoiceIndex > -1) {
- this.audioPlayer.stop()
- this.resetVoiceItemState(this.playingVoiceIndex)
- }
- this.playingVoiceIndex = index
- this.$set(this.messages[index], 'isPlaying', true)
- this.$set(this.messages[index], 'playProgress', 0)
- this.audioPlayer.src = message.audioUrl
- this.audioPlayer.play()
- },
- initRecorder() {
- if (!uni.getRecorderManager) return
- this.recorderManager = uni.getRecorderManager()
- this.recorderManager.onStop((res) => {
- const canceled = this.isRecordCancel
- const durationMs = Number(res && res.duration ? res.duration : 0)
- const durationSeconds = Math.max(1, Math.round(durationMs / 1000) || this.recordSeconds)
- this.clearRecordTimers()
- this.isRecording = false
- this.isRecordCancel = false
- this.recordStartY = 0
- if (canceled) {
- uni.showToast({ title: '已取消发送', icon: 'none' })
- return
- }
- if (!res || !res.tempFilePath || durationSeconds < 1) {
- uni.showToast({ title: '录音时间太短', icon: 'none' })
- return
- }
- const voiceMessage = buildVoiceOutgoingMessage({
- durationSeconds,
- audioUrl: res.tempFilePath,
- voiceText: ''
- }, this.currentUserMeta)
- this.appendMessage(voiceMessage)
- })
- this.recorderManager.onError(() => {
- this.clearRecordTimers()
- this.isRecording = false
- this.isRecordCancel = false
- uni.showToast({ title: '录音失败', icon: 'none' })
- })
- },
- clearRecordTimers() {
- if (this.recordTickTimer) {
- clearInterval(this.recordTickTimer)
- this.recordTickTimer = null
- }
- if (this.recordGuardTimer) {
- clearTimeout(this.recordGuardTimer)
- this.recordGuardTimer = null
- }
- },
- cleanupRecorder() {
- this.clearRecordTimers()
- if (this.isRecording && this.recorderManager) {
- this.isRecordCancel = true
- try {
- this.recorderManager.stop()
- } catch (error) {
- // noop
- }
- }
- this.isRecording = false
- this.recordSeconds = 0
- },
- handlePressToTalkStart(event) {
- if (this.isRecording) return
- if (!this.recorderManager) {
- this.initRecorder()
- }
- if (!this.recorderManager) {
- uni.showToast({ title: '当前环境不支持录音', icon: 'none' })
- return
- }
- const touch = event && event.touches && event.touches[0]
- this.recordStartY = touch ? touch.clientY : 0
- this.recordSeconds = 0
- this.isRecordCancel = false
- this.isRecording = true
- this.clearRecordTimers()
- this.recordTickTimer = setInterval(() => {
- this.recordSeconds = Math.min(60, this.recordSeconds + 1)
- }, 1000)
- this.recordGuardTimer = setTimeout(() => {
- if (!this.isRecording || !this.recorderManager) return
- this.recorderManager.stop()
- }, 60000)
- try {
- this.recorderManager.start({
- duration: 60000,
- sampleRate: 16000,
- numberOfChannels: 1,
- encodeBitRate: 96000,
- format: 'mp3'
- })
- } catch (error) {
- this.clearRecordTimers()
- this.isRecording = false
- this.isRecordCancel = false
- uni.showToast({ title: '录音启动失败', icon: 'none' })
- }
- },
- handlePressToTalkMove(event) {
- if (!this.isRecording) return
- const touch = event && event.touches && event.touches[0]
- if (!touch) return
- const deltaY = this.recordStartY - touch.clientY
- this.isRecordCancel = deltaY > 80
- },
- handlePressToTalkEnd() {
- if (!this.isRecording || !this.recorderManager) return
- this.recorderManager.stop()
- },
- handlePressToTalkCancel() {
- if (!this.isRecording || !this.recorderManager) return
- this.isRecordCancel = true
- this.recorderManager.stop()
- },
- back() {
- uni.navigateBack({
- delta: 1
- })
- },
- // 切换带回执状态
- toggleReceipt(index, value) {
- this.messages[index].needReceipt = value === 1 || value === '1'
- },
- initReceiptToggleTimers() {
- this.messages.forEach((message, index) => {
- if (message.showReceiptToggle) {
- this.registerReceiptToggleTimer(index)
- }
- })
- },
- registerReceiptToggleTimer(index) {
- const message = this.messages[index]
- if (!message || !message.showReceiptToggle) return
- if (!message.receiptToggleCreatedAt) {
- message.receiptToggleCreatedAt = Date.now()
- }
- const expireAt = message.receiptToggleCreatedAt + RECEIPT_TOGGLE_TTL_MS
- const remain = expireAt - Date.now()
- if (remain <= 0) {
- message.showReceiptToggle = false
- this.clearReceiptToggleTimer(index)
- return
- }
- this.clearReceiptToggleTimer(index)
- this.receiptToggleTimers[index] = setTimeout(() => {
- const target = this.messages[index]
- if (target) {
- target.showReceiptToggle = false
- }
- this.clearReceiptToggleTimer(index)
- }, remain)
- },
- clearReceiptToggleTimer(index) {
- const timerId = this.receiptToggleTimers[index]
- if (timerId) {
- clearTimeout(timerId)
- delete this.receiptToggleTimers[index]
- }
- },
- clearReceiptToggleTimers() {
- Object.keys(this.receiptToggleTimers).forEach((key) => {
- this.clearReceiptToggleTimer(key)
- })
- },
- // 滚动到底部
- scrollToBottom() {
- const lastIndex = this.messages.length - 1
- if (lastIndex >= 0) {
- this.scrollIntoView = 'msg-' + lastIndex
- }
- },
- toggleInputMode() {
- if (this.inputMode === 'voice') {
- this.inputMode = 'text'
- this.showMorePanel = false
- this.showEmojiPanel = false
- this.$nextTick(() => {
- this.inputFocus = true
- })
- } else {
- this.inputMode = 'voice'
- this.showMorePanel = false
- this.showEmojiPanel = false
- this.inputFocus = false
- }
- },
- toggleEmojiPanel() {
- const nextStatus = !this.showEmojiPanel
- this.showEmojiPanel = nextStatus
- if (nextStatus) {
- this.inputMode = 'text'
- this.showMorePanel = false
- this.inputFocus = false
- }
- },
- toggleMorePanel() {
- const nextStatus = !this.showMorePanel
- this.showMorePanel = nextStatus
- if (nextStatus) {
- this.inputMode = 'text'
- this.showEmojiPanel = false
- this.inputFocus = false
- }
- },
- handlePageClick() {
- if (this.showMorePanel || this.showEmojiPanel) {
- this.showMorePanel = false
- this.showEmojiPanel = false
- }
- },
- insertEmoji(emoji) {
- this.inputMode = 'text'
- this.draftText = `${this.draftText}${emoji}`
- },
- handleListTouchStart(event) {
- const touch = event.touches && event.touches[0]
- if (!touch) return
- this.listTouchMoved = false
- this.touchStartX = touch.clientX
- this.touchStartY = touch.clientY
- },
- handleListTouchMove(event) {
- const touch = event.touches && event.touches[0]
- if (!touch) return
- const deltaX = Math.abs(touch.clientX - this.touchStartX)
- const deltaY = Math.abs(touch.clientY - this.touchStartY)
- if (deltaX > 8 || deltaY > 8) {
- this.listTouchMoved = true
- }
- },
- handleListTouchEnd() {
- if (!this.listTouchMoved) {
- this.handlePageClick()
- }
- },
- appendMessages(newMessages = []) {
- if (!newMessages.length) return
- const startIndex = this.messages.length
- this.messages = [...this.messages, ...newMessages]
- newMessages.forEach((message, offset) => {
- if (message.showReceiptToggle) {
- this.registerReceiptToggleTimer(startIndex + offset)
- }
- })
- this.$nextTick(() => {
- this.scrollToBottom()
- })
- },
- appendMessage(newMessage) {
- if (!newMessage) return
- this.appendMessages([newMessage])
- },
- async handleMoreAction(type) {
- try {
- if (type === 'file') {
- const fileMessage = await pickFileOutgoingMessage(this.currentUserMeta)
- if (fileMessage) this.appendMessage(fileMessage)
- return
- }
- if (type === 'image') {
- const imageMessages = await pickImageOutgoingMessages(this.currentUserMeta)
- if (imageMessages.length) this.appendMessages(imageMessages)
- return
- }
- if (type === 'camera') {
- const imageMessage = await shootImageOutgoingMessage(this.currentUserMeta)
- if (imageMessage) this.appendMessage(imageMessage)
- return
- }
- if (type === 'video') {
- const videoMessage = await pickVideoOutgoingMessage(this.currentUserMeta)
- if (videoMessage) this.appendMessage(videoMessage)
- return
- }
- uni.showToast({ title: `点击:${type}`, icon: 'none' })
- } catch (error) {
- uni.showToast({ title: '操作已取消', icon: 'none' })
- }
- },
- openImagePreview(message) {
- const imageUrls = collectImageUrls(this.messages)
- if (!imageUrls.length) return
- const currentUrl = message.imageUrl || imageUrls[0]
- const currentIndex = imageUrls.findIndex((item) => item === currentUrl)
- this.previewType = 'image'
- this.previewImageList = imageUrls
- this.previewImageIndex = currentIndex > -1 ? currentIndex : 0
- this.showMediaPreview = true
- },
- openVideoPreview(message, index) {
- this.previewType = 'video'
- this.previewSource = message.videoUrl || message.coverUrl || '/static/logo.png'
- this.activeVideoIndex = typeof index === 'number' ? index : -1
- this.showMediaPreview = true
- },
- openFilePreview(message) {
- this.activeFileName = message.fileName || '未命名文件'
- this.activeFileUrl = message.fileUrl || ''
- this.showFileDialog = true
- },
- downloadFileFromDialog() {
- if (!this.activeFileUrl) {
- uni.showToast({
- title: '文件地址不存在',
- icon: 'none'
- })
- return
- }
- const lowerUrl = (this.activeFileUrl || '').toLowerCase()
- const lowerName = (this.activeFileName || '').toLowerCase()
- const target = lowerName || lowerUrl
- const isDocType = /(\.pdf|\.doc|\.docx|\.xls|\.xlsx|\.ppt|\.pptx|\.txt)$/.test(target)
- uni.downloadFile({
- url: this.activeFileUrl,
- success: (res) => {
- if (res.statusCode === 200) {
- this.closeFileDialog()
- if (isDocType) {
- uni.openDocument({
- filePath: res.tempFilePath,
- showMenu: true,
- fail: () => {
- uni.showToast({
- title: '文件打开失败',
- icon: 'none'
- })
- }
- })
- } else {
- uni.showToast({
- title: '下载成功,当前类型不支持在线打开',
- icon: 'none'
- })
- }
- } else {
- uni.showToast({
- title: '下载失败',
- icon: 'none'
- })
- }
- },
- fail: () => {
- uni.showToast({
- title: '下载失败',
- icon: 'none'
- })
- }
- })
- },
- closeFileDialog() {
- this.showFileDialog = false
- this.activeFileName = ''
- this.activeFileUrl = ''
- },
- closeMediaPreview() {
- this.showMediaPreview = false
- this.previewType = ''
- this.previewSource = ''
- this.activeVideoIndex = -1
- this.previewImageList = []
- this.previewImageIndex = 0
- },
- handlePreviewVideoLoaded(event) {
- const seconds = event?.detail?.duration
- if (!seconds || this.activeVideoIndex < 0 || !this.messages[this.activeVideoIndex]) return
- const mins = Math.floor(seconds / 60)
- const secs = Math.floor(seconds % 60)
- const durationText = `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
- this.messages[this.activeVideoIndex].duration = durationText
- },
- handlePreviewContentClick() {
- if (this.previewType === 'image') {
- this.closeMediaPreview()
- }
- },
- handleImageSwiperChange(event) {
- this.previewImageIndex = event.detail && event.detail.current ? event.detail.current : 0
- },
- handlePreviewVideoError() {
- uni.showToast({
- title: '视频加载失败,请使用H.264编码MP4',
- icon: 'none'
- })
- },
- handleTextLineChange(event) {
- this.textLineCount = event.detail && event.detail.lineCount ? event.detail.lineCount : 1
- },
- sendTextMessage() {
- const message = buildTextOutgoingMessage(this.draftText, this.currentUserMeta)
- if (!message) return
- message.receiptToggleCreatedAt = Date.now()
- this.appendMessage(message)
- this.draftText = ''
- this.textLineCount = 1
- },
- addImageFromUrl(url) {
- if (!url) return
- this.appendMessage(createImageMessage({
- ...this.currentUserMeta,
- imageUrl: url
- }))
- }
- }
- }
- </script>
- <style scoped lang="less">
- .message-page {
- height: 100vh;
- background: #f5f5f5;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- .nav-header {
- padding: 20rpx;
- background: #fff;
- border-bottom: 1rpx solid #eee;
- }
- .back-btn {
- width: 100rpx;
- font-size: 28rpx;
- text-align: center;
- color: #333;
- border-radius: 5rpx;
- padding: 10rpx 20rpx;
- border: 1px solid #eee;
- display: flex;
- align-items: center;
- gap: 10rpx;
- }
- .back-btn image {
- width: 25rpx;
- height: 25rpx;
- }
- .message-list {
- flex: 1;
- padding: 20rpx;
- background: #fff;
- overflow-y: auto;
- box-sizing: border-box;
- }
- .message-item {
- display: flex;
- align-items: flex-start;
- margin: 42rpx 0;
- gap: 20rpx;
- }
- /* 头像+时间区域 */
- .avatar-section {
- display: flex;
- flex-direction: column;
- align-items: center;
- }
- .avatar {
- width: 92rpx;
- height: 92rpx;
- border-radius: 50%;
- flex-shrink: 0;
- }
- .msg-time {
- font-size: 32rpx;
- color: #666;
- white-space: nowrap;
- }
- /* 内容区域 */
- .content-section {
- flex: 1;
- display: flex;
- flex-direction: column;
- gap: 10rpx;
- }
- .user-info {
- font-size: 28rpx;
- color: #666;
- }
- .message-content {
- display: flex;
- align-items: center;
- }
- /* 文字消息容器 */
- .text-message-wrapper {
- display: flex;
- align-items: flex-end;
- gap: 10rpx;
- }
- /* 带回执按钮容器 */
- .receipt-toggle-wrapper {
- display: flex;
- align-items: center;
- }
- /* 消息气泡通用样式 */
- .text-message,
- .call-message,
- .voice-message,
- .file-message {
- background: #7d89b1;
- padding: 16rpx 20rpx;
- border-radius: 8rpx;
- color: #fff;
- font-size: 32rpx;
- }
- .image-message,
- .video-message {
- border-radius: 8rpx;
- overflow: hidden;
- border: 1rpx solid #dcdcdc;
- background: #ffffff;
- }
- /* 文字消息 */
- .text-message {
- max-width: 400rpx;
- word-wrap: break-word;
- }
- /* 通话记录 */
- .call-message {
- display: flex;
- align-items: center;
- gap: 10rpx;
- }
- .call-message image {
- width: 40rpx;
- height: 40rpx;
- }
- /* 语音消息 */
- .voice-message {
- position: relative;
- display: flex;
- align-items: center;
- gap: 10rpx;
- min-width: 100rpx;
- transition: background-image 0.12s linear;
- }
- .voice-message image {
- width: 40rpx;
- height: 40rpx;
- }
- .file-message {
- display: flex;
- align-items: center;
- gap: 12rpx;
- max-width: 420rpx;
- }
- .file-name {
- flex: 1;
- min-width: 0;
- font-size: 32rpx;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .image-preview {
- width: 280rpx;
- height: 220rpx;
- display: block;
- }
- .video-message {
- position: relative;
- width: 280rpx;
- height: 220rpx;
- }
- .video-cover {
- width: 100%;
- height: 100%;
- display: block;
- }
- .video-play {
- position: absolute;
- left: 50%;
- top: 50%;
- transform: translate(-50%, -50%);
- width: 64rpx;
- height: 64rpx;
- border-radius: 50%;
- background: rgba(0, 0, 0, 0.45);
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .recording-dot {
- width: 16rpx;
- height: 16rpx;
- background: #eb6100;
- border-radius: 50%;
- position: absolute;
- right: 10rpx;
- top: 10rpx;
- }
- /* 语音转文字区域 */
- .voice-text-section {
- display: flex;
- align-items: flex-end;
- gap: 20rpx;
- }
- .voice-text-content {
- flex: 1;
- background: #7d89b1;
- padding: 20rpx;
- border-radius: 8rpx;
- font-size: 32rpx;
- color: #fff;
- line-height: 1.5;
- }
- /* 回执按钮通用样式 */
- .inline-receipt-button,
- .receipt-button {
- background: #565d6d;
- color: #fff;
- padding: 10rpx 20rpx;
- border-radius: 8rpx;
- font-size: 28rpx;
- white-space: nowrap;
- cursor: pointer;
- }
- /* 右侧消息(发送) */
- .right {
- flex-direction: row-reverse;
- }
- .right .avatar {
- border-radius: 50%;
- }
- .right .content-section {
- align-items: flex-end;
- }
- .right .user-info {
- text-align: right;
- }
- /* 右侧消息气泡样式 */
- .right .text-message,
- .right .call-message,
- .right .voice-message,
- .right .file-message,
- .right .voice-text-content {
- background: #eeeeee;
- color: #333333;
- border: 1rpx solid #dcdcdc;
- }
- .footer {
- padding: 16rpx 24rpx;
- background: #eeeeee;
- display: flex;
- align-items: flex-end;
- gap: 16rpx;
- border-top: 4rpx solid transparent;
- box-sizing: border-box;
- }
- .footer.active {
- border-top-color: #dcdcdc;
- }
- .tool-btn {
- width: 78rpx;
- height: 78rpx;
- border-radius: 4rpx;
- background: #ffffff;
- border: 1rpx solid #e5e7eb;
- display: flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
- align-self: flex-end;
- }
- .center-area {
- flex: 1;
- min-width: 0;
- display: flex;
- align-items: flex-end;
- }
- .press-to-talk {
- width: 100%;
- height: 78rpx;
- border-radius: 4rpx;
- background: #ffffff;
- border: 1rpx solid #e5e7eb;
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 12rpx;
- }
- .press-text {
- color: #6b7280;
- font-size: 30rpx;
- line-height: 1;
- }
- .text-input {
- width: 100%;
- min-height: 82rpx;
- max-height: 234rpx;
- border-radius: 4rpx;
- background: #ffffff;
- border: 1rpx solid #e5e7eb;
- padding: 18rpx 20rpx;
- font-size: 32rpx;
- line-height: 39rpx;
- color: #111827;
- overflow-y: auto;
- box-sizing: border-box;
- }
- .text-input.capped {
- height: 234rpx;
- }
- .bottom-panel {
- background: #eeeeee;
- border-top: 1rpx solid transparent;
- max-height: 0;
- opacity: 0;
- overflow: hidden;
- transform: translateY(24rpx);
- transition: max-height 0.2s ease, opacity 0.2s ease, transform 0.2s ease;
- }
- .bottom-panel.open {
- border-top-color: #dcdcdc;
- max-height: 380rpx;
- opacity: 1;
- transform: translateY(0);
- }
- .more-panel {
- background: #eeeeee;
- padding: 30rpx 24rpx;
- box-sizing: border-box;
- }
- .emoji-panel {
- background: #eeeeee;
- padding: 20rpx 24rpx;
- max-height: 320rpx;
- overflow-y: auto;
- }
- .emoji-grid {
- display: grid;
- grid-template-columns: repeat(8, 1fr);
- gap: 12rpx;
- }
- .emoji-item {
- height: 52rpx;
- line-height: 52rpx;
- text-align: center;
- font-size: 36rpx;
- border-radius: 4rpx;
- }
- .more-grid {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- }
- .more-item {
- width: 25%;
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 16rpx;
- }
- .more-icon {
- width: 96rpx;
- height: 96rpx;
- border-radius: 4rpx;
- background: #ffffff;
- border: 1rpx solid #e5e7eb;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .more-text {
- font-size: 28rpx;
- color: #6b7280;
- white-space: nowrap;
- }
- .media-preview-mask {
- position: fixed;
- left: 0;
- top: 0;
- width: 100vw;
- height: 100vh;
- background: rgba(0, 0, 0, 0.78);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 9999;
- }
- .media-preview-content {
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 0;
- box-sizing: border-box;
- }
- .media-preview-content.video-mode {
- width: 100vw;
- height: 100vh;
- background: transparent;
- border-radius: 0;
- }
- .media-preview-content.image-mode {
- width: 100vw;
- height: 100vh;
- background: transparent;
- border-radius: 0;
- }
- .media-preview-video {
- width: 100vw;
- height: 100vh;
- object-fit: cover;
- }
- .media-preview-exit {
- position: fixed;
- top: 90rpx;
- right: 30rpx;
- height: 56rpx;
- line-height: 56rpx;
- padding: 0 18rpx;
- border-radius: 28rpx;
- font-size: 26rpx;
- color: #fff;
- background: rgba(0, 0, 0, 0.45);
- z-index: 10001;
- }
- .media-preview-swiper {
- width: 100vw;
- height: 100vh;
- }
- .media-preview-swiper-item {
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .media-preview-image.fit-x {
- width: 100vw;
- height: auto;
- }
- .file-dialog-mask {
- position: fixed;
- left: 0;
- top: 0;
- width: 100vw;
- height: 100vh;
- background: rgba(255, 255, 255, 0.96);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 10000;
- }
- .file-dialog {
- width: 100vw;
- height: 100vh;
- background: transparent;
- border-radius: 0;
- padding: 0 60rpx;
- box-sizing: border-box;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- }
- .file-dialog-header {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 18rpx;
- }
- .file-dialog-name {
- max-width: 620rpx;
- font-size: 30rpx;
- color: #333;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- text-align: center;
- }
- .file-dialog-actions {
- display: flex;
- justify-content: center;
- gap: 24rpx;
- padding-top: 40rpx;
- }
- .file-dialog-btn {
- min-width: 120rpx;
- height: 64rpx;
- line-height: 64rpx;
- text-align: center;
- background: #575d6d;
- color: #fff;
- font-size: 28rpx;
- border-radius: 8rpx;
- }
- .file-dialog-btn.ghost {
- background: #f1f2f4;
- color: #666;
- }
- .record-mask {
- position: fixed;
- left: 0;
- top: 0;
- width: 100vw;
- height: 100vh;
- background: rgba(0, 0, 0, 0.2);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 12000;
- pointer-events: none;
- }
- .record-panel {
- width: 360rpx;
- padding: 30rpx 24rpx;
- border-radius: 16rpx;
- background: rgba(0, 0, 0, 0.72);
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 14rpx;
- }
- .record-title {
- font-size: 32rpx;
- color: #fff;
- }
- .record-time {
- font-size: 42rpx;
- font-weight: 600;
- color: #fff;
- }
- .record-tip {
- font-size: 26rpx;
- color: rgba(255, 255, 255, 0.9);
- }
- .record-tip.danger {
- color: #ffb2b2;
- }
- }
- </style>
|