index.vue 40 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459
  1. <template>
  2. <div
  3. class="container"
  4. :style="{ height: `calc(100% - ${currentBottomHeight}px)` }"
  5. >
  6. <div class="chat-bg"></div>
  7. <!-- 顶部导航 -->
  8. <div class="header-chat">
  9. <svg-icon class="page-icon" name="lf-arrow" @click="goBack" />
  10. <div class="header-title" v-if="wsStore.toUserInfo.type == 'group'"><span class="m-ellipsis">{{ wsStore.toUserInfo.sessionName || '群聊' }}</span>({{ groupMembersArr.length }})</div>
  11. <div v-else class="header-title">{{ wsStore.toUserInfo.sessionName }}</div>
  12. <svg-icon class="page-icon" name="more" @click="goDetail" />
  13. </div>
  14. <!-- 群公告 -->
  15. <div class="groupNotice" v-if="!noticeRead && wsStore.toUserInfo.type == 'group' && wsStore.toUserInfo.notice" @click="showNotice = true">
  16. <div class="m-ellipsis">群公告:{{ wsStore.toUserInfo.notice }}</div>
  17. <svg-icon class="item-icon" name="right1" />
  18. </div>
  19. <!-- 聊天消息区域 -->
  20. <div class="chat-list" ref="chatListRef">
  21. <van-loading class="load-box" size="24px" v-if="wsStore.loading">加载中...</van-loading>
  22. <template v-else>
  23. <van-pull-refresh v-model="refreshLoading" @refresh="onRefresh" style="overflow: initial;">
  24. <div v-for="(item, index) in imMessages" :key="index">
  25. <div class="chat-time">
  26. {{ item.createDate || formatTime(Date.now()) }}
  27. </div>
  28. <div class="box" v-if="item.contentType !== MsgType.MSG_TYPE.NOTICE">
  29. <div
  30. class="list-item"
  31. :class="isSender(item) ? '' : 'flex-reverse'"
  32. >
  33. <!-- 头像 -->
  34. <van-image
  35. class="list-img"
  36. :class="isSender(item) ? 'mr12' : 'ml12'"
  37. round
  38. :src="formatAvatarUrl(item)"
  39. @click="goToPage(item)"
  40. />
  41. <!-- 内容 -->
  42. <div class="list-cont">
  43. <div>{{ item.sender?item.sender.nickname: (item.nickname || item.fromUsername || "匿名用户") }}</div>
  44. <div v-if="!item.isTemp " style="position: relative;">
  45. <!-- 文本消息 -->
  46. <div class="content" v-if="item.contentType === MsgType.MSG_TYPE.TEXT" @click="onLongPress(item)">
  47. <span v-if="item.err" style="color: red;font-size: small;">{{item.messageType == MsgType.MESSAGE_TYPE_USER?'对方已不是您的好友':'您已不在群里'}}</span>
  48. {{ item.content }}
  49. </div>
  50. <!-- 图片消息 -->
  51. <div
  52. class="img-message"
  53. v-else-if="item.contentType === MsgType.MSG_TYPE.IMAGE"
  54. >
  55. <van-image fit="cover"
  56. :src="item?.localUrl || IM_PATH + item.url"
  57. style="width: 120px;height: 150px; border-radius: 8px"
  58. @click="previewImage(item)"
  59. />
  60. </div>
  61. <!-- 名片消息 -->
  62. <div
  63. class="content card-message"
  64. v-else-if="item.contentType === 3"
  65. >
  66. <div class="card-title">名片</div>
  67. <div class="card-name">{{ item.content }}</div>
  68. </div>
  69. <!-- 录音消息 -->
  70. <div
  71. class="audio-message"
  72. v-else-if="item.contentType === MsgType.MSG_TYPE.AUDIO"
  73. >
  74. <messageAudio
  75. :src="item?.localUrl || IM_PATH + item.url"
  76. :isSender="isSender(item)"
  77. />
  78. </div>
  79. <!-- 拍摄 -->
  80. <video class="video-message" :isSender="isSender(item)" controls v-else-if="item.contentType === MsgType.MSG_TYPE.VIDEO" :src="item?.localUrl || IM_PATH + item.url"></video>
  81. <!-- 语音消息 -->
  82. <div class="content" v-if="item.contentType === Constant.REJECT_AUDIO_ONLINE || item.contentType === Constant.REJECT_VIDEO_ONLINE">[对方拒绝]</div>
  83. <div class="content" v-if="item.contentType === Constant.CANCELL_AUDIO_ONLINE || item.contentType === Constant.CANCELL_VIDEO_ONLINE">[通话结束]</div>
  84. <div v-if="item.quote > 0" class="quotation">{{ item.quoteMsg?.content }}</div>
  85. </div>
  86. <!-- 阅后即焚消息 -->
  87. <div
  88. v-else
  89. class="burn-message"
  90. @click="viewBurnMessage(item)"
  91. >
  92. <!-- 未查看 -->
  93. <div v-if="!item.view && item.content" class="burn-mask">
  94. 阅后即焚,点击查看
  95. </div>
  96. <!-- 已查看且正在倒计时 -->
  97. <template v-else-if="item.view && item.countdown > 0 && item.content">
  98. <!-- 文本类型 -->
  99. <div v-if="item.contentType === MsgType.MSG_TYPE.TEXT" class="burn-content">
  100. {{ item.content }}
  101. <span class="burn-countdown">{{ item.countdown }}s</span>
  102. </div>
  103. <!-- 录音类型 -->
  104. <div v-else-if="item.contentType === MsgType.MSG_TYPE.AUDIO" class="burn-audio">
  105. <messageAudio
  106. :src="item?.localUrl || IM_PATH + item.url"
  107. :isSender="isSender(item)"
  108. />
  109. <span class="burn-countdown">{{ item.countdown }}s</span>
  110. </div>
  111. </template>
  112. <!-- 倒计时结束 -->
  113. <div v-else class="burn-destroyed">
  114. 该消息已焚毁
  115. </div>
  116. </div>
  117. </div>
  118. </div>
  119. </div>
  120. <div class="chat-time" v-if="item.contentType === MsgType.MSG_TYPE.NOTICE">
  121. {{ item.content }}
  122. </div>
  123. </div>
  124. </van-pull-refresh>
  125. </template>
  126. </div>
  127. <!-- 群公告组件 -->
  128. <GroupNotice
  129. v-model:show="showNotice"
  130. :notice="wsStore.toUserInfo.notice"
  131. @close="handleNoticeClose"
  132. />
  133. <!-- @页面 -->
  134. <AtUserList ref="atList" @select="onSelectUser" />
  135. <!-- 撤回弹窗 -->
  136. <van-action-sheet
  137. v-model:show="showActionSheet"
  138. :actions="actions"
  139. cancel-text="取消"
  140. @select="onActionSelect"
  141. />
  142. <!-- @样式 -->
  143. <div class="assign" v-if="wsStore.toUserInfo.type == 'group' && wsStore.isassign" @click="changAssign">
  144. <div class="assign-text">有人@我</div>
  145. </div>
  146. <!-- 引用消息展示 -->
  147. <div v-if="quoteMsg" class="quote-box">
  148. <div class="quote-content">{{ quoteMsg.fromUsername}}:{{ renderQuoteContent(quoteMsg) }}</div>
  149. <span class="quote-close" @click="cancelQuote">✕</span>
  150. </div>
  151. <!-- 输入框 -->
  152. <div class="page-foot">
  153. <div class="flex-box">
  154. <!-- 录音/文字切换按钮 -->
  155. <svg-icon
  156. type="button"
  157. class="page-icon"
  158. :name="voiceMode ? 'keyboard' : 'voice'"
  159. @click="toggleVoiceMode"
  160. />
  161. <!-- 文字输入框 或 按住说话按钮 -->
  162. <div class="box-input">
  163. <template v-if="!voiceMode">
  164. <van-field
  165. rows="1"
  166. type="textarea"
  167. :border="false"
  168. autosize
  169. class="input"
  170. v-model="text"
  171. @focus="onFocus"
  172. placeholder="输入文本"
  173. />
  174. <!-- :disabled="deletefriend.includes(wsStore.toUserInfo.uuid)" -->
  175. </template>
  176. <template v-else>
  177. <div
  178. class="hold-talk-btn"
  179. @touchstart.prevent.stop="handleTouchStart"
  180. @touchmove.prevent.stop="handleTouchMove"
  181. @touchend.prevent.stop="handleTouchEnd"
  182. >
  183. 按住说话
  184. </div>
  185. </template>
  186. </div>
  187. <van-button class="sendText"
  188. v-if="text.trim()"
  189. type="primary"
  190. size="small"
  191. round
  192. @click="sendMessageText"
  193. >
  194. 发送
  195. </van-button>
  196. <template v-else>
  197. <svg-icon
  198. class="page-icon mr12 emoji-toggle"
  199. name="emoji"
  200. @click="toggleAppBox(1)"
  201. />
  202. <svg-icon v-if="showBurn" @click="closeMessaageBurning"
  203. class="page-icon-qx"
  204. name="qx"
  205. />
  206. <svg-icon v-else
  207. class="page-icon tools-toggle"
  208. name="add2"
  209. @click="toggleAppBox(2)"
  210. />
  211. </template>
  212. </div>
  213. <!-- 录音状态浮层 -->
  214. <div v-if="recording" class="recording-toast">
  215. <div class="mic-icon"></div>
  216. <div v-if="cancelRecording" class="cancel-msg">松开手指,取消发送</div>
  217. <div v-else class="send-msg">手指上滑,取消发送</div>
  218. </div>
  219. <!-- 表情面板 -->
  220. <div
  221. class="app-box"
  222. v-show="showEmoji"
  223. :style="{ height: `${appBoxHeight}px` }"
  224. ref="emojiRef"
  225. >
  226. <div>
  227. <span
  228. v-for="(emoji, index) in emojis"
  229. :key="index"
  230. class="emoji-item"
  231. @click="insertEmoji(emoji)"
  232. >
  233. {{ emoji }}
  234. </span>
  235. </div>
  236. </div>
  237. <!-- 工具栏面板 -->
  238. <div
  239. class="app-box"
  240. v-show="showTools"
  241. :style="{ height: `${appBoxHeight}px` }"
  242. ref="toolsRef"
  243. >
  244. <div class="tool-btn">
  245. <van-uploader :after-read="afterRead" :before-read="beforeRead">
  246. <svg-icon class="tool-icon" name="tp" />
  247. </van-uploader>
  248. <div>图片</div>
  249. </div>
  250. <div class="tool-btn" @click="chatVideo = true">
  251. <svg-icon class="tool-icon" name="ps" />
  252. <div>拍摄</div>
  253. </div>
  254. <div class="tool-btn" v-if="wsStore.toUserInfo.type == 'user'">
  255. <svg-icon
  256. class="tool-icon"
  257. name="yyth"
  258. @click="startAudioOnline(Constant.DIAL_AUDIO_ONLINE, 'audio')"
  259. />
  260. <div>语音通话</div>
  261. </div>
  262. <div class="tool-btn" v-if="wsStore.toUserInfo.type == 'user'">
  263. <svg-icon
  264. class="tool-icon"
  265. name="spth"
  266. @click="startAudioOnline(Constant.DIAL_VIDEO_ONLINE, 'video')"
  267. />
  268. <div>视频通话</div>
  269. </div>
  270. <!-- <div class="tool-btn">
  271. <svg-icon class="tool-icon" name="mp" />
  272. <div>名片</div>
  273. </div> -->
  274. <div class="tool-btn" @click="openMessageBurning">
  275. <svg-icon class="tool-icon" name="mp" />
  276. <div>阅后即焚</div>
  277. </div>
  278. </div>
  279. </div>
  280. <VideoRecorder
  281. :show="chatVideo"
  282. @closeVideo="chatVideo = false"
  283. @sendVideo="handleSendVideo"
  284. />
  285. </div>
  286. </template>
  287. <script setup>
  288. import { useRouter, useRoute } from "vue-router";
  289. import { useWebSocketStore } from "@/stores/modules/webSocketStore.js";
  290. import { useWalletStore } from "@/stores/modules/walletStore.js";
  291. import { Keyboard } from "@capacitor/keyboard";
  292. import { Capacitor } from "@capacitor/core";
  293. import * as MsgType from "@/common/constant/msgType";
  294. import messageAudio from "@/views/im/components/messageAudio/index.vue";
  295. import { showToast, showImagePreview } from "vant";
  296. import { useWebRTCStore } from "@/stores/modules/webrtcStore";
  297. import * as Constant from "@/common/constant/Constant";
  298. import { soundVoice } from "@/utils/notifications.js";
  299. import AtUserList from "./components/AtUserList/index.vue";
  300. import GroupNotice from "./components/GroupNotice/index.vue";
  301. import VideoRecorder from "./components/VideoRecorder/index.vue";
  302. import { messageRevoke } from '@/api/path/im.api';
  303. const IM_PATH = import.meta.env.VITE_IM_PATH_FIlE;
  304. // 路由 & store
  305. const router = useRouter();
  306. const route = useRoute();
  307. const wsStore = useWebSocketStore();
  308. const walletStore = useWalletStore();
  309. const rtcStore = useWebRTCStore();
  310. // 输入框文本
  311. const text = ref("");
  312. // 状态管理
  313. const refreshLoading = ref(false);
  314. const keyboardHeight = ref(0);
  315. const showEmoji = ref(false);
  316. const showTools = ref(false);
  317. const appBoxHeight = ref(210);
  318. const chatListRef = ref(null);
  319. const emojiRef = ref(null);
  320. const toolsRef = ref(null);
  321. // 听筒模式
  322. const earMode = ref(false);
  323. // @成员
  324. const ccMsg = ref([]);
  325. // 消息置顶
  326. const stickTop = ref(0);
  327. // 引用消息
  328. const quoteMsg = ref(null);
  329. // 表情数组
  330. const emojis = [
  331. "😀", "😃", "😄", "😁", "😆", "😅", "😂", "🤣",
  332. "😊", "😇", "🙂", "🙃", "😉", "😌", "😍","😜","🤪","🫣","🤔","🤤","🥲","😋",
  333. "😀", "😃", "😄", "😁", "😆", "😅", "😂", "🤣",
  334. ];
  335. const atList = ref();
  336. const showBurn = ref(false) // 阅后即焚是否开启
  337. const showNotice = ref(false);
  338. const noticeRead = ref(false);
  339. const currentMsg = ref('');
  340. const showActionSheet = ref(false)
  341. const actions = ref([])
  342. const chatVideo = ref(false);
  343. // 下拉刷新
  344. const onRefresh = async () => {
  345. await wsStore.getMessages({
  346. uuid: route.query.uuid,
  347. messageType: wsStore.toUserInfo.type == 'user'?1:2, //1:个人 2:群组
  348. friendUsername: walletStore.account
  349. },true);
  350. };
  351. const formatAvatarUrl = (item) => {
  352. const url = item?.sender?.avatar || item?.fromAvatar || ''
  353. if (/^https?:\/\//.test(url)) {
  354. return url
  355. }
  356. return IM_PATH + url
  357. }
  358. const isSender = (item) => {
  359. if(item.align == 'left'){
  360. return true
  361. }else{
  362. return false
  363. }
  364. };
  365. const imMessages = computed(() =>{
  366. wsStore.funUpdateUnread(wsStore.toUserInfo.uuid);
  367. return wsStore.messages.map(item=>{
  368. item.quoteMsg = null;
  369. if (item.quote>0){
  370. item.quoteMsg = wsStore.messages.find(val => val.id == item.quote)
  371. }
  372. return item;
  373. });
  374. })
  375. const changAssign = () => {
  376. wsStore.isassign = '';
  377. }
  378. // 滚动到底部
  379. const scrollToBottom = () => {
  380. nextTick(() => {
  381. if (chatListRef.value) {
  382. const el = chatListRef.value;
  383. // el.scrollTop = el.scrollHeight;
  384. setTimeout(() => {
  385. el.scrollTo({
  386. left: 0, top: el.scrollHeight + 100,behavior: 'smooth'
  387. });
  388. }, 200);
  389. }
  390. });
  391. };
  392. watch(
  393. () => wsStore.indexs,
  394. (newVal,oldVal) => {
  395. if (newVal !== oldVal) {
  396. scrollToBottom();
  397. }
  398. }
  399. );
  400. watch(
  401. () => rtcStore.isEarpieceMode,
  402. (newVal) => {
  403. earMode.value = newVal;
  404. }
  405. );
  406. // 平台判断
  407. const isMobile = Capacitor.getPlatform() !== "web";
  408. // 计算当前底部总高度
  409. const currentBottomHeight = computed(() => {
  410. if (keyboardHeight.value > 0) return keyboardHeight.value;
  411. if (showEmoji.value || showTools.value) return appBoxHeight.value;
  412. return 0;
  413. });
  414. // 切换表情/工具面板
  415. const toggleAppBox = async (type) => {
  416. if(!wsStore.verifyChatAuth(wsStore.toUserInfo.uuid)){
  417. showToast(`${wsStore.toUserInfo.type == 'user' ? '对方已删除' : '您已不在群聊里面'}`);
  418. return;
  419. }
  420. voiceMode.value = false;
  421. if (isMobile) await Keyboard.hide();
  422. keyboardHeight.value = 0;
  423. if (type === 1) {
  424. showEmoji.value = !showEmoji.value;
  425. showTools.value = false;
  426. } else {
  427. showTools.value = !showTools.value;
  428. showEmoji.value = false;
  429. }
  430. scrollToBottom();
  431. };
  432. // 插入表情
  433. const insertEmoji = (emoji) => {
  434. text.value += emoji;
  435. };
  436. // 预览图片
  437. const previewImage = (item) => {
  438. const imageList = wsStore.messages
  439. .filter((m) => m.contentType === MsgType.MSG_TYPE.IMAGE)
  440. .map((m) => m.localUrl || IM_PATH + m.url);
  441. const index = imageList.findIndex(
  442. (url) => url === (item.localUrl || IM_PATH + item.url)
  443. );
  444. showImagePreview({
  445. images: imageList,
  446. startPosition: index,
  447. });
  448. };
  449. const onFocus = () => {
  450. // 隐藏所有面板
  451. showEmoji.value = false;
  452. showTools.value = false;
  453. if (isMobile) setupKeyboardListeners();
  454. scrollToBottom();
  455. };
  456. // 键盘监听
  457. const setupKeyboardListeners = async () => {
  458. Keyboard.addListener("keyboardWillShow", (info) => {
  459. keyboardHeight.value = info.keyboardHeight;
  460. });
  461. Keyboard.addListener("keyboardWillHide", () => {
  462. keyboardHeight.value = 0;
  463. });
  464. };
  465. // 设置消息引用
  466. const handleQuote = (item) => {
  467. quoteMsg.value = item //imMessages.find(m => m.id == item.id)
  468. };
  469. // 取消消息引用
  470. const cancelQuote = () => {
  471. quoteMsg.value = null; // 发送即删除
  472. };
  473. // 切换输出模式 true 听筒,false 扬声器
  474. const changeAudioOutputMode = async (flag) => {
  475. await rtcStore.setAudioOutputToEarpiece(flag);
  476. };
  477. // 群消息置顶操作
  478. const groupMessageStickTop = (message)=>{
  479. if(wsStore.toUserInfo.type == 'user'){
  480. return;
  481. }
  482. wsStore.sendMessage({
  483. content: JSON.stringify({
  484. content: message.content,
  485. id: message.id, // 消息id
  486. sticks: message.id, // 置顶
  487. }),
  488. contentType: message.contentType,
  489. messageType: MsgType.MESSAGE_STICKY_GROUP,
  490. });
  491. scrollToBottom();
  492. }
  493. // 开启消息阅后即焚
  494. const openMessageBurning = ()=>{
  495. showBurn.value = true
  496. showTools.value = false
  497. }
  498. // 关闭消息阅后即焚
  499. const closeMessaageBurning = ()=>{
  500. showBurn.value = false
  501. }
  502. // 撤回消息
  503. const revokeMessage = async (message)=>{
  504. try {
  505. const res = await messageRevoke({
  506. messageId:message.id + '',
  507. uuid:message.fromUuid,
  508. toUuid:message.toUuid,
  509. messageType:message.messageType
  510. })
  511. if(res.code == 200){
  512. wsStore.sendMessage({
  513. content: JSON.stringify({
  514. // content: '',
  515. // msgId: message.id + '',
  516. id: message.id,
  517. fromUsername:message.fromUsername,
  518. }),
  519. contentType: MsgType.MSG_TYPE.NOTICE,
  520. messageType: wsStore.toUserInfo.type == 'user'?MsgType.MESSAGE_REVOKE:MsgType.MESSAGE_REVOKE_GROUP,
  521. });
  522. }else{
  523. showToast('撤回失败')
  524. }
  525. } catch (error) {
  526. showToast('撤回失败')
  527. }
  528. }
  529. // 点击查看阅后即焚消息
  530. const viewBurnMessage = (message) => {
  531. // console.log("查看阅后即焚消息:", message);
  532. if (!message.view) {
  533. message.view = true;
  534. message.countdown = 15; // 倒计时
  535. // 如果是录音,可以自动播放
  536. if (message.contentType === MsgType.MSG_TYPE.AUDIO) {
  537. const audio = new Audio(message?.localUrl || IM_PATH + message.url);
  538. audio.play();
  539. }
  540. const timer = setInterval(() => {
  541. if (message.countdown > 1) {
  542. message.countdown--;
  543. } else {
  544. clearInterval(timer);
  545. destroyMessage(message); // 发撤回通知
  546. message.content = '';
  547. message.countdown = 0; // 标记焚毁
  548. }
  549. }, 1000);
  550. }
  551. };
  552. // 真正销毁
  553. const destroyMessage = (message) => {
  554. message.content = ''; // 先清空本地显示
  555. // 如果是群消息, 本地更新消息销毁
  556. if (message.messageType == MsgType.MESSAGE_TYPE_GROUP) {
  557. const msg = {isTemp: true, content: '', contentType: message.contentType, messageType: message.messageType};
  558. // 更新消息
  559. wsStore.modifyMessageId(msg, message, wsStore.toUserInfo.uuid);
  560. // 更新会话列表
  561. wsStore.updateSessionNewMessage(msg, wsStore.toUserInfo.uuid);
  562. return;
  563. }
  564. wsStore.sendMessage({
  565. content: JSON.stringify({
  566. // content: '',
  567. // msgId: message.id + '',
  568. id: message.id,
  569. isTemp: true,
  570. }),
  571. contentType: MsgType.MSG_TYPE.NOTICE,
  572. messageType: wsStore.toUserInfo.type == 'user'
  573. ? MsgType.MESSAGE_REVOKE
  574. : MsgType.MESSAGE_REVOKE_GROUP,
  575. });
  576. };
  577. // 当子组件关闭时触发---关闭群公告
  578. const handleNoticeClose = () => {
  579. showNotice.value = false
  580. // noticeRead.value = true
  581. }
  582. // 录音相关状态
  583. const voiceMode = ref(false); // false: 文字输入, true: 语音模式
  584. const recording = ref(false);
  585. const cancelRecording = ref(false);
  586. const startY = ref(0);
  587. let mediaRecorder = null;
  588. let audioChunks = [];
  589. // 切换文字/语音输入模式
  590. const toggleVoiceMode = () => {
  591. if(!wsStore.verifyChatAuth(wsStore.toUserInfo.uuid)){
  592. showToast(`${wsStore.toUserInfo.type == 'user' ? '对方已删除' : '您已不在群聊里面'}`);
  593. return;
  594. }
  595. voiceMode.value = !voiceMode.value;
  596. // 切换时关闭表情和工具面板,隐藏键盘
  597. showEmoji.value = false;
  598. showTools.value = false;
  599. if (isMobile) Keyboard.hide();
  600. keyboardHeight.value = 0;
  601. scrollToBottom();
  602. };
  603. // 录音事件
  604. const handleTouchStart = async (e) => {
  605. startY.value = e.touches[0].clientY;
  606. cancelRecording.value = false;
  607. recording.value = true;
  608. audioChunks = [];
  609. try {
  610. const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
  611. mediaRecorder = new MediaRecorder(stream, {
  612. mimeType: "audio/webm; codecs=opus",
  613. });
  614. mediaRecorder.ondataavailable = (ev) => {
  615. audioChunks.push(ev.data);
  616. };
  617. mediaRecorder.start(1000);
  618. console.log("开始录音");
  619. } catch (err) {
  620. console.error("麦克风权限获取失败", err);
  621. recording.value = false;
  622. }
  623. };
  624. // 录音
  625. const handleTouchMove = (e) => {
  626. const currentY = e.touches[0].clientY;
  627. if (startY.value - currentY > 50) {
  628. cancelRecording.value = true;
  629. } else {
  630. cancelRecording.value = false;
  631. }
  632. };
  633. // 录音发送
  634. const handleTouchEnd = () => {
  635. if (!mediaRecorder) return;
  636. mediaRecorder.stop();
  637. mediaRecorder.stream.getTracks().forEach((t) => t.stop());
  638. recording.value = false;
  639. mediaRecorder.onstop = async () => {
  640. if (cancelRecording.value) {
  641. console.log("录音取消");
  642. return;
  643. }
  644. if (audioChunks.length === 0) return;
  645. const audioBlob = new Blob(audioChunks, { type: "audio/webm" });
  646. const arrayBuffer = await audioBlob.arrayBuffer();
  647. const audioData = new Uint8Array(arrayBuffer);
  648. wsStore.sendMessage({
  649. // content: "",
  650. content: JSON.stringify({
  651. content: "",
  652. msgId: `ms${Date.now()}`, // 消息id
  653. quote: quoteMsg.value?.id, // 引用消息id
  654. cc: wsStore.toUserInfo.type == 'user'?"":ccMsg.value.join(","), // @成员
  655. isTemp: showBurn.value === true, // 消息阅后即焚
  656. }),
  657. contentType: MsgType.MSG_TYPE.AUDIO,
  658. messageType: wsStore.toUserInfo.type == 'user'?MsgType.MESSAGE_TYPE_USER:(ccMsg.value.length> 0?MsgType.MESSAGE_CC_GROUP:MsgType.MESSAGE_TYPE_GROUP),
  659. fileSuffix: "wav", // 使用webm后缀更准确
  660. file: audioData, // 将Uint8Array转为普通数组
  661. });
  662. cancelQuote(); // 取消引用
  663. ccMsg.value = []; // 清空 @成员
  664. scrollToBottom();
  665. console.log("语音已发送");
  666. };
  667. };
  668. // 监听输入 @
  669. watch(
  670. () => text.value,
  671. (val) => {
  672. if (val.slice(-1) === '@' && wsStore.toUserInfo.type != 'user') {
  673. atList.value.open(); // 打开选人弹窗
  674. }
  675. }
  676. );
  677. // 选择用户回填输入框
  678. const onSelectUser = (user) => {
  679. text.value += `${user.nickname} `;
  680. ccMsg.value.push(user)
  681. };
  682. const onLongPress = (msg) => {
  683. console.log(msg)
  684. if(msg.fromUuid != walletStore.account){
  685. actions.value = [
  686. { name: "复制", key: "copy" },
  687. { name: "引用", key: "quote" },
  688. // { name: "置顶", key: "stick" },
  689. ]
  690. }else{
  691. actions.value = [
  692. { name: "复制", key: "copy" },
  693. { name: "撤回", key: "revoke" },
  694. { name: "引用", key: "quote" },
  695. // { name: "置顶", key: "stick" },
  696. ]
  697. }
  698. currentMsg.value = msg;
  699. showActionSheet.value = true;
  700. };
  701. const onActionSelect = (action) => {
  702. if (!currentMsg.value) return;
  703. if (action.key === "copy") {
  704. navigator.clipboard.writeText(currentMsg.value.content);
  705. showToast("已复制");
  706. } else if (action.key === "revoke") {
  707. revokeMessage(currentMsg.value);
  708. currentMsg.value.revoked = true;
  709. } else if(action.key === "quote") {
  710. // 引用
  711. handleQuote(currentMsg.value)
  712. }
  713. showActionSheet.value = false;
  714. };
  715. // 渲染引用的内容(文字/图片等)
  716. const renderQuoteContent = (msg) => {
  717. if (msg.contentType === MsgType.MSG_TYPE.TEXT) {
  718. return msg.content;
  719. } else if (msg.contentType === MsgType.MSG_TYPE.IMAGE) {
  720. return "[图片]";
  721. } else if (msg.contentType === MsgType.MSG_TYPE.AUDIO) {
  722. return "[语音]";
  723. } else {
  724. return "[消息]";
  725. }
  726. };
  727. // 发送消息
  728. const sendMessageText = () => {
  729. if(!wsStore.verifyChatAuth(wsStore.toUserInfo.uuid)){
  730. showToast(`${wsStore.toUserInfo.type == 'user' ? '对方已删除' : '您已不在群聊里面'}`);
  731. return;
  732. }
  733. if (!text.value.trim()) return;
  734. // 检测消息@成员
  735. let cc = [];
  736. if(ccMsg.value.length>0 && wsStore.toUserInfo.type != 'user'){
  737. const matchs = text.value.match(/(@([^ ]+) )/ig) || [];
  738. cc = ccMsg.value.filter(val=> matchs.includes(`@${val.nickname} `)).map(val=>val.userId);
  739. // 检测@所有人
  740. if(matchs.includes(`@所有人 `)){
  741. cc = [0]
  742. }
  743. }
  744. // console.log(wsStore.toUserInfo);
  745. const message = {
  746. content: JSON.stringify({
  747. content: text.value,
  748. msgId: `ms${Date.now()}`, // 消息id
  749. quote: quoteMsg.value?.id, // 引用消息id
  750. cc: wsStore.toUserInfo.type == 'user'?"":cc.join(","), // @成员
  751. isTemp: showBurn.value === true, // 消息阅后即焚
  752. }),
  753. contentType: MsgType.MSG_TYPE.TEXT, // 1: 文本消息
  754. messageType: wsStore.toUserInfo.type == 'user'?MsgType.MESSAGE_TYPE_USER:(cc.length> 0?MsgType.MESSAGE_CC_GROUP:MsgType.MESSAGE_TYPE_GROUP),
  755. };
  756. wsStore.sendMessage(message);
  757. text.value = "";
  758. cancelQuote(); // 取消引用
  759. ccMsg.value = []; // 清空 @成员
  760. scrollToBottom();
  761. };
  762. // 发送图片消息
  763. const afterRead = async (file) => {
  764. if(!wsStore.verifyChatAuth(wsStore.toUserInfo.uuid)){
  765. showToast(`${wsStore.toUserInfo.type == 'user' ? '对方已删除' : '您已不在群聊里面'}`);
  766. return;
  767. }
  768. const arrayBuffer = await file.file.arrayBuffer();
  769. const message = {
  770. // content: text.value, // 如果有文本内容
  771. content: JSON.stringify({
  772. content: text.value,
  773. msgId: `ms${Date.now()}`, // 消息id
  774. quote: quoteMsg.value?.id, // 引用消息id
  775. cc: wsStore.toUserInfo.type == 'user'?"":ccMsg.value.join(","), // @成员
  776. isTemp: showBurn.value === true, // 消息阅后即焚
  777. }),
  778. contentType: MsgType.MSG_TYPE.IMAGE, // 音频消息类型
  779. messageType: wsStore.toUserInfo.type == 'user'?MsgType.MESSAGE_TYPE_USER:(ccMsg.value.length> 0?MsgType.MESSAGE_CC_GROUP:MsgType.MESSAGE_TYPE_GROUP),
  780. fileSuffix: file.file.type, // 使用webm后缀更准确
  781. file: new Uint8Array(arrayBuffer), // 将Uint8Array转为普通数组
  782. };
  783. cancelQuote(); // 取消引用
  784. ccMsg.value = []; // 清空 @成员
  785. wsStore.sendMessage(message);
  786. scrollToBottom();
  787. };
  788. // 图片类型
  789. const beforeRead = (file) => {
  790. const realFile = file.file || file;
  791. const type = realFile.type;
  792. const name = realFile.name || "";
  793. if (type === "image/svg+xml" || name.endsWith(".svg")) {
  794. showToast("不支持上传 SVG 格式的图片");
  795. return false;
  796. }
  797. return true;
  798. };
  799. // 发送视频
  800. const handleSendVideo = async (blob) =>{
  801. chatVideo.value = false;
  802. const arrayBuffer = await blob.arrayBuffer();
  803. const audioData = new Uint8Array(arrayBuffer);
  804. wsStore.sendMessage({
  805. // content: "",
  806. content: JSON.stringify({
  807. content: "",
  808. msgId: `ms${Date.now()}`, // 消息id
  809. quote: quoteMsg.value?.id, // 引用消息id
  810. cc: wsStore.toUserInfo.type == 'user'?"":ccMsg.value.join(","), // @成员
  811. isTemp: showBurn.value === true, // 消息阅后即焚
  812. }),
  813. contentType: MsgType.MSG_TYPE.VIDEO,
  814. messageType: wsStore.toUserInfo.type == 'user'?MsgType.MESSAGE_TYPE_USER:(ccMsg.value.length> 0?MsgType.MESSAGE_CC_GROUP:MsgType.MESSAGE_TYPE_GROUP),
  815. fileSuffix: "mp4", // 使用webm后缀更准确
  816. file: audioData, // 将Uint8Array转为普通数组
  817. });
  818. cancelQuote(); // 取消引用
  819. ccMsg.value = []; // 清空 @成员
  820. scrollToBottom();
  821. console.log("视频已发送");
  822. }
  823. // 创建呼叫:开启语音电话: 调试用,正式逻辑读src/views/im/hook/messagesHook.js
  824. // ==== 1. 发起语音通话 ====
  825. const startAudioOnline = async (contentType, streamType) => {
  826. // 清理被呼叫者信息
  827. wsStore.toUserAudioInfo = {
  828. uuid:walletStore.account,
  829. type: "user",
  830. // 自己的信息
  831. fromUuid: walletStore.account,
  832. fromAvatar: walletStore.avatar,
  833. fromUsername: wsStore.toUserInfo.nickname,
  834. // 拨号目标信息
  835. toUuid: wsStore.toUserInfo.uuid,
  836. toUsername:wsStore.toUserInfo.sessionName,
  837. toAvatar:wsStore.toUserInfo.avatar,
  838. // 发送者信息
  839. sender: {
  840. uuid: walletStore.account,
  841. avatar: walletStore.avatar,
  842. nickname: wsStore.toUserInfo.nickname,
  843. },
  844. };
  845. // wsStore.toUserInfo.sender = null;
  846. // 调起通话界面
  847. rtcStore.streamType = streamType
  848. rtcStore.imSate.videoCallModal = true;
  849. // 设置被呼叫对象
  850. rtcStore.imSate.callAvatar = wsStore.toUserAudioInfo.toAvatar;
  851. rtcStore.imSate.callName = wsStore.toUserAudioInfo.toUsername;
  852. // 设置呼叫者/被呼叫者
  853. rtcStore.isCaller = true;
  854. // 通知被呼叫者(接听者)
  855. wsStore.sendMessage({
  856. messageType: wsStore.toUserInfo.type == 'user'?MsgType.MESSAGE_TYPE_USER:MsgType.MESSAGE_TYPE_GROUP,
  857. contentType,
  858. type: Constant.MESSAGE_TRANS_TYPE,
  859. // avatar: walletStore.avatar,
  860. content: JSON.stringify({
  861. content: walletStore.username,
  862. sender: walletStore.account,
  863. }),
  864. fromUsername: wsStore.toUserAudioInfo.fromUsername,
  865. avatar: wsStore.toUserAudioInfo.fromAvatar,
  866. to: wsStore.toUserAudioInfo.toUuid,
  867. from: wsStore.toUserAudioInfo.fromUuid,
  868. });
  869. // 播放铃声
  870. soundVoice.play()
  871. // 开启本地视频
  872. if (streamType === 'video') {
  873. rtcStore.getUserMedia({ audio: true, video: true })
  874. }
  875. };
  876. // 时间格式化
  877. const formatTime = (timestamp) => {
  878. const date = new Date(timestamp);
  879. const h = date.getHours().toString().padStart(2, "0");
  880. const m = date.getMinutes().toString().padStart(2, "0");
  881. return `${h}:${m}`;
  882. };
  883. const groupMembersArr = computed(() => wsStore.groupMembersList[wsStore.toUserInfo.uuid] || []);
  884. // 页面生命周期
  885. onMounted(async () => {
  886. wsStore.toUserInfo.uuid = route.query.uuid;
  887. if (wsStore.toUserInfo.type == 'group') {
  888. await wsStore.fetchGroupMembers(route.query.uuid);
  889. }
  890. await wsStore.getMessages({
  891. uuid: route.query.uuid,
  892. messageType: wsStore.toUserInfo.type == 'user'?1:2, //1:个人 2:群组
  893. friendUsername: walletStore.account
  894. });
  895. scrollToBottom();
  896. document.addEventListener("click", handleClickOutside);
  897. });
  898. onUnmounted(() => {
  899. if (isMobile) Keyboard.removeAllListeners();
  900. });
  901. onBeforeUnmount(() => {
  902. document.removeEventListener("click", handleClickOutside);
  903. });
  904. // 判断是否点击在元素外
  905. const handleClickOutside = (event) => {
  906. const emojiEl = emojiRef.value;
  907. const toolsEl = toolsRef.value;
  908. const target = event.target;
  909. if (
  910. showEmoji.value &&
  911. emojiEl &&
  912. !emojiEl.contains(target) &&
  913. !target.closest(".emoji-toggle")
  914. ) {
  915. showEmoji.value = false;
  916. }
  917. if (
  918. showTools.value &&
  919. toolsEl &&
  920. !toolsEl.contains(target) &&
  921. !target.closest(".tools-toggle")
  922. ) {
  923. showTools.value = false;
  924. }
  925. };
  926. // 页面跳转
  927. const goToPage = (item) => {
  928. if(!(isSender(item))) return;
  929. router.push({
  930. path: 'personal',
  931. query:{
  932. uuid:wsStore.toUserInfo.type == 'user'?wsStore.toUserInfo.uuid:item.fromUuid,
  933. type:2
  934. }
  935. })
  936. }
  937. const goBack = () => router.push("im");
  938. const goDetail = () =>{
  939. if(!wsStore.verifyChatAuth(wsStore.toUserInfo.uuid)){
  940. showToast(`${wsStore.toUserInfo.type == 'user' ? '对方已删除' : '您已不在群聊里面'}`);
  941. return;
  942. }
  943. router.push({
  944. path: 'detail',
  945. query:{ status:wsStore.toUserInfo.type == 'user'?1:2 }
  946. }) // 1:单聊 2:群聊
  947. }
  948. </script>
  949. <style lang="less" scoped>
  950. .load-box{
  951. text-align: center !important;
  952. margin-top: 50px !important;
  953. }
  954. .mr12 {
  955. margin-right: 12px;
  956. }
  957. .ml12 {
  958. margin-left: 12px;
  959. }
  960. .text-right {
  961. text-align: right;
  962. }
  963. .page-icon {
  964. width: 24px;
  965. height: 24px;
  966. flex-shrink: 0;
  967. }
  968. .page-icon-qx{
  969. width: 22px;
  970. height: 22px;
  971. flex-shrink: 0;
  972. }
  973. .container {
  974. display: flex;
  975. flex-direction: column;
  976. .chat-bg {
  977. height: 126px;
  978. background: linear-gradient(90deg, @theme-color1 0%, #40a4fb 100%);
  979. position: absolute;
  980. left: 0;
  981. right: 0;
  982. z-index: -1;
  983. }
  984. .header-chat {
  985. padding-top: 56px;
  986. margin: 0 16px;
  987. display: flex;
  988. align-items: center;
  989. color: @theme-color1;
  990. .header-title {
  991. flex: 1;
  992. font-family:
  993. PingFang SC,
  994. PingFang SC;
  995. font-weight: 500;
  996. font-size: 19px;
  997. color: #ffffff;
  998. text-align: center;
  999. margin: 0 16px;
  1000. display: flex;
  1001. overflow: hidden;
  1002. justify-content: center;
  1003. align-items: center;
  1004. }
  1005. }
  1006. .chat-list {
  1007. background: #f7f8fa;
  1008. border-radius: 30px 30px 0 0;
  1009. flex: 1;
  1010. overflow: auto;
  1011. margin-top: 20px;
  1012. padding: 0 16px 24px;
  1013. .chat-time {
  1014. font-family:
  1015. PingFang SC,
  1016. PingFang SC;
  1017. font-weight: 400;
  1018. font-size: 12px;
  1019. color: #8d8d8d;
  1020. text-align: center;
  1021. margin: 20px 0;
  1022. }
  1023. .box {
  1024. .list-item {
  1025. display: flex;
  1026. margin-bottom: 24px;
  1027. .list-img {
  1028. width: 44px;
  1029. height: 44px;
  1030. flex-shrink: 0;
  1031. }
  1032. .list-cont {
  1033. font-family:
  1034. PingFang SC,
  1035. PingFang SC;
  1036. font-weight: 400;
  1037. font-size: 12px;
  1038. color: #8d8d8d;
  1039. max-width: 70%;
  1040. display: flex;
  1041. flex-direction: column;
  1042. align-items: flex-start;
  1043. .business-card {
  1044. width: 199px;
  1045. height: 93px;
  1046. background: #ffffff;
  1047. border-radius: 10px;
  1048. margin-top: 8px;
  1049. padding: 10px;
  1050. box-sizing: border-box;
  1051. .business-card-cont {
  1052. display: flex;
  1053. align-items: center;
  1054. font-family:
  1055. PingFang SC,
  1056. PingFang SC;
  1057. font-weight: 400;
  1058. font-size: 15px;
  1059. color: #000000;
  1060. }
  1061. .line {
  1062. height: 1px;
  1063. background: #f2f2f2;
  1064. margin: 10px 0 6px;
  1065. }
  1066. .business-card-text {
  1067. font-family:
  1068. PingFang SC,
  1069. PingFang SC;
  1070. font-weight: 400;
  1071. font-size: 10px;
  1072. color: #8d8d8d;
  1073. }
  1074. }
  1075. .content {
  1076. background: #ffffff; // 对方消息背景白色
  1077. color: #000;
  1078. border-radius: 10px;
  1079. margin-top: 8px;
  1080. padding: 8px 17px;
  1081. word-break: break-word;
  1082. white-space: pre-wrap;
  1083. max-width: 100%;
  1084. font-family:
  1085. PingFang SC,
  1086. PingFang SC;
  1087. font-weight: 400;
  1088. font-size: 15px;
  1089. }
  1090. .img-message {
  1091. margin-top: 8px;
  1092. }
  1093. .video-message{
  1094. margin-top: 8px;
  1095. width: 200px;
  1096. height: 200px;
  1097. // transform: scaleX(-1);
  1098. }
  1099. }
  1100. }
  1101. .withdrawal {
  1102. display: flex;
  1103. justify-content: center;
  1104. margin-bottom: 24px;
  1105. .withdrawal-text {
  1106. width: 142px;
  1107. height: 29px;
  1108. line-height: 29px;
  1109. box-sizing: border-box;
  1110. background: #f2f2f2;
  1111. border-radius: 4px;
  1112. font-family:
  1113. PingFang SC,
  1114. PingFang SC;
  1115. font-weight: 400;
  1116. font-size: 12px;
  1117. color: #8d8d8d;
  1118. text-align: center;
  1119. }
  1120. }
  1121. .flex-reverse {
  1122. flex-direction: row-reverse;
  1123. }
  1124. .flex-reverse .list-cont {
  1125. align-items: flex-end;
  1126. .content {
  1127. background: #4d71ff; // 自己的消息是蓝色
  1128. color: #ffffff; // 白字
  1129. }
  1130. }
  1131. }
  1132. }
  1133. .chat-list::-webkit-scrollbar {
  1134. width: 0;
  1135. }
  1136. .page-foot {
  1137. position: relative;
  1138. background-color: #fff;
  1139. .flex-box {
  1140. padding: 8px 16px 16px;
  1141. display: flex;
  1142. align-items: center;
  1143. box-sizing: border-box;
  1144. .box-input{
  1145. flex: 1;
  1146. margin: 0 12px;
  1147. }
  1148. .input {
  1149. flex: 1;
  1150. background: #f2f2f2;
  1151. border-radius: 17px;
  1152. border: 1px solid #d8d8d8;
  1153. padding: 6px 16px;
  1154. font-weight: 500;
  1155. font-size: 15px;
  1156. overflow-y: auto;
  1157. }
  1158. }
  1159. }
  1160. }
  1161. .app-box {
  1162. position: fixed;
  1163. bottom: 210px;
  1164. left: 0;
  1165. right: 0;
  1166. background: white;
  1167. transition: transform 0.3s ease;
  1168. transform: translateY(100%);
  1169. display: flex;
  1170. flex-wrap: wrap;
  1171. padding: 10px 16px;
  1172. overflow-y: auto;
  1173. box-sizing: border-box;
  1174. &.visible {
  1175. transform: translateY(0);
  1176. }
  1177. .tool-btn {
  1178. width: calc(100% / 4);
  1179. display: flex;
  1180. flex-direction: column;
  1181. align-items: center;
  1182. font-family:
  1183. PingFang SC,
  1184. PingFang SC;
  1185. font-weight: 400;
  1186. font-size: 12px;
  1187. color: #000000;
  1188. .tool-icon {
  1189. width: 56px;
  1190. height: 56px;
  1191. margin-bottom: 4px;
  1192. }
  1193. }
  1194. .emoji-item{
  1195. font-size: 20px;
  1196. margin: 0 4px;
  1197. }
  1198. }
  1199. .app-box::-webkit-scrollbar {
  1200. width: 0;
  1201. }
  1202. .page-foot {
  1203. position: relative;
  1204. z-index: 10; /* 确保输入框在上层 */
  1205. }
  1206. .local-video {
  1207. position: absolute;
  1208. bottom: 20px;
  1209. right: 20px;
  1210. width: 200px;
  1211. height: auto;
  1212. border: 2px solid white;
  1213. border-radius: 8px;
  1214. }
  1215. /* 按住说话按钮 */
  1216. .hold-talk-btn {
  1217. flex: 1;
  1218. text-align: center;
  1219. background: #f5f5f5;
  1220. padding: 6px 16px;
  1221. border-radius: 17px;
  1222. color: #666;
  1223. font-weight: 500;
  1224. font-size: 15px;
  1225. user-select: none;
  1226. -webkit-user-select: none;
  1227. -webkit-touch-callout: none;
  1228. line-height: 24px;
  1229. border: 1px solid #f5f5f5;
  1230. }
  1231. /* 录音中的浮层提示 */
  1232. .recording-toast {
  1233. position: fixed;
  1234. bottom: 80px;
  1235. left: 50%;
  1236. transform: translateX(-50%);
  1237. width: 160px;
  1238. min-height: 160px;
  1239. background: rgba(0, 0, 0, 0.85);
  1240. border-radius: 12px;
  1241. padding: 16px;
  1242. box-sizing: border-box;
  1243. text-align: center;
  1244. display: flex;
  1245. flex-direction: column;
  1246. align-items: center;
  1247. justify-content: center;
  1248. .mic-icon {
  1249. width: 60px;
  1250. height: 60px;
  1251. background: url("https://img.icons8.com/ios-filled/100/ffffff/microphone.png") no-repeat center center;
  1252. background-size: contain;
  1253. margin-bottom: 16px;
  1254. }
  1255. .send-msg {
  1256. color: #fff;
  1257. font-size: 14px;
  1258. }
  1259. .cancel-msg {
  1260. color: #ff4d4f;
  1261. font-size: 14px;
  1262. }
  1263. }
  1264. .m-ellipsis{
  1265. white-space: nowrap; /* 不换行 */
  1266. overflow: hidden; /* 超出隐藏 */
  1267. text-overflow: ellipsis; /* 超出显示省略号 */
  1268. }
  1269. .groupNotice{
  1270. margin-top: 15px;
  1271. background-color: #fff;
  1272. height: 40px;
  1273. line-height: 40px;
  1274. font-family: PingFang SC, PingFang SC;
  1275. font-weight: 400;
  1276. font-size: 12px;
  1277. color: #000000;
  1278. padding: 0 16px;
  1279. box-sizing: border-box;
  1280. display: flex;
  1281. justify-content: space-between;
  1282. align-items: center;
  1283. text-align: center;
  1284. .item-icon{
  1285. width: 20px;
  1286. height: 20px;
  1287. color: #969799;
  1288. }
  1289. }
  1290. .revoked-msg {
  1291. color: #999;
  1292. font-size: 12px;
  1293. font-style: italic;
  1294. text-align: center;
  1295. margin: 5px 0;
  1296. }
  1297. .burn-tag {
  1298. font-size: 10px;
  1299. color: red;
  1300. margin-left: 6px;
  1301. }
  1302. .assign{
  1303. margin-bottom: 20px;
  1304. display: flex;
  1305. justify-content: flex-end;
  1306. .assign-text{
  1307. color: #fff;
  1308. background: #4765dd;
  1309. border-radius: 30px 0 0 30px;
  1310. padding: 3px 10px;
  1311. }
  1312. }
  1313. // 引用样式
  1314. .quote-box {
  1315. display: flex;
  1316. align-items: center;
  1317. justify-content: space-between;
  1318. background: #f2f2f2;
  1319. border-radius: 6px;
  1320. padding: 4px 16px;
  1321. font-size: 14px;
  1322. color: #969696;
  1323. font-size: 12px;
  1324. }
  1325. .quote-content {
  1326. margin-bottom: 2px;
  1327. }
  1328. .quotation{
  1329. background: #f2f2f2;
  1330. border-radius: 5px;
  1331. padding: 5px 10px;
  1332. margin-top: 5px;
  1333. }
  1334. // 阅后即焚样式
  1335. .burn-message {
  1336. background: #fff;
  1337. color: #000;
  1338. border-radius: 10px;
  1339. margin-top: 8px;
  1340. padding: 8px 14px;
  1341. max-width: 100%;
  1342. font-size: 15px;
  1343. position: relative;
  1344. cursor: pointer;
  1345. word-break: break-word;
  1346. transition: all 0.3s ease;
  1347. &.self {
  1348. background: #4d71ff;
  1349. color: #fff;
  1350. }
  1351. .burn-mask {
  1352. text-align: center;
  1353. color: #999;
  1354. font-size: 14px;
  1355. filter: blur(0px);
  1356. }
  1357. .burn-content,.burn-audio {
  1358. position: relative;
  1359. }
  1360. .burn-countdown {
  1361. position: absolute;
  1362. font-size: 12px;
  1363. color: #ff5b5b;
  1364. margin-left: 6px;
  1365. top: -15px;
  1366. right: -20px;
  1367. }
  1368. .burn-destroyed {
  1369. color: #999;
  1370. font-size: 13px;
  1371. text-align: center;
  1372. }
  1373. }
  1374. .sendText{
  1375. border: none !important;
  1376. height: 25px !important;
  1377. line-height: 25px !important;
  1378. border-radius: 4px;
  1379. background-color: #4765dd;
  1380. padding: 0 16px;
  1381. box-sizing: border-box;
  1382. }
  1383. :deep(.van-button:before){
  1384. border: none;
  1385. }
  1386. </style>