|
|
@@ -0,0 +1,310 @@
|
|
|
+<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>
|
|
|
+ <span v-if="store.highlightedPostNodeIds.size > 0" class="text-primary">
|
|
|
+ 已高亮 {{ store.highlightedPostNodeIds.size }} 个节点
|
|
|
+ </span>
|
|
|
+ </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
|
|
|
+ v-for="post in store.postList"
|
|
|
+ :key="post.index"
|
|
|
+ :value="post.index"
|
|
|
+ >
|
|
|
+ {{ formatPostOption(post) }}
|
|
|
+ </option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- SVG 容器 -->
|
|
|
+ <div ref="containerRef" class="flex-1 overflow-auto bg-base-100">
|
|
|
+ <svg ref="svgRef" class="block" @click="handleSvgClick"></svg>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+import { ref, computed, onMounted, watch } from 'vue'
|
|
|
+import * as d3 from 'd3'
|
|
|
+import { useGraphStore } from '../stores/graph'
|
|
|
+
|
|
|
+const store = useGraphStore()
|
|
|
+
|
|
|
+const containerRef = ref(null)
|
|
|
+const svgRef = ref(null)
|
|
|
+
|
|
|
+// 当前选中的帖子索引
|
|
|
+const selectedPostIdx = ref(store.selectedPostIndex)
|
|
|
+
|
|
|
+// 帖子选择变化
|
|
|
+function onPostChange() {
|
|
|
+ store.selectPost(selectedPostIdx.value)
|
|
|
+}
|
|
|
+
|
|
|
+// 格式化帖子选项显示
|
|
|
+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
|
|
|
+}
|
|
|
+
|
|
|
+// 维度颜色映射(与人设树保持一致)
|
|
|
+const dimColors = {
|
|
|
+ '帖子': '#e94560',
|
|
|
+ '灵感点': '#f39c12',
|
|
|
+ '目的点': '#3498db',
|
|
|
+ '关键点': '#9b59b6'
|
|
|
+}
|
|
|
+
|
|
|
+// 节点元素映射
|
|
|
+let nodeElements = {}
|
|
|
+let currentRoot = null
|
|
|
+
|
|
|
+// 获取节点颜色
|
|
|
+function getNodeColor(d) {
|
|
|
+ if (d.data.type === '帖子') return dimColors['帖子']
|
|
|
+ return dimColors[d.data.dimension] || '#888'
|
|
|
+}
|
|
|
+
|
|
|
+// 处理节点点击
|
|
|
+function handleNodeClick(event, d) {
|
|
|
+ event.stopPropagation()
|
|
|
+ const nodeId = d.data.id
|
|
|
+ store.selectPostNode(nodeId)
|
|
|
+ updateSelection()
|
|
|
+}
|
|
|
+
|
|
|
+// 渲染树
|
|
|
+function renderTree() {
|
|
|
+ const svg = d3.select(svgRef.value)
|
|
|
+ svg.selectAll('*').remove()
|
|
|
+ nodeElements = {}
|
|
|
+
|
|
|
+ const treeData = store.postTreeData
|
|
|
+ if (!treeData || !treeData.id) return
|
|
|
+
|
|
|
+ // 创建层级数据
|
|
|
+ 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 treeHeight = Math.max(600, leafCount * 20 + 100)
|
|
|
+ // 宽度:根据深度
|
|
|
+ const treeWidth = Math.max(400, (maxDepth + 1) * 120 + 50)
|
|
|
+
|
|
|
+ svg.attr('width', treeWidth).attr('height', treeHeight + 50)
|
|
|
+
|
|
|
+ // 创建树布局
|
|
|
+ const treeLayout = d3.tree()
|
|
|
+ .size([treeHeight - 50, treeWidth - 100])
|
|
|
+ .separation((a, b) => a.parent === b.parent ? 1 : 1.2)
|
|
|
+
|
|
|
+ treeLayout(root)
|
|
|
+
|
|
|
+ // 创建主组
|
|
|
+ const g = svg.append('g')
|
|
|
+ .attr('transform', 'translate(25, 25)')
|
|
|
+
|
|
|
+ // 绘制边
|
|
|
+ g.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 midX = (d.source.y + d.target.y) / 2
|
|
|
+ return `M${d.source.y},${d.source.x} C${midX},${d.source.x} ${midX},${d.target.x} ${d.target.y},${d.target.x}`
|
|
|
+ })
|
|
|
+
|
|
|
+ // 绘制节点
|
|
|
+ const nodes = g.append('g')
|
|
|
+ .attr('class', 'tree-nodes')
|
|
|
+ .selectAll('.tree-node')
|
|
|
+ .data(root.descendants())
|
|
|
+ .join('g')
|
|
|
+ .attr('class', d => {
|
|
|
+ let cls = 'tree-node'
|
|
|
+ if (store.selectedPostNodeId === d.data.id) cls += ' selected'
|
|
|
+ if (store.highlightedPostNodeIds.has(d.data.id)) cls += ' highlighted'
|
|
|
+ return cls
|
|
|
+ })
|
|
|
+ .attr('transform', d => `translate(${d.y},${d.x})`)
|
|
|
+ .style('cursor', 'pointer')
|
|
|
+ .on('click', handleNodeClick)
|
|
|
+
|
|
|
+ // 节点形状
|
|
|
+ nodes.each(function(d) {
|
|
|
+ const el = d3.select(this)
|
|
|
+ const nodeType = d.data.type
|
|
|
+ const nodeColor = getNodeColor(d)
|
|
|
+ const isRoot = d.depth === 0
|
|
|
+ const isDimension = ['灵感点', '目的点', '关键点'].includes(nodeType)
|
|
|
+
|
|
|
+ if (nodeType === '点') {
|
|
|
+ // 点用方形
|
|
|
+ el.append('rect')
|
|
|
+ .attr('class', 'tree-shape')
|
|
|
+ .attr('x', -4)
|
|
|
+ .attr('y', -4)
|
|
|
+ .attr('width', 8)
|
|
|
+ .attr('height', 8)
|
|
|
+ .attr('rx', 1)
|
|
|
+ .attr('fill', nodeColor)
|
|
|
+ .attr('stroke', 'rgba(255,255,255,0.5)')
|
|
|
+ .attr('stroke-width', 1)
|
|
|
+ } else {
|
|
|
+ const radius = isRoot ? 6 : (isDimension ? 5 : 3)
|
|
|
+ el.append('circle')
|
|
|
+ .attr('class', 'tree-shape')
|
|
|
+ .attr('r', radius)
|
|
|
+ .attr('fill', nodeColor)
|
|
|
+ .attr('stroke', 'rgba(255,255,255,0.5)')
|
|
|
+ .attr('stroke-width', 1)
|
|
|
+ }
|
|
|
+
|
|
|
+ nodeElements[d.data.id] = this
|
|
|
+ })
|
|
|
+
|
|
|
+ // 节点标签
|
|
|
+ nodes.append('text')
|
|
|
+ .attr('dy', '0.31em')
|
|
|
+ .attr('x', d => d.children ? -8 : 8)
|
|
|
+ .attr('text-anchor', d => d.children ? 'end' : 'start')
|
|
|
+ .attr('fill', d => {
|
|
|
+ const isRoot = d.depth === 0
|
|
|
+ const isDimension = ['灵感点', '目的点', '关键点'].includes(d.data.type)
|
|
|
+ return (isRoot || isDimension) ? getNodeColor(d) : '#bbb'
|
|
|
+ })
|
|
|
+ .attr('font-size', d => {
|
|
|
+ const isRoot = d.depth === 0
|
|
|
+ const isDimension = ['灵感点', '目的点', '关键点'].includes(d.data.type)
|
|
|
+ return isRoot ? '11px' : (isDimension ? '10px' : '9px')
|
|
|
+ })
|
|
|
+ .attr('font-weight', d => {
|
|
|
+ const isRoot = d.depth === 0
|
|
|
+ const isDimension = ['灵感点', '目的点', '关键点'].includes(d.data.type)
|
|
|
+ return (isRoot || isDimension) ? 'bold' : 'normal'
|
|
|
+ })
|
|
|
+ .text(d => {
|
|
|
+ const name = d.data.name
|
|
|
+ const maxLen = d.children ? 6 : 8
|
|
|
+ return name.length > maxLen ? name.slice(0, maxLen) + '…' : name
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// 滚动到指定节点
|
|
|
+function scrollToNode(nodeId) {
|
|
|
+ const nodeEl = nodeElements[nodeId]
|
|
|
+ if (!nodeEl) return
|
|
|
+
|
|
|
+ const container = containerRef.value
|
|
|
+ const nodeRect = nodeEl.getBoundingClientRect()
|
|
|
+ const containerRect = container.getBoundingClientRect()
|
|
|
+
|
|
|
+ const scrollTop = container.scrollTop + nodeRect.top - containerRect.top - containerRect.height / 2
|
|
|
+ const scrollLeft = container.scrollLeft + nodeRect.left - containerRect.left - containerRect.width / 2
|
|
|
+
|
|
|
+ container.scrollTo({
|
|
|
+ top: Math.max(0, scrollTop),
|
|
|
+ left: Math.max(0, scrollLeft),
|
|
|
+ behavior: 'smooth'
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// 更新选中/高亮状态
|
|
|
+function updateSelection() {
|
|
|
+ const svg = d3.select(svgRef.value)
|
|
|
+ const hasHighlight = store.highlightedPostNodeIds.size > 0
|
|
|
+
|
|
|
+ svg.selectAll('.tree-node')
|
|
|
+ .classed('selected', d => store.selectedPostNodeId === d.data.id)
|
|
|
+ .classed('highlighted', d => store.highlightedPostNodeIds.has(d.data.id))
|
|
|
+ .classed('dimmed', d => hasHighlight && !store.highlightedPostNodeIds.has(d.data.id))
|
|
|
+
|
|
|
+ svg.selectAll('.tree-link')
|
|
|
+ .classed('highlighted', d => {
|
|
|
+ return store.highlightedPostNodeIds.has(d.source.data.id) &&
|
|
|
+ store.highlightedPostNodeIds.has(d.target.data.id)
|
|
|
+ })
|
|
|
+ .classed('dimmed', d => {
|
|
|
+ return hasHighlight && !(store.highlightedPostNodeIds.has(d.source.data.id) &&
|
|
|
+ store.highlightedPostNodeIds.has(d.target.data.id))
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// 清除高亮
|
|
|
+function clearHighlight() {
|
|
|
+ store.clearPostSelection()
|
|
|
+ updateSelection()
|
|
|
+}
|
|
|
+
|
|
|
+// 点击空白取消激活
|
|
|
+function handleSvgClick(event) {
|
|
|
+ if (event.target.tagName === 'svg') {
|
|
|
+ clearHighlight()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 滚动到根节点
|
|
|
+function scrollToRoot() {
|
|
|
+ if (!currentRoot) return
|
|
|
+ const container = containerRef.value
|
|
|
+ if (!container) return
|
|
|
+
|
|
|
+ const rootY = currentRoot.x + 25
|
|
|
+ const targetScroll = rootY - container.clientHeight / 2
|
|
|
+ container.scrollTop = Math.max(0, targetScroll)
|
|
|
+}
|
|
|
+
|
|
|
+// 监听选中变化
|
|
|
+watch(() => store.selectedPostNodeId, (nodeId, oldNodeId) => {
|
|
|
+ if (nodeId && nodeId !== oldNodeId) {
|
|
|
+ updateSelection()
|
|
|
+ scrollToNode(nodeId)
|
|
|
+ } else if (!nodeId) {
|
|
|
+ updateSelection()
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+// 监听高亮变化
|
|
|
+watch(() => store.highlightedPostNodeIds.size, () => {
|
|
|
+ updateSelection()
|
|
|
+})
|
|
|
+
|
|
|
+// 监听当前帖子变化,重新渲染树
|
|
|
+watch(() => store.currentPostGraph, () => {
|
|
|
+ renderTree()
|
|
|
+ setTimeout(scrollToRoot, 100)
|
|
|
+}, { immediate: false })
|
|
|
+
|
|
|
+// 监听 selectedPostIndex 变化,同步下拉框
|
|
|
+watch(() => store.selectedPostIndex, (newIdx) => {
|
|
|
+ selectedPostIdx.value = newIdx
|
|
|
+})
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ renderTree()
|
|
|
+ setTimeout(scrollToRoot, 100)
|
|
|
+})
|
|
|
+</script>
|