123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749 |
- <template>
- <div
- class="container"
- :style="{ height: `calc(100% - ${currentBottomHeight}px)` }"
- >
- <div class="chat-bg"></div>
- <!-- 顶部导航 -->
- <div class="header-chat">
- <svg-icon class="page-icon" name="lf-arrow" @click="goBack" />
- <div class="header-title">群聊2({{ wsStore.messages.length }})</div>
- <svg-icon class="page-icon" name="more" @click="goDetail" />
- </div>
- <!-- 聊天消息区域 -->
- <div class="chat-list" ref="chatListRef">
- <div v-for="(item, index) in wsStore.messages" :key="index">
- <div class="chat-time">
- {{ formatTime(item.timestamp || Date.now()) }}
- </div>
- <div class="box">
- <div
- class="list-item"
- :class="isSender(item.toUsername) ? '' : 'flex-reverse'"
- >
- <!-- 头像 -->
- <van-image
- class="list-img"
- :class="isSender(item.toUsername) ? 'mr12' : 'ml12'"
- round
- :src="item.avatar"
- @click="router.push('personal')"
- />
- <!-- 内容 -->
- <div class="list-cont">
- <div>{{ item.fromUsername || "匿名用户" }}</div>
- <!-- 文本消息 -->
- <div class="content" v-if="item.contentType === MSG_TYPE.TEXT">
- {{ item.content }}
- </div>
- <!-- 图片消息 -->
- <div
- class="img-message"
- v-else-if="item.contentType === MSG_TYPE.IMAGE"
- >
- <van-image
- :src="item?.localUrl || IM_PATH + item.url"
- style="max-width: 120px; border-radius: 8px"
- @click="previewImage(item)"
- />
- </div>
- <!-- 名片消息 -->
- <div
- class="content card-message"
- v-else-if="item.contentType === 3"
- >
- <div class="card-title">名片</div>
- <div class="card-name">{{ item.content }}</div>
- </div>
- <!-- 录音消息 -->
- <div
- class="audio-message"
- v-else-if="item.contentType === MSG_TYPE.AUDIO"
- >
- <!-- <audio
- v-if="item.localUrl"
- :src="item.localUrl"
- controls
- style="width: 200px"
- />
- <audio
- v-else
- :src="IM_PATH + item.url"
- controls
- style="width: 200px"
- /> -->
- <messageAudio
- :src="item?.localUrl || IM_PATH + item.url"
- :isSender="isSender(item.toUsername)"
- />
- </div>
- <!-- 其他未知类型 -->
- <div class="content" v-else>[未知消息类型]</div>
- </div>
- </div>
- </div>
- </div>
- </div>
- <!-- 输入框 -->
- <div class="page-foot">
- <div class="flex-box">
- <svg-icon
- type="button"
- class="page-icon"
- name="voice"
- @mousedown="startAudio"
- @touchstart="startAudio"
- @mouseup="sendAudioMessage"
- @touchend="sendAudioMessage"
- />
- <van-field
- rows="1"
- type="textarea"
- :border="false"
- autosize
- class="input"
- v-model="text"
- @focus="onFocus"
- placeholder="输入文本"
- @keyup.enter="sendMessage"
- />
- <svg-icon
- class="page-icon mr12 emoji-toggle"
- name="emoji"
- @click="toggleAppBox(1)"
- />
- <svg-icon
- class="page-icon tools-toggle"
- name="add2"
- @click="toggleAppBox(2)"
- />
- </div>
- <!-- 表情面板 -->
- <div
- class="app-box"
- v-show="showEmoji"
- :style="{ height: `${appBoxHeight}px` }"
- ref="emojiRef"
- >
- 😀 😃 😄 😁 😆 😅 😂 🤣 😊 😇 🙂 🙃 😉 😌 😍
- </div>
- <!-- 工具栏面板 -->
- <div
- class="app-box"
- v-show="showTools"
- :style="{ height: `${appBoxHeight}px` }"
- ref="toolsRef"
- >
- <div class="tool-btn">
- <van-uploader :after-read="afterRead" :before-read="beforeRead">
- <svg-icon class="tool-icon" name="tp" />
- </van-uploader>
- <div>图片</div>
- </div>
- <div class="tool-btn">
- <svg-icon class="tool-icon" name="ps" />
- <div>拍摄</div>
- </div>
- <div class="tool-btn" @click="startAudioOnline">
- <svg-icon class="tool-icon" name="yyth" />
- <div>语音通话</div>
- </div>
- <div class="tool-btn">
- <svg-icon class="tool-icon" name="spth" />
- <div>视频通话</div>
- </div>
- <div class="tool-btn">
- <svg-icon class="tool-icon" name="mp" />
- <div>名片</div>
- </div>
- </div>
- </div>
- <!-- 来电弹窗 -->
- <div v-if="rtcStore.imSate.videoCallModal && !inCall" class="call-modal">
- <p>{{ rtcStore.imSate.callName }} 正在呼叫你</p>
- <button @click="acceptCall">接听</button>
- <button @click="rejectCall">拒接</button>
- </div>
- <!-- 通话中显示挂断按钮 -->
- <div v-if="inCall" class="call-modal">
- <p>与 {{ rtcStore.imSate.callName }} 通话中...</p>
- <button @click="hangupCall">挂断</button>
- </div>
- </div>
- </template>
- <script setup>
- import { ref, computed, onMounted, onUnmounted, onBeforeUnmount } from "vue";
- import { useRouter, useRoute } from "vue-router";
- import { useWebSocketStore } from "@/stores/modules/webSocketStore.js";
- import { useWalletStore } from "@/stores/modules/walletStore.js";
- import { Keyboard } from "@capacitor/keyboard";
- import { Capacitor } from "@capacitor/core";
- import { MSG_TYPE, MESSAGE_TYPE_USER } from "@/common/constant/msgType";
- import messageAudio from "@/views/im/components/messageAudio/index.vue";
- import { showToast, showImagePreview } from "vant";
- import { useWebRTCStore } from "@/stores/modules/webrtcStore";
- import * as Constant from "@/common/constant/Constant";
- const IM_PATH = import.meta.env.VITE_IM_PATH_FIlE;
- // 路由 & store
- const router = useRouter();
- const route = useRoute();
- const wsStore = useWebSocketStore();
- const walletStore = useWalletStore();
- const rtcStore = useWebRTCStore();
- // 输入框文本
- const text = ref("");
- // 状态管理
- const keyboardHeight = ref(0);
- const showEmoji = ref(false);
- const showTools = ref(false);
- const appBoxHeight = ref(210);
- const chatListRef = ref(null);
- const emojiRef = ref(null);
- const toolsRef = ref(null);
- const inCall = ref(false); // 是否处于通话中
- // 示例用户
- const currentUser = ref({
- avatar: "https://example.com/avatar.jpg",
- nickname: "张三",
- });
- const isSender = (toUsername) => {
- return walletStore.account === toUsername;
- };
- // 滚动到底部
- const scrollToBottom = () => {
- nextTick(() => {
- if (chatListRef.value) {
- const el = chatListRef.value;
- el.scrollTop = el.scrollHeight;
- }
- });
- };
- watch(
- () => wsStore.messages.length,
- () => {
- scrollToBottom();
- }
- );
- // 平台判断
- const isMobile = Capacitor.getPlatform() !== "web";
- // 语音
- const isTouchDevice = ref(false);
- const mediaRecorder = ref(null); // 录音对象
- const audioChunks = ref([]); // 录音数据
- // 计算当前底部总高度
- const currentBottomHeight = computed(() => {
- if (keyboardHeight.value > 0) return keyboardHeight.value;
- if (showEmoji.value || showTools.value) return appBoxHeight.value;
- return 0;
- });
- // 切换表情/工具面板
- const toggleAppBox = async (type) => {
- if (isMobile) await Keyboard.hide();
- keyboardHeight.value = 0;
- if (type === 1) {
- showEmoji.value = !showEmoji.value;
- showTools.value = false;
- } else {
- showTools.value = !showTools.value;
- showEmoji.value = false;
- }
- scrollToBottom();
- };
- // 预览图片
- const previewImage = (item) => {
- const imageList = wsStore.messages
- .filter((m) => m.contentType === MSG_TYPE.IMAGE)
- .map((m) => m.localUrl || IM_PATH + m.url);
- const index = imageList.findIndex(
- (url) => url === (item.localUrl || IM_PATH + item.url)
- );
- showImagePreview({
- images: imageList,
- startPosition: index,
- });
- };
- const onFocus = () => {
- // 隐藏所有面板
- showEmoji.value = false;
- showTools.value = false;
- if (isMobile) setupKeyboardListeners();
- scrollToBottom();
- };
- // 键盘监听
- const setupKeyboardListeners = async () => {
- Keyboard.addListener("keyboardWillShow", (info) => {
- keyboardHeight.value = info.keyboardHeight;
- });
- Keyboard.addListener("keyboardWillHide", () => {
- keyboardHeight.value = 0;
- });
- };
- // 录音
- const startAudio = async (event) => {
- if (event.type === "touchstart") {
- isTouchDevice.value = true;
- }
- // 如果是触摸设备且事件是鼠标事件,则忽略
- if (isTouchDevice.value && event.type === "mousedown") {
- return;
- }
- try {
- // 请求麦克风权限
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
- // 创建 MediaRecorder 实例
- mediaRecorder.value = new MediaRecorder(stream, {
- mimeType: "audio/webm; codecs=opus",
- });
- // 收集音频数据
- mediaRecorder.value.ondataavailable = (e) => {
- audioChunks.value.push(e.data);
- };
- mediaRecorder.value.start(1000); // 每1秒收集一次数据
- console.log("Recording started");
- } catch (error) {
- console.error("Error accessing microphone:", error);
- }
- };
- // 停止录音
- const stopRecording = async () => {
- return new Promise(async (resolve) => {
- if (!mediaRecorder.value) {
- resolve(new Uint8Array());
- return;
- }
- // 停止录音
- mediaRecorder.value.stop();
- mediaRecorder.value.stream.getTracks().forEach((track) => track.stop());
- // 等待最后的数据可用
- mediaRecorder.value.onstop = async () => {
- // 合并所有音频片段
- const audioBlob = new Blob(audioChunks.value, { type: "audio/webm" });
- // 转换为 Uint8Array
- const arrayBuffer = await audioBlob.arrayBuffer();
- const audioData = new Uint8Array(arrayBuffer);
- resolve(audioData);
- };
- });
- };
- // 发送音频消息
- const sendAudioMessage = async (event) => {
- if (isTouchDevice.value && event.type === "mouseup") {
- return;
- }
- console.log("发送音频消息");
- try {
- // 1. 停止录音并获取音频数据
- const audioData = await stopRecording();
- // 2. 准备消息体
- const message = {
- content: text.value, // 如果有文本内容
- contentType: MSG_TYPE.AUDIO, // 音频消息类型
- messageType: MESSAGE_TYPE_USER, // 单聊消息
- fileSuffix: "wav", // 使用webm后缀更准确
- file: audioData, // 将Uint8Array转为普通数组
- };
- // 3. 通过WebSocket发送
- wsStore.sendMessage(message);
- // 4. 重置状态
- mediaRecorder.value = null;
- audioChunks.value = [];
- } catch (error) {
- console.error("Error sending audio message:", error);
- }
- };
- // 发送消息
- const sendMessage = () => {
- if (!text.value.trim()) return;
- const message = {
- content: text.value,
- contentType: MSG_TYPE.TEXT, // 1: 文本消息
- messageType: MESSAGE_TYPE_USER, // 1: 单聊天
- };
- wsStore.sendMessage(message);
- text.value = "";
- scrollToBottom();
- };
- // 发送图片消息
- const afterRead = async (file) => {
- const arrayBuffer = await file.file.arrayBuffer();
- const message = {
- content: text.value, // 如果有文本内容
- contentType: MSG_TYPE.IMAGE, // 音频消息类型
- messageType: MESSAGE_TYPE_USER, // 单聊消息
- fileSuffix: file.type, // 使用webm后缀更准确
- file: new Uint8Array(arrayBuffer), // 将Uint8Array转为普通数组
- };
- wsStore.sendMessage(message);
- };
- // 图片类型
- const beforeRead = (file) => {
- const realFile = file.file || file;
- const type = realFile.type;
- const name = realFile.name || "";
- if (type === "image/svg+xml" || name.endsWith(".svg")) {
- showToast("不支持上传 SVG 格式的图片");
- return false;
- }
- return true;
- };
- // 创建呼叫:开启语音电话: 调试用,正式逻辑读src/views/im/hook/messagesHook.js
- // ==== 1. 发起语音通话 ====
- const startAudioOnline = async () => {
- inCall.value = true
- wsStore.sendMessage({
- messageType: MESSAGE_TYPE_USER, // 单聊消息
- contentType: Constant.DIAL_AUDIO_ONLINE,
- type: Constant.MESSAGE_TRANS_TYPE,
- });
- };
- // ==== 2. 接听来电 ====
- async function acceptCall() {}
- // ==== 3. 拒接来电 ====
- function rejectCall() {
- wsStore.sendMessage({
- messageType: MESSAGE_TYPE_USER, // 单聊消息
- contentType: Constant.REJECT_AUDIO_ONLINE,
- type: Constant.MESSAGE_TRANS_TYPE,
- });
- }
- // ==== 4. 挂断通话 ====
- function hangupCall() {
- rtcStore.cleanup();
- }
- // ==== 5. 监听信令消息 ====
- // 建议你在 useWebSocketStore 里实现 onMessage 订阅信令消息
- // 这里模拟简易监听,示范关键流程
- // 时间格式化
- const formatTime = (timestamp) => {
- const date = new Date(timestamp);
- const h = date.getHours().toString().padStart(2, "0");
- const m = date.getMinutes().toString().padStart(2, "0");
- return `${h}:${m}`;
- };
- // 页面生命周期
- onMounted(() => {
- wsStore.toUserInfo.uuid = route.query.uuid;
- wsStore.getMessages({
- uuid: walletStore.account,
- messageType: 1,
- friendUsername: route.query.uuid,
- });
- scrollToBottom();
- document.addEventListener("click", handleClickOutside);
- });
- onUnmounted(() => {
- if (isMobile) Keyboard.removeAllListeners();
- });
- onBeforeUnmount(() => {
- document.removeEventListener("click", handleClickOutside);
- });
- // 判断是否点击在元素外
- const handleClickOutside = (event) => {
- const emojiEl = emojiRef.value;
- const toolsEl = toolsRef.value;
- const target = event.target;
- if (
- showEmoji.value &&
- emojiEl &&
- !emojiEl.contains(target) &&
- !target.closest(".emoji-toggle")
- ) {
- showEmoji.value = false;
- }
- if (
- showTools.value &&
- toolsEl &&
- !toolsEl.contains(target) &&
- !target.closest(".tools-toggle")
- ) {
- showTools.value = false;
- }
- };
- // 页面跳转
- const goBack = () => router.push("im");
- const goDetail = () => router.push("detail");
- </script>
- <style lang="less" scoped>
- .mr12 {
- margin-right: 12px;
- }
- .ml12 {
- margin-left: 12px;
- }
- .text-right {
- text-align: right;
- }
- .page-icon {
- width: 24px;
- height: 24px;
- flex-shrink: 0;
- }
- .container {
- display: flex;
- flex-direction: column;
- .chat-bg {
- height: 126px;
- background: linear-gradient(90deg, @theme-color1 0%, #40a4fb 100%);
- position: absolute;
- left: 0;
- right: 0;
- z-index: -1;
- }
- .header-chat {
- padding-top: 56px;
- margin: 0 16px;
- display: flex;
- align-items: center;
- color: @theme-color1;
- .header-title {
- flex: 1;
- font-family:
- PingFang SC,
- PingFang SC;
- font-weight: 500;
- font-size: 19px;
- color: #ffffff;
- text-align: center;
- }
- }
- .chat-list {
- background: #f7f8fa;
- border-radius: 30px 30px 0 0;
- flex: 1;
- overflow: auto;
- margin-top: 20px;
- padding: 0 16px 24px;
- .chat-time {
- font-family:
- PingFang SC,
- PingFang SC;
- font-weight: 400;
- font-size: 12px;
- color: #8d8d8d;
- text-align: center;
- margin: 20px 0;
- }
- .box {
- .list-item {
- display: flex;
- margin-bottom: 24px;
- .list-img {
- width: 44px;
- height: 44px;
- flex-shrink: 0;
- }
- .list-cont {
- font-family:
- PingFang SC,
- PingFang SC;
- font-weight: 400;
- font-size: 12px;
- color: #8d8d8d;
- max-width: 70%;
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- .business-card {
- width: 199px;
- height: 93px;
- background: #ffffff;
- border-radius: 10px;
- margin-top: 8px;
- padding: 10px;
- box-sizing: border-box;
- .business-card-cont {
- display: flex;
- align-items: center;
- font-family:
- PingFang SC,
- PingFang SC;
- font-weight: 400;
- font-size: 15px;
- color: #000000;
- }
- .line {
- height: 1px;
- background: #f2f2f2;
- margin: 10px 0 6px;
- }
- .business-card-text {
- font-family:
- PingFang SC,
- PingFang SC;
- font-weight: 400;
- font-size: 10px;
- color: #8d8d8d;
- }
- }
- .content {
- background: #ffffff; // 对方消息背景白色
- color: #000;
- border-radius: 10px;
- margin-top: 8px;
- padding: 8px 17px;
- word-break: break-word;
- white-space: pre-wrap;
- max-width: 100%;
- font-family:
- PingFang SC,
- PingFang SC;
- font-weight: 400;
- font-size: 15px;
- }
- .img-message {
- margin-top: 8px;
- }
- }
- }
- .withdrawal {
- display: flex;
- justify-content: center;
- margin-bottom: 24px;
- .withdrawal-text {
- width: 142px;
- height: 29px;
- line-height: 29px;
- box-sizing: border-box;
- background: #f2f2f2;
- border-radius: 4px;
- font-family:
- PingFang SC,
- PingFang SC;
- font-weight: 400;
- font-size: 12px;
- color: #8d8d8d;
- text-align: center;
- }
- }
- .flex-reverse {
- flex-direction: row-reverse;
- }
- .flex-reverse .list-cont {
- align-items: flex-end;
- .content {
- background: #4d71ff; // 自己的消息是蓝色
- color: #ffffff; // 白字
- }
- }
- }
- }
- .chat-list::-webkit-scrollbar {
- width: 0;
- }
- .page-foot {
- position: relative;
- background-color: #fff;
- .flex-box {
- padding: 8px 16px;
- display: flex;
- align-items: center;
- box-sizing: border-box;
- .input {
- flex: 1;
- background: #f2f2f2;
- border-radius: 17px;
- border: 1px solid #d8d8d8;
- padding: 6px 16px;
- font-weight: 500;
- font-size: 15px;
- margin: 0 12px;
- overflow-y: auto;
- }
- }
- }
- }
- .app-box {
- position: fixed;
- bottom: 210px;
- left: 0;
- right: 0;
- background: white;
- transition: transform 0.3s ease;
- transform: translateY(100%);
- display: flex;
- flex-wrap: wrap;
- padding: 10px 0 0 32px;
- &.visible {
- transform: translateY(0);
- }
- .tool-btn {
- margin-right: 32px;
- display: flex;
- flex-direction: column;
- align-items: center;
- font-family:
- PingFang SC,
- PingFang SC;
- font-weight: 400;
- font-size: 12px;
- color: #000000;
- .tool-icon {
- width: 56px;
- height: 56px;
- margin-bottom: 4px;
- }
- }
- }
- .page-foot {
- position: relative;
- z-index: 10; /* 确保输入框在上层 */
- }
- </style>
|