index.vue 17 KB

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