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.
790 lines
20 KiB
TypeScript
790 lines
20 KiB
TypeScript
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 '请求失败';
|
||
}
|