osaet/backend/internal/admin/web/assets/admin.js
yarnom f0b50d13ea feat: add admin publishing workflow and yar theme
Add Go/Postgres admin APIs, Angular admin UI, manual build flow, asset uploads, markdown import/export, configurable slug generation, and the Yar reading theme. Exclude local docs and generated development artifacts from version control.
2026-06-01 15:48:04 +08:00

321 lines
8.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = `
<span class="post-item-title"></span>
<span class="post-item-meta"></span>
`;
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();