Simplify admin publishing pipeline
This commit is contained in:
parent
13e7e4026d
commit
9186801c7f
37 changed files with 750 additions and 3367 deletions
|
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(``);
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue