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 = `
`;
$("[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 = `
${config.columns.map((column) => `| ${escapeHTML(column)} | `).join("")}
操作 |
${items.map((item) => `
${config.columns.map((column) => `| ${displayValue(item, column)} | `).join("")}
|
`).join("")}
`;
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) => `
| ${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))}
|
${editable ? `
|
|
` : ""}
`).join("")}
`;
}
function messageTable(messages, editable = true) {
return `
| 時間 |
姓名 |
聯絡 |
主題 |
內容 |
狀態 |
${(messages || []).map((item) => `
| ${escapeHTML(item.created_at || "")} |
${escapeHTML(item.name || "")} |
${escapeHTML(item.phone || "")} ${escapeHTML(item.email || "")} |
${escapeHTML(item.topic || "")} |
${escapeHTML(item.message || "")} |
${editable ? `
` : escapeHTML(messageStatusLabel(item.status))}
|
`).join("")}
`;
}
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);