feat: add image project dashboard and canvas editor
Verify and deploy production / verify-and-deploy (push) Successful in 2m34s
Verify and deploy production / verify-and-deploy (push) Successful in 2m34s
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user