mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +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',
|
resetView: 'Reset view',
|
||||||
fullscreen: 'Fullscreen',
|
fullscreen: 'Fullscreen',
|
||||||
exitFullscreen: 'Exit fullscreen',
|
exitFullscreen: 'Exit fullscreen',
|
||||||
|
searchPlaceholder: 'Search nodes by name…',
|
||||||
|
searchNoMatch: 'No matching node',
|
||||||
|
searchClear: 'Clear',
|
||||||
linksTo: 'Links to',
|
linksTo: 'Links to',
|
||||||
openPage: 'Open page',
|
openPage: 'Open page',
|
||||||
empty: 'No graph data — process some raw materials first',
|
empty: 'No graph data — process some raw materials first',
|
||||||
|
|||||||
@ -2694,6 +2694,9 @@ export default {
|
|||||||
resetView: '重置视图',
|
resetView: '重置视图',
|
||||||
fullscreen: '全屏',
|
fullscreen: '全屏',
|
||||||
exitFullscreen: '退出全屏',
|
exitFullscreen: '退出全屏',
|
||||||
|
searchPlaceholder: '按名称搜索节点…',
|
||||||
|
searchNoMatch: '无匹配节点',
|
||||||
|
searchClear: '清除',
|
||||||
linksTo: '链接到',
|
linksTo: '链接到',
|
||||||
openPage: '打开页面',
|
openPage: '打开页面',
|
||||||
empty: '暂无图谱数据,请先处理原始材料',
|
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 class="entity-graph">
|
||||||
<div ref="chartEl" class="graph-canvas" />
|
<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 -->
|
<!-- Entity detail panel -->
|
||||||
<div v-if="selected" class="entity-panel">
|
<div v-if="selected" class="entity-panel">
|
||||||
<button class="entity-panel__close" @click="selected = null">×</button>
|
<button class="entity-panel__close" @click="selected = null">×</button>
|
||||||
@ -51,13 +60,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useI18n } from 'vue-i18n'
|
||||||
import * as echarts from 'echarts/core'
|
import * as echarts from 'echarts/core'
|
||||||
import { GraphChart } from 'echarts/charts'
|
import { GraphChart } from 'echarts/charts'
|
||||||
import { TooltipComponent, LegendComponent } from 'echarts/components'
|
import { TooltipComponent, LegendComponent } from 'echarts/components'
|
||||||
import { CanvasRenderer } from 'echarts/renderers'
|
import { CanvasRenderer } from 'echarts/renderers'
|
||||||
import { wikiApi } from '@/api'
|
import { wikiApi } from '@/api'
|
||||||
|
import GraphNodeSearch from './GraphNodeSearch.vue'
|
||||||
|
|
||||||
echarts.use([GraphChart, TooltipComponent, LegendComponent, CanvasRenderer])
|
echarts.use([GraphChart, TooltipComponent, LegendComponent, CanvasRenderer])
|
||||||
|
|
||||||
@ -252,6 +262,32 @@ function renderChart() {
|
|||||||
chart.setOption(buildOption(), { notMerge: true, lazyUpdate: true })
|
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) {
|
async function openEntity(node: EntityNode) {
|
||||||
selected.value = node
|
selected.value = node
|
||||||
egoEdges.value = []
|
egoEdges.value = []
|
||||||
@ -296,6 +332,8 @@ defineExpose({ reload: load })
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.entity-graph { position: relative; flex: 1; min-height: 0; width: 100%; display: flex; }
|
.entity-graph { position: relative; flex: 1; min-height: 0; width: 100%; display: flex; }
|
||||||
.graph-canvas { flex: 1; min-height: 0; width: 100%; }
|
.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 {
|
.graph-empty {
|
||||||
position: absolute; inset: 0; display: flex; flex-direction: column;
|
position: absolute; inset: 0; display: flex; flex-direction: column;
|
||||||
|
|||||||
@ -24,7 +24,17 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Page-level graph (ECharts canvas) -->
|
<!-- 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 -->
|
<!-- Node detail panel sub-component -->
|
||||||
<WikiGraphNodePanel
|
<WikiGraphNodePanel
|
||||||
@ -58,6 +68,7 @@ import { useWikiPageType } from '@/composables/useWikiPageType'
|
|||||||
import WikiGraphToolbar from './WikiGraphToolbar.vue'
|
import WikiGraphToolbar from './WikiGraphToolbar.vue'
|
||||||
import WikiGraphNodePanel from './WikiGraphNodePanel.vue'
|
import WikiGraphNodePanel from './WikiGraphNodePanel.vue'
|
||||||
import WikiEntityGraphView from './WikiEntityGraphView.vue'
|
import WikiEntityGraphView from './WikiEntityGraphView.vue'
|
||||||
|
import GraphNodeSearch from './GraphNodeSearch.vue'
|
||||||
|
|
||||||
echarts.use([GraphChart, TooltipComponent, LegendComponent, CanvasRenderer])
|
echarts.use([GraphChart, TooltipComponent, LegendComponent, CanvasRenderer])
|
||||||
|
|
||||||
@ -216,6 +227,31 @@ const selectedNodeLinks = computed(() => {
|
|||||||
.filter(Boolean) as WikiPage[]
|
.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() {
|
function buildOption() {
|
||||||
const labelColor = cssVar('--mc-text-secondary', '#665245')
|
const labelColor = cssVar('--mc-text-secondary', '#665245')
|
||||||
const nodeSet = new Set(nodes.value.map(p => p.slug))
|
const nodeSet = new Set(nodes.value.map(p => p.slug))
|
||||||
@ -403,11 +439,24 @@ watch(graphMode, (mode) => {
|
|||||||
background: var(--mc-bg-base, #1a1a1a);
|
background: var(--mc-bg-base, #1a1a1a);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.graph-canvas-wrap {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
.graph-canvas {
|
.graph-canvas {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
width: 100%;
|
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 {
|
.graph-empty {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user