message.vue 79 KB

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