Procházet zdrojové kódy

commit 若干优化

林海 před 1 dnem
rodič
revize
154a9fb741
5 změnil soubory, kde provedl 715 přidání a 41 odebrání
  1. 274 30
      app.py
  2. 32 9
      templates/add_member.html
  3. 391 0
      templates/generation_check.html
  4. 17 2
      templates/layout.html
  5. 1 0
      templates/members.html

+ 274 - 30
app.py

@@ -1846,12 +1846,17 @@ def get_lineage(member_id):
                 
                 
                 # Get siblings of this ancestor (father's brothers)
                 # Get siblings of this ancestor (father's brothers)
                 # First get grandparent (parent's father)
                 # First get grandparent (parent's father)
-                cursor.execute("""
+                # 香火传承:优先选养父(sub_type=3);血脉传承:优先选生父(sub_type=0/2)
+                if mode == 'incense':
+                    gp_order = "CASE WHEN COALESCE(r.sub_relation_type, 0) = 3 THEN 0 ELSE 1 END"
+                else:
+                    gp_order = "CASE WHEN COALESCE(r.sub_relation_type, 0) = 3 THEN 1 ELSE 0 END"
+                cursor.execute(f"""
                     SELECT gp.id
                     SELECT gp.id
                     FROM family_relation_info r
                     FROM family_relation_info r
                     JOIN family_member_info gp ON r.parent_mid = gp.id
                     JOIN family_member_info gp ON r.parent_mid = gp.id
                     WHERE r.child_mid = %s AND r.relation_type IN (1, 2)
                     WHERE r.child_mid = %s AND r.relation_type IN (1, 2)
-                    ORDER BY CASE WHEN COALESCE(r.sub_relation_type, 0) = 3 THEN 1 ELSE 0 END, r.id
+                    ORDER BY {gp_order}, r.id
                     LIMIT 1
                     LIMIT 1
                 """, (parent['id'],))
                 """, (parent['id'],))
                 grandparent = cursor.fetchone()
                 grandparent = cursor.fetchone()
@@ -2147,12 +2152,17 @@ def get_ancestors_above(ancestor_id):
                     break
                     break
                 visited_ids.add(parent['id'])
                 visited_ids.add(parent['id'])
 
 
-                # 查祖父,用于获取该祖先的兄弟(优先亲生父母,排除养父)
-                cursor.execute("""
+                # 查祖父,用于获取该祖先的兄弟
+                # 香火传承:优先养父(sub_type=3);血脉传承:优先生父
+                if mode == 'incense':
+                    gp_order_ab = "CASE WHEN COALESCE(r.sub_relation_type, 0) = 3 THEN 0 ELSE 1 END"
+                else:
+                    gp_order_ab = "CASE WHEN COALESCE(r.sub_relation_type, 0) = 3 THEN 1 ELSE 0 END"
+                cursor.execute(f"""
                     SELECT gp.id FROM family_relation_info r
                     SELECT gp.id FROM family_relation_info r
                     JOIN family_member_info gp ON r.parent_mid = gp.id
                     JOIN family_member_info gp ON r.parent_mid = gp.id
                     WHERE r.child_mid = %s AND r.relation_type IN (1, 2)
                     WHERE r.child_mid = %s AND r.relation_type IN (1, 2)
-                    ORDER BY CASE WHEN COALESCE(r.sub_relation_type, 0) = 3 THEN 1 ELSE 0 END, r.id
+                    ORDER BY {gp_order_ab}, r.id
                     LIMIT 1
                     LIMIT 1
                 """, (parent['id'],))
                 """, (parent['id'],))
                 grandparent = cursor.fetchone()
                 grandparent = cursor.fetchone()
@@ -2905,15 +2915,16 @@ def add_member():
             relations = []
             relations = []
             # Parse relations from form data
             # Parse relations from form data
             i = 0
             i = 0
-            while True:
+            while i < 50:
                 parent_mid = request.form.get(f'relations[{i}][parent_mid]')
                 parent_mid = request.form.get(f'relations[{i}][parent_mid]')
-                rel_type = request.form.get(f'relations[{i}][relation_type]')
-                sub_rel_type = request.form.get(f'relations[{i}][sub_relation_type]', '0')
-                child_order_raw = request.form.get(f'relations[{i}][child_order]', '')
-                
-                if not parent_mid or not rel_type:
+                rel_type   = request.form.get(f'relations[{i}][relation_type]')
+                if parent_mid is None and rel_type is None:
                     break
                     break
-                
+                i += 1
+                if not parent_mid or not rel_type:
+                    continue
+                sub_rel_type    = request.form.get(f'relations[{i-1}][sub_relation_type]', '0')
+                child_order_raw = request.form.get(f'relations[{i-1}][child_order]', '')
                 child_order = int(child_order_raw) if child_order_raw.strip().isdigit() else None
                 child_order = int(child_order_raw) if child_order_raw.strip().isdigit() else None
                 relations.append({
                 relations.append({
                     'parent_mid': int(parent_mid),
                     'parent_mid': int(parent_mid),
@@ -2921,8 +2932,7 @@ def add_member():
                     'sub_relation_type': int(sub_rel_type),
                     'sub_relation_type': int(sub_rel_type),
                     'child_order': child_order
                     'child_order': child_order
                 })
                 })
-                i += 1
-            
+
             # For backward compatibility, check old-style single relation
             # For backward compatibility, check old-style single relation
             if not relations:
             if not relations:
                 related_mid = request.form.get('related_mid')
                 related_mid = request.form.get('related_mid')
@@ -3073,9 +3083,14 @@ def add_member():
                 print(f"[Add Member] Committing transaction")
                 print(f"[Add Member] Committing transaction")
                 if safe_commit(conn):
                 if safe_commit(conn):
                     print(f"[Add Member] Transaction committed successfully")
                     print(f"[Add Member] Transaction committed successfully")
+                    # 代数连续性校验
+                    gen_warns = _check_member_generation(conn, member_id)
                     if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or request.is_json:
                     if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or request.is_json:
-                        return jsonify({"success": True, "message": "成员录入成功", "member_id": member_id})
+                        return jsonify({"success": True, "message": "成员录入成功", "member_id": member_id,
+                                        "generation_warnings": gen_warns})
                     flash('成员录入成功')
                     flash('成员录入成功')
+                    for w in gen_warns:
+                        flash(w, 'warning')
                     return redirect(url_for('members'))
                     return redirect(url_for('members'))
                 else:
                 else:
                     print(f"[Add Member] Transaction commit failed!")
                     print(f"[Add Member] Transaction commit failed!")
@@ -3128,15 +3143,18 @@ def edit_member(member_id):
             # 关系数据 - 支持多条关系
             # 关系数据 - 支持多条关系
             relations = []
             relations = []
             i = 0
             i = 0
-            while True:
+            while i < 50:   # 最多 50 条,防止无限循环
                 parent_mid = request.form.get(f'relations[{i}][parent_mid]')
                 parent_mid = request.form.get(f'relations[{i}][parent_mid]')
-                rel_type = request.form.get(f'relations[{i}][relation_type]')
-                sub_rel_type = request.form.get(f'relations[{i}][sub_relation_type]', '0')
-                child_order_raw = request.form.get(f'relations[{i}][child_order]', '')
-
-                if not parent_mid or not rel_type:
+                rel_type   = request.form.get(f'relations[{i}][relation_type]')
+                # 该索引在表单中完全不存在时(非空值,而是键不存在)说明已超出行数,退出
+                if parent_mid is None and rel_type is None:
                     break
                     break
-
+                i += 1
+                # parent_mid 或 relation_type 为空(用户清除了该行)则跳过,不入库
+                if not parent_mid or not rel_type:
+                    continue
+                sub_rel_type    = request.form.get(f'relations[{i-1}][sub_relation_type]', '0')
+                child_order_raw = request.form.get(f'relations[{i-1}][child_order]', '')
                 child_order = int(child_order_raw) if child_order_raw.strip().isdigit() else None
                 child_order = int(child_order_raw) if child_order_raw.strip().isdigit() else None
                 relations.append({
                 relations.append({
                     'parent_mid': int(parent_mid),
                     'parent_mid': int(parent_mid),
@@ -3144,7 +3162,6 @@ def edit_member(member_id):
                     'sub_relation_type': int(sub_rel_type),
                     'sub_relation_type': int(sub_rel_type),
                     'child_order': child_order,
                     'child_order': child_order,
                 })
                 })
-                i += 1
 
 
             # For backward compatibility
             # For backward compatibility
             if not relations:
             if not relations:
@@ -3294,10 +3311,14 @@ def edit_member(member_id):
                 print(f"[Edit Member] Committing transaction")
                 print(f"[Edit Member] Committing transaction")
                 conn.commit()
                 conn.commit()
                 print(f"[Edit Member] Transaction committed successfully")
                 print(f"[Edit Member] Transaction committed successfully")
+                # 代数连续性校验
+                gen_warns = _check_member_generation(conn, member_id)
                 if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or request.is_json:
                 if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or request.is_json:
-                    return jsonify({"success": True, "message": "成员信息更新成功"})
-                
+                    return jsonify({"success": True, "message": "成员信息更新成功",
+                                    "generation_warnings": gen_warns})
                 flash('成员信息更新成功')
                 flash('成员信息更新成功')
+                for w in gen_warns:
+                    flash(w, 'warning')
                 return redirect(url_for('members'))
                 return redirect(url_for('members'))
         
         
         with conn.cursor() as cursor:
         with conn.cursor() as cursor:
@@ -6087,12 +6108,17 @@ def api_get_lineage(member_id):
                     break
                     break
                 visited_ancestor_ids.add(parent['id'])
                 visited_ancestor_ids.add(parent['id'])
 
 
-                # 查祖父以获取该祖先的兄弟(优先亲生父母,排除养父)
-                cursor.execute("""
+                # 查祖父以获取该祖先的兄弟
+                # 香火传承:优先养父(sub_type=3);血脉传承:优先生父
+                if mode == 'incense':
+                    gp_order_wx = "CASE WHEN COALESCE(r.sub_relation_type, 0) = 3 THEN 0 ELSE 1 END"
+                else:
+                    gp_order_wx = "CASE WHEN COALESCE(r.sub_relation_type, 0) = 3 THEN 1 ELSE 0 END"
+                cursor.execute(f"""
                     SELECT gp.id FROM family_relation_info r
                     SELECT gp.id FROM family_relation_info r
                     JOIN family_member_info gp ON r.parent_mid = gp.id
                     JOIN family_member_info gp ON r.parent_mid = gp.id
                     WHERE r.child_mid = %s AND r.relation_type IN (1, 2)
                     WHERE r.child_mid = %s AND r.relation_type IN (1, 2)
-                    ORDER BY CASE WHEN COALESCE(r.sub_relation_type, 0) = 3 THEN 1 ELSE 0 END, r.id
+                    ORDER BY {gp_order_wx}, r.id
                     LIMIT 1
                     LIMIT 1
                 """, (parent['id'],))
                 """, (parent['id'],))
                 grandparent = cursor.fetchone()
                 grandparent = cursor.fetchone()
@@ -6307,11 +6333,16 @@ def api_get_ancestors_above(ancestor_id):
                     break
                     break
                 visited_ids.add(parent['id'])
                 visited_ids.add(parent['id'])
 
 
-                cursor.execute("""
+                # 香火传承:优先养父(sub_type=3);血脉传承:优先生父
+                if mode == 'incense':
+                    gp_order_wxab = "CASE WHEN COALESCE(r.sub_relation_type, 0) = 3 THEN 0 ELSE 1 END"
+                else:
+                    gp_order_wxab = "CASE WHEN COALESCE(r.sub_relation_type, 0) = 3 THEN 1 ELSE 0 END"
+                cursor.execute(f"""
                     SELECT gp.id FROM family_relation_info r
                     SELECT gp.id FROM family_relation_info r
                     JOIN family_member_info gp ON r.parent_mid = gp.id
                     JOIN family_member_info gp ON r.parent_mid = gp.id
                     WHERE r.child_mid = %s AND r.relation_type IN (1, 2)
                     WHERE r.child_mid = %s AND r.relation_type IN (1, 2)
-                    ORDER BY CASE WHEN COALESCE(r.sub_relation_type, 0) = 3 THEN 1 ELSE 0 END, r.id
+                    ORDER BY {gp_order_wxab}, r.id
                     LIMIT 1
                     LIMIT 1
                 """, (parent['id'],))
                 """, (parent['id'],))
                 grandparent = cursor.fetchone()
                 grandparent = cursor.fetchone()
@@ -6775,5 +6806,218 @@ def mp_wx_update_member():
 
 
 # ==================== End 微信小程序 API 接口 ====================
 # ==================== End 微信小程序 API 接口 ====================
 
 
+# ==================== 代数连续性核查 ====================
+
+def _check_member_generation(conn, member_id):
+    """
+    保存成员后调用:检查该成员与其亲生父亲的代数是否连续。
+    返回 list of warning_str(空列表表示无问题)。
+    """
+    try:
+        with conn.cursor() as cursor:
+            cursor.execute(
+                "SELECT name, simplified_name, name_word_generation FROM family_member_info WHERE id = %s",
+                (member_id,)
+            )
+            member = cursor.fetchone()
+            if not member:
+                return []
+
+            cursor.execute("""
+                SELECT p.id, p.name, p.simplified_name, p.name_word_generation
+                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 = 1
+                  AND COALESCE(r.sub_relation_type, 0) != 3
+                LIMIT 5
+            """, (member_id,))
+            parents = cursor.fetchall()
+
+        if not parents:
+            return []
+
+        member_name = member.get('simplified_name') or member.get('name') or f'ID:{member_id}'
+        child_gens  = _parse_generation_labels(member.get('name_word_generation') or '')
+
+        warnings = []
+        for parent in parents:
+            parent_gens = _parse_generation_labels(parent.get('name_word_generation') or '')
+            if not parent_gens or not child_gens:
+                continue
+            parent_map = dict(parent_gens)
+            child_map  = dict(child_gens)
+            common = set(parent_map.keys()) & set(child_map.keys())
+            for prefix in common:
+                p_gen = parent_map[prefix]
+                c_gen = child_map[prefix]
+                if c_gen != p_gen + 1:
+                    parent_name = parent.get('simplified_name') or parent.get('name') or '?'
+                    expected = p_gen + 1
+                    diff_desc = (
+                        f"与父亲相同(均为第{c_gen}代)" if c_gen == p_gen
+                        else f"跳跃了{c_gen - p_gen - 1}代(应为第{expected}代,实为第{c_gen}代)" if c_gen > p_gen + 1
+                        else f"倒退(父第{p_gen}代,子第{c_gen}代)"
+                    )
+                    warnings.append(
+                        f"⚠️ 代数不连续 [{prefix}]:{member_name} 第{c_gen}代,"
+                        f"父亲 {parent_name} 第{p_gen}代,{diff_desc}"
+                    )
+        return warnings
+    except Exception as e:
+        print(f"[GenerationCheck] Warning: {e}")
+        return []
+
+
+def _cn_to_int(s):
+    """将中文数字(如"三十二")转为整数,支持 1-999。"""
+    if not s:
+        return None
+    digits = {'零':0,'一':1,'二':2,'三':3,'四':4,'五':5,'六':6,'七':7,'八':8,'九':9}
+    units  = {'十':10,'百':100,'千':1000}
+    result, temp = 0, 0
+    for ch in s:
+        if ch in digits:
+            temp = digits[ch]
+        elif ch in units:
+            if temp == 0 and ch == '十':   # 十一 → 11,十 → 10
+                temp = 1
+            result += temp * units[ch]
+            temp = 0
+    result += temp
+    return result or None
+
+
+def _parse_generation_labels(gen_str):
+    """
+    解析 name_word_generation 字段(可含多个分号分隔的代数标签),
+    返回 list of (branch_prefix, gen_int)。
+    例:"衢州第三十代;某支第五代" → [("衢州", 30), ("某支", 5)]
+    """
+    import re
+    if not gen_str:
+        return []
+    results = []
+    for part in gen_str.split(';'):
+        part = part.strip()
+        m = re.match(r'^(.*?)第([零一二三四五六七八九十百千]+)代$', part)
+        if m:
+            prefix = m.group(1)
+            num = _cn_to_int(m.group(2))
+            if num is not None:
+                results.append((prefix, num))
+    return results
+
+
+@app.route('/manager/generation_check')
+def generation_check_page():
+    if 'user_id' not in session:
+        return redirect(url_for('login'))
+    return render_template('generation_check.html')
+
+
+@app.route('/manager/api/generation_check')
+def generation_check_api():
+    """
+    核查所有父子关系中代数是否连续(子=父+1)。
+    仅检查亲生父子关系(relation_type=1,排除入继 sub_type=3)。
+    返回所有不连续的情况,包含人员信息方便排查。
+    """
+    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 id, name, simplified_name, name_word_generation
+                FROM family_member_info
+            """)
+            members = {row['id']: row for row in cursor.fetchall()}
+
+            # 获取所有亲生父子关系(排除入继)
+            cursor.execute("""
+                SELECT parent_mid, child_mid
+                FROM family_relation_info
+                WHERE relation_type = 1
+                  AND COALESCE(sub_relation_type, 0) != 3
+            """)
+            relations = cursor.fetchall()
+
+        errors = []
+        warnings = []  # 父或子缺少可解析代数
+
+        for rel in relations:
+            pid = rel['parent_mid']
+            cid = rel['child_mid']
+            parent = members.get(pid)
+            child  = members.get(cid)
+            if not parent or not child:
+                continue
+
+            parent_gens = _parse_generation_labels(parent.get('name_word_generation') or '')
+            child_gens  = _parse_generation_labels(child.get('name_word_generation') or '')
+
+            if not parent_gens or not child_gens:
+                # 至少一方没有可解析的代数,记为警告
+                if parent.get('name_word_generation') or child.get('name_word_generation'):
+                    warnings.append({
+                        'type': 'missing_gen',
+                        'parent_id': pid,
+                        'parent_name': parent.get('simplified_name') or parent.get('name'),
+                        'parent_gen_raw': parent.get('name_word_generation') or '',
+                        'child_id': cid,
+                        'child_name': child.get('simplified_name') or child.get('name'),
+                        'child_gen_raw': child.get('name_word_generation') or '',
+                    })
+                continue
+
+            # 对每个共有的支系前缀检查是否 child_gen == parent_gen + 1
+            parent_map = dict(parent_gens)
+            child_map  = dict(child_gens)
+            common_prefixes = set(parent_map.keys()) & set(child_map.keys())
+
+            if not common_prefixes:
+                # 前缀不同,可能是跨支系,不强制报错
+                continue
+
+            for prefix in sorted(common_prefixes):
+                p_gen = parent_map[prefix]
+                c_gen = child_map[prefix]
+                if c_gen != p_gen + 1:
+                    errors.append({
+                        'type': 'discontinuous',
+                        'prefix': prefix,
+                        'parent_id': pid,
+                        'parent_name': parent.get('simplified_name') or parent.get('name'),
+                        'parent_gen': p_gen,
+                        'parent_gen_raw': parent.get('name_word_generation') or '',
+                        'child_id': cid,
+                        'child_name': child.get('simplified_name') or child.get('name'),
+                        'child_gen': c_gen,
+                        'child_gen_raw': child.get('name_word_generation') or '',
+                        'expected_child_gen': p_gen + 1,
+                        'diff': c_gen - p_gen,
+                    })
+
+        # 按支系前缀和父代数排序
+        errors.sort(key=lambda x: (x['prefix'], x['parent_gen']))
+        warnings.sort(key=lambda x: (x['parent_id']))
+
+        return jsonify({
+            "success": True,
+            "error_count": len(errors),
+            "warning_count": len(warnings),
+            "errors": errors,
+            "warnings": warnings,
+        })
+    except Exception as e:
+        import traceback
+        return jsonify({"success": False, "message": str(e), "trace": traceback.format_exc()}), 500
+    finally:
+        conn.close()
+
+# ==================== End 代数连续性核查 ====================
+
 if __name__ == '__main__':
 if __name__ == '__main__':
     app.run(debug=False, host='0.0.0.0', port=5001)
     app.run(debug=False, host='0.0.0.0', port=5001)

+ 32 - 9
templates/add_member.html

@@ -246,8 +246,8 @@
                                     </select>
                                     </select>
                                 </div>
                                 </div>
                                 <div class="col-md-2 d-flex align-items-end">
                                 <div class="col-md-2 d-flex align-items-end">
-                                    <button type="button" class="btn btn-danger w-100 remove-relation-btn" {% if relations|length <= 1 %}style="display: none;"{% endif %}>
-                                        <i class="bi bi-trash"></i>
+                                    <button type="button" class="btn btn-outline-danger w-100 remove-relation-btn" title="移除此关系">
+                                        <i class="bi bi-trash"></i> 移除
                                     </button>
                                     </button>
                                 </div>
                                 </div>
                                 <div class="col-md-2 child-order-wrapper" style="display: {{ 'block' if rel.relation_type in [1,2] else 'none' }};">
                                 <div class="col-md-2 child-order-wrapper" style="display: {{ 'block' if rel.relation_type in [1,2] else 'none' }};">
@@ -1186,8 +1186,8 @@
                 </select>
                 </select>
             </div>
             </div>
             <div class="col-md-2 d-flex align-items-end">
             <div class="col-md-2 d-flex align-items-end">
-                <button type="button" class="btn btn-danger w-100 remove-relation-btn">
-                    <i class="bi bi-trash"></i>
+                <button type="button" class="btn btn-outline-danger w-100 remove-relation-btn" title="移除此关系">
+                    <i class="bi bi-trash"></i> 移除
                 </button>
                 </button>
             </div>
             </div>
             <div class="col-md-2 child-order-wrapper" style="display: none;">
             <div class="col-md-2 child-order-wrapper" style="display: none;">
@@ -1240,10 +1240,34 @@
 
 
     function removeRelationRow() {
     function removeRelationRow() {
         const row = this.closest('.relation-row');
         const row = this.closest('.relation-row');
-        if (row) {
+        if (!row) return;
+        const rows = document.querySelectorAll('.relation-row');
+        if (rows.length > 1) {
+            // 多条关系:直接删除这一行
             row.remove();
             row.remove();
-            updateRemoveButtons();
+        } else {
+            // 最后一条:清空值但保留行,并展示提示
+            const displayInput = row.querySelector('.related-member-display');
+            const hiddenInput  = row.querySelector('.related_mid');
+            const relType      = row.querySelector('.relation-type');
+            const subRelType   = row.querySelector('.sub-relation-type');
+            const childOrder   = row.querySelector('.child-order-input');
+            if (displayInput) displayInput.value = '';
+            if (hiddenInput)  hiddenInput.value  = '';
+            if (relType)      relType.value       = '';
+            if (subRelType)   subRelType.value    = '0';
+            if (childOrder)   childOrder.value    = '';
+            // 展示"已清除"提示
+            let hint = row.querySelector('.relation-cleared-hint');
+            if (!hint) {
+                hint = document.createElement('div');
+                hint.className = 'relation-cleared-hint col-12';
+                hint.style.cssText = 'font-size:12px;color:#dc3545;padding:4px 0 2px;';
+                hint.innerHTML = '<i class="bi bi-check-circle"></i> 已清除关系,保存后将移除该关联';
+                row.appendChild(hint);
+            }
         }
         }
+        updateRemoveButtons();
     }
     }
 
 
     function updateRemoveButtons() {
     function updateRemoveButtons() {
@@ -1251,10 +1275,9 @@
         rows.forEach((row, index) => {
         rows.forEach((row, index) => {
             const removeBtn = row.querySelector('.remove-relation-btn');
             const removeBtn = row.querySelector('.remove-relation-btn');
             if (removeBtn) {
             if (removeBtn) {
-                // Show remove button only if there's more than one row
-                removeBtn.style.display = rows.length > 1 ? 'block' : 'none';
+                // 始终显示删除按钮(允许清除最后一条关系)
+                removeBtn.style.display = 'block';
             }
             }
-            // Update index attribute
             row.dataset.index = index;
             row.dataset.index = index;
         });
         });
     }
     }

+ 391 - 0
templates/generation_check.html

@@ -0,0 +1,391 @@
+{% extends "layout.html" %}
+
+{% block title %}代数连续性核查 - 家谱管理系统{% endblock %}
+
+{% block extra_css %}
+<style>
+    /* ── 统计 Tab 卡片 ── */
+    .stat-tab {
+        border-radius: 10px;
+        padding: 16px 24px;
+        cursor: pointer;
+        border: 2px solid transparent;
+        transition: border-color .18s, box-shadow .18s, background .18s;
+        user-select: none;
+        min-width: 160px;
+    }
+    .stat-tab:hover { box-shadow: 0 2px 12px rgba(0,0,0,.10); }
+    .stat-tab.active-tab {
+        border-color: currentColor;
+        box-shadow: 0 3px 14px rgba(0,0,0,.13);
+    }
+    .stat-tab .stat-num  { font-size: 2.1rem; font-weight: 700; line-height: 1; }
+    .stat-tab .stat-label{ font-size: 13px; color: #6c757d; margin-top: 4px; }
+    .stat-tab.tab-error  .stat-num { color: #dc3545; }
+    .stat-tab.tab-warn   .stat-num { color: #f59e0b; }
+    .stat-tab.tab-prefix .stat-num { color: #198754; }
+    .stat-tab.tab-error.active-tab  { border-color: #dc3545; background:#fff5f5; }
+    .stat-tab.tab-warn.active-tab   { border-color: #f59e0b; background:#fffbeb; }
+
+    /* ── 分组标题 ── */
+    .prefix-group-header {
+        background: #f0f4fb; font-weight: 600; color: #2c4a8a;
+        padding: 8px 16px; border-left: 4px solid #4a69bd;
+        border-radius: 4px; margin: 18px 0 6px;
+        display: flex; align-items: center; justify-content: space-between;
+    }
+    .warn-group-header {
+        background: #fffbeb; font-weight: 600; color: #92400e;
+        padding: 8px 16px; border-left: 4px solid #f59e0b;
+        border-radius: 4px; margin: 18px 0 6px;
+    }
+
+    /* ── 徽章 ── */
+    .gen-badge {
+        display: inline-block; padding: 1px 8px;
+        border-radius: 12px; font-size: 12px; font-weight: 600;
+    }
+    .gen-badge.correct  { background:#d1fae5; color:#065f46; }
+    .gen-badge.wrong    { background:#fee2e2; color:#991b1b; }
+    .gen-badge.expected { background:#dbeafe; color:#1e40af; }
+    .gen-badge.missing  { background:#fef3c7; color:#92400e; }
+    .diff-badge {
+        display: inline-block; padding: 1px 7px;
+        border-radius: 10px; font-size: 11px; font-weight: 700;
+    }
+    .diff-badge.big  { background:#fef3c7; color:#92400e; }
+    .diff-badge.skip { background:#ffe4e6; color:#be123c; }
+
+    .member-link { color:#3b5998; text-decoration:none; font-weight:600; }
+    .member-link:hover { text-decoration:underline; }
+    .error-row { font-size: 13px; }
+
+    /* ── 筛选栏 ── */
+    .filter-bar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-bottom:16px; }
+    .filter-bar select, .filter-bar input {
+        padding:5px 10px; border:1px solid #d1d5db;
+        border-radius:6px; font-size:13px;
+    }
+
+    /* ── 加载遮罩 ── */
+    #loadingOverlay {
+        position:fixed; inset:0; background:rgba(255,255,255,.85);
+        display:flex; flex-direction:column; align-items:center;
+        justify-content:center; z-index:9999; font-size:15px; color:#555; gap:14px;
+    }
+    .spinner {
+        width:40px; height:40px; border:4px solid #e5e7eb;
+        border-top-color:#4a69bd; border-radius:50%;
+        animation:spin .8s linear infinite;
+    }
+    @keyframes spin { to { transform:rotate(360deg); } }
+</style>
+{% endblock %}
+
+{% block content %}
+<div id="loadingOverlay">
+    <div class="spinner"></div>
+    <div>正在核查所有父子代数关系,请稍候…</div>
+</div>
+
+<div class="d-flex justify-content-between align-items-center mb-4">
+    <h2><i class="bi bi-clipboard2-check"></i> 代数连续性核查</h2>
+    <div class="d-flex gap-2">
+        <button class="btn btn-outline-primary btn-sm" onclick="reloadCheck()">
+            <i class="bi bi-arrow-clockwise"></i> 重新核查
+        </button>
+        <a href="{{ url_for('members') }}" class="btn btn-outline-secondary btn-sm">返回成员列表</a>
+    </div>
+</div>
+
+<!-- 统计 Tab 卡片 -->
+<div class="row g-3 mb-4" id="statsRow" style="display:none!important;">
+    <div class="col-auto">
+        <div class="stat-tab tab-error bg-white shadow-sm active-tab" id="tabError" onclick="switchTab('error')">
+            <div class="stat-num" id="statErrors">-</div>
+            <div class="stat-label">代数不连续(需修正)</div>
+        </div>
+    </div>
+    <div class="col-auto">
+        <div class="stat-tab tab-warn bg-white shadow-sm" id="tabWarn" onclick="switchTab('warn')">
+            <div class="stat-num" id="statWarnings">-</div>
+            <div class="stat-label">代数缺失(需补录)</div>
+        </div>
+    </div>
+    <div class="col-auto">
+        <div class="stat-tab tab-prefix bg-white shadow-sm" style="cursor:default;">
+            <div class="stat-num" id="statPrefixes">-</div>
+            <div class="stat-label">涉及支系</div>
+        </div>
+    </div>
+</div>
+
+<!-- 不连续:筛选栏 -->
+<div class="filter-bar" id="filterBarError" style="display:none!important;">
+    <label style="font-size:13px;font-weight:600;">支系筛选:</label>
+    <select id="prefixFilter" onchange="renderErrors()"><option value="">全部支系</option></select>
+    <label style="font-size:13px;font-weight:600;">关键字:</label>
+    <input type="text" id="keywordFilter" placeholder="姓名/ID" oninput="renderErrors()" style="width:140px;">
+    <label style="font-size:13px;font-weight:600;">差值:</label>
+    <select id="diffFilter" onchange="renderErrors()">
+        <option value="">全部</option>
+        <option value="0">跳代(差值≠1)</option>
+        <option value="neg">倒退(子&lt;父+1)</option>
+        <option value="big">跨越(差值&gt;1)</option>
+    </select>
+    <span id="filteredCount" style="font-size:12px;color:#6b7280;margin-left:4px;"></span>
+</div>
+
+<!-- 代数缺失:筛选栏 -->
+<div class="filter-bar" id="filterBarWarn" style="display:none!important;">
+    <label style="font-size:13px;font-weight:600;">关键字:</label>
+    <input type="text" id="keywordFilterWarn" placeholder="姓名/ID" oninput="renderWarnings()" style="width:160px;">
+    <label style="font-size:13px;font-weight:600;">缺失方:</label>
+    <select id="missingSideFilter" onchange="renderWarnings()">
+        <option value="">全部</option>
+        <option value="child">子女缺失</option>
+        <option value="parent">父亲缺失</option>
+    </select>
+    <span id="filteredWarnCount" style="font-size:12px;color:#6b7280;margin-left:4px;"></span>
+</div>
+
+<!-- 不连续列表 -->
+<div id="errorsContainer"></div>
+
+<!-- 代数缺失列表 -->
+<div id="warningsContainer" style="display:none;"></div>
+
+<script>
+let _allErrors   = [];
+let _allWarnings = [];
+let _activeTab   = 'error';
+
+/* ── Tab 切换 ── */
+function switchTab(tab) {
+    _activeTab = tab;
+    const isError = (tab === 'error');
+
+    document.getElementById('tabError').classList.toggle('active-tab',  isError);
+    document.getElementById('tabWarn').classList.toggle('active-tab',  !isError);
+
+    document.getElementById('filterBarError').style.cssText = isError ? '' : 'display:none!important;';
+    document.getElementById('filterBarWarn').style.cssText  = isError ? 'display:none!important;' : '';
+
+    document.getElementById('errorsContainer').style.display   = isError ? '' : 'none';
+    document.getElementById('warningsContainer').style.display = isError ? 'none' : '';
+}
+
+/* ── 加载数据 ── */
+async function loadCheck() {
+    try {
+        const res  = await fetch('/manager/api/generation_check');
+        const data = await res.json();
+        document.getElementById('loadingOverlay').style.display = 'none';
+
+        if (!data.success) {
+            document.getElementById('errorsContainer').innerHTML =
+                `<div class="alert alert-danger">核查失败:${data.message}</div>`;
+            return;
+        }
+
+        _allErrors   = data.errors   || [];
+        _allWarnings = data.warnings || [];
+
+        const prefixes = [...new Set(_allErrors.map(e => e.prefix))];
+        document.getElementById('statErrors').textContent   = data.error_count;
+        document.getElementById('statWarnings').textContent = data.warning_count;
+        document.getElementById('statPrefixes').textContent = prefixes.length;
+        document.getElementById('statsRow').style.cssText   = '';
+
+        // 填充支系选项
+        const sel = document.getElementById('prefixFilter');
+        prefixes.sort().forEach(p => {
+            const opt = document.createElement('option');
+            opt.value = p; opt.textContent = p || '(无前缀)';
+            sel.appendChild(opt);
+        });
+
+        switchTab('error');   // 默认展示不连续
+        renderErrors();
+        renderWarnings();
+    } catch (e) {
+        document.getElementById('loadingOverlay').style.display = 'none';
+        document.getElementById('errorsContainer').innerHTML =
+            `<div class="alert alert-danger">请求失败:${e.message}</div>`;
+    }
+}
+
+/* ── 渲染:代数不连续 ── */
+function renderErrors() {
+    const prefixF = document.getElementById('prefixFilter').value;
+    const keyword = document.getElementById('keywordFilter').value.trim().toLowerCase();
+    const diffF   = document.getElementById('diffFilter').value;
+
+    let filtered = _allErrors.filter(e => {
+        if (prefixF && e.prefix !== prefixF) return false;
+        if (keyword) {
+            const hay = `${e.parent_name}${e.child_name}${e.parent_id}${e.child_id}`.toLowerCase();
+            if (!hay.includes(keyword)) return false;
+        }
+        if (diffF === '0'   && e.diff === 1)  return false;
+        if (diffF === 'neg' && e.diff >= 1)   return false;
+        if (diffF === 'big' && e.diff <= 1)   return false;
+        return true;
+    });
+
+    document.getElementById('filteredCount').textContent =
+        `显示 ${filtered.length} / ${_allErrors.length} 条`;
+
+    if (filtered.length === 0) {
+        document.getElementById('errorsContainer').innerHTML =
+            _allErrors.length === 0
+                ? '<div class="alert alert-success mt-2"><i class="bi bi-check-circle"></i> 所有父子代数均连续,无需修正!</div>'
+                : '<div class="alert alert-info mt-2">当前筛选条件无匹配结果。</div>';
+        return;
+    }
+
+    const groups = {};
+    filtered.forEach(e => {
+        const key = e.prefix || '(无前缀)';
+        if (!groups[key]) groups[key] = [];
+        groups[key].push(e);
+    });
+
+    let html = '';
+    for (const [prefix, items] of Object.entries(groups)) {
+        html += `<div class="prefix-group-header">
+            <span>📌 ${prefix} — ${items.length} 处不连续</span>
+        </div>
+        <div class="card shadow-sm border-0 mb-2"><table class="table table-hover table-sm mb-0">
+        <thead class="table-light"><tr>
+            <th style="width:36px;">#</th>
+            <th>父亲</th><th style="width:80px;">父代数</th>
+            <th>子女</th><th style="width:80px;">子代数</th>
+            <th style="width:80px;">应为</th><th style="width:80px;">差值</th>
+            <th style="width:70px;">操作</th>
+        </tr></thead><tbody>`;
+
+        items.forEach((e, i) => {
+            const diffLabel = e.diff === 0 ? '相同'
+                : e.diff < 0 ? `倒退${Math.abs(e.diff)}代`
+                : `跨越${e.diff - 1}代`;
+            const diffCls = e.diff <= 0 ? 'skip' : e.diff > 1 ? 'big' : '';
+            html += `<tr class="error-row">
+                <td class="text-muted">${i+1}</td>
+                <td>
+                    <a href="/manager/member_detail/${e.parent_id}" target="_blank" class="member-link">${e.parent_name}</a>
+                    <span class="text-muted" style="font-size:11px;"> ID:${e.parent_id}</span>
+                    <div style="font-size:10px;color:#aaa;">${e.parent_gen_raw}</div>
+                </td>
+                <td><span class="gen-badge correct">第${e.parent_gen}代</span></td>
+                <td>
+                    <a href="/manager/member_detail/${e.child_id}" target="_blank" class="member-link">${e.child_name}</a>
+                    <span class="text-muted" style="font-size:11px;"> ID:${e.child_id}</span>
+                    <div style="font-size:10px;color:#aaa;">${e.child_gen_raw}</div>
+                </td>
+                <td><span class="gen-badge wrong">第${e.child_gen}代</span></td>
+                <td><span class="gen-badge expected">第${e.expected_child_gen}代</span></td>
+                <td>${diffCls
+                    ? `<span class="diff-badge ${diffCls}">${diffLabel}</span>`
+                    : `<span class="text-muted" style="font-size:11px;">${diffLabel}</span>`}</td>
+                <td>
+                    <a href="/manager/member_detail/${e.child_id}" target="_blank"
+                       class="btn btn-outline-primary btn-sm py-0 px-2" style="font-size:11px;">修正子</a>
+                </td>
+            </tr>`;
+        });
+        html += `</tbody></table></div>`;
+    }
+    document.getElementById('errorsContainer').innerHTML = html;
+}
+
+/* ── 渲染:代数缺失 ── */
+function renderWarnings() {
+    const keyword    = (document.getElementById('keywordFilterWarn').value || '').trim().toLowerCase();
+    const missingSide = document.getElementById('missingSideFilter').value;
+
+    let filtered = _allWarnings.filter(w => {
+        if (keyword) {
+            const hay = `${w.parent_name}${w.child_name}${w.parent_id}${w.child_id}`.toLowerCase();
+            if (!hay.includes(keyword)) return false;
+        }
+        const childMissing  = !w.child_gen_raw;
+        const parentMissing = !w.parent_gen_raw;
+        if (missingSide === 'child'  && !childMissing)  return false;
+        if (missingSide === 'parent' && !parentMissing) return false;
+        return true;
+    });
+
+    document.getElementById('filteredWarnCount').textContent =
+        `显示 ${filtered.length} / ${_allWarnings.length} 条`;
+
+    if (filtered.length === 0) {
+        document.getElementById('warningsContainer').innerHTML =
+            _allWarnings.length === 0
+                ? '<div class="alert alert-success mt-2"><i class="bi bi-check-circle"></i> 所有父子均已填写代数!</div>'
+                : '<div class="alert alert-info mt-2">当前筛选条件无匹配结果。</div>';
+        return;
+    }
+
+    let html = `<div class="warn-group-header">
+        <i class="bi bi-exclamation-triangle me-2"></i>
+        共 ${filtered.length} 对父子关系中至少一方未填写可解析的代数字段
+    </div>
+    <div class="card shadow-sm border-0 mb-2"><table class="table table-hover table-sm mb-0">
+    <thead class="table-light"><tr>
+        <th style="width:36px;">#</th>
+        <th>父亲</th><th style="width:120px;">父代数字段</th>
+        <th>子女</th><th style="width:120px;">子代数字段</th>
+        <th style="width:100px;">操作</th>
+    </tr></thead><tbody>`;
+
+    filtered.forEach((w, i) => {
+        const pGen = w.parent_gen_raw
+            ? `<code style="font-size:11px;">${w.parent_gen_raw}</code>`
+            : '<span class="gen-badge missing">未填</span>';
+        const cGen = w.child_gen_raw
+            ? `<code style="font-size:11px;">${w.child_gen_raw}</code>`
+            : '<span class="gen-badge missing">未填</span>';
+        html += `<tr class="error-row">
+            <td class="text-muted">${i+1}</td>
+            <td>
+                <a href="/manager/member_detail/${w.parent_id}" target="_blank" class="member-link">${w.parent_name}</a>
+                <span class="text-muted" style="font-size:11px;"> ID:${w.parent_id}</span>
+            </td>
+            <td>${pGen}</td>
+            <td>
+                <a href="/manager/member_detail/${w.child_id}" target="_blank" class="member-link">${w.child_name}</a>
+                <span class="text-muted" style="font-size:11px;"> ID:${w.child_id}</span>
+            </td>
+            <td>${cGen}</td>
+            <td>
+                <a href="/manager/member_detail/${w.parent_id}" target="_blank"
+                   class="btn btn-outline-secondary btn-sm py-0 px-2 me-1" style="font-size:11px;">查父</a>
+                <a href="/manager/member_detail/${w.child_id}" target="_blank"
+                   class="btn btn-outline-warning btn-sm py-0 px-2" style="font-size:11px;">补录子</a>
+            </td>
+        </tr>`;
+    });
+
+    html += `</tbody></table></div>`;
+    document.getElementById('warningsContainer').innerHTML = html;
+}
+
+/* ── 重新核查 ── */
+function reloadCheck() {
+    _activeTab = 'error';
+    document.getElementById('loadingOverlay').style.display = 'flex';
+    document.getElementById('errorsContainer').innerHTML   = '';
+    document.getElementById('warningsContainer').innerHTML = '';
+    document.getElementById('statsRow').style.cssText      = 'display:none!important;';
+    document.getElementById('filterBarError').style.cssText = 'display:none!important;';
+    document.getElementById('filterBarWarn').style.cssText  = 'display:none!important;';
+    // 重置支系下拉
+    const sel = document.getElementById('prefixFilter');
+    while (sel.options.length > 1) sel.remove(1);
+    loadCheck();
+}
+
+loadCheck();
+</script>
+{% endblock %}

+ 17 - 2
templates/layout.html

@@ -92,6 +92,9 @@
                     <a href="{{ url_for('settlements') }}" class="{% if request.endpoint == 'settlements' %}active{% endif %}">
                     <a href="{{ url_for('settlements') }}" class="{% if request.endpoint == 'settlements' %}active{% endif %}">
                         <i class="bi bi-globe me-2"></i> 聚落地图
                         <i class="bi bi-globe me-2"></i> 聚落地图
                     </a>
                     </a>
+                    <a href="{{ url_for('generation_check_page') }}" class="{% if request.endpoint == 'generation_check_page' %}active{% endif %}">
+                        <i class="bi bi-clipboard2-check me-2"></i> 代数核查
+                    </a>
                     <div class="mt-5 border-top pt-3">
                     <div class="mt-5 border-top pt-3">
                         <p class="px-3 small text-muted">用户: {{ session['username'] }}</p>
                         <p class="px-3 small text-muted">用户: {{ session['username'] }}</p>
                         <a href="{{ url_for('logout') }}" class="text-danger">
                         <a href="{{ url_for('logout') }}" class="text-danger">
@@ -108,13 +111,25 @@
             
             
             <!-- Main Content -->
             <!-- Main Content -->
             <div class="col-md-{% if session.get('user_id') %}10{% else %}12{% endif %} content-area">
             <div class="col-md-{% if session.get('user_id') %}10{% else %}12{% endif %} content-area">
-                {% with messages = get_flashed_messages() %}
+                {% with messages = get_flashed_messages(with_categories=true) %}
                   {% if messages %}
                   {% if messages %}
-                    {% for message in messages %}
+                    {% for category, message in messages %}
+                      {% if category == 'warning' %}
+                      <div class="alert alert-warning alert-dismissible fade show" role="alert">
+                        {{ message }}
+                        <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
+                      </div>
+                      {% elif category == 'error' %}
+                      <div class="alert alert-danger alert-dismissible fade show" role="alert">
+                        {{ message }}
+                        <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
+                      </div>
+                      {% else %}
                       <div class="alert alert-info alert-dismissible fade show" role="alert">
                       <div class="alert alert-info alert-dismissible fade show" role="alert">
                         {{ message }}
                         {{ message }}
                         <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
                         <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
                       </div>
                       </div>
+                      {% endif %}
                     {% endfor %}
                     {% endfor %}
                   {% endif %}
                   {% endif %}
                 {% endwith %}
                 {% endwith %}

+ 1 - 0
templates/members.html

@@ -96,6 +96,7 @@
         </form>
         </form>
         <a href="{{ url_for('add_member') }}" class="btn btn-primary text-nowrap"><i class="bi bi-plus-lg"></i> 录入新成员</a>
         <a href="{{ url_for('add_member') }}" class="btn btn-primary text-nowrap"><i class="bi bi-plus-lg"></i> 录入新成员</a>
         <a href="{{ url_for('suspected_errors') }}" class="btn btn-warning text-nowrap"><i class="bi bi-exclamation-triangle"></i> 疑似汇总</a>
         <a href="{{ url_for('suspected_errors') }}" class="btn btn-warning text-nowrap"><i class="bi bi-exclamation-triangle"></i> 疑似汇总</a>
+        <a href="{{ url_for('generation_check_page') }}" class="btn btn-outline-info text-nowrap"><i class="bi bi-clipboard2-check"></i> 代数核查</a>
     </div>
     </div>
 </div>
 </div>