林海 1 месяц назад
Родитель
Сommit
537dff5687
3 измененных файлов с 137 добавлено и 8 удалено
  1. 4 1
      app.py
  2. 1 0
      requirements.txt
  3. 132 7
      templates/tree.html

+ 4 - 1
app.py

@@ -59,7 +59,10 @@ def compress_image_if_needed(file_path, max_dim=2000):
 
 # 尝试使用数据库连接池,如果不可用则使用普通连接
 try:
-    from DBUtils.PooledDB import PooledDB
+    try:
+        from dbutils.pooled_db import PooledDB   # dbutils >= 2.0(新包名)
+    except ImportError:
+        from DBUtils.PooledDB import PooledDB    # DBUtils <= 1.x(旧包名)
     # 创建连接池
     pool = PooledDB(
         creator=pymysql,

+ 1 - 0
requirements.txt

@@ -5,3 +5,4 @@ Pillow==8.3.1
 pytesseract==0.3.8
 Werkzeug==2.0.1
 zhconv==1.4.3
+dbutils>=2.0

+ 132 - 7
templates/tree.html

@@ -131,6 +131,27 @@
     .context-menu-item i {
         margin-right: 8px;
     }
+
+    /* 折叠/展开 +/- 小圆钮 */
+    .collapse-toggle {
+        cursor: pointer;
+    }
+    .collapse-toggle circle {
+        transition: fill 0.2s, transform 0.2s;
+    }
+    .collapse-toggle:hover circle {
+        opacity: 0.85;
+    }
+    .collapse-toggle text {
+        pointer-events: none;
+        user-select: none;
+    }
+    /* 节点被折叠时降低透明度以区分 */
+    .node-collapsed > rect,
+    .node-collapsed > circle {
+        opacity: 0.75;
+        stroke-dasharray: 5, 3;
+    }
 </style>
 {% endblock %}
 
@@ -144,6 +165,9 @@
                 <i class="bi bi-search"></i> 搜索
             </button>
         </div>
+        <button class="btn btn-outline-secondary btn-sm" onclick="expandAll()" title="展开所有已折叠节点">
+            <i class="bi bi-arrows-expand"></i> 全部展开
+        </button>
         <a href="{{ url_for('tree_classic') }}" class="btn btn-outline-primary btn-sm">
             <i class="bi bi-printer"></i> 导出传统吊线图
         </a>
@@ -166,6 +190,7 @@
             <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 class="context-menu-item" id="collapseMenuItem" onclick="menuAction('collapse')"><i class="bi bi-arrows-collapse"></i>收起子节点</div>
         </div>
     </div>
 
@@ -241,6 +266,7 @@
     let dragSource = null;
     let dragTarget = null;
     let selectedMid = null; // 当前选中的成员 ID
+    let collapsedNodes = new Set(); // 已折叠的节点 ID 集合
     let zoomScale = 1;
     let zoomTransform = d3.zoomIdentity;
     let zoomBehavior = d3.zoom().scaleExtent([0.1, 4]).on("zoom", zoomed);
@@ -252,6 +278,12 @@
         contextMenu.style.display = 'none';
     });
 
+    // 全部展开
+    function expandAll() {
+        collapsedNodes.clear();
+        renderTree(currentData);
+    }
+
     // 处理菜单点击
     function menuAction(type) {
         if (!selectedMid && type !== 'add') return;
@@ -266,6 +298,17 @@
             case 'add':
                 window.location.href = '/manager/add_member';
                 break;
+            case 'collapse': {
+                const savedTr = zoomTransform;
+                if (collapsedNodes.has(selectedMid)) {
+                    collapsedNodes.delete(selectedMid);
+                } else {
+                    collapsedNodes.add(selectedMid);
+                }
+                renderTree(currentData);
+                d3.select("#tree-container svg").call(zoomBehavior.transform, savedTr);
+                break;
+            }
         }
     }
 
@@ -340,10 +383,15 @@
             if (!node || processedNodes.has(nodeId)) return null;
             processedNodes.add(nodeId);
 
-            const children = hierarchicalLinks.filter(l => l.source === nodeId)
-                                .map(l => buildHierarchy(l.target, processedNodes))
-                                .filter(c => c !== null);
-            
+            const childLinks = hierarchicalLinks.filter(l => l.source === nodeId);
+            const hasHierarchicalChildren = childLinks.length > 0;
+            const isCollapsed = collapsedNodes.has(nodeId);
+
+            // 折叠时不递归子代(但仍保留配偶以维持同层显示)
+            const children = isCollapsed
+                ? []
+                : childLinks.map(l => buildHierarchy(l.target, processedNodes)).filter(c => c !== null);
+
             const spouses = spouseLinks.filter(l => l.parent_mid === nodeId)
                                 .map(l => {
                                     const spouseId = l.child_mid;
@@ -358,7 +406,17 @@
                                 })
                                 .filter(s => s !== null);
 
-            return { id: node.id, name: node.name, simplified_name: node.simplified_name, sex: node.sex, children: spouses.concat(children) };
+            return {
+                id: node.id,
+                name: node.name,
+                simplified_name: node.simplified_name,
+                sex: node.sex,
+                adoptedOut: node.adoptedOut,
+                adoptedOutTarget: node.adoptedOutTarget,
+                hasHierarchicalChildren,
+                isCollapsed,
+                children: spouses.concat(children)
+            };
         }
 
         let treeData;
@@ -481,7 +539,8 @@
             
             // 如果有配偶,子代主线从本人和最右侧配偶的中间引出,否则从自己直接引出
             const startX = spouses.length > 0 ? (node.x + spouses[spouses.length - 1].x) / 2 : node.x;
-            const startY = node.y + circleR;
+            // 若节点有折叠按钮(底部),连线从按钮下方起始
+            const startY = node.y + circleR + (node.data.hasHierarchicalChildren ? 28 : 0);
             
             // 将子女横线也相应地下移一些,避免和配偶长名字重叠
             const sibsY = childrenY - 60; 
@@ -546,6 +605,21 @@
                 if (!d.data.id) return;
                 event.preventDefault();
                 selectedMid = d.data.id;
+                // 动态更新折叠菜单项文字与可见性
+                const collapseItem = document.getElementById('collapseMenuItem');
+                if (d.data.hasHierarchicalChildren) {
+                    collapseItem.style.display = '';
+                    const icon = collapseItem.querySelector('i');
+                    if (collapsedNodes.has(d.data.id)) {
+                        icon.className = 'bi bi-arrows-expand';
+                        collapseItem.childNodes[1].textContent = '展开子节点';
+                    } else {
+                        icon.className = 'bi bi-arrows-collapse';
+                        collapseItem.childNodes[1].textContent = '收起子节点';
+                    }
+                } else {
+                    collapseItem.style.display = 'none';
+                }
                 const containerRect = document.getElementById('tree-container').getBoundingClientRect();
                 contextMenu.style.display = 'block';
                 contextMenu.style.left = (event.clientX - containerRect.left) + 'px';
@@ -572,7 +646,9 @@
         });
         
         // 人名:往下移动更多,避免和长方形/圆形图形或者连线重叠
-        const nameOffsetY = circleR + 25; // 增加间距
+        // 有折叠按钮的节点多留出按钮空间(按钮中心在 y=circleR,高度约 20px,再留 8px 间距)
+        // circleR(20) + 按钮中心(12) + 按钮半径(11) + 间距(6) = 49
+        const nameOffsetY = circleR + 49;
         const maxNameLen = 12; // 允许稍微长一点的文字
         function fullName(d) {
             if (!d.data) return '';
@@ -601,6 +677,55 @@
             }
         });
 
+        // 标记折叠节点样式
+        node.each(function(d) {
+            if (d.data.isCollapsed) d3.select(this).classed("node-collapsed", true);
+        });
+
+        // ── 折叠/展开按钮 ──────────────────────────────────────────────────
+        // 直接追加到 SVG 坐标层(非节点 g 内部),彻底脱离 drag 事件树,确保可见可点
+        nodesHier.descendants().forEach(function(nd) {
+            if (!nd.data.id || !nd.data.hasHierarchicalChildren) return;
+
+            const collapsed = nd.data.isCollapsed;
+            const bx = nd.x;
+            const by = nd.y + circleR + 14;   // 形状底边再往下 14px
+
+            const tg = svg.append("g")
+                .attr("class", "collapse-toggle")
+                .attr("transform", `translate(${bx},${by})`)
+                .style("cursor", "pointer");
+
+            tg.on("mousedown", function(e) { e.stopPropagation(); });
+            tg.on("click", function(e) {
+                e.stopPropagation();
+                const savedTransform = zoomTransform;   // 保存当前视图位置
+                if (collapsedNodes.has(nd.data.id)) {
+                    collapsedNodes.delete(nd.data.id);
+                } else {
+                    collapsedNodes.add(nd.data.id);
+                }
+                renderTree(currentData);
+                // 重建后立即还原 zoom 位置,不触发动画
+                d3.select("#tree-container svg").call(zoomBehavior.transform, savedTransform);
+            });
+
+            tg.append("title").text(collapsed ? "展开子节点" : "收起子节点");
+            // 白色底圆(防止线条透底)
+            tg.append("circle").attr("r", 11).attr("fill", "white");
+            // 彩色实心圆:展开=蓝,折叠=绿
+            tg.append("circle")
+                .attr("r", 10)
+                .attr("fill", collapsed ? "#10B981" : "#3B82F6")
+                .attr("stroke", "white").attr("stroke-width", 1.5);
+            // 符号文字
+            tg.append("text")
+                .attr("text-anchor", "middle").attr("dy", "0.38em")
+                .attr("fill", "white").attr("font-size", "16px").attr("font-weight", "bold")
+                .attr("pointer-events", "none")
+                .text(collapsed ? "+" : "−");
+        });
+
         function dragstarted(event, d) {
             if (!d.data.id) return;
             d3.select(this).raise().classed("active", true);