ForcePilot/web/src/views/DataBaseInfoView.vue
2026-06-03 23:33:01 +08:00

1342 lines
35 KiB
Vue

<template>
<div class="database-info-container extension-detail-page">
<FileDetailModal />
<FileUploadModal
v-model:visible="addFilesModalVisible"
:folder-tree="folderTree"
:current-folder-id="currentFolderId"
:is-folder-mode="isFolderUploadMode"
:mode="addFilesMode"
@success="onFileUploadSuccess"
/>
<div class="detail-top-bar">
<button class="detail-back-btn" type="button" @click="backToDatabase">
<ArrowLeft :size="16" />
<span>返回</span>
</button>
<div class="detail-title-area">
<div class="detail-icon">
<component :is="kbTypeIcon" :size="18" />
</div>
<div class="detail-title-text">
<h2>{{ database.name || '知识库加载中' }}</h2>
<span class="detail-subtitle">{{ databaseSubtitle }}</span>
</div>
</div>
<div class="detail-actions">
<a-space :size="8">
<button
type="button"
class="lucide-icon-btn extension-panel-action extension-panel-action-secondary"
@click="copyDatabaseId"
>
<Copy :size="14" />
<span>复制 ID</span>
</button>
<button
type="button"
class="lucide-icon-btn extension-panel-action extension-panel-action-primary"
@click="showEditModal"
>
<Pencil :size="14" />
<span>编辑</span>
</button>
</a-space>
</div>
</div>
<div class="database-detail-body">
<div class="database-tab-bar" aria-label="知识库功能导航">
<nav class="database-tab-list" aria-label="知识库功能标签" role="tablist">
<button
v-for="tab in visibleTabs"
:key="tab.key"
type="button"
class="database-tab-item"
:class="{ active: activeTab === tab.key }"
role="tab"
:aria-selected="activeTab === tab.key"
@click="activeTab = tab.key"
>
<component :is="tab.icon" :size="17" />
<span>{{ tab.label }}</span>
</button>
</nav>
</div>
<main class="database-tab-content">
<div v-if="isMilvus" v-show="activeTab === 'filetable'" class="tab-panel file-panel">
<div class="file-management-info">
<div class="file-info-title">
<div class="file-info-title-row">
<button
type="button"
class="lucide-icon-btn extension-panel-action extension-panel-action-primary"
@click="showAddFilesModal()"
>
<FileUp :size="14" />
<span>上传</span>
</button>
<button
type="button"
class="lucide-icon-btn extension-panel-action extension-panel-action-secondary"
@click="showCreateFolderModal"
>
<FolderPlus :size="14" />
<span>新建文件夹</span>
</button>
</div>
</div>
<div class="file-panel-status">
<div
v-if="pendingParseCount > 0"
class="file-stat-card file-stat-action"
@click="confirmBatchParse"
>
<FileText :size="20" />
<div>
<strong>{{ pendingParseCount }}</strong>
<span>待解析</span>
</div>
</div>
<div
v-if="pendingIndexCount > 0"
class="file-stat-card file-stat-action"
@click="confirmBatchIndex"
>
<DatabaseIcon :size="20" />
<div>
<strong>{{ pendingIndexCount }}</strong>
<span>待入库</span>
</div>
</div>
<div class="file-stat-card">
<FileText :size="20" />
<div>
<strong>{{ fileStats.count }}</strong>
<span>文件总数</span>
</div>
</div>
<div v-if="fileStats.sizeText" class="file-stat-card">
<DatabaseIcon :size="20" />
<div>
<strong>{{ fileStats.sizeText }}</strong>
<span>总大小</span>
</div>
</div>
<div class="file-stat-card">
<CheckCircle2 :size="20" />
<div>
<strong>{{ fileStats.processedCount }}</strong>
<span>已处理</span>
</div>
</div>
<div class="file-stat-card">
<Activity :size="20" />
<div>
<strong>{{ fileStats.completionRate }}%</strong>
<span>完成进度</span>
</div>
</div>
</div>
</div>
<FileTable ref="fileTableRef" />
</div>
<div v-show="activeTab === 'query'" class="tab-panel query-config-panel">
<div class="query-config-layout">
<div class="query-test-pane">
<QuerySection ref="querySectionRef" :visible="true" @toggle-visible="() => {}" />
</div>
<aside class="query-config-pane" aria-label="检索配置">
<div class="search-config-wrapper">
<div class="search-config-header">
<div>
<h3>检索配置</h3>
<p>调整当前知识库的检索参数。</p>
</div>
<button
type="button"
class="lucide-icon-btn extension-panel-action extension-panel-action-primary"
:disabled="searchConfigSaving"
@click="handleInlineSearchConfigSave"
>
<Save :size="14" />
<span>保存</span>
</button>
</div>
<div class="search-config-body">
<SearchConfigPanel
ref="searchConfigPanelRef"
:kb-id="kbId"
@save="handleSearchConfigSave"
/>
</div>
</div>
</aside>
</div>
</div>
<div v-if="isMilvus && activeTab === 'graph'" class="tab-panel">
<KnowledgeGraphSection
:visible="true"
:active="activeTab === 'graph'"
@toggle-visible="() => {}"
/>
</div>
<div v-if="isMilvus && activeTab === 'mindmap'" class="tab-panel">
<MindMapSection v-if="kbId" :kb-id="kbId" ref="mindmapSectionRef" />
</div>
<div v-if="isMilvus && activeTab === 'evaluation'" class="tab-panel">
<RAGEvaluationTab
v-if="kbId"
:kb-id="kbId"
@switch-to-benchmarks="activeTab = 'benchmarks'"
/>
</div>
<div v-if="isMilvus && activeTab === 'benchmarks'" class="tab-panel">
<div class="benchmark-management-container">
<div class="benchmark-content">
<EvaluationBenchmarks
v-if="kbId && isEvaluationSupported"
:kb-id="kbId"
@benchmark-selected="activeTab = 'evaluation'"
/>
</div>
</div>
</div>
</main>
</div>
<a-modal v-model:open="editModalVisible" title="编辑知识库信息" width="700px">
<template #footer>
<a-button danger @click="deleteDatabase" style="margin-right: auto; margin-left: 0">
<template #icon>
<Trash2 :size="16" style="vertical-align: -3px; margin-right: 4px" />
</template>
删除数据库
</a-button>
<a-button key="back" @click="editModalVisible = false">取消</a-button>
<a-button key="submit" type="primary" @click="handleEditSubmit">确定</a-button>
</template>
<a-form :model="editForm" :rules="rules" ref="editFormRef" layout="vertical">
<a-form-item label="知识库名称" name="name" required>
<a-input v-model:value="editForm.name" placeholder="请输入知识库名称" />
</a-form-item>
<a-form-item label="知识库描述" name="description">
<AiTextarea
v-model="editForm.description"
:name="editForm.name"
:files="fileList"
placeholder="请输入知识库描述"
:rows="4"
/>
</a-form-item>
<a-form-item v-if="!isConnector" label="自动生成问题" name="auto_generate_questions">
<a-switch
v-model:checked="editForm.auto_generate_questions"
checked-children="开启"
un-checked-children="关闭"
/>
<span style="margin-left: 8px; font-size: 12px; color: var(--gray-500)">
上传文件后自动生成测试问题
</span>
</a-form-item>
<a-form-item v-if="!isConnector" name="chunk_preset_id">
<template #label>
<span class="chunk-preset-label">
分块策略
<a-tooltip :title="editPresetDescription">
<QuestionCircleOutlined class="chunk-preset-help-icon" />
</a-tooltip>
</span>
</template>
<a-select v-model:value="editForm.chunk_preset_id" :options="chunkPresetOptions" />
</a-form-item>
<template v-if="isDifyKb">
<a-form-item label="Dify API URL" name="dify_api_url">
<a-input
v-model:value="editForm.dify_api_url"
placeholder="例如: https://api.dify.ai/v1"
/>
</a-form-item>
<a-form-item label="Dify Token" name="dify_token">
<a-input-password
v-model:value="editForm.dify_token"
placeholder="请输入 Dify API Token"
/>
</a-form-item>
<a-form-item label="Dataset ID" name="dify_dataset_id">
<a-input
v-model:value="editForm.dify_dataset_id"
placeholder="请输入 Dify dataset_id"
/>
</a-form-item>
</template>
<template v-if="isNotionKb">
<a-form-item label="Notion Token" name="notion_token">
<a-input-password
v-model:value="editForm.notion_token"
placeholder="留空则保持现有 Token 或使用环境变量"
/>
</a-form-item>
<a-form-item label="Data Source ID" name="notion_data_source_id">
<a-input
v-model:value="editForm.notion_data_source_id"
placeholder="请输入 Notion data_source_id"
/>
</a-form-item>
<a-form-item label="Notion API Version" name="notion_version">
<a-input v-model:value="editForm.notion_version" placeholder="2026-03-11" />
</a-form-item>
</template>
<a-form-item v-if="canEditShareConfig" label="共享设置" name="share_config">
<a-form-item-rest>
<ShareConfigForm
ref="shareConfigFormRef"
:model-value="database.share_config"
:auto-select-user-dept="true"
/>
</a-form-item-rest>
</a-form-item>
<a-form-item
v-else-if="database.share_config"
label="共享设置"
name="share_config_readonly"
>
<div class="share-config-readonly">
<a-tag :color="shareConfigDisplay.color">
{{ shareConfigDisplay.label }}
</a-tag>
<span class="access-names">{{ shareConfigDisplay.detail }}</span>
</div>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useDatabaseStore } from '@/stores/database'
import { useTaskerStore } from '@/stores/tasker'
import { useUserStore } from '@/stores/user'
import {
Activity,
ArrowLeft,
BarChart3,
CheckCircle2,
ClipboardList,
Copy,
Database as DatabaseIcon,
FileUp,
FileText,
FolderPlus,
Map as MapIcon,
Network,
Pencil,
Save,
Search,
Trash2
} from 'lucide-vue-next'
import { QuestionCircleOutlined } from '@ant-design/icons-vue'
import { message, Modal } from 'ant-design-vue'
import FileTable from '@/components/FileTable.vue'
import FileDetailModal from '@/components/FileDetailModal.vue'
import FileUploadModal from '@/components/FileUploadModal.vue'
import KnowledgeGraphSection from '@/components/KnowledgeGraphSection.vue'
import QuerySection from '@/components/QuerySection.vue'
import MindMapSection from '@/components/MindMapSection.vue'
import RAGEvaluationTab from '@/components/RAGEvaluationTab.vue'
import EvaluationBenchmarks from '@/components/EvaluationBenchmarks.vue'
import SearchConfigPanel from '@/components/SearchConfigPanel.vue'
import AiTextarea from '@/components/AiTextarea.vue'
import ShareConfigForm from '@/components/ShareConfigForm.vue'
import { departmentApi } from '@/apis/department_api'
import { authApi } from '@/apis/auth_api'
import { CHUNK_PRESET_OPTIONS, getChunkPresetDescription } from '@/utils/chunk_presets'
import { formatFileSize } from '@/utils/file_utils'
import { getKbTypeIcon, getKbTypeLabel, kbUtils } from '@/utils/kb_utils'
const route = useRoute()
const router = useRouter()
const store = useDatabaseStore()
const taskerStore = useTaskerStore()
const userStore = useUserStore()
const kbId = computed(() => store.kbId)
const database = computed(() => store.database)
const isCurrentDatabaseLoaded = computed(() => database.value?.kb_id === kbId.value)
const kbType = computed(() =>
isCurrentDatabaseLoaded.value ? database.value.kb_type?.toLowerCase() || 'milvus' : ''
)
const isMilvus = computed(() => kbType.value === 'milvus')
const isDifyKb = computed(() => kbType.value === 'dify')
const isNotionKb = computed(() => kbType.value === 'notion')
const isConnector = computed(
() => isCurrentDatabaseLoaded.value && kbUtils.isReadOnlyDatabase(database.value)
)
const isEvaluationSupported = computed(() => isMilvus.value)
const kbTypeIcon = computed(() => getKbTypeIcon(kbType.value || 'milvus'))
const databaseSubtitle = computed(() => {
const typeLabel = getKbTypeLabel(kbType.value || 'milvus')
if (!isCurrentDatabaseLoaded.value) return '正在加载知识库信息'
const description = database.value.description?.trim()
if (description) return description
if (isConnector.value) return `${typeLabel} 连接器`
return `${typeLabel} 知识库 · ${database.value.row_count || 0} 文件`
})
const tabs = computed(() => {
if (isMilvus.value) {
return [
{ key: 'filetable', label: '文件管理', icon: FileText },
{ key: 'query', label: '检索测试', icon: Search },
{ key: 'graph', label: '知识图谱', icon: Network },
{ key: 'mindmap', label: '知识导图', icon: MapIcon },
{ key: 'evaluation', label: 'RAG 评估', icon: BarChart3 },
{ key: 'benchmarks', label: '评估基准', icon: ClipboardList }
]
}
return [{ key: 'query', label: '检索测试', icon: Search }]
})
const visibleTabs = computed(() => tabs.value)
const activeTab = ref('filetable')
watch(
() => [kbId.value, isMilvus.value],
([newDbId, isMilvusType]) => {
if (!newDbId) return
activeTab.value = isMilvusType ? 'filetable' : 'query'
},
{ immediate: true }
)
watch(visibleTabs, (nextTabs) => {
if (!nextTabs.some((tab) => tab.key === activeTab.value)) {
activeTab.value = nextTabs[0]?.key || 'query'
}
})
const pendingParseCount = computed(() => {
const files = store.database.files || {}
return Object.values(files).filter((f) => !f.is_folder && f.status === 'uploaded').length
})
const fileStats = computed(() => {
const files = Object.values(store.database.files || {}).filter((file) => !file.is_folder)
const totalSize = files.reduce((sum, file) => {
const size = Number(file.file_size ?? file.size ?? 0)
return Number.isFinite(size) ? sum + size : sum
}, 0)
const processedCount = files.filter((file) => ['done', 'indexed'].includes(file.status)).length
return {
count: files.length,
sizeText: totalSize > 0 ? formatFileSize(totalSize) : '',
processedCount,
completionRate: files.length ? Math.round((processedCount / files.length) * 100) : 0
}
})
const pendingIndexCount = computed(() => {
const files = store.database.files || {}
return Object.values(files).filter((f) => {
if (f.is_folder) return false
return f.status === 'parsed' || f.status === 'error_indexing'
}).length
})
const confirmBatchParse = () => {
const fileIds = Object.values(store.database.files || {})
.filter((f) => f.status === 'uploaded')
.map((f) => f.file_id)
if (fileIds.length === 0) return
Modal.confirm({
title: '批量解析',
content: `确定要解析 ${fileIds.length} 个文件吗?`,
onOk: () => store.parseFiles(fileIds)
})
}
const confirmBatchIndex = () => {
const fileIds = Object.values(store.database.files || {})
.filter((f) => {
if (f.is_folder) return false
return f.status === 'parsed' || f.status === 'error_indexing'
})
.map((f) => f.file_id)
if (fileIds.length === 0) return
Modal.confirm({
title: '批量入库',
content: `确定要入库 ${fileIds.length} 个文件吗?`,
onOk: () => store.indexFiles(fileIds)
})
}
const mindmapSectionRef = ref(null)
const querySectionRef = ref(null)
const searchConfigSaving = ref(false)
const searchConfigPanelRef = ref(null)
const handleSearchConfigSave = () => {
store.getDatabaseInfo()
}
const handleInlineSearchConfigSave = async () => {
if (!searchConfigPanelRef.value) return
searchConfigSaving.value = true
try {
await searchConfigPanelRef.value.save()
} finally {
searchConfigSaving.value = false
}
}
const addFilesModalVisible = ref(false)
const currentFolderId = ref(null)
const isFolderUploadMode = ref(false)
const addFilesMode = ref('file')
const isInitialLoad = ref(true)
const fileTableRef = ref(null)
const showAddFilesModal = (options = {}) => {
const { isFolder = false, mode = 'file' } = options
isFolderUploadMode.value = isFolder
addFilesMode.value = mode
addFilesModalVisible.value = true
currentFolderId.value = null
}
const showCreateFolderModal = () => {
fileTableRef.value?.showCreateFolderModal()
}
const folderTree = computed(() => {
const files = store.database.files || {}
const fileList = Object.values(files)
const nodeMap = new Map()
const roots = []
fileList.forEach((file) => {
if (file.is_folder) {
const item = { ...file, title: file.filename, value: file.file_id, children: [] }
nodeMap.set(file.file_id, item)
}
})
fileList.forEach((file) => {
if (file.is_folder && file.parent_id && nodeMap.has(file.parent_id)) {
const parent = nodeMap.get(file.parent_id)
const child = nodeMap.get(file.file_id)
if (parent && child) parent.children.push(child)
} else if (file.is_folder && !file.parent_id && nodeMap.has(file.file_id)) {
roots.push(nodeMap.get(file.file_id))
}
})
return roots
})
const onFileUploadSuccess = () => {
taskerStore.loadTasks()
}
const resetFileSelectionState = () => {
store.selectedRowKeys = []
store.selectedFile = null
store.state.fileDetailModalVisible = false
}
watch(
() => route.params.kbId,
async (nextKbId) => {
isInitialLoad.value = true
store.kbId = nextKbId
resetFileSelectionState()
store.stopAutoRefresh()
await store.getDatabaseInfo(nextKbId, false)
store.startAutoRefresh()
},
{ immediate: true }
)
const previousFileCount = ref(0)
watch(
() => database.value?.files,
(newFiles) => {
if (!newFiles) return
const newFileCount = Object.keys(newFiles).length
const oldFileCount = previousFileCount.value
if (isInitialLoad.value) {
previousFileCount.value = newFileCount
isInitialLoad.value = false
return
}
if (newFileCount !== oldFileCount) {
if (newFileCount > 0) {
setTimeout(async () => {
if (querySectionRef.value) {
if (database.value.additional_params?.auto_generate_questions) {
await querySectionRef.value.generateSampleQuestions(true)
}
} else {
setTimeout(async () => {
if (
querySectionRef.value &&
database.value.additional_params?.auto_generate_questions
) {
await querySectionRef.value.generateSampleQuestions(true)
}
}, 2000)
}
}, 3000)
} else {
setTimeout(() => {
querySectionRef.value?.clearQuestions()
}, 1000)
}
}
previousFileCount.value = newFileCount
},
{ deep: true }
)
const backToDatabase = () => {
router.push({ path: '/extensions', query: { tab: 'knowledge' } })
}
const copyDatabaseId = async () => {
if (!database.value.kb_id) {
message.warning('知识库ID为空')
return
}
try {
await navigator.clipboard.writeText(database.value.kb_id)
message.success('知识库ID已复制到剪贴板')
} catch {
const textArea = document.createElement('textarea')
textArea.value = database.value.kb_id
document.body.appendChild(textArea)
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
message.success('知识库ID已复制到剪贴板')
}
}
const departments = ref([])
const users = ref([])
const editModalVisible = ref(false)
const editFormRef = ref(null)
const shareConfigFormRef = ref(null)
const editForm = reactive({
name: '',
description: '',
auto_generate_questions: false,
chunk_preset_id: 'general',
dify_api_url: '',
dify_token: '',
dify_dataset_id: '',
notion_token: '',
notion_data_source_id: '',
notion_version: '2026-03-11'
})
const rules = {
name: [{ required: true, message: '请输入知识库名称' }]
}
const chunkPresetOptions = CHUNK_PRESET_OPTIONS.map(({ label, value }) => ({ label, value }))
const editPresetDescription = computed(() => getChunkPresetDescription(editForm.chunk_preset_id))
const fileList = computed(() => {
if (!database.value?.files) return []
return Object.values(database.value.files)
.map((f) => f.filename)
.filter(Boolean)
})
const canEditShareConfig = computed(() => userStore.isSuperAdmin || userStore.isAdmin)
const shareConfigDisplay = computed(() => {
const shareConfig = database.value?.share_config || { access_level: 'global' }
if (shareConfig.access_level === 'department') {
const departmentIds = shareConfig.department_ids || []
const names = departmentIds.map((id) => getDepartmentName(id)).join('、') || '无'
return {
color: 'blue',
label: '部门共享',
detail: `${departmentIds.length} 个部门可访问:${names}`
}
}
if (shareConfig.access_level === 'user') {
const userUids = shareConfig.user_uids || []
const names = userUids.map((uid) => getUserName(uid)).join('、') || '无'
return {
color: 'purple',
label: '指定人可访问',
detail: `${userUids.length} 个用户可访问:${names}`
}
}
return {
color: 'green',
label: '全局共享',
detail: '所有用户可访问'
}
})
const getDepartmentName = (id) => {
const dept = departments.value.find((item) => Number(item.id) === Number(id))
return dept?.name || `部门${id}`
}
const getUserName = (uid) => {
const user = users.value.find((item) => item.uid === uid)
return user?.username || uid
}
const loadDepartments = async () => {
try {
const res = await departmentApi.getDepartments()
departments.value = res.departments || res || []
} catch {
departments.value = []
}
}
const loadUsers = async () => {
try {
users.value = await authApi.getUserAccessOptions()
} catch {
users.value = []
}
}
const showEditModal = () => {
editForm.name = database.value.name || ''
editForm.description = database.value.description || ''
editForm.auto_generate_questions =
database.value.additional_params?.auto_generate_questions || false
editForm.chunk_preset_id = database.value.additional_params?.chunk_preset_id || 'general'
editForm.dify_api_url = database.value.additional_params?.dify_api_url || ''
editForm.dify_token = database.value.additional_params?.dify_token || ''
editForm.dify_dataset_id = database.value.additional_params?.dify_dataset_id || ''
editForm.notion_token = ''
editForm.notion_data_source_id = database.value.additional_params?.notion_data_source_id || ''
editForm.notion_version = database.value.additional_params?.notion_version || '2026-03-11'
editModalVisible.value = true
}
const handleEditSubmit = () => {
editFormRef.value
.validate()
.then(async () => {
if (shareConfigFormRef.value) {
const validation = shareConfigFormRef.value.validate()
if (!validation.valid) {
message.warning(validation.message)
return
}
}
const formConfig = shareConfigFormRef.value?.config || { access_level: 'global' }
const updateData = {
name: editForm.name,
description: editForm.description,
additional_params: {},
share_config: {
access_level: formConfig.access_level,
department_ids:
formConfig.access_level === 'department' ? formConfig.department_ids || [] : [],
user_uids: formConfig.access_level === 'user' ? formConfig.user_uids || [] : []
}
}
if (isDifyKb.value) {
if (
!editForm.dify_api_url?.trim() ||
!editForm.dify_token?.trim() ||
!editForm.dify_dataset_id?.trim()
) {
message.error('请完整填写 Dify API URL、Token 和 Dataset ID')
return
}
if (!editForm.dify_api_url.trim().endsWith('/v1')) {
message.error('Dify API URL 必须以 /v1 结尾')
return
}
updateData.additional_params = {
dify_api_url: editForm.dify_api_url.trim(),
dify_token: editForm.dify_token.trim(),
dify_dataset_id: editForm.dify_dataset_id.trim()
}
} else if (isNotionKb.value) {
if (!editForm.notion_data_source_id?.trim()) {
message.error('请填写 Notion Data Source ID')
return
}
updateData.additional_params = {
notion_data_source_id: editForm.notion_data_source_id.trim(),
notion_version: editForm.notion_version?.trim() || '2026-03-11'
}
if (editForm.notion_token?.trim()) {
updateData.additional_params.notion_token = editForm.notion_token.trim()
}
} else {
updateData.additional_params = {
auto_generate_questions: editForm.auto_generate_questions,
chunk_preset_id: editForm.chunk_preset_id || 'general'
}
}
await store.updateDatabaseInfo(updateData)
editModalVisible.value = false
})
.catch((err) => {
console.error('表单验证失败:', err)
})
}
const deleteDatabase = () => {
store.deleteDatabase()
}
onMounted(() => {
loadDepartments()
loadUsers()
})
</script>
<style lang="less" scoped>
@import '@/assets/css/extensions.less';
@import '@/assets/css/extension-detail.less';
.database-info-container {
.detail-content-wrapper {
flex: 1;
min-height: 0;
}
.detail-subtitle {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.database-detail-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--gray-10);
overflow: hidden;
}
.database-tab-bar {
flex-shrink: 0;
border-bottom: 1px solid var(--gray-150);
background: var(--gray-0);
padding: 8px 12px 0;
overflow-x: auto;
overflow-y: hidden;
}
.database-tab-list {
display: flex;
align-items: center;
gap: 6px;
min-width: max-content;
}
.database-tab-item {
position: relative;
min-height: 40px;
border: none;
border-radius: 8px 8px 0 0;
background: transparent;
color: var(--gray-600);
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 0 14px 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition:
background 0.15s,
color 0.15s;
svg {
flex-shrink: 0;
}
&:hover {
color: var(--gray-900);
background: var(--gray-50);
}
&:focus-visible {
outline: 2px solid var(--main-200);
outline-offset: 2px;
}
&.active {
color: var(--main-color);
background: var(--main-20);
&::before {
content: '';
position: absolute;
left: 12px;
right: 12px;
bottom: 0;
height: 3px;
border-radius: 3px 3px 0 0;
background: var(--main-color);
}
}
}
.database-tab-content {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.tab-panel {
flex: 1;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 16px var(--page-padding);
}
.file-panel {
gap: 8px;
}
.query-config-panel {
overflow: hidden;
}
.query-config-layout {
flex: 1;
min-height: 0;
display: flex;
gap: 12px;
}
.query-test-pane {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
}
.query-test-pane :deep(.query-section) {
flex: 1;
min-width: 0;
}
.query-config-pane {
width: 360px;
flex: 0 0 360px;
min-height: 0;
display: flex;
}
.query-config-pane .search-config-wrapper {
width: 100%;
}
.query-config-pane :deep(.ant-row) {
margin-right: 0 !important;
margin-left: 0 !important;
}
.query-config-pane :deep(.ant-col) {
max-width: 100%;
flex: 0 0 100%;
padding-right: 0 !important;
padding-left: 0 !important;
}
.file-panel-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
padding: 10px 12px;
background: var(--gray-0);
border: 1px solid var(--gray-150);
border-radius: 8px;
}
.file-panel-summary {
display: flex;
align-items: baseline;
min-width: 0;
gap: 8px;
}
.file-panel-title {
font-size: 15px;
font-weight: 600;
color: var(--gray-900);
white-space: nowrap;
}
.file-panel-count {
font-size: 12px;
color: var(--gray-500);
white-space: nowrap;
}
.file-management-info {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 20px;
flex-shrink: 0;
}
.file-info-title {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 180px;
}
.file-info-title-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.file-panel-desc {
font-size: 12px;
color: var(--gray-500);
}
.file-panel-status {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.file-stat-card {
min-width: 92px;
display: flex;
align-items: center;
gap: 16px;
padding: 8px 10px;
border-radius: 8px;
background: var(--main-0);
border: 1px solid var(--gray-100);
color: var(--main-color);
font: inherit;
div {
display: flex;
flex-direction: column;
gap: 1px;
}
strong {
font-size: 14px;
line-height: 1.2;
color: var(--gray-900);
}
span {
font-size: 11px;
color: var(--gray-500);
white-space: nowrap;
}
}
.file-stat-action {
cursor: pointer;
color: var(--color-warning-500);
border: 1px solid var(--color-warning-100);
background-color: var(--color-warning-50);
transition:
background 0.15s,
border-color 0.15s;
&:hover {
border-color: var(--color-warning-700);
}
}
.search-config-wrapper {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
border: 1px solid var(--gray-200);
border-radius: 8px;
background: var(--gray-0);
overflow: hidden;
}
.search-config-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
padding: 14px 16px;
border-bottom: 1px solid var(--gray-150);
flex-shrink: 0;
h3 {
margin: 0 0 4px;
font-size: 16px;
font-weight: 600;
color: var(--gray-900);
}
p {
margin: 0;
font-size: 13px;
color: var(--gray-500);
}
}
.search-config-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px;
}
.share-config-readonly {
display: flex;
align-items: center;
gap: 8px;
.access-names {
font-size: 13px;
color: var(--gray-600);
}
}
.chunk-preset-label {
display: inline-flex;
align-items: center;
gap: 6px;
}
.chunk-preset-help-icon {
color: var(--gray-500);
cursor: help;
font-size: 14px;
}
@media (max-width: 1024px) {
.query-config-layout {
flex-direction: column;
overflow-y: auto;
}
.query-test-pane {
min-height: 360px;
}
.query-config-pane {
width: 100%;
flex: 0 0 auto;
min-height: 320px;
}
}
@media (max-width: 767px) {
.detail-top-bar {
gap: 10px;
}
.detail-actions :deep(.extension-panel-action span) {
display: none;
}
.database-tab-bar {
padding: 8px 8px 0;
}
.database-tab-item {
min-width: 104px;
}
.query-config-layout {
flex-direction: column;
}
.query-config-pane {
width: 100%;
flex: 0 0 auto;
min-height: 320px;
}
.file-panel-toolbar {
align-items: flex-start;
flex-direction: column;
}
}
</style>
<style lang="less">
@media (max-width: 767px) {
.app-layout:has(.database-info-container) {
min-width: 0;
}
}
/* 全局样式作为备用方案 */
.ant-popover .query-params-compact {
width: 220px;
}
.ant-popover .query-params-compact .params-loading {
display: flex;
justify-content: center;
align-items: center;
min-height: 80px;
}
.ant-popover .query-params-compact .params-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 10px;
}
.ant-popover .query-params-compact .param-item {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
}
.ant-popover .query-params-compact .param-item label {
font-weight: 500;
color: var(--gray-700);
margin-right: 8px;
}
/* Improve panel transitions */
.panel-section {
display: flex;
flex-direction: column;
border-radius: 4px;
transition: all 0.3s;
min-height: 0;
&.collapsed {
height: 36px;
flex: none;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid var(--gray-150);
background-color: var(--gray-25);
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.section-title {
font-size: 14px;
font-weight: 500;
color: var(--gray-700);
margin: 0;
}
.panel-actions {
display: flex;
gap: 0px;
}
}
.content {
flex: 1;
min-height: 0;
}
}
.query-section,
.graph-section {
.panel-section();
.content {
padding: 8px;
flex: 1;
overflow: hidden;
}
}
.graph-section {
border: 1px solid var(--gray-100);
border-radius: 12px;
}
.benchmark-management-container {
height: 100%;
display: flex;
flex-direction: column;
}
.benchmark-content {
flex: 1;
overflow: hidden;
min-height: 0;
}
</style>