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
260
backend/internal/admin/web/assets/admin.css
Normal file
260
backend/internal/admin/web/assets/admin.css
Normal 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;
|
||||
}
|
||||
}
|
||||
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();
|
||||
115
backend/internal/admin/web/index.html
Normal file
115
backend/internal/admin/web/index.html
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue