-
-
-
{activeTab === "上传视频" ? "拖拽视频到此或点击上传" : activeTab === "上传图文" ? "拖拽图片到此或点击上传" : activeTab === "写长文" ? "点击开始创作长文" : "拖拽音频到此或点击上传"}
-
+
+ input.current?.click()} />
+
+
+
+
+
+
+
+
+
{["项目", "最近", "收藏"].map((tab) => )}
+
+
+
-
-
-
{activeTab === "上传视频" ? "视频大小" : "文件大小"}{activeTab === "上传视频" ? <>支持时长4小时以内,
最大20GB的视频文件> : <>单个文件不超过20GB,
支持批量选择上传>}
-
{activeTab === "上传视频" ? "视频格式" : "支持格式"}{activeTab === "上传视频" ? <>支持常用视频格式,
推荐使用mp4、mov> : <>支持平台常用格式,
上传后可继续编辑>}
-
{activeTab === "上传视频" ? "视频分辨率" : "内容质量"}{activeTab === "上传视频" ? <>超过1080P的视频用网页端上传画质更清晰> : <>建议上传清晰、完整的原创内容>}
-
+
+
+ {visibleProjects.length ?
:
开始第一个图文项目
上传 1–20 张图片,进入专业画布逐页编辑
}
+
+
+
void createProject(event.target.files)} />
+ {uploading &&
{message}请不要关闭页面
}
+ {!uploading && message &&
{message}
}
;
}
+
+function SideItem({ icon, label, active }: { icon: IconName; label: string; active?: boolean }) {
+ return
;
+}
+
+function ToolCard({ icon, title, subtitle, onClick }: { icon: IconName; title: string; subtitle: string; onClick?: () => void }) {
+ return
;
+}
diff --git a/lib/config.ts b/lib/config.ts
index 4127390..d152af3 100644
--- a/lib/config.ts
+++ b/lib/config.ts
@@ -2,6 +2,7 @@ import path from "node:path";
export const APP_ROOT = process.cwd();
export const PROJECTS_ROOT = path.join(APP_ROOT, "data", "projects");
+export const IMAGE_PROJECTS_ROOT = path.join(APP_ROOT, "data", "image-projects");
export const TEMPLATES_ROOT = path.join(APP_ROOT, "templates");
export const SKILL_ROOT = path.join(APP_ROOT, "vendor", "minitool-zip-builder");
export const SKILL_VERSION = "1.6.0";
diff --git a/lib/image-projects.ts b/lib/image-projects.ts
new file mode 100644
index 0000000..4c2d6fc
--- /dev/null
+++ b/lib/image-projects.ts
@@ -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
= { "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 : {};
+ 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 {
+ 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 {
+ 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 : {};
+ 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 };
+}