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;
+20
View File
@@ -31,6 +31,26 @@ export function formatDateTime(value?: string | null, pattern = "YYYY-MM-DD HH:m
return parsed.isValid() ? parsed.format(pattern) : value;
}
export function formatBytes(bytes: number, decimals = 2): string {
if (bytes === 0) return "0 B";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
}
export function formatPercentage(value?: number | null, decimals = 2): string {
if (typeof value !== "number" || Number.isNaN(value) || !Number.isFinite(value)) {
return "--";
}
return value.toFixed(Math.max(0, decimals));
}
export function getGenerateStatusMeta(status?: string | null): { label: string; color: string } {
if (!status) {
return { label: "--", color: "default" };
+4
View File
@@ -28,6 +28,10 @@ const errorMessageMap: Record<string, string> = {
draft_not_editable: "只有草稿或生成失败的文章才可继续在向导中编辑",
article_title_required: "请输入文章标题",
invalid_image: "请选择图片文件",
image_upload_too_large: "图片不能超过 10MB",
image_upload_type_not_supported: "仅支持 PNG、JPG、JPEG、GIF、WebP、BMP、SVG、AVIF 图片",
image_canvas_context_unavailable: "当前浏览器暂不支持该图片处理能力,请更换浏览器后重试",
image_load_failed: "图片解析失败,请重新选择图片",
article_image_too_large: "图片不能超过 10MB",
article_image_type_not_supported: "仅支持 PNG、JPG、GIF、WebP 图片",
article_image_upload_failed: "图片上传失败",
+229
View File
@@ -0,0 +1,229 @@
import { encode } from "@jsquash/webp";
import { reactive, readonly } from "vue";
// upload limit set to 10MB
export const MAX_IMAGE_UPLOAD_SIZE_BYTES = 10 * 1024 * 1024;
const WEBP_MIME_TYPE = "image/webp";
const SUPPORTED_IMAGE_UPLOAD_FORMATS = [
{ mimeTypes: ["image/png"], extensions: ["png"] },
{ mimeTypes: ["image/jpeg"], extensions: ["jpg", "jpeg"] },
{ mimeTypes: ["image/gif"], extensions: ["gif"] },
{ mimeTypes: ["image/webp"], extensions: ["webp"] },
{ mimeTypes: ["image/bmp", "image/x-ms-bmp"], extensions: ["bmp"] },
{ mimeTypes: ["image/svg+xml"], extensions: ["svg"] },
{ mimeTypes: ["image/avif"], extensions: ["avif"] },
] as const;
export const IMAGE_UPLOAD_ACCEPT = SUPPORTED_IMAGE_UPLOAD_FORMATS
.flatMap((format) => format.mimeTypes)
.join(",");
export type ImageUploadProgressStage =
| "idle"
| "validating"
| "decoding"
| "encoding"
| "uploading"
| "finishing";
type ImageUploadProgressState = {
visible: boolean;
fileName: string;
percent: number;
stage: ImageUploadProgressStage;
};
type ImageUploadProgressUpdate = {
percent: number;
stage: Exclude<ImageUploadProgressStage, "idle">;
};
const supportedImageUploadMimeTypeSet: ReadonlySet<string> = new Set(
SUPPORTED_IMAGE_UPLOAD_FORMATS.flatMap((format) => format.mimeTypes),
);
const supportedImageUploadExtensionSet: ReadonlySet<string> = new Set(
SUPPORTED_IMAGE_UPLOAD_FORMATS.flatMap((format) => format.extensions),
);
const imageUploadProgressState = reactive<ImageUploadProgressState>({
visible: false,
fileName: "",
percent: 0,
stage: "idle",
});
let activeProgressToken = 0;
function ensureWebpFileName(fileName: string, fallbackBaseName = "image"): string {
const trimmed = fileName.trim();
const normalized = trimmed.replace(/\.[^.]+$/, "").trim();
const baseName = normalized || fallbackBaseName;
return `${baseName}.webp`;
}
function resolveFileExtension(fileName: string): string {
return fileName.includes(".") ? fileName.split(".").pop()?.trim().toLowerCase() ?? "" : "";
}
function resetImageUploadProgressState(): void {
imageUploadProgressState.visible = false;
imageUploadProgressState.fileName = "";
imageUploadProgressState.percent = 0;
imageUploadProgressState.stage = "idle";
}
function updateImageUploadProgress(token: number, update: ImageUploadProgressUpdate): void {
if (token !== activeProgressToken) {
return;
}
imageUploadProgressState.visible = true;
imageUploadProgressState.percent = Math.max(0, Math.min(100, Math.round(update.percent)));
imageUploadProgressState.stage = update.stage;
}
export function useImageUploadProgress() {
return readonly(imageUploadProgressState);
}
export function validateImageUploadFile(file: File): void {
if (!(file instanceof File) || !file.name.trim()) {
throw new Error("invalid_image");
}
if (file.size <= 0) {
throw new Error("invalid_image");
}
if (file.size > MAX_IMAGE_UPLOAD_SIZE_BYTES) {
throw new Error("image_upload_too_large");
}
const normalizedMimeType = file.type.trim().toLowerCase();
const extension = resolveFileExtension(file.name);
const mimeSupported = normalizedMimeType ? supportedImageUploadMimeTypeSet.has(normalizedMimeType) : false;
const extensionSupported = extension ? supportedImageUploadExtensionSet.has(extension) : false;
if (!mimeSupported && !extensionSupported) {
throw new Error("image_upload_type_not_supported");
}
}
function startImageUploadProgress(fileName: string): number {
const token = ++activeProgressToken;
imageUploadProgressState.visible = true;
imageUploadProgressState.fileName = fileName;
imageUploadProgressState.percent = 0;
imageUploadProgressState.stage = "validating";
return token;
}
function finishImageUploadProgress(token: number): void {
updateImageUploadProgress(token, { percent: 100, stage: "finishing" });
window.setTimeout(() => {
if (token === activeProgressToken) {
resetImageUploadProgressState();
}
}, 280);
}
function cancelImageUploadProgress(token: number): void {
window.setTimeout(() => {
if (token === activeProgressToken) {
resetImageUploadProgressState();
}
}, 0);
}
async function createImageDataFromBlob(
blob: Blob,
token: number,
): Promise<ImageData> {
updateImageUploadProgress(token, { percent: 28, stage: "decoding" });
if (typeof createImageBitmap === "function") {
const bitmap = await createImageBitmap(blob);
try {
const canvas = document.createElement("canvas");
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) {
throw new Error("image_canvas_context_unavailable");
}
context.drawImage(bitmap, 0, 0);
return context.getImageData(0, 0, bitmap.width, bitmap.height);
} finally {
bitmap.close();
}
}
const objectUrl = URL.createObjectURL(blob);
try {
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
const nextImage = new Image();
nextImage.onload = () => resolve(nextImage);
nextImage.onerror = () => reject(new Error("image_load_failed"));
nextImage.src = objectUrl;
});
const canvas = document.createElement("canvas");
canvas.width = image.naturalWidth || image.width;
canvas.height = image.naturalHeight || image.height;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) {
throw new Error("image_canvas_context_unavailable");
}
context.drawImage(image, 0, 0);
return context.getImageData(0, 0, canvas.width, canvas.height);
} finally {
URL.revokeObjectURL(objectUrl);
}
}
export async function convertImageFileToWebp(file: File): Promise<File> {
validateImageUploadFile(file);
const token = startImageUploadProgress(file.name);
try {
updateImageUploadProgress(token, { percent: 12, stage: "validating" });
const normalizedName = ensureWebpFileName(file.name);
if (file.type.trim().toLowerCase() === WEBP_MIME_TYPE) {
updateImageUploadProgress(token, { percent: 86, stage: "finishing" });
return new File([file], normalizedName, {
type: WEBP_MIME_TYPE,
lastModified: file.lastModified,
});
}
const imageData = await createImageDataFromBlob(file, token);
updateImageUploadProgress(token, { percent: 72, stage: "encoding" });
const encoded = await encode(imageData, { quality: 82 });
updateImageUploadProgress(token, { percent: 88, stage: "finishing" });
return new File([encoded], normalizedName, {
type: WEBP_MIME_TYPE,
lastModified: file.lastModified,
});
} catch (error) {
cancelImageUploadProgress(token);
throw error;
}
}
export function markImageUploadRequestStarted(fileName: string): number {
const token = activeProgressToken || startImageUploadProgress(fileName);
imageUploadProgressState.fileName = fileName;
updateImageUploadProgress(token, { percent: 94, stage: "uploading" });
return token;
}
export function markImageUploadRequestFinished(token: number): void {
finishImageUploadProgress(token);
}
export function markImageUploadRequestFailed(token: number): void {
cancelImageUploadProgress(token);
}
@@ -31,6 +31,7 @@ type RichImageBlockFeatureConfig = {
type ParsedImageMarkup = {
src: string;
caption: string;
assetId: number;
width: number;
height: number;
ratio: number;
@@ -42,6 +43,7 @@ type RichImageBlockNode = {
url: string;
title: string;
alt: string;
assetId: number;
width: number;
height: number;
ratio: number;
@@ -74,6 +76,14 @@ function parseRatio(value: unknown): number {
return Number(parsed.toFixed(2));
}
function parsePositiveInteger(value: unknown): number {
const parsed = Number.parseInt(String(value ?? "").trim(), 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
return 0;
}
return parsed;
}
function escapeHtmlAttribute(value: string): string {
return value
.replaceAll("&", "&amp;")
@@ -119,6 +129,7 @@ function parseImageMarkup(markup: string): ParsedImageMarkup | null {
return {
src,
caption: (image.getAttribute("title") || image.getAttribute("alt") || "").trim(),
assetId: parsePositiveInteger(image.getAttribute("data-asset-id")),
width: parsePositiveNumber(image.getAttribute("width")),
height: parsePositiveNumber(image.getAttribute("height")),
ratio: parseRatio(image.getAttribute("data-ratio")),
@@ -132,12 +143,14 @@ function serializeImageHtml(attrs: Record<string, unknown>): string {
const width = parsePositiveNumber(attrs.width);
const height = parsePositiveNumber(attrs.height);
const ratio = parseRatio(attrs.ratio);
const assetId = parsePositiveInteger(attrs.assetId);
const align = clampImageAlign(attrs.align);
const encodedCaption = escapeHtmlAttribute(caption);
const widthAttribute = width > 0 ? ` width="${Math.round(width)}"` : "";
const heightAttribute = height > 0 ? ` height="${Math.round(height)}"` : "";
const ratioAttribute = width === 0 && height === 0 && ratio !== 1 ? ` data-ratio="${ratio.toFixed(2)}"` : "";
const assetIdAttribute = assetId > 0 ? ` data-asset-id="${assetId}"` : "";
return (
`<p class="article-editor-image article-editor-image--${align}" align="${align}">` +
@@ -147,6 +160,7 @@ function serializeImageHtml(attrs: Record<string, unknown>): string {
widthAttribute +
heightAttribute +
ratioAttribute +
assetIdAttribute +
" />" +
"</p>"
);
@@ -155,8 +169,9 @@ function serializeImageHtml(attrs: Record<string, unknown>): string {
function needsHtmlSerialization(node: { attrs: Record<string, unknown> }): boolean {
const width = parsePositiveNumber(node.attrs.width);
const height = parsePositiveNumber(node.attrs.height);
const assetId = parsePositiveInteger(node.attrs.assetId);
const align = clampImageAlign(node.attrs.align);
return width > 0 || height > 0 || align !== "center";
return width > 0 || height > 0 || align !== "center" || assetId > 0;
}
function toRichImageBlockNode(parsed: ParsedImageMarkup): RichImageBlockNode {
@@ -165,6 +180,7 @@ function toRichImageBlockNode(parsed: ParsedImageMarkup): RichImageBlockNode {
url: parsed.src,
title: parsed.caption,
alt: parsed.ratio === 1 ? "" : parsed.ratio.toFixed(2),
assetId: parsed.assetId,
width: parsed.width,
height: parsed.height,
ratio: parsed.ratio,
@@ -181,6 +197,7 @@ function transformRichImageNode(node: Record<string, unknown>): Record<string, u
url: String(firstChild.url ?? ""),
title: String(firstChild.title ?? ""),
alt: String(firstChild.alt ?? ""),
assetId: 0,
width: 0,
height: 0,
ratio: parseRatio(firstChild.alt),
@@ -226,6 +243,7 @@ export const richImageBlockSchema = $nodeSchema("image-block", () => ({
attrs: {
src: { default: "", validate: "string" },
caption: { default: "", validate: "string" },
assetId: { default: 0, validate: "number" },
ratio: { default: 1, validate: "number" },
width: { default: 0, validate: "number" },
height: { default: 0, validate: "number" },
@@ -242,6 +260,7 @@ export const richImageBlockSchema = $nodeSchema("image-block", () => ({
return {
src: dom.getAttribute("src") || "",
caption: dom.getAttribute("title") || dom.getAttribute("alt") || "",
assetId: parsePositiveInteger(dom.getAttribute("data-asset-id")),
ratio: parseRatio(dom.getAttribute("data-ratio") || dom.getAttribute("ratio")),
width: parsePositiveNumber(dom.getAttribute("width")),
height: parsePositiveNumber(dom.getAttribute("height")),
@@ -258,6 +277,7 @@ export const richImageBlockSchema = $nodeSchema("image-block", () => ({
alt: node.attrs.caption,
title: node.attrs.caption,
align: node.attrs.align,
"data-asset-id": parsePositiveInteger(node.attrs.assetId) || undefined,
width: node.attrs.width || undefined,
height: node.attrs.height || undefined,
"data-ratio": node.attrs.ratio,
@@ -269,6 +289,7 @@ export const richImageBlockSchema = $nodeSchema("image-block", () => ({
state.addNode(type, {
src: String((node as RichImageBlockNode).url ?? ""),
caption: String((node as RichImageBlockNode).title ?? ""),
assetId: parsePositiveInteger((node as RichImageBlockNode).assetId),
ratio: parseRatio((node as RichImageBlockNode).ratio || (node as RichImageBlockNode).alt),
width: parsePositiveNumber((node as RichImageBlockNode).width),
height: parsePositiveNumber((node as RichImageBlockNode).height),