| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882 |
- <template>
- <view class="message-page">
- <view class="chat-nav">
- <view class="chat-title-wrap">
- <picker
- v-if="parentChatMembers.length > 1"
- mode="selector"
- :range="parentChatMemberNames"
- :value="parentChatMemberIndex"
- @change="handleParentMemberChange"
- >
- <view class="chat-title-picker">
- <Icon lib="base" name="icon-down" size="36" color="#000000" />
- <text class="chat-title">{{ currentChatName }}</text>
- </view>
- </picker>
- <view v-else class="chat-title-picker single">
- <text class="chat-title">{{ currentChatName }}</text>
- </view>
- </view>
- <view class="chat-nav-right">
- <view class="chat-call-btn" @click="startVoiceCall">
- <Icon lib="base" name="icon-aud-bold" size="36" color="#3c4151" />
- </view>
- <view class="chat-call-btn" @click="startVideoCall">
- <Icon lib="base" name="icon-vid-bold" size="36" color="#3c4151" />
- </view>
- <view v-if="sessionRole === 'device'" class="chat-call-btn" @click="handleDeviceLogout">
- <image class="chat-logout-icon" src="/static/icon/logout.png"></image>
- </view>
- </view>
- </view>
- <!-- 消息列表 -->
- <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"
- :class="getAvatarClass(message)"
- :src="getAvatarSrc(message)"
- mode="aspectFill"
- ></image>
- <text class="msg-time">{{ message.time || '--:--' }}</text>
- </view>
- <!-- 内容区域 -->
- <view class="content-section">
- <!-- 职务 | 姓名(职务先隐藏) -->
- <view class="user-info" :class="{ 'user-info-placeholder': message.direction === 'right' }">
- <text class="user-info-role">{{ message.department }} | </text>
- <text>{{ message.direction === 'left' ? (message.displayName || message.name) : '占位' }}</text>
- </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>
- </template>
- <!-- 发送的消息 -->
- <template v-else>
- <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>{{ formatVoiceBubbleText(message) }}</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
- v-if="message.direction === 'left' && message.needReceipt && message.receiptStatus !== 'read'"
- class="receipt-button"
- @click.stop="confirmRead(message, index)"
- >
- 确认阅读
- </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>
- </template>
- <!-- 发送的消息 -->
- <template v-else>
- <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" 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-photo" 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>
- <custom-modal
- :visible="modalVisible"
- :title="modalTitle"
- :content="modalContent"
- :showCancel="modalShowCancel"
- :confirmText="modalConfirmText"
- @confirm="handleModalConfirm"
- @cancel="handleModalCancel"
- />
- </view>
- </template>
- <script>
- import Icon from '@/components/icon/index.vue'
- import customModal from '@/components/custom-modal.vue'
- import { EMOJI_LIST } from '@/constants/emoji'
- import { collectImageUrls, createImageMessage, createTextMessage } from '@/utils/parent-message-factory'
- import {
- buildTextOutgoingMessage,
- buildVoiceOutgoingMessage,
- pickFileOutgoingMessage,
- pickImageOutgoingMessages,
- pickVideoOutgoingMessage,
- shootImageOutgoingMessage
- } from '@/utils/parent-message-send'
- import websocketService from '@/utils/websocket'
- import upload from '@/utils/upload'
- import { getImageUrl } from '@/utils/util'
- import { grfwApi } from '@/api/grfw'
- import { deviceApi } from '@/api/device'
- import env from '@/config/env.js'
- const RECEIPT_TOGGLE_TTL_MS = 60 * 1000
- const REC_RYLBM_STUDENT = 1100
- const REC_RYLBM_PARENT = 1200
- const CALL_END_STORAGE_KEY = 'parentLastCallInfo'
- const CALL_END_PAGE_URL = '/pages/parent/message'
- let wmpfVoip = null
- try {
- if (typeof requirePlugin === 'function') {
- wmpfVoip = requirePlugin('wmpf-voip').default
- }
- } catch (error) {
- console.warn('wmpf-voip 插件暂不可用', error)
- }
- const miniprogramState = (() => {
- if (typeof wx === 'undefined' || typeof wx.getAccountInfoSync !== 'function') {
- return 'formal'
- }
- const accountInfo = wx.getAccountInfoSync()
- if (accountInfo && accountInfo.miniProgram) {
- const platform = { develop: 'developer', trial: 'trial', release: 'formal' }
- return platform[accountInfo.miniProgram.envVersion]
- }
- return 'formal'
- })()
- export default {
- props: {
- role: {
- type: String,
- default: ''
- },
- contactId: {
- type: [String, Number],
- default: ''
- },
- contactName: {
- type: String,
- default: ''
- },
- studentId: {
- type: [String, Number],
- default: ''
- }
- },
- components: {
- Icon,
- customModal
- },
- computed: {},
- watch: {
- role() {
- this.initializeSessionFromOptions({ force: true, role: this.role, contactId: this.contactId, studentId: this.studentId })
- this.bootstrapMessageFlow()
- },
- contactId() {
- this.initializeSessionFromOptions({ force: true, role: this.role, contactId: this.contactId, studentId: this.studentId })
- this.bootstrapMessageFlow()
- },
- contactName() {
- this.initializeSessionFromOptions({ force: true, role: this.role, contactId: this.contactId, contactName: this.contactName, studentId: this.studentId })
- },
- studentId() {
- this.initializeSessionFromOptions({ force: true, role: this.role, contactId: this.contactId, studentId: this.studentId })
- this.bootstrapMessageFlow()
- }
- },
- 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: {},
- parentChatMembers: [],
- parentChatMemberNames: [],
- parentChatMemberIndex: 0,
- currentChatName: '留言',
- historyLoading: false,
- historyWaiter: null,
- historyWaiterTimer: null,
- parentInfo: {
- xm: '',
- openid: ''
- },
- currentCallContact: null,
- _voipEndPathSet: false,
- _voipEventRegistered: false,
- 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,
- sessionInitialized: false,
- sessionRole: 'parent',
- sessionContactId: '',
- sessionStudentId: '',
- entryFromDeviceIndex: false,
- socketConnected: false,
- socketUnsubscribeList: [],
- currentWsConfig: null,
- wsIdentity: {
- sendRyid: '',
- recRyid: '',
- recRylbm: REC_RYLBM_STUDENT
- },
- deviceSessionReady: true,
- deviceInitPromise: null,
- peerAvatarTypeHint: '',
- serviceStatus: null,
- inactivityTimer: null,
- inactivityTimeout: 180000,
- isCalling: false,
- modalVisible: false,
- modalTitle: '提示',
- modalContent: '',
- modalShowCancel: false,
- modalConfirmText: '确定',
- modalResolve: null,
- loadOptions: {},
- // 消息列表数据
- messages: []
- }
- },
- created() {
- this.initializeSessionFromOptions({
- role: this.role,
- contactId: this.contactId,
- contactName: this.contactName,
- studentId: this.studentId
- })
- },
- async onLoad(options) {
- this.loadOptions = { ...(options || {}) }
- this.initializeSessionFromOptions({ ...(options || {}), force: true })
- this.initParentInfo()
- if (this.sessionRole === 'device') {
- this.deviceSessionReady = false
- this.deviceInitPromise = this.initDeviceSession(options || {})
- this.deviceSessionReady = await this.deviceInitPromise
- }
- },
- async onReady() {
- // 页面渲染完成后,滚动到最新消息
- this.$nextTick(() => {
- this.scrollToBottom()
- this.initReceiptToggleTimers()
- this.initRecorder()
- })
- this.registerVoipEvent()
- this.bindSocketListeners()
- if (this.sessionRole === 'device') {
- await (this.deviceInitPromise || Promise.resolve(true))
- if (!this.deviceSessionReady) return
- await this.checkServiceStatus(115)
- await this.checkServiceStatus(111)
- this.resetInactivityTimer()
- }
- await this.bootstrapMessageFlow()
- },
- onUnload() {
- this.teardownSocket()
- this.stopVoicePlayback()
- if (this.audioPlayer) {
- this.audioPlayer.destroy()
- this.audioPlayer = null
- }
- this.cleanupRecorder()
- this.clearReceiptToggleTimers()
- if (this.inactivityTimer) {
- clearTimeout(this.inactivityTimer)
- this.inactivityTimer = null
- }
- },
- beforeUnmount() {
- this.teardownSocket()
- },
- methods: {
- showCustomModal(options = {}) {
- return new Promise((resolve) => {
- this.modalTitle = options.title || '提示'
- this.modalContent = options.content || ''
- this.modalShowCancel = options.showCancel || false
- this.modalConfirmText = options.confirmText || '确定'
- this.modalResolve = resolve
- this.modalVisible = true
- })
- },
- handleModalConfirm() {
- this.modalVisible = false
- if (this.modalResolve) {
- this.modalResolve(true)
- this.modalResolve = null
- }
- },
- handleModalCancel() {
- this.modalVisible = false
- if (this.modalResolve) {
- this.modalResolve(false)
- this.modalResolve = null
- }
- },
- initializeSessionFromOptions(options = {}) {
- if (this.sessionInitialized && !options?.force) return
- const safeDecode = (value) => {
- if (value === null || value === undefined) return ''
- const text = String(value)
- try {
- return decodeURIComponent(text)
- } catch (error) {
- return text
- }
- }
- const role = options.role || this.role || 'parent'
- this.sessionRole = role
- this.sessionContactId = safeDecode(options.contactId || this.contactId || '')
- this.sessionStudentId = options.studentId || this.studentId || ''
- this.entryFromDeviceIndex = String(options.fromIndex || '') === '1'
- const targetContactName = safeDecode(options.contactName || this.contactName || '')
- this.currentChatName = role === 'device' ? (targetContactName || '家长') : '留言'
- this.currentUserMeta = {
- ...this.currentUserMeta,
- department: role === 'device' ? '设备端' : '家长'
- }
- this.refreshCurrentUserMeta()
- this.resolveWsIdentity()
- this.currentWsConfig = this.buildWsConnectOptions()
- this.sessionInitialized = true
- if (this.socketUnsubscribeList.length) {
- this.bindSocketListeners()
- }
- },
- async bootstrapMessageFlow() {
- if (this.sessionRole === 'parent') {
- await this.loadParentChatMembers()
- } else {
- await this.loadDeviceChatMembers()
- await this.ensureSessionSocket()
- }
- await this.loadHistoryMessages({ reset: true })
- if (this.sessionRole === 'device' && this.entryFromDeviceIndex) {
- await this.loadHistoryMessages({ reset: true })
- }
- },
- async initDeviceSession(options = {}) {
- if (this.sessionRole !== 'device') return
- const userInfo = this.getStoredUserInfo()
- const snFromOption = String(options.sn || '').trim()
- const cardNo = String(options.cardNo || '').trim()
- const sn = snFromOption || String(userInfo.devId || '').trim()
- if (!sn) {
- uni.showToast({ title: '缺少设备标识,请重新刷卡登录', icon: 'none' })
- setTimeout(() => {
- uni.reLaunch({ url: '/pages/device/notice' })
- }, 1200)
- return false
- }
- if (!cardNo) {
- const hasSession = !!(userInfo.yhsbToken || userInfo.onlineToken || userInfo.sessId)
- if (!hasSession) {
- uni.showToast({ title: '登录已失效,请重新刷卡登录', icon: 'none' })
- setTimeout(() => {
- uni.reLaunch({ url: '/pages/device/notice' })
- }, 1200)
- return false
- }
- this.refreshCurrentUserMeta()
- this.resolveWsIdentity()
- this.currentWsConfig = this.buildWsConnectOptions()
- return true
- }
- try {
- const result = await deviceApi.login(sn, cardNo)
- const loginData = result?.data || {}
- if (!loginData || loginData.msg) {
- await this.handleDeviceLoginFailure('登录失败,卡无登记')
- return false
- }
- const mergedUserInfo = {
- ...this.getStoredUserInfo(),
- devId: loginData.devId || sn,
- sbmc: loginData.sbmc || '',
- sessId: loginData.sessId || '',
- xm: loginData.xm || '',
- yhsbToken: loginData.yhsbToken || '',
- onlineToken: loginData.onlineToken || '',
- cardNo,
- syList: loginData.sylist || loginData.syList || [],
- yhid: loginData.yhid || '',
- yhm: loginData.yhm || ''
- }
- if (loginData.yszwj) mergedUserInfo.yszwj = loginData.yszwj
- if (loginData.zjzwj) mergedUserInfo.zjzwj = loginData.zjzwj
- uni.setStorageSync('userInfo', mergedUserInfo)
- if (mergedUserInfo.sessId) {
- uni.setStorageSync('JSESSIONID', mergedUserInfo.sessId)
- }
- this.refreshCurrentUserMeta()
- this.resolveWsIdentity()
- this.currentWsConfig = this.buildWsConnectOptions()
- return true
- } catch (error) {
- console.error('设备端登录失败:', error)
- await this.handleDeviceLoginFailure('网络错误或服务异常')
- return false
- }
- },
- async handleDeviceLoginFailure(content = '登录失败') {
- await this.showCustomModal({
- title: '登录失败',
- content,
- showCancel: false,
- confirmText: '确定'
- })
- await this.handleDeviceLogout()
- return false
- },
- async loadParentChatMembers() {
- try {
- const result = await grfwApi.btc_selChatMbr({})
- const data = result?.data || {}
- const list = this.normalizeChatMemberList(data.chatMbrList)
- this.parentChatMembers = list
- this.parentChatMemberNames = list.map((item) => item.xm || String(item.ryid || '未命名'))
- if (!list.length) {
- this.sessionContactId = ''
- this.currentChatName = '留言'
- this.resolveWsIdentity()
- return
- }
- let targetIndex = 0
- const current = String(this.sessionContactId || '')
- if (current) {
- const idx = list.findIndex((item) => String(item.ryid || '') === current)
- if (idx > -1) targetIndex = idx
- }
- this.selectParentMemberByIndex(targetIndex)
- } catch (error) {
- console.error('加载孩子列表失败:', error)
- uni.showToast({ title: '加载孩子列表失败', icon: 'none' })
- }
- },
- async loadDeviceChatMembers() {
- try {
- const result = await deviceApi.mp_telHomep_load()
- const data = result?.data || {}
- const list = this.normalizeChatMemberList(data.chatMbrList)
- this.parentChatMembers = list
- this.parentChatMemberNames = list.map((item) => item.xm || String(item.ryid || '未命名家长'))
- if (!list.length) {
- this.sessionContactId = ''
- this.currentChatName = '家长'
- this.resolveWsIdentity()
- return
- }
- let targetIndex = 0
- const current = String(this.sessionContactId || '')
- if (current) {
- const idx = list.findIndex((item) => String(item.ryid || '') === current)
- if (idx > -1) targetIndex = idx
- }
- this.selectParentMemberByIndex(targetIndex)
- } catch (error) {
- console.error('加载家长列表失败:', error)
- uni.showToast({ title: '加载家长列表失败', icon: 'none' })
- }
- },
- normalizeChatMemberList(rawList) {
- if (!Array.isArray(rawList)) return []
- const queue = [...rawList]
- const result = []
- while (queue.length) {
- const item = queue.shift()
- if (Array.isArray(item)) {
- queue.unshift(...item)
- continue
- }
- if (item && typeof item === 'object') {
- result.push(item)
- }
- }
- return result
- },
- async handleParentMemberChange(event) {
- if (this.historyLoading) {
- await this.finishHistoryLoading()
- }
- const index = Number(event?.detail?.value || 0)
- console.log('handleParentMemberChange -> index', index)
- this.selectParentMemberByIndex(index)
- await this.loadHistoryMessages({ reset: true })
- },
- selectParentMemberByIndex(index = 0) {
- const safeIndex = Math.max(0, Math.min(index, this.parentChatMembers.length - 1))
- this.parentChatMemberIndex = safeIndex
- const target = this.parentChatMembers[safeIndex] || {}
- this.sessionContactId = String(target.ryid || '')
- this.currentChatName = target.xm || '留言'
- this.refreshCurrentUserMeta()
- this.resolveWsIdentity()
- this.currentWsConfig = this.buildWsConnectOptions()
- },
- initParentInfo() {
- let info = uni.getStorageSync('userInfo')
- if (typeof info === 'string') {
- try {
- info = JSON.parse(info)
- } catch (error) {
- info = null
- }
- }
- if (!info || typeof info !== 'object') return
- this.parentInfo = {
- ...this.parentInfo,
- ...info
- }
- this.refreshCurrentUserMeta()
- },
- getStoredUserInfo() {
- let info = uni.getStorageSync('userInfo') || {}
- if (typeof info === 'string') {
- try {
- info = JSON.parse(info)
- } catch (error) {
- info = {}
- }
- }
- return info && typeof info === 'object' ? info : {}
- },
- getSelfDisplayMeta() {
- const userInfo = this.getStoredUserInfo()
- if (this.sessionRole === 'device') {
- return {
- department: '学生',
- name: userInfo.xm || this.currentUserMeta.name || '学生'
- }
- }
- return {
- department: '家长',
- name: this.parentInfo?.xm || userInfo.xm || this.currentUserMeta.name || '家长'
- }
- },
- getPeerDisplayMeta() {
- if (this.sessionRole === 'device') {
- return {
- department: '家长',
- name: this.currentChatName || '家长'
- }
- }
- return {
- department: '学生',
- name: this.currentChatName || '学生'
- }
- },
- resolveMessageMetaByDirection(direction = 'right') {
- return direction === 'right' ? this.getSelfDisplayMeta() : this.getPeerDisplayMeta()
- },
- getSelfAvatarMeta() {
- const userInfo = this.getStoredUserInfo()
- const peerType = String(this.peerAvatarTypeHint || '')
- const preferType = peerType === '1' ? '51' : (peerType === '51' ? '1' : '')
- if (preferType === '51') {
- return {
- url: this.toDisplayImageUrl(userInfo.zjzwj || userInfo.yszwj || ''),
- type: '51'
- }
- }
- if (preferType === '1') {
- return {
- url: this.toDisplayImageUrl(userInfo.yszwj || userInfo.zjzwj || ''),
- type: '1'
- }
- }
- if (this.sessionRole === 'device') {
- if (userInfo.zjzwj) {
- return {
- url: this.toDisplayImageUrl(userInfo.zjzwj),
- type: '51'
- }
- }
- if (userInfo.yszwj) {
- return {
- url: this.toDisplayImageUrl(userInfo.yszwj),
- type: '1'
- }
- }
- return {
- url: '/static/logo.png',
- type: ''
- }
- }
- if (userInfo.yszwj) {
- return {
- url: this.toDisplayImageUrl(userInfo.yszwj),
- type: '1'
- }
- }
- if (userInfo.zjzwj) {
- return {
- url: this.toDisplayImageUrl(userInfo.zjzwj),
- type: '51'
- }
- }
- return {
- url: '/static/logo.png',
- type: ''
- }
- },
- getAvatarSrc(message = {}) {
- if (!message || message.direction === 'right') {
- return this.getSelfAvatarMeta().url
- }
- return message.avatarUrl || '/static/logo.png'
- },
- getAvatarClass(message = {}) {
- const type = String(
- message && message.direction === 'right'
- ? this.getSelfAvatarMeta().type
- : (message.avatarType || '')
- )
- return {
- 'square-avatar': type === '51',
- 'doc-avatar': type === '51'
- }
- },
- refreshCurrentUserMeta() {
- const selfMeta = this.getSelfDisplayMeta()
- this.currentUserMeta = {
- ...this.currentUserMeta,
- department: selfMeta.department,
- name: selfMeta.name
- }
- },
- buildCallContactByCurrentMember() {
- const member = this.parentChatMembers[this.parentChatMemberIndex] || {}
- if (this.sessionRole === 'device') {
- return {
- username: member.xm || member.username || '家长',
- avatar: member.yszwj ? this.toDisplayImageUrl(member.yszwj) : '/static/logo.png',
- openid: member.wbid2 || member.wbid || ''
- }
- }
- return {
- username: member.xm || member.username || '学生',
- avatar: member.yszwj ? this.toDisplayImageUrl(member.yszwj) : '/static/logo.png',
- deviceSn: member.deviceSn || member.sn || member.devId || member.sbkh || '',
- pushToken: member.pushToken || member.voipToken || member.token || ''
- }
- },
- async startVoiceCall() {
- const contact = this.buildCallContactByCurrentMember()
- await this.startCall(contact, 'voice')
- },
- async startVideoCall() {
- const contact = this.buildCallContactByCurrentMember()
- await this.startCall(contact, 'video')
- },
- async startCall(contact, roomType) {
- if (!wmpfVoip) {
- uni.showToast({ title: '通话能力暂不可用', icon: 'none' })
- return
- }
- if (this.sessionRole === 'device') {
- const userInfo = this.getStoredUserInfo()
- const callerId = String(userInfo.devId || this.currentWsConfig?.ssDevId || '').trim()
- const listenerId = String(contact?.openid || '').trim()
- if (!callerId) {
- uni.showToast({ title: '缺少设备标识', icon: 'none' })
- return
- }
- if (!listenerId) {
- uni.showToast({ title: '请让家长先关注公众号,登陆小程序后再发起', icon: 'none' })
- return
- }
- this.isCalling = true
- if (this.inactivityTimer) {
- clearTimeout(this.inactivityTimer)
- this.inactivityTimer = null
- }
- uni.showLoading({ title: '呼叫中...', mask: true })
- uni.setStorageSync(CALL_END_STORAGE_KEY, {
- name: contact?.username || '家长',
- avatar: contact?.avatar || '/static/logo.png',
- duration: 0,
- time: new Date().toLocaleString(),
- type: roomType,
- status: '呼叫中'
- })
- this.setVoipEndPagePath()
- try {
- const res = await wmpfVoip.initByCaller({
- roomType,
- caller: {
- id: callerId,
- name: userInfo.xm || '学生'
- },
- listener: {
- id: listenerId,
- name: contact?.username || '家长'
- },
- businessType: 1,
- miniprogramState
- })
- if (res.isSuccess) {
- uni.hideLoading()
- uni.redirectTo({ url: wmpfVoip.CALL_PAGE_PATH })
- return
- }
- this.isCalling = false
- this.resetInactivityTimer()
- uni.hideLoading()
- uni.showToast({ title: '呼叫失败', icon: 'error' })
- return
- } catch (error) {
- this.isCalling = false
- this.resetInactivityTimer()
- uni.hideLoading()
- console.error('设备端通话异常:', error)
- uni.showToast({ title: '发起通话失败', icon: 'none' })
- return
- }
- }
- const callerId = this.parentInfo?.openid || this.parentInfo?.wbid || ''
- if (!callerId) {
- uni.showToast({ title: '缺少家长身份标识', icon: 'none' })
- return
- }
- if (!contact?.deviceSn) {
- uni.showToast({ title: '未找到可呼叫的设备编号', icon: 'none' })
- return
- }
- if (!contact?.pushToken) {
- uni.showToast({ title: '设备未上报通话凭证', icon: 'none' })
- return
- }
- uni.showLoading({ title: '呼叫中...', mask: true })
- this.currentCallContact = contact
- const callInfo = {
- name: contact.username || '家庭设备',
- avatar: contact.avatar || '/static/logo.png',
- duration: 0,
- time: new Date().toLocaleString(),
- type: roomType,
- status: '呼叫中'
- }
- uni.setStorageSync(CALL_END_STORAGE_KEY, callInfo)
- this.setVoipEndPagePath()
- try {
- const res = await wmpfVoip.initByCaller({
- roomType,
- caller: {
- id: callerId,
- name: this.parentInfo?.xm || '家长'
- },
- listener: {
- id: contact.deviceSn,
- name: contact.username
- },
- businessType: 2,
- voipToken: contact.pushToken,
- miniprogramState
- })
- if (res.isSuccess) {
- uni.hideLoading()
- uni.redirectTo({ url: wmpfVoip.CALL_PAGE_PATH })
- return
- }
- uni.hideLoading()
- console.error('呼叫失败', res)
- uni.showToast({ title: '呼叫失败', icon: 'error' })
- } catch (error) {
- uni.hideLoading()
- console.error('通话异常:', error)
- uni.showToast({ title: '发起通话失败', icon: 'none' })
- }
- },
- async handleDeviceLogout() {
- try {
- await deviceApi.ssExit()
- } catch (error) {
- console.warn('设备端退出服务调用失败:', error)
- }
- uni.removeStorageSync('userInfo')
- uni.removeStorageSync('JSESSIONID')
- uni.removeStorageSync('currcall')
- uni.removeStorageSync('lastCallInfo')
- if (this.inactivityTimer) {
- clearTimeout(this.inactivityTimer)
- this.inactivityTimer = null
- }
- uni.showToast({
- title: '已退出',
- icon: 'success',
- duration: 1200
- })
- setTimeout(() => {
- uni.reLaunch({ url: '/pages/device/notice' })
- }, 1200)
- },
- resetInactivityTimer() {
- if (this.sessionRole !== 'device') return
- if (this.isCalling) return
- if (this.inactivityTimer) {
- clearTimeout(this.inactivityTimer)
- }
- this.inactivityTimer = setTimeout(() => {
- this.handleDeviceLogout()
- }, this.inactivityTimeout)
- },
- async checkServiceStatus(grfwxmm) {
- if (this.sessionRole !== 'device') return
- const code = Number(grfwxmm)
- if (code !== 111 && code !== 115) {
- console.warn('checkServiceStatus skip: invalid grfwxmm', grfwxmm)
- return
- }
- try {
- const result = await deviceApi.grfw_chkGrfw(code)
- const data = result?.data || {}
- if (data.ssCode === 0 && data.ssData) {
- this.serviceStatus = data.ssData
- }
- } catch (error) {
- console.error('检查服务状态失败:', error)
- }
- },
- async recordCallAndRefresh(callOptions = {}) {
- if (this.sessionRole !== 'device') return
- try {
- const duration = parseInt(callOptions.duration, 10) || 0
- const callType = callOptions.callType || 'voice'
- if (duration <= 0) return
- const minutes = Math.ceil(duration / 60)
- const grfwxmm = callType === 'video' ? 115 : 111
- await deviceApi.grfw_endGrfw({
- grfwxmm,
- sc: minutes,
- ll: 0,
- ms: `通话${duration}秒`
- })
- await this.checkServiceStatus(grfwxmm)
- } catch (error) {
- console.error('记录通话失败:', error)
- }
- },
- setVoipEndPagePath(forceUpdate = false) {
- if (this._voipEndPathSet && !forceUpdate) return
- if (!wmpfVoip) return
- const callInfo = uni.getStorageSync(CALL_END_STORAGE_KEY) || {}
- const query = [this.sessionRole === 'device' ? 'role=device' : 'role=parent']
- if (this.sessionContactId) {
- query.push(`contactId=${encodeURIComponent(this.sessionContactId)}`)
- }
- if (this.currentChatName) {
- query.push(`contactName=${encodeURIComponent(this.currentChatName)}`)
- }
- if (this.sessionRole === 'device') {
- const userInfo = this.getStoredUserInfo()
- if (userInfo.devId) {
- query.push(`sn=${encodeURIComponent(userInfo.devId)}`)
- }
- if (userInfo.cardNo) {
- query.push(`cardNo=${encodeURIComponent(userInfo.cardNo)}`)
- }
- }
- if (callInfo.name) {
- query.push(`name=${encodeURIComponent(callInfo.name)}`)
- query.push(`avatar=${encodeURIComponent(callInfo.avatar || '')}`)
- query.push(`duration=${callInfo.duration || 0}`)
- query.push(`type=${callInfo.type || 'voice'}`)
- query.push(`status=${encodeURIComponent(callInfo.status || '通话已结束')}`)
- }
- wmpfVoip.setVoipEndPagePath({
- url: CALL_END_PAGE_URL,
- key: 'Call',
- options: query.join('&'),
- routeType: 'redirectTo'
- })
- this._voipEndPathSet = true
- },
- registerVoipEvent() {
- if (this._voipEventRegistered || !wmpfVoip) return
- wmpfVoip.onVoipEvent((event) => {
- const eventName = event.eventName
- console.log('[VoIP] event:', eventName, event.data || {})
- if (eventName === 'startVoip') {
- this.isCalling = true
- if (this.inactivityTimer) {
- clearTimeout(this.inactivityTimer)
- this.inactivityTimer = null
- }
- }
- const hangupEvent = ['hangUpVoip', 'endVoip']
- const cancelEvent = ['cancelVoip']
- const timeoutEvent = ['timeout']
- const rejectEvent = ['rejectVoip']
- const callInfo = uni.getStorageSync(CALL_END_STORAGE_KEY) || {}
- if (hangupEvent.includes(eventName)) {
- callInfo.duration = event.data?.keepTime || 0
- callInfo.status = event.data?.keepTime > 0 ? '通话已结束' : '未接通'
- if (callInfo.duration > 0) {
- callInfo.needRecord = true
- }
- } else if (cancelEvent.includes(eventName)) {
- callInfo.duration = 0
- callInfo.status = '已取消'
- } else if (timeoutEvent.includes(eventName)) {
- callInfo.duration = 0
- callInfo.status = '未接听'
- } else if (rejectEvent.includes(eventName)) {
- callInfo.duration = 0
- callInfo.status = '已拒绝'
- }
- if ([...hangupEvent, ...cancelEvent, ...timeoutEvent, ...rejectEvent].includes(eventName)) {
- this.isCalling = false
- callInfo.endType = eventName
- callInfo.endTime = Date.now()
- uni.hideLoading()
- uni.setStorageSync(CALL_END_STORAGE_KEY, callInfo)
- this.setVoipEndPagePath(true)
- if (this.sessionRole === 'device') {
- let toastTitle = '通话已结束'
- if (cancelEvent.includes(eventName)) {
- toastTitle = '已取消呼叫'
- } else if (timeoutEvent.includes(eventName)) {
- toastTitle = '对方暂未接听'
- } else if (rejectEvent.includes(eventName)) {
- toastTitle = '对方已拒绝'
- } else if (hangupEvent.includes(eventName)) {
- const keepTime = Number(event?.data?.keepTime || 0)
- toastTitle = keepTime > 0 ? '通话已结束' : '未接通已结束'
- }
- uni.showToast({ title: toastTitle, icon: 'none' })
- }
- if (this.sessionRole === 'device' && callInfo.needRecord && callInfo.duration > 0) {
- this.recordCallAndRefresh({
- duration: callInfo.duration,
- callType: callInfo.type || 'voice'
- })
- delete callInfo.needRecord
- uni.setStorageSync(CALL_END_STORAGE_KEY, callInfo)
- }
- this.resetInactivityTimer()
- }
- })
- this._voipEventRegistered = true
- },
- formatPayloadTime(rawValue) {
- if (!rawValue) return ''
- const normalized = String(rawValue)
- .replace(/\u202f/g, ' ')
- .replace(/\u00a0/g, ' ')
- .replace(/\s+/g, ' ')
- .trim()
- let parsed = new Date(normalized)
- if (Number.isNaN(parsed.getTime())) {
- const monthMap = {
- Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5,
- Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11
- }
- const match = normalized.match(/^([A-Za-z]{3})\s+(\d{1,2}),\s*(\d{4}),\s*(\d{1,2}):(\d{2}):(\d{2})\s*(AM|PM)$/i)
- if (match) {
- const mon = monthMap[match[1]]
- let hh = Number(match[4])
- const mm = Number(match[5])
- const ss = Number(match[6])
- const ap = String(match[7]).toUpperCase()
- if (ap === 'PM' && hh < 12) hh += 12
- if (ap === 'AM' && hh === 12) hh = 0
- parsed = new Date(Number(match[3]), mon, Number(match[2]), hh, mm, ss)
- }
- }
- if (Number.isNaN(parsed.getTime())) return ''
- const hh = String(parsed.getHours()).padStart(2, '0')
- const mm = String(parsed.getMinutes()).padStart(2, '0')
- return `${hh}:${mm}`
- },
- waitHistoryDone(timeout = 12000) {
- if (this.historyWaiter) {
- return this.historyWaiter.promise
- }
- let resolve
- const promise = new Promise((r) => {
- resolve = r
- })
- this.historyWaiter = { promise, resolve }
- if (this.historyWaiterTimer) {
- clearTimeout(this.historyWaiterTimer)
- }
- this.historyWaiterTimer = setTimeout(() => {
- this.finishHistoryLoading()
- }, timeout)
- return promise
- },
- async finishHistoryLoading() {
- this.historyLoading = false
- if (this.historyWaiterTimer) {
- clearTimeout(this.historyWaiterTimer)
- this.historyWaiterTimer = null
- }
- if (this.historyWaiter && typeof this.historyWaiter.resolve === 'function') {
- this.historyWaiter.resolve(true)
- }
- this.historyWaiter = null
- if (this.sessionRole === 'parent') {
- await websocketService.disconnect()
- }
- },
- async loadHistoryMessages({ reset = true } = {}) {
- if (this.historyLoading) return
- if (!this.wsIdentity.sendRyid || !this.wsIdentity.recRyid) {
- console.warn('loadHistoryMessages skip: missing wsIdentity', this.wsIdentity)
- return
- }
- console.log('loadHistoryMessages start', {
- sendRyid: this.wsIdentity.sendRyid,
- recRyid: this.wsIdentity.recRyid,
- role: this.sessionRole
- })
- if (reset) {
- this.messages = []
- this.scrollIntoView = ''
- this.peerAvatarTypeHint = ''
- }
- const config = this.currentWsConfig || this.buildWsConnectOptions()
- this.historyLoading = true
- try {
- console.log('loadHistoryMessages ensureConnected start')
- await websocketService.ensureConnected(config)
- console.log('loadHistoryMessages ensureConnected done')
- await websocketService.send({
- cmd: 161,
- sendRyid: this.wsIdentity.sendRyid,
- recRyid: this.wsIdentity.recRyid
- })
- await this.waitHistoryDone()
- } catch (error) {
- this.historyLoading = false
- console.error('拉取历史留言失败:', error)
- if (this.sessionRole === 'parent') {
- await websocketService.disconnect()
- }
- }
- },
- resolveWsIdentity() {
- let userInfo = uni.getStorageSync('userInfo') || {}
- if (typeof userInfo === 'string') {
- try {
- userInfo = JSON.parse(userInfo)
- } catch (error) {
- userInfo = {}
- }
- }
- const sendRyid = String(userInfo.ryid || userInfo.yhid || userInfo.userId || '')
- const recRyid = String(
- this.sessionContactId ||
- this.sessionStudentId ||
- ''
- )
- const recRylbm = this.sessionRole === 'parent' ? REC_RYLBM_STUDENT : REC_RYLBM_PARENT
- this.wsIdentity = {
- sendRyid,
- recRyid,
- recRylbm
- }
- },
- buildWsConnectOptions() {
- let userInfo = uni.getStorageSync('userInfo') || {}
- if (typeof userInfo === 'string') {
- try {
- userInfo = JSON.parse(userInfo)
- } catch (error) {
- userInfo = {}
- }
- }
- const config = {
- role: this.sessionRole
- }
- if (this.sessionRole === 'device') {
- config.ssDevId = String(userInfo.devId || '')
- config.heartbeat = true
- config.autoReconnect = true
- } else {
- config.ssToken = String(userInfo.yhsbToken || '')
- config.heartbeat = false
- config.autoReconnect = false
- }
- return config
- },
- bindSocketListeners() {
- if (!this.currentWsConfig) return
- this.teardownSocketListeners()
- const un1 = websocketService.on('open', () => {
- this.socketConnected = true
- })
- const un2 = websocketService.on('close', () => {
- this.socketConnected = false
- })
- const un3 = websocketService.on('error', () => {
- this.socketConnected = false
- })
- const un4 = websocketService.on('cmd:101', (payload) => {
- this.handleWsIncomingMessage(payload)
- })
- const un5 = websocketService.on('cmd:165', (payload) => {
- this.handleWsHistoryMessage(payload)
- })
- const un6 = websocketService.on('cmd:11', () => {
- this.handleWsHistoryDone()
- })
- const un7 = websocketService.on('cmd:151', (payload) => {
- this.handleWsReceipt(payload)
- })
- const un8 = websocketService.on('cmd:51', () => {
- uni.$emit('device-message-refresh')
- })
- this.socketUnsubscribeList = [un1, un2, un3, un4, un5, un6, un7, un8]
- },
- teardownSocketListeners() {
- if (!this.socketUnsubscribeList.length) return
- this.socketUnsubscribeList.forEach((off) => {
- if (typeof off === 'function') off()
- })
- this.socketUnsubscribeList = []
- },
- async teardownSocket() {
- this.teardownSocketListeners()
- this.socketConnected = false
- if (this.sessionRole !== 'device') {
- await websocketService.disconnect()
- }
- },
- async ensureSessionSocket() {
- if (!this.currentWsConfig) {
- this.initializeSessionFromOptions({ force: true })
- }
- let config = this.currentWsConfig || {}
- if (config.role === 'parent') return
- if (!config.ssDevId) {
- this.resolveWsIdentity()
- this.currentWsConfig = this.buildWsConnectOptions()
- config = this.currentWsConfig || {}
- }
- if (!config.ssDevId) return
- try {
- await websocketService.ensureConnected(config)
- } catch (error) {
- console.error('设备端 WS 连接失败:', error)
- }
- },
- toDisplayImageUrl(path) {
- if (!path) return '/static/logo.png'
- if (/^https?:\/\//.test(path)) return path
- return getImageUrl(path)
- },
- toDisplayFileUrl(path, type = '') {
- if (!path) return ''
- if (/^https?:\/\//.test(path)) return path
- const typePart = type ? `&type=${encodeURIComponent(type)}` : ''
- return `${env.baseUrl}/service?ssServ=dlByHttp&wdConfirmationCaptchaService=0${typePart}&path=${encodeURIComponent(path)}`
- },
- formatVoiceBubbleText(message = {}) {
- const duration = String(message.duration || '').trim()
- return duration ? `${duration}"` : '语音'
- },
- getBaseNameFromPath(path) {
- if (!path) return ''
- const source = String(path).split('?')[0]
- const seg = source.split('/')
- return seg[seg.length - 1] || ''
- },
- normalizeIncomingCont(cont) {
- if (!cont) return {}
- if (typeof cont === 'string') {
- try {
- return JSON.parse(cont)
- } catch (error) {
- return { body: cont }
- }
- }
- return cont
- },
- appendPayloadMessage(payload = {}, { fromHistory = false } = {}) {
- if (!this.shouldHandleIncomingPayload(payload)) return
- const cont = this.normalizeIncomingCont(payload.cont)
- const typeCode = String(cont.type || '121')
- const direction = String(payload.sendRyid || '') === String(this.wsIdentity.sendRyid || '')
- ? 'right'
- : 'left'
- const messageMeta = this.resolveMessageMetaByDirection(direction)
- const payloadAlias = String(payload.alias || '').trim()
- const payloadLogo = payload.logo ? this.toDisplayImageUrl(payload.logo) : ''
- const payloadLogoType = String(payload.logoType || '')
- if (direction === 'left' && (payloadLogoType === '1' || payloadLogoType === '51')) {
- this.peerAvatarTypeHint = payloadLogoType
- }
- const msgId = payload.xxid || payload.msgId || ''
- if (fromHistory && msgId) {
- const exists = this.messages.some((item) => String(item.msgId || '') === String(msgId))
- if (exists) return
- }
- const payloadTime = this.formatPayloadTime(payload.sendTime || payload.sendTimeStr || payload.time)
- const displayTime = payloadTime || this.formatPayloadTime(new Date())
- const receiptStatus = (payload.readTime || payload.rdTime || payload.readTm) ? 'read' : 'unread'
- if (typeCode === '121') {
- const rawBody = String(cont.body || '').trim()
- if (cont.fileName && (!rawBody || /^\[文件\]/.test(rawBody))) {
- this.appendMessage({
- type: 'file',
- direction,
- department: messageMeta.department,
- name: messageMeta.name,
- displayName: direction === 'left' ? (payloadAlias || messageMeta.name) : messageMeta.name,
- avatarUrl: direction === 'left' ? payloadLogo : '',
- avatarType: direction === 'left' ? payloadLogoType : '',
- fileName: cont.baseName || this.getBaseNameFromPath(cont.fileName) || '未命名文件',
- fileUrl: this.toDisplayFileUrl(cont.fileName || ''),
- time: displayTime,
- needReceipt: true,
- receiptStatus,
- msgId
- })
- return
- }
- this.appendMessage(createTextMessage({
- direction,
- department: messageMeta.department,
- name: messageMeta.name,
- displayName: direction === 'left' ? (payloadAlias || messageMeta.name) : messageMeta.name,
- avatarUrl: direction === 'left' ? payloadLogo : '',
- avatarType: direction === 'left' ? payloadLogoType : '',
- content: cont.body || '',
- needReceipt: true,
- receiptStatus,
- time: displayTime,
- msgId
- }))
- return
- }
- if (typeCode === '122') {
- this.appendMessage(createImageMessage({
- direction,
- department: messageMeta.department,
- name: messageMeta.name,
- displayName: direction === 'left' ? (payloadAlias || messageMeta.name) : messageMeta.name,
- avatarUrl: direction === 'left' ? payloadLogo : '',
- avatarType: direction === 'left' ? payloadLogoType : '',
- imageUrl: this.toDisplayImageUrl(cont.fileName || cont.body || ''),
- time: displayTime,
- needReceipt: true,
- receiptStatus,
- msgId
- }))
- return
- }
- if (typeCode === '123') {
- this.appendMessage({
- type: 'voice',
- direction,
- department: messageMeta.department,
- name: messageMeta.name,
- displayName: direction === 'left' ? (payloadAlias || messageMeta.name) : messageMeta.name,
- avatarUrl: direction === 'left' ? payloadLogo : '',
- avatarType: direction === 'left' ? payloadLogoType : '',
- duration: String(cont.duration || ''),
- audioUrl: this.toDisplayFileUrl(cont.fileName || '', 'aud'),
- voicePreview: '',
- voiceText: cont.body || '',
- needReceipt: true,
- receiptStatus,
- time: displayTime,
- msgId
- })
- return
- }
- if (typeCode === '124') {
- this.appendMessage({
- type: 'video',
- direction,
- department: messageMeta.department,
- name: messageMeta.name,
- displayName: direction === 'left' ? (payloadAlias || messageMeta.name) : messageMeta.name,
- avatarUrl: direction === 'left' ? payloadLogo : '',
- avatarType: direction === 'left' ? payloadLogoType : '',
- coverUrl: '/static/logo.png',
- videoUrl: this.toDisplayFileUrl(cont.fileName || '', 'vid'),
- duration: String(cont.duration || ''),
- time: displayTime,
- needReceipt: true,
- receiptStatus,
- msgId
- })
- }
- },
- handleWsIncomingMessage(payload = {}) {
- this.appendPayloadMessage(payload, { fromHistory: false })
- },
- handleWsHistoryMessage(payload = {}) {
- this.appendPayloadMessage(payload, { fromHistory: true })
- },
- handleWsHistoryDone() {
- if (!this.historyLoading) return
- this.finishHistoryLoading()
- },
- shouldHandleIncomingPayload(payload = {}) {
- const send = String(payload.sendRyid || '')
- const rec = String(payload.recRyid || '')
- const me = String(this.wsIdentity.sendRyid || '')
- const peer = String(this.wsIdentity.recRyid || '')
- if (!me || !peer) return true
- return (send === me && rec === peer) || (send === peer && rec === me)
- },
- handleWsReceipt(payload = {}) {
- const msgId = String(payload.xxid || '')
- if (!msgId) return
- const idx = this.messages.findIndex((item) => String(item.msgId || '') === msgId)
- if (idx < 0) return
- this.$set(this.messages[idx], 'receiptStatus', 'read')
- },
- async confirmRead(message, index) {
- if (!message || message.direction !== 'left') return
- if (message.receiptStatus === 'read') return
- const msgId = String(message.msgId || '')
- if (!msgId) return
- const payload = {
- cmd: 151,
- echoState: 2,
- xxid: msgId,
- sendRyid: this.wsIdentity.sendRyid,
- recRyid: this.wsIdentity.recRyid,
- recRylbm: this.wsIdentity.recRylbm
- }
- const sent = await this.sendWsPayload(payload)
- if (!sent) return
- this.$set(this.messages[index], 'receiptStatus', 'read')
- },
- async sendWsPayload(payload) {
- this.resetInactivityTimer()
- const config = this.currentWsConfig || this.buildWsConnectOptions()
- if (!config) return false
- if (config.role === 'parent' && !config.ssToken) {
- uni.showToast({ title: '缺少 ssToken', icon: 'none' })
- return false
- }
- if (config.role === 'device' && !config.ssDevId) {
- uni.showToast({ title: '缺少 ssDevId', icon: 'none' })
- return false
- }
- try {
- await websocketService.ensureConnected(config)
- await websocketService.send(payload)
- if (config.role === 'parent') {
- await websocketService.disconnect()
- }
- return true
- } catch (error) {
- console.error('发送留言失败:', error)
- uni.showToast({ title: '发送失败', icon: 'none' })
- return false
- }
- },
- async sendOutgoingByType(message) {
- if (!message) return false
- const sendRyid = this.wsIdentity.sendRyid
- const recRyid = this.wsIdentity.recRyid
- if (!sendRyid || !recRyid) {
- uni.showToast({ title: '缺少人员标识', icon: 'none' })
- return false
- }
- let cont = null
- if (message.type === 'text') {
- cont = {
- title: '',
- type: '121',
- body: message.content || ''
- }
- } else if (message.type === 'image') {
- cont = {
- title: '',
- type: '122',
- body: '',
- fileName: message.serverFilePath || '',
- baseName: message.baseName || 'image.jpg'
- }
- } else if (message.type === 'voice') {
- cont = {
- title: '音频',
- type: '123',
- body: '',
- fileName: message.serverFilePath || '',
- baseName: message.baseName || 'voice.mp3',
- duration: message.duration || ''
- }
- } else if (message.type === 'video') {
- cont = {
- title: '视频',
- type: '124',
- body: '',
- fileName: message.serverFilePath || '',
- baseName: message.baseName || 'video.mp4',
- duration: message.duration || ''
- }
- } else if (message.type === 'file') {
- cont = {
- title: '',
- type: '121',
- body: '',
- fileName: message.serverFilePath || '',
- baseName: message.baseName || message.fileName || 'file'
- }
- } else {
- uni.showToast({ title: '当前消息类型不支持发送', icon: 'none' })
- return false
- }
- const payload = {
- cmd: 101,
- cont,
- sendRyid,
- recRyid,
- recRylbm: this.wsIdentity.recRylbm,
- echoState: 1
- }
- return this.sendWsPayload(payload)
- },
- 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)
- if (!voiceMessage) return
- ;(async () => {
- try {
- const localPath = voiceMessage.audioUrl
- const serverPath = await upload.uploadAudio(localPath)
- if (!serverPath) return
- voiceMessage.serverFilePath = serverPath
- voiceMessage.baseName = this.getBaseNameFromPath(localPath) || 'voice.mp3'
- voiceMessage.audioUrl = this.toDisplayFileUrl(serverPath, 'aud')
- const sent = await this.sendOutgoingByType(voiceMessage)
- if (sent) this.appendMessage(voiceMessage)
- } catch (error) {
- uni.showToast({ title: '语音发送失败', icon: 'none' })
- }
- })()
- })
- 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) {
- this.resetInactivityTimer()
- const touch = event.touches && event.touches[0]
- if (!touch) return
- this.listTouchMoved = false
- this.touchStartX = touch.clientX
- this.touchStartY = touch.clientY
- },
- handleListTouchMove(event) {
- this.resetInactivityTimer()
- 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() {
- this.resetInactivityTimer()
- 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) return
- const localPath = fileMessage.fileUrl
- const serverPath = await upload.uploadCommonFile(localPath)
- if (!serverPath) return
- fileMessage.serverFilePath = serverPath
- fileMessage.baseName = fileMessage.fileName || this.getBaseNameFromPath(localPath) || 'file'
- fileMessage.fileUrl = this.toDisplayFileUrl(serverPath)
- const sent = await this.sendOutgoingByType(fileMessage)
- if (sent) this.appendMessage(fileMessage)
- return
- }
- if (type === 'image') {
- const imageMessages = await pickImageOutgoingMessages(this.currentUserMeta)
- for (const imageMessage of imageMessages) {
- const localPath = imageMessage.imageUrl
- const serverPath = await upload.uploadImage(localPath)
- if (!serverPath) continue
- imageMessage.serverFilePath = serverPath
- imageMessage.baseName = localPath.split('/').pop() || 'image.jpg'
- imageMessage.imageUrl = this.toDisplayImageUrl(serverPath)
- const sent = await this.sendOutgoingByType(imageMessage)
- if (sent) this.appendMessage(imageMessage)
- }
- return
- }
- if (type === 'camera') {
- const imageMessage = await shootImageOutgoingMessage(this.currentUserMeta)
- if (!imageMessage) return
- const localPath = imageMessage.imageUrl
- const serverPath = await upload.uploadImage(localPath)
- if (!serverPath) return
- imageMessage.serverFilePath = serverPath
- imageMessage.baseName = localPath.split('/').pop() || 'image.jpg'
- imageMessage.imageUrl = this.toDisplayImageUrl(serverPath)
- const sent = await this.sendOutgoingByType(imageMessage)
- if (sent) this.appendMessage(imageMessage)
- return
- }
- if (type === 'video') {
- const videoMessage = await pickVideoOutgoingMessage(this.currentUserMeta)
- if (!videoMessage) return
- const localPath = videoMessage.videoUrl
- const serverPath = await upload.uploadVideo(localPath)
- if (!serverPath) return
- videoMessage.serverFilePath = serverPath
- videoMessage.baseName = this.getBaseNameFromPath(localPath) || 'video.mp4'
- videoMessage.videoUrl = this.toDisplayFileUrl(serverPath, 'vid')
- const sent = await this.sendOutgoingByType(videoMessage)
- if (sent) 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
- },
- async sendTextMessage() {
- const message = buildTextOutgoingMessage(this.draftText, this.currentUserMeta)
- if (!message) return
- message.showReceiptToggle = false
- message.needReceipt = true
- const sent = await this.sendOutgoingByType(message)
- if (!sent) return
- 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;
- .chat-nav {
- height: 110rpx;
- background: #f2f3f4;
- padding: 0 24rpx;
- display: flex;
- align-items: center;
- justify-content: space-between;
- flex-shrink: 0;
- }
- .chat-title-wrap {
- flex: 1;
- display: flex;
- justify-content: flex-start;
- align-items: center;
- min-width: 0;
- }
- .chat-title-picker {
- max-width: 460rpx;
- display: flex;
- align-items: center;
- gap: 8rpx;
- }
- .chat-title-picker.single {
- gap: 0;
- }
- .chat-name-arrow {
- font-size: 20rpx;
- color: #000000;
- line-height: 1;
- }
- .chat-title {
- font-size: 30rpx;
- font-weight: 400;
- color: #000000;
- max-width: 420rpx;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .chat-nav-right {
- display: flex;
- align-items: center;
- gap: 16rpx;
- }
- .chat-call-btn {
- width: 78rpx;
- height: 78rpx;
- border-radius: 12rpx;
- background: #ffffff;
- border: 1rpx solid #e5e5e5;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .chat-logout-icon {
- width: 36rpx;
- height: 36rpx;
- display: block;
- }
- .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;
- }
- .avatar.square-avatar {
- border-radius: 8rpx;
- }
- .avatar.doc-avatar {
- object-position: center 5px;
- }
- .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;
- }
- .user-info-role {
- display: none;
- }
- .message-content {
- display: flex;
- align-items: flex-end;
- gap: 10rpx;
- }
- /* 文字消息容器 */
- .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 .avatar.square-avatar {
- border-radius: 8rpx;
- }
- .right .content-section {
- align-items: flex-end;
- }
- .right .user-info {
- text-align: right;
- }
- .user-info-placeholder {
- color: transparent;
- }
- .user-info-placeholder .user-info-role {
- display: inline;
- }
- /* 右侧消息气泡样式 */
- .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>
|