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
@@ -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),