mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(wiki): search box to locate a node by name in the knowledge graph
This commit is contained in:
parent
4d0f9aa776
commit
812daae0fc
@ -2682,6 +2682,9 @@ export default {
|
||||
resetView: 'Reset view',
|
||||
fullscreen: 'Fullscreen',
|
||||
exitFullscreen: 'Exit fullscreen',
|
||||
searchPlaceholder: 'Search nodes by name…',
|
||||
searchNoMatch: 'No matching node',
|
||||
searchClear: 'Clear',
|
||||
linksTo: 'Links to',
|
||||
openPage: 'Open page',
|
||||
empty: 'No graph data — process some raw materials first',
|
||||
|
||||
@ -2694,6 +2694,9 @@ export default {
|
||||
resetView: '重置视图',
|
||||
fullscreen: '全屏',
|
||||
exitFullscreen: '退出全屏',
|
||||
searchPlaceholder: '按名称搜索节点…',
|
||||
searchNoMatch: '无匹配节点',
|
||||
searchClear: '清除',
|
||||
linksTo: '链接到',
|
||||
openPage: '打开页面',
|
||||
empty: '暂无图谱数据,请先处理原始材料',
|
||||
|
||||
222
mateclaw-ui/src/views/Wiki/components/GraphNodeSearch.vue
Normal file
222
mateclaw-ui/src/views/Wiki/components/GraphNodeSearch.vue
Normal file
@ -0,0 +1,222 @@
|
||||
<template>
|
||||
<div class="gns">
|
||||
<div class="gns__box">
|
||||
<svg class="gns__icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="7" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<input
|
||||
ref="inputEl"
|
||||
v-model="q"
|
||||
class="gns__input"
|
||||
:placeholder="t('wiki.graph.searchPlaceholder')"
|
||||
spellcheck="false"
|
||||
@focus="open = true"
|
||||
@input="onInput"
|
||||
@keydown.down.prevent="move(1)"
|
||||
@keydown.up.prevent="move(-1)"
|
||||
@keydown.enter.prevent="choose(active)"
|
||||
@keydown.esc.prevent="onEsc"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
<button v-if="q" class="gns__clear" :title="t('wiki.graph.searchClear')" @mousedown.prevent="clearQuery">×</button>
|
||||
</div>
|
||||
|
||||
<ul v-if="open && hasQuery && matches.length" class="gns__list">
|
||||
<li
|
||||
v-for="(m, i) in matches"
|
||||
:key="m.id"
|
||||
class="gns__item"
|
||||
:class="{ 'is-active': i === active }"
|
||||
@mousedown.prevent="choose(i)"
|
||||
@mousemove="active = i"
|
||||
>
|
||||
<span class="gns__dot" :style="{ background: m.color || 'var(--mc-text-tertiary)' }" />
|
||||
<span class="gns__name">{{ m.name }}</span>
|
||||
<span v-if="m.type" class="gns__type">{{ m.type }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else-if="open && hasQuery && !matches.length" class="gns__empty">
|
||||
{{ t('wiki.graph.searchNoMatch') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
interface SearchNode { id: string; name: string; type?: string; color?: string }
|
||||
|
||||
const props = defineProps<{ nodes: SearchNode[] }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'focus', id: string): void
|
||||
(e: 'clear'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const q = ref('')
|
||||
const open = ref(false)
|
||||
const active = ref(0)
|
||||
const inputEl = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const hasQuery = computed(() => q.value.trim().length > 0)
|
||||
|
||||
// Substring match, ranked: exact name > prefix > earliest-occurring > shortest >
|
||||
// alphabetical. Capped so the dropdown stays small on large graphs.
|
||||
const matches = computed<SearchNode[]>(() => {
|
||||
const query = q.value.trim().toLowerCase()
|
||||
if (!query) return []
|
||||
const scored: { n: SearchNode; rank: number; idx: number; len: number }[] = []
|
||||
for (const n of props.nodes) {
|
||||
const name = (n.name || '').toLowerCase()
|
||||
const idx = name.indexOf(query)
|
||||
if (idx < 0) continue
|
||||
const rank = name === query ? 0 : idx === 0 ? 1 : 2
|
||||
scored.push({ n, rank, idx, len: name.length })
|
||||
}
|
||||
scored.sort((a, b) => a.rank - b.rank || a.idx - b.idx || a.len - b.len || a.n.name.localeCompare(b.n.name))
|
||||
return scored.slice(0, 10).map(s => s.n)
|
||||
})
|
||||
|
||||
function onInput() {
|
||||
open.value = true
|
||||
active.value = 0
|
||||
}
|
||||
|
||||
function move(d: number) {
|
||||
if (!matches.value.length) return
|
||||
open.value = true
|
||||
active.value = (active.value + d + matches.value.length) % matches.value.length
|
||||
}
|
||||
|
||||
function choose(i: number) {
|
||||
const m = matches.value[i]
|
||||
if (!m) return
|
||||
q.value = m.name
|
||||
open.value = false
|
||||
emit('focus', m.id)
|
||||
}
|
||||
|
||||
function clearQuery() {
|
||||
q.value = ''
|
||||
open.value = false
|
||||
active.value = 0
|
||||
emit('clear')
|
||||
inputEl.value?.focus()
|
||||
}
|
||||
|
||||
function onEsc() {
|
||||
if (open.value && hasQuery.value) open.value = false
|
||||
else clearQuery()
|
||||
}
|
||||
|
||||
// Close the dropdown after a click lands; the items use mousedown.prevent so a
|
||||
// pick still registers before this fires.
|
||||
function onBlur() {
|
||||
setTimeout(() => { open.value = false }, 120)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gns {
|
||||
position: relative;
|
||||
width: 220px;
|
||||
max-width: 60vw;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.gns__box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--mc-bg-elevated, #fff);
|
||||
border: 1px solid var(--mc-border, #e5e7eb);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.gns__icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--mc-text-tertiary);
|
||||
}
|
||||
.gns__input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--mc-text-primary);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.gns__input::placeholder { color: var(--mc-text-quaternary, #b3a395); }
|
||||
.gns__clear {
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
color: var(--mc-text-tertiary);
|
||||
padding: 0 2px;
|
||||
}
|
||||
.gns__clear:hover { color: var(--mc-text-secondary); }
|
||||
|
||||
.gns__list {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
list-style: none;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
border-radius: 8px;
|
||||
background: var(--mc-bg-elevated, #fff);
|
||||
border: 1px solid var(--mc-border, #e5e7eb);
|
||||
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.14);
|
||||
z-index: 2;
|
||||
}
|
||||
.gns__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.gns__item.is-active { background: var(--mc-bg-hover, #f3f4f6); }
|
||||
.gns__dot {
|
||||
flex-shrink: 0;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.gns__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--mc-text-primary);
|
||||
}
|
||||
.gns__type {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: var(--mc-text-tertiary);
|
||||
}
|
||||
.gns__empty {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--mc-bg-elevated, #fff);
|
||||
border: 1px solid var(--mc-border, #e5e7eb);
|
||||
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.14);
|
||||
color: var(--mc-text-tertiary);
|
||||
font-size: 12px;
|
||||
z-index: 2;
|
||||
}
|
||||
</style>
|
||||
@ -2,6 +2,15 @@
|
||||
<div class="entity-graph">
|
||||
<div ref="chartEl" class="graph-canvas" />
|
||||
|
||||
<!-- Find a node by name: highlights it (dimming the rest) and opens its panel -->
|
||||
<GraphNodeSearch
|
||||
v-if="nodes.length"
|
||||
class="graph-search"
|
||||
:nodes="searchNodes"
|
||||
@focus="focusEntity"
|
||||
@clear="clearHighlight"
|
||||
/>
|
||||
|
||||
<!-- Entity detail panel -->
|
||||
<div v-if="selected" class="entity-panel">
|
||||
<button class="entity-panel__close" @click="selected = null">×</button>
|
||||
@ -51,13 +60,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import * as echarts from 'echarts/core'
|
||||
import { GraphChart } from 'echarts/charts'
|
||||
import { TooltipComponent, LegendComponent } from 'echarts/components'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { wikiApi } from '@/api'
|
||||
import GraphNodeSearch from './GraphNodeSearch.vue'
|
||||
|
||||
echarts.use([GraphChart, TooltipComponent, LegendComponent, CanvasRenderer])
|
||||
|
||||
@ -252,6 +262,32 @@ function renderChart() {
|
||||
chart.setOption(buildOption(), { notMerge: true, lazyUpdate: true })
|
||||
}
|
||||
|
||||
// Candidates for the node search box: name + type label + category color.
|
||||
const searchNodes = computed(() =>
|
||||
nodes.value.map(n => ({
|
||||
id: String(n.id),
|
||||
name: n.canonicalName,
|
||||
type: formatType(n.type),
|
||||
color: typeColor(n.type),
|
||||
})),
|
||||
)
|
||||
|
||||
// Search hit: open the entity's panel and emphasize its node (focus:'adjacency'
|
||||
// dims everything else so the match stands out even in a dense graph).
|
||||
function focusEntity(id: string) {
|
||||
const node = nodes.value.find(n => String(n.id) === String(id))
|
||||
if (!node) return
|
||||
openEntity(node)
|
||||
if (!chart) return
|
||||
const idx = nodes.value.findIndex(n => String(n.id) === String(id))
|
||||
chart.dispatchAction({ type: 'downplay', seriesIndex: 0 })
|
||||
if (idx >= 0) chart.dispatchAction({ type: 'highlight', seriesIndex: 0, dataIndex: idx })
|
||||
}
|
||||
|
||||
function clearHighlight() {
|
||||
chart?.dispatchAction({ type: 'downplay', seriesIndex: 0 })
|
||||
}
|
||||
|
||||
async function openEntity(node: EntityNode) {
|
||||
selected.value = node
|
||||
egoEdges.value = []
|
||||
@ -296,6 +332,8 @@ defineExpose({ reload: load })
|
||||
<style scoped>
|
||||
.entity-graph { position: relative; flex: 1; min-height: 0; width: 100%; display: flex; }
|
||||
.graph-canvas { flex: 1; min-height: 0; width: 100%; }
|
||||
/* Search sits top-left; the legend is top-center, the detail panel top-right. */
|
||||
.graph-search { position: absolute; top: 12px; left: 12px; z-index: 5; }
|
||||
|
||||
.graph-empty {
|
||||
position: absolute; inset: 0; display: flex; flex-direction: column;
|
||||
|
||||
@ -24,7 +24,17 @@
|
||||
/>
|
||||
|
||||
<!-- Page-level graph (ECharts canvas) -->
|
||||
<div v-show="graphMode === 'pages'" ref="chartEl" class="graph-canvas" />
|
||||
<div v-show="graphMode === 'pages'" class="graph-canvas-wrap">
|
||||
<!-- Find a node by name: highlights it (dimming the rest) and opens its panel -->
|
||||
<GraphNodeSearch
|
||||
v-if="nodes.length"
|
||||
class="graph-search"
|
||||
:nodes="pageSearchNodes"
|
||||
@focus="focusPageNode"
|
||||
@clear="clearHighlight"
|
||||
/>
|
||||
<div ref="chartEl" class="graph-canvas" />
|
||||
</div>
|
||||
|
||||
<!-- Node detail panel sub-component -->
|
||||
<WikiGraphNodePanel
|
||||
@ -58,6 +68,7 @@ import { useWikiPageType } from '@/composables/useWikiPageType'
|
||||
import WikiGraphToolbar from './WikiGraphToolbar.vue'
|
||||
import WikiGraphNodePanel from './WikiGraphNodePanel.vue'
|
||||
import WikiEntityGraphView from './WikiEntityGraphView.vue'
|
||||
import GraphNodeSearch from './GraphNodeSearch.vue'
|
||||
|
||||
echarts.use([GraphChart, TooltipComponent, LegendComponent, CanvasRenderer])
|
||||
|
||||
@ -216,6 +227,31 @@ const selectedNodeLinks = computed(() => {
|
||||
.filter(Boolean) as WikiPage[]
|
||||
})
|
||||
|
||||
// Candidates for the node search box: page title + type label + node color.
|
||||
const pageSearchNodes = computed(() =>
|
||||
nodes.value.map(p => ({
|
||||
id: p.slug,
|
||||
name: p.title,
|
||||
type: formatPageTypeLabel(p.pageType || 'other'),
|
||||
color: typeColor(p.pageType),
|
||||
})),
|
||||
)
|
||||
|
||||
// Search hit: select the page (opens its panel) and emphasize its node —
|
||||
// focus:'adjacency' dims the rest so the match is easy to spot.
|
||||
function focusPageNode(slug: string) {
|
||||
const page = slugToPage.value.get(slug)
|
||||
if (page) selectedNode.value = page
|
||||
if (!chart) return
|
||||
const idx = nodes.value.findIndex(p => p.slug === slug)
|
||||
chart.dispatchAction({ type: 'downplay', seriesIndex: 0 })
|
||||
if (idx >= 0) chart.dispatchAction({ type: 'highlight', seriesIndex: 0, dataIndex: idx })
|
||||
}
|
||||
|
||||
function clearHighlight() {
|
||||
chart?.dispatchAction({ type: 'downplay', seriesIndex: 0 })
|
||||
}
|
||||
|
||||
function buildOption() {
|
||||
const labelColor = cssVar('--mc-text-secondary', '#665245')
|
||||
const nodeSet = new Set(nodes.value.map(p => p.slug))
|
||||
@ -403,11 +439,24 @@ watch(graphMode, (mode) => {
|
||||
background: var(--mc-bg-base, #1a1a1a);
|
||||
}
|
||||
|
||||
.graph-canvas-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
.graph-canvas {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
/* Search overlay anchored to the canvas top-left, clear of the detail panel. */
|
||||
.graph-search {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.graph-empty {
|
||||
position: absolute;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user