This commit is contained in:
@@ -312,7 +312,7 @@ RAG_DEFAULT_TOP_K=5
|
||||
RAG_EMBED_MODEL=bge-m3:latest
|
||||
RAG_EMBED_DIM=1024
|
||||
RAG_EMBED_NORMALIZE=true
|
||||
PPT_VISION_ENABLED=false
|
||||
PPT_VISION_ENABLED=true
|
||||
PPT_VISION_MODEL=minicpm-v:latest
|
||||
PPT_VISION_TIMEOUT=60
|
||||
PPT_AUTO_GENERATION_ENABLED=true
|
||||
|
||||
@@ -32,6 +32,7 @@ RUN apt-get update && apt-get install -y \
|
||||
fonts-liberation \
|
||||
fonts-noto-cjk \
|
||||
fonts-noto-cjk-extra \
|
||||
libreoffice-impress \
|
||||
openssh-client \
|
||||
libappindicator3-1 || true \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
================================================================================
|
||||
|
||||
【已完成】
|
||||
- V10.190 補 `/observability/ppt_audit_file/<filename>` 站內線上預覽:PPTX 由 LibreOffice 轉 PDF 快取後以 iframe 預覽,保留原始 PPTX 下載;Dockerfile 加 `libreoffice-impress`,compose 預設啟用 `PPT_VISION_ENABLED=true`,PPT 產線頁新增視覺 QA 停用原因與更精簡的控制台式排版。
|
||||
- V10.188 補強 `/observability/ppt_audit_history` PPT 視覺 QA 產線:頁面明確呈現每日、每週、每月、每季、每半年、每年定期產出節奏,並顯示 `ppt_generation_runs` DB 寫入紀錄;保留自動補齊缺漏與資料庫/檔案覆蓋狀態。
|
||||
- V10.187 修正 `/daily_sales`、`/growth_analysis` 圖表空白:Chart JSON 改從 `<template>.content.textContent` 讀取,補空資料診斷狀態;成長分析改用 realtime 明細新鮮度覆蓋過期月結摘要,並為 growth cache 加入資料指紋。
|
||||
- V10.151 接續前端 V3 全站 UI/UX:廠商缺貨 `/vendor-stockout/vendor-management`、`/vendor-stockout/send-email`、`/vendor-stockout/history` 改走新版 `ewoooc_base.html` shell 與 `page-vendor-tools.css`,移除舊紫藍 navbar/live route。
|
||||
- V10.151 補 `/abc_analysis/detail` 新版 ABC 詳情頁與安全 loading state,移除 raw HTML fallback,資料表維持正式快取資料來源與匯出連結。
|
||||
|
||||
@@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
|
||||
# ==========================================
|
||||
# 系統版本與路徑
|
||||
# ==========================================
|
||||
SYSTEM_VERSION = "V10.189"
|
||||
SYSTEM_VERSION = "V10.190"
|
||||
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
|
||||
public_url = PUBLIC_URL # 用於模板顯示
|
||||
|
||||
|
||||
@@ -96,6 +96,9 @@ services:
|
||||
- OLLAMA_HOST_FALLBACK=${OLLAMA_HOST_FALLBACK:-http://192.168.0.111:11434}
|
||||
# EMBEDDING_HOST 若未設定,由 resolve_ollama_host() 自動決定(三主機級聯)
|
||||
- EMBEDDING_HOST=${EMBEDDING_HOST:-}
|
||||
# PPT 視覺 QA + 線上預覽:需要容器內 LibreOffice(Dockerfile 安裝 libreoffice-impress)
|
||||
- PPT_VISION_ENABLED=${PPT_VISION_ENABLED:-true}
|
||||
- PPT_VISION_MODEL=${PPT_VISION_MODEL:-minicpm-v:latest}
|
||||
# ADR-020: Code Review 全自動修復主開關
|
||||
# 預設 true(任何 finding 一律觸發 AiderHeal),可在 .env 顯式設 false 即時切斷
|
||||
- CODE_REVIEW_AUTO_FIX_ENABLED=${CODE_REVIEW_AUTO_FIX_ENABLED:-true}
|
||||
@@ -223,6 +226,8 @@ services:
|
||||
- OLLAMA_HOST_SECONDARY=${OLLAMA_HOST_SECONDARY:-http://34.21.145.224:11434}
|
||||
- OLLAMA_HOST_FALLBACK=${OLLAMA_HOST_FALLBACK:-http://192.168.0.111:11434}
|
||||
- EMBEDDING_HOST=${EMBEDDING_HOST:-}
|
||||
- PPT_VISION_ENABLED=${PPT_VISION_ENABLED:-true}
|
||||
- PPT_VISION_MODEL=${PPT_VISION_MODEL:-minicpm-v:latest}
|
||||
env_file:
|
||||
- .env
|
||||
command: ["python", "run_scheduler.py"]
|
||||
|
||||
@@ -106,7 +106,7 @@ SQL漏斗(~300筆)
|
||||
- CD rebuild 模式必須先 build image 成功,再短暫 stop/rm/recreate 三應用容器,避免 no-cache build 造成長時間 502。
|
||||
- ElephantAlpha 使用 NVIDIA NIM hosted API;production 預設模型為 `nvidia/llama-3.3-nemotron-super-49b-v1.5`,`ELEPHANT_ALPHA_FALLBACK_MODELS` 需保留至少一個可呼叫備援;403/404、408/409/425/429、5xx、timeout 與 connection error 必須嘗試下一個模型。
|
||||
- OpenClaw/Hermes embedding 優先呼叫 Ollama `/api/embed`,只在舊節點不支援時 fallback `/api/embeddings`;timeout 由 `EMBEDDING_TIMEOUT` / `OLLAMA_EMBED_TIMEOUT` 控制。
|
||||
- PPT 自動產線由 `momo-scheduler` 依節奏執行 `run_ppt_auto_generation_task(schedule_kind)`:每日 20:30 產日報、週一 20:40 產週報/市場情報、每月 1 日 20:50 產月報與管理型簡報、季初 21:00 產季報、半年初 21:10 產半年報、年初 21:20 產年報,再交給 22:00 `ppt_vision_audit` 做視覺審核;每次嘗試會寫入 `ppt_generation_runs`,`/observability/ppt_audit_history` 以精準參數檢查目標版本是否已產生,並可用 `/observability/ppt_audit/generate_missing` 手動補齊缺漏,總開關為 `PPT_AUTO_GENERATION_ENABLED`。
|
||||
- PPT 自動產線由 `momo-scheduler` 依節奏執行 `run_ppt_auto_generation_task(schedule_kind)`:每日 20:30 產日報、週一 20:40 產週報/市場情報、每月 1 日 20:50 產月報與管理型簡報、季初 21:00 產季報、半年初 21:10 產半年報、年初 21:20 產年報,再交給 22:00 `ppt_vision_audit` 做視覺審核;每次嘗試會寫入 `ppt_generation_runs`,`/observability/ppt_audit_history` 以精準參數檢查目標版本是否已產生,並可用 `/observability/ppt_audit/generate_missing` 手動補齊缺漏,總開關為 `PPT_AUTO_GENERATION_ENABLED`。PPT vision 需 `PPT_VISION_ENABLED=true` 與容器內 LibreOffice;`/observability/ppt_audit_file/<filename>` 會把 PPTX 轉成 PDF 快取供站內線上預覽,原始 PPTX 仍保留下載。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ Operation Ollama-First v5.0 / Phase 27 — Admin Observability Dashboard
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, render_template, request, jsonify, send_file
|
||||
from flask import Blueprint, render_template, request, jsonify, send_file, url_for
|
||||
from sqlalchemy import text as sa_text
|
||||
|
||||
from auth import login_required, get_current_user
|
||||
@@ -1866,6 +1866,7 @@ def ppt_audit_file(filename: str):
|
||||
"""提供觀測台簡報檔案預覽/下載。
|
||||
|
||||
- action=view 開啟預覽(預設)
|
||||
- action=pdf 產生/回傳線上預覽 PDF
|
||||
- action=download 直接下載
|
||||
"""
|
||||
action = (request.args.get('action', 'view') or 'view').strip().lower()
|
||||
@@ -1883,7 +1884,7 @@ def ppt_audit_file(filename: str):
|
||||
if safe_path.suffix.lower() != '.pptx':
|
||||
return '不支援的檔案格式', 400
|
||||
|
||||
if action == 'view':
|
||||
if action in ('view', 'pdf'):
|
||||
try:
|
||||
with zipfile.ZipFile(safe_path, 'r') as zf:
|
||||
bad = zf.testzip()
|
||||
@@ -1894,6 +1895,32 @@ def ppt_audit_file(filename: str):
|
||||
except Exception as e:
|
||||
return f'預覽檢查失敗:{type(e).__name__}', 409
|
||||
|
||||
if action in ('view', 'pdf'):
|
||||
from services.ppt_preview_service import build_ppt_preview
|
||||
|
||||
preview = build_ppt_preview(safe_path)
|
||||
if action == 'pdf':
|
||||
if not preview.ok or not preview.pdf_path:
|
||||
return preview.error or '無法產生預覽', 409
|
||||
return send_file(
|
||||
preview.pdf_path,
|
||||
mimetype='application/pdf',
|
||||
as_attachment=False,
|
||||
download_name=f'{safe_path.stem}.pdf',
|
||||
)
|
||||
|
||||
return render_template(
|
||||
'admin/ppt_audit_preview.html',
|
||||
active_page='obs_ppt_audit',
|
||||
filename=safe_path.name,
|
||||
file_size_kb=round(safe_path.stat().st_size / 1024, 1),
|
||||
file_mtime=datetime.fromtimestamp(safe_path.stat().st_mtime).strftime('%Y-%m-%d %H:%M'),
|
||||
preview=preview,
|
||||
pdf_url=url_for('admin_observability.ppt_audit_file', filename=safe_path.name, action='pdf'),
|
||||
download_url=url_for('admin_observability.ppt_audit_file', filename=safe_path.name, action='download'),
|
||||
back_url=url_for('admin_observability.ppt_audit_history'),
|
||||
)
|
||||
|
||||
return send_file(
|
||||
str(safe_path),
|
||||
mimetype='application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
@@ -2410,9 +2437,11 @@ def ppt_audit_history():
|
||||
logger.debug("PPT audit history table unavailable; rendering empty audit history", exc_info=True)
|
||||
|
||||
# PPT vision 啟用狀態
|
||||
vision_status = {'enabled': False, 'ready': False, 'blockers': ['視覺狀態讀取失敗']}
|
||||
try:
|
||||
from services.ppt_vision_service import is_ppt_vision_enabled
|
||||
vision_enabled = is_ppt_vision_enabled()
|
||||
from services.ppt_vision_service import get_ppt_vision_runtime_status
|
||||
vision_status = get_ppt_vision_runtime_status()
|
||||
vision_enabled = bool(vision_status.get('enabled'))
|
||||
except Exception:
|
||||
vision_enabled = False
|
||||
|
||||
@@ -2523,20 +2552,37 @@ def ppt_audit_history():
|
||||
'total': 0,
|
||||
'last_run': None,
|
||||
'can_auto_start': False,
|
||||
'cadences': [],
|
||||
'cadence_summary': '',
|
||||
}
|
||||
generation_runs = []
|
||||
try:
|
||||
from services.ppt_auto_generation_service import get_defined_report_coverage
|
||||
from services.ppt_auto_generation_service import (
|
||||
get_defined_report_coverage,
|
||||
get_generation_run_history,
|
||||
get_schedule_cadence_status,
|
||||
)
|
||||
|
||||
auto_generation = get_defined_report_coverage(
|
||||
month_start=month_start,
|
||||
month_end=month_end,
|
||||
reports_dir=reports_dir,
|
||||
)
|
||||
auto_generation.setdefault('cadences', get_schedule_cadence_status(auto_generation.get('items', [])))
|
||||
auto_generation.setdefault(
|
||||
'cadence_summary',
|
||||
'、'.join(c.get('schedule_text', '') for c in auto_generation.get('cadences', []) if c.get('schedule_text')),
|
||||
)
|
||||
auto_generation['can_auto_start'] = (
|
||||
bool(auto_generation.get('enabled'))
|
||||
and int(auto_generation.get('missing_count') or 0) > 0
|
||||
and month_label == datetime.now().strftime('%Y-%m')
|
||||
)
|
||||
generation_runs = get_generation_run_history(
|
||||
month_start=month_start,
|
||||
month_end=month_end,
|
||||
limit=24,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("PPT auto-generation coverage unavailable", exc_info=True)
|
||||
|
||||
@@ -2556,9 +2602,11 @@ def ppt_audit_history():
|
||||
audit_30d_stats=audit_30d_stats,
|
||||
top_failure_files=top_failure_files,
|
||||
vision_enabled=vision_enabled,
|
||||
vision_status=vision_status,
|
||||
auto_generation=auto_generation,
|
||||
auto_generation_items=auto_generation.get('items', []),
|
||||
auto_generation_missing_report_types=auto_generation.get('missing_report_types', []),
|
||||
generation_runs=generation_runs,
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
@@ -93,6 +93,51 @@ SCHEDULE_PROFILES = {
|
||||
"annual": ("annual",),
|
||||
}
|
||||
|
||||
SCHEDULE_CADENCES = {
|
||||
"daily": {
|
||||
"label": "每日",
|
||||
"schedule_text": "每日 20:30",
|
||||
"gate": "每日固定產出",
|
||||
"time": "20:30",
|
||||
"description": "每日補齊營運日報,供 22:00 視覺 QA 接續審核。",
|
||||
},
|
||||
"weekly": {
|
||||
"label": "每週",
|
||||
"schedule_text": "每週一 20:40",
|
||||
"gate": "週一固定產出",
|
||||
"time": "20:40",
|
||||
"description": "每週產出週報與市場情報,整理近 7 日變化。",
|
||||
},
|
||||
"monthly": {
|
||||
"label": "每月",
|
||||
"schedule_text": "每月 1 日 20:50",
|
||||
"gate": "每月 1 日產出",
|
||||
"time": "20:50",
|
||||
"description": "每月產出月報、策略、競品、促銷、品類與管理型簡報。",
|
||||
},
|
||||
"quarterly": {
|
||||
"label": "每季",
|
||||
"schedule_text": "每季首日 21:00",
|
||||
"gate": "1/4/7/10 月 1 日產出",
|
||||
"time": "21:00",
|
||||
"description": "每季首日產出季報,承接季度營運檢討。",
|
||||
},
|
||||
"half_yearly": {
|
||||
"label": "每半年",
|
||||
"schedule_text": "每半年首日 21:10",
|
||||
"gate": "1/7 月 1 日產出",
|
||||
"time": "21:10",
|
||||
"description": "每半年首日產出半年報,彙整 H1 / H2 成果。",
|
||||
},
|
||||
"annual": {
|
||||
"label": "每年",
|
||||
"schedule_text": "每年 1/1 21:20",
|
||||
"gate": "每年 1 月 1 日產出",
|
||||
"time": "21:20",
|
||||
"description": "每年首日產出年度總結,保留完整年度資料快照。",
|
||||
},
|
||||
}
|
||||
|
||||
_RUN_LOCK = threading.Lock()
|
||||
_LAST_RUN: dict | None = None
|
||||
|
||||
@@ -224,6 +269,49 @@ def get_report_type_options() -> list[dict]:
|
||||
] + [{"key": "all", "label": "全部", "prefix": "all"}]
|
||||
|
||||
|
||||
def get_schedule_cadence_status(coverage_items: Sequence[dict] | None = None) -> list[dict]:
|
||||
"""Return the scheduler contract with optional month coverage counts."""
|
||||
item_by_type = {
|
||||
str(item.get("key")): item
|
||||
for item in (coverage_items or [])
|
||||
if item.get("key")
|
||||
}
|
||||
cadences: list[dict] = []
|
||||
for key, meta in SCHEDULE_CADENCES.items():
|
||||
report_types = SCHEDULE_PROFILES.get(key, ())
|
||||
related_items = [item_by_type[report_type] for report_type in report_types if report_type in item_by_type]
|
||||
ready_count = sum(1 for item in related_items if item.get("ready"))
|
||||
missing_types = [
|
||||
report_type
|
||||
for report_type in report_types
|
||||
if not item_by_type.get(report_type, {}).get("ready")
|
||||
]
|
||||
total = len(report_types)
|
||||
if total and not missing_types:
|
||||
status = "ready"
|
||||
elif ready_count > 0:
|
||||
status = "partial"
|
||||
else:
|
||||
status = "missing"
|
||||
cadences.append({
|
||||
"key": key,
|
||||
"label": meta["label"],
|
||||
"schedule_text": meta["schedule_text"],
|
||||
"gate": meta["gate"],
|
||||
"time": meta["time"],
|
||||
"description": meta["description"],
|
||||
"report_types": list(report_types),
|
||||
"report_labels": [REPORT_TYPE_LABELS.get(report_type, report_type) for report_type in report_types],
|
||||
"ready_count": ready_count,
|
||||
"missing_count": len(missing_types),
|
||||
"missing_report_types": missing_types,
|
||||
"total": total,
|
||||
"progress_pct": round((ready_count / total * 100), 1) if total else 0,
|
||||
"status": status,
|
||||
})
|
||||
return cadences
|
||||
|
||||
|
||||
def build_defined_ppt_jobs(
|
||||
*,
|
||||
latest_date: str | None = None,
|
||||
@@ -513,9 +601,12 @@ def get_defined_report_coverage(
|
||||
for job in jobs
|
||||
]
|
||||
missing = [item for item in items if not item["ready"]]
|
||||
cadences = get_schedule_cadence_status(items)
|
||||
return {
|
||||
"enabled": is_ppt_auto_generation_enabled(),
|
||||
"items": items,
|
||||
"cadences": cadences,
|
||||
"cadence_summary": "、".join(cadence["schedule_text"] for cadence in cadences),
|
||||
"missing_report_types": [item["key"] for item in missing],
|
||||
"missing_count": len(missing),
|
||||
"ready_count": len(items) - len(missing),
|
||||
@@ -524,6 +615,78 @@ def get_defined_report_coverage(
|
||||
}
|
||||
|
||||
|
||||
def get_generation_run_history(
|
||||
*,
|
||||
month_start: datetime | None = None,
|
||||
month_end: datetime | None = None,
|
||||
limit: int = 24,
|
||||
) -> list[dict]:
|
||||
"""Read persisted PPT generation runs without blocking the observability page."""
|
||||
safe_limit = max(1, min(int(limit or 24), 100))
|
||||
where_clauses = []
|
||||
params: dict = {"limit": safe_limit}
|
||||
if month_start is not None:
|
||||
where_clauses.append("started_at >= :month_start")
|
||||
params["month_start"] = month_start
|
||||
if month_end is not None:
|
||||
where_clauses.append("started_at < :month_end")
|
||||
params["month_end"] = month_end
|
||||
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
|
||||
|
||||
try:
|
||||
session = get_session()
|
||||
try:
|
||||
rows = session.execute(
|
||||
sa_text(
|
||||
f"""
|
||||
SELECT schedule_kind, report_type, target_label, status,
|
||||
file_path, file_size, error_msg, started_at, finished_at
|
||||
FROM ppt_generation_runs
|
||||
{where_sql}
|
||||
ORDER BY started_at DESC NULLS LAST
|
||||
LIMIT :limit
|
||||
"""
|
||||
),
|
||||
params,
|
||||
).fetchall()
|
||||
finally:
|
||||
session.close()
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def _format_dt(value) -> str:
|
||||
if hasattr(value, "strftime"):
|
||||
return value.strftime("%Y-%m-%d %H:%M")
|
||||
return str(value or "")
|
||||
|
||||
items = []
|
||||
for row in rows:
|
||||
schedule_kind = row[0] or "manual"
|
||||
report_type = row[1] or ""
|
||||
file_path = row[4] or ""
|
||||
status = row[3] or ""
|
||||
items.append({
|
||||
"schedule_kind": schedule_kind,
|
||||
"schedule_label": SCHEDULE_CADENCES.get(schedule_kind, {}).get("label", "手動"),
|
||||
"report_type": report_type,
|
||||
"report_label": REPORT_TYPE_LABELS.get(report_type, report_type or "未知"),
|
||||
"target_label": row[2] or "",
|
||||
"status": status,
|
||||
"status_label": {
|
||||
"ready": "已產生",
|
||||
"missing_file": "未落盤",
|
||||
"error": "失敗",
|
||||
"planned": "已規劃",
|
||||
}.get(status, status or "未知"),
|
||||
"file_name": os.path.basename(file_path) if file_path else "",
|
||||
"file_size_kb": round(float(row[5] or 0) / 1024, 1) if row[5] else None,
|
||||
"error_msg": row[6] or "",
|
||||
"started_at": _format_dt(row[7]),
|
||||
"finished_at": _format_dt(row[8]),
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
def _generate_job(job: PPTAutoJob) -> str | None:
|
||||
from routes import openclaw_bot_routes as bot_routes
|
||||
|
||||
|
||||
103
services/ppt_preview_service.py
Normal file
103
services/ppt_preview_service.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""PPT online preview helpers.
|
||||
|
||||
Browsers cannot reliably render .pptx files inline, so the app converts a
|
||||
deck to a cached PDF and embeds that PDF in the observability preview page.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PPTPreviewResult:
|
||||
ok: bool
|
||||
pdf_path: str | None = None
|
||||
cache_hit: bool = False
|
||||
converter: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def find_libreoffice_binary() -> str | None:
|
||||
return shutil.which("libreoffice") or shutil.which("soffice")
|
||||
|
||||
|
||||
def _preview_cache_path(pptx_path: Path, cache_dir: Path) -> Path:
|
||||
stat = pptx_path.stat()
|
||||
cache_key = hashlib.sha256(
|
||||
f"{pptx_path.resolve()}:{stat.st_size}:{int(stat.st_mtime)}".encode("utf-8")
|
||||
).hexdigest()[:16]
|
||||
safe_stem = "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in pptx_path.stem)[:96]
|
||||
return cache_dir / f"{safe_stem}_{cache_key}.pdf"
|
||||
|
||||
|
||||
def build_ppt_preview(
|
||||
pptx_path: str | os.PathLike[str],
|
||||
*,
|
||||
cache_dir: str | os.PathLike[str] | None = None,
|
||||
timeout_sec: int = 90,
|
||||
) -> PPTPreviewResult:
|
||||
source = Path(pptx_path)
|
||||
if not source.is_file():
|
||||
return PPTPreviewResult(ok=False, error="pptx not found")
|
||||
if source.suffix.lower() != ".pptx":
|
||||
return PPTPreviewResult(ok=False, error="unsupported file type")
|
||||
|
||||
converter = find_libreoffice_binary()
|
||||
if not converter:
|
||||
return PPTPreviewResult(
|
||||
ok=False,
|
||||
error="LibreOffice is not installed in the app container.",
|
||||
)
|
||||
|
||||
target_dir = Path(cache_dir or os.getenv("PPT_PREVIEW_CACHE_DIR", "/app/data/ppt_previews"))
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
target_pdf = _preview_cache_path(source, target_dir)
|
||||
if target_pdf.is_file() and target_pdf.stat().st_size > 0:
|
||||
return PPTPreviewResult(ok=True, pdf_path=str(target_pdf), cache_hit=True, converter=converter)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="ppt_preview_") as tmp:
|
||||
tmpdir = Path(tmp)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[
|
||||
converter,
|
||||
"--headless",
|
||||
"--convert-to",
|
||||
"pdf",
|
||||
"--outdir",
|
||||
str(tmpdir),
|
||||
str(source),
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=timeout_sec,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return PPTPreviewResult(ok=False, converter=converter, error="LibreOffice conversion timed out.")
|
||||
except Exception as exc:
|
||||
return PPTPreviewResult(ok=False, converter=converter, error=f"{type(exc).__name__}: {str(exc)[:200]}")
|
||||
|
||||
if proc.returncode != 0:
|
||||
stderr = proc.stderr.decode("utf-8", errors="ignore").strip()
|
||||
return PPTPreviewResult(
|
||||
ok=False,
|
||||
converter=converter,
|
||||
error=f"LibreOffice conversion failed: {stderr[:240] or proc.returncode}",
|
||||
)
|
||||
|
||||
generated = tmpdir / f"{source.stem}.pdf"
|
||||
if not generated.is_file():
|
||||
candidates = sorted(tmpdir.glob("*.pdf"))
|
||||
generated = candidates[0] if candidates else generated
|
||||
if not generated.is_file() or generated.stat().st_size <= 0:
|
||||
return PPTPreviewResult(ok=False, converter=converter, error="LibreOffice did not produce a PDF.")
|
||||
|
||||
shutil.move(str(generated), str(target_pdf))
|
||||
return PPTPreviewResult(ok=True, pdf_path=str(target_pdf), cache_hit=False, converter=converter)
|
||||
@@ -21,6 +21,7 @@ import os
|
||||
import time
|
||||
import base64
|
||||
import logging
|
||||
import shutil
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
@@ -38,6 +39,27 @@ def is_ppt_vision_enabled() -> bool:
|
||||
return os.getenv('PPT_VISION_ENABLED', 'false').strip().lower() in ('true', '1', 'yes', 'on')
|
||||
|
||||
|
||||
def get_ppt_vision_runtime_status() -> Dict[str, Any]:
|
||||
"""Expose why the PPT vision pipeline is or is not ready."""
|
||||
env_value = os.getenv('PPT_VISION_ENABLED')
|
||||
enabled = is_ppt_vision_enabled()
|
||||
converter = shutil.which('libreoffice') or shutil.which('soffice')
|
||||
blockers = []
|
||||
if not enabled:
|
||||
blockers.append('PPT_VISION_ENABLED 未設定為 true')
|
||||
if not converter:
|
||||
blockers.append('容器內缺少 LibreOffice,無法轉換 PPT 做視覺審核')
|
||||
return {
|
||||
'enabled': enabled,
|
||||
'env_value': env_value if env_value is not None else '未設定(預設 false)',
|
||||
'model': os.getenv('PPT_VISION_MODEL', PPT_VISION_MODEL),
|
||||
'converter': converter,
|
||||
'converter_ready': bool(converter),
|
||||
'blockers': blockers,
|
||||
'ready': enabled and bool(converter),
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 結果容器
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -115,11 +137,16 @@ class PPTVisionService:
|
||||
result['error'] = f'pptx not found: {pptx_path}'
|
||||
return result
|
||||
|
||||
converter = shutil.which('libreoffice') or shutil.which('soffice')
|
||||
if not converter:
|
||||
result['error'] = 'libreoffice not installed (skip vision check)'
|
||||
return result
|
||||
|
||||
# 1. LibreOffice 轉 png
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
['libreoffice', '--headless', '--convert-to', 'png',
|
||||
[converter, '--headless', '--convert-to', 'png',
|
||||
'--outdir', tmpdir, pptx_path],
|
||||
capture_output=True, timeout=60,
|
||||
)
|
||||
@@ -440,6 +467,7 @@ __all__ = [
|
||||
'VisionResult',
|
||||
'ppt_vision_service',
|
||||
'is_ppt_vision_enabled',
|
||||
'get_ppt_vision_runtime_status',
|
||||
'PPT_VISION_SYSTEM_PROMPT',
|
||||
'audit_recent_ppts',
|
||||
'push_ppt_audit_to_telegram',
|
||||
|
||||
@@ -49,6 +49,22 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% if not vision_status.ready %}
|
||||
<section class="ppt-diagnostic-strip">
|
||||
<div>
|
||||
<div class="ppt-label">視覺 QA 尚未就緒</div>
|
||||
<strong>目前不是模型能力問題,而是執行環境尚未完整開啟。</strong>
|
||||
</div>
|
||||
<div class="ppt-diagnostic-items">
|
||||
<span>PPT_VISION_ENABLED={{ vision_status.env_value }}</span>
|
||||
<span>模型 {{ vision_status.model }}</span>
|
||||
<span>{{ 'LibreOffice OK' if vision_status.converter_ready else '缺 LibreOffice' }}</span>
|
||||
</div>
|
||||
{% if vision_status.blockers %}
|
||||
<small class="text-muted">{{ vision_status.blockers|join(';') }}</small>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
{% if error %}<div class="alert alert-warning mt-3"><strong><i class="fas fa-triangle-exclamation me-1"></i></strong>{{ error }}</div>{% endif %}
|
||||
|
||||
<section class="ppt-toolbar">
|
||||
@@ -79,35 +95,98 @@
|
||||
data-report-types="{{ auto_generation_missing_report_types | join(',') }}">
|
||||
<div class="ppt-panel-head">
|
||||
<div>
|
||||
<div class="ppt-label">自動產生定義</div>
|
||||
<h2 class="ppt-panel-title">簡報產線覆蓋狀態</h2>
|
||||
<div class="ppt-label">Production Command Center</div>
|
||||
<h2 class="ppt-panel-title">簡報產線控制台</h2>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-primary" type="button" data-ppt-generate-missing {% if not auto_generation.enabled or auto_generation.missing_count == 0 %}disabled{% endif %}>
|
||||
<i class="fas fa-wand-magic-sparkles me-1" aria-hidden="true"></i>補齊缺漏簡報
|
||||
</button>
|
||||
</div>
|
||||
<div class="ppt-panel-body">
|
||||
<div class="ppt-auto-grid">
|
||||
{% for item in auto_generation_items %}
|
||||
<div class="ppt-mini">
|
||||
<span class="ppt-label">{{ item.label }}</span>
|
||||
<strong class="{% if item.ready %}status-good{% elif item.has_other_versions %}status-blue{% else %}status-warn{% endif %}">
|
||||
{% if item.ready %}目標已產生{% elif item.has_other_versions %}有其他版本{% else %}待補齊{% endif %}
|
||||
</strong>
|
||||
<small class="text-muted">
|
||||
{{ item.target_label or '最新資料' }} · {{ item.count }} 筆{% if item.sources %} · {{ item.sources|join(' + ') }}{% endif %}
|
||||
{% if item.latest_generated_at %}<br>最近 {{ item.latest_generated_at }}{% endif %}
|
||||
</small>
|
||||
<div class="ppt-pipeline-layout">
|
||||
<div class="ppt-cadence-rail" aria-label="PPT 定期產出節奏">
|
||||
{% for cadence in auto_generation.cadences %}
|
||||
<article class="ppt-cadence-tile is-{{ cadence.status }}">
|
||||
<div class="ppt-cadence-top">
|
||||
<span class="ppt-label">{{ cadence.label }}</span>
|
||||
<strong>{{ cadence.schedule_text }}</strong>
|
||||
</div>
|
||||
<div class="ppt-cadence-meter" aria-hidden="true">
|
||||
<span style="width: {{ cadence.progress_pct }}%"></span>
|
||||
</div>
|
||||
<div class="ppt-cadence-meta">
|
||||
<span>{{ cadence.gate }}</span>
|
||||
{% if cadence.missing_count > 0 %}<span>缺 {{ cadence.missing_count }} 類</span>{% else %}<span>完整</span>{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="ppt-coverage-board">
|
||||
<div class="ppt-coverage-score">
|
||||
<div>
|
||||
<span class="ppt-label">定義簡報</span>
|
||||
<strong>{{ auto_generation.ready_count }}/{{ auto_generation.total }}</strong>
|
||||
<small class="text-muted">目前缺漏 {{ auto_generation.missing_count }} 類</small>
|
||||
</div>
|
||||
<div>
|
||||
<span class="ppt-label">DB 紀錄</span>
|
||||
<strong>{{ generation_runs|length }}</strong>
|
||||
<small class="text-muted">本月最近寫入</small>
|
||||
</div>
|
||||
<div>
|
||||
<span class="ppt-label">線上預覽</span>
|
||||
<strong>PDF</strong>
|
||||
<small class="text-muted">PPTX 轉檔快取</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ppt-coverage-list" aria-label="定義簡報覆蓋明細">
|
||||
{% for item in auto_generation_items %}
|
||||
<div class="ppt-coverage-row">
|
||||
<div>
|
||||
<strong>{{ item.label }}</strong>
|
||||
<small>{{ item.target_label or '最新資料' }}{% if item.latest_generated_at %} · {{ item.latest_generated_at }}{% endif %}</small>
|
||||
</div>
|
||||
<span class="ppt-run-status {% if item.ready %}is-ready{% elif item.has_other_versions %}is-partial{% else %}is-missing_file{% endif %}">
|
||||
{% if item.ready %}已產生{% elif item.has_other_versions %}其他版本{% else %}待補齊{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="ppt-auto-status small text-muted mt-3" data-ppt-auto-status>
|
||||
{% if auto_generation.enabled %}
|
||||
每日 20:30 會自動補齊定義簡報;目前缺漏 {{ auto_generation.missing_count }} 類。
|
||||
{{ auto_generation.cadence_summary }} 會定期產出並寫入 DB;目前缺漏 {{ auto_generation.missing_count }} 類。
|
||||
{% else %}
|
||||
PPT_AUTO_GENERATION_ENABLED=false,已停用自動補齊。
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="ppt-run-log mt-3">
|
||||
<div class="ppt-run-log-head">
|
||||
<div>
|
||||
<div class="ppt-label">DB 產出紀錄</div>
|
||||
<h3>最近寫入 ppt_generation_runs</h3>
|
||||
</div>
|
||||
<small class="text-muted">產出檔案、參數、狀態會同步保留在資料庫</small>
|
||||
</div>
|
||||
{% if generation_runs %}
|
||||
<div class="ppt-run-list">
|
||||
{% for run in generation_runs %}
|
||||
<div class="ppt-run-row">
|
||||
<span class="ppt-run-kind">{{ run.schedule_label }}</span>
|
||||
<strong>{{ run.report_label }}</strong>
|
||||
<span class="ppt-run-target">{{ run.target_label or '最新資料' }}</span>
|
||||
<span class="ppt-run-status is-{{ run.status }}">{{ run.status_label }}</span>
|
||||
<small class="text-muted">{{ run.started_at }}{% if run.finished_at %} → {{ run.finished_at }}{% endif %}</small>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="ppt-empty ppt-run-empty">
|
||||
目前查無本月 DB 產出紀錄;下一次自動排程或手動補齊後,會寫入 ppt_generation_runs 並顯示在這裡。
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="ppt-grid">
|
||||
@@ -223,7 +302,7 @@
|
||||
{% if f.file_exists %}
|
||||
{% if f.is_valid_ppt %}
|
||||
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('admin_observability.ppt_audit_file', filename=f.name) }}" target="_blank" rel="noopener">
|
||||
<i class="fas fa-file-powerpoint me-1"></i>開啟
|
||||
<i class="fas fa-eye me-1"></i>線上預覽
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="small status-bad">檔案不可預覽</span>
|
||||
|
||||
48
templates/admin/ppt_audit_preview.html
Normal file
48
templates/admin/ppt_audit_preview.html
Normal file
@@ -0,0 +1,48 @@
|
||||
{% extends "ewoooc_base.html" %}
|
||||
|
||||
{% block title %}PPT 線上預覽{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/page-ppt-preview.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block ewooo_content %}
|
||||
<div class="container-fluid mt-3">
|
||||
<section class="ppt-preview-hero">
|
||||
<div>
|
||||
<div class="ppt-preview-kicker"><i class="fas fa-file-powerpoint me-1"></i>PPT Online Preview</div>
|
||||
<h1 class="ppt-preview-title">{{ filename }}</h1>
|
||||
<p class="ppt-preview-subtitle">檔案大小 {{ file_size_kb }} KB · 修改時間 {{ file_mtime }} · 預覽以 PDF 快取呈現,原始 PPTX 仍可下載。</p>
|
||||
</div>
|
||||
<div class="ppt-preview-actions">
|
||||
<a class="btn btn-outline-secondary btn-sm" href="{{ back_url }}"><i class="fas fa-angle-left me-1"></i>回產線</a>
|
||||
<a class="btn btn-outline-primary btn-sm" href="{{ download_url }}"><i class="fas fa-download me-1"></i>下載 PPTX</a>
|
||||
{% if preview.ok %}
|
||||
<a class="btn btn-primary btn-sm" href="{{ pdf_url }}" target="_blank" rel="noopener"><i class="fas fa-up-right-from-square me-1"></i>開新視窗</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if preview.ok %}
|
||||
<section class="ppt-preview-frame">
|
||||
<iframe src="{{ pdf_url }}" title="{{ filename }} 線上預覽"></iframe>
|
||||
</section>
|
||||
<p class="ppt-preview-note">
|
||||
<span class="status-good">預覽已產生</span>
|
||||
{% if preview.cache_hit %} · 使用既有快取{% else %} · 已建立新快取{% endif %}
|
||||
{% if preview.converter %} · {{ preview.converter }}{% endif %}
|
||||
</p>
|
||||
{% else %}
|
||||
<section class="ppt-preview-empty">
|
||||
<div class="ppt-preview-kicker"><i class="fas fa-triangle-exclamation me-1"></i>Preview unavailable</div>
|
||||
<h2>目前無法產生線上預覽</h2>
|
||||
<p>{{ preview.error or '轉檔流程沒有回傳可用 PDF。' }}</p>
|
||||
<div class="ppt-preview-diagnostics">
|
||||
<span>需要容器內可執行 LibreOffice / soffice</span>
|
||||
<span>部署後會用 PDF 快取避免每次重轉</span>
|
||||
<span>原始 PPTX 可先下載檢查</span>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -174,6 +174,81 @@ def test_ppt_audit_history_200(client):
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_ppt_audit_history_shows_ppt_schedule_and_db_runs(client, monkeypatch):
|
||||
"""PPT 產線頁必須呈現六種固定週期與 DB 寫入紀錄。"""
|
||||
from services import ppt_auto_generation_service as svc
|
||||
|
||||
cadences = svc.get_schedule_cadence_status([
|
||||
{'key': key, 'ready': True}
|
||||
for key in svc.DEFINED_REPORT_TYPES
|
||||
])
|
||||
monkeypatch.setattr(svc, 'get_defined_report_coverage', lambda **_kw: {
|
||||
'enabled': True,
|
||||
'items': [],
|
||||
'missing_report_types': [],
|
||||
'missing_count': 0,
|
||||
'ready_count': len(svc.DEFINED_REPORT_TYPES),
|
||||
'total': len(svc.DEFINED_REPORT_TYPES),
|
||||
'last_run': None,
|
||||
'cadences': cadences,
|
||||
'cadence_summary': '、'.join(c['schedule_text'] for c in cadences),
|
||||
})
|
||||
monkeypatch.setattr(svc, 'get_generation_run_history', lambda **_kw: [{
|
||||
'schedule_kind': 'daily',
|
||||
'schedule_label': '每日',
|
||||
'report_type': 'daily',
|
||||
'report_label': '每日日報',
|
||||
'target_label': '2026/05/17',
|
||||
'status': 'ready',
|
||||
'status_label': '已產生',
|
||||
'file_name': 'ocbot_daily_20260517.pptx',
|
||||
'file_size_kb': 1024,
|
||||
'error_msg': '',
|
||||
'started_at': '2026-05-17 20:30',
|
||||
'finished_at': '2026-05-17 20:31',
|
||||
}])
|
||||
|
||||
r = client.get('/observability/ppt_audit_history')
|
||||
html = r.data.decode('utf-8')
|
||||
|
||||
for text in ['每日 20:30', '每週一 20:40', '每月 1 日 20:50', '每季首日 21:00', '每半年首日 21:10', '每年 1/1 21:20']:
|
||||
assert text in html
|
||||
assert 'ppt_generation_runs' in html
|
||||
assert '每日日報' in html
|
||||
|
||||
|
||||
def test_ppt_audit_file_view_renders_online_preview(client, monkeypatch, tmp_path):
|
||||
"""PPTX view 入口應回站內預覽頁,而不是把 PPTX 直接丟給瀏覽器。"""
|
||||
import zipfile
|
||||
from services import ppt_preview_service as preview_svc
|
||||
|
||||
reports_dir = tmp_path / 'reports'
|
||||
reports_dir.mkdir()
|
||||
pptx = reports_dir / 'ocbot_daily_20260517.pptx'
|
||||
with zipfile.ZipFile(pptx, 'w') as zf:
|
||||
zf.writestr('[Content_Types].xml', '<Types></Types>')
|
||||
|
||||
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(reports_dir / 'preview.pdf'),
|
||||
cache_hit=True,
|
||||
converter='/usr/bin/libreoffice',
|
||||
),
|
||||
)
|
||||
|
||||
r = client.get('/observability/ppt_audit_file/ocbot_daily_20260517.pptx')
|
||||
html = r.data.decode('utf-8')
|
||||
|
||||
assert r.status_code == 200
|
||||
assert 'PPT 線上預覽' in html
|
||||
assert 'action=pdf' in html
|
||||
assert '下載 PPTX' in html
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# /observability/host_health
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -124,6 +124,30 @@ def test_due_schedule_kinds_include_periodic_boundaries():
|
||||
]
|
||||
|
||||
|
||||
def test_schedule_cadence_status_exposes_all_periodic_contracts():
|
||||
from services.ppt_auto_generation_service import get_schedule_cadence_status
|
||||
|
||||
cadences = get_schedule_cadence_status([
|
||||
{"key": "daily", "ready": True},
|
||||
{"key": "weekly", "ready": False},
|
||||
{"key": "market_intel", "ready": True},
|
||||
])
|
||||
by_key = {cadence["key"]: cadence for cadence in cadences}
|
||||
|
||||
assert [cadence["schedule_text"] for cadence in cadences] == [
|
||||
"每日 20:30",
|
||||
"每週一 20:40",
|
||||
"每月 1 日 20:50",
|
||||
"每季首日 21:00",
|
||||
"每半年首日 21:10",
|
||||
"每年 1/1 21:20",
|
||||
]
|
||||
assert by_key["weekly"]["report_types"] == ["weekly", "market_intel"]
|
||||
assert by_key["weekly"]["ready_count"] == 1
|
||||
assert by_key["weekly"]["missing_report_types"] == ["weekly"]
|
||||
assert "TTM 滾動 12 月" in by_key["monthly"]["report_labels"]
|
||||
|
||||
|
||||
def test_scheduled_generation_uses_profile_without_generating(monkeypatch):
|
||||
from services import ppt_auto_generation_service as svc
|
||||
|
||||
@@ -144,3 +168,16 @@ def test_scheduled_generation_uses_profile_without_generating(monkeypatch):
|
||||
"schedule_kind": "weekly",
|
||||
"force": True,
|
||||
}]
|
||||
|
||||
|
||||
def test_ppt_preview_reports_missing_converter(monkeypatch, tmp_path):
|
||||
from services import ppt_preview_service as svc
|
||||
|
||||
pptx = tmp_path / "demo.pptx"
|
||||
pptx.write_bytes(b"fake pptx")
|
||||
monkeypatch.setattr(svc.shutil, "which", lambda _name: None)
|
||||
|
||||
result = svc.build_ppt_preview(pptx, cache_dir=tmp_path / "cache")
|
||||
|
||||
assert result.ok is False
|
||||
assert "LibreOffice" in (result.error or "")
|
||||
|
||||
@@ -36,6 +36,43 @@
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.ppt-diagnostic-strip {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 0.8fr) minmax(260px, 1fr) minmax(260px, 1.2fr);
|
||||
align-items: center;
|
||||
gap: var(--momo-space-3, 12px);
|
||||
margin-top: var(--momo-space-3, 12px);
|
||||
padding: var(--momo-space-3, 12px) var(--momo-space-4, 16px);
|
||||
border: 1px solid rgba(181, 111, 56, 0.35);
|
||||
border-radius: var(--momo-radius-lg, 8px);
|
||||
background:
|
||||
radial-gradient(circle, rgba(181, 111, 56, 0.1) 1px, transparent 1.2px),
|
||||
rgba(255, 250, 242, 0.78);
|
||||
background-size: 10px 10px, auto;
|
||||
}
|
||||
|
||||
.ppt-diagnostic-strip strong {
|
||||
display: block;
|
||||
margin-top: var(--momo-space-1, 4px);
|
||||
color: var(--obs-ink);
|
||||
}
|
||||
|
||||
.ppt-diagnostic-items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--momo-space-2, 8px);
|
||||
}
|
||||
|
||||
.ppt-diagnostic-items span {
|
||||
padding: var(--momo-space-1, 4px) var(--momo-space-2, 8px);
|
||||
border: 1px solid rgba(181, 111, 56, 0.28);
|
||||
border-radius: 999px;
|
||||
color: var(--obs-accent);
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
font-size: var(--momo-text-caption, 12px);
|
||||
font-weight: var(--momo-font-weight-bold, 700);
|
||||
}
|
||||
|
||||
.ppt-command {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
@@ -138,6 +175,211 @@
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--momo-space-3, 12px);
|
||||
margin-top: var(--momo-space-4, 16px);
|
||||
}
|
||||
|
||||
.ppt-pipeline-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 0.38fr) minmax(0, 0.62fr);
|
||||
gap: var(--momo-space-4, 16px);
|
||||
}
|
||||
|
||||
.ppt-cadence-rail,
|
||||
.ppt-coverage-board {
|
||||
display: grid;
|
||||
gap: var(--momo-space-3, 12px);
|
||||
}
|
||||
|
||||
.ppt-cadence-tile {
|
||||
padding: var(--momo-space-3, 12px);
|
||||
border: 1px solid var(--obs-line);
|
||||
border-radius: var(--momo-radius-lg, 8px);
|
||||
background:
|
||||
radial-gradient(circle, rgba(45, 40, 32, 0.08) 1px, transparent 1.2px),
|
||||
rgba(255, 255, 255, 0.56);
|
||||
background-size: 10px 10px, auto;
|
||||
}
|
||||
|
||||
.ppt-cadence-top,
|
||||
.ppt-cadence-meta,
|
||||
.ppt-run-log-head,
|
||||
.ppt-run-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--momo-space-2, 8px);
|
||||
}
|
||||
|
||||
.ppt-cadence-top strong {
|
||||
font-size: var(--momo-text-caption, 12px);
|
||||
color: var(--obs-accent);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ppt-cadence-title {
|
||||
margin-top: var(--momo-space-2, 8px);
|
||||
color: var(--obs-ink);
|
||||
font-size: var(--momo-text-body, 14px);
|
||||
font-weight: var(--momo-font-weight-black, 800);
|
||||
}
|
||||
|
||||
.ppt-cadence-gate {
|
||||
margin-top: var(--momo-space-1, 4px);
|
||||
color: var(--obs-muted);
|
||||
font-size: var(--momo-text-caption, 12px);
|
||||
}
|
||||
|
||||
.ppt-cadence-meter {
|
||||
height: 5px;
|
||||
margin: var(--momo-space-3, 12px) 0 var(--momo-space-2, 8px);
|
||||
border-radius: 999px;
|
||||
background: rgba(45, 40, 32, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ppt-cadence-meter span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--obs-green);
|
||||
}
|
||||
|
||||
.ppt-cadence-tile.is-partial .ppt-cadence-meter span {
|
||||
background: var(--obs-amber);
|
||||
}
|
||||
|
||||
.ppt-cadence-tile.is-missing .ppt-cadence-meter span {
|
||||
background: var(--obs-red);
|
||||
}
|
||||
|
||||
.ppt-cadence-meta {
|
||||
color: var(--obs-muted);
|
||||
font-size: var(--momo-text-caption, 12px);
|
||||
}
|
||||
|
||||
.ppt-coverage-score {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--momo-space-3, 12px);
|
||||
}
|
||||
|
||||
.ppt-coverage-score > div,
|
||||
.ppt-coverage-row {
|
||||
border: 1px solid var(--obs-line);
|
||||
border-radius: var(--momo-radius-md, 6px);
|
||||
background: rgba(255, 255, 255, 0.54);
|
||||
}
|
||||
|
||||
.ppt-coverage-score > div {
|
||||
padding: var(--momo-space-3, 12px);
|
||||
}
|
||||
|
||||
.ppt-coverage-score strong {
|
||||
display: block;
|
||||
margin-top: var(--momo-space-1, 4px);
|
||||
color: var(--obs-ink);
|
||||
font-size: var(--momo-text-headline, 22px);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.ppt-coverage-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--momo-space-2, 8px);
|
||||
}
|
||||
|
||||
.ppt-coverage-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--momo-space-3, 12px);
|
||||
min-height: 58px;
|
||||
padding: var(--momo-space-2, 8px) var(--momo-space-3, 12px);
|
||||
}
|
||||
|
||||
.ppt-coverage-row strong {
|
||||
display: block;
|
||||
color: var(--obs-ink);
|
||||
font-size: var(--momo-text-body, 14px);
|
||||
}
|
||||
|
||||
.ppt-coverage-row small {
|
||||
display: block;
|
||||
color: var(--obs-muted);
|
||||
font-size: var(--momo-text-caption, 12px);
|
||||
}
|
||||
|
||||
.ppt-run-log {
|
||||
border-top: 1px solid var(--obs-line);
|
||||
padding-top: var(--momo-space-4, 16px);
|
||||
}
|
||||
|
||||
.ppt-run-log-head h3 {
|
||||
margin: var(--momo-space-1, 4px) 0 0;
|
||||
font-size: var(--momo-text-body, 14px);
|
||||
font-weight: var(--momo-font-weight-black, 800);
|
||||
}
|
||||
|
||||
.ppt-run-list {
|
||||
display: grid;
|
||||
gap: var(--momo-space-2, 8px);
|
||||
margin-top: var(--momo-space-3, 12px);
|
||||
}
|
||||
|
||||
.ppt-run-row {
|
||||
padding: var(--momo-space-2, 8px) var(--momo-space-3, 12px);
|
||||
border: 1px solid var(--obs-line);
|
||||
border-radius: var(--momo-radius-md, 6px);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.ppt-run-kind,
|
||||
.ppt-run-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: 0 var(--momo-space-2, 8px);
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--obs-line);
|
||||
color: var(--obs-accent);
|
||||
font-size: var(--momo-text-caption, 12px);
|
||||
font-weight: var(--momo-font-weight-bold, 700);
|
||||
}
|
||||
|
||||
.ppt-run-status.is-ready {
|
||||
color: var(--obs-green);
|
||||
border-color: rgba(76, 137, 91, 0.35);
|
||||
}
|
||||
|
||||
.ppt-run-status.is-partial {
|
||||
color: var(--obs-blue);
|
||||
border-color: rgba(72, 108, 149, 0.35);
|
||||
}
|
||||
|
||||
.ppt-run-status.is-error,
|
||||
.ppt-run-status.is-missing_file {
|
||||
color: var(--obs-red);
|
||||
border-color: rgba(196, 84, 75, 0.35);
|
||||
}
|
||||
|
||||
.ppt-run-target {
|
||||
color: var(--obs-muted);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ppt-empty {
|
||||
border: 1px dashed var(--obs-line);
|
||||
border-radius: var(--momo-radius-lg, 8px);
|
||||
background:
|
||||
radial-gradient(circle, rgba(45, 40, 32, 0.08) 1px, transparent 1.2px),
|
||||
rgba(255, 255, 255, 0.38);
|
||||
background-size: 10px 10px, auto;
|
||||
color: var(--obs-muted);
|
||||
}
|
||||
|
||||
.ppt-run-empty {
|
||||
margin-top: var(--momo-space-3, 12px);
|
||||
padding: var(--momo-space-3, 12px);
|
||||
}
|
||||
|
||||
.ppt-mini,
|
||||
@@ -192,6 +434,11 @@
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.ppt-diagnostic-strip,
|
||||
.ppt-pipeline-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ppt-auto-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
@@ -204,12 +451,18 @@
|
||||
@media (max-width: 760px) {
|
||||
.ppt-command,
|
||||
.ppt-auto-grid,
|
||||
.ppt-mini-grid {
|
||||
.ppt-mini-grid,
|
||||
.ppt-coverage-score,
|
||||
.ppt-coverage-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ppt-panel-head,
|
||||
.ppt-table-title {
|
||||
.ppt-table-title,
|
||||
.ppt-run-log-head,
|
||||
.ppt-run-row,
|
||||
.ppt-coverage-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
111
web/static/css/page-ppt-preview.css
Normal file
111
web/static/css/page-ppt-preview.css
Normal file
@@ -0,0 +1,111 @@
|
||||
.ppt-preview-hero,
|
||||
.ppt-preview-frame,
|
||||
.ppt-preview-empty {
|
||||
border: 1px solid var(--obs-line);
|
||||
border-radius: var(--momo-radius-lg, 8px);
|
||||
background:
|
||||
radial-gradient(circle, rgba(45, 40, 32, 0.08) 1px, transparent 1.2px),
|
||||
var(--obs-card);
|
||||
background-size: 10px 10px, auto;
|
||||
box-shadow: var(--momo-shadow-md, 0 16px 38px rgba(70, 46, 28, 0.08));
|
||||
}
|
||||
|
||||
.ppt-preview-hero {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--momo-space-4, 16px);
|
||||
padding: var(--momo-space-5, 24px);
|
||||
}
|
||||
|
||||
.ppt-preview-kicker {
|
||||
color: var(--obs-accent);
|
||||
font-size: var(--momo-text-caption, 12px);
|
||||
font-weight: var(--momo-font-weight-bold, 700);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.ppt-preview-title {
|
||||
margin: var(--momo-space-2, 8px) 0 var(--momo-space-1, 4px);
|
||||
color: var(--obs-ink);
|
||||
font-family: var(--momo-font-display, "Inter", "Noto Sans TC", system-ui, sans-serif);
|
||||
font-size: clamp(22px, 2vw, 30px);
|
||||
line-height: var(--momo-line-height-tight, 1.08);
|
||||
letter-spacing: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ppt-preview-subtitle,
|
||||
.ppt-preview-note {
|
||||
color: var(--obs-muted);
|
||||
}
|
||||
|
||||
.ppt-preview-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: var(--momo-space-2, 8px);
|
||||
}
|
||||
|
||||
.ppt-preview-frame {
|
||||
height: min(76vh, 920px);
|
||||
min-height: 560px;
|
||||
margin-top: var(--momo-space-4, 16px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ppt-preview-frame iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.ppt-preview-empty {
|
||||
margin-top: var(--momo-space-4, 16px);
|
||||
padding: var(--momo-space-5, 24px);
|
||||
}
|
||||
|
||||
.ppt-preview-empty h2 {
|
||||
margin: var(--momo-space-2, 8px) 0;
|
||||
color: var(--obs-ink);
|
||||
font-size: var(--momo-text-title, 18px);
|
||||
font-weight: var(--momo-font-weight-black, 800);
|
||||
}
|
||||
|
||||
.ppt-preview-diagnostics {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--momo-space-2, 8px);
|
||||
margin-top: var(--momo-space-3, 12px);
|
||||
}
|
||||
|
||||
.ppt-preview-diagnostics span {
|
||||
padding: var(--momo-space-1, 4px) var(--momo-space-2, 8px);
|
||||
border: 1px solid var(--obs-line);
|
||||
border-radius: 999px;
|
||||
color: var(--obs-muted);
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
font-size: var(--momo-text-caption, 12px);
|
||||
}
|
||||
|
||||
.status-good {
|
||||
color: var(--obs-green);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.ppt-preview-hero {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ppt-preview-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.ppt-preview-frame {
|
||||
height: 70vh;
|
||||
min-height: 420px;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user