message.vue 62 KB

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