index.vue 15 KB

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