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

Creato il: 21 dicembre 2025

Risposto usando GPT-5.2 Thinking da Chat01

Domanda

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页面的内容模块了 这个我会你给发送图片参考的

Pensiero

Clarifying chat modification requirements

The user is asking for a series of code updates: fixing the "burn-after-reading" feature, adding buttons for "收藏分享" and "文件分享" in the attachment panel. Both buttons should lead to the collect page, which needs to be updated to support four distinct modes, including filters and parameter distinctions based on the selected mode.

Fixing burn-after-reading issue with deletion

The user mentioned that the burn-after-reading deletion isn't working when clicked. The problem seems related to the deleteOrRecallByIds function, and specifically with the API endpoint handling it. It looks like the method may not be passing the correct ID for the message deletion, which is causing the issue.

Considering possible receiver-side issue for burn after reading

It seems that the server might need to call a different endpoint for the receiver's deletion. The castingChatDeleteRecord might only allow the sender, but burn-after-reading would require both sender and receiver to delete the record. The issue could also involve mismatches in message IDs, like localId vs. id discrepancies during deletion.

Exploring potential reasons for deletion failure

For burn-after-reading, it seems the component might unmount before the action is triggered after 6 seconds. There's also the possibility that when "silent" is true, the API doesn't show updates, leading the user to believe the message wasn’t deleted. For peer messages, the issue might relate to permissions, as only the sender may be allowed to delete, not the receiver.

Addressing potential message deletion issue

It seems the issue lies in the handling of peer messages and the "burn-after-reading" trigger. The deletion logic might not be properly aligned with the conditions set for when peer messages are displayed or when the receiver clicks to delete a message. The server might not be responding as expected, and messages might not be removed because of mismatches in message IDs or improper API handling.

Improving message deletion handling

To address the issue, I'll ensure burnTriggered is set per message. After the burn-after-reading call, messages should be removed immediately in Chat.vue, even if the API fails. I'll ensure that API responses are handled correctly and sequentially, and only delete messages if the API returns a success response. If silent, we can delete the message regardless.

Handling burn-after-reading removal and UI changes

When burn-after-reading is triggered, I’ll immediately remove the message locally (optimistically), then try to delete it on the server. If the server fails, I’ll keep it removed. I’ll also adjust the seenServerIds and implement retries. Additionally, I’ll update the attachment panel to add two buttons for parameter-based navigation and modify the collect page with relevant changes to display titles and tabs based on the provided parameters.

Refining button behavior for sharing and collection

For the "收藏分享" and "文件分享" buttons, I’ll make sure each opens the correct collection page based on the fileTransfer parameter. We'll allow toggling between normal and coffee secret modes (castingSecret). For "文件分享", the page will be linked to fileTransfer=true, and for "收藏分享", it'll be linked to fileTransfer=false. Both options will allow toggling coffee secret.

Clarifying selection and sharing behavior in the collect page

The user likely wants to forward items from the collect list to chat. The current collect list only has options for swipe actions to share or delete, but doesn't include item selection for sending to chat. I may need to implement item selection and a confirmation to send to the chat. I'll update the code to support this.

Planning integration and message sending from collect page

I need to integrate the collect page with the chat to allow selecting an item and sending it back to the chat via eventChannel. When opening from chat, certain parameters will be passed. The item selected will trigger a confirmation, then the content will be sent either via websockets or server. I'll implement this flow and ensure compatibility with existing message sending methods.

Structuring collect page functionality for chat integration

I’ll implement the flow for the collect page opening from chat, with appropriate flags like fileTransfer (true/false) and chat signals. Based on the parameters, collect pages will show secret tabs and allow clicking and sending content. I'll ensure the correct functionality for share buttons, and implement a unified mode system to recognize different types (collect, file share). We’ll manage event channels to handle sending selected content back to the chat.

Refining collect page UI and type filtering for consistency

For collect.vue, I’ll implement the secretMode logic and switch between the coffee modes "咖信" and "咖密" depending on tab selection. The type tabs will show both in file transfer and collect modes, even though they originally only appeared in file transfer. I’ll ensure the activeType filters are handled properly, fixing any bugs with type params and aligning them with expected API values. This will make sure the behavior is consistent across different pages and modes.

Clarifying type handling for text, links, and forward records

It seems type values 1-4 correspond to image, video, voice, and file items. For text, content is used directly, and links are categorized as type 5 in the collect list, but not in chat bubbles where type 5 means a forward record. We'll treat links as text and possibly omit "type" for "all" filtering. We need to adjust these mappings for consistency across features.

Resolving filter tab and type conflicts

There's a lot going on with type values: in chat, type 5 might be for forward records, not links. The confusion stems from how the API handles types and how we map those for filtering and displaying views. We’ll maintain the existing bubble logic, set type 0 for text, adjust for filter map values, and ensure compatibility with backend requirements. The goal is to handle types properly without breaking functionality.

Handling filter tab value mapping

It seems like the filter tab values and the API type parameter need to be adjusted to ensure consistency in both filtering and message rendering. I’ll map values as 0 for “全部” (all), 1 for “文字” (text), and other types accordingly: text, image, voice, video, file, and link. This should align text with 0 while keeping the system's behavior intact.

Refining item type handling and sharing logic

The logic for handling file types and the sharing flow needs refinement. I’ll broaden the isFile check to cover more file-like types, ensuring it works correctly for file names, media, and links. I’ll also implement sharing logic: for selecting items, confirming the selection, and sending it through an event channel. This includes ensuring deduplication and proper handling of client IDs for text and file messages.

Improving deletion functionality and handling edge cases

I'll optimize the deletion flow by adding a parameter for optimistic deletion, ensuring media like voice are handled with the burn-after-reading feature. The user's reported issues with not deleting files may be due to id mismatches, so I'll refine how IDs are managed. I'll also review the existing logic for triggers and make adjustments where needed.

Finalizing modifications for chat and file transfer features

I’ll implement both id and castingChatId to ensure safe deletion. I'll include consumerId in requests and update chat files accordingly. The user wants full content, so I’ll ensure all modifications are present. I'll account for different modes (e.g., picker mode) and ensure UI matches screenshots, like using modals for confirmation. We’ll set proper labels based on file transfer and adapt behavior for each scenario.

Determining strategy for code output and file sizes

I think the best approach is to provide full files as downloads, especially given the user’s request for direct copying. We can reduce token usage by minimizing extra explanations and outputting code in a way that fits within the token budget. I’ll generate files programmatically with relevant changes, while ensuring no system limitations are exceeded.

Crafting precise modifications for user-provided code

I'll modify the provided chat.vue and collect.vue files carefully to respect the user's request.

I'll add the necessary functions, event handlers, and UI components. For chat.vue, I'll adjust the click handlers and update the relevant functions for burn deletion. For collect.vue, I'll implement new secret tabs, picker functionality, and adjustments to sendSelected(), along with other specific tweaks.

We'll package everything in three code blocks and ensure no syntax errors.

Thought for 6m 23s

Risposta

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

  • chat.vue(单人聊天页)
    1. 修复阅后即焚删除:点击查看后能本地立刻消失 + 同步调用 castingChatDeleteRecord(更稳)
    2. ✅ 新增底部附件面板两个按钮:收藏分享 / 文件分享
    3. ✅ 打通 collect 页面:从 collect 选择一条 → 回传 → chat 通过 websocket/接口发送出去
  • Bubblechatbox.vue(气泡组件):保留全部功能,仅让阅后即焚删除事件更稳(不改你原展示逻辑)
  • collect.vue(公用收藏/文件分享页面)
    1. 顶部切换:咖信/咖密(对应 castingSecret
    2. 支持 4 种组合:
      • 咖信收藏(fileTransfer=false, castingSecret=false)
      • 咖密收藏(fileTransfer=false, castingSecret=true)
      • 咖信文件分享(fileTransfer=true, castingSecret=false)
      • 咖密文件分享(fileTransfer=true, castingSecret=true)
    3. 下方分类(全部/文字/图片/视频/语音/文件/链接)统一可用
    4. 选择一条弹窗确认“发送”,通过 eventChannel 回传给 chat

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="openCollectSharePicker"> <uni-icons type="star-filled" size="30" /> <view>收藏分享</view> </view> <!-- ✅ 新增:文件分享 --> <view style="display: inline-block; margin-left: 40rpx; text-align: center" @click="openFileSharePicker"> <uni-icons type="folder-filled" 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 } /** ✅ 新增:收藏分享(fileTransfer=false) */ function openCollectSharePicker() { if (Isfiletransfer.value) return if (!agent.value) return uni.showToast({ title: '化身模式暂不支持', icon: 'none' }) if (multiSelectMode.value) return Didpopup.value = false uni.navigateTo({ url: '/pages/sociAlize/Coffee/Contact/collect?parameter=' + encodeURIComponent('收藏分享') + '&fromChat=1&chatConsumerId=' + encodeURIComponent(chatConsumerId.value || ''), success: (res) => { res.eventChannel.on('sendCollect', async (payload) => { const item = payload?.item if (!item) return await sendCollectItemToChat(item) }) } }) } /** ✅ 新增:文件分享(fileTransfer=true) */ function openFileSharePicker() { if (Isfiletransfer.value) return if (!agent.value) return uni.showToast({ title: '化身模式暂不支持', icon: 'none' }) if (multiSelectMode.value) return Didpopup.value = false uni.navigateTo({ url: '/pages/sociAlize/Coffee/Contact/collect?parameter=' + encodeURIComponent('文件分享') + '&fromChat=1&chatConsumerId=' + encodeURIComponent(chatConsumerId.value || ''), success: (res) => { res.eventChannel.on('sendCollect', async (payload) => { const item = payload?.item if (!item) return await sendCollectItemToChat(item) }) } }) } /** ✅ 从 collect 选中项构建可发送 content,并发送到当前聊天 */ function buildContentFromCollectItem(item) { if (!item) return '' // 1) 优先用 content(可能是 text JSON / 普通字符串 / forward JSON 等) if (item.content !== undefined && item.content !== null && item.content !== '') { return typeof item.content === 'string' ? item.content : safeStringify(item.content) } // 2) 否则按文件结构拼(playUrl / fileName / showName / type) const t = Number(item.type || 0) const play = item.playUrl || item.playURL || item.fileName || '' const showName = item.showName || filenameOf(item.fileName || '') || '' const cid = genClientId() // 仅对 1-4 走 file bubble(保持你原聊天类型) const fileType = (t >= 1 && t <= 4) ? t : 4 return packFileContent({ type: fileType, playURL: buildUrl(webProjectUrl.value, play), showName, fileName: item.fileName || '', text: '', clientId: cid }) } function refreshClientIdForResend(contentStr) { let s = typeof contentStr === 'string' ? contentStr : safeStringify(contentStr) const obj = safeParseDeep(s) if (obj && typeof obj === 'object') { // text / location / file 都强制刷新 clientId,避免 ws 去重导致“发不出去” if (obj.msgType === 'text' || obj.msgType === 'location' || obj._kind === 'file') { obj.clientId = genClientId() obj.ts = Date.now() return safeStringify(obj) } // forward 里如果是 text,也给它刷新一下(保持你原逻辑) if (obj.msgType === 'forward' || obj.msgType === 'forward_record') { obj.clientId = genClientId() obj.ts = Date.now() return safeStringify(obj) } } return s } async function sendCollectItemToChat(item) { if (!consumerId.value || !chatConsumerId.value) return uni.showToast({ title: '缺少聊天对象', icon: 'none' }) const burn = !!Postreadingburn.value let content = buildContentFromCollectItem(item) content = refreshClientIdForResend(content) // 本地回显 const parsed = safeParseDeep(content) const cid = parsed && parsed.clientId ? parsed.clientId : '' localEcho(content, cid, { burnAfterReading: burn }) // 入库 + WS 推送 try { const saveRes = await ajax.post({ url: 'castingChat', data: { senderId: consumerId.value, receiverId: chatConsumerId.value, content, burnAfterReading: burn } }) const newId = saveRes?.data?.id if (newId && cid) bindServerIdToLocalMessage(cid, newId) socket.value?.send( JSON.stringify({ type: 'castingChat', id: newId || 'tmp_' + Date.now(), burnAfterReading: burn }) ) } catch { uni.showToast({ title: '发送失败', icon: 'none' }) } } 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() }) } /** =============== 删除/撤回(单条 + 多选共用) =============== */ function removeMessagesByIds(ids = []) { const uniq = Array.from(new Set((ids || []).filter(Boolean).map(String))) if (!uniq.length) return messages.value = messages.value.filter((m) => !uniq.includes(String(m.id || ''))) uniq.forEach((id) => ds.seenServerIds.delete(String(id))) } async function deleteOrRecallByIds(ids = [], actionName = '删除', silent = false, opts = {}) { 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(async (id) => { try { // ✅ 兼容后端字段:同时带 id / castingChatId / consumerId(多带不影响) const resp = await ajax.post({ url: 'castingChatDeleteRecord', method: 'post', data: { id, castingChatId: id, consumerId: consumerId.value } }) // 返回值:可能是 {id:xxx} 或 {result:'success',id:xxx} const ok = resp?.data?.result === 'success' || !!resp?.data?.id || resp?.data === 'success' const delId = String(resp?.data?.id || id) return { ok, id: delId } } catch { return { ok: false, id: String(id) } } }) const results = await Promise.all(tasks) const successIds = results.filter((x) => x.ok).map((x) => x.id) const ok = successIds.length const fail = results.length - ok // ✅ 非阅后即焚:只删除成功的;阅后即焚会在外部先“强制本地删除” if (!opts.skipLocalRemove) { removeMessagesByIds(successIds) } 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 // ✅ 关键:先本地移除,用户立刻看到“焚毁” removeMessagesByIds([castingChatId]) // 后端同步(不弹 loading/toast) await deleteOrRecallByIds([castingChatId], '删除', true, { skipLocalRemove: 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>

2) /components/Bubblechatbox.vue(全量)

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> <!-- 文件类消息:图片/视频/语音/文件 --> <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(() => { // 对方消息:只显示 2 个(转发、收藏) 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>

3) /pages/sociAlize/Coffee/Contact/collect.vue(全量改造版)

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"> <!-- ✅ 咖信/咖密:根据入口显示 “收藏/文件分享” --> <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="switchSecret(tab.value)"> {{ tab.label }} </view> </view> <!-- ✅ 分类 Tab:全部/文字/图片/视频/语音/文件/链接 --> <view 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: 78vh;" 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', { 'collect-card--selected': isSelected(item) }]" @click="onCardClick(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> </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> <!-- ✅ 发送确认弹窗(与截图一致:确定发送当前选中内容) --> <up-popup :show="sendConfirmShow" mode="center" @close="sendConfirmShow = false" :closeable="true" closeIconPos="top-right" zIndex="99999" round="12"> <view class="send-confirm"> <view class="send-confirm__text">确定发送当前选中内容</view> <view class="send-confirm__btns"> <view class="send-confirm__btn cancel" @click="sendConfirmShow = false">取消</view> <view class="send-confirm__btn ok" @click="confirmSend">发送</view> </view> </view> </up-popup> </view> </view> </view> </template> <script setup> import { ref, inject, computed, getCurrentInstance } 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 webProjectUrl = inject('webProjectUrl') || '' // ✅ 页面模式:收藏分享 / 文件分享 / 收藏 / 文件传输助手 const pageMode = ref('收藏') // default const fromChat = ref(false) const chatConsumerId = ref('') // ✅ 是否文件助手(决定 API 的 fileTransfer) const fileTransfer = computed(() => { // 文件分享 / 文件传输助手 => true return pageMode.value === '文件分享' || pageMode.value === '文件传输助手' }) // ✅ 咖密开关(决定 API 的 castingSecret) const castingSecret = ref(false) // ✅ 顶部“咖信/咖密”tab 文案:收藏 vs 文件分享 const secretTabs = computed(() => { const isFile = fileTransfer.value return [{ label: isFile ? '咖信文件分享' : '咖信收藏', value: false }, { label: isFile ? '咖密文件分享' : '咖密收藏', value: true } ] }) function switchSecret(v) { if (castingSecret.value === v) return castingSecret.value = v Retrievehistorical(true) } // ✅ 分类 Tab(全部/文字/图片/视频/语音/文件/链接) // 这里按你现有接口习惯:0=全部,1=文字,2=图片,3=视频,4=语音,5=文件,6=链接 const filterTabs = ref([{ label: '全部', value: 0 }, { label: '文字', value: 1 }, { label: '图片', value: 2 }, { label: '视频', value: 3 }, { label: '语音', value: 4 }, { label: '文件', value: 5 }, { label: '链接', value: 6 } ]) const activeType = ref(0) function changeType(v) { if (activeType.value === v) return activeType.value = v Retrievehistorical(true) } // 返回上一页 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) // ✅ 选择发送(从 chat 打开才启用) const selectedId = ref('') const selectedItem = ref(null) const sendConfirmShow = ref(false) function isSelected(item) { if (!item) return false return String(item.id || '') === String(selectedId.value || '') } function onCardClick(item) { if (!fromChat.value) return if (!item?.id) return selectedId.value = String(item.id) selectedItem.value = item sendConfirmShow.value = true } function confirmSend() { if (!fromChat.value) return if (!selectedItem.value) return const inst = getCurrentInstance() const ec = inst?.proxy?.getOpenerEventChannel?.() ec?.emit('sendCollect', { item: selectedItem.value, mode: pageMode.value, fileTransfer: fileTransfer.value, castingSecret: castingSecret.value, chatConsumerId: chatConsumerId.value }) sendConfirmShow.value = false uni.navigateBack() } /** * 图片判断:根据后缀名 */ 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 或者有 fileName 且不是图片) */ 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 } catch (e) {} 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 (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) } } /** * 分享: * - 如果是从 chat 打开:当作“发送这条”入口 * - 否则:复制文本到剪贴板 */ function shareItem(item) { if (fromChat.value) { return onCardClick(item) } const text = getTextContent(item) if (!text) { return uni.showToast({ title: '暂无可分享内容', icon: 'none' }) } uni.setClipboardData({ data: text, success() { uni.showToast({ title: '内容已复制,可粘贴分享', icon: 'none' }) } }) } /** * 拉取收藏列表(核心:castingSecret + fileTransfer 两个开关都要带上) */ 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: !!fileTransfer.value, castingSecret: !!castingSecret.value, rowStartIdx: rowStartIdx.value, rowCount: pageSize.value, type: activeType.value // 0=全部 } 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 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: resp?.data?.msg || '删除失败', icon: 'none' }) } } catch (err) { 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}` } onLoad((option) => { const p = option?.parameter ? decodeURIComponent(option.parameter) : '收藏' pageMode.value = p title.value = p fromChat.value = option?.fromChat === '1' || option?.fromChat === 'true' chatConsumerId.value = option?.chatConsumerId ? decodeURIComponent(option.chatConsumerId) : '' // 初始拉取 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%; } .secret-tabs { margin-top: 16rpx; display: flex; gap: 22rpx; align-items: center; } .secret-tab-item { font-size: 26rpx; color: #666; padding-bottom: 8rpx; } .secret-tab-item--active { color: #ff9900; border-bottom: 4rpx solid #ff9900; } .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: #f2f2f2; } .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-confirm { width: 686rpx; background: #fff; border-radius: 12rpx; padding: 28rpx 26rpx; box-sizing: border-box; } .send-confirm__text { font-size: 30rpx; color: #111; text-align: center; padding: 24rpx 0; } .send-confirm__btns { display: flex; justify-content: space-between; margin-top: 16rpx; } .send-confirm__btn { width: 280rpx; height: 76rpx; border-radius: 12rpx; display: flex; justify-content: center; align-items: center; font-size: 28rpx; } .send-confirm__btn.cancel { background: #f2f2f2; color: #333; } .send-confirm__btn.ok { background: #d89833; color: #fff; } </style>

如果你接下来把 你接口对 type 的真实枚举(0 到 6 对应哪些)发我一下,我可以把 filterTabstype 参数映射做成 完全跟后端一致(现在这版按你 fileTransfer 历史里 type:0 能拉全量的习惯做的)。

Condividi questa Q&A