const $ = (selector, root = document) => root.querySelector(selector); const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector)); const state = { data: {}, view: "dashboard", resource: "news", editing: null, }; const navItems = [ { view: "dashboard", label: "儀表板" }, { view: "settings", label: "站台設定" }, { view: "news", label: "最新消息", resource: "news" }, { view: "services", label: "服務項目", resource: "services" }, { view: "courses", label: "課程資訊", resource: "courses" }, { view: "albums", label: "活動剪影", resource: "albums" }, { view: "articles", label: "文章資訊", resource: "articles" }, { view: "appointments", label: "預約諮詢" }, { view: "messages", label: "留言管理" }, ]; const resources = { news: { label: "最新消息", columns: ["date", "title", "category", "published"], fields: [ { name: "title", label: "標題", required: true }, { name: "date", label: "日期", type: "date", required: true }, { name: "category", label: "分類" }, { name: "excerpt", label: "摘要", type: "textarea", wide: true }, { name: "image_url", label: "圖片 URL", wide: true }, { name: "sort_order", label: "排序", type: "number" }, { name: "published", label: "前台顯示", type: "checkbox" }, ], }, services: { label: "服務項目", columns: ["group_name", "title", "sort_order", "published"], fields: [ { name: "group_name", label: "服務分組", type: "select", options: ["長照A單位", "長照B單位"], required: true }, { name: "title", label: "服務名稱", required: true }, { name: "icon", label: "識別字" }, { name: "description", label: "簡短說明", type: "textarea", wide: true }, { name: "detail", label: "補充說明", type: "textarea", wide: true }, { name: "sort_order", label: "排序", type: "number" }, { name: "published", label: "前台顯示", type: "checkbox" }, ], }, courses: { label: "課程資訊", columns: ["start_date", "title", "status", "published"], fields: [ { name: "title", label: "課程名稱", required: true }, { name: "category", label: "課程分類" }, { name: "start_date", label: "開始日期", type: "date" }, { name: "status", label: "招生狀態", type: "select", options: ["招生中", "預告", "額滿", "已結束"] }, { name: "summary", label: "課程摘要", type: "textarea", wide: true }, { name: "sort_order", label: "排序", type: "number" }, { name: "published", label: "前台顯示", type: "checkbox" }, ], }, albums: { label: "活動剪影", columns: ["date", "title", "year", "published"], fields: [ { name: "title", label: "活動名稱", required: true }, { name: "year", label: "年度" }, { name: "date", label: "日期", type: "date" }, { name: "image_url", label: "封面圖片 URL", wide: true }, { name: "description", label: "活動說明", type: "textarea", wide: true }, { name: "sort_order", label: "排序", type: "number" }, { name: "published", label: "前台顯示", type: "checkbox" }, ], }, articles: { label: "文章資訊", columns: ["category", "title", "url", "published"], fields: [ { name: "title", label: "標題", required: true }, { name: "category", label: "分類" }, { name: "url", label: "連結 URL", wide: true }, { name: "summary", label: "摘要", type: "textarea", wide: true }, { name: "sort_order", label: "排序", type: "number" }, { name: "published", label: "前台顯示", type: "checkbox" }, ], }, }; const settingFields = [ { name: "brand_name", label: "品牌名稱" }, { name: "company_name", label: "公司名稱" }, { name: "tagline", label: "品牌短句" }, { name: "hero_note", label: "首頁標籤" }, { name: "hero_title", label: "首頁主標", wide: true }, { name: "hero_subtitle", label: "首頁副標", type: "textarea", wide: true }, { name: "hero_image", label: "首頁大圖 URL", wide: true }, { name: "about_title", label: "關於標題" }, { name: "about_text", label: "關於文字", type: "textarea", wide: true }, { name: "service_intro", label: "服務導言", type: "textarea", wide: true }, { name: "service_area", label: "服務區域" }, { name: "office_hours", label: "服務時間" }, { name: "recruit_note", label: "招募文字", type: "textarea", wide: true }, { name: "address", label: "地址", wide: true }, { name: "phone", label: "電話" }, { name: "email", label: "Email" }, { name: "license_number", label: "核准設立字號", wide: true }, { name: "line_url", label: "LINE URL", wide: true }, { name: "facebook_url", label: "Facebook URL", wide: true }, ]; function escapeHTML(value = "") { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } async function api(path, options = {}) { const isFormData = options.body instanceof FormData; const response = await fetch(path, { ...options, headers: isFormData ? { ...(options.headers || {}) } : { "Content-Type": "application/json", ...(options.headers || {}), }, }); const result = await response.json().catch(() => ({})); if (!response.ok) { const error = new Error(result.error || "Request failed"); error.status = response.status; throw error; } return result; } async function refreshData() { state.data = await api("/api/admin/bootstrap"); } function showLogin(message = "") { $("[data-login-panel]").classList.remove("hidden"); $("[data-admin-shell]").classList.add("hidden"); const status = $("[data-login-status]"); status.textContent = message; status.classList.toggle("error", Boolean(message)); } function showShell() { $("[data-login-panel]").classList.add("hidden"); $("[data-admin-shell]").classList.remove("hidden"); } function setHeading(kicker, title) { $("[data-current-kicker]").textContent = kicker; $("[data-current-title]").textContent = title; } function renderNav() { const nav = $("[data-admin-nav]"); nav.innerHTML = navItems .map((item) => ` ${escapeHTML(item.label)} `) .join(""); nav.onclick = (event) => { const link = event.target.closest("[data-view]"); if (!link) return; event.preventDefault(); switchView(link.dataset.view); }; } function hidePanels() { $$("[data-dashboard-panel], [data-settings-panel], [data-resource-panel], [data-appointments-panel], [data-messages-panel]") .forEach((panel) => panel.classList.add("hidden")); } function switchView(view) { state.view = view; const navItem = navItems.find((item) => item.view === view) || navItems[0]; state.resource = navItem.resource || state.resource; state.editing = null; render(); } function render() { renderNav(); hidePanels(); if (state.view === "dashboard") renderDashboard(); else if (state.view === "settings") renderSettings(); else if (state.view === "appointments") renderAppointments(); else if (state.view === "messages") renderMessages(); else renderResource(state.view); } function countPublished(items = []) { return items.filter((item) => Number(item.published) === 1).length; } function renderDashboard() { setHeading("CMS", "儀表板"); const panel = $("[data-dashboard-panel]"); panel.classList.remove("hidden"); const messages = state.data.messages || []; const appointments = state.data.appointments || []; const newMessages = messages.filter((item) => item.status === "new"); const newAppointments = appointments.filter((item) => item.status === "new"); panel.innerHTML = `
已發布消息${countPublished(state.data.news)}
待處理預約${newAppointments.length}
待處理留言${newMessages.length}
前台服務${countPublished(state.data.services)}
課程梯次${countPublished(state.data.courses)}

最近預約諮詢

${appointmentTable(appointments.slice(0, 5), false)}

最近留言

${messageTable(messages.slice(0, 5), false)}
`; const goAppointments = $("[data-go-appointments]", panel); if (goAppointments) goAppointments.addEventListener("click", () => switchView("appointments")); const goMessages = $("[data-go-messages]", panel); if (goMessages) goMessages.addEventListener("click", () => switchView("messages")); } function fieldHTML(field, value = "") { const fieldClass = field.wide ? "wide" : ""; const required = field.required ? "required" : ""; if (field.type === "textarea") { return ` `; } if (field.type === "select") { const options = (field.options || []).map((option) => ` `).join(""); return ` `; } if (field.type === "checkbox") { const checked = value === undefined || Number(value) === 1 || value === true ? "checked" : ""; return ` `; } return ` `; } function renderSettings() { setHeading("SETTINGS", "站台設定"); const panel = $("[data-settings-panel]"); panel.classList.remove("hidden"); const settings = state.data.settings || {}; panel.innerHTML = `

前台基本資料

${settingFields.map((field) => fieldHTML(field, settings[field.name] || "")).join("")}
目前首頁大圖

支援 JPG、PNG、WebP、GIF,單檔上限 8MB。上傳後會自動更新「首頁大圖 URL」。

`; $("[data-settings-form]", panel).addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget; const status = $("[data-settings-status]", form); status.classList.remove("error"); status.textContent = "儲存中..."; const payload = Object.fromEntries(new FormData(form).entries()); try { await api("/api/admin/settings", { method: "POST", body: JSON.stringify(payload) }); await refreshData(); status.textContent = "已儲存。"; } catch (error) { status.classList.add("error"); status.textContent = error.message; } }); const uploadButton = $("[data-upload-hero]", panel); uploadButton.addEventListener("click", async () => { const form = $("[data-settings-form]", panel); const fileInput = $("[data-hero-upload-input]", panel); const status = $("[data-settings-status]", form); const file = fileInput.files[0]; status.classList.remove("error"); if (!file) { status.classList.add("error"); status.textContent = "請先選擇首頁大圖。"; return; } status.textContent = "圖片上傳中..."; const formData = new FormData(); formData.append("image", file); try { const result = await api("/api/admin/upload/hero", { method: "POST", body: formData, }); state.data.settings = result.settings || state.data.settings; form.elements.hero_image.value = result.url; const preview = $("[data-hero-preview]", panel); preview.src = `${result.url}?v=${Date.now()}`; status.textContent = "首頁大圖已上傳並套用。"; fileInput.value = ""; } catch (error) { status.classList.add("error"); status.textContent = error.message; } }); } function formPayload(form, config) { const payload = {}; config.fields.forEach((field) => { const input = form.elements[field.name]; if (!input) return; payload[field.name] = field.type === "checkbox" ? input.checked : input.value; }); return payload; } function displayValue(item, key) { if (key === "published") { return Number(item.published) === 1 ? '顯示' : '隱藏'; } if (key === "url" && item[key]) { return `${escapeHTML(item[key])}`; } return escapeHTML(item[key] || ""); } function renderResource(resource) { const config = resources[resource]; setHeading("CONTENT", config.label); const panel = $("[data-resource-panel]"); panel.classList.remove("hidden"); const items = state.data[resource] || []; const editing = state.editing && state.editing.resource === resource ? state.editing.item : null; panel.innerHTML = `

${editing ? "編輯" : "新增"}${escapeHTML(config.label)}

${editing ? '' : ""}
${config.fields.map((field) => fieldHTML(field, editing ? editing[field.name] : field.type === "checkbox" ? 1 : "")).join("")}

${escapeHTML(config.label)}列表

${config.columns.map((column) => ``).join("")} ${items.map((item) => ` ${config.columns.map((column) => ``).join("")} `).join("")}
${escapeHTML(column)}操作
${displayValue(item, column)}
`; const cancel = $("[data-cancel-edit]", panel); if (cancel) { cancel.addEventListener("click", () => { state.editing = null; renderResource(resource); }); } $("[data-resource-form]", panel).addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget; const status = $("[data-resource-status]", form); status.classList.remove("error"); status.textContent = "儲存中..."; try { const payload = formPayload(form, config); const endpoint = editing ? `/api/admin/${resource}/${editing.id}` : `/api/admin/${resource}`; await api(endpoint, { method: editing ? "PATCH" : "POST", body: JSON.stringify(payload), }); await refreshData(); state.editing = null; renderResource(resource); } catch (error) { status.classList.add("error"); status.textContent = error.message; } }); panel.onclick = async (event) => { const editButton = event.target.closest("[data-edit]"); const deleteButton = event.target.closest("[data-delete]"); if (editButton) { const item = (state.data[resource] || []).find((entry) => String(entry.id) === editButton.dataset.edit); state.editing = { resource, item }; renderResource(resource); } if (deleteButton) { const item = (state.data[resource] || []).find((entry) => String(entry.id) === deleteButton.dataset.delete); if (!item || !window.confirm(`刪除「${item.title || item.name}」?`)) return; await api(`/api/admin/${resource}/${item.id}`, { method: "DELETE" }); await refreshData(); renderResource(resource); } }; } function messageStatusLabel(status) { return { new: "新留言", in_progress: "處理中", closed: "已結案", }[status] || status; } function appointmentStatusLabel(status) { return { new: "新預約", scheduled: "已排程", contacted: "已聯絡", closed: "已結案", canceled: "已取消", }[status] || status; } function appointmentTable(appointments, editable = true) { return ` ${editable ? "" : ""} ${(appointments || []).map((item) => ` ${editable ? ` ` : ""} `).join("")}
送出時間 姓名 聯絡 預約需求 服務資訊 需求說明 狀態內部備註操作
${escapeHTML(item.created_at || "")} ${escapeHTML(item.name || "")} ${escapeHTML(item.phone || "")}
${escapeHTML(item.email || "")}
${escapeHTML(item.preferred_date || "")}
${escapeHTML(item.preferred_time || "")}
${escapeHTML(item.service_type || "")}
${escapeHTML(item.area || "")}
${escapeHTML(item.relation || "")}
${escapeHTML(item.notes || "")} ${editable ? ` ` : escapeHTML(appointmentStatusLabel(item.status))}
`; } function messageTable(messages, editable = true) { return ` ${(messages || []).map((item) => ` `).join("")}
時間 姓名 聯絡 主題 內容 狀態
${escapeHTML(item.created_at || "")} ${escapeHTML(item.name || "")} ${escapeHTML(item.phone || "")}
${escapeHTML(item.email || "")}
${escapeHTML(item.topic || "")} ${escapeHTML(item.message || "")} ${editable ? ` ` : escapeHTML(messageStatusLabel(item.status))}
`; } async function saveAppointment(row) { const id = row.dataset.appointmentId; const status = $(`[data-appointment-status="${id}"]`, row)?.value || "new"; const staffNote = $(`[data-appointment-note="${id}"]`, row)?.value || ""; await api(`/api/admin/appointments/${id}`, { method: "PATCH", body: JSON.stringify({ status, staff_note: staffNote }), }); await refreshData(); } function renderAppointments() { setHeading("BOOKING", "預約諮詢"); const panel = $("[data-appointments-panel]"); panel.classList.remove("hidden"); panel.innerHTML = `

前台預約諮詢

${appointmentTable(state.data.appointments || [], true)}
`; panel.onchange = async (event) => { const select = event.target.closest("[data-appointment-status]"); if (!select) return; const row = select.closest("[data-appointment-id]"); await saveAppointment(row); }; panel.onclick = async (event) => { const button = event.target.closest("[data-save-appointment]"); if (!button) return; const row = button.closest("[data-appointment-id]"); await saveAppointment(row); renderAppointments(); }; } function renderMessages() { setHeading("INBOX", "留言管理"); const panel = $("[data-messages-panel]"); panel.classList.remove("hidden"); panel.innerHTML = `

前台留言

${messageTable(state.data.messages || [], true)}
`; panel.onchange = async (event) => { const select = event.target.closest("[data-message-status]"); if (!select) return; await api(`/api/admin/messages/${select.dataset.messageStatus}`, { method: "PATCH", body: JSON.stringify({ status: select.value }), }); await refreshData(); }; } function setupLogin() { $("[data-login-form]").addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget; const status = $("[data-login-status]"); status.classList.remove("error"); status.textContent = "登入中..."; try { await api("/api/admin/login", { method: "POST", body: JSON.stringify(Object.fromEntries(new FormData(form).entries())), }); await refreshData(); showShell(); render(); } catch (error) { status.classList.add("error"); status.textContent = error.message; } }); $("[data-logout]").addEventListener("click", async () => { await api("/api/admin/logout", { method: "POST", body: "{}" }); showLogin(""); }); } async function boot() { setupLogin(); try { const session = await api("/api/admin/session"); if (!session.authenticated) { showLogin(""); return; } await refreshData(); showShell(); render(); } catch (error) { if (error.status === 401) { showLogin(""); return; } showLogin(error.message); } } document.addEventListener("DOMContentLoaded", boot);