Fix PPT auto generation and analytics fallbacks
Some checks failed
CD Pipeline / deploy (push) Failing after 26s

This commit is contained in:
OoO
2026-05-18 11:52:31 +08:00
parent 3284b1cf82
commit c420d48263
26 changed files with 1559 additions and 258 deletions

View File

@@ -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 # 用於模板顯示

View File

@@ -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

View File

@@ -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 APIproduction 預設模型為 `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`
---

View File

@@ -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,
)

View File

@@ -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 抓取監控資料"""

View File

@@ -8,7 +8,7 @@ run_scheduler.py — momo-scheduler 容器入口點
每 4 小時competitor_price_feeder、icaim_analysis
每 6 小時quality_rescore
每 12 小時dedup_batch
每 1 天 db_backup03:00、cleanup_agent_context03:30、backup_monitor04:00、daily_report09:00、roi_monthly_report gate09:05、ai_smoke_summary09:10、observability_daily_summary09:30、pchome_match_backfill10:30、openclaw_meta_analysis12:00, Phase 4 降頻、ppt_vision_audit22:00、daily_token_report23:55
每 1 天 db_backup03:00、cleanup_agent_context03:30、backup_monitor04:00、daily_report09:00、roi_monthly_report gate09:05、ai_smoke_summary09:10、observability_daily_summary09:30、pchome_match_backfill10:30、openclaw_meta_analysis12:00, Phase 4 降頻、ppt_auto_generation20:30、ppt_vision_audit22:00、daily_token_report23: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:05roi_monthly_report月初第 1 日才送)")
# PPT 自動簡報補齊(先產出定義中的報表,再交給 22:00 vision audit
schedule.every().day.at("20:30").do(run_ppt_auto_generation_task)
logger.info("📅 每日 20:30ppt_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:00ppt_vision_auditPPT_VISION_ENABLED=true 才生效)")

View File

@@ -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:

View 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

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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',

View 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 %}

View File

@@ -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 %}

View 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

View 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;
}
}

View File

@@ -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,

View File

@@ -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 {

View 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;
}
}

View File

@@ -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 {

View 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;
}
}

View File

@@ -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') {

View File

@@ -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();
});
})();

View File

@@ -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();
}
})();