ForcePilot/web/src/views/DataBaseInfoView.vue

2390 lines
59 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="database-info-container">
<HeaderComponent
:title="database.name || '数据库信息'"
:loading="state.databaseLoading"
>
<template #left>
<a-button type="link" @click="backToDatabase" :style="{ padding: '0px', color: 'inherit' }">
<LeftOutlined />
</a-button>
</template>
<template #behind-title>
<a-button type="link" @click="showEditModal" :style="{ padding: '0px', color: 'inherit' }">
<EditOutlined />
</a-button>
</template>
<template #actions>
<div class="header-info">
<span class="db-id">ID: {{ database.db_id || 'N/A' }}</span>
<span class="file-count">{{ database.files ? Object.keys(database.files).length : 0 }} 文件</span>
<a-tag
:color="getKbTypeColor(database.kb_type || 'lightrag')"
class="kb-type-tag"
size="small"
>
<component :is="getKbTypeIcon(database.kb_type || 'lightrag')" class="type-icon" />
{{ getKbTypeLabel(database.kb_type || 'lightrag') }}
</a-tag>
</div>
</template>
</HeaderComponent>
<!-- 添加编辑对话框 -->
<a-modal v-model:open="editModalVisible" title="编辑知识库信息">
<template #footer>
<a-button danger @click="deleteDatabse" style="margin-right: auto; margin-left: 0;">
<DeleteOutlined /> 删除数据库
</a-button>
<a-button key="back" @click="editModalVisible = false">取消</a-button>
<a-button key="submit" type="primary" :loading="state.loading" @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">
<a-textarea v-model:value="editForm.description" placeholder="请输入知识库描述" :rows="4" />
</a-form-item>
</a-form>
</a-modal>
<!-- 分块参数配置弹窗 -->
<a-modal v-model:open="chunkConfigModalVisible" title="分块参数配置" width="500px">
<template #footer>
<a-button key="back" @click="chunkConfigModalVisible = false">取消</a-button>
<a-button key="submit" type="primary" @click="handleChunkConfigSubmit">确定</a-button>
</template>
<div class="chunk-config-content">
<div class="params-info">
<p>调整分块参数可以控制文本的切分方式影响检索质量和文档加载效率</p>
</div>
<a-form
:model="tempChunkParams"
name="chunkConfig"
autocomplete="off"
layout="vertical"
>
<a-form-item label="Chunk Size" name="chunk_size">
<a-input-number v-model:value="tempChunkParams.chunk_size" :min="100" :max="10000" style="width: 100%;" />
<p class="param-description">每个文本片段的最大字符数</p>
</a-form-item>
<a-form-item label="Chunk Overlap" name="chunk_overlap">
<a-input-number v-model:value="tempChunkParams.chunk_overlap" :min="0" :max="1000" style="width: 100%;" />
<p class="param-description">相邻文本片段间的重叠字符数</p>
</a-form-item>
</a-form>
</div>
</a-modal>
<!-- 添加文件弹窗 -->
<a-modal v-model:open="addFilesModalVisible" title="添加文件" width="800px">
<template #footer>
<a-button key="back" @click="addFilesModalVisible = false">取消</a-button>
<a-button
key="submit"
type="primary"
@click="chunkData"
:loading="state.chunkLoading"
:disabled="(uploadMode === 'file' && fileList.length === 0) || (uploadMode === 'url' && !urlList.trim())"
>
生成分块
</a-button>
</template>
<div class="add-files-content">
<div class="upload-header">
<div class="source-selector">
<div class="upload-mode-selector" @click="uploadMode = 'file'" :class="{ active: uploadMode === 'file' }">
<FileOutlined /> 上传文件
</div>
<div class="upload-mode-selector" @click="uploadMode = 'url'" :class="{ active: uploadMode === 'url' }">
<LinkOutlined /> 输入网址
</div>
</div>
<div class="config-controls">
<a-button type="dashed" @click="showChunkConfigModal">
<SettingOutlined /> 分块参数 ({{ chunkParams.chunk_size }}/{{ chunkParams.chunk_overlap }})
</a-button>
</div>
</div>
<div class="ocr-config">
<a-form layout="horizontal">
<a-form-item label="使用OCR" name="enable_ocr">
<div class="ocr-controls">
<a-select
v-model:value="chunkParams.enable_ocr"
:options="enable_ocr_options"
style="width: 220px; margin-right: 12px;"
:disabled="state.ocrHealthChecking"
/>
<a-button
size="small"
type="dashed"
@click="checkOcrHealth"
:loading="state.ocrHealthChecking"
:icon="h(CheckCircleOutlined)"
>
检查OCR服务
</a-button>
</div>
<div class="param-description">
<div v-if="chunkParams.enable_ocr !== 'disable'" class="ocr-status-info">
<span v-if="getSelectedOcrStatus() && getSelectedOcrStatus() !== 'healthy'" class="ocr-warning">
⚠️ {{ getSelectedOcrMessage() }}
</span>
<span v-else-if="getSelectedOcrStatus() === 'healthy'" class="ocr-healthy">
✅ OCR服务运行正常
</span>
</div>
</div>
</a-form-item>
</a-form>
</div>
<!-- 文件上传区域 -->
<div class="upload" v-if="uploadMode === 'file'">
<a-upload-dragger
class="upload-dragger"
v-model:fileList="fileList"
name="file"
:multiple="true"
:disabled="state.chunkLoading"
:action="'/api/knowledge/files/upload?db_id=' + databaseId"
:headers="getAuthHeaders()"
@change="handleFileUpload"
@drop="handleDrop"
>
<p class="ant-upload-text">点击或者把文件拖拽到这里上传</p>
<p class="ant-upload-hint">
目前仅支持上传文本文件,如 .pdf, .txt, .md。且同名文件无法重复添加
</p>
</a-upload-dragger>
</div>
<!-- URL 输入区域 -->
<div class="url-input" v-else>
<a-form layout="vertical">
<a-form-item label="网页链接 (每行一个URL)">
<a-textarea
v-model:value="urlList"
placeholder="请输入网页链接,每行一个"
:rows="6"
:disabled="state.chunkLoading"
/>
</a-form-item>
</a-form>
<p class="url-hint">
支持添加网页内容系统会自动抓取网页文本并进行分块。请确保URL格式正确且可以公开访问。
</p>
</div>
</div>
</a-modal>
<!-- Maximize Graph Modal -->
<a-modal
v-model:open="state.isGraphMaximized"
:footer="null"
:closable="false"
width="100%"
wrap-class-name="full-modal"
:mask-closable="false"
>
<template #title>
<div class="maximized-graph-header">
<h3>知识图谱 (最大化)</h3>
<a-button type="text" @click="toggleGraphMaximize">
<CompressOutlined /> 退出最大化
</a-button>
</div>
</template>
<div class="maximized-graph-content">
<div v-if="!isGraphSupported" class="graph-disabled">
<div class="disabled-content">
<h4>知识图谱不可用</h4>
<p>当前知识库类型 "{{ getKbTypeLabel(database.kb_type || 'lightrag') }}" 不支持知识图谱功能。</p>
<p>只有 LightRAG 类型的知识库支持知识图谱。</p>
</div>
</div>
<KnowledgeGraphViewer
v-else-if="state.isGraphMaximized"
:initial-database-id="databaseId"
:hide-db-selector="true"
/>
</div>
</a-modal>
<!-- 文件详情弹窗 -->
<a-modal
v-model:open="state.fileDetailModalVisible"
:title="selectedFile?.filename || '文件详情'"
width="800px"
:footer="null"
>
<div class="file-detail-content" v-if="selectedFile">
<div class="file-info-grid">
<div class="info-item">
<label>文件ID:</label>
<span>{{ selectedFile.file_id }}</span>
</div>
<div class="info-item">
<label>上传时间:</label>
<span>{{ formatRelativeTime(Math.round(selectedFile.created_at*1000)) }}</span>
</div>
<div class="info-item">
<label>处理状态:</label>
<span class="status-badge" :class="selectedFile.status">
{{ getStatusText(selectedFile.status) }}
</span>
</div>
</div>
<div class="file-content-section" v-if="selectedFile.lines && selectedFile.lines.length > 0">
<h4>文件内容预览</h4>
<div class="content-lines">
<div
v-for="(line, index) in selectedFile.lines"
:key="index"
class="content-line"
>
<span class="line-number">{{ index + 1 }}</span>
<span class="line-text">{{ line.text || line }}</span>
</div>
</div>
</div>
<div v-else-if="state.fileDetailLoading" class="loading-container">
<a-spin />
</div>
<div v-else class="empty-content">
<p>暂无文件内容</p>
</div>
</div>
</a-modal>
<div class="unified-layout">
<div class="left-panel" :style="{ width: leftPanelWidth + '%' }">
<div class="panel-header">
<h3 style="margin-left: 8px;">共 {{ database.files ? Object.keys(database.files).length : 0 }} 个文件</h3>
<div class="panel-actions">
<a-button
type="secondary"
@click="showAddFilesModal"
:loading="state.refrashing"
:icon="h(PlusOutlined)"
title="添加文件"
/>
<div class="refresh-group">
<a-switch
v-model:checked="state.autoRefresh"
@change="toggleAutoRefresh"
size="small"
title="自动刷新文件状态"
/>
<a-button
type="text"
@click="handleRefresh"
:loading="state.refrashing"
:icon="h(ReloadOutlined)"
title="刷新"
/>
</div>
</div>
</div>
<div class="batch-actions-compact" v-if="selectedRowKeys.length > 0">
<span>{{ selectedRowKeys.length }} 项</span>
<a-button
type="text"
danger
@click="handleBatchDelete"
:loading="state.batchDeleting"
:disabled="!canBatchDelete"
:icon="h(DeleteOutlined)"
title="批量删除"
/>
</div>
<a-table
:columns="columnsCompact"
:data-source="Object.values(database.files || {})"
row-key="file_id"
class="my-table-compact"
size="small"
:pagination="paginationCompact"
:scroll="{ y: true }"
:row-selection="{
selectedRowKeys: selectedRowKeys,
onChange: onSelectChange,
getCheckboxProps: getCheckboxProps
}">
<template #bodyCell="{ column, text, record }">
<a-button v-if="column.key === 'filename'" class="main-btn" type="link" @click="openFileDetail(record)">{{ text }}</a-button>
<span v-else-if="column.key === 'type'" :class="['span-type', text]">{{ text?.toUpperCase() }}</span>
<CheckCircleFilled v-else-if="column.key === 'status' && text === 'done'" style="color: #41A317;"/>
<CloseCircleFilled v-else-if="column.key === 'status' && text === 'failed'" style="color: #FF4D4F ;"/>
<HourglassFilled v-else-if="column.key === 'status' && text === 'processing'" style="color: #1677FF;"/>
<ClockCircleFilled v-else-if="column.key === 'status' && text === 'waiting'" style="color: #FFCD43;"/>
<a-tooltip v-else-if="column.key === 'created_at'" :title="record.status" placement="left">
<span>{{ formatRelativeTime(Math.round(text*1000)) }}</span>
</a-tooltip>
<div v-else-if="column.key === 'action'" style="display: flex; gap: 4px;">
<a-button class="del-btn" type="text"
@click="handleDeleteFile(record.file_id)"
:disabled="state.lock || record.status === 'processing' || record.status === 'waiting'"
:icon="h(DeleteOutlined)"
title="删除"
/>
</div>
<span v-else>{{ text }}</span>
</template>
</a-table>
</div>
<div class="resize-handle" ref="resizeHandle"></div>
<div class="right-panel" :style="{ width: (100 - leftPanelWidth) + '%' }">
<div class="graph-section" :class="{ collapsed: !panels.graph.visible }" :style="computePanelStyles().graph">
<div class="section-header">
<div class="header-left">
<h3>知识图谱</h3>
<div v-if="graphStats.displayed_nodes > 0 || graphStats.displayed_edges > 0" class="graph-stats">
<a-tag color="blue" size="small">节点: {{ graphStats.displayed_nodes }}</a-tag>
<a-tag color="green" size="small">边: {{ graphStats.displayed_edges }}</a-tag>
<a-tag v-if="graphStats.is_truncated" color="red" size="small">已截断</a-tag>
</div>
</div>
<div class="panel-actions">
<a-button
type="text"
size="small"
:icon="h(ReloadOutlined)"
title="刷新视图"
@click="refreshGraph"
:disabled="!isGraphSupported"
>
刷新视图
</a-button>
<a-button
type="text"
size="small"
:icon="h(DeleteOutlined)"
title="清空视图"
@click="clearGraph"
:disabled="!isGraphSupported"
>
清空视图
</a-button>
<a-button
type="text"
size="small"
:icon="h(ExpandOutlined)"
title="最大化"
@click="toggleGraphMaximize"
:disabled="!isGraphSupported"
>
最大化
</a-button>
<a-button
type="text"
size="small"
@click="togglePanel('graph')"
title="折叠/展开"
>
<component :is="panels.graph.visible ? UpOutlined : DownOutlined" />
</a-button>
</div>
</div>
<div class="graph-container-compact content" v-show="panels.graph.visible">
<div v-if="!isGraphSupported" class="graph-disabled">
<div class="disabled-content">
<h4>知识图谱不可用</h4>
<p>当前知识库类型 "{{ getKbTypeLabel(database.kb_type || 'lightrag') }}" 不支持知识图谱功能。</p>
<p>只有 LightRAG 类型的知识库支持知识图谱。</p>
</div>
</div>
<KnowledgeGraphViewer
v-else
:initial-database-id="databaseId"
:hide-db-selector="true"
:hide-stats="true"
@update:stats="handleStatsUpdate"
ref="graphViewerRef"
/>
</div>
</div>
<div class="resize-handle-horizontal" ref="resizeHandleHorizontal" v-show="panels.query.visible && panels.graph.visible"></div>
<div class="query-section" :class="{ collapsed: !panels.query.visible }" :style="computePanelStyles().query">
<div class="section-header">
<h3>检索测试</h3>
<div class="panel-actions">
<a-popover trigger="click" placement="bottomRight" class="query-params-popover">
<template #content>
<div class="query-params-compact">
<div v-if="state.queryParamsLoading" class="params-loading">
<a-spin size="small" />
</div>
<div v-else class="params-grid">
<div v-for="param in queryParams" :key="param.key" class="param-item">
<label>{{ param.label }}:</label>
<a-select
v-if="param.type === 'select'"
v-model:value="meta[param.key]"
size="small"
style="width: 80px;"
>
<a-select-option
v-for="option in param.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-select-option>
</a-select>
<a-switch
v-else-if="param.type === 'boolean'"
v-model:checked="meta[param.key]"
size="small"
/>
<a-input-number
v-else-if="param.type === 'number'"
v-model:value="meta[param.key]"
size="small"
style="width: 60px;"
:min="param.min || 0"
:max="param.max || 100"
/>
</div>
</div>
</div>
</template>
<a-button
type="text"
size="small"
:icon="h(SettingOutlined)"
title="查询参数"
>查询参数</a-button>
</a-popover>
<a-button
type="text"
size="small"
@click="togglePanel('query')"
title="折叠/展开"
>
<component :is="panels.query.visible ? UpOutlined : DownOutlined" />
</a-button>
</div>
</div>
<div class="query-content content" v-show="panels.query.visible">
<div class="query-input-row">
<a-input-search
v-model:value="queryText"
placeholder="输入查询内容"
class="compact-query-input"
:loading="state.searchLoading"
@search="onQuery"
@keydown.ctrl.enter="onQuery"
enter-button="搜索"
size="default"
/>
</div>
<div class="query-examples-compact">
<span class="examples-label">示例:</span>
<a-button
v-for="example in queryExamples"
:key="example"
@click="useQueryExample(example)"
size="small"
class="example-btn"
>
{{ example }}
</a-button>
</div>
<div class="query-results" v-if="queryResult">
{{ queryResult }}
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, reactive, ref, watch, toRaw, onUnmounted, computed, h } from 'vue';
import { message, Modal } from 'ant-design-vue';
import { useRoute, useRouter } from 'vue-router';
import { useConfigStore } from '@/stores/config'
import { useUserStore } from '@/stores/user'
import { databaseApi, documentApi, queryApi, fileApi } from '@/apis/knowledge_api'
import { ocrApi } from '@/apis/system_api'
import {
ReadOutlined,
LeftOutlined,
RightOutlined,
CheckCircleFilled,
CheckCircleOutlined,
HourglassFilled,
CloseCircleFilled,
ClockCircleFilled,
DeleteOutlined,
SearchOutlined,
LoadingOutlined,
FileOutlined,
LinkOutlined,
EditOutlined,
PlusOutlined,
SettingOutlined,
DatabaseOutlined,
ThunderboltOutlined,
ReloadOutlined,
UpOutlined,
DownOutlined,
ExpandOutlined,
CompressOutlined,
} from '@ant-design/icons-vue'
import HeaderComponent from '@/components/HeaderComponent.vue';
import KnowledgeGraphViewer from '@/components/KnowledgeGraphViewer.vue';
import { Waypoints, Database, Zap } from 'lucide-vue-next';
const route = useRoute();
const router = useRouter();
const databaseId = ref(route.params.database_id);
const database = ref({});
const fileList = ref([]);
const selectedFile = ref(null);
// 查询测试
const queryText = ref('');
const queryResult = ref(null)
const configStore = useConfigStore()
const state = reactive({
databaseLoading: false,
adding: false,
refrashing: false,
searchLoading: false,
lock: false,
fileDetailModalVisible: false,
fileDetailLoading: false,
refreshInterval: null,
curPage: "files",
batchDeleting: false,
chunkLoading: false,
autoRefresh: false,
loading: false,
queryParamsLoading: false,
ocrHealthChecking: false,
isGraphMaximized: false,
});
// 计算属性:是否支持知识图谱
const isGraphSupported = computed(() => {
const kbType = database.value.kb_type?.toLowerCase();
return kbType === 'lightrag';
});
// 切换图谱最大化状态
const toggleGraphMaximize = () => {
state.isGraphMaximized = !state.isGraphMaximized;
};
// 图谱相关数据
const graphViewerRef = ref(null);
const graphStats = ref({
displayed_nodes: 0,
displayed_edges: 0,
is_truncated: false
});
// 处理图谱统计信息更新
const handleStatsUpdate = (stats) => {
graphStats.value = stats;
};
// OCR服务健康状态
const ocrHealthStatus = ref({
rapid_ocr: { status: 'unknown', message: '' },
mineru_ocr: { status: 'unknown', message: '' },
paddlex_ocr: { status: 'unknown', message: '' }
});
// 动态查询参数
const queryParams = ref([])
const meta = reactive({});
// OCR健康检查函数
const checkOcrHealth = async () => {
if (state.ocrHealthChecking) return;
state.ocrHealthChecking = true;
try {
const healthData = await ocrApi.getHealth();
ocrHealthStatus.value = healthData.services;
} catch (error) {
console.error('OCR健康检查失败:', error);
message.error('OCR服务健康检查失败');
} finally {
state.ocrHealthChecking = false;
}
};
// 生成OCR选项的计算属性包含健康状态信息
const enable_ocr_options = computed(() => [
{
value: 'disable',
label: '不启用',
title: '不启用'
},
{
value: 'onnx_rapid_ocr',
label: getRapidOcrLabel(),
title: 'ONNX with RapidOCR',
disabled: ocrHealthStatus.value.rapid_ocr.status === 'unavailable' || ocrHealthStatus.value.rapid_ocr.status === 'error'
},
{
value: 'mineru_ocr',
label: getMinerULabel(),
title: 'MinerU OCR',
disabled: ocrHealthStatus.value.mineru_ocr.status === 'unavailable' || ocrHealthStatus.value.mineru_ocr.status === 'error'
},
{
value: 'paddlex_ocr',
label: getPaddleXLabel(),
title: 'PaddleX OCR',
disabled: ocrHealthStatus.value.paddlex_ocr.status === 'unavailable' || ocrHealthStatus.value.paddlex_ocr.status === 'error'
},
]);
// OCR选项标签生成函数
const getRapidOcrLabel = () => {
const status = ocrHealthStatus.value.rapid_ocr.status;
const statusIcons = {
'healthy': '✅',
'unavailable': '❌',
'error': '⚠️',
'unknown': '❓'
};
return `${statusIcons[status] || '❓'} RapidOCR (ONNX)`;
};
const getMinerULabel = () => {
const status = ocrHealthStatus.value.mineru_ocr.status;
const statusIcons = {
'healthy': '✅',
'unavailable': '❌',
'unhealthy': '⚠️',
'timeout': '⏰',
'error': '⚠️',
'unknown': '❓'
};
return `${statusIcons[status] || '❓'} MinerU OCR`;
};
const getPaddleXLabel = () => {
const status = ocrHealthStatus.value.paddlex_ocr.status;
const statusIcons = {
'healthy': '✅',
'unavailable': '❌',
'unhealthy': '⚠️',
'timeout': '⏰',
'error': '⚠️',
'unknown': '❓'
};
return `${statusIcons[status] || '❓'} PaddleX OCR`;
};
// 获取当前选中OCR服务的状态
const getSelectedOcrStatus = () => {
switch (chunkParams.value.enable_ocr) {
case 'onnx_rapid_ocr':
return ocrHealthStatus.value.rapid_ocr.status;
case 'mineru_ocr':
return ocrHealthStatus.value.mineru_ocr.status;
case 'paddlex_ocr':
return ocrHealthStatus.value.paddlex_ocr.status;
default:
return null;
}
};
// 获取当前选中OCR服务的状态消息
const getSelectedOcrMessage = () => {
switch (chunkParams.value.enable_ocr) {
case 'onnx_rapid_ocr':
return ocrHealthStatus.value.rapid_ocr.message;
case 'mineru_ocr':
return ocrHealthStatus.value.mineru_ocr.message;
case 'paddlex_ocr':
return ocrHealthStatus.value.paddlex_ocr.message;
default:
return '';
}
};
// 验证OCR服务可用性
const validateOcrService = () => {
if (chunkParams.value.enable_ocr === 'disable') {
return true;
}
const status = getSelectedOcrStatus();
if (status === 'unavailable' || status === 'error') {
const ocrMessage = getSelectedOcrMessage();
message.error(`OCR服务不可用: ${ocrMessage}`);
return false;
}
return true;
};
// 加载知识库类型特定的查询参数
const loadQueryParams = async () => {
if (!databaseId.value) return
state.queryParamsLoading = true
try {
const response = await queryApi.getKnowledgeBaseQueryParams(databaseId.value)
queryParams.value = response.params?.options || []
// 初始化meta对象的默认值
queryParams.value.forEach(param => {
if (!(param.key in meta)) {
meta[param.key] = param.default
}
})
console.log('Loaded query params:', queryParams.value)
console.log('Initialized meta:', meta)
} catch (error) {
console.error('Failed to load query params:', error)
message.error('加载查询参数失败')
} finally {
state.queryParamsLoading = false
}
}
const pagination = ref({
pageSize: 15,
current: 1,
total: computed(() => database.value?.files?.length || 0),
showSizeChanger: true,
onChange: (page, pageSize) => pagination.value.current = page,
showTotal: (total, range) => `共 ${total} 条`,
onShowSizeChange: (current, pageSize) => pagination.value.pageSize = pageSize,
})
const onQuery = () => {
console.log(queryText.value)
state.searchLoading = true
if (!queryText.value.trim()) {
message.error('请输入查询内容')
state.searchLoading = false
return
}
// Remove db_id from meta to avoid duplicate parameter error
const queryMeta = { ...toRaw(meta) }
delete queryMeta.db_id
try {
queryApi.queryTest(database.value.db_id, queryText.value.trim(), queryMeta)
.then(data => {
console.log(data)
queryResult.value = data
})
.catch(error => {
console.error(error)
message.error(error.message)
})
.finally(() => {
state.searchLoading = false
})
} catch (error) {
console.error(error)
message.error(error.message)
state.searchLoading = false
}
}
const handleFileUpload = (event) => {
console.log(event)
console.log(fileList.value)
}
const handleDrop = (event) => {
console.log(event)
console.log(fileList.value)
}
const afterOpenChange = (visible) => {
if (!visible) {
selectedFile.value = null
}
}
const backToDatabase = () => {
router.push('/database')
}
const handleRefresh = () => {
state.refrashing = true
getDatabaseInfo().then(() => {
state.refrashing = false
console.log(database.value)
})
}
const deleteDatabse = () => {
Modal.confirm({
title: '删除数据库',
content: '确定要删除该数据库吗?',
okText: '确认',
cancelText: '取消',
onOk: () => {
state.lock = true
databaseApi.deleteDatabase(databaseId.value)
.then(data => {
console.log(data)
message.success(data.message || '删除成功')
router.push('/database')
})
.catch(error => {
console.error(error)
message.error(error.message || '删除失败')
})
.finally(() => {
state.lock = false
})
},
onCancel: () => {
console.log('Cancel');
},
});
}
const openFileDetail = (record) => {
// 先打开弹窗
if (record.status !== 'done') {
message.error('文件未处理完成,请稍后再试');
return;
}
state.fileDetailModalVisible = true;
selectedFile.value = {
...record,
lines: []
};
// 设置加载状态
state.fileDetailLoading = true;
state.lock = true;
try {
documentApi.getDocumentInfo(databaseId.value, record.file_id)
.then(data => {
console.log(data);
if (data.status == "failed") {
message.error(data.message);
state.fileDetailModalVisible = false;
return;
}
selectedFile.value = {
...record,
lines: data.lines || []
};
})
.catch(error => {
console.error(error);
message.error(error.message);
state.fileDetailModalVisible = false;
})
.finally(() => {
state.fileDetailLoading = false;
state.lock = false;
});
} catch (error) {
console.error(error);
message.error('获取文件详情失败!!!!');
state.fileDetailLoading = false;
state.lock = false;
state.fileDetailModalVisible = false;
}
}
const formatRelativeTime = (timestamp, offset = 0) => {
// 如果调整为东八区时间UTC+8则offset为8否则为0
const timezoneOffset = offset * 60 * 60 * 1000; // 东八区偏移量(毫秒)
const adjustedTimestamp = timestamp + timezoneOffset;
const now = Date.now();
const secondsPast = (now - adjustedTimestamp) / 1000;
if (secondsPast < 60) {
return Math.round(secondsPast) + ' 秒前';
} else if (secondsPast < 3600) {
return Math.round(secondsPast / 60) + ' 分钟前';
} else if (secondsPast < 86400) {
return Math.round(secondsPast / 3600) + ' 小时前';
} else {
const date = new Date(adjustedTimestamp);
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
return `${year} 年 ${month} 月 ${day} 日`;
}
}
const getDatabaseInfo = () => {
const db_id = databaseId.value
if (!db_id) {
return
}
state.lock = true
state.databaseLoading = true
return new Promise((resolve, reject) => {
databaseApi.getDatabaseInfo(db_id)
.then(async data => {
database.value = data
// 加载查询参数
await loadQueryParams()
resolve(data)
})
.catch(error => {
console.error(error)
message.error(error.message || '获取数据库信息失败')
reject(error)
})
.finally(() => {
state.lock = false
state.databaseLoading = false
})
})
}
const deleteFile = (fileId) => {
state.lock = true
console.debug("deleteFile", databaseId.value, fileId)
return documentApi.deleteDocument(databaseId.value, fileId)
.then(data => {
console.log(data)
message.success(data.message || '删除成功')
getDatabaseInfo()
})
.catch(error => {
console.error(error)
message.error(error.message || '删除失败')
throw error
})
.finally(() => {
state.lock = false
})
}
const handleDeleteFile = (fileId) => {
console.log(fileId)
//删除提示
Modal.confirm({
title: '删除文件',
content: '确定要删除该文件吗?',
okText: '确认',
cancelText: '取消',
onOk: () => deleteFile(fileId),
onCancel: () => {
console.log('Cancel');
},
});
}
// 批量删除处理函数
const handleBatchDelete = () => {
if (!canBatchDelete.value) {
message.info('没有可删除的文件');
return;
}
const files = database.value.files || {};
const fileCount = selectedRowKeys.value.length;
Modal.confirm({
title: '批量删除文件',
content: `确定要删除选中的 ${fileCount} 个文件吗?`,
okText: '确认',
cancelText: '取消',
onOk: async () => {
state.batchDeleting = true;
try {
const promises = selectedRowKeys.value
.filter(fileId => {
const file = files[fileId];
return !(file.status === 'processing' || file.status === 'waiting');
})
.map(fileId =>
deleteFile(fileId)
);
const results = await Promise.allSettled(promises);
const succeeded = results.filter(r => r.status === 'fulfilled').length;
const failed = results.filter(r => r.status === 'rejected').length;
if (succeeded > 0) {
message.success(`成功删除 ${succeeded} 个文件`);
}
if (failed > 0) {
message.error(`${failed} 个文件删除失败`);
}
selectedRowKeys.value = []; // 清空选择
getDatabaseInfo(); // 刷新列表状态
} catch (error) {
console.error('批量删除出错:', error);
message.error('批量删除过程中发生错误');
} finally {
state.batchDeleting = false;
}
},
});
};
const chunkParams = ref({
chunk_size: 1000,
chunk_overlap: 200,
enable_ocr: 'disable',
})
// "生成分块" - 新的统一方法
const addFiles = (items, contentType = 'file') => {
if (items.length === 0) {
message.error(contentType === 'file' ? '请先上传文件' : '请输入有效的网页链接');
return;
}
state.chunkLoading = true;
// 设置内容类型
const params = {
...chunkParams.value,
content_type: contentType
};
documentApi.addDocuments(databaseId.value, items, params)
.then(data => {
console.log('处理结果:', data);
if (data.status === 'success') {
const itemType = contentType === 'file' ? '文件' : 'URL';
message.success(data.message || `${itemType}已提交处理,请稍后在列表刷新查看状态`);
// 清空输入
if (contentType === 'file') {
fileList.value = [];
} else {
urlList.value = '';
}
addFilesModalVisible.value = false;
getDatabaseInfo();
} else {
message.error(data.message || '处理失败');
}
})
.catch(error => {
console.error(error);
message.error(error.message || '处理请求失败');
})
.finally(() => {
state.chunkLoading = false;
});
};
watch(() => route.params.database_id, async (newId) => {
databaseId.value = newId;
console.log(newId)
stopAutoRefresh();
await getDatabaseInfo();
startAutoRefresh();
}
);
// 添加更多示例查询
const queryExamples = ref([
'孕妇应该避免吃哪些水果?',
'荔枝应该怎么清洗?'
]);
// 使用示例查询的方法
const useQueryExample = (example) => {
queryText.value = example;
onQuery();
};
// 拖拽调整大小功能
const handleMouseDown = () => {
isDragging.value = true;
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
};
const handleMouseMove = (e) => {
if (!isDragging.value) return;
const container = document.querySelector('.unified-layout');
if (!container) return;
const containerRect = container.getBoundingClientRect();
const newWidth = ((e.clientX - containerRect.left) / containerRect.width) * 100;
// 限制最小和最大宽度
leftPanelWidth.value = Math.max(20, Math.min(60, newWidth));
};
const handleMouseUp = () => {
isDragging.value = false;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
const handleMouseDownHorizontal = () => {
isDraggingVertical.value = true;
document.addEventListener('mousemove', handleMouseMoveHorizontal);
document.addEventListener('mouseup', handleMouseUpHorizontal);
document.body.style.cursor = 'row-resize';
document.body.style.userSelect = 'none';
};
const handleMouseMoveHorizontal = (e) => {
if (!isDraggingVertical.value) return;
const container = document.querySelector('.right-panel');
if (!container) return;
const containerRect = container.getBoundingClientRect();
const newHeight = ((e.clientY - containerRect.top) / containerRect.height) * 100;
rightPanelHeight.graph = Math.max(10, Math.min(90, newHeight));
rightPanelHeight.query = 100 - rightPanelHeight.graph;
};
const handleMouseUpHorizontal = () => {
isDraggingVertical.value = false;
document.removeEventListener('mousemove', handleMouseMoveHorizontal);
document.removeEventListener('mouseup', handleMouseUpHorizontal);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
// 添加事件监听
onMounted(() => {
getDatabaseInfo();
startAutoRefresh();
// 初始化时检查OCR服务健康状态
checkOcrHealth();
setTimeout(() => {
if (isGraphSupported.value) {
refreshGraph();
}
}, 800);
// 添加拖拽事件监听
if (resizeHandle.value) {
resizeHandle.value.addEventListener('mousedown', handleMouseDown);
}
if (resizeHandleHorizontal.value) {
resizeHandleHorizontal.value.addEventListener('mousedown', handleMouseDownHorizontal);
}
});
// 清理事件监听
onUnmounted(() => {
stopAutoRefresh();
if (resizeHandle.value) {
resizeHandle.value.removeEventListener('mousedown', handleMouseDown);
}
if (resizeHandleHorizontal.value) {
resizeHandleHorizontal.value.removeEventListener('mousedown', handleMouseDownHorizontal);
}
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('mousemove', handleMouseMoveHorizontal);
document.removeEventListener('mouseup', handleMouseUpHorizontal);
});
const uploadMode = ref('file');
const urlList = ref('');
const chunkData = () => {
// 验证OCR服务可用性
if (!validateOcrService()) {
return;
}
if (uploadMode.value === 'file') {
const files = fileList.value.filter(file => file.status === 'done').map(file => file.response.file_path);
console.log(files);
addFiles(files, 'file');
} else if (uploadMode.value === 'url') {
const urls = urlList.value.split('\n')
.map(url => url.trim())
.filter(url => url.length > 0 && (url.startsWith('http://') || url.startsWith('https://')));
if (urls.length === 0) {
message.error('请输入有效的网页链接必须以http://或https://开头)');
return;
}
addFiles(urls, 'url');
}
}
const getAuthHeaders = () => {
const userStore = useUserStore();
return userStore.getAuthHeaders();
};
// 编辑知识库表单
const editModalVisible = ref(false);
const editFormRef = ref(null);
const editForm = reactive({
name: '',
description: ''
});
const rules = {
name: [{ required: true, message: '请输入知识库名称' }]
};
// 分块参数配置弹窗
const chunkConfigModalVisible = ref(false);
const tempChunkParams = ref({
chunk_size: 1000,
chunk_overlap: 200,
});
// 添加文件弹窗
const addFilesModalVisible = ref(false);
// 显示编辑对话框
const showEditModal = () => {
editForm.name = database.value.name || '';
editForm.description = database.value.description || '';
editModalVisible.value = true;
};
// 提交编辑表单
const handleEditSubmit = () => {
editFormRef.value.validate().then(() => {
updateDatabaseInfo();
}).catch(err => {
console.error('表单验证失败:', err);
});
};
// 更新知识库信息
const updateDatabaseInfo = async () => {
try {
state.lock = true;
const response = await databaseApi.updateDatabase(databaseId.value, {
name: editForm.name,
description: editForm.description
});
message.success('知识库信息更新成功');
editModalVisible.value = false;
await getDatabaseInfo(); // 刷新数据
} catch (error) {
console.error(error);
message.error(error.message || '更新失败');
} finally {
state.lock = false;
}
};
// 显示分块参数配置弹窗
const showChunkConfigModal = () => {
tempChunkParams.value = {
chunk_size: chunkParams.value.chunk_size,
chunk_overlap: chunkParams.value.chunk_overlap,
};
chunkConfigModalVisible.value = true;
};
// 处理分块参数配置提交
const handleChunkConfigSubmit = () => {
chunkParams.value.chunk_size = tempChunkParams.value.chunk_size;
chunkParams.value.chunk_overlap = tempChunkParams.value.chunk_overlap;
chunkConfigModalVisible.value = false;
message.success('分块参数配置已更新');
};
// 显示添加文件弹窗
const showAddFilesModal = () => {
addFilesModalVisible.value = true;
};
// 选中的行
const selectedRowKeys = ref([]);
// 面板可见性控制
const panels = reactive({
query: { visible: true },
graph: { visible: true },
});
const togglePanel = (panel) => {
panels[panel].visible = !panels[panel].visible;
};
// 拖拽调整大小
const leftPanelWidth = ref(45); // 百分比
const isDragging = ref(false);
const resizeHandle = ref(null);
const rightPanelHeight = reactive({ query: 50, graph: 50 });
const isDraggingVertical = ref(false);
const resizeHandleHorizontal = ref(null);
// 计算面板样式的方法
const computePanelStyles = () => {
const queryVisible = panels.query.visible;
const graphVisible = panels.graph.visible;
if (queryVisible && graphVisible) {
// 两个面板都显示时,按比例分配
return {
query: { height: rightPanelHeight.query + '%' },
graph: { height: rightPanelHeight.graph + '%' }
};
} else if (queryVisible && !graphVisible) {
// 只显示查询面板时,查询面板占满
return {
query: { height: 'calc(100% - 40px)' },
graph: { height: '36px' }
};
} else if (!queryVisible && graphVisible) {
// 只显示图谱面板时,图谱面板占满
return {
query: { height: '36px' },
graph: { height: 'calc(100% - 40px)' }
};
} else {
// 两个面板都折叠时
return {
query: { height: '36px' },
graph: { height: '36px' }
};
}
};
// 紧凑表格列定义
const columnsCompact = [
{ title: '文件名', dataIndex: 'filename', key: 'filename', ellipsis: true, width: 'auto' },
{ title: '状态', dataIndex: 'status', key: 'status', width: 60, align: 'center' },
{ title: '', key: 'action', dataIndex: 'file_id', width: 40, align: 'center' }
];
// 紧凑分页配置
const paginationCompact = ref({
pageSize: 20,
current: 1,
total: computed(() => database.value?.files?.length || 0),
showSizeChanger: false,
onChange: (page) => paginationCompact.value.current = page,
showTotal: (total) => `${total}`,
size: 'small',
showQuickJumper: false,
});
// 行选择改变处理
const onSelectChange = (keys) => {
selectedRowKeys.value = keys;
};
// 获取复选框属性
const getCheckboxProps = (record) => ({
disabled: state.lock || record.status === 'processing' || record.status === 'waiting',
});
// 计算是否可以批量删除
const canBatchDelete = computed(() => {
const files = database.value.files || {};
return selectedRowKeys.value.some(key => {
const file = files[key];
return !(state.lock || file.status === 'processing' || file.status === 'waiting');
});
});
// 开始自动刷新
const startAutoRefresh = () => {
if (state.autoRefresh && !state.refreshInterval) {
state.refreshInterval = setInterval(() => {
getDatabaseInfo();
}, 1000);
}
};
// 停止自动刷新
const stopAutoRefresh = () => {
if (state.refreshInterval) {
clearInterval(state.refreshInterval);
state.refreshInterval = null;
}
};
// 切换自动刷新状态
const toggleAutoRefresh = (checked) => {
if (checked) {
startAutoRefresh();
} else {
stopAutoRefresh();
}
};
// 获取状态文本
const getStatusText = (status) => {
const statusMap = {
'done': '处理完成',
'failed': '处理失败',
'processing': '处理中',
'waiting': '等待处理'
}
return statusMap[status] || status
}
// 刷新图谱
const refreshGraph = () => {
if (graphViewerRef.value) {
graphViewerRef.value.loadFullGraph();
}
};
// 清空图谱
const clearGraph = () => {
if (graphViewerRef.value) {
graphViewerRef.value.clearGraph();
}
};
// 知识库类型相关工具方法
const getKbTypeLabel = (type) => {
const labels = {
lightrag: 'LightRAG',
chroma: 'Chroma',
milvus: 'Milvus'
}
return labels[type] || type
}
const getKbTypeIcon = (type) => {
const icons = {
lightrag: Database,
chroma: Zap,
milvus: ThunderboltOutlined
}
return icons[type] || Database
}
const getKbTypeColor = (type) => {
const colors = {
lightrag: 'purple',
chroma: 'orange',
milvus: 'red'
}
return colors[type] || 'blue'
}
</script>
<style lang="less" scoped>
.database-info {
margin: 8px 0 0;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
.kb-type-tag {
display: flex;
align-items: center;
gap: 4px;
.type-icon {
margin-right: 4px;
font-size: 12px;
width: 14px;
height: 14px;
}
}
}
.header-container {
padding: 8px;
height: 54px;
}
.header-info {
display: flex;
align-items: center;
gap: 10px;
margin-left: auto; /* Push to the right */
.kb-type-tag {
display: flex;
align-items: center;
gap: 4px;
}
.type-icon {
font-size: 12px;
width: 12px;
height: 12px;
}
.db-id,
.file-count {
font-size: 12px;
color: var(--gray-600);
font-weight: 500;
}
}
.db-main-container {
display: flex;
width: 100%;
}
.my-table {
button.ant-btn-link {
padding: 0;
}
.span-type {
color: white;
padding: 2px 4px;
border-radius: 4px;
font-size: 10px;
font-weight: bold;
opacity: 0.8;
user-select: none;
background: #005F77;
}
.pdf {
background: #005F77;
}
.txt {
background: #068033;
}
.docx,
.doc {
background: #2C59B7;
}
.md {
background: #020817;
}
button.del-btn {
cursor: pointer;
&:hover {
color: var(--error-color);
}
&:disabled {
cursor: not-allowed;
}
}
}
.file-detail-content {
max-height: 60vh;
overflow-y: auto;
// padding: 0 10px;
.file-info-grid {
display: grid;
grid-template-columns: 100px 1fr;
gap: 6px;
padding: 12px;
background-color: var(--gray-100);
border-radius: 6px;
.info-item {
display: contents;
label {
font-weight: 600;
color: var(--gray-700);
font-size: 13px;
}
span {
font-size: 13px;
color: var(--gray-800);
}
}
.status-badge {
font-size: 12px;
font-weight: 500;
&.done {
color: #52c41a;
}
&.failed {
color: #ff4d4f;
}
&.processing {
color: #1890ff;
}
&.waiting {
color: #faad14;
}
}
}
.file-content-section {
margin-top: 8px;
h4 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: var(--gray-800);
}
.content-lines {
max-height: 400px;
overflow-y: auto;
border: 1px solid var(--gray-200);
border-radius: 4px;
.content-line {
display: flex;
padding: 8px 12px;
border-bottom: 1px solid var(--gray-100);
font-size: 13px;
line-height: 1.5;
&:last-child {
border-bottom: none;
}
&:hover {
background-color: var(--gray-50);
}
.line-number {
flex-shrink: 0;
width: 40px;
color: var(--gray-500);
font-family: monospace;
font-size: 12px;
text-align: right;
margin-right: 12px;
}
.line-text {
flex: 1;
color: var(--gray-800);
white-space: pre-wrap;
word-break: break-word;
}
}
}
}
.empty-content {
text-align: center;
padding: 40px;
color: var(--gray-500);
}
.loading-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100px;
}
}
.custom-class .line-text {
padding: 10px;
border-radius: 4px;
margin: 8px 0;
background-color: var(--gray-100);
&:hover {
background-color: var(--main-light-4);
}
}
.add-files-content {
.upload-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
gap: 20px;
.source-selector {
display: flex;
gap: 10px;
.upload-mode-selector {
cursor: pointer;
padding: 4px 16px;
border-radius: 8px;
background-color: var(--main-light-4);
border: 1px solid var(--main-light-3);
transition: all 0.2s ease;
&.active {
color: var(--main-color);
background-color: var(--main-10);
border-color: var(--main-color);
}
}
}
.config-controls {
.ant-btn {
border-color: var(--main-light-3);
color: var(--gray-700);
&:hover {
border-color: var(--main-color);
color: var(--main-color);
}
}
}
}
.ocr-config {
margin-bottom: 16px;
padding: 12px 16px;
background-color: var(--main-light-6);
border-radius: 8px;
border: 1px solid var(--main-light-3);
.ant-form-item {
margin-bottom: 0;
.ant-form-item-label {
color: var(--gray-800);
font-weight: 500;
}
}
.param-description {
color: var(--gray-600);
font-size: 12px;
margin-left: 12px;
}
.ocr-controls {
display: flex;
align-items: center;
gap: 8px;
}
.ocr-status-info {
font-size: 11px; /* Reduced from 12px */
margin-top: 4px;
}
.ocr-warning {
color: #faad14;
font-weight: 500;
}
.ocr-healthy {
color: #52c41a;
font-weight: 500;
}
}
.upload {
margin-bottom: 20px;
.upload-dragger {
margin: 0px;
min-height: 200px;
}
}
.url-input {
margin-bottom: 20px;
.ant-textarea {
border-color: var(--main-light-3);
background-color: #fff;
font-family: monospace;
resize: vertical;
}
.ant-textarea:hover,
.ant-textarea:focus {
border-color: var(--main-color);
}
.url-hint {
font-size: 13px;
color: var(--gray-600);
margin-top: 5px;
line-height: 1.5;
}
}
}
.chunk-config-content {
.params-info {
background-color: var(--main-light-4);
border-radius: 6px;
padding: 10px 12px;
margin-bottom: 16px;
p {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: var(--gray-700);
}
}
.ant-form-item {
margin-bottom: 16px;
.ant-form-item-label {
padding-bottom: 6px;
label {
color: var(--gray-800);
font-weight: 500;
font-size: 15px;
}
}
}
.ant-input-number {
border-radius: 6px;
&:hover,
&:focus {
border-color: var(--main-color);
}
}
.param-description {
color: var(--gray-600);
font-size: 12px;
margin-top: 4px;
margin-bottom: 0;
}
}
.ant-modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.auto-refresh-control {
display: flex;
align-items: center;
gap: 8px;
border-radius: 6px;
span {
color: var(--gray-700);
font-weight: 500;
font-size: 14px;
}
.ant-switch {
&.ant-switch-checked {
background-color: var(--main-color);
}
}
}
/* Unified Layout Styles */
.unified-layout {
display: flex;
height: calc(100vh - 54px); /* Adjust based on actual header height */
gap: 0;
.left-panel,
.right-panel {
background-color: #fff;
display: flex;
flex-direction: column;
overflow: hidden;
}
.left-panel {
flex-shrink: 0;
border-right: 1px solid var(--gray-200);
background-color: var(--bg-sider);
padding: 8px;
}
.right-panel {
flex-grow: 1;
overflow-y: auto;
}
.resize-handle,
.resize-handle-horizontal {
width: 2px;
cursor: col-resize;
background-color: var(--gray-200);
transition: background-color 0.2s ease;
position: relative;
z-index: 10;
&:hover {
background-color: var(--main-light-2);
}
}
.resize-handle-horizontal {
height: 2px;
width: 100%;
cursor: row-resize;
background-color: var(--gray-200);
transition: background-color 0.2s ease;
z-index: 10;
&:hover {
background-color: var(--main-light-2);
}
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
flex-shrink: 0;
padding: 4px 2px;
h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--gray-800);
&.left-panel-title {
margin-left: 8px;
}
}
.panel-actions {
display: flex;
align-items: center;
gap: 6px;
.refresh-group {
display: flex;
align-items: center;
gap: 4px;
background: var(--gray-50);
padding: 2px 6px;
border-radius: 12px;
border: 1px solid var(--gray-200);
}
.ant-btn {
transition: all 0.2s ease;
border-radius: 4px;
&:hover {
background-color: var(--main-light-5);
color: var(--main-color);
}
}
}
}
.batch-actions-compact {
display: flex;
align-items: center;
justify-content: space-between;
padding: 2px 4px;
background-color: var(--main-light-5);
border-radius: 4px;
margin-bottom: 4px;
flex-shrink: 0;
span {
font-size: 11px;
font-weight: 500;
color: var(--gray-700);
}
}
.my-table-compact {
flex: 1;
overflow: hidden;
background-color: transparent;
height: 0; /* 重要:让 flex 子项可以正确缩小 */
.main-btn {
padding: 0;
height: auto;
line-height: 1.4;
font-size: 14px;
font-weight: 600;
color: var(--color-text);
text-decoration: none;
&:hover {
cursor: pointer;
color: var(--main-color);
}
}
.del-btn {
color: var(--gray-500);
&:hover {
color: var(--error-color);
}
&:disabled {
cursor: not-allowed;
}
}
.span-type {
display: inline-block;
padding: 1px 5px;
font-size: 10px;
font-weight: bold;
color: white;
border-radius: 4px;
text-transform: uppercase;
opacity: 0.9;
}
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--gray-200);
padding: 8px;
height: 36px;
flex-shrink: 0;
background-color: var(--gray-200);
backdrop-filter: blur(4px);
position: sticky;
top: 0;
z-index: 1;
h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.graph-stats {
display: flex;
gap: 4px;
}
.panel-actions {
display: flex;
gap: 4px;
.ant-btn {
transition: all 0.2s ease;
border-radius: 4px;
&:hover {
background-color: var(--main-light-5);
color: var(--main-color);
}
}
}
}
.graph-disabled {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
background-color: var(--gray-50);
border-radius: 6px;
.disabled-content {
text-align: center;
color: var(--gray-600);
h4 {
margin: 0 0 8px 0;
color: var(--gray-800);
font-size: 16px;
}
p {
margin: 4px 0;
font-size: 13px;
line-height: 1.4;
}
}
}
.query-section {
position: relative;
.query-content {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
padding-bottom: 10px;
}
.query-input-row {
margin-bottom: 4px;
flex-shrink: 0;
}
.compact-query-input {
width: 100%;
border-radius: 4px;
font-size: 12px;
.ant-input {
font-size: 12px;
}
.ant-btn {
font-size: 12px;
}
}
.query-examples-compact {
margin-top: 4px;
margin-bottom: 6px;
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
flex-shrink: 0;
.examples-label {
font-size: 12px;
color: var(--gray-600);
}
.example-btn {
background-color: var(--gray-100);
border: 1px solid var(--gray-200);
color: var(--gray-700);
font-size: 12px;
&:hover {
background-color: var(--main-light-4);
border-color: var(--main-color);
color: var(--main-color);
}
}
}
.query-results {
margin-top: 6px;
padding: 8px;
background-color: var(--gray-50);
border: 1px solid var(--gray-200);
border-radius: 4px;
white-space: pre-wrap;
font-size: 12px;
line-height: 1.5;
flex: 1; /* 自动占据剩余所有空间 */
overflow-y: auto;
min-height: 0; /* 允许 flex 项目缩小 */
}
}
.graph-section {
flex-grow: 1;
&.collapsed {
flex-grow: 0;
}
.graph-container-compact {
flex-grow: 1;
border-radius: 6px;
overflow: hidden;
margin-top: 2px;
background-color: #fff;
position: relative;
max-width: 100%;
padding-top: 0;
}
}
}
/* Improve panel transitions */
.query-section,
.graph-section {
display: flex;
flex-direction: column;
overflow: hidden;
border-top: 1px solid var(--gray-300);
&.collapsed {
height: 36px;
min-height: 36px;
}
.content {
padding: 8px;
}
}
/* Improve the resize handle visibility */
.resize-handle,
.resize-handle-horizontal {
transition: all 0.2s ease;
opacity: 0.6;
&:hover {
opacity: 1;
background-color: var(--main-color);
}
}
/* Table row selection styling */
:deep(.ant-table-tbody > tr.ant-table-row-selected > td) {
background-color: var(--main-light-5);
}
:deep(.ant-table-tbody > tr:hover > td) {
background-color: var(--main-light-6);
}
</style>
<style lang="less">
:deep(.full-modal) {
.ant-modal {
max-width: 100%;
top: 0;
padding-bottom: 0;
margin: 0;
padding: 0;
}
.ant-modal-content {
display: flex;
flex-direction: column;
height: calc(100vh - 200px);
}
.ant-modal-body {
flex: 1;
}
}
.maximized-graph-header {
display: flex;
justify-content: space-between;
align-items: center;
h3 {
margin: 0;
color: var(--gray-800);
}
}
.maximized-graph-content {
height: calc(100vh - 300px);
border-radius: 6px;
overflow: hidden;
}
/* 全局样式作为备用方案 */
.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;
}
</style>