const state = { user: null, posts: [], currentPost: null, }; const el = { loginView: document.querySelector("#loginView"), appView: document.querySelector("#appView"), loginForm: document.querySelector("#loginForm"), loginUsername: document.querySelector("#loginUsername"), loginPassword: document.querySelector("#loginPassword"), loginMessage: document.querySelector("#loginMessage"), userBadge: document.querySelector("#userBadge"), logoutButton: document.querySelector("#logoutButton"), newPostButton: document.querySelector("#newPostButton"), statusFilter: document.querySelector("#statusFilter"), postList: document.querySelector("#postList"), postForm: document.querySelector("#postForm"), editorMode: document.querySelector("#editorMode"), editorTitle: document.querySelector("#editorTitle"), titleInput: document.querySelector("#titleInput"), slugInput: document.querySelector("#slugInput"), statusInput: document.querySelector("#statusInput"), coverInput: document.querySelector("#coverInput"), tagsInput: document.querySelector("#tagsInput"), summaryInput: document.querySelector("#summaryInput"), bodyInput: document.querySelector("#bodyInput"), publishButton: document.querySelector("#publishButton"), editorMessage: document.querySelector("#editorMessage"), }; async function api(path, options = {}) { const response = await fetch(`/api/admin${path}`, { credentials: "include", headers: { "Content-Type": "application/json", ...(options.headers ?? {}), }, ...options, }); if (response.status === 204) { return null; } const text = await response.text(); const data = text ? JSON.parse(text) : {}; if (!response.ok) { throw new Error(data.error ?? `HTTP ${response.status}`); } return data; } function showLogin() { el.loginView.hidden = false; el.appView.hidden = true; el.loginPassword.focus(); } function showApp() { el.loginView.hidden = true; el.appView.hidden = false; el.userBadge.textContent = state.user?.username ?? ""; } async function bootstrap() { try { const data = await api("/me"); state.user = data.user; showApp(); await loadPosts(); resetEditor(); } catch { showLogin(); } } async function loadPosts() { const status = el.statusFilter.value; const query = status ? `?status=${encodeURIComponent(status)}` : ""; const data = await api(`/posts${query}`); state.posts = data.posts ?? []; renderPostList(); } function renderPostList() { el.postList.innerHTML = ""; if (state.posts.length === 0) { const empty = document.createElement("p"); empty.className = "message"; empty.textContent = "暂无文章"; el.postList.append(empty); return; } for (const post of state.posts) { const button = document.createElement("button"); button.type = "button"; button.className = "post-item"; if (state.currentPost?.id === post.id) { button.classList.add("active"); } button.innerHTML = ` `; button.querySelector(".post-item-title").textContent = post.title || "未命名"; button.querySelector(".post-item-meta").textContent = `${statusText(post.status)} / ${formatDate(post.updatedAt)}`; button.addEventListener("click", () => selectPost(post.id)); el.postList.append(button); } } async function selectPost(id) { const data = await api(`/posts/${id}`); state.currentPost = data.post; fillEditor(data.post); renderPostList(); } function resetEditor() { state.currentPost = null; el.editorMode.textContent = "新文章"; el.editorTitle.textContent = "开始写作"; el.postForm.reset(); el.statusInput.value = "draft"; el.editorMessage.textContent = ""; renderPostList(); } function fillEditor(post) { el.editorMode.textContent = `版本 ${post.version}`; el.editorTitle.textContent = post.title || "未命名"; el.titleInput.value = post.title ?? ""; el.slugInput.value = post.slug ?? ""; el.statusInput.value = post.status ?? "draft"; el.coverInput.value = post.cover ?? ""; el.tagsInput.value = (post.tags ?? []).join(", "); el.summaryInput.value = post.summary ?? ""; el.bodyInput.value = post.bodyMarkdown ?? ""; el.editorMessage.textContent = ""; } function readPostInput() { return { title: el.titleInput.value.trim(), slug: el.slugInput.value.trim(), status: el.statusInput.value, cover: el.coverInput.value.trim(), tags: parseTags(el.tagsInput.value), summary: el.summaryInput.value.trim(), bodyMarkdown: el.bodyInput.value, slugSource: "manual", slugLocked: true, }; } async function savePost() { const input = readPostInput(); const path = state.currentPost ? `/posts/${state.currentPost.id}` : "/posts"; const method = state.currentPost ? "PUT" : "POST"; const data = await api(path, { method, body: JSON.stringify(input), }); state.currentPost = data.post; fillEditor(data.post); await loadPosts(); el.editorMessage.textContent = "已保存"; } async function publishPost() { if (!state.currentPost) { await savePost(); } const data = await api(`/posts/${state.currentPost.id}/publish`, { method: "POST" }); state.currentPost = data.post; fillEditor(data.post); await loadPosts(); el.editorMessage.textContent = "已开始构建"; if (data.buildJob?.id) { pollBuildJob(data.buildJob.id); } } async function pollBuildJob(id) { for (;;) { await wait(1400); const data = await api(`/build-jobs/${id}`); const job = data.buildJob; el.editorMessage.textContent = `构建状态:${buildStatusText(job.status)}`; if (["success", "failed", "cancelled"].includes(job.status)) { if (job.error) { el.editorMessage.textContent = `构建失败:${job.error}`; } return; } } } function wait(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function statusText(status) { return { draft: "草稿", published: "已发布", archived: "归档", deleted: "已删除", }[status] ?? status; } function buildStatusText(status) { return { queued: "等待中", running: "构建中", success: "成功", failed: "失败", cancelled: "已取消", }[status] ?? status; } function formatDate(value) { if (!value) { return "无时间"; } return new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", }).format(new Date(value)); } function slugify(value) { return value .trim() .toLowerCase() .replace(/[\s_]+/g, "-") .replace(/[^a-z0-9\u4e00-\u9fa5-]+/g, "") .replace(/-+/g, "-") .replace(/^-|-$/g, ""); } function parseTags(value) { const seen = new Set(); return value .split(/[,,]/) .map((tag) => tag.trim()) .filter((tag) => { const key = tag.toLowerCase(); if (!tag || seen.has(key)) { return false; } seen.add(key); return true; }); } el.loginForm.addEventListener("submit", async (event) => { event.preventDefault(); el.loginMessage.textContent = ""; try { const data = await api("/login", { method: "POST", body: JSON.stringify({ username: el.loginUsername.value.trim(), password: el.loginPassword.value, }), }); state.user = data.user; showApp(); await loadPosts(); resetEditor(); } catch (error) { el.loginMessage.textContent = error.message; } }); el.logoutButton.addEventListener("click", async () => { await api("/logout", { method: "POST" }); state.user = null; state.posts = []; state.currentPost = null; showLogin(); }); el.newPostButton.addEventListener("click", resetEditor); el.statusFilter.addEventListener("change", loadPosts); el.titleInput.addEventListener("input", () => { if (!state.currentPost && !el.slugInput.value.trim()) { el.slugInput.value = slugify(el.titleInput.value); } }); el.postForm.addEventListener("submit", async (event) => { event.preventDefault(); try { await savePost(); } catch (error) { el.editorMessage.textContent = error.message; } }); el.publishButton.addEventListener("click", async () => { try { await publishPost(); } catch (error) { el.editorMessage.textContent = error.message; } }); bootstrap();