From 71ea819d06c2952d40a9fa96345e5225f65dbdbb Mon Sep 17 00:00:00 2001
From: OoO
Date: Wed, 29 Apr 2026 21:11:45 +0800
Subject: [PATCH] =?UTF-8?q?refactor(routes):=20=E5=88=AA=E9=99=A4=20app.py?=
=?UTF-8?q?=20=E9=A6=96=E9=A0=81=E9=87=8D=E8=A4=87=E8=B7=AF=E7=94=B1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
ADR-017 Phase 3f-1 dashboard sprint;首頁改由 dashboard_bp 接管,並更新 url_for('index') 相容引用。
---
app.py | 293 ---------------------------------
auth.py | 6 +-
templates/403.html | 2 +-
templates/change_password.html | 2 +-
4 files changed, 5 insertions(+), 298 deletions(-)
diff --git a/app.py b/app.py
index b7aeeb4..8b3fece 100644
--- a/app.py
+++ b/app.py
@@ -741,299 +741,6 @@ momo_app_info{version="9.4",database_type="postgresql"} 1
return Response(f"# Error: {e}\n", mimetype='text/plain; charset=utf-8'), 500
-@app.route('/')
-def index():
- db = DatabaseManager()
-
- session = db.get_session()
- page = request.args.get('page', 1, type=int)
- category_filter = request.args.get('category', 'all')
- sort_by = request.args.get('sort_by', 'timestamp') # 預設按時間排序
- filter_type = request.args.get('filter', 'all') # 🚩 新增:狀態篩選 (increase, decrease, delisted)
- order = request.args.get('order', 'desc')
- search_query = request.args.get('q', '').strip() # 🚩 新增:搜尋關鍵字
- per_page = 50
-
- # 🚩 取得台北時間的今日起始點 (用於資料庫查詢比較)
- # 注意:若資料庫內存的是 naive time (無時區),則需轉為 naive 進行比較
- now_taipei = datetime.now(TAIPEI_TZ)
- today_start_db = now_taipei.replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
-
- try:
- # 🚩 1. 使用封裝函式獲取數據
- unique_items, today_start = get_consolidated_data()
-
- # --- 計算今日漲跌統計 ---
- increase_items = [item for item in unique_items if item['yesterday_diff'] > 0]
- decrease_items = [item for item in unique_items if item['yesterday_diff'] < 0]
-
- # --- V-New: 取得所有分類並加上筆數統計 ---
- cat_counts = {}
- for item in unique_items:
- c = item['record'].product.category
- if c:
- cat_counts[c] = cat_counts.get(c, 0) + 1
-
- all_categories = [f"{cat} ({count}筆)" for cat, count in sorted(cat_counts.items())]
-
- # V-Fix: 預先計算今日新增的商品 ID (不依賴 Product.created_at)
- new_product_ids = set()
- try:
- # 找出最早一筆價格紀錄是在今天的商品
- new_pids_query = session.query(PriceRecord.product_id).group_by(PriceRecord.product_id).having(func.min(PriceRecord.timestamp) >= today_start_db)
- new_product_ids = {r[0] for r in new_pids_query.all()}
- except Exception: pass
-
- # --- 看板統計數據 ---
- total_products_history = session.query(Product).count()
- today_new_products = session.query(func.count(Product.id)).filter(
- Product.id.in_(
- session.query(PriceRecord.product_id)
- .group_by(PriceRecord.product_id)
- .having(func.min(PriceRecord.timestamp) >= today_start_db)
- )
- ).scalar()
- total_price_records = session.query(PriceRecord).count()
- today_updates = session.query(PriceRecord).filter(PriceRecord.timestamp >= today_start_db).count()
-
- # 🚩 新增:今日下架商品統計 (狀態為 INACTIVE 且 最後更新時間 >= 今天零點)
- today_delisted_query = session.query(Product).filter(
- Product.status == 'INACTIVE',
- Product.updated_at >= today_start_db
- )
- raw_delisted_items = today_delisted_query.all()
- today_delisted_count = len(raw_delisted_items)
-
- # 🚩 V-Opt: 為下架商品補上最後價格(優化:一次查詢取得所有價格,避免 N+1 問題)
- today_delisted_items = []
- if raw_delisted_items:
- # 取得所有下架商品的 ID
- delisted_ids = [p.id for p in raw_delisted_items]
-
- # 一次性查詢所有下架商品的最後價格
- last_prices_subq = session.query(
- PriceRecord.product_id,
- func.max(PriceRecord.id).label('max_id')
- ).filter(
- PriceRecord.product_id.in_(delisted_ids)
- ).group_by(PriceRecord.product_id).subquery()
-
- last_prices_q = session.query(
- PriceRecord.product_id,
- PriceRecord.price
- ).join(
- last_prices_subq,
- PriceRecord.id == last_prices_subq.c.max_id
- )
-
- # 建立 product_id -> price 的映射
- price_map = {pid: price for pid, price in last_prices_q}
-
- # 組合結果
- for p in raw_delisted_items:
- price = price_map.get(p.id, 0)
- today_delisted_items.append({'product': p, 'last_price': price})
-
- # ========== V9.2: 新增 KPI 計算 ==========
-
- # 1. 平均漲跌幅
- avg_increase = sum(item['yesterday_diff'] for item in increase_items) / len(increase_items) if increase_items else 0
- avg_decrease = sum(item['yesterday_diff'] for item in decrease_items) / len(decrease_items) if decrease_items else 0
-
- # 2. 今日活躍度(有價格變動的商品百分比)
- active_count = len(increase_items) + len(decrease_items)
- activity_rate = (active_count / total_products_history * 100) if total_products_history > 0 else 0
-
- # 3. 最大變動(絕對值最大的價格變動)
- max_change_item = None
- max_change_value = 0
- for item in unique_items:
- if abs(item['yesterday_diff']) > abs(max_change_value):
- max_change_value = item['yesterday_diff']
- max_change_item = item
-
- # 4. 週增長 (過去 7 天新增的商品數)
- week_ago_db = now_taipei.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=7)
- week_ago_db = week_ago_db.replace(tzinfo=None)
- week_new_products = session.query(func.count(Product.id)).filter(
- Product.id.in_(
- session.query(PriceRecord.product_id)
- .group_by(PriceRecord.product_id)
- .having(func.min(PriceRecord.timestamp) >= week_ago_db)
- )
- ).scalar() or 0
-
- # 5. 價格穩定商品數(7 天內無變價)- V9.3 效能優化版
- seven_days_ago = now_taipei - timedelta(days=7)
- seven_days_ago_db = seven_days_ago.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None)
-
- # 使用 GROUP BY 一次性統計所有商品的不同價格數量(避免 N+1 查詢)
- try:
- stable_count = session.query(PriceRecord.product_id).filter(
- PriceRecord.timestamp >= seven_days_ago_db
- ).group_by(PriceRecord.product_id).having(
- func.count(func.distinct(PriceRecord.price)) == 1
- ).count()
- except Exception:
- stable_count = 0
-
- # 6. 最活躍分類(今日變動商品數最多的分類)
- category_activity = {}
- for item in increase_items + decrease_items:
- cat = item['record'].product.category
- if cat:
- category_activity[cat] = category_activity.get(cat, 0) + 1
-
- most_active_category = None
- most_active_count = 0
- if category_activity:
- most_active_category = max(category_activity.items(), key=lambda x: x[1])
- most_active_count = most_active_category[1]
- most_active_category = most_active_category[0]
-
- # 🚩 讀取系統狀態 (用於紅綠燈顯示)
- system_status = {"status": "UNKNOWN", "message": "尚無執行紀錄", "timestamp": "-"}
- status_path = os.path.join(BASE_DIR, 'data/system_status.json')
- if os.path.exists(status_path):
- try:
- with open(status_path, 'r', encoding='utf-8') as f:
- system_status = json.load(f)
- except: pass
-
- # --- 取得所有分類用於篩選器 ---
- # (已在上方取得)
-
- # 🚩 2. 後端篩選 (Server-side Filtering)
- scheduler_stats = load_scheduler_stats()
-
- # V-Fix: Handle old scheduler stats format (dict) by converting to list to prevent template errors
- if scheduler_stats.get('momo_task') and isinstance(scheduler_stats.get('momo_task'), dict):
- scheduler_stats['momo_task'] = [scheduler_stats['momo_task']]
- if scheduler_stats.get('edm_task') and isinstance(scheduler_stats.get('edm_task'), dict):
- scheduler_stats['edm_task'] = [scheduler_stats['edm_task']]
-
- filtered_items = []
-
- # 0. 先處理搜尋 (若有)
- if search_query:
- search_lower = search_query.lower()
- # V9.81: 搜尋功能修復,支援搜尋商品名稱與 i_code
- base_items = [
- item for item in unique_items
- if (item['record'].product.name and search_lower in item['record'].product.name.lower()) or
- (item['record'].product.i_code and search_lower in str(item['record'].product.i_code))
- ]
- else:
- base_items = unique_items
-
- # A. 先處理狀態篩選 (漲/跌/下架)
- if filter_type == 'increase':
- filtered_items = [i for i in base_items if i in increase_items]
- elif filter_type == 'decrease':
- filtered_items = [i for i in base_items if i in decrease_items]
- elif filter_type == 'new':
- # V-New: 新上架篩選 (今日新增的商品)
- filtered_items = [i for i in base_items if i['record'].product_id in new_product_ids]
- elif filter_type == 'delisted':
- # 特殊處理:將下架商品轉換為列表格式以便顯示
- for item in today_delisted_items:
- # 模擬 record 物件結構
- class MockRecord:
- def __init__(self, p, price): self.product = p; self.price = price; self.timestamp = p.updated_at
-
- if not search_query or search_query.lower() in item['product'].name.lower():
- filtered_items.append({
- 'record': MockRecord(item['product'], item['last_price']),
- 'stats': {'1d_diff': 0, '7d_diff': 0, '30d_diff': 0}, # 模擬 stats 結構
- 'yesterday_diff': 0,
- 'today_changes': [], # 確保結構一致
- 'status': 'DELISTED' # 新增狀態
- })
- else:
- # B. 若無狀態篩選,則處理分類篩選
- if category_filter != 'all':
- # V-New: 處理帶有筆數的分類名稱,例如 "化妝水 (50筆)" -> "化妝水"
- real_category = category_filter
- if "(" in category_filter and "筆)" in category_filter:
- real_category = category_filter.rsplit(" (", 1)[0]
- filtered_items = [item for item in base_items if item['record'].product.category == real_category]
- else:
- filtered_items = base_items
-
- # 🚩 3. 後端排序 (Server-side Sorting)
- reverse = (order == 'desc')
- def get_sort_key(item):
- # 處理 None 值,確保排序時不會出錯
- def safe_get(value, default=0):
- return default if value is None else value
-
- if sort_by == 'i_code': return int(safe_get(item['record'].product.i_code, 0))
- if sort_by == 'category': return safe_get(item['record'].product.category, '')
- if sort_by == 'name': return safe_get(item['record'].product.name, '')
- if sort_by == 'price': return safe_get(item['record'].price, 0)
- if sort_by == 'today_change': return safe_get(item['stats']['1d_diff'], 0) # 今日內波動
- if sort_by == 'yesterday_change': return safe_get(item['yesterday_diff'], 0)
- if sort_by == 'week_change': return safe_get(item['stats']['7d_diff'], 0)
- return item['record'].timestamp # 預設
-
- sorted_items = sorted(filtered_items, key=get_sort_key, reverse=reverse)
-
- # 🚩 4. 分頁 (Pagination) - 在篩選和排序之後執行
- total_items = len(sorted_items)
- total_pages = math.ceil(total_items / per_page)
-
- start_idx = (page - 1) * per_page
- paged_items = sorted_items[start_idx : start_idx + per_page]
-
- # V-Fix: 為前端準備安全的 created_at 屬性
- for item in paged_items:
- item['safe_created_at'] = getattr(item['record'].product, 'created_at', None)
-
- # 🚩 5. 為當前頁面項目添加顏色
- for item in paged_items:
- category_name = item['record'].product.category
- item['category_color'] = get_color_for_string(category_name)
-
- return render_template('dashboard.html',
- total_products=total_products_history,
- today_new_products=today_new_products,
- total_price_records=total_price_records,
- cnt_increase=len(increase_items),
- cnt_decrease=len(decrease_items), # 傳遞跌價數
- today_delisted_count=today_delisted_count,
- today_delisted_items=today_delisted_items,
- system_status=system_status,
- items=paged_items,
- categories=all_categories,
- current_page=page,
- total_pages=total_pages, # V-New: 傳遞總項目數
- total_items=total_items,
- datetime_now=now_taipei.strftime('%Y-%m-%d %H:%M:%S'), # 顯示台北時間
- today_date=now_taipei.strftime('%Y-%m-%d'), # 傳遞今日日期
- public_url=public_url,
- current_category=category_filter,
- current_filter=filter_type, # 傳遞當前篩選狀態
- search_query=search_query, # 傳遞搜尋關鍵字
- current_sort=sort_by,
- current_order=order,
- scheduler_stats=scheduler_stats,
- # V9.2: 新增 KPI 數據
- avg_increase=avg_increase,
- avg_decrease=avg_decrease,
- activity_rate=activity_rate,
- active_count=active_count,
- max_change_item=max_change_item,
- max_change_value=max_change_value,
- week_new_products=week_new_products,
- stable_count=stable_count,
- most_active_category=most_active_category,
- most_active_count=most_active_count)
- except Exception as e:
- sys_log.error(f"[Web] [Dashboard] 🚨 渲染錯誤 | Error: {e}")
- return f"系統維護中,錯誤詳情:{e}"
- finally:
- session.close()
-
@app.route('/settings')
def settings():
"""分類設定頁面"""
diff --git a/auth.py b/auth.py
index 52a536b..4fc2c5b 100644
--- a/auth.py
+++ b/auth.py
@@ -219,7 +219,7 @@ def role_required(*roles):
if user_role not in roles:
# 權限不足,返回 403
flash('您沒有權限存取此頁面', 'danger')
- return redirect(url_for('index'))
+ return redirect(url_for('dashboard.index'))
return f(*args, **kwargs)
return decorated_view
@@ -313,7 +313,7 @@ def init_auth_routes(app):
clear_login_attempts(client_ip)
print(f"✅ 登入成功 | IP: {client_ip}")
- return redirect(url_for('index'))
+ return redirect(url_for('dashboard.index'))
else:
# 登入失敗
is_now_locked = record_login_failure(client_ip)
@@ -351,4 +351,4 @@ def init_auth_routes(app):
print(f"👋 使用者已登出 | IP: {client_ip}")
return redirect(url_for('login'))
-print("✅ Auth 模組已載入(增強安全版本)")
\ No newline at end of file
+print("✅ Auth 模組已載入(增強安全版本)")
diff --git a/templates/403.html b/templates/403.html
index 761bbfa..5bb5127 100644
--- a/templates/403.html
+++ b/templates/403.html
@@ -79,7 +79,7 @@
您沒有權限存取此頁面。
如需存取,請聯繫系統管理員。
-
+
返回首頁
diff --git a/templates/change_password.html b/templates/change_password.html
index 44024d2..c640e64 100644
--- a/templates/change_password.html
+++ b/templates/change_password.html
@@ -62,7 +62,7 @@
-
+
取消