ForcePilot/web/src/components/KnowledgeGraphSection.vue
2026-06-05 00:35:31 +08:00

1162 lines
32 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="graph-section" v-if="isGraphSupported">
<div class="graph-container-compact">
<div v-if="!isGraphSupported" class="graph-disabled">
<div class="disabled-content">
<h4>知识图谱不可用</h4>
<p>当前知识库类型 "{{ kbTypeLabel }}" 不支持知识图谱功能。</p>
<p>只有 Milvus 类型的知识库支持知识图谱。</p>
</div>
</div>
<div v-else class="graph-wrapper">
<GraphCanvas
ref="graphRef"
:graph-data="graph.graphData"
@node-click="graph.handleNodeClick"
@edge-click="graph.handleEdgeClick"
@canvas-click="graph.handleCanvasClick"
>
<template #top>
<div class="compact-actions">
<div class="actions-left">
<a-input
v-model:value="searchInput"
placeholder="搜索实体"
style="width: 240px"
@keydown.enter="onSearch"
allow-clear
>
<template #suffix>
<component
:is="graph.fetching ? Loader2 : Search"
:size="14"
class="search-suffix-icon"
@click="onSearch"
/>
</template>
</a-input>
<a-button class="action-btn" @click="loadGraph" title="刷新">
<RefreshCw :size="16" :class="{ spin: graph.fetching }" />
</a-button>
</div>
<div class="actions-right">
<a-button
v-if="isMilvus"
class="action-btn index-action-btn"
:class="{ 'has-index-label': hasPendingGraphChunks }"
@click="toggleBuildPanel"
:title="graphIndexButtonTitle"
:aria-label="graphIndexButtonTitle"
>
<Database :size="16" />
<span v-if="hasPendingGraphChunks" class="index-status-label"
>{{ pendingGraphChunks }} 待索引</span
>
<span
v-if="graphIndexDotStatus"
class="status-dot"
:class="`status-dot--${graphIndexDotStatus}`"
></span>
</a-button>
<a-button class="action-btn" @click="toggleSettingsPanel" title="设置">
<Settings :size="16" />
</a-button>
</div>
</div>
</template>
</GraphCanvas>
<ResourceEmptyState
v-if="showGraphConfigEmpty"
class="graph-empty-state"
title="暂无知识图谱"
description="配置抽取器后,才能从当前知识库构建实体与关系。"
:icon="Network"
full-height
>
<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
:visible="graph.showDetailDrawer"
:item="graph.selectedItem"
:type="graph.selectedItemType"
@close="graph.handleCanvasClick"
/>
<!-- 设置浮动面板 -->
<transition name="slide-fade">
<div v-if="showSettings" class="floating-panel settings-panel">
<div class="panel-header">
<span class="panel-title">图谱设置</span>
</div>
<div class="panel-body">
<a-form layout="vertical">
<a-form-item label="最大节点数 (limit)">
<a-input-number
v-model:value="subgraphParams.maxNodes"
:min="10"
:max="1000"
:step="10"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="搜索深度 (depth)">
<a-input-number
v-model:value="subgraphParams.maxDepth"
:min="1"
:max="5"
:step="1"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="排除 Chunk 节点">
<a-switch v-model:checked="subgraphParams.excludeChunk" />
</a-form-item>
<a-form-item>
<a-button type="primary" @click="applySettings" style="width: 100%">
应用
</a-button>
</a-form-item>
</a-form>
</div>
</div>
</transition>
<!-- 索引管理浮动面板 -->
<transition name="slide-fade">
<div v-if="isMilvus && showBuildPanel" class="floating-panel build-panel">
<div class="panel-header">
<span class="panel-title">索引管理</span>
<a-button
size="small"
type="text"
:disabled="graphBuildLoading"
@click="loadGraphBuildStatus"
class="panel-refresh-btn"
>
<RefreshCw :size="14" :class="{ spin: graphBuildLoading }" />
</a-button>
</div>
<div class="panel-body">
<div class="status-row">
<span class="status-label">状态</span>
<a-tag v-if="isBuildActive" color="blue" size="small">构建中</a-tag>
<a-tag v-else-if="isBuildFailed" color="red" size="small">构建失败</a-tag>
<a-tag v-else-if="graphBuildStatus?.locked" color="green" size="small"
>已配置</a-tag
>
<a-tag v-else color="orange" size="small">未配置</a-tag>
</div>
<a-progress
v-if="isBuildActive"
:percent="graphBuildStatus?.build_task_progress ?? 0"
:stroke-color="{ '0%': '#108ee9', '100%': '#87d068' }"
size="small"
style="margin-bottom: 10px"
/>
<div class="stats-grid">
<div class="stat-item">
<span class="stat-value">{{ graphBuildStatus?.total_chunks ?? '-' }}</span>
<span class="stat-label">总 Chunk</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ graphBuildStatus?.pending_chunks ?? '-' }}</span>
<span class="stat-label">待构建</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ graphBuildStatus?.indexed_chunks ?? '-' }}</span>
<span class="stat-label">已构建</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ graphBuildStatus?.entity_count ?? '-' }}</span>
<span class="stat-label">实体</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ graphBuildStatus?.relationship_count ?? '-' }}</span>
<span class="stat-label">关系</span>
</div>
</div>
<div class="build-actions">
<a-button
v-if="!graphBuildStatus?.locked"
type="primary"
block
@click="openGraphConfig"
>
配置抽取器
</a-button>
<a-button v-else-if="isBuildActive" type="primary" block disabled>
构建中 {{ graphBuildStatus?.build_task_progress ?? 0 }}%
</a-button>
<a-button
v-else-if="isBuildFailed"
type="primary"
block
:disabled="!graphBuildStatus?.pending_chunks"
@click="startGraphBuild"
>
重试索引
</a-button>
<a-button
v-else
type="primary"
block
:disabled="!graphBuildStatus?.pending_chunks"
@click="startGraphBuild"
>
开始索引
</a-button>
<div class="actions-secondary">
<a-button
v-if="graphBuildStatus?.locked && !isBuildActive"
size="small"
type="text"
@click="openGraphConfig"
>
修改配置
</a-button>
<a-button
size="small"
type="text"
danger
v-if="graphBuildStatus?.locked && !isBuildActive"
@click="confirmResetGraph"
>重置</a-button
>
</div>
</div>
</div>
</div>
</transition>
</div>
</div>
<a-modal
v-model:open="showGraphConfig"
:title="graphConfigTitle"
width="640px"
@ok="configureGraphBuild"
>
<a-form layout="vertical">
<a-alert
v-if="isEditingGraphConfig"
class="config-warning"
type="warning"
show-icon
message="修改配置仅影响后续构建;已构建的图谱不会自动重算,如需一致请重置后重新抽取。抽取器类型创建后不可修改。"
/>
<a-form-item label="抽取器类型">
<div class="extractor-type-cards" role="radiogroup" aria-label="抽取器类型">
<div
v-for="option in extractorTypeOptions"
:key="option.value"
class="extractor-type-card"
:class="{
active: graphConfigForm.extractor_type === option.value,
disabled: isEditingGraphConfig || option.disabled
}"
role="radio"
:aria-checked="graphConfigForm.extractor_type === option.value"
:aria-disabled="isEditingGraphConfig || option.disabled"
:tabindex="isEditingGraphConfig || option.disabled ? -1 : 0"
@click="selectExtractorType(option)"
@keydown.enter.prevent="selectExtractorType(option)"
@keydown.space.prevent="selectExtractorType(option)"
>
<div class="card-header">
<component :is="option.icon" class="type-icon" />
<span class="type-title">{{ option.label }}</span>
</div>
<div class="card-description">{{ option.description }}</div>
<div v-if="option.helper" class="card-helper" :class="{ warning: option.disabled }">
{{ option.helper }}
</div>
</div>
</div>
</a-form-item>
<a-form-item label="模型">
<ModelSelectorComponent
:model_spec="graphConfigForm.model_spec"
placeholder="选择抽取模型"
@select-model="(spec) => (graphConfigForm.model_spec = spec)"
/>
</a-form-item>
<a-form-item label="Schema">
<a-textarea
v-model:value="graphConfigForm.schema"
:rows="6"
placeholder="描述实体类型、关系类型和属性约束。后端会把 Schema 拼接到固定抽取 Prompt 中。"
/>
</a-form-item>
<div class="form-grid two-columns">
<a-form-item label="并发队列数">
<a-input-number
v-model:value="graphConfigForm.concurrency_count"
:min="1"
:max="1000"
:step="1"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="模型参数 JSON">
<a-input
v-model:value="graphConfigForm.model_params_text"
placeholder='例如 {"temperature":0.1}'
/>
</a-form-item>
</div>
</a-form>
</a-modal>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick, onUnmounted, reactive } from 'vue'
import { useDatabaseStore } from '@/stores/database'
import { useTaskerStore } from '@/stores/tasker'
import { useConfigStore } from '@/stores/config'
import {
RefreshCw,
Settings,
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'
import { Modal, message } from 'ant-design-vue'
import ModelSelectorComponent from '@/components/ModelSelectorComponent.vue'
import { useGraph } from '@/composables/useGraph'
const GRAPH_BUILD_TASK_TYPE = 'knowledge_graph_index'
const MILVUS_KB_TYPE = 'milvus'
const GRAPH_SUPPORTED_KB_TYPES = new Set([MILVUS_KB_TYPE])
const props = defineProps({
active: {
type: Boolean,
default: false
}
})
const store = useDatabaseStore()
const taskerStore = useTaskerStore()
const configStore = useConfigStore()
const kbId = computed(() => store.kbId)
const kbType = computed(() => store.database.kb_type)
const kbTypeLabel = computed(() => getKbTypeLabel(kbType.value || 'milvus'))
const isMilvus = computed(() => kbType.value?.toLowerCase() === MILVUS_KB_TYPE)
const graphRef = ref(null)
const showSettings = ref(false)
const showBuildPanel = ref(false)
const subgraphParams = reactive({
maxNodes: 100,
maxDepth: 2,
excludeChunk: true
})
const searchInput = ref('')
const graphBuildStatus = ref(null)
const graphBuildLoading = ref(false)
const showGraphConfig = ref(false)
let buildStatusPollTimer = null
const extractorTypeOptions = [
{
value: 'llm',
label: 'LLM',
description: '使用大模型按 Schema 抽取实体和关系',
helper: '当前唯一支持的图谱抽取方式',
icon: BrainCircuit,
disabled: false
},
{
value: 'more',
label: '更多',
description: '更多抽取方式正在拓展中',
helper: '拓展中',
icon: ScanText,
disabled: true
}
]
const isBuildActive = computed(() => {
const s = graphBuildStatus.value?.build_task_status
return s === 'pending' || s === 'running'
})
const isBuildFailed = computed(() => {
return graphBuildStatus.value?.build_task_status === 'failed'
})
const pendingGraphChunks = computed(() => {
return Number(graphBuildStatus.value?.pending_chunks ?? 0)
})
const hasPendingGraphChunks = computed(() => pendingGraphChunks.value > 0)
const isGraphIndexComplete = computed(() => {
return (
Boolean(graphBuildStatus.value?.locked) &&
!isBuildActive.value &&
pendingGraphChunks.value === 0
)
})
const graphIndexDotStatus = computed(() => {
if (isBuildActive.value) return 'active'
if (hasPendingGraphChunks.value) return 'pending'
if (isGraphIndexComplete.value) return 'complete'
return ''
})
const graphIndexButtonTitle = computed(() => {
if (hasPendingGraphChunks.value) return `索引管理,${pendingGraphChunks.value} 待索引`
if (isGraphIndexComplete.value) return '索引管理,已全部索引'
if (isBuildActive.value) return '索引管理,索引中'
return '索引管理'
})
const toggleBuildPanel = () => {
showBuildPanel.value = !showBuildPanel.value
showSettings.value = false
}
const toggleSettingsPanel = () => {
showSettings.value = !showSettings.value
showBuildPanel.value = false
}
const isEditingGraphConfig = computed(() => Boolean(graphBuildStatus.value?.locked))
const graphConfigTitle = computed(() =>
isEditingGraphConfig.value ? '修改图谱抽取配置' : '配置图谱抽取器'
)
const stopBuildStatusPoll = () => {
if (buildStatusPollTimer) {
clearInterval(buildStatusPollTimer)
buildStatusPollTimer = null
}
}
const startBuildStatusPoll = () => {
stopBuildStatusPoll()
buildStatusPollTimer = setInterval(() => {
loadGraphBuildStatus()
}, 5000)
}
watch(
isBuildActive,
(active) => {
if (active) {
startBuildStatusPoll()
} else {
stopBuildStatusPoll()
}
},
{ immediate: true }
)
const graphConfigForm = reactive({
extractor_type: 'llm',
model_spec: '',
schema: '',
concurrency_count: 50,
model_params_text: ''
})
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
let graphLoadRequestSeq = 0
const getErrorDetail = (e, fallback) => {
return e?.response?.data?.detail || e?.response?.data?.message || e?.message || fallback
}
const loadGraphBuildStatus = async () => {
if (!kbId.value || !isMilvus.value) return
const requestSeq = ++graphStatusRequestSeq
const currentDatabaseId = kbId.value
graphBuildLoading.value = true
try {
const status = await graphBuildApi.getStatus(currentDatabaseId)
if (requestSeq === graphStatusRequestSeq && currentDatabaseId === kbId.value) {
graphBuildStatus.value = status
}
} catch (e) {
console.error('Failed to load graph build status:', e)
message.error('加载图谱构建状态失败')
} finally {
if (requestSeq === graphStatusRequestSeq) {
graphBuildLoading.value = false
}
}
}
const parseModelParams = () => {
const text = graphConfigForm.model_params_text.trim()
if (!text) return {}
let params
try {
params = JSON.parse(text)
} catch {
throw new Error('模型参数必须是合法 JSON 对象')
}
if (!params || Array.isArray(params) || typeof params !== 'object') {
throw new Error('模型参数必须是 JSON 对象')
}
return params
}
const fillGraphConfigForm = () => {
const config = graphBuildStatus.value?.config
const options = config?.extractor_options || {}
graphConfigForm.extractor_type = 'llm'
graphConfigForm.model_spec = options.model_spec || configStore.config?.default_model || ''
graphConfigForm.schema = options.schema || ''
graphConfigForm.concurrency_count = Number(options.concurrency_count || 50)
graphConfigForm.model_params_text = options.model_params
? JSON.stringify(options.model_params)
: ''
}
const openGraphConfig = () => {
fillGraphConfigForm()
showGraphConfig.value = true
}
const selectExtractorType = (option) => {
if (isEditingGraphConfig.value || option.disabled) return
graphConfigForm.extractor_type = option.value
}
const buildExtractorOptions = () => {
return {
model_spec: graphConfigForm.model_spec,
schema: graphConfigForm.schema.trim(),
concurrency_count: graphConfigForm.concurrency_count || 50,
model_params: parseModelParams()
}
}
const configureGraphBuild = async () => {
try {
document.activeElement?.blur()
await nextTick()
await graphBuildApi.configure(kbId.value, {
extractor_type: 'llm',
extractor_options: buildExtractorOptions()
})
message.success(isEditingGraphConfig.value ? '图谱抽取配置已更新' : '图谱抽取配置已保存')
showGraphConfig.value = false
await loadGraphBuildStatus()
} catch (e) {
console.error('Failed to configure graph build:', e)
message.error(getErrorDetail(e, '配置图谱抽取失败'))
}
}
const startGraphBuild = async () => {
try {
const data = await graphBuildApi.startIndex(kbId.value, 20)
message.success(data.message || '图谱构建任务已提交')
if (data.task_id) {
taskerStore.registerQueuedTask({
task_id: data.task_id,
name: `图谱构建 (${kbId.value})`,
task_type: GRAPH_BUILD_TASK_TYPE,
message: data.message,
payload: { kb_id: kbId.value }
})
}
await loadGraphBuildStatus()
} catch (e) {
console.error('Failed to start graph build:', e)
message.error(getErrorDetail(e, '提交图谱构建任务失败'))
}
}
const confirmResetGraph = () => {
Modal.confirm({
title: '清空并重建图谱',
content: '将删除该知识库在 Neo4j 中的图谱,重置 Chunk 图谱状态,并清空抽取结果与配置。',
okText: '确认重置',
cancelText: '取消',
onOk: resetGraphBuild
})
}
const resetGraphBuild = async () => {
try {
await graphBuildApi.reset(kbId.value, {
clear_extraction_result: true,
clear_config: true
})
message.success('图谱构建状态已重置')
graphLoaded.value = false
graph.clearGraph()
await loadGraphBuildStatus()
} catch (e) {
console.error('Failed to reset graph build:', e)
message.error(getErrorDetail(e, '重置图谱构建状态失败'))
}
}
const loadGraph = async () => {
if (!kbId.value || !isGraphSupported.value) return
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,
node_label: searchInput.value || '*',
max_nodes: subgraphParams.maxNodes,
max_depth: subgraphParams.maxDepth,
exclude_chunk: subgraphParams.excludeChunk
})
if (
requestSeq === graphLoadRequestSeq &&
currentDatabaseId === kbId.value &&
res.success &&
res.data
) {
graph.updateGraphData(res.data.nodes, res.data.edges)
}
} catch (e) {
console.error('Failed to load graph:', e)
message.error('加载图谱失败')
} finally {
if (requestSeq === graphLoadRequestSeq) {
graph.fetching = false
graphLoaded.value = true
}
}
}
const applySettings = () => {
showSettings.value = false
loadGraph()
}
const onSearch = () => {
loadGraph()
}
const clearGraphSearch = () => {
searchInput.value = ''
loadGraph()
}
const scheduleGraphLoad = (delay = 200) => {
if (!props.active || !isGraphSupported.value || !kbId.value) {
return
}
if (pendingLoadTimer) {
clearTimeout(pendingLoadTimer)
}
pendingLoadTimer = setTimeout(async () => {
pendingLoadTimer = null
await nextTick()
if (props.active && isGraphSupported.value && kbId.value) {
await loadGraph()
}
}, delay)
}
watch(
() => props.active,
(active) => {
if (active) {
if (isMilvus.value) {
loadGraphBuildStatus()
}
scheduleGraphLoad()
}
},
{ immediate: true }
)
watch(kbId, () => {
graphStatusRequestSeq += 1
graphLoadRequestSeq += 1
graphLoaded.value = false
graph.clearGraph()
graphBuildStatus.value = null
if (isMilvus.value) {
loadGraphBuildStatus()
}
if (isGraphSupported.value) {
scheduleGraphLoad(300)
}
})
watch(isGraphSupported, (supported) => {
if (!supported) {
graphLoaded.value = false
graph.clearGraph()
graphBuildStatus.value = null
return
}
if (isMilvus.value) {
loadGraphBuildStatus()
}
scheduleGraphLoad(200)
})
onUnmounted(() => {
if (pendingLoadTimer) {
clearTimeout(pendingLoadTimer)
pendingLoadTimer = null
}
stopBuildStatusPoll()
})
</script>
<style scoped lang="less">
.graph-section {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
user-select: none;
}
.graph-container-compact {
flex: 1;
min-height: 0;
overflow: hidden;
position: relative;
}
.graph-wrapper {
height: 100%;
width: 100%;
position: relative;
}
.graph-empty-state {
position: absolute;
inset: 0;
z-index: 30;
pointer-events: none;
:deep(.resource-empty-state__actions) {
pointer-events: auto;
}
}
.compact-actions {
position: absolute;
top: 10px;
left: 10px;
right: 10px;
display: flex;
justify-content: space-between;
align-items: center;
pointer-events: none; /* Let clicks pass through empty areas */
.actions-left,
.actions-right {
pointer-events: auto; /* Re-enable clicks for buttons/inputs */
display: flex;
align-items: center;
gap: 4px;
background: var(--color-trans-light);
backdrop-filter: blur(12px);
padding: 2px;
border-radius: 8px;
box-shadow: 0 0 4px 0px var(--shadow-2);
border: 1px solid var(--gray-100);
}
:deep(.ant-input-affix-wrapper) {
padding: 4px 11px;
border-radius: 6px;
border-color: transparent;
box-shadow: none;
background: var(--color-trans-light);
&:hover,
&:focus,
&-focused {
background: var(--main-0);
border-color: var(--primary-color);
}
input {
background: transparent;
}
}
.action-btn {
width: 32px;
height: 32px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--gray-600);
border-radius: 6px;
box-shadow: none;
position: relative;
&:hover {
background: var(--shadow-1);
color: var(--primary-color);
}
}
.index-action-btn {
gap: 6px;
overflow: visible;
&.has-index-label {
width: auto;
min-width: 84px;
padding: 0 22px 0 8px;
justify-content: flex-start;
}
.index-status-label {
font-size: 12px;
line-height: 1;
color: var(--gray-700);
white-space: nowrap;
}
}
.status-dot {
position: absolute;
bottom: 4px;
right: 4px;
width: 7px;
height: 7px;
border-radius: 50%;
box-shadow: 0 0 0 1px var(--color-trans-light);
}
.status-dot--pending {
background: var(--color-warning-500);
}
.status-dot--active {
background: var(--color-warning-500);
animation: blink 1.2s ease-in-out infinite;
}
.status-dot--complete {
background: var(--color-success-500);
}
.search-suffix-icon {
cursor: pointer;
}
.spin {
animation: spin 1s linear infinite;
}
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.2;
}
}
.graph-disabled {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
.disabled-content {
text-align: center;
color: var(--gray-400);
h4 {
margin-bottom: 8px;
}
}
.floating-panel {
position: absolute;
top: 60px;
right: 10px;
width: 300px;
max-height: calc(100% - 60px);
overflow-y: auto;
z-index: 100;
background: var(--color-trans-light);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-radius: 8px;
border: 1px solid var(--gray-100);
box-shadow: 0 0 4px 0px var(--shadow-2);
font-size: 13px;
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid var(--gray-200);
.panel-title {
font-size: 13px;
font-weight: 600;
color: var(--gray-1000);
}
.panel-refresh-btn {
padding: 2px 6px;
}
}
.panel-body {
padding: 10px 14px;
}
}
.build-panel {
.status-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
.status-label {
color: var(--gray-600);
font-size: 12px;
}
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-bottom: 12px;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 6px 4px;
border-radius: 4px;
background: var(--gray-50);
.stat-value {
font-size: 15px;
font-weight: 600;
color: var(--gray-1000);
line-height: 1.2;
}
.stat-label {
font-size: 11px;
color: var(--gray-500);
margin-top: 2px;
}
}
.build-actions {
display: flex;
flex-direction: column;
gap: 8px;
}
.actions-secondary {
display: flex;
justify-content: space-between;
}
}
.config-warning {
margin-bottom: 16px;
}
.extractor-type-cards {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
.extractor-type-card {
border: 1px solid var(--gray-150);
border-radius: 8px;
padding: 14px;
cursor: pointer;
transition: all 0.2s ease;
background: var(--gray-0);
&:hover {
border-color: var(--main-color);
}
&.active {
border-color: var(--main-color);
background: var(--main-10);
box-shadow: 0 0 0 1px var(--main-20);
.type-icon {
color: var(--main-color);
}
}
&.disabled {
cursor: not-allowed;
opacity: 0.72;
background: var(--gray-50);
&:hover {
border-color: var(--gray-150);
}
}
.card-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.type-icon {
width: 20px;
height: 20px;
color: var(--main-color);
flex-shrink: 0;
}
.type-title {
font-size: 15px;
font-weight: 600;
color: var(--gray-800);
}
.card-description {
font-size: 13px;
color: var(--gray-600);
line-height: 1.5;
}
.card-helper {
margin-top: 8px;
font-size: 12px;
color: var(--gray-500);
&.warning {
color: var(--color-warning-500);
}
}
}
}
.form-grid.two-columns {
display: grid;
grid-template-columns: 180px 1fr;
gap: 12px;
@media (max-width: 640px) {
grid-template-columns: 1fr;
}
}
.slide-fade-enter-active {
transition: all 0.25s ease-out;
}
.slide-fade-leave-active {
transition: all 0.2s cubic-bezier(1, 0.5, 0.8, 1);
}
.slide-fade-enter-from,
.slide-fade-leave-to {
transform: translateX(20px);
opacity: 0;
}
</style>