|
|
@@ -0,0 +1,693 @@
|
|
|
+<template>
|
|
|
+ <div class="ai-chat-container">
|
|
|
+ <el-card class="chat-card">
|
|
|
+ <template #header>
|
|
|
+ <div class="chat-header">
|
|
|
+ <el-icon class="chat-icon"><ChatDotRound /></el-icon>
|
|
|
+ <span class="chat-title">AI 智能助手</span>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <!-- 聊天消息区域 -->
|
|
|
+ <div class="chat-messages" ref="messagesRef" @scroll="handleScroll">
|
|
|
+ <!-- 加载更多提示 -->
|
|
|
+ <div v-if="isLoadingHistory" class="loading-more">
|
|
|
+ <el-icon class="loading-icon"><Loading /></el-icon>
|
|
|
+ <span>加载历史记录中...</span>
|
|
|
+ </div>
|
|
|
+ <div v-else-if="!hasMoreHistory" class="no-more-history">
|
|
|
+ <span>—— 没有更多记录了 ——</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div
|
|
|
+ v-for="(msg, index) in displayMessages"
|
|
|
+ :key="msg.id || index"
|
|
|
+ :class="['message', msg.role === 'user' ? 'user-message' : 'ai-message']"
|
|
|
+ >
|
|
|
+ <div class="message-avatar">
|
|
|
+ <el-avatar
|
|
|
+ :size="40"
|
|
|
+ :src="msg.role === 'user' ? userAvatar : aiAvatar"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div class="message-content">
|
|
|
+ <div class="message-sender">{{ msg.role === 'user' ? '我' : 'AI 助手' }}</div>
|
|
|
+ <div class="message-text" v-html="formatMessage(msg.content)"></div>
|
|
|
+ <div class="message-time">{{ msg.time }}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- AI 正在输入提示 -->
|
|
|
+ <div v-if="isTyping" class="message ai-message typing-message">
|
|
|
+ <div class="message-avatar">
|
|
|
+ <el-avatar :size="40" :src="aiAvatar" />
|
|
|
+ </div>
|
|
|
+ <div class="message-content">
|
|
|
+ <div class="message-sender">AI 助手</div>
|
|
|
+ <div class="typing-indicator">
|
|
|
+ <span></span>
|
|
|
+ <span></span>
|
|
|
+ <span></span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 输入区域 -->
|
|
|
+ <div class="chat-input-area">
|
|
|
+ <div class="input-toolbar">
|
|
|
+ <el-button type="primary" text @click="clearChat">
|
|
|
+ <el-icon><Delete /></el-icon>清空对话
|
|
|
+ </el-button>
|
|
|
+ <el-button type="primary" text @click="exportChat">
|
|
|
+ <el-icon><Download /></el-icon>导出记录
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ <div class="input-box">
|
|
|
+ <el-input
|
|
|
+ v-model="inputMessage"
|
|
|
+ type="textarea"
|
|
|
+ :rows="3"
|
|
|
+ placeholder="请输入您的问题,按 Enter 发送,Shift + Enter 换行..."
|
|
|
+ resize="none"
|
|
|
+ @keydown.enter.prevent="handleEnter"
|
|
|
+ />
|
|
|
+ <div class="input-actions">
|
|
|
+ <el-button
|
|
|
+ type="primary"
|
|
|
+ size="large"
|
|
|
+ :loading="isSending"
|
|
|
+ :disabled="!inputMessage.trim()"
|
|
|
+ @click="sendMessage"
|
|
|
+ >
|
|
|
+ <el-icon><Promotion /></el-icon>
|
|
|
+ 发送
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+
|
|
|
+ <!-- 快捷问题卡片 -->
|
|
|
+ <el-card class="quick-questions">
|
|
|
+ <template #header>
|
|
|
+ <span>快捷问题</span>
|
|
|
+ </template>
|
|
|
+ <div class="question-tags">
|
|
|
+ <el-tag
|
|
|
+ v-for="question in quickQuestions"
|
|
|
+ :key="question"
|
|
|
+ class="question-tag"
|
|
|
+ effect="plain"
|
|
|
+ @click="selectQuestion(question)"
|
|
|
+ >
|
|
|
+ {{ question }}
|
|
|
+ </el-tag>
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import { ref, nextTick, onMounted, computed } from 'vue'
|
|
|
+import { ElMessage } from 'element-plus'
|
|
|
+import { ChatDotRound, Delete, Download, Promotion, Loading } from '@element-plus/icons-vue'
|
|
|
+import { useUserStore } from '@/store/user'
|
|
|
+import { http } from '@/utils/request'
|
|
|
+
|
|
|
+interface Message {
|
|
|
+ id?: string
|
|
|
+ role: 'user' | 'assistant'
|
|
|
+ content: string
|
|
|
+ time: string
|
|
|
+}
|
|
|
+
|
|
|
+const userStore = useUserStore()
|
|
|
+const messagesRef = ref<HTMLDivElement>()
|
|
|
+const inputMessage = ref('')
|
|
|
+const isSending = ref(false)
|
|
|
+const isTyping = ref(false)
|
|
|
+
|
|
|
+// 头像
|
|
|
+const userAvatar = 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png'
|
|
|
+const aiAvatar = 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png'
|
|
|
+
|
|
|
+// 历史记录分页
|
|
|
+const historyPage = ref(1)
|
|
|
+const historySize = 10
|
|
|
+const isLoadingHistory = ref(false)
|
|
|
+const hasMoreHistory = ref(true)
|
|
|
+const historyMessages = ref<Message[]>([])
|
|
|
+
|
|
|
+// 当前会话消息(最多保留10条)
|
|
|
+const currentMessages = ref<Message[]>([
|
|
|
+ {
|
|
|
+ role: 'assistant',
|
|
|
+ content: '您好!我是您的 AI 智能助手,有什么可以帮助您的吗?',
|
|
|
+ time: new Date().toLocaleString()
|
|
|
+ }
|
|
|
+])
|
|
|
+
|
|
|
+// 合并显示的消息(历史 + 当前)
|
|
|
+const displayMessages = computed(() => {
|
|
|
+ return [...historyMessages.value, ...currentMessages.value]
|
|
|
+})
|
|
|
+
|
|
|
+// 快捷问题
|
|
|
+const quickQuestions = [
|
|
|
+ '如何管理系统用户?',
|
|
|
+ '如何配置菜单权限?',
|
|
|
+ '系统有哪些功能模块?',
|
|
|
+ '如何导出数据报表?',
|
|
|
+ '如何修改个人资料?'
|
|
|
+]
|
|
|
+
|
|
|
+// 格式化消息(支持简单的换行)
|
|
|
+const formatMessage = (content: string) => {
|
|
|
+ return content.replace(/\n/g, '<br>')
|
|
|
+}
|
|
|
+
|
|
|
+// 滚动到底部
|
|
|
+const scrollToBottom = () => {
|
|
|
+ nextTick(() => {
|
|
|
+ if (messagesRef.value) {
|
|
|
+ messagesRef.value.scrollTop = messagesRef.value.scrollHeight
|
|
|
+ }
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// 记录滚动位置
|
|
|
+let scrollHeightBeforeLoad = 0
|
|
|
+
|
|
|
+// 加载历史聊天记录
|
|
|
+const loadHistory = async () => {
|
|
|
+ if (isLoadingHistory.value || !hasMoreHistory.value) return
|
|
|
+
|
|
|
+ isLoadingHistory.value = true
|
|
|
+
|
|
|
+ // 记录当前滚动高度
|
|
|
+ if (messagesRef.value) {
|
|
|
+ scrollHeightBeforeLoad = messagesRef.value.scrollHeight
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await http.get<{
|
|
|
+ list: Message[]
|
|
|
+ total: number
|
|
|
+ hasMore: boolean
|
|
|
+ }>('/upms/ai/chat/history', {
|
|
|
+ page: historyPage.value,
|
|
|
+ size: historySize
|
|
|
+ })
|
|
|
+
|
|
|
+ if (res.list && res.list.length > 0) {
|
|
|
+ // 将新加载的历史记录添加到前面
|
|
|
+ historyMessages.value = [...res.list, ...historyMessages.value]
|
|
|
+ historyPage.value++
|
|
|
+ hasMoreHistory.value = res.hasMore
|
|
|
+
|
|
|
+ // 保持滚动位置
|
|
|
+ nextTick(() => {
|
|
|
+ if (messagesRef.value) {
|
|
|
+ const newScrollHeight = messagesRef.value.scrollHeight
|
|
|
+ messagesRef.value.scrollTop = newScrollHeight - scrollHeightBeforeLoad
|
|
|
+ }
|
|
|
+ })
|
|
|
+ } else {
|
|
|
+ hasMoreHistory.value = false
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('加载历史记录失败:', error)
|
|
|
+ } finally {
|
|
|
+ isLoadingHistory.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 处理滚动事件
|
|
|
+const handleScroll = () => {
|
|
|
+ if (!messagesRef.value) return
|
|
|
+
|
|
|
+ const { scrollTop } = messagesRef.value
|
|
|
+
|
|
|
+ // 当滚动到顶部时,加载更多历史记录
|
|
|
+ if (scrollTop <= 10 && !isLoadingHistory.value && hasMoreHistory.value) {
|
|
|
+ loadHistory()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 发送消息
|
|
|
+const sendMessage = async () => {
|
|
|
+ const content = inputMessage.value.trim()
|
|
|
+ if (!content || isSending.value) return
|
|
|
+
|
|
|
+ // 添加用户消息到当前会话
|
|
|
+ currentMessages.value.push({
|
|
|
+ role: 'user',
|
|
|
+ content,
|
|
|
+ time: new Date().toLocaleString()
|
|
|
+ })
|
|
|
+
|
|
|
+ // 只保留最近10条当前会话记录
|
|
|
+ if (currentMessages.value.length > 10) {
|
|
|
+ currentMessages.value = currentMessages.value.slice(-10)
|
|
|
+ }
|
|
|
+
|
|
|
+ inputMessage.value = ''
|
|
|
+ isSending.value = true
|
|
|
+ isTyping.value = true
|
|
|
+ scrollToBottom()
|
|
|
+
|
|
|
+ // 调用 AI 接口获取流式回复
|
|
|
+ try {
|
|
|
+ await sendChatMessage(content)
|
|
|
+ } catch (error) {
|
|
|
+ console.error('AI 对话失败:', error)
|
|
|
+ } finally {
|
|
|
+ isTyping.value = false
|
|
|
+ isSending.value = false
|
|
|
+ scrollToBottom()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 打字机效果显示文本
|
|
|
+const typeWriterEffect = async (text: string, delay: number = 30) => {
|
|
|
+ // 添加一个空的 AI 消息
|
|
|
+ currentMessages.value.push({
|
|
|
+ role: 'assistant',
|
|
|
+ content: '',
|
|
|
+ time: new Date().toLocaleString()
|
|
|
+ })
|
|
|
+
|
|
|
+ const msgIndex = currentMessages.value.length - 1
|
|
|
+
|
|
|
+ for (let i = 0; i < text.length; i++) {
|
|
|
+ if (currentMessages.value[msgIndex]) {
|
|
|
+ currentMessages.value[msgIndex].content += text[i]
|
|
|
+ scrollToBottom()
|
|
|
+ }
|
|
|
+ await new Promise(resolve => setTimeout(resolve, delay))
|
|
|
+ }
|
|
|
+
|
|
|
+ // 只保留最近10条当前会话记录
|
|
|
+ if (currentMessages.value.length > 10) {
|
|
|
+ currentMessages.value = currentMessages.value.slice(-10)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// AI 对话接口 (同时支持 SSE 和普通 JSON)
|
|
|
+const sendChatMessage = async (question: string): Promise<void> => {
|
|
|
+ try {
|
|
|
+ // 先尝试普通 POST 请求(适用于代理模式,后端返回 JSON)
|
|
|
+ const response = await fetch(`/api/upms/ai/chat`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: {
|
|
|
+ 'Content-Type': 'application/json',
|
|
|
+ },
|
|
|
+ body: JSON.stringify({ question })
|
|
|
+ })
|
|
|
+
|
|
|
+ // 检查响应类型
|
|
|
+ const contentType = response.headers.get('content-type') || ''
|
|
|
+
|
|
|
+ if (contentType.includes('text/event-stream')) {
|
|
|
+ // SSE 流式响应(Mock 模式)
|
|
|
+ await handleSSEResponse(response)
|
|
|
+ } else {
|
|
|
+ // JSON 响应(代理模式)
|
|
|
+ const data = await response.json()
|
|
|
+ const reply = data.data?.reply || data.reply || '暂无回复'
|
|
|
+ // 使用打字机效果显示
|
|
|
+ await typeWriterEffect(reply, 30)
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('AI 请求失败:', error)
|
|
|
+ // 尝试 SSE 方式(兼容旧版 Mock)
|
|
|
+ try {
|
|
|
+ await handleSSEFallback(question)
|
|
|
+ } catch (sseError) {
|
|
|
+ console.error('SSE 也失败了:', sseError)
|
|
|
+ // 添加错误消息
|
|
|
+ currentMessages.value.push({
|
|
|
+ role: 'assistant',
|
|
|
+ content: '抱歉,AI 服务暂时不可用,请稍后再试。',
|
|
|
+ time: new Date().toLocaleString()
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 处理 SSE 流响应
|
|
|
+const handleSSEResponse = async (response: Response): Promise<void> => {
|
|
|
+ const reader = response.body?.getReader()
|
|
|
+ if (!reader) throw new Error('无法读取响应流')
|
|
|
+
|
|
|
+ // 添加一个空的 AI 消息
|
|
|
+ currentMessages.value.push({
|
|
|
+ role: 'assistant',
|
|
|
+ content: '',
|
|
|
+ time: new Date().toLocaleString()
|
|
|
+ })
|
|
|
+
|
|
|
+ const msgIndex = currentMessages.value.length - 1
|
|
|
+ const decoder = new TextDecoder()
|
|
|
+ let buffer = ''
|
|
|
+
|
|
|
+ while (true) {
|
|
|
+ const { done, value } = await reader.read()
|
|
|
+ if (done) break
|
|
|
+
|
|
|
+ buffer += decoder.decode(value, { stream: true })
|
|
|
+ const lines = buffer.split('\n')
|
|
|
+ buffer = lines.pop() || ''
|
|
|
+
|
|
|
+ for (const line of lines) {
|
|
|
+ if (line.startsWith('data: ')) {
|
|
|
+ try {
|
|
|
+ const data = JSON.parse(line.slice(6))
|
|
|
+ if (currentMessages.value[msgIndex]) {
|
|
|
+ currentMessages.value[msgIndex].content += data.content
|
|
|
+ scrollToBottom()
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ // 忽略解析错误
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 只保留最近10条当前会话记录
|
|
|
+ if (currentMessages.value.length > 10) {
|
|
|
+ currentMessages.value = currentMessages.value.slice(-10)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// SSE 降级方案(使用 EventSource)
|
|
|
+const handleSSEFallback = (question: string): Promise<void> => {
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ // 添加一个空的 AI 消息
|
|
|
+ currentMessages.value.push({
|
|
|
+ role: 'assistant',
|
|
|
+ content: '',
|
|
|
+ time: new Date().toLocaleString()
|
|
|
+ })
|
|
|
+
|
|
|
+ const msgIndex = currentMessages.value.length - 1
|
|
|
+ const eventSource = new EventSource(`/api/upms/ai/chat?question=${encodeURIComponent(question)}`)
|
|
|
+
|
|
|
+ eventSource.onmessage = (event) => {
|
|
|
+ try {
|
|
|
+ const data = JSON.parse(event.data)
|
|
|
+ if (currentMessages.value[msgIndex]) {
|
|
|
+ currentMessages.value[msgIndex].content += data.content
|
|
|
+ scrollToBottom()
|
|
|
+ }
|
|
|
+ if (data.done) {
|
|
|
+ eventSource.close()
|
|
|
+ // 只保留最近10条当前会话记录
|
|
|
+ if (currentMessages.value.length > 10) {
|
|
|
+ currentMessages.value = currentMessages.value.slice(-10)
|
|
|
+ }
|
|
|
+ resolve()
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('解析 SSE 数据失败:', error)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ eventSource.onerror = (error) => {
|
|
|
+ eventSource.close()
|
|
|
+ if (currentMessages.value[msgIndex]?.content) {
|
|
|
+ // 只保留最近10条当前会话记录
|
|
|
+ if (currentMessages.value.length > 10) {
|
|
|
+ currentMessages.value = currentMessages.value.slice(-10)
|
|
|
+ }
|
|
|
+ resolve()
|
|
|
+ } else {
|
|
|
+ reject(error)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// 选择快捷问题
|
|
|
+const selectQuestion = (question: string) => {
|
|
|
+ inputMessage.value = question
|
|
|
+ sendMessage()
|
|
|
+}
|
|
|
+
|
|
|
+// 处理回车键
|
|
|
+const handleEnter = (e: KeyboardEvent) => {
|
|
|
+ if (!e.shiftKey) {
|
|
|
+ sendMessage()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 清空对话
|
|
|
+const clearChat = () => {
|
|
|
+ currentMessages.value = [{
|
|
|
+ role: 'assistant',
|
|
|
+ content: '对话已清空,我是您的 AI 智能助手,有什么可以帮助您的吗?',
|
|
|
+ time: new Date().toLocaleString()
|
|
|
+ }]
|
|
|
+ ElMessage.success('对话已清空')
|
|
|
+}
|
|
|
+
|
|
|
+// 导出聊天记录
|
|
|
+const exportChat = () => {
|
|
|
+ const allMessages = [...historyMessages.value, ...currentMessages.value]
|
|
|
+ const chatText = allMessages.map(m => {
|
|
|
+ return `[${m.time}] ${m.role === 'user' ? '我' : 'AI'}: ${m.content}`
|
|
|
+ }).join('\n\n')
|
|
|
+
|
|
|
+ const blob = new Blob([chatText], { type: 'text/plain;charset=utf-8' })
|
|
|
+ const url = URL.createObjectURL(blob)
|
|
|
+ const link = document.createElement('a')
|
|
|
+ link.href = url
|
|
|
+ link.download = `聊天记录_${new Date().toLocaleDateString()}.txt`
|
|
|
+ document.body.appendChild(link)
|
|
|
+ link.click()
|
|
|
+ document.body.removeChild(link)
|
|
|
+ URL.revokeObjectURL(url)
|
|
|
+
|
|
|
+ ElMessage.success('聊天记录已导出')
|
|
|
+}
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ // 初始加载历史记录
|
|
|
+ loadHistory()
|
|
|
+})
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.ai-chat-container {
|
|
|
+ display: flex;
|
|
|
+ gap: 20px;
|
|
|
+ height: calc(100vh - 140px);
|
|
|
+}
|
|
|
+
|
|
|
+.chat-card {
|
|
|
+ flex: 1;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+}
|
|
|
+
|
|
|
+.chat-card :deep(.el-card__body) {
|
|
|
+ flex: 1;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ padding: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.chat-header {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.chat-icon {
|
|
|
+ font-size: 24px;
|
|
|
+ color: #409eff;
|
|
|
+}
|
|
|
+
|
|
|
+.chat-title {
|
|
|
+ font-size: 18px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #303133;
|
|
|
+}
|
|
|
+
|
|
|
+.chat-messages {
|
|
|
+ flex: 1;
|
|
|
+ overflow-y: auto;
|
|
|
+ padding: 20px;
|
|
|
+ background-color: #f5f7fa;
|
|
|
+}
|
|
|
+
|
|
|
+.loading-more, .no-more-history {
|
|
|
+ text-align: center;
|
|
|
+ padding: 15px 0;
|
|
|
+ color: #909399;
|
|
|
+ font-size: 13px;
|
|
|
+}
|
|
|
+
|
|
|
+.loading-icon {
|
|
|
+ animation: rotating 1s linear infinite;
|
|
|
+ margin-right: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+@keyframes rotating {
|
|
|
+ from { transform: rotate(0deg); }
|
|
|
+ to { transform: rotate(360deg); }
|
|
|
+}
|
|
|
+
|
|
|
+.message {
|
|
|
+ display: flex;
|
|
|
+ gap: 12px;
|
|
|
+ margin-bottom: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+.message-avatar {
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.message-content {
|
|
|
+ flex: 1;
|
|
|
+ max-width: 70%;
|
|
|
+}
|
|
|
+
|
|
|
+.message-sender {
|
|
|
+ font-size: 13px;
|
|
|
+ color: #909399;
|
|
|
+ margin-bottom: 6px;
|
|
|
+}
|
|
|
+
|
|
|
+.message-text {
|
|
|
+ padding: 12px 16px;
|
|
|
+ border-radius: 8px;
|
|
|
+ font-size: 14px;
|
|
|
+ line-height: 1.6;
|
|
|
+ word-wrap: break-word;
|
|
|
+}
|
|
|
+
|
|
|
+.message-time {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #c0c4cc;
|
|
|
+ margin-top: 6px;
|
|
|
+}
|
|
|
+
|
|
|
+.user-message {
|
|
|
+ flex-direction: row-reverse;
|
|
|
+}
|
|
|
+
|
|
|
+.user-message .message-text {
|
|
|
+ background-color: #409eff;
|
|
|
+ color: #fff;
|
|
|
+ border-bottom-right-radius: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.user-message .message-sender,
|
|
|
+.user-message .message-time {
|
|
|
+ text-align: right;
|
|
|
+}
|
|
|
+
|
|
|
+.ai-message .message-text {
|
|
|
+ background-color: #fff;
|
|
|
+ color: #303133;
|
|
|
+ border: 1px solid #e4e7ed;
|
|
|
+ border-bottom-left-radius: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.typing-message .typing-indicator {
|
|
|
+ display: flex;
|
|
|
+ gap: 4px;
|
|
|
+ padding: 12px 16px;
|
|
|
+ background-color: #fff;
|
|
|
+ border: 1px solid #e4e7ed;
|
|
|
+ border-radius: 8px;
|
|
|
+ border-bottom-left-radius: 4px;
|
|
|
+ width: fit-content;
|
|
|
+}
|
|
|
+
|
|
|
+.typing-indicator span {
|
|
|
+ width: 8px;
|
|
|
+ height: 8px;
|
|
|
+ background-color: #409eff;
|
|
|
+ border-radius: 50%;
|
|
|
+ animation: typing 1.4s infinite ease-in-out both;
|
|
|
+}
|
|
|
+
|
|
|
+.typing-indicator span:nth-child(1) {
|
|
|
+ animation-delay: -0.32s;
|
|
|
+}
|
|
|
+
|
|
|
+.typing-indicator span:nth-child(2) {
|
|
|
+ animation-delay: -0.16s;
|
|
|
+}
|
|
|
+
|
|
|
+@keyframes typing {
|
|
|
+ 0%, 80%, 100% {
|
|
|
+ transform: scale(0);
|
|
|
+ }
|
|
|
+ 40% {
|
|
|
+ transform: scale(1);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.chat-input-area {
|
|
|
+ padding: 20px;
|
|
|
+ border-top: 1px solid #e4e7ed;
|
|
|
+ background-color: #fff;
|
|
|
+}
|
|
|
+
|
|
|
+.input-toolbar {
|
|
|
+ display: flex;
|
|
|
+ gap: 10px;
|
|
|
+ margin-bottom: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.input-box {
|
|
|
+ display: flex;
|
|
|
+ gap: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.input-box .el-textarea {
|
|
|
+ flex: 1;
|
|
|
+}
|
|
|
+
|
|
|
+.input-actions {
|
|
|
+ display: flex;
|
|
|
+ align-items: flex-end;
|
|
|
+}
|
|
|
+
|
|
|
+.input-actions .el-button {
|
|
|
+ height: 100%;
|
|
|
+ min-height: 74px;
|
|
|
+}
|
|
|
+
|
|
|
+.quick-questions {
|
|
|
+ width: 280px;
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.quick-questions :deep(.el-card__body) {
|
|
|
+ padding: 15px;
|
|
|
+}
|
|
|
+
|
|
|
+.question-tags {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.question-tag {
|
|
|
+ cursor: pointer;
|
|
|
+ padding: 10px 15px;
|
|
|
+ font-size: 13px;
|
|
|
+ transition: all 0.3s;
|
|
|
+}
|
|
|
+
|
|
|
+.question-tag:hover {
|
|
|
+ background-color: #409eff;
|
|
|
+ color: #fff;
|
|
|
+ border-color: #409eff;
|
|
|
+}
|
|
|
+</style>
|