feat(wiki): search box to locate a node by name in the knowledge graph

This commit is contained in:
matevip 2026-06-29 11:29:20 +08:00
parent 4d0f9aa776
commit 812daae0fc
5 changed files with 317 additions and 2 deletions

View File

@ -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',

View File

@ -2694,6 +2694,9 @@ export default {
resetView: '重置视图',
fullscreen: '全屏',
exitFullscreen: '退出全屏',
searchPlaceholder: '按名称搜索节点…',
searchNoMatch: '无匹配节点',
searchClear: '清除',
linksTo: '链接到',
openPage: '打开页面',
empty: '暂无图谱数据,请先处理原始材料',

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

View File

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

View File

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