index.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749
  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" @click="startAudioOnline">
  148. <svg-icon class="tool-icon" name="yyth" />
  149. <div>语音通话</div>
  150. </div>
  151. <div class="tool-btn">
  152. <svg-icon class="tool-icon" name="spth" />
  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 inCall = ref(false); // 是否处于通话中
  204. // 示例用户
  205. const currentUser = ref({
  206. avatar: "https://example.com/avatar.jpg",
  207. nickname: "张三",
  208. });
  209. const isSender = (toUsername) => {
  210. return walletStore.account === toUsername;
  211. };
  212. // 滚动到底部
  213. const scrollToBottom = () => {
  214. nextTick(() => {
  215. if (chatListRef.value) {
  216. const el = chatListRef.value;
  217. el.scrollTop = el.scrollHeight;
  218. }
  219. });
  220. };
  221. watch(
  222. () => wsStore.messages.length,
  223. () => {
  224. scrollToBottom();
  225. }
  226. );
  227. // 平台判断
  228. const isMobile = Capacitor.getPlatform() !== "web";
  229. // 语音
  230. const isTouchDevice = ref(false);
  231. const mediaRecorder = ref(null); // 录音对象
  232. const audioChunks = ref([]); // 录音数据
  233. // 计算当前底部总高度
  234. const currentBottomHeight = computed(() => {
  235. if (keyboardHeight.value > 0) return keyboardHeight.value;
  236. if (showEmoji.value || showTools.value) return appBoxHeight.value;
  237. return 0;
  238. });
  239. // 切换表情/工具面板
  240. const toggleAppBox = async (type) => {
  241. if (isMobile) await Keyboard.hide();
  242. keyboardHeight.value = 0;
  243. if (type === 1) {
  244. showEmoji.value = !showEmoji.value;
  245. showTools.value = false;
  246. } else {
  247. showTools.value = !showTools.value;
  248. showEmoji.value = false;
  249. }
  250. scrollToBottom();
  251. };
  252. // 预览图片
  253. const previewImage = (item) => {
  254. const imageList = wsStore.messages
  255. .filter((m) => m.contentType === MSG_TYPE.IMAGE)
  256. .map((m) => m.localUrl || IM_PATH + m.url);
  257. const index = imageList.findIndex(
  258. (url) => url === (item.localUrl || IM_PATH + item.url)
  259. );
  260. showImagePreview({
  261. images: imageList,
  262. startPosition: index,
  263. });
  264. };
  265. const onFocus = () => {
  266. // 隐藏所有面板
  267. showEmoji.value = false;
  268. showTools.value = false;
  269. if (isMobile) setupKeyboardListeners();
  270. scrollToBottom();
  271. };
  272. // 键盘监听
  273. const setupKeyboardListeners = async () => {
  274. Keyboard.addListener("keyboardWillShow", (info) => {
  275. keyboardHeight.value = info.keyboardHeight;
  276. });
  277. Keyboard.addListener("keyboardWillHide", () => {
  278. keyboardHeight.value = 0;
  279. });
  280. };
  281. // 录音
  282. const startAudio = async (event) => {
  283. if (event.type === "touchstart") {
  284. isTouchDevice.value = true;
  285. }
  286. // 如果是触摸设备且事件是鼠标事件,则忽略
  287. if (isTouchDevice.value && event.type === "mousedown") {
  288. return;
  289. }
  290. try {
  291. // 请求麦克风权限
  292. const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
  293. // 创建 MediaRecorder 实例
  294. mediaRecorder.value = new MediaRecorder(stream, {
  295. mimeType: "audio/webm; codecs=opus",
  296. });
  297. // 收集音频数据
  298. mediaRecorder.value.ondataavailable = (e) => {
  299. audioChunks.value.push(e.data);
  300. };
  301. mediaRecorder.value.start(1000); // 每1秒收集一次数据
  302. console.log("Recording started");
  303. } catch (error) {
  304. console.error("Error accessing microphone:", error);
  305. }
  306. };
  307. // 停止录音
  308. const stopRecording = async () => {
  309. return new Promise(async (resolve) => {
  310. if (!mediaRecorder.value) {
  311. resolve(new Uint8Array());
  312. return;
  313. }
  314. // 停止录音
  315. mediaRecorder.value.stop();
  316. mediaRecorder.value.stream.getTracks().forEach((track) => track.stop());
  317. // 等待最后的数据可用
  318. mediaRecorder.value.onstop = async () => {
  319. // 合并所有音频片段
  320. const audioBlob = new Blob(audioChunks.value, { type: "audio/webm" });
  321. // 转换为 Uint8Array
  322. const arrayBuffer = await audioBlob.arrayBuffer();
  323. const audioData = new Uint8Array(arrayBuffer);
  324. resolve(audioData);
  325. };
  326. });
  327. };
  328. // 发送音频消息
  329. const sendAudioMessage = async (event) => {
  330. if (isTouchDevice.value && event.type === "mouseup") {
  331. return;
  332. }
  333. console.log("发送音频消息");
  334. try {
  335. // 1. 停止录音并获取音频数据
  336. const audioData = await stopRecording();
  337. // 2. 准备消息体
  338. const message = {
  339. content: text.value, // 如果有文本内容
  340. contentType: MSG_TYPE.AUDIO, // 音频消息类型
  341. messageType: MESSAGE_TYPE_USER, // 单聊消息
  342. fileSuffix: "wav", // 使用webm后缀更准确
  343. file: audioData, // 将Uint8Array转为普通数组
  344. };
  345. // 3. 通过WebSocket发送
  346. wsStore.sendMessage(message);
  347. // 4. 重置状态
  348. mediaRecorder.value = null;
  349. audioChunks.value = [];
  350. } catch (error) {
  351. console.error("Error sending audio message:", error);
  352. }
  353. };
  354. // 发送消息
  355. const sendMessage = () => {
  356. if (!text.value.trim()) return;
  357. const message = {
  358. content: text.value,
  359. contentType: MSG_TYPE.TEXT, // 1: 文本消息
  360. messageType: MESSAGE_TYPE_USER, // 1: 单聊天
  361. };
  362. wsStore.sendMessage(message);
  363. text.value = "";
  364. scrollToBottom();
  365. };
  366. // 发送图片消息
  367. const afterRead = async (file) => {
  368. const arrayBuffer = await file.file.arrayBuffer();
  369. const message = {
  370. content: text.value, // 如果有文本内容
  371. contentType: MSG_TYPE.IMAGE, // 音频消息类型
  372. messageType: MESSAGE_TYPE_USER, // 单聊消息
  373. fileSuffix: file.type, // 使用webm后缀更准确
  374. file: new Uint8Array(arrayBuffer), // 将Uint8Array转为普通数组
  375. };
  376. wsStore.sendMessage(message);
  377. };
  378. // 图片类型
  379. const beforeRead = (file) => {
  380. const realFile = file.file || file;
  381. const type = realFile.type;
  382. const name = realFile.name || "";
  383. if (type === "image/svg+xml" || name.endsWith(".svg")) {
  384. showToast("不支持上传 SVG 格式的图片");
  385. return false;
  386. }
  387. return true;
  388. };
  389. // 创建呼叫:开启语音电话: 调试用,正式逻辑读src/views/im/hook/messagesHook.js
  390. // ==== 1. 发起语音通话 ====
  391. const startAudioOnline = async () => {
  392. inCall.value = true
  393. wsStore.sendMessage({
  394. messageType: MESSAGE_TYPE_USER, // 单聊消息
  395. contentType: Constant.DIAL_AUDIO_ONLINE,
  396. type: Constant.MESSAGE_TRANS_TYPE,
  397. });
  398. };
  399. // ==== 2. 接听来电 ====
  400. async function acceptCall() {}
  401. // ==== 3. 拒接来电 ====
  402. function rejectCall() {
  403. wsStore.sendMessage({
  404. messageType: MESSAGE_TYPE_USER, // 单聊消息
  405. contentType: Constant.REJECT_AUDIO_ONLINE,
  406. type: Constant.MESSAGE_TRANS_TYPE,
  407. });
  408. }
  409. // ==== 4. 挂断通话 ====
  410. function hangupCall() {
  411. rtcStore.cleanup();
  412. }
  413. // ==== 5. 监听信令消息 ====
  414. // 建议你在 useWebSocketStore 里实现 onMessage 订阅信令消息
  415. // 这里模拟简易监听,示范关键流程
  416. // 时间格式化
  417. const formatTime = (timestamp) => {
  418. const date = new Date(timestamp);
  419. const h = date.getHours().toString().padStart(2, "0");
  420. const m = date.getMinutes().toString().padStart(2, "0");
  421. return `${h}:${m}`;
  422. };
  423. // 页面生命周期
  424. onMounted(() => {
  425. wsStore.toUserInfo.uuid = route.query.uuid;
  426. wsStore.getMessages({
  427. uuid: walletStore.account,
  428. messageType: 1,
  429. friendUsername: route.query.uuid,
  430. });
  431. scrollToBottom();
  432. document.addEventListener("click", handleClickOutside);
  433. });
  434. onUnmounted(() => {
  435. if (isMobile) Keyboard.removeAllListeners();
  436. });
  437. onBeforeUnmount(() => {
  438. document.removeEventListener("click", handleClickOutside);
  439. });
  440. // 判断是否点击在元素外
  441. const handleClickOutside = (event) => {
  442. const emojiEl = emojiRef.value;
  443. const toolsEl = toolsRef.value;
  444. const target = event.target;
  445. if (
  446. showEmoji.value &&
  447. emojiEl &&
  448. !emojiEl.contains(target) &&
  449. !target.closest(".emoji-toggle")
  450. ) {
  451. showEmoji.value = false;
  452. }
  453. if (
  454. showTools.value &&
  455. toolsEl &&
  456. !toolsEl.contains(target) &&
  457. !target.closest(".tools-toggle")
  458. ) {
  459. showTools.value = false;
  460. }
  461. };
  462. // 页面跳转
  463. const goBack = () => router.push("im");
  464. const goDetail = () => router.push("detail");
  465. </script>
  466. <style lang="less" scoped>
  467. .mr12 {
  468. margin-right: 12px;
  469. }
  470. .ml12 {
  471. margin-left: 12px;
  472. }
  473. .text-right {
  474. text-align: right;
  475. }
  476. .page-icon {
  477. width: 24px;
  478. height: 24px;
  479. flex-shrink: 0;
  480. }
  481. .container {
  482. display: flex;
  483. flex-direction: column;
  484. .chat-bg {
  485. height: 126px;
  486. background: linear-gradient(90deg, @theme-color1 0%, #40a4fb 100%);
  487. position: absolute;
  488. left: 0;
  489. right: 0;
  490. z-index: -1;
  491. }
  492. .header-chat {
  493. padding-top: 56px;
  494. margin: 0 16px;
  495. display: flex;
  496. align-items: center;
  497. color: @theme-color1;
  498. .header-title {
  499. flex: 1;
  500. font-family:
  501. PingFang SC,
  502. PingFang SC;
  503. font-weight: 500;
  504. font-size: 19px;
  505. color: #ffffff;
  506. text-align: center;
  507. }
  508. }
  509. .chat-list {
  510. background: #f7f8fa;
  511. border-radius: 30px 30px 0 0;
  512. flex: 1;
  513. overflow: auto;
  514. margin-top: 20px;
  515. padding: 0 16px 24px;
  516. .chat-time {
  517. font-family:
  518. PingFang SC,
  519. PingFang SC;
  520. font-weight: 400;
  521. font-size: 12px;
  522. color: #8d8d8d;
  523. text-align: center;
  524. margin: 20px 0;
  525. }
  526. .box {
  527. .list-item {
  528. display: flex;
  529. margin-bottom: 24px;
  530. .list-img {
  531. width: 44px;
  532. height: 44px;
  533. flex-shrink: 0;
  534. }
  535. .list-cont {
  536. font-family:
  537. PingFang SC,
  538. PingFang SC;
  539. font-weight: 400;
  540. font-size: 12px;
  541. color: #8d8d8d;
  542. max-width: 70%;
  543. display: flex;
  544. flex-direction: column;
  545. align-items: flex-start;
  546. .business-card {
  547. width: 199px;
  548. height: 93px;
  549. background: #ffffff;
  550. border-radius: 10px;
  551. margin-top: 8px;
  552. padding: 10px;
  553. box-sizing: border-box;
  554. .business-card-cont {
  555. display: flex;
  556. align-items: center;
  557. font-family:
  558. PingFang SC,
  559. PingFang SC;
  560. font-weight: 400;
  561. font-size: 15px;
  562. color: #000000;
  563. }
  564. .line {
  565. height: 1px;
  566. background: #f2f2f2;
  567. margin: 10px 0 6px;
  568. }
  569. .business-card-text {
  570. font-family:
  571. PingFang SC,
  572. PingFang SC;
  573. font-weight: 400;
  574. font-size: 10px;
  575. color: #8d8d8d;
  576. }
  577. }
  578. .content {
  579. background: #ffffff; // 对方消息背景白色
  580. color: #000;
  581. border-radius: 10px;
  582. margin-top: 8px;
  583. padding: 8px 17px;
  584. word-break: break-word;
  585. white-space: pre-wrap;
  586. max-width: 100%;
  587. font-family:
  588. PingFang SC,
  589. PingFang SC;
  590. font-weight: 400;
  591. font-size: 15px;
  592. }
  593. .img-message {
  594. margin-top: 8px;
  595. }
  596. }
  597. }
  598. .withdrawal {
  599. display: flex;
  600. justify-content: center;
  601. margin-bottom: 24px;
  602. .withdrawal-text {
  603. width: 142px;
  604. height: 29px;
  605. line-height: 29px;
  606. box-sizing: border-box;
  607. background: #f2f2f2;
  608. border-radius: 4px;
  609. font-family:
  610. PingFang SC,
  611. PingFang SC;
  612. font-weight: 400;
  613. font-size: 12px;
  614. color: #8d8d8d;
  615. text-align: center;
  616. }
  617. }
  618. .flex-reverse {
  619. flex-direction: row-reverse;
  620. }
  621. .flex-reverse .list-cont {
  622. align-items: flex-end;
  623. .content {
  624. background: #4d71ff; // 自己的消息是蓝色
  625. color: #ffffff; // 白字
  626. }
  627. }
  628. }
  629. }
  630. .chat-list::-webkit-scrollbar {
  631. width: 0;
  632. }
  633. .page-foot {
  634. position: relative;
  635. background-color: #fff;
  636. .flex-box {
  637. padding: 8px 16px;
  638. display: flex;
  639. align-items: center;
  640. box-sizing: border-box;
  641. .input {
  642. flex: 1;
  643. background: #f2f2f2;
  644. border-radius: 17px;
  645. border: 1px solid #d8d8d8;
  646. padding: 6px 16px;
  647. font-weight: 500;
  648. font-size: 15px;
  649. margin: 0 12px;
  650. overflow-y: auto;
  651. }
  652. }
  653. }
  654. }
  655. .app-box {
  656. position: fixed;
  657. bottom: 210px;
  658. left: 0;
  659. right: 0;
  660. background: white;
  661. transition: transform 0.3s ease;
  662. transform: translateY(100%);
  663. display: flex;
  664. flex-wrap: wrap;
  665. padding: 10px 0 0 32px;
  666. &.visible {
  667. transform: translateY(0);
  668. }
  669. .tool-btn {
  670. margin-right: 32px;
  671. display: flex;
  672. flex-direction: column;
  673. align-items: center;
  674. font-family:
  675. PingFang SC,
  676. PingFang SC;
  677. font-weight: 400;
  678. font-size: 12px;
  679. color: #000000;
  680. .tool-icon {
  681. width: 56px;
  682. height: 56px;
  683. margin-bottom: 4px;
  684. }
  685. }
  686. }
  687. .page-foot {
  688. position: relative;
  689. z-index: 10; /* 确保输入框在上层 */
  690. }
  691. </style>