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; 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 | 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(); 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(`

${inlineMarkdown(paragraph.join(' '))}

`); paragraph = []; }; const flushList = () => { if (list.length === 0) { return; } html.push(`
    ${list.map((item) => `
  • ${inlineMarkdown(item)}
  • `).join('')}
`); list = []; }; const flushCode = () => { html.push(`
${escapeHtml(code.join('\n'))}
`); 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(`${inlineMarkdown(heading[2])}`); continue; } if (/^[-*_]{3,}$/.test(trimmed)) { flushParagraph(); flushList(); html.push('
'); 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, '$1') .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '$1') .replace(/`([^`]+)`/g, '$1') .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/\*([^*]+)\*/g, '$1'); } function escapeHtml(value: string) { return value .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function statusText(status: PostStatus) { return ( { draft: '草稿', published: '已发布', archived: '归档', deleted: '已删除' } satisfies Record )[status]; } function buildStatusText(status: string) { return ( { queued: '等待中', running: '构建中', success: '成功', failed: '失败', cancelled: '已取消' } as Record )[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 '请求失败'; }