index.vue 14 KB

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