osaet/frontend/admin/src/app/app.component.ts
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

790 lines
20 KiB
TypeScript
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.

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 '请求失败';
}