feat: add image management functionality

- Implemented image folder repository with CRUD operations.
- Added image reference repository for managing image references to articles.
- Created image repository for handling image assets, including listing, inserting, updating, and deleting images.
- Introduced image usage repository to track storage usage and quotas for tenants.
- Added SQL queries for image assets, folders, references, and usage.
- Developed image handler for HTTP endpoints to manage images and folders.
- Created database migration scripts for image-related tables and structures.
This commit is contained in:
2026-04-16 20:40:41 +08:00
parent 0a3558fc51
commit 27389164b0
57 changed files with 8063 additions and 153 deletions
@@ -60,8 +60,10 @@ import EditorActionMenu from "@/components/editor-common/EditorActionMenu.vue";
import EditorAiAssistPanel from "@/components/editor-common/EditorAiAssistPanel.vue";
import EditorGridSizePicker from "@/components/editor-common/EditorGridSizePicker.vue";
import EditorSelectionFrame from "@/components/editor-common/EditorSelectionFrame.vue";
import CoverPickerModal from "@/components/CoverPickerModal.vue";
import MarkdownPreview from "@/components/MarkdownPreview.vue";
import {
imagesApi,
streamArticleSelectionOptimize,
type ArticleSelectionOptimizeStreamEvent,
} from "@/lib/api";
@@ -90,7 +92,6 @@ const crepeRef = ref<Crepe | null>(null);
const syncingExternalMarkdown = ref(false);
const surfaceRef = ref<HTMLElement | null>(null);
const tablePickerRef = ref<HTMLElement | null>(null);
const imageInputRef = ref<HTMLInputElement | null>(null);
const AI_OPTIMIZE_PANEL_GAP = 18;
const AI_OPTIMIZE_SELECTION_HIGHLIGHT_NAME = "article-editor-ai-optimize-selection";
@@ -130,6 +131,8 @@ type ImageContextMenuAction =
| "align-right"
| "delete-image";
type ImagePickerAction = "insert" | "replace";
type AiOptimizeStatus = "idle" | "generating" | "completed" | "error";
type TableContextMenuState = {
@@ -152,6 +155,7 @@ type SelectedImageState = {
nodePos: number;
nodeSize: number;
src: string;
assetId: number | null;
align: RichImageAlign;
};
@@ -189,6 +193,7 @@ type SelectedImageSnapshot = {
nodePos: number;
nodeSize: number;
src: string;
assetId: number | null;
align: RichImageAlign;
};
@@ -215,6 +220,7 @@ function createDefaultSelectedImageState(): SelectedImageState {
nodePos: 0,
nodeSize: 0,
src: "",
assetId: null,
align: "center",
};
}
@@ -297,8 +303,10 @@ const { loading } = useEditor((root) => {
const [instanceLoading, getEditor] = useInstance();
const editorDisabled = computed(() => props.disabled || instanceLoading.value || loading.value);
const tablePickerOpen = ref(false);
const imageUploading = ref(false);
const imageInputAction = ref<"insert" | "replace">("insert");
const imagePickerOpen = ref(false);
const imagePickerAction = ref<ImagePickerAction>("insert");
const imagePickerCurrentUrl = ref("");
const imagePickerCurrentAssetId = ref<number | null>(null);
const tableContextMenu = ref<TableContextMenuState>(createDefaultTableContextMenuState());
const selectedImage = ref<SelectedImageState>(createDefaultSelectedImageState());
const imageContextMenu = ref<ImageContextMenuState>(createDefaultImageContextMenuState());
@@ -512,6 +520,7 @@ watch(
closeTableContextMenu();
clearSelectedImageState();
closeImageContextMenu();
imagePickerOpen.value = false;
closeAiOptimizePanel();
}
},
@@ -575,8 +584,16 @@ function runEditorAction(action: (ctx: Ctx) => void): void {
editor.action(action);
}
function insertImageBlockAtCursor(src: string): void {
const resolvedSrc = src.trim();
function resolveImageAssetId(value: unknown): number | null {
const resolved = Number.parseInt(String(value ?? "").trim(), 10);
if (!Number.isInteger(resolved) || resolved <= 0) {
return null;
}
return resolved;
}
function insertImageBlockAtCursor(payload: { src: string; assetId?: number | null }): void {
const resolvedSrc = payload.src.trim();
if (!resolvedSrc) {
return;
}
@@ -588,6 +605,7 @@ function insertImageBlockAtCursor(src: string): void {
attrs: {
src: resolvedSrc,
caption: "",
assetId: payload.assetId ?? 0,
ratio: 1,
width: 0,
height: 0,
@@ -606,54 +624,44 @@ function insertImage(): void {
return;
}
if (props.uploadImage) {
imageInputAction.value = "insert";
imageInputRef.value?.click();
return;
}
const src = window.prompt(t("article.editor.imagePrompt"));
if (!src?.trim()) {
return;
}
insertImageBlockAtCursor(src);
closeTablePicker();
closeTableContextMenu();
closeImageContextMenu();
imagePickerAction.value = "insert";
imagePickerCurrentUrl.value = "";
imagePickerCurrentAssetId.value = null;
imagePickerOpen.value = true;
}
async function handleImageFileChange(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
const action = imageInputAction.value;
imageInputAction.value = "insert";
async function uploadBodyImageToLibrary(file: File): Promise<{ url: string; fileName: string; assetId: number }> {
const uploaded = await imagesApi.upload(file);
return {
url: uploaded.url,
fileName: uploaded.name,
assetId: uploaded.id,
};
}
if (!file || !props.uploadImage || editorDisabled.value || imageUploading.value) {
return;
function handleImagePicked(payload: { url: string; fileName: string; assetId?: number | null }): void {
const assetId = payload.assetId ?? null;
if (imagePickerAction.value === "replace" && selectedImage.value.open) {
updateSelectedImageAttributes({
src: payload.url,
assetId: assetId ?? 0,
ratio: 1,
width: 0,
height: 0,
});
} else {
insertImageBlockAtCursor({
src: payload.url,
assetId,
});
}
imageUploading.value = true;
try {
const src = (await props.uploadImage(file)).trim();
if (!src) {
throw new Error("article_image_url_unavailable");
}
if (action === "replace" && selectedImage.value.open) {
updateSelectedImageAttributes({
src,
ratio: 1,
width: 0,
height: 0,
});
return;
}
insertImageBlockAtCursor(src);
} catch (error) {
message.error(formatError(error));
} finally {
imageUploading.value = false;
}
imagePickerAction.value = "insert";
imagePickerCurrentUrl.value = "";
imagePickerCurrentAssetId.value = null;
}
function clamp(value: number, min: number, max: number): number {
@@ -1119,6 +1127,7 @@ function resolveSelectedImageSnapshot(targetSelection?: Selection | null): Selec
nodePos: selection.from,
nodeSize: selection.node.nodeSize,
src: String(selection.node.attrs.src ?? ""),
assetId: resolveImageAssetId(selection.node.attrs.assetId),
align: resolveImageAlign(selection.node.attrs.align),
};
});
@@ -1152,6 +1161,7 @@ function applySelectedImageSnapshot(snapshot: SelectedImageSnapshot | null): voi
nodePos: snapshot.nodePos,
nodeSize: snapshot.nodeSize,
src: snapshot.src,
assetId: snapshot.assetId,
align: snapshot.align,
};
attachSelectedImageDomSync();
@@ -1396,6 +1406,7 @@ function resolveImageContextFromTarget(target: EventTarget | null): SelectedImag
nodePos,
nodeSize: node.nodeSize,
src: String(node.attrs.src ?? ""),
assetId: resolveImageAssetId(node.attrs.assetId),
align: resolveImageAlign(node.attrs.align),
};
});
@@ -1456,7 +1467,7 @@ function handleEditorDrop(): void {
}
function updateSelectedImageAttributes(
attrs: Partial<Record<"src" | "ratio" | "width" | "height" | "align", string | number>>,
attrs: Partial<Record<"src" | "assetId" | "ratio" | "width" | "height" | "align", string | number>>,
): void {
if (!selectedImage.value.open) {
return;
@@ -1478,7 +1489,7 @@ function updateSelectedImageAttributes(
}
function updateSelectedImageAttribute(
attr: "src" | "ratio" | "width" | "height" | "align",
attr: "src" | "assetId" | "ratio" | "width" | "height" | "align",
value: string | number,
): void {
updateSelectedImageAttributes({ [attr]: value });
@@ -1519,24 +1530,10 @@ function replaceSelectedImage(): void {
}
closeImageContextMenu();
if (props.uploadImage) {
imageInputAction.value = "replace";
imageInputRef.value?.click();
return;
}
const src = window.prompt(t("article.editor.imagePrompt"), selectedImage.value.src);
if (!src?.trim()) {
return;
}
updateSelectedImageAttributes({
src: src.trim(),
ratio: 1,
width: 0,
height: 0,
});
imagePickerAction.value = "replace";
imagePickerCurrentUrl.value = selectedImage.value.src;
imagePickerCurrentAssetId.value = selectedImage.value.assetId;
imagePickerOpen.value = true;
}
function runImageContextAction(action: ImageContextMenuAction): void {
@@ -1853,18 +1850,9 @@ function runTableContextAction(action: TableContextMenuAction): void {
</div>
<span class="article-editor-canvas__divider"></span>
<input
ref="imageInputRef"
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
hidden
@change="handleImageFileChange"
/>
<a-button
size="small"
type="text"
:loading="imageUploading"
:disabled="imageUploading"
@mousedown.prevent
@click="insertImage"
>
@@ -1958,6 +1946,19 @@ function runTableContextAction(action: TableContextMenuAction): void {
<MarkdownPreview :content="aiOptimizePreview" />
</template>
</EditorAiAssistPanel>
<CoverPickerModal
v-model:open="imagePickerOpen"
:article-id="articleId"
:platform-ids="[]"
:current-url="imagePickerCurrentUrl"
:current-asset-id="imagePickerCurrentAssetId"
:title="t('article.editor.imagePicker.title')"
:empty-upload-title="t('article.editor.imagePicker.emptyTitle')"
:empty-upload-hint="t('article.editor.imagePicker.emptyHint')"
:upload-handler="uploadBodyImageToLibrary"
@confirmed="handleImagePicked"
/>
</div>
</template>