2026-04-28 074044c748b2e55a422efc5f59a0887b33bb8d5c
feat: 新增 AI 智能助手侧边栏组件

- 在应用布局中添加 AIChatSidebar 组件,提供悬浮触发按钮和抽屉式聊天界面
- 实现基于流式响应的 AI 对话功能,支持文本、图表和表格数据展示
- 新增文件上传分析功能,支持通过 API 分析上传的文件内容
- 添加会话历史管理功能,支持查看、切换和删除历史对话记录
- 集成 ECharts 图表库,可动态渲染 AI 返回的各类数据图表
已添加1个文件
已修改1个文件
1208 ■■■■■ 文件已修改
src/components/AIChatSidebar/index.vue 1206 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/layout/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/AIChatSidebar/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,1206 @@
<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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .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>
src/layout/index.vue
@@ -16,6 +16,7 @@
      <app-main />
      <settings ref="settingRef" />
    </div>
    <AIChatSidebar />
  </div>
</template>
@@ -23,6 +24,7 @@
  import { useWindowSize } from "@vueuse/core";
  import Sidebar from "./components/Sidebar/index.vue";
  import { AppMain, Navbar, Settings, TagsView } from "./components";
  import AIChatSidebar from "@/components/AIChatSidebar/index.vue";
  import defaultSettings from "@/settings";
  import useAppStore from "@/store/modules/app";