ForcePilot/web/src/components/GraphDetailPanel.vue
Wenjie Zhang 6225310600 feat(kb): 新增知识图谱向量存储、文件大小补全及前端重构
- 新增 MilvusGraphVectorStore 和 KnowledgeGraphRepository
- 新增文件大小从 MinIO 补全的逻辑(上传时 + 启动时)
- 重构 FileTable、GraphDetailPanel、KnowledgeGraphSection 等前端组件
- 新增文件大小回退测试和 kb_utils 测试
- 优化 file_utils 工具函数
- 更新 roadmap 文档
2026-05-26 17:39:12 +08:00

240 lines
6.3 KiB
Vue

<template>
<transition name="slide-fade-left">
<div class="detail-panel" v-if="visible" @click="handleLinkClick">
<div class="panel-header">
<span class="panel-title">{{ title }}</span>
<X :size="14" class="close-icon" @click="$emit('close')" />
</div>
<div class="panel-body">
<template v-if="item">
<template v-if="type === 'node'">
<div :class="rowClass(item.data?.label)">
<span class="detail-label">名称</span>
<span class="detail-value">
<DetailValue :value="item.data?.label" field-key="__label__" :expanded-keys="expandedKeys" />
</span>
</div>
<div class="detail-row">
<span class="detail-label">ID</span>
<span class="detail-value detail-id">{{ item.id }}</span>
</div>
<template v-if="item.data?.original?.labels">
<div class="detail-row">
<span class="detail-label">标签</span>
<span class="detail-value">
<a-tag v-for="tag in item.data.original.labels" :key="tag" size="small">{{ tag }}</a-tag>
</span>
</div>
</template>
<template v-if="item.data?.original?.properties">
<div v-for="(value, key) in item.data.original.properties" :key="key" :class="rowClass(value)">
<span class="detail-label">{{ key }}</span>
<span class="detail-value">
<DetailValue :value="value" :field-key="key" :expanded-keys="expandedKeys" />
</span>
</div>
</template>
</template>
<template v-else-if="type === 'edge'">
<div :class="rowClass(item.data?.label)">
<span class="detail-label">类型</span>
<span class="detail-value">
<DetailValue :value="item.data?.label" field-key="__type__" :expanded-keys="expandedKeys" />
</span>
</div>
<template v-if="item.data?.original?.properties">
<div v-for="(value, key) in filteredEdgeProperties" :key="key" :class="rowClass(value)">
<span class="detail-label">{{ key }}</span>
<span class="detail-value">
<DetailValue :value="value" :field-key="key" :expanded-keys="expandedKeys" />
</span>
</div>
</template>
</template>
</template>
</div>
</div>
</transition>
</template>
<script setup>
import { computed, reactive, watch, defineComponent, h } from 'vue'
import { X } from 'lucide-vue-next'
const STACK_THRESHOLD = 50
const TRUNCATE_LIMIT = 100
const DetailValue = defineComponent({
props: {
value: [String, Number, Boolean, Object, Array],
fieldKey: { type: String, required: true },
expandedKeys: { type: Set, required: true }
},
setup(props) {
return () => {
const v = props.value
if (typeof v !== 'string') return String(v ?? '')
if (v.length <= TRUNCATE_LIMIT) return v
if (props.expandedKeys.has(props.fieldKey)) {
return [v, h('a', {
class: 'expand-link',
onClick: (e) => { e.preventDefault(); props.expandedKeys.delete(props.fieldKey) }
}, ' 收起')]
}
return [v.slice(0, TRUNCATE_LIMIT) + '...', h('a', {
class: 'expand-link',
onClick: (e) => { e.preventDefault(); props.expandedKeys.add(props.fieldKey) }
}, '展开')]
}
}
})
const props = defineProps({
visible: Boolean,
item: Object,
type: String
})
defineEmits(['close'])
const expandedKeys = reactive(new Set())
watch(() => props.item, () => {
expandedKeys.clear()
})
const isOverThreshold = (value) => typeof value === 'string' && value.length > STACK_THRESHOLD
const rowClass = (value) => {
return isOverThreshold(value) ? 'detail-row detail-row--stack' : 'detail-row'
}
const title = computed(() => {
return props.type === 'node' ? '节点详情' : '关系详情'
})
const filteredEdgeProperties = computed(() => {
if (!props.item?.data?.original?.properties) return {}
const properties = props.item.data.original.properties
const filtered = {}
const hiddenFields = ['source_id', 'target_id', '_id', 'truncate']
Object.keys(properties).forEach((key) => {
if (!hiddenFields.includes(key)) filtered[key] = properties[key]
})
return filtered
})
</script>
<style scoped lang="less">
.detail-panel {
position: absolute;
top: 50px;
left: 10px;
width: 280px;
max-height: calc(100% - 60px);
overflow-y: auto;
z-index: 100;
background: var(--color-trans-light);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-radius: 8px;
border: 1px solid var(--gray-100);
font-size: 13px;
.panel-header {
display: flex;
align-items: center;
padding: 10px 14px;
border-bottom: 1px solid var(--gray-200);
.panel-title {
font-size: 13px;
font-weight: 600;
color: var(--gray-1000);
}
.close-icon {
margin-left: auto;
cursor: pointer;
color: var(--gray-500);
transition: color 0.2s;
&:hover {
color: var(--gray-800);
}
}
}
.panel-body {
padding: 10px 14px;
}
}
.detail-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 6px 0;
border-bottom: 1px solid var(--gray-50);
&:last-child {
border-bottom: none;
}
&--stack {
flex-direction: column;
}
.detail-label {
flex-shrink: 0;
color: var(--gray-500);
font-size: 12px;
margin-right: 8px;
margin-bottom: 2px;
}
.detail-value {
text-align: right;
color: var(--gray-800);
font-size: 13px;
word-break: break-all;
&.detail-id {
font-size: 11px;
color: var(--gray-500);
}
:deep(.expand-link) {
cursor: pointer;
color: var(--main-700);
font-size: 12px;
white-space: nowrap;
margin-left: 2px;
&:hover {
color: var(--main-500);
}
}
}
}
.detail-row--stack {
.detail-value {
text-align: left;
}
}
.slide-fade-left-enter-active {
transition: all 0.25s ease-out;
}
.slide-fade-left-leave-active {
transition: all 0.2s cubic-bezier(1, 0.5, 0.8, 1);
}
.slide-fade-left-enter-from,
.slide-fade-left-leave-to {
transform: translateX(-20px);
opacity: 0;
}
</style>