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 @@ - + 取消