瀏覽代碼

首次提交

Hai Lin 1 周之前
當前提交
08a191ce93

+ 67 - 0
README.md

@@ -0,0 +1,67 @@
+# 家谱管理系统 (Genealogy Management System)
+
+本项目实现了家谱管理功能,包括用户登录、文件上传(图片/扫描件)、上传到 OSS、OCR 自动解析页码以及数据库存储。
+
+## 功能特性
+
+1. **登录管理**:基于数据库 `users` 表的身份验证。
+2. **家谱管理**:支持上传扫描件或图片。
+3. **OSS 上传**:通过指定的 `https://crmapi.dcjxb.yunzhixue.cn/file/upload` 接口实现。
+4. **OCR 页码解析**:自动解析图片中的页码并记录。
+5. **数据库存储**:在 MySQL 数据库中存储记录。
+
+## 目录结构
+
+- `app.py`: Flask 应用主入口。
+- `oss_utils.py`: OSS 上传工具类。
+- `ocr_utils.py`: OCR 页码解析工具类。
+- `init_db.py`: 数据库初始化脚本(创建表和添加测试用户)。
+- `templates/`: HTML 模板文件。
+- `requirements.txt`: Python 依赖项。
+
+## 快速开始
+
+### 1. 安装依赖
+
+```bash
+pip install -r requirements.txt
+```
+
+*注意:OCR 功能需要系统安装 `tesseract-ocr` 引擎。*
+
+### 2. 初始化数据库
+
+运行初始化脚本以创建必要的表并添加默认管理员账号:
+
+```bash
+python init_db.py
+```
+
+- 默认账号:`admin`
+- 默认密码:`admin123`
+
+### 3. 运行应用
+
+```bash
+python app.py
+```
+
+访问 `http://127.0.0.1:5001` 即可开始使用。
+
+## 数据库配置
+
+- 主机: `rm-f8ze60yirdj8786u2wo.mysql.rds.aliyuncs.com`
+- 用户: `csqz`
+- 密码: `csqz@2026`
+- 数据库: `csqz-client`
+
+## 接口说明
+
+- OSS 上传接口:`POST https://crmapi.dcjxb.yunzhixue.cn/file/upload`
+- 参数:`file` (form-data)
+
+## AI能力
+
+- 模型:`doubao-seed-1-8-251228`
+- 接口地址:`https://ark.cn-beijing.volces.com/api/v3/responses`
+- API-KEY:`a1800657-9212-4afe-9b7c-b49f015c54d3`

+ 35 - 0
add_sub_relation_column.py

@@ -0,0 +1,35 @@
+import pymysql
+
+DB_CONFIG = {
+    "host": "rm-f8ze60yirdj8786u2.mysql.rds.aliyuncs.com",
+    "port": 3306,
+    "user": "root",
+    "password": "csqz@20255",
+    "db": "csqz-client",
+    "charset": "utf8mb4",
+    "cursorclass": pymysql.cursors.DictCursor
+}
+
+def migrate():
+    conn = pymysql.connect(**DB_CONFIG)
+    try:
+        with conn.cursor() as cursor:
+            print("Checking family_relation_info schema...")
+            cursor.execute("DESCRIBE family_relation_info")
+            columns = [col['Field'] for col in cursor.fetchall()]
+            
+            if 'sub_relation_type' not in columns:
+                print("Adding 'sub_relation_type' column...")
+                cursor.execute("ALTER TABLE family_relation_info ADD COLUMN sub_relation_type INT DEFAULT 0 COMMENT '子关系类型'")
+                print("Column added.")
+            else:
+                print("'sub_relation_type' column already exists.")
+                
+        conn.commit()
+    except Exception as e:
+        print(f"Migration failed: {e}")
+    finally:
+        conn.close()
+
+if __name__ == "__main__":
+    migrate()

+ 1318 - 0
app.py

@@ -0,0 +1,1318 @@
+import os
+import pymysql
+import requests
+import json
+import re
+import threading
+import urllib3
+from flask import Flask, render_template, request, redirect, url_for, session, flash, jsonify, Response, stream_with_context
+from werkzeug.utils import secure_filename
+from oss_utils import upload_to_oss
+from ocr_utils import extract_page_number
+import time
+from datetime import datetime
+
+# Suppress InsecureRequestWarning
+urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
+
+app = Flask(__name__)
+app.secret_key = 'genealogy_secret_key'
+app.config['UPLOAD_FOLDER'] = 'uploads'
+os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
+
+# 数据库配置
+DB_CONFIG = {
+    "host": "rm-f8ze60yirdj8786u2.mysql.rds.aliyuncs.com",
+    "port": 3306,
+    "user": "root",
+    "password": "csqz@20255",
+    "db": "csqz-client",
+    "charset": "utf8mb4",
+    "cursorclass": pymysql.cursors.DictCursor
+}
+
+def get_db_connection():
+    return pymysql.connect(**DB_CONFIG)
+
+def format_timestamp(ts):
+    if not ts: return '未知'
+    try:
+        # 兼容秒和毫秒
+        if ts > 10000000000: # 超过2286年的秒数,通常认为是毫秒
+            ts = ts / 1000
+        return time.strftime('%Y-%m-%d', time.localtime(ts))
+    except:
+        return '未知'
+
+def manual_simplify(text):
+    """
+    Simple fallback for common Traditional to Simplified conversion 
+    if AI fails to convert specific characters.
+    """
+    if not text: return text
+    mapping = {
+        '學': '学', '國': '国', '萬': '万', '寶': '宝', '興': '兴', 
+        '華': '华', '會': '会', '葉': '叶', '藝': '艺', '號': '号',
+        '處': '处', '見': '见', '視': '视', '言': '言', '語': '语',
+        '貝': '贝', '車': '车', '長': '长', '門': '门', '韋': '韦',
+        '頁': '页', '風': '风', '飛': '飞', '食': '食', '馬': '马',
+        '魚': '鱼', '鳥': '鸟', '麥': '麦', '黃': '黄', '齊': '齐',
+        '齒': '齿', '龍': '龙', '龜': '龟', '壽': '寿', '榮': '荣',
+        '愛': '爱', '慶': '庆', '衛': '卫', '賢': '贤', '義': '义',
+        '禮': '礼', '樂': '乐', '靈': '灵', '滅': '灭', '氣': '气',
+        '智': '智', '信': '信', '仁': '仁', '勇': '勇', '嚴': '严',
+        '銳': '锐', '優': '优', '楊': '杨', '吳': '吴', '銀': '银'
+    }
+    
+    result = ""
+    for char in text:
+        result += mapping.get(char, char)
+    return result
+
+def clean_name(name):
+    """
+    Clean name according to Liu family genealogy rules:
+    1. If name is '学公' or '留学公', keep 'Gong' (exception).
+    2. Otherwise, if name ends with '公', remove '公'.
+    3. If name does not start with '留', prepend '留'.
+    """
+    if not name: return name
+    name = name.strip()
+    
+    # Pre-process: Ensure Simplified Chinese for specific chars
+    name = manual_simplify(name)
+    
+    # 1. Check exceptions (names that SHOULD keep 'Gong')
+    exceptions = ['学公', '留学公']
+    
+    if name in exceptions:
+        if not name.startswith('留'):
+            name = '留' + name
+        return name
+        
+    # 2. General Rule: Remove 'Gong' suffix
+    if name.endswith('公'):
+        name = name[:-1]
+        
+    # 3. Ensure 'Liu' surname
+    if not name.startswith('留'):
+        name = '留' + name
+        
+    return name
+
+def process_ai_task(record_id, image_url):
+    """Background task to process image with AI and store result."""
+    print(f"[AI Task] Starting task for record {record_id}...")
+    conn = get_db_connection()
+    try:
+        with conn.cursor() as cursor:
+            cursor.execute("UPDATE genealogy_records SET ai_status = 1 WHERE id = %s", (record_id,))
+        conn.commit()
+        print(f"[AI Task] Status updated to 'Processing' for record {record_id}")
+
+        api_key = "a1800657-9212-4afe-9b7c-b49f015c54d3"
+        api_url = "https://ark.cn-beijing.volces.com/api/v3/responses"
+        
+        prompt = """
+        请分析这张家谱图片,提取其中关于人物的信息。
+        请务必将繁体字转换为简体字(original_name 字段除外)。
+        特别注意:'name' 字段必须是纯简体中文,不能包含繁体字(例如:'學'应转换为'学','劉'应转换为'刘','萬'应转换为'万')。
+        请提取以下字段(如果存在):
+        - original_name: 原始姓名(严格保持图片上的繁体字,不做任何修改或转换)
+        - name: 简体姓名(必须转换为简体中文,去除不需要的敬称)
+        - sex: 性别(男/女)
+        - birthday: 出生日期(尝试转换为YYYY-MM-DD格式,如果无法确定年份可只填月日)
+        - father_name: 父亲姓名
+        - spouse_name: 配偶姓名
+        - generation: 第几世/代数
+        - name_word: 字辈(例如名字为“学勤公”,“学”为字辈;提取名字中的字辈信息)
+        - education: 学历/功名
+        - title: 官职/称号
+        
+        请严格以JSON列表格式返回,不要包含Markdown代码块标记(如 ```json ... ```),直接返回JSON数组。
+        如果包含多个人物,请都提取出来。
+        Do not output any reasoning or explanation, just the JSON.
+        """
+
+        payload = {
+            "model": "doubao-seed-1-8-251228",
+            "stream": True,  # Streaming for robust handling
+            "input": [
+                {
+                    "role": "user",
+                    "content": [
+                        {"type": "input_image", "image_url": image_url},
+                        {"type": "input_text", "text": prompt}
+                    ]
+                }
+            ]
+        }
+        
+        headers = {
+            "Authorization": f"Bearer {api_key}",
+            "Content-Type": "application/json"
+        }
+        
+        max_retries = 3
+        last_exception = None
+        
+        for attempt in range(max_retries):
+            try:
+                print(f"[AI Task] Attempt {attempt+1}/{max_retries} connecting to API for record {record_id}...")
+                response = requests.post(
+                    api_url, 
+                    json=payload, 
+                    headers=headers, 
+                    timeout=1200, 
+                    stream=True,
+                    verify=False,
+                    proxies={"http": None, "https": None}
+                )
+                
+                if response.status_code == 200:
+                    print(f"[AI Task] Connection established for record {record_id}, receiving stream...")
+                    full_content = ""
+                    
+                    for line in response.iter_lines():
+                        if not line: continue
+                        line_str = line.decode('utf-8')
+                        
+                        # Debug: Print full line to understand event flow
+                        print(f"[AI Task Debug] Raw Line: {line_str[:500]}") # Truncate very long lines
+
+                        if line_str.startswith('data: '):
+                            json_str = line_str[6:]
+                            if json_str.strip() == '[DONE]':
+                                print("[AI Task Debug] Received [DONE]")
+                                break
+                            try:
+                                chunk = json.loads(json_str)
+                                chunk_type = chunk.get('type')
+                                
+                                # Standard OpenAI format (choices)
+                                if 'choices' in chunk and len(chunk['choices']) > 0:
+                                    delta = chunk['choices'][0].get('delta', {})
+                                    if 'content' in delta:
+                                        full_content += delta['content']
+                                
+                                # Doubao/Volcengine specific formats (delta)
+                                elif chunk_type == 'response.text.delta':
+                                    full_content += chunk.get('delta', '')
+                                
+                                # Check response.completed if empty
+                                elif chunk_type == 'response.completed' and not full_content:
+                                    output = chunk.get('response', {}).get('output', [])
+                                    for item in output:
+                                        # Also extract from reasoning if it contains JSON-like text
+                                        if item.get('type') == 'reasoning':
+                                            summary = item.get('summary', [])
+                                            for sum_item in summary:
+                                                if sum_item.get('type') == 'summary_text':
+                                                    full_content += sum_item.get('text', '')
+                                        
+                                        elif item.get('type') == 'message':
+                                            content = item.get('content')
+                                            if isinstance(content, str):
+                                                full_content += content
+                                            elif isinstance(content, list):
+                                                for part in content:
+                                                    if isinstance(part, dict) and part.get('type') == 'text':
+                                                        full_content += part.get('text', '')
+                                
+                                # Fallback: output_item.added
+                                elif chunk_type == 'response.output_item.added':
+                                    item = chunk.get('item', {})
+                                    if item.get('role') == 'assistant':
+                                        content_field = item.get('content', [])
+                                        if isinstance(content_field, str):
+                                            full_content += content_field
+                                        elif isinstance(content_field, list):
+                                            for part in content_field:
+                                                if isinstance(part, dict) and part.get('type') == 'text':
+                                                    full_content += part.get('text', '')
+
+                            except Exception as e:
+                                print(f"[AI Task] Chunk parse error: {e}")
+                        else:
+                             # Fallback for non-SSE
+                             try:
+                                chunk = json.loads(line_str)
+                                if 'choices' in chunk and len(chunk['choices']) > 0:
+                                    content = chunk['choices'][0]['message']['content']
+                                    full_content += content
+                             except:
+                                pass
+                    
+                    print(f"[AI Task] Stream finished. Content length: {len(full_content)}")
+                    if len(full_content) == 0:
+                         print(f"[AI Task] WARNING: No content received from AI stream.")
+                         # Continue to JSON parse to fail gracefully
+                    
+                    # Clean JSON
+                    try:
+                        # 1. Try finding [...] array
+                        start = full_content.find('[')
+                        end = full_content.rfind(']')
+                        
+                        # 2. If not found, try finding {...} object and wrap it
+                        is_single_object = False
+                        if start == -1 or end == -1 or end <= start:
+                            start = full_content.find('{')
+                            end = full_content.rfind('}')
+                            is_single_object = True
+
+                        if start != -1 and end != -1 and end > start:
+                            content_clean = full_content[start:end+1]
+                        else:
+                            # Fallback to regex or raw
+                            content_clean = re.sub(r'^```json\s*', '', full_content)
+                            content_clean = re.sub(r'```$', '', content_clean)
+
+                        parsed = json.loads(content_clean)
+                        
+                        # Normalize single object to list
+                        if is_single_object and isinstance(parsed, dict):
+                            parsed = [parsed]
+                            content_clean = json.dumps(parsed, ensure_ascii=False)
+                        elif isinstance(parsed, dict) and not isinstance(parsed, list):
+                             # Just in case json.loads parsed a dict even if we looked for []
+                             parsed = [parsed]
+                             content_clean = json.dumps(parsed, ensure_ascii=False)
+
+                        # 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', '')
+                                original_name = person.get('original_name', '')
+                                
+                                # Apply clean logic to simplified name(本人生效:拼接“留”姓、处理“公”)
+                                cleaned_simplified = clean_name(simplified_name)
+                                person['simplified_name'] = cleaned_simplified
+                                
+                                # Store raw name in 'name' field (as requested)
+                                if original_name:
+                                    person['name'] = original_name
+                                else:
+                                    # Fallback: if no original_name returned, use the uncleaned name as 'name'
+                                    # or keep existing logic. But user wants raw in 'name'.
+                                    # If AI didn't return original_name, 'name' is likely simplified.
+                                    pass # Keep 'name' as is (which is Simplified) if original_name missing
+
+                                # Father name:同族,需要按“留”姓规则清洗
+                                if 'father_name' in person and person['father_name']:
+                                    person['father_name'] = clean_name(person['father_name'])
+
+                                # Spouse name:只做繁转简,不拼接“留”姓,也不去“公”
+                                if 'spouse_name' in person and person['spouse_name']:
+                                    person['spouse_name'] = manual_simplify(person['spouse_name'])
+                        
+                        # Re-serialize
+                        content_clean = json.dumps(parsed, ensure_ascii=False)
+
+                        with conn.cursor() as cursor:
+                            cursor.execute("UPDATE genealogy_records SET ai_status = 2, ai_content = %s WHERE id = %s", (content_clean, record_id))
+                        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)
+                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
+
+            except Exception as e:
+                print(f"[AI Task] Attempt {attempt+1} failed for record {record_id}: {e}")
+                last_exception = e
+                if attempt < max_retries - 1:
+                    wait_time = 2 * (attempt + 1)
+                    print(f"[AI Task] Waiting {wait_time}s before retry...")
+                    time.sleep(wait_time)
+        
+        raise last_exception or Exception("Unknown error")
+            
+    except Exception as e:
+        print(f"[AI Task] FINAL FAILURE for record {record_id}: {e}")
+        try:
+            with conn.cursor() as cursor:
+                cursor.execute("UPDATE genealogy_records SET ai_status = 3, ai_content = %s WHERE id = %s", (f"Max Retries Exceeded. Error: {str(e)}", record_id))
+            conn.commit()
+        except:
+            pass
+    finally:
+        conn.close()
+        print(f"[AI Task] Task finished for record {record_id}")
+
+@app.route('/manager/')
+def index():
+    if 'user_id' not in session:
+        return redirect(url_for('login'))
+    
+    conn = get_db_connection()
+    try:
+        with conn.cursor() as cursor:
+            # 获取家谱图片记录 (上传管理)
+            cursor.execute("SELECT * FROM genealogy_records ORDER BY upload_time DESC")
+            records = cursor.fetchall()
+    finally:
+        conn.close()
+    
+    return render_template('index.html', records=records)
+
+@app.route('/manager/members')
+def members():
+    if 'user_id' not in session:
+        return redirect(url_for('login'))
+    
+    search_name = request.args.get('name', '').strip()
+    page = request.args.get('page', 1, type=int)
+    per_page = 10
+    offset = (page - 1) * per_page
+    
+    conn = get_db_connection()
+    try:
+        with conn.cursor() as cursor:
+            # 1. Get total count
+            if search_name:
+                cursor.execute("SELECT COUNT(*) as count FROM family_member_info WHERE name LIKE %s", (f"%{search_name}%",))
+            else:
+                cursor.execute("SELECT COUNT(*) as count FROM family_member_info")
+            
+            result = cursor.fetchone()
+            total = result['count'] if result else 0
+            total_pages = (total + per_page - 1) // per_page
+            
+            # 2. Get paginated results, ordered by modified_time DESC (or create_time if modified is null/same)
+            # Using COALESCE to ensure sort works even if modified_time is NULL
+            order_clause = "ORDER BY COALESCE(modified_time, create_time) DESC"
+            
+            if search_name:
+                sql = f"SELECT * FROM family_member_info WHERE name LIKE %s {order_clause} LIMIT %s OFFSET %s"
+                cursor.execute(sql, (f"%{search_name}%", per_page, offset))
+            else:
+                sql = f"SELECT * FROM family_member_info {order_clause} LIMIT %s OFFSET %s"
+                cursor.execute(sql, (per_page, offset))
+            
+            members = cursor.fetchall()
+            
+            # 格式化日期
+            for m in members:
+                m['birthday_str'] = format_timestamp(m.get('birthday'))
+                # 格式化创建时间 (针对 TIMESTAMP 字段)
+                if m.get('create_time'):
+                    m['create_time_str'] = m['create_time'].strftime('%Y-%m-%d')
+                if m.get('modified_time'):
+                    m['modified_time_str'] = m['modified_time'].strftime('%Y-%m-%d %H:%M')
+                    
+    finally:
+        conn.close()
+    
+    return render_template('members.html', members=members, search_name=search_name, page=page, total_pages=total_pages, total=total)
+
+@app.route('/manager/tree')
+def tree():
+    if 'user_id' not in session:
+        return redirect(url_for('login'))
+    return render_template('tree.html')
+
+@app.route('/manager/api/tree_data')
+def tree_data():
+    if 'user_id' not in session:
+        return jsonify({"error": "Unauthorized"}), 401
+        
+    conn = get_db_connection()
+    try:
+        with conn.cursor() as cursor:
+            # 获取所有成员
+            cursor.execute("SELECT id, name, simplified_name, sex 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")
+            relations = cursor.fetchall()
+            
+            return jsonify({"members": members, "relations": relations})
+    finally:
+        conn.close()
+
+@app.route('/manager/api/save_relation', methods=['POST'])
+def save_relation():
+    if 'user_id' not in session:
+        return jsonify({"success": False, "message": "Unauthorized"}), 401
+    
+    data = request.json
+    source_mid = data.get('source_mid') # The member being dragged
+    target_mid = data.get('target_mid') # The member being dropped onto
+    rel_type = int(data.get('relation_type'))
+    sub_rel_type = int(data.get('sub_relation_type', 0))
+    
+    if not source_mid or not target_mid or not rel_type:
+        return jsonify({"success": False, "message": "参数不完整"}), 400
+
+    conn = get_db_connection()
+    try:
+        with conn.cursor() as cursor:
+            # 简单处理:如果是父子/母子关系
+            # target_mid 是父辈,source_mid 是子辈
+            parent_mid = target_mid
+            child_mid = source_mid
+            gen_diff = 1
+            
+            if rel_type == 10: # 夫妻
+                # 夫妻关系中,我们通常把关联人设为 parent_mid
+                parent_mid = target_mid
+                child_mid = source_mid
+                gen_diff = 0
+            elif rel_type in [11, 12]: # 兄弟姐妹
+                # 这里逻辑上比较复杂,通常兄弟姐妹有共同父母。
+                # 简化处理:暂时存为同级关系 (gen_diff=0)
+                parent_mid = target_mid
+                child_mid = source_mid
+                gen_diff = 0
+            
+            # 删除旧关系
+            cursor.execute("DELETE FROM family_relation_info WHERE source_mid = %s", (source_mid,))
+            
+            # 插入新关系
+            sql = """
+                INSERT INTO family_relation_info 
+                (parent_mid, child_mid, relation_type, sub_relation_type, source_mid, generation_diff) 
+                VALUES (%s, %s, %s, %s, %s, %s)
+            """
+            cursor.execute(sql, (parent_mid, child_mid, rel_type, sub_rel_type, source_mid, gen_diff))
+            conn.commit()
+            return jsonify({"success": True, "message": "关系已保存"})
+    except Exception as e:
+        return jsonify({"success": False, "message": str(e)}), 500
+    finally:
+        conn.close()
+
+@app.route('/manager/api/check_relations', methods=['POST'])
+def check_relations():
+    if 'user_id' not in session:
+        return jsonify({"success": False, "message": "Unauthorized"}), 401
+    
+    data = request.json
+    people = data.get('people', [])
+    if not people:
+        return jsonify({"success": False, "matches": {}})
+
+    conn = get_db_connection()
+    matches = {}
+    
+    try:
+        with conn.cursor() as cursor:
+            # Collect all father names and spouse names to query
+            names_to_check = set()
+            for p in people:
+                if p.get('father_name'): names_to_check.add(p['father_name'])
+                if p.get('spouse_name'): names_to_check.add(p['spouse_name'])
+            
+            if not names_to_check:
+                return jsonify({"success": True, "matches": {}})
+
+            # Query DB
+            format_strings = ','.join(['%s'] * len(names_to_check))
+            if names_to_check:
+                sql = "SELECT id, name, simplified_name, sex, birthday FROM family_member_info WHERE name IN (%s) OR simplified_name IN (%s)" % (format_strings, format_strings)
+                cursor.execute(sql, tuple(names_to_check) * 2)
+                results = cursor.fetchall()
+            else:
+                results = []
+            
+            # Organize by name
+            db_map = {} # name -> [list of members]
+            for r in results:
+                # Add under 'name' (Traditional/Old Simplified)
+                if r['name'] not in db_map: db_map[r['name']] = []
+                db_map[r['name']].append(r)
+                
+                # Add under 'simplified_name' if exists
+                if r.get('simplified_name'):
+                     sname = r['simplified_name']
+                     if sname not in db_map: db_map[sname] = []
+                     # Avoid duplicates if simplified_name is same as name?
+                     # The list might contain same object reference, which is fine.
+                     if sname != r['name']:
+                         db_map[sname].append(r)
+            
+            # Build matches for each input person
+            for index, p in enumerate(people):
+                p_match = {}
+                
+                # Check Father
+                fname = p.get('father_name')
+                if fname and fname in db_map:
+                    candidates = db_map[fname]
+                    # Filter: Father should be Male usually, and older than child (if birthday available)
+                    valid_fathers = [c for c in candidates if c['sex'] == 1]
+                    if valid_fathers:
+                        p_match['father'] = valid_fathers # Return all candidates
+                
+                # Check Spouse
+                sname = p.get('spouse_name')
+                if sname and sname in db_map:
+                    candidates = db_map[sname]
+                    # Filter: Spouse usually opposite sex
+                    target_sex = 1 if p.get('sex') == '女' else 2
+                    valid_spouses = [c for c in candidates if c['sex'] == target_sex]
+                    if valid_spouses:
+                        p_match['spouse'] = valid_spouses
+
+                if p_match:
+                    matches[index] = p_match
+
+        return jsonify({"success": True, "matches": matches})
+    finally:
+        conn.close()
+
+@app.route('/manager/add_member', methods=['GET', 'POST'])
+def add_member():
+    if 'user_id' not in session:
+        return redirect(url_for('login'))
+        
+    conn = get_db_connection()
+    try:
+        # Check for source_record_id (from GET or POST)
+        source_record_id = request.args.get('record_id') or request.form.get('source_record_id')
+        prefilled_content = None
+        source_oss_url = None
+        
+        if source_record_id:
+            with conn.cursor() as cursor:
+                cursor.execute("SELECT oss_url, ai_content, ai_status FROM genealogy_records WHERE id = %s", (source_record_id,))
+                rec = cursor.fetchone()
+                if rec:
+                    source_oss_url = rec['oss_url']
+                    # Check ai_status (2 = success)
+                    if rec['ai_status'] == 2 and rec['ai_content']:
+                        prefilled_content = rec['ai_content']
+
+        if request.method == 'POST':
+            # 处理生日转换为 Unix 时间戳
+            birthday_str = request.form.get('birthday')
+            birthday_ts = 0
+            if birthday_str:
+                try:
+                    birthday_ts = int(datetime.strptime(birthday_str, '%Y-%m-%d').timestamp())
+                except ValueError:
+                    birthday_ts = 0
+
+            # 关系数据
+            related_mid = request.form.get('related_mid')
+            relation_type = request.form.get('relation_type')
+            sub_relation_type = request.form.get('sub_relation_type', 0)
+            
+            # 年龄校验逻辑
+            if related_mid and relation_type in ['1', '2']: # 1:父子 2:母子
+                with conn.cursor() as cursor:
+                    cursor.execute("SELECT name, birthday FROM family_member_info WHERE id = %s", (related_mid,))
+                    parent = cursor.fetchone()
+                    if parent and parent['birthday'] > 0 and birthday_ts > 0:
+                        if birthday_ts < parent['birthday']:
+                            error_msg = f"数据冲突:成员年龄不能比其父亲/母亲({parent['name']})大,请检查并修正出生日期。"
+                            flash(error_msg)
+                            
+                            # Re-fetch data for rendering
+                            cursor.execute("SELECT id, name FROM family_member_info ORDER BY name")
+                            all_members = cursor.fetchall()
+                            cursor.execute("SELECT * FROM genealogy_records ORDER BY page_number ASC")
+                            images = cursor.fetchall()
+
+                            if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or request.is_json:
+                                return jsonify({
+                                    "success": False, 
+                                    "message": error_msg
+                                }), 400
+                            
+                            return render_template('add_member.html', all_members=all_members, images=images, 
+                                                 prefilled_content=prefilled_content, source_oss_url=source_oss_url, source_record_id=source_record_id)
+
+            # 获取表单数据
+            data = {
+                'name': request.form['name'],
+                'simplified_name': request.form.get('simplified_name'),
+                'former_name': request.form.get('former_name'),
+                'childhood_name': request.form.get('childhood_name'),
+                'name_word': request.form.get('name_word'),
+                'name_word_generation': request.form.get('name_word_generation'),
+                'name_title': request.form.get('name_title'),
+                'sex': request.form['sex'],
+                'birthday': birthday_ts,
+                'is_pass_away': request.form.get('is_pass_away', 0),
+                'marital_status': request.form.get('marital_status', 0),
+                'birth_place': request.form.get('birth_place'),
+                'branch_family_hall': request.form.get('branch_family_hall'),
+                'cluster_place': request.form.get('cluster_place'),
+                'nation': request.form.get('nation'),
+                'residential_address': request.form.get('residential_address'),
+                'phone': request.form.get('phone'),
+                'mail': request.form.get('mail'),
+                'wechat_account': request.form.get('wechat_account'),
+                'id_number': request.form.get('id_number'),
+                'occupation': request.form.get('occupation'),
+                'educational': request.form.get('educational'),
+                'blood_type': request.form.get('blood_type'),
+                'religion': request.form.get('religion'),
+                'hobbies': request.form.get('hobbies'),
+                'personal_achievements': request.form.get('personal_achievements'),
+                'family_rank': request.form.get('family_rank'),
+                'tags': request.form.get('tags'),
+                'notes': request.form.get('notes'),
+                'source_record_id': request.form.get('source_record_id') or None  # Save source record ID
+            }
+            
+            # ... (rest of logic) ...
+            
+            with conn.cursor() as cursor:
+                fields = ", ".join(data.keys())
+                placeholders = ", ".join(["%s"] * len(data))
+                sql = f"INSERT INTO family_member_info ({fields}) VALUES ({placeholders})"
+                cursor.execute(sql, list(data.values()))
+                member_id = cursor.lastrowid
+                
+                # 录入关系
+                if related_mid and relation_type:
+                    rel_type = int(relation_type)
+                    parent_mid = int(related_mid)
+                    child_mid = member_id
+                    gen_diff = 1 if rel_type in [1, 2] else 0
+                        
+                    sql_relation = """
+                        INSERT INTO family_relation_info 
+                        (parent_mid, child_mid, relation_type, sub_relation_type, source_mid, generation_diff) 
+                        VALUES (%s, %s, %s, %s, %s, %s)
+                    """
+                    cursor.execute(sql_relation, (parent_mid, child_mid, rel_type, sub_relation_type, member_id, gen_diff))
+                
+                # Update AI Record Status if applicable
+                source_record_id = data.get('source_record_id')
+                source_index = request.form.get('source_index')
+                
+                if source_record_id and source_index and source_index.isdigit():
+                    try:
+                        idx = int(source_index)
+                        cursor.execute("SELECT ai_content FROM genealogy_records WHERE id = %s FOR UPDATE", (source_record_id,))
+                        rec = cursor.fetchone()
+                        if rec and rec['ai_content']:
+                            import json
+                            content = json.loads(rec['ai_content'])
+                            # Ensure content is a list (it might be a dict if single object, though we try to normalize)
+                            if isinstance(content, dict):
+                                content = [content]
+                                
+                            if isinstance(content, list):
+                                updated = False
+                                if 0 <= idx < len(content):
+                                    if not content[idx].get('is_imported'): # Avoid redundant updates
+                                        content[idx]['is_imported'] = True
+                                        content[idx]['imported_member_id'] = member_id
+                                        updated = True
+                                
+                                if updated:
+                                    new_content = json.dumps(content, ensure_ascii=False)
+                                    cursor.execute("UPDATE genealogy_records SET ai_content = %s WHERE id = %s", (new_content, source_record_id))
+                    except Exception as e:
+                        print(f"Error updating AI content status: {e}")
+
+                conn.commit()
+                
+                if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or request.is_json:
+                    return jsonify({"success": True, "message": "成员录入成功", "member_id": member_id})
+
+                flash('成员录入成功')
+                return redirect(url_for('members'))
+        
+        with conn.cursor() as cursor:
+            cursor.execute("SELECT id, name FROM family_member_info ORDER BY name")
+            all_members = cursor.fetchall()
+            cursor.execute("SELECT * FROM genealogy_records ORDER BY page_number ASC")
+            images = cursor.fetchall()
+            
+    except Exception as e:
+        flash(f'发生错误: {e}')
+        all_members = []
+        images = []
+    finally:
+        conn.close()
+        
+    return render_template('add_member.html', all_members=all_members, images=images, 
+                         prefilled_content=prefilled_content, source_oss_url=source_oss_url, source_record_id=source_record_id)
+
+@app.route('/manager/edit_member/<int:member_id>', methods=['GET', 'POST'])
+def edit_member(member_id):
+    if 'user_id' not in session:
+        return redirect(url_for('login'))
+        
+    conn = get_db_connection()
+    try:
+        if request.method == 'POST':
+            birthday_str = request.form.get('birthday')
+            birthday_ts = 0
+            if birthday_str:
+                try:
+                    birthday_ts = int(datetime.strptime(birthday_str, '%Y-%m-%d').timestamp())
+                except ValueError:
+                    birthday_ts = 0
+
+            # 关系数据
+            related_mid = request.form.get('related_mid')
+            relation_type = request.form.get('relation_type')
+            sub_relation_type = request.form.get('sub_relation_type', 0)
+
+            # 年龄校验逻辑
+            if related_mid and relation_type in ['1', '2']:
+                with conn.cursor() as cursor:
+                    cursor.execute("SELECT name, birthday FROM family_member_info WHERE id = %s", (related_mid,))
+                    parent = cursor.fetchone()
+                    if parent and parent['birthday'] > 0 and birthday_ts > 0:
+                        if birthday_ts < parent['birthday']:
+                            flash(f"数据冲突:成员年龄不能比其父亲/母亲({parent['name']})大,请检查并修正出生日期。")
+                            # 重新加载编辑页所需数据
+                            cursor.execute("SELECT * FROM family_member_info WHERE id = %s", (member_id,))
+                            member = cursor.fetchone()
+                            member['birthday_date'] = birthday_str # 保持用户输入
+                            cursor.execute("SELECT id, name FROM family_member_info WHERE id != %s ORDER BY name", (member_id,))
+                            all_members = cursor.fetchall()
+                            cursor.execute("SELECT * FROM genealogy_records ORDER BY page_number ASC")
+                            images = cursor.fetchall()
+                            
+                            if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or request.is_json:
+                                return jsonify({
+                                    "success": False, 
+                                    "message": f"数据冲突:成员年龄不能比其父亲/母亲({parent['name']})大,请检查并修正出生日期。"
+                                }), 400
+                            
+                            return render_template('add_member.html', member=member, images=images, all_members=all_members)
+
+            data = {
+                'name': request.form['name'],
+                'simplified_name': request.form.get('simplified_name'),
+                'former_name': request.form.get('former_name'),
+                'childhood_name': request.form.get('childhood_name'),
+                'name_word': request.form.get('name_word'),
+                'name_word_generation': request.form.get('name_word_generation'),
+                'name_title': request.form.get('name_title'),
+                'sex': request.form['sex'],
+                'birthday': birthday_ts,
+                'is_pass_away': request.form.get('is_pass_away', 0),
+                'marital_status': request.form.get('marital_status', 0),
+                'birth_place': request.form.get('birth_place'),
+                'branch_family_hall': request.form.get('branch_family_hall'),
+                'cluster_place': request.form.get('cluster_place'),
+                'nation': request.form.get('nation'),
+                'residential_address': request.form.get('residential_address'),
+                'phone': request.form.get('phone'),
+                'mail': request.form.get('mail'),
+                'wechat_account': request.form.get('wechat_account'),
+                'id_number': request.form.get('id_number'),
+                'occupation': request.form.get('occupation'),
+                'educational': request.form.get('educational'),
+                'blood_type': request.form.get('blood_type'),
+                'religion': request.form.get('religion'),
+                'hobbies': request.form.get('hobbies'),
+                'personal_achievements': request.form.get('personal_achievements'),
+                'family_rank': request.form.get('family_rank'),
+                'tags': request.form.get('tags'),
+                'notes': request.form.get('notes'),
+                'source_record_id': request.form.get('source_record_id') or None
+            }
+            
+            # 关系数据
+            related_mid = request.form.get('related_mid')
+            relation_type = request.form.get('relation_type')
+            sub_relation_type = request.form.get('sub_relation_type', 0)
+            
+            with conn.cursor() as cursor:
+                update_parts = [f"{k} = %s" for k in data.keys()]
+                sql = f"UPDATE family_member_info SET {', '.join(update_parts)} WHERE id = %s"
+                cursor.execute(sql, list(data.values()) + [member_id])
+                
+                # 更新关系
+                if related_mid and relation_type:
+                    rel_type = int(relation_type)
+                    cursor.execute("DELETE FROM family_relation_info WHERE source_mid = %s", (member_id,))
+                    
+                    parent_mid = int(related_mid)
+                    child_mid = member_id
+                    gen_diff = 1 if rel_type in [1, 2] else 0
+                    
+                    sql_relation = """
+                        INSERT INTO family_relation_info 
+                        (parent_mid, child_mid, relation_type, sub_relation_type, source_mid, generation_diff) 
+                        VALUES (%s, %s, %s, %s, %s, %s)
+                    """
+                    cursor.execute(sql_relation, (parent_mid, child_mid, rel_type, sub_relation_type, member_id, gen_diff))
+                
+                # Update AI Record Status if applicable
+                source_record_id = data.get('source_record_id')
+                source_index = request.form.get('source_index')
+                
+                if source_record_id and source_index and source_index.isdigit():
+                    try:
+                        idx = int(source_index)
+                        cursor.execute("SELECT ai_content FROM genealogy_records WHERE id = %s FOR UPDATE", (source_record_id,))
+                        rec = cursor.fetchone()
+                        if rec and rec['ai_content']:
+                            import json
+                            content = json.loads(rec['ai_content'])
+                            if isinstance(content, dict):
+                                content = [content]
+                                
+                            if isinstance(content, list):
+                                updated = False
+                                if 0 <= idx < len(content):
+                                    if not content[idx].get('is_imported'): # Avoid redundant updates
+                                        content[idx]['is_imported'] = True
+                                        content[idx]['imported_member_id'] = member_id
+                                        updated = True
+                                
+                                if updated:
+                                    new_content = json.dumps(content, ensure_ascii=False)
+                                    cursor.execute("UPDATE genealogy_records SET ai_content = %s WHERE id = %s", (new_content, source_record_id))
+                    except Exception as e:
+                        print(f"Error updating AI content status: {e}")
+
+                conn.commit()
+                if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or request.is_json:
+                    return jsonify({"success": True, "message": "成员信息更新成功"})
+                
+                flash('成员信息更新成功')
+                return redirect(url_for('members'))
+        
+        with conn.cursor() as cursor:
+            cursor.execute("SELECT * FROM family_member_info WHERE id = %s", (member_id,))
+            member = cursor.fetchone()
+            if not member:
+                flash('成员不存在')
+                return redirect(url_for('members'))
+            
+            # 格式化日期供显示
+            if member.get('birthday'):
+                member['birthday_date'] = format_timestamp(member['birthday'])
+            
+            # 获取现有关系
+            cursor.execute("SELECT * FROM family_relation_info WHERE source_mid = %s LIMIT 1", (member_id,))
+            current_relation = cursor.fetchone()
+            
+            cursor.execute("SELECT id, name FROM family_member_info WHERE id != %s ORDER BY name", (member_id,))
+            all_members = cursor.fetchall()
+            
+            cursor.execute("SELECT * FROM genealogy_records ORDER BY page_number ASC")
+            images = cursor.fetchall()
+    finally:
+        conn.close()
+        
+    return render_template('add_member.html', member=member, images=images, all_members=all_members, current_relation=current_relation)
+
+@app.route('/manager/member_detail/<int:member_id>')
+def member_detail(member_id):
+    if 'user_id' not in session:
+        return redirect(url_for('login'))
+        
+    conn = get_db_connection()
+    try:
+        with conn.cursor() as cursor:
+            # Join with genealogy_records to get source image info
+            sql = """
+                SELECT m.*, r.oss_url as source_image_url, r.page_number as source_page
+                FROM family_member_info m
+                LEFT JOIN genealogy_records r ON m.source_record_id = r.id
+                WHERE m.id = %s
+            """
+            cursor.execute(sql, (member_id,))
+            member = cursor.fetchone()
+            if not member:
+                flash('成员不存在')
+                return redirect(url_for('members'))
+            
+            member['birthday_str'] = format_timestamp(member.get('birthday'))
+            
+            # 获取关系
+            cursor.execute("""
+                SELECT m.id, m.name, r.relation_type 
+                FROM family_relation_info r 
+                JOIN family_member_info m ON r.parent_mid = m.id 
+                WHERE r.child_mid = %s
+            """, (member_id,))
+            parents = cursor.fetchall()
+            
+            cursor.execute("""
+                SELECT m.id, m.name, r.relation_type 
+                FROM family_relation_info r 
+                JOIN family_member_info m ON r.child_mid = m.id 
+                WHERE r.parent_mid = %s
+            """, (member_id,))
+            children = cursor.fetchall()
+    finally:
+        conn.close()
+        
+    return render_template('member_detail.html', member=member, parents=parents, children=children)
+
+@app.route('/manager/delete_member/<int:member_id>', methods=['POST'])
+def delete_member(member_id):
+    if 'user_id' not in session:
+        return jsonify({"success": False, "message": "Unauthorized"}), 401
+        
+    conn = get_db_connection()
+    try:
+        with conn.cursor() as cursor:
+            # 1. 删除关系表中关联该成员的所有记录
+            cursor.execute("DELETE FROM family_relation_info WHERE parent_mid = %s OR child_mid = %s OR source_mid = %s", 
+                         (member_id, member_id, member_id))
+            
+            # 2. 删除成员本身
+            cursor.execute("DELETE FROM family_member_info WHERE id = %s", (member_id,))
+            
+            conn.commit()
+            flash('成员及其关系已成功删除')
+            return redirect(url_for('members'))
+    except Exception as e:
+        conn.rollback()
+        flash(f'删除失败: {e}')
+        return redirect(url_for('members'))
+    finally:
+        conn.close()
+
+@app.route('/manager/login', methods=['GET', 'POST'])
+def login():
+    if request.method == 'POST':
+        username = request.form['username']
+        password = request.form['password']
+        
+        try:
+            conn = get_db_connection()
+            try:
+                with conn.cursor() as cursor:
+                    cursor.execute("SELECT * FROM users WHERE username=%s AND password=%s", (username, password))
+                    user = cursor.fetchone()
+                    if user:
+                        session['user_id'] = user['id']
+                        session['username'] = user['username']
+                        return redirect(url_for('index'))
+                    else:
+                        flash('用户名或密码错误')
+            finally:
+                conn.close()
+        except Exception as e:
+            flash(f'数据库连接错误: {str(e)}')
+            print(f'Login error: {str(e)}')
+            
+    return render_template('login.html')
+
+@app.route('/manager/logout')
+def logout():
+    session.clear()
+    return redirect(url_for('login'))
+
+import requests
+import json
+import re
+
+@app.route('/manager/api/recognize_image', methods=['POST'])
+def recognize_image():
+    if 'user_id' not in session:
+        return jsonify({"success": False, "message": "Unauthorized"}), 401
+    
+    data = request.json
+    image_url = data.get('image_url')
+    if not image_url:
+        return jsonify({"success": False, "message": "No image URL provided"}), 400
+
+    api_key = "a1800657-9212-4afe-9b7c-b49f015c54d3"
+    api_url = "https://ark.cn-beijing.volces.com/api/v3/responses"
+    
+    prompt = """
+    请分析这张家谱图片,提取其中关于人物的信息。
+    请务必将繁体字转换为简体字(original_name 字段除外)。
+    特别注意:'name' 字段必须是纯简体中文,不能包含繁体字(例如:'學'应转换为'学','劉'应转换为'刘','萬'应转换为'万')。
+    请提取以下字段(如果存在):
+    - original_name: 原始姓名(严格保持图片上的繁体字,不做任何修改或转换)
+    - name: 简体姓名(必须转换为简体中文,去除不需要的敬称)
+    - sex: 性别(男/女)
+    - birthday: 出生日期(尝试转换为YYYY-MM-DD格式,如果无法确定年份可只填月日)
+    - father_name: 父亲姓名
+    - spouse_name: 配偶姓名
+    - generation: 第几世/代数
+    - name_word: 字辈(例如名字为“学勤公”,“学”为字辈;提取名字中的字辈信息)
+    - education: 学历/功名
+    - title: 官职/称号
+    
+    请严格以JSON列表格式返回,不要包含Markdown代码块标记(如 ```json ... ```),直接返回JSON数组。
+    如果包含多个人物,请都提取出来。
+    """
+
+    payload = {
+        "model": "doubao-seed-1-8-251228",
+        "stream": True,
+        "input": [
+            {
+                "role": "user",
+                "content": [
+                    {
+                        "type": "input_image",
+                        "image_url": image_url
+                    },
+                    {
+                        "type": "input_text",
+                        "text": prompt
+                    }
+                ]
+            }
+        ]
+    }
+    
+    headers = {
+        "Authorization": f"Bearer {api_key}",
+        "Content-Type": "application/json"
+    }
+    
+    def generate():
+        yield "正在连接 AI 服务...\n"
+        try:
+            # 使用 stream=True, timeout=120
+            # 增加 verify=False 以防 SSL 问题(开发环境)
+            # 增加 proxies=None 以防本地代理干扰
+            with requests.post(
+                api_url, 
+                json=payload, 
+                headers=headers, 
+                stream=True, 
+                timeout=1200, 
+                verify=False,
+                proxies={"http": None, "https": None}
+            ) as r:
+                if r.status_code != 200:
+                    yield f"Error: API returned status code {r.status_code}. Response: {r.text}"
+                    return
+
+                yield "连接成功,正在等待 AI 响应...\n"
+                
+                full_reasoning = ""
+                
+                json_started = False
+                
+                for line in r.iter_lines():
+                    if line:
+                        line_str = line.decode('utf-8')
+                        if line_str.startswith('data: '):
+                            json_str = line_str[6:]
+                            if json_str.strip() == '[DONE]':
+                                break
+                            try:
+                                chunk = json.loads(json_str)
+                                
+                                # 处理 standard OpenAI choices format (content)
+                                if 'choices' in chunk and len(chunk['choices']) > 0:
+                                    delta = chunk['choices'][0].get('delta', {})
+                                    if 'content' in delta:
+                                        if not json_started:
+                                            yield "|||JSON_START|||"
+                                            json_started = True
+                                        yield delta['content']
+                                    
+                                    # 处理 standard OpenAI choices format (reasoning_content) if any
+                                    if 'reasoning_content' in delta:
+                                        yield f"\n[推理]: {delta['reasoning_content']}"
+
+                                # 处理 Doubao/Volcano specific formats
+                                # Type: response.reasoning_summary_text.delta
+                                if chunk.get('type') == 'response.reasoning_summary_text.delta':
+                                    if 'delta' in chunk:
+                                        yield chunk['delta']
+                                
+                                # Type: response.text.delta
+                                if chunk.get('type') == 'response.text.delta':
+                                    if 'delta' in chunk:
+                                        if not json_started:
+                                            yield "|||JSON_START|||"
+                                            json_started = True
+                                        yield chunk['delta']
+                                        
+                                # Type: response.output_item.added (May contain initial content or status)
+                                # Type: response.reasoning_summary_part.added
+                                
+                            except Exception as e:
+                                print(f"Chunk parse error: {e}")
+                        else:
+                            # 尝试直接解析非 data: 开头的行
+                            try:
+                                chunk = json.loads(line_str)
+                                if 'choices' in chunk and len(chunk['choices']) > 0:
+                                    content = chunk['choices'][0]['message']['content']
+                                    yield content
+                            except:
+                                pass
+        except Exception as e:
+            yield f"\n[Error: {str(e)}]"
+
+    return Response(stream_with_context(generate()), mimetype='text/plain')
+
+@app.route('/manager/api/start_analysis/<int:record_id>', methods=['POST'])
+def start_analysis(record_id):
+    if 'user_id' not in session:
+        return jsonify({"success": False, "message": "Unauthorized"}), 401
+        
+    conn = get_db_connection()
+    try:
+        with conn.cursor() as cursor:
+            # Check if record exists
+            cursor.execute("SELECT oss_url, ai_status FROM genealogy_records WHERE id = %s", (record_id,))
+            record = cursor.fetchone()
+            
+            if not record:
+                return jsonify({"success": False, "message": "Record not found"}), 404
+            
+            # Update status to processing (1)
+            cursor.execute("UPDATE genealogy_records SET ai_status = 1 WHERE id = %s", (record_id,))
+            conn.commit()
+            
+            # Start background task
+            threading.Thread(target=process_ai_task, args=(record_id, record['oss_url'])).start()
+            
+            return jsonify({"success": True, "message": "Analysis started"})
+    except Exception as e:
+        return jsonify({"success": False, "message": str(e)}), 500
+    finally:
+        conn.close()
+
+@app.route('/manager/upload', methods=['GET', 'POST'])
+def upload():
+    if 'user_id' not in session:
+        return redirect(url_for('login'))
+        
+    # 获取建议页码 (当前最大页码 + 1)
+    conn = get_db_connection()
+    suggested_page = 1
+    try:
+        with conn.cursor() as cursor:
+            cursor.execute("SELECT MAX(page_number) as max_p FROM genealogy_records")
+            result = cursor.fetchone()
+            if result and result['max_p']:
+                suggested_page = result['max_p'] + 1
+    finally:
+        conn.close()
+
+    if request.method == 'POST':
+        if 'file' not in request.files:
+            flash('未选择文件')
+            return redirect(request.url)
+        
+        file = request.files['file']
+        if file.filename == '':
+            flash('未选择文件')
+            return redirect(request.url)
+            
+        # 允许用户在上传时直接指定页码,或者由 OCR 识别
+        manual_page = request.form.get('manual_page')
+        
+        if file:
+            filename = secure_filename(file.filename)
+            file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
+            file.save(file_path)
+            
+            # 1. 尝试 OCR 提取页码
+            page_num = extract_page_number(file_path)
+            
+            # 如果 OCR 没识别到,且用户也没手动输入,则跳转到“补录页码”页面
+            if not page_num and not manual_page:
+                # 先把文件传到 OSS,拿到 URL,方便后续补录
+                oss_url = upload_to_oss(file_path)
+                if oss_url:
+                    # 暂时存入 session 或者跳转带参数
+                    return render_template('confirm_page.html', 
+                                         filename=filename, 
+                                         oss_url=oss_url, 
+                                         suggested_page=suggested_page)
+                else:
+                    flash('上传 OSS 失败')
+                    return redirect(url_for('upload'))
+
+            # 使用识别到的或手动输入的页码
+            final_page = manual_page if manual_page else page_num
+            oss_url = upload_to_oss(file_path)
+            
+            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) VALUES (%s, %s, %s, 1)"
+                        cursor.execute(sql, (filename, oss_url, final_page))
+                        record_id = cursor.lastrowid
+                    conn.commit()
+                    
+                    # Start AI Task
+                    threading.Thread(target=process_ai_task, args=(record_id, oss_url)).start()
+                    
+                    flash(f'上传成功!页码:{final_page},已加入解析队列')
+                except Exception as e:
+                    flash(f'数据库错误:{e}')
+                finally:
+                    conn.close()
+                return redirect(url_for('index'))
+            else:
+                flash('上传 OSS 失败')
+                
+    return render_template('upload.html', suggested_page=suggested_page)
+
+@app.route('/manager/save_upload', methods=['POST'])
+def save_upload():
+    if 'user_id' not in session: return redirect(url_for('login'))
+    
+    filename = request.form.get('filename')
+    oss_url = request.form.get('oss_url')
+    page_number = request.form.get('page_number')
+    
+    if not oss_url or not page_number:
+        flash('页码不能为空')
+        return redirect(url_for('upload'))
+        
+    conn = get_db_connection()
+    try:
+        with conn.cursor() as cursor:
+            sql = "INSERT INTO genealogy_records (file_name, oss_url, page_number, ai_status) VALUES (%s, %s, %s, 1)"
+            cursor.execute(sql, (filename, oss_url, page_number))
+            record_id = cursor.lastrowid
+        conn.commit()
+        
+        # Start AI Task
+        threading.Thread(target=process_ai_task, args=(record_id, oss_url)).start()
+        
+        flash(f'记录已保存,页码:{page_number},已加入解析队列')
+    except Exception as e:
+        flash(f'保存失败: {e}')
+    finally:
+        conn.close()
+    return redirect(url_for('index'))
+
+@app.route('/manager/delete_upload/<int:record_id>', methods=['POST'])
+def delete_upload(record_id):
+    if 'user_id' not in session:
+        return jsonify({"success": False, "message": "Unauthorized"}), 401
+        
+    conn = get_db_connection()
+    try:
+        with conn.cursor() as cursor:
+            # 删除记录
+            cursor.execute("DELETE FROM genealogy_records WHERE id = %s", (record_id,))
+            conn.commit()
+            flash('文件记录已成功删除')
+            return redirect(url_for('index'))
+    except Exception as e:
+        conn.rollback()
+        flash(f'删除失败: {e}')
+        return redirect(url_for('index'))
+    finally:
+        conn.close()
+
+if __name__ == '__main__':
+    app.run(debug=True, port=5001)

+ 21 - 0
check_columns.py

@@ -0,0 +1,21 @@
+import pymysql
+
+DB_CONFIG = {
+    "host": "rm-f8ze60yirdj8786u2.mysql.rds.aliyuncs.com",
+    "port": 3306,
+    "user": "root",
+    "password": "csqz@20255",
+    "db": "csqz-client",
+    "charset": "utf8mb4",
+    "cursorclass": pymysql.cursors.DictCursor
+}
+
+conn = pymysql.connect(**DB_CONFIG)
+try:
+    with conn.cursor() as cursor:
+        cursor.execute("DESCRIBE family_member_info")
+        columns = cursor.fetchall()
+        for col in columns:
+            print(f"{col['Field']} - {col['Type']}")
+finally:
+    conn.close()

+ 39 - 0
check_schema.py

@@ -0,0 +1,39 @@
+import pymysql
+
+DB_CONFIG = {
+    "host": "rm-f8ze60yirdj8786u2.mysql.rds.aliyuncs.com",
+    "port": 3306,
+    "user": "root",
+    "password": "csqz@20255",
+    "db": "csqz-client",
+    "charset": "utf8mb4",
+    "cursorclass": pymysql.cursors.DictCursor
+}
+
+def check():
+    conn = pymysql.connect(**DB_CONFIG)
+    try:
+        with conn.cursor() as cursor:
+            # Check family_relation_info
+            print("--- family_relation_info ---")
+            try:
+                cursor.execute("DESCRIBE family_relation_info")
+                for col in cursor.fetchall():
+                    print(f"{col['Field']}: {col['Type']}")
+            except Exception as e:
+                print(f"Error: {e}")
+
+            # Check family_member_info
+            print("\n--- family_member_info ---")
+            try:
+                cursor.execute("DESCRIBE family_member_info")
+                for col in cursor.fetchall():
+                    print(f"{col['Field']}: {col['Type']}")
+            except Exception as e:
+                print(f"Error: {e}")
+
+    finally:
+        conn.close()
+
+if __name__ == "__main__":
+    check()

+ 58 - 0
init_db.py

@@ -0,0 +1,58 @@
+import pymysql
+import sys
+
+db_host = "rm-f8ze60yirdj8786u2.mysql.rds.aliyuncs.com "
+db_user = "root"
+db_pass = "csqz@20255"
+db_name = "csqz-client"
+
+def initialize():
+    try:
+        conn = pymysql.connect(
+            host=db_host,
+            user=db_user,
+            password=db_pass,
+            db=db_name,
+            port=3306
+        )
+        cur = conn.cursor()
+        
+        # Ensure genealogy_records table exists
+        cur.execute("""
+        CREATE TABLE IF NOT EXISTS genealogy_records (
+            id INT AUTO_INCREMENT PRIMARY KEY,
+            file_name VARCHAR(255) NOT NULL,
+            oss_url TEXT NOT NULL,
+            page_number INT,
+            upload_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+        )
+        """)
+        
+        # Add a test user if not exists
+        # We need to match the existing schema
+        cur.execute("SELECT * FROM users WHERE username='genealogy_admin'")
+        if not cur.fetchone():
+            sql = """
+            INSERT INTO users (username, password, password_hash, full_name, role, is_active) 
+            VALUES (%s, %s, %s, %s, %s, %s)
+            """
+            # Using plain password for 'password' column and a dummy hash for 'password_hash'
+            cur.execute(sql, ('genealogy_admin', 'admin123', 'dummy_hash', 'Genealogy Admin', 'admin', 1))
+            print("Added user 'genealogy_admin' with password 'admin123'")
+        
+        conn.commit()
+        print("Initialization complete.")
+        return True
+        
+    except Exception as e:
+        print(f"Error during initialization: {e}")
+        return False
+    finally:
+        if 'conn' in locals() and conn.open:
+            conn.close()
+
+if __name__ == "__main__":
+    if initialize():
+        print("SUCCESS: Database initialized.")
+    else:
+        sys.exit(1)

+ 68 - 0
init_db_new.py

@@ -0,0 +1,68 @@
+import pymysql
+import sys
+
+db_host = "rm-f8ze60yirdj8786u2.mysql.rds.aliyuncs.com "
+db_user = "root"
+db_pass = "csqz@20255"
+db_name = "csqz-client"
+
+def initialize():
+    try:
+        conn = pymysql.connect(
+            host=db_host,
+            user=db_user,
+            password=db_pass,
+            db=db_name,
+            port=3306
+        )
+        cur = conn.cursor()
+        
+        # 1. Create users table
+        print(f"Creating 'users' table in {db_name}...")
+        cur.execute("""
+        CREATE TABLE IF NOT EXISTS users (
+            id INT AUTO_INCREMENT PRIMARY KEY,
+            username VARCHAR(50) UNIQUE NOT NULL,
+            password VARCHAR(255) NOT NULL,
+            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+        )
+        """)
+        
+        # 2. Create genealogy_records table
+        print(f"Creating 'genealogy_records' table in {db_name}...")
+        cur.execute("""
+        CREATE TABLE IF NOT EXISTS genealogy_records (
+            id INT AUTO_INCREMENT PRIMARY KEY,
+            file_name VARCHAR(255) NOT NULL,
+            oss_url TEXT NOT NULL,
+            page_number INT,
+            upload_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+        )
+        """)
+        
+        # 3. Add a default user
+        print("Adding default user 'admin'...")
+        cur.execute("SELECT * FROM users WHERE username='admin'")
+        if not cur.fetchone():
+            cur.execute("INSERT INTO users (username, password) VALUES (%s, %s)", ('admin', 'admin123'))
+            print("Added default user 'admin' with password 'admin123'")
+        else:
+            print("User 'admin' already exists.")
+        
+        conn.commit()
+        print("Initialization complete.")
+        return True
+        
+    except Exception as e:
+        print(f"Error during initialization: {e}")
+        return False
+    finally:
+        if 'conn' in locals() and conn.open:
+            conn.close()
+
+if __name__ == "__main__":
+    if initialize():
+        print("SUCCESS: Database initialized.")
+    else:
+        print("FAILURE: Database initialization failed.")
+        sys.exit(1)

+ 48 - 0
migrate_ai_fields.py

@@ -0,0 +1,48 @@
+import pymysql
+
+db_config = {
+    "host": "rm-f8ze60yirdj8786u2.mysql.rds.aliyuncs.com",
+    "port": 3306,
+    "user": "root",
+    "password": "csqz@20255",
+    "db": "csqz-client",
+    "charset": "utf8mb4",
+    "cursorclass": pymysql.cursors.DictCursor
+}
+
+def migrate():
+    conn = pymysql.connect(**db_config)
+    try:
+        with conn.cursor() as cursor:
+            # 1. Add ai_status and ai_content to genealogy_records
+            print("Checking genealogy_records table...")
+            cursor.execute("DESCRIBE genealogy_records")
+            columns = [col['Field'] for col in cursor.fetchall()]
+            
+            if 'ai_status' not in columns:
+                print("Adding ai_status column...")
+                cursor.execute("ALTER TABLE genealogy_records ADD COLUMN ai_status INT DEFAULT 0 COMMENT '0:未开始, 1:处理中, 2:成功, 3:失败'")
+            
+            if 'ai_content' not in columns:
+                print("Adding ai_content column...")
+                cursor.execute("ALTER TABLE genealogy_records ADD COLUMN ai_content LONGTEXT COMMENT 'AI解析结果JSON'")
+
+            # 2. Add source_record_id to family_member_info
+            print("Checking family_member_info table...")
+            cursor.execute("DESCRIBE family_member_info")
+            member_columns = [col['Field'] for col in cursor.fetchall()]
+
+            if 'source_record_id' not in member_columns:
+                print("Adding source_record_id column...")
+                cursor.execute("ALTER TABLE family_member_info ADD COLUMN source_record_id INT DEFAULT NULL COMMENT '关联的原始图片记录ID'")
+                
+        conn.commit()
+        print("Migration completed successfully.")
+    except Exception as e:
+        print(f"Migration failed: {e}")
+        conn.rollback()
+    finally:
+        conn.close()
+
+if __name__ == "__main__":
+    migrate()

+ 50 - 0
migrate_db.py

@@ -0,0 +1,50 @@
+import pymysql
+
+db_host = "rm-f8ze60yirdj8786u2.mysql.rds.aliyuncs.com"
+db_user = "root"
+db_pass = "csqz@20255"
+db_name = "csqz-client"
+
+def migrate():
+    conn = pymysql.connect(
+        host=db_host,
+        user=db_user,
+        password=db_pass,
+        db=db_name,
+        port=3306,
+        charset='utf8mb4',
+        cursorclass=pymysql.cursors.DictCursor
+    )
+    try:
+        with conn.cursor() as cursor:
+            # 1. Update genealogy_records
+            print("Checking genealogy_records columns...")
+            cursor.execute("DESCRIBE genealogy_records")
+            columns = [col['Field'] for col in cursor.fetchall()]
+            
+            if 'status' not in columns:
+                print("Adding 'status' column to genealogy_records...")
+                cursor.execute("ALTER TABLE genealogy_records ADD COLUMN status VARCHAR(20) DEFAULT 'pending'")
+            
+            if 'ai_content' not in columns:
+                print("Adding 'ai_content' column to genealogy_records...")
+                cursor.execute("ALTER TABLE genealogy_records ADD COLUMN ai_content MEDIUMTEXT")
+
+            # 2. Update family_member_info
+            print("Checking family_member_info columns...")
+            cursor.execute("DESCRIBE family_member_info")
+            member_columns = [col['Field'] for col in cursor.fetchall()]
+
+            if 'source_record_id' not in member_columns:
+                print("Adding 'source_record_id' column to family_member_info...")
+                cursor.execute("ALTER TABLE family_member_info ADD COLUMN source_record_id INT")
+
+        conn.commit()
+        print("Migration complete.")
+    except Exception as e:
+        print(f"Migration failed: {e}")
+    finally:
+        conn.close()
+
+if __name__ == "__main__":
+    migrate()

+ 50 - 0
ocr_utils.py

@@ -0,0 +1,50 @@
+import re
+try:
+    import pytesseract
+    from PIL import Image
+except ImportError:
+    pytesseract = None
+
+def extract_page_number(file_path):
+    """
+    Extracts page number from a scanned image or PDF.
+    For simplicity, this version focus on images.
+    """
+    if not pytesseract:
+        print("pytesseract not installed. Skipping OCR.")
+        return None
+    
+    try:
+        # Load image
+        image = Image.open(file_path)
+        # Perform OCR
+        text = pytesseract.image_to_string(image, lang='chi_sim+eng')
+        
+        # Look for page numbers (e.g., "第 1 页", "1 / 10", or just "1" at the bottom)
+        # We'll use some regex patterns
+        patterns = [
+            r'第\s*(\d+)\s*页',
+            r'(\d+)\s*/\s*\d+',
+            r'-\s*(\d+)\s*-',
+            r'\n\s*(\d+)\s*\n' # Often single digits on a line at the end
+        ]
+        
+        for pattern in patterns:
+            matches = re.findall(pattern, text)
+            if matches:
+                # Usually the last match is the page number if it's at the bottom
+                return int(matches[-1])
+        
+        # If no pattern matches, look for any digits in the last few lines
+        lines = text.strip().split('\n')
+        if lines:
+            last_lines = lines[-3:] # check last 3 lines
+            for line in reversed(last_lines):
+                digits = re.findall(r'\d+', line)
+                if digits:
+                    return int(digits[-1])
+                    
+        return None
+    except Exception as e:
+        print(f"Error during OCR: {e}")
+        return None

+ 31 - 0
oss_utils.py

@@ -0,0 +1,31 @@
+import requests
+
+def upload_to_oss(file_path):
+    """
+    Uploads a file to OSS using the provided API endpoint.
+    URL: https://crmapi.dcjxb.yunzhixue.cn/file/upload
+    Method: POST
+    Form-data: parameter 'file'
+    """
+    url = "https://crmapi.dcjxb.yunzhixue.cn/file/upload"
+    try:
+        with open(file_path, 'rb') as f:
+            files = {'file': f}
+            response = requests.post(url, files=files)
+            response.raise_for_status()
+            result = response.json()
+            # Assuming the response contains the URL in a specific field
+            # We'll need to check the actual response format
+            # Typical format: {"code": 200, "data": {"url": "..."}} or similar
+            if result.get('code') == 200 or result.get('success'):
+                # Extract URL - adjusting based on common API patterns
+                data = result.get('data', {})
+                if isinstance(data, str):
+                    return data
+                return data.get('url') or data.get('fileUrl') or result.get('url')
+            else:
+                print(f"Upload failed: {result}")
+                return None
+    except Exception as e:
+        print(f"Error uploading to OSS: {e}")
+        return None

+ 6 - 0
requirements.txt

@@ -0,0 +1,6 @@
+Flask==2.0.1
+PyMySQL==1.0.2
+requests==2.26.0
+Pillow==8.3.1
+pytesseract==0.3.8
+Werkzeug==2.0.1

文件差異過大導致無法顯示
+ 1 - 0
static/js/d3.min.js


+ 1576 - 0
templates/add_member.html

@@ -0,0 +1,1576 @@
+{% extends "layout.html" %}
+
+{% block title %}{{ '编辑' if member else '录入' }}成员 - 家谱管理系统{% endblock %}
+
+{% block extra_css %}
+<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; }
+    .image-panel { flex: 0.8; padding: 20px; background: #f8f9fa; display: flex; flex-direction: column; }
+    .image-viewer { flex: 1; border: 1px solid #ccc; background: white; overflow: hidden; text-align: center; position: relative; }
+    .image-viewer img { max-width: 100%; height: auto; transition: transform 0.2s, filter 0.2s; transform-origin: top left; cursor: grab; }
+    .image-viewer img.dragging { cursor: grabbing; }
+    
+    /* 放大镜样式 */
+    .magnifier-glass {
+        position: absolute;
+        border: 3px solid #000;
+        border-radius: 50%;
+        cursor: none;
+        width: 150px;
+        height: 150px;
+        box-shadow: 0 0 10px rgba(0,0,0,0.5);
+        display: none;
+        z-index: 1000;
+        background-repeat: no-repeat;
+        background-color: white;
+        pointer-events: none;
+    }
+
+    /* Image Viewer & Dragging */
+    .image-viewer { 
+        flex: 1; 
+        border: 1px solid #ccc; 
+        background: #f0f0f0; 
+        overflow: hidden; 
+        text-align: center; 
+        position: relative; 
+        cursor: grab;
+        user-select: none;
+    }
+    .image-viewer:active {
+        cursor: grabbing;
+    }
+    
+    .image-wrapper {
+        display: inline-block;
+        transition: transform 0.2s ease-out;
+        transform-origin: center center;
+        position: absolute;
+        /* Initial centering will be handled by JS or CSS translate */
+        top: 50%;
+        left: 50%;
+        transform: translate(-50%, -50%);
+    }
+
+    .image-wrapper img {
+        max-width: 100%;
+        max-height: 100vh;
+        display: block;
+        pointer-events: none;
+        user-select: none;
+        transition: filter 0.2s;
+    }
+
+    .image-toolbar {
+        background: #e9ecef;
+        padding: 5px 10px;
+        border-bottom: 1px solid #dee2e6;
+        display: flex;
+        gap: 10px;
+        align-items: center;
+        flex-wrap: wrap;
+    }
+    .image-toolbar .btn-group-xs > .btn, .image-toolbar .btn-sm {
+        padding: 0.25rem 0.5rem;
+        font-size: 0.875rem;
+    }
+    .filter-controls { display: flex; align-items: center; gap: 5px; font-size: 0.8rem; }
+    .filter-controls input[type=range] { width: 80px; }
+    .page-nav { margin-bottom: 10px; display: flex; gap: 10px; align-items: center; }
+    .section-title { border-left: 4px solid #0d6efd; padding-left: 10px; margin: 25px 0 15px; font-weight: bold; color: #333; }
+</style>
+{% endblock %}
+
+{% block content %}
+<div class="split-container">
+    <!-- 左侧:录入/编辑表单 -->
+    <div class="form-panel">
+        <div class="card shadow-sm mb-4">
+            <div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
+                <h5 class="mb-0">{{ '编辑成员信息' if member else '录入新成员' }}</h5>
+                <a href="{{ url_for('members') }}" class="btn btn-sm btn-light">返回列表</a>
+            </div>
+            <div class="card-body">
+                <form method="POST">
+                    <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="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 '' }}">
+                        </div>
+                        <div class="col-md-6">
+                            <label class="form-label">姓名(简体)</label>
+                            <input type="text" name="simplified_name" class="form-control" value="{{ member.simplified_name if member else '' }}">
+                        </div>
+                        <div class="col-md-6">
+                            <label class="form-label">性别 <span class="text-danger">*</span></label>
+                            <select name="sex" class="form-select" required>
+                                <option value="1" {{ 'selected' if member and member.sex == 1 else '' }}>男</option>
+                                <option value="2" {{ 'selected' if member and member.sex == 2 else '' }}>女</option>
+                            </select>
+                        </div>
+                        <div class="col-md-6">
+                            <label class="form-label">出生日期 <span class="text-danger">*</span></label>
+                            <div class="input-group has-validation">
+                                {% set birthday_val = member.birthday_date if member and member.birthday_date != '未知' else '' %}
+                                <input type="date" name="birthday" class="form-control" required value="{{ birthday_val }}" onchange="validateAge()">
+                                <div class="input-group-text bg-white">
+                                    <input class="form-check-input mt-0" type="checkbox" id="birthdayUnknown" onchange="toggleBirthdayUnknown()" {{ 'checked' if member and member.birthday_date == '未知' else '' }}>
+                                    <label class="form-check-label ms-1 small user-select-none" for="birthdayUnknown">不详</label>
+                                </div>
+                                <div class="invalid-feedback" id="ageFeedback"></div>
+                            </div>
+                        </div>
+                    </div>
+
+                    <div class="section-title">关系录入 (选择关联成员及关系)</div>
+                    <div class="row g-3">
+                        <div class="col-md-5">
+                            <label class="form-label">关联成员</label>
+                            <select name="related_mid" class="form-select">
+                                <option value="">-- 请选择 --</option>
+                                {% for m in all_members %}
+                                <option value="{{ m.id }}" data-birthday="{{ m.birthday }}" {{ 'selected' if current_relation and current_relation.parent_mid == m.id else '' }}>
+                                    {{ m.name }} (ID: {{ m.id }})
+                                </option>
+                                {% endfor %}
+                            </select>
+                        </div>
+                        <div class="col-md-4">
+                            <label class="form-label">关系类型</label>
+                            <select name="relation_type" class="form-select">
+                                <option value="">-- 请选择 --</option>
+                                <option value="1" {{ 'selected' if current_relation and current_relation.relation_type == 1 else '' }}>父子 (关联人为父)</option>
+                                <option value="2" {{ 'selected' if current_relation and current_relation.relation_type == 2 else '' }}>母子 (关联人为母)</option>
+                                <option value="10" {{ 'selected' if current_relation and current_relation.relation_type == 10 else '' }}>夫妻</option>
+                                <option value="11" {{ 'selected' if current_relation and current_relation.relation_type == 11 else '' }}>兄弟</option>
+                                <option value="12" {{ 'selected' if current_relation and current_relation.relation_type == 12 else '' }}>姐妹</option>
+                            </select>
+                        </div>
+                        <div class="col-md-3">
+                            <label class="form-label">子类型</label>
+                            <select name="sub_relation_type" class="form-select">
+                                <option value="0" {{ 'selected' if current_relation and current_relation.sub_relation_type == 0 else '' }}>亲生/正妻</option>
+                                <option value="1" {{ 'selected' if current_relation and current_relation.sub_relation_type == 1 else '' }}>养父</option>
+                                <option value="2" {{ 'selected' if current_relation and current_relation.sub_relation_type == 2 else '' }}>过继</option>
+                                <option value="10" {{ 'selected' if current_relation and current_relation.sub_relation_type == 10 else '' }}>妾</option>
+                                <option value="11" {{ 'selected' if current_relation and current_relation.sub_relation_type == 11 else '' }}>外室</option>
+                            </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="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 class="d-grid gap-2 mt-5 mb-5">
+                        <button type="submit" class="btn btn-success btn-lg">
+                            <i class="bi bi-check-circle me-1"></i> {{ '保存修改' if member else '确认录入' }}
+                        </button>
+                    </div>
+                </form>
+            </div>
+        </div>
+    </div>
+
+    
+    <!-- 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>
+        
+        <!-- 推理过程 -->
+        <div class="mb-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>
+             <div class="collapse show" id="collapseReasoning">
+                 <pre id="aiLogContent" class="text-success small mb-0 p-2 bg-black rounded border border-secondary" style="max-height: 200px; overflow-y: auto; white-space: pre-wrap; font-family: monospace; font-size: 0.8rem;"></pre>
+             </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">
+                <h6 class="mb-0 text-info"><i class="bi bi-check-circle"></i> 识别结果 (<span id="resultCount">0</span>)</h6>
+                <span class="small text-muted">点击下方条目填充</span>
+            </div>
+            <div id="aiResultList" class="d-flex flex-column gap-2">
+                <!-- 结果项将动态插入 -->
+            </div>
+        </div>
+    </div>
+
+    <!-- 右侧:图片参考 -->
+    <div class="image-panel">
+        <div class="page-nav">
+            <label class="fw-bold">扫描件参考:</label>
+            <button id="aiBtn" onclick="recognizeImage()" class="btn btn-sm btn-info text-white ms-2 me-2">
+                <i class="bi bi-magic"></i> AI 识别
+            </button>
+            <input type="number" id="pageInput" class="form-control form-control-sm" style="width: 70px;" placeholder="页码">
+            <button onclick="gotoPage()" class="btn btn-sm btn-primary">跳转</button>
+            <div class="ms-auto small text-muted">
+                当前: <span id="currentPage">1</span> / <span id="totalPages">{{ images|length }}</span>
+            </div>
+        </div>
+        <div class="image-toolbar rounded-top">
+            <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>
+                <button type="button" class="btn btn-outline-secondary" onclick="rotateImage(90)" title="右旋90°"><i class="bi bi-arrow-clockwise"></i></button>
+            </div>
+            <div class="filter-controls border-start border-end px-2 mx-1">
+                <i class="bi bi-brightness-high" title="亮度"></i>
+                <input type="range" min="50" max="150" value="100" oninput="updateImageFilter()" id="brightnessRange">
+                <i class="bi bi-circle-half ms-2" title="对比度"></i>
+                <input type="range" min="50" max="200" value="100" oninput="updateImageFilter()" id="contrastRange">
+                <button class="btn btn-link btn-sm text-decoration-none py-0" onclick="resetFilters()">重置</button>
+            </div>
+            <div class="form-check form-switch ms-auto mb-0" title="开启后鼠标悬停图片可局部放大">
+                <input class="form-check-input" type="checkbox" id="magnifierSwitch">
+                <label class="form-check-label small" for="magnifierSwitch">🔍 放大镜</label>
+            </div>
+        </div>
+        <div class="image-viewer shadow-inner" id="viewer">
+            <div id="magnifier" class="magnifier-glass"></div>
+            <div id="imageWrapper" class="image-wrapper">
+                {% if images %}
+                    <img id="refImage" src="{{ images[0].oss_url }}" alt="家谱图片" draggable="false">
+                {% else %}
+                    <div class="mt-5 text-muted">
+                        <i class="bi bi-image fs-1 d-block mb-2"></i>
+                        暂无上传的家谱图片
+                    </div>
+                {% endif %}
+            </div>
+        </div>
+        <div class="mt-2 d-flex justify-content-between">
+            <button onclick="prevImage()" class="btn btn-sm btn-outline-secondary">上一张</button>
+            <button onclick="nextImage()" class="btn btn-sm btn-outline-secondary">下一张</button>
+        </div>
+    </div>
+</div>
+{% endblock %}
+
+{% block extra_js %}
+<script>
+    function toggleBirthdayUnknown() {
+        const cb = document.getElementById('birthdayUnknown');
+        const input = document.querySelector('input[name="birthday"]');
+        if (!cb || !input) return;
+        
+        if (cb.checked) {
+            input.value = '';
+            input.disabled = true;
+            input.required = false;
+            input.classList.remove('is-invalid');
+            const fb = document.getElementById('ageFeedback');
+            if(fb) fb.textContent = '';
+        } else {
+            input.disabled = false;
+            input.required = true;
+        }
+    }
+
+    function validateAge() {
+        const cb = document.getElementById('birthdayUnknown');
+        if (cb && cb.checked) return;
+
+        const birthdayInput = document.querySelector('input[name="birthday"]');
+        const relatedSelect = document.querySelector('select[name="related_mid"]');
+        const relationType = document.querySelector('select[name="relation_type"]');
+        const feedback = document.getElementById('ageFeedback');
+        
+        if (!birthdayInput.value || !relatedSelect.value) {
+            birthdayInput.classList.remove('is-invalid');
+            return;
+        }
+        
+        // Only check for Parent-Child relations (1: Father, 2: Mother)
+        if (relationType.value !== '1' && relationType.value !== '2') return;
+        
+        // We need the parent's birthday. This is tricky as we only have the ID.
+        // Option 1: Store parent birthdays in the select option dataset (easiest)
+        // Option 2: Async fetch.
+        
+        const selectedOption = relatedSelect.options[relatedSelect.selectedIndex];
+        const parentBirthdayTs = parseInt(selectedOption.dataset.birthday || '0');
+        
+        if (parentBirthdayTs > 0) {
+            const childBirthday = new Date(birthdayInput.value).getTime() / 1000;
+            if (childBirthday < parentBirthdayTs) {
+                birthdayInput.classList.add('is-invalid');
+                feedback.textContent = '警告:子女出生日期早于父母,请核对!';
+            } else if (childBirthday - parentBirthdayTs < 12 * 365 * 24 * 3600) {
+                 // Warning if age gap < 12 years
+                 birthdayInput.classList.add('is-invalid');
+                 feedback.textContent = '警告:父母与子女年龄差小于12岁,请核对!';
+            } else {
+                birthdayInput.classList.remove('is-invalid');
+            }
+        }
+    }
+
+    // Call validation when relation changes too
+    document.addEventListener('DOMContentLoaded', () => {
+        const relatedSelect = document.querySelector('select[name="related_mid"]');
+        if (relatedSelect) relatedSelect.addEventListener('change', validateAge);
+        
+        // Initialize birthday unknown state
+        toggleBirthdayUnknown();
+    });
+
+    const images = [
+        {% for img in images %}
+        { 
+            id: {{ img.id }},
+            url: "{{ img.oss_url }}", 
+            page: {{ img.page_number or 0 }},
+            ai_status: {{ img.ai_status or 0 }},
+            ai_content: {{ img.ai_content | tojson | safe if img.ai_content else 'null' }}
+        },
+        {% endfor %}
+    ];
+    let currentIndex = 0;
+    let currentParsedPeople = [];
+    
+    // Image State
+    let imgRotation = 0;
+    let imgBrightness = 100;
+    let imgContrast = 100;
+    
+    // Dragging State
+    let isDragging = false;
+    let hasDragged = false;
+    let startX = 0, startY = 0;
+    let currentX = 0, currentY = 0; // Relative to center (offsets)
+
+    // Zoom State
+    let isZoomedIn = false;
+    const ZOOM_LEVEL = 2.0;
+
+    // Magnifier Logic
+    const viewer = document.getElementById('viewer');
+    const magnifier = document.getElementById('magnifier');
+    const magnifierSwitch = document.getElementById('magnifierSwitch');
+    const imageWrapper = document.getElementById('imageWrapper');
+    
+    // Initialize Dragging and Zooming
+    if (imageWrapper) {
+        // Center initial position
+        imageWrapper.style.left = '50%';
+        imageWrapper.style.top = '50%';
+        
+        viewer.style.cursor = 'zoom-in';
+
+        viewer.addEventListener('mousedown', (e) => {
+            if (e.target.closest('.image-toolbar') || e.target.closest('.magnifier-glass')) return;
+            isDragging = true;
+            hasDragged = false;
+            startX = e.clientX;
+            startY = e.clientY;
+            
+            viewer.style.cursor = 'grabbing';
+            e.preventDefault(); // Prevent text selection
+        });
+        
+        window.addEventListener('mousemove', (e) => {
+            if (!isDragging) return;
+            
+            const dx = e.clientX - startX;
+            const dy = e.clientY - startY;
+            
+            // Threshold to consider it a drag
+            if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
+                hasDragged = true;
+            }
+
+            currentX += dx;
+            currentY += dy;
+            
+            startX = e.clientX;
+            startY = e.clientY;
+            
+            updateImageTransform();
+        });
+        
+        window.addEventListener('mouseup', (e) => {
+            if (isDragging) {
+                isDragging = false;
+                viewer.style.cursor = isZoomedIn ? 'grab' : 'zoom-in';
+                
+                // If it was a click (not a drag) and clicked inside the viewer
+                if (!hasDragged && viewer.contains(e.target)) {
+                    toggleZoom();
+                }
+            }
+        });
+    }
+
+    function toggleZoom() {
+        isZoomedIn = !isZoomedIn;
+        if (!isZoomedIn) {
+            // Reset position when zooming out to center
+            currentX = 0;
+            currentY = 0;
+        }
+        updateImageTransform();
+        // Update cursor immediately
+        viewer.style.cursor = isZoomedIn ? 'grab' : 'zoom-in';
+    }
+    
+    viewer.addEventListener('mousemove', function(e) {
+        if (!magnifierSwitch.checked) {
+            magnifier.style.display = 'none';
+            return;
+        }
+        
+        if (isDragging) {
+             magnifier.style.display = 'none';
+             return;
+        }
+        
+        const img = document.getElementById('refImage');
+        if (!img) return;
+
+        // Calculate position relative to the image
+        const rect = img.getBoundingClientRect();
+        const x = e.clientX - rect.left;
+        const y = e.clientY - rect.top;
+        
+        // Only show if inside image rect (approximate for rotated)
+        if (x < 0 || x > rect.width || y < 0 || y > rect.height) {
+            magnifier.style.display = 'none';
+            return;
+        }
+
+        magnifier.style.display = 'block';
+        
+        // Position the glass near mouse
+        const glassOffset = 20;
+        const viewerRect = viewer.getBoundingClientRect();
+        magnifier.style.left = (e.clientX - viewerRect.left + glassOffset) + 'px';
+        magnifier.style.top = (e.clientY - viewerRect.top + glassOffset) + 'px';
+        
+        // Background logic (Zoom 2x)
+        const zoom = 2.5;
+        magnifier.style.backgroundImage = `url('${img.src}')`;
+        magnifier.style.backgroundSize = `${rect.width * zoom}px ${rect.height * zoom}px`;
+        
+        // Simple version (imperfect for rotation)
+        magnifier.style.backgroundPosition = `-${x * zoom - 75}px -${y * zoom - 75}px`; 
+    });
+
+    // Image Manipulation
+    function rotateImage(deg) {
+        imgRotation = (imgRotation + deg) % 360;
+        updateImageTransform();
+    }
+    
+    function updateImageFilter() {
+        imgBrightness = document.getElementById('brightnessRange').value;
+        imgContrast = document.getElementById('contrastRange').value;
+        applyImageFilters();
+    }
+    
+    function resetFilters() {
+        imgRotation = 0;
+        imgBrightness = 100;
+        imgContrast = 100;
+        currentX = 0;
+        currentY = 0;
+        isZoomedIn = false;
+        
+        document.getElementById('brightnessRange').value = 100;
+        document.getElementById('contrastRange').value = 100;
+        
+        updateImageTransform();
+        applyImageFilters();
+    }
+    
+    function updateImageTransform() {
+        const wrapper = document.getElementById('imageWrapper');
+        if (wrapper) {
+            const scale = isZoomedIn ? ZOOM_LEVEL : 1;
+            wrapper.style.transform = `translate(calc(-50% + ${currentX}px), calc(-50% + ${currentY}px)) rotate(${imgRotation}deg) scale(${scale})`;
+            
+            // Adjust cursor based on state
+            if (!isDragging) {
+                viewer.style.cursor = isZoomedIn ? 'grab' : 'zoom-in';
+            }
+        }
+    }
+    
+    function applyImageFilters() {
+        const img = document.getElementById('refImage');
+        if (img) {
+            img.style.filter = `brightness(${imgBrightness}%) contrast(${imgContrast}%)`;
+        }
+    }
+    
+    // Reuse applyImageStyles as alias for compatibility if called elsewhere
+    function applyImageStyles() {
+        updateImageTransform();
+        applyImageFilters();
+    }
+
+    const fieldMapping = {
+        name: '姓名(繁体)',
+        simplified_name: '姓名(简体)',
+        sex: '性别',
+        birthday: '出生日期',
+        father_name: '父亲姓名',
+        spouse_name: '配偶姓名',
+        generation: '堂内排行(代数)',
+        name_word: '字辈',
+        education: '学历/功名',
+        title: '官职/称号',
+        death_date: '逝世日期',
+        note: '备注'
+    };
+
+    // --- Keyboard Shortcuts ---
+    document.addEventListener('keydown', (e) => {
+        // Ctrl/Cmd + Enter: Save
+        if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
+            e.preventDefault();
+            const submitBtn = document.querySelector('form button[type="submit"]');
+            if (submitBtn && !submitBtn.disabled) {
+                submitBtn.click(); // Trigger form submit listener
+            }
+        }
+        
+        // Ctrl/Cmd + Right Arrow: Next Image
+        if ((e.ctrlKey || e.metaKey) && e.key === 'ArrowRight') {
+            e.preventDefault();
+            nextImage();
+        }
+        
+        // Ctrl/Cmd + Left Arrow: Prev Image
+        if ((e.ctrlKey || e.metaKey) && e.key === 'ArrowLeft') {
+            e.preventDefault();
+            prevImage();
+        }
+        
+        // Alt + 1: Auto Fill First person in list
+        if (e.altKey && e.key === '1') {
+            e.preventDefault();
+            // Try to find the first "fill" button that is not disabled/success
+            const firstBtn = document.querySelector('button[id^="btn-fill-"]:not(.btn-success)');
+            if (firstBtn) firstBtn.click();
+        }
+    });
+
+    // --- AJAX Form Submission ---
+    document.addEventListener('DOMContentLoaded', () => {
+        const form = document.querySelector('form');
+        form.addEventListener('submit', async (e) => {
+            e.preventDefault();
+            
+            // Collect form data
+            const formData = new FormData(form);
+            
+            // Visual feedback on button
+            const submitBtn = form.querySelector('button[type="submit"]');
+            const originalBtnHtml = submitBtn.innerHTML;
+            submitBtn.disabled = true;
+            submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 保存中...';
+            
+            try {
+                // Use form.action to support both add and edit URLs
+                const targetUrl = form.action || window.location.href;
+                const response = await fetch(targetUrl, {
+                    method: 'POST',
+                    body: formData,
+                    headers: {
+                        'X-Requested-With': 'XMLHttpRequest'
+                    }
+                });
+                
+                const result = await response.json();
+                
+                if (result.success) {
+                    // Success!
+                    // 1. Show a toast or small alert
+                    const toast = document.createElement('div');
+                    toast.className = 'position-fixed bottom-0 start-50 translate-middle-x mb-4 p-3 bg-success text-white rounded shadow';
+                    toast.style.zIndex = '2000';
+                    toast.innerHTML = `<i class="bi bi-check-circle me-2"></i> ${result.message}`;
+                    document.body.appendChild(toast);
+                    setTimeout(() => toast.remove(), 3000);
+                    
+                    // 2. Mark the AI list item as "Saved" if applicable
+                    if (window.lastFilledIndex !== undefined) {
+                        const btn = document.getElementById(`btn-fill-${window.lastFilledIndex}`);
+                        if (btn) {
+                            btn.className = 'btn btn-sm btn-success text-white ms-2 disabled';
+                            btn.innerHTML = '<i class="bi bi-check-lg"></i> 已录入';
+                            btn.onclick = null;
+                        }
+                        
+                        // Update local data state so it persists if we switch images/filters
+                        if (currentParsedPeople[window.lastFilledIndex]) {
+                            currentParsedPeople[window.lastFilledIndex].is_imported = true;
+                            currentParsedPeople[window.lastFilledIndex].imported_member_id = result.member_id;
+                            
+                            // Sync back to images array to persist across image switching
+                            if (images[currentIndex]) {
+                                images[currentIndex].ai_content = currentParsedPeople;
+                            }
+                        }
+                    }
+                    
+                    // 3. Clear form (reset to defaults) or keep some fields?
+                    // Usually for genealogy, Surname/Generation might be same, but let's clear for safety
+                    // Resetting form but keeping "related_mid" might be useful for siblings? 
+                    // For now, simple reset.
+                    
+                    // --- Update Local Matches before resetting form ---
+                    // If we just saved a person, check if this person is the father/spouse of anyone else in the list
+                    // and update their matches so 'fillForm' will work for them.
+                    
+                    const savedName = formData.get('name'); // Traditional (Raw)
+                    const savedSimplifiedName = formData.get('simplified_name'); // Simplified (Cleaned)
+                    const savedId = result.member_id;
+                    const savedSex = formData.get('sex'); // 1: Male, 2: Female
+                    
+                    if (savedId) {
+                        currentParsedPeople.forEach(p => {
+                            if (!p.matches) p.matches = {};
+                            
+                            // Check Father Match
+                            // Try matching against Simplified Name (p.father_name is Simplified Cleaned)
+                            // Or fallback to savedName if p.father_name happened to be Traditional (rare but possible)
+                            if (p.father_name && (p.father_name === savedSimplifiedName || p.father_name === savedName)) {
+                                // Assume simple match logic here (usually father is male)
+                                if (savedSex === '1') {
+                                    if (!p.matches.father) p.matches.father = [];
+                                    // Add to matches if not exists
+                                    if (!p.matches.father.find(m => m.id === savedId)) {
+                                        p.matches.father.push({ id: savedId, name: savedName, sex: 1 }); // Mock DB object
+                                    }
+                                }
+                            }
+                            
+                            // Check Spouse Match
+                            if (p.spouse_name && (p.spouse_name === savedSimplifiedName || p.spouse_name === savedName)) {
+                                // Spouse logic...
+                                if (!p.matches.spouse) p.matches.spouse = [];
+                                if (!p.matches.spouse.find(m => m.id === savedId)) {
+                                    p.matches.spouse.push({ id: savedId, name: savedName, sex: parseInt(savedSex) });
+                                }
+                            }
+                        });
+                        
+                        // Also, we need to add this new member to the <select> options for future manual selection!
+                        // This is tricky because the select is rendered by Jinja2.
+                        // We can append an option via JS.
+                        const relatedSelect = document.querySelector('select[name="related_mid"]');
+                        if (relatedSelect) {
+                            const newOption = document.createElement('option');
+                            newOption.value = savedId;
+                            newOption.textContent = `${savedName} (ID: ${savedId})`;
+                            // Add birthday data if available for validation
+                            newOption.dataset.birthday = new Date(formData.get('birthday')).getTime() / 1000;
+                            relatedSelect.add(newOption); // Add to end
+                        }
+                    }
+                    // --- End Local Match Update ---
+
+                    form.reset();
+                    // Clear hidden/custom fields if any manually
+                    form.querySelector('[name="name_word_generation"]').value = ''; 
+                    form.querySelector('[name="personal_achievements"]').value = '';
+                    form.querySelector('[name="notes"]').value = '';
+                    form.querySelector('[name="tags"]').value = '';
+                    form.querySelector('[name="family_rank"]').value = '';
+                    
+                    // Close detail panel
+                    document.getElementById('aiCurrentDetail').style.display = 'none';
+
+                    // 4. Auto-Next Logic
+                    // Find the next available person in the list to fill
+                    if (window.lastFilledIndex !== undefined) {
+                        const nextIndex = window.lastFilledIndex + 1;
+                        if (currentParsedPeople[nextIndex]) {
+                            // Automatically fill the next one!
+                            fillForm(nextIndex);
+                            
+                            // Scroll list to show the new active item if needed
+                            const btn = document.getElementById(`btn-fill-${nextIndex}`);
+                            if(btn) btn.scrollIntoView({ behavior: 'smooth', block: 'center' });
+                        }
+                    }
+
+                } else {
+                    alert('保存失败: ' + result.message);
+                }
+            } catch (error) {
+                console.error('Error submitting form:', error);
+                alert('网络或服务器错误,请稍后重试');
+            } finally {
+                submitBtn.disabled = false;
+                submitBtn.innerHTML = originalBtnHtml;
+            }
+        });
+    });
+
+    // --- End AJAX Form Submission ---
+
+    function updateDisplay() {
+        if (images.length > 0) {
+            const img = images[currentIndex];
+            document.getElementById('refImage').src = img.url;
+            document.getElementById('currentPage').innerText = currentIndex + 1;
+            
+            // Reset image state on switch
+            resetFilters();
+            
+            // AI Button Logic
+            const aiBtn = document.getElementById('aiBtn');
+            const aiPanel = document.getElementById('aiLogPanel');
+            const resultList = document.getElementById('aiResultList');
+            const resultCount = document.getElementById('resultCount');
+            
+            // Hide panel when switching images to avoid confusion
+            if (aiPanel) aiPanel.style.display = 'none';
+            // Clear current data
+            currentParsedPeople = []; 
+            if (resultCount) resultCount.innerText = '0';
+            if (resultList) resultList.innerHTML = ''; 
+
+            if (img.ai_status === 2 && img.ai_content) {
+                // Determine content
+                let content = img.ai_content;
+                // Parse if string (it might be a string if double encoded or stored as JSON string in DB)
+                if (typeof content === 'string') {
+                    try { content = JSON.parse(content); } catch(e) { content = []; }
+                }
+                if (!Array.isArray(content) && content) content = [content];
+                
+                if (content && content.length > 0) {
+                    // Update Button to "View Results"
+                    aiBtn.innerHTML = '<i class="bi bi-list-check"></i> 查看解析结果';
+                    aiBtn.className = 'btn btn-sm btn-success text-white ms-2 me-2';
+                    aiBtn.onclick = function() {
+                        // Show panel with loading
+                        if (aiPanel) aiPanel.style.display = 'block';
+                        if (resultList) resultList.innerHTML = '<div class="text-center p-3"><div class="spinner-border text-primary" role="status"></div></div>';
+                        
+                        // Process (small delay to allow UI update)
+                        setTimeout(() => processAiData(content), 10);
+                    };
+                    return; // Done
+                }
+            } 
+            
+            // Default: Reset to "AI Recognition"
+            aiBtn.innerHTML = '<i class="bi bi-magic"></i> AI 识别';
+            aiBtn.className = 'btn btn-sm btn-info text-white ms-2 me-2';
+            aiBtn.onclick = recognizeImage;
+        }
+    }
+
+    function nextImage() {
+        if (currentIndex < images.length - 1) {
+            currentIndex++;
+            updateDisplay();
+        }
+    }
+
+    function prevImage() {
+        if (currentIndex > 0) {
+            currentIndex--;
+            updateDisplay();
+        }
+    }
+
+    function gotoPage() {
+        const val = document.getElementById('pageInput').value;
+        if (!val) return;
+        const page = parseInt(val);
+        const index = images.findIndex(img => img.page === page);
+        if (index !== -1) {
+            currentIndex = index;
+            updateDisplay();
+        } else {
+            alert('未找到该页码对应的图片');
+        }
+    }
+
+    function closeAiLog() {
+        document.getElementById('aiLogPanel').style.display = 'none';
+    }
+
+    function toggleAiPanel() {
+        const panel = document.getElementById('aiLogPanel');
+        if (panel.style.display === 'none') {
+            panel.style.display = 'block';
+        } else {
+            panel.style.display = 'none';
+        }
+    }
+
+    function updateAiButtonState(hasResults) {
+        const btn = document.getElementById('aiBtn');
+        if (!btn) return;
+
+        if (hasResults) {
+            btn.innerHTML = '<i class="bi bi-list-check"></i> 查看识别结果';
+            btn.onclick = toggleAiPanel;
+            btn.classList.remove('btn-info');
+            btn.classList.add('btn-success');
+        } else {
+            // Revert state if needed (usually on new image load if we clear data)
+            btn.innerHTML = '<i class="bi bi-magic"></i> AI 识别';
+            btn.onclick = recognizeImage;
+            btn.classList.remove('btn-success');
+            btn.classList.add('btn-info');
+        }
+    }
+
+    function fillForm(index) {
+        window.lastFilledIndex = index;
+        const person = currentParsedPeople[index];
+        if (!person) return;
+        
+        const form = document.querySelector('form');
+        form.reset(); // Clear previous data first
+        
+        // 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.simplified_name) {
+            const snInput = form.querySelector('[name="simplified_name"]');
+            if (snInput) snInput.value = person.simplified_name;
+        } else {
+             // Fallback: if no simplified_name explicitly, generate it
+             if (person.name) {
+                 const snInput = form.querySelector('[name="simplified_name"]');
+                 if (snInput) snInput.value = cleanName(person.name);
+             }
+        }
+        
+        // 2. 性别
+        if (person.sex) {
+            const sexSelect = form.querySelector('[name="sex"]');
+            if (person.sex.includes('女')) sexSelect.value = '2';
+            else if (person.sex.includes('男')) sexSelect.value = '1';
+        }
+        
+        // 3. 生日 & 自动推断过世
+        // Reset unknown toggle first
+        const birthdayUnknownCb = document.getElementById('birthdayUnknown');
+        if (birthdayUnknownCb) {
+             birthdayUnknownCb.checked = false;
+             toggleBirthdayUnknown();
+        }
+
+        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}`;
+                
+                // Auto "Is Deceased" Logic (e.g. older than 100 years from now)
+                const birthYear = parseInt(y);
+                const currentYear = new Date().getFullYear();
+                if (currentYear - birthYear > 100) {
+                    const passAwaySelect = form.querySelector('[name="is_pass_away"]');
+                    if (passAwaySelect) passAwaySelect.value = '1';
+                }
+            }
+            // 只有当日期格式正确时才填充,否则不填或者留给用户
+            if (/^\d{4}-\d{2}-\d{2}$/.test(dateVal)) {
+                form.querySelector('[name="birthday"]').value = dateVal;
+            }
+        }
+
+        // 4. 代数 -> 堂内排行
+        if (person.generation) {
+            const genMatch = person.generation.match(/\d+/);
+            // 这里将 AI 解析的 'generation' 填入 'family_rank' (堂内排行)
+            // 'name_word_generation' (世系世代) 保持为空
+            form.querySelector('[name="family_rank"]').value = person.generation; 
+        }
+
+        // 4.5 字辈 (name_word)
+        let zibei = person.name_word;
+        if (!zibei && person.name) {
+             // Heuristic: If name starts with "留" and is 3 chars long (e.g. 留学勤), Zibei is index 1.
+             // If name starts with "留" and is > 3 chars, we can't be sure, but index 1 is a good guess for generation char.
+             // "留学公" -> "留" + "学" + "公". Zibei "学".
+             // "留学勤" -> "留" + "学" + "勤". Zibei "学".
+             
+             // Let's use a safe heuristic: if name starts with '留' and length >= 3
+             if (person.name.startsWith('留') && person.name.length >= 3) {
+                 zibei = person.name.charAt(1);
+             }
+        }
+        if (zibei) {
+            form.querySelector('[name="name_word"]').value = zibei;
+            person.name_word = zibei; // Update data object for display
+        }
+
+        // 5. 其他信息
+        if (person.education) form.querySelector('[name="educational"]').value = person.education;
+        if (person.title) form.querySelector('[name="occupation"]').value = person.title;
+        
+        // 个人成就/备注字段追加信息
+        let extraInfo = [];
+        if (person.father_name) extraInfo.push(`父亲: ${person.father_name}`);
+        if (person.spouse_name) extraInfo.push(`配偶: ${person.spouse_name}`);
+        
+        // 将亲属关系存入 'notes' (人员备注) 字段
+        const notesField = form.querySelector('[name="notes"]');
+        const currentNotes = notesField.value;
+        const newInfo = extraInfo.join('; ');
+        
+        if (newInfo && !currentNotes.includes(newInfo)) {
+             notesField.value = currentNotes ? (currentNotes + '\n' + newInfo) : newInfo;
+        }
+
+        // --- Auto-Linking Logic ---
+        if (person.matches) {
+            // Priority: Father > Spouse (Configurable?)
+            // For now, if father matches, select father.
+            if (person.matches.father && person.matches.father.length > 0) {
+                // Pick the first one for now (could show UI to choose if multiple)
+                const father = person.matches.father[0];
+                const relSelect = form.querySelector('[name="related_mid"]');
+                const relTypeSelect = form.querySelector('[name="relation_type"]');
+                
+                if (relSelect && relTypeSelect) {
+                    relSelect.value = father.id;
+                    relTypeSelect.value = '1'; // 父子
+                    // Trigger change event if needed by other logic (not needed here yet)
+                }
+            } else if (person.matches.spouse && person.matches.spouse.length > 0) {
+                const spouse = person.matches.spouse[0];
+                const relSelect = form.querySelector('[name="related_mid"]');
+                const relTypeSelect = form.querySelector('[name="relation_type"]');
+                
+                if (relSelect && relTypeSelect) {
+                    relSelect.value = spouse.id;
+                    relTypeSelect.value = '10'; // 夫妻
+                }
+            }
+        }
+        
+        // --- Show Details Panel ---
+        const detailContainer = document.getElementById('aiCurrentDetail');
+        const detailContent = document.getElementById('aiDetailContent');
+        
+        let html = '<ul class="list-unstyled mb-0 font-monospace" style="font-size: 0.85rem;">';
+        
+        const getLabel = (k) => fieldMapping[k] || (k === 'children' ? '子女' : k);
+
+        // 遍历属性显示
+        for (const key in person) {
+             // 隐藏内部属性
+             if (key.startsWith('_')) continue;
+             
+             let val = person[key];
+             const label = getLabel(key);
+
+             // 特殊处理 children
+             if (key === 'children') {
+                 if (Array.isArray(val) && val.length > 0) {
+                     let childrenHtml = '<div class="d-flex flex-wrap gap-1 mt-1">';
+                     val.forEach(child => {
+                         // 使用 child._originalIndex 进行跳转填充
+                         childrenHtml += `<button class="btn btn-sm btn-outline-info py-0 px-2" style="font-size: 0.75rem;" onclick="fillForm(${child._originalIndex})">${child.name || '未知'}</button>`;
+                     });
+                     childrenHtml += '</div>';
+                     html += `<li class="mb-1"><span class="text-info opacity-75">${label}:</span> ${childrenHtml}</li>`;
+                 }
+                 continue;
+             }
+             
+             // 默认显示
+             if (!val || val === '') val = '-';
+             html += `<li class="mb-1"><span class="text-info opacity-75">${label}:</span> <span class="text-white ms-1">${val}</span></li>`;
+        }
+        html += '</ul>';
+        
+        detailContent.innerHTML = html;
+        detailContainer.style.display = 'block';
+        
+        // Visual feedback
+        const btn = document.getElementById(`btn-fill-${index}`);
+        if(btn) {
+            const originalHtml = btn.innerHTML;
+            btn.innerHTML = '<i class="bi bi-check"></i> 已填';
+            btn.classList.remove('btn-outline-info');
+            btn.classList.add('btn-info', 'text-white');
+            setTimeout(() => {
+                btn.innerHTML = originalHtml;
+                btn.classList.add('btn-outline-info');
+                btn.classList.remove('btn-info', 'text-white');
+            }, 1000);
+        }
+    }
+
+    // --- Pre-fill Logic from Backend (Async AI Result) ---
+    const prefilledContent = {{ prefilled_content | tojson | safe if prefilled_content else 'null' }};
+    const sourceOssUrl = "{{ source_oss_url if source_oss_url else '' }}";
+    const sourceRecordId = "{{ source_record_id if source_record_id else '' }}";
+
+    if (prefilledContent && sourceOssUrl) {
+        // We have prefilled content from DB, simulate "Recognize Image" success
+        document.addEventListener('DOMContentLoaded', async () => {
+            // Wait a bit for UI to settle
+            setTimeout(async () => {
+                // Find image index
+                const imgIndex = images.findIndex(img => img.url === sourceOssUrl);
+                if (imgIndex !== -1) {
+                    currentIndex = imgIndex;
+                    updateDisplay();
+                }
+                
+                // Parse and display results
+                try {
+                    let data = prefilledContent;
+                    if (typeof data === 'string') {
+                        try {
+                            data = JSON.parse(data);
+                        } catch(e) {
+                            console.error("Prefilled content parse error", e);
+                            return;
+                        }
+                    }
+                    
+                    if (!Array.isArray(data)) data = [data];
+                    
+                    await processAiData(data);
+                    
+                    // Open the log panel to show results
+                    const aiPanel = document.getElementById('aiLogPanel');
+                    if (aiPanel) aiPanel.style.display = 'block';
+                    
+                    const status = document.getElementById('reasoningStatus');
+                    if(status) {
+                        status.textContent = '已加载历史解析';
+                        status.className = 'badge bg-success ms-2';
+                    }
+                    
+                    const logContent = document.getElementById('aiLogContent');
+                    if(logContent) logContent.textContent = "已加载历史 AI 解析记录。";
+                    
+                } catch (e) {
+                    console.error("Error processing prefilled content", e);
+                }
+            }, 500);
+        });
+    } else {
+        // No prefilled content, initialize display for the first image
+        document.addEventListener('DOMContentLoaded', () => {
+            updateDisplay();
+        });
+    }
+    
+    // --- Name Cleaning Logic (Matching Backend) ---
+    // 仅做繁 -> 简转换,不动姓氏/“公”处理,用于配偶等非留氏族人
+    function manualSimplify(text) {
+        if (!text) return text;
+        text = text.trim();
+        const mapping = {
+            '學': '学', '國': '国', '萬': '万', '寶': '宝', '興': '兴', 
+            '華': '华', '會': '会', '葉': '叶', '藝': '艺', '號': '号',
+            '處': '处', '見': '见', '視': '视', '言': '言', '語': '语',
+            '貝': '贝', '車': '车', '長': '长', '門': '门', '韋': '韦',
+            '頁': '页', '風': '风', '飛': '飞', '食': '食', '馬': '马',
+            '魚': '鱼', '鳥': '鸟', '麥': '麦', '黃': '黄', '齊': '齐',
+            '齒': '齿', '龍': '龙', '龜': '龟', '壽': '寿', '榮': '荣',
+            '愛': '爱', '慶': '庆', '衛': '卫', '賢': '贤', '義': '义',
+            '禮': '礼', '樂': '乐', '靈': '灵', '滅': '灭', '氣': '气',
+            '智': '智', '信': '信', '仁': '仁', '勇': '勇', '嚴': '严',
+            '劉': '刘'
+        };
+        let result = '';
+        for (const ch of text) {
+            result += mapping[ch] || ch;
+        }
+        return result;
+    }
+
+    // 留氏本人姓名清洗:在 manualSimplify 基础上,处理“留”姓和“公”
+    function cleanName(name) {
+        if (!name) return name;
+        name = manualSimplify(name);
+
+        const exceptions = ['学公', '留学公'];
+        if (exceptions.includes(name)) {
+            if (!name.startsWith('留')) {
+                name = '留' + name;
+            }
+            return name;
+        }
+        
+        // Remove '公' suffix
+        if (name.endsWith('公')) {
+            name = name.slice(0, -1);
+        }
+        
+        // Ensure '留' prefix
+        if (!name.startsWith('留')) {
+            name = '留' + name;
+        }
+        
+        return name;
+    }
+
+    // Extracted function to process AI data and render tree
+    async function processAiData(data) {
+        // 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
+            
+            // Clean the Simplified Name(本人:带“留”姓规则)
+            p.simplified_name = cleanName(simName);
+            
+            // Set the name to be the Raw Name for storage in 'name' column
+            p.name = rawName;
+            
+            // 父亲:同族,用 cleanName(加“留”、去“公”)
+            if (p.father_name) p.father_name = cleanName(p.father_name);
+
+            // 配偶:只做繁体 -> 简体,不拼接“留”姓
+            if (p.spouse_name) p.spouse_name = manualSimplify(p.spouse_name);
+        });
+
+         // Call Relation Check API
+        try {
+            // Send simplified_name for checking relations if available, or name?
+            // The API checks against DB 'name' column.
+            // Wait, DB 'name' column is now Traditional Raw.
+            // But existing data in DB is Simplified Cleaned.
+            // New data will be Traditional Raw in 'name', Simplified Cleaned in 'simplified_name'.
+            // The check_relations API uses `WHERE name IN (...)`.
+            // The AI returns `father_name` as Simplified (usually).
+            // So we are checking Simplified Father Name against...
+            // If DB 'name' is mixed (Old Simplified, New Traditional), this is messy.
+            // But `check_relations` logic:
+            // `names_to_check.add(p['father_name'])` -> Simplified.
+            // `SELECT ... WHERE name IN ...`
+            // If DB 'name' contains Traditional, we won't find match if we search Simplified.
+            // Unless we search `simplified_name` column too?
+            // I should update `check_relations` in app.py to search both `name` and `simplified_name`.
+            
+            const checkRes = await fetch('/manager/api/check_relations', {
+                method: 'POST',
+                headers: { 'Content-Type': 'application/json' },
+                body: JSON.stringify({ people: data })
+            });
+            const checkResult = await checkRes.json();
+            if (checkResult.success && checkResult.matches) {
+                // Merge matches into data
+                for (const idx in checkResult.matches) {
+                    const match = checkResult.matches[idx];
+                    if (data[idx]) {
+                        data[idx].matches = match;
+                    }
+                }
+            }
+        } catch (e) {
+            console.warn("Auto-linking failed:", e);
+        }
+
+        currentParsedPeople = data;
+        document.getElementById('resultCount').innerText = data.length;
+        
+        // Update Button State to "View Results"
+        updateAiButtonState(true);
+        
+        // Build Relationship Tree
+        const personMap = {};
+        const roots = [];
+        
+        // 1. Initialize map
+        data.forEach((p, index) => {
+            p._originalIndex = index; // Store original index for fillForm
+            p.children = [];
+            // Use simplified_name as key if available, otherwise name (for consistent lookup)
+            const lookupKey = p.simplified_name || p.name;
+            personMap[lookupKey] = p;
+        });
+        
+        // 2. Build Hierarchy
+        data.forEach(p => {
+            let parentFound = false;
+            if (p.father_name) {
+                // Try exact match using simplified name (since father_name is usually simplified)
+                let father = personMap[p.father_name];
+                
+                // Try loose match
+                if (!father) {
+                    for (const name in personMap) {
+                        if (name.includes(p.father_name) || p.father_name.includes(name)) {
+                            father = personMap[name];
+                            break;
+                        }
+                    }
+                }
+                
+                if (father && father !== p) { 
+                    father.children.push(p);
+                    parentFound = true;
+                }
+            }
+            
+            if (!parentFound) {
+                roots.push(p);
+            }
+        });
+        
+        // 3. Recursive Render Function
+        function renderNode(p, level = 0) {
+            const indent = level * 20;
+            let html = `
+                <div class="card bg-dark border-secondary mb-1" style="margin-left: ${indent}px; background-color: #2c3034;">
+                    <div class="card-body p-2 d-flex justify-content-between align-items-center">
+                        <div class="text-white">
+                            <div class="fw-bold">
+                                ${level > 0 ? '<i class="bi bi-arrow-return-right text-secondary me-1"></i>' : ''}
+                                ${p.name || '未知姓名'} 
+                                <span class="badge bg-secondary text-light ms-1" style="font-size: 0.7rem">${p.sex || '-'}</span>
+                            </div>
+                            <div class="small text-white-50" style="font-size: 0.75rem; padding-left: ${level > 0 ? 18 : 0}px;">
+                                ${p.generation ? '第'+p.generation+'世 ' : ''}
+                                ${p.father_name ? '父:'+p.father_name : ''}
+                            </div>
+                        </div>
+                        <button id="btn-fill-${p._originalIndex}" 
+                                class="btn btn-sm ${p.is_imported ? 'btn-success disabled' : 'btn-outline-info'} text-nowrap ms-2" 
+                                onclick="${p.is_imported ? '' : `fillForm(${p._originalIndex})`}">
+                            ${p.is_imported ? '<i class="bi bi-check-lg"></i> 已录入' : '<i class="bi bi-pencil-square"></i> 填充'}
+                        </button>
+                    </div>
+                </div>
+            `;
+            
+            if (p.children && p.children.length > 0) {
+                p.children.forEach(child => {
+                    html += renderNode(child, level + 1);
+                });
+            }
+            
+            return html;
+        }
+        
+        // Render List
+        const resultList = document.getElementById('aiResultList');
+        const resultSection = document.getElementById('aiResultSection');
+        
+        resultList.innerHTML = '';
+        
+        // Fix: Use data directly if root finding logic fails or returns empty but data exists
+        if (roots.length === 0 && data.length > 0) {
+            // Just dump everything flat if tree building fails
+            data.forEach(p => resultList.innerHTML += renderNode(p, 0));
+        } else {
+            roots.forEach(p => {
+                resultList.innerHTML += renderNode(p, 0);
+            });
+        }
+        
+        resultSection.style.display = 'block';
+    }
+
+    async function recognizeImage() {
+        if (images.length === 0) {
+            alert('没有可用的图片');
+            return;
+        }
+
+        const currentImg = images[currentIndex];
+        const btn = document.getElementById('aiBtn');
+        const originalContent = btn.innerHTML;
+        const logPanel = document.getElementById('aiLogPanel');
+        const logContent = document.getElementById('aiLogContent');
+        const resultSection = document.getElementById('aiResultSection');
+        const resultList = document.getElementById('aiResultList');
+        const reasoningStatus = document.getElementById('reasoningStatus');
+
+        btn.disabled = true;
+        btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 识别中...';
+        
+        // Reset UI
+        logContent.textContent = '';
+        resultList.innerHTML = '';
+        resultSection.style.display = 'none';
+        logPanel.style.display = 'block';
+        reasoningStatus.textContent = '连接中...';
+        reasoningStatus.className = 'badge bg-secondary ms-2';
+        
+        // Ensure reasoning panel is open
+        const collapseReasoning = document.getElementById('collapseReasoning');
+        if (collapseReasoning && !collapseReasoning.classList.contains('show')) {
+            new bootstrap.Collapse(collapseReasoning, { show: true });
+        }
+
+        // Retry logic function
+        async function fetchAndParse(url, retryCount = 0) {
+            const MAX_RETRIES = 2;
+            let fullText = '';
+            let jsonPart = '';
+            let hasJsonStarted = false;
+
+            try {
+                if (retryCount > 0) {
+                    logContent.textContent = `\n[System] 解析失败,正在进行第 ${retryCount} 次重试...\n` + logContent.textContent;
+                    reasoningStatus.textContent = `重试 ${retryCount}...`;
+                }
+
+                const response = await fetch('/manager/api/recognize_image', {
+                    method: 'POST',
+                    headers: { 'Content-Type': 'application/json' },
+                    body: JSON.stringify({ image_url: url })
+                });
+
+                if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
+
+                const reader = response.body.getReader();
+                const decoder = new TextDecoder();
+                const separator = "|||JSON_START|||";
+
+                while (true) {
+                    const { value, done } = await reader.read();
+                    if (done) break;
+                    
+                    const chunk = decoder.decode(value, { stream: true });
+                    fullText += chunk;
+                    
+                    // Only update display if not parsing JSON part yet or just started
+                    if (!hasJsonStarted) {
+                        const sepIndex = fullText.indexOf(separator);
+                        if (sepIndex !== -1) {
+                            hasJsonStarted = true;
+                            reasoningStatus.textContent = '解析中...';
+                            reasoningStatus.className = 'badge bg-info ms-2';
+                            
+                            // Split content for display - only once
+                            const reasoningPart = fullText.substring(0, sepIndex);
+                            logContent.textContent = reasoningPart;
+                            
+                            if (collapseReasoning) {
+                                new bootstrap.Collapse(collapseReasoning, { hide: true });
+                            }
+                        } else {
+                            // Update reasoning text
+                            logContent.textContent = fullText;
+                            logContent.scrollTop = logContent.scrollHeight;
+                        }
+                    }
+                }
+
+                // Parsing Logic
+                if (hasJsonStarted) {
+                     const sepIndex = fullText.indexOf(separator);
+                     jsonPart = fullText.substring(sepIndex + separator.length);
+                     reasoningStatus.textContent = '完成';
+                     reasoningStatus.className = 'badge bg-success ms-2';
+                } else {
+                     // Fallback
+                     jsonPart = fullText; 
+                }
+                
+                // Clean JSON
+                // 1. Try finding [...] array
+                let start = jsonPart.indexOf('[');
+                let end = jsonPart.lastIndexOf(']');
+                
+                // 2. If not found, try finding {...} object and wrap it
+                let isSingleObject = false;
+                if (start === -1 || end === -1 || end <= start) {
+                    start = jsonPart.indexOf('{');
+                    end = jsonPart.lastIndexOf('}');
+                    isSingleObject = true;
+                }
+
+                if (start !== -1 && end !== -1 && end > start) {
+                    jsonPart = jsonPart.substring(start, end + 1);
+                } else {
+                    // Try to extract any JSON-like array/object structure using regex as fallback
+                    const jsonMatch = jsonPart.match(/(\[.*\]|\{.*\})/s);
+                    if (jsonMatch) {
+                        jsonPart = jsonMatch[0];
+                        if (jsonPart.trim().startsWith('{')) isSingleObject = true;
+                    } else {
+                        // No valid JSON structure found
+                        console.warn("No JSON brackets found in:", jsonPart);
+                        throw new Error("未找到有效的 JSON 数据结构");
+                    }
+                }
+                
+                let data;
+                try {
+                    // Pre-clean: Remove common markdown code block markers if stuck inside
+                    jsonPart = jsonPart.replace(/^```json\s*/, '').replace(/```$/, '');
+                    
+                    data = JSON.parse(jsonPart);
+                } catch (e) {
+                    // Attempt to fix common JSON errors (e.g. trailing commas, unclosed strings) - simplified
+                    console.error("JSON parse error. Content:", jsonPart);
+                    // Force retry on parse error
+                    throw new Error("JSON 格式解析错误");
+                }
+
+                if (isSingleObject && !Array.isArray(data)) {
+                    data = [data]; // Normalize to array
+                } else if (!Array.isArray(data)) {
+                    data = [data];
+                }
+                
+                return data;
+
+            } catch (error) {
+                if (retryCount < MAX_RETRIES) {
+                    // Wait 1s and retry
+                    await new Promise(r => setTimeout(r, 1000));
+                    return fetchAndParse(url, retryCount + 1);
+                }
+                throw error;
+            }
+        }
+
+        try {
+            const data = await fetchAndParse(currentImg.url);
+            
+            // Use shared processing function
+            await processAiData(data);
+
+            // Update local state for persistence during session
+            if (images[currentIndex]) {
+                images[currentIndex].ai_status = 2;
+                images[currentIndex].ai_content = data; 
+            }
+
+        } catch (error) {
+            console.error(error);
+            // Append error to log instead of overwriting valid reasoning
+            logContent.textContent += `\n\n[Error] ${error.message}`;
+            alert('AI 识别过程失败,请重试。\n错误详情: ' + error.message);
+        } finally {
+            btn.innerHTML = originalContent;
+            btn.disabled = false;
+        }
+    }
+</script>
+{% endblock %}

+ 79 - 0
templates/base.html

@@ -0,0 +1,79 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>{% block title %}家谱管理系统{% endblock %}</title>
+    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
+    <style>
+        body { background-color: #f8f9fa; }
+        .navbar { margin-bottom: 2rem; }
+        .tree ul { padding-top: 20px; position: relative; transition: all 0.5s; }
+        .tree li { float: left; text-align: center; list-style-type: none; position: relative; padding: 20px 5px 0 5px; transition: all 0.5s; }
+        .tree li::before, .tree li::after { content: ''; position: absolute; top: 0; right: 50%; border-top: 1px solid #ccc; width: 50%; height: 20px; }
+        .tree li::after { right: auto; left: 50%; border-left: 1px solid #ccc; }
+        .tree li:only-child::after, .tree li:only-child::before { display: none; }
+        .tree li:only-child { padding-top: 0; }
+        .tree li:first-child::before, .tree li:last-child::after { border: 0 none; }
+        .tree li:last-child::before { border-right: 1px solid #ccc; border-radius: 0 5px 0 0; }
+        .tree li:first-child::after { border-radius: 5px 0 0 0; }
+        .tree ul ul::before { content: ''; position: absolute; top: 0; left: 50%; border-left: 1px solid #ccc; width: 0; height: 20px; }
+        .tree li div { border: 1px solid #ccc; padding: 5px 10px; text-decoration: none; color: #666; font-family: arial, verdana, tahoma; font-size: 11px; display: inline-block; border-radius: 5px; transition: all 0.5s; background: #fff; }
+        .tree li div:hover { background: #c8e4f8; color: #000; border: 1px solid #94a0b4; }
+        .split-container { display: flex; height: calc(100vh - 150px); }
+        .form-panel { flex: 1; padding: 20px; overflow-y: auto; border-right: 1px solid #dee2e6; background: #fff; }
+        .image-panel { flex: 1; padding: 20px; background: #e9ecef; overflow-y: auto; position: relative; }
+        .image-viewer { text-align: center; }
+        .image-viewer img { max-width: 100%; height: auto; border: 1px solid #ccc; }
+        .page-nav { margin-bottom: 10px; display: flex; gap: 10px; }
+    </style>
+</head>
+<body>
+    <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
+        <div class="container">
+            <a class="navbar-brand" href="{{ url_for('index') }}">家谱管理系统</a>
+            <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
+                <span class="navbar-toggler-icon"></span>
+            </button>
+            <div class="collapse navbar-collapse" id="navbarNav">
+                <ul class="navbar-nav me-auto">
+                    <li class="nav-item">
+                        <a class="nav-link" href="{{ url_for('index') }}">首页</a>
+                    </li>
+                    <li class="nav-item">
+                        <a class="nav-link" href="{{ url_for('members') }}">成员管理</a>
+                    </li>
+                    <li class="nav-item">
+                        <a class="nav-link" href="{{ url_for('tree') }}">关系树</a>
+                    </li>
+                </ul>
+                <div class="navbar-nav">
+                    {% if session.get('user_id') %}
+                        <span class="nav-item nav-link text-white">您好, {{ session['username'] }}</span>
+                        <a class="nav-item nav-link" href="{{ url_for('logout') }}">退出</a>
+                    {% else %}
+                        <a class="nav-item nav-link" href="{{ url_for('login') }}">登录</a>
+                    {% endif %}
+                </div>
+            </div>
+        </div>
+    </nav>
+
+    <div class="container-fluid">
+        {% with messages = get_flashed_messages() %}
+          {% if messages %}
+            {% for message in messages %}
+              <div class="alert alert-info alert-dismissible fade show" role="alert">
+                {{ message }}
+                <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
+              </div>
+            {% endfor %}
+          {% endif %}
+        {% endwith %}
+
+        {% block content %}{% endblock %}
+    </div>
+
+    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
+</body>
+</html>

+ 59 - 0
templates/confirm_page.html

@@ -0,0 +1,59 @@
+{% extends "layout.html" %}
+
+{% block title %}确认页码 - 家谱管理系统{% endblock %}
+
+{% block content %}
+<div class="row justify-content-center">
+    <div class="col-md-8">
+        <div class="card shadow">
+            <div class="card-header bg-warning">
+                <h5 class="mb-0 text-dark"><i class="bi bi-exclamation-triangle me-2"></i>未识别到页码</h5>
+            </div>
+            <div class="card-body p-4">
+                <div class="row">
+                    <div class="col-md-6 border-end">
+                        <h6>预览扫描件</h6>
+                        <div class="text-center bg-light p-2 rounded" style="height: 300px; overflow: hidden;">
+                            <img src="{{ oss_url }}" class="img-fluid" style="max-height: 100%;" alt="预览">
+                        </div>
+                    </div>
+                    <div class="col-md-6 ps-4">
+                        <h6>请手动输入页码</h6>
+                        <p class="text-muted small">系统未能自动从图片中解析出页码,请对照预览图手动录入。</p>
+                        
+                        <form action="{{ url_for('save_upload') }}" method="POST">
+                            <input type="hidden" name="filename" value="{{ filename }}">
+                            <input type="hidden" name="oss_url" value="{{ oss_url }}">
+                            
+                            <div class="mb-3">
+                                <label class="form-label fw-bold">页码</label>
+                                <input type="number" name="page_number" id="manualPageInput" class="form-control form-control-lg" required>
+                            </div>
+
+                            <div class="alert alert-info py-2 small">
+                                <i class="bi bi-lightbulb me-1"></i> 快捷提示:
+                                <button type="button" class="btn btn-link btn-sm p-0 mb-1" onclick="setPage({{ suggested_page }})">
+                                    是第 <strong>{{ suggested_page }}</strong> 页吗?
+                                </button>
+                                <br>
+                                <span class="text-muted">(根据数据库中上一页自动推算)</span>
+                            </div>
+
+                            <div class="d-grid gap-2">
+                                <button type="submit" class="btn btn-primary">确认并保存</button>
+                                <a href="{{ url_for('index') }}" class="btn btn-light">稍后录入</a>
+                            </div>
+                        </form>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+
+<script>
+function setPage(val) {
+    document.getElementById('manualPageInput').value = val;
+}
+</script>
+{% endblock %}

+ 418 - 0
templates/index.html

@@ -0,0 +1,418 @@
+{% extends "layout.html" %}
+
+{% block title %}上传管理 - 家谱管理系统{% endblock %}
+
+{% block extra_css %}
+<style>
+    .thumbnail-img {
+        width: 50px;
+        height: 70px;
+        object-fit: cover;
+        border-radius: 4px;
+        cursor: pointer;
+        border: 1px solid #dee2e6;
+        transition: transform 0.2s;
+        background-color: #f8f9fa;
+    }
+    .thumbnail-img:hover {
+        transform: scale(1.5);
+        box-shadow: 0 4px 8px rgba(0,0,0,0.15);
+        z-index: 10;
+        position: relative;
+    }
+
+    /* Modal Image Viewer Styles */
+    .modal-image-container {
+        height: 85vh;
+        display: flex;
+        flex-direction: column;
+    }
+    .image-toolbar {
+        background: #e9ecef;
+        padding: 5px 10px;
+        border-bottom: 1px solid #dee2e6;
+        display: flex;
+        gap: 10px;
+        align-items: center;
+        flex-wrap: wrap;
+    }
+    .image-viewer { 
+        flex: 1; 
+        border: 1px solid #ccc; 
+        background: #f0f0f0; 
+        overflow: hidden; 
+        text-align: center; 
+        position: relative; 
+        cursor: grab;
+        user-select: none;
+    }
+    .image-viewer:active {
+        cursor: grabbing;
+    }
+    .image-wrapper {
+        display: inline-block;
+        transition: transform 0.2s ease-out;
+        transform-origin: center center;
+        position: absolute;
+        top: 50%;
+        left: 50%;
+        transform: translate(-50%, -50%);
+    }
+    .image-wrapper img {
+        max-width: 100%;
+        max-height: 100%;
+        display: block;
+        pointer-events: none;
+        user-select: none;
+        transition: filter 0.2s;
+        box-shadow: 0 0 20px rgba(0,0,0,0.1);
+    }
+    .magnifier-glass {
+        position: absolute;
+        border: 3px solid #000;
+        border-radius: 50%;
+        cursor: none;
+        width: 150px;
+        height: 150px;
+        box-shadow: 0 0 10px rgba(0,0,0,0.5);
+        display: none;
+        z-index: 1000;
+        background-repeat: no-repeat;
+        background-color: white;
+        pointer-events: none;
+    }
+</style>
+{% endblock %}
+
+{% block content %}
+<div class="d-flex justify-content-between align-items-center mb-4">
+    <h2><i class="bi bi-file-earmark-arrow-up"></i> 家谱扫描件管理</h2>
+    <a href="{{ url_for('upload') }}" class="btn btn-primary">
+        <i class="bi bi-cloud-upload me-1"></i> 上传新扫描件
+    </a>
+</div>
+
+<div class="card shadow-sm">
+    <div class="card-body p-0">
+        <div class="table-responsive">
+            <table class="table table-hover mb-0">
+                <thead class="table-light">
+                    <tr>
+                        <th class="px-4">文件名</th>
+                        <th>页码</th>
+                        <th>AI 解析状态</th>
+                        <th>上传时间</th>
+                        <th class="text-center">操作</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    {% for record in records %}
+                    <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="点击预览">
+                                <div class="text-break">{{ record.file_name }}</div>
+                            </div>
+                        </td>
+                        <td>
+                            {% if record.page_number %}
+                                <span class="badge bg-info text-dark">第 {{ record.page_number }} 页</span>
+                            {% else %}
+                                <span class="text-muted">未知</span>
+                            {% endif %}
+                        </td>
+                        <td>
+                            {% if record.ai_status == 2 %}
+                                <span class="badge bg-success"><i class="bi bi-check-circle"></i> 解析成功</span>
+                            {% elif record.ai_status == 1 %}
+                                <span class="badge bg-warning text-dark"><i class="bi bi-hourglass-split"></i> 解析中...</span>
+                            {% elif record.ai_status == 3 %}
+                                <span class="badge bg-danger"><i class="bi bi-x-circle"></i> 解析失败</span>
+                            {% else %}
+                                <span class="badge bg-secondary">未解析</span>
+                            {% endif %}
+                        </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="查看原图">
+                                <i class="bi bi-eye"></i>
+                            </button>
+                            
+                            {% if record.ai_status == 2 %}
+                                <a href="{{ url_for('add_member', record_id=record.id) }}" class="btn btn-sm btn-success" title="查看解析并录入">
+                                    <i class="bi bi-magic"></i> 录入
+                                </a>
+                                <button onclick="reStartAiAnalysis({{ record.id }})" class="btn btn-sm btn-outline-warning" title="重新AI解析">
+                                    <i class="bi bi-arrow-repeat"></i> 重析
+                                </button>
+                            {% else %}
+                                <button onclick="startAiAnalysis({{ record.id }})" class="btn btn-sm btn-outline-primary" title="AI 解析">
+                                    <i class="bi bi-robot"></i> 解析
+                                </button>
+                                <a href="{{ url_for('add_member', record_id=record.id) }}" class="btn btn-sm btn-outline-secondary" title="手动录入">
+                                    <i class="bi bi-pencil"></i> 手动
+                                </a>
+                            {% endif %}
+
+                            <form action="{{ url_for('delete_upload', record_id=record.id) }}" method="POST" class="d-inline" onsubmit="return confirm('确定要删除此文件记录吗?此操作无法撤销。');">
+                                <button type="submit" class="btn btn-sm btn-outline-danger" title="删除">
+                                    <i class="bi bi-trash"></i>
+                                </button>
+                            </form>
+                        </td>
+                    </tr>
+                    {% endfor %}
+                    {% if not records %}
+                    <tr>
+                        <td colspan="4" class="text-center py-5 text-muted">
+                            <i class="bi bi-folder-x fs-1 d-block mb-2"></i>
+                            暂无扫描件数据,请先上传。
+                        </td>
+                    </tr>
+                    {% endif %}
+                </tbody>
+            </table>
+        </div>
+    </div>
+</div>
+
+<!-- Image Viewer Modal -->
+<div class="modal fade" id="imageModal" tabindex="-1" aria-hidden="true">
+    <div class="modal-dialog modal-fullscreen">
+        <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>
+                <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="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>
+                        <button type="button" class="btn btn-outline-secondary" onclick="rotateImage(90)" title="右旋90°"><i class="bi bi-arrow-clockwise"></i></button>
+                    </div>
+                    <div class="d-flex align-items-center gap-2 mx-3 border-start border-end px-3">
+                        <i class="bi bi-brightness-high" title="亮度"></i>
+                        <input type="range" class="form-range" min="50" max="150" value="100" id="brightnessRange" oninput="updateImageFilter()" style="width: 80px;">
+                        <i class="bi bi-circle-half ms-2" title="对比度"></i>
+                        <input type="range" class="form-range" min="50" max="200" value="100" id="contrastRange" oninput="updateImageFilter()" style="width: 80px;">
+                        <button class="btn btn-link btn-sm text-decoration-none py-0" onclick="resetFilters()">重置</button>
+                    </div>
+                    <div class="form-check form-switch ms-auto mb-0" title="开启后鼠标悬停图片可局部放大">
+                        <input class="form-check-input" type="checkbox" id="magnifierSwitch">
+                        <label class="form-check-label small" for="magnifierSwitch">🔍 放大镜</label>
+                    </div>
+                </div>
+                <div class="image-viewer shadow-inner" id="viewer">
+                    <div id="magnifier" class="magnifier-glass"></div>
+                    <div id="imageWrapper" class="image-wrapper">
+                        <img id="refImage" src="" alt="家谱图片" draggable="false">
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+{% endblock %}
+
+{% 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;
+    let imgContrast = 100;
+    let isDragging = false;
+    let hasDragged = false;
+    let startX = 0, startY = 0;
+    let currentX = 0, currentY = 0;
+    let isZoomedIn = false;
+    const ZOOM_LEVEL = 2.0;
+
+    const viewer = document.getElementById('viewer');
+    const magnifier = document.getElementById('magnifier');
+    const magnifierSwitch = document.getElementById('magnifierSwitch');
+    const imageWrapper = document.getElementById('imageWrapper');
+    const refImage = document.getElementById('refImage');
+    
+    if (imageWrapper) {
+        viewer.style.cursor = 'zoom-in';
+
+        viewer.addEventListener('mousedown', (e) => {
+            if (e.target.closest('.image-toolbar') || e.target.closest('.magnifier-glass')) return;
+            isDragging = true;
+            hasDragged = false;
+            startX = e.clientX;
+            startY = e.clientY;
+            viewer.style.cursor = 'grabbing';
+            e.preventDefault(); 
+        });
+        
+        window.addEventListener('mousemove', (e) => {
+            if (!isDragging) return;
+            const dx = e.clientX - startX;
+            const dy = e.clientY - startY;
+            if (Math.abs(dx) > 2 || Math.abs(dy) > 2) hasDragged = true;
+            currentX += dx;
+            currentY += dy;
+            startX = e.clientX;
+            startY = e.clientY;
+            updateImageTransform();
+        });
+        
+        window.addEventListener('mouseup', (e) => {
+            if (isDragging) {
+                isDragging = false;
+                viewer.style.cursor = isZoomedIn ? 'grab' : 'zoom-in';
+                if (!hasDragged && viewer.contains(e.target)) toggleZoom();
+            }
+        });
+    }
+
+    function toggleZoom() {
+        isZoomedIn = !isZoomedIn;
+        if (!isZoomedIn) {
+            currentX = 0;
+            currentY = 0;
+        }
+        updateImageTransform();
+        viewer.style.cursor = isZoomedIn ? 'grab' : 'zoom-in';
+    }
+    
+    viewer.addEventListener('mousemove', function(e) {
+        if (!magnifierSwitch.checked || isDragging || !refImage.src) {
+            magnifier.style.display = 'none';
+            return;
+        }
+        const rect = refImage.getBoundingClientRect();
+        const x = e.clientX - rect.left;
+        const y = e.clientY - rect.top;
+        
+        if (x < 0 || x > rect.width || y < 0 || y > rect.height) {
+            magnifier.style.display = 'none';
+            return;
+        }
+        magnifier.style.display = 'block';
+        const glassOffset = 20;
+        const viewerRect = viewer.getBoundingClientRect();
+        magnifier.style.left = (e.clientX - viewerRect.left + glassOffset) + 'px';
+        magnifier.style.top = (e.clientY - viewerRect.top + glassOffset) + 'px';
+        
+        const zoom = 2.5;
+        magnifier.style.backgroundImage = `url('${refImage.src}')`;
+        magnifier.style.backgroundSize = `${rect.width * zoom}px ${rect.height * zoom}px`;
+        magnifier.style.backgroundPosition = `-${x * zoom - 75}px -${y * zoom - 75}px`; 
+    });
+
+    function rotateImage(deg) {
+        imgRotation = (imgRotation + deg) % 360;
+        updateImageTransform();
+    }
+    
+    function updateImageFilter() {
+        imgBrightness = document.getElementById('brightnessRange').value;
+        imgContrast = document.getElementById('contrastRange').value;
+        applyImageFilters();
+    }
+    
+    function resetFilters() {
+        imgRotation = 0;
+        imgBrightness = 100;
+        imgContrast = 100;
+        currentX = 0;
+        currentY = 0;
+        isZoomedIn = false;
+        document.getElementById('brightnessRange').value = 100;
+        document.getElementById('contrastRange').value = 100;
+        if(magnifierSwitch) magnifierSwitch.checked = false;
+        updateImageTransform();
+        applyImageFilters();
+        if(viewer) viewer.style.cursor = 'zoom-in';
+    }
+    
+    function updateImageTransform() {
+        if (imageWrapper) {
+            const scale = isZoomedIn ? ZOOM_LEVEL : 1;
+            imageWrapper.style.transform = `translate(calc(-50% + ${currentX}px), calc(-50% + ${currentY}px)) rotate(${imgRotation}deg) scale(${scale})`;
+        }
+    }
+    
+    function applyImageFilters() {
+        if (refImage) {
+            refImage.style.filter = `brightness(${imgBrightness}%) contrast(${imgContrast}%)`;
+        }
+    }
+
+    function startAiAnalysis(recordId) {
+        if (!confirm('确定要开始 AI 解析吗?这可能需要一些时间。')) return;
+        
+        // Optimistic UI update
+        const btn = event.currentTarget;
+        const originalHtml = btn.innerHTML;
+        btn.disabled = true;
+        btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> 解析中...';
+        
+        fetch('/manager/api/start_analysis/' + recordId, {
+            method: 'POST',
+            headers: {
+                'Content-Type': 'application/json'
+            }
+        })
+        .then(response => response.json())
+        .then(data => {
+            if (data.success) {
+                // Reload to show status update (or update DOM dynamically)
+                window.location.reload();
+            } else {
+                alert('启动解析失败: ' + data.message);
+                btn.innerHTML = originalHtml;
+                btn.disabled = false;
+            }
+        })
+        .catch(error => {
+            console.error('Error:', error);
+            alert('请求失败,请重试');
+            btn.innerHTML = originalHtml;
+            btn.disabled = false;
+        });
+    }
+
+    function reStartAiAnalysis(recordId) {
+        if (!confirm('确定要重新进行 AI 解析吗?这将覆盖原有的解析结果。')) return;
+        
+        const btn = event.currentTarget;
+        const originalHtml = btn.innerHTML;
+        btn.disabled = true;
+        btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> 解析中...';
+        
+        fetch('/manager/api/start_analysis/' + recordId, {
+            method: 'POST',
+            headers: {
+                'Content-Type': 'application/json'
+            }
+        })
+        .then(response => response.json())
+        .then(data => {
+            if (data.success) {
+                window.location.reload();
+            } else {
+                alert('启动解析失败: ' + data.message);
+                btn.innerHTML = originalHtml;
+                btn.disabled = false;
+            }
+        })
+        .catch(error => {
+            console.error('Error:', error);
+            alert('请求失败,请重试');
+            btn.innerHTML = originalHtml;
+            btn.disabled = false;
+        });
+    }
+</script>
+{% endblock %}

+ 68 - 0
templates/layout.html

@@ -0,0 +1,68 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>{% block title %}家谱管理系统{% endblock %}</title>
+    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
+    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css">
+    <style>
+        body { font-family: 'Microsoft YaHei', sans-serif; }
+        .sidebar { min-height: 100vh; background-color: #343a40; color: white; }
+        .sidebar a { color: rgba(255,255,255,.8); text-decoration: none; padding: 10px 20px; display: block; }
+        .sidebar a:hover { background-color: #495057; color: white; }
+        .sidebar a.active { background-color: #0d6efd; color: white; }
+        .content-area { padding: 20px; }
+    </style>
+    {% block extra_css %}{% endblock %}
+</head>
+<body class="bg-light">
+    <div class="container-fluid">
+        <div class="row">
+            {% if session.get('user_id') %}
+            <!-- Sidebar -->
+            <div class="col-md-2 sidebar d-none d-md-block">
+                <div class="py-4 text-center border-bottom mb-4">
+                    <h4>家谱管理</h4>
+                </div>
+                <nav>
+                    <a href="{{ url_for('index') }}" class="{% if request.endpoint == 'index' %}active{% endif %}">
+                        <i class="bi bi-file-earmark-arrow-up me-2"></i> 上传管理
+                    </a>
+                    <a href="{{ url_for('members') }}" class="{% if request.endpoint == 'members' %}active{% endif %}">
+                        <i class="bi bi-people me-2"></i> 成员列表
+                    </a>
+                    <a href="{{ url_for('tree') }}" class="{% if request.endpoint == 'tree' %}active{% endif %}">
+                        <i class="bi bi-diagram-3 me-2"></i> 关系树状图
+                    </a>
+                    <div class="mt-5 border-top pt-3">
+                        <p class="px-3 small text-muted">用户: {{ session['username'] }}</p>
+                        <a href="{{ url_for('logout') }}" class="text-danger">
+                            <i class="bi bi-box-arrow-right me-2"></i> 退出登录
+                        </a>
+                    </div>
+                </nav>
+            </div>
+            {% endif %}
+
+            <!-- Main Content -->
+            <div class="col-md-{% if session.get('user_id') %}10{% else %}12{% endif %} content-area">
+                {% with messages = get_flashed_messages() %}
+                  {% if messages %}
+                    {% for message in messages %}
+                      <div class="alert alert-info alert-dismissible fade show" role="alert">
+                        {{ message }}
+                        <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
+                      </div>
+                    {% endfor %}
+                  {% endif %}
+                {% endwith %}
+
+                {% block content %}{% endblock %}
+            </div>
+        </div>
+    </div>
+    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
+    {% block extra_js %}{% endblock %}
+</body>
+</html>

+ 34 - 0
templates/login.html

@@ -0,0 +1,34 @@
+{% extends "layout.html" %}
+
+{% block title %}登录 - 家谱管理系统{% endblock %}
+
+{% block content %}
+<div class="row justify-content-center mt-5">
+    <div class="col-md-4">
+        <div class="card shadow">
+            <div class="card-header bg-primary text-white text-center py-3">
+                <h4 class="mb-0">用户登录</h4>
+            </div>
+            <div class="card-body p-4">
+                <form method="POST">
+                    <div class="mb-3">
+                        <label class="form-label">用户名</label>
+                        <div class="input-group">
+                            <span class="input-group-text"><i class="bi bi-person"></i></span>
+                            <input type="text" name="username" class="form-control" placeholder="请输入用户名" required>
+                        </div>
+                    </div>
+                    <div class="mb-4">
+                        <label class="form-label">密码</label>
+                        <div class="input-group">
+                            <span class="input-group-text"><i class="bi bi-lock"></i></span>
+                            <input type="password" name="password" class="form-control" placeholder="请输入密码" required>
+                        </div>
+                    </div>
+                    <button type="submit" class="btn btn-primary w-100 py-2">立即登录</button>
+                </form>
+            </div>
+        </div>
+    </div>
+</div>
+{% endblock %}

+ 530 - 0
templates/member_detail.html

@@ -0,0 +1,530 @@
+{% extends "layout.html" %}
+
+{% block title %}成员详情 - {{ member.name }}{% endblock %}
+
+{% block extra_css %}
+<style>
+    .info-label { color: #6c757d; font-weight: normal; width: 120px; display: inline-block; }
+    .info-value { color: #212529; font-weight: 500; }
+    .detail-section { background: white; border-radius: 8px; padding: 25px; margin-bottom: 20px; box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); }
+    .section-title { font-size: 1.1rem; font-weight: bold; border-bottom: 2px solid #f8f9fa; padding-bottom: 10px; margin-bottom: 20px; color: #0d6efd; }
+    .relation-card { border-left: 4px solid #198754; background: #f8fff9; padding: 10px 15px; margin-bottom: 10px; border-radius: 4px; }
+    
+    /* Image Preview in Sidebar */
+    .source-image-preview {
+        cursor: pointer;
+        position: relative;
+        overflow: hidden;
+        border: 1px solid #dee2e6;
+        border-radius: 4px;
+        transition: transform 0.2s;
+    }
+    .source-image-preview:hover {
+        transform: scale(1.02);
+        box-shadow: 0 4px 8px rgba(0,0,0,0.1);
+    }
+    .source-image-preview img {
+        width: 100%;
+        height: auto;
+        display: block;
+    }
+    .preview-overlay {
+        position: absolute;
+        bottom: 0;
+        left: 0;
+        right: 0;
+        background: rgba(0,0,0,0.6);
+        color: white;
+        padding: 5px;
+        text-align: center;
+        font-size: 0.8rem;
+        opacity: 0;
+        transition: opacity 0.2s;
+    }
+    .source-image-preview:hover .preview-overlay {
+        opacity: 1;
+    }
+
+    /* Modal Image Viewer */
+    .modal-image-container {
+        height: 80vh;
+        display: flex;
+        flex-direction: column;
+    }
+    .image-toolbar {
+        background: #e9ecef;
+        padding: 5px 10px;
+        border-bottom: 1px solid #dee2e6;
+        display: flex;
+        gap: 10px;
+        align-items: center;
+        flex-wrap: wrap;
+    }
+    .image-viewer { 
+        flex: 1; 
+        border: 1px solid #ccc; 
+        background: #f0f0f0; 
+        overflow: hidden; 
+        text-align: center; 
+        position: relative; 
+        cursor: grab;
+        user-select: none;
+    }
+    .image-viewer:active {
+        cursor: grabbing;
+    }
+    .image-wrapper {
+        display: inline-block;
+        transition: transform 0.2s ease-out;
+        transform-origin: center center;
+        position: absolute;
+        top: 50%;
+        left: 50%;
+        transform: translate(-50%, -50%);
+    }
+    .image-wrapper img {
+        max-width: 100%;
+        max-height: 100%; /* Fit within viewer initially */
+        display: block;
+        pointer-events: none;
+        user-select: none;
+        transition: filter 0.2s;
+        box-shadow: 0 0 20px rgba(0,0,0,0.1);
+    }
+    /* 放大镜样式 - Optional if we want it in modal too */
+    .magnifier-glass {
+        position: absolute;
+        border: 3px solid #000;
+        border-radius: 50%;
+        cursor: none;
+        width: 150px;
+        height: 150px;
+        box-shadow: 0 0 10px rgba(0,0,0,0.5);
+        display: none;
+        z-index: 1000;
+        background-repeat: no-repeat;
+        background-color: white;
+        pointer-events: none;
+    }
+</style>
+{% endblock %}
+
+{% block content %}
+<div class="container py-4">
+    <div class="d-flex justify-content-between align-items-center mb-4">
+        <h2><i class="bi bi-person-badge"></i> {{ member.name }} 的个人详情</h2>
+        <div class="btn-group">
+            <a href="{{ url_for('edit_member', member_id=member.id) }}" class="btn btn-primary">
+                <i class="bi bi-pencil"></i> 编辑信息
+            </a>
+            <a href="{{ url_for('members') }}" class="btn btn-outline-secondary">返回列表</a>
+        </div>
+    </div>
+
+    <div class="row">
+        <!-- 基本与核心信息 -->
+        <div class="col-md-8">
+            <div class="detail-section">
+                <div class="section-title">基本信息</div>
+                <div class="row g-3">
+                    <div class="col-md-6">
+                        <span class="info-label">姓名(繁体):</span>
+                        <span class="info-value">{{ member.name }}</span>
+                    </div>
+                    <div class="col-md-6">
+                        <span class="info-label">姓名(简体):</span>
+                        <span class="info-value">{{ member.simplified_name or '-' }}</span>
+                    </div>
+                    <div class="col-md-6">
+                        <span class="info-label">性别:</span>
+                        <span class="info-value">{{ '男' if member.sex == 1 else '女' }}</span>
+                    </div>
+                    <div class="col-md-6">
+                        <span class="info-label">曾用名:</span>
+                        <span class="info-value">{{ member.former_name or '-' }}</span>
+                    </div>
+                    <div class="col-md-6">
+                        <span class="info-label">幼名/乳名:</span>
+                        <span class="info-value">{{ member.childhood_name or '-' }}</span>
+                    </div>
+                    <div class="col-md-6">
+                        <span class="info-label">出生日期:</span>
+                        <span class="info-value">{{ member.birthday_str or '未知' }}</span>
+                    </div>
+                    <div class="col-md-6">
+                        <span class="info-label">民族:</span>
+                        <span class="info-value">{{ member.nation or '-' }}</span>
+                    </div>
+                    <div class="col-md-6">
+                        <span class="info-label">状态:</span>
+                        <span class="info-value">
+                            {% if member.is_pass_away == 1 %}
+                                <span class="text-danger">已故</span>
+                            {% else %}
+                                <span class="text-success">健在</span>
+                            {% endif %}
+                        </span>
+                    </div>
+                    <div class="col-md-6">
+                        <span class="info-label">婚姻状况:</span>
+                        <span class="info-value">
+                            {% set marital_map = {0: '未知', 1: '未婚', 2: '已婚', 3: '离异/丧偶'} %}
+                            {{ marital_map.get(member.marital_status, '未知') }}
+                        </span>
+                    </div>
+                </div>
+
+                <div class="section-title mt-4">谱系资料</div>
+                <div class="row g-3">
+                    <div class="col-md-6">
+                        <span class="info-label">字辈:</span>
+                        <span class="info-value">{{ member.name_word or '-' }}</span>
+                    </div>
+                    <div class="col-md-6">
+                        <span class="info-label">堂内排行:</span>
+                        <span class="info-value">{{ member.family_rank or '-' }}</span>
+                    </div>
+                    <div class="col-md-6">
+                        <span class="info-label">世系世代:</span>
+                        <span class="info-value">{{ member.name_word_generation or '-' }}</span>
+                    </div>
+                    <div class="col-md-6">
+                        <span class="info-label">名号/封号:</span>
+                        <span class="info-value">{{ member.name_title or '-' }}</span>
+                    </div>
+                    <div class="col-md-6">
+                        <span class="info-label">分房/堂号:</span>
+                        <span class="info-value">{{ member.branch_family_hall or '-' }}</span>
+                    </div>
+                    <div class="col-md-12">
+                        <span class="info-label">聚居地:</span>
+                        <span class="info-value">{{ member.cluster_place or '-' }}</span>
+                    </div>
+                </div>
+
+                <div class="section-title mt-4">联系与地址</div>
+                <div class="row g-3">
+                    <div class="col-md-6">
+                        <span class="info-label">手机号:</span>
+                        <span class="info-value">{{ member.phone or '-' }}</span>
+                    </div>
+                    <div class="col-md-6">
+                        <span class="info-label">微信号:</span>
+                        <span class="info-value">{{ member.wechat_account or '-' }}</span>
+                    </div>
+                    <div class="col-md-12">
+                        <span class="info-label">现居住址:</span>
+                        <span class="info-value">{{ member.residential_address or '-' }}</span>
+                    </div>
+                </div>
+            </div>
+
+            <div class="detail-section">
+                <div class="section-title">个人履历与成就</div>
+                <div class="mb-4">
+                    <label class="info-label d-block mb-2">标签:</label>
+                    <div class="p-3 bg-light rounded">{{ member.tags or '暂无' }}</div>
+                </div>
+                <div class="mb-4">
+                    <label class="info-label d-block mb-2">人员备注:</label>
+                    <div class="p-3 bg-light rounded">{{ member.notes or '暂无' }}</div>
+                </div>
+                <div class="mb-4">
+                    <label class="info-label d-block mb-2">职业背景:</label>
+                    <div class="p-3 bg-light rounded">{{ member.occupation or '暂无信息' }}</div>
+                </div>
+                <div class="mb-4">
+                    <label class="info-label d-block mb-2">教育经历:</label>
+                    <div class="p-3 bg-light rounded">{{ member.educational or '暂无信息' }}</div>
+                </div>
+                <div>
+                    <label class="info-label d-block mb-2">个人成就:</label>
+                    <div class="p-3 bg-light rounded">{{ member.personal_achievements or '暂无信息' }}</div>
+                </div>
+            </div>
+        </div>
+
+        <!-- 关系信息与原图 -->
+        <div class="col-md-4">
+            
+            {% if member.source_image_url %}
+            <div class="detail-section">
+                <div class="section-title">来源原图</div>
+                <div class="source-image-preview" onclick="openImageViewer()">
+                    <img src="{{ member.source_image_url }}" alt="来源家谱">
+                    <div class="preview-overlay">
+                        <i class="bi bi-arrows-fullscreen"></i> 点击查看大图 (第{{ member.source_page }}页)
+                    </div>
+                </div>
+            </div>
+            {% endif %}
+
+            <div class="detail-section">
+                <div class="section-title">家族关系</div>
+                
+                <h6 class="fw-bold mb-3"><i class="bi bi-arrow-up-circle me-1"></i> 尊辈/关联人</h6>
+                {% for p in parents %}
+                <div class="relation-card">
+                    <div class="d-flex justify-content-between align-items-center">
+                        <a href="{{ url_for('member_detail', member_id=p.id) }}" class="text-decoration-none fw-bold">
+                            {{ p.name }}
+                        </a>
+                        <span class="badge bg-success small">
+                            {% set rel_map = {1: '父亲', 2: '母亲', 10: '配偶', 11: '兄弟', 12: '姐妹'} %}
+                            {{ rel_map.get(p.relation_type, '关联人') }}
+                        </span>
+                    </div>
+                </div>
+                {% endfor %}
+                {% if not parents %}
+                <p class="text-muted small">暂无上层关系记录</p>
+                {% endif %}
+
+                <h6 class="fw-bold mt-4 mb-3"><i class="bi bi-arrow-down-circle me-1"></i> 子嗣/晚辈</h6>
+                {% for c in children %}
+                <div class="relation-card" style="border-left-color: #0d6efd; background: #f0f7ff;">
+                    <div class="d-flex justify-content-between align-items-center">
+                        <a href="{{ url_for('member_detail', member_id=c.id) }}" class="text-decoration-none fw-bold">
+                            {{ c.name }}
+                        </a>
+                        <span class="badge bg-primary small">
+                            {% set rel_map = {1: '子女', 2: '子女', 10: '配偶', 11: '兄弟', 12: '姐妹'} %}
+                            {{ rel_map.get(c.relation_type, '后辈') }}
+                        </span>
+                    </div>
+                </div>
+                {% endfor %}
+                {% if not children %}
+                <p class="text-muted small">暂无下层关系记录</p>
+                {% endif %}
+            </div>
+
+            <div class="detail-section text-center">
+                <div class="section-title">系统操作</div>
+                <div class="small text-muted mb-3">
+                    记录创建于:{{ member.create_time }}<br>
+                    最后修改:{{ member.modified_time }}
+                </div>
+                <button class="btn btn-sm btn-outline-danger w-100" onclick="confirmDelete()">
+                    <i class="bi bi-trash"></i> 删除此成员
+                </button>
+                <form id="deleteForm" action="{{ url_for('delete_member', member_id=member.id) }}" method="POST" style="display: none;"></form>
+            </div>
+        </div>
+    </div>
+</div>
+
+<!-- Image Viewer Modal -->
+<div class="modal fade" id="imageModal" tabindex="-1" aria-hidden="true">
+    <div class="modal-dialog modal-fullscreen">
+        <div class="modal-content bg-light">
+            <div class="modal-header py-2">
+                <h5 class="modal-title fs-6"><i class="bi bi-image"></i> 来源扫描件查看 - 第 {{ member.source_page }} 页</h5>
+                <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="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>
+                        <button type="button" class="btn btn-outline-secondary" onclick="rotateImage(90)" title="右旋90°"><i class="bi bi-arrow-clockwise"></i></button>
+                    </div>
+                    <div class="d-flex align-items-center gap-2 mx-3 border-start border-end px-3">
+                        <i class="bi bi-brightness-high" title="亮度"></i>
+                        <input type="range" class="form-range" min="50" max="150" value="100" id="brightnessRange" oninput="updateImageFilter()" style="width: 80px;">
+                        <i class="bi bi-circle-half ms-2" title="对比度"></i>
+                        <input type="range" class="form-range" min="50" max="200" value="100" id="contrastRange" oninput="updateImageFilter()" style="width: 80px;">
+                        <button class="btn btn-link btn-sm text-decoration-none py-0" onclick="resetFilters()">重置</button>
+                    </div>
+                    <div class="form-check form-switch ms-auto mb-0" title="开启后鼠标悬停图片可局部放大">
+                        <input class="form-check-input" type="checkbox" id="magnifierSwitch">
+                        <label class="form-check-label small" for="magnifierSwitch">🔍 放大镜</label>
+                    </div>
+                </div>
+                <div class="image-viewer shadow-inner" id="viewer">
+                    <div id="magnifier" class="magnifier-glass"></div>
+                    <div id="imageWrapper" class="image-wrapper">
+                        {% if member.source_image_url %}
+                            <img id="refImage" src="{{ member.source_image_url }}" alt="家谱图片" draggable="false">
+                        {% endif %}
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+{% endblock %}
+
+{% block extra_js %}
+<script>
+    function confirmDelete() {
+        if(confirm('确定要删除此成员吗?\n这将同时删除其所有关联关系记录!')) {
+            document.getElementById('deleteForm').submit();
+        }
+    }
+
+    function openImageViewer() {
+        var myModal = new bootstrap.Modal(document.getElementById('imageModal'));
+        myModal.show();
+        // Reset state when opening
+        resetFilters();
+    }
+
+    // --- Image Viewer Logic (Reused from add_member) ---
+    // Image State
+    let imgRotation = 0;
+    let imgBrightness = 100;
+    let imgContrast = 100;
+    
+    // Dragging State
+    let isDragging = false;
+    let hasDragged = false;
+    let startX = 0, startY = 0;
+    let currentX = 0, currentY = 0; // Relative to center
+
+    // Zoom State
+    let isZoomedIn = false;
+    const ZOOM_LEVEL = 2.0;
+
+    // Elements
+    const viewer = document.getElementById('viewer');
+    const magnifier = document.getElementById('magnifier');
+    const magnifierSwitch = document.getElementById('magnifierSwitch');
+    const imageWrapper = document.getElementById('imageWrapper');
+    const refImage = document.getElementById('refImage');
+    
+    // Initialize Dragging and Zooming
+    if (imageWrapper) {
+        // Center initial position logic is handled by CSS (top 50% left 50% translate -50%)
+        
+        viewer.style.cursor = 'zoom-in';
+
+        viewer.addEventListener('mousedown', (e) => {
+            if (e.target.closest('.image-toolbar') || e.target.closest('.magnifier-glass')) return;
+            isDragging = true;
+            hasDragged = false;
+            startX = e.clientX;
+            startY = e.clientY;
+            
+            viewer.style.cursor = 'grabbing';
+            e.preventDefault(); 
+        });
+        
+        window.addEventListener('mousemove', (e) => {
+            if (!isDragging) return;
+            
+            const dx = e.clientX - startX;
+            const dy = e.clientY - startY;
+            
+            if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
+                hasDragged = true;
+            }
+
+            currentX += dx;
+            currentY += dy;
+            
+            startX = e.clientX;
+            startY = e.clientY;
+            
+            updateImageTransform();
+        });
+        
+        window.addEventListener('mouseup', (e) => {
+            if (isDragging) {
+                isDragging = false;
+                viewer.style.cursor = isZoomedIn ? 'grab' : 'zoom-in';
+                
+                if (!hasDragged && viewer.contains(e.target)) {
+                    toggleZoom();
+                }
+            }
+        });
+    }
+
+    function toggleZoom() {
+        isZoomedIn = !isZoomedIn;
+        if (!isZoomedIn) {
+            currentX = 0;
+            currentY = 0;
+        }
+        updateImageTransform();
+        viewer.style.cursor = isZoomedIn ? 'grab' : 'zoom-in';
+    }
+    
+    // Magnifier Logic
+    viewer.addEventListener('mousemove', function(e) {
+        if (!magnifierSwitch.checked) {
+            magnifier.style.display = 'none';
+            return;
+        }
+        
+        if (isDragging) {
+             magnifier.style.display = 'none';
+             return;
+        }
+        
+        if (!refImage) return;
+
+        const rect = refImage.getBoundingClientRect();
+        const x = e.clientX - rect.left;
+        const y = e.clientY - rect.top;
+        
+        if (x < 0 || x > rect.width || y < 0 || y > rect.height) {
+            magnifier.style.display = 'none';
+            return;
+        }
+
+        magnifier.style.display = 'block';
+        
+        const glassOffset = 20;
+        const viewerRect = viewer.getBoundingClientRect();
+        magnifier.style.left = (e.clientX - viewerRect.left + glassOffset) + 'px';
+        magnifier.style.top = (e.clientY - viewerRect.top + glassOffset) + 'px';
+        
+        const zoom = 2.5;
+        magnifier.style.backgroundImage = `url('${refImage.src}')`;
+        magnifier.style.backgroundSize = `${rect.width * zoom}px ${rect.height * zoom}px`;
+        magnifier.style.backgroundPosition = `-${x * zoom - 75}px -${y * zoom - 75}px`; 
+    });
+
+    function rotateImage(deg) {
+        imgRotation = (imgRotation + deg) % 360;
+        updateImageTransform();
+    }
+    
+    function updateImageFilter() {
+        imgBrightness = document.getElementById('brightnessRange').value;
+        imgContrast = document.getElementById('contrastRange').value;
+        applyImageFilters();
+    }
+    
+    function resetFilters() {
+        imgRotation = 0;
+        imgBrightness = 100;
+        imgContrast = 100;
+        currentX = 0;
+        currentY = 0;
+        isZoomedIn = false;
+        
+        document.getElementById('brightnessRange').value = 100;
+        document.getElementById('contrastRange').value = 100;
+        if(magnifierSwitch) magnifierSwitch.checked = false;
+        
+        updateImageTransform();
+        applyImageFilters();
+        if(viewer) viewer.style.cursor = 'zoom-in';
+    }
+    
+    function updateImageTransform() {
+        if (imageWrapper) {
+            const scale = isZoomedIn ? ZOOM_LEVEL : 1;
+            imageWrapper.style.transform = `translate(calc(-50% + ${currentX}px), calc(-50% + ${currentY}px)) rotate(${imgRotation}deg) scale(${scale})`;
+        }
+    }
+    
+    function applyImageFilters() {
+        if (refImage) {
+            refImage.style.filter = `brightness(${imgBrightness}%) contrast(${imgContrast}%)`;
+        }
+    }
+</script>
+{% endblock %}

+ 143 - 0
templates/members.html

@@ -0,0 +1,143 @@
+{% extends "layout.html" %}
+
+{% block title %}成员列表 - 家谱管理系统{% endblock %}
+
+{% block content %}
+<div class="d-flex justify-content-between align-items-center mb-4">
+    <h2><i class="bi bi-people"></i> 家谱成员管理</h2>
+    <div class="d-flex gap-2">
+        <form class="d-flex" action="{{ url_for('members') }}" method="GET">
+            <div class="input-group">
+                <input type="text" name="name" class="form-control" placeholder="输入姓名搜索..." value="{{ search_name or '' }}">
+                <button class="btn btn-outline-primary" type="submit"><i class="bi bi-search"></i></button>
+                {% if search_name %}
+                    <a href="{{ url_for('members') }}" class="btn btn-outline-secondary">重置</a>
+                {% endif %}
+            </div>
+        </form>
+        <a href="{{ url_for('add_member') }}" class="btn btn-primary text-nowrap"><i class="bi bi-plus-lg"></i> 录入新成员</a>
+    </div>
+</div>
+
+<div class="card shadow-sm border-0">
+    <div class="card-body p-0">
+        <div class="table-responsive">
+            <table class="table table-hover align-middle mb-0">
+                <thead class="table-light">
+                    <tr>
+                        <th class="px-4">序号</th>
+                        <th>基本信息</th>
+                        <th>分房 / 堂号</th>
+                        <th>居住地</th>
+                        <th>状态</th>
+                        <th class="text-center">操作</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    {% for member in members %}
+                    <tr>
+                        <td class="px-4 text-muted small">{{ loop.index + (page - 1) * 20 }}</td>
+                        <td>
+                            <div class="fw-bold">
+                                {{ member.name }}{% if member.simplified_name %}({{ member.simplified_name }}){% endif %}
+                            </div>
+                            <div class="small text-muted">
+                                {{ '男' if member.sex == 1 else '女' }} | {{ member.birthday_str or '未知' }}
+                            </div>
+                        </td>
+                        <td>
+                            <div class="small">
+                                {{ member.family_branch or member.hall_name or '-' }}
+                            </div>
+                        </td>
+                        <td>
+                            <div class="small text-truncate" style="max-width: 150px;">
+                                {{ member.residential_address or '-' }}
+                            </div>
+                        </td>
+                        <td>
+                            {% if member.is_pass_away == 1 %}
+                                <span class="badge bg-secondary">已故</span>
+                            {% else %}
+                                <span class="badge bg-success">健在</span>
+                            {% endif %}
+                            {% if member.modified_time_str %}
+                                <div class="text-muted" style="font-size: 0.75rem;">修改: {{ member.modified_time_str }}</div>
+                            {% else %}
+                                <div class="text-muted" style="font-size: 0.75rem;">录入: {{ member.create_time_str or '-' }}</div>
+                            {% endif %}
+                        </td>
+                        <td class="text-center">
+                            <div class="btn-group btn-group-sm">
+                                <a href="{{ url_for('member_detail', member_id=member.id) }}" class="btn btn-outline-info">
+                                    <i class="bi bi-eye"></i> 详情
+                                </a>
+                                <a href="{{ url_for('edit_member', member_id=member.id) }}" class="btn btn-outline-primary">
+                                    <i class="bi bi-pencil"></i> 编辑
+                                </a>
+                                <button type="button" class="btn btn-outline-danger" onclick="confirmDelete({{ member.id }}, '{{ member.name }}')">
+                                    <i class="bi bi-trash"></i> 删除
+                                </button>
+                            </div>
+                        </td>
+                    </tr>
+                    {% endfor %}
+                    {% if not members %}
+                    <tr>
+                        <td colspan="6" class="text-center py-5 text-muted">
+                            <i class="bi bi-person-x fs-1 d-block mb-2"></i>
+                            暂无成员数据
+                        </td>
+                    </tr>
+                    {% endif %}
+                </tbody>
+            </table>
+        </div>
+    </div>
+    
+    <!-- 分页控件 -->
+    {% if total_pages > 1 %}
+    <nav class="mt-4">
+        <ul class="pagination justify-content-center">
+            <li class="page-item {{ 'disabled' if page == 1 }}">
+                <a class="page-link" href="{{ url_for('members', page=page-1, name=search_name) }}">上一页</a>
+            </li>
+            
+            {% for p in range(1, total_pages + 1) %}
+                {% if p == page %}
+                    <li class="page-item active"><span class="page-link">{{ p }}</span></li>
+                {% else %}
+                    {# Show first, last, and around current page #}
+                    {% if p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %}
+                        <li class="page-item"><a class="page-link" href="{{ url_for('members', page=p, name=search_name) }}">{{ p }}</a></li>
+                    {% elif p == page - 3 or p == page + 3 %}
+                        <li class="page-item disabled"><span class="page-link">...</span></li>
+                    {% endif %}
+                {% endif %}
+            {% endfor %}
+            
+            <li class="page-item {{ 'disabled' if page == total_pages }}">
+                <a class="page-link" href="{{ url_for('members', page=page+1, name=search_name) }}">下一页</a>
+            </li>
+        </ul>
+        <div class="text-center text-muted small mt-2">
+            共 {{ total }} 条记录,当前第 {{ page }} / {{ total_pages }} 页
+        </div>
+    </nav>
+    {% endif %}
+</div>
+{% endblock %}
+
+{% block extra_js %}
+<script>
+function confirmDelete(id, name) {
+    if (confirm(`确定要删除成员 "${name}" 吗?此操作将同时删除与其相关的所有家谱关系,且不可恢复。`)) {
+        const form = document.createElement('form');
+        form.method = 'POST';
+        form.action = `/manager/delete_member/${id}`;
+        document.body.appendChild(form);
+        form.submit();
+    }
+}
+</script>
+{% endblock %}

+ 497 - 0
templates/tree.html

@@ -0,0 +1,497 @@
+{% extends "layout.html" %}
+
+{% block title %}生物遗传图谱 - 家谱管理系统{% endblock %}
+
+{% block extra_css %}
+<style>
+    #tree-container { 
+        width: 100%; 
+        height: 700px; 
+        background: white; 
+        border: 1px solid #e9ecef; 
+        border-radius: 8px;
+        margin-top: 10px;
+        box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
+        position: relative;
+        overflow: auto;
+    }
+    .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; }
+    
+    /* 样图一致:较粗的细实线、浅灰蓝色,显得专业 */
+    .link { fill: none; stroke: #94A3B8; stroke-width: 2px; stroke-linejoin: round; }
+    
+    /* 关系标签文字,取消白色粗描边,因为已有胶囊形背景 */
+    .link-label { font-size: 11px; fill: #64748B; font-weight: 500; }
+    .link-label-sibling { fill: #64748B; }
+
+    /* 男女形状与颜色区分:男性方形(蓝),女性圆形(粉红),添加轻微圆角与投影 */
+    .node-male rect { stroke: #3B82F6; fill: #EFF6FF; rx: 8px; ry: 8px; }
+    .node-female circle { stroke: #EC4899; fill: #FDF2F8; }
+    
+    /* 未知性别默认 */
+    .node-leaf circle, .node-internal circle { stroke: #94A3B8; fill: #F8FAFC; }
+    .node-male circle { stroke: none; fill: none; } /* 清除可能的干扰 */
+
+    /* 样图一致:全部细实线 */
+    .link-parent-child { stroke: #333; stroke-dasharray: none; }
+    .link-spouse { stroke: #333; stroke-dasharray: none; stroke-width: 1.2px; }
+    .link-sibling { stroke: #333; stroke-dasharray: none; stroke-width: 1.2px; }
+
+    /* 右键菜单样式 */
+    .context-menu {
+        position: absolute;
+        display: none;
+        background: white;
+        border: 1px solid #ccc;
+        box-shadow: 2px 2px 10px rgba(0,0,0,0.2);
+        z-index: 1000;
+        border-radius: 4px;
+        padding: 5px 0;
+        min-width: 120px;
+    }
+    .context-menu-item {
+        padding: 8px 15px;
+        cursor: pointer;
+        font-size: 14px;
+        color: #333;
+    }
+    .context-menu-item:hover {
+        background-color: #f8f9fa;
+        color: #0d6efd;
+    }
+    .context-menu-item i {
+        margin-right: 8px;
+    }
+</style>
+{% endblock %}
+
+{% 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>
+
+    <div class="alert alert-light border small py-2">
+        <i class="bi bi-info-circle me-1"></i> 提示:图中按生物遗传图谱格式展示。支持拖拽建立关系,右键点击成员可查看、编辑或新增。
+    </div>
+
+    <div id="tree-container">
+        <!-- 右键菜单 -->
+        <div id="contextMenu" class="context-menu">
+            <div class="context-menu-item" onclick="menuAction('detail')"><i class="bi bi-eye"></i>查看成员</div>
+            <div class="context-menu-item" onclick="menuAction('edit')"><i class="bi bi-pencil"></i>编辑成员</div>
+            <div class="context-menu-item" onclick="menuAction('add')"><i class="bi bi-plus-lg"></i>新增成员</div>
+        </div>
+    </div>
+
+    <!-- 关系选择弹窗 -->
+    <div class="modal fade" id="relationModal" tabindex="-1">
+        <div class="modal-dialog modal-sm">
+            <div class="modal-content">
+                <div class="modal-header">
+                    <h5 class="modal-title">建立关系</h5>
+                    <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
+                </div>
+                <div class="modal-body">
+                    <p id="relationInfo" class="small mb-3"></p>
+                    <input type="hidden" id="sourceMid">
+                    <input type="hidden" id="targetMid">
+                    <div class="mb-3">
+                        <label class="form-label small">关系类型</label>
+                        <select id="relType" class="form-select form-select-sm">
+                            <option value="1">是其 儿子/女儿</option>
+                            <option value="10">是其 妻子/丈夫</option>
+                            <option value="11">是其 兄弟</option>
+                            <option value="12">是其 姐妹</option>
+                        </select>
+                    </div>
+                    <div class="mb-3">
+                        <label class="form-label small">子类型</label>
+                        <select id="subRelType" class="form-select form-select-sm">
+                            <option value="0">亲生/正妻</option>
+                            <option value="1">养子/女</option>
+                            <option value="2">过继</option>
+                        </select>
+                    </div>
+                </div>
+                <div class="modal-footer">
+                    <button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">取消</button>
+                    <button type="button" class="btn btn-sm btn-primary" onclick="saveRelation()">保存关系</button>
+                </div>
+            </div>
+        </div>
+    </div>
+{% endblock %}
+
+{% block extra_js %}
+<script src="{{ url_for('static', filename='js/d3.min.js') }}"></script>
+<script>
+    (function() {
+        if (typeof d3 === 'undefined') {
+            var container = document.getElementById('tree-container');
+            if (container) container.innerHTML = '<div class="h-100 d-flex align-items-center justify-content-center text-danger small">D3.js 未加载,请检查网络或稍后重试。</div>';
+        }
+    })();
+    let currentData = null;
+    let dragSource = null;
+    let dragTarget = null;
+    let selectedMid = null; // 当前选中的成员 ID
+    const relationModal = new bootstrap.Modal(document.getElementById('relationModal'));
+    const contextMenu = document.getElementById('contextMenu');
+
+    // 隐藏右键菜单
+    window.addEventListener('click', () => {
+        contextMenu.style.display = 'none';
+    });
+
+    // 处理菜单点击
+    function menuAction(type) {
+        if (!selectedMid && type !== 'add') return;
+        
+        switch(type) {
+            case 'detail':
+                window.location.href = `/manager/member_detail/${selectedMid}`;
+                break;
+            case 'edit':
+                window.location.href = `/manager/edit_member/${selectedMid}`;
+                break;
+            case 'add':
+                window.location.href = '/manager/add_member';
+                break;
+        }
+    }
+
+    // 获取数据并渲染
+    function loadTree() {
+        if (typeof d3 === 'undefined') return;
+        fetch('/manager/api/tree_data')
+            .then(response => response.json())
+            .then(data => {
+                currentData = data;
+                renderTree(data);
+            });
+    }
+
+    if (typeof d3 !== 'undefined') loadTree();
+
+    function renderTree(data) {
+        const container = document.getElementById('tree-container');
+        container.innerHTML = '';
+        container.appendChild(contextMenu);
+
+        try {
+            const { members, relations } = data;
+            if (!members || members.length === 0) {
+                container.innerHTML = '<div class="h-100 d-flex align-items-center justify-content-center text-muted">暂无成员数据,无法生成关系图。</div>';
+                return;
+            }
+
+        const nodes = members.map(m => ({ id: m.id, name: m.name, simplified_name: m.simplified_name, sex: m.sex }));
+        const hierarchicalLinks = relations.filter(r => r.relation_type === 1 || r.relation_type === 2)
+                                          .map(r => ({ source: r.parent_mid, target: r.child_mid }));
+        const spouseLinks = relations.filter(r => r.relation_type === 10);
+        const otherLinks = relations.filter(r => r.relation_type >= 11);
+
+        const childIds = new Set(hierarchicalLinks.map(l => l.target));
+        const allSpouseIds = new Set(spouseLinks.map(l => l.child_mid));
+        const roots = nodes.filter(n => !childIds.has(n.id) && !allSpouseIds.has(n.id));
+
+        function buildHierarchy(nodeId, processedNodes = new Set()) {
+            const node = nodes.find(n => n.id === nodeId);
+            if (!node || processedNodes.has(nodeId)) return null;
+            processedNodes.add(nodeId);
+
+            const children = hierarchicalLinks.filter(l => l.source === nodeId)
+                                .map(l => buildHierarchy(l.target, processedNodes))
+                                .filter(c => c !== null);
+            
+            const spouses = spouseLinks.filter(l => l.parent_mid === nodeId)
+                                .map(l => {
+                                    const spouseId = l.child_mid;
+                                    if (!childIds.has(spouseId)) {
+                                        const sNode = nodes.find(n => n.id === spouseId);
+                                        if (sNode && !processedNodes.has(spouseId)) {
+                                            processedNodes.add(spouseId);
+                                            return { id: sNode.id, name: sNode.name, simplified_name: sNode.simplified_name, sex: sNode.sex, isSpouseNode: true, children: [] };
+                                        }
+                                    }
+                                    return null;
+                                })
+                                .filter(s => s !== null);
+
+            return { id: node.id, name: node.name, simplified_name: node.simplified_name, sex: node.sex, children: spouses.concat(children) };
+        }
+
+        let treeData;
+        if (roots.length > 1) {
+            treeData = { name: "家谱根源", children: roots.map(root => buildHierarchy(root.id)).filter(r => r !== null) };
+        } else if (roots.length === 1) {
+            treeData = buildHierarchy(roots[0].id);
+        } else {
+            treeData = buildHierarchy(nodes[0].id);
+        }
+
+        const margin = {top: 80, right: 60, bottom: 80, left: 60};
+        const containerWidth = document.getElementById('tree-container').offsetWidth;
+
+        let rootNode = d3.hierarchy(treeData, d => (d && d.children) || []);
+        
+        // 动态调整间距,保证节点绝对不重叠,长辈/同辈/配偶使用固定基础间距
+        const nodeWidth = 90; // 基础宽度
+        const nodeHeight = 220; // 基础高度
+        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;
+        });
+        
+        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);
+            }
+        });
+
+        // 计算边界以动态设置 SVG 宽高,实现自动滚动不挤压
+        let x0 = Infinity;
+        let x1 = -Infinity;
+        let y1 = -Infinity;
+        nodesHier.descendants().forEach(d => {
+            if (d.x < x0) x0 = d.x;
+            if (d.x > x1) x1 = d.x;
+            if (d.y > y1) y1 = d.y;
+        });
+
+        const svgWidth = Math.max(containerWidth, x1 - x0 + margin.left + margin.right);
+        const svgHeight = Math.max(600, y1 + margin.top + margin.bottom);
+        
+        // 偏移量使得最小的 x 在 margin.left 位置,且如果内容较少则居中
+        const offsetX = Math.max(margin.left, (containerWidth - (x1 - x0)) / 2 - x0);
+
+        const svg = d3.select("#tree-container").append("svg")
+            .attr("width", svgWidth)
+            .attr("height", svgHeight)
+            .append("g")
+            .attr("transform", `translate(${offsetX},${margin.top})`);
+
+        // 节点圆圈半径(连线与节点共用),调大让图谱更清晰大气
+        const circleR = 20;
+
+        // 辅助函数:绘制精致的徽章式关系标签
+        function addBadge(g, x, y, text) {
+            const group = g.append("g").attr("transform", `translate(${x},${y})`);
+            group.append("rect")
+                .attr("x", -20).attr("y", -10)
+                .attr("width", 40).attr("height", 20)
+                .attr("rx", 10).attr("ry", 10) // 胶囊形状
+                .attr("fill", "#fff")
+                .attr("stroke", "#CBD5E1").attr("stroke-width", 1.2);
+            group.append("text")
+                .attr("class", "link-label")
+                .attr("x", 0).attr("y", 0).attr("dy", "0.32em")
+                .attr("text-anchor", "middle")
+                .text(text);
+        }
+
+        // 连线:第二层连线设计(U型配偶线 + 亲子水平线)
+        nodesHier.descendants().forEach(node => {
+            if (!node.children || node.children.length === 0) return;
+            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 num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
+
+            // 夫妻关系: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, "配偶");
+            }
+
+            if (realChildren.length === 0) return;
+            
+            const childrenY = realChildren[0].y;
+            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 sibsY = childrenY - 55; // 第二层:子女上方的水平分支线(拉高一点,给徽章留足空间)
+
+            const g = svg.append("g").attr("class", "link-group");
+            
+            // 主线:从配偶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)}`;
+            
+            // 短竖线:连到每个子女顶部
+            realChildren.forEach(child => {
+                pathD += ` M${num(child.x)},${num(sibsY)} L${num(child.x)},${num(child.y) - circleR}`;
+            });
+            g.append("path").attr("class", "link link-parent-child").attr("d", pathD);
+
+            // 去除家谱根源到第一层的关系展示;下层亲子关系标记使用徽章式标签放在短竖线上,一目了然
+            const isRootToFirst = !node.parent || !(node.data && node.data.id);
+            if (!isRootToFirst && node.data) {
+                realChildren.forEach(child => {
+                    const pSex = node.data.sex;
+                    const cSex = child.data && child.data.sex;
+                    let label = "亲子";
+                    if (pSex === 1) label = cSex === 1 ? "父子" : "父女";
+                    else if (pSex === 2) label = cSex === 1 ? "母子" : "母女";
+                    
+                    const childLinkMidY = (sibsY + child.y - circleR) / 2;
+                    addBadge(g, child.x, childLinkMidY, label);
+                });
+            }
+        });
+
+        // 兄弟/姐妹:上方 U 型连线避免穿透节点
+        const idToPos = {};
+        nodesHier.descendants().forEach(d => { if (d.data.id) idToPos[d.data.id] = { x: d.x, y: d.y }; });
+        otherLinks.forEach(rel => {
+            const s = idToPos[rel.parent_mid], t = idToPos[rel.child_mid];
+            if (s && t) {
+                const x1 = Math.min(s.x, t.x), x2 = Math.max(s.x, t.x);
+                const y = s.y - circleR - 25; // 兄弟线在节点上方
+                const g = svg.append("g");
+                const pathD = `M${x1},${s.y - circleR} L${x1},${y} L${x2},${y} L${x2},${t.y - circleR}`;
+                g.append("path").attr("class", "link link-sibling").attr("d", pathD);
+                addBadge(g, (x1 + x2) / 2, y, rel.relation_type === 11 ? "兄弟" : "姐妹");
+            }
+        });
+
+        const node = svg.selectAll(".node")
+            .data(nodesHier.descendants())
+            .enter().append("g")
+            .attr("class", d => {
+                let cls = "node" + (d.children ? " node--internal" : " node--leaf");
+                if (d.data.sex === 1) cls += " node-male";
+                else if (d.data.sex === 2) cls += " node-female";
+                return cls;
+            })
+            .attr("transform", d => `translate(${d.x},${d.y})`)
+            .on("contextmenu", function(event, d) {
+                if (!d.data.id) return;
+                event.preventDefault();
+                selectedMid = d.data.id;
+                const containerRect = document.getElementById('tree-container').getBoundingClientRect();
+                contextMenu.style.display = 'block';
+                contextMenu.style.left = (event.clientX - containerRect.left) + 'px';
+                contextMenu.style.top = (event.clientY - containerRect.top) + 'px';
+            })
+            .call(d3.drag()
+                .on("start", dragstarted)
+                .on("drag", dragged)
+                .on("end", dragended));
+
+        // 图形:男性方形,女性圆形,更符合生物遗传图谱(不再需要性别符号)
+        node.each(function(d) {
+            const el = d3.select(this);
+            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 { // 女性为圆形(未知性别默认圆)
+                el.append("circle")
+                  .attr("r", circleR)
+                  .style("cursor", "grab");
+            }
+        });
+        // 人名:严格在图形下方,与图形底边留出间距,长名截断+悬停显示全名
+        const nameOffsetY = circleR + 20;
+        const maxNameLen = 8;
+        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)
+            const textNode = g.append("text")
+                .attr("class", "node-name")
+                .attr("x", 0).attr("y", 0)
+                .attr("text-anchor", "middle")
+                .style("pointer-events", "all")
+                .style("cursor", "default")
+                .text(disp);
+                
+            if (full.length > maxNameLen) {
+                textNode.append("title").text(full);
+            }
+        });
+
+        function dragstarted(event, d) {
+            if (!d.data.id) return;
+            d3.select(this).raise().classed("active", true);
+            d._currentX = d.x; d._currentY = d.y;
+            dragSource = d.data;
+        }
+
+        function dragged(event, d) {
+            d._currentX += event.dx; d._currentY += event.dy;
+            d3.select(this).attr("transform", `translate(${d._currentX},${d._currentY})`);
+        }
+
+        function dragended(event, d) {
+            d3.select(this).classed("active", false);
+            const mouseX = event.sourceEvent.clientX, mouseY = event.sourceEvent.clientY;
+            let foundNode = null;
+            svg.selectAll(".node").each(function(nodeData) {
+                if (!nodeData.data || nodeData.data.id === d.data.id || !nodeData.data.id) return;
+                const rect = this.getBoundingClientRect();
+                if (mouseX >= rect.left && mouseX <= rect.right && mouseY >= rect.top && mouseY <= rect.bottom) foundNode = nodeData.data;
+            });
+            if (foundNode && foundNode.id) {
+                dragTarget = foundNode;
+                document.getElementById('sourceMid').value = dragSource.id;
+                document.getElementById('targetMid').value = dragTarget.id;
+                document.getElementById('relationInfo').innerHTML = `确认将 <strong>${dragSource.name}</strong> 设定为 <strong>${dragTarget.name}</strong> 的关系人?`;
+                relationModal.show();
+            }
+            setTimeout(() => { renderTree(currentData); }, 100);
+        }
+        } catch (err) {
+            console.error('renderTree error:', err);
+            container.innerHTML = '<div class="h-100 d-flex align-items-center justify-content-center text-danger small">关系图渲染出错,请刷新重试。<br>' + (err.message || '') + '</div>';
+        }
+    }
+
+    function saveRelation() {
+        const payload = {
+            source_mid: document.getElementById('sourceMid').value,
+            target_mid: document.getElementById('targetMid').value,
+            relation_type: document.getElementById('relType').value,
+            sub_relation_type: document.getElementById('subRelType').value
+        };
+        fetch('/manager/api/save_relation', {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify(payload)
+        }).then(res => res.json()).then(data => {
+            if (data.success) { relationModal.hide(); loadTree(); }
+            else { alert('保存失败: ' + data.message); }
+        });
+    }
+</script>
+{% endblock %}

+ 51 - 0
templates/upload.html

@@ -0,0 +1,51 @@
+{% extends "layout.html" %}
+
+{% block title %}上传文件 - 家谱管理系统{% endblock %}
+
+{% block content %}
+<div class="row justify-content-center">
+    <div class="col-md-8">
+        <div class="card shadow">
+            <div class="card-header bg-primary text-white">
+                <h5 class="mb-0"><i class="bi bi-cloud-upload me-2"></i>上传家谱扫描件</h5>
+            </div>
+            <div class="card-body p-4">
+                <form method="POST" enctype="multipart/form-data">
+                    <div class="mb-4">
+                        <label class="form-label fw-bold">选择文件</label>
+                        <input type="file" name="file" class="form-control form-control-lg" required>
+                        <div class="form-text mt-2">
+                            支持图片 (JPG, PNG) 或 PDF 格式的扫描件。上传后将自动识别页码。
+                        </div>
+                    </div>
+
+                    <div class="mb-4">
+                        <label class="form-label fw-bold">手动指定页码 (可选)</label>
+                        <div class="input-group">
+                            <input type="number" name="manual_page" id="initialPage" class="form-control" placeholder="如不输入则由 OCR 自动识别">
+                            <button class="btn btn-outline-secondary" type="button" onclick="document.getElementById('initialPage').value = {{ suggested_page }}">
+                                提示:第 {{ suggested_page }} 页?
+                            </button>
+                        </div>
+                        <div class="form-text mt-1 text-muted">
+                            建议值为当前数据库最大页码 + 1。
+                        </div>
+                    </div>
+                    
+                    <div class="alert alert-warning mb-4">
+                        <i class="bi bi-info-circle me-2"></i>
+                        提示:文件将上传至云端 OSS 存储,处理过程可能需要几秒钟。
+                    </div>
+
+                    <div class="d-grid gap-2 d-md-flex justify-content-md-end">
+                        <a href="{{ url_for('index') }}" class="btn btn-light px-4">取消</a>
+                        <button type="submit" class="btn btn-primary px-5">
+                            <i class="bi bi-check-lg me-1"></i> 开始上传
+                        </button>
+                    </div>
+                </form>
+            </div>
+        </div>
+    </div>
+</div>
+{% endblock %}

+ 63 - 0
test_ai_stream.py

@@ -0,0 +1,63 @@
+import requests
+import json
+import os
+
+api_key = "a1800657-9212-4afe-9b7c-b49f015c54d3"
+api_url = "https://ark.cn-beijing.volces.com/api/v3/responses"
+
+prompt = "你看见了什么?"
+image_url = "https://ark-project.tos-cn-beijing.volces.com/doc_image/ark_demo_img_1.png"
+
+payload = {
+    "model": "doubao-seed-1-8-251228",
+    "stream": True,
+    "input": [
+        {
+            "role": "user",
+            "content": [
+                {
+                    "type": "input_image",
+                    "image_url": image_url
+                },
+                {
+                    "type": "input_text",
+                    "text": prompt
+                }
+            ]
+        }
+    ]
+}
+
+headers = {
+    "Authorization": f"Bearer {api_key}",
+    "Content-Type": "application/json"
+}
+
+print("开始请求 AI 接口...")
+try:
+    with requests.post(api_url, json=payload, headers=headers, stream=True, timeout=30) as r:
+        print(f"状态码: {r.status_code}")
+        if r.status_code != 200:
+            print(f"错误内容: {r.text}")
+        else:
+            print("开始接收流式数据:")
+            for line in r.iter_lines():
+                if line:
+                    line_str = line.decode('utf-8')
+                    print(f"收到行: {line_str}")
+                    if line_str.startswith('data: '):
+                        json_str = line_str[6:]
+                        if json_str.strip() == '[DONE]':
+                            print("流结束")
+                            break
+                        try:
+                            chunk = json.loads(json_str)
+                            if 'choices' in chunk and len(chunk['choices']) > 0:
+                                delta = chunk['choices'][0].get('delta', {})
+                                if 'content' in delta:
+                                    print(f"内容片段: {delta['content']}", end="", flush=True)
+                        except Exception as e:
+                            print(f"\n解析错误: {e}")
+            print("\n请求完成")
+except Exception as e:
+    print(f"请求发生异常: {e}")

+ 47 - 0
update_db_ai.py

@@ -0,0 +1,47 @@
+import pymysql
+
+DB_CONFIG = {
+    "host": "rm-f8ze60yirdj8786u2.mysql.rds.aliyuncs.com",
+    "port": 3306,
+    "user": "root",
+    "password": "csqz@20255",
+    "db": "csqz-client",
+    "charset": "utf8mb4",
+    "cursorclass": pymysql.cursors.DictCursor
+}
+
+def update_db():
+    conn = pymysql.connect(**DB_CONFIG)
+    try:
+        with conn.cursor() as cursor:
+            # 1. Update genealogy_records
+            print("Updating genealogy_records table...")
+            try:
+                cursor.execute("ALTER TABLE genealogy_records ADD COLUMN ai_status INT DEFAULT 0 COMMENT '0:未处理, 1:处理中, 2:成功, 3:失败'")
+                print("Added ai_status column.")
+            except Exception as e:
+                print(f"Skipping ai_status: {e}")
+
+            try:
+                cursor.execute("ALTER TABLE genealogy_records ADD COLUMN ai_result LONGTEXT COMMENT 'AI解析结果JSON'")
+                print("Added ai_result column.")
+            except Exception as e:
+                print(f"Skipping ai_result: {e}")
+
+            # 2. Update family_member_info
+            print("Updating family_member_info table...")
+            try:
+                cursor.execute("ALTER TABLE family_member_info ADD COLUMN source_record_id INT COMMENT '关联的家谱记录ID'")
+                print("Added source_record_id column.")
+            except Exception as e:
+                print(f"Skipping source_record_id: {e}")
+
+        conn.commit()
+        print("Database update complete.")
+    except Exception as e:
+        print(f"Error updating database: {e}")
+    finally:
+        conn.close()
+
+if __name__ == "__main__":
+    update_db()

+ 59 - 0
update_db_schema.py

@@ -0,0 +1,59 @@
+import pymysql
+import sys
+
+# 数据库配置
+DB_CONFIG = {
+    "host": "m-f8ze60yirdj8786u2.mysql.rds.aliyuncs.com",
+    "port": 3306,
+    "user": "root",
+    "password": "csqz@20255",
+    "db": "csqz-client",
+    "charset": "utf8mb4",
+    "cursorclass": pymysql.cursors.DictCursor
+}
+
+def update_schema():
+    conn = pymysql.connect(**DB_CONFIG)
+    try:
+        with conn.cursor() as cursor:
+            # 1. Add family_rank (堂内排行)
+            try:
+                cursor.execute("ALTER TABLE family_member_info ADD COLUMN family_rank VARCHAR(50) DEFAULT NULL COMMENT '堂内排行'")
+                print("Added column: family_rank")
+            except pymysql.err.OperationalError as e:
+                if e.args[0] != 1060: # Duplicate column name
+                    raise e
+                print("Column family_rank already exists.")
+
+            # 2. Add tags (标签)
+            try:
+                cursor.execute("ALTER TABLE family_member_info ADD COLUMN tags VARCHAR(255) DEFAULT NULL COMMENT '标签'")
+                print("Added column: tags")
+            except pymysql.err.OperationalError as e:
+                if e.args[0] != 1060:
+                    raise e
+                print("Column tags already exists.")
+
+            # 3. Add notes (人员备注)
+            try:
+                cursor.execute("ALTER TABLE family_member_info ADD COLUMN notes TEXT DEFAULT NULL COMMENT '人员备注'")
+                print("Added column: notes")
+            except pymysql.err.OperationalError as e:
+                if e.args[0] != 1060:
+                    raise e
+                print("Column notes already exists.")
+
+        conn.commit()
+        print("Schema update completed successfully.")
+        return True
+    except Exception as e:
+        print(f"Error updating schema: {e}")
+        return False
+    finally:
+        conn.close()
+
+if __name__ == "__main__":
+    if update_schema():
+        sys.exit(0)
+    else:
+        sys.exit(1)

二進制
uploads/1.png


二進制
uploads/2.png


二進制
uploads/20260302110449_225_93.jpg


二進制
uploads/3.png


部分文件因文件數量過多而無法顯示