这个是chat页面的内容<!-- /pages/sociAlize/ChatDisplayPage/...
Создано: 21 декабря 2025 г.
Отвечено с использованием GPT-5.2 Thinking от Chat01
Создано: 21 декабря 2025 г.
Отвечено с использованием GPT-5.2 Thinking от Chat01
这个是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>
</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="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 { 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<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>
textconst 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>
</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的如果是文件文件分享 就会 咖信文件分享 和咖密文件分享 同时下面的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>
全部 文字 图片 还有 这些都没有区别咖信收藏和咖信收藏 咖信文件分享 和咖密文件分享 你这点击页面从新区别下 这个是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页面的内容模块了 这个我会你给发送图片参考的