<template>
|
<div class="ai-chat-sidebar-wrapper">
|
<!-- 悬浮图标 -->
|
<div class="ai-chat-trigger" @click="toggleSidebar" v-show="!visible">
|
<el-tooltip content="AI 助手" placement="left">
|
<div class="trigger-icon">
|
<el-icon :size="30" color="#fff"><Cpu /></el-icon>
|
</div>
|
</el-tooltip>
|
</div>
|
|
<!-- 侧边栏对话框 -->
|
<el-drawer
|
v-model="visible"
|
:size="drawerSize"
|
direction="rtl"
|
:with-header="true"
|
class="ai-chat-drawer"
|
:modal="false"
|
:show-close="true"
|
:append-to-body="false"
|
@close="handleClose"
|
>
|
<template #header>
|
<div class="drawer-header">
|
<div class="header-left">
|
<el-icon :size="20" class="header-icon"><Cpu /></el-icon>
|
<span class="title">AI 智能助手</span>
|
</div>
|
<div class="header-actions">
|
<el-tooltip content="会话历史" placement="bottom">
|
<el-button link @click="toggleHistory">
|
<el-icon :size="18"><Timer /></el-icon>
|
</el-button>
|
</el-tooltip>
|
<el-tooltip content="开启新会话" placement="bottom">
|
<el-button link @click="newChat">
|
<el-icon :size="18"><Plus /></el-icon>
|
</el-button>
|
</el-tooltip>
|
</div>
|
</div>
|
</template>
|
|
<div class="chat-container">
|
<!-- 历史会话列表 -->
|
<div v-if="showHistory" class="history-panel">
|
<div class="history-header">
|
<span>最近会话</span>
|
<el-button link type="primary" @click="showHistory = false">返回对话</el-button>
|
</div>
|
<el-skeleton :loading="loadingSessions" animated>
|
<template #template>
|
<div v-for="i in 5" :key="i" style="padding: 10px">
|
<el-skeleton-item variant="p" style="width: 80%" />
|
</div>
|
</template>
|
<div class="session-list">
|
<div
|
v-for="session in sessions"
|
:key="session.memoryId"
|
:class="['session-item', { active: uuid === session.memoryId }]"
|
@click="selectSession(session)"
|
>
|
<el-icon><ChatDotSquare /></el-icon>
|
<span class="session-name" :title="session.lastMessage || '新会话'">
|
{{ session.lastMessage || '新会话' }}
|
</span>
|
<el-button
|
link
|
type="danger"
|
class="delete-btn"
|
@click.stop="handleDeleteSession(session.memoryId)"
|
>
|
<el-icon><Delete /></el-icon>
|
</el-button>
|
</div>
|
<el-empty v-if="sessions.length === 0" description="暂无历史会话" />
|
</div>
|
</el-skeleton>
|
</div>
|
|
<div v-else class="chat-main">
|
<div class="message-list" ref="messageListRef">
|
<div
|
v-for="(message, index) in messages"
|
:key="index"
|
:class="['message-item', message.isUser ? 'user-message' : 'bot-message']"
|
>
|
<div class="avatar">
|
<el-icon v-if="message.isUser"><User /></el-icon>
|
<el-icon v-else><Cpu /></el-icon>
|
</div>
|
<div class="message-content">
|
<!-- 文本内容 -->
|
<div class="text-box" v-html="message.htmlContent"></div>
|
|
<!-- 图表内容 -->
|
<div v-if="message.chartOptions && message.chartRenderReady" class="charts-wrapper">
|
<div
|
v-for="(option, key) in message.chartOptions"
|
:key="key"
|
class="chart-item"
|
:id="`ai-chart-${index}-${key}`"
|
></div>
|
</div>
|
|
<!-- 表格内容 -->
|
<div v-if="message.type === 'todo_list' && message.tableData" class="table-wrapper">
|
<el-table :data="message.tableData.items" border stripe size="small" style="width: 100%">
|
<el-table-column
|
v-for="col in message.tableData.columns"
|
:key="col"
|
:prop="col"
|
:label="columnLabelMap[col] || col"
|
min-width="100"
|
show-overflow-tooltip
|
/>
|
</el-table>
|
</div>
|
|
<!-- 打字中动画 -->
|
<div v-if="message.isTyping" class="typing-indicator">
|
<span class="dot"></span>
|
<span class="dot"></span>
|
<span class="dot"></span>
|
</div>
|
</div>
|
</div>
|
</div>
|
|
<div class="input-area">
|
<div class="input-actions">
|
<el-button link type="primary" size="small" @click="newChat">
|
<el-icon><Plus /></el-icon>新会话
|
</el-button>
|
<el-button v-if="isSending" link type="danger" size="small" @click="stopGeneration">
|
<el-icon><VideoPause /></el-icon>停止生成
|
</el-button>
|
<el-upload
|
class="file-upload-trigger"
|
action="#"
|
:auto-upload="false"
|
:show-file-list="false"
|
:on-change="handleFileChange"
|
:disabled="isSending"
|
>
|
<el-button link type="primary" size="small" :disabled="isSending">
|
<el-icon><Upload /></el-icon>分析文件
|
</el-button>
|
</el-upload>
|
</div>
|
<div class="input-box">
|
<div v-if="selectedFile" class="selected-file-tag">
|
<el-icon><Document /></el-icon>
|
<span class="file-name">{{ selectedFile.name }}</span>
|
<el-icon class="remove-file" @click="removeSelectedFile"><Close /></el-icon>
|
</div>
|
<el-input
|
v-model="inputMessage"
|
type="textarea"
|
:rows="selectedFile ? 2 : 3"
|
placeholder="请输入您的问题... (Enter 发送, Shift+Enter 换行)"
|
resize="none"
|
@keydown.enter.exact.prevent="sendMessage"
|
/>
|
<el-button
|
type="primary"
|
class="send-btn"
|
:disabled="isSending || (!inputMessage.trim() && !selectedFile)"
|
@click="sendMessage"
|
>
|
发送
|
</el-button>
|
</div>
|
</div>
|
</div>
|
</div>
|
</el-drawer>
|
</div>
|
</template>
|
|
<script setup>
|
import { ref, onMounted, onUnmounted, nextTick, watch, computed } from 'vue'
|
import request from '@/utils/request'
|
import * as echarts from 'echarts'
|
import { Cpu, User, Plus, Loading, Timer, Delete, ChatDotSquare, VideoPause, Upload, Document, Close } from '@element-plus/icons-vue'
|
import { ElMessage } from 'element-plus'
|
|
const visible = ref(false)
|
const windowWidth = ref(window.innerWidth)
|
const drawerSize = computed(() => {
|
if (windowWidth.value < 768) return '100%'
|
if (windowWidth.value < 1200) return '500px'
|
return '600px'
|
})
|
const messageListRef = ref(null)
|
const isSending = ref(false)
|
const currentAbortController = ref(null)
|
const inputMessage = ref('')
|
const selectedFile = ref(null)
|
const messages = ref([])
|
const uuid = ref('')
|
const chartInstances = ref({})
|
const resizeHandlers = ref([])
|
const outputState = ref({})
|
|
// 历史会话相关
|
const showHistory = ref(false)
|
const sessions = ref([])
|
const loadingSessions = ref(false)
|
|
const toggleHistory = () => {
|
showHistory.value = !showHistory.value
|
if (showHistory.value) {
|
loadSessions()
|
}
|
}
|
|
const loadSessions = async () => {
|
loadingSessions.value = true
|
try {
|
const res = await request.get('/xiaozhi/history/sessions')
|
if (res.code === 200) {
|
sessions.value = res.data || []
|
}
|
} catch (err) {
|
console.error('Failed to load sessions', err)
|
} finally {
|
loadingSessions.value = false
|
}
|
}
|
|
const selectSession = async (session) => {
|
showHistory.value = false
|
uuid.value = session.memoryId
|
localStorage.setItem('ai_chat_uuid', uuid.value)
|
|
// 加载会话消息
|
try {
|
const res = await request.get(`/xiaozhi/history/messages/${uuid.value}`)
|
if (res.code === 200) {
|
disposeCharts()
|
messages.value = []
|
const historyMsgs = res.data || []
|
|
// 重新构造消息列表并解析
|
historyMsgs.forEach((msg, idx) => {
|
const isUser = msg.role === 'user'
|
const botMsgIndex = messages.value.length
|
|
const messageObj = {
|
isUser,
|
content: msg.content,
|
htmlContent: '',
|
isTyping: false,
|
chartOptions: null,
|
chartRenderReady: false,
|
type: '',
|
tableData: null
|
}
|
|
messages.value.push(messageObj)
|
|
if (!isUser) {
|
outputState.value[botMsgIndex] = {
|
isPaused: false,
|
jsonBlockStartPos: -1,
|
jsBlockStartPos: -1,
|
blockEndPos: -1,
|
hasRenderedChart: false
|
}
|
|
// 解析历史消息中的 JSON
|
const jsonRegex = /\{"success":\s*true,[\s\S]*\}/
|
const jsonMatch = msg.content.match(jsonRegex)
|
if (jsonMatch) {
|
try {
|
const parsedData = JSON.parse(jsonMatch[0])
|
if (parsedData.success) {
|
messageObj.type = parsedData.type || ''
|
if (messageObj.type === 'todo_list' && parsedData.data) {
|
messageObj.tableData = parsedData.data
|
}
|
if (parsedData.charts && Object.keys(parsedData.charts).length > 0) {
|
messageObj.chartOptions = parsedData.charts
|
messageObj.chartRenderReady = true
|
renderCharts(botMsgIndex, messageObj.chartOptions)
|
}
|
}
|
} catch (err) {}
|
}
|
|
updateOutputState(msg.content, botMsgIndex)
|
messageObj.htmlContent = convertStreamOutput(msg.content, botMsgIndex)
|
} else {
|
messageObj.htmlContent = convertTextToHtml(msg.content)
|
}
|
})
|
scrollToBottom()
|
}
|
} catch (err) {
|
console.error('Failed to load messages', err)
|
}
|
}
|
|
const handleDeleteSession = async (memoryId) => {
|
try {
|
const res = await request.delete(`/xiaozhi/history/${memoryId}`)
|
if (res.code === 200) {
|
loadSessions()
|
if (uuid.value === memoryId) {
|
newChat()
|
}
|
}
|
} catch (err) {
|
console.error('Failed to delete session', err)
|
}
|
}
|
|
const columnLabelMap = {
|
approveId: '审批编号',
|
approveType: '审批类型',
|
approveUserName: '审批人',
|
approveUserCurrentName: '当前处理人',
|
approveReason: '审批原因',
|
approveStatus: '审批状态',
|
createTime: '创建时间'
|
}
|
|
onMounted(() => {
|
initUUID()
|
// 初始欢迎
|
if (messages.value.length === 0) {
|
hello()
|
}
|
window.addEventListener('resize', handleWindowResize)
|
})
|
|
onUnmounted(() => {
|
disposeCharts()
|
window.removeEventListener('resize', handleWindowResize)
|
})
|
|
const handleWindowResize = () => {
|
windowWidth.value = window.innerWidth
|
}
|
|
const toggleSidebar = () => {
|
visible.value = !visible.value
|
if (visible.value) {
|
scrollToBottom()
|
}
|
}
|
|
const handleClose = () => {
|
visible.value = false
|
}
|
|
const initUUID = () => {
|
let storedUUID = localStorage.getItem('ai_chat_uuid')
|
if (!storedUUID) {
|
storedUUID = Math.random().toString(36).substring(2, 10) + Date.now().toString(36).substring(4)
|
localStorage.setItem('ai_chat_uuid', storedUUID)
|
}
|
uuid.value = storedUUID
|
}
|
|
const hello = () => {
|
sendRequest('你好')
|
}
|
|
const newChat = () => {
|
disposeCharts()
|
messages.value = []
|
outputState.value = {}
|
localStorage.removeItem('ai_chat_uuid')
|
initUUID()
|
hello()
|
}
|
|
const disposeCharts = () => {
|
Object.values(chartInstances.value).forEach(chart => chart.dispose())
|
resizeHandlers.value.forEach(handler => window.removeEventListener('resize', handler))
|
chartInstances.value = {}
|
resizeHandlers.value = []
|
}
|
|
const scrollToBottom = () => {
|
nextTick(() => {
|
if (messageListRef.value) {
|
messageListRef.value.scrollTop = messageListRef.value.scrollHeight
|
}
|
})
|
}
|
|
const handleFileChange = (file) => {
|
if (!file) return
|
const rawFile = file.raw
|
if (rawFile) {
|
// 限制文件大小,例如 10MB
|
const isLt10M = rawFile.size / 1024 / 1024 < 10
|
if (!isLt10M) {
|
ElMessage.error('文件大小不能超过 10MB!')
|
return
|
}
|
selectedFile.value = rawFile
|
}
|
}
|
|
const removeSelectedFile = () => {
|
selectedFile.value = null
|
}
|
|
const analyzeFile = async (file, message = '') => {
|
if (isSending.value) return
|
isSending.value = true
|
currentAbortController.value = new AbortController()
|
|
const userMsg = message ? `${message}\n[上传文件分析] ${file.name}` : `[上传文件分析] ${file.name}`
|
messages.value.push({
|
isUser: true,
|
content: userMsg,
|
htmlContent: convertTextToHtml(userMsg),
|
isTyping: false
|
})
|
|
const botMsgIndex = messages.value.length
|
messages.value.push({
|
isUser: false,
|
content: '',
|
htmlContent: '',
|
isTyping: true,
|
chartOptions: null,
|
chartRenderReady: false,
|
type: '',
|
tableData: null
|
})
|
|
outputState.value[botMsgIndex] = {
|
isPaused: false,
|
jsonBlockStartPos: -1,
|
jsBlockStartPos: -1,
|
blockEndPos: -1,
|
hasRenderedChart: false
|
}
|
|
scrollToBottom()
|
|
const formData = new FormData()
|
formData.append('file', file)
|
formData.append('memoryId', uuid.value)
|
if (message.trim()) {
|
formData.append('message', message.trim())
|
}
|
|
request.post('/xiaozhi/analyze-file', formData, {
|
headers: {
|
'Content-Type': 'multipart/form-data'
|
},
|
signal: currentAbortController.value.signal,
|
onDownloadProgress: (e) => {
|
const fullText = e.target ? e.target.responseText : (e.event ? e.event.target.responseText : '')
|
if (!fullText) return
|
|
const currentMsg = messages.value[botMsgIndex]
|
if (!currentMsg) return
|
|
currentMsg.content = fullText
|
|
// 解析 JSON 数据(针对嵌入式 JSON)
|
const jsonRegex = /\{"success":\s*true,[\s\S]*\}/
|
const jsonMatch = fullText.match(jsonRegex)
|
|
if (jsonMatch) {
|
try {
|
const parsedData = JSON.parse(jsonMatch[0])
|
if (parsedData.success) {
|
currentMsg.type = parsedData.type || ''
|
if (currentMsg.type === 'todo_list' && parsedData.data) {
|
currentMsg.tableData = parsedData.data
|
}
|
if (parsedData.charts && Object.keys(parsedData.charts).length > 0) {
|
currentMsg.chartOptions = parsedData.charts
|
currentMsg.chartRenderReady = true
|
if (!outputState.value[botMsgIndex].hasRenderedChart) {
|
renderCharts(botMsgIndex, currentMsg.chartOptions)
|
outputState.value[botMsgIndex].hasRenderedChart = true
|
}
|
}
|
}
|
} catch (err) {}
|
}
|
|
updateOutputState(fullText, botMsgIndex)
|
currentMsg.htmlContent = convertStreamOutput(fullText, botMsgIndex)
|
scrollToBottom()
|
}
|
}).then(() => {
|
const currentMsg = messages.value[botMsgIndex]
|
currentMsg.isTyping = false
|
isSending.value = false
|
currentAbortController.value = null
|
|
// 最终解析确保图表渲染
|
if (currentMsg.chartOptions && !outputState.value[botMsgIndex].hasRenderedChart) {
|
renderCharts(botMsgIndex, currentMsg.chartOptions)
|
outputState.value[botMsgIndex].hasRenderedChart = true
|
}
|
}).catch(err => {
|
if (err.name === 'CanceledError' || err.name === 'AbortError') {
|
console.log('Analysis aborted by user')
|
return
|
}
|
console.error('File analysis error:', err)
|
const errorMsg = '抱歉,文件分析过程中遇到了一点问题,请稍后再试。'
|
if (messages.value[botMsgIndex]) {
|
messages.value[botMsgIndex].content = errorMsg
|
messages.value[botMsgIndex].htmlContent = convertTextToHtml(errorMsg)
|
messages.value[botMsgIndex].isTyping = false
|
}
|
isSending.value = false
|
currentAbortController.value = null
|
})
|
}
|
|
const sendMessage = () => {
|
const msg = inputMessage.value?.trim() || ''
|
if ((msg || selectedFile.value) && !isSending.value) {
|
if (selectedFile.value) {
|
analyzeFile(selectedFile.value, msg)
|
selectedFile.value = null
|
} else {
|
sendRequest(msg)
|
}
|
inputMessage.value = ''
|
}
|
}
|
|
const stopGeneration = () => {
|
if (currentAbortController.value) {
|
currentAbortController.value.abort()
|
currentAbortController.value = null
|
isSending.value = false
|
|
// 将最后一条消息标记为非打字状态
|
const lastMsg = messages.value[messages.value.length - 1]
|
if (lastMsg && !lastMsg.isUser) {
|
lastMsg.isTyping = false
|
}
|
}
|
}
|
|
const sendRequest = (message) => {
|
isSending.value = true
|
currentAbortController.value = new AbortController()
|
|
// 用户消息
|
if (messages.value.length > 0) {
|
messages.value.push({
|
isUser: true,
|
content: message,
|
htmlContent: convertTextToHtml(message),
|
isTyping: false
|
})
|
}
|
|
// 机器人占位
|
const botMsgIndex = messages.value.length
|
const botMsg = {
|
isUser: false,
|
content: '',
|
htmlContent: '',
|
isTyping: true,
|
chartOptions: null,
|
chartRenderReady: false,
|
type: '',
|
tableData: null
|
}
|
messages.value.push(botMsg)
|
|
outputState.value[botMsgIndex] = {
|
isPaused: false,
|
jsonBlockStartPos: -1,
|
jsBlockStartPos: -1,
|
blockEndPos: -1,
|
hasRenderedChart: false
|
}
|
|
scrollToBottom()
|
|
request.post('/xiaozhi/chat',
|
{ memoryId: uuid.value, message },
|
{
|
signal: currentAbortController.value.signal,
|
onDownloadProgress: (e) => {
|
// 兼容不同版本的 axios 获取响应文本的方式
|
const fullText = e.target ? e.target.responseText : (e.event ? e.event.target.responseText : '')
|
if (!fullText) return
|
|
const currentMsg = messages.value[botMsgIndex]
|
if (!currentMsg) return
|
|
currentMsg.content = fullText
|
|
// 解析 JSON 数据(针对嵌入式 JSON)
|
const jsonRegex = /\{"success":\s*true,[\s\S]*\}/
|
const jsonMatch = fullText.match(jsonRegex)
|
|
if (jsonMatch) {
|
try {
|
const parsedData = JSON.parse(jsonMatch[0])
|
if (parsedData.success) {
|
currentMsg.type = parsedData.type || ''
|
if (currentMsg.type === 'todo_list' && parsedData.data) {
|
currentMsg.tableData = parsedData.data
|
}
|
if (parsedData.charts && Object.keys(parsedData.charts).length > 0) {
|
currentMsg.chartOptions = parsedData.charts
|
currentMsg.chartRenderReady = true
|
if (!outputState.value[botMsgIndex].hasRenderedChart) {
|
renderCharts(botMsgIndex, currentMsg.chartOptions)
|
outputState.value[botMsgIndex].hasRenderedChart = true
|
}
|
}
|
}
|
} catch (err) {}
|
}
|
|
updateOutputState(fullText, botMsgIndex)
|
currentMsg.htmlContent = convertStreamOutput(fullText, botMsgIndex)
|
scrollToBottom()
|
}
|
}
|
).then(() => {
|
const currentMsg = messages.value[botMsgIndex]
|
currentMsg.isTyping = false
|
isSending.value = false
|
currentAbortController.value = null
|
|
// 最终解析
|
const fullText = currentMsg.content
|
const jsonRegex = /\{"success":\s*true,[\s\S]*\}/
|
const jsonMatch = fullText.match(jsonRegex)
|
if (jsonMatch) {
|
try {
|
const parsedData = JSON.parse(jsonMatch[0])
|
if (parsedData.success) {
|
currentMsg.type = parsedData.type || ''
|
if (currentMsg.type === 'todo_list' && parsedData.data) {
|
currentMsg.tableData = parsedData.data
|
}
|
if (parsedData.charts && Object.keys(parsedData.charts).length > 0) {
|
currentMsg.chartOptions = parsedData.charts
|
currentMsg.chartRenderReady = true
|
if (!outputState.value[botMsgIndex].hasRenderedChart) {
|
renderCharts(botMsgIndex, currentMsg.chartOptions)
|
outputState.value[botMsgIndex].hasRenderedChart = true
|
}
|
}
|
currentMsg.htmlContent = convertStreamOutput(fullText, botMsgIndex)
|
}
|
} catch (err) {}
|
}
|
|
// 兜底渲染
|
if (currentMsg.chartOptions && !outputState.value[botMsgIndex].hasRenderedChart) {
|
renderCharts(botMsgIndex, currentMsg.chartOptions)
|
}
|
}).catch(err => {
|
if (err.name === 'CanceledError' || err.name === 'AbortError') {
|
console.log('Request aborted by user')
|
return
|
}
|
console.error('AI Chat Error:', err)
|
const errorMsg = '抱歉,我现在遇到了一点问题,请稍后再试。'
|
if (messages.value[botMsgIndex]) {
|
messages.value[botMsgIndex].content = errorMsg
|
messages.value[botMsgIndex].htmlContent = convertTextToHtml(errorMsg)
|
messages.value[botMsgIndex].isTyping = false
|
}
|
isSending.value = false
|
currentAbortController.value = null
|
})
|
}
|
|
const updateOutputState = (text, msgIndex) => {
|
const state = outputState.value[msgIndex]
|
if (state.jsonBlockStartPos === -1) {
|
const pos = text.indexOf('```json')
|
if (pos !== -1) { state.jsonBlockStartPos = pos; state.isPaused = true }
|
}
|
if (state.jsBlockStartPos === -1) {
|
const pos = text.indexOf('```javascript') !== -1 ? text.indexOf('```javascript') : text.indexOf('```js')
|
if (pos !== -1) { state.jsBlockStartPos = pos; state.isPaused = true }
|
}
|
if ((state.jsonBlockStartPos !== -1 || state.jsBlockStartPos !== -1) && state.blockEndPos === -1) {
|
const startCheck = state.jsonBlockStartPos !== -1 ? state.jsonBlockStartPos + 7 : state.jsBlockStartPos + (text.includes('javascript') ? 13 : 5)
|
const endPos = text.indexOf('```', startCheck)
|
if (endPos !== -1) { state.blockEndPos = endPos + 3; state.isPaused = false }
|
}
|
}
|
|
const convertTextToHtml = (text) => {
|
if (!text) return ''
|
return text
|
.replace(/&/g, '&')
|
.replace(/</g, '<')
|
.replace(/>/g, '>')
|
.replace(/\n/g, '<br>')
|
}
|
|
const convertStreamOutput = (output, msgIndex) => {
|
if (!output) return ''
|
const state = outputState.value[msgIndex]
|
let display = output
|
|
const jsonRegex = /\{"success":\s*true,[\s\S]*\}/
|
const jsonMatch = output.match(jsonRegex)
|
if (jsonMatch) {
|
try {
|
const parsed = JSON.parse(jsonMatch[0])
|
display = output.replace(jsonMatch[0], '').trim()
|
if (!display && parsed.description) display = parsed.description
|
} catch (e) {
|
const start = output.search(/\{"success":\s*true/)
|
display = output.substring(0, start) + '... (正在生成数据图表)'
|
}
|
}
|
|
if (state.jsonBlockStartPos !== -1 && state.blockEndPos === -1) {
|
display = display.substring(0, state.jsonBlockStartPos)
|
} else if (state.jsBlockStartPos !== -1 && state.blockEndPos === -1) {
|
display = display.substring(0, state.jsBlockStartPos)
|
}
|
|
display = display.replace(/```(javascript|js)([\s\S]*?)```/g, '<pre class="code-block js-code">$2</pre>')
|
display = display.replace(/```([\s\S]*?)```/g, '<pre class="code-block">$1</pre>')
|
|
return convertTextToHtml(display)
|
}
|
|
const renderCharts = (msgIndex, chartOptions) => {
|
nextTick(() => {
|
Object.keys(chartOptions).forEach(key => {
|
const id = `ai-chart-${msgIndex}-${key}`
|
const tryInit = (count = 0) => {
|
const dom = document.getElementById(id)
|
if (dom) {
|
if (chartInstances.value[id]) chartInstances.value[id].dispose()
|
const chart = echarts.init(dom)
|
chartInstances.value[id] = chart
|
chart.setOption(chartOptions[key])
|
const handler = () => chart.resize()
|
resizeHandlers.value.push(handler)
|
window.addEventListener('resize', handler)
|
} else if (count < 10) {
|
setTimeout(() => tryInit(count + 1), 200)
|
}
|
}
|
tryInit()
|
})
|
})
|
}
|
|
watch(messages, () => scrollToBottom(), { deep: true })
|
</script>
|
|
<style scoped lang="scss">
|
.ai-chat-sidebar-wrapper {
|
position: fixed;
|
inset: 0;
|
z-index: 2000;
|
pointer-events: none;
|
|
:deep(.el-drawer__container) {
|
pointer-events: none;
|
}
|
|
:deep(.el-drawer) {
|
pointer-events: auto;
|
}
|
}
|
|
.ai-chat-trigger {
|
pointer-events: auto;
|
position: fixed;
|
right: 20px;
|
bottom: 100px;
|
width: 60px;
|
height: 60px;
|
background: linear-gradient(135deg, #409eff 0%, #007aff 100%);
|
border-radius: 50%;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
cursor: pointer;
|
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.4);
|
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
z-index: 2001;
|
|
&:hover {
|
transform: scale(1.1) translateY(-5px);
|
box-shadow: 0 8px 20px rgba(0, 122, 255, 0.5);
|
}
|
|
.trigger-icon {
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
}
|
}
|
|
.ai-chat-drawer {
|
:deep(.el-drawer__body) {
|
padding: 0;
|
overflow: hidden;
|
}
|
:deep(.el-drawer__header) {
|
margin-bottom: 0;
|
padding: 12px 16px;
|
background: #fff;
|
border-bottom: 1px solid #ebeef5;
|
color: #303133;
|
}
|
}
|
|
.drawer-header {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
width: 100%;
|
padding-right: 32px;
|
|
.header-left {
|
display: flex;
|
align-items: center;
|
gap: 8px;
|
|
.header-icon {
|
color: #409eff;
|
}
|
|
.title {
|
font-size: 16px;
|
font-weight: 600;
|
color: #303133;
|
}
|
}
|
|
.header-actions {
|
display: flex;
|
gap: 8px;
|
}
|
}
|
|
.chat-container {
|
display: flex;
|
flex-direction: column;
|
height: 100%;
|
background-color: #f5f7fa;
|
position: relative;
|
}
|
|
.history-panel {
|
position: absolute;
|
inset: 0;
|
background: #fff;
|
z-index: 10;
|
display: flex;
|
flex-direction: column;
|
|
.history-header {
|
padding: 16px;
|
border-bottom: 1px solid #ebeef5;
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
font-weight: 600;
|
font-size: 14px;
|
}
|
|
.session-list {
|
flex: 1;
|
overflow-y: auto;
|
padding: 8px;
|
|
.session-item {
|
display: flex;
|
align-items: center;
|
padding: 12px;
|
margin-bottom: 4px;
|
border-radius: 8px;
|
cursor: pointer;
|
transition: all 0.2s;
|
gap: 10px;
|
position: relative;
|
border: 1px solid transparent;
|
|
&:hover {
|
background-color: #f5f7fa;
|
.delete-btn {
|
opacity: 1;
|
}
|
}
|
|
&.active {
|
background-color: #ecf5ff;
|
border-color: #d9ecff;
|
color: #409eff;
|
}
|
|
.el-icon {
|
font-size: 16px;
|
flex-shrink: 0;
|
}
|
|
.session-name {
|
flex: 1;
|
font-size: 13px;
|
white-space: nowrap;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
}
|
|
.delete-btn {
|
opacity: 0;
|
transition: opacity 0.2s;
|
padding: 4px;
|
&:hover {
|
color: #f56c6c;
|
}
|
}
|
}
|
}
|
}
|
|
.chat-main {
|
display: flex;
|
flex-direction: column;
|
height: 100%;
|
flex: 1;
|
overflow: hidden;
|
}
|
|
.message-list {
|
flex: 1;
|
overflow-y: auto;
|
padding: 20px;
|
display: flex;
|
flex-direction: column;
|
gap: 20px;
|
|
&::-webkit-scrollbar {
|
width: 6px;
|
}
|
&::-webkit-scrollbar-thumb {
|
background: #dcdfe6;
|
border-radius: 3px;
|
}
|
}
|
|
.message-item {
|
display: flex;
|
gap: 12px;
|
width: 100%;
|
|
.avatar {
|
width: 36px;
|
height: 36px;
|
border-radius: 8px;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
flex-shrink: 0;
|
font-size: 20px;
|
}
|
|
.message-content {
|
flex: 1;
|
overflow-x: hidden; // 修改为 hidden,内部容器处理滚动
|
display: flex;
|
flex-direction: column;
|
max-width: calc(100% - 48px); // 减去头像和间距
|
|
.text-box {
|
padding: 12px 16px;
|
border-radius: 12px;
|
font-size: 14px;
|
line-height: 1.6;
|
word-break: break-word;
|
max-width: 100%;
|
width: fit-content;
|
overflow-x: auto;
|
&::-webkit-scrollbar {
|
height: 6px;
|
}
|
&::-webkit-scrollbar-thumb {
|
background: #dcdfe6;
|
border-radius: 3px;
|
}
|
}
|
}
|
|
&.bot-message {
|
.message-content {
|
align-items: flex-start;
|
}
|
.avatar {
|
background-color: #409eff;
|
color: #fff;
|
}
|
.text-box {
|
background-color: #fff;
|
color: #303133;
|
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
}
|
}
|
|
&.user-message {
|
flex-direction: row-reverse;
|
.message-content {
|
align-items: flex-end;
|
}
|
.avatar {
|
background-color: #95d475;
|
color: #fff;
|
}
|
.text-box {
|
background-color: #409eff;
|
color: #fff;
|
}
|
}
|
}
|
|
.charts-wrapper {
|
margin-top: 10px;
|
display: flex;
|
flex-direction: column;
|
gap: 10px;
|
overflow-x: auto;
|
width: 100%;
|
padding-bottom: 8px;
|
&::-webkit-scrollbar {
|
height: 6px;
|
}
|
&::-webkit-scrollbar-thumb {
|
background: #dcdfe6;
|
border-radius: 3px;
|
}
|
}
|
|
.chart-item {
|
width: 100%;
|
min-width: 300px;
|
height: 300px;
|
background: #fff;
|
border-radius: 8px;
|
padding: 10px;
|
}
|
|
.table-wrapper {
|
margin-top: 10px;
|
background: #fff;
|
border-radius: 8px;
|
overflow: hidden;
|
overflow-x: auto;
|
width: 100%;
|
&::-webkit-scrollbar {
|
height: 6px;
|
}
|
&::-webkit-scrollbar-thumb {
|
background: #dcdfe6;
|
border-radius: 3px;
|
}
|
.el-table {
|
min-width: 300px;
|
}
|
}
|
|
.input-area {
|
padding: 16px;
|
background-color: #fff;
|
border-top: 1px solid #dcdfe6;
|
|
.input-actions {
|
display: flex;
|
gap: 12px;
|
margin-bottom: 8px;
|
align-items: center;
|
|
.file-upload-trigger {
|
display: inline-flex;
|
align-items: center;
|
}
|
}
|
|
.input-box {
|
padding: 12px;
|
position: relative;
|
background: #fff;
|
border: 1px solid #dcdfe6;
|
border-radius: 8px;
|
margin: 0 16px 16px;
|
transition: border-color 0.2s;
|
|
&:focus-within {
|
border-color: #409eff;
|
}
|
|
.selected-file-tag {
|
display: flex;
|
align-items: center;
|
background: #f0f7ff;
|
border: 1px solid #d9ecff;
|
border-radius: 4px;
|
padding: 4px 8px;
|
margin-bottom: 8px;
|
gap: 6px;
|
width: fit-content;
|
max-width: 100%;
|
|
.el-icon {
|
color: #409eff;
|
font-size: 14px;
|
}
|
|
.file-name {
|
font-size: 12px;
|
color: #606266;
|
white-space: nowrap;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
}
|
|
.remove-file {
|
cursor: pointer;
|
color: #909399;
|
transition: color 0.2s;
|
&:hover {
|
color: #f56c6c;
|
}
|
}
|
}
|
|
:deep(.el-textarea__inner) {
|
padding: 0;
|
border: none;
|
box-shadow: none;
|
background: transparent;
|
font-family: inherit;
|
font-size: 14px;
|
line-height: 1.5;
|
color: #303133;
|
&::placeholder {
|
color: #c0c4cc;
|
}
|
}
|
|
.send-btn {
|
position: absolute;
|
right: 12px;
|
bottom: 12px;
|
padding: 8px 16px;
|
}
|
}
|
}
|
|
.typing-indicator {
|
display: flex;
|
gap: 4px;
|
padding: 8px 12px;
|
background: #fff;
|
border-radius: 12px;
|
width: fit-content;
|
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
margin-top: 4px;
|
.dot {
|
width: 6px;
|
height: 6px;
|
background-color: #909399;
|
border-radius: 50%;
|
animation: typing 1.4s infinite ease-in-out;
|
&:nth-child(2) { animation-delay: 0.2s; }
|
&:nth-child(3) { animation-delay: 0.4s; }
|
}
|
}
|
|
@keyframes typing {
|
0%, 80%, 100% { transform: scale(0); }
|
40% { transform: scale(1); }
|
}
|
|
.code-block {
|
background: #2d2d2d;
|
color: #ccc;
|
padding: 12px;
|
border-radius: 6px;
|
font-family: monospace;
|
margin: 8px 0;
|
overflow-x: auto;
|
&.js-code {
|
color: #f08d49;
|
}
|
}
|
</style>
|