chat.vue 92 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193
  1. <template>
  2. <view class="chat-page">
  3. <!-- 更多选项弹窗 -->
  4. <view v-if="showMoreOptionsModal" class="modal-mask" @click="closeMoreOptions">
  5. <view class="modal-content" @click.stop>
  6. <view class="modal-header">
  7. <text class="modal-title">{{ targetUserName }}</text>
  8. <text class="modal-subtitle">好友操作</text>
  9. </view>
  10. <view class="modal-body">
  11. <!-- <view class="modal-item" @click="goToUserProfile">
  12. <text class="item-icon">👤</text>
  13. <text class="item-text">查看资料</text>
  14. </view> -->
  15. <view class="modal-item" @click="blockFriend" :class="{ danger: true }">
  16. <text class="item-icon">🚫</text>
  17. <text class="item-text">拉黑好友</text>
  18. </view>
  19. <!-- <view class="modal-item" @click="reportUser" :class="{ danger: true }">
  20. <text class="item-icon">🚨</text>
  21. <text class="item-text">举报用户</text>
  22. </view> -->
  23. </view>
  24. <view class="modal-footer">
  25. <button class="modal-close-btn" @click="closeMoreOptions">取消</button>
  26. </view>
  27. </view>
  28. </view>
  29. <!-- 拉黑确认弹窗 -->
  30. <view v-if="showBlockConfirmModal" class="modal-mask" @click="closeBlockConfirm">
  31. <view class="confirm-modal" @click.stop>
  32. <view class="confirm-title">确认拉黑</view>
  33. <view class="confirm-content">
  34. <text>拉黑后将无法接收该用户的消息,是否确定?</text>
  35. </view>
  36. <view class="confirm-buttons">
  37. <button class="confirm-btn cancel" @click="closeBlockConfirm">取消</button>
  38. <button class="confirm-btn confirm" @click="confirmBlockFriend">确认</button>
  39. </view>
  40. </view>
  41. </view>
  42. <!-- 顶部导航 -->
  43. <view class="chat-header" :style="{paddingTop: statusBarHeight + 'px', height: navBarHeight + 'px'}">
  44. <view class="header-left" @click="goBack">
  45. <text class="icon-back">←</text>
  46. </view>
  47. <view class="header-center">
  48. <view class="title-wrapper" @click="showMoreOptions">
  49. <text class="chat-title">{{ targetUserName }}</text>
  50. <text class="title-arrow">▾</text>
  51. </view>
  52. <text class="online-status" :class="{ online: isTargetOnline }">
  53. {{ isTargetOnline ? '在线' : '离线' }}
  54. </text>
  55. </view>
  56. <view class="header-right"></view>
  57. </view>
  58. <!-- 消息列表 -->
  59. <scroll-view
  60. class="message-list"
  61. scroll-y
  62. :scroll-into-view="scrollToView"
  63. @scrolltoupper="loadMoreMessages">
  64. <!-- 加载更多提示 -->
  65. <view v-if="loading" class="loading-tip">加载中...</view>
  66. <view v-else-if="noMore" class="loading-tip">没有更多消息了</view>
  67. <!-- 时间分组和消息项 -->
  68. <view v-for="(msg, index) in messages" :key="msg.messageId">
  69. <!-- 时间分隔线 (每5分钟显示一次) -->
  70. <view v-if="shouldShowTime(msg, index)" class="time-divider">
  71. <text class="time-text">{{ formatMessageTime(msg.sendTime) }}</text>
  72. </view>
  73. <!-- 消息项 -->
  74. <view
  75. :id="'msg-' + index"
  76. class="message-item"
  77. :class="{ 'message-self': msg.fromUserId === userId }"
  78. @longpress="showMessageMenu(msg, index)">
  79. <!-- 头像 -->
  80. <image
  81. class="avatar"
  82. :src="msg.fromUserId === userId ? userAvatar : targetUserAvatar"
  83. mode="aspectFill" />
  84. <!-- 消息内容 -->
  85. <view class="message-content-wrapper">
  86. <!-- 用户名(对方消息时显示) -->
  87. <text v-if="msg.fromUserId !== userId" class="message-name">
  88. {{ msg.fromUserName }}
  89. </text>
  90. <!-- 消息气泡 -->
  91. <view
  92. class="message-bubble"
  93. :class="{ 'bubble-self': msg.fromUserId === userId, 'bubble-failed': msg.sendStatus === 4 }"
  94. @click="handleMessageClick(msg)">
  95. <!-- 文本消息 -->
  96. <text v-if="msg.messageType === 1" class="message-text">
  97. {{ msg.content }}
  98. </text>
  99. <!-- 图片消息 -->
  100. <image
  101. v-else-if="msg.messageType === 2"
  102. class="message-image"
  103. :src="msg.mediaUrl"
  104. mode="widthFix"
  105. lazy-load
  106. @click.stop="previewImage(msg.mediaUrl)" />
  107. <!-- 语音消息 -->
  108. <view
  109. v-else-if="msg.messageType === 3"
  110. class="message-voice"
  111. :style="{width: getVoiceWidth(msg.duration)}"
  112. @click.stop="toggleVoicePlay(msg)">
  113. <text class="voice-duration">{{ msg.duration }}''</text>
  114. <view class="voice-icon-wrapper" :class="{playing: playingVoiceId === msg.messageId}">
  115. <image
  116. class="voice-icon"
  117. :src="msg.fromUserId === userId ? 'http://115.190.125.125:9000/static-images/%E6%88%91%E6%96%B9%E8%AF%AD%E9%9F%B3%E6%B6%88%E6%81%AF' : 'http://115.190.125.125:9000/static-images/%E5%AF%B9%E6%96%B9%E8%AF%AD%E9%9F%B3%E6%B6%88%E6%81%AF'"
  118. mode="aspectFit"></image>
  119. </view>
  120. <!-- 暂停后的继续播放按钮 -->
  121. <view
  122. v-if="pausedVoiceId === msg.messageId"
  123. class="voice-resume-btn"
  124. :class="{'resume-btn-left': msg.fromUserId === userId, 'resume-btn-right': msg.fromUserId !== userId}"
  125. @click.stop="resumeVoice">
  126. <text class="resume-icon">▶</text>
  127. </view>
  128. </view>
  129. <!-- 视频消息 -->
  130. <video
  131. v-else-if="msg.messageType === 4"
  132. class="message-video"
  133. :src="msg.mediaUrl"
  134. controls />
  135. <!-- 撤回消息 -->
  136. <text v-if="msg.isRecalled" class="message-recalled">
  137. 消息已撤回
  138. </text>
  139. </view>
  140. <!-- 消息状态(自己的消息) -->
  141. <view v-if="msg.fromUserId === userId" class="message-status">
  142. <text v-if="msg.sendStatus === 4" class="status-failed">发送失败</text>
  143. <text v-else-if="msg.isPeerRead" class="status-read">已读</text>
  144. <text v-else class="status-unread">未读</text>
  145. </view>
  146. </view>
  147. </view>
  148. </view>
  149. </scroll-view>
  150. <!-- 新增:消息发送限制提示(双方都不是红娘,且不是从红娘工作台进入时才显示) -->
  151. <view class="message-limit-tip" v-if="!(String(userId).startsWith('m_') || String(targetUserId).startsWith('m_') || fromMatchmaker)">
  152. <!-- 同时判断isVip和hasMessageLimit,增强可靠性 -->
  153. <text v-if="isVip || !hasMessageLimit" class="vip-tip">✨ VIP特权:无发送次数限制</text>
  154. <text v-else class="limit-tip">
  155. 今日剩余可发送消息:{{ Math.max(remainingCount, 0) }} 条<text class="vip-link" @click="goToVipPage">(非VIP每日限5条)</text>
  156. </text>
  157. </view>
  158. <!-- 输入框 -->
  159. <view class="input-bar">
  160. <!-- 语音按钮 -->
  161. <view class="input-icon" @click="switchInputType">
  162. <image
  163. v-if="inputType === 'text'"
  164. class="icon-image"
  165. src="http://115.190.125.125:9000/static-images/%E8%AF%AD%E9%9F%B3%E6%B6%88%E6%81%AF%E6%8C%89%E9%92%AE"
  166. mode="aspectFit" />
  167. <text v-else>⌨️</text>
  168. </view>
  169. <!-- 文本输入 -->
  170. <input
  171. v-if="inputType === 'text'"
  172. class="input-field"
  173. v-model="inputText"
  174. placeholder="说点什么..."
  175. confirm-type="send"
  176. @confirm="sendTextMessage"
  177. @input="onInputChange" />
  178. <!-- 语音按钮 -->
  179. <button
  180. v-else
  181. class="voice-button"
  182. @touchstart="startVoiceRecord"
  183. @touchmove="onVoiceTouchMove"
  184. @touchend="stopVoiceRecord"
  185. @touchcancel="cancelVoiceRecord">
  186. 按住说话
  187. </button>
  188. <!-- 表情按钮 -->
  189. <view v-if="inputType === 'text'" class="input-icon" @click="showEmojiPanel = !showEmojiPanel">
  190. <text>😊</text>
  191. </view>
  192. <!-- 发送按钮 -->
  193. <view v-if="inputType === 'text'" class="send-button" :class="{disabled: !inputText.trim()}" @click="sendTextMessage">
  194. <text>发送</text>
  195. </view>
  196. </view>
  197. <!-- 表情面板 -->
  198. <view v-if="showEmojiPanel" class="emoji-panel">
  199. <text
  200. v-for="emoji in emojis"
  201. :key="emoji"
  202. class="emoji-item"
  203. @click="insertEmoji(emoji)">
  204. {{ emoji }}
  205. </text>
  206. </view>
  207. <!-- 消息操作菜单 -->
  208. <view v-if="showMessageAction" class="message-action-mask" @click="hideMessageMenu">
  209. <view class="message-action-menu" @click.stop>
  210. <view class="menu-item" @click="copyMessage" v-if="selectedMessage && selectedMessage.messageType === 1">
  211. <text class="menu-icon">📋</text>
  212. <text>复制</text>
  213. </view>
  214. <view class="menu-item" @click="recallMessage" v-if="selectedMessage && selectedMessage.fromUserId === userId && canRecall(selectedMessage)">
  215. <text class="menu-icon">↩️</text>
  216. <text>撤回</text>
  217. </view>
  218. <view class="menu-item" @click="deleteMessage">
  219. <text class="menu-icon">🗑️</text>
  220. <text>删除</text>
  221. </view>
  222. <view class="menu-item cancel" @click="hideMessageMenu">
  223. <text>取消</text>
  224. </view>
  225. </view>
  226. </view>
  227. <!-- 录音中提示 -->
  228. <view v-if="showVoiceRecording" class="voice-recording-mask">
  229. <view class="voice-recording-box" :class="{'canceling': voiceCanceling}">
  230. <view class="voice-wave-container">
  231. <view class="voice-wave-bar bar1" :style="{transform: `scaleY(${voiceVolume})`}"></view>
  232. <view class="voice-wave-bar bar2" :style="{transform: `scaleY(${voiceVolume * 1.2})`}"></view>
  233. <view class="voice-wave-bar bar3" :style="{transform: `scaleY(${voiceVolume * 1.5})`}"></view>
  234. <view class="voice-wave-bar bar4" :style="{transform: `scaleY(${voiceVolume * 1.3})`}"></view>
  235. <view class="voice-wave-bar bar5" :style="{transform: `scaleY(${voiceVolume})`}"></view>
  236. </view>
  237. <text class="voice-text">{{ voiceCanceling ? '松开取消发送' : '正在录音...' }}</text>
  238. <text class="voice-time">{{ voiceRecordingTime }}''</text>
  239. <text class="voice-tip" v-if="!voiceCanceling">松开发送,上滑取消</text>
  240. </view>
  241. </view>
  242. </view>
  243. </template>
  244. <script>
  245. import timManager from '@/utils/tim-manager.js';
  246. import TIM from 'tim-wx-sdk';
  247. export default {
  248. data() {
  249. return {
  250. statusBarHeight: 0, // 状态栏高度,用于适配刘海屏
  251. navBarHeight: 44, // 导航栏高度
  252. isBlockedByTarget: false,
  253. userId: null,
  254. userAvatar: '',
  255. targetUserId: null,
  256. targetUserName: '',
  257. targetUserAvatar: '',
  258. messages: [],
  259. inputText: '',
  260. inputType: 'text',
  261. conversationID: '',
  262. scrollToView: '',
  263. showEmojiPanel: false,
  264. isLogin: false,
  265. // 消息操作菜单
  266. showMessageAction: false,
  267. selectedMessage: null,
  268. selectedMessageIndex: -1,
  269. menuTop: 0,
  270. // 加载更多
  271. loading: false,
  272. noMore: false,
  273. nextReqMessageID: '',
  274. isTargetOnline: false,
  275. emojis: ['😀', '😃', '😄', '😁', '😆', '😅', '😂', '🤣', '😊', '😇', '🙂', '🙃', '😉', '😌', '😍', '🥰', '😘', '😗', '😙', '😚', '😋', '😛', '😝', '😜', '🤪', '🤨', '🧐', '🤓', '😎', '🤩', '🥳'],
  276. showMoreOptionsModal: false, // 控制更多选项弹窗显示
  277. showBlockConfirmModal: false, // 控制拉黑确认弹窗显示
  278. isVip: false, // 是否VIP用户
  279. remainingCount: 5, // 剩余可发送消息数(非VIP默认5)
  280. hasMessageLimit: true, // 是否有发送限制(VIP为false)
  281. fromMatchmaker: false, // 是否来自红娘详情页的会话(用户与红娘聊天)
  282. // 语音录制相关
  283. recorderManager: null, // 录音管理器
  284. isRecording: false, // 是否正在录音
  285. voiceStartTime: 0, // 录音开始时间
  286. voiceDuration: 0, // 录音时长
  287. voiceTempPath: '', // 录音临时文件路径
  288. showVoiceRecording: false, // 显示录音中提示
  289. voiceRecordingTime: 0, // 录音计时(秒)
  290. voiceRecordingTimer: null, // 录音计时器
  291. voiceTouchStartY: 0, // 录音按下时的Y坐标
  292. voiceCanceling: false, // 是否正在取消录音
  293. voiceVolume: 0.3, // 录音音量(0-1),控制波形高度
  294. playingVoiceId: null, // 当前播放的语音消息ID
  295. pausedVoiceId: null, // 当前暂停的语音消息ID
  296. currentAudioContext: null // 当前音频上下文
  297. };
  298. },
  299. async onLoad(options) {
  300. console.log('=== 聊天页面加载 ===');
  301. // 获取系统信息,适配刘海屏
  302. const systemInfo = uni.getSystemInfoSync();
  303. this.statusBarHeight = systemInfo.statusBarHeight || 0;
  304. // #ifdef MP-WEIXIN
  305. // 微信小程序获取胶囊按钮位置,计算导航栏高度
  306. try {
  307. const menuButtonInfo = wx.getMenuButtonBoundingClientRect();
  308. // 导航栏高度 = 胶囊按钮底部 + 胶囊按钮顶部距离状态栏的距离
  309. this.navBarHeight = (menuButtonInfo.top - this.statusBarHeight) * 2 + menuButtonInfo.height;
  310. console.log('胶囊按钮信息:', menuButtonInfo);
  311. console.log('导航栏高度:', this.navBarHeight);
  312. } catch (e) {
  313. console.log('获取胶囊按钮信息失败:', e);
  314. this.navBarHeight = 44;
  315. }
  316. // #endif
  317. console.log('状态栏高度:', this.statusBarHeight);
  318. // 严格验证登录状态
  319. const token = uni.getStorageSync('token');
  320. const userInfo = uni.getStorageSync('userInfo');
  321. const storedUserId = uni.getStorageSync('userId');
  322. console.log('登录状态检查:');
  323. console.log('- token:', token ? '存在' : '不存在');
  324. console.log('- userInfo:', userInfo);
  325. console.log('- storedUserId:', storedUserId);
  326. if (!token || !userInfo) {
  327. console.error('❌ 未登录或登录信息不完整');
  328. uni.showModal({
  329. title: '需要登录',
  330. content: '请先登录后再进行聊天',
  331. showCancel: false,
  332. success: () => {
  333. uni.reLaunch({
  334. url: '/pages/page3/page3'
  335. });
  336. }
  337. });
  338. return;
  339. }
  340. // 优先使用 storage 中的 userId,确保一致性
  341. let rawUserId = storedUserId || userInfo.userId || userInfo.id || userInfo.user_id;
  342. // 转换为数字类型(确保与消息列表页面一致)
  343. if (typeof rawUserId === 'string') {
  344. rawUserId = parseInt(rawUserId);
  345. }
  346. // 标记是否为用户与红娘的聊天,用于跳过每日5条限制和文本审核
  347. // 支持两种格式:'1' 或 'true'
  348. this.fromMatchmaker = options.fromMatchmaker === '1' || options.fromMatchmaker === 'true';
  349. // 根据入口来源确定当前会话中的“自己”是谁
  350. if (this.fromMatchmaker) {
  351. // 红娘工作台入口:使用当前 TIM 登录账号作为 userId(例如 m_22)
  352. const imUserId = timManager.getCurrentUserId();
  353. if (!imUserId) {
  354. console.error('❌ TIM 未登录,无法获取红娘IM账号');
  355. return;
  356. }
  357. this.userId = String(imUserId);
  358. } else {
  359. // 普通用户入口:仍然使用本地存储的 userId
  360. if (!rawUserId || isNaN(rawUserId)) {
  361. console.error('❌ 无法获取有效的用户ID');
  362. uni.showModal({
  363. title: '用户信息错误',
  364. content: '无法获取用户ID,请重新登录',
  365. showCancel: false,
  366. success: () => {
  367. uni.removeStorageSync('token');
  368. uni.removeStorageSync('userInfo');
  369. uni.removeStorageSync('userId');
  370. uni.reLaunch({
  371. url: '/pages/page3/page3'
  372. });
  373. }
  374. });
  375. return;
  376. }
  377. this.userId = String(rawUserId);
  378. }
  379. // 头像:默认先用用户信息中的头像(红娘入口会被 loadMatchmakerAvatar 覆盖)
  380. this.userAvatar = userInfo.avatar || userInfo.avatarUrl || '/static/default-avatar.svg';
  381. // 如果是红娘入口,再从红娘后台加载一次头像覆盖默认值
  382. if (this.fromMatchmaker) {
  383. await this.loadMatchmakerAvatar();
  384. }
  385. // 获取对方用户信息(确保是字符串格式)
  386. this.targetUserId = String(options.targetUserId);
  387. this.targetUserName = decodeURIComponent(options.targetUserName || '用户');
  388. this.targetUserAvatar = decodeURIComponent(options.targetUserAvatar || '/static/default-avatar.svg');
  389. // 生成会话 ID
  390. this.conversationID = `C2C${this.targetUserId}`;
  391. console.log('✅ 聊天页面初始化成功:');
  392. console.log(' - 当前用户ID:', this.userId);
  393. console.log(' - 对方用户ID:', this.targetUserId);
  394. console.log(' - 会话ID:', this.conversationID);
  395. // 初始化 TIM
  396. await this.initTIM();
  397. // 获取用户消息发送限制(VIP 状态 + 剩余次数)
  398. await this.getUserMessageLimit();
  399. // 等待 SDK Ready 后再加载消息
  400. await this.waitForSDKReady();
  401. // 先检查拉黑状态
  402. this.isBlockedByTarget = await this.checkIsBlockedByTarget();
  403. if (this.isBlockedByTarget) {
  404. uni.showToast({
  405. title: '你已被对方拉黑,无法发送消息',
  406. icon: 'none',
  407. duration: 3000
  408. });
  409. }
  410. // 加载历史消息
  411. await this.loadMessages();
  412. // 监听新消息
  413. this.listenMessages();
  414. // 监听已读回执
  415. this.listenMessageReadReceipt && this.listenMessageReadReceipt();
  416. // 标记当前会话的消息为已读
  417. this.markConversationRead && this.markConversationRead();
  418. // 初始化在线状态轮询
  419. this.initOnlineStatusPolling && this.initOnlineStatusPolling();
  420. // 如果有预设消息,自动发送
  421. if (options.message) {
  422. const message = decodeURIComponent(options.message);
  423. console.log('检测到预设消息,准备自动发送:', message);
  424. setTimeout(() => {
  425. this.inputText = message;
  426. this.sendTextMessage();
  427. }, 1500);
  428. }
  429. },
  430. methods: {
  431. /**
  432. * 红娘入口:根据当前登录用户ID获取红娘资料并设置头像
  433. */
  434. async loadMatchmakerAvatar() {
  435. try {
  436. const userId = uni.getStorageSync('userId');
  437. if (!userId) {
  438. console.warn('⚠️ 红娘聊天页:本地无 userId,无法加载红娘头像');
  439. return;
  440. }
  441. const res = await uni.request({
  442. url: 'http://localhost:8081/api/matchmaker/current',
  443. method: 'GET',
  444. data: { userId }
  445. });
  446. if (!res[1] || res[1].statusCode !== 200 || res[1].data.code !== 200) {
  447. console.warn('⚠️ 红娘聊天页:获取红娘信息失败');
  448. return;
  449. }
  450. const info = res[1].data.data || {};
  451. if (info.avatarUrl) {
  452. this.userAvatar = info.avatarUrl;
  453. }
  454. } catch (e) {
  455. console.error('❌ 红娘聊天页加载头像失败:', e);
  456. }
  457. },
  458. /**
  459. * 初始化 TIM
  460. */
  461. async initTIM() {
  462. try {
  463. // 如果未初始化,先初始化
  464. if (!timManager.tim) {
  465. timManager.init(1600109674); // 使用正确的 SDKAppID
  466. }
  467. // 如果未登录,获取 userSig 并登录
  468. if (!timManager.isLogin) {
  469. // 先导入当前用户和目标用户到腾讯云 IM
  470. await this.importUsers();
  471. // 从后端获取 userSig
  472. const userSig = await this.getUserSig();
  473. await timManager.login(this.userId, userSig);
  474. }
  475. this.isLogin = true;
  476. console.log('✅ TIM 初始化完成');
  477. } catch (error) {
  478. console.error('❌ TIM 初始化失败:', error);
  479. uni.showToast({
  480. title: '连接失败,请重试',
  481. icon: 'none'
  482. });
  483. }
  484. },
  485. /**
  486. * 跳转到VIP页面
  487. */
  488. goToVipPage() {
  489. console.log('点击跳转VIP页面');
  490. // 替换为你的实际VIP页面路径(例如会员开通页面)
  491. uni.navigateTo({
  492. url: '/pages/vip/index', // 请根据项目实际路径修改
  493. success: () => {
  494. console.log('跳转VIP页面成功');
  495. },
  496. fail: (err) => {
  497. console.error('跳转VIP页面失败:', err);
  498. uni.showToast({
  499. title: 'VIP页面不存在',
  500. icon: 'none'
  501. });
  502. }
  503. });
  504. },
  505. more(userid) {
  506. console.log('点击了更多按钮,开始跳转...', userid);
  507. // 如果目标页面是普通页面,用navigateTo(保留当前页面);如果是tabbar页面,用switchTab
  508. uni.navigateTo({
  509. url: `/pages/message/more?userid=${userid}`,
  510. success: () => {
  511. console.log('跳转成功');
  512. },
  513. fail: (err) => {
  514. console.error('跳转失败:', err);
  515. uni.showToast({
  516. title: '页面不存在或路径错误',
  517. icon: 'none'
  518. });
  519. }
  520. });
  521. },
  522. /**
  523. * 导入用户到腾讯云IM
  524. */
  525. async importUsers() {
  526. try {
  527. console.log('📥 开始导入用户到腾讯云IM...');
  528. console.log(' - 当前用户ID:', this.userId, '(类型:', typeof this.userId, ')');
  529. console.log(' - 目标用户ID:', this.targetUserId, '(类型:', typeof this.targetUserId, ')');
  530. // 验证用户ID
  531. if (!this.userId || this.userId === 'undefined' || this.userId === 'null') {
  532. throw new Error('当前用户ID无效: ' + this.userId);
  533. }
  534. if (!this.targetUserId || this.targetUserId === 'undefined' || this.targetUserId === 'null') {
  535. throw new Error('目标用户ID无效: ' + this.targetUserId);
  536. }
  537. // 导入当前用户(确保userId是字符串)
  538. const currentUserRes = await uni.request({
  539. url: 'http://localhost:8083/api/im/importUser',
  540. method: 'POST',
  541. data: {
  542. userId: String(this.userId),
  543. nickname: '用户' + this.userId
  544. },
  545. header: {
  546. 'Content-Type': 'application/json'
  547. }
  548. });
  549. console.log(' - 当前用户导入结果:', currentUserRes[1].data);
  550. // 导入目标用户(确保userId是字符串)
  551. const targetUserRes = await uni.request({
  552. url: 'http://localhost:8083/api/im/importUser',
  553. method: 'POST',
  554. data: {
  555. userId: String(this.targetUserId),
  556. nickname: this.targetUserName || '用户' + this.targetUserId
  557. },
  558. header: {
  559. 'Content-Type': 'application/json'
  560. }
  561. });
  562. console.log(' - 目标用户导入结果:', targetUserRes[1].data);
  563. console.log('✅ 用户导入成功');
  564. } catch (error) {
  565. console.log('⚠️ 用户导入失败(可能已存在):', error);
  566. // 导入失败不影响登录,因为用户可能已经存在
  567. }
  568. },
  569. /**
  570. * 获取 UserSig
  571. */
  572. async getUserSig() {
  573. try {
  574. const [err, res] = await uni.request({
  575. url: 'http://localhost:8083/api/im/getUserSig',
  576. method: 'GET',
  577. data: {
  578. userId: this.userId
  579. }
  580. });
  581. if (err) {
  582. throw new Error('请求失败');
  583. }
  584. if (res.data && res.data.code === 200) {
  585. return res.data.data.userSig;
  586. } else {
  587. throw new Error('获取 UserSig 失败');
  588. }
  589. } catch (error) {
  590. console.error('❌ 获取 UserSig 失败:', error);
  591. throw error;
  592. }
  593. },
  594. /**
  595. * 等待 SDK Ready
  596. */
  597. async waitForSDKReady() {
  598. return new Promise((resolve) => {
  599. // 如果已经 ready,立即返回
  600. if (timManager.isLogin) {
  601. console.log('✅ SDK 已就绪');
  602. resolve();
  603. return;
  604. }
  605. // 否则等待 SDK ready 事件
  606. console.log('⏳ 等待 SDK 就绪...');
  607. const checkReady = setInterval(() => {
  608. if (timManager.isLogin) {
  609. console.log('✅ SDK 已就绪');
  610. clearInterval(checkReady);
  611. resolve();
  612. }
  613. }, 100); // 每 100ms 检查一次
  614. // 超时保护(10秒后强制继续)
  615. setTimeout(() => {
  616. console.log('⚠️ 等待 SDK 就绪超时,继续执行');
  617. clearInterval(checkReady);
  618. resolve();
  619. }, 10000);
  620. });
  621. },
  622. /**
  623. * 加载历史消息
  624. */
  625. async loadMessages() {
  626. try {
  627. const res = await timManager.tim.getMessageList({
  628. conversationID: this.conversationID,
  629. count: 20
  630. });
  631. const messageList = res.data.messageList;
  632. this.nextReqMessageID = res.data.nextReqMessageID;
  633. this.noMore = !res.data.isCompleted;
  634. // 转换为我们的消息格式
  635. this.messages = messageList.map(msg => this.convertMessage(msg));
  636. // 🔥 获取会话对象,用 peerReadTime 更新已读状态
  637. await this.updateMessageReadStatusByConversation();
  638. // 滚动到底部
  639. this.$nextTick(() => {
  640. this.scrollToBottom();
  641. });
  642. // 标记已读
  643. await timManager.setMessageRead(this.conversationID);
  644. } catch (error) {
  645. console.error('❌ 加载消息失败:', error);
  646. }
  647. },
  648. /**
  649. * 加载更多消息(上拉)
  650. */
  651. async loadMoreMessages() {
  652. if (this.loading || !this.nextReqMessageID) {
  653. return;
  654. }
  655. this.loading = true;
  656. try {
  657. const res = await timManager.tim.getMessageList({
  658. conversationID: this.conversationID,
  659. nextReqMessageID: this.nextReqMessageID,
  660. count: 20
  661. });
  662. const messageList = res.data.messageList;
  663. this.nextReqMessageID = res.data.nextReqMessageID;
  664. this.noMore = !res.data.isCompleted;
  665. // 转换并添加到消息列表前面
  666. const newMessages = messageList.map(msg => this.convertMessage(msg));
  667. this.messages = [...newMessages, ...this.messages];
  668. // 🔥 更新已读状态
  669. await this.updateMessageReadStatusByConversation();
  670. } catch (error) {
  671. console.error('❌ 加载更多消息失败:', error);
  672. } finally {
  673. this.loading = false;
  674. }
  675. },
  676. /**
  677. * 监听新消息
  678. */
  679. listenMessages() {
  680. const handleNewMessage = (event) => {
  681. // 兼容不同格式的事件数据
  682. const messageList = event.data || event || [];
  683. // 确保是数组格式
  684. const messagesToProcess = Array.isArray(messageList) ? messageList : [messageList];
  685. messagesToProcess.forEach(msg => {
  686. // 只处理当前会话的消息
  687. if (msg.conversationID === this.conversationID) {
  688. // 查找消息在本地列表中的位置
  689. const existingIndex = this.messages.findIndex(m => m.messageId === msg.ID);
  690. if (existingIndex > -1) {
  691. // 更新现有消息状态(使用$set确保视图更新)
  692. this.$set(this.messages, existingIndex, this.convertMessage(msg));
  693. } else {
  694. // 添加新消息
  695. this.messages.push(this.convertMessage(msg));
  696. this.$nextTick(() => {
  697. this.scrollToBottom();
  698. });
  699. }
  700. // 标记已读(通知对方:我已阅读他发送的消息)
  701. console.log('📖 收到新消息,标记会话已读:', this.conversationID);
  702. timManager.setMessageRead(this.conversationID);
  703. }
  704. });
  705. };
  706. this.handleNewMessage = handleNewMessage;
  707. timManager.onMessage(handleNewMessage);
  708. // 添加消息状态变更监听
  709. // 注释原因:TIM.EVENT.MESSAGE_STATUS_CHANGED 在当前SDK版本中可能不存在,导致参数验证失败
  710. // 消息状态更新已通过 handleNewMessage 处理,暂时不需要单独监听
  711. /*
  712. if (timManager.tim) {
  713. const handleStatusChange = (event) => {
  714. console.log('📝 消息状态变更:', event);
  715. // MESSAGE_STATUS_CHANGED 事件的数据结构不同
  716. if (event && event.data && Array.isArray(event.data)) {
  717. event.data.forEach(msg => {
  718. if (msg.conversationID === this.conversationID) {
  719. const existingIndex = this.messages.findIndex(m => m.messageId === msg.ID);
  720. if (existingIndex > -1) {
  721. this.$set(this.messages, existingIndex, this.convertMessage(msg));
  722. }
  723. }
  724. });
  725. }
  726. };
  727. this.handleStatusChange = handleStatusChange;
  728. timManager.tim.on(TIM.EVENT.MESSAGE_STATUS_CHANGED, handleStatusChange);
  729. }
  730. */
  731. },
  732. /**
  733. * 转换消息格式
  734. */
  735. convertMessage(timMsg) {
  736. let sendStatus = 1; // 默认发送中
  737. // 完善的状态判断逻辑
  738. if (timMsg.status === TIM.TYPES.MSG_STATUS_SEND_SUCC ||
  739. timMsg.status === 'success' ||
  740. timMsg.status === TIM.TYPES.MSG_STATUS_RECEIVED) {
  741. sendStatus = 2; // 已送达
  742. } else if (timMsg.status === TIM.TYPES.MSG_STATUS_SEND_FAIL ||
  743. timMsg.status === 'fail') {
  744. sendStatus = 4; // 发送失败
  745. } else if (timMsg.status === TIM.TYPES.MSG_STATUS_HAS_READ ||
  746. timMsg.status === 'read') {
  747. sendStatus = 3; // 已读
  748. } else if (timMsg.status === TIM.TYPES.MSG_STATUS_SENDING) {
  749. sendStatus = 1; // 发送中
  750. }
  751. // 处理不同类型消息的URL和时长
  752. let mediaUrl = '';
  753. let duration = 0;
  754. let messageType = 1;
  755. let content = '';
  756. if (timMsg.type === TIM.TYPES.MSG_TEXT) {
  757. messageType = 1;
  758. content = timMsg.payload.text;
  759. } else if (timMsg.type === TIM.TYPES.MSG_IMAGE && timMsg.payload.imageInfoArray) {
  760. messageType = 2;
  761. content = '[图片]';
  762. mediaUrl = timMsg.payload.imageInfoArray[0]?.url || '';
  763. } else if (timMsg.type === TIM.TYPES.MSG_SOUND) {
  764. // 原生语音消息
  765. messageType = 3;
  766. content = '[语音]';
  767. mediaUrl = timMsg.payload.url || timMsg.payload.remoteAudioUrl || '';
  768. duration = timMsg.payload.second || 0;
  769. } else if (timMsg.type === TIM.TYPES.MSG_CUSTOM) {
  770. // 自定义消息(我们的语音消息)
  771. try {
  772. const customData = JSON.parse(timMsg.payload.data);
  773. if (customData.type === 'voice') {
  774. messageType = 3;
  775. content = '[语音]';
  776. mediaUrl = customData.url;
  777. // duration已经是秒数,直接使用
  778. duration = parseInt(customData.duration) || 0;
  779. }
  780. } catch (e) {
  781. console.error('解析自定义消息失败:', e);
  782. }
  783. } else if (timMsg.type === TIM.TYPES.MSG_VIDEO) {
  784. messageType = 4;
  785. content = '[视频]';
  786. }
  787. // 对于自己发送的消息,使用 TIM SDK 的 isPeerRead 值
  788. // TIM SDK 会根据 peerReadTime 自动更新这个字段
  789. let isPeerRead = false;
  790. if (timMsg.from === this.userId) {
  791. // 自己发送的消息,使用 TIM SDK 的 isPeerRead 值
  792. isPeerRead = timMsg.isPeerRead || false;
  793. } else {
  794. // 对方发送的消息,不需要 isPeerRead 字段
  795. isPeerRead = false;
  796. }
  797. return {
  798. messageId: timMsg.ID,
  799. fromUserId: timMsg.from,
  800. toUserId: timMsg.to,
  801. messageType: messageType,
  802. content: content || '[消息]',
  803. mediaUrl: mediaUrl,
  804. duration: duration,
  805. payload: timMsg.payload, // 保留原始payload
  806. sendStatus: sendStatus,
  807. sendTime: new Date(timMsg.time * 1000),
  808. isRecalled: timMsg.isRevoked,
  809. fromUserName: timMsg.from === this.userId ? '我' : this.targetUserName,
  810. isPeerRead: isPeerRead // 对方是否已读(只对自己发送的消息有意义)
  811. };
  812. },
  813. /**
  814. * 根据会话的 peerReadTime 更新消息的已读状态
  815. * 这是解决"退出重进后消息变未读"问题的关键方法
  816. */
  817. async updateMessageReadStatusByConversation() {
  818. try {
  819. console.log('🔄 开始根据会话 peerReadTime 更新已读状态...');
  820. // 获取当前会话对象
  821. const conversationRes = await timManager.tim.getConversationProfile(this.conversationID);
  822. if (!conversationRes || !conversationRes.data || !conversationRes.data.conversation) {
  823. console.warn('⚠️ 无法获取会话对象');
  824. return;
  825. }
  826. const conversation = conversationRes.data.conversation;
  827. const peerReadTime = conversation.peerReadTime; // 对方最后阅读时间(秒级时间戳)
  828. console.log(' 会话ID:', this.conversationID);
  829. console.log(' 对方最后阅读时间:', peerReadTime, new Date(peerReadTime * 1000).toLocaleString());
  830. if (!peerReadTime || peerReadTime === 0) {
  831. console.log(' 对方尚未阅读任何消息');
  832. return;
  833. }
  834. // 更新消息列表中的已读状态
  835. let updatedCount = 0;
  836. this.messages.forEach((msg, index) => {
  837. // 只处理自己发送的消息
  838. if (msg.fromUserId === this.userId) {
  839. // 如果消息的发送时间 <= 对方最后阅读时间,说明对方已读
  840. const msgTime = Math.floor(msg.sendTime.getTime() / 1000); // 转换为秒级时间戳
  841. if (msgTime <= peerReadTime) {
  842. // 只更新未标记为已读的消息
  843. if (!msg.isPeerRead) {
  844. this.$set(this.messages[index], 'isPeerRead', true);
  845. updatedCount++;
  846. console.log(` ✅ 消息 ${msg.messageId} 标记为已读 (发送时间: ${new Date(msgTime * 1000).toLocaleString()})`);
  847. }
  848. }
  849. }
  850. });
  851. console.log(`✅ 根据 peerReadTime 更新了 ${updatedCount} 条消息为已读状态`);
  852. } catch (error) {
  853. console.error('❌ 更新消息已读状态失败:', error);
  854. }
  855. },
  856. /**
  857. * 检查对方是否拉黑自己
  858. */
  859. async checkIsBlockedByTarget() {
  860. try {
  861. const [err, res] = await uni.request({
  862. url: 'http://localhost:8083/api/chatfriend/checkBlock',
  863. method: 'POST',
  864. data: {
  865. userId: this.userId, // 自己的ID
  866. targetUserId: this.targetUserId // 对方的ID
  867. },
  868. header: {
  869. 'Content-Type': 'application/json'
  870. }
  871. });
  872. console.log('拉黑检查接口响应:', res.data); // 方便调试
  873. if (err) {
  874. console.error('网络请求失败:', err);
  875. return false; // 网络错误时默认按“未被拉黑”处理(避免误拦截)
  876. }
  877. // 严格校验接口返回格式
  878. if (!res.data || res.data.code !== 200) {
  879. console.error('接口返回格式错误:', res.data);
  880. return false;
  881. }
  882. // 关键修复:后端返回 data:true 表示“已被拉黑”,直接返回该值
  883. // 你的接口中 data 就是布尔值,无需额外解析
  884. const isBlocked = res.data.data;
  885. console.log('是否被拉黑:', isBlocked); // 确认这里输出为 true(被拉黑时)
  886. return isBlocked;
  887. } catch (error) {
  888. console.error('检查拉黑状态失败:', error);
  889. return false;
  890. }
  891. },
  892. /**
  893. * 监听已读回执
  894. */
  895. listenMessageReadReceipt() {
  896. console.log('🔔 开始注册已读回执监听器...');
  897. if (!timManager.tim) {
  898. console.warn('⚠️ TIM 未初始化,无法监听已读回执');
  899. return;
  900. }
  901. console.log('✅ TIM 对象已就绪,使用全局已读回执监听');
  902. // 🔥 使用 timManager 的全局已读回执监听
  903. const handleMessageReadByPeer = async (event) => {
  904. console.log('=== 📖 [chat.vue] 收到已读回执事件 ===');
  905. console.log(' - 触发时间:', new Date().toLocaleString());
  906. console.log(' - 事件数据:', JSON.stringify(event.data));
  907. console.log(' - 当前会话ID:', this.conversationID);
  908. console.log(' - 当前用户ID:', this.userId);
  909. // event.data 包含已读的消息列表
  910. if (event.data && Array.isArray(event.data)) {
  911. let updatedCount = 0;
  912. // 🔥 直接使用事件数据中的消息对象(它们已经带有 isPeerRead: true)
  913. event.data.forEach(readMessage => {
  914. console.log(' - 处理消息:', readMessage.ID, '会话:', readMessage.conversationID);
  915. // 只处理当前会话的消息
  916. if (readMessage.conversationID === this.conversationID) {
  917. // 在本地消息列表中查找对应的消息
  918. const localMsgIndex = this.messages.findIndex(msg => msg.messageId === readMessage.ID);
  919. if (localMsgIndex > -1) {
  920. const localMsg = this.messages[localMsgIndex];
  921. // 只更新自己发送的未读消息
  922. if (localMsg.fromUserId === this.userId && !localMsg.isPeerRead) {
  923. this.$set(this.messages[localMsgIndex], 'isPeerRead', true);
  924. updatedCount++;
  925. console.log(` - ✅ 消息 ${readMessage.ID} 已标记为已读`);
  926. }
  927. } else {
  928. console.log(` - ⚠️ 本地未找到消息 ${readMessage.ID}`);
  929. }
  930. }
  931. });
  932. if (updatedCount > 0) {
  933. console.log(`✅ 共更新 ${updatedCount} 条消息为已读状态`);
  934. } else {
  935. console.log('⚠️ 没有消息需要更新');
  936. }
  937. }
  938. };
  939. // 🔥 使用 timManager 的全局已读回执监听
  940. this.handleMessageReadByPeer = handleMessageReadByPeer;
  941. timManager.onMessageRead(handleMessageReadByPeer);
  942. console.log('✅ 已通过 timManager 注册消息已读回执监听');
  943. },
  944. /**
  945. * 标记当前会话的消息为已读
  946. * 注意:这会通知对方"我已阅读他发送的消息",触发对方的 MESSAGE_READ_BY_PEER 事件
  947. */
  948. async markConversationRead() {
  949. try {
  950. if (!timManager.tim || !this.conversationID) {
  951. console.warn('⚠️ TIM 未初始化或会话ID为空');
  952. return;
  953. }
  954. console.log('📖 标记会话已读:', this.conversationID);
  955. // 调用 TIM SDK 标记会话已读
  956. // 这会通知对方:他发送给我的消息已被阅读
  957. await timManager.tim.setMessageRead({ conversationID: this.conversationID });
  958. console.log('✅ 会话已标记为已读,已通知对方');
  959. } catch (error) {
  960. console.error('❌ 标记会话已读失败:', error);
  961. }
  962. },
  963. /**
  964. * 发送文本消息
  965. */
  966. async sendTextMessage() {
  967. // 判断是否为涉及红娘的会话:任一方ID以 m_ 开头,或来自红娘工作台
  968. const isMatchmakerChat = this.fromMatchmaker || String(this.userId).startsWith('m_') || String(this.targetUserId).startsWith('m_');
  969. // 仅普通用户之间的会话才做消息次数限制
  970. if (!isMatchmakerChat && this.hasMessageLimit && this.remainingCount <= 0) {
  971. uni.showToast({
  972. title: '今日消息发送次数已用完,开通VIP无限制',
  973. icon: 'none',
  974. duration: 2000
  975. });
  976. return;
  977. }
  978. if (!this.inputText.trim()) {
  979. return;
  980. }
  981. const content = this.inputText;
  982. this.inputText = '';
  983. // 1. 先进行消息内容审核(只要有一方是红娘就跳过审核)
  984. if (!isMatchmakerChat) {
  985. try {
  986. const checkRes = await uni.request({
  987. url: 'http://localhost:8083/api/chat/checkMessage',
  988. method: 'POST',
  989. data: {
  990. userId: String(this.userId),
  991. content: content
  992. },
  993. header: {
  994. 'Content-Type': 'application/json'
  995. }
  996. });
  997. if (checkRes[1].data.code !== 200) {
  998. // 审核未通过,显示失败消息
  999. const failedMessage = {
  1000. messageId: 'failed_' + Date.now(),
  1001. fromUserId: this.userId,
  1002. toUserId: this.targetUserId,
  1003. messageType: 1,
  1004. content: content,
  1005. sendStatus: 4, // 4表示发送失败
  1006. failReason: 'audit_failed', // 标记失败原因:审核失败
  1007. sendTime: new Date(),
  1008. fromUserName: '我'
  1009. };
  1010. this.messages.push(failedMessage);
  1011. this.scrollToBottom();
  1012. uni.showToast({
  1013. title: checkRes[1].data.message || '消息发送失败',
  1014. icon: 'none',
  1015. duration: 2000
  1016. });
  1017. return;
  1018. }
  1019. } catch (error) {
  1020. console.error('❌ 消息审核失败:', error);
  1021. // 审核接口失败,为了不影响用户体验,继续发送
  1022. }
  1023. } else {
  1024. console.log('✅ 红娘聊天模式:跳过文本审核');
  1025. }
  1026. // 2. 检查是否被拉黑
  1027. const isBlocked = await this.checkIsBlockedByTarget();
  1028. if (isBlocked) {
  1029. // 被拉黑时的处理保持不变
  1030. const failedMessage = {
  1031. messageId: 'failed_' + Date.now(),
  1032. fromUserId: this.userId,
  1033. toUserId: this.targetUserId,
  1034. messageType: 1,
  1035. content: content,
  1036. sendStatus: 4, // 4表示发送失败
  1037. sendTime: new Date(),
  1038. fromUserName: '我'
  1039. };
  1040. this.messages.push(failedMessage);
  1041. this.scrollToBottom();
  1042. uni.showToast({
  1043. title: '消息发送失败',
  1044. icon: 'none'
  1045. });
  1046. return;
  1047. }
  1048. // 创建临时消息显示"发送中"
  1049. const tempMessageId = 'temp_' + Date.now();
  1050. const tempMessage = {
  1051. messageId: tempMessageId,
  1052. fromUserId: this.userId,
  1053. toUserId: this.targetUserId,
  1054. messageType: 1,
  1055. content: content,
  1056. sendStatus: 1, // 发送中
  1057. sendTime: new Date(),
  1058. fromUserName: '我'
  1059. };
  1060. this.messages.push(tempMessage);
  1061. this.scrollToBottom();
  1062. // 未被拉黑,正常发送
  1063. try {
  1064. const sendPromise = timManager.sendTextMessage(this.targetUserId, content);
  1065. const timeoutPromise = new Promise((_, reject) => {
  1066. setTimeout(() => reject(new Error('发送超时')), 15000);
  1067. });
  1068. const message = await Promise.race([sendPromise, timeoutPromise]);
  1069. // 替换临时消息
  1070. const tempIndex = this.messages.findIndex(m => m.messageId === tempMessageId);
  1071. if (tempIndex > -1) {
  1072. const convertedMsg = this.convertMessage(message);
  1073. // 强制设置为已送达
  1074. convertedMsg.sendStatus = 2;
  1075. this.$set(this.messages, tempIndex, convertedMsg);
  1076. }
  1077. console.log('✅ 消息发送成功');
  1078. this.syncMessageToMySQL(message);
  1079. // 仅普通用户之间的会话才扣减消息次数
  1080. if (!isMatchmakerChat && !this.isVip) {
  1081. await this.updateMessageCount();
  1082. }
  1083. } catch (error) {
  1084. console.error('❌ 消息发送失败:', error);
  1085. // 更新临时消息为失败状态
  1086. const tempIndex = this.messages.findIndex(m => m.messageId === tempMessageId);
  1087. if (tempIndex > -1) {
  1088. this.$set(this.messages[tempIndex], 'sendStatus', 4);
  1089. }
  1090. // 恢复输入框内容
  1091. this.inputText = content;
  1092. uni.showToast({
  1093. title: '发送失败: ' + error.message,
  1094. icon: 'none'
  1095. });
  1096. }
  1097. },
  1098. /**
  1099. * 选择图片
  1100. */
  1101. chooseImage() {
  1102. uni.chooseImage({
  1103. count: 1,
  1104. sizeType: ['compressed'],
  1105. sourceType: ['album', 'camera'],
  1106. success: async (res) => {
  1107. // 1. 先检查是否被拉黑
  1108. const isBlocked = await this.checkIsBlockedByTarget();
  1109. if (isBlocked) {
  1110. uni.showToast({
  1111. title: '你已被对方拉黑,无法发送消息',
  1112. icon: 'none'
  1113. });
  1114. return;
  1115. }
  1116. const tempFilePath = res.tempFilePaths[0];
  1117. try {
  1118. uni.showLoading({ title: '发送中...' });
  1119. const message = await timManager.sendImageMessage(this.targetUserId, tempFilePath);
  1120. this.messages.push(this.convertMessage(message));
  1121. this.scrollToBottom();
  1122. uni.hideLoading();
  1123. console.log('✅ 图片发送成功');
  1124. } catch (error) {
  1125. uni.hideLoading();
  1126. console.error('❌ 图片发送失败:', error);
  1127. uni.showToast({
  1128. title: '发送失败',
  1129. icon: 'none'
  1130. });
  1131. }
  1132. }
  1133. });
  1134. },
  1135. /**
  1136. * 输入框内容变化
  1137. */
  1138. onInputChange() {
  1139. // 腾讯云 IM 可以实现正在输入状态,这里暂时省略
  1140. console.log('输入中...');
  1141. },
  1142. /**
  1143. * 同步消息到MySQL数据库(双重存储保障)
  1144. */
  1145. async syncMessageToMySQL(timMessage) {
  1146. try {
  1147. console.log('🔄 同步消息到MySQL...', timMessage.ID);
  1148. // 构建同步参数
  1149. const syncData = {
  1150. messageId: timMessage.ID,
  1151. fromUserId: timMessage.from,
  1152. toUserId: timMessage.to,
  1153. messageType: this.getMessageType(timMessage),
  1154. content: this.getMessageContent(timMessage),
  1155. sendTime: timMessage.time // TIM返回的是秒级时间戳
  1156. };
  1157. // 如果是图片消息,添加媒体信息
  1158. if (timMessage.type === 'TIMImageElem' && timMessage.payload.imageInfoArray) {
  1159. const imageInfo = timMessage.payload.imageInfoArray[0];
  1160. syncData.mediaUrl = imageInfo.imageUrl;
  1161. syncData.thumbnailUrl = imageInfo.imageUrl;
  1162. }
  1163. // 如果是语音消息,添加语音信息
  1164. if (timMessage.type === 'TIMSoundElem' && timMessage.payload) {
  1165. syncData.mediaUrl = timMessage.payload.url || timMessage.payload.remoteAudioUrl;
  1166. syncData.duration = timMessage.payload.second || 0;
  1167. syncData.mediaSize = timMessage.payload.size || 0;
  1168. }
  1169. // 如果是自定义消息(我们的语音消息)
  1170. if (timMessage.type === 'TIMCustomElem' && timMessage.payload) {
  1171. try {
  1172. const customData = JSON.parse(timMessage.payload.data);
  1173. if (customData.type === 'voice') {
  1174. syncData.mediaUrl = customData.url;
  1175. syncData.duration = customData.duration;
  1176. syncData.mediaSize = customData.size || 0;
  1177. }
  1178. } catch (e) {
  1179. console.error('解析自定义消息失败:', e);
  1180. }
  1181. }
  1182. // 调用后端同步接口
  1183. const res = await uni.request({
  1184. url: 'http://localhost:8083/api/chat/syncTIMMessage',
  1185. method: 'POST',
  1186. data: syncData,
  1187. header: {
  1188. 'Content-Type': 'application/json'
  1189. }
  1190. });
  1191. if (res[1].data.code === 200) {
  1192. console.log('✅ 消息已同步到MySQL:', timMessage.ID);
  1193. } else {
  1194. console.warn('⚠️ 消息同步失败:', res[1].data.message);
  1195. }
  1196. } catch (error) {
  1197. console.error('❌ 同步消息到MySQL失败:', error);
  1198. // 同步失败不影响聊天功能,只记录日志
  1199. }
  1200. },
  1201. /**
  1202. * 获取消息类型
  1203. */
  1204. getMessageType(timMessage) {
  1205. const typeMap = {
  1206. 'TIMTextElem': 1, // 文本
  1207. 'TIMImageElem': 2, // 图片
  1208. 'TIMSoundElem': 3, // 语音
  1209. 'TIMVideoFileElem': 4, // 视频
  1210. 'TIMFileElem': 5 // 文件
  1211. };
  1212. // 处理自定义消息(我们的语音消息)
  1213. if (timMessage.type === 'TIMCustomElem' && timMessage.payload) {
  1214. try {
  1215. const customData = JSON.parse(timMessage.payload.data);
  1216. if (customData.type === 'voice') {
  1217. return 3; // 语音消息
  1218. }
  1219. } catch (e) {
  1220. console.error('解析自定义消息类型失败:', e);
  1221. }
  1222. }
  1223. return typeMap[timMessage.type] || 1;
  1224. },
  1225. /**
  1226. * 获取消息内容
  1227. */
  1228. getMessageContent(timMessage) {
  1229. switch (timMessage.type) {
  1230. case 'TIMTextElem':
  1231. return timMessage.payload.text || '';
  1232. case 'TIMImageElem':
  1233. return '[图片]';
  1234. case 'TIMSoundElem':
  1235. return '[语音]';
  1236. case 'TIMVideoFileElem':
  1237. return '[视频]';
  1238. case 'TIMFileElem':
  1239. return '[文件]';
  1240. case 'TIMCustomElem':
  1241. // 处理自定义消息(我们的语音消息)
  1242. try {
  1243. const customData = JSON.parse(timMessage.payload.data);
  1244. if (customData.type === 'voice') {
  1245. return '[语音]';
  1246. }
  1247. } catch (e) {
  1248. console.error('解析自定义消息内容失败:', e);
  1249. }
  1250. return '[未知消息]';
  1251. default:
  1252. return '[未知消息]';
  1253. }
  1254. },
  1255. /**
  1256. * 滚动到底部
  1257. */
  1258. scrollToBottom() {
  1259. this.$nextTick(() => {
  1260. const lastIndex = this.messages.length - 1;
  1261. this.scrollToView = 'msg-' + lastIndex;
  1262. });
  1263. },
  1264. /**
  1265. * 判断是否显示时间分隔线
  1266. */
  1267. shouldShowTime(msg, index) {
  1268. if (index === 0) return true;
  1269. const prevMsg = this.messages[index - 1];
  1270. const timeDiff = new Date(msg.sendTime) - new Date(prevMsg.sendTime);
  1271. // 超过5分钟显示时间
  1272. return timeDiff > 5 * 60 * 1000;
  1273. },
  1274. /**
  1275. * 格式化消息时间(用于时间分隔线)
  1276. */
  1277. formatMessageTime(time) {
  1278. const date = new Date(time);
  1279. const now = new Date();
  1280. const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
  1281. const yesterday = new Date(today - 86400000);
  1282. // 今天
  1283. if (date >= today) {
  1284. return date.toLocaleTimeString('zh-CN', {
  1285. hour: '2-digit',
  1286. minute: '2-digit'
  1287. });
  1288. }
  1289. // 昨天
  1290. if (date >= yesterday) {
  1291. return '昨天 ' + date.toLocaleTimeString('zh-CN', {
  1292. hour: '2-digit',
  1293. minute: '2-digit'
  1294. });
  1295. }
  1296. // 本周
  1297. if (date > new Date(now - 7 * 86400000)) {
  1298. const days = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
  1299. return days[date.getDay()] + ' ' + date.toLocaleTimeString('zh-CN', {
  1300. hour: '2-digit',
  1301. minute: '2-digit'
  1302. });
  1303. }
  1304. // 更早
  1305. return date.toLocaleString('zh-CN', {
  1306. month: '2-digit',
  1307. day: '2-digit',
  1308. hour: '2-digit',
  1309. minute: '2-digit'
  1310. });
  1311. },
  1312. /**
  1313. * 格式化时间(消息状态用)
  1314. */
  1315. formatTime(time) {
  1316. const date = new Date(time);
  1317. const now = new Date();
  1318. const diff = now - date;
  1319. if (diff < 60000) {
  1320. return '刚刚';
  1321. } else if (diff < 3600000) {
  1322. return Math.floor(diff / 60000) + '分钟前';
  1323. } else if (diff < 86400000) {
  1324. return Math.floor(diff / 3600000) + '小时前';
  1325. } else {
  1326. return date.toLocaleString('zh-CN', {
  1327. month: '2-digit',
  1328. day: '2-digit',
  1329. hour: '2-digit',
  1330. minute: '2-digit'
  1331. });
  1332. }
  1333. },
  1334. /**
  1335. * 预览图片
  1336. */
  1337. previewImage(url) {
  1338. uni.previewImage({
  1339. urls: [url],
  1340. current: url
  1341. });
  1342. },
  1343. /**
  1344. * 返回
  1345. */
  1346. goBack() {
  1347. uni.navigateBack({
  1348. success: () => {
  1349. // 通知消息列表页面刷新
  1350. uni.$emit('refreshConversations');
  1351. }
  1352. });
  1353. },
  1354. /**
  1355. * 显示消息操作菜单
  1356. */
  1357. showMessageMenu(msg, index) {
  1358. this.selectedMessage = msg;
  1359. this.selectedMessageIndex = index;
  1360. this.showMessageAction = true;
  1361. // 计算菜单位置
  1362. uni.createSelectorQuery().select('.message-item').boundingClientRect(rect => {
  1363. if (rect) {
  1364. this.menuTop = rect.top + rect.height;
  1365. }
  1366. }).exec();
  1367. },
  1368. /**
  1369. * 隐藏消息操作菜单
  1370. */
  1371. hideMessageMenu() {
  1372. this.showMessageAction = false;
  1373. this.selectedMessage = null;
  1374. this.selectedMessageIndex = -1;
  1375. },
  1376. /**
  1377. * 复制消息
  1378. */
  1379. copyMessage() {
  1380. if (!this.selectedMessage || this.selectedMessage.messageType !== 1) {
  1381. return;
  1382. }
  1383. uni.setClipboardData({
  1384. data: this.selectedMessage.content,
  1385. success: () => {
  1386. uni.showToast({
  1387. title: '已复制',
  1388. icon: 'success'
  1389. });
  1390. }
  1391. });
  1392. this.hideMessageMenu();
  1393. },
  1394. /**
  1395. * 判断是否可以撤回
  1396. */
  1397. canRecall(msg) {
  1398. const diff = Date.now() - new Date(msg.sendTime).getTime();
  1399. return diff < 2 * 60 * 1000; // 2分钟内可撤回
  1400. },
  1401. /**
  1402. * 撤回消息
  1403. */
  1404. async recallMessage() {
  1405. if (!this.selectedMessage) {
  1406. return;
  1407. }
  1408. try {
  1409. // 查找TIM消息对象
  1410. const timMessage = await timManager.tim.findMessage(this.selectedMessage.messageId);
  1411. if (timMessage) {
  1412. await timManager.revokeMessage(timMessage);
  1413. // 更新本地消息状态
  1414. this.selectedMessage.isRecalled = true;
  1415. this.selectedMessage.content = '你撤回了一条消息';
  1416. uni.showToast({
  1417. title: '已撤回',
  1418. icon: 'success'
  1419. });
  1420. }
  1421. } catch (error) {
  1422. console.error('撤回失败:', error);
  1423. uni.showToast({
  1424. title: '撤回失败',
  1425. icon: 'none'
  1426. });
  1427. }
  1428. this.hideMessageMenu();
  1429. },
  1430. /**
  1431. * 删除消息
  1432. */
  1433. deleteMessage() {
  1434. if (!this.selectedMessage) {
  1435. return;
  1436. }
  1437. uni.showModal({
  1438. title: '确认删除',
  1439. content: '确定要删除这条消息吗?',
  1440. success: (res) => {
  1441. if (res.confirm) {
  1442. // 从列表中删除
  1443. this.messages.splice(this.selectedMessageIndex, 1);
  1444. uni.showToast({
  1445. title: '已删除',
  1446. icon: 'success'
  1447. });
  1448. }
  1449. }
  1450. });
  1451. this.hideMessageMenu();
  1452. },
  1453. /**
  1454. * 处理消息点击
  1455. */
  1456. handleMessageClick(msg) {
  1457. // 如果是发送失败的消息,提示重试
  1458. if (msg.sendStatus === 4) {
  1459. // 检查失败原因
  1460. if (msg.failReason === 'audit_failed') {
  1461. // 审核失败的消息不允许重试
  1462. uni.showModal({
  1463. title: '无法重试',
  1464. content: '该消息包含敏感信息,无法发送',
  1465. showCancel: false,
  1466. confirmText: '知道了'
  1467. });
  1468. return;
  1469. }
  1470. // 其他原因失败的消息可以重试
  1471. uni.showModal({
  1472. title: '发送失败',
  1473. content: '消息发送失败,是否重试?',
  1474. success: (res) => {
  1475. if (res.confirm) {
  1476. this.retryMessage(msg);
  1477. }
  1478. }
  1479. });
  1480. }
  1481. },
  1482. /**
  1483. * 重试发送消息
  1484. */
  1485. async retryMessage(msg) {
  1486. if (msg.messageType !== 1) {
  1487. uni.showToast({ title: '暂不支持重发该类型消息', icon: 'none' });
  1488. return;
  1489. }
  1490. // 双重保护:检查是否是审核失败的消息
  1491. if (msg.failReason === 'audit_failed') {
  1492. uni.showToast({
  1493. title: '该消息包含敏感信息,无法重试',
  1494. icon: 'none',
  1495. duration: 2000
  1496. });
  1497. return;
  1498. }
  1499. const isBlocked = await this.checkIsBlockedByTarget();
  1500. if (isBlocked) {
  1501. const index = this.messages.findIndex(m => m.messageId === msg.messageId);
  1502. if (index > -1) {
  1503. this.$set(this.messages[index], 'sendStatus', 4);
  1504. }
  1505. uni.showToast({ title: '对方已拉黑你,无法发送', icon: 'none' });
  1506. return;
  1507. }
  1508. try {
  1509. const index = this.messages.findIndex(m => m.messageId === msg.messageId);
  1510. if (index > -1) {
  1511. this.$set(this.messages[index], 'sendStatus', 1); // 发送中
  1512. }
  1513. const message = await timManager.sendTextMessage(this.targetUserId, msg.content);
  1514. // 更新消息状态为已送达
  1515. if (index > -1) {
  1516. const convertedMsg = this.convertMessage(message);
  1517. convertedMsg.sendStatus = 2; // 强制已送达
  1518. convertedMsg.messageId = msg.messageId; // 保持原ID
  1519. this.$set(this.messages, index, convertedMsg);
  1520. }
  1521. console.log('✅ 消息重发成功');
  1522. this.syncMessageToMySQL(message);
  1523. } catch (error) {
  1524. console.error('❌ 消息重发失败:', error);
  1525. const index = this.messages.findIndex(m => m.messageId === msg.messageId);
  1526. if (index > -1) {
  1527. this.$set(this.messages[index], 'sendStatus', 4);
  1528. }
  1529. uni.showToast({ title: '发送失败', icon: 'none' });
  1530. }
  1531. },
  1532. // 其他功能方法
  1533. switchInputType() {
  1534. this.inputType = this.inputType === 'text' ? 'voice' : 'text';
  1535. },
  1536. /**
  1537. * 开始录音
  1538. */
  1539. startVoiceRecord(e) {
  1540. console.log('🎤 [按住说话] startVoiceRecord 方法被调用');
  1541. // 记录触摸起始位置
  1542. this.voiceTouchStartY = e.touches[0].clientY;
  1543. this.voiceCanceling = false;
  1544. // 检查是否被拉黑
  1545. if (this.isBlockedByTarget) {
  1546. uni.showToast({
  1547. title: '对方已将你拉黑',
  1548. icon: 'none'
  1549. });
  1550. return;
  1551. }
  1552. // 🔥 每次都重新获取录音管理器(确保状态干净)
  1553. console.log('📱 获取录音管理器');
  1554. this.recorderManager = uni.getRecorderManager();
  1555. // 🔥 每次录音前都重新注册回调(确保回调有效)
  1556. console.log('📝 注册录音回调');
  1557. // 录音开始回调(可能延迟或不触发,所以状态已在 start() 后立即设置)
  1558. this.recorderManager.onStart(() => {
  1559. console.log('✅ 录音开始回调触发(延迟触发)');
  1560. });
  1561. // 录音结束回调
  1562. this.recorderManager.onStop((res) => {
  1563. console.log('🎤 录音结束:', res, ', voiceCanceling:', this.voiceCanceling);
  1564. this.isRecording = false;
  1565. this.showVoiceRecording = false;
  1566. // 清除计时器
  1567. if (this.voiceRecordingTimer) {
  1568. clearInterval(this.voiceRecordingTimer);
  1569. this.voiceRecordingTimer = null;
  1570. }
  1571. // 如果是取消状态,不发送
  1572. if (this.voiceCanceling) {
  1573. console.log('❌ 录音已取消,不发送');
  1574. this.voiceCanceling = false;
  1575. return;
  1576. }
  1577. this.voiceTempPath = res.tempFilePath;
  1578. this.voiceDuration = Math.floor(res.duration / 1000); // 转换为秒
  1579. console.log(' - 文件路径:', this.voiceTempPath);
  1580. console.log(' - 时长:', this.voiceDuration, '秒');
  1581. // 时长验证
  1582. if (this.voiceDuration < 1) {
  1583. uni.showToast({
  1584. title: '录音时间太短',
  1585. icon: 'none'
  1586. });
  1587. return;
  1588. }
  1589. if (this.voiceDuration > 60) {
  1590. uni.showToast({
  1591. title: '录音时间不能超过60秒',
  1592. icon: 'none'
  1593. });
  1594. return;
  1595. }
  1596. // 发送语音消息
  1597. this.sendVoiceMessage();
  1598. });
  1599. // 录音错误回调
  1600. this.recorderManager.onError((err) => {
  1601. console.error('❌ 录音错误:', err);
  1602. // 立即清理录音状态和界面
  1603. this.isRecording = false;
  1604. this.showVoiceRecording = false;
  1605. this.voiceCanceling = true;
  1606. // 清除计时器
  1607. if (this.voiceRecordingTimer) {
  1608. clearInterval(this.voiceRecordingTimer);
  1609. this.voiceRecordingTimer = null;
  1610. }
  1611. // 如果是权限错误,不显示任何提示(用户会看到系统权限弹窗)
  1612. // 其他错误才显示提示
  1613. if (err.errCode !== 'authorize' && err.errMsg && !err.errMsg.includes('authorize')) {
  1614. uni.showToast({
  1615. title: '录音失败',
  1616. icon: 'none'
  1617. });
  1618. }
  1619. console.log('✅ 已清理录音状态,隐藏录音界面');
  1620. });
  1621. // 🔥 先检查录音权限
  1622. console.log('🔐 检查录音权限...');
  1623. uni.getSetting({
  1624. success: (res) => {
  1625. console.log('📋 权限设置:', res.authSetting);
  1626. if (res.authSetting['scope.record'] === false) {
  1627. console.warn('⚠️ 录音权限被拒绝');
  1628. uni.showModal({
  1629. title: '需要录音权限',
  1630. content: '请在设置中开启录音权限',
  1631. success: (modalRes) => {
  1632. if (modalRes.confirm) {
  1633. uni.openSetting();
  1634. }
  1635. }
  1636. });
  1637. return;
  1638. }
  1639. }
  1640. });
  1641. // 开始录音
  1642. console.log('🎙️ 调用 recorderManager.start()');
  1643. try {
  1644. this.recorderManager.start({
  1645. format: 'mp3',
  1646. sampleRate: 16000,
  1647. numberOfChannels: 1,
  1648. encodeBitRate: 48000
  1649. });
  1650. console.log('✅ recorderManager.start() 调用成功');
  1651. // 🔥 立即显示录音界面(不等待 onStart 回调)
  1652. // 因为微信小程序的 onStart 可能延迟或不触发
  1653. this.isRecording = true;
  1654. this.showVoiceRecording = true;
  1655. this.voiceStartTime = Date.now();
  1656. this.voiceRecordingTime = 0;
  1657. this.voiceVolume = 0.3;
  1658. console.log('🎬 立即显示录音界面');
  1659. console.log(' - isRecording:', this.isRecording);
  1660. console.log(' - showVoiceRecording:', this.showVoiceRecording);
  1661. // 启动计时器
  1662. let volumeDirection = 1;
  1663. let currentVolume = 0.3;
  1664. this.voiceRecordingTimer = setInterval(() => {
  1665. this.voiceRecordingTime = Math.floor((Date.now() - this.voiceStartTime) / 1000);
  1666. // 检查是否达到60秒,自动停止录音
  1667. if (this.voiceRecordingTime >= 60) {
  1668. console.log('⏰ 录音时长达到60秒,自动停止');
  1669. clearInterval(this.voiceRecordingTimer);
  1670. this.voiceRecordingTimer = null;
  1671. this.voiceCanceling = false;
  1672. if (this.recorderManager) {
  1673. this.recorderManager.stop();
  1674. }
  1675. return;
  1676. }
  1677. // 模拟音量波动
  1678. if (Math.random() > 0.2) {
  1679. currentVolume += volumeDirection * (0.1 + Math.random() * 0.2);
  1680. if (currentVolume > 1.0) {
  1681. currentVolume = 1.0;
  1682. volumeDirection = -1;
  1683. } else if (currentVolume < 0.4) {
  1684. currentVolume = 0.4;
  1685. volumeDirection = 1;
  1686. }
  1687. this.voiceVolume = currentVolume;
  1688. } else {
  1689. this.voiceVolume = Math.max(0.2, this.voiceVolume * 0.8);
  1690. }
  1691. }, 100);
  1692. } catch (err) {
  1693. console.error('❌ recorderManager.start() 调用失败:', err);
  1694. this.isRecording = false;
  1695. this.showVoiceRecording = false;
  1696. }
  1697. },
  1698. /**
  1699. * 停止录音
  1700. */
  1701. stopVoiceRecord() {
  1702. console.log('🎤 [松开] stopVoiceRecord 方法被调用, isRecording:', this.isRecording, ', voiceCanceling:', this.voiceCanceling);
  1703. // 清除计时器
  1704. if (this.voiceRecordingTimer) {
  1705. clearInterval(this.voiceRecordingTimer);
  1706. this.voiceRecordingTimer = null;
  1707. }
  1708. // 如果正在取消,则取消录音
  1709. if (this.voiceCanceling) {
  1710. console.log('🚫 检测到取消状态,调用 cancelVoiceRecord');
  1711. this.cancelVoiceRecord();
  1712. return;
  1713. }
  1714. if (this.isRecording && this.recorderManager) {
  1715. const duration = Date.now() - this.voiceStartTime;
  1716. // 录音时间太短
  1717. if (duration < 1000) {
  1718. this.recorderManager.stop();
  1719. this.isRecording = false;
  1720. this.showVoiceRecording = false;
  1721. uni.showToast({
  1722. title: '录音时间太短',
  1723. icon: 'none'
  1724. });
  1725. return;
  1726. }
  1727. this.recorderManager.stop();
  1728. }
  1729. },
  1730. /**
  1731. * 触摸移动 - 检测上滑取消
  1732. */
  1733. onVoiceTouchMove(e) {
  1734. if (!this.isRecording) return;
  1735. const currentY = e.touches[0].clientY;
  1736. const deltaY = this.voiceTouchStartY - currentY;
  1737. // 上滑超过100px,显示取消提示
  1738. if (deltaY > 100) {
  1739. this.voiceCanceling = true;
  1740. console.log('⬆️ 上滑取消录音');
  1741. } else {
  1742. this.voiceCanceling = false;
  1743. }
  1744. },
  1745. /**
  1746. * 取消录音
  1747. */
  1748. cancelVoiceRecord() {
  1749. console.log('❌ 取消录音, voiceCanceling:', this.voiceCanceling);
  1750. // 清除计时器
  1751. if (this.voiceRecordingTimer) {
  1752. clearInterval(this.voiceRecordingTimer);
  1753. this.voiceRecordingTimer = null;
  1754. }
  1755. // 先设置取消状态,再停止录音
  1756. // 这样onStop回调中能正确判断
  1757. if (!this.voiceCanceling) {
  1758. this.voiceCanceling = true;
  1759. }
  1760. if (this.recorderManager && this.isRecording) {
  1761. this.recorderManager.stop(); // 这会触发onStop回调
  1762. } else {
  1763. // 如果没有在录音,直接重置状态
  1764. this.isRecording = false;
  1765. this.showVoiceRecording = false;
  1766. this.voiceCanceling = false;
  1767. }
  1768. uni.showToast({
  1769. title: '已取消录音',
  1770. icon: 'none'
  1771. });
  1772. },
  1773. /**
  1774. * 发送语音消息
  1775. */
  1776. async sendVoiceMessage() {
  1777. // 检查是否被拉黑
  1778. const isBlocked = await this.checkIsBlockedByTarget();
  1779. if (isBlocked) {
  1780. uni.showToast({
  1781. title: '你已被对方拉黑,无法发送消息',
  1782. icon: 'none'
  1783. });
  1784. return;
  1785. }
  1786. try {
  1787. uni.showLoading({ title: '上传中...' });
  1788. // 第一步:上传语音文件到MinIO
  1789. const uploadResult = await this.uploadVoiceToMinIO();
  1790. if (!uploadResult.success) {
  1791. throw new Error(uploadResult.message || '上传失败');
  1792. }
  1793. console.log('✅ 语音文件上传成功:', uploadResult.fileUrl);
  1794. uni.showLoading({ title: '发送中...' });
  1795. // 第二步:通过腾讯IM发送语音消息(携带MinIO URL)
  1796. const message = await timManager.sendVoiceMessage(
  1797. this.targetUserId,
  1798. uploadResult.fileUrl,
  1799. this.voiceDuration,
  1800. uploadResult.fileSize
  1801. );
  1802. // 添加到消息列表
  1803. this.messages.push(this.convertMessage(message));
  1804. this.scrollToBottom();
  1805. uni.hideLoading();
  1806. console.log('✅ 语音消息发送成功');
  1807. // 同步到MySQL
  1808. this.syncMessageToMySQL(message);
  1809. // 更新消息计数
  1810. if (!this.isVip) {
  1811. await this.updateMessageCount();
  1812. }
  1813. } catch (error) {
  1814. uni.hideLoading();
  1815. console.error('❌ 语音消息发送失败:', error);
  1816. uni.showToast({
  1817. title: '发送失败: ' + error.message,
  1818. icon: 'none'
  1819. });
  1820. }
  1821. },
  1822. /**
  1823. * 上传语音文件到MinIO
  1824. */
  1825. async uploadVoiceToMinIO() {
  1826. try {
  1827. console.log('📤 开始上传语音文件到MinIO...');
  1828. console.log(' - 文件路径:', this.voiceTempPath);
  1829. console.log(' - 时长:', this.voiceDuration, '秒');
  1830. // 使用uni.uploadFile上传到后端MinIO接口
  1831. const [err, res] = await uni.uploadFile({
  1832. url: 'http://localhost:8083/api/voice/upload',
  1833. filePath: this.voiceTempPath,
  1834. name: 'file',
  1835. header: {
  1836. 'Content-Type': 'multipart/form-data'
  1837. }
  1838. });
  1839. if (err) {
  1840. console.error('❌ 上传请求失败:', err);
  1841. return {
  1842. success: false,
  1843. message: '网络请求失败'
  1844. };
  1845. }
  1846. // 解析响应
  1847. const result = JSON.parse(res.data);
  1848. console.log('📥 MinIO上传响应:', result);
  1849. if (result.success) {
  1850. return {
  1851. success: true,
  1852. fileUrl: result.fileUrl,
  1853. fileSize: result.fileSize
  1854. };
  1855. } else {
  1856. return {
  1857. success: false,
  1858. message: result.message || '上传失败'
  1859. };
  1860. }
  1861. } catch (error) {
  1862. console.error('❌ 上传语音文件失败:', error);
  1863. return {
  1864. success: false,
  1865. message: error.message || '上传异常'
  1866. };
  1867. }
  1868. },
  1869. insertEmoji(emoji) {
  1870. this.inputText += emoji;
  1871. this.showEmojiPanel = false;
  1872. },
  1873. chooseVideo() {
  1874. uni.showToast({
  1875. title: '视频功能开发中',
  1876. icon: 'none'
  1877. });
  1878. },
  1879. chooseFile() {
  1880. uni.showToast({
  1881. title: '文件功能开发中',
  1882. icon: 'none'
  1883. });
  1884. },
  1885. /**
  1886. * 切换语音播放/暂停
  1887. */
  1888. toggleVoicePlay(msg) {
  1889. // 如果正在播放这条语音,则暂停
  1890. if (this.playingVoiceId === msg.messageId) {
  1891. this.pauseVoice(msg);
  1892. } else {
  1893. // 否则开始播放
  1894. this.playVoice(msg);
  1895. }
  1896. },
  1897. /**
  1898. * 播放语音
  1899. */
  1900. playVoice(msg) {
  1901. console.log('📢 [播放语音] 开始 - messageId:', msg.messageId);
  1902. if (!msg.mediaUrl) {
  1903. // 如果是TIM消息,从payload中获取URL
  1904. const audioUrl = msg.payload?.url || msg.payload?.remoteAudioUrl;
  1905. if (!audioUrl) {
  1906. uni.showToast({
  1907. title: '语音文件不存在',
  1908. icon: 'none'
  1909. });
  1910. return;
  1911. }
  1912. msg.mediaUrl = audioUrl;
  1913. }
  1914. // 如果有其他语音正在播放,先停止
  1915. if (this.currentAudioContext) {
  1916. console.log('⏹️ 停止之前的语音');
  1917. this.currentAudioContext.stop();
  1918. this.currentAudioContext.destroy();
  1919. this.currentAudioContext = null;
  1920. }
  1921. // 清除暂停状态
  1922. this.pausedVoiceId = null;
  1923. // 设置当前播放的语音ID,触发动画
  1924. this.playingVoiceId = msg.messageId;
  1925. console.log('🎬 [动画开始] playingVoiceId =', this.playingVoiceId);
  1926. // 创建音频上下文
  1927. const innerAudioContext = uni.createInnerAudioContext();
  1928. innerAudioContext.src = msg.mediaUrl;
  1929. innerAudioContext.loop = false; // 不循环播放
  1930. this.currentAudioContext = innerAudioContext;
  1931. innerAudioContext.onPlay(() => {
  1932. console.log('🔊 开始播放语音');
  1933. });
  1934. innerAudioContext.onEnded(() => {
  1935. console.log('✅ 语音播放完成');
  1936. this.playingVoiceId = null; // 停止动画
  1937. this.pausedVoiceId = null;
  1938. this.currentAudioContext = null;
  1939. console.log('🛑 [动画停止] playingVoiceId =', this.playingVoiceId);
  1940. innerAudioContext.destroy();
  1941. });
  1942. innerAudioContext.onError((err) => {
  1943. console.error('❌ 语音播放失败:', err);
  1944. this.playingVoiceId = null; // 停止动画
  1945. this.pausedVoiceId = null;
  1946. this.currentAudioContext = null;
  1947. console.log('🛑 [动画停止-错误] playingVoiceId =', this.playingVoiceId);
  1948. uni.showToast({
  1949. title: '播放失败',
  1950. icon: 'none'
  1951. });
  1952. innerAudioContext.destroy();
  1953. });
  1954. innerAudioContext.play();
  1955. },
  1956. /**
  1957. * 暂停语音
  1958. */
  1959. pauseVoice(msg) {
  1960. if (this.currentAudioContext) {
  1961. this.currentAudioContext.pause();
  1962. this.playingVoiceId = null; // 停止动画
  1963. this.pausedVoiceId = msg.messageId; // 设置暂停状态
  1964. console.log('⏸️ [暂停] playingVoiceId =', this.playingVoiceId, ', pausedVoiceId =', this.pausedVoiceId);
  1965. }
  1966. },
  1967. /**
  1968. * 继续播放语音
  1969. */
  1970. resumeVoice() {
  1971. if (this.currentAudioContext && this.pausedVoiceId) {
  1972. this.currentAudioContext.play();
  1973. this.playingVoiceId = this.pausedVoiceId; // 恢复动画
  1974. const resumedId = this.pausedVoiceId;
  1975. this.pausedVoiceId = null; // 清除暂停状态
  1976. console.log('▶️ [继续播放] playingVoiceId =', this.playingVoiceId, ', pausedVoiceId =', this.pausedVoiceId);
  1977. }
  1978. },
  1979. showMoreOptions() {
  1980. this.showMoreOptionsModal = true;
  1981. },
  1982. closeMoreOptions() {
  1983. this.showMoreOptionsModal = false;
  1984. },
  1985. /**
  1986. * 查看用户资料(跳转到原有more页面)
  1987. */
  1988. goToUserProfile() {
  1989. this.closeMoreOptions();
  1990. uni.navigateTo({
  1991. url: `/pages/message/more?userid=${this.targetUserId}`,
  1992. fail: (err) => {
  1993. console.error('跳转失败:', err);
  1994. uni.showToast({
  1995. title: '页面不存在',
  1996. icon: 'none'
  1997. });
  1998. }
  1999. });
  2000. },
  2001. /**
  2002. * 显示拉黑确认弹窗
  2003. */
  2004. blockFriend() {
  2005. this.closeMoreOptions();
  2006. this.showBlockConfirmModal = true;
  2007. },
  2008. /**
  2009. * 关闭拉黑确认弹窗
  2010. */
  2011. closeBlockConfirm() {
  2012. this.showBlockConfirmModal = false;
  2013. },
  2014. /**
  2015. * 从后端获取VIP状态和今日剩余发送次数
  2016. */
  2017. async getUserMessageLimit() {
  2018. // 只要一方是红娘(ID 以 m_ 开头),就不做每日5条限制,直接视为无限制
  2019. const isMatchmakerChat = String(this.userId).startsWith('m_') || String(this.targetUserId).startsWith('m_');
  2020. if (isMatchmakerChat || this.fromMatchmaker) {
  2021. this.hasMessageLimit = false;
  2022. this.isVip = true;
  2023. this.remainingCount = 999;
  2024. console.log('✅ 红娘聊天模式:无消息限制(跳过 getUserMessageLimit 接口)');
  2025. return;
  2026. }
  2027. try {
  2028. const [err, res] = await uni.request({
  2029. url: 'http://localhost:1004/api/chat/getUserMessageLimit',
  2030. method: 'GET',
  2031. data: {
  2032. userId: this.userId ,// 已在onLoad中初始化的当前用户ID
  2033. targetUserId: this.targetUserId
  2034. },
  2035. header: {
  2036. 'Content-Type': 'application/json'
  2037. }
  2038. });
  2039. if (err) throw new Error('网络请求失败');
  2040. if (res.data.code !== 200) throw new Error(res.data.message || '获取限制信息失败');
  2041. const { isVip, remainingCount, hasMessageLimit } = res.data.data;
  2042. this.isVip = isVip;
  2043. this.remainingCount = remainingCount;
  2044. this.hasMessageLimit = hasMessageLimit;
  2045. console.log('✅ 用户消息限制信息:', { isVip, remainingCount, hasMessageLimit });
  2046. } catch (error) {
  2047. console.error('❌ 获取消息限制失败:', error);
  2048. // 异常降级:默认非VIP,剩余5次
  2049. this.isVip = false;
  2050. this.remainingCount = 5;
  2051. this.hasMessageLimit = true;
  2052. }
  2053. },
  2054. /**
  2055. * 发送成功后,更新后端计数并同步前端剩余次数
  2056. */
  2057. async updateMessageCount() {
  2058. try {
  2059. const [err, res] = await uni.request({
  2060. url: 'http://localhost:8083/api/chat/updateMessageCount',
  2061. method: 'get',
  2062. data: {
  2063. userId: this.userId ,// 已在onLoad中初始化的当前用户ID
  2064. targetUserId: this.targetUserId
  2065. },
  2066. header: {
  2067. 'Content-Type': 'application/json'
  2068. }
  2069. });
  2070. if (err) throw new Error('更新计数失败');
  2071. if (res.data.code === 200) {
  2072. this.remainingCount = res.data.data.remainingCount; // 同步后端返回的剩余次数
  2073. }
  2074. } catch (error) {
  2075. console.error('❌ 更新发送次数失败:', error);
  2076. // 前端本地降级:避免影响用户体验,本地暂减1(刷新页面后同步真实数据)
  2077. if (this.remainingCount > 0) {
  2078. this.remainingCount--;
  2079. }
  2080. }
  2081. },
  2082. /**
  2083. * 确认拉黑好友
  2084. */
  2085. async confirmBlockFriend() {
  2086. try {
  2087. uni.showLoading({
  2088. title: '处理中...'
  2089. });
  2090. // 调用后端拉黑接口(根据实际接口调整)
  2091. const [err, res] = await uni.request({
  2092. url: 'http://localhost:8083/api/chatfriend/block',
  2093. method: 'POST',
  2094. data: {
  2095. userId: this.userId,
  2096. targetUserId: this.targetUserId,
  2097. targetUserName: this.targetUserName,
  2098. targetUserAvatar: this.targetUserAvatar
  2099. },
  2100. header: {
  2101. 'Content-Type': 'application/json',
  2102. // 'Authorization': 'Bearer ' + uni.getStorageSync('token')
  2103. }
  2104. });
  2105. uni.hideLoading();
  2106. if (err) throw new Error('网络请求失败');
  2107. if (res.data && res.data.code === 200) {
  2108. uni.showToast({ title: '拉黑成功', icon: 'success' });
  2109. this.closeBlockConfirm();
  2110. // 触发拉黑列表更新事件,通知消息页面实时刷新
  2111. uni.$emit('blacklistUpdated');
  2112. // 拉黑成功后立即返回消息列表页面(优化体验)
  2113. setTimeout(() => {
  2114. uni.navigateBack({
  2115. delta: 1,
  2116. success: () => {
  2117. // 返回成功后再次触发一次刷新,确保万无一失
  2118. uni.$emit('blacklistUpdated');
  2119. }
  2120. });
  2121. }, 1500);
  2122. } else {
  2123. throw new Error(res.data?.message || '拉黑失败');
  2124. }
  2125. } catch (error) {
  2126. console.error('拉黑失败:', error);
  2127. uni.showToast({ title: error.message || '拉黑失败', icon: 'none' });
  2128. }
  2129. },
  2130. /**
  2131. * 初始化在线状态监听(WebSocket 实时推送 + HTTP 轮询)
  2132. */
  2133. async initOnlineStatusPolling() {
  2134. console.log('🔄 初始化在线状态监听(WebSocket + HTTP)');
  2135. const timPresenceManager = require('@/utils/tim-presence-manager.js').default;
  2136. // 1. 立即查询一次
  2137. await this.checkOnlineStatus();
  2138. // 2. 监听 WebSocket 实时推送(实时性强)
  2139. this.onlineStatusCallback = (status) => {
  2140. console.log(`⚡ 实时收到用户 ${this.targetUserId} 状态变更: ${status}`);
  2141. this.isTargetOnline = (status === 'online');
  2142. };
  2143. timPresenceManager.onStatusChange(this.targetUserId, this.onlineStatusCallback);
  2144. // 3. 启动 HTTP 轮询作为补充(30秒间隔)
  2145. this.onlineStatusTimer = setInterval(() => {
  2146. this.checkOnlineStatus();
  2147. }, 30000);
  2148. console.log('✅ 在线状态监听已启动(WebSocket 实时 + HTTP 轮询)');
  2149. },
  2150. /**
  2151. * 查询在线状态
  2152. */
  2153. async checkOnlineStatus() {
  2154. try {
  2155. const [err, res] = await uni.request({
  2156. url: 'http://localhost:8083/api/online/checkStatus',
  2157. method: 'GET',
  2158. data: {
  2159. userId: this.targetUserId
  2160. }
  2161. });
  2162. if (!err && res.data && res.data.code === 200) {
  2163. this.isTargetOnline = res.data.data.online || false;
  2164. console.log(`� 用户 ${this.targetUserId} 在线状态:`, this.isTargetOnline);
  2165. }
  2166. } catch (error) {
  2167. console.error('❌ 查询在线状态失败:', error);
  2168. }
  2169. }
  2170. },
  2171. /**
  2172. * 页面卸载时清理
  2173. */
  2174. onUnload() {
  2175. console.log('=== 聊天页面卸载 ===');
  2176. // 清理在线状态轮询
  2177. if (this.onlineStatusTimer) {
  2178. clearInterval(this.onlineStatusTimer);
  2179. this.onlineStatusTimer = null;
  2180. console.log('✅ 已停止在线状态轮询');
  2181. }
  2182. // 清理 WebSocket 监听
  2183. if (this.onlineStatusCallback) {
  2184. const timPresenceManager = require('@/utils/tim-presence-manager.js').default;
  2185. timPresenceManager.offStatusChange(this.targetUserId, this.onlineStatusCallback);
  2186. console.log('✅ 已清理 WebSocket 监听');
  2187. }
  2188. // 清理已读回执监听
  2189. if (this.handleMessageReadByPeer) {
  2190. timManager.offMessageRead(this.handleMessageReadByPeer);
  2191. console.log('✅ 已通过 timManager 清理已读回执监听');
  2192. }
  2193. // 清理新消息监听
  2194. if (this.handleNewMessage) {
  2195. timManager.offMessage(this.handleNewMessage);
  2196. console.log('✅ 已清理新消息监听');
  2197. }
  2198. }
  2199. };
  2200. </script>
  2201. <style scoped>
  2202. /* 弹窗遮罩 */
  2203. .modal-mask {
  2204. position: fixed;
  2205. top: 0;
  2206. left: 0;
  2207. right: 0;
  2208. bottom: 0;
  2209. background-color: rgba(0, 0, 0, 0.5);
  2210. z-index: 10000;
  2211. display: flex;
  2212. align-items: center;
  2213. justify-content: center;
  2214. }
  2215. /* 更多选项弹窗内容 */
  2216. .modal-content {
  2217. width: 70%;
  2218. background-color: #fff;
  2219. border-radius: 20rpx;
  2220. overflow: hidden;
  2221. }
  2222. .modal-header {
  2223. padding: 30rpx;
  2224. text-align: center;
  2225. border-bottom: 1px solid #eee;
  2226. }
  2227. .modal-title {
  2228. font-size: 32rpx;
  2229. font-weight: bold;
  2230. display: block;
  2231. }
  2232. .modal-subtitle {
  2233. font-size: 24rpx;
  2234. color: #999;
  2235. display: block;
  2236. margin-top: 10rpx;
  2237. }
  2238. .modal-body {
  2239. padding: 20rpx 0;
  2240. }
  2241. .modal-item {
  2242. display: flex;
  2243. align-items: center;
  2244. padding: 25rpx 40rpx;
  2245. font-size: 30rpx;
  2246. }
  2247. .modal-item.danger {
  2248. color: #fa5151;
  2249. }
  2250. .item-icon {
  2251. font-size: 32rpx;
  2252. margin-right: 20rpx;
  2253. }
  2254. .modal-footer {
  2255. padding: 20rpx;
  2256. border-top: 1px solid #eee;
  2257. }
  2258. .modal-close-btn {
  2259. width: 94%;
  2260. background-color: #f5f5f5;
  2261. border: none;
  2262. border-radius: 10rpx;
  2263. padding: 20rpx;
  2264. font-size: 30rpx;
  2265. }
  2266. /* 确认弹窗样式 */
  2267. .confirm-modal {
  2268. width: 60%;
  2269. background-color: #fff;
  2270. border-radius: 20rpx;
  2271. padding: 40rpx;
  2272. text-align: center;
  2273. }
  2274. .confirm-title {
  2275. font-size: 32rpx;
  2276. font-weight: bold;
  2277. margin-bottom: 30rpx;
  2278. }
  2279. .confirm-content {
  2280. font-size: 28rpx;
  2281. color: #666;
  2282. margin-bottom: 40rpx;
  2283. line-height: 1.5;
  2284. }
  2285. .confirm-buttons {
  2286. display: flex;
  2287. justify-content: space-between;
  2288. }
  2289. .confirm-btn {
  2290. flex: 1;
  2291. margin: 0 10rpx;
  2292. padding: 20rpx;
  2293. border-radius: 10rpx;
  2294. border: none;
  2295. font-size: 28rpx;
  2296. }
  2297. .confirm-btn.cancel {
  2298. background-color: #f5f5f5;
  2299. }
  2300. .confirm-btn.confirm {
  2301. background-color: #fa5151;
  2302. color: #fff;
  2303. }
  2304. .chat-page {
  2305. display: flex;
  2306. flex-direction: column;
  2307. height: 100vh;
  2308. background-color: #f5f5f5;
  2309. }
  2310. /* 顶部导航 */
  2311. .chat-header {
  2312. display: flex;
  2313. align-items: center;
  2314. justify-content: space-between;
  2315. padding: 20rpx 30rpx;
  2316. /* paddingTop 通过动态样式设置,适配刘海屏 */
  2317. background-color: #fff;
  2318. border-bottom: 1px solid #e5e5e5;
  2319. }
  2320. .header-left, .header-right {
  2321. width: 80rpx;
  2322. }
  2323. .icon-back {
  2324. font-size: 40rpx;
  2325. }
  2326. .header-center {
  2327. flex: 1;
  2328. text-align: center;
  2329. position: relative;
  2330. }
  2331. .title-wrapper {
  2332. display: inline-flex;
  2333. align-items: center;
  2334. gap: 6rpx;
  2335. position: relative;
  2336. }
  2337. .chat-title {
  2338. font-size: 36rpx;
  2339. font-weight: bold;
  2340. }
  2341. .title-arrow {
  2342. font-size: 39rpx;
  2343. color: #999;
  2344. }
  2345. .online-status {
  2346. display: block;
  2347. font-size: 24rpx;
  2348. color: #999;
  2349. margin-top: 5rpx;
  2350. }
  2351. .online-status.online {
  2352. color: #07c160;
  2353. }
  2354. /* 消息列表 */
  2355. .message-list {
  2356. flex: 1;
  2357. padding: 20rpx;
  2358. overflow-y: scroll;
  2359. }
  2360. .loading-tip {
  2361. text-align: center;
  2362. padding: 20rpx;
  2363. color: #999;
  2364. font-size: 28rpx;
  2365. }
  2366. .message-item {
  2367. display: flex;
  2368. margin-bottom: 30rpx;
  2369. }
  2370. .message-item.message-self {
  2371. flex-direction: row-reverse;
  2372. }
  2373. .avatar {
  2374. width: 80rpx;
  2375. height: 80rpx;
  2376. border-radius: 8rpx;
  2377. margin: 0 20rpx;
  2378. }
  2379. .message-content-wrapper {
  2380. max-width: 70%;
  2381. display: inline-block;
  2382. }
  2383. .message-item.message-self .message-content-wrapper {
  2384. text-align: right; /* 关键:让气泡和状态提示整体右对齐 */
  2385. }
  2386. .message-name {
  2387. display: block;
  2388. font-size: 24rpx;
  2389. color: #999;
  2390. margin-bottom: 10rpx;
  2391. }
  2392. .message-bubble {
  2393. padding: 20rpx;
  2394. border-radius: 8rpx;
  2395. background-color: #fff;
  2396. word-wrap: break-word;
  2397. display: inline-block; /* 关键:气泡宽度由内容决定 */
  2398. max-width: 100%; /* 防止内容过长超出容器 */
  2399. position: relative;
  2400. }
  2401. .message-self .message-bubble {
  2402. background-color: #95ec69;
  2403. border-top-right-radius: 4rpx;
  2404. }
  2405. .message-bubble.bubble-self {
  2406. background-color: #95ec69;
  2407. }
  2408. .message-text {
  2409. font-size: 30rpx;
  2410. line-height: 1.5;
  2411. }
  2412. .message-image {
  2413. max-width: 400rpx;
  2414. border-radius: 8rpx;
  2415. }
  2416. .message-voice {
  2417. display: flex;
  2418. align-items: center;
  2419. gap: 12rpx;
  2420. padding: 0rpx 0;
  2421. min-width: 30rpx;
  2422. max-width: 400rpx;
  2423. cursor: pointer;
  2424. -webkit-tap-highlight-color: transparent;
  2425. outline: none;
  2426. border: none;
  2427. position: relative;
  2428. }
  2429. .voice-duration {
  2430. font-size: 27rpx;
  2431. color: #333;
  2432. flex-shrink: 0;
  2433. line-height: 1;
  2434. }
  2435. .voice-icon-wrapper {
  2436. display: flex;
  2437. flex-shrink: 0;
  2438. line-height: 0;
  2439. position: relative;
  2440. top: 1rpx;
  2441. }
  2442. .voice-icon {
  2443. width: 22rpx;
  2444. height: 22rpx;
  2445. display: block;
  2446. transform: none;
  2447. animation: none;
  2448. }
  2449. .voice-icon-wrapper.playing .voice-icon {
  2450. animation: voice-bounce 0.6s ease-in-out infinite;
  2451. }
  2452. .voice-resume-btn {
  2453. position: absolute;
  2454. top: 50%;
  2455. transform: translateY(-50%);
  2456. width: 32rpx;
  2457. height: 32rpx;
  2458. background: #07c160;
  2459. border-radius: 50%;
  2460. display: flex;
  2461. align-items: center;
  2462. justify-content: center;
  2463. box-shadow: 0 2rpx 8rpx rgba(7, 193, 96, 0.3);
  2464. }
  2465. .voice-resume-btn.resume-btn-left {
  2466. left: -40rpx;
  2467. }
  2468. .voice-resume-btn.resume-btn-right {
  2469. right: -40rpx;
  2470. }
  2471. .resume-icon {
  2472. color: #fff;
  2473. font-size: 16rpx;
  2474. margin-left: 2rpx;
  2475. }
  2476. @keyframes voice-bounce {
  2477. 0%, 100% {
  2478. transform: scale(1);
  2479. }
  2480. 50% {
  2481. transform: scale(1.2);
  2482. }
  2483. }
  2484. .message-video {
  2485. width: 400rpx;
  2486. height: 300rpx;
  2487. }
  2488. .message-recalled {
  2489. color: #999;
  2490. font-size: 28rpx;
  2491. }
  2492. .message-status {
  2493. text-align: right;
  2494. font-size: 22rpx;
  2495. color: #999;
  2496. margin-top: 8rpx;
  2497. }
  2498. .message-status .status-unread {
  2499. color: #999;
  2500. }
  2501. .message-status .status-read {
  2502. color: #07c160;
  2503. }
  2504. .message-status .status-failed {
  2505. color: #fa5151;
  2506. }
  2507. .message-time {
  2508. display: block;
  2509. text-align: right;
  2510. font-size: 22rpx;
  2511. color: #ccc;
  2512. margin-top: 5rpx;
  2513. }
  2514. /* 输入框 */
  2515. .input-bar {
  2516. display: flex;
  2517. align-items: center;
  2518. padding: 20rpx;
  2519. background-color: #fff;
  2520. border-top: 1px solid #e5e5e5;
  2521. }
  2522. .input-icon {
  2523. width: 80rpx;
  2524. height: 80rpx;
  2525. display: flex;
  2526. align-items: center;
  2527. justify-content: center;
  2528. font-size: 50rpx;
  2529. }
  2530. .input-icon .icon-image {
  2531. width: 50rpx;
  2532. height: 50rpx;
  2533. }
  2534. .send-button {
  2535. padding: 0 30rpx;
  2536. height: 70rpx;
  2537. line-height: 70rpx;
  2538. background-color: #07c160;
  2539. color: #fff;
  2540. border-radius: 8rpx;
  2541. text-align: center;
  2542. font-size: 28rpx;
  2543. margin-left: 10rpx;
  2544. transition: opacity 0.3s;
  2545. }
  2546. .send-button.disabled {
  2547. opacity: 0.5;
  2548. background-color: #95ec69;
  2549. }
  2550. .input-field {
  2551. flex: 1;
  2552. height: 70rpx;
  2553. padding: 0 20rpx;
  2554. background-color: #f5f5f5;
  2555. border-radius: 8rpx;
  2556. font-size: 30rpx;
  2557. }
  2558. .voice-button {
  2559. flex: 1;
  2560. height: 70rpx;
  2561. line-height: 70rpx;
  2562. background-color: #f5f5f5;
  2563. border-radius: 8rpx;
  2564. text-align: center;
  2565. font-size: 30rpx;
  2566. border: none;
  2567. }
  2568. /* 表情面板 */
  2569. .emoji-panel {
  2570. display: flex;
  2571. flex-wrap: wrap;
  2572. padding: 20rpx;
  2573. background-color: #fff;
  2574. border-top: 1px solid #e5e5e5;
  2575. }
  2576. .emoji-item {
  2577. width: 80rpx;
  2578. height: 80rpx;
  2579. display: flex;
  2580. align-items: center;
  2581. justify-content: center;
  2582. font-size: 60rpx;
  2583. }
  2584. /* 更多功能面板 */
  2585. .more-panel {
  2586. display: flex;
  2587. padding: 40rpx;
  2588. background-color: #fff;
  2589. border-top: 1px solid #e5e5e5;
  2590. }
  2591. .more-item {
  2592. display: flex;
  2593. flex-direction: column;
  2594. align-items: center;
  2595. margin: 0 40rpx;
  2596. }
  2597. .more-icon {
  2598. width: 100rpx;
  2599. height: 100rpx;
  2600. display: flex;
  2601. align-items: center;
  2602. justify-content: center;
  2603. font-size: 60rpx;
  2604. background-color: #f5f5f5;
  2605. border-radius: 16rpx;
  2606. margin-bottom: 10rpx;
  2607. }
  2608. .more-text {
  2609. font-size: 24rpx;
  2610. color: #666;
  2611. }
  2612. /* 时间分隔线 */
  2613. .time-divider {
  2614. text-align: center;
  2615. padding: 20rpx 0;
  2616. }
  2617. .time-text {
  2618. display: inline-block;
  2619. padding: 8rpx 20rpx;
  2620. background-color: rgba(0, 0, 0, 0.1);
  2621. border-radius: 8rpx;
  2622. font-size: 22rpx;
  2623. color: #999;
  2624. }
  2625. /* 消息气泡失败状态 */
  2626. .bubble-failed {
  2627. background-color: #95ec69;
  2628. opacity: 0.6;
  2629. border: 2rpx solid #fa5151;
  2630. }
  2631. /* 消息状态优化 */
  2632. .message-status .sending {
  2633. color: #576b95;
  2634. }
  2635. .message-status .failed-group {
  2636. display: flex;
  2637. align-items: center;
  2638. gap: 10rpx;
  2639. }
  2640. .message-status .retry-btn {
  2641. color: #576b95;
  2642. text-decoration: underline;
  2643. cursor: pointer;
  2644. }
  2645. /* 消息操作菜单遮罩 */
  2646. .message-action-mask {
  2647. position: fixed;
  2648. top: 0;
  2649. left: 0;
  2650. right: 0;
  2651. bottom: 0;
  2652. background-color: rgba(0, 0, 0, 0.4);
  2653. z-index: 9999;
  2654. display: flex;
  2655. align-items: center;
  2656. justify-content: center;
  2657. }
  2658. /* 消息操作菜单 */
  2659. .message-action-menu {
  2660. width: 600rpx;
  2661. background-color: #fff;
  2662. border-radius: 20rpx;
  2663. overflow: hidden;
  2664. }
  2665. .message-action-menu .menu-item {
  2666. display: flex;
  2667. align-items: center;
  2668. justify-content: center;
  2669. gap: 15rpx;
  2670. padding: 30rpx;
  2671. font-size: 32rpx;
  2672. color: #333;
  2673. border-bottom: 1rpx solid #f0f0f0;
  2674. transition: background-color 0.2s;
  2675. }
  2676. .message-action-menu .menu-item:active {
  2677. background-color: #f5f5f5;
  2678. }
  2679. .message-action-menu .menu-item.cancel {
  2680. color: #999;
  2681. border-bottom: none;
  2682. margin-top: 20rpx;
  2683. border-top: 10rpx solid #f5f5f5;
  2684. }
  2685. .message-action-menu .menu-icon {
  2686. font-size: 36rpx;
  2687. }
  2688. /* 消息发送限制提示 */
  2689. .message-limit-tip {
  2690. padding: 10rpx 20rpx;
  2691. font-size: 24rpx;
  2692. text-align: center;
  2693. background-color: #fff;
  2694. border-bottom: 1px solid #f5f5f5;
  2695. }
  2696. .vip-tip {
  2697. color: #ff9500; /* VIP橙色提示 */
  2698. }
  2699. .limit-tip {
  2700. color: #666; /* 普通灰色提示 */
  2701. }
  2702. .vip-link {
  2703. color: #2c9fff; /* 蓝色文字 */
  2704. text-decoration: underline; /* 下划线 */
  2705. cursor: pointer; /* 鼠标悬浮时显示手型 */
  2706. margin-left: 5rpx;
  2707. }
  2708. /* 点击时添加轻微反馈 */
  2709. .vip-link:active {
  2710. opacity: 0.7; /* 点击时透明度降低 */
  2711. }
  2712. /* 录音中提示遮罩 */
  2713. .voice-recording-mask {
  2714. position: fixed;
  2715. top: 0;
  2716. left: 0;
  2717. right: 0;
  2718. bottom: 0;
  2719. background-color: rgba(0, 0, 0, 0.6);
  2720. z-index: 10000;
  2721. display: flex;
  2722. align-items: center;
  2723. justify-content: center;
  2724. }
  2725. /* 录音提示框 */
  2726. .voice-recording-box {
  2727. width: 300rpx;
  2728. height: 300rpx;
  2729. background-color: rgba(0, 0, 0, 0.8);
  2730. border-radius: 20rpx;
  2731. display: flex;
  2732. flex-direction: column;
  2733. align-items: center;
  2734. justify-content: center;
  2735. color: #fff;
  2736. transition: background-color 0.3s;
  2737. }
  2738. .voice-recording-box.canceling {
  2739. background-color: rgba(220, 38, 38, 0.9);
  2740. }
  2741. .voice-wave-container {
  2742. display: flex;
  2743. align-items: center;
  2744. justify-content: center;
  2745. gap: 8rpx;
  2746. height: 100rpx;
  2747. margin-bottom: 20rpx;
  2748. }
  2749. .voice-wave-bar {
  2750. width: 6rpx;
  2751. background: #fff;
  2752. border-radius: 3rpx;
  2753. transition: transform 0.1s ease-out;
  2754. transform-origin: center;
  2755. }
  2756. .voice-wave-bar.bar1 {
  2757. height: 30rpx;
  2758. }
  2759. .voice-wave-bar.bar2 {
  2760. height: 45rpx;
  2761. }
  2762. .voice-wave-bar.bar3 {
  2763. height: 60rpx;
  2764. }
  2765. .voice-wave-bar.bar4 {
  2766. height: 50rpx;
  2767. }
  2768. .voice-wave-bar.bar5 {
  2769. height: 35rpx;
  2770. }
  2771. .voice-text {
  2772. font-size: 32rpx;
  2773. margin-bottom: 10rpx;
  2774. }
  2775. .voice-time {
  2776. font-size: 48rpx;
  2777. font-weight: bold;
  2778. color: #07c160;
  2779. margin: 10rpx 0;
  2780. }
  2781. .voice-tip {
  2782. font-size: 24rpx;
  2783. color: #ccc;
  2784. }
  2785. </style>