Fix PPT auto generation and analytics fallbacks
Some checks failed
CD Pipeline / deploy (push) Failing after 26s
Some checks failed
CD Pipeline / deploy (push) Failing after 26s
This commit is contained in:
@@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
|
||||
# ==========================================
|
||||
# 系統版本與路徑
|
||||
# ==========================================
|
||||
SYSTEM_VERSION = "V10.172"
|
||||
SYSTEM_VERSION = "V10.179"
|
||||
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
|
||||
public_url = PUBLIC_URL # 用於模板顯示
|
||||
|
||||
|
||||
@@ -78,8 +78,8 @@ services:
|
||||
- FLASK_ENV=production
|
||||
- PYTHONUNBUFFERED=1
|
||||
- TZ=Asia/Taipei
|
||||
- METABASE_URL=https://mo.wooo.work/metabase
|
||||
- GRIST_URL=https://grist.wooo.work
|
||||
- METABASE_URL=/metabase
|
||||
- GRIST_URL=/grist
|
||||
# 關閉登入驗證(開發/測試用,生產環境預設啟用登入)
|
||||
- DISABLE_LOGIN=${DISABLE_LOGIN:-false}
|
||||
# 資料庫設定: Docker 環境使用 PostgreSQL
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# MOMO PRO — AI 競價情報模組 Single Source of Truth
|
||||
|
||||
> **最後更新**: 2026-05-13 (台北時間)
|
||||
> **最後更新**: 2026-05-18 (台北時間)
|
||||
> **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯,Gemini 僅備援 / 鎖定場景
|
||||
> **適用版本**: V10.129
|
||||
> **適用版本**: V10.179
|
||||
|
||||
---
|
||||
|
||||
@@ -105,6 +105,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` 每日 20:30 執行 `run_ppt_auto_generation_task()`,先補齊 `daily` / `weekly` / `monthly` / `strategy` / `competitor` / `promo` 定義簡報,再交給 22:00 `ppt_vision_audit` 做視覺審核;`/observability/ppt_audit_history` 可檢視覆蓋狀態並用 `/observability/ppt_audit/generate_missing` 補齊缺漏,總開關為 `PPT_AUTO_GENERATION_ENABLED`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1836,6 +1836,26 @@ def ppt_audit_trigger_aider_heal():
|
||||
return jsonify({'ok': False, 'error': f'{type(e).__name__}: {str(e)[:200]}'}), 500
|
||||
|
||||
|
||||
@admin_observability_bp.route('/ppt_audit/generate_missing', methods=['POST'])
|
||||
@login_required
|
||||
def ppt_audit_generate_missing():
|
||||
"""補齊 PPT audit 頁定義中的簡報產出。
|
||||
|
||||
這是非阻塞入口:Web 頁面只負責排入背景 thread,真正的產生流程共用
|
||||
Telegram/OpenClaw 既有 generator 與 cache key。
|
||||
"""
|
||||
try:
|
||||
from services.ppt_auto_generation_service import start_defined_ppt_generation_background
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
report_types = data.get('report_types') or None
|
||||
force = bool(data.get('force'))
|
||||
result = start_defined_ppt_generation_background(report_types=report_types, force=force)
|
||||
return jsonify(result), 202 if result.get('status') == 'queued' else 200
|
||||
except Exception as e:
|
||||
return jsonify({'ok': False, 'error': f'{type(e).__name__}: {str(e)[:200]}'}), 500
|
||||
|
||||
|
||||
@admin_observability_bp.route('/ppt_audit_file/<path:filename>')
|
||||
@login_required
|
||||
def ppt_audit_file(filename: str):
|
||||
@@ -2486,6 +2506,32 @@ def ppt_audit_history():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
auto_generation = {
|
||||
'enabled': False,
|
||||
'items': [],
|
||||
'missing_report_types': [],
|
||||
'missing_count': 0,
|
||||
'ready_count': 0,
|
||||
'total': 0,
|
||||
'last_run': None,
|
||||
'can_auto_start': False,
|
||||
}
|
||||
try:
|
||||
from services.ppt_auto_generation_service import get_defined_report_coverage
|
||||
|
||||
auto_generation = get_defined_report_coverage(
|
||||
month_start=month_start,
|
||||
month_end=month_end,
|
||||
reports_dir=reports_dir,
|
||||
)
|
||||
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')
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("PPT auto-generation coverage unavailable", exc_info=True)
|
||||
|
||||
return render_template(
|
||||
'admin/ppt_audit_history.html',
|
||||
active_page='obs_ppt_audit',
|
||||
@@ -2502,6 +2548,9 @@ def ppt_audit_history():
|
||||
audit_30d_stats=audit_30d_stats,
|
||||
top_failure_files=top_failure_files,
|
||||
vision_enabled=vision_enabled,
|
||||
auto_generation=auto_generation,
|
||||
auto_generation_items=auto_generation.get('items', []),
|
||||
auto_generation_missing_report_types=auto_generation.get('missing_report_types', []),
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
@@ -45,6 +45,50 @@ def health_check():
|
||||
}), 500
|
||||
|
||||
|
||||
@system_public_bp.route('/metabase')
|
||||
@system_public_bp.route('/metabase/')
|
||||
@login_required
|
||||
def metabase_status():
|
||||
"""Internal status page for the BI entrypoint when the public proxy is not attached."""
|
||||
return render_template(
|
||||
'external_tool_status.html',
|
||||
active_page='metabase',
|
||||
system_version=SYSTEM_VERSION,
|
||||
tool={
|
||||
'key': 'metabase',
|
||||
'eyebrow': 'Analytics Bridge',
|
||||
'title': '自訂圖表入口',
|
||||
'status_label': '代理尚未接入',
|
||||
'summary': '正式入口已留在 momo-pro 內部,避免再落到 404 或空白頁。',
|
||||
'detail': 'Metabase 容器以 bi profile 管理;公開路由需由 Gateway / Nginx 接到 momo-metabase:3000 後才會切換為完整 BI 介面。',
|
||||
'primary_label': '回月份總表',
|
||||
'primary_href': '/monthly_summary_analysis',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@system_public_bp.route('/grist')
|
||||
@system_public_bp.route('/grist/')
|
||||
@login_required
|
||||
def grist_status():
|
||||
"""Internal status page for the data collaboration entrypoint."""
|
||||
return render_template(
|
||||
'external_tool_status.html',
|
||||
active_page='grist',
|
||||
system_version=SYSTEM_VERSION,
|
||||
tool={
|
||||
'key': 'grist',
|
||||
'eyebrow': 'Data Collaboration',
|
||||
'title': '資料協作入口',
|
||||
'status_label': '錯鏈已攔截',
|
||||
'summary': '資料協作不再連到 grist.wooo.work,避免被轉往其他專案站台。',
|
||||
'detail': 'Grist 正式域名尚未完成 momo-pro 專案隔離;在完成 Gateway 綁定前,導覽會停在本頁狀態,不再跳出系統邊界。',
|
||||
'primary_label': '回月份總表',
|
||||
'primary_href': '/monthly_summary_analysis',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@system_public_bp.route('/metrics')
|
||||
def prometheus_metrics():
|
||||
"""Prometheus 指標端點 - 供 Prometheus 抓取監控資料"""
|
||||
|
||||
@@ -8,7 +8,7 @@ run_scheduler.py — momo-scheduler 容器入口點
|
||||
每 4 小時:competitor_price_feeder、icaim_analysis
|
||||
每 6 小時:quality_rescore
|
||||
每 12 小時:dedup_batch
|
||||
每 1 天 :db_backup(03:00)、cleanup_agent_context(03:30)、backup_monitor(04:00)、daily_report(09:00)、roi_monthly_report gate(09:05)、ai_smoke_summary(09:10)、observability_daily_summary(09:30)、pchome_match_backfill(10:30)、openclaw_meta_analysis(12:00, Phase 4 降頻)、ppt_vision_audit(22:00)、daily_token_report(23:55)
|
||||
每 1 天 :db_backup(03:00)、cleanup_agent_context(03:30)、backup_monitor(04:00)、daily_report(09:00)、roi_monthly_report gate(09:05)、ai_smoke_summary(09:10)、observability_daily_summary(09:30)、pchome_match_backfill(10:30)、openclaw_meta_analysis(12:00, Phase 4 降頻)、ppt_auto_generation(20:30)、ppt_vision_audit(22:00)、daily_token_report(23:55)
|
||||
每 1 週 :weekly_strategy(週一 06:00)
|
||||
每 1 月 :monthly_report(每月1日 07:00)
|
||||
"""
|
||||
@@ -39,6 +39,7 @@ from scheduler import (
|
||||
run_daily_report_task,
|
||||
run_ai_smoke_daily_summary_task,
|
||||
run_monthly_report_task,
|
||||
run_ppt_auto_generation_task,
|
||||
)
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -180,6 +181,10 @@ def _register_schedules():
|
||||
schedule.every().day.at("09:05").do(run_roi_monthly_report_if_new_month)
|
||||
logger.info("📅 每日 09:05:roi_monthly_report(月初第 1 日才送)")
|
||||
|
||||
# PPT 自動簡報補齊(先產出定義中的報表,再交給 22:00 vision audit)
|
||||
schedule.every().day.at("20:30").do(run_ppt_auto_generation_task)
|
||||
logger.info("📅 每日 20:30:ppt_auto_generation(補齊 PPT 定義報表)")
|
||||
|
||||
# Phase 26: PPT 視覺審核(每日 22:00 掃當天新生 .pptx,有 issues 才推 Telegram)
|
||||
schedule.every().day.at("22:00").do(run_ppt_vision_audit)
|
||||
logger.info("📅 每日 22:00:ppt_vision_audit(PPT_VISION_ENABLED=true 才生效)")
|
||||
|
||||
37
scheduler.py
37
scheduler.py
@@ -2783,6 +2783,43 @@ def run_monthly_report_task():
|
||||
logging.error(f"[Scheduler] [MonthlyReport] auto_heal_service 失敗: {_heal_e}")
|
||||
|
||||
|
||||
def run_ppt_auto_generation_task():
|
||||
"""每日補齊觀測台定義中的 PPT 簡報。
|
||||
|
||||
22:00 的 ppt_vision_audit 只負責視覺審核;這個任務先把 daily /
|
||||
weekly / monthly / strategy / competitor / promo 產出補齊,讓審核頁不是
|
||||
被動等 Telegram 人工觸發。
|
||||
"""
|
||||
try:
|
||||
from services.ppt_auto_generation_service import generate_defined_ppt_reports
|
||||
|
||||
result = generate_defined_ppt_reports()
|
||||
logging.info(
|
||||
"[Scheduler] [PPTAutoGeneration] status=%s ready=%s errors=%s",
|
||||
result.get("status"),
|
||||
result.get("ready", 0),
|
||||
result.get("errors", 0),
|
||||
)
|
||||
_save_stats("ppt_auto_generation", result)
|
||||
except Exception as e:
|
||||
import traceback as _tb
|
||||
logging.error(f"[Scheduler] [PPTAutoGeneration] 🚨 自動簡報補齊異常: {e}")
|
||||
_save_stats("ppt_auto_generation", {"status": "Error", "error": str(e)})
|
||||
try:
|
||||
from services.event_router import notify_failure
|
||||
notify_failure(
|
||||
task_name="run_ppt_auto_generation_task",
|
||||
error=e,
|
||||
source="Scheduler.PPTAutoGeneration",
|
||||
event_type="ppt_auto_generation_failure",
|
||||
priority="P2",
|
||||
title="PPT 自動簡報補齊異常",
|
||||
trace=_tb.format_exc(),
|
||||
)
|
||||
except Exception as _router_e:
|
||||
logging.error(f"[Scheduler] [PPTAutoGeneration] event_router 失敗: {_router_e}")
|
||||
|
||||
|
||||
def run_ai_smoke_daily_summary_task():
|
||||
"""每日 AI 自動化 Smoke trend 摘要推播(只讀 history,不重新執行 smoke)。"""
|
||||
try:
|
||||
|
||||
326
services/ppt_auto_generation_service.py
Normal file
326
services/ppt_auto_generation_service.py
Normal file
@@ -0,0 +1,326 @@
|
||||
"""
|
||||
PPT auto-generation orchestration.
|
||||
|
||||
The observability page audits generated decks, but the scheduler previously
|
||||
only ran the vision audit. This service fills that gap by materializing the
|
||||
defined deck set before the audit window.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Sequence
|
||||
|
||||
from sqlalchemy import text as sa_text
|
||||
|
||||
from database.manager import get_session
|
||||
|
||||
|
||||
TAIPEI_TZ = timezone(timedelta(hours=8))
|
||||
|
||||
DEFINED_REPORT_TYPES = ("daily", "weekly", "monthly", "strategy", "competitor", "promo")
|
||||
|
||||
REPORT_TYPE_LABELS = {
|
||||
"daily": "每日日報",
|
||||
"weekly": "週報",
|
||||
"monthly": "月報",
|
||||
"strategy": "策略",
|
||||
"competitor": "競品",
|
||||
"promo": "促銷",
|
||||
}
|
||||
|
||||
REPORT_PREFIXES = {
|
||||
"daily": "ocbot_daily_",
|
||||
"weekly": "ocbot_weekly_",
|
||||
"monthly": "ocbot_monthly_",
|
||||
"strategy": "ocbot_strategy_",
|
||||
"competitor": "ocbot_competitor_",
|
||||
"promo": "ocbot_promo_",
|
||||
}
|
||||
|
||||
_RUN_LOCK = threading.Lock()
|
||||
_LAST_RUN: dict | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PPTAutoJob:
|
||||
report_type: str
|
||||
label: str
|
||||
sub_type: str
|
||||
sub_arg: str
|
||||
target_date: str
|
||||
|
||||
|
||||
def _truthy(value: str | None, default: bool = False) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def is_ppt_auto_generation_enabled() -> bool:
|
||||
return _truthy(os.getenv("PPT_AUTO_GENERATION_ENABLED"), default=True)
|
||||
|
||||
|
||||
def _parse_report_types(report_types: Iterable[str] | str | None) -> list[str]:
|
||||
if report_types is None:
|
||||
raw = os.getenv("PPT_AUTO_REPORT_TYPES", ",".join(DEFINED_REPORT_TYPES))
|
||||
parts = raw.split(",")
|
||||
elif isinstance(report_types, str):
|
||||
parts = report_types.split(",")
|
||||
else:
|
||||
parts = list(report_types)
|
||||
|
||||
parsed = []
|
||||
for part in parts:
|
||||
key = str(part or "").strip().lower()
|
||||
if key == "all":
|
||||
return list(DEFINED_REPORT_TYPES)
|
||||
if key in DEFINED_REPORT_TYPES and key not in parsed:
|
||||
parsed.append(key)
|
||||
return parsed or list(DEFINED_REPORT_TYPES)
|
||||
|
||||
|
||||
def _latest_sales_date() -> str | None:
|
||||
try:
|
||||
from routes.openclaw_bot_routes import latest_date
|
||||
|
||||
return latest_date()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _normalise_date(value: str | None) -> str:
|
||||
if value:
|
||||
cleaned = value.strip().replace("-", "/")
|
||||
try:
|
||||
dt = datetime.strptime(cleaned, "%Y/%m/%d")
|
||||
return dt.strftime("%Y/%m/%d")
|
||||
except ValueError:
|
||||
pass
|
||||
return (datetime.now(TAIPEI_TZ) - timedelta(days=1)).strftime("%Y/%m/%d")
|
||||
|
||||
|
||||
def build_defined_ppt_jobs(
|
||||
*,
|
||||
latest_date: str | None = None,
|
||||
report_types: Iterable[str] | str | None = None,
|
||||
) -> list[PPTAutoJob]:
|
||||
target = _normalise_date(latest_date or _latest_sales_date())
|
||||
target_dt = datetime.strptime(target, "%Y/%m/%d")
|
||||
month_arg = target_dt.strftime("%Y/%m")
|
||||
promo_start = (target_dt - timedelta(days=6)).strftime("%Y/%m/%d")
|
||||
promo_arg = f"{promo_start}-{target}"
|
||||
|
||||
job_map = {
|
||||
"daily": PPTAutoJob("daily", "每日日報", "daily", target, target),
|
||||
"weekly": PPTAutoJob("weekly", "週報", "weekly", "", target),
|
||||
"monthly": PPTAutoJob("monthly", "月報", "monthly", month_arg, target),
|
||||
"strategy": PPTAutoJob("strategy", "策略(月)", "strategy", f"monthly {month_arg}", target),
|
||||
"competitor": PPTAutoJob("competitor", "競品(月)", "competitor", "monthly", target),
|
||||
"promo": PPTAutoJob("promo", "促銷(近 7 日)", "promo", promo_arg, target),
|
||||
}
|
||||
return [job_map[key] for key in _parse_report_types(report_types)]
|
||||
|
||||
|
||||
def get_defined_report_coverage(
|
||||
*,
|
||||
month_start: datetime,
|
||||
month_end: datetime,
|
||||
reports_dir: str | os.PathLike[str] | None = None,
|
||||
report_types: Iterable[str] | str | None = None,
|
||||
) -> dict:
|
||||
selected_types = _parse_report_types(report_types)
|
||||
counts = {key: 0 for key in selected_types}
|
||||
sources = {key: set() for key in selected_types}
|
||||
|
||||
try:
|
||||
session = get_session()
|
||||
try:
|
||||
rows = session.execute(
|
||||
sa_text(
|
||||
"""
|
||||
SELECT report_type, COUNT(*)
|
||||
FROM ppt_reports
|
||||
WHERE generated_at >= :month_start
|
||||
AND generated_at < :month_end
|
||||
GROUP BY report_type
|
||||
"""
|
||||
),
|
||||
{"month_start": month_start, "month_end": month_end},
|
||||
).fetchall()
|
||||
for report_type, count in rows:
|
||||
if report_type in counts:
|
||||
counts[report_type] = max(counts[report_type], int(count or 0))
|
||||
if count:
|
||||
sources[report_type].add("database")
|
||||
finally:
|
||||
session.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
root = Path(reports_dir or os.getenv("REPORTS_DIR", "/app/data/reports"))
|
||||
if root.is_dir():
|
||||
month_start_ts = month_start.timestamp()
|
||||
month_end_ts = month_end.timestamp()
|
||||
for path in root.iterdir():
|
||||
if not path.is_file() or path.is_symlink() or path.suffix.lower() != ".pptx":
|
||||
continue
|
||||
try:
|
||||
mtime = path.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
if not (month_start_ts <= mtime < month_end_ts):
|
||||
continue
|
||||
for report_type in selected_types:
|
||||
if path.name.startswith(REPORT_PREFIXES[report_type]):
|
||||
counts[report_type] += 1
|
||||
sources[report_type].add("filesystem")
|
||||
|
||||
items = [
|
||||
{
|
||||
"key": key,
|
||||
"label": REPORT_TYPE_LABELS[key],
|
||||
"count": counts[key],
|
||||
"ready": counts[key] > 0,
|
||||
"sources": sorted(sources[key]),
|
||||
}
|
||||
for key in selected_types
|
||||
]
|
||||
missing = [item for item in items if not item["ready"]]
|
||||
return {
|
||||
"enabled": is_ppt_auto_generation_enabled(),
|
||||
"items": items,
|
||||
"missing_report_types": [item["key"] for item in missing],
|
||||
"missing_count": len(missing),
|
||||
"ready_count": len(items) - len(missing),
|
||||
"total": len(items),
|
||||
"last_run": _LAST_RUN,
|
||||
}
|
||||
|
||||
|
||||
def _generate_job(job: PPTAutoJob) -> str | None:
|
||||
from routes import openclaw_bot_routes as bot_routes
|
||||
|
||||
original_send_message = getattr(bot_routes, "send_message", None)
|
||||
|
||||
def _noop_send_message(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
if original_send_message is not None:
|
||||
bot_routes.send_message = _noop_send_message
|
||||
try:
|
||||
return bot_routes._generate_ppt_cmd(
|
||||
job.sub_type,
|
||||
job.sub_arg,
|
||||
0,
|
||||
job.target_date,
|
||||
_reply_to=None,
|
||||
)
|
||||
finally:
|
||||
if original_send_message is not None:
|
||||
bot_routes.send_message = original_send_message
|
||||
|
||||
|
||||
def generate_defined_ppt_reports(
|
||||
*,
|
||||
report_types: Iterable[str] | str | None = None,
|
||||
force: bool = False,
|
||||
dry_run: bool = False,
|
||||
max_jobs: int | None = None,
|
||||
) -> dict:
|
||||
global _LAST_RUN
|
||||
|
||||
if not force and not is_ppt_auto_generation_enabled():
|
||||
result = {
|
||||
"ok": False,
|
||||
"status": "disabled",
|
||||
"message": "PPT_AUTO_GENERATION_ENABLED=false",
|
||||
"jobs": [],
|
||||
}
|
||||
_LAST_RUN = result
|
||||
return result
|
||||
|
||||
jobs = build_defined_ppt_jobs(report_types=report_types)
|
||||
if max_jobs is not None:
|
||||
jobs = jobs[: max(0, int(max_jobs))]
|
||||
|
||||
if dry_run:
|
||||
return {
|
||||
"ok": True,
|
||||
"status": "planned",
|
||||
"jobs": [asdict(job) for job in jobs],
|
||||
}
|
||||
|
||||
if not _RUN_LOCK.acquire(blocking=False):
|
||||
return {
|
||||
"ok": True,
|
||||
"status": "already_running",
|
||||
"message": "PPT auto-generation is already running.",
|
||||
"jobs": [],
|
||||
"last_run": _LAST_RUN,
|
||||
}
|
||||
|
||||
started_at = datetime.now(TAIPEI_TZ)
|
||||
results = []
|
||||
try:
|
||||
for job in jobs:
|
||||
item = asdict(job)
|
||||
try:
|
||||
path = _generate_job(job)
|
||||
item["path"] = path
|
||||
item["exists"] = bool(path and os.path.exists(path))
|
||||
item["status"] = "ready" if item["exists"] else "missing_file"
|
||||
except Exception as exc:
|
||||
item["status"] = "error"
|
||||
item["error"] = f"{type(exc).__name__}: {str(exc)[:220]}"
|
||||
results.append(item)
|
||||
|
||||
finished_at = datetime.now(TAIPEI_TZ)
|
||||
result = {
|
||||
"ok": True,
|
||||
"status": "completed",
|
||||
"started_at": started_at.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"finished_at": finished_at.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"duration_sec": round((finished_at - started_at).total_seconds(), 1),
|
||||
"jobs": results,
|
||||
"ready": sum(1 for item in results if item.get("status") == "ready"),
|
||||
"errors": sum(1 for item in results if item.get("status") == "error"),
|
||||
}
|
||||
_LAST_RUN = result
|
||||
return result
|
||||
finally:
|
||||
_RUN_LOCK.release()
|
||||
|
||||
|
||||
def start_defined_ppt_generation_background(
|
||||
*,
|
||||
report_types: Sequence[str] | str | None = None,
|
||||
force: bool = False,
|
||||
) -> dict:
|
||||
if _RUN_LOCK.locked():
|
||||
return {
|
||||
"ok": True,
|
||||
"status": "already_running",
|
||||
"message": "PPT auto-generation is already running.",
|
||||
"last_run": _LAST_RUN,
|
||||
}
|
||||
|
||||
def _run():
|
||||
generate_defined_ppt_reports(report_types=report_types, force=force)
|
||||
|
||||
thread = threading.Thread(target=_run, name="ppt-auto-generation", daemon=True)
|
||||
thread.start()
|
||||
return {
|
||||
"ok": True,
|
||||
"status": "queued",
|
||||
"message": "PPT auto-generation queued.",
|
||||
"report_types": _parse_report_types(report_types),
|
||||
}
|
||||
|
||||
|
||||
def get_last_generation_status() -> dict | None:
|
||||
return _LAST_RUN
|
||||
@@ -2,159 +2,11 @@
|
||||
|
||||
{% block title %}PPT 視覺 QA 產線{% endblock %}
|
||||
|
||||
{% block ewooo_content %}
|
||||
<style>
|
||||
.ppt-hero, .ppt-panel, .ppt-table-shell {
|
||||
border: 1px solid var(--obs-line);
|
||||
border-radius: 26px;
|
||||
background: var(--obs-card);
|
||||
box-shadow: 0 16px 38px rgba(70, 46, 28, .08);
|
||||
}
|
||||
.ppt-hero {
|
||||
padding: 1.4rem;
|
||||
background: radial-gradient(circle at 12% 14%, rgba(201,100,66,.18), transparent 24rem),
|
||||
radial-gradient(circle at 88% 8%, rgba(79,111,143,.14), transparent 22rem),
|
||||
linear-gradient(135deg, rgba(255,248,239,.98), rgba(255,255,255,.74));
|
||||
}
|
||||
.ppt-kicker {
|
||||
color: var(--obs-accent);
|
||||
font-size: .76rem;
|
||||
letter-spacing: .13em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 850;
|
||||
}
|
||||
.ppt-title {
|
||||
margin: .45rem 0 .25rem;
|
||||
font-family: var(--momo-font-display, "Inter", "Noto Sans TC", system-ui, sans-serif);
|
||||
font-size: var(--obs-title-size);
|
||||
letter-spacing: 0;
|
||||
line-height: .98;
|
||||
}
|
||||
.ppt-subtitle {
|
||||
color: var(--obs-muted);
|
||||
max-width: 860px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.ppt-command {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0,1fr));
|
||||
gap: .75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.ppt-signal {
|
||||
padding: .95rem;
|
||||
border: 1px solid var(--obs-line);
|
||||
border-radius: 20px;
|
||||
background: rgba(255,255,255,.62);
|
||||
}
|
||||
.ppt-label {
|
||||
color: var(--obs-muted);
|
||||
font-size: .72rem;
|
||||
letter-spacing: .1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.ppt-value {
|
||||
display: block;
|
||||
margin-top: .28rem;
|
||||
font-size: var(--obs-value-size);
|
||||
font-weight: 880;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.ppt-toolbar {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: .75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ppt-type-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .45rem;
|
||||
}
|
||||
.ppt-type-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .35rem;
|
||||
}
|
||||
.ppt-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0,1.2fr) minmax(330px,.8fr);
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.ppt-stack { display: grid; gap: 1rem; }
|
||||
.ppt-panel-head, .ppt-table-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1.05rem 1.1rem .25rem;
|
||||
}
|
||||
.ppt-panel-title, .ppt-table-title h3 {
|
||||
margin: .15rem 0 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.ppt-panel-body {
|
||||
padding: 1rem 1.1rem 1.1rem;
|
||||
}
|
||||
.ppt-table-shell {
|
||||
overflow: hidden;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.ppt-table-shell .table {
|
||||
min-width: 760px;
|
||||
}
|
||||
.ppt-mini-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0,1fr));
|
||||
gap: .7rem;
|
||||
}
|
||||
.ppt-mini {
|
||||
padding: .85rem;
|
||||
border: 1px solid var(--obs-line);
|
||||
border-radius: 18px;
|
||||
background: rgba(255,255,255,.58);
|
||||
}
|
||||
.ppt-mini strong {
|
||||
display: block;
|
||||
margin-top: .24rem;
|
||||
font-size: 1.35rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.fix-card {
|
||||
padding: .85rem;
|
||||
border: 1px solid var(--obs-line);
|
||||
border-radius: 18px;
|
||||
background: rgba(255,255,255,.58);
|
||||
margin-bottom: .7rem;
|
||||
}
|
||||
.status-good { color: var(--obs-green); }
|
||||
.status-warn { color: var(--obs-amber); }
|
||||
.status-bad { color: var(--obs-red); }
|
||||
.status-blue { color: var(--obs-blue); }
|
||||
.ppt-file-actions {
|
||||
display: flex;
|
||||
gap: .4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ppt-file-actions .btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .3rem;
|
||||
}
|
||||
@media(max-width:1100px) {
|
||||
.ppt-command, .ppt-mini-grid { grid-template-columns: repeat(2,minmax(0,1fr)); }
|
||||
.ppt-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media(max-width:720px) {
|
||||
.ppt-command { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/page-ppt-audit-history.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block ewooo_content %}
|
||||
{% import "admin/_observability_labels.html" as obs_label %}
|
||||
{% set report_is_daily = report_type == 'daily' %}
|
||||
|
||||
@@ -188,6 +40,13 @@
|
||||
</span>
|
||||
<small class="text-muted">視覺問題數</small>
|
||||
</div>
|
||||
<div class="ppt-signal">
|
||||
<div class="ppt-label">定義覆蓋</div>
|
||||
<span class="ppt-value {% if auto_generation.missing_count == 0 and auto_generation.total > 0 %}status-good{% elif auto_generation.missing_count > 0 %}status-warn{% else %}status-blue{% endif %}">
|
||||
{{ auto_generation.ready_count }}/{{ auto_generation.total }}
|
||||
</span>
|
||||
<small class="text-muted">自動簡報產線</small>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% if error %}<div class="alert alert-warning mt-3"><strong><i class="fas fa-triangle-exclamation me-1"></i></strong>{{ error }}</div>{% endif %}
|
||||
@@ -214,6 +73,40 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
<section class="ppt-panel mt-3"
|
||||
data-ppt-auto-generation
|
||||
data-auto-start="{{ 'true' if auto_generation.can_auto_start else 'false' }}"
|
||||
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>
|
||||
<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{% else %}status-warn{% endif %}">
|
||||
{{ '已產生' if item.ready else '待補齊' }}
|
||||
</strong>
|
||||
<small class="text-muted">{{ item.count }} 筆{% if item.sources %} · {{ item.sources|join(' + ') }}{% endif %}</small>
|
||||
</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 }} 類。
|
||||
{% else %}
|
||||
PPT_AUTO_GENERATION_ENABLED=false,已停用自動補齊。
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="ppt-grid">
|
||||
<div class="ppt-stack">
|
||||
<article class="ppt-table-shell">
|
||||
@@ -245,7 +138,17 @@
|
||||
<td class="text-end">{{ "%.2f"|format(r.confidence) }}</td>
|
||||
<td class="text-end">{{ r.duration_ms }}</td>
|
||||
<td><small class="text-muted">{{ (r.error_msg or '')[:80] }}</small></td>
|
||||
<td>{% if r.audit_status in ('failed','error') %}<button class="btn btn-sm btn-outline-warning" onclick="triggerAiderHeal({{ r.pptx_filename|tojson }}, {{ (r.error_msg or '')|tojson }})"><i class="fas fa-wrench me-1"></i>AiderHeal</button>{% endif %}</td>
|
||||
<td>
|
||||
{% if r.audit_status in ('failed','error') %}
|
||||
<button class="btn btn-sm btn-outline-warning"
|
||||
type="button"
|
||||
data-ppt-aider-heal
|
||||
data-ppt-filename="{{ r.pptx_filename }}"
|
||||
data-ppt-error="{{ r.error_msg or '' }}">
|
||||
<i class="fas fa-wrench me-1"></i>AiderHeal
|
||||
</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="text-center text-muted">目前無 daily 審核歷史;請確認 {{ report_month }} 是否已完成 22:00 排程。</td></tr>
|
||||
|
||||
@@ -1,57 +1,5 @@
|
||||
{# 分析報表第二層分頁:保留頁面內容與圖表邏輯,只提供一致的報表切換入口。 #}
|
||||
{% set _analysis_active = active_page|default('') %}
|
||||
<style>
|
||||
.analysis-report-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 18px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--momo-border-light, rgba(42, 37, 32, 0.16));
|
||||
border-radius: 8px;
|
||||
background: var(--momo-bg-elevated, #fdfaf2);
|
||||
box-shadow: var(--momo-shadow-md, 0 0 0 1px rgba(42, 37, 32, 0.10));
|
||||
}
|
||||
.analysis-report-tabs-spacer {
|
||||
flex: 1 1 auto;
|
||||
min-width: 8px;
|
||||
}
|
||||
.analysis-report-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
color: var(--momo-text-secondary, #645c52);
|
||||
text-decoration: none;
|
||||
font-family: var(--momo-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);
|
||||
font-size: var(--momo-text-body-sm, 13px);
|
||||
font-weight: 700;
|
||||
transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease;
|
||||
}
|
||||
.analysis-report-tab:hover {
|
||||
border-color: var(--momo-border-light, rgba(42, 37, 32, 0.16));
|
||||
background: var(--momo-bg-surface, #faf7f0);
|
||||
color: var(--momo-text-primary, #2a2520);
|
||||
}
|
||||
.analysis-report-tab.is-active {
|
||||
border-color: var(--momo-page-accent-dark, #a95846);
|
||||
background: var(--momo-page-accent, #c89043);
|
||||
color: var(--momo-page-inverse, #fff8ef);
|
||||
}
|
||||
.analysis-report-tab.is-external {
|
||||
border-color: var(--momo-border-light, rgba(42, 37, 32, 0.16));
|
||||
background: var(--momo-bg-surface, #faf6ec);
|
||||
font-family: var(--momo-font-family-mono, "SF Mono", Menlo, Consolas, monospace);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.analysis-report-tab i {
|
||||
color: currentColor !important;
|
||||
}
|
||||
</style>
|
||||
<nav class="analysis-report-tabs" aria-label="分析報表分頁">
|
||||
<a class="analysis-report-tab {% if _analysis_active == 'sales' %}is-active{% endif %}"
|
||||
{% if _analysis_active == 'sales' %}aria-current="page"{% endif %}
|
||||
@@ -77,12 +25,12 @@
|
||||
<span class="analysis-report-tabs-spacer" aria-hidden="true"></span>
|
||||
{% endif %}
|
||||
{% if metabase_url %}
|
||||
<a class="analysis-report-tab is-external" href="{{ metabase_url }}" target="_blank" rel="noopener">
|
||||
<a class="analysis-report-tab is-external {% if _analysis_active == 'metabase' %}is-active{% endif %}" href="{{ metabase_url }}">
|
||||
<i class="fas fa-chart-pie"></i>Metabase <i class="fas fa-up-right-from-square"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if grist_url %}
|
||||
<a class="analysis-report-tab is-external" href="{{ grist_url }}" target="_blank" rel="noopener">
|
||||
<a class="analysis-report-tab is-external {% if _analysis_active == 'grist' %}is-active{% endif %}" href="{{ grist_url }}">
|
||||
<i class="fas fa-table"></i>Grist <i class="fas fa-up-right-from-square"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -82,12 +82,12 @@
|
||||
<i class="fas fa-table"></i><span>月份總表</span><span class="momo-nav-code">04</span>
|
||||
</a>
|
||||
{% if metabase_url %}
|
||||
<a class="momo-nav-sublink" href="{{ metabase_url }}" target="_blank" rel="noopener">
|
||||
<a class="momo-nav-sublink {% if _active_page == 'metabase' %}is-active{% endif %}" href="{{ metabase_url }}">
|
||||
<i class="fas fa-chart-pie"></i><span>自訂圖表</span><span class="momo-nav-code">外部</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if grist_url %}
|
||||
<a class="momo-nav-sublink" href="{{ grist_url }}" target="_blank" rel="noopener">
|
||||
<a class="momo-nav-sublink {% if _active_page == 'grist' %}is-active{% endif %}" href="{{ grist_url }}">
|
||||
<i class="fas fa-table-cells"></i><span>資料協作</span><span class="momo-nav-code">外部</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -201,17 +201,15 @@
|
||||
{% if metabase_url %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ metabase_url }}" target="_blank">
|
||||
<a class="dropdown-item" href="{{ metabase_url }}">
|
||||
<i class="fas fa-chart-pie me-2"></i>自訂圖表 (Metabase)
|
||||
<i class="fas fa-external-link-alt ms-1 small text-muted"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if grist_url %}
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ grist_url }}" target="_blank">
|
||||
<a class="dropdown-item" href="{{ grist_url }}">
|
||||
<i class="fas fa-table me-2"></i>資料協作 (Grist)
|
||||
<i class="fas fa-external-link-alt ms-1 small text-muted"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
@@ -51,6 +51,25 @@
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro chart_snapshot(labels, values, mode='currency', limit=12) %}
|
||||
{% set total = labels|length %}
|
||||
<div class="chart-fallback-list">
|
||||
{% for label in labels %}
|
||||
{% if loop.index > total - limit %}
|
||||
{% set val = values[loop.index0]|default(0) %}
|
||||
<span class="chart-fallback-item">
|
||||
<b>{{ label }}</b>
|
||||
<strong>
|
||||
{% if mode == 'pct' %}{{ "{:+.1f}%".format(val) }}
|
||||
{% elif mode == 'number' %}{{ "{:,.0f}".format(val) }}
|
||||
{% else %}${{ "{:,.0f}".format(val) }}{% endif %}
|
||||
</strong>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% block ewooo_content %}
|
||||
<div
|
||||
class="daily-sales-page"
|
||||
@@ -262,13 +281,13 @@
|
||||
<div class="col-lg-8">
|
||||
<div class="card chart-card">
|
||||
<div class="card-header"><i class="fas fa-chart-area"></i> 每日業績趨勢(近 30 天)</div>
|
||||
<div class="card-body"><div class="chart-container"><canvas id="trendChart"></canvas></div></div>
|
||||
<div class="card-body"><div class="chart-container has-html-chart"><canvas id="trendChart"></canvas>{{ chart_snapshot(chart_data.labels, chart_data.revenue, 'currency') }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<div class="card chart-card">
|
||||
<div class="card-header"><i class="fas fa-percentage"></i> 日成長率 (DoD %)</div>
|
||||
<div class="card-body"><div class="chart-container"><canvas id="dodChart"></canvas></div></div>
|
||||
<div class="card-body"><div class="chart-container has-html-chart"><canvas id="dodChart"></canvas>{{ chart_snapshot(chart_data.labels, chart_data.dod_revenue, 'pct') }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -278,7 +297,7 @@
|
||||
<div class="col-lg-8">
|
||||
<div class="card chart-card">
|
||||
<div class="card-header"><i class="fas fa-chart-bar"></i> 週成長對比 (WoW)</div>
|
||||
<div class="card-body"><div class="chart-container"><canvas id="wowChart"></canvas></div></div>
|
||||
<div class="card-body"><div class="chart-container has-html-chart"><canvas id="wowChart"></canvas>{{ chart_snapshot(chart_data.labels, chart_data.wow_revenue, 'pct') }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
@@ -289,8 +308,9 @@
|
||||
<i class="fas fa-hand-pointer"></i> 左右滑動查看完整圖表
|
||||
</div>
|
||||
<div class="chart-responsive">
|
||||
<div class="chart-container" id="top10ChartContainer">
|
||||
<div class="chart-container has-html-chart" id="top10ChartContainer">
|
||||
<canvas id="top10Chart"></canvas>
|
||||
{{ chart_snapshot(chart_data.top10_labels, chart_data.top10_values, 'currency', 10) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -314,7 +334,7 @@
|
||||
<div class="col-lg-6 mb-4">
|
||||
<h6 class="marketing-subhead"><i class="fas fa-tags"></i> 折扣活動 Top 10</h6>
|
||||
{% if marketing_data.discount %}
|
||||
<div class="chart-container chart-container--md"><canvas id="discountChart"></canvas></div>
|
||||
<div class="chart-container chart-container--md has-html-chart"><canvas id="discountChart"></canvas>{{ chart_snapshot(marketing_data.discount['labels'], marketing_data.discount['values'], 'currency', 10) }}</div>
|
||||
{% else %}
|
||||
<div class="chart-empty"><i class="fas fa-info-circle"></i><p>暫無折扣活動數據</p></div>
|
||||
{% endif %}
|
||||
@@ -322,7 +342,7 @@
|
||||
<div class="col-lg-6 mb-4">
|
||||
<h6 class="marketing-subhead"><i class="fas fa-ticket-alt"></i> 折價券活動 Top 10</h6>
|
||||
{% if marketing_data.coupon %}
|
||||
<div class="chart-container chart-container--md"><canvas id="couponChart"></canvas></div>
|
||||
<div class="chart-container chart-container--md has-html-chart"><canvas id="couponChart"></canvas>{{ chart_snapshot(marketing_data.coupon['labels'], marketing_data.coupon['values'], 'currency', 10) }}</div>
|
||||
{% else %}
|
||||
<div class="chart-empty"><i class="fas fa-info-circle"></i><p>暫無折價券活動數據</p></div>
|
||||
{% endif %}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/ewoooc-tokens-v2-alias.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/ewoooc-shell.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/ewoooc-dotmatrix.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/analysis-report-tabs.css') }}">
|
||||
{% if active_page|default('') in [
|
||||
'obs_overview', 'obs_agent_orchestration', 'obs_business_intel',
|
||||
'obs_host_health', 'obs_ai_calls', 'obs_budget',
|
||||
@@ -49,7 +50,7 @@
|
||||
{# 群組映射 — Jinja 計算 [data-page-group] #}
|
||||
{% set _page = active_page|default('') %}
|
||||
{% set _group_monitor = ['dashboard', 'edm', 'campaigns'] %}
|
||||
{% set _group_analytics = ['sales', 'daily_sales', 'monthly', 'growth'] %}
|
||||
{% set _group_analytics = ['sales', 'daily_sales', 'monthly', 'growth', 'metabase', 'grist'] %}
|
||||
{% set _group_ops = ['vendor_stockout', 'auto_import', 'market_intel'] %}
|
||||
{% set _group_ai = ['ai_recommend', 'ai_history', 'ai_intelligence',
|
||||
'pchome_crawler', 'price_comparison', 'trends',
|
||||
|
||||
33
templates/external_tool_status.html
Normal file
33
templates/external_tool_status.html
Normal file
@@ -0,0 +1,33 @@
|
||||
{% extends "ewoooc_base.html" %}
|
||||
{% block title %}{{ tool.title }} - EwoooC{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/page-external-tools.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block ewooo_content %}
|
||||
<main class="external-tool-page" data-screen-label="{{ tool.key }}">
|
||||
<section class="external-tool-hero">
|
||||
<div>
|
||||
<div class="external-tool-eyebrow">
|
||||
<i class="fas fa-link" aria-hidden="true"></i>
|
||||
{{ tool.eyebrow }}
|
||||
</div>
|
||||
<h1>{{ tool.title }}</h1>
|
||||
<p>{{ tool.summary }}</p>
|
||||
</div>
|
||||
<span class="external-tool-status">{{ tool.status_label }}</span>
|
||||
</section>
|
||||
|
||||
<section class="external-tool-panel">
|
||||
<div class="external-tool-panel__body">
|
||||
<span class="external-tool-kicker">路由狀態</span>
|
||||
<h2>入口已由 momo-pro 接管</h2>
|
||||
<p>{{ tool.detail }}</p>
|
||||
</div>
|
||||
<a class="btn btn-primary" href="{{ tool.primary_href }}">
|
||||
<i class="fas fa-arrow-left" aria-hidden="true"></i>{{ tool.primary_label }}
|
||||
</a>
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -6,9 +6,23 @@
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/page-growth-bem.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% macro ga_chart_snapshot(labels, values, mode='currency') %}
|
||||
<div class="ga-chart-snapshot">
|
||||
{% for label in labels %}
|
||||
{% set val = values[loop.index0]|default(0) %}
|
||||
<span class="ga-chart-snapshot__item">
|
||||
<b>{{ label }}</b>
|
||||
<strong>
|
||||
{% if mode == 'pct' %}{{ "{:+.1f}%".format(val) }}
|
||||
{% else %}${{ "{:,.0f}".format(val) }}{% endif %}
|
||||
</strong>
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% block ewooo_content %}
|
||||
<div class="momo-app" data-page-group="analytics">
|
||||
<div class="growth-analysis-page">
|
||||
<div class="growth-analysis-page" data-page-group="analytics">
|
||||
{% include 'components/_analysis_report_tabs.html' %}
|
||||
|
||||
{# ── Page head ──────────────────────────────────── #}
|
||||
@@ -67,8 +81,9 @@
|
||||
<header class="ga-chart-card__head">
|
||||
<span class="ga-chart-card__title"><i class="fas fa-chart-bar"></i> 月營收與年增率 (Revenue & YoY)</span>
|
||||
</header>
|
||||
<div class="ga-chart-card__body" style="--ga-chart-h: 350px;">
|
||||
<div class="ga-chart-card__body ga-chart-card__body--lg has-html-chart">
|
||||
<canvas id="revenueChart"></canvas>
|
||||
{{ ga_chart_snapshot(chart_data.labels, chart_data.revenue, 'currency') }}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -76,8 +91,9 @@
|
||||
<header class="ga-chart-card__head">
|
||||
<span class="ga-chart-card__title"><i class="fas fa-percentage"></i> 月增率分析 (MoM)</span>
|
||||
</header>
|
||||
<div class="ga-chart-card__body" style="--ga-chart-h: 350px;">
|
||||
<div class="ga-chart-card__body ga-chart-card__body--lg has-html-chart">
|
||||
<canvas id="momChart"></canvas>
|
||||
{{ ga_chart_snapshot(chart_data.labels, chart_data.mom, 'pct') }}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
@@ -88,8 +104,9 @@
|
||||
<header class="ga-chart-card__head">
|
||||
<span class="ga-chart-card__title"><i class="fas fa-wallet"></i> 平均單價趨勢</span>
|
||||
</header>
|
||||
<div class="ga-chart-card__body" style="--ga-chart-h: 300px;">
|
||||
<div class="ga-chart-card__body ga-chart-card__body--md has-html-chart">
|
||||
<canvas id="aovChart"></canvas>
|
||||
{{ ga_chart_snapshot(chart_data.labels, chart_data.aov, 'currency') }}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -97,12 +114,12 @@
|
||||
<header class="ga-chart-card__head">
|
||||
<span class="ga-chart-card__title"><i class="fas fa-hand-holding-usd"></i> 獲利能力分析 (Gross Margin %)</span>
|
||||
</header>
|
||||
<div class="ga-chart-card__body" style="--ga-chart-h: 300px;">
|
||||
<div class="ga-chart-card__body ga-chart-card__body--md has-html-chart">
|
||||
<canvas id="marginChart"></canvas>
|
||||
{{ ga_chart_snapshot(chart_data.labels, chart_data.margin_rate, 'pct') }}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
74
tests/test_ppt_auto_generation_service.py
Normal file
74
tests/test_ppt_auto_generation_service.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def test_build_defined_ppt_jobs_uses_latest_date():
|
||||
from services.ppt_auto_generation_service import build_defined_ppt_jobs
|
||||
|
||||
jobs = build_defined_ppt_jobs(latest_date="2026-05-11")
|
||||
by_type = {job.report_type: job for job in jobs}
|
||||
|
||||
assert list(by_type) == ["daily", "weekly", "monthly", "strategy", "competitor", "promo"]
|
||||
assert by_type["daily"].sub_arg == "2026/05/11"
|
||||
assert by_type["monthly"].sub_arg == "2026/05"
|
||||
assert by_type["strategy"].sub_arg == "monthly 2026/05"
|
||||
assert by_type["competitor"].sub_arg == "monthly"
|
||||
assert by_type["promo"].sub_arg == "2026/05/05-2026/05/11"
|
||||
|
||||
|
||||
def test_auto_generation_respects_disabled_flag(monkeypatch):
|
||||
monkeypatch.setenv("PPT_AUTO_GENERATION_ENABLED", "false")
|
||||
|
||||
from services.ppt_auto_generation_service import generate_defined_ppt_reports
|
||||
|
||||
result = generate_defined_ppt_reports(report_types=["daily"])
|
||||
|
||||
assert result["ok"] is False
|
||||
assert result["status"] == "disabled"
|
||||
|
||||
|
||||
def test_dry_run_does_not_generate(monkeypatch):
|
||||
monkeypatch.setenv("PPT_AUTO_GENERATION_ENABLED", "true")
|
||||
|
||||
from services import ppt_auto_generation_service as svc
|
||||
|
||||
monkeypatch.setattr(svc, "_latest_sales_date", lambda: "2026-05-11")
|
||||
|
||||
result = svc.generate_defined_ppt_reports(
|
||||
report_types=["daily", "monthly"],
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["status"] == "planned"
|
||||
assert [job["report_type"] for job in result["jobs"]] == ["daily", "monthly"]
|
||||
|
||||
|
||||
def test_coverage_marks_ready_from_database(monkeypatch):
|
||||
from services import ppt_auto_generation_service as svc
|
||||
|
||||
class _Rows:
|
||||
def fetchall(self):
|
||||
return [("daily", 2), ("monthly", 1)]
|
||||
|
||||
class _Session:
|
||||
def execute(self, *_args, **_kwargs):
|
||||
return _Rows()
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(svc, "get_session", lambda: _Session())
|
||||
monkeypatch.setenv("PPT_AUTO_GENERATION_ENABLED", "true")
|
||||
|
||||
result = svc.get_defined_report_coverage(
|
||||
month_start=datetime(2026, 5, 1),
|
||||
month_end=datetime(2026, 6, 1),
|
||||
reports_dir="/tmp/does-not-exist-for-test",
|
||||
report_types=["daily", "monthly", "weekly"],
|
||||
)
|
||||
|
||||
by_key = {item["key"]: item for item in result["items"]}
|
||||
assert by_key["daily"]["ready"] is True
|
||||
assert by_key["monthly"]["ready"] is True
|
||||
assert by_key["weekly"]["ready"] is False
|
||||
assert result["missing_count"] == 1
|
||||
73
web/static/css/analysis-report-tabs.css
Normal file
73
web/static/css/analysis-report-tabs.css
Normal file
@@ -0,0 +1,73 @@
|
||||
.analysis-report-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--momo-space-2, 8px);
|
||||
margin: 0 0 var(--momo-space-4, 16px);
|
||||
padding: var(--momo-space-2, 8px);
|
||||
border: 1px solid var(--momo-border-light, rgba(42, 37, 32, 0.16));
|
||||
border-radius: var(--momo-radius-lg, 8px);
|
||||
background: var(--momo-bg-elevated, #fdfaf2);
|
||||
box-shadow: var(--momo-shadow-md, 0 0 0 1px rgba(42, 37, 32, 0.10));
|
||||
}
|
||||
|
||||
.analysis-report-tabs-spacer {
|
||||
flex: 1 1 auto;
|
||||
min-width: var(--momo-space-2, 8px);
|
||||
}
|
||||
|
||||
.analysis-report-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--momo-space-2, 8px);
|
||||
min-height: 34px;
|
||||
padding: 0 var(--momo-space-3, 12px);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--momo-radius-md, 7px);
|
||||
color: var(--momo-text-secondary, #645c52);
|
||||
text-decoration: none;
|
||||
font-family: var(--momo-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);
|
||||
font-size: var(--momo-text-body-sm, 13px);
|
||||
font-weight: var(--momo-font-weight-bold, 700);
|
||||
letter-spacing: 0;
|
||||
transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease;
|
||||
}
|
||||
|
||||
.analysis-report-tab:hover {
|
||||
border-color: var(--momo-border-light, rgba(42, 37, 32, 0.16));
|
||||
background: var(--momo-bg-surface, #faf7f0);
|
||||
color: var(--momo-text-primary, #2a2520);
|
||||
}
|
||||
|
||||
.analysis-report-tab.is-active {
|
||||
border-color: var(--momo-page-accent-dark, #a95846);
|
||||
background: var(--momo-page-accent, #c89043);
|
||||
color: var(--momo-page-inverse, #fff8ef);
|
||||
}
|
||||
|
||||
.analysis-report-tab.is-external {
|
||||
border-color: var(--momo-border-light, rgba(42, 37, 32, 0.16));
|
||||
background: var(--momo-bg-surface, #faf6ec);
|
||||
font-family: var(--momo-font-family-mono, "SF Mono", Menlo, Consolas, monospace);
|
||||
font-size: var(--momo-text-caption, 12px);
|
||||
}
|
||||
|
||||
.analysis-report-tab i {
|
||||
color: currentColor !important;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.analysis-report-tabs {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.analysis-report-tabs-spacer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.analysis-report-tab {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
@@ -1472,6 +1472,29 @@
|
||||
background-size: 14px 14px !important;
|
||||
}
|
||||
|
||||
.momo-observability-mode .ppt-auto-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: var(--momo-space-2, 8px);
|
||||
}
|
||||
|
||||
.momo-observability-mode .ppt-auto-status.is-working {
|
||||
color: var(--obs-accent) !important;
|
||||
font-weight: var(--momo-font-weight-bold, 700);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.momo-observability-mode .ppt-auto-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.momo-observability-mode .ppt-auto-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.momo-observability-mode h1,
|
||||
.momo-observability-mode h2,
|
||||
.momo-observability-mode h3,
|
||||
|
||||
@@ -875,6 +875,130 @@
|
||||
height: 320px;
|
||||
}
|
||||
|
||||
.chart-container canvas {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.chart-container.has-html-chart canvas {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.chart-fallback-bars {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 1rem;
|
||||
color: var(--momo-text-primary);
|
||||
}
|
||||
|
||||
.chart-fallback-bars.is-vertical {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.chart-fallback-bars.is-horizontal {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.chart-fallback-bar {
|
||||
min-width: 0;
|
||||
color: var(--momo-text-secondary);
|
||||
}
|
||||
|
||||
.chart-fallback-bars.is-vertical .chart-fallback-bar {
|
||||
position: relative;
|
||||
flex: 1 1 0;
|
||||
height: var(--bar-h);
|
||||
border-radius: 6px 6px 2px 2px;
|
||||
background: color-mix(in srgb, var(--momo-page-accent) 72%, white);
|
||||
border: 1px solid var(--momo-page-accent-line);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
padding: 0.35rem 0.25rem;
|
||||
}
|
||||
|
||||
.chart-fallback-bars.is-horizontal .chart-fallback-bar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(88px, 0.42fr) minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.chart-fallback-bars.is-horizontal .chart-fallback-bar::before {
|
||||
content: "";
|
||||
height: 16px;
|
||||
width: var(--bar-w);
|
||||
min-width: 4px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--momo-page-accent) 72%, white);
|
||||
border: 1px solid var(--momo-page-accent-line);
|
||||
}
|
||||
|
||||
.chart-fallback-bar.is-negative {
|
||||
background: color-mix(in srgb, var(--momo-danger-bg) 84%, white);
|
||||
}
|
||||
|
||||
.chart-fallback-bar.is-negative::before {
|
||||
background: color-mix(in srgb, var(--momo-danger-bg) 84%, white);
|
||||
}
|
||||
|
||||
.chart-fallback-label,
|
||||
.chart-fallback-value {
|
||||
font-family: var(--momo-font-mono);
|
||||
font-size: var(--momo-text-caption, 12px);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.chart-fallback-value {
|
||||
color: var(--momo-text-primary);
|
||||
}
|
||||
|
||||
.chart-fallback-list {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--momo-space-2, 8px);
|
||||
align-content: center;
|
||||
padding: var(--momo-space-3, 12px);
|
||||
}
|
||||
|
||||
.chart-fallback-item {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--momo-page-accent-line);
|
||||
border-radius: var(--momo-radius-md, 6px);
|
||||
background: color-mix(in srgb, var(--momo-page-accent-soft) 72%, white);
|
||||
padding: var(--momo-space-2, 8px);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--momo-space-2, 8px);
|
||||
}
|
||||
|
||||
.chart-fallback-item b,
|
||||
.chart-fallback-item strong {
|
||||
min-width: 0;
|
||||
font-family: var(--momo-font-mono);
|
||||
font-size: var(--momo-text-caption, 12px);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.chart-fallback-item b {
|
||||
color: var(--momo-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chart-fallback-item strong {
|
||||
color: var(--momo-text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chart-container--md { height: 350px; }
|
||||
|
||||
.chart-responsive {
|
||||
|
||||
98
web/static/css/page-external-tools.css
Normal file
98
web/static/css/page-external-tools.css
Normal file
@@ -0,0 +1,98 @@
|
||||
.external-tool-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--momo-space-4, 16px);
|
||||
}
|
||||
|
||||
.external-tool-hero,
|
||||
.external-tool-panel {
|
||||
background:
|
||||
radial-gradient(circle, rgba(45, 40, 32, 0.12) 1px, transparent 1.2px),
|
||||
var(--momo-bg-surface);
|
||||
background-size: 12px 12px, auto;
|
||||
border: 1px solid var(--momo-border-strong);
|
||||
border-radius: var(--momo-radius-lg, 8px);
|
||||
box-shadow: var(--momo-shadow-sm);
|
||||
}
|
||||
|
||||
.external-tool-hero {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--momo-space-5, 24px);
|
||||
padding: var(--momo-space-6, 32px);
|
||||
}
|
||||
|
||||
.external-tool-eyebrow,
|
||||
.external-tool-kicker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--momo-space-2, 8px);
|
||||
color: var(--momo-accent-rust);
|
||||
font-size: var(--momo-text-body-sm);
|
||||
font-weight: var(--momo-font-weight-bold);
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.external-tool-hero h1,
|
||||
.external-tool-panel h2 {
|
||||
margin: var(--momo-space-3, 12px) 0 var(--momo-space-2, 8px);
|
||||
color: var(--momo-text-primary);
|
||||
font-weight: var(--momo-font-weight-black);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.external-tool-hero h1 {
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
.external-tool-panel h2 {
|
||||
font-size: var(--momo-text-title-lg);
|
||||
}
|
||||
|
||||
.external-tool-hero p,
|
||||
.external-tool-panel p {
|
||||
max-width: 760px;
|
||||
margin: 0;
|
||||
color: var(--momo-text-secondary);
|
||||
font-size: var(--momo-text-body-lg);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.external-tool-status {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid var(--momo-accent-rust);
|
||||
border-radius: var(--momo-radius-pill, 999px);
|
||||
color: var(--momo-accent-rust);
|
||||
font-weight: var(--momo-font-weight-bold);
|
||||
padding: var(--momo-space-2, 8px) var(--momo-space-3, 12px);
|
||||
}
|
||||
|
||||
.external-tool-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--momo-space-5, 24px);
|
||||
padding: var(--momo-space-5, 24px);
|
||||
}
|
||||
|
||||
.external-tool-panel .btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--momo-space-2, 8px);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.external-tool-hero,
|
||||
.external-tool-panel {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: var(--momo-space-4, 16px);
|
||||
}
|
||||
|
||||
.external-tool-hero h1 {
|
||||
font-size: 32px;
|
||||
}
|
||||
}
|
||||
@@ -226,10 +226,85 @@
|
||||
padding: var(--momo-space-4, 16px);
|
||||
height: var(--ga-chart-h, 320px);
|
||||
}
|
||||
.growth-analysis-page .ga-chart-card__body--lg {
|
||||
height: 350px;
|
||||
}
|
||||
.growth-analysis-page .ga-chart-card__body--md {
|
||||
height: 300px;
|
||||
}
|
||||
.growth-analysis-page .ga-chart-card__body canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
.growth-analysis-page .ga-chart-card__body.has-html-chart canvas {
|
||||
display: none !important;
|
||||
}
|
||||
.growth-analysis-page .ga-chart-fallback {
|
||||
position: absolute;
|
||||
inset: var(--momo-space-4, 16px);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--momo-space-2, 8px);
|
||||
}
|
||||
.growth-analysis-page .ga-chart-card__body {
|
||||
position: relative;
|
||||
}
|
||||
.growth-analysis-page .ga-chart-fallback__bar {
|
||||
flex: 1 1 0;
|
||||
height: var(--bar-h);
|
||||
min-height: 8px;
|
||||
border: 1px solid var(--momo-page-accent-line);
|
||||
border-radius: 6px 6px 2px 2px;
|
||||
background: color-mix(in srgb, var(--momo-page-accent) 72%, white);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
padding: var(--momo-space-1, 4px);
|
||||
}
|
||||
.growth-analysis-page .ga-chart-fallback__bar.is-negative {
|
||||
background: color-mix(in srgb, var(--momo-danger-bg) 84%, white);
|
||||
}
|
||||
.growth-analysis-page .ga-chart-fallback__bar span,
|
||||
.growth-analysis-page .ga-chart-fallback__bar strong {
|
||||
font-family: var(--momo-font-mono);
|
||||
font-size: var(--momo-text-caption, 12px);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.growth-analysis-page .ga-chart-snapshot {
|
||||
position: absolute;
|
||||
inset: var(--momo-space-4, 16px);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--momo-space-2, 8px);
|
||||
align-content: center;
|
||||
}
|
||||
.growth-analysis-page .ga-chart-snapshot__item {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--momo-page-accent-line);
|
||||
border-radius: var(--momo-radius-md, 6px);
|
||||
background: color-mix(in srgb, var(--momo-page-accent-soft) 72%, white);
|
||||
padding: var(--momo-space-2, 8px);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--momo-space-2, 8px);
|
||||
}
|
||||
.growth-analysis-page .ga-chart-snapshot__item b,
|
||||
.growth-analysis-page .ga-chart-snapshot__item strong {
|
||||
min-width: 0;
|
||||
font-family: var(--momo-font-mono);
|
||||
font-size: var(--momo-text-caption, 12px);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.growth-analysis-page .ga-chart-snapshot__item b {
|
||||
color: var(--momo-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.growth-analysis-page .ga-chart-snapshot__item strong {
|
||||
color: var(--momo-text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.growth-analysis-page .ga-page-head {
|
||||
|
||||
204
web/static/css/page-ppt-audit-history.css
Normal file
204
web/static/css/page-ppt-audit-history.css
Normal file
@@ -0,0 +1,204 @@
|
||||
.ppt-hero,
|
||||
.ppt-panel,
|
||||
.ppt-table-shell {
|
||||
border: 1px solid var(--obs-line);
|
||||
border-radius: var(--momo-radius-lg, 8px);
|
||||
background: var(--obs-card);
|
||||
box-shadow: var(--momo-shadow-md, 0 16px 38px rgba(70, 46, 28, 0.08));
|
||||
}
|
||||
|
||||
.ppt-hero {
|
||||
padding: var(--momo-space-5, 24px);
|
||||
background:
|
||||
radial-gradient(circle, rgba(45, 40, 32, 0.12) 1px, transparent 1.2px),
|
||||
linear-gradient(135deg, rgba(255, 248, 239, 0.98), rgba(255, 255, 255, 0.78));
|
||||
background-size: 12px 12px, auto;
|
||||
}
|
||||
|
||||
.ppt-kicker {
|
||||
color: var(--obs-accent);
|
||||
font-size: var(--momo-text-caption, 12px);
|
||||
letter-spacing: 0;
|
||||
font-weight: var(--momo-font-weight-bold, 700);
|
||||
}
|
||||
|
||||
.ppt-title {
|
||||
margin: var(--momo-space-2, 8px) 0 var(--momo-space-1, 4px);
|
||||
font-family: var(--momo-font-display, "Inter", "Noto Sans TC", system-ui, sans-serif);
|
||||
font-size: var(--obs-title-size);
|
||||
letter-spacing: 0;
|
||||
line-height: var(--momo-line-height-tight, 1.08);
|
||||
}
|
||||
|
||||
.ppt-subtitle {
|
||||
color: var(--obs-muted);
|
||||
max-width: 860px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.ppt-command {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: var(--momo-space-3, 12px);
|
||||
margin-top: var(--momo-space-4, 16px);
|
||||
}
|
||||
|
||||
.ppt-signal {
|
||||
padding: var(--momo-space-3, 12px);
|
||||
border: 1px solid var(--obs-line);
|
||||
border-radius: var(--momo-radius-lg, 8px);
|
||||
background: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
|
||||
.ppt-label {
|
||||
color: var(--obs-muted);
|
||||
font-size: var(--momo-text-caption, 12px);
|
||||
letter-spacing: 0;
|
||||
font-weight: var(--momo-font-weight-bold, 700);
|
||||
}
|
||||
|
||||
.ppt-value {
|
||||
display: block;
|
||||
margin-top: var(--momo-space-1, 4px);
|
||||
font-size: var(--obs-value-size);
|
||||
font-weight: var(--momo-font-weight-black, 800);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.ppt-toolbar {
|
||||
margin-top: var(--momo-space-4, 16px);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--momo-space-3, 12px);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ppt-type-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--momo-space-2, 8px);
|
||||
}
|
||||
|
||||
.ppt-type-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--momo-space-1, 4px);
|
||||
}
|
||||
|
||||
.ppt-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(330px, 0.8fr);
|
||||
gap: var(--momo-space-4, 16px);
|
||||
margin-top: var(--momo-space-4, 16px);
|
||||
}
|
||||
|
||||
.ppt-stack {
|
||||
display: grid;
|
||||
gap: var(--momo-space-4, 16px);
|
||||
}
|
||||
|
||||
.ppt-panel-head,
|
||||
.ppt-table-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--momo-space-4, 16px);
|
||||
padding: var(--momo-space-4, 16px) var(--momo-space-4, 16px) 0;
|
||||
}
|
||||
|
||||
.ppt-panel-title,
|
||||
.ppt-table-title h3 {
|
||||
margin: var(--momo-space-1, 4px) 0 0;
|
||||
font-size: var(--momo-text-title, 18px);
|
||||
font-weight: var(--momo-font-weight-black, 800);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.ppt-panel-body {
|
||||
padding: var(--momo-space-4, 16px);
|
||||
}
|
||||
|
||||
.ppt-table-shell {
|
||||
overflow: hidden;
|
||||
margin-top: var(--momo-space-4, 16px);
|
||||
}
|
||||
|
||||
.ppt-table-shell .table {
|
||||
min-width: 760px;
|
||||
}
|
||||
|
||||
.ppt-mini-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--momo-space-3, 12px);
|
||||
}
|
||||
|
||||
.ppt-mini,
|
||||
.fix-card {
|
||||
padding: var(--momo-space-3, 12px);
|
||||
border: 1px solid var(--obs-line);
|
||||
border-radius: var(--momo-radius-lg, 8px);
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
|
||||
.fix-card {
|
||||
margin-bottom: var(--momo-space-3, 12px);
|
||||
}
|
||||
|
||||
.ppt-mini strong {
|
||||
display: block;
|
||||
margin-top: var(--momo-space-1, 4px);
|
||||
font-size: var(--momo-text-headline, 22px);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.status-good {
|
||||
color: var(--obs-green);
|
||||
}
|
||||
|
||||
.status-warn {
|
||||
color: var(--obs-amber);
|
||||
}
|
||||
|
||||
.status-bad {
|
||||
color: var(--obs-red);
|
||||
}
|
||||
|
||||
.status-blue {
|
||||
color: var(--obs-blue);
|
||||
}
|
||||
|
||||
.ppt-file-actions {
|
||||
display: flex;
|
||||
gap: var(--momo-space-2, 8px);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ppt-file-actions .btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--momo-space-1, 4px);
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.ppt-command {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.ppt-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.ppt-command,
|
||||
.ppt-mini-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ppt-panel-head,
|
||||
.ppt-table-title {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -593,6 +593,83 @@
|
||||
}
|
||||
};
|
||||
|
||||
function initPptAutoGeneration() {
|
||||
const panel = document.querySelector('[data-ppt-auto-generation]');
|
||||
document.querySelectorAll('[data-ppt-aider-heal]').forEach(button => {
|
||||
if (button.dataset.bound === '1') return;
|
||||
button.dataset.bound = '1';
|
||||
button.addEventListener('click', () => {
|
||||
window.triggerAiderHeal(button.dataset.pptFilename || '', button.dataset.pptError || '');
|
||||
});
|
||||
});
|
||||
|
||||
if (!panel) return;
|
||||
|
||||
const button = panel.querySelector('[data-ppt-generate-missing]');
|
||||
const status = panel.querySelector('[data-ppt-auto-status]');
|
||||
const reportTypes = (panel.dataset.reportTypes || '')
|
||||
.split(',')
|
||||
.map(value => value.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
async function triggerGeneration(isAuto) {
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>補齊中';
|
||||
}
|
||||
if (status) {
|
||||
status.classList.add('is-working');
|
||||
status.textContent = isAuto
|
||||
? '偵測到本月定義簡報缺漏,已排入背景補齊。'
|
||||
: '已排入背景補齊,產出完成後重新整理即可看到最新檔案。';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await postJson('/observability/ppt_audit/generate_missing', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ report_types: reportTypes })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (status) {
|
||||
status.textContent = data.message || (data.status === 'queued'
|
||||
? '已排入背景補齊,請稍後重新整理。'
|
||||
: `狀態:${data.status || '已送出'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('ppt_auto_generation_failed', error);
|
||||
if (status) {
|
||||
status.classList.remove('is-working');
|
||||
status.textContent = '補齊任務送出失敗,請稍後再試或查看系統日誌。';
|
||||
}
|
||||
if (button) button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (button) {
|
||||
button.addEventListener('click', () => triggerGeneration(false));
|
||||
}
|
||||
|
||||
if (panel.dataset.autoStart === 'true') {
|
||||
const key = `ppt-auto-generation:${new Date().toISOString().slice(0, 10)}`;
|
||||
let last = 0;
|
||||
const now = Date.now();
|
||||
try {
|
||||
last = Number(window.localStorage.getItem(key) || 0);
|
||||
} catch (_error) {
|
||||
last = 0;
|
||||
}
|
||||
if (reportTypes.length && (!last || now - last > 6 * 60 * 60 * 1000)) {
|
||||
try {
|
||||
window.localStorage.setItem(key, String(now));
|
||||
} catch (_error) {
|
||||
// Ignore storage failures; the server-side generation lock still protects the job.
|
||||
}
|
||||
triggerGeneration(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
if (!value) return '';
|
||||
return String(value).replace(/[&<>"']/g, char => ({
|
||||
@@ -661,6 +738,7 @@
|
||||
renderPromotionReview();
|
||||
renderQualityTrend();
|
||||
renderPptAudit();
|
||||
initPptAutoGeneration();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
|
||||
@@ -82,6 +82,88 @@
|
||||
top10_labels: cd.top10_labels || [],
|
||||
top10_values: cd.top10_values || []
|
||||
};
|
||||
const chartInstances = [];
|
||||
|
||||
function rememberChart(chart) {
|
||||
if (chart) chartInstances.push(chart);
|
||||
return chart;
|
||||
}
|
||||
|
||||
function stabilizeCharts() {
|
||||
window.requestAnimationFrame(() => {
|
||||
chartInstances.forEach(chart => {
|
||||
if (!chart) return;
|
||||
if (typeof chart.resize === 'function') chart.resize();
|
||||
if (typeof chart.update === 'function') chart.update('none');
|
||||
});
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
});
|
||||
}
|
||||
|
||||
function formatShort(value, mode) {
|
||||
const n = Number(value || 0);
|
||||
if (mode === 'pct') return `${n.toFixed(1)}%`;
|
||||
if (mode === 'currency') return `$${Math.round(n).toLocaleString()}`;
|
||||
return Math.round(n).toLocaleString();
|
||||
}
|
||||
|
||||
function renderHtmlBars(canvasId, labels, values, options = {}) {
|
||||
const canvas = document.getElementById(canvasId);
|
||||
const wrap = canvas ? canvas.closest('.chart-container') : null;
|
||||
if (!wrap || wrap.querySelector('.chart-fallback-bars')) return;
|
||||
const pairs = (labels || []).map((label, index) => ({
|
||||
label: String(label || ''),
|
||||
value: Number((values || [])[index] || 0)
|
||||
})).filter(item => Number.isFinite(item.value));
|
||||
const data = options.limit ? pairs.slice(-options.limit) : pairs;
|
||||
if (!data.length) return;
|
||||
|
||||
wrap.classList.add('has-html-chart');
|
||||
canvas.setAttribute('aria-hidden', 'true');
|
||||
const max = Math.max(...data.map(item => Math.abs(item.value)), 1);
|
||||
const chart = document.createElement('div');
|
||||
chart.className = `chart-fallback-bars ${options.horizontal ? 'is-horizontal' : 'is-vertical'}`;
|
||||
|
||||
data.forEach(item => {
|
||||
const bar = document.createElement('div');
|
||||
bar.className = `chart-fallback-bar ${item.value < 0 ? 'is-negative' : ''}`;
|
||||
const pct = Math.max(4, Math.round(Math.abs(item.value) / max * 100));
|
||||
if (options.horizontal) {
|
||||
bar.style.setProperty('--bar-w', `${pct}%`);
|
||||
} else {
|
||||
bar.style.setProperty('--bar-h', `${pct}%`);
|
||||
}
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'chart-fallback-label';
|
||||
label.textContent = item.label.length > 10 ? `${item.label.slice(0, 10)}...` : item.label;
|
||||
const value = document.createElement('strong');
|
||||
value.className = 'chart-fallback-value';
|
||||
value.textContent = formatShort(item.value, options.mode);
|
||||
bar.append(label, value);
|
||||
chart.appendChild(bar);
|
||||
});
|
||||
wrap.appendChild(chart);
|
||||
}
|
||||
|
||||
function renderHtmlChartFallbacks() {
|
||||
renderHtmlBars('trendChart', safe.labels, safe.revenue, { mode: 'currency', limit: 14 });
|
||||
renderHtmlBars('dodChart', safe.labels, safe.dod_revenue, { mode: 'pct', limit: 14 });
|
||||
renderHtmlBars('wowChart', safe.labels, safe.wow_revenue, { mode: 'pct', limit: 14 });
|
||||
renderHtmlBars('top10Chart', safe.top10_labels, safe.top10_values, {
|
||||
mode: 'currency',
|
||||
horizontal: true
|
||||
});
|
||||
const mk = dailySalesData.marketing || {};
|
||||
if (mk.discount) renderHtmlBars('discountChart', mk.discount.labels, mk.discount.values, {
|
||||
mode: 'currency',
|
||||
horizontal: true
|
||||
});
|
||||
if (mk.coupon) renderHtmlBars('couponChart', mk.coupon.labels, mk.coupon.values, {
|
||||
mode: 'currency',
|
||||
horizontal: true
|
||||
});
|
||||
}
|
||||
|
||||
// -- Helpers ----------------------------------------------------------
|
||||
function makeLineDataset(label, data, color, yAxisID) {
|
||||
@@ -113,7 +195,7 @@
|
||||
const el = document.getElementById('trendChart');
|
||||
if (!el || !safe.labels.length) return;
|
||||
|
||||
new Chart(el, {
|
||||
rememberChart(new Chart(el, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: safe.labels,
|
||||
@@ -145,7 +227,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
// -- Chart 2: DoD (multi-line %) --------------------------------------
|
||||
@@ -153,7 +235,7 @@
|
||||
const el = document.getElementById('dodChart');
|
||||
if (!el || !safe.labels.length) return;
|
||||
|
||||
new Chart(el, {
|
||||
rememberChart(new Chart(el, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: safe.labels,
|
||||
@@ -180,7 +262,7 @@
|
||||
y: { beginAtZero: false, title: { display: true, text: 'DoD 成長率 (%)' } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
// -- Chart 3: WoW (multi-line %, 前 7 天淡灰) -------------------------
|
||||
@@ -188,7 +270,7 @@
|
||||
const el = document.getElementById('wowChart');
|
||||
if (!el || !safe.labels.length) return;
|
||||
|
||||
new Chart(el, {
|
||||
rememberChart(new Chart(el, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: safe.labels,
|
||||
@@ -222,7 +304,7 @@
|
||||
y: { beginAtZero: false, title: { display: true, text: 'WoW 成長率 (%)' } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
// -- Chart 4: Top 10 (橫向 bar) ---------------------------------------
|
||||
@@ -230,7 +312,7 @@
|
||||
const el = document.getElementById('top10Chart');
|
||||
if (!el || !safe.top10_labels.length) return;
|
||||
|
||||
new Chart(el, {
|
||||
rememberChart(new Chart(el, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: safe.top10_labels,
|
||||
@@ -249,7 +331,7 @@
|
||||
plugins: { legend: { display: false } },
|
||||
scales: { x: { beginAtZero: true } }
|
||||
}
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
// -- Marketing charts ----------------------------------------------
|
||||
@@ -261,7 +343,7 @@
|
||||
return rgba(color, Math.max(a, 0.25));
|
||||
});
|
||||
|
||||
new Chart(el.getContext('2d'), {
|
||||
rememberChart(new Chart(el.getContext('2d'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: marketing.labels,
|
||||
@@ -301,7 +383,7 @@
|
||||
},
|
||||
onClick: () => exportMarketingData()
|
||||
}
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
// -- DataTables init ------------------------------------------------
|
||||
@@ -430,12 +512,26 @@
|
||||
const mk = dailySalesData.marketing || {};
|
||||
if (mk.discount) renderMarketingBar('discountChart', mk.discount, palette.caramel);
|
||||
if (mk.coupon) renderMarketingBar('couponChart', mk.coupon, palette.olive);
|
||||
stabilizeCharts();
|
||||
renderHtmlChartFallbacks();
|
||||
}
|
||||
|
||||
function bootCharts() {
|
||||
document.documentElement.dataset.dailyCharts = 'loading';
|
||||
loadChartJs()
|
||||
.then(renderAllCharts)
|
||||
.catch(error => console.error('[daily_sales] Chart.js 載入失敗:', error));
|
||||
.then(() => {
|
||||
renderAllCharts();
|
||||
document.documentElement.dataset.dailyCharts = 'ready';
|
||||
})
|
||||
.catch(error => {
|
||||
document.documentElement.dataset.dailyCharts = 'error';
|
||||
document.documentElement.dataset.dailyChartsError = error && error.message ? error.message : String(error);
|
||||
console.error('[daily_sales] Chart.js 載入失敗:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleChartBoot() {
|
||||
window.setTimeout(bootCharts, 0);
|
||||
}
|
||||
|
||||
function observeCharts() {
|
||||
@@ -458,6 +554,6 @@
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
initDailySalesActions();
|
||||
initDataTable();
|
||||
observeCharts();
|
||||
scheduleChartBoot();
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -33,6 +33,66 @@
|
||||
rust: token('--momo-danger-text', '#7a3210'),
|
||||
rustSoft: token('--momo-danger-bg', '#efd3c4')
|
||||
};
|
||||
const chartInstances = [];
|
||||
|
||||
function rememberChart(chart) {
|
||||
if (chart) chartInstances.push(chart);
|
||||
return chart;
|
||||
}
|
||||
|
||||
function stabilizeCharts() {
|
||||
window.requestAnimationFrame(() => {
|
||||
chartInstances.forEach(chart => {
|
||||
if (!chart) return;
|
||||
if (typeof chart.resize === 'function') chart.resize();
|
||||
if (typeof chart.update === 'function') chart.update('none');
|
||||
});
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
});
|
||||
}
|
||||
|
||||
function formatShort(value, mode) {
|
||||
const n = Number(value || 0);
|
||||
if (mode === 'pct') return `${n.toFixed(1)}%`;
|
||||
if (mode === 'currency') return `$${Math.round(n).toLocaleString()}`;
|
||||
return Math.round(n).toLocaleString();
|
||||
}
|
||||
|
||||
function renderHtmlBars(canvasId, labels, values, options = {}) {
|
||||
const canvas = document.getElementById(canvasId);
|
||||
const wrap = canvas ? canvas.closest('.ga-chart-card__body') : null;
|
||||
if (!wrap || wrap.querySelector('.ga-chart-fallback')) return;
|
||||
const pairs = (labels || []).map((label, index) => ({
|
||||
label: String(label || ''),
|
||||
value: Number((values || [])[index] || 0)
|
||||
})).filter(item => Number.isFinite(item.value));
|
||||
if (!pairs.length) return;
|
||||
const max = Math.max(...pairs.map(item => Math.abs(item.value)), 1);
|
||||
wrap.classList.add('has-html-chart');
|
||||
canvas.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const chart = document.createElement('div');
|
||||
chart.className = 'ga-chart-fallback';
|
||||
pairs.forEach(item => {
|
||||
const bar = document.createElement('div');
|
||||
bar.className = `ga-chart-fallback__bar ${item.value < 0 ? 'is-negative' : ''}`;
|
||||
bar.style.setProperty('--bar-h', `${Math.max(4, Math.round(Math.abs(item.value) / max * 100))}%`);
|
||||
const label = document.createElement('span');
|
||||
label.textContent = item.label;
|
||||
const value = document.createElement('strong');
|
||||
value.textContent = formatShort(item.value, options.mode);
|
||||
bar.append(label, value);
|
||||
chart.appendChild(bar);
|
||||
});
|
||||
wrap.appendChild(chart);
|
||||
}
|
||||
|
||||
function renderHtmlChartFallbacks() {
|
||||
renderHtmlBars('revenueChart', data.labels, data.revenue, { mode: 'currency' });
|
||||
renderHtmlBars('momChart', data.labels, data.mom, { mode: 'pct' });
|
||||
renderHtmlBars('aovChart', data.labels, data.aov, { mode: 'currency' });
|
||||
renderHtmlBars('marginChart', data.labels, data.margin_rate, { mode: 'pct' });
|
||||
}
|
||||
|
||||
function loadChartJs() {
|
||||
if (window.EwoooCChartTheme && window.EwoooCChartTheme.loadChartJs) {
|
||||
@@ -56,7 +116,7 @@
|
||||
const marginEl = document.getElementById('marginChart');
|
||||
if (!revenueEl || !momEl || !aovEl || !marginEl) return;
|
||||
|
||||
new Chart(revenueEl, {
|
||||
rememberChart(new Chart(revenueEl, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
@@ -91,9 +151,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
new Chart(momEl, {
|
||||
rememberChart(new Chart(momEl, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
@@ -108,9 +168,9 @@
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } }
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
new Chart(aovEl, {
|
||||
rememberChart(new Chart(aovEl, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
@@ -128,9 +188,9 @@
|
||||
maintainAspectRatio: false,
|
||||
scales: { y: { beginAtZero: true } }
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
new Chart(marginEl, {
|
||||
rememberChart(new Chart(marginEl, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
@@ -148,13 +208,27 @@
|
||||
maintainAspectRatio: false,
|
||||
scales: { y: { beginAtZero: true } }
|
||||
}
|
||||
});
|
||||
}));
|
||||
stabilizeCharts();
|
||||
renderHtmlChartFallbacks();
|
||||
}
|
||||
|
||||
function bootCharts() {
|
||||
document.documentElement.dataset.growthCharts = 'loading';
|
||||
loadChartJs()
|
||||
.then(renderCharts)
|
||||
.catch(error => console.error('[growth_analysis] Chart.js 載入失敗:', error));
|
||||
.then(() => {
|
||||
renderCharts();
|
||||
document.documentElement.dataset.growthCharts = 'ready';
|
||||
})
|
||||
.catch(error => {
|
||||
document.documentElement.dataset.growthCharts = 'error';
|
||||
document.documentElement.dataset.growthChartsError = error && error.message ? error.message : String(error);
|
||||
console.error('[growth_analysis] Chart.js 載入失敗:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleChartBoot() {
|
||||
window.setTimeout(bootCharts, 0);
|
||||
}
|
||||
|
||||
function observeCharts() {
|
||||
@@ -175,8 +249,8 @@
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', observeCharts, { once: true });
|
||||
document.addEventListener('DOMContentLoaded', scheduleChartBoot, { once: true });
|
||||
} else {
|
||||
observeCharts();
|
||||
scheduleChartBoot();
|
||||
}
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user