feat: add image project dashboard and canvas editor
Verify and deploy production / verify-and-deploy (push) Successful in 2m34s

This commit is contained in:
2026-10-05 15:20:43 +08:00
parent e961e3091b
commit 276a8ae473
12 changed files with 675 additions and 203 deletions
+177
View File
@@ -0,0 +1,177 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { IMAGE_PROJECTS_ROOT } from "./config";
export type ImageSettings = {
fit: "contain" | "cover";
scale: number;
offsetX: number;
offsetY: number;
rotation: number;
brightness: number;
contrast: number;
saturation: number;
borderRadius: number;
};
export type ImageSlide = {
id: string;
fileName: string;
originalName: string;
mimeType: string;
settings: ImageSettings;
};
export type ImageProject = {
id: string;
name: string;
canvas: { width: number; height: number; background: string };
slides: ImageSlide[];
createdAt: string;
updatedAt: string;
};
const PROJECT_ID = /^[0-9a-f-]{36}$/;
const FILE_ID = /^[0-9a-f-]{36}\.(?:png|jpe?g|webp)$/;
const MIME_EXTENSIONS: Record<string, string> = { "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp" };
const DEFAULT_SETTINGS: ImageSettings = { fit: "cover", scale: 100, offsetX: 0, offsetY: 0, rotation: 0, brightness: 100, contrast: 100, saturation: 100, borderRadius: 0 };
function projectRoot(id: string) {
if (!PROJECT_ID.test(id)) throw new Error("Invalid image project id");
return path.join(IMAGE_PROJECTS_ROOT, id);
}
function projectFile(id: string) { return path.join(projectRoot(id), "project.json"); }
function clamp(value: unknown, min: number, max: number, fallback: number) {
const number = Number(value);
return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback;
}
function normalizeSettings(value: unknown, previous = DEFAULT_SETTINGS): ImageSettings {
const input = value && typeof value === "object" ? value as Partial<ImageSettings> : {};
return {
fit: input.fit === "contain" ? "contain" : input.fit === "cover" ? "cover" : previous.fit,
scale: clamp(input.scale, 20, 300, previous.scale),
offsetX: clamp(input.offsetX, -100, 100, previous.offsetX),
offsetY: clamp(input.offsetY, -100, 100, previous.offsetY),
rotation: clamp(input.rotation, -180, 180, previous.rotation),
brightness: clamp(input.brightness, 0, 200, previous.brightness),
contrast: clamp(input.contrast, 0, 200, previous.contrast),
saturation: clamp(input.saturation, 0, 200, previous.saturation),
borderRadius: clamp(input.borderRadius, 0, 200, previous.borderRadius),
};
}
function cleanName(value: unknown, fallback: string) {
return typeof value === "string" && value.trim() ? value.trim().slice(0, 80) : fallback;
}
async function readProject(id: string): Promise<ImageProject> {
return JSON.parse(await fs.readFile(projectFile(id), "utf8"));
}
async function writeProject(project: ImageProject) {
await fs.writeFile(projectFile(project.id), JSON.stringify(project, null, 2), "utf8");
}
function validateFiles(files: File[]) {
if (files.length < 1 || files.length > 20) throw new Error("请选择 1 到 20 张图片");
let total = 0;
for (const file of files) {
if (!MIME_EXTENSIONS[file.type]) throw new Error(`不支持的图片格式:${file.name}`);
if (file.size > 25 * 1024 * 1024) throw new Error(`单张图片不能超过 25MB:${file.name}`);
total += file.size;
}
if (total > 150 * 1024 * 1024) throw new Error("图片总大小不能超过 150MB");
}
async function storeFiles(root: string, files: File[]): Promise<ImageSlide[]> {
const imagesRoot = path.join(root, "images");
await fs.mkdir(imagesRoot, { recursive: true });
const slides: ImageSlide[] = [];
for (const file of files) {
const id = crypto.randomUUID();
const fileName = `${id}.${MIME_EXTENSIONS[file.type]}`;
await fs.writeFile(path.join(imagesRoot, fileName), Buffer.from(await file.arrayBuffer()));
slides.push({ id, fileName, originalName: cleanName(file.name, "图片"), mimeType: file.type, settings: { ...DEFAULT_SETTINGS } });
}
return slides;
}
export async function listImageProjects() {
await fs.mkdir(IMAGE_PROJECTS_ROOT, { recursive: true });
const entries = await fs.readdir(IMAGE_PROJECTS_ROOT, { withFileTypes: true });
const projects = await Promise.all(entries.filter((entry) => entry.isDirectory()).map(async (entry) => {
try { return await readProject(entry.name); } catch { return null; }
}));
return projects.filter((project): project is ImageProject => Boolean(project)).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
export async function createImageProject(files: File[], requestedName?: unknown) {
validateFiles(files);
await fs.mkdir(IMAGE_PROJECTS_ROOT, { recursive: true });
const id = crypto.randomUUID();
const root = projectRoot(id);
await fs.mkdir(root, { recursive: true });
try {
const slides = await storeFiles(root, files);
const now = new Date().toISOString();
const baseName = path.parse(files[0].name).name || "图文项目";
const project: ImageProject = {
id,
name: cleanName(requestedName, files.length > 1 ? `${baseName} 等 ${files.length} 张` : baseName),
canvas: { width: 1080, height: 1440, background: "#ffffff" },
slides,
createdAt: now,
updatedAt: now,
};
await writeProject(project);
return project;
} catch (error) {
await fs.rm(root, { recursive: true, force: true });
throw error;
}
}
export async function getImageProject(id: string) { return readProject(id); }
export async function addImagesToProject(id: string, files: File[]) {
validateFiles(files);
const project = await readProject(id);
if (project.slides.length + files.length > 20) throw new Error("每个项目最多包含 20 张图片");
const slides = await storeFiles(projectRoot(id), files);
project.slides.push(...slides);
project.updatedAt = new Date().toISOString();
await writeProject(project);
return project;
}
export async function updateImageProject(id: string, input: unknown) {
const project = await readProject(id);
const value = input && typeof input === "object" ? input as Partial<ImageProject> : {};
project.name = cleanName(value.name, project.name);
if (value.canvas && typeof value.canvas === "object") {
project.canvas = {
width: clamp(value.canvas.width, 320, 4096, project.canvas.width),
height: clamp(value.canvas.height, 320, 4096, project.canvas.height),
background: typeof value.canvas.background === "string" && /^#[0-9a-f]{6}$/i.test(value.canvas.background) ? value.canvas.background : project.canvas.background,
};
}
if (Array.isArray(value.slides)) {
const settingsById = new Map(value.slides.map((slide) => [slide.id, slide.settings]));
project.slides = project.slides.map((slide) => ({ ...slide, settings: normalizeSettings(settingsById.get(slide.id), slide.settings) }));
}
project.updatedAt = new Date().toISOString();
await writeProject(project);
return project;
}
export async function readImageProjectFile(id: string, fileName: string) {
if (!FILE_ID.test(fileName)) throw new Error("Invalid image file");
const project = await readProject(id);
const slide = project.slides.find((item) => item.fileName === fileName);
if (!slide) throw new Error("Image not found");
return { data: await fs.readFile(path.join(projectRoot(id), "images", fileName)), mimeType: slide.mimeType };
}