import gradio as gr import os import pandas as pd import requests from supabase import create_client, Client from datetime import datetime, timedelta, timezone import json import base64 import hashlib import hmac import uuid import urllib.parse # --- 設定時區與環境變數 --- TAIPEI_TZ = timezone(timedelta(hours=8)) SUPABASE_URL = os.getenv("SUPABASE_URL") SUPABASE_KEY = os.getenv("SUPABASE_KEY") GAS_MAIL_URL = os.getenv("GAS_MAIL_URL") LINE_ACCESS_TOKEN = os.getenv("LINE_ACCESS_TOKEN") # --- 新增:LINE Pay 查詢用金鑰 --- LINE_PAY_CHANNEL_ID = os.getenv("LINE_PAY_CHANNEL_ID", "") LINE_PAY_CHANNEL_SECRET = os.getenv("LINE_PAY_CHANNEL_SECRET", "") LINE_PAY_BASE_URL = "https://sandbox-api-pay.line.me" # 改回使用 GitHub Pages 網址,確保客人點擊信件能回到充滿設計感的前台 PUBLIC_SPACE_URL = os.getenv("HF_SPACE_URL", "https://ciecietaipei.github.io/booking.html") REAL_ADMIN_USER = os.getenv("ADMIN_USER") or "Deep Learning 101" REAL_ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD") or "2016-11-11" supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY) # ========================================== # 模組 0:信件確認 Webhook (網址參數攔截) # ========================================== def check_url_action(request: gr.Request): """當客人點擊信件裡的連結來到此頁面時,攔截 action 參數並處理,隱藏登入畫面""" if not request: return gr.update(visible=False), gr.update(visible=True) action = request.query_params.get('action') bid = request.query_params.get('id') if action == 'confirm' and bid: try: supabase.table("bookings").update({"status": "顧客已確認"}).eq("id", bid).execute() msg = f"

✅ 訂位已成功確認!

感謝您的回覆,期待您的光臨。您現在可以關閉此視窗。

" return gr.update(value=msg, visible=True), gr.update(visible=False) except Exception as e: return gr.update(value=f"❌ 處理失敗: {str(e)}", visible=True), gr.update(visible=False) elif action == 'cancel' and bid: try: supabase.table("bookings").update({"status": "顧客已取消"}).eq("id", bid).execute() msg = f"

🚫 訂位已取消。

期待下次為您服務。您現在可以關閉此視窗。

" return gr.update(value=msg, visible=True), gr.update(visible=False) except Exception as e: return gr.update(value=f"❌ 處理失敗: {str(e)}", visible=True), gr.update(visible=False) # 若沒有參數,則正常顯示老闆登入畫面 return gr.update(visible=False), gr.update(visible=True) # ========================================== # 模組 1:訂位與通知管理 (包含 No-Show 切換與手動狀態) # ========================================== def get_bookings(): try: res = supabase.table("bookings").select("*").order("created_at", desc=True).execute() if not res.data: return pd.DataFrame() return pd.DataFrame(res.data) except Exception as e: print("Fetch bookings error:", e) return pd.DataFrame() def send_confirmation_hybrid(booking_id): if not booking_id: return "❌ 請輸入訂單 ID" try: res = supabase.table("bookings").select("*").eq("id", booking_id).execute() if not res.data: return f"❌ 找不到 ID: {booking_id}" booking = res.data[0] email = booking.get('email') user_id = booking.get('user_id') current_status = booking.get('status', '') remarks = booking.get('remarks', '') confirm_link = f"{PUBLIC_SPACE_URL}?id={booking_id}&action=confirm" cancel_link = f"{PUBLIC_SPACE_URL}?id={booking_id}&action=cancel" log_msg = f"🆔 {booking_id} 處理結果: " is_reminder = "確認" in current_status is_pending_payment = "待付款" in current_status # 🌟 處理補付款/催繳情境 🌟 if is_pending_payment: # 從備註中萃取原本的訂單號 order_id = None for line in remarks.split('\n'): if "訂單號:" in line: order_id = line.split(":")[-1].strip() payment_link = "" if order_id: # 這裡是一個簡化的重製付款連結邏輯 # 實際應用中,您可以呼叫 FastAPI 的一個專屬端點來重新產生有效的 LINE Pay 連結 # 為了演示,我們假設這個連結可以直接導向您前台的特定補付款頁面 payment_link = f"{PUBLIC_SPACE_URL}?action=repay&order_id={order_id}" mail_subject = f"💳 提醒付款: {booking['date']} 訂單 - Cié Cié Taipei" line_text = (f"💳 付款提醒\n\n{booking['name']} 您好,您有一筆訂單尚未完成付款。\n\n" f"📅 日期:{booking['date']}\n⏰ 時間:{booking['time']}\n\n" f"👉 請點擊下方連結完成線上結帳:\n{payment_link}\n\n" f"如已付款請忽略此訊息。") mail_html = f"""

Cié Cié Taipei

{booking['name']} 您好,您的訂單尚未完成付款:

💳 前往完成付款
""" elif is_reminder: # --- 🔔 提醒模式 --- mail_subject = f"🔔 行前提醒: {booking['date']} - Cié Cié Taipei" line_text = (f"🔔 行前提醒\n\n{booking['name']} 您好,期待今晚與您相見!\n\n" f"📅 日期:{booking['date']}\n⏰ 時間:{booking['time']}\n👥 人數:{booking['pax']} 位\n\n" f"座位已為您準備好,若需變更請聯繫我們。") mail_html = f"""

Cié Cié Taipei

{booking['name']} 您好,行前提醒:

無需再次確認。

若無法前來,請點此取消
""" else: # --- 🚀 確認模式 --- mail_subject = f"訂位確認: {booking['date']} - Cié Cié Taipei" line_text = (f"✅ 訂位確認\n\n{booking['name']} 您好,已收到您的預約。\n\n" f"📅 日期:{booking['date']}\n⏰ 時間:{booking['time']}\n👥 人數:{booking['pax']} 位\n\n" f"👉 請務必點擊下方連結「確認出席」,謝謝!\n{confirm_link}") mail_html = f"""

Cié Cié Taipei

{booking['name']} 您好,請確認您的訂位:

✅ 確認出席 🚫 取消
""" # 執行發送 if email and "@" in email and GAS_MAIL_URL: try: requests.post(GAS_MAIL_URL, json={"to": email, "subject": mail_subject, "htmlBody": mail_html, "name": "Cié Cié Taipei"}) log_msg += f"✅ Mail送出 " except: log_msg += f"❌ Mail失敗 " else: log_msg += "⚠️ 無Mail " if user_id and len(str(user_id)) > 5 and LINE_ACCESS_TOKEN: try: r = requests.post("https://api.line.me/v2/bot/message/push", headers={"Authorization": f"Bearer {LINE_ACCESS_TOKEN}"}, json={"to": user_id, "messages": [{"type": "text", "text": line_text}]}) if r.status_code == 200: log_msg += f"✅ LINE送出 " else: log_msg += f"❌ LINE失敗 " except: log_msg += f"❌ LINE錯誤 " else: log_msg += "⚠️ 無LINE ID " if not is_reminder and not is_pending_payment: supabase.table("bookings").update({"status": "已發確認信"}).eq("id", booking_id).execute() return log_msg except Exception as e: return f"嚴重錯誤: {str(e)}" def toggle_no_show(booking_id, is_noshow=True): """標記或撤銷 No-Show 狀態""" if not booking_id: return "❌ 請輸入訂單 ID" status_text = "No-Show" if is_noshow else "待處理" try: supabase.table("bookings").update({"status": status_text}).eq("id", booking_id).execute() if is_noshow: return f"🚫 訂單 {booking_id} 已成功標記為 No-Show 黑名單!" else: return f"✅ 訂單 {booking_id} 已撤銷 No-Show,恢復為「待處理」。" except Exception as e: return f"❌ 錯誤: {str(e)}" # 🌟 新增:手動強制更新狀態 🌟 def update_booking_status(booking_id, new_status): if not booking_id: return "❌ 請輸入訂單 ID" if not new_status: return "⚠️ 請選擇要更改的新狀態" try: res = supabase.table("bookings").update({"status": new_status}).eq("id", booking_id).execute() if not res.data: raise Exception("找不到該筆訂單或權限不足") return f"✅ 訂單 {booking_id} 狀態已成功更新為:【{new_status}】" except Exception as e: return f"❌ 狀態更新失敗: {str(e)}" # 🌟 新增:向 LINE Pay 總部查詢真實付款狀態 🌟 def check_linepay_status(booking_id): if not booking_id: return "❌ 請輸入訂單 ID" if not LINE_PAY_CHANNEL_SECRET: return "⚠️ 系統未設定 LINE Pay 金鑰,無法查詢" try: # 1. 從資料庫抓取這筆訂單的備註,找出專屬訂單號 (ORDER-XXXX) res = supabase.table("bookings").select("*").eq("id", booking_id).execute() if not res.data: return f"❌ 找不到訂單 ID: {booking_id}" remarks = res.data[0].get('remarks', '') order_id = None for line in remarks.split('\n'): if "訂單號:" in line: order_id = line.split(":")[-1].strip() if not order_id: return "⚠️ 此筆訂單沒有 LINE Pay 訂單號 (可能為免訂金或舊訂單)" # 2. 生成 LINE Pay V3 查詢專用加密簽章 uri = "/v3/payments" query_string = urllib.parse.urlencode({"orderId": order_id}) nonce = str(uuid.uuid4()) message = LINE_PAY_CHANNEL_SECRET + uri + query_string + nonce signature = base64.b64encode(hmac.new(LINE_PAY_CHANNEL_SECRET.encode(), message.encode(), hashlib.sha256).digest()).decode() headers = { "Content-Type": "application/json", "X-LINE-ChannelId": LINE_PAY_CHANNEL_ID, "X-LINE-Authorization-Nonce": nonce, "X-LINE-Authorization": signature } # 3. 發送查詢請求 r = requests.get(f"{LINE_PAY_BASE_URL}{uri}?{query_string}", headers=headers) res_data = r.json() # 4. 解析 LINE Pay 回傳的結果 if res_data.get("returnCode") == "0000": info = res_data.get("info", []) if not info: return f"ℹ️ LINE Pay 回報:此訂單尚未有交易紀錄 (客人可能未掃碼或已關閉結帳網頁)" tx = info[0] status_map = { "AUTHORIZATION": "授權中", "PAYMENT": "✅ 付款成功", "CANCELED": "🚫 已取消", "FAIL": "❌ 付款失敗" } status_text = status_map.get(tx.get("transactionType"), tx.get("transactionType")) amount = tx.get("payInfo", [{}])[0].get("amount", 0) return f"🔍 LINE Pay 查詢結果: [{status_text}] 金額: ${amount} (交易序號: {tx.get('transactionId')})" else: return f"❌ 查詢失敗: {res_data.get('returnMessage')}" except Exception as e: return f"❌ 系統錯誤: {str(e)}" def render_booking_cards(): df = get_bookings() count_html = f"
📊 共 {len(df)} 筆資料
" if df.empty: return f"{count_html}
📭 目前沒有訂位資料
" cards_html = f"{count_html}
" for index, row in df.iterrows(): status = row.get('status', '待處理') has_line = bool(row.get('user_id') and len(str(row.get('user_id'))) > 5) status_bg = "#ccc"; status_tx = "#000"; border_color = "#444" if '確認' in status: status_bg = "#2ecc71"; status_tx = "#000"; border_color = "#2ecc71" elif '取消' in status: status_bg = "#e74c3c"; status_tx = "#fff"; border_color = "#e74c3c" elif '已發' in status: status_bg = "#f1c40f"; status_tx = "#000"; border_color = "#f1c40f" elif 'No-Show' in status: status_bg = "#000"; status_tx = "#e74c3c"; border_color = "#e74c3c" elif '付款' in status: status_bg = "#3498db"; status_tx = "#fff"; border_color = "#3498db" line_badge = "LINE綁定" if has_line else "" card = f"""
訂單 ID {row['id']}
{status}
{line_badge}
📅 日期
{row['date']}
⏰ 時間
{row['time']}
{row['name']} ({row['pax']}位)
📞 {row['tel']}
✉️ {row['email'] or '-'}
📝 備註: {row.get('remarks') or '無'}
""" cards_html += card cards_html += "
" return cards_html # ========================================== # 模組 2:菜單動態管理 (支援編輯、修改、上下架) # ========================================== def get_menu_items(): try: res = supabase.table("menu_items").select("*").order("category").order("created_at").execute() if not res.data: return pd.DataFrame(columns=['餐點名稱', '價格', '分類', '供應時段', '可外帶', '預付規則', '狀態', '照片連結']) df = pd.DataFrame(res.data) df['available_times'] = df['available_times'].apply(lambda x: "、".join(x) if isinstance(x, list) else "全時段") df['allow_takeout'] = df['allow_takeout'].apply(lambda x: "✅" if x else "❌") df['require_prepay'] = df['require_prepay'].apply(lambda x: "🔥 需預付" if x else "一般") df['is_active'] = df['is_active'].apply(lambda x: "🟢 販售中" if x else "🔴 已下架") df['has_image'] = df.get('image_url', pd.Series()).apply(lambda x: "🔗 有" if pd.notnull(x) and str(x).strip() else "無") display_df = df[['name', 'price', 'category', 'available_times', 'allow_takeout', 'require_prepay', 'is_active', 'has_image']] display_df.columns = ['餐點名稱', '價格', '分類', '供應時段', '可外帶', '預付規則', '狀態', '照片連結'] return display_df except Exception as e: print("Fetch menu error:", e) return pd.DataFrame(columns=['餐點名稱', '價格', '分類', '供應時段', '可外帶', '預付規則', '狀態', '照片連結']) def update_menu_dropdown(): try: res = supabase.table("menu_items").select("id, name, is_active").execute() if not res.data: return gr.update(choices=[]) choices = [f"[{'🟢販售中' if item['is_active'] else '🔴已下架'}] {item['name']} | {item['id']}" for item in res.data] return gr.update(choices=choices) except: return gr.update(choices=[]) # --- 新增:將選擇的餐點資料載入到左側表單 --- def load_menu_data(selected_string): if not selected_string: return "", "", 0, "main", ["晚餐 (18:30-21:30)", "宵夜 (21:30後)"], False, True, "", "" item_id = selected_string.split(" | ")[-1].strip() try: res = supabase.table("menu_items").select("*").eq("id", item_id).execute() if not res.data: return "", "", 0, "main", ["晚餐 (18:30-21:30)", "宵夜 (21:30後)"], False, True, "", "" item = res.data[0] times = item.get("available_times", []) if not times: times = ["晚餐 (18:30-21:30)", "宵夜 (21:30後)"] # 預設值防呆 return ( item.get("name", ""), item.get("description", ""), item.get("price", 0), item.get("category", "main"), times, item.get("require_prepay", False), item.get("allow_takeout", True), item.get("image_url", "") or "", item_id # 將 ID 存入隱藏狀態中 ) except: return "", "", 0, "main", ["晚餐 (18:30-21:30)", "宵夜 (21:30後)"], False, True, "", "" def add_menu_item(name, desc, price, category, available_times, require_prepay, allow_takeout, image_url_input): if not name or not price: return "⚠️ 名稱與價格為必填", get_menu_items() if not available_times: return "⚠️ 請至少選擇一個供應時段", get_menu_items() final_image_url = image_url_input.strip() if image_url_input and str(image_url_input).strip() else None try: data = { "name": name, "description": desc, "price": int(price), "category": category, "available_times": available_times, "allow_takeout": allow_takeout, "require_prepay": require_prepay, "is_active": True, "image_url": final_image_url } supabase.table("menu_items").insert(data).execute() return f"✅ 成功新增上架:{name}", get_menu_items() except Exception as e: return f"❌ 錯誤: {str(e)}", get_menu_items() # --- 新增:儲存修改邏輯 --- def update_menu_item(item_id, name, desc, price, category, available_times, require_prepay, allow_takeout, image_url_input): if not item_id: return "⚠️ 請先從右側選擇並「載入編輯」一項餐點", get_menu_items() if not name or not price: return "⚠️ 名稱與價格為必填", get_menu_items() final_image_url = image_url_input.strip() if image_url_input and str(image_url_input).strip() else None try: data = { "name": name, "description": desc, "price": int(price), "category": category, "available_times": available_times, "allow_takeout": allow_takeout, "require_prepay": require_prepay, "image_url": final_image_url } supabase.table("menu_items").update(data).eq("id", item_id).execute() return f"💾 成功儲存修改:{name}", get_menu_items() except Exception as e: return f"❌ 錯誤: {str(e)}", get_menu_items() def toggle_menu_item(selected_string, is_active): if not selected_string: return "⚠️ 請選擇餐點", get_menu_items() try: item_id = selected_string.split(" | ")[-1].strip() supabase.table("menu_items").update({"is_active": is_active}).eq("id", item_id).execute() status_text = "上架" if is_active else "下架" return f"✅ 餐點狀態已更新為:{status_text}", get_menu_items() except Exception as e: return f"❌ 錯誤: {str(e)}", get_menu_items() # ========================================== # Gradio 介面建構 # ========================================== def check_login(user, password): if user == REAL_ADMIN_USER and password == REAL_ADMIN_PASSWORD: return { login_row: gr.update(visible=False), admin_tabs: gr.update(visible=True), error_msg: "" } return { error_msg: "❌ 帳號或密碼錯誤" } custom_css = """ body, .gradio-container { background-color: #0F0F0F; color: #fff; } #op-panel { position: sticky; top: 0; z-index: 100; background: #1a1a1a; border-bottom: 2px solid #d4af37; padding: 15px; margin-bottom: 20px; box-shadow: 0 4px 10px rgba(0,0,0,0.5); } #booking_display { height: auto !important; overflow: visible !important; } """ with gr.Blocks(title="Cié Cié Admin", css=custom_css, theme=gr.themes.Monochrome()) as demo: # 網址參數處理 (信件確認 Webhook) url_action_msg = gr.HTML(visible=False) with gr.Group(visible=True) as login_row: gr.Markdown("# 🔒 老闆/管理員登入") with gr.Row(): username_input = gr.Textbox(label="使用者名稱") password_input = gr.Textbox(label="密碼", type="password") login_btn = gr.Button("登入系統", variant="primary") error_msg = gr.Markdown("") with gr.Tabs(visible=False) as admin_tabs: # --- 分頁 1:訂位與通知管理 --- with gr.TabItem("🍷 訂位與通知管理"): with gr.Column(elem_id="op-panel"): gr.Markdown("### 🚀 訂單操作控制台") # 第一排:ID 輸入與狀態變更選單 with gr.Row(): id_input = gr.Number(label="輸入訂單 ID", precision=0, scale=1) new_status_dropdown = gr.Dropdown( label="手動選擇新狀態", choices=["待處理", "待處理 (已付訂金)", "待付款", "已發確認信", "顧客已確認", "顧客已取消", "No-Show", "已完成 (結案)"], scale=1 ) # 第二排:各式操作按鈕 with gr.Row(): send_btn = gr.Button("🚀 發信/LINE", variant="primary", scale=1) update_status_btn = gr.Button("💾 變更狀態", variant="secondary", scale=1) check_lp_btn = gr.Button("🔍 查 LINE Pay", variant="secondary", scale=1) noshow_btn = gr.Button("🚫 標記 No-Show", variant="stop", scale=1) revert_noshow_btn = gr.Button("✅ 撤銷 No-Show", scale=1) refresh_btn = gr.Button("🔄 刷新列表", scale=1) log_output = gr.Textbox(label="執行結果日誌", lines=1) booking_display = gr.HTML(elem_id="booking_display") # 🌟 新增:手動更新狀態的按鈕事件 🌟 update_status_btn.click( update_booking_status, inputs=[id_input, new_status_dropdown], outputs=log_output ).then(render_booking_cards, outputs=booking_display) check_lp_btn.click(check_linepay_status, inputs=[id_input], outputs=log_output) # 其餘按鈕事件 refresh_btn.click(render_booking_cards, outputs=booking_display) send_btn.click(send_confirmation_hybrid, inputs=id_input, outputs=log_output).then(render_booking_cards, outputs=booking_display) noshow_btn.click(toggle_no_show, inputs=[id_input, gr.State(True)], outputs=log_output).then(render_booking_cards, outputs=booking_display) revert_noshow_btn.click(toggle_no_show, inputs=[id_input, gr.State(False)], outputs=log_output).then(render_booking_cards, outputs=booking_display) # --- 分頁 2:菜單動態管理 --- with gr.TabItem("🍽️ 菜單動態管理"): gr.Markdown("### ✨ 上架與編輯餐點") # 隱藏狀態,用來紀錄正在編輯哪一筆餐點的 ID m_edit_id = gr.State("") with gr.Row(): # 左側:餐點表單 with gr.Column(scale=1): m_name = gr.Textbox(label="餐點名稱 *") m_desc = gr.Textbox(label="餐點描述 (選填)") m_price = gr.Number(label="價格 (TWD) *", precision=0) m_cat = gr.Dropdown(choices=["main", "snack", "drink", "other"], label="分類", value="main") m_image_url = gr.Textbox(label="餐點照片網址 (選填)", placeholder="例如: https://ciecietaipei.github.io/assets/steak.jpg") m_times = gr.CheckboxGroup( choices=["白天 (11:00-18:30)", "晚餐 (18:30-21:30)", "宵夜 (21:30後)"], value=["晚餐 (18:30-21:30)", "宵夜 (21:30後)"], label="🕒 適用時段 *" ) with gr.Row(): m_takeout = gr.Checkbox(label="🛍️ 開放外帶", value=True) m_prepay = gr.Checkbox(label="🔥 需全額預付", value=False) with gr.Row(): m_add_btn = gr.Button("➕ 作為新餐點上架", variant="primary") m_update_btn = gr.Button("💾 儲存修改 (需先載入)", variant="secondary") m_form_log = gr.Textbox(label="執行結果", interactive=False) # 右側:清單與操作 with gr.Column(scale=2): gr.Markdown("### 📋 目前線上菜單") menu_df = gr.Dataframe(interactive=False, wrap=True) m_refresh_btn = gr.Button("🔄 刷新菜單") gr.Markdown("#### ⚙️ 快速操作 (編輯與上下架)") with gr.Row(): m_toggle_dropdown = gr.Dropdown(label="選擇要操作的餐點", choices=[], scale=3) m_load_btn = gr.Button("✏️ 載入編輯", scale=1) m_set_active = gr.Button("🟢 上架", scale=1) m_set_inactive = gr.Button("🔴 下架", scale=1) m_toggle_log = gr.Textbox(label="操作狀態", interactive=False) # 事件綁定:載入編輯資料 m_load_btn.click( load_menu_data, inputs=[m_toggle_dropdown], outputs=[m_name, m_desc, m_price, m_cat, m_times, m_prepay, m_takeout, m_image_url, m_edit_id] ).then(lambda: "✅ 已載入至左側表單,修改後請點擊「儲存修改」", outputs=m_toggle_log) # 事件綁定:新增 / 儲存修改 m_add_btn.click( add_menu_item, inputs=[m_name, m_desc, m_price, m_cat, m_times, m_prepay, m_takeout, m_image_url], outputs=[m_form_log, menu_df] ).then(update_menu_dropdown, outputs=m_toggle_dropdown) m_update_btn.click( update_menu_item, inputs=[m_edit_id, m_name, m_desc, m_price, m_cat, m_times, m_prepay, m_takeout, m_image_url], outputs=[m_form_log, menu_df] ).then(update_menu_dropdown, outputs=m_toggle_dropdown) # 事件綁定:刷新與上下架 m_refresh_btn.click(get_menu_items, outputs=menu_df).then(update_menu_dropdown, outputs=m_toggle_dropdown) m_set_active.click(toggle_menu_item, inputs=[m_toggle_dropdown, gr.State(True)], outputs=[m_toggle_log, menu_df]).then(update_menu_dropdown, outputs=m_toggle_dropdown) m_set_inactive.click(toggle_menu_item, inputs=[m_toggle_dropdown, gr.State(False)], outputs=[m_toggle_log, menu_df]).then(update_menu_dropdown, outputs=m_toggle_dropdown) # 頁面載入攔截 URL 參數 demo.load(check_url_action, inputs=None, outputs=[url_action_msg, login_row]) # 登入事件 login_btn.click( check_login, inputs=[username_input, password_input], outputs=[login_row, admin_tabs, error_msg] ).then( render_booking_cards, outputs=booking_display ).then( get_menu_items, outputs=menu_df ).then( update_menu_dropdown, outputs=m_toggle_dropdown ) if __name__ == "__main__": demo.launch()