fix: 统一知识能力空状态

This commit is contained in:
Wenjie Zhang 2026-06-05 00:35:31 +08:00
parent 742048e9eb
commit 9bcfb0e1f4
5 changed files with 281 additions and 123 deletions

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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 {

View 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>

View File

@ -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;