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
790
frontend/admin/src/app/app.component.ts
Normal file
790
frontend/admin/src/app/app.component.ts
Normal 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(``);
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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 '请求失败';
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue