| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732 |
- <template>
- <div class="flex h-full">
- <!-- 左侧主区域:待解构帖子 -->
- <div v-if="showPostTree" 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>
- </div>
- </div>
- <!-- 帖子筛选列表 -->
- <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"
- @click="selectPost(post.index)"
- class="btn btn-xs shrink-0 transition-colors"
- :class="selectedPostIdx === post.index ? 'btn-primary' : 'btn-ghost'"
- :title="post.postTitle"
- >
- {{ 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>
- <!-- 右侧:匹配列表 + 详情 -->
- <div
- v-if="showMatchList || showDetail"
- class="shrink-0 bg-base-200 border-l border-base-300 flex flex-col text-xs transition-all duration-200"
- :class="showPostTree ? 'w-72' : 'flex-1'"
- >
- <!-- 匹配列表(1/3高度) -->
- <div
- v-if="showMatchList"
- class="flex flex-col border-b border-base-300 transition-all duration-200"
- :class="matchListCollapsed ? 'h-8' : (showDetail ? 'h-1/3' : 'flex-1')"
- >
- <div
- class="px-3 py-2 bg-base-300 text-base-content/60 flex items-center justify-between shrink-0 cursor-pointer"
- @click="emit('update:matchListCollapsed', !matchListCollapsed)"
- >
- <div class="flex items-center gap-2">
- <span class="transition-transform" :class="{ '-rotate-90': matchListCollapsed }">▼</span>
- <span>匹配列表</span>
- </div>
- <span class="text-base-content/40">{{ sortedMatchEdges.length }}</span>
- </div>
- <div v-show="!matchListCollapsed" 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 v-if="showDetail" 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 && (displayNode || 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(displayNode || store.selectedEdge, null, 2) }}</pre>
- </div>
- </template>
- <!-- 渲染模式 -->
- <template v-else-if="!showRawData">
- <!-- 节点详情(hover 优先于 selected) -->
- <template v-if="displayNode">
- <div class="flex items-center gap-2">
- <!-- hover 标识 -->
- <span v-if="store.hoverNode" class="text-[10px] text-warning/60">[hover]</span>
- <!-- 节点样式:空心(帖子域)或实心(人设域) -->
- <span
- class="w-2.5 h-2.5 shrink-0"
- :class="displayNodeStyle.shape === 'rect' ? 'rounded-sm' : 'rounded-full'"
- :style="displayNodeStyle.hollow
- ? { backgroundColor: 'transparent', border: '2px solid ' + displayNodeStyle.color }
- : { backgroundColor: displayNodeStyle.color }"
- ></span>
- <span class="text-primary font-medium truncate">{{ displayNode.name }}</span>
- </div>
- <div class="space-y-1.5 text-[11px]">
- <template v-for="(value, key) in displayNode" :key="key">
- <template v-if="!hiddenNodeFields.includes(key) && 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>
- <!-- 入边列表 -->
- <div v-if="nodeInEdges.length > 0" class="mt-3 pt-2 border-t border-base-content/10">
- <div
- class="text-[10px] text-base-content/50 mb-1 cursor-pointer hover:text-primary"
- @click="openEdgeListModal('in', nodeInEdges)"
- >入边 ({{ nodeInEdges.length }}) ›</div>
- <div class="space-y-1 max-h-24 overflow-y-auto">
- <div
- v-for="edge in nodeInEdges"
- :key="`in-${edge.source}-${edge.type}`"
- class="flex items-center gap-1 text-[10px] px-1 py-0.5 rounded hover:bg-base-300 cursor-pointer"
- @click="openEdgeModal(edge)"
- >
- <span class="w-2 h-0.5" :style="{ backgroundColor: edgeTypeColors[edge.type] }"></span>
- <span class="truncate flex-1">{{ getNodeName(edge.source) }}</span>
- <span class="text-base-content/40">{{ edge.score?.toFixed(2) || '-' }}</span>
- </div>
- </div>
- </div>
- <!-- 出边列表 -->
- <div v-if="nodeOutEdges.length > 0" class="mt-2 pt-2 border-t border-base-content/10">
- <div
- class="text-[10px] text-base-content/50 mb-1 cursor-pointer hover:text-primary"
- @click="openEdgeListModal('out', nodeOutEdges)"
- >出边 ({{ nodeOutEdges.length }}) ›</div>
- <div class="space-y-1 max-h-24 overflow-y-auto">
- <div
- v-for="edge in nodeOutEdges"
- :key="`out-${edge.target}-${edge.type}`"
- class="flex items-center gap-1 text-[10px] px-1 py-0.5 rounded hover:bg-base-300 cursor-pointer"
- @click="openEdgeModal(edge)"
- >
- <span class="w-2 h-0.5" :style="{ backgroundColor: edgeTypeColors[edge.type] }"></span>
- <span class="truncate flex-1">{{ getNodeName(edge.target) }}</span>
- <span class="text-base-content/40">{{ edge.score?.toFixed(2) || '-' }}</span>
- </div>
- </div>
- </div>
- </template>
- <!-- 边详情(hover 优先于 selected) -->
- <template v-else-if="displayEdge">
- <div class="flex items-center gap-2">
- <span class="w-4 h-0.5 shrink-0" :style="{ backgroundColor: displayEdgeColor }"></span>
- <span class="text-secondary font-medium">{{ displayEdge.type }} 边</span>
- </div>
- <div class="space-y-1.5 text-[11px]">
- <template v-for="(value, key) in displayEdge" :key="key">
- <template v-if="key !== 'index' && 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>
- <!-- 边详情模态框 -->
- <dialog v-if="modalEdge" class="modal modal-open">
- <div class="modal-box max-w-md">
- <button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" @click="closeEdgeModal">✕</button>
- <h3 class="font-bold text-lg flex items-center gap-2">
- <span class="w-4 h-0.5" :style="{ backgroundColor: edgeTypeColors[modalEdge.type] }"></span>
- {{ modalEdge.type }} 边
- </h3>
- <div class="py-4 space-y-2 text-sm">
- <template v-for="(value, key) in modalEdge" :key="key">
- <template v-if="key !== 'index' && 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-3 border-l border-base-content/20 space-y-1 text-xs">
- <template v-for="(v, k) in value" :key="k">
- <div v-if="k !== 'index' && v !== null && v !== undefined && v !== ''" class="flex justify-between gap-2">
- <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>
- </div>
- <form method="dialog" class="modal-backdrop">
- <button @click="closeEdgeModal">close</button>
- </form>
- </dialog>
- <!-- 边列表模态框 -->
- <dialog v-if="edgeListModal.show" class="modal modal-open">
- <div class="modal-box max-w-2xl max-h-[80vh]">
- <button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" @click="closeEdgeListModal">✕</button>
- <h3 class="font-bold text-lg">{{ edgeListModal.type === 'in' ? '入边' : '出边' }}列表 ({{ edgeListModal.edges.length }})</h3>
- <div class="py-4 overflow-y-auto max-h-[60vh]">
- <div class="space-y-3">
- <div
- v-for="(edge, idx) in edgeListModal.edges"
- :key="idx"
- class="p-3 bg-base-200 rounded-lg"
- >
- <div class="flex items-center gap-2 mb-2 pb-2 border-b border-base-300">
- <span class="w-4 h-0.5" :style="{ backgroundColor: edgeTypeColors[edge.type] }"></span>
- <span class="font-medium">{{ edge.type }}</span>
- <span class="text-base-content/50 text-sm">
- {{ edgeListModal.type === 'in' ? getNodeName(edge.source) : getNodeName(edge.target) }}
- </span>
- <span v-if="edge.score != null" class="ml-auto text-primary">{{ edge.score.toFixed(2) }}</span>
- </div>
- <div class="space-y-1 text-sm">
- <template v-for="(value, key) in edge" :key="key">
- <template v-if="!hiddenNodeFields.includes(key) && key !== 'type' && 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-3 border-l border-base-content/20 space-y-1 text-xs">
- <template v-for="(v, k) in value" :key="k">
- <div v-if="v !== null && v !== undefined && v !== ''" class="flex justify-between gap-2">
- <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>
- </div>
- </div>
- </div>
- </div>
- <form method="dialog" class="modal-backdrop">
- <button @click="closeEdgeListModal">close</button>
- </form>
- </dialog>
- </div>
- </template>
- <script setup>
- import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
- import * as d3 from 'd3'
- import { useGraphStore } from '../stores/graph'
- import { getNodeStyle, applyNodeShape, dimColors } from '../config/nodeStyle'
- import { getEdgeStyle, edgeTypeColors } from '../config/edgeStyle'
- import { applyHighlight, applyHoverHighlight } from '../utils/highlight'
- const props = defineProps({
- showExpand: {
- type: Boolean,
- default: false
- },
- showPostTree: {
- type: Boolean,
- default: true
- },
- showMatchList: {
- type: Boolean,
- default: true
- },
- showDetail: {
- type: Boolean,
- default: true
- },
- matchListCollapsed: {
- type: Boolean,
- default: false
- }
- })
- const emit = defineEmits(['update:matchListCollapsed'])
- // 不需要显示的节点字段
- const hiddenNodeFields = ['index', 'x', 'y', 'vx', 'vy', 'fx', 'fy']
- const store = useGraphStore()
- const containerRef = ref(null)
- const svgRef = ref(null)
- // 详情显示模式:原始JSON / 渲染
- const showRawData = ref(false)
- const copySuccess = ref(false)
- // 边详情模态框
- const modalEdge = ref(null)
- function openEdgeModal(edge) {
- modalEdge.value = edge
- }
- function closeEdgeModal() {
- modalEdge.value = null
- }
- // 边列表模态框
- const edgeListModal = ref({
- show: false,
- type: 'in', // 'in' or 'out'
- edges: []
- })
- function openEdgeListModal(type, edges) {
- edgeListModal.value = {
- show: true,
- type,
- edges
- }
- }
- function closeEdgeListModal() {
- edgeListModal.value = {
- show: false,
- type: 'in',
- edges: []
- }
- }
- // 复制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)
- })
- }
- // 当前选中的帖子索引
- const selectedPostIdx = ref(store.selectedPostIndex)
- // 匹配边列表(按分数从高到低排序,去重,无分数的放最后)
- const sortedMatchEdges = computed(() => {
- const postGraph = store.currentPostGraph
- if (!postGraph?.edges) return []
- const matchEdges = []
- const seen = new Set() // 用于去重
- for (const [edgeId, edge] of Object.entries(postGraph.edges)) {
- if (edge.type === '匹配') {
- // 生成去重key(两个节点排序后拼接,确保A-B和B-A生成相同的key)
- const pairKey = [edge.source, edge.target].sort().join('|')
- if (seen.has(pairKey)) continue
- seen.add(pairKey)
- // 获取源节点和目标节点名称
- 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'
- }
- // 显示的节点(hover 优先于 selected)
- const displayNode = computed(() => store.hoverNode || store.selectedNode)
- // 显示节点的样式
- const displayNodeStyle = computed(() => {
- if (!displayNode.value) return { color: '#888', shape: 'circle', hollow: false }
- return getNodeStyle(displayNode.value)
- })
- // 选中节点的样式(兼容旧代码)
- 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'
- })
- // 显示的边(hover 优先于 selected)
- const displayEdge = computed(() => {
- return store.hoverEdgeData || store.selectedEdge
- })
- // 显示的边颜色
- const displayEdgeColor = computed(() => {
- if (!displayEdge.value) return '#888'
- return edgeTypeColors[displayEdge.value.type] || '#888'
- })
- // 节点的入边列表(按分数降序)
- const nodeInEdges = computed(() => {
- if (!displayNode.value) return []
- const nodeId = displayNode.value.id || displayNode.value.data?.id
- if (!nodeId) return []
- const postGraph = store.currentPostGraph
- if (!postGraph?.edges) return []
- return Object.values(postGraph.edges)
- .filter(e => e.target === nodeId)
- .sort((a, b) => (b.score || 0) - (a.score || 0))
- })
- // 节点的出边列表(按分数降序)
- const nodeOutEdges = computed(() => {
- if (!displayNode.value) return []
- const nodeId = displayNode.value.id || displayNode.value.data?.id
- if (!nodeId) return []
- const postGraph = store.currentPostGraph
- if (!postGraph?.edges) return []
- return Object.values(postGraph.edges)
- .filter(e => e.source === nodeId)
- .sort((a, b) => (b.score || 0) - (a.score || 0))
- })
- // 获取节点名称(根据节点ID)
- function getNodeName(nodeId) {
- if (!nodeId) return '-'
- // 先从当前帖子图中查找
- const postGraph = store.currentPostGraph
- if (postGraph?.nodes?.[nodeId]) {
- return postGraph.nodes[nodeId].name || nodeId.split(':').pop()
- }
- // 再从人设节点中查找
- const personaNode = store.getNode(nodeId)
- if (personaNode) {
- return personaNode.name || nodeId.split(':').pop()
- }
- // 回退到从ID提取名称
- return nodeId.split(':').pop() || nodeId
- }
- // 获取边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 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
- const shortTitle = title.length > 20 ? title.slice(0, 20) + '...' : title
- return date ? `${date} ${shortTitle}` : shortTitle
- }
- // 节点元素映射(统一存储所有节点位置)
- let nodeElements = {}
- let baseNodeElements = {} // 基础节点(帖子树+匹配层),不含游走节点
- let currentRoot = null
- // 处理节点点击
- function handleNodeClick(event, d) {
- event.stopPropagation()
- // 锁定状态下点击节点无效果,但提醒用户
- if (store.lockedHoverNodeId) {
- shakeLockButton()
- return
- }
- store.selectNode(d)
- }
- // 渲染树
- function renderTree() {
- const svg = d3.select(svgRef.value)
- svg.selectAll('*').remove()
- nodeElements = {}
- const treeData = store.postTreeData
- if (!treeData || !treeData.id) return
- const container = containerRef.value
- if (!container) return
- const width = container.clientWidth
- const height = container.clientHeight
- svg.attr('viewBox', `0 0 ${width} ${height}`)
- // 创建层级数据
- const root = d3.hierarchy(treeData)
- currentRoot = root
- // 智能计算树的尺寸(垂直布局)
- const allNodes = root.descendants()
- const maxDepth = d3.max(allNodes, d => d.depth)
- const leafCount = allNodes.filter(d => !d.children).length
- // 计算最长文字长度(用于动态调整间距)
- const maxTextLen = d3.max(allNodes, d => {
- const name = d.data.name || ''
- return Math.min(name.length, 10) // 最多显示10个字
- }) || 6
- // 动态计算节点间距(根据文字长度)
- const nodeSpacing = Math.max(60, maxTextLen * 12)
- // 宽度:基于叶子节点数量和文字间距
- treeWidth = Math.max(400, leafCount * nodeSpacing + 100)
- // 高度:根据深度,增大层间距避免垂直重叠
- treeHeight = Math.max(400, (maxDepth + 1) * 120 + 50)
- // 创建缩放行为
- zoom = d3.zoom()
- .scaleExtent([0.1, 3])
- .on('zoom', (e) => {
- mainG.attr('transform', e.transform)
- })
- svg.call(zoom)
- // 创建主组
- mainG = svg.append('g')
- // 创建树布局(垂直方向:从上到下)
- const treeLayout = d3.tree()
- .size([treeWidth - 100, treeHeight - 50])
- .separation((a, b) => {
- // 同级节点间距根据是否有子节点调整
- if (a.parent === b.parent) {
- // 叶子节点需要更大间距放文字
- const aIsLeaf = !a.children
- const bIsLeaf = !b.children
- if (aIsLeaf || bIsLeaf) return 1.5
- return 1
- }
- return 2
- })
- treeLayout(root)
- // 内容组(带偏移)
- const contentG = mainG.append('g')
- .attr('transform', 'translate(50, 25)')
- // 绘制边(垂直方向)
- contentG.append('g')
- .attr('class', 'tree-edges')
- .selectAll('.tree-link')
- .data(root.links())
- .join('path')
- .attr('class', 'tree-link')
- .attr('fill', 'none')
- .attr('stroke', '#3498db')
- .attr('stroke-opacity', 0.3)
- .attr('stroke-width', 1)
- .attr('d', d => {
- const midY = (d.source.y + d.target.y) / 2
- return `M${d.source.x},${d.source.y} C${d.source.x},${midY} ${d.target.x},${midY} ${d.target.x},${d.target.y}`
- })
- // 绘制节点(垂直布局:x 是水平位置,y 是垂直位置)
- const nodes = contentG.append('g')
- .attr('class', 'tree-nodes')
- .selectAll('.tree-node')
- .data(root.descendants())
- .join('g')
- .attr('class', 'tree-node')
- .attr('transform', d => `translate(${d.x},${d.y})`)
- .style('cursor', 'pointer')
- .on('click', handleNodeClick)
- // 节点形状(使用统一配置)
- nodes.each(function(d) {
- const el = d3.select(this)
- // 帖子树节点属于帖子域(空心)
- d.data.domain = '帖子'
- const style = getNodeStyle(d)
- applyNodeShape(el, style).attr('class', 'tree-shape')
- nodeElements[d.data.id] = { element: this, x: d.x + 50, y: d.y + 25 }
- })
- // 节点标签(使用统一配置)
- nodes.append('text')
- .attr('dy', d => d.children ? -10 : 4)
- .attr('dx', d => d.children ? 0 : 10)
- .attr('text-anchor', d => d.children ? 'middle' : 'start')
- .attr('fill', d => getNodeStyle(d).text.fill)
- .attr('font-size', d => getNodeStyle(d).text.fontSize)
- .attr('font-weight', d => getNodeStyle(d).text.fontWeight)
- .text(d => {
- const name = d.data.name
- const maxLen = 10
- return name.length > maxLen ? name.slice(0, maxLen) + '…' : name
- })
- // ========== 绘制匹配层 ==========
- renderMatchLayer(contentG, root, treeHeight)
- // 初始适应视图
- fitToView()
- // 设置 hover 处理器(需要在所有元素创建后)
- nextTick(() => setupHoverHandlers())
- }
- // 绘制匹配层(人设节点 + 连线)
- function renderMatchLayer(contentG, root, baseTreeHeight) {
- const postGraph = store.currentPostGraph
- if (!postGraph || !postGraph.edges) return
- // 提取匹配边(只取帖子->人设方向的)
- const matchEdges = []
- for (const edge of Object.values(postGraph.edges)) {
- if (edge.type === '匹配' && edge.source.startsWith('帖子:') && edge.target.startsWith('人设:')) {
- matchEdges.push(edge)
- }
- }
- if (matchEdges.length === 0) return
- // 收集匹配的人设节点(去重)
- const matchedPersonaMap = new Map()
- for (const edge of matchEdges) {
- if (!matchedPersonaMap.has(edge.target)) {
- // 从人设节点ID提取信息: "人设:目的点:标签:进行产品种草"
- const parts = edge.target.split(':')
- const name = parts[parts.length - 1]
- const dimension = parts[1] // 灵感点/目的点/关键点
- const type = parts[2] // 标签/分类/点
- matchedPersonaMap.set(edge.target, {
- id: edge.target,
- name: name,
- dimension: dimension,
- type: type,
- domain: '人设', // 人设节点:实心
- sourceEdges: [] // 连接的帖子节点
- })
- }
- matchedPersonaMap.get(edge.target).sourceEdges.push({
- sourceId: edge.source,
- score: edge.score
- })
- }
- const matchedPersonas = Array.from(matchedPersonaMap.values())
- if (matchedPersonas.length === 0) return
- // 计算匹配层的 Y 位置(树的最大深度 + 间距)
- const maxY = d3.max(root.descendants(), d => d.y) || 0
- const matchLayerY = maxY + 100
- // 计算匹配节点的 X 位置(均匀分布)
- const minX = d3.min(root.descendants(), d => d.x) || 0
- const maxX = d3.max(root.descendants(), d => d.x) || 0
- const matchSpacing = (maxX - minX) / Math.max(matchedPersonas.length - 1, 1)
- matchedPersonas.forEach((persona, i) => {
- persona.x = matchedPersonas.length === 1
- ? (minX + maxX) / 2
- : minX + i * matchSpacing
- persona.y = matchLayerY
- })
- // 收集匹配边数据(统一数据结构:source, target)
- const matchLinksData = []
- for (const persona of matchedPersonas) {
- for (const srcEdge of persona.sourceEdges) {
- const sourceNode = nodeElements[srcEdge.sourceId]
- if (!sourceNode) continue
- matchLinksData.push({
- source: srcEdge.sourceId,
- target: persona.id,
- score: srcEdge.score,
- srcX: sourceNode.x - 50,
- srcY: sourceNode.y - 25,
- tgtX: persona.x,
- tgtY: persona.y
- })
- }
- }
- // 绘制匹配连线(使用 data binding)
- const matchLinksG = contentG.append('g').attr('class', 'match-links')
- matchLinksG.selectAll('.match-link')
- .data(matchLinksData)
- .join('path')
- .attr('class', 'match-link')
- .attr('fill', 'none')
- .attr('stroke', d => getEdgeStyle({ type: '匹配', score: d.score }).color)
- .attr('stroke-opacity', d => getEdgeStyle({ type: '匹配', score: d.score }).opacity)
- .attr('stroke-width', d => getEdgeStyle({ type: '匹配', score: d.score }).strokeWidth)
- .attr('stroke-dasharray', d => getEdgeStyle({ type: '匹配', score: d.score }).strokeDasharray)
- .style('cursor', 'pointer')
- .on('click', (e, d) => {
- e.stopPropagation()
- store.selectEdge({
- source: d.source,
- target: d.target,
- type: '匹配',
- score: d.score
- })
- })
- .attr('d', d => {
- const midY = (d.srcY + d.tgtY) / 2
- return `M${d.srcX},${d.srcY} C${d.srcX},${midY} ${d.tgtX},${midY} ${d.tgtX},${d.tgtY}`
- })
- // 绘制分数标签(使用 data binding)
- const scoreData = matchLinksData.filter(d => getEdgeStyle({ type: '匹配', score: d.score }).scoreText)
- const scoreGroups = matchLinksG.selectAll('.match-score')
- .data(scoreData)
- .join('g')
- .attr('class', 'match-score')
- .attr('transform', d => {
- const midX = (d.srcX + d.tgtX) / 2
- const midY = (d.srcY + d.tgtY) / 2
- return `translate(${midX}, ${midY})`
- })
- scoreGroups.append('rect')
- .attr('x', -14)
- .attr('y', -6)
- .attr('width', 28)
- .attr('height', 12)
- .attr('rx', 2)
- .attr('fill', '#1d232a')
- .attr('opacity', 0.9)
- scoreGroups.append('text')
- .attr('text-anchor', 'middle')
- .attr('dy', '0.35em')
- .attr('fill', d => getEdgeStyle({ type: '匹配', score: d.score }).color)
- .attr('font-size', '8px')
- .text(d => getEdgeStyle({ type: '匹配', score: d.score }).scoreText)
- // 绘制匹配节点
- const matchNodesG = contentG.append('g').attr('class', 'match-nodes')
- const matchNodes = matchNodesG.selectAll('.match-node')
- .data(matchedPersonas)
- .join('g')
- .attr('class', 'match-node')
- .attr('transform', d => `translate(${d.x},${d.y})`)
- .style('cursor', 'pointer')
- .on('click', handleMatchNodeClick)
- // 匹配节点形状(使用统一配置)
- matchNodes.each(function(d) {
- const el = d3.select(this)
- const style = getNodeStyle(d, { isMatch: true })
- applyNodeShape(el, style).attr('class', 'tree-shape')
- nodeElements[d.id] = { element: this, x: d.x + 50, y: d.y + 25 }
- })
- // 匹配节点标签(使用统一配置)
- matchNodes.append('text')
- .attr('dy', 4)
- .attr('dx', 10)
- .attr('text-anchor', 'start')
- .attr('fill', d => getNodeStyle(d, { isMatch: true }).text.fill)
- .attr('font-size', d => getNodeStyle(d, { isMatch: true }).text.fontSize)
- .attr('font-weight', d => getNodeStyle(d, { isMatch: true }).text.fontWeight)
- .text(d => {
- const name = d.name
- const maxLen = 10
- return name.length > maxLen ? name.slice(0, maxLen) + '…' : name
- })
- // 更新总高度(用于 fitToView)
- treeHeight = matchLayerY + 50
- // 保存匹配层 Y 位置,供游走层使用
- lastMatchLayerY = matchLayerY
- // 保存基础节点快照(帖子树+匹配层),供游走层判断已有节点
- baseNodeElements = { ...nodeElements }
- }
- // 保存匹配层 Y 位置
- let lastMatchLayerY = 0
- // 绘制游走层(按层级渲染路径)
- function renderWalkedLayer() {
- if (!mainG) return
- // 移除旧的游走层
- mainG.selectAll('.walked-layer').remove()
- // 重置 nodeElements 为基础节点(清除之前的游走节点)
- nodeElements = { ...baseNodeElements }
- const paths = store.postWalkedPaths
- if (!paths.length) return
- const contentG = mainG.select('g')
- if (contentG.empty()) return
- // 创建游走层组
- const walkedG = contentG.append('g').attr('class', 'walked-layer')
- // ========== 按路径位置分层遍历,确定每个新节点的层数 ==========
- // nodeLayer: nodeId -> layer (已有节点从 nodeElements 推断,新节点计算得出)
- const nodeLayer = new Map()
- const newNodes = new Set() // 新增的节点
- console.log('=== renderWalkedLayer 层数计算 ===')
- console.log('路径数:', paths.length)
- console.log('lastMatchLayerY:', lastMatchLayerY)
- // 初始化:已有节点的层数(帖子树和匹配层)
- // 使用 baseNodeElements(不含之前的游走节点)
- // 匹配层的 Y 坐标是 lastMatchLayerY,作为基准层 0
- for (const [nodeId, info] of Object.entries(baseNodeElements)) {
- // 根据 Y 坐标推断层数(匹配层为0,帖子树在上面为负数)
- const layer = Math.round((info.y - 25 - lastMatchLayerY) / 80)
- nodeLayer.set(nodeId, Math.min(layer, 0)) // 已有节点层数 <= 0
- }
- console.log('基础节点数:', nodeLayer.size)
- // 找出所有路径的最大长度
- const maxPathLen = Math.max(...paths.map(p => p.nodes.length))
- console.log('最大路径长度:', maxPathLen)
- // 按位置分层遍历(从第2个节点开始,index=1)
- for (let pos = 1; pos < maxPathLen; pos++) {
- console.log(`--- 处理位置 ${pos} ---`)
- for (const path of paths) {
- if (pos >= path.nodes.length) continue
- const nodeId = path.nodes[pos]
- const prevNodeId = path.nodes[pos - 1]
- // 如果已经在树上,跳过
- if (nodeLayer.has(nodeId)) {
- console.log(` 节点 ${nodeId}: 已存在,层数=${nodeLayer.get(nodeId)},跳过`)
- continue
- }
- // 找前驱节点的层数
- const prevLayer = nodeLayer.get(prevNodeId) ?? 0
- const newLayer = prevLayer + 1
- console.log(` 节点 ${nodeId}: 前驱=${prevNodeId}(层${prevLayer}) → 新层数=${newLayer}`)
- // 新节点层数 = 前驱层数 + 1
- nodeLayer.set(nodeId, newLayer)
- newNodes.add(nodeId)
- }
- }
- console.log('新增节点数:', newNodes.size)
- console.log('新增节点:', Array.from(newNodes))
- // ========== 按层分组新节点 ==========
- const layerGroups = new Map() // layer -> Set of nodeIds
- for (const nodeId of newNodes) {
- const layer = nodeLayer.get(nodeId)
- if (!layerGroups.has(layer)) {
- layerGroups.set(layer, new Set())
- }
- layerGroups.get(layer).add(nodeId)
- }
- // ========== 计算新节点位置 ==========
- const treeNodes = Object.values(nodeElements)
- const minX = d3.min(treeNodes, d => d.x) || 50
- const maxX = d3.max(treeNodes, d => d.x) || 300
- const layerSpacing = 80 // 层间距
- // nodePositions 只存储新节点的位置
- const nodePositions = {}
- for (const [layer, nodeIds] of layerGroups) {
- const nodesAtLayer = Array.from(nodeIds)
- const layerY = lastMatchLayerY + layer * layerSpacing
- // 新节点均匀分布
- const spacing = (maxX - minX - 100) / Math.max(nodesAtLayer.length - 1, 1)
- nodesAtLayer.forEach((nodeId, i) => {
- nodePositions[nodeId] = {
- x: nodesAtLayer.length === 1 ? (minX + maxX) / 2 - 50 : (minX - 50) + i * spacing,
- y: layerY
- }
- })
- }
- // ========== 收集所有边 ==========
- const allEdges = new Map()
- for (const path of paths) {
- for (const edge of path.edges) {
- const edgeKey = `${edge.source}->${edge.target}`
- if (!allEdges.has(edgeKey)) {
- allEdges.set(edgeKey, edge)
- }
- }
- }
- // 获取节点位置的辅助函数(优先新节点位置,然后已有节点位置)
- function getNodePos(nodeId) {
- if (nodePositions[nodeId]) {
- return nodePositions[nodeId]
- }
- if (nodeElements[nodeId]) {
- return {
- x: nodeElements[nodeId].x - 50,
- y: nodeElements[nodeId].y - 25
- }
- }
- return null
- }
- // ========== 绘制所有路径上的边 ==========
- const edgesData = []
- console.log('=== 绘制边 ===')
- console.log('总边数:', allEdges.size)
- for (const edge of allEdges.values()) {
- const srcPos = getNodePos(edge.source)
- const tgtPos = getNodePos(edge.target)
- console.log(`边 ${edge.source} -> ${edge.target}:`,
- 'srcPos=', srcPos ? `(${srcPos.x.toFixed(0)},${srcPos.y.toFixed(0)})` : 'null',
- 'tgtPos=', tgtPos ? `(${tgtPos.x.toFixed(0)},${tgtPos.y.toFixed(0)})` : 'null'
- )
- if (srcPos && tgtPos) {
- edgesData.push({
- ...edge,
- srcX: srcPos.x,
- srcY: srcPos.y,
- tgtX: tgtPos.x,
- tgtY: tgtPos.y
- })
- } else {
- console.log(' ⚠️ 跳过:位置缺失')
- }
- }
- console.log('实际绘制边数:', edgesData.length)
- walkedG.selectAll('.walked-link')
- .data(edgesData)
- .join('path')
- .attr('class', 'walked-link')
- .attr('fill', 'none')
- .attr('stroke', d => getEdgeStyle({ type: d.type, score: d.score }).color)
- .attr('stroke-opacity', d => getEdgeStyle({ type: d.type, score: d.score }).opacity)
- .attr('stroke-width', d => getEdgeStyle({ type: d.type, score: d.score }).strokeWidth)
- .attr('stroke-dasharray', d => getEdgeStyle({ type: d.type, score: d.score }).strokeDasharray)
- .style('cursor', 'pointer')
- .on('click', (e, d) => {
- e.stopPropagation()
- store.selectEdge({
- source: d.source,
- target: d.target,
- type: d.type,
- score: d.score
- })
- })
- .attr('d', d => {
- // 同一层的边(Y坐标相近)用向下弯曲的曲线
- if (Math.abs(d.srcY - d.tgtY) < 10) {
- const controlY = d.srcY + 50 // 向下弯曲
- const midX = (d.srcX + d.tgtX) / 2
- return `M${d.srcX},${d.srcY} Q${midX},${controlY} ${d.tgtX},${d.tgtY}`
- }
- // 不同层的边用 S 形曲线
- const midY = (d.srcY + d.tgtY) / 2
- return `M${d.srcX},${d.srcY} C${d.srcX},${midY} ${d.tgtX},${midY} ${d.tgtX},${d.tgtY}`
- })
- // 绘制分数标签
- const scoreData = edgesData.filter(d => getEdgeStyle({ type: d.type, score: d.score }).scoreText)
- const scoreGroups = walkedG.selectAll('.walked-score')
- .data(scoreData)
- .join('g')
- .attr('class', 'walked-score')
- .attr('transform', d => {
- const midX = (d.srcX + d.tgtX) / 2
- // 同一层的边用向下弯曲曲线,分数标签放在曲线中点(t=0.5 时 y = srcY + 25)
- const midY = Math.abs(d.srcY - d.tgtY) < 10 ? d.srcY + 25 : (d.srcY + d.tgtY) / 2
- return `translate(${midX}, ${midY})`
- })
- scoreGroups.append('rect')
- .attr('x', -14).attr('y', -6).attr('width', 28).attr('height', 12)
- .attr('rx', 2).attr('fill', '#1d232a').attr('opacity', 0.9)
- scoreGroups.append('text')
- .attr('text-anchor', 'middle').attr('dy', '0.35em')
- .attr('fill', d => getEdgeStyle({ type: d.type, score: d.score }).color)
- .attr('font-size', '8px')
- .text(d => getEdgeStyle({ type: d.type, score: d.score }).scoreText)
- // ========== 绘制新节点(nodePositions 中的都是新节点) ==========
- const newNodesData = []
- for (const [nodeId, pos] of Object.entries(nodePositions)) {
- // 获取节点数据
- const nodeData = store.postWalkedNodes.find(n => n.id === nodeId)
- if (nodeData) {
- newNodesData.push({ ...nodeData, x: pos.x, y: pos.y })
- }
- }
- const walkedNodeGroups = walkedG.selectAll('.walked-node')
- .data(newNodesData)
- .join('g')
- .attr('class', 'walked-node')
- .attr('transform', d => `translate(${d.x},${d.y})`)
- .style('cursor', 'pointer')
- .on('click', (event, d) => {
- event.stopPropagation()
- // 锁定状态下点击节点无效果,但提醒用户
- if (store.lockedHoverNodeId) {
- shakeLockButton()
- return
- }
- store.selectNode(d)
- })
- // 节点形状
- walkedNodeGroups.each(function(d) {
- const el = d3.select(this)
- const style = getNodeStyle(d)
- applyNodeShape(el, style).attr('class', 'walked-shape')
- nodeElements[d.id] = { element: this, x: d.x + 50, y: d.y + 25 }
- })
- // 节点标签
- walkedNodeGroups.append('text')
- .attr('dy', 4).attr('dx', 10).attr('text-anchor', 'start')
- .attr('fill', d => getNodeStyle(d).text.fill)
- .attr('font-size', d => getNodeStyle(d).text.fontSize)
- .attr('font-weight', d => getNodeStyle(d).text.fontWeight)
- .text(d => {
- const name = d.name
- const maxLen = 10
- return name.length > maxLen ? name.slice(0, maxLen) + '…' : name
- })
- // 更新高度(如果有新节点)
- if (Object.keys(nodePositions).length > 0) {
- const maxY = Math.max(...Object.values(nodePositions).map(p => p.y))
- treeHeight = Math.max(treeHeight, maxY + 50)
- }
- // ========== 添加 hover 效果(所有元素创建后统一添加) ==========
- setupHoverHandlers()
- }
- // 在节点文字后面添加锁定按钮(作为独立的 tspan)
- function showLockButton(nodeEl, immediate = false) {
- if (!nodeEl) return
- const node = d3.select(nodeEl)
- const textEl = node.select('text')
- if (textEl.empty()) return
- // 获取当前节点 ID
- const nodeData = node.datum()
- const currentNodeId = nodeData?.data?.id || nodeData?.id
- // 判断当前节点是否是已锁定的节点
- const isThisNodeLocked = store.lockedHoverNodeId && store.lockedHoverNodeId === currentNodeId
- // 如果已有按钮,只更新状态
- let btn = textEl.select('.lock-btn')
- if (!btn.empty()) {
- btn.text(isThisNodeLocked ? ' 🔓解锁' : ' 🔒锁定')
- .attr('fill', isThisNodeLocked ? '#f6ad55' : '#63b3ed')
- if (!isThisNodeLocked) startBreathingAnimation(btn)
- return
- }
- // 创建按钮的函数
- const createBtn = () => {
- // 先清除当前 SVG 内其他节点的按钮(不影响另一边)
- if (svgRef.value) {
- d3.select(svgRef.value).selectAll('.lock-btn').remove()
- }
- // 添加按钮 tspan(紧跟在文字后面)
- const btn = textEl.append('tspan')
- .attr('class', 'lock-btn')
- .attr('fill', isThisNodeLocked ? '#f6ad55' : '#63b3ed')
- .attr('font-weight', 'bold')
- .style('cursor', 'pointer')
- .text(isThisNodeLocked ? ' 🔓解锁' : ' 🔒锁定')
- .on('click', (e) => {
- e.stopPropagation()
- handleLockClick()
- })
- // 呼吸灯动画(未锁定时)
- if (!isThisNodeLocked) {
- startBreathingAnimation(btn)
- }
- }
- createBtn()
- }
- // 呼吸灯动画(只有按钮部分,蓝色呼吸)
- function startBreathingAnimation(btn) {
- function breathe() {
- if (btn.empty() || !btn.node()) return
- if (store.lockedHoverNodeId) return
- btn
- .transition()
- .duration(800)
- .attr('fill', '#90cdf4')
- .transition()
- .duration(800)
- .attr('fill', '#63b3ed')
- .on('end', breathe)
- }
- breathe()
- }
- // 隐藏锁定按钮
- function hideLockButton() {
- if (svgRef.value) {
- d3.select(svgRef.value).selectAll('.lock-btn').interrupt().remove()
- }
- }
- // 抖动锁定按钮(提醒用户需要先解锁)
- function shakeLockButton() {
- d3.selectAll('.lock-btn')
- .interrupt()
- .attr('fill', '#fc8181') // 红色警告
- .transition().duration(50).attr('dx', 3)
- .transition().duration(50).attr('dx', -3)
- .transition().duration(50).attr('dx', 3)
- .transition().duration(50).attr('dx', -3)
- .transition().duration(50).attr('dx', 0)
- .transition().duration(200).attr('fill', '#f6ad55') // 恢复橙色
- }
- // 处理锁定按钮点击
- function handleLockClick() {
- const startNodeId = store.selectedNodeId
- const currentHoverNodeId = store.hoverNodeId
- // 判断是解锁还是新锁定
- if (store.lockedHoverNodeId && store.lockedHoverNodeId === currentHoverNodeId) {
- // 点击的是当前锁定节点的按钮 → 解锁(弹出栈)
- store.clearLockedHover()
- // 如果还有上一层锁定,更新按钮状态
- if (store.lockedHoverNodeId) {
- d3.selectAll('.lock-btn')
- .interrupt()
- .text(' 🔓解锁')
- .attr('fill', '#f6ad55')
- } else {
- // 完全解锁,清除按钮
- d3.selectAll('.lock-btn').interrupt().remove()
- }
- } else if (currentHoverNodeId) {
- // 点击的是新 hover 节点的按钮 → 锁定新路径(压入栈)
- store.lockCurrentHover(startNodeId)
- // 更新按钮状态
- d3.selectAll('.lock-btn')
- .interrupt()
- .text(' 🔓解锁')
- .attr('fill', '#f6ad55')
- }
- }
- // 设置 hover 处理器(在所有元素创建后调用)
- function setupHoverHandlers() {
- if (!svgRef.value || !store.selectedNodeId) return
- const svg = d3.select(svgRef.value)
- const allNodes = svg.selectAll('.tree-node, .match-node, .walked-node')
- const startNodeId = store.selectedNodeId
- // 如果已有锁定状态,显示解锁按钮
- if (store.lockedHoverNodeId) {
- const lockedNodeInfo = nodeElements[store.lockedHoverNodeId]
- if (lockedNodeInfo?.element) {
- showLockButton(lockedNodeInfo.element, true)
- }
- }
- // 添加 hover 处理器(路径计算由 store 统一处理)
- allNodes
- .on('mouseenter', function(event, d) {
- const nodeId = d.data?.id || d.id
- // 排除起始节点
- if (nodeId === startNodeId) return
- store.computeHoverPath(startNodeId, nodeId, 'post-tree')
- store.setHoverNode(d) // 设置 hover 节点数据用于详情显示
- // 显示锁定按钮(在当前hover节点上)
- if (store.hoverPathNodes.size > 0) {
- // 如果已锁定,按钮显示在锁定节点上
- if (store.lockedHoverNodeId) {
- const lockedNodeInfo = nodeElements[store.lockedHoverNodeId]
- if (lockedNodeInfo?.element) {
- showLockButton(lockedNodeInfo.element, true)
- }
- } else {
- // 未锁定,按钮显示在当前hover节点上
- showLockButton(this)
- }
- }
- })
- .on('mouseleave', () => {
- // 调用 clearHover 恢复状态(如果已锁定会恢复到锁定路径)
- store.clearHover()
- if (store.lockedHoverNodeId) {
- // 已锁定:恢复锁定路径高亮,并在锁定节点上显示按钮
- const svg = d3.select(svgRef.value)
- const allNodes = svg.selectAll('.tree-node, .match-node, .walked-node')
- const allLinks = svg.selectAll('.tree-link, .match-link, .walked-link')
- const allLabels = svg.selectAll('.match-score, .walked-score')
- // 恢复到纯锁定路径高亮(不传 lockedPath,因为这就是唯一的路径)
- applyHoverHighlight(allNodes, allLinks, allLabels, store.hoverPathNodes, null)
- // 在锁定节点上显示解锁按钮
- const lockedNodeInfo = nodeElements[store.lockedHoverNodeId]
- if (lockedNodeInfo?.element) {
- showLockButton(lockedNodeInfo.element, true)
- }
- } else {
- hideLockButton()
- }
- })
- }
- // 匹配节点点击处理
- function handleMatchNodeClick(event, d) {
- event.stopPropagation()
- // 锁定状态下点击节点无效果,但提醒用户
- if (store.lockedHoverNodeId) {
- shakeLockButton()
- return
- }
- 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') {
- // 使用 getNodeName 获取节点名称
- return getNodeName(value)
- }
- return formatValue(value)
- }
- // 适应视图(自动缩放以显示全部内容)
- function fitToView() {
- if (!zoom || !mainG || !containerRef.value) return
- const container = containerRef.value
- const width = container.clientWidth
- const height = container.clientHeight
- // 计算缩放比例以适应容器
- const scaleX = width / treeWidth
- const scaleY = height / treeHeight
- const scale = Math.min(scaleX, scaleY, 1) * 0.9 // 留一点边距
- // 计算居中偏移
- const translateX = (width - treeWidth * scale) / 2
- const translateY = (height - treeHeight * scale) / 2
- const svg = d3.select(svgRef.value)
- svg.call(zoom.transform, d3.zoomIdentity.translate(translateX, translateY).scale(scale))
- }
- // 定位到指定节点
- function zoomToNode(nodeId) {
- const nodeInfo = nodeElements[nodeId]
- if (!nodeInfo || !zoom || !containerRef.value) return
- const container = containerRef.value
- const width = container.clientWidth
- const height = container.clientHeight
- // 计算平移使节点居中
- const scale = 1
- const translateX = width / 2 - nodeInfo.x * scale
- const translateY = height / 2 - nodeInfo.y * scale
- const svg = d3.select(svgRef.value)
- svg.transition().duration(300).call(
- zoom.transform,
- d3.zoomIdentity.translate(translateX, translateY).scale(scale)
- )
- }
- // 定位到指定边(显示边的两个端点)
- 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() {
- // 使用帖子游走的边集合(如果有),否则用人设游走的边集合
- const edgeSet = store.postWalkedEdgeSet.size > 0 ? store.postWalkedEdgeSet : store.walkedEdgeSet
- applyHighlight(svgRef.value, store.highlightedNodeIds, edgeSet, store.selectedNodeId)
- }
- // 点击空白取消(锁定状态下无效果)
- function handleSvgClick(event) {
- // 锁定状态下,点击空白无效果
- if (store.lockedHoverNodeId) return
- const target = event.target
- if (!target.closest('.tree-node') && !target.closest('.match-node') && !target.closest('.walked-node')) {
- store.clearSelection()
- hideLockButton()
- }
- }
- // 监听选中/高亮变化,统一更新
- watch(() => store.selectedNodeId, (nodeId, oldNodeId) => {
- updateHighlight()
- if (nodeId && nodeId !== oldNodeId) {
- zoomToNode(nodeId)
- }
- // 重新设置 hover 处理器(起点改变了)
- nextTick(() => setupHoverHandlers())
- })
- watch(() => store.selectedEdgeId, updateHighlight)
- // 监听聚焦边端点变化(由 store 统一管理)
- watch(() => store.focusEdgeEndpoints, (endpoints) => {
- if (endpoints) {
- nextTick(() => {
- zoomToEdge(endpoints.source, endpoints.target)
- })
- }
- })
- watch(() => store.highlightedNodeIds.size, updateHighlight)
- // 监听 hover 状态变化(用于左右联动)
- watch([() => store.hoverPathNodes.size, () => store.hoverNodeId], () => {
- if (!svgRef.value) return
- const svg = d3.select(svgRef.value)
- const allNodes = svg.selectAll('.tree-node, .match-node, .walked-node')
- const allLinks = svg.selectAll('.tree-link, .match-link, .walked-link')
- const allLabels = svg.selectAll('.match-score, .walked-score')
- if (store.hoverPathNodes.size > 0) {
- // 应用 hover 高亮(支持嵌套:传入锁定路径)
- const lockedPath = store.lockedHoverPathNodes.size > 0 ? store.lockedHoverPathNodes : null
- applyHoverHighlight(allNodes, allLinks, allLabels, store.hoverPathNodes, lockedPath)
- // 如果是从 GraphView 触发的,缩放到显示完整路径
- if (store.hoverSource === 'graph') {
- zoomToPathNodes(store.hoverPathNodes)
- }
- // 在对应节点上显示锁定按钮(无论来源)
- if (store.hoverNodeId) {
- const nodeInfo = nodeElements[store.hoverNodeId]
- if (nodeInfo?.element) {
- showLockButton(nodeInfo.element)
- }
- }
- } else {
- // 清除 hover,恢复原有高亮
- updateHighlight()
- // 如果没有锁定,隐藏按钮
- if (!store.lockedHoverNodeId) {
- hideLockButton()
- }
- }
- })
- // 缩放到显示路径上的所有节点
- function zoomToPathNodes(pathNodes) {
- if (!zoom || !containerRef.value || !svgRef.value) return
- // 收集路径节点的位置
- const positions = []
- for (const nodeId of pathNodes) {
- const nodeInfo = nodeElements[nodeId]
- if (nodeInfo) {
- positions.push({ x: nodeInfo.x, y: nodeInfo.y })
- }
- }
- if (positions.length === 0) return
- // 计算边界框
- const minX = Math.min(...positions.map(p => p.x))
- const maxX = Math.max(...positions.map(p => p.x))
- const minY = Math.min(...positions.map(p => p.y))
- const maxY = Math.max(...positions.map(p => p.y))
- const width = containerRef.value.clientWidth
- const height = containerRef.value.clientHeight
- const padding = 60
- // 计算需要的缩放和平移
- const boxWidth = maxX - minX + padding * 2
- const boxHeight = maxY - minY + padding * 2
- const scale = Math.min(width / boxWidth, height / boxHeight, 1.5)
- const centerX = (minX + maxX) / 2
- const centerY = (minY + maxY) / 2
- const translateX = width / 2 - centerX * scale
- const translateY = height / 2 - centerY * scale
- const svg = d3.select(svgRef.value)
- svg.transition().duration(200).call(
- zoom.transform,
- d3.zoomIdentity.translate(translateX, translateY).scale(scale)
- )
- }
- // 监听帖子游走结果变化,渲染游走层
- watch(() => store.postWalkedNodes.length, () => {
- nextTick(renderWalkedLayer)
- })
- // 监听当前帖子变化,重新渲染树
- watch(() => store.currentPostGraph, () => {
- nextTick(() => {
- renderTree()
- })
- }, { immediate: false })
- // 监听 selectedPostIndex 变化,同步下拉框
- watch(() => store.selectedPostIndex, (newIdx) => {
- selectedPostIdx.value = newIdx
- })
- // 恢复锁定的 hover 状态(重新渲染后调用)
- function restoreLockedHover() {
- if (!store.lockedHoverNodeId || !svgRef.value) return
- const svg = d3.select(svgRef.value)
- const allNodes = svg.selectAll('.tree-node, .match-node, .walked-node')
- const allLinks = svg.selectAll('.tree-link, .match-link, .walked-link')
- const allLabels = svg.selectAll('.match-score, .walked-score')
- // 恢复高亮效果(传入锁定路径)
- if (store.hoverPathNodes.size > 0) {
- const lockedPath = store.lockedHoverPathNodes.size > 0 ? store.lockedHoverPathNodes : null
- applyHoverHighlight(allNodes, allLinks, allLabels, store.hoverPathNodes, lockedPath)
- }
- // 恢复锁定按钮
- const lockedNodeInfo = nodeElements[store.lockedHoverNodeId]
- if (lockedNodeInfo?.element) {
- showLockButton(lockedNodeInfo.element, true)
- }
- }
- // 监听布局变化,过渡结束后重新适应视图
- function handleTransitionEnd(e) {
- if (['width', 'height', 'flex', 'flex-grow', 'flex-shrink'].includes(e.propertyName)) {
- nextTick(() => {
- renderTree()
- nextTick(() => {
- renderWalkedLayer() // 重新渲染游走层
- nextTick(() => {
- updateHighlight() // 重新应用高亮状态
- restoreLockedHover() // 恢复锁定的 hover 状态
- })
- })
- })
- }
- }
- let transitionParent = null
- onMounted(() => {
- nextTick(() => {
- renderTree()
- })
- // 监听父容器的过渡结束事件
- if (containerRef.value) {
- let parent = containerRef.value.parentElement
- while (parent && !parent.classList.contains('transition-all')) {
- parent = parent.parentElement
- }
- if (parent) {
- transitionParent = parent
- parent.addEventListener('transitionend', handleTransitionEnd)
- }
- }
- })
- onUnmounted(() => {
- if (transitionParent) {
- transitionParent.removeEventListener('transitionend', handleTransitionEnd)
- }
- })
- </script>
|