Files
phone-app------test1-/pages/Chat/Chat.vue

1830 lines
54 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="status-bar"></view>
<!-- ===== 新建对话类型选择弹窗 ===== -->
<view v-if="showNewChatModal" class="ncd-overlay" @click="closeNewChatModal">
<view class="ncd-card" @click.stop>
<view class="ncd-header">
<view class="ncd-title-eng"><text>NEW</text></view>
<text class="ncd-title-zh">创建对话</text>
<uni-icons type="closeempty" color="#ff0000" size="24" @click="closeNewChatModal"></uni-icons>
</view>
<text>选择对话类型开启全新会话体验</text>
<view class="ncd-options">
<view class="ncd-option ncd-normal">
<view class="ncd-opt-icon">
<uni-icons type="chat" color="#000000" size="30"></uni-icons>
</view>
<view class="ncd-opt-title" @click="selectNormalChat">
<view class="ncd-opt-title-1">普通会话</view>
<view class="ncd-opt-title-2">与AI自由对话,探索任何话题</view>
</view>
<uni-icons type="arrow-right" class="arrow-right-style"></uni-icons>
</view>
<view class="ncd-option ncd-intelligence">
<view class="ncd-opt-icon">
<uni-icons type="star" color="#ffffff" size="30"></uni-icons>
</view>
<view class="ncd-opt-title">
<view class="ncd-opt-title-1">智能体会话</view>
<view class="ncd-opt-title-2">选择专属智能体获得精准专业服务</view>
</view>
<uni-icons type="arrow-right" class="arrow-right-style"></uni-icons>
</view>
</view>
</view>
</view>
<view class="chat-page page-container">
<!-- 弹窗出来时的笼罩层 -->
<view v-if="isChatSidebar" class="mask" @click="handleChatSidebar"></view>
<!-- 抽屉侧边栏 -->
<view class="chat-sidebar" :class="{'sidebar-show' : isChatSidebar}">
<ChatSidebar :chatList="UserConversations" v-model:showNewChatModal="showNewChatModal"
v-model:currentSessionId="currentSessionId" v-model:chatType="ChatType"
@refresh-conversations="takeUserConversations"
@select-chat="isChatSidebar = false">
</ChatSidebar>
</view>
<view class="chat-wrapper">
<view class="chat-hearder">
<view class="chat-btn-group">
<!-- 侧边栏显示按钮 -->
<view class="head-btn" @click="handleChatSidebar">
<view class="iconfont icon-caidan"></view>
</view>
<view class="head-btn" @click="goWorkSpace">
<view class="iconfont icon-wenjianjia"></view>
</view>
<view class="head-btn" @click="goCloudDatabase">
<view class="iconfont icon-cloud"></view>
</view>
<view class="head-btn log-out" @click="logOut">
<view class="iconfont icon-tuichu"></view>
</view>
</view>
</view>
<view class="main-chat">
<scroll-view class="chat-messages" direction="vertical" scroll-y :scroll-into-view="scrollToView"
:upper-threshold="0" :scroll-with-animation="true" @scroll="onScroll">
<!-- 消息加载中弹窗仅会话变化时显示 -->
<view v-if="isLoadingMessages" class="messages-loading-overlay">
<view class="messages-loading-card">
<view class="bubble-loading-dots">
<view class="bubble-dot"></view>
<view class="bubble-dot"></view>
<view class="bubble-dot"></view>
</view>
<text class="messages-loading-text">加载中...</text>
</view>
</view>
<!-- 与AI的对话内容展示 -->
<template v-for="(message, index) in currentMessages" :key="index">
<view v-if="ChatType === 0 && message.role !== 'tool'" class="chat-message" :id="'msg-' + index"
:class="{'message-user':message.role==='user'}">
<!-- <view class="chat-avatar" :class="{'chat-avatar-user':message.role==='user'}">
<image v-if="message.role === 'user' && UserData?.avatar" :src="UserData.avatar"
class="friend-avatar" mode="aspectFill"></image>
<view v-else-if="message.role === 'assistant'" class="iconfont icon-Robot"></view>
<view v-else class="iconfont icon-yonghuziliao"></view>
</view> -->
<view v-if="message.role ==='user'" class="chat-avatar"
:class="{'chat-avatar-user':message.role==='user'}">
<image v-if="message.role === 'user' && UserData?.avatar" :src="UserData.avatar"
class="friend-avatar" mode="aspectFill"></image>
<view v-else class="iconfont icon-yonghuziliao"></view>
<!-- <view v-if="message.role ==='assistant'" class="iconfont icon-Robot"></view> -->
<!-- <view v-if="message.role ==='user'" class="iconfont icon-yonghuziliao"></view> -->
</view>
<view class="chat-content"
:class="{'chat-content-user':message.role==='user', 'chat-content-assistant':message.role==='assistant'}"
@click="handleChatContentClick($event, message)"
v-if="(message.content && String(message.content).trim() !== '') || message._streaming">
<!-- 流式加载中显示跳动的点 -->
<view
v-if="message._streaming && (!message.content || String(message.content).trim() === '')"
class="bubble-loading-dots">
<view class="bubble-dot"></view>
<view class="bubble-dot"></view>
<view class="bubble-dot"></view>
</view>
<!-- 文本内容 -->
<view v-if="parseFileInfo(message.content).textContent"
v-html="pareseMarkdown(parseFileInfo(message.content).textContent)"></view>
<!-- 文件卡片列表 -->
<view v-if="parseFileInfo(message.content).files.length > 0" class="message-file-list">
<view v-for="(file, idx) in parseFileInfo(message.content).files" :key="idx"
class="file-item">
<!-- 图片jpg/png/gif/webp -->
<image
v-if="['jpg','jpeg','png','gif','webp','bmp'].includes(file.extendName.toLowerCase())"
:src="file.url" mode="widthFix" class="message-image"
@click="previewImage(file.url)"></image>
<!-- 普通文件doc/xlsx/zip/pdf -->
<view v-else class="message-file" @click="openFile(file.url)">
<view class="file-icon">📄</view>
<view class="file-name">{{ file.name }}</view>
<view class="file-size">{{ formatFileSize(file.fileSize) }}</view>
</view>
</view>
</view>
</view>
</view>
</template>
<!-- 与好友的对话内容展示 -->
<view v-if="ChatType === 1" class="chat-message" v-for="(message, index) in currentMessages"
:key="index" :id="'msg-' + index" :class="{'message-user':message.sender === UserId}">
<view class="chat-avatar" :class="{'chat-avatar-user':message.sender === UserId}">
<!-- 自己的消息显示自己的头像 -->
<image v-if="message.sender === UserId && UserData?.avatar" :src="UserData.avatar"
class="friend-avatar" mode="aspectFill"></image>
<!-- 好友的消息显示好友的头像 -->
<image v-else-if="takeTalkUserAvatar(message.sender)"
:src="takeTalkUserAvatar(message.sender)" class="friend-avatar" mode="aspectFill">
</image>
<!-- 没有头像时显示默认图标 -->
<view v-else class="iconfont icon-yonghuziliao"></view>
</view>
<view class="chat-content" :class="{'chat-content-user':message.sender === UserId}"
@click="handleChatContentClick($event, message)">
<view v-if="message.content && String(message.content).trim() !== ''"
v-html="pareseMarkdown(message.content)"></view>
<!-- 2. 图片 + 文件 渲染核心新增 -->
<view v-if="message.contentJson" class="message-file-list">
<!-- 遍历文件/图片列表 -->
<view v-for="(file, idx) in JSON.parse(message.contentJson)" :key="idx"
class="file-item">
<!-- 图片jpg/png/gif/webp -->
<image
v-if="['jpg','jpeg','png','gif','webp','bmp'].includes(file.extendName.toLowerCase())"
:src="file.url" mode="widthFix" class="message-image"
@click="previewImage(file.url)"></image>
<!-- 普通文件doc/xlsx/zip/pdf -->
<view v-else class="message-file" @click="openFile(file.url)">
<view class="file-icon">📄</view>
<view class="file-name">{{ file.name }}</view>
<view class="file-size">{{ formatFileSize(file.fileSize) }}</view>
</view>
</view>
</view>
</view>
</view>
<!-- 与群聊的对话内容展示 -->
<view v-if="ChatType === 2" class="chat-message" v-for="(message, index) in currentMessages"
:key="index" :id="'msg-' + index" :class="{'message-user':message.sender === UserId}">
<view class="chat-avatar" :class="{'chat-avatar-user':message.sender === UserId}">
<!-- 自己的消息显示自己的头像 -->
<image v-if="message.sender === UserId && UserData?.avatar" :src="UserData.avatar"
class="friend-avatar" mode="aspectFill"></image>
<!-- 群成员的消息显示群成员的头像 -->
<image v-else-if="groupMemberList.length > 0 && getGroupMemberAvatarById(message.sender)"
:src="getGroupMemberAvatarById(message.sender)" class="friend-avatar" mode="aspectFill">
</image>
<!-- 没有头像时显示默认图标 -->
<view v-else class="iconfont icon-yonghuziliao"></view>
</view>
<view class="chat-content" :class="{'chat-content-user':message.sender === UserId}"
@click="handleChatContentClick($event, message)">
<view v-if="message.message && String(message.message).trim() !== ''"
v-html="pareseMarkdown(message.message)"></view>
<!-- 2. 图片 + 文件 渲染核心新增 -->
<view v-if="message.contentJson" class="message-file-list">
<!-- 遍历文件/图片列表 -->
<view v-for="(file, idx) in JSON.parse(message.contentJson)" :key="idx"
class="file-item">
<!-- 图片jpg/png/gif/webp -->
<image
v-if="['jpg','jpeg','png','gif','webp','bmp'].includes(file.extendName.toLowerCase())"
:src="file.url" mode="widthFix" class="message-image"
@click="previewImage(file.url)"></image>
<!-- 普通文件doc/xlsx/zip/pdf -->
<view v-else class="message-file" @click="openFile(file.url)">
<view class="file-icon">📄</view>
<view class="file-name">{{ file.name }}</view>
<view class="file-size">{{ formatFileSize(file.fileSize) }}</view>
</view>
</view>
</view>
</view>
</view>
<!-- 底部锚点两个交替切换确保流式时每次都能触发滚动到底部 -->
<view id="chat-bottom-a" style="height: 1rpx;"></view>
<view id="chat-bottom-b" style="height: 1rpx;"></view>
</scroll-view>
<view class="chat-interactive-container" :class="{ 'interactive-disabled': isThinking }">
<view class="chat-interactive-group">
<view class="chat-interactive-btn" :class="{ disabled: isThinking }"
@click="!isThinking && uploadPhoto()">拍照上传</view>
<view class="chat-interactive-btn" :class="{ disabled: isThinking }"
@click="!isThinking && uploadFile()">上传文件</view>
</view>
<view class="image-list" v-if="previewFileArray.length > 0">
<view class="image-tags">
<view class="image-tag" v-for="(item, index) in previewFileArray" :key="index">
<text class="tag-name">{{ item.name }}</text>
<text class="tag-close" @click.stop="deleteImage(index)">×</text>
</view>
</view>
</view>
<view class="chat-input-container" :class="{ 'input-disabled': isThinking }">
<textarea class="message-input"
:placeholder="isThinking ? (isSelfSent ? 'AI 正在回复中...' : '电脑正在运行,请稍后') : '输入消息...'"
:disabled="isThinking" v-model="textMessage" auto-height></textarea>
<view v-if="isThinking" class="input-btn-group stop-message-btn" @click="stopConversation">
<view class="iconfont icon-tingzhi"></view>
</view>
<view v-else class="input-btn-group send-message-btn" @click="sendMessage()">
<view class="iconfont icon-fasong"></view>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- ===== 消息详情弹窗 ===== -->
<view v-if="showMessageModal" class="ncd-overlay" @click="closeMessageDetail">
<view class="ncd-card message-detail-card" @click.stop>
<view class="ncd-header">
<text class="ncd-title-zh">消息详情</text>
<uni-icons type="closeempty" color="#ff0000" size="24" @click="closeMessageDetail"></uni-icons>
</view>
<scroll-view class="message-detail-content" scroll-y>
<view v-if="detailLinks.length" class="detail-links-section">
<view class="detail-links-title">文件 ({{ detailLinks.length }})</view>
<view v-for="(link, i) in detailLinks" :key="i" class="detail-link-item"
@click="openLink(link.url)">
<text class="link-text">{{ link.name }}</text>
</view>
</view>
<view v-else class="detail-empty">
<text>无可下载文件</text>
</view>
</scroll-view>
</view>
</view>
<!-- ===== 自定义下载提示层级高于弹窗 ===== -->
<view v-if="downloadToast.show" class="download-toast-overlay" :class="downloadToast.show ? '' : ''">
<view class="download-toast-card" :class="'download-toast-' + downloadToast.type">
<view v-if="downloadToast.type === 'loading'" class="download-toast-icon">
<view class="toast-dot"></view>
<view class="toast-dot"></view>
<view class="toast-dot"></view>
</view>
<text v-else class="download-toast-icon">{{ downloadToast.type === 'success' ? '✓' : '✕' }}</text>
<text class="download-toast-text">{{ downloadToast.message }}</text>
</view>
</view>
</template>
<script setup>
import {
computed,
getCurrentInstance,
nextTick,
onMounted,
ref,
watch
} from 'vue';
import {
onShow,
onUnload
} from '@dcloudio/uni-app';
import ChatSidebar from '@/components/ChatSidebar.vue'
import {
getUserConversations,
getConversationMessages,
addMessageDict,
createWorkspace,
createConversation,
getUserInfo,
getUserAvatar,
conversationMessageDownload
} from '@/utils/cloud-api.js'
import {
getToken,
getCurrentSessionId,
getTaskCallId,
getChatType,
getReceiverId
} from '@/utils/user-info.js'
import snarkdown from 'snarkdown'
// import mpHtml from '@/uni_modules/mp-html/components/mp-html/mp-html.vue'
import {
useSocketStore
} from '@/stores/socket.js';
import {
getChatFriend,
getGroup,
getFriendMessages,
getGroupMessages,
getGroupMemberList,
uploadFileToServer
} from '@/utils/friend-api.js'
import {
useFriendSocketStore
} from '@/stores/friend-socket.js'
import {
chooseFile
} from '@/uni_modules/lime-choose-file'
import socketManager from '../../utils/socket';
const friendSocketStore = useFriendSocketStore()
// 连接实时通讯eriendsocket
const handleFriendConnect = () => {
if (friendSocketStore.isConnected) return
if (!userToken.value || !UserId.value) {
console.warn('Token或UserId未准备好');
return
}
friendSocketStore.connect({
token: userToken.value,
UserId: UserId.value
})
}
//store
const socketStore = useSocketStore()
// 聊天类型ai好友群聊
const ChatType = ref(0)
// marked.setOptions({
// breaks: true, // 自动把换行符转成 <br> 换行
// gfm: true, // 开启 GitHub 标准 Markdown 语法
// })
// 将 Markdown 表格转为 HTML 表格snarkdown 不支持表格语法)
const convertMarkdownTable = (text) => {
const lines = text.split('\n')
let result = []
let inTable = false
let tableRows = []
let alignments = []
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim()
// 检测表格行:以 | 开头并以 | 结尾
// 修复:原正则 /^\|.+|&/ 中 | 未转义,导致匹配所有行
const isTableLine = /^\|.+\|$/.test(line)
if (isTableLine) {
// 分隔行(如 |---|---| 或 |:---:|
// 修复:原正则在字符类中包含了 | 导致分隔行误判
if (/^\|[\s\-:]+\|[\s\-:]+\|$/.test(line)) {
alignments = line.split('|').filter(c => c.trim()).map(c => {
if (c.trim().startsWith(':') && c.trim().endsWith(':')) return 'center'
if (c.trim().endsWith(':')) return 'right'
return 'left'
})
continue
}
tableRows.push(line)
inTable = true
} else {
if (inTable && tableRows.length > 0) {
// 结束表格,渲染为 HTML
result.push(renderTable(tableRows, alignments))
tableRows = []
alignments = []
inTable = false
}
result.push(line)
}
}
// 处理文末的表格
if (inTable && tableRows.length > 0) {
result.push(renderTable(tableRows, alignments))
}
return result.join('\n')
}
// 渲染表格行
const renderTable = (rows, alignments) => {
let html = '<table border="1" style="border-collapse:collapse;width:100%;margin:8px 0">'
rows.forEach((row, index) => {
const tag = index === 0 ? 'th' : 'td'
const cells = row.split('|').filter(c => c.trim() !== '')
html += '<tr>'
cells.forEach((cell, ci) => {
const align = alignments[ci] ? ` style="text-align:${alignments[ci]}"` : ''
let cellContent = cell.trim()
// 1. 先处理 Markdown 粗体 **...**
cellContent = cellContent.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
// 2. 再处理 Markdown 斜体 *...*
cellContent = cellContent.replace(/\*(.+?)\*/g, '<em>$1</em>')
// 3. 转义剩余的 _ 和 *,防止 snarkdown 将其解析为斜体/加粗
cellContent = cellContent
.replace(/_/g, '&#95;')
.replace(/\*/g, '&#42;')
html +=
`<${tag}${align} style="padding:6px 10px;border:1px solid #ddd">${cellContent}</${tag}>`
})
html += '</tr>'
})
html += '</table>'
return html
}
// 转义所有单独的 _但保护 __双下划线粗体
// snarkdown 的 [_*] 没有任何单词边界限制,会把单词中的 _ 误解析为斜体标记
// 如 watermark_mcp、char_spacing 中的 _ 会打开永不闭合的 <em>,导致后面全部斜体
const escapeLoneUnderscores = (text) => {
// 1. 先用占位符保护 __ 双下划线snarkdown 的粗体语法)
text = text.replace(/__/g, '\x00\x00')
// 2. 将剩余的单个 _ 转为 HTML 实体snarkdown 不会处理实体
text = text.replace(/_/g, '&#95;')
// 3. 还原 __ 占位符
text = text.replace(/\x00\x00/g, '__')
return text
}
// Markdown转HTML节点
const pareseMarkdown = (content) => {
if (!content) return ''
content = sanitizeContent(content)
try {
// 转义单词中的单下划线,防止 snarkdown 误解析为斜体
content = escapeLoneUnderscores(content)
// 先转换表格,再用 snarkdown 处理其他 Markdown
content = convertMarkdownTable(content)
let html = snarkdown(content)
// 将 <a href="..."> 替换为自定义 span避免 WebView 触发默认下载
html = html.replace(
/<a\s+href="([^"]*)"[^>]*>(.*?)<\/a>/gi,
'<span class="chat-inline-link" data-url="$1">$2</span>'
)
return html
} catch (e) {
console.error('解析失败', e)
return content
}
}
// 完全移除 <script> 标签及其内容,并检测表单标签
const sanitizeContent = (str) => {
// 检测是否存在 script 标签或表单相关标签
const hasScript = /<script\b[^>]*>([\s\S]*?)<\/script>/i.test(str);
const hasFormTags = /<(form|input|select|textarea|button)\b[^>]*>/i.test(str);
// 先移除所有 script 标签
let cleanedStr = str.replace(/<script\b[^>]*>([\s\S]*?)<\/script>/gi, '');
// 3. 【新增】检测并转义 minimax:tool_call 标签(只转义这个,不影响其他内容)
cleanedStr = cleanedStr
// 转义 <minimax:tool_call> ... </minimax:tool_call>
.replace(/<\/?minimax:tool_call>/g, m => m.replace(/</g, '&lt;').replace(/>/g, '&gt;'))
// 转义 <|FunctionCallEnd|> 这类AI标记
.replace(/<\|[\w]+?\|>/g, m => m.replace(/</g, '&lt;').replace(/>/g, '&gt;'));
// 如果存在脚本标签或表单标签添加红色加粗的h1提示
if (hasScript || hasFormTags) {
const warningHtml = '<h2 style="color: red; font-weight: bold;">表单仅预览,不可操作!!!</h2>';
return warningHtml + cleanedStr;
}
return cleanedStr;
}
// 操作对话内容中的图片和文件
// 图片预览
const previewImage = (url) => {
uni.previewImage({
urls: [url]
})
}
// 打开文件
const openFile = (url) => {
uni.downloadFile({
url: url,
success: (res) => {
uni.openDocument({
filePath: res.tempFilePath,
showMenu: true
})
}
})
}
// 文件大小格式化
const formatFileSize = (size) => {
if (!size) return '0KB'
if (size < 1) {
return (size * 1024).toFixed(0) + 'KB'
}
return size.toFixed(2) + 'MB'
}
// 从消息 content 中提取文件信息
// 格式: [文件信息:[{...}]]
const parseFileInfo = (content) => {
if (!content || typeof content !== 'string') return {
textContent: content || '',
files: []
}
const match = content.match(/\[文件信息:(\[.*?\])\]/)
if (!match) return {
textContent: content,
files: []
}
try {
const files = JSON.parse(match[1])
const textContent = content.replace(match[0], '').trim()
return {
textContent,
files
}
} catch (e) {
console.error('解析文件信息失败:', e)
return {
textContent: content,
files: []
}
}
}
// 新建会话相关
// 新建普通会话
const selectNormalChat = async () => {
try {
const chars = '0123456789abcdef'
let name = ''
for (let i = 0; i < 24; i++) {
name += chars[Math.floor(Math.random() * chars.length)]
}
const workspaceId = await createWorkspace(userToken.value, name)
if (!workspaceId) {
uni.showToast({
title: '工作区创建失败',
icon: 'none'
})
return
}
const loginInfo = uni.getStorageSync('yxd_login_info')
let userName = ''
if (loginInfo) {
userName = loginInfo.username || '';
}
const conversationId = await createConversation(userToken.value, workspaceId, '新会话', userName)
if (!conversationId) {
uni.showToast({
title: '新会话创建失败',
icon: 'none'
})
return
}
takeUserConversations();
} catch (error) {
// ❌ 统一捕获异步错误,防止崩溃
console.error('新建普通会话失败:', error)
uni.showToast({
title: '创建会话失败,请重试',
icon: 'none'
})
}
}
const showNewChatModal = ref(false)
const closeNewChatModal = () => {
showNewChatModal.value = false;
}
// 侧边栏相关
const isChatSidebar = ref(false)
const handleChatSidebar = () => {
isChatSidebar.value = !isChatSidebar.value
}
// 跳转到工作区
const goWorkSpace = () => {
// 保存当前会话id确保从工作区返回时能恢复
if (currentSessionId.value) {
uni.setStorageSync('currentSessionId', currentSessionId.value)
}
uni.navigateTo({
url: '/pages/WorkSpace/WorkSpace'
})
}
// 跳转到云端数据库
const goCloudDatabase = () => {
// 保存当前会话id确保从工作区返回时能恢复
if (currentSessionId.value) {
uni.setStorageSync('currentSessionId', currentSessionId.value)
}
uni.navigateTo({
url: '/pages/CloudDatabase/CloudDatabase'
})
}
const logOut = () => {
uni.reLaunch({
url: '/pages/Login/Login'
})
}
const previewFileArray = ref([])
const uploadPhoto = () => {
console.log('点击了拍照上传');
uni.chooseImage({
count: 1,
sourceType: ['camera', 'album'],
success: (res) => {
const tempFiles = res.tempFiles;
const tempFilePaths = res.tempFilePaths;
tempFiles.forEach((file, index) => {
previewFileArray.value.push({
path: tempFilePaths[index], // 图片路径(用于显示)
name: file.name || `photo_${Date.now()}_${index}.jpg`, // 文件名
size: file.size, // 文件大小
type: 'image', // 文件类型标识
tempFile: file // 原始文件对象
});
});
},
fail: (err) => {
console.error('选择图片失败', err);
}
})
}
// 删除指定图片
const deleteImage = (index) => {
previewFileArray.value.splice(index, 1);
};
const fileList = ref([])
const uploadFile = () => {
console.log('点击了上传文件');
chooseFile({
count: 5,
type: 'all',
success: (res) => {
console.log("成功了");
// 赋值文件列表
fileList.value = res.tempFiles;
// 把所有文件 push 到预览数组(展开运算符 ...
previewFileArray.value.push(...res.tempFiles);
uni.showToast({
title: `已选择 ${res.tempFiles.length} 个文件`,
icon: 'success'
})
},
fail: (err) => {
console.error('选择失败:', err)
uni.showToast({
title: '选择失败',
icon: 'error'
})
}
})
}
// 连接socket
const handleConnect = () => {
return new Promise((resolve, reject) => {
if (socketStore.isConnected) return resolve()
// 监听连接成功
const unwatch = watch(() => socketStore.isConnected, (connected) => {
if (connected) {
unwatch()
resolve()
}
})
// 监听连接错误
const unwatchError = watch(() => socketStore.connectionStatus, (status) => {
if (status === 'error') {
unwatch()
unwatchError()
reject(new Error('连接失败'))
}
})
socketStore.connect({
token: userToken.value,
conversationId: currentSessionId.value
})
})
}
// // 监听socket连接是否成功
// watch(()=>socketStore.isConnected,(newVal)=>{
// if(newVal&&)
// })
const isThinking = ref(false)
const isSelfSent = ref(false) // 是否当前设备发起的 AI 对话(用于区分弹窗/仅锁输入)
const isUploading = ref(false)
const isLoadingMessages = ref(false) // 消息加载中状态(仅会话变化时显示)
const uploadedFiles = ref([])
const streamingMessageId = ref(null) // 流式消息的临时 ID用于实时更新 AI 回复
// 消息详情弹窗
const showMessageModal = ref(false)
const detailLinks = ref([])
// 点击气泡内容:拦截链接点击,否则 AI 对话弹出详情
const handleChatContentClick = (e, message) => {
// 在事件路径中向上查找 .chat-inline-link 元素
let el = e.target
while (el && el.classList) {
if (el.classList.contains('chat-inline-link')) {
const url = el.getAttribute('data-url') || (el.dataset && el.dataset.url)
if (url) {
openLink(url)
}
return
}
el = el.parentElement
}
// 检查消息内容是否包含链接
const hasLink = message.content && (
message.content.includes('http://') ||
message.content.includes('https://') ||
/<a\s+[^>]*href\s*=\s*['"][^'"]*['"]/i.test(message.content) ||
/\[[^\]]*\]\([^)]*\)/i.test(message.content) // 匹配 Markdown 链接格式
)
// 只有包含链接时才弹出详情窗
if (message.role && hasLink) {
showMessageDetail(message)
}
}
const extractLinks = (text) => {
const seen = new Set()
const result = []
// 1. 匹配 Markdown 链接 [文件名](url)
const mdLinkRegex = /\[([^\]]*)\]\((https?:\/\/[^\s<>"{}|\\^`\[\]()]+)\)/gi
const mdLinks = [...text.matchAll(mdLinkRegex)]
mdLinks.forEach(m => {
const name = m[1].trim()
const url = m[2]
if (!seen.has(url)) {
seen.add(url)
result.push({
url,
name: name || url
})
}
})
// 2. 去除已匹配的 [xxx](url) 后,再搜裸 URL
let cleaned = text.replace(mdLinkRegex, '')
const bareRegex = /https?:\/\/[^\s<>"{}|\\^`\[\]()]+/gi
const bareMatches = cleaned.match(bareRegex)
if (bareMatches) {
bareMatches.forEach(url => {
if (!seen.has(url)) {
seen.add(url)
result.push({
url,
name: extractFileNameFromUrl(url)
})
}
})
}
return result
}
const extractFileNameFromUrl = (urlStr) => {
try {
const segments = new URL(urlStr).pathname.split('/').filter(s => s)
if (segments.length > 0) {
return decodeURIComponent(segments[segments.length - 1])
}
} catch (e) {
/* fallback */
}
const parts = urlStr.split('/').filter(s => s)
if (parts.length > 0) {
const last = parts[parts.length - 1]
const qIdx = last.indexOf('?')
return qIdx > -1 ? last.substring(0, qIdx) : last
}
return urlStr
}
const showMessageDetail = (message) => {
detailLinks.value = extractLinks(message.content || '')
showMessageModal.value = true
}
const closeMessageDetail = () => {
showMessageModal.value = false
detailLinks.value = []
}
const downloadToast = ref({
show: false,
type: 'loading',
message: ''
})
let downloadToastTimer = null
const showDownloadToast = (type, message, duration) => {
if (downloadToastTimer) clearTimeout(downloadToastTimer)
downloadToast.value = {
show: true,
type,
message
}
if (duration > 0) {
downloadToastTimer = setTimeout(() => {
downloadToast.value = {
show: false,
type: 'loading',
message: ''
}
}, duration)
}
}
const hideDownloadToast = () => {
if (downloadToastTimer) clearTimeout(downloadToastTimer)
downloadToast.value = {
show: false,
type: 'loading',
message: ''
}
}
const openLink = (url) => {
downloadAndHandle(url)
}
const downloadAndHandle = async (url) => {
try {
showDownloadToast('loading', '下载中...', 0)
const workspaceId = uni.getStorageSync('workspace_id') || ''
const result = await conversationMessageDownload(userToken.value, workspaceId, url)
const downloadUrl = typeof result === 'string' ? result : (result?.url || result?.download_url || '')
if (!downloadUrl) {
showDownloadToast('error', '获取下载地址失败', 2500)
return
}
uni.downloadFile({
url: downloadUrl,
success: (downloadRes) => {
if (downloadRes.statusCode === 200) {
hideDownloadToast()
uni.openDocument({
filePath: downloadRes.tempFilePath,
showMenu: true,
fail: () => {
showDownloadToast('error', '无法打开此文件', 2500)
}
})
} else {
showDownloadToast('error', '下载失败', 2500)
}
},
fail: () => {
showDownloadToast('error', '下载失败', 2500)
}
})
} catch (err) {
showDownloadToast('error', '下载失败: ' + (err.message || err), 2500)
}
}
const sendMessage = async () => {
const message = textMessage.value.trim()
if (!message && previewFileArray.value.length === 0) return
// 有文件时先上传
if (previewFileArray.value.length > 0) {
isUploading.value = true
uni.showLoading({
title: '上传文件中...',
mask: true
})
try {
const results = []
for (const item of previewFileArray.value) {
const result = await uploadFileToServer(item.path)
results.push(result)
}
uploadedFiles.value = results
previewFileArray.value = []
} catch (err) {
uni.hideLoading()
console.error('文件上传失败:', err)
uni.showToast({
title: '文件上传失败,请重试',
icon: 'error'
})
isUploading.value = false
return
}
uni.hideLoading()
isUploading.value = false
}
if (ChatType.value === 0) sendAIMessage();
if (ChatType.value === 1) sendFriendMessage();
if (ChatType.value === 2) sendGroupMessage();
}
// 发送好友消息
const sendFriendMessage = async () => {
if (!friendSocketStore.isConnecting) await handleFriendConnect();
const receiverId = getReceiverId()
const hasFiles = uploadedFiles.value.length > 0
const body = {
"command": 1,
"sender": UserId.value,
"receiver": receiverId,
"avatar": "",
"sessionId": currentSessionId.value,
"message": textMessage.value,
"callBackMessage": false
}
if (hasFiles) {
body.contentType = 1
body.uploadVos = uploadedFiles.value
}
const data = {
"version": "1.1",
"body": body
}
friendSocketStore.send(data)
const newMessageData = {
"sender": UserId.value,
"receiver": receiverId,
"content": textMessage.value,
"taskId": null,
"avatar": null,
"createTime": "",
"messageType": "null",
"contentJson": hasFiles ? JSON.stringify(uploadedFiles.value) : 'null'
}
allmessages.value = [...allmessages.value, newMessageData]
textMessage.value = ''
uploadedFiles.value = []
setTimeout(() => {
takeFriendMessages()
}, 1000)
}
// 发送群聊消息
const sendGroupMessage = async () => {
if (!friendSocketStore.isConnecting) await handleFriendConnect();
const hasFiles = uploadedFiles.value.length > 0
const body = {
"command": 9,
"groupId": currentSessionId.value,
"message": textMessage.value || "",
"callBackMessage": hasFiles ? false : true,
"messageType": hasFiles ? 1 : 0,
"sender": UserId.value
}
if (hasFiles) {
body.uploadVos = uploadedFiles.value
}
const data = {
"version": "1.1",
"body": body
}
friendSocketStore.send(data)
const newMessageData = {
"id": allmessages.value.length + 1,
"message": textMessage.value,
"messageType": hasFiles ? 1 : 0,
"groupId": null,
"createTime": '',
"contentJson": hasFiles ? JSON.stringify(uploadedFiles.value) : '',
"sender": UserId.value
}
allmessages.value = [...allmessages.value, newMessageData]
textMessage.value = ''
uploadedFiles.value = []
setTimeout(() => {
takeGroupMessages()
}, 1000)
}
// // 发送AI消息
// const sendAIMessage = async () => {
// try {
// handleConnect();
// let messageContent = textMessage.value
// if (uploadedFiles.value.length) {
// const fileJson = JSON.stringify(uploadedFiles.value)
// messageContent = messageContent ?
// `${messageContent}\n[文件信息:${fileJson}]` :
// `[文件信息:${fileJson}]`
// }
// const message_id = await addMessageDict(userToken.value, 'user', currentSessionId.value,
// messageContent)
// if (message_id) {
// textMessage.value = ''
// uploadedFiles.value = []
// isThinking.value = true
// }
// await waitForAIResponse();
// } catch (error) {
// isThinking.value = false
// uni.showToast({
// title: `用户发送消息失败${error}`,
// icon: 'error'
// })
// }
// }
// AI 回复超时定时器
// let aiResponseTimer = null
// 发送AI消息
const sendAIMessage = async () => {
try {
await handleConnect();
let messageContent = textMessage.value
if (uploadedFiles.value.length) {
const fileJson = JSON.stringify(uploadedFiles.value)
messageContent = messageContent ?
`${messageContent}\n[文件信息:${fileJson}]` :
`[文件信息:${fileJson}]`
}
const data = {
"type": "chat",
"conversation_id": currentSessionId.value,
"content": messageContent
}
socketStore.send(data)
isThinking.value = true
isSelfSent.value = true
// 立即添加用户消息到列表(使用完整的 messageContent包含文件信息
allmessages.value = [...allmessages.value, {
role: 'user',
content: messageContent
}]
// 添加流式消息气泡(加载中状态)
streamingMessageId.value = 'streaming-' + Date.now()
allmessages.value = [...allmessages.value, {
role: 'assistant',
content: '',
_streaming: true,
_id: streamingMessageId.value
}]
textMessage.value = ''
uploadedFiles.value = []
scrollToBottom()
} catch (error) {
isThinking.value = false
uni.showToast({
title: `用户发送消息失败${error}`,
icon: 'error'
})
}
}
// 中断 AI 对话
const stopConversation = async () => {
console.log('中断对话 - 当前会话ID:', currentSessionId.value)
try {
await socketStore.send({
type: 'stop',
conversation_id: currentSessionId.value
})
console.log('中断指令已发送成功')
} catch (err) {
console.error('中断指令发送失败:', err)
}
// 移除流式消息占位
if (streamingMessageId.value) {
allmessages.value = allmessages.value.filter(m => m._id !== streamingMessageId.value)
streamingMessageId.value = null
}
// 立即关闭弹窗,清除超时
// clearTimeout(aiResponseTimer)
isThinking.value = false
socketStore.isThinking = false
isSelfSent.value = false
textMessage.value = ''
uploadedFiles.value = []
// takeConversationMessages 由后端中断确认驱动(见 handleBusinessMessage
uni.showToast({
title: '已中断',
icon: 'none',
duration: 1500
})
}
// 全局监听 socketStore.isThinking跨设备同步思考状态锁/解锁输入框
watch(() => socketStore.isThinking, (newVal, oldVal) => {
console.log("isThinking 变化:", oldVal, "→", newVal, "messageString:", socketStore.messageString);
// false → true后端开始回复锁定输入框
if (!oldVal && newVal) {
console.log('AI开始回复跨设备同步锁定输入框');
isThinking.value = true
// 非本设备发起时,也需要添加流式气泡
if (!streamingMessageId.value) {
streamingMessageId.value = 'streaming-' + Date.now()
allmessages.value = [...allmessages.value, {
role: 'assistant',
content: '',
_streaming: true,
_id: streamingMessageId.value
}]
}
return
}
// true → falseAI 回复结束或中断,解锁输入框并刷新消息列表
if (oldVal && !newVal) {
console.log('AI回复结束正常/中断),解锁并刷新消息列表');
// 移除流式消息气泡,从服务器加载完整消息
if (streamingMessageId.value) {
allmessages.value = allmessages.value.filter(m => m._id !== streamingMessageId.value)
streamingMessageId.value = null
}
takeConversationMessages()
isThinking.value = false
isSelfSent.value = false
textMessage.value = ''
uploadedFiles.value = []
}
})
// 监听 auth_fail当 pc 端不在线时弹窗提示并取消思考状态
watch(() => socketStore.authFailReason, (reason) => {
if (reason === 'pc offline') {
isThinking.value = false
socketStore.authFailReason = ''
nextTick(() => {
uni.showModal({
title: '提示',
content: 'PC端不在线不可聊天',
showCancel: false,
confirmText: '知道了'
})
})
}
})
// 监听流式消息内容,实时更新 AI 回复(流式输出核心)
watch(() => socketStore.messageString, (newContent) => {
if (!streamingMessageId.value || !socketStore.isThinking) return
const idx = allmessages.value.findIndex(m => m._id === streamingMessageId.value)
if (idx >= 0) {
// 使用 splice 保证 Vue 响应式更新
const updated = {
...allmessages.value[idx],
content: newContent
}
allmessages.value.splice(idx, 1, updated)
scrollToBottom()
}
})
const textMessage = ref('')
// 获取用户历史会话列表
const UserConversations = ref([])
// 当前会话id
const currentSessionId = ref('')
const userToken = ref('')
const UserId = ref('')
const UserAvatar = ref('')
const UserData = ref(null)
// 获取当前用户信息(提前加载,用于显示头像等)
const takeUserInfo = async () => {
try {
userToken.value = getToken();
UserData.value = await getUserInfo(userToken.value)
UserId.value = UserData.value._id;
UserAvatar.value = UserData.value.avatar || ''
console.log("用户信息已加载:", UserId.value, UserAvatar.value);
} catch (error) {
console.error("获取用户信息失败:", error);
}
}
// 获取对话列表
const takeUserConversations = async () => {
try {
userToken.value = getToken();
console.log("token:", userToken.value);
UserConversations.value = await getUserConversations(userToken.value) || [];
// console.log("UserConversations:", JSON.stringify(UserConversations.value) );
// 优先恢复已保存的会话id避免刷新到列表第一个
const savedSessionId = getCurrentSessionId();
if (savedSessionId && UserConversations.value.some(c => c._id === savedSessionId)) {
currentSessionId.value = savedSessionId;
} else if (UserConversations.value.length > 0) {
currentSessionId.value = UserConversations.value[0]._id;
} else {
currentSessionId.value = '';
}
console.log('保存会话id', currentSessionId.value);
uni.setStorageSync('currentSessionId', currentSessionId.value)
} catch (error) {
uni.showToast({
title: `获取会话列表失败${error}`,
icon: 'error'
})
}
}
// 好友列表
// 好友消息列表(不包括头像)
const FriendInfoList = ref([])
// 获取用户好友列表
const takeFriendList = async () => {
try {
console.log("开始获取好友列表");
// UserId 已在 takeUserInfo 中获取
const friendList = await getChatFriend(UserId.value)
console.log("friendList:", friendList);
if (friendList && friendList.length) {
FriendInfoList.value = await takeUserAvatar(friendList)
} else {
FriendInfoList.value = []
console.log("好友列表为空");
}
} catch (error) {
console.error("获取好友列表失败:", error);
FriendInfoList.value = []
} finally {
UserConversations.value = FriendInfoList.value
}
}
// 从好友列表中获取单个用户头像
const takeTalkUserAvatar = (userId) => {
if (!userId) return null
const user = FriendInfoList.value.find(item => item.receiver === userId)
return user?.avatar || null
}
// 获取好友头像
const takeUserAvatar = async (friendList) => {
if (!friendList?.length) return []
try {
const friendIds = friendList.map(item => item.receiver)
const friendAvatarList = await getUserAvatar(userToken.value, friendIds)
// 创建map,用于快速查找
const userMap = new Map(friendAvatarList.map(user => [user.user_id, user]) || [])
// 合并数据
return friendList.map(friend => {
const userInfo = userMap.get(friend.receiver)
return {
...friend,
avatar: userInfo?.avatar || null
}
})
} catch (err) {
console.error('获取好友头像失败', err);
return friendList
}
}
// 群聊列表
const GroupList = ref([])
// 获取用户群聊列表
const takeGroupList = async () => {
try {
console.log("开始获取群聊列表");
// UserId 已在 takeUserInfo 中获取
GroupList.value = await getGroup(UserId.value)
// console.log("GroupList1:", JSON.stringify(GroupList.value));
} catch (error) {
console.error("获取群聊列表失败:", error);
GroupList.value = []
} finally {
UserConversations.value = GroupList.value
}
}
// 群成员列表(包含头像)
const groupMemberList = ref([])
// 获取群成员头像
const takeGroupMemberAvatar = async (memberList) => {
if (!memberList?.length) return []
try {
const memberIds = memberList.map(item => item.groupContactId)
console.log("请求头像的ID列表:", memberIds)
const memberAvatarList = await getUserAvatar(userToken.value, memberIds)
console.log("头像接口返回数据:", memberAvatarList)
// 创建map,用于快速查找
const userMap = new Map(memberAvatarList.map(user => [user.user_id, user]) || [])
console.log("userMap的keys:", Array.from(userMap.keys()))
// 合并数据
return memberList.map(member => {
const memberId = member.groupContactId
const userInfo = userMap.get(memberId)
console.log(`查找 ${memberId} 的头像:`, userInfo)
return {
...member,
avatar: userInfo?.avatar || null
}
})
} catch (err) {
console.error('获取群成员头像失败', err);
return memberList
}
}
// 从群成员列表中获取单个用户头像
const getGroupMemberAvatarById = (userId) => {
if (!userId) return null
const member = groupMemberList.value.find(item => item.groupContactId === userId)
return member?.avatar || null
}
// 当前会话的消息
// const currentMessages = ref([])
const allmessages = ref([])
// const pageInfoNumber = 10;
const scrollToView = ref('')
const bottomToggle = ref(false)
// 加载状态和分页相关变量
const isLoadingMore = ref(false) // 是否正在加载更多
const currentPage = ref(1) // 当前页码(如果后端支持分页)
// 计算属性:自动根据 allmessages 和 currentPage 计算显示消息
const currentMessages = computed(() => {
// return allmessages.value.slice(-pageInfoNumber);
return allmessages.value;
})
// 获取对话消息
const takeConversationMessages = async () => {
try {
allmessages.value = await getConversationMessages(userToken.value, currentSessionId.value) || [];
// console.log("获取到的所有消息:", JSON.stringify(allmessages.value) );
// currentMessages.value = allmessages.value.slice(-pageInfoNumber);
// 数据获取后执行滚动
scrollToBottom();
} catch (error) {
uni.showToast({
title: `获取ai会话内容失败${error}`,
icon: 'none'
})
} finally {
isLoadingMessages.value = false
}
}
const takeFriendMessages = async () => {
try {
allmessages.value = await getFriendMessages(currentSessionId.value) || [];
// console.log("好友消息:", JSON.stringify(allmessages.value));
// 数据获取后执行滚动
scrollToBottom();
} catch (error) {
uni.showToast({
title: `获取好友会话消息失败${error}`,
icon: 'none'
})
} finally {
isLoadingMessages.value = false
}
}
const takeGroupMessages = async () => {
try {
allmessages.value = await getGroupMessages(currentSessionId.value) || [];
console.log("群聊消息:", allmessages.value);
// 获取群成员列表并获取头像
const memberList = await getGroupMemberList(currentSessionId.value)
console.log("获取到的群成员列表:", memberList);
if (memberList && memberList.length) {
groupMemberList.value = await takeGroupMemberAvatar(memberList)
console.log("群成员列表(带头像):", groupMemberList.value);
} else {
groupMemberList.value = []
}
// 数据获取后执行滚动
scrollToBottom();
} catch (error) {
uni.showToast({
title: `获取群聊会话失败${error}`,
icon: 'none'
})
} finally {
isLoadingMessages.value = false
}
}
// // 加载更多消息
// const loadMoreMessages = async () => {
// console.log('加载更多信息');
// if (allmessages.value.length > pageInfoNumber * currentPage.value) {
// currentPage.value += 1;
// const newMessages = allmessages.value.slice(-pageInfoNumber * currentPage.value);
// const addMessage = newMessages.length - currentMessages.value.length
// currentMessages.value = newMessages
// // 等待DOM更新
// await nextTick()
// scrollToView.value = 'msg-' + (addMessage + 1);
// }
// // currentMessages.value = allmessages.value.slice(-pageInfoNumber);
// }
// 监听滚动
const onScroll = (e) => {}
// 滑动到底部
const scrollToBottom = async () => {
await nextTick();
if (currentMessages.value.length > 0) {
// 两个锚点交替切换,确保每次调用都能触发滚动
bottomToggle.value = !bottomToggle.value
scrollToView.value = bottomToggle.value ? 'chat-bottom-a' : 'chat-bottom-b'
}
}
// 监听好友消息
watch(() => friendSocketStore.MessageReceived, (newId) => {
console.log("收到了好友消息");
if (newId) {
console.log("ChatType:", ChatType.value);
switch (ChatType.value) {
case 0:
takeConversationMessages();
friendSocketStore.MessageReceived = false
break;
case 1:
takeFriendMessages();
friendSocketStore.MessageReceived = false
break;
case 2:
takeGroupMessages();
friendSocketStore.MessageReceived = false
break;
// 可选:默认兜底
default:
console.log("default 分支");
friendSocketStore.MessageReceived = false
break;
}
}
}, {
immediate: true
})
watch(currentSessionId, async (newId, oldId) => {
if (newId) {
uni.setStorageSync('currentSessionId', currentSessionId.value)
// 会话变化时显示加载弹窗并清空消息
if (oldId && oldId !== newId) {
isLoadingMessages.value = true
allmessages.value = []
}
switch (ChatType.value) {
case 0:
takeConversationMessages();
// 切换 AI 会话时重新连接 socket绑定新 conversationId
socketStore.disconnect()
handleConnect()
break;
case 1:
await takeFriendMessages();
break;
case 2:
await takeGroupMessages();
break;
default:
takeConversationMessages();
break;
}
}
}, {
immediate: true
})
onMounted(() => {
// currentSessionId.value = getCurrentSessionId()
})
onShow(async () => {
// 优先获取用户信息(用于显示头像等)
await takeUserInfo();
const chatType = getChatType()
ChatType.value = chatType
const sessionId = getCurrentSessionId()
// 只有在会话ID存在时才获取消息
if (sessionId) {
currentSessionId.value = sessionId
}
if (chatType === 0) {
await takeUserConversations();
// 只有在会话ID存在时才获取消息
if (currentSessionId.value) {
isLoadingMessages.value = true
takeConversationMessages();
}
// AI 对话页面:主动建立 socket 连接,确保消息实时推送
if (userToken.value && currentSessionId.value && !socketStore.isConnected) {
handleConnect()
}
} else if (chatType === 1) {
await takeFriendList()
isLoadingMessages.value = true
await takeFriendMessages();
handleFriendConnect()
} else if (chatType === 2) {
await takeGroupList()
await handleFriendConnect()
isLoadingMessages.value = true
await takeGroupMessages()
}
})
// 离开 AI 对话页面时断开 socket
onUnload(() => {
socketStore.disconnect()
})
</script>
<style lang="scss" scoped>
@import "./chat.css";
</style>
<!-- v-html 渲染的 markdown 内容无法被 scoped 样式命中需非 scoped 样式块 -->
<style lang="scss">
/* 气泡内容字体:确保 v-html 渲染的 markdown 内容使用系统字体栈 */
.chat-content {
// font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'HarmonyOS Sans', 'MiSans',
// 'Source Han Sans CN', 'Microsoft YaHei', 'Noto Sans CJK SC', sans-serif;
// font-weight: 400;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'HarmonyOS Sans', 'MiSans',
'Source Han Sans CN', 'Microsoft YaHei', 'Noto Sans CJK SC', sans-serif !important;
font-size: 16px !important;
font-weight: 600 !important;
line-height: 1.6;
margin: 8rpx 0;
h1 {
font-size: 42rpx;
line-height: 1.4;
margin: 16rpx 0 10rpx;
}
h2 {
font-size: 38rpx;
line-height: 1.4;
margin: 14rpx 0 8rpx;
}
h3 {
font-size: 36rpx;
line-height: 1.4;
margin: 12rpx 0 8rpx;
}
h4,
h5,
h6 {
font-size: 34rpx;
line-height: 1.5;
margin: 10rpx 0 6rpx;
}
/* 普通正文 */
p,
text,
span {
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'HarmonyOS Sans', 'MiSans',
'Source Han Sans CN', 'Microsoft YaHei', 'Noto Sans CJK SC', sans-serif !important;
font-size: 38rpx !important;
font-weight: 400 !important;
line-height: 1.6;
margin: 8rpx 0;
}
/* 粗体和斜体样式 */
strong,
b {
font-weight: 600 !important;
}
em,
i {
font-style: italic !important;
}
/* 气泡内的内联链接(替代 <a> 标签,点击由 JS 拦截处理) */
.chat-inline-link {
color: #3b86ff !important;
text-decoration: underline;
word-break: break-all;
}
}
/* 流式气泡中的加载动画 */
.bubble-loading-dots {
display: flex;
align-items: center;
justify-content: flex-start;
padding: 10rpx 0;
}
.bubble-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: #a0a0b8;
margin-right: 10rpx;
animation: bubbleDotBlink 1.2s infinite ease-in-out;
}
.bubble-dot:nth-child(2) {
animation-delay: 0.3s;
}
.bubble-dot:nth-child(3) {
animation-delay: 0.6s;
}
@keyframes bubbleDotBlink {
0%,
100% {
opacity: 0.25;
transform: scale(0.7);
}
50% {
opacity: 1;
transform: scale(1.1);
}
}
/* 消息加载中弹窗(仅会话变化时显示) */
.messages-loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
background: rgba(255, 255, 255, 0.9);
z-index: 100;
}
.messages-loading-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 40rpx 60rpx;
background: #ffffff;
border-radius: 24rpx;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.12);
}
.messages-loading-text {
margin-top: 20rpx;
font-size: 28rpx;
color: #999;
}
/* 消息详情弹窗 — 链接提取区域 */
.detail-links-section {
margin-top: 24rpx;
padding-top: 24rpx;
border-top: 1rpx solid #eee;
}
.detail-links-title {
font-size: 30rpx;
font-weight: 600;
color: #666;
margin-bottom: 16rpx;
}
.detail-link-item {
padding: 16rpx 20rpx;
background: #f5f7fa;
border-radius: 12rpx;
margin-bottom: 12rpx;
}
.link-text {
font-size: 28rpx;
color: #007aff;
word-break: break-all;
}
.detail-empty {
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx 0;
font-size: 28rpx;
color: #999;
}
/* ========== 自定义下载 Toast层级高于 ncd-overlay 的 9999========== */
.download-toast-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
z-index: 10001;
pointer-events: none;
}
.download-toast-card {
display: flex;
align-items: center;
justify-content: center;
padding: 24rpx 40rpx;
border-radius: 24rpx;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0 12rpx 40rpx rgba(0, 0, 0, 0.15);
animation: toastFadeIn 0.25s ease;
pointer-events: auto;
}
@keyframes toastFadeIn {
from {
opacity: 0;
transform: scale(0.85);
}
to {
opacity: 1;
transform: scale(1);
}
}
.download-toast-loading {
background: rgba(40, 40, 60, 0.92);
}
.download-toast-success {
background: rgba(40, 60, 40, 0.92);
}
.download-toast-error {
background: rgba(60, 30, 30, 0.92);
}
.download-toast-icon {
font-size: 36rpx;
color: #fff;
margin-right: 16rpx;
display: flex;
align-items: center;
}
.download-toast-card.download-toast-loading .download-toast-icon {
gap: 8rpx;
}
.toast-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: #fff;
animation: toastDotBlink 1.2s infinite ease-in-out;
}
.toast-dot:nth-child(2) {
animation-delay: 0.3s;
}
.toast-dot:nth-child(3) {
animation-delay: 0.6s;
}
@keyframes toastDotBlink {
0%,
100% {
opacity: 0.3;
transform: scale(0.75);
}
50% {
opacity: 1;
transform: scale(1.2);
}
}
.download-toast-text {
font-size: 28rpx;
color: #fff;
font-weight: 500;
}
</style>