message.vue 93 KB

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