1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459 |
- <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" v-if="wsStore.toUserInfo.type == 'group'"><span class="m-ellipsis">{{ wsStore.toUserInfo.sessionName || '群聊' }}</span>({{ groupMembersArr.length }})</div>
- <div v-else class="header-title">{{ wsStore.toUserInfo.sessionName }}</div>
- <svg-icon class="page-icon" name="more" @click="goDetail" />
- </div>
- <!-- 群公告 -->
- <div class="groupNotice" v-if="!noticeRead && wsStore.toUserInfo.type == 'group' && wsStore.toUserInfo.notice" @click="showNotice = true">
- <div class="m-ellipsis">群公告:{{ wsStore.toUserInfo.notice }}</div>
- <svg-icon class="item-icon" name="right1" />
- </div>
- <!-- 聊天消息区域 -->
- <div class="chat-list" ref="chatListRef">
- <van-loading class="load-box" size="24px" v-if="wsStore.loading">加载中...</van-loading>
- <template v-else>
- <van-pull-refresh v-model="refreshLoading" @refresh="onRefresh" style="overflow: initial;">
- <div v-for="(item, index) in imMessages" :key="index">
- <div class="chat-time">
- {{ item.createDate || formatTime(Date.now()) }}
- </div>
- <div class="box" v-if="item.contentType !== MsgType.MSG_TYPE.NOTICE">
- <div
- class="list-item"
- :class="isSender(item) ? '' : 'flex-reverse'"
- >
- <!-- 头像 -->
- <van-image
- class="list-img"
- :class="isSender(item) ? 'mr12' : 'ml12'"
- round
- :src="formatAvatarUrl(item)"
- @click="goToPage(item)"
- />
- <!-- 内容 -->
- <div class="list-cont">
- <div>{{ item.sender?item.sender.nickname: (item.nickname || item.fromUsername || "匿名用户") }}</div>
- <div v-if="!item.isTemp || item.from == walletStore.account" style="position: relative;">
- <!-- 文本消息 -->
- <div class="content" v-if="item.contentType === MsgType.MSG_TYPE.TEXT" @click="onLongPress(item)">
- <span v-if="item.err" style="color: red;font-size: small;">{{item.messageType == MsgType.MESSAGE_TYPE_USER?'对方已不是您的好友':'您已不在群里'}}</span>
- {{ item.content }}
- </div>
- <!-- 图片消息 -->
- <div
- class="img-message"
- v-else-if="item.contentType === MsgType.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 === MsgType.MSG_TYPE.AUDIO"
- >
- <messageAudio
- :src="item?.localUrl || IM_PATH + item.url"
- :isSender="isSender(item)"
- />
- </div>
- <!-- 拍摄 -->
- <video class="video-message" :isSender="isSender(item)" controls v-else-if="item.contentType === MsgType.MSG_TYPE.VIDEO" :src="item?.localUrl || IM_PATH + item.url"></video>
- <!-- 语音消息 -->
- <div class="content" v-if="item.contentType === Constant.REJECT_AUDIO_ONLINE || item.contentType === Constant.REJECT_VIDEO_ONLINE">[对方拒绝]</div>
- <div class="content" v-if="item.contentType === Constant.CANCELL_AUDIO_ONLINE || item.contentType === Constant.CANCELL_VIDEO_ONLINE">[通话结束]</div>
-
- <div v-if="item.quote > 0" class="quotation">{{ item.quoteMsg?.content }}</div>
- </div>
- <!-- 阅后即焚消息 -->
- <div
- v-else
- class="burn-message"
- @click="viewBurnMessage(item)"
- >
- <!-- 未查看 -->
- <div v-if="!item.view && item.content" class="burn-mask">
- 阅后即焚,点击查看
- </div>
- <!-- 已查看且正在倒计时 -->
- <template v-else-if="item.view && item.countdown > 0 && item.content">
- <!-- 文本类型 -->
- <div v-if="item.contentType === MsgType.MSG_TYPE.TEXT" class="burn-content">
- {{ item.content }}
- <span class="burn-countdown">{{ item.countdown }}s</span>
- </div>
- <!-- 录音类型 -->
- <div v-else-if="item.contentType === MsgType.MSG_TYPE.AUDIO" class="burn-audio">
- <messageAudio
- :src="item?.localUrl || IM_PATH + item.url"
- :isSender="isSender(item)"
- />
- <span class="burn-countdown">{{ item.countdown }}s</span>
- </div>
- </template>
- <!-- 倒计时结束 -->
- <div v-else class="burn-destroyed">
- 该消息已焚毁
- </div>
- </div>
- </div>
- </div>
- </div>
- <div class="chat-time" v-if="item.contentType === MsgType.MSG_TYPE.NOTICE">
- {{ item.content }}
- </div>
- </div>
- </van-pull-refresh>
- </template>
- </div>
- <!-- 群公告组件 -->
- <GroupNotice
- v-model:show="showNotice"
- :notice="wsStore.toUserInfo.notice"
- @close="handleNoticeClose"
- />
- <!-- @页面 -->
- <AtUserList ref="atList" @select="onSelectUser" />
- <!-- 撤回弹窗 -->
- <van-action-sheet
- v-model:show="showActionSheet"
- :actions="actions"
- cancel-text="取消"
- @select="onActionSelect"
- />
- <!-- @样式 -->
- <div class="assign" v-if="wsStore.toUserInfo.type == 'group' && wsStore.isassign" @click="changAssign">
- <div class="assign-text">有人@我</div>
- </div>
- <!-- 引用消息展示 -->
- <div v-if="quoteMsg" class="quote-box">
- <div class="quote-content">{{ quoteMsg.fromUsername}}:{{ renderQuoteContent(quoteMsg) }}</div>
- <span class="quote-close" @click="cancelQuote">✕</span>
- </div>
- <!-- 输入框 -->
- <div class="page-foot">
- <div class="flex-box">
- <!-- 录音/文字切换按钮 -->
- <svg-icon
- type="button"
- class="page-icon"
- :name="voiceMode ? 'keyboard' : 'voice'"
- @click="toggleVoiceMode"
- />
- <!-- 文字输入框 或 按住说话按钮 -->
- <div class="box-input">
- <template v-if="!voiceMode">
- <van-field
- rows="1"
- type="textarea"
- :border="false"
- autosize
- class="input"
- v-model="text"
- @focus="onFocus"
- placeholder="输入文本"
- />
- <!-- :disabled="deletefriend.includes(wsStore.toUserInfo.uuid)" -->
- </template>
- <template v-else>
- <div
- class="hold-talk-btn"
- @touchstart.prevent.stop="handleTouchStart"
- @touchmove.prevent.stop="handleTouchMove"
- @touchend.prevent.stop="handleTouchEnd"
- >
- 按住说话
- </div>
- </template>
- </div>
- <van-button class="sendText"
- v-if="text.trim()"
- type="primary"
- size="small"
- round
- @click="sendMessageText"
- >
- 发送
- </van-button>
- <template v-else>
- <svg-icon
- class="page-icon mr12 emoji-toggle"
- name="emoji"
- @click="toggleAppBox(1)"
- />
- <svg-icon v-if="showBurn" @click="closeMessaageBurning"
- class="page-icon-qx"
- name="qx"
- />
- <svg-icon v-else
- class="page-icon tools-toggle"
- name="add2"
- @click="toggleAppBox(2)"
- />
- </template>
- </div>
- <!-- 录音状态浮层 -->
- <div v-if="recording" class="recording-toast">
- <div class="mic-icon"></div>
- <div v-if="cancelRecording" class="cancel-msg">松开手指,取消发送</div>
- <div v-else class="send-msg">手指上滑,取消发送</div>
- </div>
- <!-- 表情面板 -->
- <div
- class="app-box"
- v-show="showEmoji"
- :style="{ height: `${appBoxHeight}px` }"
- ref="emojiRef"
- >
- <div>
- <span
- v-for="(emoji, index) in emojis"
- :key="index"
- class="emoji-item"
- @click="insertEmoji(emoji)"
- >
- {{ emoji }}
- </span>
- </div>
- </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" @click="chatVideo = true">
- <svg-icon class="tool-icon" name="ps" />
- <div>拍摄</div>
- </div>
- <div class="tool-btn" v-if="wsStore.toUserInfo.type == 'user'">
- <svg-icon
- class="tool-icon"
- name="yyth"
- @click="startAudioOnline(Constant.DIAL_AUDIO_ONLINE, 'audio')"
- />
- <div>语音通话</div>
- </div>
- <div class="tool-btn" v-if="wsStore.toUserInfo.type == 'user'">
- <svg-icon
- class="tool-icon"
- name="spth"
- @click="startAudioOnline(Constant.DIAL_VIDEO_ONLINE, 'video')"
- />
- <div>视频通话</div>
- </div>
- <!-- <div class="tool-btn">
- <svg-icon class="tool-icon" name="mp" />
- <div>名片</div>
- </div> -->
- <div class="tool-btn" @click="openMessageBurning">
- <svg-icon class="tool-icon" name="mp" />
- <div>阅后即焚</div>
- </div>
- </div>
- </div>
- <VideoRecorder
- :show="chatVideo"
- @closeVideo="chatVideo = false"
- @sendVideo="handleSendVideo"
- />
- </div>
- </template>
- <script setup>
- 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 * as MsgType 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";
- import { soundVoice } from "@/utils/notifications.js";
- import AtUserList from "./components/AtUserList/index.vue";
- import GroupNotice from "./components/GroupNotice/index.vue";
- import VideoRecorder from "./components/VideoRecorder/index.vue";
- import { messageRevoke } from '@/api/path/im.api';
- 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 refreshLoading = ref(false);
- 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 earMode = ref(false);
- // @成员
- const ccMsg = ref([]);
- // 消息置顶
- const stickTop = ref(0);
- // 引用消息
- const quoteMsg = ref(null);
- // 表情数组
- const emojis = [
- "😀", "😃", "😄", "😁", "😆", "😅", "😂", "🤣",
- "😊", "😇", "🙂", "🙃", "😉", "😌", "😍","😜","🤪","🫣","🤔","🤤","🥲","😋",
- "😀", "😃", "😄", "😁", "😆", "😅", "😂", "🤣",
- ];
- const atList = ref();
- const showBurn = ref(false) // 阅后即焚是否开启
- const showNotice = ref(false);
- const noticeRead = ref(false);
- const currentMsg = ref('');
- const showActionSheet = ref(false)
- const actions = ref([])
- const chatVideo = ref(false);
- // 下拉刷新
- const onRefresh = async () => {
- await wsStore.getMessages({
- uuid: route.query.uuid,
- messageType: wsStore.toUserInfo.type == 'user'?1:2, //1:个人 2:群组
- friendUsername: walletStore.account
- },true);
- };
- const formatAvatarUrl = (item) => {
- const url = item?.sender?.avatar || item?.fromAvatar || ''
- if (/^https?:\/\//.test(url)) {
- return url
- }
- return IM_PATH + url
- }
- const isSender = (item) => {
- if(item.align == 'left'){
- return true
- }else{
- return false
- }
- };
- const imMessages = computed(() =>{
- wsStore.funUpdateUnread(wsStore.toUserInfo.uuid);
- return wsStore.messages.map(item=>{
- item.quoteMsg = null;
- if (item.quote>0){
- item.quoteMsg = wsStore.messages.find(val => val.id == item.quote)
- }
- return item;
- });
- })
- const changAssign = () => {
- wsStore.isassign = '';
- }
- // 滚动到底部
- const scrollToBottom = () => {
- nextTick(() => {
- if (chatListRef.value) {
- const el = chatListRef.value;
- // el.scrollTop = el.scrollHeight;
- setTimeout(() => {
- el.scrollTo({
- left: 0, top: el.scrollHeight + 100,behavior: 'smooth'
- });
- }, 200);
- }
- });
- };
- watch(
- () => wsStore.indexs,
- (newVal,oldVal) => {
- if (newVal !== oldVal) {
- scrollToBottom();
- }
- }
- );
- watch(
- () => rtcStore.isEarpieceMode,
- (newVal) => {
- earMode.value = newVal;
- }
- );
- // 平台判断
- const isMobile = Capacitor.getPlatform() !== "web";
- // 计算当前底部总高度
- 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(!wsStore.verifyChatAuth(wsStore.toUserInfo.uuid)){
- showToast(`${wsStore.toUserInfo.type == 'user' ? '对方已删除' : '您已不在群聊里面'}`);
- return;
- }
- voiceMode.value = false;
- 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 insertEmoji = (emoji) => {
- text.value += emoji;
- };
- // 预览图片
- const previewImage = (item) => {
- const imageList = wsStore.messages
- .filter((m) => m.contentType === MsgType.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 handleQuote = (item) => {
- quoteMsg.value = item //imMessages.find(m => m.id == item.id)
- };
- // 取消消息引用
- const cancelQuote = () => {
- quoteMsg.value = null; // 发送即删除
- };
- // 切换输出模式 true 听筒,false 扬声器
- const changeAudioOutputMode = async (flag) => {
- await rtcStore.setAudioOutputToEarpiece(flag);
- };
- // 群消息置顶操作
- const groupMessageStickTop = (message)=>{
- if(wsStore.toUserInfo.type == 'user'){
- return;
- }
- wsStore.sendMessage({
- content: JSON.stringify({
- content: message.content,
- id: message.id, // 消息id
- sticks: message.id, // 置顶
- }),
- contentType: message.contentType,
- messageType: MsgType.MESSAGE_STICKY_GROUP,
- });
- scrollToBottom();
- }
- // 开启消息阅后即焚
- const openMessageBurning = ()=>{
- showBurn.value = true
- showTools.value = false
- }
- // 关闭消息阅后即焚
- const closeMessaageBurning = ()=>{
- showBurn.value = false
- }
- // 撤回消息
- const revokeMessage = async (message)=>{
- try {
- const res = await messageRevoke({
- messageId:message.id + '',
- uuid:message.fromUuid,
- toUuid:message.toUuid,
- messageType:message.messageType
- })
- if(res.code == 200){
- wsStore.sendMessage({
- content: JSON.stringify({
- // content: '',
- // msgId: message.id + '',
- id: message.id,
- fromUsername:message.fromUsername,
- }),
- contentType: MsgType.MSG_TYPE.NOTICE,
- messageType: wsStore.toUserInfo.type == 'user'?MsgType.MESSAGE_REVOKE:MsgType.MESSAGE_REVOKE_GROUP,
- });
- }else{
- showToast('撤回失败')
- }
- } catch (error) {
- showToast('撤回失败')
- }
-
- }
- // 点击查看阅后即焚消息
- const viewBurnMessage = (message) => {
- // console.log("查看阅后即焚消息:", message);
- if (!message.view) {
- message.view = true;
- message.countdown = 15; // 倒计时
- // 如果是录音,可以自动播放
- if (message.contentType === MsgType.MSG_TYPE.AUDIO) {
- const audio = new Audio(message?.localUrl || IM_PATH + message.url);
- audio.play();
- }
- const timer = setInterval(() => {
- if (message.countdown > 1) {
- message.countdown--;
- } else {
- clearInterval(timer);
- destroyMessage(message); // 发撤回通知
- message.content = '';
- message.countdown = 0; // 标记焚毁
- }
- }, 1000);
- }
- };
- // 真正销毁
- const destroyMessage = (message) => {
- message.content = ''; // 先清空本地显示
- // 如果是群消息, 本地更新消息销毁
- if (message.messageType == MsgType.MESSAGE_TYPE_GROUP) {
- const msg = {isTemp: true, content: '', contentType: message.contentType, messageType: message.messageType};
- // 更新消息
- wsStore.modifyMessage(msg, message.id, wsStore.toUserInfo.uuid);
- // 更新会话列表
- wsStore.updateSessionNewMessage(msg, wsStore.toUserInfo.uuid);
- return;
- }
- wsStore.sendMessage({
- content: JSON.stringify({
- // content: '',
- // msgId: message.id + '',
- id: message.id,
- isTemp: true,
- }),
- contentType: MsgType.MSG_TYPE.NOTICE,
- messageType: wsStore.toUserInfo.type == 'user'
- ? MsgType.MESSAGE_REVOKE
- : MsgType.MESSAGE_REVOKE_GROUP,
- });
- };
- // 当子组件关闭时触发---关闭群公告
- const handleNoticeClose = () => {
- showNotice.value = false
- // noticeRead.value = true
- }
- // 录音相关状态
- const voiceMode = ref(false); // false: 文字输入, true: 语音模式
- const recording = ref(false);
- const cancelRecording = ref(false);
- const startY = ref(0);
- let mediaRecorder = null;
- let audioChunks = [];
- // 切换文字/语音输入模式
- const toggleVoiceMode = () => {
- if(!wsStore.verifyChatAuth(wsStore.toUserInfo.uuid)){
- showToast(`${wsStore.toUserInfo.type == 'user' ? '对方已删除' : '您已不在群聊里面'}`);
- return;
- }
- voiceMode.value = !voiceMode.value;
- // 切换时关闭表情和工具面板,隐藏键盘
- showEmoji.value = false;
- showTools.value = false;
- if (isMobile) Keyboard.hide();
- keyboardHeight.value = 0;
- scrollToBottom();
- };
- // 录音事件
- const handleTouchStart = async (e) => {
- startY.value = e.touches[0].clientY;
- cancelRecording.value = false;
- recording.value = true;
- audioChunks = [];
- try {
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
- mediaRecorder = new MediaRecorder(stream, {
- mimeType: "audio/webm; codecs=opus",
- });
- mediaRecorder.ondataavailable = (ev) => {
- audioChunks.push(ev.data);
- };
- mediaRecorder.start(1000);
- console.log("开始录音");
- } catch (err) {
- console.error("麦克风权限获取失败", err);
- recording.value = false;
- }
- };
- // 录音
- const handleTouchMove = (e) => {
- const currentY = e.touches[0].clientY;
- if (startY.value - currentY > 50) {
- cancelRecording.value = true;
- } else {
- cancelRecording.value = false;
- }
- };
- // 录音发送
- const handleTouchEnd = () => {
- if (!mediaRecorder) return;
- mediaRecorder.stop();
- mediaRecorder.stream.getTracks().forEach((t) => t.stop());
- recording.value = false;
- mediaRecorder.onstop = async () => {
- if (cancelRecording.value) {
- console.log("录音取消");
- return;
- }
- if (audioChunks.length === 0) return;
- const audioBlob = new Blob(audioChunks, { type: "audio/webm" });
- const arrayBuffer = await audioBlob.arrayBuffer();
- const audioData = new Uint8Array(arrayBuffer);
- wsStore.sendMessage({
- // content: "",
- content: JSON.stringify({
- content: "",
- msgId: `ms${Date.now()}`, // 消息id
- quote: quoteMsg.value?.id, // 引用消息id
- cc: wsStore.toUserInfo.type == 'user'?"":ccMsg.value.join(","), // @成员
- isTemp: showBurn.value === true, // 消息阅后即焚
- }),
- contentType: MsgType.MSG_TYPE.AUDIO,
- messageType: wsStore.toUserInfo.type == 'user'?MsgType.MESSAGE_TYPE_USER:(ccMsg.value.length> 0?MsgType.MESSAGE_CC_GROUP:MsgType.MESSAGE_TYPE_GROUP),
- fileSuffix: "wav", // 使用webm后缀更准确
- file: audioData, // 将Uint8Array转为普通数组
- });
- cancelQuote(); // 取消引用
- ccMsg.value = []; // 清空 @成员
- scrollToBottom();
- console.log("语音已发送");
- };
- };
- // 监听输入 @
- watch(
- () => text.value,
- (val) => {
- if (val.slice(-1) === '@' && wsStore.toUserInfo.type != 'user') {
- atList.value.open(); // 打开选人弹窗
- }
- }
- );
- // 选择用户回填输入框
- const onSelectUser = (user) => {
- text.value += `${user.nickname} `;
- ccMsg.value.push(user)
- };
- const onLongPress = (msg) => {
- console.log(msg)
- if(msg.fromUuid != walletStore.account){
- actions.value = [
- { name: "复制", key: "copy" },
- { name: "引用", key: "quote" },
- // { name: "置顶", key: "stick" },
- ]
- }else{
- actions.value = [
- { name: "复制", key: "copy" },
- { name: "撤回", key: "revoke" },
- { name: "引用", key: "quote" },
- // { name: "置顶", key: "stick" },
- ]
- }
- currentMsg.value = msg;
- showActionSheet.value = true;
- };
- const onActionSelect = (action) => {
- if (!currentMsg.value) return;
- if (action.key === "copy") {
- navigator.clipboard.writeText(currentMsg.value.content);
- showToast("已复制");
- } else if (action.key === "revoke") {
- revokeMessage(currentMsg.value);
- currentMsg.value.revoked = true;
- } else if(action.key === "quote") {
- // 引用
- handleQuote(currentMsg.value)
- }
- showActionSheet.value = false;
- };
- // 渲染引用的内容(文字/图片等)
- const renderQuoteContent = (msg) => {
- if (msg.contentType === MsgType.MSG_TYPE.TEXT) {
- return msg.content;
- } else if (msg.contentType === MsgType.MSG_TYPE.IMAGE) {
- return "[图片]";
- } else if (msg.contentType === MsgType.MSG_TYPE.AUDIO) {
- return "[语音]";
- } else {
- return "[消息]";
- }
- };
- // 发送消息
- const sendMessageText = () => {
- if(!wsStore.verifyChatAuth(wsStore.toUserInfo.uuid)){
- showToast(`${wsStore.toUserInfo.type == 'user' ? '对方已删除' : '您已不在群聊里面'}`);
- return;
- }
- if (!text.value.trim()) return;
-
- // 检测消息@成员
- let cc = [];
- if(ccMsg.value.length>0 && wsStore.toUserInfo.type != 'user'){
- const matchs = text.value.match(/(@([^ ]+) )/ig) || [];
- cc = ccMsg.value.filter(val=> matchs.includes(`@${val.nickname} `)).map(val=>val.userId);
- // 检测@所有人
- if(matchs.includes(`@所有人 `)){
- cc = [0]
- }
- }
- // console.log(wsStore.toUserInfo);
- const message = {
- content: JSON.stringify({
- content: text.value,
- msgId: `ms${Date.now()}`, // 消息id
- quote: quoteMsg.value?.id, // 引用消息id
- cc: wsStore.toUserInfo.type == 'user'?"":cc.join(","), // @成员
- isTemp: showBurn.value === true, // 消息阅后即焚
- }),
- contentType: MsgType.MSG_TYPE.TEXT, // 1: 文本消息
- messageType: wsStore.toUserInfo.type == 'user'?MsgType.MESSAGE_TYPE_USER:(cc.length> 0?MsgType.MESSAGE_CC_GROUP:MsgType.MESSAGE_TYPE_GROUP),
- };
- wsStore.sendMessage(message);
- text.value = "";
- cancelQuote(); // 取消引用
- ccMsg.value = []; // 清空 @成员
- scrollToBottom();
- };
- // 发送图片消息
- const afterRead = async (file) => {
- if(!wsStore.verifyChatAuth(wsStore.toUserInfo.uuid)){
- showToast(`${wsStore.toUserInfo.type == 'user' ? '对方已删除' : '您已不在群聊里面'}`);
- return;
- }
- const arrayBuffer = await file.file.arrayBuffer();
- const message = {
- // content: text.value, // 如果有文本内容
- content: JSON.stringify({
- content: text.value,
- msgId: `ms${Date.now()}`, // 消息id
- quote: quoteMsg.value?.id, // 引用消息id
- cc: wsStore.toUserInfo.type == 'user'?"":ccMsg.value.join(","), // @成员
- isTemp: showBurn.value === true, // 消息阅后即焚
- }),
- contentType: MsgType.MSG_TYPE.IMAGE, // 音频消息类型
- messageType: wsStore.toUserInfo.type == 'user'?MsgType.MESSAGE_TYPE_USER:(ccMsg.value.length> 0?MsgType.MESSAGE_CC_GROUP:MsgType.MESSAGE_TYPE_GROUP),
- fileSuffix: file.file.type, // 使用webm后缀更准确
- file: new Uint8Array(arrayBuffer), // 将Uint8Array转为普通数组
- };
- cancelQuote(); // 取消引用
- ccMsg.value = []; // 清空 @成员
- wsStore.sendMessage(message);
- scrollToBottom();
- };
- // 图片类型
- 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;
- };
- // 发送视频
- const handleSendVideo = async (blob) =>{
- chatVideo.value = false;
- const arrayBuffer = await blob.arrayBuffer();
- const audioData = new Uint8Array(arrayBuffer);
- wsStore.sendMessage({
- // content: "",
- content: JSON.stringify({
- content: "",
- msgId: `ms${Date.now()}`, // 消息id
- quote: quoteMsg.value?.id, // 引用消息id
- cc: wsStore.toUserInfo.type == 'user'?"":ccMsg.value.join(","), // @成员
- isTemp: showBurn.value === true, // 消息阅后即焚
- }),
- contentType: MsgType.MSG_TYPE.VIDEO,
- messageType: wsStore.toUserInfo.type == 'user'?MsgType.MESSAGE_TYPE_USER:(ccMsg.value.length> 0?MsgType.MESSAGE_CC_GROUP:MsgType.MESSAGE_TYPE_GROUP),
- fileSuffix: "mp4", // 使用webm后缀更准确
- file: audioData, // 将Uint8Array转为普通数组
- });
- cancelQuote(); // 取消引用
- ccMsg.value = []; // 清空 @成员
- scrollToBottom();
- console.log("视频已发送");
- }
- // 创建呼叫:开启语音电话: 调试用,正式逻辑读src/views/im/hook/messagesHook.js
- // ==== 1. 发起语音通话 ====
- const startAudioOnline = async (contentType, streamType) => {
- // 清理被呼叫者信息
- wsStore.toUserAudioInfo = {
- uuid:walletStore.account,
- type: "user",
- // 自己的信息
- fromUuid: walletStore.account,
- fromAvatar: walletStore.avatar,
- fromUsername: wsStore.toUserInfo.nickname,
- // 拨号目标信息
- toUuid: wsStore.toUserInfo.uuid,
- toUsername:wsStore.toUserInfo.sessionName,
- toAvatar:wsStore.toUserInfo.avatar,
- // 发送者信息
- sender: {
- uuid: walletStore.account,
- avatar: walletStore.avatar,
- nickname: wsStore.toUserInfo.nickname,
- },
- };
- // wsStore.toUserInfo.sender = null;
- // 调起通话界面
- rtcStore.streamType = streamType
- rtcStore.imSate.videoCallModal = true;
- // 设置被呼叫对象
- rtcStore.imSate.callAvatar = wsStore.toUserAudioInfo.toAvatar;
- rtcStore.imSate.callName = wsStore.toUserAudioInfo.toUsername;
- // 设置呼叫者/被呼叫者
- rtcStore.isCaller = true;
- // 通知被呼叫者(接听者)
- wsStore.sendMessage({
- messageType: wsStore.toUserInfo.type == 'user'?MsgType.MESSAGE_TYPE_USER:MsgType.MESSAGE_TYPE_GROUP,
- contentType,
- type: Constant.MESSAGE_TRANS_TYPE,
- // avatar: walletStore.avatar,
- content: JSON.stringify({
- content: walletStore.username,
- sender: walletStore.account,
- }),
- fromUsername: wsStore.toUserAudioInfo.fromUsername,
- avatar: wsStore.toUserAudioInfo.fromAvatar,
- to: wsStore.toUserAudioInfo.toUuid,
- from: wsStore.toUserAudioInfo.fromUuid,
- });
- // 播放铃声
- soundVoice.play()
- // 开启本地视频
- if (streamType === 'video') {
- rtcStore.getUserMedia({ audio: true, video: true })
- }
- };
- // 时间格式化
- 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}`;
- };
- const groupMembersArr = computed(() => wsStore.groupMembersList[wsStore.toUserInfo.uuid] || []);
- // 页面生命周期
- onMounted(async () => {
- wsStore.toUserInfo.uuid = route.query.uuid;
- if (wsStore.toUserInfo.type == 'group') {
- await wsStore.fetchGroupMembers(route.query.uuid);
- }
- await wsStore.getMessages({
- uuid: route.query.uuid,
- messageType: wsStore.toUserInfo.type == 'user'?1:2, //1:个人 2:群组
- friendUsername: walletStore.account
- });
- 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 goToPage = (item) => {
- if(!(isSender(item))) return;
- router.push({
- path: 'personal',
- query:{
- uuid:wsStore.toUserInfo.type == 'user'?wsStore.toUserInfo.uuid:item.fromUuid,
- type:2
- }
- })
- }
- const goBack = () => router.push("im");
- const goDetail = () =>{
- if(!wsStore.verifyChatAuth(wsStore.toUserInfo.uuid)){
- showToast(`${wsStore.toUserInfo.type == 'user' ? '对方已删除' : '您已不在群聊里面'}`);
- return;
- }
- router.push({
- path: 'detail',
- query:{ status:wsStore.toUserInfo.type == 'user'?1:2 }
- }) // 1:单聊 2:群聊
- }
- </script>
- <style lang="less" scoped>
- .load-box{
- text-align: center !important;
- margin-top: 50px !important;
- }
- .mr12 {
- margin-right: 12px;
- }
- .ml12 {
- margin-left: 12px;
- }
- .text-right {
- text-align: right;
- }
- .page-icon {
- width: 24px;
- height: 24px;
- flex-shrink: 0;
- }
- .page-icon-qx{
- width: 22px;
- height: 22px;
- 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;
- margin: 0 16px;
- display: flex;
- overflow: hidden;
- justify-content: center;
- align-items: 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;
- }
- .video-message{
- margin-top: 8px;
- width: 200px;
- height: 200px;
- // transform: scaleX(-1);
- }
- }
- }
- .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 16px;
- display: flex;
- align-items: center;
- box-sizing: border-box;
- .box-input{
- flex: 1;
- margin: 0 12px;
- }
- .input {
- flex: 1;
- background: #f2f2f2;
- border-radius: 17px;
- border: 1px solid #d8d8d8;
- padding: 6px 16px;
- font-weight: 500;
- font-size: 15px;
- 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 16px;
- overflow-y: auto;
- box-sizing: border-box;
- &.visible {
- transform: translateY(0);
- }
- .tool-btn {
- width: calc(100% / 4);
- 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;
- }
- }
- .emoji-item{
- font-size: 20px;
- margin: 0 4px;
- }
- }
- .app-box::-webkit-scrollbar {
- width: 0;
- }
- .page-foot {
- position: relative;
- z-index: 10; /* 确保输入框在上层 */
- }
- .local-video {
- position: absolute;
- bottom: 20px;
- right: 20px;
- width: 200px;
- height: auto;
- border: 2px solid white;
- border-radius: 8px;
- }
- /* 按住说话按钮 */
- .hold-talk-btn {
- flex: 1;
- text-align: center;
- background: #f5f5f5;
- padding: 6px 16px;
- border-radius: 17px;
- color: #666;
- font-weight: 500;
- font-size: 15px;
- user-select: none;
- -webkit-user-select: none;
- -webkit-touch-callout: none;
- line-height: 24px;
- border: 1px solid #f5f5f5;
- }
- /* 录音中的浮层提示 */
- .recording-toast {
- position: fixed;
- bottom: 80px;
- left: 50%;
- transform: translateX(-50%);
- width: 160px;
- min-height: 160px;
- background: rgba(0, 0, 0, 0.85);
- border-radius: 12px;
- padding: 16px;
- box-sizing: border-box;
- text-align: center;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- .mic-icon {
- width: 60px;
- height: 60px;
- background: url("https://img.icons8.com/ios-filled/100/ffffff/microphone.png") no-repeat center center;
- background-size: contain;
- margin-bottom: 16px;
- }
- .send-msg {
- color: #fff;
- font-size: 14px;
- }
- .cancel-msg {
- color: #ff4d4f;
- font-size: 14px;
- }
- }
- .m-ellipsis{
- white-space: nowrap; /* 不换行 */
- overflow: hidden; /* 超出隐藏 */
- text-overflow: ellipsis; /* 超出显示省略号 */
- }
- .groupNotice{
- margin-top: 15px;
- background-color: #fff;
- height: 40px;
- line-height: 40px;
- font-family: PingFang SC, PingFang SC;
- font-weight: 400;
- font-size: 12px;
- color: #000000;
- padding: 0 16px;
- box-sizing: border-box;
- display: flex;
- justify-content: space-between;
- align-items: center;
- text-align: center;
- .item-icon{
- width: 20px;
- height: 20px;
- color: #969799;
- }
- }
- .revoked-msg {
- color: #999;
- font-size: 12px;
- font-style: italic;
- text-align: center;
- margin: 5px 0;
- }
- .burn-tag {
- font-size: 10px;
- color: red;
- margin-left: 6px;
- }
- .assign{
- margin-bottom: 20px;
- display: flex;
- justify-content: flex-end;
- .assign-text{
- color: #fff;
- background: #4765dd;
- border-radius: 30px 0 0 30px;
- padding: 3px 10px;
- }
- }
- // 引用样式
- .quote-box {
- display: flex;
- align-items: center;
- justify-content: space-between;
- background: #f2f2f2;
- border-radius: 6px;
- padding: 4px 16px;
- font-size: 14px;
- color: #969696;
- font-size: 12px;
- }
- .quote-content {
- margin-bottom: 2px;
- }
- .quotation{
- background: #f2f2f2;
- border-radius: 5px;
- padding: 5px 10px;
- margin-top: 5px;
- }
- // 阅后即焚样式
- .burn-message {
- background: #fff;
- color: #000;
- border-radius: 10px;
- margin-top: 8px;
- padding: 8px 14px;
- max-width: 100%;
- font-size: 15px;
- position: relative;
- cursor: pointer;
- word-break: break-word;
- transition: all 0.3s ease;
- &.self {
- background: #4d71ff;
- color: #fff;
- }
- .burn-mask {
- text-align: center;
- color: #999;
- font-size: 14px;
- filter: blur(0px);
- }
- .burn-content,.burn-audio {
- position: relative;
- }
- .burn-countdown {
- position: absolute;
- font-size: 12px;
- color: #ff5b5b;
- margin-left: 6px;
- top: -15px;
- right: -20px;
- }
- .burn-destroyed {
- color: #999;
- font-size: 13px;
- text-align: center;
- }
- }
- .sendText{
- border: none !important;
- height: 25px !important;
- line-height: 25px !important;
- border-radius: 4px;
- background-color: #4765dd;
- padding: 0 16px;
- box-sizing: border-box;
- }
- :deep(.van-button:before){
- border: none;
- }
- </style>
|