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.
This commit is contained in:
parent
b78f4b39c9
commit
f0b50d13ea
121 changed files with 27139 additions and 550 deletions
321
backend/internal/admin/web/assets/admin.js
Normal file
321
backend/internal/admin/web/assets/admin.js
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue