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
+92 -10
View File
@@ -71,6 +71,14 @@ import type {
TemplateGenerateResponse,
TemplateListItem,
TemplateCard,
ImageFolder,
ImageAssetItem,
ImageAssetListResponse,
ImageUploadResponse,
ImageReferenceDetail,
FolderDeletePreview,
ImageStorageUsage,
ImageUpdateRequest,
UpdateQuestionRequest,
UpdateArticleRequest,
UserInfo,
@@ -83,6 +91,12 @@ import {
getStoredRefreshToken,
setStoredTokens,
} from "./session";
import {
convertImageFileToWebp,
markImageUploadRequestFailed,
markImageUploadRequestFinished,
markImageUploadRequestStarted,
} from "./image-webp";
const baseURL = import.meta.env.VITE_API_BASE_URL ?? "";
const generationStreamEnabled = import.meta.env.VITE_GENERATION_STREAM_ENABLED === "true";
@@ -242,18 +256,27 @@ export const articlesApi = {
return apiClient.put<ArticleDetail, UpdateArticleRequest>(`/api/tenant/articles/${id}`, payload);
},
async uploadImage(id: number, file: File) {
const formData = new FormData();
formData.append("file", file);
const normalizedFile = await convertImageFileToWebp(file);
const progressToken = markImageUploadRequestStarted(normalizedFile.name);
const response = await apiClient.raw.post<ApiEnvelope<ArticleImageUploadResponse>>(
`/api/tenant/articles/${id}/images`,
formData,
);
try {
const formData = new FormData();
formData.append("file", normalizedFile, normalizedFile.name);
return {
...response.data.data,
url: resolveApiURL(response.data.data.url),
};
const response = await apiClient.raw.post<ApiEnvelope<ArticleImageUploadResponse>>(
`/api/tenant/articles/${id}/images`,
formData,
);
markImageUploadRequestFinished(progressToken);
return {
...response.data.data,
url: resolveApiURL(response.data.data.url),
};
} catch (error) {
markImageUploadRequestFailed(progressToken);
throw error;
}
},
versions(id: number) {
return apiClient.get<ArticleVersion[]>(`/api/tenant/articles/${id}/versions`);
@@ -320,6 +343,65 @@ export const knowledgeApi = {
},
};
export const imagesApi = {
listFolders() {
return apiClient.get<ImageFolder[]>("/api/tenant/images/folders");
},
createFolder(name: string) {
return apiClient.post<ImageFolder, { name: string }>("/api/tenant/images/folders", { name });
},
updateFolder(id: number, name: string) {
return apiClient.put<null, { name: string }>(`/api/tenant/images/folders/${id}`, { name });
},
folderDeletePreview(id: number) {
return apiClient.get<FolderDeletePreview>(`/api/tenant/images/folders/${id}/delete-preview`);
},
deleteFolder(id: number, force?: boolean) {
return apiClient.remove<null>(`/api/tenant/images/folders/${id}`, { params: force ? { force: 1 } : undefined });
},
list(params?: { folder_id?: number | null; q?: string; page?: number; page_size?: number }) {
return apiClient.get<ImageAssetListResponse>("/api/tenant/images", { params });
},
async upload(file: File, folderId?: number | null) {
const normalizedFile = await convertImageFileToWebp(file);
const progressToken = markImageUploadRequestStarted(normalizedFile.name);
try {
const formData = new FormData();
formData.append("file", normalizedFile, normalizedFile.name);
if (folderId != null) {
formData.append("folder_id", String(folderId));
}
const response = await apiClient.raw.post<ApiEnvelope<ImageUploadResponse>>(
"/api/tenant/images",
formData,
);
markImageUploadRequestFinished(progressToken);
return {
...response.data.data,
url: resolveApiURL(response.data.data.url),
};
} catch (error) {
markImageUploadRequestFailed(progressToken);
throw error;
}
},
update(id: number, payload: ImageUpdateRequest) {
return apiClient.put<null, ImageUpdateRequest>(`/api/tenant/images/${id}`, payload);
},
getReferences(id: number) {
return apiClient.get<ImageReferenceDetail>(`/api/tenant/images/${id}/references`);
},
delete(id: number, force?: boolean) {
return apiClient.remove<null>(`/api/tenant/images/${id}`, { params: force ? { force: 1 } : undefined });
},
storageUsage() {
return apiClient.get<ImageStorageUsage>("/api/tenant/images/storage-usage");
},
};
export interface ArticleGenerationStreamEvent {
type: string;
article_id: number;