فهرست منبع

feat: 添加帖子树可视化,优化布局为三层结构

- 新增 build_post_tree.py 预构建帖子树数据
- 帖子树展示:帖子卡片 -> 维度 -> 点(六边形) -> 标签
- 移除帖子点圆,改为三层垂直布局(帖子标签、人设匹配1层、人设匹配2层)
- 帖子卡片白色边框,可点击查看详情模态框
- 点->标签边显示权重(浮点数)
- 边统一连接到帖子卡片下边框中心

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui 5 روز پیش
والد
کامیت
8d67e969d1
2فایلهای تغییر یافته به همراه792 افزوده شده و 54 حذف شده
  1. 185 0
      script/data_processing/build_post_tree.py
  2. 607 54
      script/data_processing/visualize_match_graph.py

+ 185 - 0
script/data_processing/build_post_tree.py

@@ -0,0 +1,185 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+构建帖子树的中间数据
+
+输入:match_graph/*.json, results/*.json
+输出:match_graph/post_trees.json(包含所有帖子的树结构)
+"""
+
+import json
+from pathlib import Path
+import sys
+
+# 添加项目根目录到路径
+project_root = Path(__file__).parent.parent.parent
+sys.path.insert(0, str(project_root))
+
+from script.data_processing.path_config import PathConfig
+
+
+def build_post_trees():
+    """构建所有帖子的树数据"""
+    config = PathConfig()
+
+    print(f"账号: {config.account_name}")
+    print(f"输出版本: {config.output_version}")
+    print()
+
+    match_graph_dir = config.intermediate_dir / "match_graph"
+    results_dir = config.intermediate_dir.parent / "results"
+    output_file = match_graph_dir / "post_trees.json"
+
+    # 读取所有匹配图谱文件
+    graph_files = sorted(match_graph_dir.glob("*_match_graph.json"))
+    print(f"找到 {len(graph_files)} 个匹配图谱文件")
+
+    all_post_trees = []
+
+    for i, graph_file in enumerate(graph_files, 1):
+        print(f"\n[{i}/{len(graph_files)}] 处理: {graph_file.name}")
+
+        with open(graph_file, "r", encoding="utf-8") as f:
+            match_graph_data = json.load(f)
+
+        post_id = match_graph_data["说明"]["帖子ID"]
+        post_title = match_graph_data["说明"].get("帖子标题", "")
+
+        # 读取完整帖子详情
+        post_detail = {
+            "title": post_title,
+            "post_id": post_id
+        }
+        how_file = results_dir / f"{post_id}_how.json"
+        if how_file.exists():
+            with open(how_file, "r", encoding="utf-8") as f:
+                how_data = json.load(f)
+                if "帖子详情" in how_data:
+                    post_detail = how_data["帖子详情"]
+                    post_detail["post_id"] = post_id
+            print(f"  读取帖子详情: {how_file.name}")
+
+        # 获取帖子点和帖子标签
+        post_points = match_graph_data.get("帖子点节点列表", [])
+        post_tags = match_graph_data.get("帖子标签节点列表", [])
+        belong_edges = match_graph_data.get("帖子属于边列表", [])
+
+        print(f"  帖子点: {len(post_points)}, 帖子标签: {len(post_tags)}, 属于边: {len(belong_edges)}")
+
+        # 构建树结构
+        # 维度颜色
+        dim_colors = {
+            "灵感点": "#f39c12",
+            "目的点": "#3498db",
+            "关键点": "#9b59b6"
+        }
+
+        # 构建节点映射
+        point_map = {}
+        for n in post_points:
+            point_map[n["节点ID"]] = {
+                "id": n["节点ID"],
+                "name": n["节点名称"],
+                "nodeType": "点",
+                "level": n.get("节点层级", ""),
+                "dimColor": dim_colors.get(n.get("节点层级", ""), "#888"),
+                "description": n.get("描述", ""),
+                "children": []
+            }
+
+        tag_map = {}
+        for n in post_tags:
+            tag_map[n["节点ID"]] = {
+                "id": n["节点ID"],
+                "name": n["节点名称"],
+                "nodeType": "标签",
+                "level": n.get("节点层级", ""),
+                "dimColor": dim_colors.get(n.get("节点层级", ""), "#888"),
+                "weight": n.get("权重", 0),
+                "children": []
+            }
+
+        # 根据属于边,把标签挂到点下面
+        for e in belong_edges:
+            tag_node = tag_map.get(e["源节点ID"])
+            point_node = point_map.get(e["目标节点ID"])
+            if tag_node and point_node:
+                point_node["children"].append(tag_node)
+
+        # 按维度分组点节点
+        dimensions = ["灵感点", "目的点", "关键点"]
+        dimension_children = []
+
+        for dim in dimensions:
+            dim_points = [
+                point_map[n["节点ID"]]
+                for n in post_points
+                if n.get("节点层级") == dim and n["节点ID"] in point_map
+            ]
+
+            if dim_points:
+                dim_node = {
+                    "id": f"dim_{dim}",
+                    "name": dim,
+                    "nodeType": "维度",
+                    "isDimension": True,
+                    "dimColor": dim_colors[dim],
+                    "children": dim_points
+                }
+                dimension_children.append(dim_node)
+
+        # 根节点(帖子)
+        root_node = {
+            "id": f"post_{post_id}",
+            "name": post_title[:20] + "..." if len(post_title) > 20 else post_title,
+            "nodeType": "帖子",
+            "isRoot": True,
+            "postDetail": post_detail,
+            "children": dimension_children
+        }
+
+        # 统计节点数
+        total_nodes = 1 + len(dimension_children)  # 根节点 + 维度节点
+        for dim_node in dimension_children:
+            total_nodes += len(dim_node["children"])  # 点节点
+            for point_node in dim_node["children"]:
+                total_nodes += len(point_node["children"])  # 标签节点
+
+        post_tree = {
+            "postId": post_id,
+            "postTitle": post_title,
+            "postDetail": post_detail,
+            "root": root_node,
+            "stats": {
+                "totalNodes": total_nodes,
+                "pointCount": len(post_points),
+                "tagCount": len(post_tags)
+            }
+        }
+
+        all_post_trees.append(post_tree)
+        print(f"  构建完成: {total_nodes} 个节点")
+
+    # 输出
+    output_data = {
+        "说明": {
+            "描述": "帖子树结构数据(每个帖子一棵树)",
+            "帖子数": len(all_post_trees)
+        },
+        "postTrees": all_post_trees
+    }
+
+    with open(output_file, "w", encoding="utf-8") as f:
+        json.dump(output_data, f, ensure_ascii=False, indent=2)
+
+    print()
+    print("=" * 60)
+    print(f"构建完成!")
+    print(f"  帖子数: {len(all_post_trees)}")
+    print(f"  输出文件: {output_file}")
+
+    return output_file
+
+
+if __name__ == "__main__":
+    build_post_trees()

+ 607 - 54
script/data_processing/visualize_match_graph.py

@@ -754,6 +754,186 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             stroke-opacity: 0.6;
             stroke-width: 1.5;
         }}
+
+        /* 帖子详情模态框 */
+        .post-detail-modal {{
+            display: none;
+            position: fixed;
+            z-index: 4000;
+            left: 0;
+            top: 0;
+            width: 100%;
+            height: 100%;
+            background: rgba(0, 0, 0, 0.7);
+            overflow: auto;
+        }}
+        .post-detail-modal.active {{
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            padding: 40px 20px;
+        }}
+        .post-detail-content {{
+            position: relative;
+            background: white;
+            border-radius: 16px;
+            max-width: 900px;
+            width: 100%;
+            max-height: 90vh;
+            overflow-y: auto;
+            box-shadow: 0 20px 60px rgba(0,0,0,0.3);
+        }}
+        .post-detail-close {{
+            position: sticky;
+            top: 20px;
+            right: 20px;
+            float: right;
+            font-size: 36px;
+            font-weight: 300;
+            color: #9ca3af;
+            background: white;
+            border: none;
+            cursor: pointer;
+            width: 40px;
+            height: 40px;
+            border-radius: 50%;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            z-index: 10;
+            box-shadow: 0 2px 8px rgba(0,0,0,0.1);
+        }}
+        .post-detail-close:hover {{
+            color: #ef4444;
+            background: #fee2e2;
+        }}
+        .post-detail-header {{
+            padding: 40px 40px 20px 40px;
+            border-bottom: 2px solid #e5e7eb;
+        }}
+        .post-detail-title {{
+            font-size: 28px;
+            font-weight: bold;
+            color: #111827;
+            line-height: 1.4;
+            margin-bottom: 15px;
+        }}
+        .post-detail-meta {{
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+            color: #6b7280;
+            font-size: 14px;
+            gap: 20px;
+        }}
+        .post-detail-stats {{
+            display: flex;
+            gap: 15px;
+            font-weight: 500;
+        }}
+        .post-detail-body {{
+            padding: 30px 40px;
+        }}
+        .post-detail-desc {{
+            font-size: 16px;
+            line-height: 1.8;
+            color: #374151;
+            margin-bottom: 25px;
+            white-space: pre-wrap;
+        }}
+        .post-detail-images {{
+            display: grid;
+            grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+            gap: 15px;
+        }}
+        .post-detail-image {{
+            width: 100%;
+            border-radius: 12px;
+            object-fit: cover;
+        }}
+        .post-detail-footer {{
+            padding: 20px 40px 30px;
+            text-align: center;
+            border-top: 1px solid #e5e7eb;
+        }}
+        .post-detail-link {{
+            display: inline-block;
+            padding: 12px 30px;
+            background: linear-gradient(135deg, #ff2442 0%, #ff6b81 100%);
+            color: white;
+            text-decoration: none;
+            border-radius: 25px;
+            font-weight: 600;
+        }}
+        /* 帖子树根节点卡片 */
+        .post-card {{
+            background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
+            border: 2px solid #fff;
+            border-radius: 12px;
+            cursor: pointer;
+            transition: all 0.3s ease;
+            box-shadow: 0 4px 15px rgba(255, 255, 255, 0.2);
+            height: 100%;
+            display: flex;
+            flex-direction: column;
+            justify-content: center;
+        }}
+        .post-card:hover {{
+            transform: scale(1.05);
+            box-shadow: 0 6px 25px rgba(255, 255, 255, 0.4);
+            border-color: #fff;
+        }}
+        .post-card-header {{
+            display: flex;
+            gap: 10px;
+            align-items: center;
+        }}
+        .post-card-thumbnail-wrapper {{
+            position: relative;
+            flex-shrink: 0;
+            border-radius: 8px;
+            overflow: hidden;
+        }}
+        .post-card-thumbnail {{
+            object-fit: cover;
+            border-radius: 8px;
+        }}
+        .post-card-image-count {{
+            position: absolute;
+            bottom: 2px;
+            right: 2px;
+            background: rgba(0,0,0,0.7);
+            color: #fff;
+            padding: 1px 4px;
+            border-radius: 3px;
+        }}
+        .post-card-thumbnail-placeholder {{
+            background: linear-gradient(135deg, #0f3460 0%, #1a1a2e 100%);
+            border-radius: 8px;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            color: #e94560;
+        }}
+        .post-card-info {{
+            flex: 1;
+            overflow: hidden;
+        }}
+        .post-card-title {{
+            color: #fff;
+            font-weight: 600;
+            line-height: 1.3;
+            display: -webkit-box;
+            -webkit-box-orient: vertical;
+            overflow: hidden;
+            text-overflow: ellipsis;
+        }}
+        .post-card-stats {{
+            color: #8892b0;
+            display: flex;
+            gap: 8px;
+            margin-top: 4px;
+        }}
     </style>
 </head>
 <body>
@@ -1118,6 +1298,156 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             }}
         }}
 
+        // 构建帖子树数据(从上到下:帖子 -> 维度 -> 点 -> 标签)
+        function buildPostTreeData(graphData) {{
+            const dimensions = ["灵感点", "目的点", "关键点"];
+            const dimColors = {{ "灵感点": "#f39c12", "目的点": "#3498db", "关键点": "#9b59b6" }};
+            const dimensionChildren = [];
+            let totalNodeCount = 0;
+            const postDetail = graphData.postDetail || {{}};
+
+            // 获取帖子点和帖子标签
+            const postPointNodes = graphData.帖子点节点列表 || [];
+            const postTagNodes = graphData.帖子标签节点列表 || [];
+            const belongEdges = graphData.帖子属于边列表 || [];
+
+            // 构建节点映射
+            const pointMap = {{}};
+            postPointNodes.forEach(n => {{
+                pointMap[n.节点ID] = {{
+                    ...n,
+                    id: n.节点ID,
+                    name: n.节点名称,
+                    nodeType: "点",
+                    dimColor: dimColors[n.节点层级],
+                    children: []
+                }};
+            }});
+
+            const tagMap = {{}};
+            postTagNodes.forEach(n => {{
+                tagMap[n.节点ID] = {{
+                    ...n,
+                    id: n.节点ID,
+                    name: n.节点名称,
+                    nodeType: "标签",
+                    dimColor: dimColors[n.节点层级],
+                    children: []
+                }};
+            }});
+
+            // 根据属于边,把标签挂到点下面
+            belongEdges.forEach(e => {{
+                const tagNode = tagMap[e.源节点ID];
+                const pointNode = pointMap[e.目标节点ID];
+                if (tagNode && pointNode) {{
+                    pointNode.children.push(tagNode);
+                }}
+            }});
+
+            // 按维度分组点节点
+            dimensions.forEach(dim => {{
+                const dimPoints = postPointNodes
+                    .filter(n => n.节点层级 === dim)
+                    .map(n => pointMap[n.节点ID]);
+
+                if (dimPoints.length > 0) {{
+                    const dimNode = {{
+                        name: dim,
+                        节点名称: dim,
+                        isDimension: true,
+                        dimColor: dimColors[dim],
+                        children: dimPoints
+                    }};
+
+                    dimensionChildren.push(dimNode);
+                    totalNodeCount += dimPoints.length;
+                    dimPoints.forEach(p => totalNodeCount += p.children.length);
+                }}
+            }});
+
+            // 根节点"帖子"
+            const title = postDetail.title || "帖子";
+            const rootNode = {{
+                name: title.length > 15 ? title.substring(0, 15) + "..." : title,
+                节点名称: title,
+                isRoot: true,
+                postDetail: postDetail,
+                children: dimensionChildren
+            }};
+
+            return {{ root: rootNode, nodeCount: totalNodeCount, tagMap: tagMap }};
+        }}
+
+        // 显示帖子详情模态框
+        function showPostDetail(postData) {{
+            if (!postData) return;
+
+            // 生成图片HTML
+            let imagesHtml = '';
+            if (postData.images && postData.images.length > 0) {{
+                imagesHtml = postData.images.map(img =>
+                    `<img src="${{img}}" class="post-detail-image" alt="图片" loading="lazy">`
+                ).join('');
+            }} else {{
+                imagesHtml = '<div style="text-align: center; color: #9ca3af; padding: 40px;">暂无图片</div>';
+            }}
+
+            const modalHtml = `
+                <div class="post-detail-content" onclick="event.stopPropagation()">
+                    <button class="post-detail-close" onclick="closePostDetail()">×</button>
+                    <div class="post-detail-header">
+                        <div class="post-detail-title">${{postData.title || '无标题'}}</div>
+                        <div class="post-detail-meta">
+                            <div>
+                                <span>👤 ${{postData.author || '未知作者'}}</span>
+                                <span> · 📅 ${{postData.publish_time || ''}}</span>
+                            </div>
+                            <div class="post-detail-stats">
+                                <span>👍 ${{postData.like_count || 0}}</span>
+                                <span>💬 ${{postData.comment_count || 0}}</span>
+                                <span>⭐ ${{postData.collect_count || 0}}</span>
+                            </div>
+                        </div>
+                    </div>
+                    <div class="post-detail-body">
+                        ${{postData.body_text ? `<div class="post-detail-desc">${{postData.body_text}}</div>` : '<div style="color: #9ca3af;">暂无正文</div>'}}
+                        <div class="post-detail-images">
+                            ${{imagesHtml}}
+                        </div>
+                    </div>
+                    <div class="post-detail-footer">
+                        <a href="${{postData.link || '#'}}" target="_blank" class="post-detail-link">
+                            在小红书查看完整内容 →
+                        </a>
+                    </div>
+                </div>
+            `;
+
+            let modal = document.getElementById('postDetailModal');
+            if (!modal) {{
+                modal = document.createElement('div');
+                modal.id = 'postDetailModal';
+                modal.className = 'post-detail-modal';
+                modal.onclick = closePostDetail;
+                document.body.appendChild(modal);
+            }}
+
+            modal.innerHTML = modalHtml;
+            modal.classList.add('active');
+            document.body.style.overflow = 'hidden';
+        }}
+
+        function closePostDetail(event) {{
+            if (event && event.target !== event.currentTarget) return;
+
+            const modal = document.getElementById('postDetailModal');
+            if (modal) {{
+                modal.classList.remove('active');
+                document.body.style.overflow = '';
+            }}
+        }}
+
         // 切换Tab
         function switchTab(index) {{
             currentIndex = index;
@@ -1148,20 +1478,30 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             const circleAreaWidth = width - 40;
             const circleAreaCenterX = circleAreaWidth / 2;
 
-            // 准备数据
-            const nodes = data.nodes.map(n => ({{
-                ...n,
-                id: n.节点ID,
-                source: n.节点ID.startsWith("帖子_") ? "帖子" : "人设",
-                level: n.节点层级
-            }}));
+            // 准备数据(过滤掉帖子点,只保留帖子标签和人设节点)
+            const nodes = data.nodes
+                .filter(n => !(n.节点ID.startsWith("帖子_") && n.节点类型 === "点"))
+                .map(n => ({{
+                    ...n,
+                    id: n.节点ID,
+                    source: n.节点ID.startsWith("帖子_") ? "帖子" : "人设",
+                    level: n.节点层级
+                }}));
+
+            // 判断是否是帖子点节点ID
+            function isPostPointId(id) {{
+                return id.startsWith("帖子_") && id.includes("_点_");
+            }}
 
-            const links = data.edges.map(e => ({{
-                ...e,
-                source: e.源节点ID,
-                target: e.目标节点ID,
-                type: e.边类型
-            }}));
+            // 过滤掉引用帖子点节点的边
+            const links = data.edges
+                .filter(e => !isPostPointId(e.源节点ID) && !isPostPointId(e.目标节点ID))
+                .map(e => ({{
+                    ...e,
+                    source: e.源节点ID,
+                    target: e.目标节点ID,
+                    type: e.边类型
+                }}));
 
             // 分离节点类型
             const postNodes = nodes.filter(n => n.source === "帖子");
@@ -1373,17 +1713,17 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 "关键点": "#9b59b6"
             }};
 
-            // 获取节点的层级编号(四层结构
+            // 获取节点的层级编号(三层结构:帖子标签、人设匹配1层、人设匹配2层
             function getNodeLayer(d) {{
                 if (d.source === "帖子") {{
-                    return d.节点类型 === "点" ? 0 : 1;  // 点=0, 标签=1
+                    return 0;  // 帖子标签=0
                 }}
                 // 人设节点:根据是否扩展分成两层
-                return d.是否扩展 ? 3 : 2;  // 直接匹配=2, 扩展=3
+                return d.是否扩展 ? 2 : 1;  // 直接匹配=1, 扩展=2
             }}
 
-            // 统计每层节点数量(层结构)
-            const layerCounts = {{0: 0, 1: 0, 2: 0, 3: 0}};
+            // 统计每层节点数量(层结构)
+            const layerCounts = {{0: 0, 1: 0, 2: 0}};
             nodes.forEach(n => {{
                 const layer = getNodeLayer(n);
                 layerCounts[layer]++;
@@ -1391,8 +1731,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             console.log("每层节点数:", layerCounts);
 
             // 根据节点数量计算每层圆的半径
-            // 左列:帖子标签(上) + 人设匹配(下),右列:帖子点(同一行开始)
-            // 左列是主要展示区域,视图中心对准左列
+            // 三层垂直排列:帖子标签(上)+ 人设匹配1层(中)+ 人设匹配2层(下)
             const minRadius = 80;
             const maxRadius = Math.min(circleAreaWidth / 3 - 20, 180);
             const nodeSpacing = 60;
@@ -1404,17 +1743,16 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 return Math.max(minRadius, Math.min(maxRadius, r));
             }}
 
-            // 左列3层统一大小(取最大值)
-            const leftColRadius = Math.max(
+            // 三层统一大小(取最大值)
+            const unifiedRadius = Math.max(
+                calcRadius(layerCounts[0]),
                 calcRadius(layerCounts[1]),
-                calcRadius(layerCounts[2]),
-                calcRadius(layerCounts[3])
+                calcRadius(layerCounts[2])
             );
             const layerRadius = {{
-                0: calcRadius(layerCounts[0]),  // 帖子点(右列)
-                1: leftColRadius,  // 帖子标签(左上)
-                2: leftColRadius,  // 人设匹配1层(左中)
-                3: leftColRadius   // 人设匹配2层(左下)
+                0: unifiedRadius,  // 帖子标签(上)
+                1: unifiedRadius,  // 人设匹配1层(中)
+                2: unifiedRadius   // 人设匹配2层(下)
             }};
             console.log("每层半径:", layerRadius);
 
@@ -1427,31 +1765,25 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             const egoRadius = 180;
             const egoAreaHeight = egoRadius * 2 + 80;
 
-            // 计算布局:列(帖子标签+人设匹配1层+人设匹配2层),右列(帖子点)
+            // 计算布局:三层垂直排列(帖子标签+人设匹配1层+人设匹配2层)
             const layerPadding = 30;
-            const leftColHeight = layerRadius[1] * 2 + layerPadding + layerRadius[2] * 2 + layerPadding + layerRadius[3] * 2 + layerPadding;
-            const circleHeight = leftColHeight;
+            const circleHeight = layerRadius[0] * 2 + layerPadding + layerRadius[1] * 2 + layerPadding + layerRadius[2] * 2 + layerPadding;
             const height = Math.max(circleHeight + 80, treeHeight + 80, container.clientHeight);
 
-            // 计算每层圆心坐标
+            // 计算每层圆心坐标(垂直居中排列)
             const layerCenterX = {{}};
             const layerCenterY = {{}};
 
-            // 左列居中显示,右列在旁边
-            const leftColX = circleAreaCenterX;  // 左列居中
-            const rightColX = leftColX + Math.max(layerRadius[1], layerRadius[2], layerRadius[3]) + layerPadding + layerRadius[0] + 30;  // 右列在右边
+            // 三层垂直排列,X坐标相同
+            const colX = circleAreaCenterX;
 
-            // 左列:帖子标签(上)+ 人设匹配1层(中)+ 人设匹配2层(下)
-            layerCenterX[1] = leftColX;
-            layerCenterY[1] = layerRadius[1] + layerPadding / 2 + 20;
-            layerCenterX[2] = leftColX;
+            // 帖子标签(上)+ 人设匹配1层(中)+ 人设匹配2层(下)
+            layerCenterX[0] = colX;
+            layerCenterY[0] = layerRadius[0] + layerPadding / 2 + 20;
+            layerCenterX[1] = colX;
+            layerCenterY[1] = layerCenterY[0] + layerRadius[0] + layerPadding + layerRadius[1];
+            layerCenterX[2] = colX;
             layerCenterY[2] = layerCenterY[1] + layerRadius[1] + layerPadding + layerRadius[2];
-            layerCenterX[3] = leftColX;
-            layerCenterY[3] = layerCenterY[2] + layerRadius[2] + layerPadding + layerRadius[3];
-
-            // 右列:帖子点(与帖子标签同一行开始)
-            layerCenterX[0] = rightColX;
-            layerCenterY[0] = layerCenterY[1];  // 与帖子标签同一行
 
             console.log("每层圆心X:", layerCenterX);
             console.log("每层圆心Y:", layerCenterY);
@@ -1568,10 +1900,10 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
 
                 function force(alpha) {{
                     // 按层分组(三层)
-                    const layerNodes = {{0: [], 1: [], 2: [], 3: []}};
+                    const layerNodes = {{0: [], 1: [], 2: []}};
                     nodes.forEach(node => {{
                         const layer = getNodeLayer(node);
-                        layerNodes[layer].push(node);
+                        if (layerNodes[layer]) layerNodes[layer].push(node);
                     }});
 
                     // 对每层计算布局
@@ -1679,15 +2011,195 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 return "match";
             }}
 
+            // ===== 在上方绘制帖子树(只是展示用)=====
+            const postDetail = data.postDetail || {{}};
+            window.currentPostDetail = postDetail;
+
+            // 使用预构建的帖子树数据
+            const postTree = data.postTree || {{ root: null }};
+
+            // 帖子树D3布局(只在有帖子树数据时绘制)
+            const postTreeGroup = g.append("g").attr("class", "post-tree");
+            let postTreeActualHeight = 0;
+            let postTreeOffsetY = 60;
+            let postRoot = null;
+
+            if (postTree && postTree.root) {{
+            const postTreeLayout = d3.tree()
+                .nodeSize([70, 80])
+                .separation((a, b) => a.parent === b.parent ? 1.2 : 1.5);
+
+            postRoot = d3.hierarchy(postTree.root);
+            postTreeLayout(postRoot);
+
+            // 计算树的实际边界
+            let treeMinX = Infinity, treeMaxX = -Infinity, treeMinY = Infinity, treeMaxY = -Infinity;
+            postRoot.descendants().forEach(d => {{
+                treeMinX = Math.min(treeMinX, d.x);
+                treeMaxX = Math.max(treeMaxX, d.x);
+                treeMinY = Math.min(treeMinY, d.y);
+                treeMaxY = Math.max(treeMaxY, d.y);
+            }});
+
+            // 帖子树居中,放在圆的上方
+            const postTreeOffsetX = circleAreaCenterX - (treeMinX + treeMaxX) / 2;
+            postTreeGroup.attr("transform", `translate(${{postTreeOffsetX}}, ${{postTreeOffsetY}})`);
+
+            // 卡片尺寸(提前定义,边绘制时需要用)
+            const cardWidth = 240;
+            const cardHeight = 90;
+
+            // 绘制帖子树边(属于边,紫色,粗细根据权重)
+            const postTreeLinks = postTreeGroup.selectAll(".post-tree-link")
+                .data(postRoot.links())
+                .enter()
+                .append("g")
+                .attr("class", "post-tree-link-group");
+
+            postTreeLinks.append("path")
+                .attr("class", "post-tree-link")
+                .attr("d", d => {{
+                    // 如果源节点是根节点(帖子卡片),从卡片下边框中心出发
+                    const sourceY = d.source.data.isRoot ? d.source.y + cardHeight/2 : d.source.y;
+                    return `M${{d.source.x}},${{sourceY}}
+                            C${{d.source.x}},${{(sourceY + d.target.y) / 2}}
+                             ${{d.target.x}},${{(sourceY + d.target.y) / 2}}
+                             ${{d.target.x}},${{d.target.y}}`;
+                }})
+                .attr("fill", "none")
+                .attr("stroke", "#9b59b6")
+                .attr("stroke-width", 1.5)
+                .attr("stroke-opacity", 0.6);
+
+            // 在点->标签的边上显示权重(浮点数)
+            postTreeLinks.filter(d => d.target.data.nodeType === "标签" && d.target.data.weight > 0)
+                .append("text")
+                .attr("x", d => (d.source.x + d.target.x) / 2)
+                .attr("y", d => (d.source.y + d.target.y) / 2)
+                .attr("text-anchor", "middle")
+                .attr("fill", "#9b59b6")
+                .attr("font-size", "9px")
+                .attr("font-weight", "bold")
+                .text(d => d.target.data.weight.toFixed(1));
+
+            // 六边形路径生成函数
+            function hexagonPath(r) {{
+                const a = Math.PI / 3;
+                let path = "";
+                for (let i = 0; i < 6; i++) {{
+                    const angle = a * i - Math.PI / 2;
+                    const x = r * Math.cos(angle);
+                    const y = r * Math.sin(angle);
+                    path += (i === 0 ? "M" : "L") + x + "," + y;
+                }}
+                return path + "Z";
+            }}
+
+            // 绘制帖子树节点(非根节点)
+            const postTreeNodes = postTreeGroup.selectAll(".post-tree-node")
+                .data(postRoot.descendants().filter(d => !d.data.isRoot))
+                .enter()
+                .append("g")
+                .attr("class", "post-tree-node")
+                .attr("transform", d => `translate(${{d.x}},${{d.y}})`);
+
+            // 节点形状(维度=大圆,点=六边形,标签=小圆)
+            postTreeNodes.each(function(d) {{
+                const node = d3.select(this);
+                if (d.data.nodeType === "点") {{
+                    // 点节点用六边形
+                    node.append("path")
+                        .attr("d", hexagonPath(6))
+                        .attr("fill", d.data.dimColor || "#888")
+                        .attr("stroke", "#fff")
+                        .attr("stroke-width", 1);
+                }} else {{
+                    // 维度和标签用圆形
+                    node.append("circle")
+                        .attr("r", d.data.isDimension ? 7 : 3)
+                        .attr("fill", d.data.dimColor || "#888")
+                        .attr("stroke", "#fff")
+                        .attr("stroke-width", 1);
+                }}
+            }});
+
+            // 节点标签
+            postTreeNodes.append("text")
+                .attr("dy", d => d.children ? -10 : 12)
+                .attr("text-anchor", "middle")
+                .attr("fill", d => d.data.dimColor || "#ccc")
+                .attr("font-size", d => {{
+                    if (d.data.isDimension) return "11px";
+                    if (d.data.nodeType === "点") return "10px";
+                    return "8px";
+                }})
+                .attr("font-weight", d => d.data.isDimension ? "bold" : "normal")
+                .text(d => d.data.name || "");
+
+            // 绘制根节点卡片(帖子详情卡片)
+            const postRootNode = postRoot;
+            const cardX = postRootNode.x - cardWidth/2;
+            const cardY = postRootNode.y - cardHeight/2;
+
+            const rootCard = postTreeGroup.append("foreignObject")
+                .attr("x", cardX)
+                .attr("y", cardY)
+                .attr("width", cardWidth)
+                .attr("height", cardHeight);
+
+            const images = postDetail.images || [];
+            const thumbnail = images.length > 0 ? images[0] : "";
+            const title = postDetail.title || "未知标题";
+            const likeCount = postDetail.like_count || 0;
+            const collectCount = postDetail.collect_count || 0;
+            const imageCount = images.length || 0;
+
+            const thumbnailHtml = thumbnail
+                ? `<div class="post-card-thumbnail-wrapper" style="width:60px;height:60px;">
+                     <img class="post-card-thumbnail" style="width:60px;height:60px;" src="${{thumbnail}}" alt="封面" onerror="this.style.display='none'"/>
+                     ${{imageCount > 1 ? `<span class="post-card-image-count">${{imageCount}}图</span>` : ''}}
+                   </div>`
+                : `<div class="post-card-thumbnail-placeholder" style="width:60px;height:60px;font-size:20px;">📝</div>`;
+
+            const cardHtml = `
+                <div class="post-card" xmlns="http://www.w3.org/1999/xhtml" style="padding:10px;" onclick="showPostDetail(window.currentPostDetail)">
+                    <div class="post-card-header">
+                        ${{thumbnailHtml}}
+                        <div class="post-card-info">
+                            <div class="post-card-title" style="font-size:12px;-webkit-line-clamp:2;" title="${{title}}">${{title}}</div>
+                            <div class="post-card-stats" style="font-size:10px;">
+                                <span>❤️ ${{likeCount}}</span>
+                                <span>⭐ ${{collectCount}}</span>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            `;
+            rootCard.append("xhtml:div").html(cardHtml);
+
+            postTreeActualHeight = treeMaxY - treeMinY;
+            }} // end if (postTree && postTree.root)
+
+            // 根据帖子树高度,调整圆的Y位置
+            const postTreeTotalHeight = postTreeOffsetY + postTreeActualHeight + 80;
+            const circleYOffset = postTreeTotalHeight;
+
+            // 更新圆心Y坐标(三层)
+            layerCenterY[0] += circleYOffset;
+            layerCenterY[1] += circleYOffset;
+            layerCenterY[2] += circleYOffset;
+
+            // 更新SVG高度
+            svg.attr("height", height + circleYOffset);
+
             // 绘制层背景(圆形,使用动态大小)
             const layerBg = g.append("g").attr("class", "layer-backgrounds");
 
-            // 层配置:名称、颜色(三层圆,关系图在左侧)
+            // 层配置:名称、颜色(三层圆)
             const layerConfig = [
-                {{ name: "帖子点", layer: 0, color: "rgba(243, 156, 18, 0.08)", stroke: "rgba(243, 156, 18, 0.3)" }},
-                {{ name: "帖子标签", layer: 1, color: "rgba(52, 152, 219, 0.08)", stroke: "rgba(52, 152, 219, 0.3)" }},
-                {{ name: "人设匹配_1层", layer: 2, color: "rgba(155, 89, 182, 0.08)", stroke: "rgba(155, 89, 182, 0.3)" }},
-                {{ name: "人设匹配_2层", layer: 3, color: "rgba(46, 204, 113, 0.08)", stroke: "rgba(46, 204, 113, 0.3)" }}
+                {{ name: "帖子标签", layer: 0, color: "rgba(52, 152, 219, 0.08)", stroke: "rgba(52, 152, 219, 0.3)" }},
+                {{ name: "人设匹配_1层", layer: 1, color: "rgba(155, 89, 182, 0.08)", stroke: "rgba(155, 89, 182, 0.3)" }},
+                {{ name: "人设匹配_2层", layer: 2, color: "rgba(46, 204, 113, 0.08)", stroke: "rgba(46, 204, 113, 0.3)" }}
             ];
 
             // 绘制三层圆形背景
@@ -3803,10 +4315,30 @@ def main():
 
     print()
 
+    # 读取帖子树数据
+    post_trees_file = match_graph_dir / "post_trees.json"
+    post_trees_data = {}  # postId -> postTree
+
+    if post_trees_file.exists():
+        print(f"读取帖子树数据: {post_trees_file.name}")
+        with open(post_trees_file, "r", encoding="utf-8") as f:
+            trees_data = json.load(f)
+            for tree in trees_data.get("postTrees", []):
+                post_trees_data[tree["postId"]] = tree
+        print(f"  帖子树数量: {len(post_trees_data)}")
+    else:
+        print(f"警告: 帖子树数据文件不存在: {post_trees_file}")
+        print("  请先运行 build_post_tree.py 生成帖子树数据")
+
+    print()
+
     # 读取所有匹配图谱文件
     graph_files = sorted(match_graph_dir.glob("*_match_graph.json"))
     print(f"找到 {len(graph_files)} 个匹配图谱文件")
 
+    # results目录(存放完整帖子详情)
+    results_dir = config.intermediate_dir.parent / "results"
+
     all_graph_data = []
     for i, graph_file in enumerate(graph_files, 1):
         print(f"  [{i}/{len(graph_files)}] 读取: {graph_file.name}")
@@ -3814,14 +4346,35 @@ def main():
         with open(graph_file, "r", encoding="utf-8") as f:
             match_graph_data = json.load(f)
 
+        post_id = match_graph_data["说明"]["帖子ID"]
+
+        # 尝试读取完整帖子详情
+        post_detail = {
+            "title": match_graph_data["说明"].get("帖子标题", ""),
+            "post_id": post_id
+        }
+        how_file = results_dir / f"{post_id}_how.json"
+        if how_file.exists():
+            with open(how_file, "r", encoding="utf-8") as f:
+                how_data = json.load(f)
+                if "帖子详情" in how_data:
+                    post_detail = how_data["帖子详情"]
+                    post_detail["post_id"] = post_id
+
+        # 获取预构建的帖子树数据
+        post_tree = post_trees_data.get(post_id, {})
+
         # 提取需要的数据
         graph_data = {
-            "postId": match_graph_data["说明"]["帖子ID"],
+            "postId": post_id,
             "postTitle": match_graph_data["说明"].get("帖子标题", ""),
             "stats": match_graph_data["说明"]["统计"],
             "nodes": match_graph_data["节点列表"],
             "edges": match_graph_data["边列表"],
-            "personaEdgeToMirrorEdges": match_graph_data.get("人设边到镜像边映射", {})
+            "personaEdgeToMirrorEdges": match_graph_data.get("人设边到镜像边映射", {}),
+            # 预构建的帖子树数据
+            "postTree": post_tree,
+            "postDetail": post_tree.get("postDetail", post_detail)
         }
         all_graph_data.append(graph_data)