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:
yarnom 2026-06-01 15:48:04 +08:00
parent b78f4b39c9
commit f0b50d13ea
121 changed files with 27139 additions and 550 deletions

View file

@ -0,0 +1,260 @@
:root {
color: #232428;
background: #f7f6f2;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans CJK SC",
"Source Han Sans SC", "Microsoft YaHei", sans-serif;
font-size: 16px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: #f7f6f2;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
border: 0;
border-radius: 0.65em;
padding: 0.72em 1.15em;
background: #243b53;
color: #fff;
cursor: pointer;
}
button:hover {
background: #1c3147;
}
button.ghost {
background: #fff;
color: #243b53;
box-shadow: 0 0 0.2em rgb(29 53 87 / 13%);
}
button.publish {
background: #7b4f27;
}
button.publish:hover {
background: #643f1f;
}
input,
select,
textarea {
width: 100%;
border: 1px solid #e1ded7;
border-radius: 0.7em;
background: #fff;
color: #232428;
padding: 0.8em 0.95em;
outline: 0;
}
input:focus,
select:focus,
textarea:focus {
border-color: #9aa9b6;
box-shadow: 0 0 0 0.2em rgb(36 59 83 / 10%);
}
label {
display: grid;
gap: 0.5em;
color: #55575d;
font-size: 0.9em;
}
textarea {
resize: vertical;
line-height: 1.7;
}
.shell {
width: min(92vw, 1180px);
margin: 0 auto;
padding: 5vh 0;
}
.login-view {
min-height: 90vh;
display: grid;
place-items: center;
}
.login-panel {
width: min(92vw, 28em);
display: grid;
gap: 1.2em;
padding: 2em;
border-radius: 1em;
background: #fff;
box-shadow: 0 1em 3em rgb(29 53 87 / 10%);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 2em;
margin-bottom: 1.8em;
}
.topbar h1,
.editor-head h2,
.panel-heading h2,
.login-panel h1 {
margin: 0;
line-height: 1.2;
}
.eyebrow {
margin: 0 0 0.35em;
color: #8b8175;
font-size: 0.78em;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.topbar-actions,
.editor-actions {
display: flex;
align-items: center;
gap: 0.7em;
}
.user-badge {
color: #6d7179;
font-size: 0.9em;
}
.workspace {
display: grid;
grid-template-columns: minmax(18em, 0.9fr) minmax(0, 2.1fr);
gap: 1.4em;
align-items: start;
}
.post-list-panel,
.editor-panel {
min-width: 0;
}
.panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1em;
margin-bottom: 1em;
}
.panel-heading select {
max-width: 9em;
}
.post-list {
display: grid;
gap: 0.75em;
}
.post-item {
width: 100%;
display: grid;
gap: 0.45em;
text-align: left;
border-radius: 0.8em;
padding: 1em;
background: #fff;
color: #232428;
box-shadow: 0 0 0.2em rgb(29 53 87 / 10%);
}
.post-item:hover,
.post-item.active {
background: #fbfaf7;
box-shadow: 0 0.45em 1.4em rgb(29 53 87 / 11%);
}
.post-item-title {
font-weight: 700;
line-height: 1.35;
}
.post-item-meta {
color: #777b82;
font-size: 0.82em;
}
.editor-panel {
border-radius: 1em;
background: #fff;
box-shadow: 0 0 0.2em rgb(29 53 87 / 10%);
}
.editor-form {
display: grid;
gap: 1.2em;
padding: 1.4em;
}
.editor-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1em;
}
.fields-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1em;
}
.wide-field {
grid-column: 1 / -1;
}
.body-field textarea {
min-height: 42vh;
font-family:
"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.95em;
}
.message {
min-height: 1.4em;
margin: 0;
color: #7b4f27;
font-size: 0.9em;
}
@media (max-width: 860px) {
.topbar,
.editor-head {
align-items: flex-start;
flex-direction: column;
}
.workspace,
.fields-grid {
grid-template-columns: 1fr;
}
.topbar-actions,
.editor-actions {
width: 100%;
flex-wrap: wrap;
}
}

View 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();

View file

@ -0,0 +1,115 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Osaet Admin</title>
<link rel="stylesheet" href="/admin/assets/admin.css" />
</head>
<body>
<main class="shell">
<section id="loginView" class="login-view" hidden>
<form id="loginForm" class="login-panel">
<div>
<p class="eyebrow">Osaet Admin</p>
<h1>登录后台</h1>
</div>
<label>
用户名
<input id="loginUsername" autocomplete="username" value="yarnom" />
</label>
<label>
密码
<input id="loginPassword" type="password" autocomplete="current-password" />
</label>
<button type="submit">登录</button>
<p id="loginMessage" class="message" role="status"></p>
</form>
</section>
<section id="appView" hidden>
<header class="topbar">
<div>
<p class="eyebrow">Osaet Admin</p>
<h1>文章管理</h1>
</div>
<div class="topbar-actions">
<span id="userBadge" class="user-badge"></span>
<button id="newPostButton" type="button">新文章</button>
<button id="logoutButton" type="button" class="ghost">退出</button>
</div>
</header>
<div class="workspace">
<aside class="post-list-panel">
<div class="panel-heading">
<h2>文章</h2>
<select id="statusFilter" aria-label="文章状态">
<option value="">全部</option>
<option value="draft">草稿</option>
<option value="published">已发布</option>
<option value="archived">归档</option>
</select>
</div>
<div id="postList" class="post-list"></div>
</aside>
<section class="editor-panel">
<form id="postForm" class="editor-form">
<div class="editor-head">
<div>
<p id="editorMode" class="eyebrow">新文章</p>
<h2 id="editorTitle">开始写作</h2>
</div>
<div class="editor-actions">
<button id="saveButton" type="submit">保存</button>
<button id="publishButton" type="button" class="publish">发布</button>
</div>
</div>
<div class="fields-grid">
<label>
标题
<input id="titleInput" required />
</label>
<label>
Slug
<input id="slugInput" required />
</label>
<label>
状态
<select id="statusInput">
<option value="draft">草稿</option>
<option value="published">已发布</option>
<option value="archived">归档</option>
</select>
</label>
<label>
封面
<input id="coverInput" />
</label>
<label class="wide-field">
标签
<input id="tagsInput" placeholder="用逗号分隔,例如:生活, 技术" />
</label>
</div>
<label>
摘要
<textarea id="summaryInput" rows="3"></textarea>
</label>
<label class="body-field">
正文 Markdown
<textarea id="bodyInput" spellcheck="false"></textarea>
</label>
<p id="editorMessage" class="message" role="status"></p>
</form>
</section>
</div>
</section>
</main>
<script src="/admin/assets/admin.js" type="module"></script>
</body>
</html>