|
|
@@ -1,60 +1,219 @@
|
|
|
<template>
|
|
|
- <div class="flex flex-col h-full">
|
|
|
- <!-- 头部 -->
|
|
|
- <div class="flex items-center justify-between px-4 py-2 bg-base-300 text-xs text-base-content/60">
|
|
|
- <span>帖子树</span>
|
|
|
- <div class="flex items-center gap-2">
|
|
|
- <span v-if="store.highlightedNodeIds.size > 0" class="text-primary">
|
|
|
- 已高亮 {{ store.highlightedNodeIds.size }} 个节点
|
|
|
- </span>
|
|
|
- <template v-if="showExpand">
|
|
|
+ <div class="flex h-full">
|
|
|
+ <!-- 左侧主区域 -->
|
|
|
+ <div class="flex flex-col flex-1 min-w-0">
|
|
|
+ <!-- 头部 -->
|
|
|
+ <div class="flex items-center justify-between px-4 py-2 bg-base-300 text-xs text-base-content/60">
|
|
|
+ <span>待解构帖子</span>
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <span v-if="store.highlightedNodeIds.size > 0" class="text-primary">
|
|
|
+ 已高亮 {{ store.highlightedNodeIds.size }} 个节点
|
|
|
+ </span>
|
|
|
+ <template v-if="showExpand">
|
|
|
+ <button
|
|
|
+ v-if="store.expandedPanel !== 'post-tree'"
|
|
|
+ @click="store.expandPanel('post-tree')"
|
|
|
+ class="btn btn-ghost btn-xs"
|
|
|
+ title="放大"
|
|
|
+ >⤢</button>
|
|
|
+ <button
|
|
|
+ v-if="store.expandedPanel !== 'default'"
|
|
|
+ @click="store.resetLayout()"
|
|
|
+ class="btn btn-ghost btn-xs"
|
|
|
+ title="恢复"
|
|
|
+ >⊡</button>
|
|
|
+ </template>
|
|
|
<button
|
|
|
- v-if="store.expandedPanel !== 'post-tree'"
|
|
|
- @click="store.expandPanel('post-tree')"
|
|
|
- class="btn btn-ghost btn-xs"
|
|
|
- title="放大"
|
|
|
- >⤢</button>
|
|
|
- <button
|
|
|
- v-if="store.expandedPanel !== 'default'"
|
|
|
- @click="store.resetLayout()"
|
|
|
- class="btn btn-ghost btn-xs"
|
|
|
- title="恢复"
|
|
|
- >⊡</button>
|
|
|
- </template>
|
|
|
+ @click="copyPostTreeJson"
|
|
|
+ class="btn btn-ghost btn-xs opacity-60 hover:opacity-100"
|
|
|
+ :title="copyPostTreeSuccess ? '已复制' : '复制JSON'"
|
|
|
+ >
|
|
|
+ <span v-if="copyPostTreeSuccess">✓</span>
|
|
|
+ <span v-else>📋</span>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- </div>
|
|
|
|
|
|
- <!-- 帖子选择下拉框 -->
|
|
|
- <div class="px-4 py-2 bg-base-200 border-b border-base-300">
|
|
|
- <select
|
|
|
- v-model="selectedPostIdx"
|
|
|
- @change="onPostChange"
|
|
|
- class="select select-xs select-bordered w-full"
|
|
|
- >
|
|
|
- <option v-if="store.postList.length === 0" :value="-1">暂无帖子数据</option>
|
|
|
- <option
|
|
|
+ <!-- 帖子筛选列表 -->
|
|
|
+ <div class="px-2 py-1.5 bg-base-200 border-b border-base-300 flex gap-1 overflow-x-auto">
|
|
|
+ <button
|
|
|
v-for="post in store.postList"
|
|
|
:key="post.index"
|
|
|
- :value="post.index"
|
|
|
+ @click="selectPost(post.index)"
|
|
|
+ class="btn btn-xs shrink-0 transition-colors"
|
|
|
+ :class="selectedPostIdx === post.index ? 'btn-primary' : 'btn-ghost'"
|
|
|
+ :title="post.postTitle"
|
|
|
>
|
|
|
- {{ formatPostOption(post) }}
|
|
|
- </option>
|
|
|
- </select>
|
|
|
+ {{ formatPostTitle(post) }}
|
|
|
+ </button>
|
|
|
+ <span v-if="store.postList.length === 0" class="text-xs text-base-content/40 px-2">暂无帖子</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- SVG 容器 -->
|
|
|
+ <div ref="containerRef" class="flex-1 overflow-hidden bg-base-100">
|
|
|
+ <svg ref="svgRef" class="w-full h-full" @click="handleSvgClick"></svg>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
|
|
|
- <!-- SVG 容器 -->
|
|
|
- <div ref="containerRef" class="flex-1 overflow-hidden bg-base-100">
|
|
|
- <svg ref="svgRef" class="w-full h-full" @click="handleSvgClick"></svg>
|
|
|
+ <!-- 右侧:匹配列表 + 详情 -->
|
|
|
+ <div class="w-72 shrink-0 bg-base-200 border-l border-base-300 flex flex-col text-xs">
|
|
|
+ <!-- 匹配列表(上半部分) -->
|
|
|
+ <div class="flex flex-col h-1/2 border-b border-base-300">
|
|
|
+ <div class="px-3 py-2 bg-base-300 text-base-content/60 flex items-center justify-between shrink-0">
|
|
|
+ <span>匹配列表</span>
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <span class="text-base-content/40">{{ sortedMatchEdges.length }}</span>
|
|
|
+ <button
|
|
|
+ @click="copyMatchListJson"
|
|
|
+ class="btn btn-ghost btn-xs opacity-60 hover:opacity-100"
|
|
|
+ :title="copyMatchListSuccess ? '已复制' : '复制JSON'"
|
|
|
+ >
|
|
|
+ <span v-if="copyMatchListSuccess">✓</span>
|
|
|
+ <span v-else>📋</span>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="flex-1 overflow-y-auto">
|
|
|
+ <div
|
|
|
+ v-for="(edge, idx) in sortedMatchEdges"
|
|
|
+ :key="idx"
|
|
|
+ class="px-3 py-1.5 hover:bg-base-300 cursor-pointer border-b border-base-300/50 transition-colors"
|
|
|
+ :class="{ 'bg-primary/10': store.selectedEdgeId === getEdgeId(edge) }"
|
|
|
+ @click="onMatchClick(edge)"
|
|
|
+ >
|
|
|
+ <div class="flex items-center gap-1.5">
|
|
|
+ <!-- 源节点样式(帖子域-空心) -->
|
|
|
+ <span
|
|
|
+ class="w-2 h-2 shrink-0 rounded-full border-2"
|
|
|
+ :style="{ borderColor: getSourceNodeColor(edge), backgroundColor: 'transparent' }"
|
|
|
+ ></span>
|
|
|
+ <span class="truncate text-base-content/80" :title="edge.sourceName">{{ edge.sourceName }}</span>
|
|
|
+ <!-- 分数(带边颜色) -->
|
|
|
+ <span
|
|
|
+ class="px-1 text-[10px] font-medium shrink-0 border-t border-b"
|
|
|
+ :style="{ color: getScoreColor(edge.score), borderColor: getScoreColor(edge.score) }"
|
|
|
+ >{{ edge.score != null ? edge.score.toFixed(2) : '-' }}</span>
|
|
|
+ <!-- 目标节点样式(人设域-实心) -->
|
|
|
+ <span
|
|
|
+ class="w-2 h-2 shrink-0 rounded-full"
|
|
|
+ :style="{ backgroundColor: getTargetNodeColor(edge) }"
|
|
|
+ ></span>
|
|
|
+ <span class="truncate text-base-content/60" :title="edge.targetName">{{ edge.targetName }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-if="sortedMatchEdges.length === 0" class="px-3 py-4 text-base-content/40 text-center">
|
|
|
+ 暂无匹配
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 详情(下半部分) -->
|
|
|
+ <div class="flex-1 flex flex-col min-h-0">
|
|
|
+ <div class="px-3 py-2 bg-base-300 text-base-content/60 shrink-0 flex items-center justify-between">
|
|
|
+ <span>详情</span>
|
|
|
+ <label class="swap swap-flip text-[10px]">
|
|
|
+ <input type="checkbox" v-model="showRawData" />
|
|
|
+ <span class="swap-on">JSON</span>
|
|
|
+ <span class="swap-off">渲染</span>
|
|
|
+ </label>
|
|
|
+ </div>
|
|
|
+ <div class="flex-1 overflow-y-auto p-3 space-y-3">
|
|
|
+ <!-- 原始JSON模式 -->
|
|
|
+ <template v-if="showRawData && (store.selectedNode || store.selectedEdge)">
|
|
|
+ <div class="relative">
|
|
|
+ <button
|
|
|
+ @click="copyJson"
|
|
|
+ class="absolute top-1 right-1 btn btn-ghost btn-xs opacity-60 hover:opacity-100"
|
|
|
+ :title="copySuccess ? '已复制' : '复制'"
|
|
|
+ >
|
|
|
+ <span v-if="copySuccess">✓</span>
|
|
|
+ <span v-else>📋</span>
|
|
|
+ </button>
|
|
|
+ <pre class="text-[10px] bg-base-100 p-2 pr-8 rounded overflow-x-auto whitespace-pre-wrap break-all select-all">{{ JSON.stringify(store.selectedNode || store.selectedEdge, null, 2) }}</pre>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ <!-- 渲染模式 -->
|
|
|
+ <template v-else-if="!showRawData">
|
|
|
+ <!-- 节点详情 -->
|
|
|
+ <template v-if="store.selectedNode">
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <!-- 节点样式:空心(帖子域)或实心(人设域) -->
|
|
|
+ <span
|
|
|
+ class="w-2.5 h-2.5 shrink-0"
|
|
|
+ :class="selectedNodeStyle.shape === 'rect' ? 'rounded-sm' : 'rounded-full'"
|
|
|
+ :style="selectedNodeStyle.hollow
|
|
|
+ ? { backgroundColor: 'transparent', border: '2px solid ' + selectedNodeStyle.color }
|
|
|
+ : { backgroundColor: selectedNodeStyle.color }"
|
|
|
+ ></span>
|
|
|
+ <span class="text-primary font-medium truncate">{{ store.selectedNode.name }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="space-y-1.5 text-[11px]">
|
|
|
+ <template v-for="(value, key) in store.selectedNode" :key="key">
|
|
|
+ <template v-if="key !== 'name' && value !== null && value !== undefined && value !== ''">
|
|
|
+ <div v-if="typeof value !== 'object'" class="flex justify-between gap-2">
|
|
|
+ <span class="text-base-content/50 shrink-0">{{ formatKey(key) }}</span>
|
|
|
+ <span class="text-right break-all">{{ formatValue(value) }}</span>
|
|
|
+ </div>
|
|
|
+ <div v-else-if="Object.keys(value).length > 0" class="space-y-1">
|
|
|
+ <span class="text-base-content/50">{{ formatKey(key) }}</span>
|
|
|
+ <div class="pl-2 border-l border-base-content/20 space-y-1">
|
|
|
+ <template v-for="(v, k) in value" :key="k">
|
|
|
+ <div v-if="v !== null && v !== undefined && v !== ''" class="flex justify-between gap-2 text-[10px]">
|
|
|
+ <span class="text-base-content/50 shrink-0">{{ formatKey(k) }}</span>
|
|
|
+ <span class="text-right break-all">{{ formatValue(v) }}</span>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ <!-- 边详情 -->
|
|
|
+ <template v-else-if="store.selectedEdge">
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <span class="w-4 h-0.5 shrink-0" :style="{ backgroundColor: selectedEdgeColor }"></span>
|
|
|
+ <span class="text-secondary font-medium">{{ store.selectedEdge.type }} 边</span>
|
|
|
+ </div>
|
|
|
+ <div class="space-y-1.5 text-[11px]">
|
|
|
+ <template v-for="(value, key) in store.selectedEdge" :key="key">
|
|
|
+ <template v-if="value !== null && value !== undefined && value !== ''">
|
|
|
+ <div v-if="typeof value !== 'object'" class="flex justify-between gap-2">
|
|
|
+ <span class="text-base-content/50 shrink-0">{{ formatKey(key) }}</span>
|
|
|
+ <span class="text-right break-all">{{ formatEdgeValue(key, value) }}</span>
|
|
|
+ </div>
|
|
|
+ <div v-else-if="Object.keys(value).length > 0" class="space-y-1">
|
|
|
+ <span class="text-base-content/50">{{ formatKey(key) }}</span>
|
|
|
+ <div class="pl-2 border-l border-base-content/20 space-y-1">
|
|
|
+ <template v-for="(v, k) in value" :key="k">
|
|
|
+ <div v-if="v !== null && v !== undefined && v !== ''" class="flex justify-between gap-2 text-[10px]">
|
|
|
+ <span class="text-base-content/50 shrink-0">{{ formatKey(k) }}</span>
|
|
|
+ <span class="text-right break-all">{{ formatValue(v) }}</span>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ <!-- 无选中 -->
|
|
|
+ <div v-else class="text-base-content/40 text-center py-4">
|
|
|
+ 点击节点或边查看详情
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<script setup>
|
|
|
-import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
|
|
+import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
|
|
import * as d3 from 'd3'
|
|
|
import { useGraphStore } from '../stores/graph'
|
|
|
-import { getNodeStyle } from '../config/nodeStyle'
|
|
|
-import { getEdgeStyle } from '../config/edgeStyle'
|
|
|
+import { getNodeStyle, applyNodeShape, dimColors } from '../config/nodeStyle'
|
|
|
+import { getEdgeStyle, edgeTypeColors } from '../config/edgeStyle'
|
|
|
import { applyHighlight } from '../utils/highlight'
|
|
|
|
|
|
const props = defineProps({
|
|
|
@@ -69,21 +228,145 @@ const store = useGraphStore()
|
|
|
const containerRef = ref(null)
|
|
|
const svgRef = ref(null)
|
|
|
|
|
|
+// 详情显示模式:原始JSON / 渲染
|
|
|
+const showRawData = ref(false)
|
|
|
+const copySuccess = ref(false)
|
|
|
+
|
|
|
+// 复制JSON到剪贴板(详情)
|
|
|
+function copyJson() {
|
|
|
+ const data = store.selectedNode || store.selectedEdge
|
|
|
+ if (!data) return
|
|
|
+ navigator.clipboard.writeText(JSON.stringify(data, null, 2)).then(() => {
|
|
|
+ copySuccess.value = true
|
|
|
+ setTimeout(() => { copySuccess.value = false }, 1500)
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// 复制帖子树JSON
|
|
|
+const copyPostTreeSuccess = ref(false)
|
|
|
+function copyPostTreeJson() {
|
|
|
+ const data = store.currentPostGraph
|
|
|
+ if (!data) return
|
|
|
+ navigator.clipboard.writeText(JSON.stringify(data, null, 2)).then(() => {
|
|
|
+ copyPostTreeSuccess.value = true
|
|
|
+ setTimeout(() => { copyPostTreeSuccess.value = false }, 1500)
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// 复制匹配列表JSON
|
|
|
+const copyMatchListSuccess = ref(false)
|
|
|
+function copyMatchListJson() {
|
|
|
+ const data = sortedMatchEdges.value
|
|
|
+ if (!data || data.length === 0) return
|
|
|
+ navigator.clipboard.writeText(JSON.stringify(data, null, 2)).then(() => {
|
|
|
+ copyMatchListSuccess.value = true
|
|
|
+ setTimeout(() => { copyMatchListSuccess.value = false }, 1500)
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
// 当前选中的帖子索引
|
|
|
const selectedPostIdx = ref(store.selectedPostIndex)
|
|
|
|
|
|
+// 匹配边列表(按分数从高到低排序,无分数的放最后)
|
|
|
+const sortedMatchEdges = computed(() => {
|
|
|
+ const postGraph = store.currentPostGraph
|
|
|
+ if (!postGraph?.edges) return []
|
|
|
+
|
|
|
+ const matchEdges = []
|
|
|
+ for (const [edgeId, edge] of Object.entries(postGraph.edges)) {
|
|
|
+ if (edge.type === '匹配') {
|
|
|
+ // 获取源节点和目标节点名称
|
|
|
+ const sourceNode = postGraph.nodes?.[edge.source]
|
|
|
+ const targetNode = store.getNode(edge.target) // 目标是人设节点
|
|
|
+ matchEdges.push({
|
|
|
+ ...edge,
|
|
|
+ sourceName: sourceNode?.name || edge.source.split(':').pop(),
|
|
|
+ targetName: targetNode?.name || edge.target.split(':').pop()
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 按分数从高到低排序,无分数的放最后
|
|
|
+ return matchEdges.sort((a, b) => {
|
|
|
+ const aScore = a.score ?? -Infinity
|
|
|
+ const bScore = b.score ?? -Infinity
|
|
|
+ return bScore - aScore
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+// 获取匹配边的分数颜色
|
|
|
+function getScoreColor(score) {
|
|
|
+ return getEdgeStyle({ type: '匹配', score }).color
|
|
|
+}
|
|
|
+
|
|
|
+// 获取源节点颜色(帖子域节点)
|
|
|
+function getSourceNodeColor(edge) {
|
|
|
+ const postGraph = store.currentPostGraph
|
|
|
+ const sourceNode = postGraph?.nodes?.[edge.source]
|
|
|
+ if (sourceNode?.dimension) {
|
|
|
+ return dimColors[sourceNode.dimension] || '#888'
|
|
|
+ }
|
|
|
+ return '#888'
|
|
|
+}
|
|
|
+
|
|
|
+// 获取目标节点颜色(人设域节点)
|
|
|
+function getTargetNodeColor(edge) {
|
|
|
+ const targetNode = store.getNode(edge.target)
|
|
|
+ if (targetNode?.dimension) {
|
|
|
+ return dimColors[targetNode.dimension] || '#888'
|
|
|
+ }
|
|
|
+ return '#888'
|
|
|
+}
|
|
|
+
|
|
|
+// 选中节点的样式
|
|
|
+const selectedNodeStyle = computed(() => {
|
|
|
+ if (!store.selectedNode) return { color: '#888', shape: 'circle', hollow: false }
|
|
|
+ return getNodeStyle(store.selectedNode)
|
|
|
+})
|
|
|
+
|
|
|
+// 选中节点的颜色(兼容)
|
|
|
+const selectedNodeColor = computed(() => selectedNodeStyle.value.color)
|
|
|
+
|
|
|
+// 选中边的颜色
|
|
|
+const selectedEdgeColor = computed(() => {
|
|
|
+ if (!store.selectedEdge) return '#888'
|
|
|
+ return edgeTypeColors[store.selectedEdge.type] || '#888'
|
|
|
+})
|
|
|
+
|
|
|
+// 获取边ID
|
|
|
+function getEdgeId(edge) {
|
|
|
+ return `${edge.source}|${edge.type}|${edge.target}`
|
|
|
+}
|
|
|
+
|
|
|
+// 点击匹配项
|
|
|
+function onMatchClick(edge) {
|
|
|
+ store.selectEdge({
|
|
|
+ source: edge.source,
|
|
|
+ target: edge.target,
|
|
|
+ type: edge.type,
|
|
|
+ score: edge.score
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
// zoom 实例和主 g 元素
|
|
|
let zoom = null
|
|
|
let mainG = null
|
|
|
let treeWidth = 0
|
|
|
let treeHeight = 0
|
|
|
|
|
|
-// 帖子选择变化
|
|
|
-function onPostChange() {
|
|
|
- store.selectPost(selectedPostIdx.value)
|
|
|
+// 选择帖子
|
|
|
+function selectPost(index) {
|
|
|
+ selectedPostIdx.value = index
|
|
|
+ store.selectPost(index)
|
|
|
+}
|
|
|
+
|
|
|
+// 格式化帖子标题(简短显示)
|
|
|
+function formatPostTitle(post) {
|
|
|
+ const title = post.postTitle || post.postId
|
|
|
+ return title.length > 10 ? title.slice(0, 10) + '…' : title
|
|
|
}
|
|
|
|
|
|
-// 格式化帖子选项显示
|
|
|
+// 格式化帖子选项显示(完整)
|
|
|
function formatPostOption(post) {
|
|
|
const date = post.createTime ? new Date(post.createTime * 1000).toLocaleDateString() : ''
|
|
|
const title = post.postTitle || post.postId
|
|
|
@@ -205,29 +488,10 @@ function renderTree() {
|
|
|
// 节点形状(使用统一配置)
|
|
|
nodes.each(function(d) {
|
|
|
const el = d3.select(this)
|
|
|
+ // 帖子树节点属于帖子域(空心)
|
|
|
+ d.data.domain = '帖子'
|
|
|
const style = getNodeStyle(d)
|
|
|
- const halfSize = style.size / 2
|
|
|
-
|
|
|
- if (style.shape === 'rect') {
|
|
|
- el.append('rect')
|
|
|
- .attr('class', 'tree-shape')
|
|
|
- .attr('x', -halfSize)
|
|
|
- .attr('y', -halfSize)
|
|
|
- .attr('width', style.size)
|
|
|
- .attr('height', style.size)
|
|
|
- .attr('rx', 1)
|
|
|
- .attr('fill', style.color)
|
|
|
- .attr('stroke', 'rgba(255,255,255,0.5)')
|
|
|
- .attr('stroke-width', 1)
|
|
|
- } else {
|
|
|
- el.append('circle')
|
|
|
- .attr('class', 'tree-shape')
|
|
|
- .attr('r', halfSize)
|
|
|
- .attr('fill', style.color)
|
|
|
- .attr('stroke', 'rgba(255,255,255,0.5)')
|
|
|
- .attr('stroke-width', 1)
|
|
|
- }
|
|
|
-
|
|
|
+ applyNodeShape(el, style).attr('class', 'tree-shape')
|
|
|
nodeElements[d.data.id] = { element: this, x: d.x + 50, y: d.y + 25 }
|
|
|
})
|
|
|
|
|
|
@@ -281,6 +545,7 @@ function renderMatchLayer(contentG, root, baseTreeHeight) {
|
|
|
name: name,
|
|
|
dimension: dimension,
|
|
|
type: type,
|
|
|
+ domain: '人设', // 人设节点:实心
|
|
|
sourceEdges: [] // 连接的帖子节点
|
|
|
})
|
|
|
}
|
|
|
@@ -398,29 +663,7 @@ function renderMatchLayer(contentG, root, baseTreeHeight) {
|
|
|
matchNodes.each(function(d) {
|
|
|
const el = d3.select(this)
|
|
|
const style = getNodeStyle(d, { isMatch: true })
|
|
|
- const halfSize = style.size / 2
|
|
|
-
|
|
|
- if (style.shape === 'rect') {
|
|
|
- el.append('rect')
|
|
|
- .attr('class', 'tree-shape')
|
|
|
- .attr('x', -halfSize)
|
|
|
- .attr('y', -halfSize)
|
|
|
- .attr('width', style.size)
|
|
|
- .attr('height', style.size)
|
|
|
- .attr('rx', 1)
|
|
|
- .attr('fill', style.color)
|
|
|
- .attr('stroke', 'rgba(255,255,255,0.5)')
|
|
|
- .attr('stroke-width', 1)
|
|
|
- } else {
|
|
|
- el.append('circle')
|
|
|
- .attr('class', 'tree-shape')
|
|
|
- .attr('r', halfSize)
|
|
|
- .attr('fill', style.color)
|
|
|
- .attr('stroke', 'rgba(255,255,255,0.5)')
|
|
|
- .attr('stroke-width', 1)
|
|
|
- }
|
|
|
-
|
|
|
- // 保存匹配节点位置(统一存入 nodeElements)
|
|
|
+ applyNodeShape(el, style).attr('class', 'tree-shape')
|
|
|
nodeElements[d.id] = { element: this, x: d.x + 50, y: d.y + 25 }
|
|
|
})
|
|
|
|
|
|
@@ -688,23 +931,7 @@ function renderWalkedLayer() {
|
|
|
walkedNodeGroups.each(function(d) {
|
|
|
const el = d3.select(this)
|
|
|
const style = getNodeStyle(d)
|
|
|
- const halfSize = style.size / 2
|
|
|
-
|
|
|
- if (style.shape === 'rect') {
|
|
|
- el.append('rect')
|
|
|
- .attr('class', 'walked-shape')
|
|
|
- .attr('x', -halfSize).attr('y', -halfSize)
|
|
|
- .attr('width', style.size).attr('height', style.size)
|
|
|
- .attr('rx', 1).attr('fill', style.color)
|
|
|
- .attr('stroke', 'rgba(255,255,255,0.5)').attr('stroke-width', 1)
|
|
|
- } else {
|
|
|
- el.append('circle')
|
|
|
- .attr('class', 'walked-shape')
|
|
|
- .attr('r', halfSize).attr('fill', style.color)
|
|
|
- .attr('stroke', 'rgba(255,255,255,0.5)').attr('stroke-width', 1)
|
|
|
- }
|
|
|
-
|
|
|
- // 保存节点位置
|
|
|
+ applyNodeShape(el, style).attr('class', 'walked-shape')
|
|
|
nodeElements[d.id] = { element: this, x: d.x + 50, y: d.y + 25 }
|
|
|
})
|
|
|
|
|
|
@@ -733,6 +960,72 @@ function handleMatchNodeClick(event, d) {
|
|
|
store.selectNode(d)
|
|
|
}
|
|
|
|
|
|
+// ========== 详情显示格式化函数 ==========
|
|
|
+
|
|
|
+// 格式化字段名(camelCase/snake_case -> 中文/可读)
|
|
|
+function formatKey(key) {
|
|
|
+ const keyMap = {
|
|
|
+ 'id': 'ID',
|
|
|
+ 'name': '名称',
|
|
|
+ 'type': '类型',
|
|
|
+ 'dimension': '维度',
|
|
|
+ 'source': '源节点',
|
|
|
+ 'target': '目标节点',
|
|
|
+ 'score': '分数',
|
|
|
+ 'detail': '详情',
|
|
|
+ 'postId': '帖子ID',
|
|
|
+ 'postTitle': '帖子标题',
|
|
|
+ 'createTime': '创建时间',
|
|
|
+ 'parentId': '父节点',
|
|
|
+ 'children': '子节点',
|
|
|
+ 'description': '描述',
|
|
|
+ 'content': '内容',
|
|
|
+ 'tags': '标签',
|
|
|
+ 'category': '分类',
|
|
|
+ 'level': '层级',
|
|
|
+ 'depth': '深度',
|
|
|
+ 'weight': '权重',
|
|
|
+ 'count': '数量',
|
|
|
+ 'status': '状态',
|
|
|
+ 'reason': '原因',
|
|
|
+ 'explanation': '说明',
|
|
|
+ 'matchReason': '匹配原因',
|
|
|
+ 'similarity': '相似度',
|
|
|
+ 'confidence': '置信度'
|
|
|
+ }
|
|
|
+ return keyMap[key] || key
|
|
|
+}
|
|
|
+
|
|
|
+// 格式化普通值
|
|
|
+function formatValue(value) {
|
|
|
+ if (value === null || value === undefined) return '-'
|
|
|
+ if (typeof value === 'boolean') return value ? '是' : '否'
|
|
|
+ if (typeof value === 'number') {
|
|
|
+ // 如果是小数,保留2位
|
|
|
+ if (!Number.isInteger(value)) return value.toFixed(2)
|
|
|
+ return value.toString()
|
|
|
+ }
|
|
|
+ if (Array.isArray(value)) {
|
|
|
+ if (value.length === 0) return '-'
|
|
|
+ return value.join(', ')
|
|
|
+ }
|
|
|
+ if (typeof value === 'object') {
|
|
|
+ return JSON.stringify(value)
|
|
|
+ }
|
|
|
+ return String(value)
|
|
|
+}
|
|
|
+
|
|
|
+// 格式化边的值(特殊处理 source/target 显示名称)
|
|
|
+function formatEdgeValue(key, value) {
|
|
|
+ if (key === 'source' || key === 'target') {
|
|
|
+ // 提取ID最后部分作为显示名称
|
|
|
+ if (typeof value === 'string' && value.includes(':')) {
|
|
|
+ return value.split(':').pop()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return formatValue(value)
|
|
|
+}
|
|
|
+
|
|
|
// 适应视图(自动缩放以显示全部内容)
|
|
|
function fitToView() {
|
|
|
if (!zoom || !mainG || !containerRef.value) return
|
|
|
@@ -775,6 +1068,38 @@ function zoomToNode(nodeId) {
|
|
|
)
|
|
|
}
|
|
|
|
|
|
+// 定位到指定边(显示边的两个端点)
|
|
|
+function zoomToEdge(sourceId, targetId) {
|
|
|
+ const sourceInfo = nodeElements[sourceId]
|
|
|
+ const targetInfo = nodeElements[targetId]
|
|
|
+ if (!sourceInfo || !targetInfo || !zoom || !containerRef.value) return
|
|
|
+
|
|
|
+ const container = containerRef.value
|
|
|
+ const width = container.clientWidth
|
|
|
+ const height = container.clientHeight
|
|
|
+
|
|
|
+ // 计算边的中心点和范围
|
|
|
+ const centerX = (sourceInfo.x + targetInfo.x) / 2
|
|
|
+ const centerY = (sourceInfo.y + targetInfo.y) / 2
|
|
|
+ const edgeWidth = Math.abs(sourceInfo.x - targetInfo.x) + 100
|
|
|
+ const edgeHeight = Math.abs(sourceInfo.y - targetInfo.y) + 100
|
|
|
+
|
|
|
+ // 计算合适的缩放比例(让边的两端都能显示)
|
|
|
+ const scaleX = width / edgeWidth
|
|
|
+ const scaleY = height / edgeHeight
|
|
|
+ const scale = Math.min(scaleX, scaleY, 1.2) // 最大缩放1.2
|
|
|
+
|
|
|
+ // 计算平移使边居中
|
|
|
+ const translateX = width / 2 - centerX * scale
|
|
|
+ const translateY = height / 2 - centerY * scale
|
|
|
+
|
|
|
+ const svg = d3.select(svgRef.value)
|
|
|
+ svg.transition().duration(300).call(
|
|
|
+ zoom.transform,
|
|
|
+ d3.zoomIdentity.translate(translateX, translateY).scale(scale)
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
// 更新高亮/置灰状态
|
|
|
function updateHighlight() {
|
|
|
// 使用帖子游走的边集合(如果有),否则用人设游走的边集合
|
|
|
@@ -798,6 +1123,17 @@ watch(() => store.selectedNodeId, (nodeId, oldNodeId) => {
|
|
|
}
|
|
|
})
|
|
|
|
|
|
+watch(() => store.selectedEdgeId, updateHighlight)
|
|
|
+
|
|
|
+// 监听聚焦边端点变化(由 store 统一管理)
|
|
|
+watch(() => store.focusEdgeEndpoints, (endpoints) => {
|
|
|
+ if (endpoints) {
|
|
|
+ nextTick(() => {
|
|
|
+ zoomToEdge(endpoints.source, endpoints.target)
|
|
|
+ })
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
watch(() => store.highlightedNodeIds.size, updateHighlight)
|
|
|
|
|
|
// 监听帖子游走结果变化,渲染游走层
|