Просмотр исходного кода

commit 增加参考件上传功能

林海 2 недель назад
Родитель
Сommit
d78677d7c1
4 измененных файлов с 647 добавлено и 57 удалено
  1. 202 7
      app.py
  2. 40 0
      migrate_reference_document.py
  3. 272 39
      templates/add_member.html
  4. 133 11
      templates/member_detail.html

+ 202 - 7
app.py

@@ -176,6 +176,90 @@ def compress_image_if_needed(file_path, max_dim=2000):
         print(f"Warning: Image compression/normalization failed for {file_path}: {e}")
         return file_path
 
+REFERENCE_IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'}
+
+
+def save_reference_image_to_oss(file, member_id=None):
+    """Upload a reference document image to OSS. Returns (oss_url, file_name)."""
+    import uuid
+    if not file or not file.filename:
+        raise ValueError('未选择文件')
+    ext = os.path.splitext(file.filename)[1].lower()
+    if ext not in REFERENCE_IMAGE_EXTENSIONS:
+        raise ValueError('仅支持 JPG、PNG、GIF、WEBP 格式的图片')
+    timestamp = int(time.time())
+    if member_id:
+        custom_filename = f"参考件_{member_id}_{timestamp}{ext}"
+    else:
+        custom_filename = f"参考件_temp_{uuid.uuid4().hex[:8]}_{timestamp}{ext}"
+    filename = secure_filename(custom_filename)
+    if not filename or not os.path.splitext(filename)[1]:
+        filename = f"reference_{uuid.uuid4().hex[:8]}{ext}"
+    file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
+    file.save(file_path)
+    try:
+        upload_path = compress_image_if_needed(file_path)
+        oss_url = upload_to_oss(upload_path, custom_filename=filename)
+        if not oss_url:
+            raise ValueError('上传到 OSS 失败')
+        return oss_url, filename
+    finally:
+        for path in {file_path, os.path.splitext(file_path)[0] + '_normalized.jpg'}:
+            if path and os.path.exists(path):
+                try:
+                    os.remove(path)
+                except OSError:
+                    pass
+
+
+def apply_reference_from_form(data, form, session, is_update=False):
+    """Apply reference document fields from form submission."""
+    delete_reference = form.get('delete_reference') == '1'
+    reference_oss_url = (form.get('reference_oss_url') or '').strip()
+    reference_file_name = (form.get('reference_file_name') or '').strip()
+
+    if delete_reference:
+        data['reference_oss_url'] = None
+        data['reference_file_name'] = None
+        data['reference_upload_time'] = None
+        data['reference_upload_uid'] = None
+    elif reference_oss_url:
+        data['reference_oss_url'] = reference_oss_url
+        data['reference_file_name'] = reference_file_name or None
+        data['reference_upload_time'] = datetime.now()
+        data['reference_upload_uid'] = session['user_id']
+    elif not is_update:
+        data['reference_oss_url'] = None
+        data['reference_file_name'] = None
+        data['reference_upload_time'] = None
+        data['reference_upload_uid'] = None
+    return data
+
+INVALID_SOURCE_RECORD_ID = 1  # 历史占位值,表示未关联扫描件
+
+
+def normalize_source_record_id(source_record_id):
+    """source_record_id=1 视为未关联扫描件。"""
+    if source_record_id is None or source_record_id == '':
+        return None
+    try:
+        val = int(source_record_id)
+    except (TypeError, ValueError):
+        return source_record_id
+    return None if val == INVALID_SOURCE_RECORD_ID else val
+
+
+def clear_invalid_member_scan_fields(member):
+    """清除因 source_record_id=1 误关联的扫描件展示字段。"""
+    if not member:
+        return member
+    if normalize_source_record_id(member.get('source_record_id')) is None:
+        member['source_record_id'] = None
+        for field in ('source_image_url', 'source_page', 'genealogy_version',
+                      'genealogy_source', 'upload_person'):
+            member[field] = None
+    return member
+
 # 尝试使用数据库连接池,如果不可用则使用普通连接
 try:
     try:
@@ -2346,6 +2430,76 @@ def check_relations():
     finally:
         conn.close()
 
+@app.route('/manager/api/upload_reference', methods=['POST'])
+def api_upload_reference():
+    """新增成员时上传参考件(无需 member_id)"""
+    if 'user_id' not in session:
+        return jsonify({"success": False, "message": "未登录"}), 401
+    file = request.files.get('file')
+    try:
+        oss_url, file_name = save_reference_image_to_oss(file)
+        username = session.get('username', 'genealogy')
+        return jsonify({
+            "success": True,
+            "oss_url": add_oss_watermark(oss_url, username),
+            "oss_url_raw": oss_url,
+            "file_name": file_name,
+        })
+    except ValueError as e:
+        return jsonify({"success": False, "message": str(e)}), 400
+    except Exception as e:
+        print(f"[Upload Reference] Error: {e}")
+        return jsonify({"success": False, "message": str(e)}), 500
+
+
+@app.route('/manager/api/member/<int:member_id>/reference', methods=['POST', 'DELETE'])
+def api_member_reference(member_id):
+    """编辑成员时上传或删除参考件"""
+    if 'user_id' not in session:
+        return jsonify({"success": False, "message": "未登录"}), 401
+    username = session.get('username', 'genealogy')
+    conn = get_db_connection()
+    try:
+        with conn.cursor() as cursor:
+            cursor.execute("SELECT id FROM family_member_info WHERE id = %s", (member_id,))
+            if not cursor.fetchone():
+                return jsonify({"success": False, "message": "成员不存在"}), 404
+
+            if request.method == 'DELETE':
+                cursor.execute("""
+                    UPDATE family_member_info
+                    SET reference_oss_url = NULL, reference_file_name = NULL,
+                        reference_upload_time = NULL, reference_upload_uid = NULL
+                    WHERE id = %s
+                """, (member_id,))
+                conn.commit()
+                return jsonify({"success": True, "message": "参考件已删除"})
+
+            file = request.files.get('file')
+            oss_url, file_name = save_reference_image_to_oss(file, member_id=member_id)
+            cursor.execute("""
+                UPDATE family_member_info
+                SET reference_oss_url = %s, reference_file_name = %s,
+                    reference_upload_time = %s, reference_upload_uid = %s
+                WHERE id = %s
+            """, (oss_url, file_name, datetime.now(), session['user_id'], member_id))
+            conn.commit()
+            return jsonify({
+                "success": True,
+                "message": "参考件上传成功",
+                "oss_url": add_oss_watermark(oss_url, username),
+                "oss_url_raw": oss_url,
+                "file_name": file_name,
+            })
+    except ValueError as e:
+        return jsonify({"success": False, "message": str(e)}), 400
+    except Exception as e:
+        conn.rollback()
+        print(f"[Member Reference] Error: {e}")
+        return jsonify({"success": False, "message": str(e)}), 500
+    finally:
+        conn.close()
+
 @app.route('/manager/add_member', methods=['GET', 'POST'])
 def add_member():
     if 'user_id' not in session:
@@ -2357,7 +2511,9 @@ def add_member():
     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')
+        source_record_id = normalize_source_record_id(
+            request.args.get('record_id') or request.form.get('source_record_id')
+        )
         prefilled_content = None
         source_oss_url = None
         
@@ -2478,9 +2634,10 @@ def add_member():
                 'tags': request.form.get('tags'),
                 'notes': request.form.get('notes'),
                 'suspected_error': request.form.get('suspected_error').strip() if request.form.get('suspected_error') else '',
-                'source_record_id': request.form.get('source_record_id') or None,  # Save source record ID
+                'source_record_id': normalize_source_record_id(request.form.get('source_record_id') or None),
                 'create_uid': session['user_id']  # 记录当前操作人
             }
+            apply_reference_from_form(data, request.form, session, is_update=False)
             
             # ... (rest of logic) ...
             
@@ -2662,7 +2819,9 @@ def edit_member(member_id):
                                     }), 400
                                 
                                 selected_member_name = ''
-                                return render_template('add_member.html', member=member, images=images, all_members=all_members, selected_member_name=selected_member_name)
+                                if member:
+                                    clear_invalid_member_scan_fields(member)
+                                return render_template('add_member.html', member=member, images=images, all_members=all_members, selected_member_name=selected_member_name, source_record_id=normalize_source_record_id(member.get('source_record_id') if member else None))
                     break
 
             data = {
@@ -2698,9 +2857,10 @@ def edit_member(member_id):
                 'tags': request.form.get('tags'),
                 'notes': request.form.get('notes'),
                 'suspected_error': request.form.get('suspected_error').strip() if request.form.get('suspected_error') else '',
-                'source_record_id': request.form.get('source_record_id') or None,
+                'source_record_id': normalize_source_record_id(request.form.get('source_record_id') or None),
                 'create_uid': session['user_id']  # 记录当前操作人
             }
+            apply_reference_from_form(data, request.form, session, is_update=True)
             
             with conn.cursor() as cursor:
                 print(f"[Edit Member] Updating member data: {data}")
@@ -2795,6 +2955,9 @@ def edit_member(member_id):
             for img in images:
                 if img.get('oss_url'):
                     img['oss_url'] = add_oss_watermark(img['oss_url'], username)
+
+            if member.get('reference_oss_url'):
+                member['reference_image_url'] = add_oss_watermark(member['reference_oss_url'], username)
     finally:
         conn.close()
         
@@ -2807,7 +2970,9 @@ def edit_member(member_id):
                 break
     
     # Get source_record_id from member data
-    source_record_id = member.get('source_record_id') if member else None
+    if member:
+        clear_invalid_member_scan_fields(member)
+    source_record_id = normalize_source_record_id(member.get('source_record_id') if member else None)
     
     return render_template('add_member.html', member=member, images=images, all_members=all_members, current_relation=current_relation, selected_member_name=selected_member_name, source_record_id=source_record_id)
 
@@ -2827,18 +2992,22 @@ def member_detail(member_id):
                 SELECT m.*, r.oss_url as source_image_url, r.page_number as source_page,
                        r.genealogy_version, r.genealogy_source, r.upload_person
                 FROM family_member_info m
-                LEFT JOIN genealogy_records r ON m.source_record_id = r.id
+                LEFT JOIN genealogy_records r ON m.source_record_id = r.id AND m.source_record_id != %s
                 WHERE m.id = %s
             """
-            cursor.execute(sql, (member_id,))
+            cursor.execute(sql, (INVALID_SOURCE_RECORD_ID, member_id))
             member = cursor.fetchone()
             if not member:
                 flash('成员不存在')
                 return redirect(url_for('members'))
+
+            clear_invalid_member_scan_fields(member)
             
             # 为图片URL添加水印
             if member.get('source_image_url'):
                 member['source_image_url'] = add_oss_watermark(member['source_image_url'], username)
+            if member.get('reference_oss_url'):
+                member['reference_image_url'] = add_oss_watermark(member['reference_oss_url'], username)
             
             member['birthday_str'] = format_timestamp(member.get('birthday'))
             
@@ -3839,6 +4008,32 @@ def migrate_enthusiastic_members_column():
 
 migrate_enthusiastic_members_column()
 
+def migrate_reference_document_columns():
+    """为 family_member_info 表添加参考件字段(如不存在)"""
+    columns = [
+        ("reference_oss_url", "TEXT NULL COMMENT '参考件OSS地址'"),
+        ("reference_file_name", "VARCHAR(255) NULL COMMENT '参考件文件名'"),
+        ("reference_upload_time", "TIMESTAMP NULL COMMENT '参考件上传时间'"),
+        ("reference_upload_uid", "INT NULL COMMENT '参考件上传人ID'"),
+    ]
+    conn = get_db_connection()
+    try:
+        with conn.cursor() as cursor:
+            for col_name, col_def in columns:
+                cursor.execute(f"SHOW COLUMNS FROM family_member_info LIKE '{col_name}'")
+                if not cursor.fetchone():
+                    cursor.execute(f"ALTER TABLE family_member_info ADD COLUMN {col_name} {col_def}")
+                    print(f"[DB Migrate] Added {col_name} column to family_member_info")
+                else:
+                    print(f"[DB Migrate] {col_name} column already exists")
+        conn.commit()
+    except Exception as e:
+        print(f"[DB Migrate] Error adding reference document columns: {e}")
+    finally:
+        conn.close()
+
+migrate_reference_document_columns()
+
 def async_process_genealogy_task(task_id, member_ids, user_id):
     """异步处理族谱原文任务"""
     results = []

+ 40 - 0
migrate_reference_document.py

@@ -0,0 +1,40 @@
+"""为 family_member_info 表添加参考件字段"""
+import pymysql
+
+DB_CONFIG = {
+    "host": "rm-f8ze60yirdj8786u2wo.mysql.rds.aliyuncs.com",
+    "port": 3306,
+    "user": "root",
+    "password": "csqz@20255",
+    "db": "csqz-client",
+    "charset": "utf8mb4",
+    "cursorclass": pymysql.cursors.DictCursor,
+}
+
+COLUMNS = [
+    ("reference_oss_url", "TEXT NULL COMMENT '参考件OSS地址'"),
+    ("reference_file_name", "VARCHAR(255) NULL COMMENT '参考件文件名'"),
+    ("reference_upload_time", "TIMESTAMP NULL COMMENT '参考件上传时间'"),
+    ("reference_upload_uid", "INT NULL COMMENT '参考件上传人ID'"),
+]
+
+
+def migrate():
+    conn = pymysql.connect(**DB_CONFIG)
+    try:
+        with conn.cursor() as cursor:
+            for col_name, col_def in COLUMNS:
+                cursor.execute(f"SHOW COLUMNS FROM family_member_info LIKE '{col_name}'")
+                if cursor.fetchone():
+                    print(f"Column {col_name} already exists, skipping.")
+                    continue
+                cursor.execute(f"ALTER TABLE family_member_info ADD COLUMN {col_name} {col_def}")
+                print(f"Added column {col_name}.")
+        conn.commit()
+        print("Migration complete.")
+    finally:
+        conn.close()
+
+
+if __name__ == "__main__":
+    migrate()

+ 272 - 39
templates/add_member.html

@@ -77,7 +77,10 @@
     }
     .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; }
+    .page-nav { margin-bottom: 10px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
+    #imageTabNav .nav-link { cursor: pointer; font-size: 0.9rem; padding: 0.35rem 0.75rem; }
+    .reference-empty-state { color: #6c757d; padding: 40px 20px; text-align: center; }
+    .reference-empty-state i { font-size: 2.5rem; display: block; margin-bottom: 10px; }
     .section-title { border-left: 4px solid #0d6efd; padding-left: 10px; margin: 25px 0 15px; font-weight: bold; color: #333; }
     
     .father-lineage-hint {
@@ -107,8 +110,11 @@
             </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_record_id" value="{{ source_record_id or '' }}">
                     <input type="hidden" name="source_index" value="">
+                    <input type="hidden" name="reference_oss_url" id="referenceOssUrl" value="{{ member.reference_oss_url if member and member.reference_oss_url else '' }}">
+                    <input type="hidden" name="reference_file_name" id="referenceFileName" value="{{ member.reference_file_name if member and member.reference_file_name else '' }}">
+                    <input type="hidden" name="delete_reference" id="deleteReference" value="0">
                     <div class="section-title">核心信息 (必填)</div>
                     <div class="row g-3 mb-4">
                         <div class="col-md-6">
@@ -462,22 +468,53 @@
 
     <!-- 右侧:图片参考 -->
     <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>
+        <ul class="nav nav-tabs mb-2" id="imageTabNav">
+            <li class="nav-item">
+                <button type="button" class="nav-link active" id="tab-scan" onclick="switchImageTab('scan')">
+                    <i class="bi bi-file-earmark-image"></i> 查看扫描件
+                </button>
+            </li>
+            <li class="nav-item">
+                <button type="button" class="nav-link" id="tab-reference" onclick="switchImageTab('reference')">
+                    <i class="bi bi-file-earmark-plus"></i> 查看参考件
+                </button>
+            </li>
+        </ul>
+
+        <div id="scanTabPanel">
+            <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 type="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="mb-2 small text-muted" id="imageMetadata" style="display: none;">
+                <span class="me-2"><i class="bi bi-journal-text"></i> 版本名称: <span id="metaVersion">-</span></span>
+                <span class="me-2"><i class="bi bi-archive"></i> 版本来源: <span id="metaSource">-</span></span>
+                <span><i class="bi bi-person"></i> 提供人: <span id="metaPerson">-</span></span>
             </div>
         </div>
-        <div class="mb-2 small text-muted" id="imageMetadata" style="display: none;">
-            <span class="me-2"><i class="bi bi-journal-text"></i> 版本名称: <span id="metaVersion">-</span></span>
-            <span class="me-2"><i class="bi bi-archive"></i> 版本来源: <span id="metaSource">-</span></span>
-            <span><i class="bi bi-person"></i> 提供人: <span id="metaPerson">-</span></span>
+
+        <div id="referenceTabPanel" style="display: none;">
+            <div class="page-nav">
+                <label class="fw-bold">参考件:</label>
+                <input type="file" id="referenceFileInput" accept="image/jpeg,image/png,image/gif,image/webp" style="display: none;">
+                <button type="button" onclick="document.getElementById('referenceFileInput').click()" class="btn btn-sm btn-primary ms-2">
+                    <i class="bi bi-cloud-upload"></i> 上传参考件
+                </button>
+                <button type="button" id="deleteReferenceBtn" onclick="deleteReference()" class="btn btn-sm btn-outline-danger ms-2" style="display: none;">
+                    <i class="bi bi-trash"></i> 删除参考件
+                </button>
+                <span class="small text-muted ms-2">支持 Ctrl+V 粘贴图片</span>
+                <span class="ms-auto small text-muted" id="referenceFileLabel"></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>
@@ -498,19 +535,21 @@
         <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 %}
+                <img id="refImage" src="" alt="家谱图片" draggable="false" style="display: none;">
+                <div id="scanEmptyState" class="reference-empty-state" style="display: none;">
+                    <i class="bi bi-image"></i>
+                    暂无关联扫描件
+                </div>
+                <div id="referenceEmptyState" class="reference-empty-state" style="display: none;">
+                    <i class="bi bi-file-earmark-plus"></i>
+                    暂无参考件,请上传或粘贴散页图片<br>
+                    <span class="small">点击「上传参考件」或按 Ctrl+V / ⌘+V 粘贴</span>
+                </div>
             </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 id="scanNavButtons" class="mt-2 d-flex justify-content-between">
+            <button type="button" onclick="prevImage()" class="btn btn-sm btn-outline-secondary">上一张</button>
+            <button type="button" onclick="nextImage()" class="btn btn-sm btn-outline-secondary">下一张</button>
         </div>
     </div>
 
@@ -662,6 +701,12 @@
         {% endfor %}
     ];
     let currentIndex = 0;
+    let activeImageTab = 'scan';
+    let referenceImageUrl = "{{ member.reference_image_url if member and member.reference_image_url else '' }}";
+    let referenceOssUrlRaw = "{{ member.reference_oss_url if member and member.reference_oss_url else '' }}";
+    const memberIdForReference = {{ member.id if member else 'null' }};
+    const hasScanSource = {{ 'true' if source_record_id else 'false' }};
+    const hasReferenceInitial = {{ 'true' if (member and member.reference_oss_url) else 'false' }};
     
     // 初始化时根据source_record_id设置正确的扫描件
     {% if source_record_id %}
@@ -698,9 +743,164 @@
     
     // 页面加载完成后更新显示
     document.addEventListener('DOMContentLoaded', function() {
-        // 调用updateDisplay函数显示正确的扫描件
-        updateDisplay();
+        activeImageTab = hasScanSource ? 'scan' : (hasReferenceInitial ? 'reference' : 'scan');
+        switchImageTab(activeImageTab, true);
+        document.getElementById('referenceFileInput').addEventListener('change', handleReferenceFileSelect);
+        document.addEventListener('paste', handleReferencePaste);
+        updateReferenceControls();
     });
+
+    function isReferencePasteTarget(element) {
+        if (!element) return false;
+        const tag = element.tagName;
+        if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
+        if (element.isContentEditable) return true;
+        return !!element.closest('.form-panel');
+    }
+
+    function handleReferencePaste(event) {
+        if (activeImageTab !== 'reference') return;
+        if (isReferencePasteTarget(event.target)) return;
+
+        const items = event.clipboardData && event.clipboardData.items;
+        if (!items) return;
+
+        for (const item of items) {
+            if (!item.type.startsWith('image/')) continue;
+            event.preventDefault();
+            const blob = item.getAsFile();
+            if (!blob) return;
+            const ext = item.type === 'image/jpeg' ? 'jpg' : (item.type.split('/')[1] || 'png');
+            const file = new File([blob], `paste_${Date.now()}.${ext}`, { type: item.type });
+            uploadReferenceFile(file);
+            return;
+        }
+    }
+
+    function switchImageTab(tab, skipPersist) {
+        activeImageTab = tab;
+        document.getElementById('tab-scan').classList.toggle('active', tab === 'scan');
+        document.getElementById('tab-reference').classList.toggle('active', tab === 'reference');
+        document.getElementById('scanTabPanel').style.display = tab === 'scan' ? 'block' : 'none';
+        document.getElementById('referenceTabPanel').style.display = tab === 'reference' ? 'block' : 'none';
+        document.getElementById('scanNavButtons').style.display = tab === 'scan' ? 'flex' : 'none';
+        if (tab === 'scan') {
+            updateDisplay();
+        } else {
+            updateReferenceDisplay();
+        }
+    }
+
+    function updateReferenceControls() {
+        const deleteBtn = document.getElementById('deleteReferenceBtn');
+        const fileLabel = document.getElementById('referenceFileLabel');
+        const fileName = document.getElementById('referenceFileName').value;
+        const hasRef = !!referenceImageUrl;
+        deleteBtn.style.display = hasRef ? 'inline-block' : 'none';
+        fileLabel.textContent = fileName ? `当前: ${fileName}` : '';
+    }
+
+    function updateReferenceDisplay() {
+        const img = document.getElementById('refImage');
+        const scanEmpty = document.getElementById('scanEmptyState');
+        const refEmpty = document.getElementById('referenceEmptyState');
+        const metaContainer = document.getElementById('imageMetadata');
+        if (metaContainer) metaContainer.style.display = 'none';
+
+        resetFilters();
+        if (referenceImageUrl) {
+            img.src = referenceImageUrl;
+            img.style.display = 'block';
+            refEmpty.style.display = 'none';
+            scanEmpty.style.display = 'none';
+        } else {
+            img.style.display = 'none';
+            img.removeAttribute('src');
+            refEmpty.style.display = 'block';
+            scanEmpty.style.display = 'none';
+        }
+        currentX = 0;
+        currentY = 0;
+        isZoomedIn = false;
+        updateImageTransform();
+    }
+
+    async function handleReferenceFileSelect(event) {
+        const file = event.target.files && event.target.files[0];
+        event.target.value = '';
+        if (file) await uploadReferenceFile(file);
+    }
+
+    async function uploadReferenceFile(file) {
+        if (!file) return;
+        if (!file.type.startsWith('image/')) {
+            alert('请选择图片文件');
+            return;
+        }
+        if (file.size > 10 * 1024 * 1024) {
+            alert('图片大小不能超过 10MB');
+            return;
+        }
+
+        const formData = new FormData();
+        formData.append('file', file);
+        const uploadBtn = document.querySelector('#referenceTabPanel .btn-primary');
+        const originalHtml = uploadBtn.innerHTML;
+        uploadBtn.disabled = true;
+        uploadBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> 上传中...';
+
+        try {
+            const url = memberIdForReference
+                ? `/manager/api/member/${memberIdForReference}/reference`
+                : '/manager/api/upload_reference';
+            const resp = await fetch(url, { method: 'POST', body: formData });
+            const result = await resp.json();
+            if (!resp.ok || !result.success) {
+                throw new Error(result.message || '上传失败');
+            }
+            referenceImageUrl = result.oss_url;
+            referenceOssUrlRaw = result.oss_url_raw || result.oss_url;
+            document.getElementById('referenceOssUrl').value = referenceOssUrlRaw;
+            document.getElementById('referenceFileName').value = result.file_name || file.name;
+            document.getElementById('deleteReference').value = '0';
+            updateReferenceControls();
+            if (activeImageTab === 'reference') {
+                updateReferenceDisplay();
+            }
+        } catch (error) {
+            alert(error.message || '上传失败,请稍后重试');
+        } finally {
+            uploadBtn.disabled = false;
+            uploadBtn.innerHTML = originalHtml;
+        }
+    }
+
+    async function deleteReference() {
+        if (!confirm('确定要删除参考件吗?')) return;
+
+        if (memberIdForReference) {
+            try {
+                const resp = await fetch(`/manager/api/member/${memberIdForReference}/reference`, { method: 'DELETE' });
+                const result = await resp.json();
+                if (!resp.ok || !result.success) {
+                    throw new Error(result.message || '删除失败');
+                }
+            } catch (error) {
+                alert(error.message || '删除失败,请稍后重试');
+                return;
+            }
+        }
+
+        referenceImageUrl = '';
+        referenceOssUrlRaw = '';
+        document.getElementById('referenceOssUrl').value = '';
+        document.getElementById('referenceFileName').value = '';
+        document.getElementById('deleteReference').value = '1';
+        updateReferenceControls();
+        if (activeImageTab === 'reference') {
+            updateReferenceDisplay();
+        }
+    }
     
     // Initialize Dragging and Zooming
     if (imageWrapper) {
@@ -1290,18 +1490,45 @@
     // --- End AJAX Form Submission ---
 
     function updateDisplay() {
+        if (activeImageTab !== 'scan') return;
+        const img = document.getElementById('refImage');
+        const scanEmpty = document.getElementById('scanEmptyState');
+        const refEmpty = document.getElementById('referenceEmptyState');
+        const isEditMode = {{ 'true' if member else 'false' }};
+        const metaContainer = document.getElementById('imageMetadata');
+        const aiBtn = document.getElementById('aiBtn');
+
+        if (isEditMode && !hasScanSource) {
+            img.style.display = 'none';
+            img.removeAttribute('src');
+            scanEmpty.innerHTML = '<i class="bi bi-image"></i> 暂无关联扫描件';
+            scanEmpty.style.display = 'block';
+            refEmpty.style.display = 'none';
+            if (metaContainer) metaContainer.style.display = 'none';
+            if (aiBtn) {
+                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;
+            }
+            resetFilters();
+            return;
+        }
+
         if (images.length > 0) {
-            const img = images[currentIndex];
-            document.getElementById('refImage').src = img.url;
+            const imageData = images[currentIndex];
+            img.src = imageData.url;
+            img.style.display = 'block';
+            scanEmpty.style.display = 'none';
+            refEmpty.style.display = 'none';
             document.getElementById('currentPage').innerText = currentIndex + 1;
             
             // Update metadata display
             const metaContainer = document.getElementById('imageMetadata');
-            if (img.genealogy_version || img.genealogy_source || img.upload_person) {
+            if (imageData.genealogy_version || imageData.genealogy_source || imageData.upload_person) {
                 metaContainer.style.display = 'block';
-                document.getElementById('metaVersion').innerText = img.genealogy_version || '未提供';
-                document.getElementById('metaSource').innerText = img.genealogy_source || '未提供';
-                document.getElementById('metaPerson').innerText = img.upload_person || '未提供';
+                document.getElementById('metaVersion').innerText = imageData.genealogy_version || '未提供';
+                document.getElementById('metaSource').innerText = imageData.genealogy_source || '未提供';
+                document.getElementById('metaPerson').innerText = imageData.upload_person || '未提供';
             } else {
                 metaContainer.style.display = 'none';
             }
@@ -1313,7 +1540,7 @@
                 // Otherwise, use the current image's ID
                 const isEditMode = {{ 'true' if member else 'false' }};
                 if (!isEditMode) {
-                    sourceRecordIdField.value = img.id;
+                    sourceRecordIdField.value = imageData.id;
                 }
             }
             
@@ -1333,9 +1560,9 @@
             if (resultCount) resultCount.innerText = '0';
             if (resultList) resultList.innerHTML = ''; 
 
-            if (img.ai_status === 2 && img.ai_content) {
+            if (imageData.ai_status === 2 && imageData.ai_content) {
                 // Determine content
-                let content = img.ai_content;
+                let content = imageData.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 = []; }
@@ -1356,12 +1583,18 @@
                     };
                     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;
+        } else {
+            img.style.display = 'none';
+            img.removeAttribute('src');
+            scanEmpty.innerHTML = '<i class="bi bi-image"></i> 暂无上传的家谱图片';
+            scanEmpty.style.display = 'block';
+            refEmpty.style.display = 'none';
         }
     }
 

+ 133 - 11
templates/member_detail.html

@@ -44,6 +44,19 @@
     .source-image-preview:hover .preview-overlay {
         opacity: 1;
     }
+    #detailImageTabNav .nav-link { cursor: pointer; font-size: 0.85rem; padding: 0.3rem 0.6rem; }
+    .image-empty-hint { color: #6c757d; font-size: 0.85rem; padding: 20px; text-align: center; }
+    .detail-preview-empty {
+        min-height: 160px;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        background: #f8f9fa;
+        border: 1px dashed #dee2e6;
+        border-radius: 4px;
+        color: #6c757d;
+        font-size: 0.9rem;
+    }
 
     /* Modal Image Viewer */
     .modal-image-container {
@@ -282,20 +295,39 @@
         <!-- 关系信息与原图 -->
         <div class="col-md-4">
             
-            {% if member.source_image_url %}
+            {% if member.source_image_url or member.reference_image_url %}
             <div class="detail-section">
-                <div class="section-title">来源原图</div>
+                <div class="section-title">家谱图片</div>
+                <ul class="nav nav-tabs mb-3" id="detailImageTabNav">
+                    <li class="nav-item">
+                        <button type="button" class="nav-link" id="detail-tab-scan" onclick="switchDetailImageTab('scan')">
+                            <i class="bi bi-file-earmark-image"></i> 查看扫描件
+                        </button>
+                    </li>
+                    <li class="nav-item">
+                        <button type="button" class="nav-link" id="detail-tab-reference" onclick="switchDetailImageTab('reference')">
+                            <i class="bi bi-file-earmark-plus"></i> 查看参考件
+                        </button>
+                    </li>
+                </ul>
                 <div class="source-image-preview" onclick="openImageViewer()">
-                    <img src="{{ member.source_image_url }}" alt="来源家谱">
+                    <img id="detailPreviewImage" src="" alt="家谱图片" style="display: none;">
+                    <div id="detailPreviewEmpty" class="detail-preview-empty" style="display: none;">
+                        <span><i class="bi bi-image"></i> 暂无扫描件</span>
+                    </div>
                     <div class="preview-overlay">
-                        <i class="bi bi-arrows-fullscreen"></i> 点击查看大图 (第{{ member.source_page }}页)
+                        <i class="bi bi-arrows-fullscreen"></i> <span id="detailPreviewHint">点击查看大图</span>
                     </div>
                 </div>
-                <div class="mt-3 small text-muted bg-light p-2 rounded">
+                <div class="mt-3 small text-muted bg-light p-2 rounded" id="detailScanMeta">
                     <div class="mb-1"><i class="bi bi-journal-text me-1"></i><strong>版本名称:</strong> {{ member.genealogy_version or '未提供' }}</div>
                     <div class="mb-1"><i class="bi bi-archive me-1"></i><strong>版本来源:</strong> {{ member.genealogy_source or '未提供' }}</div>
                     <div><i class="bi bi-person me-1"></i><strong>文件提供人:</strong> {{ member.upload_person or '未提供' }}</div>
                 </div>
+                <div class="mt-3 small text-muted bg-light p-2 rounded" id="detailReferenceMeta" style="display: none;">
+                    <div class="mb-1"><i class="bi bi-file-earmark me-1"></i><strong>文件名:</strong> {{ member.reference_file_name or '未提供' }}</div>
+                    <div><i class="bi bi-clock me-1"></i><strong>上传时间:</strong> {{ member.reference_upload_time or '未提供' }}</div>
+                </div>
             </div>
             {% endif %}
 
@@ -386,10 +418,22 @@
     <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>
+                <h5 class="modal-title fs-6"><i class="bi bi-image"></i> <span id="modalImageTitle">家谱图片查看</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">
+                <ul class="nav nav-tabs px-3 pt-2 bg-white border-bottom" id="modalImageTabNav">
+                    <li class="nav-item">
+                        <button type="button" class="nav-link" id="modal-tab-scan" onclick="switchModalImageTab('scan')">
+                            <i class="bi bi-file-earmark-image"></i> 查看扫描件
+                        </button>
+                    </li>
+                    <li class="nav-item">
+                        <button type="button" class="nav-link" id="modal-tab-reference" onclick="switchModalImageTab('reference')">
+                            <i class="bi bi-file-earmark-plus"></i> 查看参考件
+                        </button>
+                    </li>
+                </ul>
                 <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>
@@ -410,9 +454,8 @@
                 <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 %}
+                        <img id="refImage" src="" alt="家谱图片" draggable="false" style="display: none;">
+                        <div id="modalEmptyState" class="image-empty-hint" style="display: none;">暂无图片</div>
                     </div>
                 </div>
             </div>
@@ -423,6 +466,85 @@
 
 {% block extra_js %}
 <script>
+    const scanImageUrl = {{ member.source_image_url | tojson if member.source_image_url else 'null' }};
+    const referenceImageUrl = {{ member.reference_image_url | tojson if member.reference_image_url else 'null' }};
+    const scanPage = {{ member.source_page | tojson if member.source_page else 'null' }};
+    let detailActiveTab = scanImageUrl ? 'scan' : 'reference';
+    let modalActiveTab = detailActiveTab;
+
+    function switchDetailImageTab(tab) {
+        detailActiveTab = tab;
+        document.getElementById('detail-tab-scan').classList.toggle('active', tab === 'scan');
+        document.getElementById('detail-tab-reference').classList.toggle('active', tab === 'reference');
+        document.getElementById('detailScanMeta').style.display = tab === 'scan' && scanImageUrl ? 'block' : 'none';
+        document.getElementById('detailReferenceMeta').style.display = tab === 'reference' && referenceImageUrl ? 'block' : 'none';
+        const preview = document.getElementById('detailPreviewImage');
+        const previewEmpty = document.getElementById('detailPreviewEmpty');
+        const hint = document.getElementById('detailPreviewHint');
+        if (tab === 'scan' && scanImageUrl) {
+            preview.src = scanImageUrl;
+            preview.style.display = 'block';
+            previewEmpty.style.display = 'none';
+            hint.textContent = scanPage ? `点击查看大图 (第${scanPage}页)` : '点击查看大图';
+        } else if (tab === 'reference' && referenceImageUrl) {
+            preview.src = referenceImageUrl;
+            preview.style.display = 'block';
+            previewEmpty.style.display = 'none';
+            hint.textContent = '点击查看参考件大图';
+        } else if (tab === 'scan') {
+            preview.style.display = 'none';
+            preview.removeAttribute('src');
+            previewEmpty.style.display = 'flex';
+            previewEmpty.innerHTML = '<span><i class="bi bi-image"></i> 暂无扫描件</span>';
+            hint.textContent = '暂无扫描件';
+        } else {
+            preview.style.display = 'none';
+            preview.removeAttribute('src');
+            previewEmpty.style.display = 'flex';
+            previewEmpty.innerHTML = '<span><i class="bi bi-file-earmark-plus"></i> 暂无参考件</span>';
+            hint.textContent = '暂无参考件';
+        }
+    }
+
+    function switchModalImageTab(tab) {
+        modalActiveTab = tab;
+        document.getElementById('modal-tab-scan').classList.toggle('active', tab === 'scan');
+        document.getElementById('modal-tab-reference').classList.toggle('active', tab === 'reference');
+        updateModalImageDisplay();
+        resetFilters();
+    }
+
+    function updateModalImageDisplay() {
+        const img = document.getElementById('refImage');
+        const emptyState = document.getElementById('modalEmptyState');
+        const title = document.getElementById('modalImageTitle');
+        const url = modalActiveTab === 'scan' ? scanImageUrl : referenceImageUrl;
+        if (url) {
+            img.src = url;
+            img.style.display = 'block';
+            emptyState.style.display = 'none';
+            title.textContent = modalActiveTab === 'scan'
+                ? (scanPage ? `扫描件查看 - 第 ${scanPage} 页` : '扫描件查看')
+                : '参考件查看';
+        } else {
+            img.style.display = 'none';
+            img.removeAttribute('src');
+            emptyState.style.display = 'block';
+            emptyState.textContent = modalActiveTab === 'scan' ? '暂无扫描件' : '暂无参考件';
+            title.textContent = '家谱图片查看';
+        }
+        currentX = 0;
+        currentY = 0;
+        isZoomedIn = false;
+        updateImageTransform();
+    }
+
+    document.addEventListener('DOMContentLoaded', function() {
+        if (document.getElementById('detailImageTabNav')) {
+            switchDetailImageTab(detailActiveTab);
+        }
+    });
+
     function confirmDelete() {
         if(confirm('确定要删除此成员吗?\n这将同时删除其所有关联关系记录!')) {
             document.getElementById('deleteForm').submit();
@@ -430,10 +552,10 @@
     }
 
     function openImageViewer() {
+        modalActiveTab = detailActiveTab;
+        switchModalImageTab(modalActiveTab);
         var myModal = new bootstrap.Modal(document.getElementById('imageModal'));
         myModal.show();
-        // Reset state when opening
-        resetFilters();
     }
 
     // --- Image Viewer Logic (Reused from add_member) ---