This commit is contained in:
@@ -47,6 +47,14 @@ EMAIL_RECEIVER=receiver_email@gmail.com
|
||||
PUBLIC_URL=http://your_server_ip:port
|
||||
NGROK_AUTH_TOKEN=your_ngrok_auth_token
|
||||
|
||||
# ==========================================
|
||||
# 市場情報模組設定(預設全部關閉)
|
||||
# ==========================================
|
||||
# Phase 1 僅允許安全骨架;正式爬蟲與 DB 寫入需逐步開啟
|
||||
MARKET_INTEL_ENABLED=false
|
||||
MARKET_INTEL_CRAWLER_ENABLED=false
|
||||
MARKET_INTEL_WRITE_ENABLED=false
|
||||
|
||||
# ==========================================
|
||||
# 通訊模組設定(從環境變數讀取)
|
||||
# ==========================================
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
|
||||
================================================================================
|
||||
跨平台市場情報模組 (ADR-035 / 2026-05-06) [IN PROGRESS]
|
||||
================================================================================
|
||||
|
||||
【已完成】
|
||||
- ADR-035:定義跨平台市場活動情報系統、feature flags、market_* schema、爬蟲安全邊界與分階段 rollout。
|
||||
- 模組化盤點:更新 `docs/memory/code_modularization_inventory_20260430.md`,標記市場情報不可塞回 `app.py`、`scheduler.py`、`routes/sales_routes.py` 等大檔。
|
||||
- Phase 1 安全骨架:新增 `routes/market_intel_routes.py`、`services/market_intel/`、`templates/market_intel/disabled.html`,預設只顯示 disabled 狀態。
|
||||
- Phase 2 schema 骨架:新增 `database/market_intel_models.py`,定義 `market_platforms`、`market_campaigns`、`market_campaign_snapshots`、`market_campaign_products`、`market_product_price_history`、`market_product_matches`、`market_crawler_runs`。
|
||||
- Metadata 守門:`database/manager.py` 顯式 import market_intel models,`app.py` expected metadata tables 已同步。
|
||||
- 安全開關:`.env.example` 補 `MARKET_INTEL_ENABLED=false`、`MARKET_INTEL_CRAWLER_ENABLED=false`、`MARKET_INTEL_WRITE_ENABLED=false`。
|
||||
|
||||
【下次待辦】
|
||||
- 補 marketplace 平台種子資料策略,但正式寫入仍需 feature flag 與 migration/smoke gate。
|
||||
- 新增 `services/market_intel/adapters/base.py` 與第一個 read-only MOMO/PChome discovery adapter。
|
||||
- 新增 market_intel schema smoke test,確認 fresh env metadata 表名一致。
|
||||
- 市場情報 UI 後續頁面必須沿用 V2 暖紙、暖墨、等寬數字與點陣風格,禁止複製巨型分析頁 template 模式。
|
||||
|
||||
================================================================================
|
||||
AI 自動化閉環治理同步 (2026-04-29) [DONE]
|
||||
================================================================================
|
||||
|
||||
9
app.py
9
app.py
@@ -96,7 +96,7 @@ except Exception as e:
|
||||
|
||||
# 🚩 系統版本定義 (備份與顯示用)
|
||||
# 🚩 2026-05-01 V10.76: Move monthly analysis report onto V2 shell
|
||||
SYSTEM_VERSION = "V10.77"
|
||||
SYSTEM_VERSION = "V10.86"
|
||||
|
||||
# ==========================================
|
||||
# 🔒 SQL Injection 防護函數
|
||||
@@ -378,6 +378,10 @@ from routes.pchome_routes import pchome_bp
|
||||
app.register_blueprint(pchome_bp)
|
||||
sys_log.info("[Blueprint] ✅ pchome_bp 已註冊")
|
||||
|
||||
from routes.market_intel_routes import market_intel_bp
|
||||
app.register_blueprint(market_intel_bp)
|
||||
sys_log.info("[Blueprint] ✅ market_intel_bp 已註冊")
|
||||
|
||||
# V-Fix: 註冊 slugify 函數供模板使用(實作搬至 utils/text_helpers.py)
|
||||
from utils.text_helpers import slugify # noqa: E402
|
||||
|
||||
@@ -398,6 +402,9 @@ EXPECTED_METADATA_TABLES = {
|
||||
'import_jobs', 'import_config', 'notification_templates', 'ppt_reports',
|
||||
'vendor_stockout', 'vendor_list', 'vendor_emails', 'email_send_log',
|
||||
'realtime_sales_monthly',
|
||||
'market_platforms', 'market_campaigns', 'market_campaign_snapshots',
|
||||
'market_campaign_products', 'market_product_price_history',
|
||||
'market_product_matches', 'market_crawler_runs',
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -111,6 +111,13 @@ EMAIL_RECEIVER = os.getenv('EMAIL_RECEIVER', '')
|
||||
# ==========================================
|
||||
PUBLIC_URL = os.getenv('PUBLIC_URL', 'https://mo.wooo.work')
|
||||
|
||||
# ==========================================
|
||||
# 市場情報模組設定(預設全部關閉)
|
||||
# ==========================================
|
||||
MARKET_INTEL_ENABLED = os.getenv('MARKET_INTEL_ENABLED', 'false').lower() == 'true'
|
||||
MARKET_INTEL_CRAWLER_ENABLED = os.getenv('MARKET_INTEL_CRAWLER_ENABLED', 'false').lower() == 'true'
|
||||
MARKET_INTEL_WRITE_ENABLED = os.getenv('MARKET_INTEL_WRITE_ENABLED', 'false').lower() == 'true'
|
||||
|
||||
# 補上 EXCEL_EXPORT_DIR 定義
|
||||
EXCEL_EXPORT_DIR = os.path.join(DATA_DIR, 'excel_exports')
|
||||
|
||||
@@ -307,7 +314,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
|
||||
# ==========================================
|
||||
# 系統版本與路徑
|
||||
# ==========================================
|
||||
SYSTEM_VERSION = "V10.76"
|
||||
SYSTEM_VERSION = "V10.86"
|
||||
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
|
||||
public_url = PUBLIC_URL # 用於模板顯示
|
||||
|
||||
|
||||
@@ -25,6 +25,15 @@ from .notification_models import NotificationTemplate # noqa: F401 - 確保 not
|
||||
from .ppt_reports import PPTReport # noqa: F401 - 確保 ppt_reports 表被 Base.metadata 管理
|
||||
from .vendor_models import VendorStockout, VendorList, VendorEmail, EmailSendLog # noqa: F401 - 確保 vendor 表被 Base.metadata 管理
|
||||
from .realtime_sales_models import RealtimeSalesMonthly # noqa: F401 - 確保 realtime_sales_monthly 被 Base.metadata 管理
|
||||
from .market_intel_models import ( # noqa: F401 - ADR-035 market_* 表
|
||||
MarketPlatform,
|
||||
MarketCampaign,
|
||||
MarketCampaignSnapshot,
|
||||
MarketCampaignProduct,
|
||||
MarketProductPriceHistory,
|
||||
MarketProductMatch,
|
||||
MarketCrawlerRun,
|
||||
)
|
||||
|
||||
# 🚩 導入優化後的日誌管理模組
|
||||
from utils.logger_manager import SystemLogger
|
||||
|
||||
224
database/market_intel_models.py
Normal file
224
database/market_intel_models.py
Normal file
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""跨平台市場活動情報 ORM models。"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from database.models import Base
|
||||
|
||||
|
||||
TAIPEI_TZ = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def taipei_now():
|
||||
"""取得台北時間 naive datetime,符合專案 DB 時間規範。"""
|
||||
return datetime.now(TAIPEI_TZ).replace(tzinfo=None)
|
||||
|
||||
|
||||
class MarketPlatform(Base):
|
||||
"""市場平台設定,例如 MOMO / PChome / Coupang / Shopee。"""
|
||||
|
||||
__tablename__ = "market_platforms"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
code = Column(String(50), unique=True, nullable=False, index=True)
|
||||
name = Column(String(120), nullable=False)
|
||||
base_url = Column(String(500))
|
||||
enabled = Column(Boolean, default=False, nullable=False)
|
||||
crawl_policy_json = Column(Text)
|
||||
created_at = Column(DateTime, default=taipei_now, nullable=False)
|
||||
updated_at = Column(DateTime, default=taipei_now, onupdate=taipei_now, nullable=False)
|
||||
|
||||
campaigns = relationship("MarketCampaign", back_populates="platform")
|
||||
|
||||
|
||||
class MarketCampaign(Base):
|
||||
"""跨平台活動檔期。"""
|
||||
|
||||
__tablename__ = "market_campaigns"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
platform_code = Column(String(50), ForeignKey("market_platforms.code"), nullable=False, index=True)
|
||||
campaign_key = Column(String(200), nullable=False)
|
||||
campaign_name = Column(String(500), nullable=False)
|
||||
campaign_type = Column(String(80), index=True)
|
||||
campaign_url = Column(Text)
|
||||
start_at = Column(DateTime)
|
||||
end_at = Column(DateTime)
|
||||
status = Column(String(30), default="unknown", nullable=False, index=True)
|
||||
discovered_at = Column(DateTime, default=taipei_now, nullable=False)
|
||||
last_seen_at = Column(DateTime, default=taipei_now, nullable=False)
|
||||
metadata_json = Column(Text)
|
||||
|
||||
platform = relationship("MarketPlatform", back_populates="campaigns")
|
||||
snapshots = relationship("MarketCampaignSnapshot", back_populates="campaign")
|
||||
products = relationship("MarketCampaignProduct", back_populates="campaign")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("platform_code", "campaign_key", name="uq_market_campaign_platform_key"),
|
||||
Index("idx_market_campaign_status_time", "status", "start_at", "end_at"),
|
||||
)
|
||||
|
||||
|
||||
class MarketCampaignSnapshot(Base):
|
||||
"""活動頁每次爬取快照。"""
|
||||
|
||||
__tablename__ = "market_campaign_snapshots"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
campaign_id = Column(Integer, ForeignKey("market_campaigns.id"), nullable=False, index=True)
|
||||
batch_id = Column(String(80), nullable=False, index=True)
|
||||
crawled_at = Column(DateTime, default=taipei_now, nullable=False, index=True)
|
||||
title = Column(String(500))
|
||||
hero_text = Column(Text)
|
||||
coupon_text = Column(Text)
|
||||
raw_discount_text = Column(Text)
|
||||
page_hash = Column(String(128), index=True)
|
||||
raw_snapshot_path = Column(Text)
|
||||
status = Column(String(30), default="success", nullable=False, index=True)
|
||||
error_message = Column(Text)
|
||||
metadata_json = Column(Text)
|
||||
|
||||
campaign = relationship("MarketCampaign", back_populates="snapshots")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_market_campaign_snapshot_campaign_time", "campaign_id", "crawled_at"),
|
||||
)
|
||||
|
||||
|
||||
class MarketCampaignProduct(Base):
|
||||
"""活動頁中的平台商品快照主檔。"""
|
||||
|
||||
__tablename__ = "market_campaign_products"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
campaign_id = Column(Integer, ForeignKey("market_campaigns.id"), nullable=False, index=True)
|
||||
platform_code = Column(String(50), nullable=False, index=True)
|
||||
platform_product_id = Column(String(200), nullable=False, index=True)
|
||||
product_url = Column(Text)
|
||||
name = Column(String(500), nullable=False)
|
||||
brand = Column(String(200), index=True)
|
||||
image_url = Column(Text)
|
||||
category_text = Column(String(300), index=True)
|
||||
price = Column(Float)
|
||||
original_price = Column(Float)
|
||||
discount_text = Column(String(200))
|
||||
discount_rate = Column(Float)
|
||||
coupon_text = Column(Text)
|
||||
stock_text = Column(String(200))
|
||||
sold_count = Column(Integer)
|
||||
rating = Column(Float)
|
||||
review_count = Column(Integer)
|
||||
rank_position = Column(Integer)
|
||||
is_active = Column(Boolean, default=True, nullable=False, index=True)
|
||||
first_seen_at = Column(DateTime, default=taipei_now, nullable=False)
|
||||
last_seen_at = Column(DateTime, default=taipei_now, nullable=False, index=True)
|
||||
metadata_json = Column(Text)
|
||||
|
||||
campaign = relationship("MarketCampaign", back_populates="products")
|
||||
price_history = relationship("MarketProductPriceHistory", back_populates="market_product")
|
||||
matches = relationship("MarketProductMatch", back_populates="market_product")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"campaign_id",
|
||||
"platform_code",
|
||||
"platform_product_id",
|
||||
name="uq_market_campaign_product",
|
||||
),
|
||||
Index("idx_market_product_platform_seen", "platform_code", "last_seen_at"),
|
||||
Index("idx_market_product_discount", "discount_rate", "price"),
|
||||
)
|
||||
|
||||
|
||||
class MarketProductPriceHistory(Base):
|
||||
"""市場商品價格歷史快照。"""
|
||||
|
||||
__tablename__ = "market_product_price_history"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
market_product_id = Column(Integer, ForeignKey("market_campaign_products.id"), nullable=False, index=True)
|
||||
campaign_id = Column(Integer, ForeignKey("market_campaigns.id"), nullable=False, index=True)
|
||||
platform_code = Column(String(50), nullable=False, index=True)
|
||||
platform_product_id = Column(String(200), nullable=False, index=True)
|
||||
price = Column(Float)
|
||||
original_price = Column(Float)
|
||||
discount_rate = Column(Float)
|
||||
stock_text = Column(String(200))
|
||||
sold_count = Column(Integer)
|
||||
rank_position = Column(Integer)
|
||||
crawled_at = Column(DateTime, default=taipei_now, nullable=False, index=True)
|
||||
batch_id = Column(String(80), nullable=False, index=True)
|
||||
metadata_json = Column(Text)
|
||||
|
||||
market_product = relationship("MarketCampaignProduct", back_populates="price_history")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_market_price_platform_time", "platform_code", "platform_product_id", "crawled_at"),
|
||||
Index("idx_market_price_campaign_time", "campaign_id", "crawled_at"),
|
||||
)
|
||||
|
||||
|
||||
class MarketProductMatch(Base):
|
||||
"""市場商品與我方 MOMO 商品的比對審核結果。"""
|
||||
|
||||
__tablename__ = "market_product_matches"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
market_product_id = Column(Integer, ForeignKey("market_campaign_products.id"), nullable=False, index=True)
|
||||
momo_product_id = Column(Integer, ForeignKey("products.id"), index=True)
|
||||
momo_i_code = Column(String(50), index=True)
|
||||
match_score = Column(Float, default=0.0, nullable=False)
|
||||
match_status = Column(String(30), default="needs_review", nullable=False, index=True)
|
||||
match_reason_json = Column(Text)
|
||||
created_at = Column(DateTime, default=taipei_now, nullable=False)
|
||||
reviewed_at = Column(DateTime)
|
||||
reviewed_by = Column(String(120))
|
||||
|
||||
market_product = relationship("MarketCampaignProduct", back_populates="matches")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("market_product_id", "momo_i_code", name="uq_market_product_momo_match"),
|
||||
Index("idx_market_match_status_score", "match_status", "match_score"),
|
||||
)
|
||||
|
||||
|
||||
class MarketCrawlerRun(Base):
|
||||
"""市場情報爬蟲執行紀錄。"""
|
||||
|
||||
__tablename__ = "market_crawler_runs"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
platform_code = Column(String(50), index=True)
|
||||
crawler_name = Column(String(120), nullable=False, index=True)
|
||||
campaign_id = Column(Integer, ForeignKey("market_campaigns.id"), index=True)
|
||||
batch_id = Column(String(80), nullable=False, index=True)
|
||||
started_at = Column(DateTime, default=taipei_now, nullable=False, index=True)
|
||||
finished_at = Column(DateTime)
|
||||
status = Column(String(30), default="started", nullable=False, index=True)
|
||||
dry_run = Column(Boolean, default=True, nullable=False)
|
||||
pages_found = Column(Integer, default=0, nullable=False)
|
||||
products_found = Column(Integer, default=0, nullable=False)
|
||||
products_changed = Column(Integer, default=0, nullable=False)
|
||||
error_count = Column(Integer, default=0, nullable=False)
|
||||
error_message = Column(Text)
|
||||
metadata_json = Column(Text)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_market_crawler_run_platform_time", "platform_code", "started_at"),
|
||||
Index("idx_market_crawler_run_status_time", "status", "started_at"),
|
||||
)
|
||||
181
docs/adr/ADR-035-cross-platform-market-campaign-intelligence.md
Normal file
181
docs/adr/ADR-035-cross-platform-market-campaign-intelligence.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# ADR-035: 跨平台市場活動情報系統
|
||||
|
||||
- **狀態**: Accepted
|
||||
- **日期**: 2026-05-06
|
||||
- **觸發**: 使用者提出定期掌握 MOMO / 蝦皮 / 酷澎 / PChome 等電商活動檔期、活動商品、競品價格與資料庫保存需求
|
||||
- **相關 ADR**: ADR-011(跨專案資源隔離)、ADR-017(模組化收尾路線圖)、ADR-025(市場情報週報)
|
||||
- **相關 Memory**: `docs/memory/code_modularization_inventory_20260430.md`
|
||||
|
||||
## Context
|
||||
|
||||
EwoooC 目前已有 MOMO EDM / 節慶活動資料、`promo_products`、PChome 競品價格 feeder、PPT 市場情報週報與多個分析頁,但跨平台活動情報尚未形成獨立資料模型與可維護爬蟲框架。
|
||||
|
||||
若直接把蝦皮、酷澎、PChome 等平台資料塞進既有 `promo_products`、`routes/sales_routes.py`、`scheduler.py` 或分析頁大型 template,會造成下列風險:
|
||||
|
||||
1. 不同平台欄位語意不同,舊表無法承載活動檔期、平台商品 ID、折扣券、銷售訊號與價格歷史。
|
||||
2. 既有 `scheduler.py`、`routes/sales_routes.py`、`routes/dashboard_routes.py`、多個 crawler service 已達大檔治理門檻,新增功能會加劇技術債。
|
||||
3. 新爬蟲若直接上正式排程,可能造成 DB 寫入量、外部平台 rate limit、排程阻塞與告警噪音。
|
||||
4. 新市場情報頁若複製既有巨型 Jinja template 模式,會延續 UI token、字體、色彩與點陣風格不一致問題。
|
||||
|
||||
## Decision
|
||||
|
||||
建立新的 **跨平台市場活動情報系統**,採分階段落地、預設關閉、可獨立回退的方式推進。
|
||||
|
||||
### 1. 模組邊界
|
||||
|
||||
新功能必須使用獨立模組,不得塞回既有巨檔:
|
||||
|
||||
- `services/market_intel/`:活動探索、商品爬取、正規化、商品比對、告警摘要。
|
||||
- `services/market_intel/adapters/`:各平台 adapter,例如 MOMO / PChome / Coupang / Shopee。
|
||||
- `database/market_intel_models.py`:`market_*` ORM models。
|
||||
- `routes/market_intel_routes.py`:市場情報頁面與 API route glue。
|
||||
- `templates/market_intel/`:市場情報 UI template。
|
||||
- `services/scheduler/` 或獨立 job module:市場情報排程掛載點。
|
||||
|
||||
### 2. 資料模型
|
||||
|
||||
新增 `market_*` schema 作為唯一主資料層:
|
||||
|
||||
- `market_platforms`
|
||||
- `market_campaigns`
|
||||
- `market_campaign_snapshots`
|
||||
- `market_campaign_products`
|
||||
- `market_product_price_history`
|
||||
- `market_product_matches`
|
||||
- `market_crawler_runs`
|
||||
|
||||
`promo_products` 只作為既有 MOMO 活動資料的相容來源或雙寫過渡,不再承接跨平台唯一真相。
|
||||
|
||||
### 3. Feature Flag 與啟用策略
|
||||
|
||||
市場情報第一階段必須預設關閉:
|
||||
|
||||
- `MARKET_INTEL_ENABLED=false`
|
||||
- `MARKET_INTEL_CRAWLER_ENABLED=false`
|
||||
- `MARKET_INTEL_WRITE_ENABLED=false`
|
||||
|
||||
初期允許 dry-run:建立 run log、解析活動與商品,但不大量寫入正式商品資料。正式入庫與排程啟用需另行通過 smoke test、rate limit 驗證與 rollback drill。
|
||||
|
||||
### 4. 爬蟲安全邊界
|
||||
|
||||
市場情報爬蟲只允許抓公開頁面或公開結構化資料,不做登入、不碰會員資料、不破解反爬、不使用帳號池、不繞付費牆。
|
||||
|
||||
每平台 adapter 必須具備:
|
||||
|
||||
- rate limit
|
||||
- timeout
|
||||
- retry ceiling
|
||||
- user-agent 與來源識別
|
||||
- run log
|
||||
- error classification
|
||||
- dry-run mode
|
||||
- 可單平台停用開關
|
||||
|
||||
### 5. UI / UX 邊界
|
||||
|
||||
市場情報 UI 不複製巨型分析頁模式,必須先抽共用元件與設計 token:
|
||||
|
||||
- 活動檔期看板
|
||||
- 活動商品池
|
||||
- 商品比對審核
|
||||
- 市場機會與威脅
|
||||
|
||||
所有新頁必須符合 V2 暖紙、暖墨、焦糖 accent、等寬數字、點陣紋理與真實資料規範。未串接資料時只能顯示可診斷空狀態,不得使用假商品或假 KPI。
|
||||
|
||||
### 6. 上線與回退
|
||||
|
||||
部署遵守 ADR-011:
|
||||
|
||||
- 禁止 `docker compose ... --remove-orphans`
|
||||
- 禁止影響 `momo-db` 容器生命週期
|
||||
- 只用 `docker compose up -d --no-deps --force-recreate <service>` 精準重建
|
||||
- health check 只打 `/health`
|
||||
|
||||
異常回退順序:
|
||||
|
||||
1. 關閉 `MARKET_INTEL_*` feature flags。
|
||||
2. 停用市場情報 scheduler job。
|
||||
3. 保留 `market_*` 表與 run log,不刪資料。
|
||||
4. 回復上一版 route / service 程式碼。
|
||||
5. 僅重啟受影響應用容器,不動 DB。
|
||||
|
||||
## Phased Rollout
|
||||
|
||||
### Phase 0:Readiness Audit
|
||||
|
||||
- 更新模組化 inventory。
|
||||
- 確認 `scheduler.py` 無 conflict marker 且可 py_compile。
|
||||
- 標記市場情報不可寫入的既有大檔。
|
||||
- 完成 ADR-035。
|
||||
|
||||
### Phase 1:Skeleton Only
|
||||
|
||||
- 新增 feature flags。
|
||||
- 新增 `market_intel` package skeleton。
|
||||
- 新增空 route / disabled page / dry-run service。
|
||||
- 不啟用正式排程,不大量入庫。
|
||||
|
||||
### Phase 2:DB Schema
|
||||
|
||||
- 新增 `market_*` ORM models。
|
||||
- 補 metadata import 與 schema smoke。
|
||||
- 寫入 crawler run log 與少量 dry-run snapshot。
|
||||
|
||||
### Phase 3:MOMO / PChome Adapter
|
||||
|
||||
- 先接成本最低且已有脈絡的平台。
|
||||
- 只抓公開活動入口與活動商品。
|
||||
- 建立活動與商品正規化規則。
|
||||
|
||||
### Phase 4:Coupang / Shopee Adapter
|
||||
|
||||
- Coupang 先做保守 adapter。
|
||||
- Shopee 因動態資料與反爬風險較高,最後做,並維持更嚴格節流。
|
||||
|
||||
### Phase 5:Product Matching + HITL
|
||||
|
||||
- 用品牌、規格、容量、關鍵字與現有商品資料計算 match score。
|
||||
- 低信心進人工審核,不自動合併。
|
||||
|
||||
### Phase 6:AI Insight / Telegram
|
||||
|
||||
- 只基於 DB 實證資料產生摘要與告警。
|
||||
- 不做空泛 LLM 建議。
|
||||
- 高風險告警需包含平台、活動、商品、價差、資料時間與可追溯 run id。
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### 方案 A:直接擴充 `promo_products`
|
||||
|
||||
不採用。`promo_products` 偏 MOMO 活動商品語境,無法乾淨承載跨平台活動、商品快照、價格歷史、比對審核與 crawler run log。
|
||||
|
||||
### 方案 B:直接塞進 `scheduler.py` 與既有 crawler service
|
||||
|
||||
不採用。`scheduler.py` 與多個 crawler service 已達大檔治理門檻,新增跨平台 adapter 會讓排程與錯誤隔離更脆弱。
|
||||
|
||||
### 方案 C:先做完整 UI 再補資料
|
||||
|
||||
不採用。違反真實資料與真實頁面規範,容易產生假 KPI、假商品與不可診斷狀態。
|
||||
|
||||
## Consequences
|
||||
|
||||
**正面**
|
||||
|
||||
- 市場情報可以獨立演進,不污染業績分析、dashboard、scheduler 既有技術債。
|
||||
- 每平台 adapter 可單獨停用、測試與回退。
|
||||
- `market_*` schema 可保留歷史、做趨勢與商品比對。
|
||||
- AI 告警能基於可追溯 DB run log,降低空泛推論。
|
||||
|
||||
**負面**
|
||||
|
||||
- 初期開發量比「直接塞舊表」更高。
|
||||
- 需要新增 schema、service、route、UI 與 scheduler registry。
|
||||
- Shopee 等平台可能因公開資料穩定性與 rate limit 需要較長探索期。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Phase 0 完成後,不改 runtime 行為。
|
||||
- Phase 1 完成後,`MARKET_INTEL_ENABLED=false` 時所有新功能完全不影響既有頁面與排程。
|
||||
- 任一 crawler adapter 失敗不得阻塞既有 MOMO 排程。
|
||||
- 新市場情報 route 不新增到 `app.py`。
|
||||
- 任一新 Python 檔若超過 600 行需提出拆分理由,超過 800 行需更新模組化 inventory。
|
||||
@@ -56,6 +56,7 @@
|
||||
| [032](ADR-032-rag-autonomous-learning-loop.md) | RAG 自主學習迴圈 — Distiller + PromotionGate + 反饋環(Phase 11) | Accepted | 2026-05-03 |
|
||||
| [033](ADR-033-rag-three-guardrails.md) | RAG 治理三護欄 — Promotion Gate / Firecrawl 資源 / BGE-M3 一致性(Owen v5.0 鐵律) | Accepted | 2026-05-03 |
|
||||
| [034](ADR-034-dynamic-model-router.md) | Caller × Context 動態 Model Router(短文 gemma3 / 複雜 SKU qwen3:14b / 重構 coder:32b) | Accepted | 2026-05-04 |
|
||||
| [035](ADR-035-cross-platform-market-campaign-intelligence.md) | 跨平台市場活動情報系統 | Accepted | 2026-05-06 |
|
||||
|
||||
## 規範
|
||||
|
||||
|
||||
581
docs/guides/claude_design_brief.md
Normal file
581
docs/guides/claude_design_brief.md
Normal file
@@ -0,0 +1,581 @@
|
||||
# EwoooC × MOMO Pro — Claude Design Brief
|
||||
**Version:** 1.0 (2026-05-01)
|
||||
**Purpose:** 供 Claude Design 理解現況並繼續 UI/UX 視覺設計優化。
|
||||
|
||||
---
|
||||
|
||||
## 1. 專案概覽
|
||||
|
||||
| 欄位 | 內容 |
|
||||
|------|------|
|
||||
| 產品名稱 | EwoooC 商家後台(原名 MOMO Pro)|
|
||||
| 產品類型 | B2B 電商監控與管理系統(商品價格監控、活動管理、業績分析)|
|
||||
| 語言 | 繁體中文 (zh-TW) |
|
||||
| 渲染方式 | Server-side Jinja2 (Flask),無 SPA |
|
||||
| CSS 框架 | Bootstrap 5.3.3 |
|
||||
| 圖標庫 | Font Awesome 6.0.0 |
|
||||
| 圖表 | Chart.js 3.9.1 + ECharts 5.4.3 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 設計語言宣言
|
||||
|
||||
> **"Claude 暖系 × Nothing Phone 點陣機械感"**
|
||||
|
||||
### 核心哲學
|
||||
- **底色**:溫暖米紙感(`#ebe6dc`)— 不刺眼的辦公室頁面底
|
||||
- **主調**:焦糖橘(`#c96442`)— 暖而有力的品牌色,取自 Claude AI 色調
|
||||
- **黑白對比**:Nothing Phone 風格的純黑側欄(`#1a1a1a`)搭配鮮明白字
|
||||
- **排版**:標題用 JetBrains Mono 等寬字,機械儀表板感;內文用 Inter + Noto Sans TC
|
||||
- **裝飾**:8px 點陣背景(dot matrix)作為深色卡片的背景紋路
|
||||
- **陰影哲學**:線條優先(`1px solid rgba(...)`),避免大陰影,方角為主(radius 最大 6px)
|
||||
|
||||
---
|
||||
|
||||
## 3. 兩套 Layout 系統(重要!現況)
|
||||
|
||||
目前有**兩個共存的 Base Template**,正處於從舊版遷移至新版的過渡期:
|
||||
|
||||
### 3-A. 舊版(`base.html` + `_navbar.html`)
|
||||
- **Layout**:頂部固定 Navbar,全頁 container 排版
|
||||
- **Navbar**:深藍漸層 `#1e3c72 → #2a5298`,固定頂部 `position: fixed`
|
||||
- **背景**:淺灰冷色 `linear-gradient(135deg, #f5f7fa 0%, #e8ecf1 100%)`
|
||||
- **Accent 顏色**:藍紫漸層 `#667eea → #764ba2`(舊品牌色,仍在多數頁面中)
|
||||
- **使用頁面**:`dashboard.html`、`sales_analysis.html`、`monthly_summary_analysis.html` 等主要功能頁
|
||||
|
||||
### 3-B. 新版(`ewoooc_base.html` + `ewoooc-tokens.css` + `ewoooc-shell.css`)
|
||||
- **Layout**:CSS Grid Sidebar(240px)+ 主內容區,Topbar 64px sticky
|
||||
- **Sidebar**:純黑背景 `#1a1a1a`,白色文字,焦糖橘 accent 高亮
|
||||
- **背景**:米色紙張感 `#ebe6dc`
|
||||
- **Accent 顏色**:焦糖橘 `#c96442`(新品牌色)
|
||||
- **使用頁面**:`vendor_stockout/`、部分新功能頁面(EwoooC 路由下的頁面)
|
||||
|
||||
**設計方向**:以 3-B 新版為目標方向,所有優化/新設計請對齊新版設計系統。
|
||||
|
||||
---
|
||||
|
||||
## 4. 完整 Design Token(Source of Truth)
|
||||
|
||||
### 4.1 色彩系統
|
||||
|
||||
#### 背景層次(米色系)
|
||||
```
|
||||
--momo-bg-body: #ebe6dc ← 頁面底色(最深)
|
||||
--momo-bg-surface: #faf7f0 ← 卡片表面(預設)
|
||||
--momo-bg-elevated: #fdfaf3 ← 懸浮/選中卡片
|
||||
--momo-bg-subtle: #e2dccf ← 分隔區塊底色
|
||||
--momo-bg-muted: #cfc7b5 ← 更暗的區塊
|
||||
--momo-bg-paper: #f3eee2 ← Sidebar 底色、特殊卡片
|
||||
```
|
||||
|
||||
#### 文字層次(暖墨系)
|
||||
```
|
||||
--momo-text-primary: #2a2520 ← 主要內文
|
||||
--momo-text-secondary: #645c52 ← 次要說明
|
||||
--momo-text-tertiary: #9b9081 ← 標籤標題、placeholder
|
||||
--momo-text-disabled: #c4baa8 ← 禁用狀態
|
||||
--momo-text-inverse: #faf7f0 ← 深色背景上的白字
|
||||
--momo-text-link: #c96442 ← 連結色
|
||||
--momo-text-link-hover:#8f4530 ← 連結懸停
|
||||
```
|
||||
|
||||
#### 主色調(焦糖橘)
|
||||
```
|
||||
--momo-accent: #c96442 ← 主 accent,Button/Active/Badge
|
||||
--momo-accent-50: #fbf2ef ← 極淡(hover 背景)
|
||||
--momo-accent-100: #f5e1d9 ← 淡(選中 tag 背景)
|
||||
--momo-accent-200: #ecc3b3 ← 較淡
|
||||
--momo-accent-500: #c96442 ← = accent(主)
|
||||
--momo-accent-600: #b1543a ← hover 按鈕
|
||||
--momo-accent-700: #8f4530 ← active/pressed 按鈕
|
||||
--momo-accent-soft: rgba(201,100,66,0.12) ← 懸停背景
|
||||
```
|
||||
|
||||
#### 狀態色(去飽和,適配米色底)
|
||||
```
|
||||
成功 success: text #2a7a3f | bg #e3ebd9 | border #c5d4b0
|
||||
危險 danger: text #b5342f | bg #f0d8d4 | border #d9b1ac
|
||||
警告 warning: text #b88416 | bg #f3e7c4 | border #d9c590
|
||||
資訊 info: text #2d5d80 | bg #d8e2ea | border #b5c5d2
|
||||
```
|
||||
|
||||
#### 邊框與分隔線
|
||||
```
|
||||
--momo-border: #2a2520 (實線邊框)
|
||||
--momo-border-light: rgba(42,37,32,0.16) ← 淡分隔線
|
||||
--momo-border-focus: #c96442 ← 聚焦狀態
|
||||
--momo-divider: rgba(42,37,32,0.12)
|
||||
```
|
||||
|
||||
#### 導航色(Nothing 黑)— Sidebar 專用
|
||||
```
|
||||
sidebar background: #1a1612(深暖黑)
|
||||
nav-link hover bg: rgba(201,100,66,0.12)(accent-soft)
|
||||
nav-link active bg: #c96442(實色焦糖橘)
|
||||
nav-link active text: #faf7f0(反色)
|
||||
status-card border: rgba(201,100,66,0.35)(橘色描邊)
|
||||
```
|
||||
|
||||
### 4.2 Typography
|
||||
|
||||
#### 字型堆疊
|
||||
```
|
||||
Display(標題): "JetBrains Mono", "Space Mono", "SF Mono", Menlo, Consolas, monospace
|
||||
Body(內文): "Inter", -apple-system, "PingFang TC", "Noto Sans TC", "Microsoft JhengHei", sans-serif
|
||||
Mono(數據): "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace
|
||||
```
|
||||
|
||||
#### 字型大小
|
||||
```
|
||||
xs → 0.75rem (12px) ← 極小標籤
|
||||
sm → 0.8125rem (13px) ← 次要內文、導航項目
|
||||
base → 0.9375rem (15px) ← 主要內文
|
||||
lg → 1.0625rem (17px) ← 稍大內文
|
||||
xl → 1.625rem (26px) ← 頁面主標題
|
||||
2xl → 2.25rem (36px) ← 大數字顯示
|
||||
```
|
||||
|
||||
#### 字重與行高
|
||||
```
|
||||
normal: 400 base line-height: 1.5
|
||||
medium: 500 tight line-height: 1.15(標題)
|
||||
semibold: 600 loose line-height: 1.7(長文)
|
||||
bold: 700
|
||||
black: 800(品牌名稱、數字展示用)
|
||||
```
|
||||
|
||||
#### 特殊文字工具類
|
||||
```css
|
||||
.momo-display → JetBrains Mono,標題用,搭配 font-feature-settings: "tnum", "ss01"
|
||||
.momo-mono → 等寬字體,數字/代碼,搭配 font-feature-settings: "tnum"
|
||||
.momo-label → JetBrains Mono 10px,font-weight 600,letter-spacing 0.12em,text-transform uppercase
|
||||
用於分類標題、狀態標籤(如 "監控" "營運" "系統")
|
||||
```
|
||||
|
||||
### 4.3 間距系統(8px 基數)
|
||||
```
|
||||
--momo-space-1: 0.25rem (4px)
|
||||
--momo-space-2: 0.5rem (8px)
|
||||
--momo-space-3: 0.75rem (12px)
|
||||
--momo-space-4: 1rem (16px)
|
||||
--momo-space-5: 1.5rem (24px)
|
||||
--momo-space-6: 2rem (32px)
|
||||
--momo-space-7: 3rem (48px)
|
||||
--momo-space-8: 4rem (64px)
|
||||
```
|
||||
|
||||
頁面內容區 padding:`28px 32px 40px`(desktop),`20px 16px 32px`(mobile)
|
||||
|
||||
### 4.4 圓角系統(方角優先)
|
||||
```
|
||||
--momo-radius-sm: 2px (0.125rem) ← badge、code、shortcut
|
||||
--momo-radius-md: 4px (0.25rem) ← 按鈕、輸入框、卡片 → 預設
|
||||
--momo-radius-lg: 6px (0.375rem) ← 較大卡片、modal
|
||||
--momo-radius-pill: 50rem ← 圓形按鈕、chip
|
||||
--momo-radius-circle: 50% ← 頭像
|
||||
```
|
||||
|
||||
> 注意:舊版頁面(dashboard.html)使用更大的圓角(16px~20px)。新設計請用 4px~6px。
|
||||
|
||||
### 4.5 陰影系統(線條感優先)
|
||||
```
|
||||
--momo-shadow-sm: 0 0 0 1px rgba(26,26,26,0.08)
|
||||
--momo-shadow-md: 0 0 0 1px rgba(26,26,26,0.10)
|
||||
--momo-shadow-lg: 0 12px 40px -8px rgba(26,26,26,0.18), 0 0 0 1px rgba(26,26,26,0.10)
|
||||
--momo-shadow-colored: 0 0 0 2px rgba(201,100,66,0.25) ← accent 聚焦輪廓
|
||||
```
|
||||
|
||||
> 陰影哲學:主要靠 `1px solid border` 定義邊界,大投影只用於 Modal/Popover。
|
||||
|
||||
### 4.6 動畫系統
|
||||
```
|
||||
--momo-duration-fast: 0.12s ← hover/focus 狀態切換
|
||||
--momo-duration-normal: 0.2s ← sidebar 展收、dropdown
|
||||
--momo-duration-slow: 0.4s ← Modal 出現、頁面過渡
|
||||
--momo-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1)
|
||||
--momo-ease-out: cubic-bezier(0, 0, 0.2, 1)
|
||||
|
||||
基礎過渡(所有互動元件預設):
|
||||
color / background-color / border-color / box-shadow,各 0.12s ease-in-out
|
||||
```
|
||||
|
||||
特殊動畫:
|
||||
```css
|
||||
@keyframes momo-pulse-dot ← sidebar 底部爬蟲狀態 live dot(2s 閃爍)
|
||||
@keyframes momo-fade-in ← 元素出現(opacity + translateY 2px)
|
||||
@keyframes momo-slide-up ← Toast 進場(opacity + translateY 12px)
|
||||
```
|
||||
|
||||
### 4.7 Z-Index 層級
|
||||
```
|
||||
1 → base
|
||||
1000 → dropdown
|
||||
1020 → sticky topbar
|
||||
1030 → fixed elements
|
||||
1040 → modal backdrop
|
||||
1050 → modal
|
||||
1060 → popover
|
||||
1070 → tooltip
|
||||
1080 → toast
|
||||
```
|
||||
|
||||
### 4.8 Layout 尺寸
|
||||
```
|
||||
sidebar width: 240px
|
||||
sidebar collapsed width: 72px(1180px 以下自動切換)
|
||||
topbar height: 64px
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 版面結構(新版 EwoooC Shell)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ .momo-shell (CSS Grid: sidebar | main) │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────────────────────────────┐ │
|
||||
│ │ .momo- │ │ .momo-main-shell │ │
|
||||
│ │ sidebar │ │ │ │
|
||||
│ │ │ │ ┌──────────────────────────────┐│ │
|
||||
│ │ Logo │ │ │ .momo-topbar (sticky, 64px) ││ │
|
||||
│ │ Nav │ │ │ [hamburger] [search] [user] ││ │
|
||||
│ │ Groups │ │ └──────────────────────────────┘│ │
|
||||
│ │ │ │ │ │
|
||||
│ │ Status │ │ ┌──────────────────────────────┐│ │
|
||||
│ │ Card │ │ │ .momo-content (28px 32px pad) ││ │
|
||||
│ │ │ │ │ {% block ewooo_content %} ││ │
|
||||
│ │ │ │ └──────────────────────────────┘│ │
|
||||
│ └──────────┘ └──────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Sidebar 細節
|
||||
- **Logo 區**:`momo-logo-mark`(3×3 點陣格,黑底白點,右上角點空缺)+ `momo-brand-name`(JetBrains Mono, 18px, 800 weight)
|
||||
- **Nav Group 標題**:`.momo-label`(10px 大寫等寬),右側延伸分隔線 `::after`
|
||||
- **Nav Link**:左 icon(16px)+ label + 右側數字代碼(opacity 0.48)
|
||||
- Default:透明底,暖墨字
|
||||
- Hover:`accent-soft` 背景(rgba(201,100,66,0.12))
|
||||
- Active:`accent` 實色背景(#c96442),白字
|
||||
- **Status Card**:sidebar 底部,暖黑底 `#1a1612` + 橘色描邊 + 點陣背景紋,顯示爬蟲狀態
|
||||
|
||||
### Topbar 細節
|
||||
- 左:mobile 漢堡選單(hidden on desktop)
|
||||
- 中左:搜尋框(flex-grow,最大 480px),`⌘K` 快捷鍵 badge
|
||||
- 中右彈性空間
|
||||
- 右側:排程時間 pill(深黑底+橘邊)、圖示按鈕(問號/鈴鐺)、使用者 chip(頭像+姓名+角色)
|
||||
|
||||
---
|
||||
|
||||
## 6. 元件規格
|
||||
|
||||
### 6.1 按鈕
|
||||
|
||||
#### Primary Button(CTA)
|
||||
```
|
||||
背景:--momo-accent (#c96442)
|
||||
文字:--momo-text-inverse (#faf7f0)
|
||||
邊框:none
|
||||
圓角:--momo-radius-md (4px)
|
||||
Hover:--momo-accent-600 (#b1543a)
|
||||
Active:--momo-accent-700 (#8f4530)
|
||||
Padding:9px 20px
|
||||
Font:0.875rem,600 weight
|
||||
```
|
||||
|
||||
#### Secondary / Ghost Button
|
||||
```
|
||||
背景:transparent
|
||||
文字:--momo-text-primary (#2a2520)
|
||||
邊框:1px solid --momo-border-light
|
||||
圓角:4px
|
||||
Hover:bg --momo-bg-subtle
|
||||
```
|
||||
|
||||
#### Danger Button
|
||||
```
|
||||
背景:--momo-danger (#b5342f)
|
||||
文字:white
|
||||
```
|
||||
|
||||
#### Icon Button(.momo-icon-button)
|
||||
```
|
||||
尺寸:36×36px
|
||||
背景:transparent
|
||||
圓角:4px
|
||||
Hover:bg --momo-bg-subtle
|
||||
Color:--momo-text-secondary → primary on hover
|
||||
```
|
||||
|
||||
### 6.2 卡片(Card)
|
||||
```
|
||||
背景:--momo-bg-surface (#faf7f0)
|
||||
邊框:1px solid --momo-border-light
|
||||
圓角:--momo-radius-lg (6px)
|
||||
陰影:--momo-shadow-sm
|
||||
Padding:24px (1.5rem)
|
||||
Hover(可選):--momo-shadow-md
|
||||
```
|
||||
|
||||
特殊:深色 Status Card(sidebar 底部)
|
||||
```
|
||||
背景:--momo-ink-strong (#1a1612)
|
||||
邊框:1px solid rgba(201,100,66,0.35)
|
||||
點陣背景紋路:radial-gradient dots,6px×6px
|
||||
文字:rgba(250,247,240,0.55) 標題 / rgba(250,247,240,0.62) 內文
|
||||
```
|
||||
|
||||
### 6.3 表格
|
||||
```
|
||||
Border:none(預設),行底部 border-bottom: 1px solid --momo-divider
|
||||
Header:--momo-bg-paper 底色,--momo-label 樣式欄位標題
|
||||
Hover Row:--momo-accent-soft 背景
|
||||
Cell Padding:12px 16px
|
||||
Data 數字:.momo-mono 字體
|
||||
狀態 Badge:pill 形(border-radius: 2px),對應狀態色
|
||||
```
|
||||
|
||||
### 6.4 徽章(Badge)
|
||||
```
|
||||
圓角:2px(方角)
|
||||
Font:10px,800 weight,全大寫
|
||||
Color:--momo-text-inverse (#faf7f0)
|
||||
背景:--momo-accent(主要),或對應狀態色
|
||||
Padding:1px 6px
|
||||
```
|
||||
|
||||
### 6.5 搜尋框(.momo-search-box)
|
||||
```
|
||||
高度:38px
|
||||
背景:--momo-bg-paper
|
||||
邊框:1px solid --momo-border
|
||||
圓角:4px
|
||||
Placeholder:--momo-text-secondary
|
||||
Focus border:--momo-accent
|
||||
左側 icon:fa-search,margin-right 10px
|
||||
右側:⌘K shortcut badge(accent 背景,2px 圓角)
|
||||
```
|
||||
|
||||
### 6.6 Toast 通知
|
||||
```
|
||||
位置:右上角固定(top: 24px, right: 24px),z-index: 1080
|
||||
寬度:max 360px
|
||||
圓角:6px
|
||||
自動消失:3秒
|
||||
進場動畫:slide-up(opacity + translateY 12px → 0)
|
||||
類型色:成功綠 / 危險紅 / 警告黃 / 資訊藍
|
||||
```
|
||||
|
||||
### 6.7 Modal
|
||||
```
|
||||
背景:--momo-bg-surface
|
||||
圓角:--momo-radius-lg (6px)
|
||||
Header:--momo-ink 背景,白字(舊版用 #667eea 漸層,新版請改用暖墨色)
|
||||
Backdrop:rgba(26,26,26,0.70)
|
||||
最大寬度:sm 400px / md 640px / lg 960px / xl 1140px
|
||||
```
|
||||
|
||||
### 6.8 用戶頭像 Chip(.momo-user-chip)
|
||||
```
|
||||
高度:40px
|
||||
Padding:4px 10px 4px 4px
|
||||
圓角:pill(50rem)
|
||||
Avatar:32×32px 圓形,背景 --momo-ink,白字,13px 800weight
|
||||
Hover:bg --momo-bg-subtle
|
||||
```
|
||||
|
||||
### 6.9 點陣裝飾(.momo-dot-bg)
|
||||
```css
|
||||
background-image: radial-gradient(circle, rgba(26,26,26,0.12) 1px, transparent 1px);
|
||||
background-size: 8px 8px;
|
||||
```
|
||||
用於深色背景卡片(Status Card)或特殊區塊裝飾。
|
||||
|
||||
---
|
||||
|
||||
## 7. 導航結構
|
||||
|
||||
```
|
||||
Sidebar Nav
|
||||
├── 【監控】
|
||||
│ ├── 01 商品看板 / → fa-border-all
|
||||
│ ├── 02 活動看板 /edm → fa-bullhorn
|
||||
│ └── 03 分析報表 /sales_analysis → fa-chart-line
|
||||
├── 【營運】
|
||||
│ ├── 04 廠商缺貨 /vendor-stockout → fa-box-open
|
||||
│ ├── 05 AI 助手 /ai_recommend → fa-wand-magic-sparkles
|
||||
│ └── 06 雲端匯入 /auto_import → fa-download
|
||||
└── 【系統】
|
||||
└── 07 系統管理 /settings → fa-gear
|
||||
```
|
||||
|
||||
舊版 Navbar(base.html 頁面)另有獨立下拉選單結構(業績分析/AI助手/系統管理各含多個子項目)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 主要頁面清單
|
||||
|
||||
> **完整逐頁深度盤點請見 [`claude_design_brief_pages.md`](claude_design_brief_pages.md)(Appendix A)**
|
||||
> 該附錄涵蓋 41 個 template + 3 個 component,每頁包含路徑/路由/Layout/區塊/元件/互動/配色/設計問題/改造重點 9 個欄位。
|
||||
|
||||
### 8.1 設計風格分布總覽(深掃後校正)
|
||||
|
||||
實際發現 **4 種視覺風格混雜**,不只 2 種:
|
||||
|
||||
| 風格 | 背景 | Accent | 頁面數 | 處置 |
|
||||
|------|------|--------|--------|------|
|
||||
| **A. 深藍 + 藍紫漸層**(舊主流)| `#f5f7fa` | `#1e3c72→#2a5298` + `#667eea→#764ba2` | 23+ | **全部遷移至 B** |
|
||||
| **B. 焦糖橘暖系**(目標) | `#ebe6dc` | `#c96442` | 6+ | 維持並擴張 |
|
||||
| **C. 紫色獨立入口頁** | `#667eea→#764ba2` | `#4F46E5` | 4 (login/403/maintenance/_loading) | 改用焦糖橘色相 |
|
||||
| **D. GitHub Dark Terminal** | `#0d1117` | `#3fb950/#58a6ff/#bc8cff` | 2 (ai_automation_smoke/code_review) | 保留深色但與焦糖橘對齊 |
|
||||
|
||||
### 8.2 風格 B(目標設計)參考頁面
|
||||
|
||||
Claude Design 可直接參考這幾頁的 Pattern 作為其他頁面遷移的樣板:
|
||||
|
||||
| 頁面 | 路徑 | 為何是樣板 |
|
||||
|------|------|-----------|
|
||||
| `vendor_stockout_index_v2.html` | `/vendor-stockout` | 風格 B 最完整實作:Hero+Pulse Box+KPI Grid+Flow Cards+Summary |
|
||||
| `edm_dashboard_v2.html` | `/edm/dashboard_v2` | 含 Chart.js 整合的範例(Modal 內動態載圖)|
|
||||
| `vendor_stockout_import_v2.html` | `/vendor-stockout/import` | Dropzone + 多狀態面板(File/Progress/Result/Error)|
|
||||
| `_ewoooc_shell.html` | (component) | Sidebar 導航結構與 Status Card |
|
||||
|
||||
### 8.3 重點優化頁面 Top 10
|
||||
|
||||
依照「使用頻率 × 設計債務 × 影響範圍」排序:
|
||||
|
||||
| 排名 | 頁面 | 行數 | 主要債務 |
|
||||
|------|------|------|---------|
|
||||
| 1 | `dashboard.html` | 1405 | 流量最大首頁,舊藍紫,無空狀態 |
|
||||
| 2 | `sales_analysis.html` | 3165 | 系統最複雜頁,三種圖表庫並存 |
|
||||
| 3 | `web/templates/vendor_stockout/list.html` | 1793 | 1600+ 行 inline JS 無模組化 |
|
||||
| 4 | `daily_sales.html` | 1905 | 自訂月曆 878 行 CSS,行動版爆表 |
|
||||
| 5 | `settings.html` | 1650 | 巨型設定頁,padding 重疊 bug |
|
||||
| 6 | `monthly_summary_analysis.html` | 1473 | ECharts 待換 Chart.js |
|
||||
| 7 | `edm_dashboard_v2.html` | 1130 | 已是 B 風格但 CSS 600+ 行待精簡 |
|
||||
| 8 | `ai_recommend.html` | 1000+ | AI 互動 UX 流式輸出未實作 |
|
||||
| 9 | `user_management.html` | 906 | 權限矩陣未視覺化 |
|
||||
| 10 | `logs.html` | 872 | 篩選器邏輯複雜,最佳重構樣本 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 色彩衝突說明(設計遷移中)
|
||||
|
||||
目前有**三種 Accent 色系混用**,這是技術債,新設計一律使用 **焦糖橘**:
|
||||
|
||||
| 色系 | Hex | 出現位置 | 備注 |
|
||||
|------|-----|---------|------|
|
||||
| 焦糖橘 ✓ | `#c96442` | ewoooc 新版頁面 | **目標設計色** |
|
||||
| 藍紫 ✗ | `#667eea → #764ba2` | dashboard.html、表格 header、按鈕 | 舊版,待替換 |
|
||||
| 深藍 ✗ | `#1e3c72 → #2a5298` | 舊版 Navbar、base.html | 舊版,待替換 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 響應式斷點
|
||||
|
||||
| 斷點 | 寬度 | 行為 |
|
||||
|------|------|------|
|
||||
| Desktop | ≥1180px | Sidebar 240px 全展開,顯示文字 |
|
||||
| Collapsed | 1180px–820px | Sidebar 收至 72px,只顯示圖標 |
|
||||
| Mobile | ≤820px | Sidebar 隱藏(slide-in 抽屜式),hamburger 按鈕出現 |
|
||||
|
||||
Topbar 漸進隱藏(Container Query):
|
||||
- ≤1024px:隱藏排程 pill
|
||||
- ≤880px:隱藏使用者名稱/角色
|
||||
- ≤720px:隱藏搜尋框文字
|
||||
|
||||
---
|
||||
|
||||
## 11. 圖表設計規範
|
||||
|
||||
### Chart.js(折線圖、柱狀圖、圓餅圖)
|
||||
目前使用舊藍紫色系,應遷移至:
|
||||
```
|
||||
主線色:#c96442(焦糖橘)
|
||||
輔助線:#b5342f(暗紅,下跌)、#2a7a3f(深綠,上漲)
|
||||
Fill 漸層:從 rgba(201,100,66,0.3) → rgba(201,100,66,0.05)
|
||||
格線:rgba(42,37,32,0.06)(極淡)
|
||||
Tooltip:背景 rgba(26,26,26,0.88),白字,焦糖橘描邊
|
||||
```
|
||||
|
||||
### ECharts(複雜多維圖表)
|
||||
應使用同色系調色板,確保視覺一致。
|
||||
|
||||
---
|
||||
|
||||
## 12. 互動模式
|
||||
|
||||
| 模式 | 實現方式 |
|
||||
|------|---------|
|
||||
| 表格行點擊展開詳情 | onclick → Bootstrap Modal |
|
||||
| KPI Card 點擊鑽取 | onclick → Modal + fetch API |
|
||||
| 搜尋篩選 | form GET 提交(非 SPA) |
|
||||
| 操作回饋 | Toast 通知(右上角滑入) |
|
||||
| 複製品號 | Clipboard API + 視覺回饋(文字變 ✅ 已複製)|
|
||||
| 載入狀態 | Bootstrap spinner-border |
|
||||
| 全頁載入 | 自訂 overlay 動畫(WOOO 品牌)|
|
||||
|
||||
---
|
||||
|
||||
## 13. 品牌資產
|
||||
|
||||
### Logo 變體
|
||||
```
|
||||
logo.png → 標準 logo
|
||||
logo_transparent.png → 透明底
|
||||
logo_v4_gradient.png → 漸層版
|
||||
logo_v4_glass.png → 玻璃質感
|
||||
logo_navbar.svg → 導航列向量版
|
||||
logo_circle.svg → 圓形版
|
||||
```
|
||||
|
||||
### 品牌名稱
|
||||
- 老品牌:**WOOO** / **WOOO TECH**
|
||||
- 新品牌:**EwoooC**(等寬字體呈現,副標:「價格監控 V2」)
|
||||
|
||||
### Logo Mark(點陣格設計)
|
||||
```
|
||||
3×3 格,32×32px,gap 1.5px,padding 5px
|
||||
背景:--momo-ink (#1a1612),圓角 2px
|
||||
點:圓形白點
|
||||
中心格(第5格):空白
|
||||
→ 點陣 ⠿ 風格,Nothing Phone 美學
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. 當前設計亮點(可保留延伸)
|
||||
|
||||
1. **JetBrains Mono 標題** — 機械儀表板感,數字排版優秀
|
||||
2. **點陣背景裝飾** — Status Card 深色背景上的橘色點陣,獨特品牌感
|
||||
3. **Live Dot 動畫** — 橘色脈衝點,搭配發光陰影 `box-shadow: 0 0 8px #c96442`
|
||||
4. **Nav Code 數字** — 每個導航項目右側的灰色數字代碼(01~07),Like a terminal
|
||||
5. **焦糖橘 × 暖墨 × 米白** — 三色搭配溫暖而不俗氣
|
||||
6. **⌘K 搜尋框** — 開發者友善,鍵盤優先設計
|
||||
|
||||
---
|
||||
|
||||
## 15. 當前設計弱點(優化方向)
|
||||
|
||||
1. **兩套設計系統未統一** — 舊版藍紫 vs 新版焦糖橘,視覺割裂感強
|
||||
2. **Dashboard 主頁** — 流量最大的頁面仍用舊版,設計待升級
|
||||
3. **大圓角** — 舊版 16~20px border-radius,與新版方角設計衝突
|
||||
4. **無 Dark Mode** — Design Token 已備妥,只缺 JS 實現
|
||||
5. **Modal Header** — 舊版用藍紫漸層,應統一為暖墨色
|
||||
6. **缺共用元件庫** — 卡片/表格/表單 Pattern 散落各頁面,未抽象化
|
||||
7. **無 Focus Style** — Accessibility 待加強(focus-visible 輪廓)
|
||||
8. **空狀態 (Empty State)** — 多數頁面僅有文字,缺圖示/插圖
|
||||
|
||||
---
|
||||
|
||||
## 16. 技術限制(設計時須知)
|
||||
|
||||
- **無 CSS Build Tool**:無法使用 SCSS/PostCSS,所有 CSS 必須是原生 CSS 或內嵌 `<style>`
|
||||
- **無前端框架**:無 React/Vue,互動依賴 Bootstrap 5 JS + Vanilla JS
|
||||
- **CDN 引用**:Bootstrap、Font Awesome、Chart.js 等均從 CDN 載入
|
||||
- **Jinja2 模板**:設計元素需考慮 Flask 模板語法(`{{ variable }}`、`{% if %}`)
|
||||
- **Bootstrap 5 共存**:新設計的元件需能與 Bootstrap 5 Class 共存,不衝突
|
||||
|
||||
---
|
||||
|
||||
*此文件由 Claude Code 自動掃描 45+ 個 template 檔案及 CSS token 系統生成,2026-05-01。*
|
||||
1002
docs/guides/claude_design_brief_pages.md
Normal file
1002
docs/guides/claude_design_brief_pages.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,39 +1,51 @@
|
||||
# 程式碼模組化盤點(2026-04-30)
|
||||
# 程式碼模組化盤點(2026-04-30,2026-05-06 校正)
|
||||
|
||||
> 用途:接續 ADR-017 Phase 3f 時,快速知道哪些 Python 檔案仍是大檔技術債,以及新增功能應該放在哪個模組層。
|
||||
|
||||
## 盤點結論
|
||||
|
||||
- Python 總量:約 66,997 行。
|
||||
- 最大壓力區:`routes/` 約 21,095 行、`services/` 約 26,023 行。
|
||||
- `app.py` 目前約 1,209 行,功能定位應固定為 bootstrap / Blueprint registration / startup guard,不再承接新 route。
|
||||
- 目前工作樹仍有 18 個 Python 檔案超過 800 行;這些不是禁止修 bug,而是禁止繼續塞新功能。
|
||||
- Python 總量:約 97,677 行(排除 `venv/`、`backups/`、`__pycache__/`、`.claude/worktrees/`)。
|
||||
- 最大壓力區:`services/` 約 39,444 行、`routes/` 約 28,362 行。
|
||||
- `app.py` 目前約 1,227 行,功能定位應固定為 bootstrap / Blueprint registration / startup guard,不再承接新 route。
|
||||
- 目前工作樹仍有 23 個 Python 檔案達到或超過 800 行;這些不是禁止修 bug,而是禁止繼續塞新功能。
|
||||
- 2026-05-05 追記:Phase 38→56 觀測台戰役讓 `routes/admin_observability_routes.py` 與 `run_scheduler.py` 進入大檔治理清單;後續觀測台功能應先抽 query/action service,不再把新 SQL 與 L2 mutation 直接塞回 route。
|
||||
- 2026-05-06 追記:跨平台市場情報模組啟動前,必須先把新增爬蟲、排程、DB schema、UI route 全部隔離在 `market_*` / `services/market_intel/` / `routes/market_intel_routes.py`,不可塞回既有大檔。
|
||||
|
||||
## 超過 800 行檔案清單
|
||||
## 達到或超過 800 行檔案清單
|
||||
|
||||
| 行數 | 檔案 | 分類 | 拆分方向 |
|
||||
|---:|---|---|---|
|
||||
| 5240 | `routes/openclaw_bot_routes.py` | P0 巨型 Blueprint | route / bot command service / report service / scheduler hook |
|
||||
| 2707 | `scheduler.py` | P0 排程總管 | task registry / crawler jobs / report jobs / notification jobs |
|
||||
| 2653 | `routes/sales_routes.py` | P0 巨型 Blueprint | page routes / API routes / chart query service / calendar service |
|
||||
| 2550 | `routes/admin_observability_routes.py` | P0 觀測台巨型 Blueprint | `services/observability_query_service.py` / `services/observability_action_service.py` / route glue |
|
||||
| 1743 | `routes/ai_routes.py` | P1 AI Blueprint | route glue / AI orchestration service / prompt builders |
|
||||
| 9129 | `routes/openclaw_bot_routes.py` | P0 巨型 Blueprint | route / bot command service / report service / scheduler hook;禁止再新增市場情報入口 |
|
||||
| 5499 | `services/ppt_generator.py` | P0 報表生成巨型 service | deck orchestration / slide builders / chart builders / report type registry |
|
||||
| 2822 | `scheduler.py` | P0 排程總管 | task registry / crawler jobs / report jobs / notification jobs;市場情報只能透過獨立 job module 掛入 |
|
||||
| 2730 | `services/openclaw_strategist_service.py` | P0 OpenClaw service | prompt builders / report composer / strategy rules |
|
||||
| 2653 | `routes/sales_routes.py` | P0 巨型 Blueprint | page routes / API routes / chart query service / calendar service;分析頁新增功能先抽 `services/sales/` |
|
||||
| 2639 | `routes/admin_observability_routes.py` | P0 觀測台巨型 Blueprint | `services/observability_query_service.py` / `services/observability_action_service.py` / route glue |
|
||||
| 1754 | `routes/ai_routes.py` | P1 AI Blueprint | route glue / AI orchestration service / prompt builders |
|
||||
| 1721 | `services/nemoton_dispatcher_service.py` | P1 NemoTron service | NIM client / tool-call parser / action dispatcher |
|
||||
| 1485 | `routes/vendor_routes.py` | P1 Vendor Blueprint | route glue / stockout mutation/email;V2 page query、stockout list/batches API query、vendor list/detail query 已抽到 `services/vendor_stockout_query_service.py` |
|
||||
| 1345 | `services/ppt_generator.py` | P1 報表生成 service | deck orchestration / slide builders / chart builders |
|
||||
| 1339 | `services/nemoton_dispatcher_service.py` | P1 NemoTron service | NIM client / tool-call parser / action dispatcher |
|
||||
| 1300 | `services/openclaw_strategist_service.py` | P1 OpenClaw service | prompt builders / report composer / strategy rules |
|
||||
| 1209 | `app.py` | P1 bootstrap | 保持只做 app setup;繼續往 app_factory / extension setup 抽 |
|
||||
| 1079 | `routes/cicd_routes.py` | P2 CI/CD Blueprint | route glue / CI query service / deployment action service |
|
||||
| 1024 | `routes/dashboard_routes.py` | P2 Dashboard Blueprint | competitor decision overview / dashboard query service;目前工作樹已有未提交大段新增邏輯 |
|
||||
| 986 | `services/telegram_bot_service.py` | P2 Telegram service | command handlers / message formatters / bot client |
|
||||
| 1459 | `routes/dashboard_routes.py` | P1 Dashboard Blueprint | competitor decision overview / dashboard query service;首頁資料整併需抽 service |
|
||||
| 1390 | `services/telegram_bot_service.py` | P1 Telegram service | command handlers / message formatters / bot client |
|
||||
| 1227 | `app.py` | P1 bootstrap | 保持只做 app setup;繼續往 app_factory / extension setup 抽 |
|
||||
| 1140 | `services/elephant_alpha_autonomous_engine.py` | P1 ElephantAlpha engine | HITL / executor / planning policy |
|
||||
| 1090 | `routes/cicd_routes.py` | P2 CI/CD Blueprint | route glue / CI query service / deployment action service |
|
||||
| 966 | `services/trend_crawler.py` | P2 crawler service | source adapters / parser / persistence |
|
||||
| 946 | `services/elephant_alpha_autonomous_engine.py` | P2 ElephantAlpha engine | HITL / executor / planning policy |
|
||||
| 924 | `services/learning_pipeline.py` | P2 RAG learning pipeline | distiller / promotion gate / persistence / telemetry |
|
||||
| 868 | `run_scheduler.py` | P2 scheduler entrypoint | observability jobs / token report jobs / task registration 分離 |
|
||||
| 829 | `routes/export_routes.py` | P2 Export flow | export command/router glue / file path / download orchestration |
|
||||
| 818 | `services/import_service.py` | P2 import service | validators / import writers / report builders |
|
||||
| 805 | `routes/bot_api_routes.py` | P2 Bot API Blueprint | route glue / bot action service |
|
||||
| 867 | `services/token_report_service.py` | P2 token report service | query / aggregation / chart payload / notification formatting |
|
||||
| 852 | `services/import_service.py` | P2 import service | validators / import writers / report builders |
|
||||
| 832 | `routes/export_routes.py` | P2 Export flow | export command/router glue / file path / download orchestration |
|
||||
| 813 | `services/ollama_service.py` | P2 Ollama client | host health / request client / fallback policy / response parsing |
|
||||
| 805 | `services/competitor_price_feeder.py` | P2 competitor price feeder | crawler scheduling / price normalization / cache strategy |
|
||||
| 805 | `routes/bot_api_routes.py` | P2 Bot API Blueprint | route glue / bot action service |
|
||||
|
||||
## 市場情報開發前置禁區
|
||||
|
||||
- 不得把跨平台活動頁、商品池、比對審核 API 新增到 `routes/sales_routes.py`、`routes/dashboard_routes.py` 或 `app.py`。
|
||||
- 不得把 MOMO / PChome / Coupang / Shopee adapter 寫進 `scheduler.py` 或既有 crawler 巨檔;必須放在 `services/market_intel/adapters/`。
|
||||
- 不得把市場情報商品塞回 `promo_products` 作為唯一真相;新資料以 `market_*` schema 為主,舊表只可做相容讀取或明確雙寫過渡。
|
||||
- 不得在第一階段啟用正式排程或大量入庫;必須先 feature flag 關閉、dry-run、run log、rate limit 與 rollback path。
|
||||
- 新市場情報 UI 必須先使用共用 V2 token / 分析頁元件規範,避免複製 `templates/sales_analysis.html` 的巨型 template 模式。
|
||||
|
||||
## 工作項目
|
||||
|
||||
@@ -42,8 +54,9 @@
|
||||
3. P0:拆 `scheduler.py`,建立 `jobs/` 或 `services/scheduler/` task registry。
|
||||
4. P0:拆 `routes/admin_observability_routes.py`,先搬純查詢函式到 `services/observability_query_service.py`,再搬 AutoHeal / Code Review / AiderHeal / throttle mutation 到 `services/observability_action_service.py`。
|
||||
5. P1:把 `routes/ai_routes.py` 與 `routes/vendor_routes.py` 的資料處理移出 route;Vendor V2 page query、stockout API list/batches、vendor list/detail 已完成,下一步可抽 email grouping 或 vendor mutation service。
|
||||
6. P1:把 PPT / NemoTron / OpenClaw 大 service 拆成 client、parser、composer、policy。
|
||||
6. P1:把 PPT / NemoTron / OpenClaw / Telegram 大 service 拆成 client、parser、composer、policy。
|
||||
7. P2:對 800-1100 行檔案採「碰到就順手抽」策略,但不可讓淨行數繼續增加。
|
||||
8. 市場情報:先建獨立 `services/market_intel/`、`routes/market_intel_routes.py`、`database/market_intel_models.py`,再評估是否接進 scheduler registry。
|
||||
|
||||
## 守門
|
||||
|
||||
|
||||
@@ -46,6 +46,13 @@
|
||||
- `vendor_emails`
|
||||
- `email_send_log`
|
||||
- `realtime_sales_monthly`
|
||||
- `market_platforms`
|
||||
- `market_campaigns`
|
||||
- `market_campaign_snapshots`
|
||||
- `market_campaign_products`
|
||||
- `market_product_price_history`
|
||||
- `market_product_matches`
|
||||
- `market_crawler_runs`
|
||||
|
||||
### 2. SQL migration / raw SQL 仍在用,但未見完整 ORM source of truth
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
| `edm_routes.py` | EDM 與節慶儀表板 | `/edm`, `/festival` |
|
||||
| `monthly_routes.py` | 月結分析 | `/monthly_summary_analysis`, `/api/monthly_summary_data` |
|
||||
| `daily_sales_routes.py` | 當日業績 | `/daily_sales`, `/daily_sales/export*` |
|
||||
| `market_intel_routes.py` | 市場情報 Phase 2 schema-ready 安全骨架 | `/market_intel`, `/market_intel/*`, `/api/market_intel/status`, `/api/market_intel/schema`, `/api/market_intel/dry_run_plan` |
|
||||
| `api_routes.py` | 通用任務與查詢 API | `/api/run_task`, `/api/history/*` |
|
||||
| `export_routes.py` | 匯出功能 | `/api/export/*` |
|
||||
| `import_routes.py` | 匯入功能 | `/api/import_excel`, `/api/import/monthly_summary` |
|
||||
|
||||
69
routes/market_intel_routes.py
Normal file
69
routes/market_intel_routes.py
Normal file
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""市場情報頁面與 API 路由。"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from flask import Blueprint, jsonify, render_template, request
|
||||
|
||||
from auth import login_required
|
||||
from config import SYSTEM_VERSION
|
||||
from services.market_intel import MarketIntelService
|
||||
|
||||
|
||||
TAIPEI_TZ = timezone(timedelta(hours=8))
|
||||
market_intel_bp = Blueprint("market_intel", __name__)
|
||||
|
||||
|
||||
def _service():
|
||||
return MarketIntelService()
|
||||
|
||||
|
||||
@market_intel_bp.route("/market_intel")
|
||||
@market_intel_bp.route("/market_intel/campaigns")
|
||||
@login_required
|
||||
def campaigns():
|
||||
status = _service().get_runtime_status()
|
||||
return render_template(
|
||||
"market_intel/disabled.html",
|
||||
active_page="market_intel",
|
||||
datetime_now=datetime.now(TAIPEI_TZ).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
system_version=SYSTEM_VERSION,
|
||||
status=status,
|
||||
current_section="campaigns",
|
||||
)
|
||||
|
||||
|
||||
@market_intel_bp.route("/market_intel/products")
|
||||
@market_intel_bp.route("/market_intel/matches")
|
||||
@market_intel_bp.route("/market_intel/opportunities")
|
||||
@login_required
|
||||
def disabled_section():
|
||||
status = _service().get_runtime_status()
|
||||
return render_template(
|
||||
"market_intel/disabled.html",
|
||||
active_page="market_intel",
|
||||
datetime_now=datetime.now(TAIPEI_TZ).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
system_version=SYSTEM_VERSION,
|
||||
status=status,
|
||||
current_section="disabled",
|
||||
)
|
||||
|
||||
|
||||
@market_intel_bp.route("/api/market_intel/status")
|
||||
@login_required
|
||||
def market_intel_status():
|
||||
return jsonify(_service().get_runtime_status().to_dict())
|
||||
|
||||
|
||||
@market_intel_bp.route("/api/market_intel/schema")
|
||||
@login_required
|
||||
def market_intel_schema():
|
||||
return jsonify({"tables": _service().get_schema_tables()})
|
||||
|
||||
|
||||
@market_intel_bp.route("/api/market_intel/dry_run_plan")
|
||||
@login_required
|
||||
def market_intel_dry_run_plan():
|
||||
platform_code = request.args.get("platform", "all")
|
||||
return jsonify(_service().build_dry_run_plan(platform_code=platform_code))
|
||||
@@ -218,6 +218,7 @@ def sales_analysis():
|
||||
start_date='',
|
||||
end_date='',
|
||||
total_records=0,
|
||||
active_page='sales',
|
||||
db_data_range='')
|
||||
|
||||
# V-New: 查詢資料庫的資料期間範圍
|
||||
@@ -349,6 +350,7 @@ def sales_analysis():
|
||||
keyword='', min_price='', max_price='', min_margin='', max_margin='',
|
||||
data_range_months=0, start_date='', end_date='',
|
||||
db_data_range=db_data_range,
|
||||
active_page='sales',
|
||||
marketing_data=None)
|
||||
|
||||
# 解析 data_range_months(有篩選時才處理)
|
||||
@@ -405,6 +407,7 @@ def sales_analysis():
|
||||
end_date=end_date,
|
||||
total_records=0,
|
||||
db_data_range=db_data_range,
|
||||
active_page='sales',
|
||||
marketing_data=None)
|
||||
|
||||
# 自動識別日期欄位(V-Fix: 優先匹配「日期」,因為「訂單日期」可能是固定文字)
|
||||
@@ -528,6 +531,7 @@ def sales_analysis():
|
||||
end_date=end_date,
|
||||
total_records=0,
|
||||
db_data_range=db_data_range,
|
||||
active_page='sales',
|
||||
marketing_data=None)
|
||||
|
||||
# 3. 自動識別關鍵欄位 (模糊比對)
|
||||
@@ -569,6 +573,7 @@ def sales_analysis():
|
||||
end_date=end_date,
|
||||
total_records=0,
|
||||
db_data_range=db_data_range,
|
||||
active_page='sales',
|
||||
marketing_data=None)
|
||||
|
||||
# 4. 資料處理 (Heavy Lifting - 只在快取建立時執行一次)
|
||||
@@ -1220,6 +1225,7 @@ def sales_analysis():
|
||||
start_date=start_date, # V-New: 傳遞自訂開始日期
|
||||
end_date=end_date, # V-New: 傳遞自訂結束日期
|
||||
total_records=len(df),
|
||||
active_page='sales',
|
||||
db_data_range=db_data_range) # V-New: 傳遞資料庫資料期間
|
||||
|
||||
except Exception as e:
|
||||
@@ -1260,6 +1266,7 @@ def sales_analysis():
|
||||
start_date=request.args.get('start_date', ''),
|
||||
end_date=request.args.get('end_date', ''),
|
||||
total_records=0,
|
||||
active_page='sales',
|
||||
db_data_range='')
|
||||
|
||||
|
||||
@@ -1287,6 +1294,7 @@ def growth_analysis():
|
||||
kpi=cache['kpi'],
|
||||
datetime_now=now_taipei.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
cache_hit=True,
|
||||
active_page='growth',
|
||||
cache_age=cache_age)
|
||||
|
||||
# 快取失效,重新計算
|
||||
@@ -1373,6 +1381,7 @@ def growth_analysis():
|
||||
chart_data=chart_data,
|
||||
kpi=kpi,
|
||||
datetime_now=now_taipei.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
active_page='growth',
|
||||
cache_hit=False)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
110
scheduler.py
110
scheduler.py
@@ -1801,37 +1801,72 @@ def verify_import_data_sync(expected_rows: int = None, date_range: dict = None)
|
||||
from config import DATABASE_PATH
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
date_min = date_range.get('min') if date_range else None
|
||||
date_max = date_range.get('max') if date_range else None
|
||||
|
||||
result = {
|
||||
'success': True,
|
||||
'daily_sales_snapshot': {'ok': False, 'rows': 0, 'date_min': None, 'date_max': None},
|
||||
'realtime_sales_monthly': {'ok': False, 'rows': 0, 'date_min': None, 'date_max': None},
|
||||
'scope': {'date_min': date_min, 'date_max': date_max},
|
||||
'errors': []
|
||||
}
|
||||
|
||||
if expected_rows is not None and expected_rows <= 0:
|
||||
result['success'] = False
|
||||
result['errors'].append("本次匯入筆數為 0,請檢查檔案是否實際匯入成功")
|
||||
return result
|
||||
|
||||
if not date_min or not date_max:
|
||||
result['success'] = False
|
||||
result['errors'].append("缺少本次匯入日期範圍,無法驗證資料同步")
|
||||
return result
|
||||
|
||||
try:
|
||||
engine = create_engine(DATABASE_PATH)
|
||||
|
||||
with engine.connect() as conn:
|
||||
# 檢查 daily_sales_snapshot
|
||||
snapshot_query = text("""
|
||||
SELECT COUNT(*) as cnt,
|
||||
MIN(日期::date)::text as date_min,
|
||||
MAX(日期::date)::text as date_max
|
||||
FROM daily_sales_snapshot
|
||||
""")
|
||||
snapshot_result = conn.execute(snapshot_query).fetchone()
|
||||
is_sqlite = engine.dialect.name == 'sqlite'
|
||||
if is_sqlite:
|
||||
snapshot_query = text("""
|
||||
SELECT COUNT(*) as cnt,
|
||||
MIN(date(snapshot_date)) as date_min,
|
||||
MAX(date(snapshot_date)) as date_max
|
||||
FROM daily_sales_snapshot
|
||||
WHERE date(snapshot_date) BETWEEN date(:date_min) AND date(:date_max)
|
||||
""")
|
||||
monthly_query = text("""
|
||||
SELECT COUNT(*) as cnt,
|
||||
MIN(date("日期")) as date_min,
|
||||
MAX(date("日期")) as date_max
|
||||
FROM realtime_sales_monthly
|
||||
WHERE date("日期") BETWEEN date(:date_min) AND date(:date_max)
|
||||
""")
|
||||
else:
|
||||
snapshot_query = text("""
|
||||
SELECT COUNT(*) as cnt,
|
||||
MIN(snapshot_date::date)::text as date_min,
|
||||
MAX(snapshot_date::date)::text as date_max
|
||||
FROM daily_sales_snapshot
|
||||
WHERE snapshot_date::date BETWEEN :date_min AND :date_max
|
||||
""")
|
||||
monthly_query = text("""
|
||||
SELECT COUNT(*) as cnt,
|
||||
MIN("日期"::date)::text as date_min,
|
||||
MAX("日期"::date)::text as date_max
|
||||
FROM realtime_sales_monthly
|
||||
WHERE "日期"::date BETWEEN :date_min AND :date_max
|
||||
""")
|
||||
|
||||
# V-Fix: 只驗證本次匯入日期範圍。realtime_sales_monthly 保存歷史資料,
|
||||
# 不可再拿全表總筆數和 daily_sales_snapshot 做相等比較。
|
||||
params = {'date_min': date_min, 'date_max': date_max}
|
||||
snapshot_result = conn.execute(snapshot_query, params).fetchone()
|
||||
result['daily_sales_snapshot']['rows'] = snapshot_result[0]
|
||||
result['daily_sales_snapshot']['date_min'] = snapshot_result[1]
|
||||
result['daily_sales_snapshot']['date_max'] = snapshot_result[2]
|
||||
|
||||
# 檢查 realtime_sales_monthly
|
||||
monthly_query = text("""
|
||||
SELECT COUNT(*) as cnt,
|
||||
MIN(日期::date)::text as date_min,
|
||||
MAX(日期::date)::text as date_max
|
||||
FROM realtime_sales_monthly
|
||||
""")
|
||||
monthly_result = conn.execute(monthly_query).fetchone()
|
||||
monthly_result = conn.execute(monthly_query, params).fetchone()
|
||||
result['realtime_sales_monthly']['rows'] = monthly_result[0]
|
||||
result['realtime_sales_monthly']['date_min'] = monthly_result[1]
|
||||
result['realtime_sales_monthly']['date_max'] = monthly_result[2]
|
||||
@@ -1847,11 +1882,16 @@ def verify_import_data_sync(expected_rows: int = None, date_range: dict = None)
|
||||
)
|
||||
result['success'] = False
|
||||
|
||||
# 檢查 2: 日期範圍是否一致
|
||||
if result['daily_sales_snapshot']['date_max'] != result['realtime_sales_monthly']['date_max']:
|
||||
# 檢查 2: 本次匯入日期範圍是否一致
|
||||
if (
|
||||
result['daily_sales_snapshot']['date_min'] != result['realtime_sales_monthly']['date_min']
|
||||
or result['daily_sales_snapshot']['date_max'] != result['realtime_sales_monthly']['date_max']
|
||||
):
|
||||
result['errors'].append(
|
||||
f"最新日期不一致: daily_sales_snapshot={result['daily_sales_snapshot']['date_max']}, "
|
||||
f"realtime_sales_monthly={result['realtime_sales_monthly']['date_max']}"
|
||||
f"日期範圍不一致: daily_sales_snapshot="
|
||||
f"{result['daily_sales_snapshot']['date_min']}~{result['daily_sales_snapshot']['date_max']}, "
|
||||
f"realtime_sales_monthly="
|
||||
f"{result['realtime_sales_monthly']['date_min']}~{result['realtime_sales_monthly']['date_max']}"
|
||||
)
|
||||
result['success'] = False
|
||||
|
||||
@@ -1862,22 +1902,32 @@ def verify_import_data_sync(expected_rows: int = None, date_range: dict = None)
|
||||
f"daily_sales_snapshot 筆數({snapshot_rows})少於預期({expected_rows})"
|
||||
)
|
||||
result['success'] = False
|
||||
|
||||
# 檢查 4: 如果有預期的日期範圍,驗證是否正確
|
||||
if date_range:
|
||||
expected_max = date_range.get('max')
|
||||
if expected_max and result['daily_sales_snapshot']['date_max'] != expected_max:
|
||||
if monthly_rows < expected_rows:
|
||||
result['errors'].append(
|
||||
f"最新日期({result['daily_sales_snapshot']['date_max']})與預期({expected_max})不符"
|
||||
f"realtime_sales_monthly 筆數({monthly_rows})少於預期({expected_rows})"
|
||||
)
|
||||
result['success'] = False
|
||||
|
||||
# 檢查 4: 如果有預期的日期範圍,驗證是否正確
|
||||
if result['daily_sales_snapshot']['date_min'] != date_min or result['daily_sales_snapshot']['date_max'] != date_max:
|
||||
result['errors'].append(
|
||||
f"daily_sales_snapshot 日期範圍({result['daily_sales_snapshot']['date_min']}~"
|
||||
f"{result['daily_sales_snapshot']['date_max']})與預期({date_min}~{date_max})不符"
|
||||
)
|
||||
result['success'] = False
|
||||
if result['realtime_sales_monthly']['date_min'] != date_min or result['realtime_sales_monthly']['date_max'] != date_max:
|
||||
result['errors'].append(
|
||||
f"realtime_sales_monthly 日期範圍({result['realtime_sales_monthly']['date_min']}~"
|
||||
f"{result['realtime_sales_monthly']['date_max']})與預期({date_min}~{date_max})不符"
|
||||
)
|
||||
result['success'] = False
|
||||
|
||||
# 設定各表驗證結果
|
||||
result['daily_sales_snapshot']['ok'] = snapshot_rows > 0
|
||||
result['realtime_sales_monthly']['ok'] = monthly_rows > 0 and monthly_rows == snapshot_rows
|
||||
|
||||
logging.info(f"[Scheduler] [Verify] 資料驗證完成: success={result['success']}, "
|
||||
f"snapshot={snapshot_rows}筆, monthly={monthly_rows}筆")
|
||||
f"range={date_min}~{date_max}, snapshot={snapshot_rows}筆, monthly={monthly_rows}筆")
|
||||
|
||||
except Exception as e:
|
||||
result['success'] = False
|
||||
@@ -1945,6 +1995,11 @@ def run_auto_import_task():
|
||||
except Exception:
|
||||
date_range_str = f"{date_range.get('min', '?')}~{date_range.get('max', '?')}"
|
||||
|
||||
freshness_line = ""
|
||||
data_lag_days = result.get('data_lag_days')
|
||||
if data_lag_days is not None and data_lag_days > 0:
|
||||
freshness_line = f"⚠️ 最新資料落後:{data_lag_days} 天\n"
|
||||
|
||||
# 組合通知訊息
|
||||
message = (
|
||||
f"📊 當日業績自動匯入通知 ({now_str})\n"
|
||||
@@ -1953,6 +2008,7 @@ def run_auto_import_task():
|
||||
f"📁 處理檔案數:{result.get('file_count', 0)} 個\n"
|
||||
f"📝 共匯入記錄:{result.get('total_rows', 0)} 筆\n"
|
||||
f"📅 數據日期期間:{date_range_str}\n"
|
||||
f"{freshness_line}"
|
||||
f"{'='*30}\n"
|
||||
f"詳細資料請至系統查看"
|
||||
)
|
||||
|
||||
5
services/market_intel/__init__.py
Normal file
5
services/market_intel/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""跨平台市場情報服務模組。"""
|
||||
|
||||
from services.market_intel.service import MarketIntelService, MarketIntelRuntimeStatus
|
||||
|
||||
__all__ = ["MarketIntelService", "MarketIntelRuntimeStatus"]
|
||||
82
services/market_intel/service.py
Normal file
82
services/market_intel/service.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""市場情報 Phase 1 骨架服務。
|
||||
|
||||
本階段只回報 feature flag 與 rollout 狀態,不啟動爬蟲、不寫資料庫。
|
||||
"""
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from config import (
|
||||
MARKET_INTEL_CRAWLER_ENABLED,
|
||||
MARKET_INTEL_ENABLED,
|
||||
MARKET_INTEL_WRITE_ENABLED,
|
||||
)
|
||||
|
||||
|
||||
TAIPEI_TZ = timezone(timedelta(hours=8))
|
||||
MARKET_INTEL_TABLES = (
|
||||
"market_platforms",
|
||||
"market_campaigns",
|
||||
"market_campaign_snapshots",
|
||||
"market_campaign_products",
|
||||
"market_product_price_history",
|
||||
"market_product_matches",
|
||||
"market_crawler_runs",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MarketIntelRuntimeStatus:
|
||||
"""市場情報模組目前的啟用狀態。"""
|
||||
|
||||
phase: str
|
||||
enabled: bool
|
||||
crawler_enabled: bool
|
||||
write_enabled: bool
|
||||
dry_run_only: bool
|
||||
scheduler_attached: bool
|
||||
database_write_allowed: bool
|
||||
|
||||
def to_dict(self):
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class MarketIntelService:
|
||||
"""市場情報入口服務,先集中 feature gate 與安全狀態。"""
|
||||
|
||||
phase = "phase_2_schema_ready_disabled"
|
||||
|
||||
def get_runtime_status(self) -> MarketIntelRuntimeStatus:
|
||||
return MarketIntelRuntimeStatus(
|
||||
phase=self.phase,
|
||||
enabled=MARKET_INTEL_ENABLED,
|
||||
crawler_enabled=MARKET_INTEL_CRAWLER_ENABLED,
|
||||
write_enabled=MARKET_INTEL_WRITE_ENABLED,
|
||||
dry_run_only=not MARKET_INTEL_WRITE_ENABLED,
|
||||
scheduler_attached=False,
|
||||
database_write_allowed=(
|
||||
MARKET_INTEL_ENABLED
|
||||
and MARKET_INTEL_CRAWLER_ENABLED
|
||||
and MARKET_INTEL_WRITE_ENABLED
|
||||
),
|
||||
)
|
||||
|
||||
def get_schema_tables(self):
|
||||
"""回傳 ADR-035 定義的 market_* schema 名稱。"""
|
||||
return list(MARKET_INTEL_TABLES)
|
||||
|
||||
def build_dry_run_plan(self, platform_code="all"):
|
||||
"""建立 dry-run 計畫,不執行爬蟲、不寫 DB。"""
|
||||
status = self.get_runtime_status()
|
||||
return {
|
||||
"batch_id": f"market-dry-run-{uuid4().hex[:12]}",
|
||||
"platform_code": platform_code,
|
||||
"created_at": datetime.now(TAIPEI_TZ).replace(tzinfo=None).isoformat(),
|
||||
"phase": self.phase,
|
||||
"would_discover_campaigns": bool(status.enabled and status.crawler_enabled),
|
||||
"would_write_database": bool(status.database_write_allowed),
|
||||
"scheduler_attached": status.scheduler_attached,
|
||||
"schema_tables": self.get_schema_tables(),
|
||||
"status": status.to_dict(),
|
||||
}
|
||||
88
templates/components/_analysis_report_tabs.html
Normal file
88
templates/components/_analysis_report_tabs.html
Normal file
@@ -0,0 +1,88 @@
|
||||
{# 分析報表第二層分頁:保留頁面內容與圖表邏輯,只提供一致的報表切換入口。 #}
|
||||
{% 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: rgba(250, 247, 240, 0.84);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.36);
|
||||
}
|
||||
.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-size: 0.86rem;
|
||||
font-weight: 800;
|
||||
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, #65411f);
|
||||
background: linear-gradient(135deg, var(--momo-page-accent, #8a5a2b), var(--momo-page-accent-dark, #65411f));
|
||||
color: var(--momo-page-inverse, #fff8ee);
|
||||
}
|
||||
.analysis-report-tab.is-external {
|
||||
border-color: var(--momo-border-light, rgba(42, 37, 32, 0.16));
|
||||
background: rgba(255, 252, 246, 0.62);
|
||||
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 %}
|
||||
href="/sales_analysis">
|
||||
<i class="fas fa-chart-bar"></i>業績分析
|
||||
</a>
|
||||
<a class="analysis-report-tab {% if _analysis_active == 'daily_sales' %}is-active{% endif %}"
|
||||
{% if _analysis_active == 'daily_sales' %}aria-current="page"{% endif %}
|
||||
href="/daily_sales">
|
||||
<i class="fas fa-calendar-day"></i>當日業績
|
||||
</a>
|
||||
<a class="analysis-report-tab {% if _analysis_active == 'growth' %}is-active{% endif %}"
|
||||
{% if _analysis_active == 'growth' %}aria-current="page"{% endif %}
|
||||
href="/growth_analysis">
|
||||
<i class="fas fa-chart-line"></i>成長分析
|
||||
</a>
|
||||
<a class="analysis-report-tab {% if _analysis_active == 'monthly' %}is-active{% endif %}"
|
||||
{% if _analysis_active == 'monthly' %}aria-current="page"{% endif %}
|
||||
href="/monthly_summary_analysis">
|
||||
<i class="fas fa-table"></i>月份總表
|
||||
</a>
|
||||
{% if metabase_url or grist_url %}
|
||||
<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">
|
||||
<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">
|
||||
<i class="fas fa-table"></i>Grist <i class="fas fa-up-right-from-square"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
@@ -8,6 +8,7 @@
|
||||
#}
|
||||
|
||||
{% set _active_page = active_page|default('') %}
|
||||
{% set _analysis_pages = ['sales', 'daily_sales', 'monthly', 'growth'] %}
|
||||
{% set _obs_pages = [
|
||||
'obs_overview', 'obs_agent_orchestration', 'obs_business_intel',
|
||||
'obs_host_health', 'obs_ai_calls', 'obs_budget',
|
||||
@@ -18,40 +19,64 @@
|
||||
<style>
|
||||
.momo-sidebar {
|
||||
background:
|
||||
radial-gradient(circle at 24px 28px, rgba(201, 100, 66, 0.18), transparent 34px),
|
||||
linear-gradient(180deg, #2d2018 0%, #241912 48%, #3a2418 100%) !important;
|
||||
border-right: 1px solid rgba(231, 178, 135, 0.18);
|
||||
box-shadow: inset -1px 0 0 rgba(255, 238, 214, 0.06), 18px 0 42px rgba(61, 38, 27, 0.18);
|
||||
radial-gradient(circle at 28px 30px, rgba(201, 100, 66, 0.06), transparent 36px),
|
||||
linear-gradient(180deg, #f1ecdf 0%, #e9e3d5 100%) !important;
|
||||
border-right: 1px solid rgba(42, 37, 32, 0.14);
|
||||
box-shadow: inset -1px 0 0 rgba(255, 248, 238, 0.72), 16px 0 32px rgba(61, 38, 27, 0.08);
|
||||
}
|
||||
.momo-sidebar-logo {
|
||||
color: #fff7eb;
|
||||
border-bottom: 1px solid rgba(231, 178, 135, 0.16);
|
||||
color: var(--momo-text-primary, #2a2520);
|
||||
border-bottom: 1px solid rgba(42, 37, 32, 0.12);
|
||||
}
|
||||
.momo-sidebar-logo small,
|
||||
.momo-sidebar-logo .momo-muted {
|
||||
color: rgba(255, 248, 238, 0.62) !important;
|
||||
color: var(--momo-text-secondary, #6f665b) !important;
|
||||
}
|
||||
.momo-brand-name {
|
||||
color: var(--momo-text-primary, #2a2520) !important;
|
||||
font-family: var(--momo-font-family-mono, "SF Mono", Menlo, Consolas, monospace) !important;
|
||||
font-size: 18px;
|
||||
font-weight: 950;
|
||||
line-height: 1;
|
||||
letter-spacing: 0 !important;
|
||||
}
|
||||
@supports (-webkit-background-clip: text) {
|
||||
.momo-brand-name {
|
||||
background-image: radial-gradient(circle, var(--momo-text-primary, #2a2520) 0 1.05px, transparent 1.15px);
|
||||
background-size: 3px 3px;
|
||||
background-position: 0 0;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
}
|
||||
.momo-logo-mark {
|
||||
background: var(--momo-page-accent-dark, var(--momo-ink, #2a2520));
|
||||
color: var(--momo-bg-elevated, #fdfaf3);
|
||||
box-shadow: none;
|
||||
}
|
||||
.momo-nav-group {
|
||||
border-top-color: rgba(231, 178, 135, 0.15) !important;
|
||||
border-top-color: rgba(42, 37, 32, 0.14) !important;
|
||||
}
|
||||
.momo-nav-group-title {
|
||||
color: rgba(255, 218, 184, 0.64) !important;
|
||||
color: var(--momo-text-tertiary, #9b9184) !important;
|
||||
font-weight: 850;
|
||||
}
|
||||
.momo-nav-link {
|
||||
color: rgba(255, 248, 238, 0.78) !important;
|
||||
color: var(--momo-text-primary, #2a2520) !important;
|
||||
}
|
||||
.momo-nav-link:hover {
|
||||
background: rgba(201, 100, 66, 0.13) !important;
|
||||
color: #ffd4bd !important;
|
||||
background: var(--momo-page-accent-soft, rgba(201, 100, 66, 0.09)) !important;
|
||||
color: var(--momo-page-accent-dark, var(--momo-accent-700, #8f4530)) !important;
|
||||
}
|
||||
.momo-nav-link.is-active {
|
||||
background: linear-gradient(135deg, #c96442, #9d4a32) !important;
|
||||
color: #fff8ee !important;
|
||||
box-shadow: 0 10px 24px rgba(201, 100, 66, 0.22);
|
||||
background: linear-gradient(135deg, var(--momo-page-accent, #d06d49), var(--momo-page-accent-dark, #c45f3f)) !important;
|
||||
color: var(--momo-page-inverse, #fff8ee) !important;
|
||||
box-shadow: 0 10px 22px rgba(201, 100, 66, 0.2);
|
||||
}
|
||||
.momo-nav-link .momo-nav-code {
|
||||
color: rgba(255, 248, 238, 0.46) !important;
|
||||
color: var(--momo-text-tertiary, #9b9184) !important;
|
||||
}
|
||||
.momo-nav-link.is-active .momo-nav-code {
|
||||
color: rgba(255, 248, 238, 0.76) !important;
|
||||
@@ -72,22 +97,25 @@
|
||||
.momo-nav-tree-summary::after {
|
||||
content: "⌄";
|
||||
margin-left: auto;
|
||||
color: var(--momo-muted, #8b8077);
|
||||
color: var(--momo-text-tertiary, #9b9184);
|
||||
font-size: 0.8rem;
|
||||
transform: rotate(-90deg);
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
.momo-nav-tree-summary.is-active::after {
|
||||
color: rgba(255, 248, 238, 0.78);
|
||||
}
|
||||
.momo-nav-tree[open] .momo-nav-tree-summary::after {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
.momo-nav-subtree {
|
||||
margin: 0.35rem 0 0.65rem 1.05rem;
|
||||
padding-left: 0.75rem;
|
||||
border-left: 1px solid rgba(238, 169, 128, 0.34);
|
||||
border-left: 1px solid color-mix(in srgb, var(--momo-page-accent, #c96442) 34%, transparent);
|
||||
}
|
||||
.momo-nav-subtitle {
|
||||
margin: 0.65rem 0 0.25rem;
|
||||
color: rgba(255, 248, 238, 0.58);
|
||||
color: var(--momo-text-tertiary, #9b9184);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
@@ -100,7 +128,7 @@
|
||||
gap: 0.45rem;
|
||||
padding: 0.42rem 0.55rem;
|
||||
border-radius: 0.7rem;
|
||||
color: rgba(255, 248, 238, 0.68);
|
||||
color: var(--momo-text-secondary, #6f665b);
|
||||
text-decoration: none;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
@@ -108,16 +136,16 @@
|
||||
}
|
||||
.momo-nav-sublink:hover,
|
||||
.momo-nav-sublink.is-active {
|
||||
background: rgba(201, 100, 66, 0.18);
|
||||
color: #ffb18f;
|
||||
background: var(--momo-page-accent-soft, rgba(201, 100, 66, 0.12));
|
||||
color: var(--momo-page-accent-dark, var(--momo-accent-700, #8f4530));
|
||||
transform: translateX(2px);
|
||||
}
|
||||
.momo-nav-sublink .momo-nav-num {
|
||||
color: rgba(255, 248, 238, 0.42);
|
||||
color: var(--momo-text-tertiary, #9b9184);
|
||||
font-weight: 800;
|
||||
}
|
||||
.momo-nav-sublink.is-active .momo-nav-num {
|
||||
color: rgba(255, 248, 238, 0.72);
|
||||
color: var(--momo-page-accent-dark, rgba(143, 69, 48, 0.72));
|
||||
}
|
||||
.momo-nav-sublink .momo-nav-code {
|
||||
opacity: 0.68;
|
||||
@@ -143,8 +171,8 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(255, 248, 238, 0.86);
|
||||
background: linear-gradient(135deg, #d76a45, #9d4a32);
|
||||
color: #fff8ee;
|
||||
background: linear-gradient(135deg, var(--momo-page-accent, #d76a45), var(--momo-page-accent-dark, #9d4a32));
|
||||
color: var(--momo-page-inverse, #fff8ee);
|
||||
box-shadow: 0 8px 18px rgba(132, 58, 34, 0.26);
|
||||
font-size: 0.62rem;
|
||||
line-height: 1;
|
||||
@@ -176,8 +204,7 @@
|
||||
<span></span><span></span><span></span>
|
||||
</span>
|
||||
<span class="momo-brand-word">
|
||||
<span class="momo-brand-name momo-display">EwoooC</span>
|
||||
<span class="momo-brand-subtitle momo-label">價格監控 V2</span>
|
||||
<span class="momo-brand-name">EwoooC</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
@@ -194,11 +221,37 @@
|
||||
<span class="momo-nav-label">活動看板</span>
|
||||
<span class="momo-nav-code momo-mono">02</span>
|
||||
</a>
|
||||
<a class="momo-nav-link {% if _active_page in ['sales', 'daily_sales', 'monthly', 'growth'] %}is-active{% endif %}" href="/sales_analysis">
|
||||
<span class="momo-nav-icon"><i class="fas fa-chart-line"></i></span>
|
||||
<span class="momo-nav-label">分析報表</span>
|
||||
<span class="momo-nav-code momo-mono">03</span>
|
||||
</a>
|
||||
<details class="momo-nav-tree" {% if _active_page in _analysis_pages %}open{% endif %}>
|
||||
<summary class="momo-nav-link momo-nav-tree-summary {% if _active_page in _analysis_pages %}is-active{% endif %}">
|
||||
<span class="momo-nav-icon"><i class="fas fa-chart-line"></i></span>
|
||||
<span class="momo-nav-label">分析報表</span>
|
||||
<span class="momo-nav-code momo-mono">03</span>
|
||||
</summary>
|
||||
<div class="momo-nav-subtree">
|
||||
<a class="momo-nav-sublink {% if _active_page == 'sales' %}is-active{% endif %}" href="/sales_analysis">
|
||||
<i class="fas fa-chart-bar"></i><span>業績分析</span><span class="momo-nav-code momo-mono">01</span>
|
||||
</a>
|
||||
<a class="momo-nav-sublink {% if _active_page == 'daily_sales' %}is-active{% endif %}" href="/daily_sales">
|
||||
<i class="fas fa-calendar-day"></i><span>當日業績</span><span class="momo-nav-code momo-mono">02</span>
|
||||
</a>
|
||||
<a class="momo-nav-sublink {% if _active_page == 'growth' %}is-active{% endif %}" href="/growth_analysis">
|
||||
<i class="fas fa-arrow-trend-up"></i><span>成長分析</span><span class="momo-nav-code momo-mono">03</span>
|
||||
</a>
|
||||
<a class="momo-nav-sublink {% if _active_page == 'monthly' %}is-active{% endif %}" href="/monthly_summary_analysis">
|
||||
<i class="fas fa-table"></i><span>月份總表</span><span class="momo-nav-code momo-mono">04</span>
|
||||
</a>
|
||||
{% if metabase_url %}
|
||||
<a class="momo-nav-sublink" href="{{ metabase_url }}" target="_blank" rel="noopener">
|
||||
<i class="fas fa-chart-pie"></i><span>自訂圖表</span><span class="momo-nav-code momo-mono">外部</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if grist_url %}
|
||||
<a class="momo-nav-sublink" href="{{ grist_url }}" target="_blank" rel="noopener">
|
||||
<i class="fas fa-table-cells"></i><span>資料協作</span><span class="momo-nav-code momo-mono">外部</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="momo-nav-group">
|
||||
|
||||
@@ -7,11 +7,146 @@
|
||||
{% include 'components/_navbar.html' %}
|
||||
#}
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--momo-legacy-bg: #ebe6dc;
|
||||
--momo-legacy-surface: #faf7f0;
|
||||
--momo-legacy-paper: #f3eee2;
|
||||
--momo-legacy-ink: #2a2520;
|
||||
--momo-legacy-muted: #645c52;
|
||||
--momo-legacy-accent: #c96442;
|
||||
--momo-legacy-accent-dark: #8f4530;
|
||||
--momo-legacy-accent-soft: rgba(201, 100, 66, 0.12);
|
||||
--momo-legacy-line: rgba(42, 37, 32, 0.16);
|
||||
--momo-legacy-inverse: #fff8ee;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--momo-legacy-bg) !important;
|
||||
color: var(--momo-legacy-ink);
|
||||
}
|
||||
|
||||
.navbar.navbar-dark,
|
||||
.navbar.bg-custom-dark,
|
||||
.navbar-dark.bg-primary {
|
||||
background:
|
||||
radial-gradient(circle at 24px 24px, rgba(255, 248, 238, 0.16) 1px, transparent 1px),
|
||||
linear-gradient(135deg, var(--momo-legacy-accent-dark), var(--momo-legacy-accent)) !important;
|
||||
background-size: 10px 10px, auto;
|
||||
border-bottom: 1px solid rgba(255, 248, 238, 0.18);
|
||||
box-shadow: 0 10px 24px rgba(61, 38, 27, 0.16) !important;
|
||||
}
|
||||
|
||||
.navbar .navbar-brand,
|
||||
.navbar .navbar-text,
|
||||
.navbar-dark .navbar-brand,
|
||||
.navbar-dark .navbar-text {
|
||||
color: var(--momo-legacy-inverse) !important;
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-nav .nav-link,
|
||||
.navbar.bg-custom-dark .navbar-nav .nav-link {
|
||||
color: rgba(255, 248, 238, 0.82) !important;
|
||||
border-radius: 6px;
|
||||
transition: background-color 160ms ease, color 160ms ease;
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-nav .nav-link:hover,
|
||||
.navbar.bg-custom-dark .navbar-nav .nav-link:hover {
|
||||
color: var(--momo-legacy-inverse) !important;
|
||||
background: rgba(255, 248, 238, 0.12) !important;
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-nav .nav-link.active,
|
||||
.navbar-dark .navbar-nav .nav-link.show,
|
||||
.navbar.bg-custom-dark .navbar-nav .nav-link.active,
|
||||
.navbar.bg-custom-dark .navbar-nav .nav-link.show {
|
||||
color: var(--momo-legacy-inverse) !important;
|
||||
background: rgba(255, 248, 238, 0.18) !important;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
background: var(--momo-legacy-surface);
|
||||
border: 1px solid var(--momo-legacy-line);
|
||||
box-shadow: 0 18px 34px rgba(61, 38, 27, 0.14);
|
||||
}
|
||||
|
||||
.dropdown-header,
|
||||
.dropdown-item {
|
||||
color: var(--momo-legacy-ink);
|
||||
}
|
||||
|
||||
.dropdown-item:hover,
|
||||
.dropdown-item:focus,
|
||||
.dropdown-item.active,
|
||||
.dropdown-item:active {
|
||||
color: var(--momo-legacy-accent-dark);
|
||||
background: var(--momo-legacy-accent-soft);
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.bg-primary,
|
||||
.badge.bg-primary,
|
||||
.progress-bar.bg-primary,
|
||||
.nav-pills .nav-link.active,
|
||||
.nav-tabs .nav-link.active,
|
||||
.page-item.active .page-link,
|
||||
.card-header.bg-primary {
|
||||
color: var(--momo-legacy-inverse) !important;
|
||||
background: linear-gradient(135deg, var(--momo-legacy-accent), var(--momo-legacy-accent-dark)) !important;
|
||||
border-color: var(--momo-legacy-accent-dark) !important;
|
||||
}
|
||||
|
||||
.btn-primary:hover,
|
||||
.btn-primary:focus {
|
||||
color: var(--momo-legacy-inverse) !important;
|
||||
background: linear-gradient(135deg, #b1543a, #7a3520) !important;
|
||||
border-color: #7a3520 !important;
|
||||
}
|
||||
|
||||
.btn-outline-primary {
|
||||
color: var(--momo-legacy-accent-dark) !important;
|
||||
border-color: var(--momo-legacy-accent) !important;
|
||||
}
|
||||
|
||||
.btn-outline-primary:hover,
|
||||
.btn-outline-primary.active {
|
||||
color: var(--momo-legacy-inverse) !important;
|
||||
background: var(--momo-legacy-accent) !important;
|
||||
border-color: var(--momo-legacy-accent-dark) !important;
|
||||
}
|
||||
|
||||
.text-primary,
|
||||
a,
|
||||
.product-link:hover {
|
||||
color: var(--momo-legacy-accent-dark) !important;
|
||||
}
|
||||
|
||||
.spinner-border.text-primary {
|
||||
color: var(--momo-legacy-accent) !important;
|
||||
}
|
||||
|
||||
.table thead,
|
||||
.table thead th,
|
||||
thead.bg-light th {
|
||||
background: var(--momo-legacy-paper) !important;
|
||||
color: var(--momo-legacy-ink) !important;
|
||||
border-color: var(--momo-legacy-line) !important;
|
||||
}
|
||||
|
||||
.card,
|
||||
.modal-content {
|
||||
background: var(--momo-legacy-surface);
|
||||
border-color: var(--momo-legacy-line);
|
||||
}
|
||||
</style>
|
||||
|
||||
<nav class="navbar navbar-expand-xl navbar-dark bg-custom-dark fixed-top shadow-sm">
|
||||
<div class="container">
|
||||
<!-- 品牌 Logo -->
|
||||
<a class="navbar-brand d-flex align-items-center" href="/">
|
||||
<span class="d-none d-sm-inline fw-bold" style="letter-spacing: 2px;">WOOO</span>
|
||||
<span class="d-none d-sm-inline fw-bold" style="letter-spacing: 0;">EwoooC</span>
|
||||
</a>
|
||||
|
||||
<!-- 手機版選單按鈕 -->
|
||||
|
||||
@@ -850,6 +850,7 @@
|
||||
|
||||
{% block ewooo_content %}
|
||||
<div class="daily-sales-page">
|
||||
{% include 'components/_analysis_report_tabs.html' %}
|
||||
{% if error %}
|
||||
<div class="error-message">
|
||||
<i class="fas fa-exclamation-triangle fa-3x text-warning mb-3"></i>
|
||||
|
||||
@@ -22,6 +22,175 @@
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/observability-system.css') }}">
|
||||
{% endif %}
|
||||
{% block extra_css %}{% endblock %}
|
||||
<style>
|
||||
.momo-app[data-active-page="dashboard"] {
|
||||
--momo-page-accent: var(--momo-warm-caramel);
|
||||
--momo-page-accent-dark: var(--momo-warm-mahogany);
|
||||
--momo-page-accent-soft: var(--momo-warm-caramel-soft);
|
||||
}
|
||||
.momo-app[data-active-page="edm"],
|
||||
.momo-app[data-active-page="campaigns"] {
|
||||
--momo-page-accent: var(--momo-warm-honey);
|
||||
--momo-page-accent-dark: #7a5510;
|
||||
--momo-page-accent-soft: var(--momo-warm-honey-soft);
|
||||
}
|
||||
.momo-app[data-active-page="sales"],
|
||||
.momo-app[data-active-page="daily_sales"],
|
||||
.momo-app[data-active-page="monthly"],
|
||||
.momo-app[data-active-page="growth"] {
|
||||
--momo-page-accent: var(--momo-warm-earth);
|
||||
--momo-page-accent-dark: #65411f;
|
||||
--momo-page-accent-soft: var(--momo-warm-earth-soft);
|
||||
}
|
||||
.momo-app[data-active-page="vendor_stockout"] {
|
||||
--momo-page-accent: var(--momo-warm-rust);
|
||||
--momo-page-accent-dark: #7d2520;
|
||||
--momo-page-accent-soft: var(--momo-warm-rust-soft);
|
||||
}
|
||||
.momo-app[data-active-page="ai_recommend"],
|
||||
.momo-app[data-active-page="ai_history"],
|
||||
.momo-app[data-active-page="ai_intelligence"] {
|
||||
--momo-page-accent: var(--momo-warm-honey);
|
||||
--momo-page-accent-dark: #6e500e;
|
||||
--momo-page-accent-soft: var(--momo-warm-honey-soft);
|
||||
}
|
||||
.momo-app[data-active-page="auto_import"],
|
||||
.momo-app[data-active-page="market_intel"] {
|
||||
--momo-page-accent: var(--momo-warm-mahogany);
|
||||
--momo-page-accent-dark: #5e2e20;
|
||||
--momo-page-accent-soft: var(--momo-warm-mahogany-soft);
|
||||
}
|
||||
.momo-app[data-active-page^="obs_"],
|
||||
.momo-app[data-active-page="settings"],
|
||||
.momo-app[data-active-page="system_settings"],
|
||||
.momo-app[data-active-page="logs"],
|
||||
.momo-app[data-active-page="crawler"],
|
||||
.momo-app[data-active-page="user_management"],
|
||||
.momo-app[data-active-page="ai_automation_smoke"] {
|
||||
--momo-page-accent: var(--momo-warm-caramel);
|
||||
--momo-page-accent-dark: var(--momo-warm-mahogany);
|
||||
--momo-page-accent-soft: var(--momo-warm-caramel-soft);
|
||||
}
|
||||
|
||||
.momo-app .momo-topbar-pill,
|
||||
.momo-app .momo-avatar,
|
||||
.momo-app .momo-shortcut,
|
||||
.momo-app .momo-status-card,
|
||||
.momo-app .page-header,
|
||||
.momo-app .calendar-nav button,
|
||||
.momo-app .kpi-card.text-white,
|
||||
.momo-app .bg-purple,
|
||||
.momo-app .dashboard-kpi.is-accent,
|
||||
.momo-app .dashboard-segmented a.is-active,
|
||||
.momo-app .dashboard-action-button.is-primary,
|
||||
.momo-app .dashboard-history-range button.is-active,
|
||||
.momo-app .campaign-tab.is-active,
|
||||
.momo-app .campaign-slot-tab.active,
|
||||
.momo-app .campaign-filter-chip.is-active,
|
||||
.momo-app .campaign-history-range button.is-active,
|
||||
.momo-app .vendor-action.is-primary,
|
||||
.momo-app .vendor-pulse,
|
||||
.momo-app .stockout-action.is-primary,
|
||||
.momo-app .stockout-tab.is-active,
|
||||
.momo-app .stockout-import-note,
|
||||
.momo-app .filter-section,
|
||||
.momo-app .monthly-version-pill,
|
||||
.momo-app .nav-pills .nav-link.active,
|
||||
.momo-app .nav-tabs .nav-link.active,
|
||||
.momo-app .dropdown-item.active,
|
||||
.momo-app .page-item.active .page-link,
|
||||
.momo-app .btn-primary,
|
||||
.momo-app .bg-primary,
|
||||
.momo-app .bg-dark {
|
||||
color: var(--momo-page-inverse) !important;
|
||||
background: linear-gradient(135deg, var(--momo-page-accent), var(--momo-page-accent-dark)) !important;
|
||||
border-color: var(--momo-page-accent-dark) !important;
|
||||
}
|
||||
|
||||
.momo-app .page-header h1,
|
||||
.momo-app .page-header h2,
|
||||
.momo-app .page-header h3,
|
||||
.momo-app .page-header h4,
|
||||
.momo-app .page-header h5,
|
||||
.momo-app .page-header p,
|
||||
.momo-app .page-header small,
|
||||
.momo-app .page-header .text-muted,
|
||||
.momo-app .page-header .text-info,
|
||||
.momo-app .filter-section h1,
|
||||
.momo-app .filter-section h2,
|
||||
.momo-app .filter-section h3,
|
||||
.momo-app .filter-section h4,
|
||||
.momo-app .filter-section h5,
|
||||
.momo-app .filter-section h6,
|
||||
.momo-app .filter-section label,
|
||||
.momo-app .filter-section .form-label {
|
||||
color: var(--momo-page-inverse) !important;
|
||||
}
|
||||
|
||||
.momo-app .page-header .date-selector,
|
||||
.momo-app .filter-section .form-control,
|
||||
.momo-app .filter-section .form-select,
|
||||
.momo-app .filter-section .custom-dropdown-btn {
|
||||
color: var(--momo-text-primary) !important;
|
||||
background: var(--momo-bg-elevated) !important;
|
||||
border-color: rgba(255, 248, 238, 0.68) !important;
|
||||
}
|
||||
|
||||
.momo-app .kpi-card .kpi-percent,
|
||||
.momo-app .kpi-card .badge.bg-dark {
|
||||
color: var(--momo-page-inverse) !important;
|
||||
background: rgba(255, 248, 238, 0.18) !important;
|
||||
border: 1px solid rgba(255, 248, 238, 0.24) !important;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
|
||||
.momo-app .dashboard-kpi.is-accent::before,
|
||||
.momo-app .momo-status-card::before {
|
||||
background-image: radial-gradient(circle, rgba(255, 248, 238, 0.2) 1px, transparent 1px) !important;
|
||||
}
|
||||
|
||||
.momo-app .dashboard-table th,
|
||||
.momo-app .campaign-table th,
|
||||
.momo-app .stockout-table th,
|
||||
.momo-app .vendor-table th,
|
||||
.momo-app .daily-sales-page .table thead th,
|
||||
.momo-app .monthly-analysis-page .table thead th,
|
||||
.momo-app .monthly-analysis-page .table-light th,
|
||||
.momo-app .ai-intel-page .table thead th {
|
||||
color: var(--momo-page-inverse) !important;
|
||||
background: linear-gradient(90deg, var(--momo-page-accent-dark), var(--momo-page-accent)) !important;
|
||||
border-bottom-color: color-mix(in srgb, var(--momo-page-accent-dark) 70%, #fff8ee) !important;
|
||||
}
|
||||
|
||||
.momo-app .dashboard-table th a,
|
||||
.momo-app .campaign-table th a {
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
.momo-app .btn-outline-primary {
|
||||
color: var(--momo-page-accent-dark) !important;
|
||||
border-color: var(--momo-page-accent) !important;
|
||||
}
|
||||
|
||||
.momo-app .btn-outline-primary:hover,
|
||||
.momo-app .text-bg-primary,
|
||||
.momo-app .badge.bg-primary,
|
||||
.momo-app .progress-bar.bg-primary,
|
||||
.momo-app .keyword-badge.bg-primary {
|
||||
color: var(--momo-page-inverse) !important;
|
||||
background-color: var(--momo-page-accent) !important;
|
||||
border-color: var(--momo-page-accent) !important;
|
||||
}
|
||||
|
||||
.momo-app .text-primary {
|
||||
color: var(--momo-page-accent-dark) !important;
|
||||
}
|
||||
|
||||
.momo-app .momo-live-dot {
|
||||
background: var(--momo-page-accent);
|
||||
box-shadow: 0 0 8px var(--momo-page-accent);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="momo-v2-body {% if active_page|default('') in [
|
||||
'obs_overview', 'obs_agent_orchestration', 'obs_business_intel',
|
||||
@@ -29,7 +198,7 @@
|
||||
'obs_promotion_review', 'obs_rag_queries', 'obs_quality_trend',
|
||||
'obs_ppt_audit'
|
||||
] %}momo-observability-mode{% endif %}">
|
||||
<div class="momo-app momo-shell" id="momo-shell">
|
||||
<div class="momo-app momo-shell" id="momo-shell" data-active-page="{{ active_page|default('') }}">
|
||||
{% include 'components/_ewoooc_shell.html' %}
|
||||
|
||||
{% set _next_run = next_run|default(None) %}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
{% include 'components/_navbar.html' %}
|
||||
|
||||
<div class="container-fluid px-4">
|
||||
{% include 'components/_analysis_report_tabs.html' %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4 mt-4">
|
||||
<h4 class="mb-0 fw-bold text-dark"><i class="fas fa-rocket me-2 text-success"></i>營運成長策略報表</h4>
|
||||
<span class="text-muted small">數據更新至: {{ chart_data.labels[-1] if chart_data.labels else '-' }}</span>
|
||||
@@ -247,4 +248,4 @@
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
95
templates/market_intel/disabled.html
Normal file
95
templates/market_intel/disabled.html
Normal file
@@ -0,0 +1,95 @@
|
||||
{% extends "ewoooc_base.html" %}
|
||||
|
||||
{% block title %}市場情報|EwoooC{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.market-intel-status {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.market-intel-panel {
|
||||
border: 1px solid var(--momo-border, #d8c8aa);
|
||||
border-radius: 8px;
|
||||
background:
|
||||
radial-gradient(circle at 1px 1px, rgba(120, 83, 44, 0.16) 1px, transparent 1.35px),
|
||||
var(--momo-paper, #f8f2e7);
|
||||
background-size: 10px 10px, auto;
|
||||
box-shadow: var(--momo-shadow-sm, 0 8px 18px rgba(72, 49, 28, 0.08));
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.market-intel-title {
|
||||
color: var(--momo-ink, #30251b);
|
||||
font-size: 1.35rem;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.market-intel-muted {
|
||||
color: var(--momo-muted, #756a5b);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.market-intel-flags {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.market-intel-flag {
|
||||
border-left: 3px solid var(--momo-accent, #c8752d);
|
||||
background: rgba(255, 250, 241, 0.84);
|
||||
padding: 0.8rem 0.9rem;
|
||||
}
|
||||
|
||||
.market-intel-flag span {
|
||||
color: var(--momo-muted, #756a5b);
|
||||
display: block;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.market-intel-flag strong {
|
||||
color: var(--momo-ink, #30251b);
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 1rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block ewooo_content %}
|
||||
<section class="market-intel-status">
|
||||
<div class="market-intel-panel">
|
||||
<p class="market-intel-muted momo-mono mb-2">MARKET INTEL / {{ status.phase }}</p>
|
||||
<h1 class="market-intel-title">市場情報尚未啟用</h1>
|
||||
<p class="market-intel-muted mt-2">目前只載入安全骨架;爬蟲、正式寫入與排程都尚未掛載。</p>
|
||||
|
||||
<div class="market-intel-flags">
|
||||
<div class="market-intel-flag">
|
||||
<span>模組開關</span>
|
||||
<strong>{{ 'ON' if status.enabled else 'OFF' }}</strong>
|
||||
</div>
|
||||
<div class="market-intel-flag">
|
||||
<span>爬蟲開關</span>
|
||||
<strong>{{ 'ON' if status.crawler_enabled else 'OFF' }}</strong>
|
||||
</div>
|
||||
<div class="market-intel-flag">
|
||||
<span>資料寫入</span>
|
||||
<strong>{{ 'ON' if status.write_enabled else 'OFF' }}</strong>
|
||||
</div>
|
||||
<div class="market-intel-flag">
|
||||
<span>排程掛載</span>
|
||||
<strong>{{ 'ON' if status.scheduler_attached else 'OFF' }}</strong>
|
||||
</div>
|
||||
<div class="market-intel-flag">
|
||||
<span>DB 寫入許可</span>
|
||||
<strong>{{ 'ON' if status.database_write_allowed else 'OFF' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -167,6 +167,7 @@
|
||||
</div>
|
||||
|
||||
<div class="monthly-analysis-page">
|
||||
{% include 'components/_analysis_report_tabs.html' %}
|
||||
<section class="monthly-analysis-hero">
|
||||
<div>
|
||||
<h1 class="monthly-analysis-title"><i class="fas fa-chart-pie me-2 text-primary"></i>月份總表數據分析</h1>
|
||||
|
||||
@@ -608,6 +608,7 @@
|
||||
</div>
|
||||
|
||||
<div class="container-fluid px-4">
|
||||
{% include 'components/_analysis_report_tabs.html' %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4 mt-4">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<h4 class="mb-0 fw-bold text-dark"><i class="fas fa-chart-pie me-2 text-primary"></i>業績分析儀表板</h4>
|
||||
@@ -3163,4 +3164,4 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
56
tests/test_auto_import_data_sync.py
Normal file
56
tests/test_auto_import_data_sync.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import importlib
|
||||
import os
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
|
||||
def _load_scheduler(monkeypatch, database_url):
|
||||
os.environ.setdefault("MOMO_ALLOW_INSECURE_CONFIG_FOR_TESTS", "true")
|
||||
import config
|
||||
|
||||
monkeypatch.setattr(config, "DATABASE_PATH", database_url)
|
||||
import scheduler
|
||||
|
||||
return importlib.reload(scheduler)
|
||||
|
||||
|
||||
def test_verify_import_data_sync_scopes_to_imported_date(monkeypatch, tmp_path):
|
||||
db_path = tmp_path / "sales.db"
|
||||
database_url = f"sqlite:///{db_path}"
|
||||
scheduler = _load_scheduler(monkeypatch, database_url)
|
||||
|
||||
engine = create_engine(database_url)
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text('CREATE TABLE daily_sales_snapshot ("日期" TEXT, snapshot_date TEXT, "商品ID" TEXT)'))
|
||||
conn.execute(text('CREATE TABLE realtime_sales_monthly ("日期" TEXT, "商品ID" TEXT)'))
|
||||
conn.execute(text("""
|
||||
INSERT INTO daily_sales_snapshot ("日期", snapshot_date, "商品ID") VALUES
|
||||
('2026-05-05', '2026-05-05', 'A001'),
|
||||
('2026-05-05', '2026-05-05', 'A002')
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
INSERT INTO realtime_sales_monthly ("日期", "商品ID") VALUES
|
||||
('2026-05-01', 'OLD1'),
|
||||
('2026-05-01', 'OLD2'),
|
||||
('2026-05-05', 'A001'),
|
||||
('2026-05-05', 'A002')
|
||||
"""))
|
||||
|
||||
result = scheduler.verify_import_data_sync(
|
||||
expected_rows=2,
|
||||
date_range={"min": "2026-05-05", "max": "2026-05-05"},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["daily_sales_snapshot"]["rows"] == 2
|
||||
assert result["realtime_sales_monthly"]["rows"] == 2
|
||||
|
||||
|
||||
def test_verify_import_data_sync_rejects_missing_import_scope(monkeypatch, tmp_path):
|
||||
scheduler = _load_scheduler(monkeypatch, f"sqlite:///{tmp_path / 'sales.db'}")
|
||||
|
||||
result = scheduler.verify_import_data_sync(expected_rows=10, date_range=None)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "缺少本次匯入日期範圍" in result["errors"][0]
|
||||
|
||||
@@ -7,9 +7,12 @@ GUIDE = ROOT / "docs/guides/modularization_governance.md"
|
||||
|
||||
|
||||
def _python_line_counts():
|
||||
ignored_parts = {".git", "venv", "__pycache__", ".pytest_cache"}
|
||||
ignored_parts = {".git", "venv", "backups", "__pycache__", ".pytest_cache"}
|
||||
for path in ROOT.rglob("*.py"):
|
||||
if any(part in ignored_parts for part in path.relative_to(ROOT).parts):
|
||||
parts = path.relative_to(ROOT).parts
|
||||
if any(part in ignored_parts for part in parts):
|
||||
continue
|
||||
if len(parts) >= 2 and parts[0] == ".claude" and parts[1] == "worktrees":
|
||||
continue
|
||||
with path.open(encoding="utf-8", errors="ignore") as handle:
|
||||
yield path.relative_to(ROOT).as_posix(), sum(1 for _ in handle)
|
||||
|
||||
1055
web/static/css/analysis-workbench.css
Normal file
1055
web/static/css/analysis-workbench.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -49,6 +49,12 @@
|
||||
--momo-warm-mahogany-soft:rgba(143,69,48,0.12);
|
||||
--momo-warm-earth-soft: rgba(138,90,43,0.12);
|
||||
|
||||
/* 頁面調性:共用 shell 與反白 UI 以此套用各頁暖色 accent */
|
||||
--momo-page-accent: var(--momo-warm-caramel);
|
||||
--momo-page-accent-dark: var(--momo-warm-mahogany);
|
||||
--momo-page-accent-soft: var(--momo-warm-caramel-soft);
|
||||
--momo-page-inverse: #fff8ee;
|
||||
|
||||
/* ===== 標籤色系統(Tag / Chip / Badge 統一規範) =====
|
||||
* 規則:
|
||||
* 1. 全部留在暖色域(caramel/honey/rust/mahogany/earth + 中性 ink)
|
||||
@@ -179,11 +185,16 @@
|
||||
--momo-text-inverse: #faf7f0;
|
||||
--momo-text-link: #c96442;
|
||||
--momo-text-link-hover: #8f4530;
|
||||
--momo-text-strong: var(--momo-text-primary);
|
||||
--momo-text-muted: var(--momo-text-secondary);
|
||||
--momo-muted: var(--momo-text-secondary);
|
||||
--momo-paper: var(--momo-bg-paper);
|
||||
|
||||
/* 邊框(暖色調線)*/
|
||||
--momo-border: #2a2520;
|
||||
--momo-border-light: rgba(42,37,32,0.16);
|
||||
--momo-border-strong: rgba(42,37,32,0.42);
|
||||
--momo-border-subtle: var(--momo-border-light);
|
||||
--momo-border-dark: #2a2520;
|
||||
--momo-border-focus: #c96442;
|
||||
--momo-divider: rgba(42,37,32,0.12);
|
||||
@@ -205,6 +216,7 @@
|
||||
/* 等寬:數據 */
|
||||
--momo-font-family-mono:
|
||||
"JetBrains Mono", "SF Mono", Menlo, Consolas, monospace;
|
||||
--momo-font-mono: var(--momo-font-family-mono);
|
||||
|
||||
--momo-font-size-xs: 0.75rem;
|
||||
--momo-font-size-sm: 0.8125rem;
|
||||
@@ -238,6 +250,8 @@
|
||||
--momo-shadow-lg: 0 12px 40px -8px rgba(26,26,26,0.18), 0 0 0 1px rgba(26,26,26,0.10);
|
||||
--momo-shadow-colored: 0 0 0 2px rgba(201,100,66,0.25);
|
||||
--momo-shadow-inner: inset 0 1px 2px 0 rgba(26,26,26,0.08);
|
||||
--momo-shadow-soft: var(--momo-shadow-sm);
|
||||
--momo-shadow-medium: var(--momo-shadow-md);
|
||||
|
||||
/* ===== 5. Radius(Nothing 風偏方角,僅輕微圓角) ===== */
|
||||
--momo-radius-sm: 0.125rem; /* 2px */
|
||||
@@ -283,7 +297,7 @@
|
||||
background-color: var(--momo-bg-body);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
letter-spacing: -0.005em;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.momo-app *, .momo-app *::before, .momo-app *::after { box-sizing: border-box; }
|
||||
.momo-app button:not(.btn):not(.btn-close) {
|
||||
@@ -297,7 +311,7 @@
|
||||
.momo-display {
|
||||
font-family: var(--momo-font-display);
|
||||
font-feature-settings: "tnum", "ss01";
|
||||
letter-spacing: -0.02em;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.momo-mono {
|
||||
font-family: var(--momo-font-family-mono);
|
||||
|
||||
418
web/static/js/analysis-chart-theme.js
Normal file
418
web/static/js/analysis-chart-theme.js
Normal file
@@ -0,0 +1,418 @@
|
||||
(function () {
|
||||
const root = document.documentElement;
|
||||
const css = (name, fallback) => getComputedStyle(root).getPropertyValue(name).trim() || fallback;
|
||||
|
||||
const theme = {
|
||||
font: css('--momo-font-family', '"Inter", "Noto Sans TC", sans-serif'),
|
||||
mono: css('--momo-font-family-mono', '"JetBrains Mono", monospace'),
|
||||
ink: css('--momo-text-primary', '#2a2520'),
|
||||
muted: css('--momo-text-secondary', '#645c52'),
|
||||
faint: 'rgba(42, 37, 32, 0.10)',
|
||||
paper: css('--momo-bg-paper', '#f3eee2'),
|
||||
elevated: css('--momo-bg-elevated', '#fdfaf3'),
|
||||
accent: css('--momo-warm-caramel', '#c96442'),
|
||||
honey: css('--momo-warm-honey', '#b88416'),
|
||||
rust: css('--momo-warm-rust', '#b5342f'),
|
||||
mahogany: css('--momo-warm-mahogany', '#8f4530'),
|
||||
earth: css('--momo-warm-earth', '#8a5a2b'),
|
||||
success: css('--momo-success', '#2a7a3f'),
|
||||
info: css('--momo-info', '#2d5d80')
|
||||
};
|
||||
|
||||
const palette = [
|
||||
theme.accent,
|
||||
theme.success,
|
||||
theme.honey,
|
||||
theme.mahogany,
|
||||
theme.info,
|
||||
theme.earth,
|
||||
theme.rust,
|
||||
'#6f6256',
|
||||
'#a66a3f',
|
||||
'#576a42',
|
||||
'#9b6f1c',
|
||||
'#5f4638'
|
||||
];
|
||||
|
||||
const isCompact = () => window.matchMedia && window.matchMedia('(max-width: 768px)').matches;
|
||||
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
||||
const coldColorMap = {
|
||||
'#6366f1': theme.accent,
|
||||
'#3b82f6': theme.info,
|
||||
'#8b5cf6': theme.mahogany,
|
||||
'#10b981': theme.success,
|
||||
'#14b8a6': theme.success,
|
||||
'#0ea5e9': theme.info,
|
||||
'#0284c7': theme.info,
|
||||
'#94a3b8': '#8f857a',
|
||||
'#d1d5db': '#c7bcae',
|
||||
'#ec4899': theme.rust,
|
||||
'#ef4444': theme.rust,
|
||||
'#f59e0b': theme.honey
|
||||
};
|
||||
|
||||
const alpha = (hex, amount) => {
|
||||
if (!hex || !hex.startsWith('#') || hex.length < 7) return hex;
|
||||
const n = parseInt(hex.slice(1, 7), 16);
|
||||
const r = (n >> 16) & 255;
|
||||
const g = (n >> 8) & 255;
|
||||
const b = n & 255;
|
||||
return `rgba(${r}, ${g}, ${b}, ${amount})`;
|
||||
};
|
||||
|
||||
function mapColor(color, fallback) {
|
||||
if (typeof color !== 'string') return color || fallback;
|
||||
const key = color.trim().toLowerCase();
|
||||
if (coldColorMap[key]) return coldColorMap[key];
|
||||
return color;
|
||||
}
|
||||
|
||||
function mapPalette(colors) {
|
||||
if (!Array.isArray(colors)) return palette;
|
||||
return colors.map((color, index) => mapColor(color, palette[index % palette.length]));
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return value;
|
||||
return Math.abs(value) >= 10000 ? `${Math.round(value / 10000).toLocaleString()}萬` : value.toLocaleString();
|
||||
}
|
||||
|
||||
function tuneDataset(dataset, index, chartType) {
|
||||
if (!dataset || typeof dataset !== 'object') return;
|
||||
const color = palette[index % palette.length];
|
||||
const type = dataset.type || chartType;
|
||||
|
||||
if (type === 'doughnut' || type === 'pie' || type === 'polarArea') {
|
||||
dataset.backgroundColor = palette.map((item) => alpha(item, 0.82));
|
||||
dataset.borderColor = theme.elevated;
|
||||
dataset.borderWidth = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'treemap' && typeof dataset.backgroundColor === 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof dataset.backgroundColor !== 'function') {
|
||||
dataset.backgroundColor = type === 'line' ? alpha(color, dataset.fill ? 0.16 : 0.08) : alpha(color, 0.72);
|
||||
}
|
||||
if (typeof dataset.borderColor !== 'function') {
|
||||
dataset.borderColor = color;
|
||||
}
|
||||
dataset.borderWidth = dataset.borderWidth || (type === 'line' ? 2 : 1);
|
||||
dataset.borderRadius = dataset.borderRadius ?? (type === 'bar' ? 5 : undefined);
|
||||
dataset.borderSkipped = dataset.borderSkipped ?? (type === 'bar' ? false : undefined);
|
||||
dataset.pointRadius = dataset.pointRadius ?? (type === 'line' ? 2.5 : undefined);
|
||||
dataset.pointHoverRadius = dataset.pointHoverRadius ?? (type === 'line' ? 4 : undefined);
|
||||
dataset.pointBackgroundColor = dataset.pointBackgroundColor || color;
|
||||
dataset.pointBorderColor = dataset.pointBorderColor || theme.elevated;
|
||||
dataset.tension = dataset.tension ?? (type === 'line' ? 0.32 : undefined);
|
||||
}
|
||||
|
||||
function tuneChartConfig(config) {
|
||||
if (!config || typeof config !== 'object') return config;
|
||||
const chartType = config.type || 'bar';
|
||||
const datasets = config.data && Array.isArray(config.data.datasets) ? config.data.datasets : [];
|
||||
datasets.forEach((dataset, index) => tuneDataset(dataset, index, chartType));
|
||||
|
||||
config.options = config.options || {};
|
||||
config.options.responsive = config.options.responsive !== false;
|
||||
config.options.maintainAspectRatio = false;
|
||||
config.options.resizeDelay = config.options.resizeDelay ?? 90;
|
||||
config.options.interaction = {
|
||||
mode: chartType === 'scatter' || chartType === 'bubble' ? 'nearest' : 'index',
|
||||
intersect: chartType === 'scatter' || chartType === 'bubble',
|
||||
...(config.options.interaction || {})
|
||||
};
|
||||
|
||||
const plugins = config.options.plugins = config.options.plugins || {};
|
||||
plugins.legend = {
|
||||
...(plugins.legend || {}),
|
||||
labels: {
|
||||
color: theme.muted,
|
||||
boxWidth: 10,
|
||||
boxHeight: 10,
|
||||
borderRadius: 2,
|
||||
padding: 14,
|
||||
font: { family: theme.mono, size: 11, weight: '700' },
|
||||
...((plugins.legend || {}).labels || {})
|
||||
}
|
||||
};
|
||||
plugins.tooltip = {
|
||||
...(plugins.tooltip || {}),
|
||||
backgroundColor: 'rgba(253, 250, 243, 0.97)',
|
||||
titleColor: theme.ink,
|
||||
bodyColor: theme.ink,
|
||||
borderColor: alpha(theme.accent, 0.38),
|
||||
borderWidth: 1,
|
||||
cornerRadius: 6,
|
||||
displayColors: true,
|
||||
titleFont: { family: theme.mono, weight: '800', size: 12 },
|
||||
bodyFont: { family: theme.font, weight: '600', size: 12 },
|
||||
padding: 10,
|
||||
callbacks: {
|
||||
label: function (ctx) {
|
||||
const label = ctx.dataset && ctx.dataset.label ? `${ctx.dataset.label}: ` : '';
|
||||
const value = typeof ctx.parsed === 'object' ? (ctx.parsed.y ?? ctx.parsed.x ?? ctx.raw) : ctx.parsed;
|
||||
return `${label}${formatNumber(value)}`;
|
||||
},
|
||||
...((plugins.tooltip || {}).callbacks || {})
|
||||
}
|
||||
};
|
||||
|
||||
const scales = config.options.scales || {};
|
||||
Object.keys(scales).forEach((key) => {
|
||||
scales[key] = {
|
||||
border: { color: theme.faint, ...(scales[key].border || {}) },
|
||||
grid: { color: theme.faint, tickColor: 'transparent', drawBorder: false, ...(scales[key].grid || {}) },
|
||||
ticks: {
|
||||
color: theme.muted,
|
||||
maxRotation: isCompact() ? 0 : 45,
|
||||
autoSkip: isCompact(),
|
||||
font: { family: theme.mono, size: isCompact() ? 10 : 11, weight: '650' },
|
||||
callback: function (value) {
|
||||
return formatNumber(value);
|
||||
},
|
||||
...(scales[key].ticks || {})
|
||||
},
|
||||
title: {
|
||||
color: theme.muted,
|
||||
font: { family: theme.font, size: 12, weight: '800' },
|
||||
...(scales[key].title || {})
|
||||
},
|
||||
...scales[key]
|
||||
};
|
||||
});
|
||||
config.options.scales = scales;
|
||||
return config;
|
||||
}
|
||||
|
||||
function mergeAxis(axis) {
|
||||
if (!axis) return axis;
|
||||
const tuneOne = (item) => ({
|
||||
...item,
|
||||
axisLine: {
|
||||
...(item.axisLine || {}),
|
||||
lineStyle: {
|
||||
...((item.axisLine || {}).lineStyle || {}),
|
||||
color: mapColor(((item.axisLine || {}).lineStyle || {}).color, theme.faint)
|
||||
}
|
||||
},
|
||||
axisTick: { alignWithLabel: true, ...(item.axisTick || {}) },
|
||||
splitLine: {
|
||||
...(item.splitLine || {}),
|
||||
lineStyle: {
|
||||
type: 'dashed',
|
||||
...((item.splitLine || {}).lineStyle || {}),
|
||||
color: mapColor(((item.splitLine || {}).lineStyle || {}).color, theme.faint)
|
||||
}
|
||||
},
|
||||
axisLabel: {
|
||||
...((item.axisLabel || {})),
|
||||
color: theme.muted,
|
||||
fontFamily: theme.mono,
|
||||
fontSize: isCompact() ? 10 : clamp((item.axisLabel || {}).fontSize || 11, 10, 12),
|
||||
fontWeight: 700,
|
||||
hideOverlap: true,
|
||||
overflow: 'truncate',
|
||||
width: (item.axisLabel || {}).width || (isCompact() ? 72 : 120)
|
||||
},
|
||||
nameTextStyle: {
|
||||
...((item.nameTextStyle || {})),
|
||||
color: theme.muted,
|
||||
fontFamily: theme.font,
|
||||
fontWeight: 800
|
||||
}
|
||||
});
|
||||
return Array.isArray(axis) ? axis.map(tuneOne) : tuneOne(axis);
|
||||
}
|
||||
|
||||
function tuneItemStyle(itemStyle, index) {
|
||||
const fallback = palette[index % palette.length];
|
||||
if (typeof itemStyle === 'function') return itemStyle;
|
||||
const tuned = { ...(itemStyle || {}) };
|
||||
if (typeof tuned.color === 'string') tuned.color = mapColor(tuned.color, fallback);
|
||||
if (!tuned.color) tuned.color = fallback;
|
||||
tuned.opacity = tuned.opacity ?? 0.82;
|
||||
tuned.borderColor = tuned.borderColor || alpha(theme.elevated, 0.94);
|
||||
tuned.borderWidth = tuned.borderWidth ?? 1;
|
||||
return tuned;
|
||||
}
|
||||
|
||||
function tuneSeries(series) {
|
||||
if (!series) return series;
|
||||
const list = Array.isArray(series) ? series : [series];
|
||||
const compact = isCompact();
|
||||
const tuned = list.map((item, index) => {
|
||||
const type = item.type || 'line';
|
||||
const dataLength = Array.isArray(item.data) ? item.data.length : 0;
|
||||
const label = item.label || {};
|
||||
const shouldShowLabel = label.show === true && !(compact && (dataLength > 8 || type === 'pie' || type === 'heatmap'));
|
||||
|
||||
return {
|
||||
...item,
|
||||
itemStyle: tuneItemStyle(item.itemStyle, index),
|
||||
lineStyle: type === 'line' ? {
|
||||
...item.lineStyle,
|
||||
color: mapColor((item.lineStyle || {}).color, palette[index % palette.length]),
|
||||
width: (item.lineStyle || {}).width || 2.4
|
||||
} : item.lineStyle,
|
||||
areaStyle: item.areaStyle ? {
|
||||
...item.areaStyle,
|
||||
opacity: compact ? 0.08 : 0.12,
|
||||
color: alpha(mapColor((item.areaStyle || {}).color, palette[index % palette.length]), compact ? 0.10 : 0.16)
|
||||
} : item.areaStyle,
|
||||
label: {
|
||||
...label,
|
||||
color: theme.ink,
|
||||
fontFamily: theme.mono,
|
||||
fontSize: compact ? 10 : clamp(label.fontSize || 11, 10, 12),
|
||||
fontWeight: 800,
|
||||
hideOverlap: true,
|
||||
overflow: 'truncate',
|
||||
show: shouldShowLabel
|
||||
},
|
||||
labelLine: type === 'pie' ? { length: compact ? 8 : 14, length2: compact ? 6 : 10, ...(item.labelLine || {}) } : item.labelLine,
|
||||
emphasis: {
|
||||
focus: type === 'pie' ? 'self' : 'series',
|
||||
...(item.emphasis || {})
|
||||
},
|
||||
barMaxWidth: type === 'bar' ? (compact ? 18 : (item.barMaxWidth || 28)) : item.barMaxWidth,
|
||||
barCategoryGap: type === 'bar' ? (item.barCategoryGap || '42%') : item.barCategoryGap,
|
||||
symbolSize: type === 'line' ? (item.symbolSize || (compact ? 4 : 5)) : item.symbolSize
|
||||
};
|
||||
});
|
||||
return Array.isArray(series) ? tuned : tuned[0];
|
||||
}
|
||||
|
||||
function tuneEchartsOption(option) {
|
||||
const compact = isCompact();
|
||||
const tuned = {
|
||||
...option,
|
||||
color: option.color ? mapPalette(option.color) : palette,
|
||||
backgroundColor: 'transparent',
|
||||
textStyle: { color: theme.ink, fontFamily: theme.font, ...(option.textStyle || {}) },
|
||||
grid: {
|
||||
containLabel: true,
|
||||
borderColor: theme.faint,
|
||||
left: compact ? 10 : 28,
|
||||
right: compact ? 12 : 24,
|
||||
top: compact ? 32 : 40,
|
||||
bottom: compact ? 38 : 44,
|
||||
...(option.grid || {})
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(253, 250, 243, 0.97)',
|
||||
borderColor: alpha(theme.accent, 0.36),
|
||||
borderWidth: 1,
|
||||
textStyle: { color: theme.ink, fontFamily: theme.font, fontWeight: 650 },
|
||||
extraCssText: 'box-shadow:0 10px 24px rgba(42,37,32,.12);border-radius:6px;',
|
||||
...(option.tooltip || {})
|
||||
},
|
||||
legend: {
|
||||
...(option.legend || {}),
|
||||
type: compact ? 'scroll' : ((option.legend || {}).type || 'plain'),
|
||||
textStyle: {
|
||||
...((option.legend || {}).textStyle || {}),
|
||||
color: theme.muted,
|
||||
fontFamily: theme.mono,
|
||||
fontWeight: 700,
|
||||
fontSize: compact ? 10 : clamp(((option.legend || {}).textStyle || {}).fontSize || 11, 10, 12)
|
||||
},
|
||||
itemWidth: compact ? 10 : 12,
|
||||
itemHeight: compact ? 7 : 8,
|
||||
pageIconColor: theme.accent,
|
||||
pageTextStyle: { ...((option.legend || {}).pageTextStyle || {}), color: theme.muted, fontFamily: theme.mono }
|
||||
},
|
||||
title: Array.isArray(option.title)
|
||||
? option.title.map((title) => ({ ...title, textStyle: { ...((title || {}).textStyle || {}), color: theme.ink, fontFamily: theme.font, fontWeight: 800 } }))
|
||||
: option.title ? { ...option.title, textStyle: { ...((option.title || {}).textStyle || {}), color: theme.ink, fontFamily: theme.font, fontWeight: 800 } } : option.title,
|
||||
xAxis: mergeAxis(option.xAxis),
|
||||
yAxis: mergeAxis(option.yAxis),
|
||||
series: tuneSeries(option.series),
|
||||
visualMap: option.visualMap ? {
|
||||
...option.visualMap,
|
||||
inRange: { ...((option.visualMap || {}).inRange || {}), color: ['#fbf1d3', '#eac26b', theme.honey, theme.accent, theme.mahogany] },
|
||||
textStyle: { ...((option.visualMap || {}).textStyle || {}), color: theme.muted, fontFamily: theme.mono, fontWeight: 700 },
|
||||
itemWidth: compact ? 12 : 16,
|
||||
itemHeight: compact ? 84 : 120
|
||||
} : option.visualMap
|
||||
};
|
||||
return tuned;
|
||||
}
|
||||
|
||||
const dotMatrixPlugin = {
|
||||
id: 'ewooocDotMatrix',
|
||||
beforeDraw(chart) {
|
||||
const area = chart.chartArea;
|
||||
if (!area) return;
|
||||
const ctx = chart.ctx;
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(253, 250, 243, 0.72)';
|
||||
ctx.fillRect(area.left, area.top, area.right - area.left, area.bottom - area.top);
|
||||
ctx.fillStyle = 'rgba(42, 37, 32, 0.055)';
|
||||
for (let x = Math.floor(area.left) + 8; x < area.right; x += 14) {
|
||||
for (let y = Math.floor(area.top) + 8; y < area.bottom; y += 14) {
|
||||
ctx.fillRect(x, y, 1, 1);
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
};
|
||||
|
||||
function installChartJsTheme() {
|
||||
if (!window.Chart || window.Chart.__ewooocThemed) return;
|
||||
const NativeChart = window.Chart;
|
||||
if (NativeChart.register) NativeChart.register(dotMatrixPlugin);
|
||||
NativeChart.defaults.font.family = theme.font;
|
||||
NativeChart.defaults.font.size = 12;
|
||||
NativeChart.defaults.color = theme.muted;
|
||||
|
||||
function ThemedChart(item, config) {
|
||||
return new NativeChart(item, tuneChartConfig(config));
|
||||
}
|
||||
for (const key of Reflect.ownKeys(NativeChart)) {
|
||||
if (['length', 'name', 'prototype'].includes(key)) continue;
|
||||
try {
|
||||
Object.defineProperty(ThemedChart, key, Object.getOwnPropertyDescriptor(NativeChart, key));
|
||||
} catch (error) {}
|
||||
}
|
||||
ThemedChart.prototype = NativeChart.prototype;
|
||||
Object.setPrototypeOf(ThemedChart, NativeChart);
|
||||
ThemedChart.__ewooocThemed = true;
|
||||
ThemedChart.__native = NativeChart;
|
||||
window.Chart = ThemedChart;
|
||||
}
|
||||
|
||||
function installEchartsTheme() {
|
||||
if (!window.echarts || window.echarts.__ewooocThemed) return;
|
||||
const echarts = window.echarts;
|
||||
const nativeInit = echarts.init.bind(echarts);
|
||||
echarts.init = function (dom, themeName, opts) {
|
||||
const chart = nativeInit(dom, themeName || null, opts);
|
||||
const nativeSetOption = chart.setOption.bind(chart);
|
||||
chart.setOption = function (option, notMerge, lazyUpdate) {
|
||||
return nativeSetOption(tuneEchartsOption(option || {}), notMerge, lazyUpdate);
|
||||
};
|
||||
if (window.ResizeObserver && dom) {
|
||||
const observer = new ResizeObserver(() => window.requestAnimationFrame(() => chart.resize()));
|
||||
observer.observe(dom);
|
||||
}
|
||||
if (window.IntersectionObserver && dom) {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) window.requestAnimationFrame(() => chart.resize());
|
||||
});
|
||||
}, { threshold: 0.1 });
|
||||
observer.observe(dom);
|
||||
}
|
||||
window.requestAnimationFrame(() => chart.resize());
|
||||
return chart;
|
||||
};
|
||||
echarts.__ewooocThemed = true;
|
||||
}
|
||||
|
||||
window.EwoooCChartTheme = { palette, theme, tuneChartConfig, installChartJsTheme, installEchartsTheme };
|
||||
installChartJsTheme();
|
||||
installEchartsTheme();
|
||||
})();
|
||||
Reference in New Issue
Block a user