这个是revoke页面的内容负责的开通单人聊天的revoke<template> <view ...

Criado em: 20 de dezembro de 2025

Respondido usando GPT-5.2 Thinking por Chat01

Pergunta

3d56cb5ae5f046ba2f70312708dff2ea.png
6ab7400810d140675181c0318a3a5971.png
text
这个是revoke页面的内容负责的开通单人聊天的revoke<template> <view :class="['app-container', theme]"> <view class="content"> <view class="overlay"> <!-- 顶部 Header --> <view class="header" :style="{ paddingTop: statusBarHeight + 'px', height: headerBarHeight + 'px' }"> <view class="header-inner"> <view class="left-box" @click="navigateBack"> <uni-icons type="left" color="#000000" size="26"></uni-icons> </view> <view class="center-box"> <text class="title-text">无限撤回(双向不留痕)</text> </view> <view class="right-box"> <text class="desc-text"></text> </view> </view> </view> <!-- 可滚动内容区域 --> <view style="width: 100%;display: flex;justify-content: center;align-items: center;" class=""> <view style="width: 94%;height: 100%; " class=""> <view style="width: 100%;display: flex;justify-content: space-between;box-sizing: border-box;"> <!-- 左侧 94% 区域 --> <view style="width: 94%;display: flex;box-sizing: border-box;"> <view style="width: 100%;"> <!-- 白色条:72 rpx 高 --> <view style=" width: 100%; height: 72rpx; display: flex; justify-content: center; align-items: center; border-radius: 12rpx; background-color: white; margin-top: 32rpx; "> <view style="height: 60%;width: 90%;"> <view style=" width: 100%; height: 100%; display: flex; justify-content: space-between; align-items: center; box-sizing: border-box; "> <!-- 左:文字 --> <view style=" width: 50%; height: 100%; display: flex; align-items: center; "> 群聊无限撤回 </view> <!-- 右:switch --> <view style=" width: 50%; height: 100%; display: flex; justify-content: flex-end; align-items: center; "> <up-switch v-model="isAddlayers" @change="Addlayerschange"></up-switch> </view> </view> </view> </view> </view> </view> <!-- 右侧 6% 区域 --> <view style=" width: 6%; display: flex; align-items: center; justify-content: center; height: 72rpx; /* 与左侧白条同高 */ margin-top: 32rpx; /* 与白条同步下移 */ "> <view @click="popup()" style=" width: 35rpx; height: 35rpx; border-radius: 50%; background-color: #D89833; color: #fff; font-size: 24rpx; display: flex; justify-content: center; align-items: center; "> ? </view> </view> </view> <view style="width: 100%;display: flex;justify-content: space-between;box-sizing: border-box;"> <!-- 左侧 94% 区域 --> <view style="width: 94%;display: flex;box-sizing: border-box;"> <view style="width: 100%;"> <!-- 白色条:72 rpx 高 --> <view style=" width: 100%; height: 72rpx; display: flex; justify-content: center; align-items: center; border-radius: 12rpx; background-color: white; margin-top: 32rpx; "> <view style="height: 60%;width: 90%;"> <view style=" width: 100%; height: 100%; display: flex; justify-content: space-between; align-items: center; box-sizing: border-box; "> <!-- 左:文字 --> <view style=" width: 50%; height: 100%; display: flex; align-items: center; "> 好友无限撤回 </view> <!-- 右:switch --> <view style=" width: 50%; height: 100%; display: flex; justify-content: flex-end; align-items: center; "> <up-switch v-model="personchat" @change="personmethod"></up-switch> </view> </view> </view> </view> </view> </view> <!-- 右侧 6% 区域 --> <view style=" width: 6%; display: flex; align-items: center; justify-content: center; height: 72rpx; /* 与左侧白条同高 */ margin-top: 32rpx; /* 与白条同步下移 */ "> <view @click="personpopup()" style=" width: 35rpx; height: 35rpx; border-radius: 50%; background-color: #D89833; color: #fff; font-size: 24rpx; display: flex; justify-content: center; align-items: center; "> ? </view> </view> </view> </view> </view> </view> </view> <!-- 长按操作弹窗 --> <up-popup :show="show" @close="close" mode="center" :closeable="true" :safeAreaInsetTop="true" closeIconPos="top-right" zIndex="99999" round="12" @open="open"> <view style="width: 686rpx;height: 752rpx;background-color: white;display: flex;justify-content: center;align-items: center;border-radius: 12rpx;"> <!-- 滑动区域:行内样式 --> <scroll-view scroll-y style="width: 100%;height: 100%;box-sizing: border-box;padding: 24rpx;"> <view class=""> 群无限撤回说明书 </view> <!-- 这里放任意内容,超出 752rpx 自动滚动 --> <view style="min-height: 100%;"> 随时随地撤回群内消息不留痕迹 </view> </scroll-view> </view> </up-popup> <!-- 长按操作弹窗 --> <up-popup :show="personshow" @close="personclose" mode="center" :closeable="true" :safeAreaInsetTop="true" closeIconPos="top-right" zIndex="99999" round="12" @open="personopen"> <view style="width: 686rpx;height: 752rpx;background-color: white;display: flex;justify-content: center;align-items: center;border-radius: 12rpx;"> <!-- 滑动区域:行内样式 --> <scroll-view scroll-y style="width: 100%;height: 100%;box-sizing: border-box;padding: 24rpx;"> <view class=""> 单人无限撤回说明书 </view> <!-- 这里放任意内容,超出 752rpx 自动滚动 --> <view style="min-height: 100%;"> 随时随地撤回单人消息不留痕迹 </view> </scroll-view> </view> </up-popup> </view> </template> <script setup> import { getStatusBarHeight, getTitleBarHeight } from '@/utils/system.js' import ajax from '@/api/ajax.js' import { ref, reactive, onMounted, inject } from 'vue' import { storeToRefs } from 'pinia' import { useMainStore } from '@/store/index.js' import { onLoad, onShow } from '@dcloudio/uni-app' // 全局主题 const store = useMainStore() const { theme } = storeToRefs(store) // 顶部安全区与标题栏高度 const statusBarHeight = ref(getStatusBarHeight()) const headerBarHeight = ref(getTitleBarHeight()) const isAddlayers = ref(false) const personchat = ref(false) //单人 // 到这结束返回的数据 const Groupwithdrawal = ref(false) //群撤回 const Singlepersonchat = ref(false) //单人撤回 const Addlayerschange = (e) => { isAddlayers.value = e if (isAddlayers.value) { Groupwithdrawal.value = true uni.setStorageSync('groupchat', Groupwithdrawal.value) let add = uni.getStorageSync('groupchat') uni.showToast({ title: '开启了群聊无限撤回', icon: "none" }) } else { Groupwithdrawal.value = false uni.setStorageSync('groupchat', Groupwithdrawal.value) let add = uni.getStorageSync('groupchat') uni.showToast({ title: '关闭群聊无限撤回', icon: "none" }) } } const personmethod = (e) => { personchat.value = e if (personchat.value) { Singlepersonchat.value = true uni.setStorageSync('Singlepersonchat', Singlepersonchat.value) let add = uni.getStorageSync('Singlepersonchat') uni.showToast({ title: '开启了单人聊天此功能', icon: "none" }) } else { Singlepersonchat.value = false uni.setStorageSync('Singlepersonchat', Singlepersonchat.value) let add = uni.getStorageSync('Singlepersonchat') uni.showToast({ title: '关闭了单人聊天此功能', icon: "none" }) } } const show = ref(false) function close() { show.value = false } function open() { show.value = true } // 单人 const personshow = ref(false) function personclose() { personshow.value = false } function personopen() { personshow.value = true } // 返回上一页 const navigateBack = () => { uni.navigateBack() } const popup = () => { show.value = true } const personpopup = () => { personshow.value = true } onShow(() => { isAddlayers.value = uni.getStorageSync('groupchat') //群 personchat.value = uni.getStorageSync('Singlepersonchat') //单人 }) onLoad((option) => { isAddlayers.value = uni.getStorageSync('groupchat') //群 personchat.value = uni.getStorageSync('Singlepersonchat') //单人 console.log(isAddlayers.value,'群',personchat.value,'单人') if (option) { } else { console.log('没有参数') } }) </script> <style lang="scss" scoped> @import '@/assets/styles/global.scss'; .app-container { /* 如果有深色 / 浅色主题,在这里根据 theme 做适配 */ } .content { min-height: 100vh; position: relative; background-size: cover; } .overlay { position: absolute; inset: 0; background-size: cover; } .header { width: 94%; margin: 0 auto; } .header-inner { position: relative; display: flex; align-items: center; justify-content: center; // 整体居中 height: 100%; } /* 左右两块宽度相同,但不占位 */ .left-box, .right-box { position: absolute; top: 0; bottom: 0; width: 120rpx; // 足够容纳图标/文字 display: flex; align-items: center; } .left-box { left: 0; justify-content: flex-start; } .right-box { right: 0; justify-content: flex-end; } /* 中间标题:绝对居中 */ .center-box { flex: none; // 不拉伸 } </style>只要开通了这个personchat.value登入true了下面的单人聊天页面chat<!-- /pages/sociAlize/ChatDisplayPage/chat.vue --> <template> <view :class="['app-container', theme]"> <view class="content"> <view class="overlay"> <view class="header" :style="{ paddingTop: statusBarHeight + 'px', height: headerBarHeight + 'px' }"> <view class="header-inner"> <view class="left-box" @click="navigateBack"> <uni-icons type="left" color="#000000" size="26" /> </view> <view class="center-box"> <text v-if="Isfiletransfer" class="title-text"> {{ titleText }} </text> <text v-else @click="changeAgent" class="title-text"> {{ titleText }} </text> </view> <view class="right-box"> <text v-if="multiSelectMode && !Isfiletransfer" class="cancel-text" @click="exitMultiSelect"> 取消 </text> <text v-else-if="!Isfiletransfer" @click="changeAgent" style="font-weight: bold; font-size: 40rpx" class="desc-text"> ... </text> </view> </view> </view> <view v-if="messages.length === 0 && !Isfiletransfer" style="width: 100%; display: flex; justify-content: center; align-items: center"> <view style=" width: 94%; display: flex; justify-content: space-between; box-sizing: border-box; margin-top: 8rpx; "> <view>当前对象 : <text>{{ agent ? '真人' : '化身' }}</text></view> <view style="position: relative"> <view @click="changeAgent" style=" height: 60rpx; width: 188rpx; display: flex; justify-content: center; align-items: center; background-color: #d89833; border-radius: 12rpx; position: absolute; right: 0; font-size: 24rpx; color: #ffffff; "> 选择{{ !agent ? '真人' : '化身' }}聊天 </view> </view> </view> </view> <view class="chat-scroll" :style="{ top: containerTop + 'px', bottom: ((multiSelectMode && !Isfiletransfer) ? (multiActionHeight + safeAreaBottom) : (inputAreaHeight + safeAreaBottom)) + 'px' }"> <scroll-view scroll-y :scroll-into-view="lastItemId" style="height: 100%"> <view v-for="(msg, idx) in messages" :key="msg.localId || msg.id || idx" :id="'msg-' + idx"> <view v-if="shouldShowTimestamp(idx)" class="time-divider"> {{ formatChatTime(msg.createTime || msg.timestamp) }} </view> <Bubblechatbox :messageKey="makeMsgKey(msg, idx)" :role="msg.isUser ? 'user' : 'assistant'" :content="normalizeContentForBubble(msg.content)" :userAvatar="userAvatarFull" :assistantAvatar="peerAvatarForUI" :maskPeer="isMaskChat" :maskPeerName="'****'" :maskAvatarUrl="MASK_AVATAR" :showCopyButton="!msg.isUser && isPlainText(msg.content)" :showBadge="false" :chatConsumerId="chatConsumerId" :consumerId="consumerId" :webProjectUrl="webProjectUrl" :castingChatId="msg.id || ''" :multiSelectMode="multiSelectMode && !Isfiletransfer" :selected="isSelected(makeMsgKey(msg, idx))" :selectDisabled="!msg.id" @toggle-select="onToggleSelect" @enter-multiselect="onEnterMultiSelect" @quote-message="onQuoteMessage" @delete-message="onDeleteOne" @recall-message="onRecallOne" @self-avatar-click="handleSelfAvatarClick" @peer-avatar-click="handlePeerAvatarClick" /> </view> <view v-if="!agent && isAiReplying" class="ai-typing-indicator"> <view class="ai-typing-bubble"> <view class="dot"></view> <view class="dot"></view> <view class="dot"></view> </view> </view> </scroll-view> </view> <view v-if="multiSelectMode && !Isfiletransfer" class="multi-action-bar" :style="{ paddingBottom: safeAreaBottom + 'px' }"> <view class="multi-action-inner"> <view class="multi-btn multi-btn-primary" @click="openMultiForwardPicker">转发</view> <view class="multi-btn" @click="openMultiCollectPicker">收藏</view> <view class="multi-btn" @click="confirmMultiDelete">删除</view> <view class="multi-btn" @click="confirmMultiRecall">撤回</view> </view> </view> <view v-else class="input-bar" style="" :style="{ paddingBottom: keyboardPadding + 'px' }"> <view v-if="quoteDraft && !Isfiletransfer" class="quote-bar"> <view class="quote-bar-inner"> <text class="quote-label">引用:</text> <text class="quote-text">{{ quoteDraft.senderName }}:{{ quoteDraft.preview }}</text> </view> <view class="quote-close" @click.stop="clearQuote">×</view> </view> <view style="width: 100%;display: flex;justify-content: center;align-items: center;" class=""> <view class="input-left"> <view v-if="Isfiletransfer" class="input-wrapper input-wrapper--disabled"> <input disabled value="" placeholder="文件传输助手暂不支持发送消息" /> </view> <view v-else class="input-wrapper"> <input v-model="inputText" type="text" placeholder="请输入" @confirm="onSend('NormalText')" confirm-type="send" :adjust-position="false" :maxlength="500" @focus="onInputFocus" @blur="onInputBlur" /> </view> </view> <view v-if="!Isfiletransfer" class="input-right"> <view v-if="istext" @click="onSend('NormalText')" class="btn-send"> 发送 </view> <view v-else class="btn-send" @click="openAttachPanel"> <uni-icons type="plus-filled" size="30" /> </view> </view> </view> </view> <up-popup :show="multiForwardPickerShow" mode="center" @close="multiForwardPickerShow=false" :closeable="true" closeIconPos="top-right" zIndex="99999" round="12"> <view class="mode-popup"> <up-button type="warning" style="height: 80rpx; width: 220rpx" text="合并转发" @click="gotoForwardfriend('merge')" /> <up-button style="height: 80rpx; width: 220rpx" text="逐条转发" @click="gotoForwardfriend('items')" /> </view> </up-popup> <up-popup :show="multiCollectPickerShow" mode="center" @close="multiCollectPickerShow=false" :closeable="true" closeIconPos="top-right" zIndex="99999" round="12"> <view class="mode-popup"> <up-button type="warning" style="height: 80rpx; width: 220rpx" text="咖信收藏" @click="doMultiCollect(false)" /> <up-button type="warning" style="height: 80rpx; width: 220rpx" text="咖密收藏" @click="doMultiCollect(true)" /> </view> </up-popup> <up-popup :show="Didpopup" mode="bottom" @close="bottomclose" @open="bottomopen"> <view style="width: 100%;height: 240rpx;display: flex;justify-content: center;align-items: center;padding: 10rpx 20rpx;"> <view style="width: 100%;" class=""> <view style="width: 100%;display: flex;justify-content: space-between;box-sizing: border-box;margin-top: 12rpx;"> <!-- 左侧 94% 区域 --> <view style="width: 94%;display: flex;box-sizing: border-box;"> <view style="width: 100%;"> <!-- 白色条:72 rpx 高 --> <view style=" width: 100%; height: 62rpx; display: flex; justify-content: center; align-items: center; border-radius: 12rpx; background-color: rgba(12, 34, 12, 0.7); "> <view style="height: 60%;width: 90%;"> <view style=" width: 100%; height: 100%; display: flex; justify-content: space-between; align-items: center; box-sizing: border-box; "> <!-- 左:文字 --> <view style=" width: 50%; height: 100%; display: flex; align-items: center; "> 阅后级焚 </view> <!-- 右:switch --> <view style=" width: 50%; height: 100%; display: flex; justify-content: flex-end; align-items: center; "> <up-switch v-model="Postreadingburn" @change="Burndown"></up-switch> </view> </view> </view> </view> </view> </view> <!-- 右侧 6% 区域 --> <!-- <view style=" width: 6%; display: flex; align-items: center; justify-content: center; height: 62rpx; /* 与左侧白条同高 */ "> <view @click="personpopup()" style=" width: 35rpx; height: 35rpx; border-radius: 50%; background-color: #D89833; color: #fff; font-size: 24rpx; display: flex; justify-content: center; align-items: center; "> ? </view> </view> --> </view> <view style="width: 100%; height: 100%; display: flex; align-items: center"> <view style="display: inline-block;margin-left: 40rpx; text-align: center" @click="pickAndSendImage"> <uni-icons type="image" size="30" /> <view>图片</view> </view> <view style="display: inline-block;margin-left: 40rpx; text-align: center" @click="OpencameraImage"> <uni-icons type="camera" size="30" /> <view>手机相机</view> </view> <view style="display: inline-block; margin-left: 40rpx; text-align: center" @click="Selectvideomedia"> <uni-icons type="camera" size="30" /> <view>视频</view> </view> <view style="display: inline-block; margin-left: 40rpx; text-align: center" @click="recordAndSendVoice"> <uni-icons type="mic-filled" size="30" /> <view>语音</view> </view> <view style="display: inline-block; margin-left: 40rpx; text-align: center" @click="call"> <uni-icons type="videocam-filled" size="30" /> <view>视频通话</view> </view> </view> <view style="display: inline-block; margin-left: 40rpx; text-align: center" @click="call"> <uni-icons type="location" size="30" /> <view>定位</view> </view> <view style="display: inline-block; margin-left: 40rpx; text-align: center" @click="pickAndSendFile"> <uni-icons type="wallet-filled" size="30" /> <view>文件</view> </view> </view> </view> </up-popup> </view> </view> </view> </template> <script setup> import { ref, reactive, toRefs, nextTick, computed, onMounted, onBeforeUnmount, onUnmounted, watch } from 'vue' import { onLoad, onShow } from '@dcloudio/uni-app' import { storeToRefs } from 'pinia' import Bubblechatbox from '@/components/Bubblechatbox.vue' import VideoCallsService, { enableFloatWindow } from '@/utils/videocalls.js' import { clearCounter } from '@/utils/counter.js' import { useMainStore } from '@/store/index.js' import ajax from '@/api/ajax.js' import WsRequest from '@/utils/websockets.js' import { getStatusBarHeight, getTitleBarHeight } from '@/utils/system.js' import { useadvertising } from '@/store/useforward.js' import { usechat } from '@/store/Chatfriends.js' import { getThreads, getMessages, getRuns, pollRunStatus, fetchLastAssistantMessage } from '@/utils/aiService.js' // ✅ 打码遮挡头像(你给的链接) const MASK_AVATAR = 'https://sns-img-hw.xhscdn.com/1000g0081vkua6m2f00405orgg98nr3qol0vfa58?imageView2/2/h/1080/format/webp' /** =============== 基础上传配置 =============== */ const API_BASE = (getApp().globalData && getApp().globalData.serverUrl) || 'http://47.99.182.213:7018' const uploadUrlSingle = `${API_BASE.replace(/\/$/, '')}/castingChatFile` function filenameOf(path = '') { try { return String(path).split('/').pop() || '' } catch { return '' } } function buildUrl(base, nameOrUrl = '') { if (!nameOrUrl) return '' if (/^https?:\/\//i.test(nameOrUrl)) return nameOrUrl const b = String(base || '').replace(/\/$/, '') const p = String(nameOrUrl).replace(/^\//, '') return b ? `${b}/${p}` : p } /** =============== 化身选择 & 本地缓存(Pinia) =============== */ const chatStore = usechat() const LOG_PREFIX = 'AGENT_CHAT_LOGS:' const THREAD_PREFIX = 'AGENT_THREAD:' const ymd = (d = new Date()) => { const m = String(d.getMonth() + 1).padStart(2, '0') const dd = String(d.getDate()).padStart(2, '0') return `${d.getFullYear()}${m}${dd}` } const logKey = (consumerId, friendId, agentId) => `${LOG_PREFIX}${consumerId}:${friendId}:${agentId || 'none'}:${ymd()}` const logKeyLegacy = (consumerId, friendId) => `${LOG_PREFIX}${consumerId}:${friendId}:${ymd()}` const threadKey = (consumerId, friendId, agentId) => `${THREAD_PREFIX}${consumerId}:${friendId}:${agentId || 'none'}` function readAgentLogs(consumerId, friendId, agentId) { const keyNew = logKey(consumerId, friendId, agentId) let raw = uni.getStorageSync(keyNew) if (!raw) { const keyOld = logKeyLegacy(consumerId, friendId) raw = uni.getStorageSync(keyOld) } if (!raw) return [] try { const arr = typeof raw === 'string' ? JSON.parse(raw) : raw return Array.isArray(arr) ? arr : [] } catch { return [] } } function appendAgentLog(consumerId, friendId, agentId, item) { const k = logKey(consumerId, friendId, agentId) const arr = readAgentLogs(consumerId, friendId, agentId) arr.push(item) try { uni.setStorageSync(k, JSON.stringify(arr)) } catch {} } function readAgentThreadId(consumerId, friendId, agentId) { const k = threadKey(consumerId, friendId, agentId) const raw = uni.getStorageSync(k) return raw || '' } function saveAgentThreadId(consumerId, friendId, agentId, id) { const k = threadKey(consumerId, friendId, agentId) uni.setStorageSync(k, id || '') } /** =============== 其它 store =============== */ const usestore = useadvertising() const { BrowseID } = storeToRefs(usestore) /** =============== 通话相关 =============== */ async function call() { if (Isfiletransfer.value) { return uni.showToast({ title: '文件助手不支持通话', icon: 'none' }) } uni.showActionSheet({ itemList: ['语音通话', '视频通话'], itemColor: '#000000', success: (res) => { if (res.tapIndex === 0) dial('speechvoice') else if (res.tapIndex === 1) dial('video') }, fail() { uni.showToast({ title: '用户取消了通话', icon: 'none' }) } }) } async function dial(type) { const option = type === 'speechvoice' ? { calleeList: [chatConsumerId.value], callMediaType: 1 } : { calleeList: [chatConsumerId.value], callMediaType: 2 } const resp = await VideoCallsService.call(option) try { if (!chatConsumerId.value) { return uni.showToast({ title: '没有目标用户ID', icon: 'none' }) } if (resp?.code === 0) { // success } else { uni.showToast({ title: resp?.msg || '通话失败', icon: 'none' }) } } catch (e) { uni.showToast({ title: e?.message || '通话异常', icon: 'none' }) } } /** =============== 工具函数 =============== */ function safeParseDeep(input) { let data = input for (let i = 0; i < 3; i++) { if (typeof data === 'string') { const s = data.trim() try { if ((s.startsWith('{') && s.endsWith('}')) || (s.startsWith('[') && s.endsWith(']'))) { data = JSON.parse(s) continue } if (/^"(\\.|[^"])*"$/.test(s)) { data = JSON.parse(s) continue } } catch (e) {} break } if (typeof data === 'object' && data !== null) break } return data } function safeStringify(obj) { try { return typeof obj === 'string' ? obj : JSON.stringify(obj) } catch { return String(obj || '') } } function genClientId() { return 'c_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8) } function packFileContent({ type, playURL, showName, fileName, text, clientId }) { return safeStringify({ _kind: 'file', type, playURL, showName, fileName, text, clientId: clientId || '' }) } function normalizeIncomingItem(item, baseUrl) { // ✅ 核心修复:如果是转发类型(5/6),直接返回JSON字符串,Bubblechatbox会解析 if (item.type == 5 || item.type == 6) { return safeStringify(item) } const rawContent = item.content if (typeof item.type === 'number' && item.type > 0 && item.type <= 4) { const contentStr = packFileContent({ type: item.type, playURL: buildUrl(baseUrl, item.playURL), showName: item.showName || item.fileName || '', fileName: item.fileName || '', text: rawContent || '' }) return contentStr } return typeof rawContent === 'string' ? rawContent : safeStringify(rawContent) } /** 生成引用预览文本 */ function computePreviewText(raw) { if (!raw) return '' let s = raw if (typeof s !== 'string') { try { s = JSON.stringify(s) } catch { s = String(s) } } const obj = safeParseDeep(s) if (obj && typeof obj === 'object') { if (obj.msgType === 'text') return String(obj.text || '').slice(0, 60) if (obj._kind === 'file') { const t = Number(obj.type || 0) const baseName = obj.showName || obj.fileName || '' if (t === 1) return baseName ? `[图片] ${baseName}` : '[图片]' if (t === 2) return baseName ? `[视频] ${baseName}` : '[视频]' if (t === 3) return baseName ? `[语音] ${baseName}` : '[语音]' if (t === 4) return baseName ? `[文件] ${baseName}` : '[文件]' return '[文件]' } if (obj.msgType === 'ad') return '[广告]' if (obj.type == 5 || obj.type == 6 || obj.msgType === 'forward_record' || obj.msgType === 'forward') return obj.title || '[聊天记录]' } return String(s).slice(0, 60) } /** =============== 布局与状态 =============== */ const mainStore = useMainStore() const { theme } = storeToRefs(mainStore) const statusBarHeight = ref(getStatusBarHeight()) const headerBarHeight = ref(getTitleBarHeight()) const navigateBack = () => uni.navigateBack() const ds = reactive({ containerTop: 0, inputAreaHeight: 0, safeAreaBottom: 0, keyboardPadding: 0, lastItemId: '', inputText: '', messages: [], consumerId: uni.getStorageSync('consumerId') || '', chatConsumerId: '', webProjectUrl: getApp().globalData?.webProjectUrl || '', userAvatarPath: getApp().globalData?.avatarUrl || uni.getStorageSync('avatarUrl') || '', peerAvatarPath: '', // 自己昵称(用于合并转发标题/引用显示) selfNickname: uni.getStorageSync('consumerNickname') || uni.getStorageSync('nickname') || '我', agent: true, // true=真人,false=化身 socket: null, lastLocalEchoClientId: '', seenClientIds: new Set(), rowStartIdx: 0, rowCount: 20, noMoreHistory: false, loadingHistory: false, nickname: '', avatar: '', istext: false, Didpopup: false, // 化身信息 agentId: '', agentName: '', agentAvatar: '', assistantId: '', // 当前化身 chat 对应的线程 aiThreadId: '', isAiReplying: false, ForwardingID: [], // ✅ 是否开启聊天打码(通过 consumerFollowPerformerShow.mark 判断) markChat: false, Postreadingburn: false, //阅后 }) const { containerTop, inputAreaHeight, safeAreaBottom, keyboardPadding, lastItemId, inputText, messages, consumerId, chatConsumerId, webProjectUrl, userAvatarPath, peerAvatarPath, selfNickname, agent, socket, rowStartIdx, rowCount, noMoreHistory, loadingHistory, nickname, avatar, istext, Didpopup, agentId, agentName, agentAvatar, assistantId, aiThreadId, isAiReplying, ForwardingID, Postreadingburn } = toRefs(ds) const Isfiletransfer = ref(false) // 文件传输助手 /** =============== ✅ 打码显示控制(昵称/头像遮挡) =============== */ const isMaskChat = computed(() => !!(ds.markChat && agent.value && !Isfiletransfer.value)) const peerDisplayName = computed(() => (isMaskChat.value ? '****' : (nickname.value || ''))) const titleText = computed(() => { if (Isfiletransfer.value) return nickname.value || '文件传输助手' return agent.value ? (peerDisplayName.value || '未命名') : (agentName.value || '化身') }) // 原始头像(真人=对方头像;化身=化身头像) const peerAvatarRaw = computed(() => { const p = agent.value ? (peerAvatarPath.value || '') : (agentAvatar.value || '') if (!p) return '' if (/^https?:\/\//.test(p)) return p return (webProjectUrl.value || '') + p }) // ✅ UI 用头像:打码时换成遮挡图 const peerAvatarForUI = computed(() => (isMaskChat.value ? MASK_AVATAR : peerAvatarRaw.value)) // 自己头像 const userAvatarFull = computed(() => { const p = userAvatarPath.value || '' if (!p) return '' if (/^https?:\/\//.test(p)) return p return (webProjectUrl.value || '') + p }) /** =============== ✅ 查询当前聊天对象是否被打码(consumerFollowPerformerShow) =============== */ async function fetchMarkSetting() { if (Isfiletransfer.value) { ds.markChat = false return } const cid = consumerId.value const pid = chatConsumerId.value if (!cid || !pid) { ds.markChat = false return } try { const res = await ajax.post({ url: 'consumerFollowPerformerShow', method: 'post', data: { consumerId: cid, performerId: pid } }) ds.markChat = !!res?.data?.mark } catch (e) { ds.markChat = false } } /** =============== 多选 / 引用(新增) =============== */ const multiSelectMode = ref(false) const selectedKeys = ref([]) // 存 messageKey const multiForwardPickerShow = ref(false) const multiCollectPickerShow = ref(false) const multiActionHeight = ref(uni.upx2px(136)) const quoteDraft = ref(null) // { castingChatId, preview, senderName } const BASE_INPUT_PX = uni.upx2px(136) const QUOTE_BAR_PX = uni.upx2px(84) function updateInputHeight() { ds.inputAreaHeight = BASE_INPUT_PX + (quoteDraft.value ? QUOTE_BAR_PX : 0) } watch(() => quoteDraft.value, () => { if (multiSelectMode.value) return updateInputHeight() }) watch(() => multiSelectMode.value, (v) => { if (v) { Didpopup.value = false clearQuote() } else { updateInputHeight() } }) // 阅后焚毁 const Burndown = (e) => { Postreadingburn.value = e } function makeMsgKey(msg, idx) { return String(msg?.id || msg?.localId || ('idx_' + idx)) } function isSelected(key) { return selectedKeys.value.includes(String(key)) } function onToggleSelect(key) { if (!multiSelectMode.value) return const k = String(key) if (selectedKeys.value.includes(k)) { selectedKeys.value = selectedKeys.value.filter(x => x !== k) } else { selectedKeys.value = [...selectedKeys.value, k] } } function onEnterMultiSelect(key) { if (Isfiletransfer.value) { return uni.showToast({ title: '文件助手暂不支持多选', icon: 'none' }) } if (!agent.value) { return uni.showToast({ title: '化身模式暂不支持多选', icon: 'none' }) } multiSelectMode.value = true selectedKeys.value = [String(key)] } function exitMultiSelect() { multiSelectMode.value = false selectedKeys.value = [] multiForwardPickerShow.value = false multiCollectPickerShow.value = false } function findMessageByKey(key) { const k = String(key) for (let i = 0; i < messages.value.length; i++) { const mk = makeMsgKey(messages.value[i], i) if (mk === k) return messages.value[i] } return null } const selectedMessages = computed(() => { const ks = new Set(selectedKeys.value.map(String)) const arr = [] for (let i = 0; i < messages.value.length; i++) { const msg = messages.value[i] const mk = makeMsgKey(msg, i) if (ks.has(mk)) arr.push(msg) } return arr }) const selectedCastingChatIds = computed(() => { return selectedMessages.value.map(m => m.id).filter(Boolean) }) function openMultiForwardPicker() { if (!selectedMessages.value.length) { return uni.showToast({ title: '请先选择消息', icon: 'none' }) } multiForwardPickerShow.value = true } function openMultiCollectPicker() { if (!selectedMessages.value.length) { return uni.showToast({ title: '请先选择消息', icon: 'none' }) } multiCollectPickerShow.value = true } function clearQuote() { quoteDraft.value = null } function onQuoteMessage({ castingChatId, content, isUser }) { if (Isfiletransfer.value) return if (multiSelectMode.value) return const senderName = isUser ? (selfNickname.value || '我') : (peerDisplayName.value || '对方') quoteDraft.value = { castingChatId: castingChatId || '', preview: computePreviewText(content), senderName } } /** =============== 文件传输助手本地缓存 key =============== */ const FILE_TRANSFER_PREFIX = 'FILE_TRANSFER_HISTORY:' function fileTransferKey(id) { return FILE_TRANSFER_PREFIX + (id || '') } const pendingForward = ref(null) const hasInitForward = ref(false) /** onShow:每次页面可见都刷新模式 & 历史,保证切真人/化身立即生效 */ onShow(() => { clearCounter() handleShow() }) async function handleShow() { if (!consumerId.value) return exitMultiSelect() if (Isfiletransfer.value) { await loadFileTransferHistory(true) return } // ✅ 先拉取打码配置 await fetchMarkSetting() await refreshModeAndHistory() if (!hasInitForward.value) { await sendForwardIfNeeded() hasInitForward.value = true } } function bottomclose() { Didpopup.value = false } function bottomopen() { Didpopup.value = true } function openAttachPanel() { if (Isfiletransfer.value) { Didpopup.value = false return uni.showToast({ title: '文件助手暂不支持发送附件', icon: 'none' }) } if (!agent.value) { Didpopup.value = false return uni.showToast({ title: '化身模式暂不支持发送图片/视频/文件/语音', icon: 'none' }) } if (multiSelectMode.value) { Didpopup.value = false return } Didpopup.value = true } watch(() => inputText.value, (val) => { istext.value = !!(val && val.length > 0) }) function normalizeCard(raw) { return { isGoods: !!(raw.mallGoodsGroupId || raw.mallGoodsGroupTitle), media: { playURL: raw?.ProductMediaobj?.playURL || raw?.media?.playURL || raw?.media?.fileName || '', isVideo: false, isImage: true }, title: raw.title || raw.mallEnterpriseName || '广告', price: Number(raw.price || 0), typeName: raw.typeName || '', mallGoodsGroupId: raw.mallGoodsGroupId || '', mallGoodsGroupTitle: raw.mallGoodsGroupTitle || '', mallEnterpriseId: raw.mallEnterpriseId || '', adId: raw.id || '', requiredSec: Number(raw.requiredSec || raw.limitSeconds || 30) || 30 } } function localEcho(content, clientId) { const localId = 'local_' + Date.now() const nowISO = new Date().toISOString() messages.value.push({ localId, content, senderId: consumerId.value, receiverId: chatConsumerId.value, isUser: true, createTime: nowISO }) ds.lastLocalEchoClientId = clientId || '' scrollToBottom() } function localEchoPlain(text, isUser = true) { const localId = 'local_' + Date.now() + (isUser ? '_u' : '_a') const nowISO = new Date().toISOString() messages.value.push({ localId, content: text, senderId: isUser ? consumerId.value : chatConsumerId.value, receiverId: isUser ? chatConsumerId.value : consumerId.value, isUser, createTime: nowISO }) scrollToBottom() } function makeAdPayload(list = [], base = '') { const cards = (list || []).map(normalizeCard) return { msgType: 'ad', version: 1, cards, base, ts: Date.now() } } function makeTextPayload(text = '', quote = null) { const p = { msgType: 'text', text: String(text), clientId: genClientId(), ts: Date.now() } if (quote && (quote.preview || quote.castingChatId)) { p.quote = { castingChatId: quote.castingChatId || '', preview: quote.preview || '', senderName: quote.senderName || '' } } return p } function bindServerIdToLocalMessage(clientId, serverId) { if (!clientId || !serverId) return for (let i = messages.value.length - 1; i >= 0; i--) { const msg = messages.value[i] if (!msg || msg.id || !msg.isUser) continue const parsed = safeParseDeep(msg.content) const cid = parsed && parsed.clientId if (cid && cid === clientId) { msg.id = serverId break } } } function isPlainText(content) { const obj = safeParseDeep(content) if ( obj && typeof obj === 'object' && (obj._kind === 'file' || obj.msgType === 'ad' || obj.msgType === 'text' || obj.msgType === 'forward' || obj.msgType === 'forward_record' || obj.type == 5 || obj.type == 6) ) return false return true } function normalizeContentForBubble(content) { return typeof content === 'string' ? content : safeStringify(content) } /** =============== 真人历史 =============== */ async function loadHistory(initial = false) { if (!agent.value) return if (Isfiletransfer.value) return if (loadingHistory.value || noMoreHistory.value) return loadingHistory.value = true try { const res = await ajax.post({ url: 'chatListBySenderAndReceiver', data: { consumerId: consumerId.value, chatConsumerId: chatConsumerId.value, rowStartIdx: initial ? 0 : rowStartIdx.value, rowCount: rowCount.value } }) const data = res?.data || {} if (data.consumerNickname) ds.selfNickname = data.consumerNickname if (data.chatConsumerAvatarUrl) peerAvatarPath.value = data.chatConsumerAvatarUrl const list = data.castingChatArray || [] if (initial) { messages.value = [] rowStartIdx.value = 0 noMoreHistory.value = false ds.seenClientIds = new Set() ds.lastLocalEchoClientId = '' } const normalized = list.map((item) => { const contentStr = normalizeIncomingItem(item, webProjectUrl.value) const parsed = safeParseDeep(contentStr) const cid = parsed && parsed.clientId if (cid) ds.seenClientIds.add(cid) return { id: item.id, content: contentStr, senderId: item.senderId, receiverId: item.receiverId, isUser: item.senderId === consumerId.value, createTime: item.createTime } }) if (normalized.length === 0) { if (!initial) noMoreHistory.value = true } else { messages.value = [...normalized, ...messages.value] rowStartIdx.value += normalized.length } if (initial) scrollToBottom() } catch (e) { uni.showToast({ title: '历史加载失败', icon: 'none' }) } finally { loadingHistory.value = false } } /** =============== WS(真人模式) =============== */ const WS_HOST = ref('ws://47.99.182.213:7018/ws') function initSocket() { if (!agent.value) return if (!consumerId.value) return const wsUrl = `${WS_HOST.value}/${consumerId.value}` ds.socket = new WsRequest(wsUrl, 10000) socket.value.onOpen(() => { sendForwardIfNeeded() }) socket.value.getMessage((raw) => { if (!raw) return let msg try { msg = JSON.parse(raw) } catch { return } const arr = Array.isArray(msg) ? msg : [msg] let hasNew = false for (const item of arr) { if (!item || !item.senderId || !item.receiverId) continue const related = (item.senderId === consumerId.value && item.receiverId === chatConsumerId.value) || (item.senderId === chatConsumerId.value && item.receiverId === consumerId.value) if (!related) continue const contentStr = normalizeIncomingItem(item, webProjectUrl.value) const parsed = safeParseDeep(contentStr) const cid = parsed && parsed.clientId if (cid) { if (cid === ds.lastLocalEchoClientId) { ds.lastLocalEchoClientId = '' continue } if (ds.seenClientIds.has(cid)) continue ds.seenClientIds.add(cid) } messages.value.push({ id: item.id, content: contentStr, senderId: item.senderId, receiverId: item.receiverId, isUser: item.senderId === consumerId.value, createTime: item.createTime || new Date().toISOString() }) hasNew = true } if (hasNew) scrollToBottom() }) socket.value.onClose(() => {}) socket.value.onError(() => {}) } /** =============== 删除/撤回(单条 + 多选共用) =============== */ async function deleteOrRecallByIds(ids = [], actionName = '删除') { const uniq = Array.from(new Set((ids || []).filter(Boolean).map(String))) if (!uniq.length) { return uni.showToast({ title: `没有可${actionName}的消息`, icon: 'none' }) } uni.showLoading({ title: `${actionName}中...` }) try { const tasks = uniq.map((id) => ajax.post({ url: 'castingChatDeleteRecord', method: 'post', data: { id } }) ) const results = await Promise.allSettled(tasks) const ok = results.filter(r => r.status === 'fulfilled').length const fail = results.length - ok messages.value = messages.value.filter(m => !uniq.includes(String(m.id || ''))) uni.showToast({ title: `${actionName}成功:${ok},失败:${fail}`, icon: 'none' }) } catch (e) { uni.showToast({ title: `${actionName}失败`, icon: 'none' }) } finally { uni.hideLoading() } } function onDeleteOne({ castingChatId }) { if (!castingChatId) return uni.showToast({ title: '该消息无法删除', icon: 'none' }) uni.showModal({ title: '提示', content: '确认删除这条消息?', success: async (res) => { if (!res.confirm) return await deleteOrRecallByIds([castingChatId], '删除') } }) } function onRecallOne({ castingChatId }) { if (!castingChatId) return uni.showToast({ title: '该消息无法撤回', icon: 'none' }) uni.showModal({ title: '提示', content: '确认撤回这条消息?', success: async (res) => { if (!res.confirm) return await deleteOrRecallByIds([castingChatId], '撤回') } }) } function confirmMultiDelete() { if (!selectedMessages.value.length) return uni.showToast({ title: '请先选择消息', icon: 'none' }) uni.showModal({ title: '提示', content: `确认删除已选的 ${selectedMessages.value.length} 条消息?`, success: async (res) => { if (!res.confirm) return await deleteOrRecallByIds(selectedCastingChatIds.value, '删除') exitMultiSelect() } }) } function confirmMultiRecall() { if (!selectedMessages.value.length) return uni.showToast({ title: '请先选择消息', icon: 'none' }) uni.showModal({ title: '提示', content: `确认撤回已选的 ${selectedMessages.value.length} 条消息?`, success: async (res) => { if (!res.confirm) return await deleteOrRecallByIds(selectedCastingChatIds.value, '撤回') exitMultiSelect() } }) } /** =============== 多选收藏(新增) =============== */ async function doMultiCollect(isFileTransfer = false) { multiCollectPickerShow.value = false const ids = selectedCastingChatIds.value if (!ids.length) { return uni.showToast({ title: '所选消息暂无可收藏项', icon: 'none' }) } const cid = consumerId.value if (!cid) return uni.showToast({ title: '请先登录', icon: 'none' }) uni.showLoading({ title: '收藏中...' }) try { const tasks = ids.map(id => ajax.post({ url: 'castingChatCollect', method: 'post', data: { consumerId: cid, castingChatId: id, fileTransfer: !!isFileTransfer } })) const results = await Promise.allSettled(tasks) const ok = results.filter(r => r.status === 'fulfilled').length const fail = results.length - ok uni.showToast({ title: `收藏成功:${ok},失败:${fail}`, icon: 'none' }) exitMultiSelect() } catch (e) { uni.showToast({ title: '收藏失败', icon: 'none' }) } finally { uni.hideLoading() } } function buildForwardPayload(mode = 'merge') { const list = selectedMessages.value const ids = selectedCastingChatIds.value.map(x => String(x)) const selfName = selfNickname.value || '我' const peerName = peerDisplayName.value || '对方' const items = list.map(m => { const sid = String(m.senderId || '') const senderName = sid && sid === String(consumerId.value) ? selfName : peerName return { castingChatId: m.id || '', content: m.content || '', senderId: m.senderId || '', senderName, createTime: m.createTime || '' } }) const from = { selfId: consumerId.value, selfName, selfAvatar: userAvatarFull.value || '', peerId: chatConsumerId.value, peerName, peerAvatar: peerAvatarForUI.value || '' } return { source: 'chat', mode, castingChatIds: ids, items, from } } function gotoForwardfriend(mode = 'merge') { multiForwardPickerShow.value = false const payload = buildForwardPayload(mode) exitMultiSelect() uni.navigateTo({ url: '/pages/sociAlize/forward/Forwardfriend?payload=' + encodeURIComponent(JSON.stringify(payload)) }) } /** =============== 发送文本 =============== */ async function onSend(type) { if (Isfiletransfer.value) { return uni.showToast({ title: '文件助手不支持发送消息', icon: 'none' }) } if (multiSelectMode.value) return const text = (inputText.value || '').trim() if (!consumerId.value) { return uni.showModal({ title: '未登录', content: '请先登录后再进行操作', confirmText: '去登录', success: (res) => { if (res.confirm) uni.navigateTo({ url: '/pages/Loginpage/Loginpage' }) } }) } if (!chatConsumerId.value) { return uni.showToast({ title: '缺少聊天对象', icon: 'none' }) } if (type === 'NormalText') { if (!text) return // 化身模式 if (!agent.value) { if (!agentId.value) return uni.showToast({ title: '缺少化身ID', icon: 'none' }) if (!assistantId.value) return uni.showToast({ title: '缺少助手配置', icon: 'none' }) const payload = makeTextPayload(text, quoteDraft.value) const contentStr = safeStringify(payload) localEchoPlain(contentStr, true) appendAgentLog(consumerId.value, chatConsumerId.value, agentId.value, { isUser: true, content: contentStr, createTime: new Date().toISOString() }) clearQuote() isAiReplying.value = true try { let tId = aiThreadId.value if (!tId) { const resp = await getThreads(text) tId = resp?.data?.id || '' if (!tId) throw new Error('创建 Thread 失败') aiThreadId.value = tId saveAgentThreadId(consumerId.value, chatConsumerId.value, agentId.value, tId) } else { await getMessages(text, tId) } const runRes = await getRuns(tId, assistantId.value) const runId = runRes?.data?.id if (!runId) throw new Error('创建 Run 失败') const runResult = await pollRunStatus(tId, runId, { intervalMs: 1000, maxAttempts: 40 }) if (!runResult.ok) { uni.showToast({ title: '化身回复超时', icon: 'none' }) return } const reply = await fetchLastAssistantMessage(tId) const answer = reply || '(无回复)' localEchoPlain(answer, false) appendAgentLog(consumerId.value, chatConsumerId.value, agentId.value, { isUser: false, content: answer, createTime: new Date().toISOString() }) } catch (e) { console.error(e, '化身回复错误') uni.showToast({ title: '化身回复失败', icon: 'none' }) } finally { inputText.value = '' isAiReplying.value = false } return } // 真人模式 const payload = makeTextPayload(text, quoteDraft.value) const content = safeStringify(payload) localEcho(content, payload.clientId) clearQuote() let option = { senderId: consumerId.value, receiverId: chatConsumerId.value, content, } console.log(option, '阅后焚毁') try { const saveRes = await ajax.post({ url: 'castingChat', data: option }) const newId = saveRes?.data?.id if (newId && payload.clientId) { bindServerIdToLocalMessage(payload.clientId, newId) } if (socket.value) socket.value.send(JSON.stringify({ type: 'castingChat', burnAfterReading: Postreadingburn.value, id: newId || 'tmp_' + Date.now() })) } catch (e) { uni.showToast({ title: '发送失败', icon: 'none' }) } finally { inputText.value = '' } } else { Didpopup.value = true } } /** =============== 媒体/附件(保持你原逻辑) =============== */ async function Selectvideomedia() { if (Isfiletransfer.value) { Didpopup.value = false return uni.showToast({ title: '文件助手不支持发送视频', icon: 'none' }) } if (!agent.value) { Didpopup.value = false return uni.showToast({ title: '化身模式暂不支持发送视频', icon: 'none' }) } Didpopup.value = false uni.chooseVideo({ sourceType: ['album', 'camera'], maxDuration: 60, success: async (res) => { const temp = res.tempFilePath if (temp) { await uploadAndSendSingle({ filePath: temp, type: 2, showName: filenameOf(temp), text: '' }) } } }) } // 打开相机 async function OpencameraImage() { if (Isfiletransfer.value) { Didpopup.value = false return uni.showToast({ title: '文件助手不支持发送图片', icon: 'none' }) } if (!agent.value) { Didpopup.value = false return uni.showToast({ title: '化身模式暂不支持发送图片', icon: 'none' }) } Didpopup.value = false uni.chooseImage({ count: 1, sizeType: ['original', 'compressed'], sourceType: ['camera'], success: async (res) => { const filePath = res.tempFilePaths?.[0] if (filePath) { await uploadAndSendSingle({ filePath, type: 1, showName: filenameOf(filePath), text: '' }) } } }) } async function pickAndSendImage() { if (Isfiletransfer.value) { Didpopup.value = false return uni.showToast({ title: '文件助手不支持发送图片', icon: 'none' }) } if (!agent.value) { Didpopup.value = false return uni.showToast({ title: '化身模式暂不支持发送图片', icon: 'none' }) } Didpopup.value = false uni.chooseImage({ count: 1, sizeType: ['original', 'compressed'], sourceType: ['album', 'camera'], success: async (res) => { const filePath = res.tempFilePaths?.[0] if (filePath) { await uploadAndSendSingle({ filePath, type: 1, showName: filenameOf(filePath), text: '' }) } } }) } async function pickAndSendFile() { if (Isfiletransfer.value) { Didpopup.value = false return uni.showToast({ title: '文件助手不支持发送文件', icon: 'none' }) } if (!agent.value) { Didpopup.value = false return uni.showToast({ title: '化身模式暂不支持发送文件', icon: 'none' }) } Didpopup.value = false const choose = uni.chooseMessageFile || uni.chooseFile if (!choose) return uni.showToast({ title: '当前平台不支持选择文件', icon: 'none' }) choose({ count: 1, type: 'file', success: async (res) => { const file = (res.tempFiles && res.tempFiles[0]) || (res.tempFilePaths && { path: res.tempFilePaths[0] }) const fp = file?.path || file?.tempFilePath if (fp) { await uploadAndSendSingle({ filePath: fp, type: 4, showName: file?.name || filenameOf(fp), text: '' }) } } }) } const recorder = uni.getRecorderManager ? uni.getRecorderManager() : null async function recordAndSendVoice() { if (Isfiletransfer.value) { Didpopup.value = false return uni.showToast({ title: '文件助手不支持语音', icon: 'none' }) } if (!agent.value) { Didpopup.value = false return uni.showToast({ title: '化身模式暂不支持发送语音', icon: 'none' }) } Didpopup.value = false if (!recorder) { return uni.showToast({ title: '当前平台不支持录音', icon: 'none' }) } uni.showActionSheet({ itemList: ['开始录音(最长60秒)'], success: () => { recorder.start({ duration: 60000, format: 'mp3' }) uni.showToast({ title: '录音中…再次点击结束', icon: 'none' }) recorder.onStart(() => { uni.showActionSheet({ itemList: ['结束并发送'], success: () => recorder.stop() }) }) recorder.onStop(async (res) => { const filePath = res.tempFilePath if (filePath) { await uploadAndSendSingle({ filePath, type: 3, showName: filenameOf(filePath) || '语音', text: '' }) } }) } }) } // 统一上传图片 async function uploadAndSendSingle({ filePath, type, showName, text }) { if (!consumerId.value || !chatConsumerId.value) return uni.showToast({ title: '缺少聊天对象', icon: 'none' }) uni.showLoading({ title: '上传中...' }) let option = { senderId: consumerId.value, receiverId: chatConsumerId.value, content: text || '', type, showName: showName || '' } console.log(option, '文件的参数') try { const uploadRes = await uni.uploadFile({ url: uploadUrlSingle, filePath, name: 'chatFile', formData: option }) let data = {} try { data = typeof uploadRes.data === 'string' ? JSON.parse(uploadRes.data) : uploadRes.data } catch {} const newId = data?.id const playURL = data?.playURL || '' const clientId = genClientId() const fileContent = packFileContent({ type, playURL: buildUrl(webProjectUrl.value, playURL), showName, fileName: showName, text, clientId }) localEcho(fileContent, clientId) socket.value?.send(JSON.stringify({ type: 'castingChat', id: newId || 'tmp_' + Date.now() })) } catch (e) { uni.showToast({ title: '上传失败', icon: 'none' }) } finally { uni.hideLoading() } } function onInputFocus(e) {} function onInputBlur() { ds.keyboardPadding = 0 } function trySetupKeyboardListener() {} function removeKeyboardListener() {} function shouldShowTimestamp(index) { if (index === 0) return true const cur = messages.value[index] const prev = messages.value[index - 1] const ct = new Date(cur.createTime || cur.timestamp || Date.now()).getTime() const pt = new Date(prev.createTime || prev.timestamp || Date.now()).getTime() return Math.abs(ct - pt) > 5 * 60 * 1000 } function formatChatTime(ts) { if (!ts) return '' const now = new Date() const t = new Date(ts) const Yn = now.getFullYear() const Ym = t.getFullYear() const mm = String(t.getMonth() + 1).padStart(2, '0') const dd = String(t.getDate()).padStart(2, '0') const hh = String(t.getHours()).padStart(2, '0') const min = String(t.getMinutes()).padStart(2, '0') const diffMs = now - t const oneDay = 24 * 60 * 60 * 1000 if (Yn !== Ym) return `${Ym}年${mm}月${dd}日 ${hh}:${min}` if (diffMs > oneDay) return `${mm}月${dd}日 ${hh}:${min}` return `今天 ${hh}:${min}` } function scrollToBottom() { lastItemId.value = 'msg-' + (messages.value.length - 1) nextTick(() => { lastItemId.value = '' }) } const changeAgent = () => { if (Isfiletransfer.value) return const payload = { chatConsumerId: chatConsumerId.value, // ✅ 打码时也不暴露昵称/头像(传遮挡值) nickname: isMaskChat.value ? '****' : nickname.value, avatar: isMaskChat.value ? MASK_AVATAR : avatar.value } uni.navigateTo({ url: '/pages/sociAlize/ChatDisplayPage/FriendDetails?obj=' + encodeURIComponent(JSON.stringify( payload)) }) } function handleSelfAvatarClick() { uni.showToast({ title: '自己头像点击', icon: 'none' }) } function handlePeerAvatarClick(v) { const { option } = v const others = ref({ chatConsumerId: option.chatConsumerId, nickname: isMaskChat.value ? '****' : nickname.value, avatar: isMaskChat.value ? MASK_AVATAR : option.avatar }) uni.navigateTo({ url: '/pages/sociAlize/stranger/Friendhomepage?others=' + encodeURIComponent(JSON.stringify(others .value)) }) } async function refreshModeAndHistory() { if (!consumerId.value || !chatConsumerId.value) return if (Isfiletransfer.value) return const sel = chatStore.getSelectedAgentForFriend(consumerId.value, chatConsumerId.value) if (sel && sel.agent) { const wasReal = agent.value agent.value = false agentId.value = sel.agent.id agentName.value = sel.agent.name || '化身' agentAvatar.value = sel.agent.img || '' assistantId.value = sel.agent.assistantId || '' aiThreadId.value = readAgentThreadId(consumerId.value, chatConsumerId.value, agentId.value) || '' isAiReplying.value = false if (wasReal && socket.value) { try { socket.value.close() } catch (e) {} ds.socket = null } const logs = readAgentLogs(consumerId.value, chatConsumerId.value, agentId.value) messages.value = logs.map((it) => ({ localId: 'local_' + Math.random().toString(36).slice(2), content: it.content, senderId: it.isUser ? consumerId.value : chatConsumerId.value, receiverId: it.isUser ? chatConsumerId.value : consumerId.value, isUser: !!it.isUser, createTime: it.createTime || new Date().toISOString() })) } else { agent.value = true agentId.value = '' agentName.value = '' agentAvatar.value = '' assistantId.value = '' aiThreadId.value = '' isAiReplying.value = false messages.value = [] rowStartIdx.value = 0 noMoreHistory.value = false ds.seenClientIds = new Set() ds.lastLocalEchoClientId = '' if (!socket.value) initSocket() await loadHistory(true) } } onLoad((option) => { messages.value = [] rowStartIdx.value = 0 noMoreHistory.value = false ds.seenClientIds = new Set() ds.lastLocalEchoClientId = '' Isfiletransfer.value = false pendingForward.value = null hasInitForward.value = false exitMultiSelect() clearQuote() if (option?.FriendID) { chatConsumerId.value = option.FriendID nickname.value = decodeURIComponent(option.nickname || '') avatar.value = option.avatar || '' peerAvatarPath.value = option.avatar || option.avatarUrl || '' } if (option.target) { try { Isfiletransfer.value = true const o = JSON.parse(decodeURIComponent(option.target)) chatConsumerId.value = o.id nickname.value = o.nickname || '文件传输助手' avatar.value = o.avatar peerAvatarPath.value = o.avatar || o.avatarUrl || '' } catch (e) {} } else if (option.Friendmessage) { try { const o = JSON.parse(decodeURIComponent(option.Friendmessage)) chatConsumerId.value = o.id nickname.value = o.nickname avatar.value = o.avatar peerAvatarPath.value = o.avatar || o.avatarUrl || '' } catch (e) {} } else if (option.MessageList) { try { const o = JSON.parse(decodeURIComponent(option.MessageList)) chatConsumerId.value = o.id nickname.value = o.name avatar.value = o.avatarUrl if (o.idarr && Array.isArray(o.idarr)) { ForwardingID.value = o.idarr } peerAvatarPath.value = o.avatarUrl || '' } catch (e) {} } if (option.addFriend || option.Listmediadata) { try { const addFriend = JSON.parse(decodeURIComponent(option.addFriend || '[]')) const list = JSON.parse(decodeURIComponent(option.Listmediadata || '[]')) if (addFriend?.length) { chatConsumerId.value = addFriend[0].id nickname.value = addFriend[0].nickname || addFriend[0].name || '' avatar.value = addFriend[0].avatar || addFriend[0].avatarUrl || '' peerAvatarPath.value = avatar.value } if (list && list.length) { sendAdToRecipients(addFriend || [], list) } } catch (e) {} } if (option.forward) { try { const p = JSON.parse(decodeURIComponent(option.forward)) pendingForward.value = p || null } catch (e) { pendingForward.value = null } } if (!consumerId.value) { uni.showModal({ title: '未登录', content: '请先登录后再进行操作', confirmText: '去登录', success: (res) => { if (res.confirm) { uni.navigateTo({ url: '/pages/Loginpage/Loginpage' }) } } }) return } if (!chatConsumerId.value && !Isfiletransfer.value) { uni.showToast({ title: '缺少聊天对象', icon: 'none' }) return } }) onMounted(async () => { updateInputHeight() multiActionHeight.value = uni.upx2px(136) const sys = uni.getSystemInfoSync() ds.safeAreaBottom = sys.safeArea && sys.safeArea.bottom ? sys.windowHeight - sys.safeArea.bottom : 0 ds.containerTop = statusBarHeight.value + headerBarHeight.value if (!Isfiletransfer.value) { enableFloatWindow() try { await VideoCallsService.initAndLogin() } catch (e) { console.error('登录音视频失败:', e) } VideoCallsService.enableFloatWindow(true) VideoCallsService.addEventListeners({ onError: (res) => {}, onCallReceived: (res) => {}, onCallBegin: (res) => {}, onCallEnd: (res) => {} }) } }) onUnmounted(() => { VideoCallsService.removeEventListeners({}) }) onBeforeUnmount(() => { removeKeyboardListener() if (socket.value) socket.value.close() }) async function sendAdToRecipients(recipients = [], list = []) { const payload = makeAdPayload(list, webProjectUrl.value) payload.clientId = genClientId() const content = safeStringify(payload) const ids = Array.from(new Set((recipients || []).map((r) => String(r?.id)).filter(Boolean))) const curId = String(chatConsumerId.value || '') if (!ids.length) return uni.showToast({ title: '未选择好友', icon: 'none' }) if (ids.includes(curId)) localEcho(content, payload.clientId) const reqs = ids.map((id) => ajax.post({ url: 'castingChat', data: { senderId: consumerId.value, receiverId: id, content } }) ) const results = await Promise.allSettled(reqs) results.forEach((r, i) => { if (r.status === 'fulfilled') { const newId = r.value?.data?.id || 'tmp_' + Date.now() + '_' + i socket.value?.send(JSON.stringify({ type: 'castingChat', id: newId })) } }) const ok = results.filter((r) => r.status === 'fulfilled').length const fail = results.length - ok uni.showToast({ title: `已发送:${ok},失败:${fail}`, icon: 'none' }) } /** =============== 文件助手本地缓存 & 历史(保持原逻辑) =============== */ function readFileTransferCache(uid) { if (!uid) return [] const raw = uni.getStorageSync(fileTransferKey(uid)) if (!raw) return [] try { const arr = typeof raw === 'string' ? JSON.parse(raw) : raw return Array.isArray(arr) ? arr : [] } catch (e) { return [] } } function saveFileTransferCache(uid, list) { if (!uid) return try { uni.setStorageSync(fileTransferKey(uid), JSON.stringify(list || [])) } catch (e) {} } function normalizeCollectItemToMessage(item) { let contentStr = '' if (item.content !== undefined && item.content !== null && item.content !== '') { if (typeof item.content === 'string') contentStr = item.content else contentStr = safeStringify(item.content) } else { const playURL = item.playUrl || item.fileName || '' const showName = item.showName || filenameOf(item.fileName || '') || '' const t = item.type || 4 const filePayload = packFileContent({ type: t, playURL: buildUrl(webProjectUrl.value, playURL), showName, fileName: item.fileName || '', text: '' }) contentStr = filePayload } const ts = item.createTime return { id: item.id, content: contentStr, senderId: consumerId.value, receiverId: consumerId.value, isUser: true, createTime: ts ? new Date(ts).toISOString() : new Date().toISOString() } } const fileTransferRowStartIdx = ref(0) const fileTransferRowCount = ref(50) const loadingFileTransfer = ref(false) const noMoreFileTransfer = ref(false) async function loadFileTransferHistory(initial = false) { if (!consumerId.value) return if (loadingFileTransfer.value) return loadingFileTransfer.value = true if (initial) { fileTransferRowStartIdx.value = 0 noMoreFileTransfer.value = false messages.value = [] } try { const res = await ajax.post({ url: 'castingChatCollectList', method: 'post', data: { consumerId: consumerId.value, type: 0, fileTransfer: true, rowStartIdx: fileTransferRowStartIdx.value, rowCount: fileTransferRowCount.value } }) const arr = res?.data?.castingChatCollectArray || [] if (arr.length) { const mapped = arr.map((it) => normalizeCollectItemToMessage(it)) messages.value = initial ? mapped : [...messages.value, ...mapped] fileTransferRowStartIdx.value += arr.length saveFileTransferCache(consumerId.value, messages.value) } else { if (initial) { const cached = readFileTransferCache(consumerId.value) if (cached.length) messages.value = cached } noMoreFileTransfer.value = true } } catch (e) { if (initial) { const cached = readFileTransferCache(consumerId.value) if (cached.length) { messages.value = cached } else { uni.showToast({ title: '文件助手记录加载失败', icon: 'none' }) } } else { uni.showToast({ title: '加载更多失败', icon: 'none' }) } } finally { loadingFileTransfer.value = false } } /** ✅ 修复:处理合并转发产生的 Socket 通知 + 原逐条转发逻辑保持 */ async function sendForwardIfNeeded() { if (ForwardingID.value && ForwardingID.value.length > 0) { const idStr = ForwardingID.value.join(',') if (socket.value) { socket.value.send(JSON.stringify({ type: 'forwardCastingChat', id: idStr })) } ForwardingID.value = [] return } if (Isfiletransfer.value) return const payload = pendingForward.value if (!payload) return pendingForward.value = null if (!consumerId.value || !chatConsumerId.value) { uni.showToast({ title: '缺少聊天对象', icon: 'none' }) return } let items = [] if (payload.mode === 'items' && Array.isArray(payload.items)) { items = payload.items } else if (payload.content) { items = [{ content: payload.content, castingChatId: payload.castingChatId || '' }] } else { return } for (const it of items) { let contentStr = '' const raw = it.content if (typeof raw === 'string') { try { const obj = JSON.parse(raw) if (obj && obj.msgType === 'text') { obj.clientId = genClientId() obj.ts = Date.now() contentStr = safeStringify(obj) } else { contentStr = raw } } catch (e) { contentStr = raw } } else { contentStr = safeStringify(raw) } let cid = '' try { const p = JSON.parse(contentStr) cid = p.clientId || '' } catch (e) {} localEcho(contentStr, cid) let option = { senderId: consumerId.value, receiverId: chatConsumerId.value, content: contentStr } try { const res = await ajax.post({ url: 'castingChat', data: option }) const newId = res?.data?.id if (newId && cid) bindServerIdToLocalMessage(cid, newId) socket.value?.send(JSON.stringify({ type: 'castingChat', id: newId || 'tmp_' + Date.now() })) } catch (e) { uni.showToast({ title: '转发失败', icon: 'none' }) } } } </script> <style lang="scss" scoped> @import '@/assets/styles/global.scss'; .content { min-height: 100vh; position: relative; background-size: cover; } .overlay { position: absolute; inset: 0; background-size: cover; } .time-divider { width: 100%; text-align: center; color: #999; font-size: 24rpx; margin: 20rpx 0; } .header { width: 94%; margin: 0 auto; } .header-inner { position: relative; display: flex; align-items: center; justify-content: center; height: 100%; } .left-box, .right-box { position: absolute; top: 0; bottom: 0; width: 120rpx; display: flex; align-items: center; } .left-box { left: 0; justify-content: flex-start; } .right-box { right: 0; justify-content: flex-end; } .cancel-text { font-size: 30rpx; color: #333; } .center-box { flex: none; } .title-text { font-size: 32rpx; color: #111; } .chat-scroll { position: absolute; left: 0; right: 0; z-index: 1; padding: 0 3%; box-sizing: border-box; } .multi-action-bar { width: 100%; position: absolute; bottom: 0; z-index: 2; background: rgba(255, 255, 255, 0.98); border-top: 1rpx solid #eee; } .multi-action-inner { height: 136rpx; display: flex; align-items: center; justify-content: space-around; padding: 0 24rpx; box-sizing: border-box; } .multi-btn { height: 76rpx; min-width: 140rpx; border-radius: 12rpx; display: flex; justify-content: center; align-items: center; background: #f5f5f5; color: #333; font-size: 28rpx; } .multi-btn-primary { background: #d89833; color: #fff; } .mode-popup { width: 686rpx; height: 240rpx; background-color: #ffffff; display: flex; justify-content: space-around; align-items: center; border-radius: 12rpx; } .input-bar { width: 100%; min-height: 136rpx; position: absolute; bottom: 0; z-index: 1; display: flex; flex-direction: column; justify-content: center; align-items: center; padding-top: 10rpx; box-sizing: border-box; } .quote-bar { width: 94%; background: #ffffff; border-radius: 12rpx; padding: 16rpx 18rpx; box-sizing: border-box; display: flex; align-items: center; justify-content: space-between; margin-bottom: 10rpx; border: 1rpx solid #f0f0f0; } .quote-bar-inner { flex: 1; display: flex; align-items: center; gap: 12rpx; overflow: hidden; } .quote-label { font-size: 26rpx; color: #999; flex: none; } .quote-text { font-size: 26rpx; color: #333; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .quote-close { width: 46rpx; height: 46rpx; border-radius: 50%; background: #f3f3f3; display: flex; justify-content: center; align-items: center; color: #666; font-size: 34rpx; margin-left: 12rpx; flex: none; } .input-left { width: 80%; height: 62rpx; display: flex; justify-content: center; align-items: center; } .input-right { width: 20%; height: 62rpx; display: flex; justify-content: space-between; box-sizing: border-box; } .input-wrapper { width: 100%; height: 100%; background-color: #ffffff; border-radius: 12rpx; display: flex; align-items: center; } .input-wrapper input { width: 100%; height: 100%; padding-left: 10rpx; box-sizing: border-box; } .input-wrapper--disabled { background-color: #f0f0f0; } .input-wrapper--disabled input { color: #999; } .btn-send { background-color: #d89833; border-radius: 12rpx; width: 90%; height: 100%; display: flex; justify-content: center; align-items: center; color: #fff; } .ai-typing-indicator { width: 100%; padding: 10rpx 0 20rpx; display: flex; justify-content: flex-start; } .ai-typing-bubble { background: #ffffff; border-radius: 12rpx; padding: 10rpx 18rpx; display: flex; align-items: center; gap: 6rpx; } .dot { width: 10rpx; height: 10rpx; border-radius: 50%; background: #d3d3d3; animation: typingBounce 1s infinite ease-in-out; } .dot:nth-child(2) { animation-delay: 0.15s; } .dot:nth-child(3) { animation-delay: 0.3s; } @keyframes typingBounce { 0%, 80%, 100% { transform: translateY(0); opacity: 0.4; } 40% { transform: translateY(-4rpx); opacity: 1; } } </style>这个页面下面是chat的气泡组件的内容Bubblechatbox模块<template> <view :class="['chat-message', role]"> <!-- 左侧多选圆点(自己) --> <view v-if="multiSelectMode && role === 'user'" class="select-wrap" @click.stop="toggleSelect"> <view :class="['select-circle', { selected }]"></view> </view> <!-- 头像 --> <image @click="onAvatarClick" class="avatar" :src="avatarSrc" mode="aspectFill" /> <!-- 气泡 --> <view class="bubble-wrapper" @touchstart.stop="handleTouchStart" @touchend="handleTouchEnd"> <view class="bubble" :class="{ typing: role === 'typing' }" @tap.stop="onBubbleClick" @click.stop="onBubbleClick"> <!-- 正在输入 --> <template v-if="role === 'typing'"> <view @longpress="copyPlain(renderText)" class="typing-text">{{ renderText }}</view> <view class="dots"> <view v-for="n in 3" :key="n" class="dot" :style="{ animationDelay: n * 0.2 + 's' }" /> </view> </template> <!-- 广告消息(保持原来的逻辑) --> <template v-else-if="isAd && cards.length"> <view v-for="(card, idx) in cards" :key="card.adId || idx" class="ad-card"> <view class="ad-top"> <view class="ad-time">{{ nowHM }}</view> <view class="ad-actions"> <view v-if="card.mallGoodsGroupId" class="ad-btn" @click.stop="goProduct(card)"> 查看商品 </view> <view v-if="hasShop(card)" class="ad-btn" @click.stop="goShop(card)">进店</view> </view> </view> <view class="ad-media" @click="openFullscreen(card)"> <video v-if="card?.media?.isVideo" :src="fullUrl(card?.media?.playURL)" :controls="true" :show-center-play-btn="true" :show-fullscreen-btn="false" style="height: 100%; width: 100%; object-fit: cover" /> <image v-else :src="fullUrl(card?.media?.playURL)" mode="aspectFill" style="height: 100%; width: 100%; display: block" /> <view class="ad-badge"> <view style="font-size: 25rpx; color: #ccc">广告</view> <uni-icons :type="isopen ? 'down' : 'up'" color="#ffffff" size="20" /> </view> </view> <view class="ad-ops"> <view @click="copyMedia(card)" class="ad-op-btn">复制链接</view> <view @click="shareHint" class="ad-op-btn"> <text class="gold-text">分享</text> </view> </view> <view class="ad-meta"> <text>类型:{{ card.typeName ? card.typeName + '广告' : '广告' }}</text> <text style="margin-left: 20rpx"> 价格:¥{{ (Number(card.price) || 0).toFixed(2) }} </text> </view> </view> </template> <!-- 文件类消息:图片/视频/语音/文件 --> <template v-else-if="isFileLike"> <view v-if="fileType === 1"> <image :src="fileUrl" style="max-width: 480rpx; max-height: 560rpx; border-radius: 8rpx" mode="widthFix" @click.stop="previewImage(fileUrl)" /> </view> <view v-else-if="fileType === 2" style="width: 520rpx; max-width: 75vw"> <MyVideo :videoUrl="fileUrl" /> <video :src="fileUrl" controls style="width: 100%; border-radius: 8rpx" /> </view> <view v-else-if="fileType === 3" style="max-width: 520rpx"> <audio :src="fileUrl" controls style="width: 100%" /> <view v-if="fileShowName" style="margin-top: 8rpx; color: #ddd; font-size: 24rpx"> {{ fileShowName }} </view> </view> <view v-else-if="fileType === 4" style="display: flex; align-items: center; gap: 12rpx; max-width: 520rpx"> <uni-icons type="paperclip" size="22" /> <text style="color: #000" @click="openOrDownload(fileUrl)"> {{ fileShowName || '文件' }} </text> </view> </template> <!-- ✅ 合并转发:聊天记录卡片(点击后弹出多条消息) --> <template v-else-if="isForwardRecord"> <view class="forward-card" @tap.stop="openForwardDetail" @click.stop="openForwardDetail"> <view class="fc-title">{{ forwardTitle }}</view> <view v-for="(line, i) in forwardLines" :key="i" class="fc-line"> {{ line }} </view> <view class="fc-count">聊天记录({{ forwardCount }}条)</view> </view> </template> <!-- 普通文本 --> <template v-else> <view v-if="hasQuote" class="quote-snippet"> <text class="quote-snippet-text">{{ quoteLine }}</text> </view> <text class="content">{{ renderText }}</text> </template> </view> </view> <!-- 右侧多选圆点(对方) --> <view v-if="multiSelectMode && role !== 'user'" class="select-wrap" @click.stop="toggleSelect"> <view :class="['select-circle', { selected }]"></view> </view> <!-- 长按菜单 --> <up-popup :show="showMenu" @close="closeMenu" mode="center" :closeable="true" :safeAreaInsetTop="true" closeIconPos="top-right" zIndex="99999" round="12"> <view class="menu-popup"> <view v-if="!isCollecting" class="menu-layer"> <view class="menu-row"> <view v-for="(item, index) of Theabovefunction" :key="index" @click="onMenuClick(item)"> <up-button type="warning" style="height: 80rpx; width: 120rpx" :text="item.title" /> </view> </view> <view class="menu-row"> <view v-for="(item, index) of Thefollowingfunctions" :key="index" @click="onMenuClick(item)"> <up-button type="warning" style="height: 80rpx; width: 120rpx" :text="item.title" /> </view> </view> </view> <!-- 收藏方式 --> <view v-else class="collect-layer"> <up-button style="height: 80rpx; width: 186rpx" type="warning" @click="collectMessage(false)" text="咖信收藏" /> <up-button style="height: 80rpx; width: 186rpx" type="warning" @click="collectMessage(true)" text="咖密收藏" /> </view> </view> </up-popup> <!-- ✅ 合并聊天记录详情弹窗(打码处理) --> <up-popup :show="showForwardDetail" @close="showForwardDetail = false" mode="center" :closeable="true" :safeAreaInsetTop="true" closeIconPos="top-right" zIndex="99998" round="12"> <view class="forward-detail-popup"> <scroll-view scroll-y style="height: 70vh"> <view v-for="(it, idx) in forwardItems" :key="idx" :class="['fr-row', forwardAlign(it)]"> <view class="fr-body"> <text class="fr-name">{{ forwardSenderName(it) }}</text> <view class="fr-bubble"> <view v-if="forwardHasQuote(it)" class="fr-quote"> <text class="fr-quote-text">{{ forwardQuoteLine(it) }}</text> </view> <template v-if="forwardKind(it) === 'file'"> <image v-if="forwardFileType(it) === 1" :src="forwardFileUrl(it)" mode="widthFix" style="max-width: 420rpx; border-radius: 8rpx" @click.stop="previewImage(forwardFileUrl(it))" /> <view v-else class="fr-file-line"> <text>{{ forwardPreview(it) }}</text> </view> </template> <template v-else> <text class="fr-text">{{ forwardPreview(it) }}</text> </template> </view> </view> </view> </scroll-view> </view> </up-popup> </view> </template> <script setup> import { defineProps, defineEmits, computed, ref } from 'vue' import ajax from '@/api/ajax.js' import MyVideo from '@/components/HomepageComponents/Recommend/MyVideo.vue' const props = defineProps({ role: { type: String, required: true, validator: (v) => ['user', 'assistant', 'typing'].includes(v) }, content: { type: [String, Object], default: '' }, userAvatar: { type: String, default: '' }, assistantAvatar: { type: String, default: '' }, // ✅ 打码相关 maskPeer: { type: Boolean, default: false }, maskPeerName: { type: String, default: '****' }, maskAvatarUrl: { type: String, default: '' }, showCopyButton: { type: Boolean, default: false }, showBadge: { type: Boolean, default: false }, chatConsumerId: { type: String, default: '' }, consumerId: { type: String, default: '' }, webProjectUrl: { type: String, default: '' }, castingChatId: { type: String, default: '' }, scene: { type: String, default: 'chat' }, castingChatGroupId: { type: String, default: '' }, castingChatGroupChatId: { type: String, default: '' }, messageKey: { type: String, default: '' }, multiSelectMode: { type: Boolean, default: false }, selected: { type: Boolean, default: false }, selectDisabled: { type: Boolean, default: false } }) const emit = defineEmits([ 'self-avatar-click', 'peer-avatar-click', 'toggle-select', 'enter-multiselect', 'quote-message', 'delete-message', 'recall-message' ]) // 只遮挡对方的不遮挡自己的 // const MASK_FALLBACK = // 'https://gd-hbimg.huaban.com/c294fbc2e5c35963a25d97f973f202c9a3c66f766125-RRVX90_fw658' // const avatarSrc = computed(() => { // const isPeer = props.role === 'assistant' || props.role === 'typing' // const src = isPeer ? props.assistantAvatar : props.userAvatar // if (props.maskPeer && isPeer) return props.maskAvatarUrl || MASK_FALLBACK // return src // }) // 全部拿下 const MASK_FALLBACK = 'https://gd-hbimg.huaban.com/c294fbc2e5c35963a25d97f973f202c9a3c66f766125-RRVX90_fw658'; const avatarSrc = computed(() => { // 1. 只要 maskPeer 开关打开,就强制使用遮罩图 if (props.maskPeer) return props.maskAvatarUrl || MASK_FALLBACK; const isPeer = props.role === 'assistant' || props.role === 'typing'; return isPeer ? props.assistantAvatar : props.userAvatar; }); /* ========== 通用工具 ========== */ const timer = ref(null) const showMenu = ref(false) const isCollecting = ref(false) const showForwardDetail = ref(false) const Theabovefunction = ref([{ title: '转发', id: '1' }, { title: '收藏', id: '2' }, { title: '删除', id: '3' } ]) const Thefollowingfunctions = ref([{ title: '引入', id: '4' }, { title: '多选', id: '5' }, { title: '撤回', id: '6' } ]) function closeMenu() { showMenu.value = false isCollecting.value = false } function handleTouchStart() { if (props.multiSelectMode) return timer.value = setTimeout(() => { showMenu.value = true }, 800) } function handleTouchEnd() { clearTimeout(timer.value) } function safeParseDeep(input) { let data = input for (let i = 0; i < 4; i++) { if (typeof data === 'string') { const s = data.trim() try { if ((s.startsWith('{') && s.endsWith('}')) || (s.startsWith('[') && s.endsWith(']'))) { data = JSON.parse(s) continue } if (/^"(\\.|[^"])*"$/.test(s)) { data = JSON.parse(s) continue } } catch (e) {} break } if (typeof data === 'object' && data !== null) break } return data } function normalizeParsed(input) { let o = safeParseDeep(input) for (let i = 0; i < 4; i++) { if (o && typeof o === 'object') { if (o.msgType || o._kind || o.type) return o if ('content' in o && !('msgType' in o) && !('_kind' in o)) { o = safeParseDeep(o.content) continue } } break } return o } function fullUrl(nameOrUrl = '') { if (!nameOrUrl) return '' if (/^https?:\/\//i.test(nameOrUrl)) return nameOrUrl const base = String(props.webProjectUrl || '').replace(/\/$/, '') const path = String(nameOrUrl).replace(/^\//, '') return base ? `${base}/${path}` : path } function asString(x) { try { return typeof x === 'string' ? x : JSON.stringify(x) } catch { return String(x || '') } } /* ========== 解析 content ========== */ const parsed = computed(() => normalizeParsed(props.content)) const isAd = computed(() => parsed.value && typeof parsed.value === 'object' && parsed.value.msgType === 'ad') const isTextPayload = computed(() => parsed.value && typeof parsed.value === 'object' && parsed.value.msgType === 'text') const isFileLike = computed(() => { const o = parsed.value if (!o || typeof o !== 'object') return false if (o._kind === 'file') return true if (typeof o.type === 'number' && o.type >= 1 && o.type <= 4) return true return false }) const isForwardRecord = computed(() => { const o = parsed.value if (!o || typeof o !== 'object') return false if (o.type == 5 || o.type == 6) return true if (o.msgType === 'forward_record' || o.msgType === 'forward') return true return false }) const forwardObj = computed(() => (isForwardRecord.value ? parsed.value : null)) const forwardTitle = computed(() => { // ✅ 打码时不展示可能包含真实昵称的 title if (props.maskPeer) return '聊天记录' const o = forwardObj.value if (o?.title) return o.title if (o?.type == 6) return '群聊的聊天记录' return '聊天记录' }) const forwardItems = computed(() => { const o = forwardObj.value || {} if (Array.isArray(o.forwardCastingChatArray)) return o.forwardCastingChatArray if (Array.isArray(o.forwardCastingChatGroupChatArray)) return o.forwardCastingChatGroupChatArray if (Array.isArray(o.items)) return o.items if (Array.isArray(o.chats)) return o.chats if (Array.isArray(o.records)) return o.records return [] }) const forwardCount = computed(() => Number(forwardObj.value?.count || forwardItems.value.length || 0)) function computePreviewText(raw) { if (!raw) return '' let s = raw if (typeof s !== 'string') { try { s = JSON.stringify(s) } catch { s = String(s) } } const obj = normalizeParsed(s) if (obj && typeof obj === 'object') { if (obj.msgType === 'text') return String(obj.text || '').slice(0, 60) if (!obj.msgType && typeof obj.text === 'string' && obj.text) return String(obj.text).slice(0, 60) if (obj._kind === 'file' || (obj.type >= 1 && obj.type <= 4)) { const t = Number(obj.type || 0) const baseName = obj.showName || obj.fileName || '' if (t === 1) return baseName ? `[图片] ${baseName}` : '[图片]' if (t === 2) return baseName ? `[视频] ${baseName}` : '[视频]' if (t === 3) return baseName ? `[语音] ${baseName}` : '[语音]' if (t === 4) return baseName ? `[文件] ${baseName}` : '[文件]' return '[文件]' } if (obj.msgType === 'ad') return '[广告]' if (obj.type == 5 || obj.type == 6 || obj.msgType === 'forward' || obj.msgType === 'forward_record') return obj.title || '[聊天记录]' } return String(s).slice(0, 60) } // ✅ 打码:弹窗/预览里非本人统一显示 **** function maskNameIfNeeded(it, rawName = '用户') { if (!props.maskPeer) return rawName const sid = String(it?.senderId || '') const selfId = String(props.consumerId || '') if (sid && selfId && sid === selfId) return rawName return props.maskPeerName || '****' } const forwardLines = computed(() => forwardItems.value.slice(0, 2).map((it) => { const raw = it.senderNickname || it.nickname || it.senderName || '用户' const name = maskNameIfNeeded(it, raw) return `${name}:${computePreviewText(it.content)}` }) ) const renderText = computed(() => { if (isTextPayload.value) return String(parsed.value?.text || '') if (parsed.value && typeof parsed.value === 'object' && typeof parsed.value.text === 'string') return String(parsed.value.text) if (typeof props.content === 'string') return props.content return asString(props.content) }) const hasQuote = computed(() => !!(isTextPayload.value && parsed.value?.quote && (parsed.value.quote.preview || parsed.value.quote.senderName)) ) const quoteLine = computed(() => { const q = parsed.value?.quote || {} const name = q.senderName || '用户' const p = q.preview || '' return `${name}:${p}` }) const fileType = computed(() => (isFileLike.value ? Number(parsed.value.type) : 0)) const fileUrl = computed(() => (isFileLike.value ? fullUrl(parsed.value.playURL || parsed.value.fileName) : '')) const fileShowName = computed(() => (isFileLike.value ? parsed.value.showName || parsed.value.fileName || '' : '')) const cards = computed(() => (Array.isArray(parsed.value?.cards) ? parsed.value.cards : [])) const isopen = ref(false) const nowHM = computed(() => { const d = new Date() const h = String(d.getHours()).padStart(2, '0') const m = String(d.getMinutes()).padStart(2, '0') return `${h}:${m}` }) function hasShop(card) { return !!card?.mallEnterpriseId } function previewImage(val) { uni.previewImage({ current: val, urls: [val] }) } function copyPlain(text = '') { uni.setClipboardData({ data: text, success() { uni.showToast({ title: '已复制', icon: 'none' }) } }) } function copyMedia(card) { const u = fullUrl(card?.media?.playURL || '') if (!u) return uni.showToast({ title: '暂无链接', icon: 'none' }) copyPlain(u) } function shareHint() { uni.showToast({ title: '已复制,可粘贴去分享', icon: 'none' }) } function onAvatarClick() { const isSelf = props.role === 'user' if (isSelf) emit('self-avatar-click', { consumerId: props.consumerId }) else emit('peer-avatar-click', { option: { chatConsumerId: props.chatConsumerId, avatar: avatarSrc.value } }) } function toggleSelect() { if (props.selectDisabled) { return uni.showToast({ title: '该消息暂不支持多选', icon: 'none' }) } emit('toggle-select', props.messageKey) } function onBubbleClick() { if (isForwardRecord.value) { openForwardDetail() } } function isGroupScene() { return props.scene === 'groupChat' || !!props.castingChatGroupId || !!props.castingChatGroupChatId } function currentGroupChatId() { return String(props.castingChatGroupChatId || props.castingChatId || '') } function onMenuClick(item) { const title = item?.title if (title === '收藏') { isCollecting.value = true return } if (title === '转发') { const basePayload = { mode: 'single', content: normalizeContentForForward() } if (isGroupScene()) { const gid = String(props.castingChatGroupId || '') const gcid = currentGroupChatId() const payload = { ...basePayload, source: 'groupChat', castingChatGroupId: gid, castingChatGroupChatId: gcid, castingChatGroupChatIds: gcid ? [gcid] : [] } closeMenu() return uni.navigateTo({ url: '/pages/sociAlize/forward/Forwardfriend?payload=' + encodeURIComponent(JSON.stringify( payload)) }) } const payload = { ...basePayload, source: 'chat', castingChatId: props.castingChatId || '' } closeMenu() return uni.navigateTo({ url: '/pages/sociAlize/forward/Forwardfriend?payload=' + encodeURIComponent(JSON.stringify( payload)) }) } if (title === '删除') { closeMenu() return emit('delete-message', { scene: isGroupScene() ? 'groupChat' : 'chat', castingChatId: props.castingChatId || '', castingChatGroupId: props.castingChatGroupId || '', castingChatGroupChatId: currentGroupChatId(), messageKey: props.messageKey, isUser: props.role === 'user' }) } if (title === '撤回') { closeMenu() return emit('recall-message', { scene: isGroupScene() ? 'groupChat' : 'chat', castingChatId: props.castingChatId || '', castingChatGroupId: props.castingChatGroupId || '', castingChatGroupChatId: currentGroupChatId(), messageKey: props.messageKey, isUser: props.role === 'user' }) } if (title === '多选') { closeMenu() return emit('enter-multiselect', props.messageKey) } if (title === '引入') { closeMenu() return emit('quote-message', { scene: isGroupScene() ? 'groupChat' : 'chat', castingChatId: props.castingChatId || '', castingChatGroupId: props.castingChatGroupId || '', castingChatGroupChatId: currentGroupChatId(), content: normalizeContentForForward(), isUser: props.role === 'user', messageKey: props.messageKey }) } } async function collectMessage(isFileTransfer = false) { const consumerId = uni.getStorageSync('consumerId') if (!consumerId) return uni.showToast({ title: '请先登录', icon: 'none' }) const isGroup = isGroupScene() const idForCollect = isGroup ? currentGroupChatId() : props.castingChatId || '' if (!idForCollect) return uni.showToast({ title: '该消息暂无法收藏', icon: 'none' }) try { let data = { } if (isFileTransfer) { data = { consumerId, castingSecret: !!isFileTransfer, fileTransfer: !!isFileTransfer } } else { data = { consumerId, castingSecret: !!isFileTransfer, fileTransfer: !!isFileTransfer } } if (isGroup) data.castingChatGroupChatId = idForCollect else data.castingChatId = idForCollect const res = await ajax.post({ url: 'castingChatCollect', method: 'post', data }) if (res?.data?.result === 'success') { uni.showToast({ title: '收藏成功', icon: 'none' }) closeMenu() } else { uni.showToast({ title: res?.data?.msg || '收藏失败', icon: 'none' }) } } catch (e) { uni.showToast({ title: '收藏异常', icon: 'none' }) } } function normalizeContentForForward() { if (typeof props.content === 'string') return props.content return asString(props.content) } function openOrDownload(url) { // #ifdef H5 window.open(url, '_blank') // #endif // #ifndef H5 uni.downloadFile({ url, success: ({ tempFilePath }) => { if (uni.openDocument) { uni.openDocument({ filePath: tempFilePath }) } else { uni.showToast({ title: '已下载', icon: 'none' }) } }, fail: () => uni.showToast({ title: '下载失败', icon: 'none' }) }) // #endif } function openFullscreen(card) { const { mallGoodsGroupTitle } = card if (mallGoodsGroupTitle) { const obj = { getmallGoodsGroupId: card.mallGoodsGroupId, browsingtime: card.requiredSec, price: card.price, media: card.media } uni.navigateTo({ url: '/pages/index/goodsPage/ProductDetails?advertisement=' + encodeURIComponent(JSON.stringify( obj || {})) }) } else { const list1 = [{ ...card, type: 'sharetype', Sharefriends: props.chatConsumerId }] uni.navigateTo({ url: '/pages/wealthDiscuss/fullscreenvideo?list1=' + encodeURIComponent(JSON.stringify(list1)) }) } } function goProduct(card) { const id = card?.mallGoodsGroupId if (!id) return const sec = card?.requiredSec || 30 uni.navigateTo({ url: '/pages/index/goodsPage/ProductDetails?id=' + encodeURIComponent(id) + '&fromAd=1&adId=' + encodeURIComponent(card?.adId || '') + '&sec=' + encodeURIComponent(sec) }) } function goShop(card) { const id = card?.mallEnterpriseId if (!id) return uni.showToast({ title: '暂未配置店铺入口', icon: 'none' }) uni.navigateTo({ url: `/pages/mall/enterprise?id=${id}` }) } function openForwardDetail() { showForwardDetail.value = true } function forwardAlign(it) { const o = forwardObj.value || {} const leftId = o.leftId || o.from?.peerId || '' const rightId = o.rightId || o.from?.selfId || '' const sid = String(it?.senderId || '') if (rightId && sid && sid === String(rightId)) return 'right' if (leftId && sid && sid === String(leftId)) return 'left' return 'left' } function forwardSenderName(it) { const raw = it?.senderNickname || it?.nickname || it?.senderName || '用户' return maskNameIfNeeded(it, raw) } function forwardContentObj(it) { return normalizeParsed(it?.content) } function forwardKind(it) { const o = forwardContentObj(it) if (o && typeof o === 'object') { if (o._kind === 'file' || o.type) return 'file' if (o.msgType === 'text') return 'text' } return 'text' } function forwardPreview(it) { return computePreviewText(it?.content) } function forwardFileType(it) { const o = forwardContentObj(it) return Number(o?.type || 0) } function forwardFileUrl(it) { const o = forwardContentObj(it) const u = o?.playURL || o?.fileName || '' return fullUrl(u) } function forwardHasQuote(it) { const o = forwardContentObj(it) return !!(o && o.msgType === 'text' && o.quote && (o.quote.preview || o.quote.senderName)) } function forwardQuoteLine(it) { const o = forwardContentObj(it) || {} const q = o.quote || {} const rawName = q.senderName || '用户' // 引用里也打码:只要不是本人都变 ****(引用消息的 senderName 无 senderId,只能按整体策略) const name = props.maskPeer ? (rawName === '我' ? '我' : (props.maskPeerName || '****')) : rawName return `${name}:${q.preview || ''}` } </script> <style scoped> .chat-message { display: flex; align-items: flex-start; margin-top: 12rpx; } .chat-message.user { flex-direction: row-reverse; } .avatar { width: 78rpx; height: 78rpx; border-radius: 50%; margin-right: 12rpx; } .chat-message.user .avatar { margin-left: 12rpx; margin-right: 0; } .select-wrap { width: 60rpx; height: 78rpx; display: flex; justify-content: center; align-items: center; } .select-circle { width: 34rpx; height: 34rpx; border-radius: 50%; border: 2rpx solid #cfcfcf; background: #fff; } .select-circle.selected { background: #d89833; border-color: #d89833; } .bubble-wrapper { display: flex; flex-direction: column; max-width: 75%; } .bubble { position: relative; padding: 16rpx 20rpx; line-height: 1.4; word-break: break-word; white-space: pre-wrap; } .chat-message.assistant .bubble { background: #fff; color: #000; border-radius: 16rpx 16rpx 16rpx 4rpx; } .chat-message.user .bubble { background: #95ec69; color: #000; border-radius: 16rpx 16rpx 4rpx 16rpx; } .chat-message.assistant .bubble::before { content: ''; position: absolute; left: -8rpx; top: 20rpx; border-top: 8rpx solid transparent; border-right: 8rpx solid #fff; border-bottom: 8rpx solid transparent; } .chat-message.user .bubble::after { content: ''; position: absolute; right: -8rpx; top: 20rpx; border-top: 8rpx solid transparent; border-left: 8rpx solid #95ec69; border-bottom: 8rpx solid transparent; } .forward-card { width: 540rpx; max-width: 70vw; border-radius: 12rpx; padding: 16rpx 18rpx; box-sizing: border-box; } .fc-title { font-size: 28rpx; color: #111; margin-bottom: 10rpx; font-weight: 600; } .fc-line { font-size: 26rpx; color: #333; margin-bottom: 6rpx; } .fc-count { margin-top: 10rpx; font-size: 26rpx; color: #666; } .forward-detail-popup { width: 686rpx; background: #fff; border-radius: 12rpx; padding: 18rpx; box-sizing: border-box; } .fr-row { width: 100%; display: flex; margin-bottom: 18rpx; } .fr-row.left { justify-content: flex-start; } .fr-row.right { justify-content: flex-end; } .fr-body { max-width: 82%; display: flex; flex-direction: column; } .fr-name { font-size: 24rpx; color: #666; margin-bottom: 6rpx; } .fr-bubble { background: #f4f4f4; border-radius: 12rpx; padding: 14rpx 16rpx; box-sizing: border-box; } .fr-row.right .fr-bubble { background: #e9f6e2; } .fr-text { font-size: 28rpx; color: #333; } .fr-quote { background: rgba(0, 0, 0, 0.06); border-radius: 10rpx; padding: 10rpx 12rpx; margin-bottom: 10rpx; } .fr-quote-text { font-size: 24rpx; color: #333; } .fr-file-line { font-size: 28rpx; color: #333; } .menu-popup { width: 686rpx; height: 342rpx; background-color: white; display: flex; justify-content: center; align-items: center; border-radius: 12rpx; } .menu-layer { height: 50%; width: 90%; box-sizing: border-box; } .menu-row { height: 100%; width: 100%; display: flex; justify-content: space-between; box-sizing: border-box; } .collect-layer { height: 50%; width: 90%; box-sizing: border-box; display: flex; justify-content: space-between; align-items: center; padding: 0 20rpx; } .ad-card { width: 540rpx; max-width: 70vw; } .ad-top { width: 100%; display: flex; justify-content: space-between; align-items: center; box-sizing: border-box; margin-bottom: 8rpx; } .ad-time { color: #ddd; font-size: 24rpx; } .ad-actions { display: flex; align-items: center; gap: 16rpx; } .ad-btn { height: 44rpx; border-radius: 12rpx; padding: 0 28rpx; background: #eee; display: flex; justify-content: center; align-items: center; color: #333; font-size: 24rpx; } .ad-media { border-radius: 12rpx; height: 300rpx; width: 100%; position: relative; overflow: hidden; } .ad-badge { position: absolute; top: 2%; right: 3%; display: flex; align-items: center; } .ad-ops { width: 100%; height: 44rpx; display: flex; justify-content: flex-end; gap: 16rpx; margin-top: 10rpx; } .ad-op-btn { padding: 0 16rpx; height: 44rpx; background: rgba(255, 255, 255, 0.9); border-radius: 8rpx; display: flex; justify-content: center; align-items: center; color: #666; font-size: 20rpx; } .gold-text { background: -webkit-linear-gradient(#d89838, #ceb87e); -webkit-background-clip: text; -webkit-text-fill-color: transparent; display: inline-block; } .ad-meta { margin-top: 18rpx; color: #ddd; font-size: 24rpx; } </style>开通了true这个Singlepersonchat 等于了true 就表示随时随地的撤回聊天的消息 包括文字 图片 和视频 消息 都可以撤回 如果没开就发消息的三分钟内容内可以撤回 过了三分钟 就提示uni.showToast({ title:'撤回失败', icon:'none' }) 还有如果撤回和删除 只能是撤回和删除自己的消息如果长摁别的消息就撤回和删除这两个按钮不显示就好了 只显示四个 自己的消息功能全额显示 另一个就是chat聊天页面的 "> 阅后级焚 </view> <!-- 右:switch --> <view style=" width: 50%; height: 100%; display: flex; justify-content: flex-end; align-items: center; "> <up-switch v-model="Postreadingburn" @change="Burndown"></up-switch> </view> </view> </view> </view> 阅后级焚功能只要开启了 等于了true 收到的消息又有这个值burnAfterReading: 阅后即焚 24.webSocket长链接接口 {consumerId}为当前用户的id 接受message格式: {“type”:”castingChat”,”id”:”xxxxxxxx”,burnAfterReading:true/false}json转成的字符串格式 俩人之间聊天 接受到正确的格式message后会向聊天双方或发送消息,消息格式: [ content:“XXX” 聊天内容 type:“0(0 文字 1 图片 2 视频 3 声音4 文件 5 转发个人聊天 6转发群组) /ws/{consumerId}建立链接:例如wss://www.doufan.net/ws/xxxxxxxxxxxxx burnAfterReading: 阅后即焚 fileName:“xxx” showName:“xxx” playURL:“xxx” receiverId:“xxx” 接受者 senderId:"xxx" 发送者 createTime: 建立时间 ] 如果是开启了阅后即焚发送的的图片就会显示一个马赛克 https://t12.baidu.com/it/u=3229389678,260314789&fm=30&app=106&f=JPEG?w=640&h=960&s=342320B862E7C8EB44B617930300C08D 就用这个图片给遮挡住了 然后别人点击打开图片放大看了在推出消息就删除了 删除的api直接调用就行 视频是放大退出也就删除了 文件是点击了 删除了消息 图片是点击放大六秒删除掉消息 还有就是这个 每次发图片或者视频 文件 都会显示两个气泡一个没有东西没有有东西 退出再进就没了 修复一下 然后点击这个 <view style="display: inline-block; margin-left: 40rpx; text-align: center" @click="call"> <uni-icons type="location" size="30" /> <view>定位</view> </view>定位功能 点击这个定位 就跳到这个页面sharedspace<template> <view :class="['app-container', theme]"> <view class="content"> <view class="overlay"> <!-- 顶部 Header --> <view class="header" :style="{ paddingTop: statusBarHeight + 'px', height: headerBarHeight + 'px' }"> <view class="header-inner"> <view class="left-box" @click="navigateBack"> <uni-icons type="left" color="#000000" size="26"></uni-icons> </view> <view class="center-box"> <text class="title-text">定位</text> </view> <view class="right-box"> <text class="desc-text"></text> </view> </view> </view> <!-- 可滚动内容区域 --> <view style="width: 100%;display: flex;justify-content: center;align-items: center;" class=""> <view style="width: 94%;height: 100%; " class=""> </view> </view> </view> </view> </view> </template> <script setup> import { getStatusBarHeight, getTitleBarHeight } from '@/utils/system.js' import ajax from '@/api/ajax.js' import { ref, reactive, onMounted, inject } from 'vue' import { storeToRefs } from 'pinia' import { useMainStore } from '@/store/index.js' import { onLoad } from '@dcloudio/uni-app' // 全局主题 const store = useMainStore() const { theme } = storeToRefs(store) // 顶部安全区与标题栏高度 const statusBarHeight = ref(getStatusBarHeight()) const headerBarHeight = ref(getTitleBarHeight()) // 到这结束返回的数据 // 返回上一页 const navigateBack = () => { uni.navigateBack() } onLoad((option) => { if (option) { } else { console.log('没有参数') } }) </script> <style lang="scss" scoped> @import '@/assets/styles/global.scss'; .app-container { /* 如果有深色 / 浅色主题,在这里根据 theme 做适配 */ } .content { min-height: 100vh; position: relative; background-size: cover; } .overlay { position: absolute; inset: 0; background-size: cover; } .header { width: 94%; margin: 0 auto; } .header-inner { position: relative; display: flex; align-items: center; justify-content: center; // 整体居中 height: 100%; } /* 左右两块宽度相同,但不占位 */ .left-box, .right-box { position: absolute; top: 0; bottom: 0; width: 120rpx; // 足够容纳图标/文字 display: flex; align-items: center; } .left-box { left: 0; justify-content: flex-start; } .right-box { right: 0; justify-content: flex-end; } /* 中间标题:绝对居中 */ .center-box { flex: none; // 不拉伸 } </style>现在没有写东西 大概页面三分之二的页面就是一个地图下面是实时的位置下面有个搜索框 搜索框 下面全是位子右上角有个发送位置 这个这样你定位的位置就跑到了websockets 推送给好友了 然后好友点击这个位置消息就跳到了这个页面了positioning<template> <view :class="['app-container', theme]"> <view class="content"> <view class="overlay"> <!-- 顶部 Header --> <view class="header" :style="{ paddingTop: statusBarHeight + 'px', height: headerBarHeight + 'px' }"> <view class="header-inner"> <view class="left-box" @click="navigateBack"> <uni-icons type="left" color="#000000" size="26"></uni-icons> </view> <view class="center-box"> <text class="title-text">定位</text> </view> <view class="right-box"> <text class="desc-text"></text> </view> </view> </view> <!-- 可滚动内容区域 --> <view style="width: 100%;display: flex;justify-content: center;align-items: center;" class=""> <view style="width: 94%;height: 100%; " class=""> </view> </view> </view> </view> </view> </template> <script setup> import { getStatusBarHeight, getTitleBarHeight } from '@/utils/system.js' import ajax from '@/api/ajax.js' import { ref, reactive, onMounted, inject } from 'vue' import { storeToRefs } from 'pinia' import { useMainStore } from '@/store/index.js' import { onLoad } from '@dcloudio/uni-app' // 全局主题 const store = useMainStore() const { theme } = storeToRefs(store) // 顶部安全区与标题栏高度 const statusBarHeight = ref(getStatusBarHeight()) const headerBarHeight = ref(getTitleBarHeight()) // 到这结束返回的数据 // 返回上一页 const navigateBack = () => { uni.navigateBack() } onLoad((option) => { if (option) { } else { console.log('没有参数') } }) </script> <style lang="scss" scoped> @import '@/assets/styles/global.scss'; .app-container { /* 如果有深色 / 浅色主题,在这里根据 theme 做适配 */ } .content { min-height: 100vh; position: relative; background-size: cover; } .overlay { position: absolute; inset: 0; background-size: cover; } .header { width: 94%; margin: 0 auto; } .header-inner { position: relative; display: flex; align-items: center; justify-content: center; // 整体居中 height: 100%; } /* 左右两块宽度相同,但不占位 */ .left-box, .right-box { position: absolute; top: 0; bottom: 0; width: 120rpx; // 足够容纳图标/文字 display: flex; align-items: center; } .left-box { left: 0; justify-content: flex-start; } .right-box { right: 0; justify-content: flex-end; } /* 中间标题:绝对居中 */ .center-box { flex: none; // 不拉伸 } </style>也是大概五分之四都是地图下面是左面具体的位子 距离多少米 右面是导航 这个地图页面我都会给你们图片参考 的至于高德的appkey 我后续会给你补充的你先写好 用uniapp的map地图也好还是怎么样的先把功能实现 以上的逻辑全部打通 和 一最完整的单人聊天页面chat的内容这个页面必须全量返回代码 一个代码功能都不能够少 在原来的基础上新加功能 一定要确保这个页面的完整性 我我是要直接复制使用的和一个非常完整的 chat的气泡组件的内容Bubblechatbox模块 这个组件页面也是返回完整代码 和新加的功能确保所有的需求都是能实现 一定要完整的新组件文件和功能下一个是这个页面sharedspace也是完整的和 positioning页面的内容了 特别是定位和发送位置和气泡能够点击并且打开这个页面显示位置我的需求大部分都是在app上开发的 一定要app 手机能够实现全部的效果 我会补齐这个高德的key值的你先都写好

Pensamento

Clarifying uni-app features for chat system

It seems like the user needs the full code for different pages of a chat app in uni-app. The features include controlled message recall, toggling Singlepersonchat for unlimited recall, and customizing message options. I’ll ensure the buttons and functions work based on these requirements while maintaining the current features.

Clarifying message recall and deletion logic

It looks like the user needs to implement message recall and deletion functionality with some conditions. Specifically, if "双向不留痕" option is enabled, recalling a message should remove it for both users. If not enabled, recall is restricted to 3 minutes, while deletion is likely allowed at any time — I'll proceed with those rules in place.

Implementing "read after burn" feature

For the "read after burn" feature, I've got some key steps. If it's enabled, I need to ensure that the burnAfterReading flag is included when sending messages, especially for images. For images, I'll need a mosaic placeholder and ensure the message is deleted after it’s viewed. However, detecting when the preview closes may be tricky.

Handling image and video previews with deletion

It seems I can use uni.previewImage for images and uni.previewMedia for videos to handle the preview and deletion functionality. After triggering the preview, I'll set a 6-second timer to delete the media, ensuring the requested "burn after reading" behavior is implemented clearly and simply. For video, I can use navigation to a fullscreen page if needed.

Implementing burn-after-reading behavior for images, videos, and text

To implement this, images will show a mosaic and delete after 6 seconds once clicked. For videos, a mosaic poster will be shown with a play icon, and deletion will happen after viewing. Text messages won’t be implemented for burn-after-reading, as it wasn’t requested. Burn behavior will apply only to received messages, with checks on the sender’s side.

Handling burnAfterReading flag and bug fix

To include the burnAfterReading flag, I'll adjust the payload and add the flag to both the message object and Bubblechatbox component. For images, videos, and files, a bug causes duplicate bubbles. This happens because of a mismatch in clientId handling between localEcho and the server's response, resulting in one empty bubble.

Fixing duplicate bubbles and handling ws pushes

The issue with empty bubbles seems to stem from clientId handling in file messages. To fix the bug, I'll ensure the file content includes clientId, which the server can echo. Instead of relying on localEcho, I'll check incoming websocket messages for duplicates by tracking message ids. This will ensure no duplicate message bubbles.

Fixing duplicate file bubbles and location feature

To prevent duplicate file bubbles, I'll bind the server ID to local messages after file uploads. This will enable us to deduplicate by skipping websocket messages with matching IDs. For the location feature, we need to update the "call" action and implement navigation to the shared space page with a map, showing the current location and other points of interest.

Implementing location feature with shared space

The map uses a built-in SDK (Apple for iOS, Tencent for Android) but wants to use AMap for POI search and reverse geocoding instead. A placeholder will be used for the key, enabling testing. When the user selects a location in shared space, the system will compile it into a location message, send it through the websocket, and render it as a location bubble.

Implementing location feature with navigation flow

We'll create a location payload and send it like a text message. On click, the messaging bubble opens a positioning page via uni.navigateTo. We’ll use /pages/sociAlize/location/sharedspace and /pages/sociAlize/location/positioning paths for pages. Sharedspace will pass query parameters like chatConsumerId for handling message sending with websockets.

Implementing websocket and location features with chat

We'll use sharedspace to send location via API and websocket to notify the recipient. To avoid duplicates, chat page can receive the message from its existing socket. For location previews, we'll use a static map thumbnail from the AMap API as a placeholder. We’ll ensure other features like recall and burn-after-reading are incorporated and code is copy-ready for all components.

Implementing burn-after-reading and location features

For burn-after-reading, we'll add logic to handle message view, deletion, and sending with the correct parameters. For location messages, we’ll use AMap APIs to get user coordinates, search POIs, and render a location map with markers. We’ll ensure message deletion synchronizes with burn status, while maintaining UI consistency and code clarity in sharedspace and positioning pages. We'll preserve all features and ensure build passes.

Implementing silent delete and burn message features

We’ll implement a silent delete for burn messages with no toast or modal. For handling media, burn images and videos will display a mosaic placeholder, revealing the real file on click with scheduled deletion. We’ll ensure that multi-select only allows actions on the user's own messages and modify recall/delete logic as needed.

Thought for 3m 13s

Resposta

下面给你 4 个文件的“可直接复制使用”的完整代码chat.vueBubblechatbox.vuesharedspace.vuepositioning.vue),在你原有基础上全量保留原功能并新增/打通你描述的所有逻辑:

  • 单人无限撤回开关(Singlepersonchat=true):自己发的消息(文字/图片/视频/语音/文件/定位/转发)随时可撤回
  • 未开通无限撤回:只允许撤回 3 分钟内自己的消息,超过 3 分钟直接 toast:撤回失败
  • 撤回/删除只能操作自己的消息:长按对方消息 不显示“删除/撤回”,只显示 4 个按钮(转发/收藏/引入/多选)
  • 阅后即焚(burnAfterReading=true)
    • 接收方收到带 burnAfterReading:true图片/视频/文件:气泡显示 马赛克占位图
    • 图片:点击预览后 6 秒自动删除
    • 视频:点击预览,退出预览即删除
    • 文件:点击打开/下载 即删除
    • 删除走你现有的删除 API(castingChatDeleteRecord)并本地移除
  • ✅ 修复你提到的 发送图片/视频/文件出现两个气泡(一个空的一个有内容):增加 按 serverId 去重 + 文件消息补齐 clientId 绑定
  • ✅ 新增 定位功能
    • chat 点击“定位”进入 sharedspace(选点 + 搜索 POI + 发送位置)
    • 发送后通过 castingChat 保存,并通过 websocket 推送
    • 气泡里显示“位置卡片”,点击进入 positioning 页面(地图 + 地址 + 距离 + 导航)
  • ✅ 高德 Key 先留好占位:你后续补 key 即可用(不影响编译)

1)/pages/sociAlize/ChatDisplayPage/chat.vue(完整文件)

vue
<!-- /pages/sociAlize/ChatDisplayPage/chat.vue --> <template> <view :class="['app-container', theme]"> <view class="content"> <view class="overlay"> <view class="header" :style="{ paddingTop: statusBarHeight + 'px', height: headerBarHeight + 'px' }"> <view class="header-inner"> <view class="left-box" @click="navigateBack"> <uni-icons type="left" color="#000000" size="26" /> </view> <view class="center-box"> <text v-if="Isfiletransfer" class="title-text">{{ titleText }}</text> <text v-else @click="changeAgent" class="title-text">{{ titleText }}</text> </view> <view class="right-box"> <text v-if="multiSelectMode && !Isfiletransfer" class="cancel-text" @click="exitMultiSelect">取消</text> <text v-else-if="!Isfiletransfer" @click="changeAgent" style="font-weight: bold; font-size: 40rpx" class="desc-text" > ... </text> </view> </view> </view> <view v-if="messages.length === 0 && !Isfiletransfer" style="width: 100%; display: flex; justify-content: center; align-items: center" > <view style="width: 94%; display: flex; justify-content: space-between; box-sizing: border-box; margin-top: 8rpx"> <view>当前对象 : <text>{{ agent ? '真人' : '化身' }}</text></view> <view style="position: relative"> <view @click="changeAgent" style="height: 60rpx; width: 188rpx; display: flex; justify-content: center; align-items: center; background-color: #d89833; border-radius: 12rpx; position: absolute; right: 0; font-size: 24rpx; color: #ffffff" > 选择{{ !agent ? '真人' : '化身' }}聊天 </view> </view> </view> </view> <view class="chat-scroll" :style="{ top: containerTop + 'px', bottom: ((multiSelectMode && !Isfiletransfer) ? (multiActionHeight + safeAreaBottom) : (inputAreaHeight + safeAreaBottom)) + 'px' }" > <scroll-view scroll-y :scroll-into-view="lastItemId" style="height: 100%"> <view v-for="(msg, idx) in messages" :key="msg.localId || msg.id || idx" :id="'msg-' + idx"> <view v-if="shouldShowTimestamp(idx)" class="time-divider"> {{ formatChatTime(msg.createTime || msg.timestamp) }} </view> <Bubblechatbox :messageKey="makeMsgKey(msg, idx)" :role="msg.isUser ? 'user' : 'assistant'" :content="normalizeContentForBubble(msg.content)" :userAvatar="userAvatarFull" :assistantAvatar="peerAvatarForUI" :maskPeer="isMaskChat" :maskPeerName="'****'" :maskAvatarUrl="MASK_AVATAR" :showCopyButton="!msg.isUser && isPlainText(msg.content)" :showBadge="false" :chatConsumerId="chatConsumerId" :consumerId="consumerId" :webProjectUrl="webProjectUrl" :castingChatId="msg.id || ''" :burnAfterReading="!!msg.burnAfterReading" :multiSelectMode="multiSelectMode && !Isfiletransfer" :selected="isSelected(makeMsgKey(msg, idx))" :selectDisabled="!msg.id" @toggle-select="onToggleSelect" @enter-multiselect="onEnterMultiSelect" @quote-message="onQuoteMessage" @delete-message="onDeleteOne" @recall-message="onRecallOne" @burn-after-view="onBurnAfterView" @self-avatar-click="handleSelfAvatarClick" @peer-avatar-click="handlePeerAvatarClick" /> </view> <view v-if="!agent && isAiReplying" class="ai-typing-indicator"> <view class="ai-typing-bubble"> <view class="dot"></view> <view class="dot"></view> <view class="dot"></view> </view> </view> </scroll-view> </view> <view v-if="multiSelectMode && !Isfiletransfer" class="multi-action-bar" :style="{ paddingBottom: safeAreaBottom + 'px' }"> <view class="multi-action-inner"> <view class="multi-btn multi-btn-primary" @click="openMultiForwardPicker">转发</view> <view class="multi-btn" @click="openMultiCollectPicker">收藏</view> <view class="multi-btn" @click="confirmMultiDelete">删除</view> <view class="multi-btn" @click="confirmMultiRecall">撤回</view> </view> </view> <view v-else class="input-bar" :style="{ paddingBottom: keyboardPadding + 'px' }"> <view v-if="quoteDraft && !Isfiletransfer" class="quote-bar"> <view class="quote-bar-inner"> <text class="quote-label">引用:</text> <text class="quote-text">{{ quoteDraft.senderName }}:{{ quoteDraft.preview }}</text> </view> <view class="quote-close" @click.stop="clearQuote">×</view> </view> <view style="width: 100%; display: flex; justify-content: center; align-items: center"> <view class="input-left"> <view v-if="Isfiletransfer" class="input-wrapper input-wrapper--disabled"> <input disabled value="" placeholder="文件传输助手暂不支持发送消息" /> </view> <view v-else class="input-wrapper"> <input v-model="inputText" type="text" placeholder="请输入" @confirm="onSend('NormalText')" confirm-type="send" :adjust-position="false" :maxlength="500" @focus="onInputFocus" @blur="onInputBlur" /> </view> </view> <view v-if="!Isfiletransfer" class="input-right"> <view v-if="istext" @click="onSend('NormalText')" class="btn-send">发送</view> <view v-else class="btn-send" @click="openAttachPanel"> <uni-icons type="plus-filled" size="30" /> </view> </view> </view> </view> <up-popup :show="multiForwardPickerShow" mode="center" @close="multiForwardPickerShow=false" :closeable="true" closeIconPos="top-right" zIndex="99999" round="12"> <view class="mode-popup"> <up-button type="warning" style="height: 80rpx; width: 220rpx" text="合并转发" @click="gotoForwardfriend('merge')" /> <up-button style="height: 80rpx; width: 220rpx" text="逐条转发" @click="gotoForwardfriend('items')" /> </view> </up-popup> <up-popup :show="multiCollectPickerShow" mode="center" @close="multiCollectPickerShow=false" :closeable="true" closeIconPos="top-right" zIndex="99999" round="12"> <view class="mode-popup"> <up-button type="warning" style="height: 80rpx; width: 220rpx" text="咖信收藏" @click="doMultiCollect(false)" /> <up-button type="warning" style="height: 80rpx; width: 220rpx" text="咖密收藏" @click="doMultiCollect(true)" /> </view> </up-popup> <up-popup :show="Didpopup" mode="bottom" @close="bottomclose" @open="bottomopen"> <view style="width: 100%;height: 240rpx;display: flex;justify-content: center;align-items: center;padding: 10rpx 20rpx;"> <view style="width: 100%;"> <view style="width: 100%;display: flex;justify-content: space-between;box-sizing: border-box;margin-top: 12rpx;"> <view style="width: 94%;display: flex;box-sizing: border-box;"> <view style="width: 100%;"> <view style="width: 100%;height: 62rpx;display: flex;justify-content: center;align-items: center;border-radius: 12rpx;background-color: rgba(12, 34, 12, 0.7);"> <view style="height: 60%;width: 90%;"> <view style="width: 100%;height: 100%;display: flex;justify-content: space-between;align-items: center;box-sizing: border-box;"> <view style="width: 50%;height: 100%;display: flex;align-items: center;">阅后级焚</view> <view style="width: 50%;height: 100%;display: flex;justify-content: flex-end;align-items: center;"> <up-switch v-model="Postreadingburn" @change="Burndown"></up-switch> </view> </view> </view> </view> </view> </view> </view> <view style="width: 100%; height: 100%; display: flex; align-items: center"> <view style="display: inline-block;margin-left: 40rpx; text-align: center" @click="pickAndSendImage"> <uni-icons type="image" size="30" /> <view>图片</view> </view> <view style="display: inline-block;margin-left: 40rpx; text-align: center" @click="OpencameraImage"> <uni-icons type="camera" size="30" /> <view>手机相机</view> </view> <view style="display: inline-block; margin-left: 40rpx; text-align: center" @click="Selectvideomedia"> <uni-icons type="camera" size="30" /> <view>视频</view> </view> <view style="display: inline-block; margin-left: 40rpx; text-align: center" @click="recordAndSendVoice"> <uni-icons type="mic-filled" size="30" /> <view>语音</view> </view> <view style="display: inline-block; margin-left: 40rpx; text-align: center" @click="call"> <uni-icons type="videocam-filled" size="30" /> <view>视频通话</view> </view> </view> <view style="display: inline-block; margin-left: 40rpx; text-align: center" @click="openLocation"> <uni-icons type="location" size="30" /> <view>定位</view> </view> <view style="display: inline-block; margin-left: 40rpx; text-align: center" @click="pickAndSendFile"> <uni-icons type="wallet-filled" size="30" /> <view>文件</view> </view> </view> </view> </up-popup> </view> </view> </view> </template> <script setup> import { ref, reactive, toRefs, nextTick, computed, onMounted, onBeforeUnmount, onUnmounted, watch } from 'vue' import { onLoad, onShow } from '@dcloudio/uni-app' import { storeToRefs } from 'pinia' import Bubblechatbox from '@/components/Bubblechatbox.vue' import VideoCallsService, { enableFloatWindow } from '@/utils/videocalls.js' import { clearCounter } from '@/utils/counter.js' import { useMainStore } from '@/store/index.js' import ajax from '@/api/ajax.js' import WsRequest from '@/utils/websockets.js' import { getStatusBarHeight, getTitleBarHeight } from '@/utils/system.js' import { useadvertising } from '@/store/useforward.js' import { usechat } from '@/store/Chatfriends.js' import { getThreads, getMessages, getRuns, pollRunStatus, fetchLastAssistantMessage } from '@/utils/aiService.js' const MASK_AVATAR = 'https://sns-img-hw.xhscdn.com/1000g0081vkua6m2f00405orgg98nr3qol0vfa58?imageView2/2/h/1080/format/webp' /** =============== 基础上传配置 =============== */ const API_BASE = (getApp().globalData && getApp().globalData.serverUrl) || 'http://47.99.182.213:7018' const uploadUrlSingle = `${API_BASE.replace(/\/$/, '')}/castingChatFile` function filenameOf(path = '') { try { return String(path).split('/').pop() || '' } catch { return '' } } function buildUrl(base, nameOrUrl = '') { if (!nameOrUrl) return '' if (/^https?:\/\//i.test(nameOrUrl)) return nameOrUrl const b = String(base || '').replace(/\/$/, '') const p = String(nameOrUrl).replace(/^\//, '') return b ? `${b}/${p}` : p } /** =============== 化身选择 & 本地缓存(Pinia) =============== */ const chatStore = usechat() const LOG_PREFIX = 'AGENT_CHAT_LOGS:' const THREAD_PREFIX = 'AGENT_THREAD:' const ymd = (d = new Date()) => { const m = String(d.getMonth() + 1).padStart(2, '0') const dd = String(d.getDate()).padStart(2, '0') return `${d.getFullYear()}${m}${dd}` } const logKey = (consumerId, friendId, agentId) => `${LOG_PREFIX}${consumerId}:${friendId}:${agentId || 'none'}:${ymd()}` const logKeyLegacy = (consumerId, friendId) => `${LOG_PREFIX}${consumerId}:${friendId}:${ymd()}` const threadKey = (consumerId, friendId, agentId) => `${THREAD_PREFIX}${consumerId}:${friendId}:${agentId || 'none'}` function readAgentLogs(consumerId, friendId, agentId) { const keyNew = logKey(consumerId, friendId, agentId) let raw = uni.getStorageSync(keyNew) if (!raw) { const keyOld = logKeyLegacy(consumerId, friendId) raw = uni.getStorageSync(keyOld) } if (!raw) return [] try { const arr = typeof raw === 'string' ? JSON.parse(raw) : raw return Array.isArray(arr) ? arr : [] } catch { return [] } } function appendAgentLog(consumerId, friendId, agentId, item) { const k = logKey(consumerId, friendId, agentId) const arr = readAgentLogs(consumerId, friendId, agentId) arr.push(item) try { uni.setStorageSync(k, JSON.stringify(arr)) } catch {} } function readAgentThreadId(consumerId, friendId, agentId) { const k = threadKey(consumerId, friendId, agentId) const raw = uni.getStorageSync(k) return raw || '' } function saveAgentThreadId(consumerId, friendId, agentId, id) { const k = threadKey(consumerId, friendId, agentId) uni.setStorageSync(k, id || '') } /** =============== 其它 store =============== */ const usestore = useadvertising() const { BrowseID } = storeToRefs(usestore) /** =============== 通话相关 =============== */ async function call() { if (Isfiletransfer.value) return uni.showToast({ title: '文件助手不支持通话', icon: 'none' }) uni.showActionSheet({ itemList: ['语音通话', '视频通话'], itemColor: '#000000', success: (res) => { if (res.tapIndex === 0) dial('speechvoice'); else if (res.tapIndex === 1) dial('video') }, fail() { uni.showToast({ title: '用户取消了通话', icon: 'none' }) } }) } async function dial(type) { const option = type === 'speechvoice' ? { calleeList: [chatConsumerId.value], callMediaType: 1 } : { calleeList: [chatConsumerId.value], callMediaType: 2 } const resp = await VideoCallsService.call(option) try { if (!chatConsumerId.value) return uni.showToast({ title: '没有目标用户ID', icon: 'none' }) if (resp?.code !== 0) uni.showToast({ title: resp?.msg || '通话失败', icon: 'none' }) } catch (e) { uni.showToast({ title: e?.message || '通话异常', icon: 'none' }) } } /** =============== 工具函数 =============== */ function safeParseDeep(input) { let data = input for (let i = 0; i < 3; i++) { if (typeof data === 'string') { const s = data.trim() try { if ((s.startsWith('{') && s.endsWith('}')) || (s.startsWith('[') && s.endsWith(']'))) { data = JSON.parse(s); continue } if (/^"(\\.|[^"])*"$/.test(s)) { data = JSON.parse(s); continue } } catch (e) {} break } if (typeof data === 'object' && data !== null) break } return data } function safeStringify(obj) { try { return typeof obj === 'string' ? obj : JSON.stringify(obj) } catch { return String(obj || '') } } function genClientId() { return 'c_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8) } function packFileContent({ type, playURL, showName, fileName, text, clientId }) { return safeStringify({ _kind: 'file', type, playURL, showName, fileName, text, clientId: clientId || '' }) } /** ✅ 统一把 burnAfterReading 合入消息对象(不改你原 content 结构) */ function normalizeIncomingItem(item, baseUrl) { if (item.type == 5 || item.type == 6) return safeStringify(item) const rawContent = item.content if (typeof item.type === 'number' && item.type > 0 && item.type <= 4) { return packFileContent({ type: item.type, playURL: buildUrl(baseUrl, item.playURL), showName: item.showName || item.fileName || '', fileName: item.fileName || '', text: rawContent || '', clientId: (safeParseDeep(rawContent)?.clientId) || '' // 尽量继承(如果后端把 clientId 放进 content) }) } return typeof rawContent === 'string' ? rawContent : safeStringify(rawContent) } /** 生成引用预览文本 */ function computePreviewText(raw) { if (!raw) return '' let s = raw if (typeof s !== 'string') { try { s = JSON.stringify(s) } catch { s = String(s) } } const obj = safeParseDeep(s) if (obj && typeof obj === 'object') { if (obj.msgType === 'text') return String(obj.text || '').slice(0, 60) if (obj._kind === 'file') { const t = Number(obj.type || 0) const baseName = obj.showName || obj.fileName || '' if (t === 1) return baseName ? `[图片] ${baseName}` : '[图片]' if (t === 2) return baseName ? `[视频] ${baseName}` : '[视频]' if (t === 3) return baseName ? `[语音] ${baseName}` : '[语音]' if (t === 4) return baseName ? `[文件] ${baseName}` : '[文件]' return '[文件]' } if (obj.msgType === 'ad') return '[广告]' if (obj.msgType === 'location') return '[位置]' if (obj.type == 5 || obj.type == 6 || obj.msgType === 'forward_record' || obj.msgType === 'forward') return obj.title || '[聊天记录]' } return String(s).slice(0, 60) } /** =============== 布局与状态 =============== */ const mainStore = useMainStore() const { theme } = storeToRefs(mainStore) const statusBarHeight = ref(getStatusBarHeight()) const headerBarHeight = ref(getTitleBarHeight()) const navigateBack = () => uni.navigateBack() /** ✅ 单人无限撤回开关(来自 revoke 页面 setStorageSync('Singlepersonchat', true/false)) */ const canUnlimitedRecall = ref(!!uni.getStorageSync('Singlepersonchat')) const ds = reactive({ containerTop: 0, inputAreaHeight: 0, safeAreaBottom: 0, keyboardPadding: 0, lastItemId: '', inputText: '', messages: [], consumerId: uni.getStorageSync('consumerId') || '', chatConsumerId: '', webProjectUrl: getApp().globalData?.webProjectUrl || '', userAvatarPath: getApp().globalData?.avatarUrl || uni.getStorageSync('avatarUrl') || '', peerAvatarPath: '', selfNickname: uni.getStorageSync('consumerNickname') || uni.getStorageSync('nickname') || '我', agent: true, socket: null, lastLocalEchoClientId: '', seenClientIds: new Set(), seenServerIds: new Set(), // ✅ 新增:按 id 去重,修复“两个气泡” rowStartIdx: 0, rowCount: 20, noMoreHistory: false, loadingHistory: false, nickname: '', avatar: '', istext: false, Didpopup: false, agentId: '', agentName: '', agentAvatar: '', assistantId: '', aiThreadId: '', isAiReplying: false, ForwardingID: [], markChat: false, Postreadingburn: false }) const { containerTop, inputAreaHeight, safeAreaBottom, keyboardPadding, lastItemId, inputText, messages, consumerId, chatConsumerId, webProjectUrl, userAvatarPath, peerAvatarPath, selfNickname, agent, socket, rowStartIdx, rowCount, noMoreHistory, loadingHistory, nickname, avatar, istext, Didpopup, agentId, agentName, agentAvatar, assistantId, aiThreadId, isAiReplying, ForwardingID, Postreadingburn } = toRefs(ds) const Isfiletransfer = ref(false) /** =============== ✅ 打码显示控制 =============== */ const isMaskChat = computed(() => !!(ds.markChat && agent.value && !Isfiletransfer.value)) const peerDisplayName = computed(() => (isMaskChat.value ? '****' : (nickname.value || ''))) const titleText = computed(() => { if (Isfiletransfer.value) return nickname.value || '文件传输助手' return agent.value ? (peerDisplayName.value || '未命名') : (agentName.value || '化身') }) const peerAvatarRaw = computed(() => { const p = agent.value ? (peerAvatarPath.value || '') : (agentAvatar.value || '') if (!p) return '' if (/^https?:\/\//.test(p)) return p return (webProjectUrl.value || '') + p }) const peerAvatarForUI = computed(() => (isMaskChat.value ? MASK_AVATAR : peerAvatarRaw.value)) const userAvatarFull = computed(() => { const p = userAvatarPath.value || '' if (!p) return '' if (/^https?:\/\//.test(p)) return p return (webProjectUrl.value || '') + p }) /** =============== ✅ 查询打码配置 =============== */ async function fetchMarkSetting() { if (Isfiletransfer.value) { ds.markChat = false; return } const cid = consumerId.value const pid = chatConsumerId.value if (!cid || !pid) { ds.markChat = false; return } try { const res = await ajax.post({ url: 'consumerFollowPerformerShow', method: 'post', data: { consumerId: cid, performerId: pid } }) ds.markChat = !!res?.data?.mark } catch (e) { ds.markChat = false } } /** =============== 多选 / 引用 =============== */ const multiSelectMode = ref(false) const selectedKeys = ref([]) const multiForwardPickerShow = ref(false) const multiCollectPickerShow = ref(false) const multiActionHeight = ref(uni.upx2px(136)) const quoteDraft = ref(null) const BASE_INPUT_PX = uni.upx2px(136) const QUOTE_BAR_PX = uni.upx2px(84) function updateInputHeight() { ds.inputAreaHeight = BASE_INPUT_PX + (quoteDraft.value ? QUOTE_BAR_PX : 0) } watch(() => quoteDraft.value, () => { if (multiSelectMode.value) return; updateInputHeight() }) watch(() => multiSelectMode.value, (v) => { if (v) { Didpopup.value = false; clearQuote() } else updateInputHeight() }) const Burndown = (e) => { Postreadingburn.value = e } function makeMsgKey(msg, idx) { return String(msg?.id || msg?.localId || ('idx_' + idx)) } function isSelected(key) { return selectedKeys.value.includes(String(key)) } function onToggleSelect(key) { if (!multiSelectMode.value) return const k = String(key) if (selectedKeys.value.includes(k)) selectedKeys.value = selectedKeys.value.filter(x => x !== k) else selectedKeys.value = [...selectedKeys.value, k] } function onEnterMultiSelect(key) { if (Isfiletransfer.value) return uni.showToast({ title: '文件助手暂不支持多选', icon: 'none' }) if (!agent.value) return uni.showToast({ title: '化身模式暂不支持多选', icon: 'none' }) multiSelectMode.value = true selectedKeys.value = [String(key)] } function exitMultiSelect() { multiSelectMode.value = false selectedKeys.value = [] multiForwardPickerShow.value = false multiCollectPickerShow.value = false } const selectedMessages = computed(() => { const ks = new Set(selectedKeys.value.map(String)) const arr = [] for (let i = 0; i < messages.value.length; i++) { const msg = messages.value[i] const mk = makeMsgKey(msg, i) if (ks.has(mk)) arr.push(msg) } return arr }) const selectedCastingChatIds = computed(() => selectedMessages.value.map(m => m.id).filter(Boolean)) /** ✅ 仅“自己的消息”允许删除/撤回 */ const selectedOwnCastingChatIds = computed(() => { const selfId = String(consumerId.value || '') return selectedMessages.value .filter(m => String(m.senderId || '') === selfId) .map(m => m.id) .filter(Boolean) }) function openMultiForwardPicker() { if (!selectedMessages.value.length) return uni.showToast({ title: '请先选择消息', icon: 'none' }) multiForwardPickerShow.value = true } function openMultiCollectPicker() { if (!selectedMessages.value.length) return uni.showToast({ title: '请先选择消息', icon: 'none' }) multiCollectPickerShow.value = true } function clearQuote() { quoteDraft.value = null } function onQuoteMessage({ castingChatId, content, isUser }) { if (Isfiletransfer.value) return if (multiSelectMode.value) return const senderName = isUser ? (selfNickname.value || '我') : (peerDisplayName.value || '对方') quoteDraft.value = { castingChatId: castingChatId || '', preview: computePreviewText(content), senderName } } /** =============== 文件传输助手本地缓存 key =============== */ const FILE_TRANSFER_PREFIX = 'FILE_TRANSFER_HISTORY:' function fileTransferKey(id) { return FILE_TRANSFER_PREFIX + (id || '') } /** =============== onShow =============== */ const pendingForward = ref(null) const hasInitForward = ref(false) onShow(() => { clearCounter() canUnlimitedRecall.value = !!uni.getStorageSync('Singlepersonchat') // ✅ 每次回来刷新 handleShow() }) async function handleShow() { if (!consumerId.value) return exitMultiSelect() if (Isfiletransfer.value) { await loadFileTransferHistory(true); return } await fetchMarkSetting() await refreshModeAndHistory() if (!hasInitForward.value) { await sendForwardIfNeeded() hasInitForward.value = true } } function bottomclose() { Didpopup.value = false } function bottomopen() { Didpopup.value = true } function openAttachPanel() { if (Isfiletransfer.value) { Didpopup.value = false; return uni.showToast({ title: '文件助手暂不支持发送附件', icon: 'none' }) } if (!agent.value) { Didpopup.value = false; return uni.showToast({ title: '化身模式暂不支持发送图片/视频/文件/语音', icon: 'none' }) } if (multiSelectMode.value) { Didpopup.value = false; return } Didpopup.value = true } /** ✅ 定位入口 */ function openLocation() { if (Isfiletransfer.value) return uni.showToast({ title: '文件助手不支持定位', icon: 'none' }) if (!agent.value) return uni.showToast({ title: '化身模式暂不支持定位', icon: 'none' }) Didpopup.value = false uni.navigateTo({ url: `/pages/sociAlize/ChatDisplayPage/sharedspace?chatConsumerId=${encodeURIComponent(chatConsumerId.value)}` }) } watch(() => inputText.value, (val) => { istext.value = !!(val && val.length > 0) }) function localEcho(content, clientId, extra = {}) { const localId = 'local_' + Date.now() const nowISO = new Date().toISOString() messages.value.push({ localId, content, senderId: consumerId.value, receiverId: chatConsumerId.value, isUser: true, createTime: nowISO, ...extra }) ds.lastLocalEchoClientId = clientId || '' scrollToBottom() } function localEchoPlain(text, isUser = true, extra = {}) { const localId = 'local_' + Date.now() + (isUser ? '_u' : '_a') const nowISO = new Date().toISOString() messages.value.push({ localId, content: text, senderId: isUser ? consumerId.value : chatConsumerId.value, receiverId: isUser ? chatConsumerId.value : consumerId.value, isUser, createTime: nowISO, ...extra }) scrollToBottom() } function makeAdPayload(list = [], base = '') { const cards = (list || []).map(normalizeCard) return { msgType: 'ad', version: 1, cards, base, ts: Date.now() } } function makeTextPayload(text = '', quote = null) { const p = { msgType: 'text', text: String(text), clientId: genClientId(), ts: Date.now() } if (quote && (quote.preview || quote.castingChatId)) { p.quote = { castingChatId: quote.castingChatId || '', preview: quote.preview || '', senderName: quote.senderName || '' } } return p } /** ✅ 新增:位置 payload */ function makeLocationPayload(loc) { return { msgType: 'location', clientId: genClientId(), ts: Date.now(), ...loc } } /** ✅ 修复:给本地 echo 绑定 serverId(防 ws 再来一次) */ function bindServerIdToLocalMessage(clientId, serverId) { if (!clientId || !serverId) return for (let i = messages.value.length - 1; i >= 0; i--) { const msg = messages.value[i] if (!msg || msg.id || !msg.isUser) continue const parsed = safeParseDeep(msg.content) const cid = parsed && parsed.clientId if (cid && cid === clientId) { msg.id = serverId; ds.seenServerIds.add(String(serverId)); break } } } /** ✅ 统一判断是否可撤回:只能撤回自己的;开通后不限时;未开通3分钟内 */ function canRecallCastingChat(msg) { if (!msg) return false const selfId = String(consumerId.value || '') if (String(msg.senderId || '') !== selfId) return false if (canUnlimitedRecall.value) return true const ct = new Date(msg.createTime || msg.timestamp || 0).getTime() if (!ct) return false const diff = Date.now() - ct return diff <= 3 * 60 * 1000 } function isPlainText(content) { const obj = safeParseDeep(content) if ( obj && typeof obj === 'object' && (obj._kind === 'file' || obj.msgType === 'ad' || obj.msgType === 'text' || obj.msgType === 'forward' || obj.msgType === 'forward_record' || obj.msgType === 'location' || obj.type == 5 || obj.type == 6) ) return false return true } function normalizeContentForBubble(content) { return typeof content === 'string' ? content : safeStringify(content) } /** =============== ✅ 真人历史 =============== */ async function loadHistory(initial = false) { if (!agent.value) return if (Isfiletransfer.value) return if (loadingHistory.value || noMoreHistory.value) return loadingHistory.value = true try { const res = await ajax.post({ url: 'chatListBySenderAndReceiver', data: { consumerId: consumerId.value, chatConsumerId: chatConsumerId.value, rowStartIdx: initial ? 0 : rowStartIdx.value, rowCount: rowCount.value } }) const data = res?.data || {} if (data.consumerNickname) ds.selfNickname = data.consumerNickname if (data.chatConsumerAvatarUrl) peerAvatarPath.value = data.chatConsumerAvatarUrl const list = data.castingChatArray || [] if (initial) { messages.value = [] rowStartIdx.value = 0 noMoreHistory.value = false ds.seenClientIds = new Set() ds.seenServerIds = new Set() ds.lastLocalEchoClientId = '' } const normalized = list.map((item) => { const contentStr = normalizeIncomingItem(item, webProjectUrl.value) const parsed = safeParseDeep(contentStr) const cid = parsed && parsed.clientId if (cid) ds.seenClientIds.add(cid) if (item?.id) ds.seenServerIds.add(String(item.id)) return { id: item.id, content: contentStr, senderId: item.senderId, receiverId: item.receiverId, isUser: item.senderId === consumerId.value, createTime: item.createTime, burnAfterReading: !!item.burnAfterReading } }) if (normalized.length === 0) { if (!initial) noMoreHistory.value = true } else { messages.value = [...normalized, ...messages.value] rowStartIdx.value += normalized.length } if (initial) scrollToBottom() } catch (e) { uni.showToast({ title: '历史加载失败', icon: 'none' }) } finally { loadingHistory.value = false } } /** =============== ✅ WS(真人模式) =============== */ const WS_HOST = ref('ws://47.99.182.213:7018/ws') function initSocket() { if (!agent.value) return if (!consumerId.value) return const wsUrl = `${WS_HOST.value}/${consumerId.value}` ds.socket = new WsRequest(wsUrl, 10000) socket.value.onOpen(() => { sendForwardIfNeeded() }) socket.value.getMessage((raw) => { if (!raw) return let msg try { msg = JSON.parse(raw) } catch { return } const arr = Array.isArray(msg) ? msg : [msg] let hasNew = false for (const item of arr) { if (!item || !item.senderId || !item.receiverId) continue const related = (item.senderId === consumerId.value && item.receiverId === chatConsumerId.value) || (item.senderId === chatConsumerId.value && item.receiverId === consumerId.value) if (!related) continue // ✅ serverId 去重(修复图片/视频/文件双气泡) if (item?.id && ds.seenServerIds.has(String(item.id))) continue const contentStr = normalizeIncomingItem(item, webProjectUrl.value) const parsed = safeParseDeep(contentStr) const cid = parsed && parsed.clientId if (cid) { if (cid === ds.lastLocalEchoClientId) { ds.lastLocalEchoClientId = ''; continue } if (ds.seenClientIds.has(cid)) continue ds.seenClientIds.add(cid) } if (item?.id) ds.seenServerIds.add(String(item.id)) messages.value.push({ id: item.id, content: contentStr, senderId: item.senderId, receiverId: item.receiverId, isUser: item.senderId === consumerId.value, createTime: item.createTime || new Date().toISOString(), burnAfterReading: !!item.burnAfterReading }) hasNew = true } if (hasNew) scrollToBottom() }) } /** =============== ✅ 删除/撤回 API(单条+多选共用) =============== */ async function deleteOrRecallByIds(ids = [], actionName = '删除', silent = false) { const uniq = Array.from(new Set((ids || []).filter(Boolean).map(String))) if (!uniq.length) return !silent && uni.showToast({ title: `没有可${actionName}的消息`, icon: 'none' }) if (!silent) uni.showLoading({ title: `${actionName}中...` }) try { const tasks = uniq.map((id) => ajax.post({ url: 'castingChatDeleteRecord', method: 'post', data: { id } }) ) const results = await Promise.allSettled(tasks) const ok = results.filter(r => r.status === 'fulfilled').length const fail = results.length - ok messages.value = messages.value.filter(m => !uniq.includes(String(m.id || ''))) uniq.forEach(id => ds.seenServerIds.add(String(id))) if (!silent) uni.showToast({ title: `${actionName}成功:${ok},失败:${fail}`, icon: 'none' }) } catch (e) { if (!silent) uni.showToast({ title: `${actionName}失败`, icon: 'none' }) } finally { if (!silent) uni.hideLoading() } } /** ✅ 阅后即焚:看完后静默删除(不弹成功toast) */ async function silentBurnDelete(id) { if (!id) return await deleteOrRecallByIds([id], '删除', true) } function onDeleteOne({ castingChatId, isUser }) { if (!castingChatId) return uni.showToast({ title: '该消息无法删除', icon: 'none' }) if (!isUser) return uni.showToast({ title: '只能删除自己的消息', icon: 'none' }) uni.showModal({ title: '提示', content: '确认删除这条消息?', success: async (res) => { if (!res.confirm) return await deleteOrRecallByIds([castingChatId], '删除') } }) } function onRecallOne({ castingChatId, isUser, messageKey }) { if (!castingChatId) return uni.showToast({ title: '该消息无法撤回', icon: 'none' }) if (!isUser) return uni.showToast({ title: '只能撤回自己的消息', icon: 'none' }) // ✅ 撤回时限判断 const msg = messages.value.find((m, idx) => makeMsgKey(m, idx) === String(messageKey)) || messages.value.find(m => String(m.id) === String(castingChatId)) if (!canRecallCastingChat(msg)) return uni.showToast({ title: '撤回失败', icon: 'none' }) uni.showModal({ title: '提示', content: '确认撤回这条消息?', success: async (res) => { if (!res.confirm) return await deleteOrRecallByIds([castingChatId], '撤回') } }) } function confirmMultiDelete() { if (!selectedMessages.value.length) return uni.showToast({ title: '请先选择消息', icon: 'none' }) if (selectedOwnCastingChatIds.value.length !== selectedCastingChatIds.value.length) { return uni.showToast({ title: '只能删除自己的消息', icon: 'none' }) } uni.showModal({ title: '提示', content: `确认删除已选的 ${selectedOwnCastingChatIds.value.length} 条消息?`, success: async (res) => { if (!res.confirm) return await deleteOrRecallByIds(selectedOwnCastingChatIds.value, '删除') exitMultiSelect() } }) } function confirmMultiRecall() { if (!selectedMessages.value.length) return uni.showToast({ title: '请先选择消息', icon: 'none' }) if (selectedOwnCastingChatIds.value.length !== selectedCastingChatIds.value.length) { return uni.showToast({ title: '只能撤回自己的消息', icon: 'none' }) } // ✅ 检查时间限制(未开通无限撤回) if (!canUnlimitedRecall.value) { const selfId = String(consumerId.value || '') const ownMsgs = selectedMessages.value.filter(m => String(m.senderId || '') === selfId) const allOk = ownMsgs.every(m => canRecallCastingChat(m)) if (!allOk) return uni.showToast({ title: '撤回失败', icon: 'none' }) } uni.showModal({ title: '提示', content: `确认撤回已选的 ${selectedOwnCastingChatIds.value.length} 条消息?`, success: async (res) => { if (!res.confirm) return await deleteOrRecallByIds(selectedOwnCastingChatIds.value, '撤回') exitMultiSelect() } }) } /** ✅ 阅后即焚:Bubble 触发回调 */ async function onBurnAfterView({ castingChatId, delayMs }) { if (!castingChatId) return const delay = Number(delayMs || 0) if (delay > 0) { setTimeout(() => { silentBurnDelete(castingChatId) }, delay) } else { await silentBurnDelete(castingChatId) } } /** =============== 多选收藏 =============== */ async function doMultiCollect(isFileTransfer = false) { multiCollectPickerShow.value = false const ids = selectedCastingChatIds.value if (!ids.length) return uni.showToast({ title: '所选消息暂无可收藏项', icon: 'none' }) const cid = consumerId.value if (!cid) return uni.showToast({ title: '请先登录', icon: 'none' }) uni.showLoading({ title: '收藏中...' }) try { const tasks = ids.map(id => ajax.post({ url: 'castingChatCollect', method: 'post', data: { consumerId: cid, castingChatId: id, fileTransfer: !!isFileTransfer } })) const results = await Promise.allSettled(tasks) const ok = results.filter(r => r.status === 'fulfilled').length const fail = results.length - ok uni.showToast({ title: `收藏成功:${ok},失败:${fail}`, icon: 'none' }) exitMultiSelect() } catch (e) { uni.showToast({ title: '收藏失败', icon: 'none' }) } finally { uni.hideLoading() } } /** =============== 合并转发构建 =============== */ function buildForwardPayload(mode = 'merge') { const list = selectedMessages.value const ids = selectedCastingChatIds.value.map(x => String(x)) const selfName = selfNickname.value || '我' const peerName = peerDisplayName.value || '对方' const items = list.map(m => { const sid = String(m.senderId || '') const senderName = sid && sid === String(consumerId.value) ? selfName : peerName return { castingChatId: m.id || '', content: m.content || '', senderId: m.senderId || '', senderName, createTime: m.createTime || '' } }) const from = { selfId: consumerId.value, selfName, selfAvatar: userAvatarFull.value || '', peerId: chatConsumerId.value, peerName, peerAvatar: peerAvatarForUI.value || '' } return { source: 'chat', mode, castingChatIds: ids, items, from } } function gotoForwardfriend(mode = 'merge') { multiForwardPickerShow.value = false const payload = buildForwardPayload(mode) exitMultiSelect() uni.navigateTo({ url: '/pages/sociAlize/forward/Forwardfriend?payload=' + encodeURIComponent(JSON.stringify(payload)) }) } /** =============== 发送文本 =============== */ async function onSend(type) { if (Isfiletransfer.value) return uni.showToast({ title: '文件助手不支持发送消息', icon: 'none' }) if (multiSelectMode.value) return const text = (inputText.value || '').trim() if (!consumerId.value) { return uni.showModal({ title: '未登录', content: '请先登录后再进行操作', confirmText: '去登录', success: (res) => { if (res.confirm) uni.navigateTo({ url: '/pages/Loginpage/Loginpage' }) } }) } if (!chatConsumerId.value) return uni.showToast({ title: '缺少聊天对象', icon: 'none' }) if (type === 'NormalText') { if (!text) return // 化身模式(保持你原逻辑) if (!agent.value) { if (!agentId.value) return uni.showToast({ title: '缺少化身ID', icon: 'none' }) if (!assistantId.value) return uni.showToast({ title: '缺少助手配置', icon: 'none' }) const payload = makeTextPayload(text, quoteDraft.value) const contentStr = safeStringify(payload) localEchoPlain(contentStr, true) appendAgentLog(consumerId.value, chatConsumerId.value, agentId.value, { isUser: true, content: contentStr, createTime: new Date().toISOString() }) clearQuote() isAiReplying.value = true try { let tId = aiThreadId.value if (!tId) { const resp = await getThreads(text) tId = resp?.data?.id || '' if (!tId) throw new Error('创建 Thread 失败') aiThreadId.value = tId saveAgentThreadId(consumerId.value, chatConsumerId.value, agentId.value, tId) } else { await getMessages(text, tId) } const runRes = await getRuns(tId, assistantId.value) const runId = runRes?.data?.id if (!runId) throw new Error('创建 Run 失败') const runResult = await pollRunStatus(tId, runId, { intervalMs: 1000, maxAttempts: 40 }) if (!runResult.ok) { uni.showToast({ title: '化身回复超时', icon: 'none' }); return } const reply = await fetchLastAssistantMessage(tId) const answer = reply || '(无回复)' localEchoPlain(answer, false) appendAgentLog(consumerId.value, chatConsumerId.value, agentId.value, { isUser: false, content: answer, createTime: new Date().toISOString() }) } catch (e) { console.error(e, '化身回复错误') uni.showToast({ title: '化身回复失败', icon: 'none' }) } finally { inputText.value = '' isAiReplying.value = false } return } // 真人模式 const payload = makeTextPayload(text, quoteDraft.value) const content = safeStringify(payload) localEcho(content, payload.clientId, { burnAfterReading: !!Postreadingburn.value }) clearQuote() const option = { senderId: consumerId.value, receiverId: chatConsumerId.value, content } try { const saveRes = await ajax.post({ url: 'castingChat', data: option }) const newId = saveRes?.data?.id if (newId && payload.clientId) bindServerIdToLocalMessage(payload.clientId, newId) if (socket.value) { socket.value.send(JSON.stringify({ type: 'castingChat', burnAfterReading: !!Postreadingburn.value, id: newId || 'tmp_' + Date.now() })) } } catch (e) { uni.showToast({ title: '发送失败', icon: 'none' }) } finally { inputText.value = '' } } else { Didpopup.value = true } } /** =============== 媒体/附件 =============== */ async function Selectvideomedia() { if (Isfiletransfer.value) { Didpopup.value = false; return uni.showToast({ title: '文件助手不支持发送视频', icon: 'none' }) } if (!agent.value) { Didpopup.value = false; return uni.showToast({ title: '化身模式暂不支持发送视频', icon: 'none' }) } Didpopup.value = false uni.chooseVideo({ sourceType: ['album', 'camera'], maxDuration: 60, success: async (res) => { const temp = res.tempFilePath if (temp) await uploadAndSendSingle({ filePath: temp, type: 2, showName: filenameOf(temp), text: '' }) } }) } async function OpencameraImage() { if (Isfiletransfer.value) { Didpopup.value = false; return uni.showToast({ title: '文件助手不支持发送图片', icon: 'none' }) } if (!agent.value) { Didpopup.value = false; return uni.showToast({ title: '化身模式暂不支持发送图片', icon: 'none' }) } Didpopup.value = false uni.chooseImage({ count: 1, sizeType: ['original', 'compressed'], sourceType: ['camera'], success: async (res) => { const filePath = res.tempFilePaths?.[0] if (filePath) await uploadAndSendSingle({ filePath, type: 1, showName: filenameOf(filePath), text: '' }) } }) } async function pickAndSendImage() { if (Isfiletransfer.value) { Didpopup.value = false; return uni.showToast({ title: '文件助手不支持发送图片', icon: 'none' }) } if (!agent.value) { Didpopup.value = false; return uni.showToast({ title: '化身模式暂不支持发送图片', icon: 'none' }) } Didpopup.value = false uni.chooseImage({ count: 1, sizeType: ['original', 'compressed'], sourceType: ['album', 'camera'], success: async (res) => { const filePath = res.tempFilePaths?.[0] if (filePath) await uploadAndSendSingle({ filePath, type: 1, showName: filenameOf(filePath), text: '' }) } }) } async function pickAndSendFile() { if (Isfiletransfer.value) { Didpopup.value = false; return uni.showToast({ title: '文件助手不支持发送文件', icon: 'none' }) } if (!agent.value) { Didpopup.value = false; return uni.showToast({ title: '化身模式暂不支持发送文件', icon: 'none' }) } Didpopup.value = false const choose = uni.chooseMessageFile || uni.chooseFile if (!choose) return uni.showToast({ title: '当前平台不支持选择文件', icon: 'none' }) choose({ count: 1, type: 'file', success: async (res) => { const file = (res.tempFiles && res.tempFiles[0]) || (res.tempFilePaths && { path: res.tempFilePaths[0] }) const fp = file?.path || file?.tempFilePath if (fp) await uploadAndSendSingle({ filePath: fp, type: 4, showName: file?.name || filenameOf(fp), text: '' }) } }) } const recorder = uni.getRecorderManager ? uni.getRecorderManager() : null async function recordAndSendVoice() { if (Isfiletransfer.value) { Didpopup.value = false; return uni.showToast({ title: '文件助手不支持语音', icon: 'none' }) } if (!agent.value) { Didpopup.value = false; return uni.showToast({ title: '化身模式暂不支持发送语音', icon: 'none' }) } Didpopup.value = false if (!recorder) return uni.showToast({ title: '当前平台不支持录音', icon: 'none' }) uni.showActionSheet({ itemList: ['开始录音(最长60秒)'], success: () => { recorder.start({ duration: 60000, format: 'mp3' }) uni.showToast({ title: '录音中…再次点击结束', icon: 'none' }) recorder.onStart(() => { uni.showActionSheet({ itemList: ['结束并发送'], success: () => recorder.stop() }) }) recorder.onStop(async (res) => { const filePath = res.tempFilePath if (filePath) await uploadAndSendSingle({ filePath, type: 3, showName: filenameOf(filePath) || '语音', text: '' }) }) } }) } /** ✅ 统一上传并发送(修复双气泡 + 支持 burnAfterReading) */ async function uploadAndSendSingle({ filePath, type, showName, text }) { if (!consumerId.value || !chatConsumerId.value) return uni.showToast({ title: '缺少聊天对象', icon: 'none' }) uni.showLoading({ title: '上传中...' }) const formOption = { senderId: consumerId.value, receiverId: chatConsumerId.value, content: text || '', type, showName: showName || '' } try { const uploadRes = await uni.uploadFile({ url: uploadUrlSingle, filePath, name: 'chatFile', formData: formOption }) let data = {} try { data = typeof uploadRes.data === 'string' ? JSON.parse(uploadRes.data) : uploadRes.data } catch {} const newId = data?.id const playURL = data?.playURL || '' const clientId = genClientId() const fileContent = packFileContent({ type, playURL: buildUrl(webProjectUrl.value, playURL), showName, fileName: showName, text, clientId }) // 本地回显 + 绑定 serverId(用于去重) localEcho(fileContent, clientId, { burnAfterReading: !!Postreadingburn.value }) if (newId) bindServerIdToLocalMessage(clientId, newId) // ws 推送(带 burnAfterReading) socket.value?.send(JSON.stringify({ type: 'castingChat', burnAfterReading: !!Postreadingburn.value, id: newId || 'tmp_' + Date.now() })) } catch (e) { uni.showToast({ title: '上传失败', icon: 'none' }) } finally { uni.hideLoading() } } function onInputFocus(e) {} function onInputBlur() { ds.keyboardPadding = 0 } /** =============== 时间显示 =============== */ function shouldShowTimestamp(index) { if (index === 0) return true const cur = messages.value[index] const prev = messages.value[index - 1] const ct = new Date(cur.createTime || cur.timestamp || Date.now()).getTime() const pt = new Date(prev.createTime || prev.timestamp || Date.now()).getTime() return Math.abs(ct - pt) > 5 * 60 * 1000 } function formatChatTime(ts) { if (!ts) return '' const now = new Date() const t = new Date(ts) const Yn = now.getFullYear() const Ym = t.getFullYear() const mm = String(t.getMonth() + 1).padStart(2, '0') const dd = String(t.getDate()).padStart(2, '0') const hh = String(t.getHours()).padStart(2, '0') const min = String(t.getMinutes()).padStart(2, '0') const diffMs = now - t const oneDay = 24 * 60 * 60 * 1000 if (Yn !== Ym) return `${Ym}年${mm}月${dd}日 ${hh}:${min}` if (diffMs > oneDay) return `${mm}月${dd}日 ${hh}:${min}` return `今天 ${hh}:${min}` } function scrollToBottom() { lastItemId.value = 'msg-' + (messages.value.length - 1) nextTick(() => { lastItemId.value = '' }) } /** =============== 广告卡片(保持原) =============== */ function normalizeCard(raw) { return { isGoods: !!(raw.mallGoodsGroupId || raw.mallGoodsGroupTitle), media: { playURL: raw?.ProductMediaobj?.playURL || raw?.media?.playURL || raw?.media?.fileName || '', isVideo: false, isImage: true }, title: raw.title || raw.mallEnterpriseName || '广告', price: Number(raw.price || 0), typeName: raw.typeName || '', mallGoodsGroupId: raw.mallGoodsGroupId || '', mallGoodsGroupTitle: raw.mallGoodsGroupTitle || '', mallEnterpriseId: raw.mallEnterpriseId || '', adId: raw.id || '', requiredSec: Number(raw.requiredSec || raw.limitSeconds || 30) || 30 } } /** =============== 详情页跳转 =============== */ const changeAgent = () => { if (Isfiletransfer.value) return const payload = { chatConsumerId: chatConsumerId.value, nickname: isMaskChat.value ? '****' : nickname.value, avatar: isMaskChat.value ? MASK_AVATAR : avatar.value } uni.navigateTo({ url: '/pages/sociAlize/ChatDisplayPage/FriendDetails?obj=' + encodeURIComponent(JSON.stringify(payload)) }) } function handleSelfAvatarClick() { uni.showToast({ title: '自己头像点击', icon: 'none' }) } function handlePeerAvatarClick(v) { const { option } = v const others = ref({ chatConsumerId: option.chatConsumerId, nickname: isMaskChat.value ? '****' : nickname.value, avatar: isMaskChat.value ? MASK_AVATAR : option.avatar }) uni.navigateTo({ url: '/pages/sociAlize/stranger/Friendhomepage?others=' + encodeURIComponent(JSON.stringify(others.value)) }) } /** =============== 真人/化身刷新 =============== */ async function refreshModeAndHistory() { if (!consumerId.value || !chatConsumerId.value) return if (Isfiletransfer.value) return const sel = chatStore.getSelectedAgentForFriend(consumerId.value, chatConsumerId.value) if (sel && sel.agent) { const wasReal = agent.value agent.value = false agentId.value = sel.agent.id agentName.value = sel.agent.name || '化身' agentAvatar.value = sel.agent.img || '' assistantId.value = sel.agent.assistantId || '' aiThreadId.value = readAgentThreadId(consumerId.value, chatConsumerId.value, agentId.value) || '' isAiReplying.value = false if (wasReal && socket.value) { try { socket.value.close() } catch (e) {}; ds.socket = null } const logs = readAgentLogs(consumerId.value, chatConsumerId.value, agentId.value) messages.value = logs.map((it) => ({ localId: 'local_' + Math.random().toString(36).slice(2), content: it.content, senderId: it.isUser ? consumerId.value : chatConsumerId.value, receiverId: it.isUser ? chatConsumerId.value : consumerId.value, isUser: !!it.isUser, createTime: it.createTime || new Date().toISOString(), burnAfterReading: !!it.burnAfterReading })) } else { agent.value = true agentId.value = '' agentName.value = '' agentAvatar.value = '' assistantId.value = '' aiThreadId.value = '' isAiReplying.value = false messages.value = [] rowStartIdx.value = 0 noMoreHistory.value = false ds.seenClientIds = new Set() ds.seenServerIds = new Set() ds.lastLocalEchoClientId = '' if (!socket.value) initSocket() await loadHistory(true) } } /** =============== onLoad =============== */ onLoad((option) => { messages.value = [] rowStartIdx.value = 0 noMoreHistory.value = false ds.seenClientIds = new Set() ds.seenServerIds = new Set() ds.lastLocalEchoClientId = '' Isfiletransfer.value = false pendingForward.value = null hasInitForward.value = false exitMultiSelect() clearQuote() if (option?.FriendID) { chatConsumerId.value = option.FriendID nickname.value = decodeURIComponent(option.nickname || '') avatar.value = option.avatar || '' peerAvatarPath.value = option.avatar || option.avatarUrl || '' } if (option.target) { try { Isfiletransfer.value = true const o = JSON.parse(decodeURIComponent(option.target)) chatConsumerId.value = o.id nickname.value = o.nickname || '文件传输助手' avatar.value = o.avatar peerAvatarPath.value = o.avatar || o.avatarUrl || '' } catch (e) {} } else if (option.Friendmessage) { try { const o = JSON.parse(decodeURIComponent(option.Friendmessage)) chatConsumerId.value = o.id nickname.value = o.nickname avatar.value = o.avatar peerAvatarPath.value = o.avatar || o.avatarUrl || '' } catch (e) {} } else if (option.MessageList) { try { const o = JSON.parse(decodeURIComponent(option.MessageList)) chatConsumerId.value = o.id nickname.value = o.name avatar.value = o.avatarUrl if (o.idarr && Array.isArray(o.idarr)) ForwardingID.value = o.idarr peerAvatarPath.value = o.avatarUrl || '' } catch (e) {} } if (option.forward) { try { pendingForward.value = JSON.parse(decodeURIComponent(option.forward)) || null } catch (e) { pendingForward.value = null } } if (!consumerId.value) { uni.showModal({ title: '未登录', content: '请先登录后再进行操作', confirmText: '去登录', success: (res) => { if (res.confirm) uni.navigateTo({ url: '/pages/Loginpage/Loginpage' }) } }) return } if (!chatConsumerId.value && !Isfiletransfer.value) { uni.showToast({ title: '缺少聊天对象', icon: 'none' }) return } }) /** =============== mounted/unmount =============== */ onMounted(async () => { updateInputHeight() multiActionHeight.value = uni.upx2px(136) const sys = uni.getSystemInfoSync() ds.safeAreaBottom = sys.safeArea && sys.safeArea.bottom ? sys.windowHeight - sys.safeArea.bottom : 0 ds.containerTop = statusBarHeight.value + headerBarHeight.value if (!Isfiletransfer.value) { enableFloatWindow() try { await VideoCallsService.initAndLogin() } catch (e) { console.error('登录音视频失败:', e) } VideoCallsService.enableFloatWindow(true) VideoCallsService.addEventListeners({ onError: (res) => {}, onCallReceived: (res) => {}, onCallBegin: (res) => {}, onCallEnd: (res) => {} }) } }) onUnmounted(() => { VideoCallsService.removeEventListeners({}) }) onBeforeUnmount(() => { try { socket.value && socket.value.close() } catch (e) {} }) /** =============== 文件助手缓存/历史(保持原) =============== */ function readFileTransferCache(uid) { if (!uid) return [] const raw = uni.getStorageSync(fileTransferKey(uid)) if (!raw) return [] try { const arr = typeof raw === 'string' ? JSON.parse(raw) : raw return Array.isArray(arr) ? arr : [] } catch (e) { return [] } } function saveFileTransferCache(uid, list) { if (!uid) return; try { uni.setStorageSync(fileTransferKey(uid), JSON.stringify(list || [])) } catch (e) {} } function normalizeCollectItemToMessage(item) { let contentStr = '' if (item.content !== undefined && item.content !== null && item.content !== '') { contentStr = typeof item.content === 'string' ? item.content : safeStringify(item.content) } else { const playURL = item.playUrl || item.fileName || '' const showName = item.showName || filenameOf(item.fileName || '') || '' const t = item.type || 4 contentStr = packFileContent({ type: t, playURL: buildUrl(webProjectUrl.value, playURL), showName, fileName: item.fileName || '', text: '' }) } const ts = item.createTime return { id: item.id, content: contentStr, senderId: consumerId.value, receiverId: consumerId.value, isUser: true, createTime: ts ? new Date(ts).toISOString() : new Date().toISOString() } } const fileTransferRowStartIdx = ref(0) const fileTransferRowCount = ref(50) const loadingFileTransfer = ref(false) const noMoreFileTransfer = ref(false) async function loadFileTransferHistory(initial = false) { if (!consumerId.value) return if (loadingFileTransfer.value) return loadingFileTransfer.value = true if (initial) { fileTransferRowStartIdx.value = 0 noMoreFileTransfer.value = false messages.value = [] } try { const res = await ajax.post({ url: 'castingChatCollectList', method: 'post', data: { consumerId: consumerId.value, type: 0, fileTransfer: true, rowStartIdx: fileTransferRowStartIdx.value, rowCount: fileTransferRowCount.value } }) const arr = res?.data?.castingChatCollectArray || [] if (arr.length) { const mapped = arr.map((it) => normalizeCollectItemToMessage(it)) messages.value = initial ? mapped : [...messages.value, ...mapped] fileTransferRowStartIdx.value += arr.length saveFileTransferCache(consumerId.value, messages.value) } else { if (initial) { const cached = readFileTransferCache(consumerId.value) if (cached.length) messages.value = cached } noMoreFileTransfer.value = true } } catch (e) { if (initial) { const cached = readFileTransferCache(consumerId.value) if (cached.length) messages.value = cached else uni.showToast({ title: '文件助手记录加载失败', icon: 'none' }) } else { uni.showToast({ title: '加载更多失败', icon: 'none' }) } } finally { loadingFileTransfer.value = false } } /** =============== ✅ 转发:保持你原逻辑 =============== */ async function sendForwardIfNeeded() { if (ForwardingID.value && ForwardingID.value.length > 0) { const idStr = ForwardingID.value.join(',') if (socket.value) socket.value.send(JSON.stringify({ type: 'forwardCastingChat', id: idStr })) ForwardingID.value = [] return } if (Isfiletransfer.value) return const payload = pendingForward.value if (!payload) return pendingForward.value = null if (!consumerId.value || !chatConsumerId.value) return uni.showToast({ title: '缺少聊天对象', icon: 'none' }) let items = [] if (payload.mode === 'items' && Array.isArray(payload.items)) items = payload.items else if (payload.content) items = [{ content: payload.content, castingChatId: payload.castingChatId || '' }] else return for (const it of items) { let contentStr = '' const raw = it.content if (typeof raw === 'string') { try { const obj = JSON.parse(raw) if (obj && obj.msgType === 'text') { obj.clientId = genClientId() obj.ts = Date.now() contentStr = safeStringify(obj) } else contentStr = raw } catch (e) { contentStr = raw } } else contentStr = safeStringify(raw) let cid = '' try { const p = JSON.parse(contentStr); cid = p.clientId || '' } catch (e) {} localEcho(contentStr, cid) const option = { senderId: consumerId.value, receiverId: chatConsumerId.value, content: contentStr } try { const res = await ajax.post({ url: 'castingChat', data: option }) const newId = res?.data?.id if (newId && cid) bindServerIdToLocalMessage(cid, newId) socket.value?.send(JSON.stringify({ type: 'castingChat', id: newId || 'tmp_' + Date.now() })) } catch (e) { uni.showToast({ title: '转发失败', icon: 'none' }) } } } </script> <style lang="scss" scoped> @import '@/assets/styles/global.scss'; .content { min-height: 100vh; position: relative; background-size: cover; } .overlay { position: absolute; inset: 0; background-size: cover; } .time-divider { width: 100%; text-align: center; color: #999; font-size: 24rpx; margin: 20rpx 0; } .header { width: 94%; margin: 0 auto; } .header-inner { position: relative; display: flex; align-items: center; justify-content: center; height: 100%; } .left-box, .right-box { position: absolute; top: 0; bottom: 0; width: 120rpx; display: flex; align-items: center; } .left-box { left: 0; justify-content: flex-start; } .right-box { right: 0; justify-content: flex-end; } .cancel-text { font-size: 30rpx; color: #333; } .center-box { flex: none; } .title-text { font-size: 32rpx; color: #111; } .chat-scroll { position: absolute; left: 0; right: 0; z-index: 1; padding: 0 3%; box-sizing: border-box; } .multi-action-bar { width: 100%; position: absolute; bottom: 0; z-index: 2; background: rgba(255, 255, 255, 0.98); border-top: 1rpx solid #eee; } .multi-action-inner { height: 136rpx; display: flex; align-items: center; justify-content: space-around; padding: 0 24rpx; box-sizing: border-box; } .multi-btn { height: 76rpx; min-width: 140rpx; border-radius: 12rpx; display: flex; justify-content: center; align-items: center; background: #f5f5f5; color: #333; font-size: 28rpx; } .multi-btn-primary { background: #d89833; color: #fff; } .mode-popup { width: 686rpx; height: 240rpx; background-color: #ffffff; display: flex; justify-content: space-around; align-items: center; border-radius: 12rpx; } .input-bar { width: 100%; min-height: 136rpx; position: absolute; bottom: 0; z-index: 1; display: flex; flex-direction: column; justify-content: center; align-items: center; padding-top: 10rpx; box-sizing: border-box; } .quote-bar { width: 94%; background: #ffffff; border-radius: 12rpx; padding: 16rpx 18rpx; box-sizing: border-box; display: flex; align-items: center; justify-content: space-between; margin-bottom: 10rpx; border: 1rpx solid #f0f0f0; } .quote-bar-inner { flex: 1; display: flex; align-items: center; gap: 12rpx; overflow: hidden; } .quote-label { font-size: 26rpx; color: #999; flex: none; } .quote-text { font-size: 26rpx; color: #333; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .quote-close { width: 46rpx; height: 46rpx; border-radius: 50%; background: #f3f3f3; display: flex; justify-content: center; align-items: center; color: #666; font-size: 34rpx; margin-left: 12rpx; flex: none; } .input-left { width: 80%; height: 62rpx; display: flex; justify-content: center; align-items: center; } .input-right { width: 20%; height: 62rpx; display: flex; justify-content: space-between; box-sizing: border-box; } .input-wrapper { width: 100%; height: 100%; background-color: #ffffff; border-radius: 12rpx; display: flex; align-items: center; } .input-wrapper input { width: 100%; height: 100%; padding-left: 10rpx; box-sizing: border-box; } .input-wrapper--disabled { background-color: #f0f0f0; } .input-wrapper--disabled input { color: #999; } .btn-send { background-color: #d89833; border-radius: 12rpx; width: 90%; height: 100%; display: flex; justify-content: center; align-items: center; color: #fff; } .ai-typing-indicator { width: 100%; padding: 10rpx 0 20rpx; display: flex; justify-content: flex-start; } .ai-typing-bubble { background: #ffffff; border-radius: 12rpx; padding: 10rpx 18rpx; display: flex; align-items: center; gap: 6rpx; } .dot { width: 10rpx; height: 10rpx; border-radius: 50%; background: #d3d3d3; animation: typingBounce 1s infinite ease-in-out; } .dot:nth-child(2) { animation-delay: 0.15s; } .dot:nth-child(3) { animation-delay: 0.3s; } @keyframes typingBounce { 0%, 80%, 100% { transform: translateY(0); opacity: 0.4; } 40% { transform: translateY(-4rpx); opacity: 1; } } </style>

2)/components/Bubblechatbox.vue(完整文件)

这里做了你要的两件关键事:
1)对方消息长按菜单只显示 4 项(转发/收藏/引入/多选),自己的消息显示 6 项(含删除/撤回)
2)阅后即焚:接收方 burn 图片/视频/文件显示马赛克,查看后自动删除(按你要求的规则)

vue
<!-- /components/Bubblechatbox.vue --> <template> <view :class="['chat-message', role]"> <view v-if="multiSelectMode && role === 'user'" class="select-wrap" @click.stop="toggleSelect"> <view :class="['select-circle', { selected }]"></view> </view> <image @click="onAvatarClick" class="avatar" :src="avatarSrc" mode="aspectFill" /> <view class="bubble-wrapper" @touchstart.stop="handleTouchStart" @touchend="handleTouchEnd"> <view class="bubble" :class="{ typing: role === 'typing' }" @tap.stop="onBubbleClick" @click.stop="onBubbleClick"> <!-- typing --> <template v-if="role === 'typing'"> <view @longpress="copyPlain(renderText)" class="typing-text">{{ renderText }}</view> <view class="dots"> <view v-for="n in 3" :key="n" class="dot" :style="{ animationDelay: n * 0.2 + 's' }" /> </view> </template> <!-- 广告 --> <template v-else-if="isAd && cards.length"> <view v-for="(card, idx) in cards" :key="card.adId || idx" class="ad-card"> <view class="ad-top"> <view class="ad-time">{{ nowHM }}</view> <view class="ad-actions"> <view v-if="card.mallGoodsGroupId" class="ad-btn" @click.stop="goProduct(card)">查看商品</view> <view v-if="hasShop(card)" class="ad-btn" @click.stop="goShop(card)">进店</view> </view> </view> <view class="ad-media" @click="openFullscreen(card)"> <video v-if="card?.media?.isVideo" :src="fullUrl(card?.media?.playURL)" :controls="true" :show-center-play-btn="true" :show-fullscreen-btn="false" style="height: 100%; width: 100%; object-fit: cover" /> <image v-else :src="fullUrl(card?.media?.playURL)" mode="aspectFill" style="height: 100%; width: 100%; display: block" /> <view class="ad-badge"> <view style="font-size: 25rpx; color: #ccc">广告</view> <uni-icons :type="isopen ? 'down' : 'up'" color="#ffffff" size="20" /> </view> </view> <view class="ad-ops"> <view @click="copyMedia(card)" class="ad-op-btn">复制链接</view> <view @click="shareHint" class="ad-op-btn"><text class="gold-text">分享</text></view> </view> <view class="ad-meta"> <text>类型:{{ card.typeName ? card.typeName + '广告' : '广告' }}</text> <text style="margin-left: 20rpx">价格:¥{{ (Number(card.price) || 0).toFixed(2) }}</text> </view> </view> </template> <!-- ✅ 位置消息 --> <template v-else-if="isLocation"> <view class="loc-card" @click.stop="openLocationDetail" @tap.stop="openLocationDetail"> <view class="loc-title">{{ locName }}</view> <view class="loc-addr">{{ locAddress }}</view> <view class="loc-thumb"> <image v-if="locThumbUrl" :src="locThumbUrl" mode="aspectFill" style="width: 100%; height: 100%" /> <view v-else class="loc-thumb-placeholder">地图预览</view> </view> </view> </template> <!-- 文件类消息 --> <template v-else-if="isFileLike"> <!-- ✅ burn 图片:显示马赛克占位图,点击预览真实图后 6 秒删除 --> <view v-if="fileType === 1"> <image :src="displayImageUrl" style="max-width: 480rpx; max-height: 560rpx; border-radius: 8rpx" mode="widthFix" @click.stop="onImageClick" /> <view v-if="shouldBurn" class="burn-badge">阅后即焚</view> </view> <!-- ✅ burn 视频:显示马赛克占位,点击预览,退出预览即删 --> <view v-else-if="fileType === 2" style="width: 520rpx; max-width: 75vw"> <view v-if="shouldBurn" class="burn-video" @click.stop="onVideoClick"> <image :src="BURN_MOSAIC" mode="aspectFill" style="width: 100%; height: 300rpx; border-radius: 8rpx" /> <view class="burn-play"> <uni-icons type="play-filled" size="40" color="#fff" /> </view> <view class="burn-badge">阅后即焚</view> </view> <template v-else> <MyVideo :videoUrl="fileUrl" /> <video :src="fileUrl" controls style="width: 100%; border-radius: 8rpx" /> </template> </view> <!-- 语音 --> <view v-else-if="fileType === 3" style="max-width: 520rpx"> <audio :src="fileUrl" controls style="width: 100%" /> <view v-if="fileShowName" style="margin-top: 8rpx; color: #ddd; font-size: 24rpx">{{ fileShowName }}</view> </view> <!-- ✅ 文件:burn 时点击打开后立即删除 --> <view v-else-if="fileType === 4" style="display: flex; align-items: center; gap: 12rpx; max-width: 520rpx"> <uni-icons type="paperclip" size="22" /> <text style="color: #000" @click="onFileClick">{{ fileShowName || '文件' }}</text> <view v-if="shouldBurn" class="burn-badge" style="margin-left: 10rpx">阅后即焚</view> </view> </template> <!-- 合并转发卡片 --> <template v-else-if="isForwardRecord"> <view class="forward-card" @tap.stop="openForwardDetail" @click.stop="openForwardDetail"> <view class="fc-title">{{ forwardTitle }}</view> <view v-for="(line, i) in forwardLines" :key="i" class="fc-line">{{ line }}</view> <view class="fc-count">聊天记录({{ forwardCount }}条)</view> </view> </template> <!-- 普通文本 --> <template v-else> <view v-if="hasQuote" class="quote-snippet"> <text class="quote-snippet-text">{{ quoteLine }}</text> </view> <text class="content">{{ renderText }}</text> </template> </view> </view> <view v-if="multiSelectMode && role !== 'user'" class="select-wrap" @click.stop="toggleSelect"> <view :class="['select-circle', { selected }]"></view> </view> <!-- 长按菜单 --> <up-popup :show="showMenu" @close="closeMenu" mode="center" :closeable="true" :safeAreaInsetTop="true" closeIconPos="top-right" zIndex="99999" round="12"> <view class="menu-popup"> <view v-if="!isCollecting" class="menu-layer"> <view class="menu-row"> <view v-for="(item, index) of menuRow1" :key="index" @click="onMenuClick(item)"> <up-button type="warning" style="height: 80rpx; width: 120rpx" :text="item.title" /> </view> </view> <view class="menu-row"> <view v-for="(item, index) of menuRow2" :key="index" @click="onMenuClick(item)"> <up-button type="warning" style="height: 80rpx; width: 120rpx" :text="item.title" /> </view> </view> </view> <view v-else class="collect-layer"> <up-button style="height: 80rpx; width: 186rpx" type="warning" @click="collectMessage(false)" text="咖信收藏" /> <up-button style="height: 80rpx; width: 186rpx" type="warning" @click="collectMessage(true)" text="咖密收藏" /> </view> </view> </up-popup> <!-- 合并聊天记录详情弹窗 --> <up-popup :show="showForwardDetail" @close="showForwardDetail = false" mode="center" :closeable="true" :safeAreaInsetTop="true" closeIconPos="top-right" zIndex="99998" round="12"> <view class="forward-detail-popup"> <scroll-view scroll-y style="height: 70vh"> <view v-for="(it, idx) in forwardItems" :key="idx" :class="['fr-row', forwardAlign(it)]"> <view class="fr-body"> <text class="fr-name">{{ forwardSenderName(it) }}</text> <view class="fr-bubble"> <view v-if="forwardHasQuote(it)" class="fr-quote"> <text class="fr-quote-text">{{ forwardQuoteLine(it) }}</text> </view> <template v-if="forwardKind(it) === 'file'"> <image v-if="forwardFileType(it) === 1" :src="forwardFileUrl(it)" mode="widthFix" style="max-width: 420rpx; border-radius: 8rpx" @click.stop="previewImage(forwardFileUrl(it))" /> <view v-else class="fr-file-line"><text>{{ forwardPreview(it) }}</text></view> </template> <template v-else><text class="fr-text">{{ forwardPreview(it) }}</text></template> </view> </view> </view> </scroll-view> </view> </up-popup> </view> </template> <script setup> import { defineProps, defineEmits, computed, ref } from 'vue' import ajax from '@/api/ajax.js' import MyVideo from '@/components/HomepageComponents/Recommend/MyVideo.vue' /** ✅ burn 马赛克占位图(你给的链接) */ const BURN_MOSAIC = 'https://t12.baidu.com/it/u=3229389678,260314789&fm=30&app=106&f=JPEG?w=640&h=960&s=342320B862E7C8EB44B617930300C08D' /** ✅ 高德 key(你后续补) */ const AMAP_KEY = (getApp().globalData && getApp().globalData.amapKey) || 'YOUR_AMAP_KEY' const props = defineProps({ role: { type: String, required: true, validator: (v) => ['user', 'assistant', 'typing'].includes(v) }, content: { type: [String, Object], default: '' }, userAvatar: { type: String, default: '' }, assistantAvatar: { type: String, default: '' }, maskPeer: { type: Boolean, default: false }, maskPeerName: { type: String, default: '****' }, maskAvatarUrl: { type: String, default: '' }, showCopyButton: { type: Boolean, default: false }, showBadge: { type: Boolean, default: false }, chatConsumerId: { type: String, default: '' }, consumerId: { type: String, default: '' }, webProjectUrl: { type: String, default: '' }, castingChatId: { type: String, default: '' }, /** ✅ 新增:阅后即焚标记(来自 chat.vue message.burnAfterReading) */ burnAfterReading: { type: Boolean, default: false }, scene: { type: String, default: 'chat' }, castingChatGroupId: { type: String, default: '' }, castingChatGroupChatId: { type: String, default: '' }, messageKey: { type: String, default: '' }, multiSelectMode: { type: Boolean, default: false }, selected: { type: Boolean, default: false }, selectDisabled: { type: Boolean, default: false } }) const emit = defineEmits([ 'self-avatar-click', 'peer-avatar-click', 'toggle-select', 'enter-multiselect', 'quote-message', 'delete-message', 'recall-message', 'burn-after-view' ]) const MASK_FALLBACK = 'https://gd-hbimg.huaban.com/c294fbc2e5c35963a25d97f973f202c9a3c66f766125-RRVX90_fw658' const avatarSrc = computed(() => { if (props.maskPeer) return props.maskAvatarUrl || MASK_FALLBACK const isPeer = props.role === 'assistant' || props.role === 'typing' return isPeer ? props.assistantAvatar : props.userAvatar }) const timer = ref(null) const showMenu = ref(false) const isCollecting = ref(false) const showForwardDetail = ref(false) function closeMenu() { showMenu.value = false; isCollecting.value = false } function handleTouchStart() { if (props.multiSelectMode) return timer.value = setTimeout(() => { showMenu.value = true }, 800) } function handleTouchEnd() { clearTimeout(timer.value) } /** ✅ 只能对自己的消息显示“删除/撤回” */ const isSelfMsg = computed(() => props.role === 'user') /** ✅ 你要的:对方消息只显示4个(转发/收藏/引入/多选);自己的显示6个 */ const menuRow1 = computed(() => { const base = [{ title: '转发', id: '1' }, { title: '收藏', id: '2' }] if (isSelfMsg.value) base.push({ title: '删除', id: '3' }) return base }) const menuRow2 = computed(() => { const base = [{ title: '引入', id: '4' }, { title: '多选', id: '5' }] if (isSelfMsg.value) base.push({ title: '撤回', id: '6' }) return base }) function safeParseDeep(input) { let data = input for (let i = 0; i < 4; i++) { if (typeof data === 'string') { const s = data.trim() try { if ((s.startsWith('{') && s.endsWith('}')) || (s.startsWith('[') && s.endsWith(']'))) { data = JSON.parse(s); continue } if (/^"(\\.|[^"])*"$/.test(s)) { data = JSON.parse(s); continue } } catch (e) {} break } if (typeof data === 'object' && data !== null) break } return data } function normalizeParsed(input) { let o = safeParseDeep(input) for (let i = 0; i < 4; i++) { if (o && typeof o === 'object') { if (o.msgType || o._kind || o.type) return o if ('content' in o && !('msgType' in o) && !('_kind' in o)) { o = safeParseDeep(o.content); continue } } break } return o } function fullUrl(nameOrUrl = '') { if (!nameOrUrl) return '' if (/^https?:\/\//i.test(nameOrUrl)) return nameOrUrl const base = String(props.webProjectUrl || '').replace(/\/$/, '') const path = String(nameOrUrl).replace(/^\//, '') return base ? `${base}/${path}` : path } function asString(x) { try { return typeof x === 'string' ? x : JSON.stringify(x) } catch { return String(x || '') } } const parsed = computed(() => normalizeParsed(props.content)) const isAd = computed(() => parsed.value && typeof parsed.value === 'object' && parsed.value.msgType === 'ad') const isTextPayload = computed(() => parsed.value && typeof parsed.value === 'object' && parsed.value.msgType === 'text') const isLocation = computed(() => parsed.value && typeof parsed.value === 'object' && parsed.value.msgType === 'location') const isFileLike = computed(() => { const o = parsed.value if (!o || typeof o !== 'object') return false if (o._kind === 'file') return true if (typeof o.type === 'number' && o.type >= 1 && o.type <= 4) return true return false }) const isForwardRecord = computed(() => { const o = parsed.value if (!o || typeof o !== 'object') return false if (o.type == 5 || o.type == 6) return true if (o.msgType === 'forward_record' || o.msgType === 'forward') return true return false }) /** ✅ burn:只对“接收方消息”生效(你要求的:收到的消息带 burnAfterReading 才做) */ const shouldBurn = computed(() => !!(props.burnAfterReading && props.role !== 'user')) const fileType = computed(() => (isFileLike.value ? Number(parsed.value.type) : 0)) const fileUrl = computed(() => (isFileLike.value ? fullUrl(parsed.value.playURL || parsed.value.fileName) : '')) const fileShowName = computed(() => (isFileLike.value ? parsed.value.showName || parsed.value.fileName || '' : '')) const displayImageUrl = computed(() => (shouldBurn.value ? BURN_MOSAIC : fileUrl.value)) const cards = computed(() => (Array.isArray(parsed.value?.cards) ? parsed.value.cards : [])) const isopen = ref(false) const nowHM = computed(() => { const d = new Date() const h = String(d.getHours()).padStart(2, '0') const m = String(d.getMinutes()).padStart(2, '0') return `${h}:${m}` }) /** ========== 位置卡片解析 ========== */ const locName = computed(() => String(parsed.value?.name || parsed.value?.title || '位置')) const locAddress = computed(() => String(parsed.value?.address || parsed.value?.addr || '')) const locLat = computed(() => Number(parsed.value?.lat || 0)) const locLng = computed(() => Number(parsed.value?.lng || 0)) const locThumbUrl = computed(() => { if (!locLat.value || !locLng.value) return '' if (!AMAP_KEY || AMAP_KEY === 'YOUR_AMAP_KEY') return '' const loc = `${locLng.value},${locLat.value}` // 高德静态图(可用你的 key) return `https://restapi.amap.com/v3/staticmap?location=${encodeURIComponent(loc)}&zoom=16&size=400*200&markers=mid,0xD89833,0:${encodeURIComponent(loc)}&key=${encodeURIComponent(AMAP_KEY)}` }) function openLocationDetail() { const payload = { name: locName.value, address: locAddress.value, lat: locLat.value, lng: locLng.value } uni.navigateTo({ url: '/pages/sociAlize/ChatDisplayPage/positioning?loc=' + encodeURIComponent(JSON.stringify(payload)) }) } function hasShop(card) { return !!card?.mallEnterpriseId } function previewImage(val) { uni.previewImage({ current: val, urls: [val] }) } function copyPlain(text = '') { uni.setClipboardData({ data: text, success() { uni.showToast({ title: '已复制', icon: 'none' }) } }) } function copyMedia(card) { const u = fullUrl(card?.media?.playURL || '') if (!u) return uni.showToast({ title: '暂无链接', icon: 'none' }) copyPlain(u) } function shareHint() { uni.showToast({ title: '已复制,可粘贴去分享', icon: 'none' }) } function onAvatarClick() { const isSelf = props.role === 'user' if (isSelf) emit('self-avatar-click', { consumerId: props.consumerId }) else emit('peer-avatar-click', { option: { chatConsumerId: props.chatConsumerId, avatar: avatarSrc.value } }) } function toggleSelect() { if (props.selectDisabled) return uni.showToast({ title: '该消息暂不支持多选', icon: 'none' }) emit('toggle-select', props.messageKey) } function onBubbleClick() { if (isForwardRecord.value) openForwardDetail() } /** ✅ burn 行为:图片/视频/文件 */ const burnTriggered = ref(false) function onImageClick() { // 非 burn:正常预览 if (!shouldBurn.value) return previewImage(fileUrl.value) if (burnTriggered.value) return burnTriggered.value = true // burn:预览真实图 + 6秒删除 previewImage(fileUrl.value) emit('burn-after-view', { castingChatId: props.castingChatId, delayMs: 6000 }) } function onVideoClick() { if (!shouldBurn.value) return if (burnTriggered.value) return burnTriggered.value = true // APP 端使用 previewMedia:退出即删 if (uni.previewMedia) { uni.previewMedia({ sources: [{ url: fileUrl.value, type: 'video' }], complete: () => { emit('burn-after-view', { castingChatId: props.castingChatId, delayMs: 0 }) } }) } else { // fallback:直接打开视频(H5可能不支持 complete) uni.navigateTo({ url: '/pages/common/webview?url=' + encodeURIComponent(fileUrl.value) }) emit('burn-after-view', { castingChatId: props.castingChatId, delayMs: 0 }) } } function onFileClick() { openOrDownload(fileUrl.value) if (shouldBurn.value && !burnTriggered.value) { burnTriggered.value = true emit('burn-after-view', { castingChatId: props.castingChatId, delayMs: 0 }) } } function isGroupScene() { return props.scene === 'groupChat' || !!props.castingChatGroupId || !!props.castingChatGroupChatId } function currentGroupChatId() { return String(props.castingChatGroupChatId || props.castingChatId || '') } function onMenuClick(item) { const title = item?.title if (title === '收藏') { isCollecting.value = true; return } if (title === '转发') { const basePayload = { mode: 'single', content: normalizeContentForForward() } if (isGroupScene()) { const gid = String(props.castingChatGroupId || '') const gcid = currentGroupChatId() const payload = { ...basePayload, source: 'groupChat', castingChatGroupId: gid, castingChatGroupChatId: gcid, castingChatGroupChatIds: gcid ? [gcid] : [] } closeMenu() return uni.navigateTo({ url: '/pages/sociAlize/forward/Forwardfriend?payload=' + encodeURIComponent(JSON.stringify(payload)) }) } const payload = { ...basePayload, source: 'chat', castingChatId: props.castingChatId || '' } closeMenu() return uni.navigateTo({ url: '/pages/sociAlize/forward/Forwardfriend?payload=' + encodeURIComponent(JSON.stringify(payload)) }) } if (title === '删除') { closeMenu() return emit('delete-message', { scene: isGroupScene() ? 'groupChat' : 'chat', castingChatId: props.castingChatId || '', castingChatGroupId: props.castingChatGroupId || '', castingChatGroupChatId: currentGroupChatId(), messageKey: props.messageKey, isUser: props.role === 'user' }) } if (title === '撤回') { closeMenu() return emit('recall-message', { scene: isGroupScene() ? 'groupChat' : 'chat', castingChatId: props.castingChatId || '', castingChatGroupId: props.castingChatGroupId || '', castingChatGroupChatId: currentGroupChatId(), messageKey: props.messageKey, isUser: props.role === 'user' }) } if (title === '多选') { closeMenu(); return emit('enter-multiselect', props.messageKey) } if (title === '引入') { closeMenu() return emit('quote-message', { scene: isGroupScene() ? 'groupChat' : 'chat', castingChatId: props.castingChatId || '', castingChatGroupId: props.castingChatGroupId || '', castingChatGroupChatId: currentGroupChatId(), content: normalizeContentForForward(), isUser: props.role === 'user', messageKey: props.messageKey }) } } async function collectMessage(isFileTransfer = false) { const consumerId = uni.getStorageSync('consumerId') if (!consumerId) return uni.showToast({ title: '请先登录', icon: 'none' }) const isGroup = isGroupScene() const idForCollect = isGroup ? currentGroupChatId() : props.castingChatId || '' if (!idForCollect) return uni.showToast({ title: '该消息暂无法收藏', icon: 'none' }) try { let data = { consumerId, castingSecret: !!isFileTransfer, fileTransfer: !!isFileTransfer } if (isGroup) data.castingChatGroupChatId = idForCollect else data.castingChatId = idForCollect const res = await ajax.post({ url: 'castingChatCollect', method: 'post', data }) if (res?.data?.result === 'success') { uni.showToast({ title: '收藏成功', icon: 'none' }) closeMenu() } else { uni.showToast({ title: res?.data?.msg || '收藏失败', icon: 'none' }) } } catch (e) { uni.showToast({ title: '收藏异常', icon: 'none' }) } } function normalizeContentForForward() { if (typeof props.content === 'string') return props.content return asString(props.content) } function openOrDownload(url) { // #ifdef H5 window.open(url, '_blank') // #endif // #ifndef H5 uni.downloadFile({ url, success: ({ tempFilePath }) => { if (uni.openDocument) uni.openDocument({ filePath: tempFilePath }) else uni.showToast({ title: '已下载', icon: 'none' }) }, fail: () => uni.showToast({ title: '下载失败', icon: 'none' }) }) // #endif } function openFullscreen(card) { const { mallGoodsGroupTitle } = card if (mallGoodsGroupTitle) { const obj = { getmallGoodsGroupId: card.mallGoodsGroupId, browsingtime: card.requiredSec, price: card.price, media: card.media } uni.navigateTo({ url: '/pages/index/goodsPage/ProductDetails?advertisement=' + encodeURIComponent(JSON.stringify(obj || {})) }) } else { const list1 = [{ ...card, type: 'sharetype', Sharefriends: props.chatConsumerId }] uni.navigateTo({ url: '/pages/wealthDiscuss/fullscreenvideo?list1=' + encodeURIComponent(JSON.stringify(list1)) }) } } function goProduct(card) { const id = card?.mallGoodsGroupId if (!id) return const sec = card?.requiredSec || 30 uni.navigateTo({ url: '/pages/index/goodsPage/ProductDetails?id=' + encodeURIComponent(id) + '&fromAd=1&adId=' + encodeURIComponent(card?.adId || '') + '&sec=' + encodeURIComponent(sec) }) } function goShop(card) { const id = card?.mallEnterpriseId if (!id) return uni.showToast({ title: '暂未配置店铺入口', icon: 'none' }) uni.navigateTo({ url: `/pages/mall/enterprise?id=${id}` }) } /** ========== 转发记录相关(保持原逻辑 + 打码) ========== */ const forwardObj = computed(() => (isForwardRecord.value ? parsed.value : null)) function maskNameIfNeeded(it, rawName = '用户') { if (!props.maskPeer) return rawName const sid = String(it?.senderId || '') const selfId = String(props.consumerId || '') if (sid && selfId && sid === selfId) return rawName return props.maskPeerName || '****' } function computePreviewText(raw) { if (!raw) return '' let s = raw if (typeof s !== 'string') { try { s = JSON.stringify(s) } catch { s = String(s) } } const obj = normalizeParsed(s) if (obj && typeof obj === 'object') { if (obj.msgType === 'text') return String(obj.text || '').slice(0, 60) if (!obj.msgType && typeof obj.text === 'string' && obj.text) return String(obj.text).slice(0, 60) if (obj.msgType === 'location') return '[位置]' if (obj._kind === 'file' || (obj.type >= 1 && obj.type <= 4)) { const t = Number(obj.type || 0) const baseName = obj.showName || obj.fileName || '' if (t === 1) return baseName ? `[图片] ${baseName}` : '[图片]' if (t === 2) return baseName ? `[视频] ${baseName}` : '[视频]' if (t === 3) return baseName ? `[语音] ${baseName}` : '[语音]' if (t === 4) return baseName ? `[文件] ${baseName}` : '[文件]' return '[文件]' } if (obj.msgType === 'ad') return '[广告]' if (obj.type == 5 || obj.type == 6 || obj.msgType === 'forward' || obj.msgType === 'forward_record') return obj.title || '[聊天记录]' } return String(s).slice(0, 60) } const forwardTitle = computed(() => { if (props.maskPeer) return '聊天记录' const o = forwardObj.value if (o?.title) return o.title if (o?.type == 6) return '群聊的聊天记录' return '聊天记录' }) const forwardItems = computed(() => { const o = forwardObj.value || {} if (Array.isArray(o.forwardCastingChatArray)) return o.forwardCastingChatArray if (Array.isArray(o.forwardCastingChatGroupChatArray)) return o.forwardCastingChatGroupChatArray if (Array.isArray(o.items)) return o.items if (Array.isArray(o.chats)) return o.chats if (Array.isArray(o.records)) return o.records return [] }) const forwardCount = computed(() => Number(forwardObj.value?.count || forwardItems.value.length || 0)) const forwardLines = computed(() => forwardItems.value.slice(0, 2).map((it) => { const raw = it.senderNickname || it.nickname || it.senderName || '用户' const name = maskNameIfNeeded(it, raw) return `${name}:${computePreviewText(it.content)}` }) ) const renderText = computed(() => { if (isTextPayload.value) return String(parsed.value?.text || '') if (parsed.value && typeof parsed.value === 'object' && typeof parsed.value.text === 'string') return String(parsed.value.text) if (typeof props.content === 'string') return props.content return asString(props.content) }) const hasQuote = computed(() => !!(isTextPayload.value && parsed.value?.quote && (parsed.value.quote.preview || parsed.value.quote.senderName))) const quoteLine = computed(() => { const q = parsed.value?.quote || {} const name = q.senderName || '用户' const p = q.preview || '' return `${name}:${p}` }) function openForwardDetail() { showForwardDetail.value = true } function forwardAlign(it) { const o = forwardObj.value || {} const leftId = o.leftId || o.from?.peerId || '' const rightId = o.rightId || o.from?.selfId || '' const sid = String(it?.senderId || '') if (rightId && sid && sid === String(rightId)) return 'right' if (leftId && sid && sid === String(leftId)) return 'left' return 'left' } function forwardSenderName(it) { const raw = it?.senderNickname || it?.nickname || it?.senderName || '用户' return maskNameIfNeeded(it, raw) } function forwardContentObj(it) { return normalizeParsed(it?.content) } function forwardKind(it) { const o = forwardContentObj(it) if (o && typeof o === 'object') { if (o._kind === 'file' || o.type) return 'file' if (o.msgType === 'text') return 'text' } return 'text' } function forwardPreview(it) { return computePreviewText(it?.content) } function forwardFileType(it) { const o = forwardContentObj(it); return Number(o?.type || 0) } function forwardFileUrl(it) { const o = forwardContentObj(it); const u = o?.playURL || o?.fileName || ''; return fullUrl(u) } function forwardHasQuote(it) { const o = forwardContentObj(it) return !!(o && o.msgType === 'text' && o.quote && (o.quote.preview || o.quote.senderName)) } function forwardQuoteLine(it) { const o = forwardContentObj(it) || {} const q = o.quote || {} const rawName = q.senderName || '用户' const name = props.maskPeer ? (rawName === '我' ? '我' : (props.maskPeerName || '****')) : rawName return `${name}:${q.preview || ''}` } </script> <style scoped> .chat-message { display: flex; align-items: flex-start; margin-top: 12rpx; } .chat-message.user { flex-direction: row-reverse; } .avatar { width: 78rpx; height: 78rpx; border-radius: 50%; margin-right: 12rpx; } .chat-message.user .avatar { margin-left: 12rpx; margin-right: 0; } .select-wrap { width: 60rpx; height: 78rpx; display: flex; justify-content: center; align-items: center; } .select-circle { width: 34rpx; height: 34rpx; border-radius: 50%; border: 2rpx solid #cfcfcf; background: #fff; } .select-circle.selected { background: #d89833; border-color: #d89833; } .bubble-wrapper { display: flex; flex-direction: column; max-width: 75%; } .bubble { position: relative; padding: 16rpx 20rpx; line-height: 1.4; word-break: break-word; white-space: pre-wrap; } .chat-message.assistant .bubble { background: #fff; color: #000; border-radius: 16rpx 16rpx 16rpx 4rpx; } .chat-message.user .bubble { background: #95ec69; color: #000; border-radius: 16rpx 16rpx 4rpx 16rpx; } .chat-message.assistant .bubble::before { content: ''; position: absolute; left: -8rpx; top: 20rpx; border-top: 8rpx solid transparent; border-right: 8rpx solid #fff; border-bottom: 8rpx solid transparent; } .chat-message.user .bubble::after { content: ''; position: absolute; right: -8rpx; top: 20rpx; border-top: 8rpx solid transparent; border-left: 8rpx solid #95ec69; border-bottom: 8rpx solid transparent; } .burn-badge { position: absolute; right: 10rpx; bottom: 10rpx; background: rgba(0,0,0,.45); color: #fff; font-size: 22rpx; padding: 4rpx 10rpx; border-radius: 10rpx; } .burn-video { position: relative; } .burn-play { position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%); width: 80rpx; height: 80rpx; background: rgba(0,0,0,.35); border-radius: 50%; display: flex; justify-content: center; align-items: center; } .forward-card { width: 540rpx; max-width: 70vw; border-radius: 12rpx; padding: 16rpx 18rpx; box-sizing: border-box; } .fc-title { font-size: 28rpx; color: #111; margin-bottom: 10rpx; font-weight: 600; } .fc-line { font-size: 26rpx; color: #333; margin-bottom: 6rpx; } .fc-count { margin-top: 10rpx; font-size: 26rpx; color: #666; } .forward-detail-popup { width: 686rpx; background: #fff; border-radius: 12rpx; padding: 18rpx; box-sizing: border-box; } .fr-row { width: 100%; display: flex; margin-bottom: 18rpx; } .fr-row.left { justify-content: flex-start; } .fr-row.right { justify-content: flex-end; } .fr-body { max-width: 82%; display: flex; flex-direction: column; } .fr-name { font-size: 24rpx; color: #666; margin-bottom: 6rpx; } .fr-bubble { background: #f4f4f4; border-radius: 12rpx; padding: 14rpx 16rpx; box-sizing: border-box; } .fr-row.right .fr-bubble { background: #e9f6e2; } .fr-text { font-size: 28rpx; color: #333; } .fr-quote { background: rgba(0, 0, 0, 0.06); border-radius: 10rpx; padding: 10rpx 12rpx; margin-bottom: 10rpx; } .fr-quote-text { font-size: 24rpx; color: #333; } .fr-file-line { font-size: 28rpx; color: #333; } .menu-popup { width: 686rpx; height: 342rpx; background-color: white; display: flex; justify-content: center; align-items: center; border-radius: 12rpx; } .menu-layer { height: 50%; width: 90%; box-sizing: border-box; } .menu-row { height: 100%; width: 100%; display: flex; justify-content: space-between; box-sizing: border-box; } .collect-layer { height: 50%; width: 90%; box-sizing: border-box; display: flex; justify-content: space-between; align-items: center; padding: 0 20rpx; } .ad-card { width: 540rpx; max-width: 70vw; } .ad-top { width: 100%; display: flex; justify-content: space-between; align-items: center; box-sizing: border-box; margin-bottom: 8rpx; } .ad-time { color: #ddd; font-size: 24rpx; } .ad-actions { display: flex; align-items: center; gap: 16rpx; } .ad-btn { height: 44rpx; border-radius: 12rpx; padding: 0 28rpx; background: #eee; display: flex; justify-content: center; align-items: center; color: #333; font-size: 24rpx; } .ad-media { border-radius: 12rpx; height: 300rpx; width: 100%; position: relative; overflow: hidden; } .ad-badge { position: absolute; top: 2%; right: 3%; display: flex; align-items: center; } .ad-ops { width: 100%; height: 44rpx; display: flex; justify-content: flex-end; gap: 16rpx; margin-top: 10rpx; } .ad-op-btn { padding: 0 16rpx; height: 44rpx; background: rgba(255, 255, 255, 0.9); border-radius: 8rpx; display: flex; justify-content: center; align-items: center; color: #666; font-size: 20rpx; } .gold-text { background: -webkit-linear-gradient(#d89838, #ceb87e); -webkit-background-clip: text; -webkit-text-fill-color: transparent; display: inline-block; } .ad-meta { margin-top: 18rpx; color: #ddd; font-size: 24rpx; } /* 位置卡片 */ .loc-card { width: 540rpx; max-width: 70vw; border-radius: 12rpx; overflow: hidden; } .loc-title { font-size: 28rpx; font-weight: 600; color: #111; margin-bottom: 8rpx; } .loc-addr { font-size: 24rpx; color: #666; margin-bottom: 10rpx; } .loc-thumb { width: 100%; height: 220rpx; border-radius: 12rpx; overflow: hidden; } .loc-thumb-placeholder { width: 100%; height: 100%; background: #f2f2f2; display: flex; align-items: center; justify-content: center; color: #999; font-size: 24rpx; } </style>

3)/pages/sociAlize/ChatDisplayPage/sharedspace.vue(完整:选点 + 搜索 + 发送位置)

功能:

  • 上面地图(约 2/3)
  • 下方:搜索框 + POI 列表
  • 右上角:发送
  • 发送后:castingChat 保存,然后 websocket 推送 {"type":"castingChat","id":"xxx", burnAfterReading:false}
vue
<!-- /pages/sociAlize/ChatDisplayPage/sharedspace.vue --> <template> <view :class="['app-container', theme]"> <view class="content"> <view class="overlay"> <view class="header" :style="{ paddingTop: statusBarHeight + 'px', height: headerBarHeight + 'px' }"> <view class="header-inner"> <view class="left-box" @click="navigateBack"> <uni-icons type="left" color="#000000" size="26"></uni-icons> </view> <view class="center-box"> <text class="title-text">定位</text> </view> <view class="right-box"> <view class="send-btn" @click="sendLocation">发送</view> </view> </view> </view> <view class="body"> <!-- 地图区域 --> <view class="map-wrap"> <map class="map" :latitude="centerLat" :longitude="centerLng" :scale="16" :markers="markers" :show-location="true" @regionchange="onRegionChange" @tap="onMapTap" /> <view class="center-dot"></view> <view class="cur-loc-badge"> <view class="dot"></view> <text>当前定位</text> </view> </view> <!-- 底部:搜索 + 列表 --> <view class="panel"> <view class="search-row"> <input v-model="keywords" class="search-input" placeholder="搜索位置" @input="onSearchInput" /> <uni-icons type="search" size="20" color="#999" /> </view> <scroll-view scroll-y class="list"> <view v-for="(p, idx) in poiList" :key="p.id || idx" class="poi-item" @click="selectPoi(p)" > <view class="poi-name"> <text :style="{ color: selectedPoi && selectedPoi.id === p.id ? '#D89833' : '#111' }">{{ p.name }}</text> </view> <view class="poi-addr">{{ p.address }}</view> <view class="poi-meta">{{ p.distance ? (p.distance + 'm') : '' }}</view> </view> </scroll-view> </view> </view> </view> </view> </view> </template> <script setup> import { ref, onMounted } from 'vue' import { onLoad, onUnload } from '@dcloudio/uni-app' import { storeToRefs } from 'pinia' import { useMainStore } from '@/store/index.js' import { getStatusBarHeight, getTitleBarHeight } from '@/utils/system.js' import ajax from '@/api/ajax.js' import WsRequest from '@/utils/websockets.js' /** ✅ 高德 key 你后续补 */ const AMAP_KEY = (getApp().globalData && getApp().globalData.amapKey) || 'YOUR_AMAP_KEY' const WS_HOST = (getApp().globalData && getApp().globalData.wsHost) || 'ws://47.99.182.213:7018/ws' const store = useMainStore() const { theme } = storeToRefs(store) const statusBarHeight = ref(getStatusBarHeight()) const headerBarHeight = ref(getTitleBarHeight()) const consumerId = ref(uni.getStorageSync('consumerId') || '') const chatConsumerId = ref('') const centerLat = ref(39.90923) const centerLng = ref(116.397428) const markers = ref([]) const keywords = ref('') const poiList = ref([]) const selectedPoi = ref(null) let socket = null function navigateBack() { uni.navigateBack() } onLoad((option) => { if (option?.chatConsumerId) chatConsumerId.value = decodeURIComponent(option.chatConsumerId) }) onMounted(async () => { if (!consumerId.value) { uni.showToast({ title: '请先登录', icon: 'none' }) return } initSocket() await initLocation() }) onUnload(() => { try { socket && socket.close() } catch (e) {} }) function initSocket() { const wsUrl = `${WS_HOST}/${consumerId.value}` socket = new WsRequest(wsUrl, 10000) } /** 获取当前位置 */ function initLocation() { return new Promise((resolve) => { uni.getLocation({ type: 'gcj02', isHighAccuracy: true, success: async (res) => { centerLat.value = res.latitude centerLng.value = res.longitude markers.value = [{ id: 1, latitude: centerLat.value, longitude: centerLng.value, width: 24, height: 24, iconPath: '/static/location_pin.png' // 你可换成自己的 }] await fetchNearbyPois() resolve() }, fail: async () => { await fetchNearbyPois() resolve() } }) }) } /** 地图拖动结束刷新 POI */ async function onRegionChange(e) { if (e.type !== 'end') return const { latitude, longitude } = e.detail.centerLocation || {} if (!latitude || !longitude) return centerLat.value = latitude centerLng.value = longitude await fetchNearbyPois() } /** 点地图:用点击点做中心 */ async function onMapTap(e) { const { latitude, longitude } = e.detail || {} if (!latitude || !longitude) return centerLat.value = latitude centerLng.value = longitude await fetchNearbyPois() } /** 输入搜索:调用 inputtips */ let searchTimer = null function onSearchInput() { clearTimeout(searchTimer) searchTimer = setTimeout(async () => { if (!keywords.value) return fetchNearbyPois() await fetchInputTips() }, 300) } /** 高德:周边 POI */ async function fetchNearbyPois() { if (!AMAP_KEY || AMAP_KEY === 'YOUR_AMAP_KEY') { // 没 key 也能跑 UI(列表给个占位) poiList.value = [{ id: 'mock', name: '(请补充高德 Key 后可获取真实位置)', address: `当前坐标:${centerLng.value.toFixed(6)}, ${centerLat.value.toFixed(6)}`, distance: '' }] selectedPoi.value = poiList.value[0] return } const location = `${centerLng.value},${centerLat.value}` uni.request({ url: 'https://restapi.amap.com/v3/place/around', method: 'GET', data: { key: AMAP_KEY, location, radius: 1000, sortrule: 'distance', offset: 20, page: 1, extensions: 'base' }, success: (res) => { const pois = res?.data?.pois || [] poiList.value = pois.map(p => ({ id: p.id, name: p.name, address: p.address || p.pname + p.cityname + p.adname, distance: p.distance, location: p.location // "lng,lat" })) if (poiList.value.length) selectedPoi.value = poiList.value[0] }, fail: () => { poiList.value = [] } }) } /** 高德:搜索提示 */ async function fetchInputTips() { if (!AMAP_KEY || AMAP_KEY === 'YOUR_AMAP_KEY') return const location = `${centerLng.value},${centerLat.value}` uni.request({ url: 'https://restapi.amap.com/v3/assistant/inputtips', method: 'GET', data: { key: AMAP_KEY, keywords: keywords.value, location, citylimit: false }, success: (res) => { const tips = res?.data?.tips || [] poiList.value = tips .filter(t => t.location) .map(t => ({ id: t.id || (t.location + t.name), name: t.name, address: t.address || t.district || '', distance: '', location: t.location })) if (poiList.value.length) selectedPoi.value = poiList.value[0] } }) } function selectPoi(p) { selectedPoi.value = p const loc = (p.location || '').split(',') if (loc.length === 2) { centerLng.value = Number(loc[0]) centerLat.value = Number(loc[1]) } } /** 发送位置:保存 castingChat + ws 推送 */ async function sendLocation() { if (!consumerId.value || !chatConsumerId.value) return uni.showToast({ title: '缺少聊天对象', icon: 'none' }) if (!selectedPoi.value) return uni.showToast({ title: '请选择位置', icon: 'none' }) const loc = (selectedPoi.value.location || `${centerLng.value},${centerLat.value}`).split(',') const lng = Number(loc[0] || centerLng.value) const lat = Number(loc[1] || centerLat.value) const payload = { msgType: 'location', ts: Date.now(), clientId: 'c_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8), name: selectedPoi.value.name, address: selectedPoi.value.address, lng, lat } uni.showLoading({ title: '发送中...' }) try { const res = await ajax.post({ url: 'castingChat', data: { senderId: consumerId.value, receiverId: chatConsumerId.value, content: JSON.stringify(payload) } }) const newId = res?.data?.id // ws 通知(定位不需要 burnAfterReading) try { socket && socket.send(JSON.stringify({ type: 'castingChat', id: newId || ('tmp_' + Date.now()), burnAfterReading: false })) } catch (e) {} uni.showToast({ title: '已发送', icon: 'none' }) setTimeout(() => navigateBack(), 300) } catch (e) { uni.showToast({ title: '发送失败', icon: 'none' }) } finally { uni.hideLoading() } } </script> <style lang="scss" scoped> @import '@/assets/styles/global.scss'; .content { min-height: 100vh; position: relative; background-size: cover; } .overlay { position: absolute; inset: 0; background-size: cover; } .header { width: 94%; margin: 0 auto; } .header-inner { position: relative; display: flex; align-items: center; justify-content: center; height: 100%; } .left-box, .right-box { position: absolute; top: 0; bottom: 0; width: 160rpx; display: flex; align-items: center; } .left-box { left: 0; justify-content: flex-start; } .right-box { right: 0; justify-content: flex-end; } .center-box { flex: none; } .title-text { font-size: 32rpx; color: #111; } .send-btn { background: #D89833; color: #fff; font-size: 26rpx; padding: 10rpx 18rpx; border-radius: 10rpx; } .body { width: 100%; height: calc(100vh - 88rpx); display: flex; flex-direction: column; } .map-wrap { position: relative; width: 100%; flex: 1; } .map { width: 100%; height: 100%; } .center-dot { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -100%); width: 14rpx; height: 14rpx; border-radius: 50%; background: #D89833; box-shadow: 0 0 0 8rpx rgba(216,152,51,0.2); } .cur-loc-badge { position: absolute; left: 24rpx; top: 24rpx; background: rgba(255,255,255,0.92); padding: 10rpx 14rpx; border-radius: 12rpx; display: flex; align-items: center; gap: 10rpx; font-size: 24rpx; color: #333; } .cur-loc-badge .dot { width: 16rpx; height: 16rpx; border-radius: 50%; background: #D89833; } .panel { width: 100%; height: 38vh; background: #fff; border-top-left-radius: 18rpx; border-top-right-radius: 18rpx; padding: 16rpx 20rpx; box-sizing: border-box; } .search-row { height: 72rpx; background: #f6f6f6; border-radius: 12rpx; padding: 0 16rpx; display: flex; align-items: center; justify-content: space-between; gap: 12rpx; } .search-input { flex: 1; font-size: 26rpx; } .list { height: calc(38vh - 96rpx); margin-top: 16rpx; } .poi-item { padding: 14rpx 0; border-bottom: 1rpx solid #f0f0f0; } .poi-name { font-size: 28rpx; font-weight: 600; } .poi-addr { font-size: 24rpx; color: #666; margin-top: 6rpx; } .poi-meta { font-size: 22rpx; color: #999; margin-top: 6rpx; } </style>

4)/pages/sociAlize/ChatDisplayPage/positioning.vue(完整:地图 + 距离 + 导航)

vue
<!-- /pages/sociAlize/ChatDisplayPage/positioning.vue --> <template> <view :class="['app-container', theme]"> <view class="content"> <view class="overlay"> <view class="header" :style="{ paddingTop: statusBarHeight + 'px', height: headerBarHeight + 'px' }"> <view class="header-inner"> <view class="left-box" @click="navigateBack"> <uni-icons type="left" color="#000000" size="26"></uni-icons> </view> <view class="center-box"> <text class="title-text">定位</text> </view> <view class="right-box"></view> </view> </view> <view class="wrap"> <map class="map" :latitude="lat" :longitude="lng" :scale="16" :markers="markers" :show-location="true" /> <view class="bottom"> <view class="addr"> <view class="name">{{ name }}</view> <view class="detail">{{ address }}</view> </view> <view class="actions"> <view class="dist">{{ distanceText }}</view> <view class="nav-btn" @click="goNav">导航</view> </view> </view> </view> </view> </view> </view> </template> <script setup> import { ref, onMounted } from 'vue' import { onLoad } from '@dcloudio/uni-app' import { storeToRefs } from 'pinia' import { useMainStore } from '@/store/index.js' import { getStatusBarHeight, getTitleBarHeight } from '@/utils/system.js' const store = useMainStore() const { theme } = storeToRefs(store) const statusBarHeight = ref(getStatusBarHeight()) const headerBarHeight = ref(getTitleBarHeight()) const name = ref('位置') const address = ref('') const lat = ref(0) const lng = ref(0) const markers = ref([]) const distanceText = ref('') function navigateBack() { uni.navigateBack() } onLoad((option) => { if (option?.loc) { try { const o = JSON.parse(decodeURIComponent(option.loc)) name.value = o.name || '位置' address.value = o.address || '' lat.value = Number(o.lat || 0) lng.value = Number(o.lng || 0) } catch (e) {} } markers.value = [{ id: 1, latitude: lat.value, longitude: lng.value, width: 28, height: 28, iconPath: '/static/location_pin.png' }] }) onMounted(async () => { await calcDistance() }) function haversine(lat1, lon1, lat2, lon2) { const toRad = (d) => (d * Math.PI) / 180 const R = 6371000 const dLat = toRad(lat2 - lat1) const dLon = toRad(lon2 - lon1) const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2) const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) return R * c } async function calcDistance() { if (!lat.value || !lng.value) return uni.getLocation({ type: 'gcj02', isHighAccuracy: true, success: (res) => { const d = haversine(res.latitude, res.longitude, lat.value, lng.value) distanceText.value = `距离约 ${Math.round(d)} 米` }, fail: () => { distanceText.value = '' } }) } function goNav() { if (!lat.value || !lng.value) return // 打开系统地图导航 uni.openLocation({ latitude: lat.value, longitude: lng.value, name: name.value, address: address.value, success: () => {}, fail: () => { // fallback(部分机型) // #ifdef APP-PLUS plus.runtime.openURL(`https://uri.amap.com/navigation?to=${lng.value},${lat.value},${encodeURIComponent(name.value)}&mode=car&policy=1&src=uniapp`) // #endif } }) } </script> <style lang="scss" scoped> @import '@/assets/styles/global.scss'; .content { min-height: 100vh; position: relative; background-size: cover; } .overlay { position: absolute; inset: 0; background-size: cover; } .header { width: 94%; margin: 0 auto; } .header-inner { position: relative; display: flex; align-items: center; justify-content: center; height: 100%; } .left-box, .right-box { position: absolute; top: 0; bottom: 0; width: 120rpx; display: flex; align-items: center; } .left-box { left: 0; justify-content: flex-start; } .right-box { right: 0; justify-content: flex-end; } .center-box { flex: none; } .title-text { font-size: 32rpx; color: #111; } .wrap { width: 100%; height: calc(100vh - 88rpx); display: flex; flex-direction: column; } .map { width: 100%; flex: 1; } .bottom { width: 100%; padding: 18rpx 20rpx; box-sizing: border-box; background: #fff; display: flex; justify-content: space-between; align-items: center; } .addr { width: 70%; } .name { font-size: 30rpx; font-weight: 700; color: #111; } .detail { font-size: 24rpx; color: #666; margin-top: 6rpx; } .actions { width: 30%; display: flex; flex-direction: column; align-items: flex-end; gap: 10rpx; } .dist { font-size: 24rpx; color: #999; } .nav-btn { background: #D89833; color: #fff; padding: 10rpx 18rpx; border-radius: 12rpx; font-size: 26rpx; } </style>

你需要额外做的两件小事(不改代码也能跑,但这两项必须配)

  1. pages.json 加路由(如果你还没加):
  • /pages/sociAlize/ChatDisplayPage/sharedspace
  • /pages/sociAlize/ChatDisplayPage/positioning
  1. APP 定位权限(manifest / 原生权限):
  • Android:ACCESS_FINE_LOCATION / ACCESS_COARSE_LOCATION
  • iOS:NSLocationWhenInUseUsageDescription

如果你把 高德 key 给我,我可以再把:

  • POI 搜索更精细(含逆地理、拖动取点更准确)
  • 位置卡片用真实静态地图缩略图(现在已经预留了)
  • 以及你需要的“发送位置列表样式/交互”完全对齐你发的截图(包括“当前定位”一行样式、右侧小圆点选中等)

你直接把 key 和你 pages.json 里这两个页面真实路径告诉我(或发你 pages.json 片段),我就能把跳转路径也完全对齐你项目。

Compartilhar esta Q&A