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,790 @@
import { CommonModule } from '@angular/common';
import { Component, ElementRef, HostListener, OnDestroy, OnInit, ViewChild, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Subscription, catchError, firstValueFrom, interval, of, switchMap, takeWhile } from 'rxjs';
import { AdminApiService } from './admin-api.service';
import { BuildJob, Post, PostInput, PostStatus, User } from './models';
type FeedbackTone = 'success' | 'info' | 'error';
@Component({
selector: 'osaet-root',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './app.component.html',
styleUrl: './app.component.css'
})
export class AppComponent implements OnInit, OnDestroy {
private readonly api = inject(AdminApiService);
@ViewChild('bodyTextarea') private bodyTextarea?: ElementRef<HTMLTextAreaElement>;
user: User | null = null;
posts: Post[] = [];
currentPost: Post | null = null;
statusFilter: PostStatus | '' = '';
view: 'list' | 'editor' = 'list';
editorMode: 'edit' | 'preview' | 'split' = 'edit';
page = 1;
readonly pageSize = 12;
totalPosts = 0;
loginUsername = 'yarnom';
loginPassword = '';
loginMessage = '';
editorMessage = '';
feedback: { title: string; message: string; tone: FeedbackTone } | null = null;
currentBuildJob: BuildJob | null = null;
showBuildLog = false;
loading = true;
saving = false;
uploadingAsset = false;
generatingSlug = false;
autosaveStatus = '未修改';
lastAutosavedAt: Date | null = null;
draft = this.emptyDraft();
tagsText = '';
private savedSnapshot = '';
private readonly autosaveIntervalMs = 12000;
private autosaveSubscription: Subscription | null = null;
private feedbackTimer: ReturnType<typeof setTimeout> | null = null;
ngOnInit() {
void this.bootstrap();
this.autosaveSubscription = interval(this.autosaveIntervalMs).subscribe(() => {
void this.autosave();
});
}
ngOnDestroy() {
this.autosaveSubscription?.unsubscribe();
if (this.feedbackTimer) {
clearTimeout(this.feedbackTimer);
}
}
async bootstrap() {
try {
const response = await firstValueFrom(this.api.me());
this.user = response.user;
await this.loadPosts();
} catch {
this.user = null;
} finally {
this.loading = false;
this.rememberSavedState();
this.updateAutosaveStatus();
}
}
async login() {
this.loginMessage = '';
try {
const response = await firstValueFrom(this.api.login(this.loginUsername.trim(), this.loginPassword));
this.user = response.user;
this.loginPassword = '';
await this.loadPosts();
this.newPost();
} catch (error) {
this.loginMessage = errorMessage(error);
}
}
async logout() {
if (!this.confirmDiscard()) {
return;
}
await firstValueFrom(this.api.logout().pipe(catchError(() => of({ ok: true }))));
this.user = null;
this.posts = [];
this.currentPost = null;
this.draft = this.emptyDraft();
this.tagsText = '';
this.rememberSavedState();
this.updateAutosaveStatus();
}
async loadPosts() {
const response = await firstValueFrom(
this.api.listPosts(this.statusFilter, this.pageSize, (this.page - 1) * this.pageSize)
);
this.posts = response.posts ?? [];
this.totalPosts = response.total ?? 0;
if (this.page > this.totalPages) {
this.page = this.totalPages;
await this.loadPosts();
}
}
async selectPost(id: string) {
if (!this.confirmDiscard()) {
return;
}
const response = await firstValueFrom(this.api.getPost(id));
this.currentPost = response.post;
this.draft = this.postToInput(response.post);
this.tagsText = this.draft.tags.join(', ');
this.editorMessage = '';
this.currentBuildJob = null;
this.showBuildLog = false;
this.rememberSavedState();
this.updateAutosaveStatus();
this.view = 'editor';
}
editPost(id: string, event: Event) {
event.stopPropagation();
void this.selectPost(id);
}
async deletePost(post: Post, event: Event) {
event.stopPropagation();
if (!window.confirm(`确定删除《${post.title || '未命名'}》吗?`)) {
return;
}
try {
const response = await firstValueFrom(this.api.deletePost(post.id));
if (this.currentPost?.id === post.id) {
this.currentPost = null;
this.draft = this.emptyDraft();
this.tagsText = '';
this.view = 'list';
this.rememberSavedState();
this.updateAutosaveStatus();
}
await this.loadPosts();
if (response.buildJob) {
this.currentBuildJob = response.buildJob;
this.editorMessage = '已删除,正在重新构建站点';
this.watchBuildJob(response.buildJob);
}
} catch (error) {
this.editorMessage = errorMessage(error);
}
}
newPost() {
if (!this.confirmDiscard()) {
return;
}
this.currentPost = null;
this.draft = this.emptyDraft();
this.tagsText = '';
this.editorMessage = '';
this.currentBuildJob = null;
this.showBuildLog = false;
this.rememberSavedState();
this.updateAutosaveStatus();
this.view = 'editor';
}
async backToList() {
if (!this.confirmDiscard()) {
return;
}
this.view = 'list';
await this.loadPosts();
}
async changeStatusFilter() {
this.page = 1;
await this.loadPosts();
}
async previousPage() {
if (this.page <= 1) {
return;
}
this.page -= 1;
await this.loadPosts();
}
async nextPage() {
if (this.page >= this.totalPages) {
return;
}
this.page += 1;
await this.loadPosts();
}
async goToPage(page: number) {
if (page < 1 || page > this.totalPages || page === this.page) {
return;
}
this.page = page;
await this.loadPosts();
}
async savePost(silent = false) {
this.saving = true;
if (!silent) {
this.editorMessage = '';
}
if (silent) {
this.autosaveStatus = '自动保存中';
}
try {
const input = this.normalizedDraft();
const response = this.currentPost
? await firstValueFrom(this.api.updatePost(this.currentPost.id, input))
: await firstValueFrom(this.api.createPost(input));
this.currentPost = response.post;
this.draft = this.postToInput(response.post);
this.tagsText = this.draft.tags.join(', ');
await this.loadPosts();
if (!silent) {
this.editorMessage = '已保存';
this.showFeedback('保存成功', '文章内容已经保存。');
this.lastAutosavedAt = null;
}
if (silent) {
this.lastAutosavedAt = new Date();
}
this.rememberSavedState();
this.updateAutosaveStatus();
return response;
} catch (error) {
if (silent) {
this.autosaveStatus = `自动保存失败:${errorMessage(error)}`;
} else {
const message = errorMessage(error);
this.editorMessage = message;
this.showFeedback('保存失败', message, 'error');
}
return null;
} finally {
this.saving = false;
}
}
async buildPost() {
const saved = await this.savePost();
if (!saved) {
return;
}
if (!this.currentPost) {
return;
}
this.editorMessage = '';
try {
const response = await firstValueFrom(this.api.buildPost(this.currentPost.id));
this.editorMessage = '已开始构建';
this.showFeedback('构建已提交', '正在按照当前状态生成静态站点。', 'info');
this.currentBuildJob = response.buildJob;
this.showBuildLog = false;
this.rememberSavedState();
this.updateAutosaveStatus();
this.watchBuildJob(response.buildJob);
} catch (error) {
const message = errorMessage(error);
this.editorMessage = message;
this.showFeedback('构建失败', message, 'error');
}
}
watchBuildJob(job: BuildJob, notify = true) {
interval(1400)
.pipe(
switchMap(() => this.api.getBuildJob(job.id)),
takeWhile((response) => {
const status = response.buildJob.status;
this.currentBuildJob = response.buildJob;
this.editorMessage =
status === 'failed' && response.buildJob.error
? `构建失败:${response.buildJob.error}`
: `构建状态:${buildStatusText(status)}`;
if (!notify) {
return !['success', 'failed', 'cancelled'].includes(status);
}
if (status === 'success') {
this.showFeedback('构建完成', '静态页面已经更新。');
} else if (status === 'failed') {
this.showFeedback('构建失败', response.buildJob.error || '请查看构建日志。', 'error');
} else if (status === 'cancelled') {
this.showFeedback('构建已取消', '本次构建没有完成。', 'error');
}
return !['success', 'failed', 'cancelled'].includes(status);
}, true),
catchError((error) => {
const message = errorMessage(error);
this.editorMessage = message;
this.showFeedback('构建状态获取失败', message, 'error');
return of(null);
})
)
.subscribe();
}
onTitleInput() {
if (!this.currentPost && !this.draft.slug.trim()) {
this.draft.slug = slugify(this.draft.title);
this.draft.slugSource = 'title';
this.draft.slugLocked = false;
}
this.updateAutosaveStatus();
}
onSlugInput() {
this.draft.slugSource = 'manual';
this.draft.slugLocked = true;
this.updateAutosaveStatus();
}
hasUnsavedChanges() {
return this.editorSnapshot() !== this.savedSnapshot;
}
@HostListener('window:beforeunload', ['$event'])
beforeUnload(event: BeforeUnloadEvent) {
if (this.hasUnsavedChanges()) {
event.preventDefault();
event.returnValue = '';
}
}
@HostListener('window:keydown', ['$event'])
handleKeydown(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault();
if (this.user && !this.saving) {
void this.savePost();
}
}
}
onDraftInput() {
this.updateAutosaveStatus();
}
statusText(status: PostStatus) {
return statusText(status);
}
formatDate(value?: string | null) {
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));
}
tagText(post: Post) {
return post.tags?.length ? post.tags.join(' / ') : '无标签';
}
buildStatusText(status: string) {
return buildStatusText(status);
}
toggleBuildLog() {
this.showBuildLog = !this.showBuildLog;
}
setEditorMode(mode: 'edit' | 'preview' | 'split') {
this.editorMode = mode;
}
closeFeedback() {
this.feedback = null;
if (this.feedbackTimer) {
clearTimeout(this.feedbackTimer);
this.feedbackTimer = null;
}
}
get previewHtml() {
return renderMarkdown(this.draft.bodyMarkdown);
}
async uploadAsset(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) {
return;
}
this.uploadingAsset = true;
this.editorMessage = '正在上传图片';
try {
const response = await firstValueFrom(this.api.uploadAsset(file));
this.insertMarkdown(`![${altText(file.name)}](${response.asset.path})`);
this.editorMessage = '图片已插入';
this.updateAutosaveStatus();
} catch (error) {
this.editorMessage = errorMessage(error);
} finally {
this.uploadingAsset = false;
}
}
async generateSlug() {
const title = this.draft.title.trim();
if (!title) {
this.showFeedback('无法生成 Slug', '请先填写标题。', 'error');
return;
}
this.generatingSlug = true;
this.editorMessage = '正在生成 Slug';
try {
const response = await firstValueFrom(
this.api.generateSlug(title, this.draft.summary.trim(), this.currentPost?.id)
);
this.draft.slug = response.slug;
this.draft.slugSource = 'ai';
this.draft.slugLocked = false;
this.editorMessage = 'Slug 已生成';
this.showFeedback('Slug 已生成', response.slug);
this.updateAutosaveStatus();
} catch (error) {
const message = errorMessage(error);
this.editorMessage = message;
this.showFeedback('Slug 生成失败', message, 'error');
} finally {
this.generatingSlug = false;
}
}
get totalPages() {
return Math.max(1, Math.ceil(this.totalPosts / this.pageSize));
}
get pageNumbers() {
return Array.from({ length: this.totalPages }, (_, index) => index + 1);
}
private async autosave() {
if (!this.canAutosave()) {
return;
}
await this.savePost(true);
}
private canAutosave() {
const input = this.normalizedDraft();
return Boolean(
this.user &&
!this.saving &&
this.hasUnsavedChanges() &&
input.title &&
input.slug
);
}
private normalizedDraft(): PostInput {
return {
...this.draft,
title: this.draft.title.trim(),
slug: this.draft.slug.trim(),
summary: this.draft.summary.trim(),
cover: this.draft.cover.trim(),
tags: parseTags(this.tagsText),
createdAt: datetimeLocalToIso(this.draft.createdAt)
};
}
private postToInput(post: Post): PostInput {
return {
slug: post.slug,
title: post.title,
summary: post.summary,
bodyMarkdown: post.bodyMarkdown,
status: post.status === 'deleted' ? 'draft' : post.status,
tags: [...(post.tags ?? [])],
cover: post.cover,
slugSource: post.slugSource || 'manual',
slugLocked: post.slugLocked,
createdAt: toDateTimeLocal(post.createdAt)
};
}
private emptyDraft(): PostInput {
return {
slug: '',
title: '',
summary: '',
bodyMarkdown: '',
status: 'draft',
tags: [],
cover: '',
slugSource: 'manual',
slugLocked: true,
createdAt: toDateTimeLocal(new Date().toISOString())
};
}
private confirmDiscard() {
if (!this.hasUnsavedChanges()) {
return true;
}
return window.confirm('当前文章有未保存的修改,确定要离开吗?');
}
private rememberSavedState() {
this.savedSnapshot = this.editorSnapshot();
}
private updateAutosaveStatus() {
if (this.saving) {
return;
}
if (this.hasUnsavedChanges()) {
this.autosaveStatus = '有未保存修改';
return;
}
if (this.lastAutosavedAt) {
this.autosaveStatus = `已自动保存 ${this.formatDate(this.lastAutosavedAt.toISOString())}`;
return;
}
this.autosaveStatus = '已保存';
}
private editorSnapshot() {
return JSON.stringify({
...this.draft,
tags: parseTags(this.tagsText)
});
}
private insertMarkdown(markdown: string) {
const textarea = this.bodyTextarea?.nativeElement;
const current = this.draft.bodyMarkdown ?? '';
if (!textarea) {
this.draft.bodyMarkdown = current ? `${current}\n\n${markdown}\n` : `${markdown}\n`;
return;
}
const start = textarea.selectionStart ?? current.length;
const end = textarea.selectionEnd ?? current.length;
const prefix = current.slice(0, start);
const suffix = current.slice(end);
const before = prefix && !prefix.endsWith('\n') ? '\n\n' : '';
const after = suffix && !suffix.startsWith('\n') ? '\n\n' : '\n';
this.draft.bodyMarkdown = `${prefix}${before}${markdown}${after}${suffix}`;
requestAnimationFrame(() => {
textarea.focus();
const cursor = start + before.length + markdown.length + after.length;
textarea.setSelectionRange(cursor, cursor);
});
}
private showFeedback(title: string, message: string, tone: FeedbackTone = 'success') {
this.feedback = { title, message, tone };
if (this.feedbackTimer) {
clearTimeout(this.feedbackTimer);
}
this.feedbackTimer = setTimeout(() => {
this.feedback = null;
this.feedbackTimer = null;
}, 3200);
}
}
function normalizeTags(tags: string[]) {
const seen = new Set<string>();
return tags
.map((tag) => tag.trim())
.filter((tag) => {
const key = tag.toLowerCase();
if (!tag || seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
function parseTags(value: string) {
return normalizeTags(value.split(/[,]/));
}
function slugify(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[\s_]+/g, '-')
.replace(/[^a-z0-9\u4e00-\u9fa5-]+/g, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
function altText(filename: string) {
return filename.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim() || 'image';
}
function toDateTimeLocal(value?: string | null) {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
return localDate.toISOString().slice(0, 16);
}
function datetimeLocalToIso(value?: string | null) {
if (!value) {
return null;
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
return date.toISOString();
}
function renderMarkdown(markdown: string) {
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
const html: string[] = [];
let paragraph: string[] = [];
let list: string[] = [];
let code: string[] = [];
let inCode = false;
const flushParagraph = () => {
if (paragraph.length === 0) {
return;
}
html.push(`<p>${inlineMarkdown(paragraph.join(' '))}</p>`);
paragraph = [];
};
const flushList = () => {
if (list.length === 0) {
return;
}
html.push(`<ul>${list.map((item) => `<li>${inlineMarkdown(item)}</li>`).join('')}</ul>`);
list = [];
};
const flushCode = () => {
html.push(`<pre><code>${escapeHtml(code.join('\n'))}</code></pre>`);
code = [];
};
for (const line of lines) {
if (line.trim().startsWith('```')) {
if (inCode) {
flushCode();
inCode = false;
} else {
flushParagraph();
flushList();
inCode = true;
}
continue;
}
if (inCode) {
code.push(line);
continue;
}
const trimmed = line.trim();
if (!trimmed) {
flushParagraph();
flushList();
continue;
}
const heading = /^(#{1,6})\s+(.+)$/.exec(trimmed);
if (heading) {
flushParagraph();
flushList();
const level = heading[1].length;
html.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`);
continue;
}
if (/^[-*_]{3,}$/.test(trimmed)) {
flushParagraph();
flushList();
html.push('<hr>');
continue;
}
const listItem = /^[-*]\s+(.+)$/.exec(trimmed);
if (listItem) {
flushParagraph();
list.push(listItem[1]);
continue;
}
flushList();
paragraph.push(trimmed);
}
if (inCode) {
flushCode();
}
flushParagraph();
flushList();
return html.join('\n');
}
function inlineMarkdown(value: string) {
return escapeHtml(value)
.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, '<img src="$2" alt="$1">')
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>');
}
function escapeHtml(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function statusText(status: PostStatus) {
return (
{
draft: '草稿',
published: '已发布',
archived: '归档',
deleted: '已删除'
} satisfies Record<PostStatus, string>
)[status];
}
function buildStatusText(status: string) {
return (
{
queued: '等待中',
running: '构建中',
success: '成功',
failed: '失败',
cancelled: '已取消'
} as Record<string, string>
)[status] ?? status;
}
function errorMessage(error: unknown) {
if (typeof error === 'object' && error && 'error' in error) {
const body = (error as { error?: { error?: string } }).error;
if (body?.error) {
return body.error;
}
}
if (error instanceof Error) {
return error.message;
}
return '请求失败';
}