ai-manus/chat-client/src/views/chatweb/components/ChatBox.vue

3223 lines
81 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>
<div class="chat-container">
<!-- 会话记录侧边栏 -->
<div class="chat-sidebar" :class="{ 'sidebar-collapsed': isSidebarCollapsed }">
<div class="sidebar-header">
<h3 v-show="!isSidebarCollapsed">{{ t('vabI18n.chat.history') }}</h3>
<el-button
type="primary"
class="new-chat-btn"
@click="() => createNewChat()"
v-show="!isSidebarCollapsed"
>
<svg viewBox="0 0 24 24" class="new-chat-icon">
<path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</svg>
{{ t('vabI18n.chat.newChat') }}
</el-button>
</div>
<div class="chat-history" v-show="!isSidebarCollapsed">
<div
v-for="(chat, index) in chatHistory"
:key="index"
class="chat-history-item"
:class="{ 'active': currentChatIndex === index }"
@click="switchChat(index)"
>
<span class="chat-title">{{ chat.title || t('vabI18n.chat.chat') + ` ${index + 1}` }}</span>
<span class="chat-time">{{ formatTime(chat.timestamp) }}</span>
</div>
</div>
<div class="sidebar-toggle" @click="toggleSidebar">
<svg viewBox="0 0 24 24" class="toggle-icon" :class="{ 'collapsed': isSidebarCollapsed }">
<path fill="currentColor" d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
</svg>
</div>
</div>
<!-- 主聊天区域 -->
<div class="chat-main" :class="{ 'main-expanded': isSidebarCollapsed }">
<!-- 聊天消息区域 -->
<div class="chat-messages" ref="messagesContainer">
<div v-for="(msg, index) in messages"
:key="index"
class="message-item"
:class="{ 'user-message': msg.isUser, 'assistant-message': !msg.isUser }"
@mouseenter="handleMessageHover(index, true)"
@mouseleave="handleMessageHover(index, false)">
<!-- 头像 -->
<div class="avatar" :class="{ 'user-avatar': msg.isUser, 'assistant-avatar': !msg.isUser }">
<svg v-if="msg.isUser" viewBox="0 0 24 24" class="avatar-icon">
<path fill="currentColor" d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
</svg>
<img v-else :src="getAssistantAvatar()" :alt="$t('vabI18n.chat.assistant')" class="avatar-icon" />
</div>
<!-- 消息内容 -->
<div class="message-content-wrapper" :class="{ 'has-charts': !msg.isUser && msg.echarts && msg.echarts.length > 0 }">
<div class="message-content">
<div class="message-header">
<span class="message-sender">{{ msg.isUser ? t('vabI18n.chat.you') : t('vabI18n.chat.assistant') }}</span>
</div>
<div class="message-body">
<!-- 编辑状态 -->
<template v-if="editingMessageIndex === index">
<div class="edit-container">
<div class="edit-input-wrapper">
<el-input
v-model="editingMessageText"
type="textarea"
:autosize="{ minRows: 3, maxRows: 8 }"
class="edit-textarea"
:placeholder="$t('vabI18n.chat.placeholder')"
@keydown.enter.ctrl="handleSaveEdit"
@keydown.escape="handleCancelEdit"
/>
<div class="edit-actions">
<button
@click="handleCancelEdit"
class="edit-btn cancel-btn"
>
{{ t('vabI18n.chat.cancel') }}
</button>
<button
@click="handleSaveEdit"
:disabled="!editingMessageText.trim()"
class="edit-btn save-btn"
>
{{ t('vabI18n.chat.send') }}
</button>
</div>
</div>
</div>
</template>
<!-- 正常显示状态 -->
<template v-else>
<template v-if="!msg.isUser">
<div v-if="parseThink(msg.text).think" class="think-section">
<div class="think-header">
<svg viewBox="0 0 24 24" class="think-icon">
<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
</svg>
{{ t('vabI18n.chat.think') }}
</div>
{{ parseThink(msg.text).think }}
</div>
<div v-if="msg.formattedText" class="answer-content">
<div v-html="msg.formattedText"></div>
<!-- 渲染 ECharts 图表 -->
<div v-if="msg.echarts && msg.echarts.length > 0" class="echarts-container">
<div
v-for="chart in msg.echarts"
:key="chart.id"
class="chart-wrapper"
>
<VabChart
:option="chart.option"
:init-options="{ renderer: 'svg' }"
theme="vab-echarts-theme"
class="message-chart"
/>
</div>
</div>
</div>
<div v-else class="loading-content">
<span class="loading-dots">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</span>
</div>
<!-- 文件来源信息 -->
<div v-if="msg.sources && ((msg.sources.tracePdf && msg.sources.tracePdf.length > 0) || (msg.sources.traceExcel && msg.sources.traceExcel.trim())) && props.chatType === '1'" class="sources-section">
<div class="sources-header">
<svg viewBox="0 0 24 24" class="sources-icon">
<path fill="currentColor" d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z"/>
</svg>
{{ t('vabI18n.chat.sources') }}
</div>
<div class="sources-list">
<!-- PDF文件来源 -->
<div
v-for="source in msg.sources.tracePdf"
:key="source.fileName"
class="source-item"
@click="handleSourceClick(source, msg.conversationId, msg.messageId)"
>
<img
:src="getFileTypeIcon(getFileExtension(source.fileName))"
:alt="getFileExtension(source.fileName)"
class="source-icon"
/>
<el-tooltip
:content="source.fileName"
placement="top"
:disabled="source.fileName.length <= 25"
>
<span class="source-name">{{ source.fileName }}</span>
</el-tooltip>
</div>
<!-- Excel文件来源 -->
<div
v-if="msg.sources.traceExcel && msg.sources.traceExcel.trim()"
class="source-item excel-item"
@click="handleExcelClick(msg.sources.traceExcel)"
>
<img
:src="getFileTypeIcon('excel')"
alt="excel"
class="source-icon"
/>
<el-tooltip
:content="generateExcelFileName()"
placement="top"
>
<span class="source-name">{{ generateExcelFileName() }}</span>
</el-tooltip>
</div>
</div>
</div>
<!-- 推荐问题按钮 -->
<div v-if="msg.isOpenRemark && props.recommendQuestions && props.recommendQuestions.length > 0" class="recommend-questions-section">
<div class="recommend-questions-header">
<!-- <svg viewBox="0 0 24 24" class="recommend-icon">
<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
</svg> -->
<!-- 推荐问题 -->
</div>
<div class="recommend-questions-list">
<el-tooltip
v-for="(question, qIndex) in props.recommendQuestions"
:key="qIndex"
:content="question"
placement="top"
:disabled="question.length <= 30"
>
<button
class="recommend-question-btn"
@click="handleRecommendQuestionClick(question)"
:disabled="isLoading"
>
<svg viewBox="0 0 24 24" class="question-icon">
<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"/>
</svg>
<span class="question-text">{{ question }}</span>
</button>
</el-tooltip>
</div>
</div>
</template>
<template v-else>
{{ msg.text }}
</template>
</template>
</div>
<span v-if="msg.isLoading" class="typing-indicator">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</span>
<!-- 消息操作按钮 -->
<div
v-show="hoveredMessageIndex === index && !msg.isLoading && editingMessageIndex !== index"
class="message-actions"
:class="{ 'user-actions': msg.isUser, 'assistant-actions': !msg.isUser }"
>
<!-- 用户消息操作 -->
<template v-if="msg.isUser">
<el-tooltip :content="$t('vabI18n.chat.copy')" placement="top">
<button class="action-btn copy-btn" @click="handleCopyMessage(msg)">
<svg viewBox="0 0 24 24" class="action-icon">
<path fill="currentColor" d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
</svg>
</button>
</el-tooltip>
<el-tooltip :content="$t('vabI18n.chat.edit')" placement="top">
<button class="action-btn edit-btn" @click="handleEditMessage(msg, index)">
<svg viewBox="0 0 24 24" class="action-icon">
<path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/>
</svg>
</button>
</el-tooltip>
</template>
<!-- 助手消息操作 -->
<template v-else>
<el-tooltip :content="$t('vabI18n.chat.copy')" placement="top">
<button class="action-btn copy-btn" @click="handleCopyMessage(msg)">
<svg viewBox="0 0 24 24" class="action-icon">
<path fill="currentColor" d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
</svg>
</button>
</el-tooltip>
<el-tooltip :content="$t('vabI18n.chat.regenerate')" placement="top">
<button class="action-btn regenerate-btn" @click="handleRegenerateMessage(index)">
<svg viewBox="0 0 24 24" class="action-icon">
<path fill="currentColor" d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/>
</svg>
</button>
</el-tooltip>
<el-tooltip :content="$t('vabI18n.chat.like')" placement="top">
<button class="action-btn like-btn" @click="handleLikeMessage(index)">
<svg viewBox="0 0 24 24" class="action-icon">
<path fill="currentColor" d="M1 21h4V9H1v12zm22-11c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z"/>
</svg>
</button>
</el-tooltip>
<el-tooltip :content="$t('vabI18n.chat.dislike')" placement="top">
<button class="action-btn dislike-btn" @click="handleDislikeMessage(index)">
<svg viewBox="0 0 24 24" class="action-icon">
<path fill="currentColor" d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L9.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"/>
</svg>
</button>
</el-tooltip>
</template>
</div>
</div>
</div>
</div>
<div ref="bottomAnchor"></div>
</div>
<!-- 输入区域 -->
<div class="input-area">
<!-- 中止按钮 -->
<div v-if="showStopButton" class="stop-button-container">
<el-tooltip :content="$t('vabI18n.chat.stopGenerating')" placement="top">
<button
class="stop-button"
@click="handleStopStream"
>
<svg viewBox="0 0 24 24" class="stop-icon">
<path fill="currentColor" d="M6 6h12v12H6z"/>
</svg>
</button>
</el-tooltip>
</div>
<div class="input-wrapper">
<el-input
v-model="inputMessage"
:placeholder="props.placeholder"
:class="{ 'is-loading': isLoading }"
@keyup.enter="sendMessage"
type="textarea"
:autosize="{ minRows: 1, maxRows: 4 }"
>
</el-input>
<el-button
type="primary"
class="send-button"
@click="sendMessage"
:disabled="!inputMessage.trim() || isLoading"
>
<template v-if="isLoading">
<span class="loading-icon"></span>
</template>
<svg v-else viewBox="0 0 24 24" class="send-icon" width="16" height="16">
<path fill="currentColor" d="M3.4 20.4l17.45-7.48a1 1 0 000-1.84L3.4 3.6a.993.993 0 00-1.39.91L2 9.12c0 .5.37.93.87.99L17 12L2.87 13.88c-.5.07-.87.5-.87 1l.01 4.61c0 .71.73 1.2 1.39.91z"/>
</svg>
</el-button>
</div>
<div class="input-footer">
<span class="footer-hint">{{ t('vabI18n.chat.sendHint') }}</span>
</div>
</div>
</div>
<!-- 文件预览模态框 -->
<el-dialog
v-model="filePreviewVisible"
:title="t('vabI18n.chat.previewTitle')"
width="80%"
class="file-preview-dialog"
:close-on-click-modal="false"
center
>
<div v-loading="previewLoading" class="preview-content">
<vue-office-pdf
v-if="previewFileUrl"
:src="previewFileUrl"
@rendered="() => { previewLoading = false }"
@error="() => { previewLoading = false; ElMessage.error(t('vabI18n.chat.previewTitleFail')) }"
class="pdf-preview"
/>
</div>
</el-dialog>
<!-- Excel表格预览模态框 -->
<el-dialog
v-model="excelPreviewVisible"
:title="$t('vabI18n.chat.excelPreviewTitle')"
width="90%"
class="excel-preview-dialog"
:close-on-click-modal="false"
center
>
<div v-loading="excelPreviewLoading" class="excel-preview-content">
<div
v-if="excelPreviewContent"
v-html="excelPreviewContent"
class="excel-table-content"
/>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
// 组件注册
const components = {
VueOfficePdf,
VabChart
}
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import { ref, reactive, nextTick, watch, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import VueOfficePdf from '@vue-office/pdf'
import VabChart from '@/plugins/VabChart/index.vue'
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/vue/24/solid'
import { sendChatMessage, type ChatMessageResponse, type ChatMessageSendRequest, stopMessagesStream, getFilePathList, type TraceFile, type TraceData } from '@/api/chat'
import { useI18n } from 'vue-i18n'
import { throttle } from 'lodash'
const expanded = ref(false)
const { t, locale } = useI18n()
// Props 定义
interface Props {
chatType: string // 聊天类型,必填
userId: string // 用户ID必填
assistantAvatar?: string // 助手头像,可选,默认使用内置头像
initialMessages?: Message[] // 初始消息,可选
placeholder?: string // 输入框占位符,可选
maxHistoryCount?: number // 最大历史会话数量,可选
openRemark?: string // 开场白,可选
recommendQuestions?: string[] // 推荐问题列表,可选
}
const props = withDefaults(defineProps<Props>(), {
assistantAvatar: '',
initialMessages: () => [],
placeholder: "",
maxHistoryCount: 50,
openRemark: '',
recommendQuestions: () => []
})
// Events 定义
interface Emits {
streamComplete: [data: {
conversationId: string
messageId: string
content: string
}] // 对话流完成事件
sourceClick: [source: TraceFile] // 文件来源点击事件
messageReceived: [message: Message] // 收到新消息事件
chatCreated: [chatIndex: number] // 创建新会话事件
chatSwitched: [chatIndex: number] // 切换会话事件
}
const emit = defineEmits<Emits>()
interface EChartData {
id: string
option: any
}
interface Message {
text: string
isUser: boolean
isLoading?: boolean
formattedText?: string
sources?: TraceData // 更新为TraceData类型
conversationId?: string // 会话ID
messageId?: string // 消息ID
echarts?: EChartData[] // ECharts 图表数据
isOpenRemark?: boolean // 是否为开场白消息
}
// 消息体底部锚点
const bottomAnchor = ref<HTMLElement | null>(null)
const messages = ref<Message[]>([])
const inputMessage = ref('')
const messagesContainer = ref<HTMLElement | null>(null)
const isLoading = ref(false)
const currentTaskId = ref('')
const showStopButton = ref(false)
const hoveredMessageIndex = ref(-1)
const editingMessageIndex = ref(-1)
const editingMessageText = ref('')
// 文件预览相关状态
const filePreviewVisible = ref(false)
const previewFileUrl = ref('')
const previewLoading = ref(false)
// Excel表格预览相关状态
const excelPreviewVisible = ref(false)
const excelPreviewContent = ref('')
const excelPreviewLoading = ref(false)
// 添加新的状态管理
interface ChatSession {
messages: Message[]
title: string
timestamp: number
conversationId?: string
lastMessageId?: string
}
const isSidebarCollapsed = ref(false)
const chatHistory = ref<ChatSession[]>([])
const currentChatIndex = ref(0)
// 计算sessionStorage的key
const getChatHistoryKey = () => {
return `chat_history_${props.chatType}`
}
// 从sessionStorage加载会话历史
const loadChatHistory = () => {
const savedHistory = sessionStorage.getItem(getChatHistoryKey())
if (savedHistory) {
try {
chatHistory.value = JSON.parse(savedHistory)
// 限制历史会话数量
if (chatHistory.value.length > props.maxHistoryCount) {
chatHistory.value = chatHistory.value.slice(-props.maxHistoryCount)
}
// 如果有历史会话,默认选中最后一个
if (chatHistory.value.length > 0) {
currentChatIndex.value = chatHistory.value.length - 1
messages.value = [...chatHistory.value[currentChatIndex.value].messages]
}
} catch (e) {
console.error('加载会话历史失败:', e)
chatHistory.value = []
}
}
}
// 保存会话历史到sessionStorage
const saveChatHistory = () => {
// 限制历史会话数量
if (chatHistory.value.length > props.maxHistoryCount) {
chatHistory.value = chatHistory.value.slice(-props.maxHistoryCount)
}
sessionStorage.setItem(getChatHistoryKey(), JSON.stringify(chatHistory.value))
}
// 切换侧边栏状态
const toggleSidebar = () => {
isSidebarCollapsed.value = !isSidebarCollapsed.value
}
// 创建新会话
const createNewChat = async () => {
// 保存当前会话(如果有内容的话)
if (messages.value.length > 0 && chatHistory.value[currentChatIndex.value]) {
chatHistory.value[currentChatIndex.value] = {
messages: [...messages.value],
title: messages.value[0]?.text.slice(0, 20) + '...' || '新会话',
timestamp: Date.now(),
conversationId: chatHistory.value[currentChatIndex.value].conversationId,
lastMessageId: chatHistory.value[currentChatIndex.value].lastMessageId
}
saveChatHistory()
}
// 创建新会话初始消息
let initialMessages = [...props.initialMessages]
// 如果有开场白,添加助手消息
if (props.openRemark && props.openRemark.trim()) {
const formatted = await formatAnswer(props.openRemark)
const openRemarkMessage: Message = {
text: props.openRemark,
isUser: false,
formattedText: formatted.html,
echarts: formatted.echarts,
isOpenRemark: true
}
initialMessages.push(openRemarkMessage)
}
// 创建新会话
const newSession: ChatSession = {
messages: initialMessages,
title: '新会话',
timestamp: Date.now()
}
chatHistory.value.push(newSession)
currentChatIndex.value = chatHistory.value.length - 1
messages.value = initialMessages
saveChatHistory()
// 发射会话创建事件
emit('chatCreated', currentChatIndex.value)
// 滚动到底部显示新消息
await nextTick()
throttledScrollToBottom()
}
// 切换会话
const switchChat = (index: number) => {
if (index === currentChatIndex.value) return
// 保存当前会话
if (messages.value.length > 0 && chatHistory.value[currentChatIndex.value]) {
chatHistory.value[currentChatIndex.value] = {
messages: [...messages.value],
title: messages.value[0]?.text.slice(0, 20) + '...' || '新会话',
timestamp: Date.now(),
conversationId: chatHistory.value[currentChatIndex.value].conversationId,
lastMessageId: chatHistory.value[currentChatIndex.value].lastMessageId
}
saveChatHistory()
}
// 切换到选中的会话
currentChatIndex.value = index
messages.value = [...chatHistory.value[index].messages]
// 发射会话切换事件
emit('chatSwitched', index)
}
// 更新当前会话的会话ID和消息ID
const updateSessionIds = (conversationId: string, messageId: string) => {
if (chatHistory.value[currentChatIndex.value]) {
chatHistory.value[currentChatIndex.value].conversationId = conversationId
chatHistory.value[currentChatIndex.value].lastMessageId = messageId
saveChatHistory()
}
}
// 格式化时间
const formatTime = (timestamp: number) => {
const date = new Date(timestamp)
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${date.getMinutes().toString().padStart(2, '0')}`
}
// 生成Excel文件名 (格式: YYYYMMDDHHMM.excel)
const generateExcelFileName = (timestamp: number = Date.now()) => {
const date = new Date(timestamp)
const year = date.getFullYear()
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
const hour = date.getHours().toString().padStart(2, '0')
const minute = date.getMinutes().toString().padStart(2, '0')
return `${year}${month}${day}${hour}${minute}.excel`
}
// 监听消息变化,自动保存到当前会话
watch(messages, (newMessages) => {
if (newMessages.length > 0 && currentChatIndex.value < chatHistory.value.length) {
const currentSession = chatHistory.value[currentChatIndex.value]
chatHistory.value[currentChatIndex.value] = {
messages: [...newMessages],
title: newMessages[0]?.text.slice(0, 20) + '...' || '新会话',
timestamp: Date.now(),
conversationId: currentSession?.conversationId,
lastMessageId: currentSession?.lastMessageId
}
saveChatHistory()
}
}, { deep: true })
// 用户是否正在拖动滚动条
let isUserScrolling = false
// 初始化时加载会话历史
onMounted(async () => {
loadChatHistory()
// 如果没有历史会话,创建一个新会话
if (chatHistory.value.length === 0) {
await createNewChat()
}
//监听用户是否拖动滚动条
messagesContainer.value?.addEventListener('scroll', () => {
const el = messagesContainer.value
if (!el) return
const threshold = 100
isUserScrolling = el.scrollHeight - el.scrollTop - el.clientHeight > threshold
})
})
// 解析ECharts代码块
const parseECharts = (text: string): { processedText: string; echarts: EChartData[] } => {
const echartsRegex = /```echarts\n([\s\S]*?)\n```/g
const echarts: EChartData[] = []
let match
let processedText = text
while ((match = echartsRegex.exec(text)) !== null) {
try {
const echartsJson = match[1].trim()
const option = JSON.parse(echartsJson)
const id = `echart_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
echarts.push({ id, option })
// 将echarts代码块替换为占位符
processedText = processedText.replace(match[0], `<div class="echart-placeholder" data-chart-id="${id}"></div>`)
} catch (error) {
console.error('解析ECharts配置失败:', error)
// 如果解析失败,保留原始代码块
}
}
return { processedText, echarts }
}
// 格式化答案
const formatAnswer = async (text: string) => {
try {
// 先解析ECharts代码块
const { processedText, echarts } = parseECharts(text)
// 配置marked以更好地支持表格
const html = await marked.parse(processedText, {
gfm: true,
breaks: true,
async: true
})
// 为表格添加容器包装和强制样式属性
const wrappedHtml = html.replace(
/<table>/g,
`<details class="table-wrapper" open>
<summary>
Expand / Collapse table
</summary>
<div class="table-container"><table class="excel-table" border="1" width="auto" cellspacing="0" cellpadding="0">`
).replace(
/<\/table>/g,
'</table></div></details>'
).replace(
/<th>/g,
'<th style="border: 1px solid #cbd5e1; padding: 12px; background: #f1f5f9;">'
).replace(
/<td>/g,
'<td style="border: 1px solid #cbd5e1; padding: 12px;">'
)
const sanitizedHtml = DOMPurify.sanitize(wrappedHtml, {
ALLOWED_TAGS: [
'strong', 'em', 'code', 'pre', 'a', 'br', 'p',
'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'span',
'blockquote', 'hr', 'table', 'thead', 'tbody',
'tr', 'th', 'td', 'div', 'details', 'summary'
],
ALLOWED_ATTR: ['href', 'target', 'class', 'style', 'data-chart-id', 'border', 'cellspacing', 'cellpadding', 'open']
}) as string
console.log('Markdown输入:', processedText)
console.log('生成的HTML:', sanitizedHtml)
return { html: sanitizedHtml, echarts }
} catch (error) {
console.error('Error formatting answer:', error)
return { html: text, echarts: [] }
}
}
const sendMessage = async () => {
if (!inputMessage.value.trim() || isLoading.value) return
try {
isLoading.value = true
const userQuery = inputMessage.value
inputMessage.value = ''
// 添加用户消息
const userMessage: Message = {
text: userQuery,
isUser: true,
}
messages.value.push(userMessage)
// 发射消息接收事件
emit('messageReceived', userMessage)
// 创建响应式机器人消息
const botMessage = reactive<Message>({
text: '',
isUser: false,
isLoading: true,
formattedText: ''
})
messages.value.push(botMessage)
await scrollToBottom()
// 获取当前会话的会话ID
const currentSession = chatHistory.value[currentChatIndex.value]
const conversationId = currentSession?.conversationId
// 发起请求
const response = await sendChatMessage({
content: userQuery,
userId: props.userId,
chatType: props.chatType,
conversationId: conversationId // 如果有会话ID则带上
})
// 显示中止按钮
showStopButton.value = true
if (!response.body) throw new Error('No response body')
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let newConversationId = ''
let newMessageId = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const chunks = buffer.split(/\n\n/)
buffer = chunks.pop() || ''
for (const chunk of chunks) {
const eventData = chunk.trim()
if (!eventData) continue
const jsonData = eventData.replace(/^data:\s*/, '')
try {
const data = JSON.parse(jsonData) as ChatMessageResponse
if (data.answer) {
botMessage.text += data.answer
const formatted = await formatAnswer(parseThink(botMessage.text).answer)
botMessage.formattedText = formatted.html
botMessage.echarts = formatted.echarts
// 更新会话ID和消息ID
if (data.conversationId) {
newConversationId = data.conversationId
}
if (data.messageId) {
newMessageId = data.messageId
}
// 获取并存储taskId
if (data.taskId) {
currentTaskId.value = data.taskId
}
throttledScrollToBottom()
}
} catch (e) {
console.error('解析错误:', e)
}
}
}
// 处理剩余数据
if (buffer) {
const eventData = buffer.trim()
if (eventData) {
try {
const jsonData = eventData.replace(/^data:\s*/, '')
const data = JSON.parse(jsonData)
if (data.answer) {
botMessage.text += data.answer
const formatted = await formatAnswer(parseThink(botMessage.text).answer)
botMessage.formattedText = formatted.html
botMessage.echarts = formatted.echarts
if (data.conversationId) {
newConversationId = data.conversationId
}
if (data.messageId) {
newMessageId = data.messageId
}
}
} catch (e) {
console.error('最终解析错误:', e)
}
}
}
// 更新会话ID和消息ID
if (newConversationId || newMessageId) {
updateSessionIds(newConversationId, newMessageId)
}
// 获取文件来源数据仅当chatType=1时
if (newConversationId && newMessageId && props.chatType === '1') {
try {
const { data } = await getFilePathList(newConversationId, newMessageId)
if (data && ((data.tracePdf && data.tracePdf.length > 0) || (data.traceExcel && data.traceExcel.trim()))) {
botMessage.sources = data
botMessage.conversationId = newConversationId
botMessage.messageId = newMessageId
}
} catch (error) {
console.log('获取文件来源失败:', error)
// 不显示错误信息,静默失败
}
}
// 发射消息接收事件
emit('messageReceived', botMessage)
// 流式请求完成,发送事件
emit('streamComplete', {
conversationId: newConversationId,
messageId: newMessageId,
content: userQuery
})
} catch (error) {
console.error('请求失败:', error)
const errorMessage: Message = {
text: '请求失败,请稍后重试',
isUser: false,
}
messages.value.push(errorMessage)
emit('messageReceived', errorMessage)
} finally {
isLoading.value = false
showStopButton.value = false // 隐藏中止按钮
currentTaskId.value = '' // 清除taskId
if (messages.value[messages.value.length - 1]?.isLoading) {
messages.value[messages.value.length - 1].isLoading = false
}
scrollToBottomForce()
}
}
const scrollToBottomForce = async () => {
await nextTick()
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
//将锚点滚动到最底部
const scrollToBottom = async () => {
await nextTick()
setTimeout(() => {
requestAnimationFrame(() => {
if (messagesContainer.value && !isUserScrolling) {
bottomAnchor.value.scrollIntoView({
behavior: 'smooth',
block: 'start'
})
}
})
},60)
}
const throttledScrollToBottom = throttle(() => {
scrollToBottom()
}, 300,{ leading: true, trailing: true })
// 解析方法
const parseThink = (text: string) => {
const thinkRegex = /<think>(.*?)<\/think>/s
const match = text.match(thinkRegex)
return {
think: match?.[1] || '',
answer: match ? text.replace(thinkRegex, '').trim() : text,
}
}
// 获取文件类型图标 - 使用动态导入确保路径正确
const getFileTypeIcon = (fileType: string) => {
const icons = require.context('@/assets/img/filetype-icon', false, /\.png$/)
// console.log(icons)
// console.log('所有图标 keys:', icons.keys())
const getIconUrl = (iconName: string) => {
const fullName = `./${iconName}.png`
if (icons.keys().includes(fullName)) {
return icons(fullName)
} else {
// console.warn('图标不存在:', fullName)
return ''
}
}
const iconMap: Record<string, string> = {
'pdf': getIconUrl('pdf'),
'doc': getIconUrl('word'),
'docx': getIconUrl('word'),
'xls': getIconUrl('excel'),
'xlsx': getIconUrl('excel'),
'ppt': getIconUrl('ppt'),
'pptx': getIconUrl('ppt'),
'txt': getIconUrl('txt'),
'xml': getIconUrl('xml'),
'zip': getIconUrl('zip'),
'mp4': getIconUrl('video'),
'avi': getIconUrl('video'),
'mp3': getIconUrl('audio'),
'wav': getIconUrl('audio'),
'jpg': getIconUrl('img'),
'jpeg': getIconUrl('img'),
'png': getIconUrl('img'),
'gif': getIconUrl('img'),
}
return iconMap[fileType.toLowerCase()] || getIconUrl('unknown')
}
// 获取文件扩展名
const getFileExtension = (fileName: string) => {
return fileName.split('.').pop()?.toLowerCase() || ''
}
// 处理文件来源点击事件
const handleSourceClick = async (source: TraceFile, conversationId?: string, messageId?: string) => {
// 只有chatType=1时才允许点击预览
if (props.chatType !== '1') {
return
}
if (!conversationId || !messageId) {
ElMessage.warning('缺少会话信息,无法预览文件')
return
}
try {
previewLoading.value = true
filePreviewVisible.value = true
// 构造完整的PDF预览URL
const baseUrl = window.location.protocol + '//' + window.location.host
previewFileUrl.value = baseUrl + source.filePath
console.log('预览文件:', source)
console.log('预览URL:', previewFileUrl.value)
// 发射文件来源点击事件,让父组件处理
emit('sourceClick', source)
} catch (error) {
console.error('获取文件预览失败:', error)
ElMessage.error('获取文件预览失败')
filePreviewVisible.value = false
} finally {
previewLoading.value = false
}
}
// 处理Excel表格点击事件
const handleExcelClick = async (excelContent: string) => {
try {
excelPreviewLoading.value = true
excelPreviewVisible.value = true
// 将 \n 字符串转换为真正的换行符
const processedContent = excelContent.replace(/\\n/g, '\n')
// 如果marked转换失败尝试手动解析markdown表格
let htmlContent = ''
try {
const formattedContent = await formatAnswer(processedContent)
htmlContent = formattedContent.html
} catch (error) {
console.warn('marked解析失败尝试手动解析:', error)
htmlContent = parseMarkdownTable(processedContent)
}
// 如果HTML中没有table标签尝试手动解析
if (!htmlContent.includes('<table')) {
console.warn('未检测到表格手动解析markdown')
htmlContent = parseMarkdownTable(processedContent)
}
excelPreviewContent.value = htmlContent
console.log('原始Excel数据:', excelContent)
console.log('处理后的Excel数据:', processedContent)
console.log('最终HTML:', htmlContent)
} catch (error) {
console.error('解析Excel表格失败:', error)
ElMessage.error('解析Excel表格失败')
excelPreviewVisible.value = false
} finally {
excelPreviewLoading.value = false
}
}
// 手动解析markdown表格为HTML
const parseMarkdownTable = (markdown: string): string => {
const lines = markdown.trim().split('\n')
if (lines.length < 3) return markdown // 至少需要表头、分隔线、数据行
const headerLine = lines[0]
const separatorLine = lines[1]
const dataLines = lines.slice(2)
// 检查是否是有效的markdown表格
if (!headerLine.includes('|') || !separatorLine.includes('---')) {
return markdown
}
// 解析表头
const headers = headerLine.split('|').map(h => h.trim()).filter(h => h)
// 解析数据行
const rows = dataLines.map(line =>
line.split('|').map(cell => cell.trim()).filter(cell => cell)
).filter(row => row.length > 0)
// 生成HTML表格
let html = `
<div class="table-container">
<table class="excel-table" border="1" cellspacing="0" cellpadding="0" style="border-collapse: collapse; width: auto; border: 2px solid #cbd5e1;">
<thead>
<tr>
`
headers.forEach(header => {
html += `<th style="border: 1px solid #cbd5e1; padding: 12px; background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%); font-weight: 600; text-align: left; position: sticky; top: 0; z-index: 2; white-space: nowrap;">${header}</th>`
//html += `<th style="border: 1px solid #cbd5e1; padding: 12px; background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%); font-weight: 600; text-align: left;">${header}</th>`
})
html += `
</tr>
</thead>
<tbody>
`
rows.forEach((row, rowIndex) => {
html += '<tr>'
row.forEach((cell, cellIndex) => {
const cellStyle = rowIndex % 2 === 0
? 'border: 1px solid #cbd5e1; padding: 12px; background: white;'
: 'border: 1px solid #cbd5e1; padding: 12px; background: #f8fafc;'
html += `<td style="${cellStyle}">${cell}</td>`
})
html += '</tr>'
})
html += `
</tbody>
</table>
</div>
`
return html
}
// 获取助手头像
const getAssistantAvatar = () => {
return props.assistantAvatar || new URL('@/assets/assistant.png', import.meta.url).href
}
// 中止流式回答
const handleStopStream = async () => {
if (currentTaskId.value) {
try {
await stopMessagesStream(Number(props.chatType), currentTaskId.value, props.userId)
// 隐藏中止按钮
showStopButton.value = false
// 停止加载状态
isLoading.value = false
// 移除当前消息的加载状态
if (messages.value.length > 0) {
const lastMessage = messages.value[messages.value.length - 1]
if (!lastMessage.isUser && lastMessage.isLoading) {
lastMessage.isLoading = false
if (!lastMessage.text.trim()) {
lastMessage.text = '已停止生成'
}
}
}
// 清除taskId
currentTaskId.value = ''
ElMessage.success('已停止生成')
} catch (error) {
console.error('停止流式回答失败:', error)
ElMessage.error('停止失败,请重试')
}
}
}
// 处理消息悬浮事件
const handleMessageHover = (index: number, isHovered: boolean) => {
hoveredMessageIndex.value = isHovered ? index : -1
}
// 复制消息内容
const handleCopyMessage = async (message: Message) => {
const textToCopy = message.text
try {
// 优先使用现代的 Clipboard API
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(textToCopy)
ElMessage.success('已复制到剪贴板')
return
}
// 回退到传统方法
const textArea = document.createElement('textarea')
textArea.value = textToCopy
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
textArea.style.top = '-999999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
const successful = document.execCommand('copy')
document.body.removeChild(textArea)
if (successful) {
ElMessage.success('已复制到剪贴板')
} else {
throw new Error('execCommand failed')
}
} catch (error) {
console.error('复制失败:', error)
ElMessage.error('复制失败')
}
}
// 编辑消息
const handleEditMessage = (message: Message, index: number) => {
editingMessageIndex.value = index
editingMessageText.value = message.text
hoveredMessageIndex.value = -1 // 隐藏操作按钮
}
// 取消编辑
const handleCancelEdit = () => {
editingMessageIndex.value = -1
editingMessageText.value = ''
}
// 保存编辑
const handleSaveEdit = async () => {
if (!editingMessageText.value.trim()) return
const newText = editingMessageText.value.trim()
// 重置编辑状态
editingMessageIndex.value = -1
editingMessageText.value = ''
// 将编辑后的文本作为新的用户问题发送
inputMessage.value = newText
await sendMessage()
}
// 重新生成回答
const handleRegenerateMessage = async (index: number) => {
if (index === 0) return
// 获取前一条用户消息
const userMessage = messages.value[index - 1]
if (!userMessage || !userMessage.isUser) return
// 删除当前助手消息及之后的消息
messages.value = messages.value.slice(0, index)
// 直接重新生成,不创建新的用户消息
await regenerateResponse(userMessage.text)
}
// 重新生成回答的核心逻辑
const regenerateResponse = async (userQuery: string) => {
try {
isLoading.value = true
// 创建响应式机器人消息
const botMessage = reactive<Message>({
text: '',
isUser: false,
isLoading: true,
formattedText: ''
})
messages.value.push(botMessage)
throttledScrollToBottom()
// 获取当前会话的会话ID
const currentSession = chatHistory.value[currentChatIndex.value]
const conversationId = currentSession?.conversationId
// 发起请求
const response = await sendChatMessage({
content: userQuery,
userId: props.userId,
chatType: props.chatType,
conversationId: conversationId
})
// 显示中止按钮
showStopButton.value = true
if (!response.body) throw new Error('No response body')
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let newConversationId = ''
let newMessageId = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const chunks = buffer.split(/\n\n/)
buffer = chunks.pop() || ''
for (const chunk of chunks) {
const eventData = chunk.trim()
if (!eventData) continue
const jsonData = eventData.replace(/^data:\s*/, '')
try {
const data = JSON.parse(jsonData) as ChatMessageResponse
if (data.answer) {
botMessage.text += data.answer
const formatted = await formatAnswer(parseThink(botMessage.text).answer)
botMessage.formattedText = formatted.html
botMessage.echarts = formatted.echarts
// 更新会话ID和消息ID
if (data.conversationId) {
newConversationId = data.conversationId
}
if (data.messageId) {
newMessageId = data.messageId
}
// 获取并存储taskId
if (data.taskId) {
currentTaskId.value = data.taskId
}
throttledScrollToBottom()
}
} catch (e) {
console.error('解析错误:', e)
}
}
}
// 处理剩余数据
if (buffer) {
const eventData = buffer.trim()
if (eventData) {
try {
const jsonData = eventData.replace(/^data:\s*/, '')
const data = JSON.parse(jsonData)
if (data.answer) {
botMessage.text += data.answer
const formatted = await formatAnswer(parseThink(botMessage.text).answer)
botMessage.formattedText = formatted.html
botMessage.echarts = formatted.echarts
if (data.conversationId) {
newConversationId = data.conversationId
}
if (data.messageId) {
newMessageId = data.messageId
}
}
} catch (e) {
console.error('最终解析错误:', e)
}
}
}
// 更新会话ID和消息ID
if (newConversationId || newMessageId) {
updateSessionIds(newConversationId, newMessageId)
}
// 获取文件来源数据仅当chatType=1时
if (newConversationId && newMessageId && props.chatType === '1') {
try {
const { data } = await getFilePathList(newConversationId, newMessageId)
if (data && ((data.tracePdf && data.tracePdf.length > 0) || (data.traceExcel && data.traceExcel.trim()))) {
botMessage.sources = data
botMessage.conversationId = newConversationId
botMessage.messageId = newMessageId
}
} catch (error) {
console.log('获取文件来源失败:', error)
// 不显示错误信息,静默失败
}
}
// 发射消息接收事件
emit('messageReceived', botMessage)
// 流式请求完成,发送事件
emit('streamComplete', {
conversationId: newConversationId,
messageId: newMessageId,
content: userQuery
})
} catch (error) {
console.error('重新生成失败:', error)
const errorMessage: Message = {
text: '重新生成失败,请稍后重试',
isUser: false,
}
messages.value.push(errorMessage)
emit('messageReceived', errorMessage)
} finally {
isLoading.value = false
showStopButton.value = false
currentTaskId.value = ''
if (messages.value[messages.value.length - 1]?.isLoading) {
messages.value[messages.value.length - 1].isLoading = false
}
scrollToBottomForce()
}
}
// 点赞消息
const handleLikeMessage = (index: number) => {
// TODO: 实现点赞逻辑调用相关API
ElMessage.success('感谢您的反馈!')
}
// 点踩消息
const handleDislikeMessage = (index: number) => {
// TODO: 实现点踩逻辑调用相关API
ElMessage.info('感谢您的反馈,我们会继续改进')
}
// 处理推荐问题点击事件
const handleRecommendQuestionClick = (question: string) => {
if (!question.trim() || isLoading.value) return
// 将问题内容填入输入框
inputMessage.value = question
// 发送问题
sendMessage()
}
</script>
<style scoped>
.chat-container {
height: 100%;
display: flex;
background: #ffffff;
position: relative;
overflow: hidden;
border: 1px solid #e2e8f0;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
/* 侧边栏样式 */
.chat-sidebar {
width: 280px;
background: #f8fafc;
border-right: 1px solid #e2e8f0;
display: flex;
flex-direction: column;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
flex-shrink: 0;
transform-origin: left;
will-change: width, transform;
border-top-left-radius: 12px;
border-bottom-left-radius: 12px;
}
.sidebar-collapsed {
width: 48px;
}
.sidebar-header {
padding: 1rem;
border-bottom: 1px solid #e2e8f0;
display: flex;
flex-direction: column;
gap: 0.75rem;
transition: opacity 0.2s ease;
white-space: nowrap;
overflow: hidden;
}
.sidebar-collapsed .sidebar-header {
opacity: 0;
visibility: hidden;
}
.chat-history {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
transition: opacity 0.2s ease;
}
.sidebar-collapsed .chat-history {
opacity: 0;
visibility: hidden;
}
.sidebar-header h3 {
margin: 0;
font-size: 1rem;
color: #1e293b;
}
.new-chat-btn {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
justify-content: center;
background: #4f46e5;
border: none;
padding: 0.5rem;
border-radius: 8px;
color: white;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s ease;
}
.new-chat-btn:hover {
background: #4338ca;
}
.new-chat-icon {
width: 16px;
height: 16px;
}
.chat-history-item {
padding: 0.75rem;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
margin-bottom: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.chat-history-item:hover {
background: #f1f5f9;
}
.chat-history-item.active {
background: #e0e7ff;
}
.chat-title {
font-size: 0.875rem;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-time {
font-size: 0.75rem;
color: #64748b;
}
.sidebar-toggle {
position: absolute;
right: -12px;
top: 50%;
transform: translateY(-50%);
width: 24px;
height: 24px;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
z-index: 10;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.sidebar-toggle:hover {
background: #f8fafc;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-50%) scale(1.1);
}
.sidebar-toggle:active {
transform: translateY(-50%) scale(0.95);
}
.toggle-icon {
width: 16px;
height: 16px;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transform-origin: center;
}
.toggle-icon.collapsed {
transform: rotate(180deg);
}
/* 主聊天区域样式调整 */
.chat-main {
flex: 1;
display: flex;
flex-direction: column;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
min-width: 0;
width: 100%;
position: relative;
overflow: hidden;
border-top-right-radius: 12px;
border-bottom-right-radius: 12px;
}
.main-expanded {
margin-left: 0;
width: 100%;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 2rem 0;
padding-bottom: 100px;
scroll-behavior: smooth;
background: #f9fafb;
width: 100%;
}
.message-item {
display: flex;
padding: 1rem 2rem;
position: relative;
width: 100%;
box-sizing: border-box;
}
.message-item.user-message {
flex-direction: row-reverse;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 1rem;
flex-shrink: 0;
}
.message-content-wrapper {
max-width: 85%;
position: relative;
}
/* 当消息包含图表时,扩大消息气泡宽度 */
.assistant-message .message-content-wrapper:has(.echarts-container) {
max-width: 95%;
width: 95%;
}
/* 兼容性:通过类名控制图表消息宽度 */
.assistant-message .message-content-wrapper.has-charts {
max-width: 95%;
width: 95%;
}
.message-content {
padding: 1rem 1.5rem;
border-radius: 12px;
position: relative;
word-break: break-word;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.user-message .message-content {
background: #EFF6FF;
border-radius: 16px 2px 16px 16px;
}
.assistant-message .message-content {
background: #FDFDFD;
border-radius: 2px 16px 16px 16px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
/* 消息操作按钮样式 */
.message-actions {
position: absolute;
bottom: -2.5rem;
display: flex;
gap: 0.5rem;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(8px);
border-radius: 12px;
padding: 0.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border: 1px solid rgba(0, 0, 0, 0.08);
animation: fadeInUp 0.2s ease-out;
z-index: 10;
}
.user-actions {
right: 0;
}
.assistant-actions {
left: 0;
}
/* 操作按钮样式 */
.action-btn {
width: 32px;
height: 32px;
border-radius: 8px;
border: none;
background: rgba(0, 0, 0, 0.04);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.action-btn:hover {
background: rgba(0, 0, 0, 0.08);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.12);
}
.action-btn:active {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
}
.action-icon {
width: 16px;
height: 16px;
color: #64748b;
transition: color 0.2s ease;
}
.action-btn:hover .action-icon {
color: #374151;
}
/* 特定按钮颜色 */
.copy-btn:hover {
background: rgba(59, 130, 246, 0.1);
}
.copy-btn:hover .action-icon {
color: #3b82f6;
}
.edit-btn:hover {
background: rgba(245, 158, 11, 0.1);
}
.edit-btn:hover .action-icon {
color: #f59e0b;
}
.regenerate-btn:hover {
background: rgba(16, 185, 129, 0.1);
}
.regenerate-btn:hover .action-icon {
color: #10b981;
}
.like-btn:hover {
background: rgba(34, 197, 94, 0.1);
}
.like-btn:hover .action-icon {
color: #22c55e;
}
.dislike-btn:hover {
background: rgba(239, 68, 68, 0.1);
}
.dislike-btn:hover .action-icon {
color: #ef4444;
}
/* 按钮出现动画 */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 编辑容器样式 */
.edit-container {
width: 100%;
max-width: 600px;
position: relative;
}
.edit-input-wrapper {
position: relative;
background: #ffffff;
border: 1.5px solid #e5e7eb;
border-radius: 12px;
transition: all 0.3s ease;
overflow: hidden;
}
.edit-input-wrapper:focus-within {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.edit-textarea {
width: 100%;
}
.edit-textarea :deep(.el-textarea__inner) {
border: none;
background: transparent;
font-size: 0.95rem;
line-height: 1.6;
padding: 1rem 1rem 3rem 1rem;
resize: none;
box-shadow: none;
border-radius: 0;
}
.edit-textarea :deep(.el-textarea__inner:focus) {
border: none;
box-shadow: none;
outline: none;
}
.edit-actions {
position: absolute;
bottom: 0.75rem;
right: 0.75rem;
display: flex;
gap: 0.5rem;
z-index: 10;
}
.edit-btn {
padding: 0.375rem 0.75rem;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid transparent;
outline: none;
min-width: 48px;
text-align: center;
}
.cancel-btn {
background: #f8fafc;
color: #64748b;
border-color: #e2e8f0;
}
.cancel-btn:hover {
background: #f1f5f9;
color: #475569;
border-color: #cbd5e1;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
}
.cancel-btn:active {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
.save-btn {
background: #3b82f6;
color: #ffffff;
border-color: #3b82f6;
}
.save-btn:hover:not(:disabled) {
background: #2563eb;
border-color: #2563eb;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(59, 130, 246, 0.3);
}
.save-btn:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.2);
}
.save-btn:disabled {
background: #e5e7eb;
color: #9ca3af;
border-color: #e5e7eb;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.message-header {
margin-bottom: 0.5rem;
display: flex;
align-items: center;
}
.message-sender {
font-size: 0.875rem;
color: #6b7280;
font-weight: 500;
}
.message-body {
font-size: 0.95rem;
line-height: 1.6;
color: #1f2937;
}
.think-section {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
font-size: 0.9rem;
color: #64748b;
}
.think-header {
display: flex;
align-items: center;
color: #64748b;
font-weight: 600;
margin-bottom: 0.5rem;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.think-icon {
width: 16px;
height: 16px;
margin-right: 0.5rem;
}
.answer-content {
color: #1f2937;
line-height: 1.7;
font-size: 0.95rem;
}
/* 标题样式 */
.answer-content :deep(h1),
.answer-content :deep(h2),
.answer-content :deep(h3) {
margin: 1.5rem 0 1rem;
font-weight: 600;
line-height: 1.3;
}
.answer-content :deep(h1) {
font-size: 1.5rem;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 0.5rem;
}
.answer-content :deep(h2) {
font-size: 1.3rem;
}
.answer-content :deep(h3) {
font-size: 1.1rem;
}
/* 段落和列表样式 */
.answer-content :deep(p) {
margin: 1rem 0;
}
.answer-content :deep(ul),
.answer-content :deep(ol) {
margin: 1rem 0;
padding-left: 1.5rem;
}
.answer-content :deep(li) {
margin: 0.5rem 0;
}
.answer-content :deep(ul li) {
list-style-type: disc;
}
.answer-content :deep(ol li) {
list-style-type: decimal;
}
/* 链接样式 */
.answer-content :deep(a) {
color: #4f46e5;
text-decoration: none;
border-bottom: 1px solid transparent;
transition: border-color 0.2s ease;
}
.answer-content :deep(a:hover) {
border-bottom-color: #4f46e5;
}
/* 强调和加粗 */
.answer-content :deep(em) {
font-style: italic;
}
.answer-content :deep(strong) {
font-weight: 600;
color: #111827;
}
/* 代码样式 */
.answer-content :deep(code) {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.9em;
background: #f3f4f6;
padding: 0.2em 0.4em;
border-radius: 4px;
color: #ef4444;
}
.answer-content :deep(pre) {
background: #1e293b;
color: #e2e8f0;
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
margin: 1rem 0;
}
.answer-content :deep(pre code) {
background: none;
padding: 0;
color: inherit;
font-size: 0.9rem;
line-height: 1.6;
}
/* 引用样式 */
.answer-content :deep(blockquote) {
margin: 1rem 0;
padding: 0.5rem 1rem;
border-left: 4px solid #e5e7eb;
background: #f9fafb;
color: #4b5563;
}
.answer-content :deep(blockquote p) {
margin: 0.5rem 0;
}
/* 水平线 */
.answer-content :deep(hr) {
margin: 2rem 0;
border: 0;
border-top: 1px solid #e5e7eb;
}
/* 文件来源样式 */
.sources-section {
margin-top: 1rem;
padding: 0.75rem;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 0.875rem;
}
.sources-header {
display: flex;
align-items: center;
color: #64748b;
font-weight: 600;
margin-bottom: 0.5rem;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.sources-icon {
width: 14px;
height: 14px;
margin-right: 0.5rem;
}
.sources-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.source-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 20px;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.8rem;
max-width: 200px;
}
.source-item:hover {
background: #f1f5f9;
border-color: #cbd5e1;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.source-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
object-fit: contain;
}
.source-name {
color: #374151;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
font-weight: 500;
}
/* 表格样式优化 */
.answer-content :deep(table) {
width: auto;
min-width: 1000px;
max-width: 2500px;
margin: 1.5rem 0;
border-collapse: collapse;
border-radius: 8px;
overflow: auto;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
background: white;
font-size: 0.9rem;
line-height: 1.4;
border: 1px solid #e5e7eb;
}
::v-deep .table-wrapper summary {
cursor: pointer;
display: list-item;
align-items: center;
gap: 8px;
font-weight: 600;
padding: 6px 8px;
background: #f8fafc;
/* border: 1px solid #e2e8f0; */
/* border-radius: 6px; */
font-size: 14px;
}
/* .excel-table th,
.excel-table td {
white-space: nowrap;
} */
.excel-table th {
white-space: nowrap; /* 表头不换行 */
}
.excel-table td {
min-width: 1000px; /* 内容列设置最小宽度 */
white-space: normal; /* 允许内容换行(可选) */
}
.answer-content :deep(th),
.answer-content :deep(td) {
padding: 0.75rem 1rem;
border: 1px solid #e5e7eb;
text-align: left;
vertical-align: top;
word-wrap: break-word;
max-width: 200px;
word-break: break-word;
white-space: normal;
}
.answer-content :deep(th) {
white-space: nowrap;
background: #f8fafc;
font-weight: 600;
color: #374151;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.025em;
position: sticky;
top: 0;
z-index: 1;
border-bottom: 2px solid #e5e7eb;
}
.answer-content :deep(td) {
background: white;
transition: background-color 0.2s ease;
}
.answer-content :deep(tr:hover td) {
background: #f8fafc;
}
.answer-content :deep(tr:nth-child(even) td) {
background: #fafbfc;
}
.answer-content :deep(tr:nth-child(even):hover td) {
background: #f1f5f9;
}
.table-container {
max-height: 350px; /* 限制高度,超出时滚动 */
overflow-x: auto;
overflow-x: auto; /* 保留横向滚动条(必要) */
width: 100%;
max-width: 100%;
}
.table-container table {
max-width: 2000px;
table-layout: auto; /* 或 fixed视实际内容适配性而定 */
width: auto;
}
/* 表格容器,支持横向滚动 */
.answer-content :deep(.table-container) {
overflow-x: auto;
overflow-y: auto;
width:100%;
max-width: 100%; /* 限制在父容器内不超出 */
max-height: 350px; /* 限制表格高度,触发纵向滚动(根据实际需要可调) */
margin: 1.5rem 0;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
/* 移动端表格优化 */
@media (max-width: 768px) {
.answer-content :deep(table) {
min-width: 600px;
font-size: 0.8rem;
margin: 1rem 0;
border: 1px solid #e5e7eb;
}
.answer-content :deep(th),
.answer-content :deep(td) {
padding: 0.5rem 0.75rem;
max-width: 150px;
border: 1px solid #e5e7eb;
}
.answer-content :deep(th) {
font-size: 0.75rem;
border-bottom: 2px solid #e5e7eb;
}
}
.input-area {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: #f9fafb;
padding: 2rem;
position: relative;
}
/* 中止按钮容器 */
.stop-button-container {
position: absolute;
top: -2.5rem;
left: 50%;
transform: translateX(-50%);
z-index: 10;
animation: fadeInDown 0.3s ease-out;
}
/* 中止按钮样式 */
.stop-button {
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(239, 68, 68, 0.1);
backdrop-filter: blur(10px);
border: 1.5px solid rgba(239, 68, 68, 0.2);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 3px 8px rgba(239, 68, 68, 0.15);
position: relative;
overflow: hidden;
}
.stop-button::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(circle, rgba(239, 68, 68, 0.1) 0%, transparent 70%);
opacity: 0;
transition: opacity 0.3s ease;
}
.stop-button:hover {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.3);
transform: scale(1.05);
box-shadow: 0 6px 20px rgba(239, 68, 68, 0.25);
}
.stop-button:hover::before {
opacity: 1;
}
.stop-button:active {
transform: scale(0.95);
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.2);
}
.stop-icon {
width: 16px;
height: 16px;
color: #ef4444;
transition: all 0.3s ease;
z-index: 1;
position: relative;
}
.stop-button:hover .stop-icon {
color: #dc2626;
transform: scale(1.1);
}
/* 按钮出现动画 */
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateX(-50%) translateY(-20px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
.input-wrapper {
max-width: 900px;
margin: 0 auto;
position: relative;
background: #ffffff;
border-radius: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
}
.input-wrapper:focus-within {
box-shadow: 0 4px 20px rgba(79, 70, 229, 0.15);
}
:deep(.el-textarea__inner) {
padding: 1rem 4rem 1rem 1.5rem;
border-radius: 16px;
border: 1px solid #e5e7eb;
background: transparent;
font-size: 0.95rem;
line-height: 1.5;
resize: none;
transition: all 0.3s ease;
box-shadow: none;
}
:deep(.el-textarea__inner:focus) {
border-color: #4f46e5;
}
.send-button {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
height: 2.5rem;
width: 2.5rem;
min-height: 2.5rem;
padding: 0;
border-radius: 50%;
background: #4f46e5;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
justify-content: center;
border: none;
cursor: pointer;
box-shadow: 0 2px 8px rgba(79, 70, 229, 0.25);
overflow: hidden;
}
.send-button:not(:disabled) {
background: linear-gradient(135deg, #4f46e5 0%, #4338ca 100%);
}
.send-button:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.send-button:hover:not(:disabled) {
transform: translateY(-50%) scale(1.05);
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3);
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
}
.send-icon {
width: 1.25rem;
height: 1.25rem;
margin-left: 2px;
transition: transform 0.3s ease;
}
.loading-icon {
width: 1.25rem;
height: 1.25rem;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: #ffffff;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
:deep(.el-button.is-loading) {
pointer-events: none;
opacity: 0.9;
}
:deep(.el-button.is-loading .el-loading-mask) {
display: none;
}
:deep(.el-button.is-loading::before) {
display: none;
}
.input-footer {
display: none;
}
@media (max-width: 768px) {
.chat-container {
border-radius: 8px;
}
.chat-sidebar {
border-radius: 0;
}
.chat-main {
border-radius: 0;
}
.chat-sidebar {
position: absolute;
height: 100%;
z-index: 10;
box-shadow: none;
}
.sidebar-collapsed {
transform: translateX(-100%);
}
.sidebar-toggle {
right: -16px;
background: #ffffff;
box-shadow: none;
}
.message-item {
padding: 0.75rem 1rem;
}
.message-content-wrapper {
max-width: 90%;
}
.message-content {
padding: 0.75rem 1rem;
}
/* 移动端消息操作按钮适配 */
.message-actions {
bottom: -2rem;
padding: 0.375rem;
gap: 0.375rem;
border-radius: 8px;
}
.action-btn {
width: 28px;
height: 28px;
border-radius: 6px;
}
.action-icon {
width: 14px;
height: 14px;
}
.input-area {
padding: 1.5rem 1rem;
padding-top: 2.5rem;
}
.avatar {
width: 32px;
height: 32px;
margin: 0 0.75rem;
}
.chat-messages {
padding-bottom: 100px;
}
.send-button {
right: 0.5rem;
height: 2.25rem;
width: 2.25rem;
min-height: 2.25rem;
}
.send-button .send-icon,
.loading-icon {
width: 1rem;
height: 1rem;
}
.loading-icon {
border-width: 1.5px;
}
/* 移动端文件来源样式 */
.sources-section {
padding: 0.5rem;
margin-top: 0.75rem;
}
.sources-list {
gap: 0.375rem;
}
.source-item {
padding: 0.375rem 0.5rem;
font-size: 0.75rem;
max-width: 150px;
}
.source-icon {
width: 14px;
height: 14px;
}
/* 移动端中止按钮适配 */
.stop-button-container {
top: -2rem;
}
.stop-button {
width: 32px;
height: 32px;
}
.stop-icon {
width: 14px;
height: 14px;
}
}
.loading-content {
padding: 1rem;
display: flex;
justify-content: center;
}
.loading-dots {
display: flex;
align-items: center;
gap: 0.5rem;
}
.loading-dots .dot {
width: 0.5rem;
height: 0.5rem;
background-color: #e2e8f0;
border-radius: 50%;
animation: pulse 1.4s infinite;
}
.loading-dots .dot:nth-child(2) {
animation-delay: 0.2s;
}
.loading-dots .dot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes pulse {
0%, 80%, 100% {
transform: scale(0.8);
opacity: 0.4;
}
40% {
transform: scale(1);
opacity: 1;
}
}
.typing-indicator {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-top: 0.5rem;
}
.dot {
width: 4px;
height: 4px;
background: #6b7280;
border-radius: 50%;
animation: bounce 1.4s infinite;
}
.dot:nth-child(2) { animation-delay: 0.2s; }
.dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes bounce {
0%, 80%, 100% { transform: translateY(0); }
40% { transform: translateY(-4px); }
}
.avatar-icon {
width: 24px;
height: 24px;
object-fit: cover;
border-radius: 50%;
}
/* 文件预览模态框样式 */
.file-preview-dialog {
.el-dialog {
border-radius: 12px;
overflow: hidden;
}
.el-dialog__header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 12px 12px 0 0;
margin: 0;
padding: 20px 24px;
}
.el-dialog__title {
color: white;
font-weight: 600;
font-size: 18px;
}
.el-dialog__headerbtn {
.el-dialog__close {
color: white;
font-size: 20px;
transition: all 0.2s ease;
&:hover {
color: rgba(255, 255, 255, 0.8);
transform: scale(1.1);
}
}
}
.el-dialog__body {
padding: 0;
background: #f5f7fa;
}
}
.preview-content {
min-height: 600px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f7fa;
border-radius: 0 0 12px 12px;
.pdf-preview {
width: 100%;
height: 600px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
background: white;
}
}
/* ECharts 图表样式 */
.echarts-container {
margin: 1rem auto; /* 使用auto实现水平居中 */
padding: 1.5rem;
background: #f8fafc;
border-radius: 12px;
border: 1px solid #e2e8f0;
/* 缩减到60%宽度,保持良好的显示比例 */
width: 60%;
max-width: 1000px; /* 设置最大宽度避免在大屏上过宽 */
min-width: 400px; /* 设置最小宽度确保可读性 */
position: relative;
}
::v-deep .icon-chevron {
width: 20px;
height: 20px;
transition: transform 0.3s ease;
color: #333; /* 让箭头颜色和文字颜色一致 */
}
.chart-wrapper {
margin-bottom: 1.5rem;
background: #ffffff;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.chart-wrapper:last-child {
margin-bottom: 0;
}
.message-chart {
width: 100%;
height: 500px;
min-height: 500px;
}
/* 响应式图表适配 */
@media (max-width: 768px) {
.echarts-container {
margin: 0.75rem auto; /* 移动端也使用auto居中 */
padding: 1rem;
/* 移动端保持70%宽度,确保在小屏幕上有足够显示空间 */
width: 70%;
max-width: 100%;
min-width: 280px; /* 移动端最小宽度 */
}
.chart-wrapper {
padding: 1rem;
margin-bottom: 1rem;
}
.message-chart {
height: 400px;
min-height: 400px;
}
/* 移动端消息内容区域调整 */
.message-content-wrapper {
max-width: 92%;
}
/* 移动端包含图表的消息气泡宽度 */
.assistant-message .message-content-wrapper.has-charts {
max-width: 96%;
width: 96%;
}
}
/* 移动端文件预览适配 */
@media (max-width: 768px) {
.file-preview-dialog {
.el-dialog {
width: 95% !important;
margin: 2.5vh auto;
max-height: 95vh;
overflow: hidden;
}
.el-dialog__header {
padding: 16px 20px;
}
.el-dialog__title {
font-size: 16px;
}
}
.preview-content {
min-height: 500px;
.pdf-preview {
height: 500px;
}
}
}
/* Excel表格预览模态框样式 */
.excel-preview-dialog {
.el-dialog {
border-radius: 12px;
overflow: hidden;
}
.el-dialog__header {
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
color: white;
border-radius: 12px 12px 0 0;
margin: 0;
padding: 20px 24px;
}
.el-dialog__title {
color: white;
font-weight: 600;
font-size: 18px;
}
.el-dialog__headerbtn {
.el-dialog__close {
color: white;
font-size: 20px;
transition: all 0.2s ease;
&:hover {
color: rgba(255, 255, 255, 0.8);
transform: scale(1.1);
}
}
}
.el-dialog__body {
padding: 0;
background: #f5f7fa;
max-height: 70vh;
overflow-y: auto;
}
}
.excel-preview-content {
min-height: 400px;
display: flex;
align-items: flex-start;
justify-content: center;
background: #f5f7fa;
border-radius: 0 0 12px 12px;
padding: 20px;
.excel-table-content {
width: 100%;
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
overflow-x: auto;
/* 表格样式优化 - 加强边框显示,使用更高优先级 */
:deep(table),
:deep(.answer-content table),
:deep(.table-container table) {
width: 100% !important;
border-collapse: collapse !important;
margin: 0 !important;
font-size: 14px !important;
border: 2px solid #cbd5e1 !important;
background: white !important;
border-radius: 8px !important;
overflow: hidden !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08) !important;
max-width: none !important;
}
:deep(th),
:deep(td),
:deep(.answer-content th),
:deep(.answer-content td),
:deep(.table-container th),
:deep(.table-container td) {
padding: 12px 16px !important;
text-align: left !important;
border: 1px solid #cbd5e1 !important;
word-break: break-word !important;
vertical-align: top !important;
position: relative !important;
max-width: none !important;
}
:deep(th),
:deep(.answer-content th),
:deep(.table-container th) {
background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%) !important;
font-weight: 600 !important;
color: #1e293b !important;
position: sticky !important;
top: 0 !important;
z-index: 2 !important;
border-bottom: 2px solid #94a3b8 !important;
border-right: 1px solid #94a3b8 !important;
font-size: 13px !important;
text-transform: uppercase !important;
letter-spacing: 0.025em !important;
}
:deep(td),
:deep(.answer-content td),
:deep(.table-container td) {
background: white !important;
border-right: 1px solid #cbd5e1 !important;
border-bottom: 1px solid #cbd5e1 !important;
transition: all 0.2s ease !important;
}
:deep(tr:nth-child(even) td),
:deep(.answer-content tr:nth-child(even) td),
:deep(.table-container tr:nth-child(even) td) {
background: #f8fafc !important;
}
:deep(tr:hover td),
:deep(.answer-content tr:hover td),
:deep(.table-container tr:hover td) {
background: #e0f2fe !important;
transform: scale(1.002) !important;
}
/* 第一列样式加强 */
:deep(th:first-child),
:deep(td:first-child),
:deep(.answer-content th:first-child),
:deep(.answer-content td:first-child),
:deep(.table-container th:first-child),
:deep(.table-container td:first-child) {
border-left: 2px solid #cbd5e1 !important;
font-weight: 500 !important;
}
/* 最后一列右边框加强 */
:deep(th:last-child),
:deep(td:last-child),
:deep(.answer-content th:last-child),
:deep(.answer-content td:last-child),
:deep(.table-container th:last-child),
:deep(.table-container td:last-child) {
border-right: 2px solid #cbd5e1 !important;
}
/* 最后一行底边框加强 */
:deep(tr:last-child td),
:deep(.answer-content tr:last-child td),
:deep(.table-container tr:last-child td) {
border-bottom: 2px solid #cbd5e1 !important;
}
/* Markdown 表格容器样式 */
:deep(.table-container) {
overflow-x: auto !important;
margin: 1rem 0 !important;
border-radius: 8px !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1) !important;
border: 1px solid #cbd5e1 !important;
background: white !important;
}
/* 表格标题样式 */
:deep(h1),
:deep(h2),
:deep(h3) {
margin: 1.5rem 0 1rem !important;
font-weight: 600 !important;
color: #1e293b !important;
}
/* 段落间距 */
:deep(p) {
margin: 1rem 0 !important;
color: #374151 !important;
line-height: 1.6 !important;
}
}
}
/* 移动端适配 */
@media (max-width: 768px) {
.excel-preview-dialog {
.el-dialog {
width: 95% !important;
margin: 2.5vh auto;
}
.excel-table-content {
padding: 12px;
:deep(table) {
font-size: 12px;
}
:deep(th),
:deep(td) {
padding: 8px 12px;
}
}
}
}
/* Excel文件按钮样式 */
.excel-item {
border: 1px solid #22c55e !important;
background: linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%);
&:hover {
background: linear-gradient(135deg, #bbf7d0 0%, #86efac 100%);
border-color: #16a34a !important;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(34, 197, 94, 0.3);
}
.source-name {
color: #15803d;
font-weight: 600;
}
}
.recommend-questions-section {
margin-top: 1rem;
padding: 0.75rem;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 0.875rem;
}
.recommend-questions-header {
display: flex;
align-items: center;
color: #64748b;
font-weight: 600;
margin-bottom: 0.5rem;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.recommend-icon {
width: 14px;
height: 14px;
margin-right: 0.5rem;
}
.recommend-questions-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.recommend-question-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
border: 1px solid #e2e8f0;
border-radius: 20px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 0.8rem;
max-width: 280px;
color: #374151;
font-weight: 500;
outline: none;
position: relative;
overflow: hidden;
line-height: 1.4;
min-width: 0;
}
.question-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.recommend-question-btn::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(79, 70, 229, 0.1) 0%, rgba(67, 56, 202, 0.1) 100%);
opacity: 0;
transition: opacity 0.3s ease;
}
.recommend-question-btn:hover:not(:disabled) {
background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%);
border-color: #4f46e5;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.2);
color: #4f46e5;
}
.recommend-question-btn:hover:not(:disabled)::before {
opacity: 1;
}
.recommend-question-btn:active:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(79, 70, 229, 0.15);
}
.recommend-question-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
box-shadow: none;
background: #f8fafc;
border-color: #e5e7eb;
color: #9ca3af;
}
.recommend-question-btn:disabled::before {
opacity: 0;
}
.question-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
transition: all 0.3s ease;
z-index: 1;
position: relative;
color: #64748b;
}
.recommend-question-btn:hover:not(:disabled) .question-icon {
color: #4f46e5;
transform: scale(1.1);
}
.recommend-question-btn:disabled .question-icon {
color: #9ca3af;
}
/* 移动端适配 */
@media (max-width: 768px) {
.recommend-questions-section {
padding: 0.5rem;
margin-top: 0.75rem;
}
.recommend-questions-list {
gap: 0.375rem;
}
.recommend-question-btn {
padding: 0.375rem 0.5rem;
font-size: 0.75rem;
max-width: 200px;
border-radius: 16px;
}
.question-text {
font-size: 0.75rem;
}
.question-icon {
width: 14px;
height: 14px;
}
.recommend-questions-header {
font-size: 0.75rem;
margin-bottom: 0.375rem;
}
.recommend-icon {
width: 12px;
height: 12px;
}
}
</style>