这个是chat页面的内容<!-- /pages/sociAlize/ChatDisplayPage/...

Criado em: 21 de dezembro de 2025

Respondido usando GPT-5.2 Thinking por Chat01

Pergunta

ba7cdad9b6b258c750a45e2fe68bab78.png
468a12c1b41c4734b85becc08e5a613d.png
4b88bf6b35a5feb48a2ffa7137b43085.png
ebe842b232ec432f73e5b4a0798a4c9a.png

这个是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>

text
<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" :burnAfterReading="!!msg.burnAfterReading" @toggle-select="onToggleSelect" @enter-multiselect="onEnterMultiSelect" @quote-message="onQuoteMessage" @delete-message="onDeleteOne" @recall-message="onRecallOne" @burn-after-reading="onBurnAfterReading" @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; flex-wrap: wrap"> <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> <!-- ✅ 定位:打通 sharedspace --> <view style="display: inline-block; margin-left: 30rpx; text-align: center" > </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 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" > <uni-icons type="wallet-filled" size="30" /> <view>收藏分享</view> </view> <view style="display: inline-block; margin-left: 40rpx; text-align: center" > <uni-icons type="location" size="30" /> <view>文件分享</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 {} 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) { 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.msgType === 'location') return `[位置] ${String(obj.name || obj.title || obj.address || '').slice(0, 40)}` 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, 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 { 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 } 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(() => 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(() => { 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 localEcho(content, clientId, options = {}) { const localId = 'local_' + Date.now() const nowISO = new Date().toISOString() const msg = { localId, id: options.id || '', content, senderId: consumerId.value, receiverId: chatConsumerId.value, isUser: true, createTime: nowISO, burnAfterReading: !!options.burnAfterReading } messages.value.push(msg) if (clientId) { ds.lastLocalEchoClientId = clientId ds.seenClientIds.add(clientId) } if (msg.id) ds.seenServerIds.add(String(msg.id)) scrollToBottom() } function localEchoPlain(text, isUser = true, options = {}) { const localId = 'local_' + Date.now() + (isUser ? '_u' : '_a') const nowISO = new Date().toISOString() const msg = { localId, id: options.id || '', content: text, senderId: isUser ? consumerId.value : chatConsumerId.value, receiverId: isUser ? chatConsumerId.value : consumerId.value, isUser, createTime: nowISO, burnAfterReading: !!options.burnAfterReading } messages.value.push(msg) if (msg.id) ds.seenServerIds.add(String(msg.id)) scrollToBottom() } 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 ds.seenServerIds.add(String(serverId)) break } } } /** ✅ 单人无限撤回开关:Singlepersonchat=true => 不限时;否则3分钟内 */ function isUnlimitedSingleRecall() { const v = uni.getStorageSync('Singlepersonchat') return v === true || v === 'true' || v === 1 || v === '1' } function canRecallMsg(msg) { if (!msg) return false if (isUnlimitedSingleRecall()) return true const t = new Date(msg.createTime || msg.timestamp || 0).getTime() if (!t) return false return Date.now() - t <= 3 * 60 * 1000 } /** ✅ 只允许操作自己的消息(删除/撤回) */ function isMyMsg(msg) { return !!msg?.isUser } function isPlainText(content) { const obj = safeParseDeep(content) if ( obj && typeof obj === 'object' && (obj._kind === 'file' || obj.msgType === 'ad' || obj.msgType === 'text' || obj.msgType === 'location' || 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.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 { 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 // ✅ id 去重(修复媒体重复/空气泡) const sid = item?.id ? String(item.id) : '' if (sid && ds.seenServerIds.has(sid)) continue const contentStr = normalizeIncomingItem(item, webProjectUrl.value) const parsed = safeParseDeep(contentStr) const cid = parsed && parsed.clientId if (cid) { if (cid === ds.lastLocalEchoClientId) { ds.lastLocalEchoClientId = '' // 仍然需要记录 serverId if (sid) ds.seenServerIds.add(sid) continue } if (ds.seenClientIds.has(cid)) { if (sid) ds.seenServerIds.add(sid) continue } ds.seenClientIds.add(cid) } if (sid) ds.seenServerIds.add(sid) 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() }) } /** =============== 删除/撤回(单条 + 多选共用) =============== */ async function deleteOrRecallByIds(ids = [], actionName = '删除', silent = false) { const uniq = Array.from(new Set((ids || []).filter(Boolean).map(String))) if (!uniq.length) { if (!silent) uni.showToast({ title: `没有可${actionName}的消息`, icon: 'none' }) return } 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.delete(String(id))) if (!silent) uni.showToast({ title: `${actionName}成功:${ok},失败:${fail}`, icon: 'none' }) } catch { if (!silent) uni.showToast({ title: `${actionName}失败`, icon: 'none' }) } finally { if (!silent) uni.hideLoading() } } function onDeleteOne({ castingChatId, messageKey }) { const msg = messages.value.find((m) => String(m.id || '') === String(castingChatId || '')) || findMessageByKey( messageKey) if (!msg || !isMyMsg(msg)) return uni.showToast({ title: '只能删除自己的消息', icon: 'none' }) 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, messageKey }) { const msg = messages.value.find((m) => String(m.id || '') === String(castingChatId || '')) || findMessageByKey( messageKey) if (!msg || !isMyMsg(msg)) return uni.showToast({ title: '只能撤回自己的消息', icon: 'none' }) // ✅ 3分钟限制 / 无限撤回开关 if (!canRecallMsg(msg)) return uni.showToast({ title: '撤回失败', icon: 'none' }) 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' }) // ✅ 只能删自己的 if (selectedMessages.value.some((m) => !m.isUser)) 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' }) // ✅ 只能撤回自己的 if (selectedMessages.value.some((m) => !m.isUser)) return uni.showToast({ title: '只能撤回自己的消息', icon: 'none' }) // ✅ 3分钟限制 if (selectedMessages.value.some((m) => !canRecallMsg(m))) 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 onBurnAfterReading({ castingChatId }) { if (!castingChatId) return await deleteOrRecallByIds([castingChatId], '删除', true) } /** =============== 多选收藏 =============== */ 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 { 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)) }) } /** =============== 发送定位(sharedspace 回传) =============== */ async function sendLocationMessage(loc) { if (!consumerId.value || !chatConsumerId.value) return uni.showToast({ title: '缺少聊天对象', icon: 'none' }) const payload = { msgType: 'location', name: loc?.name || '位置', address: loc?.address || '', latitude: Number(loc?.latitude || 0), longitude: Number(loc?.longitude || 0), clientId: genClientId(), ts: Date.now() } const content = safeStringify(payload) // 先本地回显 localEcho(content, payload.clientId, { burnAfterReading: false }) try { const saveRes = await ajax.post({ url: 'castingChat', data: { senderId: consumerId.value, receiverId: chatConsumerId.value, content } }) const newId = saveRes?.data?.id if (newId && payload.clientId) bindServerIdToLocalMessage(payload.clientId, newId) // 推送给双方(WS) socket.value?.send( JSON.stringify({ type: 'castingChat', id: newId || 'tmp_' + Date.now(), burnAfterReading: false }) ) } catch { uni.showToast({ title: '发送定位失败', icon: 'none' }) } } function openLocation() { 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 console.log('到这了') uni.navigateTo({ url: '/pages/sociAlize/sharedspace/sharedspace?chatConsumerId=' + encodeURIComponent(chatConsumerId .value || ''), success: (res) => { // ✅ sharedspace 回传选中的位置,这里直接发送 res.eventChannel.on('sendLocation', async (loc) => { await sendLocationMessage(loc) }) } }) } /** =============== 发送文本 =============== */ 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) // ✅ WS 推送 burnAfterReading socket.value?.send( JSON.stringify({ type: 'castingChat', burnAfterReading: !!Postreadingburn.value, id: newId || 'tmp_' + Date.now() }) ) } catch { 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: '上传中...' }) const burn = !!Postreadingburn.value const option = { senderId: consumerId.value, receiverId: chatConsumerId.value, content: text || '', type, showName: showName || '', burnAfterReading: burn } 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 }) // ✅ 本地回显直接带上 serverId(去重用) localEcho(fileContent, clientId, { id: newId || '', burnAfterReading: burn }) // ✅ WS 推送(让对方立刻收到 + 自己也会收到,但我们用 id 去重) socket.value?.send( JSON.stringify({ type: 'castingChat', id: newId || 'tmp_' + Date.now(), burnAfterReading: burn }) ) } catch { uni.showToast({ title: '上传失败', icon: 'none' }) } finally { uni.hideLoading() } } function onInputFocus() {} 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 = '' }) } 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 {} 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: false })) } 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((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 {} } 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 {} } 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 {} } if (option.forward) { try { const p = JSON.parse(decodeURIComponent(option.forward)) pendingForward.value = p || null } catch { 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: () => {}, onCallReceived: () => {}, onCallBegin: () => {}, onCallEnd: () => {} }) } }) onUnmounted(() => { VideoCallsService.removeEventListeners({}) }) onBeforeUnmount(() => { if (socket.value) socket.value.close() }) /** =============== 文件助手本地缓存 & 历史(保持原逻辑) =============== */ 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 { return [] } } function saveFileTransferCache(uid, list) { if (!uid) return try { uni.setStorageSync(fileTransferKey(uid), JSON.stringify(list || [])) } catch {} } 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(), burnAfterReading: false } } 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 { 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(',') 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 { contentStr = raw } } else { contentStr = safeStringify(raw) } let cid = '' try { const p = JSON.parse(contentStr) cid = p.clientId || '' } catch {} localEcho(contentStr, cid) try { const res = await ajax.post({ url: 'castingChat', data: { senderId: consumerId.value, receiverId: chatConsumerId.value, content: contentStr } }) const newId = res?.data?.id if (newId && cid) bindServerIdToLocalMessage(cid, newId) socket.value?.send(JSON.stringify({ type: 'castingChat', id: newId || 'tmp_' + Date.now(), burnAfterReading: false })) } catch { 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>这个是组件的气泡文件Bubblechatbox<template>
text
<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="isLocation"> <view class="loc-card" @click.stop="openLocationDetail" @tap.stop="openLocationDetail"> <view class="loc-name">{{ locationName }}</view> <view class="loc-addr">{{ locationAddress }}</view> <view class="loc-btn">定位地图</view> </view> </template> <!-- 文件类消息:图片/视频/语音/文件 --> <template v-else-if="isFileLike"> <!-- ✅ 阅后即焚:对方发来的媒体先显示马赛克 --> <view v-if="fileType === 1"> <image :src="displayFileUrl" style="max-width: 480rpx; max-height: 560rpx; border-radius: 8rpx" mode="widthFix" @click.stop="onClickBurnFile('image')" /> <view v-if="isBurnMedia" class="burn-tag">阅后即焚</view> </view> <view v-else-if="fileType === 2" style="width: 520rpx; max-width: 75vw"> <view v-if="isBurnMedia" class="burn-video-cover" @click.stop="onClickBurnFile('video')"> <image :src="BURN_MOSAIC_IMG" mode="aspectFill" style="width: 100%; height: 300rpx; border-radius: 8rpx" /> <view class="burn-play">▶</view> <view class="burn-tag burn-tag--abs">阅后即焚</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> <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.stop="onClickBurnFile('file')"> {{ fileShowName || '文件' }} </text> <view v-if="isBurnMedia" class="burn-tag burn-tag--inline">阅后即焚</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 topMenuItems" :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 bottomMenuItems" :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> <!-- ✅ 阅后即焚视频弹窗(退出即删除) --> <up-popup :show="burnVideoShow" @close="closeBurnVideo" mode="center" :closeable="true" :safeAreaInsetTop="true" closeIconPos="top-right" zIndex="100000" round="12"> <view style="width: 686rpx; background: #000; border-radius: 12rpx; overflow: hidden"> <video v-if="burnVideoUrl" :src="burnVideoUrl" controls style="width: 100%; height: 420rpx" /> </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 }, // ✅ 阅后即焚标记(来自 chat.vue 的 msg.burnAfterReading) burnAfterReading: { 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-reading' ]) // 阅后即焚马赛克遮挡图(你给的URL) const BURN_MOSAIC_IMG = 'https://t12.baidu.com/it/u=3229389678,260314789&fm=30&app=106&f=JPEG?w=640&h=960&s=342320B862E7C8EB44B617930300C08D' 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) } 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 {} 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 locationName = computed(() => String(parsed.value?.name || '位置')) const locationAddress = computed(() => String(parsed.value?.address || '')) 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)) 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 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)) 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 === 'location') return `[位置] ${String(obj.name || obj.address || '').slice(0, 40)}` 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) } 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 || '' : '')) /** ✅ 阅后即焚:只对“对方发来”的媒体生效(role=assistant),自己发的不遮 */ const isBurnMedia = computed(() => !!(props.burnAfterReading && props.role !== 'user' && (fileType.value === 1 || fileType.value === 2 || fileType.value === 4))) const displayFileUrl = computed(() => { if (isBurnMedia.value && fileType.value === 1) return BURN_MOSAIC_IMG return 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}` }) 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() if (isLocation.value) openLocationDetail() } /** =============== ✅ 只允许删除/撤回自己的消息(对方消息隐藏按钮) =============== */ const isSelfMessage = computed(() => props.role === 'user') const topMenuItems = computed(() => { // 对方消息:只显示 4 个(转发、收藏、引入、多选) if (!isSelfMessage.value) return [{ title: '转发', id: '1' }, { title: '收藏', id: '2' }] return [{ title: '转发', id: '1' }, { title: '收藏', id: '2' }, { title: '删除', id: '3' }] }) const bottomMenuItems = computed(() => { if (!isSelfMessage.value) return [{ title: '引入', id: '4' }, { title: '多选', id: '5' }] return [{ title: '引入', id: '4' }, { title: '多选', id: '5' }, { title: '撤回', id: '6' }] }) 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 { const 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 { uni.showToast({ title: '收藏异常', icon: 'none' }) } } function normalizeContentForForward() { if (typeof props.content === 'string') return props.content return asString(props.content) } function openOrDownload(url, afterOpenCb) { // #ifdef H5 window.open(url, '_blank') afterOpenCb && afterOpenCb() // #endif // #ifndef H5 uni.downloadFile({ url, success: ({ tempFilePath }) => { if (uni.openDocument) { uni.openDocument({ filePath: tempFilePath, success: () => afterOpenCb && afterOpenCb(), fail: () => afterOpenCb && afterOpenCb() }) } else { uni.showToast({ title: '已下载', icon: 'none' }) afterOpenCb && afterOpenCb() } }, fail: () => { uni.showToast({ title: '下载失败', icon: 'none' }) afterOpenCb && afterOpenCb() } }) // #endif } /** =============== ✅ 阅后即焚:点击查看后删除 =============== */ const burnTriggered = ref(false) const burnVideoShow = ref(false) const burnVideoUrl = ref('') function triggerBurnDelete(delayMs = 0) { if (burnTriggered.value) return burnTriggered.value = true const id = props.castingChatId if (!id) return setTimeout(() => { emit('burn-after-reading', { castingChatId: id }) }, Math.max(0, Number(delayMs || 0))) } function onClickBurnFile(kind) { // 非阅后即焚:按原逻辑 if (!isBurnMedia.value) { if (kind === 'image') return previewImage(fileUrl.value) if (kind === 'video') return (burnVideoUrl.value = fileUrl.value), (burnVideoShow.value = true) if (kind === 'file') return openOrDownload(fileUrl.value) return } // 阅后即焚逻辑 if (kind === 'image') { previewImage(fileUrl.value) // ✅ 图片:点击放大后 6 秒删除 triggerBurnDelete(6000) return } if (kind === 'video') { burnVideoUrl.value = fileUrl.value burnVideoShow.value = true return } if (kind === 'file') { openOrDownload(fileUrl.value, () => { // ✅ 文件:点击即删除 triggerBurnDelete(0) }) } } function closeBurnVideo() { burnVideoShow.value = false burnVideoUrl.value = '' // ✅ 视频:退出即删除 if (isBurnMedia.value) triggerBurnDelete(0) } /** =============== 定位跳转 positioning =============== */ function openLocationDetail() { const o = parsed.value || {} const payload = { name: o.name || '位置', address: o.address || '', latitude: Number(o.latitude || 0), longitude: Number(o.longitude || 0) } uni.navigateTo({ url: '/pages/sociAlize/sharedspace/positioning?payload=' + encodeURIComponent(JSON.stringify(payload)) }) } /** =============== 转发详情弹窗(原逻辑保留) =============== */ 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 || ''}` } /** 广告逻辑保留 */ 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}` }) } </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; } /** 定位卡片 */ .loc-card { width: 520rpx; max-width: 70vw; border-radius: 12rpx; padding: 16rpx 18rpx; background: #fff; box-sizing: border-box; } .loc-name { font-size: 28rpx; color: #111; margin-bottom: 8rpx; font-weight: 600; } .loc-addr { font-size: 24rpx; color: #666; margin-bottom: 14rpx; } .loc-btn { height: 76rpx; border-radius: 10rpx; background: #d89833; color: #fff; display: flex; justify-content: center; align-items: center; } /** 阅后即焚标签 */ .burn-tag { margin-top: 8rpx; font-size: 22rpx; color: #fff; background: rgba(216, 152, 51, 0.9); padding: 6rpx 12rpx; border-radius: 999rpx; display: inline-block; } .burn-tag--abs { position: absolute; bottom: 12rpx; right: 12rpx; } .burn-tag--inline { margin-left: 10rpx; } .burn-video-cover { position: relative; } .burn-play { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: 80rpx; height: 80rpx; border-radius: 50%; background: rgba(0, 0, 0, 0.45); display: flex; align-items: center; justify-content: center; color: #fff; font-size: 44rpx; } /** 合并转发 */ .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>其中这里的async function deleteOrRecallByIds(ids = [], actionName = '删除', silent = false) {
text
const uniq = Array.from(new Set((ids || []).filter(Boolean).map(String))) if (!uniq.length) { if (!silent) uni.showToast({ title: `没有可${actionName}的消息`, icon: 'none' }) return } 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.delete(String(id))) if (!silent) uni.showToast({ title: `${actionName}成功:${ok},失败:${fail}`, icon: 'none' }) } catch { if (!silent) uni.showToast({ title: `${actionName}失败`, icon: 'none' }) } finally { if (!silent) uni.hideLoading() } }的阅后即焚 删除功能还要完善 用户点击了删除不了 这个是单人的删除方法20.castingChatDeleteRecord撤回聊天记录 入口参数 String id, 要撤回消息的id

返回值
id, 要删除的消息id
其中我有又加了 <view style="display: inline-block; margin-left: 40rpx; text-align: center" >
<uni-icons type="wallet-filled" size="30" />
<view>收藏分享</view>
</view>
<view style="display: inline-block; margin-left: 40rpx; text-align: center" >
<uni-icons type="location" size="30" />
<view>文件分享</view>
</view> 这个两个按钮这个两个按钮都是调到这个文件上uni.navigateTo({
url: '/pages/sociAlize/Coffee/Contact/collect?parameter=文件传输助手'
}) 之前这个文件也是传参数做了区别 这是还是一样的同一个公用页面 传参数做区分collect<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>

text
<view class="center-box"> <text class="title-text">{{ title || '收藏' }}</text> </view> <view class="right-box"> <text class="desc-text" /> </view> </view> </view> <!-- 内容区域 --> <view class="page-body"> <view class="page-inner"> <!-- 顶部分类 Tab:只在文件传输助手模式显示 --> <view v-if="isFileTransfer" class="filter-tabs"> <view v-for="tab in filterTabs" :key="tab.value" :class="[ 'filter-tab-item', { 'filter-tab-item--active': activeType === tab.value } ]" @click="changeTab(tab)"> {{ tab.label }} </view> </view> <scroll-view scroll-y style="height: 84vh;" refresher-enabled :refresher-triggered="refresherTriggered" @refresherrefresh="onRefresherRefresh" @scrolltolower="loadMore"> <!-- 骨架屏 --> <view v-if="loading && !history.length" class="skeleton-wrapper"> <view v-for="n in 5" :key="n" class="skeleton-card"> <view class="skeleton-line time" /> <view class="skeleton-line content" /> <view class="skeleton-line name" /> </view> </view> <!-- 列表 --> <view v-else> <uni-swipe-action> <uni-swipe-action-item v-for="(item, index) in history" :key="item.id || index" :right-options="options" @click="handleSwipeClick($event, item)"> <view class="collect-card"> <!-- 时间 --> <view class="collect-card__time"> {{ gettime(item.createTime) }} </view> <!-- 内容区:文字 / 图片 / 文件 / 其他 --> <view class="collect-card__body"> <!-- 文件类 --> <view v-if="isFile(item)" class="collect-file"> <view class="collect-file__info"> <text class="collect-file__name"> {{ getFileShowName(item) }} </text> <text v-if="formatSize(item.fileSize)" class="collect-file__size"> {{ formatSize(item.fileSize) }} </text> </view> </view> <!-- 图片类 --> <view v-else-if="isImage(item)" class="collect-image-wrapper" @click="previewImage(item)"> <image class="collect-image" :src="webProjectUrl + item.fileName" mode="aspectFill" /> </view> <!-- 普通文本 / 链接等 --> <view v-else class="collect-text"> {{ getTextContent(item) }} </view> </view> <!-- 昵称 --> <view class="collect-card__nickname"> {{ item.nickname || '' }} </view> </view> </uni-swipe-action-item> </uni-swipe-action> <!-- 底部加载状态 --> <view v-if="loadingMore" class="list-footer">加载中...</view> <view v-else-if="noMore && history.length" class="list-footer"> 没有更多了 </view> </view> </scroll-view> </view> </view> </view> </view> </view>
</template> <script setup> import { ref, inject } 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' import ajax from '@/api/ajax' // 全局主题 const store = useMainStore() const { theme } = storeToRefs(store) // 顶部安全区与标题栏高度 const statusBarHeight = ref(getStatusBarHeight()) const headerBarHeight = ref(getTitleBarHeight()) // 页面标题 const title = ref('') // 是否是文件传输助手模式 const isFileTransfer = ref(false) // 顶部分类 Tab(文件传输助手使用) const filterTabs = ref([{ label: '全部', value: -1 }, { label: '文字', value: 0 }, { label: '图片', value: 1 }, { label: '视频', value: 2 }, { label: '语音', value: 3 }, { label: '文件', value: 4 }, { label: '链接', value: 5 } ]) // 当前选中的 type(-1 = 全部) const activeType = ref(-1) // 返回上一页 const navigateBack = () => { uni.navigateBack() } // swipe 右侧按钮 const options = ref([{ text: '分享', style: { backgroundColor: '#ffaa00', color: '#ffffff', height: '80rpx', width: '120rpx', borderRadius: '12rpx' } }, { text: '删除', style: { backgroundColor: '#dd524d', color: '#ffffff', height: '80rpx', width: '120rpx', borderRadius: '12rpx' } } ]) const Deleteid = ref('') const history = ref([]) // 分页相关 const rowStartIdx = ref(0) const pageSize = ref(20) const noMore = ref(false) const loading = ref(false) // 首次加载 + 下拉刷新 const loadingMore = ref(false) // 底部加载更多 const refresherTriggered = ref(false) const webProjectUrl = inject('webProjectUrl') || '' /** * 顶部 Tab 切换(文件传输助手) */ function changeTab(tab) { if (activeType.value === tab.value) return activeType.value = tab.value Retrievehistorical(true,tab) } /** * 图片判断:根据后缀名 */ const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'] function isImage(item) { if (!item || !item.fileName) return false const name = String(item.fileName).toLowerCase() return imageExts.some((ext) => name.endsWith(ext)) } /** * 文件判断: * - type === 4 明确是文件 * - 或者有文件名,且不是图片,且不为 files/collect/null 这种占位 */ function isFile(item) { if (!item) return false if (Number(item.type) === 4) return true if (!item.fileName) return false const name = String(item.fileName).toLowerCase() if (name.endsWith('/null')) return false return !isImage(item) } /** * 文件展示名 */ function getFileShowName(item) { if (!item) return '文件' if (item.showName) return item.showName if (item.fileName) { const arr = String(item.fileName).split('/') return arr[arr.length - 1] || '文件' } return '文件' } /** * 文件大小格式化 */ function formatSize(size) { if (size == null || size === '') return '' if (typeof size === 'string') return size const n = Number(size) if (Number.isNaN(n)) return '' if (n < 1024) return n + 'B' if (n < 1024 * 1024) return (n / 1024).toFixed(1) + 'KB' return (n / 1024 / 1024).toFixed(1) + 'MB' } /** * 普通文本内容获取 * 兼容: * - content 字符串(可能是 JSON) * - content 对象:取 text / content * - 兜底 text / msg / showName */ function getTextContent(item) { if (!item) return '' const c = item.content // 1. content 为字符串 if (typeof c === 'string' && c) { // 尝试当成 JSON 解析(兼容:{"clientId": "...", "msgType": "text", "text": "不对不对吧", ...}) try { const obj = JSON.parse(c) if (obj && typeof obj.text === 'string' && obj.text) { return obj.text } if (obj && typeof obj.content === 'string' && obj.content) { return obj.content } } catch (e) { // 不是 JSON,就直接展示 } return c } // 2. content 为对象 if (c && typeof c === 'object') { if (typeof c.text === 'string' && c.text) { return c.text } if (typeof c.content === 'string' && c.content) { return c.content } } // 3. 其他可能字段 if (typeof item.text === 'string' && item.text) return item.text if (typeof item.msg === 'string' && item.msg) return item.msg // 链接类没有 text 时,兜底 showName / fileName if (typeof item.showName === 'string' && item.showName) return item.showName if (typeof item.fileName === 'string' && item.fileName) return item.fileName return '' } /** * 图片预览 */ function previewImage(item) { if (!item || !item.fileName) return const url = webProjectUrl + item.fileName uni.previewImage({ current: url, urls: [url] }) } /** * 下拉刷新 */ function onRefresherRefresh() { refresherTriggered.value = true Retrievehistorical(true) } /** * 上拉加载更多 */ function loadMore() { if (loading.value || loadingMore.value || noMore.value) return Retrievehistorical(false) } /** * swipe 右侧按钮点击 */ function handleSwipeClick(e, item) { const { content } = e // content.text 为按钮文字 if (!content || !content.text) return const txt = content.text if (txt === '删除') { deleteItem(item) } else if (txt === '分享') { shareItem(item) } } /** * 分享:复制文本到剪贴板(只对文本类有意义) */ function shareItem(item) { const text = getTextContent(item) if (!text) { return uni.showToast({ title: '暂无可分享内容', icon: 'none' }) } uni.setClipboardData({ data: text, success() { uni.showToast({ title: '内容已复制,可粘贴分享', icon: 'none' }) } }) } /** * 拉取收藏 / 文件传输助手历史 * @param reset true: 重置/刷新;false: 分页加载更多 */ async function Retrievehistorical(reset = false,item) { let {value} = item const consumerId = uni.getStorageSync('consumerId') if (!consumerId) { return uni.showToast({ title: '请先登录', icon: 'none' }) } if (reset) { rowStartIdx.value = 0 noMore.value = false } const isFirstPage = rowStartIdx.value === 0 if (isFirstPage) { loading.value = true } else { loadingMore.value = true } const option = { consumerId, // 用户 id fileTransfer: isFileTransfer.value, // 是否文件传输助手 rowStartIdx: rowStartIdx.value, rowCount: pageSize.value, type:value } console.log('切换的参数',option) // 只有文件传输助手才按 type 分类 if (isFileTransfer.value && activeType.value !== -1) { option.type = activeType.value } try { const res = await ajax.post({ url: 'castingChatCollectList', data: option, method: 'post' }) console.log(res, '里面的list历史记录') const ok = res && res.data && res.data.result === 'success' if (ok) { const arr = res.data.castingChatCollectArray || [] if (reset) { history.value = arr } else { history.value = history.value.concat(arr) } if (arr.length < pageSize.value) { noMore.value = true } else { rowStartIdx.value += arr.length } } else { uni.showToast({ title: res?.data?.msg || '拉取失败', icon: 'none' }) } } catch (e) { uni.showToast({ title: '网络错误', icon: 'none' }) } finally { loading.value = false loadingMore.value = false if (refresherTriggered.value) { refresherTriggered.value = false } } } /** * 删除单条收藏 */ function deleteItem(item) { const { id } = item || {} if (!id) return Deleteid.value = id uni.showModal({ title: '提示', content: '确定要删除吗?', success: async (res) => { if (res.confirm) { try { const objdata = { id: Deleteid.value } const resp = await ajax.post({ url: 'deleteCastingChatCollect', data: objdata }) if (resp?.data?.result === 'success') { const index = history.value.findIndex( (it) => it.id === Deleteid.value ) if (index !== -1) { history.value.splice(index, 1) } uni.showToast({ title: '删除成功', icon: 'none' }) } else { uni.showToast({ title: '网络错误', icon: 'none' }) } } catch (err) { uni.showToast({ title: '删除异常', icon: 'none' }) } } } }) } /** * 时间格式化 */ function gettime(val) { if (!val) return '' // 你的接口看起来是秒,这里乘 1000 const date = new Date(val * 1000) const y = date.getFullYear() let MM = date.getMonth() + 1 MM = MM < 10 ? '0' + MM : MM let d = date.getDate() d = d < 10 ? '0' + d : d let h = date.getHours() h = h < 10 ? '0' + h : h let m = date.getMinutes() m = m < 10 ? '0' + m : m let s = date.getSeconds() s = s < 10 ? '0' + s : s return `${y}-${MM}-${d} ${h}:${m}:${s}` } // 页面入参:parameter onLoad((option) => { // 默认当成文件传输助手 isFileTransfer.value = true title.value = '文件传输助手' if (option && option.parameter) { title.value = option.parameter // 只有「收藏」是收藏模式,是文件传输助手 if (option.parameter === '收藏') { isFileTransfer.value = false } } // 初始拉取列表 Retrievehistorical(true) }) </script> <style lang="scss" scoped> @import '@/assets/styles/global.scss'; .app-container {} .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; } .page-body { width: 100%; display: flex; justify-content: center; align-items: flex-start; } .page-inner { width: 94%; height: 100%; } /* 顶部分类 Tab(文件传输助手) */ .filter-tabs { margin-top: 16rpx; margin-bottom: 8rpx; display: flex; flex-direction: row; align-items: center; flex-wrap: wrap; } .filter-tab-item { margin-right: 24rpx; margin-bottom: 8rpx; font-size: 26rpx; color: #666; padding-bottom: 8rpx; } .filter-tab-item--active { color: #ff9900; border-bottom: 4rpx solid #ff9900; } /* 收藏卡片 */ .collect-card { width: 100%; border-radius: 12rpx; background-color: #ffffff; box-sizing: border-box; padding: 16rpx 20rpx; margin-top: 16rpx; display: flex; flex-direction: column; } .collect-card__time { font-size: 24rpx; color: #9c9c9c; } .collect-card__body { margin-top: 12rpx; } /* 纯文本 */ .collect-text { font-size: 28rpx; line-height: 1.6; color: #333; white-space: pre-wrap; word-break: break-all; } /* 图片 */ .collect-image-wrapper { border-radius: 12rpx; overflow: hidden; width: 180rpx; height: 180rpx; } .collect-image { width: 100%; height: 100%; display: block; } /* 文件 */ .collect-file { flex-direction: row; align-items: center; display: flex; } .collect-file__icon { width: 60rpx; height: 60rpx; margin-right: 16rpx; } .collect-file__info { flex: 1; display: flex; flex-direction: column; } .collect-file__name { font-size: 28rpx; color: #333; } .collect-file__size { margin-top: 4rpx; font-size: 24rpx; color: #999; } /* 昵称 */ .collect-card__nickname { margin-top: 12rpx; font-size: 24rpx; color: #666; } /* 底部加载状态 */ .list-footer { text-align: center; color: #999; font-size: 24rpx; margin: 20rpx 0; } /* 骨架屏 */ .skeleton-wrapper { padding-top: 16rpx; } .skeleton-card { width: 100%; height: 200rpx; border-radius: 12rpx; background-color: #ffffff; display: flex; flex-direction: column; justify-content: center; padding: 0 20rpx; box-sizing: border-box; margin-top: 16rpx; } .skeleton-line { background: #f0f0f0; border-radius: 8rpx; overflow: hidden; position: relative; margin-bottom: 12rpx; } .skeleton-line::after { content: ''; position: absolute; left: -100%; top: 0; width: 60%; height: 100%; background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0)); animation: skeleton-loading 1.2s infinite; } .skeleton-line.time { width: 40%; height: 20rpx; } .skeleton-line.content { width: 90%; height: 40rpx; } .skeleton-line.name { width: 30%; height: 24rpx; } @keyframes skeleton-loading { 0% { transform: translateX(0); } 100% { transform: translateX(160%); } } </style> 他如果点击的收藏这么点击到这个页面的就是咖信收藏和咖信收藏 我会给你图片和怎么请求api的如果是文件文件分享 就会 咖信文件分享 和咖密文件分享 同时下面的

全部 文字 图片 还有 这些都没有区别咖信收藏和咖信收藏 咖信文件分享 和咖密文件分享 你这点击页面从新区别下 这个是api 38. castingChatCollectList收藏列表
入口参数
String consumerId, 用户id
Integer type, 类型
Boolean fileTransfer 是否是文件传输助手
Boolean castingSecret 是否是咖密收藏
Integer rowStartIdx, 起始行
Integer rowCount 行数
返回值
castingChatCollectArray
id
content 内容
sendConsumerId 发送者
nickname 发送者昵称
groupId 组id
groupTitle 组名
fileName 文件名
videoId阿里云id
playUrl 阿里云播放地址
type 类型(详见聊天类型,多了个5 链接)
showName 文件显示名
createTime 建立时间
这俩要同时调用了 fileTransfer 等于true 就是文件助手 castingSecret 等于true 就是咖密 两个都是true 就是咖密的文件助手 剩下的 就是这个逻辑 通过websockets 发送过去这个消息
打通所有的页面返回一个 最完整的chat 聊天页面 一个内容也不能少返回最完整的单人聊天页面chat的内容这个页面必须全量返回代码 一个代码功能都不能够少 在原来的基础上新加功能 一定要确保这个页面的完整性 我我是要直接复制使用的和一个非常完整的
chat的气泡组件的内容Bubblechatbox模块 和一个修改的完整的collect页面的内容模块了 这个我会你给发送图片参考的

Pensamento

Clarifying behavior for collect and file transfer modes

The collect page should display "secret tabs" like coffee collect and secret collect when not in fileTransfer mode, and show coffee and secret file transfer assistant when in fileTransfer mode. For fileTransfer, additional category filters like "全部 文字 语音" etc. are necessary. Parameters will help set initial modes.

Adjusting behavior for share modes and tabs

To handle different modes, use a parameter to toggle fileTransfer and adjust the title. For "收藏分享," show "咖信收藏分享" and "咖密收藏分享" tabs, and for "文件分享," "咖信文件分享" and "咖密文件分享." Keep the type tabs (全部 文字 图片, etc.) consistent, but ensure they work properly with castingSecret.

Fixing API parameter handling and error with Retrievehistorical

When switching between coffee and secret tabs, I need to ensure that the content list updates correctly with castingSecret and fileTransfer, as the current implementation passes the type parameter incorrectly. There's also a bug where Retrievehistorical may throw an error if no item is provided—I'll refactor it to handle optional parameters more robustly.

Fixing logic for Retrievehistorical and handling type parameter

It looks like when onLoad calls Retrievehistorical(true), an error occurs, so I need to fix that. Also, there are conflicts where option.type is overwritten when conditions should not apply. I'll determine the correct type value for queryType based on fileTransfer and activeType, as well as clean up the logic for API compatibility with type values.

Clarifying type handling and implementing new features

It seems that type=0 might be interpreted as "all," but the existing code uses 0 for delegating file transfers. There’s definitely confusion around how to handle the type filter for text, image, etc. I’ll adjust the code to omit the type for “all” and apply it only when filtering. Also, I’ll add a toggle for secret mode and adjust share modes with a preview option.

Planning the implementation and full code delivery

To minimize dependencies, I’ll use up-popup, which they already employ. For the send function, I’ll initiate a popup with confirmation and a send button. After sending, I'll navigate back. Multi-select isn’t required yet, so single select will be the approach. Let’s ensure proper handling of the full, required code for each Vue file and include all necessary details. I’ll carefully manage the code output to avoid exceeding limits.

Delivering full code while managing size

To handle the large code files, I’ll aim to deliver them as complete code blocks, following the user's request for full content in their original state. The files may exceed the output token limit, so I'll generate downloadable files and share links, while showing key code differences too. This should meet the requirements!

Delivering full code via downloadable files

To meet the user's request for full code despite size limitations, I will generate the .vue files using a Python script. I’ll provide the user with download links to these files. While the code might exceed message limits, the download links will allow the user to access everything as intended.

Deciding on handling large code output

The code is too large to provide directly within the chat’s token limits. To work around this, I’ll create downloadable files using a Python script. This approach preserves the full code, and I may compress it for efficiency, but there’s a balancing act to ensure it fits within the limit.

Handling large code limits for full delivery

The full code may exceed the output limit — chat.vue alone could be around 30k tokens. One option is to provide it via downloadable files, but creating the files requires dispatching that code. Alternatively, I could offer patches and explain the modifications. Another approach: split the code into multiple responses.

Breaking down solution for user-requested code updates

I think I can still deliver the full code within the token limit! I plan to output the full updated code in blocks, staying careful with formatting. I'll include modifications without repeating the unchanged code. This will include changes like adding new functions, templates, and handlers, while ensuring the final response fits within the constraints.

Updating components for Bubblechatbox and Collect pages

I’ll update the Bubblechatbox by adding a computed isLink and a template section for links. I'll implement the link handling based on the environment, ensuring proper logic for H5 and App platforms. For collect.vue, I need to support multiple modes and handle different behaviors based on user input, like managing websocket connections and sending messages. It’ll be complex, but I think I can manage the changes!

Thought for 6m 43s

Resposta

下面给你 3 个文件的“全量可复制使用版”(在你原来代码基础上 不删任何功能,只做新增/修复),分别是:

  1. /pages/sociAlize/ChatDisplayPage/chat.vue(单人聊天页,全量)
  2. /components/Bubblechatbox.vue(气泡组件,全量)
  3. /pages/sociAlize/Coffee/Contact/collect.vue(收藏/文件助手/分享选择页,全量)

核心改动点(都已经落到代码里):

  • 阅后即焚删除完善deleteOrRecallByIds 兼容接口返回 id / result,并支持 强制本地删除(避免“用户点了删除不了”)。
  • 新增两个按钮:收藏分享 / 文件分享:都跳同一个 collect 公用页,但通过参数区分(收藏分享/文件分享),并携带 chatConsumerId 以便在 collect 页内直接发送到当前聊天。
  • collect 页支持 4 组合
    • fileTransfer=false & castingSecret=false => 咖信收藏(或咖信收藏分享)
    • fileTransfer=false & castingSecret=true => 咖密收藏(或咖密收藏分享)
    • fileTransfer=true & castingSecret=false => 文件传输助手/咖信文件分享
    • fileTransfer=true & castingSecret=true => 咖密文件传输助手/咖密文件分享
  • type 分类(全部/文字/图片/…/链接):在 fileTransfer 模式下生效,并且在切换“咖信/咖密”时同样正确带上 castingSecret 去请求。
  • 链接 type=5 修正显示:Bubble 里把 type=5 当链接显示(不再误判为聊天记录),type=6 才是聊天记录/合并转发。

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 || ''" :multiSelectMode="multiSelectMode && !Isfiletransfer" :selected="isSelected(makeMsgKey(msg, idx))" :selectDisabled="!msg.id" :burnAfterReading="!!msg.burnAfterReading" @toggle-select="onToggleSelect" @enter-multiselect="onEnterMultiSelect" @quote-message="onQuoteMessage" @delete-message="onDeleteOne" @recall-message="onRecallOne" @burn-after-reading="onBurnAfterReading" @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; flex-wrap: wrap"> <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> <!-- ✅ 定位:打通 sharedspace --> <view style="display: inline-block; margin-left: 30rpx; text-align: center"></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 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="openCollectShare"> <uni-icons type="wallet-filled" size="30" /> <view>收藏分享</view> </view> <!-- ✅ 新增:文件分享 --> <view style="display: inline-block; margin-left: 40rpx; text-align: center" @click="openFileShare"> <uni-icons type="location" size="30" /> <view>文件分享</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 {} 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) { 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.msgType === 'location') return `[位置] ${String(obj.name || obj.title || obj.address || '').slice(0, 40)}` 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.type == 5) return `[链接] ${String(obj.content || obj.url || obj.link || '').slice(0, 40)}` if (obj.msgType === 'ad') return '[广告]' if (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, 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 { 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 } 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(() => 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(() => { 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 } /** ✅ 新增:收藏分享 / 文件分享 打开 collect 公用页(带 chatConsumerId) */ function openCollectShare() { 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 (!chatConsumerId.value) return uni.showToast({ title: '缺少聊天对象', icon: 'none' }) Didpopup.value = false uni.navigateTo({ url: '/pages/sociAlize/Coffee/Contact/collect?parameter=' + encodeURIComponent('收藏分享') + '&chatConsumerId=' + encodeURIComponent(chatConsumerId.value || '') }) } function openFileShare() { 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 (!chatConsumerId.value) return uni.showToast({ title: '缺少聊天对象', icon: 'none' }) Didpopup.value = false uni.navigateTo({ url: '/pages/sociAlize/Coffee/Contact/collect?parameter=' + encodeURIComponent('文件分享') + '&chatConsumerId=' + encodeURIComponent(chatConsumerId.value || '') }) } watch( () => inputText.value, (val) => { istext.value = !!(val && val.length > 0) } ) function localEcho(content, clientId, options = {}) { const localId = 'local_' + Date.now() const nowISO = new Date().toISOString() const msg = { localId, id: options.id || '', content, senderId: consumerId.value, receiverId: chatConsumerId.value, isUser: true, createTime: nowISO, burnAfterReading: !!options.burnAfterReading } messages.value.push(msg) if (clientId) { ds.lastLocalEchoClientId = clientId ds.seenClientIds.add(clientId) } if (msg.id) ds.seenServerIds.add(String(msg.id)) scrollToBottom() } function localEchoPlain(text, isUser = true, options = {}) { const localId = 'local_' + Date.now() + (isUser ? '_u' : '_a') const nowISO = new Date().toISOString() const msg = { localId, id: options.id || '', content: text, senderId: isUser ? consumerId.value : chatConsumerId.value, receiverId: isUser ? chatConsumerId.value : consumerId.value, isUser, createTime: nowISO, burnAfterReading: !!options.burnAfterReading } messages.value.push(msg) if (msg.id) ds.seenServerIds.add(String(msg.id)) scrollToBottom() } 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 ds.seenServerIds.add(String(serverId)) break } } } /** ✅ 单人无限撤回开关:Singlepersonchat=true => 不限时;否则3分钟内 */ function isUnlimitedSingleRecall() { const v = uni.getStorageSync('Singlepersonchat') return v === true || v === 'true' || v === 1 || v === '1' } function canRecallMsg(msg) { if (!msg) return false if (isUnlimitedSingleRecall()) return true const t = new Date(msg.createTime || msg.timestamp || 0).getTime() if (!t) return false return Date.now() - t <= 3 * 60 * 1000 } /** ✅ 只允许操作自己的消息(删除/撤回) */ function isMyMsg(msg) { return !!msg?.isUser } function isPlainText(content) { const obj = safeParseDeep(content) if ( obj && typeof obj === 'object' && (obj._kind === 'file' || obj.msgType === 'ad' || obj.msgType === 'text' || obj.msgType === 'location' || 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.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 { 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 // ✅ id 去重(修复媒体重复/空气泡) const sid = item?.id ? String(item.id) : '' if (sid && ds.seenServerIds.has(sid)) continue const contentStr = normalizeIncomingItem(item, webProjectUrl.value) const parsed = safeParseDeep(contentStr) const cid = parsed && parsed.clientId if (cid) { if (cid === ds.lastLocalEchoClientId) { ds.lastLocalEchoClientId = '' if (sid) ds.seenServerIds.add(sid) continue } if (ds.seenClientIds.has(cid)) { if (sid) ds.seenServerIds.add(sid) continue } ds.seenClientIds.add(cid) } if (sid) ds.seenServerIds.add(sid) 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() }) } /** =============== 删除/撤回(单条 + 多选共用) =============== */ function removeLocalByIds(idList = []) { const uniq = Array.from(new Set((idList || []).filter(Boolean).map((x) => String(x)))) if (!uniq.length) return messages.value = messages.value.filter((m) => !uniq.includes(String(m.id || ''))) uniq.forEach((id) => ds.seenServerIds.delete(String(id))) } /** * ✅ 修复:阅后即焚删除“点了删不掉” * - 兼容接口返回:{id: xxx} 或 {result:'success', id:xxx} 等 * - silent=true 时不弹 toast * - forceLocal=true 时:即使接口失败也本地移除(避免用户看不到删除效果) */ async function deleteOrRecallByIds(ids = [], actionName = '删除', silent = false, forceLocal = false) { const uniq = Array.from(new Set((ids || []).filter(Boolean).map(String))) if (!uniq.length) { if (!silent) uni.showToast({ title: `没有可${actionName}的消息`, icon: 'none' }) return } if (!silent) uni.showLoading({ title: `${actionName}中...` }) const okIds = [] const failIds = [] const tasks = uniq.map(async (id) => { try { const res = await ajax.post({ url: 'castingChatDeleteRecord', method: 'post', data: { id } }) // 可能返回:res.data.id / res.data.data.id / 直接就是 {id:xxx} const rid = res?.data?.id || res?.data?.data?.id || res?.id || '' const isOk = res?.data?.result === 'success' || !!rid if (isOk) okIds.push(String(rid || id)) else failIds.push(String(id)) } catch { failIds.push(String(id)) } }) try { await Promise.allSettled(tasks) } finally { // ✅ 优先使用服务端返回的 id;没有则用原 id const delIds = okIds.length ? okIds : forceLocal ? uniq : [] if (delIds.length) removeLocalByIds(delIds) if (!silent) { const ok = okIds.length const fail = uniq.length - ok uni.showToast({ title: `${actionName}成功:${ok},失败:${fail}`, icon: 'none' }) } if (!silent) uni.hideLoading() } } function onDeleteOne({ castingChatId, messageKey }) { const msg = messages.value.find((m) => String(m.id || '') === String(castingChatId || '')) || findMessageByKey(messageKey) if (!msg || !isMyMsg(msg)) return uni.showToast({ title: '只能删除自己的消息', icon: 'none' }) if (!castingChatId) return uni.showToast({ title: '该消息无法删除', icon: 'none' }) uni.showModal({ title: '提示', content: '确认删除这条消息?', success: async (res) => { if (!res.confirm) return await deleteOrRecallByIds([castingChatId], '删除', false, false) } }) } function onRecallOne({ castingChatId, messageKey }) { const msg = messages.value.find((m) => String(m.id || '') === String(castingChatId || '')) || findMessageByKey(messageKey) if (!msg || !isMyMsg(msg)) return uni.showToast({ title: '只能撤回自己的消息', icon: 'none' }) if (!canRecallMsg(msg)) return uni.showToast({ title: '撤回失败', icon: 'none' }) if (!castingChatId) return uni.showToast({ title: '该消息无法撤回', icon: 'none' }) uni.showModal({ title: '提示', content: '确认撤回这条消息?', success: async (res) => { if (!res.confirm) return await deleteOrRecallByIds([castingChatId], '撤回', false, false) } }) } function confirmMultiDelete() { if (!selectedMessages.value.length) return uni.showToast({ title: '请先选择消息', icon: 'none' }) if (selectedMessages.value.some((m) => !m.isUser)) return uni.showToast({ title: '只能删除自己的消息', icon: 'none' }) uni.showModal({ title: '提示', content: `确认删除已选的 ${selectedMessages.value.length} 条消息?`, success: async (res) => { if (!res.confirm) return await deleteOrRecallByIds(selectedCastingChatIds.value, '删除', false, false) exitMultiSelect() } }) } function confirmMultiRecall() { if (!selectedMessages.value.length) return uni.showToast({ title: '请先选择消息', icon: 'none' }) if (selectedMessages.value.some((m) => !m.isUser)) return uni.showToast({ title: '只能撤回自己的消息', icon: 'none' }) if (selectedMessages.value.some((m) => !canRecallMsg(m))) return uni.showToast({ title: '撤回失败', icon: 'none' }) uni.showModal({ title: '提示', content: `确认撤回已选的 ${selectedMessages.value.length} 条消息?`, success: async (res) => { if (!res.confirm) return await deleteOrRecallByIds(selectedCastingChatIds.value, '撤回', false, false) exitMultiSelect() } }) } /** ✅ 阅后即焚:气泡组件通知删除 */ async function onBurnAfterReading({ castingChatId }) { if (!castingChatId) return // ✅ 阅后即焚:强制本地删除,避免接口返回异常导致“删不掉” await deleteOrRecallByIds([castingChatId], '删除', true, true) } /** =============== 多选收藏 =============== */ 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 { 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)) }) } /** =============== 发送定位(sharedspace 回传) =============== */ async function sendLocationMessage(loc) { if (!consumerId.value || !chatConsumerId.value) return uni.showToast({ title: '缺少聊天对象', icon: 'none' }) const payload = { msgType: 'location', name: loc?.name || '位置', address: loc?.address || '', latitude: Number(loc?.latitude || 0), longitude: Number(loc?.longitude || 0), clientId: genClientId(), ts: Date.now() } const content = safeStringify(payload) localEcho(content, payload.clientId, { burnAfterReading: false }) try { const saveRes = await ajax.post({ url: 'castingChat', data: { senderId: consumerId.value, receiverId: chatConsumerId.value, content } }) const newId = saveRes?.data?.id if (newId && payload.clientId) bindServerIdToLocalMessage(payload.clientId, newId) socket.value?.send(JSON.stringify({ type: 'castingChat', id: newId || 'tmp_' + Date.now(), burnAfterReading: false })) } catch { uni.showToast({ title: '发送定位失败', icon: 'none' }) } } function openLocation() { 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.navigateTo({ url: '/pages/sociAlize/sharedspace/sharedspace?chatConsumerId=' + encodeURIComponent(chatConsumerId.value || ''), success: (res) => { res.eventChannel.on('sendLocation', async (loc) => { await sendLocationMessage(loc) }) } }) } /** =============== 发送文本 =============== */ 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) socket.value?.send(JSON.stringify({ type: 'castingChat', burnAfterReading: !!Postreadingburn.value, id: newId || 'tmp_' + Date.now() })) } catch { 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: '上传中...' }) const burn = !!Postreadingburn.value const option = { senderId: consumerId.value, receiverId: chatConsumerId.value, content: text || '', type, showName: showName || '', burnAfterReading: burn } 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, { id: newId || '', burnAfterReading: burn }) socket.value?.send(JSON.stringify({ type: 'castingChat', id: newId || 'tmp_' + Date.now(), burnAfterReading: burn })) } catch { uni.showToast({ title: '上传失败', icon: 'none' }) } finally { uni.hideLoading() } } function onInputFocus() {} 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 = '' }) } 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 {} 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: false })) } 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((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 {} } 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 {} } 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 {} } if (option.forward) { try { const p = JSON.parse(decodeURIComponent(option.forward)) pendingForward.value = p || null } catch { 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: () => {}, onCallReceived: () => {}, onCallBegin: () => {}, onCallEnd: () => {} }) } }) onUnmounted(() => { VideoCallsService.removeEventListeners({}) }) onBeforeUnmount(() => { if (socket.value) socket.value.close() }) /** =============== 文件助手本地缓存 & 历史(保持原逻辑) =============== */ 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 { return [] } } function saveFileTransferCache(uid, list) { if (!uid) return try { uni.setStorageSync(fileTransferKey(uid), JSON.stringify(list || [])) } catch {} } 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.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(), burnAfterReading: false } } 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 { 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(',') 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 { contentStr = raw } } else { contentStr = safeStringify(raw) } let cid = '' try { const p = JSON.parse(contentStr) cid = p.clientId || '' } catch {} localEcho(contentStr, cid) try { const res = await ajax.post({ url: 'castingChat', data: { senderId: consumerId.value, receiverId: chatConsumerId.value, content: contentStr } }) const newId = res?.data?.id if (newId && cid) bindServerIdToLocalMessage(cid, newId) socket.value?.send(JSON.stringify({ type: 'castingChat', id: newId || 'tmp_' + Date.now(), burnAfterReading: false })) } catch { 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(全量)

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"> <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-name">{{ locationName }}</view> <view class="loc-addr">{{ locationAddress }}</view> <view class="loc-btn">定位地图</view> </view> </template> <!-- ✅ 链接消息(type=5 或 msgType=link) --> <template v-else-if="isLink"> <view class="link-card" @click.stop="openLink" @tap.stop="openLink"> <view class="link-title">链接</view> <view class="link-url">{{ linkUrl }}</view> <view class="link-ops"> <view class="link-op" @click.stop="copyPlain(linkUrl)">复制</view> <view class="link-op link-op-primary" @click.stop="openLink">打开</view> </view> </view> </template> <!-- 文件类消息:图片/视频/语音/文件 --> <template v-else-if="isFileLike"> <view v-if="fileType === 1"> <image :src="displayFileUrl" style="max-width: 480rpx; max-height: 560rpx; border-radius: 8rpx" mode="widthFix" @click.stop="onClickBurnFile('image')" /> <view v-if="isBurnMedia" class="burn-tag">阅后即焚</view> </view> <view v-else-if="fileType === 2" style="width: 520rpx; max-width: 75vw"> <view v-if="isBurnMedia" class="burn-video-cover" @click.stop="onClickBurnFile('video')"> <image :src="BURN_MOSAIC_IMG" mode="aspectFill" style="width: 100%; height: 300rpx; border-radius: 8rpx" /> <view class="burn-play">▶</view> <view class="burn-tag burn-tag--abs">阅后即焚</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> <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.stop="onClickBurnFile('file')">{{ fileShowName || '文件' }}</text> <view v-if="isBurnMedia" class="burn-tag burn-tag--inline">阅后即焚</view> </view> </template> <!-- 合并转发(type=6 或 msgType forward/forward_record) --> <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 topMenuItems" :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 bottomMenuItems" :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> <!-- ✅ 阅后即焚视频弹窗(退出即删除) --> <up-popup :show="burnVideoShow" @close="closeBurnVideo" mode="center" :closeable="true" :safeAreaInsetTop="true" closeIconPos="top-right" zIndex="100000" round="12"> <view style="width: 686rpx; background: #000; border-radius: 12rpx; overflow: hidden"> <video v-if="burnVideoUrl" :src="burnVideoUrl" controls style="width: 100%; height: 420rpx" /> </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 }, burnAfterReading: { 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-reading' ]) const BURN_MOSAIC_IMG = 'https://t12.baidu.com/it/u=3229389678,260314789&fm=30&app=106&f=JPEG?w=640&h=960&s=342320B862E7C8EB44B617930300C08D' 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) } 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 {} 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 locationName = computed(() => String(parsed.value?.name || '位置')) const locationAddress = computed(() => String(parsed.value?.address || '')) // ✅ 链接:type=5 或 msgType=link const isLink = computed(() => { const o = parsed.value if (!o) return false if (typeof o === 'object' && (o.msgType === 'link' || Number(o.type) === 5)) return true return false }) const linkUrl = computed(() => { const o = parsed.value if (!o) return '' if (typeof o === 'string') return o return String(o.url || o.link || o.content || o.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 }) // ✅ 合并转发:只认 type=6 / msgType forward* const isForwardRecord = computed(() => { const o = parsed.value if (!o || typeof o !== 'object') return false if (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)) 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 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)) 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 === 'location') return `[位置] ${String(obj.name || obj.address || '').slice(0, 40)}` if (obj.msgType === 'link' || Number(obj.type) === 5) return `[链接] ${String(obj.url || obj.link || obj.content || '').slice(0, 40)}` 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 == 6 || obj.msgType === 'forward' || obj.msgType === 'forward_record') return obj.title || '[聊天记录]' } return String(s).slice(0, 60) } 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 isBurnMedia = computed(() => !!(props.burnAfterReading && props.role !== 'user' && (fileType.value === 1 || fileType.value === 2 || fileType.value === 4))) const displayFileUrl = computed(() => { if (isBurnMedia.value && fileType.value === 1) return BURN_MOSAIC_IMG return 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}` }) 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 openLink() { const u = linkUrl.value if (!u) return // #ifdef H5 window.open(u, '_blank') // #endif // #ifndef H5 try { // App/小程序环境尽量打开外部浏览器 if (typeof plus !== 'undefined' && plus.runtime && plus.runtime.openURL) { plus.runtime.openURL(u) } else { copyPlain(u) uni.showToast({ title: '已复制链接', icon: 'none' }) } } catch { copyPlain(u) } // #endif } 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() if (isLocation.value) openLocationDetail() if (isLink.value) openLink() } const isSelfMessage = computed(() => props.role === 'user') const topMenuItems = computed(() => { if (!isSelfMessage.value) return [{ title: '转发', id: '1' }, { title: '收藏', id: '2' }] return [{ title: '转发', id: '1' }, { title: '收藏', id: '2' }, { title: '删除', id: '3' }] }) const bottomMenuItems = computed(() => { if (!isSelfMessage.value) return [{ title: '引入', id: '4' }, { title: '多选', id: '5' }] return [{ title: '引入', id: '4' }, { title: '多选', id: '5' }, { title: '撤回', id: '6' }] }) 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 { const 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 { uni.showToast({ title: '收藏异常', icon: 'none' }) } } function normalizeContentForForward() { if (typeof props.content === 'string') return props.content return asString(props.content) } function openOrDownload(url, afterOpenCb) { // #ifdef H5 window.open(url, '_blank') afterOpenCb && afterOpenCb() // #endif // #ifndef H5 uni.downloadFile({ url, success: ({ tempFilePath }) => { if (uni.openDocument) { uni.openDocument({ filePath: tempFilePath, success: () => afterOpenCb && afterOpenCb(), fail: () => afterOpenCb && afterOpenCb() }) } else { uni.showToast({ title: '已下载', icon: 'none' }) afterOpenCb && afterOpenCb() } }, fail: () => { uni.showToast({ title: '下载失败', icon: 'none' }) afterOpenCb && afterOpenCb() } }) // #endif } const burnTriggered = ref(false) const burnVideoShow = ref(false) const burnVideoUrl = ref('') function triggerBurnDelete(delayMs = 0) { if (burnTriggered.value) return burnTriggered.value = true const id = props.castingChatId if (!id) return setTimeout(() => { emit('burn-after-reading', { castingChatId: id }) }, Math.max(0, Number(delayMs || 0))) } function onClickBurnFile(kind) { if (!isBurnMedia.value) { if (kind === 'image') return previewImage(fileUrl.value) if (kind === 'video') return (burnVideoUrl.value = fileUrl.value), (burnVideoShow.value = true) if (kind === 'file') return openOrDownload(fileUrl.value) return } if (kind === 'image') { previewImage(fileUrl.value) triggerBurnDelete(6000) return } if (kind === 'video') { burnVideoUrl.value = fileUrl.value burnVideoShow.value = true return } if (kind === 'file') { openOrDownload(fileUrl.value, () => { triggerBurnDelete(0) }) } } function closeBurnVideo() { burnVideoShow.value = false burnVideoUrl.value = '' if (isBurnMedia.value) triggerBurnDelete(0) } function openLocationDetail() { const o = parsed.value || {} const payload = { name: o.name || '位置', address: o.address || '', latitude: Number(o.latitude || 0), longitude: Number(o.longitude || 0) } uni.navigateTo({ url: '/pages/sociAlize/sharedspace/positioning?payload=' + encodeURIComponent(JSON.stringify(payload)) }) } 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 >= 1 && o.type <= 4)) 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 || ''}` } 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}` }) } </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; } .loc-card { width: 520rpx; max-width: 70vw; border-radius: 12rpx; padding: 16rpx 18rpx; background: #fff; box-sizing: border-box; } .loc-name { font-size: 28rpx; color: #111; margin-bottom: 8rpx; font-weight: 600; } .loc-addr { font-size: 24rpx; color: #666; margin-bottom: 14rpx; } .loc-btn { height: 76rpx; border-radius: 10rpx; background: #d89833; color: #fff; display: flex; justify-content: center; align-items: center; } /* ✅ 链接卡片 */ .link-card { width: 520rpx; max-width: 70vw; border-radius: 12rpx; padding: 16rpx 18rpx; background: #fff; box-sizing: border-box; } .link-title { font-size: 28rpx; font-weight: 600; color: #111; margin-bottom: 10rpx; } .link-url { font-size: 24rpx; color: #666; margin-bottom: 14rpx; word-break: break-all; } .link-ops { display: flex; justify-content: flex-end; gap: 16rpx; } .link-op { height: 56rpx; padding: 0 18rpx; border-radius: 10rpx; background: #f3f3f3; display: flex; align-items: center; justify-content: center; font-size: 24rpx; color: #333; } .link-op-primary { background: #d89833; color: #fff; } .burn-tag { margin-top: 8rpx; font-size: 22rpx; color: #fff; background: rgba(216, 152, 51, 0.9); padding: 6rpx 12rpx; border-radius: 999rpx; display: inline-block; } .burn-tag--abs { position: absolute; bottom: 12rpx; right: 12rpx; } .burn-tag--inline { margin-left: 10rpx; } .burn-video-cover { position: relative; } .burn-play { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: 80rpx; height: 80rpx; border-radius: 50%; background: rgba(0, 0, 0, 0.45); display: flex; align-items: center; justify-content: center; color: #fff; font-size: 44rpx; } .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>

3) /pages/sociAlize/Coffee/Contact/collect.vue(全量,含收藏/文件助手/收藏分享/文件分享)

这个版本把你说的 “两个按钮同页、传参区分、咖信/咖密 + fileTransfer 组合、type 分类也要同时正确” 全部打通;并且在 分享模式 下点击条目会弹出 “确定发送当前选中内容”,确认后调用 castingChat + websocket 推送 castingChat 给聊天页。

vue
<!-- /pages/sociAlize/Coffee/Contact/collect.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 class="title-text">{{ title || '收藏' }}</text> </view> <view class="right-box"> <text class="desc-text" /> </view> </view> </view> <!-- 内容区域 --> <view class="page-body"> <view class="page-inner"> <!-- ✅ 顶部:咖信/咖密 Tab(所有模式都显示,文案随 parameter 变化) --> <view class="secret-tabs"> <view v-for="tab in secretTabs" :key="tab.value" :class="['secret-tab-item', { 'secret-tab-item--active': castingSecret === tab.value }]" @click="changeSecret(tab.value)" > {{ tab.label }} </view> </view> <!-- 顶部分类 Tab:只在文件传输助手/文件分享 显示 --> <view v-if="isFileTransfer" class="filter-tabs"> <view v-for="tab in filterTabs" :key="tab.value" :class="['filter-tab-item', { 'filter-tab-item--active': activeType === tab.value }]" @click="changeType(tab.value)" > {{ tab.label }} </view> </view> <scroll-view scroll-y style="height: 84vh" refresher-enabled :refresher-triggered="refresherTriggered" @refresherrefresh="onRefresherRefresh" @scrolltolower="loadMore" > <view v-if="loading && !history.length" class="skeleton-wrapper"> <view v-for="n in 5" :key="n" class="skeleton-card"> <view class="skeleton-line time" /> <view class="skeleton-line content" /> <view class="skeleton-line name" /> </view> </view> <view v-else> <!-- ✅ 分享模式:点击条目选择并发送(不显示 swipe) --> <view v-if="isShareMode"> <view v-for="(item, index) in history" :key="item.id || index" :class="['collect-card', { 'collect-card--selected': String(selectedId) === String(item.id) }]" @click="onPickItem(item)" > <view class="collect-card__time">{{ gettime(item.createTime) }}</view> <view class="collect-card__body"> <view v-if="isFile(item)" class="collect-file"> <view class="collect-file__info"> <text class="collect-file__name">{{ getFileShowName(item) }}</text> <text v-if="formatSize(item.fileSize)" class="collect-file__size">{{ formatSize(item.fileSize) }}</text> </view> </view> <view v-else-if="isImage(item)" class="collect-image-wrapper" @click.stop="previewImage(item)"> <image class="collect-image" :src="webProjectUrl + item.fileName" mode="aspectFill" /> </view> <view v-else class="collect-text">{{ getTextContent(item) }}</view> </view> <view class="collect-card__nickname">{{ item.nickname || '' }}</view> </view> </view> <!-- ✅ 浏览模式:保留原 swipe(分享/删除) --> <view v-else> <uni-swipe-action> <uni-swipe-action-item v-for="(item, index) in history" :key="item.id || index" :right-options="options" @click="handleSwipeClick($event, item)"> <view class="collect-card"> <view class="collect-card__time">{{ gettime(item.createTime) }}</view> <view class="collect-card__body"> <view v-if="isFile(item)" class="collect-file"> <view class="collect-file__info"> <text class="collect-file__name">{{ getFileShowName(item) }}</text> <text v-if="formatSize(item.fileSize)" class="collect-file__size">{{ formatSize(item.fileSize) }}</text> </view> </view> <view v-else-if="isImage(item)" class="collect-image-wrapper" @click="previewImage(item)"> <image class="collect-image" :src="webProjectUrl + item.fileName" mode="aspectFill" /> </view> <view v-else class="collect-text">{{ getTextContent(item) }}</view> </view> <view class="collect-card__nickname">{{ item.nickname || '' }}</view> </view> </uni-swipe-action-item> </uni-swipe-action> </view> <view v-if="loadingMore" class="list-footer">加载中...</view> <view v-else-if="noMore && history.length" class="list-footer">没有更多了</view> </view> </scroll-view> </view> </view> <!-- ✅ 分享确认弹窗(截图同款) --> <up-popup :show="sendPopupShow" mode="center" @close="sendPopupShow = false" :closeable="true" closeIconPos="top-right" zIndex="99999" round="12"> <view class="send-popup"> <view class="send-popup__title">确定发送当前选中内容</view> <view class="send-popup__btns"> <view class="send-btn send-btn--cancel" @click="sendPopupShow = false">取消</view> <view class="send-btn send-btn--ok" @click="confirmSend">发送</view> </view> </view> </up-popup> </view> </view> </view> </template> <script setup> import { ref, inject, computed, onBeforeUnmount } 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' import ajax from '@/api/ajax' import WsRequest from '@/utils/websockets.js' const store = useMainStore() const { theme } = storeToRefs(store) const statusBarHeight = ref(getStatusBarHeight()) const headerBarHeight = ref(getTitleBarHeight()) const title = ref('') const isFileTransfer = ref(false) const isShareMode = ref(false) // ✅ 分享模式需要:当前聊天对象 const chatConsumerId = ref('') // ✅ 咖信/咖密 const castingSecret = ref(false) // ✅ fileTransfer 下分类 tab const filterTabs = ref([ { label: '全部', value: -1 }, { label: '文字', value: 0 }, { label: '图片', value: 1 }, { label: '视频', value: 2 }, { label: '语音', value: 3 }, { label: '文件', value: 4 }, { label: '链接', value: 5 } ]) const activeType = ref(-1) const navigateBack = () => uni.navigateBack() const options = ref([ { text: '分享', style: { backgroundColor: '#ffaa00', color: '#ffffff', height: '80rpx', width: '120rpx', borderRadius: '12rpx' } }, { text: '删除', style: { backgroundColor: '#dd524d', color: '#ffffff', height: '80rpx', width: '120rpx', borderRadius: '12rpx' } } ]) const history = ref([]) const rowStartIdx = ref(0) const pageSize = ref(20) const noMore = ref(false) const loading = ref(false) const loadingMore = ref(false) const refresherTriggered = ref(false) const webProjectUrl = inject('webProjectUrl') || '' // ✅ 顶部“咖信/咖密”文案随 parameter 动态变化 const secretTabs = computed(() => { const p = String(title.value || '') // 分享模式文案 if (p === '收藏分享') return [{ label: '咖信收藏分享', value: false }, { label: '咖密收藏分享', value: true }] if (p === '文件分享') return [{ label: '咖信文件分享', value: false }, { label: '咖密文件分享', value: true }] // 浏览模式文案 if (isFileTransfer.value) return [{ label: '文件传输助手', value: false }, { label: '咖密文件传输助手', value: true }] return [{ label: '咖信收藏', value: false }, { label: '咖密收藏', value: true }] }) function changeSecret(v) { if (castingSecret.value === v) return castingSecret.value = !!v Retrievehistorical(true) } function changeType(v) { if (activeType.value === v) return activeType.value = v Retrievehistorical(true) } const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'] function isImage(item) { if (!item || !item.fileName) return false const name = String(item.fileName).toLowerCase() return imageExts.some((ext) => name.endsWith(ext)) } function isFile(item) { if (!item) return false if (Number(item.type) === 4) return true if (!item.fileName) return false const name = String(item.fileName).toLowerCase() if (name.endsWith('/null')) return false return !isImage(item) } function getFileShowName(item) { if (!item) return '文件' if (item.showName) return item.showName if (item.fileName) { const arr = String(item.fileName).split('/') return arr[arr.length - 1] || '文件' } return '文件' } function formatSize(size) { if (size == null || size === '') return '' if (typeof size === 'string') return size const n = Number(size) if (Number.isNaN(n)) return '' if (n < 1024) return n + 'B' if (n < 1024 * 1024) return (n / 1024).toFixed(1) + 'KB' return (n / 1024 / 1024).toFixed(1) + 'MB' } function getTextContent(item) { if (!item) return '' const c = item.content if (typeof c === 'string' && c) { try { const obj = JSON.parse(c) if (obj && typeof obj.text === 'string' && obj.text) return obj.text if (obj && typeof obj.content === 'string' && obj.content) return obj.content if (obj && (obj.url || obj.link)) return String(obj.url || obj.link) } catch {} return c } if (c && typeof c === 'object') { if (typeof c.text === 'string' && c.text) return c.text if (typeof c.content === 'string' && c.content) return c.content if (c.url || c.link) return String(c.url || c.link) } if (typeof item.text === 'string' && item.text) return item.text if (typeof item.msg === 'string' && item.msg) return item.msg if (typeof item.showName === 'string' && item.showName) return item.showName if (typeof item.fileName === 'string' && item.fileName) return item.fileName return '' } function previewImage(item) { if (!item || !item.fileName) return const url = webProjectUrl + item.fileName uni.previewImage({ current: url, urls: [url] }) } function onRefresherRefresh() { refresherTriggered.value = true Retrievehistorical(true) } function loadMore() { if (loading.value || loadingMore.value || noMore.value) return Retrievehistorical(false) } function handleSwipeClick(e, item) { const { content } = e if (!content || !content.text) return const txt = content.text if (txt === '删除') deleteItem(item) else if (txt === '分享') shareItem(item) } function shareItem(item) { const text = getTextContent(item) if (!text) { return uni.showToast({ title: '暂无可分享内容', icon: 'none' }) } uni.setClipboardData({ data: text, success() { uni.showToast({ title: '内容已复制,可粘贴分享', icon: 'none' }) } }) } async function Retrievehistorical(reset = false) { const consumerId = uni.getStorageSync('consumerId') if (!consumerId) return uni.showToast({ title: '请先登录', icon: 'none' }) if (reset) { rowStartIdx.value = 0 noMore.value = false } const isFirstPage = rowStartIdx.value === 0 if (isFirstPage) loading.value = true else loadingMore.value = true const option = { consumerId, fileTransfer: !!isFileTransfer.value, castingSecret: !!castingSecret.value, rowStartIdx: rowStartIdx.value, rowCount: pageSize.value } // ✅ 只在 fileTransfer 模式下按 type 分类;全部(-1)时不传 type if (isFileTransfer.value && activeType.value !== -1) { option.type = activeType.value } try { const res = await ajax.post({ url: 'castingChatCollectList', data: option, method: 'post' }) const ok = res && res.data && res.data.result === 'success' if (ok) { const arr = res.data.castingChatCollectArray || [] if (reset) history.value = arr else history.value = history.value.concat(arr) if (arr.length < pageSize.value) noMore.value = true else rowStartIdx.value += arr.length } else { uni.showToast({ title: res?.data?.msg || '拉取失败', icon: 'none' }) } } catch (e) { uni.showToast({ title: '网络错误', icon: 'none' }) } finally { loading.value = false loadingMore.value = false if (refresherTriggered.value) refresherTriggered.value = false } } function deleteItem(item) { const { id } = item || {} if (!id) return uni.showModal({ title: '提示', content: '确定要删除吗?', success: async (res) => { if (!res.confirm) return try { const resp = await ajax.post({ url: 'deleteCastingChatCollect', data: { id } }) if (resp?.data?.result === 'success') { const index = history.value.findIndex((it) => String(it.id) === String(id)) if (index !== -1) history.value.splice(index, 1) uni.showToast({ title: '删除成功', icon: 'none' }) } else { uni.showToast({ title: resp?.data?.msg || '删除失败', icon: 'none' }) } } catch { uni.showToast({ title: '删除异常', icon: 'none' }) } } }) } function gettime(val) { if (!val) return '' const date = new Date(val * 1000) const y = date.getFullYear() let MM = date.getMonth() + 1 MM = MM < 10 ? '0' + MM : MM let d = date.getDate() d = d < 10 ? '0' + d : d let h = date.getHours() h = h < 10 ? '0' + h : h let m = date.getMinutes() m = m < 10 ? '0' + m : m let s = date.getSeconds() s = s < 10 ? '0' + s : s return `${y}-${MM}-${d} ${h}:${m}:${s}` } /** ================= 分享模式:选择并发送到 chat ================= */ const selectedId = ref('') const selectedItem = ref(null) const sendPopupShow = ref(false) 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 } } catch {} 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 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 } function packFileContent({ type, playURL, showName, fileName, text, clientId }) { return safeStringify({ _kind: 'file', type, playURL, showName, fileName, text, clientId: clientId || '' }) } // ✅ 把收藏/文件助手条目转换成聊天可发送的 content 字符串 function buildChatContentFromCollectItem(it) { if (!it) return '' const cid = genClientId() // 1) 优先使用 content(但补 clientId/ts) if (it.content !== undefined && it.content !== null && it.content !== '') { const raw = typeof it.content === 'string' ? it.content : safeStringify(it.content) const obj = safeParseDeep(raw) if (obj && typeof obj === 'object') { // text if (obj.msgType === 'text') { obj.clientId = cid obj.ts = Date.now() return safeStringify(obj) } // link if (obj.msgType === 'link' || Number(obj.type) === 5) { obj.clientId = cid obj.ts = Date.now() return safeStringify(obj) } // file-like if (obj._kind === 'file' || (Number(obj.type) >= 1 && Number(obj.type) <= 4)) { obj.clientId = cid return safeStringify(obj) } // forward record if (obj.msgType === 'forward' || obj.msgType === 'forward_record' || Number(obj.type) === 6) { return raw } } // 不是 json 就当纯文本发送(包装成 text) return safeStringify({ msgType: 'text', text: String(raw), clientId: cid, ts: Date.now() }) } // 2) 没有 content:按 type/fileName/playUrl 打包成 file const t = Number(it.type || 4) const play = it.playUrl || it.playURL || it.fileName || '' const playURL = buildUrl(webProjectUrl, play) const showName = it.showName || getFileShowName(it) || '' const fileName = it.fileName || '' return packFileContent({ type: t, playURL, showName, fileName, text: '', clientId: cid }) } // websocket:给聊天页推送 castingChat(和 chat.vue 一致) const WS_HOST = ref('ws://47.99.182.213:7018/ws') const ws = ref(null) const wsReady = ref(false) const wsQueue = ref([]) function ensureWs() { const consumerId = uni.getStorageSync('consumerId') || '' if (!consumerId) return if (ws.value) return const wsUrl = `${WS_HOST.value}/${consumerId}` ws.value = new WsRequest(wsUrl, 10000) ws.value.onOpen(() => { wsReady.value = true const arr = wsQueue.value.slice() wsQueue.value = [] arr.forEach((m) => ws.value?.send(JSON.stringify(m))) }) } function wsSend(msg) { ensureWs() if (!ws.value) return if (wsReady.value) ws.value.send(JSON.stringify(msg)) else wsQueue.value.push(msg) } onBeforeUnmount(() => { try { ws.value?.close && ws.value.close() } catch {} }) function onPickItem(it) { if (!it?.id) return selectedId.value = it.id selectedItem.value = it sendPopupShow.value = true } async function confirmSend() { const consumerId = uni.getStorageSync('consumerId') || '' if (!consumerId) return uni.showToast({ title: '请先登录', icon: 'none' }) if (!chatConsumerId.value) return uni.showToast({ title: '缺少聊天对象', icon: 'none' }) if (!selectedItem.value) return uni.showToast({ title: '请先选择内容', icon: 'none' }) const content = buildChatContentFromCollectItem(selectedItem.value) if (!content) return uni.showToast({ title: '内容异常', icon: 'none' }) sendPopupShow.value = false uni.showLoading({ title: '发送中...' }) try { // 1) 保存消息 const saveRes = await ajax.post({ url: 'castingChat', method: 'post', data: { senderId: consumerId, receiverId: chatConsumerId.value, content } }) const newId = saveRes?.data?.id || '' // 2) websocket 推送(让 chat 页实时收到) wsSend({ type: 'castingChat', id: newId || 'tmp_' + Date.now(), burnAfterReading: false }) uni.showToast({ title: '发送成功', icon: 'none' }) setTimeout(() => { uni.navigateBack() }, 200) } catch (e) { uni.showToast({ title: '发送失败', icon: 'none' }) } finally { uni.hideLoading() } } // 页面入参:parameter onLoad((option) => { // parameter: 收藏 / 文件传输助手 / 收藏分享 / 文件分享 const p = option?.parameter ? decodeURIComponent(option.parameter) : '' title.value = p || '收藏' chatConsumerId.value = option?.chatConsumerId ? decodeURIComponent(option.chatConsumerId) : '' isShareMode.value = p === '收藏分享' || p === '文件分享' // fileTransfer 规则 if (p === '文件传输助手' || p === '文件分享') isFileTransfer.value = true else isFileTransfer.value = false // 初始:全部 activeType.value = -1 castingSecret.value = false Retrievehistorical(true) }) </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; } .page-body { width: 100%; display: flex; justify-content: center; align-items: flex-start; } .page-inner { width: 94%; height: 100%; } /* ✅ 咖信/咖密 tabs */ .secret-tabs { margin-top: 16rpx; display: flex; flex-direction: row; align-items: center; gap: 24rpx; } .secret-tab-item { font-size: 26rpx; color: #666; padding-bottom: 8rpx; } .secret-tab-item--active { color: #ff9900; border-bottom: 4rpx solid #ff9900; } /* fileTransfer 分类 tabs */ .filter-tabs { margin-top: 12rpx; margin-bottom: 8rpx; display: flex; flex-direction: row; align-items: center; flex-wrap: wrap; } .filter-tab-item { margin-right: 24rpx; margin-bottom: 8rpx; font-size: 26rpx; color: #666; padding-bottom: 8rpx; } .filter-tab-item--active { color: #ff9900; border-bottom: 4rpx solid #ff9900; } .collect-card { width: 100%; border-radius: 12rpx; background-color: #ffffff; box-sizing: border-box; padding: 16rpx 20rpx; margin-top: 16rpx; display: flex; flex-direction: column; } /* ✅ 选中高亮(截图灰底效果) */ .collect-card--selected { background: #e8e8e8; } .collect-card__time { font-size: 24rpx; color: #9c9c9c; } .collect-card__body { margin-top: 12rpx; } .collect-text { font-size: 28rpx; line-height: 1.6; color: #333; white-space: pre-wrap; word-break: break-all; } .collect-image-wrapper { border-radius: 12rpx; overflow: hidden; width: 180rpx; height: 180rpx; } .collect-image { width: 100%; height: 100%; display: block; } .collect-file { flex-direction: row; align-items: center; display: flex; } .collect-file__info { flex: 1; display: flex; flex-direction: column; } .collect-file__name { font-size: 28rpx; color: #333; } .collect-file__size { margin-top: 4rpx; font-size: 24rpx; color: #999; } .collect-card__nickname { margin-top: 12rpx; font-size: 24rpx; color: #666; } .list-footer { text-align: center; color: #999; font-size: 24rpx; margin: 20rpx 0; } .skeleton-wrapper { padding-top: 16rpx; } .skeleton-card { width: 100%; height: 200rpx; border-radius: 12rpx; background-color: #ffffff; display: flex; flex-direction: column; justify-content: center; padding: 0 20rpx; box-sizing: border-box; margin-top: 16rpx; } .skeleton-line { background: #f0f0f0; border-radius: 8rpx; overflow: hidden; position: relative; margin-bottom: 12rpx; } .skeleton-line::after { content: ''; position: absolute; left: -100%; top: 0; width: 60%; height: 100%; background: linear-gradient( 90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0) ); animation: skeleton-loading 1.2s infinite; } .skeleton-line.time { width: 40%; height: 20rpx; } .skeleton-line.content { width: 90%; height: 40rpx; } .skeleton-line.name { width: 30%; height: 24rpx; } @keyframes skeleton-loading { 0% { transform: translateX(0); } 100% { transform: translateX(160%); } } /* ✅ 发送确认弹窗 */ .send-popup { width: 686rpx; background: #fff; border-radius: 12rpx; padding: 26rpx 26rpx 22rpx; box-sizing: border-box; } .send-popup__title { font-size: 30rpx; color: #333; text-align: center; margin-bottom: 26rpx; } .send-popup__btns { display: flex; justify-content: space-between; align-items: center; gap: 18rpx; } .send-btn { flex: 1; height: 76rpx; border-radius: 12rpx; display: flex; justify-content: center; align-items: center; font-size: 28rpx; } .send-btn--cancel { background: #f2f2f2; color: #333; } .send-btn--ok { background: #d89833; color: #fff; } </style>

如果你接下来要我 把“收藏分享/文件分享”做成支持多选 + 合并发送/逐条发送(类似你 chat 多选转发那套),我也可以直接在这个 collect.vue 上继续扩展(同样全量给你可复制版本)。

Compartilhar esta Q&A