Selaa lähdekoodia

commit 优化若干问题,新增世系查询

Hai Lin 1 viikko sitten
vanhempi
commit
f143a98807
6 muutettua tiedostoa jossa 1045 lisäystä ja 20 poistoa
  1. 57 0
      add_super_admin.py
  2. 191 0
      app.py
  3. 32 8
      templates/add_member.html
  4. 101 0
      templates/layout.html
  5. 649 0
      templates/lineage_query.html
  6. 15 12
      test_api.py

+ 57 - 0
add_super_admin.py

@@ -0,0 +1,57 @@
+import pymysql
+
+db_host = "rm-f8ze60yirdj8786u2wo.mysql.rds.aliyuncs.com"
+db_user = "root"
+db_pass = "csqz@20255"
+db_name = "csqz-client"
+
+def add_super_admin_column():
+    try:
+        conn = pymysql.connect(
+            host=db_host,
+            user=db_user,
+            password=db_pass,
+            db=db_name,
+            port=3306,
+            charset='utf8mb4'
+        )
+        cur = conn.cursor()
+        
+        # Check if column exists
+        cur.execute("""
+            SELECT COLUMN_NAME 
+            FROM INFORMATION_SCHEMA.COLUMNS 
+            WHERE TABLE_NAME = 'users' AND COLUMN_NAME = 'is_super_admin'
+        """)
+        if cur.fetchone():
+            print("Column 'is_super_admin' already exists")
+        else:
+            # Add is_super_admin column with default 0
+            cur.execute("""
+                ALTER TABLE users 
+                ADD COLUMN is_super_admin TINYINT(1) DEFAULT 0 NOT NULL
+            """)
+            print("Added column 'is_super_admin'")
+        
+        # Set lihai and liuyue as super admins
+        cur.execute("UPDATE users SET is_super_admin = 1 WHERE username IN ('linhai', 'liuyue')")
+        affected = cur.rowcount
+        print(f"Updated {affected} users as super admins")
+        
+        conn.commit()
+        print("Operation complete.")
+        return True
+        
+    except Exception as e:
+        print(f"Error: {e}")
+        return False
+    finally:
+        if 'conn' in locals() and conn.open:
+            conn.close()
+
+if __name__ == "__main__":
+    if add_super_admin_column():
+        print("SUCCESS")
+    else:
+        import sys
+        sys.exit(1)

+ 191 - 0
app.py

@@ -634,6 +634,29 @@ def ensure_pdf_table():
 def pdf_management():
     if 'user_id' not in session:
         return redirect(url_for('login'))
+    
+    username = session.get('username', 'unknown')
+    is_super_admin = session.get('is_super_admin', 'NOT_SET')
+    
+    print(f"[PDF Management Access] User: {username}, is_super_admin: {is_super_admin}")
+    
+    # Verify is_super_admin against database - always check latest status
+    conn = get_db_connection()
+    try:
+        with conn.cursor() as cursor:
+            cursor.execute("SELECT is_super_admin FROM users WHERE id = %s", (session['user_id'],))
+            db_result = cursor.fetchone()
+            db_is_super = db_result['is_super_admin'] if db_result else 0
+            print(f"[PDF Management Access] DB is_super_admin: {db_is_super}")
+            
+            if not db_is_super:
+                print(f"[PDF Management Access] Denied for {username} (DB check)")
+                flash('无权限访问此页面')
+                return redirect(url_for('home'))
+    finally:
+        conn.close()
+    
+    print(f"[PDF Management Access] Allowed for {username}")
 
     ensure_pdf_table()
     view_id = request.args.get('view', type=int)
@@ -1147,6 +1170,12 @@ def tree():
         return redirect(url_for('login'))
     return render_template('tree.html')
 
+@app.route('/manager/lineage_query')
+def lineage_query():
+    if 'user_id' not in session:
+        return redirect(url_for('login'))
+    return render_template('lineage_query.html')
+
 @app.route('/manager/tree_classic')
 def tree_classic():
     if 'user_id' not in session:
@@ -1172,6 +1201,161 @@ def tree_data():
     finally:
         conn.close()
 
+@app.route('/manager/api/search_member', methods=['POST'])
+def search_member():
+    if 'user_id' not in session:
+        return jsonify({"success": False, "message": "Unauthorized"}), 401
+    
+    data = request.get_json()
+    keyword = data.get('keyword', '').strip()
+    
+    if not keyword:
+        return jsonify({"success": False, "message": "请输入搜索关键词"})
+    
+    conn = get_db_connection()
+    try:
+        with conn.cursor() as cursor:
+            cursor.execute("""
+                SELECT id, name, simplified_name 
+                FROM family_member_info 
+                WHERE name LIKE %s OR simplified_name LIKE %s OR former_name LIKE %s
+                ORDER BY 
+                    CASE WHEN name = %s THEN 1 
+                         WHEN simplified_name = %s THEN 2 
+                         WHEN name LIKE %s THEN 3 
+                         WHEN simplified_name LIKE %s THEN 4 
+                         ELSE 5 END
+            """, (f'%{keyword}%', f'%{keyword}%', f'%{keyword}%', keyword, keyword, f'{keyword}%', f'{keyword}%'))
+            members = cursor.fetchall()
+            
+            if members:
+                return jsonify({"success": True, "members": members})
+            else:
+                return jsonify({"success": False, "message": "未找到匹配的成员"})
+    finally:
+        conn.close()
+
+@app.route('/manager/api/get_lineage/<int:member_id>')
+def get_lineage(member_id):
+    if 'user_id' not in session:
+        return jsonify({"success": False, "message": "Unauthorized"}), 401
+    
+    import time
+    start_time = time.time()
+    print(f"[Lineage Query] Starting query for member_id: {member_id} at {time.strftime('%Y-%m-%d %H:%M:%S')}")
+    
+    conn = get_db_connection()
+    try:
+        with conn.cursor() as cursor:
+            # Step 1: Get center person
+            step_start = time.time()
+            cursor.execute("SELECT id, name, simplified_name, name_word, name_word_generation FROM family_member_info WHERE id = %s", (member_id,))
+            center = cursor.fetchone()
+            print(f"[Lineage Query] Step 1 - Get center: {time.time() - step_start:.3f}s")
+            
+            if not center:
+                return jsonify({"success": False, "message": "成员不存在"})
+            
+            # Step 2: Get ancestors (simple loop, limited depth)
+            step_start = time.time()
+            ancestors = []
+            current_id = member_id
+            max_depth = 15
+            for _ 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
+                    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)
+                    LIMIT 1
+                """, (current_id,))
+                parent = cursor.fetchone()
+                if not parent:
+                    break
+                ancestors.append(parent)
+                current_id = parent['id']
+            print(f"[Lineage Query] Step 2 - Get ancestors ({len(ancestors)}): {time.time() - step_start:.3f}s")
+            
+            # Step 3: Get immediate children only (limited count)
+            step_start = time.time()
+            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
+                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)
+                ORDER BY c.id
+                LIMIT 30
+            """, (member_id,))
+            children = cursor.fetchall()
+            
+            # Initialize children array
+            for child in children:
+                child['children'] = []
+            print(f"[Lineage Query] Step 3 - Get children ({len(children)}): {time.time() - step_start:.3f}s")
+            
+            # Step 4: Get siblings (brothers and sisters)
+            step_start = time.time()
+            siblings = []
+            if ancestors:
+                parent_id = ancestors[0]['id']  # First ancestor is the father
+                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
+                    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 c.id
+                    LIMIT 30
+                """, (parent_id, member_id))
+                siblings = cursor.fetchall()
+            print(f"[Lineage Query] Step 4 - Get siblings ({len(siblings)}): {time.time() - step_start:.3f}s")
+            
+            total_time = time.time() - start_time
+            print(f"[Lineage Query] Total time: {total_time:.3f}s")
+            
+            return jsonify({
+                "success": True,
+                "data": {
+                    "center": center,
+                    "ancestors": ancestors,
+                    "siblings": siblings,
+                    "children": children
+                }
+            })
+    except Exception as e:
+        print(f"[Lineage Query] Error: {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:
+        return jsonify({"success": False, "message": "Unauthorized"}), 401
+    
+    conn = get_db_connection()
+    try:
+        with conn.cursor() as cursor:
+            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
+                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)
+                ORDER BY c.id
+                LIMIT 20
+            """, (parent_id,))
+            children = cursor.fetchall()
+            
+            for child in children:
+                child['children'] = []
+            
+            return jsonify({"success": True, "children": children})
+    finally:
+        conn.close()
+
 @app.route('/manager/api/save_relation', methods=['POST'])
 def save_relation():
     if 'user_id' not in session:
@@ -1843,6 +2027,12 @@ def home():
     if 'user_id' not in session:
         return redirect(url_for('login'))
     
+    # Force re-login if is_super_admin not set in session (fresh login required)
+    if 'is_super_admin' not in session:
+        session.clear()
+        flash('请重新登录以获取最新权限')
+        return redirect(url_for('login'))
+    
     conn = get_db_connection()
     try:
         with conn.cursor() as cursor:
@@ -1885,6 +2075,7 @@ def login():
                     if user:
                         session['user_id'] = user['id']
                         session['username'] = user['username']
+                        session['is_super_admin'] = user.get('is_super_admin', 0) == 1
                         return redirect(url_for('home'))
                     else:
                         flash('用户名或密码错误')

+ 32 - 8
templates/add_member.html

@@ -847,34 +847,54 @@
             submitBtn.disabled = true;
             submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 保存中...';
             
+            // Create loading overlay
+            const loadingOverlay = document.createElement('div');
+            loadingOverlay.className = 'position-fixed inset-0 bg-black/50 flex items-center justify-center z-50';
+            loadingOverlay.innerHTML = `
+                <div class="text-center text-white">
+                    <div class="spinner-border text-yellow-400" style="width: 3rem; height: 3rem;" role="status">
+                        <span class="visually-hidden">Loading...</span>
+                    </div>
+                    <p class="mt-3 text-lg">正在保存,请稍候...</p>
+                </div>
+            `;
+            document.body.appendChild(loadingOverlay);
+            
             try {
-                // Use form.action to support both add and edit URLs
-                const targetUrl = form.action || window.location.href;
+                // Determine target URL based on mode
+                const isEditMode = {{ 'true' if member else 'false' }};
+                const targetUrl = isEditMode ? window.location.href : '/manager/add_member';
+                console.log('[Add Member] Submitting to:', targetUrl);
+                
                 const response = await fetch(targetUrl, {
                     method: 'POST',
                     body: formData,
                     headers: {
                         'X-Requested-With': 'XMLHttpRequest'
                     },
-                    credentials: 'include'
+                    credentials: 'include',
+                    timeout: 60000 // 60秒超时
                 });
                 
-                console.log('Response status:', response.status);
+                console.log('[Add Member] Response status:', response.status);
                 
                 if (!response.ok) {
                     throw new Error(`HTTP error! status: ${response.status}`);
                 }
                 
                 const result = await response.json();
-                console.log('Response data:', result);
+                console.log('[Add Member] Response data:', result);
                 
                 if (result.success) {
+                    // Remove loading overlay
+                    loadingOverlay.remove();
+                    
                     // Success!
                     // 1. Show a toast or small alert
                     const toast = document.createElement('div');
-                    toast.className = 'position-fixed bottom-0 start-50 translate-middle-x mb-4 p-3 bg-success text-white rounded shadow';
+                    toast.className = 'position-fixed bottom-0 start-50 translate-middle-x mb-4 p-4 bg-success text-white rounded-lg shadow-lg';
                     toast.style.zIndex = '2000';
-                    toast.innerHTML = `<i class="bi bi-check-circle me-2"></i> ${result.message}`;
+                    toast.innerHTML = `<i class="bi bi-check-circle me-2"></i> ${result.message || '保存成功!'}`;
                     document.body.appendChild(toast);
                     setTimeout(() => toast.remove(), 3000);
                     
@@ -999,10 +1019,14 @@
                     }
 
                 } else {
+                    // Remove loading overlay
+                    loadingOverlay.remove();
                     alert('保存失败: ' + result.message);
                 }
             } catch (error) {
-                console.error('Error submitting form:', error);
+                // Remove loading overlay
+                loadingOverlay.remove();
+                console.error('[Add Member] Error submitting form:', error);
                 alert('网络或服务器错误,请稍后重试: ' + error.message);
             } finally {
                 submitBtn.disabled = false;

+ 101 - 0
templates/layout.html

@@ -15,6 +15,41 @@
         .sidebar a:hover { background-color: #495057; color: white; }
         .sidebar a.active { background-color: #0d6efd; color: white; }
         .content-area { padding: 20px; }
+        
+        /* Watermark styles */
+        .watermark-container {
+            position: fixed;
+            top: 0;
+            left: 0;
+            width: 100%;
+            height: 100%;
+            pointer-events: none;
+            z-index: 9999;
+            overflow: hidden;
+            opacity: 0.08;
+        }
+        
+        .watermark-text {
+            position: absolute;
+            font-size: 18px;
+            color: #999;
+            transform: rotate(-15deg);
+            white-space: nowrap;
+            font-weight: 500;
+            letter-spacing: 2px;
+        }
+        
+        .watermark-corner {
+            position: fixed;
+            bottom: 15px;
+            right: 15px;
+            font-size: 12px;
+            color: #999;
+            opacity: 0.4;
+            text-align: right;
+            z-index: 1000;
+            pointer-events: none;
+        }
     </style>
     {% block extra_css %}{% endblock %}
 </head>
@@ -31,9 +66,11 @@
                     <a href="{{ url_for('home') }}" class="{% if request.endpoint == 'home' %}active{% endif %}">
                         <i class="bi bi-house me-2"></i> 系统首页
                     </a>
+                    {% if session.get('is_super_admin') %}
                     <a href="{{ url_for('pdf_management') }}" class="{% if request.endpoint == 'pdf_management' %}active{% endif %}">
                         <i class="bi bi-book me-2"></i> 家谱管理
                     </a>
+                    {% endif %}
                     <a href="{{ url_for('index') }}" class="{% if request.endpoint == 'index' %}active{% endif %}">
                         <i class="bi bi-file-earmark-arrow-up me-2"></i> 扫描件管理
                     </a>
@@ -43,6 +80,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('lineage_query') }}" class="{% if request.endpoint == 'lineage_query' %}active{% endif %}">
+                        <i class="bi bi-tree me-2"></i> 世系查询
+                    </a>
                     <div class="mt-5 border-top pt-3">
                         <p class="px-3 small text-muted">用户: {{ session['username'] }}</p>
                         <a href="{{ url_for('logout') }}" class="text-danger">
@@ -53,6 +93,10 @@
             </div>
             {% endif %}
 
+            <!-- Watermark -->
+            <div class="watermark-container" id="watermarkContainer"></div>
+            <div class="watermark-corner" id="watermarkCorner"></div>
+            
             <!-- Main Content -->
             <div class="col-md-{% if session.get('user_id') %}10{% else %}12{% endif %} content-area">
                 {% with messages = get_flashed_messages() %}
@@ -72,6 +116,63 @@
     </div>
     <!-- Local Bootstrap JS -->
     <script src="{{ url_for('static', filename='js/bootstrap.bundle.min.js') }}"></script>
+    
+    <!-- Watermark Script -->
+    <script>
+    document.addEventListener('DOMContentLoaded', function() {
+        const username = "{{ session.get('username', '') }}";
+        const container = document.getElementById('watermarkContainer');
+        const corner = document.getElementById('watermarkCorner');
+        
+        function formatWatermarkText() {
+            if (!username) return '';
+            const now = new Date();
+            const year = now.getFullYear();
+            const month = String(now.getMonth() + 1).padStart(2, '0');
+            const day = String(now.getDate()).padStart(2, '0');
+            const hour = String(now.getHours()).padStart(2, '0');
+            const minute = String(now.getMinutes()).padStart(2, '0');
+            return `${username}_${year}${month}${day}_${hour}${minute}`;
+        }
+        
+        // Generate tiled watermark
+        if (container && username) {
+            const cols = 6;
+            const rows = 8;
+            const cellWidth = window.innerWidth / cols;
+            const cellHeight = window.innerHeight / rows;
+            
+            function updateTiledWatermark() {
+                container.innerHTML = '';
+                const watermarkText = formatWatermarkText();
+                for (let i = 0; i < rows; i++) {
+                    for (let j = 0; j < cols; j++) {
+                        const span = document.createElement('span');
+                        span.className = 'watermark-text';
+                        span.textContent = watermarkText;
+                        span.style.left = (j * cellWidth + 20) + 'px';
+                        span.style.top = (i * cellHeight + 20) + 'px';
+                        container.appendChild(span);
+                    }
+                }
+            }
+            
+            updateTiledWatermark();
+            setInterval(updateTiledWatermark, 60000);
+        }
+        
+        // Update corner watermark
+        function updateCornerWatermark() {
+            if (corner && username) {
+                corner.textContent = formatWatermarkText();
+            }
+        }
+        
+        updateCornerWatermark();
+        setInterval(updateCornerWatermark, 1000);
+    });
+    </script>
+    
     {% block extra_js %}{% endblock %}
 </body>
 </html>

+ 649 - 0
templates/lineage_query.html

@@ -0,0 +1,649 @@
+{% extends "layout.html" %}
+
+{% block title %}世系查询 - 家谱管理系统{% endblock %}
+
+{% block extra_css %}
+<style>
+    .empty-state {
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        justify-content: center;
+        height: 60vh;
+        color: rgba(255,255,255,0.5);
+    }
+    
+    .tree-container {
+        padding: 20px;
+        min-height: 60vh;
+        background: #1a1a2e;
+        border-radius: 12px;
+        border: 1px solid rgba(255,215,0,0.2);
+    }
+    
+    /* Tree node styles */
+    .tree-wrapper {
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        padding: 20px 0;
+    }
+    
+    .tree-node {
+        background: linear-gradient(135deg, #2d3436, #1e272e);
+        border: 2px solid #4a69bd;
+        border-radius: 8px;
+        padding: 12px 24px;
+        margin: 10px 0;
+        text-align: center;
+        min-width: 160px;
+        transition: all 0.3s;
+        cursor: pointer;
+        box-shadow: 0 4px 15px rgba(0,0,0,0.3);
+    }
+    
+    .tree-node:hover {
+        background: linear-gradient(135deg, #4a69bd, #2d3436);
+        transform: scale(1.05);
+        box-shadow: 0 6px 20px rgba(74, 105, 189, 0.4);
+    }
+    
+    .tree-node.center {
+        background: linear-gradient(135deg, #ffd700, #ff8c00);
+        border-color: #ffd700;
+        box-shadow: 0 0 30px rgba(255,215,0,0.5);
+    }
+    
+    .tree-node.center .node-name {
+        color: #1a1a2e !important;
+    }
+    
+    .tree-node.center .node-name.simplified {
+        color: rgba(26,26,46,0.8) !important;
+    }
+    
+    .tree-node.center .node-info {
+        color: rgba(26,26,46,0.8) !important;
+    }
+    
+    .node-name {
+        font-size: 18px;
+        font-weight: 700;
+        color: #fff;
+        margin-bottom: 4px;
+    }
+    
+    .node-name.simplified {
+        font-size: 13px;
+        color: rgba(255,255,255,0.7);
+    }
+    
+    .node-info {
+        font-size: 12px;
+        color: rgba(255,255,255,0.8);
+        font-weight: 500;
+    }
+    
+    /* Connection lines */
+    .connection-line {
+        width: 4px;
+        height: 40px;
+        background: linear-gradient(to bottom, #4a69bd, #2d3436);
+        margin: 0 auto;
+        border-radius: 2px;
+    }
+    
+    .connection-line.horizontal {
+        width: 60px;
+        height: 4px;
+        margin: 8px auto;
+        background: linear-gradient(to right, #4a69bd, #2d3436);
+    }
+    
+    /* Children container */
+    .children-container {
+        display: flex;
+        flex-wrap: wrap;
+        justify-content: center;
+        gap: 30px;
+        margin-top: 20px;
+    }
+    
+    .child-group {
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+    }
+    
+    /* Expand/Collapse button */
+    .expand-btn {
+        background: linear-gradient(135deg, #4a69bd, #2d3436);
+        border: 2px solid #4a69bd;
+        border-radius: 50%;
+        width: 36px;
+        height: 36px;
+        color: #fff;
+        font-size: 18px;
+        font-weight: bold;
+        cursor: pointer;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        margin: 15px auto;
+        transition: all 0.3s;
+        box-shadow: 0 2px 10px rgba(0,0,0,0.3);
+    }
+    
+    .expand-btn:hover {
+        background: linear-gradient(135deg, #ffd700, #ff8c00);
+        border-color: #ffd700;
+        transform: rotate(90deg);
+        box-shadow: 0 4px 15px rgba(255,215,0,0.4);
+    }
+    
+    /* Generation label */
+    .generation-label {
+        color: #ffd700;
+        font-size: 16px;
+        font-weight: 700;
+        margin-bottom: 20px;
+        text-align: center;
+        padding: 10px 20px;
+        background: rgba(255,215,0,0.1);
+        border: 1px solid rgba(255,215,0,0.3);
+        border-radius: 25px;
+        display: inline-block;
+    }
+    
+    /* Search box */
+    .search-box {
+        max-width: 350px;
+    }
+    
+    /* Section styling */
+    .ancestors-section, .children-section {
+        margin: 30px 0;
+    }
+    
+    /* Header styling */
+    .page-header {
+        color: #ffd700;
+        font-size: 24px;
+        font-weight: 700;
+        text-shadow: 0 0 10px rgba(255,215,0,0.3);
+    }
+</style>
+{% endblock %}
+
+{% block content %}
+<div class="container-fluid">
+    <!-- Header -->
+    <div class="row mb-6">
+        <div class="col-md-12">
+            <div class="d-flex justify-content-between align-items-center">
+                <h2 class="page-header">世系查询</h2>
+                <div class="search-box">
+                    <div class="input-group">
+                        <input type="text" id="searchInput" class="form-control" placeholder="搜索成员姓名(支持简繁)" />
+                        <button class="btn btn-primary" onclick="searchMember()">
+                            <i class="bi bi-search"></i>
+                        </button>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+    
+    <!-- Main Content -->
+    <div class="row">
+        <div class="col-md-12">
+            <div id="treeContent">
+                <!-- Empty State -->
+                <div class="empty-state" id="emptyState">
+                    <i class="bi bi-search text-6xl mb-4"></i>
+                    <p class="text-lg">请在右上角搜索框中输入成员姓名</p>
+                    <p class="text-sm mt-2">支持简体和繁体姓名搜索</p>
+                </div>
+                
+                <!-- Tree View -->
+                <div id="treeView" style="display: none;" class="tree-container">
+                    <!-- Ancestors Section -->
+                    <div class="ancestors-section" id="ancestorsTree"></div>
+                    
+                    <!-- Siblings and Center Person Section -->
+                    <div class="siblings-section" id="siblingsTree"></div>
+                    
+                    <!-- Children Section -->
+                    <div class="children-section" id="childrenTree"></div>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+
+<script>
+// Search member
+async function searchMember() {
+    const keyword = document.getElementById('searchInput').value.trim();
+    if (!keyword) {
+        alert('请输入搜索关键词');
+        return;
+    }
+    
+    // Show loading state
+    const searchBtn = document.querySelector('.search-box button');
+    const originalBtnHtml = searchBtn.innerHTML;
+    searchBtn.disabled = true;
+    searchBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>';
+    
+    try {
+        const response = await fetch('/manager/api/search_member', {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify({ keyword: keyword }),
+            credentials: 'include'
+        });
+        
+        const result = await response.json();
+        if (result.success && result.members) {
+            if (result.members.length === 1) {
+                // Only one match, load directly
+                document.getElementById('emptyState').style.display = 'none';
+                document.getElementById('treeView').style.display = 'block';
+                await loadLineage(result.members[0].id);
+            } else {
+                // Multiple matches, show selection dialog
+                showMemberSelection(result.members);
+            }
+        } else {
+            alert('未找到匹配的成员');
+        }
+    } finally {
+        // Restore button
+        searchBtn.disabled = false;
+        searchBtn.innerHTML = originalBtnHtml;
+    }
+}
+
+// Show member selection dialog
+function showMemberSelection(members) {
+    // Remove existing dialog
+    const existingDialog = document.getElementById('memberSelectionDialog');
+    if (existingDialog) {
+        existingDialog.remove();
+    }
+    
+    // Store members in sessionStorage
+    sessionStorage.setItem('searchMembers', JSON.stringify(members));
+    
+    // Create dialog
+    const dialog = document.createElement('div');
+    dialog.id = 'memberSelectionDialog';
+    dialog.style.cssText = `
+        position: fixed;
+        top: 50%;
+        left: 50%;
+        transform: translate(-50%, -50%);
+        background: #1a1a2e;
+        border: 2px solid #4a69bd;
+        border-radius: 12px;
+        padding: 24px;
+        z-index: 10000;
+        min-width: 400px;
+        box-shadow: 0 10px 40px rgba(0,0,0,0.5);
+    `;
+    
+    // Dialog content
+    dialog.innerHTML = `
+        <h3 style="color: #ffd700; margin-bottom: 16px; text-align: center;">找到多个匹配成员,请输入编号选择:</h3>
+        <div style="margin-bottom: 16px; max-height: 200px; overflow-y: auto;">
+            ${members.map((member, index) => `
+                <div style="padding: 10px; border-bottom: 1px solid rgba(255,255,255,0.1);">
+                    <span style="color: #ffd700; margin-right: 10px;">${index + 1}.</span>
+                    <span style="color: #fff; font-weight: 600;">${member.name}</span>
+                    ${member.simplified_name && member.simplified_name !== member.name ? 
+                        `<span style="color: rgba(255,255,255,0.7);">(${member.simplified_name})</span>` : ''}
+                </div>
+            `).join('')}
+        </div>
+        <input type="text" id="selectionInput" 
+               placeholder="请输入编号选择(1-${members.length})"
+               style="width: 100%; padding: 10px; margin-bottom: 16px; 
+                      background: #2d3436; border: 1px solid #4a69bd; 
+                      border-radius: 8px; color: #fff; text-align: center;
+                      font-size: 16px;" />
+        <div style="display: flex; justify-content: center; gap: 16px;">
+            <button onclick="selectMember()" 
+                    style="padding: 10px 30px; background: linear-gradient(135deg, #4a69bd, #2d3436); 
+                           border: 2px solid #4a69bd; border-radius: 8px; color: #fff;
+                           cursor: pointer; font-weight: 600;">确定</button>
+            <button onclick="closeSelectionDialog()" 
+                    style="padding: 10px 30px; background: #2d3436; 
+                           border: 2px solid #666; border-radius: 8px; color: #aaa;
+                           cursor: pointer;">取消</button>
+        </div>
+    `;
+    
+    // Add overlay
+    const overlay = document.createElement('div');
+    overlay.id = 'dialogOverlay';
+    overlay.style.cssText = `
+        position: fixed;
+        top: 0;
+        left: 0;
+        width: 100%;
+        height: 100%;
+        background: rgba(0,0,0,0.7);
+        z-index: 9999;
+    `;
+    overlay.onclick = closeSelectionDialog;
+    
+    document.body.appendChild(overlay);
+    document.body.appendChild(dialog);
+    
+    // Focus input
+    document.getElementById('selectionInput').focus();
+    
+    // Enter key
+    document.getElementById('selectionInput').addEventListener('keypress', function(e) {
+        if (e.key === 'Enter') {
+            selectMember();
+        }
+    });
+}
+
+// Close selection dialog
+function closeSelectionDialog() {
+    document.getElementById('memberSelectionDialog')?.remove();
+    document.getElementById('dialogOverlay')?.remove();
+}
+
+// Select member
+function selectMember() {
+    const input = document.getElementById('selectionInput');
+    const index = parseInt(input.value) - 1;
+    
+    const membersStr = sessionStorage.getItem('searchMembers');
+    if (!membersStr) {
+        alert('数据错误,请重新搜索');
+        closeSelectionDialog();
+        return;
+    }
+    
+    const members = JSON.parse(membersStr);
+    
+    if (index >= 0 && index < members.length) {
+        // Load lineage for selected member
+        document.getElementById('emptyState').style.display = 'none';
+        document.getElementById('treeView').style.display = 'block';
+        loadLineage(members[index].id);
+        
+        // Close dialog
+        closeSelectionDialog();
+    } else {
+        alert(`请输入有效的编号(1-${members.length})`);
+    }
+}
+
+// Load lineage data
+async function loadLineage(memberId) {
+    // Show loading state - preserve container elements
+    document.getElementById('ancestorsTree').innerHTML = `
+        <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px;">
+            <div class="spinner-border text-yellow-400" style="width: 3rem; height: 3rem;" role="status">
+                <span class="visually-hidden">Loading...</span>
+            </div>
+            <p class="mt-4 text-white text-lg">正在加载祖先数据...</p>
+        </div>
+    `;
+    document.getElementById('siblingsTree').innerHTML = `
+        <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px;">
+            <div class="spinner-border text-yellow-400" style="width: 3rem; height: 3rem;" role="status">
+                <span class="visually-hidden">Loading...</span>
+            </div>
+            <p class="mt-4 text-white text-lg">正在加载同辈数据...</p>
+        </div>
+    `;
+    document.getElementById('childrenTree').innerHTML = `
+        <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px;">
+            <div class="spinner-border text-yellow-400" style="width: 3rem; height: 3rem;" role="status">
+                <span class="visually-hidden">Loading...</span>
+            </div>
+            <p class="mt-4 text-white text-lg">正在加载后代数据...</p>
+        </div>
+    `;
+    
+    try {
+        const response = await fetch(`/manager/api/get_lineage/${memberId}`, {
+            credentials: 'include'
+        });
+        
+        const result = await response.json();
+        if (result.success) {
+            console.log('Lineage data received:', result.data);
+            // Use requestAnimationFrame for smoother rendering
+            requestAnimationFrame(() => {
+                renderLineage(result.data);
+            });
+        } else {
+            console.error('API error:', result.message);
+            document.getElementById('ancestorsTree').innerHTML = '';
+            document.getElementById('siblingsTree').innerHTML = `
+                <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 200px;">
+                    <i class="bi bi-x-circle text-red-500 text-6xl mb-4"></i>
+                    <p class="text-white text-lg">加载失败</p>
+                    <p class="text-gray-400 text-sm">${result.message || '服务器错误,请稍后重试'}</p>
+                </div>
+            `;
+            document.getElementById('childrenTree').innerHTML = '';
+        }
+    } catch (error) {
+        console.error('Fetch error:', error);
+        document.getElementById('ancestorsTree').innerHTML = '';
+        document.getElementById('siblingsTree').innerHTML = `
+            <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 200px;">
+                <i class="bi bi-x-circle text-red-500 text-6xl mb-4"></i>
+                <p class="text-white text-lg">加载失败</p>
+                <p class="text-gray-400 text-sm">网络错误: ${error.message}</p>
+            </div>
+        `;
+        document.getElementById('childrenTree').innerHTML = '';
+    }
+}
+
+// Render lineage tree
+function renderLineage(data) {
+    console.log('Rendering lineage:', data);
+    
+    try {
+        const ancestorsHtml = renderAncestors(data.ancestors);
+        console.log('Ancestors HTML generated:', ancestorsHtml.length);
+        document.getElementById('ancestorsTree').innerHTML = ancestorsHtml;
+    } catch (e) {
+        console.error('Error rendering ancestors:', e);
+    }
+    
+    try {
+        const siblingsWithCenterHtml = renderSiblingsWithCenter(data.center, data.siblings);
+        console.log('Siblings HTML generated:', siblingsWithCenterHtml.length);
+        document.getElementById('siblingsTree').innerHTML = siblingsWithCenterHtml;
+    } catch (e) {
+        console.error('Error rendering siblings:', e);
+    }
+    
+    try {
+        const childrenHtml = renderChildrenTree(data.children);
+        console.log('Children HTML generated:', childrenHtml.length);
+        document.getElementById('childrenTree').innerHTML = childrenHtml;
+    } catch (e) {
+        console.error('Error rendering children:', e);
+    }
+}
+
+// Render ancestors
+function renderAncestors(ancestors) {
+    if (!ancestors || ancestors.length === 0) return '';
+    
+    let html = '<div class="text-center mb-6"><span class="generation-label">祖先谱系</span></div>';
+    html += '<div class="tree-wrapper">';
+    
+    ancestors.reverse().forEach((person, index) => {
+        if (index > 0) {
+            html += '<div class="connection-line"></div>';
+        }
+        html += renderTreeNode(person, false);
+        if (person.has_children) {
+            html += `
+                <button class="expand-btn" onclick="toggleChildren(this, ${person.id})">+</button>
+                <div class="children-container" style="display: none;" data-parent-id="${person.id}">
+                </div>
+            `;
+        }
+    });
+    
+    html += '</div>';
+    return html;
+}
+
+// Render center person
+function renderCenterPerson(person) {
+    return `
+        <div class="connection-line"></div>
+        <div class="tree-node center">
+            <div class="node-name">${person.name}</div>
+            ${person.simplified_name && person.simplified_name !== person.name ? `<div class="node-name simplified">(${person.simplified_name})</div>` : ''}
+            <div class="node-info">
+                ${person.name_word ? `${person.name_word} · ` : ''}
+                ${person.name_word_generation || ''}
+            </div>
+        </div>
+    `;
+}
+
+// Render siblings with center person in the middle
+function renderSiblingsWithCenter(center, siblings) {
+    const allSiblings = siblings || [];
+    
+    // Insert center person at the middle position
+    const middleIndex = Math.floor(allSiblings.length / 2);
+    const items = [];
+    
+    for (let i = 0; i < allSiblings.length; i++) {
+        if (i === middleIndex) {
+            items.push({ type: 'center', person: center });
+        }
+        items.push({ type: 'sibling', person: allSiblings[i] });
+    }
+    
+    // If no siblings, just show center
+    if (allSiblings.length === 0) {
+        items.push({ type: 'center', person: center });
+    }
+    
+    return `
+        <div class="text-center mt-6 mb-4"><span class="generation-label">同辈兄弟姐妹</span></div>
+        <div class="children-container">
+            ${items.map(item => `
+                <div class="child-group ${item.type === 'center' ? 'center-child' : ''}">
+                    <div class="connection-line horizontal"></div>
+                    ${renderTreeNode(item.person, item.type === 'center')}
+                    ${item.type !== 'center' && item.person.has_children ? `
+                        <button class="expand-btn" onclick="toggleChildren(this, ${item.person.id})">+</button>
+                        <div class="children-container" style="display: none;" data-parent-id="${item.person.id}">
+                        </div>
+                    ` : ''}
+                </div>
+            `).join('')}
+        </div>
+    `;
+}
+
+// Render children tree
+function renderChildrenTree(children) {
+    if (!children || children.length === 0) return '';
+    
+    return `
+        <div class="text-center mt-6 mb-4"><span class="generation-label">后代谱系</span></div>
+        ${renderChildrenRecursive(children)}
+    `;
+}
+
+// Render children recursively
+function renderChildrenRecursive(children, level = 0) {
+    if (!children || children.length === 0) return '';
+    
+    return `
+        <div class="children-container">
+            ${children.map(child => `
+                <div class="child-group">
+                    <div class="connection-line"></div>
+                    ${renderTreeNode(child, false)}
+                    ${child.has_children ? `
+                        <button class="expand-btn" onclick="toggleChildren(this, ${child.id})">+</button>
+                        <div class="children-container" style="display: none;" data-parent-id="${child.id}">
+                        </div>
+                    ` : ''}
+                </div>
+            `).join('')}
+        </div>
+    `;
+}
+
+// Render tree node
+function renderTreeNode(person, isCenter = false) {
+    return `
+        <div class="tree-node ${isCenter ? 'center' : ''}">
+            <div class="node-name">${person.name}</div>
+            ${person.simplified_name && person.simplified_name !== person.name ? `<div class="node-name simplified">(${person.simplified_name})</div>` : ''}
+            <div class="node-info">
+                ${person.name_word ? `${person.name_word} · ` : ''}
+                ${person.name_word_generation || ''}
+            </div>
+        </div>
+    `;
+}
+
+// Toggle children visibility with lazy loading
+async function toggleChildren(btn, parentId) {
+    const container = btn.nextElementSibling;
+    const isExpanded = container.style.display !== 'none';
+    
+    if (isExpanded) {
+        container.style.display = 'none';
+        btn.innerHTML = '+';
+    } else {
+        // Check if children are already loaded
+        if (container.innerHTML.trim() === '') {
+            // Load children lazily
+            btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>';
+            
+            try {
+                const response = await fetch(`/manager/api/get_descendants/${parentId}`, {
+                    credentials: 'include'
+                });
+                const result = await response.json();
+                
+                if (result.success && result.children) {
+                    // Render children
+                    container.innerHTML = renderChildrenRecursive(result.children);
+                }
+            } catch (error) {
+                console.error('Failed to load children:', error);
+            } finally {
+                btn.innerHTML = '−';
+            }
+        }
+        
+        container.style.display = 'flex';
+        btn.innerHTML = '−';
+    }
+}
+
+// Enter key search
+document.getElementById('searchInput').addEventListener('keypress', function(e) {
+    if (e.key === 'Enter') {
+        searchMember();
+    }
+});
+</script>
+{% endblock %}

+ 15 - 12
test_api.py

@@ -1,15 +1,18 @@
 import requests
-import json
+import time
 
-# 测试API接口
-url = 'http://localhost:5001/manager/api/members'
-params = {'page': 1, 'search': ''}
+start = time.time()
+response = requests.get('http://localhost:5001/manager/api/get_lineage/6')
+elapsed = time.time() - start
 
-print('Testing API...')
-try:
-    response = requests.get(url, params=params)
-    print(f'Response status code: {response.status_code}')
-    print(f'Response content: {response.text}')
-    
-except Exception as e:
-    print(f'Error: {e}')
+print(f'Response time: {elapsed:.3f}s')
+print(f'Status code: {response.status_code}')
+
+if response.status_code == 200:
+    data = response.json()
+    print(f'Success: {data.get("success")}')
+    if data.get('data'):
+        print(f"Center: {data['data'].get('center', {}).get('name')}")
+        print(f"Ancestors count: {len(data['data'].get('ancestors', []))}")
+        print(f"Siblings count: {len(data['data'].get('siblings', []))}")
+        print(f"Children count: {len(data['data'].get('children', []))}")