Browse Source

优化逻辑

Hai Lin 3 weeks ago
parent
commit
6da91fcd95
8 changed files with 771 additions and 24 deletions
  1. 47 0
      add_existing_pdf.py
  2. 336 8
      app.py
  3. 3 5
      templates/index.html
  4. 4 1
      templates/layout.html
  5. 213 0
      templates/pdf_management.html
  6. 91 2
      templates/tree.html
  7. 24 8
      templates/tree_classic.html
  8. 53 0
      update_pdf_schema.py

+ 47 - 0
add_existing_pdf.py

@@ -0,0 +1,47 @@
+import pymysql
+
+# Database connection
+DB_CONFIG = {
+    "host": "rm-f8ze60yirdj8786u2wo.mysql.rds.aliyuncs.com",
+    "user": "root",
+    "password": "csqz@20255",
+    "db": "csqz-client",
+    "charset": "utf8mb4",
+    "cursorclass": pymysql.cursors.DictCursor
+}
+
+def add_existing_pdf():
+    """Add existing PDF to genealogy_pdfs table"""
+    conn = pymysql.connect(**DB_CONFIG)
+    try:
+        with conn.cursor() as cursor:
+            # Check if the PDF already exists
+            cursor.execute("SELECT * FROM genealogy_pdfs WHERE file_name LIKE %s", ("%留总-正式-卷四齿录%",))
+            existing = cursor.fetchone()
+            
+            if existing:
+                print("PDF already exists in the database")
+                return
+            
+            # Add the PDF entry
+            pdf_file_name = "留总-正式-卷四齿录_留总收藏.pdf"
+            # Create a placeholder URL based on the image URLs
+            placeholder_url = "https://file.chunsunqiuzhu.com/data/2026/03/31/留总-正式-卷四齿录_留总收藏.pdf"
+            uploader = "留总"
+            
+            cursor.execute(
+                "INSERT INTO genealogy_pdfs (file_name, oss_url, uploader) VALUES (%s, %s, %s)",
+                (pdf_file_name, placeholder_url, uploader)
+            )
+            
+            conn.commit()
+            print(f"PDF '{pdf_file_name}' added to genealogy_pdfs table")
+            
+    except Exception as e:
+        print(f"Error: {e}")
+        conn.rollback()
+    finally:
+        conn.close()
+
+if __name__ == "__main__":
+    add_existing_pdf()

+ 336 - 8
app.py

@@ -95,6 +95,92 @@ def manual_simplify(text):
         result += mapping.get(char, char)
     return result
 
+def _build_reverse_simplify_map():
+    """
+    Build a reverse map from simplified char -> list of traditional chars
+    based on the fallback manual_simplify mapping.
+    """
+    mapping = {
+        '學': '学', '國': '国', '萬': '万', '寶': '宝', '興': '兴',
+        '華': '华', '會': '会', '葉': '叶', '藝': '艺', '號': '号',
+        '處': '处', '見': '见', '視': '视', '言': '言', '語': '语',
+        '貝': '贝', '車': '车', '長': '长', '門': '门', '韋': '韦',
+        '頁': '页', '風': '风', '飛': '飞', '食': '食', '馬': '马',
+        '魚': '鱼', '鳥': '鸟', '麥': '麦', '黃': '黄', '齊': '齐',
+        '齒': '齿', '龍': '龙', '龜': '龟', '壽': '寿', '榮': '荣',
+        '愛': '爱', '慶': '庆', '衛': '卫', '賢': '贤', '義': '义',
+        '禮': '礼', '樂': '乐', '靈': '灵', '滅': '灭', '氣': '气',
+        '智': '智', '信': '信', '仁': '仁', '勇': '勇', '嚴': '严',
+        '銳': '锐', '優': '优', '楊': '杨', '吳': '吴', '銀': '银'
+    }
+    rev = {}
+    for trad, simp in mapping.items():
+        rev.setdefault(simp, [])
+        if trad not in rev[simp]:
+            rev[simp].append(trad)
+    return rev
+
+_REVERSE_SIMPLIFY_MAP = _build_reverse_simplify_map()
+
+def expand_name_search_variants(keyword, max_variants=60):
+    """
+    Expand keyword into a small set of variants so Simplified/Traditional
+    searches can match both `name` and `simplified_name`.
+
+    - Always includes original keyword
+    - Includes fallback-trad->simp conversion
+    - Includes best-effort simp->trad expansions based on reverse map
+    """
+    if not keyword:
+        return []
+    kw = str(keyword).strip()
+    if not kw:
+        return []
+
+    variants = set([kw])
+    variants.add(manual_simplify(kw))
+
+    # Build possible traditional variants when the input is simplified.
+    # For each char, if we have traditional candidates, branch; otherwise keep itself.
+    choices = []
+    for ch in kw:
+        cand = _REVERSE_SIMPLIFY_MAP.get(ch)
+        if cand:
+            # include itself too (covers already-traditional or neutral chars)
+            choices.append([ch] + cand)
+        else:
+            choices.append([ch])
+
+    # Cartesian product with early stop.
+    results = ['']
+    for opts in choices:
+        new_results = []
+        for prefix in results:
+            for opt in opts:
+                new_results.append(prefix + opt)
+                if len(new_results) >= max_variants:
+                    break
+            if len(new_results) >= max_variants:
+                break
+        results = new_results
+        if len(results) >= max_variants:
+            break
+
+    for r in results:
+        if r:
+            variants.add(r)
+            variants.add(manual_simplify(r))
+
+    # Keep deterministic order for stable SQL params
+    ordered = []
+    for v in variants:
+        v2 = (v or '').strip()
+        if v2 and v2 not in ordered:
+            ordered.append(v2)
+        if len(ordered) >= max_variants:
+            break
+    return ordered
+
 def clean_name(name):
     """
     Clean name according to Liu family genealogy rules:
@@ -450,6 +536,66 @@ def process_ai_task(record_id, image_url):
         conn.close()
         print(f"[AI Task] Task finished for record {record_id}")
 
+def ensure_pdf_table():
+    conn = get_db_connection()
+    try:
+        with conn.cursor() as cursor:
+            cursor.execute("""
+                CREATE TABLE IF NOT EXISTS genealogy_pdfs (
+                    id INT AUTO_INCREMENT PRIMARY KEY,
+                    file_name VARCHAR(255) NOT NULL,
+                    oss_url TEXT NOT NULL,
+                    description VARCHAR(500) DEFAULT '',
+                    upload_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+                    uploader VARCHAR(100) DEFAULT ''
+                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
+            """)
+        conn.commit()
+    finally:
+        conn.close()
+
+@app.route('/manager/pdf_management')
+def pdf_management():
+    if 'user_id' not in session:
+        return redirect(url_for('login'))
+
+    ensure_pdf_table()
+    view_id = request.args.get('view', type=int)
+    selected_pdf = None
+
+    conn = get_db_connection()
+    try:
+        with conn.cursor() as cursor:
+            cursor.execute("SELECT * FROM genealogy_pdfs ORDER BY upload_time DESC")
+            pdfs = cursor.fetchall()
+            if view_id:
+                cursor.execute("SELECT * FROM genealogy_pdfs WHERE id = %s", (view_id,))
+                selected_pdf = cursor.fetchone()
+            elif pdfs:
+                selected_pdf = pdfs[0]
+    finally:
+        conn.close()
+
+    return render_template('pdf_management.html', pdfs=pdfs, selected_pdf=selected_pdf)
+
+@app.route('/manager/delete_pdf/<int:pdf_id>', methods=['POST'])
+def delete_pdf(pdf_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_pdfs WHERE id = %s", (pdf_id,))
+        conn.commit()
+        flash('PDF文件记录已删除')
+    except Exception as e:
+        flash(f'删除失败: {e}')
+    finally:
+        conn.close()
+
+    return redirect(url_for('pdf_management'))
+
 @app.route('/manager/')
 def index():
     if 'user_id' not in session:
@@ -515,7 +661,17 @@ def members():
         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}%",))
+                variants = expand_name_search_variants(search_name)
+                where_parts = []
+                params = []
+                for v in variants:
+                    where_parts.append("(name LIKE %s OR simplified_name LIKE %s)")
+                    like = f"%{v}%"
+                    params.extend([like, like])
+                where_clause = " OR ".join(where_parts) if where_parts else "name LIKE %s"
+                if not where_parts:
+                    params = [f"%{search_name}%"]
+                cursor.execute(f"SELECT COUNT(*) as count FROM family_member_info WHERE {where_clause}", tuple(params))
             else:
                 cursor.execute("SELECT COUNT(*) as count FROM family_member_info")
             
@@ -528,8 +684,19 @@ def members():
             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))
+                variants = expand_name_search_variants(search_name)
+                where_parts = []
+                params = []
+                for v in variants:
+                    where_parts.append("(name LIKE %s OR simplified_name LIKE %s)")
+                    like = f"%{v}%"
+                    params.extend([like, like])
+                where_clause = " OR ".join(where_parts) if where_parts else "(name LIKE %s OR simplified_name LIKE %s)"
+                if not where_parts:
+                    like = f"%{search_name}%"
+                    params = [like, like]
+                sql = f"SELECT * FROM family_member_info WHERE {where_clause} {order_clause} LIMIT %s OFFSET %s"
+                cursor.execute(sql, tuple(params + [per_page, offset]))
             else:
                 sql = f"SELECT * FROM family_member_info {order_clause} LIMIT %s OFFSET %s"
                 cursor.execute(sql, (per_page, offset))
@@ -1360,16 +1527,49 @@ def start_analysis(record_id):
 
 def process_files_background(upload_folder, saved_files, manual_page, suggested_page, genealogy_version, genealogy_source, upload_person):
     current_suggested_page = int(manual_page) if manual_page and str(manual_page).isdigit() else suggested_page
-    
+    ensure_pdf_table()
+
     for item in saved_files:
-        if len(item) == 3:
+        if len(item) >= 4:
+            filename, file_path, file_page, original_filename = item[0], item[1], item[2], item[3]
+        elif len(item) == 3:
             filename, file_path, file_page = item
+            original_filename = filename
         else:
-            filename, file_path = item
+            filename, file_path = item[0], item[1]
             file_page = None
-            
+            original_filename = filename
+
         try:
             if filename.lower().endswith('.pdf'):
+                import uuid
+                display_pdf_name = (original_filename or filename).strip() or filename
+                oss_pdf_name = secure_filename(display_pdf_name)
+                if not oss_pdf_name or not oss_pdf_name.lower().endswith('.pdf'):
+                    oss_pdf_name = f"genealogy_pdf_{uuid.uuid4().hex[:8]}.pdf"
+                pdf_oss_url = upload_to_oss(file_path, custom_filename=oss_pdf_name)
+                if pdf_oss_url:
+                    desc_parts = []
+                    if genealogy_version:
+                        desc_parts.append(genealogy_version)
+                    if genealogy_source:
+                        desc_parts.append(genealogy_source)
+                    pdf_description = ' · '.join(desc_parts) if desc_parts else ''
+                    conn_pdf = get_db_connection()
+                    try:
+                        with conn_pdf.cursor() as cursor:
+                            cursor.execute(
+                                "INSERT INTO genealogy_pdfs (file_name, oss_url, description, uploader) VALUES (%s, %s, %s, %s)",
+                                (display_pdf_name, pdf_oss_url, pdf_description, upload_person or '')
+                            )
+                        conn_pdf.commit()
+                    except Exception as pdf_meta_e:
+                        print(f"Error inserting genealogy_pdfs for {display_pdf_name}: {pdf_meta_e}")
+                    finally:
+                        conn_pdf.close()
+                else:
+                    print(f"Warning: full PDF upload to OSS failed for {filename}, scan pages will still be processed.")
+
                 doc = fitz.open(file_path)
                 for page_index in range(len(doc)):
                     img_path = None
@@ -1533,7 +1733,7 @@ def upload():
             
             # Fetch individual page number if it exists
             file_page = request.form.get(f'page_number_{i}')
-            saved_files.append((filename, file_path, file_page))
+            saved_files.append((filename, file_path, file_page, original_filename))
             
         if saved_files:
             threading.Thread(
@@ -1603,5 +1803,133 @@ def delete_upload(record_id):
     finally:
         conn.close()
 
+@app.route('/manager/upload_pdf', methods=['POST'])
+def upload_pdf():
+    if 'user_id' not in session:
+        return jsonify({"success": False, "message": "Unauthorized"}), 401
+    
+    if 'file' not in request.files:
+        return jsonify({"success": False, "message": "未选择文件"}), 400
+    
+    file = request.files['file']
+    if file.filename == '':
+        return jsonify({"success": False, "message": "未选择文件"}), 400
+    
+    if not file.filename.lower().endswith('.pdf'):
+        return jsonify({"success": False, "message": "请上传PDF文件"}), 400
+    
+    import uuid
+    original_filename = file.filename
+    ext = os.path.splitext(original_filename)[1].lower()
+    base_name = secure_filename(original_filename)
+    
+    if not base_name or base_name == ext.strip('.'):
+        filename = f"genealogy_pdf_{uuid.uuid4().hex[:8]}{ext}"
+    else:
+        if not base_name.lower().endswith(ext):
+            filename = f"{base_name}{ext}"
+        else:
+            filename = base_name
+    
+    file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
+    file.save(file_path)
+    
+    try:
+        # Upload to OSS
+        oss_url = upload_to_oss(file_path, custom_filename=filename)
+        if not oss_url:
+            return jsonify({"success": False, "message": "文件上传失败"}), 500
+        
+        # Get form data
+        uploader = request.form.get('uploader', session.get('username', ''))
+        version_name = request.form.get('version_name', '')
+        version_source = request.form.get('version_source', '')
+        file_provider = request.form.get('file_provider', uploader)
+        
+        # Save to database
+        conn = get_db_connection()
+        try:
+            with conn.cursor() as cursor:
+                cursor.execute(
+                    "INSERT INTO genealogy_pdfs (file_name, oss_url, uploader, version_name, version_source, file_provider) VALUES (%s, %s, %s, %s, %s, %s)",
+                    (original_filename, oss_url, uploader, version_name, version_source, file_provider)
+                )
+            conn.commit()
+            
+            # Start background processing for PDF pages
+            threading.Thread(
+                target=process_pdf_pages,
+                args=(file_path, oss_url, uploader)
+            ).start()
+            
+            return jsonify({"success": True, "message": "PDF文件上传成功,正在解析页面"})
+        except Exception as e:
+            return jsonify({"success": False, "message": f"保存失败: {e}"}), 500
+        finally:
+            conn.close()
+    finally:
+        if os.path.exists(file_path):
+            try:
+                os.remove(file_path)
+            except:
+                pass
+
+def process_pdf_pages(file_path, pdf_oss_url, uploader):
+    """Process PDF pages and add them to genealogy records"""
+    try:
+        import fitz
+        doc = fitz.open(file_path)
+        
+        # Get current max page number
+        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()
+        
+        for page_index in range(len(doc)):
+            try:
+                page = doc[page_index]
+                pix = page.get_pixmap(dpi=150)
+                
+                # Save as image
+                img_filename = f"{os.path.splitext(os.path.basename(file_path))[0]}_page_{page_index+1}.jpg"
+                img_path = os.path.join(app.config['UPLOAD_FOLDER'], img_filename)
+                pix.save(img_path)
+                
+                # Upload to OSS
+                img_oss_url = upload_to_oss(img_path, custom_filename=img_filename)
+                if img_oss_url:
+                    # Save to genealogy_records
+                    conn = get_db_connection()
+                    try:
+                        with conn.cursor() as cursor:
+                            cursor.execute(
+                                "INSERT INTO genealogy_records (file_name, oss_url, page_number, ai_status, upload_person, file_type) VALUES (%s, %s, %s, 1, %s, %s)",
+                                (img_filename, img_oss_url, suggested_page + page_index, uploader, '图片')
+                            )
+                            record_id = cursor.lastrowid
+                        conn.commit()
+                        
+                        # Start AI processing
+                        threading.Thread(target=process_ai_task, args=(record_id, img_oss_url)).start()
+                    finally:
+                        conn.close()
+            except Exception as e:
+                print(f"Error processing page {page_index+1}: {e}")
+            finally:
+                if 'img_path' in locals() and os.path.exists(img_path):
+                    try:
+                        os.remove(img_path)
+                    except:
+                        pass
+    except Exception as e:
+        print(f"Error processing PDF: {e}")
+
 if __name__ == '__main__':
     app.run(debug=False, port=5001)

+ 3 - 5
templates/index.html

@@ -1,6 +1,6 @@
 {% extends "layout.html" %}
 
-{% block title %}上传管理 - 家谱管理系统{% endblock %}
+{% block title %}扫描件管理 - 家谱管理系统{% endblock %}
 
 {% block extra_css %}
 <style>
@@ -86,10 +86,8 @@
 
 {% 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>
+    <h2><i class="bi bi-file-earmark-arrow-up"></i> 扫描件管理</h2>
+
 </div>
 
 <div class="card shadow-sm mb-4">

+ 4 - 1
templates/layout.html

@@ -28,8 +28,11 @@
                     <h4>家谱管理</h4>
                 </div>
                 <nav>
+                    <a href="{{ url_for('pdf_management') }}" class="{% if request.endpoint == 'pdf_management' %}active{% endif %}">
+                        <i class="bi bi-book me-2"></i> 家谱管理
+                    </a>
                     <a href="{{ url_for('index') }}" class="{% if request.endpoint == 'index' %}active{% endif %}">
-                        <i class="bi bi-file-earmark-arrow-up me-2"></i> 家谱管理
+                        <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> 成员列表

+ 213 - 0
templates/pdf_management.html

@@ -0,0 +1,213 @@
+{% extends "layout.html" %}
+
+{% block title %}家谱管理 - 家谱管理系统{% endblock %}
+
+{% block extra_css %}
+<style>
+    .pdf-card {
+        cursor: pointer;
+        transition: all 0.3s ease;
+        border: 2px solid transparent;
+        border-radius: 8px;
+    }
+    .pdf-card:hover {
+        border-color: #0d6efd;
+        box-shadow: 0 4px 16px rgba(13,110,253,0.18);
+        transform: translateY(-2px);
+    }
+    .pdf-card.active {
+        border-color: #0d6efd;
+        background-color: #f0f6ff;
+    }
+    .pdf-card .card-body {
+        padding: 16px;
+    }
+    .pdf-card-icon {
+        font-size: 1.8rem;
+        color: #dc3545;
+    }
+    .pdf-card-title {
+        font-size: 0.95rem;
+        font-weight: 600;
+        line-height: 1.3;
+        margin-bottom: 8px;
+    }
+    .pdf-card-meta {
+        font-size: 0.75rem;
+        color: #6c757d;
+        line-height: 1.4;
+    }
+    .pdf-card-meta-item {
+        display: flex;
+        align-items: center;
+        margin-bottom: 4px;
+    }
+    .pdf-card-meta-item i {
+        font-size: 0.65rem;
+        margin-right: 6px;
+        width: 14px;
+        text-align: center;
+    }
+    .pdf-viewer-wrapper {
+        background: #e9ecef;
+        border-radius: 8px;
+        overflow: hidden;
+        min-height: 80vh;
+    }
+    .pdf-viewer-wrapper iframe,
+    .pdf-viewer-wrapper embed {
+        width: 100%;
+        min-height: 80vh;
+        border: none;
+    }
+    .pdf-list-scroll {
+        max-height: 300px;
+        overflow-y: auto;
+    }
+    .pdf-detail-meta {
+        display: flex;
+        flex-wrap: wrap;
+        gap: 16px;
+        margin-top: 8px;
+        padding-top: 12px;
+        border-top: 1px solid #e9ecef;
+    }
+    .pdf-detail-meta-item {
+        display: flex;
+        align-items: center;
+        font-size: 0.85rem;
+    }
+    .pdf-detail-meta-item i {
+        color: #6c757d;
+        margin-right: 8px;
+    }
+</style>
+{% endblock %}
+
+{% block content %}
+<div class="d-flex justify-content-between align-items-center mb-4">
+    <h2><i class="bi bi-book"></i> 家谱管理</h2>
+</div>
+
+{% if pdfs %}
+<div class="card shadow-sm mb-4">
+    <div class="card-header bg-white py-2">
+        <span class="fw-bold small text-muted">已上传家谱文件({{ pdfs|length }} 个)</span>
+    </div>
+    <div class="card-body p-2 pdf-list-scroll">
+        <div class="row g-2">
+            {% for pdf in pdfs %}
+            <div class="col-md-3 col-sm-6">
+                <div class="card pdf-card {{ 'active' if selected_pdf and selected_pdf.id == pdf.id }}"
+                     onclick="window.location.href='{{ url_for('pdf_management', view=pdf.id) }}'">
+                    <div class="card-body">
+                        <div class="d-flex align-items-start">
+                            <i class="bi bi-file-earmark-pdf pdf-card-icon me-3 flex-shrink-0"></i>
+                            <div class="flex-grow-1">
+                                <div class="pdf-card-title text-truncate" title="{{ pdf.file_name }}">{{ pdf.file_name }}</div>
+                                <div class="pdf-card-meta">
+                                    {% if pdf.version_name %}
+                                    <div class="pdf-card-meta-item">
+                                        <i class="bi bi-tag"></i>
+                                        <span>{{ pdf.version_name }}</span>
+                                    </div>
+                                    {% endif %}
+                                    {% if pdf.version_source %}
+                                    <div class="pdf-card-meta-item">
+                                        <i class="bi bi-building"></i>
+                                        <span>{{ pdf.version_source }}</span>
+                                    </div>
+                                    {% endif %}
+                                    {% if pdf.file_provider %}
+                                    <div class="pdf-card-meta-item">
+                                        <i class="bi bi-person"></i>
+                                        <span>{{ pdf.file_provider }}</span>
+                                    </div>
+                                    {% endif %}
+                                    <div class="pdf-card-meta-item">
+                                        <i class="bi bi-calendar"></i>
+                                        <span>{{ pdf.upload_time.strftime('%Y-%m-%d') if pdf.upload_time else '未知' }}</span>
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+            {% endfor %}
+        </div>
+    </div>
+</div>
+{% endif %}
+
+{% if selected_pdf %}
+<div class="card shadow-sm">
+    <div class="card-header bg-white py-3">
+        <div class="d-flex align-items-center mb-2">
+            <i class="bi bi-file-earmark-pdf text-danger me-3 fs-5"></i>
+            <h5 class="mb-0">{{ selected_pdf.file_name }}</h5>
+        </div>
+        <div class="pdf-detail-meta">
+            {% if selected_pdf.version_name %}
+            <div class="pdf-detail-meta-item">
+                <i class="bi bi-tag"></i>
+                <span><strong>版本名称:</strong>{{ selected_pdf.version_name }}</span>
+            </div>
+            {% endif %}
+            {% if selected_pdf.version_source %}
+            <div class="pdf-detail-meta-item">
+                <i class="bi bi-building"></i>
+                <span><strong>版本来源:</strong>{{ selected_pdf.version_source }}</span>
+            </div>
+            {% endif %}
+            {% if selected_pdf.file_provider %}
+            <div class="pdf-detail-meta-item">
+                <i class="bi bi-person"></i>
+                <span><strong>文件提供人:</strong>{{ selected_pdf.file_provider }}</span>
+            </div>
+            {% endif %}
+            <div class="pdf-detail-meta-item">
+                <i class="bi bi-calendar"></i>
+                <span><strong>上传时间:</strong>{{ selected_pdf.upload_time.strftime('%Y-%m-%d %H:%M') if selected_pdf.upload_time else '未知' }}</span>
+            </div>
+        </div>
+        <div class="d-flex gap-2 mt-3 justify-content-end">
+            <a href="{{ selected_pdf.oss_url }}" target="_blank" class="btn btn-sm btn-outline-primary">
+                <i class="bi bi-box-arrow-up-right"></i> 新窗口打开
+            </a>
+            <a href="{{ selected_pdf.oss_url }}" download class="btn btn-sm btn-outline-secondary">
+                <i class="bi bi-download"></i> 下载
+            </a>
+            <form action="{{ url_for('delete_pdf', pdf_id=selected_pdf.id) }}" method="POST" class="d-inline"
+                  onsubmit="return confirm('确定要删除此PDF文件吗?此操作无法撤销。');">
+                <button type="submit" class="btn btn-sm btn-outline-danger">
+                    <i class="bi bi-trash"></i> 删除
+                </button>
+            </form>
+        </div>
+    </div>
+    <div class="card-body p-0">
+        <div class="pdf-viewer-wrapper">
+            <iframe id="pdfViewer" src="{{ selected_pdf.oss_url }}" type="application/pdf"></iframe>
+        </div>
+    </div>
+</div>
+{% elif not pdfs %}
+<div class="card shadow-sm">
+    <div class="card-body text-center py-5 text-muted">
+        <i class="bi bi-file-earmark-pdf fs-1 d-block mb-3 text-secondary"></i>
+        <h5 class="text-secondary">暂无PDF家谱文件</h5>
+        <p class="mb-3">在扫描件管理中上传PDF文件后,会自动添加到此处。</p>
+    </div>
+</div>
+{% else %}
+<div class="card shadow-sm">
+    <div class="card-body text-center py-5 text-muted">
+        <i class="bi bi-hand-index-thumb fs-1 d-block mb-3"></i>
+        <h5 class="text-secondary">请从上方选择一个PDF文件进行查看</h5>
+    </div>
+</div>
+{% endif %}
+
+
+{% endblock %}

+ 91 - 2
templates/tree.html

@@ -13,8 +13,36 @@
         margin-top: 10px;
         box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
         position: relative;
-        overflow: auto;
-        scroll-behavior: smooth;
+        overflow: hidden;
+    }
+    
+    .zoom-controls {
+        position: absolute;
+        top: 10px;
+        right: 10px;
+        z-index: 100;
+        background: white;
+        border-radius: 4px;
+        box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+        padding: 5px;
+    }
+    
+    .zoom-btn {
+        display: block;
+        width: 30px;
+        height: 30px;
+        margin: 5px 0;
+        border: 1px solid #ddd;
+        border-radius: 4px;
+        background: white;
+        cursor: pointer;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+    }
+    
+    .zoom-btn:hover {
+        background: #f8f9fa;
     }
     
     #tree-container::-webkit-scrollbar {
@@ -113,6 +141,12 @@
     </div>
 
     <div id="tree-container">
+        <!-- 缩放控制 -->
+        <div class="zoom-controls">
+            <button class="zoom-btn" onclick="zoomIn()"><i class="bi bi-plus"></i></button>
+            <button class="zoom-btn" onclick="zoomOut()"><i class="bi bi-dash"></i></button>
+            <button class="zoom-btn" onclick="zoomReset()"><i class="bi bi-arrow-counterclockwise"></i></button>
+        </div>
         <!-- 右键菜单 -->
         <div id="contextMenu" class="context-menu">
             <div class="context-menu-item" onclick="menuAction('detail')"><i class="bi bi-eye"></i>查看成员</div>
@@ -193,6 +227,9 @@
     let dragSource = null;
     let dragTarget = null;
     let selectedMid = null; // 当前选中的成员 ID
+    let zoomScale = 1;
+    let zoomTransform = d3.zoomIdentity;
+    let zoomBehavior = d3.zoom().scaleExtent([0.1, 4]).on("zoom", zoomed);
     const relationModal = new bootstrap.Modal(document.getElementById('relationModal'));
     const contextMenu = document.getElementById('contextMenu');
 
@@ -341,8 +378,12 @@
         const svg = d3.select("#tree-container").append("svg")
             .attr("width", svgWidth)
             .attr("height", svgHeight)
+            .call(zoomBehavior)
             .append("g")
             .attr("transform", `translate(${offsetX},${margin.top})`);
+        
+        // 存储SVG元素引用,用于缩放操作
+        window.svgGroup = svg;
 
         // 节点圆圈半径(连线与节点共用),调大让图谱更清晰大气
         const circleR = 20;
@@ -566,5 +607,53 @@
             else { alert('保存失败: ' + data.message); }
         });
     }
+    
+    // 缩放函数
+    function zoomed(event) {
+        zoomTransform = event.transform;
+        zoomScale = event.transform.k;
+        if (window.svgGroup) {
+            window.svgGroup.attr("transform", event.transform);
+        }
+    }
+    
+    function zoomIn() {
+        if (zoomScale < 4) {
+            const newScale = zoomScale * 1.2;
+            const container = document.getElementById('tree-container');
+            const centerX = container.clientWidth / 2;
+            const centerY = container.clientHeight / 2;
+            
+            const svg = d3.select("#tree-container svg");
+            svg.transition().duration(300).call(
+                zoomBehavior.transform, 
+                d3.zoomIdentity.translate(centerX, centerY).scale(newScale).translate(-centerX, -centerY)
+            );
+        }
+    }
+    
+    function zoomOut() {
+        if (zoomScale > 0.1) {
+            const newScale = zoomScale / 1.2;
+            const container = document.getElementById('tree-container');
+            const centerX = container.clientWidth / 2;
+            const centerY = container.clientHeight / 2;
+            
+            const svg = d3.select("#tree-container svg");
+            svg.transition().duration(300).call(
+                zoomBehavior.transform, 
+                d3.zoomIdentity.translate(centerX, centerY).scale(newScale).translate(-centerX, -centerY)
+            );
+        }
+    }
+    
+    function zoomReset() {
+        const svg = d3.select("#tree-container svg");
+        svg.transition().duration(300).call(
+            zoomBehavior.transform, 
+            d3.zoomIdentity
+        );
+        zoomScale = 1;
+    }
 </script>
 {% endblock %}

+ 24 - 8
templates/tree_classic.html

@@ -245,20 +245,34 @@
         let selectedRootIdsSet = new Set();
         let showChartSelectors = true; // 图中快速勾选框显示开关
 
+        const TRAD_TO_SIMP = {
+            '學':'学','國':'国','萬':'万','寶':'宝','興':'兴','華':'华','會':'会',
+            '葉':'叶','藝':'艺','號':'号','處':'处','見':'见','視':'视','語':'语',
+            '貝':'贝','車':'车','長':'长','門':'门','韋':'韦','頁':'页','風':'风',
+            '飛':'飞','馬':'马','魚':'鱼','鳥':'鸟','麥':'麦','黃':'黄','齊':'齐',
+            '齒':'齿','龍':'龙','龜':'龟','壽':'寿','榮':'荣','愛':'爱','慶':'庆',
+            '衛':'卫','賢':'贤','義':'义','禮':'礼','樂':'乐','靈':'灵','滅':'灭',
+            '氣':'气','嚴':'严','銳':'锐','優':'优','楊':'杨','吳':'吴','銀':'银',
+            '劉':'刘','陳':'陈','張':'张','趙':'赵','鄭':'郑','錢':'钱','許':'许',
+            '鄧':'邓','蕭':'萧','謝':'谢','鍾':'钟','盧':'卢','譚':'谭','廖':'廖',
+            '範':'范','蘇':'苏','薑':'姜','傳':'传','紀':'纪','開':'开','書':'书'
+        };
+        function toSimplifiedChinese(text) {
+            return Array.from(text).map(ch => TRAD_TO_SIMP[ch] || ch).join('');
+        }
+
         document.addEventListener('DOMContentLoaded', function() {
             loadTreeData();
 
             // Search filter for member list
             document.getElementById('memberSearch').addEventListener('input', function() {
                 const term = this.value.trim().toLowerCase();
+                const simplifiedTerm = toSimplifiedChinese(term);
                 const labels = document.querySelectorAll('#memberList label');
                 labels.forEach(lbl => {
-                    const text = lbl.textContent.toLowerCase();
-                    if (text.includes(term)) {
-                        lbl.style.display = 'block';
-                    } else {
-                        lbl.style.display = 'none';
-                    }
+                    const searchData = lbl.dataset.search || lbl.textContent.toLowerCase();
+                    const visible = searchData.includes(term) || searchData.includes(simplifiedTerm);
+                    lbl.style.display = visible ? 'block' : 'none';
                 });
             });
             document.getElementById('memberSelectModal').addEventListener('show.bs.modal', function() {
@@ -383,10 +397,12 @@
                 // Populate member list in modal
                 const listHtml = allMembersData.map(m => {
                     const gen = m.family_rank ? ` (排行/代数: ${m.family_rank})` : '';
+                    const sname = m.simplified_name ? `(${m.simplified_name})` : '';
+                    const searchText = `${m.name || ''} ${m.simplified_name || ''} ${m.family_rank || ''}`.toLowerCase();
                     return `
-                        <label class="list-group-item">
+                        <label class="list-group-item" data-search="${searchText}">
                             <input class="form-check-input me-1 member-checkbox" type="checkbox" value="${m.id}">
-                            ${m.name}${gen}
+                            ${m.name}${sname}${gen}
                         </label>
                     `;
                 }).join('');

+ 53 - 0
update_pdf_schema.py

@@ -0,0 +1,53 @@
+import pymysql
+
+# Database connection
+DB_CONFIG = {
+    "host": "rm-f8ze60yirdj8786u2wo.mysql.rds.aliyuncs.com",
+    "user": "root",
+    "password": "csqz@20255",
+    "db": "csqz-client",
+    "charset": "utf8mb4",
+    "cursorclass": pymysql.cursors.DictCursor
+}
+
+def update_pdf_schema():
+    """Update genealogy_pdfs table to add required fields"""
+    conn = pymysql.connect(**DB_CONFIG)
+    try:
+        with conn.cursor() as cursor:
+            # Add version_name field
+            try:
+                cursor.execute("ALTER TABLE genealogy_pdfs ADD COLUMN version_name VARCHAR(255) DEFAULT ''")
+                print("Added version_name column")
+            except Exception as e:
+                print(f"version_name column may already exist: {e}")
+            
+            # Add version_source field
+            try:
+                cursor.execute("ALTER TABLE genealogy_pdfs ADD COLUMN version_source VARCHAR(255) DEFAULT ''")
+                print("Added version_source column")
+            except Exception as e:
+                print(f"version_source column may already exist: {e}")
+            
+            # Add file_provider field
+            try:
+                cursor.execute("ALTER TABLE genealogy_pdfs ADD COLUMN file_provider VARCHAR(255) DEFAULT ''")
+                print("Added file_provider column")
+            except Exception as e:
+                print(f"file_provider column may already exist: {e}")
+            
+            # Update the existing record with sample data
+            cursor.execute("UPDATE genealogy_pdfs SET version_name = '留总正式版', version_source = '家族收藏', file_provider = '留总' WHERE id = 1")
+            print("Updated existing record")
+            
+        conn.commit()
+        print("Database schema updated successfully")
+        
+    except Exception as e:
+        print(f"Error: {e}")
+        conn.rollback()
+    finally:
+        conn.close()
+
+if __name__ == "__main__":
+    update_pdf_schema()