refactor(routes): 刪除 app.py 首頁重複路由
ADR-017 Phase 3f-1 dashboard sprint;首頁改由 dashboard_bp 接管,並更新 url_for('index') 相容引用。
This commit is contained in:
293
app.py
293
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():
|
||||
"""分類設定頁面"""
|
||||
|
||||
4
auth.py
4
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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user