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
@@ -4,6 +4,7 @@ import { message } from "ant-design-vue";
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import ImageLibraryPicker from "@/components/ImageLibraryPicker.vue";
import { articlesApi } from "@/lib/api";
import {
deriveCoverFileName,
@@ -11,6 +12,14 @@ import {
type PlatformCoverRequirement,
} from "@/lib/cover-requirements";
import { formatError } from "@/lib/errors";
import { IMAGE_UPLOAD_ACCEPT, useImageUploadProgress, validateImageUploadFile } from "@/lib/image-webp";
import type { ImageAssetItem } from "@geo/shared-types";
type PickerUploadResult = {
url: string;
fileName?: string | null;
assetId?: number | null;
};
const props = defineProps<{
open: boolean;
@@ -18,14 +27,21 @@ const props = defineProps<{
platformIds: string[];
currentUrl?: string | null;
currentFileName?: string | null;
currentAssetId?: number | null;
title?: string;
localTabLabel?: string;
emptyUploadTitle?: string;
emptyUploadHint?: string;
uploadHandler?: (file: File) => Promise<PickerUploadResult>;
}>();
const emit = defineEmits<{
"update:open": [value: boolean];
confirmed: [payload: { url: string; fileName: string }];
confirmed: [payload: { url: string; fileName: string; assetId?: number | null }];
}>();
const { t } = useI18n();
const imageUploadProgress = useImageUploadProgress();
const fileInput = ref<HTMLInputElement | null>(null);
const activeTab = ref("local");
@@ -41,6 +57,7 @@ const confirmLoading = ref(false);
const transformDirty = ref(false);
const localObjectUrl = ref("");
const localFile = ref<File | null>(null);
const selectedLibraryImageId = ref<number | null>(null);
const dragging = ref(false);
const dragStartX = ref(0);
@@ -51,13 +68,18 @@ const imageLoadToken = ref(0);
const primaryRequirement = computed(() => getPrimaryCoverRequirement(props.platformIds));
const aspectRatioLocked = computed(() => Boolean(primaryRequirement.value.aspectRatio));
const modalTitle = computed(() => props.title?.trim() || t("coverPicker.title"));
const resolvedLocalTabLabel = computed(() => props.localTabLabel?.trim() || t("coverPicker.tabs.local"));
const resolvedEmptyUploadTitle = computed(() => props.emptyUploadTitle?.trim() || t("coverPicker.dropzoneTitle"));
const resolvedEmptyUploadHint = computed(() => props.emptyUploadHint?.trim() || t("coverPicker.dropzoneHint"));
const localTabLabel = computed(() =>
workingUrl.value ? `${t("coverPicker.tabs.local")} (1)` : t("coverPicker.tabs.local"),
workingUrl.value ? `${resolvedLocalTabLabel.value} (1)` : resolvedLocalTabLabel.value,
);
const hasImage = computed(() => Boolean(workingUrl.value));
const previewHint = computed(() =>
aspectRatioLocked.value ? t("coverPicker.previewHintLocked") : t("coverPicker.previewHintFree"),
);
const imageUploadBusy = computed(() => imageUploadProgress.visible);
const stageSize = computed(() => {
const ratio = primaryRequirement.value.aspectRatio
|| (naturalWidth.value > 0 && naturalHeight.value > 0 ? naturalWidth.value / naturalHeight.value : 4 / 3);
@@ -164,6 +186,7 @@ function hydrateFromProps(): void {
workingFileName.value = nextFileName;
sourceKind.value = nextUrl ? "remote" : null;
localFile.value = null;
selectedLibraryImageId.value = props.currentAssetId ?? null;
if (!nextUrl) {
resetStage();
@@ -226,10 +249,18 @@ async function loadImageMeta(url: string): Promise<void> {
}
function openFileDialog(): void {
if (imageUploadBusy.value) {
return;
}
fileInput.value?.click();
}
function handleFileChange(event: Event): void {
if (imageUploadBusy.value) {
(event.target as HTMLInputElement).value = "";
return;
}
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
@@ -237,12 +268,11 @@ function handleFileChange(event: Event): void {
if (!file) {
return;
}
if (!/^image\/(png|jpeg|gif|webp)$/i.test(file.type)) {
message.warning(t("coverPicker.messages.invalidType"));
return;
}
if (file.size > 10 * 1024 * 1024) {
message.warning(t("coverPicker.messages.tooLarge"));
try {
validateImageUploadFile(file);
} catch (error) {
message.warning(formatError(error));
return;
}
@@ -253,6 +283,7 @@ function handleFileChange(event: Event): void {
workingFileName.value = file.name;
sourceKind.value = "local";
localFile.value = file;
selectedLibraryImageId.value = null;
transformDirty.value = false;
void loadImageMeta(objectUrl);
}
@@ -320,18 +351,30 @@ function handleClear(): void {
workingFileName.value = "";
sourceKind.value = null;
localFile.value = null;
selectedLibraryImageId.value = null;
transformDirty.value = false;
resetStage();
revokeLocalObjectUrl();
}
function handleLibrarySelect(image: ImageAssetItem): void {
activeTab.value = "local";
workingUrl.value = image.url;
workingFileName.value = image.name;
sourceKind.value = "remote";
selectedLibraryImageId.value = image.id;
localFile.value = null;
transformDirty.value = false;
void loadImageMeta(image.url);
}
async function handleConfirm(): Promise<void> {
if (!workingUrl.value) {
message.warning(t("coverPicker.messages.missingImage"));
return;
}
if (!props.articleId) {
if (!props.uploadHandler && !props.articleId) {
message.error(t("coverPicker.messages.missingArticle"));
return;
}
@@ -340,6 +383,7 @@ async function handleConfirm(): Promise<void> {
emit("confirmed", {
url: workingUrl.value,
fileName: workingFileName.value || deriveCoverFileName(workingUrl.value) || "cover.png",
assetId: selectedLibraryImageId.value,
});
emit("update:open", false);
return;
@@ -348,10 +392,12 @@ async function handleConfirm(): Promise<void> {
confirmLoading.value = true;
try {
if (sourceKind.value === "local" && !transformDirty.value && !aspectRatioLocked.value && localFile.value) {
const uploaded = await articlesApi.uploadImage(props.articleId, localFile.value);
const fallbackFileName = localFile.value.name || deriveCoverFileName(workingUrl.value) || "cover.webp";
const uploaded = await uploadPickedFile(localFile.value, fallbackFileName);
emit("confirmed", {
url: uploaded.url,
fileName: localFile.value.name,
fileName: uploaded.fileName,
assetId: uploaded.assetId,
});
emit("update:open", false);
return;
@@ -363,11 +409,12 @@ async function handleConfirm(): Promise<void> {
blob.type,
);
const file = new File([blob], fileName, { type: blob.type });
const uploaded = await articlesApi.uploadImage(props.articleId, file);
const uploaded = await uploadPickedFile(file, fileName);
emit("confirmed", {
url: uploaded.url,
fileName,
fileName: uploaded.fileName,
assetId: uploaded.assetId,
});
emit("update:open", false);
} catch (error) {
@@ -377,6 +424,28 @@ async function handleConfirm(): Promise<void> {
}
}
async function uploadPickedFile(file: File, fallbackFileName: string): Promise<{ url: string; fileName: string; assetId?: number | null }> {
if (props.uploadHandler) {
const uploaded = await props.uploadHandler(file);
return {
url: uploaded.url,
fileName: String(uploaded.fileName ?? "").trim() || fallbackFileName,
assetId: uploaded.assetId ?? null,
};
}
if (!props.articleId) {
throw new Error(t("coverPicker.messages.missingArticle"));
}
const uploaded = await articlesApi.uploadImage(props.articleId, file);
return {
url: uploaded.url,
fileName: fallbackFileName,
assetId: null,
};
}
async function renderCroppedBlob(requirement: PlatformCoverRequirement): Promise<Blob> {
const image = await loadImageElement(workingUrl.value);
const canvas = document.createElement("canvas");
@@ -456,7 +525,7 @@ function loadImageElement(url: string): Promise<HTMLImageElement> {
:confirm-loading="confirmLoading"
:ok-text="t('common.confirm')"
:cancel-text="t('common.cancel')"
:title="t('coverPicker.title')"
:title="modalTitle"
@ok="handleConfirm"
@cancel="closeModal"
>
@@ -464,7 +533,8 @@ function loadImageElement(url: string): Promise<HTMLImageElement> {
<input
ref="fileInput"
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
:accept="IMAGE_UPLOAD_ACCEPT"
:disabled="imageUploadBusy"
hidden
@change="handleFileChange"
/>
@@ -495,13 +565,14 @@ function loadImageElement(url: string): Promise<HTMLImageElement> {
v-else
class="cover-picker__stage-empty"
type="button"
:disabled="imageUploadBusy"
@click="openFileDialog"
>
<span class="cover-picker__stage-empty-icon">
<CloudUploadOutlined />
</span>
<strong>点击本地上传</strong>
<small>支持jpgpng等格式单张图片最大支持5M清晰的图片更有利于推荐</small>
<strong>{{ resolvedEmptyUploadTitle }}</strong>
<small>{{ resolvedEmptyUploadHint }}</small>
</button>
</div>
</div>
@@ -524,7 +595,12 @@ function loadImageElement(url: string): Promise<HTMLImageElement> {
</div>
<div v-if="hasImage" class="cover-picker__thumbs">
<button class="cover-picker__upload-tile" type="button" @click="openFileDialog">
<button
class="cover-picker__upload-tile"
type="button"
:disabled="imageUploadBusy"
@click="openFileDialog"
>
<span class="cover-picker__upload-tile-icon">
<PlusOutlined />
</span>
@@ -545,10 +621,9 @@ function loadImageElement(url: string): Promise<HTMLImageElement> {
</div>
</a-tab-pane>
<a-tab-pane key="ai" :tab="t('coverPicker.tabs.ai')">
<div class="cover-picker__ai-placeholder">
<strong>{{ t("coverPicker.aiTitle") }}</strong>
<p>{{ t("coverPicker.aiHint") }}</p>
<a-tab-pane key="library" :tab="t('images.libraryTab')">
<div class="cover-picker__library">
<ImageLibraryPicker @select="handleLibrarySelect" />
</div>
</a-tab-pane>
</a-tabs>
@@ -572,7 +647,7 @@ function loadImageElement(url: string): Promise<HTMLImageElement> {
}
.cover-picker__workspace,
.cover-picker__ai-placeholder {
.cover-picker__library {
border: none;
border-radius: 0;
background: transparent;
@@ -583,6 +658,11 @@ function loadImageElement(url: string): Promise<HTMLImageElement> {
padding: 0;
}
.cover-picker__library {
padding: 0;
height: 520px;
}
.cover-picker__stage-panel {
display: flex;
justify-content: center;
@@ -641,6 +721,12 @@ function loadImageElement(url: string): Promise<HTMLImageElement> {
cursor: pointer;
}
.cover-picker__stage-empty:disabled,
.cover-picker__upload-tile:disabled {
opacity: 0.56;
cursor: not-allowed;
}
.cover-picker__ai-placeholder strong,
.cover-picker__stage-empty strong {
margin: 0;
@@ -766,10 +852,6 @@ function loadImageElement(url: string): Promise<HTMLImageElement> {
transform: scale(1.05);
}
.cover-picker__ai-placeholder {
padding: 28px 22px;
}
@media (max-width: 960px) {
.cover-picker__thumbs {
flex-wrap: wrap;