message.vue 81 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882
  1. <template>
  2. <view class="message-page">
  3. <view class="chat-nav">
  4. <view class="chat-title-wrap">
  5. <picker
  6. v-if="parentChatMembers.length > 1"
  7. mode="selector"
  8. :range="parentChatMemberNames"
  9. :value="parentChatMemberIndex"
  10. @change="handleParentMemberChange"
  11. >
  12. <view class="chat-title-picker">
  13. <Icon lib="base" name="icon-down" size="36" color="#000000" />
  14. <text class="chat-title">{{ currentChatName }}</text>
  15. </view>
  16. </picker>
  17. <view v-else class="chat-title-picker single">
  18. <text class="chat-title">{{ currentChatName }}</text>
  19. </view>
  20. </view>
  21. <view class="chat-nav-right">
  22. <view class="chat-call-btn" @click="startVoiceCall">
  23. <Icon lib="base" name="icon-aud-bold" size="36" color="#3c4151" />
  24. </view>
  25. <view class="chat-call-btn" @click="startVideoCall">
  26. <Icon lib="base" name="icon-vid-bold" size="36" color="#3c4151" />
  27. </view>
  28. <view v-if="sessionRole === 'device'" class="chat-call-btn" @click="handleDeviceLogout">
  29. <image class="chat-logout-icon" src="/static/icon/logout.png"></image>
  30. </view>
  31. </view>
  32. </view>
  33. <!-- 消息列表 -->
  34. <scroll-view class="message-list" scroll-y :scroll-top="scrollTop" :scroll-into-view="scrollIntoView"
  35. @touchstart="handleListTouchStart" @touchmove="handleListTouchMove" @touchend="handleListTouchEnd">
  36. <view class="message-item" :class="message.direction" v-for="(message, index) in messages" :key="index"
  37. :id="'msg-' + index">
  38. <!-- 头像+时间区域 -->
  39. <view class="avatar-section">
  40. <image
  41. class="avatar"
  42. :class="getAvatarClass(message)"
  43. :src="getAvatarSrc(message)"
  44. mode="aspectFill"
  45. ></image>
  46. <text class="msg-time">{{ message.time || '--:--' }}</text>
  47. </view>
  48. <!-- 内容区域 -->
  49. <view class="content-section">
  50. <!-- 职务 | 姓名(职务先隐藏) -->
  51. <view class="user-info" :class="{ 'user-info-placeholder': message.direction === 'right' }">
  52. <text class="user-info-role">{{ message.department }} | </text>
  53. <text>{{ message.direction === 'left' ? (message.displayName || message.name) : '占位' }}</text>
  54. </view>
  55. <!-- 消息内容 -->
  56. <view class="message-content">
  57. <!-- 文字消息 -->
  58. <view v-if="message.type === 'text'" class="text-message-wrapper">
  59. <!-- 接收的消息 -->
  60. <template v-if="message.direction === 'left'">
  61. <view class="text-message">
  62. <text>{{ message.content }}</text>
  63. </view>
  64. </template>
  65. <!-- 发送的消息 -->
  66. <template v-else>
  67. <view class="text-message">
  68. <text>{{ message.content }}</text>
  69. </view>
  70. </template>
  71. </view>
  72. <!-- 通话记录 -->
  73. <view v-if="message.type === 'call'" class="call-message">
  74. <image src="/static/icon/tingtong.png"></image>
  75. <text>通话时长 {{ message.duration }}</text>
  76. </view>
  77. <!-- 语音消息 -->
  78. <view
  79. v-if="message.type === 'voice'"
  80. class="voice-message"
  81. :style="getVoiceBubbleStyle(message)"
  82. @click.stop="toggleVoicePlayback(message, index)"
  83. >
  84. <image
  85. :src="message.direction === 'left' ? '/static/icon/guangbo.png' : '/static/icon/yinbo.png'">
  86. </image>
  87. <text>{{ formatVoiceBubbleText(message) }}</text>
  88. <view v-if="message.isRecording" class="recording-dot"></view>
  89. </view>
  90. <!-- 图片消息 -->
  91. <view v-if="message.type === 'image'" class="image-message" @click.stop="openImagePreview(message)">
  92. <image class="image-preview" :src="message.imageUrl || '/static/logo.png'" mode="aspectFill"></image>
  93. </view>
  94. <!-- 文件消息 -->
  95. <view v-if="message.type === 'file'" class="file-message" @click.stop="openFilePreview(message)">
  96. <Icon lib="base" name="icon-file" size="39" :color="iconColor" />
  97. <text class="file-name">{{ message.fileName }}</text>
  98. </view>
  99. <!-- 视频消息 -->
  100. <view v-if="message.type === 'video'" class="video-message" @click.stop="openVideoPreview(message, index)">
  101. <video
  102. class="video-cover"
  103. :src="message.videoUrl"
  104. muted
  105. :controls="false"
  106. :show-center-play-btn="false"
  107. :enable-progress-gesture="false"
  108. object-fit="cover"
  109. ></video>
  110. <view class="video-play">
  111. <Icon lib="base" name="icon-vid" size="40" color="#ffffff" />
  112. </view>
  113. </view>
  114. <view
  115. v-if="message.direction === 'left' && message.needReceipt && message.receiptStatus !== 'read'"
  116. class="receipt-button"
  117. @click.stop="confirmRead(message, index)"
  118. >
  119. 确认阅读
  120. </view>
  121. </view>
  122. <!-- 语音转文字区域(仅语音消息且有转文字内容时显示) -->
  123. <view v-if="message.type === 'voice' && message.voiceText" class="voice-text-section">
  124. <!-- 接收的消息 -->
  125. <template v-if="message.direction === 'left'">
  126. <view class="voice-text-content">{{ message.voiceText }}</view>
  127. </template>
  128. <!-- 发送的消息 -->
  129. <template v-else>
  130. <view class="voice-text-content">{{ message.voiceText }}</view>
  131. </template>
  132. </view>
  133. </view>
  134. </view>
  135. </scroll-view>
  136. <!-- 底部操作栏 -->
  137. <view class="footer" :class="{ active: showMorePanel || showEmojiPanel }" @click.stop>
  138. <!-- 左侧:语音/键盘切换 -->
  139. <view class="tool-btn" @click="toggleInputMode">
  140. <Icon lib="base" :name="inputMode === 'voice' ? 'icon-txt' : 'icon-spk'" size="36" :color="iconColor" />
  141. </view>
  142. <!-- 中间:输入框 / 按住说话 -->
  143. <view class="center-area">
  144. <view
  145. v-if="inputMode === 'voice'"
  146. class="press-to-talk"
  147. @longpress="handlePressToTalkStart"
  148. @touchmove.stop.prevent="handlePressToTalkMove"
  149. @touchend.stop.prevent="handlePressToTalkEnd"
  150. @touchcancel.stop.prevent="handlePressToTalkCancel"
  151. >
  152. <Icon lib="base" name="icon-aud" size="36" :color="iconColor" />
  153. <text class="press-text">长按说话</text>
  154. </view>
  155. <textarea
  156. v-else
  157. class="text-input"
  158. :class="{ capped: textLineCount >= 5 }"
  159. v-model="draftText"
  160. :focus="inputFocus"
  161. auto-height
  162. confirm-type="send"
  163. @confirm="sendTextMessage"
  164. maxlength="-1"
  165. @linechange="handleTextLineChange"
  166. @blur="inputFocus = false"
  167. />
  168. </view>
  169. <!-- 右侧:表情 + 更多 -->
  170. <view class="tool-btn" @click="toggleEmojiPanel">
  171. <Icon lib="base" name="icon-emoji" size="36" :color="iconColor" />
  172. </view>
  173. <view class="tool-btn" @click="toggleMorePanel">
  174. <Icon lib="base" name="icon-add" size="36" :color="iconColor" />
  175. </view>
  176. </view>
  177. <!-- 底部扩展面板(更多/表情) -->
  178. <view class="bottom-panel" :class="{ open: showMorePanel || showEmojiPanel }" @click.stop>
  179. <view v-show="showMorePanel" class="more-panel">
  180. <view class="more-grid">
  181. <view class="more-item" @click="handleMoreAction('file')">
  182. <view class="more-icon">
  183. <Icon lib="base" name="icon-file" size="36" :color="iconColor" />
  184. </view>
  185. <text class="more-text">文件</text>
  186. </view>
  187. <view class="more-item" @click="handleMoreAction('image')">
  188. <view class="more-icon">
  189. <Icon lib="base" name="icon-img" size="36" :color="iconColor" />
  190. </view>
  191. <text class="more-text">照片</text>
  192. </view>
  193. <view class="more-item" @click="handleMoreAction('camera')">
  194. <view class="more-icon">
  195. <Icon lib="base" name="icon-photo" size="36" :color="iconColor" />
  196. </view>
  197. <text class="more-text">拍照</text>
  198. </view>
  199. <view class="more-item" @click="handleMoreAction('video')">
  200. <view class="more-icon">
  201. <Icon lib="base" name="icon-vid" size="36" :color="iconColor" />
  202. </view>
  203. <text class="more-text">视频留言</text>
  204. </view>
  205. </view>
  206. </view>
  207. <view v-show="showEmojiPanel" class="emoji-panel">
  208. <view class="emoji-grid">
  209. <view class="emoji-item" v-for="(emoji, index) in emojiList" :key="index"
  210. @click="insertEmoji(emoji)">
  211. {{ emoji }}
  212. </view>
  213. </view>
  214. </view>
  215. </view>
  216. <view v-if="showMediaPreview" class="media-preview-mask" @click="closeMediaPreview">
  217. <view class="media-preview-content" :class="previewType === 'image' ? 'image-mode' : 'video-mode'" @click.stop="handlePreviewContentClick">
  218. <swiper
  219. v-if="previewType === 'image'"
  220. class="media-preview-swiper"
  221. :current="previewImageIndex"
  222. @change="handleImageSwiperChange"
  223. >
  224. <swiper-item class="media-preview-swiper-item" v-for="(img, idx) in previewImageList" :key="idx">
  225. <image class="media-preview-image fit-x" :src="img" mode="widthFix"></image>
  226. </swiper-item>
  227. </swiper>
  228. <video
  229. v-if="previewType === 'video'"
  230. class="media-preview-video"
  231. :src="previewSource"
  232. autoplay
  233. controls
  234. object-fit="contain"
  235. @loadedmetadata="handlePreviewVideoLoaded"
  236. @error="handlePreviewVideoError"
  237. ></video>
  238. </view>
  239. <view v-if="previewType === 'video'" class="media-preview-exit" @click.stop="closeMediaPreview">退出</view>
  240. </view>
  241. <view v-if="showFileDialog" class="file-dialog-mask" @click="closeFileDialog">
  242. <view class="file-dialog" @click.stop>
  243. <view class="file-dialog-header">
  244. <Icon lib="base" name="icon-file" size="44" :color="iconColor" />
  245. <text class="file-dialog-name">{{ activeFileName }}</text>
  246. </view>
  247. <view class="file-dialog-actions">
  248. <view class="file-dialog-btn ghost" @click="closeFileDialog">取消</view>
  249. <view class="file-dialog-btn" @click="downloadFileFromDialog">下载</view>
  250. </view>
  251. </view>
  252. </view>
  253. <view v-if="isRecording" class="record-mask">
  254. <view class="record-panel">
  255. <view class="record-title">正在录音</view>
  256. <view class="record-time">{{ recordSeconds }}s</view>
  257. <view class="record-tip" :class="{ danger: isRecordCancel }">
  258. {{ isRecordCancel ? '松开取消发送' : '手指上滑,取消发送' }}
  259. </view>
  260. </view>
  261. </view>
  262. <custom-modal
  263. :visible="modalVisible"
  264. :title="modalTitle"
  265. :content="modalContent"
  266. :showCancel="modalShowCancel"
  267. :confirmText="modalConfirmText"
  268. @confirm="handleModalConfirm"
  269. @cancel="handleModalCancel"
  270. />
  271. </view>
  272. </template>
  273. <script>
  274. import Icon from '@/components/icon/index.vue'
  275. import customModal from '@/components/custom-modal.vue'
  276. import { EMOJI_LIST } from '@/constants/emoji'
  277. import { collectImageUrls, createImageMessage, createTextMessage } from '@/utils/parent-message-factory'
  278. import {
  279. buildTextOutgoingMessage,
  280. buildVoiceOutgoingMessage,
  281. pickFileOutgoingMessage,
  282. pickImageOutgoingMessages,
  283. pickVideoOutgoingMessage,
  284. shootImageOutgoingMessage
  285. } from '@/utils/parent-message-send'
  286. import websocketService from '@/utils/websocket'
  287. import upload from '@/utils/upload'
  288. import { getImageUrl } from '@/utils/util'
  289. import { grfwApi } from '@/api/grfw'
  290. import { deviceApi } from '@/api/device'
  291. import env from '@/config/env.js'
  292. const RECEIPT_TOGGLE_TTL_MS = 60 * 1000
  293. const REC_RYLBM_STUDENT = 1100
  294. const REC_RYLBM_PARENT = 1200
  295. const CALL_END_STORAGE_KEY = 'parentLastCallInfo'
  296. const CALL_END_PAGE_URL = '/pages/parent/message'
  297. let wmpfVoip = null
  298. try {
  299. if (typeof requirePlugin === 'function') {
  300. wmpfVoip = requirePlugin('wmpf-voip').default
  301. }
  302. } catch (error) {
  303. console.warn('wmpf-voip 插件暂不可用', error)
  304. }
  305. const miniprogramState = (() => {
  306. if (typeof wx === 'undefined' || typeof wx.getAccountInfoSync !== 'function') {
  307. return 'formal'
  308. }
  309. const accountInfo = wx.getAccountInfoSync()
  310. if (accountInfo && accountInfo.miniProgram) {
  311. const platform = { develop: 'developer', trial: 'trial', release: 'formal' }
  312. return platform[accountInfo.miniProgram.envVersion]
  313. }
  314. return 'formal'
  315. })()
  316. export default {
  317. props: {
  318. role: {
  319. type: String,
  320. default: ''
  321. },
  322. contactId: {
  323. type: [String, Number],
  324. default: ''
  325. },
  326. contactName: {
  327. type: String,
  328. default: ''
  329. },
  330. studentId: {
  331. type: [String, Number],
  332. default: ''
  333. }
  334. },
  335. components: {
  336. Icon,
  337. customModal
  338. },
  339. computed: {},
  340. watch: {
  341. role() {
  342. this.initializeSessionFromOptions({ force: true, role: this.role, contactId: this.contactId, studentId: this.studentId })
  343. this.bootstrapMessageFlow()
  344. },
  345. contactId() {
  346. this.initializeSessionFromOptions({ force: true, role: this.role, contactId: this.contactId, studentId: this.studentId })
  347. this.bootstrapMessageFlow()
  348. },
  349. contactName() {
  350. this.initializeSessionFromOptions({ force: true, role: this.role, contactId: this.contactId, contactName: this.contactName, studentId: this.studentId })
  351. },
  352. studentId() {
  353. this.initializeSessionFromOptions({ force: true, role: this.role, contactId: this.contactId, studentId: this.studentId })
  354. this.bootstrapMessageFlow()
  355. }
  356. },
  357. data() {
  358. return {
  359. scrollTop: 0,
  360. scrollIntoView: '',
  361. inputMode: 'voice', // voice | text
  362. inputFocus: false,
  363. draftText: '',
  364. iconColor: '#575d6d',
  365. showEmojiPanel: false,
  366. showMorePanel: false,
  367. emojiList: EMOJI_LIST,
  368. showMediaPreview: false,
  369. previewType: '',
  370. previewSource: '',
  371. activeVideoIndex: -1,
  372. previewImageList: [],
  373. previewImageIndex: 0,
  374. showFileDialog: false,
  375. activeFileName: '',
  376. activeFileUrl: '',
  377. receiptToggleTimers: {},
  378. parentChatMembers: [],
  379. parentChatMemberNames: [],
  380. parentChatMemberIndex: 0,
  381. currentChatName: '留言',
  382. historyLoading: false,
  383. historyWaiter: null,
  384. historyWaiterTimer: null,
  385. parentInfo: {
  386. xm: '',
  387. openid: ''
  388. },
  389. currentCallContact: null,
  390. _voipEndPathSet: false,
  391. _voipEventRegistered: false,
  392. currentUserMeta: {
  393. direction: 'right',
  394. department: '家长',
  395. name: '家长'
  396. },
  397. textLineCount: 1,
  398. listTouchMoved: false,
  399. touchStartX: 0,
  400. touchStartY: 0,
  401. recorderManager: null,
  402. isRecording: false,
  403. isRecordCancel: false,
  404. recordSeconds: 0,
  405. recordStartY: 0,
  406. recordTickTimer: null,
  407. recordGuardTimer: null,
  408. audioPlayer: null,
  409. playingVoiceIndex: -1,
  410. sessionInitialized: false,
  411. sessionRole: 'parent',
  412. sessionContactId: '',
  413. sessionStudentId: '',
  414. entryFromDeviceIndex: false,
  415. socketConnected: false,
  416. socketUnsubscribeList: [],
  417. currentWsConfig: null,
  418. wsIdentity: {
  419. sendRyid: '',
  420. recRyid: '',
  421. recRylbm: REC_RYLBM_STUDENT
  422. },
  423. deviceSessionReady: true,
  424. deviceInitPromise: null,
  425. peerAvatarTypeHint: '',
  426. serviceStatus: null,
  427. inactivityTimer: null,
  428. inactivityTimeout: 180000,
  429. isCalling: false,
  430. modalVisible: false,
  431. modalTitle: '提示',
  432. modalContent: '',
  433. modalShowCancel: false,
  434. modalConfirmText: '确定',
  435. modalResolve: null,
  436. loadOptions: {},
  437. // 消息列表数据
  438. messages: []
  439. }
  440. },
  441. created() {
  442. this.initializeSessionFromOptions({
  443. role: this.role,
  444. contactId: this.contactId,
  445. contactName: this.contactName,
  446. studentId: this.studentId
  447. })
  448. },
  449. async onLoad(options) {
  450. this.loadOptions = { ...(options || {}) }
  451. this.initializeSessionFromOptions({ ...(options || {}), force: true })
  452. this.initParentInfo()
  453. if (this.sessionRole === 'device') {
  454. this.deviceSessionReady = false
  455. this.deviceInitPromise = this.initDeviceSession(options || {})
  456. this.deviceSessionReady = await this.deviceInitPromise
  457. }
  458. },
  459. async onReady() {
  460. // 页面渲染完成后,滚动到最新消息
  461. this.$nextTick(() => {
  462. this.scrollToBottom()
  463. this.initReceiptToggleTimers()
  464. this.initRecorder()
  465. })
  466. this.registerVoipEvent()
  467. this.bindSocketListeners()
  468. if (this.sessionRole === 'device') {
  469. await (this.deviceInitPromise || Promise.resolve(true))
  470. if (!this.deviceSessionReady) return
  471. await this.checkServiceStatus(115)
  472. await this.checkServiceStatus(111)
  473. this.resetInactivityTimer()
  474. }
  475. await this.bootstrapMessageFlow()
  476. },
  477. onUnload() {
  478. this.teardownSocket()
  479. this.stopVoicePlayback()
  480. if (this.audioPlayer) {
  481. this.audioPlayer.destroy()
  482. this.audioPlayer = null
  483. }
  484. this.cleanupRecorder()
  485. this.clearReceiptToggleTimers()
  486. if (this.inactivityTimer) {
  487. clearTimeout(this.inactivityTimer)
  488. this.inactivityTimer = null
  489. }
  490. },
  491. beforeUnmount() {
  492. this.teardownSocket()
  493. },
  494. methods: {
  495. showCustomModal(options = {}) {
  496. return new Promise((resolve) => {
  497. this.modalTitle = options.title || '提示'
  498. this.modalContent = options.content || ''
  499. this.modalShowCancel = options.showCancel || false
  500. this.modalConfirmText = options.confirmText || '确定'
  501. this.modalResolve = resolve
  502. this.modalVisible = true
  503. })
  504. },
  505. handleModalConfirm() {
  506. this.modalVisible = false
  507. if (this.modalResolve) {
  508. this.modalResolve(true)
  509. this.modalResolve = null
  510. }
  511. },
  512. handleModalCancel() {
  513. this.modalVisible = false
  514. if (this.modalResolve) {
  515. this.modalResolve(false)
  516. this.modalResolve = null
  517. }
  518. },
  519. initializeSessionFromOptions(options = {}) {
  520. if (this.sessionInitialized && !options?.force) return
  521. const safeDecode = (value) => {
  522. if (value === null || value === undefined) return ''
  523. const text = String(value)
  524. try {
  525. return decodeURIComponent(text)
  526. } catch (error) {
  527. return text
  528. }
  529. }
  530. const role = options.role || this.role || 'parent'
  531. this.sessionRole = role
  532. this.sessionContactId = safeDecode(options.contactId || this.contactId || '')
  533. this.sessionStudentId = options.studentId || this.studentId || ''
  534. this.entryFromDeviceIndex = String(options.fromIndex || '') === '1'
  535. const targetContactName = safeDecode(options.contactName || this.contactName || '')
  536. this.currentChatName = role === 'device' ? (targetContactName || '家长') : '留言'
  537. this.currentUserMeta = {
  538. ...this.currentUserMeta,
  539. department: role === 'device' ? '设备端' : '家长'
  540. }
  541. this.refreshCurrentUserMeta()
  542. this.resolveWsIdentity()
  543. this.currentWsConfig = this.buildWsConnectOptions()
  544. this.sessionInitialized = true
  545. if (this.socketUnsubscribeList.length) {
  546. this.bindSocketListeners()
  547. }
  548. },
  549. async bootstrapMessageFlow() {
  550. if (this.sessionRole === 'parent') {
  551. await this.loadParentChatMembers()
  552. } else {
  553. await this.loadDeviceChatMembers()
  554. await this.ensureSessionSocket()
  555. }
  556. await this.loadHistoryMessages({ reset: true })
  557. if (this.sessionRole === 'device' && this.entryFromDeviceIndex) {
  558. await this.loadHistoryMessages({ reset: true })
  559. }
  560. },
  561. async initDeviceSession(options = {}) {
  562. if (this.sessionRole !== 'device') return
  563. const userInfo = this.getStoredUserInfo()
  564. const snFromOption = String(options.sn || '').trim()
  565. const cardNo = String(options.cardNo || '').trim()
  566. const sn = snFromOption || String(userInfo.devId || '').trim()
  567. if (!sn) {
  568. uni.showToast({ title: '缺少设备标识,请重新刷卡登录', icon: 'none' })
  569. setTimeout(() => {
  570. uni.reLaunch({ url: '/pages/device/notice' })
  571. }, 1200)
  572. return false
  573. }
  574. if (!cardNo) {
  575. const hasSession = !!(userInfo.yhsbToken || userInfo.onlineToken || userInfo.sessId)
  576. if (!hasSession) {
  577. uni.showToast({ title: '登录已失效,请重新刷卡登录', icon: 'none' })
  578. setTimeout(() => {
  579. uni.reLaunch({ url: '/pages/device/notice' })
  580. }, 1200)
  581. return false
  582. }
  583. this.refreshCurrentUserMeta()
  584. this.resolveWsIdentity()
  585. this.currentWsConfig = this.buildWsConnectOptions()
  586. return true
  587. }
  588. try {
  589. const result = await deviceApi.login(sn, cardNo)
  590. const loginData = result?.data || {}
  591. if (!loginData || loginData.msg) {
  592. await this.handleDeviceLoginFailure('登录失败,卡无登记')
  593. return false
  594. }
  595. const mergedUserInfo = {
  596. ...this.getStoredUserInfo(),
  597. devId: loginData.devId || sn,
  598. sbmc: loginData.sbmc || '',
  599. sessId: loginData.sessId || '',
  600. xm: loginData.xm || '',
  601. yhsbToken: loginData.yhsbToken || '',
  602. onlineToken: loginData.onlineToken || '',
  603. cardNo,
  604. syList: loginData.sylist || loginData.syList || [],
  605. yhid: loginData.yhid || '',
  606. yhm: loginData.yhm || ''
  607. }
  608. if (loginData.yszwj) mergedUserInfo.yszwj = loginData.yszwj
  609. if (loginData.zjzwj) mergedUserInfo.zjzwj = loginData.zjzwj
  610. uni.setStorageSync('userInfo', mergedUserInfo)
  611. if (mergedUserInfo.sessId) {
  612. uni.setStorageSync('JSESSIONID', mergedUserInfo.sessId)
  613. }
  614. this.refreshCurrentUserMeta()
  615. this.resolveWsIdentity()
  616. this.currentWsConfig = this.buildWsConnectOptions()
  617. return true
  618. } catch (error) {
  619. console.error('设备端登录失败:', error)
  620. await this.handleDeviceLoginFailure('网络错误或服务异常')
  621. return false
  622. }
  623. },
  624. async handleDeviceLoginFailure(content = '登录失败') {
  625. await this.showCustomModal({
  626. title: '登录失败',
  627. content,
  628. showCancel: false,
  629. confirmText: '确定'
  630. })
  631. await this.handleDeviceLogout()
  632. return false
  633. },
  634. async loadParentChatMembers() {
  635. try {
  636. const result = await grfwApi.btc_selChatMbr({})
  637. const data = result?.data || {}
  638. const list = this.normalizeChatMemberList(data.chatMbrList)
  639. this.parentChatMembers = list
  640. this.parentChatMemberNames = list.map((item) => item.xm || String(item.ryid || '未命名'))
  641. if (!list.length) {
  642. this.sessionContactId = ''
  643. this.currentChatName = '留言'
  644. this.resolveWsIdentity()
  645. return
  646. }
  647. let targetIndex = 0
  648. const current = String(this.sessionContactId || '')
  649. if (current) {
  650. const idx = list.findIndex((item) => String(item.ryid || '') === current)
  651. if (idx > -1) targetIndex = idx
  652. }
  653. this.selectParentMemberByIndex(targetIndex)
  654. } catch (error) {
  655. console.error('加载孩子列表失败:', error)
  656. uni.showToast({ title: '加载孩子列表失败', icon: 'none' })
  657. }
  658. },
  659. async loadDeviceChatMembers() {
  660. try {
  661. const result = await deviceApi.mp_telHomep_load()
  662. const data = result?.data || {}
  663. const list = this.normalizeChatMemberList(data.chatMbrList)
  664. this.parentChatMembers = list
  665. this.parentChatMemberNames = list.map((item) => item.xm || String(item.ryid || '未命名家长'))
  666. if (!list.length) {
  667. this.sessionContactId = ''
  668. this.currentChatName = '家长'
  669. this.resolveWsIdentity()
  670. return
  671. }
  672. let targetIndex = 0
  673. const current = String(this.sessionContactId || '')
  674. if (current) {
  675. const idx = list.findIndex((item) => String(item.ryid || '') === current)
  676. if (idx > -1) targetIndex = idx
  677. }
  678. this.selectParentMemberByIndex(targetIndex)
  679. } catch (error) {
  680. console.error('加载家长列表失败:', error)
  681. uni.showToast({ title: '加载家长列表失败', icon: 'none' })
  682. }
  683. },
  684. normalizeChatMemberList(rawList) {
  685. if (!Array.isArray(rawList)) return []
  686. const queue = [...rawList]
  687. const result = []
  688. while (queue.length) {
  689. const item = queue.shift()
  690. if (Array.isArray(item)) {
  691. queue.unshift(...item)
  692. continue
  693. }
  694. if (item && typeof item === 'object') {
  695. result.push(item)
  696. }
  697. }
  698. return result
  699. },
  700. async handleParentMemberChange(event) {
  701. if (this.historyLoading) {
  702. await this.finishHistoryLoading()
  703. }
  704. const index = Number(event?.detail?.value || 0)
  705. console.log('handleParentMemberChange -> index', index)
  706. this.selectParentMemberByIndex(index)
  707. await this.loadHistoryMessages({ reset: true })
  708. },
  709. selectParentMemberByIndex(index = 0) {
  710. const safeIndex = Math.max(0, Math.min(index, this.parentChatMembers.length - 1))
  711. this.parentChatMemberIndex = safeIndex
  712. const target = this.parentChatMembers[safeIndex] || {}
  713. this.sessionContactId = String(target.ryid || '')
  714. this.currentChatName = target.xm || '留言'
  715. this.refreshCurrentUserMeta()
  716. this.resolveWsIdentity()
  717. this.currentWsConfig = this.buildWsConnectOptions()
  718. },
  719. initParentInfo() {
  720. let info = uni.getStorageSync('userInfo')
  721. if (typeof info === 'string') {
  722. try {
  723. info = JSON.parse(info)
  724. } catch (error) {
  725. info = null
  726. }
  727. }
  728. if (!info || typeof info !== 'object') return
  729. this.parentInfo = {
  730. ...this.parentInfo,
  731. ...info
  732. }
  733. this.refreshCurrentUserMeta()
  734. },
  735. getStoredUserInfo() {
  736. let info = uni.getStorageSync('userInfo') || {}
  737. if (typeof info === 'string') {
  738. try {
  739. info = JSON.parse(info)
  740. } catch (error) {
  741. info = {}
  742. }
  743. }
  744. return info && typeof info === 'object' ? info : {}
  745. },
  746. getSelfDisplayMeta() {
  747. const userInfo = this.getStoredUserInfo()
  748. if (this.sessionRole === 'device') {
  749. return {
  750. department: '学生',
  751. name: userInfo.xm || this.currentUserMeta.name || '学生'
  752. }
  753. }
  754. return {
  755. department: '家长',
  756. name: this.parentInfo?.xm || userInfo.xm || this.currentUserMeta.name || '家长'
  757. }
  758. },
  759. getPeerDisplayMeta() {
  760. if (this.sessionRole === 'device') {
  761. return {
  762. department: '家长',
  763. name: this.currentChatName || '家长'
  764. }
  765. }
  766. return {
  767. department: '学生',
  768. name: this.currentChatName || '学生'
  769. }
  770. },
  771. resolveMessageMetaByDirection(direction = 'right') {
  772. return direction === 'right' ? this.getSelfDisplayMeta() : this.getPeerDisplayMeta()
  773. },
  774. getSelfAvatarMeta() {
  775. const userInfo = this.getStoredUserInfo()
  776. const peerType = String(this.peerAvatarTypeHint || '')
  777. const preferType = peerType === '1' ? '51' : (peerType === '51' ? '1' : '')
  778. if (preferType === '51') {
  779. return {
  780. url: this.toDisplayImageUrl(userInfo.zjzwj || userInfo.yszwj || ''),
  781. type: '51'
  782. }
  783. }
  784. if (preferType === '1') {
  785. return {
  786. url: this.toDisplayImageUrl(userInfo.yszwj || userInfo.zjzwj || ''),
  787. type: '1'
  788. }
  789. }
  790. if (this.sessionRole === 'device') {
  791. if (userInfo.zjzwj) {
  792. return {
  793. url: this.toDisplayImageUrl(userInfo.zjzwj),
  794. type: '51'
  795. }
  796. }
  797. if (userInfo.yszwj) {
  798. return {
  799. url: this.toDisplayImageUrl(userInfo.yszwj),
  800. type: '1'
  801. }
  802. }
  803. return {
  804. url: '/static/logo.png',
  805. type: ''
  806. }
  807. }
  808. if (userInfo.yszwj) {
  809. return {
  810. url: this.toDisplayImageUrl(userInfo.yszwj),
  811. type: '1'
  812. }
  813. }
  814. if (userInfo.zjzwj) {
  815. return {
  816. url: this.toDisplayImageUrl(userInfo.zjzwj),
  817. type: '51'
  818. }
  819. }
  820. return {
  821. url: '/static/logo.png',
  822. type: ''
  823. }
  824. },
  825. getAvatarSrc(message = {}) {
  826. if (!message || message.direction === 'right') {
  827. return this.getSelfAvatarMeta().url
  828. }
  829. return message.avatarUrl || '/static/logo.png'
  830. },
  831. getAvatarClass(message = {}) {
  832. const type = String(
  833. message && message.direction === 'right'
  834. ? this.getSelfAvatarMeta().type
  835. : (message.avatarType || '')
  836. )
  837. return {
  838. 'square-avatar': type === '51',
  839. 'doc-avatar': type === '51'
  840. }
  841. },
  842. refreshCurrentUserMeta() {
  843. const selfMeta = this.getSelfDisplayMeta()
  844. this.currentUserMeta = {
  845. ...this.currentUserMeta,
  846. department: selfMeta.department,
  847. name: selfMeta.name
  848. }
  849. },
  850. buildCallContactByCurrentMember() {
  851. const member = this.parentChatMembers[this.parentChatMemberIndex] || {}
  852. if (this.sessionRole === 'device') {
  853. return {
  854. username: member.xm || member.username || '家长',
  855. avatar: member.yszwj ? this.toDisplayImageUrl(member.yszwj) : '/static/logo.png',
  856. openid: member.wbid2 || member.wbid || ''
  857. }
  858. }
  859. return {
  860. username: member.xm || member.username || '学生',
  861. avatar: member.yszwj ? this.toDisplayImageUrl(member.yszwj) : '/static/logo.png',
  862. deviceSn: member.deviceSn || member.sn || member.devId || member.sbkh || '',
  863. pushToken: member.pushToken || member.voipToken || member.token || ''
  864. }
  865. },
  866. async startVoiceCall() {
  867. const contact = this.buildCallContactByCurrentMember()
  868. await this.startCall(contact, 'voice')
  869. },
  870. async startVideoCall() {
  871. const contact = this.buildCallContactByCurrentMember()
  872. await this.startCall(contact, 'video')
  873. },
  874. async startCall(contact, roomType) {
  875. if (!wmpfVoip) {
  876. uni.showToast({ title: '通话能力暂不可用', icon: 'none' })
  877. return
  878. }
  879. if (this.sessionRole === 'device') {
  880. const userInfo = this.getStoredUserInfo()
  881. const callerId = String(userInfo.devId || this.currentWsConfig?.ssDevId || '').trim()
  882. const listenerId = String(contact?.openid || '').trim()
  883. if (!callerId) {
  884. uni.showToast({ title: '缺少设备标识', icon: 'none' })
  885. return
  886. }
  887. if (!listenerId) {
  888. uni.showToast({ title: '请让家长先关注公众号,登陆小程序后再发起', icon: 'none' })
  889. return
  890. }
  891. this.isCalling = true
  892. if (this.inactivityTimer) {
  893. clearTimeout(this.inactivityTimer)
  894. this.inactivityTimer = null
  895. }
  896. uni.showLoading({ title: '呼叫中...', mask: true })
  897. uni.setStorageSync(CALL_END_STORAGE_KEY, {
  898. name: contact?.username || '家长',
  899. avatar: contact?.avatar || '/static/logo.png',
  900. duration: 0,
  901. time: new Date().toLocaleString(),
  902. type: roomType,
  903. status: '呼叫中'
  904. })
  905. this.setVoipEndPagePath()
  906. try {
  907. const res = await wmpfVoip.initByCaller({
  908. roomType,
  909. caller: {
  910. id: callerId,
  911. name: userInfo.xm || '学生'
  912. },
  913. listener: {
  914. id: listenerId,
  915. name: contact?.username || '家长'
  916. },
  917. businessType: 1,
  918. miniprogramState
  919. })
  920. if (res.isSuccess) {
  921. uni.hideLoading()
  922. uni.redirectTo({ url: wmpfVoip.CALL_PAGE_PATH })
  923. return
  924. }
  925. this.isCalling = false
  926. this.resetInactivityTimer()
  927. uni.hideLoading()
  928. uni.showToast({ title: '呼叫失败', icon: 'error' })
  929. return
  930. } catch (error) {
  931. this.isCalling = false
  932. this.resetInactivityTimer()
  933. uni.hideLoading()
  934. console.error('设备端通话异常:', error)
  935. uni.showToast({ title: '发起通话失败', icon: 'none' })
  936. return
  937. }
  938. }
  939. const callerId = this.parentInfo?.openid || this.parentInfo?.wbid || ''
  940. if (!callerId) {
  941. uni.showToast({ title: '缺少家长身份标识', icon: 'none' })
  942. return
  943. }
  944. if (!contact?.deviceSn) {
  945. uni.showToast({ title: '未找到可呼叫的设备编号', icon: 'none' })
  946. return
  947. }
  948. if (!contact?.pushToken) {
  949. uni.showToast({ title: '设备未上报通话凭证', icon: 'none' })
  950. return
  951. }
  952. uni.showLoading({ title: '呼叫中...', mask: true })
  953. this.currentCallContact = contact
  954. const callInfo = {
  955. name: contact.username || '家庭设备',
  956. avatar: contact.avatar || '/static/logo.png',
  957. duration: 0,
  958. time: new Date().toLocaleString(),
  959. type: roomType,
  960. status: '呼叫中'
  961. }
  962. uni.setStorageSync(CALL_END_STORAGE_KEY, callInfo)
  963. this.setVoipEndPagePath()
  964. try {
  965. const res = await wmpfVoip.initByCaller({
  966. roomType,
  967. caller: {
  968. id: callerId,
  969. name: this.parentInfo?.xm || '家长'
  970. },
  971. listener: {
  972. id: contact.deviceSn,
  973. name: contact.username
  974. },
  975. businessType: 2,
  976. voipToken: contact.pushToken,
  977. miniprogramState
  978. })
  979. if (res.isSuccess) {
  980. uni.hideLoading()
  981. uni.redirectTo({ url: wmpfVoip.CALL_PAGE_PATH })
  982. return
  983. }
  984. uni.hideLoading()
  985. console.error('呼叫失败', res)
  986. uni.showToast({ title: '呼叫失败', icon: 'error' })
  987. } catch (error) {
  988. uni.hideLoading()
  989. console.error('通话异常:', error)
  990. uni.showToast({ title: '发起通话失败', icon: 'none' })
  991. }
  992. },
  993. async handleDeviceLogout() {
  994. try {
  995. await deviceApi.ssExit()
  996. } catch (error) {
  997. console.warn('设备端退出服务调用失败:', error)
  998. }
  999. uni.removeStorageSync('userInfo')
  1000. uni.removeStorageSync('JSESSIONID')
  1001. uni.removeStorageSync('currcall')
  1002. uni.removeStorageSync('lastCallInfo')
  1003. if (this.inactivityTimer) {
  1004. clearTimeout(this.inactivityTimer)
  1005. this.inactivityTimer = null
  1006. }
  1007. uni.showToast({
  1008. title: '已退出',
  1009. icon: 'success',
  1010. duration: 1200
  1011. })
  1012. setTimeout(() => {
  1013. uni.reLaunch({ url: '/pages/device/notice' })
  1014. }, 1200)
  1015. },
  1016. resetInactivityTimer() {
  1017. if (this.sessionRole !== 'device') return
  1018. if (this.isCalling) return
  1019. if (this.inactivityTimer) {
  1020. clearTimeout(this.inactivityTimer)
  1021. }
  1022. this.inactivityTimer = setTimeout(() => {
  1023. this.handleDeviceLogout()
  1024. }, this.inactivityTimeout)
  1025. },
  1026. async checkServiceStatus(grfwxmm) {
  1027. if (this.sessionRole !== 'device') return
  1028. const code = Number(grfwxmm)
  1029. if (code !== 111 && code !== 115) {
  1030. console.warn('checkServiceStatus skip: invalid grfwxmm', grfwxmm)
  1031. return
  1032. }
  1033. try {
  1034. const result = await deviceApi.grfw_chkGrfw(code)
  1035. const data = result?.data || {}
  1036. if (data.ssCode === 0 && data.ssData) {
  1037. this.serviceStatus = data.ssData
  1038. }
  1039. } catch (error) {
  1040. console.error('检查服务状态失败:', error)
  1041. }
  1042. },
  1043. async recordCallAndRefresh(callOptions = {}) {
  1044. if (this.sessionRole !== 'device') return
  1045. try {
  1046. const duration = parseInt(callOptions.duration, 10) || 0
  1047. const callType = callOptions.callType || 'voice'
  1048. if (duration <= 0) return
  1049. const minutes = Math.ceil(duration / 60)
  1050. const grfwxmm = callType === 'video' ? 115 : 111
  1051. await deviceApi.grfw_endGrfw({
  1052. grfwxmm,
  1053. sc: minutes,
  1054. ll: 0,
  1055. ms: `通话${duration}秒`
  1056. })
  1057. await this.checkServiceStatus(grfwxmm)
  1058. } catch (error) {
  1059. console.error('记录通话失败:', error)
  1060. }
  1061. },
  1062. setVoipEndPagePath(forceUpdate = false) {
  1063. if (this._voipEndPathSet && !forceUpdate) return
  1064. if (!wmpfVoip) return
  1065. const callInfo = uni.getStorageSync(CALL_END_STORAGE_KEY) || {}
  1066. const query = [this.sessionRole === 'device' ? 'role=device' : 'role=parent']
  1067. if (this.sessionContactId) {
  1068. query.push(`contactId=${encodeURIComponent(this.sessionContactId)}`)
  1069. }
  1070. if (this.currentChatName) {
  1071. query.push(`contactName=${encodeURIComponent(this.currentChatName)}`)
  1072. }
  1073. if (this.sessionRole === 'device') {
  1074. const userInfo = this.getStoredUserInfo()
  1075. if (userInfo.devId) {
  1076. query.push(`sn=${encodeURIComponent(userInfo.devId)}`)
  1077. }
  1078. if (userInfo.cardNo) {
  1079. query.push(`cardNo=${encodeURIComponent(userInfo.cardNo)}`)
  1080. }
  1081. }
  1082. if (callInfo.name) {
  1083. query.push(`name=${encodeURIComponent(callInfo.name)}`)
  1084. query.push(`avatar=${encodeURIComponent(callInfo.avatar || '')}`)
  1085. query.push(`duration=${callInfo.duration || 0}`)
  1086. query.push(`type=${callInfo.type || 'voice'}`)
  1087. query.push(`status=${encodeURIComponent(callInfo.status || '通话已结束')}`)
  1088. }
  1089. wmpfVoip.setVoipEndPagePath({
  1090. url: CALL_END_PAGE_URL,
  1091. key: 'Call',
  1092. options: query.join('&'),
  1093. routeType: 'redirectTo'
  1094. })
  1095. this._voipEndPathSet = true
  1096. },
  1097. registerVoipEvent() {
  1098. if (this._voipEventRegistered || !wmpfVoip) return
  1099. wmpfVoip.onVoipEvent((event) => {
  1100. const eventName = event.eventName
  1101. console.log('[VoIP] event:', eventName, event.data || {})
  1102. if (eventName === 'startVoip') {
  1103. this.isCalling = true
  1104. if (this.inactivityTimer) {
  1105. clearTimeout(this.inactivityTimer)
  1106. this.inactivityTimer = null
  1107. }
  1108. }
  1109. const hangupEvent = ['hangUpVoip', 'endVoip']
  1110. const cancelEvent = ['cancelVoip']
  1111. const timeoutEvent = ['timeout']
  1112. const rejectEvent = ['rejectVoip']
  1113. const callInfo = uni.getStorageSync(CALL_END_STORAGE_KEY) || {}
  1114. if (hangupEvent.includes(eventName)) {
  1115. callInfo.duration = event.data?.keepTime || 0
  1116. callInfo.status = event.data?.keepTime > 0 ? '通话已结束' : '未接通'
  1117. if (callInfo.duration > 0) {
  1118. callInfo.needRecord = true
  1119. }
  1120. } else if (cancelEvent.includes(eventName)) {
  1121. callInfo.duration = 0
  1122. callInfo.status = '已取消'
  1123. } else if (timeoutEvent.includes(eventName)) {
  1124. callInfo.duration = 0
  1125. callInfo.status = '未接听'
  1126. } else if (rejectEvent.includes(eventName)) {
  1127. callInfo.duration = 0
  1128. callInfo.status = '已拒绝'
  1129. }
  1130. if ([...hangupEvent, ...cancelEvent, ...timeoutEvent, ...rejectEvent].includes(eventName)) {
  1131. this.isCalling = false
  1132. callInfo.endType = eventName
  1133. callInfo.endTime = Date.now()
  1134. uni.hideLoading()
  1135. uni.setStorageSync(CALL_END_STORAGE_KEY, callInfo)
  1136. this.setVoipEndPagePath(true)
  1137. if (this.sessionRole === 'device') {
  1138. let toastTitle = '通话已结束'
  1139. if (cancelEvent.includes(eventName)) {
  1140. toastTitle = '已取消呼叫'
  1141. } else if (timeoutEvent.includes(eventName)) {
  1142. toastTitle = '对方暂未接听'
  1143. } else if (rejectEvent.includes(eventName)) {
  1144. toastTitle = '对方已拒绝'
  1145. } else if (hangupEvent.includes(eventName)) {
  1146. const keepTime = Number(event?.data?.keepTime || 0)
  1147. toastTitle = keepTime > 0 ? '通话已结束' : '未接通已结束'
  1148. }
  1149. uni.showToast({ title: toastTitle, icon: 'none' })
  1150. }
  1151. if (this.sessionRole === 'device' && callInfo.needRecord && callInfo.duration > 0) {
  1152. this.recordCallAndRefresh({
  1153. duration: callInfo.duration,
  1154. callType: callInfo.type || 'voice'
  1155. })
  1156. delete callInfo.needRecord
  1157. uni.setStorageSync(CALL_END_STORAGE_KEY, callInfo)
  1158. }
  1159. this.resetInactivityTimer()
  1160. }
  1161. })
  1162. this._voipEventRegistered = true
  1163. },
  1164. formatPayloadTime(rawValue) {
  1165. if (!rawValue) return ''
  1166. const normalized = String(rawValue)
  1167. .replace(/\u202f/g, ' ')
  1168. .replace(/\u00a0/g, ' ')
  1169. .replace(/\s+/g, ' ')
  1170. .trim()
  1171. let parsed = new Date(normalized)
  1172. if (Number.isNaN(parsed.getTime())) {
  1173. const monthMap = {
  1174. Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5,
  1175. Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11
  1176. }
  1177. 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)
  1178. if (match) {
  1179. const mon = monthMap[match[1]]
  1180. let hh = Number(match[4])
  1181. const mm = Number(match[5])
  1182. const ss = Number(match[6])
  1183. const ap = String(match[7]).toUpperCase()
  1184. if (ap === 'PM' && hh < 12) hh += 12
  1185. if (ap === 'AM' && hh === 12) hh = 0
  1186. parsed = new Date(Number(match[3]), mon, Number(match[2]), hh, mm, ss)
  1187. }
  1188. }
  1189. if (Number.isNaN(parsed.getTime())) return ''
  1190. const hh = String(parsed.getHours()).padStart(2, '0')
  1191. const mm = String(parsed.getMinutes()).padStart(2, '0')
  1192. return `${hh}:${mm}`
  1193. },
  1194. waitHistoryDone(timeout = 12000) {
  1195. if (this.historyWaiter) {
  1196. return this.historyWaiter.promise
  1197. }
  1198. let resolve
  1199. const promise = new Promise((r) => {
  1200. resolve = r
  1201. })
  1202. this.historyWaiter = { promise, resolve }
  1203. if (this.historyWaiterTimer) {
  1204. clearTimeout(this.historyWaiterTimer)
  1205. }
  1206. this.historyWaiterTimer = setTimeout(() => {
  1207. this.finishHistoryLoading()
  1208. }, timeout)
  1209. return promise
  1210. },
  1211. async finishHistoryLoading() {
  1212. this.historyLoading = false
  1213. if (this.historyWaiterTimer) {
  1214. clearTimeout(this.historyWaiterTimer)
  1215. this.historyWaiterTimer = null
  1216. }
  1217. if (this.historyWaiter && typeof this.historyWaiter.resolve === 'function') {
  1218. this.historyWaiter.resolve(true)
  1219. }
  1220. this.historyWaiter = null
  1221. if (this.sessionRole === 'parent') {
  1222. await websocketService.disconnect()
  1223. }
  1224. },
  1225. async loadHistoryMessages({ reset = true } = {}) {
  1226. if (this.historyLoading) return
  1227. if (!this.wsIdentity.sendRyid || !this.wsIdentity.recRyid) {
  1228. console.warn('loadHistoryMessages skip: missing wsIdentity', this.wsIdentity)
  1229. return
  1230. }
  1231. console.log('loadHistoryMessages start', {
  1232. sendRyid: this.wsIdentity.sendRyid,
  1233. recRyid: this.wsIdentity.recRyid,
  1234. role: this.sessionRole
  1235. })
  1236. if (reset) {
  1237. this.messages = []
  1238. this.scrollIntoView = ''
  1239. this.peerAvatarTypeHint = ''
  1240. }
  1241. const config = this.currentWsConfig || this.buildWsConnectOptions()
  1242. this.historyLoading = true
  1243. try {
  1244. console.log('loadHistoryMessages ensureConnected start')
  1245. await websocketService.ensureConnected(config)
  1246. console.log('loadHistoryMessages ensureConnected done')
  1247. await websocketService.send({
  1248. cmd: 161,
  1249. sendRyid: this.wsIdentity.sendRyid,
  1250. recRyid: this.wsIdentity.recRyid
  1251. })
  1252. await this.waitHistoryDone()
  1253. } catch (error) {
  1254. this.historyLoading = false
  1255. console.error('拉取历史留言失败:', error)
  1256. if (this.sessionRole === 'parent') {
  1257. await websocketService.disconnect()
  1258. }
  1259. }
  1260. },
  1261. resolveWsIdentity() {
  1262. let userInfo = uni.getStorageSync('userInfo') || {}
  1263. if (typeof userInfo === 'string') {
  1264. try {
  1265. userInfo = JSON.parse(userInfo)
  1266. } catch (error) {
  1267. userInfo = {}
  1268. }
  1269. }
  1270. const sendRyid = String(userInfo.ryid || userInfo.yhid || userInfo.userId || '')
  1271. const recRyid = String(
  1272. this.sessionContactId ||
  1273. this.sessionStudentId ||
  1274. ''
  1275. )
  1276. const recRylbm = this.sessionRole === 'parent' ? REC_RYLBM_STUDENT : REC_RYLBM_PARENT
  1277. this.wsIdentity = {
  1278. sendRyid,
  1279. recRyid,
  1280. recRylbm
  1281. }
  1282. },
  1283. buildWsConnectOptions() {
  1284. let userInfo = uni.getStorageSync('userInfo') || {}
  1285. if (typeof userInfo === 'string') {
  1286. try {
  1287. userInfo = JSON.parse(userInfo)
  1288. } catch (error) {
  1289. userInfo = {}
  1290. }
  1291. }
  1292. const config = {
  1293. role: this.sessionRole
  1294. }
  1295. if (this.sessionRole === 'device') {
  1296. config.ssDevId = String(userInfo.devId || '')
  1297. config.heartbeat = true
  1298. config.autoReconnect = true
  1299. } else {
  1300. config.ssToken = String(userInfo.yhsbToken || '')
  1301. config.heartbeat = false
  1302. config.autoReconnect = false
  1303. }
  1304. return config
  1305. },
  1306. bindSocketListeners() {
  1307. if (!this.currentWsConfig) return
  1308. this.teardownSocketListeners()
  1309. const un1 = websocketService.on('open', () => {
  1310. this.socketConnected = true
  1311. })
  1312. const un2 = websocketService.on('close', () => {
  1313. this.socketConnected = false
  1314. })
  1315. const un3 = websocketService.on('error', () => {
  1316. this.socketConnected = false
  1317. })
  1318. const un4 = websocketService.on('cmd:101', (payload) => {
  1319. this.handleWsIncomingMessage(payload)
  1320. })
  1321. const un5 = websocketService.on('cmd:165', (payload) => {
  1322. this.handleWsHistoryMessage(payload)
  1323. })
  1324. const un6 = websocketService.on('cmd:11', () => {
  1325. this.handleWsHistoryDone()
  1326. })
  1327. const un7 = websocketService.on('cmd:151', (payload) => {
  1328. this.handleWsReceipt(payload)
  1329. })
  1330. const un8 = websocketService.on('cmd:51', () => {
  1331. uni.$emit('device-message-refresh')
  1332. })
  1333. this.socketUnsubscribeList = [un1, un2, un3, un4, un5, un6, un7, un8]
  1334. },
  1335. teardownSocketListeners() {
  1336. if (!this.socketUnsubscribeList.length) return
  1337. this.socketUnsubscribeList.forEach((off) => {
  1338. if (typeof off === 'function') off()
  1339. })
  1340. this.socketUnsubscribeList = []
  1341. },
  1342. async teardownSocket() {
  1343. this.teardownSocketListeners()
  1344. this.socketConnected = false
  1345. if (this.sessionRole !== 'device') {
  1346. await websocketService.disconnect()
  1347. }
  1348. },
  1349. async ensureSessionSocket() {
  1350. if (!this.currentWsConfig) {
  1351. this.initializeSessionFromOptions({ force: true })
  1352. }
  1353. let config = this.currentWsConfig || {}
  1354. if (config.role === 'parent') return
  1355. if (!config.ssDevId) {
  1356. this.resolveWsIdentity()
  1357. this.currentWsConfig = this.buildWsConnectOptions()
  1358. config = this.currentWsConfig || {}
  1359. }
  1360. if (!config.ssDevId) return
  1361. try {
  1362. await websocketService.ensureConnected(config)
  1363. } catch (error) {
  1364. console.error('设备端 WS 连接失败:', error)
  1365. }
  1366. },
  1367. toDisplayImageUrl(path) {
  1368. if (!path) return '/static/logo.png'
  1369. if (/^https?:\/\//.test(path)) return path
  1370. return getImageUrl(path)
  1371. },
  1372. toDisplayFileUrl(path, type = '') {
  1373. if (!path) return ''
  1374. if (/^https?:\/\//.test(path)) return path
  1375. const typePart = type ? `&type=${encodeURIComponent(type)}` : ''
  1376. return `${env.baseUrl}/service?ssServ=dlByHttp&wdConfirmationCaptchaService=0${typePart}&path=${encodeURIComponent(path)}`
  1377. },
  1378. formatVoiceBubbleText(message = {}) {
  1379. const duration = String(message.duration || '').trim()
  1380. return duration ? `${duration}"` : '语音'
  1381. },
  1382. getBaseNameFromPath(path) {
  1383. if (!path) return ''
  1384. const source = String(path).split('?')[0]
  1385. const seg = source.split('/')
  1386. return seg[seg.length - 1] || ''
  1387. },
  1388. normalizeIncomingCont(cont) {
  1389. if (!cont) return {}
  1390. if (typeof cont === 'string') {
  1391. try {
  1392. return JSON.parse(cont)
  1393. } catch (error) {
  1394. return { body: cont }
  1395. }
  1396. }
  1397. return cont
  1398. },
  1399. appendPayloadMessage(payload = {}, { fromHistory = false } = {}) {
  1400. if (!this.shouldHandleIncomingPayload(payload)) return
  1401. const cont = this.normalizeIncomingCont(payload.cont)
  1402. const typeCode = String(cont.type || '121')
  1403. const direction = String(payload.sendRyid || '') === String(this.wsIdentity.sendRyid || '')
  1404. ? 'right'
  1405. : 'left'
  1406. const messageMeta = this.resolveMessageMetaByDirection(direction)
  1407. const payloadAlias = String(payload.alias || '').trim()
  1408. const payloadLogo = payload.logo ? this.toDisplayImageUrl(payload.logo) : ''
  1409. const payloadLogoType = String(payload.logoType || '')
  1410. if (direction === 'left' && (payloadLogoType === '1' || payloadLogoType === '51')) {
  1411. this.peerAvatarTypeHint = payloadLogoType
  1412. }
  1413. const msgId = payload.xxid || payload.msgId || ''
  1414. if (fromHistory && msgId) {
  1415. const exists = this.messages.some((item) => String(item.msgId || '') === String(msgId))
  1416. if (exists) return
  1417. }
  1418. const payloadTime = this.formatPayloadTime(payload.sendTime || payload.sendTimeStr || payload.time)
  1419. const displayTime = payloadTime || this.formatPayloadTime(new Date())
  1420. const receiptStatus = (payload.readTime || payload.rdTime || payload.readTm) ? 'read' : 'unread'
  1421. if (typeCode === '121') {
  1422. const rawBody = String(cont.body || '').trim()
  1423. if (cont.fileName && (!rawBody || /^\[文件\]/.test(rawBody))) {
  1424. this.appendMessage({
  1425. type: 'file',
  1426. direction,
  1427. department: messageMeta.department,
  1428. name: messageMeta.name,
  1429. displayName: direction === 'left' ? (payloadAlias || messageMeta.name) : messageMeta.name,
  1430. avatarUrl: direction === 'left' ? payloadLogo : '',
  1431. avatarType: direction === 'left' ? payloadLogoType : '',
  1432. fileName: cont.baseName || this.getBaseNameFromPath(cont.fileName) || '未命名文件',
  1433. fileUrl: this.toDisplayFileUrl(cont.fileName || ''),
  1434. time: displayTime,
  1435. needReceipt: true,
  1436. receiptStatus,
  1437. msgId
  1438. })
  1439. return
  1440. }
  1441. this.appendMessage(createTextMessage({
  1442. direction,
  1443. department: messageMeta.department,
  1444. name: messageMeta.name,
  1445. displayName: direction === 'left' ? (payloadAlias || messageMeta.name) : messageMeta.name,
  1446. avatarUrl: direction === 'left' ? payloadLogo : '',
  1447. avatarType: direction === 'left' ? payloadLogoType : '',
  1448. content: cont.body || '',
  1449. needReceipt: true,
  1450. receiptStatus,
  1451. time: displayTime,
  1452. msgId
  1453. }))
  1454. return
  1455. }
  1456. if (typeCode === '122') {
  1457. this.appendMessage(createImageMessage({
  1458. direction,
  1459. department: messageMeta.department,
  1460. name: messageMeta.name,
  1461. displayName: direction === 'left' ? (payloadAlias || messageMeta.name) : messageMeta.name,
  1462. avatarUrl: direction === 'left' ? payloadLogo : '',
  1463. avatarType: direction === 'left' ? payloadLogoType : '',
  1464. imageUrl: this.toDisplayImageUrl(cont.fileName || cont.body || ''),
  1465. time: displayTime,
  1466. needReceipt: true,
  1467. receiptStatus,
  1468. msgId
  1469. }))
  1470. return
  1471. }
  1472. if (typeCode === '123') {
  1473. this.appendMessage({
  1474. type: 'voice',
  1475. direction,
  1476. department: messageMeta.department,
  1477. name: messageMeta.name,
  1478. displayName: direction === 'left' ? (payloadAlias || messageMeta.name) : messageMeta.name,
  1479. avatarUrl: direction === 'left' ? payloadLogo : '',
  1480. avatarType: direction === 'left' ? payloadLogoType : '',
  1481. duration: String(cont.duration || ''),
  1482. audioUrl: this.toDisplayFileUrl(cont.fileName || '', 'aud'),
  1483. voicePreview: '',
  1484. voiceText: cont.body || '',
  1485. needReceipt: true,
  1486. receiptStatus,
  1487. time: displayTime,
  1488. msgId
  1489. })
  1490. return
  1491. }
  1492. if (typeCode === '124') {
  1493. this.appendMessage({
  1494. type: 'video',
  1495. direction,
  1496. department: messageMeta.department,
  1497. name: messageMeta.name,
  1498. displayName: direction === 'left' ? (payloadAlias || messageMeta.name) : messageMeta.name,
  1499. avatarUrl: direction === 'left' ? payloadLogo : '',
  1500. avatarType: direction === 'left' ? payloadLogoType : '',
  1501. coverUrl: '/static/logo.png',
  1502. videoUrl: this.toDisplayFileUrl(cont.fileName || '', 'vid'),
  1503. duration: String(cont.duration || ''),
  1504. time: displayTime,
  1505. needReceipt: true,
  1506. receiptStatus,
  1507. msgId
  1508. })
  1509. }
  1510. },
  1511. handleWsIncomingMessage(payload = {}) {
  1512. this.appendPayloadMessage(payload, { fromHistory: false })
  1513. },
  1514. handleWsHistoryMessage(payload = {}) {
  1515. this.appendPayloadMessage(payload, { fromHistory: true })
  1516. },
  1517. handleWsHistoryDone() {
  1518. if (!this.historyLoading) return
  1519. this.finishHistoryLoading()
  1520. },
  1521. shouldHandleIncomingPayload(payload = {}) {
  1522. const send = String(payload.sendRyid || '')
  1523. const rec = String(payload.recRyid || '')
  1524. const me = String(this.wsIdentity.sendRyid || '')
  1525. const peer = String(this.wsIdentity.recRyid || '')
  1526. if (!me || !peer) return true
  1527. return (send === me && rec === peer) || (send === peer && rec === me)
  1528. },
  1529. handleWsReceipt(payload = {}) {
  1530. const msgId = String(payload.xxid || '')
  1531. if (!msgId) return
  1532. const idx = this.messages.findIndex((item) => String(item.msgId || '') === msgId)
  1533. if (idx < 0) return
  1534. this.$set(this.messages[idx], 'receiptStatus', 'read')
  1535. },
  1536. async confirmRead(message, index) {
  1537. if (!message || message.direction !== 'left') return
  1538. if (message.receiptStatus === 'read') return
  1539. const msgId = String(message.msgId || '')
  1540. if (!msgId) return
  1541. const payload = {
  1542. cmd: 151,
  1543. echoState: 2,
  1544. xxid: msgId,
  1545. sendRyid: this.wsIdentity.sendRyid,
  1546. recRyid: this.wsIdentity.recRyid,
  1547. recRylbm: this.wsIdentity.recRylbm
  1548. }
  1549. const sent = await this.sendWsPayload(payload)
  1550. if (!sent) return
  1551. this.$set(this.messages[index], 'receiptStatus', 'read')
  1552. },
  1553. async sendWsPayload(payload) {
  1554. this.resetInactivityTimer()
  1555. const config = this.currentWsConfig || this.buildWsConnectOptions()
  1556. if (!config) return false
  1557. if (config.role === 'parent' && !config.ssToken) {
  1558. uni.showToast({ title: '缺少 ssToken', icon: 'none' })
  1559. return false
  1560. }
  1561. if (config.role === 'device' && !config.ssDevId) {
  1562. uni.showToast({ title: '缺少 ssDevId', icon: 'none' })
  1563. return false
  1564. }
  1565. try {
  1566. await websocketService.ensureConnected(config)
  1567. await websocketService.send(payload)
  1568. if (config.role === 'parent') {
  1569. await websocketService.disconnect()
  1570. }
  1571. return true
  1572. } catch (error) {
  1573. console.error('发送留言失败:', error)
  1574. uni.showToast({ title: '发送失败', icon: 'none' })
  1575. return false
  1576. }
  1577. },
  1578. async sendOutgoingByType(message) {
  1579. if (!message) return false
  1580. const sendRyid = this.wsIdentity.sendRyid
  1581. const recRyid = this.wsIdentity.recRyid
  1582. if (!sendRyid || !recRyid) {
  1583. uni.showToast({ title: '缺少人员标识', icon: 'none' })
  1584. return false
  1585. }
  1586. let cont = null
  1587. if (message.type === 'text') {
  1588. cont = {
  1589. title: '',
  1590. type: '121',
  1591. body: message.content || ''
  1592. }
  1593. } else if (message.type === 'image') {
  1594. cont = {
  1595. title: '',
  1596. type: '122',
  1597. body: '',
  1598. fileName: message.serverFilePath || '',
  1599. baseName: message.baseName || 'image.jpg'
  1600. }
  1601. } else if (message.type === 'voice') {
  1602. cont = {
  1603. title: '音频',
  1604. type: '123',
  1605. body: '',
  1606. fileName: message.serverFilePath || '',
  1607. baseName: message.baseName || 'voice.mp3',
  1608. duration: message.duration || ''
  1609. }
  1610. } else if (message.type === 'video') {
  1611. cont = {
  1612. title: '视频',
  1613. type: '124',
  1614. body: '',
  1615. fileName: message.serverFilePath || '',
  1616. baseName: message.baseName || 'video.mp4',
  1617. duration: message.duration || ''
  1618. }
  1619. } else if (message.type === 'file') {
  1620. cont = {
  1621. title: '',
  1622. type: '121',
  1623. body: '',
  1624. fileName: message.serverFilePath || '',
  1625. baseName: message.baseName || message.fileName || 'file'
  1626. }
  1627. } else {
  1628. uni.showToast({ title: '当前消息类型不支持发送', icon: 'none' })
  1629. return false
  1630. }
  1631. const payload = {
  1632. cmd: 101,
  1633. cont,
  1634. sendRyid,
  1635. recRyid,
  1636. recRylbm: this.wsIdentity.recRylbm,
  1637. echoState: 1
  1638. }
  1639. return this.sendWsPayload(payload)
  1640. },
  1641. getVoiceBubbleStyle(message) {
  1642. if (!message || !message.isPlaying) return {}
  1643. const progress = Math.max(0, Math.min(100, Number(message.playProgress || 0)))
  1644. if (message.direction === 'right') {
  1645. return {
  1646. backgroundImage: `linear-gradient(to right, #7d89b1 ${progress}%, #eeeeee ${progress}%)`
  1647. }
  1648. }
  1649. return {
  1650. backgroundImage: `linear-gradient(to right, #eeeeee ${progress}%, #7d89b1 ${progress}%)`
  1651. }
  1652. },
  1653. resetVoiceItemState(index) {
  1654. if (index < 0 || !this.messages[index]) return
  1655. this.$set(this.messages[index], 'isPlaying', false)
  1656. this.$set(this.messages[index], 'playProgress', 0)
  1657. },
  1658. updateVoicePlayProgress() {
  1659. const index = this.playingVoiceIndex
  1660. if (index < 0 || !this.messages[index] || !this.audioPlayer) return
  1661. const duration = Number(this.audioPlayer.duration || 0)
  1662. const currentTime = Number(this.audioPlayer.currentTime || 0)
  1663. if (duration <= 0) return
  1664. const progress = Math.max(0, Math.min(100, (currentTime / duration) * 100))
  1665. this.$set(this.messages[index], 'playProgress', progress)
  1666. },
  1667. initAudioPlayer() {
  1668. if (this.audioPlayer || !uni.createInnerAudioContext) return
  1669. this.audioPlayer = uni.createInnerAudioContext()
  1670. this.audioPlayer.onTimeUpdate(() => {
  1671. this.updateVoicePlayProgress()
  1672. })
  1673. this.audioPlayer.onEnded(() => {
  1674. this.resetVoiceItemState(this.playingVoiceIndex)
  1675. this.playingVoiceIndex = -1
  1676. })
  1677. this.audioPlayer.onStop(() => {
  1678. this.resetVoiceItemState(this.playingVoiceIndex)
  1679. this.playingVoiceIndex = -1
  1680. })
  1681. this.audioPlayer.onError(() => {
  1682. this.resetVoiceItemState(this.playingVoiceIndex)
  1683. this.playingVoiceIndex = -1
  1684. uni.showToast({ title: '语音播放失败', icon: 'none' })
  1685. })
  1686. },
  1687. stopVoicePlayback() {
  1688. if (this.audioPlayer) {
  1689. this.audioPlayer.stop()
  1690. }
  1691. this.resetVoiceItemState(this.playingVoiceIndex)
  1692. this.playingVoiceIndex = -1
  1693. },
  1694. toggleVoicePlayback(message, index) {
  1695. if (!message || message.type !== 'voice') return
  1696. if (!message.audioUrl) {
  1697. uni.showToast({ title: '该语音暂无音频文件', icon: 'none' })
  1698. return
  1699. }
  1700. this.initAudioPlayer()
  1701. if (!this.audioPlayer) {
  1702. uni.showToast({ title: '当前环境不支持播放', icon: 'none' })
  1703. return
  1704. }
  1705. if (this.playingVoiceIndex === index) {
  1706. this.stopVoicePlayback()
  1707. return
  1708. }
  1709. if (this.playingVoiceIndex > -1) {
  1710. this.audioPlayer.stop()
  1711. this.resetVoiceItemState(this.playingVoiceIndex)
  1712. }
  1713. this.playingVoiceIndex = index
  1714. this.$set(this.messages[index], 'isPlaying', true)
  1715. this.$set(this.messages[index], 'playProgress', 0)
  1716. this.audioPlayer.src = message.audioUrl
  1717. this.audioPlayer.play()
  1718. },
  1719. initRecorder() {
  1720. if (!uni.getRecorderManager) return
  1721. this.recorderManager = uni.getRecorderManager()
  1722. this.recorderManager.onStop((res) => {
  1723. const canceled = this.isRecordCancel
  1724. const durationMs = Number(res && res.duration ? res.duration : 0)
  1725. const durationSeconds = Math.max(1, Math.round(durationMs / 1000) || this.recordSeconds)
  1726. this.clearRecordTimers()
  1727. this.isRecording = false
  1728. this.isRecordCancel = false
  1729. this.recordStartY = 0
  1730. if (canceled) {
  1731. uni.showToast({ title: '已取消发送', icon: 'none' })
  1732. return
  1733. }
  1734. if (!res || !res.tempFilePath || durationSeconds < 1) {
  1735. uni.showToast({ title: '录音时间太短', icon: 'none' })
  1736. return
  1737. }
  1738. const voiceMessage = buildVoiceOutgoingMessage({
  1739. durationSeconds,
  1740. audioUrl: res.tempFilePath,
  1741. voiceText: ''
  1742. }, this.currentUserMeta)
  1743. if (!voiceMessage) return
  1744. ;(async () => {
  1745. try {
  1746. const localPath = voiceMessage.audioUrl
  1747. const serverPath = await upload.uploadAudio(localPath)
  1748. if (!serverPath) return
  1749. voiceMessage.serverFilePath = serverPath
  1750. voiceMessage.baseName = this.getBaseNameFromPath(localPath) || 'voice.mp3'
  1751. voiceMessage.audioUrl = this.toDisplayFileUrl(serverPath, 'aud')
  1752. const sent = await this.sendOutgoingByType(voiceMessage)
  1753. if (sent) this.appendMessage(voiceMessage)
  1754. } catch (error) {
  1755. uni.showToast({ title: '语音发送失败', icon: 'none' })
  1756. }
  1757. })()
  1758. })
  1759. this.recorderManager.onError(() => {
  1760. this.clearRecordTimers()
  1761. this.isRecording = false
  1762. this.isRecordCancel = false
  1763. uni.showToast({ title: '录音失败', icon: 'none' })
  1764. })
  1765. },
  1766. clearRecordTimers() {
  1767. if (this.recordTickTimer) {
  1768. clearInterval(this.recordTickTimer)
  1769. this.recordTickTimer = null
  1770. }
  1771. if (this.recordGuardTimer) {
  1772. clearTimeout(this.recordGuardTimer)
  1773. this.recordGuardTimer = null
  1774. }
  1775. },
  1776. cleanupRecorder() {
  1777. this.clearRecordTimers()
  1778. if (this.isRecording && this.recorderManager) {
  1779. this.isRecordCancel = true
  1780. try {
  1781. this.recorderManager.stop()
  1782. } catch (error) {
  1783. // noop
  1784. }
  1785. }
  1786. this.isRecording = false
  1787. this.recordSeconds = 0
  1788. },
  1789. handlePressToTalkStart(event) {
  1790. if (this.isRecording) return
  1791. if (!this.recorderManager) {
  1792. this.initRecorder()
  1793. }
  1794. if (!this.recorderManager) {
  1795. uni.showToast({ title: '当前环境不支持录音', icon: 'none' })
  1796. return
  1797. }
  1798. const touch = event && event.touches && event.touches[0]
  1799. this.recordStartY = touch ? touch.clientY : 0
  1800. this.recordSeconds = 0
  1801. this.isRecordCancel = false
  1802. this.isRecording = true
  1803. this.clearRecordTimers()
  1804. this.recordTickTimer = setInterval(() => {
  1805. this.recordSeconds = Math.min(60, this.recordSeconds + 1)
  1806. }, 1000)
  1807. this.recordGuardTimer = setTimeout(() => {
  1808. if (!this.isRecording || !this.recorderManager) return
  1809. this.recorderManager.stop()
  1810. }, 60000)
  1811. try {
  1812. this.recorderManager.start({
  1813. duration: 60000,
  1814. sampleRate: 16000,
  1815. numberOfChannels: 1,
  1816. encodeBitRate: 96000,
  1817. format: 'mp3'
  1818. })
  1819. } catch (error) {
  1820. this.clearRecordTimers()
  1821. this.isRecording = false
  1822. this.isRecordCancel = false
  1823. uni.showToast({ title: '录音启动失败', icon: 'none' })
  1824. }
  1825. },
  1826. handlePressToTalkMove(event) {
  1827. if (!this.isRecording) return
  1828. const touch = event && event.touches && event.touches[0]
  1829. if (!touch) return
  1830. const deltaY = this.recordStartY - touch.clientY
  1831. this.isRecordCancel = deltaY > 80
  1832. },
  1833. handlePressToTalkEnd() {
  1834. if (!this.isRecording || !this.recorderManager) return
  1835. this.recorderManager.stop()
  1836. },
  1837. handlePressToTalkCancel() {
  1838. if (!this.isRecording || !this.recorderManager) return
  1839. this.isRecordCancel = true
  1840. this.recorderManager.stop()
  1841. },
  1842. back() {
  1843. uni.navigateBack({
  1844. delta: 1
  1845. })
  1846. },
  1847. // 切换带回执状态
  1848. toggleReceipt(index, value) {
  1849. this.messages[index].needReceipt = value === 1 || value === '1'
  1850. },
  1851. initReceiptToggleTimers() {
  1852. this.messages.forEach((message, index) => {
  1853. if (message.showReceiptToggle) {
  1854. this.registerReceiptToggleTimer(index)
  1855. }
  1856. })
  1857. },
  1858. registerReceiptToggleTimer(index) {
  1859. const message = this.messages[index]
  1860. if (!message || !message.showReceiptToggle) return
  1861. if (!message.receiptToggleCreatedAt) {
  1862. message.receiptToggleCreatedAt = Date.now()
  1863. }
  1864. const expireAt = message.receiptToggleCreatedAt + RECEIPT_TOGGLE_TTL_MS
  1865. const remain = expireAt - Date.now()
  1866. if (remain <= 0) {
  1867. message.showReceiptToggle = false
  1868. this.clearReceiptToggleTimer(index)
  1869. return
  1870. }
  1871. this.clearReceiptToggleTimer(index)
  1872. this.receiptToggleTimers[index] = setTimeout(() => {
  1873. const target = this.messages[index]
  1874. if (target) {
  1875. target.showReceiptToggle = false
  1876. }
  1877. this.clearReceiptToggleTimer(index)
  1878. }, remain)
  1879. },
  1880. clearReceiptToggleTimer(index) {
  1881. const timerId = this.receiptToggleTimers[index]
  1882. if (timerId) {
  1883. clearTimeout(timerId)
  1884. delete this.receiptToggleTimers[index]
  1885. }
  1886. },
  1887. clearReceiptToggleTimers() {
  1888. Object.keys(this.receiptToggleTimers).forEach((key) => {
  1889. this.clearReceiptToggleTimer(key)
  1890. })
  1891. },
  1892. // 滚动到底部
  1893. scrollToBottom() {
  1894. const lastIndex = this.messages.length - 1
  1895. if (lastIndex >= 0) {
  1896. this.scrollIntoView = 'msg-' + lastIndex
  1897. }
  1898. },
  1899. toggleInputMode() {
  1900. if (this.inputMode === 'voice') {
  1901. this.inputMode = 'text'
  1902. this.showMorePanel = false
  1903. this.showEmojiPanel = false
  1904. this.$nextTick(() => {
  1905. this.inputFocus = true
  1906. })
  1907. } else {
  1908. this.inputMode = 'voice'
  1909. this.showMorePanel = false
  1910. this.showEmojiPanel = false
  1911. this.inputFocus = false
  1912. }
  1913. },
  1914. toggleEmojiPanel() {
  1915. const nextStatus = !this.showEmojiPanel
  1916. this.showEmojiPanel = nextStatus
  1917. if (nextStatus) {
  1918. this.inputMode = 'text'
  1919. this.showMorePanel = false
  1920. this.inputFocus = false
  1921. }
  1922. },
  1923. toggleMorePanel() {
  1924. const nextStatus = !this.showMorePanel
  1925. this.showMorePanel = nextStatus
  1926. if (nextStatus) {
  1927. this.inputMode = 'text'
  1928. this.showEmojiPanel = false
  1929. this.inputFocus = false
  1930. }
  1931. },
  1932. handlePageClick() {
  1933. if (this.showMorePanel || this.showEmojiPanel) {
  1934. this.showMorePanel = false
  1935. this.showEmojiPanel = false
  1936. }
  1937. },
  1938. insertEmoji(emoji) {
  1939. this.inputMode = 'text'
  1940. this.draftText = `${this.draftText}${emoji}`
  1941. },
  1942. handleListTouchStart(event) {
  1943. this.resetInactivityTimer()
  1944. const touch = event.touches && event.touches[0]
  1945. if (!touch) return
  1946. this.listTouchMoved = false
  1947. this.touchStartX = touch.clientX
  1948. this.touchStartY = touch.clientY
  1949. },
  1950. handleListTouchMove(event) {
  1951. this.resetInactivityTimer()
  1952. const touch = event.touches && event.touches[0]
  1953. if (!touch) return
  1954. const deltaX = Math.abs(touch.clientX - this.touchStartX)
  1955. const deltaY = Math.abs(touch.clientY - this.touchStartY)
  1956. if (deltaX > 8 || deltaY > 8) {
  1957. this.listTouchMoved = true
  1958. }
  1959. },
  1960. handleListTouchEnd() {
  1961. this.resetInactivityTimer()
  1962. if (!this.listTouchMoved) {
  1963. this.handlePageClick()
  1964. }
  1965. },
  1966. appendMessages(newMessages = []) {
  1967. if (!newMessages.length) return
  1968. const startIndex = this.messages.length
  1969. this.messages = [...this.messages, ...newMessages]
  1970. newMessages.forEach((message, offset) => {
  1971. if (message.showReceiptToggle) {
  1972. this.registerReceiptToggleTimer(startIndex + offset)
  1973. }
  1974. })
  1975. this.$nextTick(() => {
  1976. this.scrollToBottom()
  1977. })
  1978. },
  1979. appendMessage(newMessage) {
  1980. if (!newMessage) return
  1981. this.appendMessages([newMessage])
  1982. },
  1983. async handleMoreAction(type) {
  1984. try {
  1985. if (type === 'file') {
  1986. const fileMessage = await pickFileOutgoingMessage(this.currentUserMeta)
  1987. if (!fileMessage) return
  1988. const localPath = fileMessage.fileUrl
  1989. const serverPath = await upload.uploadCommonFile(localPath)
  1990. if (!serverPath) return
  1991. fileMessage.serverFilePath = serverPath
  1992. fileMessage.baseName = fileMessage.fileName || this.getBaseNameFromPath(localPath) || 'file'
  1993. fileMessage.fileUrl = this.toDisplayFileUrl(serverPath)
  1994. const sent = await this.sendOutgoingByType(fileMessage)
  1995. if (sent) this.appendMessage(fileMessage)
  1996. return
  1997. }
  1998. if (type === 'image') {
  1999. const imageMessages = await pickImageOutgoingMessages(this.currentUserMeta)
  2000. for (const imageMessage of imageMessages) {
  2001. const localPath = imageMessage.imageUrl
  2002. const serverPath = await upload.uploadImage(localPath)
  2003. if (!serverPath) continue
  2004. imageMessage.serverFilePath = serverPath
  2005. imageMessage.baseName = localPath.split('/').pop() || 'image.jpg'
  2006. imageMessage.imageUrl = this.toDisplayImageUrl(serverPath)
  2007. const sent = await this.sendOutgoingByType(imageMessage)
  2008. if (sent) this.appendMessage(imageMessage)
  2009. }
  2010. return
  2011. }
  2012. if (type === 'camera') {
  2013. const imageMessage = await shootImageOutgoingMessage(this.currentUserMeta)
  2014. if (!imageMessage) return
  2015. const localPath = imageMessage.imageUrl
  2016. const serverPath = await upload.uploadImage(localPath)
  2017. if (!serverPath) return
  2018. imageMessage.serverFilePath = serverPath
  2019. imageMessage.baseName = localPath.split('/').pop() || 'image.jpg'
  2020. imageMessage.imageUrl = this.toDisplayImageUrl(serverPath)
  2021. const sent = await this.sendOutgoingByType(imageMessage)
  2022. if (sent) this.appendMessage(imageMessage)
  2023. return
  2024. }
  2025. if (type === 'video') {
  2026. const videoMessage = await pickVideoOutgoingMessage(this.currentUserMeta)
  2027. if (!videoMessage) return
  2028. const localPath = videoMessage.videoUrl
  2029. const serverPath = await upload.uploadVideo(localPath)
  2030. if (!serverPath) return
  2031. videoMessage.serverFilePath = serverPath
  2032. videoMessage.baseName = this.getBaseNameFromPath(localPath) || 'video.mp4'
  2033. videoMessage.videoUrl = this.toDisplayFileUrl(serverPath, 'vid')
  2034. const sent = await this.sendOutgoingByType(videoMessage)
  2035. if (sent) this.appendMessage(videoMessage)
  2036. return
  2037. }
  2038. uni.showToast({ title: `点击:${type}`, icon: 'none' })
  2039. } catch (error) {
  2040. uni.showToast({ title: '操作已取消', icon: 'none' })
  2041. }
  2042. },
  2043. openImagePreview(message) {
  2044. const imageUrls = collectImageUrls(this.messages)
  2045. if (!imageUrls.length) return
  2046. const currentUrl = message.imageUrl || imageUrls[0]
  2047. const currentIndex = imageUrls.findIndex((item) => item === currentUrl)
  2048. this.previewType = 'image'
  2049. this.previewImageList = imageUrls
  2050. this.previewImageIndex = currentIndex > -1 ? currentIndex : 0
  2051. this.showMediaPreview = true
  2052. },
  2053. openVideoPreview(message, index) {
  2054. this.previewType = 'video'
  2055. this.previewSource = message.videoUrl || message.coverUrl || '/static/logo.png'
  2056. this.activeVideoIndex = typeof index === 'number' ? index : -1
  2057. this.showMediaPreview = true
  2058. },
  2059. openFilePreview(message) {
  2060. this.activeFileName = message.fileName || '未命名文件'
  2061. this.activeFileUrl = message.fileUrl || ''
  2062. this.showFileDialog = true
  2063. },
  2064. downloadFileFromDialog() {
  2065. if (!this.activeFileUrl) {
  2066. uni.showToast({
  2067. title: '文件地址不存在',
  2068. icon: 'none'
  2069. })
  2070. return
  2071. }
  2072. const lowerUrl = (this.activeFileUrl || '').toLowerCase()
  2073. const lowerName = (this.activeFileName || '').toLowerCase()
  2074. const target = lowerName || lowerUrl
  2075. const isDocType = /(\.pdf|\.doc|\.docx|\.xls|\.xlsx|\.ppt|\.pptx|\.txt)$/.test(target)
  2076. uni.downloadFile({
  2077. url: this.activeFileUrl,
  2078. success: (res) => {
  2079. if (res.statusCode === 200) {
  2080. this.closeFileDialog()
  2081. if (isDocType) {
  2082. uni.openDocument({
  2083. filePath: res.tempFilePath,
  2084. showMenu: true,
  2085. fail: () => {
  2086. uni.showToast({
  2087. title: '文件打开失败',
  2088. icon: 'none'
  2089. })
  2090. }
  2091. })
  2092. } else {
  2093. uni.showToast({
  2094. title: '下载成功,当前类型不支持在线打开',
  2095. icon: 'none'
  2096. })
  2097. }
  2098. } else {
  2099. uni.showToast({
  2100. title: '下载失败',
  2101. icon: 'none'
  2102. })
  2103. }
  2104. },
  2105. fail: () => {
  2106. uni.showToast({
  2107. title: '下载失败',
  2108. icon: 'none'
  2109. })
  2110. }
  2111. })
  2112. },
  2113. closeFileDialog() {
  2114. this.showFileDialog = false
  2115. this.activeFileName = ''
  2116. this.activeFileUrl = ''
  2117. },
  2118. closeMediaPreview() {
  2119. this.showMediaPreview = false
  2120. this.previewType = ''
  2121. this.previewSource = ''
  2122. this.activeVideoIndex = -1
  2123. this.previewImageList = []
  2124. this.previewImageIndex = 0
  2125. },
  2126. handlePreviewVideoLoaded(event) {
  2127. const seconds = event?.detail?.duration
  2128. if (!seconds || this.activeVideoIndex < 0 || !this.messages[this.activeVideoIndex]) return
  2129. const mins = Math.floor(seconds / 60)
  2130. const secs = Math.floor(seconds % 60)
  2131. const durationText = `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
  2132. this.messages[this.activeVideoIndex].duration = durationText
  2133. },
  2134. handlePreviewContentClick() {
  2135. if (this.previewType === 'image') {
  2136. this.closeMediaPreview()
  2137. }
  2138. },
  2139. handleImageSwiperChange(event) {
  2140. this.previewImageIndex = event.detail && event.detail.current ? event.detail.current : 0
  2141. },
  2142. handlePreviewVideoError() {
  2143. uni.showToast({
  2144. title: '视频加载失败,请使用H.264编码MP4',
  2145. icon: 'none'
  2146. })
  2147. },
  2148. handleTextLineChange(event) {
  2149. this.textLineCount = event.detail && event.detail.lineCount ? event.detail.lineCount : 1
  2150. },
  2151. async sendTextMessage() {
  2152. const message = buildTextOutgoingMessage(this.draftText, this.currentUserMeta)
  2153. if (!message) return
  2154. message.showReceiptToggle = false
  2155. message.needReceipt = true
  2156. const sent = await this.sendOutgoingByType(message)
  2157. if (!sent) return
  2158. this.appendMessage(message)
  2159. this.draftText = ''
  2160. this.textLineCount = 1
  2161. },
  2162. addImageFromUrl(url) {
  2163. if (!url) return
  2164. this.appendMessage(createImageMessage({
  2165. ...this.currentUserMeta,
  2166. imageUrl: url
  2167. }))
  2168. }
  2169. }
  2170. }
  2171. </script>
  2172. <style scoped lang="less">
  2173. .message-page {
  2174. height: 100vh;
  2175. background: #f5f5f5;
  2176. display: flex;
  2177. flex-direction: column;
  2178. overflow: hidden;
  2179. .chat-nav {
  2180. height: 110rpx;
  2181. background: #f2f3f4;
  2182. padding: 0 24rpx;
  2183. display: flex;
  2184. align-items: center;
  2185. justify-content: space-between;
  2186. flex-shrink: 0;
  2187. }
  2188. .chat-title-wrap {
  2189. flex: 1;
  2190. display: flex;
  2191. justify-content: flex-start;
  2192. align-items: center;
  2193. min-width: 0;
  2194. }
  2195. .chat-title-picker {
  2196. max-width: 460rpx;
  2197. display: flex;
  2198. align-items: center;
  2199. gap: 8rpx;
  2200. }
  2201. .chat-title-picker.single {
  2202. gap: 0;
  2203. }
  2204. .chat-name-arrow {
  2205. font-size: 20rpx;
  2206. color: #000000;
  2207. line-height: 1;
  2208. }
  2209. .chat-title {
  2210. font-size: 30rpx;
  2211. font-weight: 400;
  2212. color: #000000;
  2213. max-width: 420rpx;
  2214. white-space: nowrap;
  2215. overflow: hidden;
  2216. text-overflow: ellipsis;
  2217. }
  2218. .chat-nav-right {
  2219. display: flex;
  2220. align-items: center;
  2221. gap: 16rpx;
  2222. }
  2223. .chat-call-btn {
  2224. width: 78rpx;
  2225. height: 78rpx;
  2226. border-radius: 12rpx;
  2227. background: #ffffff;
  2228. border: 1rpx solid #e5e5e5;
  2229. display: flex;
  2230. align-items: center;
  2231. justify-content: center;
  2232. }
  2233. .chat-logout-icon {
  2234. width: 36rpx;
  2235. height: 36rpx;
  2236. display: block;
  2237. }
  2238. .message-list {
  2239. flex: 1;
  2240. padding: 20rpx;
  2241. background: #fff;
  2242. overflow-y: auto;
  2243. box-sizing: border-box;
  2244. }
  2245. .message-item {
  2246. display: flex;
  2247. align-items: flex-start;
  2248. margin: 42rpx 0;
  2249. gap: 20rpx;
  2250. }
  2251. /* 头像+时间区域 */
  2252. .avatar-section {
  2253. display: flex;
  2254. flex-direction: column;
  2255. align-items: center;
  2256. }
  2257. .avatar {
  2258. width: 92rpx;
  2259. height: 92rpx;
  2260. border-radius: 50%;
  2261. flex-shrink: 0;
  2262. }
  2263. .avatar.square-avatar {
  2264. border-radius: 8rpx;
  2265. }
  2266. .avatar.doc-avatar {
  2267. object-position: center 5px;
  2268. }
  2269. .msg-time {
  2270. font-size: 32rpx;
  2271. color: #666;
  2272. white-space: nowrap;
  2273. }
  2274. /* 内容区域 */
  2275. .content-section {
  2276. flex: 1;
  2277. display: flex;
  2278. flex-direction: column;
  2279. gap: 10rpx;
  2280. }
  2281. .user-info {
  2282. font-size: 28rpx;
  2283. color: #666;
  2284. }
  2285. .user-info-role {
  2286. display: none;
  2287. }
  2288. .message-content {
  2289. display: flex;
  2290. align-items: flex-end;
  2291. gap: 10rpx;
  2292. }
  2293. /* 文字消息容器 */
  2294. .text-message-wrapper {
  2295. display: flex;
  2296. align-items: flex-end;
  2297. gap: 10rpx;
  2298. }
  2299. /* 带回执按钮容器 */
  2300. .receipt-toggle-wrapper {
  2301. display: flex;
  2302. align-items: center;
  2303. }
  2304. /* 消息气泡通用样式 */
  2305. .text-message,
  2306. .call-message,
  2307. .voice-message,
  2308. .file-message {
  2309. background: #7d89b1;
  2310. padding: 16rpx 20rpx;
  2311. border-radius: 8rpx;
  2312. color: #fff;
  2313. font-size: 32rpx;
  2314. }
  2315. .image-message,
  2316. .video-message {
  2317. border-radius: 8rpx;
  2318. overflow: hidden;
  2319. border: 1rpx solid #dcdcdc;
  2320. background: #ffffff;
  2321. }
  2322. /* 文字消息 */
  2323. .text-message {
  2324. max-width: 400rpx;
  2325. word-wrap: break-word;
  2326. }
  2327. /* 通话记录 */
  2328. .call-message {
  2329. display: flex;
  2330. align-items: center;
  2331. gap: 10rpx;
  2332. }
  2333. .call-message image {
  2334. width: 40rpx;
  2335. height: 40rpx;
  2336. }
  2337. /* 语音消息 */
  2338. .voice-message {
  2339. position: relative;
  2340. display: flex;
  2341. align-items: center;
  2342. gap: 10rpx;
  2343. min-width: 100rpx;
  2344. transition: background-image 0.12s linear;
  2345. }
  2346. .voice-message image {
  2347. width: 40rpx;
  2348. height: 40rpx;
  2349. }
  2350. .file-message {
  2351. display: flex;
  2352. align-items: center;
  2353. gap: 12rpx;
  2354. max-width: 420rpx;
  2355. }
  2356. .file-name {
  2357. flex: 1;
  2358. min-width: 0;
  2359. font-size: 32rpx;
  2360. white-space: nowrap;
  2361. overflow: hidden;
  2362. text-overflow: ellipsis;
  2363. }
  2364. .image-preview {
  2365. width: 280rpx;
  2366. height: 220rpx;
  2367. display: block;
  2368. }
  2369. .video-message {
  2370. position: relative;
  2371. width: 280rpx;
  2372. height: 220rpx;
  2373. }
  2374. .video-cover {
  2375. width: 100%;
  2376. height: 100%;
  2377. display: block;
  2378. }
  2379. .video-play {
  2380. position: absolute;
  2381. left: 50%;
  2382. top: 50%;
  2383. transform: translate(-50%, -50%);
  2384. width: 64rpx;
  2385. height: 64rpx;
  2386. border-radius: 50%;
  2387. background: rgba(0, 0, 0, 0.45);
  2388. display: flex;
  2389. align-items: center;
  2390. justify-content: center;
  2391. }
  2392. .recording-dot {
  2393. width: 16rpx;
  2394. height: 16rpx;
  2395. background: #eb6100;
  2396. border-radius: 50%;
  2397. position: absolute;
  2398. right: 10rpx;
  2399. top: 10rpx;
  2400. }
  2401. /* 语音转文字区域 */
  2402. .voice-text-section {
  2403. display: flex;
  2404. align-items: flex-end;
  2405. gap: 20rpx;
  2406. }
  2407. .voice-text-content {
  2408. flex: 1;
  2409. background: #7d89b1;
  2410. padding: 20rpx;
  2411. border-radius: 8rpx;
  2412. font-size: 32rpx;
  2413. color: #fff;
  2414. line-height: 1.5;
  2415. }
  2416. /* 回执按钮通用样式 */
  2417. .inline-receipt-button,
  2418. .receipt-button {
  2419. background: #565d6d;
  2420. color: #fff;
  2421. padding: 10rpx 20rpx;
  2422. border-radius: 8rpx;
  2423. font-size: 28rpx;
  2424. white-space: nowrap;
  2425. cursor: pointer;
  2426. }
  2427. /* 右侧消息(发送) */
  2428. .right {
  2429. flex-direction: row-reverse;
  2430. }
  2431. .right .avatar {
  2432. border-radius: 50%;
  2433. }
  2434. .right .avatar.square-avatar {
  2435. border-radius: 8rpx;
  2436. }
  2437. .right .content-section {
  2438. align-items: flex-end;
  2439. }
  2440. .right .user-info {
  2441. text-align: right;
  2442. }
  2443. .user-info-placeholder {
  2444. color: transparent;
  2445. }
  2446. .user-info-placeholder .user-info-role {
  2447. display: inline;
  2448. }
  2449. /* 右侧消息气泡样式 */
  2450. .right .text-message,
  2451. .right .call-message,
  2452. .right .voice-message,
  2453. .right .file-message,
  2454. .right .voice-text-content {
  2455. background: #eeeeee;
  2456. color: #333333;
  2457. border: 1rpx solid #dcdcdc;
  2458. }
  2459. .footer {
  2460. padding: 16rpx 24rpx;
  2461. background: #eeeeee;
  2462. display: flex;
  2463. align-items: flex-end;
  2464. gap: 16rpx;
  2465. border-top: 4rpx solid transparent;
  2466. box-sizing: border-box;
  2467. }
  2468. .footer.active {
  2469. border-top-color: #dcdcdc;
  2470. }
  2471. .tool-btn {
  2472. width: 78rpx;
  2473. height: 78rpx;
  2474. border-radius: 4rpx;
  2475. background: #ffffff;
  2476. border: 1rpx solid #e5e7eb;
  2477. display: flex;
  2478. align-items: center;
  2479. justify-content: center;
  2480. flex-shrink: 0;
  2481. align-self: flex-end;
  2482. }
  2483. .center-area {
  2484. flex: 1;
  2485. min-width: 0;
  2486. display: flex;
  2487. align-items: flex-end;
  2488. }
  2489. .press-to-talk {
  2490. width: 100%;
  2491. height: 78rpx;
  2492. border-radius: 4rpx;
  2493. background: #ffffff;
  2494. border: 1rpx solid #e5e7eb;
  2495. display: flex;
  2496. align-items: center;
  2497. justify-content: center;
  2498. gap: 12rpx;
  2499. }
  2500. .press-text {
  2501. color: #6b7280;
  2502. font-size: 30rpx;
  2503. line-height: 1;
  2504. }
  2505. .text-input {
  2506. width: 100%;
  2507. min-height: 82rpx;
  2508. max-height: 234rpx;
  2509. border-radius: 4rpx;
  2510. background: #ffffff;
  2511. border: 1rpx solid #e5e7eb;
  2512. padding: 18rpx 20rpx;
  2513. font-size: 32rpx;
  2514. line-height: 39rpx;
  2515. color: #111827;
  2516. overflow-y: auto;
  2517. box-sizing: border-box;
  2518. }
  2519. .text-input.capped {
  2520. height: 234rpx;
  2521. }
  2522. .bottom-panel {
  2523. background: #eeeeee;
  2524. border-top: 1rpx solid transparent;
  2525. max-height: 0;
  2526. opacity: 0;
  2527. overflow: hidden;
  2528. transform: translateY(24rpx);
  2529. transition: max-height 0.2s ease, opacity 0.2s ease, transform 0.2s ease;
  2530. }
  2531. .bottom-panel.open {
  2532. border-top-color: #dcdcdc;
  2533. max-height: 380rpx;
  2534. opacity: 1;
  2535. transform: translateY(0);
  2536. }
  2537. .more-panel {
  2538. background: #eeeeee;
  2539. padding: 30rpx 24rpx;
  2540. box-sizing: border-box;
  2541. }
  2542. .emoji-panel {
  2543. background: #eeeeee;
  2544. padding: 20rpx 24rpx;
  2545. max-height: 320rpx;
  2546. overflow-y: auto;
  2547. }
  2548. .emoji-grid {
  2549. display: grid;
  2550. grid-template-columns: repeat(8, 1fr);
  2551. gap: 12rpx;
  2552. }
  2553. .emoji-item {
  2554. height: 52rpx;
  2555. line-height: 52rpx;
  2556. text-align: center;
  2557. font-size: 36rpx;
  2558. border-radius: 4rpx;
  2559. }
  2560. .more-grid {
  2561. display: flex;
  2562. align-items: flex-start;
  2563. justify-content: space-between;
  2564. }
  2565. .more-item {
  2566. width: 25%;
  2567. display: flex;
  2568. flex-direction: column;
  2569. align-items: center;
  2570. gap: 16rpx;
  2571. }
  2572. .more-icon {
  2573. width: 96rpx;
  2574. height: 96rpx;
  2575. border-radius: 4rpx;
  2576. background: #ffffff;
  2577. border: 1rpx solid #e5e7eb;
  2578. display: flex;
  2579. align-items: center;
  2580. justify-content: center;
  2581. }
  2582. .more-text {
  2583. font-size: 28rpx;
  2584. color: #6b7280;
  2585. white-space: nowrap;
  2586. }
  2587. .media-preview-mask {
  2588. position: fixed;
  2589. left: 0;
  2590. top: 0;
  2591. width: 100vw;
  2592. height: 100vh;
  2593. background: rgba(0, 0, 0, 0.78);
  2594. display: flex;
  2595. align-items: center;
  2596. justify-content: center;
  2597. z-index: 9999;
  2598. }
  2599. .media-preview-content {
  2600. display: flex;
  2601. align-items: center;
  2602. justify-content: center;
  2603. padding: 0;
  2604. box-sizing: border-box;
  2605. }
  2606. .media-preview-content.video-mode {
  2607. width: 100vw;
  2608. height: 100vh;
  2609. background: transparent;
  2610. border-radius: 0;
  2611. }
  2612. .media-preview-content.image-mode {
  2613. width: 100vw;
  2614. height: 100vh;
  2615. background: transparent;
  2616. border-radius: 0;
  2617. }
  2618. .media-preview-video {
  2619. width: 100vw;
  2620. height: 100vh;
  2621. object-fit: cover;
  2622. }
  2623. .media-preview-exit {
  2624. position: fixed;
  2625. top: 90rpx;
  2626. right: 30rpx;
  2627. height: 56rpx;
  2628. line-height: 56rpx;
  2629. padding: 0 18rpx;
  2630. border-radius: 28rpx;
  2631. font-size: 26rpx;
  2632. color: #fff;
  2633. background: rgba(0, 0, 0, 0.45);
  2634. z-index: 10001;
  2635. }
  2636. .media-preview-swiper {
  2637. width: 100vw;
  2638. height: 100vh;
  2639. }
  2640. .media-preview-swiper-item {
  2641. display: flex;
  2642. align-items: center;
  2643. justify-content: center;
  2644. }
  2645. .media-preview-image.fit-x {
  2646. width: 100vw;
  2647. height: auto;
  2648. }
  2649. .file-dialog-mask {
  2650. position: fixed;
  2651. left: 0;
  2652. top: 0;
  2653. width: 100vw;
  2654. height: 100vh;
  2655. background: rgba(255, 255, 255, 0.96);
  2656. display: flex;
  2657. align-items: center;
  2658. justify-content: center;
  2659. z-index: 10000;
  2660. }
  2661. .file-dialog {
  2662. width: 100vw;
  2663. height: 100vh;
  2664. background: transparent;
  2665. border-radius: 0;
  2666. padding: 0 60rpx;
  2667. box-sizing: border-box;
  2668. display: flex;
  2669. flex-direction: column;
  2670. align-items: center;
  2671. justify-content: center;
  2672. }
  2673. .file-dialog-header {
  2674. display: flex;
  2675. flex-direction: column;
  2676. align-items: center;
  2677. gap: 18rpx;
  2678. }
  2679. .file-dialog-name {
  2680. max-width: 620rpx;
  2681. font-size: 30rpx;
  2682. color: #333;
  2683. white-space: nowrap;
  2684. overflow: hidden;
  2685. text-overflow: ellipsis;
  2686. text-align: center;
  2687. }
  2688. .file-dialog-actions {
  2689. display: flex;
  2690. justify-content: center;
  2691. gap: 24rpx;
  2692. padding-top: 40rpx;
  2693. }
  2694. .file-dialog-btn {
  2695. min-width: 120rpx;
  2696. height: 64rpx;
  2697. line-height: 64rpx;
  2698. text-align: center;
  2699. background: #575d6d;
  2700. color: #fff;
  2701. font-size: 28rpx;
  2702. border-radius: 8rpx;
  2703. }
  2704. .file-dialog-btn.ghost {
  2705. background: #f1f2f4;
  2706. color: #666;
  2707. }
  2708. .record-mask {
  2709. position: fixed;
  2710. left: 0;
  2711. top: 0;
  2712. width: 100vw;
  2713. height: 100vh;
  2714. background: rgba(0, 0, 0, 0.2);
  2715. display: flex;
  2716. align-items: center;
  2717. justify-content: center;
  2718. z-index: 12000;
  2719. pointer-events: none;
  2720. }
  2721. .record-panel {
  2722. width: 360rpx;
  2723. padding: 30rpx 24rpx;
  2724. border-radius: 16rpx;
  2725. background: rgba(0, 0, 0, 0.72);
  2726. display: flex;
  2727. flex-direction: column;
  2728. align-items: center;
  2729. gap: 14rpx;
  2730. }
  2731. .record-title {
  2732. font-size: 32rpx;
  2733. color: #fff;
  2734. }
  2735. .record-time {
  2736. font-size: 42rpx;
  2737. font-weight: 600;
  2738. color: #fff;
  2739. }
  2740. .record-tip {
  2741. font-size: 26rpx;
  2742. color: rgba(255, 255, 255, 0.9);
  2743. }
  2744. .record-tip.danger {
  2745. color: #ffb2b2;
  2746. }
  2747. }
  2748. </style>