refactor(routes): 刪除 app.py 首頁重複路由

ADR-017 Phase 3f-1 dashboard sprint;首頁改由 dashboard_bp 接管,並更新 url_for('index') 相容引用。
This commit is contained in:
OoO
2026-04-29 21:11:45 +08:00
parent aa56479c66
commit 71ea819d06
4 changed files with 5 additions and 298 deletions

293
app.py
View File

@@ -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():
"""分類設定頁面"""

View File

@@ -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 模組已載入(增強安全版本)")
print("✅ Auth 模組已載入(增強安全版本)")

View File

@@ -79,7 +79,7 @@
您沒有權限存取此頁面。<br>
如需存取,請聯繫系統管理員。
</p>
<a href="{{ url_for('index') }}" class="btn-back">
<a href="{{ url_for('dashboard.index') }}" class="btn-back">
<i class="fas fa-home me-2"></i>返回首頁
</a>
</div>

View File

@@ -62,7 +62,7 @@
<button type="submit" class="btn btn-primary" id="submitBtn">
<i class="fas fa-save me-2"></i>變更密碼
</button>
<a href="{{ url_for('index') }}" class="btn btn-secondary">
<a href="{{ url_for('dashboard.index') }}" class="btn btn-secondary">
<i class="fas fa-times me-2"></i>取消
</a>
</div>