From 48d71c711b61ed0e7369c282ee9ca9f96f6e7c7d Mon Sep 17 00:00:00 2001 From: OoO Date: Mon, 18 May 2026 20:03:19 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20PPT=20=E9=A0=90=E8=A6=BD?= =?UTF-8?q?=E5=BF=AB=E5=8F=96=E9=A0=90=E7=86=B1=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO_NEXT_STEPS.txt | 1 + config.py | 2 +- routes/admin_observability_routes.py | 90 ++++++++++++++++++------ templates/admin/ppt_audit_history.html | 14 +++- tests/test_admin_observability_routes.py | 56 +++++++++++++++ web/static/js/observability-charts.js | 48 +++++++++++++ 6 files changed, 187 insertions(+), 24 deletions(-) diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index 0e52db1..b2d3439 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -4,6 +4,7 @@ ================================================================================ 【已完成】 + - V10.203 補 `/observability/ppt_audit_history` 單檔 PDF 預熱操作:未快取的可預覽 PPTX 會顯示「預熱 PDF」,透過 JSON 端點建立 PDF 快取並即時更新頁面狀態。 - V10.201 強化 `/observability/ppt_audit_history` 線上預覽可診斷性:產線清單不觸發轉檔即可顯示 PDF 預覽快取狀態,Pipeline Health、Preview Workbench 與已產檔案表同步標記「PDF 快取 / 首次轉檔」。 - V10.199 讓 `/observability/ppt_audit_history` Action Queue 可直接處理異常:待補齊與異常優先項目新增單一報表「重跑」按鈕,透過既有非阻塞背景產線排入指定 report_type。 - V10.197 強化 `/observability/ppt_audit_history` Action Queue:新增「異常優先」lane,將產出失敗、PPTX 檔案異常、視覺 QA 失敗拉到最前面,並顯示錯誤訊息與可預覽入口。 diff --git a/config.py b/config.py index c44a906..f2cfeaa 100644 --- a/config.py +++ b/config.py @@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.202" +SYSTEM_VERSION = "V10.203" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/routes/admin_observability_routes.py b/routes/admin_observability_routes.py index cc1fba8..79a991e 100644 --- a/routes/admin_observability_routes.py +++ b/routes/admin_observability_routes.py @@ -1860,6 +1860,68 @@ def ppt_audit_generate_missing(): return jsonify({'ok': False, 'error': f'{type(e).__name__}: {str(e)[:200]}'}), 500 +def _resolve_ppt_report_path(filename: str): + """在 REPORTS_DIR 內解析簡報檔名,並阻擋路徑逃逸。""" + import os + from utils.security import safe_join + + reports_dir = os.environ.get('REPORTS_DIR', '/app/data/reports') + safe_path = safe_join(reports_dir, filename) + if not safe_path.exists() or not safe_path.is_file(): + return None, ('檔案不存在', 404) + if safe_path.suffix.lower() != '.pptx': + return None, ('不支援的檔案格式', 400) + return safe_path, None + + +def _validate_pptx_for_preview(safe_path): + import zipfile + + try: + with zipfile.ZipFile(safe_path, 'r') as zf: + bad = zf.testzip() + if bad is not None: + return f'PPT 檔案損毀,無法預覽(損毀區段:{bad})' + except zipfile.BadZipFile: + return 'PPT 檔案損毀,無法預覽(非有效 zip)' + except Exception as e: + return f'預覽檢查失敗:{type(e).__name__}' + return None + + +@admin_observability_bp.route('/ppt_audit_file//prewarm', methods=['POST']) +@login_required +def ppt_audit_file_prewarm(filename: str): + """建立單一 PPT 的 PDF 預覽快取,並回傳 JSON 狀態。""" + try: + safe_path, error_response = _resolve_ppt_report_path(filename) + if error_response: + message, status_code = error_response + return jsonify({'ok': False, 'error': message}), status_code + + validation_error = _validate_pptx_for_preview(safe_path) + if validation_error: + return jsonify({'ok': False, 'error': validation_error}), 409 + + from services.ppt_preview_service import build_ppt_preview + + preview = build_ppt_preview(safe_path) + if not preview.ok or not preview.pdf_path: + return jsonify({'ok': False, 'error': preview.error or '無法產生預覽'}), 409 + + return jsonify({ + 'ok': True, + 'filename': safe_path.name, + 'cache_hit': bool(preview.cache_hit), + 'converter': preview.converter, + 'message': 'PDF 預覽快取已建立' if not preview.cache_hit else 'PDF 預覽快取已存在', + }) + except ValueError: + return jsonify({'ok': False, 'error': '非法路徑'}), 400 + except Exception as e: + return jsonify({'ok': False, 'error': f'{type(e).__name__}: {str(e)[:200]}'}), 500 + + @admin_observability_bp.route('/ppt_audit_file/') @login_required def ppt_audit_file(filename: str): @@ -1871,29 +1933,15 @@ def ppt_audit_file(filename: str): """ action = (request.args.get('action', 'view') or 'view').strip().lower() try: - import os - import zipfile - from utils.security import safe_join - - reports_dir = os.environ.get('REPORTS_DIR', '/app/data/reports') - safe_path = safe_join(reports_dir, filename) - - if not safe_path.exists() or not safe_path.is_file(): - return '檔案不存在', 404 - - if safe_path.suffix.lower() != '.pptx': - return '不支援的檔案格式', 400 + safe_path, error_response = _resolve_ppt_report_path(filename) + if error_response: + message, status_code = error_response + return message, status_code if action in ('view', 'pdf'): - try: - with zipfile.ZipFile(safe_path, 'r') as zf: - bad = zf.testzip() - if bad is not None: - return f'PPT 檔案損毀,無法預覽(損毀區段:{bad})', 409 - except zipfile.BadZipFile: - return 'PPT 檔案損毀,無法預覽(非有效 zip)', 409 - except Exception as e: - return f'預覽檢查失敗:{type(e).__name__}', 409 + validation_error = _validate_pptx_for_preview(safe_path) + if validation_error: + return validation_error, 409 if action in ('view', 'pdf'): from services.ppt_preview_service import build_ppt_preview diff --git a/templates/admin/ppt_audit_history.html b/templates/admin/ppt_audit_history.html index f695b88..ee51064 100644 --- a/templates/admin/ppt_audit_history.html +++ b/templates/admin/ppt_audit_history.html @@ -111,7 +111,7 @@ {{ f.source }} {% if f.file_exists and f.is_valid_ppt %}可預覽{% else %}需回補{% endif %} {% if f.file_exists and f.is_valid_ppt %} - {% if f.preview_cache_ready %}PDF 快取{% else %}首次轉檔{% endif %} + {% if f.preview_cache_ready %}PDF 快取{% else %}首次轉檔{% endif %} {% endif %}
@@ -119,6 +119,11 @@ 線上預覽 + {% if not f.preview_cache_ready %} + + {% endif %} {% endif %} {% if f.file_exists %} @@ -423,7 +428,7 @@ {% endif %} {% if f.file_exists and f.is_valid_ppt %}
- + {% if f.preview_cache_ready %} PDF 預覽快取已建立{% if f.preview_cache_mtime %} · {{ f.preview_cache_mtime }}{% endif %} {% else %} @@ -440,6 +445,11 @@ 線上預覽 + {% if not f.preview_cache_ready %} + + {% endif %} {% else %} 檔案不可預覽 {% endif %} diff --git a/tests/test_admin_observability_routes.py b/tests/test_admin_observability_routes.py index a533dfb..f7c1f93 100644 --- a/tests/test_admin_observability_routes.py +++ b/tests/test_admin_observability_routes.py @@ -315,6 +315,62 @@ def test_ppt_audit_file_view_renders_online_preview(client, monkeypatch, tmp_pat assert '下載 PPTX' in html +def test_ppt_audit_history_shows_preview_prewarm_action(client, monkeypatch, tmp_path): + """未快取 PDF 的 PPTX 要能在產線清單直接預熱預覽。""" + import zipfile + + reports_dir = tmp_path / 'reports' + reports_dir.mkdir() + pptx = reports_dir / 'ocbot_daily_20260518.pptx' + with zipfile.ZipFile(pptx, 'w') as zf: + zf.writestr('[Content_Types].xml', '') + + monkeypatch.setenv('REPORTS_DIR', str(reports_dir)) + monkeypatch.setenv('PPT_PREVIEW_CACHE_DIR', str(tmp_path / 'preview-cache')) + + r = client.get('/observability/ppt_audit_history?month=2026-05') + html = r.data.decode('utf-8') + + assert r.status_code == 200 + assert '首次轉檔' in html + assert 'data-ppt-prewarm-preview' in html + assert 'data-ppt-filename="ocbot_daily_20260518.pptx"' in html + assert '預熱 PDF' in html + + +def test_ppt_audit_file_prewarm_builds_preview_cache(client, monkeypatch, tmp_path): + """預熱端點應回 JSON,不直接把 PDF body 丟給前端。""" + import zipfile + from services import ppt_preview_service as preview_svc + + reports_dir = tmp_path / 'reports' + reports_dir.mkdir() + pptx = reports_dir / 'ocbot_daily_20260518.pptx' + pdf = reports_dir / 'preview.pdf' + with zipfile.ZipFile(pptx, 'w') as zf: + zf.writestr('[Content_Types].xml', '') + + monkeypatch.setenv('REPORTS_DIR', str(reports_dir)) + monkeypatch.setattr( + preview_svc, + 'build_ppt_preview', + lambda *_args, **_kwargs: preview_svc.PPTPreviewResult( + ok=True, + pdf_path=str(pdf), + cache_hit=False, + converter='/usr/bin/libreoffice', + ), + ) + + r = client.post('/observability/ppt_audit_file/ocbot_daily_20260518.pptx/prewarm') + data = r.get_json() + + assert r.status_code == 200 + assert data['ok'] is True + assert data['filename'] == 'ocbot_daily_20260518.pptx' + assert data['message'] == 'PDF 預覽快取已建立' + + # ────────────────────────────────────────────────────────────────────────── # /observability/host_health # ────────────────────────────────────────────────────────────────────────── diff --git a/web/static/js/observability-charts.js b/web/static/js/observability-charts.js index f9666a4..3e474eb 100644 --- a/web/static/js/observability-charts.js +++ b/web/static/js/observability-charts.js @@ -595,6 +595,7 @@ function initPptAutoGeneration() { const panel = document.querySelector('[data-ppt-auto-generation]'); + const pageStatus = document.querySelector('[data-ppt-auto-status]'); document.querySelectorAll('[data-ppt-aider-heal]').forEach(button => { if (button.dataset.bound === '1') return; button.dataset.bound = '1'; @@ -602,6 +603,53 @@ window.triggerAiderHeal(button.dataset.pptFilename || '', button.dataset.pptError || ''); }); }); + document.querySelectorAll('[data-ppt-prewarm-preview]').forEach(button => { + if (button.dataset.bound === '1') return; + button.dataset.bound = '1'; + button.addEventListener('click', async () => { + const filename = button.dataset.pptFilename || ''; + if (!filename) return; + const originalHtml = button.innerHTML; + button.disabled = true; + button.innerHTML = '預熱中'; + if (pageStatus) { + pageStatus.classList.add('is-working'); + pageStatus.textContent = `${filename} 正在建立 PDF 預覽快取。`; + } + try { + const response = await postJson(`/observability/ppt_audit_file/${encodeURIComponent(filename)}/prewarm`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + const data = await response.json(); + if (!response.ok || !data.ok) { + throw new Error(data.error || '預熱失敗'); + } + document.querySelectorAll('[data-ppt-preview-state]').forEach(node => { + if (node.dataset.pptPreviewState !== filename) return; + node.classList.remove('status-warn'); + node.classList.add('status-blue'); + node.textContent = 'PDF 預覽快取已建立'; + }); + document.querySelectorAll('[data-ppt-prewarm-preview]').forEach(node => { + if (node.dataset.pptFilename !== filename) return; + node.innerHTML = '已快取'; + node.disabled = true; + }); + if (pageStatus) { + pageStatus.textContent = data.message || `${filename} 的 PDF 預覽快取已建立。`; + } + } catch (error) { + console.warn('ppt_preview_prewarm_failed', error); + button.disabled = false; + button.innerHTML = originalHtml; + if (pageStatus) { + pageStatus.classList.remove('is-working'); + pageStatus.textContent = 'PDF 預覽預熱失敗,請稍後再試或直接開啟線上預覽。'; + } + } + }); + }); if (!panel) return;