| 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() |
|
|
| |
| 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', '') |
| |
| |
| 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 |
|
|
| if is_reminder: |
| |
| action_label = "提醒" |
| mail_subject = f"🔔 行前提醒: {booking['date']} - Cié Cié Taipei" |
| |
| |
| 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: |
| |
| action_label = "確認" |
| mail_subject = f"訂位確認: {booking['date']} - Cié Cié Taipei" |
| |
| |
| 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> |
| """ |
|
|
| |
| |
| 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 " |
| |
| |
| 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 " |
|
|
| |
| 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)}" |
|
|
| |
| 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>"} |
|
|
| |
| 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() |