Bladeren bron

优化录入操作,增加批量上传功能,实现关系树形图导出图片功能

Hai Lin 5 dagen geleden
bovenliggende
commit
07fb9f71c5
6 gewijzigde bestanden met toevoegingen van 1617 en 302 verwijderingen
  1. 122 123
      app.py
  2. 385 132
      templates/add_member.html
  3. 80 11
      templates/index.html
  4. 90 35
      templates/tree.html
  5. 752 0
      templates/tree_classic.html
  6. 188 1
      templates/upload.html

+ 122 - 123
app.py

@@ -126,6 +126,40 @@ def clean_name(name):
         
     return name
 
+def is_female_value(sex_value):
+    """Return True when sex value represents female."""
+    if sex_value is None:
+        return False
+    s = str(sex_value).strip().lower()
+    return s in ('女', '2', 'female', 'f')
+
+def normalize_lookup_name(name):
+    """Normalize names for loose matching in AI parsed content."""
+    if not name:
+        return ''
+    return manual_simplify(str(name)).strip()
+
+def should_skip_liu_prefix_for_person(person, spouse_name_set):
+    """
+    Female spouse records should not auto-prepend '留' in simplified_name.
+    We treat a person as female spouse if:
+    1) sex is female, and
+    2) has spouse_name field OR appears in another person's spouse_name list.
+    """
+    if not isinstance(person, dict):
+        return False
+    if not is_female_value(person.get('sex')):
+        return False
+
+    own_names = set()
+    own_names.add(normalize_lookup_name(person.get('name')))
+    own_names.add(normalize_lookup_name(person.get('original_name')))
+    own_names.discard('')
+
+    has_spouse_name = bool(normalize_lookup_name(person.get('spouse_name')))
+    referenced_by_other = any(n in spouse_name_set for n in own_names)
+    return has_spouse_name or referenced_by_other
+
 def get_normalized_base64_image(image_url):
     """Download image, normalize to JPEG, and return base64 data URI for AI payload."""
     import io
@@ -181,6 +215,7 @@ def process_ai_task(record_id, image_url):
         - name: 简体姓名(必须转换为简体中文,去除不需要的敬称)
         - sex: 性别(男/女)
         - birthday: 出生日期(尝试转换为YYYY-MM-DD格式,如果无法确定年份可只填月日)
+        - death_date: 逝世日期(如文本中出现“殁”、“葬”、“卒”等字眼及其对应的时间,请提取)
         - father_name: 父亲姓名
         - spouse_name: 配偶姓名
         - generation: 第几世/代数
@@ -340,15 +375,27 @@ def process_ai_task(record_id, image_url):
                              parsed = [parsed]
                              content_clean = json.dumps(parsed, ensure_ascii=False)
 
+                        # Build spouse name lookup for "female spouse" detection
+                        spouse_name_set = set()
+                        if isinstance(parsed, list):
+                            for person in parsed:
+                                n = normalize_lookup_name(person.get('spouse_name'))
+                                if n:
+                                    spouse_name_set.add(n)
+
                         # Clean names in parsed content
                         if isinstance(parsed, list):
                             for person in parsed:
                                 # Process Name: 'name' is Simplified from AI, 'original_name' is Traditional/Raw from AI
-                                simplified_name = person.get('name', '')
+                                simplified_name = person.get('name', '') or person.get('original_name', '')
                                 original_name = person.get('original_name', '')
                                 
-                                # Apply clean logic to simplified name(本人生效:拼接“留”姓、处理“公”)
-                                cleaned_simplified = clean_name(simplified_name)
+                                # Female spouse: only simplify Chinese, do NOT prepend '留'
+                                if should_skip_liu_prefix_for_person(person, spouse_name_set):
+                                    cleaned_simplified = manual_simplify(simplified_name)
+                                else:
+                                    # Same-clan default: prepend '留' and handle trailing '公'
+                                    cleaned_simplified = clean_name(simplified_name)
                                 person['simplified_name'] = cleaned_simplified
                                 
                                 # Store raw name in 'name' field (as requested)
@@ -376,21 +423,10 @@ def process_ai_task(record_id, image_url):
                         conn.commit()
                         print(f"[AI Task] SUCCESS: Record {record_id} processed and saved.")
                         return # Success
-                    except json.JSONDecodeError:
-                        print(f"[AI Task] WARNING: JSON Parse Error for record {record_id}. Saving raw content.")
-                        with conn.cursor() as cursor:
-                            cursor.execute("UPDATE genealogy_records SET ai_status = 3, ai_content = %s WHERE id = %s", (f"JSON Parse Error. Raw: {full_content}", record_id))
-                        conn.commit()
-                        return # Success (technically API worked, just bad content)
+                    except json.JSONDecodeError as err:
+                        raise Exception(f"JSON Parse Error: {str(err)}. Raw: {full_content}")
                 else:
-                    print(f"[AI Task] API Error {response.status_code} for record {record_id}: {response.text[:100]}...")
-                    if response.status_code >= 500:
-                        raise Exception(f"Server Error {response.status_code}")
-                    
-                    with conn.cursor() as cursor:
-                        cursor.execute("UPDATE genealogy_records SET ai_status = 3, ai_content = %s WHERE id = %s", (f"API Error: {response.text}", record_id))
-                    conn.commit()
-                    return
+                    raise Exception(f"API Error {response.status_code}: {response.text}")
 
             except Exception as e:
                 print(f"[AI Task] Attempt {attempt+1} failed for record {record_id}: {e}")
@@ -520,6 +556,12 @@ def tree():
         return redirect(url_for('login'))
     return render_template('tree.html')
 
+@app.route('/manager/tree_classic')
+def tree_classic():
+    if 'user_id' not in session:
+        return redirect(url_for('login'))
+    return render_template('tree_classic.html')
+
 @app.route('/manager/api/tree_data')
 def tree_data():
     if 'user_id' not in session:
@@ -529,7 +571,7 @@ def tree_data():
     try:
         with conn.cursor() as cursor:
             # 获取所有成员
-            cursor.execute("SELECT id, name, simplified_name, sex FROM family_member_info")
+            cursor.execute("SELECT id, name, simplified_name, sex, family_rank FROM family_member_info")
             members = cursor.fetchall()
             # 获取所有关系 (1:父子 2:母子 10:夫妻 11:兄弟 12:姐妹)
             cursor.execute("SELECT parent_mid, child_mid, relation_type FROM family_relation_info")
@@ -1109,6 +1151,38 @@ def logout():
     session.clear()
     return redirect(url_for('login'))
 
+@app.route('/manager/api/check_name')
+def check_name():
+    if 'user_id' not in session:
+        return jsonify({"success": False, "message": "Unauthorized"}), 401
+        
+    name = request.args.get('name', '').strip()
+    if not name:
+        return jsonify({"success": True, "exists": False})
+        
+    conn = get_db_connection()
+    try:
+        with conn.cursor() as cursor:
+            # Check for name or simplified_name match
+            cursor.execute("SELECT id, name, simplified_name, sex, birthday, is_pass_away FROM family_member_info WHERE name = %s OR simplified_name = %s", (name, name))
+            matches = cursor.fetchall()
+            
+            if matches:
+                # Format birthday for display
+                for m in matches:
+                    if m.get('birthday'):
+                        m['birthday_str'] = format_timestamp(m['birthday'])
+                    else:
+                        m['birthday_str'] = '未知'
+                
+                return jsonify({"success": True, "exists": True, "matches": matches})
+            else:
+                return jsonify({"success": True, "exists": False})
+    except Exception as e:
+        return jsonify({"success": False, "error": str(e)}), 500
+    finally:
+        conn.close()
+
 import requests
 import json
 import re
@@ -1135,6 +1209,7 @@ def recognize_image():
     - name: 简体姓名(必须转换为简体中文,去除不需要的敬称)
     - sex: 性别(男/女)
     - birthday: 出生日期(尝试转换为YYYY-MM-DD格式,如果无法确定年份可只填月日)
+    - death_date: 逝世日期(如文本中出现“殁”、“葬”、“卒”等字眼及其对应的时间,请提取)
     - father_name: 父亲姓名
     - spouse_name: 配偶姓名
     - generation: 第几世/代数
@@ -1282,101 +1357,16 @@ def start_analysis(record_id):
     finally:
         conn.close()
 
-def process_files_background(saved_files, manual_page, suggested_page, genealogy_version, genealogy_source, upload_person):
-    current_suggested_page = int(manual_page) if manual_page and manual_page.isdigit() else suggested_page
-    
-    for filename, file_path in saved_files:
-        try:
-            if filename.lower().endswith('.pdf'):
-                doc = fitz.open(file_path)
-                for page_index in range(len(doc)):
-                    img_path = None
-                    try:
-                        page = doc.load_page(page_index)
-                        max_dim = max(page.rect.width, page.rect.height)
-                        zoom = 2000 / max_dim if max_dim > 0 else 2.0
-                        if zoom > 2.5: zoom = 2.5
-                        mat = fitz.Matrix(zoom, zoom)
-                        
-                        pix = page.get_pixmap(matrix=mat)
-                        img_filename = f"{os.path.splitext(filename)[0]}_page_{page_index+1}.jpg"
-                        img_path = os.path.join(app.config['UPLOAD_FOLDER'], img_filename)
-                        pix.save(img_path)
-                        
-                        oss_url = upload_to_oss(img_path)
-                        if oss_url:
-                            final_page = current_suggested_page
-                            conn = get_db_connection()
-                            try:
-                                with conn.cursor() as cursor:
-                                    sql = """INSERT INTO genealogy_records 
-                                             (file_name, oss_url, page_number, ai_status, genealogy_version, genealogy_source, upload_person, file_type) 
-                                             VALUES (%s, %s, %s, 1, %s, %s, %s, %s)"""
-                                    cursor.execute(sql, (img_filename, oss_url, final_page, genealogy_version, genealogy_source, upload_person, 'PDF'))
-                                    record_id = cursor.lastrowid
-                                conn.commit()
-                                threading.Thread(target=process_ai_task, args=(record_id, oss_url)).start()
-                                current_suggested_page += 1
-                            finally:
-                                conn.close()
-                    finally:
-                        if img_path and os.path.exists(img_path):
-                            try:
-                                os.remove(img_path)
-                            except:
-                                pass
-                doc.close()
-            else:
-                img_path = compress_image_if_needed(file_path)
-                
-                page_num = extract_page_number(img_path)
-                final_page = page_num if page_num else current_suggested_page
-                
-                ext = os.path.splitext(img_path)[1]
-                if genealogy_version and genealogy_source:
-                    if final_page is not None and str(final_page).strip() != '':
-                        img_filename = f"{genealogy_version}_{genealogy_source}_{final_page}{ext}"
-                    else:
-                        img_filename = f"{genealogy_version}_{genealogy_source}{ext}"
-                else:
-                    img_filename = os.path.basename(img_path)
-                
-                oss_url = upload_to_oss(img_path, custom_filename=img_filename)
-                if oss_url:
-                    conn = get_db_connection()
-                    try:
-                        with conn.cursor() as cursor:
-                            sql = """INSERT INTO genealogy_records 
-                                     (file_name, oss_url, page_number, ai_status, genealogy_version, genealogy_source, upload_person, file_type) 
-                                     VALUES (%s, %s, %s, 1, %s, %s, %s, %s)"""
-                            cursor.execute(sql, (img_filename, oss_url, final_page, genealogy_version, genealogy_source, upload_person, '图片'))
-                            record_id = cursor.lastrowid
-                        conn.commit()
-                        threading.Thread(target=process_ai_task, args=(record_id, oss_url)).start()
-                        if page_num:
-                            current_suggested_page = page_num + 1
-                        else:
-                            current_suggested_page += 1
-                    finally:
-                        conn.close()
-                if img_path != file_path and os.path.exists(img_path):
-                    try:
-                        os.remove(img_path)
-                    except:
-                        pass
-        except Exception as e:
-            print(f"Error processing file {filename}: {e}")
-        finally:
-            if os.path.exists(file_path):
-                try:
-                    os.remove(file_path)
-                except:
-                    pass
-
 def process_files_background(upload_folder, saved_files, manual_page, suggested_page, genealogy_version, genealogy_source, upload_person):
     current_suggested_page = int(manual_page) if manual_page and str(manual_page).isdigit() else suggested_page
     
-    for filename, file_path in saved_files:
+    for item in saved_files:
+        if len(item) == 3:
+            filename, file_path, file_page = item
+        else:
+            filename, file_path = item
+            file_page = None
+            
         try:
             if filename.lower().endswith('.pdf'):
                 doc = fitz.open(file_path)
@@ -1433,8 +1423,14 @@ def process_files_background(upload_folder, saved_files, manual_page, suggested_
             else:
                 img_path = compress_image_if_needed(file_path)
                 
-                page_num = extract_page_number(img_path)
-                final_page = page_num if page_num else current_suggested_page
+                # Use explicitly set page number if provided, otherwise extract from filename or auto-increment
+                if file_page and str(file_page).isdigit():
+                    final_page = int(file_page)
+                    current_suggested_page = final_page + 1
+                    page_num = final_page
+                else:
+                    page_num = extract_page_number(img_path)
+                    final_page = page_num if page_num else current_suggested_page
                 
                 ext = os.path.splitext(img_path)[1]
                 if genealogy_version and genealogy_source:
@@ -1513,7 +1509,7 @@ def upload():
             
         import uuid
         saved_files = []
-        for file in files:
+        for i, file in enumerate(files):
             if not file or not file.filename:
                 continue
             
@@ -1533,17 +1529,20 @@ def upload():
                     
             file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
             file.save(file_path)
-            saved_files.append((filename, file_path))
             
-            if saved_files:
-                threading.Thread(
-                    target=process_files_background,
-                    args=(app.config['UPLOAD_FOLDER'], saved_files, manual_page, suggested_page, genealogy_version, genealogy_source, upload_person)
-                ).start()
-                flash('上传完成,AI解析中,稍后查看')
-                
-            time.sleep(1.5)
-            return redirect(url_for('index'))
+            # Fetch individual page number if it exists
+            file_page = request.form.get(f'page_number_{i}')
+            saved_files.append((filename, file_path, file_page))
+            
+        if saved_files:
+            threading.Thread(
+                target=process_files_background,
+                args=(app.config['UPLOAD_FOLDER'], saved_files, manual_page, suggested_page, genealogy_version, genealogy_source, upload_person)
+            ).start()
+            flash('上传完成,AI解析中,稍后查看')
+            
+        time.sleep(1.5)
+        return redirect(url_for('index'))
                 
     return render_template('upload.html', suggested_page=suggested_page)
 

+ 385 - 132
templates/add_member.html

@@ -3,6 +3,7 @@
 {% block title %}{{ '编辑' if member else '录入' }}成员 - 家谱管理系统{% endblock %}
 
 {% block extra_css %}
+<link href="https://cdn.jsdelivr.net/npm/tom-select@2.2.2/dist/css/tom-select.bootstrap5.min.css" rel="stylesheet">
 <style>
     .split-container { display: flex; height: calc(100vh - 100px); overflow: hidden; }
     .form-panel { flex: 1.2; padding: 20px; overflow-y: auto; border-right: 1px solid #dee2e6; }
@@ -96,10 +97,11 @@
                     <input type="hidden" name="source_record_id" value="{{ source_record_id if source_record_id else (member.source_record_id if member and member.source_record_id else '') }}">
                     <input type="hidden" name="source_index" value="">
                     <div class="section-title">核心信息 (必填)</div>
-                    <div class="row g-3">
+                    <div class="row g-3 mb-4">
                         <div class="col-md-6">
                             <label class="form-label">姓名(繁体) <span class="text-danger">*</span></label>
-                            <input type="text" name="name" class="form-control" required value="{{ member.name if member else '' }}">
+                            <input type="text" name="name" id="nameInput" class="form-control" required value="{{ member.name if member else '' }}">
+                            <div id="nameCheckResult" class="mt-2"></div>
                         </div>
                         <div class="col-md-6">
                             <label class="form-label">姓名(简体)</label>
@@ -126,8 +128,29 @@
                         </div>
                     </div>
 
+                    <div class="section-title">状态信息</div>
+                    <div class="row g-3 mb-4">
+                        <div class="col-md-6">
+                            <label class="form-label">是否过世</label>
+                            <select name="is_pass_away" class="form-select">
+                                <option value="0" {{ 'selected' if member and member.is_pass_away == 0 else '' }}>健在</option>
+                                <option value="1" {{ 'selected' if member and member.is_pass_away == 1 else '' }}>已故</option>
+                                <option value="2" {{ 'selected' if member and member.is_pass_away == 2 else '' }}>未知</option>
+                            </select>
+                        </div>
+                        <div class="col-md-6">
+                            <label class="form-label">婚姻状况</label>
+                            <select name="marital_status" class="form-select">
+                                <option value="0" {{ 'selected' if member and member.marital_status == 0 else '' }}>未知</option>
+                                <option value="1" {{ 'selected' if member and member.marital_status == 1 else '' }}>未婚</option>
+                                <option value="2" {{ 'selected' if member and member.marital_status == 2 else '' }}>已婚</option>
+                                <option value="3" {{ 'selected' if member and member.marital_status == 3 else '' }}>离异/丧偶</option>
+                            </select>
+                        </div>
+                    </div>
+
                     <div class="section-title">关系录入 (选择关联成员及关系)</div>
-                    <div class="row g-3">
+                    <div class="row g-3 mb-4">
                         <div class="col-md-5">
                             <label class="form-label">关联成员</label>
                             <select name="related_mid" class="form-select">
@@ -161,108 +184,112 @@
                             </select>
                         </div>
                     </div>
-
-                    <div class="section-title">谱系详情</div>
-                    <div class="row g-3">
-                        <div class="col-md-4">
-                            <label class="form-label">曾用名</label>
-                            <input type="text" name="former_name" class="form-control" value="{{ member.former_name if member else '' }}">
-                        </div>
-                        <div class="col-md-4">
-                            <label class="form-label">幼名/乳名</label>
-                            <input type="text" name="childhood_name" class="form-control" value="{{ member.childhood_name if member else '' }}">
-                        </div>
-                        <div class="col-md-4">
-                            <label class="form-label">字辈</label>
-                            <input type="text" name="name_word" class="form-control" value="{{ member.name_word if member else '' }}">
-                        </div>
-                        <div class="col-md-4">
-                            <label class="form-label">堂内排行</label>
-                            <input type="text" name="family_rank" class="form-control" value="{{ member.family_rank if member else '' }}">
-                        </div>
-                        <div class="col-md-4">
-                            <label class="form-label">世系世代</label>
-                            <input type="text" name="name_word_generation" class="form-control" value="{{ member.name_word_generation if member else '' }}">
-                        </div>
-                        <div class="col-md-6">
-                            <label class="form-label">名号/封号</label>
-                            <input type="text" name="name_title" class="form-control" value="{{ member.name_title if member else '' }}">
-                        </div>
-                        <div class="col-md-6">
-                            <label class="form-label">分房/堂号</label>
-                            <input type="text" name="branch_family_hall" class="form-control" value="{{ member.branch_family_hall if member else '' }}">
-                        </div>
-                        <div class="col-md-6">
-                            <label class="form-label">聚居地</label>
-                            <input type="text" name="cluster_place" class="form-control" value="{{ member.cluster_place if member else '' }}">
-                        </div>
-                    </div>
-
-                    <div class="section-title">状态与联系</div>
-                    <div class="row g-3">
-                        <div class="col-md-4">
-                            <label class="form-label">是否过世</label>
-                            <select name="is_pass_away" class="form-select">
-                                <option value="0" {{ 'selected' if member and member.is_pass_away == 0 else '' }}>健在</option>
-                                <option value="1" {{ 'selected' if member and member.is_pass_away == 1 else '' }}>已故</option>
-                            </select>
-                        </div>
-                        <div class="col-md-4">
-                            <label class="form-label">婚姻状况</label>
-                            <select name="marital_status" class="form-select">
-                                <option value="0" {{ 'selected' if member and member.marital_status == 0 else '' }}>未知</option>
-                                <option value="1" {{ 'selected' if member and member.marital_status == 1 else '' }}>未婚</option>
-                                <option value="2" {{ 'selected' if member and member.marital_status == 2 else '' }}>已婚</option>
-                                <option value="3" {{ 'selected' if member and member.marital_status == 3 else '' }}>离异/丧偶</option>
-                            </select>
-                        </div>
-                        <div class="col-md-4">
-                            <label class="form-label">民族</label>
-                            <input type="text" name="nation" class="form-control" value="{{ member.nation if member else '' }}">
-                        </div>
-                        <div class="col-md-6">
-                            <label class="form-label">手机号</label>
-                            <input type="text" name="phone" class="form-control" value="{{ member.phone if member else '' }}">
-                        </div>
-                        <div class="col-md-6">
-                            <label class="form-label">微信号</label>
-                            <input type="text" name="wechat_account" class="form-control" value="{{ member.wechat_account if member else '' }}">
-                        </div>
-                        <div class="col-md-12">
-                            <label class="form-label">现居住址</label>
-                            <input type="text" name="residential_address" class="form-control" value="{{ member.residential_address if member else '' }}">
-                        </div>
-                    </div>
-
-                    <div class="section-title">个人履历</div>
-                    <div class="row g-3">
-                        <div class="col-md-6">
-                            <label class="form-label">职业</label>
-                            <textarea name="occupation" class="form-control" rows="2">{{ member.occupation if member else '' }}</textarea>
-                        </div>
-                        <div class="col-md-6">
-                            <label class="form-label">教育背景</label>
-                            <textarea name="educational" class="form-control" rows="2">{{ member.educational if member else '' }}</textarea>
-                        </div>
-                        <div class="col-md-12">
-                            <label class="form-label">标签</label>
-                            <input type="text" name="tags" class="form-control" placeholder="例如:抗战老兵, 教师 (用逗号分隔)" value="{{ member.tags if member else '' }}">
-                        </div>
-                        <div class="col-md-12">
-                            <label class="form-label">人员备注</label>
-                            <textarea name="notes" class="form-control" rows="3">{{ member.notes if member else '' }}</textarea>
-                        </div>
+                    
+                    <div class="section-title">人员备注</div>
+                    <div class="row g-3 mb-4">
                         <div class="col-md-12">
-                            <label class="form-label">个人成就</label>
-                            <textarea name="personal_achievements" class="form-control" rows="3">{{ member.personal_achievements if member else '' }}</textarea>
+                            <textarea name="notes" class="form-control" rows="2">{{ member.notes if member else '' }}</textarea>
                         </div>
                     </div>
-
-                    <div class="d-grid gap-2 mt-5 mb-5">
+                    
+                    <!-- 悬浮的保存按钮,始终保持在一屏内或跟随页面底部 -->
+                    <div class="d-grid gap-2 mb-4 sticky-bottom bg-white py-2 border-top" style="z-index: 1020;">
                         <button type="submit" class="btn btn-success btn-lg">
                             <i class="bi bi-check-circle me-1"></i> {{ '保存修改' if member else '确认录入' }}
                         </button>
                     </div>
+
+                    <!-- 折叠的其他信息区 -->
+                    <div class="accordion" id="accordionExtraInfo">
+                      <div class="accordion-item border-0">
+                        <h2 class="accordion-header" id="headingExtra">
+                          <button class="accordion-button collapsed bg-light text-secondary rounded shadow-sm border" type="button" data-bs-toggle="collapse" data-bs-target="#collapseExtra" aria-expanded="false" aria-controls="collapseExtra">
+                            <i class="bi bi-three-dots me-2"></i> 展开更多其他信息(谱系详情、联络、履历等)
+                          </button>
+                        </h2>
+                        <div id="collapseExtra" class="accordion-collapse collapse" aria-labelledby="headingExtra" data-bs-parent="#accordionExtraInfo">
+                          <div class="accordion-body px-0 pt-3 pb-0">
+                            
+                            <div class="section-title mt-0">谱系详情</div>
+                            <div class="row g-3 mb-4">
+                                <div class="col-md-4">
+                                    <label class="form-label">曾用名</label>
+                                    <input type="text" name="former_name" class="form-control" value="{{ member.former_name if member else '' }}">
+                                </div>
+                                <div class="col-md-4">
+                                    <label class="form-label">幼名/乳名</label>
+                                    <input type="text" name="childhood_name" class="form-control" value="{{ member.childhood_name if member else '' }}">
+                                </div>
+                                <div class="col-md-4">
+                                    <label class="form-label">字辈</label>
+                                    <input type="text" name="name_word" class="form-control" value="{{ member.name_word if member else '' }}">
+                                </div>
+                                <div class="col-md-4">
+                                    <label class="form-label">堂内排行</label>
+                                    <input type="text" name="family_rank" class="form-control" value="{{ member.family_rank if member else '' }}">
+                                </div>
+                                <div class="col-md-4">
+                                    <label class="form-label">世系世代</label>
+                                    <input type="text" name="name_word_generation" class="form-control" value="{{ member.name_word_generation if member else '' }}">
+                                </div>
+                                <div class="col-md-6">
+                                    <label class="form-label">名号/封号</label>
+                                    <input type="text" name="name_title" class="form-control" value="{{ member.name_title if member else '' }}">
+                                </div>
+                                <div class="col-md-6">
+                                    <label class="form-label">分房/堂号</label>
+                                    <input type="text" name="branch_family_hall" class="form-control" value="{{ member.branch_family_hall if member else '' }}">
+                                </div>
+                                <div class="col-md-6">
+                                    <label class="form-label">聚居地</label>
+                                    <input type="text" name="cluster_place" class="form-control" value="{{ member.cluster_place if member else '' }}">
+                                </div>
+                            </div>
+
+                            <div class="section-title">联络信息</div>
+                            <div class="row g-3 mb-4">
+                                <div class="col-md-4">
+                                    <label class="form-label">民族</label>
+                                    <input type="text" name="nation" class="form-control" value="{{ member.nation if member else '' }}">
+                                </div>
+                                <div class="col-md-4">
+                                    <label class="form-label">手机号</label>
+                                    <input type="text" name="phone" class="form-control" value="{{ member.phone if member else '' }}">
+                                </div>
+                                <div class="col-md-4">
+                                    <label class="form-label">微信号</label>
+                                    <input type="text" name="wechat_account" class="form-control" value="{{ member.wechat_account if member else '' }}">
+                                </div>
+                                <div class="col-md-12">
+                                    <label class="form-label">现居住址</label>
+                                    <input type="text" name="residential_address" class="form-control" value="{{ member.residential_address if member else '' }}">
+                                </div>
+                            </div>
+
+                            <div class="section-title">个人履历</div>
+                            <div class="row g-3 mb-2">
+                                <div class="col-md-6">
+                                    <label class="form-label">职业</label>
+                                    <textarea name="occupation" class="form-control" rows="2">{{ member.occupation if member else '' }}</textarea>
+                                </div>
+                                <div class="col-md-6">
+                                    <label class="form-label">教育背景</label>
+                                    <textarea name="educational" class="form-control" rows="2">{{ member.educational if member else '' }}</textarea>
+                                </div>
+                                <div class="col-md-12">
+                                    <label class="form-label">标签</label>
+                                    <input type="text" name="tags" class="form-control" placeholder="例如:抗战老兵, 教师 (用逗号分隔)" value="{{ member.tags if member else '' }}">
+                                </div>
+                                <div class="col-md-12">
+                                    <label class="form-label">个人成就</label>
+                                    <textarea name="personal_achievements" class="form-control" rows="3">{{ member.personal_achievements if member else '' }}</textarea>
+                                </div>
+                            </div>
+                            
+                          </div>
+                        </div>
+                      </div>
+                    </div>
                 </form>
             </div>
         </div>
@@ -272,13 +299,26 @@
     <!-- AI 推理日志及结果面板 -->
     <div id="aiLogPanel" class="position-fixed bottom-0 end-0 p-3 bg-dark text-white shadow" 
          style="display: none; width: 450px; max-height: 85vh; border-radius: 8px 0 0 0; z-index: 1050; opacity: 0.95; overflow-y: auto;">
-        <div class="d-flex justify-content-between align-items-center mb-2 border-bottom border-secondary pb-2 sticky-top bg-dark pt-1">
-            <span class="fw-bold"><i class="bi bi-robot"></i> AI 识别助手</span>
-            <button class="btn btn-sm btn-outline-light py-0" onclick="closeAiLog()">×</button>
+        
+        <!-- 吸顶头部与当前选中详情 -->
+        <div class="sticky-top bg-dark pb-2" style="z-index: 1060; margin-top: -1rem; padding-top: 1rem; border-bottom: 1px solid #444;">
+            <div class="d-flex justify-content-between align-items-center mb-2 pb-1">
+                <span class="fw-bold"><i class="bi bi-robot"></i> AI 识别助手</span>
+                <button class="btn btn-sm btn-outline-light py-0" onclick="closeAiLog()">×</button>
+            </div>
+            
+            <!-- 当前选中详情 -->
+            <div id="aiCurrentDetail" class="mt-2 p-2 bg-secondary bg-opacity-25 rounded border border-info shadow-sm" style="display:none; max-height: 250px; overflow-y: auto;">
+                <div class="d-flex justify-content-between align-items-center mb-2 border-bottom border-secondary pb-1">
+                    <strong class="text-info"><i class="bi bi-info-circle"></i> 当前填充详情</strong>
+                    <button class="btn btn-sm btn-link text-muted py-0 text-decoration-none" onclick="document.getElementById('aiCurrentDetail').style.display='none'">×</button>
+                </div>
+                <div id="aiDetailContent" class="small text-light" style="word-break: break-all;"></div>
+            </div>
         </div>
         
         <!-- 推理过程 -->
-        <div class="mb-3">
+        <div class="mb-3 mt-3">
              <button class="btn btn-sm btn-link text-decoration-none text-light p-0 mb-1 d-flex align-items-center" type="button" data-bs-toggle="collapse" data-bs-target="#collapseReasoning">
                  <i class="bi bi-cpu me-1"></i> 推理过程 <span class="badge bg-secondary ms-2" id="reasoningStatus">进行中...</span>
              </button>
@@ -287,15 +327,6 @@
              </div>
         </div>
 
-        <!-- 当前选中详情 -->
-        <div id="aiCurrentDetail" class="mb-3 p-2 bg-secondary bg-opacity-25 rounded border border-info" style="display:none;">
-            <div class="d-flex justify-content-between align-items-center mb-2 border-bottom border-secondary pb-1">
-                <strong class="text-info"><i class="bi bi-info-circle"></i> 当前填充详情</strong>
-                <button class="btn btn-sm btn-link text-muted py-0 text-decoration-none" onclick="document.getElementById('aiCurrentDetail').style.display='none'">×</button>
-            </div>
-            <div id="aiDetailContent" class="small text-light" style="word-break: break-all; max-height: 200px; overflow-y: auto;"></div>
-        </div>
-
         <!-- 识别结果列表 -->
         <div id="aiResultSection" style="display: none;">
             <div class="d-flex justify-content-between align-items-center mb-2">
@@ -365,7 +396,9 @@
 {% endblock %}
 
 {% block extra_js %}
+<script src="https://cdn.jsdelivr.net/npm/tom-select@2.2.2/dist/js/tom-select.complete.min.js"></script>
 <script>
+    let tomSelectInstance = null;
     function toggleBirthdayUnknown() {
         const cb = document.getElementById('birthdayUnknown');
         const input = document.querySelector('input[name="birthday"]');
@@ -426,7 +459,21 @@
     // Call validation when relation changes too
     document.addEventListener('DOMContentLoaded', () => {
         const relatedSelect = document.querySelector('select[name="related_mid"]');
-        if (relatedSelect) relatedSelect.addEventListener('change', validateAge);
+        if (relatedSelect) {
+            relatedSelect.addEventListener('change', validateAge);
+            if (typeof TomSelect !== 'undefined') {
+                tomSelectInstance = new TomSelect(relatedSelect, {
+                    create: false,
+                    sortField: null,
+                    searchField: ['text'],
+                    render: {
+                        no_results: function(data, escape) {
+                            return '<div class="no-results">未找到匹配项</div>';
+                        }
+                    }
+                });
+            }
+        }
         
         // Initialize birthday unknown state
         toggleBirthdayUnknown();
@@ -962,13 +1009,20 @@
         
         const form = document.querySelector('form');
         form.reset(); // Clear previous data first
+        if (tomSelectInstance) {
+            tomSelectInstance.clear();
+        }
         
         // Set Source Index
         const sourceIndexInput = form.querySelector('[name="source_index"]');
         if (sourceIndexInput) sourceIndexInput.value = index;
 
         // 1. 姓名
-        if (person.name) form.querySelector('[name="name"]').value = person.name;
+        if (person.name) {
+            const nameInput = form.querySelector('[name="name"]');
+            nameInput.value = person.name;
+            nameInput.dispatchEvent(new Event('input')); // 触发重名检测
+        }
         if (person.simplified_name) {
             const snInput = form.querySelector('[name="simplified_name"]');
             if (snInput) snInput.value = person.simplified_name;
@@ -995,27 +1049,87 @@
              toggleBirthdayUnknown();
         }
 
+        let isDeceased = false;
+        let isDeceasedUnknown = true;
+        
         if (person.birthday) {
             let dateVal = person.birthday;
-            // 尝试标准化
-            const dateMatch = dateVal.match(/(\d{4})[-/年](\d{1,2})[-/月](\d{1,2})/);
-            if (dateMatch) {
-                const y = dateMatch[1];
-                const m = dateMatch[2].padStart(2, '0');
-                const d = dateMatch[3].padStart(2, '0');
-                dateVal = `${y}-${m}-${d}`;
+            
+            // Handle partial dates like "1890年" or "1890年?月?日"
+            const partialYearMatch = dateVal.match(/^(\d{4})[^\d]*$/) || dateVal.match(/(\d{4})年\s*[??Xxx]\s*月/i);
+            if (partialYearMatch) {
+                dateVal = `${partialYearMatch[1]}-01-01`;
+            } else {
+                // 尝试标准化完整日期
+                const dateMatch = dateVal.match(/(\d{4})[-/年](\d{1,2})[-/月](\d{1,2})/);
+                if (dateMatch) {
+                    const y = dateMatch[1];
+                    const m = dateMatch[2].padStart(2, '0');
+                    const d = dateMatch[3].padStart(2, '0');
+                    dateVal = `${y}-${m}-${d}`;
+                }
+            }
+            
+            // 只有当日期格式正确时才填充
+            if (/^\d{4}-\d{2}-\d{2}$/.test(dateVal)) {
+                form.querySelector('[name="birthday"]').value = dateVal;
                 
                 // Auto "Is Deceased" Logic (e.g. older than 100 years from now)
-                const birthYear = parseInt(y);
+                const birthYear = parseInt(dateVal.substring(0, 4));
                 const currentYear = new Date().getFullYear();
                 if (currentYear - birthYear > 100) {
-                    const passAwaySelect = form.querySelector('[name="is_pass_away"]');
-                    if (passAwaySelect) passAwaySelect.value = '1';
+                    isDeceased = true;
+                }
+                isDeceasedUnknown = false;
+            } else {
+                // Parse failed, set to unknown
+                if (birthdayUnknownCb) {
+                    birthdayUnknownCb.checked = true;
+                    toggleBirthdayUnknown();
                 }
             }
-            // 只有当日期格式正确时才填充,否则不填或者留给用户
-            if (/^\d{4}-\d{2}-\d{2}$/.test(dateVal)) {
-                form.querySelector('[name="birthday"]').value = dateVal;
+        } else {
+            // No birthday found, automatically check unknown
+            if (birthdayUnknownCb) {
+                birthdayUnknownCb.checked = true;
+                toggleBirthdayUnknown();
+            }
+        }
+        
+        // 当自己年龄不详时,通过父母年龄推断是否在世
+        if (isDeceasedUnknown && person.matches && person.matches.father && person.matches.father.length > 0) {
+            const father = person.matches.father[0];
+            if (father.birthday) {
+                const fatherBirthYear = new Date(father.birthday * 1000).getFullYear();
+                const currentYear = new Date().getFullYear();
+                // 假设如果父亲是120年前出生的,子女大概率也已超过100岁
+                if (currentYear - fatherBirthYear > 120) {
+                    isDeceased = true;
+                    isDeceasedUnknown = false;
+                }
+            }
+        }
+        
+        // 已故状态
+        const passAwaySelect = form.querySelector('[name="is_pass_away"]');
+        if (passAwaySelect) {
+            // "殁", "葬", "卒" in raw text usually means deceased. If AI extracted death_date, also true.
+            if (isDeceased || person.death_date) {
+                passAwaySelect.value = '1'; // 已故
+            } else if (isDeceasedUnknown) {
+                passAwaySelect.value = '2'; // 未知
+            } else {
+                passAwaySelect.value = '0'; // 默认健在,除非有证据
+            }
+        }
+
+        // 4. 婚姻状况
+        const maritalSelect = form.querySelector('[name="marital_status"]');
+        if (maritalSelect) {
+            if (person.spouse_name) {
+                maritalSelect.value = '2'; // 已婚
+            } else {
+                maritalSelect.value = '0'; // 未知
             }
         }
 
@@ -1062,7 +1176,13 @@
         if (newInfo && !currentNotes.includes(newInfo)) {
              notesField.value = currentNotes ? (currentNotes + '\n' + newInfo) : newInfo;
         }
-
+        
+        // 确保无论如何都触发一遍自动匹配事件
+        notesField.dispatchEvent(new Event('input', {bubbles: true}));
+        if (window.checkSpouseInNotes) {
+            window.checkSpouseInNotes();
+        }
+        
         // --- Auto-Linking Logic ---
         if (person.matches) {
             // Priority: Father > Spouse (Configurable?)
@@ -1074,7 +1194,11 @@
                 const relTypeSelect = form.querySelector('[name="relation_type"]');
                 
                 if (relSelect && relTypeSelect) {
-                    relSelect.value = father.id;
+                    if (typeof tomSelectInstance !== 'undefined' && tomSelectInstance) {
+                        tomSelectInstance.setValue(father.id);
+                    } else {
+                        relSelect.value = father.id;
+                    }
                     relTypeSelect.value = '1'; // 父子
                     // Trigger change event if needed by other logic (not needed here yet)
                 }
@@ -1084,7 +1208,11 @@
                 const relTypeSelect = form.querySelector('[name="relation_type"]');
                 
                 if (relSelect && relTypeSelect) {
-                    relSelect.value = spouse.id;
+                    if (typeof tomSelectInstance !== 'undefined' && tomSelectInstance) {
+                        tomSelectInstance.setValue(spouse.id);
+                    } else {
+                        relSelect.value = spouse.id;
+                    }
                     relTypeSelect.value = '10'; // 夫妻
                 }
             }
@@ -1253,16 +1381,41 @@
         return name;
     }
 
+    function isFemaleSex(sexValue) {
+        if (sexValue === null || sexValue === undefined) return false;
+        const s = String(sexValue).trim().toLowerCase();
+        return s === '女' || s === '2' || s === 'female' || s === 'f';
+    }
+
+    function normalizeLookupName(name) {
+        if (!name) return '';
+        return manualSimplify(String(name)).trim();
+    }
+
     // Extracted function to process AI data and render tree
     async function processAiData(data) {
+        const spouseNameSet = new Set();
+        data.forEach(p => {
+            const n = normalizeLookupName(p.spouse_name);
+            if (n) spouseNameSet.add(n);
+        });
+
         // Clean Names First
         data.forEach(p => {
             // Determine "Original" (Raw) and "Simplified" (Cleaned)
             let rawName = p.original_name || p.name;
-            let simName = p.name; // This is the Simplified one from AI if original_name exists
+            let simName = p.name || p.original_name; // Prefer AI simplified name; fallback to raw
+
+            const ownName1 = normalizeLookupName(p.name);
+            const ownName2 = normalizeLookupName(p.original_name);
+            const isFemaleSpouse = isFemaleSex(p.sex) && (
+                !!normalizeLookupName(p.spouse_name) ||
+                (ownName1 && spouseNameSet.has(ownName1)) ||
+                (ownName2 && spouseNameSet.has(ownName2))
+            );
             
-            // Clean the Simplified Name(本人:带“留”姓规则)
-            p.simplified_name = cleanName(simName);
+            // 女性配偶:只繁转简,不拼接“留”;其他人维持原规则
+            p.simplified_name = isFemaleSpouse ? manualSimplify(simName) : cleanName(simName);
             
             // Set the name to be the Raw Name for storage in 'name' column
             p.name = rawName;
@@ -1591,5 +1744,105 @@
             btn.disabled = false;
         }
     }
+    // Check for duplicate name
+    let nameCheckTimeout = null;
+    const nameInput = document.getElementById('nameInput');
+    const nameCheckResult = document.getElementById('nameCheckResult');
+    
+    if (nameInput) {
+        nameInput.addEventListener('input', function() {
+            clearTimeout(nameCheckTimeout);
+            const nameVal = this.value.trim();
+            if (!nameVal) {
+                nameCheckResult.innerHTML = '';
+                return;
+            }
+            
+            nameCheckTimeout = setTimeout(() => {
+                fetch(`/manager/api/check_name?name=${encodeURIComponent(nameVal)}`)
+                    .then(r => r.json())
+                    .then(data => {
+                        if (data.success && data.exists) {
+                            let html = `<div class="alert alert-warning py-2 mb-0 mt-2 small">
+                                <i class="bi bi-exclamation-triangle-fill"></i> 发现 <strong>${data.matches.length}</strong> 个同名记录,请确认是否为同一人:
+                                <ul class="mb-0 mt-1 ps-3">`;
+                            data.matches.forEach(m => {
+                                let sex = m.sex === 1 ? '男' : (m.sex === 2 ? '女' : '未知');
+                                let deadStr = m.is_pass_away == 1 ? ' (已故)' : (m.is_pass_away == 2 ? ' (未知)' : '');
+                                html += `<li><a href="/manager/member_detail/${m.id}" target="_blank" class="alert-link">${m.name}</a> - ${sex} - 出生: ${m.birthday_str}${deadStr}</li>`;
+                            });
+                            html += `</ul></div>`;
+                            nameCheckResult.innerHTML = html;
+                        } else {
+                            nameCheckResult.innerHTML = '';
+                        }
+                    })
+                    .catch(err => console.error('Error checking name:', err));
+            }, 600);
+        });
+    }
+
+    // Auto-link spouse from notes if female
+    const notesInput = document.querySelector('textarea[name="notes"]');
+    const sexSelect = document.querySelector('select[name="sex"]');
+    
+    // Attach to window so fillForm can explicitly call it
+    window.checkSpouseInNotes = function() {
+        if (!notesInput || !sexSelect) return;
+        
+        // Only trigger if female
+        if (sexSelect.value === '2') {
+            const val = notesInput.value;
+            // Match cases like "配偶:张三", "配偶:张三", "配偶 张三", "配偶张三"
+            // We use a robust regex to get the word after 配偶
+            const match = val.match(/配偶[::\s]*([^\s;;,,。]+)/);
+            if (match && match[1]) {
+                const spouseName = match[1].trim();
+                const normalizedSpouse = spouseName.replace(/公$/, '').replace(/^留/, '');
+                const relatedSelect = document.querySelector('select[name="related_mid"]');
+                const relationTypeSelect = document.querySelector('select[name="relation_type"]');
+                
+                if (relatedSelect && relationTypeSelect) {
+                    for (let i = 0; i < relatedSelect.options.length; i++) {
+                        const opt = relatedSelect.options[i];
+                        if (!opt.value) continue;
+                        
+                        const optText = opt.text.trim();
+                        // Extract name before " (ID:" robustly
+                        const optName = optText.replace(/\s*\(ID:.*$/, '').trim();
+                        const normalizedOpt = optName.replace(/公$/, '').replace(/^留/, '');
+                        
+                        // Match exact or without '公' suffix and without '留' prefix
+                        if (optName === spouseName || normalizedOpt === normalizedSpouse || 
+                            (normalizedOpt && normalizedSpouse && (normalizedOpt.includes(normalizedSpouse) || normalizedSpouse.includes(normalizedOpt)))) {
+                            // If not already selected, select it and set relation to Spouse
+                            if (relatedSelect.value !== opt.value) {
+                                if (typeof tomSelectInstance !== 'undefined' && tomSelectInstance) {
+                                    tomSelectInstance.setValue(opt.value);
+                                } else {
+                                    relatedSelect.value = opt.value;
+                                }
+                                relationTypeSelect.value = '10'; // 10 is Spouse
+                                
+                                // Optional visual feedback to user
+                                notesInput.style.transition = "background-color 0.3s";
+                                notesInput.style.backgroundColor = "#e8f5e9";
+                                setTimeout(() => notesInput.style.backgroundColor = "", 1000);
+                                
+                                console.log("Auto-linked spouse: ", optName);
+                            }
+                            break;
+                        }
+                    }
+                }
+            }
+        }
+    };
+    
+    if (notesInput && sexSelect) {
+        notesInput.addEventListener('input', window.checkSpouseInNotes);
+        sexSelect.addEventListener('change', window.checkSpouseInNotes);
+    }
+
 </script>
 {% endblock %}

+ 80 - 11
templates/index.html

@@ -142,7 +142,7 @@
                     <tr>
                         <td class="px-4">
                             <div class="d-flex align-items-center">
-                                <img src="{{ record.oss_url }}" class="thumbnail-img me-3" onclick="openImageViewer('{{ record.oss_url }}', {{ record.page_number }})" title="点击预览">
+                                <img src="{{ record.oss_url }}" class="thumbnail-img me-3 list-preview-img" data-url="{{ record.oss_url }}" data-page="{{ record.page_number or '' }}" title="点击预览" onclick="openImageViewer('{{ record.oss_url }}', '{{ record.page_number or '' }}')">
                                 <div class="text-break fw-bold">
                                     {% if record.file_type == 'PDF' %}
                                     <span class="badge bg-danger me-1">PDF</span>
@@ -186,7 +186,7 @@
                         </td>
                         <td>{{ record.upload_time }}</td>
                         <td class="text-center">
-                            <button onclick="openImageViewer('{{ record.oss_url }}', {{ record.page_number }})" class="btn btn-sm btn-outline-info" title="查看原图">
+                            <button onclick="openImageViewer('{{ record.oss_url }}', '{{ record.page_number or '' }}')" class="btn btn-sm btn-outline-info list-preview-btn" data-url="{{ record.oss_url }}" data-page="{{ record.page_number or '' }}" title="查看原图">
                                 <i class="bi bi-eye"></i>
                             </button>
                             
@@ -264,9 +264,10 @@
         <div class="modal-content bg-light">
             <div class="modal-header py-2">
                 <h5 class="modal-title fs-6"><i class="bi bi-image"></i> 扫描件预览 <span id="modalPageNum" class="badge bg-secondary ms-2"></span></h5>
+                <div class="ms-auto me-3 text-muted small" id="listImageIndexIndicator"></div>
                 <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
             </div>
-            <div class="modal-body p-0 modal-image-container">
+            <div class="modal-body p-0 modal-image-container position-relative">
                 <div class="image-toolbar">
                     <div class="btn-group btn-group-sm">
                         <button type="button" class="btn btn-outline-secondary" onclick="rotateImage(-90)" title="左旋90°"><i class="bi bi-arrow-counterclockwise"></i></button>
@@ -284,6 +285,14 @@
                         <label class="form-check-label small" for="magnifierSwitch">🔍 放大镜</label>
                     </div>
                 </div>
+                
+                <button class="btn btn-dark position-absolute top-50 start-0 translate-middle-y ms-3 rounded-circle shadow" id="listPrevImageBtn" style="width: 48px; height: 48px; opacity: 0.7; z-index: 1050; display: none;">
+                    <i class="bi bi-chevron-left fs-4"></i>
+                </button>
+                <button class="btn btn-dark position-absolute top-50 end-0 translate-middle-y me-3 rounded-circle shadow" id="listNextImageBtn" style="width: 48px; height: 48px; opacity: 0.7; z-index: 1050; display: none;">
+                    <i class="bi bi-chevron-right fs-4"></i>
+                </button>
+
                 <div class="image-viewer shadow-inner" id="viewer">
                     <div id="magnifier" class="magnifier-glass"></div>
                     <div id="imageWrapper" class="image-wrapper">
@@ -298,14 +307,6 @@
 
 {% block extra_js %}
 <script>
-    function openImageViewer(url, pageNum) {
-        const modal = new bootstrap.Modal(document.getElementById('imageModal'));
-        document.getElementById('refImage').src = url;
-        document.getElementById('modalPageNum').textContent = pageNum ? '第 ' + pageNum + ' 页' : '';
-        modal.show();
-        resetFilters();
-    }
-
     // --- Image Viewer Logic ---
     let imgRotation = 0;
     let imgBrightness = 100;
@@ -317,6 +318,74 @@
     let isZoomedIn = false;
     const ZOOM_LEVEL = 2.0;
 
+    let currentListImages = [];
+    let currentListIndex = 0;
+
+    document.addEventListener('DOMContentLoaded', () => {
+        // Collect all previewable images from the current table
+        const imgs = document.querySelectorAll('.list-preview-img');
+        imgs.forEach((img, idx) => {
+            currentListImages.push({
+                url: img.dataset.url,
+                page: img.dataset.page
+            });
+        });
+        
+        // Add click events to buttons
+        document.getElementById('listPrevImageBtn').addEventListener('click', () => {
+            if (currentListIndex > 0) openListImage(currentListIndex - 1);
+        });
+        
+        document.getElementById('listNextImageBtn').addEventListener('click', () => {
+            if (currentListIndex < currentListImages.length - 1) openListImage(currentListIndex + 1);
+        });
+        
+        // Keyboard navigation
+        document.getElementById('imageModal').addEventListener('keydown', (e) => {
+            if (e.key === 'ArrowLeft' && currentListIndex > 0) {
+                openListImage(currentListIndex - 1);
+            } else if (e.key === 'ArrowRight' && currentListIndex < currentListImages.length - 1) {
+                openListImage(currentListIndex + 1);
+            }
+        });
+    });
+
+    function openListImage(index) {
+        if (index < 0 || index >= currentListImages.length) return;
+        currentListIndex = index;
+        const data = currentListImages[index];
+        openImageViewer(data.url, data.page);
+    }
+
+    function openImageViewer(url, pageNum) {
+        // Update index if opened via direct click instead of arrows
+        const foundIdx = currentListImages.findIndex(img => img.url === url);
+        if (foundIdx !== -1) currentListIndex = foundIdx;
+        
+        const modalEl = document.getElementById('imageModal');
+        let modal = bootstrap.Modal.getInstance(modalEl);
+        if (!modal) {
+            modal = new bootstrap.Modal(modalEl);
+        }
+        
+        document.getElementById('refImage').src = url;
+        document.getElementById('modalPageNum').textContent = pageNum ? '第 ' + pageNum + ' 页' : '';
+        
+        // Update indicator and buttons
+        if (currentListImages.length > 0 && foundIdx !== -1) {
+            document.getElementById('listImageIndexIndicator').textContent = `${currentListIndex + 1} / ${currentListImages.length}`;
+            document.getElementById('listPrevImageBtn').style.display = currentListIndex === 0 ? 'none' : 'block';
+            document.getElementById('listNextImageBtn').style.display = currentListIndex === currentListImages.length - 1 ? 'none' : 'block';
+        } else {
+            document.getElementById('listImageIndexIndicator').textContent = '';
+            document.getElementById('listPrevImageBtn').style.display = 'none';
+            document.getElementById('listNextImageBtn').style.display = 'none';
+        }
+
+        modal.show();
+        resetFilters();
+    }
+
     const viewer = document.getElementById('viewer');
     const magnifier = document.getElementById('magnifier');
     const magnifierSwitch = document.getElementById('magnifierSwitch');

+ 90 - 35
templates/tree.html

@@ -14,13 +14,41 @@
         box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
         position: relative;
         overflow: auto;
+        scroll-behavior: smooth;
+    }
+    
+    #tree-container::-webkit-scrollbar {
+        width: 8px;
+        height: 8px;
+    }
+    
+    #tree-container::-webkit-scrollbar-track {
+        background: #f1f1f1;
+        border-radius: 4px;
+    }
+    
+    #tree-container::-webkit-scrollbar-thumb {
+        background: #c1c1c1;
+        border-radius: 4px;
+    }
+    
+    #tree-container::-webkit-scrollbar-thumb:hover {
+        background: #a1a1a1;
     }
     .node rect, .node circle { 
         stroke-width: 2px; 
         filter: drop-shadow(0 2px 4px rgba(0,0,0,0.1));
     }
     .node text { font: 13px 'Microsoft YaHei', sans-serif; }
-    .node .node-name { font-size: 13px; font-weight: 500; fill: #334155; }
+    .node .node-name { 
+        font-size: 13px; 
+        font-weight: 500; 
+        fill: #334155; 
+        stroke: #fff; 
+        stroke-width: 4px; 
+        paint-order: stroke; 
+        stroke-linejoin: round; 
+    }
     
     /* 样图一致:较粗的细实线、浅灰蓝色,显得专业 */
     .link { fill: none; stroke: #94A3B8; stroke-width: 2px; stroke-linejoin: round; }
@@ -73,7 +101,11 @@
 {% block content %}
 <div class="d-flex justify-content-between align-items-center mb-3">
     <h2><i class="bi bi-diagram-3"></i> 家谱关系树状图</h2>
-    <!-- <p class="text-muted mb-0">基于父子/母子关系的生物遗传图谱</p> -->
+    <div>
+        <a href="{{ url_for('tree_classic') }}" class="btn btn-outline-primary" target="_blank">
+            <i class="bi bi-printer"></i> 导出传统吊线图
+        </a>
+    </div>
 </div>
 
     <div class="alert alert-light border small py-2">
@@ -243,24 +275,24 @@
         
         // 动态调整间距,保证节点绝对不重叠,长辈/同辈/配偶使用固定基础间距
         const nodeWidth = 90; // 基础宽度
-        const nodeHeight = 220; // 基础高度
+        const nodeHeight = 260; // 基础高度,调大以避免上下层重叠
         const treemap = d3.tree().nodeSize([nodeWidth, nodeHeight]).separation((a, b) => {
-            const aSp = a.data && a.data.isSpouseNode;
-            const bSp = b.data && b.data.isSpouseNode;
-            if (aSp || bSp) return 1.8;
-            return a.parent === b.parent ? 1.6 : 2.5;
+            return a.parent === b.parent ? 1.4 : 2.0;
         });
         
         let nodesHier = treemap(rootNode);
 
-        // 样图一致:配偶与当事人同代、同一水平线并排,且水平至少间隔 spouseGap
-        const spouseGap = 110;
-        nodesHier.descendants().forEach(d => {
-            if (d.data && d.data.isSpouseNode && d.parent) {
-                d.y = d.parent.y;
-                const dx = d.x - d.parent.x;
-                if (Math.abs(dx) < spouseGap) d.x = d.parent.x + (dx >= 0 ? spouseGap : -spouseGap);
-            }
+        // 配偶固定在“下层半级”:纵向放到两代中间;横向围绕本人轻度展开
+        const spouseHalfLevelOffsetY = Math.round(nodeHeight * 0.5); // 半级
+        const spouseSpreadX = 110; // 多配偶时的横向间距
+        nodesHier.descendants().forEach(parent => {
+            const spouses = (parent.children || []).filter(c => c.data && c.data.isSpouseNode);
+            if (spouses.length === 0) return;
+            spouses.forEach((spouse, idx) => {
+                const order = idx - (spouses.length - 1) / 2;
+                spouse.x = parent.x + order * spouseSpreadX;
+                spouse.y = parent.y + spouseHalfLevelOffsetY;
+            });
         });
 
         // 计算边界以动态设置 SVG 宽高,实现自动滚动不挤压
@@ -273,11 +305,19 @@
             if (d.y > y1) y1 = d.y;
         });
 
-        const svgWidth = Math.max(containerWidth, x1 - x0 + margin.left + margin.right);
+        // 增加更宽的边距以确保左侧不被切断
+        const minWidth = containerWidth;
+        const calculatedWidth = x1 - x0 + margin.left + margin.right + 400; // 增加额外的宽度缓冲
+        const svgWidth = Math.max(minWidth, calculatedWidth);
         const svgHeight = Math.max(600, y1 + margin.top + margin.bottom);
         
-        // 偏移量使得最小的 x 在 margin.left 位置,且如果内容较少则居中
-        const offsetX = Math.max(margin.left, (containerWidth - (x1 - x0)) / 2 - x0);
+        // 修正偏移量计算:确保最小的 x0 节点完全在可视区域内(加上足够的左边距)
+        // 这样即便是负的很大,也会被完整平移到正数区域
+        const extraLeftMargin = 200; // 增加更多左侧空间
+        let offsetX = margin.left - x0 + extraLeftMargin; // 强制将最左侧节点右移确保文字不被截断
+        
+        // 确保offsetX至少为margin.left,防止内容被左侧菜单遮挡
+        offsetX = Math.max(offsetX, margin.left + 50);
 
         const svg = d3.select("#tree-container").append("svg")
             .attr("width", svgWidth)
@@ -310,17 +350,23 @@
             const realChildren = (node.children || []).filter(c => c.data && !c.data.isSpouseNode);
             const spouses = (node.children || []).filter(c => c.data && c.data.isSpouseNode);
 
-            const spouseY = node.y + 65; // 第一层:夫妻下方 U 型横线高度
+            // 配偶连线拐点:位于本人与配偶节点之间,匹配“下层半级”显示
+            const hY = node.y + Math.round(spouseHalfLevelOffsetY * 0.55);
             const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
 
-            // 夫妻关系:U型连线(增加第二层连线,主线在连出来
+            // 夫妻关系:倒U型连线(主节点向下,然后横向连到配偶上面
             if (spouses.length > 0) {
-                const x1 = Math.min(node.x, spouses[0].x), x2 = Math.max(node.x, spouses[0].x);
                 const g = svg.append("g").attr("class", "link-group");
-                const pathD = `M${num(node.x)},${num(node.y + circleR)} L${num(node.x)},${num(spouseY)} M${num(spouses[0].x)},${num(node.y + circleR)} L${num(spouses[0].x)},${num(spouseY)} M${num(x1)},${num(spouseY)} L${num(x2)},${num(spouseY)}`;
-                g.append("path").attr("class", "link link-spouse").attr("d", pathD);
-                
-                addBadge(g, (x1 + x2) / 2, spouseY, "配偶");
+                spouses.forEach((spouse, idx) => {
+                    // 主节点向下,拐向配偶,再向下连接配偶顶部 (spouse.y - circleR)
+                    const pathD = `M${num(node.x)},${num(node.y + circleR)} 
+                                   L${num(node.x)},${num(hY)} 
+                                   L${num(spouse.x)},${num(hY)} 
+                                   L${num(spouse.x)},${num(spouse.y - circleR)}`;
+                    g.append("path").attr("class", "link link-spouse").attr("d", pathD);
+                    
+                    if (idx === 0) addBadge(g, (node.x + spouse.x) / 2, hY + 14, "配偶");
+                });
             }
 
             if (realChildren.length === 0) return;
@@ -329,15 +375,20 @@
             const minChildX = d3.min(realChildren, c => c.x);
             const maxChildX = d3.max(realChildren, c => c.x);
             
+            // 如果有配偶,子代主线从配偶连线的中间引出,否则从自己直接引出
             const startX = spouses.length > 0 ? (node.x + spouses[0].x) / 2 : node.x;
-            const startY = spouses.length > 0 ? spouseY : node.y + circleR;
+            const startY = spouses.length > 0 ? hY : node.y + circleR;
             
-            const sibsY = childrenY - 55; // 第二层:子女上方的水平分支线(拉高一点,给徽章留足空间)
+            // 将子女横线也相应地下移一些,避免和配偶长名字重叠
+            const sibsY = childrenY - 60; 
 
             const g = svg.append("g").attr("class", "link-group");
             
+            const hLineLeft = Math.min(minChildX, startX);
+            const hLineRight = Math.max(maxChildX, startX);
+
             // 主线:从配偶U型线中点连到子女水平线
-            let pathD = `M${num(startX)},${num(startY)} L${num(startX)},${num(sibsY)} L${num(minChildX)},${num(sibsY)} M${num(startX)},${num(sibsY)} L${num(maxChildX)},${num(sibsY)}`;
+            let pathD = `M${num(startX)},${num(startY)} L${num(startX)},${num(sibsY)} M${num(hLineLeft)},${num(sibsY)} L${num(hLineRight)},${num(sibsY)}`;
             
             // 短竖线:连到每个子女顶部
             realChildren.forEach(child => {
@@ -400,37 +451,41 @@
                 .on("drag", dragged)
                 .on("end", dragended));
 
-        // 图形:男性方形,女性圆形,更符合生物遗传图谱(不再需要性别符号)
+        // 图形:男性方形,女性圆形,更符合生物遗传图谱
         node.each(function(d) {
             const el = d3.select(this);
-            if (d.data.sex === 1) { // 男性为方形
+            if (d.data.sex === 1) { 
                 el.append("rect")
                   .attr("x", -circleR).attr("y", -circleR)
                   .attr("width", circleR * 2).attr("height", circleR * 2)
                   .style("cursor", "grab");
-            } else { // 女性为圆形(未知性别默认圆)
+            } else { 
                 el.append("circle")
                   .attr("r", circleR)
                   .style("cursor", "grab");
             }
         });
-        // 人名:严格在图形下方,与图形底边留出间距,长名截断+悬停显示全名
-        const nameOffsetY = circleR + 20;
-        const maxNameLen = 8;
+        
+        // 人名:往下移动更多,避免和长方形/圆形图形或者连线重叠
+        const nameOffsetY = circleR + 25; // 增加间距
+        const maxNameLen = 12; // 允许稍微长一点的文字
         function fullName(d) {
             if (!d.data) return '';
             return d.data.simplified_name ? `${d.data.name || ''} (${d.data.simplified_name})` : (d.data.name || '');
         }
+        
         const nameGroup = node.append("g").attr("class", "node-name-wrap").attr("transform", "translate(0, " + nameOffsetY + ")");
         nameGroup.each(function(d) {
             const g = d3.select(this);
             const full = fullName(d);
             const disp = full.length <= maxNameLen ? full : full.slice(0, maxNameLen) + '…';
             
-            // 允许指针事件以触发 tooltip (title)
+            // 姓名可能较长,我们这里做个简单的多行拆分显示或者让它有一个白色背景遮挡线
+            // 这里为了简单不破坏原有结构,仅使用 text,但可以给一个白色的 stroke 做底或者调整 y 坐标
             const textNode = g.append("text")
                 .attr("class", "node-name")
                 .attr("x", 0).attr("y", 0)
+                .attr("dy", "0.8em") // 使得文字基线往下靠,进一步远离图形
                 .attr("text-anchor", "middle")
                 .style("pointer-events", "all")
                 .style("cursor", "default")

+ 752 - 0
templates/tree_classic.html

@@ -0,0 +1,752 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <title>传统吊线图导出 - 家谱管理系统</title>
+    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
+    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css">
+    <style>
+        body {
+            background-color: #f0f2f5;
+            padding: 20px;
+            font-family: "Microsoft YaHei", "SimSun", serif;
+        }
+        .toolbar {
+            background: white;
+            padding: 15px;
+            border-radius: 8px;
+            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+            margin-bottom: 20px;
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+        }
+        
+        .export-container {
+            margin: 0 auto;
+            display: flex;
+            flex-direction: column;
+            gap: 40px;
+            align-items: center;
+        }
+
+        .page-block {
+            background-color: #fff;
+            padding: 40px 60px;
+            border: 1px solid #ddd;
+            box-shadow: 0 4px 12px rgba(0,0,0,0.1);
+            position: relative;
+            /* 类似A4纸横向或纵向,这里允许根据内容自适应宽度 */
+            min-width: 1000px;
+            overflow-x: auto;
+        }
+
+        .page-header {
+            text-align: center;
+            margin-bottom: 30px;
+            position: relative;
+        }
+        
+        .page-title {
+            font-size: 28px;
+            font-weight: bold;
+            letter-spacing: 4px;
+            font-family: "KaiTi", "SimSun", serif;
+        }
+
+        .page-subtitle {
+            position: absolute;
+            right: 0;
+            top: 10px;
+            font-size: 14px;
+            color: #333;
+        }
+        
+        .page-left-title {
+            position: absolute;
+            left: 0;
+            top: 10px;
+            font-size: 14px;
+            color: #333;
+        }
+
+        /* SVG 样式 */
+        .tree-svg {
+            display: block;
+            margin: 0 auto;
+            overflow: visible;
+        }
+
+        .line {
+            fill: none;
+            stroke: #333;
+            stroke-width: 1.5px;
+            shape-rendering: crispEdges;
+        }
+
+        .gen-text {
+            font-size: 16px;
+            fill: #d32f2f;
+            font-weight: bold;
+            font-family: "KaiTi", "SimSun", serif;
+        }
+
+        .node-rect {
+            fill: #fff;
+        }
+
+        .node-text {
+            font-size: 18px;
+            font-weight: bold;
+            fill: #000;
+            font-family: "KaiTi", "SimSun", serif;
+        }
+        .node-text.selected-root-text {
+            fill: #0d6efd;
+            font-weight: 700;
+        }
+        .node-selector {
+            cursor: pointer;
+        }
+        .node-selector-box {
+            fill: #fff;
+            stroke: #999;
+            stroke-width: 1.2px;
+            rx: 2px;
+            ry: 2px;
+        }
+        .node-selector-box.checked {
+            fill: #0d6efd;
+            stroke: #0d6efd;
+        }
+        .node-selector-mark {
+            font-size: 10px;
+            fill: #fff;
+            font-family: Arial, sans-serif;
+            pointer-events: none;
+        }
+        .selected-member-chip {
+            display: inline-flex;
+            align-items: center;
+            gap: 6px;
+            padding: 4px 8px;
+            border: 1px solid #ced4da;
+            border-radius: 12px;
+            margin: 4px 6px 0 0;
+            font-size: 13px;
+            background: #f8f9fa;
+        }
+        .selected-member-chip button {
+            border: none;
+            background: transparent;
+            color: #dc3545;
+            font-size: 14px;
+            line-height: 1;
+            padding: 0 2px;
+            cursor: pointer;
+        }
+        
+        .node-spouse {
+            font-size: 12px;
+            fill: #555;
+            font-family: "KaiTi", "SimSun", serif;
+        }
+
+        .node-mark {
+            font-size: 12px;
+            fill: #d32f2f;
+            font-family: "Microsoft YaHei", sans-serif;
+        }
+        
+        .rel-text {
+            font-size: 12px;
+            fill: #666;
+            font-family: "KaiTi", "SimSun", serif;
+        }
+
+    </style>
+</head>
+<body>
+    <div class="container-fluid">
+        <div class="toolbar d-print-none">
+            <div>
+                <a href="/manager/tree" class="btn btn-outline-secondary me-2"><i class="bi bi-arrow-left"></i> 返回</a>
+                <h4 class="d-inline mb-0 align-middle">传统吊线图预览</h4>
+            </div>
+            <div>
+                <button class="btn btn-outline-primary me-2" data-bs-toggle="modal" data-bs-target="#memberSelectModal">
+                    <i class="bi bi-person-lines-fill"></i> 选择起始人员
+                </button>
+                <button id="btnExport" class="btn btn-primary"><i class="bi bi-download"></i> 导出为图片</button>
+            </div>
+        </div>
+
+        <div id="loading" class="text-center py-5">
+            <div class="spinner-border text-primary" role="status"></div>
+            <p class="mt-2 text-muted">正在加载并生成排版...</p>
+        </div>
+
+        <div class="export-container" id="exportArea">
+            <!-- 页面块将通过JS生成插入到这里 -->
+        </div>
+    </div>
+
+    <!-- 人员选择 Modal -->
+    <div class="modal fade" id="memberSelectModal" tabindex="-1" aria-hidden="true">
+      <div class="modal-dialog modal-dialog-scrollable">
+        <div class="modal-content">
+          <div class="modal-header">
+            <h5 class="modal-title">选择要导出的起始人员(祖先)</h5>
+            <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+          </div>
+          <div class="modal-body">
+            <div class="mb-3">
+                <div class="small text-muted mb-1">已勾选起始人员(可删除)</div>
+                <div id="selectedMembersPanel"></div>
+            </div>
+            <input type="text" id="memberSearch" class="form-control mb-3" placeholder="搜索姓名...">
+            <div class="mb-2">
+                <button class="btn btn-sm btn-outline-secondary" onclick="selectAllMembers(true)">全选</button>
+                <button class="btn btn-sm btn-outline-secondary" onclick="selectAllMembers(false)">全不选</button>
+                <span class="text-muted small ms-2">可搜索增加人员;已勾选人员会显示在上方</span>
+            </div>
+            <div id="memberList" class="list-group">
+                <!-- 成员复选框列表 -->
+            </div>
+          </div>
+          <div class="modal-footer">
+            <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
+            <button type="button" class="btn btn-primary" onclick="drawSelectedTree()">开始绘制</button>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
+    
+    <script>
+        // 配置常量
+        const CONFIG = {
+            MAX_GEN_PER_PAGE: 10,  // 每页最多显示的代数
+            X_STEP: 55,           // 列宽
+            Y_STEP: 160,          // 行高 (代与代之间的距离)
+            NODE_WIDTH: 24,       // 名字竖排的估计宽度
+            NODE_HEIGHT: 80,      // 名字竖排的估计高度
+            MARGIN_LEFT: 60,      // 左侧世代标记留白
+            MARGIN_TOP: 20,       // 顶部留白
+            MARGIN_BOTTOM: 40,    // 底部留白
+            LINE_MID_OFFSET: 40   // 横线距离父节点底部的距离
+        };
+
+        let totalPagesCount = 0;
+        let allMembersData = [];
+        let allRelationsData = [];
+        let selectedRootIdsSet = new Set();
+        let showChartSelectors = true; // 图中快速勾选框显示开关
+
+        document.addEventListener('DOMContentLoaded', function() {
+            loadTreeData();
+
+            // Search filter for member list
+            document.getElementById('memberSearch').addEventListener('input', function() {
+                const term = this.value.trim().toLowerCase();
+                const labels = document.querySelectorAll('#memberList label');
+                labels.forEach(lbl => {
+                    const text = lbl.textContent.toLowerCase();
+                    if (text.includes(term)) {
+                        lbl.style.display = 'block';
+                    } else {
+                        lbl.style.display = 'none';
+                    }
+                });
+            });
+            document.getElementById('memberSelectModal').addEventListener('show.bs.modal', function() {
+                syncCheckboxesFromSelectedSet();
+                renderSelectedMembersPanel();
+            });
+
+            document.getElementById('btnExport').addEventListener('click', function() {
+                const element = document.getElementById('exportArea');
+                const btn = this;
+                const originalHtml = btn.innerHTML;
+                
+                btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 正在渲染...';
+                btn.disabled = true;
+
+                setTimeout(() => {
+                    html2canvas(element, {
+                        scale: 2,
+                        backgroundColor: '#f0f2f5',
+                        logging: false
+                    }).then(canvas => {
+                        const imgData = canvas.toDataURL('image/jpeg', 0.9);
+                        const link = document.createElement('a');
+                        link.download = '家谱吊线图_' + new Date().getTime() + '.jpg';
+                        link.href = imgData;
+                        link.click();
+                        
+                        btn.innerHTML = originalHtml;
+                        btn.disabled = false;
+                    }).catch(err => {
+                        console.error('导出失败:', err);
+                        alert('导出失败,请重试。');
+                        btn.innerHTML = originalHtml;
+                        btn.disabled = false;
+                    });
+                }, 500);
+            });
+        });
+
+        function selectAllMembers(checked) {
+            document.querySelectorAll('.member-checkbox').forEach(cb => {
+                if (cb.parentElement.style.display !== 'none') {
+                    cb.checked = checked;
+                }
+            });
+            syncSelectedSetFromCheckboxes();
+            renderSelectedMembersPanel();
+        }
+
+        function syncSelectedSetFromCheckboxes() {
+            selectedRootIdsSet = new Set(
+                Array.from(document.querySelectorAll('.member-checkbox:checked'))
+                    .map(cb => parseInt(cb.value))
+                    .filter(v => !Number.isNaN(v))
+            );
+        }
+
+        function syncCheckboxesFromSelectedSet() {
+            document.querySelectorAll('.member-checkbox').forEach(cb => {
+                const id = parseInt(cb.value);
+                cb.checked = selectedRootIdsSet.has(id);
+            });
+        }
+
+        function renderSelectedMembersPanel() {
+            const panel = document.getElementById('selectedMembersPanel');
+            if (!panel) return;
+            const selectedMembers = allMembersData.filter(m => selectedRootIdsSet.has(m.id));
+            if (selectedMembers.length === 0) {
+                panel.innerHTML = '<span class="text-muted small">当前未勾选任何人员</span>';
+                return;
+            }
+            panel.innerHTML = selectedMembers.map(m => `
+                <span class="selected-member-chip">
+                    <span>${m.name || ('ID:' + m.id)}</span>
+                    <button type="button" data-remove-id="${m.id}" title="移除">×</button>
+                </span>
+            `).join('');
+            panel.querySelectorAll('button[data-remove-id]').forEach(btn => {
+                btn.addEventListener('click', function() {
+                    const id = parseInt(this.dataset.removeId);
+                    if (Number.isNaN(id)) return;
+                    selectedRootIdsSet.delete(id);
+                    syncCheckboxesFromSelectedSet();
+                    renderSelectedMembersPanel();
+                });
+            });
+        }
+
+        function drawSelectedTree() {
+            const selectedIds = Array.from(document.querySelectorAll('.member-checkbox:checked')).map(cb => parseInt(cb.value));
+            if (selectedIds.length === 0) {
+                alert('请至少选择一位起始人员。');
+                return;
+            }
+            selectedRootIdsSet = new Set(selectedIds);
+            showChartSelectors = false; // 点击“开始绘制”后进入正式输出模式,不显示勾选框
+            
+            // Close modal
+            const modalEl = document.getElementById('memberSelectModal');
+            const modal = bootstrap.Modal.getInstance(modalEl);
+            if(modal) modal.hide();
+            
+            document.getElementById('loading').style.display = 'block';
+            document.getElementById('exportArea').innerHTML = '';
+            
+            // Allow UI to update before heavy computation
+            setTimeout(() => {
+                buildAndRenderPages(allMembersData, allRelationsData, selectedIds);
+            }, 100);
+        }
+
+        async function loadTreeData() {
+            try {
+                const response = await fetch('/manager/api/tree_data');
+                const data = await response.json();
+                if (data.error) return alert('获取数据失败: ' + data.error);
+                
+                allMembersData = data.members;
+                allRelationsData = data.relations;
+                
+                // Populate member list in modal
+                const listHtml = allMembersData.map(m => {
+                    const gen = m.family_rank ? ` (排行/代数: ${m.family_rank})` : '';
+                    return `
+                        <label class="list-group-item">
+                            <input class="form-check-input me-1 member-checkbox" type="checkbox" value="${m.id}">
+                            ${m.name}${gen}
+                        </label>
+                    `;
+                }).join('');
+                document.getElementById('memberList').innerHTML = listHtml;
+
+                document.querySelectorAll('.member-checkbox').forEach(cb => {
+                    cb.addEventListener('change', syncSelectedSetFromCheckboxes);
+                    cb.addEventListener('change', renderSelectedMembersPanel);
+                });
+                
+                // default build using empty selection (will find absolute roots)
+                buildAndRenderPages(data.members, data.relations, []);
+            } catch (error) {
+                console.error(error);
+                document.getElementById('loading').innerHTML = '<span class="text-danger">加载失败,请检查网络并重试。</span>';
+            }
+        }
+
+        function extractGen(rankStr) {
+            if (!rankStr) return null;
+            const match = String(rankStr).match(/\d+/);
+            return match ? parseInt(match[0]) : null;
+        }
+
+        function buildAndRenderPages(members, relations, selectedRootIds) {
+            // 1. 构建树结构
+            const memberMap = {};
+            // Deep clone members to avoid state pollution between multiple drawings
+            const clonedMembers = members.map(m => ({...m, children: [], spouses: []}));
+            
+            clonedMembers.forEach(m => {
+                m.gen = extractGen(m.family_rank);
+                memberMap[m.id] = m;
+            });
+
+            relations.forEach(rel => {
+                const parent = memberMap[rel.parent_mid];
+                const child = memberMap[rel.child_mid];
+                if (!parent || !child) return;
+
+                if (rel.relation_type === 1 || rel.relation_type === 2) {
+                    parent.children.push(child);
+                    child._hasParent = true;
+                } else if (rel.relation_type === 10) {
+                    parent.spouses.push(child);
+                    child._isSpouse = true;
+                }
+            });
+
+            // 寻找根节点并推断代数
+            let roots = [];
+            if (selectedRootIds && selectedRootIds.length > 0) {
+                roots = selectedRootIds.map(id => memberMap[id]).filter(m => m);
+            } else {
+                roots = clonedMembers.filter(m => !m._hasParent && !m._isSpouse);
+            }
+            
+            // 递归填充缺失的代数
+            function fillGen(node, currentGen) {
+                if (node.gen === null || isNaN(node.gen)) {
+                    node.gen = currentGen;
+                }
+                node.children.forEach(c => fillGen(c, node.gen + 1));
+            }
+            
+            // 如果根节点也没有代数,默认给个1
+            roots.forEach(r => fillGen(r, r.gen || 1));
+
+            // 按照世代对roots进行排序,优先显示最老的祖先
+            roots.sort((a, b) => a.gen - b.gen);
+
+            document.getElementById('loading').style.display = 'none';
+            const container = document.getElementById('exportArea');
+            
+            if (roots.length === 0) {
+                container.innerHTML = '<div class="text-muted">暂无家谱数据。</div>';
+                return;
+            }
+
+            // 2. 分页切块逻辑
+            const pageQueue = [];
+            // 将第一批 roots 放入第一页
+            if (roots.length > 0) {
+                let minGen = Math.min(...roots.map(r => r.gen));
+                pageQueue.push({
+                    nodes: roots,
+                    startGen: minGen,
+                    leftTitle: '',
+                    pageId: 1
+                });
+            }
+
+            let pagesRendered = [];
+            let nextPageId = 2;
+
+            while(pageQueue.length > 0) {
+                let currentJob = pageQueue.shift();
+                
+                // 克隆当前块的节点树,遇到超过 MAX_GEN_PER_PAGE 的裁剪并生成新任务
+                let maxGenForThisPage = currentJob.startGen + CONFIG.MAX_GEN_PER_PAGE - 1;
+                let blockNodes = cloneAndClip(currentJob.nodes, maxGenForThisPage, pageQueue);
+                
+                pagesRendered.push({
+                    id: currentJob.pageId,
+                    nodes: blockNodes,
+                    startGen: currentJob.startGen,
+                    endGen: maxGenForThisPage,
+                    leftTitle: currentJob.leftTitle
+                });
+            }
+
+            totalPagesCount = pagesRendered.length;
+
+            // 3. 渲染页面
+            pagesRendered.forEach((page, index) => {
+                const pageHtml = renderPageSVG(page, index + 1, totalPagesCount);
+                container.insertAdjacentHTML('beforeend', pageHtml);
+            });
+            bindQuickSelectOnChartNames();
+            syncCheckboxesFromSelectedSet();
+            
+            // Function to clip deep branches
+            function cloneAndClip(nodes, maxGen, queue) {
+                return nodes.map(n => {
+                    let clone = { ...n };
+                    // 当当前节点已达到本页最大代数,且还有子节点时,截断并送入下一页
+                    if (clone.gen >= maxGen && clone.children && clone.children.length > 0) {
+                        clone.hasNextPage = true;
+                        clone.nextPageLink = nextPageId;
+                        
+                        queue.push({
+                            nodes: clone.children,
+                            startGen: clone.gen + 1,
+                            leftTitle: `上接 ${clone.name}`,
+                            pageId: nextPageId
+                        });
+                        nextPageId++;
+                        clone.children = []; 
+                    } else if (clone.children && clone.children.length > 0) {
+                        clone.children = cloneAndClip(clone.children, maxGen, queue);
+                    }
+                    return clone;
+                });
+            }
+        }
+
+        // SVG 生成核心逻辑
+        function renderPageSVG(page, pageNum, totalPages) {
+            let currentX = 0;
+            const nodesFlat = [];
+            const lines = [];
+
+            // 1. 布局计算 (核心算法: 父亲对齐长子,从右向左排版 RTL)
+            function layout(node, depthY) {
+                if (!node.children || node.children.length === 0) {
+                    node.x = currentX;
+                    node.y = depthY;
+                    currentX -= CONFIG.X_STEP; // 向左递减,实现从右到左排版
+                } else {
+                    node.children.forEach(child => layout(child, depthY + 1));
+                    node.x = node.children[0].x; // 父亲与长子对齐
+                    node.y = depthY;
+                }
+                nodesFlat.push(node);
+            }
+
+            // 对 page.nodes 进行布局
+            page.nodes.forEach(root => layout(root, 0));
+
+            // 获取所需的实际尺寸
+            const minX = currentX; // 因为向左排布,最小X是负数
+            const maxDepth = Math.max(...nodesFlat.map(n => n.y), 0);
+            
+            // 坐标平移,把所有负数X转为正数,并在右侧留白
+            const offsetX = Math.abs(minX) + CONFIG.MARGIN_LEFT + 50; 
+            
+            const svgWidth = offsetX + 100; // 总宽度
+            const svgHeight = (maxDepth + 1) * CONFIG.Y_STEP + CONFIG.MARGIN_TOP + CONFIG.MARGIN_BOTTOM;
+
+            // 2. 构建连线
+            nodesFlat.forEach(node => {
+                if (node.children && node.children.length > 0) {
+                    const firstChild = node.children[0];
+                    const lastChild = node.children[node.children.length - 1];
+
+                    // 从父亲到底部横线
+                    const pX = node.x + offsetX;
+                    const pY = node.y * CONFIG.Y_STEP + CONFIG.MARGIN_TOP + CONFIG.NODE_HEIGHT;
+                    const midY = pY + CONFIG.LINE_MID_OFFSET;
+
+                    lines.push(`<line class="line" x1="${pX}" y1="${pY}" x2="${pX}" y2="${midY}" />`);
+
+                    // 绘制子节点水平连线
+                    const firstX = firstChild.x + offsetX;
+                    const lastX = lastChild.x + offsetX;
+                    if (firstX !== lastX) {
+                        lines.push(`<line class="line" x1="${firstX}" y1="${midY}" x2="${lastX}" y2="${midY}" />`);
+                    }
+
+                    // 绘制连接到每个子节点的垂线
+                    node.children.forEach((child, i) => {
+                        const cX = child.x + offsetX;
+                        const cY = child.y * CONFIG.Y_STEP + CONFIG.MARGIN_TOP;
+                        lines.push(`<line class="line" x1="${cX}" y1="${midY}" x2="${cX}" y2="${cY}" />`);
+                        
+                        // 右侧标记“长子”、“次子” (因为从右到左,字应该写在左边或者右边)
+                        // 传统家谱写在线右侧
+                        const relLabels = ['长子', '次子', '三子', '四子', '五子', '六子', '七子', '八子'];
+                        let relLabel = relLabels[i] || '子';
+                        if(child.sex === 2) relLabel = '女';
+                        
+                        // 竖排显示长子、次子
+                        lines.push(`<text class="rel-text" x="${cX + 8}" y="${midY + 15}">${relLabel[0]}</text>`);
+                        if(relLabel[1]) {
+                            lines.push(`<text class="rel-text" x="${cX + 8}" y="${midY + 28}">${relLabel[1]}</text>`);
+                        }
+                    });
+                }
+            });
+
+            // 3. 构建节点文字
+            const nodesHtml = nodesFlat.map(node => {
+                const nx = node.x + offsetX;
+                const ny = node.y * CONFIG.Y_STEP + CONFIG.MARGIN_TOP;
+                const isSelectedRoot = selectedRootIdsSet.has(node.id);
+                
+                let html = '';
+                
+                // 姓名竖排处理 (简单切分文字为数组)
+                let nameArr = Array.from(node.name || '未知');
+                nameArr.forEach((char, i) => {
+                    const cls = `node-text${isSelectedRoot ? ' selected-root-text' : ''}`;
+                    html += `<text class="${cls}" data-member-id="${node.id}" x="${nx}" y="${ny + 16 + i * 18}" text-anchor="middle">${char}</text>`;
+                });
+
+                // 人名上方勾选框(用于快速选择起始人员)
+                if (showChartSelectors && node.id !== undefined && node.id !== null) {
+                    const checkedCls = isSelectedRoot ? 'checked' : '';
+                    html += `
+                        <g class="node-selector" data-member-id="${node.id}" transform="translate(${nx - 6}, ${ny - 14})">
+                            <rect class="node-selector-box ${checkedCls}" width="12" height="12"></rect>
+                            ${isSelectedRoot ? '<text class="node-selector-mark" x="6" y="9" text-anchor="middle">✓</text>' : ''}
+                        </g>
+                    `;
+                }
+
+                // 配偶信息(放在名字左侧)
+                if (node.spouses && node.spouses.length > 0) {
+                    const spNames = node.spouses.map(s => s.name).join('、');
+                    let spStr = '配' + spNames;
+                    Array.from(spStr).forEach((char, i) => {
+                        // 左侧(X减小)
+                        html += `<text class="node-spouse" x="${nx - 18}" y="${ny + 12 + i * 14}" text-anchor="middle">${char}</text>`;
+                    });
+                }
+
+                // 标记下一页
+                if (node.hasNextPage) {
+                    html += `<text class="node-mark" x="${nx}" y="${ny + CONFIG.NODE_HEIGHT + 25}" text-anchor="middle">下接</text>`;
+                    html += `<text class="node-mark" x="${nx}" y="${ny + CONFIG.NODE_HEIGHT + 40}" text-anchor="middle">第</text>`;
+                    html += `<text class="node-mark" x="${nx}" y="${ny + CONFIG.NODE_HEIGHT + 55}" text-anchor="middle">${node.nextPageLink}</text>`;
+                    html += `<text class="node-mark" x="${nx}" y="${ny + CONFIG.NODE_HEIGHT + 70}" text-anchor="middle">页</text>`;
+                }
+
+                return html;
+            }).join('');
+
+            // 4. 构建左侧世代标记
+            let genLabels = '';
+            for(let i=0; i<=maxDepth; i++) {
+                const actualGen = page.startGen + i;
+                const y = i * CONFIG.Y_STEP + CONFIG.MARGIN_TOP + 15;
+                const x = 30; // 左侧留白
+                
+                // 将代数竖排
+                let genStr = actualGen + '世';
+                // 支持如"30世"
+                genLabels += `<text class="gen-text" x="${x}" y="${y}" text-anchor="middle">${actualGen}</text>`;
+                genLabels += `<text class="gen-text" x="${x}" y="${y + 18}" text-anchor="middle">世</text>`;
+                
+                // 画一条灰色的分隔虚线(非必须,但好看)
+                genLabels += `<line x1="10" y1="${y-20}" x2="${svgWidth}" y2="${y-20}" stroke="#eee" stroke-dasharray="4" />`;
+            }
+
+            return `
+            <div class="page-block">
+                <div class="page-header">
+                    <div class="page-left-title">${page.leftTitle}</div>
+                    <div class="page-title">传统家谱吊线图</div>
+                    <div class="page-subtitle">共 ${totalPages} 页 第 ${pageNum} 页</div>
+                </div>
+                <svg class="tree-svg" width="${svgWidth}" height="${svgHeight}">
+                    <!-- 背景和网格 -->
+                    <rect width="100%" height="100%" fill="white" />
+                    ${genLabels}
+                    <!-- 连线 -->
+                    ${lines.join('\n')}
+                    <!-- 节点 -->
+                    ${nodesHtml}
+                </svg>
+            </div>`;
+        }
+
+        function applySelectionStateToChart(memberId) {
+            const selected = selectedRootIdsSet.has(memberId);
+
+            document.querySelectorAll(`.node-text[data-member-id="${memberId}"]`).forEach(t => {
+                t.classList.toggle('selected-root-text', selected);
+            });
+
+            document.querySelectorAll(`.node-selector[data-member-id="${memberId}"]`).forEach(g => {
+                const rect = g.querySelector('.node-selector-box');
+                if (rect) rect.classList.toggle('checked', selected);
+
+                const mark = g.querySelector('.node-selector-mark');
+                if (selected && !mark) {
+                    const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
+                    textEl.setAttribute('class', 'node-selector-mark');
+                    textEl.setAttribute('x', '6');
+                    textEl.setAttribute('y', '9');
+                    textEl.setAttribute('text-anchor', 'middle');
+                    textEl.textContent = '✓';
+                    g.appendChild(textEl);
+                } else if (!selected && mark) {
+                    mark.remove();
+                }
+            });
+        }
+
+        function toggleSelection(memberId) {
+            if (Number.isNaN(memberId)) return;
+            if (selectedRootIdsSet.has(memberId)) selectedRootIdsSet.delete(memberId);
+            else selectedRootIdsSet.add(memberId);
+            applySelectionStateToChart(memberId);
+            syncCheckboxesFromSelectedSet();
+            renderSelectedMembersPanel();
+        }
+
+        function bindQuickSelectOnChartNames() {
+            if (!showChartSelectors) return;
+            // 勾选框点击:仅切换勾选状态,不重绘、不跳页
+            const selectors = document.querySelectorAll('.node-selector[data-member-id]');
+            selectors.forEach(el => {
+                el.addEventListener('click', function(event) {
+                    event.stopPropagation();
+                    const id = parseInt(this.dataset.memberId);
+                    toggleSelection(id);
+                });
+            });
+
+            // 人名点击:同样仅切换勾选状态
+            const names = document.querySelectorAll('.node-text[data-member-id]');
+            names.forEach(el => {
+                el.addEventListener('click', function(event) {
+                    event.stopPropagation();
+                    const id = parseInt(this.dataset.memberId);
+                    toggleSelection(id);
+                });
+            });
+        }
+    </script>
+</body>
+</html>

+ 188 - 1
templates/upload.html

@@ -2,6 +2,12 @@
 
 {% block title %}上传文件 - 家谱管理系统{% endblock %}
 
+{% block extra_css %}
+<style>
+    .drop-shadow { filter: drop-shadow(0 0 4px rgba(0,0,0,0.5)); }
+</style>
+{% endblock %}
+
 {% block content %}
 <div class="row justify-content-center">
     <div class="col-md-8">
@@ -34,7 +40,7 @@
                         </div>
                     </div>
 
-                    <div class="mb-4">
+                    <div class="mb-4" id="globalPageWrapper">
                         <label class="form-label fw-bold">手动指定页码 (可选)</label>
                         <div class="input-group">
                             <input type="number" name="manual_page" id="initialPage" class="form-control" placeholder="如不输入则由 OCR 自动识别">
@@ -47,6 +53,13 @@
                         </div>
                     </div>
                     
+                    <!-- 预览容器 -->
+                    <div id="previewContainer" class="mb-4" style="display: none;">
+                        <label class="form-label fw-bold">文件预览及页码设置</label>
+                        <div class="row g-3" id="previewGrid">
+                        </div>
+                    </div>
+                    
                     <div class="alert alert-warning mb-4">
                         <i class="bi bi-info-circle me-2"></i>
                         提示:文件将上传至云端 OSS 存储,处理过程可能需要几秒钟。
@@ -63,6 +76,33 @@
         </div>
     </div>
 </div>
+
+<!-- 放大查看图片的模态框 -->
+<div class="modal fade" id="imagePreviewModal" tabindex="-1" aria-hidden="true" style="z-index: 1060;">
+    <div class="modal-dialog modal-xl modal-dialog-centered" style="max-width: 95vw;">
+        <div class="modal-content bg-transparent border-0">
+            <div class="modal-header border-0 pb-0 position-absolute top-0 end-0" style="z-index: 1070;">
+                <button type="button" class="btn-close btn-close-white bg-dark p-2 rounded-circle shadow" data-bs-dismiss="modal" aria-label="Close"></button>
+            </div>
+            <div class="modal-body p-0 text-center position-relative d-flex justify-content-center align-items-center" style="min-height: 50vh;">
+                <img id="largePreviewImage" src="" class="img-fluid rounded shadow-lg" style="max-height: 90vh; max-width: 100%; object-fit: contain; pointer-events: none;">
+                
+                <!-- 切换按钮 -->
+                <button class="btn btn-dark position-absolute top-50 start-0 translate-middle-y ms-3 ms-md-5 rounded-circle shadow" id="prevImageBtn" style="width: 48px; height: 48px; opacity: 0.8; z-index: 1065;">
+                    <i class="bi bi-chevron-left fs-4"></i>
+                </button>
+                <button class="btn btn-dark position-absolute top-50 end-0 translate-middle-y me-3 me-md-5 rounded-circle shadow" id="nextImageBtn" style="width: 48px; height: 48px; opacity: 0.8; z-index: 1065;">
+                    <i class="bi bi-chevron-right fs-4"></i>
+                </button>
+                
+                <!-- 页码指示器 -->
+                <div class="position-absolute bottom-0 start-50 translate-middle-x mb-3" style="z-index: 1065;">
+                    <span class="badge bg-dark bg-opacity-75 fs-6 px-3 py-2 rounded-pill shadow" id="imageIndexIndicator">1 / 1</span>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
 {% endblock %}
 
 {% block extra_js %}
@@ -85,6 +125,63 @@
         }, 10);
     });
 
+    let currentPreviewFiles = [];
+    let currentPreviewIndex = 0;
+    
+    // 暴露函数到全局以确保 onclick 能找到
+    window.showLargePreview = function(index) {
+        if (currentPreviewFiles.length === 0 || index < 0 || index >= currentPreviewFiles.length) {
+            return;
+        }
+        
+        currentPreviewIndex = index;
+        const file = currentPreviewFiles[index];
+        const objectUrl = URL.createObjectURL(file);
+        
+        const imgEl = document.getElementById('largePreviewImage');
+        imgEl.src = objectUrl;
+        
+        document.getElementById('imageIndexIndicator').textContent = `${index + 1} / ${currentPreviewFiles.length}`;
+        
+        // Update button states
+        document.getElementById('prevImageBtn').style.display = index === 0 ? 'none' : 'block';
+        document.getElementById('nextImageBtn').style.display = index === currentPreviewFiles.length - 1 ? 'none' : 'block';
+        
+        // 使用原生 Bootstrap API 显示模态框
+        const modalEl = document.getElementById('imagePreviewModal');
+        let modal = bootstrap.Modal.getInstance(modalEl);
+        if (!modal) {
+            modal = new bootstrap.Modal(modalEl);
+        }
+        
+        // Ensure image fits vertically
+        imgEl.style.maxHeight = '85vh';
+        imgEl.style.maxWidth = '100%';
+        imgEl.style.objectFit = 'contain';
+        imgEl.style.pointerEvents = 'auto'; // allow user interaction if needed
+        
+        modal.show();
+    };
+    
+    document.getElementById('prevImageBtn').addEventListener('click', (e) => {
+        e.stopPropagation();
+        if (currentPreviewIndex > 0) window.showLargePreview(currentPreviewIndex - 1);
+    });
+    
+    document.getElementById('nextImageBtn').addEventListener('click', (e) => {
+        e.stopPropagation();
+        if (currentPreviewIndex < currentPreviewFiles.length - 1) window.showLargePreview(currentPreviewIndex + 1);
+    });
+    
+    // Keyboard navigation
+    document.getElementById('imagePreviewModal').addEventListener('keydown', (e) => {
+        if (e.key === 'ArrowLeft') {
+            if (currentPreviewIndex > 0) window.showLargePreview(currentPreviewIndex - 1);
+        } else if (e.key === 'ArrowRight') {
+            if (currentPreviewIndex < currentPreviewFiles.length - 1) window.showLargePreview(currentPreviewIndex + 1);
+        }
+    });
+
     document.getElementById('fileInput').addEventListener('change', function(e) {
         let files = e.target.files;
         let pdfCount = 0;
@@ -101,9 +198,99 @@
         if (imgCount > 10) {
             alert('一次最多只能上传10张图片,请重新选择。');
             e.target.value = ''; // clear selection
+            document.getElementById('previewContainer').style.display = 'none';
+            document.getElementById('globalPageWrapper').style.display = 'block';
+            return;
         } else if (pdfCount > 0 && files.length > 1) {
             alert('上传PDF时,一次只能选择1个文件。');
             e.target.value = ''; // clear selection
+            document.getElementById('previewContainer').style.display = 'none';
+            document.getElementById('globalPageWrapper').style.display = 'block';
+            return;
+        }
+        
+        const previewContainer = document.getElementById('previewContainer');
+        const previewGrid = document.getElementById('previewGrid');
+        const globalPageWrapper = document.getElementById('globalPageWrapper');
+        
+        previewGrid.innerHTML = '';
+        
+        if (files.length > 0) {
+            previewContainer.style.display = 'block';
+            if (imgCount > 0) {
+                // Hide global page input for images, as we will set them individually
+                globalPageWrapper.style.display = 'none';
+                
+                let startPage = parseInt('{{ suggested_page }}') || 1;
+                
+                currentPreviewFiles = []; // Reset current preview files
+                
+                Array.from(files).forEach((file, index) => {
+                    if (file.type.startsWith('image/') || !file.name.toLowerCase().endsWith('.pdf')) {
+                        currentPreviewFiles.push(file);
+                    }
+                });
+                
+                let imageIndexCounter = 0;
+
+                Array.from(files).forEach((file, index) => {
+                    const isImage = file.type.startsWith('image/') || !file.name.toLowerCase().endsWith('.pdf');
+                    if (!isImage) return; // Skip non-images
+                    
+                    const currentIndex = imageIndexCounter++;
+                    const objectUrl = URL.createObjectURL(file);
+                    const col = document.createElement('div');
+                    col.className = 'col-md-6 col-lg-4';
+                    col.innerHTML = `
+                        <div class="card h-100">
+                            <div class="position-relative preview-click-area" data-index="${currentIndex}" style="cursor: pointer;">
+                                <img src="${objectUrl}" class="card-img-top" style="height: 200px; object-fit: cover; background: #f8f9fa; transition: opacity 0.2s;" alt="预览图" onmouseover="this.style.opacity='0.8'" onmouseout="this.style.opacity='1'">
+                                <div class="position-absolute top-50 start-50 translate-middle text-white" style="opacity: 0; transition: opacity 0.2s; pointer-events: none;" onmouseover="this.parentElement.querySelector('img').style.opacity='0.8'; this.style.opacity='1'" onmouseout="this.parentElement.querySelector('img').style.opacity='1'; this.style.opacity='0'">
+                                    <i class="bi bi-zoom-in fs-1 drop-shadow"></i>
+                                </div>
+                            </div>
+                            <div class="card-body p-2 border-top">
+                                <div class="text-truncate small mb-2 text-muted" title="${file.name}">${file.name}</div>
+                                <div class="input-group input-group-sm">
+                                    <span class="input-group-text bg-light text-secondary">指定页码</span>
+                                    <input type="number" name="page_number_${index}" class="form-control" value="${startPage + currentIndex}">
+                                </div>
+                            </div>
+                        </div>
+                    `;
+                    
+                    // Bind click event safely
+                    const clickArea = col.querySelector('.preview-click-area');
+                    if (clickArea) {
+                        clickArea.addEventListener('click', function(evt) {
+                            evt.preventDefault();
+                            evt.stopPropagation();
+                            window.showLargePreview(currentIndex);
+                        });
+                    }
+                    
+                    previewGrid.appendChild(col);
+                });
+            } else if (pdfCount > 0) {
+                // For PDF, keep the global page input
+                globalPageWrapper.style.display = 'block';
+                
+                const col = document.createElement('div');
+                col.className = 'col-12';
+                col.innerHTML = `
+                    <div class="alert alert-secondary d-flex align-items-center mb-0">
+                        <i class="bi bi-file-earmark-pdf fs-3 me-3 text-danger"></i>
+                        <div>
+                            <strong>${files[0].name}</strong><br>
+                            <span class="small text-muted">PDF文件将自动按页拆分处理</span>
+                        </div>
+                    </div>
+                `;
+                previewGrid.appendChild(col);
+            }
+        } else {
+            previewContainer.style.display = 'none';
+            globalPageWrapper.style.display = 'block';
         }
     });
 </script>