|
|
|
|
@ -0,0 +1,848 @@
|
|
|
|
|
<!--
|
|
|
|
|
@file views/QueryPage.vue
|
|
|
|
|
@description 数据查询页面
|
|
|
|
|
|
|
|
|
|
功能:
|
|
|
|
|
- 自然语言查询输入
|
|
|
|
|
- SQL 生成与执行
|
|
|
|
|
- 结果展示(表格/图表)
|
|
|
|
|
- 查询历史侧边栏
|
|
|
|
|
- 推荐查询侧边栏
|
|
|
|
|
|
|
|
|
|
布局结构:
|
|
|
|
|
- 最外层:flex容器,无背景色(继承父级)
|
|
|
|
|
- 左侧:聊天消息区域 + 输入区域
|
|
|
|
|
- 右侧:推荐侧边栏(桌面端)/ 移动端覆盖层
|
|
|
|
|
- 覆盖层:历史对话侧边栏
|
|
|
|
|
|
|
|
|
|
@author Frontend Team
|
|
|
|
|
-->
|
|
|
|
|
<template>
|
|
|
|
|
<!-- 最外层:全宽布局,使用纯色背景,使用flex-1确保不被顶部导航栏挤压 -->
|
|
|
|
|
<div class="flex-1 flex flex-col relative min-h-0 bg-gray-50">
|
|
|
|
|
<!-- 推荐按钮栏:固定在右上角(顶部栏下方) -->
|
|
|
|
|
<div class="absolute top-4 right-6 z-50">
|
|
|
|
|
<QueryRecommendSidebar
|
|
|
|
|
:current-conversation="currentConversation"
|
|
|
|
|
@recommendation-click="handleRecommendationClick"
|
|
|
|
|
@open-common="openCommonRecommendations"
|
|
|
|
|
@open-suggestions="openAISuggestions"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 主要内容区:flex容器,聊天区域,右边距增加避免与推荐按钮冲突 -->
|
|
|
|
|
<div class="flex-1 flex flex-col px-4 md:px-8 lg:px-12 py-6 pr-28 overflow-hidden">
|
|
|
|
|
<!-- 聊天区域 - 聊天界面容器,包含消息和输入框 -->
|
|
|
|
|
<div class="flex-1 flex flex-col min-h-0 mx-auto w-full" style="max-width: 900px;">
|
|
|
|
|
<!-- 聊天界面容器:包裹消息区域和输入框,确保输入框固定在底部 -->
|
|
|
|
|
<div class="flex-1 flex flex-col min-h-0">
|
|
|
|
|
<!-- 聊天消息区域 - 自适应高度,可滚动,优化视觉效果,增加右边距避免与推荐按钮重叠 -->
|
|
|
|
|
<div
|
|
|
|
|
ref="chatContainerRef"
|
|
|
|
|
class="flex-1 overflow-y-auto min-h-0 py-6 px-2"
|
|
|
|
|
style="scrollbar-width: thin; scrollbar-color: rgba(156, 163, 175, 0.5) transparent; padding-right: 1rem;"
|
|
|
|
|
>
|
|
|
|
|
<!-- 消息容器:居中显示,限制最大宽度,与输入框等宽 -->
|
|
|
|
|
<div class="w-full mx-auto space-y-6">
|
|
|
|
|
<!-- 聊天消息 -->
|
|
|
|
|
<ChatMessage
|
|
|
|
|
v-for="(msg, index) in currentConversation?.messages"
|
|
|
|
|
:key="index"
|
|
|
|
|
:message="msg"
|
|
|
|
|
:saved-queries="savedQueries"
|
|
|
|
|
@save-query="handleSaveQuery"
|
|
|
|
|
@share-query="handleShareQuery"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<!-- 加载中状态 - 优化设计 -->
|
|
|
|
|
<div v-if="isLoading" class="w-full flex justify-start">
|
|
|
|
|
<div class="inline-flex items-center space-x-3 px-5 py-4 bg-white rounded-2xl shadow-md border border-gray-100">
|
|
|
|
|
<div class="flex space-x-1">
|
|
|
|
|
<div class="w-2 h-2 bg-primary rounded-full animate-bounce" style="animation-delay: 0ms"></div>
|
|
|
|
|
<div class="w-2 h-2 bg-primary rounded-full animate-bounce" style="animation-delay: 150ms"></div>
|
|
|
|
|
<div class="w-2 h-2 bg-primary rounded-full animate-bounce" style="animation-delay: 300ms"></div>
|
|
|
|
|
</div>
|
|
|
|
|
<span class="text-sm text-gray-600 font-medium">AI正在思考中...</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 输入框容器:固定在底部,现代化设计,与消息气泡等宽(两边聊天气泡的最远端) -->
|
|
|
|
|
<div class="flex-shrink-0 mt-4 w-full flex justify-center">
|
|
|
|
|
<div class="bg-white rounded-2xl shadow-lg border border-gray-200/50 p-4 backdrop-blur-sm" style="max-width: 900px; width: 100%;">
|
|
|
|
|
<!-- textarea 区域:现代化输入框 -->
|
|
|
|
|
<textarea
|
|
|
|
|
v-model="prompt"
|
|
|
|
|
@keydown.enter.prevent="handleEnterKey"
|
|
|
|
|
@input="handleInputChange"
|
|
|
|
|
@focus="showRecommendations = true"
|
|
|
|
|
placeholder="输入您的查询需求,例如:展示2023年各季度的订单量..."
|
|
|
|
|
class="w-full px-4 py-3 resize-none focus:outline-none text-base bg-gray-50 rounded-xl border-2 border-transparent focus:border-primary/30 focus:bg-white transition-all max-h-40 overflow-y-auto placeholder:text-gray-400"
|
|
|
|
|
:disabled="isLoading"
|
|
|
|
|
rows="1"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<!-- 按钮区域:优化布局和样式 -->
|
|
|
|
|
<div class="flex items-center justify-between pt-3 mt-3 border-t border-gray-100">
|
|
|
|
|
<!-- 左侧:模型和数据库选择 -->
|
|
|
|
|
<div class="flex items-center gap-3">
|
|
|
|
|
<!-- 模型选择 -->
|
|
|
|
|
<div class="relative group">
|
|
|
|
|
<i class="fa fa-cogs absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm pointer-events-none z-10 group-hover:text-primary transition-colors"></i>
|
|
|
|
|
<select
|
|
|
|
|
v-model="selectedModelId"
|
|
|
|
|
:class="[
|
|
|
|
|
'pl-9 pr-10 py-2 text-sm border-2 rounded-lg bg-white hover:border-primary/30 transition-all appearance-none cursor-pointer min-w-[150px]',
|
|
|
|
|
selectedModelId ? 'border-primary/50 bg-primary/5 text-gray-800' : 'border-gray-200 text-gray-600'
|
|
|
|
|
]"
|
|
|
|
|
:title="modelOptions.find(m => m.id === selectedModelId)?.name || '选择模型'"
|
|
|
|
|
style="white-space: normal; text-overflow: initial;"
|
|
|
|
|
>
|
|
|
|
|
<option value="">选择模型</option>
|
|
|
|
|
<option v-for="model in modelOptions" :key="model.id" :value="model.id">
|
|
|
|
|
{{ model.name }}
|
|
|
|
|
</option>
|
|
|
|
|
</select>
|
|
|
|
|
<i class="fa fa-chevron-down absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 text-xs pointer-events-none"></i>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 数据库选择 -->
|
|
|
|
|
<div class="relative group">
|
|
|
|
|
<i class="fa fa-database absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm pointer-events-none z-10 group-hover:text-primary transition-colors"></i>
|
|
|
|
|
<select
|
|
|
|
|
v-model="selectedDatabaseId"
|
|
|
|
|
:class="[
|
|
|
|
|
'pl-9 pr-10 py-2 text-sm border-2 rounded-lg bg-white hover:border-primary/30 transition-all appearance-none cursor-pointer min-w-[150px]',
|
|
|
|
|
selectedDatabaseId ? 'border-primary/50 bg-primary/5 text-gray-800' : 'border-gray-200 text-gray-600'
|
|
|
|
|
]"
|
|
|
|
|
:title="databaseOptions.find(d => d.id === selectedDatabaseId)?.name || '选择数据库'"
|
|
|
|
|
style="white-space: normal; text-overflow: initial;"
|
|
|
|
|
>
|
|
|
|
|
<option value="">选择数据库</option>
|
|
|
|
|
<option v-for="db in databaseOptions" :key="db.id" :value="db.id">
|
|
|
|
|
{{ db.name }}
|
|
|
|
|
</option>
|
|
|
|
|
</select>
|
|
|
|
|
<i class="fa fa-chevron-down absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 text-xs pointer-events-none"></i>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 右侧:发送按钮 -->
|
|
|
|
|
<button
|
|
|
|
|
@click="handleSubmit()"
|
|
|
|
|
:disabled="!prompt.trim() || isLoading"
|
|
|
|
|
class="px-6 py-2.5 rounded-xl flex items-center justify-center gap-2 disabled:opacity-40 disabled:cursor-not-allowed bg-gradient-to-r from-primary to-blue-600 hover:from-primary/90 hover:to-blue-600/90 text-white font-medium shadow-md hover:shadow-lg transition-all transform hover:scale-105 disabled:transform-none min-w-[100px] max-w-[150px]"
|
|
|
|
|
title="发送"
|
|
|
|
|
>
|
|
|
|
|
<i v-if="isLoading" class="fa fa-spinner fa-spin text-sm flex-shrink-0"></i>
|
|
|
|
|
<i v-else class="fa fa-paper-plane text-sm flex-shrink-0"></i>
|
|
|
|
|
<span class="text-sm truncate">发送</span>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 历史对话侧边栏 - 覆盖层 -->
|
|
|
|
|
<HistorySidebar
|
|
|
|
|
:is-open="isHistoryOpen"
|
|
|
|
|
:conversations="conversations"
|
|
|
|
|
:current-conversation-id="currentConversationId"
|
|
|
|
|
@close="toggleHistory"
|
|
|
|
|
@switch-conversation="handleSwitchConversation"
|
|
|
|
|
@new-conversation="handleNewConversation"
|
|
|
|
|
@delete-conversation="handleDeleteConversation"
|
|
|
|
|
/>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
|
|
|
|
import type { Conversation, MessageRole, QueryResultData } from '../types'
|
|
|
|
|
import ChatMessage from '../components/feature/chat/ChatMessage.vue'
|
|
|
|
|
import Dropdown from '../components/ui/Dropdown.vue'
|
|
|
|
|
import HistorySidebar from '../components/layout/sidebars/QueryHistorySidebar.vue'
|
|
|
|
|
import QueryRecommendSidebar from '../components/layout/sidebars/QueryRecommendSidebar.vue'
|
|
|
|
|
import { queryApi, llmConfigApi, dbConnectionApi, queryShareApi, queryLogApi } from '../services/api.real'
|
|
|
|
|
import type { QueryResponse } from '../services/api.real'
|
|
|
|
|
import { COMMON_RECOMMENDATIONS, MOCK_FAILURE_SUGGESTIONS, MOCK_SUCCESS_SUGGESTIONS } from '../constants'
|
|
|
|
|
import { saveQuery, shareQuery, isQuerySaved, isQuerySavedByContent } from '../services/queryShareService'
|
|
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
// 外部传入的初始提示(用于从历史页面重新执行查询)
|
|
|
|
|
initialPrompt?: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface Emits {
|
|
|
|
|
(e: 'update:title', title: string): void
|
|
|
|
|
(e: 'save-query', query: QueryResultData): void
|
|
|
|
|
(e: 'share-query', queryId: string, friendId: string): void
|
|
|
|
|
(e: 'toggle-history'): void
|
|
|
|
|
(e: 'new-conversation'): void
|
|
|
|
|
(e: 'rerun-query', prompt: string): void
|
|
|
|
|
(e: 'view-in-chat', conversationId: string): void
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const props = defineProps<Props>()
|
|
|
|
|
const emit = defineEmits<Emits>()
|
|
|
|
|
|
|
|
|
|
// ===== 对话状态管理(从 App.vue 移入) =====
|
|
|
|
|
const initialConversation: Conversation = {
|
|
|
|
|
id: 'conv-initial',
|
|
|
|
|
title: '',
|
|
|
|
|
messages: [
|
|
|
|
|
{
|
|
|
|
|
role: 'ai',
|
|
|
|
|
content:
|
|
|
|
|
'您好!我是数据查询助手,您可以通过自然语言描述您的查询需求(例如:"展示2023年各季度的订单量"),我会为您生成相应的结果。',
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
createTime: new Date().toISOString(),
|
|
|
|
|
}
|
|
|
|
|
const conversations = ref<Conversation[]>([initialConversation])
|
|
|
|
|
const currentConversationId = ref<string>(initialConversation.id)
|
|
|
|
|
const isHistoryOpen = ref(false)
|
|
|
|
|
const savedQueries = ref<QueryResultData[]>([])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 计算属性
|
|
|
|
|
const currentConversation = computed(() => {
|
|
|
|
|
return conversations.value.find((c) => c.id === currentConversationId.value)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 监听对话标题变化,通知父组件更新 TopHeader
|
|
|
|
|
watch(
|
|
|
|
|
() => currentConversation.value?.title,
|
|
|
|
|
(newTitle) => {
|
|
|
|
|
if (newTitle) {
|
|
|
|
|
emit('update:title', newTitle)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// ===== 查询相关状态 =====
|
|
|
|
|
const prompt = ref('')
|
|
|
|
|
const modelOptions = ref<
|
|
|
|
|
Array<{
|
|
|
|
|
id: string
|
|
|
|
|
name: string
|
|
|
|
|
disabled: boolean
|
|
|
|
|
description: string
|
|
|
|
|
}>
|
|
|
|
|
>([])
|
|
|
|
|
const selectedModelId = ref('')
|
|
|
|
|
const databaseOptions = ref<
|
|
|
|
|
Array<{
|
|
|
|
|
id: string
|
|
|
|
|
name: string
|
|
|
|
|
disabled: boolean
|
|
|
|
|
description: string
|
|
|
|
|
}>
|
|
|
|
|
>([])
|
|
|
|
|
const selectedDatabaseId = ref('')
|
|
|
|
|
const selectedDatabase = ref('')
|
|
|
|
|
const isLoading = ref(false)
|
|
|
|
|
const error = ref<string | null>(null)
|
|
|
|
|
const abortController = ref<AbortController | null>(null)
|
|
|
|
|
const pendingConversationId = ref<string | null>(null)
|
|
|
|
|
const chatContainerRef = ref<HTMLElement | null>(null)
|
|
|
|
|
const isMobile = ref(false)
|
|
|
|
|
const showRecommendations = ref(false)
|
|
|
|
|
const allRecommendations = ref<string[]>([])
|
|
|
|
|
|
|
|
|
|
const checkMobile = () => {
|
|
|
|
|
isMobile.value = window.innerWidth < 768 // md breakpoint
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 过滤推荐搜索(基于输入内容)
|
|
|
|
|
const filteredRecommendations = computed(() => {
|
|
|
|
|
if (!prompt.value.trim()) {
|
|
|
|
|
return allRecommendations.value.slice(0, 5) // 没有输入时显示前5个
|
|
|
|
|
}
|
|
|
|
|
const lowerPrompt = prompt.value.toLowerCase()
|
|
|
|
|
return allRecommendations.value
|
|
|
|
|
.filter((rec) => rec.toLowerCase().includes(lowerPrompt))
|
|
|
|
|
.slice(0, 5)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 处理输入变化
|
|
|
|
|
const handleInputChange = () => {
|
|
|
|
|
if (prompt.value.trim()) {
|
|
|
|
|
showRecommendations.value = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ===== 生命周期钩子 =====
|
|
|
|
|
/**
|
|
|
|
|
* 组件挂载时的初始化逻辑
|
|
|
|
|
*
|
|
|
|
|
* 统一加载流程:
|
|
|
|
|
* 1. 初始化移动端检测
|
|
|
|
|
* 2. 加载大模型配置(异步)
|
|
|
|
|
* 3. 加载数据库连接(异步)
|
|
|
|
|
* 4. 初始化推荐列表(同步)
|
|
|
|
|
* 5. 初始化标题(同步)
|
|
|
|
|
* 6. 绑定事件监听器
|
|
|
|
|
*/
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
// 1. 初始化移动端检测
|
|
|
|
|
checkMobile()
|
|
|
|
|
window.addEventListener('resize', checkMobile)
|
|
|
|
|
|
|
|
|
|
// 2. 加载配置数据(异步,不阻塞渲染)
|
|
|
|
|
loadAvailableModels()
|
|
|
|
|
loadDatabaseConnections()
|
|
|
|
|
|
|
|
|
|
// 3. 初始化推荐列表(同步)
|
|
|
|
|
allRecommendations.value = COMMON_RECOMMENDATIONS
|
|
|
|
|
|
|
|
|
|
// 4. 初始化标题(同步)
|
|
|
|
|
emit('update:title', currentConversation.value?.title || '新对话')
|
|
|
|
|
|
|
|
|
|
// 5. 绑定事件监听器
|
|
|
|
|
document.addEventListener('click', handleClickOutside)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 组件卸载时的清理逻辑
|
|
|
|
|
*
|
|
|
|
|
* 统一清理流程:
|
|
|
|
|
* 1. 移除窗口大小监听器
|
|
|
|
|
* 2. 移除文档点击监听器
|
|
|
|
|
*/
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
window.removeEventListener('resize', checkMobile)
|
|
|
|
|
document.removeEventListener('click', handleClickOutside)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 点击外部关闭推荐列表
|
|
|
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
|
|
|
const target = event.target as HTMLElement
|
|
|
|
|
const inputArea = target.closest('form')
|
|
|
|
|
const recommendationPopup = target.closest('.absolute')
|
|
|
|
|
|
|
|
|
|
// 如果点击的不是输入区域和推荐弹窗,则关闭推荐列表
|
|
|
|
|
if (!inputArea && !recommendationPopup) {
|
|
|
|
|
showRecommendations.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Watchers
|
|
|
|
|
watch(
|
|
|
|
|
() => currentConversation.value?.messages,
|
|
|
|
|
() => {
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
if (chatContainerRef.value) {
|
|
|
|
|
chatContainerRef.value.scrollTop = chatContainerRef.value.scrollHeight
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
{ deep: true },
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
() => props.initialPrompt,
|
|
|
|
|
(newPrompt) => {
|
|
|
|
|
if (newPrompt) {
|
|
|
|
|
prompt.value = newPrompt
|
|
|
|
|
handleSubmit(undefined, newPrompt)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
() => currentConversationId.value,
|
|
|
|
|
() => {
|
|
|
|
|
if (
|
|
|
|
|
isLoading.value &&
|
|
|
|
|
pendingConversationId.value &&
|
|
|
|
|
pendingConversationId.value !== currentConversationId.value
|
|
|
|
|
) {
|
|
|
|
|
handleStop()
|
|
|
|
|
}
|
|
|
|
|
// 切换对话时更新标题
|
|
|
|
|
emit('update:title', currentConversation.value?.title || '新对话')
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// ===== 数据加载方法 =====
|
|
|
|
|
/**
|
|
|
|
|
* 加载大模型配置
|
|
|
|
|
*
|
|
|
|
|
* 统一加载逻辑:
|
|
|
|
|
* 1. 调用 API 获取配置列表
|
|
|
|
|
* 2. 转换为下拉选项格式
|
|
|
|
|
* 3. 设置默认选中项(第一个)
|
|
|
|
|
* 4. 错误处理:记录错误并设置空数组
|
|
|
|
|
*/
|
|
|
|
|
const loadAvailableModels = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const configs = await llmConfigApi.getAvailable()
|
|
|
|
|
const options = configs.map((config) => ({
|
|
|
|
|
id: String(config.id),
|
|
|
|
|
name: `${config.name} (${config.version})`,
|
|
|
|
|
disabled: false,
|
|
|
|
|
description: `${config.name} - ${config.version}`,
|
|
|
|
|
}))
|
|
|
|
|
modelOptions.value = options
|
|
|
|
|
if (options.length > 0) {
|
|
|
|
|
selectedModelId.value = options[0].id
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('加载大模型配置失败:', err)
|
|
|
|
|
modelOptions.value = []
|
|
|
|
|
error.value = '无法加载大模型配置,请联系管理员'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 加载数据库连接配置
|
|
|
|
|
*
|
|
|
|
|
* 统一加载逻辑:
|
|
|
|
|
* 1. 调用 API 获取连接列表
|
|
|
|
|
* 2. 过滤掉已禁用的连接
|
|
|
|
|
* 3. 转换为下拉选项格式
|
|
|
|
|
* 4. 设置默认选中项(第一个)
|
|
|
|
|
* 5. 错误处理:记录错误并设置空数组
|
|
|
|
|
*/
|
|
|
|
|
const loadDatabaseConnections = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const connections = await dbConnectionApi.getList()
|
|
|
|
|
const activeConnections = connections.filter((conn) => conn.status !== 'disabled')
|
|
|
|
|
const options = activeConnections.map((conn) => ({
|
|
|
|
|
id: String(conn.id),
|
|
|
|
|
name: conn.name,
|
|
|
|
|
disabled: false,
|
|
|
|
|
description: `${conn.name} - ${conn.url}`,
|
|
|
|
|
}))
|
|
|
|
|
databaseOptions.value = options
|
|
|
|
|
if (options.length > 0) {
|
|
|
|
|
selectedDatabaseId.value = options[0].id
|
|
|
|
|
selectedDatabase.value = options[0].name
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('加载数据库连接失败:', err)
|
|
|
|
|
databaseOptions.value = []
|
|
|
|
|
error.value = '无法加载数据库连接,请联系管理员'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleSubmit = async (event?: Event, customPrompt?: string) => {
|
|
|
|
|
if (event) event.preventDefault()
|
|
|
|
|
|
|
|
|
|
const finalPrompt = customPrompt || prompt.value
|
|
|
|
|
if (!finalPrompt.trim() || isLoading.value) return
|
|
|
|
|
|
|
|
|
|
const requestConversationId = currentConversationId.value
|
|
|
|
|
pendingConversationId.value = requestConversationId
|
|
|
|
|
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
abortController.value = controller
|
|
|
|
|
|
|
|
|
|
handleAddMessage('user', finalPrompt)
|
|
|
|
|
prompt.value = ''
|
|
|
|
|
isLoading.value = true
|
|
|
|
|
error.value = null
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
if (!currentConversation.value) throw new Error('No active conversation.')
|
|
|
|
|
|
|
|
|
|
const response: QueryResponse = await queryApi.execute({
|
|
|
|
|
userPrompt: finalPrompt,
|
|
|
|
|
model: selectedModelId.value,
|
|
|
|
|
database: selectedDatabase.value,
|
|
|
|
|
dbConnectionId: Number(selectedDatabaseId.value),
|
|
|
|
|
conversationId:
|
|
|
|
|
currentConversation.value.id !== 'conv-initial' ? currentConversation.value.id : undefined,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const result: QueryResultData = {
|
|
|
|
|
id: response.id,
|
|
|
|
|
userPrompt: response.userPrompt,
|
|
|
|
|
sqlQuery: response.sqlQuery,
|
|
|
|
|
conversationId: response.conversationId,
|
|
|
|
|
queryTime: response.queryTime,
|
|
|
|
|
executionTime: response.executionTime,
|
|
|
|
|
database: response.database,
|
|
|
|
|
model: response.model,
|
|
|
|
|
tableData: response.tableData,
|
|
|
|
|
chartData: response.chartData
|
|
|
|
|
? {
|
|
|
|
|
type: (response.chartData.type || 'bar') as 'bar' | 'line' | 'pie',
|
|
|
|
|
labels: response.chartData.labels || [],
|
|
|
|
|
datasets: (response.chartData.datasets || []).map((dataset) => ({
|
|
|
|
|
label: dataset.label,
|
|
|
|
|
data: dataset.data,
|
|
|
|
|
backgroundColor: dataset.backgroundColor || '#3b82f6',
|
|
|
|
|
})),
|
|
|
|
|
}
|
|
|
|
|
: undefined,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (currentConversationId.value === requestConversationId) {
|
|
|
|
|
handleAddMessage('ai', result)
|
|
|
|
|
// 注意:后端execute接口返回的id是临时ID("query_xxxx"),不是真实的queryLogId
|
|
|
|
|
// 需要主动调用queryLogApi.create()保存查询,获取真实的queryLogId
|
|
|
|
|
try {
|
|
|
|
|
const userId = Number(sessionStorage.getItem('userId') || '1')
|
|
|
|
|
// 保存查询到数据库,获取真实的queryLogId
|
|
|
|
|
const savedLog = await queryLogApi.create({
|
|
|
|
|
userId,
|
|
|
|
|
userPrompt: result.userPrompt,
|
|
|
|
|
sqlQuery: result.sqlQuery,
|
|
|
|
|
queryResult: JSON.stringify({
|
|
|
|
|
tableData: result.tableData,
|
|
|
|
|
chartData: result.chartData,
|
|
|
|
|
}),
|
|
|
|
|
dialogId: result.conversationId || '',
|
|
|
|
|
dbConnectionId: Number(result.database) || 0,
|
|
|
|
|
llmConfigId: Number(result.model) || 0,
|
|
|
|
|
queryTime: result.queryTime || new Date().toISOString(),
|
|
|
|
|
executionTime: result.executionTime || '0ms',
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 使用真实的queryLogId更新result和savedQueries
|
|
|
|
|
const realQueryLogId = String(savedLog.id)
|
|
|
|
|
result.id = realQueryLogId
|
|
|
|
|
|
|
|
|
|
// 更新消息中的ID(如果可能)
|
|
|
|
|
const currentConv = currentConversation.value
|
|
|
|
|
if (currentConv) {
|
|
|
|
|
const lastMessage = currentConv.messages[currentConv.messages.length - 1]
|
|
|
|
|
if (lastMessage && lastMessage.role === 'ai' && typeof lastMessage.content !== 'string') {
|
|
|
|
|
lastMessage.content.id = realQueryLogId
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 添加到savedQueries
|
|
|
|
|
const savedQuery: QueryResultData = {
|
|
|
|
|
...result,
|
|
|
|
|
id: realQueryLogId,
|
|
|
|
|
}
|
|
|
|
|
// 检查是否已存在,避免重复添加
|
|
|
|
|
if (!savedQueries.value.some((q) => q.id === realQueryLogId)) {
|
|
|
|
|
savedQueries.value = [savedQuery, ...savedQueries.value]
|
|
|
|
|
}
|
|
|
|
|
console.log('✅ 查询结果已保存到历史记录,真实queryLogId:', realQueryLogId)
|
|
|
|
|
} catch (saveError) {
|
|
|
|
|
console.error('保存查询失败:', saveError)
|
|
|
|
|
// 保存失败不影响查询结果显示,但会在分享时提示需要先保存
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
console.log(
|
|
|
|
|
`AI回复已丢弃(目标对话已切换):原对话ID=${requestConversationId},新对话ID=${currentConversationId.value}`,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
if (err instanceof Error && err.name === 'AbortError') {
|
|
|
|
|
if (currentConversationId.value === requestConversationId) {
|
|
|
|
|
handleAddMessage('ai', '查询已被手动停止')
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
const errorMessage = err instanceof Error ? err.message : '查询失败,请稍后重试'
|
|
|
|
|
if (currentConversationId.value === requestConversationId) {
|
|
|
|
|
error.value = errorMessage
|
|
|
|
|
handleAddMessage('ai', errorMessage)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
if (currentConversationId.value === requestConversationId) {
|
|
|
|
|
isLoading.value = false
|
|
|
|
|
abortController.value = null
|
|
|
|
|
pendingConversationId.value = null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleStop = () => {
|
|
|
|
|
if (abortController.value) {
|
|
|
|
|
abortController.value.abort()
|
|
|
|
|
isLoading.value = false
|
|
|
|
|
abortController.value = null
|
|
|
|
|
pendingConversationId.value = null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleRecommendationClick = (recommendation: string) => {
|
|
|
|
|
prompt.value = recommendation
|
|
|
|
|
showRecommendations.value = false
|
|
|
|
|
handleSubmit(undefined, recommendation)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 打开常用搜索(保留以兼容,实际由QueryRecommendSidebar内部处理)
|
|
|
|
|
const openCommonRecommendations = () => {
|
|
|
|
|
// 功能已移至 QueryRecommendSidebar 组件内部
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 打开AI建议(保留以兼容,实际由QueryRecommendSidebar内部处理)
|
|
|
|
|
const openAISuggestions = () => {
|
|
|
|
|
// 功能已移至 QueryRecommendSidebar 组件内部
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 计算相关搜索(用于大模型思考弹出层)
|
|
|
|
|
const queryFailed = computed(() => {
|
|
|
|
|
const lastMessage = currentConversation.value?.messages[currentConversation.value.messages.length - 1]
|
|
|
|
|
if (!lastMessage || lastMessage.role !== 'ai') return false
|
|
|
|
|
return typeof lastMessage.content === 'string'
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const relatedSearches = computed(() => {
|
|
|
|
|
if (!queryFailed.value && currentConversation.value && currentConversation.value.messages.length > 1) {
|
|
|
|
|
const lastMessage = currentConversation.value.messages[currentConversation.value.messages.length - 1]
|
|
|
|
|
if (lastMessage.role === 'ai') {
|
|
|
|
|
return MOCK_SUCCESS_SUGGESTIONS
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return queryFailed.value ? MOCK_FAILURE_SUGGESTIONS : []
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const handleEnterKey = (event: KeyboardEvent) => {
|
|
|
|
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
handleSubmit()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== 对话管理方法 =====
|
|
|
|
|
const handleAddMessage = (role: MessageRole, content: string | QueryResultData) => {
|
|
|
|
|
if (!currentConversationId.value) return
|
|
|
|
|
|
|
|
|
|
conversations.value = conversations.value.map((conv) => {
|
|
|
|
|
if (conv.id === currentConversationId.value) {
|
|
|
|
|
const newMessages = [...conv.messages, { role, content }]
|
|
|
|
|
let newTitle = conv.title
|
|
|
|
|
|
|
|
|
|
// 首次用户消息,截取前20字作为对话标题
|
|
|
|
|
if (conv.messages.length === 1 && role === 'user' && typeof content === 'string') {
|
|
|
|
|
newTitle = content.substring(0, 20)
|
|
|
|
|
emit('update:title', newTitle)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { ...conv, title: newTitle, messages: newMessages }
|
|
|
|
|
}
|
|
|
|
|
return conv
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleNewConversation = () => {
|
|
|
|
|
const isEmptyConv = (conv: Conversation) => {
|
|
|
|
|
return (
|
|
|
|
|
conv.messages.length === 1 &&
|
|
|
|
|
conv.messages[0].role === 'ai' &&
|
|
|
|
|
typeof conv.messages[0].content === 'string' &&
|
|
|
|
|
conv.messages[0].content.includes('您好!我是数据查询助手')
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const existingEmptyConv = conversations.value.find(isEmptyConv)
|
|
|
|
|
|
|
|
|
|
if (existingEmptyConv) {
|
|
|
|
|
currentConversationId.value = existingEmptyConv.id
|
|
|
|
|
} else {
|
|
|
|
|
const newConv: Conversation = {
|
|
|
|
|
id: 'conv-' + Date.now(),
|
|
|
|
|
title: '新对话',
|
|
|
|
|
messages: [
|
|
|
|
|
{
|
|
|
|
|
role: 'ai',
|
|
|
|
|
content:
|
|
|
|
|
'您好!我是数据查询助手,您可以通过自然语言描述您的查询需求(例如:"展示2023年各季度的订单量"),我会为您生成相应的结果。',
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
createTime: new Date().toISOString(),
|
|
|
|
|
}
|
|
|
|
|
conversations.value = [newConv, ...conversations.value]
|
|
|
|
|
currentConversationId.value = newConv.id
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 关闭历史侧边栏(如果打开的话)
|
|
|
|
|
if (isHistoryOpen.value) {
|
|
|
|
|
isHistoryOpen.value = false
|
|
|
|
|
}
|
|
|
|
|
emit('update:title', '新对话')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleSwitchConversation = (id: string) => {
|
|
|
|
|
currentConversationId.value = id
|
|
|
|
|
// 注意:侧边栏会在切换后通过 @close 事件自动关闭
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleDeleteConversation = (deleteId: string) => {
|
|
|
|
|
const updatedConversations = conversations.value.filter((conv) => conv.id !== deleteId)
|
|
|
|
|
|
|
|
|
|
if (currentConversationId.value === deleteId) {
|
|
|
|
|
if (updatedConversations.length > 0) {
|
|
|
|
|
currentConversationId.value = updatedConversations[0].id
|
|
|
|
|
} else {
|
|
|
|
|
const newConv: Conversation = {
|
|
|
|
|
id: 'conv-' + Date.now(),
|
|
|
|
|
title: '新对话',
|
|
|
|
|
messages: [
|
|
|
|
|
{
|
|
|
|
|
role: 'ai',
|
|
|
|
|
content: '您好!我是数据查询助手,您可以通过自然语言描述您的查询需求...',
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
createTime: new Date().toISOString(),
|
|
|
|
|
}
|
|
|
|
|
updatedConversations.unshift(newConv)
|
|
|
|
|
currentConversationId.value = newConv.id
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
conversations.value = updatedConversations
|
|
|
|
|
emit('update:title', currentConversation.value?.title || '新对话')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const toggleHistory = () => {
|
|
|
|
|
isHistoryOpen.value = !isHistoryOpen.value
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ===== 查询保存和分享(使用统一服务) =====
|
|
|
|
|
const handleSaveQuery = async (query: QueryResultData) => {
|
|
|
|
|
// 检查是否已保存(通过内容匹配)
|
|
|
|
|
if (isQuerySavedByContent(query, savedQueries.value)) {
|
|
|
|
|
alert('该查询结果已保存')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// 使用统一服务保存查询
|
|
|
|
|
const savedQuery = await saveQuery(query)
|
|
|
|
|
|
|
|
|
|
// 更新本地列表
|
|
|
|
|
savedQueries.value = [savedQuery, ...savedQueries.value]
|
|
|
|
|
emit('save-query', savedQuery)
|
|
|
|
|
alert('查询结果已保存成功')
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('保存查询失败:', error)
|
|
|
|
|
alert('保存查询失败,请重试')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ⚠️ 注意:后端API不完整,前端跳过检查
|
|
|
|
|
// 后端 QueryShare 实体类缺少 queryLogId 字段,可能导致分享失败
|
|
|
|
|
const handleShareQuery = async (queryId: string, friendId: string) => {
|
|
|
|
|
try {
|
|
|
|
|
// 先从savedQueries中查找
|
|
|
|
|
let queryToShare = savedQueries.value.find((q) => q.id === queryId)
|
|
|
|
|
|
|
|
|
|
// 如果找不到,尝试从当前对话的消息中查找
|
|
|
|
|
if (!queryToShare) {
|
|
|
|
|
const currentConv = currentConversation.value
|
|
|
|
|
if (currentConv) {
|
|
|
|
|
const aiMessage = currentConv.messages.find(
|
|
|
|
|
(msg) => msg.role === 'ai' && typeof msg.content !== 'string' && msg.content.id === queryId
|
|
|
|
|
)
|
|
|
|
|
if (aiMessage && typeof aiMessage.content !== 'string') {
|
|
|
|
|
queryToShare = aiMessage.content
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 使用统一服务分享查询(会自动处理临时ID的情况)
|
|
|
|
|
// ⚠️ 后端可能无法正确处理 queryLogId,但前端继续调用
|
|
|
|
|
const realQueryLogId = await shareQuery(queryId, friendId, queryToShare)
|
|
|
|
|
|
|
|
|
|
// 如果queryId是临时ID,shareQuery内部已经保存了,需要更新本地ID
|
|
|
|
|
const isRealQueryLogId = !isNaN(Number(queryId)) && Number(queryId) > 0
|
|
|
|
|
if (queryToShare && !isRealQueryLogId && realQueryLogId !== queryId) {
|
|
|
|
|
// shareQuery已经保存了查询,使用返回的真实ID更新本地数据
|
|
|
|
|
const savedQuery: QueryResultData = {
|
|
|
|
|
...queryToShare,
|
|
|
|
|
id: realQueryLogId,
|
|
|
|
|
}
|
|
|
|
|
// 更新savedQueries和对话消息中的ID
|
|
|
|
|
const index = savedQueries.value.findIndex((q) => q.id === queryId)
|
|
|
|
|
if (index !== -1) {
|
|
|
|
|
savedQueries.value[index] = savedQuery
|
|
|
|
|
} else {
|
|
|
|
|
// 如果不在savedQueries中,添加进去
|
|
|
|
|
savedQueries.value = [savedQuery, ...savedQueries.value]
|
|
|
|
|
}
|
|
|
|
|
const currentConv = currentConversation.value
|
|
|
|
|
if (currentConv) {
|
|
|
|
|
const aiMessage = currentConv.messages.find(
|
|
|
|
|
(msg) => msg.role === 'ai' && typeof msg.content !== 'string' && msg.content.id === queryId
|
|
|
|
|
)
|
|
|
|
|
if (aiMessage && typeof aiMessage.content !== 'string') {
|
|
|
|
|
aiMessage.content.id = realQueryLogId
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
alert('分享成功!')
|
|
|
|
|
emit('share-query', realQueryLogId, friendId)
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('分享失败:', error)
|
|
|
|
|
alert('分享失败,请重试: ' + (error instanceof Error ? error.message : '未知错误'))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 暴露给父组件的方法(用于从历史页面重新执行查询)
|
|
|
|
|
defineExpose({
|
|
|
|
|
conversations,
|
|
|
|
|
savedQueries,
|
|
|
|
|
handleRerunQuery: (prompt: string) => {
|
|
|
|
|
handleNewConversation()
|
|
|
|
|
handleSubmit(undefined, prompt)
|
|
|
|
|
},
|
|
|
|
|
handleViewInChat: (conversationId: string) => {
|
|
|
|
|
const conv = conversations.value.find((c) => c.id === conversationId)
|
|
|
|
|
if (conv) {
|
|
|
|
|
currentConversationId.value = conversationId
|
|
|
|
|
isHistoryOpen.value = true
|
|
|
|
|
emit('update:title', conv.title || '新对话')
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
toggleHistory: () => {
|
|
|
|
|
toggleHistory()
|
|
|
|
|
},
|
|
|
|
|
handleNewConversation: () => {
|
|
|
|
|
handleNewConversation()
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
/* 确保 select 文本能够正确截断(选择框本身) */
|
|
|
|
|
select {
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
padding-right: 3rem; /* 增加右边留白,为下拉箭头留出空间 */
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* 确保 select 选项文本完整显示(下拉时,不截断) */
|
|
|
|
|
select option {
|
|
|
|
|
white-space: normal !important;
|
|
|
|
|
text-overflow: initial !important;
|
|
|
|
|
overflow: visible !important;
|
|
|
|
|
padding-right: 2rem; /* 增加右边留白 */
|
|
|
|
|
max-width: none !important;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* 美化滚动条 */
|
|
|
|
|
.overflow-y-auto::-webkit-scrollbar {
|
|
|
|
|
width: 6px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.overflow-y-auto::-webkit-scrollbar-track {
|
|
|
|
|
background: transparent;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.overflow-y-auto::-webkit-scrollbar-thumb {
|
|
|
|
|
background: rgba(156, 163, 175, 0.5);
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
|
|
|
|
background: rgba(156, 163, 175, 0.7);
|
|
|
|
|
}
|
|
|
|
|
</style>
|