index.vue 27 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031
  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">{{ wsStore.toUserInfo.nickname }}</div>
  11. <svg-icon class="page-icon" name="more" @click="goDetail" />
  12. </div>
  13. <!-- 群公告 -->
  14. <div class="groupNotice" v-if="wsStore.toUserInfo.type == 'group'">
  15. <div class="m-ellipsis">{{ wsStore.toUserInfo.notice }}</div>
  16. <svg-icon class="item-icon" name="right1" />
  17. </div>
  18. <!-- 聊天消息区域 -->
  19. <div class="chat-list" ref="chatListRef">
  20. <van-loading class="load-box" size="24px" v-if="wsStore.loading">加载中...</van-loading>
  21. <template v-else>
  22. <div v-for="(item, index) in imMessages" :key="index">
  23. <div class="chat-time">
  24. {{ item.createDate || formatTime(Date.now()) }}
  25. </div>
  26. <div class="box" v-if="item.contentType !== MSG_TYPE.NOTICE">
  27. <div
  28. class="list-item"
  29. :class="isSender(item.toUsername, item) ? '' : 'flex-reverse'"
  30. >
  31. <!-- 头像 -->
  32. <van-image
  33. class="list-img"
  34. :class="isSender(item.toUsername, item) ? 'mr12' : 'ml12'"
  35. round
  36. :src="item.sender?item.sender.avatar:item.avatar"
  37. @click="goToPage(item)"
  38. />
  39. <!-- 内容 -->
  40. <div class="list-cont">
  41. <div>{{ item.sender?item.sender.nickname: (item.nickname || item.fromUsername || "匿名用户") }}</div>
  42. <!-- 文本消息 -->
  43. <div class="content" v-if="item.contentType === MSG_TYPE.TEXT" @click="onLongPress(item)">
  44. {{ item.content }}
  45. </div>
  46. <!-- 图片消息 -->
  47. <div
  48. class="img-message"
  49. v-else-if="item.contentType === MSG_TYPE.IMAGE"
  50. >
  51. <van-image
  52. :src="item?.localUrl || IM_PATH + item.url"
  53. style="max-width: 120px; border-radius: 8px"
  54. @click="previewImage(item)"
  55. />
  56. </div>
  57. <!-- 名片消息 -->
  58. <div
  59. class="content card-message"
  60. v-else-if="item.contentType === 3"
  61. >
  62. <div class="card-title">名片</div>
  63. <div class="card-name">{{ item.content }}</div>
  64. </div>
  65. <!-- 录音消息 -->
  66. <div
  67. class="audio-message"
  68. v-else-if="item.contentType === MSG_TYPE.AUDIO"
  69. >
  70. <messageAudio
  71. :src="item?.localUrl || IM_PATH + item.url"
  72. :isSender="isSender(item.toUsername, item)"
  73. />
  74. </div>
  75. <!-- 语音消息 -->
  76. <div class="content" v-if="item.contentType === Constant.REJECT_AUDIO_ONLINE || item.contentType === Constant.REJECT_VIDEO_ONLINE">[对方拒绝]</div>
  77. <div class="content" v-if="item.contentType === Constant.CANCELL_AUDIO_ONLINE || item.contentType === Constant.CANCELL_VIDEO_ONLINE">[通话结束]</div>
  78. </div>
  79. </div>
  80. </div>
  81. <div class="chat-time" v-if="item.contentType === MSG_TYPE.NOTICE">
  82. {{ item.content }}
  83. </div>
  84. </div>
  85. </template>
  86. </div>
  87. <!-- @页面 -->
  88. <AtUserList ref="atList" @select="onSelectUser" />
  89. <!-- 撤回弹窗 -->
  90. <van-action-sheet
  91. v-model:show="showActionSheet"
  92. :actions="actions"
  93. cancel-text="取消"
  94. @select="onActionSelect"
  95. />
  96. <!-- 输入框 -->
  97. <div class="page-foot">
  98. <div class="flex-box">
  99. <!-- 录音/文字切换按钮 -->
  100. <svg-icon
  101. type="button"
  102. class="page-icon"
  103. :name="voiceMode ? 'keyboard' : 'voice'"
  104. @click="toggleVoiceMode"
  105. />
  106. <!-- 文字输入框 或 按住说话按钮 -->
  107. <template v-if="!voiceMode">
  108. <van-field
  109. rows="1"
  110. type="textarea"
  111. :border="false"
  112. autosize
  113. class="input"
  114. v-model="text"
  115. @focus="onFocus"
  116. placeholder="输入文本"
  117. @keypress="handleKeyPress"
  118. @keyup.enter="sendMessage"
  119. />
  120. <!-- :disabled="deletefriend.includes(wsStore.toUserInfo.uuid)" -->
  121. </template>
  122. <template v-else>
  123. <div
  124. class="hold-talk-btn"
  125. @touchstart.prevent.stop="handleTouchStart"
  126. @touchmove.prevent.stop="handleTouchMove"
  127. @touchend.prevent.stop="handleTouchEnd"
  128. >
  129. {{ cancelRecording ? "松开手指,取消发送" : "按住说话" }}
  130. </div>
  131. </template>
  132. <svg-icon
  133. class="page-icon mr12 emoji-toggle"
  134. name="emoji"
  135. @click="toggleAppBox(1)"
  136. />
  137. <svg-icon
  138. class="page-icon tools-toggle"
  139. name="add2"
  140. @click="toggleAppBox(2)"
  141. />
  142. </div>
  143. <!-- 录音状态浮层 -->
  144. <!-- <div v-if="recording" class="recording-toast">
  145. <div v-if="cancelRecording" class="cancel-msg">松开手指,取消发送</div>
  146. <div v-else class="send-msg">松开发送,上滑取消</div>
  147. </div> -->
  148. <div v-if="recording" class="recording-toast">
  149. <div class="mic-icon"></div>
  150. <div v-if="cancelRecording" class="cancel-msg">松开手指,取消发送</div>
  151. <div v-else class="send-msg">手指上滑,取消发送</div>
  152. </div>
  153. <!-- 表情面板 -->
  154. <div
  155. class="app-box"
  156. v-show="showEmoji"
  157. :style="{ height: `${appBoxHeight}px` }"
  158. ref="emojiRef"
  159. >
  160. <div>
  161. <span
  162. v-for="(emoji, index) in emojis"
  163. :key="index"
  164. class="emoji-item"
  165. @click="insertEmoji(emoji)"
  166. >
  167. {{ emoji }}
  168. </span>
  169. </div>
  170. </div>
  171. <!-- 工具栏面板 -->
  172. <div
  173. class="app-box"
  174. v-show="showTools"
  175. :style="{ height: `${appBoxHeight}px` }"
  176. ref="toolsRef"
  177. >
  178. <div class="tool-btn">
  179. <van-uploader :after-read="afterRead" :before-read="beforeRead">
  180. <svg-icon class="tool-icon" name="tp" />
  181. </van-uploader>
  182. <div>图片</div>
  183. </div>
  184. <!-- <div class="tool-btn">
  185. <svg-icon class="tool-icon" name="ps" />
  186. <div>拍摄</div>
  187. </div> -->
  188. <div class="tool-btn" v-if="wsStore.toUserInfo.type == 'user'">
  189. <svg-icon
  190. class="tool-icon"
  191. name="yyth"
  192. @click="startAudioOnline(Constant.DIAL_AUDIO_ONLINE, 'audio')"
  193. />
  194. <div>语音通话</div>
  195. </div>
  196. <div class="tool-btn" v-if="wsStore.toUserInfo.type == 'user'">
  197. <svg-icon
  198. class="tool-icon"
  199. name="spth"
  200. @click="startAudioOnline(Constant.DIAL_VIDEO_ONLINE, 'video')"
  201. />
  202. <div>视频通话</div>
  203. </div>
  204. <!-- <div class="tool-btn">
  205. <svg-icon class="tool-icon" name="mp" />
  206. <div>名片</div>
  207. </div> -->
  208. </div>
  209. </div>
  210. </div>
  211. </template>
  212. <script setup>
  213. import { useRouter, useRoute } from "vue-router";
  214. import { useWebSocketStore } from "@/stores/modules/webSocketStore.js";
  215. import { useWalletStore } from "@/stores/modules/walletStore.js";
  216. import { Keyboard } from "@capacitor/keyboard";
  217. import { Capacitor } from "@capacitor/core";
  218. import { MSG_TYPE, MESSAGE_TYPE_USER,MESSAGE_TYPE_GROUP,MESSAGE_REVOKE,MESSAGE_REVOKE_GROUP } from "@/common/constant/msgType";
  219. import messageAudio from "@/views/im/components/messageAudio/index.vue";
  220. import { showToast, showImagePreview } from "vant";
  221. import { useWebRTCStore } from "@/stores/modules/webrtcStore";
  222. import * as Constant from "@/common/constant/Constant";
  223. import { soundVoice } from "@/utils/notifications.js";
  224. import AtUserList from "./components/AtUserList/index.vue";
  225. const IM_PATH = import.meta.env.VITE_IM_PATH_FIlE;
  226. // 路由 & store
  227. const router = useRouter();
  228. const route = useRoute();
  229. const wsStore = useWebSocketStore();
  230. const walletStore = useWalletStore();
  231. const rtcStore = useWebRTCStore();
  232. // 输入框文本
  233. const text = ref("");
  234. // 状态管理
  235. const keyboardHeight = ref(0);
  236. const showEmoji = ref(false);
  237. const showTools = ref(false);
  238. const appBoxHeight = ref(210);
  239. const chatListRef = ref(null);
  240. const emojiRef = ref(null);
  241. const toolsRef = ref(null);
  242. const deletefriend = ref([])
  243. // 表情数组
  244. const emojis = [
  245. "😀", "😃", "😄", "😁", "😆", "😅", "😂", "🤣",
  246. "😊", "😇", "🙂", "🙃", "😉", "😌", "😍","😜","🤪","🫣","🤔","🤤","🥲","😋",
  247. "😀", "😃", "😄", "😁", "😆", "😅", "😂", "🤣",
  248. ];
  249. const atList = ref();
  250. const isSender = (toUsername, item) => {
  251. if(item?.sender){
  252. return walletStore.account !== item.sender.uuid;
  253. }
  254. return walletStore.account === toUsername;
  255. };
  256. const imMessages = computed(() =>{
  257. wsStore.funUpdateUnread(wsStore.toUserInfo.uuid);
  258. return wsStore.messages.filter(item =>{
  259. if(item.messageType == MESSAGE_TYPE_GROUP && wsStore.toUserInfo.type != 'user') {
  260. return item
  261. }
  262. if(item.messageType == MESSAGE_TYPE_USER && wsStore.toUserInfo.type == 'user') {
  263. return item
  264. }
  265. })
  266. })
  267. // 滚动到底部
  268. const scrollToBottom = () => {
  269. nextTick(() => {
  270. if (chatListRef.value) {
  271. const el = chatListRef.value;
  272. el.scrollTop = el.scrollHeight;
  273. }
  274. });
  275. };
  276. watch(
  277. () => wsStore.indexs,
  278. (newVal,oldVal) => {
  279. if (newVal !== oldVal) {
  280. scrollToBottom();
  281. }
  282. }
  283. );
  284. // 平台判断
  285. const isMobile = Capacitor.getPlatform() !== "web";
  286. // 计算当前底部总高度
  287. const currentBottomHeight = computed(() => {
  288. if (keyboardHeight.value > 0) return keyboardHeight.value;
  289. if (showEmoji.value || showTools.value) return appBoxHeight.value;
  290. return 0;
  291. });
  292. // 切换表情/工具面板
  293. const toggleAppBox = async (type) => {
  294. if(wsStore.chatDelAuth[ wsStore.toUserInfo.uuid]){
  295. showToast(`${wsStore.toUserInfo.type == 'user' ? '对方已删除' : '您已不在群聊里面'}`);
  296. return;
  297. }
  298. if (isMobile) await Keyboard.hide();
  299. keyboardHeight.value = 0;
  300. if (type === 1) {
  301. showEmoji.value = !showEmoji.value;
  302. showTools.value = false;
  303. } else {
  304. showTools.value = !showTools.value;
  305. showEmoji.value = false;
  306. }
  307. scrollToBottom();
  308. };
  309. // 插入表情
  310. const insertEmoji = (emoji) => {
  311. text.value += emoji;
  312. };
  313. // 预览图片
  314. const previewImage = (item) => {
  315. const imageList = wsStore.messages
  316. .filter((m) => m.contentType === MSG_TYPE.IMAGE)
  317. .map((m) => m.localUrl || IM_PATH + m.url);
  318. const index = imageList.findIndex(
  319. (url) => url === (item.localUrl || IM_PATH + item.url)
  320. );
  321. showImagePreview({
  322. images: imageList,
  323. startPosition: index,
  324. });
  325. };
  326. const onFocus = () => {
  327. // 隐藏所有面板
  328. showEmoji.value = false;
  329. showTools.value = false;
  330. if (isMobile) setupKeyboardListeners();
  331. scrollToBottom();
  332. };
  333. // 键盘监听
  334. const setupKeyboardListeners = async () => {
  335. Keyboard.addListener("keyboardWillShow", (info) => {
  336. keyboardHeight.value = info.keyboardHeight;
  337. });
  338. Keyboard.addListener("keyboardWillHide", () => {
  339. keyboardHeight.value = 0;
  340. });
  341. };
  342. // 录音相关状态
  343. const voiceMode = ref(false); // false: 文字输入, true: 语音模式
  344. const recording = ref(false);
  345. const cancelRecording = ref(false);
  346. const startY = ref(0);
  347. let mediaRecorder = null;
  348. let audioChunks = [];
  349. // 切换文字/语音输入模式
  350. const toggleVoiceMode = () => {
  351. if(wsStore.chatDelAuth[ wsStore.toUserInfo.uuid]){
  352. showToast(`${wsStore.toUserInfo.type == 'user' ? '对方已删除' : '您已不在群聊里面'}`);
  353. return;
  354. }
  355. voiceMode.value = !voiceMode.value;
  356. // 切换时关闭表情和工具面板,隐藏键盘
  357. showEmoji.value = false;
  358. showTools.value = false;
  359. if (isMobile) Keyboard.hide();
  360. keyboardHeight.value = 0;
  361. scrollToBottom();
  362. };
  363. // 录音事件
  364. const handleTouchStart = async (e) => {
  365. startY.value = e.touches[0].clientY;
  366. cancelRecording.value = false;
  367. recording.value = true;
  368. audioChunks = [];
  369. try {
  370. const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
  371. mediaRecorder = new MediaRecorder(stream, {
  372. mimeType: "audio/webm; codecs=opus",
  373. });
  374. mediaRecorder.ondataavailable = (ev) => {
  375. audioChunks.push(ev.data);
  376. };
  377. mediaRecorder.start(1000);
  378. console.log("开始录音");
  379. } catch (err) {
  380. console.error("麦克风权限获取失败", err);
  381. recording.value = false;
  382. }
  383. };
  384. // 录音
  385. const handleTouchMove = (e) => {
  386. const currentY = e.touches[0].clientY;
  387. if (startY.value - currentY > 50) {
  388. cancelRecording.value = true;
  389. } else {
  390. cancelRecording.value = false;
  391. }
  392. };
  393. // 录音发送
  394. const handleTouchEnd = () => {
  395. if (!mediaRecorder) return;
  396. mediaRecorder.stop();
  397. mediaRecorder.stream.getTracks().forEach((t) => t.stop());
  398. recording.value = false;
  399. mediaRecorder.onstop = async () => {
  400. if (cancelRecording.value) {
  401. console.log("录音取消");
  402. return;
  403. }
  404. if (audioChunks.length === 0) return;
  405. const audioBlob = new Blob(audioChunks, { type: "audio/webm" });
  406. const arrayBuffer = await audioBlob.arrayBuffer();
  407. const audioData = new Uint8Array(arrayBuffer);
  408. wsStore.sendMessage({
  409. // content: "",
  410. content: JSON.stringify({
  411. content: "",
  412. msgId: `ms${Date.now()}`, // 消息id
  413. }),
  414. contentType: MSG_TYPE.AUDIO,
  415. messageType: wsStore.toUserInfo.type == 'user'?MESSAGE_TYPE_USER:MESSAGE_TYPE_GROUP,
  416. fileSuffix: "wav", // 使用webm后缀更准确
  417. file: audioData, // 将Uint8Array转为普通数组
  418. });
  419. scrollToBottom();
  420. console.log("语音已发送");
  421. };
  422. };
  423. // 监听输入 @
  424. const handleKeyPress = (e) => {
  425. if(wsStore.toUserInfo.type == 'user') return;
  426. if (e.key === "@") {
  427. atList.value.open(); // 打开选人弹窗
  428. }
  429. };
  430. // 选择用户回填输入框
  431. const onSelectUser = (user) => {
  432. text.value += `${user.nickname} `;
  433. };
  434. // 撤回
  435. const currentMsg = ref('');
  436. const showActionSheet = ref(false)
  437. const actions = [
  438. { name: "复制", key: "copy" },
  439. { name: "撤回", key: "revoke" },
  440. ];
  441. const onLongPress = (msg) => {
  442. currentMsg.value = msg;
  443. showActionSheet.value = true;
  444. };
  445. const onActionSelect = (action) => {
  446. if (!currentMsg.value) return;
  447. if (action.key === "copy") {
  448. navigator.clipboard.writeText(currentMsg.value.content);
  449. showToast("已复制");
  450. } else if (action.key === "revoke") {
  451. const message = {
  452. content: JSON.stringify({
  453. content: '',
  454. msgId: currentMsg.value.id + '',
  455. }),
  456. contentType: MSG_TYPE.NOTICE, // 1: 文本消息
  457. messageType: wsStore.toUserInfo.type == 'user'?MESSAGE_REVOKE:MESSAGE_REVOKE_GROUP,
  458. };
  459. wsStore.sendMessage(message);
  460. currentMsg.value.revoked = true;
  461. }
  462. showActionSheet.value = false;
  463. };
  464. // 发送消息
  465. const sendMessage = () => {
  466. if(wsStore.chatDelAuth[wsStore.toUserInfo.uuid]){
  467. showToast(`${wsStore.toUserInfo.type == 'user' ? '对方已删除' : '您已不在群聊里面'}`);
  468. return;
  469. }
  470. if (!text.value.trim()) return;
  471. // content: JSON.stringify({
  472. // content: text.value,
  473. // msgId: Math.random(),
  474. // cc: [].join(",")
  475. // }),
  476. const message = {
  477. content: JSON.stringify({
  478. content: text.value,
  479. msgId: `ms${Date.now()}`, // 消息id
  480. }),
  481. contentType: MSG_TYPE.TEXT, // 1: 文本消息
  482. messageType: wsStore.toUserInfo.type == 'user'?MESSAGE_TYPE_USER:MESSAGE_TYPE_GROUP,
  483. };
  484. wsStore.sendMessage(message);
  485. text.value = "";
  486. scrollToBottom();
  487. };
  488. // 发送图片消息
  489. const afterRead = async (file) => {
  490. const arrayBuffer = await file.file.arrayBuffer();
  491. const message = {
  492. // content: text.value, // 如果有文本内容
  493. content: JSON.stringify({
  494. content: text.value,
  495. msgId: `ms${Date.now()}`, // 消息id
  496. }),
  497. contentType: MSG_TYPE.IMAGE, // 音频消息类型
  498. messageType: wsStore.toUserInfo.type == 'user'?MESSAGE_TYPE_USER:MESSAGE_TYPE_GROUP,
  499. fileSuffix: file.file.type, // 使用webm后缀更准确
  500. file: new Uint8Array(arrayBuffer), // 将Uint8Array转为普通数组
  501. };
  502. wsStore.sendMessage(message);
  503. scrollToBottom();
  504. };
  505. // 图片类型
  506. const beforeRead = (file) => {
  507. const realFile = file.file || file;
  508. const type = realFile.type;
  509. const name = realFile.name || "";
  510. if (type === "image/svg+xml" || name.endsWith(".svg")) {
  511. showToast("不支持上传 SVG 格式的图片");
  512. return false;
  513. }
  514. return true;
  515. };
  516. // 创建呼叫:开启语音电话: 调试用,正式逻辑读src/views/im/hook/messagesHook.js
  517. // ==== 1. 发起语音通话 ====
  518. const startAudioOnline = async (contentType, streamType) => {
  519. // 清理被呼叫者信息
  520. wsStore.toUserInfo.sender = null;
  521. // 调起通话界面
  522. rtcStore.streamType = streamType
  523. rtcStore.imSate.videoCallModal = true;
  524. // 设置被呼叫对象
  525. rtcStore.imSate.callAvatar = wsStore.toUserInfo.avatar;
  526. rtcStore.imSate.callName = wsStore.toUserInfo.nickname;
  527. // 设置呼叫者/被呼叫者
  528. rtcStore.isCaller = true;
  529. // 通知被呼叫者
  530. wsStore.sendMessage({
  531. messageType: wsStore.toUserInfo.type == 'user'?MESSAGE_TYPE_USER:MESSAGE_TYPE_GROUP,
  532. contentType,
  533. type: Constant.MESSAGE_TRANS_TYPE,
  534. avatar: walletStore.avatar,
  535. content: JSON.stringify({
  536. content: walletStore.username,
  537. sender: walletStore.account,
  538. })
  539. });
  540. // 播放铃声
  541. soundVoice.play()
  542. // 开启本地视频
  543. if (streamType === 'video') {
  544. rtcStore.getUserMedia({ audio: true, video: true })
  545. }
  546. };
  547. // 时间格式化
  548. const formatTime = (timestamp) => {
  549. const date = new Date(timestamp);
  550. const h = date.getHours().toString().padStart(2, "0");
  551. const m = date.getMinutes().toString().padStart(2, "0");
  552. return `${h}:${m}`;
  553. };
  554. // 页面生命周期
  555. onMounted(async () => {
  556. wsStore.toUserInfo.uuid = route.query.uuid;
  557. await wsStore.getMessages({
  558. uuid: wsStore.toUserInfo.type == 'user'?walletStore.account : route.query.uuid,
  559. messageType: wsStore.toUserInfo.type == 'user'?1:2, //1:个人 2:群组
  560. friendUsername: wsStore.toUserInfo.type == 'user'?route.query.uuid : walletStore.account
  561. });
  562. scrollToBottom();
  563. document.addEventListener("click", handleClickOutside);
  564. });
  565. onUnmounted(() => {
  566. if (isMobile) Keyboard.removeAllListeners();
  567. });
  568. onBeforeUnmount(() => {
  569. document.removeEventListener("click", handleClickOutside);
  570. });
  571. // 判断是否点击在元素外
  572. const handleClickOutside = (event) => {
  573. const emojiEl = emojiRef.value;
  574. const toolsEl = toolsRef.value;
  575. const target = event.target;
  576. if (
  577. showEmoji.value &&
  578. emojiEl &&
  579. !emojiEl.contains(target) &&
  580. !target.closest(".emoji-toggle")
  581. ) {
  582. showEmoji.value = false;
  583. }
  584. if (
  585. showTools.value &&
  586. toolsEl &&
  587. !toolsEl.contains(target) &&
  588. !target.closest(".tools-toggle")
  589. ) {
  590. showTools.value = false;
  591. }
  592. };
  593. // 页面跳转
  594. const goToPage = (item) => {
  595. if(!(isSender(item.toUsername))) return;
  596. router.push({
  597. path: 'personal',
  598. query:{
  599. uuid:wsStore.toUserInfo.type == 'user'?wsStore.toUserInfo.uuid:item.uuid,
  600. type:2
  601. }
  602. })
  603. }
  604. const goBack = () => router.push("im");
  605. const goDetail = () =>{
  606. if(wsStore.chatDelAuth[ wsStore.toUserInfo.uuid]){
  607. showToast(`${wsStore.toUserInfo.type == 'user' ? '对方已删除' : '您已不在群聊里面'}`);
  608. return;
  609. }
  610. // 单聊的话跳转查看个人信息页面
  611. if(wsStore.toUserInfo.type == 'user'){
  612. router.push({
  613. path: 'personal',
  614. query:{
  615. uuid:wsStore.toUserInfo.uuid,
  616. type:2
  617. }
  618. })
  619. return;
  620. }
  621. router.push({
  622. path: 'detail',
  623. query:{ status:wsStore.toUserInfo.type == 'user'?1:2 }
  624. }) // 1:单聊 2:群聊
  625. }
  626. </script>
  627. <style lang="less" scoped>
  628. .load-box{
  629. text-align: center !important;
  630. margin-top: 50px !important;
  631. }
  632. .mr12 {
  633. margin-right: 12px;
  634. }
  635. .ml12 {
  636. margin-left: 12px;
  637. }
  638. .text-right {
  639. text-align: right;
  640. }
  641. .page-icon {
  642. width: 24px;
  643. height: 24px;
  644. flex-shrink: 0;
  645. }
  646. .container {
  647. display: flex;
  648. flex-direction: column;
  649. .chat-bg {
  650. height: 126px;
  651. background: linear-gradient(90deg, @theme-color1 0%, #40a4fb 100%);
  652. position: absolute;
  653. left: 0;
  654. right: 0;
  655. z-index: -1;
  656. }
  657. .header-chat {
  658. padding-top: 56px;
  659. margin: 0 16px;
  660. display: flex;
  661. align-items: center;
  662. color: @theme-color1;
  663. .header-title {
  664. flex: 1;
  665. font-family:
  666. PingFang SC,
  667. PingFang SC;
  668. font-weight: 500;
  669. font-size: 19px;
  670. color: #ffffff;
  671. text-align: center;
  672. }
  673. }
  674. .chat-list {
  675. background: #f7f8fa;
  676. border-radius: 30px 30px 0 0;
  677. flex: 1;
  678. overflow: auto;
  679. margin-top: 20px;
  680. padding: 0 16px 24px;
  681. .chat-time {
  682. font-family:
  683. PingFang SC,
  684. PingFang SC;
  685. font-weight: 400;
  686. font-size: 12px;
  687. color: #8d8d8d;
  688. text-align: center;
  689. margin: 20px 0;
  690. }
  691. .box {
  692. .list-item {
  693. display: flex;
  694. margin-bottom: 24px;
  695. .list-img {
  696. width: 44px;
  697. height: 44px;
  698. flex-shrink: 0;
  699. }
  700. .list-cont {
  701. font-family:
  702. PingFang SC,
  703. PingFang SC;
  704. font-weight: 400;
  705. font-size: 12px;
  706. color: #8d8d8d;
  707. max-width: 70%;
  708. display: flex;
  709. flex-direction: column;
  710. align-items: flex-start;
  711. .business-card {
  712. width: 199px;
  713. height: 93px;
  714. background: #ffffff;
  715. border-radius: 10px;
  716. margin-top: 8px;
  717. padding: 10px;
  718. box-sizing: border-box;
  719. .business-card-cont {
  720. display: flex;
  721. align-items: center;
  722. font-family:
  723. PingFang SC,
  724. PingFang SC;
  725. font-weight: 400;
  726. font-size: 15px;
  727. color: #000000;
  728. }
  729. .line {
  730. height: 1px;
  731. background: #f2f2f2;
  732. margin: 10px 0 6px;
  733. }
  734. .business-card-text {
  735. font-family:
  736. PingFang SC,
  737. PingFang SC;
  738. font-weight: 400;
  739. font-size: 10px;
  740. color: #8d8d8d;
  741. }
  742. }
  743. .content {
  744. background: #ffffff; // 对方消息背景白色
  745. color: #000;
  746. border-radius: 10px;
  747. margin-top: 8px;
  748. padding: 8px 17px;
  749. word-break: break-word;
  750. white-space: pre-wrap;
  751. max-width: 100%;
  752. font-family:
  753. PingFang SC,
  754. PingFang SC;
  755. font-weight: 400;
  756. font-size: 15px;
  757. }
  758. .img-message {
  759. margin-top: 8px;
  760. }
  761. }
  762. }
  763. .withdrawal {
  764. display: flex;
  765. justify-content: center;
  766. margin-bottom: 24px;
  767. .withdrawal-text {
  768. width: 142px;
  769. height: 29px;
  770. line-height: 29px;
  771. box-sizing: border-box;
  772. background: #f2f2f2;
  773. border-radius: 4px;
  774. font-family:
  775. PingFang SC,
  776. PingFang SC;
  777. font-weight: 400;
  778. font-size: 12px;
  779. color: #8d8d8d;
  780. text-align: center;
  781. }
  782. }
  783. .flex-reverse {
  784. flex-direction: row-reverse;
  785. }
  786. .flex-reverse .list-cont {
  787. align-items: flex-end;
  788. .content {
  789. background: #4d71ff; // 自己的消息是蓝色
  790. color: #ffffff; // 白字
  791. }
  792. }
  793. }
  794. }
  795. .chat-list::-webkit-scrollbar {
  796. width: 0;
  797. }
  798. .page-foot {
  799. position: relative;
  800. background-color: #fff;
  801. .flex-box {
  802. padding: 8px 16px 20px;
  803. display: flex;
  804. align-items: center;
  805. box-sizing: border-box;
  806. .input {
  807. flex: 1;
  808. background: #f2f2f2;
  809. border-radius: 17px;
  810. border: 1px solid #d8d8d8;
  811. padding: 6px 16px;
  812. font-weight: 500;
  813. font-size: 15px;
  814. margin: 0 12px;
  815. overflow-y: auto;
  816. }
  817. }
  818. }
  819. }
  820. .app-box {
  821. position: fixed;
  822. bottom: 210px;
  823. left: 0;
  824. right: 0;
  825. background: white;
  826. transition: transform 0.3s ease;
  827. transform: translateY(100%);
  828. display: flex;
  829. flex-wrap: wrap;
  830. padding: 10px 16px;
  831. overflow-y: auto;
  832. box-sizing: border-box;
  833. &.visible {
  834. transform: translateY(0);
  835. }
  836. .tool-btn {
  837. width: calc(100% / 4);
  838. display: flex;
  839. flex-direction: column;
  840. align-items: center;
  841. font-family:
  842. PingFang SC,
  843. PingFang SC;
  844. font-weight: 400;
  845. font-size: 12px;
  846. color: #000000;
  847. .tool-icon {
  848. width: 56px;
  849. height: 56px;
  850. margin-bottom: 4px;
  851. }
  852. }
  853. .emoji-item{
  854. font-size: 20px;
  855. margin: 0 4px;
  856. }
  857. }
  858. .app-box::-webkit-scrollbar {
  859. width: 0;
  860. }
  861. .page-foot {
  862. position: relative;
  863. z-index: 10; /* 确保输入框在上层 */
  864. }
  865. .local-video {
  866. position: absolute;
  867. bottom: 20px;
  868. right: 20px;
  869. width: 200px;
  870. height: auto;
  871. border: 2px solid white;
  872. border-radius: 8px;
  873. }
  874. .remote-video {
  875. width: 100%;
  876. height: 100vh;
  877. background: black;
  878. object-fit: cover;
  879. }
  880. /* 按住说话按钮 */
  881. .hold-talk-btn {
  882. flex: 1;
  883. text-align: center;
  884. background: #f5f5f5;
  885. padding: 6px 16px;
  886. border-radius: 17px;
  887. color: #666;
  888. font-weight: 500;
  889. font-size: 15px;
  890. user-select: none;
  891. -webkit-user-select: none;
  892. -webkit-touch-callout: none;
  893. margin: 0 12px;
  894. line-height: 24px;
  895. border: 1px solid #f5f5f5;
  896. }
  897. /* 录音中的浮层提示 */
  898. .recording-toast {
  899. position: fixed;
  900. bottom: 80px;
  901. left: 50%;
  902. transform: translateX(-50%);
  903. width: 160px;
  904. min-height: 160px;
  905. background: rgba(0, 0, 0, 0.85);
  906. border-radius: 12px;
  907. padding: 16px;
  908. box-sizing: border-box;
  909. text-align: center;
  910. display: flex;
  911. flex-direction: column;
  912. align-items: center;
  913. justify-content: center;
  914. .mic-icon {
  915. width: 60px;
  916. height: 60px;
  917. background: url("https://img.icons8.com/ios-filled/100/ffffff/microphone.png") no-repeat center center;
  918. background-size: contain;
  919. margin-bottom: 16px;
  920. }
  921. .send-msg {
  922. color: #fff;
  923. font-size: 14px;
  924. }
  925. .cancel-msg {
  926. color: #ff4d4f;
  927. font-size: 14px;
  928. }
  929. }
  930. .groupNotice{
  931. margin-top: 15px;
  932. background-color: #fff;
  933. height: 40px;
  934. line-height: 40px;
  935. font-family: PingFang SC, PingFang SC;
  936. font-weight: 400;
  937. font-size: 12px;
  938. color: #000000;
  939. padding: 0 16px;
  940. box-sizing: border-box;
  941. display: flex;
  942. justify-content: space-between;
  943. align-items: center;
  944. text-align: center;
  945. .m-ellipsis{
  946. white-space: nowrap; /* 不换行 */
  947. overflow: hidden; /* 超出隐藏 */
  948. text-overflow: ellipsis; /* 超出显示省略号 */
  949. }
  950. .item-icon{
  951. width: 20px;
  952. height: 20px;
  953. color: #969799;
  954. }
  955. }
  956. .revoked-msg {
  957. color: #999;
  958. font-size: 12px;
  959. font-style: italic;
  960. text-align: center;
  961. margin: 5px 0;
  962. }
  963. .burn-tag {
  964. font-size: 10px;
  965. color: red;
  966. margin-left: 6px;
  967. }
  968. </style>