Update app.py
Browse files
app.py
CHANGED
|
@@ -24,14 +24,11 @@ supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
|
| 24 |
def get_bookings():
|
| 25 |
res = supabase.table("bookings").select("*").order("created_at", desc=True).execute()
|
| 26 |
if not res.data: return pd.DataFrame()
|
| 27 |
-
|
| 28 |
-
cols = ['id', 'date', 'time', 'name', 'tel', 'email', 'pax', 'remarks', 'status', 'user_id']
|
| 29 |
-
for c in cols:
|
| 30 |
-
if c not in df.columns: df[c] = ""
|
| 31 |
-
return df[cols]
|
| 32 |
|
| 33 |
def send_confirmation_hybrid(booking_id):
|
| 34 |
try:
|
|
|
|
| 35 |
res = supabase.table("bookings").select("*").eq("id", booking_id).execute()
|
| 36 |
if not res.data: return "❌ 找不到訂單"
|
| 37 |
booking = res.data[0]
|
|
@@ -41,82 +38,103 @@ def send_confirmation_hybrid(booking_id):
|
|
| 41 |
confirm_link = f"{PUBLIC_SPACE_URL}/?id={booking_id}&action=confirm"
|
| 42 |
cancel_link = f"{PUBLIC_SPACE_URL}/?id={booking_id}&action=cancel"
|
| 43 |
|
| 44 |
-
#
|
| 45 |
-
if
|
|
|
|
|
|
|
| 46 |
try:
|
|
|
|
| 47 |
html = f"""
|
| 48 |
-
<div style="padding: 20px; background: #111; color: #d4af37; border-radius:
|
| 49 |
-
<h2
|
| 50 |
-
<p
|
| 51 |
-
<
|
| 52 |
-
<
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
</div>
|
| 59 |
-
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
| 60 |
-
<tr>
|
| 61 |
-
<td align="center">
|
| 62 |
-
<a href="{confirm_link}" style="display: inline-block; background: #d4af37; color: #000; padding: 12px 30px; text-decoration: none; border-radius: 5px; font-weight: bold; margin-right: 10px;">✅ 確認出席</a>
|
| 63 |
-
<a href="{cancel_link}" style="display: inline-block; border: 1px solid #ff5252; color: #ff5252; padding: 11px 29px; text-decoration: none; border-radius: 5px; font-weight: bold; margin-left: 10px;">🚫 取消</a>
|
| 64 |
-
</td>
|
| 65 |
-
</tr>
|
| 66 |
-
</table>
|
| 67 |
-
<hr style="border: 0; border-top: 1px solid #333; margin-top: 30px;">
|
| 68 |
-
<p style="color: #666; font-size: 12px; text-align: center;">如需更改,請直接回覆此信件。</p>
|
| 69 |
</div>
|
| 70 |
"""
|
| 71 |
-
requests.post(GAS_MAIL_URL, json={"to": email, "subject":
|
| 72 |
-
log_msg += f"✅
|
| 73 |
except Exception as e:
|
| 74 |
-
log_msg += f"
|
| 75 |
|
| 76 |
-
#
|
| 77 |
-
if
|
| 78 |
-
log_msg += "| ⚠️ 未設定 LINE_ACCESS_TOKEN"
|
| 79 |
-
elif not user_id or len(str(user_id)) < 10:
|
| 80 |
-
log_msg += "| ℹ️ 無 LINE ID"
|
| 81 |
-
else:
|
| 82 |
try:
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
{"type": "text", "text": "訂位確認", "weight": "bold", "size": "lg", "color": "#ffffff", "align": "center", "margin": "md"},
|
| 94 |
-
{"type": "separator", "margin": "lg", "color": "#444444"},
|
| 95 |
-
{"type": "box", "layout": "vertical", "margin": "lg", "spacing": "sm", "contents": [
|
| 96 |
-
{"type": "box", "layout": "baseline", "spacing": "sm", "contents": [ {"type": "text", "text": "姓名", "color": "#aaaaaa", "size": "sm", "flex": 2}, {"type": "text", "text": f"{booking['name']}", "wrap": True, "color": "#ffffff", "size": "sm", "flex": 4} ]},
|
| 97 |
-
{"type": "box", "layout": "baseline", "spacing": "sm", "contents": [ {"type": "text", "text": "日期", "color": "#aaaaaa", "size": "sm", "flex": 2}, {"type": "text", "text": f"{booking['date']}", "wrap": True, "color": "#ffffff", "size": "sm", "flex": 4} ]},
|
| 98 |
-
{"type": "box", "layout": "baseline", "spacing": "sm", "contents": [ {"type": "text", "text": "時間", "color": "#aaaaaa", "size": "sm", "flex": 2}, {"type": "text", "text": f"{booking['time']}", "wrap": True, "color": "#ffffff", "size": "sm", "flex": 4} ]},
|
| 99 |
-
{"type": "box", "layout": "baseline", "spacing": "sm", "contents": [ {"type": "text", "text": "人數", "color": "#aaaaaa", "size": "sm", "flex": 2}, {"type": "text", "text": f"{booking['pax']} 位", "wrap": True, "color": "#ffffff", "size": "sm", "flex": 4} ]}
|
| 100 |
-
]}
|
| 101 |
-
]
|
| 102 |
-
},
|
| 103 |
-
"footer": {
|
| 104 |
-
"type": "box", "layout": "vertical", "spacing": "sm",
|
| 105 |
-
"contents": [
|
| 106 |
-
{ "type": "button", "style": "primary", "color": "#d4af37", "height": "sm", "action": { "type": "uri", "label": "✅ 確認出席", "uri": confirm_link } },
|
| 107 |
-
{ "type": "button", "style": "secondary", "height": "sm", "color": "#aaaaaa", "action": { "type": "uri", "label": "🚫 取消訂位", "uri": cancel_link } }
|
| 108 |
-
]
|
| 109 |
-
}
|
| 110 |
-
}
|
| 111 |
-
}
|
| 112 |
-
r = requests.post("https://api.line.me/v2/bot/message/push", headers={"Authorization": f"Bearer {LINE_ACCESS_TOKEN}", "Content-Type": "application/json"}, json={"to": user_id, "messages": [flex_payload]})
|
| 113 |
-
if r.status_code == 200: log_msg += "| ✅ LINE Flex ok"
|
| 114 |
-
else: log_msg += f"| ❌ LINE 錯誤: {r.text}"
|
| 115 |
-
except Exception as e: log_msg += f"| ❌ LINE 例外: {e}"
|
| 116 |
-
|
| 117 |
supabase.table("bookings").update({"status": "已發確認信"}).eq("id", booking_id).execute()
|
| 118 |
return log_msg
|
| 119 |
-
except Exception as e: return f"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
|
| 121 |
# --- 登入邏輯 ---
|
| 122 |
def check_login(user, password):
|
|
@@ -131,154 +149,59 @@ def check_login(user, password):
|
|
| 131 |
error_msg: "<span style='color: red'>❌ 帳號或密碼錯誤</span>"
|
| 132 |
}
|
| 133 |
|
| 134 |
-
# ---
|
| 135 |
-
fix_scroll_js = """
|
| 136 |
-
function() {
|
| 137 |
-
const applyFix = () => {
|
| 138 |
-
const root = document.querySelector('#booking_table');
|
| 139 |
-
if (!root) return;
|
| 140 |
-
|
| 141 |
-
// 1. 殺掉外層捲軸 (Red Scrollbar)
|
| 142 |
-
// 找到所有包在表格外面的 div,只要不是直接包著 table 的,通通 hidden
|
| 143 |
-
const allDivs = root.querySelectorAll('div');
|
| 144 |
-
allDivs.forEach(div => {
|
| 145 |
-
// 如果這個 div 裡面還有一個 table-wrap,或者它本身就是外層包裝
|
| 146 |
-
// 而且它不是 table 的直接父層
|
| 147 |
-
const hasInnerTable = div.querySelector('table');
|
| 148 |
-
if (hasInnerTable && window.getComputedStyle(div).overflowX === 'auto') {
|
| 149 |
-
div.style.overflowX = 'hidden';
|
| 150 |
-
div.style.maxWidth = '100%';
|
| 151 |
-
}
|
| 152 |
-
});
|
| 153 |
-
|
| 154 |
-
// 確保最外層也是 hidden
|
| 155 |
-
root.style.overflowX = 'hidden';
|
| 156 |
-
|
| 157 |
-
// 2. 保留內層捲軸 (Green Scrollbar)
|
| 158 |
-
const table = root.querySelector('table');
|
| 159 |
-
if (table) {
|
| 160 |
-
// 強制設定表格寬度,確保內容撐開
|
| 161 |
-
table.style.width = '1500px';
|
| 162 |
-
table.style.minWidth = '1500px';
|
| 163 |
-
table.style.tableLayout = 'fixed';
|
| 164 |
-
|
| 165 |
-
// 找到直接父層 (Green Scrollbar 所在位置)
|
| 166 |
-
const parent = table.parentElement;
|
| 167 |
-
if (parent) {
|
| 168 |
-
parent.style.overflowX = 'auto'; // 開啟
|
| 169 |
-
parent.style.maxWidth = '100vw'; // 限制寬度
|
| 170 |
-
parent.style.display = 'block';
|
| 171 |
-
}
|
| 172 |
-
}
|
| 173 |
-
};
|
| 174 |
-
|
| 175 |
-
// 啟動時執行
|
| 176 |
-
applyFix();
|
| 177 |
-
// 循環執行以對抗 Gradio 的動態渲染
|
| 178 |
-
setInterval(applyFix, 500);
|
| 179 |
-
}
|
| 180 |
-
"""
|
| 181 |
-
|
| 182 |
-
# --- 🔥 [CSS] 定寬 + 換行 + 隱藏全域捲軸 ---
|
| 183 |
custom_css = """
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
overflow-x: hidden !important; /* 殺死瀏覽器捲軸 */
|
| 187 |
-
max-width: 100vw !important;
|
| 188 |
-
}
|
| 189 |
-
|
| 190 |
-
#booking_table {
|
| 191 |
-
overflow: hidden !important; /* 殺死元件捲軸 */
|
| 192 |
-
max-width: 100% !important;
|
| 193 |
-
border: none !important;
|
| 194 |
-
padding: 0 !important;
|
| 195 |
-
}
|
| 196 |
-
|
| 197 |
-
/* 2. 中間層:確保沒有任何中間人偷偷加捲軸 */
|
| 198 |
-
#booking_table .wrap,
|
| 199 |
-
#booking_table .svelte-12cmxck {
|
| 200 |
-
overflow-x: hidden !important;
|
| 201 |
-
max-width: 100% !important;
|
| 202 |
-
}
|
| 203 |
-
|
| 204 |
-
/* 3. 內層 (綠框位置):唯一允許捲動的地方 */
|
| 205 |
-
#booking_table .table-wrap,
|
| 206 |
-
#booking_table tbody {
|
| 207 |
-
overflow-x: auto !important;
|
| 208 |
-
overflow-y: hidden !important;
|
| 209 |
-
max-width: 100vw !important; /* 確保不超過螢幕 */
|
| 210 |
-
border: 1px solid #444 !important;
|
| 211 |
-
}
|
| 212 |
-
|
| 213 |
-
/* 4. 表格本體:撐開它! */
|
| 214 |
-
#booking_table table {
|
| 215 |
-
table-layout: fixed !important;
|
| 216 |
-
width: 1500px !important; /* 總寬度 */
|
| 217 |
-
min-width: 1500px !important;
|
| 218 |
-
}
|
| 219 |
-
|
| 220 |
-
/* 5. 欄位內容:自動換行 */
|
| 221 |
-
#booking_table th, #booking_table td {
|
| 222 |
-
white-space: normal !important;
|
| 223 |
-
word-break: break-all !important;
|
| 224 |
-
overflow-wrap: break-word !important;
|
| 225 |
-
vertical-align: top !important;
|
| 226 |
-
padding: 8px 5px !important;
|
| 227 |
-
border: 1px solid #444 !important;
|
| 228 |
-
font-size: 13px !important;
|
| 229 |
-
line-height: 1.4 !important;
|
| 230 |
-
}
|
| 231 |
-
|
| 232 |
-
/* 6. 個別欄位寬度 */
|
| 233 |
-
#booking_table th:nth-child(1), #booking_table td:nth-child(1) { width: 60px !important; }
|
| 234 |
-
#booking_table th:nth-child(2), #booking_table td:nth-child(2) { width: 170px !important; }
|
| 235 |
-
#booking_table th:nth-child(3), #booking_table td:nth-child(3) { width: 80px !important; }
|
| 236 |
-
#booking_table th:nth-child(4), #booking_table td:nth-child(4) { width: 120px !important; }
|
| 237 |
-
#booking_table th:nth-child(5), #booking_table td:nth-child(5) { width: 120px !important; }
|
| 238 |
-
#booking_table th:nth-child(6), #booking_table td:nth-child(6) { width: 250px !important; }
|
| 239 |
-
#booking_table th:nth-child(7), #booking_table td:nth-child(7) { width: 50px !important; }
|
| 240 |
-
#booking_table th:nth-child(8), #booking_table td:nth-child(8) { width: 180px !important; }
|
| 241 |
-
#booking_table th:nth-child(9), #booking_table td:nth-child(9) { width: 120px !important; }
|
| 242 |
-
#booking_table th:nth-child(10), #booking_table td:nth-child(10) { width: 320px !important; }
|
| 243 |
"""
|
| 244 |
|
| 245 |
-
# ---
|
| 246 |
with gr.Blocks(title="Admin", css=custom_css) as demo:
|
| 247 |
|
| 248 |
-
# 1.
|
| 249 |
with gr.Group(visible=True) as login_row:
|
| 250 |
gr.Markdown("# 🔒 請登入後台")
|
| 251 |
with gr.Row():
|
| 252 |
-
username_input = gr.Textbox(label="帳號
|
| 253 |
-
password_input = gr.Textbox(label="密碼
|
| 254 |
-
login_btn = gr.Button("登入
|
| 255 |
error_msg = gr.Markdown("")
|
| 256 |
|
| 257 |
-
# 2.
|
| 258 |
with gr.Group(visible=False) as admin_row:
|
| 259 |
-
gr.Markdown("# 🍷
|
| 260 |
-
refresh_btn = gr.Button("🔄 重新整理")
|
| 261 |
|
| 262 |
-
|
| 263 |
-
|
| 264 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
with gr.Row():
|
| 266 |
-
id_input = gr.Number(label="
|
| 267 |
action_btn = gr.Button("📧 發送確認信 (Hybrid)", variant="primary")
|
| 268 |
-
log_output = gr.Textbox(label="結果")
|
| 269 |
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
|
| 283 |
if __name__ == "__main__":
|
| 284 |
demo.launch()
|
|
|
|
| 24 |
def get_bookings():
|
| 25 |
res = supabase.table("bookings").select("*").order("created_at", desc=True).execute()
|
| 26 |
if not res.data: return pd.DataFrame()
|
| 27 |
+
return pd.DataFrame(res.data)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
def send_confirmation_hybrid(booking_id):
|
| 30 |
try:
|
| 31 |
+
# 1. 抓取資料
|
| 32 |
res = supabase.table("bookings").select("*").eq("id", booking_id).execute()
|
| 33 |
if not res.data: return "❌ 找不到訂單"
|
| 34 |
booking = res.data[0]
|
|
|
|
| 38 |
confirm_link = f"{PUBLIC_SPACE_URL}/?id={booking_id}&action=confirm"
|
| 39 |
cancel_link = f"{PUBLIC_SPACE_URL}/?id={booking_id}&action=cancel"
|
| 40 |
|
| 41 |
+
# 2. 發送 Email (偵錯版)
|
| 42 |
+
if not GAS_MAIL_URL:
|
| 43 |
+
log_msg += "⚠️ 未設GAS_URL "
|
| 44 |
+
elif email and "@" in email:
|
| 45 |
try:
|
| 46 |
+
# 簡單的 HTML 信件
|
| 47 |
html = f"""
|
| 48 |
+
<div style="padding: 20px; background: #111; color: #d4af37; border-radius: 8px;">
|
| 49 |
+
<h2>Cié Cié Taipei 訂位確認</h2>
|
| 50 |
+
<p>{booking['name']} 您好,已為您保留:</p>
|
| 51 |
+
<ul style="color: #eee;">
|
| 52 |
+
<li>日期:{booking['date']}</li>
|
| 53 |
+
<li>時間:{booking['time']}</li>
|
| 54 |
+
<li>人數:{booking['pax']} 位</li>
|
| 55 |
+
</ul>
|
| 56 |
+
<a href="{confirm_link}" style="background:#d4af37; color:#000; padding:10px 20px; text-decoration:none; border-radius:5px;">確認出席</a>
|
| 57 |
+
<a href="{cancel_link}" style="color:#ff5252; padding:10px 20px;">取消</a>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
</div>
|
| 59 |
"""
|
| 60 |
+
r = requests.post(GAS_MAIL_URL, json={"to": email, "subject": "訂位確認", "htmlBody": html, "name": "Cié Cié Taipei"})
|
| 61 |
+
log_msg += f"✅ Mail({r.status_code}) "
|
| 62 |
except Exception as e:
|
| 63 |
+
log_msg += f"❌ MailErr "
|
| 64 |
|
| 65 |
+
# 3. 發送 LINE (維持原樣)
|
| 66 |
+
if user_id and len(str(user_id)) > 10 and LINE_ACCESS_TOKEN:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
try:
|
| 68 |
+
# 這裡省略詳細 Flex Message 代碼以節省篇幅,維持您原本的邏輯即可
|
| 69 |
+
# 簡單發個文字測試
|
| 70 |
+
requests.post("https://api.line.me/v2/bot/message/push",
|
| 71 |
+
headers={"Authorization": f"Bearer {LINE_ACCESS_TOKEN}"},
|
| 72 |
+
json={"to": user_id, "messages": [{"type": "text", "text": f"✅ 訂位確認\n{booking['date']} {booking['time']}\n請回覆確認,謝謝!"}]})
|
| 73 |
+
log_msg += "✅ LINE "
|
| 74 |
+
except:
|
| 75 |
+
log_msg += "❌ LINEErr"
|
| 76 |
+
|
| 77 |
+
# 4. 更新狀態
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
supabase.table("bookings").update({"status": "已發確認信"}).eq("id", booking_id).execute()
|
| 79 |
return log_msg
|
| 80 |
+
except Exception as e: return f"Error: {e}"
|
| 81 |
+
|
| 82 |
+
# 🔥🔥🔥 核心改變:將 Dataframe 轉為 HTML 卡片 🔥🔥🔥
|
| 83 |
+
def render_booking_cards():
|
| 84 |
+
df = get_bookings()
|
| 85 |
+
if df.empty:
|
| 86 |
+
return "<div style='text-align:center; padding:20px; color:#888;'>📅 目前沒有訂位資料</div>"
|
| 87 |
+
|
| 88 |
+
cards_html = "<div style='display: flex; flex-direction: column; gap: 15px; padding-bottom: 50px;'>"
|
| 89 |
+
|
| 90 |
+
for index, row in df.iterrows():
|
| 91 |
+
# 定義狀態顏色
|
| 92 |
+
status = row.get('status', '待處理')
|
| 93 |
+
if '確認' in status: border_color = '#2ecc71' # 綠色
|
| 94 |
+
elif '取消' in status: border_color = '#e74c3c' # 紅色
|
| 95 |
+
elif '已發' in status: border_color = '#f1c40f' # 黃色
|
| 96 |
+
else: border_color = '#95a5a6' # 灰色
|
| 97 |
+
|
| 98 |
+
# 卡片 HTML 結構
|
| 99 |
+
card = f"""
|
| 100 |
+
<div style="
|
| 101 |
+
background: #262626;
|
| 102 |
+
border-left: 5px solid {border_color};
|
| 103 |
+
border-radius: 8px;
|
| 104 |
+
padding: 15px;
|
| 105 |
+
color: #eee;
|
| 106 |
+
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
|
| 107 |
+
font-family: sans-serif;">
|
| 108 |
+
|
| 109 |
+
<div style="display:flex; justify-content:space-between; margin-bottom:8px;">
|
| 110 |
+
<span style="font-size:1.2em; font-weight:bold; color:#d4af37;">
|
| 111 |
+
{row['time']} <span style="color:#fff; font-size:0.9em;">{row['name']}</span>
|
| 112 |
+
</span>
|
| 113 |
+
<span style="font-size:0.8em; padding:2px 6px; border-radius:4px; background:#333; color:{border_color};">
|
| 114 |
+
{status}
|
| 115 |
+
</span>
|
| 116 |
+
</div>
|
| 117 |
+
|
| 118 |
+
<div style="display:flex; gap:15px; color:#aaa; font-size:0.9em; margin-bottom:8px;">
|
| 119 |
+
<span>📅 {row['date']}</span>
|
| 120 |
+
<span>👤 {row['pax']} 位</span>
|
| 121 |
+
<span>🆔 {row['id']}</span>
|
| 122 |
+
</div>
|
| 123 |
+
|
| 124 |
+
<div style="background:#1a1a1a; padding:8px; border-radius:4px; font-size:0.9em; color:#ccc; margin-bottom:10px;">
|
| 125 |
+
📝 {row.get('remarks') or '無備註'}
|
| 126 |
+
</div>
|
| 127 |
+
|
| 128 |
+
<div style="display:flex; justify-content:space-between; align-items:center; font-size:0.9em;">
|
| 129 |
+
<a href="tel:{row['tel']}" style="color:#3498db; text-decoration:none;">📞 {row['tel']}</a>
|
| 130 |
+
<span style="color:#666; font-size:0.8em;">{row['email']}</span>
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
"""
|
| 134 |
+
cards_html += card
|
| 135 |
+
|
| 136 |
+
cards_html += "</div>"
|
| 137 |
+
return cards_html
|
| 138 |
|
| 139 |
# --- 登入邏輯 ---
|
| 140 |
def check_login(user, password):
|
|
|
|
| 149 |
error_msg: "<span style='color: red'>❌ 帳號或密碼錯誤</span>"
|
| 150 |
}
|
| 151 |
|
| 152 |
+
# --- CSS (極簡化,因為現在用卡片了,不需要處理表格捲軸) ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
custom_css = """
|
| 154 |
+
body, .gradio-container { background-color: #0F0F0F; color: #fff; }
|
| 155 |
+
#booking_display { max-height: 80vh; overflow-y: auto; } /* 讓卡片區可以垂直捲動 */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
"""
|
| 157 |
|
| 158 |
+
# --- 介面 ---
|
| 159 |
with gr.Blocks(title="Admin", css=custom_css) as demo:
|
| 160 |
|
| 161 |
+
# 1. 登入
|
| 162 |
with gr.Group(visible=True) as login_row:
|
| 163 |
gr.Markdown("# 🔒 請登入後台")
|
| 164 |
with gr.Row():
|
| 165 |
+
username_input = gr.Textbox(label="帳號", placeholder="Enter username")
|
| 166 |
+
password_input = gr.Textbox(label="密碼", type="password", placeholder="Enter password")
|
| 167 |
+
login_btn = gr.Button("登入", variant="primary")
|
| 168 |
error_msg = gr.Markdown("")
|
| 169 |
|
| 170 |
+
# 2. 後台
|
| 171 |
with gr.Group(visible=False) as admin_row:
|
| 172 |
+
gr.Markdown("# 🍷 訂位管理 (Card View)")
|
|
|
|
| 173 |
|
| 174 |
+
with gr.Row():
|
| 175 |
+
refresh_btn = gr.Button("🔄 重新整理列表")
|
| 176 |
|
| 177 |
+
# 🔥 顯示卡片的地方 (用 HTML 渲染)
|
| 178 |
+
booking_display = gr.HTML(label="訂位列表", elem_id="booking_display")
|
| 179 |
+
|
| 180 |
+
gr.Markdown("### 🛠️ 操作區")
|
| 181 |
with gr.Row():
|
| 182 |
+
id_input = gr.Number(label="輸入訂單 ID", precision=0)
|
| 183 |
action_btn = gr.Button("📧 發送確認信 (Hybrid)", variant="primary")
|
|
|
|
| 184 |
|
| 185 |
+
log_output = gr.Textbox(label="執行結果")
|
| 186 |
+
|
| 187 |
+
# 事件綁定
|
| 188 |
+
# 1. 登入後自動載入列表
|
| 189 |
+
login_btn.click(check_login, inputs=[username_input, password_input], outputs=[login_row, admin_row, error_msg]).then(
|
| 190 |
+
render_booking_cards, outputs=booking_display
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
# 2. 重新整理
|
| 194 |
+
refresh_btn.click(render_booking_cards, outputs=booking_display)
|
| 195 |
+
|
| 196 |
+
# 3. 發送確認信 -> 發送後自動更新列表 (變色)
|
| 197 |
+
action_btn.click(
|
| 198 |
+
send_confirmation_hybrid,
|
| 199 |
+
inputs=id_input,
|
| 200 |
+
outputs=log_output
|
| 201 |
+
).then(
|
| 202 |
+
render_booking_cards,
|
| 203 |
+
outputs=booking_display
|
| 204 |
+
)
|
| 205 |
|
| 206 |
if __name__ == "__main__":
|
| 207 |
demo.launch()
|