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>
@@ -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;
@@ -10,6 +10,7 @@ import PromptRuleModal from "@/components/PromptRuleModal.vue";
import PublishPlatformSelector from "@/components/PublishPlatformSelector.vue";
import { generateApi, mediaApi, promptRulesApi, schedulesApi } from "@/lib/api";
import { formatError } from "@/lib/errors";
import { IMAGE_UPLOAD_ACCEPT, useImageUploadProgress, validateImageUploadFile } from "@/lib/image-webp";
import {
buildPublishPlatformOptions,
normalizePublishPlatformIds,
@@ -29,6 +30,7 @@ const emit = defineEmits<{
const queryClient = useQueryClient();
const { t } = useI18n();
const imageUploadProgress = useImageUploadProgress();
const promptDrawerOpen = ref(false);
const coverPreviewUrl = ref("");
@@ -149,6 +151,7 @@ const submitLoading = computed(
updateScheduleMutation.isPending.value ||
instantGenerateMutation.isPending.value,
);
const imageUploadBusy = computed(() => imageUploadProgress.visible);
function hydrateForm(): void {
form.name = props.task?.name ?? "";
@@ -202,12 +205,26 @@ function resetCoverState(): void {
}
function handleCoverChange(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 = "";
if (!file) {
return;
}
try {
validateImageUploadFile(file);
} catch (error) {
message.warning(formatError(error));
return;
}
if (coverPreviewUrl.value) {
URL.revokeObjectURL(coverPreviewUrl.value);
}
@@ -373,13 +390,13 @@ function padTime(value: string): string {
<label
class="generate-task-drawer__cover"
:class="{ 'generate-task-drawer__cover--disabled': !form.coverEnabled }"
:class="{ 'generate-task-drawer__cover--disabled': !form.coverEnabled || imageUploadBusy }"
>
<input
type="file"
accept="image/*"
:accept="IMAGE_UPLOAD_ACCEPT"
hidden
:disabled="!form.coverEnabled"
:disabled="!form.coverEnabled || imageUploadBusy"
@change="handleCoverChange"
/>
@@ -0,0 +1,250 @@
<script setup lang="ts">
import { SearchOutlined } from "@ant-design/icons-vue";
import { useQuery } from "@tanstack/vue-query";
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { imagesApi } from "@/lib/api";
import { formatBytes } from "@/lib/display";
import type { ImageAssetItem } from "@geo/shared-types";
const props = withDefaults(defineProps<{
singleSelect?: boolean;
}>(), {
singleSelect: true,
});
const emit = defineEmits<{
select: [image: ImageAssetItem];
}>();
const { t } = useI18n();
// State
const selectedFolderId = ref<number | null>(null);
const searchKeyword = ref("");
const debouncedSearch = ref("");
const currentPage = ref(1);
const pageSize = ref(12);
const selectedId = ref<number | null>(null);
// Queries
const foldersQuery = useQuery({
queryKey: ["images", "folders"],
queryFn: () => imagesApi.listFolders(),
});
const imagesQuery = useQuery({
queryKey: computed(() => ["images", "list", "picker", {
folder_id: selectedFolderId.value,
q: debouncedSearch.value,
page: currentPage.value,
page_size: pageSize.value,
}]),
queryFn: () => imagesApi.list({
folder_id: selectedFolderId.value ?? undefined,
q: debouncedSearch.value,
page: currentPage.value,
page_size: pageSize.value,
}),
});
const storageQuery = useQuery({
queryKey: ["images", "storage-usage", "picker"],
queryFn: () => imagesApi.storageUsage(),
});
// Computed
const folders = computed(() => foldersQuery.data.value ?? []);
const images = computed(() => imagesQuery.data.value?.items ?? []);
const totalImages = computed(() => imagesQuery.data.value?.total ?? 0);
const storageUsage = computed(() => storageQuery.data.value);
// Actions
let searchTimer: ReturnType<typeof setTimeout> | null = null;
watch(searchKeyword, () => {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
debouncedSearch.value = searchKeyword.value;
currentPage.value = 1;
}, 300);
});
function handleSelect(image: ImageAssetItem) {
selectedId.value = image.id;
emit("select", image);
}
function getSelectPopupContainer(triggerNode: HTMLElement) {
return triggerNode.parentElement ?? triggerNode;
}
</script>
<template>
<div class="image-library-picker">
<div class="picker-toolbar">
<a-select
v-model:value="selectedFolderId"
style="width: 160px"
:placeholder="t('images.allFolders')"
allow-clear
:get-popup-container="getSelectPopupContainer"
@change="currentPage = 1"
>
<a-select-option :value="null">{{ t("images.allFolders") }}</a-select-option>
<a-select-option v-for="f in folders" :key="f.id" :value="f.id">
{{ f.name }}
</a-select-option>
</a-select>
<a-input-search
v-model:value="searchKeyword"
:placeholder="t('images.searchPlaceholder')"
style="width: 240px"
allow-clear
/>
</div>
<div class="picker-content">
<div v-if="images.length > 0" class="image-grid">
<div
v-for="image in images"
:key="image.id"
class="image-item"
:class="{ 'image-item--selected': selectedId === image.id }"
@click="handleSelect(image)"
>
<div class="image-item__preview">
<img :src="image.url" :alt="image.name" loading="lazy" />
</div>
<div class="image-item__name" :title="image.name">{{ image.name }}</div>
</div>
</div>
<div v-else-if="!imagesQuery.isPending.value" class="empty-state">
<a-empty :description="t('images.noImages')" />
</div>
<div v-if="totalImages > pageSize" class="pagination-wrapper">
<a-pagination
v-model:current="currentPage"
:total="totalImages"
:page-size="pageSize"
size="small"
@change="selectedId = null"
/>
</div>
</div>
<div v-if="storageUsage" class="storage-usage">
<div class="storage-usage__info">
<span>{{ t("images.storageUsage") }}: {{ t("images.storageUsageDetail", {
used: formatBytes(storageUsage.used_bytes),
total: formatBytes(storageUsage.quota_bytes),
percent: storageUsage.used_pct
}) }}</span>
</div>
<a-progress
:percent="storageUsage.used_pct"
:show-info="false"
stroke-color="#1677ff"
size="small"
/>
</div>
</div>
</template>
<style scoped>
.image-library-picker {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
}
.picker-toolbar {
display: flex;
gap: 12px;
align-items: center;
}
.picker-content {
flex: 1;
overflow-y: auto;
min-height: 360px;
display: flex;
flex-direction: column;
}
.image-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.image-item {
border: 1px solid #f0f0f0;
border-radius: 6px;
overflow: hidden;
cursor: pointer;
transition: all 0.2s;
}
.image-item:hover {
border-color: #1677ff;
}
.image-item--selected {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
background: #e6f4ff;
}
.image-item__preview {
aspect-ratio: 1;
background: #f9fafb;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.image-item__preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-item__name {
padding: 6px 8px;
font-size: 11px;
color: #4b5563;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
}
.empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.pagination-wrapper {
margin-top: 16px;
display: flex;
justify-content: center;
}
.storage-usage {
margin-top: auto;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
}
.storage-usage__info {
font-size: 11px;
color: #6b7280;
margin-bottom: 4px;
}
</style>
@@ -0,0 +1,100 @@
<script setup lang="ts">
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { useImageUploadProgress } from "@/lib/image-webp";
const { t } = useI18n();
const progress = useImageUploadProgress();
const stageLabel = computed(() => {
if (!progress.visible) {
return "";
}
return t(`common.imageUploadProgress.stages.${progress.stage}`);
});
</script>
<template>
<Teleport to="body">
<transition name="image-upload-progress">
<div v-if="progress.visible" class="image-upload-progress">
<div class="image-upload-progress__card">
<div class="image-upload-progress__header">
<strong>{{ t("common.imageUploadProgress.title") }}</strong>
<span>{{ progress.percent }}%</span>
</div>
<div class="image-upload-progress__file" :title="progress.fileName">
{{ progress.fileName }}
</div>
<div class="image-upload-progress__stage">
{{ stageLabel }}
</div>
<a-progress
:percent="progress.percent"
:show-info="false"
size="small"
status="active"
stroke-color="#1677ff"
/>
</div>
</div>
</transition>
</Teleport>
</template>
<style scoped>
.image-upload-progress {
position: fixed;
right: 24px;
top: 30px;
z-index: 3200;
pointer-events: none;
}
.image-upload-progress__card {
width: min(360px, calc(100vw - 32px));
padding: 16px 18px;
border: 1px solid rgba(20, 37, 63, 0.08);
border-radius: 16px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 16px 44px rgba(15, 23, 42, 0.14);
backdrop-filter: blur(16px);
}
.image-upload-progress__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
color: #0f172a;
font-size: 14px;
}
.image-upload-progress__file {
overflow: hidden;
color: #0f172a;
font-size: 13px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.image-upload-progress__stage {
margin: 6px 0 10px;
color: #475569;
font-size: 12px;
}
.image-upload-progress-enter-active,
.image-upload-progress-leave-active {
transition: all 0.18s ease;
}
.image-upload-progress-enter-from,
.image-upload-progress-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>