Просмотр исходного кода

commit 优化若干效果,增加世代分层家谱树

林海 1 месяц назад
Родитель
Сommit
36171d5b82
7 измененных файлов с 1496 добавлено и 43 удалено
  1. 350 24
      app.py
  2. 3 0
      templates/layout.html
  3. 106 5
      templates/lineage_query.html
  4. 6 0
      templates/member_detail.html
  5. 2 1
      templates/tree.html
  6. 18 13
      templates/tree_classic.html
  7. 1011 0
      templates/tree_gen.html

+ 350 - 24
app.py

@@ -1292,6 +1292,12 @@ def tree_classic():
         return redirect(url_for('login'))
     return render_template('tree_classic.html')
 
+@app.route('/manager/tree_gen')
+def tree_gen():
+    if 'user_id' not in session:
+        return redirect(url_for('login'))
+    return render_template('tree_gen.html')
+
 @app.route('/manager/api/tree_data')
 def tree_data():
     if 'user_id' not in session:
@@ -1370,10 +1376,11 @@ def get_lineage(member_id):
             step_start = time.time()
             generations = []  # Array of generations, each with main ancestor and siblings
             current_id = member_id
-            max_depth = 15
+            max_depth = 100          # 支持最多 100 代祖先(实际家谱一般不超过 80 代)
             ancestor_ids = []  # Track ancestor IDs for exclusion when expanding
             displayed_ids = set()  # Track IDs that are already displayed
             displayed_ids.add(member_id)  # Center person is displayed
+            visited_ancestor_ids = set([member_id])   # 循环检测:避免脏数据死循环
             
             for depth in range(max_depth):
                 # 获取所有父母关系(支持出继/入继)
@@ -1405,7 +1412,12 @@ def get_lineage(member_id):
                 # 如果没有找到普通父母,使用入继父母
                 if not parent:
                     parent = adoptive_parent
-                
+
+                # 循环检测:如果该祖先已在链中出现过,终止(数据异常保护)
+                if parent['id'] in visited_ancestor_ids:
+                    break
+                visited_ancestor_ids.add(parent['id'])
+
                 ancestor_ids.append(parent['id'])
                 displayed_ids.add(parent['id'])
                 
@@ -1469,7 +1481,10 @@ def get_lineage(member_id):
             # Step 3: Get immediate children only (limited count)
             step_start = time.time()
             
-            # 获取所有子女(包括出继和入继)
+            # 获取子女:
+            #   - 包含入继子女(sub_relation_type=3,养父母侧)
+            #   - 包含普通子女(sub_relation_type 为空或非2/3)
+            #   - 排除出继子女(sub_relation_type=2,生父母侧)若该子女已有养父母记录
             cursor.execute("""
                 SELECT c.id, c.name, c.simplified_name, c.name_word, c.name_word_generation,
                        EXISTS(SELECT 1 FROM family_relation_info WHERE parent_mid = c.id AND relation_type IN (1, 2)) as has_children,
@@ -1478,27 +1493,36 @@ def get_lineage(member_id):
                 FROM family_relation_info r
                 JOIN family_member_info c ON r.child_mid = c.id
                 WHERE r.parent_mid = %s AND r.relation_type IN (1, 2)
+                  AND (
+                    COALESCE(r.sub_relation_type, 0) != 2
+                    OR NOT EXISTS (
+                      SELECT 1 FROM family_relation_info r2
+                      WHERE r2.child_mid = c.id AND r2.sub_relation_type = 3
+                    )
+                  )
                 ORDER BY COALESCE(r.child_order, 99999), c.id
                 LIMIT 30
             """, (member_id,))
             children = cursor.fetchall()
-            
-            # 对于出继的子女,需要获取他们入继到的家庭信息
+
+            # 对于入继的子女,获取其生父母信息并生成"由xxx公第N子入继"说明
+            _order_labels_lg = {1:'长', 2:'次', 3:'三', 4:'四', 5:'五',
+                                6:'六', 7:'七', 8:'八', 9:'九', 10:'十'}
             for child in children:
-                if child['sub_relation_type'] == 2:  # 出继
-                    # 查找该子女入继到的父母
+                if child['sub_relation_type'] == 3:  # 入继
                     cursor.execute("""
-                        SELECT p.id, p.name, p.simplified_name
+                        SELECT p.id, p.name, p.simplified_name, r.child_order
                         FROM family_relation_info r
                         JOIN family_member_info p ON r.parent_mid = p.id
-                        WHERE r.child_mid = %s AND r.sub_relation_type = 3
+                        WHERE r.child_mid = %s AND r.sub_relation_type = 2
                         LIMIT 1
                     """, (child['id'],))
-                    adoptive_parent = cursor.fetchone()
-                    if adoptive_parent:
-                        child['adoptive_parent_name'] = adoptive_parent['name']
-                        if adoptive_parent['simplified_name'] and adoptive_parent['simplified_name'] != adoptive_parent['name']:
-                            child['adoptive_parent_name'] += f" ({adoptive_parent['simplified_name']})"
+                    bio_parent = cursor.fetchone()
+                    if bio_parent:
+                        bio_name = bio_parent['simplified_name'] or bio_parent['name']
+                        order = bio_parent['child_order']
+                        order_str = _order_labels_lg.get(order, f'第{order}') if order else '某'
+                        child['adopt_info'] = f"由{bio_name}公{order_str}子入继"
             
             # Initialize children array
             for child in children:
@@ -1526,6 +1550,17 @@ def get_lineage(member_id):
             total_time = time.time() - start_time
             print(f"[Lineage Query] Total time: {total_time:.3f}s")
             
+            # 判断是否还有更高的祖先(顶端祖先是否仍有父亲)
+            has_more_ancestors = False
+            topmost_ancestor_id = None
+            if generations:
+                topmost_ancestor_id = generations[-1]['ancestor']['id']
+                cursor.execute("""
+                    SELECT COUNT(*) as cnt FROM family_relation_info
+                    WHERE child_mid = %s AND relation_type IN (1,2)
+                """, (topmost_ancestor_id,))
+                has_more_ancestors = cursor.fetchone()['cnt'] > 0
+
             return jsonify({
                 "success": True,
                 "data": {
@@ -1533,7 +1568,9 @@ def get_lineage(member_id):
                     "generations": generations,
                     "ancestor_ids": ancestor_ids,
                     "siblings": siblings,
-                    "children": children
+                    "children": children,
+                    "has_more_ancestors": has_more_ancestors,
+                    "topmost_ancestor_id": topmost_ancestor_id
                 }
             })
     except Exception as e:
@@ -1542,6 +1579,116 @@ def get_lineage(member_id):
     finally:
         conn.close()
 
+@app.route('/manager/api/get_ancestors_above/<int:ancestor_id>')
+def get_ancestors_above(ancestor_id):
+    """从指定祖先节点继续向上追溯,用于世系查询"继续向上"按钮"""
+    if 'user_id' not in session:
+        return jsonify({"success": False, "message": "Unauthorized"}), 401
+
+    conn = get_db_connection()
+    try:
+        with conn.cursor() as cursor:
+            generations = []
+            current_id = ancestor_id
+            max_depth = 100
+            visited_ids = set([ancestor_id])
+
+            for depth in range(max_depth):
+                cursor.execute("""
+                    SELECT p.id, p.name, p.simplified_name, p.name_word, p.name_word_generation,
+                           EXISTS(SELECT 1 FROM family_relation_info
+                                  WHERE parent_mid = p.id AND relation_type IN (1,2)) as has_children,
+                           r.sub_relation_type
+                    FROM family_relation_info r
+                    JOIN family_member_info p ON r.parent_mid = p.id
+                    WHERE r.child_mid = %s AND r.relation_type IN (1, 2)
+                """, (current_id,))
+                parents = cursor.fetchall()
+                if not parents:
+                    break
+
+                parent = None
+                adoptive_parent = None
+                for p in parents:
+                    if p['sub_relation_type'] == 3:
+                        adoptive_parent = p
+                    else:
+                        parent = p
+                if not parent:
+                    parent = adoptive_parent
+
+                if parent['id'] in visited_ids:
+                    break
+                visited_ids.add(parent['id'])
+
+                # 查祖父,用于获取该祖先的兄弟
+                cursor.execute("""
+                    SELECT gp.id FROM family_relation_info r
+                    JOIN family_member_info gp ON r.parent_mid = gp.id
+                    WHERE r.child_mid = %s AND r.relation_type IN (1, 2) LIMIT 1
+                """, (parent['id'],))
+                grandparent = cursor.fetchone()
+
+                parent_siblings = []
+                if grandparent:
+                    cursor.execute("""
+                        SELECT COALESCE(child_order, 1) AS child_order
+                        FROM family_relation_info
+                        WHERE parent_mid = %s AND child_mid = %s AND relation_type IN (1,2) LIMIT 1
+                    """, (grandparent['id'], parent['id']))
+                    co_row = cursor.fetchone()
+                    parent['child_order'] = co_row['child_order'] if co_row else 1
+
+                    cursor.execute("""
+                        SELECT c.id, c.name, c.simplified_name, c.name_word, c.name_word_generation,
+                               EXISTS(SELECT 1 FROM family_relation_info
+                                      WHERE parent_mid = c.id AND relation_type IN (1,2)) as has_children,
+                               COALESCE(r.child_order, 1) AS child_order
+                        FROM family_relation_info r
+                        JOIN family_member_info c ON r.child_mid = c.id
+                        WHERE r.parent_mid = %s AND r.relation_type IN (1,2) AND c.id != %s
+                        ORDER BY COALESCE(r.child_order, 1), c.id
+                        LIMIT 10
+                    """, (grandparent['id'], parent['id']))
+                    parent_siblings = cursor.fetchall()
+                    for s in parent_siblings:
+                        s['has_children'] = bool(s['has_children'])
+                else:
+                    parent['child_order'] = None
+
+                parent['has_children'] = bool(parent['has_children'])
+                generations.append({
+                    'ancestor': parent,
+                    'siblings': list(parent_siblings),
+                    'depth': depth
+                })
+                current_id = parent['id']
+
+            # 是否还有更高的祖先
+            has_more_ancestors = False
+            topmost_ancestor_id = None
+            if generations:
+                topmost_ancestor_id = generations[-1]['ancestor']['id']
+                cursor.execute("""
+                    SELECT COUNT(*) as cnt FROM family_relation_info
+                    WHERE child_mid = %s AND relation_type IN (1,2)
+                """, (topmost_ancestor_id,))
+                has_more_ancestors = cursor.fetchone()['cnt'] > 0
+
+            return jsonify({
+                "success": True,
+                "data": {
+                    "generations": generations,
+                    "has_more_ancestors": has_more_ancestors,
+                    "topmost_ancestor_id": topmost_ancestor_id
+                }
+            })
+    except Exception as e:
+        return jsonify({"success": False, "message": str(e)})
+    finally:
+        conn.close()
+
+
 @app.route('/manager/api/get_descendants/<int:parent_id>')
 def get_descendants(parent_id):
     if 'user_id' not in session:
@@ -2583,7 +2730,7 @@ def member_detail(member_id):
             
             # 获取关系(包含子类型和第几子)
             cursor.execute("""
-                SELECT m.id, m.name, r.relation_type, r.sub_relation_type, r.child_order
+                SELECT m.id, m.name, m.simplified_name, r.relation_type, r.sub_relation_type, r.child_order
                 FROM family_relation_info r 
                 JOIN family_member_info m ON r.parent_mid = m.id 
                 WHERE r.child_mid = %s
@@ -2591,17 +2738,32 @@ def member_detail(member_id):
             parents = cursor.fetchall()
             
             cursor.execute("""
-                SELECT m.id, m.name, r.relation_type, r.sub_relation_type, r.child_order
+                SELECT m.id, m.name, m.simplified_name, r.relation_type, r.sub_relation_type, r.child_order
                 FROM family_relation_info r 
                 JOIN family_member_info m ON r.child_mid = m.id 
                 WHERE r.parent_mid = %s
                 ORDER BY COALESCE(r.child_order, 99999), m.id
             """, (member_id,))
             children = cursor.fetchall()
+
+            # 计算入继说明:若该成员有 sub_relation_type=3(养父母)记录,
+            # 则从 sub_relation_type=2(生父母)记录中取排行,生成"由xxx公第N子入继"
+            _order_labels = {1:'长', 2:'次', 3:'三', 4:'四', 5:'五',
+                             6:'六', 7:'七', 8:'八', 9:'九', 10:'十'}
+            adopt_info = None
+            is_adopted_in = any(p['sub_relation_type'] == 3 for p in parents)
+            if is_adopted_in:
+                bio = next((p for p in parents if p['sub_relation_type'] == 2), None)
+                if bio:
+                    bio_name = bio['simplified_name'] or bio['name']
+                    order = bio['child_order']
+                    order_str = _order_labels.get(order, f'第{order}') if order else '某'
+                    adopt_info = f"由{bio_name}公{order_str}子入继"
     finally:
         conn.close()
         
-    return render_template('member_detail.html', member=member, parents=parents, children=children)
+    return render_template('member_detail.html', member=member, parents=parents,
+                           children=children, adopt_info=adopt_info)
 
 @app.route('/manager/delete_member/<int:member_id>', methods=['POST'])
 def delete_member(member_id):
@@ -4917,7 +5079,7 @@ def api_get_member(member_id):
             # 父母
             cursor.execute("""
                 SELECT m.id, m.name, m.simplified_name, m.name_word_generation,
-                       r.relation_type, r.sub_relation_type
+                       r.relation_type, r.sub_relation_type, r.child_order
                 FROM family_relation_info r
                 JOIN family_member_info m ON m.id = r.parent_mid
                 WHERE r.child_mid = %s
@@ -4941,9 +5103,23 @@ def api_get_member(member_id):
         for p in parents:
             p['relation_label'] = relation_labels.get(p.get('relation_type'), '亲属')
 
+        # 计算入继说明
+        _order_labels = {1:'长', 2:'次', 3:'三', 4:'四', 5:'五',
+                         6:'六', 7:'七', 8:'八', 9:'九', 10:'十'}
+        adopt_info = None
+        is_adopted_in = any(p.get('sub_relation_type') == 3 for p in parents)
+        if is_adopted_in:
+            bio = next((p for p in parents if p.get('sub_relation_type') == 2), None)
+            if bio:
+                bio_name = bio.get('simplified_name') or bio.get('name', '')
+                order = bio.get('child_order')
+                order_str = _order_labels.get(order, f'第{order}') if order else '某'
+                adopt_info = f"由{bio_name}公{order_str}子入继"
+
         return jsonify({
             "success": True,
-            "data": {**member, "parents": parents, "children": children}
+            "data": {**member, "parents": parents, "children": children,
+                     "adopt_info": adopt_info}
         })
     finally:
         conn.close()
@@ -5172,10 +5348,11 @@ def api_get_lineage(member_id):
             if not center:
                 return jsonify({"success": False, "message": "成员不存在"}), 404
 
-            # Step 2: 向上追溯祖先链(最多6代),每代带同辈兄弟
+            # Step 2: 向上追溯祖先链(最多100代),每代带同辈兄弟
             generations = []
             current_id = member_id
-            max_depth = 6
+            max_depth = 100
+            visited_ancestor_ids = set([member_id])   # 循环检测
 
             for depth in range(max_depth):
                 cursor.execute("""
@@ -5200,6 +5377,11 @@ def api_get_lineage(member_id):
                 if not parent:
                     parent = parents[0]
 
+                # 循环检测
+                if parent['id'] in visited_ancestor_ids:
+                    break
+                visited_ancestor_ids.add(parent['id'])
+
                 # 查祖父以获取该祖先的兄弟
                 cursor.execute("""
                     SELECT gp.id FROM family_relation_info r
@@ -5245,7 +5427,7 @@ def api_get_lineage(member_id):
                 })
                 current_id = parent['id']
 
-            # Step 3: 获取子女(带排行,NULL child_order 默认按1处理
+            # Step 3: 获取子女(排除出继、保留入继,带排行)
             cursor.execute("""
                 SELECT c.id, c.name, c.simplified_name, c.name_word, c.name_word_generation,
                        EXISTS(SELECT 1 FROM family_relation_info
@@ -5254,12 +5436,35 @@ def api_get_lineage(member_id):
                 FROM family_relation_info r
                 JOIN family_member_info c ON r.child_mid = c.id
                 WHERE r.parent_mid = %s AND r.relation_type IN (1, 2)
+                  AND (
+                    COALESCE(r.sub_relation_type, 0) != 2
+                    OR NOT EXISTS (
+                      SELECT 1 FROM family_relation_info r2
+                      WHERE r2.child_mid = c.id AND r2.sub_relation_type = 3
+                    )
+                  )
                 ORDER BY COALESCE(r.child_order, 1), c.id
                 LIMIT 20
             """, (member_id,))
             children = cursor.fetchall()
+            _order_labels_alg = {1:'长', 2:'次', 3:'三', 4:'四', 5:'五',
+                                 6:'六', 7:'七', 8:'八', 9:'九', 10:'十'}
             for c in children:
                 c['has_children'] = bool(c['has_children'])
+                # 入继子女:附加生父母信息,生成"由xxx公第N子入继"说明
+                if c['sub_relation_type'] == 3:
+                    cursor.execute("""
+                        SELECT p.name, p.simplified_name, r.child_order
+                        FROM family_relation_info r
+                        JOIN family_member_info p ON r.parent_mid = p.id
+                        WHERE r.child_mid = %s AND r.sub_relation_type = 2 LIMIT 1
+                    """, (c['id'],))
+                    bp = cursor.fetchone()
+                    if bp:
+                        bio_name = bp['simplified_name'] or bp['name']
+                        order = bp['child_order']
+                        order_str = _order_labels_alg.get(order, f'第{order}') if order else '某'
+                        c['adopt_info'] = f"由{bio_name}公{order_str}子入继"
 
             # Step 4: 获取查询人物的同辈兄弟(含center自己的child_order)
             siblings = []
@@ -5290,13 +5495,26 @@ def api_get_lineage(member_id):
                 for s in siblings:
                     s['has_children'] = bool(s['has_children'])
 
+            # 判断是否还有更高的祖先
+            has_more_ancestors = False
+            topmost_ancestor_id = None
+            if generations:
+                topmost_ancestor_id = generations[-1]['ancestor']['id']
+                cursor.execute("""
+                    SELECT COUNT(*) as cnt FROM family_relation_info
+                    WHERE child_mid = %s AND relation_type IN (1,2)
+                """, (topmost_ancestor_id,))
+                has_more_ancestors = cursor.fetchone()['cnt'] > 0
+
         return jsonify({
             "success": True,
             "data": {
                 "center": {**center, "child_order": center_child_order or 1},
                 "generations": generations,
                 "siblings": list(siblings),
-                "children": list(children)
+                "children": list(children),
+                "has_more_ancestors": has_more_ancestors,
+                "topmost_ancestor_id": topmost_ancestor_id
             }
         })
     except Exception as e:
@@ -5306,6 +5524,114 @@ def api_get_lineage(member_id):
         conn.close()
 
 
+@app.route('/manager/api/lineage/<int:ancestor_id>/ancestors_above', methods=['GET'])
+def api_get_ancestors_above(ancestor_id):
+    """小程序世系查询:从指定祖先节点继续向上追溯(分批加载更多祖先)"""
+    token = request.headers.get('Authorization', '').replace('Bearer ', '')
+    if not token:
+        return jsonify({"success": False, "message": "未登录"}), 401
+
+    conn = get_db_connection()
+    try:
+        with conn.cursor() as cursor:
+            generations = []
+            current_id = ancestor_id
+            max_depth = 100
+            visited_ids = set([ancestor_id])
+
+            for depth in range(max_depth):
+                cursor.execute("""
+                    SELECT p.id, p.name, p.simplified_name, p.name_word, p.name_word_generation,
+                           EXISTS(SELECT 1 FROM family_relation_info
+                                  WHERE parent_mid = p.id AND relation_type IN (1,2)) as has_children,
+                           r.sub_relation_type
+                    FROM family_relation_info r
+                    JOIN family_member_info p ON r.parent_mid = p.id
+                    WHERE r.child_mid = %s AND r.relation_type IN (1, 2)
+                """, (current_id,))
+                parents = cursor.fetchall()
+                if not parents:
+                    break
+
+                parent = None
+                for p in parents:
+                    if p['sub_relation_type'] != 3:
+                        parent = p
+                        break
+                if not parent:
+                    parent = parents[0]
+
+                if parent['id'] in visited_ids:
+                    break
+                visited_ids.add(parent['id'])
+
+                cursor.execute("""
+                    SELECT gp.id FROM family_relation_info r
+                    JOIN family_member_info gp ON r.parent_mid = gp.id
+                    WHERE r.child_mid = %s AND r.relation_type IN (1, 2) LIMIT 1
+                """, (parent['id'],))
+                grandparent = cursor.fetchone()
+
+                parent_siblings = []
+                if grandparent:
+                    cursor.execute("""
+                        SELECT COALESCE(child_order, 1) AS child_order
+                        FROM family_relation_info
+                        WHERE parent_mid = %s AND child_mid = %s AND relation_type IN (1,2) LIMIT 1
+                    """, (grandparent['id'], parent['id']))
+                    co_row = cursor.fetchone()
+                    parent['child_order'] = co_row['child_order'] if co_row else 1
+
+                    cursor.execute("""
+                        SELECT c.id, c.name, c.simplified_name, c.name_word, c.name_word_generation,
+                               EXISTS(SELECT 1 FROM family_relation_info
+                                      WHERE parent_mid = c.id AND relation_type IN (1,2)) as has_children,
+                               COALESCE(r.child_order, 1) AS child_order
+                        FROM family_relation_info r
+                        JOIN family_member_info c ON r.child_mid = c.id
+                        WHERE r.parent_mid = %s AND r.relation_type IN (1,2) AND c.id != %s
+                        ORDER BY COALESCE(r.child_order, 1), c.id
+                        LIMIT 10
+                    """, (grandparent['id'], parent['id']))
+                    parent_siblings = cursor.fetchall()
+                    for s in parent_siblings:
+                        s['has_children'] = bool(s['has_children'])
+                else:
+                    parent['child_order'] = None
+
+                parent['has_children'] = bool(parent['has_children'])
+                generations.append({
+                    'ancestor': parent,
+                    'siblings': list(parent_siblings),
+                    'depth': depth
+                })
+                current_id = parent['id']
+
+            has_more_ancestors = False
+            topmost_ancestor_id = None
+            if generations:
+                topmost_ancestor_id = generations[-1]['ancestor']['id']
+                cursor.execute("""
+                    SELECT COUNT(*) as cnt FROM family_relation_info
+                    WHERE child_mid = %s AND relation_type IN (1,2)
+                """, (topmost_ancestor_id,))
+                has_more_ancestors = cursor.fetchone()['cnt'] > 0
+
+        return jsonify({
+            "success": True,
+            "data": {
+                "generations": generations,
+                "has_more_ancestors": has_more_ancestors,
+                "topmost_ancestor_id": topmost_ancestor_id
+            }
+        })
+    except Exception as e:
+        print(f"[API Ancestors Above] Error: {e}")
+        return jsonify({"success": False, "message": str(e)}), 500
+    finally:
+        conn.close()
+
+
 @app.route('/manager/api/mp/wx/auth/login', methods=['POST'])
 def mp_wx_login():
     """微信小程序登录接口"""

+ 3 - 0
templates/layout.html

@@ -83,6 +83,9 @@
                     <a href="{{ url_for('tree') }}" class="{% if request.endpoint == 'tree' %}active{% endif %}">
                         <i class="bi bi-diagram-3 me-2"></i> 家谱世系树状图
                     </a>
+                    <a href="{{ url_for('tree_gen') }}" class="{% if request.endpoint == 'tree_gen' %}active{% endif %}">
+                        <i class="bi bi-layout-three-columns me-2"></i> 世代分层树
+                    </a>
                     <a href="{{ url_for('lineage_query') }}" class="{% if request.endpoint == 'lineage_query' %}active{% endif %}">
                         <i class="bi bi-tree me-2"></i> 世系查询
                     </a>

+ 106 - 5
templates/lineage_query.html

@@ -115,6 +115,29 @@
         background: rgba(255,215,0,0.15);
         min-width: 40px;
     }
+    .load-more-ancestors-btn {
+        display: flex;
+        align-items: center;
+        gap: 8px;
+        background: linear-gradient(135deg, rgba(74,105,189,0.15), rgba(74,105,189,0.05));
+        border: 1px dashed rgba(74,144,217,0.5);
+        border-radius: 8px;
+        color: rgba(74,144,217,0.9);
+        font-size: 13px;
+        padding: 10px 20px;
+        cursor: pointer;
+        margin: 8px auto 4px;
+        transition: all 0.2s;
+    }
+    .load-more-ancestors-btn:hover {
+        background: linear-gradient(135deg, rgba(74,105,189,0.3), rgba(74,105,189,0.15));
+        border-color: #4a90d9;
+        color: #4a90d9;
+    }
+    .load-more-ancestors-btn:disabled {
+        opacity: 0.5;
+        cursor: not-allowed;
+    }
     
     .tree-container {
         padding: 20px;
@@ -399,9 +422,10 @@
         background: rgba(255, 107, 107, 0.1) !important;
     }
     
+    /* 入继节点:橙黄色虚线框,区别于实线 */
     .tree-node.adopted-in {
-        border: 2px solid #4ecdc4 !important;
-        background: rgba(78, 205, 196, 0.1) !important;
+        border: 2px dashed #f59e0b !important;
+        background: rgba(245,158,11,0.08) !important;
     }
     
     .adoption-label {
@@ -411,6 +435,9 @@
         margin-top: 4px;
         text-align: center;
     }
+    .adoption-label.adopted-in-label {
+        color: #f59e0b;
+    }
 
     /* 兄弟节点:稍小,低调 */
     .tree-node.sibling-node {
@@ -727,9 +754,15 @@ function renderNode(person, type) {
     if (person.sub_relation_type === 2) cls += ' adopted-out';
     else if (person.sub_relation_type === 3) cls += ' adopted-in';
 
-    const adoptLabel = person.sub_relation_type === 2
-        ? `<div class="adoption-label">${person.adoptive_parent_name ? '出继给 ' + person.adoptive_parent_name : '出继'}</div>`
-        : '';
+    let adoptLabel = '';
+    if (person.sub_relation_type === 2) {
+        // 出继(生父母侧记录,通常不再显示,但保留以防万一)
+        adoptLabel = `<div class="adoption-label">${person.adoptive_parent_name ? '出继给 ' + person.adoptive_parent_name : '出继'}</div>`;
+    } else if (person.sub_relation_type === 3) {
+        // 入继(养父母侧记录):优先显示完整说明"由xxx公第N子入继"
+        const adoptText = person.adopt_info || (person.bio_parent_name ? `入继自 ${person.bio_parent_name}` : '入继');
+        adoptLabel = `<div class="adoption-label adopted-in-label">${adoptText}</div>`;
+    }
 
     return `
         <div class="${cls}" data-id="${person.id}" onclick="openPersonDetail(${person.id})">
@@ -761,14 +794,44 @@ function renderSiblingsList(siblings) {
 
 // ── 主渲染函数 ────────────────────────────────────────────────────────────────
 
+// 全局状态:当前展示的 generations(用于"继续向上"追加)
+let _currentGenerations = [];
+let _currentCenter = null;
+let _currentSiblings = [];
+let _currentChildren = [];
+
 function renderLineage(data) {
     const { center, generations, siblings, children } = data;
+    _currentGenerations = [...generations];
+    _currentCenter = center;
+    _currentSiblings = siblings || [];
+    _currentChildren = children || [];
+    _renderAncestorView(center, _currentGenerations, _currentSiblings, _currentChildren,
+        data.has_more_ancestors, data.topmost_ancestor_id);
+}
+
+function _renderAncestorView(center, generations, siblings, children, hasMore, topmostId) {
     const ancestorGens = [...generations].reverse(); // 从最远祖先 → 父亲
 
     let html = '<div class="lineage-view">';
 
     // ── 1. 祖先竖列(最远→父亲)────────────────────────────────────────────
     if (ancestorGens.length > 0) {
+        // "继续向上追溯"按钮(如果还有更高祖先)
+        if (hasMore && topmostId) {
+            html += `<div class="lin-row"><div class="lin-center">
+                <button class="load-more-ancestors-btn" id="loadMoreAncestorsBtn"
+                    onclick="loadMoreAncestors(${topmostId}, this)">
+                    <i class="bi bi-arrow-up-circle"></i> 继续向上追溯(仍有更早的祖先)
+                </button>
+            </div></div>`;
+        } else if (ancestorGens.length > 0) {
+            html += `<div class="lin-row"><div class="lin-center">
+                <div style="font-size:12px;color:rgba(255,255,255,0.3);text-align:center;padding:6px 0;">
+                    ↑ 已到达最上辈先祖
+                </div>
+            </div></div>`;
+        }
         html += `<div class="section-divider">祖先世系</div>`;
     }
     ancestorGens.forEach((gen, idx) => {
@@ -824,6 +887,44 @@ function renderLineage(data) {
     document.getElementById('childrenTree').innerHTML  = '';
 }
 
+// 继续向上追溯:加载 ancestor_id 以上的祖先链,并前插到当前列表
+async function loadMoreAncestors(topmostAncestorId, btn) {
+    if (btn) {
+        btn.disabled = true;
+        btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> 追溯中...';
+    }
+    try {
+        const resp = await fetch(`/manager/api/get_ancestors_above/${topmostAncestorId}`, {
+            credentials: 'include'
+        });
+        const result = await resp.json();
+        if (!result.success) {
+            alert('加载失败:' + result.message);
+            if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-arrow-up-circle"></i> 继续向上追溯'; }
+            return;
+        }
+        const newGens = result.data.generations; // 顺序:从 topmostAncestorId 的父亲 → 更高祖先
+        if (!newGens || newGens.length === 0) {
+            if (btn) btn.outerHTML = '<div style="font-size:12px;color:rgba(255,255,255,0.3);text-align:center;padding:6px 0;">↑ 已到达最上辈先祖</div>';
+            return;
+        }
+        // 将新祖先追加到全局 _currentGenerations 末尾(末尾 = 更远的祖先)
+        _currentGenerations = _currentGenerations.concat(newGens);
+        // 重新渲染整个视图(中心人物、兄弟、子女使用缓存数据)
+        _renderAncestorView(
+            _currentCenter,
+            _currentGenerations,
+            _currentSiblings,
+            _currentChildren,
+            result.data.has_more_ancestors,
+            result.data.topmost_ancestor_id
+        );
+    } catch (e) {
+        alert('网络错误:' + e.message);
+        if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-arrow-up-circle"></i> 继续向上追溯'; }
+    }
+}
+
 // 展开子孙(按钮旁的懒加载容器)
 function renderChildrenRecursive(children) {
     if (!children || children.length === 0) return '';

+ 6 - 0
templates/member_detail.html

@@ -126,6 +126,12 @@
         <div class="col-md-8">
             <div class="detail-section">
                 <div class="section-title">基本信息</div>
+                {% if adopt_info %}
+                <div class="alert alert-warning d-flex align-items-center mb-3 py-2 px-3" role="alert" style="border-left: 4px solid #fd7e14; background:#fff8f0;">
+                    <i class="bi bi-house-door-fill me-2 text-warning"></i>
+                    <span class="fw-bold">{{ adopt_info }}</span>
+                </div>
+                {% endif %}
                 <div class="row g-3">
                     <div class="col-md-6">
                         <span class="info-label">姓名(繁体):</span>

+ 2 - 1
templates/tree.html

@@ -6,7 +6,8 @@
 <style>
     #tree-container { 
         width: 100%; 
-        height: 700px; 
+        height: calc(100vh - 160px); 
+        min-height: 500px;
         background: white; 
         border: 1px solid #e9ecef; 
         border-radius: 8px;

+ 18 - 13
templates/tree_classic.html

@@ -508,18 +508,25 @@
                 memberMap[m.id] = m;
             });
 
+            // 找出有"入继"养父母的子女 ID 集合
+            const adoptiveChildIds = new Set(
+                relations.filter(r => (r.relation_type === 1 || r.relation_type === 2) && r.sub_relation_type === 3)
+                         .map(r => r.child_mid)
+            );
+
             relations.forEach(rel => {
                 const parent = memberMap[rel.parent_mid];
                 const child = memberMap[rel.child_mid];
                 if (!parent || !child) return;
 
                 if (rel.relation_type === 1 || rel.relation_type === 2) {
+                    // 出继子女(生父母侧)且已有养父母记录 → 跳过,不显示在生父母名下
+                    if (rel.sub_relation_type === 2 && adoptiveChildIds.has(rel.child_mid)) return;
                     parent.children.push(child);
                     child._hasParent = true;
-                } else if (rel.relation_type === 10) {
-                    parent.spouses.push(child);
-                    child._isSpouse = true;
+                    if (rel.sub_relation_type === 3) child._isAdoptedIn = true;
                 }
+                // relation_type === 10(夫妻)不处理,不在树中展示配偶
             });
 
             // 寻找根节点并推断代数
@@ -720,9 +727,15 @@
                 
                 // 姓名竖排处理 (简单切分文字为数组)
                 let nameArr = Array.from(node.name || '未知');
+                // 入继节点:在姓名上方加"入继"小字标注
+                if (node._isAdoptedIn) {
+                    html += `<text class="node-text" x="${nx}" y="${ny}" text-anchor="middle" font-size="8" fill="#b45309" stroke="white" stroke-width="2" paint-order="stroke">入</text>`;
+                    html += `<text class="node-text" x="${nx}" y="${ny + 9}" text-anchor="middle" font-size="8" fill="#b45309" stroke="white" stroke-width="2" paint-order="stroke">继</text>`;
+                }
                 nameArr.forEach((char, i) => {
+                    const yOffset = node._isAdoptedIn ? 18 : 0;
                     const cls = `node-text${isSelectedRoot ? ' selected-root-text' : ''}`;
-                    html += `<text class="${cls}" data-member-id="${node.id}" x="${nx}" y="${ny + 16 + i * 18}" text-anchor="middle">${char}</text>`;
+                    html += `<text class="${cls}" data-member-id="${node.id}" x="${nx}" y="${ny + 16 + yOffset + i * 18}" text-anchor="middle">${char}</text>`;
                 });
 
                 // 人名上方勾选框(用于快速选择起始人员)
@@ -736,15 +749,7 @@
                     `;
                 }
 
-                // 配偶信息(放在名字左侧)
-                if (node.spouses && node.spouses.length > 0) {
-                    const spNames = node.spouses.map(s => s.name).join('、');
-                    let spStr = '配' + spNames;
-                    Array.from(spStr).forEach((char, i) => {
-                        // 左侧(X减小)
-                        html += `<text class="node-spouse" x="${nx - 18}" y="${ny + 12 + i * 14}" text-anchor="middle">${char}</text>`;
-                    });
-                }
+                // 配偶信息已移除,不在传统家谱树中展示
 
                 // 标记下一页
                 if (node.hasNextPage) {

+ 1011 - 0
templates/tree_gen.html

@@ -0,0 +1,1011 @@
+{% extends "layout.html" %}
+
+{% block title %}世代分层家谱树 - 家谱管理系统{% endblock %}
+
+{% block extra_css %}
+<style>
+/* ── 容器 ── */
+#tree-gen-container {
+    width: 100%;
+    height: calc(100vh - 160px);
+    min-height: 500px;
+    background: #fafbfd;
+    border: 1px solid #e9ecef;
+    border-radius: 8px;
+    position: relative;
+    overflow: hidden;
+    margin-top: 6px;
+    box-shadow: 0 0.125rem 0.25rem rgba(0,0,0,0.075);
+}
+
+/* ── 左侧世代标签栏 ── */
+#gen-sidebar {
+    position: absolute;
+    left: 0; top: 0;
+    width: 148px;
+    height: 100%;
+    z-index: 12;
+    background: #f1f5f9;
+    border-right: 1.5px solid #cbd5e1;
+    overflow: hidden;
+    /* pointer-events 由子元素各自控制 */
+}
+#gen-sidebar-title {
+    position: absolute;
+    top: 0; left: 0; right: 0;
+    height: 38px;
+    background: #e2e8f0;
+    border-bottom: 1px solid #cbd5e1;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    font-size: 12px;
+    font-weight: 600;
+    color: #475569;
+    font-family: 'Microsoft YaHei', sans-serif;
+    z-index: 1;
+    pointer-events: none;
+}
+
+/* 世代标签行(不可交互,仅展示) */
+.gen-label-row {
+    position: absolute;
+    left: 0; right: 0;
+    text-align: center;
+    transform: translateY(-50%);
+    padding: 4px 6px;
+    line-height: 1.5;
+    pointer-events: none;
+    user-select: none;
+}
+.gen-label-num {
+    display: block;
+    font-size: 13px;
+    font-weight: bold;
+    color: #334155;
+    font-family: 'Microsoft YaHei', sans-serif;
+    letter-spacing: 0.5px;
+}
+.gen-label-lineage {
+    display: block;
+    font-size: 10px;
+    color: #94a3b8;
+    font-family: 'Microsoft YaHei', sans-serif;
+    margin-top: 1px;
+}
+
+/* ── 侧边栏折叠手柄 ── */
+.gen-collapse-handle {
+    position: absolute;
+    left: 0; right: 0;
+    height: 20px;
+    transform: translateY(-50%);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    pointer-events: auto;
+    cursor: pointer;
+    opacity: 0;
+    transition: opacity 0.2s;
+    z-index: 3;
+}
+#gen-sidebar:hover .gen-collapse-handle { opacity: 1; }
+.gen-collapse-handle:hover { opacity: 1 !important; }
+.gen-collapse-handle .h-line {
+    position: absolute;
+    left: 8px; right: 8px;
+    height: 1.5px;
+    background: #fbbf24;
+    border-radius: 1px;
+}
+.gen-collapse-handle .h-btn {
+    position: absolute;
+    right: 6px;
+    width: 16px; height: 16px;
+    background: #fbbf24;
+    border-radius: 50%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    font-size: 9px;
+    color: white;
+    font-weight: 900;
+    line-height: 1;
+    box-shadow: 0 1px 3px rgba(0,0,0,0.2);
+}
+.gen-collapse-handle:hover .h-line { background: #f59e0b; height: 2px; }
+.gen-collapse-handle:hover .h-btn  { background: #f59e0b; transform: scale(1.15); }
+/* 第一次点击后高亮 */
+.gen-collapse-handle.first-selected .h-line { background: #ef4444; height: 2px; }
+.gen-collapse-handle.first-selected .h-btn  { background: #ef4444; }
+
+/* ── 已折叠世代行 ── */
+.gen-collapsed-row {
+    position: absolute;
+    left: 4px; right: 4px;
+    transform: translateY(-50%);
+    background: #fffbeb;
+    border: 1.5px dashed #fbbf24;
+    border-radius: 5px;
+    padding: 4px 4px;
+    text-align: center;
+    pointer-events: auto;
+    cursor: pointer;
+    font-family: 'Microsoft YaHei', sans-serif;
+}
+.gen-collapsed-row:hover { background: #fef3c7; border-color: #f59e0b; }
+.gen-collapsed-row .cr-title {
+    display: block;
+    font-size: 11px;
+    font-weight: bold;
+    color: #92400e;
+    line-height: 1.4;
+}
+.gen-collapsed-row .cr-hint {
+    display: block;
+    font-size: 9px;
+    color: #b45309;
+    margin-top: 1px;
+}
+
+/* ── SVG 节点 ── */
+.gen-node rect, .gen-node circle {
+    stroke-width: 2px;
+    filter: drop-shadow(0 2px 3px rgba(0,0,0,0.12));
+}
+.node-male   rect   { stroke: #3B82F6; fill: #EFF6FF; }
+.node-female circle { stroke: #EC4899; fill: #FDF2F8; }
+.node-unknown circle, .node-unknown rect { stroke: #94A3B8; fill: #F8FAFC; }
+.node-name-text {
+    font-family: 'Microsoft YaHei', sans-serif;
+    font-size: 12px;
+    fill: #334155;
+    stroke: white;
+    stroke-width: 3.5px;
+    paint-order: stroke;
+    stroke-linejoin: round;
+    pointer-events: none;
+}
+/* 高亮 */
+.node-highlight rect   { stroke: #ef4444 !important; stroke-width: 3px !important; }
+.node-highlight circle { stroke: #ef4444 !important; stroke-width: 3px !important; }
+/* 入继节点:虚线边框 */
+.node-adopted-in rect   { stroke-dasharray: 5,3; stroke: #f59e0b !important; }
+.node-adopted-in circle { stroke-dasharray: 5,3; stroke: #f59e0b !important; }
+.node-adopt-label {
+    font-family: 'Microsoft YaHei', sans-serif;
+    font-size: 9px;
+    fill: #f59e0b;
+    stroke: white;
+    stroke-width: 2px;
+    paint-order: stroke;
+    pointer-events: none;
+}
+
+/* ── 连线 ── */
+.link-parent  { fill: none; stroke: #94a3b8; stroke-width: 1.6px; stroke-linejoin: round; }
+.link-spouse  { fill: none; stroke: #f59e0b; stroke-width: 1.4px; stroke-dasharray: 5,3; }
+/* 折叠穿透虚线 */
+.link-collapse { fill: none; stroke: #f59e0b; stroke-width: 2px; stroke-dasharray: 8,4; }
+
+/* ── 折叠区间背景 ── */
+.collapse-stripe { fill: #fef3c7; opacity: 0.55; }
+
+/* ── 缩放控件 ── */
+.zoom-controls {
+    position: absolute;
+    top: 10px; right: 10px;
+    z-index: 100;
+    background: white;
+    border-radius: 6px;
+    box-shadow: 0 2px 8px rgba(0,0,0,0.12);
+    padding: 4px;
+}
+.zoom-btn {
+    display: flex;
+    width: 28px; height: 28px;
+    margin: 3px 0;
+    border: 1px solid #e2e8f0;
+    border-radius: 4px;
+    background: white; cursor: pointer;
+    align-items: center; justify-content: center;
+    font-size: 14px; color: #475569;
+}
+.zoom-btn:hover { background: #f1f5f9; color: #0f172a; }
+
+/* ── 右键菜单 ── */
+.context-menu {
+    position: absolute;
+    display: none;
+    background: white;
+    border: 1px solid #e2e8f0;
+    box-shadow: 0 4px 16px rgba(0,0,0,0.15);
+    z-index: 200;
+    border-radius: 6px;
+    padding: 4px 0;
+    min-width: 130px;
+}
+.context-menu-item {
+    padding: 8px 14px;
+    cursor: pointer;
+    font-size: 13px;
+    color: #334155;
+    display: flex;
+    align-items: center;
+    gap: 6px;
+}
+.context-menu-item:hover { background: #f1f5f9; color: #0d6efd; }
+
+/* ── 操作提示浮层 ── */
+#collapse-hint-float {
+    position: absolute;
+    bottom: 12px; left: 50%;
+    transform: translateX(-50%);
+    background: rgba(245,158,11,0.92);
+    color: white;
+    padding: 6px 14px;
+    border-radius: 20px;
+    font-size: 12px;
+    font-family: 'Microsoft YaHei', sans-serif;
+    z-index: 300;
+    pointer-events: none;
+    display: none;
+    white-space: nowrap;
+    box-shadow: 0 2px 10px rgba(0,0,0,0.2);
+}
+</style>
+{% endblock %}
+
+{% block content %}
+<div class="d-flex justify-content-between align-items-center mb-2">
+    <h2><i class="bi bi-layout-three-columns me-2"></i>世代分层家谱树</h2>
+    <div class="d-flex gap-2">
+        <div class="input-group" style="width:260px">
+            <input id="genSearch" type="text" class="form-control form-control-sm" placeholder="输入成员名搜索定位">
+            <button class="btn btn-sm btn-primary" onclick="searchMember()"><i class="bi bi-search"></i></button>
+        </div>
+        <a href="{{ url_for('tree') }}" class="btn btn-outline-secondary btn-sm">
+            <i class="bi bi-diagram-3 me-1"></i>标准树状图
+        </a>
+        <a href="{{ url_for('tree_classic') }}" class="btn btn-outline-secondary btn-sm">
+            <i class="bi bi-printer me-1"></i>传统吊线图
+        </a>
+    </div>
+</div>
+
+<!-- ── 向上收紧工具栏 ── -->
+<div class="d-flex align-items-center gap-2 mb-2 p-2 rounded border bg-warning bg-opacity-10">
+    <i class="bi bi-arrows-collapse text-warning"></i>
+    <span class="small fw-semibold text-warning-emphasis">向上收紧:</span>
+    <span class="small text-secondary">折叠第</span>
+    <input id="collapseFrom" type="number" class="form-control form-control-sm text-center"
+           style="width:62px" min="1" placeholder="起">
+    <span class="small text-secondary">至</span>
+    <input id="collapseTo"   type="number" class="form-control form-control-sm text-center"
+           style="width:62px" min="1" placeholder="止">
+    <span class="small text-secondary">世(区间)</span>
+    <button class="btn btn-sm btn-warning"          onclick="applyCollapseFromInput()">收起</button>
+    <button class="btn btn-sm btn-outline-secondary" onclick="clearAllCollapse()">展开全部</button>
+    <span class="small text-muted ms-1">或在左侧标签栏点击两次
+        <span style="display:inline-block;width:14px;height:14px;background:#fbbf24;border-radius:50%;vertical-align:middle;font-size:9px;line-height:14px;text-align:center;color:#fff;font-weight:900">─</span>
+        手柄折叠
+    </span>
+</div>
+
+<div class="alert alert-light border small py-1 mb-2">
+    <i class="bi bi-info-circle me-1"></i>
+    每行 = 同一世代。<strong>向上收紧</strong>:将中间世代压缩为虚线,保留两端节点。
+    男性方形<span style="display:inline-block;width:12px;height:12px;background:#EFF6FF;border:2px solid #3B82F6;border-radius:2px;vertical-align:middle;margin:0 3px"></span>,
+    女性圆形<span style="display:inline-block;width:12px;height:12px;background:#FDF2F8;border:2px solid #EC4899;border-radius:50%;vertical-align:middle;margin:0 3px"></span>,
+    橙色虚线=折叠穿透。右键可查看/编辑。
+</div>
+
+<div id="tree-gen-container">
+    <!-- 左侧世代标签栏 -->
+    <div id="gen-sidebar">
+        <div id="gen-sidebar-title">世代</div>
+    </div>
+
+    <!-- 缩放控件 -->
+    <div class="zoom-controls">
+        <button class="zoom-btn" onclick="zoomIn()"    title="放大"><i class="bi bi-plus"></i></button>
+        <button class="zoom-btn" onclick="zoomOut()"   title="缩小"><i class="bi bi-dash"></i></button>
+        <button class="zoom-btn" onclick="zoomReset()" title="重置"><i class="bi bi-arrow-counterclockwise"></i></button>
+    </div>
+
+    <!-- 右键菜单 -->
+    <div id="contextMenuGen" class="context-menu">
+        <div class="context-menu-item" onclick="menuAction('detail')"><i class="bi bi-eye"></i>查看成员</div>
+        <div class="context-menu-item" onclick="menuAction('edit')"><i class="bi bi-pencil"></i>编辑成员</div>
+        <div class="context-menu-item" onclick="menuAction('add')"><i class="bi bi-plus-lg"></i>新增成员</div>
+    </div>
+
+    <!-- 操作提示浮层 -->
+    <div id="collapse-hint-float"></div>
+
+    <!-- 加载提示 -->
+    <div id="gen-loading" style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;color:#94a3b8;z-index:5">
+        <div class="spinner-border spinner-border-sm mb-2" role="status"></div>
+        <div style="font-size:13px">加载中...</div>
+    </div>
+</div>
+{% endblock %}
+
+{% block extra_js %}
+<script src="{{ url_for('static', filename='js/d3.min.js') }}"></script>
+<script>
+if (typeof d3 === 'undefined') {
+    var _ds = document.createElement('script');
+    _ds.src = 'https://cdn.jsdelivr.net/npm/d3@7.8.5/dist/d3.min.js';
+    _ds.onload = startInit;
+    _ds.onerror = () => {
+        document.getElementById('gen-loading').innerHTML =
+            '<div class="text-danger small">D3.js 未加载,请检查网络。</div>';
+    };
+    document.head.appendChild(_ds);
+} else {
+    startInit();
+}
+
+// ═══════════════════════════════════════════════════════════
+//  配置常量
+// ═══════════════════════════════════════════════════════════
+const CFG = {
+    SIDEBAR_W:   150,   // 侧边栏宽度
+    ROW_H:       160,   // 正常行高
+    COLLAPSED_H:  80,   // 折叠区间虚拟行高
+    NODE_SPC:    100,   // 水平节点间距
+    NODE_R:       22,   // 节点半径
+    TOP_PAD:      60,   // 顶部留白
+    LEFT_PAD:     30,   // 侧边栏右侧额外留白
+    MAX_NAME:      6,   // 节点显示最大字符数
+};
+
+// ═══════════════════════════════════════════════════════════
+//  全局状态
+// ═══════════════════════════════════════════════════════════
+let _rawData         = null;
+let _mMap            = null;
+let _minGen          = 1;
+let _maxGen          = 1;
+let _lineageRef      = null;
+let _zoomBhv         = null;
+let _svgEl           = null;
+let _mainG           = null;
+let _selMid          = null;
+let _collapsedRanges = [];        // [{start, end}, ...]  当前所有折叠区间
+let _collapseFirst   = null;      // 侧边栏手柄:第一次点击的 betweenGen 值
+
+const _ctxMenu = document.getElementById('contextMenuGen');
+window.addEventListener('click', () => { _ctxMenu.style.display = 'none'; });
+
+// ═══════════════════════════════════════════════════════════
+//  中文数字工具
+// ═══════════════════════════════════════════════════════════
+const _CN_D = ['零','一','二','三','四','五','六','七','八','九'];
+const _CN_U = ['','十','百','千','万'];
+
+function numToCN(n) {
+    if (n <= 0) return '零';
+    if (n <= 10) return n === 10 ? '十' : _CN_D[n];
+    if (n < 20)  return '十' + (n % 10 === 0 ? '' : _CN_D[n % 10]);
+    let r = '', s = String(n);
+    for (let i = 0; i < s.length; i++) {
+        const d = +s[i], u = s.length - 1 - i;
+        if (d === 0) { if (r && !r.endsWith('零') && i < s.length-1) r += '零'; }
+        else r += _CN_D[d] + _CN_U[u];
+    }
+    return r.replace(/零+$/, '');
+}
+function cnToNum(s) {
+    if (!s) return NaN;
+    const dm = {'零':0,'一':1,'二':2,'三':3,'四':4,'五':5,'六':6,'七':7,'八':8,'九':9};
+    const um = {'十':10,'百':100,'千':1000,'万':10000};
+    let r = 0, t = 0;
+    for (const ch of s.trim()) {
+        if (dm[ch] !== undefined)      t = dm[ch];
+        else if (um[ch] !== undefined) { if (!t && um[ch]===10) t=1; r += t*um[ch]; t=0; }
+    }
+    return (r+t) || NaN;
+}
+function extractGen(str) {
+    if (!str) return null;
+    const m = String(str).match(/\d+/);
+    return m ? parseInt(m[0]) : null;
+}
+function parseLineage(str) {
+    if (!str) return [];
+    return str.split(';').filter(s => s.trim()).map(item => {
+        item = item.trim();
+        const m = item.match(/^(.+?)第(.+?)代$/);
+        if (m) return { place: m[1], num: cnToNum(m[2]) };
+        return { place: '', num: NaN };
+    });
+}
+function lineageLbl(refLineage, refGen, targetGen) {
+    if (!refLineage || !refLineage.length) return [];
+    const diff = targetGen - refGen;
+    return refLineage.map(lg => {
+        const n = lg.num + diff;
+        if (isNaN(n) || n <= 0) return null;
+        return (lg.place ? lg.place + '第' : '第') + numToCN(n) + '代';
+    }).filter(Boolean);
+}
+
+// ═══════════════════════════════════════════════════════════
+//  折叠区间工具函数
+// ═══════════════════════════════════════════════════════════
+
+/** 计算 gen 在当前折叠状态下的 Y 坐标(内容坐标系) */
+function computeY(gen) {
+    let y = CFG.TOP_PAD;
+    let g = _minGen;
+    while (g < gen) {
+        const cr = _collapsedRanges.find(r => r.start <= g && g <= r.end);
+        if (cr) {
+            y += CFG.COLLAPSED_H;   // 整个折叠区间只占 COLLAPSED_H
+            g = cr.end + 1;         // 跳过区间
+        } else {
+            y += CFG.ROW_H;
+            g++;
+        }
+    }
+    return y;
+}
+
+/** gen 是否在某个折叠区间内 */
+function isCollapsedGen(gen) {
+    return _collapsedRanges.some(r => r.start <= gen && gen <= r.end);
+}
+
+/** 获取包含 gen 的折叠区间(没有则返回 null) */
+function getCollapseRange(gen) {
+    return _collapsedRanges.find(r => r.start <= gen && gen <= r.end) || null;
+}
+
+/**
+ * BFS:从 entryNode 的孩子出发,穿越折叠区间 cr,
+ * 找到所有 gen > cr.end 的"出口节点"
+ */
+function findExitNodes(entryNode, cr) {
+    const exits = [];
+    const visited = new Set([entryNode.id]);
+    const queue = entryNode.children.filter(c => c.gen >= cr.start && c.gen <= cr.end);
+    while (queue.length) {
+        const cur = queue.shift();
+        if (visited.has(cur.id)) continue;
+        visited.add(cur.id);
+        if (cur.gen > cr.end) {
+            if (!exits.find(e => e.id === cur.id)) exits.push(cur);
+        } else {
+            cur.children.forEach(c => { if (!visited.has(c.id)) queue.push(c); });
+        }
+    }
+    return exits;
+}
+
+// ═══════════════════════════════════════════════════════════
+//  折叠操作
+// ═══════════════════════════════════════════════════════════
+
+function applyCollapse(start, end) {
+    if (start >= end) { alert('折叠区间无效,起始世代须小于结束世代'); return; }
+    // 移除重叠的旧区间
+    _collapsedRanges = _collapsedRanges.filter(r => r.end < start || r.start > end);
+    _collapsedRanges.push({ start, end });
+    renderGenTree(_rawData);
+}
+
+function expandRange(start, end) {
+    _collapsedRanges = _collapsedRanges.filter(r => !(r.start === start && r.end === end));
+    renderGenTree(_rawData);
+}
+
+function applyCollapseFromInput() {
+    const from = parseInt(document.getElementById('collapseFrom').value);
+    const to   = parseInt(document.getElementById('collapseTo').value);
+    if (isNaN(from) || isNaN(to)) { alert('请输入有效的起始和结束世代数字'); return; }
+    applyCollapse(Math.min(from, to), Math.max(from, to));
+}
+
+function clearAllCollapse() {
+    _collapsedRanges = [];
+    renderGenTree(_rawData);
+}
+
+// ── 侧边栏手柄点击逻辑 ──
+function handleCollapseClick(betweenGen, el) {
+    if (_collapseFirst === null) {
+        // 第一次点击
+        _collapseFirst = betweenGen;
+        document.querySelectorAll('.gen-collapse-handle').forEach(h => h.classList.remove('first-selected'));
+        el.classList.add('first-selected');
+        _showHint('✓ 已标记折叠起点(第' + numToCN(betweenGen) + '/'+ numToCN(betweenGen+1) +'世之间),再点一个位置完成收紧');
+    } else {
+        // 第二次点击
+        const first  = _collapseFirst;
+        const second = betweenGen;
+        _collapseFirst = null;
+        document.querySelectorAll('.gen-collapse-handle').forEach(h => h.classList.remove('first-selected'));
+        _hideHint();
+
+        const lo = Math.min(first, second) + 1;
+        const hi = Math.max(first, second);
+        if (lo > hi) { alert('请选择间距至少 1 代的两个位置'); return; }
+        applyCollapse(lo, hi);
+    }
+}
+
+function _showHint(msg) {
+    const el = document.getElementById('collapse-hint-float');
+    el.textContent = msg;
+    el.style.display = 'block';
+}
+function _hideHint() {
+    document.getElementById('collapse-hint-float').style.display = 'none';
+}
+
+// ═══════════════════════════════════════════════════════════
+//  入口
+// ═══════════════════════════════════════════════════════════
+function startInit() {
+    fetch('/manager/api/tree_data')
+        .then(r => r.json())
+        .then(data => {
+            _rawData = data;
+            document.getElementById('gen-loading').style.display = 'none';
+            renderGenTree(data);
+        })
+        .catch(() => {
+            document.getElementById('gen-loading').innerHTML =
+                '<div class="text-danger small">加载失败,请刷新重试。</div>';
+        });
+}
+
+// ═══════════════════════════════════════════════════════════
+//  主渲染函数
+// ═══════════════════════════════════════════════════════════
+function renderGenTree(data) {
+    const container = document.getElementById('tree-gen-container');
+    d3.select('#tree-gen-container svg').remove();
+    document.querySelectorAll('#gen-sidebar .gen-label-row, #gen-sidebar .gen-collapse-handle, #gen-sidebar .gen-collapsed-row').forEach(el => el.remove());
+
+    const { members, relations } = data;
+    if (!members || !members.length) {
+        container.insertAdjacentHTML('beforeend',
+            '<div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#94a3b8">暂无成员数据。</div>');
+        return;
+    }
+
+    // ── 1. 构建成员 Map ──
+    const mMap = {};
+    members.forEach(m => {
+        mMap[m.id] = {
+            id: m.id, name: m.name || '', sname: m.simplified_name || '',
+            sex: m.sex, family_rank: m.family_rank, nwg: m.name_word_generation,
+            gen: extractGen(m.family_rank),
+            children: [], spouses: [], _pCount: 0, _spouseOf: null,
+            x: 0, y: 0,
+        };
+    });
+
+    // ── 2. 处理关系 ──
+    const pcRels = relations.filter(r => r.relation_type === 1 || r.relation_type === 2);
+    const spRels = relations.filter(r => r.relation_type === 10);
+    const hasFather = new Set(relations.filter(r => r.relation_type === 1).map(r => r.child_mid));
+    // 找出有"入继"养父母的子女 ID 集合(这些人应显示在养父母名下,不显示在生父母名下)
+    const hasAdoptiveParent = new Set(
+        pcRels.filter(r => r.sub_relation_type === 3).map(r => r.child_mid)
+    );
+
+    pcRels.forEach(r => {
+        if (r.relation_type === 2 && hasFather.has(r.child_mid)) return;
+        // 出继子女(生父母侧,sub_relation_type=2)且已有养父母记录 → 跳过,不显示在生父母名下
+        if (r.sub_relation_type === 2 && hasAdoptiveParent.has(r.child_mid)) return;
+        const p = mMap[r.parent_mid], c = mMap[r.child_mid];
+        if (!p || !c) return;
+        if (!p.children.find(x => x.id === c.id)) p.children.push(c);
+        c._pCount++;
+        // 标记入继节点(养父母侧)
+        if (r.sub_relation_type === 3) c._isAdoptedIn = true;
+    });
+    // 配偶关系不再在树中展示(spRels 不处理)
+
+    // ── 3. 找根节点 ──
+    let roots = Object.values(mMap).filter(m => !m._pCount);
+    if (!roots.length) roots = [Object.values(mMap)[0]];
+
+    // ── 4. 填充缺失世代 ──
+    const gVis = new Set();
+    function fillGen(node, pg) {
+        if (gVis.has(node.id)) return;
+        gVis.add(node.id);
+        if (node.gen == null || isNaN(node.gen)) node.gen = pg;
+        node.children.forEach(c => fillGen(c, node.gen + 1));
+    }
+    roots.sort((a, b) => ((a.gen || 999) - (b.gen || 999)));
+    roots.forEach(r => fillGen(r, r.gen || 1));
+
+    // ── 5. 后序遍历分配 X 坐标 ──
+    const xVis = new Set();
+    let xCursor = 0;
+    function assignX(node) {
+        if (xVis.has(node.id)) return;
+        xVis.add(node.id);
+        if (!node.children.length) {
+            node.x = xCursor + CFG.NODE_SPC / 2;
+            xCursor += CFG.NODE_SPC;
+        } else {
+            node.children.forEach(c => assignX(c));
+            const xs = node.children.map(c => c.x);
+            node.x = (Math.min(...xs) + Math.max(...xs)) / 2;
+            const needed = Math.max(...xs) + CFG.NODE_SPC * 0.5;
+            if (needed > xCursor) xCursor = needed;
+        }
+    }
+    roots.forEach(r => { assignX(r); xCursor += CFG.NODE_SPC * 1.5; });
+
+    // ── 6. 依世代计算 Y 坐标(考虑折叠区间) ──
+    const allGens = Object.values(mMap).map(m => m.gen).filter(g => g != null && !isNaN(g));
+    const minGen  = allGens.length ? Math.min(...allGens) : 1;
+    const maxGen  = allGens.length ? Math.max(...allGens) : 1;
+    _minGen = minGen; _maxGen = maxGen;
+
+    const offsetX = CFG.SIDEBAR_W + CFG.LEFT_PAD;
+    Object.values(mMap).forEach(m => {
+        const g = (!isNaN(m.gen) && m.gen != null) ? m.gen : minGen;
+        m.y = computeY(g);          // 使用折叠感知的 Y 计算
+        m.x = m.x + offsetX;
+    });
+
+    // ── 8. 找世系参考点 ──
+    let lineageRef = null;
+    Object.values(mMap).find(m => {
+        if (!m.nwg) return false;
+        const parsed = parseLineage(m.nwg);
+        if (parsed.length && !isNaN(parsed[0].num)) { lineageRef = { gen: m.gen, lineage: parsed }; return true; }
+        return false;
+    });
+    _lineageRef = lineageRef; _mMap = mMap;
+
+    // ── 9. 创建 SVG ──
+    const allX = Object.values(mMap).map(m => m.x);
+    const allY = Object.values(mMap).map(m => m.y);
+    const maxX = allX.length ? Math.max(...allX) : 800;
+    const maxY = allY.length ? Math.max(...allY) : 400;
+    const svgW = Math.max(container.offsetWidth || 800, maxX + 150);
+    const svgH = Math.max(container.offsetHeight || 600, maxY + 120);
+
+    _zoomBhv = d3.zoom().scaleExtent([0.04, 5]).on('zoom', e => {
+        _mainG.attr('transform', e.transform);
+        _updateSidebar(e.transform);
+    });
+    _svgEl = d3.select('#tree-gen-container')
+        .append('svg')
+        .attr('width', svgW).attr('height', svgH)
+        .style('position', 'absolute').style('top', '0').style('left', '0').style('z-index', '2')
+        .call(_zoomBhv);
+    _mainG = _svgEl.append('g').attr('class', 'main-g');
+
+    // ── 10. 行背景条纹 ──
+    const stripeG = _mainG.append('g').attr('class', 'stripes');
+    const fullW   = Math.max(svgW, maxX + 300);
+
+    {
+        let g = minGen;
+        let idx = 0;
+        while (g <= maxGen) {
+            const cr = _collapsedRanges.find(r => r.start === g);
+            if (cr) {
+                // 折叠区间:特殊背景
+                const rowY = computeY(cr.start);
+                stripeG.append('rect')
+                    .attr('x', offsetX - CFG.LEFT_PAD * 0.5)
+                    .attr('y', rowY - CFG.COLLAPSED_H * 0.45)
+                    .attr('width', fullW).attr('height', CFG.COLLAPSED_H * 0.9)
+                    .attr('class', 'collapse-stripe');
+                // 上下边界虚线
+                [-1, 1].forEach(sign => {
+                    stripeG.append('line')
+                        .attr('x1', offsetX - CFG.LEFT_PAD * 0.5)
+                        .attr('y1', rowY + sign * CFG.COLLAPSED_H * 0.45)
+                        .attr('x2', fullW)
+                        .attr('y2', rowY + sign * CFG.COLLAPSED_H * 0.45)
+                        .attr('stroke', '#fbbf24').attr('stroke-width', 0.8)
+                        .attr('stroke-dasharray', '4,3');
+                });
+                g = cr.end + 1; idx++;
+            } else {
+                const rowY = computeY(g);
+                if (idx % 2 === 0) {
+                    stripeG.append('rect')
+                        .attr('x', offsetX - CFG.LEFT_PAD * 0.5)
+                        .attr('y', rowY - CFG.ROW_H * 0.46)
+                        .attr('width', fullW).attr('height', CFG.ROW_H * 0.92)
+                        .attr('fill', '#eef2ff').attr('opacity', 0.45);
+                }
+                if (g > minGen) {
+                    stripeG.append('line')
+                        .attr('x1', offsetX - CFG.LEFT_PAD * 0.5).attr('y1', rowY - CFG.ROW_H / 2)
+                        .attr('x2', fullW).attr('y2', rowY - CFG.ROW_H / 2)
+                        .attr('stroke', '#e2e8f0').attr('stroke-width', 0.8)
+                        .attr('stroke-dasharray', '6,5');
+                }
+                g++; idx++;
+            }
+        }
+    }
+
+    // ── 11. 正常亲子连线(跳过折叠区间中的节点) ──
+    const linkG = _mainG.append('g').attr('class', 'links');
+    Object.values(mMap).forEach(node => {
+        if (isCollapsedGen(node.gen)) return;           // 折叠区内的节点不画线
+        const visChildren = node.children.filter(c => !isCollapsedGen(c.gen));
+        if (!visChildren.length) return;
+
+        const px = node.x, py = node.y;
+        const midY = py + CFG.ROW_H * 0.40;
+        const xs   = visChildren.map(c => c.x);
+        const minCX = Math.min(...xs), maxCX = Math.max(...xs);
+
+        linkG.append('line').attr('x1', px).attr('y1', py + CFG.NODE_R + 2).attr('x2', px).attr('y2', midY).attr('class', 'link-parent');
+        if (xs.length > 1) {
+            linkG.append('line').attr('x1', minCX).attr('y1', midY).attr('x2', maxCX).attr('y2', midY).attr('class', 'link-parent');
+        }
+        visChildren.forEach(child => {
+            linkG.append('line').attr('x1', child.x).attr('y1', midY).attr('x2', child.x).attr('y2', child.y - CFG.NODE_R - 2).attr('class', 'link-parent');
+        });
+    });
+
+    // ── 11b. 折叠穿透虚线(Collapse connectors) ──
+    const colConnG = _mainG.append('g').attr('class', 'collapse-conns');
+    _collapsedRanges.forEach(cr => {
+        Object.values(mMap).forEach(entryNode => {
+            if (isCollapsedGen(entryNode.gen)) return;
+            if (entryNode.gen >= cr.start) return;
+            const hasChildInRange = entryNode.children.some(c => c.gen >= cr.start && c.gen <= cr.end);
+            if (!hasChildInRange) return;
+
+            const exits = findExitNodes(entryNode, cr);
+            if (!exits.length) return;
+
+            const px = entryNode.x, py = entryNode.y;
+            const exitYs = exits.map(e => e.y);
+            const exitY  = Math.min(...exitYs);          // 最近的出口行 Y
+            const midY   = py + (exitY - py) * 0.50;     // 虚线中点
+
+            const exitXs = exits.map(e => e.x);
+            const allXs  = [px, ...exitXs];
+            const minBX  = Math.min(...allXs), maxBX = Math.max(...allXs);
+
+            // 父节点向下
+            colConnG.append('line')
+                .attr('x1', px).attr('y1', py + CFG.NODE_R + 2)
+                .attr('x2', px).attr('y2', midY)
+                .attr('class', 'link-collapse');
+            // 横向汇聚线
+            if (exits.length > 1 || Math.abs(px - exits[0].x) > 2) {
+                colConnG.append('line')
+                    .attr('x1', minBX).attr('y1', midY)
+                    .attr('x2', maxBX).attr('y2', midY)
+                    .attr('class', 'link-collapse');
+            }
+            // 到各出口节点
+            exits.forEach(exit => {
+                colConnG.append('line')
+                    .attr('x1', exit.x).attr('y1', midY)
+                    .attr('x2', exit.x).attr('y2', exit.y - CFG.NODE_R - 2)
+                    .attr('class', 'link-collapse');
+            });
+
+            // 角标:× N代
+            const count  = cr.end - cr.start + 1;
+            const bdgX   = (minBX + maxBX) / 2;
+            const bdgY   = midY;
+            const label  = `↑收紧 ${count}代`;
+            colConnG.append('rect')
+                .attr('x', bdgX - 30).attr('y', bdgY - 11)
+                .attr('width', 60).attr('height', 22)
+                .attr('rx', 11).attr('fill', '#fffbeb')
+                .attr('stroke', '#fbbf24').attr('stroke-width', 1.2);
+            colConnG.append('text')
+                .attr('x', bdgX).attr('y', bdgY + 1)
+                .attr('text-anchor', 'middle').attr('dominant-baseline', 'middle')
+                .attr('font-size', '10px').attr('font-weight', '600')
+                .attr('fill', '#d97706').attr('font-family', '"Microsoft YaHei", sans-serif')
+                .text(label);
+        });
+    });
+
+    // ── 12. 配偶连线已移除 ──
+
+    // ── 13. 节点(跳过折叠区内) ──
+    const nodeG = _mainG.append('g').attr('class', 'nodes');
+    Object.values(mMap).forEach(m => {
+        if (isCollapsedGen(m.gen)) return;          // 跳过折叠区内节点
+
+        const sexCls     = m.sex === 1 ? 'node-male' : m.sex === 2 ? 'node-female' : 'node-unknown';
+        const adoptedCls = m._isAdoptedIn ? ' node-adopted-in' : '';
+        const ng = nodeG.append('g')
+            .attr('class', `gen-node ${sexCls}${adoptedCls}`)
+            .attr('data-mid', m.id)
+            .attr('transform', `translate(${m.x},${m.y})`)
+            .style('cursor', 'pointer')
+            .on('contextmenu', event => {
+                event.preventDefault();
+                _selMid = m.id;
+                const cr = container.getBoundingClientRect();
+                _ctxMenu.style.display = 'block';
+                _ctxMenu.style.left = (event.clientX - cr.left) + 'px';
+                _ctxMenu.style.top  = (event.clientY - cr.top)  + 'px';
+            })
+            .on('dblclick', () => { if (m.id) window.location.href = `/manager/member_detail/${m.id}`; });
+
+        const R = CFG.NODE_R;
+        if (m.sex === 1) {
+            ng.append('rect').attr('x', -R).attr('y', -R).attr('width', R*2).attr('height', R*2).attr('rx', 4).attr('ry', 4);
+        } else {
+            ng.append('circle').attr('r', R);
+        }
+        // 入继标签(节点正上方)
+        if (m._isAdoptedIn) {
+            ng.append('text').attr('class', 'node-adopt-label')
+                .attr('x', 0).attr('y', -R - 3).attr('dy', '-0.2em')
+                .attr('text-anchor', 'middle').text('入继');
+        }
+        const disp  = m.sname || m.name;
+        const short = disp.length > CFG.MAX_NAME ? disp.slice(0, CFG.MAX_NAME) + '…' : disp;
+        const txt = ng.append('text').attr('class', 'node-name-text')
+            .attr('x', 0).attr('y', R + 16).attr('dy', '0.15em').attr('text-anchor', 'middle').text(short);
+        if (disp.length > CFG.MAX_NAME || (m.name && m.sname && m.name !== m.sname)) {
+            const tip = m.name + (m.sname && m.name !== m.sname ? ` (${m.sname})` : '');
+            txt.append('title').text(tip);
+            ng.append('title').text(tip);
+        }
+    });
+
+    // ── 14. 侧边栏 ──
+    _buildSidebar(minGen, maxGen, lineageRef);
+    _updateSidebar(d3.zoomIdentity);
+}
+
+// ═══════════════════════════════════════════════════════════
+//  侧边栏构建
+// ═══════════════════════════════════════════════════════════
+function _buildSidebar(minGen, maxGen, lineageRef) {
+    const sidebar = document.getElementById('gen-sidebar');
+
+    let g = minGen;
+    while (g <= maxGen) {
+        // 检查是否是折叠区间的起点
+        const cr = _collapsedRanges.find(r => r.start === g);
+        if (cr) {
+            // 折叠行(点击展开)
+            const count = cr.end - cr.start + 1;
+            const div   = document.createElement('div');
+            div.className = 'gen-collapsed-row';
+            div.dataset.start = cr.start;
+            div.dataset.end   = cr.end;
+            div.innerHTML =
+                `<span class="cr-title">⇕ 第${numToCN(cr.start)}~${numToCN(cr.end)}世</span>` +
+                `<span class="cr-hint">(${count}代已收紧,点击展开)</span>`;
+            div.onclick = () => expandRange(cr.start, cr.end);
+            sidebar.appendChild(div);
+            g = cr.end + 1;
+            continue;
+        }
+
+        // 正常世代标签行
+        const div = document.createElement('div');
+        div.className = 'gen-label-row';
+        div.dataset.gen = g;
+        const numSpan = document.createElement('span');
+        numSpan.className = 'gen-label-num';
+        numSpan.textContent = `第${numToCN(g)}世`;
+        div.appendChild(numSpan);
+        if (lineageRef) {
+            lineageLbl(lineageRef.lineage, lineageRef.gen, g).forEach(lbl => {
+                const sp = document.createElement('span');
+                sp.className = 'gen-label-lineage';
+                sp.textContent = lbl;
+                div.appendChild(sp);
+            });
+        }
+        sidebar.appendChild(div);
+
+        // 手柄:在当前代和下一代之间(如果下一代存在且未到 maxGen)
+        if (g < maxGen) {
+            const handle = document.createElement('div');
+            handle.className = 'gen-collapse-handle';
+            handle.dataset.between = g;
+            handle.title = `点击标记折叠边界(第${numToCN(g)}~${numToCN(g+1)}世之间)`;
+            handle.innerHTML = '<div class="h-line"><div class="h-btn">─</div></div>';
+            handle.addEventListener('click', function(e) {
+                e.stopPropagation();
+                handleCollapseClick(parseInt(this.dataset.between), this);
+            });
+            sidebar.appendChild(handle);
+        }
+
+        g++;
+    }
+}
+
+// ═══════════════════════════════════════════════════════════
+//  侧边栏位置同步
+// ═══════════════════════════════════════════════════════════
+function _updateSidebar(transform) {
+    const k = transform.k, ty = transform.y;
+
+    // 世代标签行
+    document.querySelectorAll('#gen-sidebar .gen-label-row').forEach(el => {
+        const g = parseInt(el.dataset.gen);
+        el.style.top = (computeY(g) * k + ty) + 'px';
+    });
+
+    // 折叠手柄(在 gen g 和 g+1 之间的中点)
+    document.querySelectorAll('#gen-sidebar .gen-collapse-handle').forEach(el => {
+        const bg = parseInt(el.dataset.between);
+        const y  = (computeY(bg) + computeY(bg + 1)) / 2;
+        el.style.top = (y * k + ty) + 'px';
+    });
+
+    // 折叠区间行(居中于折叠区间虚拟行)
+    document.querySelectorAll('#gen-sidebar .gen-collapsed-row').forEach(el => {
+        const start = parseInt(el.dataset.start);
+        // 折叠区间起始的内容 Y + 半个虚拟行高
+        const y = computeY(start) + CFG.COLLAPSED_H / 2;
+        el.style.top = (y * k + ty) + 'px';
+    });
+}
+
+// ═══════════════════════════════════════════════════════════
+//  缩放控件
+// ═══════════════════════════════════════════════════════════
+function zoomIn()    { if (_svgEl) _svgEl.transition().duration(280).call(_zoomBhv.scaleBy, 1.35); }
+function zoomOut()   { if (_svgEl) _svgEl.transition().duration(280).call(_zoomBhv.scaleBy, 0.75); }
+function zoomReset() { if (_svgEl) _svgEl.transition().duration(300).call(_zoomBhv.transform, d3.zoomIdentity); }
+
+// ═══════════════════════════════════════════════════════════
+//  右键菜单
+// ═══════════════════════════════════════════════════════════
+function menuAction(type) {
+    switch(type) {
+        case 'detail': if (_selMid) window.location.href = `/manager/member_detail/${_selMid}`; break;
+        case 'edit':   if (_selMid) window.location.href = `/manager/edit_member/${_selMid}`;   break;
+        case 'add':    window.location.href = '/manager/add_member'; break;
+    }
+}
+
+// ═══════════════════════════════════════════════════════════
+//  搜索
+// ═══════════════════════════════════════════════════════════
+function searchMember() {
+    const term = document.getElementById('genSearch').value.trim().toLowerCase();
+    if (!term) return;
+    if (!_rawData || !_rawData.members) { alert('数据未加载完成,请稍候再试'); return; }
+    const matches = _rawData.members.filter(m =>
+        (m.name || '').toLowerCase().includes(term) ||
+        (m.simplified_name || '').toLowerCase().includes(term)
+    );
+    if (!matches.length) { alert('未找到匹配成员'); return; }
+    const target = matches[0];
+    const node   = _mMap && _mMap[target.id];
+    if (!node || !_svgEl) return;
+    const cont = document.getElementById('tree-gen-container');
+    const scale = 1.6;
+    _svgEl.transition().duration(800).call(
+        _zoomBhv.transform,
+        d3.zoomIdentity.translate(cont.clientWidth/2 - node.x*scale, cont.clientHeight/2 - node.y*scale).scale(scale)
+    );
+    _mainG.selectAll('.gen-node').filter(function() {
+        return d3.select(this).attr('data-mid') == target.id;
+    }).classed('node-highlight', true);
+    setTimeout(() => { _mainG.selectAll('.gen-node').classed('node-highlight', false); }, 2000);
+}
+
+document.getElementById('genSearch').addEventListener('keypress', e => {
+    if (e.key === 'Enter') searchMember();
+});
+</script>
+{% endblock %}