fix: 统一知识能力空状态
This commit is contained in:
parent
742048e9eb
commit
9bcfb0e1f4
@ -23,11 +23,24 @@
|
||||
|
||||
<!-- 基准列表 -->
|
||||
<div class="benchmarks-list">
|
||||
<div v-if="!loading && benchmarks.length === 0" class="empty-state">
|
||||
<div class="empty-icon">📋</div>
|
||||
<div class="empty-title">暂无评估基准</div>
|
||||
<div class="empty-description">上传或生成评估基准开始使用</div>
|
||||
</div>
|
||||
<ResourceEmptyState
|
||||
v-if="!loading && benchmarks.length === 0"
|
||||
title="暂无评估基准"
|
||||
description="上传数据集,或从当前知识库自动生成评估问题。"
|
||||
:icon="ClipboardList"
|
||||
size="compact"
|
||||
>
|
||||
<template #actions>
|
||||
<a-button type="primary" class="lucide-icon-btn" @click="showUploadModal">
|
||||
<template #icon><Upload :size="16" /></template>
|
||||
上传基准
|
||||
</a-button>
|
||||
<a-button class="lucide-icon-btn" @click="showGenerateModal">
|
||||
<template #icon><Bot :size="16" /></template>
|
||||
自动生成
|
||||
</a-button>
|
||||
</template>
|
||||
</ResourceEmptyState>
|
||||
|
||||
<div v-else-if="loading" class="loading-state">
|
||||
<a-spin size="large" />
|
||||
@ -272,9 +285,19 @@
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, onUnmounted, computed, watch, h } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { Bot, Download, MoreVertical, RefreshCw, Trash2, Upload, X } from 'lucide-vue-next'
|
||||
import {
|
||||
Bot,
|
||||
ClipboardList,
|
||||
Download,
|
||||
MoreVertical,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Upload,
|
||||
X
|
||||
} from 'lucide-vue-next'
|
||||
import { evaluationApi } from '@/apis/knowledge_api'
|
||||
import { useTaskerStore } from '@/stores/tasker'
|
||||
import ResourceEmptyState from '@/components/shared/ResourceEmptyState.vue'
|
||||
import BenchmarkUploadModal from './modals/BenchmarkUploadModal.vue'
|
||||
import BenchmarkGenerateModal from './modals/BenchmarkGenerateModal.vue'
|
||||
|
||||
@ -914,33 +937,6 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 300px;
|
||||
text-align: center;
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--gray-900);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
font-size: 14px;
|
||||
color: var(--gray-600);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@ -65,14 +65,49 @@
|
||||
</div>
|
||||
</template>
|
||||
</GraphCanvas>
|
||||
<div
|
||||
v-if="isMilvus && !graphBuildStatus?.locked && !graphBuildLoading"
|
||||
class="graph-config-empty"
|
||||
<ResourceEmptyState
|
||||
v-if="showGraphConfigEmpty"
|
||||
class="graph-empty-state"
|
||||
title="暂无知识图谱"
|
||||
description="配置抽取器后,才能从当前知识库构建实体与关系。"
|
||||
:icon="Network"
|
||||
full-height
|
||||
>
|
||||
<div class="empty-title">尚未配置图谱抽取器</div>
|
||||
<div class="empty-description">配置抽取器后才能从知识库 Chunk 构建实体与关系。</div>
|
||||
<a-button type="primary" @click="openGraphConfig"> 配置抽取器 </a-button>
|
||||
</div>
|
||||
<template #actions>
|
||||
<a-button type="primary" class="lucide-icon-btn" @click="openGraphConfig">
|
||||
<Settings :size="16" />
|
||||
配置抽取器
|
||||
</a-button>
|
||||
</template>
|
||||
</ResourceEmptyState>
|
||||
<ResourceEmptyState
|
||||
v-else-if="showGraphDataEmpty"
|
||||
class="graph-empty-state"
|
||||
:title="graphDataEmptyTitle"
|
||||
:description="graphDataEmptyDescription"
|
||||
:icon="Network"
|
||||
full-height
|
||||
>
|
||||
<template #actions>
|
||||
<a-button v-if="searchInput.trim()" class="lucide-icon-btn" @click="clearGraphSearch">
|
||||
<Search :size="16" />
|
||||
清空搜索
|
||||
</a-button>
|
||||
<a-button
|
||||
v-else-if="hasPendingGraphChunks && !isBuildActive"
|
||||
type="primary"
|
||||
class="lucide-icon-btn"
|
||||
@click="startGraphBuild"
|
||||
>
|
||||
<Database :size="16" />
|
||||
开始索引
|
||||
</a-button>
|
||||
<a-button v-else class="lucide-icon-btn" @click="loadGraph">
|
||||
<RefreshCw :size="16" :class="{ spin: graph.fetching }" />
|
||||
刷新图谱
|
||||
</a-button>
|
||||
</template>
|
||||
</ResourceEmptyState>
|
||||
|
||||
<!-- 详情浮动卡片 -->
|
||||
<GraphDetailPanel
|
||||
@ -320,11 +355,13 @@ import {
|
||||
Search,
|
||||
Loader2,
|
||||
Database,
|
||||
Network,
|
||||
BrainCircuit,
|
||||
ScanText
|
||||
} from 'lucide-vue-next'
|
||||
import GraphCanvas from '@/components/GraphCanvas.vue'
|
||||
import GraphDetailPanel from '@/components/GraphDetailPanel.vue'
|
||||
import ResourceEmptyState from '@/components/shared/ResourceEmptyState.vue'
|
||||
import { getKbTypeLabel } from '@/utils/kb_utils'
|
||||
import { unifiedApi } from '@/apis/graph_api'
|
||||
import { graphBuildApi } from '@/apis/knowledge_api'
|
||||
@ -472,9 +509,31 @@ const graphConfigForm = reactive({
|
||||
})
|
||||
|
||||
const graph = reactive(useGraph(graphRef))
|
||||
const graphLoaded = ref(false)
|
||||
|
||||
// 计算属性:是否支持知识图谱
|
||||
const isGraphSupported = computed(() => GRAPH_SUPPORTED_KB_TYPES.has(kbType.value?.toLowerCase()))
|
||||
const hasGraphNodes = computed(() => graph.graphData.nodes.length > 0)
|
||||
const showGraphConfigEmpty = computed(
|
||||
() => isMilvus.value && !graphBuildStatus.value?.locked && !graphBuildLoading.value
|
||||
)
|
||||
const showGraphDataEmpty = computed(
|
||||
() =>
|
||||
isMilvus.value &&
|
||||
Boolean(graphBuildStatus.value?.locked) &&
|
||||
graphLoaded.value &&
|
||||
!graph.fetching &&
|
||||
!hasGraphNodes.value
|
||||
)
|
||||
const graphDataEmptyTitle = computed(() =>
|
||||
searchInput.value.trim() ? '未找到匹配实体' : '暂无知识图谱'
|
||||
)
|
||||
const graphDataEmptyDescription = computed(() => {
|
||||
if (searchInput.value.trim()) return '换个关键词或调整图谱设置后再搜索。'
|
||||
if (isBuildActive.value) return '图谱索引正在运行,完成后会展示实体与关系。'
|
||||
if (hasPendingGraphChunks.value) return '当前还有待索引 Chunk,完成索引后会展示实体与关系。'
|
||||
return '当前知识库还没有可展示的实体与关系。'
|
||||
})
|
||||
|
||||
let pendingLoadTimer = null
|
||||
let graphStatusRequestSeq = 0
|
||||
@ -604,6 +663,7 @@ const resetGraphBuild = async () => {
|
||||
clear_config: true
|
||||
})
|
||||
message.success('图谱构建状态已重置')
|
||||
graphLoaded.value = false
|
||||
graph.clearGraph()
|
||||
await loadGraphBuildStatus()
|
||||
} catch (e) {
|
||||
@ -618,6 +678,9 @@ const loadGraph = async () => {
|
||||
const requestSeq = ++graphLoadRequestSeq
|
||||
const currentDatabaseId = kbId.value
|
||||
graph.fetching = true
|
||||
if (!hasGraphNodes.value) {
|
||||
graphLoaded.value = false
|
||||
}
|
||||
try {
|
||||
const res = await unifiedApi.getSubgraph({
|
||||
kb_id: currentDatabaseId,
|
||||
@ -641,6 +704,7 @@ const loadGraph = async () => {
|
||||
} finally {
|
||||
if (requestSeq === graphLoadRequestSeq) {
|
||||
graph.fetching = false
|
||||
graphLoaded.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -654,6 +718,11 @@ const onSearch = () => {
|
||||
loadGraph()
|
||||
}
|
||||
|
||||
const clearGraphSearch = () => {
|
||||
searchInput.value = ''
|
||||
loadGraph()
|
||||
}
|
||||
|
||||
const scheduleGraphLoad = (delay = 200) => {
|
||||
if (!props.active || !isGraphSupported.value || !kbId.value) {
|
||||
return
|
||||
@ -687,6 +756,7 @@ watch(
|
||||
watch(kbId, () => {
|
||||
graphStatusRequestSeq += 1
|
||||
graphLoadRequestSeq += 1
|
||||
graphLoaded.value = false
|
||||
graph.clearGraph()
|
||||
graphBuildStatus.value = null
|
||||
if (isMilvus.value) {
|
||||
@ -699,6 +769,7 @@ watch(kbId, () => {
|
||||
|
||||
watch(isGraphSupported, (supported) => {
|
||||
if (!supported) {
|
||||
graphLoaded.value = false
|
||||
graph.clearGraph()
|
||||
graphBuildStatus.value = null
|
||||
return
|
||||
@ -741,30 +812,13 @@ onUnmounted(() => {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.graph-config-empty {
|
||||
.graph-empty-state {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
text-align: center;
|
||||
|
||||
.empty-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--gray-1000);
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
font-size: 13px;
|
||||
color: var(--gray-600);
|
||||
}
|
||||
|
||||
:deep(.ant-btn) {
|
||||
:deep(.resource-empty-state__actions) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,13 +3,19 @@
|
||||
<!-- 评估结果区域 -->
|
||||
<div class="evaluation-results">
|
||||
<template v-if="!selectedDataset">
|
||||
<div class="empty-state">
|
||||
<a-empty description="暂无可用评估基准,请前往基准管理">
|
||||
<a-space>
|
||||
<a-button @click="$emit('switch-to-benchmarks')"> 前往基准管理 </a-button>
|
||||
</a-space>
|
||||
</a-empty>
|
||||
</div>
|
||||
<ResourceEmptyState
|
||||
class="rag-evaluation-empty"
|
||||
title="暂无可用评估基准"
|
||||
description="先上传或生成评估基准,再运行 RAG 评估。"
|
||||
:icon="BarChart3"
|
||||
>
|
||||
<template #actions>
|
||||
<a-button type="primary" class="lucide-icon-btn" @click="$emit('switch-to-benchmarks')">
|
||||
<ClipboardList :size="16" />
|
||||
前往基准管理
|
||||
</a-button>
|
||||
</template>
|
||||
</ResourceEmptyState>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="last-evaluation-section">
|
||||
@ -445,7 +451,8 @@ import { ref, reactive, onMounted, onUnmounted, computed, watch, h } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { evaluationApi } from '@/apis/knowledge_api'
|
||||
import ModelSelectorComponent from '@/components/ModelSelectorComponent.vue'
|
||||
import { ChevronDown, RefreshCw, X } from 'lucide-vue-next'
|
||||
import { BarChart3, ChevronDown, ClipboardList, RefreshCw, X } from 'lucide-vue-next'
|
||||
import ResourceEmptyState from '@/components/shared/ResourceEmptyState.vue'
|
||||
import { useTaskerStore } from '@/stores/tasker'
|
||||
|
||||
const props = defineProps({
|
||||
@ -1349,14 +1356,8 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
.rag-evaluation-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--gray-0);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
|
||||
130
web/src/components/shared/ResourceEmptyState.vue
Normal file
130
web/src/components/shared/ResourceEmptyState.vue
Normal file
@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div
|
||||
class="resource-empty-state"
|
||||
:class="{
|
||||
'resource-empty-state--compact': size === 'compact',
|
||||
'resource-empty-state--full-height': fullHeight
|
||||
}"
|
||||
>
|
||||
<div v-if="icon" class="resource-empty-state__icon" aria-hidden="true">
|
||||
<component :is="icon" :size="iconSize" :stroke-width="1.8" />
|
||||
</div>
|
||||
<h3 class="resource-empty-state__title">{{ title }}</h3>
|
||||
<p v-if="description" class="resource-empty-state__description">{{ description }}</p>
|
||||
<div v-if="$slots.actions" class="resource-empty-state__actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { Inbox } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, required: true },
|
||||
description: { type: String, default: '' },
|
||||
icon: { type: [Object, Function, String], default: () => Inbox },
|
||||
size: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
validator: (value) => ['default', 'compact'].includes(value)
|
||||
},
|
||||
fullHeight: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const iconSize = computed(() => (props.size === 'compact' ? 22 : 24))
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.resource-empty-state {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 300px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 72px 20px;
|
||||
text-align: center;
|
||||
|
||||
&--compact {
|
||||
min-height: 240px;
|
||||
padding: 48px 16px;
|
||||
|
||||
.resource-empty-state__icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.resource-empty-state__title {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&--full-height {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 32px 20px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: flex;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--gray-150);
|
||||
border-radius: 8px;
|
||||
background: var(--main-30);
|
||||
color: var(--main-color);
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
color: var(--gray-900);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
&__description {
|
||||
max-width: 360px;
|
||||
margin: 8px 0 0;
|
||||
color: var(--gray-600);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.resource-empty-state {
|
||||
min-height: 260px;
|
||||
padding: 56px 16px;
|
||||
|
||||
&--compact {
|
||||
min-height: 220px;
|
||||
padding: 40px 16px;
|
||||
}
|
||||
|
||||
&--full-height {
|
||||
min-height: 0;
|
||||
padding: 28px 16px;
|
||||
}
|
||||
|
||||
&__description {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -27,10 +27,11 @@
|
||||
<template #actions>
|
||||
<a-button
|
||||
type="primary"
|
||||
class="lucide-icon-btn"
|
||||
:disabled="!kbTypes.length"
|
||||
@click="state.openNewDatabaseModel = true"
|
||||
>
|
||||
<PlusOutlined /> 新建知识库
|
||||
<Plus :size="16" /> 新建知识库
|
||||
</a-button>
|
||||
</template>
|
||||
</PageShoulder>
|
||||
@ -182,21 +183,27 @@
|
||||
</div>
|
||||
|
||||
<!-- 空状态显示 -->
|
||||
<div v-else-if="!databases || databases.length === 0" class="empty-state">
|
||||
<h3 class="empty-title">暂无知识库</h3>
|
||||
<p class="empty-description">创建您的第一个知识库,开始管理文档和知识</p>
|
||||
<a-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:disabled="!kbTypes.length"
|
||||
@click="state.openNewDatabaseModel = true"
|
||||
>
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
创建知识库
|
||||
</a-button>
|
||||
</div>
|
||||
<ResourceEmptyState
|
||||
v-else-if="!databases || databases.length === 0"
|
||||
title="暂无知识库"
|
||||
description="创建知识库后,可以上传文件并配置检索、图谱和评估能力。"
|
||||
:icon="getKbTypeIcon('milvus')"
|
||||
>
|
||||
<template #actions>
|
||||
<a-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="lucide-icon-btn"
|
||||
:disabled="!kbTypes.length"
|
||||
@click="state.openNewDatabaseModel = true"
|
||||
>
|
||||
<template #icon>
|
||||
<Plus :size="16" />
|
||||
</template>
|
||||
创建知识库
|
||||
</a-button>
|
||||
</template>
|
||||
</ResourceEmptyState>
|
||||
|
||||
<!-- 数据库列表 -->
|
||||
<ExtensionCardGrid v-else>
|
||||
@ -224,11 +231,13 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { useDatabaseStore } from '@/stores/database'
|
||||
import { PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons-vue'
|
||||
import { QuestionCircleOutlined } from '@ant-design/icons-vue'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { typeApi } from '@/apis/knowledge_api'
|
||||
import PageHeader from '@/components/shared/PageHeader.vue'
|
||||
import PageShoulder from '@/components/shared/PageShoulder.vue'
|
||||
import ResourceEmptyState from '@/components/shared/ResourceEmptyState.vue'
|
||||
import EmbeddingModelSelector from '@/components/EmbeddingModelSelector.vue'
|
||||
import ShareConfigForm from '@/components/ShareConfigForm.vue'
|
||||
import ExtensionCardGrid from '@/components/extensions/ExtensionCardGrid.vue'
|
||||
@ -736,38 +745,6 @@ defineExpose({
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 100px 20px;
|
||||
text-align: center;
|
||||
|
||||
.empty-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--gray-900);
|
||||
margin: 0 0 12px 0;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
font-size: 14px;
|
||||
color: var(--gray-600);
|
||||
margin: 0 0 32px 0;
|
||||
line-height: 1.5;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
height: 44px;
|
||||
padding: 0 24px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user