index.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743
  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">群聊2({{ wsStore.messages.length }})</div>
  11. <svg-icon class="page-icon" name="more" @click="goDetail" />
  12. </div>
  13. <!-- 聊天消息区域 -->
  14. <div class="chat-list" ref="chatListRef">
  15. <div v-for="(item, index) in wsStore.messages" :key="index">
  16. <div class="chat-time">
  17. {{ formatTime(item.timestamp || Date.now()) }}
  18. </div>
  19. <div class="box">
  20. <div
  21. class="list-item"
  22. :class="isSender(item.toUsername) ? '' : 'flex-reverse'"
  23. >
  24. <!-- 头像 -->
  25. <van-image
  26. class="list-img"
  27. :class="isSender(item.toUsername) ? 'mr12' : 'ml12'"
  28. round
  29. :src="item.avatar"
  30. @click="router.push('personal')"
  31. />
  32. <!-- 内容 -->
  33. <div class="list-cont">
  34. <div>{{ item.fromUsername || "匿名用户" }}</div>
  35. <!-- 文本消息 -->
  36. <div class="content" v-if="item.contentType === MSG_TYPE.TEXT">
  37. {{ item.content }}
  38. </div>
  39. <!-- 图片消息 -->
  40. <div
  41. class="img-message"
  42. v-else-if="item.contentType === MSG_TYPE.IMAGE"
  43. >
  44. <van-image
  45. :src="item?.localUrl || IM_PATH + item.url"
  46. style="max-width: 120px; border-radius: 8px"
  47. @click="previewImage(item)"
  48. />
  49. </div>
  50. <!-- 名片消息 -->
  51. <div
  52. class="content card-message"
  53. v-else-if="item.contentType === 3"
  54. >
  55. <div class="card-title">名片</div>
  56. <div class="card-name">{{ item.content }}</div>
  57. </div>
  58. <!-- 录音消息 -->
  59. <div
  60. class="audio-message"
  61. v-else-if="item.contentType === MSG_TYPE.AUDIO"
  62. >
  63. <!-- <audio
  64. v-if="item.localUrl"
  65. :src="item.localUrl"
  66. controls
  67. style="width: 200px"
  68. />
  69. <audio
  70. v-else
  71. :src="IM_PATH + item.url"
  72. controls
  73. style="width: 200px"
  74. /> -->
  75. <messageAudio
  76. :src="item?.localUrl || IM_PATH + item.url"
  77. :isSender="isSender(item.toUsername)"
  78. />
  79. </div>
  80. <!-- 其他未知类型 -->
  81. <div class="content" v-else>[未知消息类型]</div>
  82. </div>
  83. </div>
  84. </div>
  85. </div>
  86. </div>
  87. <!-- 输入框 -->
  88. <div class="page-foot">
  89. <div class="flex-box">
  90. <svg-icon
  91. type="button"
  92. class="page-icon"
  93. name="voice"
  94. @mousedown="startAudio"
  95. @touchstart="startAudio"
  96. @mouseup="sendAudioMessage"
  97. @touchend="sendAudioMessage"
  98. />
  99. <van-field
  100. rows="1"
  101. type="textarea"
  102. :border="false"
  103. autosize
  104. class="input"
  105. v-model="text"
  106. @focus="onFocus"
  107. placeholder="输入文本"
  108. @keyup.enter="sendMessage"
  109. />
  110. <svg-icon
  111. class="page-icon mr12 emoji-toggle"
  112. name="emoji"
  113. @click="toggleAppBox(1)"
  114. />
  115. <svg-icon
  116. class="page-icon tools-toggle"
  117. name="add2"
  118. @click="toggleAppBox(2)"
  119. />
  120. </div>
  121. <!-- 表情面板 -->
  122. <div
  123. class="app-box"
  124. v-show="showEmoji"
  125. :style="{ height: `${appBoxHeight}px` }"
  126. ref="emojiRef"
  127. >
  128. 😀 😃 😄 😁 😆 😅 😂 🤣 😊 😇 🙂 🙃 😉 😌 😍
  129. </div>
  130. <!-- 工具栏面板 -->
  131. <div
  132. class="app-box"
  133. v-show="showTools"
  134. :style="{ height: `${appBoxHeight}px` }"
  135. ref="toolsRef"
  136. >
  137. <div class="tool-btn">
  138. <van-uploader :after-read="afterRead" :before-read="beforeRead">
  139. <svg-icon class="tool-icon" name="tp" />
  140. </van-uploader>
  141. <div>图片</div>
  142. </div>
  143. <div class="tool-btn">
  144. <svg-icon class="tool-icon" name="ps" />
  145. <div>拍摄</div>
  146. </div>
  147. <div class="tool-btn">
  148. <svg-icon class="tool-icon" name="yyth" @click="startAudioOnline(Constant.DIAL_AUDIO_ONLINE)" />
  149. <div>语音通话</div>
  150. </div>
  151. <div class="tool-btn">
  152. <svg-icon class="tool-icon" name="spth" @click="startAudioOnline(Constant.DIAL_VIDEO_ONLINE)" />
  153. <div>视频通话</div>
  154. </div>
  155. <div class="tool-btn">
  156. <svg-icon class="tool-icon" name="mp" />
  157. <div>名片</div>
  158. </div>
  159. </div>
  160. </div>
  161. <!-- 来电弹窗 -->
  162. <!-- <div v-if="rtcStore.imSate.videoCallModal && !inCall" class="call-modal">
  163. <p>{{ rtcStore.imSate.callName }} 正在呼叫你</p>
  164. <button @click="acceptCall">接听</button>
  165. <button @click="rejectCall">拒接</button>
  166. </div> -->
  167. <!-- 通话中显示挂断按钮 -->
  168. <!-- <div v-if="inCall" class="call-modal">
  169. <p>与 {{ rtcStore.imSate.callName }} 通话中...</p>
  170. <button @click="hangupCall">挂断</button>
  171. </div> -->
  172. </div>
  173. </template>
  174. <script setup>
  175. import { ref, computed, onMounted, onUnmounted, onBeforeUnmount } from "vue";
  176. import { useRouter, useRoute } from "vue-router";
  177. import { useWebSocketStore } from "@/stores/modules/webSocketStore.js";
  178. import { useWalletStore } from "@/stores/modules/walletStore.js";
  179. import { Keyboard } from "@capacitor/keyboard";
  180. import { Capacitor } from "@capacitor/core";
  181. import { MSG_TYPE, MESSAGE_TYPE_USER } from "@/common/constant/msgType";
  182. import messageAudio from "@/views/im/components/messageAudio/index.vue";
  183. import { showToast, showImagePreview } from "vant";
  184. import { useWebRTCStore } from "@/stores/modules/webrtcStore";
  185. import * as Constant from "@/common/constant/Constant";
  186. const IM_PATH = import.meta.env.VITE_IM_PATH_FIlE;
  187. // 路由 & store
  188. const router = useRouter();
  189. const route = useRoute();
  190. const wsStore = useWebSocketStore();
  191. const walletStore = useWalletStore();
  192. const rtcStore = useWebRTCStore();
  193. // 输入框文本
  194. const text = ref("");
  195. // 状态管理
  196. const keyboardHeight = ref(0);
  197. const showEmoji = ref(false);
  198. const showTools = ref(false);
  199. const appBoxHeight = ref(210);
  200. const chatListRef = ref(null);
  201. const emojiRef = ref(null);
  202. const toolsRef = ref(null);
  203. const isSender = (toUsername) => {
  204. return walletStore.account === toUsername;
  205. };
  206. // 滚动到底部
  207. const scrollToBottom = () => {
  208. nextTick(() => {
  209. if (chatListRef.value) {
  210. const el = chatListRef.value;
  211. el.scrollTop = el.scrollHeight;
  212. }
  213. });
  214. };
  215. watch(
  216. () => wsStore.messages.length,
  217. () => {
  218. scrollToBottom();
  219. }
  220. );
  221. // 平台判断
  222. const isMobile = Capacitor.getPlatform() !== "web";
  223. // 语音
  224. const isTouchDevice = ref(false);
  225. const mediaRecorder = ref(null); // 录音对象
  226. const audioChunks = ref([]); // 录音数据
  227. // 计算当前底部总高度
  228. const currentBottomHeight = computed(() => {
  229. if (keyboardHeight.value > 0) return keyboardHeight.value;
  230. if (showEmoji.value || showTools.value) return appBoxHeight.value;
  231. return 0;
  232. });
  233. // 切换表情/工具面板
  234. const toggleAppBox = async (type) => {
  235. if (isMobile) await Keyboard.hide();
  236. keyboardHeight.value = 0;
  237. if (type === 1) {
  238. showEmoji.value = !showEmoji.value;
  239. showTools.value = false;
  240. } else {
  241. showTools.value = !showTools.value;
  242. showEmoji.value = false;
  243. }
  244. scrollToBottom();
  245. };
  246. // 预览图片
  247. const previewImage = (item) => {
  248. const imageList = wsStore.messages
  249. .filter((m) => m.contentType === MSG_TYPE.IMAGE)
  250. .map((m) => m.localUrl || IM_PATH + m.url);
  251. const index = imageList.findIndex(
  252. (url) => url === (item.localUrl || IM_PATH + item.url)
  253. );
  254. showImagePreview({
  255. images: imageList,
  256. startPosition: index,
  257. });
  258. };
  259. const onFocus = () => {
  260. // 隐藏所有面板
  261. showEmoji.value = false;
  262. showTools.value = false;
  263. if (isMobile) setupKeyboardListeners();
  264. scrollToBottom();
  265. };
  266. // 键盘监听
  267. const setupKeyboardListeners = async () => {
  268. Keyboard.addListener("keyboardWillShow", (info) => {
  269. keyboardHeight.value = info.keyboardHeight;
  270. });
  271. Keyboard.addListener("keyboardWillHide", () => {
  272. keyboardHeight.value = 0;
  273. });
  274. };
  275. // 录音
  276. const startAudio = async (event) => {
  277. if (event.type === "touchstart") {
  278. isTouchDevice.value = true;
  279. }
  280. // 如果是触摸设备且事件是鼠标事件,则忽略
  281. if (isTouchDevice.value && event.type === "mousedown") {
  282. return;
  283. }
  284. try {
  285. // 请求麦克风权限
  286. const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
  287. // 创建 MediaRecorder 实例
  288. mediaRecorder.value = new MediaRecorder(stream, {
  289. mimeType: "audio/webm; codecs=opus",
  290. });
  291. // 收集音频数据
  292. mediaRecorder.value.ondataavailable = (e) => {
  293. audioChunks.value.push(e.data);
  294. };
  295. mediaRecorder.value.start(1000); // 每1秒收集一次数据
  296. console.log("Recording started");
  297. } catch (error) {
  298. console.error("Error accessing microphone:", error);
  299. }
  300. };
  301. // 停止录音
  302. const stopRecording = async () => {
  303. return new Promise(async (resolve) => {
  304. if (!mediaRecorder.value) {
  305. resolve(new Uint8Array());
  306. return;
  307. }
  308. // 停止录音
  309. mediaRecorder.value.stop();
  310. mediaRecorder.value.stream.getTracks().forEach((track) => track.stop());
  311. // 等待最后的数据可用
  312. mediaRecorder.value.onstop = async () => {
  313. // 合并所有音频片段
  314. const audioBlob = new Blob(audioChunks.value, { type: "audio/webm" });
  315. // 转换为 Uint8Array
  316. const arrayBuffer = await audioBlob.arrayBuffer();
  317. const audioData = new Uint8Array(arrayBuffer);
  318. resolve(audioData);
  319. };
  320. });
  321. };
  322. // 发送音频消息
  323. const sendAudioMessage = async (event) => {
  324. if (isTouchDevice.value && event.type === "mouseup") {
  325. return;
  326. }
  327. console.log("发送音频消息");
  328. try {
  329. // 1. 停止录音并获取音频数据
  330. const audioData = await stopRecording();
  331. // 2. 准备消息体
  332. const message = {
  333. content: text.value, // 如果有文本内容
  334. contentType: MSG_TYPE.AUDIO, // 音频消息类型
  335. messageType: MESSAGE_TYPE_USER, // 单聊消息
  336. fileSuffix: "wav", // 使用webm后缀更准确
  337. file: audioData, // 将Uint8Array转为普通数组
  338. };
  339. // 3. 通过WebSocket发送
  340. wsStore.sendMessage(message);
  341. // 4. 重置状态
  342. mediaRecorder.value = null;
  343. audioChunks.value = [];
  344. } catch (error) {
  345. console.error("Error sending audio message:", error);
  346. }
  347. };
  348. // 发送消息
  349. const sendMessage = () => {
  350. if (!text.value.trim()) return;
  351. const message = {
  352. content: text.value,
  353. contentType: MSG_TYPE.TEXT, // 1: 文本消息
  354. messageType: MESSAGE_TYPE_USER, // 1: 单聊天
  355. };
  356. wsStore.sendMessage(message);
  357. text.value = "";
  358. scrollToBottom();
  359. };
  360. // 发送图片消息
  361. const afterRead = async (file) => {
  362. const arrayBuffer = await file.file.arrayBuffer();
  363. const message = {
  364. content: text.value, // 如果有文本内容
  365. contentType: MSG_TYPE.IMAGE, // 音频消息类型
  366. messageType: MESSAGE_TYPE_USER, // 单聊消息
  367. fileSuffix: file.type, // 使用webm后缀更准确
  368. file: new Uint8Array(arrayBuffer), // 将Uint8Array转为普通数组
  369. };
  370. wsStore.sendMessage(message);
  371. };
  372. // 图片类型
  373. const beforeRead = (file) => {
  374. const realFile = file.file || file;
  375. const type = realFile.type;
  376. const name = realFile.name || "";
  377. if (type === "image/svg+xml" || name.endsWith(".svg")) {
  378. showToast("不支持上传 SVG 格式的图片");
  379. return false;
  380. }
  381. return true;
  382. };
  383. // 创建呼叫:开启语音电话: 调试用,正式逻辑读src/views/im/hook/messagesHook.js
  384. // ==== 1. 发起语音通话 ====
  385. const startAudioOnline = async (contentType) => {
  386. rtcStore.imSate.videoCallModal = true;
  387. rtcStore.imSate.callAvatar = wsStore.toUserInfo.avatar;
  388. rtcStore.imSate.callName = wsStore.toUserInfo.nickname;
  389. rtcStore.isCaller = true;
  390. wsStore.sendMessage({
  391. messageType: MESSAGE_TYPE_USER, // 单聊消息
  392. contentType,
  393. type: Constant.MESSAGE_TRANS_TYPE,
  394. avatar: walletStore.avatar,
  395. });
  396. };
  397. // 时间格式化
  398. const formatTime = (timestamp) => {
  399. const date = new Date(timestamp);
  400. const h = date.getHours().toString().padStart(2, "0");
  401. const m = date.getMinutes().toString().padStart(2, "0");
  402. return `${h}:${m}`;
  403. };
  404. // 页面生命周期
  405. onMounted(() => {
  406. wsStore.toUserInfo.uuid = route.query.uuid;
  407. wsStore.getMessages({
  408. uuid: walletStore.account,
  409. messageType: 1,
  410. friendUsername: route.query.uuid,
  411. });
  412. scrollToBottom();
  413. document.addEventListener("click", handleClickOutside);
  414. });
  415. onUnmounted(() => {
  416. if (isMobile) Keyboard.removeAllListeners();
  417. });
  418. onBeforeUnmount(() => {
  419. document.removeEventListener("click", handleClickOutside);
  420. });
  421. // 判断是否点击在元素外
  422. const handleClickOutside = (event) => {
  423. const emojiEl = emojiRef.value;
  424. const toolsEl = toolsRef.value;
  425. const target = event.target;
  426. if (
  427. showEmoji.value &&
  428. emojiEl &&
  429. !emojiEl.contains(target) &&
  430. !target.closest(".emoji-toggle")
  431. ) {
  432. showEmoji.value = false;
  433. }
  434. if (
  435. showTools.value &&
  436. toolsEl &&
  437. !toolsEl.contains(target) &&
  438. !target.closest(".tools-toggle")
  439. ) {
  440. showTools.value = false;
  441. }
  442. };
  443. // 页面跳转
  444. const goBack = () => router.push("im");
  445. const goDetail = () => router.push("detail");
  446. </script>
  447. <style lang="less" scoped>
  448. .mr12 {
  449. margin-right: 12px;
  450. }
  451. .ml12 {
  452. margin-left: 12px;
  453. }
  454. .text-right {
  455. text-align: right;
  456. }
  457. .page-icon {
  458. width: 24px;
  459. height: 24px;
  460. flex-shrink: 0;
  461. }
  462. .container {
  463. display: flex;
  464. flex-direction: column;
  465. .chat-bg {
  466. height: 126px;
  467. background: linear-gradient(90deg, @theme-color1 0%, #40a4fb 100%);
  468. position: absolute;
  469. left: 0;
  470. right: 0;
  471. z-index: -1;
  472. }
  473. .header-chat {
  474. padding-top: 56px;
  475. margin: 0 16px;
  476. display: flex;
  477. align-items: center;
  478. color: @theme-color1;
  479. .header-title {
  480. flex: 1;
  481. font-family:
  482. PingFang SC,
  483. PingFang SC;
  484. font-weight: 500;
  485. font-size: 19px;
  486. color: #ffffff;
  487. text-align: center;
  488. }
  489. }
  490. .chat-list {
  491. background: #f7f8fa;
  492. border-radius: 30px 30px 0 0;
  493. flex: 1;
  494. overflow: auto;
  495. margin-top: 20px;
  496. padding: 0 16px 24px;
  497. .chat-time {
  498. font-family:
  499. PingFang SC,
  500. PingFang SC;
  501. font-weight: 400;
  502. font-size: 12px;
  503. color: #8d8d8d;
  504. text-align: center;
  505. margin: 20px 0;
  506. }
  507. .box {
  508. .list-item {
  509. display: flex;
  510. margin-bottom: 24px;
  511. .list-img {
  512. width: 44px;
  513. height: 44px;
  514. flex-shrink: 0;
  515. }
  516. .list-cont {
  517. font-family:
  518. PingFang SC,
  519. PingFang SC;
  520. font-weight: 400;
  521. font-size: 12px;
  522. color: #8d8d8d;
  523. max-width: 70%;
  524. display: flex;
  525. flex-direction: column;
  526. align-items: flex-start;
  527. .business-card {
  528. width: 199px;
  529. height: 93px;
  530. background: #ffffff;
  531. border-radius: 10px;
  532. margin-top: 8px;
  533. padding: 10px;
  534. box-sizing: border-box;
  535. .business-card-cont {
  536. display: flex;
  537. align-items: center;
  538. font-family:
  539. PingFang SC,
  540. PingFang SC;
  541. font-weight: 400;
  542. font-size: 15px;
  543. color: #000000;
  544. }
  545. .line {
  546. height: 1px;
  547. background: #f2f2f2;
  548. margin: 10px 0 6px;
  549. }
  550. .business-card-text {
  551. font-family:
  552. PingFang SC,
  553. PingFang SC;
  554. font-weight: 400;
  555. font-size: 10px;
  556. color: #8d8d8d;
  557. }
  558. }
  559. .content {
  560. background: #ffffff; // 对方消息背景白色
  561. color: #000;
  562. border-radius: 10px;
  563. margin-top: 8px;
  564. padding: 8px 17px;
  565. word-break: break-word;
  566. white-space: pre-wrap;
  567. max-width: 100%;
  568. font-family:
  569. PingFang SC,
  570. PingFang SC;
  571. font-weight: 400;
  572. font-size: 15px;
  573. }
  574. .img-message {
  575. margin-top: 8px;
  576. }
  577. }
  578. }
  579. .withdrawal {
  580. display: flex;
  581. justify-content: center;
  582. margin-bottom: 24px;
  583. .withdrawal-text {
  584. width: 142px;
  585. height: 29px;
  586. line-height: 29px;
  587. box-sizing: border-box;
  588. background: #f2f2f2;
  589. border-radius: 4px;
  590. font-family:
  591. PingFang SC,
  592. PingFang SC;
  593. font-weight: 400;
  594. font-size: 12px;
  595. color: #8d8d8d;
  596. text-align: center;
  597. }
  598. }
  599. .flex-reverse {
  600. flex-direction: row-reverse;
  601. }
  602. .flex-reverse .list-cont {
  603. align-items: flex-end;
  604. .content {
  605. background: #4d71ff; // 自己的消息是蓝色
  606. color: #ffffff; // 白字
  607. }
  608. }
  609. }
  610. }
  611. .chat-list::-webkit-scrollbar {
  612. width: 0;
  613. }
  614. .page-foot {
  615. position: relative;
  616. background-color: #fff;
  617. .flex-box {
  618. padding: 8px 16px;
  619. display: flex;
  620. align-items: center;
  621. box-sizing: border-box;
  622. .input {
  623. flex: 1;
  624. background: #f2f2f2;
  625. border-radius: 17px;
  626. border: 1px solid #d8d8d8;
  627. padding: 6px 16px;
  628. font-weight: 500;
  629. font-size: 15px;
  630. margin: 0 12px;
  631. overflow-y: auto;
  632. }
  633. }
  634. }
  635. }
  636. .app-box {
  637. position: fixed;
  638. bottom: 210px;
  639. left: 0;
  640. right: 0;
  641. background: white;
  642. transition: transform 0.3s ease;
  643. transform: translateY(100%);
  644. display: flex;
  645. flex-wrap: wrap;
  646. padding: 10px 0 0 32px;
  647. &.visible {
  648. transform: translateY(0);
  649. }
  650. .tool-btn {
  651. margin-right: 32px;
  652. display: flex;
  653. flex-direction: column;
  654. align-items: center;
  655. font-family:
  656. PingFang SC,
  657. PingFang SC;
  658. font-weight: 400;
  659. font-size: 12px;
  660. color: #000000;
  661. .tool-icon {
  662. width: 56px;
  663. height: 56px;
  664. margin-bottom: 4px;
  665. }
  666. }
  667. }
  668. .page-foot {
  669. position: relative;
  670. z-index: 10; /* 确保输入框在上层 */
  671. }
  672. .local-video {
  673. position: absolute;
  674. bottom: 20px;
  675. right: 20px;
  676. width: 200px;
  677. height: auto;
  678. border: 2px solid white;
  679. border-radius: 8px;
  680. }
  681. .remote-video {
  682. width: 100%;
  683. height: 100vh;
  684. background: black;
  685. object-fit: cover;
  686. }
  687. </style>