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

3223 lines
81 KiB
Vue
Raw Normal View History

2025-07-18 16:38:18 +08:00
<template>
<div class="chat-container">
<!-- 会话记录侧边栏 -->
<div class="chat-sidebar" :class="{ 'sidebar-collapsed': isSidebarCollapsed }">
<div class="sidebar-header">
2025-07-29 15:14:47 +08:00
<h3 v-show="!isSidebarCollapsed">{{ t('vabI18n.chat.history') }}</h3>
2025-07-18 16:38:18 +08:00
<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>
2025-07-29 15:14:47 +08:00
{{ t('vabI18n.chat.newChat') }}
2025-07-18 16:38:18 +08:00
</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)"
>
2025-07-29 15:14:47 +08:00
<span class="chat-title">{{ chat.title || t('vabI18n.chat.chat') + ` ${index + 1}` }}</span>
2025-07-18 16:38:18 +08:00
<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>
2025-07-29 15:14:47 +08:00
<img v-else :src="getAssistantAvatar()" :alt="$t('vabI18n.chat.assistant')" class="avatar-icon" />
2025-07-18 16:38:18 +08:00
</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">
2025-07-29 15:14:47 +08:00
<span class="message-sender">{{ msg.isUser ? t('vabI18n.chat.you') : t('vabI18n.chat.assistant') }}</span>
2025-07-18 16:38:18 +08:00
</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"
2025-07-29 15:14:47 +08:00
:placeholder="$t('vabI18n.chat.placeholder')"
2025-07-18 16:38:18 +08:00
@keydown.enter.ctrl="handleSaveEdit"
@keydown.escape="handleCancelEdit"
/>
<div class="edit-actions">
<button
@click="handleCancelEdit"
class="edit-btn cancel-btn"
>
2025-07-29 15:14:47 +08:00
{{ t('vabI18n.chat.cancel') }}
2025-07-18 16:38:18 +08:00
</button>
<button
@click="handleSaveEdit"
:disabled="!editingMessageText.trim()"
class="edit-btn save-btn"
>
2025-07-29 15:14:47 +08:00
{{ t('vabI18n.chat.send') }}
2025-07-18 16:38:18 +08:00
</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>
2025-07-29 15:14:47 +08:00
{{ t('vabI18n.chat.think') }}
2025-07-18 16:38:18 +08:00
</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>
2025-07-29 15:14:47 +08:00
{{ t('vabI18n.chat.sources') }}
2025-07-18 16:38:18 +08:00
</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>
2025-07-30 15:30:23 +08:00
<div ref="bottomAnchor"></div>
2025-07-18 16:38:18 +08:00
</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">
2025-07-29 15:14:47 +08:00
<span class="footer-hint">{{ t('vabI18n.chat.sendHint') }}</span>
2025-07-18 16:38:18 +08:00
</div>
</div>
</div>
<!-- 文件预览模态框 -->
<el-dialog
v-model="filePreviewVisible"
2025-07-29 15:14:47 +08:00
:title="t('vabI18n.chat.previewTitle')"
2025-07-18 16:38:18 +08:00
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 }"
2025-07-29 15:14:47 +08:00
@error="() => { previewLoading = false; ElMessage.error(t('vabI18n.chat.previewTitleFail')) }"
2025-07-18 16:38:18 +08:00
class="pdf-preview"
/>
</div>
</el-dialog>
<!-- Excel表格预览模态框 -->
<el-dialog
v-model="excelPreviewVisible"
2025-07-29 15:14:47 +08:00
:title="$t('vabI18n.chat.excelPreviewTitle')"
2025-07-18 16:38:18 +08:00
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'
2025-07-30 15:30:23 +08:00
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/vue/24/solid'
2025-07-18 16:38:18 +08:00
import { sendChatMessage, type ChatMessageResponse, type ChatMessageSendRequest, stopMessagesStream, getFilePathList, type TraceFile, type TraceData } from '@/api/chat'
import { useI18n } from 'vue-i18n'
2025-07-29 17:57:22 +08:00
import { throttle } from 'lodash'
2025-07-18 16:38:18 +08:00
2025-07-30 15:30:23 +08:00
const expanded = ref(false)
2025-07-18 16:38:18 +08:00
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 // 是否为开场白消息
}
2025-07-30 15:30:23 +08:00
// 消息体底部锚点
const bottomAnchor = ref<HTMLElement | null>(null)
2025-07-18 16:38:18 +08:00
const messages = ref<Message[]>([])
const inputMessage = ref('')
2025-07-30 15:30:23 +08:00
const messagesContainer = ref<HTMLElement | null>(null)
2025-07-18 16:38:18 +08:00
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()
2025-07-30 15:30:23 +08:00
throttledScrollToBottom()
2025-07-18 16:38:18 +08:00
}
// 切换会话
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 })
2025-07-30 15:30:23 +08:00
// 用户是否正在拖动滚动条
let isUserScrolling = false
2025-07-18 16:38:18 +08:00
// 初始化时加载会话历史
onMounted(async () => {
loadChatHistory()
// 如果没有历史会话,创建一个新会话
if (chatHistory.value.length === 0) {
await createNewChat()
}
2025-07-30 15:30:23 +08:00
//监听用户是否拖动滚动条
messagesContainer.value?.addEventListener('scroll', () => {
const el = messagesContainer.value
if (!el) return
const threshold = 100
isUserScrolling = el.scrollHeight - el.scrollTop - el.clientHeight > threshold
})
2025-07-18 16:38:18 +08:00
})
// 解析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,
2025-07-30 15:30:23 +08:00
`<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">`
2025-07-18 16:38:18 +08:00
).replace(
2025-07-30 15:30:23 +08:00
/<\/table>/g,
'</table></div></details>'
2025-07-18 16:38:18 +08:00
).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',
2025-07-30 15:30:23 +08:00
'tr', 'th', 'td', 'div', 'details', 'summary'
2025-07-18 16:38:18 +08:00
],
2025-07-30 15:30:23 +08:00
ALLOWED_ATTR: ['href', 'target', 'class', 'style', 'data-chart-id', 'border', 'cellspacing', 'cellpadding', 'open']
2025-07-18 16:38:18 +08:00
}) 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
}
2025-07-29 17:57:22 +08:00
throttledScrollToBottom()
2025-07-18 16:38:18 +08:00
}
} 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
}
2025-07-30 15:30:23 +08:00
scrollToBottomForce()
2025-07-18 16:38:18 +08:00
}
}
2025-07-30 15:30:23 +08:00
const scrollToBottomForce = async () => {
2025-07-18 16:38:18 +08:00
await nextTick()
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
2025-07-30 15:30:23 +08:00
//将锚点滚动到最底部
const scrollToBottom = async () => {
await nextTick()
setTimeout(() => {
requestAnimationFrame(() => {
if (messagesContainer.value && !isUserScrolling) {
bottomAnchor.value.scrollIntoView({
behavior: 'smooth',
block: 'start'
})
}
})
},60)
}
2025-07-29 17:57:22 +08:00
const throttledScrollToBottom = throttle(() => {
2025-07-30 15:30:23 +08:00
scrollToBottom()
}, 300,{ leading: true, trailing: true })
2025-07-29 17:57:22 +08:00
2025-07-18 16:38:18 +08:00
// 解析方法
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) => {
2025-07-29 15:14:47 +08:00
const icons = require.context('@/assets/img/filetype-icon', false, /\.png$/)
// console.log(icons)
// console.log('所有图标 keys:', icons.keys())
2025-07-18 16:38:18 +08:00
const getIconUrl = (iconName: string) => {
2025-07-29 15:14:47 +08:00
const fullName = `./${iconName}.png`
if (icons.keys().includes(fullName)) {
return icons(fullName)
} else {
// console.warn('图标不存在:', fullName)
return ''
}
2025-07-18 16:38:18 +08:00
}
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)
2025-07-30 15:30:23 +08:00
throttledScrollToBottom()
2025-07-18 16:38:18 +08:00
// 获取当前会话的会话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
}
2025-07-30 15:30:23 +08:00
throttledScrollToBottom()
2025-07-18 16:38:18 +08:00
}
} 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
}
2025-07-30 15:30:23 +08:00
scrollToBottomForce()
2025-07-18 16:38:18 +08:00
}
}
// 点赞消息
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 {
2025-07-30 15:30:23 +08:00
height: 100%;
2025-07-18 16:38:18 +08:00
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;
}
2025-07-30 15:30:23 +08:00
::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;
}
2025-07-18 16:38:18 +08:00
/* .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;
}
2025-07-30 15:30:23 +08:00
::v-deep .icon-chevron {
width: 20px;
height: 20px;
transition: transform 0.3s ease;
color: #333; /* 让箭头颜色和文字颜色一致 */
}
2025-07-18 16:38:18 +08:00
.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>