1
0

7 Revīzijas e69fe2671c ... 710226ad29

Autors SHA1 Ziņojums Datums
  yangxiaohui 710226ad29 feat: 优化边颜色区分度,统一节点样式 6 dienas atpakaļ
  yangxiaohui 40a7b792e3 feat: 统一人设树和关系图的交互逻辑 6 dienas atpakaļ
  yangxiaohui 13114f5c37 feat: 添加层间排斥力,保持四层节点分离 6 dienas atpakaļ
  yangxiaohui 81d48221b6 feat: 添加帖子点节点层级,支持四层图结构 6 dienas atpakaļ
  yangxiaohui a38e8f0465 feat: 完善匹配图构建和可视化功能 6 dienas atpakaļ
  yangxiaohui fd13025dc7 feat: 优化匹配图谱扩展逻辑和交互体验 6 dienas atpakaļ
  yangxiaohui 64380d838c add 6 dienas atpakaļ

+ 1 - 1
config/accounts.json

@@ -32,7 +32,7 @@
       "description": "未启用的示例账号"
     }
   ],
-  "default_account": "阿里多多酱",
+  "default_account": "阿里多多酱_1125",
   "comment": "数据根目录可通过 data_root 配置(支持绝对路径、~、环境变量),也可通过 DATA_ROOT 环境变量覆盖",
   "filter_mode": "exclude_current_posts",
   "filter_modes": {

+ 802 - 0
script/data_processing/build_match_graph.py

@@ -0,0 +1,802 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+从匹配结果中构建帖子与人设的节点边关系图
+
+输入:
+1. filtered_results目录下的匹配结果文件
+2. 节点列表.json
+3. 边关系.json
+
+输出:
+1. match_graph目录下的节点边关系文件
+"""
+
+import json
+from pathlib import Path
+from typing import Dict, List, Set, Any, Optional
+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_node_id(dimension: str, node_type: str, name: str) -> str:
+    """构建帖子节点ID
+
+    Args:
+        dimension: 维度(灵感点/关键点/目的点)
+        node_type: 节点类型(点/标签)
+        name: 节点名称
+    """
+    return f"帖子_{dimension}_{node_type}_{name}"
+
+
+def build_persona_node_id(dimension: str, node_type: str, name: str) -> str:
+    """构建人设节点ID"""
+    return f"{dimension}_{node_type}_{name}"
+
+
+def extract_matched_nodes_and_edges(filtered_data: Dict) -> tuple:
+    """
+    从匹配结果中提取帖子节点(点+标签)、人设节点和边
+
+    Args:
+        filtered_data: 匹配结果数据
+
+    Returns:
+        (帖子节点列表, 人设节点ID集合, 边列表)
+        帖子节点包括:点节点(灵感点/关键点/目的点)和标签节点
+        边包括:点→标签的属于边 + 标签→人设的匹配边
+    """
+    post_nodes = []
+    persona_node_ids = set()
+    edges = []  # 包含属于边和匹配边
+
+    how_result = filtered_data.get("how解构结果", {})
+
+    # 维度映射
+    dimension_mapping = {
+        "灵感点列表": "灵感点",
+        "目的点列表": "目的点",
+        "关键点列表": "关键点"
+    }
+
+    for list_key, dimension in dimension_mapping.items():
+        points = how_result.get(list_key, [])
+
+        for point in points:
+            point_name = point.get("名称", "")
+            point_desc = point.get("描述", "")
+
+            if not point_name:
+                continue
+
+            # 创建帖子点节点
+            point_node_id = build_post_node_id(dimension, "点", point_name)
+            point_node = {
+                "节点ID": point_node_id,
+                "节点名称": point_name,
+                "节点类型": "点",
+                "节点层级": dimension,
+                "描述": point_desc,
+                "source": "帖子"
+            }
+
+            # 避免重复添加点节点
+            if not any(n["节点ID"] == point_node_id for n in post_nodes):
+                post_nodes.append(point_node)
+
+            # 遍历how步骤列表,提取标签节点
+            how_steps = point.get("how步骤列表", [])
+
+            for step in how_steps:
+                features = step.get("特征列表", [])
+
+                for feature in features:
+                    feature_name = feature.get("特征名称", "")
+                    weight = feature.get("权重", 0)
+                    match_results = feature.get("匹配结果", [])
+
+                    if not feature_name:
+                        continue
+
+                    # 创建帖子标签节点(无论是否有匹配结果)
+                    tag_node_id = build_post_node_id(dimension, "标签", feature_name)
+                    tag_node = {
+                        "节点ID": tag_node_id,
+                        "节点名称": feature_name,
+                        "节点类型": "标签",
+                        "节点层级": dimension,
+                        "权重": weight,
+                        "source": "帖子",
+                        "已匹配": len(match_results) > 0  # 标记是否有匹配
+                    }
+
+                    # 避免重复添加标签节点
+                    if not any(n["节点ID"] == tag_node_id for n in post_nodes):
+                        post_nodes.append(tag_node)
+
+                    # 创建标签→点的属于边
+                    belong_edge = {
+                        "源节点ID": tag_node_id,
+                        "目标节点ID": point_node_id,
+                        "边类型": "属于",
+                        "边详情": {
+                            "说明": f"标签「{feature_name}」属于点「{point_name}」"
+                        }
+                    }
+                    # 避免重复添加属于边
+                    edge_key = (tag_node_id, point_node_id, "属于")
+                    if not any((e["源节点ID"], e["目标节点ID"], e["边类型"]) == edge_key for e in edges):
+                        edges.append(belong_edge)
+
+                    # 如果有匹配结果,创建匹配边
+                    if match_results:
+                        for match in match_results:
+                            persona_name = match.get("人设特征名称", "")
+                            persona_dimension = match.get("人设特征层级", "")
+                            persona_type = match.get("特征类型", "标签")
+                            match_detail = match.get("匹配结果", {})
+
+                            if not persona_name or not persona_dimension:
+                                continue
+
+                            # 构建人设节点ID
+                            persona_node_id = build_persona_node_id(
+                                persona_dimension, persona_type, persona_name
+                            )
+                            persona_node_ids.add(persona_node_id)
+
+                            # 创建匹配边
+                            match_edge = {
+                                "源节点ID": tag_node_id,
+                                "目标节点ID": persona_node_id,
+                                "边类型": "匹配",
+                                "边详情": {
+                                    "相似度": match_detail.get("相似度", 0),
+                                    "说明": match_detail.get("说明", "")
+                                }
+                            }
+                            edges.append(match_edge)
+
+    return post_nodes, persona_node_ids, edges
+
+
+def get_persona_nodes_details(
+    persona_node_ids: Set[str],
+    nodes_data: Dict
+) -> List[Dict]:
+    """
+    从节点列表中获取人设节点的详细信息
+
+    Args:
+        persona_node_ids: 人设节点ID集合
+        nodes_data: 节点列表数据
+
+    Returns:
+        人设节点详情列表
+    """
+    persona_nodes = []
+    all_nodes = nodes_data.get("节点列表", [])
+
+    for node in all_nodes:
+        if node["节点ID"] in persona_node_ids:
+            persona_nodes.append(node)
+
+    return persona_nodes
+
+
+def get_edges_between_nodes(
+    node_ids: Set[str],
+    edges_data: Dict
+) -> List[Dict]:
+    """
+    获取指定节点之间的边关系
+
+    Args:
+        node_ids: 节点ID集合
+        edges_data: 边关系数据
+
+    Returns:
+        节点之间的边列表
+    """
+    edges_between = []
+    all_edges = edges_data.get("边列表", [])
+
+    for edge in all_edges:
+        source_id = edge["源节点ID"]
+        target_id = edge["目标节点ID"]
+
+        # 两个节点都在集合中
+        if source_id in node_ids and target_id in node_ids:
+            edges_between.append(edge)
+
+    return edges_between
+
+
+def create_mirrored_post_edges(
+    match_edges: List[Dict],
+    persona_edges: List[Dict]
+) -> List[Dict]:
+    """
+    根据人设节点之间的边,创建帖子节点之间的镜像边
+
+    逻辑:如果人设节点A和B之间有边,且帖子节点X匹配A,帖子节点Y匹配B,
+    则创建帖子节点X和Y之间的镜像边
+
+    Args:
+        match_edges: 匹配边列表(帖子节点 -> 人设节点)
+        persona_edges: 人设节点之间的边列表
+
+    Returns:
+        帖子节点之间的镜像边列表
+    """
+    # 构建人设节点到帖子节点的反向映射
+    # persona_id -> [post_id1, post_id2, ...]
+    persona_to_posts = {}
+    for edge in match_edges:
+        post_id = edge["源节点ID"]
+        persona_id = edge["目标节点ID"]
+        if persona_id not in persona_to_posts:
+            persona_to_posts[persona_id] = []
+        if post_id not in persona_to_posts[persona_id]:
+            persona_to_posts[persona_id].append(post_id)
+
+    # 根据人设边创建帖子镜像边
+    post_edges = []
+    seen_edges = set()
+
+    for persona_edge in persona_edges:
+        source_persona = persona_edge["源节点ID"]
+        target_persona = persona_edge["目标节点ID"]
+        edge_type = persona_edge["边类型"]
+
+        # 获取匹配到这两个人设节点的帖子节点
+        source_posts = persona_to_posts.get(source_persona, [])
+        target_posts = persona_to_posts.get(target_persona, [])
+
+        # 为每对帖子节点创建镜像边
+        for src_post in source_posts:
+            for tgt_post in target_posts:
+                if src_post == tgt_post:
+                    continue
+
+                # 使用排序后的key避免重复(A-B 和 B-A 视为同一条边)
+                edge_key = tuple(sorted([src_post, tgt_post])) + (edge_type,)
+                if edge_key in seen_edges:
+                    continue
+                seen_edges.add(edge_key)
+
+                post_edge = {
+                    "源节点ID": src_post,
+                    "目标节点ID": tgt_post,
+                    "边类型": f"镜像_{edge_type}",  # 标记为镜像边
+                    "边详情": {
+                        "原始边类型": edge_type,
+                        "源人设节点": source_persona,
+                        "目标人设节点": target_persona
+                    }
+                }
+                post_edges.append(post_edge)
+
+    return post_edges
+
+
+def expand_one_layer(
+    node_ids: Set[str],
+    edges_data: Dict,
+    nodes_data: Dict,
+    edge_types: List[str] = None,
+    direction: str = "both"
+) -> tuple:
+    """
+    从指定节点扩展一层,获取相邻节点和连接边
+
+    Args:
+        node_ids: 起始节点ID集合
+        edges_data: 边关系数据
+        nodes_data: 节点列表数据
+        edge_types: 要扩展的边类型列表,None表示所有类型
+        direction: 扩展方向
+            - "outgoing": 只沿出边扩展(源节点在集合中,扩展到目标节点)
+            - "incoming": 只沿入边扩展(目标节点在集合中,扩展到源节点)
+            - "both": 双向扩展
+
+    Returns:
+        (扩展的节点列表, 扩展的边列表, 扩展的节点ID集合)
+    """
+    expanded_node_ids = set()
+    expanded_edges = []
+    all_edges = edges_data.get("边列表", [])
+
+    # 找出所有与起始节点相连的边和节点
+    for edge in all_edges:
+        # 过滤边类型
+        if edge_types and edge["边类型"] not in edge_types:
+            continue
+
+        source_id = edge["源节点ID"]
+        target_id = edge["目标节点ID"]
+
+        # 沿出边扩展:源节点在集合中,扩展到目标节点
+        if direction in ["outgoing", "both"]:
+            if source_id in node_ids and target_id not in node_ids:
+                expanded_node_ids.add(target_id)
+                expanded_edges.append(edge)
+
+        # 沿入边扩展:目标节点在集合中,扩展到源节点
+        if direction in ["incoming", "both"]:
+            if target_id in node_ids and source_id not in node_ids:
+                expanded_node_ids.add(source_id)
+                expanded_edges.append(edge)
+
+    # 获取扩展节点的详情
+    expanded_nodes = []
+    all_nodes = nodes_data.get("节点列表", [])
+    for node in all_nodes:
+        if node["节点ID"] in expanded_node_ids:
+            # 标记为扩展节点
+            node_copy = node.copy()
+            node_copy["是否扩展"] = True
+            node_copy["source"] = "人设"
+            expanded_nodes.append(node_copy)
+
+    return expanded_nodes, expanded_edges, expanded_node_ids
+
+
+def expand_and_filter_useful_nodes(
+    matched_persona_ids: Set[str],
+    match_edges: List[Dict],
+    edges_data: Dict,
+    nodes_data: Dict,
+    exclude_edge_types: List[str] = None
+) -> tuple:
+    """
+    扩展人设节点一层,只保留能产生新帖子连线的扩展节点
+
+    逻辑:如果扩展节点E连接了2个以上的已匹配人设节点,
+    那么通过E可以产生新的帖子间连线,保留E
+
+    Args:
+        matched_persona_ids: 已匹配的人设节点ID集合
+        match_edges: 匹配边列表
+        edges_data: 边关系数据
+        nodes_data: 节点列表数据
+        exclude_edge_types: 要排除的边类型列表
+
+    Returns:
+        (有效扩展节点列表, 扩展边列表, 通过扩展节点的帖子镜像边列表)
+    """
+    if exclude_edge_types is None:
+        exclude_edge_types = []
+
+    all_edges = edges_data.get("边列表", [])
+
+    # 构建人设节点到帖子节点的映射
+    persona_to_posts = {}
+    for edge in match_edges:
+        post_id = edge["源节点ID"]
+        persona_id = edge["目标节点ID"]
+        if persona_id not in persona_to_posts:
+            persona_to_posts[persona_id] = []
+        if post_id not in persona_to_posts[persona_id]:
+            persona_to_posts[persona_id].append(post_id)
+
+    # 找出所有扩展节点及其连接的已匹配人设节点
+    # expanded_node_id -> [(matched_persona_id, edge), ...]
+    expanded_connections = {}
+
+    for edge in all_edges:
+        # 跳过排除的边类型
+        if edge["边类型"] in exclude_edge_types:
+            continue
+
+        source_id = edge["源节点ID"]
+        target_id = edge["目标节点ID"]
+
+        # 源节点是已匹配的,目标节点是扩展候选
+        if source_id in matched_persona_ids and target_id not in matched_persona_ids:
+            if target_id not in expanded_connections:
+                expanded_connections[target_id] = []
+            expanded_connections[target_id].append((source_id, edge))
+
+        # 目标节点是已匹配的,源节点是扩展候选
+        if target_id in matched_persona_ids and source_id not in matched_persona_ids:
+            if source_id not in expanded_connections:
+                expanded_connections[source_id] = []
+            expanded_connections[source_id].append((target_id, edge))
+
+    # 过滤:只保留连接2个以上已匹配人设节点的扩展节点
+    useful_expanded_ids = set()
+    useful_edges = []
+    post_mirror_edges = []
+    seen_mirror_edges = set()
+
+    for expanded_id, connections in expanded_connections.items():
+        connected_personas = list(set([c[0] for c in connections]))
+
+        if len(connected_personas) >= 2:
+            useful_expanded_ids.add(expanded_id)
+
+            # 收集边
+            for persona_id, edge in connections:
+                useful_edges.append(edge)
+
+            # 为通过此扩展节点连接的每对人设节点,创建帖子镜像边
+            for i, p1 in enumerate(connected_personas):
+                for p2 in connected_personas[i+1:]:
+                    posts1 = persona_to_posts.get(p1, [])
+                    posts2 = persona_to_posts.get(p2, [])
+
+                    # 找出连接p1和p2的边类型
+                    edge_types_p1 = [c[1]["边类型"] for c in connections if c[0] == p1]
+                    edge_types_p2 = [c[1]["边类型"] for c in connections if c[0] == p2]
+                    # 用第一个边类型作为代表
+                    edge_type = edge_types_p1[0] if edge_types_p1 else (edge_types_p2[0] if edge_types_p2 else "扩展")
+
+                    for post1 in posts1:
+                        for post2 in posts2:
+                            if post1 == post2:
+                                continue
+
+                            # 避免重复
+                            edge_key = tuple(sorted([post1, post2])) + (f"二阶_{edge_type}",)
+                            if edge_key in seen_mirror_edges:
+                                continue
+                            seen_mirror_edges.add(edge_key)
+
+                            post_mirror_edges.append({
+                                "源节点ID": post1,
+                                "目标节点ID": post2,
+                                "边类型": f"二阶_{edge_type}",
+                                "边详情": {
+                                    "原始边类型": edge_type,
+                                    "扩展节点": expanded_id,
+                                    "源人设节点": p1,
+                                    "目标人设节点": p2
+                                }
+                            })
+
+    # 获取扩展节点详情
+    useful_expanded_nodes = []
+    all_nodes = nodes_data.get("节点列表", [])
+    for node in all_nodes:
+        if node["节点ID"] in useful_expanded_ids:
+            node_copy = node.copy()
+            node_copy["是否扩展"] = True
+            useful_expanded_nodes.append(node_copy)
+
+    # 边去重
+    seen_edges = set()
+    unique_edges = []
+    for edge in useful_edges:
+        edge_key = (edge["源节点ID"], edge["目标节点ID"], edge["边类型"])
+        if edge_key not in seen_edges:
+            seen_edges.add(edge_key)
+            unique_edges.append(edge)
+
+    return useful_expanded_nodes, unique_edges, post_mirror_edges
+
+
+def process_filtered_result(
+    filtered_file: Path,
+    nodes_data: Dict,
+    edges_data: Dict,
+    output_dir: Path
+) -> Dict:
+    """
+    处理单个匹配结果文件
+
+    Args:
+        filtered_file: 匹配结果文件路径
+        nodes_data: 节点列表数据
+        edges_data: 边关系数据
+        output_dir: 输出目录
+
+    Returns:
+        处理结果统计
+    """
+    # 读取匹配结果
+    with open(filtered_file, "r", encoding="utf-8") as f:
+        filtered_data = json.load(f)
+
+    post_id = filtered_data.get("帖子id", "")
+    post_detail = filtered_data.get("帖子详情", {})
+    post_title = post_detail.get("title", "")
+
+    # 提取节点和边(包括帖子点节点、标签节点、属于边和匹配边)
+    post_nodes, persona_node_ids, post_edges_raw = extract_matched_nodes_and_edges(filtered_data)
+
+    # 分离帖子侧的边:属于边(标签→点)和匹配边(标签→人设)
+    post_belong_edges = [e for e in post_edges_raw if e["边类型"] == "属于"]
+    match_edges = [e for e in post_edges_raw if e["边类型"] == "匹配"]
+
+    # 统计帖子点节点和标签节点
+    post_point_nodes = [n for n in post_nodes if n["节点类型"] == "点"]
+    post_tag_nodes = [n for n in post_nodes if n["节点类型"] == "标签"]
+
+    # 获取人设节点详情(直接匹配的,标记为非扩展)
+    persona_nodes = get_persona_nodes_details(persona_node_ids, nodes_data)
+    for node in persona_nodes:
+        node["是否扩展"] = False
+        node["source"] = "人设"
+
+    # 获取人设节点之间的边
+    persona_edges = get_edges_between_nodes(persona_node_ids, edges_data)
+
+    # 创建帖子节点之间的镜像边(基于直接人设边的投影)
+    post_edges = create_mirrored_post_edges(match_edges, persona_edges)
+
+    # 扩展人设节点一层,只对标签类型的节点通过"属于"边扩展到分类
+    # 过滤出标签类型的人设节点(只有标签才能"属于"分类)
+    tag_persona_ids = {pid for pid in persona_node_ids if "_标签_" in pid}
+    expanded_nodes, expanded_edges, _ = expand_one_layer(
+        tag_persona_ids, edges_data, nodes_data,
+        edge_types=["属于"],
+        direction="outgoing"  # 只向外扩展:标签->分类
+    )
+
+    # 创建通过扩展节点的帖子镜像边(正确逻辑)
+    # 逻辑:帖子->标签->分类,分类之间有边,则对应帖子产生二阶边
+
+    # 1. 构建 标签 -> 帖子列表 的映射
+    tag_to_posts = {}
+    for edge in match_edges:
+        post_node_id = edge["源节点ID"]
+        tag_id = edge["目标节点ID"]
+        if tag_id not in tag_to_posts:
+            tag_to_posts[tag_id] = []
+        if post_node_id not in tag_to_posts[tag_id]:
+            tag_to_posts[tag_id].append(post_node_id)
+
+    # 2. 构建 分类 -> 标签列表 的映射(通过属于边)
+    expanded_node_ids = set(n["节点ID"] for n in expanded_nodes)
+    category_to_tags = {}  # 分类 -> [连接的标签]
+    for edge in expanded_edges:
+        src, tgt = edge["源节点ID"], edge["目标节点ID"]
+        # 属于边:标签 -> 分类
+        if tgt in expanded_node_ids and src in persona_node_ids:
+            if tgt not in category_to_tags:
+                category_to_tags[tgt] = []
+            if src not in category_to_tags[tgt]:
+                category_to_tags[tgt].append(src)
+
+    # 3. 获取扩展节点(分类)之间的边
+    category_edges = []
+    for edge in edges_data.get("边列表", []):
+        src, tgt = edge["源节点ID"], edge["目标节点ID"]
+        # 两端都是扩展节点(分类)
+        if src in expanded_node_ids and tgt in expanded_node_ids:
+            category_edges.append(edge)
+
+    # 4. 基于分类之间的边,生成帖子之间的二阶镜像边
+    post_edges_via_expanded = []
+    seen_mirror = set()
+    for cat_edge in category_edges:
+        cat1, cat2 = cat_edge["源节点ID"], cat_edge["目标节点ID"]
+        edge_type = cat_edge["边类型"]
+
+        # 获取连接到这两个分类的标签
+        tags1 = category_to_tags.get(cat1, [])
+        tags2 = category_to_tags.get(cat2, [])
+
+        # 通过标签找到对应的帖子,产生二阶边
+        for tag1 in tags1:
+            for tag2 in tags2:
+                posts1 = tag_to_posts.get(tag1, [])
+                posts2 = tag_to_posts.get(tag2, [])
+                for post1 in posts1:
+                    for post2 in posts2:
+                        if post1 == post2:
+                            continue
+                        edge_key = tuple(sorted([post1, post2])) + (f"二阶_{edge_type}",)
+                        if edge_key in seen_mirror:
+                            continue
+                        seen_mirror.add(edge_key)
+                        post_edges_via_expanded.append({
+                            "源节点ID": post1,
+                            "目标节点ID": post2,
+                            "边类型": f"二阶_{edge_type}",
+                            "边详情": {
+                                "原始边类型": edge_type,
+                                "分类节点1": cat1,
+                                "分类节点2": cat2,
+                                "标签节点1": tag1,
+                                "标签节点2": tag2
+                            }
+                        })
+
+    # 只保留对帖子连接有帮助的扩展节点和边
+    # 1. 找出产生了二阶帖子边的扩展节点(分类)
+    useful_expanded_ids = set()
+    for edge in post_edges_via_expanded:
+        cat1 = edge.get("边详情", {}).get("分类节点1")
+        cat2 = edge.get("边详情", {}).get("分类节点2")
+        if cat1:
+            useful_expanded_ids.add(cat1)
+        if cat2:
+            useful_expanded_ids.add(cat2)
+
+    # 2. 只保留有用的扩展节点
+    useful_expanded_nodes = [n for n in expanded_nodes if n["节点ID"] in useful_expanded_ids]
+
+    # 3. 只保留连接到有用扩展节点的属于边
+    useful_expanded_edges = [e for e in expanded_edges
+                            if e["目标节点ID"] in useful_expanded_ids or e["源节点ID"] in useful_expanded_ids]
+
+    # 4. 只保留有用的分类之间的边(产生了二阶帖子边的)
+    useful_category_edges = [e for e in category_edges
+                            if e["源节点ID"] in useful_expanded_ids and e["目标节点ID"] in useful_expanded_ids]
+
+    # 合并节点列表
+    all_nodes = post_nodes + persona_nodes + useful_expanded_nodes
+
+    # 合并边列表(加入帖子内的属于边)
+    all_edges = post_belong_edges + match_edges + persona_edges + post_edges + useful_expanded_edges + useful_category_edges + post_edges_via_expanded
+    # 去重边
+    seen_edges = set()
+    unique_edges = []
+    for edge in all_edges:
+        edge_key = (edge["源节点ID"], edge["目标节点ID"], edge["边类型"])
+        if edge_key not in seen_edges:
+            seen_edges.add(edge_key)
+            unique_edges.append(edge)
+    all_edges = unique_edges
+
+    # 构建节点边索引
+    edges_by_node = {}
+    for edge in all_edges:
+        source_id = edge["源节点ID"]
+        target_id = edge["目标节点ID"]
+        edge_type = edge["边类型"]
+
+        if source_id not in edges_by_node:
+            edges_by_node[source_id] = {}
+        if edge_type not in edges_by_node[source_id]:
+            edges_by_node[source_id][edge_type] = {}
+        edges_by_node[source_id][edge_type][target_id] = edge
+
+    # 构建输出数据
+    output_data = {
+        "说明": {
+            "帖子ID": post_id,
+            "帖子标题": post_title,
+            "描述": "帖子与人设的节点匹配关系",
+            "统计": {
+                "帖子点节点数": len(post_point_nodes),
+                "帖子标签节点数": len(post_tag_nodes),
+                "帖子节点总数": len(post_nodes),
+                "人设节点数(直接匹配)": len(persona_nodes),
+                "扩展节点数(有效)": len(useful_expanded_nodes),
+                "帖子属于边数": len(post_belong_edges),
+                "匹配边数": len(match_edges),
+                "人设节点间边数": len(persona_edges),
+                "扩展边数(有效)": len(useful_expanded_edges),
+                "帖子镜像边数(直接)": len(post_edges),
+                "帖子镜像边数(二阶)": len(post_edges_via_expanded),
+                "总节点数": len(all_nodes),
+                "总边数": len(all_edges)
+            }
+        },
+        "帖子点节点列表": post_point_nodes,
+        "帖子标签节点列表": post_tag_nodes,
+        "帖子节点列表": post_nodes,
+        "人设节点列表": persona_nodes,
+        "扩展节点列表": useful_expanded_nodes,
+        "帖子属于边列表": post_belong_edges,
+        "匹配边列表": match_edges,
+        "人设节点间边列表": persona_edges,
+        "扩展边列表": useful_expanded_edges,
+        "帖子镜像边列表(直接)": post_edges,
+        "帖子镜像边列表(二阶)": post_edges_via_expanded,
+        "节点列表": all_nodes,
+        "边列表": all_edges,
+        "节点边索引": edges_by_node
+    }
+
+    # 保存输出文件
+    output_file = output_dir / f"{post_id}_match_graph.json"
+    with open(output_file, "w", encoding="utf-8") as f:
+        json.dump(output_data, f, ensure_ascii=False, indent=2)
+
+    return {
+        "帖子ID": post_id,
+        "帖子点节点数": len(post_point_nodes),
+        "帖子标签节点数": len(post_tag_nodes),
+        "帖子节点数": len(post_nodes),
+        "人设节点数": len(persona_nodes),
+        "扩展节点数": len(useful_expanded_nodes),
+        "帖子属于边数": len(post_belong_edges),
+        "匹配边数": len(match_edges),
+        "人设边数": len(persona_edges),
+        "扩展边数": len(useful_expanded_edges),
+        "帖子边数(直接)": len(post_edges),
+        "帖子边数(二阶)": len(post_edges_via_expanded),
+        "总节点数": len(all_nodes),
+        "总边数": len(all_edges),
+        "输出文件": str(output_file)
+    }
+
+
+def main():
+    # 使用路径配置
+    config = PathConfig()
+    config.ensure_dirs()
+
+    print(f"账号: {config.account_name}")
+    print(f"输出版本: {config.output_version}")
+    print()
+
+    # 输入文件/目录
+    filtered_results_dir = config.intermediate_dir / "filtered_results"
+    nodes_file = config.intermediate_dir / "节点列表.json"
+    edges_file = config.intermediate_dir / "边关系.json"
+
+    # 输出目录
+    output_dir = config.intermediate_dir / "match_graph"
+    output_dir.mkdir(parents=True, exist_ok=True)
+
+    print(f"输入:")
+    print(f"  匹配结果目录: {filtered_results_dir}")
+    print(f"  节点列表: {nodes_file}")
+    print(f"  边关系: {edges_file}")
+    print(f"\n输出目录: {output_dir}")
+    print()
+
+    # 读取节点和边数据
+    print("正在读取节点列表...")
+    with open(nodes_file, "r", encoding="utf-8") as f:
+        nodes_data = json.load(f)
+    print(f"  共 {len(nodes_data.get('节点列表', []))} 个节点")
+
+    print("正在读取边关系...")
+    with open(edges_file, "r", encoding="utf-8") as f:
+        edges_data = json.load(f)
+    print(f"  共 {len(edges_data.get('边列表', []))} 条边")
+
+    # 处理所有匹配结果文件
+    print("\n" + "="*60)
+    print("处理匹配结果文件...")
+
+    filtered_files = list(filtered_results_dir.glob("*_filtered.json"))
+    print(f"找到 {len(filtered_files)} 个匹配结果文件")
+
+    results = []
+    for i, filtered_file in enumerate(filtered_files, 1):
+        print(f"\n[{i}/{len(filtered_files)}] 处理: {filtered_file.name}")
+        result = process_filtered_result(filtered_file, nodes_data, edges_data, output_dir)
+        results.append(result)
+        print(f"  帖子节点: {result['帖子节点数']}, 人设节点: {result['人设节点数']}, 扩展节点: {result['扩展节点数']}")
+        print(f"  匹配边: {result['匹配边数']}, 人设边: {result['人设边数']}, 扩展边: {result['扩展边数']}")
+        print(f"  帖子边(直接): {result['帖子边数(直接)']}, 帖子边(二阶): {result['帖子边数(二阶)']}")
+
+    # 汇总统计
+    print("\n" + "="*60)
+    print("处理完成!")
+    print(f"\n汇总:")
+    print(f"  处理文件数: {len(results)}")
+    total_post = sum(r['帖子节点数'] for r in results)
+    total_persona = sum(r['人设节点数'] for r in results)
+    total_expanded = sum(r['扩展节点数'] for r in results)
+    total_match = sum(r['匹配边数'] for r in results)
+    total_persona_edges = sum(r['人设边数'] for r in results)
+    total_expanded_edges = sum(r['扩展边数'] for r in results)
+    total_post_edges_direct = sum(r['帖子边数(直接)'] for r in results)
+    total_post_edges_2hop = sum(r['帖子边数(二阶)'] for r in results)
+    print(f"  总帖子节点: {total_post}")
+    print(f"  总人设节点: {total_persona}")
+    print(f"  总扩展节点: {total_expanded}")
+    print(f"  总匹配边: {total_match}")
+    print(f"  总人设边: {total_persona_edges}")
+    print(f"  总扩展边: {total_expanded_edges}")
+    print(f"  总帖子边(直接): {total_post_edges_direct}")
+    print(f"  总帖子边(二阶): {total_post_edges_2hop}")
+    print(f"\n输出目录: {output_dir}")
+
+
+if __name__ == "__main__":
+    main()

+ 187 - 0
script/data_processing/build_persona_tree.py

@@ -0,0 +1,187 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+构建人设树的中间数据
+
+输入:节点列表.json, 边关系.json
+输出:persona_tree.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_persona_tree():
+    """构建人设树数据"""
+    config = PathConfig()
+
+    print(f"账号: {config.account_name}")
+    print(f"输出版本: {config.output_version}")
+    print()
+
+    node_list_file = config.intermediate_dir / "节点列表.json"
+    edge_list_file = config.intermediate_dir / "边关系.json"
+    output_file = config.intermediate_dir / "persona_tree.json"
+
+    # 读取节点
+    print(f"读取节点列表: {node_list_file.name}")
+    with open(node_list_file, "r", encoding="utf-8") as f:
+        node_data = json.load(f)
+
+    all_nodes = node_data.get("节点列表", [])
+
+    # 分离分类和标签
+    category_nodes = [n for n in all_nodes if n.get("节点类型") == "分类"]
+    tag_nodes = [n for n in all_nodes if n.get("节点类型") == "标签"]
+
+    print(f"  分类节点: {len(category_nodes)}")
+    print(f"  标签节点: {len(tag_nodes)}")
+
+    # 读取边关系(获取所有边)
+    print(f"读取边关系: {edge_list_file.name}")
+    with open(edge_list_file, "r", encoding="utf-8") as f:
+        edge_data = json.load(f)
+
+    all_edges = edge_data.get("边列表", [])
+
+    # 统计各类型边
+    edge_type_counts = {}
+    for e in all_edges:
+        t = e.get("边类型", "未知")
+        edge_type_counts[t] = edge_type_counts.get(t, 0) + 1
+
+    for t, count in sorted(edge_type_counts.items(), key=lambda x: -x[1]):
+        print(f"  {t}: {count}")
+
+    # 构建树结构
+    tree_nodes = []
+    tree_edges = []
+
+    # 添加分类节点
+    for n in category_nodes:
+        tree_nodes.append({
+            "节点ID": n["节点ID"],
+            "节点名称": n["节点名称"],
+            "节点类型": "分类",
+            "节点层级": n.get("节点层级", ""),
+            "所属分类": n.get("所属分类", []),
+            "帖子数": n.get("帖子数", 0)
+        })
+
+    # 添加标签节点
+    for n in tag_nodes:
+        tree_nodes.append({
+            "节点ID": n["节点ID"],
+            "节点名称": n["节点名称"],
+            "节点类型": "标签",
+            "节点层级": n.get("节点层级", ""),
+            "所属分类": n.get("所属分类", []),
+            "帖子数": n.get("帖子数", 0)
+        })
+
+    # 构建节点ID集合和名称映射
+    node_ids = set(n["节点ID"] for n in tree_nodes)
+
+    # 按层级构建分类名称到ID的映射
+    category_name_to_id = {}
+    for n in category_nodes:
+        level = n.get("节点层级", "")
+        name = n.get("节点名称", "")
+        category_name_to_id[(level, name)] = n["节点ID"]
+
+    # 从分类的"所属分类"字段构建分类之间的层级边(统一用"属于")
+    for n in category_nodes:
+        level = n.get("节点层级", "")
+        parent_names = n.get("所属分类", [])
+        if parent_names:
+            parent_name = parent_names[-1]  # 取最后一个作为直接父分类
+            parent_id = category_name_to_id.get((level, parent_name))
+            if parent_id:
+                tree_edges.append({
+                    "源节点ID": n["节点ID"],
+                    "目标节点ID": parent_id,
+                    "边类型": "属于"
+                })
+
+    # 添加所有原始边(两端节点都在树中的,排除"包含"边因为与"属于"重复)
+    for e in all_edges:
+        src_id = e["源节点ID"]
+        tgt_id = e["目标节点ID"]
+        edge_type = e["边类型"]
+        # 跳过"包含"边(与"属于"是反向关系,保留"属于"即可)
+        if edge_type == "包含":
+            continue
+        if src_id in node_ids and tgt_id in node_ids:
+            tree_edges.append({
+                "源节点ID": src_id,
+                "目标节点ID": tgt_id,
+                "边类型": edge_type,
+                "边详情": e.get("边详情", {})
+            })
+
+    # 从标签的"所属分类"字段补充标签->分类的边(如果不存在)
+    for n in tag_nodes:
+        level = n.get("节点层级", "")
+        parent_names = n.get("所属分类", [])
+        if parent_names:
+            parent_name = parent_names[-1]
+            parent_id = category_name_to_id.get((level, parent_name))
+            if parent_id:
+                # 检查是否已存在属于边
+                edge_exists = any(
+                    e["源节点ID"] == n["节点ID"] and e["目标节点ID"] == parent_id
+                    and e["边类型"] == "属于"
+                    for e in tree_edges
+                )
+                if not edge_exists:
+                    tree_edges.append({
+                        "源节点ID": n["节点ID"],
+                        "目标节点ID": parent_id,
+                        "边类型": "属于",
+                        "边详情": {}
+                    })
+
+    # 统计各类型边
+    tree_edge_counts = {}
+    for e in tree_edges:
+        t = e["边类型"]
+        tree_edge_counts[t] = tree_edge_counts.get(t, 0) + 1
+
+    print()
+    print(f"构建人设树:")
+    print(f"  总节点数: {len(tree_nodes)}")
+    print(f"  总边数: {len(tree_edges)}")
+    for t, count in sorted(tree_edge_counts.items(), key=lambda x: -x[1]):
+        print(f"    {t}: {count}")
+
+    # 输出
+    output_data = {
+        "说明": {
+            "描述": "人设树结构数据(包含分类、标签和所有边类型)",
+            "分类节点数": len(category_nodes),
+            "标签节点数": len(tag_nodes),
+            "总边数": len(tree_edges),
+            "边类型统计": tree_edge_counts
+        },
+        "nodes": tree_nodes,
+        "edges": tree_edges
+    }
+
+    with open(output_file, "w", encoding="utf-8") as f:
+        json.dump(output_data, f, ensure_ascii=False, indent=2)
+
+    print()
+    print(f"输出文件: {output_file}")
+
+    return output_file
+
+
+if __name__ == "__main__":
+    build_persona_tree()

+ 166 - 0
script/data_processing/extract_category_edges.py

@@ -0,0 +1,166 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+从dimension_associations_analysis.json中提取分类之间的边关系
+"""
+
+import json
+from pathlib import Path
+from typing import Dict, List, Any
+import argparse
+
+
+def get_last_segment(path: str) -> str:
+    """获取路径的最后一段"""
+    return path.split("/")[-1]
+
+
+def build_node_id(dimension: str, node_type: str, name: str) -> str:
+    """
+    构建节点ID
+
+    Args:
+        dimension: 节点层级(灵感点、目的点、关键点)
+        node_type: 节点类型(分类、标签)
+        name: 节点名称(完整路径)
+
+    Returns:
+        节点ID,格式: {层级}_{类型}_{名称最后一段}
+    """
+    last_segment = get_last_segment(name)
+    return f"{dimension}_{node_type}_{last_segment}"
+
+
+def extract_edges_from_single_dimension(data: Dict) -> List[Dict]:
+    """
+    从单维度关联分析中提取边
+
+    Args:
+        data: 单维度关联分析数据
+
+    Returns:
+        边列表
+    """
+    edges = []
+
+    if "单维度关联分析" not in data:
+        return edges
+
+    single_dim = data["单维度关联分析"]
+
+    # 维度映射
+    dimension_map = {
+        "灵感点维度": "灵感点",
+        "目的点维度": "目的点",
+        "关键点维度": "关键点"
+    }
+
+    for dim_key, dim_data in single_dim.items():
+        if dim_key not in dimension_map:
+            continue
+
+        source_dimension = dimension_map[dim_key]
+
+        # 遍历该维度下的所有关联方向
+        for direction_key, direction_data in dim_data.items():
+            if direction_key == "说明":
+                continue
+
+            # 解析方向,如 "灵感点→目的点"
+            if "→" not in direction_key:
+                continue
+
+            # 遍历每个源分类
+            for source_path, source_info in direction_data.items():
+                source_node_id = build_node_id(source_dimension, "分类", source_path)
+
+                # 确定目标维度
+                # 从关联字段名推断,如 "与目的点的关联"
+                for field_name, associations in source_info.items():
+                    if not field_name.startswith("与") or not field_name.endswith("的关联"):
+                        continue
+
+                    # 提取目标维度名称
+                    target_dimension = field_name[1:-3]  # 去掉"与"和"的关联"
+
+                    if not isinstance(associations, list):
+                        continue
+
+                    for assoc in associations:
+                        target_path = assoc.get("目标分类", "")
+                        if not target_path:
+                            continue
+
+                        target_node_id = build_node_id(target_dimension, "分类", target_path)
+
+                        edge = {
+                            "源节点ID": source_node_id,
+                            "目标节点ID": target_node_id,
+                            "边类型": f"{source_dimension}_分类-{target_dimension}_分类",
+                            "边详情": {
+                                "Jaccard相似度": assoc.get("Jaccard相似度", 0),
+                                "重叠系数": assoc.get("重叠系数", 0),
+                                "共同帖子数": assoc.get("共同帖子数", 0),
+                                "共同帖子ID": assoc.get("共同帖子ID", [])
+                            }
+                        }
+                        edges.append(edge)
+
+    return edges
+
+
+def main():
+    parser = argparse.ArgumentParser(description="从dimension_associations_analysis.json中提取分类边关系")
+    parser.add_argument("--input", "-i", type=str, required=True, help="输入文件路径")
+    parser.add_argument("--output", "-o", type=str, required=True, help="输出文件路径")
+    args = parser.parse_args()
+
+    input_file = Path(args.input)
+    output_file = Path(args.output)
+
+    print(f"输入文件: {input_file}")
+    print(f"输出文件: {output_file}")
+
+    # 读取输入文件
+    print(f"\n正在读取文件: {input_file}")
+    with open(input_file, "r", encoding="utf-8") as f:
+        data = json.load(f)
+
+    # 提取边
+    print("\n正在提取边关系...")
+    edges = extract_edges_from_single_dimension(data)
+
+    print(f"提取到 {len(edges)} 条边")
+
+    # 统计边类型
+    edge_type_count = {}
+    for edge in edges:
+        edge_type = edge["边类型"]
+        edge_type_count[edge_type] = edge_type_count.get(edge_type, 0) + 1
+
+    print("\n边类型统计:")
+    for edge_type, count in sorted(edge_type_count.items()):
+        print(f"  {edge_type}: {count} 条")
+
+    # 构建输出
+    output = {
+        "说明": {
+            "描述": "分类之间的边关系",
+            "数据来源": input_file.name
+        },
+        "边列表": edges
+    }
+
+    # 确保输出目录存在
+    output_file.parent.mkdir(parents=True, exist_ok=True)
+
+    # 保存结果
+    print(f"\n正在保存结果到: {output_file}")
+    with open(output_file, "w", encoding="utf-8") as f:
+        json.dump(output, f, ensure_ascii=False, indent=2)
+
+    print("完成!")
+
+
+if __name__ == "__main__":
+    main()

+ 978 - 0
script/data_processing/extract_nodes_and_edges.py

@@ -0,0 +1,978 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+从源数据文件中提取节点列表和边关系
+
+输入:
+1. 过去帖子_pattern聚合结果.json - 分类节点、标签-分类边
+2. 过去帖子_what解构结果目录 - 标签节点来源
+3. dimension_associations_analysis.json - 分类-分类边(共现)
+
+输出:
+1. 节点列表.json
+2. 边关系.json
+"""
+
+import json
+from pathlib import Path
+from typing import Dict, List, Any, Set, Optional
+import sys
+import re
+
+# 添加项目根目录到路径
+project_root = Path(__file__).parent.parent.parent
+sys.path.insert(0, str(project_root))
+
+from script.data_processing.path_config import PathConfig
+from script.detail import get_xiaohongshu_detail
+
+
+def get_post_detail(post_id: str) -> Optional[Dict]:
+    """获取帖子详情"""
+    try:
+        detail = get_xiaohongshu_detail(post_id)
+        return detail
+    except Exception as e:
+        print(f"  警告: 获取帖子 {post_id} 详情失败: {e}")
+        return None
+
+
+def get_last_segment(path: str) -> str:
+    """获取路径的最后一段"""
+    return path.split("/")[-1]
+
+
+def build_node_id(dimension: str, node_type: str, name: str) -> str:
+    """
+    构建节点ID
+
+    Args:
+        dimension: 节点层级(灵感点、目的点、关键点)
+        node_type: 节点类型(分类、标签)
+        name: 节点名称
+
+    Returns:
+        节点ID,格式: {层级}_{类型}_{名称}
+    """
+    return f"{dimension}_{node_type}_{name}"
+
+
+def extract_post_id_from_filename(filename: str) -> str:
+    """从文件名中提取帖子ID"""
+    match = re.match(r'^([^_]+)_', filename)
+    if match:
+        return match.group(1)
+    return ""
+
+
+def get_current_post_ids(current_posts_dir: Path) -> Set[str]:
+    """
+    获取当前帖子目录中的所有帖子ID
+
+    Args:
+        current_posts_dir: 当前帖子目录路径
+
+    Returns:
+        当前帖子ID集合
+    """
+    if not current_posts_dir.exists():
+        print(f"警告: 当前帖子目录不存在: {current_posts_dir}")
+        return set()
+
+    json_files = list(current_posts_dir.glob("*.json"))
+    if not json_files:
+        print(f"警告: 当前帖子目录为空: {current_posts_dir}")
+        return set()
+
+    print(f"找到 {len(json_files)} 个当前帖子")
+
+    post_ids = set()
+    for file_path in json_files:
+        post_id = extract_post_id_from_filename(file_path.name)
+        if post_id:
+            post_ids.add(post_id)
+
+    print(f"提取到 {len(post_ids)} 个帖子ID")
+    return post_ids
+
+
+def collect_all_post_ids_from_nodes(nodes: List[Dict]) -> Set[str]:
+    """从节点列表中收集所有帖子ID"""
+    post_ids = set()
+    for node in nodes:
+        for source in node.get("节点来源", []):
+            post_id = source.get("帖子ID", "")
+            if post_id:
+                post_ids.add(post_id)
+    return post_ids
+
+
+def collect_all_post_ids_from_edges(edges: List[Dict]) -> Set[str]:
+    """从边列表中收集所有帖子ID"""
+    post_ids = set()
+    for edge in edges:
+        if edge.get("边类型") in ("分类共现(跨点)", "标签共现"):
+            edge_details = edge.get("边详情", {})
+            common_post_ids = edge_details.get("共同帖子ID", [])
+            post_ids.update(common_post_ids)
+        # 点内共现边不包含帖子ID
+    return post_ids
+
+
+def fetch_post_details(post_ids: Set[str]) -> Dict[str, Dict]:
+    """
+    批量获取帖子详情
+
+    Args:
+        post_ids: 帖子ID集合
+
+    Returns:
+        帖子ID -> 帖子详情 的映射
+    """
+    print(f"\n正在获取 {len(post_ids)} 个帖子的详情...")
+    post_details = {}
+    for i, post_id in enumerate(sorted(post_ids), 1):
+        print(f"  [{i}/{len(post_ids)}] 获取帖子 {post_id} 的详情...")
+        detail = get_post_detail(post_id)
+        if detail:
+            post_details[post_id] = detail
+    print(f"成功获取 {len(post_details)} 个帖子详情")
+    return post_details
+
+
+
+
+def filter_nodes_by_post_ids(nodes: List[Dict], exclude_post_ids: Set[str]) -> List[Dict]:
+    """
+    过滤节点,排除指定帖子ID的来源
+
+    Args:
+        nodes: 节点列表
+        exclude_post_ids: 要排除的帖子ID集合
+
+    Returns:
+        过滤后的节点列表
+    """
+    filtered_nodes = []
+    for node in nodes:
+        # 过滤节点来源
+        filtered_sources = [
+            source for source in node.get("节点来源", [])
+            if source.get("帖子ID", "") not in exclude_post_ids
+        ]
+
+        # 只保留有来源的节点
+        if filtered_sources:
+            node_copy = node.copy()
+            node_copy["节点来源"] = filtered_sources
+            # 重新计算帖子数
+            unique_post_ids = set(s.get("帖子ID", "") for s in filtered_sources if s.get("帖子ID"))
+            node_copy["帖子数"] = len(unique_post_ids)
+            filtered_nodes.append(node_copy)
+
+    return filtered_nodes
+
+
+def filter_edges_by_post_ids(edges: List[Dict], exclude_post_ids: Set[str]) -> List[Dict]:
+    """
+    过滤边,排除指定帖子ID的共现边
+
+    Args:
+        edges: 边列表
+        exclude_post_ids: 要排除的帖子ID集合
+
+    Returns:
+        过滤后的边列表
+    """
+    filtered_edges = []
+    for edge in edges:
+        edge_type = edge["边类型"]
+        if edge_type in ("分类共现(跨点)", "标签共现"):
+            # 过滤共同帖子ID
+            edge_details = edge.get("边详情", {})
+            common_post_ids = edge_details.get("共同帖子ID", [])
+            filtered_post_ids = [pid for pid in common_post_ids if pid not in exclude_post_ids]
+
+            if filtered_post_ids:
+                edge_copy = edge.copy()
+                edge_copy["边详情"] = edge_details.copy()
+                edge_copy["边详情"]["共同帖子ID"] = filtered_post_ids
+                edge_copy["边详情"]["共同帖子数"] = len(filtered_post_ids)
+                filtered_edges.append(edge_copy)
+        elif edge_type == "分类共现(点内)":
+            # 点内共现边不涉及帖子ID,直接保留
+            filtered_edges.append(edge)
+        else:
+            # 属于/包含边不需要过滤
+            filtered_edges.append(edge)
+
+    return filtered_edges
+
+
+# ========== 分类节点提取 ==========
+
+def extract_category_nodes_from_pattern(
+    pattern_data: Dict,
+    dimension_key: str,
+    dimension_name: str
+) -> List[Dict]:
+    """
+    从pattern聚合结果中提取分类节点
+
+    Args:
+        pattern_data: pattern聚合数据
+        dimension_key: 维度键名(灵感点列表、目的点、关键点列表)
+        dimension_name: 维度名称(灵感点、目的点、关键点)
+
+    Returns:
+        分类节点列表
+    """
+    nodes = []
+
+    if dimension_key not in pattern_data:
+        return nodes
+
+    def traverse_node(node: Dict, parent_categories: List[str]):
+        """递归遍历节点"""
+        for key, value in node.items():
+            if key in ["特征列表", "_meta", "帖子数", "特征数", "帖子列表"]:
+                continue
+
+            if isinstance(value, dict):
+                # 当前节点是一个分类
+                current_path = parent_categories + [key]
+
+                # 获取帖子列表
+                post_ids = value.get("帖子列表", [])
+
+                # 构建节点来源(从特征列表中获取)
+                node_sources = []
+                if "特征列表" in value:
+                    for feature in value["特征列表"]:
+                        source = {
+                            "点的名称": feature.get("所属点", ""),
+                            "点的描述": feature.get("点描述", ""),
+                            "帖子ID": feature.get("帖子id", "")
+                        }
+                        node_sources.append(source)
+
+                node_info = {
+                    "节点ID": build_node_id(dimension_name, "分类", key),
+                    "节点名称": key,
+                    "节点类型": "分类",
+                    "节点层级": dimension_name,
+                    "所属分类": parent_categories.copy(),
+                    "帖子数": len(post_ids),
+                    "节点来源": node_sources
+                }
+                nodes.append(node_info)
+
+                # 递归处理子节点
+                traverse_node(value, current_path)
+
+    traverse_node(pattern_data[dimension_key], [])
+    return nodes
+
+
+# ========== 标签节点提取 ==========
+
+def extract_tag_nodes_from_pattern(
+    pattern_data: Dict,
+    dimension_key: str,
+    dimension_name: str
+) -> List[Dict]:
+    """
+    从pattern聚合结果中提取标签节点
+
+    Args:
+        pattern_data: pattern聚合数据
+        dimension_key: 维度键名
+        dimension_name: 维度名称
+
+    Returns:
+        标签节点列表
+    """
+    nodes = []
+    tag_map = {}  # 用于合并同名标签
+
+    if dimension_key not in pattern_data:
+        return nodes
+
+    def traverse_node(node: Dict, parent_categories: List[str]):
+        """递归遍历节点"""
+        # 处理特征列表(标签)
+        if "特征列表" in node:
+            for feature in node["特征列表"]:
+                tag_name = feature.get("特征名称", "")
+                if not tag_name:
+                    continue
+
+                source = {
+                    "点的名称": feature.get("所属点", ""),
+                    "点的描述": feature.get("点描述", ""),
+                    "帖子ID": feature.get("帖子id", "")
+                }
+
+                tag_id = build_node_id(dimension_name, "标签", tag_name)
+
+                if tag_id not in tag_map:
+                    tag_map[tag_id] = {
+                        "节点ID": tag_id,
+                        "节点名称": tag_name,
+                        "节点类型": "标签",
+                        "节点层级": dimension_name,
+                        "所属分类": parent_categories.copy(),
+                        "帖子数": 0,
+                        "节点来源": [],
+                        "_post_ids": set()
+                    }
+
+                tag_map[tag_id]["节点来源"].append(source)
+                if source["帖子ID"]:
+                    tag_map[tag_id]["_post_ids"].add(source["帖子ID"])
+
+        # 递归处理子节点
+        for key, value in node.items():
+            if key in ["特征列表", "_meta", "帖子数", "特征数", "帖子列表"]:
+                continue
+
+            if isinstance(value, dict):
+                current_path = parent_categories + [key]
+                traverse_node(value, current_path)
+
+    traverse_node(pattern_data[dimension_key], [])
+
+    # 转换为列表,计算帖子数
+    for tag_id, tag_info in tag_map.items():
+        tag_info["帖子数"] = len(tag_info["_post_ids"])
+        del tag_info["_post_ids"]
+        nodes.append(tag_info)
+
+    return nodes
+
+
+# ========== 标签-分类边提取 ==========
+
+def extract_tag_category_edges_from_pattern(
+    pattern_data: Dict,
+    dimension_key: str,
+    dimension_name: str
+) -> List[Dict]:
+    """
+    从pattern聚合结果中提取标签-分类边(属于/包含)
+
+    Args:
+        pattern_data: pattern聚合数据
+        dimension_key: 维度键名
+        dimension_name: 维度名称
+
+    Returns:
+        边列表
+    """
+    edges = []
+    seen_edges = set()  # 避免重复边
+
+    if dimension_key not in pattern_data:
+        return edges
+
+    def traverse_node(node: Dict, parent_categories: List[str]):
+        """递归遍历节点"""
+        current_category = parent_categories[-1] if parent_categories else None
+
+        # 处理特征列表(标签)
+        if "特征列表" in node and current_category:
+            for feature in node["特征列表"]:
+                tag_name = feature.get("特征名称", "")
+                if not tag_name:
+                    continue
+
+                tag_id = build_node_id(dimension_name, "标签", tag_name)
+                category_id = build_node_id(dimension_name, "分类", current_category)
+
+                # 属于边:标签 -> 分类
+                edge_key_belong = (tag_id, category_id, "属于")
+                if edge_key_belong not in seen_edges:
+                    seen_edges.add(edge_key_belong)
+                    edges.append({
+                        "源节点ID": tag_id,
+                        "目标节点ID": category_id,
+                        "边类型": "属于",
+                        "边详情": {}
+                    })
+
+                # 包含边:分类 -> 标签
+                edge_key_contain = (category_id, tag_id, "包含")
+                if edge_key_contain not in seen_edges:
+                    seen_edges.add(edge_key_contain)
+                    edges.append({
+                        "源节点ID": category_id,
+                        "目标节点ID": tag_id,
+                        "边类型": "包含",
+                        "边详情": {}
+                    })
+
+        # 递归处理子节点
+        for key, value in node.items():
+            if key in ["特征列表", "_meta", "帖子数", "特征数", "帖子列表"]:
+                continue
+
+            if isinstance(value, dict):
+                current_path = parent_categories + [key]
+                traverse_node(value, current_path)
+
+    traverse_node(pattern_data[dimension_key], [])
+    return edges
+
+
+# ========== 标签-标签共现边提取 ==========
+
+def extract_tags_from_post(post_data: Dict) -> Dict[str, List[str]]:
+    """
+    从单个帖子的解构结果中提取所有标签(特征名称)
+
+    Args:
+        post_data: 帖子解构数据
+
+    Returns:
+        按维度分组的标签字典 {"灵感点": [...], "目的点": [...], "关键点": [...]}
+    """
+    tags_by_dimension = {
+        "灵感点": [],
+        "目的点": [],
+        "关键点": []
+    }
+
+    if "三点解构" not in post_data:
+        return tags_by_dimension
+
+    three_points = post_data["三点解构"]
+
+    # 提取灵感点的特征
+    if "灵感点" in three_points:
+        inspiration = three_points["灵感点"]
+        for section in ["全新内容", "共性差异", "共性内容"]:
+            if section in inspiration and isinstance(inspiration[section], list):
+                for item in inspiration[section]:
+                    if "提取的特征" in item and isinstance(item["提取的特征"], list):
+                        for feature in item["提取的特征"]:
+                            tag_name = feature.get("特征名称", "")
+                            if tag_name:
+                                tags_by_dimension["灵感点"].append(tag_name)
+
+    # 提取目的点的特征
+    if "目的点" in three_points:
+        purpose = three_points["目的点"]
+        if "purposes" in purpose and isinstance(purpose["purposes"], list):
+            for item in purpose["purposes"]:
+                if "提取的特征" in item and isinstance(item["提取的特征"], list):
+                    for feature in item["提取的特征"]:
+                        tag_name = feature.get("特征名称", "")
+                        if tag_name:
+                            tags_by_dimension["目的点"].append(tag_name)
+
+    # 提取关键点的特征
+    if "关键点" in three_points:
+        key_points = three_points["关键点"]
+        if "key_points" in key_points and isinstance(key_points["key_points"], list):
+            for item in key_points["key_points"]:
+                if "提取的特征" in item and isinstance(item["提取的特征"], list):
+                    for feature in item["提取的特征"]:
+                        tag_name = feature.get("特征名称", "")
+                        if tag_name:
+                            tags_by_dimension["关键点"].append(tag_name)
+
+    return tags_by_dimension
+
+
+def extract_tag_cooccurrence_edges(historical_posts_dir: Path, exclude_post_ids: Set[str] = None) -> List[Dict]:
+    """
+    从历史帖子解构结果中提取标签-标签共现边
+
+    Args:
+        historical_posts_dir: 历史帖子解构结果目录
+        exclude_post_ids: 要排除的帖子ID集合
+
+    Returns:
+        标签共现边列表
+    """
+    if exclude_post_ids is None:
+        exclude_post_ids = set()
+
+    # 存储每对标签的共现信息
+    # key: (tag1_id, tag2_id), value: {"共同帖子ID": set()}
+    cooccurrence_map = {}
+
+    if not historical_posts_dir.exists():
+        print(f"警告: 历史帖子目录不存在: {historical_posts_dir}")
+        return []
+
+    json_files = list(historical_posts_dir.glob("*.json"))
+    print(f"找到 {len(json_files)} 个历史帖子文件")
+
+    for file_path in json_files:
+        # 提取帖子ID
+        post_id = extract_post_id_from_filename(file_path.name)
+        if not post_id:
+            continue
+
+        # 跳过排除的帖子
+        if post_id in exclude_post_ids:
+            continue
+
+        try:
+            with open(file_path, "r", encoding="utf-8") as f:
+                post_data = json.load(f)
+
+            # 提取该帖子的所有标签
+            tags_by_dimension = extract_tags_from_post(post_data)
+
+            # 对每个维度内的标签两两组合,构建共现关系
+            for dimension, tags in tags_by_dimension.items():
+                unique_tags = list(set(tags))  # 去重
+                for i in range(len(unique_tags)):
+                    for j in range(i + 1, len(unique_tags)):
+                        tag1 = unique_tags[i]
+                        tag2 = unique_tags[j]
+
+                        # 构建节点ID
+                        tag1_id = build_node_id(dimension, "标签", tag1)
+                        tag2_id = build_node_id(dimension, "标签", tag2)
+
+                        # 确保顺序一致(按字典序)
+                        if tag1_id > tag2_id:
+                            tag1_id, tag2_id = tag2_id, tag1_id
+
+                        key = (tag1_id, tag2_id, dimension)
+
+                        if key not in cooccurrence_map:
+                            cooccurrence_map[key] = {"共同帖子ID": set()}
+
+                        cooccurrence_map[key]["共同帖子ID"].add(post_id)
+
+        except Exception as e:
+            print(f"  警告: 处理文件 {file_path.name} 时出错: {e}")
+
+    # 转换为边列表
+    edges = []
+    for (tag1_id, tag2_id, dimension), info in cooccurrence_map.items():
+        common_post_ids = list(info["共同帖子ID"])
+        edge = {
+            "源节点ID": tag1_id,
+            "目标节点ID": tag2_id,
+            "边类型": "标签共现",
+            "边详情": {
+                "共同帖子数": len(common_post_ids),
+                "共同帖子ID": common_post_ids
+            }
+        }
+        edges.append(edge)
+
+    return edges
+
+
+# ========== 分类-分类边提取 ==========
+
+def extract_category_edges_from_associations(associations_data: Dict) -> List[Dict]:
+    """
+    从dimension_associations_analysis.json中提取分类-分类边(共现)
+
+    Args:
+        associations_data: 关联分析数据
+
+    Returns:
+        边列表
+    """
+    edges = []
+
+    if "单维度关联分析" not in associations_data:
+        return edges
+
+    single_dim = associations_data["单维度关联分析"]
+
+    # 维度映射
+    dimension_map = {
+        "灵感点维度": "灵感点",
+        "目的点维度": "目的点",
+        "关键点维度": "关键点"
+    }
+
+    for dim_key, dim_data in single_dim.items():
+        if dim_key not in dimension_map:
+            continue
+
+        source_dimension = dimension_map[dim_key]
+
+        # 遍历该维度下的所有关联方向
+        for direction_key, direction_data in dim_data.items():
+            if direction_key == "说明":
+                continue
+
+            if "→" not in direction_key:
+                continue
+
+            # 遍历每个源分类
+            for source_path, source_info in direction_data.items():
+                source_name = get_last_segment(source_path)
+                source_node_id = build_node_id(source_dimension, "分类", source_name)
+
+                # 确定目标维度
+                for field_name, associations in source_info.items():
+                    if not field_name.startswith("与") or not field_name.endswith("的关联"):
+                        continue
+
+                    target_dimension = field_name[1:-3]
+
+                    if not isinstance(associations, list):
+                        continue
+
+                    for assoc in associations:
+                        target_path = assoc.get("目标分类", "")
+                        if not target_path:
+                            continue
+
+                        target_name = get_last_segment(target_path)
+                        target_node_id = build_node_id(target_dimension, "分类", target_name)
+
+                        edge = {
+                            "源节点ID": source_node_id,
+                            "目标节点ID": target_node_id,
+                            "边类型": "分类共现(跨点)",
+                            "边详情": {
+                                "Jaccard相似度": assoc.get("Jaccard相似度", 0),
+                                "重叠系数": assoc.get("重叠系数", 0),
+                                "共同帖子数": assoc.get("共同帖子数", 0),
+                                "共同帖子ID": assoc.get("共同帖子ID", [])
+                            }
+                        }
+                        edges.append(edge)
+
+    return edges
+
+
+# ========== 点内分类共现边提取 ==========
+
+def extract_intra_category_edges(intra_associations_data: Dict) -> List[Dict]:
+    """
+    从intra_dimension_associations_analysis.json中提取点内分类共现边
+
+    Args:
+        intra_associations_data: 点内关联分析数据
+
+    Returns:
+        边列表
+    """
+    edges = []
+    seen_edges = set()  # 避免重复边
+
+    if "叶子分类组合聚类" not in intra_associations_data:
+        return edges
+
+    clusters_by_dim = intra_associations_data["叶子分类组合聚类"]
+
+    for dimension, clusters in clusters_by_dim.items():
+        if dimension not in ("灵感点", "目的点", "关键点"):
+            continue
+
+        for cluster_key, cluster_data in clusters.items():
+            leaf_categories = cluster_data.get("叶子分类组合", [])
+            point_count = cluster_data.get("点数", 0)
+            point_details = cluster_data.get("点详情列表", [])
+
+            # 提取点名称列表
+            point_names = [p.get("点名称", "") for p in point_details if p.get("点名称")]
+
+            # 两两组合生成共现边
+            for i in range(len(leaf_categories)):
+                for j in range(i + 1, len(leaf_categories)):
+                    cat1 = leaf_categories[i]
+                    cat2 = leaf_categories[j]
+
+                    # 构建节点ID
+                    cat1_id = build_node_id(dimension, "分类", cat1)
+                    cat2_id = build_node_id(dimension, "分类", cat2)
+
+                    # 确保顺序一致(按字典序)
+                    if cat1_id > cat2_id:
+                        cat1_id, cat2_id = cat2_id, cat1_id
+
+                    edge_key = (cat1_id, cat2_id, dimension)
+
+                    if edge_key in seen_edges:
+                        # 已存在的边,累加点数和点名称
+                        for edge in edges:
+                            if (edge["源节点ID"] == cat1_id and
+                                edge["目标节点ID"] == cat2_id and
+                                edge["边类型"] == "分类共现(点内)"):
+                                edge["边详情"]["点数"] += point_count
+                                edge["边详情"]["关联点名称"].extend(point_names)
+                                break
+                    else:
+                        seen_edges.add(edge_key)
+                        edge = {
+                            "源节点ID": cat1_id,
+                            "目标节点ID": cat2_id,
+                            "边类型": "分类共现(点内)",
+                            "边详情": {
+                                "点数": point_count,
+                                "关联点名称": point_names.copy()
+                            }
+                        }
+                        edges.append(edge)
+
+    return edges
+
+
+# ========== 主函数 ==========
+
+def main():
+    # 使用路径配置
+    config = PathConfig()
+    config.ensure_dirs()
+
+    print(f"账号: {config.account_name}")
+    print(f"输出版本: {config.output_version}")
+    print(f"过滤模式: {config.filter_mode}")
+    print()
+
+    # 输入文件路径
+    pattern_file = config.pattern_cluster_file
+    associations_file = config.account_dir / "pattern相关文件/optimization/dimension_associations_analysis.json"
+    intra_associations_file = config.account_dir / "pattern相关文件/optimization/intra_dimension_associations_analysis.json"
+    current_posts_dir = config.current_posts_dir
+
+    # 输出文件路径
+    nodes_output_file = config.intermediate_dir / "节点列表.json"
+    edges_output_file = config.intermediate_dir / "边关系.json"
+
+    print(f"输入文件:")
+    print(f"  pattern聚合文件: {pattern_file}")
+    print(f"  跨点关联分析文件: {associations_file}")
+    print(f"  点内关联分析文件: {intra_associations_file}")
+    print(f"  当前帖子目录: {current_posts_dir}")
+    print(f"\n输出文件:")
+    print(f"  节点列表: {nodes_output_file}")
+    print(f"  边关系: {edges_output_file}")
+    print()
+
+    # 读取pattern聚合结果
+    print("正在读取pattern聚合结果...")
+    with open(pattern_file, "r", encoding="utf-8") as f:
+        pattern_data = json.load(f)
+
+    # 读取跨点关联分析结果
+    print("正在读取跨点关联分析结果...")
+    with open(associations_file, "r", encoding="utf-8") as f:
+        associations_data = json.load(f)
+
+    # 读取点内关联分析结果
+    print("正在读取点内关联分析结果...")
+    with open(intra_associations_file, "r", encoding="utf-8") as f:
+        intra_associations_data = json.load(f)
+
+    # ===== 提取节点 =====
+    print("\n" + "="*60)
+    print("正在提取节点...")
+
+    all_nodes = []
+
+    # 维度映射
+    dimension_mapping = {
+        "灵感点列表": "灵感点",
+        "目的点": "目的点",
+        "关键点列表": "关键点"
+    }
+
+    # 提取分类节点
+    print("\n提取分类节点:")
+    for dim_key, dim_name in dimension_mapping.items():
+        category_nodes = extract_category_nodes_from_pattern(pattern_data, dim_key, dim_name)
+        all_nodes.extend(category_nodes)
+        print(f"  {dim_name}: {len(category_nodes)} 个分类节点")
+
+    # 提取标签节点
+    print("\n提取标签节点:")
+    for dim_key, dim_name in dimension_mapping.items():
+        tag_nodes = extract_tag_nodes_from_pattern(pattern_data, dim_key, dim_name)
+        all_nodes.extend(tag_nodes)
+        print(f"  {dim_name}: {len(tag_nodes)} 个标签节点")
+
+    print(f"\n总计: {len(all_nodes)} 个节点")
+
+    # 统计节点类型
+    category_count = sum(1 for n in all_nodes if n["节点类型"] == "分类")
+    tag_count = sum(1 for n in all_nodes if n["节点类型"] == "标签")
+    print(f"  分类节点: {category_count}")
+    print(f"  标签节点: {tag_count}")
+
+    # ===== 提取边 =====
+    print("\n" + "="*60)
+    print("正在提取边...")
+
+    all_edges = []
+
+    # 提取分类-分类边(跨点共现)
+    print("\n提取分类-分类边(跨点共现):")
+    category_edges = extract_category_edges_from_associations(associations_data)
+    all_edges.extend(category_edges)
+    print(f"  分类共现(跨点)边: {len(category_edges)} 条")
+
+    # 提取分类-分类边(点内共现)
+    print("\n提取分类-分类边(点内共现):")
+    intra_category_edges = extract_intra_category_edges(intra_associations_data)
+    all_edges.extend(intra_category_edges)
+    print(f"  分类共现(点内)边: {len(intra_category_edges)} 条")
+
+    # 提取标签-分类边(属于/包含)
+    print("\n提取标签-分类边(属于/包含):")
+    belong_count = 0
+    contain_count = 0
+    for dim_key, dim_name in dimension_mapping.items():
+        tag_category_edges = extract_tag_category_edges_from_pattern(pattern_data, dim_key, dim_name)
+        all_edges.extend(tag_category_edges)
+        dim_belong = sum(1 for e in tag_category_edges if e["边类型"] == "属于")
+        dim_contain = sum(1 for e in tag_category_edges if e["边类型"] == "包含")
+        belong_count += dim_belong
+        contain_count += dim_contain
+        print(f"  {dim_name}: {dim_belong} 条属于边, {dim_contain} 条包含边")
+
+    # 提取标签-标签边(共现)- 需要在过滤之前先记录排除的帖子ID
+    # 这里先占位,过滤后再处理
+    tag_cooccurrence_edges_placeholder = True
+
+    print(f"\n边统计(标签共现待提取):")
+    print(f"  分类共现(跨点)边: {len(category_edges)}")
+    print(f"  分类共现(点内)边: {len(intra_category_edges)}")
+    print(f"  属于边: {belong_count}")
+    print(f"  包含边: {contain_count}")
+
+    # ===== 应用过滤 =====
+    exclude_post_ids = set()
+    filter_mode = config.filter_mode
+
+    if filter_mode == "exclude_current_posts":
+        print("\n" + "="*60)
+        print("应用过滤规则: 排除当前帖子ID")
+        exclude_post_ids = get_current_post_ids(current_posts_dir)
+
+        if exclude_post_ids:
+            # 过滤节点
+            nodes_before = len(all_nodes)
+            all_nodes = filter_nodes_by_post_ids(all_nodes, exclude_post_ids)
+            nodes_after = len(all_nodes)
+            print(f"\n节点过滤: {nodes_before} -> {nodes_after} (移除 {nodes_before - nodes_after} 个)")
+
+            # 过滤边
+            edges_before = len(all_edges)
+            all_edges = filter_edges_by_post_ids(all_edges, exclude_post_ids)
+            edges_after = len(all_edges)
+            print(f"边过滤: {edges_before} -> {edges_after} (移除 {edges_before - edges_after} 条)")
+    elif filter_mode == "none":
+        print("\n过滤模式: none,不应用任何过滤")
+    else:
+        print(f"\n警告: 未知的过滤模式 '{filter_mode}',不应用过滤")
+
+    # ===== 提取标签-标签共现边 =====
+    print("\n" + "="*60)
+    print("提取标签-标签共现边...")
+    historical_posts_dir = config.historical_posts_dir
+    print(f"历史帖子目录: {historical_posts_dir}")
+    tag_cooccurrence_edges = extract_tag_cooccurrence_edges(historical_posts_dir, exclude_post_ids)
+    all_edges.extend(tag_cooccurrence_edges)
+    print(f"  标签-标签共现边: {len(tag_cooccurrence_edges)} 条")
+
+    # 更新总计
+    print(f"\n总计: {len(all_edges)} 条边")
+    print(f"  分类共现(跨点)边: {len(category_edges)}")
+    print(f"  分类共现(点内)边: {len(intra_category_edges)}")
+    print(f"  标签共现边: {len(tag_cooccurrence_edges)}")
+    print(f"  属于边: {belong_count}")
+    print(f"  包含边: {contain_count}")
+
+    # ===== 获取帖子详情 =====
+    print("\n" + "="*60)
+    print("获取帖子详情...")
+
+    # 收集所有需要获取详情的帖子ID(从节点和边)
+    post_ids_from_nodes = collect_all_post_ids_from_nodes(all_nodes)
+    post_ids_from_edges = collect_all_post_ids_from_edges(all_edges)
+    all_post_ids = post_ids_from_nodes | post_ids_from_edges
+    print(f"节点中的帖子: {len(post_ids_from_nodes)} 个")
+    print(f"边中的帖子: {len(post_ids_from_edges)} 个")
+    print(f"合计(去重): {len(all_post_ids)} 个")
+
+    # 批量获取帖子详情
+    post_details = fetch_post_details(all_post_ids)
+
+    # ===== 保存结果 =====
+    print("\n" + "="*60)
+
+    # 输出文件路径
+    post_details_output_file = config.intermediate_dir / "帖子详情映射.json"
+
+    # 保存节点列表
+    nodes_output = {
+        "说明": {
+            "描述": "分类和标签节点列表",
+            "数据来源": ["过去帖子_pattern聚合结果.json"],
+            "过滤模式": filter_mode,
+            "过滤帖子数": len(exclude_post_ids) if exclude_post_ids else 0
+        },
+        "节点列表": all_nodes
+    }
+
+    print(f"正在保存节点列表到: {nodes_output_file}")
+    with open(nodes_output_file, "w", encoding="utf-8") as f:
+        json.dump(nodes_output, f, ensure_ascii=False, indent=2)
+
+    # 构建节点ID索引的边关系: 节点 -> 边类型 -> {目标节点: 完整边信息}
+    edges_by_node = {}  # key: 节点ID, value: {边类型: {目标节点ID: 完整边信息}}
+    for edge in all_edges:
+        source_id = edge["源节点ID"]
+        target_id = edge["目标节点ID"]
+        edge_type = edge["边类型"]
+
+        # 源节点 -> 目标节点
+        if source_id not in edges_by_node:
+            edges_by_node[source_id] = {}
+        if edge_type not in edges_by_node[source_id]:
+            edges_by_node[source_id][edge_type] = {}
+        edges_by_node[source_id][edge_type][target_id] = edge
+
+    # 保存边关系
+    edges_output = {
+        "说明": {
+            "描述": "分类和标签之间的边关系",
+            "数据来源": ["过去帖子_pattern聚合结果.json", "dimension_associations_analysis.json", "过去帖子_what解构结果目录"],
+            "过滤模式": filter_mode,
+            "过滤帖子数": len(exclude_post_ids) if exclude_post_ids else 0
+        },
+        "边列表": all_edges,
+        "节点边索引": edges_by_node
+    }
+
+    print(f"正在保存边关系到: {edges_output_file}")
+    with open(edges_output_file, "w", encoding="utf-8") as f:
+        json.dump(edges_output, f, ensure_ascii=False, indent=2)
+
+    # 保存帖子详情映射
+    post_details_output = {
+        "说明": {
+            "描述": "帖子ID到帖子详情的映射",
+            "帖子数": len(post_details)
+        },
+        "帖子详情": post_details
+    }
+
+    print(f"正在保存帖子详情映射到: {post_details_output_file}")
+    with open(post_details_output_file, "w", encoding="utf-8") as f:
+        json.dump(post_details_output, f, ensure_ascii=False, indent=2)
+
+    print("\n完成!")
+    print(f"\n输出文件:")
+    print(f"  节点列表: {len(all_nodes)} 个节点")
+    print(f"  边关系: {len(all_edges)} 条边")
+    print(f"  帖子详情映射: {len(post_details)} 个帖子")
+
+
+if __name__ == "__main__":
+    main()

+ 2832 - 0
script/data_processing/visualize_match_graph.py

@@ -0,0 +1,2832 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+将匹配图谱数据可视化为交互式HTML文件
+
+输入:match_graph目录下的JSON文件
+输出:单个HTML文件,包含所有帖子的图谱,可通过Tab切换
+"""
+
+import json
+from pathlib import Path
+from typing import Dict, List
+import sys
+
+# 添加项目根目录到路径
+project_root = Path(__file__).parent.parent.parent
+sys.path.insert(0, str(project_root))
+
+from script.data_processing.path_config import PathConfig
+
+
+HTML_TEMPLATE = '''<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>匹配图谱可视化</title>
+    <script src="https://d3js.org/d3.v7.min.js"></script>
+    <style>
+        * {{
+            margin: 0;
+            padding: 0;
+            box-sizing: border-box;
+        }}
+        body {{
+            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+            background: #1a1a2e;
+            color: #eee;
+            overflow: hidden;
+        }}
+        #container {{
+            display: flex;
+            height: 100vh;
+            flex-direction: column;
+        }}
+
+        /* Tab样式 */
+        .tabs {{
+            display: flex;
+            background: #0f3460;
+            padding: 0 20px;
+            overflow-x: auto;
+            flex-shrink: 0;
+        }}
+        .tab {{
+            padding: 12px 20px;
+            cursor: pointer;
+            border-bottom: 3px solid transparent;
+            white-space: nowrap;
+            font-size: 13px;
+            color: #888;
+            transition: all 0.2s;
+        }}
+        .tab:hover {{
+            color: #fff;
+            background: rgba(255,255,255,0.05);
+        }}
+        .tab.active {{
+            color: #e94560;
+            border-bottom-color: #e94560;
+            background: rgba(233, 69, 96, 0.1);
+        }}
+
+        /* 主内容区 */
+        .main-content {{
+            display: flex;
+            flex: 1;
+            overflow: hidden;
+        }}
+        #graph {{
+            flex: 1;
+            position: relative;
+            overflow: auto;
+        }}
+        #sidebar {{
+            width: 300px;
+            background: #16213e;
+            padding: 15px;
+            overflow-y: auto;
+            border-left: 1px solid #0f3460;
+            display: flex;
+            flex-direction: column;
+        }}
+        #persona-tree-panel {{
+            flex: 1;
+            overflow-y: auto;
+            margin-top: 15px;
+            border-top: 1px solid #0f3460;
+            padding-top: 10px;
+        }}
+        .tree-dimension {{
+            margin-bottom: 15px;
+        }}
+        .tree-dimension-title {{
+            font-size: 12px;
+            font-weight: bold;
+            padding: 5px 8px;
+            border-radius: 4px;
+            margin-bottom: 5px;
+            cursor: pointer;
+            display: flex;
+            align-items: center;
+            gap: 5px;
+        }}
+        .tree-dimension-title:hover {{
+            opacity: 0.8;
+        }}
+        .tree-dimension-title .toggle {{
+            font-size: 10px;
+            transition: transform 0.2s;
+        }}
+        .tree-dimension-title.collapsed .toggle {{
+            transform: rotate(-90deg);
+        }}
+        .tree-dimension.inspiration .tree-dimension-title {{
+            background: rgba(243, 156, 18, 0.2);
+            color: #f39c12;
+        }}
+        .tree-dimension.purpose .tree-dimension-title {{
+            background: rgba(52, 152, 219, 0.2);
+            color: #3498db;
+        }}
+        .tree-dimension.key .tree-dimension-title {{
+            background: rgba(155, 89, 182, 0.2);
+            color: #9b59b6;
+        }}
+        .tree-list {{
+            list-style: none;
+            padding-left: 0;
+            margin: 0;
+            font-size: 11px;
+        }}
+        .tree-list.collapsed {{
+            display: none;
+        }}
+        .tree-item {{
+            padding: 3px 0;
+            color: #aaa;
+            cursor: pointer;
+            border-radius: 3px;
+            padding: 3px 6px;
+            margin: 1px 0;
+        }}
+        .tree-item:hover {{
+            background: rgba(255,255,255,0.08);
+            color: #fff;
+        }}
+        .tree-item.matched {{
+            color: #2ecc71;
+            font-weight: 500;
+        }}
+        .tree-item .indent {{
+            display: inline-block;
+            width: 12px;
+            color: #555;
+        }}
+        h1 {{
+            font-size: 15px;
+            margin-bottom: 10px;
+            color: #e94560;
+        }}
+        h2 {{
+            font-size: 12px;
+            margin: 10px 0 6px;
+            color: #0f9b8e;
+        }}
+        .legend {{
+            margin-top: 10px;
+        }}
+        .legend-grid {{
+            display: grid;
+            grid-template-columns: 1fr 1fr;
+            gap: 4px 8px;
+        }}
+        .legend-item {{
+            display: flex;
+            align-items: center;
+            font-size: 11px;
+        }}
+        .legend-color {{
+            width: 12px;
+            height: 12px;
+            border-radius: 50%;
+            margin-right: 6px;
+            flex-shrink: 0;
+        }}
+        .legend-line {{
+            width: 20px;
+            height: 3px;
+            margin-right: 6px;
+            flex-shrink: 0;
+        }}
+        .detail-panel {{
+            margin-top: 20px;
+            padding: 15px;
+            background: #0f3460;
+            border-radius: 8px;
+            display: none;
+        }}
+        .detail-panel.active {{
+            display: block;
+        }}
+        .detail-panel h3 {{
+            font-size: 14px;
+            margin-bottom: 10px;
+            color: #e94560;
+        }}
+        .detail-panel p {{
+            font-size: 12px;
+            line-height: 1.6;
+            color: #ccc;
+            margin: 5px 0;
+        }}
+        .detail-panel .label {{
+            color: #888;
+        }}
+        .detail-panel .close-btn {{
+            position: absolute;
+            top: 10px;
+            right: 10px;
+            background: none;
+            border: none;
+            color: #888;
+            cursor: pointer;
+            font-size: 16px;
+        }}
+        .detail-panel .close-btn:hover {{
+            color: #e94560;
+        }}
+        .detail-panel-wrapper {{
+            position: relative;
+        }}
+        .similarity-score {{
+            background: #e94560;
+            color: #fff;
+            padding: 2px 6px;
+            border-radius: 4px;
+            font-weight: bold;
+        }}
+        .edge-description {{
+            background: #1a1a2e;
+            padding: 10px;
+            border-radius: 4px;
+            margin-top: 8px;
+            font-size: 11px;
+            line-height: 1.5;
+        }}
+        .edge-list {{
+            background: #1a1a2e;
+            padding: 8px;
+            border-radius: 4px;
+            margin-top: 4px;
+            font-size: 11px;
+        }}
+        .edge-type-item {{
+            display: flex;
+            justify-content: space-between;
+            padding: 2px 0;
+            border-bottom: 1px solid rgba(255,255,255,0.05);
+        }}
+        .edge-type {{
+            color: #aaa;
+        }}
+        .edge-count {{
+            color: #e94560;
+            font-weight: bold;
+        }}
+        .edge-detail {{
+            padding: 3px 0;
+            color: #888;
+            font-size: 10px;
+            border-left: 2px solid #333;
+            padding-left: 8px;
+            margin: 2px 0;
+        }}
+        .edge-type-tag {{
+            background: rgba(255,255,255,0.1);
+            padding: 1px 4px;
+            border-radius: 2px;
+            font-size: 9px;
+            margin-left: 4px;
+        }}
+        .edge-more {{
+            color: #666;
+            font-size: 10px;
+            text-align: center;
+            padding: 4px 0;
+        }}
+        .edge-empty {{
+            color: #555;
+            font-size: 10px;
+            padding: 4px 0;
+        }}
+        svg {{
+            width: 100%;
+            display: block;  /* 避免底部多余空间 */
+        }}
+        .node {{
+            cursor: pointer;
+        }}
+        .node circle, .node rect, .node polygon {{
+            stroke-width: 3px;
+        }}
+        .node .post-point-node {{
+            stroke: #fff;
+            stroke-width: 4px;
+        }}
+        .node .post-node,
+        .node .persona-node {{
+            stroke: #fff;
+        }}
+        .node text {{
+            font-size: 11px;
+            fill: #fff;
+            pointer-events: none;
+        }}
+        .link {{
+            stroke-opacity: 0.7;
+        }}
+        .link-hitarea {{
+            stroke: transparent;
+            stroke-width: 15px;
+            cursor: pointer;
+            fill: none;
+        }}
+        .link-hitarea:hover + .link {{
+            stroke-opacity: 1;
+            stroke-width: 3px;
+        }}
+        .edge-label {{
+            font-size: 10px;
+            fill: #fff;
+            pointer-events: none;
+            text-anchor: middle;
+        }}
+        .edge-label-bg {{
+            fill: rgba(0,0,0,0.7);
+        }}
+        .link.match {{
+            stroke: #e94560;
+            stroke-dasharray: 5,5;
+        }}
+        .link.category-cross {{
+            stroke: #2ecc71;
+        }}
+        .link.category-intra {{
+            stroke: #3498db;
+        }}
+        .link.tag-cooccur {{
+            stroke: #f39c12;
+        }}
+        .link.belong {{
+            stroke: #9b59b6;
+        }}
+        .link.contain {{
+            stroke: #8e44ad;
+            stroke-dasharray: 2,2;
+        }}
+        /* 镜像边样式(实线,颜色与原边相同) */
+        .link.mirror-category-cross {{
+            stroke: #2ecc71;
+        }}
+        .link.mirror-category-intra {{
+            stroke: #3498db;
+        }}
+        .link.mirror-tag-cooccur {{
+            stroke: #f39c12;
+        }}
+        .link.mirror-belong {{
+            stroke: #9b59b6;
+        }}
+        .link.mirror-contain {{
+            stroke: #8e44ad;
+        }}
+        /* 二阶边现在使用与镜像边相同的样式(基于原始边类型) */
+        /* 高亮/灰化样式 */
+        .node.dimmed circle, .node.dimmed rect, .node.dimmed polygon {{
+            opacity: 0.15 !important;
+        }}
+        .node.dimmed text {{
+            opacity: 0.15 !important;
+        }}
+        .link-group.dimmed .link {{
+            stroke-opacity: 0.08 !important;
+        }}
+        .link-group.dimmed .edge-label-group {{
+            opacity: 0.15 !important;
+        }}
+        .node.highlighted circle, .node.highlighted rect, .node.highlighted polygon {{
+            stroke: #fff !important;
+            stroke-width: 4px !important;
+            filter: drop-shadow(0 0 8px rgba(255,255,255,0.5));
+        }}
+        .link-group.highlighted .link {{
+            stroke-opacity: 1 !important;
+            stroke-width: 3px !important;
+            filter: drop-shadow(0 0 4px rgba(255,255,255,0.3));
+        }}
+        .tooltip {{
+            position: absolute;
+            background: rgba(0,0,0,0.9);
+            color: #fff;
+            padding: 10px 15px;
+            border-radius: 6px;
+            font-size: 12px;
+            pointer-events: none;
+            max-width: 300px;
+            z-index: 1000;
+            display: none;
+        }}
+        .controls {{
+            background: rgba(15, 52, 96, 0.5);
+            padding: 12px;
+            border-radius: 8px;
+            margin-top: auto;
+            border-top: 1px solid #0f3460;
+            padding-top: 15px;
+        }}
+        .controls button {{
+            background: #0f3460;
+            color: #fff;
+            border: none;
+            padding: 8px 15px;
+            margin: 5px;
+            border-radius: 4px;
+            cursor: pointer;
+            font-size: 12px;
+        }}
+        .controls button:hover {{
+            background: #e94560;
+        }}
+        .controls button.active {{
+            background: #e94560;
+        }}
+        .controls .control-group {{
+            margin-top: 10px;
+            padding-top: 10px;
+            border-top: 1px solid rgba(255,255,255,0.1);
+        }}
+        .controls .control-label {{
+            font-size: 11px;
+            color: #888;
+            margin-bottom: 5px;
+        }}
+        .controls select {{
+            background: #0f3460;
+            color: #fff;
+            border: none;
+            padding: 6px 10px;
+            border-radius: 4px;
+            font-size: 12px;
+            cursor: pointer;
+        }}
+        /* 关系子图节点样式 */
+        .ego-node {{
+            cursor: pointer;
+        }}
+        .ego-node circle {{
+            stroke-width: 2px;
+        }}
+        .ego-node.center circle {{
+            stroke: #fff;
+            stroke-width: 3px;
+            filter: drop-shadow(0 0 8px rgba(255,255,255,0.5));
+        }}
+        .ego-node text {{
+            font-size: 10px;
+            fill: #fff;
+            pointer-events: none;
+        }}
+        .ego-edge {{
+            stroke-opacity: 0.6;
+            stroke-width: 1.5;
+        }}
+    </style>
+</head>
+<body>
+    <div id="container">
+        <div class="tabs" id="tabs">
+            {tabs_html}
+        </div>
+        <div class="main-content">
+            <div id="graph">
+                <div class="tooltip" id="tooltip"></div>
+            </div>
+            <div id="sidebar">
+                <h1>匹配图谱</h1>
+
+                <div class="detail-panel active" id="detailPanel">
+                    <h3 id="detailTitle">点击节点或边查看详情</h3>
+                    <div id="detailContent">
+                        <p style="color: #888; font-size: 11px;">点击图中的节点或边,这里会显示详细信息</p>
+                    </div>
+                </div>
+
+                <div class="legend">
+                    <h2>节点</h2>
+                    <div class="legend-grid">
+                        <div class="legend-item">
+                            <div class="legend-color" style="background: #f39c12; border: 2px solid #fff;"></div>
+                            <span>灵感点</span>
+                        </div>
+                        <div class="legend-item">
+                            <div class="legend-color" style="background: #3498db; border: 2px solid #fff;"></div>
+                            <span>目的点</span>
+                        </div>
+                        <div class="legend-item">
+                            <div class="legend-color" style="background: #9b59b6; border: 2px solid #fff;"></div>
+                            <span>关键点</span>
+                        </div>
+                    </div>
+                    <h2>边</h2>
+                    <div class="legend-grid">
+                        <div class="legend-item">
+                            <div class="legend-line" style="background: #e94560;"></div>
+                            <span>匹配</span>
+                        </div>
+                        <div class="legend-item">
+                            <div class="legend-line" style="background: #9b59b6;"></div>
+                            <span>属于</span>
+                        </div>
+                        <div class="legend-item">
+                            <div class="legend-line" style="background: #2ecc71;"></div>
+                            <span>分类共现(跨)</span>
+                        </div>
+                        <div class="legend-item">
+                            <div class="legend-line" style="background: #3498db;"></div>
+                            <span>分类共现(内)</span>
+                        </div>
+                        <div class="legend-item">
+                            <div class="legend-line" style="background: #f39c12;"></div>
+                            <span>标签共现</span>
+                        </div>
+                    </div>
+                </div>
+
+                <div class="controls">
+                    <div class="control-label">视图控制</div>
+                    <button onclick="resetZoom()">重置视图</button>
+                    <button onclick="toggleLabels()">切换标签</button>
+                    <button onclick="toggleCrossLayerEdges()" id="crossLayerBtn">显示跨层边</button>
+                    <div class="control-group">
+                        <div class="control-label">人设树配置</div>
+                        <button onclick="toggleTreeTags()" id="treeTagBtn" class="active">显示标签</button>
+                        <select id="treeDepthSelect" onchange="setTreeDepth(this.value)">
+                            <option value="0">全部层级</option>
+                            <option value="2">2层</option>
+                            <option value="3">3层</option>
+                            <option value="4">4层</option>
+                            <option value="5">5层</option>
+                        </select>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <script>
+        // 所有帖子的图谱数据
+        const allGraphData = {all_graph_data};
+
+        // 完整的人设树数据(所有人设节点和属于边)
+        const personaTreeData = {persona_tree_data};
+
+        // 当前选中的帖子索引
+        let currentIndex = 0;
+        let simulation = null;
+        let svg = null;
+        let g = null;
+        let zoom = null;
+        let showLabels = true;
+        let showCrossLayerEdges = false;  // 跨层边默认隐藏
+        let isCrossLayerEdge = null;  // 跨层边判断函数(在renderGraph中设置)
+
+        // 人设树配置
+        let showTreeTags = true;  // 是否显示标签节点
+        let treeMaxDepth = 0;     // 显示的最大层数(0=全部)
+
+        // 切换标签显示
+        function toggleTreeTags() {{
+            showTreeTags = !showTreeTags;
+            const btn = document.getElementById("treeTagBtn");
+            btn.textContent = showTreeTags ? "显示标签" : "隐藏标签";
+            btn.classList.toggle("active", showTreeTags);
+            // 重新渲染当前图谱
+            renderGraph(allGraphData[currentIndex]);
+        }}
+
+        // 设置树的显示层数
+        function setTreeDepth(depth) {{
+            treeMaxDepth = parseInt(depth);
+            // 重新渲染当前图谱
+            renderGraph(allGraphData[currentIndex]);
+        }}
+
+        // 初始化
+        function init() {{
+            const container = document.getElementById("graph");
+            const width = container.clientWidth;
+            const height = container.clientHeight;
+
+            svg = d3.select("#graph")
+                .append("svg")
+                .attr("width", width)
+                .attr("height", height);
+
+            g = svg.append("g");
+
+            zoom = d3.zoom()
+                .scaleExtent([0.1, 4])
+                .on("zoom", (event) => {{
+                    g.attr("transform", event.transform);
+                }});
+
+            svg.call(zoom);
+
+            // 绑定Tab点击事件
+            document.querySelectorAll(".tab").forEach((tab, index) => {{
+                tab.addEventListener("click", () => switchTab(index));
+            }});
+
+            // 显示第一个帖子
+            switchTab(0);
+        }}
+
+        // 构建人设树数据(合并为一棵树,根节点为"人设")
+        // showTreeTags: 控制是否显示标签节点
+        // treeMaxDepth: 控制树的显示深度(0=全部,2=维度,3=+一层,4=+两层...)
+        function buildPersonaTreeData() {{
+            const dimensions = ["灵感点", "目的点", "关键点"];
+            const dimColors = {{ "灵感点": "#f39c12", "目的点": "#3498db", "关键点": "#9b59b6" }};
+            const dimensionChildren = [];
+            let totalNodeCount = 0;
+
+            dimensions.forEach(dim => {{
+                // 根据 showTreeTags 过滤节点(独立于深度控制)
+                let dimNodes = personaTreeData.nodes.filter(n => n.节点层级 === dim);
+                if (!showTreeTags) {{
+                    dimNodes = dimNodes.filter(n => n.节点类型 !== "标签");
+                }}
+
+                if (dimNodes.length === 0) {{
+                    const dimNode = {{
+                        name: dim,
+                        节点名称: dim,
+                        isDimension: true,
+                        dimColor: dimColors[dim],
+                        children: []
+                    }};
+                    dimensionChildren.push(dimNode);
+                    return;
+                }}
+
+                const nodeMap = {{}};
+                dimNodes.forEach(n => nodeMap[n.节点ID] = {{ ...n, name: n.节点名称, children: [], dimColor: dimColors[dim] }});
+
+                // 只用属于边构建父子关系
+                const hierarchyEdgeTypes = ["属于"];
+                const hasParent = new Set();
+                personaTreeData.edges.forEach(e => {{
+                    if (!hierarchyEdgeTypes.includes(e.边类型)) return;  // 跳过非层级边
+                    const srcNode = nodeMap[e.源节点ID];
+                    const tgtNode = nodeMap[e.目标节点ID];
+                    if (srcNode && tgtNode) {{
+                        tgtNode.children.push(srcNode);
+                        hasParent.add(e.源节点ID);
+                    }}
+                }});
+
+                const roots = dimNodes.filter(n => !hasParent.has(n.节点ID)).map(n => nodeMap[n.节点ID]);
+
+                const dimNode = {{
+                    name: dim,
+                    节点名称: dim,
+                    isDimension: true,
+                    dimColor: dimColors[dim],
+                    children: roots
+                }};
+
+                dimensionChildren.push(dimNode);
+                totalNodeCount += dimNodes.length;
+            }});
+
+            // 根节点"人设"
+            const rootNode = {{
+                name: "人设",
+                节点名称: "人设",
+                isRoot: true,
+                children: dimensionChildren
+            }};
+
+            // 根据 treeMaxDepth 截断树深度(0=全部)
+            // 深度1=根,深度2=维度,深度3+=子节点
+            if (treeMaxDepth > 0) {{
+                truncateTree(rootNode, 1, treeMaxDepth);
+            }}
+
+            return {{ root: rootNode, nodeCount: totalNodeCount }};
+        }}
+
+        // 递归截断树的深度
+        function truncateTree(node, currentDepth, maxDepth) {{
+            if (currentDepth >= maxDepth) {{
+                node.children = [];
+                return;
+            }}
+            if (node.children) {{
+                node.children.forEach(child => truncateTree(child, currentDepth + 1, maxDepth));
+            }}
+        }}
+
+        // 切换Tab
+        function switchTab(index) {{
+            currentIndex = index;
+
+            // 更新Tab样式
+            document.querySelectorAll(".tab").forEach((tab, i) => {{
+                tab.classList.toggle("active", i === index);
+            }});
+
+            // 更新图谱
+            renderGraph(allGraphData[index]);
+        }}
+
+        // 渲染图谱
+        function renderGraph(data) {{
+            // 清空现有图谱
+            g.selectAll("*").remove();
+            if (simulation) {{
+                simulation.stop();
+            }}
+
+            const container = document.getElementById("graph");
+            const width = container.clientWidth;
+
+            // 布局配置:左侧人设树,右侧三层圆形
+            // 树宽度自适应:占总宽度的35%,最小400,最大550
+            const treeAreaWidth = Math.max(400, Math.min(550, width * 0.35));
+            const circleAreaLeft = treeAreaWidth + 40;  // 圆形区域起始X
+            const circleAreaWidth = width - circleAreaLeft - 50;
+            const circleAreaCenterX = circleAreaLeft + circleAreaWidth / 2;
+
+            // 准备数据
+            const nodes = data.nodes.map(n => ({{
+                ...n,
+                id: n.节点ID,
+                source: n.节点ID.startsWith("帖子_") ? "帖子" : "人设",
+                level: n.节点层级
+            }}));
+
+            const links = data.edges.map(e => ({{
+                ...e,
+                source: e.源节点ID,
+                target: e.目标节点ID,
+                type: e.边类型
+            }}));
+
+            // 分离节点类型
+            const postNodes = nodes.filter(n => n.source === "帖子");
+            const personaNodes = nodes.filter(n => n.source === "人设" && !n.是否扩展);
+            const expandedNodes = nodes.filter(n => n.source === "人设" && n.是否扩展);
+            const matchLinks = links.filter(l => l.type === "匹配");
+
+            // 构建帖子节点到人设节点的映射
+            const postToPersona = {{}};
+            const personaToPost = {{}};
+            matchLinks.forEach(l => {{
+                const sid = typeof l.source === "object" ? l.source.id : l.source;
+                const tid = typeof l.target === "object" ? l.target.id : l.target;
+                if (!postToPersona[sid]) postToPersona[sid] = [];
+                postToPersona[sid].push(tid);
+                if (!personaToPost[tid]) personaToPost[tid] = [];
+                personaToPost[tid].push(sid);
+            }});
+
+            // 找出所有连通分量
+            function findConnectedComponents(nodes, links) {{
+                const nodeIds = new Set(nodes.map(n => n.id));
+                const adj = {{}};
+                nodeIds.forEach(id => adj[id] = []);
+
+                links.forEach(l => {{
+                    const sid = typeof l.source === "object" ? l.source.id : l.source;
+                    const tid = typeof l.target === "object" ? l.target.id : l.target;
+                    if (nodeIds.has(sid) && nodeIds.has(tid)) {{
+                        adj[sid].push(tid);
+                        adj[tid].push(sid);
+                    }}
+                }});
+
+                const visited = new Set();
+                const components = [];
+
+                nodeIds.forEach(startId => {{
+                    if (visited.has(startId)) return;
+
+                    const component = [];
+                    const queue = [startId];
+
+                    while (queue.length > 0) {{
+                        const id = queue.shift();
+                        if (visited.has(id)) continue;
+                        visited.add(id);
+                        component.push(id);
+                        adj[id].forEach(neighbor => {{
+                            if (!visited.has(neighbor)) queue.push(neighbor);
+                        }});
+                    }}
+
+                    components.push(component);
+                }});
+
+                return components;
+            }}
+
+            // 按大小排序连通分量(大的在前)
+            const components = findConnectedComponents(nodes, links)
+                .sort((a, b) => b.length - a.length);
+            console.log(`找到 ${{components.length}} 个连通分量`);
+
+            // 为每个节点分配连通分量ID和分量内的X范围
+            const nodeToComponent = {{}};
+            const componentCenters = {{}};
+            const componentBounds = {{}};
+            const padding = 50;  // 分量之间的间距
+            const totalPadding = padding * (components.length - 1);
+            const availableWidth = width - totalPadding - 100;  // 留边距
+
+            // 根据分量大小分配宽度
+            const totalNodes = nodes.length;
+            let currentX = 50;  // 起始边距
+
+            components.forEach((comp, i) => {{
+                const compWidth = Math.max(150, (comp.length / totalNodes) * availableWidth);
+                const centerX = currentX + compWidth / 2;
+                componentCenters[i] = centerX;
+                componentBounds[i] = {{ start: currentX, end: currentX + compWidth, width: compWidth }};
+                comp.forEach(nodeId => {{
+                    nodeToComponent[nodeId] = i;
+                }});
+                currentX += compWidth + padding;
+            }});
+
+            // 使用重心法(Barycenter)减少边交叉
+            // 迭代优化:交替调整两层节点的顺序
+
+            const nodeTargetX = {{}};
+            const personaXMap = {{}};
+
+            // 对每个连通分量单独处理
+            components.forEach((comp, compIdx) => {{
+                const bounds = componentBounds[compIdx];
+                const compPostNodes = postNodes.filter(n => nodeToComponent[n.id] === compIdx);
+                const compPersonaNodes = personaNodes.filter(n => nodeToComponent[n.id] === compIdx);
+
+                if (compPostNodes.length === 0 || compPersonaNodes.length === 0) {{
+                    // 没有匹配关系的分量,均匀分布
+                    const spacing = bounds.width / (comp.length + 1);
+                    comp.forEach((nodeId, i) => {{
+                        const node = nodes.find(n => n.id === nodeId);
+                        if (node) {{
+                            node.x = bounds.start + spacing * (i + 1);
+                            nodeTargetX[nodeId] = node.x;
+                            if (node.source === "人设") personaXMap[nodeId] = node.x;
+                        }}
+                    }});
+                    return;
+                }}
+
+                // 初始化:给人设节点一个初始顺序
+                let personaOrder = compPersonaNodes.map((n, i) => ({{ node: n, order: i }}));
+
+                // 迭代优化(3轮)
+                for (let iter = 0; iter < 3; iter++) {{
+                    // 1. 根据人设节点位置,计算帖子节点的重心
+                    const postBarycenter = {{}};
+                    compPostNodes.forEach(pn => {{
+                        const matched = postToPersona[pn.id] || [];
+                        if (matched.length > 0) {{
+                            const avgOrder = matched.reduce((sum, pid) => {{
+                                const po = personaOrder.find(p => p.node.id === pid);
+                                return sum + (po ? po.order : 0);
+                            }}, 0) / matched.length;
+                            postBarycenter[pn.id] = avgOrder;
+                        }} else {{
+                            postBarycenter[pn.id] = 0;
+                        }}
+                    }});
+
+                    // 按重心排序帖子节点
+                    const sortedPosts = [...compPostNodes].sort((a, b) =>
+                        postBarycenter[a.id] - postBarycenter[b.id]
+                    );
+
+                    // 2. 根据帖子节点位置,重新计算人设节点的重心
+                    const personaBarycenter = {{}};
+                    compPersonaNodes.forEach(pn => {{
+                        const matched = personaToPost[pn.id] || [];
+                        if (matched.length > 0) {{
+                            const avgOrder = matched.reduce((sum, pid) => {{
+                                const idx = sortedPosts.findIndex(p => p.id === pid);
+                                return sum + (idx >= 0 ? idx : 0);
+                            }}, 0) / matched.length;
+                            personaBarycenter[pn.id] = avgOrder;
+                        }} else {{
+                            personaBarycenter[pn.id] = personaOrder.find(p => p.node.id === pn.id)?.order || 0;
+                        }}
+                    }});
+
+                    // 更新人设节点顺序
+                    personaOrder = compPersonaNodes
+                        .map(n => ({{ node: n, order: personaBarycenter[n.id] }}))
+                        .sort((a, b) => a.order - b.order)
+                        .map((item, i) => ({{ node: item.node, order: i }}));
+                }}
+
+                // 最终排序
+                const finalPersonaOrder = personaOrder.map(p => p.node);
+                const postBarycenter = {{}};
+                compPostNodes.forEach(pn => {{
+                    const matched = postToPersona[pn.id] || [];
+                    if (matched.length > 0) {{
+                        const avgOrder = matched.reduce((sum, pid) => {{
+                            const idx = finalPersonaOrder.findIndex(n => n.id === pid);
+                            return sum + (idx >= 0 ? idx : 0);
+                        }}, 0) / matched.length;
+                        postBarycenter[pn.id] = avgOrder;
+                    }} else {{
+                        postBarycenter[pn.id] = 0;
+                    }}
+                }});
+                const finalPostOrder = [...compPostNodes].sort((a, b) =>
+                    postBarycenter[a.id] - postBarycenter[b.id]
+                );
+
+                // 设置位置
+                const personaSpacing = bounds.width / (finalPersonaOrder.length + 1);
+                finalPersonaOrder.forEach((n, i) => {{
+                    n.x = bounds.start + personaSpacing * (i + 1);
+                    nodeTargetX[n.id] = n.x;
+                    personaXMap[n.id] = n.x;
+                }});
+
+                const postSpacing = bounds.width / (finalPostOrder.length + 1);
+                finalPostOrder.forEach((n, i) => {{
+                    // 帖子节点用重心位置(匹配人设的平均X)
+                    const matched = postToPersona[n.id] || [];
+                    if (matched.length > 0) {{
+                        const avgX = matched.reduce((sum, pid) => sum + (personaXMap[pid] || bounds.start + bounds.width/2), 0) / matched.length;
+                        n.x = avgX;
+                    }} else {{
+                        n.x = bounds.start + postSpacing * (i + 1);
+                    }}
+                    nodeTargetX[n.id] = n.x;
+                }});
+            }});
+
+            // 节点颜色
+            const levelColors = {{
+                "灵感点": "#f39c12",
+                "目的点": "#3498db",
+                "关键点": "#9b59b6"
+            }};
+
+            // 获取节点的层级编号(三层结构)
+            function getNodeLayer(d) {{
+                if (d.source === "帖子") {{
+                    return d.节点类型 === "点" ? 0 : 1;  // 点=0, 标签=1
+                }}
+                return 2;  // 人设(标签+分类)都在层2
+            }}
+
+            // 统计每层节点数量(三层结构)
+            const layerCounts = {{0: 0, 1: 0, 2: 0}};
+            nodes.forEach(n => {{
+                const layer = getNodeLayer(n);
+                layerCounts[layer]++;
+            }});
+            console.log("每层节点数:", layerCounts);
+
+            // 根据节点数量计算每层圆的半径
+            const minRadius = 80;
+            const maxRadius = Math.min(circleAreaWidth / 2 - 30, 350);
+            const nodeSpacing = 70;
+
+            function calcRadius(nodeCount) {{
+                if (nodeCount <= 1) return minRadius;
+                const circumference = nodeCount * nodeSpacing;
+                const r = circumference / (2 * Math.PI);
+                return Math.max(minRadius, Math.min(maxRadius, r));
+            }}
+
+            const layerRadius = {{
+                0: calcRadius(layerCounts[0]),
+                1: calcRadius(layerCounts[1]),
+                2: calcRadius(layerCounts[2]),
+                3: 250  // 关系图层默认半径(更大,容纳更多节点)
+            }};
+            console.log("每层半径:", layerRadius);
+
+            // 计算四层的高度和圆心Y坐标
+            const layerPadding = 50;
+            const layerHeights = {{
+                0: layerRadius[0] * 2 + layerPadding,
+                1: layerRadius[1] * 2 + layerPadding,
+                2: layerRadius[2] * 2 + layerPadding,
+                3: layerRadius[3] * 2 + layerPadding
+            }};
+
+            // 获取人设树数据(单棵树)
+            const personaTree = buildPersonaTreeData();
+            // 树高度自适应:根据节点数量,每个节点约12px,最小800
+            const treeHeight = Math.max(800, personaTree.nodeCount * 12 + 150);
+
+            // 计算总高度(取圆形和树的最大值)
+            const circleHeight = layerHeights[0] + layerHeights[1] + layerHeights[2] + layerHeights[3];
+            const height = Math.max(circleHeight + 100, treeHeight + 80, container.clientHeight);
+
+            // 计算每层圆心坐标(在右侧区域)
+            const layerCenterX = {{}};
+            const layerCenterY = {{}};
+
+            let currentY = layerRadius[0] + layerPadding / 2 + 30;
+            layerCenterY[0] = currentY;
+            layerCenterX[0] = circleAreaCenterX;
+            currentY += layerRadius[0] + layerPadding + layerRadius[1];
+            layerCenterY[1] = currentY;
+            layerCenterX[1] = circleAreaCenterX;
+            currentY += layerRadius[1] + layerPadding + layerRadius[2];
+            layerCenterY[2] = currentY;
+            layerCenterX[2] = circleAreaCenterX;
+            currentY += layerRadius[2] + layerPadding + layerRadius[3];
+            layerCenterY[3] = currentY;
+            layerCenterX[3] = circleAreaCenterX;
+
+            console.log("每层圆心X:", layerCenterX);
+            console.log("每层圆心Y:", layerCenterY);
+
+            // 更新SVG高度
+            svg.attr("height", height);
+
+            // 获取节点的基准Y(层圆心)
+            function getNodeBaseY(d) {{
+                const layer = getNodeLayer(d);
+                return layerCenterY[layer];
+            }}
+
+            // 获取节点的基准X(层圆心)
+            function getNodeBaseX(d) {{
+                const layer = getNodeLayer(d);
+                return layerCenterX[layer];
+            }}
+
+            // 层边界配置(不再使用)
+            const layerBounds = {{}};
+
+            // 自定义层内排斥力(只对同层节点生效,尽量分散)
+            function forceIntraLayerRepulsion(strength) {{
+                let nodes;
+
+                function force(alpha) {{
+                    for (let i = 0; i < nodes.length; i++) {{
+                        const nodeA = nodes[i];
+                        const layerA = getNodeLayer(nodeA);
+
+                        for (let j = i + 1; j < nodes.length; j++) {{
+                            const nodeB = nodes[j];
+                            const layerB = getNodeLayer(nodeB);
+
+                            // 只对同层节点施加排斥力
+                            if (layerA !== layerB) continue;
+
+                            const dx = nodeB.x - nodeA.x;
+                            const dy = nodeB.y - nodeA.y;
+                            const dist = Math.sqrt(dx * dx + dy * dy) || 1;
+                            const minDist = 150;  // 增大最小距离,让节点更分散
+
+                            if (dist < minDist) {{
+                                // 排斥力随距离衰减,但基础强度更大
+                                const pushStrength = strength * alpha * (minDist - dist) / dist * 2;
+                                const px = dx * pushStrength;
+                                const py = dy * pushStrength;
+
+                                nodeA.vx -= px;
+                                nodeA.vy -= py;
+                                nodeB.vx += px;
+                                nodeB.vy += py;
+                            }}
+                        }}
+                    }}
+                }}
+
+                force.initialize = function(_) {{
+                    nodes = _;
+                }};
+
+                return force;
+            }}
+
+            // 层圆的半径(使用动态计算的 layerRadius)
+            // layerRadius 和 layerCenterY 已在前面定义
+
+            // 层边界约束力(把节点限制在层内)
+            function forceLayerBoundary() {{
+                let nodes;
+
+                function force(alpha) {{
+                    nodes.forEach(node => {{
+                        const layer = getNodeLayer(node);
+                        const centerX = layerCenterX[layer];
+                        const centerY = layerCenterY[layer];
+
+                        // 使用圆形边界
+                        const maxRadius = layerRadius[layer] - 15;
+
+                        // 计算到圆心的距离
+                        const dx = node.x - centerX;
+                        const dy = node.y - centerY;
+                        const dist = Math.sqrt(dx * dx + dy * dy);
+
+                        // 如果超出圆形边界,拉回来
+                        if (dist > maxRadius) {{
+                            const scale = maxRadius / dist;
+                            node.x = centerX + dx * scale;
+                            node.y = centerY + dy * scale;
+
+                            // 阻止继续向外运动
+                            const angle = Math.atan2(dy, dx);
+                            const vOutward = node.vx * Math.cos(angle) + node.vy * Math.sin(angle);
+                            if (vOutward > 0) {{
+                                node.vx -= vOutward * Math.cos(angle);
+                                node.vy -= vOutward * Math.sin(angle);
+                            }}
+                        }}
+                    }});
+                }}
+
+                force.initialize = function(_) {{
+                    nodes = _;
+                }};
+
+                return force;
+            }}
+
+            // 层内圆形布局力(让同层节点围绕圆心分布)
+            function forceCircularLayout(strength) {{
+                let nodes;
+
+                function force(alpha) {{
+                    // 按层分组(三层)
+                    const layerNodes = {{0: [], 1: [], 2: []}};
+                    nodes.forEach(node => {{
+                        const layer = getNodeLayer(node);
+                        layerNodes[layer].push(node);
+                    }});
+
+                    // 对每层计算布局
+                    Object.entries(layerNodes).forEach(([layerIdx, layerGroup]) => {{
+                        if (layerGroup.length === 0) return;
+
+                        const layer = parseInt(layerIdx);
+                        const centerX = layerCenterX[layer];
+                        const centerY = layerCenterY[layer];
+
+                        // 使用圆形布局
+                        const nodeRadius = layerRadius[layer] - 20;
+
+                        if (layerGroup.length === 1) {{
+                            // 单个节点放在圆心
+                            const node = layerGroup[0];
+                            node.vx += (centerX - node.x) * strength * alpha;
+                            node.vy += (centerY - node.y) * strength * alpha;
+                            return;
+                        }}
+
+                        // 按角度排序节点(保持相对位置稳定)
+                        layerGroup.forEach(node => {{
+                            node._angle = Math.atan2(node.y - centerY, node.x - centerX);
+                        }});
+                        layerGroup.sort((a, b) => a._angle - b._angle);
+
+                        // 均匀分布在圆周上
+                        const angleStep = (2 * Math.PI) / layerGroup.length;
+                        layerGroup.forEach((node, i) => {{
+                            const angle = -Math.PI / 2 + i * angleStep;  // 从顶部开始
+                            const idealX = centerX + nodeRadius * Math.cos(angle);
+                            const idealY = centerY + nodeRadius * Math.sin(angle);
+
+                            node.vx += (idealX - node.x) * strength * alpha;
+                            node.vy += (idealY - node.y) * strength * alpha;
+                        }});
+                    }});
+                }}
+
+                force.initialize = function(_) {{
+                    nodes = _;
+                }};
+
+                return force;
+            }}
+
+            // 力导向模拟
+            simulation = d3.forceSimulation(nodes)
+                // 连线力(跨层连接)
+                .force("link", d3.forceLink(links).id(d => d.id)
+                    .distance(d => {{
+                        // 跨层连线距离
+                        if (d.type === "匹配" || d.type === "属于") {{
+                            return 150;  // 跨层边
+                        }}
+                        return 60;  // 同层边
+                    }})
+                    .strength(d => {{
+                        // 跨层边力度弱一些,不要拉扯节点出层
+                        if (d.type === "匹配" || d.type === "属于") {{
+                            return 0.03;
+                        }}
+                        return 0.1;
+                    }}))
+                // X方向:轻微拉向各层中心
+                .force("x", d3.forceX(d => getNodeBaseX(d)).strength(0.02))
+                // Y方向:轻微拉向层中心
+                .force("y", d3.forceY(d => getNodeBaseY(d)).strength(0.02))
+                // 层内碰撞检测
+                .force("collision", d3.forceCollide().radius(30))
+                // 层内排斥力(同层节点相互排斥)
+                .force("intraLayer", forceIntraLayerRepulsion(0.8))
+                // 层内布局(圆形或树状)
+                .force("circularLayout", forceCircularLayout(0.15))
+                // 层边界约束(节点不能跑出层,硬约束)
+                .force("boundary", forceLayerBoundary());
+
+            // 边类型到CSS类的映射
+            const edgeTypeClass = {{
+                "匹配": "match",
+                "分类共现(跨点)": "category-cross",
+                "分类共现(点内)": "category-intra",
+                "标签共现": "tag-cooccur",
+                "属于": "belong",
+                "包含": "contain",
+                // 镜像边(帖子节点之间,虚线)
+                "镜像_分类共现(跨点)": "mirror-category-cross",
+                "镜像_分类共现(点内)": "mirror-category-intra",
+                "镜像_标签共现": "mirror-tag-cooccur",
+                "镜像_属于": "mirror-belong",
+                "镜像_包含": "mirror-contain"
+            }};
+
+            // 获取边的CSS类(处理二阶边,映射到对应的镜像边样式)
+            function getEdgeClass(edgeType) {{
+                if (edgeTypeClass[edgeType]) return edgeTypeClass[edgeType];
+                // 二阶边映射到对应的镜像边样式(颜色相同,都用虚线)
+                if (edgeType.startsWith("二阶_")) {{
+                    const originalType = edgeType.replace("二阶_", "");
+                    const mirrorType = "镜像_" + originalType;
+                    if (edgeTypeClass[mirrorType]) return edgeTypeClass[mirrorType];
+                }}
+                return "match";
+            }}
+
+            // 绘制层背景(圆形,使用动态大小)
+            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: "人设", layer: 2, color: "rgba(155, 89, 182, 0.08)", stroke: "rgba(155, 89, 182, 0.3)" }},
+                {{ name: "关系图", layer: 3, color: "rgba(233, 69, 96, 0.08)", stroke: "rgba(233, 69, 96, 0.3)" }}
+            ];
+
+            // 绘制四层圆形背景
+            layerConfig.forEach(cfg => {{
+                const cx = layerCenterX[cfg.layer];
+                const cy = layerCenterY[cfg.layer];
+                const r = layerRadius[cfg.layer];
+
+                layerBg.append("circle")
+                    .attr("cx", cx)
+                    .attr("cy", cy)
+                    .attr("r", r)
+                    .attr("fill", cfg.color)
+                    .attr("stroke", cfg.stroke)
+                    .attr("stroke-width", 2);
+
+                // 层标签(圆的左侧)
+                layerBg.append("text")
+                    .attr("x", cx - r - 15)
+                    .attr("y", cy)
+                    .attr("dy", "0.35em")
+                    .attr("fill", "rgba(255,255,255,0.5)")
+                    .attr("font-size", "13px")
+                    .attr("font-weight", "bold")
+                    .attr("text-anchor", "end")
+                    .text(cfg.name);
+
+                // 关系图层添加占位提示
+                if (cfg.layer === 3) {{
+                    layerBg.append("text")
+                        .attr("class", "ego-placeholder")
+                        .attr("x", cx)
+                        .attr("y", cy)
+                        .attr("dy", "0.35em")
+                        .attr("fill", "rgba(255,255,255,0.3)")
+                        .attr("font-size", "12px")
+                        .attr("text-anchor", "middle")
+                        .text("点击左侧人设树节点查看关系");
+                }}
+            }});
+
+            // 创建关系图层内容组(动态填充)
+            const egoGraphGroup = g.append("g")
+                .attr("class", "ego-graph-content")
+                .attr("transform", `translate(${{layerCenterX[3]}}, ${{layerCenterY[3]}})`);
+
+            // 绘制左侧人设树(单棵树,根节点为"人设")
+            const dimColors = {{ "灵感点": "#f39c12", "目的点": "#3498db", "关键点": "#9b59b6" }};
+
+            const treeGroup = g.append("g")
+                .attr("class", "persona-tree")
+                .attr("transform", `translate(15, 25)`);
+
+            // 绘制背景矩形
+            treeGroup.append("rect")
+                .attr("x", -5)
+                .attr("y", -10)
+                .attr("width", treeAreaWidth - 20)
+                .attr("height", treeHeight - 20)
+                .attr("rx", 8)
+                .attr("fill", "rgba(100, 100, 100, 0.08)")
+                .attr("stroke", "rgba(150, 150, 150, 0.2)")
+                .attr("stroke-width", 1);
+
+            // D3树布局 - 宽度留出边距给标签
+            const treeLayout = d3.tree()
+                .size([treeHeight - 50, treeAreaWidth - 70]);
+
+            const root = d3.hierarchy(personaTree.root);
+            treeLayout(root);
+
+            // 获取节点颜色(根据维度)
+            function getNodeColor(d) {{
+                if (d.data.isRoot) return "#e94560";  // 根节点红色
+                if (d.data.isDimension) return d.data.dimColor;  // 维度节点用维度颜色
+                return d.data.dimColor || "#888";  // 分类节点继承维度颜色
+            }}
+
+            // 构建节点ID到D3节点的映射(用于绘制原始边和高亮)
+            const treeNodeById = {{}};
+            root.descendants().forEach(d => {{
+                if (d.data.节点ID) {{
+                    treeNodeById[d.data.节点ID] = d;
+                }}
+            }});
+
+            // 边类型颜色和样式
+            const treeEdgeColors = {{
+                "分类层级": "#2ecc71",       // 绿色 - 分类的层级关系
+                "属于": "#9b59b6",           // 紫色 - 标签属于分类
+                "包含": "#8e44ad",           // 深紫 - 分类包含标签
+                "分类共现(跨点)": "#2ecc71", // 绿色 - 跨帖子分类共现
+                "分类共现(点内)": "#3498db", // 蓝色 - 同帖子分类共现
+                "标签共现": "#f39c12"        // 橙色 - 标签共现
+            }};
+            const treeEdgeDash = {{
+                "分类层级": "none",
+                "属于": "3,2",
+                "包含": "3,2",
+                "分类共现(跨点)": "5,3",
+                "分类共现(点内)": "5,3",
+                "标签共现": "2,2"
+            }};
+
+            // 只保留"属于"边用于树的展示(其他边用专门子图展示)
+            const visibleTreeEdges = personaTreeData.edges.filter(e => {{
+                return e.边类型 === "属于" && treeNodeById[e.源节点ID] && treeNodeById[e.目标节点ID];
+            }});
+
+            // 添加树结构边(根节点->维度,维度->子节点)
+            root.links().forEach(link => {{
+                visibleTreeEdges.push({{
+                    源节点ID: link.source.data.节点ID || link.source.data.name,
+                    目标节点ID: link.target.data.节点ID || link.target.data.name,
+                    边类型: "属于",
+                    isTreeLink: true,
+                    sourceNode: link.source,
+                    targetNode: link.target
+                }});
+            }});
+
+            // 绘制原始边(分类层级、标签属于)
+            const treeEdgeGroup = treeGroup.append("g").attr("class", "tree-edges");
+
+            // 先绘制可见边
+            const treeEdges = treeEdgeGroup.selectAll(".tree-edge")
+                .data(visibleTreeEdges)
+                .join("path")
+                .attr("class", "tree-edge")
+                .attr("fill", "none")
+                .attr("stroke", "#9b59b6")  // 属于边用紫色
+                .attr("stroke-opacity", 0.3)
+                .attr("stroke-width", 1)
+                .attr("d", d => {{
+                    const source = d.isTreeLink ? d.sourceNode : treeNodeById[d.源节点ID];
+                    const target = d.isTreeLink ? d.targetNode : treeNodeById[d.目标节点ID];
+                    if (!source || !target) return "";
+                    const midX = (source.y + target.y) / 2;
+                    return `M${{source.y}},${{source.x}} C${{midX}},${{source.x}} ${{midX}},${{target.x}} ${{target.y}},${{target.x}}`;
+                }});
+
+            // 绘制透明的热区边(便于点击)
+            const treeEdgeHitareas = treeEdgeGroup.selectAll(".tree-edge-hitarea")
+                .data(visibleTreeEdges)
+                .join("path")
+                .attr("class", "tree-edge-hitarea")
+                .attr("fill", "none")
+                .attr("stroke", "transparent")
+                .attr("stroke-width", 12)
+                .style("cursor", "pointer")
+                .attr("d", d => {{
+                    const source = d.isTreeLink ? d.sourceNode : treeNodeById[d.源节点ID];
+                    const target = d.isTreeLink ? d.targetNode : treeNodeById[d.目标节点ID];
+                    if (!source || !target) return "";
+                    const midX = (source.y + target.y) / 2;
+                    return `M${{source.y}},${{source.x}} C${{midX}},${{source.x}} ${{midX}},${{target.x}} ${{target.y}},${{target.x}}`;
+                }})
+                .on("click", (event, d) => {{
+                    event.stopPropagation();
+                    // 调用共享的边点击处理函数
+                    handleEdgeClick(d.源节点ID, d.目标节点ID, d.边类型);
+                }});
+
+            // 绘制节点
+            const treeNodes = treeGroup.selectAll(".tree-node")
+                .data(root.descendants())
+                .join("g")
+                .attr("class", "tree-node")
+                .attr("transform", d => `translate(${{d.y}},${{d.x}})`)
+                .style("cursor", "pointer")
+                .on("click", (event, d) => {{
+                    event.stopPropagation();
+                    // 调用共享的节点点击处理函数
+                    handleNodeClick(d.data.节点ID, d.data.节点名称 || d.data.name);
+                }});
+
+            // 节点形状:根节点/维度=圆形,分类=正方形,标签=圆形
+            // 统一实心填充维度颜色
+            treeNodes.each(function(d) {{
+                const el = d3.select(this);
+                const nodeType = d.data.节点类型;
+                const isRoot = d.data.isRoot;
+                const isDimension = d.data.isDimension;
+                const nodeColor = getNodeColor(d);
+
+                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);
+                }}
+            }});
+
+            // 节点标签
+            treeNodes.append("text")
+                .attr("dy", "0.31em")
+                .attr("x", d => d.children ? -8 : 8)
+                .attr("text-anchor", d => d.children ? "end" : "start")
+                .attr("fill", d => (d.data.isRoot || d.data.isDimension) ? getNodeColor(d) : "#bbb")
+                .attr("font-size", d => d.data.isRoot ? "11px" : (d.data.isDimension ? "10px" : "8px"))
+                .attr("font-weight", d => (d.data.isRoot || d.data.isDimension) ? "bold" : "normal")
+                .text(d => d.data.name);
+
+            // 重置树高亮状态的函数
+            function resetTreeHighlight() {{
+                treeGroup.selectAll(".tree-node")
+                    .classed("tree-dimmed", false)
+                    .classed("tree-highlighted", false);
+
+                // 重置所有形状(圆形和方形)- 统一实心填充
+                treeGroup.selectAll(".tree-node .tree-shape")
+                    .attr("fill", d => getNodeColor(d))
+                    .attr("stroke", "rgba(255,255,255,0.5)")
+                    .attr("stroke-opacity", 1)
+                    .attr("opacity", 1);
+
+                treeGroup.selectAll(".tree-node text")
+                    .attr("fill", d => (d.data.isRoot || d.data.isDimension) ? getNodeColor(d) : "#bbb")
+                    .attr("opacity", 1);
+
+                // 重置边样式
+                treeGroup.selectAll(".tree-edge")
+                    .attr("stroke-opacity", 0.3)
+                    .attr("stroke-width", 1);
+            }}
+
+            // 重置右侧图高亮状态的函数
+            function resetGraphHighlight() {{
+                g.selectAll(".node").classed("dimmed", false).classed("highlighted", false);
+                g.selectAll(".link-group").classed("dimmed", false).classed("highlighted", false);
+            }}
+
+            // 点击树背景也取消高亮
+            treeGroup.select("rect").on("click", function(event) {{
+                event.stopPropagation();
+                resetTreeHighlight();
+                resetGraphHighlight();
+                clearEgoGraph();
+                closeDetailPanel();
+            }});
+
+            // 创建边的容器
+            const linkGroup = g.append("g").attr("class", "links");
+
+            // 为每条边创建组
+            const linkG = linkGroup.selectAll("g")
+                .data(links)
+                .join("g")
+                .attr("class", "link-group");
+
+            // 绘制点击热区(透明宽线)
+            const linkHitarea = linkG.append("line")
+                .attr("class", "link-hitarea");
+
+            // 绘制可见的边
+            const link = linkG.append("line")
+                .attr("class", d => "link " + getEdgeClass(d.type))
+                .attr("stroke-width", d => d.type === "匹配" ? 2.5 : 1.5);
+
+            // 判断是否为跨层边(根据源和目标节点的层级)- 赋值给全局变量
+            isCrossLayerEdge = function(d) {{
+                const sourceNode = typeof d.source === "object" ? d.source : nodes.find(n => n.id === d.source);
+                const targetNode = typeof d.target === "object" ? d.target : nodes.find(n => n.id === d.target);
+                if (!sourceNode || !targetNode) return false;
+                return getNodeLayer(sourceNode) !== getNodeLayer(targetNode);
+            }};
+
+            // 设置跨层边的初始可见性(默认隐藏)
+            linkG.each(function(d) {{
+                if (isCrossLayerEdge(d) && !showCrossLayerEdges) {{
+                    d3.select(this).style("display", "none");
+                }}
+            }});
+
+            // 为匹配边添加分数标签
+            const edgeLabels = linkG.filter(d => d.type === "匹配" && d.边详情 && d.边详情.相似度)
+                .append("g")
+                .attr("class", "edge-label-group");
+
+            edgeLabels.append("rect")
+                .attr("class", "edge-label-bg")
+                .attr("rx", 3)
+                .attr("ry", 3);
+
+            edgeLabels.append("text")
+                .attr("class", "edge-label")
+                .text(d => {{
+                    const score = d.边详情.相似度;
+                    return typeof score === "number" ? score.toFixed(2) : score;
+                }});
+
+            // 边的点击事件
+            linkHitarea.on("click", (event, d, i) => {{
+                event.stopPropagation();
+                const linkIndex = links.indexOf(d);
+                highlightEdge(d, linkIndex);
+                showEdgeInfo(d);
+            }})
+            .on("mouseover", function(event, d) {{
+                d3.select(this.parentNode).select(".link")
+                    .attr("stroke-opacity", 1)
+                    .attr("stroke-width", 4);
+            }})
+            .on("mouseout", function(event, d) {{
+                d3.select(this.parentNode).select(".link")
+                    .attr("stroke-opacity", 0.7)
+                    .attr("stroke-width", d.type === "匹配" ? 2.5 : 1.5);
+            }});
+
+            // 绘制节点
+            const node = g.append("g")
+                .selectAll("g")
+                .data(nodes)
+                .join("g")
+                .attr("class", "node")
+                .call(d3.drag()
+                    .on("start", dragstarted)
+                    .on("drag", dragged)
+                    .on("end", dragended));
+
+            // 根据节点类型绘制不同形状
+            // - 点节点(帖子点):六边形
+            // - 标签节点:圆形
+            // - 分类节点:方形
+            // 帖子标签用虚线,人设标签用实线
+            node.each(function(d) {{
+                const el = d3.select(this);
+                const isPointNode = d.节点类型 === "点";
+                const isPostNode = d.source === "帖子";
+
+                // 节点大小
+                let size;
+                if (isPointNode) {{
+                    size = 16;  // 点节点最大
+                }} else if (isPostNode) {{
+                    size = 12;  // 帖子标签
+                }} else {{
+                    size = 10;  // 人设标签
+                }}
+
+                const fill = levelColors[d.level] || "#666";
+                const nodeClass = isPostNode
+                    ? (isPointNode ? "post-point-node" : "post-node")
+                    : "persona-node";
+
+                if (isPointNode) {{
+                    // 六边形(点节点)
+                    const hexPoints = [];
+                    for (let i = 0; i < 6; i++) {{
+                        const angle = (i * 60 - 30) * Math.PI / 180;
+                        hexPoints.push([size * Math.cos(angle), size * Math.sin(angle)]);
+                    }}
+                    el.append("polygon")
+                        .attr("points", hexPoints.map(p => p.join(",")).join(" "))
+                        .attr("fill", fill)
+                        .attr("class", nodeClass);
+                }} else if (d.节点类型 === "分类") {{
+                    // 方形(分类节点)
+                    el.append("rect")
+                        .attr("width", size * 2)
+                        .attr("height", size * 2)
+                        .attr("x", -size)
+                        .attr("y", -size)
+                        .attr("fill", fill)
+                        .attr("class", nodeClass)
+                        .attr("rx", 3);
+                }} else {{
+                    // 圆形(标签节点)
+                    el.append("circle")
+                        .attr("r", size)
+                        .attr("fill", fill)
+                        .attr("class", nodeClass);
+                }}
+            }});
+
+            const labels = node.append("text")
+                .attr("dx", 15)
+                .attr("dy", 4)
+                .text(d => d.节点名称)
+                .style("display", showLabels ? "block" : "none");
+
+            // 工具提示
+            const tooltip = d3.select("#tooltip");
+
+            node.on("mouseover", (event, d) => {{
+                let html = `<strong>${{d.节点名称}}</strong><br/>类型: ${{d.节点类型}}<br/>层级: ${{d.节点层级}}`;
+                // 点节点显示描述
+                if (d.描述) {{
+                    html += `<br/><br/><em style="font-size:10px;color:#aaa">${{d.描述.slice(0, 100)}}${{d.描述.length > 100 ? '...' : ''}}</em>`;
+                }}
+                tooltip.style("display", "block").html(html);
+            }})
+            .on("mousemove", (event) => {{
+                tooltip.style("left", (event.pageX + 15) + "px")
+                    .style("top", (event.pageY - 10) + "px");
+            }})
+            .on("mouseout", () => {{
+                tooltip.style("display", "none");
+            }})
+            .on("click", (event, d) => {{
+                event.stopPropagation();
+                highlightNode(d);
+                showNodeInfo(d);
+            }});
+
+            // 更新位置
+            simulation.on("tick", () => {{
+                // 更新热区线
+                linkHitarea
+                    .attr("x1", d => d.source.x)
+                    .attr("y1", d => d.source.y)
+                    .attr("x2", d => d.target.x)
+                    .attr("y2", d => d.target.y);
+
+                // 更新可见边
+                link
+                    .attr("x1", d => d.source.x)
+                    .attr("y1", d => d.source.y)
+                    .attr("x2", d => d.target.x)
+                    .attr("y2", d => d.target.y);
+
+                // 更新边标签位置(放在边的中点)
+                edgeLabels.attr("transform", d => {{
+                    const midX = (d.source.x + d.target.x) / 2;
+                    const midY = (d.source.y + d.target.y) / 2;
+                    return `translate(${{midX}},${{midY}})`;
+                }});
+
+                // 更新标签背景大小
+                edgeLabels.each(function(d) {{
+                    const textEl = d3.select(this).select("text").node();
+                    if (textEl) {{
+                        const bbox = textEl.getBBox();
+                        d3.select(this).select("rect")
+                            .attr("x", bbox.x - 3)
+                            .attr("y", bbox.y - 1)
+                            .attr("width", bbox.width + 6)
+                            .attr("height", bbox.height + 2);
+                    }}
+                }});
+
+                node.attr("transform", d => `translate(${{d.x}},${{d.y}})`);
+            }});
+
+            // 拖拽函数
+            function dragstarted(event, d) {{
+                if (!event.active) simulation.alphaTarget(0.3).restart();
+                d.fx = d.x;
+                d.fy = d.y;
+            }}
+
+            function dragged(event, d) {{
+                d.fx = event.x;
+                d.fy = event.y;
+            }}
+
+            function dragended(event, d) {{
+                if (!event.active) simulation.alphaTarget(0);
+                d.fx = null;
+                d.fy = null;
+            }}
+
+            // 清除所有高亮
+            function clearHighlight() {{
+                node.classed("dimmed", false).classed("highlighted", false);
+                linkG.classed("dimmed", false).classed("highlighted", false);
+
+                // 恢复跨层边的可见性状态(根据全局开关)
+                linkG.each(function(d) {{
+                    if (isCrossLayerEdge(d)) {{
+                        d3.select(this).style("display", showCrossLayerEdges ? "block" : "none");
+                    }}
+                }});
+            }}
+
+            // 高亮指定的节点和边
+            function highlightElements(highlightNodeIds, highlightLinkIndices) {{
+                // 先灰化所有
+                node.classed("dimmed", true).classed("highlighted", false);
+                linkG.classed("dimmed", true).classed("highlighted", false);
+
+                // 高亮指定节点
+                node.filter(d => highlightNodeIds.has(d.id))
+                    .classed("dimmed", false)
+                    .classed("highlighted", true);
+
+                // 高亮指定边
+                linkG.filter((d, i) => highlightLinkIndices.has(i))
+                    .classed("dimmed", false)
+                    .classed("highlighted", true);
+            }}
+
+            // 点击节点时的高亮逻辑
+            function highlightNode(clickedNode) {{
+                const highlightNodeIds = new Set([clickedNode.id]);
+                const highlightLinkIndices = new Set();
+
+                links.forEach((link, i) => {{
+                    const sourceId = typeof link.source === "object" ? link.source.id : link.source;
+                    const targetId = typeof link.target === "object" ? link.target.id : link.target;
+
+                    // 与点击节点直接相连的边
+                    if (sourceId === clickedNode.id || targetId === clickedNode.id) {{
+                        highlightLinkIndices.add(i);
+                        highlightNodeIds.add(sourceId);
+                        highlightNodeIds.add(targetId);
+
+                        // 如果是帖子节点,还要高亮对应的镜像边
+                        if (clickedNode.source === "帖子") {{
+                            // 找到通过该帖子连接的其他帖子(镜像边)
+                            links.forEach((otherLink, j) => {{
+                                const otherType = otherLink.type;
+                                if (otherType.startsWith("镜像_") || otherType.startsWith("二阶_")) {{
+                                    const oSrc = typeof otherLink.source === "object" ? otherLink.source.id : otherLink.source;
+                                    const oTgt = typeof otherLink.target === "object" ? otherLink.target.id : otherLink.target;
+                                    if (oSrc === clickedNode.id || oTgt === clickedNode.id) {{
+                                        highlightLinkIndices.add(j);
+                                        highlightNodeIds.add(oSrc);
+                                        highlightNodeIds.add(oTgt);
+                                    }}
+                                }}
+                            }});
+                        }}
+                    }}
+                }});
+
+                // 高亮相关的节点和边
+                highlightElements(highlightNodeIds, highlightLinkIndices);
+
+                // 临时显示关联的跨层边(即使全局开关是关闭的)
+                linkG.each(function(d, i) {{
+                    if (highlightLinkIndices.has(i)) {{
+                        d3.select(this).style("display", "block");
+                    }}
+                }});
+            }}
+
+            // 点击边时的高亮逻辑
+            function highlightEdge(clickedLink, clickedIndex) {{
+                const highlightNodeIds = new Set();
+                const highlightLinkIndices = new Set([clickedIndex]);
+
+                const sourceId = typeof clickedLink.source === "object" ? clickedLink.source.id : clickedLink.source;
+                const targetId = typeof clickedLink.target === "object" ? clickedLink.target.id : clickedLink.target;
+
+                highlightNodeIds.add(sourceId);
+                highlightNodeIds.add(targetId);
+
+                // 如果是二阶边,显示完整路径
+                if (clickedLink.type.startsWith("二阶_") && clickedLink.边详情) {{
+                    const detail = clickedLink.边详情;
+                    // 分类节点
+                    if (detail.分类节点1) highlightNodeIds.add(detail.分类节点1);
+                    if (detail.分类节点2) highlightNodeIds.add(detail.分类节点2);
+                    // 标签节点
+                    if (detail.标签节点1) highlightNodeIds.add(detail.标签节点1);
+                    if (detail.标签节点2) highlightNodeIds.add(detail.标签节点2);
+
+                    // 找出路径上的边
+                    links.forEach((link, i) => {{
+                        const lSrc = typeof link.source === "object" ? link.source.id : link.source;
+                        const lTgt = typeof link.target === "object" ? link.target.id : link.target;
+
+                        // 帖子->标签 的匹配边
+                        if (link.type === "匹配") {{
+                            if ((lSrc === sourceId && lTgt === detail.标签节点1) ||
+                                (lSrc === targetId && lTgt === detail.标签节点2)) {{
+                                highlightLinkIndices.add(i);
+                            }}
+                        }}
+                        // 标签->分类 的属于边
+                        if (link.type === "属于") {{
+                            if ((lSrc === detail.标签节点1 && lTgt === detail.分类节点1) ||
+                                (lSrc === detail.标签节点2 && lTgt === detail.分类节点2)) {{
+                                highlightLinkIndices.add(i);
+                            }}
+                        }}
+                        // 分类之间的边
+                        if ((lSrc === detail.分类节点1 && lTgt === detail.分类节点2) ||
+                            (lSrc === detail.分类节点2 && lTgt === detail.分类节点1)) {{
+                            highlightLinkIndices.add(i);
+                        }}
+                    }});
+                }}
+                // 如果是镜像边,显示对应的人设边
+                else if (clickedLink.type.startsWith("镜像_") && clickedLink.边详情) {{
+                    const detail = clickedLink.边详情;
+                    if (detail.源人设节点) highlightNodeIds.add(detail.源人设节点);
+                    if (detail.目标人设节点) highlightNodeIds.add(detail.目标人设节点);
+
+                    // 找出对应的人设边和匹配边
+                    links.forEach((link, i) => {{
+                        const lSrc = typeof link.source === "object" ? link.source.id : link.source;
+                        const lTgt = typeof link.target === "object" ? link.target.id : link.target;
+
+                        // 匹配边
+                        if (link.type === "匹配") {{
+                            if ((lSrc === sourceId && lTgt === detail.源人设节点) ||
+                                (lSrc === targetId && lTgt === detail.目标人设节点)) {{
+                                highlightLinkIndices.add(i);
+                            }}
+                        }}
+                        // 人设边
+                        if ((lSrc === detail.源人设节点 && lTgt === detail.目标人设节点) ||
+                            (lSrc === detail.目标人设节点 && lTgt === detail.源人设节点)) {{
+                            highlightLinkIndices.add(i);
+                        }}
+                    }});
+                }}
+
+                // 高亮相关的节点和边
+                highlightElements(highlightNodeIds, highlightLinkIndices);
+
+                // 临时显示关联的跨层边(即使全局开关是关闭的)
+                linkG.each(function(d, i) {{
+                    if (highlightLinkIndices.has(i)) {{
+                        d3.select(this).style("display", "block");
+                    }}
+                }});
+            }}
+
+            // 点击空白处清除高亮(合并所有空白点击逻辑)
+            svg.on("click", (event) => {{
+                // 检查是否点击的是空白区域(svg本身或layer-backgrounds)
+                const isBlank = event.target === svg.node() ||
+                                event.target.tagName === "svg" ||
+                                event.target.classList.contains("layer-backgrounds");
+                if (isBlank) {{
+                    // 清除右侧图高亮
+                    clearHighlight();
+                    // 清除人设树高亮
+                    resetTreeHighlight();
+                    resetGraphHighlight();
+                    // 关闭详情面板
+                    closeDetailPanel();
+                    // 清除关系图
+                    clearEgoGraph();
+                }}
+            }});
+        }}
+
+        // 控制函数
+        function resetZoom() {{
+            const container = document.getElementById("svg-container");
+            const width = container.clientWidth;
+            const height = container.clientHeight;
+            svg.transition().duration(750).call(
+                zoom.transform,
+                d3.zoomIdentity.translate(width/2, height/2).scale(1).translate(-width/2, -height/2)
+            );
+        }}
+
+        function toggleLabels() {{
+            showLabels = !showLabels;
+            g.selectAll(".node text").style("display", showLabels ? "block" : "none");
+        }}
+
+        // 切换跨层边显示
+        function toggleCrossLayerEdges() {{
+            showCrossLayerEdges = !showCrossLayerEdges;
+            const btn = document.getElementById("crossLayerBtn");
+            btn.textContent = showCrossLayerEdges ? "隐藏跨层边" : "显示跨层边";
+            btn.style.background = showCrossLayerEdges ? "#e94560" : "";
+
+            // 更新边的可见性(使用 isCrossLayerEdge 函数判断)
+            g.selectAll(".link-group").each(function(d) {{
+                if (isCrossLayerEdge(d)) {{
+                    d3.select(this).style("display", showCrossLayerEdges ? "block" : "none");
+                }}
+            }});
+        }}
+
+        function showNodeInfo(d) {{
+            const panel = document.getElementById("detailPanel");
+            panel.classList.add("active");
+            document.getElementById("detailTitle").textContent = d.source === "帖子" ? "📌 帖子节点" : "👤 人设节点";
+
+            let html = `
+                <p><span class="label">节点ID:</span> ${{d.节点ID}}</p>
+                <p><span class="label">名称:</span> <strong>${{d.节点名称}}</strong></p>
+                <p><span class="label">类型:</span> ${{d.节点类型}}</p>
+                <p><span class="label">层级:</span> ${{d.节点层级}}</p>
+            `;
+
+            if (d.权重) {{
+                html += `<p><span class="label">权重:</span> ${{d.权重}}</p>`;
+            }}
+            if (d.所属分类 && d.所属分类.length > 0) {{
+                html += `<p><span class="label">所属分类:</span> ${{d.所属分类.join(" > ")}}</p>`;
+            }}
+            if (d.帖子数) {{
+                html += `<p><span class="label">帖子数:</span> ${{d.帖子数}}</p>`;
+            }}
+            document.getElementById("detailContent").innerHTML = html;
+        }}
+
+        // 显示人设树节点详情(包含入边、出边、相关节点)
+        function showTreeNodeDetail(nodeData, inEdges, outEdges) {{
+            const panel = document.getElementById("detailPanel");
+            panel.classList.add("active");
+            document.getElementById("detailTitle").textContent = "🌳 人设树节点";
+
+            // 节点基本信息
+            let html = `
+                <p><span class="label">名称:</span> <strong>${{nodeData.节点名称 || nodeData.name}}</strong></p>
+                <p><span class="label">类型:</span> ${{nodeData.节点类型 || (nodeData.isRoot ? "根节点" : "维度")}}</p>
+                <p><span class="label">层级:</span> ${{nodeData.节点层级 || "-"}}</p>
+            `;
+
+            if (nodeData.所属分类 && nodeData.所属分类.length > 0) {{
+                html += `<p><span class="label">所属分类:</span> ${{nodeData.所属分类.join(" > ")}}</p>`;
+            }}
+            if (nodeData.帖子数) {{
+                html += `<p><span class="label">帖子数:</span> ${{nodeData.帖子数}}</p>`;
+            }}
+
+            // 统计边类型
+            const inEdgeTypes = {{}};
+            const outEdgeTypes = {{}};
+            inEdges.forEach(e => {{ inEdgeTypes[e.边类型] = (inEdgeTypes[e.边类型] || 0) + 1; }});
+            outEdges.forEach(e => {{ outEdgeTypes[e.边类型] = (outEdgeTypes[e.边类型] || 0) + 1; }});
+
+            // 入边统计
+            html += `<h4 style="margin-top:12px;color:#3498db;font-size:12px;">📥 入边 (${{inEdges.length}})</h4>`;
+            if (inEdges.length > 0) {{
+                html += `<div class="edge-list">`;
+                for (const [type, count] of Object.entries(inEdgeTypes)) {{
+                    html += `<div class="edge-type-item"><span class="edge-type">${{type}}</span><span class="edge-count">${{count}}</span></div>`;
+                }}
+                // 显示前5条入边详情
+                const showInEdges = inEdges.slice(0, 5);
+                showInEdges.forEach(e => {{
+                    const srcName = personaTreeData.nodes.find(n => n.节点ID === e.源节点ID)?.节点名称 || e.源节点ID;
+                    html += `<div class="edge-detail">← ${{srcName}} <span class="edge-type-tag">${{e.边类型}}</span></div>`;
+                }});
+                if (inEdges.length > 5) {{
+                    html += `<div class="edge-more">... 还有 ${{inEdges.length - 5}} 条</div>`;
+                }}
+                html += `</div>`;
+            }} else {{
+                html += `<div class="edge-empty">无</div>`;
+            }}
+
+            // 出边统计
+            html += `<h4 style="margin-top:12px;color:#e74c3c;font-size:12px;">📤 出边 (${{outEdges.length}})</h4>`;
+            if (outEdges.length > 0) {{
+                html += `<div class="edge-list">`;
+                for (const [type, count] of Object.entries(outEdgeTypes)) {{
+                    html += `<div class="edge-type-item"><span class="edge-type">${{type}}</span><span class="edge-count">${{count}}</span></div>`;
+                }}
+                // 显示前5条出边详情
+                const showOutEdges = outEdges.slice(0, 5);
+                showOutEdges.forEach(e => {{
+                    const tgtName = personaTreeData.nodes.find(n => n.节点ID === e.目标节点ID)?.节点名称 || e.目标节点ID;
+                    html += `<div class="edge-detail">→ ${{tgtName}} <span class="edge-type-tag">${{e.边类型}}</span></div>`;
+                }});
+                if (outEdges.length > 5) {{
+                    html += `<div class="edge-more">... 还有 ${{outEdges.length - 5}} 条</div>`;
+                }}
+                html += `</div>`;
+            }} else {{
+                html += `<div class="edge-empty">无</div>`;
+            }}
+
+            document.getElementById("detailContent").innerHTML = html;
+        }}
+
+        function showEdgeInfo(d) {{
+            const panel = document.getElementById("detailPanel");
+            panel.classList.add("active");
+
+            const sourceNode = typeof d.source === "object" ? d.source : {{ id: d.source }};
+            const targetNode = typeof d.target === "object" ? d.target : {{ id: d.target }};
+
+            // 判断是否为镜像边
+            const isMirror = d.type.startsWith("镜像_");
+            document.getElementById("detailTitle").textContent = isMirror ? "🪞 镜像边详情" : "🔗 边详情";
+
+            let html = `
+                <p><span class="label">边类型:</span> <strong>${{d.type}}</strong></p>
+                <p><span class="label">源节点:</span> ${{sourceNode.节点名称 || sourceNode.id}}</p>
+                <p><span class="label">目标节点:</span> ${{targetNode.节点名称 || targetNode.id}}</p>
+            `;
+
+            if (d.边详情) {{
+                if (d.边详情.相似度 !== undefined) {{
+                    const score = typeof d.边详情.相似度 === "number" ? d.边详情.相似度.toFixed(2) : d.边详情.相似度;
+                    html += `<p><span class="label">相似度:</span> <span class="similarity-score">${{score}}</span></p>`;
+                }}
+                if (d.边详情.说明) {{
+                    html += `<p><span class="label">说明:</span></p><div class="edge-description">${{d.边详情.说明}}</div>`;
+                }}
+                if (d.边详情.共现次数 !== undefined) {{
+                    html += `<p><span class="label">共现次数:</span> ${{d.边详情.共现次数}}</p>`;
+                }}
+                // 镜像边特有信息
+                if (d.边详情.原始边类型) {{
+                    html += `<p><span class="label">原始边类型:</span> ${{d.边详情.原始边类型}}</p>`;
+                }}
+                if (d.边详情.源人设节点) {{
+                    html += `<p><span class="label">源人设节点:</span> ${{d.边详情.源人设节点}}</p>`;
+                }}
+                if (d.边详情.目标人设节点) {{
+                    html += `<p><span class="label">目标人设节点:</span> ${{d.边详情.目标人设节点}}</p>`;
+                }}
+            }}
+
+            document.getElementById("detailContent").innerHTML = html;
+        }}
+
+        function closeDetailPanel() {{
+            document.getElementById("detailPanel").classList.remove("active");
+        }}
+
+        // 获取节点颜色(全局版本,根据节点数据判断维度)
+        function getTreeNodeColor(d) {{
+            const dimColors = {{ "灵感点": "#f39c12", "目的点": "#3498db", "关键点": "#9b59b6" }};
+            if (d.data.isRoot) return "#e94560";
+            if (d.data.isDimension) return d.data.dimColor;
+            return d.data.dimColor || "#888";
+        }}
+
+        // 共享的节点点击处理函数(人设树和关系图复用)
+        function handleNodeClick(clickedNodeId, clickedNodeName) {{
+            if (!clickedNodeId) return;
+
+            // 动态获取树相关元素
+            const treeGroup = d3.select(".persona-tree");
+            if (treeGroup.empty()) return;
+
+            const treeEdges = treeGroup.selectAll(".tree-edge");
+
+            // 先重置所有高亮状态
+            treeGroup.selectAll(".tree-node")
+                .classed("tree-dimmed", false)
+                .classed("tree-highlighted", false);
+            treeGroup.selectAll(".tree-node .tree-shape")
+                .attr("fill", d => getTreeNodeColor(d))
+                .attr("stroke", "rgba(255,255,255,0.5)")
+                .attr("stroke-width", 1)
+                .attr("stroke-opacity", 1)
+                .attr("opacity", 1);
+            treeGroup.selectAll(".tree-node text")
+                .attr("fill", d => (d.data.isRoot || d.data.isDimension) ? getTreeNodeColor(d) : "#bbb")
+                .attr("opacity", 1);
+            treeEdges.attr("stroke-opacity", 0.3).attr("stroke-width", 1);
+            if (g) {{
+                g.selectAll(".node").classed("dimmed", false).classed("highlighted", false);
+                g.selectAll(".link-group").classed("dimmed", false).classed("highlighted", false);
+            }}
+
+            // 构建节点ID到D3节点的映射
+            const treeNodeById = {{}};
+            treeGroup.selectAll(".tree-node").each(function(n) {{
+                if (n.data.节点ID) treeNodeById[n.data.节点ID] = n;
+            }});
+
+            // 查找所有与该节点相关的边
+            const allConnectedEdges = personaTreeData.edges.filter(e =>
+                e.源节点ID === clickedNodeId || e.目标节点ID === clickedNodeId
+            );
+
+            // 收集所有相关节点ID
+            const connectedNodeIds = new Set();
+            connectedNodeIds.add(clickedNodeId);
+            allConnectedEdges.forEach(e => {{
+                connectedNodeIds.add(e.源节点ID);
+                connectedNodeIds.add(e.目标节点ID);
+            }});
+
+            // 获取D3树节点
+            const clickedD3Node = treeNodeById[clickedNodeId];
+
+            // 也添加树结构中的父子节点
+            if (clickedD3Node) {{
+                if (clickedD3Node.parent && clickedD3Node.parent.data.节点ID) {{
+                    connectedNodeIds.add(clickedD3Node.parent.data.节点ID);
+                }}
+                if (clickedD3Node.children) {{
+                    clickedD3Node.children.forEach(c => {{
+                        if (c.data.节点ID) connectedNodeIds.add(c.data.节点ID);
+                    }});
+                }}
+            }}
+
+            // 转换为D3节点集合
+            const connectedD3Nodes = new Set();
+            if (clickedD3Node) connectedD3Nodes.add(clickedD3Node);
+            connectedNodeIds.forEach(id => {{
+                if (treeNodeById[id]) connectedD3Nodes.add(treeNodeById[id]);
+            }});
+            if (clickedD3Node) {{
+                if (clickedD3Node.parent) connectedD3Nodes.add(clickedD3Node.parent);
+                if (clickedD3Node.children) clickedD3Node.children.forEach(c => connectedD3Nodes.add(c));
+            }}
+
+            // 高亮树中的节点
+            treeGroup.selectAll(".tree-node")
+                .classed("tree-dimmed", n => !connectedD3Nodes.has(n))
+                .classed("tree-highlighted", n => connectedD3Nodes.has(n));
+
+            // 高亮所有形状(不变粗)
+            treeGroup.selectAll(".tree-node .tree-shape")
+                .attr("fill", function(n) {{
+                    if (!connectedD3Nodes.has(n)) return "#555";
+                    return getTreeNodeColor(n);
+                }})
+                .attr("stroke", function(n) {{
+                    if (n.data.节点ID === clickedNodeId) return "#fff";
+                    return connectedD3Nodes.has(n) ? "rgba(255,255,255,0.8)" : "#333";
+                }})
+                .attr("stroke-opacity", 1);
+
+            treeGroup.selectAll(".tree-node text")
+                .attr("fill", function(n) {{
+                    if (!connectedD3Nodes.has(n)) return "#555";
+                    return (n.data.isRoot || n.data.isDimension) ? getTreeNodeColor(n) : "#bbb";
+                }});
+
+            // 边高亮
+            treeEdges.attr("stroke-opacity", function(e) {{
+                const isConnected = (e.源节点ID === clickedNodeId || e.目标节点ID === clickedNodeId) ||
+                    (e.isTreeLink && (e.源节点ID === clickedNodeName || e.目标节点ID === clickedNodeName));
+                return isConnected ? 0.9 : 0.15;
+            }});
+
+            // 计算入边和出边
+            const inEdges = allConnectedEdges.filter(e => e.目标节点ID === clickedNodeId);
+            const outEdges = allConnectedEdges.filter(e => e.源节点ID === clickedNodeId);
+
+            // 显示详情面板
+            const nodeData = personaTreeData.nodes.find(n => n.节点ID === clickedNodeId);
+            if (nodeData) {{
+                showTreeNodeDetail(nodeData, inEdges, outEdges);
+            }}
+
+            // 显示关系子图
+            renderEgoGraph(clickedNodeId, clickedNodeName);
+
+            // 高亮右侧图中对应节点
+            if (g) {{
+                const graphNode = g.selectAll(".node").filter(n => n.节点ID === clickedNodeId);
+                if (!graphNode.empty()) {{
+                    g.selectAll(".node").classed("dimmed", true).classed("highlighted", false);
+                    g.selectAll(".link-group").classed("dimmed", true).classed("highlighted", false);
+                    graphNode.classed("dimmed", false).classed("highlighted", true);
+                }}
+            }}
+        }}
+
+        // 共享的边点击处理函数(人设树和关系图复用)
+        function handleEdgeClick(srcNodeId, tgtNodeId, edgeType) {{
+            // 动态获取树相关元素
+            const treeGroup = d3.select(".persona-tree");
+            if (treeGroup.empty()) return;
+
+            const treeEdges = treeGroup.selectAll(".tree-edge");
+
+            // 先重置所有高亮状态
+            treeGroup.selectAll(".tree-node")
+                .classed("tree-dimmed", false)
+                .classed("tree-highlighted", false);
+            treeGroup.selectAll(".tree-node .tree-shape")
+                .attr("fill", d => getTreeNodeColor(d))
+                .attr("stroke", "rgba(255,255,255,0.5)")
+                .attr("stroke-width", 1)
+                .attr("stroke-opacity", 1)
+                .attr("opacity", 1);
+            treeGroup.selectAll(".tree-node text")
+                .attr("fill", d => (d.data.isRoot || d.data.isDimension) ? getTreeNodeColor(d) : "#bbb")
+                .attr("opacity", 1);
+            treeEdges.attr("stroke-opacity", 0.3).attr("stroke-width", 1);
+            if (g) {{
+                g.selectAll(".node").classed("dimmed", false).classed("highlighted", false);
+                g.selectAll(".link-group").classed("dimmed", false).classed("highlighted", false);
+            }}
+
+            // 构建节点ID到D3节点的映射
+            const treeNodeById = {{}};
+            treeGroup.selectAll(".tree-node").each(function(n) {{
+                if (n.data.节点ID) treeNodeById[n.data.节点ID] = n;
+            }});
+
+            const sourceTreeNode = treeNodeById[srcNodeId];
+            const targetTreeNode = treeNodeById[tgtNodeId];
+
+            // 高亮树边(不变粗)
+            treeEdges.attr("stroke-opacity", function(e) {{
+                const isThisEdge = (e.源节点ID === srcNodeId && e.目标节点ID === tgtNodeId) ||
+                                   (e.源节点ID === tgtNodeId && e.目标节点ID === srcNodeId);
+                return isThisEdge ? 1 : 0.1;
+            }});
+
+            // 高亮相关节点
+            const connectedNodes = new Set();
+            if (sourceTreeNode) connectedNodes.add(sourceTreeNode);
+            if (targetTreeNode) connectedNodes.add(targetTreeNode);
+
+            treeGroup.selectAll(".tree-node .tree-shape")
+                .attr("fill", n => connectedNodes.has(n) ? getTreeNodeColor(n) : "#555")
+                .attr("opacity", 1);
+
+            treeGroup.selectAll(".tree-node text")
+                .attr("fill", n => connectedNodes.has(n) ?
+                    ((n.data.isRoot || n.data.isDimension) ? getTreeNodeColor(n) : "#bbb") : "#555")
+                .attr("opacity", 1);
+
+            // 显示边详情
+            const panel = document.getElementById("detailPanel");
+            panel.classList.add("active");
+            document.getElementById("detailTitle").textContent = "🔗 边详情";
+
+            const srcNode = personaTreeData.nodes.find(n => n.节点ID === srcNodeId);
+            const tgtNode = personaTreeData.nodes.find(n => n.节点ID === tgtNodeId);
+            const srcName = sourceTreeNode ? (sourceTreeNode.data.节点名称 || sourceTreeNode.data.name) : (srcNode?.节点名称 || srcNodeId);
+            const tgtName = targetTreeNode ? (targetTreeNode.data.节点名称 || targetTreeNode.data.name) : (tgtNode?.节点名称 || tgtNodeId);
+
+            let html = `
+                <p><span class="label">边类型:</span> <strong>${{edgeType}}</strong></p>
+                <p><span class="label">源节点:</span> ${{srcName}}</p>
+                <p><span class="label">目标节点:</span> ${{tgtName}}</p>
+            `;
+            document.getElementById("detailContent").innerHTML = html;
+
+            // 在关系图中展示这条边和两个节点
+            const edgeData = {{
+                边类型: edgeType,
+                源节点ID: srcNodeId,
+                目标节点ID: tgtNodeId
+            }};
+            renderEgoGraphEdge(edgeData, sourceTreeNode, targetTreeNode);
+        }}
+
+        // 关系子图(Ego Graph)- 在画布第四层显示
+        let currentEgoSimulation = null;  // 保存当前的力模拟,用于停止
+
+        function renderEgoGraph(centerNodeId, centerNodeName) {{
+            // 获取关系图层组
+            const egoGroup = d3.select(".ego-graph-content");
+            if (egoGroup.empty()) return;
+
+            // 停止之前的模拟
+            if (currentEgoSimulation) {{
+                currentEgoSimulation.stop();
+                currentEgoSimulation = null;
+            }}
+
+            // 清除旧内容
+            egoGroup.selectAll("*").remove();
+
+            // 隐藏占位提示
+            d3.select(".ego-placeholder").style("display", "none");
+
+            // 获取层半径(需要从全局获取或重新计算)
+            const radius = 120;  // 固定半径
+
+            // 找到所有相关的边
+            const relatedEdges = personaTreeData.edges.filter(e =>
+                e.源节点ID === centerNodeId || e.目标节点ID === centerNodeId
+            );
+
+            // 收集所有相关节点ID
+            const nodeIds = new Set([centerNodeId]);
+            relatedEdges.forEach(e => {{
+                nodeIds.add(e.源节点ID);
+                nodeIds.add(e.目标节点ID);
+            }});
+
+            // 构建节点数据
+            const nodeMap = {{}};
+            personaTreeData.nodes.forEach(n => {{
+                if (nodeIds.has(n.节点ID)) {{
+                    nodeMap[n.节点ID] = {{
+                        id: n.节点ID,
+                        name: n.节点名称,
+                        type: n.节点类型,
+                        level: n.节点层级,
+                        isCenter: n.节点ID === centerNodeId
+                    }};
+                }}
+            }});
+
+            const nodes = Object.values(nodeMap);
+            const links = relatedEdges.map(e => ({{
+                source: e.源节点ID,
+                target: e.目标节点ID,
+                type: e.边类型
+            }}));
+
+            // 根据节点数量动态计算实际使用的半径
+            const nodeCount = nodes.length;
+            const nodeSpacingEgo = 65;  // 节点间距(增大)
+            const minEgoRadius = 120;
+            const maxEgoRadius = Math.max(radius, 250);  // 允许更大的半径
+            let actualRadius;
+            if (nodeCount <= 1) {{
+                actualRadius = minEgoRadius;
+            }} else {{
+                const circumference = nodeCount * nodeSpacingEgo;
+                const calcR = circumference / (2 * Math.PI);
+                actualRadius = Math.max(minEgoRadius, Math.min(maxEgoRadius, calcR));
+            }}
+
+            // 显示节点名称作为标题
+            egoGroup.append("text")
+                .attr("class", "ego-title")
+                .attr("y", -actualRadius - 15)
+                .attr("text-anchor", "middle")
+                .attr("fill", "#e94560")
+                .attr("font-size", "12px")
+                .attr("font-weight", "bold")
+                .text(centerNodeName + ` (${{nodeCount}}个节点)`);
+
+            // 如果没有相关节点,显示提示
+            if (nodes.length <= 1) {{
+                egoGroup.append("text")
+                    .attr("text-anchor", "middle")
+                    .attr("fill", "rgba(255,255,255,0.4)")
+                    .attr("font-size", "11px")
+                    .text("该节点没有相关边");
+                return;
+            }}
+
+            // 边类型颜色(统一用实线)
+            const edgeColors = {{
+                "属于": "#9b59b6",           // 紫色 - 层级关系
+                "分类共现(跨点)": "#2ecc71", // 绿色 - 跨帖子分类共现
+                "分类共现(点内)": "#3498db", // 蓝色 - 同帖子分类共现
+                "标签共现": "#f39c12"         // 橙色 - 标签共现
+            }};
+
+            // 维度颜色(与人设树完全一致)
+            const dimColors = {{
+                "灵感点": "#f39c12",
+                "目的点": "#3498db",
+                "关键点": "#9b59b6"
+            }};
+
+            // 获取节点的维度颜色(与人设树一致)
+            function getNodeDimColor(d) {{
+                // 根据节点层级判断维度
+                const level = d.level || "";
+                if (level.includes("灵感点")) return dimColors["灵感点"];
+                if (level.includes("目的点")) return dimColors["目的点"];
+                if (level.includes("关键点")) return dimColors["关键点"];
+                return "#888";
+            }}
+
+            // 获取节点填充色(实心,用维度颜色,不因选中而改变)
+            function getNodeFill(d) {{
+                return getNodeDimColor(d);  // 始终用维度颜色填充
+            }}
+
+            // 获取节点边框色(选中节点用白色边框区分)
+            function getNodeStroke(d) {{
+                if (d.isCenter) return "#fff";  // 选中节点白色边框
+                return "rgba(255,255,255,0.5)";  // 普通节点半透明白边框
+            }}
+
+            // 获取节点边框粗细(选中节点更粗)
+            function getNodeStrokeWidth(d) {{
+                return d.isCenter ? 3 : 1.5;
+            }}
+
+            // 根据节点数量调整力模拟参数
+            const linkDistance = Math.max(50, Math.min(80, actualRadius / 2.5));
+            const chargeStrength = Math.max(-300, Math.min(-100, -nodeCount * 10));
+            const collisionRadius = 35;  // 碰撞半径,防止重叠
+
+            // 创建力导向模拟(中心在0,0因为已经平移了)
+            const simulation = d3.forceSimulation(nodes)
+                .force("link", d3.forceLink(links).id(d => d.id).distance(linkDistance))
+                .force("charge", d3.forceManyBody().strength(chargeStrength))
+                .force("center", d3.forceCenter(0, 0))
+                .force("collision", d3.forceCollide().radius(collisionRadius))
+                .force("boundary", function() {{
+                    // 限制节点在圆形区域内
+                    const maxR = actualRadius - 30;
+                    return function(alpha) {{
+                        nodes.forEach(d => {{
+                            const dist = Math.sqrt(d.x * d.x + d.y * d.y);
+                            if (dist > maxR) {{
+                                const scale = maxR / dist;
+                                d.x *= scale;
+                                d.y *= scale;
+                            }}
+                        }});
+                    }};
+                }}());
+
+            currentEgoSimulation = simulation;
+
+            // 绘制边(统一用实线)
+            const link = egoGroup.selectAll(".ego-edge")
+                .data(links)
+                .join("line")
+                .attr("class", "ego-edge")
+                .attr("stroke", d => edgeColors[d.type] || "#666")
+                .attr("stroke-width", 1.5)
+                .attr("stroke-opacity", 0.7);
+
+            // 绘制节点(分类用方形,标签用圆形)
+            const node = egoGroup.selectAll(".ego-node")
+                .data(nodes)
+                .join("g")
+                .attr("class", d => "ego-node" + (d.isCenter ? " center" : ""));
+
+            // 根据节点类型绘制不同形状(与人设树样式一致)
+            const nodeSize = 8;
+            node.each(function(d) {{
+                const el = d3.select(this);
+                const fill = getNodeFill(d);
+                const stroke = getNodeStroke(d);
+                const size = d.isCenter ? nodeSize + 2 : nodeSize;
+                const strokeWidth = getNodeStrokeWidth(d);
+
+                if (d.type === "分类") {{
+                    // 方形(分类节点)- 与人设树一致
+                    el.append("rect")
+                        .attr("class", "ego-shape")
+                        .attr("width", size * 2)
+                        .attr("height", size * 2)
+                        .attr("x", -size)
+                        .attr("y", -size)
+                        .attr("fill", fill)
+                        .attr("stroke", stroke)
+                        .attr("stroke-width", strokeWidth)
+                        .attr("rx", 1);
+                }} else {{
+                    // 圆形(标签节点)- 与人设树一致
+                    el.append("circle")
+                        .attr("class", "ego-shape")
+                        .attr("r", size)
+                        .attr("fill", fill)
+                        .attr("stroke", stroke)
+                        .attr("stroke-width", strokeWidth);
+                }}
+            }});
+
+            node.append("text")
+                .attr("dy", -12)
+                .attr("text-anchor", "middle")
+                .attr("font-size", "9px")
+                .attr("fill", "#fff")
+                .text(d => d.name.length > 6 ? d.name.substring(0, 6) + ".." : d.name);
+
+            // 添加点击事件(直接调用共享函数)
+            node.on("click", function(event, d) {{
+                event.stopPropagation();
+                handleNodeClick(d.id, d.name);
+            }});
+
+            // 边点击事件(直接调用共享函数)
+            link.on("click", function(event, d) {{
+                event.stopPropagation();
+                handleEdgeClick(d.source.id, d.target.id, d.type);
+            }});
+
+            // 更新位置
+            simulation.on("tick", () => {{
+                link
+                    .attr("x1", d => d.source.x)
+                    .attr("y1", d => d.source.y)
+                    .attr("x2", d => d.target.x)
+                    .attr("y2", d => d.target.y);
+
+                node.attr("transform", d => `translate(${{d.x}},${{d.y}})`);
+            }});
+        }}
+
+        function clearEgoGraph() {{
+            const egoGroup = d3.select(".ego-graph-content");
+            if (!egoGroup.empty()) {{
+                egoGroup.selectAll("*").remove();
+            }}
+            // 显示占位提示
+            d3.select(".ego-placeholder").style("display", null);
+            // 停止模拟
+            if (currentEgoSimulation) {{
+                currentEgoSimulation.stop();
+                currentEgoSimulation = null;
+            }}
+        }}
+
+        // 渲染单条边和两个节点(点击树边时调用)
+        function renderEgoGraphEdge(edgeData, sourceNode, targetNode) {{
+            const egoGroup = d3.select(".ego-graph-content");
+            if (egoGroup.empty()) return;
+
+            // 停止之前的模拟
+            if (currentEgoSimulation) {{
+                currentEgoSimulation.stop();
+                currentEgoSimulation = null;
+            }}
+
+            // 清除旧内容
+            egoGroup.selectAll("*").remove();
+            d3.select(".ego-placeholder").style("display", "none");
+
+            const radius = 250;
+
+            // 边类型颜色
+            const edgeColors = {{
+                "属于": "#9b59b6",
+                "分类共现(跨点)": "#2ecc71",
+                "分类共现(点内)": "#3498db",
+                "标签共现": "#f39c12"
+            }};
+
+            // 维度颜色
+            const dimColors = {{
+                "灵感点": "#f39c12",
+                "目的点": "#3498db",
+                "关键点": "#9b59b6"
+            }};
+
+            // 获取节点颜色
+            function getNodeColor(nodeData) {{
+                const level = nodeData.节点层级 || "";
+                if (level.includes("灵感点")) return dimColors["灵感点"];
+                if (level.includes("目的点")) return dimColors["目的点"];
+                if (level.includes("关键点")) return dimColors["关键点"];
+                return "#888";
+            }}
+
+            // 标题
+            egoGroup.append("text")
+                .attr("class", "ego-title")
+                .attr("y", -80)
+                .attr("text-anchor", "middle")
+                .attr("fill", edgeColors[edgeData.边类型] || "#666")
+                .attr("font-size", "12px")
+                .attr("font-weight", "bold")
+                .text(`${{edgeData.边类型}}`);
+
+            // 两个节点的位置
+            const srcX = -60, srcY = 0;
+            const tgtX = 60, tgtY = 0;
+
+            // 绘制边
+            egoGroup.append("line")
+                .attr("class", "ego-edge")
+                .attr("x1", srcX)
+                .attr("y1", srcY)
+                .attr("x2", tgtX)
+                .attr("y2", tgtY)
+                .attr("stroke", edgeColors[edgeData.边类型] || "#666")
+                .attr("stroke-width", 3)
+                .attr("stroke-opacity", 0.8);
+
+            // 获取节点数据
+            const srcData = sourceNode ? sourceNode.data : {{}};
+            const tgtData = targetNode ? targetNode.data : {{}};
+
+            // 绘制源节点
+            const srcGroup = egoGroup.append("g")
+                .attr("class", "ego-node")
+                .attr("transform", `translate(${{srcX}}, ${{srcY}})`);
+
+            const srcSize = 15;
+            const srcColor = getNodeColor(srcData);
+            if (srcData.节点类型 === "分类") {{
+                srcGroup.append("rect")
+                    .attr("width", srcSize * 2)
+                    .attr("height", srcSize * 2)
+                    .attr("x", -srcSize)
+                    .attr("y", -srcSize)
+                    .attr("fill", srcColor)
+                    .attr("stroke", "rgba(255,255,255,0.5)")
+                    .attr("stroke-width", 2)
+                    .attr("rx", 2);
+            }} else {{
+                srcGroup.append("circle")
+                    .attr("r", srcSize)
+                    .attr("fill", srcColor)
+                    .attr("stroke", "rgba(255,255,255,0.5)")
+                    .attr("stroke-width", 2);
+            }}
+            srcGroup.append("text")
+                .attr("dy", -srcSize - 8)
+                .attr("text-anchor", "middle")
+                .attr("fill", "#fff")
+                .attr("font-size", "11px")
+                .text(srcData.节点名称 || srcData.name || "源节点");
+
+            // 绘制目标节点
+            const tgtGroup = egoGroup.append("g")
+                .attr("class", "ego-node")
+                .attr("transform", `translate(${{tgtX}}, ${{tgtY}})`);
+
+            const tgtSize = 15;
+            const tgtColor = getNodeColor(tgtData);
+            if (tgtData.节点类型 === "分类") {{
+                tgtGroup.append("rect")
+                    .attr("width", tgtSize * 2)
+                    .attr("height", tgtSize * 2)
+                    .attr("x", -tgtSize)
+                    .attr("y", -tgtSize)
+                    .attr("fill", tgtColor)
+                    .attr("stroke", "rgba(255,255,255,0.5)")
+                    .attr("stroke-width", 2)
+                    .attr("rx", 2);
+            }} else {{
+                tgtGroup.append("circle")
+                    .attr("r", tgtSize)
+                    .attr("fill", tgtColor)
+                    .attr("stroke", "rgba(255,255,255,0.5)")
+                    .attr("stroke-width", 2);
+            }}
+            tgtGroup.append("text")
+                .attr("dy", -tgtSize - 8)
+                .attr("text-anchor", "middle")
+                .attr("fill", "#fff")
+                .attr("font-size", "11px")
+                .text(tgtData.节点名称 || tgtData.name || "目标节点");
+
+            // 边标签
+            egoGroup.append("text")
+                .attr("y", 25)
+                .attr("text-anchor", "middle")
+                .attr("fill", "rgba(255,255,255,0.6)")
+                .attr("font-size", "10px")
+                .text(edgeData.边类型);
+        }}
+
+        // 页面加载完成后初始化
+        window.addEventListener("load", init);
+        window.addEventListener("resize", () => {{
+            if (currentIndex >= 0) {{
+                renderGraph(allGraphData[currentIndex]);
+            }}
+        }});
+    </script>
+</body>
+</html>
+'''
+
+
+def generate_combined_html(all_graph_data: List[Dict], persona_tree_data: Dict, output_file: Path):
+    """
+    生成包含所有帖子图谱的HTML文件
+
+    Args:
+        all_graph_data: 所有帖子的图谱数据列表
+        persona_tree_data: 完整的人设树数据(节点和边)
+        output_file: 输出文件路径
+    """
+    # 生成Tab HTML
+    tabs_html = ""
+    for i, data in enumerate(all_graph_data):
+        post_title = data.get("postTitle", "")
+        # 使用帖子标题,如果太长则截断
+        if post_title:
+            tab_name = post_title[:15] + "..." if len(post_title) > 15 else post_title
+        else:
+            tab_name = f"帖子 {i+1}"
+        active_class = "active" if i == 0 else ""
+        tabs_html += f'<div class="tab {active_class}" data-index="{i}">{tab_name}</div>\n'
+
+    # 生成HTML
+    html_content = HTML_TEMPLATE.format(
+        tabs_html=tabs_html,
+        all_graph_data=json.dumps(all_graph_data, ensure_ascii=False),
+        persona_tree_data=json.dumps(persona_tree_data, ensure_ascii=False)
+    )
+
+    with open(output_file, "w", encoding="utf-8") as f:
+        f.write(html_content)
+
+
+def main():
+    # 使用路径配置
+    config = PathConfig()
+
+    print(f"账号: {config.account_name}")
+    print(f"输出版本: {config.output_version}")
+    print()
+
+    # 输入目录
+    match_graph_dir = config.intermediate_dir / "match_graph"
+
+    # 输出文件
+    output_file = config.intermediate_dir / "match_graph.html"
+
+    print(f"输入目录: {match_graph_dir}")
+    print(f"输出文件: {output_file}")
+    print()
+
+    # 读取人设树中间数据
+    persona_tree_file = config.intermediate_dir / "persona_tree.json"
+    persona_tree_data = {"nodes": [], "edges": []}
+
+    if persona_tree_file.exists():
+        print(f"读取人设树数据: {persona_tree_file.name}")
+        with open(persona_tree_file, "r", encoding="utf-8") as f:
+            tree_data = json.load(f)
+            persona_tree_data["nodes"] = tree_data.get("nodes", [])
+            persona_tree_data["edges"] = tree_data.get("edges", [])
+        category_count = len([n for n in persona_tree_data["nodes"] if n.get("节点类型") == "分类"])
+        tag_count = len([n for n in persona_tree_data["nodes"] if n.get("节点类型") == "标签"])
+        print(f"  分类节点: {category_count}, 标签节点: {tag_count}")
+        print(f"  边数: {len(persona_tree_data['edges'])}")
+
+    print()
+
+    # 读取所有匹配图谱文件
+    graph_files = sorted(match_graph_dir.glob("*_match_graph.json"))
+    print(f"找到 {len(graph_files)} 个匹配图谱文件")
+
+    all_graph_data = []
+    for i, graph_file in enumerate(graph_files, 1):
+        print(f"  [{i}/{len(graph_files)}] 读取: {graph_file.name}")
+
+        with open(graph_file, "r", encoding="utf-8") as f:
+            match_graph_data = json.load(f)
+
+        # 提取需要的数据
+        graph_data = {
+            "postId": match_graph_data["说明"]["帖子ID"],
+            "postTitle": match_graph_data["说明"].get("帖子标题", ""),
+            "stats": match_graph_data["说明"]["统计"],
+            "nodes": match_graph_data["节点列表"],
+            "edges": match_graph_data["边列表"]
+        }
+        all_graph_data.append(graph_data)
+
+    # 生成HTML
+    print("\n生成HTML文件...")
+    generate_combined_html(all_graph_data, persona_tree_data, output_file)
+
+    print("\n" + "="*60)
+    print("处理完成!")
+    print(f"输出文件: {output_file}")
+
+
+if __name__ == "__main__":
+    main()