Add R2 image uploads to admin
All checks were successful
ci/woodpecker/push/ci Pipeline was successful

This commit is contained in:
yarnom 2026-06-04 14:31:45 +08:00
parent 9186801c7f
commit 49a0d078da
16 changed files with 809 additions and 14 deletions

View file

@ -4,6 +4,7 @@ import { Injectable, inject } from '@angular/core';
import {
AuditLogsResponse,
BuildJobResponse,
ImageUploadResponse,
LoginResponse,
DeletePostResponse,
PostInput,
@ -129,4 +130,12 @@ export class AdminApiService {
withCredentials: true
});
}
uploadImage(file: File) {
const body = new FormData();
body.append('file', file);
return this.http.post<ImageUploadResponse>(`${this.baseUrl}/images`, body, {
withCredentials: true
});
}
}

View file

@ -61,6 +61,13 @@ label {
font-size: 0.9em;
}
.body-field {
display: grid;
gap: 0.5em;
color: #55575d;
font-size: 0.9em;
}
textarea {
resize: vertical;
line-height: 1.7;
@ -560,6 +567,43 @@ textarea {
gap: 1em;
}
.body-tools {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 0.45em;
justify-content: flex-end;
}
.image-upload-button {
width: auto;
min-height: 2.35em;
display: inline-grid;
place-items: center;
border: 1px solid #eee;
border-radius: 0.65em;
background: #fff;
color: #2f4a63;
cursor: pointer;
font-size: 0.9em;
line-height: 1;
padding: 0 0.8em;
}
.image-upload-button:hover {
background: #f1f3f5;
color: #1c3147;
}
.image-upload-button.disabled {
cursor: not-allowed;
opacity: 0.55;
}
.image-upload-button input {
display: none;
}
.mode-switch {
display: inline-flex;
align-items: center;
@ -604,6 +648,7 @@ textarea {
}
.markdown-preview {
container-type: inline-size;
overflow: auto;
border: 1px solid #e8e5df;
border-radius: 0.7em;
@ -636,9 +681,31 @@ textarea {
.markdown-preview img {
display: block;
max-width: min(100%, 42em);
max-width: 80%;
max-width: min(100%, 80cqw);
max-height: min(48vh, 72cqw);
width: auto;
height: auto;
border-radius: 0.7em;
margin: 1.2em auto;
object-fit: contain;
}
:host ::ng-deep .markdown-preview p > img,
:host ::ng-deep .markdown-preview img {
display: block;
max-width: 80%;
max-width: min(100%, 80cqw);
max-height: min(48vh, 72cqw);
width: auto;
height: auto;
border-radius: 0.7em;
margin: 1.2em auto;
object-fit: contain;
}
:host ::ng-deep .markdown-preview p:has(> img:only-child) {
margin: 1.2em 0;
}
.markdown-preview pre {
@ -827,4 +894,9 @@ textarea {
align-items: flex-start;
flex-direction: column;
}
.body-tools {
width: 100%;
justify-content: flex-start;
}
}

View file

@ -306,19 +306,30 @@
></textarea>
</label>
<label class="body-field">
<div class="body-field">
<span class="body-label-row">
正文 Markdown
<span class="mode-switch" role="group" aria-label="编辑模式">
<button type="button" [class.active]="editorMode === 'edit'" (click)="setEditorMode('edit')">
编辑
</button>
<button type="button" [class.active]="editorMode === 'preview'" (click)="setEditorMode('preview')">
预览
</button>
<button type="button" [class.active]="editorMode === 'split'" (click)="setEditorMode('split')">
分栏
</button>
<span class="body-tools">
<label class="image-upload-button" [class.disabled]="uploadingImage">
{{ uploadingImage ? '上传中' : '上传图片' }}
<input
type="file"
accept="image/*"
[disabled]="uploadingImage"
(change)="uploadImage($event)"
/>
</label>
<span class="mode-switch" role="group" aria-label="编辑模式">
<button type="button" [class.active]="editorMode === 'edit'" (click)="setEditorMode('edit')">
编辑
</button>
<button type="button" [class.active]="editorMode === 'preview'" (click)="setEditorMode('preview')">
预览
</button>
<button type="button" [class.active]="editorMode === 'split'" (click)="setEditorMode('split')">
分栏
</button>
</span>
</span>
</span>
@ -336,7 +347,7 @@
<article class="markdown-preview" [innerHTML]="previewHtml"></article>
}
</div>
</label>
</div>
<div class="editor-status-row">
<p class="message">{{ editorMessage }}</p>

View file

@ -45,6 +45,7 @@ export class AppComponent implements OnInit, OnDestroy {
loading = true;
saving = false;
generatingSlug = false;
uploadingImage = false;
autosaveStatus = '未修改';
lastAutosavedAt: Date | null = null;
@ -485,6 +486,31 @@ export class AppComponent implements OnInit, OnDestroy {
}
}
async uploadImage(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
this.uploadingImage = true;
this.editorMessage = '正在上传图片';
try {
const response = await firstValueFrom(this.api.uploadImage(file));
this.insertMarkdown(response.image.markdown);
this.editorMessage = '图片已上传并插入正文';
this.showFeedback('图片上传成功', response.image.url);
this.updateAutosaveStatus();
} catch (error) {
const message = errorMessage(error);
this.editorMessage = message;
this.showFeedback('图片上传失败', message, 'error');
} finally {
this.uploadingImage = false;
input.value = '';
}
}
get totalPages() {
return Math.max(1, Math.ceil(this.totalPosts / this.pageSize));
}
@ -612,6 +638,11 @@ export class AppComponent implements OnInit, OnDestroy {
this.feedbackTimer = null;
}, 3200);
}
private insertMarkdown(markdown: string) {
const body = this.draft.bodyMarkdown.trimEnd();
this.draft.bodyMarkdown = body ? `${body}\n\n${markdown}\n` : `${markdown}\n`;
}
}
function normalizeTags(tags: string[]) {

View file

@ -98,3 +98,16 @@ export type AuditLogsResponse = {
logs: AuditLog[];
total: number;
};
export type UploadedImage = {
key: string;
url: string;
markdown: string;
filename: string;
size: number;
contentType: string;
};
export type ImageUploadResponse = {
image: UploadedImage;
};

View file

@ -0,0 +1,18 @@
<svg width="400" height="400" viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="faviconGradient" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#e5485f"/>
<stop offset="100%" stop-color="#25aba4"/>
</linearGradient>
</defs>
<text
x="200"
y="250"
text-anchor="middle"
dominant-baseline="middle"
font-family="Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="270"
font-weight="800"
letter-spacing="-4"
fill="url(#faviconGradient)">´༥`</text>
</svg>

After

Width:  |  Height:  |  Size: 612 B

View file

@ -36,6 +36,7 @@ const rssUrl = absoluteUrl('/rss.xml');
<title>{pageTitle}</title>
<meta name="description" content={description} />
<meta name="robots" content="index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="canonical" href={canonicalUrl} />
<link rel="alternate" type="application/rss+xml" title={`${site.title} RSS`} href={rssUrl} />
<meta property="og:site_name" content={site.title} />