Simplify admin publishing pipeline

This commit is contained in:
yarnom 2026-06-03 18:18:50 +08:00
parent 13e7e4026d
commit 9186801c7f
37 changed files with 750 additions and 3367 deletions

View file

@ -2,7 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import {
AssetResponse,
AuditLogsResponse,
BuildJobResponse,
LoginResponse,
DeletePostResponse,
@ -99,14 +99,6 @@ export class AdminApiService {
});
}
uploadAsset(file: File) {
const body = new FormData();
body.append('file', file);
return this.http.post<AssetResponse>(`${this.baseUrl}/assets`, body, {
withCredentials: true
});
}
generateSlug(title: string, summary: string, postId?: string) {
return this.http.post<SlugResponse>(
`${this.baseUrl}/slug`,
@ -114,4 +106,27 @@ export class AdminApiService {
{ withCredentials: true }
);
}
listAuditLogs(action: string, resourceType: string, query: string, limit?: number, offset?: number) {
let params = new HttpParams();
if (action) {
params = params.set('action', action);
}
if (resourceType) {
params = params.set('resourceType', resourceType);
}
if (query) {
params = params.set('query', query);
}
if (limit) {
params = params.set('limit', String(limit));
}
if (offset) {
params = params.set('offset', String(offset));
}
return this.http.get<AuditLogsResponse>(`${this.baseUrl}/audit-logs`, {
params,
withCredentials: true
});
}
}

View file

@ -23,25 +23,6 @@ button:disabled {
opacity: 0.5;
}
.upload-button {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.7em;
background: #fff;
color: #2f4a63;
cursor: pointer;
padding: 0.72em 1.15em;
}
.upload-button:hover {
background: #f1f3f5;
}
.upload-button input {
display: none;
}
.link-button {
background: transparent;
color: #2f4a63;
@ -312,6 +293,19 @@ textarea {
padding: 0 1em;
}
.log-actions {
max-width: 100%;
flex-wrap: wrap;
justify-content: flex-end;
}
.log-actions input {
width: min(20em, 100%);
min-height: 2.55em;
border-radius: 0.65em;
font-size: 0.9em;
}
.editor-head h1 {
display: flex;
align-items: center;
@ -371,6 +365,39 @@ textarea {
background: #fafafa;
}
.log-table {
overflow-x: auto;
}
.log-table-head,
.log-table-row {
min-width: 58em;
display: grid;
grid-template-columns: 10em 7em 8em 14em minmax(14em, 1fr) 8em;
align-items: center;
gap: 1em;
border-bottom: 1px solid #eee;
padding: 0.9em 0.75em;
}
.log-table-head {
color: #777b82;
font-size: 0.78em;
line-height: 1.2;
}
.log-table-row {
color: #777b82;
font-size: 0.86em;
}
.log-details {
overflow: hidden;
color: #3d3d3f;
text-overflow: ellipsis;
white-space: nowrap;
}
.post-row-title {
overflow: hidden;
font-weight: 700;
@ -461,8 +488,7 @@ textarea {
box-shadow: 0 0.08em 0.35em rgb(35 36 40 / 4%);
}
.editor-actions button,
.editor-actions .upload-button {
.editor-actions button {
min-height: 2.45em;
border-radius: 0.62em;
padding: 0 0.9em;
@ -470,14 +496,12 @@ textarea {
white-space: nowrap;
}
.editor-actions .upload-button,
.editor-actions .save-button,
.editor-actions .build-button {
background: transparent;
color: #2f4a63;
}
.editor-actions .upload-button:hover,
.editor-actions .save-button:hover,
.editor-actions .build-button:hover {
background: #243b53;
@ -791,8 +815,7 @@ textarea {
align-items: stretch;
}
.editor-actions button,
.editor-actions .upload-button {
.editor-actions button {
width: 100%;
}

View file

@ -35,6 +35,10 @@
<span>/</span>
@if (view === 'list') {
<span>文章</span>
} @else if (view === 'logs') {
<button type="button" (click)="backToList()">文章</button>
<span>/</span>
<span>日志</span>
} @else {
<button type="button" (click)="backToList()">文章</button>
<span>/</span>
@ -45,6 +49,7 @@
<details class="user-menu">
<summary>{{ user.username }}</summary>
<div class="user-menu-panel">
<button type="button" (click)="showLogs()">日志</button>
<button type="button" (click)="logout()">退出</button>
</div>
</details>
@ -135,6 +140,82 @@
</span>
<button type="button" class="link-button" [disabled]="page >= totalPages" (click)="nextPage()">
下一页 →
</button>
</nav>
</section>
} @else if (view === 'logs') {
<section class="list-view">
<div class="page-heading">
<div>
<p class="eyebrow">Audit</p>
<h1>操作日志</h1>
</div>
<div class="page-actions log-actions">
<input
name="auditQuery"
placeholder="搜索用户、动作、资源、详情"
[(ngModel)]="auditQuery"
(keyup.enter)="changeAuditFilter()"
/>
<select aria-label="日志动作" [(ngModel)]="auditActionFilter" (change)="changeAuditFilter()">
<option value="">全部动作</option>
<option value="login">登录</option>
<option value="login_failed">登录失败</option>
<option value="logout">退出</option>
<option value="post_create">新建文章</option>
<option value="post_update">修改文章</option>
<option value="post_delete">删除文章</option>
<option value="post_publish">发布文章</option>
<option value="build_create">提交构建</option>
<option value="slug_generate">生成 Slug</option>
</select>
<select aria-label="资源类型" [(ngModel)]="auditResourceFilter" (change)="changeAuditFilter()">
<option value="">全部资源</option>
<option value="user">用户</option>
<option value="post">文章</option>
<option value="build_job">构建</option>
</select>
<button type="button" (click)="changeAuditFilter()">查询</button>
</div>
</div>
<div class="post-table log-table">
@if (auditLogs.length === 0) {
<p class="empty-message">暂无日志</p>
} @else {
<div class="log-table-head">
<span>时间</span>
<span>用户</span>
<span>动作</span>
<span>资源</span>
<span>详情</span>
<span>IP</span>
</div>
@for (log of auditLogs; track log.id) {
<div class="log-table-row">
<span>{{ formatDate(log.createdAt) }}</span>
<span>{{ log.actorUsername || '匿名' }}</span>
<span>{{ actionText(log.action) }}</span>
<span>{{ log.resourceType || '-' }} {{ log.resourceId || '' }}</span>
<span class="log-details">{{ detailsText(log) }}</span>
<span>{{ log.ipAddress || '-' }}</span>
</div>
}
}
</div>
<nav class="pagination" aria-label="日志分页">
<button type="button" class="link-button" [disabled]="auditPage <= 1" (click)="previousAuditPage()">
← 上一页
</button>
<span>第 {{ auditPage }} / {{ totalAuditPages }} 页,共 {{ totalAuditLogs }} 条</span>
<button
type="button"
class="link-button"
[disabled]="auditPage >= totalAuditPages"
(click)="nextAuditPage()"
>
下一页 →
</button>
</nav>
</section>
@ -152,10 +233,6 @@
</h1>
</div>
<div class="editor-actions">
<label class="upload-button">
{{ uploadingAsset ? '上传中' : '上传图片' }}
<input type="file" accept="image/*" [disabled]="uploadingAsset" (change)="uploadAsset($event)" />
</label>
<button type="submit" class="save-button" [disabled]="saving">{{ saving ? '保存中' : '保存' }}</button>
<button type="button" class="build-button" (click)="buildPost()">构建</button>
</div>
@ -248,7 +325,6 @@
<div class="markdown-workspace" [class.split]="editorMode === 'split'">
@if (editorMode !== 'preview') {
<textarea
#bodyTextarea
name="bodyMarkdown"
spellcheck="false"
[(ngModel)]="draft.bodyMarkdown"

View file

@ -1,10 +1,10 @@
import { CommonModule } from '@angular/common';
import { Component, ElementRef, HostListener, OnDestroy, OnInit, ViewChild, inject } from '@angular/core';
import { Component, HostListener, OnDestroy, OnInit, 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';
import { AuditLog, BuildJob, Post, PostInput, PostStatus, User } from './models';
type FeedbackTone = 'success' | 'info' | 'error';
@ -17,17 +17,23 @@ type FeedbackTone = 'success' | 'info' | 'error';
})
export class AppComponent implements OnInit, OnDestroy {
private readonly api = inject(AdminApiService);
@ViewChild('bodyTextarea') private bodyTextarea?: ElementRef<HTMLTextAreaElement>;
user: User | null = null;
posts: Post[] = [];
auditLogs: AuditLog[] = [];
currentPost: Post | null = null;
statusFilter: PostStatus | '' = '';
view: 'list' | 'editor' = 'list';
auditActionFilter = '';
auditResourceFilter = '';
auditQuery = '';
view: 'list' | 'editor' | 'logs' = 'list';
editorMode: 'edit' | 'preview' | 'split' = 'edit';
page = 1;
auditPage = 1;
readonly pageSize = 12;
readonly auditPageSize = 20;
totalPosts = 0;
totalAuditLogs = 0;
loginUsername = 'yarnom';
loginPassword = '';
@ -38,7 +44,6 @@ export class AppComponent implements OnInit, OnDestroy {
showBuildLog = false;
loading = true;
saving = false;
uploadingAsset = false;
generatingSlug = false;
autosaveStatus = '未修改';
lastAutosavedAt: Date | null = null;
@ -98,6 +103,7 @@ export class AppComponent implements OnInit, OnDestroy {
await firstValueFrom(this.api.logout().pipe(catchError(() => of({ ok: true }))));
this.user = null;
this.posts = [];
this.auditLogs = [];
this.currentPost = null;
this.draft = this.emptyDraft();
this.tagsText = '';
@ -117,6 +123,32 @@ export class AppComponent implements OnInit, OnDestroy {
}
}
async loadAuditLogs() {
const response = await firstValueFrom(
this.api.listAuditLogs(
this.auditActionFilter,
this.auditResourceFilter,
this.auditQuery.trim(),
this.auditPageSize,
(this.auditPage - 1) * this.auditPageSize
)
);
this.auditLogs = response.logs ?? [];
this.totalAuditLogs = response.total ?? 0;
if (this.auditPage > this.totalAuditPages) {
this.auditPage = this.totalAuditPages;
await this.loadAuditLogs();
}
}
async showLogs() {
if (!this.confirmDiscard()) {
return;
}
this.view = 'logs';
await this.loadAuditLogs();
}
async selectPost(id: string) {
if (!this.confirmDiscard()) {
return;
@ -188,6 +220,27 @@ export class AppComponent implements OnInit, OnDestroy {
await this.loadPosts();
}
async changeAuditFilter() {
this.auditPage = 1;
await this.loadAuditLogs();
}
async previousAuditPage() {
if (this.auditPage <= 1) {
return;
}
this.auditPage -= 1;
await this.loadAuditLogs();
}
async nextAuditPage() {
if (this.auditPage >= this.totalAuditPages) {
return;
}
this.auditPage += 1;
await this.loadAuditLogs();
}
async changeStatusFilter() {
this.page = 1;
await this.loadPosts();
@ -404,28 +457,6 @@ export class AppComponent implements OnInit, OnDestroy {
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) {
@ -458,10 +489,26 @@ export class AppComponent implements OnInit, OnDestroy {
return Math.max(1, Math.ceil(this.totalPosts / this.pageSize));
}
get totalAuditPages() {
return Math.max(1, Math.ceil(this.totalAuditLogs / this.auditPageSize));
}
get pageNumbers() {
return Array.from({ length: this.totalPages }, (_, index) => index + 1);
}
actionText(action: string) {
return actionText(action);
}
detailsText(log: AuditLog) {
const details = log.details ?? {};
const pairs = Object.entries(details)
.filter(([, value]) => value !== undefined && value !== null && value !== '')
.map(([key, value]) => `${key}: ${String(value)}`);
return pairs.length ? pairs.join(' / ') : '无详情';
}
private async autosave() {
if (!this.canAutosave()) {
return;
@ -555,29 +602,6 @@ export class AppComponent implements OnInit, OnDestroy {
});
}
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) {
@ -618,10 +642,6 @@ function slugify(value: string) {
.replace(/^-|-$/g, '');
}
function altText(filename: string) {
return filename.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim() || 'image';
}
function toDateTimeLocal(value?: string | null) {
if (!value) {
return '';
@ -776,6 +796,22 @@ function buildStatusText(status: string) {
)[status] ?? status;
}
function actionText(action: string) {
return (
{
login: '登录',
login_failed: '登录失败',
logout: '退出',
post_create: '新建文章',
post_update: '修改文章',
post_delete: '删除文章',
post_publish: '发布文章',
build_create: '提交构建',
slug_generate: '生成 Slug'
} as Record<string, string>
)[action] ?? action;
}
function errorMessage(error: unknown) {
if (typeof error === 'object' && error && 'error' in error) {
const body = (error as { error?: { error?: string } }).error;

View file

@ -53,15 +53,17 @@ export type BuildJob = {
createdBy?: string | null;
};
export type Asset = {
export type AuditLog = {
id: string;
path: string;
originalName: string;
mimeType: string;
sizeBytes: number;
sha256: string;
actorId?: string | null;
actorUsername: string;
action: string;
resourceType: string;
resourceId: string;
ipAddress: string;
userAgent: string;
details: Record<string, unknown>;
createdAt: string;
createdBy?: string | null;
};
export type LoginResponse = {
@ -83,10 +85,6 @@ export type BuildJobResponse = {
buildJob: BuildJob;
};
export type AssetResponse = {
asset: Asset;
};
export type SlugResponse = {
slug: string;
};
@ -95,3 +93,8 @@ export type DeletePostResponse = {
ok: boolean;
buildJob?: BuildJob | null;
};
export type AuditLogsResponse = {
logs: AuditLog[];
total: number;
};