Spaces:
Sleeping
Sleeping
| import gradio as gr | |
| import os | |
| import pandas as pd | |
| import requests | |
| from supabase import create_client, Client | |
| from datetime import datetime, timedelta, timezone | |
| # 設定台北時區 | |
| 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") | |
| PUBLIC_SPACE_URL = "https://deeplearning101-ciecietaipei.hf.space" | |
| # 取得帳密 | |
| 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) | |
| 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: | |
| return pd.DataFrame() | |
| # 🔥🔥🔥 核心後端:智慧判斷發送邏輯 (修正 LINE 連結) 🔥🔥🔥 | |
| def send_confirmation_hybrid(booking_id): | |
| if not booking_id: return "❌ 請輸入訂單 ID" | |
| try: | |
| # 1. 撈資料 | |
| 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', '') | |
| # 連結 | |
| 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}: " | |
| # 2. 判斷模式 | |
| is_reminder = "確認" in current_status | |
| if is_reminder: | |
| # --- 🔔 提醒模式 (Reminder) --- | |
| action_label = "提醒" | |
| mail_subject = f"🔔 行前提醒: {booking['date']} - Cié Cié Taipei" | |
| # 🔥 LINE 修正:加入取消連結,方便客人臨時取消 | |
| line_text = ( | |
| f"🔔 行前提醒\n\n" | |
| f"{booking['name']} 您好,期待今晚與您相見!\n\n" | |
| f"📅 日期:{booking['date']}\n" | |
| f"⏰ 時間:{booking['time']}\n" | |
| f"👥 人數:{booking['pax']} 位\n\n" | |
| f"座位已為您準備好。\n" | |
| f"若無法前來,請點擊下方連結取消:\n{cancel_link}" | |
| ) | |
| mail_html = f""" | |
| <div style="background:#1a1a1a; color:#d4af37; padding:20px; border-radius:8px; font-family:sans-serif;"> | |
| <h2 style="text-align:center; border-bottom:1px solid #444; padding-bottom:15px;">Cié Cié Taipei</h2> | |
| <p style="color:#eee; font-size:16px;"><strong>{booking['name']}</strong> 您好,行前提醒:</p> | |
| <div style="background:#2a2a2a; padding:15px; border-radius:8px; margin:20px 0; border-left:4px solid #f1c40f;"> | |
| <ul style="color:#ddd; padding-left:20px; line-height:1.8;"> | |
| <li>📅 日期:<strong style="color:#fff;">{booking['date']}</strong></li> | |
| <li>⏰ 時間:<strong style="color:#fff;">{booking['time']}</strong></li> | |
| <li>👥 人數:<strong style="color:#fff;">{booking['pax']} 位</strong></li> | |
| </ul> | |
| </div> | |
| <div style="text-align:center; margin-top:30px;"> | |
| <span style="color:#888;">無需再次確認。</span><br><br> | |
| <a href="{cancel_link}" style="display:inline-block; border:1px solid #555; color:#aaa; padding:10px 20px; text-decoration:none; border-radius:50px; font-size:12px;">若無法前來,請點此取消</a> | |
| </div> | |
| </div> | |
| """ | |
| else: | |
| # --- 🚀 確認模式 (Confirmation) --- | |
| action_label = "確認" | |
| mail_subject = f"訂位確認: {booking['date']} - Cié Cié Taipei" | |
| # 🔥 LINE 修正:加入 確認 與 取消 的連結 | |
| line_text = ( | |
| f"✅ 訂位確認\n\n" | |
| f"{booking['name']} 您好,已收到您的預約。\n\n" | |
| f"📅 日期:{booking['date']}\n" | |
| f"⏰ 時間:{booking['time']}\n" | |
| f"👥 人數:{booking['pax']} 位\n\n" | |
| f"請點擊下方連結確認出席:\n" | |
| f"👉 確認:{confirm_link}\n\n" | |
| f"🚫 取消:{cancel_link}" | |
| ) | |
| mail_html = f""" | |
| <div style="background:#1a1a1a; color:#d4af37; padding:20px; border-radius:8px; font-family:sans-serif;"> | |
| <h2 style="text-align:center; border-bottom:1px solid #444; padding-bottom:15px;">Cié Cié Taipei</h2> | |
| <p style="color:#eee; font-size:16px;"><strong>{booking['name']}</strong> 您好,請確認您的訂位:</p> | |
| <div style="background:#2a2a2a; padding:15px; border-radius:8px; margin:20px 0; border-left:4px solid #2ecc71;"> | |
| <ul style="color:#ddd; padding-left:20px; line-height:1.8;"> | |
| <li>📅 日期:<strong style="color:#fff;">{booking['date']}</strong></li> | |
| <li>⏰ 時間:<strong style="color:#fff;">{booking['time']}</strong></li> | |
| <li>👥 人數:<strong style="color:#fff;">{booking['pax']} 位</strong></li> | |
| </ul> | |
| </div> | |
| <div style="text-align:center; margin-top:30px;"> | |
| <a href="{confirm_link}" style="display:inline-block; background:#d4af37; color:#000; padding:12px 30px; text-decoration:none; border-radius:50px; font-weight:bold; margin-right:10px;">✅ 確認出席</a> | |
| <a href="{cancel_link}" style="display:inline-block; border:1px solid #555; color:#aaa; padding:11px 29px; text-decoration:none; border-radius:50px;">🚫 取消</a> | |
| </div> | |
| </div> | |
| """ | |
| # 3. 執行發送 | |
| 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({action_label}) " | |
| except Exception as e: log_msg += f"❌ MailErr({str(e)}) " | |
| else: | |
| log_msg += "⚠️ 無Mail " | |
| # LINE | |
| 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({action_label}) " | |
| else: | |
| log_msg += f"❌ LINE失敗({r.status_code}: {r.text}) " | |
| except Exception as e: log_msg += f"❌ LINEErr({str(e)}) " | |
| else: | |
| log_msg += "⚠️ 無LINE ID " | |
| # 4. 更新狀態 (僅在非提醒模式下更新) | |
| if not is_reminder: | |
| supabase.table("bookings").update({"status": "已發確認信"}).eq("id", booking_id).execute() | |
| return log_msg | |
| except Exception as e: return f"嚴重錯誤: {str(e)}" | |
| # 🔥🔥🔥 卡片渲染 (純顯示,無 JS 互動) 🔥🔥🔥 | |
| def render_booking_cards(): | |
| df = get_bookings() | |
| count_html = f"<div style='color:#bbb; margin-bottom:10px; text-align:right; font-size:14px;'>📊 共 <span style='color:#fff; font-weight:bold;'>{len(df)}</span> 筆資料</div>" | |
| if df.empty: return f"{count_html}<div style='text-align:center; padding:60px; color:#666; font-size:1.5em;'>📭 目前沒有訂位資料</div>" | |
| cards_html = f"{count_html}<div style='display: flex; flex-direction: column; gap: 20px; padding-bottom: 20px;'>" | |
| for index, row in df.iterrows(): | |
| status = row.get('status', '待處理') | |
| # 顏色邏輯 | |
| 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" | |
| card = f""" | |
| <div class="booking-card" style=" | |
| background: #1a1a1a; | |
| border-left: 6px solid {border_color}; | |
| border-radius: 12px; | |
| padding: 20px; | |
| box-shadow: 0 4px 15px rgba(0,0,0,0.5); | |
| font-family: '微軟正黑體', sans-serif; | |
| position: relative;"> | |
| <div style="display:flex; justify-content:space-between; align-items:start; margin-bottom:15px;"> | |
| <div> | |
| <span style="font-size:0.9em; color:#666; font-weight:bold; display:block;">訂單 ID</span> | |
| <span style="font-size:2.5em; color:#fff; font-weight:900; line-height:1; font-family:monospace;">{row['id']}</span> | |
| </div> | |
| <div style="background:{status_bg}; color:{status_tx}; padding:6px 12px; border-radius:4px; font-weight:bold; font-size:0.9em;"> | |
| {status} | |
| </div> | |
| </div> | |
| <div style="display:grid; grid-template-columns: 1fr 1fr; gap:10px; margin-bottom:15px; border-top:1px solid #333; padding-top:15px;"> | |
| <div> | |
| <span style="color:#888; font-size:0.85em;">📅 日期 Date</span><br> | |
| <span style="color:#d4af37; font-size:1.2em; font-weight:bold;">{row['date']}</span> | |
| </div> | |
| <div> | |
| <span style="color:#888; font-size:0.85em;">⏰ 時間 Time</span><br> | |
| <span style="color:#d4af37; font-size:1.5em; font-weight:900;">{row['time']}</span> | |
| </div> | |
| </div> | |
| <div style="background:#222; padding:15px; border-radius:8px; margin-bottom:15px;"> | |
| <div style="margin-bottom:8px;"> | |
| <span style="color:#fff; font-size:1.4em; font-weight:bold;">{row['name']}</span> | |
| <span style="color:#aaa;">({row['pax']}位)</span> | |
| </div> | |
| <div style="font-size:1.1em; margin-bottom:5px;"> | |
| 📞 <a href="tel:{row['tel']}" style="color:#69c0ff; text-decoration:none;">{row['tel']}</a> | |
| </div> | |
| <div style="font-size:0.9em; color:#888;">✉️ {row['email'] or '-'}</div> | |
| </div> | |
| <div style="background:#f1c40f11; padding:12px; border-radius:6px; border:1px solid #f1c40f33;"> | |
| <span style="color:#aaa; font-size:0.8em;">📝 備註:</span> | |
| <span style="color:#f1c40f;">{row.get('remarks') or '無'}</span> | |
| </div> | |
| </div> | |
| """ | |
| cards_html += card | |
| cards_html += "</div>" | |
| return cards_html | |
| # --- 登入邏輯 --- | |
| def check_login(user, password): | |
| if user == REAL_ADMIN_USER and password == REAL_ADMIN_PASSWORD: | |
| return { | |
| login_row: gr.update(visible=False), | |
| admin_row: gr.update(visible=True), | |
| error_msg: "" | |
| } | |
| else: return {error_msg: "<span style='color: red'>❌ 帳號或密碼錯誤</span>"} | |
| # --- CSS --- | |
| 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="Admin") as demo: | |
| with gr.Group(visible=True) as login_row: | |
| gr.Markdown("# 🔒 Login") | |
| with gr.Row(): | |
| username_input = gr.Textbox(label="User") | |
| password_input = gr.Textbox(label="Pass", type="password") | |
| login_btn = gr.Button("Enter", variant="primary") | |
| error_msg = gr.Markdown("") | |
| with gr.Group(visible=False) as admin_row: | |
| # 🔥 操作區 (固定在頂部) | |
| with gr.Column(elem_id="op-panel"): | |
| gr.Markdown("### 🍷 Cié Cié 訂位管理") | |
| with gr.Row(): | |
| id_input = gr.Number(label="輸入 ID 發送通知", precision=0, scale=2) | |
| send_btn = gr.Button("🚀 發送通知 / 提醒 (Hybrid)", variant="primary", scale=1) | |
| refresh_btn = gr.Button("🔄 刷新列表", scale=1) | |
| log_output = gr.Textbox(label="執行結果", lines=1) | |
| # 卡片顯示區 | |
| booking_display = gr.HTML(elem_id="booking_display") | |
| login_btn.click(check_login, inputs=[username_input, password_input], outputs=[login_row, admin_row, error_msg]).then( | |
| render_booking_cards, outputs=booking_display | |
| ) | |
| 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 | |
| ) | |
| demo.launch(css=custom_css) | |
| if __name__ == "__main__": | |
| demo.launch() |