|
|
@@ -9,10 +9,19 @@
|
|
|
</template>
|
|
|
|
|
|
<!-- 聊天消息区域 -->
|
|
|
- <div class="chat-messages" ref="messagesRef">
|
|
|
+ <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 messages"
|
|
|
- :key="index"
|
|
|
+ v-for="(msg, index) in displayMessages"
|
|
|
+ :key="msg.id || index"
|
|
|
:class="['message', msg.role === 'user' ? 'user-message' : 'ai-message']"
|
|
|
>
|
|
|
<div class="message-avatar">
|
|
|
@@ -100,13 +109,14 @@
|
|
|
</template>
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
-import { ref, nextTick, onMounted } from 'vue'
|
|
|
+import { ref, nextTick, onMounted, computed } from 'vue'
|
|
|
import { ElMessage } from 'element-plus'
|
|
|
-import { ChatDotRound, Delete, Download, Promotion } from '@element-plus/icons-vue'
|
|
|
+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
|
|
|
@@ -122,8 +132,15 @@ 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 messages = ref<Message[]>([
|
|
|
+// 历史记录分页
|
|
|
+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 智能助手,有什么可以帮助您的吗?',
|
|
|
@@ -131,6 +148,11 @@ const messages = ref<Message[]>([
|
|
|
}
|
|
|
])
|
|
|
|
|
|
+// 合并显示的消息(历史 + 当前)
|
|
|
+const displayMessages = computed(() => {
|
|
|
+ return [...historyMessages.value, ...currentMessages.value]
|
|
|
+})
|
|
|
+
|
|
|
// 快捷问题
|
|
|
const quickQuestions = [
|
|
|
'如何管理系统用户?',
|
|
|
@@ -154,18 +176,82 @@ const scrollToBottom = () => {
|
|
|
})
|
|
|
}
|
|
|
|
|
|
+// 记录滚动位置
|
|
|
+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
|
|
|
|
|
|
- // 添加用户消息
|
|
|
- messages.value.push({
|
|
|
+ // 添加用户消息到当前会话
|
|
|
+ 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
|
|
|
@@ -184,26 +270,32 @@ const sendMessage = async () => {
|
|
|
}
|
|
|
|
|
|
// 打字机效果显示文本
|
|
|
-const typeWriterEffect = async (index: number, text: string, delay: number = 50) => {
|
|
|
+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 (messages.value[index]) {
|
|
|
- messages.value[index].content += text[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> => {
|
|
|
- // 先添加一个空的 AI 消息
|
|
|
- const aiMessageIndex = messages.value.length
|
|
|
- messages.value.push({
|
|
|
- role: 'assistant',
|
|
|
- content: '',
|
|
|
- time: new Date().toLocaleString()
|
|
|
- })
|
|
|
-
|
|
|
try {
|
|
|
// 先尝试普通 POST 请求(适用于代理模式,后端返回 JSON)
|
|
|
const response = await fetch(`/api/upms/ai/chat`, {
|
|
|
@@ -219,33 +311,44 @@ const sendChatMessage = async (question: string): Promise<void> => {
|
|
|
|
|
|
if (contentType.includes('text/event-stream')) {
|
|
|
// SSE 流式响应(Mock 模式)
|
|
|
- await handleSSEResponse(response, aiMessageIndex)
|
|
|
+ await handleSSEResponse(response)
|
|
|
} else {
|
|
|
// JSON 响应(代理模式)
|
|
|
const data = await response.json()
|
|
|
const reply = data.data?.reply || data.reply || '暂无回复'
|
|
|
// 使用打字机效果显示
|
|
|
- await typeWriterEffect(aiMessageIndex, reply, 30)
|
|
|
+ await typeWriterEffect(reply, 30)
|
|
|
}
|
|
|
} catch (error) {
|
|
|
console.error('AI 请求失败:', error)
|
|
|
// 尝试 SSE 方式(兼容旧版 Mock)
|
|
|
try {
|
|
|
- await handleSSEFallback(question, aiMessageIndex)
|
|
|
+ await handleSSEFallback(question)
|
|
|
} catch (sseError) {
|
|
|
console.error('SSE 也失败了:', sseError)
|
|
|
- if (messages.value[aiMessageIndex]) {
|
|
|
- messages.value[aiMessageIndex].content = '抱歉,AI 服务暂时不可用,请稍后再试。'
|
|
|
- }
|
|
|
+ // 添加错误消息
|
|
|
+ currentMessages.value.push({
|
|
|
+ role: 'assistant',
|
|
|
+ content: '抱歉,AI 服务暂时不可用,请稍后再试。',
|
|
|
+ time: new Date().toLocaleString()
|
|
|
+ })
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 处理 SSE 流响应
|
|
|
-const handleSSEResponse = async (response: Response, index: number): Promise<void> => {
|
|
|
+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 = ''
|
|
|
|
|
|
@@ -261,8 +364,8 @@ const handleSSEResponse = async (response: Response, index: number): Promise<voi
|
|
|
if (line.startsWith('data: ')) {
|
|
|
try {
|
|
|
const data = JSON.parse(line.slice(6))
|
|
|
- if (messages.value[index]) {
|
|
|
- messages.value[index].content += data.content
|
|
|
+ if (currentMessages.value[msgIndex]) {
|
|
|
+ currentMessages.value[msgIndex].content += data.content
|
|
|
scrollToBottom()
|
|
|
}
|
|
|
} catch (e) {
|
|
|
@@ -271,22 +374,39 @@ const handleSSEResponse = async (response: Response, index: number): Promise<voi
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+ // 只保留最近10条当前会话记录
|
|
|
+ if (currentMessages.value.length > 10) {
|
|
|
+ currentMessages.value = currentMessages.value.slice(-10)
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
// SSE 降级方案(使用 EventSource)
|
|
|
-const handleSSEFallback = (question: string, index: number): Promise<void> => {
|
|
|
+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 (messages.value[index]) {
|
|
|
- messages.value[index].content += data.content
|
|
|
+ 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) {
|
|
|
@@ -296,7 +416,11 @@ const handleSSEFallback = (question: string, index: number): Promise<void> => {
|
|
|
|
|
|
eventSource.onerror = (error) => {
|
|
|
eventSource.close()
|
|
|
- if (messages.value[index]?.content) {
|
|
|
+ if (currentMessages.value[msgIndex]?.content) {
|
|
|
+ // 只保留最近10条当前会话记录
|
|
|
+ if (currentMessages.value.length > 10) {
|
|
|
+ currentMessages.value = currentMessages.value.slice(-10)
|
|
|
+ }
|
|
|
resolve()
|
|
|
} else {
|
|
|
reject(error)
|
|
|
@@ -320,7 +444,7 @@ const handleEnter = (e: KeyboardEvent) => {
|
|
|
|
|
|
// 清空对话
|
|
|
const clearChat = () => {
|
|
|
- messages.value = [{
|
|
|
+ currentMessages.value = [{
|
|
|
role: 'assistant',
|
|
|
content: '对话已清空,我是您的 AI 智能助手,有什么可以帮助您的吗?',
|
|
|
time: new Date().toLocaleString()
|
|
|
@@ -330,7 +454,8 @@ const clearChat = () => {
|
|
|
|
|
|
// 导出聊天记录
|
|
|
const exportChat = () => {
|
|
|
- const chatText = messages.value.map(m => {
|
|
|
+ const allMessages = [...historyMessages.value, ...currentMessages.value]
|
|
|
+ const chatText = allMessages.map(m => {
|
|
|
return `[${m.time}] ${m.role === 'user' ? '我' : 'AI'}: ${m.content}`
|
|
|
}).join('\n\n')
|
|
|
|
|
|
@@ -348,7 +473,8 @@ const exportChat = () => {
|
|
|
}
|
|
|
|
|
|
onMounted(() => {
|
|
|
- scrollToBottom()
|
|
|
+ // 初始加载历史记录
|
|
|
+ loadHistory()
|
|
|
})
|
|
|
</script>
|
|
|
|
|
|
@@ -396,6 +522,23 @@ onMounted(() => {
|
|
|
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;
|