統一全站暖色視覺與市場情報骨架
All checks were successful
CD Pipeline / deploy (push) Successful in 58s

This commit is contained in:
OoO
2026-05-06 20:24:46 +08:00
parent 153e4c9734
commit 30a173cf69
32 changed files with 4463 additions and 93 deletions

View File

@@ -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
# ==========================================
# 通訊模組設定(從環境變數讀取)
# ==========================================

View File

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

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

View File

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

View File

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

View 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"),
)

View 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 0Readiness Audit
- 更新模組化 inventory。
- 確認 `scheduler.py` 無 conflict marker 且可 py_compile。
- 標記市場情報不可寫入的既有大檔。
- 完成 ADR-035。
### Phase 1Skeleton Only
- 新增 feature flags。
- 新增 `market_intel` package skeleton。
- 新增空 route / disabled page / dry-run service。
- 不啟用正式排程,不大量入庫。
### Phase 2DB Schema
- 新增 `market_*` ORM models。
- 補 metadata import 與 schema smoke。
- 寫入 crawler run log 與少量 dry-run snapshot。
### Phase 3MOMO / PChome Adapter
- 先接成本最低且已有脈絡的平台。
- 只抓公開活動入口與活動商品。
- 建立活動與商品正規化規則。
### Phase 4Coupang / Shopee Adapter
- Coupang 先做保守 adapter。
- Shopee 因動態資料與反爬風險較高,最後做,並維持更嚴格節流。
### Phase 5Product Matching + HITL
- 用品牌、規格、容量、關鍵字與現有商品資料計算 match score。
- 低信心進人工審核,不自動合併。
### Phase 6AI 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。

View File

@@ -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 |
## 規範

View 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 Sidebar240px+ 主內容區Topbar 64px sticky
- **Sidebar**:純黑背景 `#1a1a1a`,白色文字,焦糖橘 accent 高亮
- **背景**:米色紙張感 `#ebe6dc`
- **Accent 顏色**:焦糖橘 `#c96442`(新品牌色)
- **使用頁面**`vendor_stockout/`、部分新功能頁面EwoooC 路由下的頁面)
**設計方向**:以 3-B 新版為目標方向,所有優化/新設計請對齊新版設計系統。
---
## 4. 完整 Design TokenSource 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 ← 主 accentButton/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 10pxfont-weight 600letter-spacing 0.12emtext-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 dot2s 閃爍
@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: 72px1180px 以下自動切換)
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**:左 icon16px+ 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 ButtonCTA
```
背景:--momo-accent (#c96442)
文字:--momo-text-inverse (#faf7f0)
邊框none
圓角:--momo-radius-md (4px)
Hover--momo-accent-600 (#b1543a)
Active--momo-accent-700 (#8f4530)
Padding9px 20px
Font0.875rem600 weight
```
#### Secondary / Ghost Button
```
背景transparent
文字:--momo-text-primary (#2a2520)
邊框1px solid --momo-border-light
圓角4px
Hoverbg --momo-bg-subtle
```
#### Danger Button
```
背景:--momo-danger (#b5342f)
文字white
```
#### Icon Button.momo-icon-button
```
尺寸36×36px
背景transparent
圓角4px
Hoverbg --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
Padding24px (1.5rem)
Hover可選--momo-shadow-md
```
特殊:深色 Status Cardsidebar 底部)
```
背景:--momo-ink-strong (#1a1612)
邊框1px solid rgba(201,100,66,0.35)
點陣背景紋路radial-gradient dots6px×6px
文字rgba(250,247,240,0.55) 標題 / rgba(250,247,240,0.62) 內文
```
### 6.3 表格
```
Bordernone預設行底部 border-bottom: 1px solid --momo-divider
Header--momo-bg-paper 底色,--momo-label 樣式欄位標題
Hover Row--momo-accent-soft 背景
Cell Padding12px 16px
Data 數字:.momo-mono 字體
狀態 Badgepill 形border-radius: 2px對應狀態色
```
### 6.4 徽章Badge
```
圓角2px方角
Font10px800 weight全大寫
Color--momo-text-inverse (#faf7f0)
背景:--momo-accent主要或對應狀態色
Padding1px 6px
```
### 6.5 搜尋框(.momo-search-box
```
高度38px
背景:--momo-bg-paper
邊框1px solid --momo-border
圓角4px
Placeholder--momo-text-secondary
Focus border--momo-accent
左側 iconfa-searchmargin-right 10px
右側⌘K shortcut badgeaccent 背景2px 圓角)
```
### 6.6 Toast 通知
```
位置右上角固定top: 24px, right: 24pxz-index: 1080
寬度max 360px
圓角6px
自動消失3秒
進場動畫slide-upopacity + translateY 12px → 0
類型色:成功綠 / 危險紅 / 警告黃 / 資訊藍
```
### 6.7 Modal
```
背景:--momo-bg-surface
圓角:--momo-radius-lg (6px)
Header--momo-ink 背景,白字(舊版用 #667eea 漸層,新版請改用暖墨色)
Backdroprgba(26,26,26,0.70)
最大寬度sm 400px / md 640px / lg 960px / xl 1140px
```
### 6.8 用戶頭像 Chip.momo-user-chip
```
高度40px
Padding4px 10px 4px 4px
圓角pill50rem
Avatar32×32px 圓形,背景 --momo-ink白字13px 800weight
Hoverbg --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
```
舊版 Navbarbase.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 | 1180px820px | 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×32pxgap 1.5pxpadding 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~07Like 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。*

File diff suppressed because it is too large Load Diff

View File

@@ -1,39 +1,51 @@
# 程式碼模組化盤點2026-04-30
# 程式碼模組化盤點2026-04-302026-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/emailV2 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` 的資料處理移出 routeVendor 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。
## 守門

View File

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

View File

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

View 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))

View File

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

View File

@@ -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"詳細資料請至系統查看"
)

View File

@@ -0,0 +1,5 @@
"""跨平台市場情報服務模組。"""
from services.market_intel.service import MarketIntelService, MarketIntelRuntimeStatus
__all__ = ["MarketIntelService", "MarketIntelRuntimeStatus"]

View 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(),
}

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

View File

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

View File

@@ -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>
<!-- 手機版選單按鈕 -->

View File

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

View File

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

View File

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

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

View File

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

View File

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

View 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]

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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. RadiusNothing 風偏方角,僅輕微圓角) ===== */
--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);

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